diff --git a/packages/ai/src/protocols/openai-chat.ts b/packages/ai/src/protocols/openai-chat.ts index e99a48a5a57..1e830a15560 100644 --- a/packages/ai/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -1054,10 +1054,10 @@ const step = (state: ParserState, event: OpenAIChatEvent) => events.push(...result.events) } - const contentFiltered = finishReason?.normalized === "content-filter" + const incompleteTools = finishReason?.normalized === "content-filter" || finishReason?.normalized === "length" if ( finishReason !== undefined && - !contentFiltered && + !incompleteTools && state.finishReason === undefined && Object.keys(pendingTools).length ) @@ -1067,10 +1067,10 @@ const step = (state: ParserState, event: OpenAIChatEvent) => ProviderShared.encodeJson(event), ) - // A content filter terminates the response without confirming pending tool calls. + // Filtering or truncation terminates the response without confirming pending tool calls. const finished = finishReason !== undefined && - !contentFiltered && + !incompleteTools && state.finishReason === undefined && Object.keys(tools).length > 0 ? yield* ToolStream.finishAll(ADAPTER, tools) diff --git a/packages/ai/test/provider/openai-chat.test.ts b/packages/ai/test/provider/openai-chat.test.ts index 45e603b0cb1..aeb907d7615 100644 --- a/packages/ai/test/provider/openai-chat.test.ts +++ b/packages/ai/test/provider/openai-chat.test.ts @@ -1516,6 +1516,62 @@ describe("OpenAI Chat route", () => { }), ) + it.effect("does not finalize streamed tool calls when output is truncated", () => + Effect.gen(function* () { + const body = sseEvents( + deltaChunk({ + tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: '{"query":"weather"}' } }], + }), + deltaChunk({}, "length"), + ) + 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: "length", raw: "length" }, + usage: undefined, + providerMetadata: undefined, + }, + { type: "finish", reason: { normalized: "length", raw: "length" }, 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({}, "length"), + ), + ), + ), + ) + expect(missingIdentity.finishReason).toEqual({ normalized: "length", raw: "length" }) + expect(missingIdentity.toolCalls).toEqual([]) + }), + ) + it.effect("ignores empty identity fields on later tool call deltas", () => Effect.gen(function* () { const body = sseEvents(