diff --git a/src/cliproxy/quota-fetcher-gemini-cli.ts b/src/cliproxy/quota-fetcher-gemini-cli.ts index b29f218c..f8364a91 100644 --- a/src/cliproxy/quota-fetcher-gemini-cli.ts +++ b/src/cliproxy/quota-fetcher-gemini-cli.ts @@ -16,6 +16,8 @@ import type { GeminiCliQuotaResult, GeminiCliBucket } from './quota-types'; /** Google Cloud Code API endpoints */ const GEMINI_CLI_API_BASE = 'https://cloudcode-pa.googleapis.com'; const GEMINI_CLI_API_VERSION = 'v1internal'; +const GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH = 320; +const GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX = '...[truncated]'; /** * Model groups for quota consolidation. @@ -68,6 +70,12 @@ interface GeminiCliQuotaResponse { buckets?: RawGeminiCliBucket[]; } +interface ParsedGeminiCliErrorBody { + errorCode?: string; + errorDetail?: string; + message?: string; +} + /** * Extract project ID from account field * Input: "user@example.com (cloudaicompanion-abc-123)" @@ -240,6 +248,228 @@ function shouldIgnoreModel(modelId: string): boolean { return IGNORED_MODEL_PREFIXES.some((prefix) => modelId.startsWith(prefix)); } +function buildGeminiCliFailureResult( + accountId: string, + projectId: string | null, + options: { + error: string; + httpStatus?: number; + errorCode?: string; + errorDetail?: string; + actionHint?: string; + retryable?: boolean; + needsReauth?: boolean; + isForbidden?: boolean; + } +): GeminiCliQuotaResult { + return { + success: false, + buckets: [], + projectId, + 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 sanitizeGeminiCliErrorDetail(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 > GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH) { + sanitized = `${sanitized.slice( + 0, + GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH - GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX.length + )}${GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX}`; + } + + return sanitized; +} + +function extractGeminiCliNestedMessage(value: unknown): string | undefined { + if (Array.isArray(value)) { + for (const entry of value) { + const nested = extractGeminiCliNestedMessage(entry); + if (nested) return nested; + } + return undefined; + } + + if (!value || typeof value !== 'object') { + return undefined; + } + + const record = value as Record; + const directMessage = [ + record.message, + record.localizedMessage, + record.description, + record.reason, + record.error, + ].find( + (candidate): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0 + ); + if (directMessage) { + return directMessage; + } + + return undefined; +} + +function parseGeminiCliErrorBody(bodyText: string): ParsedGeminiCliErrorBody { + const trimmed = bodyText.trim(); + if (!trimmed) { + return {}; + } + + const sanitizedDetail = sanitizeGeminiCliErrorDetail(trimmed); + + try { + const parsed = JSON.parse(trimmed) as Record; + const topLevelMessage = [parsed.message, parsed.error].find( + (candidate): candidate is string => + typeof candidate === 'string' && candidate.trim().length > 0 + ); + const topLevelCode = [parsed.code, parsed.status].find( + (candidate): candidate is string => + typeof candidate === 'string' && candidate.trim().length > 0 + ); + + if (parsed.error && typeof parsed.error === 'object') { + const error = parsed.error as Record; + return { + errorCode: + [error.status, error.code, topLevelCode].find( + (candidate): candidate is string => + typeof candidate === 'string' && candidate.trim().length > 0 + ) || undefined, + errorDetail: sanitizedDetail, + message: + [ + error.message, + error.error, + extractGeminiCliNestedMessage(error.details), + topLevelMessage, + ].find( + (candidate): candidate is string => + typeof candidate === 'string' && candidate.trim().length > 0 + ) || undefined, + }; + } + + return { + errorCode: topLevelCode, + errorDetail: sanitizedDetail, + message: + [topLevelMessage, extractGeminiCliNestedMessage(parsed.details)].find( + (candidate): candidate is string => + typeof candidate === 'string' && candidate.trim().length > 0 + ) || undefined, + }; + } catch { + return { + errorDetail: sanitizedDetail, + message: sanitizedDetail === '[HTML error response omitted]' ? undefined : trimmed, + }; + } +} + +function buildGeminiCliForbiddenActionHint(parsed: ParsedGeminiCliErrorBody): string { + const combined = `${parsed.message || ''} ${parsed.errorDetail || ''}`.toLowerCase(); + if (combined.includes('verify') || combined.includes('verification')) { + return 'Complete the Google account verification mentioned above, then retry quota refresh.'; + } + if (combined.includes('project')) { + return 'Confirm this Google project still has Gemini CLI quota access, then retry.'; + } + return 'Check the Google account or workspace access shown above, then retry quota refresh.'; +} + +function buildGeminiCliHttpFailureResult( + accountId: string, + projectId: string | null, + status: number, + bodyText: string +): GeminiCliQuotaResult { + const parsed = parseGeminiCliErrorBody(bodyText); + + if (status === 401) { + return buildGeminiCliFailureResult(accountId, projectId, { + error: parsed.message || 'Token expired or invalid', + httpStatus: 401, + errorCode: parsed.errorCode || 'reauth_required', + errorDetail: parsed.errorDetail, + actionHint: 'Run ccs gemini --auth to reconnect this account.', + needsReauth: true, + retryable: false, + }); + } + + if (status === 403) { + return buildGeminiCliFailureResult(accountId, projectId, { + error: parsed.message || 'Quota access forbidden for this account', + httpStatus: 403, + errorCode: parsed.errorCode || 'quota_api_forbidden', + errorDetail: parsed.errorDetail, + actionHint: buildGeminiCliForbiddenActionHint(parsed), + isForbidden: true, + retryable: false, + }); + } + + if (status === 429) { + return buildGeminiCliFailureResult(accountId, projectId, { + error: parsed.message || '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 buildGeminiCliFailureResult(accountId, projectId, { + error: parsed.message || `Gemini quota service unavailable (HTTP ${status})`, + httpStatus: status, + errorCode: parsed.errorCode || 'provider_unavailable', + errorDetail: parsed.errorDetail, + actionHint: 'Retry later. This looks like a temporary Google upstream problem.', + retryable: true, + }); + } + + return buildGeminiCliFailureResult(accountId, projectId, { + error: parsed.message || `Gemini quota request failed (HTTP ${status})`, + httpStatus: status, + errorCode: parsed.errorCode || 'quota_request_failed', + errorDetail: parsed.errorDetail, + actionHint: 'Inspect the upstream response details and retry if appropriate.', + retryable: false, + }); +} + /** * Build GeminiCliBucket array from API response * Groups buckets by model series and token type @@ -334,14 +564,12 @@ async function fetchWithAuthData( if (!authData.projectId) { const error = 'Cannot resolve project ID from auth file'; if (verbose) console.error(`[!] Error: ${error}`); - return { - success: false, - buckets: [], - projectId: null, - lastUpdated: Date.now(), + return buildGeminiCliFailureResult(accountId, null, { error, - accountId, - }; + errorCode: 'missing_project_id', + actionHint: 'Run ccs gemini --auth to reconnect this account and recover the project ID.', + retryable: false, + }); } const url = `${GEMINI_CLI_API_BASE}/${GEMINI_CLI_API_VERSION}:retrieveUserQuota`; @@ -363,49 +591,14 @@ async function fetchWithAuthData( if (verbose) console.error(`[i] Gemini CLI API status: ${response.status}`); - if (response.status === 401) { - return { - success: false, - buckets: [], - projectId: authData.projectId, - lastUpdated: Date.now(), - error: 'Token expired or invalid', - accountId, - needsReauth: true, - }; - } - - if (response.status === 403) { - return { - success: false, - buckets: [], - projectId: authData.projectId, - lastUpdated: Date.now(), - error: 'Quota access forbidden for this account', - accountId, - }; - } - - if (response.status === 429) { - return { - success: false, - buckets: [], - projectId: authData.projectId, - lastUpdated: Date.now(), - error: 'Rate limited - try again later', - accountId, - }; - } - if (!response.ok) { - return { - success: false, - buckets: [], - projectId: authData.projectId, - lastUpdated: Date.now(), - error: `API error: ${response.status}`, + const bodyText = await response.text(); + return buildGeminiCliHttpFailureResult( accountId, - }; + authData.projectId, + response.status, + bodyText + ); } const data = (await response.json()) as GeminiCliQuotaResponse; @@ -432,14 +625,14 @@ async function fetchWithAuthData( if (verbose) console.error(`[!] Gemini CLI quota error: ${errorMsg}`); - return { - success: false, - buckets: [], - projectId: authData.projectId, - lastUpdated: Date.now(), + return buildGeminiCliFailureResult(accountId, authData.projectId, { error: errorMsg, - accountId, - }; + errorCode: + err instanceof Error && err.name === 'AbortError' ? 'network_timeout' : 'network_error', + actionHint: 'Retry later. This looks temporary.', + retryable: true, + httpStatus: err instanceof Error && err.name === 'AbortError' ? 408 : undefined, + }); } } @@ -460,14 +653,12 @@ export async function fetchGeminiCliQuota( if (!authData) { const error = 'Auth file not found for Gemini account'; if (verbose) console.error(`[!] Error: ${error}`); - return { - success: false, - buckets: [], - projectId: null, - lastUpdated: Date.now(), + return buildGeminiCliFailureResult(accountId, null, { error, - accountId, - }; + errorCode: 'auth_file_missing', + actionHint: 'Run ccs gemini --auth to reconnect this account.', + retryable: false, + }); } // Proactive refresh: refresh if expired OR expiring within 5 minutes @@ -497,15 +688,14 @@ export async function fetchGeminiCliQuota( // Only fail if token is actually expired (not just expiring soon) const error = refreshResult.error || 'Token refresh failed'; if (verbose) console.error(`[!] Refresh failed: ${error}`); - return { - success: false, - buckets: [], - projectId: null, - lastUpdated: Date.now(), + return buildGeminiCliFailureResult(accountId, authData.projectId, { error, - accountId, + errorCode: 'reauth_required', + errorDetail: error, + actionHint: 'Run ccs gemini --auth to reconnect this account.', needsReauth: true, - }; + retryable: false, + }); } // If proactive refresh fails but token isn't expired yet, continue with existing token } @@ -553,5 +743,12 @@ export async function fetchAllGeminiCliQuotas( return results; } +export const __testExports = { + sanitizeGeminiCliErrorDetail, + extractGeminiCliNestedMessage, + parseGeminiCliErrorBody, + buildGeminiCliForbiddenActionHint, +}; + // Export for testing export { resolveGeminiCliProjectId, buildGeminiCliBuckets }; diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index c19dec31..ae0c8573 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -27,6 +27,7 @@ import type { ClaudeQuotaResult, GeminiCliQuotaResult, GhcpQuotaResult, + QuotaErrorMetadata, } from '../../cliproxy/quota-types'; import { isOnCooldown } from '../../cliproxy/quota-manager'; import { CLIProxyProvider } from '../../cliproxy/types'; @@ -96,6 +97,75 @@ function formatResetTimeISO(isoTime: string): string { return formatResetTime(seconds); } +interface QuotaFailureDisplayEntry { + tone: 'error' | 'info' | 'dim'; + text: string; +} + +function getQuotaFailureDisplayEntries( + quota: QuotaErrorMetadata & { + error?: string; + } +): QuotaFailureDisplayEntry[] { + const entries: QuotaFailureDisplayEntry[] = [ + { + tone: 'error', + text: quota.error || 'Failed to fetch quota', + }, + ]; + + if (quota.actionHint) { + entries.push({ + tone: 'info', + text: quota.actionHint, + }); + } + + const diagnostics: string[] = []; + if (typeof quota.httpStatus === 'number') { + diagnostics.push(`HTTP ${quota.httpStatus}`); + } + if (quota.errorCode) { + diagnostics.push(`Code: ${quota.errorCode}`); + } + if (quota.retryable) { + diagnostics.push('Retryable'); + } + if (diagnostics.length > 0) { + entries.push({ + tone: 'dim', + text: diagnostics.join(' | '), + }); + } + + const normalizedError = quota.error?.trim(); + const normalizedDetail = quota.errorDetail?.trim(); + if (normalizedDetail && normalizedDetail !== normalizedError) { + entries.push({ + tone: 'dim', + text: `Detail: ${normalizedDetail}`, + }); + } + + return entries; +} + +function displayQuotaFailure( + quota: QuotaErrorMetadata & { + error?: string; + } +): void { + for (const entry of getQuotaFailureDisplayEntries(quota)) { + const rendered = + entry.tone === 'error' + ? color(entry.text, 'error') + : entry.tone === 'info' + ? info(entry.text) + : dim(entry.text); + console.log(` ${rendered}`); + } +} + function formatAbsoluteResetTime(isoTime: string): string | null { if (!isoTime) return null; const resetDate = new Date(isoTime); @@ -318,7 +388,7 @@ function displayCodexQuotaSection(results: { account: string; quota: CodexQuotaR if (!quota.success) { console.log(` ${fail(account)}${defaultMark}`); - console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`); + displayQuotaFailure(quota); console.log(''); continue; } @@ -473,7 +543,7 @@ function displayClaudeQuotaSection(results: { account: string; quota: ClaudeQuot if (!quota.success) { console.log(` ${fail(account)}${defaultMark}`); - console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`); + displayQuotaFailure(quota); console.log(''); continue; } @@ -550,7 +620,7 @@ function displayGeminiCliQuotaSection( if (!quota.success) { console.log(` ${fail(account)}${defaultMark}`); - console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`); + displayQuotaFailure(quota); console.log(''); continue; } @@ -601,7 +671,7 @@ function displayGhcpQuotaSection(results: { account: string; quota: GhcpQuotaRes if (!quota.success) { console.log(` ${fail(account)}${defaultMark}`); - console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`); + displayQuotaFailure(quota); console.log(''); continue; } @@ -697,6 +767,10 @@ const QUOTA_PROVIDER_RUNTIME: Record { if (!quota.success) { console.log(` ${fail(accountLabel)}${defaultBadge}`); - console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`); + displayQuotaFailure(quota); if (quota.isUnprovisioned) { console.log( ` ${warn('Account not provisioned - open Gemini Code Assist in IDE first')}` diff --git a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts index 76850fb5..e8cb4e46 100644 --- a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts @@ -11,6 +11,8 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks'; describe('Gemini CLI Quota Fetcher', () => { + const GEMINI_QUOTA_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota'; + const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; let tempHome: string; let originalCcsHome: string | undefined; let originalCcsDir: string | undefined; @@ -18,7 +20,9 @@ describe('Gemini CLI Quota Fetcher', () => { let originalGeminiClientSecret: string | undefined; let moduleVersion = 0; let buildGeminiCliBuckets: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').buildGeminiCliBuckets; + let fetchGeminiCliQuota: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').fetchGeminiCliQuota; let resolveGeminiCliProjectId: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').resolveGeminiCliProjectId; + let geminiTestExports: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').__testExports; let refreshGeminiToken: typeof import('../../../src/cliproxy/auth/gemini-token-refresh').refreshGeminiToken; let getProviderAuthDir: typeof import('../../../src/cliproxy/config-generator').getProviderAuthDir; @@ -30,6 +34,26 @@ describe('Gemini CLI Quota Fetcher', () => { return tokenPath; } + function writeActiveGeminiAccount( + accountId: string, + overrides: Record = {} + ): string { + return writeGeminiToken({ + type: 'gemini', + email: accountId, + project_id: 'cloudaicompanion-test-123', + token: { + access_token: 'access-token', + refresh_token: 'refresh-token', + expiry: Date.now() + 60 * 60 * 1000, + client_id: 'test-client-id', + client_secret: 'test-client-secret', + token_uri: GOOGLE_TOKEN_URL, + }, + ...overrides, + }); + } + beforeEach(async () => { moduleVersion += 1; tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-gemini-refresh-')); @@ -46,7 +70,12 @@ describe('Gemini CLI Quota Fetcher', () => { const configGenerator = await import( `../../../src/cliproxy/config-generator?gemini-config-generator=${moduleVersion}` ); - ({ buildGeminiCliBuckets, resolveGeminiCliProjectId } = await import( + ({ + buildGeminiCliBuckets, + fetchGeminiCliQuota, + resolveGeminiCliProjectId, + __testExports: geminiTestExports, + } = await import( `../../../src/cliproxy/quota-fetcher-gemini-cli?gemini-quota-fetcher=${moduleVersion}` )); ({ refreshGeminiToken } = await import( @@ -265,6 +294,226 @@ describe('Gemini CLI Quota Fetcher', () => { }); }); + describe('fetchGeminiCliQuota failure metadata', () => { + it('maps 401 responses to reauth-required metadata', async () => { + writeActiveGeminiAccount('reauth@example.com'); + + mockFetch([ + { + url: GEMINI_QUOTA_URL, + method: 'POST', + status: 401, + response: { + error: { + message: 'Session expired', + status: 'UNAUTHENTICATED', + }, + }, + }, + { + url: GOOGLE_TOKEN_URL, + method: 'POST', + status: 400, + response: { + error: 'invalid_grant', + }, + }, + ]); + + const result = await fetchGeminiCliQuota('reauth@example.com'); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(401); + expect(result.errorCode).toBe('UNAUTHENTICATED'); + expect(result.needsReauth).toBe(true); + expect(result.retryable).toBe(false); + expect(result.actionHint).toContain('ccs gemini --auth'); + expect(result.error).toBe('Session expired'); + }); + + it('preserves 403 verification detail and exposes a helpful action hint', async () => { + writeActiveGeminiAccount('verify@example.com'); + + mockFetch([ + { + url: GEMINI_QUOTA_URL, + method: 'POST', + status: 403, + response: { + error: { + message: 'Google requires you to verify this account before using Gemini CLI quota.', + status: 'PERMISSION_DENIED', + details: [ + { + reason: 'ACCOUNT_VERIFICATION_REQUIRED', + }, + ], + }, + }, + }, + ]); + + const result = await fetchGeminiCliQuota('verify@example.com'); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(403); + expect(result.isForbidden).toBe(true); + expect(result.retryable).toBe(false); + expect(result.error).toContain('verify this account'); + expect(result.actionHint).toContain('verification'); + expect(result.errorDetail).toContain('ACCOUNT_VERIFICATION_REQUIRED'); + }); + + it('marks 429 responses as retryable', async () => { + writeActiveGeminiAccount('rate-limit@example.com'); + + mockFetch([ + { + url: GEMINI_QUOTA_URL, + method: 'POST', + status: 429, + response: { + error: { + message: 'Too many quota requests', + status: 'RESOURCE_EXHAUSTED', + }, + }, + }, + ]); + + const result = await fetchGeminiCliQuota('rate-limit@example.com'); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(429); + expect(result.retryable).toBe(true); + expect(result.errorCode).toBe('RESOURCE_EXHAUSTED'); + expect(result.actionHint).toContain('Retry'); + expect(result.error).toBe('Too many quota requests'); + }); + + it('preserves non-JSON upstream error text when Gemini returns a plain-text failure', async () => { + writeActiveGeminiAccount('plaintext@example.com'); + + mockFetch([ + { + url: GEMINI_QUOTA_URL, + method: 'POST', + status: 418, + headers: { 'Content-Type': 'text/plain' }, + response: 'Internal Server Error', + }, + ]); + + const result = await fetchGeminiCliQuota('plaintext@example.com'); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(418); + expect(result.errorCode).toBe('quota_request_failed'); + expect(result.retryable).toBe(false); + expect(result.error).toBe('Internal Server Error'); + expect(result.errorDetail).toBe('Internal Server Error'); + }); + + it('marks 5xx Gemini quota responses as retryable provider outages', async () => { + writeActiveGeminiAccount('outage@example.com'); + + mockFetch([ + { + url: GEMINI_QUOTA_URL, + method: 'POST', + status: 503, + headers: { 'Content-Type': 'text/plain' }, + response: 'Service temporarily unavailable', + }, + ]); + + const result = await fetchGeminiCliQuota('outage@example.com'); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(503); + expect(result.errorCode).toBe('provider_unavailable'); + expect(result.retryable).toBe(true); + expect(result.actionHint).toContain('temporary Google upstream problem'); + expect(result.error).toBe('Service temporarily unavailable'); + }); + + it('omits raw HTML upstream bodies from Gemini quota error detail', async () => { + writeActiveGeminiAccount('html@example.com'); + + mockFetch([ + { + url: GEMINI_QUOTA_URL, + method: 'POST', + status: 502, + headers: { 'Content-Type': 'text/html' }, + response: 'bad gateway', + }, + ]); + + const result = await fetchGeminiCliQuota('html@example.com'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Gemini quota service unavailable (HTTP 502)'); + expect(result.errorDetail).toBe('[HTML error response omitted]'); + }); + }); + + describe('direct Gemini error helper coverage', () => { + it('sanitizes HTML and truncates oversized token-bearing error details', () => { + const longTokenBody = JSON.stringify({ + access_token: 'super-secret-token', + detail: `Bearer top-secret ${'x'.repeat(400)}`, + }); + + const sanitized = geminiTestExports.sanitizeGeminiCliErrorDetail(longTokenBody); + + expect(sanitized).toContain('[redacted]'); + expect(sanitized).toContain('Bearer [redacted]'); + expect(sanitized?.endsWith('...[truncated]')).toBe(true); + expect(sanitized?.length).toBeLessThanOrEqual(320); + expect(geminiTestExports.sanitizeGeminiCliErrorDetail('bad gateway')).toBe( + '[HTML error response omitted]' + ); + }); + + it('extracts nested messages and parses structured JSON error bodies', () => { + expect( + geminiTestExports.extractGeminiCliNestedMessage([ + { reason: 'ACCOUNT_VERIFICATION_REQUIRED' }, + ]) + ).toBe('ACCOUNT_VERIFICATION_REQUIRED'); + + const parsed = geminiTestExports.parseGeminiCliErrorBody( + JSON.stringify({ + error: { + message: 'Verification required', + status: 'PERMISSION_DENIED', + details: [{ reason: 'ACCOUNT_VERIFICATION_REQUIRED' }], + }, + }) + ); + + expect(parsed.message).toBe('Verification required'); + expect(parsed.errorCode).toBe('PERMISSION_DENIED'); + expect(parsed.errorDetail).toContain('ACCOUNT_VERIFICATION_REQUIRED'); + }); + + it('builds verification and project-specific forbidden action hints', () => { + expect( + geminiTestExports.buildGeminiCliForbiddenActionHint({ + message: 'Please verify this account', + errorDetail: 'ACCOUNT_VERIFICATION_REQUIRED', + }) + ).toContain('verification'); + + expect( + geminiTestExports.buildGeminiCliForbiddenActionHint({ + message: 'Project no longer has access', + }) + ).toContain('project'); + }); + }); + describe('refreshGeminiToken', () => { it('uses OAuth client metadata stored in the token file', async () => { writeGeminiToken({ diff --git a/tests/unit/commands/cliproxy-quota-subcommand.test.ts b/tests/unit/commands/cliproxy-quota-subcommand.test.ts new file mode 100644 index 00000000..0b49cfef --- /dev/null +++ b/tests/unit/commands/cliproxy-quota-subcommand.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'bun:test'; + +async function loadQuotaCommandTestExports() { + const moduleId = Date.now() + Math.random(); + const mod = await import( + `../../../src/commands/cliproxy/quota-subcommand?cliproxy-quota-subcommand=${moduleId}` + ); + return mod.__testExports; +} + +describe('cliproxy quota subcommand failure formatting', () => { + it('builds Gemini failure lines with the remediation hint, code, and detail', async () => { + const { getQuotaFailureDisplayEntries } = await loadQuotaCommandTestExports(); + + const entries = getQuotaFailureDisplayEntries({ + error: 'Google requires you to verify this account before using Gemini CLI quota.', + actionHint: + 'Complete the Google account verification mentioned above, then retry quota refresh.', + httpStatus: 403, + errorCode: 'PERMISSION_DENIED', + errorDetail: 'ACCOUNT_VERIFICATION_REQUIRED', + retryable: false, + }); + + expect(entries).toEqual([ + { + tone: 'error', + text: 'Google requires you to verify this account before using Gemini CLI quota.', + }, + { + tone: 'info', + text: 'Complete the Google account verification mentioned above, then retry quota refresh.', + }, + { + tone: 'dim', + text: 'HTTP 403 | Code: PERMISSION_DENIED', + }, + { + tone: 'dim', + text: 'Detail: ACCOUNT_VERIFICATION_REQUIRED', + }, + ]); + }); + + it('marks retryable failures in the CLI diagnostics line', async () => { + const { getQuotaFailureDisplayEntries } = await loadQuotaCommandTestExports(); + + const entries = getQuotaFailureDisplayEntries({ + error: 'Gemini quota service unavailable (HTTP 503)', + actionHint: 'Retry later. This looks like a temporary Google upstream problem.', + httpStatus: 503, + errorCode: 'provider_unavailable', + errorDetail: 'Service temporarily unavailable', + retryable: true, + }); + + expect(entries[2]).toEqual({ + tone: 'dim', + text: 'HTTP 503 | Code: provider_unavailable | Retryable', + }); + }); + + it('suppresses duplicate error detail lines', async () => { + const { getQuotaFailureDisplayEntries } = await loadQuotaCommandTestExports(); + + const entries = getQuotaFailureDisplayEntries({ + error: 'Internal Server Error', + errorDetail: 'Internal Server Error', + }); + + expect(entries).toEqual([ + { + tone: 'error', + text: 'Internal Server Error', + }, + ]); + }); +}); diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index ab0964b3..b5c14ad7 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -486,8 +486,7 @@ const resources = { 'Settings > Proxy power user mode is skipping the AGY responsibility checklist and Gemini dashboard risk phrase. You accept full responsibility for OAuth/account risk.', powerUserLoadFailed: 'Failed to load power user mode settings. Check Settings > Proxy and try again.', - powerUserLoading: - 'Loading power user safety settings. Please wait a moment and retry.', + powerUserLoading: 'Loading power user safety settings. Please wait a moment and retry.', powerUserUnavailableRetry: 'Power user mode is unavailable. Complete the required provider safety step and retry.', authMethod: 'Auth Method', @@ -763,7 +762,7 @@ const resources = { variantsIncompatible: 'Existing Kiro/Copilot variants will not work with CLIProxyAPI. Switch to CLIProxyAPIPlus or remove those variants.', safety: 'Safety', - agyModeTitle: 'Antigravity Power User Mode', + agyModeTitle: 'Antigravity + Gemini Power User Mode', agyModeDesc: 'Skip the AGY responsibility checklist and the Gemini dashboard typed acknowledgement.', agyWarning: @@ -776,10 +775,10 @@ const resources = { step2: 'Step 2', typePrefix: 'Type', typeSuffix: 'to enable.', - typePhraseAria: 'Type I ACCEPT RISK to enable power user mode', + typePhraseAria: 'Type I ACCEPT RISK to enable Antigravity + Gemini power user mode', exactPhrase: 'Exact phrase required.', - enableAgyMode: 'Enable Power User Mode', - toggleAgyMode: 'Toggle power user mode', + enableAgyMode: 'Enable Antigravity + Gemini Power User Mode', + toggleAgyMode: 'Toggle Antigravity + Gemini power user mode', fallbackSettings: 'Fallback Settings', enableFallback: 'Enable fallback to local', enableFallbackDesc: 'Use local proxy if remote is unreachable', @@ -1939,7 +1938,7 @@ const resources = { variantsIncompatible: '现有 Kiro/Copilot 变体与 CLIProxyAPI 不兼容。请切换到 CLIProxyAPIPlus 或移除这些变体。', safety: '安全', - agyModeTitle: 'Antigravity 高级模式', + agyModeTitle: 'Antigravity + Gemini 高级模式', agyModeDesc: '跳过 AGY 责任确认清单,以及 Gemini Dashboard 中输入风险短语的步骤。', agyWarning: '仅在你充分理解 OAuth 封禁风险模式(#509)后使用。CCS 不对账号损失承担责任。', finalConfirm: '需要最终确认', @@ -1950,10 +1949,10 @@ const resources = { step2: '步骤 2', typePrefix: '输入', typeSuffix: '以启用。', - typePhraseAria: '输入 I ACCEPT RISK 以启用高级模式', + typePhraseAria: '输入 I ACCEPT RISK 以启用 Antigravity + Gemini 高级模式', exactPhrase: '必须输入完全一致的短语。', - enableAgyMode: '启用高级模式', - toggleAgyMode: '切换高级模式', + enableAgyMode: '启用 Antigravity + Gemini 高级模式', + toggleAgyMode: '切换 Antigravity + Gemini 高级模式', fallbackSettings: '回退设置', enableFallback: '启用本地回退', enableFallbackDesc: '远程不可达时使用本地代理', @@ -3155,7 +3154,7 @@ const resources = { variantsIncompatible: 'Các biến thể Kiro/Copilot hiện tại sẽ không hoạt động với CLIProxyAPI. Chuyển sang CLIProxyAPIPlus hoặc xóa các biến thể đó.', safety: 'An toàn', - agyModeTitle: 'Chế độ power user Antigravity', + agyModeTitle: 'Chế độ power user Antigravity + Gemini', agyModeDesc: 'Bỏ qua danh sách kiểm tra trách nhiệm AGY và bước nhập cụm từ xác nhận của Gemini trên dashboard.', agyWarning: @@ -3168,10 +3167,10 @@ const resources = { step2: 'Bước 2', typePrefix: 'Nhập', typeSuffix: 'để kích hoạt.', - typePhraseAria: 'Nhập I ACCEPT RISK để bật chế độ power user', + typePhraseAria: 'Nhập I ACCEPT RISK để bật chế độ power user Antigravity + Gemini', exactPhrase: 'Phải nhập chính xác cụm từ.', - enableAgyMode: 'Bật chế độ power user', - toggleAgyMode: 'Chuyển đổi chế độ power user', + enableAgyMode: 'Bật chế độ power user Antigravity + Gemini', + toggleAgyMode: 'Chuyển đổi chế độ power user Antigravity + Gemini', fallbackSettings: 'Cài đặt dự phòng', enableFallback: 'Cho phép dự phòng về cục bộ', enableFallbackDesc: 'Sử dụng proxy cục bộ nếu không thể truy cập được từ xa', @@ -4389,7 +4388,7 @@ const resources = { variantsIncompatible: '既存の Kiro/Copilot バリアントは CLIProxyAPI では動作しません。CLIProxyAPIPlus に切り替えるか、それらのバリアントを削除してください。', safety: '安全設定', - agyModeTitle: 'Antigravity パワーユーザーモード', + agyModeTitle: 'Antigravity + Gemini パワーユーザーモード', agyModeDesc: 'アカウント追加画面の AGY 責任確認チェックと、Gemini ダッシュボードの入力確認をスキップします。', agyWarning: @@ -4402,10 +4401,11 @@ const resources = { step2: 'ステップ 2', typePrefix: '有効にするには', typeSuffix: 'と入力してください。', - typePhraseAria: 'パワーユーザーモードを有効にするには I ACCEPT RISK と入力してください', + typePhraseAria: + 'Antigravity + Gemini パワーユーザーモードを有効にするには I ACCEPT RISK と入力してください', exactPhrase: '完全一致で入力してください。', - enableAgyMode: 'パワーユーザーモードを有効にする', - toggleAgyMode: 'パワーユーザーモードを切り替え', + enableAgyMode: 'Antigravity + Gemini パワーユーザーモードを有効にする', + toggleAgyMode: 'Antigravity + Gemini パワーユーザーモードを切り替え', fallbackSettings: 'フォールバック設定', enableFallback: 'ローカルへのフォールバックを有効化', enableFallbackDesc: 'リモートに接続できない場合はローカルプロキシを使用します',