Skip to content

Commit 05a3b62

Browse files
committed
fix(providers): canonicalize baseUrl across paste/test/save/inference
Users pasting the full inference endpoint URL (e.g. .../v1/chat/completions) caused the /models probe to build nonsense paths like .../v1/chat/completions/v1/models, which many OpenAI-compatible gateways black-hole into "connection timeout". Hardcoded /v1 also corrupted vendors that use other version segments: Zhipu GLM (/api/paas/v4), Volcengine (/api/v3), Google AI Studio (/v1beta/openai). Centralize all base-URL handling in @open-codesign/shared: - stripInferenceEndpointSuffix: strip query/hash/trailing slash/endpoint suffix - ensureVersionedBase: trust any /v<n>[a-z\d]* segment; default /v1 otherwise - canonicalBaseUrl(url, wire): wire-aware root for config + SDK - modelsEndpointUrl(url, wire): GET /models URL for the Test button Wire into every call site so paste -> test -> save -> inference all resolve to the same base: connection-ipc.buildEndpointForWire, onboarding-ipc .runListEndpointModels, AddCustomProviderModal.handleSave, and core/agent.buildPiModel (defensive net for legacy configs).
1 parent b0a0a4a commit 05a3b62

10 files changed

Lines changed: 630 additions & 23 deletions

File tree

apps/desktop/src/main/connection-ipc.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,47 @@ describe('normalizeBaseUrl', () => {
596596
'https://generativelanguage.googleapis.com',
597597
);
598598
});
599+
600+
// Users often paste the full inference endpoint URL instead of the API root.
601+
// Regression: without suffix stripping we'd build .../v1/chat/completions/v1/models,
602+
// which many OpenAI-compatible gateways black-hole → "connection timeout".
603+
describe('strips endpoint path suffixes', () => {
604+
it('openai: /v1/chat/completions → /v1', () => {
605+
expect(normalizeBaseUrl('https://api.example.com/v1/chat/completions', 'openai')).toBe(
606+
'https://api.example.com/v1',
607+
);
608+
});
609+
610+
it('openai: /chat/completions (no /v1 prefix) → /v1', () => {
611+
expect(normalizeBaseUrl('https://api.example.com/chat/completions', 'openai')).toBe(
612+
'https://api.example.com/v1',
613+
);
614+
});
615+
616+
it('openai: /v1/responses → /v1', () => {
617+
expect(normalizeBaseUrl('https://api.example.com/v1/responses', 'openai')).toBe(
618+
'https://api.example.com/v1',
619+
);
620+
});
621+
622+
it('openai: /v1/models → /v1', () => {
623+
expect(normalizeBaseUrl('https://api.example.com/v1/models', 'openai')).toBe(
624+
'https://api.example.com/v1',
625+
);
626+
});
627+
628+
it('anthropic: /v1/messages → root', () => {
629+
expect(normalizeBaseUrl('https://api.anthropic.com/v1/messages', 'anthropic')).toBe(
630+
'https://api.anthropic.com',
631+
);
632+
});
633+
634+
it('openrouter: /v1/chat/completions with trailing slash → /v1', () => {
635+
expect(normalizeBaseUrl('https://openrouter.ai/api/v1/chat/completions/', 'openrouter')).toBe(
636+
'https://openrouter.ai/api/v1',
637+
);
638+
});
639+
});
599640
});
600641

601642
// ---------------------------------------------------------------------------

