mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-03 07:12:18 +00:00
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:
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: ' ',
|
||||
|
||||
Reference in new issue
Block a user