fix(anthropic): replay signed thinking on Bedrock, Vertex, Foundry and MiniMax

The "not anthropic.com => third_party => strip all thinking" catch-all
dropped Claude's signed thinking on every Anthropic-signed cloud platform
and MiniMax's own signed blocks. Anthropic documents signatures as valid
across the Claude API, Bedrock and Google Cloud, and Bedrock requires the
last tool-use turn's thinking back; MiniMax's Anthropic API asks for its
thinking blocks "unchanged in later turns". Without them manual-budget
models silently ran tool-loop continuations with thinking disabled and
adaptive models lost their interleaved chain and cached prefix.

- anthropic_endpoints: _is_bedrock_runtime_endpoint / _is_vertex_ai_endpoint
  / _is_claude_platform_endpoint (hostname-matched, lookalikes rejected).
- anthropic_thinking_policy: route resolution is a first-match rule table;
  Claude platforms -> native, MiniMax /anthropic -> new "minimax" route
  (replay verbatim on every turn). SIGNED_REPLAY_ROUTES drives the existing
  signature-400 self-heal (tracks_rejected_thinking) for both. Unknown
  relays still strip; DeepSeek keeps unsigned-only on any non-Anthropic
  host (Claude platforms included); Kimi unchanged.
- Haiku 5.5 is keep-all per Anthropic's preservation table: the last-turn
  regex now only matches Claude 3.x; Haiku is version-gated (>= 5.5).
- Last-turn-only models keep thinking on every assistant turn of the
  in-flight tool loop (after the last non-tool-result user message), not
  just the latest assistant message. Older turns stay stripped so the
  cached prefix only changes where the API itself drops them.
- AnthropicTransport.convert_messages forwards model= so preserving models
  are not treated as last-turn-only on that entry point.
This commit is contained in:
Teknium
2026-10-10 13:39:19 -07:00
parent f89688440e
commit 33d6139ccd
7 changed files with 205 additions and 40 deletions

View File

@@ -157,3 +157,29 @@ def _is_azure_anthropic_endpoint(base_url: str | None) -> bool:
host_padded = f".{(parsed.hostname or '').lower().rstrip('.')}."
is_azure_host = ".services.ai.azure." in host_padded or ".openai.azure." in host_padded
return is_azure_host and "/anthropic" in (parsed.path or "").lower()
def _is_bedrock_runtime_endpoint(base_url: str | None) -> bool:
"""Amazon Bedrock's runtime host (``bedrock-runtime[-fips].<region>.amazonaws.com[.cn]``, and VPC
endpoint names carrying the same label), which the AnthropicBedrock SDK path binds as its base URL."""
host = base_url_hostname(base_url or "")
return any(label.startswith("bedrock-runtime") for label in host.split(".")) and (
base_url_host_matches(host, "amazonaws.com") or base_url_host_matches(host, "amazonaws.com.cn")
)
def _is_vertex_ai_endpoint(base_url: str | None) -> bool:
"""Vertex AI hosts: global ``aiplatform.googleapis.com`` and regional ``<region>-aiplatform.googleapis.com``."""
host = base_url_hostname(base_url or "")
return host == "aiplatform.googleapis.com" or host.endswith("-aiplatform.googleapis.com")
def _is_claude_platform_endpoint(base_url: str | None) -> bool:
"""Cloud platforms serving Claude under Anthropic's own thinking signatures: Bedrock, Vertex AI and
Azure AI Foundry ``/anthropic``. Anthropic documents ``signature`` values as compatible across the
Claude API, Bedrock and Google Cloud, so these replay signed thinking like the direct API."""
return (
_is_bedrock_runtime_endpoint(base_url)
or _is_vertex_ai_endpoint(base_url)
or _is_azure_anthropic_endpoint(base_url)
)

View File

