diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 965f0514c..83f6aa46e 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -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 diff --git a/nanobot/providers/anthropic_provider.py b/nanobot/providers/anthropic_provider.py index 943a46038..0c3f3ebf2 100644 --- a/nanobot/providers/anthropic_provider.py +++ b/nanobot/providers/anthropic_provider.py @@ -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[a-z]+)-(?P\d+)(?:-(?P\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: """Ensure tool_use/tool_result IDs match Anthropic's required pattern. @@ -562,13 +592,11 @@ 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) + omit_temperature = _model_version_at_least( + model_name, _SAMPLING_DEPRECATED_MIN_VERSIONS ) kwargs: dict[str, Any] = { @@ -580,16 +608,20 @@ class AnthropicProvider(LLMProvider): if system: kwargs["system"] = system - if reasoning_effort == "adaptive": + if 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: diff --git a/tests/providers/test_anthropic_thinking.py b/tests/providers/test_anthropic_thinking.py index 0672709e4..0f193c616 100644 --- a/tests/providers/test_anthropic_thinking.py +++ b/tests/providers/test_anthropic_thinking.py @@ -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,10 +134,11 @@ 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: @@ -133,6 +147,27 @@ def test_sonnet_5_omits_temperature_none() -> None: 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: kw = _build(_make_provider("claude-sonnet-4-6"), None) assert kw["temperature"] == 0.7