mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
fix(cliproxy): delegate gemini refresh to upstream
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
|
||||
describe('Gemini refresh delegation', () => {
|
||||
let tempHome: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalCcsDir: string | undefined;
|
||||
let moduleVersion = 0;
|
||||
|
||||
beforeEach(() => {
|
||||
moduleVersion += 1;
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-gemini-delegation-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
delete process.env.CCS_DIR;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
|
||||
if (originalCcsDir === undefined) {
|
||||
delete process.env.CCS_DIR;
|
||||
} else {
|
||||
process.env.CCS_DIR = originalCcsDir;
|
||||
}
|
||||
});
|
||||
|
||||
it('treats Gemini as runtime-managed even when the local token lacks OAuth client metadata', async () => {
|
||||
const { getProviderAuthDir } = await import(
|
||||
`../../../src/cliproxy/config-generator?gemini-delegation-config=${moduleVersion}`
|
||||
);
|
||||
const { ensureTokenValid } = await import(
|
||||
`../../../src/cliproxy/auth/token-manager?gemini-delegation-manager=${moduleVersion}`
|
||||
);
|
||||
|
||||
const authDir = getProviderAuthDir('gemini');
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(authDir, 'gemini-delegated.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
type: 'gemini',
|
||||
email: 'delegated@example.com',
|
||||
project_id: 'delegated-project',
|
||||
token: {
|
||||
access_token: 'expired-access-token',
|
||||
refresh_token: 'still-present-refresh-token',
|
||||
expiry: Date.now() - 60_000,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const result = await ensureTokenValid('gemini');
|
||||
|
||||
expect(result).toEqual({
|
||||
valid: true,
|
||||
refreshed: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -118,6 +118,7 @@ describe('provider-capabilities', () => {
|
||||
expect(getOAuthCallbackPort('cursor')).toBeNull();
|
||||
expect(getOAuthCallbackPort('gitlab')).toBe(17171);
|
||||
expect(getOAuthCallbackPort('gemini')).toBe(8085);
|
||||
expect(PROVIDER_CAPABILITIES.gemini.refreshOwnership).toBe('cliproxy');
|
||||
expect(getProviderDisplayName('agy')).toBe('Antigravity');
|
||||
expect(getProviderDisplayName('kilo')).toBe('Kilo AI');
|
||||
});
|
||||
|
||||
@@ -13,18 +13,16 @@ import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks';
|
||||
describe('Gemini CLI Quota Fetcher', () => {
|
||||
const GEMINI_QUOTA_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota';
|
||||
const GEMINI_CODE_ASSIST_URL = 'https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist';
|
||||
const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token';
|
||||
const MANAGEMENT_AUTH_FILES_URL = 'http://127.0.0.1:8317/v0/management/auth-files';
|
||||
const MANAGEMENT_API_CALL_URL = 'http://127.0.0.1:8317/v0/management/api-call';
|
||||
let tempHome: string;
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalCcsDir: string | undefined;
|
||||
let originalGeminiClientId: string | undefined;
|
||||
let originalGeminiClientSecret: string | undefined;
|
||||
let moduleVersion = 0;
|
||||
let buildGeminiCliBuckets: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').buildGeminiCliBuckets;
|
||||
let fetchGeminiCliQuota: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').fetchGeminiCliQuota;
|
||||
let resolveGeminiCliProjectId: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').resolveGeminiCliProjectId;
|
||||
let geminiTestExports: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').__testExports;
|
||||
let refreshGeminiToken: typeof import('../../../src/cliproxy/auth/gemini-token-refresh').refreshGeminiToken;
|
||||
let getProviderAuthDir: typeof import('../../../src/cliproxy/config-generator').getProviderAuthDir;
|
||||
|
||||
function writeGeminiToken(token: Record<string, unknown>, filename = 'gemini-test.json'): string {
|
||||
@@ -47,9 +45,6 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expiry: Date.now() + 60 * 60 * 1000,
|
||||
client_id: 'test-client-id',
|
||||
client_secret: 'test-client-secret',
|
||||
token_uri: GOOGLE_TOKEN_URL,
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
@@ -60,12 +55,7 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-gemini-refresh-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalCcsDir = process.env.CCS_DIR;
|
||||
originalGeminiClientId = process.env.CCS_GEMINI_OAUTH_CLIENT_ID;
|
||||
originalGeminiClientSecret = process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
|
||||
delete process.env.CCS_GEMINI_OAUTH_CLIENT_ID;
|
||||
delete process.env.CCS_GEMINI_OAUTH_CLIENT_SECRET;
|
||||
delete process.env.CCS_DIR;
|
||||
|
||||
const configGenerator = await import(
|
||||
@@ -79,9 +69,6 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
} = await import(
|
||||
`../../../src/cliproxy/quota-fetcher-gemini-cli?gemini-quota-fetcher=${moduleVersion}`
|
||||
));
|
||||
({ refreshGeminiToken } = await import(
|
||||
`../../../src/cliproxy/auth/gemini-token-refresh?gemini-refresh=${moduleVersion}`
|
||||
));
|
||||
({ getProviderAuthDir } = configGenerator);
|
||||
});
|
||||
|
||||
@@ -100,18 +87,6 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
} else {
|
||||
process.env.CCS_DIR = originalCcsDir;
|
||||
}
|
||||
|
||||
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', () => {
|
||||
@@ -520,14 +495,6 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
url: GOOGLE_TOKEN_URL,
|
||||
method: 'POST',
|
||||
status: 400,
|
||||
response: {
|
||||
error: 'invalid_grant',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await fetchGeminiCliQuota('reauth@example.com');
|
||||
@@ -667,7 +634,7 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
expect(result.errorDetail).toBe('[HTML error response omitted]');
|
||||
});
|
||||
|
||||
it('refreshes the requested Gemini account instead of the default account', async () => {
|
||||
it('uses the requested Gemini account when delegating auth recovery to CLIProxy management', async () => {
|
||||
writeGeminiToken(
|
||||
{
|
||||
type: 'gemini',
|
||||
@@ -677,9 +644,6 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
access_token: 'default-access-token',
|
||||
refresh_token: 'default-refresh-token',
|
||||
expiry: Date.now() + 60 * 60 * 1000,
|
||||
client_id: 'default-client-id',
|
||||
client_secret: 'default-client-secret',
|
||||
token_uri: GOOGLE_TOKEN_URL,
|
||||
},
|
||||
},
|
||||
'gemini-default.json'
|
||||
@@ -694,9 +658,6 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
access_token: 'target-stale-token',
|
||||
refresh_token: 'target-refresh-token',
|
||||
expiry: Date.now() - 1000,
|
||||
client_id: 'target-client-id',
|
||||
client_secret: 'target-client-secret',
|
||||
token_uri: GOOGLE_TOKEN_URL,
|
||||
},
|
||||
},
|
||||
'gemini-target.json'
|
||||
@@ -704,16 +665,37 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
|
||||
mockFetch([
|
||||
{
|
||||
url: GOOGLE_TOKEN_URL,
|
||||
url: MANAGEMENT_AUTH_FILES_URL,
|
||||
response: {
|
||||
files: [
|
||||
{
|
||||
auth_index: 'target-auth-index',
|
||||
provider: 'gemini',
|
||||
email: 'target@example.com',
|
||||
name: 'target@example.com-gen-lang-client-target-project.json',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
url: MANAGEMENT_API_CALL_URL,
|
||||
method: 'POST',
|
||||
response: { access_token: 'target-fresh-token', expires_in: 1800 },
|
||||
response: {
|
||||
status_code: 200,
|
||||
body: JSON.stringify({
|
||||
buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.88 }],
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
url: GEMINI_QUOTA_URL,
|
||||
method: 'POST',
|
||||
status: 200,
|
||||
status: 401,
|
||||
response: {
|
||||
buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.88 }],
|
||||
error: {
|
||||
message: 'Session expired',
|
||||
status: 'UNAUTHENTICATED',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -728,14 +710,14 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const [refreshRequest, quotaRequest] = getCapturedFetchRequests();
|
||||
expect(refreshRequest.url).toBe(GOOGLE_TOKEN_URL);
|
||||
expect(refreshRequest.body).toContain('refresh_token=target-refresh-token');
|
||||
expect(refreshRequest.body).not.toContain('default-refresh-token');
|
||||
expect(quotaRequest.headers.Authorization).toBe('Bearer target-fresh-token');
|
||||
const [, managedLookupRequest, managedQuotaRequest] = getCapturedFetchRequests();
|
||||
expect(managedLookupRequest.url).toBe(MANAGEMENT_AUTH_FILES_URL);
|
||||
expect(managedQuotaRequest.url).toBe(MANAGEMENT_API_CALL_URL);
|
||||
expect(managedQuotaRequest.body).toContain('"auth_index":"target-auth-index"');
|
||||
expect(managedQuotaRequest.body).not.toContain('default-refresh-token');
|
||||
});
|
||||
|
||||
it('retries a 401 quota failure after a transient proactive refresh failure', async () => {
|
||||
it('retries a 401 quota failure through the management API instead of refreshing locally', async () => {
|
||||
writeGeminiToken(
|
||||
{
|
||||
type: 'gemini',
|
||||
@@ -745,20 +727,12 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
access_token: 'retry-stale-token',
|
||||
refresh_token: 'retry-refresh-token',
|
||||
expiry: Date.now() + 60 * 1000,
|
||||
client_id: 'retry-client-id',
|
||||
client_secret: 'retry-client-secret',
|
||||
token_uri: GOOGLE_TOKEN_URL,
|
||||
},
|
||||
},
|
||||
'gemini-retry.json'
|
||||
);
|
||||
|
||||
mockFetch([
|
||||
{
|
||||
url: GOOGLE_TOKEN_URL,
|
||||
method: 'POST',
|
||||
response: { access_token: 'unused-default', expires_in: 1800 },
|
||||
},
|
||||
{
|
||||
url: GEMINI_QUOTA_URL,
|
||||
method: 'POST',
|
||||
@@ -776,49 +750,63 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
]);
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
let refreshAttempt = 0;
|
||||
let quotaAttempt = 0;
|
||||
let managedLookupAttempt = 0;
|
||||
let managedRequestAttempt = 0;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url =
|
||||
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
|
||||
if (url === GOOGLE_TOKEN_URL) {
|
||||
refreshAttempt += 1;
|
||||
return refreshAttempt === 1
|
||||
? new Response(JSON.stringify({ error: 'temporarily_unavailable' }), {
|
||||
status: 503,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
: new Response(JSON.stringify({ access_token: 'retry-fresh-token', expires_in: 1800 }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
if (url === GEMINI_QUOTA_URL) {
|
||||
quotaAttempt += 1;
|
||||
return quotaAttempt === 1
|
||||
? new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: 'Session expired',
|
||||
status: 'UNAUTHENTICATED',
|
||||
},
|
||||
}),
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: 'Session expired',
|
||||
status: 'UNAUTHENTICATED',
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (url === MANAGEMENT_AUTH_FILES_URL) {
|
||||
managedLookupAttempt += 1;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
files: [
|
||||
{
|
||||
status: 401,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
: new Response(
|
||||
JSON.stringify({
|
||||
buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.9 }],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
auth_index: 'retry-auth-index',
|
||||
provider: 'gemini',
|
||||
email: 'retry@example.com',
|
||||
name: 'retry@example.com-gen-lang-client-retry-project.json',
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (url === MANAGEMENT_API_CALL_URL) {
|
||||
managedRequestAttempt += 1;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
status_code: 200,
|
||||
body: JSON.stringify({
|
||||
buckets: [{ model_id: 'gemini-3-flash-preview', remaining_fraction: 0.9 }],
|
||||
}),
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return originalFetch(input, init);
|
||||
@@ -828,13 +816,9 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
const result = await fetchGeminiCliQuota('retry@example.com');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(refreshAttempt).toBe(2);
|
||||
expect(quotaAttempt).toBe(2);
|
||||
|
||||
const storedToken = JSON.parse(
|
||||
fs.readFileSync(path.join(getProviderAuthDir('gemini'), 'gemini-retry.json'), 'utf8')
|
||||
) as { token?: { access_token?: string } };
|
||||
expect(storedToken.token?.access_token).toBe('retry-fresh-token');
|
||||
expect(quotaAttempt).toBe(1);
|
||||
expect(managedLookupAttempt).toBe(1);
|
||||
expect(managedRequestAttempt).toBe(1);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
@@ -934,84 +918,4 @@ describe('Gemini CLI Quota Fetcher', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user