From f565d1b54dedf5d511ad6b773619b5577f616c4a Mon Sep 17 00:00:00 2001 From: ciat-777 Date: Tue, 17 Mar 2026 23:13:01 +0800 Subject: [PATCH] fix: improve oauth error and model discovery fallbacks --- src/server/routes/api/oauth.test.ts | 148 ++++++++++++++++ .../services/modelService.discovery.test.ts | 44 +++-- src/server/services/modelService.ts | 163 ++++++++++++++++++ .../services/oauth/antigravityProvider.ts | 2 + .../services/oauth/geminiCliProvider.ts | 12 +- src/server/services/oauth/service.ts | 74 ++++---- 6 files changed, 394 insertions(+), 49 deletions(-) diff --git a/src/server/routes/api/oauth.test.ts b/src/server/routes/api/oauth.test.ts index 157d25f..7f94b92 100644 --- a/src/server/routes/api/oauth.test.ts +++ b/src/server/routes/api/oauth.test.ts @@ -385,6 +385,154 @@ describe('oauth routes', { timeout: 15_000 }, () => { }); }); + it('marks gemini oauth session as error when token exchange fails before account persistence', async () => { + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ + error: 'invalid_grant', + error_description: 'Bad Request', + }), + text: async () => '{"error":"invalid_grant","error_description":"Bad Request"}', + }); + + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/gemini-cli/start', + payload: { + projectId: 'demo-project', + }, + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + 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=oauth-code-gemini-123`, + }, + }); + expect(callbackResponse.statusCode).toBe(500); + expect(callbackResponse.json()).toMatchObject({ + message: expect.stringContaining('invalid_grant'), + }); + + 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('invalid_grant'), + }); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toEqual([]); + }); + + it('defaults Gemini CLI oauth to the first available Google Cloud project when projectId is omitted', 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: 'first-project-id' }, + { projectId: 'second-project-id' }, + ], + }), + text: async () => JSON.stringify({ ok: true }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ state: 'ENABLED' }), + text: async () => JSON.stringify({ state: 'ENABLED' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ email: 'gemini-user@example.com' }), + text: async () => JSON.stringify({ email: 'gemini-user@example.com' }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ state: 'ENABLED' }), + text: async () => JSON.stringify({ state: 'ENABLED' }), + }); + + 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-123`, + }, + }); + expect(callbackResponse.statusCode).toBe(200); + expect(callbackResponse.json()).toEqual({ success: true }); + + 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: 'success', + }); + + const accounts = await db.select().from(schema.accounts).all(); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatchObject({ + oauthProvider: 'gemini-cli', + oauthProjectId: 'first-project-id', + username: 'gemini-user@example.com', + accessToken: 'gemini-access-token', + }); + + const parsed = JSON.parse(accounts[0]?.extraConfig || '{}'); + expect(parsed.oauth).toMatchObject({ + provider: 'gemini-cli', + email: 'gemini-user@example.com', + projectId: 'first-project-id', + refreshToken: 'gemini-refresh-token', + }); + + expect(String(fetchMock.mock.calls[1]?.[0] || '')).toContain('cloudresourcemanager.googleapis.com/v1/projects'); + expect(String(fetchMock.mock.calls[2]?.[0] || '')).toContain('/projects/first-project-id/services/cloudaicompanion.googleapis.com'); + expect(String(fetchMock.mock.calls[4]?.[0] || '')).toContain('/projects/first-project-id/services/cloudaicompanion.googleapis.com'); + }); + it('lists oauth connection health metadata and supports deleting the connection', async () => { const site = await db.insert(schema.sites).values({ name: 'ChatGPT Codex OAuth', diff --git a/src/server/services/modelService.discovery.test.ts b/src/server/services/modelService.discovery.test.ts index 8300658..fab1811 100644 --- a/src/server/services/modelService.discovery.test.ts +++ b/src/server/services/modelService.discovery.test.ts @@ -482,13 +482,27 @@ describe('refreshModelsForAccount credential discovery', () => { expect(parsed.runtimeHealth?.state).toBe('unhealthy'); }); - it('discovers antigravity oauth models via direct credential routing', async () => { + it('discovers antigravity oauth models via fetchAvailableModels fallback using the oauth project id', async () => { getApiTokenMock.mockResolvedValue(null); - getModelsMock.mockImplementation(async (_baseUrl: string, token: string) => ( - token === 'antigravity-access-token' - ? ['gemini-3-pro-preview', 'claude-sonnet-4-5-20250929'] - : [] - )); + getModelsMock.mockRejectedValue(new Error('antigravity oauth discovery should not call adapter.getModels')); + undiciFetchMock + .mockResolvedValueOnce({ + ok: false, + status: 503, + json: async () => ({ error: 'unavailable' }), + text: async () => 'unavailable', + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + models: { + 'gemini-3-pro-preview': { displayName: 'Gemini 3 Pro Preview' }, + 'claude-sonnet-4-5-20250929': { displayName: 'Claude Sonnet 4.5' }, + }, + }), + text: async () => JSON.stringify({ ok: true }), + }); const site = await db.insert(schema.sites).values({ name: 'antigravity-site', @@ -526,11 +540,19 @@ describe('refreshModelsForAccount credential discovery', () => { modelCount: 2, modelsPreview: ['gemini-3-pro-preview', 'claude-sonnet-4-5-20250929'], }); - expect(getModelsMock).toHaveBeenCalledWith( - 'https://cloudcode-pa.googleapis.com', - 'antigravity-access-token', - undefined, - ); + expect(getModelsMock).not.toHaveBeenCalled(); + expect(undiciFetchMock).toHaveBeenCalledTimes(2); + expect(String(undiciFetchMock.mock.calls[0]?.[0] || '')).toBe('https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels'); + expect(String(undiciFetchMock.mock.calls[1]?.[0] || '')).toBe('https://daily-cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels'); + expect(undiciFetchMock.mock.calls[0]?.[1]).toMatchObject({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer antigravity-access-token', + }), + }); + expect(JSON.parse(String(undiciFetchMock.mock.calls[0]?.[1]?.body || '{}'))).toEqual({ + project: 'project-demo', + }); const rows = await db.select().from(schema.modelAvailability) .where(eq(schema.modelAvailability.accountId, account.id)) diff --git a/src/server/services/modelService.ts b/src/server/services/modelService.ts index 16c97b3..cee9f99 100644 --- a/src/server/services/modelService.ts +++ b/src/server/services/modelService.ts @@ -22,6 +22,14 @@ import { withSiteRecordProxyRequestInit } from './siteProxy.js'; import { getCodexOauthInfoFromExtraConfig, isCodexPlatform } from './oauth/codexAccount.js'; import { buildOauthInfo, getOauthInfoFromExtraConfig } from './oauth/oauthAccount.js'; import { CLAUDE_DEFAULT_ANTHROPIC_VERSION } from './oauth/claudeProvider.js'; +import { + ANTIGRAVITY_CLIENT_METADATA, + ANTIGRAVITY_DAILY_UPSTREAM_BASE_URL, + ANTIGRAVITY_GOOGLE_API_CLIENT, + ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL, + ANTIGRAVITY_UPSTREAM_BASE_URL, + ANTIGRAVITY_USER_AGENT, +} from './oauth/antigravityProvider.js'; import { GEMINI_CLI_GOOGLE_API_CLIENT, GEMINI_CLI_REQUIRED_SERVICE, @@ -184,6 +192,46 @@ function extractCodexModelIds(payload: unknown): string[] { }); } +function normalizeBaseUrl(baseUrl: string): string { + return (baseUrl || '').replace(/\/+$/, ''); +} + +function extractAntigravityModelIds(payload: unknown): string[] { + if (!payload || typeof payload !== 'object') return []; + const record = payload as { models?: unknown }; + if (record.models && typeof record.models === 'object' && !Array.isArray(record.models)) { + return Object.keys(record.models).map((name) => name.trim()).filter(Boolean); + } + if (!Array.isArray(record.models)) return []; + return record.models.flatMap((item) => { + if (typeof item === 'string') { + const trimmed = item.trim(); + return trimmed ? [trimmed] : []; + } + if (!item || typeof item !== 'object' || Array.isArray(item)) return []; + const value = item as { id?: unknown; name?: unknown }; + const id = typeof value.id === 'string' + ? value.id.trim() + : (typeof value.name === 'string' ? value.name.trim() : ''); + return id ? [id] : []; + }); +} + +function buildAntigravityDiscoveryBaseUrls(siteUrl: string): string[] { + const seen = new Set(); + return [ + siteUrl, + ANTIGRAVITY_UPSTREAM_BASE_URL, + ANTIGRAVITY_DAILY_UPSTREAM_BASE_URL, + ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL, + ].flatMap((candidate) => { + const normalized = normalizeBaseUrl(candidate); + if (!normalized || seen.has(normalized)) return []; + seen.add(normalized); + return [normalized]; + }); +} + async function updateOauthModelDiscoveryState(input: { account: typeof schema.accounts.$inferSelect; checkedAt: string; @@ -297,6 +345,51 @@ async function validateGeminiCliOauthConnection(input: { } } +async function discoverAntigravityModelsFromCloud(input: { + site: typeof schema.sites.$inferSelect; + account: typeof schema.accounts.$inferSelect; +}): Promise { + const accessToken = (input.account.accessToken || '').trim(); + if (!accessToken) { + throw new Error('antigravity oauth access token missing'); + } + + const oauth = getOauthInfoFromExtraConfig(input.account.extraConfig); + const requestBody = oauth?.projectId ? { project: oauth.projectId } : {}; + let lastError = ''; + + for (const baseUrl of buildAntigravityDiscoveryBaseUrls(input.site.url || ANTIGRAVITY_UPSTREAM_BASE_URL)) { + const response = await fetch( + `${baseUrl}/v1internal:fetchAvailableModels`, + withSiteRecordProxyRequestInit(input.site, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': ANTIGRAVITY_USER_AGENT, + 'X-Goog-Api-Client': ANTIGRAVITY_GOOGLE_API_CLIENT, + 'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA, + }, + body: JSON.stringify(requestBody), + }), + ); + if (!response.ok) { + lastError = await response.text().catch(() => '') || `HTTP ${response.status}`; + continue; + } + + const payload = await response.json(); + const models = normalizeModels(extractAntigravityModelIds(payload)); + if (models.length > 0) { + return models; + } + lastError = '未获取到可用模型'; + } + + throw new Error(lastError || '未获取到可用模型'); +} + function isExactModelPattern(modelPattern: string): boolean { const normalized = modelPattern.trim(); if (!normalized) return false; @@ -632,6 +725,76 @@ export async function refreshModelsForAccount(accountId: number): Promise discoverAntigravityModelsFromCloud({ site, account }), + MODEL_DISCOVERY_TIMEOUT_MS, + `antigravity model discovery timeout (${Math.round(MODEL_DISCOVERY_TIMEOUT_MS / 1000)}s)`, + ); + if (antigravityModels.length === 0) { + throw new Error('未获取到可用模型'); + } + + await db.insert(schema.modelAvailability).values( + antigravityModels.map((modelName) => ({ + accountId, + modelName, + available: true, + latencyMs: Date.now() - startedAt, + checkedAt, + })), + ).run(); + await updateOauthModelDiscoveryState({ + account, + checkedAt, + status: 'healthy', + lastDiscoveredModels: antigravityModels, + }); + await setAccountRuntimeHealth(accountId, { + state: 'healthy', + reason: 'Antigravity OAuth 健康探测成功', + source: 'model-discovery', + checkedAt, + }); + return buildSuccessfulRefreshResult({ + accountId, + modelCount: antigravityModels.length, + modelsPreview: antigravityModels.slice(0, 10), + tokenScanned: 0, + discoveredByCredential: true, + discoveredApiToken: false, + }); + } catch (err) { + const rawMessage = (err as { message?: string })?.message || 'antigravity model discovery failed'; + const errorCode = classifyModelDiscoveryError(rawMessage); + const errorMessage = `Antigravity 模型获取失败(${rawMessage})`; + await updateOauthModelDiscoveryState({ + account, + checkedAt, + status: 'abnormal', + lastModelSyncError: errorMessage, + lastDiscoveredModels: [], + }); + await setAccountRuntimeHealth(account.id, { + state: 'unhealthy', + reason: errorMessage, + source: 'model-discovery', + checkedAt, + }); + return buildFailedRefreshResult({ + accountId, + errorCode, + errorMessage, + tokenScanned: 0, + discoveredByCredential: false, + discoveredApiToken: false, + }); + } + } + if (!adapter) { return buildSkippedRefreshResult(accountId, 'adapter_or_status', '平台不可用或账号未激活'); } diff --git a/src/server/services/oauth/antigravityProvider.ts b/src/server/services/oauth/antigravityProvider.ts index 3dd6b19..bf1ab7c 100644 --- a/src/server/services/oauth/antigravityProvider.ts +++ b/src/server/services/oauth/antigravityProvider.ts @@ -11,6 +11,8 @@ export const ANTIGRAVITY_LOOPBACK_CALLBACK_PORT = 51121; export const ANTIGRAVITY_LOOPBACK_CALLBACK_PATH = '/oauth-callback'; export const ANTIGRAVITY_LOOPBACK_REDIRECT_URI = `http://localhost:${ANTIGRAVITY_LOOPBACK_CALLBACK_PORT}${ANTIGRAVITY_LOOPBACK_CALLBACK_PATH}`; export const ANTIGRAVITY_UPSTREAM_BASE_URL = 'https://cloudcode-pa.googleapis.com'; +export const ANTIGRAVITY_DAILY_UPSTREAM_BASE_URL = 'https://daily-cloudcode-pa.googleapis.com'; +export const ANTIGRAVITY_SANDBOX_DAILY_UPSTREAM_BASE_URL = 'https://daily-cloudcode-pa.sandbox.googleapis.com'; export const ANTIGRAVITY_GOOGLE_API_CLIENT = 'google-cloud-sdk vscode_cloudshelleditor/0.1'; export const ANTIGRAVITY_USER_AGENT = 'google-api-nodejs-client/9.15.1'; export const ANTIGRAVITY_CLIENT_METADATA = '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}'; diff --git a/src/server/services/oauth/geminiCliProvider.ts b/src/server/services/oauth/geminiCliProvider.ts index fa68d37..e08bf79 100644 --- a/src/server/services/oauth/geminiCliProvider.ts +++ b/src/server/services/oauth/geminiCliProvider.ts @@ -156,16 +156,16 @@ async function resolveGeminiProjectId(accessToken: string, requestedProjectId?: } const projects = await fetchGcpProjects(accessToken); - if (projects.length === 1 && projects[0]) { - await ensureGeminiProjectEnabled(accessToken, projects[0]); - return projects[0]; - } - if (projects.length <= 0) { throw new Error('no Google Cloud projects available for this account'); } - throw new Error('gemini cli: project selection required'); + const firstProject = projects[0]; + if (!firstProject) { + throw new Error('no Google Cloud projects available for this account'); + } + await ensureGeminiProjectEnabled(accessToken, firstProject); + return firstProject; } export const geminiCliOauthProvider: OAuthProviderDefinition = { diff --git a/src/server/services/oauth/service.ts b/src/server/services/oauth/service.ts index 0bd9213..41f0e77 100644 --- a/src/server/services/oauth/service.ts +++ b/src/server/services/oauth/service.ts @@ -356,40 +356,50 @@ export async function handleOauthCallback(input: { throw new Error('missing oauth code'); } - const exchange = await definition.exchangeAuthorizationCode({ - code, - state: input.state, - redirectUri: session.redirectUri, - codeVerifier: session.codeVerifier, - projectId: session.projectId, - }); - const { account, site, created } = await upsertOauthAccount({ - definition, - exchange, - rebindAccountId: session.rebindAccountId, - }); - if (!account) { - markOauthSessionError(input.state, 'failed to persist oauth account'); - throw new Error('failed to persist oauth account'); - } - - const refreshResult = await refreshModelsForAccount(account.id); - if (refreshResult.status !== 'success') { - if (created) { - await db.delete(schema.accounts).where(eq(schema.accounts.id, account.id)).run(); + try { + const exchange = await definition.exchangeAuthorizationCode({ + code, + state: input.state, + redirectUri: session.redirectUri, + codeVerifier: session.codeVerifier, + projectId: session.projectId, + }); + const { account, site, created } = await upsertOauthAccount({ + definition, + exchange, + rebindAccountId: session.rebindAccountId, + }); + if (!account) { + markOauthSessionError(input.state, 'failed to persist oauth account'); + throw new Error('failed to persist oauth account'); } - await rebuildTokenRoutesFromAvailability(); - const errorMessage = refreshResult.errorMessage || `${input.provider} model discovery failed`; - markOauthSessionError(input.state, errorMessage); - throw new Error(errorMessage); - } - await rebuildTokenRoutesFromAvailability(); - markOauthSessionSuccess(input.state, { - accountId: account.id, - siteId: site.id, - }); - return { accountId: account.id, siteId: site.id }; + const refreshResult = await refreshModelsForAccount(account.id); + if (refreshResult.status !== 'success') { + if (created) { + await db.delete(schema.accounts).where(eq(schema.accounts.id, account.id)).run(); + } + await rebuildTokenRoutesFromAvailability(); + const errorMessage = refreshResult.errorMessage || `${input.provider} model discovery failed`; + markOauthSessionError(input.state, errorMessage); + throw new Error(errorMessage); + } + + await rebuildTokenRoutesFromAvailability(); + markOauthSessionSuccess(input.state, { + accountId: account.id, + siteId: site.id, + }); + return { accountId: account.id, siteId: site.id }; + } catch (error) { + const message = ( + error instanceof Error + ? (error.message || error.name) + : String(error || 'OAuth failed') + ).trim() || 'OAuth failed'; + markOauthSessionError(input.state, message); + throw error; + } } export async function submitOauthManualCallback(input: {