Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
65 changes: 65 additions & 0 deletions packages/providers/src/gemini-compat.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
26 changes: 26 additions & 0 deletions packages/providers/src/gemini-compat.ts
Original file line number Diff line number Diff line change
@@ -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 (
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] This matcher currently keys off hostname only, so non-OpenAI-compat URLs on the same host (for example /v1beta/models) still trigger models/ stripping. That can misroute model lookup and synthesize the wrong id.

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;
  }
}

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\//, '');
}
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