fix(cliproxy): close remaining quota edge-case gaps

This commit is contained in:
Tam Nhu Tran committed 2026-02-22 02:12:40 +07:00
1 parent 9371a72c16
commit 8c790f41ff
10 files changed
+482 -80

No files matched your search

+44 -31
View File
@@ -64,6 +64,10 @@ function getClaudeWindowLabel(rateLimitType: string): string {
return 'Opus limit'; return 'Opus limit';
case 'seven_day_sonnet': case 'seven_day_sonnet':
return 'Sonnet limit'; return 'Sonnet limit';
case 'seven_day_oauth_apps':
return 'OAuth apps limit';
case 'seven_day_cowork':
return 'Cowork limit';
case 'overage': case 'overage':
return 'Extra usage'; return 'Extra usage';
default: 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<string, unknown>): { function normalizeUtilization(raw: Record<string, unknown>): {
utilization: number | null; utilization: number | null;
usedPercent: number; usedPercent: number;
@@ -82,9 +90,10 @@ function normalizeUtilization(raw: Record<string, unknown>): {
if (utilizationRaw !== null) { if (utilizationRaw !== null) {
const ratio = utilizationRaw <= 1 ? utilizationRaw : utilizationRaw / 100; const ratio = utilizationRaw <= 1 ? utilizationRaw : utilizationRaw / 100;
const usedPercent = clampPercent(ratio * 100); const normalizedRatio = clampUnit(ratio);
const usedPercent = clampPercent(normalizedRatio * 100);
return { return {
utilization: ratio, utilization: normalizedRatio,
usedPercent, usedPercent,
remainingPercent: clampPercent(100 - 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. * 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 fiveHourWindow = windows.find((window) => window.rateLimitType === 'five_hour') || null;
const weeklyCandidates = windows.filter((window) => 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); const weeklyWindow = pickMostRestrictiveWeekly(weeklyCandidates);
// Fallback: infer shortest/longest reset windows from non-overage limits. if (fiveHourWindow && weeklyWindow) {
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);
return { return {
fiveHour: mapCoreWindow(inferredFiveHour), fiveHour: mapCoreWindow(fiveHourWindow),
weekly: mapCoreWindow(inferredWeekly), 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 { return {
fiveHour: mapCoreWindow(fiveHourWindow), fiveHour: mapCoreWindow(inferredFiveHour),
weekly: mapCoreWindow(weeklyWindow), weekly: mapCoreWindow(inferredWeekly),
}; };
} }
+12 -1
View File
@@ -520,7 +520,18 @@ function scheduleNextPoll(
try { try {
const quota = await fetchQuotaWithDedup(provider, accountId); const quota = await fetchQuotaWithDedup(provider, accountId);
if (monitorStopped) return; // Re-check after async fetch 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) { if (avgQuota <= monitorConfig.exhaustion_threshold) {
// EXHAUSTED: cooldown + switch default + stop monitoring. // EXHAUSTED: cooldown + switch default + stop monitoring.
+12 -6
View File
@@ -97,31 +97,33 @@ function normalizeQuotaProvider(value: string): QuotaProviderFilter | null {
return canonicalProvider; return canonicalProvider;
} }
function parseProviderArg(args: string[]): { export function parseProviderArg(args: string[]): {
provider: QuotaProviderFilter; provider: QuotaProviderFilter;
remainingArgs: string[]; remainingArgs: string[];
invalid: boolean;
} { } {
const extracted = extractOption(args, ['--provider']); const extracted = extractOption(args, ['--provider']);
if (!extracted.found) { if (!extracted.found) {
return { provider: 'all', remainingArgs: args }; return { provider: 'all', remainingArgs: args, invalid: false };
} }
if (extracted.missingValue || !extracted.value) { if (extracted.missingValue || !extracted.value) {
console.error( 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 value = extracted.value.toLowerCase();
const normalized = normalizeQuotaProvider(value); const normalized = normalizeQuotaProvider(value);
if (!normalized) { if (!normalized) {
console.error(`Invalid provider '${value}'. Valid options: ${QUOTA_PROVIDER_HELP_TEXT}`); 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 { return {
provider: normalized, provider: normalized,
remainingArgs: extracted.remainingArgs, remainingArgs: extracted.remainingArgs,
invalid: false,
}; };
} }
@@ -163,7 +165,11 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
} }
if (command === 'quota') { 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); await handleQuotaStatus(verbose, providerFilter);
return; return;
} }
@@ -121,6 +121,42 @@ describe('Claude Quota Fetcher', () => {
expect.arrayContaining(['five_hour', 'seven_day_opus']) 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', () => { describe('buildClaudeCoreUsageSummary', () => {
@@ -159,6 +195,93 @@ describe('Claude Quota Fetcher', () => {
expect(summary.weekly?.rateLimitType).toBe('seven_day_opus'); expect(summary.weekly?.rateLimitType).toBe('seven_day_opus');
expect(summary.weekly?.remainingPercent).toBe(15); 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', () => { describe('fetchClaudeQuota', () => {
@@ -253,5 +376,142 @@ describe('Claude Quota Fetcher', () => {
expect(result.error).toContain('Auth file not found'); expect(result.error).toContain('Auth file not found');
expect(fetchMock).toHaveBeenCalledTimes(0); 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);
});
}); });
}); });
@@ -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();
});
});
@@ -24,6 +24,13 @@ import { cleanEmail } from './utils';
import { AccountCardStats } from './account-card-stats'; import { AccountCardStats } from './account-card-stats';
type Zone = 'left' | 'right' | 'top' | 'bottom'; type Zone = 'left' | 'right' | 'top' | 'bottom';
const QUOTA_PROVIDER_ALIASES = [
'antigravity',
'anthropic',
'gemini-cli',
'copilot',
'github-copilot',
];
interface AccountCardProps { interface AccountCardProps {
account: AccountData; account: AccountData;
@@ -92,11 +99,14 @@ export function AccountCard({
const connectorPosition = CONNECTOR_POSITION_MAP[zone]; const connectorPosition = CONNECTOR_POSITION_MAP[zone];
// Quota for CLIProxy accounts (agy, codex, claude, gemini, ghcp) // Quota for CLIProxy accounts (agy, codex, claude, gemini, ghcp)
const isCliproxyProvider = QUOTA_SUPPORTED_PROVIDERS.includes( const normalizedProvider = account.provider.toLowerCase();
account.provider as QuotaSupportedProvider 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( const { data: quota, isLoading: quotaLoading } = useAccountQuota(
account.provider, normalizedProvider,
account.id, account.id,
isCliproxyProvider isCliproxyProvider
); );
@@ -105,7 +115,7 @@ export function AccountCard({
const minQuota = getProviderMinQuota(account.provider, quota); const minQuota = getProviderMinQuota(account.provider, quota);
const resetTime = getProviderResetTime(account.provider, quota); const resetTime = getProviderResetTime(account.provider, quota);
const codexBreakdown = const codexBreakdown =
account.provider === 'codex' && quota && isCodexQuotaResult(quota) isCodexProvider && quota && isCodexQuotaResult(quota)
? getCodexQuotaBreakdown(quota.windows) ? getCodexQuotaBreakdown(quota.windows)
: null; : null;
const codexQuotaRows = [ const codexQuotaRows = [
@@ -113,7 +123,7 @@ export function AccountCard({
{ label: 'Wk', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null }, { label: 'Wk', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null },
].filter((row): row is { label: string; value: number } => row.value !== null); ].filter((row): row is { label: string; value: number } => row.value !== null);
const claudeQuotaRows = const claudeQuotaRows =
account.provider === 'claude' && quota && isClaudeQuotaResult(quota) isClaudeProvider && quota && isClaudeQuotaResult(quota)
? [ ? [
{ {
label: '5h', label: '5h',
@@ -140,13 +150,13 @@ export function AccountCard({
}, },
].filter((row): row is { label: string; value: number } => row.value !== null) ].filter((row): row is { label: string; value: number } => row.value !== null)
: []; : [];
const compactQuotaRows = const compactQuotaRows = isCodexProvider
account.provider === 'codex' ? codexQuotaRows
? codexQuotaRows : isClaudeProvider
: account.provider === 'claude' ? claudeQuotaRows
? claudeQuotaRows : [];
: [];
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null; 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 // Tier badge (AGY only) - show P for Pro, U for Ultra
const showTierBadge = const showTierBadge =
@@ -258,7 +268,7 @@ export function AccountCard({
<Loader2 className="w-2.5 h-2.5 animate-spin" /> <Loader2 className="w-2.5 h-2.5 animate-spin" />
<span>Quota...</span> <span>Quota...</span>
</div> </div>
) : minQuota !== null ? ( ) : minQuotaValue !== null ? (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -270,9 +280,9 @@ export function AccountCard({
<span <span
className={cn( className={cn(
'text-[10px] font-mono font-bold', 'text-[10px] font-mono font-bold',
minQuota > 50 minQuotaValue > 50
? 'text-emerald-600 dark:text-emerald-400' ? 'text-emerald-600 dark:text-emerald-400'
: minQuota > 20 : minQuotaValue > 20
? 'text-amber-500' ? 'text-amber-500'
: 'text-red-500' : 'text-red-500'
)} )}
@@ -293,13 +303,13 @@ export function AccountCard({
<div <div
className={cn( className={cn(
'h-full rounded-full transition-all', 'h-full rounded-full transition-all',
minQuota > 50 minQuotaValue > 50
? 'bg-emerald-500' ? 'bg-emerald-500'
: minQuota > 20 : minQuotaValue > 20
? 'bg-amber-500' ? 'bg-amber-500'
: 'bg-red-500' : 'bg-red-500'
)} )}
style={{ width: `${minQuota}%` }} style={{ width: `${minQuotaValue}%` }}
/> />
</div> </div>
</div> </div>
@@ -309,6 +319,8 @@ export function AccountCard({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
) : quota?.success ? (
<div className="text-[8px] text-muted-foreground/60">Quota limits unavailable</div>
) : quota?.needsReauth ? ( ) : quota?.needsReauth ? (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
@@ -106,12 +106,16 @@ export function AccountItem({
selected, selected,
onSelectChange, onSelectChange,
}: AccountItemProps) { }: 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) // Fetch runtime stats to get actual lastUsedAt (more accurate than file state)
const { data: stats } = useCliproxyStats(showQuota); const { data: stats } = useCliproxyStats(showQuota);
// Fetch quota for all provider accounts // Fetch quota for all provider accounts
const { data: quota, isLoading: quotaLoading } = useAccountQuota( const { data: quota, isLoading: quotaLoading } = useAccountQuota(
account.provider, normalizedProvider,
account.id, account.id,
showQuota showQuota
); );
@@ -124,7 +128,7 @@ export function AccountItem({
const minQuota = getProviderMinQuota(account.provider, quota); const minQuota = getProviderMinQuota(account.provider, quota);
const nextReset = getProviderResetTime(account.provider, quota); const nextReset = getProviderResetTime(account.provider, quota);
const codexBreakdown = const codexBreakdown =
account.provider === 'codex' && quota && isCodexQuotaResult(quota) isCodexProvider && quota && isCodexQuotaResult(quota)
? getCodexQuotaBreakdown(quota.windows) ? getCodexQuotaBreakdown(quota.windows)
: null; : null;
const codexQuotaRows = [ const codexQuotaRows = [
@@ -132,7 +136,7 @@ export function AccountItem({
{ label: 'Weekly', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null }, { label: 'Weekly', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null },
].filter((row): row is { label: string; value: number } => row.value !== null); ].filter((row): row is { label: string; value: number } => row.value !== null);
const claudeQuotaRows = const claudeQuotaRows =
account.provider === 'claude' && quota && isClaudeQuotaResult(quota) isClaudeProvider && quota && isClaudeQuotaResult(quota)
? [ ? [
{ {
label: '5h', label: '5h',
@@ -159,13 +163,13 @@ export function AccountItem({
}, },
].filter((row): row is { label: string; value: number } => row.value !== null) ].filter((row): row is { label: string; value: number } => row.value !== null)
: []; : [];
const dualWindowQuotaRows = const dualWindowQuotaRows = isCodexProvider
account.provider === 'codex' ? codexQuotaRows
? codexQuotaRows : isClaudeProvider
: account.provider === 'claude' ? claudeQuotaRows
? claudeQuotaRows : [];
: [];
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null; const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null;
return ( return (
<div <div
@@ -359,7 +363,7 @@ export function AccountItem({
<Loader2 className="w-3 h-3 animate-spin" /> <Loader2 className="w-3 h-3 animate-spin" />
<span>Loading quota...</span> <span>Loading quota...</span>
</div> </div>
) : minQuota !== null ? ( ) : minQuotaValue !== null ? (
<div className="space-y-1.5"> <div className="space-y-1.5">
{/* Status indicator based on runtime usage, not file state */} {/* Status indicator based on runtime usage, not file state */}
<div className="flex items-center gap-1.5 text-xs"> <div className="flex items-center gap-1.5 text-xs">
@@ -409,9 +413,9 @@ export function AccountItem({
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Progress <Progress
value={Math.max(0, Math.min(100, minQuota))} value={Math.max(0, Math.min(100, minQuotaValue))}
className="h-2 flex-1" className="h-2 flex-1"
indicatorClassName={getQuotaColor(minQuota)} indicatorClassName={getQuotaColor(minQuotaValue)}
/> />
<span className="text-xs font-medium w-10 text-right"> <span className="text-xs font-medium w-10 text-right">
{minQuotaLabel}% {minQuotaLabel}%
@@ -425,6 +429,16 @@ export function AccountItem({
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
</div> </div>
) : quota?.success ? (
<div className="flex items-center gap-1.5">
<Badge
variant="outline"
className="text-[10px] h-5 px-2 gap-1 border-muted-foreground/50 text-muted-foreground"
>
<HelpCircle className="w-3 h-3" />
No limits
</Badge>
</div>
) : quota?.needsReauth ? ( ) : quota?.needsReauth ? (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
@@ -94,6 +94,14 @@ export function ModelConfigTab({
privacyMode, privacyMode,
isRemoteMode, isRemoteMode,
}: ModelConfigTabProps) { }: 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) // Kiro-specific: no-incognito setting (defaults to true = normal browser)
const isKiro = provider === 'kiro'; const isKiro = provider === 'kiro';
const [kiroNoIncognito, setKiroNoIncognito] = useState(true); const [kiroNoIncognito, setKiroNoIncognito] = useState(true);
@@ -177,9 +185,7 @@ export function ModelConfigTab({
isBulkPausing={isBulkPausing} isBulkPausing={isBulkPausing}
isBulkResuming={isBulkResuming} isBulkResuming={isBulkResuming}
privacyMode={privacyMode} privacyMode={privacyMode}
showQuota={ showQuota={showQuota}
QUOTA_SUPPORTED_PROVIDERS.includes(provider as QuotaSupportedProvider) && !isRemoteMode
}
isKiro={isKiro} isKiro={isKiro}
kiroNoIncognito={kiroNoIncognito} kiroNoIncognito={kiroNoIncognito}
onKiroNoIncognitoChange={saveKiroNoIncognito} onKiroNoIncognitoChange={saveKiroNoIncognito}
+31 -7
View File
@@ -216,6 +216,26 @@ export type {
/** Providers with quota API support */ /** Providers with quota API support */
export const QUOTA_SUPPORTED_PROVIDERS = ['agy', 'codex', 'claude', 'gemini', 'ghcp'] as const; export const QUOTA_SUPPORTED_PROVIDERS = ['agy', 'codex', 'claude', 'gemini', 'ghcp'] as const;
export type QuotaSupportedProvider = (typeof QUOTA_SUPPORTED_PROVIDERS)[number]; export type QuotaSupportedProvider = (typeof QUOTA_SUPPORTED_PROVIDERS)[number];
const QUOTA_PROVIDER_ALIAS_MAP: Readonly<Record<string, QuotaSupportedProvider>> = {
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 * Fetch account quota from generic API route
@@ -317,7 +337,12 @@ async function fetchQuotaByProvider(
provider: string, provider: string,
accountId: string accountId: string
): Promise<UnifiedQuotaResult> { ): Promise<UnifiedQuotaResult> {
switch (provider) { const canonicalProvider = normalizeQuotaProvider(provider);
if (!canonicalProvider) {
return fetchAccountQuota(provider, accountId);
}
switch (canonicalProvider) {
case 'codex': case 'codex':
return fetchCodexQuotaApi(accountId); return fetchCodexQuotaApi(accountId);
case 'claude': case 'claude':
@@ -336,13 +361,12 @@ async function fetchQuotaByProvider(
* Supports agy, codex, claude, gemini, and ghcp providers * Supports agy, codex, claude, gemini, and ghcp providers
*/ */
export function useAccountQuota(provider: string, accountId: string, enabled = true) { export function useAccountQuota(provider: string, accountId: string, enabled = true) {
const canonicalProvider = normalizeQuotaProvider(provider);
return useQuery({ return useQuery({
queryKey: ['account-quota', provider, accountId], queryKey: ['account-quota', canonicalProvider ?? provider, accountId],
queryFn: () => fetchQuotaByProvider(provider, accountId), queryFn: () => fetchQuotaByProvider(canonicalProvider ?? provider, accountId),
enabled: enabled: enabled && !!canonicalProvider && !!accountId,
enabled &&
QUOTA_SUPPORTED_PROVIDERS.includes(provider as QuotaSupportedProvider) &&
!!accountId,
staleTime: 60000, // Match refetchInterval to prevent early refetching staleTime: 60000, // Match refetchInterval to prevent early refetching
refetchInterval: 60000, // Refresh every 1 minute refetchInterval: 60000, // Refresh every 1 minute
refetchOnWindowFocus: false, // Don't refetch on tab switch refetchOnWindowFocus: false, // Don't refetch on tab switch
+4 -2
View File
@@ -670,8 +670,9 @@ export function getProviderMinQuota(
quota: UnifiedQuotaResult | null | undefined quota: UnifiedQuotaResult | null | undefined
): number | null { ): number | null {
if (!quota?.success) return null; if (!quota?.success) return null;
const normalizedProvider = provider.trim().toLowerCase();
switch (provider) { switch (normalizedProvider) {
case 'agy': case 'agy':
if (isAgyQuotaResult(quota)) { if (isAgyQuotaResult(quota)) {
return getMinClaudeQuota(quota.models); return getMinClaudeQuota(quota.models);
@@ -713,8 +714,9 @@ export function getProviderResetTime(
quota: UnifiedQuotaResult | null | undefined quota: UnifiedQuotaResult | null | undefined
): string | null { ): string | null {
if (!quota?.success) return null; if (!quota?.success) return null;
const normalizedProvider = provider.trim().toLowerCase();
switch (provider) { switch (normalizedProvider) {
case 'agy': case 'agy':
if (isAgyQuotaResult(quota)) { if (isAgyQuotaResult(quota)) {
return getClaudeResetTime(quota.models); return getClaudeResetTime(quota.models);