From 8c790f41ffba9a09467899b8b641dcbe2b692ae3 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 22 Feb 2026 02:12:40 +0700 Subject: [PATCH] fix(cliproxy): close remaining quota edge-case gaps --- .../quota-fetcher-claude-normalizer.ts | 75 ++--- src/cliproxy/quota-manager.ts | 13 +- src/commands/cliproxy/index.ts | 18 +- .../cliproxy/quota-fetcher-claude.test.ts | 260 ++++++++++++++++++ .../commands/cliproxy-provider-arg.test.ts | 54 ++++ .../account/flow-viz/account-card.tsx | 48 ++-- .../cliproxy/provider-editor/account-item.tsx | 38 ++- .../provider-editor/model-config-tab.tsx | 12 +- ui/src/hooks/use-cliproxy-stats.ts | 38 ++- ui/src/lib/utils.ts | 6 +- 10 files changed, 482 insertions(+), 80 deletions(-) create mode 100644 tests/unit/commands/cliproxy-provider-arg.test.ts diff --git a/src/cliproxy/quota-fetcher-claude-normalizer.ts b/src/cliproxy/quota-fetcher-claude-normalizer.ts index a082fd18..11d8396e 100644 --- a/src/cliproxy/quota-fetcher-claude-normalizer.ts +++ b/src/cliproxy/quota-fetcher-claude-normalizer.ts @@ -64,6 +64,10 @@ function getClaudeWindowLabel(rateLimitType: string): string { return 'Opus limit'; case 'seven_day_sonnet': return 'Sonnet limit'; + case 'seven_day_oauth_apps': + return 'OAuth apps limit'; + case 'seven_day_cowork': + return 'Cowork limit'; case 'overage': return 'Extra usage'; default: @@ -71,6 +75,10 @@ function getClaudeWindowLabel(rateLimitType: string): string { } } +function clampUnit(value: number): number { + return Math.max(0, Math.min(1, value)); +} + function normalizeUtilization(raw: Record): { utilization: number | null; usedPercent: number; @@ -82,9 +90,10 @@ function normalizeUtilization(raw: Record): { if (utilizationRaw !== null) { const ratio = utilizationRaw <= 1 ? utilizationRaw : utilizationRaw / 100; - const usedPercent = clampPercent(ratio * 100); + const normalizedRatio = clampUnit(ratio); + const usedPercent = clampPercent(normalizedRatio * 100); return { - utilization: ratio, + utilization: normalizedRatio, usedPercent, remainingPercent: clampPercent(100 - usedPercent), }; @@ -243,6 +252,14 @@ function mapCoreWindow(window: ClaudeQuotaWindow | null): ClaudeCoreUsageSummary }; } +const WEEKLY_RATE_LIMIT_TYPES = new Set([ + 'seven_day', + 'seven_day_opus', + 'seven_day_sonnet', + 'seven_day_oauth_apps', + 'seven_day_cowork', +]); + /** * Build explicit 5h + weekly usage summary from Claude policy windows. */ @@ -253,42 +270,38 @@ export function buildClaudeCoreUsageSummary(windows: ClaudeQuotaWindow[]): Claud const fiveHourWindow = windows.find((window) => window.rateLimitType === 'five_hour') || null; const weeklyCandidates = windows.filter((window) => - ['seven_day', 'seven_day_opus', 'seven_day_sonnet'].includes(window.rateLimitType) + WEEKLY_RATE_LIMIT_TYPES.has(window.rateLimitType) ); const weeklyWindow = pickMostRestrictiveWeekly(weeklyCandidates); - // Fallback: infer shortest/longest reset windows from non-overage limits. - if (!fiveHourWindow || !weeklyWindow) { - const nonOverage = windows.filter((window) => window.rateLimitType !== 'overage'); - const withReset = nonOverage - .map((window) => ({ - window, - resetMs: toEpochMs(window.resetAt), - })) - .filter((entry) => entry.resetMs !== null) - .sort((a, b) => (a.resetMs as number) - (b.resetMs as number)); - - const inferredFiveHour = - fiveHourWindow || - (withReset.length > 0 - ? withReset[0].window - : nonOverage.length > 0 - ? pickMostRestrictiveWeekly(nonOverage) - : null); - const inferredWeekly = - weeklyWindow || - (withReset.length > 1 - ? withReset[withReset.length - 1].window - : nonOverage.find((window) => window !== inferredFiveHour) || null); - + if (fiveHourWindow && weeklyWindow) { return { - fiveHour: mapCoreWindow(inferredFiveHour), - weekly: mapCoreWindow(inferredWeekly), + fiveHour: mapCoreWindow(fiveHourWindow), + weekly: mapCoreWindow(weeklyWindow), }; } + // Fallback: infer shortest/longest reset windows from non-overage limits. + const nonOverage = windows.filter((window) => window.rateLimitType !== 'overage'); + const withReset = nonOverage + .map((window) => ({ + window, + resetMs: toEpochMs(window.resetAt), + })) + .filter((entry) => entry.resetMs !== null) + .sort((a, b) => (a.resetMs as number) - (b.resetMs as number)); + + const inferredWeekly = + weeklyWindow || + [...withReset].reverse().find((entry) => entry.window !== fiveHourWindow)?.window || + pickMostRestrictiveWeekly(nonOverage.filter((window) => window !== fiveHourWindow)); + const inferredFiveHour = + fiveHourWindow || + withReset.find((entry) => entry.window !== inferredWeekly)?.window || + pickMostRestrictiveWeekly(nonOverage.filter((window) => window !== inferredWeekly)); + return { - fiveHour: mapCoreWindow(fiveHourWindow), - weekly: mapCoreWindow(weeklyWindow), + fiveHour: mapCoreWindow(inferredFiveHour), + weekly: mapCoreWindow(inferredWeekly), }; } diff --git a/src/cliproxy/quota-manager.ts b/src/cliproxy/quota-manager.ts index 2aea73fc..d8a4fd78 100644 --- a/src/cliproxy/quota-manager.ts +++ b/src/cliproxy/quota-manager.ts @@ -520,7 +520,18 @@ function scheduleNextPoll( try { const quota = await fetchQuotaWithDedup(provider, accountId); if (monitorStopped) return; // Re-check after async fetch - const avgQuota = calculateQuotaPercent(quota) ?? 100; + const avgQuota = calculateQuotaPercent(quota); + + if (avgQuota === null) { + // Quota data unavailable: keep polling, but do not treat unknown as healthy/exhausted. + scheduleNextPoll( + provider, + accountId, + monitorConfig, + monitorConfig.normal_interval_seconds * 1000 + ); + return; + } if (avgQuota <= monitorConfig.exhaustion_threshold) { // EXHAUSTED: cooldown + switch default + stop monitoring. diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index b1735bc5..972c692e 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -97,31 +97,33 @@ function normalizeQuotaProvider(value: string): QuotaProviderFilter | null { return canonicalProvider; } -function parseProviderArg(args: string[]): { +export function parseProviderArg(args: string[]): { provider: QuotaProviderFilter; remainingArgs: string[]; + invalid: boolean; } { const extracted = extractOption(args, ['--provider']); if (!extracted.found) { - return { provider: 'all', remainingArgs: args }; + return { provider: 'all', remainingArgs: args, invalid: false }; } if (extracted.missingValue || !extracted.value) { console.error( - `Warning: --provider requires a value. Valid options: ${QUOTA_PROVIDER_HELP_TEXT}` + `Invalid provider value. --provider requires a value. Valid options: ${QUOTA_PROVIDER_HELP_TEXT}` ); - return { provider: 'all', remainingArgs: extracted.remainingArgs }; + return { provider: 'all', remainingArgs: extracted.remainingArgs, invalid: true }; } const value = extracted.value.toLowerCase(); const normalized = normalizeQuotaProvider(value); if (!normalized) { console.error(`Invalid provider '${value}'. Valid options: ${QUOTA_PROVIDER_HELP_TEXT}`); - return { provider: 'all', remainingArgs: extracted.remainingArgs }; + return { provider: 'all', remainingArgs: extracted.remainingArgs, invalid: true }; } return { provider: normalized, remainingArgs: extracted.remainingArgs, + invalid: false, }; } @@ -163,7 +165,11 @@ export async function handleCliproxyCommand(args: string[]): Promise { } if (command === 'quota') { - const { provider: providerFilter } = parseProviderArg(remainingArgs.slice(1)); + const { provider: providerFilter, invalid } = parseProviderArg(remainingArgs.slice(1)); + if (invalid) { + process.exitCode = 1; + return; + } await handleQuotaStatus(verbose, providerFilter); return; } diff --git a/tests/unit/cliproxy/quota-fetcher-claude.test.ts b/tests/unit/cliproxy/quota-fetcher-claude.test.ts index 1e591067..40cbcdef 100644 --- a/tests/unit/cliproxy/quota-fetcher-claude.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-claude.test.ts @@ -121,6 +121,42 @@ describe('Claude Quota Fetcher', () => { expect.arrayContaining(['five_hour', 'seven_day_opus']) ); }); + + it('clamps utilization ratio into 0..1', () => { + const windows = buildClaudeQuotaWindows({ + restrictions: [ + { + rateLimitType: 'five_hour', + utilization: 150, + status: 'allowed', + }, + { + rateLimitType: 'seven_day', + utilization: -25, + status: 'allowed', + }, + ], + }); + + expect(windows).toHaveLength(2); + expect(windows[0].utilization).toBe(1); + expect(windows[0].remainingPercent).toBe(0); + expect(windows[1].utilization).toBe(0); + expect(windows[1].remainingPercent).toBe(100); + }); + + it('parses direct single restriction payload shape', () => { + const windows = buildClaudeQuotaWindows({ + rateLimitType: 'five_hour', + utilization: 0.6, + status: 'allowed', + resetsAt: '2026-02-28T10:00:00Z', + }); + + expect(windows).toHaveLength(1); + expect(windows[0].rateLimitType).toBe('five_hour'); + expect(windows[0].remainingPercent).toBe(40); + }); }); describe('buildClaudeCoreUsageSummary', () => { @@ -159,6 +195,93 @@ describe('Claude Quota Fetcher', () => { expect(summary.weekly?.rateLimitType).toBe('seven_day_opus'); expect(summary.weekly?.remainingPercent).toBe(15); }); + + it('considers oauth/cowork weekly windows in core summary', () => { + const summary = buildClaudeCoreUsageSummary([ + { + rateLimitType: 'five_hour', + label: 'Session limit', + status: 'allowed', + utilization: 0.35, + usedPercent: 35, + remainingPercent: 65, + resetAt: '2026-02-28T10:00:00Z', + }, + { + rateLimitType: 'seven_day_oauth_apps', + label: 'OAuth apps limit', + status: 'allowed_warning', + utilization: 0.92, + usedPercent: 92, + remainingPercent: 8, + resetAt: '2026-03-06T10:00:00Z', + }, + { + rateLimitType: 'seven_day_cowork', + label: 'Cowork limit', + status: 'allowed', + utilization: 0.4, + usedPercent: 40, + remainingPercent: 60, + resetAt: '2026-03-06T12:00:00Z', + }, + ]); + + expect(summary.fiveHour?.rateLimitType).toBe('five_hour'); + expect(summary.weekly?.rateLimitType).toBe('seven_day_oauth_apps'); + expect(summary.weekly?.remainingPercent).toBe(8); + }); + + it('does not duplicate weekly window into fiveHour when only weekly exists', () => { + const summary = buildClaudeCoreUsageSummary([ + { + rateLimitType: 'seven_day', + label: 'Weekly limit', + status: 'allowed', + utilization: 0.45, + usedPercent: 45, + remainingPercent: 55, + resetAt: '2026-03-06T10:00:00Z', + }, + ]); + + expect(summary.fiveHour).toBeNull(); + expect(summary.weekly?.rateLimitType).toBe('seven_day'); + }); + + it('uses earliest reset as tie-breaker for equal weekly remaining quota', () => { + const summary = buildClaudeCoreUsageSummary([ + { + rateLimitType: 'five_hour', + label: 'Session limit', + status: 'allowed', + utilization: 0.2, + usedPercent: 20, + remainingPercent: 80, + resetAt: '2026-02-28T10:00:00Z', + }, + { + rateLimitType: 'seven_day_opus', + label: 'Opus limit', + status: 'allowed', + utilization: 0.6, + usedPercent: 60, + remainingPercent: 40, + resetAt: '2026-03-06T12:00:00Z', + }, + { + rateLimitType: 'seven_day_sonnet', + label: 'Sonnet limit', + status: 'allowed', + utilization: 0.6, + usedPercent: 60, + remainingPercent: 40, + resetAt: '2026-03-06T10:00:00Z', + }, + ]); + + expect(summary.weekly?.rateLimitType).toBe('seven_day_sonnet'); + }); }); describe('fetchClaudeQuota', () => { @@ -253,5 +376,142 @@ describe('Claude Quota Fetcher', () => { expect(result.error).toContain('Auth file not found'); expect(fetchMock).toHaveBeenCalledTimes(0); }); + + it('retries once on transient 500 then succeeds', async () => { + createClaudeAccount('claude-retry@example.com', { + access_token: 'retry-token', + expired: '2099-01-01T00:00:00.000Z', + type: 'claude', + }); + + let attempt = 0; + global.fetch = mock(() => { + attempt += 1; + if (attempt === 1) { + return Promise.resolve(new Response('', { status: 500 })); + } + + return Promise.resolve( + new Response( + JSON.stringify({ + restrictions: [ + { + rateLimitType: 'five_hour', + utilization: 0.4, + resetsAt: '2026-03-01T01:00:00Z', + status: 'allowed', + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ); + }) as typeof fetch; + + const result = await fetchClaudeQuota('claude-retry@example.com'); + + expect(result.success).toBe(true); + expect(attempt).toBe(2); + expect(result.coreUsage?.fiveHour?.remainingPercent).toBe(60); + }); + + it('retries once after AbortError and succeeds', async () => { + createClaudeAccount('claude-timeout@example.com', { + access_token: 'timeout-token', + expired: '2099-01-01T00:00:00.000Z', + type: 'claude', + }); + + let attempt = 0; + global.fetch = mock(() => { + attempt += 1; + if (attempt === 1) { + const abortError = Object.assign(new Error('aborted'), { name: 'AbortError' }); + return Promise.reject(abortError); + } + + return Promise.resolve( + new Response( + JSON.stringify({ + restrictions: [ + { + rateLimitType: 'seven_day', + utilization: 0.3, + resetsAt: '2026-03-07T01:00:00Z', + status: 'allowed', + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ); + }) as typeof fetch; + + const result = await fetchClaudeQuota('claude-timeout@example.com'); + + expect(result.success).toBe(true); + expect(attempt).toBe(2); + expect(result.coreUsage?.weekly?.remainingPercent).toBe(70); + }); + + it('falls back to alternate auth file when preferred file is invalid JSON', async () => { + const accountId = 'claude-fallback@example.com'; + const cliproxyDir = path.join(tmpDir, '.ccs', 'cliproxy'); + const authDir = path.join(cliproxyDir, 'auth'); + const sanitized = sanitizeEmail(accountId); + + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync(path.join(authDir, `claude-${sanitized}.json`), '{invalid'); + fs.writeFileSync( + path.join(authDir, `anthropic-${sanitized}.json`), + JSON.stringify( + { + access_token: 'valid-anthropic-token', + expired: '2099-01-01T00:00:00.000Z', + type: 'anthropic', + }, + null, + 2 + ) + ); + fs.writeFileSync( + path.join(cliproxyDir, 'accounts.json'), + JSON.stringify( + { + version: 1, + providers: { + claude: { + default: accountId, + accounts: { + [accountId]: { + email: accountId, + tokenFile: `anthropic-${sanitized}.json`, + createdAt: '2026-02-20T00:00:00.000Z', + lastUsedAt: '2026-02-20T00:00:00.000Z', + }, + }, + }, + }, + }, + null, + 2 + ) + ); + + global.fetch = mock((_url: string, options?: RequestInit) => { + expect(options?.headers).toMatchObject({ + Authorization: 'Bearer valid-anthropic-token', + }); + return Promise.resolve( + new Response(JSON.stringify({ restrictions: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + }) as typeof fetch; + + const result = await fetchClaudeQuota(accountId); + expect(result.success).toBe(true); + }); }); }); diff --git a/tests/unit/commands/cliproxy-provider-arg.test.ts b/tests/unit/commands/cliproxy-provider-arg.test.ts new file mode 100644 index 00000000..2be22e06 --- /dev/null +++ b/tests/unit/commands/cliproxy-provider-arg.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, mock } from 'bun:test'; +import { parseProviderArg } from '../../../src/commands/cliproxy'; + +const originalConsoleError = console.error; + +afterEach(() => { + console.error = originalConsoleError; +}); + +describe('parseProviderArg', () => { + it('defaults to all when --provider is not specified', () => { + const result = parseProviderArg(['--verbose']); + + expect(result.provider).toBe('all'); + expect(result.invalid).toBe(false); + expect(result.remainingArgs).toEqual(['--verbose']); + }); + + it('accepts canonical providers', () => { + const result = parseProviderArg(['--provider', 'claude']); + + expect(result.provider).toBe('claude'); + expect(result.invalid).toBe(false); + }); + + it('accepts external aliases', () => { + const result = parseProviderArg(['--provider', 'anthropic']); + + expect(result.provider).toBe('claude'); + expect(result.invalid).toBe(false); + }); + + it('marks invalid when provider value is unsupported', () => { + const errorSpy = mock(() => {}); + console.error = errorSpy as typeof console.error; + + const result = parseProviderArg(['--provider', 'nope']); + + expect(result.provider).toBe('all'); + expect(result.invalid).toBe(true); + expect(errorSpy).toHaveBeenCalled(); + }); + + it('marks invalid when --provider value is missing', () => { + const errorSpy = mock(() => {}); + console.error = errorSpy as typeof console.error; + + const result = parseProviderArg(['--provider']); + + expect(result.provider).toBe('all'); + expect(result.invalid).toBe(true); + expect(errorSpy).toHaveBeenCalled(); + }); +}); diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 599c1c1a..4bf4abc5 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -24,6 +24,13 @@ import { cleanEmail } from './utils'; import { AccountCardStats } from './account-card-stats'; type Zone = 'left' | 'right' | 'top' | 'bottom'; +const QUOTA_PROVIDER_ALIASES = [ + 'antigravity', + 'anthropic', + 'gemini-cli', + 'copilot', + 'github-copilot', +]; interface AccountCardProps { account: AccountData; @@ -92,11 +99,14 @@ export function AccountCard({ const connectorPosition = CONNECTOR_POSITION_MAP[zone]; // Quota for CLIProxy accounts (agy, codex, claude, gemini, ghcp) - const isCliproxyProvider = QUOTA_SUPPORTED_PROVIDERS.includes( - account.provider as QuotaSupportedProvider - ); + const normalizedProvider = account.provider.toLowerCase(); + const isCliproxyProvider = + QUOTA_SUPPORTED_PROVIDERS.includes(normalizedProvider as QuotaSupportedProvider) || + QUOTA_PROVIDER_ALIASES.includes(normalizedProvider); + const isCodexProvider = normalizedProvider === 'codex'; + const isClaudeProvider = normalizedProvider === 'claude' || normalizedProvider === 'anthropic'; const { data: quota, isLoading: quotaLoading } = useAccountQuota( - account.provider, + normalizedProvider, account.id, isCliproxyProvider ); @@ -105,7 +115,7 @@ export function AccountCard({ const minQuota = getProviderMinQuota(account.provider, quota); const resetTime = getProviderResetTime(account.provider, quota); const codexBreakdown = - account.provider === 'codex' && quota && isCodexQuotaResult(quota) + isCodexProvider && quota && isCodexQuotaResult(quota) ? getCodexQuotaBreakdown(quota.windows) : null; const codexQuotaRows = [ @@ -113,7 +123,7 @@ export function AccountCard({ { label: 'Wk', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null }, ].filter((row): row is { label: string; value: number } => row.value !== null); const claudeQuotaRows = - account.provider === 'claude' && quota && isClaudeQuotaResult(quota) + isClaudeProvider && quota && isClaudeQuotaResult(quota) ? [ { label: '5h', @@ -140,13 +150,13 @@ export function AccountCard({ }, ].filter((row): row is { label: string; value: number } => row.value !== null) : []; - const compactQuotaRows = - account.provider === 'codex' - ? codexQuotaRows - : account.provider === 'claude' - ? claudeQuotaRows - : []; + const compactQuotaRows = isCodexProvider + ? codexQuotaRows + : isClaudeProvider + ? claudeQuotaRows + : []; const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null; + const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null; // Tier badge (AGY only) - show P for Pro, U for Ultra const showTierBadge = @@ -258,7 +268,7 @@ export function AccountCard({ Quota... - ) : minQuota !== null ? ( + ) : minQuotaValue !== null ? ( @@ -270,9 +280,9 @@ export function AccountCard({ 50 + minQuotaValue > 50 ? 'text-emerald-600 dark:text-emerald-400' - : minQuota > 20 + : minQuotaValue > 20 ? 'text-amber-500' : 'text-red-500' )} @@ -293,13 +303,13 @@ export function AccountCard({
50 + minQuotaValue > 50 ? 'bg-emerald-500' - : minQuota > 20 + : minQuotaValue > 20 ? 'bg-amber-500' : 'bg-red-500' )} - style={{ width: `${minQuota}%` }} + style={{ width: `${minQuotaValue}%` }} />
@@ -309,6 +319,8 @@ export function AccountCard({
+ ) : quota?.success ? ( +
Quota limits unavailable
) : quota?.needsReauth ? ( diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 01aa19b3..fd95e5fe 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -106,12 +106,16 @@ export function AccountItem({ selected, onSelectChange, }: AccountItemProps) { + const normalizedProvider = account.provider.toLowerCase(); + const isCodexProvider = normalizedProvider === 'codex'; + const isClaudeProvider = normalizedProvider === 'claude' || normalizedProvider === 'anthropic'; + // Fetch runtime stats to get actual lastUsedAt (more accurate than file state) const { data: stats } = useCliproxyStats(showQuota); // Fetch quota for all provider accounts const { data: quota, isLoading: quotaLoading } = useAccountQuota( - account.provider, + normalizedProvider, account.id, showQuota ); @@ -124,7 +128,7 @@ export function AccountItem({ const minQuota = getProviderMinQuota(account.provider, quota); const nextReset = getProviderResetTime(account.provider, quota); const codexBreakdown = - account.provider === 'codex' && quota && isCodexQuotaResult(quota) + isCodexProvider && quota && isCodexQuotaResult(quota) ? getCodexQuotaBreakdown(quota.windows) : null; const codexQuotaRows = [ @@ -132,7 +136,7 @@ export function AccountItem({ { label: 'Weekly', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null }, ].filter((row): row is { label: string; value: number } => row.value !== null); const claudeQuotaRows = - account.provider === 'claude' && quota && isClaudeQuotaResult(quota) + isClaudeProvider && quota && isClaudeQuotaResult(quota) ? [ { label: '5h', @@ -159,13 +163,13 @@ export function AccountItem({ }, ].filter((row): row is { label: string; value: number } => row.value !== null) : []; - const dualWindowQuotaRows = - account.provider === 'codex' - ? codexQuotaRows - : account.provider === 'claude' - ? claudeQuotaRows - : []; + const dualWindowQuotaRows = isCodexProvider + ? codexQuotaRows + : isClaudeProvider + ? claudeQuotaRows + : []; const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null; + const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null; return (
Loading quota...
- ) : minQuota !== null ? ( + ) : minQuotaValue !== null ? (
{/* Status indicator based on runtime usage, not file state */}
@@ -409,9 +413,9 @@ export function AccountItem({ ) : (
{minQuotaLabel}% @@ -425,6 +429,16 @@ export function AccountItem({
+ ) : quota?.success ? ( +
+ + + No limits + +
) : quota?.needsReauth ? ( diff --git a/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx index a42ed816..320a0495 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx @@ -94,6 +94,14 @@ export function ModelConfigTab({ privacyMode, isRemoteMode, }: ModelConfigTabProps) { + const normalizedProvider = provider.toLowerCase(); + const showQuota = + (QUOTA_SUPPORTED_PROVIDERS.includes(normalizedProvider as QuotaSupportedProvider) || + ['anthropic', 'antigravity', 'gemini-cli', 'copilot', 'github-copilot'].includes( + normalizedProvider + )) && + !isRemoteMode; + // Kiro-specific: no-incognito setting (defaults to true = normal browser) const isKiro = provider === 'kiro'; const [kiroNoIncognito, setKiroNoIncognito] = useState(true); @@ -177,9 +185,7 @@ export function ModelConfigTab({ isBulkPausing={isBulkPausing} isBulkResuming={isBulkResuming} privacyMode={privacyMode} - showQuota={ - QUOTA_SUPPORTED_PROVIDERS.includes(provider as QuotaSupportedProvider) && !isRemoteMode - } + showQuota={showQuota} isKiro={isKiro} kiroNoIncognito={kiroNoIncognito} onKiroNoIncognitoChange={saveKiroNoIncognito} diff --git a/ui/src/hooks/use-cliproxy-stats.ts b/ui/src/hooks/use-cliproxy-stats.ts index 5cb09892..105049b6 100644 --- a/ui/src/hooks/use-cliproxy-stats.ts +++ b/ui/src/hooks/use-cliproxy-stats.ts @@ -216,6 +216,26 @@ export type { /** Providers with quota API support */ export const QUOTA_SUPPORTED_PROVIDERS = ['agy', 'codex', 'claude', 'gemini', 'ghcp'] as const; export type QuotaSupportedProvider = (typeof QUOTA_SUPPORTED_PROVIDERS)[number]; +const QUOTA_PROVIDER_ALIAS_MAP: Readonly> = { + antigravity: 'agy', + anthropic: 'claude', + 'gemini-cli': 'gemini', + copilot: 'ghcp', + 'github-copilot': 'ghcp', +}; + +function normalizeQuotaProvider(provider: string): QuotaSupportedProvider | null { + const normalized = provider.trim().toLowerCase(); + if (!normalized) { + return null; + } + + if ((QUOTA_SUPPORTED_PROVIDERS as readonly string[]).includes(normalized)) { + return normalized as QuotaSupportedProvider; + } + + return QUOTA_PROVIDER_ALIAS_MAP[normalized] ?? null; +} /** * Fetch account quota from generic API route @@ -317,7 +337,12 @@ async function fetchQuotaByProvider( provider: string, accountId: string ): Promise { - switch (provider) { + const canonicalProvider = normalizeQuotaProvider(provider); + if (!canonicalProvider) { + return fetchAccountQuota(provider, accountId); + } + + switch (canonicalProvider) { case 'codex': return fetchCodexQuotaApi(accountId); case 'claude': @@ -336,13 +361,12 @@ async function fetchQuotaByProvider( * Supports agy, codex, claude, gemini, and ghcp providers */ export function useAccountQuota(provider: string, accountId: string, enabled = true) { + const canonicalProvider = normalizeQuotaProvider(provider); + return useQuery({ - queryKey: ['account-quota', provider, accountId], - queryFn: () => fetchQuotaByProvider(provider, accountId), - enabled: - enabled && - QUOTA_SUPPORTED_PROVIDERS.includes(provider as QuotaSupportedProvider) && - !!accountId, + queryKey: ['account-quota', canonicalProvider ?? provider, accountId], + queryFn: () => fetchQuotaByProvider(canonicalProvider ?? provider, accountId), + enabled: enabled && !!canonicalProvider && !!accountId, staleTime: 60000, // Match refetchInterval to prevent early refetching refetchInterval: 60000, // Refresh every 1 minute refetchOnWindowFocus: false, // Don't refetch on tab switch diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 7296690e..56007a4b 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -670,8 +670,9 @@ export function getProviderMinQuota( quota: UnifiedQuotaResult | null | undefined ): number | null { if (!quota?.success) return null; + const normalizedProvider = provider.trim().toLowerCase(); - switch (provider) { + switch (normalizedProvider) { case 'agy': if (isAgyQuotaResult(quota)) { return getMinClaudeQuota(quota.models); @@ -713,8 +714,9 @@ export function getProviderResetTime( quota: UnifiedQuotaResult | null | undefined ): string | null { if (!quota?.success) return null; + const normalizedProvider = provider.trim().toLowerCase(); - switch (provider) { + switch (normalizedProvider) { case 'agy': if (isAgyQuotaResult(quota)) { return getClaudeResetTime(quota.models);