mirror of
https://github.com/stablyai/orca.git
synced 2026-10-07 16:02:29 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -3710,6 +3710,7 @@
|
||||
"grokUsageAria": "Grok の使用状況詳細を開く",
|
||||
"grokUsageMenu": "Grok の使用状況",
|
||||
"floatingTerminalNewActivity": "{{label}}、新規アクティビティ",
|
||||
"4fba7dc1e7": "残高 {{value0}}",
|
||||
"codexSignInSuccess": "Codex にサインインしました",
|
||||
"codexSignInError": "Codex のサインインに失敗しました。もう一度お試しください。",
|
||||
"cursorUsageMenu": "Cursor の使用状況",
|
||||
|
||||
@@ -3710,6 +3710,7 @@
|
||||
"grokUsageAria": "Grok 사용량 세부 정보 열기",
|
||||
"grokUsageMenu": "Grok 사용량",
|
||||
"floatingTerminalNewActivity": "{{label}}, 새 활동",
|
||||
"4fba7dc1e7": "잔액 {{value0}}",
|
||||
"codexSignInSuccess": "Codex에 로그인했습니다",
|
||||
"codexSignInError": "Codex 로그인에 실패했습니다. 다시 시도해 주세요.",
|
||||
"cursorUsageMenu": "Cursor 사용량",
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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)}`
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user