From 6ccf6c5e138f6fdc847d47ef885dde39bc7aeeb1 Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Mon, 29 Dec 2025 13:03:37 -0500 Subject: [PATCH] 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 + ); +}