mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 00:17:47 +00:00
fix(cliproxy): align delegated gemini auth recovery
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user