fix(cliproxy): align delegated gemini auth recovery

This commit is contained in:
Tam Nhu Tran
2026-04-16 14:15:03 -04:00
parent 4845b797de
commit 6b53df0147
2 changed files with 123 additions and 33 deletions
+111 -29
View File
@@ -14,6 +14,7 @@ import {
buildGeminiCliBucketsFromParsedBuckets, buildGeminiCliBucketsFromParsedBuckets,
type GeminiCliParsedBucket, type GeminiCliParsedBucket,
} from './gemini-cli-quota-normalizer'; } from './gemini-cli-quota-normalizer';
import { mapExternalProviderName } from './provider-capabilities';
import { buildManagementHeaders, buildProxyUrl, getProxyTarget } from './proxy-target-resolver'; import { buildManagementHeaders, buildProxyUrl, getProxyTarget } from './proxy-target-resolver';
import type { GeminiCliQuotaResult, GeminiCliBucket } from './quota-types'; import type { GeminiCliQuotaResult, GeminiCliBucket } from './quota-types';
import { import {
@@ -33,6 +34,7 @@ const GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH = 320;
const GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX = '...[truncated]'; const GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX = '...[truncated]';
const GEMINI_CLI_G1_CREDIT_TYPE = 'GOOGLE_ONE_AI'; const GEMINI_CLI_G1_CREDIT_TYPE = 'GOOGLE_ONE_AI';
const MANAGEMENT_API_TIMEOUT_MS = 5000; const MANAGEMENT_API_TIMEOUT_MS = 5000;
const SECONDARY_REQUEST_TIMEOUT_MS = 2000;
/** Auth data extracted from Gemini CLI auth file */ /** Auth data extracted from Gemini CLI auth file */
interface GeminiCliAuthData { interface GeminiCliAuthData {
@@ -114,6 +116,20 @@ interface ManagedResponse {
viaManagement: boolean; viaManagement: boolean;
} }
interface ManagedGeminiAuthContext {
authIndexLookupPromise?: Promise<ManagedGeminiAuthLookupResult>;
}
interface ManagedGeminiAuthLookupResult {
authIndex: string | number | null;
unavailable: boolean;
}
interface ManagedGeminiRequestResult {
response: ManagedResponse | null;
unavailable: boolean;
}
function getRemainingTimeoutMs(deadlineMs: number): number { function getRemainingTimeoutMs(deadlineMs: number): number {
return Math.max(1, deadlineMs - Date.now()); return Math.max(1, deadlineMs - Date.now());
} }
@@ -214,8 +230,8 @@ async function readManagedResponse(
} }
function isGeminiAuthFileForAccount(file: ManagementAuthFile, accountId: string): boolean { function isGeminiAuthFileForAccount(file: ManagementAuthFile, accountId: string): boolean {
const provider = normalizeStringValue(file.provider ?? file.type); const rawProvider = normalizeStringValue(file.provider ?? file.type);
if (provider !== 'gemini') { if (!rawProvider || mapExternalProviderName(rawProvider) !== 'gemini') {
return false; return false;
} }
@@ -242,7 +258,7 @@ function isGeminiAuthFileForAccount(file: ManagementAuthFile, accountId: string)
async function findManagedGeminiAuthIndex( async function findManagedGeminiAuthIndex(
accountId: string, accountId: string,
timeoutMs: number timeoutMs: number
): Promise<string | number | null> { ): Promise<ManagedGeminiAuthLookupResult> {
const target = getProxyTarget(); const target = getProxyTarget();
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs); const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
@@ -255,15 +271,35 @@ async function findManagedGeminiAuthIndex(
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (!response.ok) { if (!response.ok) {
return null; return { authIndex: null, unavailable: true };
} }
const data = (await response.json()) as { files?: ManagementAuthFile[] }; const data = (await response.json()) as { files?: ManagementAuthFile[] };
const match = data.files?.find((file) => isGeminiAuthFileForAccount(file, accountId)); const match = data.files?.find((file) => isGeminiAuthFileForAccount(file, accountId));
return match?.auth_index ?? null; return { authIndex: match?.auth_index ?? null, unavailable: false };
} catch { } catch {
clearTimeout(timeoutId); clearTimeout(timeoutId);
return null; return { authIndex: null, unavailable: true };
}
}
async function getManagedGeminiAuthIndex(
accountId: string,
timeoutMs: number,
context?: ManagedGeminiAuthContext
): Promise<ManagedGeminiAuthLookupResult> {
if (!context) {
return await findManagedGeminiAuthIndex(accountId, timeoutMs);
}
context.authIndexLookupPromise ??= findManagedGeminiAuthIndex(accountId, timeoutMs);
return await context.authIndexLookupPromise;
}
class GeminiManagedAuthUnavailableError extends Error {
constructor() {
super('CLIProxy managed Gemini auth is temporarily unavailable');
this.name = 'GeminiManagedAuthUnavailableError';
} }
} }
@@ -271,16 +307,27 @@ async function performManagedGeminiRequest(
accountId: string, accountId: string,
url: string, url: string,
body: string, body: string,
timeoutMs: number timeoutMs: number,
): Promise<ManagedResponse | null> { authContext?: ManagedGeminiAuthContext
const authIndex = await findManagedGeminiAuthIndex(accountId, timeoutMs); ): Promise<ManagedGeminiRequestResult> {
const deadlineMs = Date.now() + timeoutMs;
const lookupResult = await getManagedGeminiAuthIndex(
accountId,
getRemainingTimeoutMs(deadlineMs),
authContext
);
if (lookupResult.unavailable) {
return { response: null, unavailable: true };
}
const authIndex = lookupResult.authIndex;
if (authIndex === null || authIndex === undefined) { if (authIndex === null || authIndex === undefined) {
return null; return { response: null, unavailable: false };
} }
const target = getProxyTarget(); const target = getProxyTarget();
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs); const timeoutId = setTimeout(() => controller.abort(), getRemainingTimeoutMs(deadlineMs));
try { try {
const response = await fetch(buildProxyUrl(target, '/v0/management/api-call'), { const response = await fetch(buildProxyUrl(target, '/v0/management/api-call'), {
@@ -303,20 +350,23 @@ async function performManagedGeminiRequest(
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (!response.ok) { if (!response.ok) {
return null; return { response: null, unavailable: true };
} }
const apiResponse = (await response.json()) as ManagementApiCallResponse; const apiResponse = (await response.json()) as ManagementApiCallResponse;
const bodyText = typeof apiResponse.body === 'string' ? apiResponse.body : ''; const bodyText = typeof apiResponse.body === 'string' ? apiResponse.body : '';
return { return {
status: typeof apiResponse.status_code === 'number' ? apiResponse.status_code : 500, response: {
bodyText, status: typeof apiResponse.status_code === 'number' ? apiResponse.status_code : 500,
json: safeParseJson(bodyText), bodyText,
viaManagement: true, json: safeParseJson(bodyText),
viaManagement: true,
},
unavailable: false,
}; };
} catch { } catch {
clearTimeout(timeoutId); clearTimeout(timeoutId);
return null; return { response: null, unavailable: true };
} }
} }
@@ -325,10 +375,11 @@ async function performGeminiCliRequest(
accessToken: string, accessToken: string,
url: string, url: string,
body: string, body: string,
preferManagement = false preferManagement = false,
authContext?: ManagedGeminiAuthContext
): Promise<ManagedResponse> { ): Promise<ManagedResponse> {
const deadlineMs = Date.now() + MANAGEMENT_API_TIMEOUT_MS;
let managementAttempted = false; let managementAttempted = false;
let managementUnavailable = false;
if (preferManagement) { if (preferManagement) {
managementAttempted = true; managementAttempted = true;
@@ -336,15 +387,20 @@ async function performGeminiCliRequest(
accountId, accountId,
url, url,
body, body,
getRemainingTimeoutMs(deadlineMs) MANAGEMENT_API_TIMEOUT_MS,
authContext
); );
if (managedResult) { managementUnavailable = managedResult.unavailable;
return managedResult; if (managedResult.response) {
return managedResult.response;
} }
} }
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), getRemainingTimeoutMs(deadlineMs)); const timeoutId = setTimeout(
() => controller.abort(),
managementAttempted ? SECONDARY_REQUEST_TIMEOUT_MS : MANAGEMENT_API_TIMEOUT_MS
);
try { try {
const response = await fetch(url, { const response = await fetch(url, {
@@ -364,6 +420,9 @@ async function performGeminiCliRequest(
} }
if (managementAttempted) { if (managementAttempted) {
if (managementUnavailable) {
throw new GeminiManagedAuthUnavailableError();
}
return directResult; return directResult;
} }
@@ -371,9 +430,16 @@ async function performGeminiCliRequest(
accountId, accountId,
url, url,
body, body,
getRemainingTimeoutMs(deadlineMs) SECONDARY_REQUEST_TIMEOUT_MS,
authContext
); );
return managedResult ?? directResult; if (managedResult.response) {
return managedResult.response;
}
if (managedResult.unavailable) {
throw new GeminiManagedAuthUnavailableError();
}
return directResult;
} catch (error) { } catch (error) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
throw error; throw error;
@@ -524,7 +590,8 @@ async function fetchGeminiCliSupplementary(
accountId: string, accountId: string,
accessToken: string, accessToken: string,
projectId: string, projectId: string,
verbose: boolean verbose: boolean,
authContext?: ManagedGeminiAuthContext
): Promise<GeminiCliSupplementaryInfo> { ): Promise<GeminiCliSupplementaryInfo> {
const requestBody = JSON.stringify({ const requestBody = JSON.stringify({
cloudaicompanionProject: projectId, cloudaicompanionProject: projectId,
@@ -541,7 +608,9 @@ async function fetchGeminiCliSupplementary(
accountId, accountId,
accessToken, accessToken,
GEMINI_CLI_CODE_ASSIST_URL, GEMINI_CLI_CODE_ASSIST_URL,
requestBody requestBody,
false,
authContext
); );
if (response.status !== 200) { if (response.status !== 200) {
@@ -890,11 +959,13 @@ async function fetchWithAuthData(
}); });
} }
const authContext: ManagedGeminiAuthContext = {};
const supplementaryPromise = fetchGeminiCliSupplementary( const supplementaryPromise = fetchGeminiCliSupplementary(
accountId, accountId,
authData.accessToken, authData.accessToken,
authData.projectId, authData.projectId,
verbose verbose,
authContext
); );
const requestBody = JSON.stringify({ project: authData.projectId }); const requestBody = JSON.stringify({ project: authData.projectId });
@@ -904,7 +975,8 @@ async function fetchWithAuthData(
authData.accessToken, authData.accessToken,
GEMINI_CLI_QUOTA_URL, GEMINI_CLI_QUOTA_URL,
requestBody, requestBody,
authData.isExpired authData.isExpired,
authContext
); );
if (verbose) { if (verbose) {
@@ -952,6 +1024,16 @@ async function fetchWithAuthData(
accountId, accountId,
}; };
} catch (err) { } catch (err) {
if (err instanceof GeminiManagedAuthUnavailableError) {
return buildGeminiCliFailureResult(accountId, authData.projectId, {
error: 'Gemini delegated auth refresh is temporarily unavailable',
errorCode: 'managed_auth_unavailable',
errorDetail: err.message,
actionHint: 'Retry later. CLIProxy management could not refresh this Gemini account.',
retryable: true,
});
}
const errorMsg = const errorMsg =
err instanceof Error && err.name === 'AbortError' err instanceof Error && err.name === 'AbortError'
? 'Request timeout' ? 'Request timeout'
@@ -484,6 +484,12 @@ describe('Gemini CLI Quota Fetcher', () => {
writeActiveGeminiAccount('reauth@example.com'); writeActiveGeminiAccount('reauth@example.com');
mockFetch([ mockFetch([
{
url: MANAGEMENT_AUTH_FILES_URL,
response: {
files: [],
},
},
{ {
url: GEMINI_QUOTA_URL, url: GEMINI_QUOTA_URL,
method: 'POST', method: 'POST',
@@ -670,7 +676,7 @@ describe('Gemini CLI Quota Fetcher', () => {
files: [ files: [
{ {
auth_index: 'target-auth-index', auth_index: 'target-auth-index',
provider: 'gemini', provider: 'gemini-cli',
email: 'target@example.com', email: 'target@example.com',
name: 'target@example.com-gen-lang-client-target-project.json', name: 'target@example.com-gen-lang-client-target-project.json',
}, },
@@ -781,7 +787,7 @@ describe('Gemini CLI Quota Fetcher', () => {
files: [ files: [
{ {
auth_index: 'retry-auth-index', auth_index: 'retry-auth-index',
provider: 'gemini', provider: 'gemini-cli',
email: 'retry@example.com', email: 'retry@example.com',
name: 'retry@example.com-gen-lang-client-retry-project.json', name: 'retry@example.com-gen-lang-client-retry-project.json',
}, },
@@ -825,7 +831,7 @@ describe('Gemini CLI Quota Fetcher', () => {
} }
}); });
it('does not retry the management API twice when the preferred managed path already failed', async () => { it('reports a retryable management failure instead of reauth when delegated auth is unavailable', async () => {
writeGeminiToken( writeGeminiToken(
{ {
type: 'gemini', type: 'gemini',
@@ -890,7 +896,9 @@ describe('Gemini CLI Quota Fetcher', () => {
const result = await fetchGeminiCliQuota('managed-failure@example.com'); const result = await fetchGeminiCliQuota('managed-failure@example.com');
expect(result.success).toBe(false); expect(result.success).toBe(false);
expect(result.needsReauth).toBe(true); expect(result.needsReauth).toBeUndefined();
expect(result.retryable).toBe(true);
expect(result.errorCode).toBe('managed_auth_unavailable');
expect(directQuotaAttempt).toBe(1); expect(directQuotaAttempt).toBe(1);
expect(managedLookupAttempt).toBe(1); expect(managedLookupAttempt).toBe(1);
expect(managedRequestAttempt).toBe(0); expect(managedRequestAttempt).toBe(0);