fix(compat-oai): drop empty toolRequest chunks during tool-call streaming#5492
fix(compat-oai): drop empty toolRequest chunks during tool-call streaming#5492serhiizghama wants to merge 2 commits into
Conversation
…ming
When streaming, OpenAI-compatible models send a tool call's id and function
name only in the first delta; later deltas carry incremental argument
fragments with no id and no name. fromOpenAIChunkChoice mapped every delta to
a toolRequest part, so those continuation deltas emitted empty
{ toolRequest: { input: '' } } chunks. Filter the deltas to keep only the ones
that start a new tool call; the complete tool call is still reconstructed from
the final response via stream.finalChatCompletion().
Add fromOpenAIChunkChoice cases for the first tool-call delta (emits the identity chunk) and for an argument-continuation delta with no id/name (emits no toolRequest part).
There was a problem hiding this comment.
Code Review
This pull request addresses an issue where streaming continuation deltas for tool calls in OpenAI-compatible models produced empty tool request chunks. It filters the deltas to only keep those starting a new tool call (containing an ID or function name) and adds corresponding unit tests. Feedback suggests refining the filter condition to check only for toolCall.function?.name instead of toolCall.id || toolCall.function?.name to prevent potential runtime errors or type violations if toolCall.id is present but toolCall.function or its name is undefined.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| const toolRequestParts = choice.delta.tool_calls | ||
| ?.filter((toolCall) => toolCall.id || toolCall.function?.name) | ||
| .map((toolCall) => fromOpenAIToolCall(toolCall, choice)); |
There was a problem hiding this comment.
If a streaming chunk contains toolCall.id but toolCall.function is undefined (or toolCall.function.name is undefined), the current filter toolCall.id || toolCall.function?.name will evaluate to true.
This leads to two issues:
- If
toolCall.functionis undefined,fromOpenAIToolCallwill throw a runtime error because of the checkif (!toolCall.function) { throw Error(...) }. - If
toolCall.functionis defined buttoolCall.function.nameis undefined,fromOpenAIToolCallwill return aToolRequestPartwithname: undefined, which violates the type definition ofToolRequestPart(wherenameis a required string).
Filtering solely on toolCall.function?.name guarantees that both toolCall.function and toolCall.function.name are defined, preventing both the runtime crash and the invalid type output.
| const toolRequestParts = choice.delta.tool_calls | |
| ?.filter((toolCall) => toolCall.id || toolCall.function?.name) | |
| .map((toolCall) => fromOpenAIToolCall(toolCall, choice)); | |
| const toolRequestParts = choice.delta.tool_calls | |
| ?.filter((toolCall) => toolCall.function?.name) | |
| .map((toolCall) => fromOpenAIToolCall(toolCall, choice)); |
Problem
Fixes #5374. When streaming a tool call from an OpenAI-compatible model (e.g. gpt-5.5), the model emits the tool call's identity (
id+function.name) only in the first delta; every following delta carries an incrementalargumentsfragment with noidand noname.fromOpenAIChunkChoicemapped everytool_callsdelta to atoolRequestpart, so those argument-continuation deltas produced a stream of empty chunks:Each follow-up
{ toolRequest: { input: "" } }carries no name, no ref, and no input — pure noise in the model chunk stream.Solution
In
fromOpenAIChunkChoice, filter the streamedtool_callsdeltas to keep only those that start a new tool call (they carry anidor afunction.name). Argument-continuation deltas are skipped, so no emptytoolRequestpart is emitted.This only touches the streaming path. The complete tool call — with fully assembled arguments — is still reconstructed from the final response via
stream.finalChatCompletion(), so the final result is unchanged. The non-streaming path (fromOpenAIChoice) is untouched.Testing
pnpm --filter @genkit-ai/compat-oai test— all 55 cases pass, including two newfromOpenAIChunkChoicecases:name+ref)id, noname) emits notoolRequestpart