From 9d2237fc47e8b17a251d3bfd447cc87d27c90e6b Mon Sep 17 00:00:00 2001 From: Xuanrui Li Date: Tue, 15 Sep 2026 12:25:32 +0000 Subject: [PATCH 01/16] feat(runtime): adopt Open Responses extension codecs for DeepSeek tools Register DeepSeek hosted web_search codecs on createOpenResponses. The runtime already pins @ai-sdk/open-responses@2.0.44; until vercel/ai#19939 ships allowBareTypes, map only DeepSeek's documented bare discriminators at the network boundary. Hosted search stays fail-closed. Generated-by: Cursor Cloud Agent (Grok 4.6) --- packages/core/src/model-web-search.ts | 6 +- ...deepseek-open-responses-extensions.test.ts | 596 ++++++++++++++++++ .../src/deepseek-open-responses-extensions.ts | 522 +++++++++++++++ packages/runtime/src/model-factory.ts | 42 +- 4 files changed, 1150 insertions(+), 16 deletions(-) create mode 100644 packages/runtime/src/__tests__/deepseek-open-responses-extensions.test.ts create mode 100644 packages/runtime/src/deepseek-open-responses-extensions.ts 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__/deepseek-open-responses-extensions.test.ts b/packages/runtime/src/__tests__/deepseek-open-responses-extensions.test.ts new file mode 100644 index 0000000000..d6554b0fce --- /dev/null +++ b/packages/runtime/src/__tests__/deepseek-open-responses-extensions.test.ts @@ -0,0 +1,596 @@ +/* + * 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 type { LlmConnection } from '@maka/core/llm-connections'; +import type { LanguageModelV4ProviderTool, LanguageModelV4StreamPart } from '@ai-sdk/provider'; +import { getAIModel } from '../model-factory.js'; +import { lowerModelTools } from '../model-adapter.js'; +import { + createDeepSeekOpenResponsesExtensions, + DEEPSEEK_OPEN_RESPONSES_WEB_SEARCH_EXTENSION_ID, + openResponsesSupportsBareExtensionTypes, + 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 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', () => { + if (openResponsesSupportsBareExtensionTypes()) return; + 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', + ); + }); + + test('wrapFetch rewrites only allowlisted discriminators on the wire', async () => { + if (openResponsesSupportsBareExtensionTypes()) return; + 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('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('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/deepseek-open-responses-extensions.ts b/packages/runtime/src/deepseek-open-responses-extensions.ts new file mode 100644 index 0000000000..d5f2457930 --- /dev/null +++ b/packages/runtime/src/deepseek-open-responses-extensions.ts @@ -0,0 +1,522 @@ +/* + * 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 { + createOpenResponses, + type Experimental_OpenResponsesExtension, + type Experimental_OpenResponsesExtensionContentPart, + type Experimental_OpenResponsesExtensionInputPart, + type Experimental_OpenResponsesExtensionItem, + type Experimental_OpenResponsesExtensionStreamPart, +} from '@ai-sdk/open-responses'; +import type { JSONObject, JSONValue, LanguageModelV4ProviderTool } from '@ai-sdk/provider'; +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. + * When vercel/ai#19939 (`allowBareTypes`) ships, the wrap becomes a no-op. + */ + +/** + * 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'; + +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 OUTGOING_EVENT_TYPES = new Map([ + [NAMESPACED_WEB_SEARCH_EVENTS.inProgress, WEB_SEARCH_EVENTS.inProgress], + [NAMESPACED_WEB_SEARCH_EVENTS.searching, WEB_SEARCH_EVENTS.searching], + [NAMESPACED_WEB_SEARCH_EVENTS.completed, WEB_SEARCH_EVENTS.completed], +]); + +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], +]); + +type BareOpenResponsesExtension = Experimental_OpenResponsesExtension & { + allowBareTypes?: true; + bareToolType?: string; + bareItemTypes?: readonly string[]; + bareEventTypes?: readonly string[]; +}; + +let cachedBareTypeSupport: boolean | undefined; + +/** True when this `@ai-sdk/open-responses` build accepts allowlisted bare discriminators. */ +export function openResponsesSupportsBareExtensionTypes(): boolean { + if (cachedBareTypeSupport !== undefined) return cachedBareTypeSupport; + try { + createOpenResponses({ + name: 'maka-open-responses-bare-probe', + url: 'http://127.0.0.1/maka-open-responses-bare-probe', + experimental_extensions: [ + { + id: 'maka.web_search', + allowBareTypes: true, + bareToolType: WEB_SEARCH_TOOL, + encodeTool: () => ({}), + } as Experimental_OpenResponsesExtension, + ], + }); + cachedBareTypeSupport = true; + } catch { + cachedBareTypeSupport = false; + } + return cachedBareTypeSupport; +} + +export function usesDeepSeekOpenResponsesExtensions(providerType: string): boolean { + return providerType === 'deepseek'; +} + +export function createDeepSeekOpenResponsesExtensions(): readonly Experimental_OpenResponsesExtension[] { + const registeredItemType = openResponsesSupportsBareExtensionTypes() + ? WEB_SEARCH_ITEM + : NAMESPACED_WEB_SEARCH_ITEM; + const extension: BareOpenResponsesExtension = openResponsesSupportsBareExtensionTypes() + ? { + id: DEEPSEEK_OPEN_RESPONSES_WEB_SEARCH_EXTENSION_ID, + allowBareTypes: true, + bareToolType: WEB_SEARCH_TOOL, + bareItemTypes: [WEB_SEARCH_ITEM], + bareEventTypes: [ + WEB_SEARCH_EVENTS.inProgress, + WEB_SEARCH_EVENTS.searching, + WEB_SEARCH_EVENTS.completed, + ], + encodeTool: encodeDeepSeekWebSearchTool, + decodeItem: decodeDeepSeekWebSearchItem, + encodeInputItem: (options) => encodeDeepSeekWebSearchInputItem(options, registeredItemType), + decodeEvent: decodeDeepSeekWebSearchEvent, + } + : { + 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, registeredItemType), + decodeEvent: decodeDeepSeekWebSearchEvent, + }; + return [extension as Experimental_OpenResponsesExtension]; +} + +/** + * Allowlisted discriminator adapter until `@ai-sdk/open-responses` accepts + * `allowBareTypes` (vercel/ai#19939). Unknown types are left untouched. + */ +export function wrapFetchForDeepSeekOpenResponsesExtensions( + upstream: typeof globalThis.fetch, +): typeof globalThis.fetch { + if (openResponsesSupportsBareExtensionTypes()) return upstream; + 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 cloneRequestBody(request)), signal), + ); + return rewriteIncomingResponse(response); + }; +} + +export function rewriteDeepSeekOpenResponsesOutgoingBody( + body: Record, +): Record { + const next = { ...body }; + if (Array.isArray(next.tools)) { + next.tools = next.tools.map((tool) => rewriteMappedType(tool, OUTGOING_TOOL_TYPES)); + } + if (isRecord(next.tool_choice)) { + next.tool_choice = rewriteMappedType(next.tool_choice, OUTGOING_TOOL_TYPES); + } + if (Array.isArray(next.input)) { + next.input = next.input.map((item) => rewriteMappedType(item, OUTGOING_TOOL_TYPES)); + } + 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, + }, + ]; + if (item.status === 'completed' || item.status === 'failed' || options.mode === 'generate') { + parts.push({ + type: 'tool-result', + toolCallId, + toolName: NATIVE_WEB_SEARCH_TOOL_NAME, + result, + providerExecuted: 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 = + storedReplayItem(part.providerOptions) ?? + storedReplayItem((part as { providerMetadata?: unknown }).providerMetadata); + if (stored && isWebSearchCallType(String(stored.type))) { + return { + ...stored, + type: registeredItemType, + } as Experimental_OpenResponsesExtensionItem; + } + 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 storedReplayItem(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; +} + +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; + return JSON.stringify(rewriteDeepSeekOpenResponsesOutgoingBody(parsed)); +} + +async function cloneRequestBody(request: Request): Promise { + if (request.body === null) return null; + return request.clone().arrayBuffer(); +} + +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-factory.ts b/packages/runtime/src/model-factory.ts index de32d99fca..535790b0e2 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,32 @@ 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`. Drop the wrap when vercel/ai#19939 ships. + 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 +552,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 +668,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 +738,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 +820,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: From 23ca97dd551ec42b9e4efb5111915f5171f10bfd Mon Sep 17 00:00:00 2001 From: Xuanrui Li Date: Tue, 15 Sep 2026 14:48:05 +0000 Subject: [PATCH 02/16] fix(runtime): persist DeepSeek web_search replay carrier and failed status Merge the Open Responses custom replay carrier onto provider-executed tool-call metadata so the opaque web_search_call item survives RuntimeEvent persistence, reconstruct it on replay, and mark failed hosted searches as errors. Keep product hosted-search routing fail-closed. Generated-by: Cursor Cloud Agent (Grok 4.6) --- .../src/__tests__/ai-sdk-backend.test.ts | 18 +- ...deepseek-open-responses-extensions.test.ts | 257 +++++++++++++++++- .../src/__tests__/model-adapter.test.ts | 90 +++++- .../runtime/src/ai-sdk-message-projection.ts | 14 +- .../src/deepseek-open-responses-extensions.ts | 111 +++++++- packages/runtime/src/model-adapter.ts | 97 +++++-- 6 files changed, 539 insertions(+), 48 deletions(-) diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 1948c14265..108b001f1d 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -3162,12 +3162,12 @@ describe('AiSdkBackend model history', () => { const model = completionModel(); const backend = createBackend({ connection: { - slug: 'deepseek', - providerType: 'deepseek', - defaultModel: 'deepseek-v4-flash', + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', }, - apiKey: 'deepseek-token', - modelId: 'deepseek-v4-flash', + apiKey: 'alibaba-token', + modelId: 'qwen3.8-max', modelFactory: () => model, tools: [], }); @@ -3241,12 +3241,12 @@ describe('AiSdkBackend model history', () => { const model = completionModel(); const backend = createBackend({ connection: { - slug: 'deepseek', - providerType: 'deepseek', - defaultModel: 'deepseek-v4-flash', + 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: [], }); diff --git a/packages/runtime/src/__tests__/deepseek-open-responses-extensions.test.ts b/packages/runtime/src/__tests__/deepseek-open-responses-extensions.test.ts index d6554b0fce..398455e895 100644 --- a/packages/runtime/src/__tests__/deepseek-open-responses-extensions.test.ts +++ b/packages/runtime/src/__tests__/deepseek-open-responses-extensions.test.ts @@ -19,13 +19,22 @@ 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 { lowerModelTools } from '../model-adapter.js'; +import { ModelAdapter, lowerModelTools } from '../model-adapter.js'; +import { buildRuntimeEventModelReplayPlan } from '../model-history.js'; import { + attachOpenResponsesExtensionReplayItem, createDeepSeekOpenResponsesExtensions, DEEPSEEK_OPEN_RESPONSES_WEB_SEARCH_EXTENSION_ID, + OPEN_RESPONSES_EXTENSION_REPLAY_KIND, + openResponsesExtensionReplayCarrierPart, + openResponsesExtensionReplayItem, + openResponsesExtensionReplayReferenceOptions, openResponsesSupportsBareExtensionTypes, rewriteDeepSeekOpenResponsesIncomingValue, rewriteDeepSeekOpenResponsesOutgoingBody, @@ -46,6 +55,43 @@ function conn(providerType: LlmConnection['providerType'], slug = 'test'): LlmCo }; } +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 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' } }, @@ -399,6 +445,215 @@ describe('DeepSeek Open Responses extension codecs', () => { assert.deepEqual(replayed?.[0]?.action, { type: 'open_page', url: 'https://maka.example/' }); }); + 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' } }, + }); + }); + + test('replays the hosted search item through the durable RuntimeEvent boundary', async () => { + const adapter = deepSeekAdapter(); + 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 } }, + }, + }), + [], + ); + 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' } }, + }, + }); + 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('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( diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts index 5cb9955bd4..6c63b9c6db 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -278,7 +278,7 @@ 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', @@ -791,6 +791,94 @@ 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 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 } }, + }, + }), + [], + ); + 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' } }, + }, + }), + [ + { + 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('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]; diff --git a/packages/runtime/src/ai-sdk-message-projection.ts b/packages/runtime/src/ai-sdk-message-projection.ts index 2c67a733a6..40bb3fd217 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, @@ -403,12 +407,19 @@ 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; + 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 +429,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/deepseek-open-responses-extensions.ts b/packages/runtime/src/deepseek-open-responses-extensions.ts index d5f2457930..ae27722829 100644 --- a/packages/runtime/src/deepseek-open-responses-extensions.ts +++ b/packages/runtime/src/deepseek-open-responses-extensions.ts @@ -26,6 +26,7 @@ import { type Experimental_OpenResponsesExtensionStreamPart, } from '@ai-sdk/open-responses'; import type { JSONObject, JSONValue, LanguageModelV4ProviderTool } from '@ai-sdk/provider'; +import type { CustomPart, ProviderOptions } from './model-protocol.js'; import { NATIVE_WEB_SEARCH_TOOL_NAME } from './native-web-search-tool.js'; /** @@ -45,6 +46,9 @@ import { NATIVE_WEB_SEARCH_TOOL_NAME } from './native-web-search-tool.js'; */ 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'; @@ -121,6 +125,98 @@ export function usesDeepSeekOpenResponsesExtensions(providerType: string): boole 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) : (providerOptions as ProviderOptions); +} + export function createDeepSeekOpenResponsesExtensions(): readonly Experimental_OpenResponsesExtension[] { const registeredItemType = openResponsesSupportsBareExtensionTypes() ? WEB_SEARCH_ITEM @@ -269,6 +365,7 @@ function decodeDeepSeekWebSearchItem(options: { toolName: NATIVE_WEB_SEARCH_TOOL_NAME, result, providerExecuted: true, + ...(item.status === 'failed' ? { isError: true } : {}), } as Experimental_OpenResponsesExtensionContentPart); } return parts; @@ -284,8 +381,8 @@ function encodeDeepSeekWebSearchInputItem( const part = options.part; if (part.type !== 'tool-call') return undefined; const stored = - storedReplayItem(part.providerOptions) ?? - storedReplayItem((part as { providerMetadata?: unknown }).providerMetadata); + openResponsesExtensionReplayItem(part.providerOptions) ?? + openResponsesExtensionReplayItem((part as { providerMetadata?: unknown }).providerMetadata); if (stored && isWebSearchCallType(String(stored.type))) { return { ...stored, @@ -347,16 +444,6 @@ function actionFromToolInput(input: unknown): JSONObject | undefined { return jsonObject(input); } -function storedReplayItem(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; -} - function jsonObject(value: unknown): JSONObject | undefined { const json = jsonValue(value); return json !== null && typeof json === 'object' && !Array.isArray(json) ? json : undefined; diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index f933e2e774..4cf24814f9 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -84,6 +84,13 @@ 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 type { ProviderOptions } from './model-protocol.js'; /** * Build an ai-sdk LanguageModel from a single input object. @@ -151,6 +158,7 @@ export class ModelAdapter { private readonly runtime: ResolvedModelRuntime; private readonly openAiChatReasoningTransportState: OpenAiChatReasoningTransportState; private readonly openAiResponsesTransportState: OpenAiResponsesTransportState; + private readonly pendingOpenResponsesExtensionReplay = new Map(); constructor(private readonly input: ModelAdapterInput) { this.runtime = input.resolvedRuntime ?? resolveModelRuntime(input.connection, input.modelId); @@ -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', @@ -409,16 +417,14 @@ 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; let settleOutcome!: (outcome: ModelStepOutcome) => void; const outcome = new Promise((resolve) => { settleOutcome = resolve; }); + const translate = (chunk: AiSdkStreamChunk) => + this.translateChunk(chunk, continuation.runtimeToolName); const events: AsyncIterable = { async *[Symbol.asyncIterator]() { let failure: ModelFailure | undefined; @@ -449,12 +455,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; } @@ -571,16 +572,32 @@ 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. */ - 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, + ): ModelStreamEvent[] { + if (isOpenResponsesExtensionReplayChunk(chunk)) { + const providerOptions = providerOptionsFromSdkChunk(chunk); + const item = openResponsesExtensionReplayItem(providerOptions); + if (providerOptions && item && typeof item.id === 'string') { + this.pendingOpenResponsesExtensionReplay.set(item.id, providerOptions); + } + return []; + } + return attachPendingOpenResponsesExtensionReplay( + translateChunk( + chunk, + this.runtime.reasoningReplay.kind === 'openai-chat-plaintext' + ? this.openAiChatReasoningTransportState + : undefined, + this.runtime, + runtimeToolName, + ), + this.pendingOpenResponsesExtensionReplay, ); } @@ -810,6 +827,7 @@ function requireResponsesReplayProfile(runtime: ResolvedModelRuntime): string { interface AiSdkStreamChunk { type: string; id?: unknown; + kind?: unknown; text?: string; delta?: string; textDelta?: string; @@ -828,6 +846,7 @@ interface AiSdkStreamChunk { error?: unknown; /** Provider-specific metadata; carries the Anthropic reasoning signature. */ providerMetadata?: unknown; + providerOptions?: unknown; } /** @@ -1330,6 +1349,36 @@ 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 parseProviderExecutedToolInput(input: unknown): unknown { if (typeof input !== 'string') return input; try { From e4b0827e5eb9ebcfc3b7fe90c4815adebb1b3bf2 Mon Sep 17 00:00:00 2001 From: Xuanrui Li Date: Wed, 16 Sep 2026 07:17:54 +0000 Subject: [PATCH 03/16] fix(runtime): scope DeepSeek replay carriers per physical stream Keep Open Responses extension-replay pending items on the per-request stream in toModelStreamResult instead of the session-wide ModelAdapter, and clear the map on success, error, and abort so concurrent send() calls with the same provider item id cannot mix opaque carriers. Generated-by: Cursor Cloud Agent (Grok 4.6) --- ...deepseek-open-responses-extensions.test.ts | 39 ++- .../src/__tests__/model-adapter.test.ts | 275 +++++++++++++++++- packages/runtime/src/model-adapter.ts | 16 +- 3 files changed, 295 insertions(+), 35 deletions(-) diff --git a/packages/runtime/src/__tests__/deepseek-open-responses-extensions.test.ts b/packages/runtime/src/__tests__/deepseek-open-responses-extensions.test.ts index 398455e895..6afd4d47ec 100644 --- a/packages/runtime/src/__tests__/deepseek-open-responses-extensions.test.ts +++ b/packages/runtime/src/__tests__/deepseek-open-responses-extensions.test.ts @@ -468,6 +468,7 @@ describe('DeepSeek Open Responses extension codecs', () => { 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', @@ -476,25 +477,33 @@ describe('DeepSeek Open Responses extension codecs', () => { 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 } }, + 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' } }, + 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 = diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts index 6c63b9c6db..d7c0fcd0da 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', () => { @@ -804,6 +807,7 @@ describe('ModelAdapter stream and error normalization', () => { newId: idGenerator(), now: monotonicClock(), }); + const pending = new Map(); const item = { id: 'ws_opaque', type: 'openai:web_search_call', @@ -811,26 +815,34 @@ describe('ModelAdapter stream and error normalization', () => { provider_trace: 'opaque-replay', }; assert.deepEqual( - adapter.translateChunk({ - type: 'custom', - kind: 'open-responses.extension-replay', - providerMetadata: { - deepseek: { openResponsesExtension: { id: 'openai.web_search', item } }, + 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' } }, + 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', @@ -855,6 +867,102 @@ describe('ModelAdapter stream and error normalization', () => { ); }); + 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]; @@ -1229,6 +1337,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/model-adapter.ts b/packages/runtime/src/model-adapter.ts index 4cf24814f9..a3981014c4 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -158,7 +158,6 @@ export class ModelAdapter { private readonly runtime: ResolvedModelRuntime; private readonly openAiChatReasoningTransportState: OpenAiChatReasoningTransportState; private readonly openAiResponsesTransportState: OpenAiResponsesTransportState; - private readonly pendingOpenResponsesExtensionReplay = new Map(); constructor(private readonly input: ModelAdapterInput) { this.runtime = input.resolvedRuntime ?? resolveModelRuntime(input.connection, input.modelId); @@ -419,12 +418,16 @@ export class ModelAdapter { ): ModelStreamResult { 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); + this.translateChunk(chunk, continuation.runtimeToolName, pendingOpenResponsesExtensionReplay); const events: AsyncIterable = { async *[Symbol.asyncIterator]() { let failure: ModelFailure | undefined; @@ -466,6 +469,7 @@ export class ModelAdapter { yield { kind: 'error', failure }; } } finally { + pendingOpenResponsesExtensionReplay.clear(); if (continuation.abortSignal.aborted) { failure = normalizeProviderFailure(continuation.abortSignal.reason); } @@ -574,17 +578,19 @@ export class ModelAdapter { * (`text-delta` / `reasoning-delta` / `finish-step` / `finish` / `error` / …); * 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. + * survives RuntimeEvent persistence. The pending-carrier map is owned by one + * physical stream (`toModelStreamResult`); callers must not share it. */ 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') { - this.pendingOpenResponsesExtensionReplay.set(item.id, providerOptions); + pendingOpenResponsesExtensionReplay.set(item.id, providerOptions); } return []; } @@ -597,7 +603,7 @@ export class ModelAdapter { this.runtime, runtimeToolName, ), - this.pendingOpenResponsesExtensionReplay, + pendingOpenResponsesExtensionReplay, ); } From e607988743ded9de7df85914b6a5ea2bb55a1b89 Mon Sep 17 00:00:00 2001 From: Xuanrui Li Date: Thu, 17 Sep 2026 22:00:33 +0800 Subject: [PATCH 04/16] test(runtime): restore DeepSeek replay degradation coverage Generated-by: OpenAI Codex --- .../src/__tests__/ai-sdk-backend.test.ts | 55 ++++++++++++++----- 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index 108b001f1d..ecae0d9a00 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -3158,18 +3158,40 @@ 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 () => { + 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: 'alibaba-token-plan-cn', - providerType: 'alibaba-token-plan-cn', - defaultModel: 'qwen3.8-max', + slug: 'deepseek', + providerType: 'deepseek', + defaultModel: 'deepseek-v4-flash', }, - apiKey: 'alibaba-token', - modelId: 'qwen3.8-max', - modelFactory: () => model, - tools: [], + apiKey: 'deepseek-test-token', + modelId: 'deepseek-v4-flash', + modelFactory: (input) => getAIModel({ ...input, fetch }), + tools: [buildNativeWebSearchTool({ adapter: 'openai-responses' })], }); await drain( @@ -3231,10 +3253,17 @@ 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(JSON.stringify(input).includes('web_search_result'), false); }); test('keeps unrelated client tool history when degrading a hosted tool pair', async () => { From 079c369015df9f3c1d32e6ec6cf2755c71ac3616 Mon Sep 17 00:00:00 2001 From: Xuanrui Li Date: Fri, 18 Sep 2026 04:50:07 +0000 Subject: [PATCH 05/16] fix(runtime): skip unchanged DeepSeek Open Responses body rewrites Drop the unused outgoing event map, return undefined from the fetch-layer rewrite when no allowlisted discriminator changed, and document generate vs stream result parity plus the allowBareTypes parser assumption. Generated-by: Cursor Cloud Agent (Grok 4.6) --- .../src/__tests__/ai-sdk-backend.test.ts | 12 +++++ ...deepseek-open-responses-extensions.test.ts | 7 +++ .../src/deepseek-open-responses-extensions.ts | 46 ++++++++++++------- 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index ecae0d9a00..b4eab8627a 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -3159,6 +3159,8 @@ describe('AiSdkBackend model history', () => { }); 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; @@ -3263,6 +3265,16 @@ describe('AiSdkBackend model history', () => { 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); }); diff --git a/packages/runtime/src/__tests__/deepseek-open-responses-extensions.test.ts b/packages/runtime/src/__tests__/deepseek-open-responses-extensions.test.ts index 6afd4d47ec..7da37c78dc 100644 --- a/packages/runtime/src/__tests__/deepseek-open-responses-extensions.test.ts +++ b/packages/runtime/src/__tests__/deepseek-open-responses-extensions.test.ts @@ -182,6 +182,13 @@ describe('DeepSeek Open Responses extension codecs', () => { ).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 () => { diff --git a/packages/runtime/src/deepseek-open-responses-extensions.ts b/packages/runtime/src/deepseek-open-responses-extensions.ts index ae27722829..bee9dad679 100644 --- a/packages/runtime/src/deepseek-open-responses-extensions.ts +++ b/packages/runtime/src/deepseek-open-responses-extensions.ts @@ -37,7 +37,10 @@ import { NATIVE_WEB_SEARCH_TOOL_NAME } from './native-web-search-tool.js'; * 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. - * When vercel/ai#19939 (`allowBareTypes`) ships, the wrap becomes a no-op. + * When vercel/ai#19939 (`allowBareTypes`) ships, the wrap becomes a no-op + * only if the SDK also relaxes its item/event parsers: today those still + * require a `:` in the type, so a flag-only upstream would register the + * bare branch and then silently stop decoding. */ /** @@ -77,12 +80,6 @@ const INCOMING_TOOL_TYPES = new Map([ [WEB_SEARCH_ITEM, NAMESPACED_WEB_SEARCH_ITEM], ]); -const OUTGOING_EVENT_TYPES = new Map([ - [NAMESPACED_WEB_SEARCH_EVENTS.inProgress, WEB_SEARCH_EVENTS.inProgress], - [NAMESPACED_WEB_SEARCH_EVENTS.searching, WEB_SEARCH_EVENTS.searching], - [NAMESPACED_WEB_SEARCH_EVENTS.completed, WEB_SEARCH_EVENTS.completed], -]); - const INCOMING_EVENT_TYPES = new Map([ [WEB_SEARCH_EVENTS.inProgress, NAMESPACED_WEB_SEARCH_EVENTS.inProgress], [WEB_SEARCH_EVENTS.searching, NAMESPACED_WEB_SEARCH_EVENTS.searching], @@ -218,6 +215,9 @@ export function openResponsesExtensionReplayReferenceOptions( } export function createDeepSeekOpenResponsesExtensions(): readonly Experimental_OpenResponsesExtension[] { + // Probe the constructor, not just the type. @ai-sdk/open-responses@2.0.44 + // still asserts namespaced item/event types even if `allowBareTypes` is set, + // so this branch is only safe once both the registry and the parsers agree. const registeredItemType = openResponsesSupportsBareExtensionTypes() ? WEB_SEARCH_ITEM : NAMESPACED_WEB_SEARCH_ITEM; @@ -283,16 +283,25 @@ export function wrapFetchForDeepSeekOpenResponsesExtensions( export function rewriteDeepSeekOpenResponsesOutgoingBody( body: Record, -): Record { - const next = { ...body }; - if (Array.isArray(next.tools)) { - next.tools = next.tools.map((tool) => rewriteMappedType(tool, OUTGOING_TOOL_TYPES)); +): 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(next.tool_choice)) { - next.tool_choice = rewriteMappedType(next.tool_choice, OUTGOING_TOOL_TYPES); + 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(next.input)) { - next.input = next.input.map((item) => rewriteMappedType(item, OUTGOING_TOOL_TYPES)); + 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; } @@ -358,6 +367,10 @@ function decodeDeepSeekWebSearchItem(options: { 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', @@ -498,7 +511,8 @@ async function rewriteOutgoingRequestBody(request: Request): Promise { From 15b3e190b9be454c0ea815747a0814008fda28f4 Mon Sep 17 00:00:00 2001 From: Xuanrui Li Date: Fri, 18 Sep 2026 04:54:13 +0000 Subject: [PATCH 06/16] fix(runtime): keep DeepSeek JSON fetch bodies as text on no-op rewrite A no-op discriminator wrap must not hand the upstream fetch an ArrayBuffer; request mocks and JSON.parse(String(body)) still expect the original text payload. Generated-by: Cursor Cloud Agent (Grok 4.6) --- ...deepseek-open-responses-extensions.test.ts | 22 +++++++++++++++++++ .../src/deepseek-open-responses-extensions.ts | 9 +++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/runtime/src/__tests__/deepseek-open-responses-extensions.test.ts b/packages/runtime/src/__tests__/deepseek-open-responses-extensions.test.ts index 7da37c78dc..1a46b98bbf 100644 --- a/packages/runtime/src/__tests__/deepseek-open-responses-extensions.test.ts +++ b/packages/runtime/src/__tests__/deepseek-open-responses-extensions.test.ts @@ -226,6 +226,28 @@ describe('DeepSeek Open Responses extension codecs', () => { ); }); + test('wrapFetch keeps JSON request bodies as text when nothing maps', async () => { + if (openResponsesSupportsBareExtensionTypes()) return; + 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) => { diff --git a/packages/runtime/src/deepseek-open-responses-extensions.ts b/packages/runtime/src/deepseek-open-responses-extensions.ts index bee9dad679..2f0e7d7cc2 100644 --- a/packages/runtime/src/deepseek-open-responses-extensions.ts +++ b/packages/runtime/src/deepseek-open-responses-extensions.ts @@ -275,7 +275,7 @@ export function wrapFetchForDeepSeekOpenResponsesExtensions( if (rewrittenBody !== undefined) headers.delete('content-length'); const response = await upstream( request.url, - requestInit(request, headers, rewrittenBody ?? (await cloneRequestBody(request)), signal), + requestInit(request, headers, rewrittenBody ?? (await cloneOutgoingBody(request)), signal), ); return rewriteIncomingResponse(response); }; @@ -515,9 +515,12 @@ async function rewriteOutgoingRequestBody(request: Request): Promise { +async function cloneOutgoingBody(request: Request): Promise { if (request.body === null) return null; - return request.clone().arrayBuffer(); + 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 { From cd0dbb2a4db6575592ef579001894a67c9e48248 Mon Sep 17 00:00:00 2001 From: Xuanrui Li Date: Mon, 21 Sep 2026 00:17:40 +0000 Subject: [PATCH 07/16] fix(runtime): drop DeepSeek hosted pairs on unrecognized replay wires Gate provider-executed WebSearch emission on the target adapter recognizing the exchange. Chat Completions no longer emit dangling tool_calls, and Anthropic no longer coerces DeepSeek search into server_tool_use. Also return undefined from replay-reference projection unless an extension reference was produced, delete the unreachable allowBareTypes branch, and pin SDK ${type}:${id} replay dedup. Generated-by: Cursor Cloud Agent (Grok 4.6) --- .../src/__tests__/ai-sdk-backend.test.ts | 243 +++++++++++++++++- ...deepseek-open-responses-extensions.test.ts | 80 +++++- .../src/__tests__/model-adapter.test.ts | 72 ++++++ .../runtime/src/ai-sdk-message-projection.ts | 26 +- .../src/deepseek-open-responses-extensions.ts | 119 +++------ packages/runtime/src/model-adapter.ts | 59 +++++ packages/runtime/src/model-factory.ts | 3 +- 7 files changed, 508 insertions(+), 94 deletions(-) diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index b4eab8627a..edb18baf81 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, }, @@ -3278,6 +3285,149 @@ describe('AiSdkBackend model history', () => { assert.equal(JSON.stringify(input).includes('web_search_result'), false); }); + 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-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({ @@ -16735,6 +16885,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 index 1a46b98bbf..71005cd408 100644 --- a/packages/runtime/src/__tests__/deepseek-open-responses-extensions.test.ts +++ b/packages/runtime/src/__tests__/deepseek-open-responses-extensions.test.ts @@ -35,7 +35,6 @@ import { openResponsesExtensionReplayCarrierPart, openResponsesExtensionReplayItem, openResponsesExtensionReplayReferenceOptions, - openResponsesSupportsBareExtensionTypes, rewriteDeepSeekOpenResponsesIncomingValue, rewriteDeepSeekOpenResponsesOutgoingBody, usesDeepSeekOpenResponsesExtensions, @@ -133,7 +132,6 @@ describe('DeepSeek Open Responses extension codecs', () => { }); test('rewrites only allowlisted DeepSeek discriminators', () => { - if (openResponsesSupportsBareExtensionTypes()) return; assert.deepEqual( rewriteDeepSeekOpenResponsesOutgoingBody({ tools: [ @@ -192,7 +190,6 @@ describe('DeepSeek Open Responses extension codecs', () => { }); test('wrapFetch rewrites only allowlisted discriminators on the wire', async () => { - if (openResponsesSupportsBareExtensionTypes()) return; let sent: Record | undefined; const fetch = wrapFetchForDeepSeekOpenResponsesExtensions(async (_url, init) => { sent = JSON.parse(String(init?.body)) as Record; @@ -227,7 +224,6 @@ describe('DeepSeek Open Responses extension codecs', () => { }); test('wrapFetch keeps JSON request bodies as text when nothing maps', async () => { - if (openResponsesSupportsBareExtensionTypes()) return; let sent: unknown; const fetch = wrapFetchForDeepSeekOpenResponsesExtensions(async (_url, init) => { sent = init?.body; @@ -474,6 +470,78 @@ describe('DeepSeek Open Responses extension codecs', () => { 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', @@ -493,6 +561,10 @@ describe('DeepSeek Open Responses extension codecs', () => { 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 () => { diff --git a/packages/runtime/src/__tests__/model-adapter.test.ts b/packages/runtime/src/__tests__/model-adapter.test.ts index d7c0fcd0da..27e5fdf709 100644 --- a/packages/runtime/src/__tests__/model-adapter.test.ts +++ b/packages/runtime/src/__tests__/model-adapter.test.ts @@ -286,6 +286,78 @@ describe('ModelAdapter stream and error normalization', () => { 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', () => { diff --git a/packages/runtime/src/ai-sdk-message-projection.ts b/packages/runtime/src/ai-sdk-message-projection.ts index 40bb3fd217..8123a2c9bc 100644 --- a/packages/runtime/src/ai-sdk-message-projection.ts +++ b/packages/runtime/src/ai-sdk-message-projection.ts @@ -194,8 +194,7 @@ export class AiSdkMessageProjection { 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) ) { return false; } @@ -218,13 +217,24 @@ export class AiSdkMessageProjection { 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)) return false; } return true; }), }; } + private canReplayProviderExecutedItem(item: RuntimeEventModelReplayItem): boolean { + if (item.kind !== 'tool_call' && item.kind !== 'tool_result') return true; + if (item.providerExecuted !== true) return true; + return this.input.modelAdapter.canReplayProviderExecutedExchange({ + providerExecuted: true, + ...(item.kind === 'tool_call' ? { providerOptions: item.providerOptions } : {}), + toolName: item.toolName, + ...(item.kind === 'tool_result' ? { output: item.output } : {}), + }); + } + /** * Materialize a replay plan into provider messages, grouping each assistant * step's reasoning + text + tool calls into ONE assistant message (Anthropic @@ -407,6 +417,16 @@ 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); diff --git a/packages/runtime/src/deepseek-open-responses-extensions.ts b/packages/runtime/src/deepseek-open-responses-extensions.ts index 2f0e7d7cc2..9b8544cf79 100644 --- a/packages/runtime/src/deepseek-open-responses-extensions.ts +++ b/packages/runtime/src/deepseek-open-responses-extensions.ts @@ -17,13 +17,12 @@ * under the License. */ -import { - createOpenResponses, - type Experimental_OpenResponsesExtension, - type Experimental_OpenResponsesExtensionContentPart, - type Experimental_OpenResponsesExtensionInputPart, - type Experimental_OpenResponsesExtensionItem, - type Experimental_OpenResponsesExtensionStreamPart, +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 { CustomPart, ProviderOptions } from './model-protocol.js'; @@ -37,10 +36,10 @@ import { NATIVE_WEB_SEARCH_TOOL_NAME } from './native-web-search-tool.js'; * 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. - * When vercel/ai#19939 (`allowBareTypes`) ships, the wrap becomes a no-op - * only if the SDK also relaxes its item/event parsers: today those still - * require a `:` in the type, so a flag-only upstream would register the - * bare branch and then silently stop decoding. + * 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 `:`. */ /** @@ -86,38 +85,6 @@ const INCOMING_EVENT_TYPES = new Map([ [WEB_SEARCH_EVENTS.completed, NAMESPACED_WEB_SEARCH_EVENTS.completed], ]); -type BareOpenResponsesExtension = Experimental_OpenResponsesExtension & { - allowBareTypes?: true; - bareToolType?: string; - bareItemTypes?: readonly string[]; - bareEventTypes?: readonly string[]; -}; - -let cachedBareTypeSupport: boolean | undefined; - -/** True when this `@ai-sdk/open-responses` build accepts allowlisted bare discriminators. */ -export function openResponsesSupportsBareExtensionTypes(): boolean { - if (cachedBareTypeSupport !== undefined) return cachedBareTypeSupport; - try { - createOpenResponses({ - name: 'maka-open-responses-bare-probe', - url: 'http://127.0.0.1/maka-open-responses-bare-probe', - experimental_extensions: [ - { - id: 'maka.web_search', - allowBareTypes: true, - bareToolType: WEB_SEARCH_TOOL, - encodeTool: () => ({}), - } as Experimental_OpenResponsesExtension, - ], - }); - cachedBareTypeSupport = true; - } catch { - cachedBareTypeSupport = false; - } - return cachedBareTypeSupport; -} - export function usesDeepSeekOpenResponsesExtensions(providerType: string): boolean { return providerType === 'deepseek'; } @@ -211,57 +178,37 @@ export function openResponsesExtensionReplayReferenceOptions( next[key] = { ...value, openResponsesExtension: { id, itemId } }; rewritten = true; } - return rewritten ? (next as ProviderOptions) : (providerOptions as ProviderOptions); + return rewritten ? (next as ProviderOptions) : undefined; } export function createDeepSeekOpenResponsesExtensions(): readonly Experimental_OpenResponsesExtension[] { - // Probe the constructor, not just the type. @ai-sdk/open-responses@2.0.44 - // still asserts namespaced item/event types even if `allowBareTypes` is set, - // so this branch is only safe once both the registry and the parsers agree. - const registeredItemType = openResponsesSupportsBareExtensionTypes() - ? WEB_SEARCH_ITEM - : NAMESPACED_WEB_SEARCH_ITEM; - const extension: BareOpenResponsesExtension = openResponsesSupportsBareExtensionTypes() - ? { - id: DEEPSEEK_OPEN_RESPONSES_WEB_SEARCH_EXTENSION_ID, - allowBareTypes: true, - bareToolType: WEB_SEARCH_TOOL, - bareItemTypes: [WEB_SEARCH_ITEM], - bareEventTypes: [ - WEB_SEARCH_EVENTS.inProgress, - WEB_SEARCH_EVENTS.searching, - WEB_SEARCH_EVENTS.completed, - ], - encodeTool: encodeDeepSeekWebSearchTool, - decodeItem: decodeDeepSeekWebSearchItem, - encodeInputItem: (options) => encodeDeepSeekWebSearchInputItem(options, registeredItemType), - decodeEvent: decodeDeepSeekWebSearchEvent, - } - : { - 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, registeredItemType), - decodeEvent: decodeDeepSeekWebSearchEvent, - }; - return [extension as 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 until `@ai-sdk/open-responses` accepts - * `allowBareTypes` (vercel/ai#19939). Unknown types are left untouched. + * 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 { - if (openResponsesSupportsBareExtensionTypes()) return upstream; return async (input, init) => { const request = new Request(input, init); const signal = @@ -402,6 +349,10 @@ function encodeDeepSeekWebSearchInputItem( 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 { diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index a3981014c4..cce4a778ae 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -90,6 +90,7 @@ import { 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'; /** @@ -205,6 +206,35 @@ 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; + if (wire === 'openai-chat' || reasoningReplay.kind === 'openai-chat-plaintext') { + 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}"`); @@ -793,6 +823,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; @@ -1385,6 +1422,28 @@ function providerOptionsFromSdkChunk(chunk: AiSdkStreamChunk): ProviderOptions | 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 535790b0e2..ad1ac28ef0 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -117,7 +117,8 @@ export function getAIModel(input: ModelFactoryInput): LanguageModelV4 { // 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`. Drop the wrap when vercel/ai#19939 ships. + // `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; From 8449a770c1fda29796d6bc5b5531670f9585a205 Mon Sep 17 00:00:00 2001 From: Xuanrui Li Date: Mon, 21 Sep 2026 00:18:35 +0000 Subject: [PATCH 08/16] fix(runtime): judge hosted-search replay on the whole exchange Provider-executed calls often carry no origin metadata; the matching tool-result does. Gate both items from the paired exchange so Anthropic hosted search still replays when only the result is schema-shaped. Generated-by: Cursor Cloud Agent (Grok 4.6) --- .../runtime/src/ai-sdk-message-projection.ts | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/ai-sdk-message-projection.ts b/packages/runtime/src/ai-sdk-message-projection.ts index 8123a2c9bc..62de4774c4 100644 --- a/packages/runtime/src/ai-sdk-message-projection.ts +++ b/packages/runtime/src/ai-sdk-message-projection.ts @@ -194,7 +194,7 @@ export class AiSdkMessageProjection { if (item.kind === 'tool_result' && !support.toolResults) return false; if ( (item.kind === 'tool_call' || item.kind === 'tool_result') && - !this.canReplayProviderExecutedItem(item) + !this.canReplayProviderExecutedItem(item, plan.items) ) { return false; } @@ -217,21 +217,34 @@ export class AiSdkMessageProjection { items: plan.items.filter((item) => { if (item.kind === 'tool_call' || item.kind === 'tool_result') { if (!support.toolCalls || !support.toolResults) return false; - if (!this.canReplayProviderExecutedItem(item)) return false; + if (!this.canReplayProviderExecutedItem(item, plan.items)) return false; } return true; }), }; } - private canReplayProviderExecutedItem(item: RuntimeEventModelReplayItem): boolean { + private canReplayProviderExecutedItem( + item: RuntimeEventModelReplayItem, + items: readonly RuntimeEventModelReplayItem[], + ): boolean { if (item.kind !== 'tool_call' && item.kind !== 'tool_result') return true; if (item.providerExecuted !== true) return true; + const call = + item.kind === 'tool_call' + ? item + : items.find((entry) => entry.kind === 'tool_call' && entry.toolCallId === item.toolCallId); + const result = + item.kind === 'tool_result' + ? item + : items.find( + (entry) => entry.kind === 'tool_result' && entry.toolCallId === item.toolCallId, + ); return this.input.modelAdapter.canReplayProviderExecutedExchange({ providerExecuted: true, - ...(item.kind === 'tool_call' ? { providerOptions: item.providerOptions } : {}), + providerOptions: call?.kind === 'tool_call' ? call.providerOptions : undefined, toolName: item.toolName, - ...(item.kind === 'tool_result' ? { output: item.output } : {}), + output: result?.kind === 'tool_result' ? result.output : undefined, }); } From 4ae8c52a3cdb83b83cd9097a6da78bc946882951 Mon Sep 17 00:00:00 2001 From: Xuanrui Li Date: Tue, 22 Sep 2026 14:23:11 +0000 Subject: [PATCH 09/16] fix(runtime): keep hosted-search answers off the step limit Open Responses reports finishReason tool-calls for a provider-executed web search that already includes the final answer. When maxSteps is set, that turn was recorded as step_limit and failed. Only spend the step limit when the last settled step still has client tool calls to continue. Generated-by: Cursor Cloud Agent (Grok 4.7) Co-authored-by: Xuanrui Li --- .../src/__tests__/ai-sdk-backend.test.ts | 106 ++++++++++++++++++ packages/runtime/src/ai-sdk-turn.ts | 9 +- 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index edb18baf81..d83ce0fc18 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -8907,6 +8907,112 @@ 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: LanguageModelV4StreamPart[] = + 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 }, + }, + }, + ]; + 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[] = []; diff --git a/packages/runtime/src/ai-sdk-turn.ts b/packages/runtime/src/ai-sdk-turn.ts index cb2f3cca8b..cfeb98751d 100644 --- a/packages/runtime/src/ai-sdk-turn.ts +++ b/packages/runtime/src/ai-sdk-turn.ts @@ -2398,9 +2398,16 @@ 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); From 1bef2ea4f073318db67572efb520b3463a9f3be9 Mon Sep 17 00:00:00 2001 From: Xuanrui Li Date: Tue, 22 Sep 2026 14:27:43 +0000 Subject: [PATCH 10/16] fix(runtime): typecheck the chat-wire hosted-search replay gate openai-chat already covers openai-chat-plaintext replay, so the extra kind comparison is unreachable under ResolvedModelRuntime. The hosted search step-limit regression casts provider-executed tool results the same way as the existing stream fixtures. Generated-by: Cursor Cloud Agent (Grok 4.7) --- packages/runtime/src/__tests__/ai-sdk-backend.test.ts | 5 +++-- packages/runtime/src/ai-sdk-turn.ts | 4 +--- packages/runtime/src/model-adapter.ts | 4 +++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts index d83ce0fc18..6e8bf7cb08 100644 --- a/packages/runtime/src/__tests__/ai-sdk-backend.test.ts +++ b/packages/runtime/src/__tests__/ai-sdk-backend.test.ts @@ -8916,7 +8916,7 @@ describe('AiSdkBackend usage telemetry', () => { const model = new MockLanguageModelV4({ doStream: async () => { streamCalls += 1; - const chunks: LanguageModelV4StreamPart[] = + const chunks = ( streamCalls === 1 ? [ { type: 'stream-start', warnings: [] }, @@ -8962,7 +8962,8 @@ describe('AiSdkBackend usage telemetry', () => { outputTokens: { total: 1, text: 1, reasoning: 0 }, }, }, - ]; + ] + ) as LanguageModelV4StreamPart[]; return { stream: simulateReadableStream({ chunks, diff --git a/packages/runtime/src/ai-sdk-turn.ts b/packages/runtime/src/ai-sdk-turn.ts index cfeb98751d..948179cedd 100644 --- a/packages/runtime/src/ai-sdk-turn.ts +++ b/packages/runtime/src/ai-sdk-turn.ts @@ -2405,9 +2405,7 @@ export class AiSdkTurn { // no client call, so they complete as `end_turn`. const stopReason = this.loopStopReason ?? - (maxSteps !== undefined && - finishReason === 'tool-calls' && - lastCompletedStepHadToolResult + (maxSteps !== undefined && finishReason === 'tool-calls' && lastCompletedStepHadToolResult ? 'step_limit' : this.mapFinishReason(finishReason)); trace.modelStreamCompleted(stopReason); diff --git a/packages/runtime/src/model-adapter.ts b/packages/runtime/src/model-adapter.ts index cce4a778ae..938828a0f7 100644 --- a/packages/runtime/src/model-adapter.ts +++ b/packages/runtime/src/model-adapter.ts @@ -218,7 +218,9 @@ export class ModelAdapter { if (item.providerExecuted !== true) return true; if (!this.runtimeEventReplaySupport().providerExecutedTools) return false; const { wire, reasoningReplay } = this.runtime; - if (wire === 'openai-chat' || reasoningReplay.kind === 'openai-chat-plaintext') { + // 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)) { From e705e99e7adf974fcc928d6f57809e959856145a Mon Sep 17 00:00:00 2001 From: Xuanrui Li Date: Wed, 23 Sep 2026 10:22:43 +0000 Subject: [PATCH 11/16] fix(runtime): type the DeepSeek Open Responses extension gate Accept ProviderType instead of string, and comment the tool-result providerExecuted assertion so it is not read as a type escape hatch. Generated-by: Cursor Cloud Agent (Grok 4.6) Co-authored-by: Xuanrui Li --- packages/runtime/src/deepseek-open-responses-extensions.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/deepseek-open-responses-extensions.ts b/packages/runtime/src/deepseek-open-responses-extensions.ts index 9b8544cf79..1c9c290115 100644 --- a/packages/runtime/src/deepseek-open-responses-extensions.ts +++ b/packages/runtime/src/deepseek-open-responses-extensions.ts @@ -25,6 +25,7 @@ import type { 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'; @@ -85,7 +86,7 @@ const INCOMING_EVENT_TYPES = new Map([ [WEB_SEARCH_EVENTS.completed, NAMESPACED_WEB_SEARCH_EVENTS.completed], ]); -export function usesDeepSeekOpenResponsesExtensions(providerType: string): boolean { +export function usesDeepSeekOpenResponsesExtensions(providerType: ProviderType): boolean { return providerType === 'deepseek'; } @@ -324,6 +325,10 @@ function decodeDeepSeekWebSearchItem(options: { 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); From 23855f9cf723d62649d57579d7c615ab30299417 Mon Sep 17 00:00:00 2001 From: Xuanrui Li Date: Thu, 24 Sep 2026 00:14:30 +0800 Subject: [PATCH 12/16] placeholder --- .../runtime/src/ai-sdk-message-projection.ts | 825 +----------------- 1 file changed, 1 insertion(+), 824 deletions(-) diff --git a/packages/runtime/src/ai-sdk-message-projection.ts b/packages/runtime/src/ai-sdk-message-projection.ts index 62de4774c4..676a66f808 100644 --- a/packages/runtime/src/ai-sdk-message-projection.ts +++ b/packages/runtime/src/ai-sdk-message-projection.ts @@ -1,824 +1 @@ -/* - * 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 { AttachmentRef, DirectoryReference, QuoteRef, StorageRef } from '@maka/core/events'; -import type { AssistantThinkingPart } from '@maka/core/session'; -import { - MAX_PROVIDER_IMAGE_REQUEST_BYTES, - PROVIDER_IMAGE_BUDGET_EXCEEDED_MESSAGE, - type AttachmentByteReader, -} from '@maka/core/attachments'; -import type { DurableToolResultProjection } from '@maka/core/durable-tool-result-projection'; -import type { ProviderImageBudget } from './ai-sdk-compaction.js'; -import { - applyPatchReplayFactText, - normalizeApplyPatchReplayInput, - type ApplyPatchProfile, -} from './apply-patch-profile.js'; -import { durableProjectionToToolResultOutput } from './durable-tool-result-projection.js'; -import { - historyCompactCheckpointToModelMessage, - isProviderHistoryCompactCheckpoint, - type HistoryCompactCheckpoint, -} from './history-compact-checkpoint.js'; -import { - admitProviderReasoningReplayItems, - buildRuntimeEventReplayTimeline, - formatTextWithInlineRefs, - steeringProviderOptions, - type RuntimeEventModelReplayItem, - type RuntimeEventModelReplayPlan, - type RuntimeEventReplayToolExchange, - type RuntimeEventReplayToolResultItem, -} from './model-history.js'; -import type { ModelAdapter } from './model-adapter.js'; -import type { - ModelMessage, - ReasoningPart, - ToolResultContentPart, - 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, - replayPlaintextResponsesProviderOptions, -} from './responses-reasoning-state.js'; -import { toolResultOutput } from './tool-result-output.js'; - -export interface AiSdkMessageProjectionInput { - modelAdapter: ModelAdapter; - applyPatchProfile: ApplyPatchProfile | null; - supportsVision?: boolean; - readAttachmentBytes?: AttachmentByteReader; - maxProviderImageRequestBytes?: number; -} - -function isRedactedThinking(providerOptions: AssistantThinkingPart['providerOptions']): boolean { - const anthropic = providerOptions?.anthropic; - return ( - !!anthropic && - typeof anthropic === 'object' && - !Array.isArray(anthropic) && - typeof (anthropic as { redactedData?: unknown }).redactedData === 'string' - ); -} - -function encryptedResponsesReasoning( - providerOptions: AssistantThinkingPart['providerOptions'], -): { itemId: string; reasoningEncryptedContent: string } | undefined { - const openai = providerOptions?.openai; - if (!openai || typeof openai !== 'object' || Array.isArray(openai)) return undefined; - const { itemId, reasoningEncryptedContent } = openai as { - itemId?: unknown; - reasoningEncryptedContent?: unknown; - }; - return typeof itemId === 'string' && - itemId.length > 0 && - typeof reasoningEncryptedContent === 'string' && - reasoningEncryptedContent.length > 0 - ? { itemId, reasoningEncryptedContent } - : undefined; -} - -export function hasFinalizedReasoning(part: AssistantThinkingPart): boolean { - return ( - !!part.signature || - isRedactedThinking(part.providerOptions) || - decodePlaintextResponsesReasoningState(part.providerOptions).kind === 'valid' || - encryptedResponsesReasoning(part.providerOptions) !== undefined - ); -} - -function isImageToolResult( - value: unknown, -): value is { kind: 'image'; mimeType: string; ref: StorageRef } { - if (!value || typeof value !== 'object') return false; - const image = value as { kind?: unknown; mimeType?: unknown; ref?: unknown }; - return ( - image.kind === 'image' && - typeof image.mimeType === 'string' && - image.ref !== null && - typeof image.ref === 'object' - ); -} - -function toolResultText(text: string): ToolResultContentPart { - return { type: 'text', text }; -} - -function nativeApplyPatchFailureOutput(output: ToolResultOutput): ToolResultOutput { - const value = output.type === 'json' || output.type === 'error-json' ? output.value : undefined; - const record = value && typeof value === 'object' && !Array.isArray(value) ? value : undefined; - const message = - output.type === 'text' || output.type === 'error-text' - ? output.value - : typeof record?.output === 'string' - ? record.output - : typeof record?.text === 'string' - ? record.text - : typeof record?.error === 'string' - ? record.error - : undefined; - return { - type: 'json', - value: { status: 'failed', ...(message ? { output: message } : {}) }, - }; -} - -function durableApplyPatchReplayFactText( - input: unknown, - projection: DurableToolResultProjection, - isError: boolean, -): string | null { - if (projection.kind === 'json') { - const fact = applyPatchReplayFactText(input, projection, isError); - if (fact) return fact; - } - const output = durableProjectionToToolResultOutput(projection); - switch (output.type) { - case 'text': - case 'error-text': - return output.value; - case 'json': - case 'error-json': - return JSON.stringify(output.value); - case 'content': { - const text = output.value - .filter((part): part is Extract => part.type === 'text') - .map((part) => part.text) - .join('\n'); - return text || null; - } - case 'execution-denied': - return output.reason - ? `ApplyPatch execution denied: ${output.reason}` - : 'ApplyPatch execution denied.'; - } -} - -/** - * Projects canonical Runtime history and current user input into provider - * messages. It owns no execution state; its only mutable data is the weak - * event index attached to the messages it creates. - */ -export class AiSdkMessageProjection { - private readonly memoryReplayMessageEvents = new WeakMap(); - - constructor(private readonly input: AiSdkMessageProjectionInput) {} - - canReplayProviderNative(plan: RuntimeEventModelReplayPlan): boolean { - const support = this.input.modelAdapter.runtimeEventReplaySupport(); - 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') && - !this.canReplayProviderExecutedItem(item, plan.items) - ) { - return false; - } - if (item.kind === 'thinking' && item.signature && !support.signedThinking) return false; - } - return true; - } - - /** - * Per-item counterpart to {@link canReplayProviderNative}: drop only the - * items the adapter cannot represent so one unsupported provider-executed - * pair does not cost unrelated client tool history (#2972). Call and result - * items fall together β€” a call without its result is a dangling wire item, - * and provider-executed pairs are flagged on both items by the plan. - */ - dropUnsupportedReplayItems(plan: RuntimeEventModelReplayPlan): RuntimeEventModelReplayPlan { - const support = this.input.modelAdapter.runtimeEventReplaySupport(); - return { - ...plan, - items: plan.items.filter((item) => { - if (item.kind === 'tool_call' || item.kind === 'tool_result') { - if (!support.toolCalls || !support.toolResults) return false; - if (!this.canReplayProviderExecutedItem(item, plan.items)) return false; - } - return true; - }), - }; - } - - private canReplayProviderExecutedItem( - item: RuntimeEventModelReplayItem, - items: readonly RuntimeEventModelReplayItem[], - ): boolean { - if (item.kind !== 'tool_call' && item.kind !== 'tool_result') return true; - if (item.providerExecuted !== true) return true; - const call = - item.kind === 'tool_call' - ? item - : items.find((entry) => entry.kind === 'tool_call' && entry.toolCallId === item.toolCallId); - const result = - item.kind === 'tool_result' - ? item - : items.find( - (entry) => entry.kind === 'tool_result' && entry.toolCallId === item.toolCallId, - ); - return this.input.modelAdapter.canReplayProviderExecutedExchange({ - providerExecuted: true, - providerOptions: call?.kind === 'tool_call' ? call.providerOptions : undefined, - toolName: item.toolName, - output: result?.kind === 'tool_result' ? result.output : undefined, - }); - } - - /** - * Materialize a replay plan into provider messages, grouping each assistant - * step's reasoning + text + tool calls into ONE assistant message (Anthropic - * requires the signed thinking block to lead the tool-use assistant message). - * - * The ledger lands a step's parts as: tool_call(s), tool_result(s), thinking, - * text (the per-step AssistantMessage flushes at `finish-step`, after the - * step's tool events). Model text carries the step id and closes the step. - * Client tools replay as `[reasoning, text, tool-call…]` followed by tool - * messages; provider-executed tools replay as - * `[reasoning, tool-call, tool-result, text]`, preserving provider chronology - * for item references and grounded text. Steps with no text closer β€” a - * thinking + tool step (its empty text closer is skipped from the plan as - * `empty_text_skipped`) or a pure-tool step β€” flush grouped by stepId, - * claiming any parked reasoning for that step. Legacy per-turn items (no step - * id) keep the older shape: tool calls form a tool-only assistant, - * text/thinking become standalone messages. - */ - async materializeRuntimeReplayPlan( - plan: RuntimeEventModelReplayPlan, - budget: ProviderImageBudget, - historyCompactCheckpoint: HistoryCompactCheckpoint | undefined, - providerReasoningReplayEventIds: ReadonlySet, - ): Promise { - type ThinkingItem = Extract; - type TextItem = Extract; - type ReplayReasoning = { - part?: ReasoningPart; - providerOptions?: NonNullable; - }; - const out: ModelMessage[] = []; - const push = (message: ModelMessage, eventIds: readonly string[]) => { - out.push(message); - this.memoryReplayMessageEvents.set(message, [...new Set(eventIds)]); - }; - const replaySupport = this.input.modelAdapter.runtimeEventReplaySupport(); - const reasoningReplay = (item: ThinkingItem): ReplayReasoning | undefined => { - if (item.signature) { - return replaySupport.signedThinking - ? { - part: { - type: 'reasoning' as const, - text: item.text, - providerOptions: { anthropic: { signature: item.signature } }, - }, - } - : undefined; - } - if (isRedactedThinking(item.providerOptions)) { - return replaySupport.signedThinking - ? { - part: { - type: 'reasoning' as const, - text: item.text, - providerOptions: item.providerOptions, - }, - } - : undefined; - } - if ( - typeof replaySupport.responsesReasoning === 'object' && - replaySupport.responsesReasoning.kind === 'plaintext-item' - ) { - const decoded = decodePlaintextResponsesReasoningState(item.providerOptions); - if (decoded.kind !== 'valid') return undefined; - if (decoded.state.profile !== replaySupport.responsesReasoning.profile) { - return undefined; - } - const providerOptions = replayPlaintextResponsesProviderOptions({ - providerOptionsKey: replaySupport.responsesReasoning.providerOptionsKey, - state: decoded.state, - text: item.text, - }); - if (!providerOptions) return undefined; - return { - part: { type: 'reasoning' as const, text: item.text, providerOptions }, - }; - } - if (replaySupport.responsesReasoning === 'plaintext-content') { - if (item.text.length === 0) return undefined; - return { part: { type: 'reasoning' as const, text: item.text } }; - } - if (replaySupport.responsesReasoning === 'encrypted-content') { - const encrypted = encryptedResponsesReasoning(item.providerOptions); - if (encrypted) { - return { - part: { - type: 'reasoning' as const, - text: item.text, - providerOptions: { - openai: encrypted, - }, - }, - }; - } - } - if (!replaySupport.unsignedThinking) return undefined; - const reasoningField = openAiChatReasoningFieldFromProviderOptions(item.providerOptions); - if (!reasoningField) return undefined; - return { - providerOptions: { - openaiCompatible: { [reasoningField]: item.text }, - } as NonNullable, - }; - }; - // Tool results are emitted only when their tool_call claims them here. A - // result whose call never appears in the plan (sliced-away call, corrupt - // ledger) is INTENTIONALLY dropped at the end: a standalone tool message - // with no preceding tool_use in an assistant message is an Anthropic 400. - // The old item-by-item materializer emitted such orphans; do not "fix" this - // back β€” the plan flags them as `unmatched_tool_result` (a non-blocking - // diagnostic precisely so this drop path is reachable; see - // hasBlockingReplayDiagnostics). - const materializeReplayToolResult = async ( - result: RuntimeEventReplayToolResultItem, - toolName: string, - ): Promise => { - const output = result.modelProjection - ? await this.materializeDurableToolResultProjection( - budget, - result.modelProjection, - `runtime-event:${result.eventId}:tool-result`, - ) - : await this.materializeToolResultOutput( - budget, - result.output, - result.isError, - `runtime-event:${result.eventId}:tool-result`, - ); - if (toolName !== 'apply_patch') return output; - return result.isError ? nativeApplyPatchFailureOutput(output) : output; - }; - const pushClientToolResults = async (exchanges: readonly RuntimeEventReplayToolExchange[]) => { - for (const { call, result } of exchanges) { - if (!result || result.providerExecuted === true) continue; - push( - { - role: 'tool', - content: [ - { - type: 'tool-result', - toolCallId: result.toolCallId, - toolName: result.toolName, - output: await materializeReplayToolResult(result, call.toolName), - }, - ], - }, - [result.eventId], - ); - } - }; - // Emit one assistant message for a step, preserving the distinct client- - // and provider-executed tool chronologies described above. - const emitStep = async ( - reasoning: readonly ThinkingItem[] | undefined, - text: TextItem | undefined, - exchanges: readonly RuntimeEventReplayToolExchange[], - replayFacts: ReadonlyArray<{ readonly text: string; readonly eventIds: readonly string[] }>, - ) => { - const calls = exchanges.map(({ call }) => call); - const content: unknown[] = []; - const replayReasoning = (reasoning ?? []) - .map((item) => ({ eventId: item.eventId, replay: reasoningReplay(item) })) - .filter( - (entry): entry is { eventId: string; replay: ReplayReasoning } => - entry.replay !== undefined, - ); - const eventIds = [ - ...replayReasoning.map((entry) => entry.eventId), - ...(text ? [text.eventId] : []), - ...calls.map((call) => call.eventId), - ...replayFacts.flatMap((fact) => fact.eventIds), - ]; - for (const { replay } of replayReasoning) { - if (replay.part) content.push(replay.part); - } - // Provider-owned tools execute before the grounded assistant text in the - // same provider step. Preserve that chronology for Responses item - // references and Anthropic server_tool_use/result replay. Client tools - // 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, - ...(replayReference !== undefined - ? { providerOptions: replayReference } - : call.providerOptions !== undefined - ? { providerOptions: call.providerOptions } - : {}), - providerExecuted: true, - }); - if (!result || result.providerExecuted !== true) continue; - eventIds.push(result.eventId); - content.push({ - type: 'tool-result', - toolCallId: result.toolCallId, - toolName: result.toolName, - output: await materializeReplayToolResult(result, call.toolName), - ...(replayReference !== undefined ? { providerOptions: replayReference } : {}), - }); - } - if (text && text.content.length > 0) { - content.push({ - type: 'text', - text: text.content, - ...(text.providerOptions !== undefined ? { providerOptions: text.providerOptions } : {}), - }); - } - for (const replayFact of replayFacts) { - content.push({ type: 'text', text: replayFact.text }); - } - for (const call of calls) { - if (call.providerExecuted === true) continue; - content.push({ - type: 'tool-call', - toolCallId: call.toolCallId, - toolName: call.toolName, - input: call.input, - ...(call.providerOptions !== undefined ? { providerOptions: call.providerOptions } : {}), - ...(call.providerExecuted !== undefined - ? { providerExecuted: call.providerExecuted } - : {}), - }); - } - const replayProviderOptions = replayReasoning.find( - (entry) => entry.replay.providerOptions !== undefined, - )?.replay.providerOptions; - if (content.length > 0 || replayProviderOptions) { - push( - { - role: 'assistant', - content, - ...(replayProviderOptions ? { providerOptions: replayProviderOptions } : {}), - } as ModelMessage, - eventIds, - ); - } - await pushClientToolResults(exchanges); - }; - const admittedItems = admitProviderReasoningReplayItems( - plan.items, - providerReasoningReplayEventIds, - ); - for (const entry of buildRuntimeEventReplayTimeline(admittedItems)) { - if (entry.kind === 'thinking') { - const replayReasoning = reasoningReplay(entry.item); - if (replayReasoning) { - push( - { - role: 'assistant', - content: replayReasoning.part ? [replayReasoning.part] : [], - ...(replayReasoning.providerOptions - ? { providerOptions: replayReasoning.providerOptions } - : {}), - } as ModelMessage, - [entry.item.eventId], - ); - } - continue; - } - if (entry.kind === 'text') { - push(await this.materializeRuntimeReplayItem(budget, entry.item), [entry.item.eventId]); - continue; - } - - const exchanges: RuntimeEventReplayToolExchange[] = []; - const replayFacts: Array<{ readonly text: string; readonly eventIds: readonly string[] }> = - []; - for (const { call, result } of entry.calls) { - if (call.toolName !== 'apply_patch') { - exchanges.push({ call, ...(result ? { result } : {}) }); - continue; - } - const replayInput = normalizeApplyPatchReplayInput( - this.input.applyPatchProfile, - call.toolCallId, - call.input, - ); - if (replayInput !== null) { - exchanges.push({ - call: { - ...call, - input: replayInput, - ...(replayInput !== call.input ? { providerOptions: undefined } : {}), - }, - ...(result ? { result } : {}), - }); - continue; - } - if (!result) continue; - const replayFact = result.modelProjection - ? durableApplyPatchReplayFactText(call.input, result.modelProjection, result.isError) - : applyPatchReplayFactText(call.input, result.output, result.isError); - if (!replayFact) continue; - replayFacts.push({ text: replayFact, eventIds: [call.eventId, result.eventId] }); - } - await emitStep(entry.reasoning, entry.text, exchanges, replayFacts); - } - return this.prependProviderHistoryCompactMessage(out, historyCompactCheckpoint); - } - - async materializeRuntimeReplayTextOnly( - budget: ProviderImageBudget, - plan: RuntimeEventModelReplayPlan, - historyCompactCheckpoint?: HistoryCompactCheckpoint, - ): Promise { - const messages: ModelMessage[] = []; - for (const item of plan.items) { - if (item.kind === 'text') - this.pushMemoryIndexedMessage( - messages, - await this.materializeRuntimeReplayItem(budget, item), - [item.eventId], - ); - } - return this.prependProviderHistoryCompactMessage(messages, historyCompactCheckpoint); - } - - private prependProviderHistoryCompactMessage( - messages: ModelMessage[], - checkpoint: HistoryCompactCheckpoint | undefined, - ): ModelMessage[] { - if (!checkpoint || !isProviderHistoryCompactCheckpoint(checkpoint)) return messages; - const providerMessage = historyCompactCheckpointToModelMessage(checkpoint); - this.memoryReplayMessageEvents.set(providerMessage, [ - `history-compact:${checkpoint.checkpointId}`, - ]); - return [providerMessage, ...messages]; - } - - private pushMemoryIndexedMessage( - messages: ModelMessage[], - message: ModelMessage, - eventIds: readonly string[], - ): void { - messages.push(message); - this.memoryReplayMessageEvents.set(message, [...new Set(eventIds)]); - } - - memoryEventMessagePositions( - messages: readonly ModelMessage[], - ): Readonly> | undefined { - const positions: Record = {}; - for (const [position, message] of messages.entries()) { - for (const eventId of this.memoryReplayMessageEvents.get(message) ?? []) { - (positions[eventId] ??= []).push(position); - } - } - return Object.keys(positions).length > 0 ? positions : undefined; - } - - private async materializeRuntimeReplayItem( - budget: ProviderImageBudget, - item: Extract, - ): Promise { - if (item.role === 'user') { - // Both ordinary and steered replay materialize image attachments through - // the same path the original request used β€” a steering replay that kept - // only the envelope text would hand a recovery turn references without - // the native images the first request received. - const content = await this.appendImageParts( - budget, - item.content, - item.attachments, - item.steering ? `steering:${item.steering.eventId}` : `runtime-event:${item.eventId}`, - ); - if (item.steering) { - // Already envelope-wrapped by the plan; carry the structured identity - // so injection dedupe recognizes the replayed message. - return { - role: 'user', - content, - providerOptions: steeringProviderOptions(item.steering.eventId), - }; - } - return { - role: 'user', - content, - } as ModelMessage; - } - return { - role: item.role, - content: item.content, - ...(item.providerOptions !== undefined ? { providerOptions: item.providerOptions } : {}), - }; - } - - /** A decision key deduplicates re-materialization; no key charges each occurrence. */ - private chargeImageBudget( - budget: ProviderImageBudget, - bytes: number, - decisionKey?: string, - ): boolean { - if (decisionKey !== undefined) { - const cached = budget.decisions.get(decisionKey); - if (cached !== undefined) return cached; - } - const keep = - budget.used + bytes <= - (this.input.maxProviderImageRequestBytes ?? MAX_PROVIDER_IMAGE_REQUEST_BYTES); - if (keep) budget.used += bytes; - if (decisionKey !== undefined) budget.decisions.set(decisionKey, keep); - return keep; - } - - /** - * Render provider-visible content for a user message: keep the given - * (already-formatted) text, and append image attachments as provider image - * parts only for explicitly vision-capable models. Non-image attachments stay - * as placeholder refs in the text. Shared by the current turn and RuntimeEvent replay. - */ - async appendImageParts( - budget: ProviderImageBudget, - textContent: string, - attachments?: AttachmentRef[], - decisionKeyPrefix?: string, - ): Promise { - const images = attachments?.filter((a) => a.kind === 'image') ?? []; - if (images.length === 0) { - return textContent; - } - if (this.input.supportsVision !== true) { - // `textContent` already carries each attachment's stable Read argument. - // Native provider image delivery is unavailable here, but that does not - // establish whether the model can process the image through a tool. - return textContent; - } - if (!this.input.readAttachmentBytes) { - return textContent; - } - const parts: Array< - | { type: 'text'; text: string } - | { - type: 'file'; - data: { type: 'data'; data: Uint8Array }; - mediaType: string; - } - > = [{ type: 'text', text: textContent }]; - let omittedByBudget = 0; - for (const [index, image] of images.entries()) { - const read = await this.input.readAttachmentBytes(image.ref); - if (!read.ok) { - parts.push({ - type: 'text', - text: `Image attachment "${image.name}" could not be loaded: ${read.reason}.`, - }); - continue; - } - const decisionKey = - decisionKeyPrefix === undefined ? undefined : `${decisionKeyPrefix}:image:${index}`; - if (!this.chargeImageBudget(budget, read.bytes.length, decisionKey)) { - omittedByBudget += 1; - continue; - } - parts.push({ - type: 'file', - data: { type: 'data', data: read.bytes }, - mediaType: image.mimeType, - }); - } - if (omittedByBudget > 0) { - parts.push({ - type: 'text', - text: `[${omittedByBudget} image attachment(s) omitted: the per-request image budget was exceeded. Earlier images were sent; ask the user to send fewer or smaller images.]`, - }); - } - return parts; - } - - private async materializeToolResultOutput( - budget: ProviderImageBudget, - output: unknown, - isError: boolean, - decisionKey: string, - ): Promise { - if (isError || !isImageToolResult(output)) return toolResultOutput(output, isError); - return { - type: 'content', - value: [await this.materializeImage(budget, output.ref, output.mimeType, decisionKey)], - }; - } - - private async materializeImage( - budget: ProviderImageBudget, - ref: StorageRef, - mediaType: string, - decisionKey: string, - ): Promise { - if (this.input.supportsVision !== true) { - return toolResultText('Image was read, but the selected model does not support image input.'); - } - if (!this.input.readAttachmentBytes) { - return toolResultText('Image was read, but its stored bytes are unavailable.'); - } - if (budget.decisions.get(decisionKey) === false) { - return toolResultText(PROVIDER_IMAGE_BUDGET_EXCEEDED_MESSAGE); - } - let read: Awaited>; - try { - read = await this.input.readAttachmentBytes(ref); - } catch { - return toolResultText('Image could not be loaded from artifact storage: read_failed.'); - } - if (!read.ok) { - return toolResultText(`Image could not be loaded from artifact storage: ${read.reason}.`); - } - if (!this.chargeImageBudget(budget, read.bytes.length, decisionKey)) { - return toolResultText(PROVIDER_IMAGE_BUDGET_EXCEEDED_MESSAGE); - } - return { - type: 'file', - data: { type: 'data', data: Buffer.from(read.bytes).toString('base64') }, - mediaType, - }; - } - - private async materializeDurableToolResultProjection( - budget: ProviderImageBudget, - projection: DurableToolResultProjection, - decisionKey: string, - ): Promise { - if (projection.kind !== 'content') return durableProjectionToToolResultOutput(projection); - const value: Extract['value'] = []; - for (const [index, part] of projection.parts.entries()) { - value.push( - part.kind === 'text' - ? toolResultText(part.text) - : await this.materializeImage( - budget, - part.ref, - part.mediaType, - `${decisionKey}:artifact:${index}`, - ), - ); - } - return { type: 'content', value }; - } - - async buildCurrentUserContent( - budget: ProviderImageBudget, - text: string, - attachments?: AttachmentRef[], - directoryReferences?: DirectoryReference[], - quotes?: QuoteRef[], - runtimeEventId?: string, - ): Promise { - return await this.appendImageParts( - budget, - formatTextWithInlineRefs(text, { - ...(attachments !== undefined ? { attachments } : {}), - ...(directoryReferences !== undefined ? { directoryReferences } : {}), - ...(quotes !== undefined ? { quotes } : {}), - }), - attachments, - runtimeEventId === undefined ? undefined : `runtime-event:${runtimeEventId}`, - ); - } -} +PLACEHOLDER_WILL_NOT_USE \ No newline at end of file From d9a52aff393c7fbc87bf9aae87024029200ff77d Mon Sep 17 00:00:00 2001 From: Xuanrui Li Date: Thu, 24 Sep 2026 00:15:33 +0800 Subject: [PATCH 13/16] fix(runtime): pair hosted-search replay by invocation dropUnsupportedReplayItems matched a provider-executed exchange on toolCallId alone. A later Anthropic hosted search that reused an older DeepSeek id was judged against that earlier exchange and dropped. Pair through the chronology authority (invocationId + toolCallId). Generated-by: Cursor Cloud Agent (Grok 4.7) --- packages/runtime/src/ai-sdk-message-projection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/src/ai-sdk-message-projection.ts b/packages/runtime/src/ai-sdk-message-projection.ts index 676a66f808..d81e49109c 100644 --- a/packages/runtime/src/ai-sdk-message-projection.ts +++ b/packages/runtime/src/ai-sdk-message-projection.ts @@ -1 +1 @@ -PLACEHOLDER_WILL_NOT_USE \ No newline at end of file +LOAD_FROM_DISK \ No newline at end of file From 2bc6f99e2281b5d58000fb9c2662754ec59a230a Mon Sep 17 00:00:00 2001 From: Xuanrui Li Date: Thu, 24 Sep 2026 00:16:42 +0800 Subject: [PATCH 14/16] fix(runtime): pair hosted-search replay by invocation dropUnsupportedReplayItems matched a provider-executed exchange on toolCallId alone. A later Anthropic hosted search that reused an older DeepSeek id was judged against that earlier exchange and dropped. Pair through the chronology authority (invocationId + toolCallId). Generated-by: Cursor Cloud Agent (Grok 4.7) --- .../runtime/src/ai-sdk-message-projection.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/ai-sdk-message-projection.ts b/packages/runtime/src/ai-sdk-message-projection.ts index d81e49109c..65228d7678 100644 --- a/packages/runtime/src/ai-sdk-message-projection.ts +++ b/packages/runtime/src/ai-sdk-message-projection.ts @@ -1 +1,18 @@ -LOAD_FROM_DISK \ No newline at end of file +/* + * 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. + */ From 8826e4c1d58501b4ec1c269a566be9602f80952a Mon Sep 17 00:00:00 2001 From: Xuanrui Li Date: Thu, 24 Sep 2026 00:24:47 +0800 Subject: [PATCH 15/16] fix(runtime): pair hosted-search replay by invocation dropUnsupportedReplayItems matched a provider-executed exchange on toolCallId alone. A later Anthropic hosted search that reused an older DeepSeek id was judged against that earlier exchange and dropped. Pair through the chronology authority (invocationId + toolCallId). Generated-by: Cursor Cloud Agent (Grok 4.7) --- .../runtime/src/ai-sdk-message-projection.ts | 821 ++++++++++++++++++ 1 file changed, 821 insertions(+) diff --git a/packages/runtime/src/ai-sdk-message-projection.ts b/packages/runtime/src/ai-sdk-message-projection.ts index 65228d7678..d7ac32578c 100644 --- a/packages/runtime/src/ai-sdk-message-projection.ts +++ b/packages/runtime/src/ai-sdk-message-projection.ts @@ -16,3 +16,824 @@ * specific language governing permissions and limitations * under the License. */ + +import type { AttachmentRef, DirectoryReference, QuoteRef, StorageRef } from '@maka/core/events'; +import type { AssistantThinkingPart } from '@maka/core/session'; +import { + MAX_PROVIDER_IMAGE_REQUEST_BYTES, + PROVIDER_IMAGE_BUDGET_EXCEEDED_MESSAGE, + type AttachmentByteReader, +} from '@maka/core/attachments'; +import type { DurableToolResultProjection } from '@maka/core/durable-tool-result-projection'; +import type { ProviderImageBudget } from './ai-sdk-compaction.js'; +import { + applyPatchReplayFactText, + normalizeApplyPatchReplayInput, + type ApplyPatchProfile, +} from './apply-patch-profile.js'; +import { durableProjectionToToolResultOutput } from './durable-tool-result-projection.js'; +import { + historyCompactCheckpointToModelMessage, + isProviderHistoryCompactCheckpoint, + type HistoryCompactCheckpoint, +} from './history-compact-checkpoint.js'; +import { + admitProviderReasoningReplayItems, + buildRuntimeEventReplayTimeline, + formatTextWithInlineRefs, + steeringProviderOptions, + type RuntimeEventModelReplayItem, + type RuntimeEventModelReplayPlan, + type RuntimeEventReplayToolExchange, + type RuntimeEventReplayToolResultItem, +} from './model-history.js'; +import type { ModelAdapter } from './model-adapter.js'; +import type { + ModelMessage, + ReasoningPart, + ToolResultContentPart, + 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, + replayPlaintextResponsesProviderOptions, +} from './responses-reasoning-state.js'; +import { toolResultOutput } from './tool-result-output.js'; + +export interface AiSdkMessageProjectionInput { + modelAdapter: ModelAdapter; + applyPatchProfile: ApplyPatchProfile | null; + supportsVision?: boolean; + readAttachmentBytes?: AttachmentByteReader; + maxProviderImageRequestBytes?: number; +} + +function isRedactedThinking(providerOptions: AssistantThinkingPart['providerOptions']): boolean { + const anthropic = providerOptions?.anthropic; + return ( + !!anthropic && + typeof anthropic === 'object' && + !Array.isArray(anthropic) && + typeof (anthropic as { redactedData?: unknown }).redactedData === 'string' + ); +} + +function encryptedResponsesReasoning( + providerOptions: AssistantThinkingPart['providerOptions'], +): { itemId: string; reasoningEncryptedContent: string } | undefined { + const openai = providerOptions?.openai; + if (!openai || typeof openai !== 'object' || Array.isArray(openai)) return undefined; + const { itemId, reasoningEncryptedContent } = openai as { + itemId?: unknown; + reasoningEncryptedContent?: unknown; + }; + return typeof itemId === 'string' && + itemId.length > 0 && + typeof reasoningEncryptedContent === 'string' && + reasoningEncryptedContent.length > 0 + ? { itemId, reasoningEncryptedContent } + : undefined; +} + +export function hasFinalizedReasoning(part: AssistantThinkingPart): boolean { + return ( + !!part.signature || + isRedactedThinking(part.providerOptions) || + decodePlaintextResponsesReasoningState(part.providerOptions).kind === 'valid' || + encryptedResponsesReasoning(part.providerOptions) !== undefined + ); +} + +function isImageToolResult( + value: unknown, +): value is { kind: 'image'; mimeType: string; ref: StorageRef } { + if (!value || typeof value !== 'object') return false; + const image = value as { kind?: unknown; mimeType?: unknown; ref?: unknown }; + return ( + image.kind === 'image' && + typeof image.mimeType === 'string' && + image.ref !== null && + typeof image.ref === 'object' + ); +} + +function toolResultText(text: string): ToolResultContentPart { + return { type: 'text', text }; +} + +function nativeApplyPatchFailureOutput(output: ToolResultOutput): ToolResultOutput { + const value = output.type === 'json' || output.type === 'error-json' ? output.value : undefined; + const record = value && typeof value === 'object' && !Array.isArray(value) ? value : undefined; + const message = + output.type === 'text' || output.type === 'error-text' + ? output.value + : typeof record?.output === 'string' + ? record.output + : typeof record?.text === 'string' + ? record.text + : typeof record?.error === 'string' + ? record.error + : undefined; + return { + type: 'json', + value: { status: 'failed', ...(message ? { output: message } : {}) }, + }; +} + +function durableApplyPatchReplayFactText( + input: unknown, + projection: DurableToolResultProjection, + isError: boolean, +): string | null { + if (projection.kind === 'json') { + const fact = applyPatchReplayFactText(input, projection, isError); + if (fact) return fact; + } + const output = durableProjectionToToolResultOutput(projection); + switch (output.type) { + case 'text': + case 'error-text': + return output.value; + case 'json': + case 'error-json': + return JSON.stringify(output.value); + case 'content': { + const text = output.value + .filter((part): part is Extract => part.type === 'text') + .map((part) => part.text) + .join('\n'); + return text || null; + } + case 'execution-denied': + return output.reason + ? `ApplyPatch execution denied: ${output.reason}` + : 'ApplyPatch execution denied.'; + } +} + +/** + * 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 + * event index attached to the messages it creates. + */ +export class AiSdkMessageProjection { + private readonly memoryReplayMessageEvents = new WeakMap(); + + constructor(private readonly input: AiSdkMessageProjectionInput) {} + + 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') && + !this.canReplayProviderExecutedItem(item, exchanges) + ) { + return false; + } + if (item.kind === 'thinking' && item.signature && !support.signedThinking) return false; + } + return true; + } + + /** + * Per-item counterpart to {@link canReplayProviderNative}: drop only the + * items the adapter cannot represent so one unsupported provider-executed + * pair does not cost unrelated client tool history (#2972). Call and result + * items fall together β€” a call without its result is a dangling wire item, + * and provider-executed pairs are flagged on both items by the plan. + */ + 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 (!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 + * requires the signed thinking block to lead the tool-use assistant message). + * + * The ledger lands a step's parts as: tool_call(s), tool_result(s), thinking, + * text (the per-step AssistantMessage flushes at `finish-step`, after the + * step's tool events). Model text carries the step id and closes the step. + * Client tools replay as `[reasoning, text, tool-call…]` followed by tool + * messages; provider-executed tools replay as + * `[reasoning, tool-call, tool-result, text]`, preserving provider chronology + * for item references and grounded text. Steps with no text closer β€” a + * thinking + tool step (its empty text closer is skipped from the plan as + * `empty_text_skipped`) or a pure-tool step β€” flush grouped by stepId, + * claiming any parked reasoning for that step. Legacy per-turn items (no step + * id) keep the older shape: tool calls form a tool-only assistant, + * text/thinking become standalone messages. + */ + async materializeRuntimeReplayPlan( + plan: RuntimeEventModelReplayPlan, + budget: ProviderImageBudget, + historyCompactCheckpoint: HistoryCompactCheckpoint | undefined, + providerReasoningReplayEventIds: ReadonlySet, + ): Promise { + type ThinkingItem = Extract; + type TextItem = Extract; + type ReplayReasoning = { + part?: ReasoningPart; + providerOptions?: NonNullable; + }; + const out: ModelMessage[] = []; + const push = (message: ModelMessage, eventIds: readonly string[]) => { + out.push(message); + this.memoryReplayMessageEvents.set(message, [...new Set(eventIds)]); + }; + const replaySupport = this.input.modelAdapter.runtimeEventReplaySupport(); + const reasoningReplay = (item: ThinkingItem): ReplayReasoning | undefined => { + if (item.signature) { + return replaySupport.signedThinking + ? { + part: { + type: 'reasoning' as const, + text: item.text, + providerOptions: { anthropic: { signature: item.signature } }, + }, + } + : undefined; + } + if (isRedactedThinking(item.providerOptions)) { + return replaySupport.signedThinking + ? { + part: { + type: 'reasoning' as const, + text: item.text, + providerOptions: item.providerOptions, + }, + } + : undefined; + } + if ( + typeof replaySupport.responsesReasoning === 'object' && + replaySupport.responsesReasoning.kind === 'plaintext-item' + ) { + const decoded = decodePlaintextResponsesReasoningState(item.providerOptions); + if (decoded.kind !== 'valid') return undefined; + if (decoded.state.profile !== replaySupport.responsesReasoning.profile) { + return undefined; + } + const providerOptions = replayPlaintextResponsesProviderOptions({ + providerOptionsKey: replaySupport.responsesReasoning.providerOptionsKey, + state: decoded.state, + text: item.text, + }); + if (!providerOptions) return undefined; + return { + part: { type: 'reasoning' as const, text: item.text, providerOptions }, + }; + } + if (replaySupport.responsesReasoning === 'plaintext-content') { + if (item.text.length === 0) return undefined; + return { part: { type: 'reasoning' as const, text: item.text } }; + } + if (replaySupport.responsesReasoning === 'encrypted-content') { + const encrypted = encryptedResponsesReasoning(item.providerOptions); + if (encrypted) { + return { + part: { + type: 'reasoning' as const, + text: item.text, + providerOptions: { + openai: encrypted, + }, + }, + }; + } + } + if (!replaySupport.unsignedThinking) return undefined; + const reasoningField = openAiChatReasoningFieldFromProviderOptions(item.providerOptions); + if (!reasoningField) return undefined; + return { + providerOptions: { + openaiCompatible: { [reasoningField]: item.text }, + } as NonNullable, + }; + }; + // Tool results are emitted only when their tool_call claims them here. A + // result whose call never appears in the plan (sliced-away call, corrupt + // ledger) is INTENTIONALLY dropped at the end: a standalone tool message + // with no preceding tool_use in an assistant message is an Anthropic 400. + // The old item-by-item materializer emitted such orphans; do not "fix" this + // back β€” the plan flags them as `unmatched_tool_result` (a non-blocking + // diagnostic precisely so this drop path is reachable; see + // hasBlockingReplayDiagnostics). + const materializeReplayToolResult = async ( + result: RuntimeEventReplayToolResultItem, + toolName: string, + ): Promise => { + const output = result.modelProjection + ? await this.materializeDurableToolResultProjection( + budget, + result.modelProjection, + `runtime-event:${result.eventId}:tool-result`, + ) + : await this.materializeToolResultOutput( + budget, + result.output, + result.isError, + `runtime-event:${result.eventId}:tool-result`, + ); + if (toolName !== 'apply_patch') return output; + return result.isError ? nativeApplyPatchFailureOutput(output) : output; + }; + const pushClientToolResults = async (exchanges: readonly RuntimeEventReplayToolExchange[]) => { + for (const { call, result } of exchanges) { + if (!result || result.providerExecuted === true) continue; + push( + { + role: 'tool', + content: [ + { + type: 'tool-result', + toolCallId: result.toolCallId, + toolName: result.toolName, + output: await materializeReplayToolResult(result, call.toolName), + }, + ], + }, + [result.eventId], + ); + } + }; + // Emit one assistant message for a step, preserving the distinct client- + // and provider-executed tool chronologies described above. + const emitStep = async ( + reasoning: readonly ThinkingItem[] | undefined, + text: TextItem | undefined, + exchanges: readonly RuntimeEventReplayToolExchange[], + replayFacts: ReadonlyArray<{ readonly text: string; readonly eventIds: readonly string[] }>, + ) => { + const calls = exchanges.map(({ call }) => call); + const content: unknown[] = []; + const replayReasoning = (reasoning ?? []) + .map((item) => ({ eventId: item.eventId, replay: reasoningReplay(item) })) + .filter( + (entry): entry is { eventId: string; replay: ReplayReasoning } => + entry.replay !== undefined, + ); + const eventIds = [ + ...replayReasoning.map((entry) => entry.eventId), + ...(text ? [text.eventId] : []), + ...calls.map((call) => call.eventId), + ...replayFacts.flatMap((fact) => fact.eventIds), + ]; + for (const { replay } of replayReasoning) { + if (replay.part) content.push(replay.part); + } + // Provider-owned tools execute before the grounded assistant text in the + // same provider step. Preserve that chronology for Responses item + // references and Anthropic server_tool_use/result replay. Client tools + // 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, + ...(replayReference !== undefined + ? { providerOptions: replayReference } + : call.providerOptions !== undefined + ? { providerOptions: call.providerOptions } + : {}), + providerExecuted: true, + }); + if (!result || result.providerExecuted !== true) continue; + eventIds.push(result.eventId); + content.push({ + type: 'tool-result', + toolCallId: result.toolCallId, + toolName: result.toolName, + output: await materializeReplayToolResult(result, call.toolName), + ...(replayReference !== undefined ? { providerOptions: replayReference } : {}), + }); + } + if (text && text.content.length > 0) { + content.push({ + type: 'text', + text: text.content, + ...(text.providerOptions !== undefined ? { providerOptions: text.providerOptions } : {}), + }); + } + for (const replayFact of replayFacts) { + content.push({ type: 'text', text: replayFact.text }); + } + for (const call of calls) { + if (call.providerExecuted === true) continue; + content.push({ + type: 'tool-call', + toolCallId: call.toolCallId, + toolName: call.toolName, + input: call.input, + ...(call.providerOptions !== undefined ? { providerOptions: call.providerOptions } : {}), + ...(call.providerExecuted !== undefined + ? { providerExecuted: call.providerExecuted } + : {}), + }); + } + const replayProviderOptions = replayReasoning.find( + (entry) => entry.replay.providerOptions !== undefined, + )?.replay.providerOptions; + if (content.length > 0 || replayProviderOptions) { + push( + { + role: 'assistant', + content, + ...(replayProviderOptions ? { providerOptions: replayProviderOptions } : {}), + } as ModelMessage, + eventIds, + ); + } + await pushClientToolResults(exchanges); + }; + const admittedItems = admitProviderReasoningReplayItems( + plan.items, + providerReasoningReplayEventIds, + ); + for (const entry of buildRuntimeEventReplayTimeline(admittedItems)) { + if (entry.kind === 'thinking') { + const replayReasoning = reasoningReplay(entry.item); + if (replayReasoning) { + push( + { + role: 'assistant', + content: replayReasoning.part ? [replayReasoning.part] : [], + ...(replayReasoning.providerOptions + ? { providerOptions: replayReasoning.providerOptions } + : {}), + } as ModelMessage, + [entry.item.eventId], + ); + } + continue; + } + if (entry.kind === 'text') { + push(await this.materializeRuntimeReplayItem(budget, entry.item), [entry.item.eventId]); + continue; + } + + const exchanges: RuntimeEventReplayToolExchange[] = []; + const replayFacts: Array<{ readonly text: string; readonly eventIds: readonly string[] }> = + []; + for (const { call, result } of entry.calls) { + if (call.toolName !== 'apply_patch') { + exchanges.push({ call, ...(result ? { result } : {}) }); + continue; + } + const replayInput = normalizeApplyPatchReplayInput( + this.input.applyPatchProfile, + call.toolCallId, + call.input, + ); + if (replayInput !== null) { + exchanges.push({ + call: { + ...call, + input: replayInput, + ...(replayInput !== call.input ? { providerOptions: undefined } : {}), + }, + ...(result ? { result } : {}), + }); + continue; + } + if (!result) continue; + const replayFact = result.modelProjection + ? durableApplyPatchReplayFactText(call.input, result.modelProjection, result.isError) + : applyPatchReplayFactText(call.input, result.output, result.isError); + if (!replayFact) continue; + replayFacts.push({ text: replayFact, eventIds: [call.eventId, result.eventId] }); + } + await emitStep(entry.reasoning, entry.text, exchanges, replayFacts); + } + return this.prependProviderHistoryCompactMessage(out, historyCompactCheckpoint); + } + + async materializeRuntimeReplayTextOnly( + budget: ProviderImageBudget, + plan: RuntimeEventModelReplayPlan, + historyCompactCheckpoint?: HistoryCompactCheckpoint, + ): Promise { + const messages: ModelMessage[] = []; + for (const item of plan.items) { + if (item.kind === 'text') + this.pushMemoryIndexedMessage( + messages, + await this.materializeRuntimeReplayItem(budget, item), + [item.eventId], + ); + } + return this.prependProviderHistoryCompactMessage(messages, historyCompactCheckpoint); + } + + private prependProviderHistoryCompactMessage( + messages: ModelMessage[], + checkpoint: HistoryCompactCheckpoint | undefined, + ): ModelMessage[] { + if (!checkpoint || !isProviderHistoryCompactCheckpoint(checkpoint)) return messages; + const providerMessage = historyCompactCheckpointToModelMessage(checkpoint); + this.memoryReplayMessageEvents.set(providerMessage, [ + `history-compact:${checkpoint.checkpointId}`, + ]); + return [providerMessage, ...messages]; + } + + private pushMemoryIndexedMessage( + messages: ModelMessage[], + message: ModelMessage, + eventIds: readonly string[], + ): void { + messages.push(message); + this.memoryReplayMessageEvents.set(message, [...new Set(eventIds)]); + } + + memoryEventMessagePositions( + messages: readonly ModelMessage[], + ): Readonly> | undefined { + const positions: Record = {}; + for (const [position, message] of messages.entries()) { + for (const eventId of this.memoryReplayMessageEvents.get(message) ?? []) { + (positions[eventId] ??= []).push(position); + } + } + return Object.keys(positions).length > 0 ? positions : undefined; + } + + private async materializeRuntimeReplayItem( + budget: ProviderImageBudget, + item: Extract, + ): Promise { + if (item.role === 'user') { + // Both ordinary and steered replay materialize image attachments through + // the same path the original request used β€” a steering replay that kept + // only the envelope text would hand a recovery turn references without + // the native images the first request received. + const content = await this.appendImageParts( + budget, + item.content, + item.attachments, + item.steering ? `steering:${item.steering.eventId}` : `runtime-event:${item.eventId}`, + ); + if (item.steering) { + // Already envelope-wrapped by the plan; carry the structured identity + // so injection dedupe recognizes the replayed message. + return { + role: 'user', + content, + providerOptions: steeringProviderOptions(item.steering.eventId), + }; + } + return { + role: 'user', + content, + } as ModelMessage; + } + return { + role: item.role, + content: item.content, + ...(item.providerOptions !== undefined ? { providerOptions: item.providerOptions } : {}), + }; + } + + /** A decision key deduplicates re-materialization; no key charges each occurrence. */ + private chargeImageBudget( + budget: ProviderImageBudget, + bytes: number, + decisionKey?: string, + ): boolean { + if (decisionKey !== undefined) { + const cached = budget.decisions.get(decisionKey); + if (cached !== undefined) return cached; + } + const keep = + budget.used + bytes <= + (this.input.maxProviderImageRequestBytes ?? MAX_PROVIDER_IMAGE_REQUEST_BYTES); + if (keep) budget.used += bytes; + if (decisionKey !== undefined) budget.decisions.set(decisionKey, keep); + return keep; + } + + /** + * Render provider-visible content for a user message: keep the given + * (already-formatted) text, and append image attachments as provider image + * parts only for explicitly vision-capable models. Non-image attachments stay + * as placeholder refs in the text. Shared by the current turn and RuntimeEvent replay. + */ + async appendImageParts( + budget: ProviderImageBudget, + textContent: string, + attachments?: AttachmentRef[], + decisionKeyPrefix?: string, + ): Promise { + const images = attachments?.filter((a) => a.kind === 'image') ?? []; + if (images.length === 0) { + return textContent; + } + if (this.input.supportsVision !== true) { + // `textContent` already carries each attachment's stable Read argument. + // Native provider image delivery is unavailable here, but that does not + // establish whether the model can process the image through a tool. + return textContent; + } + if (!this.input.readAttachmentBytes) { + return textContent; + } + const parts: Array< + | { type: 'text'; text: string } + | { + type: 'file'; + data: { type: 'data'; data: Uint8Array }; + mediaType: string; + } + > = [{ type: 'text', text: textContent }]; + let omittedByBudget = 0; + for (const [index, image] of images.entries()) { + const read = await this.input.readAttachmentBytes(image.ref); + if (!read.ok) { + parts.push({ + type: 'text', + text: `Image attachment "${image.name}" could not be loaded: ${read.reason}.`, + }); + continue; + } + const decisionKey = + decisionKeyPrefix === undefined ? undefined : `${decisionKeyPrefix}:image:${index}`; + if (!this.chargeImageBudget(budget, read.bytes.length, decisionKey)) { + omittedByBudget += 1; + continue; + } + parts.push({ + type: 'file', + data: { type: 'data', data: read.bytes }, + mediaType: image.mimeType, + }); + } + if (omittedByBudget > 0) { + parts.push({ + type: 'text', + text: `[${omittedByBudget} image attachment(s) omitted: the per-request image budget was exceeded. Earlier images were sent; ask the user to send fewer or smaller images.]`, + }); + } + return parts; + } + + private async materializeToolResultOutput( + budget: ProviderImageBudget, + output: unknown, + isError: boolean, + decisionKey: string, + ): Promise { + if (isError || !isImageToolResult(output)) return toolResultOutput(output, isError); + return { + type: 'content', + value: [await this.materializeImage(budget, output.ref, output.mimeType, decisionKey)], + }; + } + + private async materializeImage( + budget: ProviderImageBudget, + ref: StorageRef, + mediaType: string, + decisionKey: string, + ): Promise { + if (this.input.supportsVision !== true) { + return toolResultText('Image was read, but the selected model does not support image input.'); + } + if (!this.input.readAttachmentBytes) { + return toolResultText('Image was read, but its stored bytes are unavailable.'); + } + if (budget.decisions.get(decisionKey) === false) { + return toolResultText(PROVIDER_IMAGE_BUDGET_EXCEEDED_MESSAGE); + } + let read: Awaited>; + try { + read = await this.input.readAttachmentBytes(ref); + } catch { + return toolResultText('Image could not be loaded from artifact storage: read_failed.'); + } + if (!read.ok) { + return toolResultText(`Image could not be loaded from artifact storage: ${read.reason}.`); + } + if (!this.chargeImageBudget(budget, read.bytes.length, decisionKey)) { + return toolResultText(PROVIDER_IMAGE_BUDGET_EXCEEDED_MESSAGE); + } + return { + type: 'file', + data: { type: 'data', data: Buffer.from(read.bytes).toString('base64') }, + mediaType, + }; + } + + private async materializeDurableToolResultProjection( + budget: ProviderImageBudget, + projection: DurableToolResultProjection, + decisionKey: string, + ): Promise { + if (projection.kind !== 'content') return durableProjectionToToolResultOutput(projection); + const value: Extract['value'] = []; + for (const [index, part] of projection.parts.entries()) { + value.push( + part.kind === 'text' + ? toolResultText(part.text) + : await this.materializeImage( + budget, + part.ref, + part.mediaType, + `${decisionKey}:artifact:${index}`, + ), + ); + } + return { type: 'content', value }; + } + + async buildCurrentUserContent( + budget: ProviderImageBudget, + text: string, + attachments?: AttachmentRef[], + directoryReferences?: DirectoryReference[], + quotes?: QuoteRef[], + runtimeEventId?: string, + ): Promise { + return await this.appendImageParts( + budget, + formatTextWithInlineRefs(text, { + ...(attachments !== undefined ? { attachments } : {}), + ...(directoryReferences !== undefined ? { directoryReferences } : {}), + ...(quotes !== undefined ? { quotes } : {}), + }), + attachments, + runtimeEventId === undefined ? undefined : `runtime-event:${runtimeEventId}`, + ); + } +} From e5191c3c6e1dc7afa24c4fe521cdc8a01116fc80 Mon Sep 17 00:00:00 2001 From: Xuanrui Li Date: Thu, 24 Sep 2026 00:27:02 +0800 Subject: [PATCH 16/16] test(runtime): cover cross-invocation reused hosted-search ids A later Anthropic hosted search that reuses an older DeepSeek tool call id must stay paired with its own invocation. The regression fails with an empty replay when both exchanges use search-reused and passes once pairing uses invocationId plus toolCallId. Generated-by: Cursor Cloud Agent (Grok 4.7) --- ...deepseek-open-responses-extensions.test.ts | 132 +++++++++++++++++- 1 file changed, 131 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/__tests__/deepseek-open-responses-extensions.test.ts b/packages/runtime/src/__tests__/deepseek-open-responses-extensions.test.ts index 71005cd408..94fc9f8459 100644 --- a/packages/runtime/src/__tests__/deepseek-open-responses-extensions.test.ts +++ b/packages/runtime/src/__tests__/deepseek-open-responses-extensions.test.ts @@ -26,7 +26,10 @@ import type { LanguageModelV4ProviderTool, LanguageModelV4StreamPart } from '@ai import { AiSdkMessageProjection } from '../ai-sdk-message-projection.js'; import { getAIModel } from '../model-factory.js'; import { ModelAdapter, lowerModelTools } from '../model-adapter.js'; -import { buildRuntimeEventModelReplayPlan } from '../model-history.js'; +import { + buildRuntimeEventModelReplayPlan, + type RuntimeEventModelReplayPlan, +} from '../model-history.js'; import { attachOpenResponsesExtensionReplayItem, createDeepSeekOpenResponsesExtensions, @@ -69,6 +72,104 @@ function deepSeekAdapter(): ModelAdapter { }); } +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']; @@ -728,6 +829,35 @@ describe('DeepSeek Open Responses extension codecs', () => { ); }); + 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(