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:
mateo-berri 2026-09-09 19:27:54 -07:00
parent 4716b46c24
commit 5a1be56426
3 changed files with 16 additions and 21 deletions

View file

@ -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:

View file

@ -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"}]}

View file

@ -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: