fix(bedrock): Anthropic-shaped thinking stays Claude's on Converse

Only Claude reads the anthropic_content_blocks / signed reasoning_details
carriers on Converse: they hold Claude signatures, which other Converse
models reject. Non-Claude models keep base behaviour for those carriers.
This commit is contained in:
Teknium
2026-10-10 13:48:43 -07:00
parent 3e31dc2239
commit da9c15942e
2 changed files with 44 additions and 17 deletions

View File

@@ -809,30 +809,31 @@ def _anthropic_ordered_blocks(msg: dict) -> list[dict]:
return [block for block in converted if block is not None]
def _reasoning_details_blocks(msg: dict) -> list[dict]:
"""Signed and redacted thinking from ``reasoning_details`` (Anthropic shape, or Converse's own redacted
capture), in stored order. Converse wants reasoning ahead of toolUse."""
def _reasoning_details_blocks(msg: dict, claude: bool) -> list[dict]:
"""Redacted thinking from ``reasoning_details`` (Converse's own capture) and, for Claude, signed thinking
in Anthropic shape, in stored order. Converse wants reasoning ahead of toolUse."""
blocks = []
for d in msg.get("reasoning_details") or []:
if not isinstance(d, dict):
continue
if d.get("type") == "thinking" and d.get("signature"):
if claude and d.get("type") == "thinking" and d.get("signature"):
blocks.append(_reasoning_text_block(d.get("thinking"), d["signature"]))
elif d.get("type") == "redacted_thinking" and (block := _redacted_reasoning_block(d.get("data") or d.get("redactedContentBase64"))):
blocks.append(block)
return blocks
def _assistant_blocks(msg: dict, content) -> list[dict]:
def _assistant_blocks(msg: dict, content, claude: bool = True) -> list[dict]:
"""Assistant message → Converse blocks, from the first carrier that yields any: the ordered
``bedrock_content_blocks`` sidecar, the ordered ``anthropic_content_blocks`` sidecar, else thinking
from ``reasoning_details`` followed by text, then tool calls."""
``bedrock_content_blocks`` sidecar, (Claude only) the ordered ``anthropic_content_blocks`` sidecar,
else thinking from ``reasoning_details`` followed by text, then tool calls. Anthropic-shaped carriers
hold Claude signatures, so other models never read them."""
ordered_blocks = msg.get("bedrock_content_blocks")
if isinstance(ordered_blocks, list) and (content_blocks := _replay_ordered_blocks(ordered_blocks)):
return content_blocks
if content_blocks := _anthropic_ordered_blocks(msg):
if claude and (content_blocks := _anthropic_ordered_blocks(msg)):
return content_blocks
content_blocks = _reasoning_details_blocks(msg)
content_blocks = _reasoning_details_blocks(msg, claude)
if isinstance(content, str) and content.strip():
content_blocks.append({"text": content})
elif isinstance(content, list):
@@ -901,7 +902,8 @@ def convert_messages_to_converse(
user blocks. Converse needs strict user/assistant alternation with a user turn first and last:
same-role neighbours merge, placeholder user turns pad the ends. ``model`` selects the reasoning
replay policy for turns before the in-flight tool loop (None replays every carrier verbatim)."""
prior_filter, loop_filter = _REASONING_FILTERS[_converse_replay_policy(model)]
policy = _converse_replay_policy(model)
prior_filter, loop_filter = _REASONING_FILTERS[policy]
# Tool results are role "tool" here, so the last role "user" message opens the in-flight tool loop.
loop_start = next((i for i in range(len(messages) - 1, -1, -1) if messages[i].get("role") == "user"), -1)
system_blocks: list[dict] = []
@@ -923,7 +925,9 @@ def convert_messages_to_converse(
append_turn("user", [{"toolResult": {
"toolUseId": msg.get("tool_call_id", ""), "content": [{"text": _safe_text(result_content)}]}}])
elif role == "assistant":
blocks = _filter_reasoning(_assistant_blocks(msg, content), prior_filter if idx < loop_start else loop_filter)
blocks = _filter_reasoning(
_assistant_blocks(msg, content, claude=policy == "signed"), prior_filter if idx < loop_start else loop_filter
)
append_turn("assistant", blocks or [dict(_PLACEHOLDER_BLOCK)])
elif role == "user":
append_turn("user", _convert_content_to_converse(content))

View File

@@ -30,6 +30,24 @@ def _anthropic_bedrock_turn(question, sig, tool_id):
]
def _converse_turn(question, sig, tool_id):
"""A tool turn captured by the Converse normalizer for a non-Claude model (ordered sidecar)."""
call = {"id": tool_id, "type": "function", "function": {"name": "read_file", "arguments": '{"path": "a"}'}}
return [
{"role": "user", "content": question},
{"role": "assistant", "content": "", "tool_calls": [call], "reasoning_content": f"own {sig}",
"bedrock_content_blocks": [{"reasoningContent": {"text": f"own {sig}"}},
{"toolUse": {"toolUseId": tool_id, "name": "read_file", "input": {"path": "a"}}}]},
{"role": "tool", "tool_call_id": tool_id, "content": "ok"},
]
def _converse_history():
turn = _converse_turn("Q1", "sig_prior", "t1")
turn[1]["bedrock_content_blocks"][0]["reasoningContent"]["signature"] = "foreign" # e.g. minted by Claude
return turn + [{"role": "assistant", "content": "A1"}] + _converse_turn("Q2", "sig_loop", "t2")
def _history():
return _anthropic_bedrock_turn("Q1", "sig_prior", "t1") + [{"role": "assistant", "content": "A1"}] + _anthropic_bedrock_turn("Q2", "sig_loop", "t2")
@@ -42,16 +60,18 @@ def _reasoning(blocks):
"model, prior, loop",
[
(CLAUDE, [{"text": "plan sig_prior", "signature": "sig_prior"}], [{"text": "plan sig_loop", "signature": "sig_loop"}]),
("us.deepseek.r1-v1:0", [], [{"text": "plan sig_loop", "signature": "sig_loop"}]),
("global.moonshotai.kimi-k3", [], [{"text": "plan sig_loop", "signature": "sig_loop"}]),
# Earlier turns may hold another model's signatures: a non-Claude model gets their text only.
("openai.gpt-oss-120b-1:0", [{"text": "plan sig_prior"}], [{"text": "plan sig_loop", "signature": "sig_loop"}]),
# The model's own Converse capture: prior turns follow its contract, the in-flight loop replays as captured.
("us.deepseek.r1-v1:0", [], [{"text": "own sig_loop"}]),
("global.moonshotai.kimi-k3", [], [{"text": "own sig_loop"}]),
# gpt-oss rejects reasoningText.signature: prior turns go back as readable text only.
("openai.gpt-oss-120b-1:0", [{"text": "own sig_prior"}], [{"text": "own sig_loop"}]),
],
)
def test_converse_replays_each_models_reasoning_contract(model, prior, loop):
"""Invariant: the in-flight tool loop replays its reasoning ahead of its toolUse with redacted tool
input; earlier turns and signatures follow the model's documented contract; history is untouched."""
history = _history()
input; earlier turns follow the model's documented contract; a Claude signature never reaches a
non-Claude model; history is untouched."""
history = _history() if "claude" in model else _converse_history()
before = copy.deepcopy(history)
kwargs = BedrockTransport().build_kwargs(model=model, messages=history, reasoning_config={"enabled": True, "effort": "high"})
@@ -61,6 +81,9 @@ def test_converse_replays_each_models_reasoning_contract(model, prior, loop):
assert [next(iter(b)) for b in assistants[-1] if "cachePoint" not in b] == ["reasoningContent", "toolUse"]
assert assistants[-1][1]["toolUse"]["input"] == {"path": "a"}
assert history == before
if "claude" not in model: # Claude-shaped carriers from before a /model switch stay Claude's
switched = BedrockTransport().build_kwargs(model=model, messages=_history())
assert "signature" not in repr(switched["messages"])
def test_claude_on_converse_requests_thinking():