From 07351b527f9da656ae04741a95f13dc074ccaa03 Mon Sep 17 00:00:00 2001 From: ciat-777 Date: Tue, 17 Mar 2026 20:17:04 +0800 Subject: [PATCH 1/2] feat: enhance OAuthManagement with improved provider handling and user feedback --- .../routes/proxy/chat.codex-oauth.test.ts | 71 ++++- src/web/pages/OAuthManagement.test.tsx | 283 ++++++++++++++++++ src/web/pages/OAuthManagement.tsx | 64 +++- 3 files changed, 405 insertions(+), 13 deletions(-) diff --git a/src/server/routes/proxy/chat.codex-oauth.test.ts b/src/server/routes/proxy/chat.codex-oauth.test.ts index 7b7ab1a..60f6bc1 100644 --- a/src/server/routes/proxy/chat.codex-oauth.test.ts +++ b/src/server/routes/proxy/chat.codex-oauth.test.ts @@ -89,8 +89,9 @@ describe('chat proxy codex oauth compatibility', () => { }; beforeAll(async () => { - const { claudeMessagesProxyRoute } = await import('./chat.js'); + const { chatProxyRoute, claudeMessagesProxyRoute } = await import('./chat.js'); app = Fastify(); + await app.register(chatProxyRoute); await app.register(claudeMessagesProxyRoute); }); @@ -176,4 +177,72 @@ describe('chat proxy codex oauth compatibility', () => { expect(body.content?.[0]?.type).toBe('text'); expect(body.content?.[0]?.text).toContain('pong from codex'); }); + + it('translates codex SSE into Claude messages stream events instead of leaking raw response events', async () => { + fetchMock.mockResolvedValue(createSseResponse([ + 'event: response.created\n', + 'data: {"type":"response.created","response":{"id":"resp_codex_claude_stream","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n', + 'event: response.output_item.added\n', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_claude_stream","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n', + 'event: response.content_part.added\n', + 'data: {"type":"response.content_part.added","output_index":0,"content_index":0,"item_id":"msg_codex_claude_stream","part":{"type":"output_text","text":""}}\n\n', + 'event: response.output_text.delta\n', + 'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_claude_stream","content_index":0,"delta":"pong from codex"}\n\n', + 'event: response.content_part.done\n', + 'data: {"type":"response.content_part.done","output_index":0,"content_index":0,"item_id":"msg_codex_claude_stream","part":{"type":"output_text","text":"pong from codex"}}\n\n', + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_codex_claude_stream","model":"gpt-5.4","status":"completed","usage":{"input_tokens":9,"output_tokens":3,"total_tokens":12}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const response = await app.inject({ + method: 'POST', + url: '/v1/messages', + payload: { + model: 'gpt-5.4', + stream: true, + max_tokens: 256, + messages: [{ role: 'user', content: 'hello codex' }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toContain('text/event-stream'); + expect(response.body).toContain('event: message_start'); + expect(response.body).toContain('event: content_block_delta'); + expect(response.body).toContain('pong from codex'); + expect(response.body).not.toContain('event: response.created'); + expect(response.body).not.toContain('"type":"response.output_text.delta"'); + }); + + it('translates codex SSE into OpenAI chat completion chunks instead of leaking raw response events', async () => { + fetchMock.mockResolvedValue(createSseResponse([ + 'event: response.created\n', + 'data: {"type":"response.created","response":{"id":"resp_codex_openai_stream","model":"gpt-5.4","created_at":1706000000,"status":"in_progress","output":[]}}\n\n', + 'event: response.output_item.added\n', + 'data: {"type":"response.output_item.added","output_index":0,"item":{"id":"msg_codex_openai_stream","type":"message","role":"assistant","status":"in_progress","content":[]}}\n\n', + 'event: response.output_text.delta\n', + 'data: {"type":"response.output_text.delta","output_index":0,"item_id":"msg_codex_openai_stream","delta":"pong from codex"}\n\n', + 'event: response.completed\n', + 'data: {"type":"response.completed","response":{"id":"resp_codex_openai_stream","model":"gpt-5.4","status":"completed","usage":{"input_tokens":9,"output_tokens":3,"total_tokens":12}}}\n\n', + 'data: [DONE]\n\n', + ])); + + const response = await app.inject({ + method: 'POST', + url: '/v1/chat/completions', + payload: { + model: 'gpt-5.4', + stream: true, + messages: [{ role: 'user', content: 'hello codex' }], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toContain('text/event-stream'); + expect(response.body).toContain('"object":"chat.completion.chunk"'); + expect(response.body).toContain('pong from codex'); + expect(response.body).not.toContain('event: response.created'); + expect(response.body).not.toContain('"type":"response.output_text.delta"'); + }); }); diff --git a/src/web/pages/OAuthManagement.test.tsx b/src/web/pages/OAuthManagement.test.tsx index 689bc23..296c65b 100644 --- a/src/web/pages/OAuthManagement.test.tsx +++ b/src/web/pages/OAuthManagement.test.tsx @@ -374,6 +374,289 @@ describe('OAuthManagement page', () => { } }); + it('explains oauth usage and disables unavailable providers', async () => { + apiMock.getOAuthProviders.mockResolvedValue({ + providers: [ + { + provider: 'codex', + label: 'Codex', + platform: 'codex', + enabled: false, + loginType: 'oauth', + requiresProjectId: false, + supportsDirectAccountRouting: true, + supportsCloudValidation: true, + supportsNativeProxy: true, + }, + ], + }); + apiMock.getOAuthConnections.mockResolvedValue({ + items: [], + total: 0, + limit: 100, + offset: 0, + }); + + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await vi.waitFor(async () => { + await flushMicrotasks(); + const text = collectText(root!.root); + expect(text).toContain('官方上游连接'); + expect(text).toContain('CLI'); + expect(text).toContain('下游密钥'); + }); + + const startButton = root!.root.find((node) => ( + node.type === 'button' + && collectText(node).includes('当前不可用') + )); + expect(startButton.props.disabled).toBe(true); + expect(collectText(root!.root)).toContain('当前环境未启用'); + } finally { + root?.unmount(); + } + }); + + it('prefers renamed account title and still shows oauth email', async () => { + apiMock.getOAuthProviders.mockResolvedValue({ + providers: [ + { + provider: 'codex', + label: 'Codex', + platform: 'codex', + enabled: true, + loginType: 'oauth', + requiresProjectId: false, + supportsDirectAccountRouting: true, + supportsCloudValidation: true, + supportsNativeProxy: true, + }, + ], + }); + apiMock.getOAuthConnections.mockResolvedValue({ + items: [ + { + accountId: 7, + provider: 'codex', + username: 'Juricek Team A', + email: 'juricek.chen@gmail.com', + accountKey: 'chatgpt-account-123', + planType: 'team', + modelCount: 11, + modelsPreview: ['gpt-5.4'], + status: 'healthy', + routeChannelCount: 11, + }, + ], + total: 1, + limit: 100, + offset: 0, + }); + + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await vi.waitFor(async () => { + await flushMicrotasks(); + const text = collectText(root!.root); + expect(text).toContain('Juricek Team A'); + expect(text).toContain('juricek.chen@gmail.com'); + }); + } finally { + root?.unmount(); + } + }); + + it('allows starting gemini oauth without entering a project id', async () => { + promptMock.mockReturnValueOnce(''); + apiMock.getOAuthProviders.mockResolvedValue({ + providers: [ + { + provider: 'gemini-cli', + label: 'Gemini CLI', + platform: 'gemini-cli', + enabled: true, + loginType: 'oauth', + requiresProjectId: true, + supportsDirectAccountRouting: true, + supportsCloudValidation: true, + supportsNativeProxy: true, + }, + ], + }); + apiMock.getOAuthConnections.mockResolvedValue({ + items: [], + total: 0, + limit: 100, + offset: 0, + }); + apiMock.startOAuthProvider.mockResolvedValue({ + provider: 'gemini-cli', + state: 'gemini-state-123', + authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-state-123', + instructions: { + redirectUri: 'http://localhost:8085/oauth2callback', + callbackPort: 8085, + callbackPath: '/oauth2callback', + manualCallbackDelayMs: 15000, + }, + }); + apiMock.getOAuthSession.mockResolvedValue({ + provider: 'gemini-cli', + state: 'gemini-state-123', + status: 'pending', + }); + + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await vi.waitFor(async () => { + await flushMicrotasks(); + }); + + const startButton = root!.root.find((node) => ( + node.type === 'button' + && typeof node.props.onClick === 'function' + && collectText(node).includes('连接 Gemini CLI') + )); + + await act(async () => { + await startButton.props.onClick(); + }); + await vi.waitFor(async () => { + await flushMicrotasks(); + }); + + expect(promptMock).toHaveBeenCalled(); + expect(apiMock.startOAuthProvider).toHaveBeenCalledWith('gemini-cli', { projectId: undefined }); + expect(openMock).toHaveBeenCalledWith( + 'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-state-123', + 'oauth-gemini-cli', + expect.stringContaining('width=540'), + ); + expect(collectText(root!.root)).not.toContain('Gemini CLI 连接需要 Project ID'); + } finally { + root?.unmount(); + } + }); + + it('rebinds gemini oauth without prompting for a project id again', async () => { + apiMock.getOAuthProviders.mockResolvedValue({ + providers: [ + { + provider: 'gemini-cli', + label: 'Gemini CLI', + platform: 'gemini-cli', + enabled: true, + loginType: 'oauth', + requiresProjectId: true, + supportsDirectAccountRouting: true, + supportsCloudValidation: true, + supportsNativeProxy: true, + }, + ], + }); + apiMock.getOAuthConnections.mockResolvedValue({ + items: [ + { + accountId: 11, + provider: 'gemini-cli', + email: 'gemini-user@example.com', + planType: 'cloud', + modelCount: 2, + modelsPreview: ['gemini-2.5-pro', 'gemini-2.5-flash'], + status: 'healthy', + projectId: 'project-demo', + routeChannelCount: 0, + }, + ], + total: 1, + limit: 100, + offset: 0, + }); + apiMock.rebindOAuthConnection.mockResolvedValue({ + provider: 'gemini-cli', + state: 'gemini-rebind-123', + authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-rebind-123', + instructions: { + redirectUri: 'http://localhost:8085/oauth2callback', + callbackPort: 8085, + callbackPath: '/oauth2callback', + manualCallbackDelayMs: 15000, + }, + }); + apiMock.getOAuthSession.mockResolvedValue({ + provider: 'gemini-cli', + state: 'gemini-rebind-123', + status: 'pending', + }); + + let root: ReturnType | null = null; + try { + await act(async () => { + root = create( + + + + + , + ); + }); + await vi.waitFor(async () => { + await flushMicrotasks(); + }); + + const rebindButton = root!.root.find((node) => ( + node.type === 'button' + && typeof node.props.onClick === 'function' + && collectText(node).includes('重新授权') + )); + + await act(async () => { + await rebindButton.props.onClick(); + }); + await vi.waitFor(async () => { + await flushMicrotasks(); + }); + + expect(promptMock).not.toHaveBeenCalled(); + expect(apiMock.rebindOAuthConnection).toHaveBeenCalledWith(11); + expect(openMock).toHaveBeenCalledWith( + 'https://accounts.google.com/o/oauth2/v2/auth?state=gemini-rebind-123', + 'oauth-gemini-cli', + expect.stringContaining('width=540'), + ); + } finally { + root?.unmount(); + } + }); + it('shows oauth connection status metadata and allows deleting a connection', async () => { apiMock.getOAuthProviders.mockResolvedValue({ providers: [ diff --git a/src/web/pages/OAuthManagement.tsx b/src/web/pages/OAuthManagement.tsx index d6fd9bb..7fde182 100644 --- a/src/web/pages/OAuthManagement.tsx +++ b/src/web/pages/OAuthManagement.tsx @@ -34,6 +34,29 @@ function resolveConnectionStatusLabel(status?: string): string { return status === 'abnormal' ? '异常' : '正常'; } +function asTrimmedString(value: string | null | undefined): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function resolveProviderActionLabel(provider: OAuthProviderInfo, loading: boolean): string { + if (loading) return '启动中...'; + if (!provider.enabled) return '当前不可用'; + return `连接 ${provider.label}`; +} + +function resolveConnectionPrimaryTitle(connection: OAuthConnectionInfo): string { + return asTrimmedString(connection.username) + || asTrimmedString(connection.email) + || asTrimmedString(connection.provider) + || 'OAuth 连接'; +} + +function resolveConnectionEmailLabel(connection: OAuthConnectionInfo): string { + const email = asTrimmedString(connection.email); + if (!email) return ''; + return email; +} + function renderCodeBlock(value: string) { return ( { + if (!provider.enabled) { + setSessionMessage(`${provider.label} 当前环境未启用`); + return; + } const actionKey = `start:${provider.provider}:${accountId || 0}`; setActionLoadingKey(actionKey); try { - const projectId = provider.requiresProjectId + const shouldPromptForProjectId = provider.requiresProjectId && !accountId; + const projectId = shouldPromptForProjectId ? (() => { if (typeof window === 'undefined' || typeof window.prompt !== 'function') return undefined; - const value = window.prompt('输入 Google Cloud Project ID'); + const value = window.prompt('输入 Google Cloud Project ID(可选,留空则自动解析)'); return typeof value === 'string' && value.trim() ? value.trim() : undefined; })() : undefined; - if (provider.requiresProjectId && !projectId && !accountId) { - setSessionMessage('Gemini CLI 连接需要 Project ID'); - return; - } const started = accountId ? await api.rebindOAuthConnection(accountId) : await api.startOAuthProvider(provider.provider, { projectId }); @@ -245,7 +269,9 @@ export default function OAuthManagement() {
OAuth 管理
-
统一管理需要浏览器授权的上游连接,包括 Codex、Claude 和 Gemini CLI。
+
+ 统一管理需要浏览器授权的官方上游连接。授权完成后,可把 Codex、Claude、Gemini CLI 等 CLI / Web 登录态接入 metapi,继续通过统一路由、下游密钥、模型操练场和第三方客户端转发使用。 +
@@ -348,26 +374,33 @@ export default function OAuthManagement() {
授权入口
+
+ 适合把官方 CLI / Web 登录得到的账号统一接入 metapi,再供各种 CLI、SDK、下游密钥和模型操练场复用。 +
{providers.map((provider) => { const actionKey = `start:${provider.provider}:0`; + const disabled = !provider.enabled || actionLoadingKey === actionKey; return (
{provider.label}
{provider.platform} - {provider.requiresProjectId ? ' · 需要 Project ID' : ''} + {provider.requiresProjectId ? ' · 可选 Project ID' : ''} {provider.supportsNativeProxy ? ' · 原生代理' : ''} + {!provider.enabled ? ' · 当前环境未启用' : ''}
); @@ -384,13 +417,20 @@ export default function OAuthManagement() { {connections.map((connection) => { const rebindActionKey = `start:${connection.provider}:${connection.accountId}`; const deleteActionKey = `delete:${connection.accountId}`; + const primaryTitle = resolveConnectionPrimaryTitle(connection); + const emailLabel = resolveConnectionEmailLabel(connection); return (
-
{connection.email || connection.username || connection.provider}
+
{primaryTitle}
+ {emailLabel && emailLabel !== primaryTitle && ( +
+ 授权邮箱: {emailLabel} +
+ )}
- {connection.planType || 'unknown'} · {connection.modelCount} 个模型 + {connection.provider} · {connection.planType || 'unknown'} · {connection.modelCount} 个模型
{connection.accountKey && (
From 2e34f9033d74532c55282d66b69e9261af3d46a0 Mon Sep 17 00:00:00 2001 From: ciat-777 Date: Tue, 17 Mar 2026 21:22:23 +0800 Subject: [PATCH 2/2] feat: expand gemini proxy compatibility matrix --- src/server/db/schema.ts | 2 +- src/server/routes/api/oauth.test.ts | 42 ++ src/server/routes/proxy/gemini.test.ts | 505 +++++++++++++ src/server/routes/proxy/gemini.ts | 688 ++++++++++++------ .../routes/proxy/upstreamEndpoint.test.ts | 46 ++ src/server/routes/proxy/upstreamEndpoint.ts | 19 +- .../services/modelService.discovery.test.ts | 59 ++ .../services/oauth/antigravityProvider.ts | 205 ++++++ src/server/services/oauth/providers.ts | 4 +- src/server/services/platforms/antigravity.ts | 79 ++ src/server/services/platforms/index.test.ts | 5 + src/server/services/platforms/index.ts | 4 + src/server/services/platforms/titleHint.ts | 18 +- .../gemini/generate-content/compatibility.ts | 314 ++++++++ .../gemini/generate-content/index.ts | 7 + src/server/transformers/shared/normalized.ts | 6 +- .../transformers/shared/protocolModel.ts | 3 +- 17 files changed, 1784 insertions(+), 222 deletions(-) create mode 100644 src/server/services/oauth/antigravityProvider.ts create mode 100644 src/server/services/platforms/antigravity.ts create mode 100644 src/server/transformers/gemini/generate-content/compatibility.ts diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index e50a0bd..9b4f665 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -6,7 +6,7 @@ export const sites = sqliteTable('sites', { name: text('name').notNull(), url: text('url').notNull(), externalCheckinUrl: text('external_checkin_url'), - platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api' | 'openai' | 'claude' | 'gemini' | 'codex' | 'gemini-cli' + platform: text('platform').notNull(), // 'new-api' | 'one-api' | 'veloera' | 'one-hub' | 'done-hub' | 'sub2api' | 'openai' | 'claude' | 'gemini' | 'codex' | 'gemini-cli' | 'antigravity' proxyUrl: text('proxy_url'), useSystemProxy: integer('use_system_proxy', { mode: 'boolean' }).default(false), customHeaders: text('custom_headers'), diff --git a/src/server/routes/api/oauth.test.ts b/src/server/routes/api/oauth.test.ts index eed23cb..157d25f 100644 --- a/src/server/routes/api/oauth.test.ts +++ b/src/server/routes/api/oauth.test.ts @@ -83,10 +83,52 @@ describe('oauth routes', { timeout: 15_000 }, () => { loginType: 'oauth', requiresProjectId: true, }), + expect.objectContaining({ + provider: 'antigravity', + platform: 'antigravity', + enabled: true, + loginType: 'oauth', + requiresProjectId: false, + }), ]), }); }); + it('starts an antigravity oauth session and returns provider metadata', async () => { + const startResponse = await app.inject({ + method: 'POST', + url: '/api/oauth/providers/antigravity/start', + headers: { + host: 'metapi.example', + 'x-forwarded-proto': 'https', + }, + }); + + expect(startResponse.statusCode).toBe(200); + const startBody = startResponse.json() as { + provider: string; + state: string; + authorizationUrl: string; + instructions?: { + redirectUri: string; + callbackPort: number; + callbackPath: string; + manualCallbackDelayMs: number; + }; + }; + expect(startBody.provider).toBe('antigravity'); + expect(startBody.state).toMatch(/^[a-zA-Z0-9_-]{20,}$/); + expect(startBody.authorizationUrl).toContain('https://accounts.google.com/o/oauth2/v2/auth?'); + expect(startBody.authorizationUrl).toContain(encodeURIComponent('http://localhost:51121/oauth-callback')); + expect(startBody.authorizationUrl).toContain(`state=${encodeURIComponent(startBody.state)}`); + expect(startBody.instructions).toMatchObject({ + redirectUri: 'http://localhost:51121/oauth-callback', + callbackPort: 51121, + callbackPath: '/oauth-callback', + manualCallbackDelayMs: 15000, + }); + }); + it('starts a codex oauth session and exposes pending status', async () => { const startResponse = await app.inject({ method: 'POST', diff --git a/src/server/routes/proxy/gemini.test.ts b/src/server/routes/proxy/gemini.test.ts index ceccffb..2cb0cfa 100644 --- a/src/server/routes/proxy/gemini.test.ts +++ b/src/server/routes/proxy/gemini.test.ts @@ -4,6 +4,8 @@ import path from 'node:path'; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; const fetchMock = vi.fn(); +const fetchModelPricingCatalogMock = vi.fn(); +const refreshModelsAndRebuildRoutesMock = vi.fn(); const selectChannelMock = vi.fn(); const selectNextChannelMock = vi.fn(); const recordSuccessMock = vi.fn(); @@ -13,6 +15,8 @@ const invalidateTokenRouterCacheMock = vi.fn(); const authorizeDownstreamTokenMock = vi.fn(); const consumeManagedKeyRequestMock = vi.fn(); const isModelAllowedByPolicyOrAllowedRoutesMock = vi.fn(); +const dbSelectAllMock = vi.fn(); +const dbSelectGetMock = vi.fn(); const dbInsertValuesMock = vi.fn((_values?: unknown) => ({ run: () => undefined, })); @@ -20,10 +24,34 @@ const dbInsertMock = vi.fn((_table?: unknown) => ({ values: (values: unknown) => dbInsertValuesMock(values), })); +function createDbSelectChain() { + return { + from() { + return this; + }, + innerJoin() { + return this; + }, + where() { + return this; + }, + all: (...args: unknown[]) => dbSelectAllMock(...args), + get: (...args: unknown[]) => dbSelectGetMock(...args), + }; +} + vi.mock('undici', () => ({ fetch: (...args: unknown[]) => fetchMock(...args), })); +vi.mock('../../services/modelPricingService.js', () => ({ + fetchModelPricingCatalog: (...args: unknown[]) => fetchModelPricingCatalogMock(...args), +})); + +vi.mock('../../services/modelService.js', () => ({ + refreshModelsAndRebuildRoutes: (...args: unknown[]) => refreshModelsAndRebuildRoutesMock(...args), +})); + vi.mock('../../services/tokenRouter.js', () => ({ tokenRouter: { selectChannel: (...args: unknown[]) => selectChannelMock(...args), @@ -43,10 +71,29 @@ vi.mock('../../services/downstreamApiKeyService.js', () => ({ vi.mock('../../db/index.js', () => ({ db: { + select: (..._args: unknown[]) => createDbSelectChain(), insert: (arg: unknown) => dbInsertMock(arg), }, schema: { proxyLogs: {}, + modelAvailability: { + modelName: Symbol('modelAvailability.modelName'), + accountId: Symbol('modelAvailability.accountId'), + available: Symbol('modelAvailability.available'), + }, + accounts: { + id: Symbol('accounts.id'), + siteId: Symbol('accounts.siteId'), + status: Symbol('accounts.status'), + }, + sites: { + id: Symbol('sites.id'), + status: Symbol('sites.status'), + }, + tokenRoutes: { + displayName: Symbol('tokenRoutes.displayName'), + enabled: Symbol('tokenRoutes.enabled'), + }, }, })); @@ -101,6 +148,8 @@ describe('gemini native proxy routes', () => { beforeEach(() => { fetchMock.mockReset(); + fetchModelPricingCatalogMock.mockReset(); + refreshModelsAndRebuildRoutesMock.mockReset(); selectChannelMock.mockReset(); selectNextChannelMock.mockReset(); recordSuccessMock.mockReset(); @@ -111,6 +160,8 @@ describe('gemini native proxy routes', () => { isModelAllowedByPolicyOrAllowedRoutesMock.mockReset(); dbInsertMock.mockClear(); dbInsertValuesMock.mockClear(); + dbSelectAllMock.mockReset(); + dbSelectGetMock.mockReset(); authorizeDownstreamTokenMock.mockResolvedValue({ ok: true, @@ -118,6 +169,10 @@ describe('gemini native proxy routes', () => { token: 'sk-managed-gemini', policy: {}, }); + fetchModelPricingCatalogMock.mockResolvedValue(null); + refreshModelsAndRebuildRoutesMock.mockResolvedValue(undefined); + dbSelectGetMock.mockResolvedValue(null); + dbSelectAllMock.mockResolvedValue([]); selectChannelMock.mockReturnValue({ channel: { id: 11, routeId: 22 }, @@ -256,6 +311,52 @@ describe('gemini native proxy routes', () => { }); }); + it('synthesizes /v1beta/models from locally available routed models for non-gemini upstreams', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 41, routeId: 22 }, + site: { id: 77, name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' }, + account: { id: 37, username: 'openai-user@example.com' }, + tokenName: 'default', + tokenValue: 'openai-access-token', + actualModel: 'gpt-4.1', + }); + dbSelectAllMock + .mockResolvedValueOnce([ + { modelName: 'gpt-4.1' }, + { modelName: 'claude-sonnet-4-5-20250929' }, + ]) + .mockResolvedValueOnce([ + { displayName: 'gemini-2.5-flash' }, + ]); + + const response = await app.inject({ + method: 'GET', + url: '/v1beta/models', + headers: { + authorization: 'Bearer sk-managed-gemini', + }, + }); + + expect(response.statusCode).toBe(200); + expect(fetchMock).not.toHaveBeenCalled(); + expect(response.json()).toEqual({ + models: [ + { + name: 'models/claude-sonnet-4-5-20250929', + displayName: 'claude-sonnet-4-5-20250929', + }, + { + name: 'models/gemini-2.5-flash', + displayName: 'gemini-2.5-flash', + }, + { + name: 'models/gpt-4.1', + displayName: 'gpt-4.1', + }, + ], + }); + }); + it('forwards native generateContent requests through the gemini route group', async () => { fetchMock.mockResolvedValue(new Response(JSON.stringify({ candidates: [ @@ -405,6 +506,410 @@ describe('gemini native proxy routes', () => { }); }); + it('routes Gemini native generateContent requests to openai upstreams and serializes the response back to Gemini shape', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 41, routeId: 22 }, + site: { id: 77, name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' }, + account: { id: 37, username: 'openai-user@example.com' }, + tokenName: 'default', + tokenValue: 'openai-access-token', + actualModel: 'gpt-4.1', + }); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'chatcmpl-openai-1', + object: 'chat.completion', + created: 1_742_160_000, + model: 'gpt-4.1', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'hello from openai', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 4, + completion_tokens: 3, + total_tokens: 7, + }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1beta/models/gemini-2.5-flash:generateContent', + headers: { + authorization: 'Bearer sk-managed-gemini', + }, + payload: { + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(targetUrl).toBe('https://api.openai.com/v1/chat/completions'); + expect(requestInit.headers).toMatchObject({ + Authorization: 'Bearer openai-access-token', + 'Content-Type': 'application/json', + }); + expect(JSON.parse(String(requestInit.body))).toEqual({ + model: 'gpt-4.1', + stream: false, + messages: [ + { + role: 'user', + content: 'hello', + }, + ], + }); + expect(response.json()).toEqual({ + responseId: 'chatcmpl-openai-1', + modelVersion: 'gpt-4.1', + candidates: [ + { + index: 0, + content: { + role: 'model', + parts: [{ text: 'hello from openai' }], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 4, + candidatesTokenCount: 3, + totalTokenCount: 7, + }, + }); + }); + + it('exposes GeminiCLI downstream generateContent endpoint and wraps the downstream response payload', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 42, routeId: 22 }, + site: { id: 78, name: 'openai-site', url: 'https://api.openai.com', platform: 'openai' }, + account: { id: 38, username: 'openai-user@example.com' }, + tokenName: 'default', + tokenValue: 'openai-access-token', + actualModel: 'gpt-4.1', + }); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + id: 'chatcmpl-openai-2', + object: 'chat.completion', + created: 1_742_160_001, + model: 'gpt-4.1', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: 'hello from gemini cli downstream', + }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 6, + completion_tokens: 5, + total_tokens: 11, + }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1internal:generateContent', + headers: { + authorization: 'Bearer sk-managed-gemini', + }, + payload: { + model: 'gpt-4.1', + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(targetUrl).toBe('https://api.openai.com/v1/chat/completions'); + expect(JSON.parse(String(requestInit.body))).toEqual({ + model: 'gpt-4.1', + stream: false, + messages: [ + { + role: 'user', + content: 'hello', + }, + ], + }); + expect(response.json()).toEqual({ + response: { + responseId: 'chatcmpl-openai-2', + modelVersion: 'gpt-4.1', + candidates: [ + { + index: 0, + content: { + role: 'model', + parts: [{ text: 'hello from gemini cli downstream' }], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 6, + candidatesTokenCount: 5, + totalTokenCount: 11, + }, + }, + }); + }); + + it('routes Gemini native generateContent requests to antigravity upstreams through the internal content endpoint', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 43, routeId: 22 }, + site: { id: 79, name: 'antigravity-site', url: 'https://cloudcode-pa.googleapis.com', platform: 'antigravity' }, + account: { + id: 39, + username: 'antigravity-user@example.com', + extraConfig: JSON.stringify({ + oauth: { + provider: 'antigravity', + email: 'antigravity-user@example.com', + projectId: 'project-demo', + }, + }), + }, + tokenName: 'default', + tokenValue: 'antigravity-access-token', + actualModel: 'gemini-3-pro-preview', + }); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + response: { + responseId: 'antigravity-response-1', + modelVersion: 'gemini-3-pro-preview', + candidates: [ + { + content: { + role: 'model', + parts: [{ text: 'hello from antigravity' }], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 8, + candidatesTokenCount: 4, + totalTokenCount: 12, + }, + }, + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1beta/models/gemini-3-pro-preview:generateContent', + headers: { + authorization: 'Bearer sk-managed-gemini', + }, + payload: { + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(targetUrl).toBe('https://cloudcode-pa.googleapis.com/v1internal:generateContent'); + expect(requestInit.headers).toMatchObject({ + Authorization: 'Bearer antigravity-access-token', + 'Content-Type': 'application/json', + }); + expect(JSON.parse(String(requestInit.body))).toEqual({ + project: 'project-demo', + model: 'gemini-3-pro-preview', + request: { + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + expect(response.json()).toEqual({ + responseId: 'antigravity-response-1', + modelVersion: 'gemini-3-pro-preview', + candidates: [ + { + index: 0, + content: { + role: 'model', + parts: [{ text: 'hello from antigravity' }], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 8, + candidatesTokenCount: 4, + totalTokenCount: 12, + }, + }); + }); + + it('exposes GeminiCLI downstream streamGenerateContent endpoint and preserves GeminiCLI response envelopes', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 44, routeId: 22 }, + site: { id: 80, name: 'gemini-cli-site', url: 'https://cloudcode-pa.googleapis.com', platform: 'gemini-cli' }, + account: { + id: 40, + username: 'gemini-cli-user@example.com', + extraConfig: JSON.stringify({ + oauth: { + provider: 'gemini-cli', + email: 'gemini-cli-user@example.com', + projectId: 'project-demo', + }, + }), + }, + tokenName: 'default', + tokenValue: 'gemini-cli-access-token', + actualModel: 'gemini-2.5-pro', + }); + + const encoder = new TextEncoder(); + const upstreamBody = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"response":{"responseId":"cli-stream-1","candidates":[{"content":{"role":"model","parts":[{"text":"hello from cli stream"}]},"finishReason":"STOP"}]}}\n\n')); + controller.enqueue(encoder.encode('data: [DONE]\n\n')); + controller.close(); + }, + }); + + fetchMock.mockResolvedValue(new Response(upstreamBody, { + status: 200, + headers: { 'content-type': 'text/event-stream; charset=utf-8' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1internal:streamGenerateContent', + headers: { + authorization: 'Bearer sk-managed-gemini', + }, + payload: { + model: 'gemini-2.5-pro', + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.headers['content-type']).toContain('text/event-stream'); + expect(response.body).toContain('data: {"response":{"responseId":"cli-stream-1"'); + expect(response.body).toContain('hello from cli stream'); + }); + + it('exposes GeminiCLI downstream countTokens endpoint', async () => { + selectChannelMock.mockReturnValue({ + channel: { id: 45, routeId: 22 }, + site: { id: 81, name: 'gemini-cli-site', url: 'https://cloudcode-pa.googleapis.com', platform: 'gemini-cli' }, + account: { + id: 41, + username: 'gemini-cli-user@example.com', + extraConfig: JSON.stringify({ + oauth: { + provider: 'gemini-cli', + email: 'gemini-cli-user@example.com', + projectId: 'project-demo', + }, + }), + }, + tokenName: 'default', + tokenValue: 'gemini-cli-access-token', + actualModel: 'gemini-2.5-pro', + }); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ + totalTokens: 13, + promptTokensDetails: [ + { + modality: 'TEXT', + tokenCount: 13, + }, + ], + }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })); + + const response = await app.inject({ + method: 'POST', + url: '/v1internal:countTokens', + headers: { + authorization: 'Bearer sk-managed-gemini', + }, + payload: { + model: 'gemini-2.5-pro', + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + + expect(response.statusCode).toBe(200); + const [targetUrl, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(targetUrl).toBe('https://cloudcode-pa.googleapis.com/v1internal:countTokens'); + expect(JSON.parse(String(requestInit.body))).toEqual({ + request: { + contents: [ + { + role: 'user', + parts: [{ text: 'hello' }], + }, + ], + }, + }); + expect(response.json()).toEqual({ + totalTokens: 13, + promptTokensDetails: [ + { + modality: 'TEXT', + tokenCount: 13, + }, + ], + }); + }); + it('filters Gemini native model list by downstream managed-key policy', async () => { authorizeDownstreamTokenMock.mockResolvedValue({ ok: true, diff --git a/src/server/routes/proxy/gemini.ts b/src/server/routes/proxy/gemini.ts index 5e02694..c22a79c 100644 --- a/src/server/routes/proxy/gemini.ts +++ b/src/server/routes/proxy/gemini.ts @@ -1,6 +1,7 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { TextDecoder } from 'node:util'; import { fetch } from 'undici'; +import { and, eq } from 'drizzle-orm'; import { db, schema } from '../../db/index.js'; import { formatUtcSqlDateTime } from '../../services/localTimeService.js'; import { parseProxyUsage } from '../../services/proxyUsageParser.js'; @@ -8,11 +9,20 @@ import { isModelAllowedByPolicyOrAllowedRoutes } from '../../services/downstream import { tokenRouter } from '../../services/tokenRouter.js'; import { buildOauthProviderHeaders } from '../../services/oauth/service.js'; import { getOauthInfoFromExtraConfig } from '../../services/oauth/oauthAccount.js'; +import { withSiteProxyRequestInit } from '../../services/siteProxy.js'; +import { refreshModelsAndRebuildRoutes } from '../../services/modelService.js'; import { getDownstreamRoutingPolicy } from './downstreamPolicy.js'; +import { executeEndpointFlow } from './endpointFlow.js'; import { composeProxyLogMessage } from './logPathMeta.js'; +import { + buildUpstreamEndpointRequest, + resolveUpstreamEndpointCandidates, +} from './upstreamEndpoint.js'; import { geminiGenerateContentTransformer, } from '../../transformers/gemini/generate-content/index.js'; +import { createChatEndpointStrategy } from '../../transformers/shared/chatEndpointStrategy.js'; +import { normalizeUpstreamFinalResponse } from '../../transformers/shared/normalized.js'; import { createGeminiCliStreamReader, unwrapGeminiCliPayload, @@ -45,6 +55,14 @@ function isGeminiCliPlatform(platform: unknown): boolean { return String(platform || '').trim().toLowerCase() === 'gemini-cli'; } +function isAntigravityPlatform(platform: unknown): boolean { + return String(platform || '').trim().toLowerCase() === 'antigravity'; +} + +function isInternalGeminiPlatform(platform: unknown): boolean { + return isGeminiCliPlatform(platform) || isAntigravityPlatform(platform); +} + function buildGeminiCliActionPath(input: { isStreamAction: boolean; isCountTokensAction: boolean; @@ -54,6 +72,17 @@ function buildGeminiCliActionPath(input: { return '/v1internal:generateContent'; } +function isDirectGeminiFamilyPlatform(platform: unknown): boolean { + const normalized = String(platform || '').trim().toLowerCase(); + return normalized === 'gemini' || normalized === 'gemini-cli' || normalized === 'antigravity'; +} + +function omitGeminiCliModelField(body: unknown): Record { + if (!body || typeof body !== 'object' || Array.isArray(body)) return {}; + const { model: _model, ...rest } = body as Record; + return rest; +} + async function selectGeminiChannel(request: FastifyRequest) { const policy = getDownstreamRoutingPolicy(request); for (const candidate of GEMINI_MODEL_PROBES) { @@ -128,6 +157,41 @@ async function filterGeminiListedModelsForPolicy( }; } +async function readRouteAwareGeminiModels(request: FastifyRequest): Promise> { + const policy = getDownstreamRoutingPolicy(request); + const rows = await db.select({ modelName: schema.modelAvailability.modelName }) + .from(schema.modelAvailability) + .innerJoin(schema.accounts, eq(schema.modelAvailability.accountId, schema.accounts.id)) + .innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id)) + .where(and( + eq(schema.modelAvailability.available, true), + eq(schema.accounts.status, 'active'), + eq(schema.sites.status, 'active'), + )) + .all(); + const routeAliases = await db.select({ displayName: schema.tokenRoutes.displayName }) + .from(schema.tokenRoutes) + .where(eq(schema.tokenRoutes.enabled, true)) + .all(); + const deduped = Array.from(new Set([ + ...rows.map((row) => String(row.modelName || '').trim()).filter(Boolean), + ...routeAliases.map((row) => String(row.displayName || '').trim()).filter(Boolean), + ])).sort(); + + const allowed: Array<{ name: string; displayName: string }> = []; + for (const modelName of deduped) { + if (!await isModelAllowedByPolicyOrAllowedRoutes(modelName, policy)) continue; + const decision = await tokenRouter.explainSelection?.(modelName, [], policy); + if (decision && typeof decision.selectedChannelId !== 'number') continue; + allowed.push({ + name: `models/${modelName}`, + displayName: modelName, + }); + } + + return allowed; +} + async function logProxy( selected: any, modelRequested: string, @@ -194,6 +258,15 @@ export async function geminiProxyRoute(app: FastifyInstance) { excludeChannelIds.push(selected.channel.id); try { + if (!isDirectGeminiFamilyPlatform(selected.site.platform)) { + let models = await readRouteAwareGeminiModels(request); + if (models.length <= 0) { + await refreshModelsAndRebuildRoutes(); + models = await readRouteAwareGeminiModels(request); + } + return reply.code(200).send({ models }); + } + if (isGeminiCliPlatform(selected.site.platform)) { const filtered = await filterGeminiListedModelsForPolicy( { models: GEMINI_CLI_STATIC_MODELS }, @@ -243,13 +316,36 @@ export async function geminiProxyRoute(app: FastifyInstance) { } }; - const generateContent = async (request: FastifyRequest, reply: FastifyReply) => { - const parsedPath = geminiGenerateContentTransformer.parseProxyRequestPath({ - rawUrl: request.raw.url || request.url || '', - params: request.params as { geminiApiVersion?: string } | undefined, - }); + const handleGenerateContent = async ( + request: FastifyRequest, + reply: FastifyReply, + options?: { + downstreamProtocol?: 'gemini' | 'gemini-cli'; + action?: 'generateContent' | 'streamGenerateContent' | 'countTokens'; + }, + ) => { + const downstreamProtocol = options?.downstreamProtocol || 'gemini'; + const isGeminiCliDownstream = downstreamProtocol === 'gemini-cli'; + const cliRequestedModel = isGeminiCliDownstream + ? (typeof (request.body as Record | null | undefined)?.model === 'string' + ? String((request.body as Record).model).trim() + : '') + : ''; + const parsedPath = isGeminiCliDownstream + ? { + apiVersion: 'v1beta', + modelActionPath: `models/${cliRequestedModel}:${options?.action || 'generateContent'}`, + isStreamAction: options?.action === 'streamGenerateContent', + requestedModel: cliRequestedModel, + } + : geminiGenerateContentTransformer.parseProxyRequestPath({ + rawUrl: request.raw.url || request.url || '', + params: request.params as { geminiApiVersion?: string } | undefined, + }); const { apiVersion, modelActionPath, isStreamAction, requestedModel } = parsedPath; - const isCountTokensAction = modelActionPath.endsWith(':countTokens'); + const isCountTokensAction = isGeminiCliDownstream + ? options?.action === 'countTokens' + : modelActionPath.endsWith(':countTokens'); if (!requestedModel) { return reply.code(400).send({ error: { message: 'Gemini model path is required', type: 'invalid_request_error' }, @@ -274,114 +370,227 @@ export async function geminiProxyRoute(app: FastifyInstance) { excludeChannelIds.push(selected.channel.id); - const body = geminiGenerateContentTransformer.inbound.normalizeRequest( - request.body || {}, - selected.actualModel || requestedModel, + const actualModel = selected.actualModel || requestedModel; + const normalizedBody = geminiGenerateContentTransformer.inbound.normalizeRequest( + isGeminiCliDownstream ? omitGeminiCliModelField(request.body) : (request.body || {}), + actualModel, ); const oauth = getOauthInfoFromExtraConfig(selected.account.extraConfig); const isGeminiCli = isGeminiCliPlatform(selected.site.platform); - if (isGeminiCli && !oauth?.projectId) { - lastStatus = 500; - lastContentType = 'application/json'; - lastText = JSON.stringify({ - error: { - message: 'Gemini CLI OAuth project is missing', - type: 'server_error', - }, - }); - await tokenRouter.recordFailure?.(selected.channel.id); - if (retryCount < MAX_RETRIES) { - retryCount += 1; - continue; - } - return reply.code(lastStatus).type(lastContentType).send(lastText); - } - const actualModelAction = modelActionPath.replace( - /^models\/[^:]+/, - `models/${selected.actualModel || requestedModel}`, - ); - const upstreamPath = isGeminiCli - ? buildGeminiCliActionPath({ isStreamAction, isCountTokensAction }) - : resolveUpstreamPath(apiVersion, actualModelAction); - const query = new URLSearchParams(request.query as Record).toString(); - const requestBody = isGeminiCli - ? ( - isCountTokensAction - ? { request: body } - : wrapGeminiCliRequest({ - modelName: selected.actualModel || requestedModel, - projectId: oauth?.projectId || '', - request: body as Record, - }) - ) - : body; - const requestHeaders = isGeminiCli - ? { - 'Content-Type': 'application/json', - ...(isStreamAction ? { Accept: 'text/event-stream' } : {}), - Authorization: `Bearer ${selected.tokenValue}`, - ...buildOauthProviderHeaders({ - extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null, - downstreamHeaders: request.headers as Record, - }), - } - : { - 'Content-Type': 'application/json', - }; + const isInternalGemini = isInternalGeminiPlatform(selected.site.platform); + const isDirectGeminiFamily = isDirectGeminiFamilyPlatform(selected.site.platform); const startTime = Date.now(); - try { - const targetUrl = isGeminiCli - ? `${selected.site.url}${upstreamPath}` - : geminiGenerateContentTransformer.resolveActionUrl( - selected.site.url, - apiVersion, - actualModelAction, - selected.tokenValue, - query, - ); - const upstream = await fetch(targetUrl, { - method: 'POST', - headers: requestHeaders, - body: JSON.stringify(requestBody), - }); - const contentType = upstream.headers.get('content-type') || 'application/json'; - if (!upstream.ok) { - lastStatus = upstream.status; - lastContentType = contentType; - lastText = await upstream.text(); - await tokenRouter.recordFailure?.(selected.channel.id); - await logProxy( - selected, - requestedModel, - 'failed', - lastStatus, - Date.now() - startTime, - lastText, - retryCount, - downstreamPath, - upstreamPath, - ); - if (retryCount < MAX_RETRIES) { - retryCount += 1; - continue; - } + let upstreamPath = ''; - try { - return reply.code(lastStatus).send(JSON.parse(lastText)); - } catch { + try { + if (isDirectGeminiFamily) { + if (isGeminiCli && !oauth?.projectId) { + lastStatus = 500; + lastContentType = 'application/json'; + lastText = JSON.stringify({ + error: { + message: 'Gemini CLI OAuth project is missing', + type: 'server_error', + }, + }); + await tokenRouter.recordFailure?.(selected.channel.id); + if (retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } return reply.code(lastStatus).type(lastContentType).send(lastText); } - } - if (geminiGenerateContentTransformer.stream.isSseContentType(contentType)) { - reply.hijack(); - reply.raw.statusCode = upstream.status; - reply.raw.setHeader('Content-Type', contentType || 'text/event-stream'); - const upstreamReader = upstream.body?.getReader(); - const reader = isGeminiCliPlatform(selected.site.platform) && upstreamReader - ? createGeminiCliStreamReader(upstreamReader) - : upstreamReader; - if (!reader) { + const actualModelAction = modelActionPath.replace( + /^models\/[^:]+/, + `models/${actualModel}`, + ); + upstreamPath = isInternalGemini + ? buildGeminiCliActionPath({ isStreamAction, isCountTokensAction }) + : resolveUpstreamPath(apiVersion, actualModelAction); + const query = new URLSearchParams(request.query as Record).toString(); + const requestBody = isInternalGemini + ? ( + isCountTokensAction + ? { request: normalizedBody } + : wrapGeminiCliRequest({ + modelName: actualModel, + projectId: oauth?.projectId || '', + request: normalizedBody as Record, + }) + ) + : normalizedBody; + const requestHeaders = isInternalGemini + ? { + 'Content-Type': 'application/json', + ...(isStreamAction ? { Accept: 'text/event-stream' } : {}), + Authorization: `Bearer ${selected.tokenValue}`, + ...buildOauthProviderHeaders({ + extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null, + downstreamHeaders: request.headers as Record, + }), + } + : { + 'Content-Type': 'application/json', + }; + const targetUrl = isInternalGemini + ? `${selected.site.url}${upstreamPath}` + : geminiGenerateContentTransformer.resolveActionUrl( + selected.site.url, + apiVersion, + actualModelAction, + selected.tokenValue, + query, + ); + const upstream = await fetch(targetUrl, { + method: 'POST', + headers: requestHeaders, + body: JSON.stringify(requestBody), + }); + const contentType = upstream.headers.get('content-type') || 'application/json'; + if (!upstream.ok) { + lastStatus = upstream.status; + lastContentType = contentType; + lastText = await upstream.text(); + await tokenRouter.recordFailure?.(selected.channel.id); + await logProxy( + selected, + requestedModel, + 'failed', + lastStatus, + Date.now() - startTime, + lastText, + retryCount, + downstreamPath, + upstreamPath, + ); + if (retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + + try { + return reply.code(lastStatus).send(JSON.parse(lastText)); + } catch { + return reply.code(lastStatus).type(lastContentType).send(lastText); + } + } + + if (geminiGenerateContentTransformer.stream.isSseContentType(contentType)) { + reply.hijack(); + reply.raw.statusCode = upstream.status; + reply.raw.setHeader('Content-Type', contentType || 'text/event-stream'); + const upstreamReader = upstream.body?.getReader(); + const reader = isInternalGemini && !isGeminiCliDownstream && upstreamReader + ? createGeminiCliStreamReader(upstreamReader) + : upstreamReader; + if (!reader) { + const latency = Date.now() - startTime; + await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0); + await logProxy( + selected, + requestedModel, + 'success', + upstream.status, + latency, + null, + retryCount, + downstreamPath, + upstreamPath, + ); + reply.raw.end(); + return; + } + const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState(); + const decoder = new TextDecoder(); + let rest = ''; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + const chunkText = decoder.decode(value, { stream: true }); + const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer( + aggregateState, + rest + chunkText, + ); + rest = consumed.rest; + for (const line of consumed.lines) { + reply.raw.write(line); + } + } + const tail = decoder.decode(); + if (tail) { + const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer( + aggregateState, + rest + tail, + ); + for (const line of consumed.lines) { + reply.raw.write(line); + } + } + } finally { + reader.releaseLock(); + reply.raw.end(); + } + const parsedUsage = parseProxyUsage(aggregateState); + const latency = Date.now() - startTime; + await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0); + await logProxy( + selected, + requestedModel, + 'success', + upstream.status, + latency, + null, + retryCount, + downstreamPath, + upstreamPath, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + ); + return; + } + + const text = await upstream.text(); + const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState(); + let parsedUsage = EMPTY_PROXY_USAGE; + try { + const parsed = JSON.parse(text); + const unwrappedPayload = isInternalGemini && !isGeminiCliDownstream + ? unwrapGeminiCliPayload(parsed) + : parsed; + const responsePayload = isCountTokensAction + ? unwrappedPayload + : geminiGenerateContentTransformer.stream.serializeUpstreamJsonPayload( + aggregateState, + unwrappedPayload, + isStreamAction, + ); + parsedUsage = parseProxyUsage(aggregateState); + const latency = Date.now() - startTime; + await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0); + await logProxy( + selected, + requestedModel, + 'success', + upstream.status, + latency, + null, + retryCount, + downstreamPath, + upstreamPath, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + ); + return reply.code(upstream.status).send( + isGeminiCliDownstream && !isCountTokensAction + ? { response: responsePayload } + : responsePayload, + ); + } catch { const latency = Date.now() - startTime; await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0); await logProxy( @@ -395,110 +604,158 @@ export async function geminiProxyRoute(app: FastifyInstance) { downstreamPath, upstreamPath, ); - reply.raw.end(); - return; + return reply.code(upstream.status).type(contentType || 'application/json').send(text); } - const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState(); - const decoder = new TextDecoder(); - let rest = ''; - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - if (!value) continue; - const chunkText = decoder.decode(value, { stream: true }); - const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer( - aggregateState, - rest + chunkText, - ); - rest = consumed.rest; - for (const line of consumed.lines) { - reply.raw.write(line); - } - } - const tail = decoder.decode(); - if (tail) { - const consumed = geminiGenerateContentTransformer.stream.consumeUpstreamSseBuffer( - aggregateState, - rest + tail, - ); - for (const line of consumed.lines) { - reply.raw.write(line); - } - } - } finally { - reader.releaseLock(); - reply.raw.end(); - } - const parsedUsage = parseProxyUsage(aggregateState); - const latency = Date.now() - startTime; - await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0); - await logProxy( - selected, - requestedModel, - 'success', - upstream.status, - latency, - null, - retryCount, - downstreamPath, - upstreamPath, - parsedUsage.promptTokens, - parsedUsage.completionTokens, - parsedUsage.totalTokens, - ); - return; } - const text = await upstream.text(); - const aggregateState = geminiGenerateContentTransformer.stream.createAggregateState(); - let parsedUsage = EMPTY_PROXY_USAGE; - try { - const parsed = JSON.parse(text); - const unwrappedPayload = unwrapGeminiCliPayload(parsed); - const responsePayload = isCountTokensAction - ? unwrappedPayload - : geminiGenerateContentTransformer.stream.serializeUpstreamJsonPayload( - aggregateState, - unwrappedPayload, - isStreamAction, - ); - parsedUsage = parseProxyUsage(aggregateState); - const latency = Date.now() - startTime; - await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0); - await logProxy( - selected, - requestedModel, - 'success', - upstream.status, - latency, - null, - retryCount, - downstreamPath, - upstreamPath, - parsedUsage.promptTokens, - parsedUsage.completionTokens, - parsedUsage.totalTokens, - ); - return reply.code(upstream.status).send( - responsePayload, - ); - } catch { - const latency = Date.now() - startTime; - await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0); - await logProxy( - selected, - requestedModel, - 'success', - upstream.status, - latency, - null, - retryCount, - downstreamPath, - upstreamPath, - ); - return reply.code(upstream.status).type(contentType || 'application/json').send(text); + if (isCountTokensAction) { + lastStatus = 501; + lastContentType = 'application/json'; + lastText = JSON.stringify({ + error: { + message: 'Gemini countTokens compatibility is not implemented for this upstream', + type: 'invalid_request_error', + }, + }); + return reply.code(lastStatus).type(lastContentType).send(lastText); } + + const openAiBody = geminiGenerateContentTransformer.compatibility.buildOpenAiBodyFromGeminiRequest({ + body: normalizedBody as Record, + modelName: actualModel, + stream: isStreamAction, + }); + const endpointCandidates = await resolveUpstreamEndpointCandidates( + { + site: selected.site, + account: selected.account, + }, + actualModel, + 'openai', + requestedModel, + ); + const buildEndpointRequest = ( + endpoint: 'chat' | 'messages' | 'responses', + requestOptions: { forceNormalizeClaudeBody?: boolean } = {}, + ) => { + const endpointRequest = buildUpstreamEndpointRequest({ + endpoint, + modelName: actualModel, + stream: isStreamAction, + tokenValue: selected.tokenValue, + oauthProvider: oauth?.provider, + oauthProjectId: oauth?.projectId, + sitePlatform: selected.site.platform, + siteUrl: selected.site.url, + openaiBody: openAiBody, + downstreamFormat: 'openai', + forceNormalizeClaudeBody: requestOptions.forceNormalizeClaudeBody, + downstreamHeaders: request.headers as Record, + providerHeaders: buildOauthProviderHeaders({ + extraConfig: typeof selected.account.extraConfig === 'string' ? selected.account.extraConfig : null, + downstreamHeaders: request.headers as Record, + }), + }); + return { + endpoint, + path: endpointRequest.path, + headers: endpointRequest.headers, + body: endpointRequest.body as Record, + }; + }; + const endpointStrategy = createChatEndpointStrategy({ + downstreamFormat: 'openai', + endpointCandidates, + modelName: actualModel, + requestedModelHint: requestedModel, + sitePlatform: selected.site.platform, + isStream: isStreamAction, + buildRequest: ({ endpoint, forceNormalizeClaudeBody }) => buildEndpointRequest( + endpoint, + { forceNormalizeClaudeBody }, + ), + dispatchRequest: async (compatibilityRequest, targetUrl) => fetch( + targetUrl ?? `${selected.site.url}${compatibilityRequest.path}`, + await withSiteProxyRequestInit(selected.site.url, { + method: 'POST', + headers: compatibilityRequest.headers, + body: JSON.stringify(compatibilityRequest.body), + }), + ), + }); + const endpointResult = await executeEndpointFlow({ + siteUrl: selected.site.url, + endpointCandidates, + buildRequest: (endpoint) => buildEndpointRequest(endpoint), + tryRecover: endpointStrategy.tryRecover, + shouldDowngrade: endpointStrategy.shouldDowngrade, + }); + if (!endpointResult.ok) { + lastStatus = endpointResult.status; + lastContentType = 'application/json'; + lastText = JSON.stringify({ + error: { + message: endpointResult.errText, + type: 'upstream_error', + }, + }); + await tokenRouter.recordFailure?.(selected.channel.id); + await logProxy( + selected, + requestedModel, + 'failed', + lastStatus, + Date.now() - startTime, + endpointResult.errText, + retryCount, + downstreamPath, + null, + ); + if (retryCount < MAX_RETRIES) { + retryCount += 1; + continue; + } + return reply.code(lastStatus).type(lastContentType).send(lastText); + } + + upstreamPath = endpointResult.upstreamPath; + const upstream = endpointResult.upstream; + const rawText = await upstream.text(); + let upstreamData: unknown = rawText; + try { + upstreamData = JSON.parse(rawText); + } catch {} + const parsedUsage = parseProxyUsage(upstreamData); + const normalizedFinal = normalizeUpstreamFinalResponse(upstreamData, actualModel, rawText); + const geminiResponse = geminiGenerateContentTransformer.compatibility.serializeNormalizedFinalToGemini({ + normalized: normalizedFinal, + usage: { + promptTokens: parsedUsage.promptTokens, + completionTokens: parsedUsage.completionTokens, + totalTokens: parsedUsage.totalTokens, + }, + }); + const latency = Date.now() - startTime; + await tokenRouter.recordSuccess?.(selected.channel.id, latency, 0); + await logProxy( + selected, + requestedModel, + 'success', + upstream.status, + latency, + null, + retryCount, + downstreamPath, + upstreamPath, + parsedUsage.promptTokens, + parsedUsage.completionTokens, + parsedUsage.totalTokens, + ); + return reply.code(upstream.status).send( + isGeminiCliDownstream + ? { response: geminiResponse } + : geminiResponse, + ); } catch (error) { lastStatus = 502; lastContentType = 'application/json'; @@ -518,7 +775,7 @@ export async function geminiProxyRoute(app: FastifyInstance) { error instanceof Error ? error.message : 'Gemini upstream request failed', retryCount, downstreamPath, - upstreamPath, + upstreamPath || null, ); if (retryCount < MAX_RETRIES) { retryCount += 1; @@ -529,8 +786,25 @@ export async function geminiProxyRoute(app: FastifyInstance) { } }; + const generateContent = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply); + const geminiCliGenerateContent = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply, { + downstreamProtocol: 'gemini-cli', + action: 'generateContent', + }); + const geminiCliStreamGenerateContent = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply, { + downstreamProtocol: 'gemini-cli', + action: 'streamGenerateContent', + }); + const geminiCliCountTokens = async (request: FastifyRequest, reply: FastifyReply) => handleGenerateContent(request, reply, { + downstreamProtocol: 'gemini-cli', + action: 'countTokens', + }); + app.get('/v1beta/models', listModels); app.get('/gemini/:geminiApiVersion/models', listModels); app.post('/v1beta/models/*', generateContent); app.post('/gemini/:geminiApiVersion/models/*', generateContent); + app.post('/v1internal::generateContent', geminiCliGenerateContent); + app.post('/v1internal::streamGenerateContent', geminiCliStreamGenerateContent); + app.post('/v1internal::countTokens', geminiCliCountTokens); } diff --git a/src/server/routes/proxy/upstreamEndpoint.test.ts b/src/server/routes/proxy/upstreamEndpoint.test.ts index c2fc1f3..980e82c 100644 --- a/src/server/routes/proxy/upstreamEndpoint.test.ts +++ b/src/server/routes/proxy/upstreamEndpoint.test.ts @@ -415,6 +415,52 @@ describe('buildUpstreamEndpointRequest', () => { }); }); + it('builds antigravity native requests with the same internal Gemini envelope', () => { + const request = buildUpstreamEndpointRequest({ + endpoint: 'chat', + modelName: 'gemini-3-pro-preview', + stream: false, + tokenValue: 'oauth-access-token', + oauthProvider: 'antigravity', + oauthProjectId: 'project-demo', + sitePlatform: 'antigravity', + siteUrl: 'https://cloudcode-pa.googleapis.com', + openaiBody: { + model: 'gemini-3-pro-preview', + messages: [ + { role: 'system', content: 'be concise' }, + { role: 'user', content: 'hello antigravity' }, + ], + }, + downstreamFormat: 'openai', + providerHeaders: { + 'User-Agent': 'google-api-nodejs-client/9.15.1', + 'X-Goog-Api-Client': 'google-cloud-sdk vscode_cloudshelleditor/0.1', + }, + }); + + expect(request.path).toBe('/v1internal:generateContent'); + expect(request.headers.Authorization).toBe('Bearer oauth-access-token'); + expect(request.headers['User-Agent']).toContain('google-api-nodejs-client'); + expect(request.headers['X-Goog-Api-Client']).toContain('google-cloud-sdk'); + expect(request.body).toEqual({ + project: 'project-demo', + model: 'gemini-3-pro-preview', + request: { + systemInstruction: { + role: 'user', + parts: [{ text: 'be concise' }], + }, + contents: [ + { + role: 'user', + parts: [{ text: 'hello antigravity' }], + }, + ], + }, + }); + }); + it('uses bearer auth for claude oauth upstream requests', () => { const request = buildUpstreamEndpointRequest({ endpoint: 'messages', diff --git a/src/server/routes/proxy/upstreamEndpoint.ts b/src/server/routes/proxy/upstreamEndpoint.ts index f3e0494..01c9180 100644 --- a/src/server/routes/proxy/upstreamEndpoint.ts +++ b/src/server/routes/proxy/upstreamEndpoint.ts @@ -332,6 +332,10 @@ function preferredEndpointOrder( return ['chat']; } + if (platform === 'antigravity') { + return ['chat']; + } + if (platform === 'openai') { if (preferMessagesForClaudeModel && downstreamFormat !== 'responses') { // Some OpenAI-compatible gateways expose Claude natively via /v1/messages. @@ -409,6 +413,7 @@ export async function resolveUpstreamEndpointCandidates( ? (() => { if (sitePlatform === 'claude') return ['messages'] as UpstreamEndpoint[]; if (sitePlatform === 'gemini') return ['responses', 'chat'] as UpstreamEndpoint[]; + if (sitePlatform === 'gemini-cli' || sitePlatform === 'antigravity') return ['chat'] as UpstreamEndpoint[]; if (preferMessagesForClaudeModel) return ['messages', 'responses', 'chat'] as UpstreamEndpoint[]; return ['responses', 'messages', 'chat'] as UpstreamEndpoint[]; })() @@ -428,6 +433,8 @@ export async function resolveUpstreamEndpointCandidates( && preferMessagesForClaudeModel && sitePlatform !== 'openai' && sitePlatform !== 'gemini' + && sitePlatform !== 'antigravity' + && sitePlatform !== 'gemini-cli' ); try { @@ -532,6 +539,8 @@ export function buildUpstreamEndpointRequest(input: { const isClaudeUpstream = sitePlatform === 'claude'; const isGeminiUpstream = sitePlatform === 'gemini'; const isGeminiCliUpstream = sitePlatform === 'gemini-cli'; + const isAntigravityUpstream = sitePlatform === 'antigravity'; + const isInternalGeminiUpstream = isGeminiCliUpstream || isAntigravityUpstream; const isClaudeOauthUpstream = isClaudeUpstream && input.oauthProvider === 'claude'; const resolveGeminiEndpointPath = (endpoint: UpstreamEndpoint): string => { @@ -562,7 +571,7 @@ export function buildUpstreamEndpointRequest(input: { return '/responses'; } - if (sitePlatform === 'gemini-cli') { + if (sitePlatform === 'gemini-cli' || sitePlatform === 'antigravity') { return input.stream ? '/v1internal:streamGenerateContent?alt=sse' : '/v1internal:generateContent'; @@ -589,7 +598,7 @@ export function buildUpstreamEndpointRequest(input: { const stripGeminiUnsupportedFields = (body: Record) => { const next = { ...body }; - if (isGeminiUpstream || isGeminiCliUpstream) { + if (isGeminiUpstream || isInternalGeminiUpstream) { for (const key of [ 'frequency_penalty', 'presence_penalty', @@ -606,9 +615,9 @@ export function buildUpstreamEndpointRequest(input: { const openaiBody = stripGeminiUnsupportedFields(input.openaiBody); - if (isGeminiCliUpstream) { + if (isInternalGeminiUpstream) { const projectId = asTrimmedString(input.oauthProjectId); - if (!projectId) { + if (isGeminiCliUpstream && !projectId) { throw new Error('gemini-cli oauth project id missing'); } const instructions = ( @@ -628,7 +637,7 @@ export function buildUpstreamEndpointRequest(input: { headers, body: wrapGeminiCliRequest({ modelName: input.modelName, - projectId, + projectId: projectId || '', request: geminiRequest, }) as Record, }; diff --git a/src/server/services/modelService.discovery.test.ts b/src/server/services/modelService.discovery.test.ts index 0a32805..8300658 100644 --- a/src/server/services/modelService.discovery.test.ts +++ b/src/server/services/modelService.discovery.test.ts @@ -481,4 +481,63 @@ describe('refreshModelsForAccount credential discovery', () => { expect(parsed.oauth.lastModelSyncAt).toMatch(/\d{4}-\d{2}-\d{2}T/); expect(parsed.runtimeHealth?.state).toBe('unhealthy'); }); + + it('discovers antigravity oauth models via direct credential routing', async () => { + getApiTokenMock.mockResolvedValue(null); + getModelsMock.mockImplementation(async (_baseUrl: string, token: string) => ( + token === 'antigravity-access-token' + ? ['gemini-3-pro-preview', 'claude-sonnet-4-5-20250929'] + : [] + )); + + const site = await db.insert(schema.sites).values({ + name: 'antigravity-site', + url: 'https://cloudcode-pa.googleapis.com', + platform: 'antigravity', + status: 'active', + }).returning().get(); + + const account = await db.insert(schema.accounts).values({ + siteId: site.id, + username: 'antigravity-user@example.com', + accessToken: 'antigravity-access-token', + apiToken: null, + status: 'active', + extraConfig: JSON.stringify({ + credentialMode: 'session', + oauth: { + provider: 'antigravity', + email: 'antigravity-user@example.com', + projectId: 'project-demo', + }, + }), + }).returning().get(); + + const result = await refreshModelsForAccount(account.id); + + expect(result).toMatchObject({ + accountId: account.id, + refreshed: true, + status: 'success', + errorCode: null, + tokenScanned: 0, + discoveredByCredential: true, + discoveredApiToken: false, + modelCount: 2, + modelsPreview: ['gemini-3-pro-preview', 'claude-sonnet-4-5-20250929'], + }); + expect(getModelsMock).toHaveBeenCalledWith( + 'https://cloudcode-pa.googleapis.com', + 'antigravity-access-token', + undefined, + ); + + const rows = await db.select().from(schema.modelAvailability) + .where(eq(schema.modelAvailability.accountId, account.id)) + .all(); + expect(rows.map((row) => row.modelName).sort()).toEqual([ + 'claude-sonnet-4-5-20250929', + 'gemini-3-pro-preview', + ]); + }); }); diff --git a/src/server/services/oauth/antigravityProvider.ts b/src/server/services/oauth/antigravityProvider.ts new file mode 100644 index 0000000..3dd6b19 --- /dev/null +++ b/src/server/services/oauth/antigravityProvider.ts @@ -0,0 +1,205 @@ +import { fetch } from 'undici'; +import type { OAuthProviderDefinition } from './providers.js'; + +export const ANTIGRAVITY_OAUTH_PROVIDER = 'antigravity'; +export const ANTIGRAVITY_AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth'; +export const ANTIGRAVITY_TOKEN_URL = 'https://oauth2.googleapis.com/token'; +export const ANTIGRAVITY_USERINFO_URL = 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json'; +export const ANTIGRAVITY_CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com'; +export const ANTIGRAVITY_CLIENT_SECRET = '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'; +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_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"}'; + +const ANTIGRAVITY_SCOPES = [ + 'https://www.googleapis.com/auth/cloud-platform', + 'https://www.googleapis.com/auth/userinfo.email', + 'https://www.googleapis.com/auth/userinfo.profile', + 'https://www.googleapis.com/auth/cclog', + 'https://www.googleapis.com/auth/experimentsandconfigs', +]; + +type AntigravityOAuthTokenPayload = { + access_token?: unknown; + refresh_token?: unknown; + token_type?: unknown; + expires_in?: unknown; + scope?: unknown; + expiry?: unknown; +}; + +function asTrimmedString(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed || undefined; +} + +function parseExpiresAt(payload: AntigravityOAuthTokenPayload): number | undefined { + if (typeof payload.expires_in === 'number' && Number.isFinite(payload.expires_in) && payload.expires_in > 0) { + return Date.now() + Math.trunc(payload.expires_in) * 1000; + } + if (typeof payload.expires_in === 'string') { + const parsed = Number.parseInt(payload.expires_in.trim(), 10); + if (Number.isFinite(parsed) && parsed > 0) { + return Date.now() + parsed * 1000; + } + } + if (typeof payload.expiry === 'string') { + const parsed = Date.parse(payload.expiry); + if (!Number.isNaN(parsed)) return parsed; + } + return undefined; +} + +async function postAntigravityToken(body: URLSearchParams) { + const response = await fetch(ANTIGRAVITY_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Accept: 'application/json', + }, + body: body.toString(), + }); + if (!response.ok) { + const text = await response.text().catch(() => ''); + throw new Error(text || `antigravity token exchange failed with status ${response.status}`); + } + const payload = await response.json() as AntigravityOAuthTokenPayload; + const accessToken = asTrimmedString(payload.access_token); + if (!accessToken) { + throw new Error('antigravity token exchange response missing access token'); + } + return { + accessToken, + refreshToken: asTrimmedString(payload.refresh_token), + tokenExpiresAt: parseExpiresAt(payload), + providerData: { + tokenType: asTrimmedString(payload.token_type), + scope: asTrimmedString(payload.scope), + }, + }; +} + +async function fetchAntigravityUserEmail(accessToken: string): Promise { + const response = await fetch(ANTIGRAVITY_USERINFO_URL, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }); + if (!response.ok) return undefined; + const payload = await response.json() as { email?: unknown }; + return asTrimmedString(payload.email); +} + +async function fetchAntigravityProjectId(accessToken: string): Promise { + const response = await fetch(`${ANTIGRAVITY_UPSTREAM_BASE_URL}/v1internal:loadCodeAssist`, { + 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({ + metadata: { + ideType: 'ANTIGRAVITY', + platform: 'PLATFORM_UNSPECIFIED', + pluginType: 'GEMINI', + }, + }), + }); + if (!response.ok) return undefined; + const payload = await response.json() as { + cloudaicompanionProject?: unknown; + }; + if (typeof payload.cloudaicompanionProject === 'string') { + return asTrimmedString(payload.cloudaicompanionProject); + } + if (payload.cloudaicompanionProject && typeof payload.cloudaicompanionProject === 'object') { + return asTrimmedString((payload.cloudaicompanionProject as { id?: unknown }).id); + } + return undefined; +} + +export const antigravityOauthProvider: OAuthProviderDefinition = { + metadata: { + provider: ANTIGRAVITY_OAUTH_PROVIDER, + label: 'Antigravity', + platform: 'antigravity', + enabled: true, + loginType: 'oauth', + requiresProjectId: false, + supportsDirectAccountRouting: true, + supportsCloudValidation: true, + supportsNativeProxy: false, + }, + site: { + name: 'Google Antigravity OAuth', + url: ANTIGRAVITY_UPSTREAM_BASE_URL, + platform: 'antigravity', + }, + loopback: { + host: '127.0.0.1', + port: ANTIGRAVITY_LOOPBACK_CALLBACK_PORT, + path: ANTIGRAVITY_LOOPBACK_CALLBACK_PATH, + redirectUri: ANTIGRAVITY_LOOPBACK_REDIRECT_URI, + }, + buildAuthorizationUrl: async ({ state, redirectUri }) => { + const params = new URLSearchParams({ + client_id: ANTIGRAVITY_CLIENT_ID, + redirect_uri: redirectUri, + response_type: 'code', + access_type: 'offline', + prompt: 'consent', + scope: ANTIGRAVITY_SCOPES.join(' '), + state, + }); + return `${ANTIGRAVITY_AUTH_URL}?${params.toString()}`; + }, + exchangeAuthorizationCode: async ({ code, redirectUri }) => { + const token = await postAntigravityToken(new URLSearchParams({ + code, + client_id: ANTIGRAVITY_CLIENT_ID, + client_secret: ANTIGRAVITY_CLIENT_SECRET, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + })); + const email = await fetchAntigravityUserEmail(token.accessToken); + const projectId = await fetchAntigravityProjectId(token.accessToken); + return { + ...token, + email, + accountKey: email, + accountId: email, + projectId, + }; + }, + refreshAccessToken: async ({ refreshToken, oauth }) => { + const token = await postAntigravityToken(new URLSearchParams({ + client_id: ANTIGRAVITY_CLIENT_ID, + client_secret: ANTIGRAVITY_CLIENT_SECRET, + grant_type: 'refresh_token', + refresh_token: refreshToken, + })); + const email = await fetchAntigravityUserEmail(token.accessToken); + return { + ...token, + email, + accountKey: email, + accountId: email, + projectId: oauth?.projectId ?? await fetchAntigravityProjectId(token.accessToken), + }; + }, + buildProxyHeaders: () => ({ + 'User-Agent': ANTIGRAVITY_USER_AGENT, + 'X-Goog-Api-Client': ANTIGRAVITY_GOOGLE_API_CLIENT, + 'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA, + }), +}; diff --git a/src/server/services/oauth/providers.ts b/src/server/services/oauth/providers.ts index 4e2978a..b04d866 100644 --- a/src/server/services/oauth/providers.ts +++ b/src/server/services/oauth/providers.ts @@ -1,8 +1,9 @@ import { codexOauthProvider } from './codexProvider.js'; import { claudeOauthProvider } from './claudeProvider.js'; import { geminiCliOauthProvider } from './geminiCliProvider.js'; +import { antigravityOauthProvider } from './antigravityProvider.js'; -export type OAuthProviderId = 'codex' | 'claude' | 'gemini-cli'; +export type OAuthProviderId = 'codex' | 'claude' | 'gemini-cli' | 'antigravity'; export type OAuthProviderMetadata = { provider: OAuthProviderId; @@ -85,6 +86,7 @@ const PROVIDERS: OAuthProviderDefinition[] = [ codexOauthProvider, claudeOauthProvider, geminiCliOauthProvider, + antigravityOauthProvider, ]; const PROVIDER_BY_ID = new Map(PROVIDERS.map((provider) => [provider.metadata.provider, provider] as const)); diff --git a/src/server/services/platforms/antigravity.ts b/src/server/services/platforms/antigravity.ts new file mode 100644 index 0000000..7198b3e --- /dev/null +++ b/src/server/services/platforms/antigravity.ts @@ -0,0 +1,79 @@ +import { BasePlatformAdapter, type BalanceInfo, type CheckinResult, type UserInfo } from './base.js'; +import { + ANTIGRAVITY_CLIENT_METADATA, + ANTIGRAVITY_GOOGLE_API_CLIENT, + ANTIGRAVITY_UPSTREAM_BASE_URL, + ANTIGRAVITY_USER_AGENT, +} from '../oauth/antigravityProvider.js'; + +function normalizeBaseUrl(baseUrl: string): string { + return (baseUrl || '').replace(/\/+$/, ''); +} + +function extractAntigravityModelNames(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 record.models.flatMap((item) => { + if (typeof item === 'string') return item.trim() ? [item.trim()] : []; + if (!item || typeof item !== 'object') return []; + const id = typeof (item as { id?: unknown }).id === 'string' + ? (item as { id: string }).id.trim() + : (typeof (item as { name?: unknown }).name === 'string' + ? (item as { name: string }).name.trim() + : ''); + return id ? [id] : []; + }); + } + return []; +} + +export class AntigravityAdapter extends BasePlatformAdapter { + readonly platformName = 'antigravity'; + + async detect(url: string): Promise { + const normalized = (url || '').toLowerCase(); + return normalized.includes('antigravity'); + } + + override async login(_baseUrl: string, _username: string, _password: string) { + return { success: false, message: 'login endpoint not supported' }; + } + + override async getUserInfo(_baseUrl: string, _accessToken: string): Promise { + return null; + } + + async checkin(_baseUrl: string, _accessToken: string): Promise { + return { success: false, message: 'checkin endpoint not supported' }; + } + + async getBalance(_baseUrl: string, _accessToken: string): Promise { + return { balance: 0, used: 0, quota: 0 }; + } + + async getModels(baseUrl: string, accessToken: string): Promise { + try { + const payload = await this.fetchJson<{ models?: unknown }>( + `${normalizeBaseUrl(baseUrl || ANTIGRAVITY_UPSTREAM_BASE_URL)}/v1internal:fetchAvailableModels`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + 'User-Agent': ANTIGRAVITY_USER_AGENT, + 'X-Goog-Api-Client': ANTIGRAVITY_GOOGLE_API_CLIENT, + 'Client-Metadata': ANTIGRAVITY_CLIENT_METADATA, + }, + body: JSON.stringify({}), + }, + ); + return extractAntigravityModelNames(payload); + } catch { + return []; + } + } +} diff --git a/src/server/services/platforms/index.test.ts b/src/server/services/platforms/index.test.ts index 15c11c4..e726cd4 100644 --- a/src/server/services/platforms/index.test.ts +++ b/src/server/services/platforms/index.test.ts @@ -58,6 +58,11 @@ describe('getAdapter platform aliases', () => { expect(getAdapter('gemini')?.platformName).toBe('gemini'); }); + it('supports antigravity adapter aliases', () => { + expect(getAdapter('antigravity')?.platformName).toBe('antigravity'); + expect(getAdapter('anti-gravity')?.platformName).toBe('antigravity'); + }); + it('supports dedicated codex adapter and aliases', () => { expect(getAdapter('codex')?.platformName).toBe('codex'); expect(getAdapter('chatgpt-codex')?.platformName).toBe('codex'); diff --git a/src/server/services/platforms/index.ts b/src/server/services/platforms/index.ts index bdc6079..1fc4333 100644 --- a/src/server/services/platforms/index.ts +++ b/src/server/services/platforms/index.ts @@ -11,6 +11,7 @@ import { CodexAdapter } from './codex.js'; import { ClaudeAdapter } from './claude.js'; import { GeminiAdapter } from './gemini.js'; import { GeminiCliAdapter } from './geminiCli.js'; +import { AntigravityAdapter } from './antigravity.js'; import { CliProxyApiAdapter } from './cliproxyapi.js'; import { detectPlatformByTitle } from './titleHint.js'; @@ -21,6 +22,7 @@ const adapters: PlatformAdapter[] = [ new ClaudeAdapter(), new GeminiAdapter(), new GeminiCliAdapter(), + new AntigravityAdapter(), new CliProxyApiAdapter(), new AnyRouterAdapter(), new DoneHubAdapter(), @@ -60,6 +62,8 @@ const platformAliases: Record = { claude: 'claude', gemini: 'gemini', 'gemini-cli': 'gemini-cli', + antigravity: 'antigravity', + 'anti-gravity': 'antigravity', google: 'gemini', // CLIProxyAPI aliases cliproxyapi: 'cliproxyapi', diff --git a/src/server/services/platforms/titleHint.ts b/src/server/services/platforms/titleHint.ts index 57fe3de..35ee023 100644 --- a/src/server/services/platforms/titleHint.ts +++ b/src/server/services/platforms/titleHint.ts @@ -44,10 +44,7 @@ function extractHtmlTitle(html: string): string { return match[1].replace(/\s+/g, ' ').trim(); } -export async function detectPlatformByTitle(url: string): Promise { - const base = normalizeBaseUrl(url); - if (!base) return undefined; - +async function detectPlatformByTitleOnce(base: string): Promise { try { const { fetch } = await import('undici'); const res = await fetch(`${base}/`, { @@ -71,3 +68,16 @@ export async function detectPlatformByTitle(url: string): Promise { + const base = normalizeBaseUrl(url); + if (!base) return undefined; + + const first = await detectPlatformByTitleOnce(base); + if (first) return first; + + // Under heavy parallel test load, local title probes can occasionally race + // with just-started ephemeral HTTP servers. Retry once before giving up. + await new Promise((resolve) => setTimeout(resolve, 50)); + return detectPlatformByTitleOnce(base); +} diff --git a/src/server/transformers/gemini/generate-content/compatibility.ts b/src/server/transformers/gemini/generate-content/compatibility.ts new file mode 100644 index 0000000..3a946d7 --- /dev/null +++ b/src/server/transformers/gemini/generate-content/compatibility.ts @@ -0,0 +1,314 @@ +import type { NormalizedFinalResponse } from '../../shared/normalized.js'; +import { extractReasoningMetadataFromGeminiRequest } from './convert.js'; + +type GeminiRecord = Record; + +function isRecord(value: unknown): value is GeminiRecord { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function asTrimmedString(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function parseJsonIfPossible(value: string): unknown { + const trimmed = value.trim(); + if (!trimmed) return {}; + try { + return JSON.parse(trimmed); + } catch { + return { raw: value }; + } +} + +function buildDataUrl(part: GeminiRecord): string | null { + const inlineData = isRecord(part.inlineData) ? part.inlineData : null; + if (!inlineData) return null; + const mimeType = asTrimmedString(inlineData.mime_type ?? inlineData.mimeType) || 'application/octet-stream'; + const data = asTrimmedString(inlineData.data); + if (!data) return null; + return `data:${mimeType};base64,${data}`; +} + +function toOpenAiContent(contentParts: GeminiRecord[]): string | Array> { + const blocks: Array> = []; + let textContent = ''; + + for (const part of contentParts) { + if (typeof part.text === 'string' && part.text.length > 0) { + if (part.thought === true) continue; + textContent += part.text; + continue; + } + + const dataUrl = buildDataUrl(part); + if (dataUrl) { + blocks.push({ + type: 'image_url', + image_url: { url: dataUrl }, + }); + } + } + + if (blocks.length <= 0) { + return textContent; + } + + if (textContent) { + blocks.unshift({ + type: 'text', + text: textContent, + }); + } + return blocks; +} + +function extractToolDeclarations(tools: unknown): Array> | undefined { + if (!Array.isArray(tools)) return undefined; + const next = tools.flatMap((tool) => { + if (!isRecord(tool) || !Array.isArray(tool.functionDeclarations)) return []; + return tool.functionDeclarations.flatMap((declaration) => { + if (!isRecord(declaration)) return []; + const name = asTrimmedString(declaration.name); + if (!name) return []; + return [{ + type: 'function', + function: { + name, + ...(asTrimmedString(declaration.description) + ? { description: asTrimmedString(declaration.description) } + : {}), + parameters: isRecord(declaration.parametersJsonSchema) + ? declaration.parametersJsonSchema + : (isRecord(declaration.parameters) ? declaration.parameters : { type: 'object', properties: {} }), + }, + }]; + }); + }); + return next.length > 0 ? next : undefined; +} + +function extractToolChoice(toolConfig: unknown): string | undefined { + const functionCallingConfig = ( + isRecord(toolConfig) && isRecord(toolConfig.functionCallingConfig) + ? toolConfig.functionCallingConfig + : null + ); + const mode = asTrimmedString(functionCallingConfig?.mode).toUpperCase(); + if (mode === 'NONE') return 'none'; + if (mode === 'ANY' || mode === 'VALIDATED') return 'required'; + if (mode === 'AUTO') return 'auto'; + return undefined; +} + +function buildGeminiMessages(body: GeminiRecord): Array> { + const messages: Array> = []; + + if (isRecord(body.systemInstruction) && Array.isArray(body.systemInstruction.parts)) { + const content = toOpenAiContent( + body.systemInstruction.parts.filter((part): part is GeminiRecord => isRecord(part)), + ); + const hasContent = typeof content === 'string' ? content.length > 0 : content.length > 0; + if (hasContent) { + messages.push({ + role: 'system', + content, + }); + } + } + + const contents = Array.isArray(body.contents) ? body.contents : []; + for (const contentItem of contents) { + if (!isRecord(contentItem)) continue; + const role = asTrimmedString(contentItem.role) === 'model' ? 'assistant' : 'user'; + const parts = Array.isArray(contentItem.parts) + ? contentItem.parts.filter((part): part is GeminiRecord => isRecord(part)) + : []; + + const toolCalls = parts + .map((part, index) => { + const functionCall = isRecord(part.functionCall) ? part.functionCall : null; + const name = asTrimmedString(functionCall?.name); + if (!functionCall || !name) return null; + return { + id: asTrimmedString(functionCall.id) || `call_${index}`, + type: 'function', + function: { + name, + arguments: JSON.stringify(functionCall.args ?? {}), + }, + }; + }) + .filter((item): item is NonNullable => !!item); + + const functionResponses = parts + .map((part) => (isRecord(part.functionResponse) ? part.functionResponse : null)) + .filter((item): item is GeminiRecord => !!item); + + if (functionResponses.length > 0) { + for (let index = 0; index < functionResponses.length; index += 1) { + const functionResponse = functionResponses[index] as GeminiRecord; + const toolName = asTrimmedString(functionResponse.name) || `tool_${index}`; + const toolResponse = functionResponse.response; + messages.push({ + role: 'tool', + tool_call_id: toolName, + content: JSON.stringify(toolResponse ?? {}), + }); + } + continue; + } + + const openAiParts = parts.filter((part) => !part.functionCall && !part.functionResponse); + const content = toOpenAiContent(openAiParts); + const hasContent = typeof content === 'string' ? content.length > 0 : content.length > 0; + if (!hasContent && toolCalls.length <= 0) continue; + + const message: Record = { + role, + }; + if (hasContent) { + message.content = content; + } + if (toolCalls.length > 0) { + message.tool_calls = toolCalls; + if (!hasContent) message.content = ''; + } + messages.push(message); + } + + return messages; +} + +export function buildOpenAiBodyFromGeminiRequest(input: { + body: GeminiRecord; + modelName: string; + stream: boolean; +}): Record { + const openAiBody: Record = { + model: input.modelName, + stream: input.stream, + messages: buildGeminiMessages(input.body), + }; + + const generationConfig = isRecord(input.body.generationConfig) ? input.body.generationConfig : null; + const maxTokens = Number( + generationConfig?.maxOutputTokens + ?? input.body.max_output_tokens + ?? input.body.max_completion_tokens + ?? input.body.max_tokens + ?? 0, + ); + if (Number.isFinite(maxTokens) && maxTokens > 0) { + openAiBody.max_tokens = Math.trunc(maxTokens); + } + const temperature = Number(generationConfig?.temperature); + if (Number.isFinite(temperature)) openAiBody.temperature = temperature; + const topP = Number(generationConfig?.topP); + if (Number.isFinite(topP)) openAiBody.top_p = topP; + if (Array.isArray(generationConfig?.stopSequences) && generationConfig!.stopSequences.length > 0) { + openAiBody.stop = generationConfig!.stopSequences; + } + + const tools = extractToolDeclarations(input.body.tools); + if (tools) { + openAiBody.tools = tools; + } + const toolChoice = extractToolChoice(input.body.toolConfig); + if (toolChoice) { + openAiBody.tool_choice = toolChoice; + } + + const reasoning = extractReasoningMetadataFromGeminiRequest(input.body); + if (reasoning) { + openAiBody.reasoning = { + effort: reasoning.reasoningEffort, + budget_tokens: reasoning.reasoningBudget, + }; + openAiBody.reasoning_effort = reasoning.reasoningEffort; + } + + return openAiBody; +} + +function mapFinishReason(finishReason: string): string { + const normalized = finishReason.trim().toLowerCase(); + if (!normalized) return 'STOP'; + if (normalized === 'stop' || normalized === 'completed' || normalized === 'tool_calls') return 'STOP'; + if (normalized === 'length' || normalized === 'max_tokens' || normalized === 'max_output_tokens') return 'MAX_TOKENS'; + if (normalized === 'content_filter' || normalized === 'safety') return 'SAFETY'; + return normalized.toUpperCase().replace(/[^A-Z0-9_]+/g, '_'); +} + +function buildUsageMetadata(usage?: { + promptTokens?: number | null; + completionTokens?: number | null; + totalTokens?: number | null; +}): Record | undefined { + if (!usage) return undefined; + const metadata: Record = {}; + if (typeof usage.promptTokens === 'number' && Number.isFinite(usage.promptTokens)) { + metadata.promptTokenCount = usage.promptTokens; + } + if (typeof usage.completionTokens === 'number' && Number.isFinite(usage.completionTokens)) { + metadata.candidatesTokenCount = usage.completionTokens; + } + if (typeof usage.totalTokens === 'number' && Number.isFinite(usage.totalTokens)) { + metadata.totalTokenCount = usage.totalTokens; + } + return Object.keys(metadata).length > 0 ? metadata : undefined; +} + +export function serializeNormalizedFinalToGemini(input: { + normalized: NormalizedFinalResponse; + usage?: { + promptTokens?: number | null; + completionTokens?: number | null; + totalTokens?: number | null; + }; +}): Record { + const parts: Array> = []; + + if (input.normalized.reasoningContent) { + parts.push({ + text: input.normalized.reasoningContent, + thought: true, + }); + } + if (input.normalized.content) { + parts.push({ + text: input.normalized.content, + }); + } + for (const toolCall of input.normalized.toolCalls) { + parts.push({ + functionCall: { + ...(toolCall.id ? { id: toolCall.id } : {}), + name: toolCall.name, + args: parseJsonIfPossible(toolCall.arguments), + }, + }); + } + + const response: Record = { + responseId: input.normalized.id || '', + modelVersion: input.normalized.model || '', + candidates: [ + { + index: 0, + content: { + role: 'model', + parts, + }, + finishReason: mapFinishReason(input.normalized.finishReason), + }, + ], + }; + + const usageMetadata = buildUsageMetadata(input.usage); + if (usageMetadata) { + response.usageMetadata = usageMetadata; + } + + return response; +} diff --git a/src/server/transformers/gemini/generate-content/index.ts b/src/server/transformers/gemini/generate-content/index.ts index 13dcf91..ab706ee 100644 --- a/src/server/transformers/gemini/generate-content/index.ts +++ b/src/server/transformers/gemini/generate-content/index.ts @@ -83,6 +83,7 @@ import { geminiGenerateContentStream } from './stream.js'; import { createGeminiGenerateContentAggregateState, applyGeminiGenerateContentAggregate } from './aggregator.js'; import { geminiGenerateContentUsage } from './usage.js'; import { reasoningEffortToGeminiThinkingConfig, geminiThinkingConfigToReasoning } from './convert.js'; +import { buildOpenAiBodyFromGeminiRequest, serializeNormalizedFinalToGemini } from './compatibility.js'; export const geminiGenerateContentTransformer = { protocol: 'gemini/generate-content' as const, @@ -98,6 +99,10 @@ export const geminiGenerateContentTransformer = { reasoningEffortToGeminiThinkingConfig, geminiThinkingConfigToReasoning, }, + compatibility: { + buildOpenAiBodyFromGeminiRequest, + serializeNormalizedFinalToGemini, + }, parseProxyRequestPath: parseGeminiProxyRequestPath, resolveProxyApiVersion: resolveGeminiProxyApiVersion, resolveBaseUrl: resolveGeminiNativeBaseUrl, @@ -114,4 +119,6 @@ export { geminiGenerateContentUsage, reasoningEffortToGeminiThinkingConfig, geminiThinkingConfigToReasoning, + buildOpenAiBodyFromGeminiRequest, + serializeNormalizedFinalToGemini, }; diff --git a/src/server/transformers/shared/normalized.ts b/src/server/transformers/shared/normalized.ts index c40caa7..ab8cf03 100644 --- a/src/server/transformers/shared/normalized.ts +++ b/src/server/transformers/shared/normalized.ts @@ -88,7 +88,7 @@ export type TransformerMetadata = { }; export type NormalizedRequest = { - protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini'; + protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini' | 'gemini-cli'; model: string; stream: boolean; rawBody: unknown; @@ -98,7 +98,7 @@ export type NormalizedRequest = { }; export type NormalizedResponseEnvelope = { - protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini'; + protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini' | 'gemini-cli'; model: string; final: import('./chatFormatsCore.js').NormalizedFinalResponse; usage?: unknown; @@ -107,7 +107,7 @@ export type NormalizedResponseEnvelope = { }; export type NormalizedStreamEnvelope = { - protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini'; + protocol: import('./chatFormatsCore.js').DownstreamFormat | 'responses' | 'gemini' | 'gemini-cli'; model: string; event: import('./chatFormatsCore.js').NormalizedStreamEvent; metadata?: TransformerMetadata; diff --git a/src/server/transformers/shared/protocolModel.ts b/src/server/transformers/shared/protocolModel.ts index 4a28964..e80b148 100644 --- a/src/server/transformers/shared/protocolModel.ts +++ b/src/server/transformers/shared/protocolModel.ts @@ -7,7 +7,8 @@ export type TransformerProtocol = | 'openai/chat' | 'anthropic/messages' | 'openai/responses' - | 'gemini/generate-content'; + | 'gemini/generate-content' + | 'gemini-cli/generate-content'; export type ProtocolRequestEnvelope< TProtocol extends TransformerProtocol = TransformerProtocol,