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 authored and GitHub committed 2026-03-07 02:27:27 -05:00
commit b1e8519bf7
5 files changed
+284 -18

No files matched your search

@@ -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: ' ',