From 19f6b3c4e65c514b42b331380065f34db9dc5405 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Thu, 26 Mar 2026 17:12:54 -0400 Subject: [PATCH] fix(cliproxy): surface quota metadata in CLI --- src/cliproxy/quota-fetcher-gemini-cli.ts | 7 ++ src/commands/cliproxy/quota-subcommand.ts | 84 +++++++++++++++++-- .../cliproxy/quota-fetcher-gemini-cli.test.ts | 64 +++++++++++++- .../cliproxy-quota-subcommand.test.ts | 78 +++++++++++++++++ 4 files changed, 227 insertions(+), 6 deletions(-) create mode 100644 tests/unit/commands/cliproxy-quota-subcommand.test.ts diff --git a/src/cliproxy/quota-fetcher-gemini-cli.ts b/src/cliproxy/quota-fetcher-gemini-cli.ts index 8bc983d4..f8364a91 100644 --- a/src/cliproxy/quota-fetcher-gemini-cli.ts +++ b/src/cliproxy/quota-fetcher-gemini-cli.ts @@ -743,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 05dfdcab..e8cb4e46 100644 --- a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts @@ -22,6 +22,7 @@ describe('Gemini CLI Quota Fetcher', () => { 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; @@ -69,7 +70,12 @@ describe('Gemini CLI Quota Fetcher', () => { const configGenerator = await import( `../../../src/cliproxy/config-generator?gemini-config-generator=${moduleVersion}` ); - ({ buildGeminiCliBuckets, fetchGeminiCliQuota, resolveGeminiCliProjectId } = await import( + ({ + buildGeminiCliBuckets, + fetchGeminiCliQuota, + resolveGeminiCliProjectId, + __testExports: geminiTestExports, + } = await import( `../../../src/cliproxy/quota-fetcher-gemini-cli?gemini-quota-fetcher=${moduleVersion}` )); ({ refreshGeminiToken } = await import( @@ -452,6 +458,62 @@ describe('Gemini CLI Quota Fetcher', () => { }); }); + 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', + }, + ]); + }); +});