mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 08:17:11 +00:00
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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 */
|
||||
|
||||
Reference in New Issue
Block a user