From 2f5a50b801a48314b76f102ccdd4f71c4d1cf87d Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 29 Jan 2026 21:50:02 -0500 Subject: [PATCH 01/11] feat(cliproxy): add Codex/Gemini quota API routes - add dedicated /quota/codex/:accountId and /quota/gemini/:accountId endpoints - reorder routes to place specific before generic for correct Express matching - fix Gemini auth file detection to support new naming format - handle nested token structure in Gemini auth files --- src/cliproxy/quota-fetcher-gemini-cli.ts | 138 ++++++++++++++---- .../routes/cliproxy-stats-routes.ts | 58 +++++++- 2 files changed, 164 insertions(+), 32 deletions(-) diff --git a/src/cliproxy/quota-fetcher-gemini-cli.ts b/src/cliproxy/quota-fetcher-gemini-cli.ts index f28c9b62..f64c7d57 100644 --- a/src/cliproxy/quota-fetcher-gemini-cli.ts +++ b/src/cliproxy/quota-fetcher-gemini-cli.ts @@ -82,61 +82,137 @@ function resolveGeminiCliProjectId(accountField: string): string | null { return lastMatch; } +/** + * Extract access token from Gemini auth file data + * Handles both flat (access_token) and nested (token.access_token) structures + */ +function extractAccessToken(data: Record): string | null { + // Flat structure: { access_token: "..." } + if (typeof data.access_token === 'string') { + return data.access_token; + } + // Nested structure: { token: { access_token: "..." } } + if (data.token && typeof data.token === 'object') { + const token = data.token as Record; + if (typeof token.access_token === 'string') { + return token.access_token; + } + } + return null; +} + +/** + * Extract expiry from Gemini auth file data + * Handles both flat (expired) and nested (token.expiry) structures + */ +function extractExpiry(data: Record): string | null { + // Flat structure: { expired: "..." } + if (typeof data.expired === 'string') { + return data.expired; + } + // Nested structure: { token: { expiry: "..." } } + if (data.token && typeof data.token === 'object') { + const token = data.token as Record; + if (typeof token.expiry === 'string') { + return token.expiry; + } + } + return null; +} + +/** + * Check if file matches Gemini CLI auth file patterns + * Patterns: gemini-*.json OR *-gen-lang-client-*.json OR email@domain.com-*.json with type=gemini + */ +function isGeminiAuthFile(filename: string): boolean { + if (!filename.endsWith('.json')) return false; + // Legacy pattern: gemini-email.json + if (filename.startsWith('gemini-')) return true; + // New pattern: email-gen-lang-client-projectId.json + if (filename.includes('-gen-lang-client-')) return true; + // Check if contains @ (email pattern) - will verify type inside + if (filename.includes('@')) return true; + return false; +} + /** * Read auth data from Gemini CLI auth file + * Supports multiple file naming conventions and JSON structures */ function readGeminiCliAuthData(accountId: string): GeminiCliAuthData | null { const authDirs = [getAuthDir(), getPausedDir()]; const sanitizedId = sanitizeEmail(accountId); - const expectedFile = `gemini-${sanitizedId}.json`; + const expectedFiles = [ + `gemini-${sanitizedId}.json`, // Legacy format + `${accountId}-gen-lang-client-`, // New format prefix (partial match) + ]; for (const authDir of authDirs) { if (!fs.existsSync(authDir)) continue; - const filePath = path.join(authDir, expectedFile); - if (fs.existsSync(filePath)) { + // Try exact legacy match first + const legacyPath = path.join(authDir, expectedFiles[0]); + if (fs.existsSync(legacyPath)) { try { - const content = fs.readFileSync(filePath, 'utf-8'); - const data = JSON.parse(content); - if (!data.access_token) continue; + const content = fs.readFileSync(legacyPath, 'utf-8'); + const data = JSON.parse(content) as Record; + const accessToken = extractAccessToken(data); + if (accessToken) { + const projectId = + typeof data.project_id === 'string' + ? data.project_id + : resolveGeminiCliProjectId(String(data.account || '')); + const expiry = extractExpiry(data); - // Extract project ID from account field - const accountField = data.account || ''; - const projectId = resolveGeminiCliProjectId(accountField); - - return { - accessToken: data.access_token, - projectId, - isExpired: isTokenExpired(data.expired), - expiresAt: data.expired || null, - }; + return { + accessToken, + projectId, + isExpired: isTokenExpired(expiry ?? undefined), + expiresAt: expiry, + }; + } } catch { - continue; + // Continue to fallback } } - // Fallback: scan directory for matching email in file content + // Scan directory for matching files const files = fs.readdirSync(authDir); for (const file of files) { - if (file.startsWith('gemini-') && file.endsWith('.json')) { - const candidatePath = path.join(authDir, file); - try { - const content = fs.readFileSync(candidatePath, 'utf-8'); - const data = JSON.parse(content); - if (data.email === accountId && data.access_token) { - const accountField = data.account || ''; - const projectId = resolveGeminiCliProjectId(accountField); + if (!isGeminiAuthFile(file)) continue; + + const candidatePath = path.join(authDir, file); + try { + const content = fs.readFileSync(candidatePath, 'utf-8'); + const data = JSON.parse(content) as Record; + + // Check if this file matches our account + const fileEmail = typeof data.email === 'string' ? data.email : null; + const fileType = typeof data.type === 'string' ? data.type : null; + const matchesEmail = fileEmail === accountId; + const matchesFilename = file.startsWith(`${accountId}-`) || file.includes(sanitizedId); + const isGeminiType = fileType === 'gemini' || fileType === 'gemini-cli'; + + // Must match account AND be gemini type (or legacy gemini- prefix) + if ((matchesEmail || matchesFilename) && (isGeminiType || file.startsWith('gemini-'))) { + const accessToken = extractAccessToken(data); + if (accessToken) { + const projectId = + typeof data.project_id === 'string' + ? data.project_id + : resolveGeminiCliProjectId(String(data.account || '')); + const expiry = extractExpiry(data); return { - accessToken: data.access_token, + accessToken, projectId, - isExpired: isTokenExpired(data.expired), - expiresAt: data.expired || null, + isExpired: isTokenExpired(expiry ?? undefined), + expiresAt: expiry, }; } - } catch { - continue; } + } catch { + continue; } } } diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index 032a86a0..a657c587 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -13,6 +13,8 @@ import { fetchCliproxyErrorLogContent, } from '../../cliproxy/stats-fetcher'; import { fetchAccountQuota } from '../../cliproxy/quota-fetcher'; +import { fetchCodexQuota } from '../../cliproxy/quota-fetcher-codex'; +import { fetchGeminiCliQuota } from '../../cliproxy/quota-fetcher-gemini-cli'; import type { CLIProxyProvider } from '../../cliproxy/types'; import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; import { @@ -510,10 +512,64 @@ router.put('/models/:provider', async (req: Request, res: Response): Promise => { + const { accountId } = req.params; + + // Validate accountId - prevent path traversal + if ( + !accountId || + accountId.includes('..') || + accountId.includes('/') || + accountId.includes('\\') + ) { + res.status(400).json({ error: 'Invalid account ID' }); + return; + } + + try { + const result = await fetchCodexQuota(accountId); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/cliproxy/quota/gemini/:accountId - Get Gemini quota for a specific account + * Returns: GeminiCliQuotaResult with quota buckets + */ +router.get('/quota/gemini/:accountId', async (req: Request, res: Response): Promise => { + const { accountId } = req.params; + + // Validate accountId - prevent path traversal + if ( + !accountId || + accountId.includes('..') || + accountId.includes('/') || + accountId.includes('\\') + ) { + res.status(400).json({ error: 'Invalid account ID' }); + return; + } + + try { + const result = await fetchGeminiCliQuota(accountId); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +/** + * GET /api/cliproxy/quota/:provider/:accountId - Get quota for a specific account (generic) * Returns: QuotaResult with model quotas and reset times + * NOTE: This generic route MUST come after specific routes (codex, gemini) to avoid matching them */ router.get('/quota/:provider/:accountId', async (req: Request, res: Response): Promise => { const { provider, accountId } = req.params; From 19a57c395c29beb83661edb28286e01211b80261 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 29 Jan 2026 21:50:43 -0500 Subject: [PATCH 02/11] feat(ui): add Codex/Gemini quota API client and hooks - add CodexQuotaResult and GeminiCliQuotaResult types - add api.quota.getCodex and api.quota.getGemini API functions - add useCodexQuota and useGeminiQuota React hooks - add getMinCodexQuota, getMinGeminiQuota, getCodexResetTime, getGeminiResetTime utils --- ui/src/hooks/use-cliproxy-stats.ts | 111 +++++++++++++++++++++++++++-- ui/src/lib/api-client.ts | 70 ++++++++++++++++++ ui/src/lib/utils.ts | 39 ++++++++++ 3 files changed, 214 insertions(+), 6 deletions(-) diff --git a/ui/src/hooks/use-cliproxy-stats.ts b/ui/src/hooks/use-cliproxy-stats.ts index e84b23d7..587e2dcc 100644 --- a/ui/src/hooks/use-cliproxy-stats.ts +++ b/ui/src/hooks/use-cliproxy-stats.ts @@ -3,7 +3,12 @@ */ import { useQuery } from '@tanstack/react-query'; -import type { ModelQuota, QuotaResult } from '@/lib/api-client'; +import type { + ModelQuota, + QuotaResult, + CodexQuotaResult, + GeminiCliQuotaResult, +} from '@/lib/api-client'; /** Per-account usage statistics */ export interface AccountUsageStats { @@ -196,10 +201,13 @@ export function useCliproxyErrorLogContent(name: string | null) { } // Re-export for consumers -export type { ModelQuota, QuotaResult }; +export type { ModelQuota, QuotaResult, CodexQuotaResult, GeminiCliQuotaResult }; + +/** Providers with quota API support */ +const SUPPORTED_PROVIDERS = ['agy', 'codex', 'gemini'] as const; /** - * Fetch account quota from API + * Fetch account quota from API (Antigravity only) */ async function fetchAccountQuota(provider: string, accountId: string): Promise { const response = await fetch(`/api/cliproxy/quota/${provider}/${encodeURIComponent(accountId)}`); @@ -216,15 +224,74 @@ async function fetchAccountQuota(provider: string, accountId: string): Promise { + const response = await fetch(`/api/cliproxy/quota/codex/${encodeURIComponent(accountId)}`); + if (!response.ok) { + let message = 'Failed to fetch Codex quota'; + try { + const error = await response.json(); + message = error.message || message; + } catch { + // Use default message if response isn't JSON + } + throw new Error(message); + } + return response.json(); +} + +/** + * Fetch Gemini quota from API + */ +async function fetchGeminiQuotaApi(accountId: string): Promise { + const response = await fetch(`/api/cliproxy/quota/gemini/${encodeURIComponent(accountId)}`); + if (!response.ok) { + let message = 'Failed to fetch Gemini quota'; + try { + const error = await response.json(); + message = error.message || message; + } catch { + // Use default message if response isn't JSON + } + throw new Error(message); + } + return response.json(); +} + +/** Unified quota result type for all providers */ +export type UnifiedQuotaResult = QuotaResult | CodexQuotaResult | GeminiCliQuotaResult; + +/** + * Fetch quota by provider (dispatcher) + */ +async function fetchQuotaByProvider( + provider: string, + accountId: string +): Promise { + switch (provider) { + case 'codex': + return fetchCodexQuotaApi(accountId); + case 'gemini': + return fetchGeminiQuotaApi(accountId); + default: + return fetchAccountQuota(provider, accountId); + } +} + /** * Hook to get account quota - * Supports all providers that have quota API implemented + * Supports agy, codex, and gemini providers */ export function useAccountQuota(provider: string, accountId: string, enabled = true) { return useQuery({ queryKey: ['account-quota', provider, accountId], - queryFn: () => fetchAccountQuota(provider, accountId), - enabled: enabled && provider === 'agy' && !!accountId, + queryFn: () => fetchQuotaByProvider(provider, accountId), + enabled: + enabled && + SUPPORTED_PROVIDERS.includes(provider as (typeof SUPPORTED_PROVIDERS)[number]) && + !!accountId, staleTime: 60000, // Match refetchInterval to prevent early refetching refetchInterval: 60000, // Refresh every 1 minute refetchOnWindowFocus: false, // Don't refetch on tab switch @@ -232,3 +299,35 @@ export function useAccountQuota(provider: string, accountId: string, enabled = t retry: 1, }); } + +/** + * Hook to get Codex quota for a specific account + */ +export function useCodexQuota(accountId: string | null) { + return useQuery({ + queryKey: ['codex-quota', accountId], + queryFn: async () => { + if (!accountId) throw new Error('Account ID required'); + return fetchCodexQuotaApi(accountId); + }, + enabled: !!accountId, + staleTime: 30000, + refetchInterval: 60000, + }); +} + +/** + * Hook to get Gemini CLI quota for a specific account + */ +export function useGeminiQuota(accountId: string | null) { + return useQuery({ + queryKey: ['gemini-quota', accountId], + queryFn: async () => { + if (!accountId) throw new Error('Account ID required'); + return fetchGeminiQuotaApi(accountId); + }, + enabled: !!accountId, + staleTime: 30000, + refetchInterval: 60000, + }); +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index cf190caa..753e0de5 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -146,6 +146,70 @@ export interface QuotaResult { error?: string; } +/** Codex rate limit window */ +export interface CodexQuotaWindow { + /** Window label: "Primary", "Secondary", "Code Review (Primary)", "Code Review (Secondary)" */ + label: string; + /** Percentage used (0-100) */ + usedPercent: number; + /** Percentage remaining (100 - usedPercent) */ + remainingPercent: number; + /** Seconds until quota resets, null if unknown */ + resetAfterSeconds: number | null; + /** ISO timestamp when quota resets, null if unknown */ + resetAt: string | null; +} + +/** Codex quota result */ +export interface CodexQuotaResult { + /** Whether fetch succeeded */ + success: boolean; + /** Quota windows (primary, secondary, code review) */ + windows: CodexQuotaWindow[]; + /** Plan type: free, plus, team, or null if unknown */ + planType: 'free' | 'plus' | 'team' | null; + /** Timestamp of fetch */ + lastUpdated: number; + /** Error message if fetch failed */ + error?: string; + /** Account ID (email) this quota belongs to */ + accountId?: string; +} + +/** Gemini CLI bucket (grouped by model series) */ +export interface GeminiCliBucket { + /** Unique bucket identifier (e.g., "gemini-flash-series::input") */ + id: string; + /** Display label (e.g., "Gemini Flash Series") */ + label: string; + /** Token type: "input", "output", or null if combined */ + tokenType: string | null; + /** Remaining quota as fraction (0-1) */ + remainingFraction: number; + /** Remaining quota as percentage (0-100) */ + remainingPercent: number; + /** ISO timestamp when quota resets, null if unknown */ + resetTime: string | null; + /** Model IDs in this bucket */ + modelIds: string[]; +} + +/** Gemini CLI quota result */ +export interface GeminiCliQuotaResult { + /** Whether fetch succeeded */ + success: boolean; + /** Quota buckets grouped by model series */ + buckets: GeminiCliBucket[]; + /** GCP project ID for this account */ + projectId: string | null; + /** Timestamp of fetch */ + lastUpdated: number; + /** Error message if fetch failed */ + error?: string; + /** Account ID (email) this quota belongs to */ + accountId?: string; +} + /** Provider accounts summary */ export type ProviderAccountsMap = Record; @@ -552,5 +616,11 @@ export const api = { /** Fetch quota for a specific account */ get: (provider: string, accountId: string) => request(`/cliproxy/quota/${provider}/${encodeURIComponent(accountId)}`), + /** Fetch Codex quota for a specific account */ + getCodex: (accountId: string) => + request(`/cliproxy/quota/codex/${encodeURIComponent(accountId)}`), + /** Fetch Gemini CLI quota for a specific account */ + getGemini: (accountId: string) => + request(`/cliproxy/quota/gemini/${encodeURIComponent(accountId)}`), }, }; diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index a6966d0a..54e665f1 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -1,5 +1,6 @@ import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; +import type { CodexQuotaWindow, GeminiCliBucket } from './api-client'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -298,3 +299,41 @@ export function groupModelsByTier(models: TieredModel[]): Map w.remainingPercent); + return Math.min(...percentages); +} + +/** + * Get earliest reset time from Codex windows + */ +export function getCodexResetTime(windows: CodexQuotaWindow[]): string | null { + if (!windows || windows.length === 0) return null; + const resets = windows.map((w) => w.resetAt).filter((t): t is string => t !== null); + if (resets.length === 0) return null; + return resets.sort()[0]; +} + +/** + * Get minimum remaining percentage across Gemini CLI buckets + */ +export function getMinGeminiQuota(buckets: GeminiCliBucket[]): number | null { + if (!buckets || buckets.length === 0) return null; + const percentages = buckets.map((b) => b.remainingPercent); + return Math.min(...percentages); +} + +/** + * Get earliest reset time from Gemini buckets + */ +export function getGeminiResetTime(buckets: GeminiCliBucket[]): string | null { + if (!buckets || buckets.length === 0) return null; + const resets = buckets.map((b) => b.resetTime).filter((t): t is string => t !== null); + if (resets.length === 0) return null; + return resets.sort()[0]; +} From 387c01026731d251459420d70830df418fd1311f Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 29 Jan 2026 21:51:39 -0500 Subject: [PATCH 03/11] feat(ui): display Codex/Gemini quota in dashboard - update account-card.tsx to show quota for all CLIProxy providers - update account-item.tsx with provider-specific tooltip rendering - enable showQuota for codex and gemini in model-config-tab.tsx --- ui/bun.lock | 1 - .../account/flow-viz/account-card.tsx | 166 +++++++++++---- .../cliproxy/provider-editor/account-item.tsx | 201 ++++++++++++++---- .../provider-editor/model-config-tab.tsx | 2 +- 4 files changed, 278 insertions(+), 92 deletions(-) diff --git a/ui/bun.lock b/ui/bun.lock index c2e29de1..2756da98 100644 --- a/ui/bun.lock +++ b/ui/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "ui", diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 110d6f8a..c5773214 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -9,11 +9,16 @@ import { getMinClaudeQuota, getModelsWithTiers, groupModelsByTier, + getMinCodexQuota, + getMinGeminiQuota, + getCodexResetTime, + getGeminiResetTime, type ModelTier, } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { GripVertical, Loader2, Clock, Pause, Play } from 'lucide-react'; import { useAccountQuota } from '@/hooks/use-cliproxy-stats'; +import type { CodexQuotaResult, GeminiCliQuotaResult, QuotaResult } from '@/lib/api-client'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Button } from '@/components/ui/button'; @@ -89,19 +94,36 @@ export function AccountCard({ const borderColor = getBorderColorStyle(zone, account.color); const connectorPosition = CONNECTOR_POSITION_MAP[zone]; - // Quota for AGY accounts - const isAgy = account.provider === 'agy'; + // Quota for CLIProxy accounts (agy, codex, gemini) + const isCliproxyProvider = ['agy', 'codex', 'gemini'].includes(account.provider); const { data: quota, isLoading: quotaLoading } = useAccountQuota( account.provider, account.id, - isAgy + isCliproxyProvider ); - // Show minimum quota of Claude models (primary), fallback to min of all models - const minQuota = quota?.success ? getMinClaudeQuota(quota.models) : null; + + // Get provider-specific minimum quota + const getProviderMinQuota = () => { + if (!quota?.success) return null; + switch (account.provider) { + case 'agy': + return getMinClaudeQuota((quota as QuotaResult).models); + case 'codex': + return getMinCodexQuota((quota as CodexQuotaResult).windows); + case 'gemini': + return getMinGeminiQuota((quota as GeminiCliQuotaResult).buckets); + default: + return null; + } + }; + const minQuota = getProviderMinQuota(); // Tier badge (AGY only) - show P for Pro, U for Ultra const showTierBadge = - isAgy && account.tier && account.tier !== 'unknown' && account.tier !== 'free'; + account.provider === 'agy' && + account.tier && + account.tier !== 'unknown' && + account.tier !== 'free'; return (
- {/* Quota bar for AGY accounts */} - {isAgy && ( + {/* Quota bar for CLIProxy accounts (agy, codex, gemini) */} + {isCliproxyProvider && (
{quotaLoading ? (
@@ -244,47 +266,99 @@ export function AccountCard({
-
-

Model Quotas:

- {(() => { - const tiered = getModelsWithTiers(quota?.models || []); - const groups = groupModelsByTier(tiered); - const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other']; - return tierOrder.map((tier, idx) => { - const models = groups.get(tier); - if (!models || models.length === 0) return null; - const isFirst = tierOrder - .slice(0, idx) - .every((t) => !groups.get(t)?.length); - return ( -
- {!isFirst &&
} - {models.map((m) => ( -
- - {m.displayName} - - - {m.percentage}% - -
- ))} + {account.provider === 'agy' ? ( +
+

Model Quotas:

+ {(() => { + const tiered = getModelsWithTiers((quota as QuotaResult)?.models || []); + const groups = groupModelsByTier(tiered); + const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other']; + return tierOrder.map((tier, idx) => { + const models = groups.get(tier); + if (!models || models.length === 0) return null; + const isFirst = tierOrder + .slice(0, idx) + .every((t) => !groups.get(t)?.length); + return ( +
+ {!isFirst &&
} + {models.map((m) => ( +
+ + {m.displayName} + + + {m.percentage}% + +
+ ))} +
+ ); + }); + })()} + {(() => { + const resetTime = getClaudeResetTime((quota as QuotaResult)?.models || []); + return resetTime ? ( +
+ + + Resets {formatResetTime(resetTime)} +
- ); - }); - })()} - {(() => { - const resetTime = getClaudeResetTime(quota?.models || []); - return resetTime ? ( -
- - - Resets {formatResetTime(resetTime)} + ) : null; + })()} +
+ ) : account.provider === 'codex' ? ( +
+

Rate Limits:

+ {(quota as CodexQuotaResult)?.windows?.map((w) => ( +
+ + {w.label} + {w.remainingPercent}%
- ) : null; - })()} -
+ ))} + {(() => { + const resetTime = getCodexResetTime( + (quota as CodexQuotaResult)?.windows || [] + ); + return resetTime ? ( +
+ + + Resets {formatResetTime(resetTime)} + +
+ ) : null; + })()} +
+ ) : account.provider === 'gemini' ? ( +
+

Buckets:

+ {(quota as GeminiCliQuotaResult)?.buckets?.map((b) => ( +
+ + {b.label} + + {b.remainingPercent}% +
+ ))} + {(() => { + const resetTime = getGeminiResetTime( + (quota as GeminiCliQuotaResult)?.buckets || [] + ); + return resetTime ? ( +
+ + + Resets {formatResetTime(resetTime)} + +
+ ) : null; + })()} +
+ ) : null} diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index fd6040e3..ef705c09 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -36,10 +36,20 @@ import { getMinClaudeQuota, getModelsWithTiers, groupModelsByTier, + getMinCodexQuota, + getMinGeminiQuota, + getCodexResetTime, + getGeminiResetTime, type ModelTier, } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; -import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats'; +import { + useAccountQuota, + useCliproxyStats, + type QuotaResult, + type CodexQuotaResult, + type GeminiCliQuotaResult, +} from '@/hooks/use-cliproxy-stats'; import type { AccountItemProps } from './types'; /** @@ -118,12 +128,152 @@ export function AccountItem({ const runtimeLastUsed = stats?.accountStats?.[account.email || account.id]?.lastUsedAt; const wasRecentlyUsed = isRecentlyUsed(runtimeLastUsed); - // Show minimum quota of Claude models (primary), fallback to min of all models - const minQuota = quota?.success ? getMinClaudeQuota(quota.models) : null; + // Compute min quota based on provider + const getProviderMinQuota = (): number | null => { + if (!quota?.success) return null; + switch (account.provider) { + case 'agy': + return getMinClaudeQuota((quota as QuotaResult).models); + case 'codex': + return getMinCodexQuota((quota as CodexQuotaResult).windows); + case 'gemini': + return getMinGeminiQuota((quota as GeminiCliQuotaResult).buckets); + default: + return null; + } + }; + const minQuota = getProviderMinQuota(); - // Get earliest reset time - const nextReset = - quota?.success && quota.models.length > 0 ? getClaudeResetTime(quota.models) : null; + // Compute reset time based on provider + const getProviderResetTime = (): string | null => { + if (!quota?.success) return null; + switch (account.provider) { + case 'agy': + return getClaudeResetTime((quota as QuotaResult).models); + case 'codex': + return getCodexResetTime((quota as CodexQuotaResult).windows); + case 'gemini': + return getGeminiResetTime((quota as GeminiCliQuotaResult).buckets); + default: + return null; + } + }; + const nextReset = getProviderResetTime(); + + // Render Antigravity (agy) provider tooltip + const renderAgyTooltip = () => { + const tiered = getModelsWithTiers((quota as QuotaResult).models || []); + const groups = groupModelsByTier(tiered); + const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other']; + return tierOrder.map((tier, idx) => { + const models = groups.get(tier); + if (!models || models.length === 0) return null; + const isFirst = tierOrder.slice(0, idx).every((t) => !groups.get(t)?.length); + return ( +
+ {!isFirst &&
} + {models.map((m) => ( +
+ {m.displayName} + + {m.percentage}% + +
+ ))} +
+ ); + }); + }; + + // Render Codex provider tooltip + const renderCodexTooltip = () => { + const windows = (quota as CodexQuotaResult).windows; + const planType = (quota as CodexQuotaResult).planType; + return ( + <> + {planType &&

Plan: {planType}

} + {windows.map((w) => ( +
+ {w.label} + {w.remainingPercent}% +
+ ))} + + ); + }; + + // Render Gemini provider tooltip + const renderGeminiTooltip = () => { + const buckets = (quota as GeminiCliQuotaResult).buckets; + return ( + <> + {buckets.map((b) => ( +
+ + {b.label} + {b.tokenType ? ` (${b.tokenType})` : ''} + + {b.remainingPercent}% +
+ ))} + + ); + }; + + // Provider-specific tooltip content renderer + const renderQuotaTooltip = () => { + if (!quota?.success) return null; + + switch (account.provider) { + case 'agy': + return ( +
+

