mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-10 06:20:13 +00:00
Merge pull request #401 from kaitranntt/dev
feat(release): promote dev to main - Codex/Gemini quota display
This commit is contained in:
+1
-1
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)}`),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user