fix(anthropic): send an explicit thinking disable on the native Messages wire
Adaptive Claude models think by default, so omitting the `thinking`
parameter left thinking ON for users who had turned it off. Send
`thinking: {"type": "disabled"}` instead, and keep the omission for
reasoning-mandatory families that answer a disable with HTTP 400.
This commit is contained in:
committed by
brooklyn!
parent
c47f0b4590
commit
c670464ca7
@@ -132,6 +132,17 @@ _NO_XHIGH_CLAUDE_SUBSTRINGS = (
|
||||
"claude-sonnet-4-6", "claude-sonnet-4.6",
|
||||
)
|
||||
|
||||
# Adaptive Claude families that REJECT a thinking disable — thinking is
|
||||
# mandatory and ``thinking: {"type": "disabled"}`` answers HTTP 400. The Portal
|
||||
# catalog flags the same families with ``reasoning.mandatory``.
|
||||
#
|
||||
# Unlike the two lists above, the failure here is asymmetric: a missing entry
|
||||
# 400s the turn, while a spurious one only leaves thinking on. When in doubt,
|
||||
# add the family.
|
||||
_MANDATORY_THINKING_CLAUDE_SUBSTRINGS = (
|
||||
"claude-fable",
|
||||
)
|
||||
|
||||
|
||||
def _is_claude_model(model: str | None) -> bool:
|
||||
return "claude" in (model or "").lower()
|
||||
@@ -298,6 +309,32 @@ def _supports_xhigh_effort(model: str) -> bool:
|
||||
return not any(v in m for v in _NO_XHIGH_CLAUDE_SUBSTRINGS)
|
||||
|
||||
|
||||
def _accepts_thinking_disable(model: str) -> bool:
|
||||
"""Return True when *model* accepts an explicit thinking disable.
|
||||
|
||||
Adaptive Claude models default to thinking ON, so "thinking off" only
|
||||
takes effect if we actively send ``thinking: {"type": "disabled"}`` —
|
||||
omitting the parameter leaves the upstream default in place and the model
|
||||
thinks anyway. Reasoning-mandatory families reject the disable outright
|
||||
with an HTTP 400, so they keep the omit-everything behavior.
|
||||
|
||||
Legacy manual-thinking Claude models are excluded because they need no
|
||||
disable: thinking is opt-in there via ``budget_tokens``, so not sending
|
||||
the block already means off.
|
||||
|
||||
Scoped to Claude deliberately. Kimi/Moonshot endpoints also speak the
|
||||
adaptive contract, but their documented disable behavior is omission
|
||||
(#13848) and they are not part of this bug; sending them a new parameter
|
||||
on the strength of Claude's contract would be a guess.
|
||||
"""
|
||||
if not _is_claude_model(model):
|
||||
return False
|
||||
if not _supports_adaptive_thinking(model):
|
||||
return False
|
||||
m = model.lower()
|
||||
return not any(v in m for v in _MANDATORY_THINKING_CLAUDE_SUBSTRINGS)
|
||||
|
||||
|
||||
def _forbids_sampling_params(model: str) -> bool:
|
||||
"""Return True for models that 400 on any non-default temperature/top_p/top_k.
|
||||
|
||||
@@ -3053,7 +3090,15 @@ def build_anthropic_kwargs(
|
||||
# request "summarized" so the reasoning blocks stay populated — matching
|
||||
# 4.6 behavior and preserving the activity-feed UX during long tool runs.
|
||||
if reasoning_config and isinstance(reasoning_config, dict):
|
||||
if reasoning_config.get("enabled") is not False and "haiku" not in model.lower():
|
||||
if reasoning_config.get("enabled") is False:
|
||||
# "Thinking off". Adaptive models think by DEFAULT, so omitting the
|
||||
# parameter is not a disable — it silently leaves thinking on and
|
||||
# the user keeps paying for it. Send the disable explicitly.
|
||||
# Mandatory-thinking models reject it with a 400, so they keep the
|
||||
# omission: a silently-ignored disable beats a dead turn.
|
||||
if _accepts_thinking_disable(model):
|
||||
kwargs["thinking"] = {"type": "disabled"}
|
||||
elif "haiku" not in model.lower():
|
||||
effort = str(reasoning_config.get("effort", "medium")).lower()
|
||||
budget = THINKING_BUDGET.get(effort, 8000)
|
||||
if _supports_adaptive_thinking(model):
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""'Thinking off' on the native Anthropic Messages wire.
|
||||
|
||||
Adaptive Claude models (4.6+) think by DEFAULT. Omitting the ``thinking``
|
||||
parameter is therefore NOT a disable — it leaves the upstream default in
|
||||
place and the model keeps thinking, which is exactly what a user who turned
|
||||
thinking off is trying to stop paying for. The disable has to be sent:
|
||||
|
||||
thinking: {"type": "disabled"}
|
||||
|
||||
Reasoning-mandatory families (claude-fable) reject that with an HTTP 400
|
||||
("Thinking is mandatory for this model"), so they keep the omission — a
|
||||
silently-ignored disable is a much better failure than a dead turn.
|
||||
|
||||
Legacy manual-thinking Claude (<= 4.5) needs no disable at all: thinking is
|
||||
opt-in there via ``budget_tokens``, so sending nothing already means off.
|
||||
|
||||
Sibling contract on the chat_completions wire: hermes-agent#90412.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.anthropic_adapter import build_anthropic_kwargs
|
||||
|
||||
MESSAGES = [{"role": "user", "content": "hello"}]
|
||||
|
||||
# Portal ids and their bare Anthropic equivalents: the disable verdict is a
|
||||
# property of the model family, not of which route serves it.
|
||||
ADAPTIVE_DISABLEABLE = [
|
||||
"anthropic/claude-opus-5",
|
||||
"anthropic/claude-sonnet-5",
|
||||
"anthropic/claude-opus-4.8",
|
||||
"anthropic/claude-opus-4.7",
|
||||
"anthropic/claude-opus-4.6",
|
||||
"anthropic/claude-sonnet-4.6",
|
||||
"claude-opus-4-6",
|
||||
]
|
||||
|
||||
|
||||
def _kwargs(model: str, reasoning_config: dict | None, **extra):
|
||||
return build_anthropic_kwargs(
|
||||
model=model,
|
||||
messages=MESSAGES,
|
||||
tools=None,
|
||||
max_tokens=4096,
|
||||
reasoning_config=reasoning_config,
|
||||
**extra,
|
||||
)
|
||||
|
||||
|
||||
class TestThinkingOffIsSentExplicitly:
|
||||
@pytest.mark.parametrize("model", ADAPTIVE_DISABLEABLE)
|
||||
def test_adaptive_models_receive_an_explicit_disable(self, model: str) -> None:
|
||||
"""The whole point: omission would leave thinking ON for these."""
|
||||
kwargs = _kwargs(model, {"enabled": False})
|
||||
assert kwargs["thinking"] == {"type": "disabled"}
|
||||
|
||||
@pytest.mark.parametrize("model", ADAPTIVE_DISABLEABLE)
|
||||
def test_disable_carries_no_effort_dial(self, model: str) -> None:
|
||||
"""``output_config.effort`` describes how hard to think — meaningless
|
||||
alongside a disable, and it is what the enable path sets."""
|
||||
kwargs = _kwargs(model, {"enabled": False, "effort": "high"})
|
||||
assert kwargs["thinking"] == {"type": "disabled"}
|
||||
assert "output_config" not in kwargs
|
||||
|
||||
def test_mandatory_thinking_models_keep_the_omission(self) -> None:
|
||||
"""claude-fable answers a disable with HTTP 400, so don't send one."""
|
||||
kwargs = _kwargs("anthropic/claude-fable-5", {"enabled": False})
|
||||
assert "thinking" not in kwargs
|
||||
|
||||
def test_legacy_manual_thinking_models_keep_the_omission(self) -> None:
|
||||
"""Pre-4.6 thinking is opt-in via budget_tokens: absence IS off."""
|
||||
kwargs = _kwargs("claude-sonnet-4-5", {"enabled": False})
|
||||
assert "thinking" not in kwargs
|
||||
|
||||
def test_haiku_keeps_the_omission(self) -> None:
|
||||
"""Haiku is legacy-manual, so absence already means off."""
|
||||
kwargs = _kwargs("anthropic/claude-haiku-4.5", {"enabled": False})
|
||||
assert "thinking" not in kwargs
|
||||
|
||||
def test_kimi_keeps_its_documented_omission(self) -> None:
|
||||
"""Kimi speaks the adaptive contract but is out of scope here (#13848)."""
|
||||
kwargs = _kwargs(
|
||||
"kimi-k2.5", {"enabled": False}, base_url="https://api.kimi.com/coding"
|
||||
)
|
||||
assert "thinking" not in kwargs
|
||||
|
||||
|
||||
class TestEnablePathIsUnchanged:
|
||||
"""The disable branch must not disturb the thinking-ON contract."""
|
||||
|
||||
def test_adaptive_enable_still_sends_adaptive_plus_effort(self) -> None:
|
||||
kwargs = _kwargs("anthropic/claude-opus-5", {"enabled": True, "effort": "high"})
|
||||
assert kwargs["thinking"] == {"type": "adaptive", "display": "summarized"}
|
||||
assert kwargs["output_config"] == {"effort": "high"}
|
||||
|
||||
def test_mandatory_model_still_thinks_when_asked_to(self) -> None:
|
||||
kwargs = _kwargs("anthropic/claude-fable-5", {"enabled": True, "effort": "max"})
|
||||
assert kwargs["thinking"] == {"type": "adaptive", "display": "summarized"}
|
||||
assert kwargs["output_config"] == {"effort": "max"}
|
||||
|
||||
def test_legacy_enable_still_sends_budget_tokens(self) -> None:
|
||||
kwargs = _kwargs("claude-sonnet-4-5", {"enabled": True, "effort": "high"})
|
||||
assert kwargs["thinking"] == {"type": "enabled", "budget_tokens": 16000}
|
||||
|
||||
def test_haiku_still_never_gets_thinking_on_the_enable_path(self) -> None:
|
||||
kwargs = _kwargs("anthropic/claude-haiku-4.5", {"enabled": True, "effort": "high"})
|
||||
assert "thinking" not in kwargs
|
||||
|
||||
def test_no_reasoning_config_sends_no_thinking_field(self) -> None:
|
||||
assert "thinking" not in _kwargs("anthropic/claude-opus-5", None)
|
||||
|
||||
|
||||
class TestDisableVerdictHelper:
|
||||
"""``_accepts_thinking_disable`` is the single source of the verdict."""
|
||||
|
||||
def test_verdict_matches_the_mandatory_flag_the_catalog_publishes(self) -> None:
|
||||
from agent.anthropic_adapter import _accepts_thinking_disable
|
||||
|
||||
# Portal catalog: reasoning.mandatory is true for fable, false for these.
|
||||
assert _accepts_thinking_disable("anthropic/claude-opus-5") is True
|
||||
assert _accepts_thinking_disable("anthropic/claude-sonnet-5") is True
|
||||
assert _accepts_thinking_disable("anthropic/claude-fable-5") is False
|
||||
|
||||
def test_non_claude_models_are_left_alone(self) -> None:
|
||||
from agent.anthropic_adapter import _accepts_thinking_disable
|
||||
|
||||
for model in ("minimax-m2.7", "qwen3-max", "kimi-k2.5"):
|
||||
assert _accepts_thinking_disable(model) is False, model
|
||||
|
||||
def test_unknown_claude_releases_default_to_disableable(self) -> None:
|
||||
"""Mirrors _supports_adaptive_thinking: new Claude gets the modern
|
||||
contract without a code change."""
|
||||
from agent.anthropic_adapter import _accepts_thinking_disable
|
||||
|
||||
assert _accepts_thinking_disable("anthropic/claude-opus-6") is True
|
||||
Reference in New Issue
Block a user