fix(anthropic): support Opus 5 effort controls (#5236)

This commit is contained in:
chengyongru 2026-08-04 13:38:54 +08:00 committed by GitHub
parent d99f589a59
commit 4e8702a47b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 124 additions and 20 deletions

View File

@ -139,7 +139,7 @@ class AgentDefaults(Base):
validation_alias=AliasChoices("toolHintMaxLength"),
serialization_alias="toolHintMaxLength",
) # 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"
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

View File

@ -31,6 +31,36 @@ def _gen_tool_id() -> str:
_VALID_TOOL_ID = re.compile(r"^[a-zA-Z0-9_-]+$")
_CLAUDE_MODEL_VERSION = re.compile(
r"claude-(?P<family>[a-z]+)-(?P<major>\d+)"
r"(?:-(?P<minor>\d{1,2})(?=-|$))?"
)
_ADAPTIVE_ONLY_MIN_VERSIONS = {
"opus": (4, 7),
"sonnet": (5, 0),
"fable": (5, 0),
"mythos": (5, 0),
}
_THINKING_DISABLE_MIN_VERSIONS = {
"opus": (5, 0),
"sonnet": (5, 0),
}
_SAMPLING_DEPRECATED_MODELS = {"claude-mythos-preview"}
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:
"""Ensure tool_use/tool_result IDs match Anthropic's required pattern.
@ -562,13 +592,13 @@ class AnthropicProvider(LLMProvider):
)
max_tokens = max(1, max_tokens)
thinking_enabled = bool(reasoning_effort) and reasoning_effort.lower() != "none"
# Several Anthropic models (opus-4-7, opus-4-8, sonnet-5, fable) deprecated the
# `temperature` parameter — the API returns 400 if it is present.
_model_lower = model_name.lower()
omit_temperature = any(
m in _model_lower for m in ("opus-4-7", "opus-4-8", "sonnet-5", "fable")
reasoning_effort_lower = reasoning_effort.lower() if reasoning_effort else None
thinking_enabled = reasoning_effort_lower not in (None, "", "none")
adaptive_only = _model_version_at_least(model_name, _ADAPTIVE_ONLY_MIN_VERSIONS)
# Mythos Preview rejects sampling parameters but still accepts manual
# thinking budgets, so it is not part of the adaptive-only capability.
omit_temperature = (
adaptive_only or model_name.lower() in _SAMPLING_DEPRECATED_MODELS
)
kwargs: dict[str, Any] = {
@ -580,16 +610,26 @@ class AnthropicProvider(LLMProvider):
if system:
kwargs["system"] = system
if reasoning_effort == "adaptive":
if reasoning_effort_lower == "none" and _model_version_at_least(
model_name, _THINKING_DISABLE_MIN_VERSIONS
):
# These models think by default, so omission would not honor an
# explicit request to disable thinking.
kwargs["thinking"] = {"type": "disabled"}
elif reasoning_effort_lower == "adaptive":
# 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.
kwargs["thinking"] = {"type": "adaptive"}
if not omit_temperature:
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:
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["max_tokens"] = max(max_tokens, budget + 4096)
if not omit_temperature:

View File

@ -24,7 +24,7 @@ license-files = [
dependencies = [
"typer>=0.20.0,<1.0.0",
"anthropic>=0.45.0,<1.0.0",
"anthropic>=0.100.0,<1.0.0",
"pydantic>=2.12.0,<3.0.0",
"pydantic-settings>=2.12.0,<3.0.0",
# Feishu's lark-oapi currently requires websockets<16; core supports 15 and 16.

View File

@ -4,6 +4,8 @@ from __future__ import annotations
from unittest.mock import patch
import pytest
from nanobot.providers.anthropic_provider import AnthropicProvider
@ -65,17 +67,24 @@ def test_none_does_not_enable_thinking() -> None:
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:
kw = _build(_make_provider("claude-opus-4-7"), "adaptive")
assert "temperature" not in kw
assert kw["thinking"] == {"type": "adaptive"}
def test_opus_4_7_omits_temperature_enabled() -> None:
"""Enabled thinking (high) must also omit temperature for opus-4-7."""
def test_opus_4_7_high_uses_adaptive_effort() -> None:
kw = _build(_make_provider("claude-opus-4-7"), "high", max_tokens=4096)
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:
@ -90,9 +99,11 @@ def test_opus_4_8_omits_temperature_adaptive() -> None:
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)
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:
@ -105,9 +116,11 @@ def test_fable_omits_temperature_adaptive() -> None:
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)
assert "temperature" not in kw
assert kw["thinking"] == {"type": "adaptive"}
assert kw["output_config"] == {"effort": "high"}
def test_fable_omits_temperature_none() -> None:
@ -121,16 +134,67 @@ def test_sonnet_5_omits_temperature_adaptive() -> None:
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)
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:
kw = _build(_make_provider("anthropic/claude-sonnet-5"), None)
kw = _build(_make_provider("anthropic/claude-sonnet-5"), "none")
assert "temperature" not in kw
assert kw["thinking"] == {"type": "disabled"}
assert "output_config" not in kw
def test_mythos_preview_omits_temperature_but_keeps_manual_budget() -> None:
kw = _build(_make_provider("claude-mythos-preview"), "high", max_tokens=4096)
assert "temperature" not in kw
assert kw["thinking"] == {"type": "enabled", "budget_tokens": 8192}
assert "output_config" not in kw
@pytest.mark.parametrize(
"reasoning_effort", [None, "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
def test_opus_5_none_disables_default_thinking() -> None:
kw = _build(_make_provider("claude-opus-5"), "none")
assert kw["thinking"] == {"type": "disabled"}
assert "output_config" not in kw
def test_opus_5_unset_preserves_provider_default() -> None:
kw = _build(_make_provider("claude-opus-5"), None)
assert "thinking" not in kw
assert "output_config" 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_dated_opus_4_model_does_not_treat_date_as_minor_version() -> None:
kw = _build(_make_provider("claude-opus-4-20250514"), "high")
assert kw["temperature"] == 1.0
assert kw["thinking"] == {"type": "enabled", "budget_tokens": 8192}
assert "output_config" not in kw
def test_ordinary_model_sends_temperature() -> None: