From 9e880825e744a078fa60fdbf8dd89d267184c9cb Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Tue, 14 Apr 2026 23:01:16 -0400 Subject: [PATCH] fix(cliproxy): fetch Claude OAuth quota from usage endpoint --- .../quota-fetcher-claude-normalizer.ts | 29 +++- src/cliproxy/quota-fetcher-claude.ts | 56 +++---- .../cliproxy/quota-fetcher-claude.test.ts | 144 +++++++++--------- 3 files changed, 119 insertions(+), 110 deletions(-) diff --git a/src/cliproxy/quota-fetcher-claude-normalizer.ts b/src/cliproxy/quota-fetcher-claude-normalizer.ts index 25104425..527fec35 100644 --- a/src/cliproxy/quota-fetcher-claude-normalizer.ts +++ b/src/cliproxy/quota-fetcher-claude-normalizer.ts @@ -1,7 +1,7 @@ /** * Claude Quota Response Normalization Helpers * - * Parses Anthropic policy limits payload into normalized windows and core usage summary. + * Parses Anthropic policy-limits or OAuth-usage payloads into normalized windows and core usage summary. */ import type { ClaudeCoreUsageSummary, ClaudeQuotaWindow } from './quota-types'; @@ -185,8 +185,19 @@ function normalizeRestriction( /** * Parse raw policy limits response into normalized windows. - * Supports both array and object-map `restrictions` shapes. + * 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', +]); + export function buildClaudeQuotaWindows(payload: Record): ClaudeQuotaWindow[] { const rawRestrictions = payload['restrictions']; const windows: ClaudeQuotaWindow[] = []; @@ -206,9 +217,19 @@ export function buildClaudeQuotaWindows(payload: Record): Claud if (window) windows.push(window); } } 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; + const window = normalizeRestriction(raw, key); + if (window) windows.push(window); + } + // Some responses may contain a single restriction object directly. - const direct = normalizeRestriction(payload); - if (direct) windows.push(direct); + if (windows.length === 0) { + const direct = normalizeRestriction(payload); + if (direct) windows.push(direct); + } } const seen = new Set(); diff --git a/src/cliproxy/quota-fetcher-claude.ts b/src/cliproxy/quota-fetcher-claude.ts index 60f88d3b..62ce4b35 100644 --- a/src/cliproxy/quota-fetcher-claude.ts +++ b/src/cliproxy/quota-fetcher-claude.ts @@ -1,7 +1,7 @@ /** * Quota Fetcher for Claude (Anthropic) Accounts * - * Fetches policy limits from Claude API and normalizes 5h + weekly windows. + * Fetches OAuth usage windows from Claude API and normalizes 5h + weekly windows. */ import * as path from 'node:path'; @@ -17,11 +17,10 @@ import { export { buildClaudeQuotaWindows, buildClaudeCoreUsageSummary }; -export const CLAUDE_POLICY_LIMITS_URL = 'https://api.anthropic.com/api/claude_code/policy_limits'; +export const CLAUDE_OAUTH_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'; const CLAUDE_QUOTA_TIMEOUT_MS = 10000; const CLAUDE_QUOTA_MAX_ATTEMPTS = 2; -const CLAUDE_USER_AGENT = 'ccs-cli/claude-quota'; -const CLAUDE_OAUTH_UNSUPPORTED_MESSAGE = 'oauth authentication is currently not supported'; +const CLAUDE_OAUTH_BETA_HEADER = 'oauth-2025-04-20'; interface ClaudeAuthData { accessToken: string; @@ -193,16 +192,6 @@ function buildEmptyResult( }; } -function buildPolicyUnavailableResult(accountId: string): ClaudeQuotaResult { - return { - success: true, - windows: [], - coreUsage: { fiveHour: null, weekly: null }, - lastUpdated: Date.now(), - accountId, - }; -} - /** * Fetch quota for a single Claude account. */ @@ -230,46 +219,43 @@ export async function fetchClaudeQuota( const timeoutId = setTimeout(() => controller.abort(), CLAUDE_QUOTA_TIMEOUT_MS); try { - const response = await fetch(CLAUDE_POLICY_LIMITS_URL, { + const response = await fetch(CLAUDE_OAUTH_USAGE_URL, { method: 'GET', signal: controller.signal, headers: { Authorization: `Bearer ${authData.accessToken}`, Accept: 'application/json', - 'User-Agent': CLAUDE_USER_AGENT, + 'Content-Type': 'application/json', + 'anthropic-beta': CLAUDE_OAUTH_BETA_HEADER, }, }); clearTimeout(timeoutId); if (verbose) { - console.error(`[i] Claude policy limits status: ${response.status} (attempt ${attempt})`); + console.error(`[i] Claude OAuth usage status: ${response.status} (attempt ${attempt})`); } if (response.status === 401) { const errorMessage = await readResponseErrorMessage(response); - if (errorMessage && errorMessage.toLowerCase().includes(CLAUDE_OAUTH_UNSUPPORTED_MESSAGE)) { - if (verbose) { - console.error( - '[i] Claude policy limits endpoint does not support OAuth tokens; treating quota as unavailable' - ); - } - return buildPolicyUnavailableResult(accountId); - } - - return buildEmptyResult('Authentication required for policy limits', accountId, true); + return buildEmptyResult( + errorMessage || 'Authentication required for Claude OAuth usage', + accountId, + true + ); } if (response.status === 404) { - // Some accounts may not expose policy limits; treat as unavailable but successful. - return buildPolicyUnavailableResult(accountId); + return buildEmptyResult('Claude OAuth usage endpoint not found', accountId); } if (response.status === 403) { - return buildEmptyResult('Not authorized for policy limits', accountId); + return buildEmptyResult('Not authorized for Claude OAuth usage', accountId); } if (!response.ok) { - lastError = `Policy limits API error: ${response.status}`; + lastError = + (await readResponseErrorMessage(response)) || + `Claude OAuth usage API error: ${response.status}`; if ( attempt < CLAUDE_QUOTA_MAX_ATTEMPTS && (response.status === 429 || response.status >= 500) @@ -283,11 +269,11 @@ export async function fetchClaudeQuota( try { payload = await response.json(); } catch { - return buildEmptyResult('Invalid policy limits format', accountId); + return buildEmptyResult('Invalid Claude OAuth usage format', accountId); } if (!toObject(payload)) { - return buildEmptyResult('Invalid policy limits format', accountId); + return buildEmptyResult('Invalid Claude OAuth usage format', accountId); } const windows = buildClaudeQuotaWindows(payload as Record); @@ -304,7 +290,7 @@ export async function fetchClaudeQuota( clearTimeout(timeoutId); lastError = error instanceof Error && error.name === 'AbortError' - ? 'Policy limits request timeout' + ? 'Claude OAuth usage request timeout' : error instanceof Error ? error.message : 'Unknown error'; @@ -313,7 +299,7 @@ export async function fetchClaudeQuota( const errorDetails = error instanceof Error ? (error.stack ?? error.message) : JSON.stringify(error); console.error( - `[!] Claude policy limits failed (attempt ${attempt}): ${lastError}${errorDetails ? `\n${errorDetails}` : ''}` + `[!] Claude OAuth usage failed (attempt ${attempt}): ${lastError}${errorDetails ? `\n${errorDetails}` : ''}` ); } diff --git a/tests/unit/cliproxy/quota-fetcher-claude.test.ts b/tests/unit/cliproxy/quota-fetcher-claude.test.ts index 25455a5c..686ae037 100644 --- a/tests/unit/cliproxy/quota-fetcher-claude.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-claude.test.ts @@ -1,7 +1,7 @@ /** * Claude Quota Fetcher Unit Tests * - * Covers policy limits parsing and auth/token edge cases. + * Covers Claude quota parsing and auth/token edge cases. */ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; @@ -157,6 +157,31 @@ describe('Claude Quota Fetcher', () => { expect(windows[0].rateLimitType).toBe('five_hour'); expect(windows[0].remainingPercent).toBe(40); }); + + it('parses OAuth usage payload keyed by window name', () => { + const windows = buildClaudeQuotaWindows({ + five_hour: { + utilization: 39, + resets_at: '2026-02-28T10:00:00Z', + }, + seven_day_sonnet: { + utilization: 9, + resets_at: '2026-03-06T10:00:00Z', + }, + extra_usage: { + is_enabled: true, + monthly_limit: 5000, + used_credits: 1200, + utilization: 0.24, + }, + }); + + expect(windows).toHaveLength(2); + expect(windows[0].rateLimitType).toBe('five_hour'); + expect(windows[0].remainingPercent).toBe(61); + expect(windows[1].rateLimitType).toBe('seven_day_sonnet'); + expect(windows[1].remainingPercent).toBe(91); + }); }); describe('buildClaudeCoreUsageSummary', () => { @@ -285,7 +310,7 @@ describe('Claude Quota Fetcher', () => { }); describe('fetchClaudeQuota', () => { - it('fetches and normalizes policy limits response', async () => { + it('fetches and normalizes Claude OAuth usage response', async () => { createClaudeAccount('claude-main@example.com', { access_token: 'claude-token', expired: '2099-01-01T00:00:00.000Z', @@ -293,30 +318,26 @@ describe('Claude Quota Fetcher', () => { }); global.fetch = mock((url: string, options?: RequestInit) => { - expect(url).toBe('https://api.anthropic.com/api/claude_code/policy_limits'); + expect(url).toBe('https://api.anthropic.com/api/oauth/usage'); expect(options?.method).toBe('GET'); expect(options?.headers).toMatchObject({ Authorization: 'Bearer claude-token', Accept: 'application/json', + 'Content-Type': 'application/json', + 'anthropic-beta': 'oauth-2025-04-20', }); return Promise.resolve( new Response( JSON.stringify({ - restrictions: [ - { - rateLimitType: 'five_hour', - utilization: 0.5, - resetsAt: '2026-03-01T01:00:00Z', - status: 'allowed', - }, - { - rateLimitType: 'seven_day', - utilization: 0.75, - resetsAt: '2026-03-07T01:00:00Z', - status: 'allowed_warning', - }, - ], + five_hour: { + utilization: 39, + resets_at: '2026-03-01T01:00:00Z', + }, + seven_day: { + utilization: 75, + resets_at: '2026-03-07T01:00:00Z', + }, }), { status: 200, @@ -331,7 +352,7 @@ describe('Claude Quota Fetcher', () => { expect(result.success).toBe(true); expect(result.accountId).toBe('claude-main@example.com'); expect(result.windows).toHaveLength(2); - expect(result.coreUsage?.fiveHour?.remainingPercent).toBe(50); + expect(result.coreUsage?.fiveHour?.remainingPercent).toBe(61); expect(result.coreUsage?.weekly?.remainingPercent).toBe(25); const all = await fetchAllClaudeQuotas(); @@ -340,7 +361,7 @@ describe('Claude Quota Fetcher', () => { expect(all[0].quota.success).toBe(true); }); - it('returns needsReauth on 401 responses', async () => { + it('returns needsReauth on empty 401 OAuth usage responses', async () => { createClaudeAccount( 'claude-auth@example.com', { @@ -360,9 +381,9 @@ describe('Claude Quota Fetcher', () => { expect(result.error).toContain('Authentication'); }); - it('treats OAuth-unsupported 401 as policy-limits unavailable', async () => { + it('surfaces nested OAuth usage 401 messages', async () => { createClaudeAccount( - 'claude-oauth-unsupported@example.com', + 'claude-oauth-nested-message@example.com', { access_token: 'oauth-token', expired: '2099-01-01T00:00:00.000Z', @@ -378,7 +399,7 @@ describe('Claude Quota Fetcher', () => { type: 'error', error: { type: 'authentication_error', - message: 'OAuth authentication is currently not supported.', + message: 'OAuth session expired.', }, }), { status: 401, headers: { 'Content-Type': 'application/json' } } @@ -386,16 +407,14 @@ describe('Claude Quota Fetcher', () => { ) ) as typeof fetch; - const result = await fetchClaudeQuota('claude-oauth-unsupported@example.com'); + const result = await fetchClaudeQuota('claude-oauth-nested-message@example.com'); - expect(result.success).toBe(true); - expect(result.needsReauth).toBeUndefined(); - expect(result.windows).toHaveLength(0); - expect(result.coreUsage?.fiveHour).toBeNull(); - expect(result.coreUsage?.weekly).toBeNull(); + expect(result.success).toBe(false); + expect(result.needsReauth).toBe(true); + expect(result.error).toContain('OAuth session expired.'); }); - it('treats root-level OAuth-unsupported 401 message as policy-limits unavailable', async () => { + it('surfaces root-level OAuth usage 401 messages', async () => { createClaudeAccount('claude-oauth-root-message@example.com', { access_token: 'oauth-token', expired: '2099-01-01T00:00:00.000Z', @@ -406,7 +425,7 @@ describe('Claude Quota Fetcher', () => { Promise.resolve( new Response( JSON.stringify({ - message: 'OAuth authentication is currently not supported.', + message: 'OAuth session expired.', }), { status: 401, headers: { 'Content-Type': 'application/json' } } ) @@ -415,14 +434,12 @@ describe('Claude Quota Fetcher', () => { const result = await fetchClaudeQuota('claude-oauth-root-message@example.com'); - expect(result.success).toBe(true); - expect(result.needsReauth).toBeUndefined(); - expect(result.windows).toHaveLength(0); - expect(result.coreUsage?.fiveHour).toBeNull(); - expect(result.coreUsage?.weekly).toBeNull(); + expect(result.success).toBe(false); + expect(result.needsReauth).toBe(true); + expect(result.error).toContain('OAuth session expired.'); }); - it('treats plain-text OAuth-unsupported 401 as policy-limits unavailable', async () => { + it('surfaces plain-text OAuth usage 401 messages', async () => { createClaudeAccount('claude-oauth-plaintext@example.com', { access_token: 'oauth-token', expired: '2099-01-01T00:00:00.000Z', @@ -430,18 +447,14 @@ describe('Claude Quota Fetcher', () => { }); global.fetch = mock(() => - Promise.resolve( - new Response('OAuth authentication is currently not supported.', { status: 401 }) - ) + Promise.resolve(new Response('OAuth session expired.', { status: 401 })) ) as typeof fetch; const result = await fetchClaudeQuota('claude-oauth-plaintext@example.com'); - expect(result.success).toBe(true); - expect(result.needsReauth).toBeUndefined(); - expect(result.windows).toHaveLength(0); - expect(result.coreUsage?.fiveHour).toBeNull(); - expect(result.coreUsage?.weekly).toBeNull(); + expect(result.success).toBe(false); + expect(result.needsReauth).toBe(true); + expect(result.error).toContain('OAuth session expired.'); }); it('keeps non-matching 401 payloads in the reauth path', async () => { @@ -469,11 +482,11 @@ describe('Claude Quota Fetcher', () => { expect(result.success).toBe(false); expect(result.needsReauth).toBe(true); - expect(result.error).toContain('Authentication'); + expect(result.error).toContain('Token revoked.'); }); - it('treats 404 policy limits responses as unavailable but successful', async () => { - createClaudeAccount('claude-policy-limits-404@example.com', { + it('treats 404 OAuth usage responses as failures', async () => { + createClaudeAccount('claude-usage-404@example.com', { access_token: 'oauth-token', expired: '2099-01-01T00:00:00.000Z', type: 'claude', @@ -481,13 +494,10 @@ describe('Claude Quota Fetcher', () => { global.fetch = mock(() => Promise.resolve(new Response('', { status: 404 }))) as typeof fetch; - const result = await fetchClaudeQuota('claude-policy-limits-404@example.com'); + const result = await fetchClaudeQuota('claude-usage-404@example.com'); - expect(result.success).toBe(true); - expect(result.needsReauth).toBeUndefined(); - expect(result.windows).toHaveLength(0); - expect(result.coreUsage?.fiveHour).toBeNull(); - expect(result.coreUsage?.weekly).toBeNull(); + expect(result.success).toBe(false); + expect(result.error).toContain('not found'); }); it('fails fast when auth file has no token', async () => { @@ -515,7 +525,7 @@ describe('Claude Quota Fetcher', () => { global.fetch = mock(() => Promise.resolve( - new Response(JSON.stringify({ restrictions: [] }), { + new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' }, }) @@ -545,14 +555,10 @@ describe('Claude Quota Fetcher', () => { return Promise.resolve( new Response( JSON.stringify({ - restrictions: [ - { - rateLimitType: 'five_hour', - utilization: 0.4, - resetsAt: '2026-03-01T01:00:00Z', - status: 'allowed', - }, - ], + five_hour: { + utilization: 40, + resets_at: '2026-03-01T01:00:00Z', + }, }), { status: 200, headers: { 'Content-Type': 'application/json' } } ) @@ -584,14 +590,10 @@ describe('Claude Quota Fetcher', () => { return Promise.resolve( new Response( JSON.stringify({ - restrictions: [ - { - rateLimitType: 'seven_day', - utilization: 0.3, - resetsAt: '2026-03-07T01:00:00Z', - status: 'allowed', - }, - ], + seven_day: { + utilization: 30, + resets_at: '2026-03-07T01:00:00Z', + }, }), { status: 200, headers: { 'Content-Type': 'application/json' } } ) @@ -654,7 +656,7 @@ describe('Claude Quota Fetcher', () => { Authorization: 'Bearer valid-anthropic-token', }); return Promise.resolve( - new Response(JSON.stringify({ restrictions: [] }), { + new Response(JSON.stringify({}), { status: 200, headers: { 'Content-Type': 'application/json' }, })