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)
This commit is contained in:
kaitranntt
2026-01-29 23:01:45 -05:00
parent 0b8635d3ba
commit 7947a7ac89
8 changed files with 216 additions and 1 deletions
+2
View File
@@ -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,
};
}
+2
View File
@@ -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,
};
}
+111
View File
@@ -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<T> {
data: T;
cachedAt: number;
}
/** In-memory cache store */
const quotaCache = new Map<string, CacheEntry<unknown>>();
/**
* 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<T>(
provider: string,
accountId: string,
ttlMs: number = DEFAULT_CACHE_TTL_MS
): T | null {
const key = getCacheKey(provider, accountId);
const entry = quotaCache.get(key) as CacheEntry<T> | 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<T>(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;
+4
View File
@@ -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;
}
@@ -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<voi
// ==================== Account Quota ====================
// NOTE: Specific routes MUST be defined BEFORE generic routes for Express routing to work correctly
// NOTE: All quota endpoints use in-memory caching (2 min TTL) to reduce external API calls
/**
* GET /api/cliproxy/quota/codex/:accountId - Get Codex quota for a specific account
* Returns: CodexQuotaResult with rate limit windows
* Caching: 2 minute TTL to reduce ChatGPT API calls
*/
router.get('/quota/codex/:accountId', async (req: Request, res: Response): Promise<void> => {
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<CodexQuotaResult>('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<void> => {
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<GeminiCliQuotaResult>('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<void> => {
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<QuotaResult>(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 });
@@ -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({
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : quota?.needsReauth ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 text-[8px] text-amber-600 dark:text-amber-400">
<KeyRound className="w-2.5 h-2.5" />
<span>Reauth needed</span>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[180px]">
<p className="text-xs">Token expired. Re-authenticate via CLI.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : quota?.error ? (
<div className="text-[8px] text-muted-foreground/60 truncate" title={quota.error}>
{quota.error.length > 20 ? `${quota.error.slice(0, 18)}...` : quota.error}
@@ -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({
</Tooltip>
</TooltipProvider>
</div>
) : quota?.needsReauth ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5">
<Badge
variant="outline"
className="text-[10px] h-5 px-2 gap-1 border-amber-500/50 text-amber-600 dark:text-amber-400"
>
<KeyRound className="w-3 h-3" />
Reauth
</Badge>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[200px]">
<p className="text-xs">
Token expired. Re-authenticate via CLI:{' '}
<code className="font-mono text-[10px]">
ccs cliproxy auth {account.provider}
</code>
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : quota?.error || (quota && !quota.success) ? (
<TooltipProvider>
<Tooltip>
+8
View File
@@ -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 */