diff --git a/packages/runtime/src/__tests__/provider-conformance.test.ts b/packages/runtime/src/__tests__/provider-conformance.test.ts index 06c3cad86a..b53a8dbf98 100644 --- a/packages/runtime/src/__tests__/provider-conformance.test.ts +++ b/packages/runtime/src/__tests__/provider-conformance.test.ts @@ -956,6 +956,68 @@ describe('models.dev provider conformance', () => { assert.equal(probedPath, '/v1/responses'); }); + test('a Responses relay configured with its host root discovers and probes one /v1 base', async () => { + const requests: Array<{ method: string; url: string }> = []; + const server = await startJsonServer((request, response) => { + requests.push({ method: request.method ?? '', url: request.url ?? '' }); + if (request.method === 'GET' && request.url === '/v1/models') { + respondJson(response, 200, { data: [{ id: 'relay-reasoner' }] }); + return; + } + if (request.method === 'POST' && request.url === '/v1/responses') { + respondJson(response, 200, {}); + return; + } + // A mainstream relay mounts the OpenAI surface under `/v1` only. That is + // what let the host-root form pass the probe and 404 on discovery: both + // addresses have to come from one normalized API base (#3320). + respondJson(response, 404, { error: { message: 'not found' } }); + }); + const connection: LlmConnection = { + slug: 'responses-relay', + name: 'Responses Relay', + providerType: 'openai-responses-compatible', + baseUrl: server.url, + defaultModel: 'relay-reasoner', + enabled: true, + createdAt: 1, + updatedAt: 1, + }; + + assert.deepEqual( + (await fetchProviderModels(connection, 'relay-key')).map(({ id }) => id), + ['relay-reasoner'], + ); + assert.equal((await testConnection(connection, 'relay-key')).ok, true); + assert.deepEqual(requests, [ + { method: 'GET', url: '/v1/models' }, + { method: 'POST', url: '/v1/responses' }, + ]); + }); + + test('a DeepSeek root base keeps serving Responses at the root it publishes', async () => { + let probedPath: string | undefined; + const server = await startJsonServer((request, response) => { + probedPath = request.url; + respondJson(response, 200, {}); + }); + const connection: LlmConnection = { + slug: 'deepseek', + name: 'DeepSeek', + providerType: 'deepseek', + baseUrl: server.url, + defaultModel: 'deepseek-v4-pro', + enabled: true, + createdAt: 1, + updatedAt: 1, + }; + + // The relay self-heal is provider-scoped: a built-in that publishes an + // unversioned root must not be rewritten to `/v1` behind the user's back. + assert.equal((await testConnection(connection, 'deepseek-token')).ok, true); + assert.equal(probedPath, '/responses'); + }); + test('Ollama Cloud requests usage in streamed chat completions', async () => { let requestBody: Record | undefined; const server = await startJsonServer(async (request, response) => { diff --git a/packages/runtime/src/__tests__/provider-contract-matrix.test.ts b/packages/runtime/src/__tests__/provider-contract-matrix.test.ts index 4f3b95ffe5..d87486a1e0 100644 --- a/packages/runtime/src/__tests__/provider-contract-matrix.test.ts +++ b/packages/runtime/src/__tests__/provider-contract-matrix.test.ts @@ -202,7 +202,7 @@ function assertDiscoveryRequest( // Path: the declared path, or the protocol's default models path. assert.equal( url.pathname, - expectedDiscoveryPathname(discovery), + expectedDiscoveryPathname(row, discovery), `${where} must request the declared models path`, ); @@ -249,7 +249,10 @@ function assertDiscoveryRequest( } } -function expectedDiscoveryPathname(discovery: ProviderContractDiscoveryPlan): string { +function expectedDiscoveryPathname( + row: ProviderContractRow, + discovery: ProviderContractDiscoveryPlan, +): string { switch (discovery.protocol) { case 'anthropic': return '/v1/models'; @@ -257,7 +260,11 @@ function expectedDiscoveryPathname(discovery: ProviderContractDiscoveryPlan): st return '/v1beta/models'; default: { const path = discovery.path ?? '/models'; - return path.startsWith('/') ? path : `/${path}`; + const pathname = path.startsWith('/') ? path : `/${path}`; + // A Responses relay normalizes the host root `baseConnection` hands it to + // `/v1`, so discovery and the Responses request resolve from one API base + // instead of splitting across `/models` and `/v1/models` (#3320). + return row.providerType === 'openai-responses-compatible' ? `/v1${pathname}` : pathname; } } } diff --git a/packages/runtime/src/__tests__/responses-wire-contract.test.ts b/packages/runtime/src/__tests__/responses-wire-contract.test.ts index 99b1bc93cf..7348ffd715 100644 --- a/packages/runtime/src/__tests__/responses-wire-contract.test.ts +++ b/packages/runtime/src/__tests__/responses-wire-contract.test.ts @@ -28,7 +28,11 @@ import { buildProviderOptions, getAIModel } from '../model-factory.js'; import { resolveModelRuntime } from '../model-runtime.js'; import { lowerModelTools } from '../model-adapter.js'; import { openAiCodexCompactionMessages } from '../openai-codex-history-compactor.js'; -import { openAiResponsesBaseUrl, openResponsesUrl } from '../provider-urls.js'; +import { + openAiResponsesBaseUrl, + openResponsesUrl, + responsesRelayApiBaseUrl, +} from '../provider-urls.js'; function conn(providerType: LlmConnection['providerType'], slug = 'test'): LlmConnection { return { @@ -141,6 +145,68 @@ describe('responses wire contract', () => { assert.deepEqual(urls, ['https://relay.example/v1/responses']); }); + test('resolves a Responses relay host root to one versioned API base', async () => { + assert.equal( + responsesRelayApiBaseUrl('http://relay.example:3000'), + 'http://relay.example:3000/v1', + ); + assert.equal( + responsesRelayApiBaseUrl('http://relay.example:3000/'), + 'http://relay.example:3000/v1', + ); + assert.equal(responsesRelayApiBaseUrl('https://relay.example/v1'), 'https://relay.example/v1'); + assert.equal(responsesRelayApiBaseUrl('https://relay.example/v1/'), 'https://relay.example/v1'); + assert.equal( + responsesRelayApiBaseUrl('https://relay.example/v1/responses'), + 'https://relay.example/v1', + ); + assert.equal( + responsesRelayApiBaseUrl('https://relay.example/relay/v1'), + 'https://relay.example/relay/v1', + ); + // No base to normalize: the caller fails where it already failed instead of + // on a URL constructor. + assert.equal(responsesRelayApiBaseUrl(''), ''); + + // The relay resolves send, probe, and discovery from that one base, while a + // built-in that publishes an unversioned root keeps it (#3320). + assert.equal( + resolveModelRuntime( + { providerType: 'openai-responses-compatible', baseUrl: 'http://relay.example:3000' }, + 'relay-model', + ).baseUrl, + 'http://relay.example:3000/v1', + ); + assert.equal( + resolveModelRuntime({ providerType: 'deepseek' }, 'deepseek-v4-flash').baseUrl, + 'https://api.deepseek.com', + ); + + const urls: string[] = []; + const fetch = (async (url: string | URL | Request) => { + urls.push(String(url)); + return Response.json({ + id: 'r', + object: 'response', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 1 }, + }); + }) as typeof globalThis.fetch; + const model = getAIModel({ + connection: { ...conn('openai-responses-compatible'), baseUrl: 'http://relay.example:3000' }, + apiKey: '[redacted]', + modelId: 'relay-model', + fetch, + }); + + await model.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'ping' }] }], + }); + + assert.deepEqual(urls, ['http://relay.example:3000/v1/responses']); + }); + test('resolves only supported Responses adapter and replay pairings', () => { const deepseek = resolveModelRuntime({ providerType: 'deepseek' }, 'deepseek-v4-flash'); assert.deepEqual(deepseek.reasoningReplay, { diff --git a/packages/runtime/src/model-fetcher.ts b/packages/runtime/src/model-fetcher.ts index 3b4e478509..e07aa7ab96 100644 --- a/packages/runtime/src/model-fetcher.ts +++ b/packages/runtime/src/model-fetcher.ts @@ -30,7 +30,7 @@ import { CONNECTION_MODEL_ID_MAX_LENGTH, normalizeConnectionModelDiscoveryResult, } from '@maka/core/runtime-policy'; -import { anthropicV1Url, googleApiUrl } from './provider-urls.js'; +import { anthropicV1Url, googleApiUrl, responsesRelayApiBaseUrl } from './provider-urls.js'; import { openAiCodexHeaders } from './subscription-auth.js'; import { GITHUB_COPILOT_API_VERSION, @@ -179,7 +179,15 @@ async function fetchProviderModelsStrict( apiKey: string, fetchFn: ConnectionEffectFetch | undefined, ): Promise { - const baseUrl = effectiveBaseUrl(connection); + // A Responses relay serves `/models` next to `/responses`, so discovery has + // to resolve from the same API base `resolveModelRuntime` hands the send and + // probe paths — otherwise a configured host root passes the probe and 404s + // here (#3320). + const configuredBaseUrl = effectiveBaseUrl(connection); + const baseUrl = + connection.providerType === 'openai-responses-compatible' + ? responsesRelayApiBaseUrl(configuredBaseUrl) + : configuredBaseUrl; const definition = PROVIDER_DEFAULTS[connection.providerType]; // Unknown providerType → no discovery path. Throw a clear error (caught and // generalized by the caller) rather than crashing on `.modelDiscovery`. diff --git a/packages/runtime/src/model-runtime.ts b/packages/runtime/src/model-runtime.ts index 874c3e206b..d2db4f1a39 100644 --- a/packages/runtime/src/model-runtime.ts +++ b/packages/runtime/src/model-runtime.ts @@ -31,6 +31,7 @@ import { openAiAdapterApiProtocol, } from '@maka/core/model-metadata'; import { isRetiredProvider } from '@maka/core/provider-registry'; +import { responsesRelayApiBaseUrl } from './provider-urls.js'; import { resolveApplyPatchProfile, type ApplyPatchProfile } from './apply-patch-profile.js'; export type ModelRuntimeWire = @@ -120,10 +121,16 @@ export function resolveModelRuntime( const parallelToolCalls = resolveParallelToolCalls(connection, modelId, adapter); return { adapter, + // A Responses relay resolves its send and probe addresses from the same + // API base that model discovery uses in `fetchProviderModelsStrict`, so a + // configured host root cannot answer `/responses` while `/models` 404s + // (#3320). baseUrl: connection.providerType === 'kimi-coding-plan' && apiProtocol === 'openai-chat' ? kimiOpenAiBaseUrl(resolvedBaseUrl) - : resolvedBaseUrl, + : connection.providerType === 'openai-responses-compatible' + ? responsesRelayApiBaseUrl(resolvedBaseUrl) + : resolvedBaseUrl, ...(apiProtocol ? { apiProtocol } : {}), wire, reasoningReplay: reasoningReplayContract(adapter, wire), diff --git a/packages/runtime/src/provider-urls.ts b/packages/runtime/src/provider-urls.ts index f7ddbffc6a..6e08928d1e 100644 --- a/packages/runtime/src/provider-urls.ts +++ b/packages/runtime/src/provider-urls.ts @@ -75,6 +75,35 @@ export function openAiResponsesBaseUrl(baseUrl: string): string { return url.toString(); } +/** + * A custom OpenAI Responses relay is configured with a single URL that has to + * serve both `…/responses` (send and probe) and `…/models` (discovery). Users + * paste the host root — `http://relay.example:3000` — which mainstream relays + * mount under `/v1`, so `/responses` answers at the root while `/models` 404s + * and the model catalog stops refreshing behind a passing probe (#3320). + * + * Normalize the configured value into the one API base every relay call site + * derives from: a path-less root self-heals to `/v1`, as `anthropicV1BaseUrl` + * and `googleV1BetaBaseUrl` already do for their versions; an authored path + * (`/v1`, `/relay/v1`) is preserved verbatim; the endpoint form reduces to its + * base. Callers scope this to the relay provider, because built-ins such as + * DeepSeek legitimately serve `/responses` and `/models` at their own root. + * + * A value that is not a parsable absolute URL is returned untouched: there is + * no base to normalize, and the request it feeds fails where it fails today. + */ +export function responsesRelayApiBaseUrl(baseUrl: string): string { + let url: URL; + try { + url = new URL(baseUrl); + } catch { + return baseUrl; + } + const basePath = stripTrailing(url.pathname).replace(/\/responses$/i, ''); + url.pathname = basePath === '' ? '/v1' : basePath; + return url.toString(); +} + function stripTrailing(u: string): string { return u.replace(/\/+$/, ''); }