diff --git a/packages/runtime/src/__tests__/provider-conformance.test.ts b/packages/runtime/src/__tests__/provider-conformance.test.ts index 7f430d6c50..debb625756 100644 --- a/packages/runtime/src/__tests__/provider-conformance.test.ts +++ b/packages/runtime/src/__tests__/provider-conformance.test.ts @@ -1111,6 +1111,199 @@ describe('models.dev provider conformance', () => { assert.equal(probedPath, '/v1/responses'); }); + test('a root-mounted Responses relay keeps discovery and probes at the configured root', 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 === '/models') { + respondJson(response, 200, { data: [{ id: 'relay-reasoner' }] }); + return; + } + if (request.method === 'POST' && request.url === '/responses') { + respondJson(response, 200, {}); + return; + } + 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: '/models' }, + { method: 'POST', url: '/responses' }, + ]); + }); + + test('a Responses relay falls back to /v1/models when its root model route is absent', 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 === '/models') { + respondJson(response, 404, { error: { message: 'not found' } }); + return; + } + if (request.method === 'GET' && request.url === '/v1/models') { + respondJson(response, 200, { data: [{ id: 'relay-reasoner' }] }); + return; + } + if (request.method === 'POST' && request.url === '/responses') { + respondJson(response, 200, {}); + return; + } + 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: '/models' }, + { method: 'GET', url: '/v1/models' }, + { method: 'POST', url: '/responses' }, + ]); + }); + + test('a plain OpenAI-compatible relay never retries discovery at /v1/models', async () => { + // The 404 fallback is scoped to the Responses relay by provider type; both + // OpenAI-shaped adapters share this discovery branch. + const requestedPaths: string[] = []; + const server = await startJsonServer((request, response) => { + requestedPaths.push(request.url ?? ''); + respondJson(response, 404, { error: { message: 'not found' } }); + }); + const connection: LlmConnection = { + slug: 'chat-relay', + name: 'Chat Relay', + providerType: 'openai-compatible', + baseUrl: server.url, + defaultModel: 'relay-model', + enabled: true, + createdAt: 1, + updatedAt: 1, + }; + + await assert.rejects(fetchProviderModels(connection, 'relay-key')); + assert.deepEqual(requestedPaths, ['/models']); + }); + + test('a path-mounted Responses relay never appends /v1 to its own prefix', async () => { + const requestedPaths: string[] = []; + const server = await startJsonServer((request, response) => { + requestedPaths.push(request.url ?? ''); + respondJson(response, 404, { error: { message: 'not found' } }); + }); + const connection: LlmConnection = { + slug: 'responses-relay', + name: 'Responses Relay', + providerType: 'openai-responses-compatible', + baseUrl: `${server.url}/gateway`, + defaultModel: 'relay-reasoner', + enabled: true, + createdAt: 1, + updatedAt: 1, + }; + + await assert.rejects(fetchProviderModels(connection, 'relay-key')); + assert.deepEqual(requestedPaths, ['/gateway/models']); + }); + + test('an endpoint-form Responses relay discovers through its own API base', async () => { + const requestedPaths: string[] = []; + const server = await startJsonServer((request, response) => { + requestedPaths.push(request.url ?? ''); + if (request.url === '/v1/models') { + respondJson(response, 200, { data: [{ id: 'relay-reasoner' }] }); + return; + } + respondJson(response, 404, { error: { message: 'not found' } }); + }); + const connection: LlmConnection = { + slug: 'responses-relay', + name: 'Responses Relay', + providerType: 'openai-responses-compatible', + baseUrl: `${server.url}/v1/responses`, + defaultModel: 'relay-reasoner', + enabled: true, + createdAt: 1, + updatedAt: 1, + }; + + assert.deepEqual( + (await fetchProviderModels(connection, 'relay-key')).map(({ id }) => id), + ['relay-reasoner'], + ); + assert.deepEqual(requestedPaths, ['/v1/models']); + }); + + test('a Responses relay does not mask root authorization failures with a /v1 fallback', async () => { + const requestedPaths: string[] = []; + const server = await startJsonServer((request, response) => { + requestedPaths.push(request.url ?? ''); + respondJson(response, 401, { error: { message: 'unauthorized' } }); + }); + 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, + }; + + await assert.rejects(fetchProviderModels(connection, 'bad-key')); + assert.deepEqual(requestedPaths, ['/models']); + }); + + 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 compatibility fallback is scoped to custom relay discovery; a + // built-in root probe must remain unversioned. + assert.equal((await testConnection(connection, 'deepseek-token')).ok, true); + assert.equal(probedPath, '/responses'); + }); + for (const [label, providerType] of [ ['a plain OpenAI-compatible relay', 'openai-compatible'], ['local Ollama', 'ollama'], diff --git a/packages/runtime/src/__tests__/responses-wire-contract.test.ts b/packages/runtime/src/__tests__/responses-wire-contract.test.ts index 11d2b38cba..f1b32a6bcb 100644 --- a/packages/runtime/src/__tests__/responses-wire-contract.test.ts +++ b/packages/runtime/src/__tests__/responses-wire-contract.test.ts @@ -301,6 +301,40 @@ describe('responses wire contract', () => { assert.deepEqual(urls, ['https://relay.example/v1/responses']); }); + test('keeps a Responses relay host root on its configured API base', async () => { + assert.equal( + resolveModelRuntime( + { providerType: 'openai-responses-compatible', baseUrl: 'http://relay.example:3000' }, + 'relay-model', + ).baseUrl, + 'http://relay.example:3000', + ); + + 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/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 e886114c3e..cf2a6d9ff2 100644 --- a/packages/runtime/src/model-fetcher.ts +++ b/packages/runtime/src/model-fetcher.ts @@ -33,7 +33,7 @@ import { CONNECTION_MODEL_ID_MAX_LENGTH, normalizeConnectionModelDiscoveryResult, } from '@maka/core/runtime-policy'; -import { anthropicV1Url, googleApiUrl } from './provider-urls.js'; +import { anthropicV1Url, googleApiUrl, openAiResponsesBaseUrl } from './provider-urls.js'; import { openAiCodexHeaders } from './subscription-auth.js'; import { GITHUB_COPILOT_API_VERSION, @@ -182,7 +182,14 @@ async function fetchProviderModelsStrict( apiKey: string, fetchFn: ConnectionEffectFetch | undefined, ): Promise { - const baseUrl = effectiveBaseUrl(connection); + // Endpoint-form overrides are valid for Responses send and probe. Reduce + // them to the API base before deriving `/models`; bare roots stay + // authoritative and get only the route-not-found fallback below (#3320). + const configuredBaseUrl = effectiveBaseUrl(connection); + const baseUrl = + connection.providerType === 'openai-responses-compatible' + ? openAiResponsesBaseUrl(configuredBaseUrl) + : configuredBaseUrl; const definition = PROVIDER_REGISTRY[connection.providerType]; // Unknown providerType → no discovery path. Throw a clear error (caught and // generalized by the caller) rather than crashing on `.modelDiscovery`. @@ -245,21 +252,33 @@ async function fetchProviderModelsStrict( } case 'openai': case 'openai-compatible': { - const r = await fetchForConnectionEffect( + const requestInit = { + headers: { + 'content-type': 'application/json', + ...(apiKey && + (discovery.auth === 'oauth-bearer' || + (discovery.auth !== 'none' && providerAuthSupportsApiKey(connection.providerType))) + ? { authorization: `Bearer ${apiKey}` } + : {}), + }, + timeoutMs: MODEL_FETCH_TIMEOUT_MS, + }; + let r = await fetchForConnectionEffect( fetchFn, modelListUrl(baseUrl, discovery.path, discovery.query), - { - headers: { - 'content-type': 'application/json', - ...(apiKey && - (discovery.auth === 'oauth-bearer' || - (discovery.auth !== 'none' && providerAuthSupportsApiKey(connection.providerType))) - ? { authorization: `Bearer ${apiKey}` } - : {}), - }, - timeoutMs: MODEL_FETCH_TIMEOUT_MS, - }, + requestInit, ); + if (!r.ok && r.status === 404 && connection.providerType === 'openai-responses-compatible') { + const fallbackBaseUrl = responsesRelayV1FallbackBaseUrl(baseUrl); + if (fallbackBaseUrl) { + await r.cancel(); + r = await fetchForConnectionEffect( + fetchFn, + modelListUrl(fallbackBaseUrl, discovery.path, discovery.query), + requestInit, + ); + } + } if (!r.ok) { await r.cancel(); if (connection.providerType === 'xai-oauth') { @@ -669,6 +688,23 @@ function modelListUrl( return search ? `${url}?${search}` : url; } +/** + * A pathless Responses relay keeps its configured root as the primary + * contract. If that exact `/models` route is absent, discovery can safely + * retry the conventional versioned list once; send and probe are not replayed. + */ +function responsesRelayV1FallbackBaseUrl(baseUrl: string): string | undefined { + let url: URL; + try { + url = new URL(baseUrl); + } catch { + return undefined; + } + if (stripTrailing(url.pathname) !== '') return undefined; + url.pathname = '/v1'; + return url.toString(); +} + async function fetchFireworksModels( baseUrl: string, apiKey: string,