Skip to content

Commit 003d81b

Browse files
authored
fix(providers): strip models/ prefix for Gemini OpenAI-compat endpoint (#175) (#186)
## Summary Fixes #175. Google's OpenAI-compatible endpoint at `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, producing an opaque `400 status code (no body)` when routed through a custom provider (OpenAI Chat wire) configured with that baseUrl. - New helper `packages/providers/src/gemini-compat.ts` exposes `isGeminiOpenAICompat(baseUrl)` and `normalizeGeminiModelId(modelId, baseUrl)`. - `complete()` in `packages/providers/src/index.ts` normalizes the modelId on the wire only — Settings keeps the prefixed form so provider/model UX stays in sync with `/models`, while requests drop the prefix before hitting pi-ai. No changes to retry / errors / Settings UI / agent.ts / core. Skipped the OpenAI-specific param stripping step (presence_penalty / frequency_penalty / response_format): none of those keywords exist in `packages/providers`, `packages/core`, or `apps/desktop/src/main`, so pi-ai is not being handed Chat-specific knobs that Gemini would reject. ## Test plan - [x] Unit tests for `isGeminiOpenAICompat` and `normalizeGeminiModelId` (Gemini host, OpenAI host, undefined baseUrl, non-Gemini models/ id preserved). - [x] Integration test in `index.test.ts` verifying `complete({ modelId: 'models/gemini-2-pro', baseUrl: '.../generativelanguage.googleapis.com/...' })` sends bare `gemini-2-pro` to pi-ai. - [x] `pnpm exec vitest run` in `packages/providers`: 10 files, 141 tests passed. - [x] `pnpm typecheck` and `pnpm lint` green. ## PRINCIPLES §5b - Compatibility: green — only affects requests routed to Gemini host; Settings storage and UI unchanged. - Upgradeability: green — single-file helper, trivial to remove when Gemini normalizes its API. - No bloat: green — 18 lines of runtime code, no new deps. - Elegance: green — pure, local normalization at the wire boundary. --------- Signed-off-by: hqhq1025 <1506751656@qq.com>
1 parent 83e81e0 commit 003d81b

4 files changed

Lines changed: 136 additions & 3 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { isGeminiOpenAICompat, normalizeGeminiModelId } from './gemini-compat';
3+
4+
describe('isGeminiOpenAICompat', () => {
5+
it('detects the official Gemini OpenAI-compat endpoint', () => {
6+
expect(isGeminiOpenAICompat('https://generativelanguage.googleapis.com/v1beta/openai/')).toBe(
7+
true,
8+
);
9+
});
10+
11+
it('returns false for non-Gemini bases', () => {
12+
expect(isGeminiOpenAICompat('https://api.openai.com/v1')).toBe(false);
13+
});
14+
15+
it('returns false when baseUrl is undefined', () => {
16+
expect(isGeminiOpenAICompat(undefined)).toBe(false);
17+
});
18+
19+
it('returns false when baseUrl is empty', () => {
20+
expect(isGeminiOpenAICompat('')).toBe(false);
21+
});
22+
23+
it('returns false when baseUrl is not a parseable URL', () => {
24+
expect(isGeminiOpenAICompat('not a url')).toBe(false);
25+
});
26+
27+
it('rejects spoofed URLs with Gemini host in query string', () => {
28+
expect(
29+
isGeminiOpenAICompat('https://attacker.com/?x=generativelanguage.googleapis.com/v1'),
30+
).toBe(false);
31+
});
32+
33+
it('rejects spoofed URLs with Gemini host as subdomain suffix of attacker domain', () => {
34+
expect(isGeminiOpenAICompat('https://generativelanguage.googleapis.com.evil.com/v1')).toBe(
35+
false,
36+
);
37+
});
38+
39+
it('rejects spoofed URLs with Gemini host hyphenated into attacker domain', () => {
40+
expect(isGeminiOpenAICompat('https://generativelanguage-googleapis-com.evil.com')).toBe(false);
41+
});
42+
});
43+
44+
describe('normalizeGeminiModelId', () => {
45+
it('strips the models/ prefix for Gemini hosts', () => {
46+
expect(
47+
normalizeGeminiModelId(
48+
'models/gemini-3.1-pro-preview',
49+
'https://generativelanguage.googleapis.com/v1beta/openai/',
50+
),
51+
).toBe('gemini-3.1-pro-preview');
52+
});
53+
54+
it('leaves non-Gemini model ids untouched', () => {
55+
expect(normalizeGeminiModelId('gpt-4', 'https://api.openai.com/v1')).toBe('gpt-4');
56+
});
57+
58+
it('does not strip models/ prefix when baseUrl is not a Gemini host', () => {
59+
expect(normalizeGeminiModelId('models/foo', 'https://api.openai.com/v1')).toBe('models/foo');
60+
});
61+
62+
it('is a no-op when baseUrl is undefined', () => {
63+
expect(normalizeGeminiModelId('models/gemini-2-pro', undefined)).toBe('models/gemini-2-pro');
64+
});
65+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* Google's OpenAI-compatible endpoint
3+
* (https://generativelanguage.googleapis.com/v1beta/openai/) accepts the same
4+
* request shape as OpenAI Chat Completions but rejects model ids carrying the
5+
* `models/` prefix that its own /models listing returns. Settings UI keeps the
6+
* prefixed id (so it matches the /models response), and we strip it only on
7+
* the wire. See issue #175.
8+
*/
9+
10+
export function isGeminiOpenAICompat(baseUrl: string | undefined): boolean {
11+
if (!baseUrl) return false;
12+
try {
13+
const { hostname } = new URL(baseUrl);
14+
return (
15+
hostname === 'generativelanguage.googleapis.com' ||
16+
hostname.endsWith('.generativelanguage.googleapis.com')
17+
);
18+
} catch {
19+
return false;
20+
}
21+
}
22+
23+
export function normalizeGeminiModelId(modelId: string, baseUrl: string | undefined): string {
24+
if (!isGeminiOpenAICompat(baseUrl)) return modelId;
25+
return modelId.replace(/^models\//, '');
26+
}

packages/providers/src/index.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,42 @@ describe('complete', () => {
303303
),
304304
).rejects.toMatchObject({ code: 'ATTACHMENT_TOO_LARGE' });
305305
});
306+
307+
it('strips models/ prefix from modelId when routing through Gemini OpenAI-compat endpoint', async () => {
308+
getModelMock.mockReturnValue(undefined);
309+
completeSimpleMock.mockImplementationOnce(async (piModel) => {
310+
expect(piModel.id).toBe('gemini-2-pro');
311+
return {
312+
role: 'assistant',
313+
content: [{ type: 'text', text: 'hi' }],
314+
api: 'openai-completions',
315+
provider: 'custom-gemini',
316+
model: 'gemini-2-pro',
317+
usage: {
318+
input: 0,
319+
output: 0,
320+
cacheRead: 0,
321+
cacheWrite: 0,
322+
totalTokens: 0,
323+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
324+
},
325+
stopReason: 'stop',
326+
timestamp: Date.now(),
327+
};
328+
});
329+
330+
await complete(
331+
{ provider: 'custom-gemini', modelId: 'models/gemini-2-pro' },
332+
[{ role: 'user', content: 'hello' }],
333+
{
334+
apiKey: 'token',
335+
wire: 'openai-chat',
336+
baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai/',
337+
},
338+
);
339+
340+
expect(getModelMock).toHaveBeenCalledWith('custom-gemini', 'gemini-2-pro');
341+
});
306342
});
307343

308344
describe('complete — openai-responses strict instructions', () => {

packages/providers/src/index.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
looksLikeClaudeOAuthToken,
1919
shouldForceClaudeCodeIdentity,
2020
} from './claude-code-compat';
21+
import { normalizeGeminiModelId } from './gemini-compat';
2122

2223
/** Subset of pi-ai's `ThinkingLevel` we expose. Maps directly to its `reasoning`
2324
* field, which Anthropic adapters translate to extended-thinking effort/budget
@@ -221,6 +222,11 @@ export async function complete(
221222
}
222223
const apiKey = opts.apiKey || 'open-codesign-keyless';
223224

225+
// Gemini's OpenAI-compat endpoint rejects the `models/` prefix that its own
226+
// /models listing returns (issue #175). Normalize on the wire only; Settings
227+
// keeps the prefixed form so provider/model UX stays in sync with /models.
228+
const effectiveModelId = normalizeGeminiModelId(model.modelId, opts.baseUrl);
229+
224230
const pi = (await import('@mariozechner/pi-ai')) as unknown as {
225231
getModel: (provider: string, modelId: string) => PiModel | undefined;
226232
completeSimple: (
@@ -238,12 +244,12 @@ export async function complete(
238244
) => Promise<PiAssistantMessage>;
239245
};
240246

241-
let piModel = pi.getModel(model.provider, model.modelId);
247+
let piModel = pi.getModel(model.provider, effectiveModelId);
242248
if (!piModel) {
243249
if (opts.wire !== undefined) {
244-
piModel = synthesizeWireModel(model.provider, model.modelId, opts.wire, opts.baseUrl);
250+
piModel = synthesizeWireModel(model.provider, effectiveModelId, opts.wire, opts.baseUrl);
245251
} else if (model.provider === 'openrouter') {
246-
piModel = synthesizeOpenRouterModel(model.modelId);
252+
piModel = synthesizeOpenRouterModel(effectiveModelId);
247253
} else {
248254
throw new CodesignError(
249255
`Unknown model ${model.provider}:${model.modelId}`,

0 commit comments

Comments
 (0)