diff --git a/src/cliproxy/quota-fetcher-claude-normalizer.ts b/src/cliproxy/quota-fetcher-claude-normalizer.ts index 11d8396e..25104425 100644 --- a/src/cliproxy/quota-fetcher-claude-normalizer.ts +++ b/src/cliproxy/quota-fetcher-claude-normalizer.ts @@ -7,6 +7,9 @@ import type { ClaudeCoreUsageSummary, ClaudeQuotaWindow } from './quota-types'; import { clampPercent } from '../utils/percentage'; +// Distinguishes epoch milliseconds from seconds (1e12 ~= 2001-09-09T01:46:40Z). +const EPOCH_MS_THRESHOLD = 1e12; + function asString(value: unknown): string | null { return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; } @@ -32,7 +35,7 @@ function asNumber(value: unknown): number | null { function normalizeTimestamp(value: unknown): string | null { const asNum = asNumber(value); if (asNum !== null) { - const millis = asNum > 1e12 ? asNum : asNum * 1000; + const millis = asNum > EPOCH_MS_THRESHOLD ? asNum : asNum * 1000; const date = new Date(millis); return isNaN(date.getTime()) ? null : date.toISOString(); } @@ -44,7 +47,7 @@ function normalizeTimestamp(value: unknown): string | null { if (/^\d+$/.test(str)) { const numeric = Number(str); if (isFinite(numeric)) { - const millis = numeric > 1e12 ? numeric : numeric * 1000; + const millis = numeric > EPOCH_MS_THRESHOLD ? numeric : numeric * 1000; const date = new Date(millis); return isNaN(date.getTime()) ? null : date.toISOString(); } @@ -260,6 +263,19 @@ const WEEKLY_RATE_LIMIT_TYPES = new Set([ 'seven_day_cowork', ]); +export function isClaudeWeeklyRateLimitType(rateLimitType: string): boolean { + return WEEKLY_RATE_LIMIT_TYPES.has(rateLimitType); +} + +export function pickMostRestrictiveClaudeWeeklyWindow( + windows: ClaudeQuotaWindow[] +): ClaudeQuotaWindow | null { + const weeklyCandidates = windows.filter((window) => + isClaudeWeeklyRateLimitType(window.rateLimitType) + ); + return pickMostRestrictiveWeekly(weeklyCandidates); +} + /** * Build explicit 5h + weekly usage summary from Claude policy windows. */ @@ -269,10 +285,7 @@ export function buildClaudeCoreUsageSummary(windows: ClaudeQuotaWindow[]): Claud } const fiveHourWindow = windows.find((window) => window.rateLimitType === 'five_hour') || null; - const weeklyCandidates = windows.filter((window) => - WEEKLY_RATE_LIMIT_TYPES.has(window.rateLimitType) - ); - const weeklyWindow = pickMostRestrictiveWeekly(weeklyCandidates); + const weeklyWindow = pickMostRestrictiveClaudeWeeklyWindow(windows); if (fiveHourWindow && weeklyWindow) { return { diff --git a/src/cliproxy/quota-fetcher-claude.ts b/src/cliproxy/quota-fetcher-claude.ts index 745a5b03..3f0c0aaf 100644 --- a/src/cliproxy/quota-fetcher-claude.ts +++ b/src/cliproxy/quota-fetcher-claude.ts @@ -17,7 +17,7 @@ import { export { buildClaudeQuotaWindows, buildClaudeCoreUsageSummary }; -const CLAUDE_POLICY_LIMITS_URL = 'https://api.anthropic.com/api/claude_code/policy_limits'; +export const CLAUDE_POLICY_LIMITS_URL = 'https://api.anthropic.com/api/claude_code/policy_limits'; const CLAUDE_QUOTA_TIMEOUT_MS = 10000; const CLAUDE_QUOTA_MAX_ATTEMPTS = 2; const CLAUDE_USER_AGENT = 'ccs-cli/claude-quota'; @@ -61,6 +61,10 @@ function extractExpiry(data: Record): string | null { return null; } +function isAuthExpired(expiry: string | null): boolean { + return expiry ? isTokenExpired(expiry) : false; +} + async function readJsonFile(filePath: string): Promise | null> { try { const raw = await fsp.readFile(filePath, 'utf-8'); @@ -81,7 +85,7 @@ async function readAuthCandidate(filePath: string): Promise= CLAUDE_QUOTA_MAX_ATTEMPTS) { diff --git a/src/cliproxy/quota-manager.ts b/src/cliproxy/quota-manager.ts index d8a4fd78..afb431e8 100644 --- a/src/cliproxy/quota-manager.ts +++ b/src/cliproxy/quota-manager.ts @@ -209,10 +209,14 @@ export function clearCooldown(provider: CLIProxyProvider, accountId: string): vo async function batchedMap( items: T[], fn: (item: T) => Promise, - concurrency = 10 + concurrency = 10, + delayMs = 100 ): Promise { const results: R[] = []; for (let i = 0; i < items.length; i += concurrency) { + if (i > 0 && delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } const batch = items.slice(i, i + concurrency); const batchResults = await Promise.all(batch.map(fn)); results.push(...batchResults); diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index dfd58dcd..c19dec31 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -19,6 +19,7 @@ import { import { fetchAllProviderQuotas } from '../../cliproxy/quota-fetcher'; import { fetchAllCodexQuotas } from '../../cliproxy/quota-fetcher-codex'; import { fetchAllClaudeQuotas } from '../../cliproxy/quota-fetcher-claude'; +import { pickMostRestrictiveClaudeWeeklyWindow } from '../../cliproxy/quota-fetcher-claude-normalizer'; import { fetchAllGeminiCliQuotas } from '../../cliproxy/quota-fetcher-gemini-cli'; import { fetchAllGhcpQuotas } from '../../cliproxy/quota-fetcher-ghcp'; import type { @@ -438,30 +439,6 @@ function toClaudeCoreDisplayWindow( }; } -function pickClaudeWeeklyWindow( - windows: ClaudeQuotaResult['windows'] -): ClaudeQuotaResult['windows'][number] | null { - const weeklyCandidates = windows.filter((window) => - [ - 'seven_day', - 'seven_day_opus', - 'seven_day_sonnet', - 'seven_day_oauth_apps', - 'seven_day_cowork', - ].includes(window.rateLimitType) - ); - if (weeklyCandidates.length === 0) return null; - - return [...weeklyCandidates].sort((a, b) => { - if (a.remainingPercent !== b.remainingPercent) { - return a.remainingPercent - b.remainingPercent; - } - const aReset = a.resetAt ? new Date(a.resetAt).getTime() : Number.POSITIVE_INFINITY; - const bReset = b.resetAt ? new Date(b.resetAt).getTime() : Number.POSITIVE_INFINITY; - return aReset - bReset; - })[0]; -} - function getClaudeCoreUsageWindows(quota: ClaudeQuotaResult): { fiveHourWindow: ClaudeDisplayWindow | null; weeklyWindow: ClaudeDisplayWindow | null; @@ -478,7 +455,7 @@ function getClaudeCoreUsageWindows(quota: ClaudeQuotaResult): { const fiveHourPolicy = quota.windows.find((window) => window.rateLimitType === 'five_hour') ?? null; - const weeklyPolicy = pickClaudeWeeklyWindow(quota.windows); + const weeklyPolicy = pickMostRestrictiveClaudeWeeklyWindow(quota.windows); return { fiveHourWindow: fiveHourPolicy ? toClaudeDisplayWindow(fiveHourPolicy) : null, diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index 8fd2611e..4d446b90 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -54,6 +54,36 @@ import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager'; const router = Router(); +const QUOTA_RATE_LIMIT_WINDOW_MS = 60_000; +const QUOTA_RATE_LIMIT_MAX_REQUESTS = 120; + +interface QuotaRateLimitEntry { + windowStart: number; + count: number; +} + +const quotaRateLimits = new Map(); + +function buildQuotaRateLimitKey(req: Request, provider: string): string { + const clientIp = req.ip || req.socket.remoteAddress || 'unknown'; + return `${clientIp}:${provider}`; +} + +function isQuotaRouteRateLimited(req: Request, provider: string): boolean { + const key = buildQuotaRateLimitKey(req, provider); + const now = Date.now(); + const current = quotaRateLimits.get(key); + + if (!current || now - current.windowStart >= QUOTA_RATE_LIMIT_WINDOW_MS) { + quotaRateLimits.set(key, { windowStart: now, count: 1 }); + return false; + } + + current.count += 1; + quotaRateLimits.set(key, current); + return current.count > QUOTA_RATE_LIMIT_MAX_REQUESTS; +} + /** * Cache only stable failures; avoid pinning transient network failures (timeouts, 429s). */ @@ -592,6 +622,12 @@ router.put('/models/:provider', async (req: Request, res: Response): Promise => { const { accountId } = req.params; + if (isQuotaRouteRateLimited(req, 'codex')) { + res + .status(429) + .json({ error: 'Too many quota requests', message: 'Retry after a short delay' }); + return; + } // Validate accountId - prevent path traversal if ( @@ -633,6 +669,12 @@ router.get('/quota/codex/:accountId', async (req: Request, res: Response): Promi */ router.get('/quota/claude/:accountId', async (req: Request, res: Response): Promise => { const { accountId } = req.params; + if (isQuotaRouteRateLimited(req, 'claude')) { + res + .status(429) + .json({ error: 'Too many quota requests', message: 'Retry after a short delay' }); + return; + } // Validate accountId - prevent path traversal if ( @@ -674,6 +716,12 @@ router.get('/quota/claude/:accountId', async (req: Request, res: Response): Prom */ router.get('/quota/gemini/:accountId', async (req: Request, res: Response): Promise => { const { accountId } = req.params; + if (isQuotaRouteRateLimited(req, 'gemini')) { + res + .status(429) + .json({ error: 'Too many quota requests', message: 'Retry after a short delay' }); + return; + } // Validate accountId - prevent path traversal if ( @@ -715,6 +763,12 @@ router.get('/quota/gemini/:accountId', async (req: Request, res: Response): Prom */ router.get('/quota/ghcp/:accountId', async (req: Request, res: Response): Promise => { const { accountId } = req.params; + if (isQuotaRouteRateLimited(req, 'ghcp')) { + res + .status(429) + .json({ error: 'Too many quota requests', message: 'Retry after a short delay' }); + return; + } // Validate accountId - prevent path traversal if ( @@ -757,6 +811,12 @@ router.get('/quota/ghcp/:accountId', async (req: Request, res: Response): Promis */ router.get('/quota/:provider/:accountId', async (req: Request, res: Response): Promise => { const { provider, accountId } = req.params; + if (isQuotaRouteRateLimited(req, provider)) { + res + .status(429) + .json({ error: 'Too many quota requests', message: 'Retry after a short delay' }); + return; + } // Validate provider - use canonical CLIPROXY_PROFILES const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES]; diff --git a/tests/unit/cliproxy/quota-fetcher-claude.test.ts b/tests/unit/cliproxy/quota-fetcher-claude.test.ts index 40cbcdef..dd197288 100644 --- a/tests/unit/cliproxy/quota-fetcher-claude.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-claude.test.ts @@ -377,6 +377,27 @@ describe('Claude Quota Fetcher', () => { expect(fetchMock).toHaveBeenCalledTimes(0); }); + it('treats missing expiry as not expired', async () => { + createClaudeAccount('claude-no-expiry@example.com', { + access_token: 'no-expiry-token', + type: 'claude', + }); + + global.fetch = mock(() => + Promise.resolve( + new Response(JSON.stringify({ restrictions: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + ) as typeof fetch; + + const result = await fetchClaudeQuota('claude-no-expiry@example.com'); + + expect(result.success).toBe(true); + expect(result.windows).toHaveLength(0); + }); + it('retries once on transient 500 then succeeds', async () => { createClaudeAccount('claude-retry@example.com', { access_token: 'retry-token', diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 4bf4abc5..f3410a91 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -315,7 +315,7 @@ export function AccountCard({ - {quota && } + diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index fd95e5fe..501fc4c3 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -424,7 +424,7 @@ export function AccountItem({ )} - {quota && } + diff --git a/ui/src/components/shared/quota-tooltip-content.tsx b/ui/src/components/shared/quota-tooltip-content.tsx index 4dbac2d6..d95dab71 100644 --- a/ui/src/components/shared/quota-tooltip-content.tsx +++ b/ui/src/components/shared/quota-tooltip-content.tsx @@ -22,7 +22,7 @@ import { } from '@/lib/utils'; interface QuotaTooltipContentProps { - quota: UnifiedQuotaResult; + quota: UnifiedQuotaResult | null | undefined; resetTime: string | null; } @@ -62,7 +62,13 @@ function getClaudeWindowDisplayLabel(rateLimitType: string, fallback: string): s * Uses type guards for proper TypeScript narrowing */ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentProps) { - if (!quota?.success) return null; + if (!quota) { + return