@@ -11,7 +11,7 @@ import re
from typing import Any, Dict, List, Optional, Tuple
from agent.image_eviction_policy import outbound_image_retire_count
from agent.anthropic_thinking_policy import anthropic_thinking_route, model_preserves_prior_thinking
from agent.anthropic_thinking_policy import anthropic_thinking_route, route_replays_prior_thinking
logger = logging.getLogger(__name__)
@@ -570,20 +570,33 @@ def _keep_valid_thinking(content: list[Any], signature_dead: bool) -> list[Any]:
return new_content
def _current_tool_loop_start(result: list[dict[str, Any]]) -> int:
"""Index after the last user message that is not purely tool results: every assistant turn from
there on belongs to the in-flight tool loop, whose thinking Anthropic requires back."""
for i in range(len(result) - 1, -1, -1):
m = result[i]
if m.get("role") != "user":
continue
content = m.get("content")
if not (isinstance(content, list) and content and all(_block_type(b) == "tool_result" for b in content)):
return i + 1
return 0
def _manage_thinking_signatures(result: list[dict[str, Any]], base_url: str | None, model: str | None) -> None:
"""Strip or preserve thinking blocks per endpoint. Mutates ``result`` in place.
Anthropic signs thinking blocks against the full turn; any upstream mutation invalidates them
(400 "Invalid signature in thinking block"). Native preserved-thinking models keep valid signed
blocks on every assistant turn; older Claude models retain the established latest-turn-only
policy. Signatures are proprietary: third-party endpoints strip all thinking. Kimi replays as-is;
DeepSeek needs unsigned blocks round-tripped but rejects signed ones. Nous Portal proxies Claude
with sticky sessions and validates the same signatures, so it takes the native path despite not
being anthropic.com.
(400 "Invalid signature in thinking block"). Signed routes (Anthropic, Nous Portal, Bedrock,
Vertex, Foundry; MiniMax for its own blocks) keep valid signed blocks on every assistant turn
when the model keeps prior thinking, else on every turn of the in-flight tool loop (the API
requires those back and strips older turns itself; cutting at the latest assistant message
broke the loop's earlier steps). Unknown relays cannot verify signatures and strip all; Kimi
replays as-is; DeepSeek needs unsigned blocks round-tripped but rejects signed ones.
"""
route = anthropic_thinking_route(base_url, model)
last_assistant_idx = next((i for i in range(len(result) - 1, -1, -1) if result[i].get("role") == "assistant"), None)
preserve_prior = model_preserves_prior_thinking(model)
loop_start = _current_tool_loop_start(result)
preserve_prior = route_replays_prior_thinking(route, model)
for idx, m in _assistant_block_lists(result):
if route == "kimi":
pass # shared cleanup below still strips cache markers + the flag
@@ -594,7 +607,7 @@ def _manage_thinking_signatures(result: list[dict[str, Any]], base_url: str | No
if _block_type(b) not in _THINKING_TYPES or not (b.get("signature") or b.get("data"))
]
m["content"] = new_content or [_text_block("(empty)")]
elif route == "third_party" or (idx != last_assistant_idx and not preserve_prior):
elif route == "third_party" or (idx < loop_start and not preserve_prior):
m["content"] = _strip_thinking(m["content"]) or [_text_block("(thinking elided)")]
else:
new_content = _keep_valid_thinking(m["content"], bool(m.get("_thinking_signature_invalidated")))
@@ -716,8 +729,8 @@ def convert_messages_to_anthropic(
) -> tuple[Optional[Any], list[dict]]:
"""Convert OpenAI-format messages to Anthropic format -> ``(system, messages)``. System is
extracted into its own param (a string, or a block list when cache_control is present).
``base_url``/``model`` drive thinking-signature policy — third-party endpoints strip signatures
(proprietary, they 400 on them); Kimi-family endpoints/models keep unsigned
``base_url``/``model`` drive thinking-signature policy — unknown third-party relays strip
signatures (they cannot verify them); Kimi-family endpoints/models keep unsigned
reasoning_content-derived blocks, which Kimi requires even when empty."""
system = None
result: list[dict[str, Any]] = []

View File

