diff --git a/packages/vscode/src/quotaProviders.test.ts b/packages/vscode/src/quotaProviders.test.ts index 66be257b..2309ea6d 100644 --- a/packages/vscode/src/quotaProviders.test.ts +++ b/packages/vscode/src/quotaProviders.test.ts @@ -20,6 +20,7 @@ const AUTH = JSON.stringify({ 'command-code': { type: 'oauth', access: 'test-token' }, 'zai-coding-plan': { key: 'test-token' }, deepseek: { key: 'test-token' }, + anthropic: { access: 'test-token', refresh: 'test-refresh' }, }); ((fs as unknown) as { existsSync: () => boolean }).existsSync = () => true; ((fs as unknown) as { readFileSync: () => string }).readFileSync = () => AUTH; @@ -202,6 +203,27 @@ describe('Crof quota provider (VS Code parity)', () => { }); describe('Codex quota provider (VS Code parity)', () => { + test('coalesces concurrent refreshes for the same provider', async () => { + let resolveResponse: ((response: Response) => void) | undefined; + let requestCount = 0; + globalThis.fetch = (() => { + requestCount += 1; + return new Promise((resolve) => { + resolveResponse = resolve; + }); + }) as typeof fetch; + + const first = fetchQuotaForProvider('codex'); + const second = fetchQuotaForProvider('codex'); + resolveResponse?.(mockResponse({ rate_limit: null })); + + const [firstResult, secondResult] = await Promise.all([first, second]); + + assert.equal(firstResult.ok, true); + assert.equal(secondResult.ok, true); + assert.equal(requestCount, 1); + }); + test('surfaces spend_control individual limit for business accounts', async () => { stubFetchReturning(() => Promise.resolve(mockResponse({ plan_type: 'business', @@ -226,6 +248,60 @@ describe('Codex quota provider (VS Code parity)', () => { }); }); +describe('Claude quota provider (VS Code parity)', () => { + test('parses current limits, model-scoped limits, and extra usage', async () => { + stubFetchReturning(() => Promise.resolve(mockResponse({ + limits: [ + { kind: 'session', percent: 12, resets_at: '2026-08-20T12:00:00Z', scope: null }, + { kind: 'weekly_all', percent: 34, resets_at: '2026-08-24T12:00:00Z', scope: null }, + { kind: 'weekly_scoped', percent: 56, resets_at: '2026-08-24T12:00:00Z', scope: { model: { display_name: 'Sonnet' } } }, + ], + spend: { + enabled: true, + percent: 25, + used: { amount_minor: 2500, exponent: 2, currency: 'USD' }, + limit: { amount_minor: 10000, exponent: 2, currency: 'USD' }, + }, + }))); + + const result = await fetchQuotaForProvider('claude'); + + assert.equal(result.ok, true); + assert.equal(result.usage?.windows['5h']?.usedPercent, 12); + assert.equal(result.usage?.windows['7d']?.usedPercent, 34); + assert.equal(result.usage?.models?.Sonnet?.windows['7d']?.usedPercent, 56); + assert.equal(result.usage?.windows.extra_usage?.valueLabel, '$25.00 / $100.00'); + }); + + test('keeps serving the last good values while Anthropic rate limits', async () => { + const responses = [ + mockResponse({ five_hour: { utilization: 12, resets_at: '2026-08-20T12:00:00Z' } }), + { + ok: false, + status: 429, + headers: new Headers({ 'retry-after': '120' }), + json: async () => ({}), + } as Response, + ]; + let requestCount = 0; + globalThis.fetch = (async () => { + const response = responses[requestCount]; + requestCount += 1; + return response; + }) as typeof fetch; + + const initial = await fetchQuotaForProvider('claude'); + const rateLimited = await fetchQuotaForProvider('claude'); + const duringCooldown = await fetchQuotaForProvider('claude'); + + assert.equal(initial.ok, true); + assert.equal(rateLimited.ok, true); + assert.equal(duringCooldown.ok, true); + assert.equal(duringCooldown.usage?.windows['5h']?.usedPercent, 12); + assert.equal(requestCount, 2); + }); +}); + describe('Z.ai quota provider (VS Code parity)', () => { test('surfaces 5-hour, weekly, and MCP quota windows', async () => { stubFetchReturning(() => Promise.resolve(mockResponse({ diff --git a/packages/vscode/src/quotaProviders.ts b/packages/vscode/src/quotaProviders.ts index e25c5f70..a33bf6b6 100644 --- a/packages/vscode/src/quotaProviders.ts +++ b/packages/vscode/src/quotaProviders.ts @@ -170,6 +170,7 @@ export type ProviderResult = { usage: ProviderUsage | null; fetchedAt: number; error?: string; + planLabel?: string | null; }; const OPENCODE_CONFIG_DIR = path.join(os.homedir(), '.config', 'opencode'); @@ -1255,6 +1256,112 @@ const fetchGoogleQuota = async (): Promise => { }); }; +const CLAUDE_DEFAULT_COOLDOWN_MS = 5 * 60 * 1000; +const CLAUDE_MAX_COOLDOWN_MS = 60 * 60 * 1000; +let claudeCredentialFingerprint: string | null = null; +let claudeCachedUsage: ProviderUsage | null = null; +let claudeCooldownUntil = 0; + +const claudeCooldownFromResponse = (response: Response): number => { + const raw = response.headers.get('retry-after'); + const seconds = raw ? Number(raw) : Number.NaN; + if (Number.isFinite(seconds) && seconds > 0) { + return Math.min(seconds * 1000, CLAUDE_MAX_COOLDOWN_MS); + } + if (raw) { + const retryAt = Date.parse(raw); + if (Number.isFinite(retryAt) && retryAt > Date.now()) { + return Math.min(retryAt - Date.now(), CLAUDE_MAX_COOLDOWN_MS); + } + } + return CLAUDE_DEFAULT_COOLDOWN_MS; +}; + +const buildClaudeRateLimitResult = (): ProviderResult => ( + claudeCachedUsage + ? buildResult({ + providerId: 'claude', + providerName: 'Claude', + ok: true, + configured: true, + usage: claudeCachedUsage, + }) + : buildResult({ + providerId: 'claude', + providerName: 'Claude', + ok: false, + configured: true, + error: 'Rate limited by Anthropic. Retrying shortly.', + }) +); + +const buildClaudeUsage = (payload: Record): ProviderUsage => { + const windows: Record = {}; + const models: Record = {}; + const limits = Array.isArray(payload.limits) ? payload.limits : []; + + for (const entry of limits) { + const limit = asObject(entry); + if (!limit) continue; + const usedPercent = toNumber(limit.percent); + const resetAt = toTimestamp(limit.resets_at); + if (limit.kind === 'session') { + windows['5h'] = toUsageWindow({ usedPercent, windowSeconds: 5 * 60 * 60, resetAt }); + } else if (limit.kind === 'weekly_all') { + windows['7d'] = toUsageWindow({ usedPercent, windowSeconds: 7 * 24 * 60 * 60, resetAt }); + } else if (limit.kind === 'weekly_scoped') { + const modelName = asNonEmptyString(asObject(asObject(limit.scope)?.model)?.display_name); + if (modelName) { + models[modelName] = { + windows: { + '7d': toUsageWindow({ usedPercent, windowSeconds: 7 * 24 * 60 * 60, resetAt }), + }, + }; + } + } + } + + if (!limits.length) { + const fiveHour = asObject(payload.five_hour); + const sevenDay = asObject(payload.seven_day); + if (fiveHour) { + windows['5h'] = toUsageWindow({ + usedPercent: toNumber(fiveHour.utilization), + windowSeconds: 5 * 60 * 60, + resetAt: toTimestamp(fiveHour.resets_at), + }); + } + if (sevenDay) { + windows['7d'] = toUsageWindow({ + usedPercent: toNumber(sevenDay.utilization), + windowSeconds: 7 * 24 * 60 * 60, + resetAt: toTimestamp(sevenDay.resets_at), + }); + } + } + + const spend = asObject(payload.spend); + if (spend?.enabled === true) { + const usedMoney = asObject(spend.used); + const limitMoney = asObject(spend.limit); + const usedMinor = toNumber(usedMoney?.amount_minor); + const limitMinor = toNumber(limitMoney?.amount_minor); + const exponent = toNumber(usedMoney?.exponent) ?? 2; + const currency = asNonEmptyString(usedMoney?.currency); + const prefix = currency === 'USD' || !currency ? '$' : `${currency} `; + const used = usedMinor === null ? null : usedMinor / 10 ** exponent; + const limit = limitMinor === null ? null : limitMinor / 10 ** (toNumber(limitMoney?.exponent) ?? 2); + windows.extra_usage = toUsageWindow({ + usedPercent: toNumber(spend.percent), + windowSeconds: null, + resetAt: null, + valueLabel: used === null ? null : `${prefix}${formatMoney(used)}${limit === null ? '' : ` / ${prefix}${formatMoney(limit)}`}`, + }); + } + + return Object.keys(models).length ? { windows, models } : { windows }; +}; + const fetchClaudeQuota = async (): Promise => { const auth = readAuthFile(); const entry = normalizeAuthEntry(getAuthEntry(auth, ['anthropic', 'claude'])) as Record | null; @@ -1270,6 +1377,15 @@ const fetchClaudeQuota = async (): Promise => { }); } + const refreshToken = typeof entry?.refresh === 'string' ? entry.refresh : ''; + const fingerprint = `${accessToken}\0${refreshToken}`; + if (claudeCredentialFingerprint !== fingerprint) { + claudeCredentialFingerprint = fingerprint; + claudeCachedUsage = null; + claudeCooldownUntil = 0; + } + if (Date.now() < claudeCooldownUntil) return buildClaudeRateLimitResult(); + try { const response = await fetch('https://api.anthropic.com/api/oauth/usage', { method: 'GET', @@ -1279,6 +1395,21 @@ const fetchClaudeQuota = async (): Promise => { }, }); + if (response.status === 429) { + claudeCooldownUntil = Date.now() + claudeCooldownFromResponse(response); + return buildClaudeRateLimitResult(); + } + + if (response.status === 401 || response.status === 403) { + return buildResult({ + providerId: 'claude', + providerName: 'Claude', + ok: false, + configured: true, + error: 'Claude session expired. Open Claude Code to sign in again.', + }); + } + if (!response.ok) { return buildResult({ providerId: 'claude', @@ -1290,47 +1421,14 @@ const fetchClaudeQuota = async (): Promise => { } const payload = await response.json() as Record; - const windows: Record = {}; - const fiveHour = (payload as Record).five_hour as Record | undefined; - const sevenDay = (payload as Record).seven_day as Record | undefined; - const sevenDaySonnet = (payload as Record).seven_day_sonnet as Record | undefined; - const sevenDayOpus = (payload as Record).seven_day_opus as Record | undefined; - - if (fiveHour) { - windows['5h'] = toUsageWindow({ - usedPercent: toNumber(fiveHour.utilization), - windowSeconds: null, - resetAt: toTimestamp(fiveHour.resets_at), - }); - } - if (sevenDay) { - windows['7d'] = toUsageWindow({ - usedPercent: toNumber(sevenDay.utilization), - windowSeconds: null, - resetAt: toTimestamp(sevenDay.resets_at), - }); - } - if (sevenDaySonnet) { - windows['7d-sonnet'] = toUsageWindow({ - usedPercent: toNumber(sevenDaySonnet.utilization), - windowSeconds: null, - resetAt: toTimestamp(sevenDaySonnet.resets_at), - }); - } - if (sevenDayOpus) { - windows['7d-opus'] = toUsageWindow({ - usedPercent: toNumber(sevenDayOpus.utilization), - windowSeconds: null, - resetAt: toTimestamp(sevenDayOpus.resets_at), - }); - } - + const usage = buildClaudeUsage(payload); + claudeCachedUsage = usage; return buildResult({ providerId: 'claude', providerName: 'Claude', ok: true, configured: true, - usage: { windows }, + usage, }); } catch (error) { return buildResult({ @@ -2709,7 +2807,7 @@ const fetchXaiQuota = async (): Promise => { } }; -export const fetchQuotaForProvider = async (providerId: string): Promise => { +const fetchQuotaForProviderUncoalesced = async (providerId: string): Promise => { switch (providerId) { case 'claude': return fetchClaudeQuota(); @@ -2782,3 +2880,16 @@ export const fetchQuotaForProvider = async (providerId: string): Promise>(); + +export const fetchQuotaForProvider = (providerId: string): Promise => { + const existing = pendingQuotaFetches.get(providerId); + if (existing) return existing; + + const pending = fetchQuotaForProviderUncoalesced(providerId).finally(() => { + if (pendingQuotaFetches.get(providerId) === pending) pendingQuotaFetches.delete(providerId); + }); + pendingQuotaFetches.set(providerId, pending); + return pending; +}; diff --git a/packages/web/server/lib/quota/DOCUMENTATION.md b/packages/web/server/lib/quota/DOCUMENTATION.md index 38c1589e..78d86706 100644 --- a/packages/web/server/lib/quota/DOCUMENTATION.md +++ b/packages/web/server/lib/quota/DOCUMENTATION.md @@ -66,6 +66,7 @@ Claude quota reports the subscription limits Claude Code itself is bound by, rea - **Limits come from the `limits` array**, keyed by `kind`: `session` maps to the `5h` window, `weekly_all` to `7d`, and `weekly_scoped` to a per-model `7d` window named by `scope.model.display_name`. The legacy `five_hour`/`seven_day` fields are only a fallback; `seven_day_sonnet`/`seven_day_opus` are no longer populated by Anthropic. Unrecognized limit kinds and Anthropic's rotating internal code names (`nimbus_quill`, `tangelo`, ...) are ignored rather than guessed at. - **Extra usage** is reported as the `extra_usage` window from `spend`, only while `spend.enabled` is true, with a money `valueLabel`. - **Rate limiting**: Anthropic returns 429 aggressively. The last successful usage payload is cached in memory and reserved during a cooldown (`Retry-After`, else five minutes, capped at one hour). The cache is keyed by a hash of the access and refresh tokens, so switching accounts drops it instead of showing the previous account's numbers. +- **Runtime parity**: Web/Electron and VS Code preserve the last successful Claude values during the same bounded 429 cooldown. Quota dispatchers also coalesce concurrent refreshes for the same provider in each runtime, while requests for different providers remain parallel. ## Add a new provider (quick steps) 1. Choose module shape based on complexity: diff --git a/packages/web/server/lib/quota/providers/claude/index.js b/packages/web/server/lib/quota/providers/claude/index.js index 79e82068..8ee0d677 100644 Binary files a/packages/web/server/lib/quota/providers/claude/index.js and b/packages/web/server/lib/quota/providers/claude/index.js differ diff --git a/packages/web/server/lib/quota/providers/claude/index.test.js b/packages/web/server/lib/quota/providers/claude/index.test.js index d22fe769..77ef029f 100644 --- a/packages/web/server/lib/quota/providers/claude/index.test.js +++ b/packages/web/server/lib/quota/providers/claude/index.test.js @@ -67,6 +67,24 @@ describe('Claude quota provider', () => { expect(result.usage.windows['5h'].usedPercent).toBe(5); }); + it('coalesces concurrent refreshes into one Anthropic request', async () => { + let resolveResponse; + const fetchMock = vi.fn().mockReturnValue(new Promise((resolve) => { + resolveResponse = resolve; + })); + vi.stubGlobal('fetch', fetchMock); + + const first = fetchQuota(); + const second = fetchQuota(); + resolveResponse(jsonResponse(PAYLOAD)); + + const [firstResult, secondResult] = await Promise.all([first, second]); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(firstResult.ok).toBe(true); + expect(secondResult.ok).toBe(true); + }); + it('keeps serving the last good values while Anthropic rate limits', async () => { const fetchMock = vi.fn() .mockResolvedValueOnce(jsonResponse(PAYLOAD)) diff --git a/packages/web/server/lib/quota/providers/index.js b/packages/web/server/lib/quota/providers/index.js index 789f3163..1ce58558 100644 --- a/packages/web/server/lib/quota/providers/index.js +++ b/packages/web/server/lib/quota/providers/index.js @@ -158,6 +158,8 @@ const registry = { } }; +const pendingFetches = new Map(); + export const listConfiguredQuotaProviders = () => { const configured = []; @@ -174,7 +176,7 @@ export const listConfiguredQuotaProviders = () => { return configured; }; -export const fetchQuotaForProvider = async (providerId) => { +const fetchQuotaForProviderUncoalesced = async (providerId) => { const provider = registry[providerId]; if (!provider) { @@ -200,6 +202,17 @@ export const fetchQuotaForProvider = async (providerId) => { } }; +export const fetchQuotaForProvider = (providerId) => { + const existing = pendingFetches.get(providerId); + if (existing) return existing; + + const pending = fetchQuotaForProviderUncoalesced(providerId).finally(() => { + if (pendingFetches.get(providerId) === pending) pendingFetches.delete(providerId); + }); + pendingFetches.set(providerId, pending); + return pending; +}; + export const fetchClaudeQuota = claude.fetchQuota; export const fetchOpenaiQuota = openai.fetchQuota; export const fetchGoogleQuota = google.fetchGoogleQuota; diff --git a/packages/web/server/lib/quota/providers/index.test.js b/packages/web/server/lib/quota/providers/index.test.js index b95cfb8a..8ccb097e 100644 --- a/packages/web/server/lib/quota/providers/index.test.js +++ b/packages/web/server/lib/quota/providers/index.test.js @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest'; import * as google from './google/index.js'; -import { listConfiguredQuotaProviders } from './index.js'; +import { fetchQuotaForProvider, listConfiguredQuotaProviders } from './index.js'; describe('quota provider registry', () => { it('exposes google provider configuration helpers through the provider module', () => { @@ -14,4 +14,13 @@ describe('quota provider registry', () => { it('can list configured providers without missing provider exports', () => { expect(() => listConfiguredQuotaProviders()).not.toThrow(); }); + + it('coalesces concurrent refreshes by provider ID', async () => { + const first = fetchQuotaForProvider('unsupported-test-provider'); + const second = fetchQuotaForProvider('unsupported-test-provider'); + + expect(first).toBe(second); + await first; + expect(fetchQuotaForProvider('unsupported-test-provider')).not.toBe(first); + }); });