diff --git a/litellm/litellm_core_utils/prompt_templates/common_utils.py b/litellm/litellm_core_utils/prompt_templates/common_utils.py index 7ca2a8aad6a..d70530534da 100644 --- a/litellm/litellm_core_utils/prompt_templates/common_utils.py +++ b/litellm/litellm_core_utils/prompt_templates/common_utils.py @@ -1890,8 +1890,13 @@ def is_encrypted_reasoning_block(block: object) -> bool: def strip_encrypted_reasoning_from_messages(messages: object) -> None: - """Drop the encrypted reasoning a routed deployment cannot decrypt from Anthropic-shaped - history, keeping the readable thinking text. + """Drop the bridge-tagged reasoning blocks a routed deployment cannot decrypt from + Anthropic-shaped history. + + The whole block goes, the way #40280 drops undecryptable Responses ``input`` items: a + provider that did not mint the block rejects it signed (a foreign signature) and unsigned + (a missing signature) alike, so keeping its text as an unsigned thinking block only moves + the 400 from the router to the provider. Mutates the content lists in place: the router's fallback snapshot shares these message objects, so a rebound list would replay the stripped blocks on the fallback hop. @@ -1914,20 +1919,8 @@ def _anthropic_content_lists(messages: Sequence[object]) -> Iterator[object]: def _strip_encrypted_reasoning_from_blocks(content: object) -> None: blocks: Final = cast(list[object], content) # cast-ok: narrowed by the caller's isinstance - stripped: Final = tuple(_without_encrypted_reasoning_block(block) for block in blocks) - blocks[:] = (block for block in stripped if block is not None) # rebind-ok: list shared with fallback snapshot - - -def _without_encrypted_reasoning_block(block: object) -> object | None: - if not is_encrypted_reasoning_block(block): - return block - mapping: Final = cast(Mapping[str, object], block) # cast-ok: narrowed by is_encrypted_reasoning_block - if mapping.get("type") != "thinking" or not mapping.get("thinking"): - return None - kept: Final[dict[str, object]] = { # mutable-ok: thinking block rebuilt without the undecryptable signature - key: value for key, value in mapping.items() if key != "signature" - } - return kept + kept: Final = tuple(block for block in blocks if not is_encrypted_reasoning_block(block)) + blocks[:] = kept # rebind-ok: shared with fallback snapshot def _reasoning_replay_group_key(indexed_block: tuple[int, Mapping[str, object]]) -> str: diff --git a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py index 71b71d55fa7..7c1445d79c9 100644 --- a/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py +++ b/tests/test_litellm/litellm_core_utils/prompt_templates/test_litellm_core_utils_prompt_templates_common_utils.py @@ -1633,7 +1633,7 @@ class TestEncryptedReasoningReplay: def test_is_encrypted_reasoning_block(self, block, expected): assert is_encrypted_reasoning_block(block) is expected - def test_strip_keeps_the_readable_thinking_and_drops_the_undecryptable_bytes(self): + def test_strip_drops_every_bridge_tagged_block_and_leaves_no_unsigned_thinking_behind(self): assistant_content = [ {"type": "thinking", "thinking": "minted by Anthropic", "signature": "ErcBCkgIValid"}, {"type": "thinking", "thinking": "packed by the bridge", "signature": encrypted_reasoning_signature("g1")}, @@ -1652,9 +1652,9 @@ class TestEncryptedReasoningReplay: assert messages[1]["content"] is assistant_content assert assistant_content == [ {"type": "thinking", "thinking": "minted by Anthropic", "signature": "ErcBCkgIValid"}, - {"type": "thinking", "thinking": "packed by the bridge"}, {"type": "text", "text": "answer"}, ] + assert all(block["signature"] for block in assistant_content if block["type"] == "thinking") assert messages[0] == {"role": "user", "content": "question"} assert messages[2] == {"role": "user", "content": [{"type": "text", "text": "follow-up"}]} diff --git a/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py b/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py index 57c02e43fc1..ea8e2eacaa6 100644 --- a/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py +++ b/tests/test_litellm/router_utils/pre_call_checks/test_encrypted_content_affinity_check.py @@ -1635,8 +1635,10 @@ def _bridge_replayed_anthropic_messages(minted_by: str) -> list: async def test_encrypted_content_affinity_strips_bridge_reasoning_from_messages_routed_to_another_group(): """ The /v1/messages twin of the tier-change case: the routed group holds no deployment - of the org that minted the reasoning, so the bridge-tagged blocks are stripped down - to their readable thinking text and the request dispatches to the routed pool. + of the org that minted the reasoning, so the bridge-tagged blocks are dropped whole + and the request dispatches to the routed pool. No unsigned thinking block may be left + behind: Anthropic and Bedrock reject a thinking block with a missing signature the + same way they reject a foreign one. """ originating = _make_originating_mock(None, "key-a", model_name="gpt-reasoning-tier") mock_router = _make_router_mock_with_cooldown( @@ -1660,9 +1662,9 @@ async def test_encrypted_content_affinity_strips_bridge_reasoning_from_messages_ assert messages[1]["content"] is assistant_content assert assistant_content == [ {"type": "thinking", "thinking": "Anthropic minted this one", "signature": "ErcCCpIBCBEYAipA"}, - {"type": "thinking", "thinking": "The bridge packed this one"}, {"type": "text", "text": "The zebra owner lives in the green house."}, ] + assert all(block["signature"] for block in assistant_content if block["type"] == "thinking") class TestStripEncryptedReasoningFromInput: