fix(anthropic): support Opus 5 effort controls

This commit is contained in:
chengyongru 2026-08-04 09:54:09 +08:00
parent 44b7e1bf41
commit 39b2294ecf
3 changed files with 85 additions and 18 deletions

View File

@ -139,7 +139,7 @@ class AgentDefaults(Base):
validation_alias=AliasChoices("toolHintMaxLength"), validation_alias=AliasChoices("toolHintMaxLength"),
serialization_alias="toolHintMaxLength", serialization_alias="toolHintMaxLength",
) # Max characters for tool hint display (e.g. "$ cd …/project && npm test") ) # Max characters for tool hint display (e.g. "$ cd …/project && npm test")
reasoning_effort: str | None = None # low / medium / high / adaptive / none — LLM thinking effort; None preserves the provider default reasoning_effort: str | None = None # low / medium / high / xhigh / max / adaptive / none — LLM thinking effort; None preserves the provider default
timezone: str = "UTC" # IANA timezone, e.g. "Asia/Shanghai", "America/New_York" timezone: str = "UTC" # IANA timezone, e.g. "Asia/Shanghai", "America/New_York"
bot_name: str = "nanobot" # Display name shown in CLI prompts (e.g. "{name} is thinking...") bot_name: str = "nanobot" # Display name shown in CLI prompts (e.g. "{name} is thinking...")
bot_icon: str = "🐈" # Short icon (emoji or text) shown next to the bot name in CLI; "" to omit bot_icon: str = "🐈" # Short icon (emoji or text) shown next to the bot name in CLI; "" to omit

View File