@@ -1,14 +1,15 @@
"""Shared Anthropic prior-turn thinking retention policy.
Anthropic preserves and bills prior assistant thinking on Opus 4.5+ and
Sonnet 4.6+ (plus the newer Fable/Mythos families). Older models accept
replayed blocks but strip them server-side. Keep the model capability in
one place so message conversion and context accounting cannot diverge.
Anthropic preserves and bills prior assistant thinking on Opus 4.5+, Sonnet
4.6+ and Haiku 5.5+ (plus the newer Fable/Mythos families). Older models
accept replayed blocks but strip them server-side. Keep the model capability
and the per-endpoint replay contract in one place so message conversion and
context accounting cannot diverge.
Unknown or future Claude ids default to keep: replaying a block the API
strips costs nothing, while stripping on a model that keeps it rewrites the
cached prefix on every call. Only the known last-turn-only generations
(Haiku, Claude 3, Opus < 4.5, Sonnet < 4.6) strip. Non-Claude ids never keep.
(Claude 3, Haiku < 5.5, Opus < 4.5, Sonnet < 4.6) strip. Non-Claude ids never keep.
"""
from __future__ import annotations
@@ -17,8 +18,10 @@ import re
from typing import Any
from agent.anthropic_endpoints import (
_is_claude_platform_endpoint,
_is_deepseek_anthropic_endpoint,
_is_kimi_family_endpoint,
_is_minimax_anthropic_endpoint,
_is_nous_portal_endpoint,
_is_third_party_anthropic_endpoint,
_model_name_is_deepseek_thinking,
@@ -28,10 +31,12 @@ from agent.anthropic_endpoints import (
_CLAUDE_VERSION_RE = re.compile(
# Semantic minors are short version components. Snapshot dates such as
# claude-opus-4-20250514 must remain 4.0 rather than becoming 4.20250514.
r"claude[-_.](opus|sonnet|fable|mythos)[-_.](\d+)(?:[-_.](\d{1,2})(?=$|[-_.]))?",
r"claude[-_.](opus|sonnet|haiku|fable|mythos)[-_.](\d+)(?:[-_.](\d{1,2})(?=$|[-_.]))?",
re.IGNORECASE,
)
_LAST_TURN_ONLY_RE = re.compile(r"haiku|claude[-_.]?3(?!\d)", re.IGNORECASE)
# Claude 3.x ids put the family after the version (claude-3-5-haiku); 4+ ids are version-gated below.
_LAST_TURN_ONLY_RE = re.compile(r"claude[-_.]?3(?!\d)", re.IGNORECASE)
_KEEP_ALL_FROM = {"opus": (4, 5), "sonnet": (4, 6), "haiku": (5, 5)}
def _claude_family_version(model: Any) -> tuple[str, tuple[int, int]] | None:
@@ -54,21 +59,44 @@ def model_preserves_prior_thinking(model: Any) -> bool:
if parsed is None:
return True
family, version = parsed
return version >= {"opus": (4, 5), "sonnet": (4, 6)}.get(family, (5, 0))
return version >= _KEEP_ALL_FROM.get(family, (5, 0))
# Routes whose thinking blocks carry signatures the upstream verifies: they replay signed blocks, and a
# signature 400 there is healed by anthropic_thinking_replay's durable suppression.
SIGNED_REPLAY_ROUTES = frozenset({"native", "minimax"})
def _is_off_anthropic(base_url: Any) -> bool:
return _is_third_party_anthropic_endpoint(base_url) and not _is_nous_portal_endpoint(base_url)
# First match wins; an unmatched relay host is ``third_party``. DeepSeek models never sign, so they keep
# the unsigned-only contract on any host but Anthropic's own, Claude platforms included.
_ROUTE_RULES = (
("kimi", lambda url, model: _is_kimi_family_endpoint(url, model)),
("deepseek", lambda url, model: _is_deepseek_anthropic_endpoint(url)
or (_is_off_anthropic(url) and _model_name_is_deepseek_thinking(model))),
("native", lambda url, model: not _is_off_anthropic(url) or _is_claude_platform_endpoint(url)),
("minimax", lambda url, model: _is_minimax_anthropic_endpoint(url)),
)
def anthropic_thinking_route(base_url: Any, model: Any) -> str:
"""Which thinking-replay contract an Anthropic Messages request follows: ``kimi`` (replay as-is),
``deepseek`` (unsigned only), ``third_party`` (strip all) or ``native`` (Anthropic-signed blocks:
direct Anthropic and Nous Portal). The converter and signature-rejection state share this."""
is_third_party = _is_third_party_anthropic_endpoint(base_url) and not _is_nous_portal_endpoint(base_url)
if _is_kimi_family_endpoint(base_url, model):
return "kimi"
if _is_deepseek_anthropic_endpoint(base_url) or (is_third_party and _model_name_is_deepseek_thinking(model)):
return "deepseek"
return "third_party" if is_third_party else "native"
``deepseek`` (unsigned only), ``native`` (Anthropic-signed blocks: the direct API, Nous Portal,
Bedrock, Vertex AI, Azure AI Foundry), ``minimax`` (its own signed blocks, returned unchanged on
every turn as its docs require) or ``third_party`` (an unknown relay that cannot verify Anthropic
signatures: strip all). The converter, accounting and signature-rejection state share this."""
return next((route for route, matches in _ROUTE_RULES if matches(base_url, model)), "third_party")
def route_replays_prior_thinking(route: str, model: Any) -> bool:
"""Whether ``route`` replays signed thinking on assistant turns before the in-flight tool loop."""
return route == "minimax" or (route == "native" and model_preserves_prior_thinking(model))
def native_anthropic_preserves_prior_thinking(base_url: Any, model: Any) -> bool:
"""True for direct Anthropic/Nous Portal routes whose model retains old thinking."""
return anthropic_thinking_route(base_url, model) == "native" and model_preserves_prior_thinking(model)
"""True when the route replays (and the model keeps) signed thinking from earlier turns, so stale
thinking reaches the wire and accounting must charge the signed carriers."""
return route_replays_prior_thinking(anthropic_thinking_route(base_url, model), model)

View File

@@ -4,7 +4,7 @@ Canonical history stays untouched. We persist only fingerprints of rejected opaq
signature/data values, then filter those blocks from each rebuilt request copy. This is
needed beyond the immediate retry because context selection and process resume can rebuild
from canonical history later. The state belongs to one session (and is carried onto its
compression continuation); it applies only where Anthropic signs the blocks.
compression continuation); it applies only where the upstream signs the blocks.
The fingerprinting is deliberately coarse: one signature 400 marks every signed block in the
rejected request, so the session falls back to stripping that history (one cache miss, then
@@ -43,13 +43,13 @@ def _fingerprint(block: Any) -> str | None:
def tracks_rejected_thinking(agent: Any) -> bool:
"""Native Anthropic signatures only: Kimi, DeepSeek and third-party routes keep their own replay
contract and the one-request ``reasoning_details`` repair."""
from agent.anthropic_thinking_policy import anthropic_thinking_route
"""Signed-replay routes only (Anthropic and its cloud platforms, MiniMax): Kimi, DeepSeek and
third-party routes keep their own replay contract and the one-request ``reasoning_details`` repair."""
from agent.anthropic_thinking_policy import SIGNED_REPLAY_ROUTES, anthropic_thinking_route
return getattr(agent, "api_mode", None) == "anthropic_messages" and anthropic_thinking_route(
getattr(agent, "base_url", None), getattr(agent, "model", None)
) == "native"
) in SIGNED_REPLAY_ROUTES
def rejected_thinking_fingerprints(session_db: Any, session_id: Any) -> set[str]:

View File

@@ -43,9 +43,9 @@ class AnthropicTransport(ProviderTransport):
return "anthropic_messages"
def convert_messages(self, messages: list[dict[str, Any]], **kwargs) -> Any:
"""Convert OpenAI messages to an Anthropic (system, messages) tuple; ``base_url`` affects thinking-signature handling."""
"""Convert OpenAI messages to an Anthropic (system, messages) tuple; ``base_url`` and ``model`` pick the thinking-replay policy."""
from agent.anthropic_message_convert import convert_messages_to_anthropic
return convert_messages_to_anthropic(messages, base_url=kwargs.get("base_url"))
return convert_messages_to_anthropic(messages, base_url=kwargs.get("base_url"), model=kwargs.get("model"))
def convert_tools(self, tools: list[dict[str, Any]]) -> Any:
"""Convert OpenAI tool schemas to Anthropic input_schema format."""

View File

@@ -0,0 +1,98 @@
"""Signed thinking reaches the wire wherever the upstream can verify it, and only there.
Anthropic documents ``signature`` values as valid across the Claude API, Bedrock and Google Cloud;
Foundry serves the same Claude; MiniMax's Anthropic API asks for its own thinking blocks back
unchanged. Unknown relays cannot verify them and keep stripping; DeepSeek and Kimi keep their own
contracts. Within the in-flight tool loop every model gets its thinking back (required by the API);
earlier turns only where the model keeps prior thinking.
"""
from __future__ import annotations
import copy
import pytest
from agent.anthropic_thinking_policy import native_anthropic_preserves_prior_thinking
from agent.anthropic_thinking_replay import tracks_rejected_thinking
from agent.message_sanitization import stale_thinking_reaches_wire
from agent.transports.anthropic import AnthropicTransport
BEDROCK = "https://bedrock-runtime.us-east-1.amazonaws.com"
VERTEX = "https://us-east5-aiplatform.googleapis.com/v1/projects/p/locations/us-east5/publishers/anthropic/models"
FOUNDRY = "https://my-res.services.ai.azure.com/anthropic"
MINIMAX = "https://api.minimax.io/anthropic"
MINIMAX_CN = "https://api.minimaxi.com/anthropic"
RELAY = "https://relay.example.com/anthropic"
ALL, LOOP, NONE = ["sig_prior", "sig_loop1", "sig_loop2"], ["sig_loop1", "sig_loop2"], []
_CASES = {
# Claude platforms: preserving models replay every turn, last-turn-only models the whole tool loop.
"bedrock_opus": (BEDROCK, "us.anthropic.claude-opus-4-8-v1:0", ALL, True),
"bedrock_sonnet45": (BEDROCK, "us.anthropic.claude-sonnet-4-5-20250929-v1:0", LOOP, True),
"bedrock_fips": ("https://bedrock-runtime-fips.us-gov-west-1.amazonaws.com", "anthropic.claude-opus-4-8", ALL, True),
"vertex": (VERTEX, "claude-opus-4-8", ALL, True),
"foundry": (FOUNDRY, "claude-opus-4-8", ALL, True),
"minimax": (MINIMAX, "MiniMax-M2.7", ALL, True),
"minimax_cn": (MINIMAX_CN, "MiniMax-M3", ALL, True),
# Direct API: Haiku 5.5 keeps all turns; older generations keep the current tool loop.
"native_haiku55": (None, "claude-haiku-5-5", ALL, True),
"native_haiku45": (None, "claude-haiku-4-5", LOOP, True),
"native_sonnet45": (None, "claude-sonnet-4-5", LOOP, True),
"native_opus48": (None, "claude-opus-4-8", ALL, True),
# Unchanged contracts.
"unknown_relay": (RELAY, "claude-opus-4-8", NONE, False),
"bedrock_lookalike_host": ("https://bedrock-runtime.us-east-1.amazonaws.com.evil.test", "claude-opus-4-8", NONE, False),
"bedrock_lookalike_path": ("https://evil.test/bedrock-runtime.us-east-1.amazonaws.com", "claude-opus-4-8", NONE, False),
"deepseek": ("https://api.deepseek.com/anthropic", "deepseek-v4-pro", NONE, False),
"deepseek_on_bedrock_host": (BEDROCK, "deepseek-v4-pro", NONE, False),
"kimi": ("https://api.kimi.com/coding", "kimi-k2.5", ALL, False),
}
def _signed(sig):
return {"type": "thinking", "thinking": f"thought {sig}", "signature": sig}
def _loop_step(n):
call = {"id": f"t{n}", "type": "function", "function": {"name": "read_file", "arguments": "{}"}}
return [
{"role": "assistant", "content": "", "tool_calls": [call], "reasoning_details": [_signed(f"sig_loop{n}")],
"anthropic_content_blocks": [_signed(f"sig_loop{n}"), {"type": "tool_use", "id": f"t{n}", "name": "read_file", "input": {}}]},
{"role": "tool", "tool_call_id": f"t{n}", "content": "ok"},
]
def _history():
return [
{"role": "user", "content": "Q1"},
{"role": "assistant", "content": "A1", "reasoning_details": [_signed("sig_prior")]},
{"role": "user", "content": "Q2"},
*_loop_step(1),
*_loop_step(2),
]
@pytest.mark.parametrize("case", list(_CASES))
def test_signed_thinking_replays_exactly_where_the_route_can_verify_it(case):
"""Invariant: the transport entry point sends the route's signed blocks in their captured order,
the signature-400 self-heal covers every signed route, accounting charges prior-turn thinking
exactly when it is on the wire, and stored history is never mutated."""
base_url, model, expected, signed_route = _CASES[case]
history = _history()
before = copy.deepcopy(history)
_system, wire = AnthropicTransport().convert_messages(history, base_url=base_url, model=model)
blocks = [b for m in wire if m["role"] == "assistant" for b in m["content"]]
assert [b.get("signature") for b in blocks if b.get("type") == "thinking"] == expected
for step in (1, 2): # interleaved order: each step's thinking precedes its tool_use
types = [b["type"] for b in blocks if b.get("signature") == f"sig_loop{step}" or b.get("id") == f"t{step}"]
assert types == (["thinking", "tool_use"] if f"sig_loop{step}" in expected else ["tool_use"])
agent = type("A", (), {"api_mode": "anthropic_messages", "base_url": base_url, "model": model})()
assert tracks_rejected_thinking(agent) is signed_route
# Kimi replays as-is through its own reasoning_content echo; signed routes are priced by the carriers.
assert native_anthropic_preserves_prior_thinking(base_url, model) is (signed_route and "sig_prior" in expected)
if signed_route:
assert stale_thinking_reaches_wire("anthropic_messages", "custom", model, base_url) is ("sig_prior" in expected)
assert history == before

View File

@@ -411,13 +411,13 @@ class TestPortalThinkingReplay:
self._assert_thinking_kept(STAGING_URL)
def test_other_third_party_gateways_still_strip_thinking(self):
"""The Portal carve-out must not leak into MiniMax-style proxies."""
"""The Portal carve-out must not leak into unknown Anthropic-compatible relays."""
from agent.anthropic_message_convert import convert_messages_to_anthropic
_system, converted = convert_messages_to_anthropic(
self._messages(),
base_url="https://api.minimax.io/anthropic",
model="MiniMax-M2.7",
base_url="https://relay.example.com/anthropic",
model="claude-opus-4-8",
)
assistant = next(m for m in converted if m["role"] == "assistant")
thinking = [