Repository navigation
fix(anthropic): keep the replayed prefix byte-stable for preserved thinking on chat completions - #42630
Conversation
Pure functions over the OpenAI-format message list: split off the leading system run, keep later system messages as role=system at a placement Anthropic accepts on models flagged supports_mid_conversation_system (after a user turn, before an assistant turn or the end, never adjacent), and convert them to user turns in place elsewhere, keeping tool_result first in a merged user turn.
…ons system prompt translate_system_message hoisted every role=system message, at any index, into the top-level system block. On a conversation carrying a mid-session reminder that rewrites the cached prefix, so the provider re-bills the whole history at cache-write pricing on every turn (#36559). #36968 fixed this on /v1/messages; the chat completions path, shared by first-party Anthropic, Vertex, Azure AI and Bedrock Invoke, still hoisted. Only the leading system run becomes the system prompt now. Later system messages go through the placement policy, and anthropic_messages_pt emits a system message instead of rejecting the role. The caller's message list is no longer mutated. Tests pin the two-turn prefix invariant across all four chat configs and both flag states.
The /v1/messages pass-through and the chat completions path must prefix a converted system turn with the same operator note.
…reminder on chat completions Same priming and assertions as the /v1/messages cases, through /v1/chat/completions with OpenAI-format messages, for first-party Anthropic and Bedrock Invoke on a flagged (Opus 4.8) and an unflagged (Haiku 4.5) model. The reminder sits between the assistant turn and the next user turn, the shape OpenAI-style agent frameworks send, which is the placement the chat path has to translate.
…test helpers Codecov flagged the 5m ttl branch and the empty-system path of the wire builder; both now have a test. Greptile asked for full typing on the new test helpers.
…supports_ helper supports_mid_conversation_system joins the other supports_* helpers in litellm.utils, so the chat transformation stops importing the private _supports_factory.
The Final sweep tightened LIT010, which exempts TypeAlias declarations but counts a bare alias assignment as an unannotated binding.
…sage union The translator now emits role=system inside messages for models that accept it, so anthropic_messages_pt returns the pass-through union. add_code_execution_tool still declared the narrower user/assistant union while only ever reading content, so upstream's strip_advisor_blocks_from_messages call in between made the mismatch visible to the type checker.
- non-dict guard in _opens_with_tool_result - in-place conversion without tool context - str/list cache_control preservation in mid-conversation path - drop unreachable non-system guard in hoisted loop
…s only A run after an assistant turn now slides behind the user turn that immediately follows it, and a run that ends the array or precedes an assistant turn becomes a user turn in place. No later message can move an earlier run, so a client that replays the conversation with more turns appended sends a byte-identical prefix and preserved thinking blocks keep their binding
… module Converse imports CONVERTED_SYSTEM_NOTE instead of carrying its own copy of the same text, and the reordering helpers lose their comments
One test per audited feature, through the real entrypoint: the chat transformations for anthropic, bedrock invoke, vertex and converse, the modify_params dummy tool result, dotprompt with unchanged variables, and Presidio masking against an in-process fake. Each serializes system, tools and the earlier messages of turn N and N+1 and asserts they match. The e2e mid-conversation system test imports its content blocks from models.py again and is marked provider_live
|
I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".
|
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
…mplates The prompt factory imported the placement helper from the Anthropic provider package, whose common_utils reads a factory constant at import time, so loading the factory first raised ImportError. The module now sits next to anthropic_messages_pt and every consumer imports core utils A user turn with content [] or None puts no block on the wire, so a system run anchored to it landed first in messages or behind an assistant turn. Such a run now converts in place; empty strings and empty text blocks still anchor because the factory fills them with a placeholder
|
bugbot run |
|
Thanks for carrying the Bedrock side over with attribution. Checked the commit list here: it includes the final green state of #41057, role narrowing in the hoisted loop and the gate-suppression placement included, so nothing from our branch is missing. I'll keep #41057 open until this merges and close it after; say the word if you'd rather it close now. |
…rs and convert a system run whose assistant follower sends nothing A history that replays the previous turn as the litellm.Message object was invisible to the Converse system-message helpers, so a mid-conversation system stayed between a tool call and its result or reached Converse as role: system. The helpers now read fields through the shared message_field and parts_of accessors and drop the local role predicate. Flagged placement anchored a system run on any assistant follower, but anthropic_messages_pt drops an assistant turn that puts no block on the wire (content None, an empty list, an unsigned thinking part), so the system landed directly before the next user turn, which Anthropic rejects. Such a run now converts in place. An empty or whitespace text turn still anchors, since the converter pads it with a placeholder.
…plit the stacked comprehensions the new LIT014 rule flags
|
bugbot run |
…stant turn for system placement An assistant turn whose only blocks carry Responses API encrypted reasoning is dropped by anthropic_messages_pt, so a mid-conversation system run anchored before it landed directly before the next user turn. The unsignable-thinking predicate now lives in common_utils and both the factory and the placement policy consult it.
|
bugbot run |
…ocks in system placement anthropic_messages_pt skips an assistant turn's separate thinking_blocks as soon as its content list carries an inline thinking or redacted_thinking part, so a turn whose inline part is unsigned puts nothing on the wire even when the separate block is signed. The placement policy now mirrors that rule.
|
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 e05e9dc. Configure here.
TLDR
Problem this solves:
systemmessage was hoisted into the top-levelsystempromptHow it solves it:
systemrun by its neighbours only, so appended turns never move itrole: systemin place; Converse and older models get a user turn with an operator noteUser Flow
Before: a developer whose app runs multi-turn conversations with preserved thinking on Claude Fable 5.1 through the proxy loses the model's reasoning, or the whole request, as soon as the app adds a mid-conversation system reminder
"model": "claude-fable-5-1", theanthropic-beta: thinking-binding-controls-2026-08-01header,"thinking": {"type": "adaptive", "block_binding": {"prefix_mismatch_behavior": "error"}}, a leading system message, and a user questionthinking_blocksentry and the answer{"role": "system", "content": "The user switched to read-only mode."}appended after itmessages.1.content.0: Invalid signature in thinking block. The block is bound to a different conversation ... The system prompt differs from the one this block was created with"prefix_mismatch_behavior": "drop_block"instead, the same turn returns 200 but the model answers without its earlier reasoning, and Anthropic's response (visible in the proxy's debug log) lists{"type": "thinking_dropped", "path": "messages.1.content.0"}underinput_transformations"model": "bedrock/invoke/us.anthropic.claude-fable-5-1", and with"model": "bedrock/converse/us.anthropic.claude-fable-5-1"theerrorsetting returns a 400After: the same second turn succeeds and the model keeps its reasoning
"model": "claude-fable-5-1", theanthropic-beta: thinking-binding-controls-2026-08-01header,"thinking": {"type": "adaptive", "block_binding": {"prefix_mismatch_behavior": "error"}}, a leading system message, and a user questionthinking_blocksentry and the answer{"role": "system", "content": "The user switched to read-only mode."}appended after it"input_transformations": [], so the signed block was accepted"prefix_mismatch_behavior": "drop_block"the same turn returns 200 with"input_transformations": []as well"model": "bedrock/invoke/us.anthropic.claude-fable-5-1"and with"model": "bedrock/converse/us.anthropic.claude-fable-5-1"Audit
Anthropic's block binding compares the top-level
system, thetoolslist, and every message ahead of a signed thinking block with what the block was created under.cache_controlmarkers are outside the comparison. Every proxy feature that rewrites messages between turns was checked live against Claude Fable 5.1systemon/v1/chat/completionsthrough the Anthropic transformation (anthropic, bedrock invoke; vertex and azure_ai share it)thinking_droppedunderdrop_block, 400 undererror; vertex hit its quota, azure_ai was not runmessages, placed by its neighbours onlysystemon Bedrock Converseerrorsystemon/v1/messagesinput_transformations: []thinking_droppedmodify_paramsdummy tool result for an orphaned tool callmodify_paramstrailing whitespace trim on assistant prefilltoolsis rebuilt from the server per requestcache_controlmarkersDesign decisions
role: system; a run right after an assistant turn slides behind the user turn that immediately follows it, and becomes a user turn in place when the array ends or an assistant turn follows. The alternative, sliding to the nearest later user turn wherever it is, lets a turn appended on request N+1 move a run placed on request N, which is exactly the prefix change the binding rejectssystemrole rather than being demoted to user text (the note raised on fix(anthropic): keep mid-conversation system out of the chat completions system prompt #38053). Anthropic acceptsrole: systeminsidemessageson these models in the placed shape, and demoting would rewrite the same prefix while dropping the instruction's authorityrole: systeminsidemessagesand older Claude models reject it anywhere but the top; hoisting breaks the prefix, dropping the message loses the instruction, and a user turn without the note would read as if the user wrote it. On Converse this applies to every model, the non-Claude ones (Nova, Llama, Mistral, DeepSeek, Writer) included, because the surface has nosystemrole insidemessagesfor any of them and the leading-run hoist plus in-place conversion is one behavior per surface. The alternative, keeping the old hoist for non-Claude models, was rejected: the hoist rewrites the top-level system between turns, which also invalidates thecachePointprefix those models cache through, and it loses the reminder's position in the conversation; a per-model split would have made one Converse deployment's reminders read differently from the nextsystemmessage, because Anthropic rejects consecutivesystemmessagescache_controlis left alone: it is outside the comparison and stripping it would cost prompt caching for nothingCONVERTED_SYSTEM_NOTEfrom the shared placement module. The two texts were byte-identical copies, and a drifting copy would make the same conversation read differently per providerapi_basesettings, so the real HTTP path runs without patching class attributeslitellm/litellm_core_utils/prompt_templates/next toanthropic_messages_pt, not underlitellm/llms/anthropic/. The factory needs its wire helper, and a core-utils module importing a provider package loadslitellm/llms/anthropic/__init__, whosecommon_utilsreads a factory constant before the factory has defined it; the cycle stayed latent only becauselitellm/__init__happens to load the Anthropic package first, and importing the factory first raisesImportError: cannot import name 'THOUGHT_SIGNATURE_SEPARATOR' from partially initialized module. A lazy import would hide the same inversion, so the module moved and every consumer (the factory, the Anthropic chat transformation, Bedrock Converse) now imports core utilscontent: []orcontent: null) cannot anchor a system run.anthropic_messages_ptemits no block for it, so a run anchored there landed first inmessagesor behind an assistant turn (Greptile's empty-anchor finding, reproduced); such a run now converts in place, which is what it would have done had the empty turn not been there. Empty strings and empty text blocks still anchor because the factory fills them with a placeholderinput_audio, unknown types) counts as empty too._rendersmirrors the converter's part types (text,image_url,document,file) instead of testing for a non-empty list, and one test runs every part type throughtransform_requesttwice, with and without a reminder behind the turn, asserting the reminder stayssystemexactly when the bare turn reaches the wire, so a converter that learns a new part type fails the test until the policy follows (Greptile's second empty-anchor finding, reproduced)Sequence[AllMessageValues],ChatCompletionSystemMessage -> ChatCompletionUserMessage | None) and build the converted turn in one shot. A mid-conversation system entry that carries no text converts toNoneand is dropped, which replaces the earlierlen(content) > 1check and is pinned bytest_mid_conversation_system_entry_without_text_is_dropped. The role and shape checks are plain predicates because the type-discipline gate bansTypeGuard; the twocasts sit right behind the check that justifies them (Greptile's P2 on coarse types)message_fieldandparts_ofaccessors instead ofisinstance(message, dict)checks. The SDK path replays the previous turn as alitellm.Messageobject, which those checks silently skipped, so a mid-conversation system stayed between a tool call and its result or reached Converse asrole: system; the local role predicate is gone and Converse sharesis_system_messagewith the placement policy (Bugbot's High finding, reproduced with a pydantic assistant turn on both the reorder and the conversion paths)anthropic_messages_ptemits no turn forcontent: null,content: [], or a list holding only an unsigned thinking part, so a run anchored before such a turn landed directly before the next user turn, which Anthropic rejects;_anchor_blocknow asks_assistant_rendersabout the turn after the anchor and converts the run in place when it vanishes. Empty and whitespace-only text (a string or a text part) still counts as rendering because the converter pads it with its placeholder, and a test runs each padded shape through the policy to pin that (Bugbot's Medium finding, reproduced)anthropic_messages_ptdrops it whether the tag sits in aredacted_thinkingblock'sdataor athinkingblock'ssignature, so an assistant turn made only of such blocks vanishes from the wire and the run anchored before it has to convert. The predicate the factory used for that moved intocommon_utilsasis_unsignable_thinking_block, next tois_encrypted_reasoning_block, so the factory and the placement policy consult one rule instead of two copies that can drift (Greptile's P1 at the merged tip, reproduced with both encrypted shapes at the policy level and throughtransform_request)thinking_blocksreach the wire only when the content list carries no inlinethinkingorredacted_thinkingpart:anthropic_messages_ptskips the separate blocks as soon as such a part is present, whether or not the part itself survives, so an unsigned inline part next to a signed separate block leaves nothing on the wire._separate_thinking_blocks_rendermirrors that rule (Greptile's second finding at bf3ba0d, reproduced with an unsigned inline part and an inline redacted part at both levels; a text part beside a signed block still renders)Review feedback carried over
systemrun: covered bytest_mid_conversation_system_after_multiple_tool_resultsfrom that PRsystemto user text: answered by decisions 3 and 4Review feedback on this PR
systemanchor: fixed by decision 11, with regression tests at the policy level and throughtransform_requestsystemanchor: fixed by decision 12transform_requesttests over every empty and padded shapethinking_blocks: fixed by decision 17, with regression tests at both levelsRelevant issues
Supersedes #38053 and #41057, whose commits are here with their authorship kept
Affected release
Linear ticket
Resolves LIT-8372
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/test_litellm/<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
Live runs against real Claude Fable 5.1 deployments through a local proxy (
--num_workers 2 --detailed_debug), each flow run once at the merge base of the day (da82ea8, port 31274) and once at this PR's tip (e05e9dc, port 39422). Deployments:fable=anthropic/claude-fable-5-1,fable-bedrock-invoke=bedrock/invoke/us.anthropic.claude-fable-5-1(us-west-2),fable-bedrock=bedrock/converse/us.anthropic.claude-fable-5-1(us-west-2),fable-vertex=vertex_ai/claude-fable-5-1. Every flow is two turns: turn 1 asks a puzzle with a top-level system message and thinking on, turn 2 replays turn 1's assistant message verbatim (thinking blocks and signatures included), adds a user turn, then arole: systemreminder, which is what a coding agent's system-reminder looks like on the wireBefore, da82ea8: the reminder is hoisted into
system, so the replayed block no longer matches its prefixAfter, e05e9dc: the reminder stays in
messages, the prefix is unchanged, turn 2 is 200 everywhereOn the flagged models the proxy sends S2 as
role: systeminsidemessages; on Converse it goes out as a user turn led by the operator note. Underdrop_blockthe debug log's emptyinput_transformationsis the direct provider-side confirmation that no block was dropped, where the before leg loggedthinking_droppedBoth legs
mcp_serversin the configA separate live A/B over 177 scenarios (mid, trailing, wedged, consecutive, tool-result, pydantic SDK replay, empty assistant, signed and unsigned thinking, streaming, Presidio, leading multi-system) at the merge base 993a5b9 and at e05e9dc on anthropic, azure_ai, bedrock invoke, Converse (Claude, Nova, Llama), Claude Platform on Bedrock and openai found no scenario going from 200 to non-200. Two went from 400 to 200: a conversation that ends with a system reminder used to have that reminder hoisted away, leaving the array ending on the assistant turn, which Claude Fable 5.1 rejects on anthropic and Azure Foundry ("This model does not support assistant message prefill"); the tip converts the trailing run in place and the call succeeds
The
/v1/responsesand/v1/messagesrows are the same on both legs because neither surface replays a signed block to the provider today:/v1/responseson Fable 5.1 returns noreasoningitem withencrypted_content, and the native/v1/messageshandler strips Fable 5.1's empty-text signed thinking block before sending (the same block replays fine sent straight to Anthropic: 200,input_transformations: []), while the Converse bridge sends the assistant turn as text only. Both are pre-existing and tracked separately (see Caveats). The full runs, request and response bodies, and proxy logs for both legs are in the QA directories named on the ticketType
🐛 Bug Fix
🧹 Refactoring
✅ Test
Caveats (if any)
Low
input_transformationsis not surfaced in the OpenAI-format response, so underdrop_blockonly the proxy debug log shows a dropped block/v1/messagesrows in the proof are not prefix-stability evidence. The native handler (Anthropic and Bedrock Invoke) strips Fable 5.1's empty-text signed thinking block before sending (strip_empty_content_blocks_from_anthropic_messages, whose docstring predates a model that signs an empty block), even though Anthropic accepts that exact block sent directly (200,input_transformations: []), and the Converse bridge sends the replayed assistant turn as text only. Both pre-exist this PR and are tracked as LIT-8616 and LIT-8615anthropic_messages_ptnow emits a wirerole: systementry for any mid-conversation system message a caller hands it, whatever the model. Every production caller either applies the placement policy first (the Anthropic chat transformation) or feeds it system-free runs (the guardrail translation handler), and the legacy text-completion caller stringifies the result, so no path reaches a model with an unplaced system entry todaytranslate_system_messagestill stripsx-anthropic-billing-header:blocks, but now only from the leading run; a mid-conversation billing-header system message would be placed rather than dropped. The only injector today is Claude Code on the top-levelsystemof/v1/messages, so the shape does not occur_text_parthelper still requires adictpart while the shared policy reads parts throughmessage_field, so a pydantic content part inside a mid-conversation system message is dropped on Converse where the Anthropic path converts it. Decision 14 covered message-level objects only; part-level objects inside a system message are not a shape any client sendsbedrock/claude_platform/claude-fable-5-1) takes the unflagged conversion for a flagged model: the prefix is stripped before the handler runs and the config's provider isbedrock, sosupports_mid_conversation_system("claude-fable-5-1", "bedrock")is unmapped and falls back to False. The prefix stays stable and all 15 live scenarios on that route are 200 on both sides, but the reminder reaches the model as user text with the operator note instead ofrole: system; tracked as LIT-8624test_anthropic_chat_transformation.pyshows 353 deletions at bf3ba0d that are a parametrize and signature reflow: every removeddef test_has a re-added twin and 23 tests are new, so no coverage was lost, but the reflow inflates the diffusing_litellm_on_windowstimes out inuv syncon main too (3 of its last 6 runs). The one red outside that set on PR pipeline 90128,tests/local_testing/test_streaming.py::test_openai_stream_options_call_text_completion('MockValSer' object is not an instance of 'SchemaSerializer'), fails the same way run alone at the merge base da82ea8 and at main's b395bfe, and passed on pipeline 90120 with the same text-completion code, so it is a pre-existing order-dependent failure rather than this PR'sQA runbook
Prerequisites:
ANTHROPIC_API_KEY, AWS credentials with Bedrock access in the region the test names, and a proxy withSTORE_MODEL_IN_DB=True. Each test registers its own deployment and deletes it afterwardsrole: systeminsidemessages, a reminder added on turn two leaves the cached turn-one prefix untouched{"model_name": "e2e-chat-midsys-1", "litellm_params": {"model": "anthropic/claude-opus-4-8", "api_key": "os.environ/ANTHROPIC_API_KEY"}}"cache_control": {"type": "ephemeral"}, a cache-marked user turn of about 100 sentences, and"cache": {"no-cache": true}; repeat the exact same body untilusage.cache_read_input_tokenscovers the whole prefix on three sends in a row{"role": "assistant", "content": "Understood."},{"role": "system", "content": "<system-reminder>Answer with exactly one word.</system-reminder>"}, then a new cache-marked user turnchoices[0].message.roleofassistant, non-empty text, andusage.cache_read_input_tokensat least the turn-one prefix size, which proves the reminder was not hoisted intosystem"model": "anthropic/claude-haiku-4-5-20251001"assistantrole, non-empty text, andusage.cache_read_input_tokensat least the turn-one prefix size{"model_name": "e2e-chat-midsys-2", "litellm_params": {"model": "bedrock/invoke/us.anthropic.claude-sonnet-5", "aws_region_name": "<region in the test>"}}, then the same priming and turn-two stepsassistantrole, non-empty text, andusage.cache_read_input_tokensat least the turn-one prefix size"model": "bedrock/invoke/us.anthropic.claude-haiku-4-5-20251001-v1:0"assistantrole, non-empty text, andusage.cache_read_input_tokensat least the turn-one prefix sizeFinal Attestation
Note
Medium Risk
Changes core message translation for Anthropic, Vertex, Azure, and Bedrock paths; incorrect placement could cause 400s or cache/thinking regressions, but behavior is heavily tested.
Overview
Fixes mid-conversation
role: systemhandling on OpenAI-format chat completions routed to Anthropic-shaped APIs (#36559). Previously every system message was hoisted into the top-levelsystemfield, which mutates the cached prefix and breaks prompt-cache reads or preserved thinking binding on the next turn.Only the leading run of system messages is still translated into the
systemparameter. Later reminders go through a newplace_mid_conversation_systempolicy: models flaggedsupports_mid_conversation_systemkeep validrole: systementries inmessages(with placement rules mirrored from Anthropic); others get an in-place user turn prefixed with a shared operator note.anthropic_messages_ptcan now emit wiresystemmessages; Bedrock Converse hoists the leading run only, converts the rest, and reorders system runs so they do not split tool calls from results.is_unsignable_thinking_blockis centralized incommon_utilsso placement logic and the factory agree on which thinking blocks never render. Types addAllAnthropicPassThroughMessageValuesfor in-message system roles. Coverage includes unit tests, provider transform tests, and live e2e cache assertions for Anthropic and Bedrock Invoke.Reviewed by Cursor Bugbot for commit e05e9dc. Bugbot is set up for automated code reviews on this repo. Configure here.