diff --git a/packages/providers/src/gemini-compat.test.ts b/packages/providers/src/gemini-compat.test.ts new file mode 100644 index 00000000..dfc70e3b --- /dev/null +++ b/packages/providers/src/gemini-compat.test.ts @@ -0,0 +1,65 @@ +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); + }); + + 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', () => { + 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..e5d7de58 --- /dev/null +++ b/packages/providers/src/gemini-compat.ts @@ -0,0 +1,26 @@ +/** + * 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; + 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 { + 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}`,