Skip to content

Commit 1a1827d

Browse files
feat(openai-codex): add ChatGPT subscription usage limits dashboard (#10813)
1 parent 0f08867 commit 1a1827d

File tree

28 files changed

+1144
-1
lines changed

28 files changed

+1144
-1
lines changed

packages/types/src/providers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export * from "./moonshot.js"
1919
export * from "./ollama.js"
2020
export * from "./openai.js"
2121
export * from "./openai-codex.js"
22+
export * from "./openai-codex-rate-limits.js"
2223
export * from "./openrouter.js"
2324
export * from "./qwen-code.js"
2425
export * from "./requesty.js"
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* OpenAI Codex usage/rate limit information (ChatGPT subscription)
3+
*/
4+
export interface OpenAiCodexRateLimitInfo {
5+
primary?: {
6+
/** Used percent in 0–100 */
7+
usedPercent: number
8+
/** Window length in minutes, when provided */
9+
windowMinutes?: number
10+
/** Reset time (unix ms since epoch), when provided */
11+
resetsAt?: number
12+
}
13+
secondary?: {
14+
/** Used percent in 0–100 */
15+
usedPercent: number
16+
/** Window length in minutes, when provided */
17+
windowMinutes?: number
18+
/** Reset time (unix ms since epoch), when provided */
19+
resetsAt?: number
20+
}
21+
credits?: {
22+
hasCredits: boolean
23+
unlimited: boolean
24+
balance?: string
25+
}
26+
planType?: string
27+
/** Timestamp when this was fetched (unix ms since epoch) */
28+
fetchedAt: number
29+
}

packages/types/src/vscode-extension-host.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type { SerializedCustomToolDefinition } from "./custom-tool.js"
1919
import type { GitCommit } from "./git.js"
2020
import type { McpServer } from "./mcp.js"
2121
import type { ModelRecord, RouterModels } from "./model.js"
22+
import type { OpenAiCodexRateLimitInfo } from "./providers/openai-codex-rate-limits.js"
2223

2324
/**
2425
* ExtensionMessage
@@ -95,6 +96,7 @@ export interface ExtensionMessage {
9596
| "customToolsResult"
9697
| "modes"
9798
| "taskWithAggregatedCosts"
99+
| "openAiCodexRateLimits"
98100
text?: string
99101
payload?: any // eslint-disable-line @typescript-eslint/no-explicit-any
100102
checkpointWarning?: {
@@ -150,7 +152,9 @@ export interface ExtensionMessage {
150152
customMode?: ModeConfig
151153
slug?: string
152154
success?: boolean
153-
values?: Record<string, any> // eslint-disable-line @typescript-eslint/no-explicit-any
155+
/** Generic payload for extension messages that use `values` */
156+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
157+
values?: Record<string, any>
154158
requestId?: string
155159
promptText?: string
156160
results?:
@@ -192,6 +196,12 @@ export interface ExtensionMessage {
192196
historyItem?: HistoryItem
193197
}
194198

199+
export interface OpenAiCodexRateLimitsMessage {
200+
type: "openAiCodexRateLimits"
201+
values?: OpenAiCodexRateLimitInfo
202+
error?: string
203+
}
204+
195205
export type ExtensionState = Pick<
196206
GlobalSettings,
197207
| "currentApiConfigName"
@@ -518,6 +528,7 @@ export interface WebviewMessage {
518528
| "openDebugUiHistory"
519529
| "downloadErrorDiagnostics"
520530
| "requestClaudeCodeRateLimits"
531+
| "requestOpenAiCodexRateLimits"
521532
| "refreshCustomTools"
522533
| "requestModes"
523534
| "switchMode"
@@ -546,6 +557,7 @@ export interface WebviewMessage {
546557
promptMode?: string | "enhance"
547558
customPrompt?: PromptComponent
548559
dataUrls?: string[]
560+
/** Generic payload for webview messages that use `values` */
549561
// eslint-disable-next-line @typescript-eslint/no-explicit-any
550562
values?: Record<string, any>
551563
query?: string
@@ -612,6 +624,10 @@ export interface WebviewMessage {
612624
updatedSettings?: RooCodeSettings
613625
}
614626

627+
export interface RequestOpenAiCodexRateLimitsMessage {
628+
type: "requestOpenAiCodexRateLimits"
629+
}
630+
615631
export const checkoutDiffPayloadSchema = z.object({
616632
ts: z.number().optional(),
617633
previousCommitHash: z.string().optional(),

src/core/webview/__tests__/webviewMessageHandler.spec.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ import type { Mock } from "vitest"
55
// Mock dependencies - must come before imports
66
vi.mock("../../../api/providers/fetchers/modelCache")
77

8+
vi.mock("../../../integrations/openai-codex/oauth", () => ({
9+
openAiCodexOAuthManager: {
10+
getAccessToken: vi.fn(),
11+
getAccountId: vi.fn(),
12+
},
13+
}))
14+
15+
vi.mock("../../../integrations/openai-codex/rate-limits", () => ({
16+
fetchOpenAiCodexRateLimitInfo: vi.fn(),
17+
}))
18+
819
// Mock the diagnosticsHandler module
920
vi.mock("../diagnosticsHandler", () => ({
1021
generateErrorDiagnostics: vi.fn().mockResolvedValue({ success: true, filePath: "/tmp/diagnostics.json" }),
@@ -15,8 +26,13 @@ import type { ModelRecord } from "@roo-code/types"
1526
import { webviewMessageHandler } from "../webviewMessageHandler"
1627
import type { ClineProvider } from "../ClineProvider"
1728
import { getModels } from "../../../api/providers/fetchers/modelCache"
29+
const { openAiCodexOAuthManager } = await import("../../../integrations/openai-codex/oauth")
30+
const { fetchOpenAiCodexRateLimitInfo } = await import("../../../integrations/openai-codex/rate-limits")
1831

1932
const mockGetModels = getModels as Mock<typeof getModels>
33+
const mockGetAccessToken = vi.mocked(openAiCodexOAuthManager.getAccessToken)
34+
const mockGetAccountId = vi.mocked(openAiCodexOAuthManager.getAccountId)
35+
const mockFetchOpenAiCodexRateLimitInfo = vi.mocked(fetchOpenAiCodexRateLimitInfo)
2036

2137
// Mock ClineProvider
2238
const mockClineProvider = {
@@ -580,6 +596,43 @@ describe("webviewMessageHandler - requestRouterModels", () => {
580596
})
581597
})
582598

599+
describe("webviewMessageHandler - requestOpenAiCodexRateLimits", () => {
600+
beforeEach(() => {
601+
vi.clearAllMocks()
602+
mockGetAccessToken.mockResolvedValue(null)
603+
mockGetAccountId.mockResolvedValue(null)
604+
})
605+
606+
it("posts error when not authenticated", async () => {
607+
await webviewMessageHandler(mockClineProvider, { type: "requestOpenAiCodexRateLimits" } as any)
608+
609+
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
610+
type: "openAiCodexRateLimits",
611+
error: "Not authenticated with OpenAI Codex",
612+
})
613+
})
614+
615+
it("posts values when authenticated", async () => {
616+
mockGetAccessToken.mockResolvedValue("token")
617+
mockGetAccountId.mockResolvedValue("acct_123")
618+
mockFetchOpenAiCodexRateLimitInfo.mockResolvedValue({
619+
primary: { usedPercent: 10, resetsAt: 1700000000000 },
620+
fetchedAt: 1700000000000,
621+
})
622+
623+
await webviewMessageHandler(mockClineProvider, { type: "requestOpenAiCodexRateLimits" } as any)
624+
625+
expect(mockFetchOpenAiCodexRateLimitInfo).toHaveBeenCalledWith("token", { accountId: "acct_123" })
626+
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith({
627+
type: "openAiCodexRateLimits",
628+
values: {
629+
primary: { usedPercent: 10, resetsAt: 1700000000000 },
630+
fetchedAt: 1700000000000,
631+
},
632+
})
633+
})
634+
})
635+
583636
describe("webviewMessageHandler - deleteCustomMode", () => {
584637
beforeEach(() => {
585638
vi.clearAllMocks()

src/core/webview/webviewMessageHandler.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3285,6 +3285,38 @@ export const webviewMessageHandler = async (
32853285
break
32863286
}
32873287

3288+
case "requestOpenAiCodexRateLimits": {
3289+
try {
3290+
const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth")
3291+
const accessToken = await openAiCodexOAuthManager.getAccessToken()
3292+
3293+
if (!accessToken) {
3294+
provider.postMessageToWebview({
3295+
type: "openAiCodexRateLimits",
3296+
error: "Not authenticated with OpenAI Codex",
3297+
})
3298+
break
3299+
}
3300+
3301+
const accountId = await openAiCodexOAuthManager.getAccountId()
3302+
const { fetchOpenAiCodexRateLimitInfo } = await import("../../integrations/openai-codex/rate-limits")
3303+
const rateLimits = await fetchOpenAiCodexRateLimitInfo(accessToken, { accountId })
3304+
3305+
provider.postMessageToWebview({
3306+
type: "openAiCodexRateLimits",
3307+
values: rateLimits,
3308+
})
3309+
} catch (error) {
3310+
const errorMessage = error instanceof Error ? error.message : String(error)
3311+
provider.log(`Error fetching OpenAI Codex rate limits: ${errorMessage}`)
3312+
provider.postMessageToWebview({
3313+
type: "openAiCodexRateLimits",
3314+
error: errorMessage,
3315+
})
3316+
}
3317+
break
3318+
}
3319+
32883320
case "openDebugApiHistory":
32893321
case "openDebugUiHistory": {
32903322
const currentTask = provider.getCurrentTask()
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { describe, it, expect } from "vitest"
2+
3+
import { parseOpenAiCodexUsagePayload } from "../rate-limits"
4+
5+
describe("parseOpenAiCodexUsagePayload()", () => {
6+
it("maps primary/secondary windows", () => {
7+
const fetchedAt = 1234567890000
8+
const payload = {
9+
rate_limit: {
10+
primary_window: { used_percent: 12.34, limit_window_seconds: 300 * 60, reset_at: 1700000000 },
11+
secondary_window: { used_percent: 99.9, limit_window_seconds: 10080 * 60, reset_at: 1700000000 },
12+
},
13+
plan_type: "plus",
14+
}
15+
16+
const out = parseOpenAiCodexUsagePayload(payload, fetchedAt)
17+
18+
expect(out).toEqual({
19+
primary: {
20+
usedPercent: 12.34,
21+
windowMinutes: 300,
22+
resetsAt: 1700000000 * 1000,
23+
},
24+
secondary: {
25+
usedPercent: 99.9,
26+
windowMinutes: 10080,
27+
resetsAt: 1700000000 * 1000,
28+
},
29+
planType: "plus",
30+
fetchedAt,
31+
})
32+
})
33+
34+
it("clamps used_percent to 0–100 and tolerates missing fields", () => {
35+
const fetchedAt = 1
36+
const payload = {
37+
rate_limit: {
38+
primary_window: { used_percent: 1000 },
39+
secondary_window: { used_percent: -5 },
40+
},
41+
}
42+
const out = parseOpenAiCodexUsagePayload(payload, fetchedAt)
43+
expect(out.primary?.usedPercent).toBe(100)
44+
expect(out.secondary?.usedPercent).toBe(0)
45+
expect(out.fetchedAt).toBe(fetchedAt)
46+
})
47+
})
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import type { OpenAiCodexRateLimitInfo } from "@roo-code/types"
2+
3+
const WHAM_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage"
4+
5+
type WhamUsageResponse = {
6+
rate_limit?: {
7+
primary_window?: {
8+
limit_window_seconds?: number
9+
used_percent?: number
10+
reset_at?: number
11+
}
12+
secondary_window?: {
13+
limit_window_seconds?: number
14+
used_percent?: number
15+
reset_at?: number
16+
}
17+
}
18+
plan_type?: string
19+
}
20+
21+
function clampPercent(value: number): number {
22+
if (!Number.isFinite(value)) return 0
23+
return Math.max(0, Math.min(100, value))
24+
}
25+
26+
function secondsToMs(value: number | undefined): number | undefined {
27+
return typeof value === "number" && Number.isFinite(value) ? Math.round(value * 1000) : undefined
28+
}
29+
30+
export function parseOpenAiCodexUsagePayload(payload: unknown, fetchedAt: number): OpenAiCodexRateLimitInfo {
31+
const data = (payload && typeof payload === "object" ? payload : {}) as WhamUsageResponse
32+
const primaryRaw = data.rate_limit?.primary_window
33+
const secondaryRaw = data.rate_limit?.secondary_window
34+
35+
const primary: OpenAiCodexRateLimitInfo["primary"] | undefined =
36+
primaryRaw && typeof primaryRaw.used_percent === "number"
37+
? {
38+
usedPercent: clampPercent(primaryRaw.used_percent),
39+
...(typeof primaryRaw.limit_window_seconds === "number"
40+
? { windowMinutes: Math.round(primaryRaw.limit_window_seconds / 60) }
41+
: {}),
42+
...(secondsToMs(primaryRaw.reset_at) !== undefined
43+
? { resetsAt: secondsToMs(primaryRaw.reset_at) }
44+
: {}),
45+
}
46+
: undefined
47+
48+
const secondary: OpenAiCodexRateLimitInfo["secondary"] | undefined =
49+
secondaryRaw && typeof secondaryRaw.used_percent === "number"
50+
? {
51+
usedPercent: clampPercent(secondaryRaw.used_percent),
52+
...(typeof secondaryRaw.limit_window_seconds === "number"
53+
? { windowMinutes: Math.round(secondaryRaw.limit_window_seconds / 60) }
54+
: {}),
55+
...(secondsToMs(secondaryRaw.reset_at) !== undefined
56+
? { resetsAt: secondsToMs(secondaryRaw.reset_at) }
57+
: {}),
58+
}
59+
: undefined
60+
61+
return {
62+
...(primary ? { primary } : {}),
63+
...(secondary ? { secondary } : {}),
64+
...(typeof data.plan_type === "string" ? { planType: data.plan_type } : {}),
65+
fetchedAt,
66+
}
67+
}
68+
69+
export async function fetchOpenAiCodexRateLimitInfo(
70+
accessToken: string,
71+
options?: { accountId?: string | null },
72+
): Promise<OpenAiCodexRateLimitInfo> {
73+
const fetchedAt = Date.now()
74+
const headers: Record<string, string> = {
75+
Authorization: `Bearer ${accessToken}`,
76+
Accept: "application/json",
77+
}
78+
if (options?.accountId) {
79+
headers["ChatGPT-Account-Id"] = options.accountId
80+
}
81+
82+
const response = await fetch(WHAM_USAGE_URL, { method: "GET", headers })
83+
if (!response.ok) {
84+
const text = await response.text().catch(() => "")
85+
throw new Error(
86+
`OpenAI Codex WHAM usage request failed: ${response.status} ${response.statusText}${text ? ` - ${text}` : ""}`,
87+
)
88+
}
89+
90+
const json = (await response.json()) as unknown
91+
const parsed = parseOpenAiCodexUsagePayload(json, fetchedAt)
92+
if (!parsed.primary && !parsed.secondary) {
93+
throw new Error("OpenAI Codex WHAM usage response did not include rate_limit windows")
94+
}
95+
return parsed
96+
}

webview-ui/src/components/settings/providers/OpenAICodex.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Button } from "@src/components/ui"
77
import { vscode } from "@src/utils/vscode"
88

99
import { ModelPicker } from "../ModelPicker"
10+
import { OpenAICodexRateLimitDashboard } from "./OpenAICodexRateLimitDashboard"
1011

1112
interface OpenAICodexProps {
1213
apiConfiguration: ProviderSettings
@@ -50,6 +51,9 @@ export const OpenAICodex: React.FC<OpenAICodexProps> = ({
5051
)}
5152
</div>
5253

54+
{/* Rate Limit Dashboard - only shown when authenticated */}
55+
<OpenAICodexRateLimitDashboard isAuthenticated={openAiCodexIsAuthenticated} />
56+
5357
{/* Model Picker */}
5458
<ModelPicker
5559
apiConfiguration={apiConfiguration}

0 commit comments

Comments
 (0)