Merge pull request #679 from 0xble/fix/claude-oauth-policy-limits-unavailable

fix(cliproxy): handle Claude OAuth policy-limits 401 correctly
This commit is contained in:
Kai (Tam Nhu) Tran
2026-03-07 02:27:27 -05:00
committed by GitHub
5 changed files with 284 additions and 18 deletions
+54 -8
View File
@@ -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<string | null> {
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<Record<string, unknown> | 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) {
+10 -8
View File
@@ -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];
}
/**
@@ -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: {
@@ -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: ' ',
@@ -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"
>
<HelpCircle className="w-3 h-3" />
No limits
{t('accountCard.quotaUnavailable')}
</Badge>
</div>
) : failureInfo ? (