Merge pull request #234 from cita-777/codex/gemini-oauth-hardening

Fix Gemini OAuth refresh and failure coverage
This commit is contained in:
Cita
2026-03-21 20:13:08 +08:00
committed by GitHub
5 changed files with 590 additions and 71 deletions
@@ -9,6 +9,7 @@ import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstream
import { tokenRouter } from '../../services/tokenRouter.js'; import { tokenRouter } from '../../services/tokenRouter.js';
import { buildOauthProviderHeaders } from '../../services/oauth/service.js'; import { buildOauthProviderHeaders } from '../../services/oauth/service.js';
import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js'; import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js';
import { refreshOauthAccessTokenSingleflight } from '../../services/oauth/refreshSingleflight.js';
import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js'; import { resolveChannelProxyUrl, withSiteRecordProxyRequestInit } from '../../services/siteProxy.js';
import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js';
import { getDownstreamRoutingPolicy } from '../../routes/proxy/downstreamPolicy.js'; import { getDownstreamRoutingPolicy } from '../../routes/proxy/downstreamPolicy.js';
@@ -406,7 +407,7 @@ export async function geminiProxyRoute(app: FastifyInstance) {
isGeminiCliDownstream ? omitGeminiCliModelField(request.body) : (request.body || {}), isGeminiCliDownstream ? omitGeminiCliModelField(request.body) : (request.body || {}),
actualModel, actualModel,
); );
const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig); let oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig);
const isGeminiCli = isGeminiCliPlatform(selected.site.platform); const isGeminiCli = isGeminiCliPlatform(selected.site.platform);
const isInternalGemini = isInternalGeminiPlatform(selected.site.platform); const isInternalGemini = isInternalGeminiPlatform(selected.site.platform);
const isDirectGeminiFamily = isDirectGeminiFamilyPlatform(selected.site.platform); const isDirectGeminiFamily = isDirectGeminiFamilyPlatform(selected.site.platform);
@@ -443,6 +444,8 @@ export async function geminiProxyRoute(app: FastifyInstance) {
? buildGeminiCliActionPath({ isStreamAction, isCountTokensAction }) ? buildGeminiCliActionPath({ isStreamAction, isCountTokensAction })
: resolveUpstreamPath(apiVersion, actualModelAction); : resolveUpstreamPath(apiVersion, actualModelAction);
const query = new URLSearchParams(request.query as Record<string, string>).toString(); const query = new URLSearchParams(request.query as Record<string, string>).toString();
const channelProxyUrl = resolveChannelProxyUrl(selected.site, selected.account.extraConfig);
const dispatchSelectedRequest = async () => {
const requestBody = isInternalGemini const requestBody = isInternalGemini
? ( ? (
isCountTokensAction isCountTokensAction
@@ -476,9 +479,9 @@ export async function geminiProxyRoute(app: FastifyInstance) {
selected.tokenValue, selected.tokenValue,
query, query,
); );
const channelProxyUrl = resolveChannelProxyUrl(selected.site, selected.account.extraConfig);
const upstream = isInternalGemini return isInternalGemini
? await dispatchRuntimeRequest({ ? dispatchRuntimeRequest({
siteUrl: selected.site.url, siteUrl: selected.site.url,
targetUrl, targetUrl,
request: { request: {
@@ -502,12 +505,31 @@ export async function geminiProxyRoute(app: FastifyInstance) {
body: JSON.stringify(requestForFetch.body), body: JSON.stringify(requestForFetch.body),
}, channelProxyUrl), }, channelProxyUrl),
}) })
: await fetch(targetUrl, { : fetch(targetUrl, {
method: 'POST', method: 'POST',
headers: requestHeaders, headers: requestHeaders,
body: JSON.stringify(requestBody), body: JSON.stringify(requestBody),
}); });
const contentType = upstream.headers.get('content-type') || 'application/json'; };
let upstream = await dispatchSelectedRequest();
let contentType = upstream.headers.get('content-type') || 'application/json';
if (upstream.status === 401 && oauth) {
try {
const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id);
selected.tokenValue = refreshed.accessToken;
selected.account = {
...selected.account,
accessToken: refreshed.accessToken,
extraConfig: refreshed.extraConfig ?? selected.account.extraConfig,
};
oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig);
upstream = await dispatchSelectedRequest();
contentType = upstream.headers.get('content-type') || 'application/json';
} catch {
// Preserve the original 401 response when refresh fails.
}
}
if (!upstream.ok) { if (!upstream.ok) {
lastStatus = upstream.status; lastStatus = upstream.status;
lastContentType = contentType; lastContentType = contentType;
+162
View File
@@ -789,6 +789,168 @@ describe('oauth routes', { timeout: 15_000 }, () => {
expect(String(fetchMock.mock.calls[7]?.[0] || '')).toContain('/projects/gen-lang-client-0123456789/services/cloudaicompanion.googleapis.com'); expect(String(fetchMock.mock.calls[7]?.[0] || '')).toContain('/projects/gen-lang-client-0123456789/services/cloudaicompanion.googleapis.com');
}); });
it('surfaces Cloud AI API enable failures during Gemini CLI oauth setup and keeps the account rolled back', async () => {
fetchMock
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
access_token: 'gemini-access-token',
refresh_token: 'gemini-refresh-token',
expires_in: 3600,
token_type: 'Bearer',
scope: 'cloud-platform',
}),
text: async () => JSON.stringify({ ok: true }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
projects: [
{ projectId: 'project-enable-failure' },
],
}),
text: async () => JSON.stringify({ ok: true }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
cloudaicompanionProject: {
id: 'project-enable-failure',
},
allowedTiers: [
{ id: 'legacy-tier', isDefault: true },
],
}),
text: async () => JSON.stringify({ ok: true }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
done: true,
response: {
cloudaicompanionProject: {
id: 'project-enable-failure',
},
},
}),
text: async () => JSON.stringify({ done: true }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ state: 'DISABLED' }),
text: async () => JSON.stringify({ state: 'DISABLED' }),
})
.mockResolvedValueOnce({
ok: false,
status: 403,
json: async () => ({ error: { message: 'Permission denied for project-enable-failure' } }),
text: async () => JSON.stringify({ error: { message: 'Permission denied for project-enable-failure' } }),
});
const startResponse = await app.inject({
method: 'POST',
url: '/api/oauth/providers/gemini-cli/start',
headers: {
host: 'metapi.example',
'x-forwarded-proto': 'https',
},
});
expect(startResponse.statusCode).toBe(200);
const startBody = startResponse.json() as { state: string };
const callbackResponse = await app.inject({
method: 'POST',
url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`,
payload: {
callbackUrl: `http://localhost:8085/oauth2callback?state=${encodeURIComponent(startBody.state)}&code=gemini-oauth-code-enable-failure`,
},
});
expect(callbackResponse.statusCode).toBe(500);
expect(callbackResponse.json()).toMatchObject({
message: expect.stringContaining('project activation required'),
});
const sessionResponse = await app.inject({
method: 'GET',
url: `/api/oauth/sessions/${startBody.state}`,
});
expect(sessionResponse.statusCode).toBe(200);
expect(sessionResponse.json()).toMatchObject({
provider: 'gemini-cli',
status: 'error',
error: expect.stringContaining('Permission denied for project-enable-failure'),
});
const accounts = await db.select().from(schema.accounts).all();
expect(accounts).toEqual([]);
});
it('fails Gemini CLI oauth setup when the Google account has no available Cloud projects', async () => {
fetchMock
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
access_token: 'gemini-access-token',
refresh_token: 'gemini-refresh-token',
expires_in: 3600,
token_type: 'Bearer',
scope: 'cloud-platform',
}),
text: async () => JSON.stringify({ ok: true }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
projects: [],
}),
text: async () => JSON.stringify({ ok: true }),
});
const startResponse = await app.inject({
method: 'POST',
url: '/api/oauth/providers/gemini-cli/start',
headers: {
host: 'metapi.example',
'x-forwarded-proto': 'https',
},
});
expect(startResponse.statusCode).toBe(200);
const startBody = startResponse.json() as { state: string };
const callbackResponse = await app.inject({
method: 'POST',
url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`,
payload: {
callbackUrl: `http://localhost:8085/oauth2callback?state=${encodeURIComponent(startBody.state)}&code=gemini-oauth-code-no-projects`,
},
});
expect(callbackResponse.statusCode).toBe(500);
expect(callbackResponse.json()).toMatchObject({
message: 'no Google Cloud projects available for this account',
});
const sessionResponse = await app.inject({
method: 'GET',
url: `/api/oauth/sessions/${startBody.state}`,
});
expect(sessionResponse.statusCode).toBe(200);
expect(sessionResponse.json()).toMatchObject({
provider: 'gemini-cli',
status: 'error',
error: 'no Google Cloud projects available for this account',
});
const accounts = await db.select().from(schema.accounts).all();
expect(accounts).toEqual([]);
});
it('lists oauth connection health metadata and supports deleting the connection', async () => { it('lists oauth connection health metadata and supports deleting the connection', async () => {
const site = await db.insert(schema.sites).values({ const site = await db.insert(schema.sites).values({
name: 'ChatGPT Codex OAuth', name: 'ChatGPT Codex OAuth',
+175
View File
@@ -7,6 +7,7 @@ import { resetUpstreamEndpointRuntimeState } from './upstreamEndpoint.js';
const fetchMock = vi.fn(); const fetchMock = vi.fn();
const fetchModelPricingCatalogMock = vi.fn(); const fetchModelPricingCatalogMock = vi.fn();
const refreshModelsAndRebuildRoutesMock = vi.fn(); const refreshModelsAndRebuildRoutesMock = vi.fn();
const refreshOauthAccessTokenSingleflightMock = vi.fn();
const selectChannelMock = vi.fn(); const selectChannelMock = vi.fn();
const selectNextChannelMock = vi.fn(); const selectNextChannelMock = vi.fn();
const recordSuccessMock = vi.fn(); const recordSuccessMock = vi.fn();
@@ -43,6 +44,7 @@ function createDbSelectChain() {
vi.mock('undici', () => ({ vi.mock('undici', () => ({
fetch: (...args: unknown[]) => fetchMock(...args), fetch: (...args: unknown[]) => fetchMock(...args),
Response: globalThis.Response,
})); }));
vi.mock('../../services/modelPricingService.js', () => ({ vi.mock('../../services/modelPricingService.js', () => ({
@@ -53,6 +55,10 @@ vi.mock('../../services/modelService.js', () => ({
refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args), refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args),
})); }));
vi.mock('../../services/oauth/refreshSingleflight.js', () => ({
refreshOauthAccessTokenSingleflight: (...args: unknown[]) => refreshOauthAccessTokenSingleflightMock(...args),
}));
vi.mock('../../services/tokenRouter.js', () => ({ vi.mock('../../services/tokenRouter.js', () => ({
tokenRouter: { tokenRouter: {
selectChannel: (...args: unknown[]) => selectChannelMock(...args), selectChannel: (...args: unknown[]) => selectChannelMock(...args),
@@ -154,6 +160,7 @@ describe('gemini native proxy routes', () => {
fetchMock.mockReset(); fetchMock.mockReset();
fetchModelPricingCatalogMock.mockReset(); fetchModelPricingCatalogMock.mockReset();
refreshModelsAndRebuildRoutesMock.mockReset(); refreshModelsAndRebuildRoutesMock.mockReset();
refreshOauthAccessTokenSingleflightMock.mockReset();
selectChannelMock.mockReset(); selectChannelMock.mockReset();
selectNextChannelMock.mockReset(); selectNextChannelMock.mockReset();
recordSuccessMock.mockReset(); recordSuccessMock.mockReset();
@@ -514,6 +521,174 @@ describe('gemini native proxy routes', () => {
}); });
}); });
it('refreshes gemini-cli oauth token and retries the same internal request on 401', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 31, routeId: 22 },
site: { id: 66, name: 'gemini-cli-site', url: 'https://cloudcode-pa.googleapis.com', platform: 'gemini-cli' },
account: {
id: 36,
username: 'gemini-cli-user@example.com',
accessToken: 'oauth-access-token',
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'gemini-cli',
email: 'gemini-cli-user@example.com',
projectId: 'project-before-refresh',
refreshToken: 'gemini-refresh-token',
},
}),
},
tokenName: 'default',
tokenValue: 'oauth-access-token',
actualModel: 'gemini-2.5-pro',
});
refreshOauthAccessTokenSingleflightMock.mockResolvedValue({
accountId: 36,
accessToken: 'refreshed-access-token',
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'gemini-cli',
email: 'gemini-cli-user@example.com',
projectId: 'project-after-refresh',
refreshToken: 'gemini-refresh-token',
},
}),
});
fetchMock
.mockResolvedValueOnce(new Response(JSON.stringify({
error: { message: 'token expired' },
}), {
status: 401,
headers: { 'content-type': 'application/json' },
}))
.mockResolvedValueOnce(new Response(JSON.stringify({
response: {
candidates: [
{
content: {
parts: [{ text: 'hello after refresh' }],
role: 'model',
},
finishReason: 'STOP',
},
],
},
}), {
status: 200,
headers: { 'content-type': 'application/json' },
}));
const response = await app.inject({
method: 'POST',
url: '/v1beta/models/gemini-2.5-pro:generateContent',
headers: {
authorization: 'Bearer sk-managed-gemini',
},
payload: {
contents: [
{
role: 'user',
parts: [{ text: 'hello' }],
},
],
},
});
expect(response.statusCode).toBe(200);
expect(refreshOauthAccessTokenSingleflightMock).toHaveBeenCalledWith(36);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(selectNextChannelMock).not.toHaveBeenCalled();
const [, firstRequestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
const [, secondRequestInit] = fetchMock.mock.calls[1] as [string, RequestInit];
expect(firstRequestInit.headers).toMatchObject({
Authorization: 'Bearer oauth-access-token',
});
expect(secondRequestInit.headers).toMatchObject({
Authorization: 'Bearer refreshed-access-token',
});
expect(JSON.parse(String(secondRequestInit.body))).toEqual({
project: 'project-after-refresh',
model: 'gemini-2.5-pro',
request: {
contents: [
{
role: 'user',
parts: [{ text: 'hello' }],
},
],
},
});
expect(response.json()).toEqual({
responseId: '',
modelVersion: '',
candidates: [
{
index: 0,
content: {
parts: [{ text: 'hello after refresh' }],
role: 'model',
},
finishReason: 'STOP',
},
],
});
});
it('returns a server error when gemini-cli oauth project metadata is missing at runtime', async () => {
selectChannelMock.mockReturnValue({
channel: { id: 31, routeId: 22 },
site: { id: 66, name: 'gemini-cli-site', url: 'https://cloudcode-pa.googleapis.com', platform: 'gemini-cli' },
account: {
id: 36,
username: 'gemini-cli-user@example.com',
accessToken: 'oauth-access-token',
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'gemini-cli',
email: 'gemini-cli-user@example.com',
},
}),
},
tokenName: 'default',
tokenValue: 'oauth-access-token',
actualModel: 'gemini-2.5-pro',
});
selectNextChannelMock.mockReturnValue(null);
const response = await app.inject({
method: 'POST',
url: '/v1beta/models/gemini-2.5-pro:generateContent',
headers: {
authorization: 'Bearer sk-managed-gemini',
},
payload: {
contents: [
{
role: 'user',
parts: [{ text: 'hello' }],
},
],
},
});
expect(response.statusCode).toBe(500);
expect(fetchMock).not.toHaveBeenCalled();
expect(recordFailureMock).toHaveBeenCalledWith(31, {
status: 500,
errorText: 'Gemini CLI OAuth project is missing',
});
expect(JSON.parse(response.body)).toEqual({
error: {
message: 'Gemini CLI OAuth project is missing',
type: 'server_error',
},
});
});
it('routes Gemini native generateContent requests to openai upstreams and serializes the response back to Gemini shape', async () => { it('routes Gemini native generateContent requests to openai upstreams and serializes the response back to Gemini shape', async () => {
selectChannelMock.mockReturnValue({ selectChannelMock.mockReturnValue({
channel: { id: 41, routeId: 22 }, channel: { id: 41, routeId: 22 },
@@ -8,6 +8,7 @@ const getApiTokenMock = vi.fn();
const getModelsMock = vi.fn(); const getModelsMock = vi.fn();
const undiciFetchMock = vi.fn(); const undiciFetchMock = vi.fn();
const proxyAgentCtorMock = vi.fn(); const proxyAgentCtorMock = vi.fn();
const refreshOauthAccessTokenSingleflightMock = vi.fn();
class MockProxyAgent { class MockProxyAgent {
readonly proxyUrl: string; readonly proxyUrl: string;
@@ -33,6 +34,10 @@ vi.mock('undici', () => ({
Agent: MockAgent, Agent: MockAgent,
})); }));
vi.mock('./oauth/refreshSingleflight.js', () => ({
refreshOauthAccessTokenSingleflight: (...args: unknown[]) => refreshOauthAccessTokenSingleflightMock(...args),
}));
type DbModule = typeof import('../db/index.js'); type DbModule = typeof import('../db/index.js');
type ModelServiceModule = typeof import('./modelService.js'); type ModelServiceModule = typeof import('./modelService.js');
@@ -62,6 +67,7 @@ describe('refreshModelsForAccount credential discovery', () => {
getModelsMock.mockReset(); getModelsMock.mockReset();
undiciFetchMock.mockReset(); undiciFetchMock.mockReset();
proxyAgentCtorMock.mockReset(); proxyAgentCtorMock.mockReset();
refreshOauthAccessTokenSingleflightMock.mockReset();
await db.delete(schema.routeChannels).run(); await db.delete(schema.routeChannels).run();
await db.delete(schema.tokenRoutes).run(); await db.delete(schema.tokenRoutes).run();
@@ -791,6 +797,130 @@ describe('refreshModelsForAccount credential discovery', () => {
}); });
}); });
it('refreshes gemini oauth access token during validation through singleflight and reuses the refreshed account state', async () => {
getApiTokenMock.mockResolvedValue(null);
getModelsMock.mockRejectedValue(new Error('gemini oauth validation should not call adapter.getModels'));
refreshOauthAccessTokenSingleflightMock.mockImplementation(async (accountId: number) => {
const refreshedExtraConfig = JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'gemini-cli',
email: 'gemini-refreshed-user@example.com',
accountId: 'gemini-refreshed-user@example.com',
accountKey: 'gemini-refreshed-user@example.com',
projectId: 'project-refresh-demo',
refreshToken: 'gemini-refresh-token-next',
},
});
await db.update(schema.accounts).set({
accessToken: 'gemini-access-token-refreshed',
oauthProvider: 'gemini-cli',
oauthAccountKey: 'gemini-refreshed-user@example.com',
oauthProjectId: 'project-refresh-demo',
extraConfig: refreshedExtraConfig,
status: 'active',
updatedAt: '2026-03-21T00:00:00.000Z',
}).where(eq(schema.accounts.id, accountId)).run();
return {
accountId,
accessToken: 'gemini-access-token-refreshed',
accountKey: 'gemini-refreshed-user@example.com',
extraConfig: refreshedExtraConfig,
};
});
undiciFetchMock
.mockResolvedValueOnce({
ok: false,
status: 401,
json: async () => ({ error: 'expired' }),
text: async () => 'expired',
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({ state: 'ENABLED' }),
text: async () => JSON.stringify({ state: 'ENABLED' }),
});
const site = await db.insert(schema.sites).values({
name: 'gemini-refresh-site',
url: 'https://gemini.example.com',
platform: 'gemini',
status: 'active',
}).returning().get();
const account = await db.insert(schema.accounts).values({
siteId: site.id,
username: 'gemini-user@example.com',
accessToken: 'gemini-access-token-expired',
apiToken: null,
status: 'active',
oauthProvider: 'gemini-cli',
oauthAccountKey: 'gemini-user@example.com',
oauthProjectId: 'project-refresh-demo',
extraConfig: JSON.stringify({
credentialMode: 'session',
oauth: {
provider: 'gemini-cli',
email: 'gemini-user@example.com',
accountId: 'gemini-user@example.com',
accountKey: 'gemini-user@example.com',
projectId: 'project-refresh-demo',
refreshToken: 'gemini-refresh-token',
},
}),
}).returning().get();
const result = await refreshModelsForAccount(account.id);
expect(result).toMatchObject({
accountId: account.id,
refreshed: true,
status: 'success',
modelCount: expect.any(Number),
discoveredByCredential: true,
});
expect(refreshOauthAccessTokenSingleflightMock).toHaveBeenCalledWith(account.id);
expect(undiciFetchMock).toHaveBeenCalledTimes(2);
expect(String(undiciFetchMock.mock.calls[0]?.[0] || '')).toContain('/projects/project-refresh-demo/services/');
expect(undiciFetchMock.mock.calls[0]?.[1]).toMatchObject({
method: 'GET',
headers: expect.objectContaining({
Authorization: 'Bearer gemini-access-token-expired',
}),
});
expect(
undiciFetchMock.mock.calls.some(([url]) => String(url || '').includes('oauth2.googleapis.com/token')),
).toBe(false);
expect(String(undiciFetchMock.mock.calls[1]?.[0] || '')).toContain('/projects/project-refresh-demo/services/');
expect(undiciFetchMock.mock.calls[1]?.[1]).toMatchObject({
method: 'GET',
headers: expect.objectContaining({
Authorization: 'Bearer gemini-access-token-refreshed',
}),
});
const latest = await db.select().from(schema.accounts)
.where(eq(schema.accounts.id, account.id))
.get();
expect(latest).toMatchObject({
accessToken: 'gemini-access-token-refreshed',
oauthProvider: 'gemini-cli',
oauthAccountKey: 'gemini-refreshed-user@example.com',
oauthProjectId: 'project-refresh-demo',
});
const parsed = JSON.parse(latest!.extraConfig || '{}');
expect(parsed.oauth).toMatchObject({
provider: 'gemini-cli',
email: 'gemini-refreshed-user@example.com',
refreshToken: 'gemini-refresh-token-next',
projectId: 'project-refresh-demo',
modelDiscoveryStatus: 'healthy',
});
});
it('discovers antigravity oauth models via fetchAvailableModels fallback using the oauth project id', async () => { it('discovers antigravity oauth models via fetchAvailableModels fallback using the oauth project id', async () => {
getApiTokenMock.mockResolvedValue(null); getApiTokenMock.mockResolvedValue(null);
getModelsMock.mockRejectedValue(new Error('antigravity oauth discovery should not call adapter.getModels')); getModelsMock.mockRejectedValue(new Error('antigravity oauth discovery should not call adapter.getModels'));
+33 -3
View File
@@ -24,6 +24,7 @@ import { withAccountProxyOverride, withExplicitProxyRequestInit, withSiteRecordP
import { getCodexOauthInfoFromExtraConfig, isCodexPlatform } from './oauth/codexAccount.js'; import { getCodexOauthInfoFromExtraConfig, isCodexPlatform } from './oauth/codexAccount.js';
import { buildOauthInfo, getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js'; import { buildOauthInfo, getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js';
import { CLAUDE_DEFAULT_ANTHROPIC_VERSION } from './oauth/claudeProvider.js'; import { CLAUDE_DEFAULT_ANTHROPIC_VERSION } from './oauth/claudeProvider.js';
import { refreshOauthAccessTokenSingleflight } from './oauth/refreshSingleflight.js';
import { import {
ANTIGRAVITY_DAILY_UPSTREAM_BASE_URL, ANTIGRAVITY_DAILY_UPSTREAM_BASE_URL,
ANTIGRAVITY_MODELS_USER_AGENT, ANTIGRAVITY_MODELS_USER_AGENT,
@@ -485,6 +486,13 @@ function buildSuccessfulRefreshResult(input: {
}; };
} }
function shouldRetryModelDiscoveryWithOauthRefresh(error: unknown): boolean {
const message = ((error as { message?: string })?.message || '').toLowerCase();
return message.includes('http 401')
|| message.includes('unauthorized')
|| message.includes('unauthenticated');
}
export async function refreshModelsForAccount(accountId: number): Promise<ModelRefreshResult> { export async function refreshModelsForAccount(accountId: number): Promise<ModelRefreshResult> {
const row = await db.select().from(schema.accounts) const row = await db.select().from(schema.accounts)
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
@@ -668,13 +676,35 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
if (oauth?.provider === 'gemini-cli') { if (oauth?.provider === 'gemini-cli') {
const checkedAt = new Date().toISOString(); const checkedAt = new Date().toISOString();
const startedAt = Date.now(); const startedAt = Date.now();
let discoveryAccount = account;
try {
try { try {
await withTimeout( await withTimeout(
() => withAccountProxyOverride(accountProxyUrl, () => withAccountProxyOverride(accountProxyUrl,
() => validateGeminiCliOauthConnection({ account })), () => validateGeminiCliOauthConnection({ account: discoveryAccount })),
MODEL_DISCOVERY_TIMEOUT_MS, MODEL_DISCOVERY_TIMEOUT_MS,
`gemini cli oauth validation timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, `gemini cli oauth validation timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
); );
} catch (error) {
if (!shouldRetryModelDiscoveryWithOauthRefresh(error)) {
throw error;
}
const refreshed = await refreshOauthAccessTokenSingleflight(discoveryAccount.id);
if (!refreshed?.extraConfig) {
throw error;
}
discoveryAccount = {
...discoveryAccount,
accessToken: refreshed.accessToken,
extraConfig: refreshed.extraConfig,
};
await withTimeout(
() => withAccountProxyOverride(accountProxyUrl,
() => validateGeminiCliOauthConnection({ account: discoveryAccount })),
MODEL_DISCOVERY_TIMEOUT_MS,
`gemini cli oauth validation timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`,
);
}
await db.insert(schema.modelAvailability).values( await db.insert(schema.modelAvailability).values(
GEMINI_CLI_STATIC_MODELS.map((modelName) => ({ GEMINI_CLI_STATIC_MODELS.map((modelName) => ({
accountId, accountId,
@@ -685,7 +715,7 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
})), })),
).run(); ).run();
await updateOauthModelDiscoveryState({ await updateOauthModelDiscoveryState({
account, account: discoveryAccount,
checkedAt, checkedAt,
status: 'healthy', status: 'healthy',
lastDiscoveredModels: GEMINI_CLI_STATIC_MODELS, lastDiscoveredModels: GEMINI_CLI_STATIC_MODELS,
@@ -709,7 +739,7 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
const errorCode = classifyModelDiscoveryError(rawMessage); const errorCode = classifyModelDiscoveryError(rawMessage);
const errorMessage = `Gemini CLI 模型获取失败(${rawMessage})`; const errorMessage = `Gemini CLI 模型获取失败(${rawMessage})`;
await updateOauthModelDiscoveryState({ await updateOauthModelDiscoveryState({
account, account: discoveryAccount,
checkedAt, checkedAt,
status: 'abnormal', status: 'abnormal',
lastModelSyncError: errorMessage, lastModelSyncError: errorMessage,