fix(anthropic): strip bridge reasoning in the native messages transform, not the empty-block pass

This commit is contained in:
mateo-berri 2026-09-09 12:41:38 -07:00
parent 2bca7ff673
commit 751431dc3f
4 changed files with 126 additions and 22 deletions

View file

@ -1202,6 +1202,30 @@ def strip_thinking_blocks_from_anthropic_messages(messages: list[Any]) -> list[A
return out
def _without_encrypted_reasoning_blocks(message: dict) -> dict | None: # mutable-ok: Anthropic message payload shape
content: Final = message.get("content")
if not isinstance(content, list):
return message
kept: Final = [b for b in content if not is_encrypted_reasoning_block(b)] # mutable-ok: API message payload
if len(kept) == len(content):
return message
if not kept:
return None
return {**message, "content": kept} # mutable-ok: API message payload
def strip_encrypted_reasoning_blocks_from_anthropic_messages(
messages: Sequence[dict], # mutable-ok: Anthropic message payload shape
) -> list[dict]: # mutable-ok: AnthropicMessagesRequest.messages is typed list[dict]
"""
Drop thinking / redacted_thinking blocks that carry another provider's encrypted
reasoning (a turn the Responses API bridge served) before the request reaches
Anthropic, which cannot verify them. Anthropic's own signed blocks are kept.
"""
stripped: Final = (_without_encrypted_reasoning_blocks(m) for m in messages)
return [m for m in stripped if m is not None] # mutable-ok: API message payload
def strip_thinking_blocks_from_anthropic_messages_request_dict(
data: dict[str, Any],
) -> None:
@ -1236,10 +1260,8 @@ def strip_empty_content_blocks_from_anthropic_messages(
on the unified ``/v1/messages`` path. ``/v1/chat/completions`` already
handles this in ``anthropic_messages_pt``; this helper provides the
equivalent guarantee for the native Anthropic Messages path.
A thinking or ``redacted_thinking`` block whose signature or data carries
another provider's encrypted reasoning (a turn served by the Responses API
bridge) is dropped too, since Anthropic cannot verify it; every other
``redacted_thinking`` block is left alone.
``redacted_thinking`` blocks are never touched: they carry opaque
``data`` instead of thinking text.
Messages whose content is a list and becomes empty after stripping are
omitted, matching :func:`strip_thinking_blocks_from_anthropic_messages`.
@ -1252,11 +1274,7 @@ def strip_empty_content_blocks_from_anthropic_messages(
out.append(m)
continue
content = m["content"]
filtered = [ # mutable-ok: rebuilt message content list
b
for b in content
if not _is_empty_text_block(b) and not is_empty_thinking_block(b) and not is_encrypted_reasoning_block(b)
]
filtered = [b for b in content if not _is_empty_text_block(b) and not is_empty_thinking_block(b)]
if len(filtered) == len(content):
out.append(m)
elif filtered:

View file

@ -25,6 +25,7 @@ from ...common_utils import (
AnthropicModelInfo,
optionally_handle_anthropic_oauth,
strip_advisor_blocks_from_messages,
strip_encrypted_reasoning_blocks_from_anthropic_messages,
)
DEFAULT_ANTHROPIC_API_VERSION: Final = "2023-06-01"
@ -613,7 +614,7 @@ class AnthropicMessagesConfig(BaseAnthropicMessagesConfig):
messages = strip_advisor_blocks_from_messages(messages)
anthropic_messages_request: Final[AnthropicMessagesRequest] = AnthropicMessagesRequest(
messages=messages,
messages=strip_encrypted_reasoning_blocks_from_anthropic_messages(messages),
max_tokens=max_tokens,
model=model,
**anthropic_messages_optional_request_params,

View file

@ -0,0 +1,50 @@
from litellm.litellm_core_utils.prompt_templates.common_utils import (
encrypted_reasoning_signature,
)
from litellm.llms.anthropic.experimental_pass_through.messages.transformation import (
AnthropicMessagesConfig,
)
def _transform(messages):
return AnthropicMessagesConfig().transform_anthropic_messages_request(
model="claude-sonnet-4-5",
messages=messages,
anthropic_messages_optional_request_params={"max_tokens": 1024},
litellm_params={},
headers={},
)
def test_reasoning_replayed_from_the_responses_bridge_never_reaches_anthropic():
"""Claude Code resumed on a Claude model echoes the thinking blocks a gpt turn produced."""
messages = [
{"role": "user", "content": "Solve it."},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "plan", "signature": encrypted_reasoning_signature("gAAAA_1")},
{"type": "redacted_thinking", "data": encrypted_reasoning_signature("gAAAA_2")},
{"type": "text", "text": "The answer."},
],
},
{"role": "user", "content": "And the next one?"},
]
request = _transform(messages)
assert request["messages"][1]["content"] == [{"type": "text", "text": "The answer."}]
assert len(messages[1]["content"]) == 3
def test_anthropic_signed_thinking_blocks_are_forwarded_untouched():
messages = [
{"role": "user", "content": "Solve it."},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "plan", "signature": "EqQBCkYIAxgCIkA_anthropic_signed"},
{"type": "redacted_thinking", "data": "EmwKAhgBEgy_anthropic_minted"},
{"type": "text", "text": "The answer."},
],
},
]
assert _transform(messages)["messages"] == messages

