Skip to content

fix(streaming): stop stream_chunk_builder duplicating Anthropic thinking text - #35118

Open
vineethsaivs wants to merge 13 commits into
BerriAI:litellm_internal_stagingfrom
vineethsaivs:fix-thinking-block-duplication-stream-chunk-builder
Open

fix(streaming): stop stream_chunk_builder duplicating Anthropic thinking text#35118
vineethsaivs wants to merge 13 commits into
BerriAI:litellm_internal_stagingfrom
vineethsaivs:fix-thinking-block-duplication-stream-chunk-builder

Conversation

@vineethsaivs

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • stream_chunk_builder() returns Anthropic extended-thinking reasoning twice over, so the rebuilt thinking_blocks[0]["thinking"] is the whole reasoning trace concatenated with itself
  • reasoning_content on that same rebuilt response is correct, so the two fields disagree about what the model actually reasoned

How it solves it:

  • Skip appending a thinking block's text when it arrives with a signature and is exactly what has already been accumulated, which is the shape the Anthropic iterator produces

Relevant issues

None open for this one. #33034 and its PR #33035 cover a different failure in the same function (thinking dropped when no signature ever arrives); this is orthogonal and the two touch different parts of the function, so they should not conflict beyond a trivial adjacency

Linear ticket

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

I could not produce the live-proxy curl output the template asks for, because I do not have an Anthropic key I am able to spend on this, so I want to be upfront about that rather than pass off a test run as proof. What follows is what I could verify offline, and below it are the exact commands to reproduce against a real key

The mechanism, driving the real ModelResponseIterator from litellm/llms/anthropic/chat/handler.py over the event sequence Anthropic sends for one thinking block, then handing the resulting chunks to ChunkProcessor:

=== normal turn, signature arrives
  expected thinking : 'Let me work through this step by step.'
  before            : 'Let me work through this step by step.Let me work through this step by step.'
  after             : 'Let me work through this step by step.'
  reasoning_content : 'Let me work through this step by step.'    (correct both ways)

Two separate signed thinking blocks in one turn stay separate and are each emitted once, before and after

To reproduce against a real key, with the proxy on localhost:4000 and a config exposing an Anthropic model with thinking enabled:

curl -sS http://localhost:4000/v1/chat/completions \
  -H 'Content-Type: application/json' \
  -H "Authorization: Bearer $LITELLM_MASTER_KEY" \
  -d '{
    "model": "claude-sonnet-4-5",
    "messages": [{"role": "user", "content": "What is 17 * 23? Think it through."}],
    "thinking": {"type": "enabled", "budget_tokens": 1024},
    "max_tokens": 2048,
    "stream": true,
    "stream_options": {"include_usage": true}
  }' > /dev/null

Then open http://localhost:4000/ui/?page=logs, click into that request, and compare the assembled response's thinking_blocks[0].thinking against reasoning_content. On litellm_internal_staging the first is the second repeated twice; with this PR they match

Type

Bug Fix

Changes

get_combined_thinking_content accumulates a block's text across chunks and flushes when a signature arrives. Anthropic streams a thinking block as N thinking_delta chunks followed by one signature_delta, and ModelResponseIterator._handle_content_block_delta builds that final chunk by joining every prior thinking delta:

signature = content_block["delta"].get("signature")
if isinstance(signature, str) and signature:
    thinking_blocks = [
        ChatCompletionThinkingBlock(
            type="thinking",
            thinking="".join(
                cast(str, block["delta"].get("thinking"))
                for block in self.content_blocks
                if isinstance(block["delta"].get("thinking"), str)
            ),
            signature=signature,
        )
    ]

So the signature chunk carries the block's whole text again rather than an increment, and appending it on top of the accumulated parts doubled the reasoning. The existing test for this function did not catch it because its fixture models the signature chunk as {"type": "thinking", "thinking": None, "signature": "sig_block1"}, which is not what the iterator emits

The guard is an exact-equality check, so it only suppresses a genuine repeat. A provider that sends the last increment alongside the signature still has that increment appended, which the second test pins