Model Quotas:

+ {renderAgyTooltip()} + {nextReset && ( +
+ + + Resets {formatResetTime(nextReset)} + +
+ )} +
+ ); + case 'codex': + return ( +
+

Rate Limit Windows:

+ {renderCodexTooltip()} + {nextReset && ( +
+ + + Resets {formatResetTime(nextReset)} + +
+ )} +
+ ); + case 'gemini': + return ( +
+

Model Buckets:

+ {renderGeminiTooltip()} + {nextReset && ( +
+ + + Resets {formatResetTime(nextReset)} + +
+ )} +
+ ); + default: + return null; + } + }; return (
-
-

Model Quotas:

- {(() => { - const tiered = getModelsWithTiers(quota?.models || []); - const groups = groupModelsByTier(tiered); - const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other']; - return tierOrder.map((tier, idx) => { - const models = groups.get(tier); - if (!models || models.length === 0) return null; - const isFirst = tierOrder - .slice(0, idx) - .every((t) => !groups.get(t)?.length); - return ( -
- {!isFirst &&
} - {models.map((m) => ( -
- - {m.displayName} - - - {m.percentage}% - -
- ))} -
- ); - }); - })()} - {nextReset && ( -
- - - Resets {formatResetTime(nextReset)} - -
- )} -
+ {renderQuotaTooltip()} 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 62eeadd8..464e30b4 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx @@ -167,7 +167,7 @@ export function ModelConfigTab({ isBulkPausing={isBulkPausing} isBulkResuming={isBulkResuming} privacyMode={privacyMode} - showQuota={provider === 'agy' && !isRemoteMode} + showQuota={['agy', 'codex', 'gemini'].includes(provider) && !isRemoteMode} isKiro={isKiro} kiroNoIncognito={kiroNoIncognito} onKiroNoIncognitoChange={saveKiroNoIncognito} From 8ce749581ef7667bec29bdfe17d02f824e06bed4 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 29 Jan 2026 22:17:57 -0500 Subject: [PATCH 04/11] refactor(ui): extract duplicated quota helpers to shared utils Address PR review feedback: - Add type guards (isAgyQuotaResult, isCodexQuotaResult, isGeminiQuotaResult) - Add unified getProviderMinQuota() and getProviderResetTime() helpers - Export QUOTA_SUPPORTED_PROVIDERS constant from hooks - Update account-item.tsx and account-card.tsx to use shared functions - Eliminate code duplication across provider-specific quota handling Closes #400 review feedback --- .../account/flow-viz/account-card.tsx | 58 ++--- .../cliproxy/provider-editor/account-item.tsx | 245 +++++++----------- ui/src/hooks/use-cliproxy-stats.ts | 5 +- ui/src/lib/utils.ts | 92 ++++++- 4 files changed, 208 insertions(+), 192 deletions(-) diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index c5773214..f52ca1f6 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -5,20 +5,19 @@ import { cn, formatResetTime, - getClaudeResetTime, - getMinClaudeQuota, getModelsWithTiers, groupModelsByTier, - getMinCodexQuota, - getMinGeminiQuota, - getCodexResetTime, - getGeminiResetTime, + getProviderMinQuota, + getProviderResetTime, + isAgyQuotaResult, + isCodexQuotaResult, + isGeminiQuotaResult, type ModelTier, } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { GripVertical, Loader2, Clock, Pause, Play } from 'lucide-react'; -import { useAccountQuota } from '@/hooks/use-cliproxy-stats'; -import type { CodexQuotaResult, GeminiCliQuotaResult, QuotaResult } from '@/lib/api-client'; +import { useAccountQuota, QUOTA_SUPPORTED_PROVIDERS } from '@/hooks/use-cliproxy-stats'; +import type { QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Button } from '@/components/ui/button'; @@ -95,28 +94,17 @@ export function AccountCard({ const connectorPosition = CONNECTOR_POSITION_MAP[zone]; // Quota for CLIProxy accounts (agy, codex, gemini) - const isCliproxyProvider = ['agy', 'codex', 'gemini'].includes(account.provider); + const isCliproxyProvider = QUOTA_SUPPORTED_PROVIDERS.includes( + account.provider as QuotaSupportedProvider + ); const { data: quota, isLoading: quotaLoading } = useAccountQuota( account.provider, account.id, isCliproxyProvider ); - // Get provider-specific minimum quota - const getProviderMinQuota = () => { - if (!quota?.success) return null; - switch (account.provider) { - case 'agy': - return getMinClaudeQuota((quota as QuotaResult).models); - case 'codex': - return getMinCodexQuota((quota as CodexQuotaResult).windows); - case 'gemini': - return getMinGeminiQuota((quota as GeminiCliQuotaResult).buckets); - default: - return null; - } - }; - const minQuota = getProviderMinQuota(); + // Use shared helper for provider-specific minimum quota + const minQuota = getProviderMinQuota(account.provider, quota); // Tier badge (AGY only) - show P for Pro, U for Ultra const showTierBadge = @@ -266,11 +254,11 @@ export function AccountCard({
- {account.provider === 'agy' ? ( + {quota && isAgyQuotaResult(quota) ? (

Model Quotas:

{(() => { - const tiered = getModelsWithTiers((quota as QuotaResult)?.models || []); + const tiered = getModelsWithTiers(quota.models || []); const groups = groupModelsByTier(tiered); const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other']; return tierOrder.map((tier, idx) => { @@ -297,7 +285,7 @@ export function AccountCard({ }); })()} {(() => { - const resetTime = getClaudeResetTime((quota as QuotaResult)?.models || []); + const resetTime = getProviderResetTime('agy', quota); return resetTime ? (
@@ -308,10 +296,10 @@ export function AccountCard({ ) : null; })()}
- ) : account.provider === 'codex' ? ( + ) : quota && isCodexQuotaResult(quota) ? (

Rate Limits:

- {(quota as CodexQuotaResult)?.windows?.map((w) => ( + {quota.windows?.map((w) => (
{w.label} @@ -320,9 +308,7 @@ export function AccountCard({
))} {(() => { - const resetTime = getCodexResetTime( - (quota as CodexQuotaResult)?.windows || [] - ); + const resetTime = getProviderResetTime('codex', quota); return resetTime ? (
@@ -333,10 +319,10 @@ export function AccountCard({ ) : null; })()}
- ) : account.provider === 'gemini' ? ( + ) : quota && isGeminiQuotaResult(quota) ? (

Buckets:

- {(quota as GeminiCliQuotaResult)?.buckets?.map((b) => ( + {quota.buckets?.map((b) => (
{b.label} @@ -345,9 +331,7 @@ export function AccountCard({
))} {(() => { - const resetTime = getGeminiResetTime( - (quota as GeminiCliQuotaResult)?.buckets || [] - ); + const resetTime = getProviderResetTime('gemini', quota); return resetTime ? (
diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index ef705c09..474d4db4 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -32,24 +32,17 @@ import { import { cn, formatResetTime, - getClaudeResetTime, - getMinClaudeQuota, getModelsWithTiers, groupModelsByTier, - getMinCodexQuota, - getMinGeminiQuota, - getCodexResetTime, - getGeminiResetTime, + getProviderMinQuota, + getProviderResetTime, + isAgyQuotaResult, + isCodexQuotaResult, + isGeminiQuotaResult, type ModelTier, } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; -import { - useAccountQuota, - useCliproxyStats, - type QuotaResult, - type CodexQuotaResult, - type GeminiCliQuotaResult, -} from '@/hooks/use-cliproxy-stats'; +import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats'; import type { AccountItemProps } from './types'; /** @@ -128,151 +121,99 @@ export function AccountItem({ const runtimeLastUsed = stats?.accountStats?.[account.email || account.id]?.lastUsedAt; const wasRecentlyUsed = isRecentlyUsed(runtimeLastUsed); - // Compute min quota based on provider - const getProviderMinQuota = (): number | null => { - if (!quota?.success) return null; - switch (account.provider) { - case 'agy': - return getMinClaudeQuota((quota as QuotaResult).models); - case 'codex': - return getMinCodexQuota((quota as CodexQuotaResult).windows); - case 'gemini': - return getMinGeminiQuota((quota as GeminiCliQuotaResult).buckets); - default: - return null; - } - }; - const minQuota = getProviderMinQuota(); + // Use shared utility functions for provider-specific quota handling + const minQuota = getProviderMinQuota(account.provider, quota); + const nextReset = getProviderResetTime(account.provider, quota); - // Compute reset time based on provider - const getProviderResetTime = (): string | null => { - if (!quota?.success) return null; - switch (account.provider) { - case 'agy': - return getClaudeResetTime((quota as QuotaResult).models); - case 'codex': - return getCodexResetTime((quota as CodexQuotaResult).windows); - case 'gemini': - return getGeminiResetTime((quota as GeminiCliQuotaResult).buckets); - default: - return null; - } - }; - const nextReset = getProviderResetTime(); - - // Render Antigravity (agy) provider tooltip - const renderAgyTooltip = () => { - const tiered = getModelsWithTiers((quota as QuotaResult).models || []); - const groups = groupModelsByTier(tiered); - const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other']; - return tierOrder.map((tier, idx) => { - const models = groups.get(tier); - if (!models || models.length === 0) return null; - const isFirst = tierOrder.slice(0, idx).every((t) => !groups.get(t)?.length); - return ( -
- {!isFirst &&
} - {models.map((m) => ( -
- {m.displayName} - - {m.percentage}% - -
- ))} -
- ); - }); - }; - - // Render Codex provider tooltip - const renderCodexTooltip = () => { - const windows = (quota as CodexQuotaResult).windows; - const planType = (quota as CodexQuotaResult).planType; - return ( - <> - {planType &&

Plan: {planType}

} - {windows.map((w) => ( -
- {w.label} - {w.remainingPercent}% -
- ))} - - ); - }; - - // Render Gemini provider tooltip - const renderGeminiTooltip = () => { - const buckets = (quota as GeminiCliQuotaResult).buckets; - return ( - <> - {buckets.map((b) => ( -
- - {b.label} - {b.tokenType ? ` (${b.tokenType})` : ''} - - {b.remainingPercent}% -
- ))} - - ); - }; - - // Provider-specific tooltip content renderer + // Provider-specific tooltip content renderer using type guards const renderQuotaTooltip = () => { if (!quota?.success) return null; - switch (account.provider) { - case 'agy': - return ( -
-

Model Quotas:

- {renderAgyTooltip()} - {nextReset && ( -
- - - Resets {formatResetTime(nextReset)} - + // Antigravity (agy) provider tooltip + if (isAgyQuotaResult(quota)) { + const tiered = getModelsWithTiers(quota.models || []); + const groups = groupModelsByTier(tiered); + const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other']; + return ( +
+

Model Quotas:

+ {tierOrder.map((tier, idx) => { + const models = groups.get(tier); + if (!models || models.length === 0) return null; + const isFirst = tierOrder.slice(0, idx).every((t) => !groups.get(t)?.length); + return ( +
+ {!isFirst &&
} + {models.map((m) => ( +
+ + {m.displayName} + + + {m.percentage}% + +
+ ))}
- )} -
- ); - case 'codex': - return ( -
-

Rate Limit Windows:

- {renderCodexTooltip()} - {nextReset && ( -
- - - Resets {formatResetTime(nextReset)} - -
- )} -
- ); - case 'gemini': - return ( -
-

Model Buckets:

- {renderGeminiTooltip()} - {nextReset && ( -
- - - Resets {formatResetTime(nextReset)} - -
- )} -
- ); - default: - return null; + ); + })} + {nextReset && ( +
+ + Resets {formatResetTime(nextReset)} +
+ )} +
+ ); } + + // Codex provider tooltip + if (isCodexQuotaResult(quota)) { + return ( +
+

Rate Limit Windows:

+ {quota.planType &&

Plan: {quota.planType}

} + {quota.windows.map((w) => ( +
+ {w.label} + {w.remainingPercent}% +
+ ))} + {nextReset && ( +
+ + Resets {formatResetTime(nextReset)} +
+ )} +
+ ); + } + + // Gemini provider tooltip + if (isGeminiQuotaResult(quota)) { + return ( +
+

Model Buckets:

+ {quota.buckets.map((b) => ( +
+ + {b.label} + {b.tokenType ? ` (${b.tokenType})` : ''} + + {b.remainingPercent}% +
+ ))} + {nextReset && ( +
+ + Resets {formatResetTime(nextReset)} +
+ )} +
+ ); + } + + return null; }; return ( diff --git a/ui/src/hooks/use-cliproxy-stats.ts b/ui/src/hooks/use-cliproxy-stats.ts index 587e2dcc..05fa0682 100644 --- a/ui/src/hooks/use-cliproxy-stats.ts +++ b/ui/src/hooks/use-cliproxy-stats.ts @@ -204,7 +204,8 @@ export function useCliproxyErrorLogContent(name: string | null) { export type { ModelQuota, QuotaResult, CodexQuotaResult, GeminiCliQuotaResult }; /** Providers with quota API support */ -const SUPPORTED_PROVIDERS = ['agy', 'codex', 'gemini'] as const; +export const QUOTA_SUPPORTED_PROVIDERS = ['agy', 'codex', 'gemini'] as const; +export type QuotaSupportedProvider = (typeof QUOTA_SUPPORTED_PROVIDERS)[number]; /** * Fetch account quota from API (Antigravity only) @@ -290,7 +291,7 @@ export function useAccountQuota(provider: string, accountId: string, enabled = t queryFn: () => fetchQuotaByProvider(provider, accountId), enabled: enabled && - SUPPORTED_PROVIDERS.includes(provider as (typeof SUPPORTED_PROVIDERS)[number]) && + QUOTA_SUPPORTED_PROVIDERS.includes(provider as QuotaSupportedProvider) && !!accountId, staleTime: 60000, // Match refetchInterval to prevent early refetching refetchInterval: 60000, // Refresh every 1 minute diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 54e665f1..6c2df585 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -1,6 +1,12 @@ import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; -import type { CodexQuotaWindow, GeminiCliBucket } from './api-client'; +import type { + CodexQuotaWindow, + CodexQuotaResult, + GeminiCliBucket, + GeminiCliQuotaResult, + QuotaResult, +} from './api-client'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -337,3 +343,87 @@ export function getGeminiResetTime(buckets: GeminiCliBucket[]): string | null { if (resets.length === 0) return null; return resets.sort()[0]; } + +// ==================== Unified Quota Type Guards ==================== + +/** Unified quota result type for provider-agnostic handling */ +export type UnifiedQuotaResult = QuotaResult | CodexQuotaResult | GeminiCliQuotaResult; + +/** 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); +} + +/** Type guard: Check if quota result is from Codex provider */ +export function isCodexQuotaResult(quota: UnifiedQuotaResult): quota is CodexQuotaResult { + return 'windows' in quota && Array.isArray((quota as CodexQuotaResult).windows); +} + +/** 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); +} + +// ==================== Unified Quota Helpers ==================== + +/** + * Get minimum quota percentage for any provider + * Centralizes provider-specific logic to eliminate duplication + */ +export function getProviderMinQuota( + provider: string, + quota: UnifiedQuotaResult | null | undefined +): number | null { + if (!quota?.success) return null; + + switch (provider) { + case 'agy': + if (isAgyQuotaResult(quota)) { + return getMinClaudeQuota(quota.models); + } + return null; + case 'codex': + if (isCodexQuotaResult(quota)) { + return getMinCodexQuota(quota.windows); + } + return null; + case 'gemini': + if (isGeminiQuotaResult(quota)) { + return getMinGeminiQuota(quota.buckets); + } + return null; + default: + return null; + } +} + +/** + * Get earliest reset time for any provider + * Centralizes provider-specific logic to eliminate duplication + */ +export function getProviderResetTime( + provider: string, + quota: UnifiedQuotaResult | null | undefined +): string | null { + if (!quota?.success) return null; + + switch (provider) { + case 'agy': + if (isAgyQuotaResult(quota)) { + return getClaudeResetTime(quota.models); + } + return null; + case 'codex': + if (isCodexQuotaResult(quota)) { + return getCodexResetTime(quota.windows); + } + return null; + case 'gemini': + if (isGeminiQuotaResult(quota)) { + return getGeminiResetTime(quota.buckets); + } + return null; + default: + return null; + } +} From 32ef23314a517bcda1816ce0c55691b3dd2616de Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 29 Jan 2026 22:23:53 -0500 Subject: [PATCH 05/11] refactor(ui): address code review feedback - Consolidate UnifiedQuotaResult type: import from utils.ts, re-export - Remove unused useCodexQuota/useGeminiQuota hooks (useAccountQuota handles all) - Remove unnecessary optional chaining where type guards guarantee property --- .../account/flow-viz/account-card.tsx | 4 +- ui/src/hooks/use-cliproxy-stats.ts | 37 ++----------------- 2 files changed, 5 insertions(+), 36 deletions(-) diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index f52ca1f6..babef8dc 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -299,7 +299,7 @@ export function AccountCard({ ) : quota && isCodexQuotaResult(quota) ? (

Rate Limits:

- {quota.windows?.map((w) => ( + {quota.windows.map((w) => (
{w.label} @@ -322,7 +322,7 @@ export function AccountCard({ ) : quota && isGeminiQuotaResult(quota) ? (

Buckets:

- {quota.buckets?.map((b) => ( + {quota.buckets.map((b) => (
{b.label} diff --git a/ui/src/hooks/use-cliproxy-stats.ts b/ui/src/hooks/use-cliproxy-stats.ts index 05fa0682..91dac614 100644 --- a/ui/src/hooks/use-cliproxy-stats.ts +++ b/ui/src/hooks/use-cliproxy-stats.ts @@ -9,6 +9,7 @@ import type { CodexQuotaResult, GeminiCliQuotaResult, } from '@/lib/api-client'; +import type { UnifiedQuotaResult } from '@/lib/utils'; /** Per-account usage statistics */ export interface AccountUsageStats { @@ -261,8 +262,8 @@ async function fetchGeminiQuotaApi(accountId: string): Promise { - if (!accountId) throw new Error('Account ID required'); - return fetchCodexQuotaApi(accountId); - }, - enabled: !!accountId, - staleTime: 30000, - refetchInterval: 60000, - }); -} - -/** - * Hook to get Gemini CLI quota for a specific account - */ -export function useGeminiQuota(accountId: string | null) { - return useQuery({ - queryKey: ['gemini-quota', accountId], - queryFn: async () => { - if (!accountId) throw new Error('Account ID required'); - return fetchGeminiQuotaApi(accountId); - }, - enabled: !!accountId, - staleTime: 30000, - refetchInterval: 60000, - }); -} From eeb0dde8cabf0db5eea758d50ca7fd0b126a6404 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 29 Jan 2026 22:33:07 -0500 Subject: [PATCH 06/11] refactor(ui): extract shared QuotaTooltipContent component Address PR review feedback: - Create QuotaTooltipContent component in shared/ for DRY principle - Use component in account-item.tsx and account-card.tsx - Use QUOTA_SUPPORTED_PROVIDERS constant in model-config-tab.tsx - Reduces ~200 lines of duplicated tooltip rendering code --- .../account/flow-viz/account-card.tsx | 107 +--------------- .../cliproxy/provider-editor/account-item.tsx | 107 +--------------- .../provider-editor/model-config-tab.tsx | 5 +- ui/src/components/shared/index.ts | 1 + .../shared/quota-tooltip-content.tsx | 116 ++++++++++++++++++ 5 files changed, 129 insertions(+), 207 deletions(-) create mode 100644 ui/src/components/shared/quota-tooltip-content.tsx diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index babef8dc..7f678b3a 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -2,24 +2,14 @@ * Account Card Component for Flow Visualization */ -import { - cn, - formatResetTime, - getModelsWithTiers, - groupModelsByTier, - getProviderMinQuota, - getProviderResetTime, - isAgyQuotaResult, - isCodexQuotaResult, - isGeminiQuotaResult, - type ModelTier, -} from '@/lib/utils'; +import { cn, getProviderMinQuota, getProviderResetTime } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; -import { GripVertical, Loader2, Clock, Pause, Play } from 'lucide-react'; +import { GripVertical, Loader2, Pause, Play } from 'lucide-react'; import { useAccountQuota, QUOTA_SUPPORTED_PROVIDERS } from '@/hooks/use-cliproxy-stats'; import type { QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Button } from '@/components/ui/button'; +import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content'; import type { AccountData, DragOffset } from './types'; import { cleanEmail } from './utils'; @@ -105,6 +95,7 @@ export function AccountCard({ // Use shared helper for provider-specific minimum quota const minQuota = getProviderMinQuota(account.provider, quota); + const resetTime = getProviderResetTime(account.provider, quota); // Tier badge (AGY only) - show P for Pro, U for Ultra const showTierBadge = @@ -254,95 +245,7 @@ export function AccountCard({
- {quota && isAgyQuotaResult(quota) ? ( -
-

Model Quotas:

- {(() => { - const tiered = getModelsWithTiers(quota.models || []); - const groups = groupModelsByTier(tiered); - const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other']; - return tierOrder.map((tier, idx) => { - const models = groups.get(tier); - if (!models || models.length === 0) return null; - const isFirst = tierOrder - .slice(0, idx) - .every((t) => !groups.get(t)?.length); - return ( -
- {!isFirst &&
} - {models.map((m) => ( -
- - {m.displayName} - - - {m.percentage}% - -
- ))} -
- ); - }); - })()} - {(() => { - const resetTime = getProviderResetTime('agy', quota); - return resetTime ? ( -
- - - Resets {formatResetTime(resetTime)} - -
- ) : null; - })()} -
- ) : quota && isCodexQuotaResult(quota) ? ( -
-

Rate Limits:

- {quota.windows.map((w) => ( -
- - {w.label} - - {w.remainingPercent}% -
- ))} - {(() => { - const resetTime = getProviderResetTime('codex', quota); - return resetTime ? ( -
- - - Resets {formatResetTime(resetTime)} - -
- ) : null; - })()} -
- ) : quota && isGeminiQuotaResult(quota) ? ( -
-

Buckets:

- {quota.buckets.map((b) => ( -
- - {b.label} - - {b.remainingPercent}% -
- ))} - {(() => { - const resetTime = getProviderResetTime('gemini', quota); - return resetTime ? ( -
- - - Resets {formatResetTime(resetTime)} - -
- ) : null; - })()} -
- ) : null} + {quota && } diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 474d4db4..a7e58558 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -29,20 +29,10 @@ import { FolderCode, Check, } from 'lucide-react'; -import { - cn, - formatResetTime, - getModelsWithTiers, - groupModelsByTier, - getProviderMinQuota, - getProviderResetTime, - isAgyQuotaResult, - isCodexQuotaResult, - isGeminiQuotaResult, - type ModelTier, -} from '@/lib/utils'; +import { cn, getProviderMinQuota, getProviderResetTime } from '@/lib/utils'; 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 type { AccountItemProps } from './types'; /** @@ -125,97 +115,6 @@ export function AccountItem({ const minQuota = getProviderMinQuota(account.provider, quota); const nextReset = getProviderResetTime(account.provider, quota); - // Provider-specific tooltip content renderer using type guards - const renderQuotaTooltip = () => { - if (!quota?.success) return null; - - // Antigravity (agy) provider tooltip - if (isAgyQuotaResult(quota)) { - const tiered = getModelsWithTiers(quota.models || []); - const groups = groupModelsByTier(tiered); - const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other']; - return ( -
-

Model Quotas:

- {tierOrder.map((tier, idx) => { - const models = groups.get(tier); - if (!models || models.length === 0) return null; - const isFirst = tierOrder.slice(0, idx).every((t) => !groups.get(t)?.length); - return ( -
- {!isFirst &&
} - {models.map((m) => ( -
- - {m.displayName} - - - {m.percentage}% - -
- ))} -
- ); - })} - {nextReset && ( -
- - Resets {formatResetTime(nextReset)} -
- )} -
- ); - } - - // Codex provider tooltip - if (isCodexQuotaResult(quota)) { - return ( -
-

Rate Limit Windows:

- {quota.planType &&

Plan: {quota.planType}

} - {quota.windows.map((w) => ( -
- {w.label} - {w.remainingPercent}% -
- ))} - {nextReset && ( -
- - Resets {formatResetTime(nextReset)} -
- )} -
- ); - } - - // Gemini provider tooltip - if (isGeminiQuotaResult(quota)) { - return ( -
-

Model Buckets:

- {quota.buckets.map((b) => ( -
- - {b.label} - {b.tokenType ? ` (${b.tokenType})` : ''} - - {b.remainingPercent}% -
- ))} - {nextReset && ( -
- - Resets {formatResetTime(nextReset)} -
- )} -
- ); - } - - return null; - }; - return (
- {renderQuotaTooltip()} + {quota && } 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 464e30b4..ed5ff819 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx @@ -11,6 +11,7 @@ import { AccountsSection } from './accounts-section'; import { api } from '@/lib/api-client'; import type { ProviderCatalog } from '../provider-model-selector'; import type { OAuthAccount } from '@/lib/api-client'; +import { QUOTA_SUPPORTED_PROVIDERS, type QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats'; interface ModelConfigTabProps { provider: string; @@ -167,7 +168,9 @@ export function ModelConfigTab({ isBulkPausing={isBulkPausing} isBulkResuming={isBulkResuming} privacyMode={privacyMode} - showQuota={['agy', 'codex', 'gemini'].includes(provider) && !isRemoteMode} + showQuota={ + QUOTA_SUPPORTED_PROVIDERS.includes(provider as QuotaSupportedProvider) && !isRemoteMode + } isKiro={isKiro} kiroNoIncognito={kiroNoIncognito} onKiroNoIncognitoChange={saveKiroNoIncognito} diff --git a/ui/src/components/shared/index.ts b/ui/src/components/shared/index.ts index 602a9712..ae67a7e4 100644 --- a/ui/src/components/shared/index.ts +++ b/ui/src/components/shared/index.ts @@ -17,6 +17,7 @@ export { PrivacyToggle } from './privacy-toggle'; export { ProjectSelectionDialog } from './project-selection-dialog'; export { ProviderIcon } from './provider-icon'; export { QuickCommands } from './quick-commands'; +export { QuotaTooltipContent } from './quota-tooltip-content'; export { SettingsDialog } from './settings-dialog'; export { SponsorButton } from './sponsor-button'; export { StatCard } from './stat-card'; diff --git a/ui/src/components/shared/quota-tooltip-content.tsx b/ui/src/components/shared/quota-tooltip-content.tsx new file mode 100644 index 00000000..177394e8 --- /dev/null +++ b/ui/src/components/shared/quota-tooltip-content.tsx @@ -0,0 +1,116 @@ +/** + * Shared Quota Tooltip Content Component + * Displays provider-specific quota information in tooltips + */ + +import { Clock } from 'lucide-react'; +import { + cn, + formatResetTime, + getModelsWithTiers, + groupModelsByTier, + isAgyQuotaResult, + isCodexQuotaResult, + isGeminiQuotaResult, + type ModelTier, + type UnifiedQuotaResult, +} from '@/lib/utils'; + +interface QuotaTooltipContentProps { + quota: UnifiedQuotaResult; + resetTime: string | null; +} + +/** + * Renders provider-specific quota tooltip content + * Uses type guards for proper TypeScript narrowing + */ +export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentProps) { + if (!quota?.success) return null; + + // Antigravity (agy) provider tooltip + if (isAgyQuotaResult(quota)) { + const tiered = getModelsWithTiers(quota.models || []); + const groups = groupModelsByTier(tiered); + const tierOrder: ModelTier[] = ['primary', 'gemini-3', 'gemini-2', 'other']; + + return ( +
+

Model Quotas:

+ {tierOrder.map((tier, idx) => { + const models = groups.get(tier); + if (!models || models.length === 0) return null; + const isFirst = tierOrder.slice(0, idx).every((t) => !groups.get(t)?.length); + return ( +
+ {!isFirst &&
} + {models.map((m) => ( +
+ + {m.displayName} + + + {m.percentage}% + +
+ ))} +
+ ); + })} + +
+ ); + } + + // Codex provider tooltip + if (isCodexQuotaResult(quota)) { + return ( +
+

Rate Limits:

+ {quota.planType &&

Plan: {quota.planType}

} + {quota.windows.map((w) => ( +
+ {w.label} + {w.remainingPercent}% +
+ ))} + +
+ ); + } + + // Gemini provider tooltip + if (isGeminiQuotaResult(quota)) { + return ( +
+

Buckets:

+ {quota.buckets.map((b) => ( +
+ + {b.label} + {b.tokenType ? ` (${b.tokenType})` : ''} + + {b.remainingPercent}% +
+ ))} + +
+ ); + } + + return null; +} + +/** + * Reset time indicator shown at bottom of tooltip + */ +function ResetTimeIndicator({ resetTime }: { resetTime: string | null }) { + if (!resetTime) return null; + + return ( +
+ + Resets {formatResetTime(resetTime)} +
+ ); +} From 0b8635d3ba47cf954ad9cc5fe4d30c64ec77779c Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 29 Jan 2026 22:44:09 -0500 Subject: [PATCH 07/11] test(ui): add comprehensive tests for quota utility functions Address PR review feedback - add tests for: - getMinCodexQuota() / getCodexResetTime() - getMinGeminiQuota() / getGeminiResetTime() - getProviderMinQuota() / getProviderResetTime() - Type guards (isAgyQuotaResult, isCodexQuotaResult, isGeminiQuotaResult) Covers: empty arrays, null/undefined, edge cases (0%/100%/negative), provider dispatch logic, and type discrimination. --- ui/tests/unit/ui/lib/quota-utils.test.ts | 1129 +++++++++++++++++++++- 1 file changed, 1121 insertions(+), 8 deletions(-) diff --git a/ui/tests/unit/ui/lib/quota-utils.test.ts b/ui/tests/unit/ui/lib/quota-utils.test.ts index af30823d..a1f1c1e4 100644 --- a/ui/tests/unit/ui/lib/quota-utils.test.ts +++ b/ui/tests/unit/ui/lib/quota-utils.test.ts @@ -3,7 +3,28 @@ */ import { describe, it, expect } from 'vitest'; -import { getMinClaudeQuota, sortModelsByPriority, getEarliestResetTime } from '@/lib/utils'; +import { + getMinClaudeQuota, + sortModelsByPriority, + getEarliestResetTime, + getMinCodexQuota, + getCodexResetTime, + getMinGeminiQuota, + getGeminiResetTime, + getProviderMinQuota, + getProviderResetTime, + isAgyQuotaResult, + isCodexQuotaResult, + isGeminiQuotaResult, +} from '@/lib/utils'; +import type { + CodexQuotaWindow, + CodexQuotaResult, + GeminiCliBucket, + GeminiCliQuotaResult, + QuotaResult, + ModelQuota, +} from '@/lib/api-client'; describe('getMinClaudeQuota', () => { describe('basic functionality', () => { @@ -20,12 +41,13 @@ describe('getMinClaudeQuota', () => { expect(getMinClaudeQuota(models)).toBe(90); }); - it('falls back to minimum of all models when no Claude models', () => { + it('returns 0 when no Claude/GPT models (exhausted)', () => { const models = [ { name: 'gemini-2.5-flash', displayName: 'Gemini 2.5 Flash', percentage: 100 }, { name: 'gemini-3-pro', displayName: 'Gemini 3 Pro', percentage: 98 }, ]; - expect(getMinClaudeQuota(models)).toBe(98); + // No Claude/GPT models means they're exhausted + expect(getMinClaudeQuota(models)).toBe(0); }); it('handles single Claude model', () => { @@ -100,12 +122,13 @@ describe('getMinClaudeQuota', () => { expect(getMinClaudeQuota(models)).toBe(75); }); - it('returns null when all percentages are invalid', () => { + it('returns 0 when all percentages are invalid', () => { const models = [ { name: 'claude-opus', displayName: 'Claude Opus', percentage: NaN }, { name: 'claude-sonnet', displayName: 'Claude Sonnet', percentage: Infinity }, ]; - expect(getMinClaudeQuota(models)).toBeNull(); + // Invalid percentages filtered out, no valid ones left -> returns 0 + expect(getMinClaudeQuota(models)).toBe(0); }); }); @@ -153,16 +176,18 @@ describe('getMinClaudeQuota', () => { }); describe('sortModelsByPriority', () => { - it('sorts Claude models first', () => { + it('sorts Claude and GPT models first (Tier 0)', () => { const models = [ { name: 'gemini-flash', displayName: 'Gemini Flash' }, { name: 'claude-opus', displayName: 'Claude Opus' }, { name: 'gpt-4', displayName: 'GPT-4' }, ]; const sorted = sortModelsByPriority(models); + // Both Claude and GPT are Tier 0, sorted alphabetically: Claude Opus < GPT-4 expect(sorted[0].name).toBe('claude-opus'); - expect(sorted[1].name).toBe('gemini-flash'); - expect(sorted[2].name).toBe('gpt-4'); + expect(sorted[1].name).toBe('gpt-4'); + // Gemini is lower priority + expect(sorted[2].name).toBe('gemini-flash'); }); it('sorts alphabetically within same priority', () => { @@ -216,3 +241,1091 @@ describe('getEarliestResetTime', () => { expect(getEarliestResetTime(models)).toBe('2026-01-01T14:00:00Z'); }); }); + +// ==================== Codex Quota Functions ==================== + +describe('getMinCodexQuota', () => { + describe('basic functionality', () => { + it('returns null for empty windows array', () => { + expect(getMinCodexQuota([])).toBeNull(); + }); + + it('returns null for null input', () => { + expect(getMinCodexQuota(null as unknown as CodexQuotaWindow[])).toBeNull(); + }); + + it('returns null for undefined input', () => { + expect(getMinCodexQuota(undefined as unknown as CodexQuotaWindow[])).toBeNull(); + }); + + it('returns minimum remaining percent from single window', () => { + const windows: CodexQuotaWindow[] = [ + { + label: 'Primary', + usedPercent: 25, + remainingPercent: 75, + resetAfterSeconds: 3600, + resetAt: '2026-01-30T12:00:00Z', + }, + ]; + expect(getMinCodexQuota(windows)).toBe(75); + }); + + it('returns minimum remaining percent from multiple windows', () => { + const windows: CodexQuotaWindow[] = [ + { + label: 'Primary', + usedPercent: 20, + remainingPercent: 80, + resetAfterSeconds: 3600, + resetAt: '2026-01-30T12:00:00Z', + }, + { + label: 'Secondary', + usedPercent: 65, + remainingPercent: 35, + resetAfterSeconds: 7200, + resetAt: '2026-01-30T14:00:00Z', + }, + { + label: 'Code Review (Primary)', + usedPercent: 50, + remainingPercent: 50, + resetAfterSeconds: 1800, + resetAt: '2026-01-30T11:00:00Z', + }, + ]; + expect(getMinCodexQuota(windows)).toBe(35); + }); + }); + + describe('edge cases', () => { + it('handles 0% remaining quota', () => { + const windows: CodexQuotaWindow[] = [ + { + label: 'Primary', + usedPercent: 100, + remainingPercent: 0, + resetAfterSeconds: 3600, + resetAt: '2026-01-30T12:00:00Z', + }, + { + label: 'Secondary', + usedPercent: 50, + remainingPercent: 50, + resetAfterSeconds: 7200, + resetAt: '2026-01-30T14:00:00Z', + }, + ]; + expect(getMinCodexQuota(windows)).toBe(0); + }); + + it('handles 100% remaining quota', () => { + const windows: CodexQuotaWindow[] = [ + { + label: 'Primary', + usedPercent: 0, + remainingPercent: 100, + resetAfterSeconds: 3600, + resetAt: '2026-01-30T12:00:00Z', + }, + ]; + expect(getMinCodexQuota(windows)).toBe(100); + }); + + it('handles negative values (should not occur but test defensive code)', () => { + const windows: CodexQuotaWindow[] = [ + { + label: 'Primary', + usedPercent: 120, + remainingPercent: -20, + resetAfterSeconds: 3600, + resetAt: '2026-01-30T12:00:00Z', + }, + { + label: 'Secondary', + usedPercent: 50, + remainingPercent: 50, + resetAfterSeconds: 7200, + resetAt: '2026-01-30T14:00:00Z', + }, + ]; + expect(getMinCodexQuota(windows)).toBe(-20); + }); + }); + + describe('real-world scenarios', () => { + it('matches Codex rate limit response structure', () => { + const windows: CodexQuotaWindow[] = [ + { + label: 'Primary', + usedPercent: 45.2, + remainingPercent: 54.8, + resetAfterSeconds: 3456, + resetAt: '2026-01-30T13:27:36Z', + }, + { + label: 'Secondary', + usedPercent: 78.9, + remainingPercent: 21.1, + resetAfterSeconds: 7890, + resetAt: '2026-01-30T15:41:30Z', + }, + { + label: 'Code Review (Primary)', + usedPercent: 12.5, + remainingPercent: 87.5, + resetAfterSeconds: 1234, + resetAt: '2026-01-30T12:20:34Z', + }, + { + label: 'Code Review (Secondary)', + usedPercent: 91.3, + remainingPercent: 8.7, + resetAfterSeconds: 5432, + resetAt: '2026-01-30T14:30:32Z', + }, + ]; + expect(getMinCodexQuota(windows)).toBe(8.7); + }); + }); +}); + +describe('getCodexResetTime', () => { + describe('basic functionality', () => { + it('returns null for empty windows array', () => { + expect(getCodexResetTime([])).toBeNull(); + }); + + it('returns null for null input', () => { + expect(getCodexResetTime(null as unknown as CodexQuotaWindow[])).toBeNull(); + }); + + it('returns null for undefined input', () => { + expect(getCodexResetTime(undefined as unknown as CodexQuotaWindow[])).toBeNull(); + }); + + it('returns earliest reset time from single window', () => { + const windows: CodexQuotaWindow[] = [ + { + label: 'Primary', + usedPercent: 25, + remainingPercent: 75, + resetAfterSeconds: 3600, + resetAt: '2026-01-30T12:00:00Z', + }, + ]; + expect(getCodexResetTime(windows)).toBe('2026-01-30T12:00:00Z'); + }); + + it('returns earliest reset time from multiple windows', () => { + const windows: CodexQuotaWindow[] = [ + { + label: 'Primary', + usedPercent: 20, + remainingPercent: 80, + resetAfterSeconds: 3600, + resetAt: '2026-01-30T14:00:00Z', + }, + { + label: 'Secondary', + usedPercent: 65, + remainingPercent: 35, + resetAfterSeconds: 7200, + resetAt: '2026-01-30T10:00:00Z', + }, + { + label: 'Code Review (Primary)', + usedPercent: 50, + remainingPercent: 50, + resetAfterSeconds: 1800, + resetAt: '2026-01-30T16:00:00Z', + }, + ]; + // Should return earliest (alphabetically sorted) + expect(getCodexResetTime(windows)).toBe('2026-01-30T10:00:00Z'); + }); + }); + + describe('edge cases', () => { + it('returns null when all resetAt are null', () => { + const windows: CodexQuotaWindow[] = [ + { + label: 'Primary', + usedPercent: 25, + remainingPercent: 75, + resetAfterSeconds: null, + resetAt: null, + }, + { + label: 'Secondary', + usedPercent: 50, + remainingPercent: 50, + resetAfterSeconds: null, + resetAt: null, + }, + ]; + expect(getCodexResetTime(windows)).toBeNull(); + }); + + it('handles mixed null and valid reset times', () => { + const windows: CodexQuotaWindow[] = [ + { + label: 'Primary', + usedPercent: 20, + remainingPercent: 80, + resetAfterSeconds: null, + resetAt: null, + }, + { + label: 'Secondary', + usedPercent: 65, + remainingPercent: 35, + resetAfterSeconds: 7200, + resetAt: '2026-01-30T12:00:00Z', + }, + { + label: 'Code Review', + usedPercent: 50, + remainingPercent: 50, + resetAfterSeconds: null, + resetAt: null, + }, + ]; + expect(getCodexResetTime(windows)).toBe('2026-01-30T12:00:00Z'); + }); + + it('sorts timestamps alphabetically (ISO 8601 format)', () => { + const windows: CodexQuotaWindow[] = [ + { + label: 'Primary', + usedPercent: 25, + remainingPercent: 75, + resetAfterSeconds: 3600, + resetAt: '2026-01-30T15:00:00Z', + }, + { + label: 'Secondary', + usedPercent: 50, + remainingPercent: 50, + resetAfterSeconds: 1800, + resetAt: '2026-01-30T09:30:00Z', + }, + { + label: 'Code Review', + usedPercent: 75, + remainingPercent: 25, + resetAfterSeconds: 5400, + resetAt: '2026-01-30T18:45:00Z', + }, + ]; + expect(getCodexResetTime(windows)).toBe('2026-01-30T09:30:00Z'); + }); + }); +}); + +// ==================== Gemini Quota Functions ==================== + +describe('getMinGeminiQuota', () => { + describe('basic functionality', () => { + it('returns null for empty buckets array', () => { + expect(getMinGeminiQuota([])).toBeNull(); + }); + + it('returns null for null input', () => { + expect(getMinGeminiQuota(null as unknown as GeminiCliBucket[])).toBeNull(); + }); + + it('returns null for undefined input', () => { + expect(getMinGeminiQuota(undefined as unknown as GeminiCliBucket[])).toBeNull(); + }); + + it('returns minimum remaining percent from single bucket', () => { + const buckets: GeminiCliBucket[] = [ + { + id: 'gemini-flash-series::input', + label: 'Gemini Flash Series', + tokenType: 'input', + remainingFraction: 0.82, + remainingPercent: 82, + resetTime: '2026-01-30T00:00:00Z', + modelIds: ['gemini-2.5-flash', 'gemini-3-flash'], + }, + ]; + expect(getMinGeminiQuota(buckets)).toBe(82); + }); + + it('returns minimum remaining percent from multiple buckets', () => { + const buckets: GeminiCliBucket[] = [ + { + id: 'gemini-flash-series::input', + label: 'Gemini Flash Series', + tokenType: 'input', + remainingFraction: 0.95, + remainingPercent: 95, + resetTime: '2026-01-30T00:00:00Z', + modelIds: ['gemini-2.5-flash'], + }, + { + id: 'gemini-pro-series::input', + label: 'Gemini Pro Series', + tokenType: 'input', + remainingFraction: 0.45, + remainingPercent: 45, + resetTime: '2026-01-30T00:00:00Z', + modelIds: ['gemini-2.5-pro', 'gemini-3-pro'], + }, + { + id: 'gemini-flash-series::output', + label: 'Gemini Flash Series', + tokenType: 'output', + remainingFraction: 0.78, + remainingPercent: 78, + resetTime: '2026-01-30T00:00:00Z', + modelIds: ['gemini-2.5-flash'], + }, + ]; + expect(getMinGeminiQuota(buckets)).toBe(45); + }); + }); + + describe('edge cases', () => { + it('handles 0% remaining quota', () => { + const buckets: GeminiCliBucket[] = [ + { + id: 'gemini-flash-series::input', + label: 'Gemini Flash Series', + tokenType: 'input', + remainingFraction: 0, + remainingPercent: 0, + resetTime: '2026-01-30T00:00:00Z', + modelIds: ['gemini-2.5-flash'], + }, + { + id: 'gemini-pro-series::input', + label: 'Gemini Pro Series', + tokenType: 'input', + remainingFraction: 0.5, + remainingPercent: 50, + resetTime: '2026-01-30T00:00:00Z', + modelIds: ['gemini-2.5-pro'], + }, + ]; + expect(getMinGeminiQuota(buckets)).toBe(0); + }); + + it('handles 100% remaining quota', () => { + const buckets: GeminiCliBucket[] = [ + { + id: 'gemini-flash-series::input', + label: 'Gemini Flash Series', + tokenType: 'input', + remainingFraction: 1.0, + remainingPercent: 100, + resetTime: '2026-01-30T00:00:00Z', + modelIds: ['gemini-2.5-flash'], + }, + ]; + expect(getMinGeminiQuota(buckets)).toBe(100); + }); + + it('handles negative values (should not occur but test defensive code)', () => { + const buckets: GeminiCliBucket[] = [ + { + id: 'gemini-flash-series::input', + label: 'Gemini Flash Series', + tokenType: 'input', + remainingFraction: -0.1, + remainingPercent: -10, + resetTime: '2026-01-30T00:00:00Z', + modelIds: ['gemini-2.5-flash'], + }, + { + id: 'gemini-pro-series::input', + label: 'Gemini Pro Series', + tokenType: 'input', + remainingFraction: 0.5, + remainingPercent: 50, + resetTime: '2026-01-30T00:00:00Z', + modelIds: ['gemini-2.5-pro'], + }, + ]; + expect(getMinGeminiQuota(buckets)).toBe(-10); + }); + }); + + describe('real-world scenarios', () => { + it('matches Gemini CLI response structure', () => { + const buckets: GeminiCliBucket[] = [ + { + id: 'gemini-flash-series::input', + label: 'Gemini Flash Series', + tokenType: 'input', + remainingFraction: 0.923, + remainingPercent: 92.3, + resetTime: '2026-01-30T00:00:00Z', + modelIds: ['gemini-2.5-flash', 'gemini-3-flash'], + }, + { + id: 'gemini-flash-series::output', + label: 'Gemini Flash Series', + tokenType: 'output', + remainingFraction: 0.867, + remainingPercent: 86.7, + resetTime: '2026-01-30T00:00:00Z', + modelIds: ['gemini-2.5-flash', 'gemini-3-flash'], + }, + { + id: 'gemini-pro-series::input', + label: 'Gemini Pro Series', + tokenType: 'input', + remainingFraction: 0.341, + remainingPercent: 34.1, + resetTime: '2026-01-30T00:00:00Z', + modelIds: ['gemini-2.5-pro', 'gemini-3-pro-high', 'gemini-3-pro-low'], + }, + { + id: 'gemini-pro-series::output', + label: 'Gemini Pro Series', + tokenType: 'output', + remainingFraction: 0.456, + remainingPercent: 45.6, + resetTime: '2026-01-30T00:00:00Z', + modelIds: ['gemini-2.5-pro', 'gemini-3-pro-high', 'gemini-3-pro-low'], + }, + ]; + expect(getMinGeminiQuota(buckets)).toBe(34.1); + }); + }); +}); + +describe('getGeminiResetTime', () => { + describe('basic functionality', () => { + it('returns null for empty buckets array', () => { + expect(getGeminiResetTime([])).toBeNull(); + }); + + it('returns null for null input', () => { + expect(getGeminiResetTime(null as unknown as GeminiCliBucket[])).toBeNull(); + }); + + it('returns null for undefined input', () => { + expect(getGeminiResetTime(undefined as unknown as GeminiCliBucket[])).toBeNull(); + }); + + it('returns earliest reset time from single bucket', () => { + const buckets: GeminiCliBucket[] = [ + { + id: 'gemini-flash-series::input', + label: 'Gemini Flash Series', + tokenType: 'input', + remainingFraction: 0.82, + remainingPercent: 82, + resetTime: '2026-01-30T00:00:00Z', + modelIds: ['gemini-2.5-flash'], + }, + ]; + expect(getGeminiResetTime(buckets)).toBe('2026-01-30T00:00:00Z'); + }); + + it('returns earliest reset time from multiple buckets', () => { + const buckets: GeminiCliBucket[] = [ + { + id: 'gemini-flash-series::input', + label: 'Gemini Flash Series', + tokenType: 'input', + remainingFraction: 0.95, + remainingPercent: 95, + resetTime: '2026-01-30T12:00:00Z', + modelIds: ['gemini-2.5-flash'], + }, + { + id: 'gemini-pro-series::input', + label: 'Gemini Pro Series', + tokenType: 'input', + remainingFraction: 0.45, + remainingPercent: 45, + resetTime: '2026-01-30T06:00:00Z', + modelIds: ['gemini-2.5-pro'], + }, + { + id: 'gemini-flash-series::output', + label: 'Gemini Flash Series', + tokenType: 'output', + remainingFraction: 0.78, + remainingPercent: 78, + resetTime: '2026-01-30T18:00:00Z', + modelIds: ['gemini-2.5-flash'], + }, + ]; + expect(getGeminiResetTime(buckets)).toBe('2026-01-30T06:00:00Z'); + }); + }); + + describe('edge cases', () => { + it('returns null when all resetTime are null', () => { + const buckets: GeminiCliBucket[] = [ + { + id: 'gemini-flash-series::input', + label: 'Gemini Flash Series', + tokenType: 'input', + remainingFraction: 0.82, + remainingPercent: 82, + resetTime: null, + modelIds: ['gemini-2.5-flash'], + }, + { + id: 'gemini-pro-series::input', + label: 'Gemini Pro Series', + tokenType: 'input', + remainingFraction: 0.45, + remainingPercent: 45, + resetTime: null, + modelIds: ['gemini-2.5-pro'], + }, + ]; + expect(getGeminiResetTime(buckets)).toBeNull(); + }); + + it('handles mixed null and valid reset times', () => { + const buckets: GeminiCliBucket[] = [ + { + id: 'gemini-flash-series::input', + label: 'Gemini Flash Series', + tokenType: 'input', + remainingFraction: 0.95, + remainingPercent: 95, + resetTime: null, + modelIds: ['gemini-2.5-flash'], + }, + { + id: 'gemini-pro-series::input', + label: 'Gemini Pro Series', + tokenType: 'input', + remainingFraction: 0.45, + remainingPercent: 45, + resetTime: '2026-01-30T00:00:00Z', + modelIds: ['gemini-2.5-pro'], + }, + { + id: 'gemini-flash-series::output', + label: 'Gemini Flash Series', + tokenType: 'output', + remainingFraction: 0.78, + remainingPercent: 78, + resetTime: null, + modelIds: ['gemini-2.5-flash'], + }, + ]; + expect(getGeminiResetTime(buckets)).toBe('2026-01-30T00:00:00Z'); + }); + + it('sorts timestamps alphabetically (ISO 8601 format)', () => { + const buckets: GeminiCliBucket[] = [ + { + id: 'gemini-flash-series::input', + label: 'Gemini Flash Series', + tokenType: 'input', + remainingFraction: 0.95, + remainingPercent: 95, + resetTime: '2026-01-30T18:00:00Z', + modelIds: ['gemini-2.5-flash'], + }, + { + id: 'gemini-pro-series::input', + label: 'Gemini Pro Series', + tokenType: 'input', + remainingFraction: 0.45, + remainingPercent: 45, + resetTime: '2026-01-30T03:30:00Z', + modelIds: ['gemini-2.5-pro'], + }, + { + id: 'gemini-flash-series::output', + label: 'Gemini Flash Series', + tokenType: 'output', + remainingFraction: 0.78, + remainingPercent: 78, + resetTime: '2026-01-30T21:45:00Z', + modelIds: ['gemini-2.5-flash'], + }, + ]; + expect(getGeminiResetTime(buckets)).toBe('2026-01-30T03:30:00Z'); + }); + }); +}); + +// ==================== Type Guards ==================== + +describe('isAgyQuotaResult', () => { + it('returns true for valid Agy quota result', () => { + const quota: QuotaResult = { + success: true, + models: [ + { name: 'claude-opus-4', displayName: 'Claude Opus 4', percentage: 95, resetTime: null }, + ], + lastUpdated: Date.now(), + }; + expect(isAgyQuotaResult(quota)).toBe(true); + }); + + it('returns false for Codex quota result', () => { + const quota: CodexQuotaResult = { + success: true, + windows: [], + planType: 'free', + lastUpdated: Date.now(), + }; + expect(isAgyQuotaResult(quota)).toBe(false); + }); + + it('returns false for Gemini quota result', () => { + const quota: GeminiCliQuotaResult = { + success: true, + buckets: [], + projectId: null, + lastUpdated: Date.now(), + }; + expect(isAgyQuotaResult(quota)).toBe(false); + }); + + it('returns true for Agy quota with empty models array', () => { + const quota: QuotaResult = { + success: true, + models: [], + lastUpdated: Date.now(), + }; + expect(isAgyQuotaResult(quota)).toBe(true); + }); +}); + +describe('isCodexQuotaResult', () => { + it('returns true for valid Codex quota result', () => { + const quota: CodexQuotaResult = { + success: true, + windows: [ + { + label: 'Primary', + usedPercent: 25, + remainingPercent: 75, + resetAfterSeconds: 3600, + resetAt: '2026-01-30T12:00:00Z', + }, + ], + planType: 'free', + lastUpdated: Date.now(), + }; + expect(isCodexQuotaResult(quota)).toBe(true); + }); + + it('returns false for Agy quota result', () => { + const quota: QuotaResult = { + success: true, + models: [], + lastUpdated: Date.now(), + }; + expect(isCodexQuotaResult(quota)).toBe(false); + }); + + it('returns false for Gemini quota result', () => { + const quota: GeminiCliQuotaResult = { + success: true, + buckets: [], + projectId: null, + lastUpdated: Date.now(), + }; + expect(isCodexQuotaResult(quota)).toBe(false); + }); + + it('returns true for Codex quota with empty windows array', () => { + const quota: CodexQuotaResult = { + success: true, + windows: [], + planType: null, + lastUpdated: Date.now(), + }; + expect(isCodexQuotaResult(quota)).toBe(true); + }); +}); + +describe('isGeminiQuotaResult', () => { + it('returns true for valid Gemini quota result', () => { + const quota: GeminiCliQuotaResult = { + success: true, + buckets: [ + { + id: 'gemini-flash-series::input', + label: 'Gemini Flash Series', + tokenType: 'input', + remainingFraction: 0.82, + remainingPercent: 82, + resetTime: '2026-01-30T00:00:00Z', + modelIds: ['gemini-2.5-flash'], + }, + ], + projectId: 'my-project-123', + lastUpdated: Date.now(), + }; + expect(isGeminiQuotaResult(quota)).toBe(true); + }); + + it('returns false for Agy quota result', () => { + const quota: QuotaResult = { + success: true, + models: [], + lastUpdated: Date.now(), + }; + expect(isGeminiQuotaResult(quota)).toBe(false); + }); + + it('returns false for Codex quota result', () => { + const quota: CodexQuotaResult = { + success: true, + windows: [], + planType: 'free', + lastUpdated: Date.now(), + }; + expect(isGeminiQuotaResult(quota)).toBe(false); + }); + + it('returns true for Gemini quota with empty buckets array', () => { + const quota: GeminiCliQuotaResult = { + success: true, + buckets: [], + projectId: null, + lastUpdated: Date.now(), + }; + expect(isGeminiQuotaResult(quota)).toBe(true); + }); +}); + +// ==================== Unified Provider Helpers ==================== + +describe('getProviderMinQuota', () => { + describe('agy provider', () => { + it('returns minimum quota from Claude models', () => { + const models: ModelQuota[] = [ + { name: 'claude-opus-4', displayName: 'Claude Opus 4', percentage: 85, resetTime: null }, + { + name: 'claude-sonnet-4', + displayName: 'Claude Sonnet 4', + percentage: 92, + resetTime: null, + }, + { + name: 'gemini-2.5-flash', + displayName: 'Gemini 2.5 Flash', + percentage: 100, + resetTime: null, + }, + ]; + const quota: QuotaResult = { success: true, models, lastUpdated: Date.now() }; + expect(getProviderMinQuota('agy', quota)).toBe(85); + }); + + it('returns null when quota is null', () => { + expect(getProviderMinQuota('agy', null)).toBeNull(); + }); + + it('returns null when quota is undefined', () => { + expect(getProviderMinQuota('agy', undefined)).toBeNull(); + }); + + it('returns null when success is false', () => { + const quota: QuotaResult = { + success: false, + models: [], + lastUpdated: Date.now(), + error: 'Failed', + }; + expect(getProviderMinQuota('agy', quota)).toBeNull(); + }); + + it('returns null when quota is wrong type', () => { + const quota: CodexQuotaResult = { + success: true, + windows: [], + planType: 'free', + lastUpdated: Date.now(), + }; + expect(getProviderMinQuota('agy', quota)).toBeNull(); + }); + }); + + describe('codex provider', () => { + it('returns minimum quota from windows', () => { + const windows: CodexQuotaWindow[] = [ + { + label: 'Primary', + usedPercent: 20, + remainingPercent: 80, + resetAfterSeconds: 3600, + resetAt: '2026-01-30T12:00:00Z', + }, + { + label: 'Secondary', + usedPercent: 65, + remainingPercent: 35, + resetAfterSeconds: 7200, + resetAt: '2026-01-30T14:00:00Z', + }, + ]; + const quota: CodexQuotaResult = { + success: true, + windows, + planType: 'free', + lastUpdated: Date.now(), + }; + expect(getProviderMinQuota('codex', quota)).toBe(35); + }); + + it('returns null when quota is null', () => { + expect(getProviderMinQuota('codex', null)).toBeNull(); + }); + + it('returns null when success is false', () => { + const quota: CodexQuotaResult = { + success: false, + windows: [], + planType: null, + lastUpdated: Date.now(), + error: 'Failed', + }; + expect(getProviderMinQuota('codex', quota)).toBeNull(); + }); + + it('returns null when quota is wrong type', () => { + const quota: QuotaResult = { success: true, models: [], lastUpdated: Date.now() }; + expect(getProviderMinQuota('codex', quota)).toBeNull(); + }); + }); + + describe('gemini provider', () => { + it('returns minimum quota from buckets', () => { + const buckets: GeminiCliBucket[] = [ + { + id: 'gemini-flash-series::input', + label: 'Gemini Flash Series', + tokenType: 'input', + remainingFraction: 0.95, + remainingPercent: 95, + resetTime: '2026-01-30T00:00:00Z', + modelIds: ['gemini-2.5-flash'], + }, + { + id: 'gemini-pro-series::input', + label: 'Gemini Pro Series', + tokenType: 'input', + remainingFraction: 0.45, + remainingPercent: 45, + resetTime: '2026-01-30T00:00:00Z', + modelIds: ['gemini-2.5-pro'], + }, + ]; + const quota: GeminiCliQuotaResult = { + success: true, + buckets, + projectId: 'test', + lastUpdated: Date.now(), + }; + expect(getProviderMinQuota('gemini', quota)).toBe(45); + }); + + it('returns null when quota is null', () => { + expect(getProviderMinQuota('gemini', null)).toBeNull(); + }); + + it('returns null when success is false', () => { + const quota: GeminiCliQuotaResult = { + success: false, + buckets: [], + projectId: null, + lastUpdated: Date.now(), + error: 'Failed', + }; + expect(getProviderMinQuota('gemini', quota)).toBeNull(); + }); + + it('returns null when quota is wrong type', () => { + const quota: QuotaResult = { success: true, models: [], lastUpdated: Date.now() }; + expect(getProviderMinQuota('gemini', quota)).toBeNull(); + }); + }); + + describe('unknown provider', () => { + it('returns null for unknown provider', () => { + const quota: QuotaResult = { success: true, models: [], lastUpdated: Date.now() }; + expect(getProviderMinQuota('unknown', quota)).toBeNull(); + }); + + it('returns null for empty provider name', () => { + const quota: QuotaResult = { success: true, models: [], lastUpdated: Date.now() }; + expect(getProviderMinQuota('', quota)).toBeNull(); + }); + }); +}); + +describe('getProviderResetTime', () => { + describe('agy provider', () => { + it('returns earliest reset time from Claude models', () => { + const models: ModelQuota[] = [ + { + name: 'claude-opus-4', + displayName: 'Claude Opus 4', + percentage: 85, + resetTime: '2026-01-30T14:00:00Z', + }, + { + name: 'claude-sonnet-4', + displayName: 'Claude Sonnet 4', + percentage: 92, + resetTime: '2026-01-30T10:00:00Z', + }, + { + name: 'gemini-2.5-flash', + displayName: 'Gemini 2.5 Flash', + percentage: 100, + resetTime: '2026-01-30T12:00:00Z', + }, + ]; + const quota: QuotaResult = { success: true, models, lastUpdated: Date.now() }; + expect(getProviderResetTime('agy', quota)).toBe('2026-01-30T10:00:00Z'); + }); + + it('returns null when quota is null', () => { + expect(getProviderResetTime('agy', null)).toBeNull(); + }); + + it('returns null when success is false', () => { + const quota: QuotaResult = { + success: false, + models: [], + lastUpdated: Date.now(), + error: 'Failed', + }; + expect(getProviderResetTime('agy', quota)).toBeNull(); + }); + + it('returns null when quota is wrong type', () => { + const quota: CodexQuotaResult = { + success: true, + windows: [], + planType: 'free', + lastUpdated: Date.now(), + }; + expect(getProviderResetTime('agy', quota)).toBeNull(); + }); + }); + + describe('codex provider', () => { + it('returns earliest reset time from windows', () => { + const windows: CodexQuotaWindow[] = [ + { + label: 'Primary', + usedPercent: 20, + remainingPercent: 80, + resetAfterSeconds: 3600, + resetAt: '2026-01-30T14:00:00Z', + }, + { + label: 'Secondary', + usedPercent: 65, + remainingPercent: 35, + resetAfterSeconds: 7200, + resetAt: '2026-01-30T10:00:00Z', + }, + ]; + const quota: CodexQuotaResult = { + success: true, + windows, + planType: 'free', + lastUpdated: Date.now(), + }; + expect(getProviderResetTime('codex', quota)).toBe('2026-01-30T10:00:00Z'); + }); + + it('returns null when quota is null', () => { + expect(getProviderResetTime('codex', null)).toBeNull(); + }); + + it('returns null when success is false', () => { + const quota: CodexQuotaResult = { + success: false, + windows: [], + planType: null, + lastUpdated: Date.now(), + error: 'Failed', + }; + expect(getProviderResetTime('codex', quota)).toBeNull(); + }); + + it('returns null when quota is wrong type', () => { + const quota: QuotaResult = { success: true, models: [], lastUpdated: Date.now() }; + expect(getProviderResetTime('codex', quota)).toBeNull(); + }); + }); + + describe('gemini provider', () => { + it('returns earliest reset time from buckets', () => { + const buckets: GeminiCliBucket[] = [ + { + id: 'gemini-flash-series::input', + label: 'Gemini Flash Series', + tokenType: 'input', + remainingFraction: 0.95, + remainingPercent: 95, + resetTime: '2026-01-30T12:00:00Z', + modelIds: ['gemini-2.5-flash'], + }, + { + id: 'gemini-pro-series::input', + label: 'Gemini Pro Series', + tokenType: 'input', + remainingFraction: 0.45, + remainingPercent: 45, + resetTime: '2026-01-30T06:00:00Z', + modelIds: ['gemini-2.5-pro'], + }, + ]; + const quota: GeminiCliQuotaResult = { + success: true, + buckets, + projectId: 'test', + lastUpdated: Date.now(), + }; + expect(getProviderResetTime('gemini', quota)).toBe('2026-01-30T06:00:00Z'); + }); + + it('returns null when quota is null', () => { + expect(getProviderResetTime('gemini', null)).toBeNull(); + }); + + it('returns null when success is false', () => { + const quota: GeminiCliQuotaResult = { + success: false, + buckets: [], + projectId: null, + lastUpdated: Date.now(), + error: 'Failed', + }; + expect(getProviderResetTime('gemini', quota)).toBeNull(); + }); + + it('returns null when quota is wrong type', () => { + const quota: QuotaResult = { success: true, models: [], lastUpdated: Date.now() }; + expect(getProviderResetTime('gemini', quota)).toBeNull(); + }); + }); + + describe('unknown provider', () => { + it('returns null for unknown provider', () => { + const quota: QuotaResult = { success: true, models: [], lastUpdated: Date.now() }; + expect(getProviderResetTime('unknown', quota)).toBeNull(); + }); + + it('returns null for empty provider name', () => { + const quota: QuotaResult = { success: true, models: [], lastUpdated: Date.now() }; + expect(getProviderResetTime('', quota)).toBeNull(); + }); + }); +}); From 7947a7ac89b977a34adc9ab72b89b38a61fb5200 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 29 Jan 2026 23:01:45 -0500 Subject: [PATCH 08/11] feat(cliproxy): add backend caching and reauth indicator for quota endpoints - Add in-memory quota cache with 2-minute TTL to reduce external API calls - Add needsReauth flag to CodexQuotaResult and GeminiCliQuotaResult types - Update quota routes to use caching (codex, gemini, and generic routes) - Add "Reauth needed" indicator in account-item.tsx with CLI command hint - Add "Reauth needed" indicator in account-card.tsx for flow visualization - Cache successful results only (don't cache expired tokens needing reauth) --- src/cliproxy/quota-fetcher-codex.ts | 2 + src/cliproxy/quota-fetcher-gemini-cli.ts | 2 + src/cliproxy/quota-response-cache.ts | 111 ++++++++++++++++++ src/cliproxy/quota-types.ts | 4 + .../routes/cliproxy-stats-routes.ts | 49 ++++++++ .../account/flow-viz/account-card.tsx | 16 ++- .../cliproxy/provider-editor/account-item.tsx | 25 ++++ ui/src/lib/api-client.ts | 8 ++ 8 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 src/cliproxy/quota-response-cache.ts diff --git a/src/cliproxy/quota-fetcher-codex.ts b/src/cliproxy/quota-fetcher-codex.ts index d8fd53b6..9343d436 100644 --- a/src/cliproxy/quota-fetcher-codex.ts +++ b/src/cliproxy/quota-fetcher-codex.ts @@ -204,6 +204,7 @@ export async function fetchCodexQuota( lastUpdated: Date.now(), error, accountId, + needsReauth: true, }; } @@ -247,6 +248,7 @@ export async function fetchCodexQuota( lastUpdated: Date.now(), error: 'Token expired or invalid', accountId, + needsReauth: true, }; } diff --git a/src/cliproxy/quota-fetcher-gemini-cli.ts b/src/cliproxy/quota-fetcher-gemini-cli.ts index f64c7d57..e3376d58 100644 --- a/src/cliproxy/quota-fetcher-gemini-cli.ts +++ b/src/cliproxy/quota-fetcher-gemini-cli.ts @@ -358,6 +358,7 @@ export async function fetchGeminiCliQuota( lastUpdated: Date.now(), error, accountId, + needsReauth: true, }; } @@ -401,6 +402,7 @@ export async function fetchGeminiCliQuota( lastUpdated: Date.now(), error: 'Token expired or invalid', accountId, + needsReauth: true, }; } diff --git a/src/cliproxy/quota-response-cache.ts b/src/cliproxy/quota-response-cache.ts new file mode 100644 index 00000000..8aae9466 --- /dev/null +++ b/src/cliproxy/quota-response-cache.ts @@ -0,0 +1,111 @@ +/** + * In-Memory Quota Cache + * + * Reduces external API calls by caching quota results with TTL. + * Uses a simple Map-based cache with automatic expiration. + */ + +/** Default TTL for quota cache entries (2 minutes) */ +const DEFAULT_CACHE_TTL_MS = 2 * 60 * 1000; + +/** Cache entry with timestamp */ +interface CacheEntry { + data: T; + cachedAt: number; +} + +/** In-memory cache store */ +const quotaCache = new Map>(); + +/** + * Generate cache key for provider/account combination + */ +function getCacheKey(provider: string, accountId: string): string { + return `${provider}:${accountId}`; +} + +/** + * Get cached quota result if still valid + * @param provider - Provider name (codex, gemini, agy) + * @param accountId - Account identifier + * @param ttlMs - Time-to-live in milliseconds (default: 2 minutes) + * @returns Cached result or null if expired/missing + */ +export function getCachedQuota( + provider: string, + accountId: string, + ttlMs: number = DEFAULT_CACHE_TTL_MS +): T | null { + const key = getCacheKey(provider, accountId); + const entry = quotaCache.get(key) as CacheEntry | undefined; + + if (!entry) { + return null; + } + + // Check if cache is still valid + if (Date.now() - entry.cachedAt < ttlMs) { + return entry.data; + } + + // Cache expired - remove entry + quotaCache.delete(key); + return null; +} + +/** + * Store quota result in cache + * @param provider - Provider name (codex, gemini, agy) + * @param accountId - Account identifier + * @param data - Quota result to cache + */ +export function setCachedQuota(provider: string, accountId: string, data: T): void { + const key = getCacheKey(provider, accountId); + quotaCache.set(key, { + data, + cachedAt: Date.now(), + }); +} + +/** + * Invalidate cache for a specific account + * @param provider - Provider name + * @param accountId - Account identifier + */ +export function invalidateQuotaCache(provider: string, accountId: string): void { + const key = getCacheKey(provider, accountId); + quotaCache.delete(key); +} + +/** + * Invalidate all cache entries for a provider + * @param provider - Provider name to clear + */ +export function invalidateProviderCache(provider: string): void { + const prefix = `${provider}:`; + for (const key of quotaCache.keys()) { + if (key.startsWith(prefix)) { + quotaCache.delete(key); + } + } +} + +/** + * Clear entire quota cache + */ +export function clearQuotaCache(): void { + quotaCache.clear(); +} + +/** + * Get cache statistics for debugging + */ +export function getQuotaCacheStats(): { size: number; entries: string[] } { + return { + size: quotaCache.size, + entries: Array.from(quotaCache.keys()), + }; +} + +/** Export cache TTL for consumers */ +export const QUOTA_CACHE_TTL_MS = DEFAULT_CACHE_TTL_MS; diff --git a/src/cliproxy/quota-types.ts b/src/cliproxy/quota-types.ts index d888bf6c..ae0ac8d3 100644 --- a/src/cliproxy/quota-types.ts +++ b/src/cliproxy/quota-types.ts @@ -43,6 +43,8 @@ export interface CodexQuotaResult { error?: string; /** Account ID (email) this quota belongs to */ accountId?: string; + /** True if token is expired and needs re-authentication */ + needsReauth?: boolean; } /** @@ -81,4 +83,6 @@ export interface GeminiCliQuotaResult { error?: string; /** Account ID (email) this quota belongs to */ accountId?: string; + /** True if token is expired and needs re-authentication */ + needsReauth?: boolean; } diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index a657c587..bae7f14c 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -15,6 +15,9 @@ import { import { fetchAccountQuota } from '../../cliproxy/quota-fetcher'; import { fetchCodexQuota } from '../../cliproxy/quota-fetcher-codex'; import { fetchGeminiCliQuota } from '../../cliproxy/quota-fetcher-gemini-cli'; +import { getCachedQuota, setCachedQuota } from '../../cliproxy/quota-response-cache'; +import type { CodexQuotaResult, GeminiCliQuotaResult } from '../../cliproxy/quota-types'; +import type { QuotaResult } from '../../cliproxy/quota-fetcher'; import type { CLIProxyProvider } from '../../cliproxy/types'; import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; import { @@ -513,10 +516,12 @@ router.put('/models/:provider', async (req: Request, res: Response): Promise => { const { accountId } = req.params; @@ -533,7 +538,21 @@ router.get('/quota/codex/:accountId', async (req: Request, res: Response): Promi } try { + // Check cache first + const cached = getCachedQuota('codex', accountId); + if (cached) { + res.json({ ...cached, cached: true }); + return; + } + + // Fetch from external API const result = await fetchCodexQuota(accountId); + + // Cache successful results (don't cache errors that need reauth) + if (result.success || !result.needsReauth) { + setCachedQuota('codex', accountId, result); + } + res.json(result); } catch (error) { res.status(500).json({ error: (error as Error).message }); @@ -543,6 +562,7 @@ router.get('/quota/codex/:accountId', async (req: Request, res: Response): Promi /** * GET /api/cliproxy/quota/gemini/:accountId - Get Gemini quota for a specific account * Returns: GeminiCliQuotaResult with quota buckets + * Caching: 2 minute TTL to reduce Google Cloud API calls */ router.get('/quota/gemini/:accountId', async (req: Request, res: Response): Promise => { const { accountId } = req.params; @@ -559,7 +579,21 @@ router.get('/quota/gemini/:accountId', async (req: Request, res: Response): Prom } try { + // Check cache first + const cached = getCachedQuota('gemini', accountId); + if (cached) { + res.json({ ...cached, cached: true }); + return; + } + + // Fetch from external API const result = await fetchGeminiCliQuota(accountId); + + // Cache successful results (don't cache errors that need reauth) + if (result.success || !result.needsReauth) { + setCachedQuota('gemini', accountId, result); + } + res.json(result); } catch (error) { res.status(500).json({ error: (error as Error).message }); @@ -570,6 +604,7 @@ router.get('/quota/gemini/:accountId', async (req: Request, res: Response): Prom * GET /api/cliproxy/quota/:provider/:accountId - Get quota for a specific account (generic) * Returns: QuotaResult with model quotas and reset times * NOTE: This generic route MUST come after specific routes (codex, gemini) to avoid matching them + * Caching: 2 minute TTL to reduce external API calls */ router.get('/quota/:provider/:accountId', async (req: Request, res: Response): Promise => { const { provider, accountId } = req.params; @@ -596,7 +631,21 @@ router.get('/quota/:provider/:accountId', async (req: Request, res: Response): P } try { + // Check cache first + const cached = getCachedQuota(provider, accountId); + if (cached) { + res.json({ ...cached, cached: true }); + return; + } + + // Fetch from external API const result = await fetchAccountQuota(provider as CLIProxyProvider, accountId); + + // Cache successful results + if (result.success) { + setCachedQuota(provider, accountId, result); + } + res.json(result); } catch (error) { res.status(500).json({ error: (error as Error).message }); diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 7f678b3a..bf57099a 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -4,7 +4,7 @@ import { cn, getProviderMinQuota, getProviderResetTime } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; -import { GripVertical, Loader2, Pause, Play } from 'lucide-react'; +import { GripVertical, Loader2, Pause, Play, KeyRound } from 'lucide-react'; import { useAccountQuota, QUOTA_SUPPORTED_PROVIDERS } from '@/hooks/use-cliproxy-stats'; import type { QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; @@ -249,6 +249,20 @@ export function AccountCard({ + ) : quota?.needsReauth ? ( + + + +
+ + Reauth needed +
+
+ +

Token expired. Re-authenticate via CLI.

+
+
+
) : quota?.error ? (
{quota.error.length > 20 ? `${quota.error.slice(0, 18)}...` : quota.error} diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index a7e58558..59868e38 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -28,6 +28,7 @@ import { AlertTriangle, FolderCode, Check, + KeyRound, } from 'lucide-react'; import { cn, getProviderMinQuota, getProviderResetTime } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; @@ -351,6 +352,30 @@ export function AccountItem({
+ ) : quota?.needsReauth ? ( + + + +
+ + + Reauth + +
+
+ +

+ Token expired. Re-authenticate via CLI:{' '} + + ccs cliproxy auth {account.provider} + +

+
+
+
) : quota?.error || (quota && !quota.success) ? ( diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 753e0de5..682b739d 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -174,6 +174,10 @@ export interface CodexQuotaResult { error?: string; /** Account ID (email) this quota belongs to */ accountId?: string; + /** True if token is expired and needs re-authentication */ + needsReauth?: boolean; + /** True if result was served from cache */ + cached?: boolean; } /** Gemini CLI bucket (grouped by model series) */ @@ -208,6 +212,10 @@ export interface GeminiCliQuotaResult { error?: string; /** Account ID (email) this quota belongs to */ accountId?: string; + /** True if token is expired and needs re-authentication */ + needsReauth?: boolean; + /** True if result was served from cache */ + cached?: boolean; } /** Provider accounts summary */ From 606bb7272318bea9f294d9b563369d595b336cf4 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 29 Jan 2026 23:09:47 -0500 Subject: [PATCH 09/11] feat(cliproxy): add proactive token refresh for Gemini quota (match AGY pattern) - Add proactive refresh: refresh token 5min before expiry (not just after) - Add retry on 401: if API returns 401, refresh token and retry once - Extract fetchWithAuthData helper to reduce code duplication - Update UI to show better error messages (no more "via CLI" text) - Falls back gracefully if proactive refresh fails but token not yet expired --- src/cliproxy/quota-fetcher-gemini-cli.ts | 128 +++++++++++++----- .../account/flow-viz/account-card.tsx | 8 +- .../cliproxy/provider-editor/account-item.tsx | 11 +- 3 files changed, 103 insertions(+), 44 deletions(-) diff --git a/src/cliproxy/quota-fetcher-gemini-cli.ts b/src/cliproxy/quota-fetcher-gemini-cli.ts index e3376d58..b29f218c 100644 --- a/src/cliproxy/quota-fetcher-gemini-cli.ts +++ b/src/cliproxy/quota-fetcher-gemini-cli.ts @@ -10,6 +10,7 @@ import * as path from 'node:path'; import { getAuthDir } from './config-generator'; import { getProviderAccounts, getPausedDir } from './account-manager'; import { sanitizeEmail, isTokenExpired } from './auth-utils'; +import { refreshGeminiToken } from './auth/gemini-token-refresh'; import type { GeminiCliQuotaResult, GeminiCliBucket } from './quota-types'; /** Google Cloud Code API endpoints */ @@ -322,46 +323,14 @@ function buildGeminiCliBuckets(rawBuckets: RawGeminiCliBucket[]): GeminiCliBucke } /** - * Fetch quota for a single Gemini CLI account - * - * @param accountId - Account identifier (email) - * @param verbose - Show detailed diagnostics - * @returns Quota result with buckets and percentages + * Internal helper: Fetch quota with validated auth data + * Extracted to support auto-refresh retry logic */ -export async function fetchGeminiCliQuota( +async function fetchWithAuthData( + authData: GeminiCliAuthData, accountId: string, - verbose = false + verbose: boolean ): Promise { - if (verbose) console.error(`[i] Fetching Gemini CLI quota for ${accountId}...`); - - const authData = readGeminiCliAuthData(accountId); - if (!authData) { - const error = 'Auth file not found for Gemini account'; - if (verbose) console.error(`[!] Error: ${error}`); - return { - success: false, - buckets: [], - projectId: null, - lastUpdated: Date.now(), - error, - accountId, - }; - } - - if (authData.isExpired) { - const error = 'Token expired - re-authenticate with ccs cliproxy auth gemini'; - if (verbose) console.error(`[!] Error: ${error}`); - return { - success: false, - buckets: [], - projectId: null, - lastUpdated: Date.now(), - error, - accountId, - needsReauth: true, - }; - } - if (!authData.projectId) { const error = 'Cannot resolve project ID from auth file'; if (verbose) console.error(`[!] Error: ${error}`); @@ -474,6 +443,91 @@ export async function fetchGeminiCliQuota( } } +/** + * Fetch quota for a single Gemini CLI account + * + * @param accountId - Account identifier (email) + * @param verbose - Show detailed diagnostics + * @returns Quota result with buckets and percentages + */ +export async function fetchGeminiCliQuota( + accountId: string, + verbose = false +): Promise { + if (verbose) console.error(`[i] Fetching Gemini CLI quota for ${accountId}...`); + + let authData = readGeminiCliAuthData(accountId); + if (!authData) { + const error = 'Auth file not found for Gemini account'; + if (verbose) console.error(`[!] Error: ${error}`); + return { + success: false, + buckets: [], + projectId: null, + lastUpdated: Date.now(), + error, + accountId, + }; + } + + // Proactive refresh: refresh if expired OR expiring within 5 minutes + const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000; + const shouldRefresh = + authData.isExpired || + !authData.expiresAt || + new Date(authData.expiresAt).getTime() - Date.now() < REFRESH_LEAD_TIME_MS; + + if (shouldRefresh) { + if (verbose) + console.error( + authData.isExpired + ? '[i] Token expired, refreshing...' + : '[i] Token expiring soon, proactive refresh...' + ); + const refreshResult = await refreshGeminiToken(); + + if (refreshResult.success) { + if (verbose) console.error('[i] Token refreshed successfully'); + // Re-read auth data after successful refresh + const refreshedAuthData = readGeminiCliAuthData(accountId); + if (refreshedAuthData) { + authData = refreshedAuthData; + } + } else if (authData.isExpired) { + // Only fail if token is actually expired (not just expiring soon) + const error = refreshResult.error || 'Token refresh failed'; + if (verbose) console.error(`[!] Refresh failed: ${error}`); + return { + success: false, + buckets: [], + projectId: null, + lastUpdated: Date.now(), + error, + accountId, + needsReauth: true, + }; + } + // If proactive refresh fails but token isn't expired yet, continue with existing token + } + + // First attempt with current token + const result = await fetchWithAuthData(authData, accountId, verbose); + + // If 401 error and we haven't refreshed yet, try refresh and retry + if (result.needsReauth && result.error?.includes('expired')) { + if (verbose) console.error('[i] Got 401, attempting refresh and retry...'); + const refreshResult = await refreshGeminiToken(); + if (refreshResult.success) { + const refreshedAuthData = readGeminiCliAuthData(accountId); + if (refreshedAuthData) { + return await fetchWithAuthData(refreshedAuthData, accountId, verbose); + } + } + } + + return result; +} + /** * Fetch quota for all Gemini CLI accounts * diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index bf57099a..ba990e63 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -258,8 +258,12 @@ export function AccountCard({ Reauth needed
- -

Token expired. Re-authenticate via CLI.

+ +

+ {quota.error?.includes('No refresh token') + ? 'Remove and re-add account' + : quota.error || 'Auto-refresh failed'} +

diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 59868e38..00d5ee67 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -366,12 +366,13 @@ export function AccountItem({
- +

- Token expired. Re-authenticate via CLI:{' '} - - ccs cliproxy auth {account.provider} - + {quota.error?.includes('No refresh token') + ? 'No refresh token available. Remove and re-add account to fix.' + : quota.error?.includes('refresh') || quota.error?.includes('Invalid') + ? `Auto-refresh failed: ${quota.error}` + : `Token issue: ${quota.error || 'Re-authenticate required'}`}

From 3df2619023abee095901636310818a676e02d639 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Thu, 29 Jan 2026 23:16:39 -0500 Subject: [PATCH 10/11] test(quota): add extensive test suite for quota caching system - Add quota-response-cache.test.ts (22 tests): - Cache set/get operations - TTL expiration handling - Provider and account isolation - Cache invalidation patterns - High-volume concurrent access - Add quota-caching-integration.test.ts (15 tests): - GeminiCliQuotaResult caching with bucket preservation - CodexQuotaResult caching with window preservation - Cross-provider isolation verification - Error state caching for visibility - needsReauth flag handling Total: 37 new tests for quota caching behavior --- .../quota-caching-integration.test.ts | 348 ++++++++++++++++++ .../cliproxy/quota-response-cache.test.ts | 296 +++++++++++++++ ui/src/lib/api-client.ts | 2 + 3 files changed, 646 insertions(+) create mode 100644 tests/unit/cliproxy/quota-caching-integration.test.ts create mode 100644 tests/unit/cliproxy/quota-response-cache.test.ts diff --git a/tests/unit/cliproxy/quota-caching-integration.test.ts b/tests/unit/cliproxy/quota-caching-integration.test.ts new file mode 100644 index 00000000..f934a342 --- /dev/null +++ b/tests/unit/cliproxy/quota-caching-integration.test.ts @@ -0,0 +1,348 @@ +/** + * Quota Caching Integration Tests + * + * Tests for quota response caching behavior across providers: + * - Cache hit/miss scenarios + * - Cache invalidation patterns + * - TTL expiration behavior + * - Provider isolation + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { + getCachedQuota, + setCachedQuota, + invalidateQuotaCache, + clearQuotaCache, + getQuotaCacheStats, + QUOTA_CACHE_TTL_MS, +} from '../../../src/cliproxy/quota-response-cache'; +import type { GeminiCliQuotaResult, CodexQuotaResult } from '../../../src/cliproxy/quota-types'; + +describe('Quota Caching Integration', () => { + beforeEach(() => { + clearQuotaCache(); + }); + + afterEach(() => { + clearQuotaCache(); + }); + + describe('GeminiCliQuotaResult caching', () => { + const createGeminiQuota = ( + remainingPercent: number, + options: Partial = {} + ): GeminiCliQuotaResult => ({ + success: true, + buckets: [ + { + id: 'gemini-flash-series::combined', + label: 'Gemini Flash Series', + tokenType: null, + remainingFraction: remainingPercent / 100, + remainingPercent, + resetTime: null, + modelIds: ['gemini-3-flash-preview'], + }, + ], + projectId: 'test-project-123', + lastUpdated: Date.now(), + accountId: 'test@example.com', + ...options, + }); + + it('should cache successful Gemini quota result', () => { + const quota = createGeminiQuota(75); + setCachedQuota('gemini', 'user@example.com', quota); + + const cached = getCachedQuota('gemini', 'user@example.com'); + expect(cached).not.toBeNull(); + expect(cached?.success).toBe(true); + expect(cached?.buckets[0].remainingPercent).toBe(75); + }); + + it('should NOT cache quota with needsReauth flag', () => { + const quota = createGeminiQuota(0, { + success: false, + needsReauth: true, + error: 'Token expired', + }); + + // In real usage, we would not cache reauth results + // This test verifies the data structure + setCachedQuota('gemini', 'user@example.com', quota); + const cached = getCachedQuota('gemini', 'user@example.com'); + expect(cached?.needsReauth).toBe(true); + }); + + it('should preserve all Gemini bucket fields through cache', () => { + const quota = createGeminiQuota(50, { + buckets: [ + { + id: 'gemini-pro-series::input', + label: 'Gemini Pro Series', + tokenType: 'input', + remainingFraction: 0.5, + remainingPercent: 50, + resetTime: '2026-01-30T12:00:00Z', + modelIds: ['gemini-3-pro-preview', 'gemini-2.5-pro'], + }, + ], + }); + + setCachedQuota('gemini', 'user@example.com', quota); + const cached = getCachedQuota('gemini', 'user@example.com'); + + expect(cached?.buckets[0].tokenType).toBe('input'); + expect(cached?.buckets[0].resetTime).toBe('2026-01-30T12:00:00Z'); + expect(cached?.buckets[0].modelIds).toContain('gemini-3-pro-preview'); + }); + }); + + describe('CodexQuotaResult caching', () => { + const createCodexQuota = ( + primaryUsed: number, + secondaryUsed?: number, + options: Partial = {} + ): CodexQuotaResult => ({ + success: true, + windows: [ + { + label: 'Primary', + usedPercent: primaryUsed, + remainingPercent: 100 - primaryUsed, + resetAfterSeconds: 3600, + resetAt: new Date(Date.now() + 3600000).toISOString(), + }, + ...(secondaryUsed !== undefined + ? [ + { + label: 'Secondary', + usedPercent: secondaryUsed, + remainingPercent: 100 - secondaryUsed, + resetAfterSeconds: 86400, + resetAt: new Date(Date.now() + 86400000).toISOString(), + }, + ] + : []), + ], + planType: 'plus', + lastUpdated: Date.now(), + accountId: 'test@example.com', + ...options, + }); + + it('should cache successful Codex quota result', () => { + const quota = createCodexQuota(30, 10); + setCachedQuota('codex', 'user@example.com', quota); + + const cached = getCachedQuota('codex', 'user@example.com'); + expect(cached).not.toBeNull(); + expect(cached?.success).toBe(true); + expect(cached?.windows).toHaveLength(2); + expect(cached?.windows[0].usedPercent).toBe(30); + }); + + it('should preserve planType through cache', () => { + const quota = createCodexQuota(20, undefined, { planType: 'team' }); + setCachedQuota('codex', 'user@example.com', quota); + + const cached = getCachedQuota('codex', 'user@example.com'); + expect(cached?.planType).toBe('team'); + }); + + it('should handle Codex quota with code review limits', () => { + const quota: CodexQuotaResult = { + success: true, + windows: [ + { + label: 'Primary', + usedPercent: 25, + remainingPercent: 75, + resetAfterSeconds: 3600, + resetAt: null, + }, + { + label: 'Code Review (Primary)', + usedPercent: 80, + remainingPercent: 20, + resetAfterSeconds: 1800, + resetAt: null, + }, + ], + planType: 'plus', + lastUpdated: Date.now(), + accountId: 'user@example.com', + }; + + setCachedQuota('codex', 'user@example.com', quota); + const cached = getCachedQuota('codex', 'user@example.com'); + + expect(cached?.windows).toHaveLength(2); + const codeReview = cached?.windows.find((w) => w.label.includes('Code Review')); + expect(codeReview?.usedPercent).toBe(80); + }); + }); + + describe('cross-provider isolation', () => { + it('should isolate Gemini and Codex cache for same email', () => { + const geminiQuota: GeminiCliQuotaResult = { + success: true, + buckets: [ + { + id: 'gemini-flash::combined', + label: 'Flash', + tokenType: null, + remainingFraction: 0.9, + remainingPercent: 90, + resetTime: null, + modelIds: [], + }, + ], + projectId: 'proj', + lastUpdated: Date.now(), + accountId: 'shared@example.com', + }; + + const codexQuota: CodexQuotaResult = { + success: true, + windows: [ + { + label: 'Primary', + usedPercent: 10, + remainingPercent: 90, + resetAfterSeconds: 3600, + resetAt: null, + }, + ], + planType: 'plus', + lastUpdated: Date.now(), + accountId: 'shared@example.com', + }; + + setCachedQuota('gemini', 'shared@example.com', geminiQuota); + setCachedQuota('codex', 'shared@example.com', codexQuota); + + const cachedGemini = getCachedQuota('gemini', 'shared@example.com'); + const cachedCodex = getCachedQuota('codex', 'shared@example.com'); + + expect(cachedGemini?.buckets).toBeDefined(); + expect(cachedCodex?.windows).toBeDefined(); + expect((cachedGemini as unknown as CodexQuotaResult).windows).toBeUndefined(); + expect((cachedCodex as unknown as GeminiCliQuotaResult).buckets).toBeUndefined(); + }); + + it('should allow invalidating one provider without affecting others', () => { + setCachedQuota('gemini', 'user@example.com', { success: true, buckets: [] } as never); + setCachedQuota('codex', 'user@example.com', { success: true, windows: [] } as never); + setCachedQuota('agy', 'user@example.com', { success: true, quotas: [] } as never); + + invalidateQuotaCache('gemini', 'user@example.com'); + + expect(getCachedQuota('gemini', 'user@example.com')).toBeNull(); + expect(getCachedQuota('codex', 'user@example.com')).not.toBeNull(); + expect(getCachedQuota('agy', 'user@example.com')).not.toBeNull(); + }); + }); + + describe('cache TTL behavior', () => { + it('should use 2-minute TTL by default', () => { + expect(QUOTA_CACHE_TTL_MS).toBe(120000); + }); + + it('should allow custom TTL on retrieval', () => { + setCachedQuota('gemini', 'user@example.com', { success: true } as never); + + // With very long TTL, should find it + expect(getCachedQuota('gemini', 'user@example.com', 10000000)).not.toBeNull(); + + // With 0 TTL, should be expired + expect(getCachedQuota('gemini', 'user@example.com', 0)).toBeNull(); + }); + + it('should clean up expired entries lazily on access', async () => { + setCachedQuota('gemini', 'user1@example.com', { id: 1 }); + setCachedQuota('gemini', 'user2@example.com', { id: 2 }); + + expect(getQuotaCacheStats().size).toBe(2); + + // Access with very short TTL to trigger expiration cleanup + await new Promise((r) => setTimeout(r, 10)); + getCachedQuota('gemini', 'user1@example.com', 5); + + // Only user1 entry should be deleted (the one we accessed) + expect(getQuotaCacheStats().size).toBe(1); + }); + }); + + describe('error state caching', () => { + it('should cache failed quota results for visibility', () => { + const failedQuota: GeminiCliQuotaResult = { + success: false, + buckets: [], + projectId: null, + lastUpdated: Date.now(), + error: 'Rate limited', + accountId: 'user@example.com', + }; + + setCachedQuota('gemini', 'user@example.com', failedQuota); + const cached = getCachedQuota('gemini', 'user@example.com'); + + expect(cached?.success).toBe(false); + expect(cached?.error).toBe('Rate limited'); + }); + + it('should preserve error message through cache round-trip', () => { + const errorQuota: CodexQuotaResult = { + success: false, + windows: [], + planType: null, + lastUpdated: Date.now(), + error: 'API error: 503', + accountId: 'user@example.com', + }; + + setCachedQuota('codex', 'user@example.com', errorQuota); + const cached = getCachedQuota('codex', 'user@example.com'); + + expect(cached?.error).toBe('API error: 503'); + }); + }); + + describe('high-volume scenarios', () => { + it('should handle 50+ accounts efficiently', () => { + const numAccounts = 50; + const providers = ['gemini', 'codex', 'agy']; + + // Populate cache + for (let i = 0; i < numAccounts; i++) { + for (const provider of providers) { + setCachedQuota(provider, `user${i}@example.com`, { + success: true, + id: `${provider}-${i}`, + }); + } + } + + const stats = getQuotaCacheStats(); + expect(stats.size).toBe(numAccounts * providers.length); + + // Verify random access + const cached = getCachedQuota<{ id: string }>('codex', 'user25@example.com'); + expect(cached?.id).toBe('codex-25'); + }); + + it('should handle rapid cache updates', () => { + const iterations = 100; + + for (let i = 0; i < iterations; i++) { + setCachedQuota('gemini', 'user@example.com', { iteration: i }); + } + + const cached = getCachedQuota<{ iteration: number }>('gemini', 'user@example.com'); + expect(cached?.iteration).toBe(iterations - 1); + expect(getQuotaCacheStats().size).toBe(1); // Only one entry, updated 100 times + }); + }); +}); diff --git a/tests/unit/cliproxy/quota-response-cache.test.ts b/tests/unit/cliproxy/quota-response-cache.test.ts new file mode 100644 index 00000000..b05741ba --- /dev/null +++ b/tests/unit/cliproxy/quota-response-cache.test.ts @@ -0,0 +1,296 @@ +/** + * Quota Response Cache Unit Tests + * + * Tests for in-memory quota caching with TTL expiration + */ + +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { + getCachedQuota, + setCachedQuota, + invalidateQuotaCache, + invalidateProviderCache, + clearQuotaCache, + getQuotaCacheStats, + QUOTA_CACHE_TTL_MS, +} from '../../../src/cliproxy/quota-response-cache'; + +interface TestQuota { + success: boolean; + buckets: { label: string; remainingPercent: number }[]; +} + +describe('Quota Response Cache', () => { + beforeEach(() => { + clearQuotaCache(); + }); + + afterEach(() => { + clearQuotaCache(); + }); + + describe('setCachedQuota and getCachedQuota', () => { + it('should store and retrieve quota data', () => { + const quota: TestQuota = { + success: true, + buckets: [{ label: 'Flash', remainingPercent: 80 }], + }; + + setCachedQuota('gemini', 'user@example.com', quota); + const cached = getCachedQuota('gemini', 'user@example.com'); + + expect(cached).not.toBeNull(); + expect(cached?.success).toBe(true); + expect(cached?.buckets[0].remainingPercent).toBe(80); + }); + + it('should return null for non-existent cache entry', () => { + const cached = getCachedQuota('gemini', 'nonexistent@example.com'); + expect(cached).toBeNull(); + }); + + it('should isolate cache entries by provider', () => { + const geminiQuota: TestQuota = { success: true, buckets: [] }; + const codexQuota = { success: true, windows: [] }; + + setCachedQuota('gemini', 'user@example.com', geminiQuota); + setCachedQuota('codex', 'user@example.com', codexQuota); + + const cached1 = getCachedQuota('gemini', 'user@example.com'); + const cached2 = getCachedQuota<{ windows: unknown[] }>('codex', 'user@example.com'); + + expect(cached1?.buckets).toBeDefined(); + expect(cached2?.windows).toBeDefined(); + }); + + it('should isolate cache entries by account', () => { + const quota1: TestQuota = { success: true, buckets: [{ label: 'A', remainingPercent: 50 }] }; + const quota2: TestQuota = { success: true, buckets: [{ label: 'B', remainingPercent: 90 }] }; + + setCachedQuota('gemini', 'user1@example.com', quota1); + setCachedQuota('gemini', 'user2@example.com', quota2); + + const cached1 = getCachedQuota('gemini', 'user1@example.com'); + const cached2 = getCachedQuota('gemini', 'user2@example.com'); + + expect(cached1?.buckets[0].label).toBe('A'); + expect(cached2?.buckets[0].label).toBe('B'); + }); + + it('should update existing cache entry', () => { + const quota1: TestQuota = { success: true, buckets: [{ label: 'X', remainingPercent: 30 }] }; + const quota2: TestQuota = { success: true, buckets: [{ label: 'X', remainingPercent: 70 }] }; + + setCachedQuota('gemini', 'user@example.com', quota1); + setCachedQuota('gemini', 'user@example.com', quota2); + + const cached = getCachedQuota('gemini', 'user@example.com'); + expect(cached?.buckets[0].remainingPercent).toBe(70); + }); + }); + + describe('cache TTL expiration', () => { + it('should return data within TTL', () => { + const quota: TestQuota = { success: true, buckets: [] }; + setCachedQuota('gemini', 'user@example.com', quota); + + // Immediately retrieve (well within TTL) + const cached = getCachedQuota('gemini', 'user@example.com'); + expect(cached).not.toBeNull(); + }); + + it('should return null for expired cache with custom TTL', () => { + const quota: TestQuota = { success: true, buckets: [] }; + setCachedQuota('gemini', 'user@example.com', quota); + + // Request with 0ms TTL (effectively expired immediately) + const cached = getCachedQuota('gemini', 'user@example.com', 0); + expect(cached).toBeNull(); + }); + + it('should return null for expired cache entry', async () => { + const quota: TestQuota = { success: true, buckets: [] }; + setCachedQuota('gemini', 'user@example.com', quota); + + // Wait briefly and use very short TTL + await new Promise((resolve) => setTimeout(resolve, 10)); + const cached = getCachedQuota('gemini', 'user@example.com', 5); + expect(cached).toBeNull(); + }); + + it('should delete expired entries on access', async () => { + const quota: TestQuota = { success: true, buckets: [] }; + setCachedQuota('gemini', 'user@example.com', quota); + + // First access should find it + const stats1 = getQuotaCacheStats(); + expect(stats1.size).toBe(1); + + // Access with short TTL should expire and delete + await new Promise((resolve) => setTimeout(resolve, 10)); + getCachedQuota('gemini', 'user@example.com', 5); + + // Entry should be deleted + const stats2 = getQuotaCacheStats(); + expect(stats2.size).toBe(0); + }); + }); + + describe('invalidateQuotaCache', () => { + it('should invalidate specific account cache', () => { + const quota: TestQuota = { success: true, buckets: [] }; + setCachedQuota('gemini', 'user@example.com', quota); + setCachedQuota('gemini', 'other@example.com', quota); + + invalidateQuotaCache('gemini', 'user@example.com'); + + expect(getCachedQuota('gemini', 'user@example.com')).toBeNull(); + expect(getCachedQuota('gemini', 'other@example.com')).not.toBeNull(); + }); + + it('should be safe to call on non-existent entry', () => { + // Should not throw + invalidateQuotaCache('gemini', 'nonexistent@example.com'); + expect(getCachedQuota('gemini', 'nonexistent@example.com')).toBeNull(); + }); + }); + + describe('invalidateProviderCache', () => { + it('should invalidate all accounts for a provider', () => { + const quota: TestQuota = { success: true, buckets: [] }; + setCachedQuota('gemini', 'user1@example.com', quota); + setCachedQuota('gemini', 'user2@example.com', quota); + setCachedQuota('codex', 'user1@example.com', quota); + + invalidateProviderCache('gemini'); + + expect(getCachedQuota('gemini', 'user1@example.com')).toBeNull(); + expect(getCachedQuota('gemini', 'user2@example.com')).toBeNull(); + expect(getCachedQuota('codex', 'user1@example.com')).not.toBeNull(); + }); + + it('should be safe to call for non-existent provider', () => { + // Should not throw + invalidateProviderCache('nonexistent'); + expect(getQuotaCacheStats().size).toBe(0); + }); + }); + + describe('clearQuotaCache', () => { + it('should clear all cache entries', () => { + const quota: TestQuota = { success: true, buckets: [] }; + setCachedQuota('gemini', 'user1@example.com', quota); + setCachedQuota('gemini', 'user2@example.com', quota); + setCachedQuota('codex', 'user@example.com', quota); + setCachedQuota('agy', 'user@example.com', quota); + + const statsBefore = getQuotaCacheStats(); + expect(statsBefore.size).toBe(4); + + clearQuotaCache(); + + const statsAfter = getQuotaCacheStats(); + expect(statsAfter.size).toBe(0); + }); + }); + + describe('getQuotaCacheStats', () => { + it('should return correct cache size', () => { + const quota: TestQuota = { success: true, buckets: [] }; + setCachedQuota('gemini', 'user1@example.com', quota); + setCachedQuota('codex', 'user2@example.com', quota); + + const stats = getQuotaCacheStats(); + expect(stats.size).toBe(2); + }); + + it('should return cache entry keys', () => { + const quota: TestQuota = { success: true, buckets: [] }; + setCachedQuota('gemini', 'user@example.com', quota); + setCachedQuota('codex', 'other@example.com', quota); + + const stats = getQuotaCacheStats(); + expect(stats.entries).toContain('gemini:user@example.com'); + expect(stats.entries).toContain('codex:other@example.com'); + }); + + it('should return empty stats for empty cache', () => { + const stats = getQuotaCacheStats(); + expect(stats.size).toBe(0); + expect(stats.entries).toHaveLength(0); + }); + }); + + describe('QUOTA_CACHE_TTL_MS constant', () => { + it('should be 2 minutes (120000ms)', () => { + expect(QUOTA_CACHE_TTL_MS).toBe(2 * 60 * 1000); + }); + }); + + describe('cache key generation', () => { + it('should handle special characters in account IDs', () => { + const quota: TestQuota = { success: true, buckets: [] }; + const accountWithPlus = 'user+tag@example.com'; + const accountWithDots = 'first.last@example.com'; + + setCachedQuota('gemini', accountWithPlus, quota); + setCachedQuota('gemini', accountWithDots, quota); + + expect(getCachedQuota('gemini', accountWithPlus)).not.toBeNull(); + expect(getCachedQuota('gemini', accountWithDots)).not.toBeNull(); + }); + + it('should handle empty strings gracefully', () => { + const quota: TestQuota = { success: true, buckets: [] }; + setCachedQuota('', '', quota); + + // Should still work, even if unusual + const stats = getQuotaCacheStats(); + expect(stats.entries).toContain(':'); + }); + }); + + describe('concurrent access patterns', () => { + it('should handle rapid set/get operations', () => { + const quota: TestQuota = { success: true, buckets: [] }; + + // Simulate rapid updates + for (let i = 0; i < 100; i++) { + setCachedQuota('gemini', 'user@example.com', { + ...quota, + buckets: [{ label: `iter-${i}`, remainingPercent: i }], + }); + } + + const cached = getCachedQuota('gemini', 'user@example.com'); + expect(cached?.buckets[0].label).toBe('iter-99'); + }); + + it('should handle multiple providers simultaneously', () => { + const providers = ['gemini', 'codex', 'agy']; + const accounts = ['user1@example.com', 'user2@example.com']; + const quota: TestQuota = { success: true, buckets: [] }; + + // Set cache for all combinations + for (const provider of providers) { + for (const account of accounts) { + setCachedQuota(provider, account, { + ...quota, + buckets: [{ label: `${provider}-${account}`, remainingPercent: 50 }], + }); + } + } + + const stats = getQuotaCacheStats(); + expect(stats.size).toBe(6); + + // Verify all entries exist + for (const provider of providers) { + for (const account of accounts) { + const cached = getCachedQuota(provider, account); + expect(cached?.buckets[0].label).toBe(`${provider}-${account}`); + } + } + }); + }); +}); diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 682b739d..51907b8a 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -144,6 +144,8 @@ export interface QuotaResult { isForbidden?: boolean; /** Error message if fetch failed */ error?: string; + /** True if token is expired and needs re-authentication */ + needsReauth?: boolean; } /** Codex rate limit window */ From bd89cabc8ad646d718847dc3905ad0ec2b71bff4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 30 Jan 2026 04:30:44 +0000 Subject: [PATCH 11/11] chore(release): 7.32.0-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d83d2c18..209c4161 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.32.0", + "version": "7.32.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli",