From f61858e683e985e1fef127012f6274dcdc5ed00c Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:44:05 -0500 Subject: [PATCH] fix(ai): avoid filtered tool calls (#46029) --- packages/ai/src/protocols/openai-chat.ts | 16 ++++-- packages/ai/test/provider/openai-chat.test.ts | 56 +++++++++++++++++++ 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/packages/ai/src/protocols/openai-chat.ts b/packages/ai/src/protocols/openai-chat.ts index 4e9c5647a7d..e99a48a5a57 100644 --- a/packages/ai/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -1054,17 +1054,25 @@ const step = (state: ParserState, event: OpenAIChatEvent) => events.push(...result.events) } - if (finishReason !== undefined && state.finishReason === undefined && Object.keys(pendingTools).length > 0) + const contentFiltered = finishReason?.normalized === "content-filter" + if ( + finishReason !== undefined && + !contentFiltered && + state.finishReason === undefined && + Object.keys(pendingTools).length + ) return yield* ProviderShared.eventError( ADAPTER, "OpenAI Chat tool call delta is missing id or name", ProviderShared.encodeJson(event), ) - // Finalize accumulated tool inputs eagerly when finish_reason arrives so - // valid calls and malformed local calls settle independently. + // A content filter terminates the response without confirming pending tool calls. const finished = - finishReason !== undefined && state.finishReason === undefined && Object.keys(tools).length > 0 + finishReason !== undefined && + !contentFiltered && + state.finishReason === undefined && + Object.keys(tools).length > 0 ? yield* ToolStream.finishAll(ADAPTER, tools) : undefined diff --git a/packages/ai/test/provider/openai-chat.test.ts b/packages/ai/test/provider/openai-chat.test.ts index b4515881703..45e603b0cb1 100644 --- a/packages/ai/test/provider/openai-chat.test.ts +++ b/packages/ai/test/provider/openai-chat.test.ts @@ -1460,6 +1460,62 @@ describe("OpenAI Chat route", () => { }), ) + it.effect("does not finalize streamed tool calls when content is filtered", () => + Effect.gen(function* () { + const body = sseEvents( + deltaChunk({ + tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query":"weather"' } }], + }), + deltaChunk({}, "content_filter"), + ) + const response = yield* LLMClient.generate( + LLMRequest.update(request, { + tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })], + }), + ).pipe(Effect.provide(fixedResponse(body))) + + expect(response.events).toEqual([ + { type: "step-start", index: 0 }, + { + type: "tool-input-start", + id: "call_1", + name: "lookup", + providerExecuted: undefined, + providerMetadata: undefined, + }, + { + type: "tool-input-delta", + id: "call_1", + name: "lookup", + text: '{"query":"weather"', + input: { query: "weather" }, + }, + { + type: "step-finish", + index: 0, + reason: { normalized: "content-filter", raw: "content_filter" }, + usage: undefined, + providerMetadata: undefined, + }, + { type: "finish", reason: { normalized: "content-filter", raw: "content_filter" }, usage: undefined }, + ]) + expect(response.toolCalls).toEqual([]) + + const missingIdentity = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + deltaChunk({ tool_calls: [{ index: 0, id: "call_2", function: { arguments: "{}" } }] }), + deltaChunk({}, "content_filter"), + ), + ), + ), + ) + expect(missingIdentity.finishReason).toEqual({ normalized: "content-filter", raw: "content_filter" }) + expect(missingIdentity.toolCalls).toEqual([]) + }), + ) + it.effect("ignores empty identity fields on later tool call deltas", () => Effect.gen(function* () { const body = sseEvents(