fix(cliproxy): fetch Claude OAuth quota from usage endpoint

This commit is contained in:
Tam Nhu Tran committed 2026-04-14 23:01:16 -04:00
1 parent 42d6427c60
commit 9e880825e7
3 files changed
+119 -110

No files matched your search

@@ -1,7 +1,7 @@
/** /**
* Claude Quota Response Normalization Helpers * 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'; import type { ClaudeCoreUsageSummary, ClaudeQuotaWindow } from './quota-types';
@@ -185,8 +185,19 @@ function normalizeRestriction(
/** /**
* Parse raw policy limits response into normalized windows. * 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<string, unknown>): ClaudeQuotaWindow[] { export function buildClaudeQuotaWindows(payload: Record<string, unknown>): ClaudeQuotaWindow[] {
const rawRestrictions = payload['restrictions']; const rawRestrictions = payload['restrictions'];
const windows: ClaudeQuotaWindow[] = []; const windows: ClaudeQuotaWindow[] = [];
@@ -206,9 +217,19 @@ export function buildClaudeQuotaWindows(payload: Record<string, unknown>): Claud
if (window) windows.push(window); if (window) windows.push(window);
} }
} else if (toObject(payload)) { } 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. // Some responses may contain a single restriction object directly.
const direct = normalizeRestriction(payload); if (windows.length === 0) {
if (direct) windows.push(direct); const direct = normalizeRestriction(payload);
if (direct) windows.push(direct);
}
} }
const seen = new Set<string>(); const seen = new Set<string>();
+21 -35
View File
@@ -1,7 +1,7 @@
/** /**
* Quota Fetcher for Claude (Anthropic) Accounts * 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'; import * as path from 'node:path';
@@ -17,11 +17,10 @@ import {
export { buildClaudeQuotaWindows, buildClaudeCoreUsageSummary }; 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_TIMEOUT_MS = 10000;
const CLAUDE_QUOTA_MAX_ATTEMPTS = 2; const CLAUDE_QUOTA_MAX_ATTEMPTS = 2;
const CLAUDE_USER_AGENT = 'ccs-cli/claude-quota'; const CLAUDE_OAUTH_BETA_HEADER = 'oauth-2025-04-20';
const CLAUDE_OAUTH_UNSUPPORTED_MESSAGE = 'oauth authentication is currently not supported';
interface ClaudeAuthData { interface ClaudeAuthData {
accessToken: string; 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. * Fetch quota for a single Claude account.
*/ */
@@ -230,46 +219,43 @@ export async function fetchClaudeQuota(
const timeoutId = setTimeout(() => controller.abort(), CLAUDE_QUOTA_TIMEOUT_MS); const timeoutId = setTimeout(() => controller.abort(), CLAUDE_QUOTA_TIMEOUT_MS);
try { try {
const response = await fetch(CLAUDE_POLICY_LIMITS_URL, { const response = await fetch(CLAUDE_OAUTH_USAGE_URL, {
method: 'GET', method: 'GET',
signal: controller.signal, signal: controller.signal,
headers: { headers: {
Authorization: `Bearer ${authData.accessToken}`, Authorization: `Bearer ${authData.accessToken}`,
Accept: 'application/json', Accept: 'application/json',
'User-Agent': CLAUDE_USER_AGENT, 'Content-Type': 'application/json',
'anthropic-beta': CLAUDE_OAUTH_BETA_HEADER,
}, },
}); });
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (verbose) { 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) { if (response.status === 401) {
const errorMessage = await readResponseErrorMessage(response); const errorMessage = await readResponseErrorMessage(response);
if (errorMessage && errorMessage.toLowerCase().includes(CLAUDE_OAUTH_UNSUPPORTED_MESSAGE)) { return buildEmptyResult(
if (verbose) { errorMessage || 'Authentication required for Claude OAuth usage',
console.error( accountId,
'[i] Claude policy limits endpoint does not support OAuth tokens; treating quota as unavailable' true
); );
}
return buildPolicyUnavailableResult(accountId);
}
return buildEmptyResult('Authentication required for policy limits', accountId, true);
} }
if (response.status === 404) { if (response.status === 404) {
// Some accounts may not expose policy limits; treat as unavailable but successful. return buildEmptyResult('Claude OAuth usage endpoint not found', accountId);
return buildPolicyUnavailableResult(accountId);
} }
if (response.status === 403) { if (response.status === 403) {
return buildEmptyResult('Not authorized for policy limits', accountId); return buildEmptyResult('Not authorized for Claude OAuth usage', accountId);
} }
if (!response.ok) { if (!response.ok) {
lastError = `Policy limits API error: ${response.status}`; lastError =
(await readResponseErrorMessage(response)) ||
`Claude OAuth usage API error: ${response.status}`;
if ( if (
attempt < CLAUDE_QUOTA_MAX_ATTEMPTS && attempt < CLAUDE_QUOTA_MAX_ATTEMPTS &&
(response.status === 429 || response.status >= 500) (response.status === 429 || response.status >= 500)
@@ -283,11 +269,11 @@ export async function fetchClaudeQuota(
try { try {
payload = await response.json(); payload = await response.json();
} catch { } catch {
return buildEmptyResult('Invalid policy limits format', accountId); return buildEmptyResult('Invalid Claude OAuth usage format', accountId);
} }
if (!toObject(payload)) { if (!toObject(payload)) {
return buildEmptyResult('Invalid policy limits format', accountId); return buildEmptyResult('Invalid Claude OAuth usage format', accountId);
} }
const windows = buildClaudeQuotaWindows(payload as Record<string, unknown>); const windows = buildClaudeQuotaWindows(payload as Record<string, unknown>);
@@ -304,7 +290,7 @@ export async function fetchClaudeQuota(
clearTimeout(timeoutId); clearTimeout(timeoutId);
lastError = lastError =
error instanceof Error && error.name === 'AbortError' error instanceof Error && error.name === 'AbortError'
? 'Policy limits request timeout' ? 'Claude OAuth usage request timeout'
: error instanceof Error : error instanceof Error
? error.message ? error.message
: 'Unknown error'; : 'Unknown error';
@@ -313,7 +299,7 @@ export async function fetchClaudeQuota(
const errorDetails = const errorDetails =
error instanceof Error ? (error.stack ?? error.message) : JSON.stringify(error); error instanceof Error ? (error.stack ?? error.message) : JSON.stringify(error);
console.error( console.error(
`[!] Claude policy limits failed (attempt ${attempt}): ${lastError}${errorDetails ? `\n${errorDetails}` : ''}` `[!] Claude OAuth usage failed (attempt ${attempt}): ${lastError}${errorDetails ? `\n${errorDetails}` : ''}`
); );
} }
@@ -1,7 +1,7 @@
/** /**
* Claude Quota Fetcher Unit Tests * 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'; 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].rateLimitType).toBe('five_hour');
expect(windows[0].remainingPercent).toBe(40); 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', () => { describe('buildClaudeCoreUsageSummary', () => {
@@ -285,7 +310,7 @@ describe('Claude Quota Fetcher', () => {
}); });
describe('fetchClaudeQuota', () => { describe('fetchClaudeQuota', () => {
it('fetches and normalizes policy limits response', async () => { it('fetches and normalizes Claude OAuth usage response', async () => {
createClaudeAccount('claude-main@example.com', { createClaudeAccount('claude-main@example.com', {
access_token: 'claude-token', access_token: 'claude-token',
expired: '2099-01-01T00:00:00.000Z', expired: '2099-01-01T00:00:00.000Z',
@@ -293,30 +318,26 @@ describe('Claude Quota Fetcher', () => {
}); });
global.fetch = mock((url: string, options?: RequestInit) => { 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?.method).toBe('GET');
expect(options?.headers).toMatchObject({ expect(options?.headers).toMatchObject({
Authorization: 'Bearer claude-token', Authorization: 'Bearer claude-token',
Accept: 'application/json', Accept: 'application/json',
'Content-Type': 'application/json',
'anthropic-beta': 'oauth-2025-04-20',
}); });
return Promise.resolve( return Promise.resolve(
new Response( new Response(
JSON.stringify({ JSON.stringify({
restrictions: [ five_hour: {
{ utilization: 39,
rateLimitType: 'five_hour', resets_at: '2026-03-01T01:00:00Z',
utilization: 0.5, },
resetsAt: '2026-03-01T01:00:00Z', seven_day: {
status: 'allowed', utilization: 75,
}, resets_at: '2026-03-07T01:00:00Z',
{ },
rateLimitType: 'seven_day',
utilization: 0.75,
resetsAt: '2026-03-07T01:00:00Z',
status: 'allowed_warning',
},
],
}), }),
{ {
status: 200, status: 200,
@@ -331,7 +352,7 @@ describe('Claude Quota Fetcher', () => {
expect(result.success).toBe(true); expect(result.success).toBe(true);
expect(result.accountId).toBe('claude-main@example.com'); expect(result.accountId).toBe('claude-main@example.com');
expect(result.windows).toHaveLength(2); 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); expect(result.coreUsage?.weekly?.remainingPercent).toBe(25);
const all = await fetchAllClaudeQuotas(); const all = await fetchAllClaudeQuotas();
@@ -340,7 +361,7 @@ describe('Claude Quota Fetcher', () => {
expect(all[0].quota.success).toBe(true); 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( createClaudeAccount(
'claude-auth@example.com', 'claude-auth@example.com',
{ {
@@ -360,9 +381,9 @@ describe('Claude Quota Fetcher', () => {
expect(result.error).toContain('Authentication'); expect(result.error).toContain('Authentication');
}); });
it('treats OAuth-unsupported 401 as policy-limits unavailable', async () => { it('surfaces nested OAuth usage 401 messages', async () => {
createClaudeAccount( createClaudeAccount(
'claude-oauth-unsupported@example.com', 'claude-oauth-nested-message@example.com',
{ {
access_token: 'oauth-token', access_token: 'oauth-token',
expired: '2099-01-01T00:00:00.000Z', expired: '2099-01-01T00:00:00.000Z',
@@ -378,7 +399,7 @@ describe('Claude Quota Fetcher', () => {
type: 'error', type: 'error',
error: { error: {
type: 'authentication_error', type: 'authentication_error',
message: 'OAuth authentication is currently not supported.', message: 'OAuth session expired.',
}, },
}), }),
{ status: 401, headers: { 'Content-Type': 'application/json' } } { status: 401, headers: { 'Content-Type': 'application/json' } }
@@ -386,16 +407,14 @@ describe('Claude Quota Fetcher', () => {
) )
) as typeof fetch; ) 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.success).toBe(false);
expect(result.needsReauth).toBeUndefined(); expect(result.needsReauth).toBe(true);
expect(result.windows).toHaveLength(0); expect(result.error).toContain('OAuth session expired.');
expect(result.coreUsage?.fiveHour).toBeNull();
expect(result.coreUsage?.weekly).toBeNull();
}); });
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', { createClaudeAccount('claude-oauth-root-message@example.com', {
access_token: 'oauth-token', access_token: 'oauth-token',
expired: '2099-01-01T00:00:00.000Z', expired: '2099-01-01T00:00:00.000Z',
@@ -406,7 +425,7 @@ describe('Claude Quota Fetcher', () => {
Promise.resolve( Promise.resolve(
new Response( new Response(
JSON.stringify({ JSON.stringify({
message: 'OAuth authentication is currently not supported.', message: 'OAuth session expired.',
}), }),
{ status: 401, headers: { 'Content-Type': 'application/json' } } { 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'); const result = await fetchClaudeQuota('claude-oauth-root-message@example.com');
expect(result.success).toBe(true); expect(result.success).toBe(false);
expect(result.needsReauth).toBeUndefined(); expect(result.needsReauth).toBe(true);
expect(result.windows).toHaveLength(0); expect(result.error).toContain('OAuth session expired.');
expect(result.coreUsage?.fiveHour).toBeNull();
expect(result.coreUsage?.weekly).toBeNull();
}); });
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', { createClaudeAccount('claude-oauth-plaintext@example.com', {
access_token: 'oauth-token', access_token: 'oauth-token',
expired: '2099-01-01T00:00:00.000Z', expired: '2099-01-01T00:00:00.000Z',
@@ -430,18 +447,14 @@ describe('Claude Quota Fetcher', () => {
}); });
global.fetch = mock(() => global.fetch = mock(() =>
Promise.resolve( Promise.resolve(new Response('OAuth session expired.', { status: 401 }))
new Response('OAuth authentication is currently not supported.', { status: 401 })
)
) as typeof fetch; ) as typeof fetch;
const result = await fetchClaudeQuota('claude-oauth-plaintext@example.com'); const result = await fetchClaudeQuota('claude-oauth-plaintext@example.com');
expect(result.success).toBe(true); expect(result.success).toBe(false);
expect(result.needsReauth).toBeUndefined(); expect(result.needsReauth).toBe(true);
expect(result.windows).toHaveLength(0); expect(result.error).toContain('OAuth session expired.');
expect(result.coreUsage?.fiveHour).toBeNull();
expect(result.coreUsage?.weekly).toBeNull();
}); });
it('keeps non-matching 401 payloads in the reauth path', async () => { 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.success).toBe(false);
expect(result.needsReauth).toBe(true); 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 () => { it('treats 404 OAuth usage responses as failures', async () => {
createClaudeAccount('claude-policy-limits-404@example.com', { createClaudeAccount('claude-usage-404@example.com', {
access_token: 'oauth-token', access_token: 'oauth-token',
expired: '2099-01-01T00:00:00.000Z', expired: '2099-01-01T00:00:00.000Z',
type: 'claude', type: 'claude',
@@ -481,13 +494,10 @@ describe('Claude Quota Fetcher', () => {
global.fetch = mock(() => Promise.resolve(new Response('', { status: 404 }))) as typeof fetch; 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.success).toBe(false);
expect(result.needsReauth).toBeUndefined(); expect(result.error).toContain('not found');
expect(result.windows).toHaveLength(0);
expect(result.coreUsage?.fiveHour).toBeNull();
expect(result.coreUsage?.weekly).toBeNull();
}); });
it('fails fast when auth file has no token', async () => { it('fails fast when auth file has no token', async () => {
@@ -515,7 +525,7 @@ describe('Claude Quota Fetcher', () => {
global.fetch = mock(() => global.fetch = mock(() =>
Promise.resolve( Promise.resolve(
new Response(JSON.stringify({ restrictions: [] }), { new Response(JSON.stringify({}), {
status: 200, status: 200,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}) })
@@ -545,14 +555,10 @@ describe('Claude Quota Fetcher', () => {
return Promise.resolve( return Promise.resolve(
new Response( new Response(
JSON.stringify({ JSON.stringify({
restrictions: [ five_hour: {
{ utilization: 40,
rateLimitType: 'five_hour', resets_at: '2026-03-01T01:00:00Z',
utilization: 0.4, },
resetsAt: '2026-03-01T01:00:00Z',
status: 'allowed',
},
],
}), }),
{ status: 200, headers: { 'Content-Type': 'application/json' } } { status: 200, headers: { 'Content-Type': 'application/json' } }
) )
@@ -584,14 +590,10 @@ describe('Claude Quota Fetcher', () => {
return Promise.resolve( return Promise.resolve(
new Response( new Response(
JSON.stringify({ JSON.stringify({
restrictions: [ seven_day: {
{ utilization: 30,
rateLimitType: 'seven_day', resets_at: '2026-03-07T01:00:00Z',
utilization: 0.3, },
resetsAt: '2026-03-07T01:00:00Z',
status: 'allowed',
},
],
}), }),
{ status: 200, headers: { 'Content-Type': 'application/json' } } { status: 200, headers: { 'Content-Type': 'application/json' } }
) )
@@ -654,7 +656,7 @@ describe('Claude Quota Fetcher', () => {
Authorization: 'Bearer valid-anthropic-token', Authorization: 'Bearer valid-anthropic-token',
}); });
return Promise.resolve( return Promise.resolve(
new Response(JSON.stringify({ restrictions: [] }), { new Response(JSON.stringify({}), {
status: 200, status: 200,
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
}) })