View file

@ -42,12 +42,15 @@ FAKE_AUTH_TOKEN = "sk-ant-aut01-fake-auth-token-for-testing-123456789"
def test_is_claude_code_one_shot_subagent_request(messages, system, expected):
from litellm.llms.anthropic.common_utils import is_claude_code_one_shot_subagent_request
assert is_claude_code_one_shot_subagent_request(
messages=messages,
system=system,
tools=None,
user_agent="claude-cli/2.1.263 (external, cli)",
) is expected
assert (
is_claude_code_one_shot_subagent_request(
messages=messages,
system=system,
tools=None,
user_agent="claude-cli/2.1.263 (external, cli)",
)
is expected
)
class TestOptionallyHandleAnthropicOAuth:
@ -1541,8 +1544,8 @@ class TestAnthropicThinkingSignatureSelfHeal:
out = strip_empty_content_blocks_from_anthropic_messages(msgs)
assert [b["type"] for b in out[0]["content"]] == ["thinking"]
def test_strip_drops_encrypted_reasoning_blocks_from_the_responses_bridge(self):
"""A session resumed on an Anthropic model replays reasoning only OpenAI can verify."""
def test_strip_keeps_encrypted_reasoning_blocks_for_the_responses_bridge(self):
"""The /v1/messages handler runs this before dispatch, so the bridge must still see the replay."""
from litellm.litellm_core_utils.prompt_templates.common_utils import (
encrypted_reasoning_signature,
)
@ -1556,14 +1559,46 @@ class TestAnthropicThinkingSignatureSelfHeal:
"content": [
{"type": "thinking", "thinking": "plan", "signature": encrypted_reasoning_signature("gAAAA_1")},
{"type": "redacted_thinking", "data": encrypted_reasoning_signature("gAAAA_2")},
{"type": "redacted_thinking", "data": "EmwKAhgBEgy_anthropic_minted"},
{"type": "text", "text": "The answer."},
],
}
]
out = strip_empty_content_blocks_from_anthropic_messages(msgs)
assert [b["type"] for b in out[0]["content"]] == ["redacted_thinking", "text"]
assert len(msgs[0]["content"]) == 4
assert strip_empty_content_blocks_from_anthropic_messages(msgs) == msgs
def test_strip_encrypted_reasoning_drops_only_the_bridge_tagged_blocks(self):
"""A session resumed on an Anthropic model replays reasoning only OpenAI can verify."""
from litellm.litellm_core_utils.prompt_templates.common_utils import (
encrypted_reasoning_signature,
)
from litellm.llms.anthropic.common_utils import (
strip_encrypted_reasoning_blocks_from_anthropic_messages,
)
msgs = [
{"role": "user", "content": "Solve it."},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "plan", "signature": encrypted_reasoning_signature("gAAAA_1")},
{"type": "redacted_thinking", "data": encrypted_reasoning_signature("gAAAA_2")},
],
},
{
"role": "assistant",
"content": [
{"type": "thinking", "thinking": "plan", "signature": encrypted_reasoning_signature("gAAAA_3")},
{"type": "thinking", "thinking": "native", "signature": "EqQBCkYIAxgCIkA_anthropic_signed"},
{"type": "redacted_thinking", "data": "EmwKAhgBEgy_anthropic_minted"},
{"type": "text", "text": "The answer."},
],
},
]
out = strip_encrypted_reasoning_blocks_from_anthropic_messages(msgs)
assert [m["role"] for m in out] == ["user", "assistant"]
assert [b["type"] for b in out[1]["content"]] == ["thinking", "redacted_thinking", "text"]
assert out[1]["content"][0]["signature"] == "EqQBCkYIAxgCIkA_anthropic_signed"
assert len(msgs[1]["content"]) == 2
assert len(msgs[2]["content"]) == 4
def test_strip_empty_text_blocks_treats_null_text_as_empty(self):
from litellm.llms.anthropic.common_utils import (