mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-16 22:14:21 +00:00
Merge pull request #801 from kaitranntt/kai/fix/gemini-quota-error-surface
fix(cliproxy): surface gemini quota failure details
This commit is contained in:
5 files changed
+691
-93
No files matched your search
@@ -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 (/^<!doctype html/i.test(trimmed) || /^<html/i.test(trimmed) || /^<[^>]+>/.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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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 };
|
||||
@@ -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')}`
|
||||
|
||||
@@ -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, unknown> = {}
|
||||
): 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: '<!doctype html><html><body>bad gateway</body></html>',
|
||||
},
|
||||
]);
|
||||
|
||||
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('<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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
+18
-18
@@ -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: 'リモートに接続できない場合はローカルプロキシを使用します',
|
||||
|
||||
Reference in new issue
Block a user