diff --git a/LICENSE b/LICENSE index 1434dfe53f..bc584524a7 100644 --- a/LICENSE +++ b/LICENSE @@ -208,10 +208,13 @@ Vercel AI SDK Source: https://www.npmjs.com/package/@ai-sdk/provider-utils/v/5.0.11 https://www.npmjs.com/package/@ai-sdk/provider/v/4.0.3 Patched package: https://www.npmjs.com/package/@ai-sdk/provider-utils/v/5.0.28 + https://www.npmjs.com/package/@ai-sdk/open-responses/v/2.0.34 Repository: https://github.com/vercel/ai/tree/main/packages/provider-utils https://github.com/vercel/ai/tree/main/packages/provider + https://github.com/vercel/ai/tree/main/packages/open-responses Adapted declarations: @ai-sdk/provider-utils 5.0.11, @ai-sdk/provider 4.0.3 Dependency patch: patches/@ai-sdk+provider-utils+5.0.28.patch + patches/@ai-sdk+open-responses+2.0.34.patch Copyright 2023 Vercel, Inc. License: Apache-2.0 @@ -223,8 +226,9 @@ License, Version 2.0 above applies to the adapted material. The pinned versions are the ones present when the adaptation was made. The adapted declarations are unchanged through @ai-sdk/provider-utils 5.0.25 and @ai-sdk/provider 4.0.7, the versions recorded by the code origin audit. Maka -also redistributes a patch against @ai-sdk/provider-utils 5.0.28; that patch -modifies Apache-2.0 source and is covered by the license reproduced above. +also redistributes patches against @ai-sdk/provider-utils 5.0.28 and +@ai-sdk/open-responses 2.0.34; those patches modify Apache-2.0 source and are +covered by the license reproduced above. Astryx diff --git a/docs/web-search-provider-capability.md b/docs/web-search-provider-capability.md index 7d8c68cf5a..88aaf91c6a 100644 --- a/docs/web-search-provider-capability.md +++ b/docs/web-search-provider-capability.md @@ -19,10 +19,11 @@ # Provider-hosted web search capability -Status: implemented for OpenAI Responses and Anthropic Messages -`web_search_20250305`; other provider-native wires remain explicit follow-ups. +Status: implemented for OpenAI Responses, Alibaba Token Plan Open Responses, +and Anthropic Messages `web_search_20250305`; other provider-native wires +remain explicit follow-ups. Verified against public provider documentation and shipped client/SDK behavior -on 2026-08-04. +through 2026-08-24. ## Problem @@ -59,8 +60,15 @@ set `capabilities.webSearch`; otherwise narrow provider/model rules apply. The implemented native adapters are: - `openai-responses` for Codex-style `web_search`; +- `openai-responses` over the Alibaba Token Plan Open Responses profile for + `qwen3.8-max` Harness `web_search`; - `anthropic-messages` for Claude Code-compatible `web_search_20250305`. +Alibaba Token Plan stays deliberately narrower than the provider's published +model matrix: the current Runtime routes only `qwen3.8-max` through its verified +Responses profile. Qwen 3.7 models remain on Chat in Maka and therefore do not +receive a provider-native search tool yet. + ## Execution surfaces All production `AiSdkBackend` composition roots use the same @@ -105,8 +113,11 @@ turn-start tool surface The search request uses the existing model credential. It does not duplicate the secret into web-search settings, start a nested model request, or expose the secret to the renderer. Provider search call/results are marked -`providerExecuted`, preserved separately from local ToolRuntime execution, and -replayed through the native provider-tool shape. +`providerExecuted` and preserved separately from local ToolRuntime execution. +Adapters that can round-trip the pair replay its native provider-tool shape. +Alibaba Open Responses currently keeps the durable episode and grounded text but +omits the pair from the next provider request, because that adapter cannot yet +round-trip provider-executed results without producing a dangling output. The durable event keeps normalized `result` data for the canonical read model, UI, and exports, plus opaque `providerOutput` data for provider-protocol replay. @@ -187,7 +198,8 @@ search-heavy workflows that value source visibility over cache economics. | OpenAI API | Responses `web_search` tool | Maka currently enables the native path for GPT-5 families, whose runtime wire is already Responses | Integrated through `openai-responses` | | Custom Responses relay | Responses `web_search` tool when explicitly declared by model metadata | `openai-responses-compatible` connections with `apiProtocol=openai-responses` and `capabilities.webSearch=true` | Integrated through `openai-responses` | | xAI API / OAuth | Responses Agent Tools `web_search` | Maka currently enables the verified Grok 4.5 Responses route | Integrated through `openai-responses` | -| Alibaba Model Studio | Responses `web_search` | Qwen 3.5 Plus/Flash provider support is recorded | Provider supports it; Maka Responses adapter pending | +| Alibaba Token Plan | Responses Harness `web_search` | `qwen3.8-max` on the Token Plan China/Singapore access paths | Integrated through the Alibaba Open Responses profile; live smoke pending | +| Alibaba Model Studio pay-as-you-go | Responses `web_search` | Provider model and deployment support varies | Provider supports it; Maka pay-as-you-go Responses adapter pending | | Anthropic / Claude subscription | Messages `web_search_20250305` | Current Claude Opus/Sonnet/Haiku/Fable families | Integrated through `anthropic-messages` | | MiniMax API / Coding Plan | Anthropic-compatible `web_search_20250305` | MiniMax M2.7/M3 families | Integrated through `anthropic-messages`; live provider verification pending | | Google Gemini | Gemini API grounding with Google Search | Supported Gemini 2.0+ model families vary by release | Provider supports it; Maka adapter pending | diff --git a/packages/core/src/__tests__/model-web-search.test.ts b/packages/core/src/__tests__/model-web-search.test.ts index 47b13b2953..a4235ef922 100644 --- a/packages/core/src/__tests__/model-web-search.test.ts +++ b/packages/core/src/__tests__/model-web-search.test.ts @@ -49,6 +49,19 @@ describe('hosted web search capability', () => { adapter: 'openai-responses', implemented: false, }); + for (const providerType of ['alibaba-token-plan-cn', 'alibaba-token-plan'] as const) { + assert.deepEqual(resolveHostedWebSearchCapability(providerType, undefined, 'qwen3.8-max'), { + adapter: 'openai-responses', + implemented: true, + }); + assert.equal(resolveHostedWebSearchCapability(providerType, undefined, 'qwen3.7-max'), null); + assert.equal(resolveHostedWebSearchCapability(providerType, undefined, 'qwen3.7-plus'), null); + assert.equal( + resolveHostedWebSearchCapability(providerType, undefined, 'qwen3.8-max-preview'), + null, + ); + assert.equal(resolveHostedWebSearchCapability(providerType, undefined, 'qwen3.6-plus'), null); + } assert.equal(resolveHostedWebSearchCapability('openai', undefined, 'gpt-4.1'), null); }); @@ -155,6 +168,14 @@ describe('hosted web search capability', () => { ), null, ); + assert.equal( + resolveHostedWebSearchCapability( + 'alibaba-token-plan-cn', + [{ id: 'qwen3.8-max', apiProtocol: 'openai-chat' }], + 'qwen3.8-max', + ), + null, + ); }); it('publishes native search for both first-party DeepSeek V4 models', () => { diff --git a/packages/core/src/model-web-search.ts b/packages/core/src/model-web-search.ts index 5372a8aa79..d97d60e5e5 100644 --- a/packages/core/src/model-web-search.ts +++ b/packages/core/src/model-web-search.ts @@ -89,6 +89,9 @@ function providerHostedWebSearchAdapter( case 'alibaba': case 'alibaba-cn': return { adapter: 'openai-responses', implemented: false }; + case 'alibaba-token-plan-cn': + case 'alibaba-token-plan': + return { adapter: 'openai-responses', implemented: true }; case 'anthropic': case 'MiniMax': case 'MiniMax-cn': @@ -127,6 +130,11 @@ function providerDefaultHostedWebSearchCapability( case 'alibaba': case 'alibaba-cn': return /^qwen3\.5-(?:plus|flash)(?:[.-]|$)/i.test(modelId) ? capability : null; + case 'alibaba-token-plan-cn': + case 'alibaba-token-plan': + // Keep the hosted tool on the exact model that the Token Plan runtime + // routes through Responses. Aliases and Qwen 3.7 still use Chat here. + return modelId === 'qwen3.8-max' ? capability : null; case 'anthropic': return /^claude-(?:[\d.]+-)*(?:opus|sonnet|haiku|fable)\b/i.test(modelId) ? capability : null; case 'MiniMax': diff --git a/packages/runtime/src/__tests__/native-web-search-tool.test.ts b/packages/runtime/src/__tests__/native-web-search-tool.test.ts index 9b111a77c1..ac54914602 100644 --- a/packages/runtime/src/__tests__/native-web-search-tool.test.ts +++ b/packages/runtime/src/__tests__/native-web-search-tool.test.ts @@ -145,6 +145,28 @@ test('turn-start routing compiles Claude models to the CC-compatible Anthropic t }); }); +test('turn-start routing compiles Qwen3.8 Max Token Plan to Responses web search', () => { + const routed = routeWebSearchTools({ + tools: [], + settings: { enabled: true, defaultProvider: 'model' }, + connection: { + slug: 'alibaba-token-plan-cn', + providerType: 'alibaba-token-plan-cn', + defaultModel: 'qwen3.8-max', + }, + model: 'qwen3.8-max', + tavilyReady: false, + allowAddNative: true, + }); + + assert.equal(routed.length, 1); + assert.equal(routed[0]?.name, NATIVE_WEB_SEARCH_TOOL_NAME); + assert.deepEqual(routed[0]?.providerTool, { + kind: 'openai-web-search', + searchContextSize: 'medium', + }); +}); + test('root surfaces do not advertise unsupported DeepSeek native search', () => { const connection = { slug: 'deepseek', diff --git a/packages/runtime/src/__tests__/responses-wire-contract.test.ts b/packages/runtime/src/__tests__/responses-wire-contract.test.ts index fbabfb3b7a..784d4548bb 100644 --- a/packages/runtime/src/__tests__/responses-wire-contract.test.ts +++ b/packages/runtime/src/__tests__/responses-wire-contract.test.ts @@ -19,6 +19,7 @@ import assert from 'node:assert/strict'; import { describe, test } from 'node:test'; +import { streamText, type ToolSet } from 'ai'; import type { LlmConnection } from '@maka/core/llm-connections'; import type { RuntimeEvent } from '@maka/core/runtime-event'; import { z } from 'zod'; @@ -542,6 +543,204 @@ describe('responses wire request body', () => { assert.equal(headers?.get('x-token-plan-routing'), 'custom'); }); + test('Alibaba Token Plan lowers and decodes provider-executed web search', async () => { + let body: Record | undefined; + const webSearchCall = { + type: 'web_search_call', + id: 'web-search-1', + status: 'completed', + action: { + type: 'search', + query: 'latest Maka', + sources: [{ type: 'url', url: 'https://maka.example/releases' }], + }, + }; + const message = { + type: 'message', + id: 'message-1', + status: 'completed', + role: 'assistant', + content: [{ type: 'output_text', text: 'Maka shipped.', annotations: [] }], + }; + const completed = { + id: 'response-web-search', + object: 'response', + created_at: 1, + model: 'qwen3.8-max', + status: 'completed', + output: [webSearchCall, message], + usage: { + input_tokens: 10, + output_tokens: 4, + total_tokens: 14, + x_tools: { web_search: { count: 1 } }, + }, + }; + const events = [ + { type: 'response.created', response: { id: completed.id } }, + { + type: 'response.output_item.added', + output_index: 0, + item: { ...webSearchCall, status: 'in_progress', action: undefined }, + }, + { type: 'response.output_item.done', output_index: 0, item: webSearchCall }, + { type: 'response.output_item.added', output_index: 1, item: message }, + { + type: 'response.output_text.delta', + item_id: message.id, + output_index: 1, + content_index: 0, + delta: 'Maka shipped.', + }, + { type: 'response.output_item.done', output_index: 1, item: message }, + { type: 'response.completed', response: completed }, + ]; + const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + body = JSON.parse(String(init?.body)); + 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 connection = { + ...conn('alibaba-token-plan-cn'), + baseUrl: 'https://token-plan.example/compatible-mode/v1', + }; + const model = getAIModel({ + connection, + apiKey: 'token-plan-key', + modelId: 'qwen3.8-max', + fetch, + }); + const tools = lowerModelTools({ + WebSearch: { + kind: 'provider', + providerTool: { kind: 'openai-web-search', searchContextSize: 'medium' }, + }, + }); + const result = streamText({ + model, + messages: [{ role: 'user', content: 'What shipped?' }], + tools: tools as ToolSet, + maxRetries: 0, + }); + const parts = []; + for await (const part of result.fullStream) parts.push(part); + + assert.deepEqual(body?.tools, [{ type: 'web_search' }]); + assert.equal(body?.store, false); + const call = parts.find((part) => part.type === 'tool-call'); + assert.ok(call && call.type === 'tool-call'); + assert.equal(call.toolCallId, 'web-search-1'); + assert.equal(call.toolName, 'WebSearch'); + assert.equal(call.providerExecuted, true); + const toolResult = parts.find((part) => part.type === 'tool-result'); + assert.ok(toolResult && toolResult.type === 'tool-result'); + assert.equal(toolResult.providerExecuted, true); + assert.deepEqual(toolResult.output, { + action: { type: 'search', query: 'latest Maka' }, + sources: [{ type: 'url', url: 'https://maka.example/releases' }], + }); + assert.equal(await result.text, 'Maka shipped.'); + const finishStep = parts.find((part) => part.type === 'finish-step'); + assert.ok(finishStep && finishStep.type === 'finish-step'); + assert.deepEqual(finishStep.usage.raw?.x_tools, { web_search: { count: 1 } }); + }); + + test('Open Responses preserves search, open-page, and find-in-page actions', async () => { + let body: Record | undefined; + const calls = [ + { + type: 'web_search_call', + id: 'search-1', + status: 'completed', + action: { type: 'search', queries: ['latest Maka'] }, + }, + { + type: 'web_search_call', + id: 'open-1', + status: 'completed', + action: { type: 'open_page', url: 'https://maka.example/releases' }, + }, + { + type: 'web_search_call', + id: 'find-1', + status: 'completed', + action: { + type: 'find_in_page', + url: 'https://maka.example/releases', + pattern: 'WebSearch', + }, + }, + ]; + const completed = { + id: 'response-page-actions', + object: 'response', + created_at: 1, + model: 'deepseek-v4-flash', + status: 'completed', + output: calls, + usage: { input_tokens: 5, output_tokens: 2, total_tokens: 7 }, + }; + const events = [ + { type: 'response.created', response: { id: completed.id } }, + ...calls.flatMap((call, output_index) => [ + { + type: 'response.output_item.added', + output_index, + item: { ...call, status: 'in_progress', action: undefined }, + }, + { type: 'response.output_item.done', output_index, item: call }, + ]), + { type: 'response.completed', response: completed }, + ]; + const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { + body = JSON.parse(String(init?.body)); + 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 model = getAIModel({ + connection: { ...conn('deepseek'), baseUrl: 'https://deepseek.example' }, + apiKey: 'deepseek-key', + modelId: 'deepseek-v4-flash', + fetch, + }); + const tools = lowerModelTools({ + WebSearch: { + kind: 'provider', + providerTool: { kind: 'openai-web-search', searchContextSize: 'medium' }, + }, + }); + const result = streamText({ + model, + messages: [{ role: 'user', content: 'Search, open, and find.' }], + tools: tools as ToolSet, + maxRetries: 0, + }); + const parts = []; + for await (const part of result.fullStream) parts.push(part); + + assert.deepEqual(body?.tools, [{ type: 'web_search' }]); + assert.deepEqual( + parts + .filter((part) => part.type === 'tool-result') + .map((part) => (part.type === 'tool-result' ? part.output : undefined)), + [ + { action: { type: 'search', queries: ['latest Maka'] } }, + { action: { type: 'openPage', url: 'https://maka.example/releases' } }, + { + action: { + type: 'findInPage', + url: 'https://maka.example/releases', + pattern: 'WebSearch', + }, + }, + ], + ); + }); + test('adds the V2 trigger only through the explicit OpenAI provider option', async () => { const bodies: Array> = []; const fetch = (async (_url: string | URL | Request, init?: RequestInit) => { diff --git a/patches/@ai-sdk+open-responses+2.0.34.patch b/patches/@ai-sdk+open-responses+2.0.34.patch new file mode 100644 index 0000000000..6c2053417a --- /dev/null +++ b/patches/@ai-sdk+open-responses+2.0.34.patch @@ -0,0 +1,383 @@ +diff --git a/node_modules/@ai-sdk/open-responses/dist/index.js b/node_modules/@ai-sdk/open-responses/dist/index.js +index 59434d4..23c0d0c 100644 +--- a/node_modules/@ai-sdk/open-responses/dist/index.js ++++ b/node_modules/@ai-sdk/open-responses/dist/index.js +@@ -455,15 +455,19 @@ var OpenResponsesLanguageModel = class _OpenResponsesLanguageModel { + providerOptionsName: this.config.providerOptionsName + }); + warnings.push(...inputWarnings); +- const functionTools = []; ++ const responseTools = []; + for (const tool of tools != null ? tools : []) { + if (tool.type === "provider") { +- warnings.push({ +- type: "unsupported", +- feature: `provider-defined tool ${tool.id}` +- }); ++ if (tool.id === "openai.web_search") { ++ responseTools.push({ type: "web_search" }); ++ } else { ++ warnings.push({ ++ type: "unsupported", ++ feature: `provider-defined tool ${tool.id}` ++ }); ++ } + } else { +- functionTools.push({ ++ responseTools.push({ + type: "function", + name: tool.name, + description: tool.description, +@@ -516,7 +520,7 @@ var OpenResponsesLanguageModel = class _OpenResponsesLanguageModel { + summary: openResponsesOptions.reasoningSummary + } + } : void 0, +- tools: functionTools.length ? functionTools : void 0, ++ tools: responseTools.length ? responseTools : void 0, + tool_choice: convertedToolChoice, + ...textFormat != null && { text: { format: textFormat } } + }, +@@ -572,6 +576,7 @@ var OpenResponsesLanguageModel = class _OpenResponsesLanguageModel { + } + const content = []; + let hasToolCalls = false; ++ const webSearchToolName = providerWebSearchToolName(options.tools); + for (const part of response.output) { + switch (part.type) { + // TODO AI SDK 7 adjust reasoning in the specification to better support the reasoning structure from open responses. +@@ -629,6 +634,22 @@ var OpenResponsesLanguageModel = class _OpenResponsesLanguageModel { + }); + break; + } ++ case "web_search_call": { ++ content.push({ ++ type: "tool-call", ++ toolCallId: part.id, ++ toolName: webSearchToolName != null ? webSearchToolName : "web_search", ++ input: JSON.stringify({}), ++ providerExecuted: true ++ }); ++ content.push({ ++ type: "tool-result", ++ toolCallId: part.id, ++ toolName: webSearchToolName != null ? webSearchToolName : "web_search", ++ result: mapWebSearchOutput(part.action) ++ }); ++ break; ++ } + } + } + const usage = response.usage; +@@ -732,6 +753,7 @@ var OpenResponsesLanguageModel = class _OpenResponsesLanguageModel { + }; + const toolCallsByItemId = /* @__PURE__ */ new Map(); + const providerOptionsName = this.config.providerOptionsName; ++ const webSearchToolName = providerWebSearchToolName(options.tools); + return { + stream: response.pipeThrough( + new TransformStream({ +@@ -795,6 +817,31 @@ var OpenResponsesLanguageModel = class _OpenResponsesLanguageModel { + }); + hasToolCalls = true; + toolCallsByItemId.delete(chunk.item.id); ++ } else if (chunk.type === "response.output_item.added" && chunk.item.type === "web_search_call") { ++ controller.enqueue({ ++ type: "tool-input-start", ++ id: chunk.item.id, ++ toolName: webSearchToolName != null ? webSearchToolName : "web_search", ++ providerExecuted: true ++ }); ++ controller.enqueue({ ++ type: "tool-input-end", ++ id: chunk.item.id ++ }); ++ controller.enqueue({ ++ type: "tool-call", ++ toolCallId: chunk.item.id, ++ toolName: webSearchToolName != null ? webSearchToolName : "web_search", ++ input: JSON.stringify({}), ++ providerExecuted: true ++ }); ++ } else if (chunk.type === "response.output_item.done" && chunk.item.type === "web_search_call") { ++ controller.enqueue({ ++ type: "tool-result", ++ toolCallId: chunk.item.id, ++ toolName: webSearchToolName != null ? webSearchToolName : "web_search", ++ result: mapWebSearchOutput(chunk.item.action) ++ }); + } else if (chunk.type === "response.output_item.added" && chunk.item.type === "reasoning") { + controller.enqueue({ + type: "reasoning-start", +@@ -904,6 +951,36 @@ var OpenResponsesLanguageModel = class _OpenResponsesLanguageModel { + }; + } + }; ++function providerWebSearchToolName(tools) { ++ var _a; ++ return (_a = tools == null ? void 0 : tools.find( ++ (tool) => tool.type === "provider" && tool.id === "openai.web_search" ++ )) == null ? void 0 : _a.name; ++} ++function mapWebSearchOutput(action) { ++ if (action == null) return {}; ++ switch (action.type) { ++ case "search": ++ return { ++ action: { ++ type: "search", ++ ...action.query != null && { query: action.query }, ++ ...action.queries != null && { queries: action.queries } ++ }, ++ ...action.sources != null && { sources: action.sources } ++ }; ++ case "open_page": ++ return { action: { type: "openPage", url: action.url } }; ++ case "find_in_page": ++ return { ++ action: { ++ type: "findInPage", ++ url: action.url, ++ pattern: action.pattern ++ } ++ }; ++ } ++} + function createOpenResponsesStreamError({ + type, + error, +diff --git a/node_modules/@ai-sdk/open-responses/src/responses/open-responses-api.ts b/node_modules/@ai-sdk/open-responses/src/responses/open-responses-api.ts +index 8bc1e20..4c1ca42 100644 +--- a/node_modules/@ai-sdk/open-responses/src/responses/open-responses-api.ts ++++ b/node_modules/@ai-sdk/open-responses/src/responses/open-responses-api.ts +@@ -240,6 +240,13 @@ export type FunctionToolParam = { + type: 'function'; + }; + ++/** ++ * A provider-executed web search tool parameter. ++ */ ++export type WebSearchToolParam = { ++ type: 'web_search'; ++}; ++ + /** + * A specific function tool choice. + */ +@@ -476,6 +483,29 @@ export type ReasoningBody = { + encrypted_content?: string; + }; + ++/** ++ * A provider-executed web search action. ++ */ ++export type WebSearchAction = ++ | { ++ type: 'search'; ++ query?: string; ++ queries?: string[]; ++ sources?: Array<{ type: 'url'; url: string }>; ++ } ++ | { type: 'open_page'; url?: string | null } ++ | { type: 'find_in_page'; url?: string | null; pattern?: string | null }; ++ ++/** ++ * A provider-executed web search call. ++ */ ++export type WebSearchCall = { ++ type: 'web_search_call'; ++ id: string; ++ status: FunctionCallStatus; ++ action?: WebSearchAction; ++}; ++ + /** + * Output item field union type. + */ +@@ -483,7 +513,8 @@ export type OutputItem = + | FunctionCall + | FunctionCallOutput + | Message +- | ReasoningBody; ++ | ReasoningBody ++ | WebSearchCall; + + /** + * Details about why the response was incomplete. +@@ -643,7 +674,7 @@ export type OpenResponsesRequestBody = { + /** + * A list of tools that the model may call while generating the response. + */ +- tools?: FunctionToolParam[]; ++ tools?: Array; + + /** + * Controls which tool the model should use, if any. +diff --git a/node_modules/@ai-sdk/open-responses/src/responses/open-responses-language-model.ts b/node_modules/@ai-sdk/open-responses/src/responses/open-responses-language-model.ts +index 413b453..6c55ed3 100644 +--- a/node_modules/@ai-sdk/open-responses/src/responses/open-responses-language-model.ts ++++ b/node_modules/@ai-sdk/open-responses/src/responses/open-responses-language-model.ts +@@ -38,6 +38,7 @@ import { + type OpenResponsesChunk, + type ReasoningBody, + type ToolChoiceParam, ++ type WebSearchAction, + } from './open-responses-api'; + import { mapOpenResponsesFinishReason } from './map-open-responses-finish-reason'; + import type { OpenResponsesConfig } from './open-responses-config'; +@@ -121,17 +122,22 @@ export class OpenResponsesLanguageModel implements LanguageModelV4 { + + warnings.push(...inputWarnings); + +- // Convert function tools to the Open Responses format +- const functionTools: FunctionToolParam[] = []; ++ // Convert function and the Responses-native web search tool to the Open ++ // Responses format. Other provider-defined tools stay fail-closed. ++ const responseTools: Array = []; + + for (const tool of tools ?? []) { + if (tool.type === 'provider') { +- warnings.push({ +- type: 'unsupported', +- feature: `provider-defined tool ${tool.id}`, +- }); ++ if (tool.id === 'openai.web_search') { ++ responseTools.push({ type: 'web_search' }); ++ } else { ++ warnings.push({ ++ type: 'unsupported', ++ feature: `provider-defined tool ${tool.id}`, ++ }); ++ } + } else { +- functionTools.push({ ++ responseTools.push({ + type: 'function', + name: tool.name, + description: tool.description, +@@ -210,7 +216,7 @@ export class OpenResponsesLanguageModel implements LanguageModelV4 { + }), + } + : undefined, +- tools: functionTools.length ? functionTools : undefined, ++ tools: responseTools.length ? responseTools : undefined, + tool_choice: convertedToolChoice, + ...(textFormat != null && { text: { format: textFormat } }), + }, +@@ -274,6 +280,7 @@ export class OpenResponsesLanguageModel implements LanguageModelV4 { + + const content: Array = []; + let hasToolCalls = false; ++ const webSearchToolName = providerWebSearchToolName(options.tools); + + for (const part of response.output) { + switch (part.type) { +@@ -336,6 +343,23 @@ export class OpenResponsesLanguageModel implements LanguageModelV4 { + }); + break; + } ++ ++ case 'web_search_call': { ++ content.push({ ++ type: 'tool-call', ++ toolCallId: part.id, ++ toolName: webSearchToolName ?? 'web_search', ++ input: JSON.stringify({}), ++ providerExecuted: true, ++ }); ++ content.push({ ++ type: 'tool-result', ++ toolCallId: part.id, ++ toolName: webSearchToolName ?? 'web_search', ++ result: mapWebSearchOutput(part.action), ++ }); ++ break; ++ } + } + } + +@@ -455,6 +479,7 @@ export class OpenResponsesLanguageModel implements LanguageModelV4 { + { toolName?: string; toolCallId?: string; arguments?: string } + >(); + const providerOptionsName = this.config.providerOptionsName; ++ const webSearchToolName = providerWebSearchToolName(options.tools); + + return { + stream: response.pipeThrough( +@@ -541,6 +566,37 @@ export class OpenResponsesLanguageModel implements LanguageModelV4 { + hasToolCalls = true; + + toolCallsByItemId.delete(chunk.item.id); ++ } else if ( ++ chunk.type === 'response.output_item.added' && ++ chunk.item.type === 'web_search_call' ++ ) { ++ controller.enqueue({ ++ type: 'tool-input-start', ++ id: chunk.item.id, ++ toolName: webSearchToolName ?? 'web_search', ++ providerExecuted: true, ++ }); ++ controller.enqueue({ ++ type: 'tool-input-end', ++ id: chunk.item.id, ++ }); ++ controller.enqueue({ ++ type: 'tool-call', ++ toolCallId: chunk.item.id, ++ toolName: webSearchToolName ?? 'web_search', ++ input: JSON.stringify({}), ++ providerExecuted: true, ++ }); ++ } else if ( ++ chunk.type === 'response.output_item.done' && ++ chunk.item.type === 'web_search_call' ++ ) { ++ controller.enqueue({ ++ type: 'tool-result', ++ toolCallId: chunk.item.id, ++ toolName: webSearchToolName ?? 'web_search', ++ result: mapWebSearchOutput(chunk.item.action), ++ }); + } + + // Reasoning events (note: response.reasoning_text.delta is an LM Studio extension, not in official spec) +@@ -699,6 +755,39 @@ function createOpenResponsesStreamError({ + }); + } + ++function providerWebSearchToolName( ++ tools: LanguageModelV4CallOptions['tools'], ++): string | undefined { ++ return tools?.find( ++ tool => tool.type === 'provider' && tool.id === 'openai.web_search', ++ )?.name; ++} ++ ++function mapWebSearchOutput(action: WebSearchAction | undefined) { ++ if (action == null) return {}; ++ switch (action.type) { ++ case 'search': ++ return { ++ action: { ++ type: 'search' as const, ++ ...(action.query != null && { query: action.query }), ++ ...(action.queries != null && { queries: action.queries }), ++ }, ++ ...(action.sources != null && { sources: action.sources }), ++ }; ++ case 'open_page': ++ return { action: { type: 'openPage' as const, url: action.url } }; ++ case 'find_in_page': ++ return { ++ action: { ++ type: 'findInPage' as const, ++ url: action.url, ++ pattern: action.pattern, ++ }, ++ }; ++ } ++} ++ + function createReasoningProviderMetadata({ + part, + providerOptionsName, diff --git a/patches/README.md b/patches/README.md index 8e76603c70..317d4d03d2 100644 --- a/patches/README.md +++ b/patches/README.md @@ -45,6 +45,21 @@ Streaming tool-call association for gateways that reuse or omit `index` / `id` Delete when that guard passes against an unpatched package. +## `@ai-sdk/open-responses@2.0.34` + +The adapter drops every provider-defined tool and only decodes function-call +output items. Alibaba Token Plan exposes its provider-executed Harness web +search over the otherwise supported Responses wire, using +`tools: [{ type: "web_search" }]` and `web_search_call` output items. The patch +lowers that one published tool shape and maps its streamed call/result pair into +the AI SDK provider-executed tool protocol; all other provider-defined tools +remain unsupported. Product-level wire tests pin the request and result shape. + +Tracked upstream by vercel/ai#19933 and its implementation PR #19939. Delete +this patch when a released `Experimental_OpenResponsesBareExtension` codec can +own the exact `web_search` and `web_search_call` wire types and the product-level +wire/replay tests pass against the unpatched package. + ## `@astryxdesign/core@0.5.0` Five published component seams drop host-owned state or semantics: