mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 17:38:35 +00:00
fix(anthropic): support Opus 5 effort controls
This commit is contained in:
parent
44b7e1bf41
commit
39b2294ecf
@ -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
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user