Skip to content

[None][fix] Chat streaming: send each tool call index once per chunk - #21468

Open
JunyiXu-nv wants to merge 1 commit into
NVIDIA:mainfrom
JunyiXu-nv:dev-junyix-fix-chat-tool-call-one-entry-per-index
Open

JunyiXu-nv wants to merge 1 commit into
NVIDIA:mainfrom
JunyiXu-nv:dev-junyix-fix-chat-tool-call-one-entry-per-index

Conversation

@JunyiXu-nv

@JunyiXu-nv JunyiXu-nv commented Nov 2, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Symptom. On /v1/chat/completions with stream: true, a DeepSeek V3.2 / V4 / V4.1 model served with its DSML tool parser sends each tool call as two entries of the same chunk's delta.tool_calls, both with the same index: the first carries the id, type and name with empty arguments, the second repeats type: "function" and carries the whole arguments without id or name. OpenAI never repeats an index within a chunk. The openai-python stream helper (client.chat.completions.stream(...), ChatCompletionStreamState) before 3.14 takes the first tool_calls list it receives as is, so it ends with two calls: one named with empty arguments and one nameless with the arguments. Every tool call of these models is broken for such a client; with several calls in one chunk the snapshot is scrambled further (two calls come back as four). Clients that accumulate the entries by index themselves are not affected.

Cause. Several tool parsers report a call's name and its arguments as separate ToolCallItems of one streaming step, and chat_stream_post_processor turned every item into its own DeltaToolCall:

  • DeepSeek V3.2, V4 and V4.1 (DSML): when an invoke closes, the header (name, "") and then the full arguments, for every call.
  • Poolside V1: name and arguments together for every call.
  • Qwen3, for bare-JSON calls: name and arguments together.
  • Qwen3-Coder, when a call's name and its parameters arrive in the same step (a whole call in one step): name, parameter fragments and the closing brace.

DeepSeek V3 / V3.1, Kimi K2 / K3, GLM-4 / GLM-4.7, Gemma 4, MiniMax-M3 and Qwen3 <tool_call> calls never repeat an index within a step. MiniMax-M2 does and is merged too, but its streaming parser has a separate defect (a completed invoke is also reported again under the next index) that this PR does not address.

Change. chat_stream_post_processor merges the items of one step that share a tool index before it builds the entries (_merge_calls_by_index): the merged entry takes the position of the index's first item, the first name among its items (so the call id is generated once), and the arguments joined in order. This holds for every parser, so the chunk carries at most one entry per index.

Behaviour. Only the number of entries in a chunk's delta.tool_calls changes (at most one per index); a client that accumulates by index assembles the same calls as before. Chunk boundaries, content, reasoning, ids, finish_reason and usage are unchanged, and parsers that never repeat an index in a step stream exactly as before, chunk for chunk. The Anthropic Messages stream, which is reframed from these chunks, emits the same events (checked frame by frame for one call, two calls, text followed by a call, and a call block split into small increments). The tool parsers themselves, the non-streaming chat response and the Responses API are not changed.

Test Coverage

New tests/unittest/llmapi/apps/test_openai_chat_stream_tool_calls.py (CPU only, added to l0_cpu.yml):

  • test_dsml_block_in_one_step_streams_one_entry_per_call[one_call, two_calls]: POSTs a streaming request to an OpenAIServer built with make_stub_openai_server and tool_parser="deepseek_v41", whose stub engine streams text and then a whole DSML block in one step. No chunk repeats an index; each call is one entry with id, type, name and its full arguments; the content and finish_reason="tool_calls" are intact.
  • test_openai_sdk_stream_helper_assembles_the_calls[one_call, two_calls]: feeds the same chunks to openai-python's ChatCompletionStreamState and checks that it ends with exactly the calls made, names and arguments included (skipped if openai cannot be imported).
  • test_parsers_reporting_name_and_arguments_in_one_step[deepseek_v32, deepseek_v4, poolside_v1, qwen3_bare_json, qwen3_coder]: chat_stream_post_processor with each of these parsers and a whole call in one step sends the call as one entry.
  • test_name_and_arguments_in_separate_steps_stream_as_parsed[qwen3, deepseek_v31]: for parsers that name a call in one step and stream its arguments in later ones, every chunk carries exactly the parser's items, compared step by step with the parser run on its own.

make_stub_openai_server takes an optional tool_parser, passed to the OpenAIServer constructor.

Results (11 new tests): on main, 7 fail (a chunk repeats an index) and 4 pass; with an openai-python release before 3.14 installed, 9 fail, since the SDK test then assembles 2 calls instead of 1 and 4 instead of 2. All 11 pass with this change, with either SDK. The two "separate steps" tests pass on both by design, and the SDK test passes on main with openai-python 3.14 or later, which merges repeated indices itself. Removing the merge call makes the same 7 tests fail again. Over tests/unittest/llmapi/apps and tests/unittest/executor/test_spec_decode_stats_payload.py, the only outcome changes from main are those 7 tests going from failed to passed.

PR Checklist

  • PR description clearly explains what and why.

GitHub Bot Help

To see a list of available CI bot commands, please comment /bot help.

🤖 Generated with Claude Code

Dev Engineer Review

chat_stream_post_processor now merges ToolCallItems with the same index within each streaming step before building delta.tool_calls. The merge keeps the first position and name, and joins argument fragments in order. This limits each chunk to one entry per index. The change does not modify parsers, non-streaming responses, or the Responses API. No review findings or test-run results were supplied.

