mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
Fix Gemini BYOK model URL normalization (#2761)
Some checks failed
visual-baseline / Capture visual baselines (push) Waiting to run
ci / Detect CI change scopes (push) Successful in 1s
nix-check / build (push) Failing after 2s
ci / Validate Nix flake (push) Has been skipped
ci / Preflight (push) Failing after 1s
ci / Workspace unit tests (push) Failing after 1s
ci / Daemon workspace tests (push) Failing after 1s
ci / Web workspace tests (push) Failing after 1s
ci / Browser tests (push) Failing after 1s
ci / Build workspaces (push) Failing after 1s
ci / Validate workspace (push) Failing after 0s
ci / Runtime trace (push) Has been skipped
Some checks failed
visual-baseline / Capture visual baselines (push) Waiting to run
ci / Detect CI change scopes (push) Successful in 1s
nix-check / build (push) Failing after 2s
ci / Validate Nix flake (push) Has been skipped
ci / Preflight (push) Failing after 1s
ci / Workspace unit tests (push) Failing after 1s
ci / Daemon workspace tests (push) Failing after 1s
ci / Web workspace tests (push) Failing after 1s
ci / Browser tests (push) Failing after 1s
ci / Build workspaces (push) Failing after 1s
ci / Validate workspace (push) Failing after 0s
ci / Runtime trace (push) Has been skipped
Co-authored-by: ATXP Earn Clowdbot <bougie-atxp@users.noreply.github.com>
This commit is contained in:
parent
840019c8e2
commit
d28acdc879
7 changed files with 134 additions and 12 deletions
|
|
@ -18,6 +18,7 @@ import {
|
|||
import { isSafeId as isSafeProjectId } from './projects.js';
|
||||
import { projectKindToTracking } from '@open-design/contracts/analytics';
|
||||
import { validateBaseUrlResolved } from './connectionTest.js';
|
||||
import { googleStreamGenerateContentUrl } from './google-models.js';
|
||||
|
||||
// Allowlist for the `/feedback` route. Mirrors the
|
||||
// ChatMessageFeedbackReasonCode union in packages/contracts/src/api/chat.ts.
|
||||
|
|
@ -989,8 +990,7 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
);
|
||||
}
|
||||
|
||||
const clean = effectiveBaseUrl.replace(/\/+$/, '');
|
||||
const url = `${clean}/v1beta/models/${encodeURIComponent(model)}:streamGenerateContent?alt=sse`;
|
||||
const url = googleStreamGenerateContentUrl(effectiveBaseUrl, model);
|
||||
console.log(
|
||||
`[proxy:google] ${req.method} ${validated.parsed!.hostname} model=${model}`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ import {
|
|||
type ParsedBaseUrl,
|
||||
type ProviderTestRequest,
|
||||
} from '@open-design/contracts/api/connectionTest';
|
||||
import { googleGenerateContentUrl } from './google-models.js';
|
||||
|
||||
export { validateBaseUrl } from '@open-design/contracts/api/connectionTest';
|
||||
|
||||
|
|
@ -621,9 +622,8 @@ function buildProviderCall(input: ProviderTestRequest): ProviderCallShape {
|
|||
};
|
||||
}
|
||||
case 'google': {
|
||||
const trimmedBase = baseUrl.replace(/\/+$/, '');
|
||||
return {
|
||||
url: `${trimmedBase}/v1beta/models/${encodeURIComponent(model)}:generateContent`,
|
||||
url: googleGenerateContentUrl(baseUrl, model),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-goog-api-key': apiKey,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import {
|
|||
validateProjectPath,
|
||||
} from './projects.js';
|
||||
import { exportProjectTranscript } from './transcript-export.js';
|
||||
import { googleGenerateContentUrl } from './google-models.js';
|
||||
|
||||
// Re-export the request/response types so existing daemon-internal
|
||||
// imports (and the route handler) keep their referenced names. The
|
||||
|
|
@ -595,9 +596,8 @@ function buildFinalizeProviderRequest(params: FinalizeProviderCallParams): Final
|
|||
}
|
||||
|
||||
if (params.protocol === 'google') {
|
||||
const clean = params.baseUrl.replace(/\/+$/, '');
|
||||
return {
|
||||
url: `${clean}/v1beta/models/${encodeURIComponent(params.model)}:generateContent`,
|
||||
url: googleGenerateContentUrl(params.baseUrl, params.model),
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-goog-api-key': params.apiKey,
|
||||
|
|
|
|||
33
apps/daemon/src/google-models.ts
Normal file
33
apps/daemon/src/google-models.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
export function googleGenerativeLanguageBaseUrl(baseUrl: string): string {
|
||||
const url = new URL(baseUrl);
|
||||
url.search = '';
|
||||
url.hash = '';
|
||||
const pathname = url.pathname
|
||||
.replace(/\/+$/, '')
|
||||
.replace(/\/v\d+(?:beta)?$/i, '');
|
||||
url.pathname = pathname || '/';
|
||||
return url.toString().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function normalizeGoogleModelId(model: string): string {
|
||||
const trimmed = model.trim();
|
||||
return trimmed.startsWith('models/') ? trimmed.slice('models/'.length) : trimmed;
|
||||
}
|
||||
|
||||
export function googleModelPathSegment(model: string): string {
|
||||
return encodeURIComponent(normalizeGoogleModelId(model));
|
||||
}
|
||||
|
||||
export function googleGenerateContentUrl(baseUrl: string, model: string): string {
|
||||
return `${googleGenerativeLanguageBaseUrl(baseUrl)}/v1beta/models/${googleModelPathSegment(model)}:generateContent`;
|
||||
}
|
||||
|
||||
export function googleStreamGenerateContentUrl(baseUrl: string, model: string): string {
|
||||
return `${googleGenerativeLanguageBaseUrl(baseUrl)}/v1beta/models/${googleModelPathSegment(model)}:streamGenerateContent?alt=sse`;
|
||||
}
|
||||
|
||||
export function googleProviderModelsUrl(baseUrl: string, apiKey: string): string {
|
||||
const url = new URL(`${googleGenerativeLanguageBaseUrl(baseUrl)}/v1beta/models`);
|
||||
url.searchParams.set('key', apiKey);
|
||||
return url.toString();
|
||||
}
|
||||
|
|
@ -9,6 +9,7 @@ import type {
|
|||
} from '@open-design/contracts/api/providerModels';
|
||||
import { isLoopbackApiHost } from '@open-design/contracts/api/connectionTest';
|
||||
import { redactSecrets, validateBaseUrlResolved } from './connectionTest.js';
|
||||
import { googleProviderModelsUrl, normalizeGoogleModelId } from './google-models.js';
|
||||
|
||||
type ProviderModelsInput = ProviderModelsRequest & { signal?: AbortSignal };
|
||||
|
||||
|
|
@ -114,11 +115,10 @@ function extractAnthropicModels(data: unknown): ProviderModelOption[] {
|
|||
|
||||
function googleModelId(rawName: unknown, rawBaseModelId: unknown): string {
|
||||
if (typeof rawBaseModelId === 'string' && rawBaseModelId.trim()) {
|
||||
return rawBaseModelId.trim();
|
||||
return normalizeGoogleModelId(rawBaseModelId);
|
||||
}
|
||||
if (typeof rawName !== 'string') return '';
|
||||
const name = rawName.trim();
|
||||
return name.startsWith('models/') ? name.slice('models/'.length) : name;
|
||||
return normalizeGoogleModelId(rawName);
|
||||
}
|
||||
|
||||
function supportsGoogleGenerateContent(item: unknown): boolean {
|
||||
|
|
@ -158,9 +158,7 @@ function providerModelsUrl(protocol: ConnectionTestProtocol, baseUrl: string, ap
|
|||
return url.toString();
|
||||
}
|
||||
if (protocol === 'google') {
|
||||
const url = new URL(`${baseUrl.replace(/\/+$/, '')}/v1beta/models`);
|
||||
url.searchParams.set('key', apiKey);
|
||||
return url.toString();
|
||||
return googleProviderModelsUrl(baseUrl, apiKey);
|
||||
}
|
||||
throw new Error(`Unsupported protocol: ${protocol}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -271,6 +271,39 @@ describe('POST /api/provider/models', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not double-append v1beta when listing Gemini models', async () => {
|
||||
const fetchMock = passThroughOrUpstream((url) => {
|
||||
expect(url).toBe(
|
||||
'https://generativelanguage.googleapis.com/v1beta/models?key=goog-key',
|
||||
);
|
||||
return jsonResponse({
|
||||
models: [
|
||||
{
|
||||
name: 'models/gemini-2.0-flash',
|
||||
displayName: 'Gemini 2.0 Flash',
|
||||
supportedGenerationMethods: ['generateContent'],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const res = await realFetch(`${baseUrl}/api/provider/models`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
protocol: 'google',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
|
||||
apiKey: 'goog-key',
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(res.json()).resolves.toMatchObject({
|
||||
ok: true,
|
||||
models: [{ id: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('lets unsupported contract protocols return a classified provider-models result', async () => {
|
||||
const fetchMock = passThroughOrUpstream(() => jsonResponse({}));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
|
@ -1400,6 +1433,38 @@ describe('POST /api/test/connection provider mode', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('normalizes Gemini model ids and base URLs in the provider smoke test', async () => {
|
||||
const fetchMock = passThroughOrUpstream(() =>
|
||||
jsonResponse({
|
||||
candidates: [
|
||||
{ content: { parts: [{ text: 'ok' }] } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const res = await realFetch(`${baseUrl}/api/test/connection`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
mode: 'provider',
|
||||
protocol: 'google',
|
||||
baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
|
||||
apiKey: 'goog-key',
|
||||
model: 'models/gemini-2.0-flash',
|
||||
}),
|
||||
});
|
||||
const body = (await res.json()) as Record<string, unknown>;
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.sample).toBe('ok');
|
||||
const upstream = fetchMock.mock.calls.find(
|
||||
([input]) => !String(input).startsWith(baseUrl),
|
||||
);
|
||||
expect(String(upstream![0])).toBe(
|
||||
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects malformed bodies with HTTP 400 (not the test envelope)', async () => {
|
||||
const res = await realFetch(`${baseUrl}/api/test/connection`, {
|
||||
method: 'POST',
|
||||
|
|
|
|||
|
|
@ -691,6 +691,32 @@ describe('API proxy routes', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('normalizes Gemini model ids and base URLs in the streaming proxy', async () => {
|
||||
const fetchMock = vi.fn((input: FetchInput, init?: FetchInit) => {
|
||||
const url = String(input);
|
||||
if (url.startsWith(baseUrl)) return realFetch(input, init);
|
||||
return Promise.resolve(sseResponse('data: {"candidates":[{"content":{"parts":[{"text":"ok"}]}}]}\n\n'));
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await realFetch(`${baseUrl}/api/proxy/google/stream`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
|
||||
apiKey: 'google-key',
|
||||
model: 'models/gemini-2.0-flash',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
}),
|
||||
});
|
||||
|
||||
const [upstreamUrl, upstreamInit] = fetchMock.mock.calls[0]!;
|
||||
expect(String(upstreamUrl)).toBe(
|
||||
'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:streamGenerateContent?alt=sse',
|
||||
);
|
||||
expect(upstreamInit?.redirect).toBe('error');
|
||||
});
|
||||
|
||||
// Regression for PR #1176: the Ollama proxy fetch must also set
|
||||
// `redirect: 'error'`. Without it, a validated public host could
|
||||
// 3xx the daemon to a private/internal URL and slip past the
|
||||
|
|
|
|||
Loading…
Reference in a new issue