fix: improve quota diagnostics and oauth refresh handling

This commit is contained in:
Tam Nhu Tran
2026-03-07 11:09:15 +07:00
parent c55c4e88a5
commit fc3600e922
15 changed files with 1664 additions and 442 deletions
+65 -17
View File
@@ -16,26 +16,18 @@ import { getProviderAuthDir } from '../config-generator';
import { getDefaultAccount, getProviderAccounts } from '../account-manager';
import { isTokenFileForProvider } from './token-manager';
/**
* Gemini OAuth credentials - PUBLIC from official Gemini CLI source code
* These are not secrets - they're public OAuth client credentials that Google
* distributes with their official applications. See:
* https://github.com/google/generative-ai-python (Gemini CLI source)
*
* GitHub secret scanning may flag these, but they are intentionally hardcoded
* as they're required for OAuth token refresh and are publicly documented.
*/
const GEMINI_CLIENT_ID = '681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com';
const GEMINI_CLIENT_SECRET = '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';
/** Google OAuth token endpoint */
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
/** Refresh tokens 5 minutes before expiry */
const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000;
const GEMINI_CLIENT_ID_ENV_KEYS = ['CCS_GEMINI_OAUTH_CLIENT_ID', 'OPENCLAW_GEMINI_OAUTH_CLIENT_ID'];
const GEMINI_CLIENT_SECRET_ENV_KEYS = [
'CCS_GEMINI_OAUTH_CLIENT_SECRET',
'OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET',
];
/** Gemini oauth_creds.json structure */
interface GeminiOAuthCreds {
access_token: string;
@@ -44,6 +36,9 @@ interface GeminiOAuthCreds {
scope?: string;
token_type?: string;
id_token?: string;
client_id?: string;
client_secret?: string;
token_uri?: string;
}
/** Gemini credentials with source path for write-back */
@@ -58,6 +53,9 @@ interface CliproxyGeminiToken {
access_token: string;
refresh_token?: string;
expiry?: number; // Unix timestamp in milliseconds
client_id?: string;
client_secret?: string;
token_uri?: string;
};
project_id: string;
email: string;
@@ -73,6 +71,12 @@ interface TokenRefreshResponse {
error_description?: string;
}
interface GoogleOAuthClientCredentials {
clientId: string;
clientSecret: string;
tokenUrl: string;
}
/**
* Get path to Gemini OAuth credentials file
*/
@@ -89,6 +93,9 @@ function mapCliproxyToGeminiCreds(cliproxy: CliproxyGeminiToken): GeminiOAuthCre
refresh_token: cliproxy.token.refresh_token,
expiry_date: cliproxy.token.expiry,
token_type: 'Bearer',
client_id: cliproxy.token.client_id,
client_secret: cliproxy.token.client_secret,
token_uri: cliproxy.token.token_uri,
};
}
@@ -104,6 +111,41 @@ function isValidCliproxyToken(data: unknown): data is CliproxyGeminiToken {
return typeof token.access_token === 'string';
}
function getFirstEnvValue(keys: readonly string[]): string | undefined {
for (const key of keys) {
const value = process.env[key]?.trim();
if (value) {
return value;
}
}
return undefined;
}
function resolveGeminiRefreshCredentials(creds: GeminiOAuthCreds): {
credentials?: GoogleOAuthClientCredentials;
error?: string;
} {
const clientId = creds.client_id?.trim() || getFirstEnvValue(GEMINI_CLIENT_ID_ENV_KEYS);
const clientSecret =
creds.client_secret?.trim() || getFirstEnvValue(GEMINI_CLIENT_SECRET_ENV_KEYS);
if (!clientId || !clientSecret) {
return {
error:
'Gemini token refresh unavailable: missing OAuth client credentials in the token file. ' +
'Re-authenticate with CLIProxy or set CCS_GEMINI_OAUTH_CLIENT_ID and CCS_GEMINI_OAUTH_CLIENT_SECRET.',
};
}
return {
credentials: {
clientId,
clientSecret,
tokenUrl: creds.token_uri?.trim() || GOOGLE_TOKEN_URL,
},
};
}
/**
* Read Gemini token from CLIProxy auth directory
* Returns credentials with source path, or null if no valid token found
@@ -294,11 +336,17 @@ export async function refreshGeminiToken(accountId?: string): Promise<{
}
const { creds, sourcePath } = result;
const resolvedCredentials = resolveGeminiRefreshCredentials(creds);
if (!resolvedCredentials.credentials) {
return { success: false, error: resolvedCredentials.error };
}
const { clientId, clientSecret, tokenUrl } = resolvedCredentials.credentials;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
try {
const response = await fetch(GOOGLE_TOKEN_URL, {
const response = await fetch(tokenUrl, {
method: 'POST',
signal: controller.signal,
headers: {
@@ -307,8 +355,8 @@ export async function refreshGeminiToken(accountId?: string): Promise<{
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: creds.refresh_token as string, // Already validated above
client_id: GEMINI_CLIENT_ID,
client_secret: GEMINI_CLIENT_SECRET,
client_id: clientId,
client_secret: clientSecret,
}).toString(),
});
+250 -66
View File
@@ -16,6 +16,7 @@ import type { CodexQuotaResult, CodexQuotaWindow, CodexCoreUsageSummary } from '
const CODEX_API_BASE = 'https://chatgpt.com/backend-api';
const CODEX_QUOTA_TIMEOUT_MS = 12000;
const CODEX_QUOTA_MAX_ATTEMPTS = 2;
const CODEX_ERROR_DETAIL_MAX_LENGTH = 240;
/**
* User agent matching Codex CLI for API compatibility.
@@ -57,6 +58,12 @@ interface CodexWindowData {
resetAfterSeconds?: number | null;
}
interface ParsedCodexErrorBody {
errorCode?: string;
errorDetail?: string;
message?: string;
}
type CodexWindowKind =
| 'usage-5h'
| 'usage-weekly'
@@ -276,6 +283,225 @@ function buildCodexQuotaWindows(payload: CodexUsageResponse): CodexQuotaWindow[]
return windows;
}
function buildCodexFailureResult(
accountId: string,
options: {
error: string;
httpStatus?: number;
errorCode?: string;
errorDetail?: string;
actionHint?: string;
retryable?: boolean;
needsReauth?: boolean;
isForbidden?: boolean;
}
): CodexQuotaResult {
return {
success: false,
windows: [],
planType: null,
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 sanitizeCodexErrorDetail(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 > CODEX_ERROR_DETAIL_MAX_LENGTH) {
sanitized = `${sanitized.slice(0, CODEX_ERROR_DETAIL_MAX_LENGTH - 14)}...[truncated]`;
}
return sanitized;
}
function parseCodexErrorBody(bodyText: string): ParsedCodexErrorBody {
const trimmed = bodyText.trim();
if (!trimmed) {
return {};
}
const sanitizedDetail = sanitizeCodexErrorDetail(trimmed);
try {
const parsed = JSON.parse(trimmed) as Record<string, unknown>;
const topLevelMessage =
typeof parsed.message === 'string'
? parsed.message
: typeof parsed.detail === 'string'
? parsed.detail
: undefined;
const topLevelCode = typeof parsed.code === 'string' ? parsed.code : undefined;
if (parsed.error && typeof parsed.error === 'object') {
const error = parsed.error as Record<string, unknown>;
const errorCode = typeof error.code === 'string' ? error.code : topLevelCode;
const errorMessage =
typeof error.message === 'string'
? error.message
: typeof error.error === 'string'
? error.error
: topLevelMessage;
return {
errorCode,
errorDetail: sanitizedDetail,
message: errorMessage,
};
}
if (parsed.detail && typeof parsed.detail === 'object') {
const detail = parsed.detail as Record<string, unknown>;
return {
errorCode:
typeof detail.code === 'string'
? detail.code
: typeof detail.type === 'string'
? detail.type
: topLevelCode,
errorDetail: sanitizedDetail,
message:
typeof detail.message === 'string'
? detail.message
: typeof detail.error === 'string'
? detail.error
: topLevelMessage,
};
}
return {
errorCode: topLevelCode,
errorDetail: sanitizedDetail,
message: topLevelMessage,
};
} catch {
return {
errorDetail: sanitizedDetail,
message: trimmed,
};
}
}
function buildCodexHttpFailureResult(
accountId: string,
status: number,
bodyText: string
): CodexQuotaResult {
const parsed = parseCodexErrorBody(bodyText);
if (status === 401) {
return buildCodexFailureResult(accountId, {
error: 'Token expired or invalid',
httpStatus: 401,
errorCode: parsed.errorCode || 'reauth_required',
errorDetail: parsed.errorDetail,
actionHint: 'Run ccs cliproxy auth codex to re-authenticate this account.',
needsReauth: true,
retryable: false,
});
}
if (status === 402) {
if (parsed.errorCode === 'deactivated_workspace') {
return buildCodexFailureResult(accountId, {
error: 'Workspace deactivated (HTTP 402)',
httpStatus: 402,
errorCode: parsed.errorCode,
errorDetail: parsed.errorDetail,
actionHint:
'Remove and re-add this account from an active ChatGPT workspace before retrying.',
retryable: false,
});
}
return buildCodexFailureResult(accountId, {
error: parsed.message || 'Payment or workspace access required (HTTP 402)',
httpStatus: 402,
errorCode: parsed.errorCode || 'payment_required',
errorDetail: parsed.errorDetail,
actionHint: 'Confirm the ChatGPT workspace/subscription is active, then retry.',
retryable: false,
});
}
if (status === 403) {
return buildCodexFailureResult(accountId, {
error: 'Quota API access forbidden (HTTP 403)',
httpStatus: 403,
errorCode: parsed.errorCode || 'quota_api_forbidden',
errorDetail: parsed.errorDetail,
actionHint: 'This account cannot access the Codex quota endpoint.',
isForbidden: true,
retryable: false,
});
}
if (status === 404) {
return buildCodexFailureResult(accountId, {
error: 'Codex quota endpoint not found (HTTP 404)',
httpStatus: 404,
errorCode: parsed.errorCode || 'quota_endpoint_not_found',
errorDetail: parsed.errorDetail,
actionHint: 'The upstream Codex quota endpoint changed or is unavailable.',
retryable: false,
});
}
if (status === 429) {
return buildCodexFailureResult(accountId, {
error: '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 buildCodexFailureResult(accountId, {
error: `Codex quota service unavailable (HTTP ${status})`,
httpStatus: status,
errorCode: parsed.errorCode || 'provider_unavailable',
errorDetail: parsed.errorDetail,
actionHint: 'Retry later. This looks like a temporary upstream problem.',
retryable: true,
});
}
return buildCodexFailureResult(accountId, {
error: parsed.message || `Codex quota request failed (HTTP ${status})`,
httpStatus: status,
errorCode: parsed.errorCode || 'unknown_upstream_error',
errorDetail: parsed.errorDetail,
actionHint: 'Inspect the upstream response details and retry if appropriate.',
retryable: false,
});
}
/**
* Fetch quota for a single Codex account
*
@@ -293,41 +519,35 @@ export async function fetchCodexQuota(
if (!authData) {
const error = 'Auth file not found for Codex account';
if (verbose) console.error(`[!] Error: ${error}`);
return {
success: false,
windows: [],
planType: null,
lastUpdated: Date.now(),
return buildCodexFailureResult(accountId, {
error,
accountId,
};
errorCode: 'auth_file_missing',
actionHint: 'Remove the stale account or authenticate again with ccs cliproxy auth codex.',
retryable: false,
});
}
if (authData.isExpired) {
const error = 'Token expired - re-authenticate with ccs cliproxy auth codex';
if (verbose) console.error(`[!] Error: ${error}`);
return {
success: false,
windows: [],
planType: null,
lastUpdated: Date.now(),
return buildCodexFailureResult(accountId, {
error,
accountId,
errorCode: 'token_expired',
actionHint: 'Run ccs cliproxy auth codex to refresh the token for this account.',
needsReauth: true,
};
retryable: false,
});
}
if (!authData.accountId) {
const error = 'Missing ChatGPT-Account-Id in auth file';
if (verbose) console.error(`[!] Error: ${error}`);
return {
success: false,
windows: [],
planType: null,
lastUpdated: Date.now(),
return buildCodexFailureResult(accountId, {
error,
accountId,
};
errorCode: 'missing_account_id',
actionHint: 'Remove and re-add this Codex account to refresh workspace metadata.',
retryable: false,
});
}
const url = `${CODEX_API_BASE}/wham/usage`;
@@ -352,52 +572,9 @@ export async function fetchCodexQuota(
if (verbose) console.error(`[i] Codex API status: ${response.status} (attempt ${attempt})`);
if (response.status === 401) {
return {
success: false,
windows: [],
planType: null,
lastUpdated: Date.now(),
error: 'Token expired or invalid',
accountId,
needsReauth: true,
};
}
if (response.status === 403) {
// 403 = account lacks API access (not same as quota exhausted)
// Keep success=false with isForbidden flag for UI to show distinct "403" badge
return {
success: false,
windows: [],
planType: null,
lastUpdated: Date.now(),
error: '403 Forbidden - No quota API access',
accountId,
isForbidden: true,
};
}
if (response.status === 429) {
return {
success: false,
windows: [],
planType: null,
lastUpdated: Date.now(),
error: 'Rate limited - try again later',
accountId,
};
}
if (!response.ok) {
return {
success: false,
windows: [],
planType: null,
lastUpdated: Date.now(),
error: `API error: ${response.status}`,
accountId,
};
const bodyText = await response.text();
return buildCodexHttpFailureResult(accountId, response.status, bodyText);
}
const data = (await response.json()) as CodexUsageResponse;
@@ -456,6 +633,11 @@ export async function fetchCodexQuota(
lastUpdated: Date.now(),
error: lastErrorMsg,
accountId,
errorCode: isAbortError ? 'network_timeout' : 'network_error',
actionHint: isAbortError
? 'Retry later. The Codex quota endpoint timed out.'
: 'Retry later or inspect network connectivity.',
retryable: true,
};
}
}
@@ -467,6 +649,8 @@ export async function fetchCodexQuota(
lastUpdated: Date.now(),
error: lastErrorMsg,
accountId,
errorCode: 'unknown_error',
retryable: true,
};
}
+444 -306
View File
@@ -17,6 +17,7 @@ import {
type AccountTier,
} from './account-manager';
import { sanitizeEmail, isTokenExpired } from './auth-utils';
import { buildManagementHeaders, buildProxyUrl, getProxyTarget } from './proxy-target-resolver';
/** Individual model quota info */
export interface ModelQuota {
@@ -38,12 +39,24 @@ export interface QuotaResult {
models: ModelQuota[];
/** Timestamp of fetch */
lastUpdated: number;
/** Upstream HTTP status when available */
httpStatus?: number;
/** Stable machine-readable error code */
errorCode?: string;
/** Additional provider-specific detail/code from upstream */
errorDetail?: string;
/** True if account lacks quota access (403) */
isForbidden?: boolean;
/** Error message if fetch failed */
error?: string;
/** Provider-specific remediation guidance */
actionHint?: string;
/** True when the failure is temporary and retrying later may help */
retryable?: boolean;
/** True if token is expired and needs re-auth */
isExpired?: boolean;
/** True if token refresh cannot proceed and the account should be re-authenticated */
needsReauth?: boolean;
/** ISO timestamp when token expires/expired */
expiresAt?: string;
/** True if account hasn't been activated in official Antigravity app */
@@ -59,14 +72,7 @@ export interface QuotaResult {
/** Google Cloud Code API endpoints */
const ANTIGRAVITY_API_BASE = 'https://cloudcode-pa.googleapis.com';
const ANTIGRAVITY_API_VERSION = 'v1internal';
/** Google OAuth token endpoint */
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
/** Antigravity OAuth credentials (from CLIProxyAPIPlus - public in open-source code) */
const ANTIGRAVITY_CLIENT_ID =
'1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
const ANTIGRAVITY_CLIENT_SECRET = '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';
const MANAGEMENT_API_TIMEOUT_MS = 5000;
/** Headers for loadCodeAssist (matches CLIProxyAPI antigravity.go) */
const LOADCODEASSIST_HEADERS = {
@@ -104,15 +110,6 @@ interface AuthData {
expiresAt: string | null;
}
/** Token refresh response */
interface TokenRefreshResponse {
access_token?: string;
expires_in?: number;
token_type?: string;
error?: string;
error_description?: string;
}
/** Tier info from loadCodeAssist */
interface TierInfo {
id?: string;
@@ -155,66 +152,301 @@ interface FetchAvailableModelsResponse {
models?: Record<string, AvailableModel>;
}
/**
* Refresh access token using refresh_token via Google OAuth
* This allows CCS to get fresh tokens independently of CLIProxyAPI
*/
async function refreshAccessToken(
refreshToken: string,
verbose = false
): Promise<{ accessToken: string | null; error?: string }> {
if (verbose) console.error('[i] Refreshing access token...');
interface ManagementAuthFile {
auth_index?: string | number;
provider?: string;
type?: string;
email?: string;
name?: string;
}
interface ManagementApiCallResponse {
status_code?: number;
body?: string;
}
interface ManagedResponse {
status: number;
bodyText: string;
json: unknown;
viaManagement: boolean;
}
interface ProjectLookupResult {
projectId: string | null;
tier?: AccountTier;
error?: string;
errorCode?: string;
errorDetail?: string;
actionHint?: string;
retryable?: boolean;
httpStatus?: number;
needsReauth?: boolean;
isUnprovisioned?: boolean;
}
function safeParseJson(bodyText: string): unknown {
try {
return JSON.parse(bodyText);
} catch {
return null;
}
}
function normalizeErrorDetail(bodyText: string): string | undefined {
const normalized = bodyText.trim();
if (!normalized) {
return undefined;
}
if (normalized.length <= 400) {
return normalized;
}
return `${normalized.slice(0, 397)}...`;
}
function buildAntigravityFailure(
status: number | undefined,
bodyText?: string
): Pick<
QuotaResult,
'error' | 'errorCode' | 'errorDetail' | 'actionHint' | 'retryable' | 'httpStatus' | 'needsReauth'
> & { isForbidden?: boolean } {
const detail = normalizeErrorDetail(bodyText || '');
if (status === 401) {
return {
httpStatus: 401,
error: 'Token expired or invalid',
errorCode: 'reauth_required',
actionHint:
'Re-authenticate this account. If CLIProxy is running, retry after the proxy finishes refreshing the token.',
needsReauth: true,
errorDetail: detail,
};
}
if (status === 403) {
return {
httpStatus: 403,
error: 'Access forbidden',
errorCode: 'quota_api_forbidden',
actionHint: 'This account does not have Gemini Code Assist quota access.',
isForbidden: true,
errorDetail: detail,
};
}
if (status === 429) {
return {
httpStatus: 429,
error: 'Rate limited - try again later',
errorCode: 'rate_limited',
actionHint: 'Retry later. This looks temporary.',
retryable: true,
errorDetail: detail,
};
}
if (status === 408) {
return {
httpStatus: 408,
error: 'Request timeout',
errorCode: 'network_timeout',
actionHint: 'Retry later. This looks temporary.',
retryable: true,
errorDetail: detail,
};
}
if (typeof status === 'number' && status >= 500) {
return {
httpStatus: status,
error: `API error: ${status}`,
errorCode: 'provider_unavailable',
actionHint: 'Retry later. The provider appears unavailable.',
retryable: true,
errorDetail: detail,
};
}
if (typeof status === 'number' && status >= 400) {
return {
httpStatus: status,
error: `API error: ${status}`,
errorCode: 'quota_request_failed',
errorDetail: detail,
};
}
return {
error: 'Quota request failed',
errorCode: 'quota_request_failed',
errorDetail: detail,
};
}
async function readManagedResponse(
response: Response,
viaManagement: boolean
): Promise<ManagedResponse> {
const bodyText = await response.text();
return {
status: response.status,
bodyText,
json: safeParseJson(bodyText),
viaManagement,
};
}
function isAntigravityAuthFileForAccount(file: ManagementAuthFile, accountId: string): boolean {
const provider = (file.provider || file.type || '').trim().toLowerCase();
if (provider !== 'antigravity' && provider !== 'agy') {
return false;
}
const normalizedAccount = accountId.trim().toLowerCase();
const normalizedEmail = file.email?.trim().toLowerCase();
if (normalizedEmail && normalizedEmail === normalizedAccount) {
return true;
}
const normalizedName = file.name?.trim().toLowerCase();
if (!normalizedName) {
return false;
}
const sanitizedAccount = sanitizeEmail(accountId).toLowerCase();
return (
normalizedName === `antigravity-${sanitizedAccount}.json` ||
normalizedName === `agy-${sanitizedAccount}.json`
);
}
async function findManagedAntigravityAuthIndex(accountId: string): Promise<string | number | null> {
const target = getProxyTarget();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
const timeoutId = setTimeout(() => controller.abort(), MANAGEMENT_API_TIMEOUT_MS);
try {
const response = await fetch(GOOGLE_TOKEN_URL, {
const response = await fetch(buildProxyUrl(target, '/v0/management/auth-files'), {
signal: controller.signal,
headers: buildManagementHeaders(target),
});
clearTimeout(timeoutId);
if (!response.ok) {
return null;
}
const data = (await response.json()) as { files?: ManagementAuthFile[] };
const match = data.files?.find((file) => isAntigravityAuthFileForAccount(file, accountId));
return match?.auth_index ?? null;
} catch {
clearTimeout(timeoutId);
return null;
}
}
async function performManagedAntigravityRequest(
accountId: string,
url: string,
headers: Record<string, string>,
body: string
): Promise<ManagedResponse | null> {
const authIndex = await findManagedAntigravityAuthIndex(accountId);
if (authIndex === null || authIndex === undefined) {
return null;
}
const target = getProxyTarget();
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), MANAGEMENT_API_TIMEOUT_MS);
try {
const response = await fetch(buildProxyUrl(target, '/v0/management/api-call'), {
method: 'POST',
signal: controller.signal,
headers: buildManagementHeaders(target, {
'Content-Type': 'application/json',
}),
body: JSON.stringify({
auth_index: authIndex,
method: 'POST',
url,
header: {
...headers,
Authorization: 'Bearer $TOKEN$',
},
data: body,
}),
});
clearTimeout(timeoutId);
if (!response.ok) {
return null;
}
const apiResponse = (await response.json()) as ManagementApiCallResponse;
const bodyText = typeof apiResponse.body === 'string' ? apiResponse.body : '';
return {
status: typeof apiResponse.status_code === 'number' ? apiResponse.status_code : 500,
bodyText,
json: safeParseJson(bodyText),
viaManagement: true,
};
} catch {
clearTimeout(timeoutId);
return null;
}
}
async function performAntigravityRequest(
accountId: string,
accessToken: string,
url: string,
headers: Record<string, string>,
body: string
): Promise<ManagedResponse> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), MANAGEMENT_API_TIMEOUT_MS);
try {
const response = await fetch(url, {
method: 'POST',
signal: controller.signal,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...headers,
Authorization: `Bearer ${accessToken}`,
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: ANTIGRAVITY_CLIENT_ID,
client_secret: ANTIGRAVITY_CLIENT_SECRET,
}).toString(),
body,
});
clearTimeout(timeoutId);
if (verbose) console.error(`[i] Token refresh status: ${response.status}`);
const directResult = await readManagedResponse(response, false);
if (directResult.status !== 401) {
return directResult;
}
const data = (await response.json()) as TokenRefreshResponse;
if (!response.ok || data.error) {
const error = data.error_description || data.error || `OAuth error: ${response.status}`;
if (verbose) console.error(`[!] Token refresh failed: ${error}`);
const managedResult = await performManagedAntigravityRequest(accountId, url, headers, body);
return managedResult ?? directResult;
} catch (err) {
clearTimeout(timeoutId);
if (err instanceof Error && err.name === 'AbortError') {
return {
accessToken: null,
error,
status: 408,
bodyText: '',
json: null,
viaManagement: false,
};
}
if (!data.access_token) {
if (verbose) console.error('[!] Token refresh failed: No access_token in response');
return { accessToken: null, error: 'No access_token in response' };
}
if (verbose) console.error('[i] Token refresh: success');
return { accessToken: data.access_token };
} catch (err) {
clearTimeout(timeoutId);
const errorMsg =
err instanceof Error && err.name === 'AbortError'
? 'Token refresh timeout'
: err instanceof Error
? err.message
: 'Unknown error';
if (verbose) console.error(`[!] Token refresh failed: ${errorMsg}`);
return { accessToken: null, error: errorMsg };
const message = err instanceof Error ? err.message : 'Unknown error';
return {
status: 503,
bodyText: message,
json: null,
viaManagement: false,
};
}
}
@@ -305,80 +537,63 @@ function mapTierString(tierStr: string | undefined): AccountTier {
* Get project ID and tier via loadCodeAssist endpoint
* Uses paidTier.id for accurate tier detection (g1-ultra-tier, g1-pro-tier)
*/
async function getProjectId(accessToken: string): Promise<{
projectId: string | null;
tier?: AccountTier;
error?: string;
isUnprovisioned?: boolean;
}> {
async function getProjectId(accountId: string, accessToken: string): Promise<ProjectLookupResult> {
const url = `${ANTIGRAVITY_API_BASE}/${ANTIGRAVITY_API_VERSION}:loadCodeAssist`;
const body = JSON.stringify({
metadata: {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
},
});
const response = await performAntigravityRequest(
accountId,
accessToken,
url,
LOADCODEASSIST_HEADERS,
body
);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
const response = await fetch(url, {
method: 'POST',
signal: controller.signal,
headers: {
...LOADCODEASSIST_HEADERS,
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
metadata: {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
},
}),
});
clearTimeout(timeoutId);
if (!response.ok) {
// Return specific error based on status
if (response.status === 401) {
return { projectId: null, error: 'Token expired or invalid' };
}
if (response.status === 403) {
return { projectId: null, error: 'Access forbidden' };
}
return { projectId: null, error: `API error: ${response.status}` };
}
const data = (await response.json()) as LoadCodeAssistResponse;
// Extract project ID from response
let projectId: string | undefined;
if (typeof data.cloudaicompanionProject === 'string') {
projectId = data.cloudaicompanionProject;
} else if (typeof data.cloudaicompanionProject === 'object') {
projectId = data.cloudaicompanionProject?.id;
}
if (!projectId?.trim()) {
// Account authenticated but not provisioned - user needs to sign in via Antigravity app
return {
projectId: null,
error: 'Sign in to Antigravity app to activate quota.',
isUnprovisioned: true,
};
}
// Extract tier - paidTier reflects actual subscription status, takes priority
// API returns: paidTier.id = "g1-ultra-tier" or "g1-pro-tier"
// allowedTiers/currentTier often return "standard-tier" which is not useful
const tierStr = data.paidTier?.id || data.currentTier?.id;
const tier = mapTierString(tierStr);
return { projectId: projectId.trim(), tier };
} catch (err) {
clearTimeout(timeoutId);
if (err instanceof Error && err.name === 'AbortError') {
return { projectId: null, error: 'Request timeout' };
}
return { projectId: null, error: err instanceof Error ? err.message : 'Unknown error' };
if (response.status < 200 || response.status >= 300) {
return {
projectId: null,
...buildAntigravityFailure(response.status, response.bodyText),
};
}
const data = response.json as LoadCodeAssistResponse | null;
if (!data) {
return {
projectId: null,
error: 'Invalid quota response from provider',
errorCode: 'provider_unavailable',
retryable: true,
};
}
// Extract project ID from response
let projectId: string | undefined;
if (typeof data.cloudaicompanionProject === 'string') {
projectId = data.cloudaicompanionProject;
} else if (typeof data.cloudaicompanionProject === 'object') {
projectId = data.cloudaicompanionProject?.id;
}
if (!projectId?.trim()) {
return {
projectId: null,
error: 'Sign in to Antigravity app to activate quota.',
errorCode: 'account_unprovisioned',
actionHint: 'Complete sign-in in the Antigravity app, then retry quota refresh.',
isUnprovisioned: true,
};
}
// Extract tier - paidTier reflects actual subscription status, takes priority
const tierStr = data.paidTier?.id || data.currentTier?.id;
const tier = mapTierString(tierStr);
return { projectId: projectId.trim(), tier };
}
/**
@@ -386,116 +601,75 @@ async function getProjectId(accessToken: string): Promise<{
* Note: projectId is kept for potential future use but not sent in body
* (CLIProxyAPI sends empty {} body for this endpoint)
*/
async function fetchAvailableModels(accessToken: string, _projectId: string): Promise<QuotaResult> {
async function fetchAvailableModels(
accountId: string,
accessToken: string,
_projectId: string
): Promise<QuotaResult> {
const url = `${ANTIGRAVITY_API_BASE}/${ANTIGRAVITY_API_VERSION}:fetchAvailableModels`;
const response = await performAntigravityRequest(
accountId,
accessToken,
url,
FETCHMODELS_HEADERS,
JSON.stringify({})
);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
// Match CLIProxyAPI exactly: empty body, minimal headers
const response = await fetch(url, {
method: 'POST',
signal: controller.signal,
headers: {
...FETCHMODELS_HEADERS,
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({}),
});
clearTimeout(timeoutId);
if (response.status === 403) {
// 403 = account lacks Gemini Code Assist access (not same as quota exhausted)
// Keep success=false with isForbidden flag for UI to show distinct "403" badge
return {
success: false,
models: [],
lastUpdated: Date.now(),
isForbidden: true,
error: '403 Forbidden - No Gemini Code Assist access',
};
}
if (response.status === 401) {
return {
success: false,
models: [],
lastUpdated: Date.now(),
error: 'Access token expired or invalid',
};
}
if (response.status === 429) {
return {
success: false,
models: [],
lastUpdated: Date.now(),
error: 'Rate limited - try again later',
};
}
if (!response.ok) {
return {
success: false,
models: [],
lastUpdated: Date.now(),
error: `API error: ${response.status}`,
};
}
const data = (await response.json()) as FetchAvailableModelsResponse;
const models: ModelQuota[] = [];
if (data.models && typeof data.models === 'object') {
for (const [modelId, modelData] of Object.entries(data.models)) {
const quotaInfo = modelData.quotaInfo || modelData.quota_info;
if (!quotaInfo) continue;
// Extract remaining fraction (0-1 range)
const remaining =
quotaInfo.remainingFraction ?? quotaInfo.remaining_fraction ?? quotaInfo.remaining;
// Extract reset time
const resetTime = quotaInfo.resetTime || quotaInfo.reset_time || null;
// If remaining is not a valid number but resetTime exists, treat as exhausted (0%)
// This happens when Claude models hit quota limit - API returns resetTime but no fraction
let percentage: number;
if (typeof remaining === 'number' && isFinite(remaining)) {
percentage = Math.max(0, Math.min(100, Math.round(remaining * 100)));
} else if (resetTime) {
// Model is exhausted but has reset time - show as 0%
percentage = 0;
} else {
// No valid data, skip this model
continue;
}
models.push({
name: modelId,
displayName: modelData.displayName,
percentage,
resetTime,
});
}
}
return {
success: true,
models,
lastUpdated: Date.now(),
};
} catch (err) {
clearTimeout(timeoutId);
if (response.status < 200 || response.status >= 300) {
return {
success: false,
models: [],
lastUpdated: Date.now(),
error: err instanceof Error ? err.message : 'Unknown error',
...buildAntigravityFailure(response.status, response.bodyText),
};
}
const data = response.json as FetchAvailableModelsResponse | null;
if (!data) {
return {
success: false,
models: [],
lastUpdated: Date.now(),
error: 'Invalid quota response from provider',
errorCode: 'provider_unavailable',
retryable: true,
};
}
const models: ModelQuota[] = [];
if (data.models && typeof data.models === 'object') {
for (const [modelId, modelData] of Object.entries(data.models)) {
const quotaInfo = modelData.quotaInfo || modelData.quota_info;
if (!quotaInfo) continue;
const remaining =
quotaInfo.remainingFraction ?? quotaInfo.remaining_fraction ?? quotaInfo.remaining;
const resetTime = quotaInfo.resetTime || quotaInfo.reset_time || null;
let percentage: number;
if (typeof remaining === 'number' && isFinite(remaining)) {
percentage = Math.max(0, Math.min(100, Math.round(remaining * 100)));
} else if (resetTime) {
percentage = 0;
} else {
continue;
}
models.push({
name: modelId,
displayName: modelData.displayName,
percentage,
resetTime,
});
}
}
return {
success: true,
models,
lastUpdated: Date.now(),
};
}
/**
@@ -535,63 +709,47 @@ export async function fetchAccountQuota(
models: [],
lastUpdated: Date.now(),
error,
errorCode: 'auth_file_missing',
actionHint: 'Reconnect this account so CCS can read a current auth token.',
};
}
// Determine which access token to use
// File-based token is often stale (CLIProxyAPIPlus refreshes at runtime but doesn't persist)
// Proactive refresh: refresh 5 minutes before expiry (matches CLIProxyAPIPlus behavior)
let accessToken = authData.accessToken;
const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000; // 5 minutes
let tokenRefreshed = false;
if (authData.refreshToken) {
const shouldRefresh =
authData.isExpired || // Already expired
!authData.expiresAt || // No expiry info - refresh to be safe
new Date(authData.expiresAt).getTime() - Date.now() < REFRESH_LEAD_TIME_MS; // Expiring soon
if (shouldRefresh) {
const refreshResult = await refreshAccessToken(authData.refreshToken, verbose);
if (refreshResult.accessToken) {
accessToken = refreshResult.accessToken;
tokenRefreshed = true;
}
// If refresh fails, fall back to existing token (might still work)
}
}
if (verbose && !tokenRefreshed) {
console.error('[i] Token refresh: skipped');
const accessToken = authData.accessToken;
if (verbose) {
const expiryState = authData.isExpired
? 'expired'
: authData.expiresAt
? `expires ${authData.expiresAt}`
: 'expiry unknown';
console.error(`[i] Auth token state: ${expiryState}`);
}
// Get project ID and tier - prefer stored project ID, but always call API for tier
let projectId = authData.projectId;
let apiTier: AccountTier = 'unknown';
// Always call loadCodeAssist to get accurate tier from API
let lastProjectResult = await getProjectId(accessToken);
// Always call loadCodeAssist to get accurate tier from API.
// If the file token is stale, the helper retries through CLIProxy management auth.
const lastProjectResult = await getProjectId(accountId, accessToken);
if (!lastProjectResult.projectId && !projectId) {
// If project ID fetch fails, it might be token issue - try refresh if we haven't
if (authData.refreshToken && accessToken === authData.accessToken) {
const refreshResult = await refreshAccessToken(authData.refreshToken, verbose);
if (refreshResult.accessToken) {
accessToken = refreshResult.accessToken;
lastProjectResult = await getProjectId(accessToken);
}
}
if (!lastProjectResult.projectId) {
const error = lastProjectResult.error || 'Failed to retrieve project ID';
if (verbose) console.error(`[!] Error: ${error}`);
return {
success: false,
models: [],
lastUpdated: Date.now(),
error,
isUnprovisioned: lastProjectResult.isUnprovisioned,
};
}
const error = lastProjectResult.error || 'Failed to retrieve project ID';
if (verbose) console.error(`[!] Error: ${error}`);
return {
success: false,
models: [],
lastUpdated: Date.now(),
error,
errorCode: lastProjectResult.errorCode,
errorDetail: lastProjectResult.errorDetail,
actionHint: lastProjectResult.actionHint,
retryable: lastProjectResult.retryable,
httpStatus: lastProjectResult.httpStatus,
needsReauth: lastProjectResult.needsReauth,
isUnprovisioned: lastProjectResult.isUnprovisioned,
isExpired: authData.isExpired,
expiresAt: authData.expiresAt || undefined,
};
}
// Use API project ID if available, else fallback to stored
@@ -601,42 +759,22 @@ export async function fetchAccountQuota(
if (verbose) console.error(`[i] Project ID: ${projectId || 'not found'}`);
// Fetch models with quota
const result = await fetchAvailableModels(accessToken, projectId as string);
const result = await fetchAvailableModels(accountId, accessToken, projectId as string);
if (verbose) console.error(`[i] Models found: ${result.models.length}`);
// If quota fetch fails with auth error and we haven't refreshed yet, try refresh
if (!result.success && result.error?.includes('expired') && authData.refreshToken) {
const refreshResult = await refreshAccessToken(authData.refreshToken, verbose);
if (refreshResult.accessToken) {
const retryResult = await fetchAvailableModels(
refreshResult.accessToken,
projectId as string
);
// Determine tier from API response only (model inference is unreliable)
if (retryResult.success) {
const finalTier = apiTier !== 'unknown' ? apiTier : 'unknown';
retryResult.tier = finalTier;
retryResult.accountId = accountId;
if (finalTier !== 'unknown') {
setAccountTier(provider, accountId, finalTier);
}
if (verbose && retryResult.error) {
console.log(`[!] Error: ${retryResult.error}`);
}
}
return retryResult;
}
}
result.accountId = accountId;
result.projectId = projectId || undefined;
// Determine tier from API response only
if (result.success) {
const finalTier = apiTier !== 'unknown' ? apiTier : 'unknown';
result.tier = finalTier;
result.accountId = accountId;
if (finalTier !== 'unknown') {
setAccountTier(provider, accountId, finalTier);
}
} else {
result.isExpired = authData.isExpired;
result.expiresAt = authData.expiresAt || undefined;
}
if (verbose && result.error) {
+19 -4
View File
@@ -11,6 +11,21 @@ export type QuotaProvider = 'agy' | 'codex' | 'claude' | 'gemini' | 'ghcp';
// Re-export Antigravity types for unified access
export type { QuotaResult as AntigravityQuotaResult } from './quota-fetcher';
export interface QuotaErrorMetadata {
/** Upstream HTTP status when available */
httpStatus?: number;
/** Stable machine-readable error code */
errorCode?: string;
/** Additional provider-specific detail/code from upstream */
errorDetail?: string;
/** True if account lacks quota access (403) */
isForbidden?: boolean;
/** Provider-specific remediation guidance */
actionHint?: string;
/** True when the failure is temporary and retrying later may help */
retryable?: boolean;
}
/**
* Codex quota window (primary, secondary, code review)
*/
@@ -50,7 +65,7 @@ export interface CodexCoreUsageSummary {
/**
* Codex quota fetch result
*/
export interface CodexQuotaResult {
export interface CodexQuotaResult extends QuotaErrorMetadata {
/** Whether fetch succeeded */
success: boolean;
/** Quota windows (primary, secondary, code review) */
@@ -130,7 +145,7 @@ export interface ClaudeCoreUsageSummary {
/**
* Claude quota fetch result
*/
export interface ClaudeQuotaResult {
export interface ClaudeQuotaResult extends QuotaErrorMetadata {
/** Whether fetch succeeded */
success: boolean;
/** Policy limit windows */
@@ -170,7 +185,7 @@ export interface GeminiCliBucket {
/**
* Gemini CLI quota fetch result
*/
export interface GeminiCliQuotaResult {
export interface GeminiCliQuotaResult extends QuotaErrorMetadata {
/** Whether fetch succeeded */
success: boolean;
/** Quota buckets grouped by model series */
@@ -214,7 +229,7 @@ export interface GhcpQuotaSnapshot {
/**
* GitHub Copilot quota fetch result.
*/
export interface GhcpQuotaResult {
export interface GhcpQuotaResult extends QuotaErrorMetadata {
/** Whether fetch succeeded */
success: boolean;
/** Copilot plan type (individual/business/enterprise/free) */
+13 -1
View File
@@ -103,14 +103,26 @@ function isQuotaRouteRateLimited(req: Request, provider: string): boolean {
* Cache only stable failures; skip transient network errors (timeouts, 429s, 5xx).
* Generic across all quota result types.
*/
function shouldCacheQuotaResult(result: {
export function shouldCacheQuotaResult(result: {
success: boolean;
needsReauth?: boolean;
isForbidden?: boolean;
httpStatus?: number;
retryable?: boolean;
error?: string;
}): boolean {
if (result.success) return true;
if (result.needsReauth || result.isForbidden) return true;
if (result.retryable === true) return false;
if (result.retryable === false) return true;
if (typeof result.httpStatus === 'number') {
if (result.httpStatus === 429 || result.httpStatus === 408 || result.httpStatus >= 500) {
return false;
}
if (result.httpStatus >= 400 && result.httpStatus < 500) {
return true;
}
}
const msg = (result.error || '').toLowerCase();
if (!msg) return false;
const transientPatterns = ['timeout', 'rate limited', 'api error: 5', 'fetch failed'];
@@ -17,6 +17,7 @@ import {
getQuotaCacheStats,
QUOTA_CACHE_TTL_MS,
} from '../../../src/cliproxy/quota-response-cache';
import { shouldCacheQuotaResult } from '../../../src/web-server/routes/cliproxy-stats-routes';
import type { GeminiCliQuotaResult, CodexQuotaResult } from '../../../src/cliproxy/quota-types';
describe('Quota Caching Integration', () => {
@@ -308,6 +309,60 @@ describe('Quota Caching Integration', () => {
expect(cached?.error).toBe('API error: 503');
});
it('should cache stable auth and workspace failures', () => {
expect(
shouldCacheQuotaResult({
success: false,
needsReauth: true,
error: 'Token expired',
})
).toBe(true);
expect(
shouldCacheQuotaResult({
success: false,
httpStatus: 402,
error: 'Workspace deactivated (HTTP 402)',
})
).toBe(true);
});
it('should skip transient failures marked retryable or temporary by status', () => {
expect(
shouldCacheQuotaResult({
success: false,
retryable: true,
error: 'Rate limited - try again later',
})
).toBe(false);
expect(
shouldCacheQuotaResult({
success: false,
httpStatus: 429,
error: 'Rate limited - try again later',
})
).toBe(false);
expect(
shouldCacheQuotaResult({
success: false,
httpStatus: 503,
error: 'Codex quota service unavailable (HTTP 503)',
})
).toBe(false);
});
it('should respect explicit non-retryable failures even without message pattern matches', () => {
expect(
shouldCacheQuotaResult({
success: false,
retryable: false,
error: 'Unknown upstream error',
})
).toBe(true);
});
});
describe('high-volume scenarios', () => {
+216 -1
View File
@@ -4,13 +4,48 @@
* Tests for Codex quota window parsing and transformation logic
*/
import { describe, it, expect } from 'bun:test';
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {
buildCodexQuotaWindows,
buildCodexCoreUsageSummary,
fetchCodexQuota,
getUnknownCodexWindowLabels,
} from '../../../src/cliproxy/quota-fetcher-codex';
let tmpDir: string;
let originalCcsHome: string | undefined;
let originalFetch: typeof fetch;
function createCodexAccount(
accountId: string,
tokenPayload: Record<string, unknown>,
tokenFile = `codex-${accountId.replace(/[@.]/g, '_')}.json`
): void {
const authDir = path.join(tmpDir, '.ccs', 'cliproxy', 'auth');
fs.mkdirSync(authDir, { recursive: true });
fs.writeFileSync(path.join(authDir, tokenFile), JSON.stringify(tokenPayload));
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-quota-test-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tmpDir;
originalFetch = global.fetch;
});
afterEach(() => {
global.fetch = originalFetch;
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe('Codex Quota Fetcher', () => {
describe('buildCodexQuotaWindows', () => {
it('should parse snake_case API response', () => {
@@ -307,4 +342,184 @@ describe('Codex Quota Fetcher', () => {
expect(labels).toEqual([]);
});
});
describe('fetchCodexQuota failure mapping', () => {
function createValidCodexAccount(email: string, accountId = `workspace-${email}`): void {
createCodexAccount(email, {
access_token: 'test-token',
account_id: accountId,
expired: '2099-01-01T00:00:00.000Z',
email,
type: 'codex',
});
}
it('maps deactivated workspace 402 responses to structured metadata', async () => {
createValidCodexAccount('workspace@example.com', 'workspace-123');
global.fetch = mock(() =>
Promise.resolve(
new Response(JSON.stringify({ detail: { code: 'deactivated_workspace' } }), {
status: 402,
headers: { 'Content-Type': 'application/json' },
})
)
) as typeof fetch;
const result = await fetchCodexQuota('workspace@example.com');
expect(result.success).toBe(false);
expect(result.httpStatus).toBe(402);
expect(result.errorCode).toBe('deactivated_workspace');
expect(result.error).toContain('Workspace deactivated');
expect(result.actionHint).toContain('active ChatGPT workspace');
expect(result.retryable).toBe(false);
});
it('maps 401 responses to reauth-required metadata', async () => {
createValidCodexAccount('reauth@example.com', 'workspace-reauth');
global.fetch = mock(() => Promise.resolve(new Response('', { status: 401 }))) as typeof fetch;
const result = await fetchCodexQuota('reauth@example.com');
expect(result.success).toBe(false);
expect(result.httpStatus).toBe(401);
expect(result.errorCode).toBe('reauth_required');
expect(result.needsReauth).toBe(true);
expect(result.actionHint).toContain('ccs cliproxy auth codex');
});
it('maps 403 responses to forbidden metadata', async () => {
createValidCodexAccount('forbidden@example.com', 'workspace-forbidden');
global.fetch = mock(() =>
Promise.resolve(
new Response(JSON.stringify({ detail: { code: 'quota_api_forbidden' } }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
})
)
) as typeof fetch;
const result = await fetchCodexQuota('forbidden@example.com');
expect(result.success).toBe(false);
expect(result.httpStatus).toBe(403);
expect(result.errorCode).toBe('quota_api_forbidden');
expect(result.isForbidden).toBe(true);
expect(result.retryable).toBe(false);
});
it('maps 429 responses to retryable rate-limit metadata', async () => {
createValidCodexAccount('rate-limit@example.com', 'workspace-rate-limit');
global.fetch = mock(() =>
Promise.resolve(
new Response(JSON.stringify({ detail: { code: 'rate_limited' } }), {
status: 429,
headers: { 'Content-Type': 'application/json' },
})
)
) as typeof fetch;
const result = await fetchCodexQuota('rate-limit@example.com');
expect(result.success).toBe(false);
expect(result.httpStatus).toBe(429);
expect(result.errorCode).toBe('rate_limited');
expect(result.retryable).toBe(true);
expect(result.actionHint).toContain('Retry');
});
it('maps 5xx responses to retryable provider-unavailable metadata', async () => {
createValidCodexAccount('outage@example.com', 'workspace-outage');
global.fetch = mock(() =>
Promise.resolve(
new Response(JSON.stringify({ detail: { code: 'upstream_failure' } }), {
status: 503,
headers: { 'Content-Type': 'application/json' },
})
)
) as typeof fetch;
const result = await fetchCodexQuota('outage@example.com');
expect(result.success).toBe(false);
expect(result.httpStatus).toBe(503);
expect(result.errorCode).toBe('upstream_failure');
expect(result.retryable).toBe(true);
expect(result.error).toContain('service unavailable');
});
it('maps unknown upstream statuses to a non-retryable structured error', async () => {
createValidCodexAccount('teapot@example.com', 'workspace-teapot');
global.fetch = mock(() =>
Promise.resolve(
new Response('{"message":"Strange upstream response"}', {
status: 418,
headers: { 'Content-Type': 'application/json' },
})
)
) as typeof fetch;
const result = await fetchCodexQuota('teapot@example.com');
expect(result.success).toBe(false);
expect(result.httpStatus).toBe(418);
expect(result.errorCode).toBe('unknown_upstream_error');
expect(result.retryable).toBe(false);
expect(result.error).toBe('Strange upstream response');
});
it('sanitizes and truncates raw upstream error detail before returning it', async () => {
createValidCodexAccount('sanitized@example.com', 'workspace-sanitized');
const leakedToken = 'secret-token-value-123';
const oversizedMessage = 'x'.repeat(400);
global.fetch = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
message: 'Upstream failure',
access_token: leakedToken,
extra: oversizedMessage,
}),
{
status: 418,
headers: { 'Content-Type': 'application/json' },
}
)
)
) as typeof fetch;
const result = await fetchCodexQuota('sanitized@example.com');
expect(result.success).toBe(false);
expect(result.errorDetail).toBeDefined();
expect(result.errorDetail).not.toContain(leakedToken);
expect(result.errorDetail).toContain('[redacted]');
expect(result.errorDetail?.endsWith('...[truncated]')).toBe(true);
});
it('omits raw HTML upstream bodies from the returned error detail', async () => {
createValidCodexAccount('html@example.com', 'workspace-html');
global.fetch = mock(() =>
Promise.resolve(
new Response('<html><body>bad gateway</body></html>', {
status: 503,
headers: { 'Content-Type': 'text/html' },
})
)
) as typeof fetch;
const result = await fetchCodexQuota('html@example.com');
expect(result.success).toBe(false);
expect(result.errorDetail).toBe('[HTML error response omitted]');
});
});
});
@@ -4,13 +4,75 @@
* Tests for Gemini CLI bucket parsing and transformation logic
*/
import { describe, it, expect } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import {
buildGeminiCliBuckets,
resolveGeminiCliProjectId,
} from '../../../src/cliproxy/quota-fetcher-gemini-cli';
import { refreshGeminiToken } from '../../../src/cliproxy/auth/gemini-token-refresh';
import { getProviderAuthDir } from '../../../src/cliproxy/config-generator';
import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks';
describe('Gemini CLI Quota Fetcher', () => {
let tempHome: string;
let originalHome: string | undefined;
let originalCcsHome: string | undefined;
let originalGeminiClientId: string | undefined;
let originalGeminiClientSecret: string | undefined;
function writeGeminiToken(token: Record<string, unknown>): string {
const authDir = getProviderAuthDir('gemini');
fs.mkdirSync(authDir, { recursive: true });
const tokenPath = path.join(authDir, 'gemini-test.json');
fs.writeFileSync(tokenPath, JSON.stringify(token, null, 2));
return tokenPath;
}
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-gemini-refresh-'));
originalHome = process.env.HOME;
originalCcsHome = process.env.CCS_HOME;
originalGeminiClientId = process.env.CCS_GEMINI_OAUTH_CLIENT_ID;
originalGeminiClientSecret = process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET;
process.env.HOME = tempHome;
process.env.CCS_HOME = tempHome;
delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID;
delete process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET;
});
afterEach(() => {
restoreFetch();
fs.rmSync(tempHome, { recursive: true, force: true });
if (originalHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = originalHome;
}
if (originalCcsHome === undefined) {
delete process.env.CCS_HOME;
} else {
process.env.CCS_HOME = originalCcsHome;
}
if (originalGeminiClientId === undefined) {
delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID;
} else {
process.env.CCS_GEMINI_OAUTH_CLIENT_ID = originalGeminiClientId;
}
if (originalGeminiClientSecret === undefined) {
delete process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET;
} else {
process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET = originalGeminiClientSecret;
}
});
describe('resolveGeminiCliProjectId', () => {
it('should extract project ID from account field', () => {
const account = 'user@example.com (cloudaicompanion-abc-123)';
@@ -191,4 +253,85 @@ describe('Gemini CLI Quota Fetcher', () => {
expect(flashBucket!.modelIds).toContain('gemini-2.5-flash');
});
});
describe('refreshGeminiToken', () => {
it('uses OAuth client metadata stored in the token file', async () => {
writeGeminiToken({
type: 'gemini',
email: 'file@example.com',
token: {
access_token: 'old-token',
refresh_token: 'refresh-from-file',
expiry: Date.now() - 1000,
client_id: 'file-client-id',
client_secret: 'file-client-secret',
token_uri: 'https://oauth2.googleapis.com/token',
},
});
mockFetch([
{
url: 'https://oauth2.googleapis.com/token',
method: 'POST',
response: { access_token: 'fresh-token', expires_in: 1800 },
},
]);
const result = await refreshGeminiToken();
expect(result.success).toBe(true);
const [request] = getCapturedFetchRequests();
expect(request.body).toContain('client_id=file-client-id');
expect(request.body).toContain('client_secret=file-client-secret');
expect(request.body).toContain('refresh_token=refresh-from-file');
});
it('falls back to CCS_GEMINI_OAUTH_CLIENT_* env vars when token metadata is missing', async () => {
process.env.CCS_GEMINI_OAUTH_CLIENT_ID = 'env-client-id';
process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET = 'env-client-secret';
writeGeminiToken({
type: 'gemini',
email: 'env@example.com',
token: {
access_token: 'old-token',
refresh_token: 'refresh-from-file',
expiry: Date.now() - 1000,
},
});
mockFetch([
{
url: 'https://oauth2.googleapis.com/token',
method: 'POST',
response: { access_token: 'fresh-token', expires_in: 1800 },
},
]);
const result = await refreshGeminiToken();
expect(result.success).toBe(true);
const [request] = getCapturedFetchRequests();
expect(request.body).toContain('client_id=env-client-id');
expect(request.body).toContain('client_secret=env-client-secret');
});
it('returns a clear error when no refresh client credentials are available', async () => {
writeGeminiToken({
type: 'gemini',
email: 'missing@example.com',
token: {
access_token: 'old-token',
refresh_token: 'refresh-from-file',
expiry: Date.now() - 1000,
},
});
const result = await refreshGeminiToken();
expect(result.success).toBe(false);
expect(result.error).toContain('CCS_GEMINI_OAUTH_CLIENT_ID');
expect(result.error).toContain('CCS_GEMINI_OAUTH_CLIENT_SECRET');
});
});
});
@@ -6,13 +6,22 @@ import {
cn,
formatQuotaPercent,
getCodexQuotaBreakdown,
getQuotaFailureInfo,
getProviderMinQuota,
getProviderResetTime,
isClaudeQuotaResult,
isCodexQuotaResult,
} from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { GripVertical, Loader2, Pause, Play, KeyRound } from 'lucide-react';
import {
GripVertical,
Loader2,
Pause,
Play,
KeyRound,
AlertTriangle,
AlertCircle,
} from 'lucide-react';
import { useAccountQuota, QUOTA_SUPPORTED_PROVIDERS } from '@/hooks/use-cliproxy-stats';
import type { QuotaSupportedProvider } from '@/hooks/use-cliproxy-stats';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
@@ -159,6 +168,19 @@ export function AccountCard({
: [];
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null;
const failureInfo = getQuotaFailureInfo(quota);
const FailureIcon =
failureInfo?.label === 'Reauth'
? KeyRound
: failureInfo?.tone === 'warning'
? AlertTriangle
: AlertCircle;
const failureTextClass =
failureInfo?.tone === 'warning'
? 'text-amber-600 dark:text-amber-400'
: failureInfo?.tone === 'destructive'
? 'text-destructive'
: 'text-muted-foreground/70';
// Tier badge (AGY only) - show P for Pro, U for Ultra
const showTierBadge =
@@ -325,28 +347,35 @@ export function AccountCard({
<div className="text-[8px] text-muted-foreground/60">
{t('accountCard.quotaUnavailable')}
</div>
) : quota?.needsReauth ? (
) : failureInfo ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 text-[8px] text-amber-600 dark:text-amber-400">
<KeyRound className="w-2.5 h-2.5" />
<span>{t('accountCard.reauthNeeded')}</span>
<div className={cn('flex items-center gap-1 text-[8px]', failureTextClass)}>
<FailureIcon className="w-2.5 h-2.5" />
<span>{failureInfo.label}</span>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[200px]">
<p className="text-xs">
{quota.error?.includes('No refresh token')
? t('accountCard.removeAndReadd')
: quota.error || t('accountCard.autoRefreshFailed')}
</p>
<TooltipContent side="top" className="max-w-[220px]">
<div className="space-y-1 text-xs">
<p>{failureInfo.summary}</p>
{failureInfo.actionHint && (
<p className="text-muted-foreground">{failureInfo.actionHint}</p>
)}
{failureInfo.technicalDetail && (
<p className="font-mono text-[11px] text-muted-foreground">
{failureInfo.technicalDetail}
</p>
)}
{failureInfo.rawDetail && (
<pre className="whitespace-pre-wrap break-all rounded bg-muted/40 px-2 py-1 font-mono text-[10px] text-muted-foreground">
{failureInfo.rawDetail}
</pre>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : quota?.error ? (
<div className="text-[8px] text-muted-foreground/60 truncate" title={quota.error}>
{quota.error.length > 20 ? `${quota.error.slice(0, 18)}...` : quota.error}
</div>
) : null}
</div>
)}
@@ -34,6 +34,7 @@ import {
cn,
formatQuotaPercent,
getCodexQuotaBreakdown,
getQuotaFailureInfo,
getProviderMinQuota,
getProviderResetTime,
isClaudeQuotaResult,
@@ -170,6 +171,19 @@ export function AccountItem({
: [];
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null;
const failureInfo = getQuotaFailureInfo(quota);
const FailureIcon =
failureInfo?.label === 'Reauth'
? KeyRound
: failureInfo?.tone === 'warning'
? AlertTriangle
: AlertCircle;
const failureBadgeClass =
failureInfo?.tone === 'warning'
? 'border-amber-500/50 text-amber-600 dark:text-amber-400'
: failureInfo?.tone === 'destructive'
? 'border-destructive/50 text-destructive'
: 'border-muted-foreground/50 text-muted-foreground';
return (
<div
@@ -439,47 +453,37 @@ export function AccountItem({
No limits
</Badge>
</div>
) : quota?.needsReauth ? (
) : failureInfo ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5">
<Badge
variant="outline"
className="text-[10px] h-5 px-2 gap-1 border-amber-500/50 text-amber-600 dark:text-amber-400"
className={cn('text-[10px] h-5 px-2 gap-1', failureBadgeClass)}
>
<KeyRound className="w-3 h-3" />
Reauth
<FailureIcon className="w-3 h-3" />
{failureInfo.label}
</Badge>
</div>
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-[220px]">
<p className="text-xs">
{quota.error?.includes('No refresh token')
? 'No refresh token available. Remove and re-add account to fix.'
: quota.error?.includes('refresh') || quota.error?.includes('Invalid')
? `Auto-refresh failed: ${quota.error}`
: `Token issue: ${quota.error || 'Re-authenticate required'}`}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : quota?.error || (quota && !quota.success) ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5">
<Badge
variant="outline"
className="text-[10px] h-5 px-2 gap-1 border-muted-foreground/50 text-muted-foreground"
>
<AlertCircle className="w-3 h-3" />
N/A
</Badge>
<TooltipContent side="bottom" className="max-w-[260px]">
<div className="space-y-1 text-xs">
<p>{failureInfo.summary}</p>
{failureInfo.actionHint && (
<p className="text-muted-foreground">{failureInfo.actionHint}</p>
)}
{failureInfo.technicalDetail && (
<p className="font-mono text-[11px] text-muted-foreground">
{failureInfo.technicalDetail}
</p>
)}
{failureInfo.rawDetail && (
<pre className="whitespace-pre-wrap break-all rounded bg-muted/40 px-2 py-1 font-mono text-[10px] text-muted-foreground">
{failureInfo.rawDetail}
</pre>
)}
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
<p className="text-xs">{quota?.error || 'Quota information unavailable'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -9,6 +9,7 @@ import {
formatQuotaPercent,
formatResetTime,
getCodexQuotaBreakdown,
getQuotaFailureInfo,
getCodexWindowDisplayLabel,
getModelsWithTiers,
groupModelsByTier,
@@ -67,7 +68,28 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
}
if (!quota.success) {
return <p className="text-xs text-destructive">{quota.error || 'Failed to load quota'}</p>;
const failureInfo = getQuotaFailureInfo(quota);
return (
<div className="text-xs space-y-1">
<p className="font-medium text-destructive">
{failureInfo?.label || quota.error || 'Failed to load quota'}
</p>
<p className="text-destructive/90">{failureInfo?.summary || quota.error}</p>
{failureInfo?.actionHint && (
<p className="text-muted-foreground">{failureInfo.actionHint}</p>
)}
{failureInfo?.technicalDetail && (
<p className="font-mono text-[11px] text-muted-foreground">
{failureInfo.technicalDetail}
</p>
)}
{failureInfo?.rawDetail && (
<pre className="whitespace-pre-wrap break-all rounded bg-muted/40 px-2 py-1 font-mono text-[10px] text-muted-foreground">
{failureInfo.rawDetail}
</pre>
)}
</div>
);
}
// Antigravity (agy) provider tooltip
+50
View File
@@ -241,10 +241,20 @@ export interface QuotaResult {
models: ModelQuota[];
/** Timestamp of fetch */
lastUpdated: number;
/** Upstream HTTP status when available */
httpStatus?: number;
/** Stable machine-readable error code */
errorCode?: string;
/** Additional provider-specific detail/code from upstream */
errorDetail?: string;
/** True if account lacks quota access (403) */
isForbidden?: boolean;
/** Error message if fetch failed */
error?: string;
/** Provider-specific remediation guidance */
actionHint?: string;
/** True when the failure is temporary and retrying later may help */
retryable?: boolean;
/** True if token is expired and needs re-authentication */
needsReauth?: boolean;
}
@@ -295,12 +305,22 @@ export interface CodexQuotaResult {
planType: 'free' | 'plus' | 'team' | null;
/** Timestamp of fetch */
lastUpdated: number;
/** Upstream HTTP status when available */
httpStatus?: number;
/** Stable machine-readable error code */
errorCode?: string;
/** Additional provider-specific detail/code from upstream */
errorDetail?: string;
/** Error message if fetch failed */
error?: string;
/** Account ID (email) this quota belongs to */
accountId?: string;
/** Provider-specific remediation guidance */
actionHint?: string;
/** True if token is expired and needs re-authentication */
needsReauth?: boolean;
/** True when the failure is temporary and retrying later may help */
retryable?: boolean;
/** True if result was served from cache */
cached?: boolean;
/** True if account lacks quota access (403) - displayed as 0% instead of error */
@@ -353,9 +373,15 @@ export interface ClaudeQuotaResult {
windows: ClaudeQuotaWindow[];
coreUsage?: ClaudeCoreUsageSummary;
lastUpdated: number;
httpStatus?: number;
errorCode?: string;
errorDetail?: string;
isForbidden?: boolean;
error?: string;
accountId?: string;
actionHint?: string;
needsReauth?: boolean;
retryable?: boolean;
/** True if result was served from cache */
cached?: boolean;
}
@@ -388,12 +414,24 @@ export interface GeminiCliQuotaResult {
projectId: string | null;
/** Timestamp of fetch */
lastUpdated: number;
/** Upstream HTTP status when available */
httpStatus?: number;
/** Stable machine-readable error code */
errorCode?: string;
/** Additional provider-specific detail/code from upstream */
errorDetail?: string;
/** True if account lacks quota access (403) */
isForbidden?: boolean;
/** Error message if fetch failed */
error?: string;
/** Account ID (email) this quota belongs to */
accountId?: string;
/** Provider-specific remediation guidance */
actionHint?: string;
/** True if token is expired and needs re-authentication */
needsReauth?: boolean;
/** True when the failure is temporary and retrying later may help */
retryable?: boolean;
/** True if result was served from cache */
cached?: boolean;
}
@@ -435,12 +473,24 @@ export interface GhcpQuotaResult {
};
/** Timestamp of fetch */
lastUpdated: number;
/** Upstream HTTP status when available */
httpStatus?: number;
/** Stable machine-readable error code */
errorCode?: string;
/** Additional provider-specific detail/code from upstream */
errorDetail?: string;
/** True if account lacks quota access (403) */
isForbidden?: boolean;
/** Error message if fetch failed */
error?: string;
/** Account ID this quota belongs to */
accountId?: string;
/** Provider-specific remediation guidance */
actionHint?: string;
/** True if token is expired and needs re-authentication */
needsReauth?: boolean;
/** True when the failure is temporary and retrying later may help */
retryable?: boolean;
/** True if result was served from cache */
cached?: boolean;
}
+41
View File
@@ -512,6 +512,20 @@ const resources = {
quota: 'Quota',
quotaUnavailable: 'Quota limits unavailable',
reauthNeeded: 'Reauth needed',
failureLabelReauth: 'Reauth',
failureLabelWorkspace: 'Workspace',
failureLabelNoAccess: 'No Access',
failureLabelRetry: 'Retry',
failureLabelReconnect: 'Reconnect',
failureLabelTemporary: 'Temporary',
failureLabelUnavailable: 'Unavailable',
failureHintReauth: 'Refresh this account by running its auth flow again.',
failureHintWorkspace:
'Move this account back to an active workspace, then remove and re-add it.',
failureHintNoAccess: 'This account cannot access the provider quota endpoint.',
failureHintRetry: 'Wait a bit, then retry the quota request.',
failureHintReconnect: 'Remove the stale account and authenticate it again.',
failureHintTemporary: 'Retry later. This looks temporary.',
removeAndReadd: 'Remove and re-add account',
autoRefreshFailed: 'Auto-refresh failed',
},
@@ -1634,6 +1648,19 @@ const resources = {
quota: '配额',
quotaUnavailable: '配额限制不可用',
reauthNeeded: '需要重新认证',
failureLabelReauth: '重新认证',
failureLabelWorkspace: '工作区',
failureLabelNoAccess: '无权限',
failureLabelRetry: '重试',
failureLabelReconnect: '重新连接',
failureLabelTemporary: '临时问题',
failureLabelUnavailable: '不可用',
failureHintReauth: '请重新运行该账号的认证流程以刷新访问权限。',
failureHintWorkspace: '请将该账号移回可用的工作区,然后移除并重新添加。',
failureHintNoAccess: '该账号无法访问提供商的配额接口。',
failureHintRetry: '稍后再试一次。',
failureHintReconnect: '请移除这个失效账号并重新认证。',
failureHintTemporary: '这看起来是临时问题,请稍后重试。',
removeAndReadd: '移除并重新添加账号',
autoRefreshFailed: '自动刷新失败',
},
@@ -2772,6 +2799,20 @@ const resources = {
quota: 'hạn ngạch',
quotaUnavailable: 'Thông tin quota chưa khả dụng',
reauthNeeded: 'Cần xác thực lại',
failureLabelReauth: 'Xác thực lại',
failureLabelWorkspace: 'Workspace',
failureLabelNoAccess: 'Không có quyền',
failureLabelRetry: 'Thử lại',
failureLabelReconnect: 'Kết nối lại',
failureLabelTemporary: 'Tạm thời',
failureLabelUnavailable: 'Chưa khả dụng',
failureHintReauth: 'Chạy lại luồng xác thực của tài khoản này để làm mới quyền truy cập.',
failureHintWorkspace:
'Chuyển tài khoản này về workspace còn hoạt động rồi xóa và thêm lại.',
failureHintNoAccess: 'Tài khoản này không thể truy cập endpoint quota của nhà cung cấp.',
failureHintRetry: 'Đợi một chút rồi thử lại yêu cầu quota.',
failureHintReconnect: 'Xóa tài khoản cũ rồi xác thực lại.',
failureHintTemporary: 'Có vẻ đây là lỗi tạm thời. Hãy thử lại sau.',
removeAndReadd: 'Xóa và thêm lại tài khoản',
autoRefreshFailed: 'Tự động làm mới không thành công',
},
+166
View File
@@ -9,6 +9,7 @@ import type {
GhcpQuotaResult,
QuotaResult,
} from './api-client';
import i18n from './i18n';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -718,6 +719,171 @@ export function isGhcpQuotaResult(quota: UnifiedQuotaResult): quota is GhcpQuota
// ==================== Unified Quota Helpers ====================
export interface QuotaFailureInfo {
label: string;
summary: string;
actionHint: string | null;
technicalDetail: string | null;
rawDetail: string | null;
tone: 'warning' | 'muted' | 'destructive';
}
function buildQuotaTechnicalDetail(quota: UnifiedQuotaResult): string | null {
const details: string[] = [];
if (typeof quota.httpStatus === 'number') {
details.push(`HTTP ${quota.httpStatus}`);
}
if (typeof quota.errorCode === 'string' && quota.errorCode.trim()) {
details.push(quota.errorCode.trim());
}
return details.length > 0 ? details.join(' | ') : null;
}
function buildQuotaRawDetail(
quota: UnifiedQuotaResult,
summary: string,
technicalDetail: string | null
): string | null {
const rawDetail = quota.errorDetail?.trim() || null;
if (!rawDetail) return null;
const normalizedRawDetail = rawDetail.toLowerCase();
if (normalizedRawDetail === summary.toLowerCase()) {
return null;
}
if (technicalDetail && normalizedRawDetail === technicalDetail.toLowerCase()) {
return null;
}
return rawDetail;
}
export function getQuotaFailureInfo(
quota: UnifiedQuotaResult | null | undefined
): QuotaFailureInfo | null {
if (!quota || quota.success) {
return null;
}
const summary = quota.error?.trim() || 'Quota information unavailable';
const actionHint = quota.actionHint?.trim() || null;
const errorCode = quota.errorCode?.trim().toLowerCase() || '';
const technicalDetail = buildQuotaTechnicalDetail(quota);
const rawDetail = buildQuotaRawDetail(quota, summary, technicalDetail);
const lowerSummary = summary.toLowerCase();
if (
quota.needsReauth ||
errorCode === 'token_expired' ||
errorCode === 'reauth_required' ||
lowerSummary.includes('token expired') ||
lowerSummary.includes('re-authenticate') ||
lowerSummary.includes('reauth') ||
lowerSummary.includes('expired or invalid')
) {
return {
label: i18n.t('accountCard.failureLabelReauth'),
summary,
actionHint: actionHint || i18n.t('accountCard.failureHintReauth'),
technicalDetail,
rawDetail,
tone: 'warning',
};
}
if (
errorCode === 'deactivated_workspace' ||
quota.httpStatus === 402 ||
lowerSummary.includes('workspace deactivated') ||
lowerSummary.includes('payment or workspace access required')
) {
return {
label: i18n.t('accountCard.failureLabelWorkspace'),
summary,
actionHint: actionHint || i18n.t('accountCard.failureHintWorkspace'),
technicalDetail,
rawDetail,
tone: 'warning',
};
}
if (
quota.isForbidden ||
quota.httpStatus === 403 ||
errorCode === 'quota_api_forbidden' ||
lowerSummary.includes('forbidden')
) {
return {
label: i18n.t('accountCard.failureLabelNoAccess'),
summary,
actionHint: actionHint || i18n.t('accountCard.failureHintNoAccess'),
technicalDetail,
rawDetail,
tone: 'muted',
};
}
if (
quota.httpStatus === 429 ||
errorCode === 'rate_limited' ||
lowerSummary.includes('rate limited')
) {
return {
label: i18n.t('accountCard.failureLabelRetry'),
summary,
actionHint: actionHint || i18n.t('accountCard.failureHintRetry'),
technicalDetail,
rawDetail,
tone: 'warning',
};
}
if (
errorCode === 'auth_file_missing' ||
errorCode === 'missing_account_id' ||
lowerSummary.includes('auth file not found') ||
lowerSummary.includes('missing chatgpt-account-id')
) {
return {
label: i18n.t('accountCard.failureLabelReconnect'),
summary,
actionHint: actionHint || i18n.t('accountCard.failureHintReconnect'),
technicalDetail,
rawDetail,
tone: 'muted',
};
}
if (
quota.retryable ||
errorCode === 'network_timeout' ||
errorCode === 'network_error' ||
errorCode === 'provider_unavailable' ||
lowerSummary.includes('timeout') ||
lowerSummary.includes('network') ||
lowerSummary.includes('fetch failed') ||
lowerSummary.includes('service unavailable')
) {
return {
label: i18n.t('accountCard.failureLabelTemporary'),
summary,
actionHint: actionHint || i18n.t('accountCard.failureHintTemporary'),
technicalDetail,
rawDetail,
tone: 'warning',
};
}
return {
label: i18n.t('accountCard.failureLabelUnavailable'),
summary,
actionHint,
technicalDetail,
rawDetail,
tone: 'muted',
};
}
/**
* Get minimum quota percentage for any provider
* Centralizes provider-specific logic to eliminate duplication
+100
View File
@@ -12,6 +12,7 @@ import {
getCodexWindowDisplayLabel,
getMinGeminiQuota,
getGeminiResetTime,
getQuotaFailureInfo,
getProviderMinQuota,
getProviderResetTime,
isAgyQuotaResult,
@@ -1724,3 +1725,102 @@ describe('getProviderResetTime', () => {
});
});
});
describe('getQuotaFailureInfo', () => {
it('maps reauth failures to a clear reauth badge and technical detail', () => {
const quota: CodexQuotaResult = {
success: false,
windows: [],
planType: null,
lastUpdated: Date.now(),
error: 'Codex token expired or invalid',
needsReauth: true,
httpStatus: 401,
errorCode: 'reauth_required',
errorDetail: '{"detail":"session expired"}',
};
expect(getQuotaFailureInfo(quota)).toEqual({
label: 'Reauth',
summary: 'Codex token expired or invalid',
actionHint: 'Refresh this account by running its auth flow again.',
technicalDetail: 'HTTP 401 | reauth_required',
rawDetail: '{"detail":"session expired"}',
tone: 'warning',
});
});
it('maps 402 workspace failures to a workspace badge and actionable hint', () => {
const quota: CodexQuotaResult = {
success: false,
windows: [],
planType: null,
lastUpdated: Date.now(),
error: 'Workspace deactivated (HTTP 402)',
httpStatus: 402,
errorCode: 'deactivated_workspace',
errorDetail: '{"detail":{"code":"deactivated_workspace"}}',
};
expect(getQuotaFailureInfo(quota)).toEqual({
label: 'Workspace',
summary: 'Workspace deactivated (HTTP 402)',
actionHint: 'Move this account back to an active workspace, then remove and re-add it.',
technicalDetail: 'HTTP 402 | deactivated_workspace',
rawDetail: '{"detail":{"code":"deactivated_workspace"}}',
tone: 'warning',
});
});
it('maps retryable network failures to a temporary badge', () => {
const quota: GeminiCliQuotaResult = {
success: false,
buckets: [],
projectId: null,
lastUpdated: Date.now(),
error: 'Network timeout while fetching quota',
errorCode: 'network_timeout',
retryable: true,
errorDetail: 'ETIMEDOUT',
};
expect(getQuotaFailureInfo(quota)).toEqual({
label: 'Temporary',
summary: 'Network timeout while fetching quota',
actionHint: 'Retry later. This looks temporary.',
technicalDetail: 'network_timeout',
rawDetail: 'ETIMEDOUT',
tone: 'warning',
});
});
it('returns null when quota fetch succeeded', () => {
const quota: QuotaResult = {
success: true,
models: [],
lastUpdated: Date.now(),
};
expect(getQuotaFailureInfo(quota)).toBeNull();
});
it('suppresses raw detail when it only duplicates the summary', () => {
const quota: CodexQuotaResult = {
success: false,
windows: [],
planType: null,
lastUpdated: Date.now(),
error: 'Quota fetch failed',
errorDetail: 'Quota fetch failed',
};
expect(getQuotaFailureInfo(quota)).toEqual({
label: 'Temporary',
summary: 'Quota fetch failed',
actionHint: 'Retry later. This looks temporary.',
technicalDetail: null,
rawDetail: null,
tone: 'warning',
});
});
});