make_stub_openai_server adds an optional tool_parser argument and passes it to OpenAIServer. The argument defaults to None, preserving existing calls.

QA Engineer Review

The new CPU-only test module covers unique tool-call indexes within a chunk, assistant text, finish reasons, SDK stream assembly, parser output, and calls split across steps. The test is listed in tests/integration/test_lists/test-db/l0_cpu.yml. The supplied change summary reports 11 tests; it does not provide test function names or execution results. Coverage verdict: sufficient for the described streaming behavior.

Per-File QA Perspective

  • tensorrt_llm/serve/postprocess_handlers.py: Verify same-index fragments merge in order and preserve the first position and name. Also verify chunks with distinct indexes and parsers that do not repeat indexes retain expected output.
  • tests/integration/test_lists/test-db/l0_cpu.yml: Adds the new CPU test module to the CI test list.
  • tests/unittest/llmapi/apps/openai_server_stub.py: Verify the optional tool_parser is passed to the server and that calls without it retain the default behavior.
  • tests/unittest/llmapi/apps/test_openai_chat_stream_tool_calls.py: Tests streamed tool-call merging, SDK assembly, and separate-step behavior. The module is listed in test-db/l0_cpu.yml.

Several tool parsers report a call's name and its arguments as separate
items of one streaming step: the DeepSeek V3.2, V4 and V4.1 DSML parsers
and Poolside V1 for every call, Qwen3 for bare-JSON calls, and
Qwen3-Coder when a call's name and arguments arrive in one step. The
chat completion stream turned each item into its own delta.tool_calls
entry, so one chunk carried the same index twice, which OpenAI never
does. The openai-python stream helper (client.chat.completions.stream)
before 3.14 takes a chunk's first tool_calls list as it is and ends up
with two calls: one named with empty arguments, one nameless with the
arguments.

Merge the items of one step that share an index before building the
entries: the entry keeps the first item's position and name (so its id
is generated once) and the arguments joined in order. Parsers that never
repeat an index within a step stream exactly as before, and the
Anthropic Messages stream built on these chunks emits the same events.

Signed-off-by: JunyiXu-nv <219237550+JunyiXu-nv@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between cbef70d and 9cefe66.

Files selected for processing: 4
  • tensorrt_llm/serve/postprocess_handlers.py
  • tests/integration/test_lists/test-db/l0_cpu.yml
  • tests/unittest/llmapi/apps/openai_server_stub.py
  • tests/unittest/llmapi/apps/test_openai_chat_stream_tool_calls.py

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 9 remain after this review.

Walkthrough

The streaming chat postprocessor now merges tool-call parser items that share an index before building deltas. CPU tests cover streamed calls, chunk ordering, SDK assembly, and parser output across one or multiple steps.

Changes

Streaming tool-call handling

Layer / File(s) Summary
Merge same-index parser results
tensorrt_llm/serve/postprocess_handlers.py
The streaming chat postprocessor merges parser items with the same tool-call index before creating deltas. It preserves item order, the first available name, and concatenated argument fragments.
Test streamed tool-call output
tests/unittest/llmapi/apps/openai_server_stub.py, tests/unittest/llmapi/apps/test_openai_chat_stream_tool_calls.py, tests/integration/test_lists/test-db/l0_cpu.yml
The test server stub accepts and passes a tool_parser option. CPU tests check streamed calls, chunk ordering, SDK assembly, and parser output. The integration test list includes the new test file.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Possibly related PRs

Suggested labels: api-compatible

Suggested reviewers: qijune

Merge Risk: Minimal · up to 9cefe

No actionable issue remains from this review; the change is mergeable after normal checks.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 78.57% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documenta… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: merging streaming tool-call entries so each tool-call index appears once per chunk.
Description check Passed The description includes the required Description, Test Coverage, and PR Checklist sections. It clearly explains the issue, cause, solution, behavior, and test results. The checklist does not address …
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description includes the required Description, Test Coverage, and PR Checklist sections. It clearly explains the issue, cause, solution, behavior, and test results. The checklist does not address every optional item individually, but the description is otherwise complete.

Full details: Docstring Coverage

Explanation

Docstring coverage is 78.57% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff with cyclomatic complexity of at least 2. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 14 functions across 3 files. (1 skipped: 1 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

Finishing Touches
Generate unit tests (beta)
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

@JunyiXu-nv

Copy link
Copy Markdown
Collaborator Author

/bot run --disable-fail-fast

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #82115 [ run ] triggered by Bot. Commit: 9cefe66 Link to invocation

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #82115 [ run ] completed with state SUCCESS. Commit: 9cefe66
/LLM/main/L0_MergeRequest_PR pipeline #67748 completed with status: 'UNSTABLE'

CI Report

⚠️ Action Required:

  • Please check the failed tests and fix your PR
  • If you cannot view the failures, ask the CI triggerer to share details
  • Once fixed, request an NVIDIA team member to trigger CI again

Link to invocation

@JunyiXu-nv

Copy link
Copy Markdown
Collaborator Author

/bot run --disable-fail-fast

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #82184 [ run ] triggered by Bot. Commit: 9cefe66 Link to invocation

@tensorrt-cicd

Copy link
Copy Markdown
Collaborator

PR_Github #82184 [ run ] completed with state SUCCESS. Commit: 9cefe66
/LLM/main/L0_MergeRequest_PR pipeline #67813 completed with status: 'SUCCESS'

CI Report

Link to invocation

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants