mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 02:11:28 +00:00
fix(cliproxy): surface quota metadata in CLI
This commit is contained in:
@@ -743,5 +743,12 @@ export async function fetchAllGeminiCliQuotas(
|
||||
return results;
|
||||
}
|
||||
|
||||
export const __testExports = {
|
||||
sanitizeGeminiCliErrorDetail,
|
||||
extractGeminiCliNestedMessage,
|
||||
parseGeminiCliErrorBody,
|
||||
buildGeminiCliForbiddenActionHint,
|
||||
};
|
||||
|
||||
// Export for testing
|
||||
export { resolveGeminiCliProjectId, buildGeminiCliBuckets };
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user