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 */