fix(cliproxy): delegate gemini refresh to upstream

This commit is contained in:
Tam Nhu Tran committed 2026-04-16 12:49:51 -04:00
1 parent e7297bd66b
commit 9dc6374851
8 files changed
+410 -756

No files matched your search

-437
View File
@@ -1,437 +0,0 @@
/**
* Gemini Token Refresh
*
* Handles proactive token validation and refresh for Gemini OAuth tokens.
* Prevents UND_ERR_SOCKET errors by ensuring tokens are valid before use.
*
* Token sources (priority order):
* 1. CLIProxy auth dir (~/.ccs/cliproxy/auth/) - CCS-managed tokens
* 2. Standard Gemini CLI (~/.gemini/oauth_creds.json) - backward compatibility
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { getProviderAuthDir } from '../config-generator';
import { getDefaultAccount, getProviderAccounts } from '../account-manager';
import { isTokenFileForProvider } from './token-manager';
/** 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;
refresh_token?: string;
expiry_date?: number; // Unix timestamp in milliseconds
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 */
interface GeminiCredsWithSource {
creds: GeminiOAuthCreds;
sourcePath: string;
}
/** CLIProxyAPI Gemini token structure (from GeminiTokenStorage Go struct) */
interface CliproxyGeminiToken {
token: {
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;
type: 'gemini';
}
/** Token refresh response from Google */
interface TokenRefreshResponse {
access_token?: string;
expires_in?: number;
token_type?: string;
error?: string;
error_description?: string;
}
interface GoogleOAuthClientCredentials {
clientId: string;
clientSecret: string;
tokenUrl: string;
}
/**
* Get path to Gemini OAuth credentials file
*/
export function getGeminiOAuthPath(): string {
return path.join(os.homedir(), '.gemini', 'oauth_creds.json');
}
/**
* Map CLIProxyAPI token format to internal GeminiOAuthCreds format
*/
function mapCliproxyToGeminiCreds(cliproxy: CliproxyGeminiToken): GeminiOAuthCreds {
return {
access_token: cliproxy.token.access_token,
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,
};
}
/**
* Validate CLIProxyAPI token structure has required fields
*/
function isValidCliproxyToken(data: unknown): data is CliproxyGeminiToken {
if (typeof data !== 'object' || data === null) return false;
const obj = data as Record<string, unknown>;
if (obj.type !== 'gemini') return false;
if (typeof obj.token !== 'object' || obj.token === null) return false;
const token = obj.token as Record<string, unknown>;
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
*/
function readCliproxyGeminiCreds(accountId?: string): GeminiCredsWithSource | null {
const authDir = getProviderAuthDir('gemini');
if (!fs.existsSync(authDir)) return null;
let tokenPath: string | null = null;
const normalizedAccountId = accountId?.trim();
const accounts = getProviderAccounts('gemini');
// Account-specific refresh path (used by background worker)
if (normalizedAccountId) {
const targetAccount = accounts.find((account) => account.id === normalizedAccountId);
if (!targetAccount) {
return null;
}
tokenPath = path.join(authDir, targetAccount.tokenFile);
}
if (!normalizedAccountId) {
// Try to find default account's token file
const defaultAccount = getDefaultAccount('gemini');
if (defaultAccount) {
tokenPath = path.join(authDir, defaultAccount.tokenFile);
if (!fs.existsSync(tokenPath)) tokenPath = null;
}
// Fallback: find any gemini account token file
if (!tokenPath && accounts.length > 0) {
tokenPath = path.join(authDir, accounts[0].tokenFile);
if (!fs.existsSync(tokenPath)) tokenPath = null;
}
// Last fallback: scan directory for gemini token files
if (!tokenPath) {
try {
const files = fs.readdirSync(authDir).filter((f) => f.endsWith('.json'));
for (const file of files) {
const filePath = path.join(authDir, file);
if (file.startsWith('gemini-') || isTokenFileForProvider(filePath, 'gemini')) {
tokenPath = filePath;
break;
}
}
} catch {
// Directory read failed - continue to return null
return null;
}
}
}
if (!tokenPath) return null;
try {
const content = fs.readFileSync(tokenPath, 'utf8');
const data: unknown = JSON.parse(content);
// Validate CLIProxyAPI format with proper type checking
if (isValidCliproxyToken(data)) {
return {
creds: mapCliproxyToGeminiCreds(data),
sourcePath: tokenPath,
};
}
return null;
} catch {
return null;
}
}
/**
* Read Gemini OAuth credentials
* Priority: CLIProxy auth dir first, then ~/.gemini/oauth_creds.json
* Returns credentials with source path for correct write-back
*/
function readGeminiCreds(accountId?: string): GeminiCredsWithSource | null {
// 1. Try CLIProxy auth directory first (CCS-managed tokens)
const cliproxyResult = readCliproxyGeminiCreds(accountId);
if (cliproxyResult) {
return cliproxyResult;
}
// Account-scoped refresh is only supported for CLIProxy account files.
// Do not fall back to ~/.gemini for a specific accountId.
if (accountId?.trim()) {
return null;
}
// 2. Fall back to standard Gemini CLI location
const oauthPath = getGeminiOAuthPath();
if (!fs.existsSync(oauthPath)) {
return null;
}
try {
const content = fs.readFileSync(oauthPath, 'utf8');
return {
creds: JSON.parse(content) as GeminiOAuthCreds,
sourcePath: oauthPath,
};
} catch {
return null;
}
}
/**
* Write updated credentials to CLIProxy token file
* Preserves existing fields (email, project_id), only updates token subfields
*/
function writeCliproxyGeminiCreds(tokenPath: string, creds: GeminiOAuthCreds): string | undefined {
try {
const existing = JSON.parse(fs.readFileSync(tokenPath, 'utf8'));
const updated = {
...existing,
token: {
...existing.token,
access_token: creds.access_token,
refresh_token: creds.refresh_token,
expiry: creds.expiry_date,
},
};
fs.writeFileSync(tokenPath, JSON.stringify(updated, null, 2), { mode: 0o600 });
return undefined;
} catch (err) {
return err instanceof Error ? err.message : 'Failed to write credentials';
}
}
/**
* Write Gemini OAuth credentials
* Writes back to the specified source location (CLIProxy or ~/.gemini)
* @param creds - The credentials to write
* @param sourcePath - The path where credentials were originally read from
* @returns error message if write failed, undefined on success
*/
function writeGeminiCreds(creds: GeminiOAuthCreds, sourcePath: string): string | undefined {
const geminiOAuthPath = getGeminiOAuthPath();
// If source is not the standard Gemini path, write to CLIProxy format
if (sourcePath !== geminiOAuthPath) {
return writeCliproxyGeminiCreds(sourcePath, creds);
}
// Otherwise write to standard Gemini CLI location
const dir = path.dirname(geminiOAuthPath);
try {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
}
fs.writeFileSync(geminiOAuthPath, JSON.stringify(creds, null, 2), { mode: 0o600 });
return undefined;
} catch (err) {
return err instanceof Error ? err.message : 'Failed to write credentials';
}
}
/**
* Check if Gemini token is expired or expiring soon
*/
export function isGeminiTokenExpiringSoon(accountId?: string): boolean {
const result = readGeminiCreds(accountId);
if (!result || !result.creds.access_token) {
return true; // No token = needs auth
}
if (!result.creds.expiry_date) {
return false; // No expiry info = assume valid
}
const expiresIn = result.creds.expiry_date - Date.now();
return expiresIn < REFRESH_LEAD_TIME_MS;
}
/**
* Refresh Gemini access token using refresh_token
* @param accountId Optional account ID for account-scoped refresh
* @returns Result with success status, optional error, and expiry time
*/
export async function refreshGeminiToken(accountId?: string): Promise<{
success: boolean;
error?: string;
expiresAt?: number;
}> {
const result = readGeminiCreds(accountId);
if (!result || !result.creds.refresh_token) {
return { success: false, error: 'No refresh token available' };
}
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(tokenUrl, {
method: 'POST',
signal: controller.signal,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: creds.refresh_token as string, // Already validated above
client_id: clientId,
client_secret: clientSecret,
}).toString(),
});
clearTimeout(timeoutId);
const data = (await response.json()) as TokenRefreshResponse;
if (!response.ok || data.error) {
return {
success: false,
error: data.error_description || data.error || `OAuth error: ${response.status}`,
};
}
if (!data.access_token) {
return { success: false, error: 'No access_token in response' };
}
// Update credentials file with new token
const expiresAt = Date.now() + (data.expires_in ?? 3600) * 1000;
const updatedCreds: GeminiOAuthCreds = {
...creds,
access_token: data.access_token,
expiry_date: expiresAt,
};
const writeError = writeGeminiCreds(updatedCreds, sourcePath);
if (writeError) {
return { success: false, error: `Token refreshed but failed to save: ${writeError}` };
}
return { success: true, expiresAt };
} catch (err) {
clearTimeout(timeoutId);
if (err instanceof Error && err.name === 'AbortError') {
return { success: false, error: 'Token refresh timeout' };
}
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
}
}
/**
* Ensure Gemini token is valid, refreshing if needed
* @param verbose Log progress if true
* @param accountId Optional account ID for account-scoped refresh
* @returns true if token is valid (or was refreshed), false if refresh failed
*/
export async function ensureGeminiTokenValid(
verbose = false,
accountId?: string
): Promise<{
valid: boolean;
refreshed: boolean;
error?: string;
}> {
const result = readGeminiCreds(accountId);
if (!result || !result.creds.access_token) {
return { valid: false, refreshed: false, error: 'No Gemini credentials found' };
}
if (!isGeminiTokenExpiringSoon(accountId)) {
return { valid: true, refreshed: false };
}
// Token is expired or expiring soon - try to refresh
if (verbose) {
console.log('[i] Gemini token expired or expiring soon, refreshing...');
}
const refreshResult = await refreshGeminiToken(accountId);
if (refreshResult.success) {
if (verbose) {
console.log('[OK] Gemini token refreshed successfully');
}
return { valid: true, refreshed: true };
}
return { valid: false, refreshed: false, error: refreshResult.error };
}
+6 -29
View File
@@ -4,8 +4,7 @@
* Exports refresh functions for each OAuth provider.
*
* Refresh responsibility:
* - CCS-managed: gemini (CCS refreshes tokens directly via Google OAuth)
* - CLIProxy-delegated: codex, agy, kiro, ghcp, qwen, iflow, kimi
* - CLIProxy-delegated: gemini, codex, agy, kiro, ghcp, qwen, iflow, kimi
* (CLIProxyAPIPlus handles refresh automatically in background)
* - Not implemented: claude
*/
@@ -16,7 +15,6 @@ import {
getTokenRefreshOwnership,
isRefreshDelegatedToCLIProxy,
} from '../../provider-capabilities';
import { refreshGeminiToken } from '../gemini-token-refresh';
/** Token refresh result */
export interface ProviderRefreshResult {
@@ -66,19 +64,18 @@ export async function refreshToken(
};
}
if (provider === 'gemini') {
return await refreshGeminiTokenWrapper(normalizedAccountId);
}
const ownership = getTokenRefreshOwnership(provider);
switch (ownership) {
case 'cliproxy':
// CLIProxyAPIPlus handles refresh for these providers automatically.
// No action needed from CCS — report success with delegated flag.
return { success: true, delegated: true };
case 'unsupported':
case 'ccs':
// Non-gemini CCS-owned refresh paths are not implemented yet.
return {
success: false,
error: `Token refresh not yet implemented for ${provider}`,
};
case 'unsupported':
return {
success: false,
error: `Token refresh not yet implemented for ${provider}`,
@@ -87,23 +84,3 @@ export async function refreshToken(
return assertNever(ownership);
}
}
/**
* Wrapper for Gemini token refresh
* Converts gemini-token-refresh.ts format to provider-refreshers format
*/
async function refreshGeminiTokenWrapper(accountId: string): Promise<ProviderRefreshResult> {
const result = await refreshGeminiToken(accountId);
if (!result.success) {
return {
success: false,
error: result.error,
};
}
return {
success: true,
expiresAt: result.expiresAt,
};
}
+9 -6
View File
@@ -14,6 +14,7 @@ import { getProviderAuthDir } from '../config-generator';
import { getProviderAccounts, getDefaultAccount } from '../account-manager';
import { deleteTokenFile, extractAccountIdFromTokenFile } from '../accounts/token-file-ops';
import { buildEmailBackedAccountId } from '../accounts/email-account-identity';
import { getTokenRefreshOwnership } from '../provider-capabilities';
import {
AuthStatus,
PROVIDER_AUTH_PREFIXES,
@@ -507,14 +508,16 @@ export function displayAuthStatus(): void {
*/
export async function ensureTokenValid(
provider: CLIProxyProvider,
verbose = false
_verbose = false
): Promise<{ valid: boolean; refreshed: boolean; error?: string }> {
if (provider === 'gemini') {
const { ensureGeminiTokenValid } = await import('./gemini-token-refresh');
return ensureGeminiTokenValid(verbose);
if (getTokenRefreshOwnership(provider) === 'ccs') {
return {
valid: false,
refreshed: false,
error: `CCS-managed token validation is not available for ${provider}`,
};
}
// For CLIProxy-delegated providers, token refresh is handled by CLIProxyAPIPlus.
// CCS only verifies the token file exists (authentication state).
// Runtime-managed providers refresh upstream. CCS only verifies auth material exists locally.
return { valid: isAuthenticated(provider), refreshed: false };
}
+1 -1
View File
@@ -33,7 +33,7 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
callbackPort: 8085,
callbackProviderName: 'gemini',
authUrlProviderName: 'gemini-cli',
refreshOwnership: 'ccs',
refreshOwnership: 'cliproxy',
authFilePrefixes: ['gemini-', 'google-'],
tokenTypeValues: ['gemini'],
aliases: ['gemini-cli'],
+235 -101
View File
@@ -10,11 +10,11 @@ import * as path from 'node:path';
import { getAuthDir } from './config-generator';
import { getProviderAccounts, getPausedDir, setAccountTier } from './account-manager';
import { getTokenExpiryTimestamp, sanitizeEmail, isTokenExpired } from './auth-utils';
import { refreshGeminiToken } from './auth/gemini-token-refresh';
import {
buildGeminiCliBucketsFromParsedBuckets,
type GeminiCliParsedBucket,
} from './gemini-cli-quota-normalizer';
import { buildManagementHeaders, buildProxyUrl, getProxyTarget } from './proxy-target-resolver';
import type { GeminiCliQuotaResult, GeminiCliBucket } from './quota-types';
import {
buildProviderEntitlementEvidence,
@@ -32,6 +32,7 @@ const GEMINI_CLI_CODE_ASSIST_URL = `${GEMINI_CLI_API_BASE}/${GEMINI_CLI_API_VERS
const GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH = 320;
const GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX = '...[truncated]';
const GEMINI_CLI_G1_CREDIT_TYPE = 'GOOGLE_ONE_AI';
const MANAGEMENT_API_TIMEOUT_MS = 5000;
/** Auth data extracted from Gemini CLI auth file */
interface GeminiCliAuthData {
@@ -93,6 +94,26 @@ interface GeminiCliSupplementaryInfo {
normalizedTier: 'free' | 'pro' | 'ultra' | 'unknown';
}
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;
}
/**
* Extract project ID from account field
* Input: "user@example.com (cloudaicompanion-abc-123)"
@@ -167,6 +188,172 @@ function isGeminiAuthFile(filename: string): boolean {
return false;
}
function safeParseJson(bodyText: string): unknown {
try {
return JSON.parse(bodyText);
} catch {
return null;
}
}
async function readManagedResponse(
response: Response,
viaManagement: boolean
): Promise<ManagedResponse> {
const bodyText = await response.text();
return {
status: response.status,
bodyText,
json: safeParseJson(bodyText),
viaManagement,
};
}
function isGeminiAuthFileForAccount(file: ManagementAuthFile, accountId: string): boolean {
const provider = normalizeStringValue(file.provider ?? file.type);
if (provider !== 'gemini') {
return false;
}
const email = normalizeStringValue(file.email);
const normalizedAccountId = accountId.trim().toLowerCase();
if (email?.toLowerCase() === normalizedAccountId) {
return true;
}
const normalizedName = normalizeStringValue(file.name);
if (!normalizedName) {
return false;
}
const normalizedFileName = normalizedName.toLowerCase();
const sanitizedAccount = sanitizeEmail(accountId).toLowerCase();
return (
normalizedFileName === `gemini-${sanitizedAccount}.json` ||
normalizedFileName.startsWith(`${normalizedAccountId}-gen-lang-client-`) ||
normalizedFileName.includes(sanitizedAccount)
);
}
async function findManagedGeminiAuthIndex(accountId: string): Promise<string | number | 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/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) => isGeminiAuthFileForAccount(file, accountId));
return match?.auth_index ?? null;
} catch {
clearTimeout(timeoutId);
return null;
}
}
async function performManagedGeminiRequest(
accountId: string,
url: string,
body: string
): Promise<ManagedResponse | null> {
const authIndex = await findManagedGeminiAuthIndex(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: {
Authorization: 'Bearer $TOKEN$',
'Content-Type': 'application/json',
},
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 performGeminiCliRequest(
accountId: string,
accessToken: string,
url: string,
body: string,
preferManagement = false
): Promise<ManagedResponse> {
if (preferManagement) {
const managedResult = await performManagedGeminiRequest(accountId, url, body);
if (managedResult) {
return managedResult;
}
}
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: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body,
});
clearTimeout(timeoutId);
const directResult = await readManagedResponse(response, false);
if (directResult.status !== 401) {
return directResult;
}
const managedResult = await performManagedGeminiRequest(accountId, url, body);
return managedResult ?? directResult;
} catch (error) {
clearTimeout(timeoutId);
throw error;
}
}
/**
* Read auth data from Gemini CLI auth file
* Supports multiple file naming conventions and JSON structures
@@ -308,42 +495,40 @@ function resolveGeminiCliCreditBalance(payload: GeminiCliCodeAssistResponse | nu
}
async function fetchGeminiCliSupplementary(
accountId: string,
accessToken: string,
projectId: string,
verbose: boolean
): Promise<GeminiCliSupplementaryInfo> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const requestBody = JSON.stringify({
cloudaicompanionProject: projectId,
metadata: {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
duetProject: projectId,
},
});
try {
const response = await fetch(GEMINI_CLI_CODE_ASSIST_URL, {
method: 'POST',
signal: controller.signal,
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
cloudaicompanionProject: projectId,
metadata: {
ideType: 'IDE_UNSPECIFIED',
platform: 'PLATFORM_UNSPECIFIED',
pluginType: 'GEMINI',
duetProject: projectId,
},
}),
});
const response = await performGeminiCliRequest(
accountId,
accessToken,
GEMINI_CLI_CODE_ASSIST_URL,
requestBody
);
clearTimeout(timeoutId);
if (!response.ok) {
if (response.status !== 200) {
if (verbose) {
console.error(`[i] Gemini CLI supplementary metadata unavailable: HTTP ${response.status}`);
const source = response.viaManagement ? 'managed' : 'direct';
console.error(
`[i] Gemini CLI supplementary metadata unavailable via ${source}: HTTP ${response.status}`
);
}
return { tierLabel: null, tierId: null, creditBalance: null, normalizedTier: 'unknown' };
}
const payload = (await response.json()) as GeminiCliCodeAssistResponse;
const payload = response.json as GeminiCliCodeAssistResponse | null;
return {
tierLabel: resolveGeminiCliTierLabel(payload),
tierId: resolveGeminiCliTierId(payload),
@@ -351,7 +536,6 @@ async function fetchGeminiCliSupplementary(
normalizedTier: normalizeProviderTierId(resolveGeminiCliTierId(payload)),
};
} catch (error) {
clearTimeout(timeoutId);
if (verbose) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error(`[i] Gemini CLI supplementary metadata skipped: ${message}`);
@@ -680,41 +864,39 @@ async function fetchWithAuthData(
});
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
const supplementaryPromise = fetchGeminiCliSupplementary(
accountId,
authData.accessToken,
authData.projectId,
verbose
);
const requestBody = JSON.stringify({ project: authData.projectId });
try {
const response = await fetch(GEMINI_CLI_QUOTA_URL, {
method: 'POST',
signal: controller.signal,
headers: {
Authorization: `Bearer ${authData.accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ project: authData.projectId }),
});
const response = await performGeminiCliRequest(
accountId,
authData.accessToken,
GEMINI_CLI_QUOTA_URL,
requestBody,
authData.isExpired
);
clearTimeout(timeoutId);
if (verbose) {
const source = response.viaManagement ? 'managed' : 'direct';
console.error(`[i] Gemini CLI API status via ${source}: ${response.status}`);
}
if (verbose) console.error(`[i] Gemini CLI API status: ${response.status}`);
if (!response.ok) {
const bodyText = await response.text();
if (response.status !== 200) {
return buildGeminiCliHttpFailureResult(
accountId,
authData.projectId,
response.status,
bodyText
response.bodyText
);
}
const data = (await response.json()) as GeminiCliQuotaResponse;
const rawBuckets = data.buckets || [];
const data = response.json as GeminiCliQuotaResponse | null;
const rawBuckets = data?.buckets || [];
const buckets = buildGeminiCliBuckets(rawBuckets);
const supplementary = await supplementaryPromise;
@@ -744,7 +926,6 @@ async function fetchWithAuthData(
accountId,
};
} catch (err) {
clearTimeout(timeoutId);
const errorMsg =
err instanceof Error && err.name === 'AbortError'
? 'Request timeout'
@@ -778,7 +959,7 @@ export async function fetchGeminiCliQuota(
): Promise<GeminiCliQuotaResult> {
if (verbose) console.error(`[i] Fetching Gemini CLI quota for ${accountId}...`);
let authData = readGeminiCliAuthData(accountId);
const authData = readGeminiCliAuthData(accountId);
if (!authData) {
const error = 'Auth file not found for Gemini account';
if (verbose) console.error(`[!] Error: ${error}`);
@@ -790,62 +971,15 @@ export async function fetchGeminiCliQuota(
});
}
// Proactive refresh: refresh if expired OR expiring within 5 minutes
const REFRESH_LEAD_TIME_MS = 5 * 60 * 1000;
const expiresAt = getTokenExpiryTimestamp(authData.expiresAt);
const shouldRefresh =
authData.isExpired || expiresAt === null || expiresAt - Date.now() < REFRESH_LEAD_TIME_MS;
let refreshedBeforeQuotaFetch = false;
if (shouldRefresh) {
if (verbose)
console.error(
authData.isExpired
? '[i] Token expired, refreshing...'
: '[i] Token expiring soon, proactive refresh...'
);
const refreshResult = await refreshGeminiToken(accountId);
if (refreshResult.success) {
refreshedBeforeQuotaFetch = true;
if (verbose) console.error('[i] Token refreshed successfully');
// Re-read auth data after successful refresh
const refreshedAuthData = readGeminiCliAuthData(accountId);
if (refreshedAuthData) {
authData = refreshedAuthData;
}
} else if (authData.isExpired) {
// Only fail if token is actually expired (not just expiring soon)
const error = refreshResult.error || 'Token refresh failed';
if (verbose) console.error(`[!] Refresh failed: ${error}`);
return buildGeminiCliFailureResult(accountId, authData.projectId, {
error,
errorCode: 'reauth_required',
errorDetail: error,
actionHint: 'Run ccs gemini --auth to reconnect this account.',
needsReauth: true,
retryable: false,
});
}
// If proactive refresh fails but token isn't expired yet, continue with existing token
if (authData.isExpired && verbose) {
const expiresAt = getTokenExpiryTimestamp(authData.expiresAt);
const expiryLabel = expiresAt ? new Date(expiresAt).toISOString() : 'unknown';
console.error(
`[i] Gemini access token is expired (${expiryLabel}); quota requests will defer to managed auth when available.`
);
}
// First attempt with current token
const result = await fetchWithAuthData(authData, accountId, verbose);
// Retry once with an account-scoped refresh when the quota endpoint rejects auth.
if (result.needsReauth && !refreshedBeforeQuotaFetch) {
if (verbose) console.error('[i] Got 401, attempting refresh and retry...');
const refreshResult = await refreshGeminiToken(accountId);
if (refreshResult.success) {
const refreshedAuthData = readGeminiCliAuthData(accountId);
if (refreshedAuthData) {
return await fetchWithAuthData(refreshedAuthData, accountId, verbose);
}
}
}
return result;
return await fetchWithAuthData(authData, accountId, verbose);
}
/**