Skip to content

fix(compat-oai): drop empty toolRequest chunks during tool-call streaming#5492

Open
serhiizghama wants to merge 2 commits into
genkit-ai:mainfrom
serhiizghama:fix/streaming-empty-toolrequest-chunks
Open

fix(compat-oai): drop empty toolRequest chunks during tool-call streaming#5492
serhiizghama wants to merge 2 commits into
genkit-ai:mainfrom
serhiizghama:fix/streaming-empty-toolrequest-chunks

Conversation

@serhiizghama

Copy link
Copy Markdown

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 incremental arguments fragment with no id and no name.

fromOpenAIChunkChoice mapped every tool_calls delta to a toolRequest part, so those argument-continuation deltas produced a stream of empty chunks:

data: {"message":{"modelChunk":{"role":"model","index":0,"content":[{"toolRequest":{"name":"list_files","ref":"call_...","input":""}}]}}}
data: {"message":{"modelChunk":{"role":"model","index":0,"content":[{"toolRequest":{"input":""}}]}}}
data: {"message":{"modelChunk":{"role":"model","index":0,"content":[{"toolRequest":{"input":""}}]}}}
...

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 streamed tool_calls deltas to keep only those that start a new tool call (they carry an id or a function.name). Argument-continuation deltas are skipped, so no empty toolRequest part 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 new fromOpenAIChunkChoice cases:

  • the first tool-call delta still emits the identity chunk (name + ref)
  • an argument-continuation delta (no id, no name) emits no toolRequest part

…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).

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +443 to +445
const toolRequestParts = choice.delta.tool_calls
?.filter((toolCall) => toolCall.id || toolCall.function?.name)
.map((toolCall) => fromOpenAIToolCall(toolCall, choice));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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:

  1. If toolCall.function is undefined, fromOpenAIToolCall will throw a runtime error because of the check if (!toolCall.function) { throw Error(...) }.
  2. If toolCall.function is defined but toolCall.function.name is undefined, fromOpenAIToolCall will return a ToolRequestPart with name: undefined, which violates the type definition of ToolRequestPart (where name is 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.

Suggested change
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));

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[JS][compat-oai] when streaming gpt-5.5 produces empty toolRequest model chunks

1 participant