diff --git a/.changeset/fix-openai-web-search-action.md b/.changeset/fix-openai-web-search-action.md new file mode 100644 index 00000000000..ac95aedf19c --- /dev/null +++ b/.changeset/fix-openai-web-search-action.md @@ -0,0 +1,5 @@ +--- +"@effect/ai-openai": patch +--- + +Fix OpenAI stable web search response decoding by preserving the provider action in tool call parameters. diff --git a/packages/ai/openai/src/OpenAiLanguageModel.ts b/packages/ai/openai/src/OpenAiLanguageModel.ts index e8559fea08e..62cc4e55cc3 100644 --- a/packages/ai/openai/src/OpenAiLanguageModel.ts +++ b/packages/ai/openai/src/OpenAiLanguageModel.ts @@ -1615,7 +1615,9 @@ const makeResponse = Effect.fnUntraced( type: "tool-call", id: part.id, name: toolName, - params: {}, + params: webSearchTool?.name === "OpenAiWebSearchPreview" + ? {} + : { action: part.action }, providerExecuted: true }) parts.push({ @@ -1943,6 +1945,9 @@ const makeStreamResponse = Effect.fnUntraced( id: event.item.id, name: toolName } + if (webSearchTool?.name === "OpenAiWebSearch") { + break + } parts.push({ type: "tool-params-start", id: event.item.id, @@ -2256,6 +2261,15 @@ const makeStreamResponse = Effect.fnUntraced( const toolName = toolNameMapper.getCustomName( webSearchTool?.name ?? "web_search" ) + if (webSearchTool?.name === "OpenAiWebSearch") { + parts.push({ + type: "tool-call", + id: event.item.id, + name: toolName, + params: { action: event.item.action }, + providerExecuted: true + }) + } parts.push({ type: "tool-result", id: event.item.id, diff --git a/packages/ai/openai/test/OpenAiLanguageModel.test.ts b/packages/ai/openai/test/OpenAiLanguageModel.test.ts index 1250b88b922..4a9ab04d607 100644 --- a/packages/ai/openai/test/OpenAiLanguageModel.test.ts +++ b/packages/ai/openai/test/OpenAiLanguageModel.test.ts @@ -752,6 +752,38 @@ describe("OpenAiLanguageModel", () => { } })))) + it.each(["gpt-4.1", "gpt-5.6"] as const)( + "maps stable web search action to tool call parameters with %s", + (model) => + Effect.runPromise( + Effect.gen(function*() { + const toolkit = Toolkit.make(OpenAiTool.WebSearch({})) + const result = yield* LanguageModel.generateText({ + prompt: "Search the web", + toolkit + }).pipe(Effect.provide(OpenAiLanguageModel.model(model))) + + const toolCall = result.content.find((part) => part.type === "tool-call") + assert.isDefined(toolCall) + assert.deepStrictEqual(toolCall.params, { + action: { type: "search", query: "Effect TypeScript" } + }) + + const toolResult = result.content.find((part) => part.type === "tool-result") + assert.isDefined(toolResult) + assert.deepStrictEqual(toolResult.result, { + action: { type: "search", query: "Effect TypeScript" }, + status: "completed" + }) + }).pipe(Effect.provide(makeTestLayer({ + body: { + model, + output: [makeWebSearchCall()] + } + }))) + ) + ) + it.effect("uses canonical OpenAiMcp name for mcp_approval_request", () => Effect.gen(function*() { const result = yield* LanguageModel.generateText({ @@ -1099,6 +1131,59 @@ describe("OpenAiLanguageModel", () => { assert.isDefined(toolParamsEnd) })) + it.effect("waits for the stable streamed web search action before emitting the tool call", () => + Effect.gen(function*() { + const toolkit = Toolkit.make(OpenAiTool.WebSearch({})) + const streamEvents = [ + { + type: "response.created", + sequence_number: 1, + response: makeDefaultResponse({ status: "in_progress" }) + }, + { + type: "response.output_item.added", + sequence_number: 2, + output_index: 0, + item: { + type: "web_search_call", + id: "ws_123", + status: "in_progress" + } + }, + { + type: "response.output_item.done", + sequence_number: 3, + output_index: 0, + item: makeWebSearchCall() + } + ] as unknown as ReadonlyArray + + const parts = yield* LanguageModel.streamText({ + prompt: "Search the web", + toolkit, + disableToolCallResolution: true + }).pipe( + Stream.runCollect, + Effect.provide(OpenAiLanguageModel.model("gpt-4o-mini")), + Effect.provide(makeStreamTestLayer(streamEvents)) + ) + + const toolCalls = parts.filter((part) => part.type === "tool-call") + strictEqual(toolCalls.length, 1) + const toolCall = toolCalls[0] + assert.isDefined(toolCall) + assert.deepStrictEqual(toolCall.params, { + action: { type: "search", query: "Effect TypeScript" } + }) + + const toolResult = parts.find((part) => part.type === "tool-result") + assert.isDefined(toolResult) + assert.deepStrictEqual(toolResult.result, { + action: { type: "search", query: "Effect TypeScript" }, + status: "completed" + }) + })) + it.effect("handles reasoning summary events when reasoning state is missing", () => Effect.gen(function*() { const streamEvents = [ @@ -1512,6 +1597,16 @@ const makeFunctionCall = ( ...overrides }) +const makeWebSearchCall = ( + overrides: Partial = {} +): Generated.WebSearchToolCall => ({ + type: "web_search_call", + id: "ws_123", + status: "completed", + action: { type: "search", query: "Effect TypeScript" }, + ...overrides +}) + const makeMcpCall = ( name: string, args: Record,