mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
fix: improve quota diagnostics and oauth refresh handling
This commit is contained in:
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user