Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-openai-web-search-action.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect/ai-openai": patch
---

Fix OpenAI stable web search response decoding by preserving the provider action in tool call parameters.
16 changes: 15 additions & 1 deletion packages/ai/openai/src/OpenAiLanguageModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
95 changes: 95 additions & 0 deletions packages/ai/openai/test/OpenAiLanguageModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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<typeof Generated.ResponseStreamEvent.Type>

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 = [
Expand Down Expand Up @@ -1512,6 +1597,16 @@ const makeFunctionCall = (
...overrides
})

const makeWebSearchCall = (
overrides: Partial<Generated.WebSearchToolCall> = {}
): Generated.WebSearchToolCall => ({
type: "web_search_call",
id: "ws_123",
status: "completed",
action: { type: "search", query: "Effect TypeScript" },
...overrides
})

const makeMcpCall = (
name: string,
args: Record<string, unknown>,
Expand Down
Loading