From 205b5ab71fe560cdc8eed046ae133d40343df156 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Sun, 28 Dec 2025 19:24:12 -0500 Subject: [PATCH 01/12] feat(cliproxy): add account quota display for Antigravity provider - Add quota-fetcher.ts for Google Cloud Code API integration - Add REST endpoint GET /api/cliproxy/quota/:provider/:accountId - Add useAccountQuota hook with React Query caching - Display quota bar in AccountItem with per-model tooltip - Create reusable Progress component - Consolidate quota types in api-client.ts --- src/cliproxy/index.ts | 4 + src/cliproxy/quota-fetcher.ts | 313 ++++++++++++++++++ .../routes/cliproxy-stats-routes.ts | 48 +++ .../cliproxy/provider-editor/account-item.tsx | 209 +++++++++--- .../provider-editor/accounts-section.tsx | 4 + .../cliproxy/provider-editor/index.tsx | 1 + .../provider-editor/model-config-tab.tsx | 4 + .../cliproxy/provider-editor/types.ts | 2 + ui/src/components/ui/progress.tsx | 40 +++ ui/src/hooks/use-cliproxy-stats.ts | 31 ++ ui/src/lib/api-client.ts | 32 ++ 11 files changed, 637 insertions(+), 51 deletions(-) create mode 100644 src/cliproxy/quota-fetcher.ts create mode 100644 ui/src/components/ui/progress.tsx diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index e42705be..5e327180 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -111,6 +111,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..f4d7c05b --- /dev/null +++ b/src/cliproxy/quota-fetcher.ts @@ -0,0 +1,313 @@ +/** + * 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; +} + +/** Google Cloud Code API endpoints */ +const ANTIGRAVITY_API_BASE = 'https://cloudcode-pa.googleapis.com'; +const ANTIGRAVITY_API_VERSION = 'v1internal'; + +/** API client headers */ +const ANTIGRAVITY_HEADERS = { + 'Content-Type': 'application/json', + 'User-Agent': 'antigravity/1.11.5 linux/amd64', + 'X-Goog-Api-Client': 'gl-node/20.9.0', +}; + +/** Auth file structure */ +interface AntigravityAuthFile { + access_token: string; + refresh_token?: string; + email?: string; + expired?: string; + expires_in?: number; + timestamp?: number; + type?: 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; +} + +/** + * Read access token from auth file + */ +function readAccessToken(provider: CLIProxyProvider, accountId: string): string | null { + const authDir = getAuthDir(); + + // Account ID format: email with @ and . replaced by _ + // Try to find matching token file + const files = fs.readdirSync(authDir); + const prefix = provider === 'agy' ? 'antigravity-' : `${provider}-`; + + for (const file of files) { + if (file.startsWith(prefix) && file.endsWith('.json')) { + // Check if this file matches the account ID + const baseName = file.replace(prefix, '').replace('.json', ''); + if (baseName === accountId || file === accountId || file === `${accountId}.json`) { + const filePath = path.join(authDir, file); + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const data = JSON.parse(content) as AntigravityAuthFile; + return data.access_token || null; + } catch { + return null; + } + } + } + } + + return null; +} + +/** + * Get project ID via loadCodeAssist endpoint + */ +async function getProjectId(accessToken: string): Promise { + 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: { + ...ANTIGRAVITY_HEADERS, + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + metadata: { + ideType: 'IDE_UNSPECIFIED', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI', + }, + }), + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + return null; + } + + 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; + } + + return projectId?.trim() || null; + } catch { + clearTimeout(timeoutId); + return null; + } +} + +/** + * Fetch available models with quota info + */ +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 { + const response = await fetch(url, { + method: 'POST', + signal: controller.signal, + headers: { + ...ANTIGRAVITY_HEADERS, + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + project: projectId, + }), + }); + + 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; + + if (typeof remaining !== 'number') continue; + + // Convert to percentage (0-100) + const percentage = 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 with _ replacing @ and .) + * @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 access token from auth file + const accessToken = readAccessToken(provider, accountId); + if (!accessToken) { + return { + success: false, + models: [], + lastUpdated: Date.now(), + error: 'Access token not found for account', + }; + } + + // Get project ID first + const projectId = await getProjectId(accessToken); + if (!projectId) { + return { + success: false, + models: [], + lastUpdated: Date.now(), + error: 'Failed to retrieve project ID', + }; + } + + // Fetch models with quota + return fetchAvailableModels(accessToken, projectId); +} diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index a4ac196c..729ebd4e 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, getConfigPath, @@ -430,4 +432,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/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 589353ee..e2d008de 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -1,88 +1,195 @@ /** * 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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { User, Star, MoreHorizontal, Clock, Trash2, Loader2 } from 'lucide-react'; import { cn } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; +import { useAccountQuota } from '@/hooks/use-cliproxy-stats'; import type { AccountItemProps } from './types'; +/** + * Format reset time as relative time (e.g., "in 2 hours") + */ +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 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'; +} + export function AccountItem({ account, onSetDefault, onRemove, isRemoving, privacyMode, + showQuota, }: AccountItemProps) { + // Fetch quota for 'agy' provider accounts + const { data: quota, isLoading: quotaLoading } = useAccountQuota( + account.provider, + account.id, + showQuota && account.provider === 'agy' + ); + + // 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 + ? quota.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 + ) + : 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 ? ( + + + +
+ + {avgQuota}% +
+
+ +
+

Model Quotas:

+ {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 7d398ac3..939777a8 100644 --- a/ui/src/components/cliproxy/provider-editor/accounts-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/accounts-section.tsx @@ -16,6 +16,8 @@ interface AccountsSectionProps { onRemoveAccount: (accountId: string) => void; isRemovingAccount?: boolean; privacyMode?: boolean; + /** Show quota bars for accounts (only applicable for 'agy' provider) */ + showQuota?: boolean; } export function AccountsSection({ @@ -25,6 +27,7 @@ export function AccountsSection({ onRemoveAccount, isRemovingAccount, privacyMode, + showQuota, }: AccountsSectionProps) { return (
@@ -54,6 +57,7 @@ export function AccountsSection({ onRemove={() => onRemoveAccount(account.id)} isRemoving={isRemovingAccount} privacyMode={privacyMode} + showQuota={showQuota} /> ))}
diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index ab278d5e..d1c13f43 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -177,6 +177,7 @@ export function ProviderEditor({ onRemoveAccount={onRemoveAccount} isRemovingAccount={isRemovingAccount} privacyMode={privacyMode} + provider={provider} /> void; isRemovingAccount?: boolean; privacyMode?: boolean; + /** Provider name for quota display */ + provider?: string; } export function ModelConfigTab({ @@ -56,6 +58,7 @@ export function ModelConfigTab({ onRemoveAccount, isRemovingAccount, privacyMode, + provider, }: ModelConfigTabProps) { return ( @@ -82,6 +85,7 @@ export function ModelConfigTab({ onRemoveAccount={onRemoveAccount} isRemovingAccount={isRemovingAccount} privacyMode={privacyMode} + showQuota={provider === 'agy'} />
diff --git a/ui/src/components/cliproxy/provider-editor/types.ts b/ui/src/components/cliproxy/provider-editor/types.ts index 6bea9288..600016f4 100644 --- a/ui/src/components/cliproxy/provider-editor/types.ts +++ b/ui/src/components/cliproxy/provider-editor/types.ts @@ -35,6 +35,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/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-stats.ts b/ui/src/hooks/use-cliproxy-stats.ts index 2e825e01..9f93ec2c 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 { @@ -189,3 +190,33 @@ 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) { + const error = await response.json(); + throw new Error(error.message || 'Failed to fetch quota'); + } + 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: 60000, // Cache for 1 minute + refetchInterval: 300000, // Refresh every 5 minutes + retry: 1, + }); +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 87013507..8dbb2d31 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -107,6 +107,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; @@ -404,4 +430,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)}`), + }, }; From 3531991b5ddeb9678927c140383c1588a3898d16 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 29 Dec 2025 10:07:45 -0500 Subject: [PATCH 02/12] fix(ui): remove duplicate provider prop in ModelConfigTab --- ui/src/components/cliproxy/provider-editor/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index ed864a86..8cd38baf 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -186,7 +186,6 @@ export function ProviderEditor({ onRemoveAccount={onRemoveAccount} isRemovingAccount={isRemovingAccount} privacyMode={privacyMode} - provider={provider} /> Date: Mon, 29 Dec 2025 13:03:37 -0500 Subject: [PATCH 03/12] feat(ui): replace misleading token expiry with runtime-based status - Remove "Token expired" warning (showed stale file state, not runtime) - Add "Active/Last used" status based on CLIProxyAPI runtime stats - Show green checkmark for recently used accounts (within 1h) - Show "Not used yet" for accounts without usage stats - Remove expired warning from flow-viz account cards - Add model quota sorting (Claude > Gemini > GPT > other) - Add quota reset time display in tooltips - Fix re-auth button to use correct CCS endpoint - Reduce quota cache staleness (30s stale, 1m refresh) CLIProxyAPI intentionally doesn't persist refreshed tokens to disk (to prevent refresh loops), so file-based expiry was misleading. Dashboard now shows truthful operational state from runtime stats. --- src/cliproxy/quota-fetcher.ts | 150 ++++++++++++--- .../account/flow-viz/account-card.tsx | 94 ++++++++- .../cliproxy/provider-editor/account-item.tsx | 182 ++++++++++++------ ui/src/hooks/use-cliproxy-auth-flow.ts | 164 ++++------------ ui/src/hooks/use-cliproxy-stats.ts | 4 +- ui/src/lib/utils.ts | 62 ++++++ 6 files changed, 427 insertions(+), 229 deletions(-) diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index 89c70b1a..9380fdfb 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -34,6 +34,10 @@ export interface QuotaResult { 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; } /** Google Cloud Code API endpoints */ @@ -56,6 +60,15 @@ interface AntigravityAuthFile { expires_in?: number; timestamp?: number; type?: string; + project_id?: string; +} + +/** Auth data returned from file */ +interface AuthData { + accessToken: string; + projectId: string | null; + isExpired: boolean; + expiresAt: string | null; } /** loadCodeAssist response */ @@ -89,9 +102,30 @@ interface FetchAvailableModelsResponse { } /** - * Read access token from auth file + * Sanitize email to match CLIProxyAPI auth file naming convention + * Replaces @ and . with underscores (matches Go sanitizeAntigravityFileName) */ -function readAccessToken(provider: CLIProxyProvider, accountId: string): string | null { +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; + } +} + +/** + * 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 @@ -99,24 +133,48 @@ function readAccessToken(provider: CLIProxyProvider, accountId: string): string return null; } - // Account ID format: email with @ and . replaced by _ - // Try to find matching token file - const files = fs.readdirSync(authDir); + // 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, + 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')) { - // Check if this file matches the account ID - const baseName = file.replace(prefix, '').replace('.json', ''); - if (baseName === accountId || file === accountId || file === `${accountId}.json`) { - const filePath = path.join(authDir, file); - try { - const content = fs.readFileSync(filePath, 'utf-8'); - const data = JSON.parse(content) as AntigravityAuthFile; - return data.access_token || null; - } catch { - return null; + 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, + projectId: data.project_id || null, + isExpired: isTokenExpired(data.expired), + expiresAt: data.expired || null, + }; } + } catch { + continue; } } } @@ -127,7 +185,9 @@ function readAccessToken(provider: CLIProxyProvider, accountId: string): string /** * Get project ID via loadCodeAssist endpoint */ -async function getProjectId(accessToken: string): Promise { +async function getProjectId( + accessToken: string +): Promise<{ projectId: string | null; error?: string }> { const url = `${ANTIGRAVITY_API_BASE}/${ANTIGRAVITY_API_VERSION}:loadCodeAssist`; const controller = new AbortController(); @@ -153,7 +213,14 @@ async function getProjectId(accessToken: string): Promise { clearTimeout(timeoutId); if (!response.ok) { - return null; + // 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; @@ -166,10 +233,17 @@ async function getProjectId(accessToken: string): Promise { projectId = data.cloudaicompanionProject?.id; } - return projectId?.trim() || null; - } catch { + if (!projectId?.trim()) { + return { projectId: null, error: 'No project ID in response' }; + } + + return { projectId: projectId.trim() }; + } catch (err) { clearTimeout(timeoutId); - return null; + if (err instanceof Error && err.name === 'AbortError') { + return { projectId: null, error: 'Request timeout' }; + } + return { projectId: null, error: err instanceof Error ? err.message : 'Unknown error' }; } } @@ -275,7 +349,7 @@ async function fetchAvailableModels(accessToken: string, projectId: string): Pro * Fetch quota for an Antigravity account * * @param provider - Provider name (only 'agy' supported) - * @param accountId - Account identifier (email with _ replacing @ and .) + * @param accountId - Account identifier (email) * @returns Quota result with models and percentages */ export async function fetchAccountQuota( @@ -292,28 +366,44 @@ export async function fetchAccountQuota( }; } - // Read access token from auth file - const accessToken = readAccessToken(provider, accountId); - if (!accessToken) { + // Read auth data from auth file + const authData = readAuthData(provider, accountId); + if (!authData) { return { success: false, models: [], lastUpdated: Date.now(), - error: 'Access token not found for account', + error: 'Auth file not found for account', }; } - // Get project ID first - const projectId = await getProjectId(accessToken); - if (!projectId) { + // Check if token is expired + if (authData.isExpired) { return { success: false, models: [], lastUpdated: Date.now(), - error: 'Failed to retrieve project ID', + isExpired: true, + expiresAt: authData.expiresAt || undefined, + error: 'Token expired', }; } + // Get project ID - prefer stored value, fallback to API call + let projectId = authData.projectId; + if (!projectId) { + const projectResult = await getProjectId(authData.accessToken); + if (!projectResult.projectId) { + return { + success: false, + models: [], + lastUpdated: Date.now(), + error: projectResult.error || 'Failed to retrieve project ID', + }; + } + projectId = projectResult.projectId; + } + // Fetch models with quota - return fetchAvailableModels(accessToken, projectId); + return fetchAvailableModels(authData.accessToken, projectId); } 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} +
+ )}
0) return `in ${hours}h ${minutes}m`; - return `in ${minutes}m`; - } catch { - return null; - } -} - /** * Get color class based on quota percentage */ @@ -49,6 +37,45 @@ function getQuotaColor(percentage: number): string { 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, @@ -57,6 +84,9 @@ export function AccountItem({ 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, @@ -64,6 +94,10 @@ export function AccountItem({ 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 @@ -72,16 +106,7 @@ export function AccountItem({ // Get earliest reset time const nextReset = - quota?.success && quota.models.length > 0 - ? quota.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 - ) - : null; + quota?.success && quota.models.length > 0 ? getEarliestResetTime(quota.models) : null; return (
Loading quota...
) : avgQuota !== null ? ( - - - -
- - {avgQuota}% -
-
- -
-

Model Quotas:

- {quota?.models.map((m) => ( -
- {m.displayName || m.name} - {m.percentage}% -
- ))} - {nextReset && ( -

- Resets {formatResetTime(nextReset)} -

- )} -
-
-
-
+
+ {/* 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/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 63e9c2a6..014e5f4e 100644 --- a/ui/src/hooks/use-cliproxy-stats.ts +++ b/ui/src/hooks/use-cliproxy-stats.ts @@ -221,8 +221,8 @@ export function useAccountQuota(provider: string, accountId: string, enabled = t queryKey: ['account-quota', provider, accountId], queryFn: () => fetchAccountQuota(provider, accountId), enabled: enabled && provider === 'agy' && !!accountId, - staleTime: 60000, // Cache for 1 minute - refetchInterval: 300000, // Refresh every 5 minutes + staleTime: 30000, // Consider stale after 30s (tokens can refresh anytime) + refetchInterval: 60000, // Refresh every 1 minute retry: 1, }); } 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 + ); +} From 4233415095d7a56ebd98cb0f76a95e37ce25ddea Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 29 Dec 2025 13:15:51 -0500 Subject: [PATCH 04/12] fix(ui): replace misleading 'Expires' with 'Last used' in credential health - credential-health-list.tsx showed 'Expires: Expired' based on stale file state (CLIProxyAPIPlus intentionally doesn't persist refreshed token expiry to disk) - Now shows runtime-based 'Last used: Xm/h/d ago' from useCliproxyStats - Consistent with account-item.tsx changes from previous commit --- .../overview/credential-health-list.tsx | 62 ++++++++++++------- 1 file changed, 38 insertions(+), 24 deletions(-) diff --git a/ui/src/components/cliproxy/overview/credential-health-list.tsx b/ui/src/components/cliproxy/overview/credential-health-list.tsx index a1876ee9..5d08146a 100644 --- a/ui/src/components/cliproxy/overview/credential-health-list.tsx +++ b/ui/src/components/cliproxy/overview/credential-health-list.tsx @@ -6,8 +6,9 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; -import { CheckCircle2, AlertCircle, XCircle, MinusCircle, RefreshCw } from 'lucide-react'; +import { CheckCircle2, AlertCircle, XCircle, MinusCircle, RefreshCw, Clock } from 'lucide-react'; import { useCliproxyAuth } from '@/hooks/use-cliproxy'; +import { useCliproxyStats } from '@/hooks/use-cliproxy-stats'; import { cn } from '@/lib/utils'; import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; @@ -19,7 +20,7 @@ interface CredentialRowProps { status: CredentialStatus; statusMessage: string; email?: string; - expiresAt?: string; + lastUsedAt?: string; onRefresh?: () => 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) { From 739270aac40f23239bd85a07dab30c20a3fab80a Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 29 Dec 2025 13:18:02 -0500 Subject: [PATCH 05/12] fix(quota): remove misleading token expiration check in quota fetcher Backend was returning 'Token expired' error based on stale file state without attempting API call. CLIProxyAPIPlus refreshes tokens at runtime but intentionally doesn't persist to disk, making file-based expiration checks always misleading. Now quota fetcher attempts API call regardless of file expiration state. If token is truly invalid, API returns 401 which is handled properly. --- src/cliproxy/quota-fetcher.ts | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index 9380fdfb..98d6db49 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -377,17 +377,10 @@ export async function fetchAccountQuota( }; } - // Check if token is expired - if (authData.isExpired) { - return { - success: false, - models: [], - lastUpdated: Date.now(), - isExpired: true, - expiresAt: authData.expiresAt || undefined, - error: 'Token expired', - }; - } + // Note: We don't check isExpired here because: + // 1. CLIProxyAPIPlus refreshes tokens at runtime but doesn't persist to disk + // 2. File-based expiration is always stale and misleading + // 3. If token is truly invalid, the API call below will fail with 401 // Get project ID - prefer stored value, fallback to API call let projectId = authData.projectId; From 4be8e927a08bbdcca02d000a9780e8466f0fc1f0 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 29 Dec 2025 13:25:14 -0500 Subject: [PATCH 06/12] feat(quota): add OAuth token refresh for independent quota fetching CCS can now fetch quota independently of CLIProxyAPI by refreshing access tokens using the refresh_token stored in auth files. - Add refreshAccessToken() using Google OAuth token endpoint - Use public Antigravity OAuth credentials (from CLIProxyAPIPlus) - Proactively refresh token before API calls to avoid stale tokens - Fallback retry on auth errors This fixes quota showing "Access token expired" for accounts whose file-based access_token is stale (CLIProxyAPI refreshes at runtime but doesn't persist to disk). --- src/cliproxy/quota-fetcher.ts | 130 ++++++++++++++++++++++++++++++---- 1 file changed, 117 insertions(+), 13 deletions(-) diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index 98d6db49..1dbba816 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -44,6 +44,14 @@ export interface QuotaResult { 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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'; + /** API client headers */ const ANTIGRAVITY_HEADERS = { 'Content-Type': 'application/json', @@ -66,11 +74,21 @@ interface AntigravityAuthFile { /** 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 }; @@ -122,6 +140,56 @@ function isTokenExpired(expiredStr?: string): boolean { } } +/** + * 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) */ @@ -147,6 +215,7 @@ function readAuthData(provider: CLIProxyProvider, accountId: string): AuthData | 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, @@ -168,6 +237,7 @@ function readAuthData(provider: CLIProxyProvider, accountId: string): AuthData | 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, @@ -377,26 +447,60 @@ export async function fetchAccountQuota( }; } - // Note: We don't check isExpired here because: - // 1. CLIProxyAPIPlus refreshes tokens at runtime but doesn't persist to disk - // 2. File-based expiration is always stale and misleading - // 3. If token is truly invalid, the API call below will fail with 401 + // Determine which access token to use + // File-based token is often stale (CLIProxyAPIPlus refreshes at runtime but doesn't persist) + // If we have refresh_token, proactively refresh to get a fresh access_token + let accessToken = authData.accessToken; + + if (authData.refreshToken) { + // Always refresh to ensure we have a valid token + // This is necessary because CLIProxyAPIPlus doesn't persist refreshed tokens + 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) { - const projectResult = await getProjectId(authData.accessToken); + const projectResult = await getProjectId(accessToken); if (!projectResult.projectId) { - return { - success: false, - models: [], - lastUpdated: Date.now(), - error: projectResult.error || 'Failed to retrieve project ID', - }; + // 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; + const retryResult = await getProjectId(accessToken); + if (retryResult.projectId) { + projectId = retryResult.projectId; + } + } + } + if (!projectId) { + return { + success: false, + models: [], + lastUpdated: Date.now(), + error: projectResult.error || 'Failed to retrieve project ID', + }; + } + } else { + projectId = projectResult.projectId; } - projectId = projectResult.projectId; } // Fetch models with quota - return fetchAvailableModels(authData.accessToken, projectId); + 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; } From e3a71fc89372e81af3c425c5bf8e42630b4c1b6b Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 29 Dec 2025 13:40:17 -0500 Subject: [PATCH 07/12] feat(error-logs): extract model and quota reset info from error logs - Add model, quotaResetDelay, quotaResetTimestamp fields to ParsedErrorLog - Extract model from requestBody JSON - Parse quota reset delay/timestamp from 429 response bodies - Display model prominently in Overview tab with violet highlight - Show quota reset countdown for rate limit errors - Improve actionable suggestions with color-coded error types --- .../monitoring/error-logs/tab-components.tsx | 92 +++++++++-- ui/src/lib/error-log-parser.ts | 156 ++++++++++++++++++ 2 files changed, 237 insertions(+), 11 deletions(-) diff --git a/ui/src/components/monitoring/error-logs/tab-components.tsx b/ui/src/components/monitoring/error-logs/tab-components.tsx index 42043731..00f4de41 100644 --- a/ui/src/components/monitoring/error-logs/tab-components.tsx +++ b/ui/src/components/monitoring/error-logs/tab-components.tsx @@ -4,13 +4,22 @@ */ 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 */} @@ -22,6 +31,32 @@ export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
+ {/* 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 */}
@@ -58,21 +93,56 @@ export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) {
{parsed.timestamp || 'N/A'}
- {/* Suggestion based on error type */} + {/* Actionable suggestion based on error type */} {parsed.errorType !== 'unknown' && ( -
- -
- {parsed.errorType === 'rate_limit' && - 'Rate limited. Consider using multiple accounts or reducing request frequency.'} +
+ {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. Check credentials or re-authenticate with the provider.'} + '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 or check provider status.'} + 'Server error from upstream. Retry later or check provider status page.'} {parsed.errorType === 'timeout' && - 'Request timed out. Check network or increase timeout settings.'} + 'Request timed out. Check network connection or increase timeout settings.'}
)} diff --git a/ui/src/lib/error-log-parser.ts b/ui/src/lib/error-log-parser.ts index 3afa030c..5e29fd64 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 */ @@ -96,6 +101,9 @@ export function parseErrorLog(content: string): ParsedErrorLog { isClientError: false, isServerError: false, errorType: 'unknown', + model: null, + quotaResetDelay: null, + quotaResetTimestamp: null, }; // Split into sections @@ -251,6 +259,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 +441,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; + } +} From ecfdcdef782c429e2e125598d11ef7d974e68ae2 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 29 Dec 2025 13:47:11 -0500 Subject: [PATCH 08/12] fix(quota): add unprovisioned account detection with actionable message - Add isUnprovisioned flag to QuotaResult interface - Detect when account is authenticated but lacks project ID - Show actionable message: "Sign in to Antigravity app to activate quota." - Refactor getProjectId retry logic for cleaner error propagation --- src/cliproxy/quota-fetcher.ts | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index 1dbba816..5a198401 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -38,6 +38,8 @@ export interface QuotaResult { 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 */ @@ -257,7 +259,7 @@ function readAuthData(provider: CLIProxyProvider, accountId: string): AuthData | */ async function getProjectId( accessToken: string -): Promise<{ projectId: string | null; error?: string }> { +): Promise<{ projectId: string | null; error?: string; isUnprovisioned?: boolean }> { const url = `${ANTIGRAVITY_API_BASE}/${ANTIGRAVITY_API_VERSION}:loadCodeAssist`; const controller = new AbortController(); @@ -304,7 +306,12 @@ async function getProjectId( } if (!projectId?.trim()) { - return { projectId: null, error: 'No project ID in response' }; + // 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() }; @@ -465,30 +472,27 @@ export async function fetchAccountQuota( // Get project ID - prefer stored value, fallback to API call let projectId = authData.projectId; if (!projectId) { - const projectResult = await getProjectId(accessToken); - if (!projectResult.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; - const retryResult = await getProjectId(accessToken); - if (retryResult.projectId) { - projectId = retryResult.projectId; - } + lastProjectResult = await getProjectId(accessToken); } } - if (!projectId) { + if (!lastProjectResult.projectId) { return { success: false, models: [], lastUpdated: Date.now(), - error: projectResult.error || 'Failed to retrieve project ID', + error: lastProjectResult.error || 'Failed to retrieve project ID', + isUnprovisioned: lastProjectResult.isUnprovisioned, }; } - } else { - projectId = projectResult.projectId; } + projectId = lastProjectResult.projectId; } // Fetch models with quota From 19550b28f0087ec81925076d10205ce333c37799 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 29 Dec 2025 13:53:24 -0500 Subject: [PATCH 09/12] fix(error-logs): fix endpoint regex for v1/messages URL format --- ui/src/lib/error-log-parser.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ui/src/lib/error-log-parser.ts b/ui/src/lib/error-log-parser.ts index 5e29fd64..3bf8de82 100644 --- a/ui/src/lib/error-log-parser.ts +++ b/ui/src/lib/error-log-parser.ts @@ -237,8 +237,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]; } From 00597b335887b9280b22d78d522146ee65e7037e Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 29 Dec 2025 13:57:21 -0500 Subject: [PATCH 10/12] feat(quota): implement proactive token refresh (5-min lead time) - Refresh token 5 minutes before expiry (matches CLIProxyAPIPlus) - Only refresh when: expired, no expiry info, or expiring within 5 min - Reduces unnecessary OAuth API calls when token is still valid --- src/cliproxy/quota-fetcher.ts | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index 5a198401..e2f7e014 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -456,17 +456,23 @@ export async function fetchAccountQuota( // Determine which access token to use // File-based token is often stale (CLIProxyAPIPlus refreshes at runtime but doesn't persist) - // If we have refresh_token, proactively refresh to get a fresh access_token + // 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) { - // Always refresh to ensure we have a valid token - // This is necessary because CLIProxyAPIPlus doesn't persist refreshed tokens - const refreshResult = await refreshAccessToken(authData.refreshToken); - if (refreshResult.accessToken) { - accessToken = refreshResult.accessToken; + 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) } - // If refresh fails, fall back to existing token (might still work) } // Get project ID - prefer stored value, fallback to API call From ac6f382f6a6cd64aa3fa0727d11bcf498aae28fc Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 29 Dec 2025 15:48:37 -0500 Subject: [PATCH 11/12] fix(quota,error-logs): match CLIProxyAPI headers and enhance error log display Quota fetcher: - loadCodeAssist headers now match antigravity.go exactly - fetchAvailableModels uses empty body {} and correct User-Agent - Prevents accounts being flagged for anomalous requests Error logs: - Extract status code from end of log (RESPONSE section) - Display full model names instead of abbreviated - Show status badges (500/429/4xx) with color coding - Add provider icons with white background --- src/cliproxy/quota-fetcher.ts | 29 ++- src/cliproxy/stats-fetcher.ts | 4 + .../routes/cliproxy-stats-routes.ts | 62 ++++- .../monitoring/error-logs/error-log-item.tsx | 101 ++++---- .../monitoring/error-logs/index.tsx | 2 + .../monitoring/error-logs/tab-components.tsx | 220 +++++++++--------- .../components/monitoring/error-logs/types.ts | 4 + ui/src/hooks/use-cliproxy-stats.ts | 4 + ui/src/lib/error-log-parser.ts | 11 +- 9 files changed, 266 insertions(+), 171 deletions(-) diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index e2f7e014..749d9393 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -54,11 +54,19 @@ const ANTIGRAVITY_CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com'; const ANTIGRAVITY_CLIENT_SECRET = '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'; -/** API client headers */ -const ANTIGRAVITY_HEADERS = { +/** Headers for loadCodeAssist (matches CLIProxyAPI antigravity.go) */ +const LOADCODEASSIST_HEADERS = { 'Content-Type': 'application/json', - 'User-Agent': 'antigravity/1.11.5 linux/amd64', - 'X-Goog-Api-Client': 'gl-node/20.9.0', + '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 */ @@ -270,7 +278,7 @@ async function getProjectId( method: 'POST', signal: controller.signal, headers: { - ...ANTIGRAVITY_HEADERS, + ...LOADCODEASSIST_HEADERS, Authorization: `Bearer ${accessToken}`, }, body: JSON.stringify({ @@ -326,24 +334,25 @@ async function getProjectId( /** * 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 { +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: { - ...ANTIGRAVITY_HEADERS, + ...FETCHMODELS_HEADERS, Authorization: `Bearer ${accessToken}`, }, - body: JSON.stringify({ - project: projectId, - }), + body: JSON.stringify({}), }); clearTimeout(timeoutId); 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 95d65263..ac11ca84 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -25,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 */ @@ -214,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 }); } 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 00f4de41..bba80c37 100644 --- a/ui/src/components/monitoring/error-logs/tab-components.tsx +++ b/ui/src/components/monitoring/error-logs/tab-components.tsx @@ -21,132 +21,134 @@ export function OverviewTab({ parsed }: { parsed: ParsedErrorLog }) { formatQuotaResetTimestamp(parsed.quotaResetTimestamp); return ( -
- {/* Status row */} -
- - {parsed.statusText} - - {getErrorTypeLabel(parsed.errorType)} - -
+ +
+ {/* Status row */} +
+ + {parsed.statusText} + + {getErrorTypeLabel(parsed.errorType)} + +
- {/* Model info - prominent display */} - {parsed.model && ( -
- -
- Model: - - {parsed.model} - + {/* 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'} +
- )} - {/* Quota reset info for 429 errors */} - {parsed.errorType === 'rate_limit' && quotaResetDisplay && ( -
- -
- Quota resets in - - {quotaResetDisplay} - + {/* URL */} +
+
URL
+
+ {parsed.url || 'N/A'}
- )} - {/* Key metrics grid */} -
-
-
Method
-
{parsed.method || 'N/A'}
+ {/* Timestamp */} +
+
Timestamp
+
{parsed.timestamp || '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'}
-
- - {/* Actionable suggestion based on error type */} - {parsed.errorType !== 'unknown' && ( -
- {parsed.errorType === 'rate_limit' ? ( - - ) : parsed.errorType === 'auth' ? ( - - ) : ( - - )} + {/* Actionable suggestion based on error type */} + {parsed.errorType !== 'unknown' && (
- {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 === 'rate_limit' ? ( + + ) : parsed.errorType === 'auth' ? ( + + ) : ( + )} - {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.'} +
+ {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/hooks/use-cliproxy-stats.ts b/ui/src/hooks/use-cliproxy-stats.ts index 014e5f4e..cc9cf222 100644 --- a/ui/src/hooks/use-cliproxy-stats.ts +++ b/ui/src/hooks/use-cliproxy-stats.ts @@ -139,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; } /** diff --git a/ui/src/lib/error-log-parser.ts b/ui/src/lib/error-log-parser.ts index 3bf8de82..ff2116b9 100644 --- a/ui/src/lib/error-log-parser.ts +++ b/ui/src/lib/error-log-parser.ts @@ -62,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, '/'); } @@ -122,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; From d7bac2391b72fd6b64cce1daa4e1772c94e511c6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 29 Dec 2025 20:51:35 +0000 Subject: [PATCH 12/12] chore(release): 7.9.0-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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",