diff --git a/src/cliproxy/auth/gemini-token-refresh.ts b/src/cliproxy/auth/gemini-token-refresh.ts index c08799b8..543dc657 100644 --- a/src/cliproxy/auth/gemini-token-refresh.ts +++ b/src/cliproxy/auth/gemini-token-refresh.ts @@ -16,26 +16,18 @@ import { getProviderAuthDir } from '../config-generator'; import { getDefaultAccount, getProviderAccounts } from '../account-manager'; import { isTokenFileForProvider } from './token-manager'; -/** - * Gemini OAuth credentials - PUBLIC from official Gemini CLI source code - * These are not secrets - they're public OAuth client credentials that Google - * distributes with their official applications. See: - * https://github.com/google/generative-ai-python (Gemini CLI source) - * - * GitHub secret scanning may flag these, but they are intentionally hardcoded - * as they're required for OAuth token refresh and are publicly documented. - */ - -const GEMINI_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com'; - -const GEMINI_CLIENT_SECRET = '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'; - /** Google OAuth token endpoint */ const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; /** Refresh tokens 5 minutes before expiry */ const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000; +const GEMINI_CLIENT_ID_ENV_KEYS = ['CCS_GEMINI_OAUTH_CLIENT_ID', 'OPENCLAW_GEMINI_OAUTH_CLIENT_ID']; +const GEMINI_CLIENT_SECRET_ENV_KEYS = [ + 'CCS_GEMINI_OAUTH_CLIENT_SECRET', + 'OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET', +]; + /** Gemini oauth_creds.json structure */ interface GeminiOAuthCreds { access_token: string; @@ -44,6 +36,9 @@ interface GeminiOAuthCreds { scope?: string; token_type?: string; id_token?: string; + client_id?: string; + client_secret?: string; + token_uri?: string; } /** Gemini credentials with source path for write-back */ @@ -58,6 +53,9 @@ interface CliproxyGeminiToken { access_token: string; refresh_token?: string; expiry?: number; // Unix timestamp in milliseconds + client_id?: string; + client_secret?: string; + token_uri?: string; }; project_id: string; email: string; @@ -73,6 +71,12 @@ interface TokenRefreshResponse { error_description?: string; } +interface GoogleOAuthClientCredentials { + clientId: string; + clientSecret: string; + tokenUrl: string; +} + /** * Get path to Gemini OAuth credentials file */ @@ -89,6 +93,9 @@ function mapCliproxyToGeminiCreds(cliproxy: CliproxyGeminiToken): GeminiOAuthCre refresh_token: cliproxy.token.refresh_token, expiry_date: cliproxy.token.expiry, token_type: 'Bearer', + client_id: cliproxy.token.client_id, + client_secret: cliproxy.token.client_secret, + token_uri: cliproxy.token.token_uri, }; } @@ -104,6 +111,41 @@ function isValidCliproxyToken(data: unknown): data is CliproxyGeminiToken { return typeof token.access_token === 'string'; } +function getFirstEnvValue(keys: readonly string[]): string | undefined { + for (const key of keys) { + const value = process.env[key]?.trim(); + if (value) { + return value; + } + } + return undefined; +} + +function resolveGeminiRefreshCredentials(creds: GeminiOAuthCreds): { + credentials?: GoogleOAuthClientCredentials; + error?: string; +} { + const clientId = creds.client_id?.trim() || getFirstEnvValue(GEMINI_CLIENT_ID_ENV_KEYS); + const clientSecret = + creds.client_secret?.trim() || getFirstEnvValue(GEMINI_CLIENT_SECRET_ENV_KEYS); + + if (!clientId || !clientSecret) { + return { + error: + 'Gemini token refresh unavailable: missing OAuth client credentials in the token file. ' + + 'Re-authenticate with CLIProxy or set CCS_GEMINI_OAUTH_CLIENT_ID and CCS_GEMINI_OAUTH_CLIENT_SECRET.', + }; + } + + return { + credentials: { + clientId, + clientSecret, + tokenUrl: creds.token_uri?.trim() || GOOGLE_TOKEN_URL, + }, + }; +} + /** * Read Gemini token from CLIProxy auth directory * Returns credentials with source path, or null if no valid token found @@ -294,11 +336,17 @@ export async function refreshGeminiToken(accountId?: string): Promise<{ } const { creds, sourcePath } = result; + const resolvedCredentials = resolveGeminiRefreshCredentials(creds); + if (!resolvedCredentials.credentials) { + return { success: false, error: resolvedCredentials.error }; + } + + const { clientId, clientSecret, tokenUrl } = resolvedCredentials.credentials; const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 10000); try { - const response = await fetch(GOOGLE_TOKEN_URL, { + const response = await fetch(tokenUrl, { method: 'POST', signal: controller.signal, headers: { @@ -307,8 +355,8 @@ export async function refreshGeminiToken(accountId?: string): Promise<{ body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: creds.refresh_token as string, // Already validated above - client_id: GEMINI_CLIENT_ID, - client_secret: GEMINI_CLIENT_SECRET, + client_id: clientId, + client_secret: clientSecret, }).toString(), }); diff --git a/src/cliproxy/quota-fetcher-codex.ts b/src/cliproxy/quota-fetcher-codex.ts index bc83f178..52ef60cc 100644 --- a/src/cliproxy/quota-fetcher-codex.ts +++ b/src/cliproxy/quota-fetcher-codex.ts @@ -16,6 +16,7 @@ import type { CodexQuotaResult, CodexQuotaWindow, CodexCoreUsageSummary } from ' const CODEX_API_BASE = 'https://chatgpt.com/backend-api'; const CODEX_QUOTA_TIMEOUT_MS = 12000; const CODEX_QUOTA_MAX_ATTEMPTS = 2; +const CODEX_ERROR_DETAIL_MAX_LENGTH = 240; /** * User agent matching Codex CLI for API compatibility. @@ -57,6 +58,12 @@ interface CodexWindowData { resetAfterSeconds?: number | null; } +interface ParsedCodexErrorBody { + errorCode?: string; + errorDetail?: string; + message?: string; +} + type CodexWindowKind = | 'usage-5h' | 'usage-weekly' @@ -276,6 +283,225 @@ function buildCodexQuotaWindows(payload: CodexUsageResponse): CodexQuotaWindow[] return windows; } +function buildCodexFailureResult( + accountId: string, + options: { + error: string; + httpStatus?: number; + errorCode?: string; + errorDetail?: string; + actionHint?: string; + retryable?: boolean; + needsReauth?: boolean; + isForbidden?: boolean; + } +): CodexQuotaResult { + return { + success: false, + windows: [], + planType: null, + lastUpdated: Date.now(), + accountId, + error: options.error, + httpStatus: options.httpStatus, + errorCode: options.errorCode, + errorDetail: options.errorDetail, + actionHint: options.actionHint, + retryable: options.retryable, + needsReauth: options.needsReauth, + isForbidden: options.isForbidden, + }; +} + +function sanitizeCodexErrorDetail(bodyText: string): string | undefined { + const trimmed = bodyText.trim(); + if (!trimmed) { + return undefined; + } + + if (/^]+>/.test(trimmed)) { + return '[HTML error response omitted]'; + } + + let sanitized = trimmed + .replace( + /"(access[_-]?token|refresh[_-]?token|authorization|cookie|set-cookie|api[_-]?key|session[_-]?token|token)"\s*:\s*"[^"]*"/gi, + '"$1":"[redacted]"' + ) + .replace(/Bearer\s+[A-Za-z0-9._-]+/g, 'Bearer [redacted]') + .replace(/\s+/g, ' '); + + if (sanitized.length > CODEX_ERROR_DETAIL_MAX_LENGTH) { + sanitized = `${sanitized.slice(0, CODEX_ERROR_DETAIL_MAX_LENGTH - 14)}...[truncated]`; + } + + return sanitized; +} + +function parseCodexErrorBody(bodyText: string): ParsedCodexErrorBody { + const trimmed = bodyText.trim(); + if (!trimmed) { + return {}; + } + + const sanitizedDetail = sanitizeCodexErrorDetail(trimmed); + + try { + const parsed = JSON.parse(trimmed) as Record; + + const topLevelMessage = + typeof parsed.message === 'string' + ? parsed.message + : typeof parsed.detail === 'string' + ? parsed.detail + : undefined; + const topLevelCode = typeof parsed.code === 'string' ? parsed.code : undefined; + + if (parsed.error && typeof parsed.error === 'object') { + const error = parsed.error as Record; + const errorCode = typeof error.code === 'string' ? error.code : topLevelCode; + const errorMessage = + typeof error.message === 'string' + ? error.message + : typeof error.error === 'string' + ? error.error + : topLevelMessage; + return { + errorCode, + errorDetail: sanitizedDetail, + message: errorMessage, + }; + } + + if (parsed.detail && typeof parsed.detail === 'object') { + const detail = parsed.detail as Record; + return { + errorCode: + typeof detail.code === 'string' + ? detail.code + : typeof detail.type === 'string' + ? detail.type + : topLevelCode, + errorDetail: sanitizedDetail, + message: + typeof detail.message === 'string' + ? detail.message + : typeof detail.error === 'string' + ? detail.error + : topLevelMessage, + }; + } + + return { + errorCode: topLevelCode, + errorDetail: sanitizedDetail, + message: topLevelMessage, + }; + } catch { + return { + errorDetail: sanitizedDetail, + message: trimmed, + }; + } +} + +function buildCodexHttpFailureResult( + accountId: string, + status: number, + bodyText: string +): CodexQuotaResult { + const parsed = parseCodexErrorBody(bodyText); + + if (status === 401) { + return buildCodexFailureResult(accountId, { + error: 'Token expired or invalid', + httpStatus: 401, + errorCode: parsed.errorCode || 'reauth_required', + errorDetail: parsed.errorDetail, + actionHint: 'Run ccs cliproxy auth codex to re-authenticate this account.', + needsReauth: true, + retryable: false, + }); + } + + if (status === 402) { + if (parsed.errorCode === 'deactivated_workspace') { + return buildCodexFailureResult(accountId, { + error: 'Workspace deactivated (HTTP 402)', + httpStatus: 402, + errorCode: parsed.errorCode, + errorDetail: parsed.errorDetail, + actionHint: + 'Remove and re-add this account from an active ChatGPT workspace before retrying.', + retryable: false, + }); + } + + return buildCodexFailureResult(accountId, { + error: parsed.message || 'Payment or workspace access required (HTTP 402)', + httpStatus: 402, + errorCode: parsed.errorCode || 'payment_required', + errorDetail: parsed.errorDetail, + actionHint: 'Confirm the ChatGPT workspace/subscription is active, then retry.', + retryable: false, + }); + } + + if (status === 403) { + return buildCodexFailureResult(accountId, { + error: 'Quota API access forbidden (HTTP 403)', + httpStatus: 403, + errorCode: parsed.errorCode || 'quota_api_forbidden', + errorDetail: parsed.errorDetail, + actionHint: 'This account cannot access the Codex quota endpoint.', + isForbidden: true, + retryable: false, + }); + } + + if (status === 404) { + return buildCodexFailureResult(accountId, { + error: 'Codex quota endpoint not found (HTTP 404)', + httpStatus: 404, + errorCode: parsed.errorCode || 'quota_endpoint_not_found', + errorDetail: parsed.errorDetail, + actionHint: 'The upstream Codex quota endpoint changed or is unavailable.', + retryable: false, + }); + } + + if (status === 429) { + return buildCodexFailureResult(accountId, { + error: 'Rate limited - try again later', + httpStatus: 429, + errorCode: parsed.errorCode || 'rate_limited', + errorDetail: parsed.errorDetail, + actionHint: 'Retry after a short delay.', + retryable: true, + }); + } + + if (status >= 500) { + return buildCodexFailureResult(accountId, { + error: `Codex quota service unavailable (HTTP ${status})`, + httpStatus: status, + errorCode: parsed.errorCode || 'provider_unavailable', + errorDetail: parsed.errorDetail, + actionHint: 'Retry later. This looks like a temporary upstream problem.', + retryable: true, + }); + } + + return buildCodexFailureResult(accountId, { + error: parsed.message || `Codex quota request failed (HTTP ${status})`, + httpStatus: status, + errorCode: parsed.errorCode || 'unknown_upstream_error', + errorDetail: parsed.errorDetail, + actionHint: 'Inspect the upstream response details and retry if appropriate.', + retryable: false, + }); +} + /** * Fetch quota for a single Codex account * @@ -293,41 +519,35 @@ export async function fetchCodexQuota( if (!authData) { const error = 'Auth file not found for Codex account'; if (verbose) console.error(`[!] Error: ${error}`); - return { - success: false, - windows: [], - planType: null, - lastUpdated: Date.now(), + return buildCodexFailureResult(accountId, { error, - accountId, - }; + errorCode: 'auth_file_missing', + actionHint: 'Remove the stale account or authenticate again with ccs cliproxy auth codex.', + retryable: false, + }); } if (authData.isExpired) { const error = 'Token expired - re-authenticate with ccs cliproxy auth codex'; if (verbose) console.error(`[!] Error: ${error}`); - return { - success: false, - windows: [], - planType: null, - lastUpdated: Date.now(), + return buildCodexFailureResult(accountId, { error, - accountId, + errorCode: 'token_expired', + actionHint: 'Run ccs cliproxy auth codex to refresh the token for this account.', needsReauth: true, - }; + retryable: false, + }); } if (!authData.accountId) { const error = 'Missing ChatGPT-Account-Id in auth file'; if (verbose) console.error(`[!] Error: ${error}`); - return { - success: false, - windows: [], - planType: null, - lastUpdated: Date.now(), + return buildCodexFailureResult(accountId, { error, - accountId, - }; + errorCode: 'missing_account_id', + actionHint: 'Remove and re-add this Codex account to refresh workspace metadata.', + retryable: false, + }); } const url = `${CODEX_API_BASE}/wham/usage`; @@ -352,52 +572,9 @@ export async function fetchCodexQuota( if (verbose) console.error(`[i] Codex API status: ${response.status} (attempt ${attempt})`); - if (response.status === 401) { - return { - success: false, - windows: [], - planType: null, - lastUpdated: Date.now(), - error: 'Token expired or invalid', - accountId, - needsReauth: true, - }; - } - - if (response.status === 403) { - // 403 = account lacks API access (not same as quota exhausted) - // Keep success=false with isForbidden flag for UI to show distinct "403" badge - return { - success: false, - windows: [], - planType: null, - lastUpdated: Date.now(), - error: '403 Forbidden - No quota API access', - accountId, - isForbidden: true, - }; - } - - if (response.status === 429) { - return { - success: false, - windows: [], - planType: null, - lastUpdated: Date.now(), - error: 'Rate limited - try again later', - accountId, - }; - } - if (!response.ok) { - return { - success: false, - windows: [], - planType: null, - lastUpdated: Date.now(), - error: `API error: ${response.status}`, - accountId, - }; + const bodyText = await response.text(); + return buildCodexHttpFailureResult(accountId, response.status, bodyText); } const data = (await response.json()) as CodexUsageResponse; @@ -456,6 +633,11 @@ export async function fetchCodexQuota( lastUpdated: Date.now(), error: lastErrorMsg, accountId, + errorCode: isAbortError ? 'network_timeout' : 'network_error', + actionHint: isAbortError + ? 'Retry later. The Codex quota endpoint timed out.' + : 'Retry later or inspect network connectivity.', + retryable: true, }; } } @@ -467,6 +649,8 @@ export async function fetchCodexQuota( lastUpdated: Date.now(), error: lastErrorMsg, accountId, + errorCode: 'unknown_error', + retryable: true, }; } diff --git a/src/cliproxy/quota-fetcher.ts b/src/cliproxy/quota-fetcher.ts index 2e965707..1365ae42 100644 --- a/src/cliproxy/quota-fetcher.ts +++ b/src/cliproxy/quota-fetcher.ts @@ -17,6 +17,7 @@ import { type AccountTier, } from './account-manager'; import { sanitizeEmail, isTokenExpired } from './auth-utils'; +import { buildManagementHeaders, buildProxyUrl, getProxyTarget } from './proxy-target-resolver'; /** Individual model quota info */ export interface ModelQuota { @@ -38,12 +39,24 @@ export interface QuotaResult { models: ModelQuota[]; /** Timestamp of fetch */ lastUpdated: number; + /** Upstream HTTP status when available */ + httpStatus?: number; + /** Stable machine-readable error code */ + errorCode?: string; + /** Additional provider-specific detail/code from upstream */ + errorDetail?: string; /** True if account lacks quota access (403) */ isForbidden?: boolean; /** Error message if fetch failed */ error?: string; + /** Provider-specific remediation guidance */ + actionHint?: string; + /** True when the failure is temporary and retrying later may help */ + retryable?: boolean; /** True if token is expired and needs re-auth */ isExpired?: boolean; + /** True if token refresh cannot proceed and the account should be re-authenticated */ + needsReauth?: boolean; /** ISO timestamp when token expires/expired */ expiresAt?: string; /** True if account hasn't been activated in official Antigravity app */ @@ -59,14 +72,7 @@ export interface QuotaResult { /** Google Cloud Code API endpoints */ const ANTIGRAVITY_API_BASE = 'https://cloudcode-pa.googleapis.com'; const ANTIGRAVITY_API_VERSION = 'v1internal'; - -/** Google OAuth token endpoint */ -const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; - -/** Antigravity OAuth credentials (from CLIProxyAPIPlus - public in open-source code) */ -const ANTIGRAVITY_CLIENT_ID = - '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com'; -const ANTIGRAVITY_CLIENT_SECRET = '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'; +const MANAGEMENT_API_TIMEOUT_MS = 5000; /** Headers for loadCodeAssist (matches CLIProxyAPI antigravity.go) */ const LOADCODEASSIST_HEADERS = { @@ -104,15 +110,6 @@ interface AuthData { expiresAt: string | null; } -/** Token refresh response */ -interface TokenRefreshResponse { - access_token?: string; - expires_in?: number; - token_type?: string; - error?: string; - error_description?: string; -} - /** Tier info from loadCodeAssist */ interface TierInfo { id?: string; @@ -155,66 +152,301 @@ interface FetchAvailableModelsResponse { models?: Record; } -/** - * Refresh access token using refresh_token via Google OAuth - * This allows CCS to get fresh tokens independently of CLIProxyAPI - */ -async function refreshAccessToken( - refreshToken: string, - verbose = false -): Promise<{ accessToken: string | null; error?: string }> { - if (verbose) console.error('[i] Refreshing access token...'); +interface ManagementAuthFile { + auth_index?: string | number; + provider?: string; + type?: string; + email?: string; + name?: string; +} +interface ManagementApiCallResponse { + status_code?: number; + body?: string; +} + +interface ManagedResponse { + status: number; + bodyText: string; + json: unknown; + viaManagement: boolean; +} + +interface ProjectLookupResult { + projectId: string | null; + tier?: AccountTier; + error?: string; + errorCode?: string; + errorDetail?: string; + actionHint?: string; + retryable?: boolean; + httpStatus?: number; + needsReauth?: boolean; + isUnprovisioned?: boolean; +} + +function safeParseJson(bodyText: string): unknown { + try { + return JSON.parse(bodyText); + } catch { + return null; + } +} + +function normalizeErrorDetail(bodyText: string): string | undefined { + const normalized = bodyText.trim(); + if (!normalized) { + return undefined; + } + if (normalized.length <= 400) { + return normalized; + } + return `${normalized.slice(0, 397)}...`; +} + +function buildAntigravityFailure( + status: number | undefined, + bodyText?: string +): Pick< + QuotaResult, + 'error' | 'errorCode' | 'errorDetail' | 'actionHint' | 'retryable' | 'httpStatus' | 'needsReauth' +> & { isForbidden?: boolean } { + const detail = normalizeErrorDetail(bodyText || ''); + + if (status === 401) { + return { + httpStatus: 401, + error: 'Token expired or invalid', + errorCode: 'reauth_required', + actionHint: + 'Re-authenticate this account. If CLIProxy is running, retry after the proxy finishes refreshing the token.', + needsReauth: true, + errorDetail: detail, + }; + } + + if (status === 403) { + return { + httpStatus: 403, + error: 'Access forbidden', + errorCode: 'quota_api_forbidden', + actionHint: 'This account does not have Gemini Code Assist quota access.', + isForbidden: true, + errorDetail: detail, + }; + } + + if (status === 429) { + return { + httpStatus: 429, + error: 'Rate limited - try again later', + errorCode: 'rate_limited', + actionHint: 'Retry later. This looks temporary.', + retryable: true, + errorDetail: detail, + }; + } + + if (status === 408) { + return { + httpStatus: 408, + error: 'Request timeout', + errorCode: 'network_timeout', + actionHint: 'Retry later. This looks temporary.', + retryable: true, + errorDetail: detail, + }; + } + + if (typeof status === 'number' && status >= 500) { + return { + httpStatus: status, + error: `API error: ${status}`, + errorCode: 'provider_unavailable', + actionHint: 'Retry later. The provider appears unavailable.', + retryable: true, + errorDetail: detail, + }; + } + + if (typeof status === 'number' && status >= 400) { + return { + httpStatus: status, + error: `API error: ${status}`, + errorCode: 'quota_request_failed', + errorDetail: detail, + }; + } + + return { + error: 'Quota request failed', + errorCode: 'quota_request_failed', + errorDetail: detail, + }; +} + +async function readManagedResponse( + response: Response, + viaManagement: boolean +): Promise { + const bodyText = await response.text(); + return { + status: response.status, + bodyText, + json: safeParseJson(bodyText), + viaManagement, + }; +} + +function isAntigravityAuthFileForAccount(file: ManagementAuthFile, accountId: string): boolean { + const provider = (file.provider || file.type || '').trim().toLowerCase(); + if (provider !== 'antigravity' && provider !== 'agy') { + return false; + } + + const normalizedAccount = accountId.trim().toLowerCase(); + const normalizedEmail = file.email?.trim().toLowerCase(); + if (normalizedEmail && normalizedEmail === normalizedAccount) { + return true; + } + + const normalizedName = file.name?.trim().toLowerCase(); + if (!normalizedName) { + return false; + } + + const sanitizedAccount = sanitizeEmail(accountId).toLowerCase(); + return ( + normalizedName === `antigravity-${sanitizedAccount}.json` || + normalizedName === `agy-${sanitizedAccount}.json` + ); +} + +async function findManagedAntigravityAuthIndex(accountId: string): Promise { + const target = getProxyTarget(); const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10000); + const timeoutId = setTimeout(() => controller.abort(), MANAGEMENT_API_TIMEOUT_MS); try { - const response = await fetch(GOOGLE_TOKEN_URL, { + const response = await fetch(buildProxyUrl(target, '/v0/management/auth-files'), { + signal: controller.signal, + headers: buildManagementHeaders(target), + }); + clearTimeout(timeoutId); + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as { files?: ManagementAuthFile[] }; + const match = data.files?.find((file) => isAntigravityAuthFileForAccount(file, accountId)); + return match?.auth_index ?? null; + } catch { + clearTimeout(timeoutId); + return null; + } +} + +async function performManagedAntigravityRequest( + accountId: string, + url: string, + headers: Record, + body: string +): Promise { + const authIndex = await findManagedAntigravityAuthIndex(accountId); + if (authIndex === null || authIndex === undefined) { + return null; + } + + const target = getProxyTarget(); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), MANAGEMENT_API_TIMEOUT_MS); + + try { + const response = await fetch(buildProxyUrl(target, '/v0/management/api-call'), { + method: 'POST', + signal: controller.signal, + headers: buildManagementHeaders(target, { + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ + auth_index: authIndex, + method: 'POST', + url, + header: { + ...headers, + Authorization: 'Bearer $TOKEN$', + }, + data: body, + }), + }); + clearTimeout(timeoutId); + + if (!response.ok) { + return null; + } + + const apiResponse = (await response.json()) as ManagementApiCallResponse; + const bodyText = typeof apiResponse.body === 'string' ? apiResponse.body : ''; + return { + status: typeof apiResponse.status_code === 'number' ? apiResponse.status_code : 500, + bodyText, + json: safeParseJson(bodyText), + viaManagement: true, + }; + } catch { + clearTimeout(timeoutId); + return null; + } +} + +async function performAntigravityRequest( + accountId: string, + accessToken: string, + url: string, + headers: Record, + body: string +): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), MANAGEMENT_API_TIMEOUT_MS); + + try { + const response = await fetch(url, { method: 'POST', signal: controller.signal, headers: { - 'Content-Type': 'application/x-www-form-urlencoded', + ...headers, + Authorization: `Bearer ${accessToken}`, }, - body: new URLSearchParams({ - grant_type: 'refresh_token', - refresh_token: refreshToken, - client_id: ANTIGRAVITY_CLIENT_ID, - client_secret: ANTIGRAVITY_CLIENT_SECRET, - }).toString(), + body, }); - clearTimeout(timeoutId); - if (verbose) console.error(`[i] Token refresh status: ${response.status}`); + const directResult = await readManagedResponse(response, false); + if (directResult.status !== 401) { + return directResult; + } - const data = (await response.json()) as TokenRefreshResponse; - - if (!response.ok || data.error) { - const error = data.error_description || data.error || `OAuth error: ${response.status}`; - if (verbose) console.error(`[!] Token refresh failed: ${error}`); + const managedResult = await performManagedAntigravityRequest(accountId, url, headers, body); + return managedResult ?? directResult; + } catch (err) { + clearTimeout(timeoutId); + if (err instanceof Error && err.name === 'AbortError') { return { - accessToken: null, - error, + status: 408, + bodyText: '', + json: null, + viaManagement: false, }; } - if (!data.access_token) { - if (verbose) console.error('[!] Token refresh failed: No access_token in response'); - return { accessToken: null, error: 'No access_token in response' }; - } - - if (verbose) console.error('[i] Token refresh: success'); - return { accessToken: data.access_token }; - } catch (err) { - clearTimeout(timeoutId); - const errorMsg = - err instanceof Error && err.name === 'AbortError' - ? 'Token refresh timeout' - : err instanceof Error - ? err.message - : 'Unknown error'; - if (verbose) console.error(`[!] Token refresh failed: ${errorMsg}`); - return { accessToken: null, error: errorMsg }; + const message = err instanceof Error ? err.message : 'Unknown error'; + return { + status: 503, + bodyText: message, + json: null, + viaManagement: false, + }; } } @@ -305,80 +537,63 @@ function mapTierString(tierStr: string | undefined): AccountTier { * Get project ID and tier via loadCodeAssist endpoint * Uses paidTier.id for accurate tier detection (g1-ultra-tier, g1-pro-tier) */ -async function getProjectId(accessToken: string): Promise<{ - projectId: string | null; - tier?: AccountTier; - error?: string; - isUnprovisioned?: boolean; -}> { +async function getProjectId(accountId: string, accessToken: string): Promise { const url = `${ANTIGRAVITY_API_BASE}/${ANTIGRAVITY_API_VERSION}:loadCodeAssist`; + const body = JSON.stringify({ + metadata: { + ideType: 'IDE_UNSPECIFIED', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI', + }, + }); + const response = await performAntigravityRequest( + accountId, + accessToken, + url, + LOADCODEASSIST_HEADERS, + body + ); - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); - - try { - const response = await fetch(url, { - method: 'POST', - signal: controller.signal, - headers: { - ...LOADCODEASSIST_HEADERS, - Authorization: `Bearer ${accessToken}`, - }, - body: JSON.stringify({ - metadata: { - ideType: 'IDE_UNSPECIFIED', - platform: 'PLATFORM_UNSPECIFIED', - pluginType: 'GEMINI', - }, - }), - }); - - clearTimeout(timeoutId); - - if (!response.ok) { - // Return specific error based on status - if (response.status === 401) { - return { projectId: null, error: 'Token expired or invalid' }; - } - if (response.status === 403) { - return { projectId: null, error: 'Access forbidden' }; - } - return { projectId: null, error: `API error: ${response.status}` }; - } - - const data = (await response.json()) as LoadCodeAssistResponse; - - // Extract project ID from response - let projectId: string | undefined; - if (typeof data.cloudaicompanionProject === 'string') { - projectId = data.cloudaicompanionProject; - } else if (typeof data.cloudaicompanionProject === 'object') { - projectId = data.cloudaicompanionProject?.id; - } - - if (!projectId?.trim()) { - // Account authenticated but not provisioned - user needs to sign in via Antigravity app - return { - projectId: null, - error: 'Sign in to Antigravity app to activate quota.', - isUnprovisioned: true, - }; - } - - // Extract tier - paidTier reflects actual subscription status, takes priority - // API returns: paidTier.id = "g1-ultra-tier" or "g1-pro-tier" - // allowedTiers/currentTier often return "standard-tier" which is not useful - const tierStr = data.paidTier?.id || data.currentTier?.id; - const tier = mapTierString(tierStr); - - return { projectId: projectId.trim(), tier }; - } catch (err) { - clearTimeout(timeoutId); - if (err instanceof Error && err.name === 'AbortError') { - return { projectId: null, error: 'Request timeout' }; - } - return { projectId: null, error: err instanceof Error ? err.message : 'Unknown error' }; + if (response.status < 200 || response.status >= 300) { + return { + projectId: null, + ...buildAntigravityFailure(response.status, response.bodyText), + }; } + + const data = response.json as LoadCodeAssistResponse | null; + if (!data) { + return { + projectId: null, + error: 'Invalid quota response from provider', + errorCode: 'provider_unavailable', + retryable: true, + }; + } + + // Extract project ID from response + let projectId: string | undefined; + if (typeof data.cloudaicompanionProject === 'string') { + projectId = data.cloudaicompanionProject; + } else if (typeof data.cloudaicompanionProject === 'object') { + projectId = data.cloudaicompanionProject?.id; + } + + if (!projectId?.trim()) { + return { + projectId: null, + error: 'Sign in to Antigravity app to activate quota.', + errorCode: 'account_unprovisioned', + actionHint: 'Complete sign-in in the Antigravity app, then retry quota refresh.', + isUnprovisioned: true, + }; + } + + // Extract tier - paidTier reflects actual subscription status, takes priority + const tierStr = data.paidTier?.id || data.currentTier?.id; + const tier = mapTierString(tierStr); + + return { projectId: projectId.trim(), tier }; } /** @@ -386,116 +601,75 @@ async function getProjectId(accessToken: string): Promise<{ * 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( + accountId: string, + accessToken: string, + _projectId: string +): Promise { const url = `${ANTIGRAVITY_API_BASE}/${ANTIGRAVITY_API_VERSION}:fetchAvailableModels`; + const response = await performAntigravityRequest( + accountId, + accessToken, + url, + FETCHMODELS_HEADERS, + JSON.stringify({}) + ); - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); - - try { - // Match CLIProxyAPI exactly: empty body, minimal headers - const response = await fetch(url, { - method: 'POST', - signal: controller.signal, - headers: { - ...FETCHMODELS_HEADERS, - Authorization: `Bearer ${accessToken}`, - }, - body: JSON.stringify({}), - }); - - clearTimeout(timeoutId); - - if (response.status === 403) { - // 403 = account lacks Gemini Code Assist access (not same as quota exhausted) - // Keep success=false with isForbidden flag for UI to show distinct "403" badge - return { - success: false, - models: [], - lastUpdated: Date.now(), - isForbidden: true, - error: '403 Forbidden - No Gemini Code Assist access', - }; - } - - if (response.status === 401) { - return { - success: false, - models: [], - lastUpdated: Date.now(), - error: 'Access token expired or invalid', - }; - } - - if (response.status === 429) { - return { - success: false, - models: [], - lastUpdated: Date.now(), - error: 'Rate limited - try again later', - }; - } - - 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; - - // Extract reset time - const resetTime = quotaInfo.resetTime || quotaInfo.reset_time || null; - - // If remaining is not a valid number but resetTime exists, treat as exhausted (0%) - // This happens when Claude models hit quota limit - API returns resetTime but no fraction - let percentage: number; - if (typeof remaining === 'number' && isFinite(remaining)) { - percentage = Math.max(0, Math.min(100, Math.round(remaining * 100))); - } else if (resetTime) { - // Model is exhausted but has reset time - show as 0% - percentage = 0; - } else { - // No valid data, skip this model - continue; - } - - models.push({ - name: modelId, - displayName: modelData.displayName, - percentage, - resetTime, - }); - } - } - - return { - success: true, - models, - lastUpdated: Date.now(), - }; - } catch (err) { - clearTimeout(timeoutId); + if (response.status < 200 || response.status >= 300) { return { success: false, models: [], lastUpdated: Date.now(), - error: err instanceof Error ? err.message : 'Unknown error', + ...buildAntigravityFailure(response.status, response.bodyText), }; } + + const data = response.json as FetchAvailableModelsResponse | null; + if (!data) { + return { + success: false, + models: [], + lastUpdated: Date.now(), + error: 'Invalid quota response from provider', + errorCode: 'provider_unavailable', + retryable: true, + }; + } + + 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; + + const remaining = + quotaInfo.remainingFraction ?? quotaInfo.remaining_fraction ?? quotaInfo.remaining; + const resetTime = quotaInfo.resetTime || quotaInfo.reset_time || null; + + let percentage: number; + if (typeof remaining === 'number' && isFinite(remaining)) { + percentage = Math.max(0, Math.min(100, Math.round(remaining * 100))); + } else if (resetTime) { + percentage = 0; + } else { + continue; + } + + models.push({ + name: modelId, + displayName: modelData.displayName, + percentage, + resetTime, + }); + } + } + + return { + success: true, + models, + lastUpdated: Date.now(), + }; } /** @@ -535,63 +709,47 @@ export async function fetchAccountQuota( models: [], lastUpdated: Date.now(), error, + errorCode: 'auth_file_missing', + actionHint: 'Reconnect this account so CCS can read a current auth token.', }; } - // Determine which access token to use - // File-based token is often stale (CLIProxyAPIPlus refreshes at runtime but doesn't persist) - // Proactive refresh: refresh 5 minutes before expiry (matches CLIProxyAPIPlus behavior) - let accessToken = authData.accessToken; - const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000; // 5 minutes - let tokenRefreshed = false; - - if (authData.refreshToken) { - const shouldRefresh = - authData.isExpired || // Already expired - !authData.expiresAt || // No expiry info - refresh to be safe - new Date(authData.expiresAt).getTime() - Date.now() < REFRESH_LEAD_TIME_MS; // Expiring soon - - if (shouldRefresh) { - const refreshResult = await refreshAccessToken(authData.refreshToken, verbose); - if (refreshResult.accessToken) { - accessToken = refreshResult.accessToken; - tokenRefreshed = true; - } - // If refresh fails, fall back to existing token (might still work) - } - } - - if (verbose && !tokenRefreshed) { - console.error('[i] Token refresh: skipped'); + const accessToken = authData.accessToken; + if (verbose) { + const expiryState = authData.isExpired + ? 'expired' + : authData.expiresAt + ? `expires ${authData.expiresAt}` + : 'expiry unknown'; + console.error(`[i] Auth token state: ${expiryState}`); } // Get project ID and tier - prefer stored project ID, but always call API for tier let projectId = authData.projectId; let apiTier: AccountTier = 'unknown'; - // Always call loadCodeAssist to get accurate tier from API - let lastProjectResult = await getProjectId(accessToken); + // Always call loadCodeAssist to get accurate tier from API. + // If the file token is stale, the helper retries through CLIProxy management auth. + const lastProjectResult = await getProjectId(accountId, accessToken); if (!lastProjectResult.projectId && !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, verbose); - if (refreshResult.accessToken) { - accessToken = refreshResult.accessToken; - lastProjectResult = await getProjectId(accessToken); - } - } - if (!lastProjectResult.projectId) { - const error = lastProjectResult.error || 'Failed to retrieve project ID'; - if (verbose) console.error(`[!] Error: ${error}`); - return { - success: false, - models: [], - lastUpdated: Date.now(), - error, - isUnprovisioned: lastProjectResult.isUnprovisioned, - }; - } + const error = lastProjectResult.error || 'Failed to retrieve project ID'; + if (verbose) console.error(`[!] Error: ${error}`); + return { + success: false, + models: [], + lastUpdated: Date.now(), + error, + errorCode: lastProjectResult.errorCode, + errorDetail: lastProjectResult.errorDetail, + actionHint: lastProjectResult.actionHint, + retryable: lastProjectResult.retryable, + httpStatus: lastProjectResult.httpStatus, + needsReauth: lastProjectResult.needsReauth, + isUnprovisioned: lastProjectResult.isUnprovisioned, + isExpired: authData.isExpired, + expiresAt: authData.expiresAt || undefined, + }; } // Use API project ID if available, else fallback to stored @@ -601,42 +759,22 @@ export async function fetchAccountQuota( if (verbose) console.error(`[i] Project ID: ${projectId || 'not found'}`); // Fetch models with quota - const result = await fetchAvailableModels(accessToken, projectId as string); + const result = await fetchAvailableModels(accountId, accessToken, projectId as string); if (verbose) console.error(`[i] Models found: ${result.models.length}`); - - // 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, verbose); - if (refreshResult.accessToken) { - const retryResult = await fetchAvailableModels( - refreshResult.accessToken, - projectId as string - ); - // Determine tier from API response only (model inference is unreliable) - if (retryResult.success) { - const finalTier = apiTier !== 'unknown' ? apiTier : 'unknown'; - retryResult.tier = finalTier; - retryResult.accountId = accountId; - if (finalTier !== 'unknown') { - setAccountTier(provider, accountId, finalTier); - } - if (verbose && retryResult.error) { - console.log(`[!] Error: ${retryResult.error}`); - } - } - return retryResult; - } - } + result.accountId = accountId; + result.projectId = projectId || undefined; // Determine tier from API response only if (result.success) { const finalTier = apiTier !== 'unknown' ? apiTier : 'unknown'; result.tier = finalTier; - result.accountId = accountId; if (finalTier !== 'unknown') { setAccountTier(provider, accountId, finalTier); } + } else { + result.isExpired = authData.isExpired; + result.expiresAt = authData.expiresAt || undefined; } if (verbose && result.error) { diff --git a/src/cliproxy/quota-types.ts b/src/cliproxy/quota-types.ts index 51fba8f6..fa54ed69 100644 --- a/src/cliproxy/quota-types.ts +++ b/src/cliproxy/quota-types.ts @@ -11,6 +11,21 @@ export type QuotaProvider = 'agy' | 'codex' | 'claude' | 'gemini' | 'ghcp'; // Re-export Antigravity types for unified access export type { QuotaResult as AntigravityQuotaResult } from './quota-fetcher'; +export interface QuotaErrorMetadata { + /** Upstream HTTP status when available */ + httpStatus?: number; + /** Stable machine-readable error code */ + errorCode?: string; + /** Additional provider-specific detail/code from upstream */ + errorDetail?: string; + /** True if account lacks quota access (403) */ + isForbidden?: boolean; + /** Provider-specific remediation guidance */ + actionHint?: string; + /** True when the failure is temporary and retrying later may help */ + retryable?: boolean; +} + /** * Codex quota window (primary, secondary, code review) */ @@ -50,7 +65,7 @@ export interface CodexCoreUsageSummary { /** * Codex quota fetch result */ -export interface CodexQuotaResult { +export interface CodexQuotaResult extends QuotaErrorMetadata { /** Whether fetch succeeded */ success: boolean; /** Quota windows (primary, secondary, code review) */ @@ -130,7 +145,7 @@ export interface ClaudeCoreUsageSummary { /** * Claude quota fetch result */ -export interface ClaudeQuotaResult { +export interface ClaudeQuotaResult extends QuotaErrorMetadata { /** Whether fetch succeeded */ success: boolean; /** Policy limit windows */ @@ -170,7 +185,7 @@ export interface GeminiCliBucket { /** * Gemini CLI quota fetch result */ -export interface GeminiCliQuotaResult { +export interface GeminiCliQuotaResult extends QuotaErrorMetadata { /** Whether fetch succeeded */ success: boolean; /** Quota buckets grouped by model series */ @@ -214,7 +229,7 @@ export interface GhcpQuotaSnapshot { /** * GitHub Copilot quota fetch result. */ -export interface GhcpQuotaResult { +export interface GhcpQuotaResult extends QuotaErrorMetadata { /** Whether fetch succeeded */ success: boolean; /** Copilot plan type (individual/business/enterprise/free) */ diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index 4b082730..8f0f1089 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -103,14 +103,26 @@ function isQuotaRouteRateLimited(req: Request, provider: string): boolean { * Cache only stable failures; skip transient network errors (timeouts, 429s, 5xx). * Generic across all quota result types. */ -function shouldCacheQuotaResult(result: { +export function shouldCacheQuotaResult(result: { success: boolean; needsReauth?: boolean; isForbidden?: boolean; + httpStatus?: number; + retryable?: boolean; error?: string; }): boolean { if (result.success) return true; if (result.needsReauth || result.isForbidden) return true; + if (result.retryable === true) return false; + if (result.retryable === false) return true; + if (typeof result.httpStatus === 'number') { + if (result.httpStatus === 429 || result.httpStatus === 408 || result.httpStatus >= 500) { + return false; + } + if (result.httpStatus >= 400 && result.httpStatus < 500) { + return true; + } + } const msg = (result.error || '').toLowerCase(); if (!msg) return false; const transientPatterns = ['timeout', 'rate limited', 'api error: 5', 'fetch failed']; diff --git a/tests/unit/cliproxy/quota-caching-integration.test.ts b/tests/unit/cliproxy/quota-caching-integration.test.ts index f934a342..4e956689 100644 --- a/tests/unit/cliproxy/quota-caching-integration.test.ts +++ b/tests/unit/cliproxy/quota-caching-integration.test.ts @@ -17,6 +17,7 @@ import { getQuotaCacheStats, QUOTA_CACHE_TTL_MS, } from '../../../src/cliproxy/quota-response-cache'; +import { shouldCacheQuotaResult } from '../../../src/web-server/routes/cliproxy-stats-routes'; import type { GeminiCliQuotaResult, CodexQuotaResult } from '../../../src/cliproxy/quota-types'; describe('Quota Caching Integration', () => { @@ -308,6 +309,60 @@ describe('Quota Caching Integration', () => { expect(cached?.error).toBe('API error: 503'); }); + + it('should cache stable auth and workspace failures', () => { + expect( + shouldCacheQuotaResult({ + success: false, + needsReauth: true, + error: 'Token expired', + }) + ).toBe(true); + + expect( + shouldCacheQuotaResult({ + success: false, + httpStatus: 402, + error: 'Workspace deactivated (HTTP 402)', + }) + ).toBe(true); + }); + + it('should skip transient failures marked retryable or temporary by status', () => { + expect( + shouldCacheQuotaResult({ + success: false, + retryable: true, + error: 'Rate limited - try again later', + }) + ).toBe(false); + + expect( + shouldCacheQuotaResult({ + success: false, + httpStatus: 429, + error: 'Rate limited - try again later', + }) + ).toBe(false); + + expect( + shouldCacheQuotaResult({ + success: false, + httpStatus: 503, + error: 'Codex quota service unavailable (HTTP 503)', + }) + ).toBe(false); + }); + + it('should respect explicit non-retryable failures even without message pattern matches', () => { + expect( + shouldCacheQuotaResult({ + success: false, + retryable: false, + error: 'Unknown upstream error', + }) + ).toBe(true); + }); }); describe('high-volume scenarios', () => { diff --git a/tests/unit/cliproxy/quota-fetcher-codex.test.ts b/tests/unit/cliproxy/quota-fetcher-codex.test.ts index 69b3f21a..fc089c84 100644 --- a/tests/unit/cliproxy/quota-fetcher-codex.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-codex.test.ts @@ -4,13 +4,48 @@ * Tests for Codex quota window parsing and transformation logic */ -import { describe, it, expect } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; import { buildCodexQuotaWindows, buildCodexCoreUsageSummary, + fetchCodexQuota, getUnknownCodexWindowLabels, } from '../../../src/cliproxy/quota-fetcher-codex'; +let tmpDir: string; +let originalCcsHome: string | undefined; +let originalFetch: typeof fetch; + +function createCodexAccount( + accountId: string, + tokenPayload: Record, + tokenFile = `codex-${accountId.replace(/[@.]/g, '_')}.json` +): void { + const authDir = path.join(tmpDir, '.ccs', 'cliproxy', 'auth'); + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync(path.join(authDir, tokenFile), JSON.stringify(tokenPayload)); +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-quota-test-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpDir; + originalFetch = global.fetch; +}); + +afterEach(() => { + global.fetch = originalFetch; + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + describe('Codex Quota Fetcher', () => { describe('buildCodexQuotaWindows', () => { it('should parse snake_case API response', () => { @@ -307,4 +342,184 @@ describe('Codex Quota Fetcher', () => { expect(labels).toEqual([]); }); }); + + describe('fetchCodexQuota failure mapping', () => { + function createValidCodexAccount(email: string, accountId = `workspace-${email}`): void { + createCodexAccount(email, { + access_token: 'test-token', + account_id: accountId, + expired: '2099-01-01T00:00:00.000Z', + email, + type: 'codex', + }); + } + + it('maps deactivated workspace 402 responses to structured metadata', async () => { + createValidCodexAccount('workspace@example.com', 'workspace-123'); + + global.fetch = mock(() => + Promise.resolve( + new Response(JSON.stringify({ detail: { code: 'deactivated_workspace' } }), { + status: 402, + headers: { 'Content-Type': 'application/json' }, + }) + ) + ) as typeof fetch; + + const result = await fetchCodexQuota('workspace@example.com'); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(402); + expect(result.errorCode).toBe('deactivated_workspace'); + expect(result.error).toContain('Workspace deactivated'); + expect(result.actionHint).toContain('active ChatGPT workspace'); + expect(result.retryable).toBe(false); + }); + + it('maps 401 responses to reauth-required metadata', async () => { + createValidCodexAccount('reauth@example.com', 'workspace-reauth'); + + global.fetch = mock(() => Promise.resolve(new Response('', { status: 401 }))) as typeof fetch; + + const result = await fetchCodexQuota('reauth@example.com'); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(401); + expect(result.errorCode).toBe('reauth_required'); + expect(result.needsReauth).toBe(true); + expect(result.actionHint).toContain('ccs cliproxy auth codex'); + }); + + it('maps 403 responses to forbidden metadata', async () => { + createValidCodexAccount('forbidden@example.com', 'workspace-forbidden'); + + global.fetch = mock(() => + Promise.resolve( + new Response(JSON.stringify({ detail: { code: 'quota_api_forbidden' } }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }) + ) + ) as typeof fetch; + + const result = await fetchCodexQuota('forbidden@example.com'); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(403); + expect(result.errorCode).toBe('quota_api_forbidden'); + expect(result.isForbidden).toBe(true); + expect(result.retryable).toBe(false); + }); + + it('maps 429 responses to retryable rate-limit metadata', async () => { + createValidCodexAccount('rate-limit@example.com', 'workspace-rate-limit'); + + global.fetch = mock(() => + Promise.resolve( + new Response(JSON.stringify({ detail: { code: 'rate_limited' } }), { + status: 429, + headers: { 'Content-Type': 'application/json' }, + }) + ) + ) as typeof fetch; + + const result = await fetchCodexQuota('rate-limit@example.com'); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(429); + expect(result.errorCode).toBe('rate_limited'); + expect(result.retryable).toBe(true); + expect(result.actionHint).toContain('Retry'); + }); + + it('maps 5xx responses to retryable provider-unavailable metadata', async () => { + createValidCodexAccount('outage@example.com', 'workspace-outage'); + + global.fetch = mock(() => + Promise.resolve( + new Response(JSON.stringify({ detail: { code: 'upstream_failure' } }), { + status: 503, + headers: { 'Content-Type': 'application/json' }, + }) + ) + ) as typeof fetch; + + const result = await fetchCodexQuota('outage@example.com'); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(503); + expect(result.errorCode).toBe('upstream_failure'); + expect(result.retryable).toBe(true); + expect(result.error).toContain('service unavailable'); + }); + + it('maps unknown upstream statuses to a non-retryable structured error', async () => { + createValidCodexAccount('teapot@example.com', 'workspace-teapot'); + + global.fetch = mock(() => + Promise.resolve( + new Response('{"message":"Strange upstream response"}', { + status: 418, + headers: { 'Content-Type': 'application/json' }, + }) + ) + ) as typeof fetch; + + const result = await fetchCodexQuota('teapot@example.com'); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(418); + expect(result.errorCode).toBe('unknown_upstream_error'); + expect(result.retryable).toBe(false); + expect(result.error).toBe('Strange upstream response'); + }); + + it('sanitizes and truncates raw upstream error detail before returning it', async () => { + createValidCodexAccount('sanitized@example.com', 'workspace-sanitized'); + const leakedToken = 'secret-token-value-123'; + const oversizedMessage = 'x'.repeat(400); + + global.fetch = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ + message: 'Upstream failure', + access_token: leakedToken, + extra: oversizedMessage, + }), + { + status: 418, + headers: { 'Content-Type': 'application/json' }, + } + ) + ) + ) as typeof fetch; + + const result = await fetchCodexQuota('sanitized@example.com'); + + expect(result.success).toBe(false); + expect(result.errorDetail).toBeDefined(); + expect(result.errorDetail).not.toContain(leakedToken); + expect(result.errorDetail).toContain('[redacted]'); + expect(result.errorDetail?.endsWith('...[truncated]')).toBe(true); + }); + + it('omits raw HTML upstream bodies from the returned error detail', async () => { + createValidCodexAccount('html@example.com', 'workspace-html'); + + global.fetch = mock(() => + Promise.resolve( + new Response('bad gateway', { + status: 503, + headers: { 'Content-Type': 'text/html' }, + }) + ) + ) as typeof fetch; + + const result = await fetchCodexQuota('html@example.com'); + + expect(result.success).toBe(false); + expect(result.errorDetail).toBe('[HTML error response omitted]'); + }); + }); }); diff --git a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts index c879d84b..f3dfd82d 100644 --- a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts @@ -4,13 +4,75 @@ * Tests for Gemini CLI bucket parsing and transformation logic */ -import { describe, it, expect } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { buildGeminiCliBuckets, resolveGeminiCliProjectId, } from '../../../src/cliproxy/quota-fetcher-gemini-cli'; +import { refreshGeminiToken } from '../../../src/cliproxy/auth/gemini-token-refresh'; +import { getProviderAuthDir } from '../../../src/cliproxy/config-generator'; +import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks'; describe('Gemini CLI Quota Fetcher', () => { + let tempHome: string; + let originalHome: string | undefined; + let originalCcsHome: string | undefined; + let originalGeminiClientId: string | undefined; + let originalGeminiClientSecret: string | undefined; + + function writeGeminiToken(token: Record): string { + const authDir = getProviderAuthDir('gemini'); + fs.mkdirSync(authDir, { recursive: true }); + const tokenPath = path.join(authDir, 'gemini-test.json'); + fs.writeFileSync(tokenPath, JSON.stringify(token, null, 2)); + return tokenPath; + } + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-gemini-refresh-')); + originalHome = process.env.HOME; + originalCcsHome = process.env.CCS_HOME; + originalGeminiClientId = process.env.CCS_GEMINI_OAUTH_CLIENT_ID; + originalGeminiClientSecret = process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET; + + process.env.HOME = tempHome; + process.env.CCS_HOME = tempHome; + delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID; + delete process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET; + }); + + afterEach(() => { + restoreFetch(); + fs.rmSync(tempHome, { recursive: true, force: true }); + + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + + if (originalGeminiClientId === undefined) { + delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID; + } else { + process.env.CCS_GEMINI_OAUTH_CLIENT_ID = originalGeminiClientId; + } + + if (originalGeminiClientSecret === undefined) { + delete process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET; + } else { + process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET = originalGeminiClientSecret; + } + }); + describe('resolveGeminiCliProjectId', () => { it('should extract project ID from account field', () => { const account = 'user@example.com (cloudaicompanion-abc-123)'; @@ -191,4 +253,85 @@ describe('Gemini CLI Quota Fetcher', () => { expect(flashBucket!.modelIds).toContain('gemini-2.5-flash'); }); }); + + describe('refreshGeminiToken', () => { + it('uses OAuth client metadata stored in the token file', async () => { + writeGeminiToken({ + type: 'gemini', + email: 'file@example.com', + token: { + access_token: 'old-token', + refresh_token: 'refresh-from-file', + expiry: Date.now() - 1000, + client_id: 'file-client-id', + client_secret: 'file-client-secret', + token_uri: 'https://oauth2.googleapis.com/token', + }, + }); + + mockFetch([ + { + url: 'https://oauth2.googleapis.com/token', + method: 'POST', + response: { access_token: 'fresh-token', expires_in: 1800 }, + }, + ]); + + const result = await refreshGeminiToken(); + + expect(result.success).toBe(true); + const [request] = getCapturedFetchRequests(); + expect(request.body).toContain('client_id=file-client-id'); + expect(request.body).toContain('client_secret=file-client-secret'); + expect(request.body).toContain('refresh_token=refresh-from-file'); + }); + + it('falls back to CCS_GEMINI_OAUTH_CLIENT_* env vars when token metadata is missing', async () => { + process.env.CCS_GEMINI_OAUTH_CLIENT_ID = 'env-client-id'; + process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET = 'env-client-secret'; + + writeGeminiToken({ + type: 'gemini', + email: 'env@example.com', + token: { + access_token: 'old-token', + refresh_token: 'refresh-from-file', + expiry: Date.now() - 1000, + }, + }); + + mockFetch([ + { + url: 'https://oauth2.googleapis.com/token', + method: 'POST', + response: { access_token: 'fresh-token', expires_in: 1800 }, + }, + ]); + + const result = await refreshGeminiToken(); + + expect(result.success).toBe(true); + const [request] = getCapturedFetchRequests(); + expect(request.body).toContain('client_id=env-client-id'); + expect(request.body).toContain('client_secret=env-client-secret'); + }); + + it('returns a clear error when no refresh client credentials are available', async () => { + writeGeminiToken({ + type: 'gemini', + email: 'missing@example.com', + token: { + access_token: 'old-token', + refresh_token: 'refresh-from-file', + expiry: Date.now() - 1000, + }, + }); + + const result = await refreshGeminiToken(); + + expect(result.success).toBe(false); + expect(result.error).toContain('CCS_GEMINI_OAUTH_CLIENT_ID'); + expect(result.error).toContain('CCS_GEMINI_OAUTH_CLIENT_SECRET'); + }); + }); }); diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index dfecbf8a..2d8bbfe3 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -6,13 +6,22 @@ import { cn, formatQuotaPercent, getCodexQuotaBreakdown, + getQuotaFailureInfo, getProviderMinQuota, getProviderResetTime, isClaudeQuotaResult, isCodexQuotaResult, } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; -import { GripVertical, Loader2, Pause, Play, KeyRound } from 'lucide-react'; +import { + GripVertical, + Loader2, + Pause, + Play, + KeyRound, + AlertTriangle, + AlertCircle, +} from 'lucide-react'; import { useAccountQuota, QUOTA_SUPPORTED_PROVIDERS } from '@/hooks/use-cliproxy-stats'; import type { QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; @@ -159,6 +168,19 @@ export function AccountCard({ : []; const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null; const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null; + const failureInfo = getQuotaFailureInfo(quota); + const FailureIcon = + failureInfo?.label === 'Reauth' + ? KeyRound + : failureInfo?.tone === 'warning' + ? AlertTriangle + : AlertCircle; + const failureTextClass = + failureInfo?.tone === 'warning' + ? 'text-amber-600 dark:text-amber-400' + : failureInfo?.tone === 'destructive' + ? 'text-destructive' + : 'text-muted-foreground/70'; // Tier badge (AGY only) - show P for Pro, U for Ultra const showTierBadge = @@ -325,28 +347,35 @@ export function AccountCard({
{t('accountCard.quotaUnavailable')}
- ) : quota?.needsReauth ? ( + ) : failureInfo ? ( -
- - {t('accountCard.reauthNeeded')} +
+ + {failureInfo.label}
- -

- {quota.error?.includes('No refresh token') - ? t('accountCard.removeAndReadd') - : quota.error || t('accountCard.autoRefreshFailed')} -

+ +
+

{failureInfo.summary}

+ {failureInfo.actionHint && ( +

{failureInfo.actionHint}

+ )} + {failureInfo.technicalDetail && ( +

+ {failureInfo.technicalDetail} +

+ )} + {failureInfo.rawDetail && ( +
+                        {failureInfo.rawDetail}
+                      
+ )} +
- ) : quota?.error ? ( -
- {quota.error.length > 20 ? `${quota.error.slice(0, 18)}...` : quota.error} -
) : null}
)} diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 501fc4c3..beb6fead 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -34,6 +34,7 @@ import { cn, formatQuotaPercent, getCodexQuotaBreakdown, + getQuotaFailureInfo, getProviderMinQuota, getProviderResetTime, isClaudeQuotaResult, @@ -170,6 +171,19 @@ export function AccountItem({ : []; const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null; const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null; + const failureInfo = getQuotaFailureInfo(quota); + const FailureIcon = + failureInfo?.label === 'Reauth' + ? KeyRound + : failureInfo?.tone === 'warning' + ? AlertTriangle + : AlertCircle; + const failureBadgeClass = + failureInfo?.tone === 'warning' + ? 'border-amber-500/50 text-amber-600 dark:text-amber-400' + : failureInfo?.tone === 'destructive' + ? 'border-destructive/50 text-destructive' + : 'border-muted-foreground/50 text-muted-foreground'; return (
- ) : quota?.needsReauth ? ( + ) : failureInfo ? (
- - Reauth + + {failureInfo.label}
- -

- {quota.error?.includes('No refresh token') - ? 'No refresh token available. Remove and re-add account to fix.' - : quota.error?.includes('refresh') || quota.error?.includes('Invalid') - ? `Auto-refresh failed: ${quota.error}` - : `Token issue: ${quota.error || 'Re-authenticate required'}`} -

-
-
-
- ) : quota?.error || (quota && !quota.success) ? ( - - - -
- - - N/A - + +
+

{failureInfo.summary}

+ {failureInfo.actionHint && ( +

{failureInfo.actionHint}

+ )} + {failureInfo.technicalDetail && ( +

+ {failureInfo.technicalDetail} +

+ )} + {failureInfo.rawDetail && ( +
+                        {failureInfo.rawDetail}
+                      
+ )}
- - -

{quota?.error || 'Quota information unavailable'}

diff --git a/ui/src/components/shared/quota-tooltip-content.tsx b/ui/src/components/shared/quota-tooltip-content.tsx index d95dab71..4554bb22 100644 --- a/ui/src/components/shared/quota-tooltip-content.tsx +++ b/ui/src/components/shared/quota-tooltip-content.tsx @@ -9,6 +9,7 @@ import { formatQuotaPercent, formatResetTime, getCodexQuotaBreakdown, + getQuotaFailureInfo, getCodexWindowDisplayLabel, getModelsWithTiers, groupModelsByTier, @@ -67,7 +68,28 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro } if (!quota.success) { - return

{quota.error || 'Failed to load quota'}

; + const failureInfo = getQuotaFailureInfo(quota); + return ( +
+

+ {failureInfo?.label || quota.error || 'Failed to load quota'} +

+

{failureInfo?.summary || quota.error}

+ {failureInfo?.actionHint && ( +

{failureInfo.actionHint}

+ )} + {failureInfo?.technicalDetail && ( +

+ {failureInfo.technicalDetail} +

+ )} + {failureInfo?.rawDetail && ( +
+            {failureInfo.rawDetail}
+          
+ )} +
+ ); } // Antigravity (agy) provider tooltip diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 5f581f2f..0fd726a0 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -241,10 +241,20 @@ export interface QuotaResult { models: ModelQuota[]; /** Timestamp of fetch */ lastUpdated: number; + /** Upstream HTTP status when available */ + httpStatus?: number; + /** Stable machine-readable error code */ + errorCode?: string; + /** Additional provider-specific detail/code from upstream */ + errorDetail?: string; /** True if account lacks quota access (403) */ isForbidden?: boolean; /** Error message if fetch failed */ error?: string; + /** Provider-specific remediation guidance */ + actionHint?: string; + /** True when the failure is temporary and retrying later may help */ + retryable?: boolean; /** True if token is expired and needs re-authentication */ needsReauth?: boolean; } @@ -295,12 +305,22 @@ export interface CodexQuotaResult { planType: 'free' | 'plus' | 'team' | null; /** Timestamp of fetch */ lastUpdated: number; + /** Upstream HTTP status when available */ + httpStatus?: number; + /** Stable machine-readable error code */ + errorCode?: string; + /** Additional provider-specific detail/code from upstream */ + errorDetail?: string; /** Error message if fetch failed */ error?: string; /** Account ID (email) this quota belongs to */ accountId?: string; + /** Provider-specific remediation guidance */ + actionHint?: string; /** True if token is expired and needs re-authentication */ needsReauth?: boolean; + /** True when the failure is temporary and retrying later may help */ + retryable?: boolean; /** True if result was served from cache */ cached?: boolean; /** True if account lacks quota access (403) - displayed as 0% instead of error */ @@ -353,9 +373,15 @@ export interface ClaudeQuotaResult { windows: ClaudeQuotaWindow[]; coreUsage?: ClaudeCoreUsageSummary; lastUpdated: number; + httpStatus?: number; + errorCode?: string; + errorDetail?: string; + isForbidden?: boolean; error?: string; accountId?: string; + actionHint?: string; needsReauth?: boolean; + retryable?: boolean; /** True if result was served from cache */ cached?: boolean; } @@ -388,12 +414,24 @@ export interface GeminiCliQuotaResult { projectId: string | null; /** Timestamp of fetch */ lastUpdated: number; + /** Upstream HTTP status when available */ + httpStatus?: number; + /** Stable machine-readable error code */ + errorCode?: string; + /** Additional provider-specific detail/code from upstream */ + errorDetail?: string; + /** True if account lacks quota access (403) */ + isForbidden?: boolean; /** Error message if fetch failed */ error?: string; /** Account ID (email) this quota belongs to */ accountId?: string; + /** Provider-specific remediation guidance */ + actionHint?: string; /** True if token is expired and needs re-authentication */ needsReauth?: boolean; + /** True when the failure is temporary and retrying later may help */ + retryable?: boolean; /** True if result was served from cache */ cached?: boolean; } @@ -435,12 +473,24 @@ export interface GhcpQuotaResult { }; /** Timestamp of fetch */ lastUpdated: number; + /** Upstream HTTP status when available */ + httpStatus?: number; + /** Stable machine-readable error code */ + errorCode?: string; + /** Additional provider-specific detail/code from upstream */ + errorDetail?: string; + /** True if account lacks quota access (403) */ + isForbidden?: boolean; /** Error message if fetch failed */ error?: string; /** Account ID this quota belongs to */ accountId?: string; + /** Provider-specific remediation guidance */ + actionHint?: string; /** True if token is expired and needs re-authentication */ needsReauth?: boolean; + /** True when the failure is temporary and retrying later may help */ + retryable?: boolean; /** True if result was served from cache */ cached?: boolean; } diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 3d72b140..a7bc4c0a 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -512,6 +512,20 @@ const resources = { quota: 'Quota', quotaUnavailable: 'Quota limits unavailable', reauthNeeded: 'Reauth needed', + failureLabelReauth: 'Reauth', + failureLabelWorkspace: 'Workspace', + failureLabelNoAccess: 'No Access', + failureLabelRetry: 'Retry', + failureLabelReconnect: 'Reconnect', + failureLabelTemporary: 'Temporary', + failureLabelUnavailable: 'Unavailable', + failureHintReauth: 'Refresh this account by running its auth flow again.', + failureHintWorkspace: + 'Move this account back to an active workspace, then remove and re-add it.', + failureHintNoAccess: 'This account cannot access the provider quota endpoint.', + failureHintRetry: 'Wait a bit, then retry the quota request.', + failureHintReconnect: 'Remove the stale account and authenticate it again.', + failureHintTemporary: 'Retry later. This looks temporary.', removeAndReadd: 'Remove and re-add account', autoRefreshFailed: 'Auto-refresh failed', }, @@ -1634,6 +1648,19 @@ const resources = { quota: '配额', quotaUnavailable: '配额限制不可用', reauthNeeded: '需要重新认证', + failureLabelReauth: '重新认证', + failureLabelWorkspace: '工作区', + failureLabelNoAccess: '无权限', + failureLabelRetry: '重试', + failureLabelReconnect: '重新连接', + failureLabelTemporary: '临时问题', + failureLabelUnavailable: '不可用', + failureHintReauth: '请重新运行该账号的认证流程以刷新访问权限。', + failureHintWorkspace: '请将该账号移回可用的工作区,然后移除并重新添加。', + failureHintNoAccess: '该账号无法访问提供商的配额接口。', + failureHintRetry: '稍后再试一次。', + failureHintReconnect: '请移除这个失效账号并重新认证。', + failureHintTemporary: '这看起来是临时问题,请稍后重试。', removeAndReadd: '移除并重新添加账号', autoRefreshFailed: '自动刷新失败', }, @@ -2772,6 +2799,20 @@ const resources = { quota: 'hạn ngạch', quotaUnavailable: 'Thông tin quota chưa khả dụng', reauthNeeded: 'Cần xác thực lại', + failureLabelReauth: 'Xác thực lại', + failureLabelWorkspace: 'Workspace', + failureLabelNoAccess: 'Không có quyền', + failureLabelRetry: 'Thử lại', + failureLabelReconnect: 'Kết nối lại', + failureLabelTemporary: 'Tạm thời', + failureLabelUnavailable: 'Chưa khả dụng', + failureHintReauth: 'Chạy lại luồng xác thực của tài khoản này để làm mới quyền truy cập.', + failureHintWorkspace: + 'Chuyển tài khoản này về workspace còn hoạt động rồi xóa và thêm lại.', + failureHintNoAccess: 'Tài khoản này không thể truy cập endpoint quota của nhà cung cấp.', + failureHintRetry: 'Đợi một chút rồi thử lại yêu cầu quota.', + failureHintReconnect: 'Xóa tài khoản cũ rồi xác thực lại.', + failureHintTemporary: 'Có vẻ đây là lỗi tạm thời. Hãy thử lại sau.', removeAndReadd: 'Xóa và thêm lại tài khoản', autoRefreshFailed: 'Tự động làm mới không thành công', }, diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 37650ad7..9db71545 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -9,6 +9,7 @@ import type { GhcpQuotaResult, QuotaResult, } from './api-client'; +import i18n from './i18n'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -718,6 +719,171 @@ export function isGhcpQuotaResult(quota: UnifiedQuotaResult): quota is GhcpQuota // ==================== Unified Quota Helpers ==================== +export interface QuotaFailureInfo { + label: string; + summary: string; + actionHint: string | null; + technicalDetail: string | null; + rawDetail: string | null; + tone: 'warning' | 'muted' | 'destructive'; +} + +function buildQuotaTechnicalDetail(quota: UnifiedQuotaResult): string | null { + const details: string[] = []; + if (typeof quota.httpStatus === 'number') { + details.push(`HTTP ${quota.httpStatus}`); + } + if (typeof quota.errorCode === 'string' && quota.errorCode.trim()) { + details.push(quota.errorCode.trim()); + } + return details.length > 0 ? details.join(' | ') : null; +} + +function buildQuotaRawDetail( + quota: UnifiedQuotaResult, + summary: string, + technicalDetail: string | null +): string | null { + const rawDetail = quota.errorDetail?.trim() || null; + if (!rawDetail) return null; + + const normalizedRawDetail = rawDetail.toLowerCase(); + if (normalizedRawDetail === summary.toLowerCase()) { + return null; + } + if (technicalDetail && normalizedRawDetail === technicalDetail.toLowerCase()) { + return null; + } + + return rawDetail; +} + +export function getQuotaFailureInfo( + quota: UnifiedQuotaResult | null | undefined +): QuotaFailureInfo | null { + if (!quota || quota.success) { + return null; + } + + const summary = quota.error?.trim() || 'Quota information unavailable'; + const actionHint = quota.actionHint?.trim() || null; + const errorCode = quota.errorCode?.trim().toLowerCase() || ''; + const technicalDetail = buildQuotaTechnicalDetail(quota); + const rawDetail = buildQuotaRawDetail(quota, summary, technicalDetail); + const lowerSummary = summary.toLowerCase(); + + if ( + quota.needsReauth || + errorCode === 'token_expired' || + errorCode === 'reauth_required' || + lowerSummary.includes('token expired') || + lowerSummary.includes('re-authenticate') || + lowerSummary.includes('reauth') || + lowerSummary.includes('expired or invalid') + ) { + return { + label: i18n.t('accountCard.failureLabelReauth'), + summary, + actionHint: actionHint || i18n.t('accountCard.failureHintReauth'), + technicalDetail, + rawDetail, + tone: 'warning', + }; + } + + if ( + errorCode === 'deactivated_workspace' || + quota.httpStatus === 402 || + lowerSummary.includes('workspace deactivated') || + lowerSummary.includes('payment or workspace access required') + ) { + return { + label: i18n.t('accountCard.failureLabelWorkspace'), + summary, + actionHint: actionHint || i18n.t('accountCard.failureHintWorkspace'), + technicalDetail, + rawDetail, + tone: 'warning', + }; + } + + if ( + quota.isForbidden || + quota.httpStatus === 403 || + errorCode === 'quota_api_forbidden' || + lowerSummary.includes('forbidden') + ) { + return { + label: i18n.t('accountCard.failureLabelNoAccess'), + summary, + actionHint: actionHint || i18n.t('accountCard.failureHintNoAccess'), + technicalDetail, + rawDetail, + tone: 'muted', + }; + } + + if ( + quota.httpStatus === 429 || + errorCode === 'rate_limited' || + lowerSummary.includes('rate limited') + ) { + return { + label: i18n.t('accountCard.failureLabelRetry'), + summary, + actionHint: actionHint || i18n.t('accountCard.failureHintRetry'), + technicalDetail, + rawDetail, + tone: 'warning', + }; + } + + if ( + errorCode === 'auth_file_missing' || + errorCode === 'missing_account_id' || + lowerSummary.includes('auth file not found') || + lowerSummary.includes('missing chatgpt-account-id') + ) { + return { + label: i18n.t('accountCard.failureLabelReconnect'), + summary, + actionHint: actionHint || i18n.t('accountCard.failureHintReconnect'), + technicalDetail, + rawDetail, + tone: 'muted', + }; + } + + if ( + quota.retryable || + errorCode === 'network_timeout' || + errorCode === 'network_error' || + errorCode === 'provider_unavailable' || + lowerSummary.includes('timeout') || + lowerSummary.includes('network') || + lowerSummary.includes('fetch failed') || + lowerSummary.includes('service unavailable') + ) { + return { + label: i18n.t('accountCard.failureLabelTemporary'), + summary, + actionHint: actionHint || i18n.t('accountCard.failureHintTemporary'), + technicalDetail, + rawDetail, + tone: 'warning', + }; + } + + return { + label: i18n.t('accountCard.failureLabelUnavailable'), + summary, + actionHint, + technicalDetail, + rawDetail, + tone: 'muted', + }; +} + /** * Get minimum quota percentage for any provider * Centralizes provider-specific logic to eliminate duplication diff --git a/ui/tests/unit/ui/lib/quota-utils.test.ts b/ui/tests/unit/ui/lib/quota-utils.test.ts index 62865580..f17391f0 100644 --- a/ui/tests/unit/ui/lib/quota-utils.test.ts +++ b/ui/tests/unit/ui/lib/quota-utils.test.ts @@ -12,6 +12,7 @@ import { getCodexWindowDisplayLabel, getMinGeminiQuota, getGeminiResetTime, + getQuotaFailureInfo, getProviderMinQuota, getProviderResetTime, isAgyQuotaResult, @@ -1724,3 +1725,102 @@ describe('getProviderResetTime', () => { }); }); }); + +describe('getQuotaFailureInfo', () => { + it('maps reauth failures to a clear reauth badge and technical detail', () => { + const quota: CodexQuotaResult = { + success: false, + windows: [], + planType: null, + lastUpdated: Date.now(), + error: 'Codex token expired or invalid', + needsReauth: true, + httpStatus: 401, + errorCode: 'reauth_required', + errorDetail: '{"detail":"session expired"}', + }; + + expect(getQuotaFailureInfo(quota)).toEqual({ + label: 'Reauth', + summary: 'Codex token expired or invalid', + actionHint: 'Refresh this account by running its auth flow again.', + technicalDetail: 'HTTP 401 | reauth_required', + rawDetail: '{"detail":"session expired"}', + tone: 'warning', + }); + }); + + it('maps 402 workspace failures to a workspace badge and actionable hint', () => { + const quota: CodexQuotaResult = { + success: false, + windows: [], + planType: null, + lastUpdated: Date.now(), + error: 'Workspace deactivated (HTTP 402)', + httpStatus: 402, + errorCode: 'deactivated_workspace', + errorDetail: '{"detail":{"code":"deactivated_workspace"}}', + }; + + expect(getQuotaFailureInfo(quota)).toEqual({ + label: 'Workspace', + summary: 'Workspace deactivated (HTTP 402)', + actionHint: 'Move this account back to an active workspace, then remove and re-add it.', + technicalDetail: 'HTTP 402 | deactivated_workspace', + rawDetail: '{"detail":{"code":"deactivated_workspace"}}', + tone: 'warning', + }); + }); + + it('maps retryable network failures to a temporary badge', () => { + const quota: GeminiCliQuotaResult = { + success: false, + buckets: [], + projectId: null, + lastUpdated: Date.now(), + error: 'Network timeout while fetching quota', + errorCode: 'network_timeout', + retryable: true, + errorDetail: 'ETIMEDOUT', + }; + + expect(getQuotaFailureInfo(quota)).toEqual({ + label: 'Temporary', + summary: 'Network timeout while fetching quota', + actionHint: 'Retry later. This looks temporary.', + technicalDetail: 'network_timeout', + rawDetail: 'ETIMEDOUT', + tone: 'warning', + }); + }); + + it('returns null when quota fetch succeeded', () => { + const quota: QuotaResult = { + success: true, + models: [], + lastUpdated: Date.now(), + }; + + expect(getQuotaFailureInfo(quota)).toBeNull(); + }); + + it('suppresses raw detail when it only duplicates the summary', () => { + const quota: CodexQuotaResult = { + success: false, + windows: [], + planType: null, + lastUpdated: Date.now(), + error: 'Quota fetch failed', + errorDetail: 'Quota fetch failed', + }; + + expect(getQuotaFailureInfo(quota)).toEqual({ + label: 'Temporary', + summary: 'Quota fetch failed', + actionHint: 'Retry later. This looks temporary.', + technicalDetail: null, + rawDetail: null, + tone: 'warning', + }); + }); +});