diff --git a/packages/core/src/__tests__/model-web-search.test.ts b/packages/core/src/__tests__/model-web-search.test.ts index 52bd3a9ff6..34deba60e8 100644 --- a/packages/core/src/__tests__/model-web-search.test.ts +++ b/packages/core/src/__tests__/model-web-search.test.ts @@ -77,6 +77,24 @@ describe('hosted web search capability', () => { ), null, ); + assert.equal( + resolveHostedWebSearchCapability( + 'openai', + [{ id: 'gpt-4.1', capabilities: { webSearch: true } }], + 'gpt-4.1', + ), + null, + 'capability metadata must not switch a Chat Completions model to Responses', + ); + assert.equal( + resolveHostedWebSearchCapability( + 'deepseek', + [{ id: 'deepseek-chat', capabilities: { webSearch: true } }], + 'deepseek-chat', + ), + null, + 'capability metadata must not switch a DeepSeek Chat model to Responses', + ); assert.deepEqual( resolveHostedWebSearchCapability( 'openai-responses-compatible', diff --git a/packages/core/src/model-web-search.ts b/packages/core/src/model-web-search.ts index 8bbffc8dad..137ca9d8e6 100644 --- a/packages/core/src/model-web-search.ts +++ b/packages/core/src/model-web-search.ts @@ -18,10 +18,11 @@ export interface HostedWebSearchCapability { /** * Resolves provider-hosted search for one exact model. * - * Stored model metadata wins when it explicitly declares webSearch. Unknown - * models then use narrow provider rules backed by the provider's public API - * contract. Unknown never means supported: sending an unsupported built-in - * tool can be silently ignored by several OpenAI-compatible providers. + * Stored model metadata can explicitly deny search, or grant it when the + * selected request wire can carry the provider tool. Unknown models use + * narrow provider rules backed by the provider's public API contract. Unknown + * never means supported: sending an unsupported built-in tool can be silently + * ignored by several OpenAI-compatible providers. * * The connection provider/wire is authoritative when a service supports both * Responses and Anthropic Messages. Search never switches wire independently @@ -47,11 +48,33 @@ export function resolveHostedWebSearchCapability( return null; } if (stored?.capabilities?.webSearch === true) { + // A capability declaration does not select a request protocol. These + // providers expose both Chat Completions and Responses, so an omitted + // apiProtocol must still follow the same narrow model-family default as + // the model factory. Otherwise metadata such as `webSearch: true` on a + // Chat model would make Runtime compile a Responses-only provider tool for + // the wrong wire. + if ( + stored.apiProtocol === undefined && + adapter.adapter === 'openai-responses' && + providerSelectsOpenAiWireByModel(providerType) + ) { + return providerDefaultHostedWebSearchCapability(providerType, id, adapter); + } return adapter; } return providerDefaultHostedWebSearchCapability(providerType, id, adapter); } +function providerSelectsOpenAiWireByModel(providerType: ProviderType): boolean { + return ( + providerType === 'deepseek' || + providerType === 'openai' || + providerType === 'xai' || + providerType === 'xai-oauth' + ); +} + function providerHostedWebSearchAdapter( providerType: ProviderType, ): HostedWebSearchCapability | null { diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts index 43d09a7ec6..1202c7f4ed 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -131,6 +131,51 @@ describe('ModelAdapter stream and error normalization', () => { }); }); + test('supports Responses reasoning replay for dual-wire OpenAI-compatible providers', () => { + const deepseek = new ModelAdapter({ + connection: { + slug: 'deepseek', + providerType: 'deepseek', + defaultModel: 'deepseek-v4-flash', + }, + apiKey: 'deepseek-token', + modelId: 'deepseek-v4-flash', + modelFactory: () => ({}), + newId: idGenerator(), + now: monotonicClock(), + }); + const chat = new ModelAdapter({ + connection: { + slug: 'deepseek', + providerType: 'deepseek', + defaultModel: 'deepseek-chat', + models: [{ id: 'deepseek-chat', apiProtocol: 'openai-chat' }], + }, + apiKey: 'deepseek-token', + modelId: 'deepseek-chat', + modelFactory: () => ({}), + newId: idGenerator(), + now: monotonicClock(), + }); + const explicitOpenAiChat = new ModelAdapter({ + connection: { + slug: 'openai', + providerType: 'openai', + defaultModel: 'gpt-5.5', + models: [{ id: 'gpt-5.5', apiProtocol: 'openai-chat' }], + }, + apiKey: 'openai-token', + modelId: 'gpt-5.5', + modelFactory: () => ({}), + newId: idGenerator(), + now: monotonicClock(), + }); + + assert.equal(deepseek.runtimeEventReplaySupport().openAiResponsesThinking, true); + assert.equal(chat.runtimeEventReplaySupport().openAiResponsesThinking, false); + assert.equal(explicitOpenAiChat.runtimeEventReplaySupport().openAiResponsesThinking, false); + }); + test('translates provider text, reasoning, tool calls, and errors into ModelStreamEvents', () => { const adapter = newAdapter(); type Chunk = Parameters[0]; diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index cf1c2e1eb9..a7583ff3dc 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -444,12 +444,26 @@ function usesKimiOpenAiChat(connection: RuntimeExecutionConnection, modelId: str function usesOpenAiResponses(connection: RuntimeExecutionConnection, modelId: string): boolean { const runtime = resolveModelRuntime(connection, modelId); - if (runtime.adapter.kind !== 'openai') return false; - return ( - runtime.adapter.apiProtocol === 'openai-responses' || - runtime.apiProtocol === 'openai-responses' || - openAiAdapterApiProtocol(modelId, connection.providerType) === 'openai-responses' - ); + switch (runtime.adapter.kind) { + case 'openai-codex': + return true; + case 'github-copilot': + return runtime.apiProtocol === 'openai-responses'; + case 'openai': + return ( + (runtime.adapter.apiProtocol ?? + runtime.apiProtocol ?? + openAiAdapterApiProtocol(modelId, connection.providerType)) === 'openai-responses' + ); + case 'openai-compatible': + return ( + runtime.adapter.supportsOpenAiResponses === true && + (runtime.apiProtocol ?? openAiAdapterApiProtocol(modelId, connection.providerType)) === + 'openai-responses' + ); + default: + return false; + } } function fixedAnthropicThinkingBudget(