Loading quota...

; + } + + if (!quota.success) { + return

{quota.error || 'Failed to load quota'}

; + } // Antigravity (agy) provider tooltip if (isAgyQuotaResult(quota)) { diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 56007a4b..1fbbe124 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -618,45 +618,97 @@ export type UnifiedQuotaResult = | GeminiCliQuotaResult | GhcpQuotaResult; +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + /** Type guard: Check if quota result is from Antigravity (agy) provider */ export function isAgyQuotaResult(quota: UnifiedQuotaResult): quota is QuotaResult { - return 'models' in quota && Array.isArray((quota as QuotaResult).models); + if (!isRecord(quota)) return false; + const models = (quota as Partial).models; + return typeof quota.success === 'boolean' && Array.isArray(models); } /** Type guard: Check if quota result is from Codex provider */ export function isCodexQuotaResult(quota: UnifiedQuotaResult): quota is CodexQuotaResult { - return ( - 'windows' in quota && 'planType' in quota && Array.isArray((quota as CodexQuotaResult).windows) + if (!isRecord(quota)) return false; + + const candidate = quota as Partial; + if (typeof candidate.success !== 'boolean') return false; + if (!Array.isArray(candidate.windows)) return false; + if (!('planType' in candidate)) return false; + + return candidate.windows.every( + (window) => + isRecord(window) && + typeof window.label === 'string' && + isFiniteNumber(window.usedPercent) && + isFiniteNumber(window.remainingPercent) ); } /** Type guard: Check if quota result is from Claude provider */ export function isClaudeQuotaResult(quota: UnifiedQuotaResult): quota is ClaudeQuotaResult { - return ( - 'windows' in quota && - !('planType' in quota) && - Array.isArray((quota as ClaudeQuotaResult).windows) + if (!isRecord(quota)) return false; + + const candidate = quota as Partial; + if (typeof candidate.success !== 'boolean') return false; + if (!Array.isArray(candidate.windows)) return false; + if ('planType' in candidate) return false; + + return candidate.windows.every( + (window) => + isRecord(window) && + typeof window.rateLimitType === 'string' && + isFiniteNumber(window.remainingPercent) && + typeof window.status === 'string' ); } /** Type guard: Check if quota result is from Gemini CLI provider */ export function isGeminiQuotaResult(quota: UnifiedQuotaResult): quota is GeminiCliQuotaResult { - return 'buckets' in quota && Array.isArray((quota as GeminiCliQuotaResult).buckets); + if (!isRecord(quota)) return false; + + const candidate = quota as Partial; + if (typeof candidate.success !== 'boolean') return false; + if (!Array.isArray(candidate.buckets)) return false; + + return candidate.buckets.every( + (bucket) => + isRecord(bucket) && + typeof bucket.id === 'string' && + isFiniteNumber(bucket.remainingFraction) && + isFiniteNumber(bucket.remainingPercent) && + Array.isArray(bucket.modelIds) + ); } /** Type guard: Check if quota result is from GitHub Copilot (ghcp) provider */ export function isGhcpQuotaResult(quota: UnifiedQuotaResult): quota is GhcpQuotaResult { - const candidate = quota as GhcpQuotaResult; - const snapshots = candidate.snapshots as Record | null | undefined; + if (!isRecord(quota)) return false; - return ( - 'snapshots' in quota && - typeof snapshots === 'object' && - snapshots !== null && - 'premiumInteractions' in snapshots && - 'chat' in snapshots && - 'completions' in snapshots - ); + const candidate = quota as Partial; + const snapshots = candidate.snapshots as Record | null | undefined; + if (typeof candidate.success !== 'boolean') return false; + if (!isRecord(snapshots)) return false; + + const snapshotKeys: Array = [ + 'premiumInteractions', + 'chat', + 'completions', + ]; + return snapshotKeys.every((key) => { + const snapshot = snapshots[key] as Record | undefined; + return ( + isRecord(snapshot) && + isFiniteNumber(snapshot.percentRemaining) && + isFiniteNumber(snapshot.percentUsed) + ); + }); } // ==================== Unified Quota Helpers ==================== diff --git a/ui/tests/unit/ui/lib/quota-utils.test.ts b/ui/tests/unit/ui/lib/quota-utils.test.ts index d6c9dcd2..62865580 100644 --- a/ui/tests/unit/ui/lib/quota-utils.test.ts +++ b/ui/tests/unit/ui/lib/quota-utils.test.ts @@ -15,6 +15,7 @@ import { getProviderMinQuota, getProviderResetTime, isAgyQuotaResult, + isClaudeQuotaResult, isCodexQuotaResult, isGeminiQuotaResult, isGhcpQuotaResult, @@ -22,6 +23,7 @@ import { import type { CodexQuotaWindow, CodexQuotaResult, + ClaudeQuotaResult, GeminiCliBucket, GeminiCliQuotaResult, GhcpQuotaResult, @@ -1002,6 +1004,56 @@ describe('isCodexQuotaResult', () => { }); }); +describe('isClaudeQuotaResult', () => { + it('returns true for valid Claude quota result', () => { + const quota: ClaudeQuotaResult = { + success: true, + windows: [ + { + rateLimitType: 'five_hour', + label: 'Session limit', + status: 'allowed', + utilization: 0.5, + usedPercent: 50, + remainingPercent: 50, + resetAt: '2026-01-30T12:00:00Z', + }, + ], + coreUsage: { + fiveHour: { + rateLimitType: 'five_hour', + label: 'Session limit', + remainingPercent: 50, + resetAt: '2026-01-30T12:00:00Z', + status: 'allowed', + }, + weekly: null, + }, + lastUpdated: Date.now(), + }; + expect(isClaudeQuotaResult(quota)).toBe(true); + }); + + it('returns false for Codex quota result', () => { + const quota: CodexQuotaResult = { + success: true, + windows: [], + planType: 'free', + lastUpdated: Date.now(), + }; + expect(isClaudeQuotaResult(quota as unknown as ClaudeQuotaResult)).toBe(false); + }); + + it('returns false when Claude windows are malformed', () => { + const malformed = { + success: true, + windows: [{ rateLimitType: 'five_hour', label: 'Session limit' }], + lastUpdated: Date.now(), + }; + expect(isClaudeQuotaResult(malformed as unknown as ClaudeQuotaResult)).toBe(false); + }); +}); + describe('isGeminiQuotaResult', () => { it('returns true for valid Gemini quota result', () => { const quota: GeminiCliQuotaResult = {