QA runbook

Beyond the curl above, worth exercising a turn that produces two separate thinking blocks and one that is cut short by max_tokens mid-thinking, since both go through this function. The truncated case still returns no thinking blocks, unchanged by this PR and left to #33035

Unit tests, tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_utils.py: 19 passed. With the fix reverted, test_get_combined_thinking_content_does_not_duplicate_resent_thinking fails and the other 18 pass. Mutating the guard so it always skips instead of skipping only exact repeats makes test_get_combined_thinking_content_keeps_a_genuine_final_increment fail and the other 18 pass, so each new test is killed by its own mutant and neither is decorative

Whole directory tests/test_litellm/litellm_core_utils/: 4 failed, 1400 passed. The same 4 fail on a clean tree with 1398 passed, so they are pre-existing (test_bedrock_converse_messages_pt_document_various_formats, test_bedrock_midstream_internal_server_error_wraps_for_fallback, test_blocks_ietf_protocol_assignments_old_oracle_metadata, test_logfire_logger_accepts_env_vars_for_base_url) and the delta is exactly the 2 tests added here

ruff 0.15.3 reports one unused-import error and one file needing reformat in these two files, both of which reproduce identically on a clean tree, so neither comes from this change. My longest added line is 99 characters

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

yuneng-berri and others added 13 commits July 21, 2026 19:03
chore(ci): promote internal staging to main
chore(ci): promote internal staging to main
chore(ci): promote internal staging to main
chore(ci): promote internal staging to main
_await_model_servable used poll_timeout (120s), the spend/log read-back
budget. A stuck model reload therefore stalled every suite that creates a
deployment for two minutes before failing

Give create_model a fixed harness middle ground: model_servable_timeout=40s,
polled every 2s, with each /v1/models call capped at 5s and clamped to the
remaining deadline so one slow GET cannot overrun the wait. Happy path still
returns on the first listing. Not derived from proxy general_settings or env

Transport.get accepts an optional per-call timeout for that clamp. Unit tests
cover the deadline arithmetic and clamp without a live proxy

(cherry picked from commit c082a0e)
create_model returned after the first /v1/models hit that listed the model,
so chat could still land on a cold gateway worker (numWorkers>1 / peer pod)
and 400 Invalid model name. Require continuous listing for the product
default add_deployment interval (30s) after first sight so every worker has
synced from the DB; first listing still bounded at 40s

(cherry picked from commit 7d1ee2f)
Keep the create_model DB-sync wait in the harness; the pure-function unit
file is not needed for this PR

(cherry picked from commit 8920465)
When less than one full poll interval remained in the first-listing budget,
the pre-sleep check returned NotServable without another /v1/models call.
Sleep only min(interval, time left) so a model that becomes listable in the
last seconds of the timeout still gets a clamped final poll

(cherry picked from commit 8439195)
A poll may start with remaining budget and still return after started+timeout
if the transport overruns its clamp. Recheck the first-listing deadline after
the response so a late listing does not open the continuous DB-sync phase

(cherry picked from commit 7ff2bcb)
…l_servable_timeout

test(e2e): bound the post-/model/new servable wait at 40s
* fix(mcp): resolve call_tool by registry without requiring tool map

Multi-worker reloads put MCP servers in the registry from the DB but do
not re-run tools/list on every process. Gating call_tool on
tool_name_to_mcp_server_name_mapping made cold workers 500 with Tool not
found after another worker had already listed the tool. Treat a registry
match on server id/name/alias as enough; upstream rejects unknown tools

* test(e2e): poll MCP register, tools/list, and tools/call across multi-worker lag

Stage multi-worker gateways only load MCP servers and tool maps on the
process that handled the request. Poll until the server is listed, the
tool appears on tools/list, and tools/call is not a cold-worker 500 so
key-access and Datadog MCP e2e stop racing the LB

* Revert "fix(mcp): resolve call_tool by registry without requiring tool map"

This reverts commit 8b56e51.

* test(e2e): tighten MCP multi-worker lag classifier

