Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions packages/providers/src/gemini-compat.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
18 changes: 18 additions & 0 deletions packages/providers/src/gemini-compat.ts
Original file line number Diff line number Diff line change
@@ -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');

Check failure

Code scanning / CodeQL

Incomplete URL substring sanitization High

'
generativelanguage.googleapis.com
' can be anywhere in the URL, and arbitrary hosts may come before or after it.
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Major] includes('generativelanguage.googleapis.com') accepts spoofed/non-canonical URLs and can trigger unintended models/ stripping. Prefer strict URL parsing with exact hostname and OpenAI-compat path checks.

Suggested fix:

export function isGeminiOpenAICompat(baseUrl: string | undefined): boolean {
  if (!baseUrl) return false;
  try {
    const url = new URL(baseUrl);
    return (
      url.hostname === 'generativelanguage.googleapis.com' &&
      /(^|\/)openai(\/|$)/.test(url.pathname)
    );
  } catch {
    return false;
  }
}

}

export function normalizeGeminiModelId(modelId: string, baseUrl: string | undefined): string {
if (!isGeminiOpenAICompat(baseUrl)) return modelId;
return modelId.replace(/^models\//, '');
}
36 changes: 36 additions & 0 deletions packages/providers/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
12 changes: 9 additions & 3 deletions packages/providers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: (
Expand All @@ -237,12 +243,12 @@ export async function complete(
) => Promise<PiAssistantMessage>;
};

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}`,
Expand Down
Loading