Repository navigation
fix(anthropic): stop repeating streamed thinking text in the signature chunk - #44127
Conversation
…e chunk The Anthropic stream handler emitted, on the signature_delta, a thinking block carrying every thinking delta seen so far plus the signature, after it had already streamed that text as per-delta blocks. Every additive consumer (the Agents SDK, stream_chunk_builder, the Responses bridge) stored the text twice under one signature and replayed the doubled block on the next turn The signature chunk now carries a signature-only block, matching the Bedrock emitter, so accumulators rebuild the text once and the saved history replays to Anthropic exactly as it was streamed
|
I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".
|
|
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
|
bugbot run |
|
bugbot run |
| assert second.status_code == 200, second.text | ||
| second_deltas: Final = deltas_of(chunks_of(second.text)) | ||
| assert content_text(second_deltas) == answer(marker), second.text | ||
| assert all(block == thinking_block(THINKING, SIGNATURE) for block in accumulate(second_deltas)), second.text |
There was a problem hiding this comment.
Cache test can pass without thinking
If the cached stream has no thinking_blocks, accumulate(second_deltas) returns an empty tuple and all(...) still passes. The test would report success even though the cache replay lost the signed thinking it is meant to check. Compare the accumulated blocks with the expected block directly.
| assert all(block == thinking_block(THINKING, SIGNATURE) for block in accumulate(second_deltas)), second.text | |
| assert accumulate(second_deltas) == (thinking_block(THINKING, SIGNATURE),), second.text |
There was a problem hiding this comment.
Direct equality fails because the cached replay drops thinking blocks before this PR, tracked separately. The check now admits only empty or one signed block
There was a problem hiding this comment.
That context makes sense. Since cached stream replays dropping thinking_blocks is a separately tracked pre-existing behavior, requiring the expected block here would make this PR’s test fail for an unrelated issue. The current assertion still rejects the regression this PR targets (a duplicated or otherwise unexpected non-empty block) while allowing the known empty replay, so I’m withdrawing the finding.
|
bugbot run |
There was a problem hiding this comment.
✅ 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 775fe9f. Configure here.
TLDR
Problem this solves:
How it solves it:
User Flow
Before: a developer whose Agents SDK app runs Claude with extended thinking saves a tool-call turn and finds the thinking text doubled under one signature
LitellmModel("anthropic/claude-sonnet-4-5"),Reasoning(effort="medium")) streams a Claude turn that thinks, then callsget_weatherandget_local_timefor Parisresult.to_input_list()to JSON and open the reasoning item:thinking_blocks[0].thinkingis twice as long as the thinking they watched stream, the same text repeated once, under one signaturePOST https://api.anthropic.com/v1/messages; Claude accepts it, so every later turn pays input tokens for the thinking twice and any UI of the saved history shows it twicePOST https://litellm-domain/v1/responseswith"stream": true, returns areasoningitem whoseencrypted_contentholds the doubled textAfter: the same turn saves the thinking text exactly once
LitellmModel("anthropic/claude-sonnet-4-5"),Reasoning(effort="medium")) streams a Claude turn that thinks, then callsget_weatherandget_local_timefor Parisresult.to_input_list()to JSON and open the reasoning item:thinking_blocks[0].thinkingequals the thinking they watched stream, once, with the same signaturePOST https://api.anthropic.com/v1/messages; Claude accepts it and the history carries each thought oncePOST https://litellm-domain/v1/responseswith"stream": true, returns areasoningitem whoseencrypted_contentholds the text onceLinear ticket
Resolves LIT-9150
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/unit/<your_test_file>.py -v. Leave the suites (make test-unit-*,make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more@greptileaito re-request a review after pushing changes)Delays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
Screenshots / Proof of Fix
Last updated: 775fe9f. QA and /live-pr-risk ran on 1955444, the last commit with a product diff; the commits since are the audit's tests-only commits 6e40e47 and 775fe9f
QA ran at 1955444, the last commit with a product diff. Before = merge base 3f39fef, After = 1955444. Each leg is its own worktree and venv, its own proxy booted with
litellm --config proxy_config.yaml --host 127.0.0.1 --port <random port> --num_workers 2and no database, real Anthropic calls toclaude-sonnet-4-5, OpenAI Agents SDK 0.22.3 on openai 3.20.0. The scripts and curls below are identical for both legs; only the proxy's commit differsproxy_config.yamlCase 1: Agents SDK
LitellmModel("anthropic/claude-sonnet-4-5"), in processThe app streams a Paris tool-call turn, saves
result.to_input_list()to JSON, reloads it, and runs a Rome turn on top.thinking charsis the savedprovider_data.thinking_blocks[0].thinkingthe SDK will replay to Claude;streamedis the reasoning the user watched arrivecase1_sdk_litellm_model.py
Before (3f39fef): the saved block is the streamed thinking twice, and the Rome turn replays the doubled block
After (1955444): the saved block equals the streamed thinking, once, still signed, and the Rome turn succeeds on it
Case 2: Agents SDK
OpenAIResponsesModelthrough the proxy,POST http://127.0.0.1:<port>/v1/responseswith"stream": trueSame script, with
set_default_openai_client(AsyncOpenAI(base_url="http://127.0.0.1:<port>/v1", api_key=os.environ["LITELLM_MASTER_KEY"])),model="claude-sonnet-4-5", and the savedreasoningitem'sencrypted_contentdecoded withjson.loadsinstead of readingprovider_dataBefore (3f39fef)
After (1955444)
Case 3:
POST /v1/chat/completionsstream with thinking enabledBefore (3f39fef): the signed delta repeats the whole thinking text
After (1955444): the signed delta carries the signature only
Case 4:
POST /v1/messagesstream with thinking enabled (unchanged surface)Before (3f39fef)
After (1955444)
Observations from the legs
/v1/responsesputs reasoning text incontent, notsummary; pre-existing, left alone/v1/responsesemits an empty message item before tool calls; pre-existing, left alone/live-pr-risk at 1955444
Two proxies A/B'd from this PR's merge base 3f39fef and from 1955444, each with its own native Postgres (schema migrated by the proxy's own boot),
--num_workers 2,store_prompts_in_spend_logs: true, a recording reverse proxy in front ofapi.anthropic.comfor the Responses bridge scenario, and real calls to Anthropic, Bedrock, and Vertex AI. Nine scenarios ran on both sides in the same order with the same inputs; spend rows were read back fromGET /spend/logsafter each one. Then the top of the list ran a third time on 1955444 merged withorigin/main(d729f97, five commits of drift,uv.lockincluded): the merge applied clean, and all nine scenarios on the merged tree matched the head side (signed deltas with no text, the spend log'sthinking_blocksholding the text once, the Responses bridge replaying a 354-char block equal to the streamed text, non-stream unchanged, the deleted key answering 401)Breaking
POST /key/deletegot 401 on the base side and 200 on the head side. The two workers each keep an in-memory copy of the key foruser_api_key_cache_ttl(60 s default), the delete clears only the worker that served it, and the follow-up call landed on the other worker on the head side. The diff toucheslitellm/llms/anthropic/chat/handler.pyand its test only, so this is the rig's two-worker setup, not this PRBackward incompatible
thinking_blocksdelta on streamed chat chunks now carriesthinking: ""plus the signature, where it repeated the whole text. Observed on direct Anthropic (408 chars to 0), Bedrock invoke (445 to 0), Vertex AI Claude (429 to 0), and the router fallback replay (429 to 0), each with the per-delta blocks unchanged and the spend log's rebuiltthinking_blocksholding the text once (816 to 439, 890 to 469, 858 to 435, 858 to 437). A client that reads only the signed delta and ignores the deltas before it now gets no text; every client that accumulates deltas (the Agents SDK, the Responses bridge,stream_chunk_builder) gets the text once instead of twice. This is the change the PR ships (1955444); the alternative, text in the signed delta too, is the bug itselfprovider_specific_fields.thinking_blocks(spend logresponse, and whatstream_chunk_builderhands a caller) now mirrors the signature-only chunk instead of the full block: direct chat 408 chars to 0,/v1/messages519 to 0, Bedrock invoke 445 to 0, Vertex 429 to 0, pass-through 343 to 0, fallback 429 to 0. The message's ownthinking_blocks, the field LiteLLM documents for replay, carries the text once in every one of those rows. Left as is: merging the text back into the signature chunk's copy would put the text on the wire twice for every client that accumulates deltas, which is what this PR removes; a consumer ofprovider_specific_fields.thinking_blockson a rebuilt stream was not found in the repo. Open for the reviewer's callRegression risk
claude_platform, and Azure AI Anthropic deployments reuse this streaming iterator and were traced, not run: the rig has no deployment for them (no Snowflake orclaude_platformcredentials on this box; Azure AI and mantle credentials exist but no deployment was added in this run). Exercising them is onemodel_listentry each in the rig config and a rerun of the direct-stream scenario/v1/messagesadapter for non-Anthropic models does not run this iterator (traced, not on the changed path)Dependency graph
ModelResponseIteratorsignature chunk (the changed code): unit tests intests/unit/llms/anthropic/chat/test_anthropic_chat_handler.py(per-chunk shape and the next-turn replay); verified live, direct Anthropic streamCustomStreamWrapperto client chunks: verified live on direct, Bedrock invoke, Vertex, and fallback streamsstream_chunk_builderto the spend logresponseand to callers: verified live, spend rows of every stream scenario/v1/messagesroute and its spend log: verified live (signature_delta carried no text before or after; the spend row's block halves)/anthropic/v1/messagespass-through logging handler (rebuilds the response with the iterator): verified live, spend row halvesLiteLLMCompletionResponsesConfig, streaming iterator): verified live with the recorder; the Rome turn replays a 264-char signed block that equals the streamed text, where the base replayed 588 chars (the text twice) on three later requestsLitellmModelin process: verified live in the QA legs aboveclaude_platform, Azure AI Anthropic: traced, untested livetests/llm_translation/test_anthropic_completion.py,tests/local_testing/test_streaming.py,tests/llm_translation/test_bedrock_completion.py,-k "thinking or reasoning or signature"): 15 passed, 1 skipped (test_bedrock_thinking_in_assistant_message[False], the suite's own skip)Not verified
claude_platformstreaming (no credentials on this box)Audit round 1 of 3
/audit of tip
19554443c80fe1dba329996aa03fa3f5cb65c613against merge base3f39fef52f85b9cc161abba6bb47ca6740801200. Rig: owned Postgres, Redis, and a scripted Anthropic-wire upstream, one 2-worker proxy per leg, built fromtests/integration/providers/test_anthropic_thinking_signature_stream_wire.pyandtest_anthropic_thinking_signature_logging_wire.py(--seed 44127 --order-seed 44127, head run twice with identical selections and results). T is the scripted thinking text, S its signature. "red" on the merge base is the fix-sensitive thinking assertion (doubled T under one S), "green" on both legs is a controltest_openai_sdk_sync_stream_accumulates_the_thinking_oncetest_openai_sdk_async_stream_accumulates_the_thinking_oncetest_signature_chunk_carries_no_thinking_text_on_every_anthropic_wire_provider[anthropic]test_non_streaming_completion_keeps_the_signed_thinking_block_intact..._on_every_anthropic_wire_provider[bedrock_invoke]..._on_every_anthropic_wire_provider[claude_platform]..._on_every_anthropic_wire_provider[vertex_ai]..._on_every_anthropic_wire_provider[snowflake]..._on_every_anthropic_wire_provider[azure_ai]test_responses_stream_encrypts_the_thinking_oncetest_responses_non_stream_encrypts_the_signed_block_as_receivedtest_chat_stream_spend_row_stores_the_thinking_oncetest_cache_hit_replays_the_answer_from_one_upstream_call_and_never_doubles_the_thinkingtest_native_messages_stream_through_the_anthropic_sdk_logs_the_thinking_oncetest_native_messages_stream_on_bedrock_mantle_logs_the_thinking_oncetest_adapter_messages_stream_on_snowflake_logs_the_thinking_oncetest_anthropic_passthrough_stream_relays_the_frames_and_logs_the_thinking_oncetest_bedrock_invoke_passthrough_stream_relays_the_frames_and_logs_the_thinking_once:streamRawPredictpass-through, Claude Sonnet 5.5test_two_signed_blocks_each_keep_their_own_text_through_a_replaytest_redacted_block_before_a_signed_block_replays_each_oncetest_signature_only_block_without_thinking_deltas_is_relayed_as_istest_replaying_the_accumulated_turn_sends_the_thinking_once_with_its_signaturetest_two_identical_requests_with_no_cache_each_land_their_own_spend_rowtest_unusable_signature_values_yield_no_signed_block_and_keep_the_stream_intact[integer]..._keep_the_stream_intact[list]..._keep_the_stream_intact[empty]test_five_kilobyte_signature_is_relayed_verbatim_without_thinking_texttest_duplicate_signature_deltas_never_repeat_the_thinking_texttest_non_string_thinking_delta_is_ignored_and_the_signed_block_still_lands_oncetest_upstream_authentication_error_reaches_the_caller_and_leaves_the_proxy_healthytest_unauthenticated_stream_is_refused_before_the_upstream_is_calledtest_mid_thinking_upstream_aborts_in_a_mixed_burst_leave_every_completed_call_logged_oncetest_worker_sigkill_mid_burst_leaves_the_sibling_streaming_signed_thinking_onceLitellmModelreplay, Claude Sonnet 5.5Live rows ran once per leg against the real Anthropic API and Vertex AI with adaptive thinking (
reasoning_effort: high,display: summarized). On the merge base the Agents SDK saved the thinking text twice under one signature and sent it back doubled on turn 2 (the API still answered 200); on the tip it saved and replayed the text once. H6 (Bedrock Mantle on/v1/chat/completions) was dropped from the inventory because that surface never touches the Anthropic iteratorThree pre-existing behaviors outside the diff were ticketed internally: a cached stream replay drops
thinking_blocksandreasoning_contenton both legs, the Anthropic pass-through logging handler only runs when the upstream hostname isapi.anthropic.com, and the router pass-through keeps theinvoke/route marker of abedrock/invoke/...deployment in the upstream model pathRerun at 775fe9f (tests only, tightens the cache cell's replay assertion to exactly an empty tuple or one signed block): H14 ran again on both legs, green on the tip and red on the merge base at the first-stream thinking assertion as before (the stream module: 27 passed on the tip, 20 failed and 7 passed on the base, the same red set as the first base run); every other row keeps its result from the run at 6e40e47
Type
🐛 Bug Fix
Caveats (if any)
Low
/v1/messagesadapter already turned a signed block into asignature_deltaand dropped that chunk's text, so its output is unchangedprovider_specific_fields.thinking_blockschange on rebuilt streams is recorded under Backward incompatible in the /live-pr-risk section above, with the decision to leave itFinal 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
1955444 passes /live-pr-risk
Note
Medium Risk
Changes streamed chat
thinking_blocksshape on the signature chunk (intentional contract shift); clients that read only that delta lose inline text, though downstream accumulation and logging paths are heavily tested.Overview
Fixes doubled extended-thinking text when Claude streams a
signature_delta: the Anthropic chat stream iterator no longer re-emits the full accumulated thinking in that chunk and instead attaches a signature-onlythinking_blocksentry (thinking: ""plus the signature). Per-delta thinking streams unchanged viareasoning_contentand unsigned thinking blocks, so accumulators (stream_chunk_builder, Agents SDK, Responses bridge) rebuild one signed block with the text once.Tests add shared wire fixtures and broad integration coverage (chat/messages/responses, multiple providers, spend logging, replay, cache, chaos) plus updated unit expectations and a round-trip replay test.
Reviewed by Cursor Bugbot for commit 775fe9f. Bugbot is set up for automated code reviews on this repo. Configure here.