diff --git a/packages/core/src/model-web-search.ts b/packages/core/src/model-web-search.ts index 9cb50cce41..2a618a335a 100644 --- a/packages/core/src/model-web-search.ts +++ b/packages/core/src/model-web-search.ts @@ -84,9 +84,9 @@ function providerHostedWebSearchAdapter( ? { adapter: wire, implemented: true } : null; case 'deepseek': - // @ai-sdk/open-responses currently serializes function tools only. - // Mark native search unavailable so routing never hands it a provider - // tool that would be silently filtered from the request. + // Open Responses codecs for DeepSeek's bare `web_search` wire are + // registered (#4107). Product routing stays fail-closed until #3689 + // enables the hosted-search capability on this adapter. return { adapter: 'openai-responses', implemented: false }; case 'openai': case 'xai': diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 1948c14265..6e8bf7cb08 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -3000,8 +3000,15 @@ describe('AiSdkBackend model history', () => { kind: 'function_response', id: 'search-1', name: 'WebSearch', - result: { type: 'web_search_result', query: 'latest Maka' }, - providerOutput: { type: 'web_search_result', id: 'ws_123' }, + result: [ + { + type: 'web_search_result', + url: 'https://maka.example/', + title: 'Maka', + pageAge: '2026-08-04', + encryptedContent: 'encrypted-result-ws_123', + }, + ], providerExecuted: true, isError: false, }, @@ -3158,18 +3165,42 @@ describe('AiSdkBackend model history', () => { ); }); - test('falls back to grounded text when Open Responses cannot replay a hosted tool pair', async () => { - const model = completionModel(); + test('synthesizes a DeepSeek hosted tool call when replay metadata is missing', async () => { + // History without carrier `providerOptions` takes encodeInputItem's + // synthesis path: emit `web_search_call`, drop the tool-result, keep text. + let requestBody: Record | undefined; + const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + requestBody = JSON.parse(String(init?.body)) as Record; + const events = [ + { type: 'response.created', response: { id: 'response-current' } }, + { + type: 'response.completed', + response: { + id: 'response-current', + object: 'response', + created_at: 8, + model: 'deepseek-v4-flash', + status: 'completed', + output: [], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }, + ]; + return new Response( + `${events.map((event) => `data: ${JSON.stringify(event)}`).join('\n\n')}\n\ndata: [DONE]\n\n`, + { status: 200, headers: { 'content-type': 'text/event-stream' } }, + ); + }) as unknown as typeof globalThis.fetch; const backend = createBackend({ connection: { slug: 'deepseek', providerType: 'deepseek', defaultModel: 'deepseek-v4-flash', }, - apiKey: 'deepseek-token', + apiKey: 'deepseek-test-token', modelId: 'deepseek-v4-flash', - modelFactory: () => model, - tools: [], + modelFactory: (input) => getAIModel({ ...input, fetch }), + tools: [buildNativeWebSearchTool({ adapter: 'openai-responses' })], }); await drain( @@ -3231,22 +3262,182 @@ describe('AiSdkBackend model history', () => { }), ); - const prompt = compactPrompt(model) as Array<{ role: string; content: unknown }>; - assert.match(JSON.stringify(prompt), /Maka shipped the feature/); - assert.equal(JSON.stringify(prompt).includes('tool-call'), false); - assert.equal(JSON.stringify(prompt).includes('tool-result'), false); + const input = requestBody?.input as Array> | undefined; + const searchCalls = input?.filter((item) => item.type === 'web_search_call'); + assert.equal(searchCalls?.length, 1, JSON.stringify(input)); + assert.deepEqual(searchCalls?.[0], { + id: 'search-1', + type: 'web_search_call', + status: 'completed', + action: { query: 'latest Maka' }, + }); + assert.match(JSON.stringify(input), /Maka shipped the feature/); + assert.equal( + input?.some( + (item) => + item.type === 'function_call_output' || + item.type === 'web_search_result' || + item.type === 'tool-result', + ), + false, + JSON.stringify(input), + ); + assert.equal(JSON.stringify(input).includes('web_search_result'), false); }); - test('keeps unrelated client tool history when degrading a hosted tool pair', async () => { - const model = completionModel(); + test('drops DeepSeek hosted search when replaying onto deepseek-chat', async () => { + let requestBody: Record | undefined; + const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + requestBody = JSON.parse(String(init?.body)) as Record; + const chunk = (delta: unknown, finish_reason: string | null = null) => ({ + id: 'chat-switch', + object: 'chat.completion.chunk', + created: 1, + model: 'deepseek-chat', + choices: [{ index: 0, delta, finish_reason }], + }); + const events = [ + chunk({ role: 'assistant', content: '' }), + chunk({ content: 'ok' }), + chunk({}, 'stop'), + ]; + return new Response( + `${events.map((event) => `data: ${JSON.stringify(event)}`).join('\n\n')}\n\ndata: [DONE]\n\n`, + { status: 200, headers: { 'content-type': 'text/event-stream' } }, + ); + }) as unknown as typeof globalThis.fetch; const backend = createBackend({ connection: { slug: 'deepseek', providerType: 'deepseek', - defaultModel: 'deepseek-v4-flash', + defaultModel: 'deepseek-chat', + }, + apiKey: 'deepseek-test-token', + modelId: 'deepseek-chat', + modelFactory: (input) => getAIModel({ ...input, fetch }), + tools: [], + }); + + const events: SessionEvent[] = []; + await collectEvents( + backend.send({ + turnId: 'turn-current', + text: '', + context: [], + runtimeContext: deepSeekHostedSearchHistory(), + continuation: { + sourceInvocationId: 'invocation-source', + sourceRunId: 'run-source', + sourceTurnId: 'turn-prev', + sourceRuntimeEventHighWater: 4, + }, + }), + events, + ); + + assert.equal( + events.find((event) => event.type === 'error'), + undefined, + JSON.stringify(events.find((event) => event.type === 'error')), + ); + const messages = requestBody?.messages as Array> | undefined; + assert.match(JSON.stringify(messages), /Maka shipped the feature/); + assert.equal( + messages?.some((message) => { + const toolCalls = message.tool_calls; + return ( + Array.isArray(toolCalls) && + toolCalls.some((call) => { + if (!call || typeof call !== 'object') return false; + const fn = (call as { function?: { name?: string } }).function; + return fn?.name === 'WebSearch'; + }) + ); + }), + false, + JSON.stringify(messages), + ); + assert.equal( + messages?.some((message) => message.role === 'tool'), + false, + JSON.stringify(messages), + ); + }); + + test('drops DeepSeek hosted search when replaying onto Anthropic web_search', async () => { + let requestBody: Record | undefined; + const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + requestBody = JSON.parse(String(init?.body)) as Record; + return new Response(anthropicCompletedSse('ok'), { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }); + }) as unknown as typeof globalThis.fetch; + const backend = createBackend({ + connection: connection(), + apiKey: 'anthropic-test-token', + modelId: 'claude-sonnet-4-5-20250929', + modelFactory: (input) => getAIModel({ ...input, fetch }), + tools: [buildNativeWebSearchTool({ adapter: 'anthropic-messages' })], + }); + + const events: SessionEvent[] = []; + await collectEvents( + backend.send({ + turnId: 'turn-current', + text: '', + context: [], + runtimeContext: deepSeekHostedSearchHistory({ + providerOptions: { + deepseek: { + openResponsesExtension: { + id: 'openai.web_search', + item: { + id: 'search-1', + type: 'web_search_call', + status: 'completed', + action: { type: 'search', query: 'latest Maka' }, + }, + }, + }, + }, + result: { + type: 'web_search_call', + status: 'completed', + action: { query: 'latest Maka' }, + }, + }), + continuation: { + sourceInvocationId: 'invocation-source', + sourceRunId: 'run-source', + sourceTurnId: 'turn-prev', + sourceRuntimeEventHighWater: 4, + }, + }), + events, + ); + + assert.equal( + events.find((event) => event.type === 'error'), + undefined, + JSON.stringify(events.find((event) => event.type === 'error')), + ); + const wire = JSON.stringify(requestBody); + assert.match(wire, /Maka shipped the feature/); + assert.equal(wire.includes('server_tool_use'), false, wire); + assert.equal(wire.includes('web_search_tool_result'), false, wire); + }); + + test('keeps unrelated client tool history when degrading a hosted tool pair', async () => { + const model = completionModel(); + const backend = createBackend({ + connection: { + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', }, apiKey: '[redacted]', - modelId: 'deepseek-v4-flash', + modelId: 'qwen3.8-max', modelFactory: () => model, tools: [], }); @@ -8716,6 +8907,113 @@ describe('AiSdkBackend usage telemetry', () => { ); }); + test('records a hosted-search answer as end_turn when the provider finish reason is tool-calls', async () => { + // Open Responses reports `tool-calls` for a provider-executed search that + // already includes the final answer. With maxSteps set, that must stay a + // successful end_turn rather than step_limit / failed. + const appended: StoredMessage[] = []; + let streamCalls = 0; + const model = new MockLanguageModelV4({ + doStream: async () => { + streamCalls += 1; + const chunks = ( + streamCalls === 1 + ? [ + { type: 'stream-start', warnings: [] }, + { + type: 'tool-call', + toolCallId: 'search-1', + toolName: 'WebSearch', + input: '{}', + providerExecuted: true, + }, + { + type: 'tool-result', + toolCallId: 'search-1', + toolName: 'WebSearch', + result: { + action: { type: 'search', queries: ['latest Maka'] }, + sources: [{ type: 'url', url: 'https://maka.example/' }], + }, + providerExecuted: true, + }, + { type: 'text-start', id: 'text-1' }, + { type: 'text-delta', id: 'text-1', delta: 'Maka shipped the feature.' }, + { type: 'text-end', id: 'text-1' }, + { + type: 'finish', + finishReason: { unified: 'tool-calls', raw: 'tool_calls' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ] + : [ + { type: 'stream-start', warnings: [] }, + { type: 'text-start', id: 'text-extra' }, + { type: 'text-delta', id: 'text-extra', delta: 'unexpected continuation' }, + { type: 'text-end', id: 'text-extra' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: { + inputTokens: { total: 1, noCache: 1, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 1, text: 1, reasoning: 0 }, + }, + }, + ] + ) as LanguageModelV4StreamPart[]; + return { + stream: simulateReadableStream({ + chunks, + initialDelayInMs: null, + chunkDelayInMs: null, + }), + }; + }, + }); + const backend = createBackend({ + appendMessage: async (message) => { + appended.push(message); + }, + connection: { + slug: 'deepseek', + providerType: 'deepseek', + defaultModel: 'deepseek-v4-flash', + }, + modelId: 'deepseek-v4-flash', + modelFactory: () => model, + tools: [buildNativeWebSearchTool({ adapter: 'openai-responses' })], + maxSteps: 4, + }); + + const events: SessionEvent[] = []; + await collectEvents( + backend.send({ turnId: 'turn-hosted-search-answer', text: 'search', context: [] }), + events, + ); + + assert.equal(streamCalls, 1); + assert.equal( + events.some((event) => event.type === 'error'), + false, + ); + const start = events.find((event) => event.type === 'tool_start'); + assert.equal(start?.type === 'tool_start' ? start.providerExecuted : undefined, true); + const result = events.find((event) => event.type === 'tool_result'); + assert.equal(result?.type === 'tool_result' ? result.providerExecuted : undefined, true); + assert.equal( + appended.find((message): message is AssistantMessage => message.type === 'assistant')?.text, + 'Maka shipped the feature.', + ); + assert.equal(events.at(-1)?.type, 'complete'); + assert.equal( + (events.at(-1) as Extract).stopReason, + 'end_turn', + ); + }); + test('records cumulative usage checkpoints across tool-loop steps and turns', async () => { const messages: unknown[] = []; const events: SessionEvent[] = []; @@ -16694,6 +16992,95 @@ function countingToolLoopModel(toolCallsBeforeStop?: number): { return { model, callCount: () => calls }; } +function deepSeekHostedSearchHistory(input?: { + providerOptions?: Record; + result?: unknown; +}): RuntimeEvent[] { + return [ + runtimeTextEvent({ + id: 'rt-u-search', + turnId: 'turn-prev', + role: 'user', + author: 'user', + text: 'search', + }), + runtimeEvent({ + id: 'rt-search-call', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + refs: { stepId: 'provider-step' }, + content: { + kind: 'function_call', + id: 'search-1', + name: 'WebSearch', + args: { query: 'latest Maka' }, + providerExecuted: true, + ...(input?.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), + }, + }), + runtimeEvent({ + id: 'rt-search-result', + turnId: 'turn-prev', + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: 'search-1', + name: 'WebSearch', + result: input?.result ?? { type: 'web_search_result', query: 'latest Maka' }, + providerExecuted: true, + isError: false, + }, + }), + runtimeEvent({ + id: 'rt-search-text', + turnId: 'turn-prev', + role: 'model', + author: 'agent', + refs: { providerEventId: 'provider-step' }, + content: { kind: 'text', text: 'Maka shipped the feature.' }, + }), + ]; +} + +function anthropicCompletedSse(text: string): string { + const send = (event: string, data: unknown) => + `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + return [ + send('message_start', { + type: 'message_start', + message: { + id: 'msg-switch', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5-20250929', + content: [], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 8, output_tokens: 0 }, + }, + }), + send('content_block_start', { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }), + send('content_block_delta', { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text }, + }), + send('content_block_stop', { type: 'content_block_stop', index: 0 }), + send('message_delta', { + type: 'message_delta', + delta: { stop_reason: 'end_turn' }, + usage: { output_tokens: 1 }, + }), + send('message_stop', { type: 'message_stop' }), + ].join(''); +} + function runtimeTextEvent(input: { id: string; turnId: string; diff --git a/packages/runtime/src/__tests__/deepseek-open-responses-extensions.test.ts b/packages/runtime/src/__tests__/deepseek-open-responses-extensions.test.ts new file mode 100644 index 0000000000..94fc9f8459 --- /dev/null +++ b/packages/runtime/src/__tests__/deepseek-open-responses-extensions.test.ts @@ -0,0 +1,1091 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { encodeCanonicalRuntimeEvent } from '@maka/core/canonical-runtime-event'; +import type { LlmConnection } from '@maka/core/llm-connections'; +import type { RuntimeEvent } from '@maka/core/runtime-event'; +import type { LanguageModelV4ProviderTool, LanguageModelV4StreamPart } from '@ai-sdk/provider'; +import { AiSdkMessageProjection } from '../ai-sdk-message-projection.js'; +import { getAIModel } from '../model-factory.js'; +import { ModelAdapter, lowerModelTools } from '../model-adapter.js'; +import { + buildRuntimeEventModelReplayPlan, + type RuntimeEventModelReplayPlan, +} from '../model-history.js'; +import { + attachOpenResponsesExtensionReplayItem, + createDeepSeekOpenResponsesExtensions, + DEEPSEEK_OPEN_RESPONSES_WEB_SEARCH_EXTENSION_ID, + OPEN_RESPONSES_EXTENSION_REPLAY_KIND, + openResponsesExtensionReplayCarrierPart, + openResponsesExtensionReplayItem, + openResponsesExtensionReplayReferenceOptions, + rewriteDeepSeekOpenResponsesIncomingValue, + rewriteDeepSeekOpenResponsesOutgoingBody, + usesDeepSeekOpenResponsesExtensions, + wrapFetchForDeepSeekOpenResponsesExtensions, +} from '../deepseek-open-responses-extensions.js'; +import { routeWebSearchTools } from '../native-web-search-tool.js'; + +function conn(providerType: LlmConnection['providerType'], slug = 'test'): LlmConnection { + return { + slug, + name: slug, + providerType, + defaultModel: 'm', + enabled: true, + createdAt: 0, + updatedAt: 0, + }; +} + +function deepSeekAdapter(): ModelAdapter { + return new ModelAdapter({ + connection: { + slug: 'deepseek', + providerType: 'deepseek', + defaultModel: 'deepseek-v4-flash', + }, + apiKey: 'test-key', + modelId: 'deepseek-v4-flash', + modelFactory: () => ({}), + newId: () => 'id-1', + now: () => 1, + }); +} + +function anthropicReplayAdapter(): ModelAdapter { + return new ModelAdapter({ + connection: { + slug: 'anthropic-main', + providerType: 'anthropic', + defaultModel: 'claude-sonnet-4-5-20250929', + }, + apiKey: 'test-key', + modelId: 'claude-sonnet-4-5-20250929', + modelFactory: () => ({}), + newId: () => 'id-1', + now: () => 1, + }); +} + +function hostedReplayIdentity(plan: RuntimeEventModelReplayPlan): string[] { + return plan.items.flatMap((item) => + item.kind === 'tool_call' || item.kind === 'tool_result' + ? [`${item.invocationId}:${item.kind}:${item.toolCallId}`] + : [], + ); +} + +function crossInvocationHostedSearchHistory(ids: { + deepSeek: string; + anthropic: string; +}): RuntimeEvent[] { + const exchange = ( + invocationId: string, + toolCallId: string, + providerOptions: Record, + providerOutput: unknown, + ): RuntimeEvent[] => [ + { + id: `${invocationId}-call`, + invocationId, + runId: invocationId, + sessionId: 'session-replay', + turnId: invocationId, + ts: 1, + partial: false, + role: 'model', + author: 'agent', + refs: { stepId: `${invocationId}-step` }, + content: { + kind: 'function_call', + id: toolCallId, + name: 'WebSearch', + args: { query: 'latest Maka' }, + providerExecuted: true, + providerOptions, + }, + }, + { + id: `${invocationId}-result`, + invocationId, + runId: invocationId, + sessionId: 'session-replay', + turnId: invocationId, + ts: 2, + partial: false, + role: 'tool', + author: 'tool', + content: { + kind: 'function_response', + id: toolCallId, + name: 'WebSearch', + result: providerOutput, + providerExecuted: true, + providerOutput, + isError: false, + }, + }, + ]; + return [ + ...exchange( + 'invocation-deepseek', + ids.deepSeek, + { + deepseek: { + openResponsesExtension: { + id: 'openai.web_search', + item: { id: ids.deepSeek, type: 'web_search_call', status: 'completed' }, + }, + }, + }, + { type: 'web_search_call', status: 'completed' }, + ), + ...exchange('invocation-anthropic', ids.anthropic, { anthropic: { type: 'server_tool_use' } }, [ + { + type: 'web_search_result', + url: 'https://maka.example/', + encryptedContent: 'encrypted-result', + }, + ]), + ]; +} + +function runtimeEvent(input: { + id: string; + role: RuntimeEvent['role']; + author: RuntimeEvent['author']; + content: RuntimeEvent['content']; + refs?: RuntimeEvent['refs']; +}): RuntimeEvent { + return { + id: input.id, + invocationId: 'inv-durable', + runId: 'run-durable', + sessionId: 'sess-durable', + turnId: 'turn-durable', + ts: 1, + partial: false, + role: input.role, + author: input.author, + content: input.content, + ...(input.refs ? { refs: input.refs } : {}), + }; +} + +function webSearchTool(): LanguageModelV4ProviderTool { + const tools = lowerModelTools({ + WebSearch: { kind: 'provider', providerTool: { kind: 'openai-web-search' } }, + }); + return { + ...(tools.WebSearch as object), + type: 'provider', + id: DEEPSEEK_OPEN_RESPONSES_WEB_SEARCH_EXTENSION_ID, + name: 'WebSearch', + args: { searchContextSize: 'medium' }, + }; +} + +function completedResponse(output: unknown[]): Record { + return { + id: 'resp_deepseek_search', + object: 'response', + created_at: 1_700_000_000, + model: 'deepseek-v4-flash', + status: 'completed', + output, + usage: { input_tokens: 8, output_tokens: 4 }, + }; +} + +function sse(events: Array>): string { + return events + .map((event, index) => `data: ${JSON.stringify({ sequence_number: index, ...event })}\n\n`) + .join(''); +} + +describe('DeepSeek Open Responses extension codecs', () => { + test('registers against the compiled Open Responses search tool id', () => { + const extensions = createDeepSeekOpenResponsesExtensions(); + assert.equal(extensions.length, 1); + assert.equal(extensions[0]?.id, DEEPSEEK_OPEN_RESPONSES_WEB_SEARCH_EXTENSION_ID); + assert.equal(usesDeepSeekOpenResponsesExtensions('deepseek'), true); + assert.equal(usesDeepSeekOpenResponsesExtensions('alibaba-token-plan-cn'), false); + }); + + test('rewrites only allowlisted DeepSeek discriminators', () => { + assert.deepEqual( + rewriteDeepSeekOpenResponsesOutgoingBody({ + tools: [ + { type: 'openai:web_search' }, + { type: 'function', name: 'Read' }, + { type: 'openai.file_search' }, + ], + tool_choice: { type: 'openai:web_search' }, + input: [ + { type: 'message', role: 'user', content: 'hi' }, + { type: 'openai:web_search_call', id: 'ws_1', status: 'completed' }, + ], + }), + { + tools: [ + { type: 'web_search' }, + { type: 'function', name: 'Read' }, + { type: 'openai.file_search' }, + ], + tool_choice: { type: 'web_search' }, + input: [ + { type: 'message', role: 'user', content: 'hi' }, + { type: 'web_search_call', id: 'ws_1', status: 'completed' }, + ], + }, + ); + assert.deepEqual( + rewriteDeepSeekOpenResponsesIncomingValue({ + type: 'response.web_search_call.in_progress', + item_id: 'ws_1', + item: { type: 'web_search_call', id: 'ws_1', status: 'in_progress' }, + output: [{ type: 'web_search_call', id: 'ws_1', status: 'completed' }], + }), + { + type: 'openai:web_search_call.in_progress', + item_id: 'ws_1', + item: { type: 'openai:web_search_call', id: 'ws_1', status: 'in_progress' }, + output: [{ type: 'openai:web_search_call', id: 'ws_1', status: 'completed' }], + }, + ); + assert.equal( + ( + rewriteDeepSeekOpenResponsesIncomingValue({ type: 'web_search_2025_08_26' }) as { + type: string; + } + ).type, + 'openai:web_search', + ); + assert.equal( + rewriteDeepSeekOpenResponsesOutgoingBody({ + tools: [{ type: 'function', name: 'Read' }], + input: [{ type: 'message', role: 'user', content: 'hi' }], + }), + undefined, + ); + }); + + test('wrapFetch rewrites only allowlisted discriminators on the wire', async () => { + let sent: Record | undefined; + const fetch = wrapFetchForDeepSeekOpenResponsesExtensions(async (_url, init) => { + sent = JSON.parse(String(init?.body)) as Record; + return Response.json({ + output: [ + { type: 'web_search_call', id: 'ws_1', status: 'completed' }, + { + type: 'function_call', + id: 'fc_1', + call_id: 'call_read', + name: 'Read', + arguments: '{}', + }, + ], + }); + }); + const response = await fetch('https://example.test/v1/responses', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + tools: [{ type: 'openai:web_search' }, { type: 'function', name: 'Read' }], + input: [{ type: 'openai:web_search_call', id: 'ws_1', status: 'completed' }], + }), + }); + assert.deepEqual(sent?.tools, [{ type: 'web_search' }, { type: 'function', name: 'Read' }]); + assert.deepEqual( + ((await response.json()) as { output: Array<{ type: string }> }).output.map( + (item) => item.type, + ), + ['openai:web_search_call', 'function_call'], + ); + }); + + test('wrapFetch keeps JSON request bodies as text when nothing maps', async () => { + let sent: unknown; + const fetch = wrapFetchForDeepSeekOpenResponsesExtensions(async (_url, init) => { + sent = init?.body; + return Response.json({ output: [] }); + }); + await fetch('https://example.test/v1/responses', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + tools: [{ type: 'function', name: 'Read' }], + input: [{ type: 'message', role: 'user', content: 'hi' }], + }), + }); + assert.equal(typeof sent, 'string'); + assert.deepEqual(JSON.parse(String(sent)), { + tools: [{ type: 'function', name: 'Read' }], + input: [{ type: 'message', role: 'user', content: 'hi' }], + }); + }); + + test('encodes DeepSeek hosted search as a bare web_search tool', async () => { + const bodies: Record[] = []; + const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body)) as Record); + return Response.json(completedResponse([])); + }) as unknown as typeof globalThis.fetch; + const model = getAIModel({ + connection: conn('deepseek'), + apiKey: 'test-key', + modelId: 'deepseek-v4-flash', + fetch, + }); + const result = await model.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'search the web' }] }], + tools: [webSearchTool()], + }); + + assert.deepEqual(bodies[0]?.tools, [{ type: 'web_search' }]); + assert.equal( + result.warnings?.some( + (warning) => + warning.type === 'unsupported' && + warning.feature === + `provider-defined tool ${DEEPSEEK_OPEN_RESPONSES_WEB_SEARCH_EXTENSION_ID}`, + ), + false, + JSON.stringify(result.warnings), + ); + }); + + test('omits unregistered provider tools with an explicit warning', async () => { + const bodies: Record[] = []; + const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body)) as Record); + return Response.json(completedResponse([])); + }) as unknown as typeof globalThis.fetch; + const model = getAIModel({ + connection: conn('deepseek'), + apiKey: 'test-key', + modelId: 'deepseek-v4-flash', + fetch, + }); + const result = await model.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'search files' }] }], + tools: [ + webSearchTool(), + { type: 'provider', id: 'openai.file_search', name: 'file_search', args: {} }, + ], + }); + + assert.deepEqual(bodies[0]?.tools, [{ type: 'web_search' }]); + assert.equal( + result.warnings?.some( + (warning) => + warning.type === 'unsupported' && + warning.feature === 'provider-defined tool openai.file_search', + ), + true, + JSON.stringify(result.warnings), + ); + }); + + test('decodes a completed hosted search item without entering the client tool loop', async () => { + const fetch = (async () => + Response.json( + completedResponse([ + { + id: 'ws_opaque', + type: 'web_search_call', + status: 'completed', + provider_trace: 'opaque-replay', + action: { type: 'search', query: 'latest Maka', queries: ['latest Maka'] }, + }, + { + id: 'msg_1', + type: 'message', + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text: 'Maka shipped the feature.' }], + }, + ]), + )) as unknown as typeof globalThis.fetch; + const model = getAIModel({ + connection: conn('deepseek'), + apiKey: 'test-key', + modelId: 'deepseek-v4-flash', + fetch, + }); + const result = await model.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'search' }] }], + tools: [webSearchTool()], + }); + + const types = result.content.map((part) => part.type); + assert.deepEqual( + types.filter((type) => type === 'tool-call' || type === 'tool-result' || type === 'text'), + ['tool-call', 'tool-result', 'text'], + ); + const call = result.content.find((part) => part.type === 'tool-call'); + const searchResult = result.content.find((part) => part.type === 'tool-result'); + assert.equal(call && 'providerExecuted' in call ? call.providerExecuted : undefined, true); + assert.equal(call && 'toolName' in call ? call.toolName : undefined, 'WebSearch'); + assert.match(JSON.stringify(call), /latest Maka/); + assert.match(JSON.stringify(searchResult), /latest Maka/); + assert.equal( + result.finishReason.unified === 'stop' || result.finishReason.unified === 'tool-calls', + true, + JSON.stringify(result.finishReason), + ); + }); + + test('keeps mixed client and provider-executed tools in chronology', async () => { + const bodies: Record[] = []; + const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body)) as Record); + return Response.json( + completedResponse([ + { + id: 'ws_mixed', + type: 'web_search_call', + status: 'completed', + action: { type: 'search', query: 'maka codecs' }, + }, + { + id: 'fc_read', + type: 'function_call', + status: 'completed', + call_id: 'call_read', + name: 'Read', + arguments: '{"path":"README.md"}', + }, + ]), + ); + }) as unknown as typeof globalThis.fetch; + const model = getAIModel({ + connection: conn('deepseek'), + apiKey: 'test-key', + modelId: 'deepseek-v4-flash', + fetch, + }); + const result = await model.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'search then read' }] }], + tools: [ + webSearchTool(), + { + type: 'function', + name: 'Read', + inputSchema: { + type: 'object', + properties: { path: { type: 'string' } }, + required: ['path'], + additionalProperties: false, + }, + }, + ], + }); + + assert.deepEqual( + (bodies[0]?.tools as Array> | undefined)?.map((tool) => tool.type), + ['web_search', 'function'], + ); + const owned = result.content + .filter((part) => part.type === 'tool-call' || part.type === 'tool-result') + .map((part) => ({ + type: part.type, + toolName: 'toolName' in part ? part.toolName : undefined, + providerExecuted: 'providerExecuted' in part ? part.providerExecuted : undefined, + })); + assert.deepEqual(owned, [ + { type: 'tool-call', toolName: 'WebSearch', providerExecuted: true }, + { type: 'tool-result', toolName: 'WebSearch', providerExecuted: true }, + { type: 'tool-call', toolName: 'Read', providerExecuted: undefined }, + ]); + }); + + test('replays the original hosted search item exactly once', async () => { + const bodies: Record[] = []; + const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body)) as Record); + return Response.json( + completedResponse([ + { + id: 'ws_opaque', + type: 'web_search_call', + status: 'completed', + provider_trace: 'opaque-replay', + action: { type: 'open_page', url: 'https://maka.example/' }, + }, + { + id: 'msg_1', + type: 'message', + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text: 'Opened the page.' }], + }, + ]), + ); + }) as unknown as typeof globalThis.fetch; + const model = getAIModel({ + connection: conn('deepseek'), + apiKey: 'test-key', + modelId: 'deepseek-v4-flash', + fetch, + }); + const first = await model.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'open the docs' }] }], + tools: [webSearchTool()], + }); + await model.doGenerate({ + prompt: [ + { role: 'user', content: [{ type: 'text', text: 'open the docs' }] }, + { role: 'assistant', content: first.content as never }, + { role: 'user', content: [{ type: 'text', text: 'continue' }] }, + ], + tools: [webSearchTool()], + }); + + const replayed = (bodies[1]?.input as Array> | undefined)?.filter( + (item) => item.type === 'web_search_call', + ); + assert.equal(replayed?.length, 1, JSON.stringify(bodies[1]?.input)); + assert.equal(replayed?.[0]?.id, 'ws_opaque'); + assert.equal(replayed?.[0]?.provider_trace, 'opaque-replay'); + assert.deepEqual(replayed?.[0]?.action, { type: 'open_page', url: 'https://maka.example/' }); + }); + + test('replays distinct hosted search items when ids differ', async () => { + // SDK encode dedups on `${type}:${id}`. Distinct ids must both reach the + // wire; same-id reuse across DeepSeek responses is unverified. + const bodies: Record[] = []; + const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body)) as Record); + return Response.json(completedResponse([])); + }) as unknown as typeof globalThis.fetch; + const model = getAIModel({ + connection: conn('deepseek'), + apiKey: 'test-key', + modelId: 'deepseek-v4-flash', + fetch, + }); + const first = { + type: 'tool-call' as const, + toolCallId: 'ws_a', + toolName: 'WebSearch', + input: JSON.stringify({ type: 'search', query: 'first' }), + providerExecuted: true, + providerOptions: { + deepseek: { + openResponsesExtension: { + id: 'openai.web_search', + item: { + id: 'ws_a', + type: 'openai:web_search_call', + status: 'completed', + action: { type: 'search', query: 'first' }, + }, + }, + }, + }, + }; + const second = { + type: 'tool-call' as const, + toolCallId: 'ws_b', + toolName: 'WebSearch', + input: JSON.stringify({ type: 'search', query: 'second' }), + providerExecuted: true, + providerOptions: { + deepseek: { + openResponsesExtension: { + id: 'openai.web_search', + item: { + id: 'ws_b', + type: 'openai:web_search_call', + status: 'completed', + action: { type: 'search', query: 'second' }, + }, + }, + }, + }, + }; + await model.doGenerate({ + prompt: [ + { role: 'user', content: [{ type: 'text', text: 'search twice' }] }, + { role: 'assistant', content: [first, second] as never }, + { role: 'user', content: [{ type: 'text', text: 'continue' }] }, + ], + tools: [webSearchTool()], + }); + const replayed = (bodies[0]?.input as Array> | undefined)?.filter( + (item) => item.type === 'web_search_call', + ); + assert.deepEqual( + replayed?.map((item) => item.id), + ['ws_a', 'ws_b'], + JSON.stringify(bodies[0]?.input), + ); + }); + + test('merges the opaque replay item onto tool-call provider options', () => { + const item = { + id: 'ws_merge', + type: 'openai:web_search_call', + status: 'completed', + provider_trace: 'opaque-merge', + }; + const merged = attachOpenResponsesExtensionReplayItem( + { deepseek: { openResponsesExtension: { id: 'openai.web_search', itemId: 'ws_merge' } } }, + { deepseek: { openResponsesExtension: { id: 'openai.web_search', item } } }, + ); + assert.equal(openResponsesExtensionReplayItem(merged)?.provider_trace, 'opaque-merge'); + assert.equal( + openResponsesExtensionReplayCarrierPart(merged)?.kind, + OPEN_RESPONSES_EXTENSION_REPLAY_KIND, + ); + assert.deepEqual(openResponsesExtensionReplayReferenceOptions(merged), { + deepseek: { openResponsesExtension: { id: 'openai.web_search', itemId: 'ws_merge' } }, + }); + assert.equal( + openResponsesExtensionReplayReferenceOptions({ anthropic: { type: 'server_tool_use' } }), + undefined, + ); + }); + + test('replays the hosted search item through the durable RuntimeEvent boundary', async () => { + const adapter = deepSeekAdapter(); + const pending = new Map(); + const item = { + id: 'ws_durable', + type: 'openai:web_search_call', + status: 'completed', + provider_trace: 'opaque-durable-trace', + action: { type: 'search', query: 'durable replay' }, + }; + assert.deepEqual( + adapter.translateChunk( + { + type: 'custom', + kind: OPEN_RESPONSES_EXTENSION_REPLAY_KIND, + providerMetadata: { + deepseek: { openResponsesExtension: { id: 'openai.web_search', item } }, + }, + }, + undefined, + pending, + ), + [], + ); + const translated = adapter.translateChunk( + { + type: 'tool-call', + toolCallId: 'ws_durable', + toolName: 'WebSearch', + input: JSON.stringify(item.action), + providerExecuted: true, + providerMetadata: { + deepseek: { openResponsesExtension: { id: 'openai.web_search', itemId: 'ws_durable' } }, + }, + }, + undefined, + pending, + ); + const callEvent = translated[0]; + assert.equal(callEvent?.kind, 'tool-call'); + const persistedOptions = + callEvent?.kind === 'tool-call' ? callEvent.toolCall.providerOptions : undefined; + assert.equal( + openResponsesExtensionReplayItem(persistedOptions)?.provider_trace, + 'opaque-durable-trace', + ); + assert.ok(persistedOptions); + + const persisted = [ + runtimeEvent({ + id: 'evt-user-durable', + role: 'user', + author: 'user', + content: { kind: 'text', text: 'search and remember the trace' }, + }), + runtimeEvent({ + id: 'evt-search-call', + role: 'model', + author: 'agent', + refs: { toolCallId: 'ws_durable', stepId: 'step-durable' }, + content: { + kind: 'function_call', + id: 'ws_durable', + name: 'WebSearch', + args: item.action, + providerExecuted: true, + providerOptions: persistedOptions, + }, + }), + runtimeEvent({ + id: 'evt-search-result', + role: 'tool', + author: 'tool', + refs: { toolCallId: 'ws_durable' }, + content: { + kind: 'function_response', + id: 'ws_durable', + name: 'WebSearch', + result: { type: 'web_search_call', status: 'completed', action: item.action }, + providerExecuted: true, + providerOutput: { type: 'web_search_call', status: 'completed', action: item.action }, + isError: false, + }, + }), + ].map((event) => encodeCanonicalRuntimeEvent(event).event); + + assert.equal(adapter.runtimeEventReplaySupport().providerExecutedTools, true); + const plan = buildRuntimeEventModelReplayPlan(persisted); + const projection = new AiSdkMessageProjection({ + modelAdapter: adapter, + applyPatchProfile: null, + }); + const replayPlan = projection.dropUnsupportedReplayItems(plan); + assert.equal( + replayPlan.items.filter((entry) => entry.kind === 'tool_call' || entry.kind === 'tool_result') + .length, + 2, + JSON.stringify(replayPlan.items.map((entry) => entry.kind)), + ); + const messages = await projection.materializeRuntimeReplayPlan( + replayPlan, + { used: 0, decisions: new Map() }, + undefined, + new Set(), + ); + const assistant = messages.find( + (message) => + message.role === 'assistant' && + Array.isArray(message.content) && + message.content.some((part) => part.type === 'tool-call'), + ); + assert.ok(assistant && Array.isArray(assistant.content), JSON.stringify(messages)); + const carrier = assistant.content.find( + (part) => part.type === 'custom' && part.kind === OPEN_RESPONSES_EXTENSION_REPLAY_KIND, + ); + assert.equal( + openResponsesExtensionReplayItem( + carrier && 'providerOptions' in carrier ? carrier.providerOptions : undefined, + )?.provider_trace, + 'opaque-durable-trace', + JSON.stringify(assistant.content), + ); + + const bodies: Record[] = []; + const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body)) as Record); + return Response.json(completedResponse([])); + }) as unknown as typeof globalThis.fetch; + const model = getAIModel({ + connection: conn('deepseek'), + apiKey: 'test-key', + modelId: 'deepseek-v4-flash', + fetch, + }); + const result = await model.doGenerate({ + prompt: [ + ...(messages as never[]), + { role: 'user', content: [{ type: 'text', text: 'continue' }] }, + ], + tools: [webSearchTool()], + }); + + const replayed = (bodies[0]?.input as Array> | undefined)?.filter( + (entry) => entry.type === 'web_search_call', + ); + assert.equal(replayed?.length, 1, JSON.stringify(bodies[0]?.input)); + assert.equal(replayed?.[0]?.id, 'ws_durable'); + assert.equal(replayed?.[0]?.provider_trace, 'opaque-durable-trace'); + assert.deepEqual(replayed?.[0]?.action, item.action); + assert.equal( + result.warnings?.some( + (warning) => + warning.type === 'unsupported' && + warning.feature === + `provider-defined tool ${DEEPSEEK_OPEN_RESPONSES_WEB_SEARCH_EXTENSION_ID} tool-result history`, + ), + false, + JSON.stringify(result.warnings), + ); + }); + + test('keeps a later Anthropic hosted search when an older DeepSeek exchange reused its id', () => { + const projection = new AiSdkMessageProjection({ + modelAdapter: anthropicReplayAdapter(), + applyPatchProfile: null, + }); + const replayToolIds = (toolCallId: { deepSeek: string; anthropic: string }) => { + const plan = buildRuntimeEventModelReplayPlan(crossInvocationHostedSearchHistory(toolCallId)); + assert.deepEqual(hostedReplayIdentity(plan), [ + `invocation-deepseek:tool_call:${toolCallId.deepSeek}`, + `invocation-deepseek:tool_result:${toolCallId.deepSeek}`, + `invocation-anthropic:tool_call:${toolCallId.anthropic}`, + `invocation-anthropic:tool_result:${toolCallId.anthropic}`, + ]); + return hostedReplayIdentity(projection.dropUnsupportedReplayItems(plan)); + }; + + assert.deepEqual( + replayToolIds({ deepSeek: 'search-deepseek', anthropic: 'search-anthropic' }), + [ + 'invocation-anthropic:tool_call:search-anthropic', + 'invocation-anthropic:tool_result:search-anthropic', + ], + ); + assert.deepEqual(replayToolIds({ deepSeek: 'search-reused', anthropic: 'search-reused' }), [ + 'invocation-anthropic:tool_call:search-reused', + 'invocation-anthropic:tool_result:search-reused', + ]); + }); + + test('marks a failed hosted search item as an error result', async () => { + const fetch = (async () => + Response.json( + completedResponse([ + { + id: 'ws_failed', + type: 'web_search_call', + status: 'failed', + action: { type: 'search', query: 'missing page' }, + }, + ]), + )) as unknown as typeof globalThis.fetch; + const model = getAIModel({ + connection: conn('deepseek'), + apiKey: 'test-key', + modelId: 'deepseek-v4-flash', + fetch, + }); + const result = await model.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'search' }] }], + tools: [webSearchTool()], + }); + const searchResult = result.content.find((part) => part.type === 'tool-result'); + assert.equal( + searchResult && 'providerExecuted' in searchResult + ? searchResult.providerExecuted + : undefined, + true, + ); + assert.equal( + searchResult && 'isError' in searchResult ? searchResult.isError : undefined, + true, + ); + assert.match(JSON.stringify(searchResult), /failed/); + }); + + test('streams hosted-search progress then finishes without a client tool call', async () => { + const fetch = (async () => + new Response( + sse([ + { + type: 'response.created', + response: { id: 'resp_stream', status: 'in_progress', output: [] }, + }, + { + type: 'response.output_item.added', + output_index: 0, + item: { id: 'ws_stream', type: 'web_search_call', status: 'in_progress' }, + }, + { type: 'response.web_search_call.in_progress', item_id: 'ws_stream' }, + { type: 'response.web_search_call.searching', item_id: 'ws_stream' }, + { + type: 'response.output_item.done', + output_index: 0, + item: { + id: 'ws_stream', + type: 'web_search_call', + status: 'completed', + action: { type: 'find_in_page', url: 'https://maka.example/', pattern: 'codec' }, + }, + }, + { + type: 'response.output_item.added', + output_index: 1, + item: { + id: 'msg_stream', + type: 'message', + status: 'in_progress', + role: 'assistant', + content: [], + }, + }, + { + type: 'response.content_part.added', + item_id: 'msg_stream', + output_index: 1, + content_index: 0, + part: { type: 'output_text', text: '' }, + }, + { + type: 'response.output_text.delta', + item_id: 'msg_stream', + output_index: 1, + content_index: 0, + delta: 'Found the codec notes.', + }, + { + type: 'response.output_text.done', + item_id: 'msg_stream', + output_index: 1, + content_index: 0, + text: 'Found the codec notes.', + }, + { + type: 'response.content_part.done', + item_id: 'msg_stream', + output_index: 1, + content_index: 0, + part: { type: 'output_text', text: 'Found the codec notes.' }, + }, + { + type: 'response.output_item.done', + output_index: 1, + item: { + id: 'msg_stream', + type: 'message', + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text: 'Found the codec notes.' }], + }, + }, + { + type: 'response.completed', + response: { + id: 'resp_stream', + status: 'completed', + output: [], + usage: { input_tokens: 3, output_tokens: 2 }, + }, + }, + ]), + { status: 200, headers: { 'content-type': 'text/event-stream' } }, + )) as unknown as typeof globalThis.fetch; + const model = getAIModel({ + connection: conn('deepseek'), + apiKey: 'test-key', + modelId: 'deepseek-v4-flash', + fetch, + }); + const { stream } = await model.doStream({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'find the codec' }] }], + tools: [webSearchTool()], + }); + const parts: LanguageModelV4StreamPart[] = []; + for await (const part of stream) parts.push(part); + + assert.equal( + parts.some( + (part) => + part.type === 'tool-input-start' && + part.toolName === 'WebSearch' && + part.providerExecuted === true, + ), + true, + JSON.stringify(parts.map((part) => part.type)), + ); + assert.equal( + parts.some( + (part) => + part.type === 'tool-call' && + part.toolName === 'WebSearch' && + part.providerExecuted === true, + ), + true, + JSON.stringify( + parts.filter((part) => part.type === 'tool-call' || part.type === 'tool-result'), + ), + ); + assert.equal( + parts.some((part) => part.type === 'tool-result' && part.toolName === 'WebSearch'), + true, + ); + assert.match(JSON.stringify(parts), /Found the codec notes/); + const finish = parts.find((part) => part.type === 'finish'); + assert.ok(finish); + }); + + test('leaves generic Open Responses providers fail-closed for hosted search', async () => { + const bodies: Record[] = []; + const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body)) as Record); + return Response.json(completedResponse([])); + }) as unknown as typeof globalThis.fetch; + const model = getAIModel({ + connection: { ...conn('alibaba-token-plan-cn'), defaultModel: 'qwen3.8-max' }, + apiKey: 'test-key', + modelId: 'qwen3.8-max', + fetch, + }); + const result = await model.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'search' }] }], + tools: [webSearchTool()], + }); + assert.equal(bodies[0]?.tools, undefined); + assert.equal( + result.warnings?.some( + (warning) => + warning.type === 'unsupported' && + warning.feature === + `provider-defined tool ${DEEPSEEK_OPEN_RESPONSES_WEB_SEARCH_EXTENSION_ID}`, + ), + true, + JSON.stringify(result.warnings), + ); + }); + + test('keeps Tavily and Anthropic-compatible DeepSeek routing off the Responses codec', () => { + const tavily = { + name: 'WebSearch', + description: 'Tavily', + parameters: {}, + impl: async () => undefined, + }; + const routedTavily = routeWebSearchTools({ + tools: [tavily], + settings: { enabled: true, defaultProvider: 'tavily' }, + connection: { + slug: 'deepseek', + providerType: 'deepseek', + defaultModel: 'deepseek-v4-flash', + }, + model: 'deepseek-v4-flash', + tavilyReady: true, + }); + assert.equal(routedTavily[0], tavily); + + const routedAnthropic = routeWebSearchTools({ + tools: [tavily], + settings: { enabled: true, defaultProvider: 'model' }, + connection: { + slug: 'anthropic-compatible', + providerType: 'anthropic-compatible', + defaultModel: 'deepseek-v4-flash', + models: [{ id: 'deepseek-v4-flash', apiProtocol: 'anthropic-messages' }], + }, + model: 'deepseek-v4-flash', + tavilyReady: false, + }); + assert.equal(routedAnthropic[0]?.providerTool?.kind, 'anthropic-web-search-20250305'); + }); +}); diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts index 5cb9955bd4..27e5fdf709 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -20,9 +20,12 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; import { RetryError } from 'ai'; +import type { LanguageModelV4StreamPart, LanguageModelV4Usage } from '@ai-sdk/provider'; +import { convertArrayToReadableStream, MockLanguageModelV4 } from 'ai/test'; import { ModelAdapter, normalizeAiSdkUsage } from '../model-adapter.js'; import type { ModelStreamEvent } from '../model-protocol.js'; +import { openResponsesExtensionReplayItem } from '../deepseek-open-responses-extensions.js'; describe('ModelAdapter stream and error normalization', () => { test('shrinks a provider output limit when the persisted request is near the window', () => { @@ -278,11 +281,83 @@ describe('ModelAdapter stream and error normalization', () => { assert.deepEqual(adapter.runtimeEventReplaySupport(), { toolCalls: true, toolResults: true, - providerExecutedTools: false, + providerExecutedTools: true, signedThinking: false, unsignedThinking: false, responsesReasoning: 'plaintext-content', }); + assert.equal( + adapter.canReplayProviderExecutedExchange({ + providerExecuted: true, + toolName: 'WebSearch', + providerOptions: { + deepseek: { + openResponsesExtension: { + id: 'openai.web_search', + item: { id: 'ws_1', type: 'web_search_call' }, + }, + }, + }, + }), + true, + ); + }); + + test('drops provider-executed hosted search on DeepSeek chat and foreign Anthropic wires', () => { + const chat = new ModelAdapter({ + connection: { + slug: 'deepseek', + providerType: 'deepseek', + defaultModel: 'deepseek-chat', + }, + apiKey: 'deepseek-token', + modelId: 'deepseek-chat', + modelFactory: () => ({}), + newId: idGenerator(), + now: monotonicClock(), + }); + const anthropic = new ModelAdapter({ + connection: { + slug: 'anthropic-main', + providerType: 'anthropic', + defaultModel: 'claude-sonnet-4-5-20250929', + }, + apiKey: 'anthropic-token', + modelId: 'claude-sonnet-4-5-20250929', + modelFactory: () => ({}), + newId: idGenerator(), + now: monotonicClock(), + }); + const deepSeekPair = { + providerExecuted: true as const, + toolName: 'WebSearch', + providerOptions: { + deepseek: { + openResponsesExtension: { + id: 'openai.web_search', + item: { id: 'ws_1', type: 'web_search_call', status: 'completed' }, + }, + }, + }, + output: { type: 'web_search_call', status: 'completed' }, + }; + assert.equal(chat.canReplayProviderExecutedExchange(deepSeekPair), false); + assert.equal(anthropic.canReplayProviderExecutedExchange(deepSeekPair), false); + assert.equal( + anthropic.canReplayProviderExecutedExchange({ + providerExecuted: true, + toolName: 'WebSearch', + providerOptions: { anthropic: { type: 'server_tool_use' } }, + output: [ + { + type: 'web_search_result', + url: 'https://maka.example/', + encryptedContent: 'encrypted-result', + }, + ], + }), + true, + ); }); test('supports summary-item Responses reasoning replay for Alibaba Token Plan', () => { @@ -791,6 +866,199 @@ describe('ModelAdapter stream and error normalization', () => { ); }); + test('merges Open Responses extension replay carriers onto the matching tool call', () => { + const adapter = new ModelAdapter({ + connection: { + slug: 'deepseek', + providerType: 'deepseek', + defaultModel: 'deepseek-v4-flash', + }, + apiKey: 'deepseek-token', + modelId: 'deepseek-v4-flash', + modelFactory: () => ({}), + newId: idGenerator(), + now: monotonicClock(), + }); + const pending = new Map(); + const item = { + id: 'ws_opaque', + type: 'openai:web_search_call', + status: 'completed', + provider_trace: 'opaque-replay', + }; + assert.deepEqual( + adapter.translateChunk( + { + type: 'custom', + kind: 'open-responses.extension-replay', + providerMetadata: { + deepseek: { openResponsesExtension: { id: 'openai.web_search', item } }, + }, + }, + undefined, + pending, + ), + [], + ); + assert.deepEqual( + adapter.translateChunk( + { + type: 'tool-call', + toolCallId: 'ws_opaque', + toolName: 'WebSearch', + input: '{"type":"search"}', + providerExecuted: true, + providerMetadata: { + deepseek: { openResponsesExtension: { id: 'openai.web_search', itemId: 'ws_opaque' } }, + }, + }, + undefined, + pending, + ), + [ + { + kind: 'tool-call', + toolCall: { + type: 'tool-call', + toolCallId: 'ws_opaque', + toolName: 'WebSearch', + input: { type: 'search' }, + providerExecuted: true, + providerOptions: { + deepseek: { + openResponsesExtension: { + id: 'openai.web_search', + itemId: 'ws_opaque', + item, + }, + }, + }, + }, + }, + ], + ); + }); + + test('isolates Open Responses replay carriers across concurrent physical streams', async () => { + const adapter = newDeepSeekStreamAdapter(); + const streamA = controlledLanguageModelStream(); + const streamB = controlledLanguageModelStream(); + let activityA = 0; + let activityB = 0; + const resultA = await startDeepSeekReplayStream(adapter, streamA.model, () => { + activityA += 1; + }); + const resultB = await startDeepSeekReplayStream(adapter, streamB.model, () => { + activityB += 1; + }); + const collectedA = collectStreamEvents(resultA.events); + const collectedB = collectStreamEvents(resultB.events); + await Promise.all([streamA.ready, streamB.ready]); + + streamA.enqueue(streamStartPart()); + streamB.enqueue(streamStartPart()); + await waitForCondition(() => activityA > 0 && activityB > 0, 'both streams to start'); + + const seenA = activityA; + streamA.enqueue(extensionReplayCarrier(replayItem('ws_shared', 'trace-a'))); + await waitForCondition(() => activityA > seenA, 'stream A to ingest carrier A'); + + const seenB = activityB; + streamB.enqueue(extensionReplayCarrier(replayItem('ws_shared', 'trace-b'))); + await waitForCondition(() => activityB > seenB, 'stream B to ingest carrier B'); + + const afterCarrierA = activityA; + streamA.enqueue(extensionReplayToolCall('ws_shared')); + await waitForCondition( + () => + activityA > afterCarrierA && collectedA.events.some((event) => event.kind === 'tool-call'), + 'stream A to emit tool-call A', + ); + + const afterCarrierB = activityB; + streamB.enqueue(extensionReplayToolCall('ws_shared')); + await waitForCondition( + () => + activityB > afterCarrierB && collectedB.events.some((event) => event.kind === 'tool-call'), + 'stream B to emit tool-call B', + ); + + streamA.enqueue(streamFinishPart()); + streamA.close(); + streamB.enqueue(streamFinishPart()); + streamB.close(); + await Promise.all([collectedA.done, collectedB.done, resultA.outcome, resultB.outcome]); + + assert.equal(replayTrace(collectedA.events), 'trace-a', JSON.stringify(collectedA.events)); + assert.equal(replayTrace(collectedB.events), 'trace-b', JSON.stringify(collectedB.events)); + }); + + test('does not leak an aborted stream replay carrier into a later request', async () => { + const adapter = newDeepSeekStreamAdapter(); + const aborted = new AbortController(); + const first = controlledLanguageModelStream(); + let firstActivity = 0; + const firstResult = await startDeepSeekReplayStream( + adapter, + first.model, + () => { + firstActivity += 1; + }, + aborted.signal, + ); + const firstEvents = collectStreamEvents(firstResult.events); + await first.ready; + + first.enqueue(streamStartPart()); + await waitForCondition(() => firstActivity > 0, 'aborted stream to start'); + const beforeCarrier = firstActivity; + first.enqueue(extensionReplayCarrier(replayItem('ws_stale', 'stale-aborted-trace'))); + await waitForCondition(() => firstActivity > beforeCarrier, 'aborted stream to ingest carrier'); + aborted.abort(); + first.close(); + await Promise.all([firstEvents.done, firstResult.outcome]); + + const second = new MockLanguageModelV4({ + doStream: async () => ({ + stream: convertArrayToReadableStream([ + streamStartPart(), + extensionReplayToolCall('ws_stale'), + streamFinishPart(), + ]), + }), + }); + const secondResult = await startDeepSeekReplayStream(adapter, second, () => {}); + const secondEvents: ModelStreamEvent[] = []; + for await (const event of secondResult.events) secondEvents.push(event); + await secondResult.outcome; + + assert.equal(replayTrace(secondEvents), undefined, JSON.stringify(secondEvents)); + }); + + test('marks failed provider-executed tool results as errors', () => { + const adapter = newAdapter(); + type Chunk = Parameters[0]; + assert.deepEqual( + adapter.translateChunk({ + type: 'tool-result', + toolCallId: 'ws_failed', + toolName: 'WebSearch', + providerExecuted: true, + isError: true, + result: { type: 'web_search_call', status: 'failed' }, + } as Chunk), + [ + { + kind: 'provider-tool-result', + toolCallId: 'ws_failed', + toolName: 'WebSearch', + output: { type: 'web_search_call', status: 'failed' }, + isError: true, + }, + ], + ); + }); + test('captures the Anthropic reasoning signature without emitting an empty thinking event', () => { const adapter = newAdapter(); type Chunk = Parameters[0]; @@ -1141,6 +1409,143 @@ function newAdapter(): ModelAdapter { }); } +function newDeepSeekStreamAdapter(): ModelAdapter { + return new ModelAdapter({ + connection: { + slug: 'deepseek', + providerType: 'deepseek', + defaultModel: 'deepseek-v4-flash', + }, + apiKey: 'deepseek-token', + modelId: 'deepseek-v4-flash', + modelFactory: () => ({}), + newId: idGenerator(), + now: monotonicClock(), + }); +} + +function startDeepSeekReplayStream( + adapter: ModelAdapter, + model: unknown, + onStreamActivity: () => void, + abortSignal = new AbortController().signal, +) { + return adapter.startStream({ + model, + messages: [{ role: 'user', content: 'search' }], + tools: {}, + activeTools: [], + onStreamActivity, + abortSignal, + repairToolCall: async () => null, + }); +} + +function controlledLanguageModelStream(): { + model: MockLanguageModelV4; + ready: Promise; + enqueue: (part: LanguageModelV4StreamPart) => void; + close: () => void; +} { + let controller: ReadableStreamDefaultController | undefined; + let resolveReady: (() => void) | undefined; + const ready = new Promise((resolve) => { + resolveReady = resolve; + }); + const stream = new ReadableStream({ + start(streamController) { + controller = streamController; + resolveReady?.(); + }, + }); + return { + model: new MockLanguageModelV4({ + doStream: async () => ({ stream }), + }), + ready, + enqueue: (part) => { + if (!controller) throw new Error('language model stream is not started'); + controller.enqueue(part); + }, + close: () => { + if (!controller) throw new Error('language model stream is not started'); + controller.close(); + }, + }; +} + +function collectStreamEvents(events: AsyncIterable): { + events: ModelStreamEvent[]; + done: Promise; +} { + const collected: ModelStreamEvent[] = []; + return { + events: collected, + done: (async () => { + for await (const event of events) collected.push(event); + })(), + }; +} + +function replayItem(id: string, providerTrace: string): Record { + return { + id, + type: 'openai:web_search_call', + status: 'completed', + provider_trace: providerTrace, + }; +} + +function extensionReplayCarrier(item: Record): LanguageModelV4StreamPart { + return { + type: 'custom', + kind: 'open-responses.extension-replay', + providerMetadata: { + deepseek: { openResponsesExtension: { id: 'openai.web_search', item } }, + }, + }; +} + +function extensionReplayToolCall(id: string): LanguageModelV4StreamPart { + return { + type: 'tool-call', + toolCallId: id, + toolName: 'WebSearch', + input: '{"type":"search"}', + providerExecuted: true, + providerMetadata: { + deepseek: { openResponsesExtension: { id: 'openai.web_search', itemId: id } }, + }, + }; +} + +function streamStartPart(): LanguageModelV4StreamPart { + return { type: 'stream-start', warnings: [] }; +} + +function streamFinishPart(): LanguageModelV4StreamPart { + const usage: LanguageModelV4Usage = { + inputTokens: { total: 0, noCache: 0, cacheRead: 0, cacheWrite: 0 }, + outputTokens: { total: 0, text: 0, reasoning: 0 }, + }; + return { type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, usage }; +} + +function replayTrace(events: readonly ModelStreamEvent[]): string | undefined { + const call = events.find((event) => event.kind === 'tool-call'); + if (call?.kind !== 'tool-call') return undefined; + const item = openResponsesExtensionReplayItem(call.toolCall.providerOptions); + return typeof item?.provider_trace === 'string' ? item.provider_trace : undefined; +} + +async function waitForCondition(check: () => boolean, label: string): Promise { + const deadline = Date.now() + 2_000; + while (!check()) { + if (Date.now() >= deadline) throw new Error(`timed out waiting for ${label}`); + await new Promise((resolve) => setImmediate(resolve)); + } +} + function idGenerator(): () => string { let index = 0; return () => `id-${++index}`; diff --git a/packages/runtime/src/ai-sdk-message-projection.ts b/packages/runtime/src/ai-sdk-message-projection.ts index 2c67a733a6..d7ac32578c 100644 --- a/packages/runtime/src/ai-sdk-message-projection.ts +++ b/packages/runtime/src/ai-sdk-message-projection.ts @@ -55,6 +55,10 @@ import type { ToolResultOutput, UserContent, } from './model-protocol.js'; +import { + openResponsesExtensionReplayCarrierPart, + openResponsesExtensionReplayReferenceOptions, +} from './deepseek-open-responses-extensions.js'; import { openAiChatReasoningFieldFromProviderOptions } from './openai-chat-reasoning-transport.js'; import { decodePlaintextResponsesReasoningState, @@ -173,6 +177,26 @@ function durableApplyPatchReplayFactText( } } +/** + * Pair each replay item with the exchange `buildRuntimeEventReplayTimeline` + * already formed. That chronology keys a tool exchange by invocation plus + * provider-local id, so a later invocation can reuse a tool call id without + * borrowing the earlier call or result. + */ +function replayToolExchangesByItem( + items: readonly RuntimeEventModelReplayItem[], +): ReadonlyMap { + const exchanges = new Map(); + for (const entry of buildRuntimeEventReplayTimeline(items)) { + if (entry.kind !== 'assistant_step') continue; + for (const exchange of entry.calls) { + exchanges.set(exchange.call, exchange); + if (exchange.result) exchanges.set(exchange.result, exchange); + } + } + return exchanges; +} + /** * Projects canonical Runtime history and current user input into provider * messages. It owns no execution state; its only mutable data is the weak @@ -185,13 +209,13 @@ export class AiSdkMessageProjection { canReplayProviderNative(plan: RuntimeEventModelReplayPlan): boolean { const support = this.input.modelAdapter.runtimeEventReplaySupport(); + const exchanges = replayToolExchangesByItem(plan.items); for (const item of plan.items) { if (item.kind === 'tool_call' && !support.toolCalls) return false; if (item.kind === 'tool_result' && !support.toolResults) return false; if ( (item.kind === 'tool_call' || item.kind === 'tool_result') && - item.providerExecuted === true && - !support.providerExecutedTools + !this.canReplayProviderExecutedItem(item, exchanges) ) { return false; } @@ -209,18 +233,36 @@ export class AiSdkMessageProjection { */ dropUnsupportedReplayItems(plan: RuntimeEventModelReplayPlan): RuntimeEventModelReplayPlan { const support = this.input.modelAdapter.runtimeEventReplaySupport(); + const exchanges = replayToolExchangesByItem(plan.items); return { ...plan, items: plan.items.filter((item) => { if (item.kind === 'tool_call' || item.kind === 'tool_result') { if (!support.toolCalls || !support.toolResults) return false; - if (item.providerExecuted === true && !support.providerExecutedTools) return false; + if (!this.canReplayProviderExecutedItem(item, exchanges)) return false; } return true; }), }; } + private canReplayProviderExecutedItem( + item: RuntimeEventModelReplayItem, + exchanges: ReadonlyMap, + ): boolean { + if (item.kind !== 'tool_call' && item.kind !== 'tool_result') return true; + if (item.providerExecuted !== true) return true; + const exchange = exchanges.get(item); + const call = exchange?.call ?? (item.kind === 'tool_call' ? item : undefined); + const result = exchange?.result ?? (item.kind === 'tool_result' ? item : undefined); + return this.input.modelAdapter.canReplayProviderExecutedExchange({ + providerExecuted: true, + providerOptions: call?.providerOptions, + toolName: item.toolName, + output: result?.output, + }); + } + /** * Materialize a replay plan into provider messages, grouping each assistant * step's reasoning + text + tool calls into ONE assistant message (Anthropic @@ -403,12 +445,29 @@ export class AiSdkMessageProjection { // stay after text because their execution begins only after this step. for (const { call, result } of exchanges) { if (call.providerExecuted !== true) continue; + if ( + !this.input.modelAdapter.canReplayProviderExecutedExchange({ + providerExecuted: true, + providerOptions: call.providerOptions, + toolName: call.toolName, + output: result?.output, + }) + ) { + continue; + } + const replayCarrier = openResponsesExtensionReplayCarrierPart(call.providerOptions); + if (replayCarrier) content.push(replayCarrier); + const replayReference = openResponsesExtensionReplayReferenceOptions(call.providerOptions); content.push({ type: 'tool-call', toolCallId: call.toolCallId, toolName: call.toolName, input: call.input, - ...(call.providerOptions !== undefined ? { providerOptions: call.providerOptions } : {}), + ...(replayReference !== undefined + ? { providerOptions: replayReference } + : call.providerOptions !== undefined + ? { providerOptions: call.providerOptions } + : {}), providerExecuted: true, }); if (!result || result.providerExecuted !== true) continue; @@ -418,6 +477,7 @@ export class AiSdkMessageProjection { toolCallId: result.toolCallId, toolName: result.toolName, output: await materializeReplayToolResult(result, call.toolName), + ...(replayReference !== undefined ? { providerOptions: replayReference } : {}), }); } if (text && text.content.length > 0) { diff --git a/packages/runtime/src/ai-sdk-turn.ts b/packages/runtime/src/ai-sdk-turn.ts index cb2f3cca8b..948179cedd 100644 --- a/packages/runtime/src/ai-sdk-turn.ts +++ b/packages/runtime/src/ai-sdk-turn.ts @@ -2398,9 +2398,14 @@ export class AiSdkTurn { // Usage above still belongs to this physical attempt. Its Runtime owner // seals the drained stream; no complete/abort event ends the logical Turn. if (this.handoffPaused) return; + // `step_limit` means the client tool budget was spent while Maka still + // had calls to continue. Open Responses counts a provider-executed + // hosted search in `finishReason: tool-calls` even when that step + // already contains the final answer (#4107). Those turns settle with + // no client call, so they complete as `end_turn`. const stopReason = this.loopStopReason ?? - (maxSteps !== undefined && finishReason === 'tool-calls' + (maxSteps !== undefined && finishReason === 'tool-calls' && lastCompletedStepHadToolResult ? 'step_limit' : this.mapFinishReason(finishReason)); trace.modelStreamCompleted(stopReason); diff --git a/packages/runtime/src/deepseek-open-responses-extensions.ts b/packages/runtime/src/deepseek-open-responses-extensions.ts new file mode 100644 index 0000000000..1c9c290115 --- /dev/null +++ b/packages/runtime/src/deepseek-open-responses-extensions.ts @@ -0,0 +1,582 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { + Experimental_OpenResponsesExtension, + Experimental_OpenResponsesExtensionContentPart, + Experimental_OpenResponsesExtensionInputPart, + Experimental_OpenResponsesExtensionItem, + Experimental_OpenResponsesExtensionStreamPart, +} from '@ai-sdk/open-responses'; +import type { JSONObject, JSONValue, LanguageModelV4ProviderTool } from '@ai-sdk/provider'; +import type { ProviderType } from '@maka/core/llm-connections'; +import type { CustomPart, ProviderOptions } from './model-protocol.js'; +import { NATIVE_WEB_SEARCH_TOOL_NAME } from './native-web-search-tool.js'; + +/** + * DeepSeek Open Responses codecs for hosted `web_search` (#4107). + * + * Product routing stays fail-closed (`implemented: false`); this registers + * encode/decode/replay only. `@ai-sdk/open-responses@2.0.44` still requires + * namespaced `:` registrations, so a DeepSeek-only + * allowlisted discriminator wrap maps those to DeepSeek's documented bare + * `web_search` / `web_search_call` / `response.web_search_call.*` wire. + * Namespaced registration plus the discriminator wrap already emit DeepSeek's + * bare wire. Do not register a bare `allowBareTypes` variant: a flag-only + * upstream (vercel/ai#19939) would accept the probe and then silently stop + * decoding because the SDK parsers still require a `:`. + */ + +/** + * AI SDK provider-tool ID for the compiled Open Responses search descriptor. + * DeepSeek's first-party wire uses the bare `web_search` tool instead. + */ +export const DEEPSEEK_OPEN_RESPONSES_WEB_SEARCH_EXTENSION_ID = 'openai.web_search'; + +/** SDK custom part that carries the original extension item for lossless replay. */ +export const OPEN_RESPONSES_EXTENSION_REPLAY_KIND = 'open-responses.extension-replay'; + +const WEB_SEARCH_ITEM = 'web_search_call'; +const WEB_SEARCH_TOOL = 'web_search'; +const NAMESPACED_WEB_SEARCH_TOOL = 'openai:web_search'; +const NAMESPACED_WEB_SEARCH_ITEM = 'openai:web_search_call'; + +const WEB_SEARCH_EVENTS = { + inProgress: 'response.web_search_call.in_progress', + searching: 'response.web_search_call.searching', + completed: 'response.web_search_call.completed', +} as const; + +const NAMESPACED_WEB_SEARCH_EVENTS = { + inProgress: 'openai:web_search_call.in_progress', + searching: 'openai:web_search_call.searching', + completed: 'openai:web_search_call.completed', +} as const; + +const OUTGOING_TOOL_TYPES = new Map([ + [NAMESPACED_WEB_SEARCH_TOOL, WEB_SEARCH_TOOL], + [NAMESPACED_WEB_SEARCH_ITEM, WEB_SEARCH_ITEM], +]); + +const INCOMING_TOOL_TYPES = new Map([ + [WEB_SEARCH_TOOL, NAMESPACED_WEB_SEARCH_TOOL], + ['web_search_2025_08_26', NAMESPACED_WEB_SEARCH_TOOL], + [WEB_SEARCH_ITEM, NAMESPACED_WEB_SEARCH_ITEM], +]); + +const INCOMING_EVENT_TYPES = new Map([ + [WEB_SEARCH_EVENTS.inProgress, NAMESPACED_WEB_SEARCH_EVENTS.inProgress], + [WEB_SEARCH_EVENTS.searching, NAMESPACED_WEB_SEARCH_EVENTS.searching], + [WEB_SEARCH_EVENTS.completed, NAMESPACED_WEB_SEARCH_EVENTS.completed], +]); + +export function usesDeepSeekOpenResponsesExtensions(providerType: ProviderType): boolean { + return providerType === 'deepseek'; +} + +export function isOpenResponsesExtensionReplayChunk(chunk: { + type: string; + kind?: unknown; +}): boolean { + return chunk.type === 'custom' && chunk.kind === OPEN_RESPONSES_EXTENSION_REPLAY_KIND; +} + +/** Original opaque item stored on an Open Responses extension replay carrier. */ +export function openResponsesExtensionReplayItem(container: unknown): JSONObject | undefined { + if (!isRecord(container)) return undefined; + for (const value of Object.values(container)) { + if (!isRecord(value) || !isRecord(value.openResponsesExtension)) continue; + const item = jsonObject(value.openResponsesExtension.item); + if (item && typeof item.id === 'string') return item; + } + return undefined; +} + +export function attachOpenResponsesExtensionReplayItem( + toolCallProviderOptions: unknown, + carrierProviderOptions: unknown, +): ProviderOptions | undefined { + const item = openResponsesExtensionReplayItem(carrierProviderOptions); + const base = isRecord(toolCallProviderOptions) + ? { ...toolCallProviderOptions } + : isRecord(carrierProviderOptions) + ? { ...carrierProviderOptions } + : {}; + if (!item || !isRecord(carrierProviderOptions)) { + return Object.keys(base).length > 0 ? (base as ProviderOptions) : undefined; + } + for (const [key, value] of Object.entries(carrierProviderOptions)) { + if (!isRecord(value) || !isRecord(value.openResponsesExtension)) continue; + const existing = isRecord(base[key]) ? base[key] : {}; + const existingExt = isRecord(existing.openResponsesExtension) + ? existing.openResponsesExtension + : {}; + base[key] = { + ...existing, + openResponsesExtension: { + ...existingExt, + ...value.openResponsesExtension, + item, + }, + }; + } + return base as ProviderOptions; +} + +export function openResponsesExtensionReplayCarrierPart( + providerOptions: unknown, +): CustomPart | undefined { + if (!openResponsesExtensionReplayItem(providerOptions) || !isRecord(providerOptions)) { + return undefined; + } + return { + type: 'custom', + kind: OPEN_RESPONSES_EXTENSION_REPLAY_KIND, + providerOptions: providerOptions as ProviderOptions, + }; +} + +export function openResponsesExtensionReplayReferenceOptions( + providerOptions: unknown, +): ProviderOptions | undefined { + if (!isRecord(providerOptions)) return undefined; + const next: Record = {}; + let rewritten = false; + for (const [key, value] of Object.entries(providerOptions)) { + if (!isRecord(value) || !isRecord(value.openResponsesExtension)) { + next[key] = value; + continue; + } + const extension = value.openResponsesExtension; + const item = jsonObject(extension.item); + const id = typeof extension.id === 'string' ? extension.id : undefined; + const itemId = + typeof extension.itemId === 'string' + ? extension.itemId + : item && typeof item.id === 'string' + ? item.id + : undefined; + if (!id || !itemId) { + next[key] = value; + continue; + } + next[key] = { ...value, openResponsesExtension: { id, itemId } }; + rewritten = true; + } + return rewritten ? (next as ProviderOptions) : undefined; +} + +export function createDeepSeekOpenResponsesExtensions(): readonly Experimental_OpenResponsesExtension[] { + return [ + { + id: DEEPSEEK_OPEN_RESPONSES_WEB_SEARCH_EXTENSION_ID, + toolType: NAMESPACED_WEB_SEARCH_TOOL, + itemTypes: [NAMESPACED_WEB_SEARCH_ITEM], + eventTypes: [ + NAMESPACED_WEB_SEARCH_EVENTS.inProgress, + NAMESPACED_WEB_SEARCH_EVENTS.searching, + NAMESPACED_WEB_SEARCH_EVENTS.completed, + ], + encodeTool: encodeDeepSeekWebSearchTool, + decodeItem: decodeDeepSeekWebSearchItem, + encodeInputItem: (options) => + encodeDeepSeekWebSearchInputItem(options, NAMESPACED_WEB_SEARCH_ITEM), + decodeEvent: decodeDeepSeekWebSearchEvent, + }, + ]; +} + +/** + * Allowlisted discriminator adapter. Namespaced registration stays in place + * even if vercel/ai#19939 ships a flag-only `allowBareTypes`. Unknown types + * are left untouched. + */ +export function wrapFetchForDeepSeekOpenResponsesExtensions( + upstream: typeof globalThis.fetch, +): typeof globalThis.fetch { + return async (input, init) => { + const request = new Request(input, init); + const signal = + init?.signal !== undefined + ? init.signal + : input instanceof Request + ? input.signal + : undefined; + const headers = new Headers(request.headers); + const rewrittenBody = await rewriteOutgoingRequestBody(request); + if (rewrittenBody !== undefined) headers.delete('content-length'); + const response = await upstream( + request.url, + requestInit(request, headers, rewrittenBody ?? (await cloneOutgoingBody(request)), signal), + ); + return rewriteIncomingResponse(response); + }; +} + +export function rewriteDeepSeekOpenResponsesOutgoingBody( + body: Record, +): Record | undefined { + let next: Record | undefined; + const assign = (key: string, value: unknown) => { + next ??= { ...body }; + next[key] = value; + }; + if (Array.isArray(body.tools)) { + const original = body.tools; + const tools = original.map((tool) => rewriteMappedType(tool, OUTGOING_TOOL_TYPES)); + if (tools.some((tool, index) => tool !== original[index])) assign('tools', tools); + } + if (isRecord(body.tool_choice)) { + const toolChoice = rewriteMappedType(body.tool_choice, OUTGOING_TOOL_TYPES); + if (toolChoice !== body.tool_choice) assign('tool_choice', toolChoice); + } + if (Array.isArray(body.input)) { + const original = body.input; + const input = original.map((item) => rewriteMappedType(item, OUTGOING_TOOL_TYPES)); + if (input.some((item, index) => item !== original[index])) assign('input', input); + } + return next; +} + +export function rewriteDeepSeekOpenResponsesIncomingValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((entry) => rewriteDeepSeekOpenResponsesIncomingValue(entry)); + } + if (!isRecord(value)) return value; + let next: Record = value; + const rewrittenType = + mapType(value.type, INCOMING_EVENT_TYPES) ?? mapType(value.type, INCOMING_TOOL_TYPES); + if (rewrittenType !== undefined && rewrittenType !== value.type) { + next = { ...next, type: rewrittenType }; + } + if (isRecord(next.item)) { + const item = rewriteMappedType(next.item, INCOMING_TOOL_TYPES); + if (item !== next.item) next = { ...next, item }; + } + if (Array.isArray(next.output)) { + next = { + ...next, + output: next.output.map((item) => rewriteMappedType(item, INCOMING_TOOL_TYPES)), + }; + } + if (isRecord(next.response)) { + const response = rewriteDeepSeekOpenResponsesIncomingValue(next.response); + if (response !== next.response) next = { ...next, response }; + } + return next; +} + +function encodeDeepSeekWebSearchTool(): JSONObject { + // DeepSeek documents `{ type: "web_search" }` and ignores search_context_size + // and user_location. The adapter supplies the registered tool type. + return {}; +} + +function decodeDeepSeekWebSearchItem(options: { + item: Experimental_OpenResponsesExtensionItem; + mode: 'generate' | 'stream'; +}): Experimental_OpenResponsesExtensionContentPart[] | undefined { + const item = options.item; + if (!isWebSearchCallType(item.type) || typeof item.id !== 'string' || item.id.length === 0) { + return undefined; + } + if (typeof item.status !== 'string' || item.status.length === 0) return undefined; + const action = jsonObject(item.action) ?? {}; + const toolCallId = item.id; + const input = JSON.stringify(action); + const result = jsonValue({ + type: WEB_SEARCH_ITEM, + status: item.status, + ...(Object.keys(action).length > 0 ? { action } : {}), + }); + if (result === undefined) return undefined; + const parts: Experimental_OpenResponsesExtensionContentPart[] = [ + { + type: 'tool-call', + toolCallId, + toolName: NATIVE_WEB_SEARCH_TOOL_NAME, + input, + providerExecuted: true, + }, + ]; + // Streaming materializes the call/result from `output_item.done`, so + // non-terminal statuses stay as tool-input-start only. generate() has no + // later item, so emit the result even for `in_progress` to keep doGenerate + // callers on a complete provider-executed pair. + if (item.status === 'completed' || item.status === 'failed' || options.mode === 'generate') { + parts.push({ + type: 'tool-result', + toolCallId, + toolName: NATIVE_WEB_SEARCH_TOOL_NAME, + result, + // LanguageModelV4ToolResult carries providerExecuted; the extension + // content-part union omits it on tool-results, so the codec type + // needs this assertion. Maka drops provider-executed results unless + // the flag is present (model-adapter translateChunk). + providerExecuted: true, + ...(item.status === 'failed' ? { isError: true } : {}), + } as Experimental_OpenResponsesExtensionContentPart); + } + return parts; +} + +function encodeDeepSeekWebSearchInputItem( + options: { + part: Experimental_OpenResponsesExtensionInputPart; + tool: LanguageModelV4ProviderTool; + }, + registeredItemType: string, +): Experimental_OpenResponsesExtensionItem | undefined { + const part = options.part; + if (part.type !== 'tool-call') return undefined; + const stored = + openResponsesExtensionReplayItem(part.providerOptions) ?? + openResponsesExtensionReplayItem((part as { providerMetadata?: unknown }).providerMetadata); + if (stored && isWebSearchCallType(String(stored.type))) { + return { + ...stored, + type: registeredItemType, + } as Experimental_OpenResponsesExtensionItem; + } + // The SDK dedups extension input items on `${type}:${id}`. Distinct searches + // must keep distinct item ids. Whether DeepSeek reuses `web_search_call` ids + // across responses is unverified, so replay treats id uniqueness as a + // request-history invariant. + if (part.toolCallId.length === 0) return undefined; + const action = actionFromToolInput(part.input); + return { + id: part.toolCallId, + type: registeredItemType, + status: 'completed', + ...(action ? { action } : {}), + } as Experimental_OpenResponsesExtensionItem; +} + +function decodeDeepSeekWebSearchEvent(options: { + event: { type: string; sequence_number: number } & JSONObject; + state: Map; +}): Experimental_OpenResponsesExtensionStreamPart[] | undefined { + const eventType = String(options.event.type); + if ( + eventType !== WEB_SEARCH_EVENTS.inProgress && + eventType !== NAMESPACED_WEB_SEARCH_EVENTS.inProgress + ) { + return []; + } + const itemId = + typeof options.event.item_id === 'string' + ? options.event.item_id + : isRecord(options.event.item) && typeof options.event.item.id === 'string' + ? options.event.item.id + : undefined; + if (!itemId) return undefined; + if (options.state.get(itemId) === 'started') return []; + options.state.set(itemId, 'started'); + return [ + { + type: 'tool-input-start', + id: itemId, + toolName: NATIVE_WEB_SEARCH_TOOL_NAME, + providerExecuted: true, + }, + ]; +} + +function isWebSearchCallType(type: string): boolean { + return type === WEB_SEARCH_ITEM || type === NAMESPACED_WEB_SEARCH_ITEM; +} + +function actionFromToolInput(input: unknown): JSONObject | undefined { + if (typeof input === 'string') { + try { + return jsonObject(JSON.parse(input)); + } catch { + return undefined; + } + } + return jsonObject(input); +} + +function jsonObject(value: unknown): JSONObject | undefined { + const json = jsonValue(value); + return json !== null && typeof json === 'object' && !Array.isArray(json) ? json : undefined; +} + +function jsonValue(value: unknown): JSONValue | undefined { + if (value === null || typeof value === 'string' || typeof value === 'number') return value; + if (typeof value === 'boolean') return value; + if (Array.isArray(value)) { + const items: JSONValue[] = []; + for (const entry of value) { + const json = jsonValue(entry); + if (json === undefined) return undefined; + items.push(json); + } + return items; + } + if (!isRecord(value) || Object.getPrototypeOf(value) !== Object.prototype) return undefined; + const result: Record = {}; + for (const [key, entry] of Object.entries(value)) { + const json = jsonValue(entry); + if (json === undefined) continue; + result[key] = json; + } + return result; +} + +function rewriteMappedType( + value: unknown, + table: ReadonlyMap, +): Record | unknown { + if (!isRecord(value)) return value; + const mapped = mapType(value.type, table); + return mapped === undefined || mapped === value.type ? value : { ...value, type: mapped }; +} + +function mapType(type: unknown, table: ReadonlyMap): string | undefined { + return typeof type === 'string' ? table.get(type) : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +async function rewriteOutgoingRequestBody(request: Request): Promise { + if (!requestHasJsonBody(request)) return undefined; + const raw = await request.clone().arrayBuffer(); + let parsed: unknown; + try { + parsed = JSON.parse(new TextDecoder().decode(raw)); + } catch { + return undefined; + } + if (!isRecord(parsed)) return undefined; + const rewritten = rewriteDeepSeekOpenResponsesOutgoingBody(parsed); + return rewritten === undefined ? undefined : JSON.stringify(rewritten); +} + +async function cloneOutgoingBody(request: Request): Promise { + if (request.body === null) return null; + const raw = await request.clone().arrayBuffer(); + // Keep JSON as text so callers that `JSON.parse(String(init.body))` still + // work, and so a no-op wrap does not re-serialize the original payload. + return requestHasJsonBody(request) ? new TextDecoder().decode(raw) : raw; +} + +async function rewriteIncomingResponse(response: Response): Promise { + const contentType = response.headers.get('content-type'); + if (isEventStream(contentType) && response.body) { + const headers = new Headers(response.headers); + headers.delete('content-length'); + return new Response(rewriteSseStream(response.body), { + status: response.status, + statusText: response.statusText, + headers, + }); + } + if (!isJsonContentType(contentType)) return response; + let parsed: unknown; + try { + parsed = JSON.parse(await response.clone().text()); + } catch { + return response; + } + const rewritten = rewriteDeepSeekOpenResponsesIncomingValue(parsed); + if (rewritten === parsed) return response; + const headers = new Headers(response.headers); + headers.delete('content-length'); + return new Response(JSON.stringify(rewritten), { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +function rewriteSseStream(stream: ReadableStream): ReadableStream { + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let pending = ''; + return stream.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + pending += decoder.decode(chunk, { stream: true }); + const lines = pending.split('\n'); + pending = lines.pop() ?? ''; + for (const line of lines) { + controller.enqueue(encoder.encode(`${rewriteSseLine(line)}\n`)); + } + }, + flush(controller) { + pending += decoder.decode(); + if (pending.length > 0) controller.enqueue(encoder.encode(rewriteSseLine(pending))); + }, + }), + ); +} + +function rewriteSseLine(line: string): string { + const cr = line.endsWith('\r'); + const core = cr ? line.slice(0, -1) : line; + if (!core.startsWith('data:')) return line; + const payload = core.slice(5).trim(); + if (payload.length === 0 || payload === '[DONE]') return line; + try { + const rewritten = rewriteDeepSeekOpenResponsesIncomingValue(JSON.parse(payload)); + return `data: ${JSON.stringify(rewritten)}${cr ? '\r' : ''}`; + } catch { + return line; + } +} + +function requestHasJsonBody(request: Request): boolean { + if (request.method === 'GET' || request.method === 'HEAD' || request.body === null) return false; + return isJsonContentType(request.headers.get('content-type')); +} + +function isJsonContentType(contentType: string | null): boolean { + return ( + contentType === null || /(^|\s|;)application\/(?:[\w.+-]+\+)?json(?:\s*;|$)/i.test(contentType) + ); +} + +function isEventStream(contentType: string | null): boolean { + return contentType !== null && /(^|\s|;)text\/event-stream(?:\s|;|$)/i.test(contentType); +} + +function requestInit( + request: Request, + headers: Headers, + body: BodyInit | null, + signal: AbortSignal | null | undefined, +): RequestInit { + return { + method: request.method, + headers: [...headers.entries()], + ...(body === null ? {} : { body, duplex: 'half' }), + signal, + cache: request.cache, + credentials: request.credentials, + integrity: request.integrity, + keepalive: request.keepalive, + mode: request.mode, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + } as RequestInit; +} diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index f933e2e774..938828a0f7 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -84,6 +84,14 @@ import { } from './openai-responses-websocket.js'; import { openAiApplyPatchProviderTool, codexApplyPatchProviderTool } from './openai-apply-patch.js'; import { TOOL_SEARCH_NAME, TOOL_SEARCH_PROVIDER_NAME } from './tool-availability.js'; +import { + attachOpenResponsesExtensionReplayItem, + isOpenResponsesExtensionReplayChunk, + openResponsesExtensionReplayItem, + usesDeepSeekOpenResponsesExtensions, +} from './deepseek-open-responses-extensions.js'; +import { NATIVE_WEB_SEARCH_TOOL_NAME } from './native-web-search-tool.js'; +import type { ProviderOptions } from './model-protocol.js'; /** * Build an ai-sdk LanguageModel from a single input object. @@ -167,12 +175,12 @@ export class ModelAdapter { return { toolCalls: true, toolResults: true, - // Verified against @ai-sdk/open-responses@2.0.34: replay preserves - // item order and IDs, but a provider-executed result embedded in the - // assistant message (Maka's provider-tool chronology) is still dropped, - // leaving a dangling function_call on the wire. Fail closed until the - // upstream extension seam (vercel/ai#18899) can round-trip the pair. + // Open Responses dropped provider-executed pairs until the extension + // seam could round-trip them. DeepSeek registers that codec (#4107), so + // replay is open for its hosted items; other Open Responses providers + // stay fail-closed. providerExecutedTools: + usesDeepSeekOpenResponsesExtensions(this.input.connection.providerType) || this.runtime.reasoningReplay.kind !== 'responses' || this.runtime.reasoningReplay.contract.adapter !== 'open-responses', signedThinking: this.runtime.reasoningReplay.kind === 'anthropic-signed', @@ -198,6 +206,37 @@ export class ModelAdapter { }; } + /** + * Whether this target adapter can consume a persisted provider-executed + * exchange. `providerExecutedTools` admits DeepSeek hosted pairs on every + * DeepSeek wire; chat converters still emit those calls as client + * `tool_calls` with no matching `tool` message, and Anthropic coerces an + * unrecognized `WebSearch` pair into `server_tool_use` that fails its + * output schema. Gate emission on the target recognizing the replay state. + */ + canReplayProviderExecutedExchange(item: ProviderExecutedReplayProbe): boolean { + if (item.providerExecuted !== true) return true; + if (!this.runtimeEventReplaySupport().providerExecutedTools) return false; + const { wire, reasoningReplay } = this.runtime; + // Chat wires always pair with `none` or `openai-chat-plaintext` replay. + // Those converters emit provider-executed calls as client `tool_calls`. + if (wire === 'openai-chat') { + return false; + } + if (isOpenResponsesHostedSearchReplay(item)) { + return ( + usesDeepSeekOpenResponsesExtensions(this.input.connection.providerType) && + wire === 'openai-responses' && + reasoningReplay.kind === 'responses' && + reasoningReplay.contract.adapter === 'open-responses' + ); + } + if (item.toolName === NATIVE_WEB_SEARCH_TOOL_NAME && wire === 'anthropic-messages') { + return isAnthropicHostedSearchReplay(item); + } + return true; + } + resolveModel(): unknown { if (providerAuthRequiresSecret(this.input.connection.providerType) && !this.input.apiKey) { throw new Error(`No API key stored for connection "${this.input.connection.slug}"`); @@ -409,16 +448,18 @@ export class ModelAdapter { settleAccounting: (outcome: ModelStepOutcome) => Promise; }, ): ModelStreamResult { - const openAiChatReasoningTransportState = - this.runtime.reasoningReplay.kind === 'openai-chat-plaintext' - ? this.openAiChatReasoningTransportState - : undefined; const openAiResponsesTransportState = this.openAiResponsesTransportState; const resolvedRuntime = this.runtime; + // One map per physical request. AiSdkBackend can run concurrent send() + // calls through this adapter; a session-wide map would mix provider-owned + // item ids across streams and leak aborted carriers into later turns. + const pendingOpenResponsesExtensionReplay = new Map(); let settleOutcome!: (outcome: ModelStepOutcome) => void; const outcome = new Promise((resolve) => { settleOutcome = resolve; }); + const translate = (chunk: AiSdkStreamChunk) => + this.translateChunk(chunk, continuation.runtimeToolName, pendingOpenResponsesExtensionReplay); const events: AsyncIterable = { async *[Symbol.asyncIterator]() { let failure: ModelFailure | undefined; @@ -449,12 +490,7 @@ export class ModelAdapter { sawUnfinalizedPlaintextSummary = true; continue; } - for (const event of translateChunk( - chunk, - openAiChatReasoningTransportState, - resolvedRuntime, - continuation.runtimeToolName, - )) { + for (const event of translate(chunk)) { if (event.kind === 'error') failure = event.failure; yield event; } @@ -465,6 +501,7 @@ export class ModelAdapter { yield { kind: 'error', failure }; } } finally { + pendingOpenResponsesExtensionReplay.clear(); if (continuation.abortSignal.aborted) { failure = normalizeProviderFailure(continuation.abortSignal.reason); } @@ -571,16 +608,34 @@ export class ModelAdapter { * Translate one raw AI SDK stream chunk into zero or more Maka-owned * `ModelStreamEvent`s. This is the sole place that parses SDK chunk names * (`text-delta` / `reasoning-delta` / `finish-step` / `finish` / `error` / …); - * the backend never sees them. Pure and side-effect-free so it is directly - * testable through the Maka-owned event contract. + * the backend never sees them. Open Responses extension-replay carriers are + * merged into the matching provider-executed tool-call so the opaque item + * survives RuntimeEvent persistence. The pending-carrier map is owned by one + * physical stream (`toModelStreamResult`); callers must not share it. */ - translateChunk(chunk: AiSdkStreamChunk): ModelStreamEvent[] { - return translateChunk( - chunk, - this.runtime.reasoningReplay.kind === 'openai-chat-plaintext' - ? this.openAiChatReasoningTransportState - : undefined, - this.runtime, + translateChunk( + chunk: AiSdkStreamChunk, + runtimeToolName?: (name: string) => string, + pendingOpenResponsesExtensionReplay: Map = new Map(), + ): ModelStreamEvent[] { + if (isOpenResponsesExtensionReplayChunk(chunk)) { + const providerOptions = providerOptionsFromSdkChunk(chunk); + const item = openResponsesExtensionReplayItem(providerOptions); + if (providerOptions && item && typeof item.id === 'string') { + pendingOpenResponsesExtensionReplay.set(item.id, providerOptions); + } + return []; + } + return attachPendingOpenResponsesExtensionReplay( + translateChunk( + chunk, + this.runtime.reasoningReplay.kind === 'openai-chat-plaintext' + ? this.openAiChatReasoningTransportState + : undefined, + this.runtime, + runtimeToolName, + ), + pendingOpenResponsesExtensionReplay, ); } @@ -770,6 +825,13 @@ function fixedAnthropicThinkingBudget( return type === 'enabled' && typeof budgetTokens === 'number' ? budgetTokens : 0; } +export interface ProviderExecutedReplayProbe { + providerExecuted?: boolean; + providerOptions?: unknown; + toolName?: string; + output?: unknown; +} + export interface ModelAdapterRuntimeEventReplaySupport { toolCalls: boolean; toolResults: boolean; @@ -810,6 +872,7 @@ function requireResponsesReplayProfile(runtime: ResolvedModelRuntime): string { interface AiSdkStreamChunk { type: string; id?: unknown; + kind?: unknown; text?: string; delta?: string; textDelta?: string; @@ -828,6 +891,7 @@ interface AiSdkStreamChunk { error?: unknown; /** Provider-specific metadata; carries the Anthropic reasoning signature. */ providerMetadata?: unknown; + providerOptions?: unknown; } /** @@ -1330,6 +1394,58 @@ function remapProviderToolNamesInText( return text.replace(/\btool_search\b/gu, providerToolName(TOOL_SEARCH_NAME)); } +function attachPendingOpenResponsesExtensionReplay( + events: ModelStreamEvent[], + pending: Map, +): ModelStreamEvent[] { + if (pending.size === 0) return events; + return events.map((event) => { + if (event.kind !== 'tool-call' || event.toolCall.providerExecuted !== true) return event; + const carrier = pending.get(event.toolCall.toolCallId); + if (!carrier) return event; + pending.delete(event.toolCall.toolCallId); + const providerOptions = attachOpenResponsesExtensionReplayItem( + event.toolCall.providerOptions, + carrier, + ); + return { + ...event, + toolCall: { + ...event.toolCall, + ...(providerOptions !== undefined ? { providerOptions } : {}), + }, + }; + }); +} + +function providerOptionsFromSdkChunk(chunk: AiSdkStreamChunk): ProviderOptions | undefined { + const raw = chunk.providerMetadata ?? chunk.providerOptions; + if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return undefined; + return raw as ProviderOptions; +} + +function isReplayRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isOpenResponsesHostedSearchReplay(item: ProviderExecutedReplayProbe): boolean { + if (openResponsesExtensionReplayItem(item.providerOptions)) return true; + if (!isReplayRecord(item.output)) return false; + return item.output.type === 'web_search_call' || item.output.type === 'openai:web_search_call'; +} + +function isAnthropicHostedSearchReplay(item: ProviderExecutedReplayProbe): boolean { + if (isReplayRecord(item.providerOptions)) { + const anthropic = item.providerOptions.anthropic; + if (isReplayRecord(anthropic) && anthropic.type === 'server_tool_use') return true; + } + if (!Array.isArray(item.output)) return false; + return item.output.some((entry) => { + if (!isReplayRecord(entry) || entry.type !== 'web_search_result') return false; + return typeof entry.url === 'string' || typeof entry.encryptedContent === 'string'; + }); +} + function parseProviderExecutedToolInput(input: unknown): unknown { if (typeof input !== 'string') return input; try { diff --git a/packages/runtime/src/model-factory.ts b/packages/runtime/src/model-factory.ts index de32d99fca..ad1ac28ef0 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -56,6 +56,11 @@ import { import type { OpenAiResponsesTransportState } from './openai-responses-websocket.js'; import { openResponsesUrl } from './provider-urls.js'; import { createOpenResponsesCompatibilityFinalizer } from './open-responses-compatibility.js'; +import { + createDeepSeekOpenResponsesExtensions, + usesDeepSeekOpenResponsesExtensions, + wrapFetchForDeepSeekOpenResponsesExtensions, +} from './deepseek-open-responses-extensions.js'; import { resolveModelRuntime, type ResolvedModelRuntime } from './model-runtime.js'; import { openAiCodexHeaders } from './subscription-auth.js'; import { createRequestCustomizationFetch } from './request-customization-fetch.js'; @@ -108,20 +113,33 @@ export function getAIModel(input: ModelFactoryInput): LanguageModelV4 { const contract = reasoningReplay.kind === 'responses' ? reasoningReplay.contract : undefined; if (contract?.adapter !== 'open-responses') return undefined; const finalizeBody = createOpenResponsesCompatibilityFinalizer(contract.compatibility); + const deepSeekExtensions = usesDeepSeekOpenResponsesExtensions(connection.providerType); + // Discriminator rewrite sits closest to the network so overlays still + // see SDK namespaced types. @ai-sdk/open-responses@2.0.44 only accepts + // `:`; DeepSeek documents bare `web_search` / + // `web_search_call`. Keep the wrap even if vercel/ai#19939 ships a + // flag-only `allowBareTypes` — the parsers still require a `:`. + const transportFetch = deepSeekExtensions + ? wrapFetchForDeepSeekOpenResponsesExtensions(baseFetch) + : baseFetch; // Request customization is applied first; provider compatibility is // the final authority before network dispatch, so an overlay cannot // re-enable storage or violate the provider's tool-choice contract. - const responsesFetch = finalizeBody - ? createRequestCustomizationFetch(baseFetch, { - ...requestCustomization, - finalizeBody, - }) - : requestFetch; + const responsesFetch = + finalizeBody || deepSeekExtensions + ? createRequestCustomizationFetch(transportFetch, { + ...requestCustomization, + ...(finalizeBody ? { finalizeBody } : {}), + }) + : requestFetch; return createOpenResponses({ name: connection.providerType, apiKey, url: openResponsesUrl(baseURL), fetch: responsesFetch, + ...(deepSeekExtensions + ? { experimental_extensions: createDeepSeekOpenResponsesExtensions() } + : {}), })(modelId); }; @@ -535,7 +553,7 @@ function buildThinkingProviderOptions( }; } // Anthropic-protocol: effort enum models send `effort`; toggle/budget - // models send `thinking.disabled` for off. No budget-token mapping — the + // models send `thinking.disabled` for off. No budget-token mapping �the // provider's native effort values pass through unchanged. case 'anthropic': case 'MiniMax': @@ -651,9 +669,9 @@ function buildThinkingProviderOptions( } : {}; // Every remaining path resolves to one of a handful of wire families. - // Keying the fallback on the resolved adapter — the same object + // Keying the fallback on the resolved adapter �the same object // `getAIModel` switches on, including per-model models.dev package - // overrides — keeps declaration and wire in one seam. The variant gate + // overrides �keeps declaration and wire in one seam. The variant gate // above (level is defined only when metadata declares it) is what makes // this safe to generalize: undeclared models never reach the wire. default: @@ -721,8 +739,8 @@ function buildFamilyWire( // through verbatim, ahead of the cross-provider top-level `reasoning` // enum that cannot express DeepSeek's `max` (whose documented mapping // sends `xhigh` to high, not max). The SDK resolves providerOptions - // under the raw provider `name` — no camelCase alias, unlike - // openai-compatible — so key by the same name getAIModel passes. + // under the raw provider `name` �no camelCase alias, unlike + // openai-compatible �so key by the same name getAIModel passes. return explicitReasoningEffort ? { [connection.providerType]: { reasoningEffort: explicitReasoningEffort } } : {}; @@ -803,8 +821,7 @@ function toCamelCase(name: string): string { /** * The providerOptions key for an openai-compatible model: the camelCase * alias of the identity passed to `createOpenAICompatible`. The SDK - * resolves both spellings — known options and passthrough fields alike — - * but flags dashed keys as deprecated (a `type: 'deprecated'` warning on + * resolves both spellings �known options and passthrough fields alike � * but flags dashed keys as deprecated (a `type: 'deprecated'` warning on * every doGenerate result), so the camelCase alias is the canonical key. * * The same alias also selects the SDK's *response* metadata namespace: