diff --git a/packages/ai/src/protocols/openai-chat.ts b/packages/ai/src/protocols/openai-chat.ts index 68ab2e3949..f17da855dc 100644 --- a/packages/ai/src/protocols/openai-chat.ts +++ b/packages/ai/src/protocols/openai-chat.ts @@ -164,8 +164,15 @@ export const OpenAIChatEvent = Schema.Struct({ export type OpenAIChatEvent = Schema.Schema.Type type OpenAIChatRequestMessage = LLMRequest["messages"][number] +interface PendingToolDelta { + readonly id?: string + readonly name?: string + readonly input: string +} + export interface ParserState { readonly tools: ToolStream.State + readonly pendingTools: Partial> readonly toolCallEvents: ReadonlyArray readonly usage?: Usage readonly finishReason?: FinishReason @@ -432,6 +439,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) => const delta = choice?.delta const toolDeltas = delta?.tool_calls ?? [] let tools = state.tools + let pendingTools = state.pendingTools let lifecycle = state.lifecycle @@ -450,11 +458,24 @@ const step = (state: ParserState, event: OpenAIChatEvent) => if (toolDeltas.length) lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0") for (const tool of toolDeltas) { + const current = tools[tool.index] + const pending = pendingTools[tool.index] + const id = current?.id ?? pending?.id ?? (tool.id || undefined) + const name = current?.name ?? pending?.name ?? (tool.function?.name || undefined) + const text = `${pending?.input ?? ""}${tool.function?.arguments ?? ""}` + if (!current && (!id || !name)) { + pendingTools = { ...pendingTools, [tool.index]: { id: id || undefined, name: name || undefined, input: text } } + continue + } + if (pending) { + pendingTools = { ...pendingTools } + delete pendingTools[tool.index] + } const result = ToolStream.appendOrStart( ADAPTER, tools, tool.index, - { id: tool.id ?? undefined, name: tool.function?.name ?? undefined, text: tool.function?.arguments ?? "" }, + { id: id || undefined, name: name || undefined, text }, "OpenAI Chat tool call delta is missing id or name", ) if (ToolStream.isError(result)) return yield* result @@ -463,6 +484,9 @@ const step = (state: ParserState, event: OpenAIChatEvent) => events.push(...result.events) } + if (finishReason !== undefined && state.finishReason === undefined && Object.keys(pendingTools).length > 0) + return yield* ProviderShared.eventError(ADAPTER, "OpenAI Chat tool call delta is missing id or name") + // Finalize accumulated tool inputs eagerly when finish_reason arrives so // valid calls and malformed local calls settle independently. const finished = @@ -473,6 +497,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) => return [ { tools: finished?.tools ?? tools, + pendingTools, toolCallEvents: finished?.events ?? state.toolCallEvents, usage, finishReason, @@ -512,6 +537,7 @@ export const protocol = Protocol.make({ event: Protocol.jsonEvent(OpenAIChatEvent), initial: () => ({ tools: ToolStream.empty(), + pendingTools: {}, toolCallEvents: [], lifecycle: Lifecycle.initial(), reasoningField: undefined, diff --git a/packages/ai/src/protocols/utils/tool-stream.ts b/packages/ai/src/protocols/utils/tool-stream.ts index 1bb271f889..fbea376e70 100644 --- a/packages/ai/src/protocols/utils/tool-stream.ts +++ b/packages/ai/src/protocols/utils/tool-stream.ts @@ -140,8 +140,8 @@ export const appendOrStart = ( missingToolMessage: string, ): AppendOutcome | LLMError => { const current = tools[key] - const id = delta.id ?? current?.id - const name = delta.name ?? current?.name + const id = current?.id ?? delta.id + const name = current?.name ?? delta.name if (!id || !name) return eventError(route, missingToolMessage) const tool = { diff --git a/packages/ai/test/provider/openai-chat.test.ts b/packages/ai/test/provider/openai-chat.test.ts index e2efa1717d..58502434b8 100644 --- a/packages/ai/test/provider/openai-chat.test.ts +++ b/packages/ai/test/provider/openai-chat.test.ts @@ -606,6 +606,67 @@ describe("OpenAI Chat route", () => { }), ) + it.effect("ignores empty identity fields on later tool call deltas", () => + Effect.gen(function* () { + const body = sseEvents( + deltaChunk({ + tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: "{" } }], + }), + deltaChunk({ + tool_calls: [{ index: 0, id: "", function: { name: "", arguments: '\"query\":\"weather\"}' } }], + }), + deltaChunk({}, "tool_calls"), + ) + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedResponse(body))) + + expect(response.toolCalls).toMatchObject([{ id: "call_1", name: "lookup", input: { query: "weather" } }]) + }), + ) + + it.effect("buffers tool call deltas until the function name arrives", () => + Effect.gen(function* () { + const body = sseEvents( + deltaChunk({ + tool_calls: [{ index: 0, id: "call_1", function: { arguments: "{" } }], + }), + deltaChunk({ + tool_calls: [{ index: 0, function: { name: "lookup", arguments: '\"query\":' } }], + }), + deltaChunk({ tool_calls: [{ index: 0, function: { arguments: '\"weather\"}' } }] }), + deltaChunk({}, "tool_calls"), + ) + const response = yield* LLMClient.generate( + LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedResponse(body))) + + expect(response.toolCalls).toMatchObject([{ id: "call_1", name: "lookup", input: { query: "weather" } }]) + }), + ) + + it.effect("fails when a buffered tool call never receives a function name", () => + Effect.gen(function* () { + const body = sseEvents( + deltaChunk({ + tool_calls: [{ index: 0, id: "call_1", function: { arguments: "{}" } }], + }), + deltaChunk({}, "tool_calls"), + ) + const error = yield* LLMClient.generate( + LLM.updateRequest(request, { + tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }], + }), + ).pipe(Effect.provide(fixedResponse(body)), Effect.flip) + + expect(error.message).toContain("OpenAI Chat tool call delta is missing id or name") + }), + ) + it.effect("fails a streamed tool call when the provider ends without a finish reason", () => Effect.gen(function* () { const body = sseEvents( diff --git a/packages/ai/test/tool-stream.test.ts b/packages/ai/test/tool-stream.test.ts index c02b57c486..72160ed229 100644 --- a/packages/ai/test/tool-stream.test.ts +++ b/packages/ai/test/tool-stream.test.ts @@ -36,6 +36,33 @@ describe("ToolStream", () => { }), ) + it.effect("keeps accumulated identity when later deltas contain empty strings", () => + Effect.gen(function* () { + const first = ToolStream.appendOrStart( + ADAPTER, + ToolStream.empty(), + 0, + { id: "call_1", name: "lookup", text: '{"query"' }, + "missing tool", + ) + if (ToolStream.isError(first)) return yield* first + const second = ToolStream.appendOrStart( + ADAPTER, + first.tools, + 0, + { id: "", name: "", text: ':"weather"}' }, + "missing tool", + ) + if (ToolStream.isError(second)) return yield* second + const finished = yield* ToolStream.finish(ADAPTER, second.tools, 0) + + expect(finished.events).toEqual([ + { type: "tool-input-end", id: "call_1", name: "lookup" }, + { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } }, + ]) + }), + ) + it.effect("fails appendExisting when the provider skipped the tool start", () => Effect.gen(function* () { const error = ToolStream.appendExisting(ADAPTER, ToolStream.empty(), 0, "{}", "missing tool")