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 () => {
const site = await db.insert(schema.sites).values({
name: 'ChatGPT Codex OAuth',
@@ -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))
+163
View File
@@ -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<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: {
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<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 {
const normalized = modelPattern.trim();
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) {
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_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"}';
@@ -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 = {
+10
View File
@@ -356,6 +356,7 @@ export async function handleOauthCallback(input: {
throw new Error('missing oauth code');
}
try {
const exchange = await definition.exchangeAuthorizationCode({
code,
state: input.state,
@@ -390,6 +391,15 @@ export async function handleOauthCallback(input: {
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: {