fix(cliproxy): surface quota metadata in CLI

This commit is contained in:
Tam Nhu Tran
2026-03-26 17:13:11 -04:00
parent 4d49d13931
commit 19f6b3c4e6
4 changed files with 227 additions and 6 deletions
+7
View File
@@ -743,5 +743,12 @@ export async function fetchAllGeminiCliQuotas(
return results;
}
export const __testExports = {
sanitizeGeminiCliErrorDetail,
extractGeminiCliNestedMessage,
parseGeminiCliErrorBody,
buildGeminiCliForbiddenActionHint,
};
// Export for testing
export { resolveGeminiCliProjectId, buildGeminiCliBuckets };
+79 -5
View File
@@ -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<QuotaSupportedProvider, QuotaProviderRuntim
},
};
export const __testExports = {
getQuotaFailureDisplayEntries,
};
export async function handleQuotaStatus(
verbose = false,
providerFilter: QuotaSupportedProvider | 'all' = 'all'
@@ -771,7 +845,7 @@ export async function handleDoctor(verbose = false): Promise<void> {
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')}`
@@ -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('<html>bad gateway</html>')).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({
@@ -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',
},
]);
});
});