Merge pull request #401 from kaitranntt/dev

feat(release): promote dev to main - Codex/Gemini quota display
This commit is contained in:
Kai (Tam Nhu) Tran
2026-01-29 23:33:59 -05:00
committed by GitHub
18 changed files with 2663 additions and 197 deletions
+1 -1
View File
@@ -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",
+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,
};
}
+199 -67
View File
@@ -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 */
@@ -82,61 +83,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, unknown>): 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<string, unknown>;
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, unknown>): 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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
// 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;
}
}
}
@@ -246,45 +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<GeminiCliQuotaResult> {
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,
};
}
if (!authData.projectId) {
const error = 'Cannot resolve project ID from auth file';
if (verbose) console.error(`[!] Error: ${error}`);
@@ -325,6 +371,7 @@ export async function fetchGeminiCliQuota(
lastUpdated: Date.now(),
error: 'Token expired or invalid',
accountId,
needsReauth: true,
};
}
@@ -396,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<GeminiCliQuotaResult> {
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
*
+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;
}
+106 -1
View File
@@ -13,6 +13,11 @@ 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 { 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 {
@@ -510,10 +515,96 @@ 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/:provider/:accountId - Get quota for a specific account
* 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;
// Validate accountId - prevent path traversal
if (
!accountId ||
accountId.includes('..') ||
accountId.includes('/') ||
accountId.includes('\\')
) {
res.status(400).json({ error: 'Invalid account ID' });
return;
}
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 });
}
});
/**
* 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;
// Validate accountId - prevent path traversal
if (
!accountId ||
accountId.includes('..') ||
accountId.includes('/') ||
accountId.includes('\\')
) {
res.status(400).json({ error: 'Invalid account ID' });
return;
}
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 });
}
});
/**
* 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;
@@ -540,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 });
@@ -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> = {}
): 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<GeminiCliQuotaResult>('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<GeminiCliQuotaResult>('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<GeminiCliQuotaResult>('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> = {}
): 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<CodexQuotaResult>('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<CodexQuotaResult>('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<CodexQuotaResult>('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<GeminiCliQuotaResult>('gemini', 'shared@example.com');
const cachedCodex = getCachedQuota<CodexQuotaResult>('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<GeminiCliQuotaResult>('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<CodexQuotaResult>('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
});
});
});
@@ -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<TestQuota>('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<TestQuota>('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<TestQuota>('gemini', 'user1@example.com');
const cached2 = getCachedQuota<TestQuota>('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<TestQuota>('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<TestQuota>('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<TestQuota>('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<TestQuota>('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<TestQuota>('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<TestQuota>(provider, account);
expect(cached?.buckets[0].label).toBe(`${provider}-${account}`);
}
}
});
});
});
-1
View File
@@ -1,6 +1,5 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "ui",
@@ -2,20 +2,14 @@
* Account Card Component for Flow Visualization
*/
import {
cn,
formatResetTime,
getClaudeResetTime,
getMinClaudeQuota,
getModelsWithTiers,
groupModelsByTier,
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 { useAccountQuota } from '@/hooks/use-cliproxy-stats';
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';
import { Button } from '@/components/ui/button';
import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content';
import type { AccountData, DragOffset } from './types';
import { cleanEmail } from './utils';
@@ -89,19 +83,26 @@ 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 = QUOTA_SUPPORTED_PROVIDERS.includes(
account.provider as QuotaSupportedProvider
);
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;
// 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 =
isAgy && account.tier && account.tier !== 'unknown' && account.tier !== 'free';
account.provider === 'agy' &&
account.tier &&
account.tier !== 'unknown' &&
account.tier !== 'free';
return (
<div
@@ -198,8 +199,8 @@ export function AccountCard({
failure={account.failureCount}
showDetails={showDetails}
/>
{/* Quota bar for AGY accounts */}
{isAgy && (
{/* Quota bar for CLIProxy accounts (agy, codex, gemini) */}
{isCliproxyProvider && (
<div className="mt-2 px-0.5">
{quotaLoading ? (
<div className="flex items-center gap-1 text-[8px] text-muted-foreground">
@@ -244,47 +245,25 @@ export function AccountCard({
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<div className="text-xs space-y-1">
<p className="font-medium">Model Quotas:</p>
{(() => {
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 (
<div key={tier}>
{!isFirst && <div className="border-t border-border/40 my-1" />}
{models.map((m) => (
<div key={m.name} className="flex justify-between gap-4">
<span className={cn('truncate', m.exhausted && 'text-red-500')}>
{m.displayName}
</span>
<span className={cn('font-mono', m.exhausted && 'text-red-500')}>
{m.percentage}%
</span>
</div>
))}
</div>
);
});
})()}
{(() => {
const resetTime = getClaudeResetTime(quota?.models || []);
return resetTime ? (
<div className="flex items-center gap-1.5 pt-1 border-t border-border/50">
<Clock className="w-3 h-3 text-blue-400" />
<span className="text-blue-400 font-medium">
Resets {formatResetTime(resetTime)}
</span>
</div>
) : null;
})()}
{quota && <QuotaTooltipContent quota={quota} resetTime={resetTime} />}
</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-[200px]">
<p className="text-xs">
{quota.error?.includes('No refresh token')
? 'Remove and re-add account'
: quota.error || 'Auto-refresh failed'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -28,18 +28,12 @@ import {
AlertTriangle,
FolderCode,
Check,
KeyRound,
} from 'lucide-react';
import {
cn,
formatResetTime,
getClaudeResetTime,
getMinClaudeQuota,
getModelsWithTiers,
groupModelsByTier,
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';
/**
@@ -118,12 +112,9 @@ 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;
// Get earliest reset time
const nextReset =
quota?.success && quota.models.length > 0 ? getClaudeResetTime(quota.models) : null;
// Use shared utility functions for provider-specific quota handling
const minQuota = getProviderMinQuota(account.provider, quota);
const nextReset = getProviderResetTime(account.provider, quota);
return (
<div
@@ -356,48 +347,36 @@ export function AccountItem({
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
<div className="text-xs space-y-1">
<p className="font-medium">Model Quotas:</p>
{(() => {
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 (
<div key={tier}>
{!isFirst && <div className="border-t border-border/40 my-1" />}
{models.map((m) => (
<div key={m.name} className="flex justify-between gap-4">
<span className={cn('truncate', m.exhausted && 'text-red-500')}>
{m.displayName}
</span>
<span className={cn('font-mono', m.exhausted && 'text-red-500')}>
{m.percentage}%
</span>
</div>
))}
</div>
);
});
})()}
{nextReset && (
<div className="flex items-center gap-1.5 pt-1 border-t border-border/50">
<Clock className="w-3 h-3 text-blue-400" />
<span className="text-blue-400 font-medium">
Resets {formatResetTime(nextReset)}
</span>
</div>
)}
</div>
{quota && <QuotaTooltipContent quota={quota} resetTime={nextReset} />}
</TooltipContent>
</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-[220px]">
<p className="text-xs">
{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'}`}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : quota?.error || (quota && !quota.success) ? (
<TooltipProvider>
<Tooltip>
@@ -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={provider === 'agy' && !isRemoteMode}
showQuota={
QUOTA_SUPPORTED_PROVIDERS.includes(provider as QuotaSupportedProvider) && !isRemoteMode
}
isKiro={isKiro}
kiroNoIncognito={kiroNoIncognito}
onKiroNoIncognitoChange={saveKiroNoIncognito}
+1
View File
@@ -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';
@@ -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 (
<div className="text-xs space-y-1">
<p className="font-medium">Model Quotas:</p>
{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 (
<div key={tier}>
{!isFirst && <div className="border-t border-border/40 my-1" />}
{models.map((m) => (
<div key={m.name} className="flex justify-between gap-4">
<span className={cn('truncate', m.exhausted && 'text-red-500')}>
{m.displayName}
</span>
<span className={cn('font-mono', m.exhausted && 'text-red-500')}>
{m.percentage}%
</span>
</div>
))}
</div>
);
})}
<ResetTimeIndicator resetTime={resetTime} />
</div>
);
}
// Codex provider tooltip
if (isCodexQuotaResult(quota)) {
return (
<div className="text-xs space-y-1">
<p className="font-medium">Rate Limits:</p>
{quota.planType && <p className="text-muted-foreground">Plan: {quota.planType}</p>}
{quota.windows.map((w) => (
<div key={w.label} className="flex justify-between gap-4">
<span className={cn(w.remainingPercent < 20 && 'text-red-500')}>{w.label}</span>
<span className="font-mono">{w.remainingPercent}%</span>
</div>
))}
<ResetTimeIndicator resetTime={resetTime} />
</div>
);
}
// Gemini provider tooltip
if (isGeminiQuotaResult(quota)) {
return (
<div className="text-xs space-y-1">
<p className="font-medium">Buckets:</p>
{quota.buckets.map((b) => (
<div key={b.id} className="flex justify-between gap-4">
<span className={cn(b.remainingPercent < 20 && 'text-red-500')}>
{b.label}
{b.tokenType ? ` (${b.tokenType})` : ''}
</span>
<span className="font-mono">{b.remainingPercent}%</span>
</div>
))}
<ResetTimeIndicator resetTime={resetTime} />
</div>
);
}
return null;
}
/**
* Reset time indicator shown at bottom of tooltip
*/
function ResetTimeIndicator({ resetTime }: { resetTime: string | null }) {
if (!resetTime) return null;
return (
<div className="flex items-center gap-1.5 pt-1 border-t border-border/50">
<Clock className="w-3 h-3 text-blue-400" />
<span className="text-blue-400 font-medium">Resets {formatResetTime(resetTime)}</span>
</div>
);
}
+75 -6
View File
@@ -3,7 +3,13 @@
*/
import { useQuery } from '@tanstack/react-query';
import type { ModelQuota, QuotaResult } from '@/lib/api-client';
import type {
ModelQuota,
QuotaResult,
CodexQuotaResult,
GeminiCliQuotaResult,
} from '@/lib/api-client';
import type { UnifiedQuotaResult } from '@/lib/utils';
/** Per-account usage statistics */
export interface AccountUsageStats {
@@ -196,10 +202,14 @@ 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 */
export const QUOTA_SUPPORTED_PROVIDERS = ['agy', 'codex', 'gemini'] as const;
export type QuotaSupportedProvider = (typeof QUOTA_SUPPORTED_PROVIDERS)[number];
/**
* Fetch account quota from API
* Fetch account quota from API (Antigravity only)
*/
async function fetchAccountQuota(provider: string, accountId: string): Promise<QuotaResult> {
const response = await fetch(`/api/cliproxy/quota/${provider}/${encodeURIComponent(accountId)}`);
@@ -216,15 +226,74 @@ async function fetchAccountQuota(provider: string, accountId: string): Promise<Q
return response.json();
}
/**
* Fetch Codex quota from API
*/
async function fetchCodexQuotaApi(accountId: string): Promise<CodexQuotaResult> {
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<GeminiCliQuotaResult> {
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();
}
// Re-export unified type from utils for consumers
export type { UnifiedQuotaResult } from '@/lib/utils';
/**
* Fetch quota by provider (dispatcher)
*/
async function fetchQuotaByProvider(
provider: string,
accountId: string
): Promise<UnifiedQuotaResult> {
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 &&
QUOTA_SUPPORTED_PROVIDERS.includes(provider as QuotaSupportedProvider) &&
!!accountId,
staleTime: 60000, // Match refetchInterval to prevent early refetching
refetchInterval: 60000, // Refresh every 1 minute
refetchOnWindowFocus: false, // Don't refetch on tab switch
+80
View File
@@ -144,6 +144,80 @@ 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 */
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;
/** 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) */
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;
/** True if token is expired and needs re-authentication */
needsReauth?: boolean;
/** True if result was served from cache */
cached?: boolean;
}
/** Provider accounts summary */
@@ -552,5 +626,11 @@ export const api = {
/** Fetch quota for a specific account */
get: (provider: string, accountId: string) =>
request<QuotaResult>(`/cliproxy/quota/${provider}/${encodeURIComponent(accountId)}`),
/** Fetch Codex quota for a specific account */
getCodex: (accountId: string) =>
request<CodexQuotaResult>(`/cliproxy/quota/codex/${encodeURIComponent(accountId)}`),
/** Fetch Gemini CLI quota for a specific account */
getGemini: (accountId: string) =>
request<GeminiCliQuotaResult>(`/cliproxy/quota/gemini/${encodeURIComponent(accountId)}`),
},
};
+129
View File
@@ -1,5 +1,12 @@
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
import type {
CodexQuotaWindow,
CodexQuotaResult,
GeminiCliBucket,
GeminiCliQuotaResult,
QuotaResult,
} from './api-client';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -298,3 +305,125 @@ export function groupModelsByTier(models: TieredModel[]): Map<ModelTier, TieredM
}
return groups;
}
/**
* Get minimum remaining percentage across Codex rate limit windows
*/
export function getMinCodexQuota(windows: CodexQuotaWindow[]): number | null {
if (!windows || windows.length === 0) return null;
const percentages = windows.map((w) => 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];
}
// ==================== 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;
}
}
File diff suppressed because it is too large Load Diff