Only retry tools/call on gateway shapes Tool <name> not found and
server_not_found, not any 500 that mentions tool/server not found, so
upstream failures are not retried until the poll deadline

* test(e2e): drop unit file for MCP lag classifier

The live await_call_tool polls already cover multi-worker lag; a separate
string-match unit module is not worth keeping

(cherry picked from commit c274cf3)
…p_e2e_poll

test(e2e): poll MCP tools across multi-worker lag (BerriAI#35047)
…ing text

Anthropic streams a thinking block as N thinking_delta chunks and then one
signature_delta. The Anthropic iterator builds that signature chunk by joining
every prior thinking delta, so it carries the whole block's text again rather
than an increment.

get_combined_thinking_content appended that text on top of the parts it had
already accumulated, so a rebuilt extended-thinking response came back with its
reasoning emitted twice. reasoning_content on the same response was correct,
which is what makes the two disagree.

Skip the append when a block arrives with a signature and its text is exactly
what has been accumulated so far. A provider that sends only a final increment
alongside the signature is unaffected, since the texts differ.
Comment on lines +390 to +391
"""
if not isinstance(result, UnknownApiError) or result.status_code != 500:

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.

P1 Registry misses bypass polling

When a cold data-plane worker returns the MCP endpoint's HTTP 404 server_not_found response, this status gate returns False before inspecting the structured error, causing await_call_tool and await_call_tool_denied to fail immediately instead of waiting for registry propagation.

Comment thread tests/e2e/proxy_client.py
Comment on lines +154 to +163
if not listed:
first_seen_at = None
elif first_seen_at is None:
if t > started + timeout:
return NotServable(last_result=last_result)
first_seen_at = t
if db_sync_seconds <= 0:
return Servable()
elif t - first_seen_at >= db_sync_seconds:
return Servable()

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.

P1 Continuous listing misses cold workers

When the load balancer routes every /v1/models poll to the same synchronized worker, this continuous-listing window succeeds even though another worker has not loaded the model, causing the next model request routed to that cold worker to fail with Invalid model name.

@greptile-apps

greptile-apps Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes duplicate signed thinking text during streamed-response reconstruction and also revises unrelated E2E synchronization helpers.

  • Suppresses a signed thinking snapshot when it exactly matches the text already accumulated.
  • Adds regression tests for resent snapshots and genuine final increments.
  • Adds MCP tool-call polling and model-servability polling to the E2E harness.
  • Adds per-request timeout overrides to E2E transports.

Confidence Score: 3/5

The streaming fix is sound, but both E2E synchronization helpers need correction before merging because they still release or fail callers during multi-worker propagation.

The MCP retry classifier rejects the endpoint's actual 404 registry-miss response, while the model-servability poll can repeatedly observe one hot worker and incorrectly conclude that all workers have loaded the model.

Files Needing Attention: tests/e2e/mcp/mcp_client.py, tests/e2e/proxy_client.py

Important Files Changed

Filename Overview
litellm/litellm_core_utils/streaming_chunk_builder_utils.py Correctly avoids appending Anthropic's repeated signed thinking snapshot while retaining distinct signed increments.
tests/test_litellm/litellm_core_utils/test_streaming_chunk_builder_utils.py Adds focused coverage for snapshot deduplication and preservation of genuine final increments.
tests/e2e/mcp/mcp_client.py Adds registry-lag polling, but the status gate prevents retrying the endpoint's HTTP 404 server-not-found response.
tests/e2e/proxy_client.py Adds model-list polling whose continuous visibility window can observe only a hot worker and still release callers before every worker has synchronized.
tests/e2e/transport.py Propagates an optional request-specific timeout through both HTTP and split transports.

Reviews (1): Last reviewed commit: "fix(streaming): stop stream_chunk_builde..." | Re-trigger Greptile

@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing vineethsaivs:fix-thinking-block-duplication-stream-chunk-builder (eb30b9e) with litellm_internal_staging (c56e657)1

Open in CodSpeed

Footnotes

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

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.

3 participants