diff --git a/src/cliproxy/quota-fetcher-claude.ts b/src/cliproxy/quota-fetcher-claude.ts index 3f0c0aaf..60f88d3b 100644 --- a/src/cliproxy/quota-fetcher-claude.ts +++ b/src/cliproxy/quota-fetcher-claude.ts @@ -21,6 +21,7 @@ export const CLAUDE_POLICY_LIMITS_URL = 'https://api.anthropic.com/api/claude_co 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'; interface ClaudeAuthData { accessToken: string; @@ -65,6 +66,37 @@ function isAuthExpired(expiry: string | null): boolean { return expiry ? isTokenExpired(expiry) : false; } +function extractErrorMessage(payload: unknown): string | null { + const root = toObject(payload); + if (!root) return null; + + const direct = asString(root['message']); + if (direct) return direct; + + const nested = toObject(root['error']); + if (!nested) return null; + return asString(nested['message']); +} + +async function readResponseErrorMessage(response: Response): Promise { + try { + const body = await response.text(); + if (!body || body.trim().length === 0) return null; + + try { + const parsed = JSON.parse(body) as unknown; + const extracted = extractErrorMessage(parsed); + if (extracted) return extracted; + } catch { + // fall through to plain-text fallback + } + + return body.trim(); + } catch { + return null; + } +} + async function readJsonFile(filePath: string): Promise | null> { try { const raw = await fsp.readFile(filePath, 'utf-8'); @@ -161,6 +193,16 @@ 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. */ @@ -204,18 +246,22 @@ export async function fetchClaudeQuota( } 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); } if (response.status === 404) { - // Some accounts may not expose policy limits; treat as empty but successful. - return { - success: true, - windows: [], - coreUsage: { fiveHour: null, weekly: null }, - lastUpdated: Date.now(), - accountId, - }; + // Some accounts may not expose policy limits; treat as unavailable but successful. + return buildPolicyUnavailableResult(accountId); } if (response.status === 403) { diff --git a/src/cliproxy/quota-manager.ts b/src/cliproxy/quota-manager.ts index afb431e8..31c46780 100644 --- a/src/cliproxy/quota-manager.ts +++ b/src/cliproxy/quota-manager.ts @@ -281,7 +281,7 @@ function calculateQuotaPercent(quota: ManagedQuotaResult): number | null { export async function findHealthyAccount( provider: CLIProxyProvider, exclude: string[] -): Promise<{ id: string; tier: string; lastQuota: number } | null> { +): Promise<{ id: string; tier: string; lastQuota: number | null } | null> { if (!isManagedQuotaProvider(provider)) { return null; } @@ -309,7 +309,7 @@ export async function findHealthyAccount( quota = await fetchQuotaWithDedup(provider, account.id); } - const avgQuota = calculateQuotaPercent(quota) ?? 0; + const avgQuota = calculateQuotaPercent(quota); return { id: account.id, @@ -320,22 +320,24 @@ export async function findHealthyAccount( 10 ); - // Filter by threshold - const healthy = withQuotas.filter((a) => a.lastQuota >= threshold); - if (healthy.length === 0) return null; + // Prefer accounts with known healthy quota. If all remaining accounts have unavailable + // quota data, fall back to those unknown-but-usable accounts instead of treating them as 0%. + const healthy = withQuotas.filter((a) => a.lastQuota !== null && a.lastQuota >= threshold); + const selectable = healthy.length > 0 ? healthy : withQuotas.filter((a) => a.lastQuota === null); + if (selectable.length === 0) return null; // Sort by tier priority then quota descending - healthy.sort((a, b) => { + selectable.sort((a, b) => { const tierA = tierPriority.indexOf(a.tier); const tierB = tierPriority.indexOf(b.tier); const tierOrderA = tierA === -1 ? 999 : tierA; const tierOrderB = tierB === -1 ? 999 : tierB; if (tierOrderA !== tierOrderB) return tierOrderA - tierOrderB; - return b.lastQuota - a.lastQuota; + return (b.lastQuota ?? -1) - (a.lastQuota ?? -1); }); - return healthy[0]; + return selectable[0]; } /** diff --git a/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts b/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts index a24db225..732902dd 100644 --- a/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts +++ b/tests/unit/cliproxy/account-safety-quota-exhaustion.test.ts @@ -9,7 +9,7 @@ * - Email masking */ -import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; @@ -18,18 +18,22 @@ import { writeQuotaWarning, maskEmail, } from '../../../src/cliproxy/account-safety'; +import { sanitizeEmail } from '../../../src/cliproxy/auth-utils'; // Setup test isolation let tmpDir: string; let origCcsHome: string | undefined; +let originalFetch: typeof fetch; beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-exhaust-')); origCcsHome = process.env.CCS_HOME; process.env.CCS_HOME = tmpDir; + originalFetch = global.fetch; }); afterEach(() => { + global.fetch = originalFetch; if (origCcsHome !== undefined) { process.env.CCS_HOME = origCcsHome; } else { @@ -61,6 +65,25 @@ function writeConfig(quotaConfig: unknown): void { ); } +function writeClaudeAuth(accountId: string, accessToken: string): void { + const authDir = path.join(tmpDir, '.ccs', 'cliproxy', 'auth'); + const tokenFile = `claude-${sanitizeEmail(accountId)}.json`; + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync( + path.join(authDir, tokenFile), + JSON.stringify( + { + access_token: accessToken, + expired: '2099-01-01T00:00:00.000Z', + type: 'claude', + email: accountId, + }, + null, + 2 + ) + ); +} + describe('Quota Exhaustion Handlers', () => { describe('writeQuotaWarning', () => { it('should write to stderr with box format', async () => { @@ -230,6 +253,69 @@ 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 () => { + writeRegistry({ + claude: { + default: 'exhausted@example.com', + accounts: { + 'exhausted@example.com': { + email: 'exhausted@example.com', + tokenFile: `claude-${sanitizeEmail('exhausted@example.com')}.json`, + }, + 'fallback@example.com': { + email: 'fallback@example.com', + tokenFile: `claude-${sanitizeEmail('fallback@example.com')}.json`, + }, + }, + }, + }); + + writeConfig({ + mode: 'auto', + auto: { + tier_priority: ['ultra', 'pro', 'free'], + exhaustion_threshold: 5, + cooldown_minutes: 10, + preflight_check: true, + }, + runtime_monitor: { + enabled: true, + normal_interval_seconds: 300, + critical_interval_seconds: 60, + warn_threshold: 20, + exhaustion_threshold: 5, + cooldown_minutes: 10, + }, + }); + + writeClaudeAuth('exhausted@example.com', 'exhausted-token'); + writeClaudeAuth('fallback@example.com', 'fallback-token'); + + 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: 500 })); + }) as typeof fetch; + + const result = await handleQuotaExhaustion('claude', 'exhausted@example.com', 10); + const { getDefaultAccount } = await import('../../../src/cliproxy/account-manager'); + + expect(result.switchedTo).toBe('fallback@example.com'); + expect(getDefaultAccount('claude')?.id).toBe('fallback@example.com'); + }); + it('should write warning to stderr', async () => { writeRegistry({ agy: { diff --git a/tests/unit/cliproxy/quota-fetcher-claude.test.ts b/tests/unit/cliproxy/quota-fetcher-claude.test.ts index dd197288..25455a5c 100644 --- a/tests/unit/cliproxy/quota-fetcher-claude.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-claude.test.ts @@ -360,6 +360,136 @@ describe('Claude Quota Fetcher', () => { expect(result.error).toContain('Authentication'); }); + it('treats OAuth-unsupported 401 as policy-limits unavailable', async () => { + createClaudeAccount( + 'claude-oauth-unsupported@example.com', + { + access_token: 'oauth-token', + expired: '2099-01-01T00:00:00.000Z', + type: 'claude', + }, + 'claude' + ); + + global.fetch = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ + type: 'error', + error: { + type: 'authentication_error', + message: 'OAuth authentication is currently not supported.', + }, + }), + { status: 401, headers: { 'Content-Type': 'application/json' } } + ) + ) + ) as typeof fetch; + + const result = await fetchClaudeQuota('claude-oauth-unsupported@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(); + }); + + it('treats root-level OAuth-unsupported 401 message as policy-limits unavailable', async () => { + createClaudeAccount('claude-oauth-root-message@example.com', { + access_token: 'oauth-token', + expired: '2099-01-01T00:00:00.000Z', + type: 'claude', + }); + + global.fetch = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ + message: 'OAuth authentication is currently not supported.', + }), + { status: 401, headers: { 'Content-Type': 'application/json' } } + ) + ) + ) as typeof fetch; + + 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(); + }); + + it('treats plain-text OAuth-unsupported 401 as policy-limits unavailable', async () => { + createClaudeAccount('claude-oauth-plaintext@example.com', { + access_token: 'oauth-token', + expired: '2099-01-01T00:00:00.000Z', + type: 'claude', + }); + + global.fetch = mock(() => + Promise.resolve( + new Response('OAuth authentication is currently not supported.', { 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(); + }); + + it('keeps non-matching 401 payloads in the reauth path', async () => { + createClaudeAccount('claude-auth-other-401@example.com', { + access_token: 'oauth-token', + expired: '2099-01-01T00:00:00.000Z', + type: 'claude', + }); + + global.fetch = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ + error: { + type: 'authentication_error', + message: 'Token revoked.', + }, + }), + { status: 401, headers: { 'Content-Type': 'application/json' } } + ) + ) + ) as typeof fetch; + + const result = await fetchClaudeQuota('claude-auth-other-401@example.com'); + + expect(result.success).toBe(false); + expect(result.needsReauth).toBe(true); + expect(result.error).toContain('Authentication'); + }); + + it('treats 404 policy limits responses as unavailable but successful', async () => { + createClaudeAccount('claude-policy-limits-404@example.com', { + access_token: 'oauth-token', + expired: '2099-01-01T00:00:00.000Z', + type: 'claude', + }); + + global.fetch = mock(() => Promise.resolve(new Response('', { status: 404 }))) as typeof fetch; + + const result = await fetchClaudeQuota('claude-policy-limits-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(); + }); + it('fails fast when auth file has no token', async () => { createClaudeAccount('claude-missing@example.com', { access_token: ' ', diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index beb6fead..0c3520cc 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -43,6 +43,7 @@ import { import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats'; import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content'; +import { useTranslation } from 'react-i18next'; import type { AccountItemProps } from './types'; /** @@ -107,6 +108,7 @@ export function AccountItem({ selected, onSelectChange, }: AccountItemProps) { + const { t } = useTranslation(); const normalizedProvider = account.provider.toLowerCase(); const isCodexProvider = normalizedProvider === 'codex'; const isClaudeProvider = normalizedProvider === 'claude' || normalizedProvider === 'anthropic'; @@ -450,7 +452,7 @@ export function AccountItem({ className="text-[10px] h-5 px-2 gap-1 border-muted-foreground/50 text-muted-foreground" > - No limits + {t('accountCard.quotaUnavailable')} ) : failureInfo ? (