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:
@@ -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))
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user