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>
This commit is contained in:
Neil
2026-10-05 00:07:23 -07:00
committed by GitHub
co-authored by mmarabel
parent a154562a89
commit 2b0ce17514
32 changed files with 2013 additions and 35 deletions
@@ -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<string, number>([
// 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],
@@ -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()
}
)
})
@@ -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'
@@ -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
}
}
+3 -1
View File
@@ -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
}
}
@@ -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<CodexRateLimitWindowsSnapshot['credits']>
// 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
}
}
@@ -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 })
})
})
@@ -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 }
@@ -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'
@@ -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('<html></html>')).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(
@@ -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
@@ -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<Response>((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<Response>((_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<Response>((_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,
@@ -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<ExtraUsageBalance> => {
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/<id>/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<typeof startOptionalBillingFetch> | 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,
@@ -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 () => {
@@ -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',
@@ -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 (
<span className="inline-flex items-center gap-1.5">
@@ -339,6 +379,12 @@ export function ProviderSegment({
showLabel={!compact}
/>
) : null}
{showBalance && p.extraUsage ? (
<>
<span className="text-muted-foreground">·</span>
<span className="tabular-nums">{formatCompactExtraUsage(p.extraUsage)}</span>
</>
) : null}
{isStale && <AlertTriangle size={11} className="text-muted-foreground/80" />}
</span>
)
@@ -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 (
<div className="space-y-1">
<div className={`font-medium ${textClass}`}>{label}</div>
<div className={mutedClass}>{renderUncappedBalanceLine(balance)}</div>
</div>
)
}
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 (
<div className="space-y-1">
<div className={`font-medium ${textClass}`}>{label}</div>
<div className={mutedClass}>{status}</div>
{balance.disabledReason === 'api-key-source' ? (
<div className={faintClass}>
{translate(
'auto.components.status.bar.provider.extra.usage.section.apiKeyBalanceUnavailable',
'Balance is not included in this usage response.'
)}
</div>
) : null}
</div>
)
}
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 (
<div className="space-y-1">
<div className={`font-medium ${textClass}`}>{label}</div>
<div className={mutedClass}>
{balanceText === null ? limitLabel : renderUncappedBalanceLine(balance)}
</div>
</div>
)
}
const spentPct = clampUsedPercent(balance.spentPercent ?? 0)
const spent = formatCurrencyAmount(balance.spent ?? 0, balance.currencyCode)
const limit = formatCurrencyAmount(balance.spendLimit ?? 0, balance.currencyCode)
return (
<div className="space-y-1">
<div className={`font-medium ${textClass}`}>{label}</div>
<div className={`h-[6px] w-full overflow-hidden rounded-full ${emptyBarClass}`}>
<div
className={`h-full rounded-full ${barColor(spentPct)} transition-all duration-300`}
style={{ width: `${spentPct}%` }}
/>
</div>
<div className={`flex justify-between ${mutedClass}`}>
<span>{`${spent} / ${limit}`}</span>
<span>{formatUsagePercentageLabel(spentPct, usagePercentageDisplay)}</span>
</div>
{balanceText !== null ? (
<div className={faintClass}>
{translate('auto.components.status.bar.tooltip.473d45cd0f', 'Balance {{value0}}', {
value0: balanceText
})}
</div>
) : null}
</div>
)
}
@@ -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<string, string>) => {
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(
<ProviderSegment p={openCodeGo(100)} compact={false} display="used" />
)
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(
<ProviderSegment p={openCodeGo(100)} compact={false} display="used" />
)
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(
<ProviderSegment p={openCodeGo(100, balance)} compact={false} display="used" />
)
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(
<ProviderSegment p={openCodeGo(20)} compact={false} display="used" />
)
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(
<ProviderSegment p={claudeUnknownBalance()} compact={false} display="used" />
)
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(
<ProviderSegment p={claudeDisabledCredits()} compact={false} display="used" />
)
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(
<ProviderSegment p={codexCredits(100)} compact={false} display="used" />
)
expect(markup).toContain('500 credits')
expect(markup).not.toContain('$500')
expect(markup).not.toContain('bal')
})
})
@@ -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 <img> 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('<img')).toBe(true)
expect(markup).toContain('aria-hidden="true"')
expect(markup).toMatch(/src="[^"]+"/)
@@ -18,6 +18,14 @@ import {
} from '../../../../shared/usage-percentage-display'
import { formatUsagePercentageLabel } from './usage-percentage-label'
import { useResetCountdownClock } from '@/hooks/useResetCountdownClock'
import { barColor, ProviderExtraUsageSection } from './provider-extra-usage-section'
export {
barColor,
getExtraUsageLabel,
USAGE_URGENT_PERCENT,
USAGE_WARNING_PERCENT
} from './provider-extra-usage-section'
// Re-exported from its shared home so status-bar callers keep a single import.
export { clampUsedPercent }
@@ -208,21 +216,6 @@ export function getWindowSections(
// `text-background` for primary text and `text-background/50` for secondary
// to stay readable inside the inverted tooltip container.
// Why: urgency color tracks % used even when fill represents % remaining;
// low usage stays neutral so persistent chrome stays quiet.
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'
}
function ProviderRateLimitWindowSection({
window,
label,
@@ -378,6 +371,18 @@ export function ProviderPanel({
/>
))}
{p.extraUsage ? (
<ProviderExtraUsageSection
balance={p.extraUsage}
provider={p.provider}
textClass={textClass}
mutedClass={mutedClass}
faintClass={faintClass}
emptyBarClass={emptyBarClass}
usagePercentageDisplay={usagePercentageDisplay}
/>
) : null}
{p.error ? (
<ErrorMessage
message={p.error}
+21
View File
@@ -3882,6 +3882,9 @@
"grokUsageAria": "Open Grok usage details",
"grokUsageMenu": "Grok Usage",
"floatingTerminalNewActivity": "{{label}}, new activity",
"4fba7dc1e7": "{{value0}} bal",
"4025a6f62f": "Unlimited",
"a95969101f": "{{value0}} credits",
"codexSignInSuccess": "Signed in to Codex",
"codexSignInError": "Codex sign-in failed. Please try again.",
"cursorUsageMenu": "Cursor Usage",
@@ -4032,6 +4035,16 @@
}
}
},
"provider": {
"extra": {
"usage": {
"section": {
"135d51c19f": "Limit {{value0}}",
"apiKeyBalanceUnavailable": "Balance is not included in this usage response."
}
}
}
},
"tooltip": {
"zcode": {
"mcp": "MCP"
@@ -4069,6 +4082,14 @@
"d1b7f509ac": "Run grok in a terminal on the computer running Orca and wait for it to start. If prompted, complete sign-in, then retry usage. You do not need to send a chat message.",
"f90b3d7a16": "Run Kimi to refresh",
"a37e8c15d4": "Run kimi in a terminal on the computer running Orca and wait for it to start, then retry usage.",
"fbc80d8be2": "Zen balance",
"c03c61f53f": "Balance",
"f6a27a3c0a": "{{value0}} available",
"7404abbece": "Usage credits",
"473d45cd0f": "Balance {{value0}}",
"f21b2ba897": "Credits",
"56c0d70577": "Unlimited",
"87b5bda4d3": "{{value0}} credits available",
"minimax": {
"expired": {
"label": "Sign-in expired",
+1
View File
@@ -3710,6 +3710,7 @@
"grokUsageAria": "Abrir detalles de uso de Grok",
"grokUsageMenu": "Uso de Grok",
"floatingTerminalNewActivity": "{{label}}, nueva actividad",
"4fba7dc1e7": "saldo {{value0}}",
"codexSignInSuccess": "Sesión iniciada en Codex",
"codexSignInError": "No se pudo iniciar sesión en Codex. Inténtalo de nuevo.",
"cursorUsageMenu": "Uso de Cursor",
+1
View File
@@ -3805,6 +3805,7 @@
"grokUsageAria": "Ouvrir les détails d'utilisation Grok",
"grokUsageMenu": "Utilisation Grok",
"floatingTerminalNewActivity": "{{label}}, nouvelle activité",
"4fba7dc1e7": "solde {{value0}}",
"codexSignInSuccess": "Connecté à Codex",
"codexSignInError": "Échec de la connexion à Codex. Veuillez réessayer.",
"cursorUsageMenu": "Utilisation Cursor",
+1
View File
@@ -3710,6 +3710,7 @@
"grokUsageAria": "Grok の使用状況詳細を開く",
"grokUsageMenu": "Grok の使用状況",
"floatingTerminalNewActivity": "{{label}}、新規アクティビティ",
"4fba7dc1e7": "残高 {{value0}}",
"codexSignInSuccess": "Codex にサインインしました",
"codexSignInError": "Codex のサインインに失敗しました。もう一度お試しください。",
"cursorUsageMenu": "Cursor の使用状況",
+1
View File
@@ -3710,6 +3710,7 @@
"grokUsageAria": "Grok 사용량 세부 정보 열기",
"grokUsageMenu": "Grok 사용량",
"floatingTerminalNewActivity": "{{label}}, 새 활동",
"4fba7dc1e7": "잔액 {{value0}}",
"codexSignInSuccess": "Codex에 로그인했습니다",
"codexSignInError": "Codex 로그인에 실패했습니다. 다시 시도해 주세요.",
"cursorUsageMenu": "Cursor 사용량",
+1
View File
@@ -3710,6 +3710,7 @@
"grokUsageAria": "打开 Grok 使用详情",
"grokUsageMenu": "Grok 使用情况",
"floatingTerminalNewActivity": "{{label}},有新活动",
"4fba7dc1e7": "余额 {{value0}}",
"codexSignInSuccess": "已登录 Codex",
"codexSignInError": "Codex 登录失败,请重试。",
"cursorUsageMenu": "Cursor 用量",
@@ -0,0 +1,95 @@
import { createInstance } from 'i18next'
import { describe, expect, it } from 'vitest'
import en from './locales/en.json'
import enRuntimeRequired from './en-runtime-required.json'
import es from './locales/es.json'
import fr from './locales/fr.json'
import ja from './locales/ja.json'
import ko from './locales/ko.json'
import zh from './locales/zh.json'
const catalogs = {
es: { catalog: es, compactBalanceMessage: 'saldo 12.50' },
fr: { catalog: fr, compactBalanceMessage: 'solde 12.50' },
ja: { catalog: ja, compactBalanceMessage: '残高 12.50' },
ko: { catalog: ko, compactBalanceMessage: '잔액 12.50' },
zh: { catalog: zh, compactBalanceMessage: '余额 12.50' }
}
const balanceMessages = [
['StatusBar.4025a6f62f', 'Unlimited'],
['StatusBar.a95969101f', '{{value0}} credits'],
['tooltip.fbc80d8be2', 'Zen balance'],
['tooltip.c03c61f53f', 'Balance'],
['tooltip.f6a27a3c0a', '{{value0}} available'],
['tooltip.7404abbece', 'Usage credits'],
['tooltip.473d45cd0f', 'Balance {{value0}}'],
['tooltip.f21b2ba897', 'Credits'],
['tooltip.56c0d70577', 'Unlimited'],
['tooltip.87b5bda4d3', '{{value0}} credits available'],
[
'provider.extra.usage.section.apiKeyBalanceUnavailable',
'Balance is not included in this usage response.'
]
] as const
function balanceKey(suffix: string): string {
return `auto.components.status.bar.${suffix}`
}
describe('provider credit balance sparse target catalogs', () => {
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'
)
})
})
+14
View File
@@ -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')
})
})
+6
View File
@@ -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)
}
+30
View File
@@ -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/)
})
})
+21
View File
@@ -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
* "<CODE> <amount>" 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)}`
}
}
+28
View File
@@ -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. */