mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 10:16:49 +00:00
fix(cliproxy): preserve Claude fallback when quota is unavailable
This commit is contained in:
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user