diff --git a/index.ts b/index.ts index 23dff3cf..0b491c2e 100644 --- a/index.ts +++ b/index.ts @@ -4156,9 +4156,299 @@ while (attempted.size < Math.max(1, accountCount)) { })}`, ); - return lines.join("\n"); - }, - }), + return lines.join("\n"); + }, + }), + "codex-limits": tool({ + description: "Show live 5-hour and weekly Codex usage limits for all accounts.", + args: {}, + async execute() { + const ui = resolveUiRuntime(); + const storage = await loadAccounts(); + if (!storage || storage.accounts.length === 0) { + if (ui.v2Enabled) { + return [ + ...formatUiHeader(ui, "Codex limits"), + "", + formatUiItem(ui, "No accounts configured.", "warning"), + formatUiItem(ui, "Run: opencode auth login", "accent"), + ].join("\n"); + } + return "No Codex accounts configured. Run: opencode auth login"; + } + + type UsageWindow = { + used_percent?: number; + limit_window_seconds?: number; + reset_at?: number; + reset_after_seconds?: number; + } | null; + + type LimitWindow = { + usedPercent?: number; + windowMinutes?: number; + resetAtMs?: number; + }; + + type UsageRateLimit = { + primary_window?: UsageWindow; + secondary_window?: UsageWindow; + } | null; + + type UsageCredits = { + has_credits?: boolean; + unlimited?: boolean; + balance?: string | null; + } | null; + + type UsagePayload = { + plan_type?: string; + rate_limit?: UsageRateLimit; + code_review_rate_limit?: UsageRateLimit; + additional_rate_limits?: Array<{ + limit_name?: string; + metered_feature?: string; + rate_limit?: UsageRateLimit; + }> | null; + credits?: UsageCredits; + }; + + const formatWindowLabel = (windowMinutes: number | undefined): string => { + if (!windowMinutes || !Number.isFinite(windowMinutes) || windowMinutes <= 0) { + return "quota"; + } + if (windowMinutes % 1440 === 0) return `${windowMinutes / 1440}d`; + if (windowMinutes % 60 === 0) return `${windowMinutes / 60}h`; + return `${windowMinutes}m`; + }; + + const formatReset = (resetAtMs: number | undefined): string | undefined => { + if (!resetAtMs || !Number.isFinite(resetAtMs) || resetAtMs <= 0) return undefined; + const date = new Date(resetAtMs); + if (!Number.isFinite(date.getTime())) return undefined; + + const now = new Date(); + const sameDay = + now.getFullYear() === date.getFullYear() && + now.getMonth() === date.getMonth() && + now.getDate() === date.getDate(); + const time = date.toLocaleTimeString(undefined, { + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); + if (sameDay) return time; + const day = date.toLocaleDateString(undefined, { month: "short", day: "2-digit" }); + return `${time} on ${day}`; + }; + + const mapWindow = (window: UsageWindow): LimitWindow => { + if (!window) return {}; + return { + usedPercent: + typeof window.used_percent === "number" && Number.isFinite(window.used_percent) + ? window.used_percent + : undefined, + windowMinutes: + typeof window.limit_window_seconds === "number" && Number.isFinite(window.limit_window_seconds) + ? Math.max(1, Math.ceil(window.limit_window_seconds / 60)) + : undefined, + resetAtMs: + typeof window.reset_at === "number" && window.reset_at > 0 + ? window.reset_at * 1000 + : typeof window.reset_after_seconds === "number" && window.reset_after_seconds > 0 + ? Date.now() + window.reset_after_seconds * 1000 + : undefined, + }; + }; + + const formatLimitTitle = (windowMinutes: number | undefined, fallback = "quota"): string => { + if (windowMinutes === 300) return "5h limit"; + if (windowMinutes === 10080) return "Weekly limit"; + if (fallback !== "quota") return fallback; + return `${formatWindowLabel(windowMinutes)} limit`; + }; + + const formatLimitSummary = (window: LimitWindow): string => { + const used = window.usedPercent; + const left = + typeof used === "number" && Number.isFinite(used) + ? Math.max(0, Math.min(100, Math.round(100 - used))) + : undefined; + const reset = formatReset(window.resetAtMs); + if (left !== undefined && reset) return `${left}% left (resets ${reset})`; + if (left !== undefined) return `${left}% left`; + if (reset) return `resets ${reset}`; + return "unavailable"; + }; + + const formatCredits = (credits: UsageCredits): string | undefined => { + if (!credits) return undefined; + if (credits.unlimited) return "unlimited"; + if (typeof credits.balance === "string" && credits.balance.trim()) { + return credits.balance.trim(); + } + if (credits.has_credits) return "available"; + return undefined; + }; + + const formatExtraName = (name: string | undefined): string => { + if (!name) return "Additional limit"; + if (name === "code_review_rate_limit") return "Code review"; + return name.replace(/[_-]+/g, " ").replace(/\b\w/g, (match) => match.toUpperCase()); + }; + + const fetchUsage = async (params: { + accountId: string; + accessToken: string; + organizationId: string | undefined; + }): Promise => { + const headers = createCodexHeaders(undefined, params.accountId, params.accessToken, { + organizationId: params.organizationId, + }); + headers.set("accept", "application/json"); + + const response = await fetch(`${CODEX_BASE_URL}/wham/usage`, { + method: "GET", + headers, + }); + if (!response.ok) { + const bodyText = await response.text().catch(() => ""); + throw new Error(bodyText || `HTTP ${response.status}`); + } + return (await response.json()) as UsagePayload; + }; + + // Deduplicate accounts by refreshToken (same credential = same limits) + const seenTokens = new Set(); + const uniqueIndices: number[] = []; + for (let i = 0; i < storage.accounts.length; i++) { + const acct = storage.accounts[i]; + if (!acct) continue; + if (seenTokens.has(acct.refreshToken)) continue; + seenTokens.add(acct.refreshToken); + uniqueIndices.push(i); + } + + const lines: string[] = ui.v2Enabled + ? [...formatUiHeader(ui, "Codex limits"), ""] + : [`Codex limits (${uniqueIndices.length} account${uniqueIndices.length === 1 ? "" : "s"}):`, ""]; + const activeIndex = resolveActiveIndex(storage, "codex"); + let storageChanged = false; + + for (const i of uniqueIndices) { + const account = storage.accounts[i]; + if (!account) continue; + const label = formatCommandAccountLabel(account, i); + const activeSuffix = i === activeIndex ? (ui.v2Enabled ? ` ${formatUiBadge(ui, "active", "accent")}` : " [active]") : ""; + + try { + let accessToken = account.accessToken; + if ( + typeof accessToken !== "string" || + !accessToken || + typeof account.expiresAt !== "number" || + account.expiresAt <= Date.now() + 30_000 + ) { + const refreshResult = await queuedRefresh(account.refreshToken); + if (refreshResult.type !== "success") { + throw new Error(refreshResult.message ?? refreshResult.reason); + } + account.refreshToken = refreshResult.refresh; + account.accessToken = refreshResult.access; + account.expiresAt = refreshResult.expires; + accessToken = refreshResult.access; + storageChanged = true; + } + + const accountId = account.accountId ?? extractAccountId(accessToken); + if (!accountId) { + throw new Error("Missing account id"); + } + + const payload = await fetchUsage({ + accountId, + accessToken, + organizationId: account.organizationId, + }); + + const primary = mapWindow(payload.rate_limit?.primary_window ?? null); + const secondary = mapWindow(payload.rate_limit?.secondary_window ?? null); + const codeReviewRateLimit = + payload.code_review_rate_limit ?? + payload.additional_rate_limits?.find((entry) => entry.limit_name === "code_review_rate_limit")?.rate_limit ?? + null; + const codeReview = mapWindow(codeReviewRateLimit?.primary_window ?? null); + const credits = formatCredits(payload.credits ?? null); + const additionalLimits = (payload.additional_rate_limits ?? []).filter( + (entry) => entry.limit_name !== "code_review_rate_limit", + ); + + if (ui.v2Enabled) { + lines.push(formatUiItem(ui, `${label}${activeSuffix}`)); + lines.push(` ${formatUiKeyValue(ui, formatLimitTitle(primary.windowMinutes), formatLimitSummary(primary), "muted")}`); + lines.push(` ${formatUiKeyValue(ui, formatLimitTitle(secondary.windowMinutes), formatLimitSummary(secondary), "muted")}`); + if (codeReview.windowMinutes || typeof codeReview.usedPercent === "number" || codeReview.resetAtMs) { + lines.push(` ${formatUiKeyValue(ui, "Code review", formatLimitSummary(codeReview), "muted")}`); + } + for (const limit of additionalLimits) { + const extraWindow = mapWindow(limit.rate_limit?.primary_window ?? null); + lines.push(` ${formatUiKeyValue(ui, formatExtraName(limit.limit_name ?? limit.metered_feature), formatLimitSummary(extraWindow), "muted")}`); + } + if (payload.plan_type) { + lines.push(` ${formatUiKeyValue(ui, "Plan", payload.plan_type, "muted")}`); + } + if (credits) { + lines.push(` ${formatUiKeyValue(ui, "Credits", credits, "muted")}`); + } + } else { + lines.push(`${label}${activeSuffix}:`); + lines.push(` ${formatLimitTitle(primary.windowMinutes)}: ${formatLimitSummary(primary)}`); + lines.push(` ${formatLimitTitle(secondary.windowMinutes)}: ${formatLimitSummary(secondary)}`); + if (codeReview.windowMinutes || typeof codeReview.usedPercent === "number" || codeReview.resetAtMs) { + lines.push(` Code review: ${formatLimitSummary(codeReview)}`); + } + for (const limit of additionalLimits) { + const extraWindow = mapWindow(limit.rate_limit?.primary_window ?? null); + lines.push(` ${formatExtraName(limit.limit_name ?? limit.metered_feature)}: ${formatLimitSummary(extraWindow)}`); + } + if (payload.plan_type) { + lines.push(` Plan: ${payload.plan_type}`); + } + if (credits) { + lines.push(` Credits: ${credits}`); + } + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (ui.v2Enabled) { + lines.push(formatUiItem(ui, `${label}${activeSuffix}`)); + lines.push(` ${formatUiKeyValue(ui, "Error", message.slice(0, 160), "danger")}`); + } else { + lines.push(`${label}${activeSuffix}:`); + lines.push(` Error: ${message.slice(0, 160)}`); + } + } + + lines.push(""); + } + + if (storageChanged) { + await saveAccounts(storage); + if (cachedAccountManager) { + const reloadedManager = await AccountManager.loadFromDisk(); + cachedAccountManager = reloadedManager; + accountManagerPromise = Promise.resolve(reloadedManager); + } + } + + while (lines.length > 0 && lines[lines.length - 1] === "") { + lines.pop(); + } + + return lines.join("\n"); + }, + }), "codex-metrics": tool({ description: "Show runtime request metrics for this plugin process.", args: {}, diff --git a/test/index.test.ts b/test/index.test.ts index 40501343..42d2c7d6 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -394,6 +394,7 @@ type PluginType = { "codex-list": OptionalToolExecute<{ tag?: string }>; "codex-switch": OptionalToolExecute<{ index?: number }>; "codex-status": ToolExecute; + "codex-limits": ToolExecute; "codex-metrics": ToolExecute; "codex-help": ToolExecute<{ topic?: string }>; "codex-setup": OptionalToolExecute<{ wizard?: boolean }>; @@ -453,6 +454,7 @@ describe("OpenAIOAuthPlugin", () => { expect(plugin.tool["codex-list"]).toBeDefined(); expect(plugin.tool["codex-switch"]).toBeDefined(); expect(plugin.tool["codex-status"]).toBeDefined(); + expect(plugin.tool["codex-limits"]).toBeDefined(); expect(plugin.tool["codex-metrics"]).toBeDefined(); expect(plugin.tool["codex-help"]).toBeDefined(); expect(plugin.tool["codex-setup"]).toBeDefined(); @@ -690,6 +692,146 @@ describe("OpenAIOAuthPlugin", () => { }); }); + describe("codex-limits tool", () => { + let originalFetch: typeof globalThis.fetch; + + beforeEach(() => { + originalFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("returns error when no accounts", async () => { + mockStorage.accounts = []; + const result = await plugin.tool["codex-limits"].execute(); + expect(result).toContain("No Codex accounts configured"); + }); + + it("shows live usage windows from wham usage", async () => { + mockStorage.accounts = [ + { + refreshToken: "r1", + accountId: "acc-1", + email: "user@example.com", + accessToken: "access-1", + expiresAt: Date.now() + 3600_000, + }, + ]; + globalThis.fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + plan_type: "team", + rate_limit: { + primary_window: { + used_percent: 13, + limit_window_seconds: 18000, + reset_at: Math.floor(Date.now() / 1000) + 3600, + }, + secondary_window: { + used_percent: 36, + limit_window_seconds: 604800, + reset_at: Math.floor(Date.now() / 1000) + 86400, + }, + }, + code_review_rate_limit: { + primary_window: { + used_percent: 0, + limit_window_seconds: 604800, + reset_at: Math.floor(Date.now() / 1000) + 7200, + }, + }, + credits: { unlimited: true, has_credits: true }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + const result = await plugin.tool["codex-limits"].execute(); + + expect(result).toContain("Codex limits"); + expect(result).toContain("5h limit: 87% left"); + expect(result).toContain("Weekly limit: 64% left"); + expect(result).toContain("Code review: 100% left"); + expect(result).toContain("Plan: team"); + expect(result).toContain("Credits: unlimited"); + expect(globalThis.fetch).toHaveBeenCalledWith( + "https://chatgpt.com/backend-api/wham/usage", + expect.objectContaining({ method: "GET" }), + ); + }); + + it("refreshes missing tokens before fetching usage", async () => { + const { saveAccounts } = await import("../lib/storage.js"); + mockStorage.accounts = [ + { refreshToken: "r1", accountId: "acc-1", email: "user@example.com" }, + ]; + globalThis.fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + rate_limit: { + primary_window: { used_percent: 0, limit_window_seconds: 18000, reset_at: Math.floor(Date.now() / 1000) + 1800 }, + secondary_window: { used_percent: 0, limit_window_seconds: 604800, reset_at: Math.floor(Date.now() / 1000) + 3600 }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + const result = await plugin.tool["codex-limits"].execute(); + + expect(result).toContain("100% left"); + expect(mockStorage.accounts[0]?.accessToken).toBe("refreshed-access"); + expect(saveAccounts).toHaveBeenCalled(); + }); + + it("deduplicates accounts with same refreshToken", async () => { + mockStorage.accounts = [ + { + refreshToken: "rt_same", + accountId: "acc-1", + email: "a@test.com", + accessToken: "access-1", + expiresAt: Date.now() + 3600_000, + }, + { + refreshToken: "rt_same", + accountId: "acc-2", + email: "a@test.com", + accessToken: "access-2", + expiresAt: Date.now() + 3600_000, + }, + { + refreshToken: "rt_other", + accountId: "acc-3", + email: "b@test.com", + accessToken: "access-3", + expiresAt: Date.now() + 3600_000, + }, + ]; + globalThis.fetch = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + rate_limit: { + primary_window: { used_percent: 50, limit_window_seconds: 18000, reset_at: Math.floor(Date.now() / 1000) + 1800 }, + secondary_window: { used_percent: 50, limit_window_seconds: 604800, reset_at: Math.floor(Date.now() / 1000) + 86400 }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + const result = await plugin.tool["codex-limits"].execute(); + + expect(result).toBeDefined(); + // Header should say "2 accounts" (not 3) + expect(result).toContain("2 account"); + // fetch should be called exactly twice (once per unique refreshToken) + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + }); + }); + describe("codex-metrics tool", () => { it("shows runtime metrics", async () => { const result = await plugin.tool["codex-metrics"].execute();