apps/desktop/src/main/connection-ipc.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@ import {
55
ERROR_CODES,
66
type SupportedOnboardingProvider,
77
type WireApi,
8+
canonicalBaseUrl,
9+
ensureVersionedBase,
810
isSupportedOnboardingProvider,
11+
stripInferenceEndpointSuffix,
912
} from '@open-codesign/shared';
1013
import { ipcMain } from './electron-runtime';
1114
import { getApiKeyForProvider, getCachedConfig } from './onboarding-ipc';
@@ -114,16 +117,19 @@ interface ProviderEndpoint {
114117
* so downstream path concatenation never produces duplicate segments.
115118
*
116119
* - anthropic: strip trailing /v1 — we append /v1/models internally
117-
* - openai / openrouter: ensure /v1 suffix — the API lives at <root>/v1/models
120+
* - openai / openrouter: ensure a version segment exists — the API lives at
121+
* <root>/<version>/models (usually /v1, but Zhipu uses /v4, Volcengine
122+
* uses /v3, Google AI Studio uses /v1beta/openai). If the user already
123+
* encoded a version we trust it; otherwise we default to /v1.
118124
* - google: strip trailing /v1 or /v1beta — we append the full path internally
119125
*/
120126
export function normalizeBaseUrl(
121127
baseUrl: string,
122128
provider: 'openai' | 'anthropic' | 'google' | 'openrouter',
123129
): string {
124-
const cleaned = baseUrl.replace(/\/+$/, ''); // strip trailing slashes
130+
const cleaned = stripInferenceEndpointSuffix(baseUrl);
125131
if (provider === 'openai' || provider === 'openrouter') {
126-
return cleaned.endsWith('/v1') ? cleaned : `${cleaned}/v1`;
132+
return ensureVersionedBase(cleaned);
127133
}
128134
if (provider === 'anthropic') {
129135
return cleaned.replace(/\/v1$/, '');
@@ -144,14 +150,10 @@ function buildEndpointForWire(
144150
wire: WireApi,
145151
baseUrl: string,
146152
): { url: string; normalizedBaseUrl: string } {
147-
if (wire === 'anthropic') {
148-
const cleaned = baseUrl.replace(/\/+$/, '').replace(/\/v1$/, '');
149-
return { url: `${cleaned}/v1/models`, normalizedBaseUrl: cleaned };
150-
}
151-
// openai-chat and openai-responses both expose /models at the v1 root.
152-
const cleaned = baseUrl.replace(/\/+$/, '');
153-
const withV1 = cleaned.endsWith('/v1') ? cleaned : `${cleaned}/v1`;
154-
return { url: `${withV1}/models`, normalizedBaseUrl: withV1 };
153+
const normalizedBaseUrl = canonicalBaseUrl(baseUrl, wire);
154+
const url =
155+
wire === 'anthropic' ? `${normalizedBaseUrl}/v1/models` : `${normalizedBaseUrl}/models`;
156+
return { url, normalizedBaseUrl };
155157
}
156158

157159
export function buildAuthHeadersForWire(

apps/desktop/src/main/onboarding-ipc.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
WireApiSchema,
1616
hydrateConfig,
1717
isSupportedOnboardingProvider,
18+
modelsEndpointUrl,
1819
} from '@open-codesign/shared';
1920
import { defaultConfigDir, readConfig, writeConfig } from './config';
2021
import { dialog, ipcMain, shell } from './electron-runtime';
@@ -874,13 +875,7 @@ async function runListEndpointModels(raw: unknown): Promise<ListEndpointModelsRe
874875
if (typeof apiKey !== 'string' || apiKey.trim().length === 0) {
875876
return { ok: false, error: 'apiKey required' };
876877
}
877-
const cleaned = baseUrl
878-
.replace(/\/+$/, '')
879-
.replace(/\/(chat\/completions|completions|responses|messages|models)$/, '');
880-
const url =
881-
parsedWire.data === 'anthropic'
882-
? `${cleaned.replace(/\/v1$/, '')}/v1/models`
883-
: `${cleaned.endsWith('/v1') ? cleaned : `${cleaned}/v1`}/models`;
878+
const url = modelsEndpointUrl(baseUrl, parsedWire.data);
884879
const headers: Record<string, string> =
885880
parsedWire.data === 'anthropic'
886881
? { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' }

apps/desktop/src/renderer/src/components/AddCustomProviderModal.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useT } from '@open-codesign/i18n';
2-
import { type WireApi, detectWireFromBaseUrl } from '@open-codesign/shared';
2+
import { type WireApi, canonicalBaseUrl, detectWireFromBaseUrl } from '@open-codesign/shared';
33
import { Button } from '@open-codesign/ui';
44
import { AlertCircle, CheckCircle, Loader2, X } from 'lucide-react';
55
import { useState } from 'react';
@@ -69,11 +69,15 @@ export function AddCustomProviderModal({ onSave, onClose, initialSetAsActive = t
6969
try {
7070
const slug = slugify(name);
7171
const id = `custom-${slug}-${Date.now().toString(36).slice(-4)}`;
72+
// Canonicalize before persisting so pi-ai / Anthropic SDK always see
73+
// the root they expect. Without this, a user pasting /v1/chat/completions
74+
// would have it stored verbatim and then pi-ai would append another
75+
// /chat/completions at inference time.
7276
await window.codesign.config.addProvider({
7377
id,
7478
name: name.trim() || id,
7579
wire,
76-
baseUrl: baseUrl.trim(),
80+
baseUrl: canonicalBaseUrl(baseUrl.trim(), wire),
7781
apiKey: apiKey.trim(),
7882
defaultModel: defaultModel.trim(),
7983
setAsActive: initialSetAsActive,

packages/core/src/agent.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
ERROR_CODES,
3939
type ModelRef,
4040
type StoredDesignSystem,
41+
canonicalBaseUrl,
4142
} from '@open-codesign/shared';
4243
import type { TSchema } from '@sinclair/typebox';
4344
import { buildTransformContext } from './context-prune.js';
@@ -258,12 +259,16 @@ function buildPiModel(
258259
ERROR_CODES.PROVIDER_BASE_URL_MISSING,
259260
);
260261
}
262+
// Defensive: canonicalize stored baseUrl before handing to pi-ai. Rescues
263+
// legacy configs that persisted pre-normalization (e.g. raw `/v1/chat/completions`
264+
// pasted in an older build). No-op for configs saved post-fix.
265+
const canonicalBase = wire ? canonicalBaseUrl(resolvedBaseUrl, wire) : resolvedBaseUrl;
261266
const out: PiModel = {
262267
id: model.modelId,
263268
name: model.modelId,
264269
api: apiForWire(wire),
265270
provider: model.provider,
266-
baseUrl: resolvedBaseUrl,
271+
baseUrl: canonicalBase,
267272
reasoning: true,
268273
input: ['text'],
269274
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },

packages/providers/src/validate.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,4 +113,41 @@ describe('pingProvider', () => {
113113
const result = await pingProvider('openai', 'sk-test', 'https://proxy.duckcoding.com/v1/');
114114
expect(result).toEqual({ ok: true, modelCount: 1 });
115115
});
116+
117+
it('strips /v1/chat/completions suffix when user pastes the full endpoint', async () => {
118+
mockFetch(async (url) => {
119+
expect(url).toBe('https://proxy.example.com/v1/models');
120+
return new Response(JSON.stringify({ data: [{ id: 'x' }] }), { status: 200 });
121+
});
122+
const result = await pingProvider(
123+
'openai',
124+
'sk-test',
125+
'https://proxy.example.com/v1/chat/completions',
126+
);
127+
expect(result).toEqual({ ok: true, modelCount: 1 });
128+
});
129+
130+
it('strips /chat/completions (no /v1 prefix) suffix', async () => {
131+
mockFetch(async (url) => {
132+
expect(url).toBe('https://proxy.example.com/v1/models');
133+
return new Response(JSON.stringify({ data: [] }), { status: 200 });
134+
});
135+
await pingProvider('openai', 'sk-test', 'https://proxy.example.com/chat/completions');
136+
});
137+
138+
it('strips /v1/messages suffix for Anthropic', async () => {
139+
mockFetch(async (url) => {
140+
expect(url).toBe('https://api.anthropic.com/v1/models');
141+
return new Response(JSON.stringify({ data: [] }), { status: 200 });
142+
});
143+
await pingProvider('anthropic', 'sk-ant-test', 'https://api.anthropic.com/v1/messages');
144+
});
145+
146+
it('strips /v1/responses suffix', async () => {
147+
mockFetch(async (url) => {
148+
expect(url).toBe('https://api.example.com/v1/models');
149+
return new Response(JSON.stringify({ data: [] }), { status: 200 });
150+
});
151+
await pingProvider('openai', 'sk-test', 'https://api.example.com/v1/responses');
152+
});
116153
});

packages/providers/src/validate.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
ERROR_CODES,
44
type SupportedOnboardingProvider,
55
isSupportedOnboardingProvider,
6+
stripInferenceEndpointSuffix,
67
} from '@open-codesign/shared';
78

89
export type ValidateResult =
@@ -16,13 +17,14 @@ interface ProviderEndpoint {
1617

1718
/**
1819
* Normalize a user-supplied baseUrl so that appending /v1/models never
19-
* produces a double /v1/ segment.
20+
* produces a double /v1/ segment, and so that pasting the full inference
21+
* endpoint (e.g. /v1/chat/completions) still resolves to the API root.
2022
*
2123
* - openai / openrouter: strip trailing /v1 — we append /v1/models below
2224
* - anthropic: strip trailing /v1 — we append /v1/models below
2325
*/
2426
function normalizeValidateBaseUrl(baseUrl: string): string {
25-
return baseUrl.replace(/\/+$/, '').replace(/\/v1$/, '');
27+
return stripInferenceEndpointSuffix(baseUrl).replace(/\/v1$/, '');
2628
}
2729

2830
function endpoint(provider: SupportedOnboardingProvider, baseUrl?: string): ProviderEndpoint {

0 commit comments

Comments
 (0)