Skip to content

fix(anthropic): split mixed stream chunks by payload kind - #35289

Merged
yucheng-berri merged 4 commits into
litellm_internal_stagingfrom
litellm_anthropic_mixed_split_fixes
Jul 31, 2026
Merged

fix(anthropic): split mixed stream chunks by payload kind#35289
yucheng-berri merged 4 commits into
litellm_internal_stagingfrom
litellm_anthropic_mixed_split_fixes

Conversation

@yucheng-berri

@yucheng-berri yucheng-berri commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Mixed stream chunks with tool calls emitted the tool twice
  • Mixed chunks with thinking_blocks duplicated thinking and dropped answer text
  • thinking_blocks plus text chunks never split at all

How it solves it:

Relevant issues

Fixes #33224. Adopts and supersedes #34701 by @Napuh, whose two commits are preserved with his authorship; his split of reasoning and text is kept and extended to cover tool calls and thinking_blocks

Linear ticket

Resolves LIT-5019

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to re-request a review after pushing changes)

Screenshots / Proof of Fix

Live proxy legs, real Gemini behind the adapter, no mocks. Proxy launched from this branch (worktree import asserted), captured at the tree-identical pre-rebase commit f004001149; the rebase onto current staging changed no file in this PR

curl -sS -N http://127.0.0.1:20019/v1/messages \
  -H 'x-api-key: <master-key>' -H 'anthropic-version: 2023-06-01' -H 'content-type: application/json' \
  -d '{"model":"gemini-think","max_tokens":2048,"stream":true,"thinking":{"type":"enabled","budget_tokens":1024},"messages":[{"role":"user","content":"Write a python function that adds two numbers."}]}'
message_start
content_block_start   idx=0 type=thinking
content_block_delta   idx=0 thinking_delta (x4)
content_block_stop    idx=0
content_block_start   idx=1 type=text
content_block_delta   idx=1 text_delta (x15)
content_block_stop    idx=1
message_delta         stop_reason=end_turn out_tokens=1405
message_stop

The forced tool call leg returns tool_use at index 0 with a single input_json_delta, the plain text leg returns text at index 0, the non-streaming leg returns 200, and all four write correct spend rows to Postgres (0.003516 / 0.000203 / 0.000630 / 0.000312 USD)

