fix(quota): coalesce provider usage refreshes
This commit is contained in:
@@ -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<Response>((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({
|
||||
|
||||
@@ -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<ProviderResult> => {
|
||||
});
|
||||
};
|
||||
|
||||
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<string, unknown>): ProviderUsage => {
|
||||
const windows: Record<string, UsageWindow> = {};
|
||||
const models: Record<string, ProviderUsage> = {};
|
||||
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<ProviderResult> => {
|
||||
const auth = readAuthFile();
|
||||
const entry = normalizeAuthEntry(getAuthEntry(auth, ['anthropic', 'claude'])) as Record<string, unknown> | null;
|
||||
@@ -1270,6 +1377,15 @@ const fetchClaudeQuota = async (): Promise<ProviderResult> => {
|
||||
});
|
||||
}
|
||||
|
||||
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<ProviderResult> => {
|
||||
},
|
||||
});
|
||||
|
||||
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<ProviderResult> => {
|
||||
}
|
||||
|
||||
const payload = await response.json() as Record<string, unknown>;
|
||||
const windows: Record<string, UsageWindow> = {};
|
||||
const fiveHour = (payload as Record<string, unknown>).five_hour as Record<string, unknown> | undefined;
|
||||
const sevenDay = (payload as Record<string, unknown>).seven_day as Record<string, unknown> | undefined;
|
||||
const sevenDaySonnet = (payload as Record<string, unknown>).seven_day_sonnet as Record<string, unknown> | undefined;
|
||||
const sevenDayOpus = (payload as Record<string, unknown>).seven_day_opus as Record<string, unknown> | 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<ProviderResult> => {
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchQuotaForProvider = async (providerId: string): Promise<ProviderResult> => {
|
||||
const fetchQuotaForProviderUncoalesced = async (providerId: string): Promise<ProviderResult> => {
|
||||
switch (providerId) {
|
||||
case 'claude':
|
||||
return fetchClaudeQuota();
|
||||
@@ -2782,3 +2880,16 @@ export const fetchQuotaForProvider = async (providerId: string): Promise<Provide
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const pendingQuotaFetches = new Map<string, Promise<ProviderResult>>();
|
||||
|
||||
export const fetchQuotaForProvider = (providerId: string): Promise<ProviderResult> => {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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:
|
||||
|
||||
Binary file not shown.
@@ -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))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user