mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 22:17:14 +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(
|
export async function findHealthyAccount(
|
||||||
provider: CLIProxyProvider,
|
provider: CLIProxyProvider,
|
||||||
exclude: string[]
|
exclude: string[]
|
||||||
): Promise<{ id: string; tier: string; lastQuota: number } | null> {
|
): Promise<{ id: string; tier: string; lastQuota: number | null } | null> {
|
||||||
if (!isManagedQuotaProvider(provider)) {
|
if (!isManagedQuotaProvider(provider)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -309,7 +309,7 @@ export async function findHealthyAccount(
|
|||||||
quota = await fetchQuotaWithDedup(provider, account.id);
|
quota = await fetchQuotaWithDedup(provider, account.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const avgQuota = calculateQuotaPercent(quota) ?? 0;
|
const avgQuota = calculateQuotaPercent(quota);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: account.id,
|
id: account.id,
|
||||||
@@ -320,22 +320,24 @@ export async function findHealthyAccount(
|
|||||||
10
|
10
|
||||||
);
|
);
|
||||||
|
|
||||||
// Filter by threshold
|
// Prefer accounts with known healthy quota. If all remaining accounts have unavailable
|
||||||
const healthy = withQuotas.filter((a) => a.lastQuota >= threshold);
|
// quota data, fall back to those unknown-but-usable accounts instead of treating them as 0%.
|
||||||
if (healthy.length === 0) return null;
|
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
|
// Sort by tier priority then quota descending
|
||||||
healthy.sort((a, b) => {
|
selectable.sort((a, b) => {
|
||||||
const tierA = tierPriority.indexOf(a.tier);
|
const tierA = tierPriority.indexOf(a.tier);
|
||||||
const tierB = tierPriority.indexOf(b.tier);
|
const tierB = tierPriority.indexOf(b.tier);
|
||||||
const tierOrderA = tierA === -1 ? 999 : tierA;
|
const tierOrderA = tierA === -1 ? 999 : tierA;
|
||||||
const tierOrderB = tierB === -1 ? 999 : tierB;
|
const tierOrderB = tierB === -1 ? 999 : tierB;
|
||||||
|
|
||||||
if (tierOrderA !== tierOrderB) return tierOrderA - tierOrderB;
|
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
|
* - 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 fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
@@ -18,18 +18,22 @@ import {
|
|||||||
writeQuotaWarning,
|
writeQuotaWarning,
|
||||||
maskEmail,
|
maskEmail,
|
||||||
} from '../../../src/cliproxy/account-safety';
|
} from '../../../src/cliproxy/account-safety';
|
||||||
|
import { sanitizeEmail } from '../../../src/cliproxy/auth-utils';
|
||||||
|
|
||||||
// Setup test isolation
|
// Setup test isolation
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
let origCcsHome: string | undefined;
|
let origCcsHome: string | undefined;
|
||||||
|
let originalFetch: typeof fetch;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-exhaust-'));
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-exhaust-'));
|
||||||
origCcsHome = process.env.CCS_HOME;
|
origCcsHome = process.env.CCS_HOME;
|
||||||
process.env.CCS_HOME = tmpDir;
|
process.env.CCS_HOME = tmpDir;
|
||||||
|
originalFetch = global.fetch;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
if (origCcsHome !== undefined) {
|
if (origCcsHome !== undefined) {
|
||||||
process.env.CCS_HOME = origCcsHome;
|
process.env.CCS_HOME = origCcsHome;
|
||||||
} else {
|
} 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('Quota Exhaustion Handlers', () => {
|
||||||
describe('writeQuotaWarning', () => {
|
describe('writeQuotaWarning', () => {
|
||||||
it('should write to stderr with box format', async () => {
|
it('should write to stderr with box format', async () => {
|
||||||
@@ -230,6 +253,69 @@ describe('Quota Exhaustion Handlers', () => {
|
|||||||
expect(result.reason).toContain('no alternatives');
|
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 () => {
|
it('should write warning to stderr', async () => {
|
||||||
writeRegistry({
|
writeRegistry({
|
||||||
agy: {
|
agy: {
|
||||||
|
|||||||
@@ -395,6 +395,101 @@ describe('Claude Quota Fetcher', () => {
|
|||||||
expect(result.coreUsage?.weekly).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 () => {
|
it('fails fast when auth file has no token', async () => {
|
||||||
createClaudeAccount('claude-missing@example.com', {
|
createClaudeAccount('claude-missing@example.com', {
|
||||||
access_token: ' ',
|
access_token: ' ',
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||||
import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
||||||
import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
|
import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import type { AccountItemProps } from './types';
|
import type { AccountItemProps } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,6 +107,7 @@ export function AccountItem({
|
|||||||
selected,
|
selected,
|
||||||
onSelectChange,
|
onSelectChange,
|
||||||
}: AccountItemProps) {
|
}: AccountItemProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const normalizedProvider = account.provider.toLowerCase();
|
const normalizedProvider = account.provider.toLowerCase();
|
||||||
const isCodexProvider = normalizedProvider === 'codex';
|
const isCodexProvider = normalizedProvider === 'codex';
|
||||||
const isClaudeProvider = normalizedProvider === 'claude' || normalizedProvider === 'anthropic';
|
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"
|
className="text-[10px] h-5 px-2 gap-1 border-muted-foreground/50 text-muted-foreground"
|
||||||
>
|
>
|
||||||
<HelpCircle className="w-3 h-3" />
|
<HelpCircle className="w-3 h-3" />
|
||||||
No limits
|
{t('accountCard.quotaUnavailable')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
) : quota?.needsReauth ? (
|
) : quota?.needsReauth ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user