1
0
forked from boos/BoosAPI

fix: improve oauth error and model discovery fallbacks

This commit is contained in:
ciat-777
2026-03-17 23:13:01 +08:00
parent 2f2c93477d
commit f565d1b54d
6 changed files with 394 additions and 49 deletions
+148
View File
@@ -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 () => { 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',
@@ -482,13 +482,27 @@ describe('refreshModelsForAccount credential discovery', () => {
expect(parsed.runtimeHealth?.state).toBe('unhealthy'); 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); getApiTokenMock.mockResolvedValue(null);
getModelsMock.mockImplementation(async (_baseUrl: string, token: string) => ( getModelsMock.mockRejectedValue(new Error('antigravity oauth discovery should not call adapter.getModels'));
token === 'antigravity-access-token' undiciFetchMock
? ['gemini-3-pro-preview', 'claude-sonnet-4-5-20250929'] .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({ const site = await db.insert(schema.sites).values({
name: 'antigravity-site', name: 'antigravity-site',
@@ -526,11 +540,19 @@ describe('refreshModelsForAccount credential discovery', () => {
modelCount: 2, modelCount: 2,
modelsPreview: ['gemini-3-pro-preview', 'claude-sonnet-4-5-20250929'], modelsPreview: ['gemini-3-pro-preview', 'claude-sonnet-4-5-20250929'],
}); });
expect(getModelsMock).toHaveBeenCalledWith( expect(getModelsMock).not.toHaveBeenCalled();
'https://cloudcode-pa.googleapis.com', expect(undiciFetchMock).toHaveBeenCalledTimes(2);
'antigravity-access-token', expect(String(undiciFetchMock.mock.calls[0]?.[0] || '')).toBe('https://cloudcode-pa.googleapis.com/v1internal:fetchAvailableModels');
undefined, 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) const rows = await db.select().from(schema.modelAvailability)
.where(eq(schema.modelAvailability.accountId, account.id)) .where(eq(schema.modelAvailability.accountId, account.id))
+163
View File
@@ -22,6 +22,14 @@ import { withSiteRecordProxyRequestInit } from './siteProxy.js';
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 {
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 { import {
GEMINI_CLI_GOOGLE_API_CLIENT, GEMINI_CLI_GOOGLE_API_CLIENT,
GEMINI_CLI_REQUIRED_SERVICE, 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<string>();
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: { async function updateOauthModelDiscoveryState(input: {
account: typeof schema.accounts.$inferSelect; account: typeof schema.accounts.$inferSelect;
checkedAt: string; 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<string[]> {
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 { function isExactModelPattern(modelPattern: string): boolean {
const normalized = modelPattern.trim(); const normalized = modelPattern.trim();
if (!normalized) return false; if (!normalized) return false;
@@ -632,6 +725,76 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
} }
} }
if (oauth?.provider === 'antigravity') {
const checkedAt = new Date().toISOString();
const startedAt = Date.now();
try {
const antigravityModels = await withTimeout(
() => 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) { if (!adapter) {
return buildSkippedRefreshResult(accountId, 'adapter_or_status', '平台不可用或账号未激活'); return buildSkippedRefreshResult(accountId, 'adapter_or_status', '平台不可用或账号未激活');
} }
@@ -11,6 +11,8 @@ export const ANTIGRAVITY_LOOPBACK_CALLBACK_PORT = 51121;
export const ANTIGRAVITY_LOOPBACK_CALLBACK_PATH = '/oauth-callback'; 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_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_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_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_USER_AGENT = 'google-api-nodejs-client/9.15.1';
export const ANTIGRAVITY_CLIENT_METADATA = '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}'; export const ANTIGRAVITY_CLIENT_METADATA = '{"ideType":"IDE_UNSPECIFIED","platform":"PLATFORM_UNSPECIFIED","pluginType":"GEMINI"}';
@@ -156,16 +156,16 @@ async function resolveGeminiProjectId(accessToken: string, requestedProjectId?:
} }
const projects = await fetchGcpProjects(accessToken); const projects = await fetchGcpProjects(accessToken);
if (projects.length === 1 && projects[0]) {
await ensureGeminiProjectEnabled(accessToken, projects[0]);
return projects[0];
}
if (projects.length <= 0) { if (projects.length <= 0) {
throw new Error('no Google Cloud projects available for this account'); 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 = { export const geminiCliOauthProvider: OAuthProviderDefinition = {
+42 -32
View File
@@ -356,40 +356,50 @@ export async function handleOauthCallback(input: {
throw new Error('missing oauth code'); throw new Error('missing oauth code');
} }
const exchange = await definition.exchangeAuthorizationCode({ try {
code, const exchange = await definition.exchangeAuthorizationCode({
state: input.state, code,
redirectUri: session.redirectUri, state: input.state,
codeVerifier: session.codeVerifier, redirectUri: session.redirectUri,
projectId: session.projectId, codeVerifier: session.codeVerifier,
}); projectId: session.projectId,
const { account, site, created } = await upsertOauthAccount({ });
definition, const { account, site, created } = await upsertOauthAccount({
exchange, definition,
rebindAccountId: session.rebindAccountId, exchange,
}); rebindAccountId: session.rebindAccountId,
if (!account) { });
markOauthSessionError(input.state, 'failed to persist oauth account'); if (!account) {
throw new Error('failed to persist oauth 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();
} }
await rebuildTokenRoutesFromAvailability();
const errorMessage = refreshResult.errorMessage || `${input.provider} model discovery failed`;
markOauthSessionError(input.state, errorMessage);
throw new Error(errorMessage);
}
await rebuildTokenRoutesFromAvailability(); const refreshResult = await refreshModelsForAccount(account.id);
markOauthSessionSuccess(input.state, { if (refreshResult.status !== 'success') {
accountId: account.id, if (created) {
siteId: site.id, await db.delete(schema.accounts).where(eq(schema.accounts.id, account.id)).run();
}); }
return { accountId: account.id, siteId: site.id }; 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: { export async function submitOauthManualCallback(input: {