mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +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 { isSafeId as isSafeProjectId } from './projects.js';
|
||||||
import { projectKindToTracking } from '@open-design/contracts/analytics';
|
import { projectKindToTracking } from '@open-design/contracts/analytics';
|
||||||
import { validateBaseUrlResolved } from './connectionTest.js';
|
import { validateBaseUrlResolved } from './connectionTest.js';
|
||||||
|
import { googleStreamGenerateContentUrl } from './google-models.js';
|
||||||
|
|
||||||
// Allowlist for the `/feedback` route. Mirrors the
|
// Allowlist for the `/feedback` route. Mirrors the
|
||||||
// ChatMessageFeedbackReasonCode union in packages/contracts/src/api/chat.ts.
|
// 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 = googleStreamGenerateContentUrl(effectiveBaseUrl, model);
|
||||||
const url = `${clean}/v1beta/models/${encodeURIComponent(model)}:streamGenerateContent?alt=sse`;
|
|
||||||
console.log(
|
console.log(
|
||||||
`[proxy:google] ${req.method} ${validated.parsed!.hostname} model=${model}`,
|
`[proxy:google] ${req.method} ${validated.parsed!.hostname} model=${model}`,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ import {
|
||||||
type ParsedBaseUrl,
|
type ParsedBaseUrl,
|
||||||
type ProviderTestRequest,
|
type ProviderTestRequest,
|
||||||
} from '@open-design/contracts/api/connectionTest';
|
} from '@open-design/contracts/api/connectionTest';
|
||||||
|
import { googleGenerateContentUrl } from './google-models.js';
|
||||||
|
|
||||||
export { validateBaseUrl } from '@open-design/contracts/api/connectionTest';
|
export { validateBaseUrl } from '@open-design/contracts/api/connectionTest';
|
||||||
|
|
||||||
|
|
@ -621,9 +622,8 @@ function buildProviderCall(input: ProviderTestRequest): ProviderCallShape {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'google': {
|
case 'google': {
|
||||||
const trimmedBase = baseUrl.replace(/\/+$/, '');
|
|
||||||
return {
|
return {
|
||||||
url: `${trimmedBase}/v1beta/models/${encodeURIComponent(model)}:generateContent`,
|
url: googleGenerateContentUrl(baseUrl, model),
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
'x-goog-api-key': apiKey,
|
'x-goog-api-key': apiKey,
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ import {
|
||||||
validateProjectPath,
|
validateProjectPath,
|
||||||
} from './projects.js';
|
} from './projects.js';
|
||||||
import { exportProjectTranscript } from './transcript-export.js';
|
import { exportProjectTranscript } from './transcript-export.js';
|
||||||
|
import { googleGenerateContentUrl } from './google-models.js';
|
||||||
|
|
||||||
// Re-export the request/response types so existing daemon-internal
|
// Re-export the request/response types so existing daemon-internal
|
||||||
// imports (and the route handler) keep their referenced names. The
|
// imports (and the route handler) keep their referenced names. The
|
||||||
|
|
@ -595,9 +596,8 @@ function buildFinalizeProviderRequest(params: FinalizeProviderCallParams): Final
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.protocol === 'google') {
|
if (params.protocol === 'google') {
|
||||||
const clean = params.baseUrl.replace(/\/+$/, '');
|
|
||||||
return {
|
return {
|
||||||
url: `${clean}/v1beta/models/${encodeURIComponent(params.model)}:generateContent`,
|
url: googleGenerateContentUrl(params.baseUrl, params.model),
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
'x-goog-api-key': params.apiKey,
|
'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';
|
} from '@open-design/contracts/api/providerModels';
|
||||||
import { isLoopbackApiHost } from '@open-design/contracts/api/connectionTest';
|
import { isLoopbackApiHost } from '@open-design/contracts/api/connectionTest';
|
||||||
import { redactSecrets, validateBaseUrlResolved } from './connectionTest.js';
|
import { redactSecrets, validateBaseUrlResolved } from './connectionTest.js';
|
||||||
|
import { googleProviderModelsUrl, normalizeGoogleModelId } from './google-models.js';
|
||||||
|
|
||||||
type ProviderModelsInput = ProviderModelsRequest & { signal?: AbortSignal };
|
type ProviderModelsInput = ProviderModelsRequest & { signal?: AbortSignal };
|
||||||
|
|
||||||
|
|
@ -114,11 +115,10 @@ function extractAnthropicModels(data: unknown): ProviderModelOption[] {
|
||||||
|
|
||||||
function googleModelId(rawName: unknown, rawBaseModelId: unknown): string {
|
function googleModelId(rawName: unknown, rawBaseModelId: unknown): string {
|
||||||
if (typeof rawBaseModelId === 'string' && rawBaseModelId.trim()) {
|
if (typeof rawBaseModelId === 'string' && rawBaseModelId.trim()) {
|
||||||
return rawBaseModelId.trim();
|
return normalizeGoogleModelId(rawBaseModelId);
|
||||||
}
|
}
|
||||||
if (typeof rawName !== 'string') return '';
|
if (typeof rawName !== 'string') return '';
|
||||||
const name = rawName.trim();
|
return normalizeGoogleModelId(rawName);
|
||||||
return name.startsWith('models/') ? name.slice('models/'.length) : name;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function supportsGoogleGenerateContent(item: unknown): boolean {
|
function supportsGoogleGenerateContent(item: unknown): boolean {
|
||||||
|
|
@ -158,9 +158,7 @@ function providerModelsUrl(protocol: ConnectionTestProtocol, baseUrl: string, ap
|
||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
if (protocol === 'google') {
|
if (protocol === 'google') {
|
||||||
const url = new URL(`${baseUrl.replace(/\/+$/, '')}/v1beta/models`);
|
return googleProviderModelsUrl(baseUrl, apiKey);
|
||||||
url.searchParams.set('key', apiKey);
|
|
||||||
return url.toString();
|
|
||||||
}
|
}
|
||||||
throw new Error(`Unsupported protocol: ${protocol}`);
|
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 () => {
|
it('lets unsupported contract protocols return a classified provider-models result', async () => {
|
||||||
const fetchMock = passThroughOrUpstream(() => jsonResponse({}));
|
const fetchMock = passThroughOrUpstream(() => jsonResponse({}));
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
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 () => {
|
it('rejects malformed bodies with HTTP 400 (not the test envelope)', async () => {
|
||||||
const res = await realFetch(`${baseUrl}/api/test/connection`, {
|
const res = await realFetch(`${baseUrl}/api/test/connection`, {
|
||||||
method: 'POST',
|
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
|
// Regression for PR #1176: the Ollama proxy fetch must also set
|
||||||
// `redirect: 'error'`. Without it, a validated public host could
|
// `redirect: 'error'`. Without it, a validated public host could
|
||||||
// 3xx the daemon to a private/internal URL and slip past the
|
// 3xx the daemon to a private/internal URL and slip past the
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue