mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 20:15:40 +00:00
fix(cliproxy): surface quota metadata in CLI
This commit is contained in:
1 parent
4d49d13931
commit
19f6b3c4e6
4 files changed
+227
-6
No files matched your search
@@ -743,5 +743,12 @@ export async function fetchAllGeminiCliQuotas(
|
|||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const __testExports = {
|
||||||
|
sanitizeGeminiCliErrorDetail,
|
||||||
|
extractGeminiCliNestedMessage,
|
||||||
|
parseGeminiCliErrorBody,
|
||||||
|
buildGeminiCliForbiddenActionHint,
|
||||||
|
};
|
||||||
|
|
||||||
// Export for testing
|
// Export for testing
|
||||||
export { resolveGeminiCliProjectId, buildGeminiCliBuckets };
|
export { resolveGeminiCliProjectId, buildGeminiCliBuckets };
|
||||||
@@ -27,6 +27,7 @@ import type {
|
|||||||
ClaudeQuotaResult,
|
ClaudeQuotaResult,
|
||||||
GeminiCliQuotaResult,
|
GeminiCliQuotaResult,
|
||||||
GhcpQuotaResult,
|
GhcpQuotaResult,
|
||||||
|
QuotaErrorMetadata,
|
||||||
} from '../../cliproxy/quota-types';
|
} from '../../cliproxy/quota-types';
|
||||||
import { isOnCooldown } from '../../cliproxy/quota-manager';
|
import { isOnCooldown } from '../../cliproxy/quota-manager';
|
||||||
import { CLIProxyProvider } from '../../cliproxy/types';
|
import { CLIProxyProvider } from '../../cliproxy/types';
|
||||||
@@ -96,6 +97,75 @@ function formatResetTimeISO(isoTime: string): string {
|
|||||||
return formatResetTime(seconds);
|
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 {
|
function formatAbsoluteResetTime(isoTime: string): string | null {
|
||||||
if (!isoTime) return null;
|
if (!isoTime) return null;
|
||||||
const resetDate = new Date(isoTime);
|
const resetDate = new Date(isoTime);
|
||||||
@@ -318,7 +388,7 @@ function displayCodexQuotaSection(results: { account: string; quota: CodexQuotaR
|
|||||||
|
|
||||||
if (!quota.success) {
|
if (!quota.success) {
|
||||||
console.log(` ${fail(account)}${defaultMark}`);
|
console.log(` ${fail(account)}${defaultMark}`);
|
||||||
console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`);
|
displayQuotaFailure(quota);
|
||||||
console.log('');
|
console.log('');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -473,7 +543,7 @@ function displayClaudeQuotaSection(results: { account: string; quota: ClaudeQuot
|
|||||||
|
|
||||||
if (!quota.success) {
|
if (!quota.success) {
|
||||||
console.log(` ${fail(account)}${defaultMark}`);
|
console.log(` ${fail(account)}${defaultMark}`);
|
||||||
console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`);
|
displayQuotaFailure(quota);
|
||||||
console.log('');
|
console.log('');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -550,7 +620,7 @@ function displayGeminiCliQuotaSection(
|
|||||||
|
|
||||||
if (!quota.success) {
|
if (!quota.success) {
|
||||||
console.log(` ${fail(account)}${defaultMark}`);
|
console.log(` ${fail(account)}${defaultMark}`);
|
||||||
console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`);
|
displayQuotaFailure(quota);
|
||||||
console.log('');
|
console.log('');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -601,7 +671,7 @@ function displayGhcpQuotaSection(results: { account: string; quota: GhcpQuotaRes
|
|||||||
|
|
||||||
if (!quota.success) {
|
if (!quota.success) {
|
||||||
console.log(` ${fail(account)}${defaultMark}`);
|
console.log(` ${fail(account)}${defaultMark}`);
|
||||||
console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`);
|
displayQuotaFailure(quota);
|
||||||
console.log('');
|
console.log('');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -697,6 +767,10 @@ const QUOTA_PROVIDER_RUNTIME: Record<QuotaSupportedProvider, QuotaProviderRuntim
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const __testExports = {
|
||||||
|
getQuotaFailureDisplayEntries,
|
||||||
|
};
|
||||||
|
|
||||||
export async function handleQuotaStatus(
|
export async function handleQuotaStatus(
|
||||||
verbose = false,
|
verbose = false,
|
||||||
providerFilter: QuotaSupportedProvider | 'all' = 'all'
|
providerFilter: QuotaSupportedProvider | 'all' = 'all'
|
||||||
@@ -771,7 +845,7 @@ export async function handleDoctor(verbose = false): Promise<void> {
|
|||||||
|
|
||||||
if (!quota.success) {
|
if (!quota.success) {
|
||||||
console.log(` ${fail(accountLabel)}${defaultBadge}`);
|
console.log(` ${fail(accountLabel)}${defaultBadge}`);
|
||||||
console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`);
|
displayQuotaFailure(quota);
|
||||||
if (quota.isUnprovisioned) {
|
if (quota.isUnprovisioned) {
|
||||||
console.log(
|
console.log(
|
||||||
` ${warn('Account not provisioned - open Gemini Code Assist in IDE first')}`
|
` ${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 buildGeminiCliBuckets: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').buildGeminiCliBuckets;
|
||||||
let fetchGeminiCliQuota: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').fetchGeminiCliQuota;
|
let fetchGeminiCliQuota: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').fetchGeminiCliQuota;
|
||||||
let resolveGeminiCliProjectId: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').resolveGeminiCliProjectId;
|
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 refreshGeminiToken: typeof import('../../../src/cliproxy/auth/gemini-token-refresh').refreshGeminiToken;
|
||||||
let getProviderAuthDir: typeof import('../../../src/cliproxy/config-generator').getProviderAuthDir;
|
let getProviderAuthDir: typeof import('../../../src/cliproxy/config-generator').getProviderAuthDir;
|
||||||
|
|
||||||
@@ -69,7 +70,12 @@ describe('Gemini CLI Quota Fetcher', () => {
|
|||||||
const configGenerator = await import(
|
const configGenerator = await import(
|
||||||
`../../../src/cliproxy/config-generator?gemini-config-generator=${moduleVersion}`
|
`../../../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}`
|
`../../../src/cliproxy/quota-fetcher-gemini-cli?gemini-quota-fetcher=${moduleVersion}`
|
||||||
));
|
));
|
||||||
({ refreshGeminiToken } = await import(
|
({ 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', () => {
|
describe('refreshGeminiToken', () => {
|
||||||
it('uses OAuth client metadata stored in the token file', async () => {
|
it('uses OAuth client metadata stored in the token file', async () => {
|
||||||
writeGeminiToken({
|
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