From 01239373f7929f43e3b1001f05ceb0cc596a2e5f Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Thu, 23 Apr 2026 10:44:02 +0800 Subject: [PATCH 1/2] fix(providers): strip models/ prefix for Gemini OpenAI-compat endpoint (#175) Google's OpenAI-compatible endpoint (https://generativelanguage.googleapis.com/v1beta/openai/) rejects model ids carrying the `models/` prefix that its own /models listing returns, yielding an opaque `400 status code (no body)`. Normalize on the wire only: Settings keeps the prefixed form so the UI stays consistent with what /models returns, while requests drop the prefix before hitting pi-ai. Signed-off-by: hqhq1025 <1506751656@qq.com> --- packages/providers/src/gemini-compat.test.ts | 41 ++++++++++++++++++++ packages/providers/src/gemini-compat.ts | 18 +++++++++ packages/providers/src/index.test.ts | 36 +++++++++++++++++ packages/providers/src/index.ts | 12 ++++-- 4 files changed, 104 insertions(+), 3 deletions(-) create mode 100644 packages/providers/src/gemini-compat.test.ts create mode 100644 packages/providers/src/gemini-compat.ts diff --git a/packages/providers/src/gemini-compat.test.ts b/packages/providers/src/gemini-compat.test.ts new file mode 100644 index 00000000..485dffa8 --- /dev/null +++ b/packages/providers/src/gemini-compat.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { isGeminiOpenAICompat, normalizeGeminiModelId } from './gemini-compat'; + +describe('isGeminiOpenAICompat', () => { + it('detects the official Gemini OpenAI-compat endpoint', () => { + expect(isGeminiOpenAICompat('https://generativelanguage.googleapis.com/v1beta/openai/')).toBe( + true, + ); + }); + + it('returns false for non-Gemini bases', () => { + expect(isGeminiOpenAICompat('https://api.openai.com/v1')).toBe(false); + }); + + it('returns false when baseUrl is undefined', () => { + expect(isGeminiOpenAICompat(undefined)).toBe(false); + }); +}); + +describe('normalizeGeminiModelId', () => { + it('strips the models/ prefix for Gemini hosts', () => { + expect( + normalizeGeminiModelId( + 'models/gemini-3.1-pro-preview', + 'https://generativelanguage.googleapis.com/v1beta/openai/', + ), + ).toBe('gemini-3.1-pro-preview'); + }); + + it('leaves non-Gemini model ids untouched', () => { + expect(normalizeGeminiModelId('gpt-4', 'https://api.openai.com/v1')).toBe('gpt-4'); + }); + + it('does not strip models/ prefix when baseUrl is not a Gemini host', () => { + expect(normalizeGeminiModelId('models/foo', 'https://api.openai.com/v1')).toBe('models/foo'); + }); + + it('is a no-op when baseUrl is undefined', () => { + expect(normalizeGeminiModelId('models/gemini-2-pro', undefined)).toBe('models/gemini-2-pro'); + }); +}); diff --git a/packages/providers/src/gemini-compat.ts b/packages/providers/src/gemini-compat.ts new file mode 100644 index 00000000..0c9bab9a --- /dev/null +++ b/packages/providers/src/gemini-compat.ts @@ -0,0 +1,18 @@ +/** + * Google's OpenAI-compatible endpoint + * (https://generativelanguage.googleapis.com/v1beta/openai/) accepts the same + * request shape as OpenAI Chat Completions but rejects model ids carrying the + * `models/` prefix that its own /models listing returns. Settings UI keeps the + * prefixed id (so it matches the /models response), and we strip it only on + * the wire. See issue #175. + */ + +export function isGeminiOpenAICompat(baseUrl: string | undefined): boolean { + if (!baseUrl) return false; + return baseUrl.includes('generativelanguage.googleapis.com'); +} + +export function normalizeGeminiModelId(modelId: string, baseUrl: string | undefined): string { + if (!isGeminiOpenAICompat(baseUrl)) return modelId; + return modelId.replace(/^models\//, ''); +} diff --git a/packages/providers/src/index.test.ts b/packages/providers/src/index.test.ts index ab5a2a18..d6b31e01 100644 --- a/packages/providers/src/index.test.ts +++ b/packages/providers/src/index.test.ts @@ -303,4 +303,40 @@ describe('complete', () => { ), ).rejects.toMatchObject({ code: 'ATTACHMENT_TOO_LARGE' }); }); + + it('strips models/ prefix from modelId when routing through Gemini OpenAI-compat endpoint', async () => { + getModelMock.mockReturnValue(undefined); + completeSimpleMock.mockImplementationOnce(async (piModel) => { + expect(piModel.id).toBe('gemini-2-pro'); + return { + role: 'assistant', + content: [{ type: 'text', text: 'hi' }], + api: 'openai-completions', + provider: 'custom-gemini', + model: 'gemini-2-pro', + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: 'stop', + timestamp: Date.now(), + }; + }); + + await complete( + { provider: 'custom-gemini', modelId: 'models/gemini-2-pro' }, + [{ role: 'user', content: 'hello' }], + { + apiKey: 'token', + wire: 'openai-chat', + baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai/', + }, + ); + + expect(getModelMock).toHaveBeenCalledWith('custom-gemini', 'gemini-2-pro'); + }); }); diff --git a/packages/providers/src/index.ts b/packages/providers/src/index.ts index d9e795ad..32c3d1b3 100644 --- a/packages/providers/src/index.ts +++ b/packages/providers/src/index.ts @@ -18,6 +18,7 @@ import { looksLikeClaudeOAuthToken, shouldForceClaudeCodeIdentity, } from './claude-code-compat'; +import { normalizeGeminiModelId } from './gemini-compat'; /** Subset of pi-ai's `ThinkingLevel` we expose. Maps directly to its `reasoning` * field, which Anthropic adapters translate to extended-thinking effort/budget @@ -221,6 +222,11 @@ export async function complete( } const apiKey = opts.apiKey || 'open-codesign-keyless'; + // Gemini's OpenAI-compat endpoint rejects the `models/` prefix that its own + // /models listing returns (issue #175). Normalize on the wire only; Settings + // keeps the prefixed form so provider/model UX stays in sync with /models. + const effectiveModelId = normalizeGeminiModelId(model.modelId, opts.baseUrl); + const pi = (await import('@mariozechner/pi-ai')) as unknown as { getModel: (provider: string, modelId: string) => PiModel | undefined; completeSimple: ( @@ -237,12 +243,12 @@ export async function complete( ) => Promise; }; - let piModel = pi.getModel(model.provider, model.modelId); + let piModel = pi.getModel(model.provider, effectiveModelId); if (!piModel) { if (opts.wire !== undefined) { - piModel = synthesizeWireModel(model.provider, model.modelId, opts.wire, opts.baseUrl); + piModel = synthesizeWireModel(model.provider, effectiveModelId, opts.wire, opts.baseUrl); } else if (model.provider === 'openrouter') { - piModel = synthesizeOpenRouterModel(model.modelId); + piModel = synthesizeOpenRouterModel(effectiveModelId); } else { throw new CodesignError( `Unknown model ${model.provider}:${model.modelId}`, From d82335b39fe59e882aeaa9af9d68b257ea28766a Mon Sep 17 00:00:00 2001 From: hqhq1025 <1506751656@qq.com> Date: Thu, 23 Apr 2026 10:53:18 +0800 Subject: [PATCH 2/2] fix(providers): use URL.hostname match for Gemini detection (#175 follow-up) Signed-off-by: hqhq1025 <1506751656@qq.com> --- packages/providers/src/gemini-compat.test.ts | 24 ++++++++++++++++++++ packages/providers/src/gemini-compat.ts | 10 +++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/providers/src/gemini-compat.test.ts b/packages/providers/src/gemini-compat.test.ts index 485dffa8..dfc70e3b 100644 --- a/packages/providers/src/gemini-compat.test.ts +++ b/packages/providers/src/gemini-compat.test.ts @@ -15,6 +15,30 @@ describe('isGeminiOpenAICompat', () => { it('returns false when baseUrl is undefined', () => { expect(isGeminiOpenAICompat(undefined)).toBe(false); }); + + it('returns false when baseUrl is empty', () => { + expect(isGeminiOpenAICompat('')).toBe(false); + }); + + it('returns false when baseUrl is not a parseable URL', () => { + expect(isGeminiOpenAICompat('not a url')).toBe(false); + }); + + it('rejects spoofed URLs with Gemini host in query string', () => { + expect( + isGeminiOpenAICompat('https://attacker.com/?x=generativelanguage.googleapis.com/v1'), + ).toBe(false); + }); + + it('rejects spoofed URLs with Gemini host as subdomain suffix of attacker domain', () => { + expect(isGeminiOpenAICompat('https://generativelanguage.googleapis.com.evil.com/v1')).toBe( + false, + ); + }); + + it('rejects spoofed URLs with Gemini host hyphenated into attacker domain', () => { + expect(isGeminiOpenAICompat('https://generativelanguage-googleapis-com.evil.com')).toBe(false); + }); }); describe('normalizeGeminiModelId', () => { diff --git a/packages/providers/src/gemini-compat.ts b/packages/providers/src/gemini-compat.ts index 0c9bab9a..e5d7de58 100644 --- a/packages/providers/src/gemini-compat.ts +++ b/packages/providers/src/gemini-compat.ts @@ -9,7 +9,15 @@ export function isGeminiOpenAICompat(baseUrl: string | undefined): boolean { if (!baseUrl) return false; - return baseUrl.includes('generativelanguage.googleapis.com'); + try { + const { hostname } = new URL(baseUrl); + return ( + hostname === 'generativelanguage.googleapis.com' || + hostname.endsWith('.generativelanguage.googleapis.com') + ); + } catch { + return false; + } } export function normalizeGeminiModelId(modelId: string, baseUrl: string | undefined): string {