Real providers do not emit the collapsed mixed shape on demand (it arises from fake-streamed responses and vLLM-style parser boundary chunks), so the defect differential drives the real AnthropicStreamWrapper in process with the collapsed chunks from the bug reports. Before = dbdfe4a (adopted #34701 unchanged), after = c4b2cc1:

one chunk: reasoning "Thought." + content "Answer." + tool_call get_weather + finish tool_calls

before: start:tool_use@0, input_json_delta@0, start:tool_use@1, input_json_delta@1   (tool twice, thinking and text lost)
after:  start:thinking@0, thinking_delta@0, start:text@1, text_delta@1, start:tool_use@2, input_json_delta@2

one chunk: content "Answer." + thinking_blocks "Thought." (no reasoning_content)

before: start:text@0, thinking_delta@0    (invalid stream, answer lost)
after:  start:thinking@0, thinking_delta@0, start:text@1, text_delta@1

The reasoning-plus-text shapes from #34701 itself are byte-identical before and after, and @Napuh verified that base split against a live GLM-5.2 vLLM backend in #34701 (comment)

A second adversarial verification pass (lensed investigators plus reproduce-or-reject verifiers against the genuine merge-base module, real provider chunk shapes, and a real anthropic SDK accumulator) found three shapes where the first iteration of the split diverged beyond its target; commit bc12ede closes them and adds a pinning test per shape (each fails at d6b3608, passes at bc12ede). A mixed chunk carrying a tool argument continuation (function.name None) now passes through unsplit, so the in-flight tool_use block keeps assembling valid JSON exactly as on the merge-base. A multi-choice chunk passes through unsplit, so secondary choices' payload is neither dropped nor repeated. A signature-less thinking_blocks piece is normalized to reasoning_content, so the synthesized block start opens empty and SSE accumulators collect the thinking exactly once instead of twice via the seeded start body

Type

🐛 Bug Fix

Changes

_split_mixed_reasoning_and_text deep-copied the mixed chunk and cleared only the one field it knew about on each piece, so any other payload riding the chunk survived on both pieces: tool_calls produced two tool_use blocks with the same id, thinking_blocks on the text piece emitted duplicated thinking into a text block while dropping the answer text, and chunks whose reasoning arrived only as thinking_blocks never matched the predicate and never split

The replacement _split_by_payload_kind builds one piece per payload kind present (reasoning = reasoning_content and thinking_blocks together, text = content, tools = tool_calls), ordered thinking then text then tool_use to match native Anthropic block order. Each piece's delta is a freshly constructed Delta carrying only its own kind, mirroring _clear_later_replay_slice_metadata: Delta deletes unset reasoning attributes and the translators branch on hasattr, so clearing fields on a copy would resurrect them as attribute-present Nones, and a future Delta field could otherwise ride along on every piece. Secondary choices on split pieces get empty deltas so multi-choice chunks cannot leak payload either. reasoning_content and thinking_blocks stay on one piece so a signature-carrying thinking chunk keeps its signature suppression, and _split still runs first, peeling finish_reason and usage onto the finish chunk

Regression tests cover the three defect shapes (tool call emitted once with full ordering asserted, sync and async; thinking_blocks-only mixed chunk splits; both reasoning fields keep the text) plus usage emitted exactly once on a mixed finish chunk. All fail on the adopted #34701 code and pass with the fix

Behavior changes

Mixed chunks now emit one block per payload kind in thinking, text, tool_use order; previously the tool_use block was duplicated and thinking or text could be mislabeled or dropped. The split deliberately skips tool argument continuations and multi-choice chunks, which behave byte-identically to the merge-base. Signature-less thinking_blocks payloads on split pieces are emitted through the reasoning_content branch, so their thinking block opens with an empty start body instead of a pre-seeded one. Delta fields that neither streaming translator reads (annotations, reasoning_items, delta-level provider_specific_fields) no longer ride along on split pieces; this is unobservable in emitted events today and prevents the next such field from reintroducing the duplication class

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

@greptile-apps

greptile-apps Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Splits mixed Anthropic stream chunks into ordered, single-payload pieces.

  • Rebuilds reasoning, text, and tool-use deltas independently.
  • Preserves usage on the terminal chunk and bypasses splitting for tool continuations and multi-choice chunks.
  • Adds regression coverage for mixed payloads, thinking blocks, tool calls, usage, and passthrough edge cases.

Confidence Score: 5/5

This follow-up appears safe to merge.

No blocking failure remains within the eligible follow-up-review scope.

Important Files Changed

Filename Overview
litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py Adds payload-kind splitting and reasoning normalization while preserving terminal usage and selected passthrough behavior.
tests/test_litellm/llms/anthropic/experimental_pass_through/adapters/test_streaming_iterator_first_delta.py Adds focused sync and async regressions for mixed reasoning, text, tool-use, continuation, multi-choice, and usage behavior.

Reviews (3): Last reviewed commit: "fix(anthropic): keep continuation and mu..." | Re-trigger Greptile

@devin-ai-integration devin-ai-integration 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.

Devin Review found 1 potential issue.

Open in Devin Review

Comment on lines +137 to +145
pieces = tuple(copy.deepcopy(chunk) for _ in present_groups)
for index, (piece, group) in enumerate(zip(pieces, present_groups)):
copied_delta = piece.choices[0].delta
fields = {field: value for field in group if (value := getattr(copied_delta, field, None))}
role = getattr(copied_delta, "role", None) if index == 0 else None
piece.choices[0].delta = Delta(role=role, **fields)
for extra_choice in piece.choices[1:]:
extra_choice.delta = Delta()
return pieces

@devin-ai-integration devin-ai-integration Bot Jul 30, 2026

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.

🟡 Several tool calls arriving in one collapsed chunk are merged into a single broken tool call

All tool calls riding on a collapsed chunk are put on one split piece (("tool_calls",) group at litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py:102) instead of one piece per call, so a response with two or more tool calls reaches the client as a single tool call whose arguments are two JSON objects glued together.
Impact: When a provider returns the whole answer in one chunk with multiple tool calls, the client sees only the first tool's name/id and unparseable arguments, so those tool calls fail.

Mechanism: single tool_use block built from tool_calls[0] with all arguments concatenated

Fake-streamed providers collapse an entire non-streaming response into one chunk (MockResponseIterator at litellm/llms/base_llm/base_model_iterator.py:204-221), so delta.tool_calls can hold several parallel calls. _split_by_payload_kind groups them all onto one piece, and the translator then derives the block header from choices[0].delta.tool_calls[0] only (litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py:1385-1405) while concatenating every tool's function.arguments into a single partial_json (litellm/llms/anthropic/experimental_pass_through/adapters/transformation.py:1450-1456). Result: one content_block_start of type tool_use with the first tool's id/name, followed by an input_json_delta like {"a":1}{"b":2}. The per-tool-call block splitting in _should_start_new_content_block only fires across chunks, never within one. Emitting one piece per tool call (mirroring the existing per-kind loop) would make each tool_use block well-formed.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Verified pre-existing rather than introduced here. Running the exact shape (one collapsed chunk, two named tool_calls) through the wrapper at the merge-base and at this head gives byte-identical output: one tool_use block named from tool_calls[0] with the arguments concatenated ('{"city":"NY"}{"tz":"EST"}') on both sides. The gluing happens in the translator (block header from tool_calls[0], every call's arguments accumulated into one partial_json), which pure tool chunks hit without ever entering the new split, so splitting per call here would not fix it either. The right fix is per-call block emission in the wrapper or translator, tracked separately rather than folded into this PR

@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 91.11111% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...mental_pass_through/adapters/streaming_iterator.py 91.11% 4 Missing ⚠️

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_anthropic_mixed_split_fixes (bc12ede) with litellm_internal_staging (6e26087)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (8ccbc3e) during the generation of this report, so 2593168 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@yucheng-berri

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor 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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit c4b2cc1. Configure here.

Napuh and others added 4 commits July 30, 2026 18:51
The mixed-chunk split cleared only the fields it knew about on each
deep-copied piece, so any other payload riding the chunk survived on
both pieces: tool_calls were emitted as two tool_use blocks with the
same id, thinking_blocks on the text piece emitted duplicated thinking
into a text block while dropping the answer text, and chunks whose
reasoning arrived only as thinking_blocks never split at all

Rebuild each piece's delta from scratch with exactly one payload kind
(reasoning, text, tool calls), ordered to match native Anthropic block
order. Fresh Delta construction keeps unset attributes deleted, which
matters because the translators branch on hasattr, and prevents future
Delta fields from riding along on every piece
…it signature-less thinking once

Adversarial verification against the merge-base found three shapes where
the payload-kind split changed behavior beyond its target: a mixed chunk
carrying a tool argument continuation was torn into a truncated block
plus a fabricated one, a multi-choice chunk lost its secondary choices'
payload, and a signature-less thinking_blocks piece inherited the
non-empty block start body so accumulators collected the thinking twice

Continuation and multi-choice chunks now pass through the splitter
untouched, matching the merge-base byte for byte, and signature-less
thinking_blocks pieces are normalized to reasoning_content so the block
start opens empty and the thinking text is emitted exactly once
@yucheng-berri
yucheng-berri force-pushed the litellm_anthropic_mixed_split_fixes branch from c4b2cc1 to bc12ede Compare July 31, 2026 01:52
@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai please review the current head bc12ede

@yucheng-berri
yucheng-berri enabled auto-merge (squash) July 31, 2026 01:57

@devin-ai-integration devin-ai-integration 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.

Devin Review found 2 new potential issues.

View 3 additional findings in Devin Review.

Open in Devin Review

return pieces

@staticmethod
def _normalize_reasoning_fields(fields: "dict[str, Any]") -> "dict[str, Any]":

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.

🟡 New helper uses a banned loose dictionary type instead of a precise type

The new reasoning-normalization helper is typed with the coarse dict[str, Any] signature (_normalize_reasoning_fields at litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py:160), which the repository's coding guidelines explicitly forbid.
Impact: The change ships with weaker type information than the project requires, so mistakes in the data passed through it are not caught automatically.

Rule violated and where the loose type flows

CLAUDE.md (referenced as mandatory by AGENTS.md) states: "Fully typed; no Any or coarse types like dict[str, Any] or just dict. Every function parameter must be strongly typed". _normalize_reasoning_fields both accepts and returns dict[str, Any], and its result is splatted into Delta(role=role, **fields) at litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py:156, so no type checker can verify that the keys/values match Delta's reasoning/text/tool parameters. A TypedDict (e.g. with optional reasoning_content, thinking_blocks, content, tool_calls keys) or returning a small frozen dataclass consumed explicitly would satisfy the rule.

Prompt for agents
CLAUDE.md forbids coarse types such as dict[str, Any]. `_CombinedChunkSplitter._normalize_reasoning_fields` in litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py is annotated `(fields: dict[str, Any]) -> dict[str, Any]`, and its return value is splatted into `Delta(role=role, **fields)`, so the payload shape is completely untyped at the boundary. Consider modelling the per-piece payload explicitly (e.g. a TypedDict with the optional keys reasoning_content / thinking_blocks / content / tool_calls, or a frozen dataclass that the caller turns into explicit Delta keyword arguments) so the splat is type-checked.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@yucheng-berri yucheng-berri Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correction: the PR merged at bc12ede before this cleanup landed, and the follow-up was dropped as style-level; the merged code keeps the dict passing helper. Verified behavior-neutral either way over a 720 shape differential

Comment on lines +153 to +154
fields = {field: value for field in group if (value := getattr(copied_delta, field, None))}
fields = _CombinedChunkSplitter._normalize_reasoning_fields(fields)

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.

🟡 Split logic rebinds a local variable, against the project's no-mutation convention

The per-piece payload value is built and then immediately overwritten by reassigning the same local name (fields = _CombinedChunkSplitter._normalize_reasoning_fields(fields) at litellm/llms/anthropic/experimental_pass_through/adapters/streaming_iterator.py:154), which the repository's coding guidelines forbid.
Impact: The new code deviates from the required style for this repository, making it harder to review and maintain.

Rule text and a conforming shape

CLAUDE.md (mandatory via AGENTS.md) lists under coding conventions: "No mutation; don't reassign variables, global or local." Line 153 builds fields from the copied delta and line 154 rebinds the same name to the normalized result. Composing in one expression, e.g. normalized_fields = _CombinedChunkSplitter._normalize_reasoning_fields({field: value for field in group if (value := getattr(copied_delta, field, None))}), keeps a single binding.

Suggested change
fields = {field: value for field in group if (value := getattr(copied_delta, field, None))}
fields = _CombinedChunkSplitter._normalize_reasoning_fields(fields)
normalized_fields = _CombinedChunkSplitter._normalize_reasoning_fields(
{field: value for field in group if (value := getattr(copied_delta, field, None))}
)
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@yucheng-berri yucheng-berri Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correction: the PR merged at bc12ede before this cleanup landed, and the follow-up was dropped as style-level; the rebinding remains in the merged code. Verified behavior-neutral either way over a 720 shape differential

@yucheng-berri
yucheng-berri merged commit 1018d18 into litellm_internal_staging Jul 31, 2026
77 checks passed
@yucheng-berri
yucheng-berri deleted the litellm_anthropic_mixed_split_fixes branch July 31, 2026 02:05
@yucheng-berri

Copy link
Copy Markdown
Contributor Author

@greptileai please review the current head 1e2c9c2

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.

[Bug]: Anthropic /v1/messages + NVIDIA NIM Nemotron 3 Ultra fails in Claude Code with Content block is not a thinking block

3 participants