From 2b0ce175145ca6779dcc0dfaa1a8300658b0e6e7 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:07:23 -0700 Subject: [PATCH] Show provider credit balances alongside usage limits (#25408) Display provider credit and currency balances separately from rate limits. Preserve quotas when balance data is unavailable and translate complete labels. Supersedes #9363; credit to @mmarabel for the original implementation. Co-authored-by: mmarabel <166927047+mmarabel@users.noreply.github.com> --- ...roxy-guarded-fetch-call-site-audit.test.ts | 2 +- .../claude-fetcher-extra-usage.test.ts | 302 ++++++++++++++++++ .../rate-limits/claude-oauth-usage-request.ts | 8 + src/main/rate-limits/claude-usage-credits.ts | 127 ++++++++ src/main/rate-limits/claude-usage-result.ts | 4 +- src/main/rate-limits/codex-credit-balance.ts | 29 ++ .../codex-fetcher-credit-balance.test.ts | 137 ++++++++ .../codex-rate-limit-window-classification.ts | 6 + .../rate-limits/codex-rpc-rate-limit-probe.ts | 3 + .../opencode-go-status-parsing.test.ts | 72 +++++ .../rate-limits/opencode-go-status-parsing.ts | 57 +++- .../opencode-go-usage-fetcher.test.ts | 253 ++++++++++++++- .../rate-limits/opencode-go-usage-fetcher.ts | 95 +++++- ...opencode-go-usage-source-selection.test.ts | 3 + .../opencode-go-usage-source-selection.ts | 3 +- .../status-bar/StatusBarProviderSegment.tsx | 46 +++ .../provider-extra-usage-section.tsx | 158 +++++++++ .../provider-segment-balance.test.tsx | 212 ++++++++++++ .../src/components/status-bar/tooltip.test.ts | 276 +++++++++++++++- .../src/components/status-bar/tooltip.tsx | 35 +- src/renderer/src/i18n/locales/en.json | 21 ++ src/renderer/src/i18n/locales/es.json | 1 + src/renderer/src/i18n/locales/fr.json | 1 + src/renderer/src/i18n/locales/ja.json | 1 + src/renderer/src/i18n/locales/ko.json | 1 + src/renderer/src/i18n/locales/zh.json | 1 + .../provider-credit-balance-locales.test.ts | 95 ++++++ src/shared/credit-count-format.test.ts | 14 + src/shared/credit-count-format.ts | 6 + src/shared/currency-format.test.ts | 30 ++ src/shared/currency-format.ts | 21 ++ src/shared/rate-limit-types.ts | 28 ++ 32 files changed, 2013 insertions(+), 35 deletions(-) create mode 100644 src/main/rate-limits/claude-fetcher-extra-usage.test.ts create mode 100644 src/main/rate-limits/claude-usage-credits.ts create mode 100644 src/main/rate-limits/codex-credit-balance.ts create mode 100644 src/main/rate-limits/codex-fetcher-credit-balance.test.ts create mode 100644 src/renderer/src/components/status-bar/provider-extra-usage-section.tsx create mode 100644 src/renderer/src/components/status-bar/provider-segment-balance.test.tsx create mode 100644 src/renderer/src/i18n/provider-credit-balance-locales.test.ts create mode 100644 src/shared/credit-count-format.test.ts create mode 100644 src/shared/credit-count-format.ts create mode 100644 src/shared/currency-format.test.ts create mode 100644 src/shared/currency-format.ts diff --git a/src/main/proxy-guarded-fetch-call-site-audit.test.ts b/src/main/proxy-guarded-fetch-call-site-audit.test.ts index b92b266f427..53d22bad37f 100644 --- a/src/main/proxy-guarded-fetch-call-site-audit.test.ts +++ b/src/main/proxy-guarded-fetch-call-site-audit.test.ts @@ -14,7 +14,7 @@ import { describe, expect, it } from 'vitest' // means a call site was added, removed, or moved: re-audit the file and update the count. const AUDITED_NON_NET_FETCH_CALLS = new Map([ // Isolated cookie-jar session, proxied by createOpenCodeRequestSession before any request. - ['main/rate-limits/opencode-go-usage-fetcher.ts', 2], + ['main/rate-limits/opencode-go-usage-fetcher.ts', 3], // Isolated cookie-jar session that does NOT apply the proxy — a pre-existing gap, not a // regression: no proxy has ever reached this partition. Keep it listed so it stays visible. ['main/rate-limits/minimax/minimax-request-context.ts', 2], diff --git a/src/main/rate-limits/claude-fetcher-extra-usage.test.ts b/src/main/rate-limits/claude-fetcher-extra-usage.test.ts new file mode 100644 index 00000000000..fb56512bd8c --- /dev/null +++ b/src/main/rate-limits/claude-fetcher-extra-usage.test.ts @@ -0,0 +1,302 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest' +import { fetchClaudeRateLimits } from './claude-fetcher' +import { primeClaudeFetcherMocks, restorePlatform } from './claude-fetcher-test-harness' +import { readActiveClaudeKeychainCredentialsStrict } from '../claude-accounts/keychain' +import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' + +const { netFetchMock, readFileMock, resolveProxyMock, setProxyMock, appGetPathMock } = vi.hoisted( + () => ({ + netFetchMock: vi.fn(), + readFileMock: vi.fn(), + resolveProxyMock: vi.fn(), + setProxyMock: vi.fn(), + appGetPathMock: vi.fn() + }) +) + +vi.mock('node:fs/promises', () => ({ + readFile: readFileMock +})) + +vi.mock('electron', () => ({ + app: { + getPath: appGetPathMock + }, + net: { + fetch: netFetchMock + }, + session: { + defaultSession: { + resolveProxy: resolveProxyMock, + setProxy: setProxyMock + } + } +})) + +vi.mock('./claude-pty', () => ({ + fetchViaPty: vi.fn() +})) + +vi.mock('../claude-accounts/keychain', () => ({ + deleteActiveClaudeKeychainCredentialsStrict: vi.fn(), + readActiveClaudeKeychainCredentials: vi.fn(), + readActiveClaudeKeychainCredentialsStrict: vi.fn(), + readManagedClaudeKeychainCredentials: vi.fn(), + writeActiveClaudeKeychainCredentials: vi.fn(), + writeManagedClaudeKeychainCredentials: vi.fn() +})) + +function oauthPrep(): ClaudeRuntimeAuthPreparation { + return { + configDir: '/Users/test/.claude', + envPatch: { CLAUDE_CONFIG_DIR: '/Users/test/.claude' }, + stripAuthEnv: false, + provenance: 'system' + } +} + +async function fetchUsageResponse(body: string) { + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { accessToken: 'oauth-token', expiresAt: Date.now() + 60_000 } + }) + ) + netFetchMock.mockResolvedValueOnce(new Response(body, { status: 200 })) + return fetchClaudeRateLimits({ authPreparation: oauthPrep() }) +} + +describe('fetchClaudeRateLimits extra usage', () => { + beforeEach(() => { + primeClaudeFetcherMocks({ + netFetchMock, + readFileMock, + resolveProxyMock, + setProxyMock, + appGetPathMock + }) + }) + + afterEach(() => { + restorePlatform() + }) + + it('maps the usage-credits spend object into a capped balance in major units', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { utilization: 10 }, + spend: { + used: { amount_minor: 5000, currency: 'EUR', exponent: 2 }, + limit: { amount_minor: 200000, currency: 'EUR', exponent: 2 }, + percent: 2.5, + enabled: true, + balance: { amount_minor: 1000, currency: 'EUR', exponent: 2 } + } + }), + { status: 200 } + ) + ) + + await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + extraUsage: { + balance: 10, + spent: 50, + spendLimit: 2000, + spentPercent: 2.5, + currencyCode: 'EUR', + enabled: true, + disabledReason: null, + resetsAt: null + } + }) + }) + + it('keeps the usage-credits cap visible without inventing an out-of-credits balance', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { utilization: 10 }, + spend: { + used: { amount_minor: 0, currency: 'EUR', exponent: 2 }, + limit: { amount_minor: 200000, currency: 'EUR', exponent: 2 }, + percent: 0, + enabled: false, + disabled_reason: 'out_of_credits', + balance: null + } + }), + { status: 200 } + ) + ) + + await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + extraUsage: { + balance: null, + spent: 0, + spendLimit: 2000, + spentPercent: 0, + currencyCode: 'EUR', + enabled: false, + disabledReason: 'out_of_credits' + } + }) + }) + + it('keeps legacy extra_usage spend data without inventing a missing balance', async () => { + vi.mocked(readActiveClaudeKeychainCredentialsStrict).mockResolvedValueOnce( + JSON.stringify({ + claudeAiOauth: { + accessToken: 'oauth-token', + expiresAt: Date.now() + 60_000 + } + }) + ) + netFetchMock.mockResolvedValueOnce( + new Response( + JSON.stringify({ + five_hour: { utilization: 10 }, + extra_usage: { + is_enabled: true, + monthly_limit: 200000, + used_credits: 5000, + utilization: 2.5, + currency: 'EUR', + decimal_places: 2 + } + }), + { status: 200 } + ) + ) + + await expect(fetchClaudeRateLimits({ authPreparation: oauthPrep() })).resolves.toMatchObject({ + provider: 'claude', + status: 'ok', + extraUsage: { + balance: null, + spent: 50, + spendLimit: 2000, + spentPercent: 2.5, + currencyCode: 'EUR', + enabled: true + } + }) + }) + it('keeps a missing balance unknown while preserving a known cap', async () => { + const result = await fetchUsageResponse( + '{"five_hour":{"utilization":10},"spend":{"limit":{"amount_minor":200000,"currency":"EUR"},"enabled":true}}' + ) + expect(result.extraUsage).toMatchObject({ + balance: null, + spendLimit: 2000, + currencyCode: 'EUR' + }) + }) + + it.each([1000, 0])('preserves a balance-only EUR amount of %s minor units', async (amount) => { + const result = await fetchUsageResponse( + JSON.stringify({ + five_hour: { utilization: 10 }, + spend: { balance: { amount_minor: amount, currency: 'EUR' }, enabled: true } + }) + ) + expect(result.extraUsage).toMatchObject({ + balance: amount / 100, + currencyCode: 'EUR', + spendLimit: null + }) + }) + + it('keeps a numeric overflow balance unknown and derives a finite percentage', async () => { + const result = await fetchUsageResponse( + '{"five_hour":{"utilization":10},"spend":{"used":{"amount_minor":5000},"limit":{"amount_minor":200000},"balance":{"amount_minor":1e309},"percent":1e309,"enabled":true}}' + ) + expect(result.extraUsage).toMatchObject({ + balance: null, + spent: 50, + spendLimit: 2000, + spentPercent: 2.5 + }) + }) + + it('keeps nullable legacy spend unknown despite a supplied percentage', async () => { + const result = await fetchUsageResponse( + '{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":200000,"used_credits":null,"utilization":10,"currency":"EUR","is_enabled":true}}' + ) + expect(result.extraUsage).toMatchObject({ + balance: null, + spent: null, + spendLimit: 2000, + spentPercent: 10 + }) + }) + + it('derives legacy percentage when the response percentage overflows', async () => { + const result = await fetchUsageResponse( + '{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":200000,"used_credits":5000,"utilization":1e309,"currency":"EUR","is_enabled":true}}' + ) + expect(result.extraUsage).toMatchObject({ + balance: null, + spent: 50, + spendLimit: 2000, + spentPercent: 2.5 + }) + }) + + it('does not turn an overflowing legacy spend into a known amount', async () => { + const result = await fetchUsageResponse( + '{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":200000,"used_credits":1e309,"currency":"EUR","is_enabled":true}}' + ) + expect(result.extraUsage).toMatchObject({ + balance: null, + spent: null, + spendLimit: 2000, + spentPercent: null + }) + }) + + it.each(['null', '1e309'])('omits a legacy cap whose amount is %s', async (limit) => { + const result = await fetchUsageResponse( + `{"five_hour":{"utilization":10},"extra_usage":{"monthly_limit":${limit},"used_credits":5000,"currency":"EUR","is_enabled":true}}` + ) + expect(result.extraUsage ?? null).toBeNull() + }) + + it.each([-1, 0.5, 309])( + 'omits malformed legacy precision %s rather than displaying zero', + async (exponent) => { + const result = await fetchUsageResponse( + JSON.stringify({ + five_hour: { utilization: 10 }, + extra_usage: { + monthly_limit: 200000, + used_credits: 5000, + decimal_places: exponent, + currency: 'EUR', + is_enabled: true + } + }) + ) + expect(result.extraUsage ?? null).toBeNull() + } + ) +}) diff --git a/src/main/rate-limits/claude-oauth-usage-request.ts b/src/main/rate-limits/claude-oauth-usage-request.ts index 9565a064514..b3bca9298aa 100644 --- a/src/main/rate-limits/claude-oauth-usage-request.ts +++ b/src/main/rate-limits/claude-oauth-usage-request.ts @@ -4,6 +4,11 @@ import { ensureElectronProxyFromEnvironment } from '../network/proxy-settings' import { createOAuthUsageError } from './claude-oauth-usage-error' import { mapClaudeUsageWindow, type ClaudeUsageWindowInput } from './claude-usage-window' import { abortedClaudeRateLimitResult } from './claude-usage-result' +import { + mapClaudeExtraUsage, + type ClaudeOAuthExtraUsage, + type ClaudeOAuthSpend +} from './claude-usage-credits' const OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage' const API_TIMEOUT_MS = 10_000 @@ -22,6 +27,8 @@ type OAuthUsageResponse = { fable_weekly?: ClaudeUsageWindowInput fable_seven_day?: ClaudeUsageWindowInput seven_day_fable?: ClaudeUsageWindowInput + spend?: ClaudeOAuthSpend + extra_usage?: ClaudeOAuthExtraUsage limits?: OAuthUsageLimit[] | null } @@ -90,6 +97,7 @@ export async function fetchClaudeOAuthUsage( session: mapClaudeUsageWindow(data.five_hour, 300), weekly: mapClaudeUsageWindow(data.seven_day, 10080), fableWeekly: mapFableWeeklyWindow(data), + extraUsage: mapClaudeExtraUsage(data), updatedAt: Date.now(), error: null, status: 'ok' diff --git a/src/main/rate-limits/claude-usage-credits.ts b/src/main/rate-limits/claude-usage-credits.ts new file mode 100644 index 00000000000..9f01fd63064 --- /dev/null +++ b/src/main/rate-limits/claude-usage-credits.ts @@ -0,0 +1,127 @@ +import type { ExtraUsageBalance } from '../../shared/rate-limit-types' + +// Money is reported as minor units plus an exponent (e.g. 200000 @ exp 2 = €2000). +type OAuthMoney = { + amount_minor?: number + currency?: string + exponent?: number +} + +// Optional richer spend fields are retained for response compatibility. +export type ClaudeOAuthSpend = { + used?: OAuthMoney + limit?: OAuthMoney + percent?: number + enabled?: boolean + disabled_reason?: string | null + cap?: { money?: OAuthMoney | null } | null + balance?: OAuthMoney | number | null +} + +// Legacy shape kept as a fallback for older responses that only send `extra_usage`. +export type ClaudeOAuthExtraUsage = { + is_enabled?: boolean + monthly_limit?: number | null + used_credits?: number | null + utilization?: number | null + currency?: string + decimal_places?: number + disabled_reason?: string | null +} + +function clampPercent(value: number): number { + return Math.min(100, Math.max(0, value)) +} + +function moneyToMajor(money: OAuthMoney | number | null | undefined): number | null { + if (typeof money === 'number') { + return Number.isFinite(money) ? money : null + } + if (!money || typeof money.amount_minor !== 'number' || !Number.isFinite(money.amount_minor)) { + return null + } + const exponent = typeof money.exponent === 'number' ? money.exponent : 2 + if (!Number.isInteger(exponent) || exponent < 0 || exponent > 6) { + return null + } + return money.amount_minor / 10 ** exponent +} + +// Prefer the richer `spend` object; fall back to the legacy `extra_usage` shape. +export function mapClaudeExtraUsage(data: { + spend?: ClaudeOAuthSpend + extra_usage?: ClaudeOAuthExtraUsage +}): ExtraUsageBalance | null { + return mapSpend(data.spend) ?? mapLegacyExtraUsage(data.extra_usage) +} + +function mapSpend(spend: ClaudeOAuthSpend | undefined): ExtraUsageBalance | null { + if (!spend) { + return null + } + const spent = moneyToMajor(spend.used) + const spendLimit = moneyToMajor(spend.limit) ?? moneyToMajor(spend.cap?.money) + const balance = moneyToMajor(spend.balance) + // Nothing worth showing when the account has neither a configured cap nor a balance. + if (spendLimit === null && balance === null) { + return null + } + const spentPercent = + typeof spend.percent === 'number' && Number.isFinite(spend.percent) + ? clampPercent(spend.percent) + : spendLimit !== null && spendLimit > 0 && spent !== null + ? clampPercent((spent / spendLimit) * 100) + : null + return { + balance, + unit: 'currency', + currencyCode: + spend.used?.currency ?? + spend.limit?.currency ?? + spend.cap?.money?.currency ?? + (typeof spend.balance === 'object' && spend.balance !== null + ? spend.balance.currency + : undefined) ?? + 'USD', + enabled: spend.enabled === true, + disabledReason: spend.disabled_reason ?? null, + spent, + spendLimit, + spentPercent, + resetsAt: null + } +} + +function mapLegacyExtraUsage(extra: ClaudeOAuthExtraUsage | undefined): ExtraUsageBalance | null { + if (!extra) { + return null + } + const spendLimit = moneyToMajor({ + amount_minor: extra.monthly_limit ?? undefined, + exponent: extra.decimal_places + }) + if (spendLimit === null) { + return null + } + const spent = moneyToMajor({ + amount_minor: extra.used_credits ?? undefined, + exponent: extra.decimal_places + }) + const spentPercent = + typeof extra.utilization === 'number' && Number.isFinite(extra.utilization) + ? clampPercent(extra.utilization) + : spendLimit > 0 && spent !== null + ? clampPercent((spent / spendLimit) * 100) + : null + return { + balance: null, + unit: 'currency', + currencyCode: extra.currency?.trim() || 'USD', + enabled: extra.is_enabled === true, + disabledReason: extra.disabled_reason ?? null, + spent, + spendLimit, + spentPercent, + resetsAt: null + } +} diff --git a/src/main/rate-limits/claude-usage-result.ts b/src/main/rate-limits/claude-usage-result.ts index aea522e673b..a29cb38d4aa 100644 --- a/src/main/rate-limits/claude-usage-result.ts +++ b/src/main/rate-limits/claude-usage-result.ts @@ -112,7 +112,9 @@ export function mergeClaudeUsageWindows( ...primary, session: primary.session ?? supplement.session, weekly: primary.weekly ?? supplement.weekly, - fableWeekly: primary.fableWeekly ?? supplement.fableWeekly ?? null + fableWeekly: primary.fableWeekly ?? supplement.fableWeekly ?? null, + // Only OAuth reports the extra-usage cap; keep it when the CLI supplements windows. + extraUsage: primary.extraUsage ?? supplement.extraUsage ?? null } } diff --git a/src/main/rate-limits/codex-credit-balance.ts b/src/main/rate-limits/codex-credit-balance.ts new file mode 100644 index 00000000000..ece52f2df64 --- /dev/null +++ b/src/main/rate-limits/codex-credit-balance.ts @@ -0,0 +1,29 @@ +import type { ExtraUsageBalance } from '../../shared/rate-limit-types' +import type { CodexRateLimitWindowsSnapshot } from './codex-rate-limit-window-classification' + +// Codex's pay-as-you-go credit balance, reported alongside the rate windows. +// `balance` is a plain credit count (string), not a currency amount. +type RpcCredits = NonNullable + +// Codex credits are a unitless count, not currency. Only surface the balance +// when the account actually has credits (or unlimited) so accounts that never +// bought any don't get an empty "0 credits" row. +export function mapCodexCredits(raw: RpcCredits | null | undefined): ExtraUsageBalance | null { + if (!raw || (raw.hasCredits !== true && raw.unlimited !== true)) { + return null + } + const parsed = + typeof raw.balance === 'string' && raw.balance.trim() !== '' ? Number(raw.balance) : raw.balance + if ((typeof parsed !== 'number' || !Number.isFinite(parsed)) && raw.unlimited !== true) { + return null + } + const balance = typeof parsed === 'number' && Number.isFinite(parsed) ? Math.max(0, parsed) : 0 + return { + balance, + unit: 'credits', + unlimited: raw.unlimited === true, + enabled: raw.hasCredits === true || raw.unlimited === true, + disabledReason: null, + resetsAt: null + } +} diff --git a/src/main/rate-limits/codex-fetcher-credit-balance.test.ts b/src/main/rate-limits/codex-fetcher-credit-balance.test.ts new file mode 100644 index 00000000000..780687c9a09 --- /dev/null +++ b/src/main/rate-limits/codex-fetcher-credit-balance.test.ts @@ -0,0 +1,137 @@ +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { childSpawnMock, readFileMock } = vi.hoisted(() => ({ + childSpawnMock: vi.fn(), + readFileMock: vi.fn() +})) + +vi.mock('node:child_process', () => ({ spawn: childSpawnMock })) +vi.mock('node:fs/promises', () => ({ readFile: readFileMock })) +vi.mock('../codex-cli/command', () => ({ resolveCodexCommand: () => 'codex' })) +vi.mock('node-pty', () => ({ spawn: vi.fn() })) +vi.mock('./codex-auth-presence', () => ({ + probeCodexAuthPresence: vi.fn(async () => 'present') +})) + +import { fetchCodexRateLimits } from './codex-fetcher' + +function parseRpcRequest(line: string): { id?: number; method?: string } { + const parsed: unknown = JSON.parse(line) + if (typeof parsed !== 'object' || parsed === null) { + return {} + } + return { + id: 'id' in parsed && typeof parsed.id === 'number' ? parsed.id : undefined, + method: 'method' in parsed && typeof parsed.method === 'string' ? parsed.method : undefined + } +} + +// Like the real app-server, the fake dies on stdin EOF or a signal. +class FakeRpcChild extends EventEmitter { + readonly stdout = new EventEmitter() + readonly stderr = new EventEmitter() + exitCode: number | null = null + rateLimits: unknown = null + readonly kill = vi.fn(() => { + this.exitNow() + return true + }) + readonly stdin = Object.assign(new EventEmitter(), { + end: vi.fn(() => this.exitNow()), + write: vi.fn((line: string) => { + const request = parseRpcRequest(line) + if (request.method === 'initialize') { + this.reply(request.id, {}) + } + if (request.method === 'account/rateLimits/read') { + this.reply(request.id, { rateLimits: this.rateLimits }) + } + }) + }) + + private reply(id: number | undefined, result: unknown): void { + setTimeout(() => { + this.stdout.emit('data', Buffer.from(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`)) + }, 0) + } + + private exitNow(): void { + this.exitCode = 0 + this.emit('exit', 0, null) + } +} + +async function fetchWithRateLimits(rateLimits: unknown) { + const child = new FakeRpcChild() + child.rateLimits = rateLimits + childSpawnMock.mockReturnValue(child) + const resultPromise = fetchCodexRateLimits() + await vi.advanceTimersByTimeAsync(1) + await vi.advanceTimersByTimeAsync(1) + return resultPromise +} + +describe('fetchCodexRateLimits credit balance', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + readFileMock.mockRejectedValue(new Error('no auth fixture')) + vi.stubGlobal('fetch', vi.fn()) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('maps the RPC credits object into a Codex credit-count balance', async () => { + const result = await fetchWithRateLimits({ + primary: { usedPercent: 20, windowDurationMins: 10079 }, + secondary: null, + credits: { hasCredits: true, unlimited: false, balance: '500' } + }) + + expect(result.extraUsage).toMatchObject({ + balance: 500, + unit: 'credits', + unlimited: false, + enabled: true + }) + }) + + it('omits the Codex balance when the account has no credits', async () => { + const result = await fetchWithRateLimits({ + primary: { usedPercent: 20, windowDurationMins: 10079 }, + credits: { hasCredits: false, unlimited: false, balance: '0' } + }) + + expect(result.extraUsage ?? null).toBeNull() + }) + it.each([undefined, null, '', ' ', 'invalid', 'Infinity', '1e999'])( + 'omits unavailable credit balance %s despite hasCredits', + async (balance) => { + const result = await fetchWithRateLimits({ + primary: { usedPercent: 20, windowDurationMins: 10079 }, + credits: { hasCredits: true, unlimited: false, balance } + }) + expect(result.extraUsage ?? null).toBeNull() + } + ) + + it('preserves an explicitly reported zero credit balance', async () => { + const result = await fetchWithRateLimits({ + primary: { usedPercent: 20, windowDurationMins: 10079 }, + credits: { hasCredits: true, unlimited: false, balance: '0' } + }) + expect(result.extraUsage).toMatchObject({ unit: 'credits', unlimited: false, balance: 0 }) + }) + + it('keeps the unlimited indication without requiring a numeric balance', async () => { + const result = await fetchWithRateLimits({ + primary: { usedPercent: 20, windowDurationMins: 10079 }, + credits: { hasCredits: false, unlimited: true, balance: null } + }) + expect(result.extraUsage).toMatchObject({ unit: 'credits', unlimited: true, enabled: true }) + }) +}) diff --git a/src/main/rate-limits/codex-rate-limit-window-classification.ts b/src/main/rate-limits/codex-rate-limit-window-classification.ts index cdf667b6306..a19363bed8a 100644 --- a/src/main/rate-limits/codex-rate-limit-window-classification.ts +++ b/src/main/rate-limits/codex-rate-limit-window-classification.ts @@ -13,6 +13,12 @@ export type CodexRateWindowSnapshot = { export type CodexRateLimitWindowsSnapshot = { primary?: CodexRateWindowSnapshot | null secondary?: CodexRateWindowSnapshot | null + /** Pay-as-you-go credit balance, when the app-server reports one. */ + credits?: { + hasCredits?: boolean + unlimited?: boolean + balance?: string | number + } | null } type MappableCodexRateWindowSnapshot = CodexRateWindowSnapshot & { usedPercent: number } diff --git a/src/main/rate-limits/codex-rpc-rate-limit-probe.ts b/src/main/rate-limits/codex-rpc-rate-limit-probe.ts index ededfa9c349..0c59403c35f 100644 --- a/src/main/rate-limits/codex-rpc-rate-limit-probe.ts +++ b/src/main/rate-limits/codex-rpc-rate-limit-probe.ts @@ -12,6 +12,7 @@ import { CODEX_WEEKLY_WINDOW_MINUTES, type CodexRateLimitWindowsSnapshot } from './codex-rate-limit-window-classification' +import { mapCodexCredits } from './codex-credit-balance' import type { CodexRateLimitFetchOptions } from './codex-rate-limit-fetch-options' import { abortedCodexRateLimitResult } from './codex-rate-limit-fetch-result' import { mapCodexRateLimitWindow } from './codex-rate-limit-window-mapper' @@ -244,12 +245,14 @@ export function readCodexRateLimitsViaRpc( const wrapper = message.result as RpcRateLimitsResponse | undefined const classified = classifyCodexRateLimitWindows(wrapper?.rateLimits) const credits = mapRpcRateLimitResetCredits(wrapper?.rateLimitResetCredits) + const extraUsage = mapCodexCredits(wrapper?.rateLimits?.credits) settle( { provider: 'codex', session: mapCodexRateLimitWindow(classified.session, CODEX_SESSION_WINDOW_MINUTES), weekly: mapCodexRateLimitWindow(classified.weekly, CODEX_WEEKLY_WINDOW_MINUTES), ...(credits !== undefined ? { rateLimitResetCredits: credits } : {}), + ...(extraUsage ? { extraUsage } : {}), updatedAt: Date.now(), error: null, status: 'ok' diff --git a/src/main/rate-limits/opencode-go-status-parsing.test.ts b/src/main/rate-limits/opencode-go-status-parsing.test.ts index 1116dcad71b..b77f12f80c8 100644 --- a/src/main/rate-limits/opencode-go-status-parsing.test.ts +++ b/src/main/rate-limits/opencode-go-status-parsing.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' import { + isOpenCodeGoExplicitNoAccessPayload, + parseOpenCodeGoBillingStatusPayload, parseOpenCodeGoStatusPayload, parseOpenCodeGoUsageApiPayload } from './opencode-go-status-parsing' @@ -120,6 +122,76 @@ describe('parseOpenCodeGoStatusPayload', () => { }) }) +describe('parseOpenCodeGoBillingStatusPayload', () => { + it('maps the verified PAYG micro-cent string into USD major units', () => { + expect( + parseOpenCodeGoBillingStatusPayload( + JSON.stringify({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '2786781005' + }) + ) + ).toBe(27.86781005) + }) + + it('preserves zero and negative balances', () => { + expect( + parseOpenCodeGoBillingStatusPayload( + JSON.stringify({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '0' + }) + ) + ).toBe(0) + expect( + parseOpenCodeGoBillingStatusPayload( + JSON.stringify({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '-125000000' + }) + ) + ).toBe(-1.25) + }) + + it.each([ + { billingMode: 'credit', mode: 'pay-as-you-go', balanceMicroCents: '100000000' }, + { billingMode: 'seat', mode: 'pay-as-you-go', balanceMicroCents: '100000000' }, + { billingMode: 'prepaid', mode: 'invoiceable', balanceMicroCents: '100000000' }, + { billingMode: 'prepaid', mode: 'pay-as-you-go', balanceMicroCents: 100000000 }, + { billingMode: 'prepaid', mode: 'pay-as-you-go', balanceMicroCents: '1.5' }, + { + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '9007199254740992' + } + ])('rejects unsupported or unsafe billing payloads: $billingMode/$mode', (payload) => { + expect(parseOpenCodeGoBillingStatusPayload(JSON.stringify(payload))).toBeNull() + }) + + it('fails closed for malformed and non-object bodies', () => { + expect(parseOpenCodeGoBillingStatusPayload('{not json')).toBeNull() + expect(parseOpenCodeGoBillingStatusPayload('[]')).toBeNull() + expect(parseOpenCodeGoBillingStatusPayload('')).toBeNull() + }) +}) + +describe('isOpenCodeGoExplicitNoAccessPayload', () => { + it('accepts only explicit JSON null or access:null without an error verdict', () => { + expect(isOpenCodeGoExplicitNoAccessPayload('null')).toBe(true) + expect(isOpenCodeGoExplicitNoAccessPayload('{"access":null}')).toBe(true) + expect( + isOpenCodeGoExplicitNoAccessPayload('{"access":null,"error":{"type":"AuthError"}}') + ).toBe(false) + expect(isOpenCodeGoExplicitNoAccessPayload('{"access":{}}')).toBe(false) + expect(isOpenCodeGoExplicitNoAccessPayload('{}')).toBe(false) + expect(isOpenCodeGoExplicitNoAccessPayload('')).toBe(false) + expect(isOpenCodeGoExplicitNoAccessPayload('{not json')).toBe(false) + }) +}) + describe('parseOpenCodeGoUsageApiPayload', () => { it('clamps an out-of-range percent and tolerates a missing resetsAt', () => { const parsed = parseOpenCodeGoUsageApiPayload( diff --git a/src/main/rate-limits/opencode-go-status-parsing.ts b/src/main/rate-limits/opencode-go-status-parsing.ts index cc98fd477ed..c4635792fc9 100644 --- a/src/main/rate-limits/opencode-go-status-parsing.ts +++ b/src/main/rate-limits/opencode-go-status-parsing.ts @@ -1,4 +1,4 @@ -import type { RateLimitWindow } from '../../shared/rate-limit-types' +import type { ExtraUsageBalance, RateLimitWindow } from '../../shared/rate-limit-types' const SESSION_WINDOW_MINUTES = 300 const WEEKLY_WINDOW_MINUTES = 10_080 @@ -100,6 +100,61 @@ export function parseOpenCodeGoUsageApiPayload(text: string): OpenCodeGoUsageWin } } +// Console BillingStatus uses integer-string LedgerMicroCents (100,000,000 per USD). +export function parseOpenCodeGoBillingStatusPayload(text: string): number | null { + if (!text || text.length > MAX_STATUS_PAYLOAD_CHARS) { + return null + } + let payload: unknown + try { + payload = JSON.parse(text) + } catch { + return null + } + if ( + !isRecord(payload) || + payload.billingMode !== 'prepaid' || + payload.mode !== 'pay-as-you-go' || + typeof payload.balanceMicroCents !== 'string' || + !/^-?\d+$/.test(payload.balanceMicroCents) + ) { + return null + } + const microCents = Number(payload.balanceMicroCents) + return Number.isSafeInteger(microCents) ? microCents / 100_000_000 : null +} + +export function makeOpenCodeGoZenBalance( + balance: number | null, + disabledReason: string | null = null +): ExtraUsageBalance { + return { + balance, + unit: 'currency', + currencyCode: 'USD', + enabled: balance !== null, + disabledReason, + spent: null, + spendLimit: null, + spentPercent: null, + resetsAt: null + } +} + +export function isOpenCodeGoExplicitNoAccessPayload(text: string): boolean { + if (!text || text.length > MAX_STATUS_PAYLOAD_CHARS) { + return false + } + try { + const payload: unknown = JSON.parse(text) + return ( + payload === null || (isRecord(payload) && payload.access === null && !('error' in payload)) + ) + } catch { + return false + } +} + export function parseOpenCodeGoStatusPayload(text: string): OpenCodeGoUsageWindows | null { if (!text || text.length > MAX_STATUS_PAYLOAD_CHARS) { return null diff --git a/src/main/rate-limits/opencode-go-usage-fetcher.test.ts b/src/main/rate-limits/opencode-go-usage-fetcher.test.ts index 9e07f83f472..0c65c2fb788 100644 --- a/src/main/rate-limits/opencode-go-usage-fetcher.test.ts +++ b/src/main/rate-limits/opencode-go-usage-fetcher.test.ts @@ -15,6 +15,7 @@ import { fetchOpenCodeGoRateLimits, normalizeCookieInput } from './opencode-go-u const WORKSPACES_SERVER_ID = 'def39973159c7f0483d8793a822b8dbb10d067e12c65455fcb4608459ba0234f' const CONSOLE_STATUS_URL = 'https://opencode.ai/console/api/go/status' +const CONSOLE_BILLING_STATUS_URL = 'https://opencode.ai/console/api/billing/status' const LEGACY_WORKSPACE_GO_URL = /https:\/\/opencode\.ai\/workspace\/[^/]+\/go/ function makeResponse(body: string, status = 200): Response { @@ -259,8 +260,10 @@ describe('fetchOpenCodeGoRateLimits', () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) + .mockResolvedValueOnce(makeResponse('unavailable', 503)) .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) + .mockResolvedValueOnce(makeResponse('unavailable', 503)) const proxySettings = { httpProxyUrl: 'http://proxy.example:8080', @@ -343,6 +346,254 @@ describe('fetchOpenCodeGoRateLimits', () => { }) }) + it.each(['discovery', 'override'])('accepts an org_ workspace from %s', async (source) => { + netFetchMock.mockImplementation(async (url) => { + if (String(url).startsWith('https://opencode.ai/_server')) { + return makeResponse('id: "org_CONSOLE123"') + } + return url === CONSOLE_STATUS_URL + ? makeJsonResponse(STATUS_WITH_MONTHLY) + : makeJsonResponse({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '2786781005' + }) + }) + + const result = await fetchOpenCodeGoRateLimits( + '__Host-console_session=placeholder', + source === 'override' ? 'org_CONSOLE123' : undefined + ) + + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(30) + expect(result.extraUsage?.balance).toBe(27.86781005) + expect(netFetchMock).toHaveBeenCalledWith( + CONSOLE_BILLING_STATUS_URL, + expect.objectContaining({ + headers: expect.objectContaining({ 'x-org-id': 'org_CONSOLE123' }) + }) + ) + }) + + it('enriches the selected cookie workspace with its verified PAYG Zen balance', async () => { + netFetchMock.mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)).mockResolvedValueOnce( + makeJsonResponse({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '2786781005' + }) + ) + + const result = await fetchOpenCodeGoRateLimits( + '__Host-console_session=consoleTok', + 'wrk_OVERRIDE123' + ) + + expect(netFetchMock).toHaveBeenNthCalledWith( + 2, + CONSOLE_BILLING_STATUS_URL, + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'x-org-id': 'wrk_OVERRIDE123', + Accept: 'application/json' + }) + }) + ) + expect(netFetchMock.mock.calls[1][1].headers).not.toHaveProperty('Cookie') + expect(result.extraUsage).toEqual({ + balance: 27.86781005, + unit: 'currency', + currencyCode: 'USD', + enabled: true, + disabledReason: null, + spent: null, + spendLimit: null, + spentPercent: null, + resetsAt: null + }) + }) + + it.each([ + [ + 'unsupported mode', + makeJsonResponse({ billingMode: 'seat', mode: 'pay-as-you-go' }), + 'billing-unavailable' + ], + ['billing failure', makeResponse('unavailable', 503), 'refresh-failed'] + ])( + 'keeps valid quota when optional billing has an %s', + async (_label, billingResponse, disabledReason) => { + netFetchMock + .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) + .mockResolvedValueOnce(billingResponse) + + const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') + + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(30) + expect(result.extraUsage).toEqual( + expect.objectContaining({ balance: null, enabled: false, disabledReason }) + ) + } + ) + + it('returns a verified balance-only snapshot for explicit access:null', async () => { + netFetchMock.mockResolvedValueOnce(makeJsonResponse({ access: null })).mockResolvedValueOnce( + makeJsonResponse({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '0' + }) + ) + + const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') + + expect(result).toEqual( + expect.objectContaining({ + provider: 'opencode-go', + session: null, + weekly: null, + monthly: null, + error: null, + status: 'ok', + extraUsage: expect.objectContaining({ balance: 0, currencyCode: 'USD' }) + }) + ) + }) + + it('returns a verified balance-only snapshot for an explicit JSON null status', async () => { + netFetchMock.mockResolvedValueOnce(makeJsonResponse(null)).mockResolvedValueOnce( + makeJsonResponse({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '-125000000' + }) + ) + + const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') + + expect(result.status).toBe('ok') + expect(result.session).toBeNull() + expect(result.extraUsage).toEqual(expect.objectContaining({ balance: -1.25 })) + }) + + it('aborts billing before returning a generic malformed-usage error', async () => { + let billingSignal: AbortSignal | undefined + netFetchMock + .mockResolvedValueOnce(makeJsonResponse({ access: {} })) + .mockImplementationOnce((_url, init: RequestInit) => { + billingSignal = init.signal ?? undefined + return new Promise((resolve, reject) => { + billingSignal?.addEventListener('abort', () => reject(new Error('aborted')), { + once: true + }) + setTimeout( + () => + resolve( + makeJsonResponse({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '100000000' + }) + ), + 100 + ) + }) + }) + + const resultPromise = fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') + await vi.advanceTimersByTimeAsync(0) + const abortedBeforeBillingResponse = billingSignal?.aborted + await vi.advanceTimersByTimeAsync(100) + const result = await resultPromise + + expect(abortedBeforeBillingResponse).toBe(true) + expect(result.status).toBe('error') + expect(result.error).toBe('Could not parse usage data') + expect(clearStorageDataMock).toHaveBeenCalledTimes(2) + }) + + it('prefers a later valid Go workspace over an earlier balance-only candidate', async () => { + netFetchMock + .mockResolvedValueOnce(makeResponse('id: "wrk_FIRST" id: "wrk_SECOND"')) + .mockResolvedValueOnce(makeJsonResponse({ access: null })) + .mockResolvedValueOnce( + makeJsonResponse({ + billingMode: 'prepaid', + mode: 'pay-as-you-go', + balanceMicroCents: '900000000' + }) + ) + .mockResolvedValueOnce(makeJsonResponse(STATUS_NO_MONTHLY)) + .mockResolvedValueOnce(makeResponse('unavailable', 503)) + + const result = await fetchOpenCodeGoRateLimits('auth=mytoken') + + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(10) + expect(result.extraUsage).toEqual( + expect.objectContaining({ balance: null, enabled: false, disabledReason: 'refresh-failed' }) + ) + expect(netFetchMock).toHaveBeenNthCalledWith( + 4, + CONSOLE_STATUS_URL, + expect.objectContaining({ headers: expect.objectContaining({ 'x-org-id': 'wrk_SECOND' }) }) + ) + }) + + it('aborts and settles an optional billing timeout before clearing the cookie jar', async () => { + let billingSignal: AbortSignal | undefined + netFetchMock + .mockResolvedValueOnce(makeJsonResponse(STATUS_WITH_MONTHLY)) + .mockImplementationOnce((_url, init: RequestInit) => { + billingSignal = init.signal ?? undefined + return new Promise((_resolve, reject) => { + billingSignal?.addEventListener('abort', () => reject(new Error('aborted')), { + once: true + }) + }) + }) + + const resultPromise = fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') + await vi.advanceTimersByTimeAsync(5_000) + const result = await resultPromise + + expect(result.status).toBe('ok') + expect(result.session?.usedPercent).toBe(30) + expect(billingSignal?.aborted).toBe(true) + expect(clearStorageDataMock).toHaveBeenCalledTimes(2) + }) + + it('aborts and settles billing before continuing after a non-ok usage response', async () => { + let billingSignal: AbortSignal | undefined + let billingSettled = false + netFetchMock + .mockResolvedValueOnce(makeResponse('Unauthorized', 401)) + .mockImplementationOnce((_url, init: RequestInit) => { + billingSignal = init.signal ?? undefined + return new Promise((_resolve, reject) => { + billingSignal?.addEventListener( + 'abort', + () => { + billingSettled = true + reject(new Error('aborted')) + }, + { once: true } + ) + }) + }) + + const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') + + expect(result.status).toBe('error') + expect(result.error).toContain('Usage fetch failed (401)') + expect(billingSignal?.aborted).toBe(true) + expect(billingSettled).toBe(true) + expect(clearStorageDataMock).toHaveBeenCalledTimes(2) + }) + it('returns ok with null monthly when the month meter is absent', async () => { netFetchMock .mockResolvedValueOnce(makeResponse(WORKSPACES_RESPONSE)) @@ -400,7 +651,7 @@ describe('fetchOpenCodeGoRateLimits', () => { const result = await fetchOpenCodeGoRateLimits('auth=mytoken', 'wrk_OVERRIDE123') - expect(netFetchMock).toHaveBeenCalledTimes(1) + expect(netFetchMock).toHaveBeenCalledTimes(2) expect(requestedUrls().some((url) => LEGACY_WORKSPACE_GO_URL.test(url))).toBe(false) expect(netFetchMock).toHaveBeenCalledWith( CONSOLE_STATUS_URL, diff --git a/src/main/rate-limits/opencode-go-usage-fetcher.ts b/src/main/rate-limits/opencode-go-usage-fetcher.ts index 451d2f316bc..d8ebf61d17d 100644 --- a/src/main/rate-limits/opencode-go-usage-fetcher.ts +++ b/src/main/rate-limits/opencode-go-usage-fetcher.ts @@ -1,17 +1,24 @@ import type { Session } from 'electron' import { randomUUID } from 'node:crypto' import type { NetworkProxySettings } from '../../shared/network-proxy' -import type { ProviderRateLimits } from '../../shared/rate-limit-types' +import type { ExtraUsageBalance, ProviderRateLimits } from '../../shared/rate-limit-types' import { clearOpenCodeSessionCookies, createOpenCodeRequestSession, OPENCODE_BASE_URL } from './opencode-go-request-session' -import { parseOpenCodeGoStatusPayload } from './opencode-go-status-parsing' +import { + isOpenCodeGoExplicitNoAccessPayload, + makeOpenCodeGoZenBalance, + parseOpenCodeGoBillingStatusPayload, + parseOpenCodeGoStatusPayload +} from './opencode-go-status-parsing' const OPENCODE_SERVER_URL = 'https://opencode.ai/_server' const OPENCODE_GO_STATUS_URL = `${OPENCODE_BASE_URL}/console/api/go/status` +const OPENCODE_BILLING_STATUS_URL = `${OPENCODE_BASE_URL}/console/api/billing/status` const API_TIMEOUT_MS = 15_000 +const OPTIONAL_BILLING_TIMEOUT_MS = 5_000 // Server-function hash for the workspaces endpoint — stable identifier used by // the opencode.ai SST/TanStack router server-fn protocol. @@ -59,12 +66,9 @@ function parseAuthCookies(raw: string): { name: string; value: string }[] { } function parseWorkspaceIds(text: string): string[] { - // Match id:"wrk_..." or id: "wrk_..." patterns in JS-serialized output. - // Why: Workspace IDs follow a 'wrk_xxx' or 'wk_xxx' pattern. Using a - // more specific regex with word boundaries avoids picking up unrelated - // object properties that might match a generic ID pattern. + // Console uses org_/wrk_; wk_ remains accepted for older workspace responses. const ids: string[] = [] - const workspaceIdRegex = /\bid\s*:\s*["']((?:wrk|wk)_[a-zA-Z0-9]+)["']/g + const workspaceIdRegex = /\bid\s*:\s*["']((?:org|wrk|wk)_[a-zA-Z0-9]+)["']/g for (const match of text.matchAll(workspaceIdRegex)) { const id = match[1] if (id && !ids.includes(id)) { @@ -126,6 +130,35 @@ export async function fetchOpenCodeGoRateLimits( } } +function startOptionalBillingFetch(openCodeSession: Session, workspaceId: string) { + const abortController = new AbortController() + const promise = (async (): Promise => { + const timeout = setTimeout(() => abortController.abort(), OPTIONAL_BILLING_TIMEOUT_MS) + try { + const response = await openCodeSession.fetch(OPENCODE_BILLING_STATUS_URL, { + method: 'GET', + headers: { + Accept: 'application/json', + Origin: OPENCODE_BASE_URL, + Referer: `${OPENCODE_BASE_URL}/console/${workspaceId}/billing`, + 'x-org-id': workspaceId + }, + signal: abortController.signal + }) + if (!response.ok) { + return makeOpenCodeGoZenBalance(null, 'refresh-failed') + } + const balance = parseOpenCodeGoBillingStatusPayload(await response.text()) + return makeOpenCodeGoZenBalance(balance, balance === null ? 'billing-unavailable' : null) + } catch { + return makeOpenCodeGoZenBalance(null, 'refresh-failed') + } finally { + clearTimeout(timeout) + } + })() + return { promise, abort: () => abortController.abort() } +} + function makeOpenCodeError(error: unknown): ProviderRateLimits { return { provider: 'opencode-go', @@ -147,14 +180,14 @@ async function fetchOpenCodeGoRateLimitsWithSession( const override = workspaceIdOverride?.trim() if (override) { - if (!/^(wrk|wk)_[A-Za-z0-9]+$/.test(override)) { + if (!/^(org|wrk|wk)_[A-Za-z0-9]+$/.test(override)) { return { provider: 'opencode-go', session: null, weekly: null, monthly: null, updatedAt: Date.now(), - error: 'Invalid workspace ID format: must match ^(wrk|wk)_[A-Za-z0-9]+$', + error: 'Invalid workspace ID format: must match ^(org|wrk|wk)_[A-Za-z0-9]+$', status: 'error' } } @@ -221,9 +254,11 @@ async function fetchOpenCodeGoRateLimitsWithSession( // Why: /workspace//go now 302s to console login. Usage is JSON at // /console/api/go/status, scoped by x-org-id and authed by the console session. let lastError = '' + let balanceOnlyResult: ProviderRateLimits | null = null for (const candidateId of ids) { + let billingTask: ReturnType | null = null try { - const statusRes = await openCodeSession.fetch(OPENCODE_GO_STATUS_URL, { + const statusPromise = openCodeSession.fetch(OPENCODE_GO_STATUS_URL, { method: 'GET', headers: { Accept: 'application/json', @@ -233,8 +268,11 @@ async function fetchOpenCodeGoRateLimitsWithSession( }, signal: AbortSignal.timeout(API_TIMEOUT_MS) }) - + billingTask = startOptionalBillingFetch(openCodeSession, candidateId) + const statusRes = await statusPromise if (!statusRes.ok) { + billingTask.abort() + await billingTask.promise lastError = statusRes.status === 401 ? 'Usage fetch failed (401) — paste the full Cookie header including __Host-console_session (auth alone is not enough)' @@ -242,25 +280,56 @@ async function fetchOpenCodeGoRateLimitsWithSession( continue } - const parsed = parseOpenCodeGoStatusPayload(await statusRes.text()) + const statusText = await statusRes.text() + const parsed = parseOpenCodeGoStatusPayload(statusText) + const explicitNoAccess = isOpenCodeGoExplicitNoAccessPayload(statusText) + if (!parsed && !explicitNoAccess) { + billingTask.abort() + await billingTask.promise + lastError = 'Could not parse usage data' + continue + } + + const balance = await billingTask.promise if (parsed) { return { provider: 'opencode-go', session: parsed.session, weekly: parsed.weekly, monthly: parsed.monthly, + extraUsage: balance, updatedAt: Date.now(), error: null, status: 'ok' } } - lastError = 'Could not parse usage data' + if (balance.balance !== null) { + balanceOnlyResult ??= { + provider: 'opencode-go', + session: null, + weekly: null, + monthly: null, + extraUsage: balance, + updatedAt: Date.now(), + error: null, + status: 'ok' + } + } else { + lastError = 'Could not parse usage data' + } } catch (err) { + if (billingTask) { + billingTask.abort() + await billingTask.promise + } const message = err instanceof Error ? err.message : 'Unknown error' lastError = message } } + if (balanceOnlyResult) { + return balanceOnlyResult + } return { provider: 'opencode-go', session: null, diff --git a/src/main/rate-limits/opencode-go-usage-source-selection.test.ts b/src/main/rate-limits/opencode-go-usage-source-selection.test.ts index 64b7ac622ca..44cc775afa4 100644 --- a/src/main/rate-limits/opencode-go-usage-source-selection.test.ts +++ b/src/main/rate-limits/opencode-go-usage-source-selection.test.ts @@ -66,6 +66,9 @@ describe('fetchOpenCodeGoUsage', () => { expect(result.status).toBe('ok') expect(result.session).toEqual(WINDOWS.session) expect(result.usageMetadata?.credentialSource).toBe('environment') + expect(result.extraUsage).toEqual( + expect.objectContaining({ balance: null, enabled: false, disabledReason: 'api-key-source' }) + ) }) it('passes the settings override down as the highest-precedence tier', async () => { diff --git a/src/main/rate-limits/opencode-go-usage-source-selection.ts b/src/main/rate-limits/opencode-go-usage-source-selection.ts index c38fc1dc844..5973d8c1e91 100644 --- a/src/main/rate-limits/opencode-go-usage-source-selection.ts +++ b/src/main/rate-limits/opencode-go-usage-source-selection.ts @@ -4,7 +4,7 @@ import { resolveOpenCodeGoApiKey, type OpenCodeGoApiKeyResolution } from './opencode-go-api-key-source' -import type { OpenCodeGoUsageWindows } from './opencode-go-status-parsing' +import { makeOpenCodeGoZenBalance, type OpenCodeGoUsageWindows } from './opencode-go-status-parsing' import type { OpenCodeCredentialBackend } from '../opencode/opencode-credential-backend' import { fetchOpenCodeGoUsageWithApiKey, @@ -52,6 +52,7 @@ function usageResult( session: windows.session, weekly: windows.weekly, monthly: windows.monthly, + extraUsage: makeOpenCodeGoZenBalance(null, 'api-key-source'), updatedAt: Date.now(), error: null, status: 'ok', diff --git a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx index a0be19112c7..f9a484ede41 100644 --- a/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx +++ b/src/renderer/src/components/status-bar/StatusBarProviderSegment.tsx @@ -6,6 +6,8 @@ import { type UsagePercentageDisplay } from '../../../../shared/usage-percentage-display' import type { StatusBarUsageMode } from '../../../../shared/status-bar-usage-mode' +import { formatCurrencyAmount } from '../../../../shared/currency-format' +import { formatCreditCount } from '../../../../shared/credit-count-format' import { ProviderIcon, USAGE_URGENT_PERCENT, @@ -262,6 +264,43 @@ function VerboseProviderUsage({ ) } +// A plan spends into its overage balance only once an included window is +// exhausted. Treat ~100% as capped to tolerate provider rounding. +const CAP_THRESHOLD_PERCENT = 99.5 + +// Why: only reveal the compact balance once a capped window can spend it. +function isExtraUsageActive(p: ProviderRateLimits): boolean { + if ( + !p.extraUsage || + !p.extraUsage.enabled || + (p.extraUsage.unit === 'currency' && + (p.extraUsage.balance === null || p.extraUsage.balance <= 0)) + ) { + return false + } + return [p.session, p.weekly, p.monthly, p.fableWeekly].some( + (w) => w != null && clampUsedPercent(w.usedPercent) >= CAP_THRESHOLD_PERCENT + ) +} + +function formatCompactExtraUsage(balance: ProviderRateLimits['extraUsage']): string { + if (!balance) { + return '' + } + if (balance.unit === 'credits') { + return balance.unlimited + ? translate('auto.components.status.bar.StatusBar.4025a6f62f', 'Unlimited') + : translate('auto.components.status.bar.StatusBar.a95969101f', '{{value0}} credits', { + value0: formatCreditCount(balance.balance) + }) + } + return balance.balance === null + ? '' + : translate('auto.components.status.bar.StatusBar.4fba7dc1e7', '{{value0}} bal', { + value0: formatCurrencyAmount(balance.balance, balance.currencyCode) + }) +} + export function ProviderSegment({ p, compact, @@ -320,6 +359,7 @@ export function ProviderSegment({ // Has data (ok, fetching with stale data, or error with stale data) const isStale = p.status === 'error' + const showBalance = isExtraUsageActive(p) return ( @@ -339,6 +379,12 @@ export function ProviderSegment({ showLabel={!compact} /> ) : null} + {showBalance && p.extraUsage ? ( + <> + · + {formatCompactExtraUsage(p.extraUsage)} + + ) : null} {isStale && } ) diff --git a/src/renderer/src/components/status-bar/provider-extra-usage-section.tsx b/src/renderer/src/components/status-bar/provider-extra-usage-section.tsx new file mode 100644 index 00000000000..3eabad4bb5e --- /dev/null +++ b/src/renderer/src/components/status-bar/provider-extra-usage-section.tsx @@ -0,0 +1,158 @@ +import type { ExtraUsageBalance, ProviderRateLimits } from '../../../../shared/rate-limit-types' +import type { UsagePercentageDisplay } from '../../../../shared/usage-percentage-display' +import { clampUsedPercent } from '../../../../shared/usage-percentage-display' +import { formatCreditCount } from '../../../../shared/credit-count-format' +import { formatCurrencyAmount } from '../../../../shared/currency-format' +import { translate } from '@/i18n/i18n' +import { formatUsagePercentageLabel } from './usage-percentage-label' + +export const USAGE_WARNING_PERCENT = 60 +export const USAGE_URGENT_PERCENT = 80 + +export function barColor(usedPct: number): string { + if (usedPct < USAGE_WARNING_PERCENT) { + return 'bg-muted-foreground/40' + } + if (usedPct < USAGE_URGENT_PERCENT) { + return 'bg-yellow-500' + } + return 'bg-red-500' +} + +export function getExtraUsageLabel(provider: ProviderRateLimits['provider']): string { + if (provider === 'claude') { + return translate('auto.components.status.bar.tooltip.7404abbece', 'Usage credits') + } + if (provider === 'opencode-go') { + return translate('auto.components.status.bar.tooltip.fbc80d8be2', 'Zen balance') + } + if (provider === 'codex') { + return translate('auto.components.status.bar.tooltip.f21b2ba897', 'Credits') + } + return translate('auto.components.status.bar.tooltip.c03c61f53f', 'Balance') +} + +function renderUncappedBalanceLine(balance: ExtraUsageBalance): string { + if (balance.unit === 'credits') { + if (balance.unlimited) { + return translate('auto.components.status.bar.tooltip.56c0d70577', 'Unlimited') + } + return translate( + 'auto.components.status.bar.tooltip.87b5bda4d3', + '{{value0}} credits available', + { value0: formatCreditCount(balance.balance) } + ) + } + if (balance.balance === null) { + return '' + } + const currencyText = formatCurrencyAmount(balance.balance, balance.currencyCode) + return translate('auto.components.status.bar.tooltip.f6a27a3c0a', '{{value0}} available', { + value0: currencyText + }) +} + +export function ProviderExtraUsageSection({ + balance, + provider, + textClass, + mutedClass, + faintClass, + emptyBarClass, + usagePercentageDisplay +}: { + balance: ExtraUsageBalance + provider: ProviderRateLimits['provider'] + textClass: string + mutedClass: string + faintClass: string + emptyBarClass: string + usagePercentageDisplay: UsagePercentageDisplay +}): React.JSX.Element | null { + const label = getExtraUsageLabel(provider) + if (balance.unit === 'credits') { + return ( +
+
{label}
+
{renderUncappedBalanceLine(balance)}
+
+ ) + } + + if (provider === 'opencode-go' && balance.balance === null) { + const status = + balance.disabledReason === 'refresh-failed' + ? translate('auto.components.status.bar.tooltip.e740f92596', 'Refresh failed') + : translate('auto.components.status.bar.tooltip.1292d4f2ee', 'Unavailable') + return ( +
+
{label}
+
{status}
+ {balance.disabledReason === 'api-key-source' ? ( +
+ {translate( + 'auto.components.status.bar.provider.extra.usage.section.apiKeyBalanceUnavailable', + 'Balance is not included in this usage response.' + )} +
+ ) : null} +
+ ) + } + + const capped = + balance.spendLimit !== null && balance.spentPercent !== null && balance.spent !== null + const balanceText = + balance.balance === null ? null : formatCurrencyAmount(balance.balance, balance.currencyCode) + if (!capped) { + const limitText = + balance.spendLimit === null + ? null + : formatCurrencyAmount(balance.spendLimit, balance.currencyCode) + const limitLabel = + limitText === null + ? null + : translate( + 'auto.components.status.bar.provider.extra.usage.section.135d51c19f', + 'Limit {{value0}}', + { value0: limitText } + ) + if (balanceText === null && limitLabel === null) { + return null + } + return ( +
+
{label}
+
+ {balanceText === null ? limitLabel : renderUncappedBalanceLine(balance)} +
+
+ ) + } + + const spentPct = clampUsedPercent(balance.spentPercent ?? 0) + const spent = formatCurrencyAmount(balance.spent ?? 0, balance.currencyCode) + const limit = formatCurrencyAmount(balance.spendLimit ?? 0, balance.currencyCode) + return ( +
+
{label}
+
+
+
+
+ {`${spent} / ${limit}`} + {formatUsagePercentageLabel(spentPct, usagePercentageDisplay)} +
+ {balanceText !== null ? ( +
+ {translate('auto.components.status.bar.tooltip.473d45cd0f', 'Balance {{value0}}', { + value0: balanceText + })} +
+ ) : null} +
+ ) +} diff --git a/src/renderer/src/components/status-bar/provider-segment-balance.test.tsx b/src/renderer/src/components/status-bar/provider-segment-balance.test.tsx new file mode 100644 index 00000000000..e176ca44987 --- /dev/null +++ b/src/renderer/src/components/status-bar/provider-segment-balance.test.tsx @@ -0,0 +1,212 @@ +import { renderToStaticMarkup } from 'react-dom/server' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ProviderRateLimits } from '../../../../shared/rate-limit-types' + +const compactMessages = vi.hoisted(() => ({ balance: '' })) + +afterEach(() => { + compactMessages.balance = '' +}) + +vi.mock('@/i18n/i18n', () => ({ + i18n: { language: 'en' }, + translate: (_key: string, fallback: string, values?: Record) => { + let result = + _key === 'auto.components.status.bar.StatusBar.4fba7dc1e7' && compactMessages.balance + ? compactMessages.balance + : fallback + for (const [key, value] of Object.entries(values ?? {})) { + result = result.replace(`{{${key}}}`, value) + } + return result + } +})) + +vi.mock('@/lib/agent-catalog', () => ({ + AgentIcon: () => null +})) + +vi.mock('../../store', () => ({ + useAppStore: (selector: (state: { usagePercentageDisplay: 'used' | 'remaining' }) => unknown) => + selector({ usagePercentageDisplay: 'used' }) +})) + +function openCodeGo(sessionUsedPercent: number, balance = 12.4): ProviderRateLimits { + return { + provider: 'opencode-go', + session: { + usedPercent: sessionUsedPercent, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + weekly: { usedPercent: 40, windowMinutes: 10080, resetsAt: null, resetDescription: null }, + extraUsage: { + balance, + unit: 'currency', + currencyCode: 'USD', + enabled: true, + disabledReason: null, + spent: null, + spendLimit: null, + spentPercent: null, + resetsAt: null + }, + updatedAt: Date.now(), + error: null, + status: 'ok' + } +} + +function claudeUnknownBalance(): ProviderRateLimits { + return { + provider: 'claude', + session: { usedPercent: 100, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: { usedPercent: 40, windowMinutes: 10080, resetsAt: null, resetDescription: null }, + extraUsage: { + balance: null, + unit: 'currency', + currencyCode: 'EUR', + enabled: true, + disabledReason: null, + spent: 50, + spendLimit: 2000, + spentPercent: 2.5, + resetsAt: null + }, + updatedAt: Date.now(), + error: null, + status: 'ok' + } +} + +function codexCredits(sessionUsedPercent: number): ProviderRateLimits { + return { + provider: 'codex', + session: { + usedPercent: sessionUsedPercent, + windowMinutes: 300, + resetsAt: null, + resetDescription: null + }, + weekly: { usedPercent: 40, windowMinutes: 10080, resetsAt: null, resetDescription: null }, + extraUsage: { + balance: 500, + unit: 'credits', + unlimited: false, + enabled: true, + disabledReason: null, + resetsAt: null + }, + updatedAt: Date.now(), + error: null, + status: 'ok' + } +} + +function claudeDisabledCredits(): ProviderRateLimits { + return { + provider: 'claude', + session: { usedPercent: 100, windowMinutes: 300, resetsAt: null, resetDescription: null }, + weekly: { usedPercent: 80, windowMinutes: 10080, resetsAt: null, resetDescription: null }, + extraUsage: { + balance: 0, + unit: 'currency', + currencyCode: 'EUR', + enabled: false, + disabledReason: 'out_of_credits', + spent: 0, + spendLimit: 2000, + spentPercent: 0, + resetsAt: null + }, + updatedAt: Date.now(), + error: null, + status: 'ok' + } +} + +describe('ProviderSegment extra-usage balance token', () => { + it.each([ + ['Spanish', 'saldo {{value0}}', 'saldo $12.40'], + ['French', 'solde {{value0}}', 'solde $12.40'] + ])('lets %s put the balance label before its amount', async (_locale, message, expected) => { + compactMessages.balance = message + const { ProviderSegment } = await import('./StatusBar') + const markup = renderToStaticMarkup( + + ) + expect(markup).toContain(expected) + expect(markup).not.toContain('{{value0}}') + }) + + it('reveals the remaining balance once a plan window is capped', async () => { + const { ProviderSegment } = await import('./StatusBar') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('$12.40') + expect(markup).toContain('bal') + }) + + it.each([0, -1.25])( + 'hides a depleted currency balance of %s from the compact segment', + async (balance) => { + const { ProviderSegment } = await import('./StatusBar') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).not.toContain('bal') + expect(markup).not.toContain('$0.00') + expect(markup).not.toContain('-$1.25') + } + ) + + it('hides the balance while plan windows still have headroom', async () => { + const { ProviderSegment } = await import('./StatusBar') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).not.toContain('$12.40') + expect(markup).not.toContain('bal') + }) + + it('does not reveal an unavailable balance as zero when a window is capped', async () => { + const { ProviderSegment } = await import('./StatusBar') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).not.toContain('€0.00') + expect(markup).not.toContain('bal') + }) + + it('does not reveal a disabled balance even when a window is capped', async () => { + const { ProviderSegment } = await import('./StatusBar') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).not.toContain('bal') + }) + + it('reveals a Codex credit count (not a currency amount) when a window caps', async () => { + const { ProviderSegment } = await import('./StatusBar') + + const markup = renderToStaticMarkup( + + ) + + expect(markup).toContain('500 credits') + expect(markup).not.toContain('$500') + expect(markup).not.toContain('bal') + }) +}) diff --git a/src/renderer/src/components/status-bar/tooltip.test.ts b/src/renderer/src/components/status-bar/tooltip.test.ts index f08f1e4a1eb..6f928745dbb 100644 --- a/src/renderer/src/components/status-bar/tooltip.test.ts +++ b/src/renderer/src/components/status-bar/tooltip.test.ts @@ -28,6 +28,7 @@ import { formatResetCreditExpiry, formatResetCountdown, getProviderUsageErrorMessage, + getExtraUsageLabel, getProviderUsageStatusLabel, getWindowSections, ProviderIcon, @@ -461,6 +462,277 @@ describe('getWindowSections', () => { }) }) +describe('getExtraUsageLabel', () => { + it('names the balance per provider', () => { + expect(getExtraUsageLabel('claude')).toBe('Usage credits') + expect(getExtraUsageLabel('opencode-go')).toBe('Zen balance') + expect(getExtraUsageLabel('codex')).toBe('Credits') + expect(getExtraUsageLabel('gemini')).toBe('Balance') + }) +}) + +describe('ProviderPanel extra-usage rendering', () => { + it('renders the Claude usage-credits cap as a spent/limit meter plus balance', () => { + const p = provider({ + status: 'ok', + session: { usedPercent: 100, windowMinutes: 300, resetsAt: null, resetDescription: null }, + extraUsage: { + balance: 10, + unit: 'currency', + currencyCode: 'EUR', + enabled: true, + disabledReason: null, + spent: 50, + spendLimit: 2000, + spentPercent: 2.5, + resetsAt: null + } + }) + + const markup = renderToStaticMarkup(createElement(ProviderPanel, { p })) + + expect(markup).toContain('Usage credits') + expect(markup).toContain('€2,000.00') + expect(markup).toContain('€50.00') + expect(markup).toContain('% used') + expect(markup).toContain('Balance €10.00') + }) + + it('renders a legacy spend cap without presenting an unavailable balance as zero', () => { + const p = provider({ + status: 'ok', + session: { usedPercent: 100, windowMinutes: 300, resetsAt: null, resetDescription: null }, + extraUsage: { + balance: null, + unit: 'currency', + currencyCode: 'EUR', + enabled: true, + disabledReason: null, + spent: 50, + spendLimit: 2000, + spentPercent: 2.5, + resetsAt: null + } + }) + + const markup = renderToStaticMarkup(createElement(ProviderPanel, { p })) + + expect(markup).toContain('Usage credits') + expect(markup).toContain('€50.00 / €2,000.00') + expect(markup).not.toContain('Balance €0.00') + }) + + it('renders a known legacy cap without inventing spend, percent, balance, or a meter', () => { + const p = provider({ + status: 'ok', + extraUsage: { + balance: null, + unit: 'currency', + currencyCode: 'EUR', + enabled: true, + disabledReason: null, + spent: null, + spendLimit: 2000, + spentPercent: null, + resetsAt: null + } + }) + + const markup = renderToStaticMarkup(createElement(ProviderPanel, { p })) + + expect(markup).toContain('Usage credits') + expect(markup).toContain('Limit €2,000.00') + expect(markup).not.toContain('€0.00') + expect(markup).not.toContain('% used') + expect(markup).not.toContain('h-[6px]') + }) + + it('keeps a known cap as a caption when spend is unknown even if a percentage is present', () => { + const p = provider({ + status: 'ok', + extraUsage: { + balance: null, + unit: 'currency', + currencyCode: 'EUR', + enabled: true, + disabledReason: null, + spent: null, + spendLimit: 2000, + spentPercent: 10, + resetsAt: null + } + }) + const markup = renderToStaticMarkup(createElement(ProviderPanel, { p })) + expect(markup).toContain('Limit €2,000.00') + expect(markup).not.toContain('€0.00') + expect(markup).not.toContain('% used') + expect(markup).not.toContain('h-[6px]') + }) + + it('renders a disabled, out-of-credits cap with a zero balance', () => { + const p = provider({ + status: 'ok', + session: { usedPercent: 100, windowMinutes: 300, resetsAt: null, resetDescription: null }, + extraUsage: { + balance: 0, + unit: 'currency', + currencyCode: 'EUR', + enabled: false, + disabledReason: 'out_of_credits', + spent: 0, + spendLimit: 2000, + spentPercent: 0, + resetsAt: null + } + }) + + const markup = renderToStaticMarkup(createElement(ProviderPanel, { p })) + + expect(markup).toContain('Usage credits') + expect(markup).toContain('€0.00 / €2,000.00') + expect(markup).toContain('Balance €0.00') + }) + + it('renders an uncapped OpenCode Go balance as a plain available amount', () => { + const p = provider({ + provider: 'opencode-go', + status: 'ok', + session: { usedPercent: 20, windowMinutes: 300, resetsAt: null, resetDescription: null }, + extraUsage: { + balance: 12.4, + unit: 'currency', + currencyCode: 'USD', + enabled: true, + disabledReason: null, + spent: null, + spendLimit: null, + spentPercent: null, + resetsAt: null + } + }) + + const markup = renderToStaticMarkup(createElement(ProviderPanel, { p })) + + expect(markup).toContain('Zen balance') + expect(markup).toContain('$12.40') + expect(markup).toContain('available') + }) + + it.each([ + [0, '$0.00'], + [-1.25, '-$1.25'] + ])('keeps a depleted Zen balance of %s visible in the popover', (balance, formatted) => { + const p = provider({ + provider: 'opencode-go', + status: 'ok', + session: { usedPercent: 100, windowMinutes: 300, resetsAt: null, resetDescription: null }, + extraUsage: { + balance, + unit: 'currency', + currencyCode: 'USD', + enabled: true, + disabledReason: null, + spent: null, + spendLimit: null, + spentPercent: null, + resetsAt: null + } + }) + + const markup = renderToStaticMarkup(createElement(ProviderPanel, { p })) + + expect(markup).toContain('Zen balance') + expect(markup).toContain(formatted) + expect(markup).toContain('available') + }) + + it.each([ + ['refresh-failed', 'Refresh failed'], + ['billing-unavailable', 'Unavailable'], + ['api-key-source', 'Balance is not included in this usage response.'] + ])('shows an unknown Zen balance honestly for %s', (disabledReason, message) => { + const p = provider({ + provider: 'opencode-go', + status: 'ok', + session: { usedPercent: 20, windowMinutes: 300, resetsAt: null, resetDescription: null }, + extraUsage: { + balance: null, + unit: 'currency', + currencyCode: 'USD', + enabled: false, + disabledReason, + spent: null, + spendLimit: null, + spentPercent: null, + resetsAt: null + } + }) + + const markup = renderToStaticMarkup(createElement(ProviderPanel, { p })) + + expect(markup).toContain('Zen balance') + expect(markup).toContain(message) + expect(markup).toContain('Session') + expect(markup).not.toContain('$0.00') + expect(markup).not.toMatch(/\$[\d,.]+ available/) + }) + + it('renders a Codex credit count as a plain "N credits available" line', () => { + const p = provider({ + provider: 'codex', + status: 'ok', + session: { usedPercent: 20, windowMinutes: 300, resetsAt: null, resetDescription: null }, + extraUsage: { + balance: 500, + unit: 'credits', + unlimited: false, + enabled: true, + disabledReason: null, + resetsAt: null + } + }) + + const markup = renderToStaticMarkup(createElement(ProviderPanel, { p })) + + expect(markup).toContain('Credits') + expect(markup).toContain('500 credits available') + // A credit count is not a currency amount. + expect(markup).not.toContain('$500') + }) + + it('renders unlimited Codex credits as "Unlimited"', () => { + const p = provider({ + provider: 'codex', + status: 'ok', + session: { usedPercent: 20, windowMinutes: 300, resetsAt: null, resetDescription: null }, + extraUsage: { + balance: 0, + unit: 'credits', + unlimited: true, + enabled: true, + disabledReason: null, + resetsAt: null + } + }) + + const markup = renderToStaticMarkup(createElement(ProviderPanel, { p })) + + expect(markup).toContain('Unlimited') + }) + + it('omits the balance row when no extra usage is reported', () => { + const p = provider({ + status: 'ok', + session: { usedPercent: 40, windowMinutes: 300, resetsAt: null, resetDescription: null } + }) + + const markup = renderToStaticMarkup(createElement(ProviderPanel, { p })) + + expect(markup).not.toContain('Usage credits') + expect(markup).not.toContain('Zen balance') + }) +}) + describe('ProviderPanel reset rendering', () => { it('renders the Fable reset countdown when Claude reports a reset timestamp', () => { vi.useFakeTimers() @@ -594,7 +866,7 @@ describe('barColor', () => { describe('ProviderIcon', () => { it('renders the Antigravity agent icon for the antigravity provider', () => { - const markup = renderToStaticMarkup(ProviderIcon({ provider: 'antigravity' })) + const markup = renderToStaticMarkup(createElement(ProviderIcon, { provider: 'antigravity' })) expect(markup).toContain('data-agent-icon="antigravity"') }) @@ -602,7 +874,7 @@ describe('ProviderIcon', () => { // Why: the icon must travel to the status bar / tooltip unchanged so the // user recognises the brand. We pin it to an with a non-empty // resource URL and aria-hidden so the icon stays purely decorative. - const markup = renderToStaticMarkup(ProviderIcon({ provider: 'minimax' })) + const markup = renderToStaticMarkup(createElement(ProviderIcon, { provider: 'minimax' })) expect(markup.startsWith(' ))} + {p.extraUsage ? ( + + ) : null} + {p.error ? ( { + it.each(Object.entries(catalogs))( + '%s omits copied English balance entries', + async (locale, { catalog }) => { + const instance = createInstance() + await instance.init({ lng: locale, resources: { [locale]: { translation: catalog } } }) + + for (const [suffix] of balanceMessages) { + expect(instance.getResource(locale, 'translation', balanceKey(suffix))).toBeUndefined() + } + } + ) + + it.each(Object.entries(catalogs))( + '%s falls back and interpolates balance copy at runtime', + async (locale, { catalog, compactBalanceMessage }) => { + const instance = createInstance() + await instance.init({ + lng: locale, + fallbackLng: 'en', + resources: { en: { translation: enRuntimeRequired }, [locale]: { translation: catalog } }, + interpolation: { escapeValue: false } + }) + + for (const [suffix, defaultValue] of balanceMessages) { + expect(instance.t(balanceKey(suffix), { defaultValue, value0: '12.50' })).toBe( + defaultValue.replace('{{value0}}', '12.50') + ) + } + expect( + instance.t(balanceKey('tooltip.87b5bda4d3'), { + defaultValue: '{{value0}} credits available', + value0: 500 + }) + ).toBe('500 credits available') + expect( + instance.t(balanceKey('StatusBar.4fba7dc1e7'), { + defaultValue: '{{value0}} bal', + value0: '12.50' + }) + ).toBe(compactBalanceMessage) + } + ) + + it('retains every English source balance message', async () => { + const instance = createInstance() + await instance.init({ lng: 'en', resources: { en: { translation: en } } }) + + for (const [suffix, value] of balanceMessages) { + expect(instance.getResource('en', 'translation', balanceKey(suffix))).toBe(value) + } + expect(instance.getResource('en', 'translation', balanceKey('StatusBar.4fba7dc1e7'))).toBe( + '{{value0}} bal' + ) + }) +}) diff --git a/src/shared/credit-count-format.test.ts b/src/shared/credit-count-format.test.ts new file mode 100644 index 00000000000..07ab4554252 --- /dev/null +++ b/src/shared/credit-count-format.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { formatCreditCount } from './credit-count-format' + +describe('formatCreditCount', () => { + it('uses the viewer locale for grouped credit counts', () => { + const expected = new Intl.NumberFormat(undefined, { maximumFractionDigits: 20 }).format(1234) + expect(formatCreditCount(1234)).toBe(expected) + }) + + it('normalizes invalid and negative counts', () => { + expect(formatCreditCount(Number.NaN)).toBe('0') + expect(formatCreditCount(-5)).toBe('0') + }) +}) diff --git a/src/shared/credit-count-format.ts b/src/shared/credit-count-format.ts new file mode 100644 index 00000000000..14cfde62bfb --- /dev/null +++ b/src/shared/credit-count-format.ts @@ -0,0 +1,6 @@ +const CREDIT_COUNT_FORMATTER = new Intl.NumberFormat(undefined, { maximumFractionDigits: 20 }) + +export function formatCreditCount(count: number): string { + const safeCount = Number.isFinite(count) ? Math.max(0, count) : 0 + return CREDIT_COUNT_FORMATTER.format(safeCount) +} diff --git a/src/shared/currency-format.test.ts b/src/shared/currency-format.test.ts new file mode 100644 index 00000000000..b0c5cd6f174 --- /dev/null +++ b/src/shared/currency-format.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest' +import { formatCurrencyAmount } from './currency-format' + +describe('formatCurrencyAmount', () => { + it('formats USD amounts with the dollar symbol', () => { + // Force a stable locale-independent expectation by asserting substrings. + const formatted = formatCurrencyAmount(12.4, 'USD') + expect(formatted).toMatch(/12[.,]40/) + expect(formatted).toContain('$') + }) + + it('formats EUR amounts with the euro symbol and thousands separator', () => { + const formatted = formatCurrencyAmount(2000, 'EUR') + expect(formatted).toContain('€') + expect(formatted).toMatch(/2[.,]000/) + }) + + it('falls back to code + amount for a malformed currency code', () => { + // A non-3-letter code makes Intl.NumberFormat throw, exercising the fallback. + expect(formatCurrencyAmount(9.5, 'ZZ')).toBe('ZZ 9.50') + }) + + it('defaults blank currency codes to USD', () => { + expect(formatCurrencyAmount(1, ' ')).toContain('$') + }) + + it('treats non-finite amounts as zero', () => { + expect(formatCurrencyAmount(Number.NaN, 'USD')).toMatch(/0[.,]00/) + }) +}) diff --git a/src/shared/currency-format.ts b/src/shared/currency-format.ts new file mode 100644 index 00000000000..c8779246986 --- /dev/null +++ b/src/shared/currency-format.ts @@ -0,0 +1,21 @@ +/** + * Format a major-unit amount (e.g. 12.4) as a localized currency string + * (e.g. "$12.40"). Shared by the usage popover and the compact status-bar + * balance token so both render identically. + * + * Why: `Intl.NumberFormat` throws on an unrecognized currency code (the code + * comes from a provider API we don't control), so fall back to a plain + * " " rendering rather than crashing the status bar. + */ +export function formatCurrencyAmount(amount: number, currencyCode: string): string { + const safeAmount = Number.isFinite(amount) ? amount : 0 + const code = currencyCode.trim() || 'USD' + try { + return new Intl.NumberFormat(undefined, { + style: 'currency', + currency: code + }).format(safeAmount) + } catch { + return `${code} ${safeAmount.toFixed(2)}` + } +} diff --git a/src/shared/rate-limit-types.ts b/src/shared/rate-limit-types.ts index 7f69b33264b..0a9abf64ab1 100644 --- a/src/shared/rate-limit-types.ts +++ b/src/shared/rate-limit-types.ts @@ -11,6 +11,32 @@ export type RateLimitWindow = { export type ProviderRateLimitStatus = 'idle' | 'fetching' | 'ok' | 'error' | 'unavailable' +type ExtraUsageBalanceBase = { + enabled: boolean + disabledReason: string | null + resetsAt: number | null +} + +type CurrencyExtraUsageBalance = ExtraUsageBalanceBase & { + unit: 'currency' + /** Null when the current balance is unavailable. */ + balance: number | null + currencyCode: string + spent: number | null + spendLimit: number | null + spentPercent: number | null +} + +type CreditExtraUsageBalance = ExtraUsageBalanceBase & { + unit: 'credits' + balance: number + unlimited: boolean +} + +// Why: currency and unitless credits have different metadata; the discriminator +// prevents consumers from inventing dummy currency or spend-limit values. +export type ExtraUsageBalance = CurrencyExtraUsageBalance | CreditExtraUsageBalance + export type RateLimitBucket = RateLimitWindow & { name: string } @@ -67,6 +93,8 @@ export type ProviderRateLimits = { fableWeekly?: RateLimitWindow | null /** 30-day monthly window (OpenCode Go, Grok unified billing, Cursor plan pools), null if not available. */ monthly?: RateLimitWindow | null + /** Overage / pay-as-you-go balance the plan spends into once its windows cap. */ + extraUsage?: ExtraUsageBalance | null /** Named per-model buckets (Gemini models, Cursor plan pools). */ buckets?: RateLimitBucket[] /** Available earned Codex rate-limit reset credits, if reported. */