@ -31,6 +31,36 @@ def _gen_tool_id() -> str:
_VALID_TOOL_ID = re.compile(r"^[a-zA-Z0-9_-]+$") _VALID_TOOL_ID = re.compile(r"^[a-zA-Z0-9_-]+$")
_CLAUDE_MODEL_VERSION = re.compile(
r"claude-(?P<family>[a-z]+)-(?P<major>\d+)(?:-(?P<minor>\d+))?"
)
_ADAPTIVE_ONLY_MIN_VERSIONS = {
"opus": (4, 7),
"sonnet": (5, 0),
"fable": (5, 0),
"mythos": (5, 0),
}
_SAMPLING_DEPRECATED_MIN_VERSIONS = {
"opus": (4, 7),
"sonnet": (5, 0),
"fable": (5, 0),
"mythos": (5, 0),
}
def _model_version_at_least(
model_name: str,
minimum_versions: dict[str, tuple[int, int]],
) -> bool:
match = _CLAUDE_MODEL_VERSION.search(model_name.lower())
if match is None:
return False
minimum = minimum_versions.get(match.group("family"))
if minimum is None:
return False
version = (int(match.group("major")), int(match.group("minor") or 0))
return version >= minimum
def _sanitize_tool_id(tid: str) -> str: def _sanitize_tool_id(tid: str) -> str:
"""Ensure tool_use/tool_result IDs match Anthropic's required pattern. """Ensure tool_use/tool_result IDs match Anthropic's required pattern.
@ -562,13 +592,11 @@ class AnthropicProvider(LLMProvider):
) )
max_tokens = max(1, max_tokens) max_tokens = max(1, max_tokens)
thinking_enabled = bool(reasoning_effort) and reasoning_effort.lower() != "none" reasoning_effort_lower = reasoning_effort.lower() if reasoning_effort else None
thinking_enabled = reasoning_effort_lower not in (None, "", "none")
# Several Anthropic models (opus-4-7, opus-4-8, sonnet-5, fable) deprecated the adaptive_only = _model_version_at_least(model_name, _ADAPTIVE_ONLY_MIN_VERSIONS)
# `temperature` parameter — the API returns 400 if it is present. omit_temperature = _model_version_at_least(
_model_lower = model_name.lower() model_name, _SAMPLING_DEPRECATED_MIN_VERSIONS
omit_temperature = any(
m in _model_lower for m in ("opus-4-7", "opus-4-8", "sonnet-5", "fable")
) )
kwargs: dict[str, Any] = { kwargs: dict[str, Any] = {
@ -580,16 +608,20 @@ class AnthropicProvider(LLMProvider):
if system: if system:
kwargs["system"] = system kwargs["system"] = system
if reasoning_effort == "adaptive": if reasoning_effort_lower == "adaptive":
# Adaptive thinking: model decides when and how much to think # Adaptive thinking: model decides when and how much to think
# Supported on claude-sonnet-4-6 and claude-opus-4-6.
# Also auto-enables interleaved thinking between tool calls. # Also auto-enables interleaved thinking between tool calls.
kwargs["thinking"] = {"type": "adaptive"} kwargs["thinking"] = {"type": "adaptive"}
if not omit_temperature: if not omit_temperature:
kwargs["temperature"] = 1.0 kwargs["temperature"] = 1.0
elif thinking_enabled and adaptive_only:
# Newer Claude models removed manual token budgets. Their effort
# control is independent from the adaptive thinking mode.
kwargs["thinking"] = {"type": "adaptive"}
kwargs["output_config"] = {"effort": reasoning_effort_lower}
elif thinking_enabled: elif thinking_enabled:
budget_map = {"low": 1024, "medium": 4096, "high": max(8192, max_tokens)} budget_map = {"low": 1024, "medium": 4096, "high": max(8192, max_tokens)}
budget = budget_map.get(cast(str, reasoning_effort).lower(), 4096) budget = budget_map.get(reasoning_effort_lower, 4096)
kwargs["thinking"] = {"type": "enabled", "budget_tokens": budget} kwargs["thinking"] = {"type": "enabled", "budget_tokens": budget}
kwargs["max_tokens"] = max(max_tokens, budget + 4096) kwargs["max_tokens"] = max(max_tokens, budget + 4096)
if not omit_temperature: if not omit_temperature:

View File

@ -4,6 +4,8 @@ from __future__ import annotations
from unittest.mock import patch from unittest.mock import patch
import pytest
from nanobot.providers.anthropic_provider import AnthropicProvider from nanobot.providers.anthropic_provider import AnthropicProvider
@ -65,17 +67,24 @@ def test_none_does_not_enable_thinking() -> None:
assert kw["temperature"] == 0.7 assert kw["temperature"] == 0.7
def test_empty_effort_does_not_enable_thinking() -> None:
kw = _build(_make_provider(), "")
assert "thinking" not in kw
assert kw["temperature"] == 0.7
def test_opus_4_7_omits_temperature_adaptive() -> None: def test_opus_4_7_omits_temperature_adaptive() -> None:
kw = _build(_make_provider("claude-opus-4-7"), "adaptive") kw = _build(_make_provider("claude-opus-4-7"), "adaptive")
assert "temperature" not in kw assert "temperature" not in kw
assert kw["thinking"] == {"type": "adaptive"} assert kw["thinking"] == {"type": "adaptive"}
def test_opus_4_7_omits_temperature_enabled() -> None: def test_opus_4_7_high_uses_adaptive_effort() -> None:
"""Enabled thinking (high) must also omit temperature for opus-4-7."""
kw = _build(_make_provider("claude-opus-4-7"), "high", max_tokens=4096) kw = _build(_make_provider("claude-opus-4-7"), "high", max_tokens=4096)
assert "temperature" not in kw assert "temperature" not in kw
assert kw["thinking"]["type"] == "enabled" assert kw["thinking"] == {"type": "adaptive"}
assert kw["output_config"] == {"effort": "high"}
assert kw["max_tokens"] == 4096
def test_opus_4_7_omits_temperature_none() -> None: def test_opus_4_7_omits_temperature_none() -> None:
@ -90,9 +99,11 @@ def test_opus_4_8_omits_temperature_adaptive() -> None:
assert "temperature" not in kw assert "temperature" not in kw
def test_opus_4_8_omits_temperature_enabled() -> None: def test_opus_4_8_high_uses_adaptive_effort() -> None:
kw = _build(_make_provider("claude-opus-4-8"), "high", max_tokens=4096) kw = _build(_make_provider("claude-opus-4-8"), "high", max_tokens=4096)
assert "temperature" not in kw assert "temperature" not in kw
assert kw["thinking"] == {"type": "adaptive"}
assert kw["output_config"] == {"effort": "high"}
def test_opus_4_8_omits_temperature_none() -> None: def test_opus_4_8_omits_temperature_none() -> None:
@ -105,9 +116,11 @@ def test_fable_omits_temperature_adaptive() -> None:
assert "temperature" not in kw assert "temperature" not in kw
def test_fable_omits_temperature_enabled() -> None: def test_fable_high_uses_adaptive_effort() -> None:
kw = _build(_make_provider("claude-fable-5"), "high", max_tokens=4096) kw = _build(_make_provider("claude-fable-5"), "high", max_tokens=4096)
assert "temperature" not in kw assert "temperature" not in kw
assert kw["thinking"] == {"type": "adaptive"}
assert kw["output_config"] == {"effort": "high"}
def test_fable_omits_temperature_none() -> None: def test_fable_omits_temperature_none() -> None:
@ -121,10 +134,11 @@ def test_sonnet_5_omits_temperature_adaptive() -> None:
assert kw["thinking"] == {"type": "adaptive"} assert kw["thinking"] == {"type": "adaptive"}
def test_sonnet_5_omits_temperature_enabled() -> None: def test_sonnet_5_high_uses_adaptive_effort() -> None:
kw = _build(_make_provider("claude-sonnet-5"), "high", max_tokens=4096) kw = _build(_make_provider("claude-sonnet-5"), "high", max_tokens=4096)
assert "temperature" not in kw assert "temperature" not in kw
assert kw["thinking"]["type"] == "enabled" assert kw["thinking"] == {"type": "adaptive"}
assert kw["output_config"] == {"effort": "high"}
def test_sonnet_5_omits_temperature_none() -> None: def test_sonnet_5_omits_temperature_none() -> None:
@ -133,6 +147,27 @@ def test_sonnet_5_omits_temperature_none() -> None:
assert "thinking" not in kw assert "thinking" not in kw
@pytest.mark.parametrize("reasoning_effort", [None, "adaptive", "low", "medium", "high", "xhigh", "max"])
def test_opus_5_omits_temperature(reasoning_effort: str | None) -> None:
kw = _build(_make_provider("claude-opus-5"), reasoning_effort)
assert "temperature" not in kw
@pytest.mark.parametrize("reasoning_effort", ["low", "medium", "high", "xhigh", "max"])
def test_opus_5_uses_adaptive_thinking_with_effort(reasoning_effort: str) -> None:
kw = _build(_make_provider("claude-opus-5"), reasoning_effort, max_tokens=4096)
assert kw["thinking"] == {"type": "adaptive"}
assert kw["output_config"] == {"effort": reasoning_effort}
assert kw["max_tokens"] == 4096
def test_dated_opus_5_model_uses_family_capabilities() -> None:
kw = _build(_make_provider("claude-opus-5-20260724"), "medium")
assert "temperature" not in kw
assert kw["thinking"] == {"type": "adaptive"}
assert kw["output_config"] == {"effort": "medium"}
def test_ordinary_model_sends_temperature() -> None: def test_ordinary_model_sends_temperature() -> None:
kw = _build(_make_provider("claude-sonnet-4-6"), None) kw = _build(_make_provider("claude-sonnet-4-6"), None)
assert kw["temperature"] == 0.7 assert kw["temperature"] == 0.7