diff --git a/package.json b/package.json index 3dee93f2..2647799f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.9.0", + "version": "7.9.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index 08bc50a2..6858f43d 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -112,6 +112,10 @@ export { export type { CliproxyStats } from './stats-fetcher'; export { fetchCliproxyStats, isCliproxyRunning } from './stats-fetcher'; +// Quota fetcher +export type { ModelQuota, QuotaResult } from './quota-fetcher'; +export { fetchAccountQuota } from './quota-fetcher'; + // OpenAI compatibility layer export type { OpenAICompatProvider, OpenAICompatModel } from './openai-compat-manager'; export { diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts new file mode 100644 index 00000000..749d9393 --- /dev/null +++ b/src/cliproxy/quota-fetcher.ts @@ -0,0 +1,525 @@ +/** + * Quota Fetcher for Antigravity Accounts + * + * Fetches quota information from Google Cloud Code internal API. + * Used for displaying remaining quota percentages and reset times. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { getAuthDir } from './config-generator'; +import { CLIProxyProvider } from './types'; + +/** Individual model quota info */ +export interface ModelQuota { + /** Model name, e.g., "gemini-3-pro-high" */ + name: string; + /** Display name from API, e.g., "Gemini 3 Pro" */ + displayName?: string; + /** Remaining quota as percentage (0-100) */ + percentage: number; + /** ISO timestamp when quota resets, null if unknown */ + resetTime: string | null; +} + +/** Quota fetch result */ +export interface QuotaResult { + /** Whether fetch succeeded */ + success: boolean; + /** Quota for each available model */ + models: ModelQuota[]; + /** Timestamp of fetch */ + lastUpdated: number; + /** True if account lacks quota access (403) */ + isForbidden?: boolean; + /** Error message if fetch failed */ + error?: string; + /** True if token is expired and needs re-auth */ + isExpired?: boolean; + /** ISO timestamp when token expires/expired */ + expiresAt?: string; + /** True if account hasn't been activated in official Antigravity app */ + isUnprovisioned?: boolean; +} + +/** Google Cloud Code API endpoints */ +const ANTIGRAVITY_API_BASE = 'https://cloudcode-pa.googleapis.com'; +const ANTIGRAVITY_API_VERSION = 'v1internal'; + +/** Google OAuth token endpoint */ +const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; + +/** Antigravity OAuth credentials (from CLIProxyAPIPlus - public in open-source code) */ +const ANTIGRAVITY_CLIENT_ID = + '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com'; +const ANTIGRAVITY_CLIENT_SECRET = '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'; + +/** Headers for loadCodeAssist (matches CLIProxyAPI antigravity.go) */ +const LOADCODEASSIST_HEADERS = { + 'Content-Type': 'application/json', + 'User-Agent': 'google-api-nodejs-client/9.15.1', + 'X-Goog-Api-Client': 'google-cloud-sdk vscode_cloudshelleditor/0.1', + 'Client-Metadata': + '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}', +}; + +/** Headers for fetchAvailableModels (matches CLIProxyAPI antigravity_executor.go) */ +const FETCHMODELS_HEADERS = { + 'Content-Type': 'application/json', + 'User-Agent': 'antigravity/1.104.0 darwin/arm64', +}; + +/** Auth file structure */ +interface AntigravityAuthFile { + access_token: string; + refresh_token?: string; + email?: string; + expired?: string; + expires_in?: number; + timestamp?: number; + type?: string; + project_id?: string; +} + +/** Auth data returned from file */ +interface AuthData { + accessToken: string; + refreshToken: string | null; + projectId: string | null; + isExpired: boolean; + expiresAt: string | null; +} + +/** Token refresh response */ +interface TokenRefreshResponse { + access_token?: string; + expires_in?: number; + token_type?: string; + error?: string; + error_description?: string; +} + +/** loadCodeAssist response */ +interface LoadCodeAssistResponse { + cloudaicompanionProject?: string | { id?: string }; +} + +/** fetchAvailableModels response model */ +interface AvailableModel { + name?: string; + displayName?: string; + quotaInfo?: { + remainingFraction?: number; + remaining_fraction?: number; + remaining?: number; + resetTime?: string; + reset_time?: string; + }; + quota_info?: { + remainingFraction?: number; + remaining_fraction?: number; + remaining?: number; + resetTime?: string; + reset_time?: string; + }; +} + +/** fetchAvailableModels response */ +interface FetchAvailableModelsResponse { + models?: Record; +} + +/** + * Sanitize email to match CLIProxyAPI auth file naming convention + * Replaces @ and . with underscores (matches Go sanitizeAntigravityFileName) + */ +function sanitizeEmail(email: string): string { + return email.replace(/@/g, '_').replace(/\./g, '_'); +} + +/** + * Check if token is expired based on the expired timestamp + */ +function isTokenExpired(expiredStr?: string): boolean { + if (!expiredStr) return false; + try { + const expiredDate = new Date(expiredStr); + return expiredDate.getTime() < Date.now(); + } catch { + return false; + } +} + +/** + * Refresh access token using refresh_token via Google OAuth + * This allows CCS to get fresh tokens independently of CLIProxyAPI + */ +async function refreshAccessToken( + refreshToken: string +): Promise<{ accessToken: string | null; error?: string }> { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + try { + const response = await fetch(GOOGLE_TOKEN_URL, { + method: 'POST', + signal: controller.signal, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken, + client_id: ANTIGRAVITY_CLIENT_ID, + client_secret: ANTIGRAVITY_CLIENT_SECRET, + }).toString(), + }); + + clearTimeout(timeoutId); + + const data = (await response.json()) as TokenRefreshResponse; + + if (!response.ok || data.error) { + return { + accessToken: null, + error: data.error_description || data.error || `OAuth error: ${response.status}`, + }; + } + + if (!data.access_token) { + return { accessToken: null, error: 'No access_token in response' }; + } + + return { accessToken: data.access_token }; + } catch (err) { + clearTimeout(timeoutId); + if (err instanceof Error && err.name === 'AbortError') { + return { accessToken: null, error: 'Token refresh timeout' }; + } + return { accessToken: null, error: err instanceof Error ? err.message : 'Unknown error' }; + } +} + +/** + * Read auth data from auth file (access token, project_id, expiry status) + */ +function readAuthData(provider: CLIProxyProvider, accountId: string): AuthData | null { + const authDir = getAuthDir(); + + // Check if auth directory exists + if (!fs.existsSync(authDir)) { + return null; + } + + // Sanitize accountId (email) to match auth file naming: @ and . → _ + const sanitizedId = sanitizeEmail(accountId); + const prefix = provider === 'agy' ? 'antigravity-' : `${provider}-`; + const expectedFile = `${prefix}${sanitizedId}.json`; + const filePath = path.join(authDir, expectedFile); + + // Direct file access (most common case) + if (fs.existsSync(filePath)) { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const data = JSON.parse(content) as AntigravityAuthFile; + if (!data.access_token) return null; + return { + accessToken: data.access_token, + refreshToken: data.refresh_token || null, + projectId: data.project_id || null, + isExpired: isTokenExpired(data.expired), + expiresAt: data.expired || null, + }; + } catch { + return null; + } + } + + // Fallback: scan directory for matching email in file content + const files = fs.readdirSync(authDir); + for (const file of files) { + if (file.startsWith(prefix) && file.endsWith('.json')) { + const candidatePath = path.join(authDir, file); + try { + const content = fs.readFileSync(candidatePath, 'utf-8'); + const data = JSON.parse(content) as AntigravityAuthFile; + // Match by email field inside the auth file + if (data.email === accountId && data.access_token) { + return { + accessToken: data.access_token, + refreshToken: data.refresh_token || null, + projectId: data.project_id || null, + isExpired: isTokenExpired(data.expired), + expiresAt: data.expired || null, + }; + } + } catch { + continue; + } + } + } + + return null; +} + +/** + * Get project ID via loadCodeAssist endpoint + */ +async function getProjectId( + accessToken: string +): Promise<{ projectId: string | null; error?: string; isUnprovisioned?: boolean }> { + const url = `${ANTIGRAVITY_API_BASE}/${ANTIGRAVITY_API_VERSION}:loadCodeAssist`; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + try { + const response = await fetch(url, { + method: 'POST', + signal: controller.signal, + headers: { + ...LOADCODEASSIST_HEADERS, + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + metadata: { + ideType: 'IDE_UNSPECIFIED', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI', + }, + }), + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + // Return specific error based on status + if (response.status === 401) { + return { projectId: null, error: 'Token expired or invalid' }; + } + if (response.status === 403) { + return { projectId: null, error: 'Access forbidden' }; + } + return { projectId: null, error: `API error: ${response.status}` }; + } + + const data = (await response.json()) as LoadCodeAssistResponse; + + // Extract project ID from response + let projectId: string | undefined; + if (typeof data.cloudaicompanionProject === 'string') { + projectId = data.cloudaicompanionProject; + } else if (typeof data.cloudaicompanionProject === 'object') { + projectId = data.cloudaicompanionProject?.id; + } + + if (!projectId?.trim()) { + // Account authenticated but not provisioned - user needs to sign in via Antigravity app + return { + projectId: null, + error: 'Sign in to Antigravity app to activate quota.', + isUnprovisioned: true, + }; + } + + return { projectId: projectId.trim() }; + } catch (err) { + clearTimeout(timeoutId); + if (err instanceof Error && err.name === 'AbortError') { + return { projectId: null, error: 'Request timeout' }; + } + return { projectId: null, error: err instanceof Error ? err.message : 'Unknown error' }; + } +} + +/** + * Fetch available models with quota info + * Note: projectId is kept for potential future use but not sent in body + * (CLIProxyAPI sends empty {} body for this endpoint) + */ +async function fetchAvailableModels(accessToken: string, _projectId: string): Promise { + const url = `${ANTIGRAVITY_API_BASE}/${ANTIGRAVITY_API_VERSION}:fetchAvailableModels`; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + try { + // Match CLIProxyAPI exactly: empty body, minimal headers + const response = await fetch(url, { + method: 'POST', + signal: controller.signal, + headers: { + ...FETCHMODELS_HEADERS, + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({}), + }); + + clearTimeout(timeoutId); + + if (response.status === 403) { + return { + success: false, + models: [], + lastUpdated: Date.now(), + isForbidden: true, + error: 'Quota access forbidden for this account', + }; + } + + if (response.status === 401) { + return { + success: false, + models: [], + lastUpdated: Date.now(), + error: 'Access token expired or invalid', + }; + } + + if (!response.ok) { + return { + success: false, + models: [], + lastUpdated: Date.now(), + error: `API error: ${response.status}`, + }; + } + + const data = (await response.json()) as FetchAvailableModelsResponse; + const models: ModelQuota[] = []; + + if (data.models && typeof data.models === 'object') { + for (const [modelId, modelData] of Object.entries(data.models)) { + const quotaInfo = modelData.quotaInfo || modelData.quota_info; + if (!quotaInfo) continue; + + // Extract remaining fraction (0-1 range) + const remaining = + quotaInfo.remainingFraction ?? quotaInfo.remaining_fraction ?? quotaInfo.remaining; + + // Skip invalid values (NaN, Infinity, non-numbers) + if (typeof remaining !== 'number' || !isFinite(remaining)) continue; + + // Convert to percentage (0-100) and clamp to valid range + const percentage = Math.max(0, Math.min(100, Math.round(remaining * 100))); + + // Extract reset time + const resetTime = quotaInfo.resetTime || quotaInfo.reset_time || null; + + models.push({ + name: modelId, + displayName: modelData.displayName, + percentage, + resetTime, + }); + } + } + + return { + success: true, + models, + lastUpdated: Date.now(), + }; + } catch (err) { + clearTimeout(timeoutId); + return { + success: false, + models: [], + lastUpdated: Date.now(), + error: err instanceof Error ? err.message : 'Unknown error', + }; + } +} + +/** + * Fetch quota for an Antigravity account + * + * @param provider - Provider name (only 'agy' supported) + * @param accountId - Account identifier (email) + * @returns Quota result with models and percentages + */ +export async function fetchAccountQuota( + provider: CLIProxyProvider, + accountId: string +): Promise { + // Only Antigravity supports quota fetching + if (provider !== 'agy') { + return { + success: false, + models: [], + lastUpdated: Date.now(), + error: `Quota not supported for provider: ${provider}`, + }; + } + + // Read auth data from auth file + const authData = readAuthData(provider, accountId); + if (!authData) { + return { + success: false, + models: [], + lastUpdated: Date.now(), + error: 'Auth file not found for account', + }; + } + + // Determine which access token to use + // File-based token is often stale (CLIProxyAPIPlus refreshes at runtime but doesn't persist) + // Proactive refresh: refresh 5 minutes before expiry (matches CLIProxyAPIPlus behavior) + let accessToken = authData.accessToken; + const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000; // 5 minutes + + if (authData.refreshToken) { + const shouldRefresh = + authData.isExpired || // Already expired + !authData.expiresAt || // No expiry info - refresh to be safe + new Date(authData.expiresAt).getTime() - Date.now() < REFRESH_LEAD_TIME_MS; // Expiring soon + + if (shouldRefresh) { + const refreshResult = await refreshAccessToken(authData.refreshToken); + if (refreshResult.accessToken) { + accessToken = refreshResult.accessToken; + } + // If refresh fails, fall back to existing token (might still work) + } + } + + // Get project ID - prefer stored value, fallback to API call + let projectId = authData.projectId; + if (!projectId) { + let lastProjectResult = await getProjectId(accessToken); + if (!lastProjectResult.projectId) { + // If project ID fetch fails, it might be token issue - try refresh if we haven't + if (authData.refreshToken && accessToken === authData.accessToken) { + const refreshResult = await refreshAccessToken(authData.refreshToken); + if (refreshResult.accessToken) { + accessToken = refreshResult.accessToken; + lastProjectResult = await getProjectId(accessToken); + } + } + if (!lastProjectResult.projectId) { + return { + success: false, + models: [], + lastUpdated: Date.now(), + error: lastProjectResult.error || 'Failed to retrieve project ID', + isUnprovisioned: lastProjectResult.isUnprovisioned, + }; + } + } + projectId = lastProjectResult.projectId; + } + + // Fetch models with quota + const result = await fetchAvailableModels(accessToken, projectId); + + // If quota fetch fails with auth error and we haven't refreshed yet, try refresh + if (!result.success && result.error?.includes('expired') && authData.refreshToken) { + const refreshResult = await refreshAccessToken(authData.refreshToken); + if (refreshResult.accessToken) { + return fetchAvailableModels(refreshResult.accessToken, projectId); + } + } + + return result; +} diff --git a/src/cliproxy/stats-fetcher.ts b/src/cliproxy/stats-fetcher.ts index ee74b8a9..58c20a4b 100644 --- a/src/cliproxy/stats-fetcher.ts +++ b/src/cliproxy/stats-fetcher.ts @@ -297,6 +297,10 @@ export interface CliproxyErrorLog { modified: number; /** Absolute path to the log file (injected by backend) */ absolutePath?: string; + /** HTTP status code extracted from log (injected by backend) */ + statusCode?: number; + /** Model name extracted from request body (injected by backend) */ + model?: string; } /** Response from /v0/management/request-error-logs endpoint */ diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index 53e32c9a..ac11ca84 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -12,6 +12,8 @@ import { fetchCliproxyErrorLogs, fetchCliproxyErrorLogContent, } from '../../cliproxy/stats-fetcher'; +import { fetchAccountQuota } from '../../cliproxy/quota-fetcher'; +import type { CLIProxyProvider } from '../../cliproxy/types'; import { getCliproxyWritablePath, getCliproxyConfigPath, @@ -23,6 +25,48 @@ import { checkCliproxyUpdate } from '../../cliproxy/binary-manager'; const router = Router(); +/** + * Extract status code and model from error log file (lightweight parsing) + * Reads first 4KB for model, last 2KB for status code + */ +async function extractErrorLogMetadata( + filePath: string +): Promise<{ statusCode?: number; model?: string }> { + try { + const fd = fs.openSync(filePath, 'r'); + const stat = fs.fstatSync(fd); + const fileSize = stat.size; + + // Read first 4KB for model (in request body) + const startBuffer = Buffer.alloc(Math.min(4096, fileSize)); + fs.readSync(fd, startBuffer, 0, startBuffer.length, 0); + const startContent = startBuffer.toString('utf-8'); + + // Extract model from request body JSON: "model":"gemini-3-flash-preview" + const modelMatch = startContent.match(/"model"\s*:\s*"([^"]+)"/); + const model = modelMatch ? modelMatch[1] : undefined; + + // Read last 2KB for status code (in response section at end) + let statusCode: number | undefined; + if (fileSize > 2048) { + const endBuffer = Buffer.alloc(2048); + fs.readSync(fd, endBuffer, 0, 2048, fileSize - 2048); + const endContent = endBuffer.toString('utf-8'); + const statusMatch = endContent.match(/Status:\s*(\d{3})/); + statusCode = statusMatch ? parseInt(statusMatch[1], 10) : undefined; + } else { + // Small file - check start content for status + const statusMatch = startContent.match(/Status:\s*(\d{3})/); + statusCode = statusMatch ? parseInt(statusMatch[1], 10) : undefined; + } + + fs.closeSync(fd); + return { statusCode, model }; + } catch { + return {}; + } +} + /** * Shared handler for stats/usage endpoint */ @@ -212,14 +256,22 @@ router.get('/error-logs', async (_req: Request, res: Response): Promise => return; } - // Inject absolute paths into each file entry + // Inject absolute paths and extract metadata from each file const logsDir = path.join(getCliproxyWritablePath(), 'logs'); - const filesWithPaths = files.map((file) => ({ - ...file, - absolutePath: path.join(logsDir, file.name), - })); + const filesWithMetadata = await Promise.all( + files.map(async (file) => { + const absolutePath = path.join(logsDir, file.name); + const metadata = await extractErrorLogMetadata(absolutePath); + return { + ...file, + absolutePath, + statusCode: metadata.statusCode, + model: metadata.model, + }; + }) + ); - res.json({ files: filesWithPaths }); + res.json({ files: filesWithMetadata }); } catch (error) { res.status(500).json({ error: (error as Error).message }); } @@ -430,4 +482,50 @@ router.put('/models/:provider', async (req: Request, res: Response): Promise => { + const { provider, accountId } = req.params; + + // Validate provider + const validProviders: CLIProxyProvider[] = [ + 'agy', + 'gemini', + 'codex', + 'qwen', + 'iflow', + 'kiro', + 'ghcp', + ]; + if (!validProviders.includes(provider as CLIProxyProvider)) { + res.status(400).json({ + error: 'Invalid provider', + message: `Provider must be one of: ${validProviders.join(', ')}`, + }); + return; + } + + // Validate accountId - prevent path traversal + if ( + !accountId || + accountId.includes('..') || + accountId.includes('/') || + accountId.includes('\\') + ) { + res.status(400).json({ error: 'Invalid account ID' }); + return; + } + + try { + const result = await fetchAccountQuota(provider as CLIProxyProvider, accountId); + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + export default router; diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 3defbedc..25e8b68a 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -2,9 +2,11 @@ * Account Card Component for Flow Visualization */ -import { cn } from '@/lib/utils'; +import { cn, sortModelsByPriority, formatResetTime, getEarliestResetTime } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; -import { GripVertical } from 'lucide-react'; +import { GripVertical, Loader2, Clock } from 'lucide-react'; +import { useAccountQuota } from '@/hooks/use-cliproxy-stats'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import type { AccountData, DragOffset } from './types'; import { cleanEmail } from './utils'; @@ -74,6 +76,18 @@ export function AccountCard({ const borderColor = getBorderColorStyle(zone, account.color); const connectorPosition = CONNECTOR_POSITION_MAP[zone]; + // Quota for AGY accounts + const isAgy = account.provider === 'agy'; + const { data: quota, isLoading: quotaLoading } = useAccountQuota( + account.provider, + account.id, + isAgy + ); + const avgQuota = + quota?.success && quota.models.length > 0 + ? Math.round(quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length) + : null; + return (
+ {/* Quota bar for AGY accounts */} + {isAgy && ( +
+ {quotaLoading ? ( +
+ + Quota... +
+ ) : avgQuota !== null ? ( + + + +
+
+ + Quota + + 50 + ? 'text-emerald-600 dark:text-emerald-400' + : avgQuota > 20 + ? 'text-amber-500' + : 'text-red-500' + )} + > + {avgQuota}% + +
+
+
50 + ? 'bg-emerald-500' + : avgQuota > 20 + ? 'bg-amber-500' + : 'bg-red-500' + )} + style={{ width: `${avgQuota}%` }} + /> +
+
+ + +
+

Model Quotas:

+ {sortModelsByPriority(quota?.models || []).map((m) => ( +
+ {m.displayName || m.name} + {m.percentage}% +
+ ))} + {(() => { + const resetTime = getEarliestResetTime(quota?.models || []); + return resetTime ? ( +
+ + + Resets {formatResetTime(resetTime)} + +
+ ) : null; + })()} +
+
+ + + ) : quota?.error ? ( +
+ {quota.error.length > 20 ? `${quota.error.slice(0, 18)}...` : quota.error} +
+ ) : null} +
+ )}
void; privacyMode?: boolean; } @@ -30,7 +31,7 @@ function CredentialRow({ status, statusMessage, email, - expiresAt, + lastUsedAt, onRefresh, privacyMode, }: CredentialRowProps) { @@ -60,16 +61,23 @@ function CredentialRow({ const config = statusConfig[status]; const Icon = config.icon; - const formatExpiry = (date?: string) => { - if (!date) return 'Never'; - const expiry = new Date(date); - const now = new Date(); - const diff = expiry.getTime() - now.getTime(); - if (diff < 0) return 'Expired'; - const hours = Math.floor(diff / 3600000); - if (hours < 1) return 'Soon'; - if (hours < 24) return `${hours}h`; - return `${Math.floor(hours / 24)}d`; + const formatLastUsed = (date?: string) => { + if (!date) return 'Never used'; + try { + const lastUsed = new Date(date); + const now = new Date(); + const diff = now.getTime() - lastUsed.getTime(); + if (diff < 0) return 'Just now'; + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + if (days > 0) return `${days}d ago`; + if (hours > 0) return `${hours}h ago`; + if (minutes > 0) return `${minutes}m ago`; + return 'Just now'; + } catch { + return 'Unknown'; + } }; return ( @@ -96,8 +104,9 @@ function CredentialRow({ > {statusMessage} -
- Expires: {formatExpiry(expiresAt)} +
+ + {formatLastUsed(lastUsedAt)}
{status === 'warning' && onRefresh && ( @@ -129,23 +138,28 @@ function CredentialHealthSkeleton() { export function CredentialHealthList() { const { data: authData, isLoading } = useCliproxyAuth(); + const { data: stats } = useCliproxyStats(true); const { privacyMode } = usePrivacy(); if (isLoading) { return ; } - // Flatten accounts from all providers + // Flatten accounts from all providers with runtime lastUsedAt const credentials = authData?.authStatus.flatMap((status) => - (status.accounts ?? []).map((account) => ({ - name: account.id, - provider: status.provider, - status: (account as { status?: CredentialStatus }).status ?? 'ready', - statusMessage: (account as { statusMessage?: string }).statusMessage ?? 'Ready', - email: account.email, - expiresAt: (account as { expiresAt?: string }).expiresAt, - })) + (status.accounts ?? []).map((account) => { + const accountKey = account.email || account.id; + const runtimeLastUsed = stats?.accountStats?.[accountKey]?.lastUsedAt; + return { + name: account.id, + provider: status.provider, + status: (account as { status?: CredentialStatus }).status ?? 'ready', + statusMessage: (account as { statusMessage?: string }).statusMessage ?? 'Ready', + email: account.email, + lastUsedAt: runtimeLastUsed || account.lastUsedAt, + }; + }) ) ?? []; if (credentials.length === 0) { diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 589353ee..98c9eef9 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -1,88 +1,249 @@ /** * Account Item Component - * Displays a single OAuth account with actions + * Displays a single OAuth account with actions and quota bar */ import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { User, Star, MoreHorizontal, Clock, Trash2 } from 'lucide-react'; -import { cn } from '@/lib/utils'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { + User, + Star, + MoreHorizontal, + Clock, + Trash2, + Loader2, + CheckCircle2, + HelpCircle, +} from 'lucide-react'; +import { cn, sortModelsByPriority, formatResetTime, getEarliestResetTime } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; +import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats'; import type { AccountItemProps } from './types'; +/** + * Get color class based on quota percentage + */ +function getQuotaColor(percentage: number): string { + if (percentage <= 20) return 'bg-destructive'; + if (percentage <= 50) return 'bg-yellow-500'; + return 'bg-green-500'; +} + +/** + * Format relative time (e.g., "5m ago", "2h ago") + */ +function formatRelativeTime(dateStr: string | undefined): string { + if (!dateStr) return ''; + try { + const date = new Date(dateStr); + const now = new Date(); + const diff = now.getTime() - date.getTime(); + if (diff < 0) return 'just now'; + + const minutes = Math.floor(diff / (1000 * 60)); + const hours = Math.floor(diff / (1000 * 60 * 60)); + const days = Math.floor(diff / (1000 * 60 * 60 * 24)); + + if (days > 0) return `${days}d ago`; + if (hours > 0) return `${hours}h ago`; + if (minutes > 0) return `${minutes}m ago`; + return 'just now'; + } catch { + return ''; + } +} + +/** + * Check if account was used recently (within last hour = token likely refreshed) + */ +function isRecentlyUsed(lastUsedAt: string | undefined): boolean { + if (!lastUsedAt) return false; + try { + const lastUsed = new Date(lastUsedAt); + const now = new Date(); + const diff = now.getTime() - lastUsed.getTime(); + return diff < 60 * 60 * 1000; // Within last hour + } catch { + return false; + } +} + export function AccountItem({ account, onSetDefault, onRemove, isRemoving, privacyMode, + showQuota, }: AccountItemProps) { + // Fetch runtime stats to get actual lastUsedAt (more accurate than file state) + const { data: stats } = useCliproxyStats(showQuota && account.provider === 'agy'); + + // Fetch quota for 'agy' provider accounts + const { data: quota, isLoading: quotaLoading } = useAccountQuota( + account.provider, + account.id, + showQuota && account.provider === 'agy' + ); + + // Get last used time from runtime stats (more accurate than file) + const runtimeLastUsed = stats?.accountStats?.[account.email || account.id]?.lastUsedAt; + const wasRecentlyUsed = isRecentlyUsed(runtimeLastUsed); + + // Calculate average quota across all models + const avgQuota = + quota?.success && quota.models.length > 0 + ? Math.round(quota.models.reduce((sum, m) => sum + m.percentage, 0) / quota.models.length) + : null; + + // Get earliest reset time + const nextReset = + quota?.success && quota.models.length > 0 ? getEarliestResetTime(quota.models) : null; + return (
-
-
- -
-
-
- - {account.email || account.id} - - {account.isDefault && ( - - - Default - +
+
+
+ +
+
+
+ + {account.email || account.id} + + {account.isDefault && ( + + + Default + + )} +
+ {account.lastUsedAt && ( +
+ + Last used: {new Date(account.lastUsedAt).toLocaleDateString()} +
)}
- {account.lastUsedAt && ( -
- - Last used: {new Date(account.lastUsedAt).toLocaleDateString()} -
- )}
+ + + + + + + {!account.isDefault && ( + + + Set as default + + )} + + + {isRemoving ? 'Removing...' : 'Remove account'} + + +
- - - - - - {!account.isDefault && ( - - - Set as default - - )} - - - {isRemoving ? 'Removing...' : 'Remove account'} - - - + {/* Quota bar - only for 'agy' provider */} + {showQuota && account.provider === 'agy' && ( +
+ {quotaLoading ? ( +
+ + Loading quota... +
+ ) : avgQuota !== null ? ( +
+ {/* Status indicator based on runtime usage, not file state */} +
+ {wasRecentlyUsed ? ( + <> + + + Active · {formatRelativeTime(runtimeLastUsed)} + + + ) : runtimeLastUsed ? ( + <> + + + Last used {formatRelativeTime(runtimeLastUsed)} + + + ) : ( + <> + + Not used yet + + )} +
+ {/* Quota bar */} + + + +
+ + {avgQuota}% +
+
+ +
+

Model Quotas:

+ {sortModelsByPriority(quota?.models || []).map((m) => ( +
+ {m.displayName || m.name} + {m.percentage}% +
+ ))} + {nextReset && ( +
+ + + Resets {formatResetTime(nextReset)} + +
+ )} +
+
+
+
+
+ ) : quota?.error ? ( +
{quota.error}
+ ) : null} +
+ )}
); } diff --git a/ui/src/components/cliproxy/provider-editor/accounts-section.tsx b/ui/src/components/cliproxy/provider-editor/accounts-section.tsx index bc93dd5c..f7527bcd 100644 --- a/ui/src/components/cliproxy/provider-editor/accounts-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/accounts-section.tsx @@ -17,6 +17,8 @@ interface AccountsSectionProps { onRemoveAccount: (accountId: string) => void; isRemovingAccount?: boolean; privacyMode?: boolean; + /** Show quota bars for accounts (only applicable for 'agy' provider) */ + showQuota?: boolean; /** Kiro-specific: show "use normal browser" toggle */ isKiro?: boolean; kiroNoIncognito?: boolean; @@ -31,6 +33,7 @@ export function AccountsSection({ onRemoveAccount, isRemovingAccount, privacyMode, + showQuota, isKiro, kiroNoIncognito, onKiroNoIncognitoChange, @@ -64,6 +67,7 @@ export function AccountsSection({ onRemove={() => onRemoveAccount(account.id)} isRemoving={isRemovingAccount} privacyMode={privacyMode} + showQuota={showQuota} /> ))}
diff --git a/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx index 921c8767..fc6c66e1 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx @@ -133,6 +133,7 @@ export function ModelConfigTab({ onRemoveAccount={onRemoveAccount} isRemovingAccount={isRemovingAccount} privacyMode={privacyMode} + showQuota={provider === 'agy'} isKiro={isKiro} kiroNoIncognito={kiroNoIncognito} onKiroNoIncognitoChange={saveKiroNoIncognito} diff --git a/ui/src/components/cliproxy/provider-editor/types.ts b/ui/src/components/cliproxy/provider-editor/types.ts index be162f90..47b5fd01 100644 --- a/ui/src/components/cliproxy/provider-editor/types.ts +++ b/ui/src/components/cliproxy/provider-editor/types.ts @@ -39,6 +39,8 @@ export interface AccountItemProps { onRemove: () => void; isRemoving?: boolean; privacyMode?: boolean; + /** Show quota bar (only for 'agy' provider) */ + showQuota?: boolean; } export interface ModelMappingValues { diff --git a/ui/src/components/monitoring/error-logs/error-log-item.tsx b/ui/src/components/monitoring/error-logs/error-log-item.tsx index a4d4b2e1..5f2f170b 100644 --- a/ui/src/components/monitoring/error-logs/error-log-item.tsx +++ b/ui/src/components/monitoring/error-logs/error-log-item.tsx @@ -1,71 +1,86 @@ /** * Error Log Item Component - * Individual log entry in the list view + * Individual log entry in the list view - shows status code, model, endpoint, time */ import { useMemo } from 'react'; import { cn } from '@/lib/utils'; -import { Clock, FileText } from 'lucide-react'; +import { Clock } from 'lucide-react'; import { ProviderIcon } from '@/components/shared/provider-icon'; -import { parseFilename, formatRelativeTime, formatBytes } from '@/lib/error-log-parser'; +import { parseFilename, formatRelativeTime } from '@/lib/error-log-parser'; import type { ErrorLogItemProps } from './types'; -export function ErrorLogItem({ name, size, modified, isSelected, onClick }: ErrorLogItemProps) { +/** Get status badge color based on HTTP status code */ +function getStatusColor(code?: number): string { + if (!code) return 'bg-gray-100 text-gray-600'; + if (code === 429) return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'; + if (code >= 500) return 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'; + if (code >= 400) + return 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'; + return 'bg-gray-100 text-gray-600'; +} + +/** Format model name for display (full name) */ +function formatModel(model?: string): string { + if (!model) return ''; + return model; +} + +export function ErrorLogItem({ + name, + size: _size, + modified, + isSelected, + onClick, + statusCode, + model, +}: ErrorLogItemProps) { const parsed = useMemo(() => parseFilename(name), [name]); + const displayModel = useMemo(() => formatModel(model), [model]); return ( diff --git a/ui/src/components/monitoring/error-logs/index.tsx b/ui/src/components/monitoring/error-logs/index.tsx index b96a65c7..8f8cea22 100644 --- a/ui/src/components/monitoring/error-logs/index.tsx +++ b/ui/src/components/monitoring/error-logs/index.tsx @@ -168,6 +168,8 @@ export function ErrorLogsMonitor() { modified={log.modified} isSelected={effectiveSelection === log.name} onClick={() => setSelectedLog(log.name)} + statusCode={log.statusCode} + model={log.model} /> ))}
diff --git a/ui/src/components/monitoring/error-logs/tab-components.tsx b/ui/src/components/monitoring/error-logs/tab-components.tsx index 42043731..bba80c37 100644 --- a/ui/src/components/monitoring/error-logs/tab-components.tsx +++ b/ui/src/components/monitoring/error-logs/tab-components.tsx @@ -4,79 +4,151 @@ */ import { ScrollArea } from '@/components/ui/scroll-area'; -import { Info } from 'lucide-react'; +import { Info, Clock, Cpu, AlertTriangle } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { getErrorTypeLabel, type ParsedErrorLog } from '@/lib/error-log-parser'; +import { + getErrorTypeLabel, + formatQuotaResetDelay, + formatQuotaResetTimestamp, + type ParsedErrorLog, +} from '@/lib/error-log-parser'; import { StatusBadge } from './ui-primitives'; /** Overview tab content */ export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) { + const quotaResetDisplay = + formatQuotaResetDelay(parsed.quotaResetDelay) || + formatQuotaResetTimestamp(parsed.quotaResetTimestamp); + return ( -
- {/* Status row */} -
- - {parsed.statusText} - - {getErrorTypeLabel(parsed.errorType)} - -
+ +
+ {/* Status row */} +
+ + {parsed.statusText} + + {getErrorTypeLabel(parsed.errorType)} + +
- {/* Key metrics grid */} -
-
-
Method
-
{parsed.method || 'N/A'}
-
-
-
Provider
-
{parsed.provider || 'N/A'}
-
-
-
Version
-
{parsed.version || 'N/A'}
-
-
-
Endpoint
-
- {parsed.endpoint || 'N/A'} + {/* Model info - prominent display */} + {parsed.model && ( +
+ +
+ Model: + + {parsed.model} + +
+
+ )} + + {/* Quota reset info for 429 errors */} + {parsed.errorType === 'rate_limit' && quotaResetDisplay && ( +
+ +
+ Quota resets in + + {quotaResetDisplay} + +
+
+ )} + + {/* Key metrics grid */} +
+
+
Method
+
{parsed.method || 'N/A'}
+
+
+
Provider
+
{parsed.provider || 'N/A'}
+
+
+
Version
+
{parsed.version || 'N/A'}
+
+
+
Endpoint
+
+ {parsed.endpoint || 'N/A'} +
-
- {/* URL */} -
-
URL
-
- {parsed.url || 'N/A'} -
-
- - {/* Timestamp */} -
-
Timestamp
-
{parsed.timestamp || 'N/A'}
-
- - {/* Suggestion based on error type */} - {parsed.errorType !== 'unknown' && ( -
- -
- {parsed.errorType === 'rate_limit' && - 'Rate limited. Consider using multiple accounts or reducing request frequency.'} - {parsed.errorType === 'auth' && - 'Authentication failed. Check credentials or re-authenticate with the provider.'} - {parsed.errorType === 'not_found' && - 'Endpoint not found. This endpoint may not exist on this provider.'} - {parsed.errorType === 'server' && - 'Server error from upstream. Retry or check provider status.'} - {parsed.errorType === 'timeout' && - 'Request timed out. Check network or increase timeout settings.'} + {/* URL */} +
+
URL
+
+ {parsed.url || 'N/A'}
- )} -
+ + {/* Timestamp */} +
+
Timestamp
+
{parsed.timestamp || 'N/A'}
+
+ + {/* Actionable suggestion based on error type */} + {parsed.errorType !== 'unknown' && ( +
+ {parsed.errorType === 'rate_limit' ? ( + + ) : parsed.errorType === 'auth' ? ( + + ) : ( + + )} +
+ {parsed.errorType === 'rate_limit' && ( + <> + Rate Limited. Switch to a different account or wait for quota + reset. + {parsed.model && ( + <> + {' '} + Model {parsed.model} has + exhausted quota. + + )} + + )} + {parsed.errorType === 'auth' && + 'Authentication failed. Re-authenticate via CLIProxy Settings or check API key.'} + {parsed.errorType === 'not_found' && + 'Endpoint not found. This endpoint may not exist on this provider.'} + {parsed.errorType === 'server' && + 'Server error from upstream. Retry later or check provider status page.'} + {parsed.errorType === 'timeout' && + 'Request timed out. Check network connection or increase timeout settings.'} +
+
+ )} +
+ ); } diff --git a/ui/src/components/monitoring/error-logs/types.ts b/ui/src/components/monitoring/error-logs/types.ts index 0cae04f1..e5cd35be 100644 --- a/ui/src/components/monitoring/error-logs/types.ts +++ b/ui/src/components/monitoring/error-logs/types.ts @@ -10,6 +10,10 @@ export interface ErrorLogItemProps { modified: number; isSelected: boolean; onClick: () => void; + /** HTTP status code from pre-parsed metadata */ + statusCode?: number; + /** Model name from pre-parsed metadata */ + model?: string; } export interface LogContentPanelProps { diff --git a/ui/src/components/ui/progress.tsx b/ui/src/components/ui/progress.tsx new file mode 100644 index 00000000..403613bc --- /dev/null +++ b/ui/src/components/ui/progress.tsx @@ -0,0 +1,40 @@ +/** + * Progress Component + * Simple progress bar with customizable indicator color + */ + +import * as React from 'react'; +import { cn } from '@/lib/utils'; + +interface ProgressProps extends React.HTMLAttributes { + value?: number; + max?: number; + indicatorClassName?: string; +} + +const Progress = React.forwardRef( + ({ className, value = 0, max = 100, indicatorClassName, ...props }, ref) => { + const percentage = Math.min(Math.max((value / max) * 100, 0), 100); + + return ( +
+
+
+ ); + } +); + +Progress.displayName = 'Progress'; + +export { Progress }; diff --git a/ui/src/hooks/use-cliproxy-auth-flow.ts b/ui/src/hooks/use-cliproxy-auth-flow.ts index 502b4b19..5bc452b5 100644 --- a/ui/src/hooks/use-cliproxy-auth-flow.ts +++ b/ui/src/hooks/use-cliproxy-auth-flow.ts @@ -1,6 +1,6 @@ /** * OAuth Auth Flow Hook for CLIProxy - * Manages popup-based OAuth authentication flows + * Triggers backend-managed OAuth authentication flows */ import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; @@ -13,19 +13,7 @@ interface AuthFlowState { error: string | null; } -const AUTH_ENDPOINTS: Record = { - claude: '/anthropic-auth-url', - gemini: '/gemini-cli-auth-url', - codex: '/codex-auth-url', - agy: '/antigravity-auth-url', - qwen: '/qwen-auth-url', - iflow: '/iflow-auth-url', - kiro: '/kiro-auth-url', - ghcp: '/ghcp-auth-url', -}; - -const AUTH_TIMEOUT_MS = 300000; // 5 minutes -const POLL_INTERVAL_MS = 500; +const VALID_PROVIDERS = ['gemini', 'codex', 'agy', 'qwen', 'iflow', 'kiro', 'ghcp']; export function useCliproxyAuthFlow() { const [state, setState] = useState({ @@ -34,36 +22,19 @@ export function useCliproxyAuthFlow() { error: null, }); - const popupRef = useRef(null); - const pollIntervalRef = useRef | null>(null); - const timeoutRef = useRef | null>(null); + const abortControllerRef = useRef(null); const queryClient = useQueryClient(); - // Cleanup function - const cleanup = useCallback(() => { - if (pollIntervalRef.current) { - clearInterval(pollIntervalRef.current); - pollIntervalRef.current = null; - } - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; - } - if (popupRef.current && !popupRef.current.closed) { - popupRef.current.close(); - } - popupRef.current = null; - }, []); - // Cleanup on unmount useEffect(() => { - return () => cleanup(); - }, [cleanup]); + return () => { + abortControllerRef.current?.abort(); + }; + }, []); const startAuth = useCallback( async (provider: string) => { - const endpoint = AUTH_ENDPOINTS[provider]; - if (!endpoint) { + if (!VALID_PROVIDERS.includes(provider)) { setState({ provider: null, isAuthenticating: false, @@ -72,117 +43,48 @@ export function useCliproxyAuthFlow() { return; } + // Abort any in-progress auth + abortControllerRef.current?.abort(); + abortControllerRef.current = new AbortController(); + setState({ provider, isAuthenticating: true, error: null }); try { - // Get auth URL from API - const response = await fetch(`/api/cliproxy${endpoint}?is_webui=true`); - if (!response.ok) { - throw new Error(`Failed to get auth URL: ${response.statusText}`); - } + // POST to CCS auth endpoint - backend opens browser and waits + const response = await fetch(`/api/cliproxy/auth/${provider}/start`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + signal: abortControllerRef.current.signal, + }); const data = await response.json(); - const { url, state: authState } = data; - if (!url) { - throw new Error('No auth URL returned from server'); + if (response.ok && data.success) { + queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); + queryClient.invalidateQueries({ queryKey: ['account-quota'] }); + toast.success(`${provider} authentication successful`); + setState({ provider: null, isAuthenticating: false, error: null }); + } else { + throw new Error(data.error || 'Authentication failed'); } - - // Open popup - const popup = window.open(url, `${provider}_auth`, 'width=600,height=700,popup=yes'); - - if (!popup) { - throw new Error('Popup blocked. Please allow popups for this site.'); - } - - popupRef.current = popup; - - // Poll for completion - pollIntervalRef.current = setInterval(async () => { - // Check if popup was closed by user - if (popup.closed) { - cleanup(); - // Check final status - try { - const statusRes = await fetch(`/api/cliproxy/get-auth-status?state=${authState}`); - const statusData = await statusRes.json(); - - if (statusData.status === 'ok') { - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); - toast.success(`${provider} authentication successful`); - setState({ provider: null, isAuthenticating: false, error: null }); - } else if (statusData.status === 'error') { - setState({ - provider: null, - isAuthenticating: false, - error: statusData.error || 'Authentication failed', - }); - } else { - // User closed popup before completing - setState({ - provider: null, - isAuthenticating: false, - error: 'Authentication cancelled', - }); - } - } catch { - setState({ - provider: null, - isAuthenticating: false, - error: 'Failed to check auth status', - }); - } - return; - } - - // Poll status while popup is open - try { - const statusRes = await fetch(`/api/cliproxy/get-auth-status?state=${authState}`); - const statusData = await statusRes.json(); - - if (statusData.status === 'ok') { - cleanup(); - queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] }); - toast.success(`${provider} authentication successful`); - setState({ provider: null, isAuthenticating: false, error: null }); - } else if (statusData.status === 'error') { - cleanup(); - setState({ - provider: null, - isAuthenticating: false, - error: statusData.error || 'Authentication failed', - }); - } - // 'wait' status means keep polling - } catch { - // Silently ignore polling errors, will retry - } - }, POLL_INTERVAL_MS); - - // Timeout after 5 minutes - timeoutRef.current = setTimeout(() => { - cleanup(); - toast.error('Authentication timed out'); - setState({ - provider: null, - isAuthenticating: false, - error: 'Authentication timed out', - }); - }, AUTH_TIMEOUT_MS); } catch (error) { - cleanup(); + if (error instanceof Error && error.name === 'AbortError') { + setState({ provider: null, isAuthenticating: false, error: null }); + return; + } const message = error instanceof Error ? error.message : 'Authentication failed'; toast.error(message); setState({ provider: null, isAuthenticating: false, error: message }); } }, - [cleanup, queryClient] + [queryClient] ); const cancelAuth = useCallback(() => { - cleanup(); + abortControllerRef.current?.abort(); setState({ provider: null, isAuthenticating: false, error: null }); - }, [cleanup]); + }, []); return useMemo( () => ({ diff --git a/ui/src/hooks/use-cliproxy-stats.ts b/ui/src/hooks/use-cliproxy-stats.ts index 2e825e01..cc9cf222 100644 --- a/ui/src/hooks/use-cliproxy-stats.ts +++ b/ui/src/hooks/use-cliproxy-stats.ts @@ -3,6 +3,7 @@ */ import { useQuery } from '@tanstack/react-query'; +import type { ModelQuota, QuotaResult } from '@/lib/api-client'; /** Per-account usage statistics */ export interface AccountUsageStats { @@ -138,6 +139,10 @@ export interface CliproxyErrorLog { modified: number; /** Absolute path to the log file (injected by backend) */ absolutePath?: string; + /** HTTP status code extracted from log (injected by backend) */ + statusCode?: number; + /** Model name extracted from request body (injected by backend) */ + model?: string; } /** @@ -189,3 +194,39 @@ export function useCliproxyErrorLogContent(name: string | null) { staleTime: 60000, // Cache log content for 1 minute }); } + +// Re-export for consumers +export type { ModelQuota, QuotaResult }; + +/** + * Fetch account quota from API + */ +async function fetchAccountQuota(provider: string, accountId: string): Promise { + const response = await fetch(`/api/cliproxy/quota/${provider}/${encodeURIComponent(accountId)}`); + if (!response.ok) { + let message = 'Failed to fetch 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(); +} + +/** + * Hook to get account quota + * Only enabled for 'agy' provider (Antigravity) as it's the only one supporting quota + */ +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, + staleTime: 30000, // Consider stale after 30s (tokens can refresh anytime) + refetchInterval: 60000, // Refresh every 1 minute + retry: 1, + }); +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 8145b054..0a003cb4 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -109,6 +109,32 @@ export interface CliproxyModelsResponse { totalCount: number; } +/** Individual model quota info from Google Cloud Code API */ +export interface ModelQuota { + /** Model name, e.g., "gemini-3-pro-high" */ + name: string; + /** Display name from API, e.g., "Gemini 3 Pro" */ + displayName?: string; + /** Remaining quota as percentage (0-100) */ + percentage: number; + /** ISO timestamp when quota resets, null if unknown */ + resetTime: string | null; +} + +/** Quota fetch result */ +export interface QuotaResult { + /** Whether fetch succeeded */ + success: boolean; + /** Quota for each available model */ + models: ModelQuota[]; + /** Timestamp of fetch */ + lastUpdated: number; + /** True if account lacks quota access (403) */ + isForbidden?: boolean; + /** Error message if fetch failed */ + error?: string; +} + /** Provider accounts summary */ export type ProviderAccountsMap = Record; @@ -414,4 +440,10 @@ export const api = { body: JSON.stringify(params), }), }, + /** Account quota API */ + quota: { + /** Fetch quota for a specific account */ + get: (provider: string, accountId: string) => + request(`/cliproxy/quota/${provider}/${encodeURIComponent(accountId)}`), + }, }; diff --git a/ui/src/lib/error-log-parser.ts b/ui/src/lib/error-log-parser.ts index 3afa030c..ff2116b9 100644 --- a/ui/src/lib/error-log-parser.ts +++ b/ui/src/lib/error-log-parser.ts @@ -29,6 +29,11 @@ export interface ParsedErrorLog { isClientError: boolean; isServerError: boolean; errorType: 'rate_limit' | 'auth' | 'not_found' | 'server' | 'timeout' | 'unknown'; + + // Extracted from request/response bodies + model: string | null; + quotaResetDelay: number | null; // seconds until reset + quotaResetTimestamp: string | null; // ISO timestamp when quota resets } /** Parsed filename metadata */ @@ -57,9 +62,10 @@ export function parseFilename(name: string): ParsedFilename { result.provider = providerMatch[1]; } - // Extract endpoint from after provider: ...-api-{ENDPOINT}-{timestamp} - // Example: error-api-provider-agy-api-event_logging-batch-2025-12-18T185041-... - const endpointMatch = name.match(/-api-([a-z_]+(?:-[a-z_]+)*)-\d{4}-\d{2}-\d{2}T/i); + // Extract endpoint: after provider, before timestamp + // Format: error-api-provider-{provider}-{endpoint}-{timestamp}-{id}.log + // Example: error-api-provider-agy-v1-messages-2025-12-29T105823-a12b73f8.log + const endpointMatch = name.match(/error-api-provider-[^-]+-(.+?)-\d{4}-\d{2}-\d{2}T/); if (endpointMatch) { result.endpoint = endpointMatch[1].replace(/-/g, '/'); } @@ -96,6 +102,9 @@ export function parseErrorLog(content: string): ParsedErrorLog { isClientError: false, isServerError: false, errorType: 'unknown', + model: null, + quotaResetDelay: null, + quotaResetTimestamp: null, }; // Split into sections @@ -114,6 +123,10 @@ export function parseErrorLog(content: string): ParsedErrorLog { } else if (part === 'REQUEST BODY') { currentSection = 'request_body'; continue; + } else if (part === 'API RESPONSE') { + // Skip API RESPONSE section - we parse the actual RESPONSE section instead + currentSection = ''; + continue; } else if (part === 'RESPONSE') { currentSection = 'response'; continue; @@ -229,8 +242,9 @@ function computeDerivedFields(result: ParsedErrorLog): void { result.provider = providerMatch[1]; } - // Extract endpoint from URL - const endpointMatch = result.url.match(/\/api\/provider\/[^/]+\/api\/(.+)/); + // Extract endpoint from URL: /api/provider/{provider}/{version}/{endpoint} + // e.g., /api/provider/agy/v1/messages?beta=true → v1/messages + const endpointMatch = result.url.match(/\/api\/provider\/[^/]+\/(.+?)(?:\?|$)/); if (endpointMatch) { result.endpoint = endpointMatch[1]; } @@ -251,6 +265,113 @@ function computeDerivedFields(result: ParsedErrorLog): void { } else if (result.statusCode === 408 || result.statusCode === 504) { result.errorType = 'timeout'; } + + // Extract model from request body + extractModelFromRequestBody(result); + + // Extract quota reset info from response body (for 429 errors) + if (result.statusCode === 429) { + extractQuotaResetInfo(result); + } +} + +/** Extract model name from request body JSON */ +function extractModelFromRequestBody(result: ParsedErrorLog): void { + if (!result.requestBody) return; + try { + const body = JSON.parse(result.requestBody); + if (typeof body.model === 'string') { + result.model = body.model; + } + } catch { + // Not valid JSON, skip + } +} + +/** Extract quota reset info from 429 response body */ +function extractQuotaResetInfo(result: ParsedErrorLog): void { + if (!result.responseBody) return; + try { + const body = JSON.parse(result.responseBody); + // Look for quotaResetDelay in various response formats + // Format 1: { error: { details: [{ quotaResetDelay: "123s" }] } } + // Format 2: { error: { quotaResetDelay: 123 } } + // Format 3: { quotaResetDelay: 123, quotaResetTimeStamp: "..." } + const delay = findQuotaResetDelay(body); + if (delay !== null) { + result.quotaResetDelay = delay; + } + const timestamp = findQuotaResetTimestamp(body); + if (timestamp) { + result.quotaResetTimestamp = timestamp; + } + } catch { + // Not valid JSON, skip + } +} + +/** Recursively find quotaResetDelay in response object */ +function findQuotaResetDelay(obj: unknown): number | null { + if (typeof obj !== 'object' || obj === null) return null; + + const record = obj as Record; + + // Check direct properties + if ('quotaResetDelay' in record) { + const val = record.quotaResetDelay; + if (typeof val === 'number') return val; + if (typeof val === 'string') { + // Handle "123s" format + const match = val.match(/^(\d+)s?$/); + if (match) return parseInt(match[1], 10); + } + } + + // Check nested error object + if ('error' in record && typeof record.error === 'object') { + const found = findQuotaResetDelay(record.error); + if (found !== null) return found; + } + + // Check details array + if ('details' in record && Array.isArray(record.details)) { + for (const detail of record.details) { + const found = findQuotaResetDelay(detail); + if (found !== null) return found; + } + } + + return null; +} + +/** Recursively find quotaResetTimeStamp in response object */ +function findQuotaResetTimestamp(obj: unknown): string | null { + if (typeof obj !== 'object' || obj === null) return null; + + const record = obj as Record; + + // Check direct properties (various casing) + for (const key of ['quotaResetTimeStamp', 'quotaResetTimestamp', 'resetTime', 'reset_time']) { + if (key in record && typeof record[key] === 'string') { + return record[key] as string; + } + } + + // Check nested error object + if ('error' in record && typeof record.error === 'object') { + const found = findQuotaResetTimestamp(record.error); + if (found) return found; + } + + // Check details array + if ('details' in record && Array.isArray(record.details)) { + for (const detail of record.details) { + const found = findQuotaResetTimestamp(detail); + if (found) return found; + } + } + + return null; } /** Get status text for common codes */ @@ -326,3 +447,44 @@ export function getErrorTypeLabel(type: ParsedErrorLog['errorType']): string { }; return labels[type] || 'Error'; } + +/** + * Format quota reset delay as human-readable string + */ +export function formatQuotaResetDelay(seconds: number | null): string | null { + if (seconds === null || seconds <= 0) return null; + + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) { + const mins = Math.floor(seconds / 60); + return `${mins}m`; + } + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; +} + +/** + * Format quota reset timestamp as relative time + */ +export function formatQuotaResetTimestamp(timestamp: string | null): string | null { + if (!timestamp) return null; + try { + const resetDate = new Date(timestamp); + const now = new Date(); + const diff = resetDate.getTime() - now.getTime(); + if (diff <= 0) return 'now'; + + const seconds = Math.floor(diff / 1000); + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) { + const mins = Math.floor(seconds / 60); + return `${mins}m`; + } + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + return mins > 0 ? `${hours}h ${mins}m` : `${hours}h`; + } catch { + return null; + } +} diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 29bdb99d..2e1a70ee 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -54,3 +54,65 @@ export function getProviderColor(provider: string): string { const normalized = provider.toLowerCase(); return PROVIDER_COLORS[normalized] || getModelColor(provider); } + +/** + * Sort models with Claude models first, then alphabetically + * Prioritizes: Claude > Gemini > GPT > Other (alphabetically) + */ +export function sortModelsByPriority( + models: T[] +): T[] { + const getPriority = (model: T): number => { + const name = (model.displayName || model.name).toLowerCase(); + if (name.includes('claude')) return 0; + if (name.includes('gemini')) return 1; + if (name.includes('gpt')) return 2; + return 3; + }; + + return [...models].sort((a, b) => { + const priorityDiff = getPriority(a) - getPriority(b); + if (priorityDiff !== 0) return priorityDiff; + // Same priority: sort alphabetically by display name + const nameA = (a.displayName || a.name).toLowerCase(); + const nameB = (b.displayName || b.name).toLowerCase(); + return nameA.localeCompare(nameB); + }); +} + +/** + * Format reset time as relative time (e.g., "in 2h 30m") + */ +export function formatResetTime(resetTime: string | null): string | null { + if (!resetTime) return null; + try { + const reset = new Date(resetTime); + const now = new Date(); + const diff = reset.getTime() - now.getTime(); + if (diff <= 0) return 'soon'; + + const hours = Math.floor(diff / (1000 * 60 * 60)); + const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60)); + + if (hours > 0) return `in ${hours}h ${minutes}m`; + return `in ${minutes}m`; + } catch { + return null; + } +} + +/** + * Get earliest reset time from models array + */ +export function getEarliestResetTime( + models: T[] +): string | null { + return models.reduce( + (earliest, m) => { + if (!m.resetTime) return earliest; + if (!earliest) return m.resetTime; + return new Date(m.resetTime) < new Date(earliest) ? m.resetTime : earliest; + }, + null as string | null + ); +}