mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
fix(router): drop bridge-tagged reasoning blocks whole when the minting deployment is not in the routed group
The cross-group branch of EncryptedContentAffinityCheck removed only the signature from Anthropic-shaped thinking blocks, which left unsigned thinking blocks that Anthropic and Bedrock reject (thinking.signature: Field required). Drop the whole block, the way #40280 drops undecryptable Responses input items, so the routed request carries the conversation text with no reasoning item for those turns
This commit is contained in:
parent
4716b46c24
commit
5a1be56426
3 changed files with 16 additions and 21 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"}]}
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue