fix(cliproxy): preserve Claude fallback when quota is unavailable

This commit is contained in:
Tam Nhu Tran
2026-03-07 13:57:27 +07:00
parent 7e8f9e82c1
commit 6ed95a2a86
4 changed files with 195 additions and 10 deletions
+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: {
@@ -395,6 +395,101 @@ describe('Claude Quota Fetcher', () => {
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: ' ',
@@ -42,6 +42,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';
/**
@@ -106,6 +107,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';
@@ -436,7 +438,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>
) : quota?.needsReauth ? (