From 050b41bb554a90b5938be1e081efc033cb390f3e Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 14 Apr 2026 23:09:18 -0400 Subject: [PATCH] fix(cliproxy): harden Claude OAuth quota window parsing --- .../quota-fetcher-claude-normalizer.ts | 27 +++++++++++-------- .../account-safety-quota-exhaustion.test.ts | 13 ++------- .../cliproxy/quota-fetcher-claude.test.ts | 14 ++++++++++ 3 files changed, 32 insertions(+), 22 deletions(-) diff --git a/src/cliproxy/quota-fetcher-claude-normalizer.ts b/src/cliproxy/quota-fetcher-claude-normalizer.ts index 527fec35..a6cdda19 100644 --- a/src/cliproxy/quota-fetcher-claude-normalizer.ts +++ b/src/cliproxy/quota-fetcher-claude-normalizer.ts @@ -74,7 +74,14 @@ function getClaudeWindowLabel(rateLimitType: string): string { case 'overage': return 'Extra usage'; default: - return rateLimitType || 'Unknown limit'; + if (!rateLimitType) return 'Unknown limit'; + return rateLimitType + .split(/[_-]+/g) + .filter((part) => part.length > 0) + .map((part) => + /^\d+$/.test(part) ? part : `${part.charAt(0).toUpperCase()}${part.slice(1)}` + ) + .join(' '); } } @@ -188,15 +195,13 @@ function normalizeRestriction( * Supports both policy-limits `restrictions` payloads and OAuth usage payloads * keyed by window name (`five_hour`, `seven_day`, `seven_day_sonnet`, ...). */ -const CLAUDE_USAGE_WINDOW_KEYS = new Set([ - 'five_hour', - 'seven_day', - 'seven_day_opus', - 'seven_day_sonnet', - 'seven_day_oauth_apps', - 'seven_day_cowork', - 'iguana_necktie', -]); +function isClaudeOAuthUsageWindowCandidate(key: string, raw: Record): boolean { + if (key === 'extra_usage') return false; + if (asNumber(raw['utilization']) === null) return false; + + const resetAt = raw['resetsAt'] ?? raw['resets_at'] ?? raw['resetAt'] ?? raw['reset_at'] ?? null; + return resetAt !== null && resetAt !== undefined; +} export function buildClaudeQuotaWindows(payload: Record): ClaudeQuotaWindow[] { const rawRestrictions = payload['restrictions']; @@ -218,9 +223,9 @@ export function buildClaudeQuotaWindows(payload: Record): Claud } } else if (toObject(payload)) { for (const [key, value] of Object.entries(payload)) { - if (!CLAUDE_USAGE_WINDOW_KEYS.has(key)) continue; const raw = toObject(value); if (!raw) continue; + if (!isClaudeOAuthUsageWindowCandidate(key, raw)) continue; const window = normalizeRestriction(raw, key); if (window) windows.push(window); } diff --git a/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts b/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts index 732902dd..4ad5145f 100644 --- a/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts +++ b/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts @@ -253,7 +253,7 @@ describe('Quota Exhaustion Handlers', () => { expect(result.reason).toContain('no alternatives'); }); - it('should switch Claude accounts when fallback quota is unavailable but auth is valid', async () => { + it('should switch Claude accounts when fallback quota endpoint returns 404', async () => { writeRegistry({ claude: { default: 'exhausted@example.com', @@ -294,16 +294,7 @@ describe('Quota Exhaustion Handlers', () => { global.fetch = mock((_url: string, options?: RequestInit) => { const authHeader = new Headers(options?.headers).get('Authorization') ?? ''; if (authHeader === 'Bearer fallback-token') { - return Promise.resolve( - new Response( - JSON.stringify({ - error: { - message: 'OAuth authentication is currently not supported.', - }, - }), - { status: 401, headers: { 'Content-Type': 'application/json' } } - ) - ); + return Promise.resolve(new Response('', { status: 404 })); } return Promise.resolve(new Response('', { status: 500 })); diff --git a/tests/unit/cliproxy/quota-fetcher-claude.test.ts b/tests/unit/cliproxy/quota-fetcher-claude.test.ts index 686ae037..c1bcc4ae 100644 --- a/tests/unit/cliproxy/quota-fetcher-claude.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-claude.test.ts @@ -182,6 +182,20 @@ describe('Claude Quota Fetcher', () => { expect(windows[1].rateLimitType).toBe('seven_day_sonnet'); expect(windows[1].remainingPercent).toBe(91); }); + + it('parses future OAuth usage windows without hardcoded keys', () => { + const windows = buildClaudeQuotaWindows({ + seven_day_haiku: { + utilization: 16, + resets_at: '2026-03-06T10:00:00Z', + }, + }); + + expect(windows).toHaveLength(1); + expect(windows[0].rateLimitType).toBe('seven_day_haiku'); + expect(windows[0].label).toBe('Seven Day Haiku'); + expect(windows[0].remainingPercent).toBe(84); + }); }); describe('buildClaudeCoreUsageSummary', () => {