diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 389b21831..e64bc54bc 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -73,20 +73,50 @@ ADAPTIVE_EFFORT_MAP = { "minimal": "low", } -# Models that accept the "xhigh" output_config.effort level. Opus 4.7 added -# xhigh as a distinct level between high and max; older adaptive-thinking -# models (4.6) reject it with a 400. Keep this substring list in sync with -# the Anthropic migration guide as new model families ship. -_XHIGH_EFFORT_SUBSTRINGS = ("4-7", "4.7", "4-8", "4.8") +# ── Anthropic thinking-mode classification ──────────────────────────── +# Claude 4.6 replaced budget-based extended thinking with *adaptive* thinking, +# and 4.7 additionally forbids the manual ``thinking`` block entirely and drops +# temperature/top_p/top_k. Newer Claude releases (4.8, and named models like +# claude-fable-5) follow the same modern contract — but they share no common +# version substring, so an allowlist of version numbers ("4.6", "4.7", …) goes +# stale the moment a model ships without a recognized number and silently +# routes it down the legacy manual-thinking path. +# +# Instead we DEFAULT unknown Claude models to the modern contract and keep an +# explicit *legacy* list of the older Claude families that still require manual +# thinking. This mirrors _get_anthropic_max_output's "default to newest" design +# (future models are unlikely to regress to the older contract), so each new +# Claude release works without a code change. +# +# Non-Claude Anthropic-Messages models (minimax, qwen3, GLM, …) are NOT Claude, +# so they fall through to the legacy path automatically — exactly what those +# manual-thinking endpoints need. + +# Older Claude families that DON'T support adaptive thinking (manual thinking +# with budget_tokens only). Substring-matched against the model name. +_LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS = ( + "claude-3", # 3, 3.5, 3.7 + "claude-opus-4-0", "claude-opus-4.0", "claude-opus-4-1", "claude-opus-4.1", + "claude-sonnet-4-0", "claude-sonnet-4.0", + "claude-opus-4-2025", "claude-sonnet-4-2025", # date-stamped 4.0 IDs + "claude-opus-4-5", "claude-opus-4.5", + "claude-sonnet-4-5", "claude-sonnet-4.5", + "claude-haiku-4-5", "claude-haiku-4.5", +) + +# Older Claude families that DON'T accept the "xhigh" effort level (4.6 only +# supports low/medium/high/max). xhigh arrived with Opus 4.7. Adaptive models +# not in this list (4.7, 4.8, fable, future) accept xhigh. +_NO_XHIGH_CLAUDE_SUBSTRINGS = ( + "claude-opus-4-6", "claude-opus-4.6", + "claude-sonnet-4-6", "claude-sonnet-4.6", +) + + +def _is_claude_model(model: str | None) -> bool: + return "claude" in (model or "").lower() -# Models where extended thinking is deprecated/removed (4.6+ behavior: adaptive -# is the only supported mode; 4.7 additionally forbids manual thinking entirely -# and drops temperature/top_p/top_k). -_ADAPTIVE_THINKING_SUBSTRINGS = ("4-6", "4.6", "4-7", "4.7", "4-8", "4.8") -# Models where temperature/top_p/top_k return 400 if set to non-default values. -# This is the Opus 4.7 contract; future 4.x+ models are expected to follow it. -_NO_SAMPLING_PARAMS_SUBSTRINGS = ("4-7", "4.7", "4-8", "4.8") _FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6") # ── Max output token limits per Anthropic model ─────────────────────── @@ -94,6 +124,8 @@ _FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6") # max_tokens as a mandatory field. Previously we hardcoded 16384, which # starves thinking-enabled models (thinking tokens count toward the limit). _ANTHROPIC_OUTPUT_LIMITS = { + # Mythos-class named models (claude-fable-5, …) — 1M context, reasoning + "claude-fable": 128_000, # Claude 4.8 "claude-opus-4-8": 128_000, # Claude 4.7 @@ -208,8 +240,17 @@ def _resolve_anthropic_messages_max_tokens( def _supports_adaptive_thinking(model: str) -> bool: - """Return True for Claude 4.6+ models that support adaptive thinking.""" - return any(v in model for v in _ADAPTIVE_THINKING_SUBSTRINGS) + """Return True for Claude models that use adaptive thinking (4.6+). + + Defaults *unknown* Claude models to adaptive (the modern contract) and + only returns False for the explicit legacy list of older Claude families + that require manual budget-based thinking. Non-Claude Anthropic-Messages + models (minimax, qwen3, …) return False so they keep the manual path. + """ + if not _is_claude_model(model): + return False + m = model.lower() + return not any(v in m for v in _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS) def _supports_xhigh_effort(model: str) -> bool: @@ -219,18 +260,33 @@ def _supports_xhigh_effort(model: str) -> bool: Pre-4.7 adaptive models (Opus/Sonnet 4.6) only accept low/medium/high/max and reject xhigh with an HTTP 400. Callers should downgrade xhigh→max when this returns False. + + Defaults unknown adaptive Claude models to accepting xhigh (4.7+ contract); + only the 4.6 family and legacy manual-thinking models are excluded. """ - return any(v in model for v in _XHIGH_EFFORT_SUBSTRINGS) + if not _supports_adaptive_thinking(model): + return False + m = model.lower() + return not any(v in m for v in _NO_XHIGH_CLAUDE_SUBSTRINGS) def _forbids_sampling_params(model: str) -> bool: """Return True for models that 400 on any non-default temperature/top_p/top_k. - Opus 4.7 explicitly rejects sampling parameters; later Claude releases are - expected to follow suit. Callers should omit these fields entirely rather - than passing zero/default values (the API rejects anything non-null). + Opus 4.7 introduced this restriction; later Claude releases follow it. + Defaults unknown Claude models to forbidding sampling params (the modern + contract). The 4.6 family still accepts them, and the legacy manual-thinking + families (4.5 and older) accept them too, so both are excluded. Non-Claude + models are unaffected. Callers should omit these fields entirely rather than + passing zero/default values (the API rejects anything non-null). """ - return any(v in model for v in _NO_SAMPLING_PARAMS_SUBSTRINGS) + if not _is_claude_model(model): + return False + m = model.lower() + # 4.6 family is adaptive but still accepts sampling params. + if any(v in m for v in _NO_XHIGH_CLAUDE_SUBSTRINGS): + return False + return not any(v in m for v in _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS) def _supports_fast_mode(model: str) -> bool: diff --git a/agent/model_metadata.py b/agent/model_metadata.py index bc9186c03..b2c5a1c9f 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -141,6 +141,8 @@ DEFAULT_CONTEXT_LENGTHS = { # fuzzy-match collisions (e.g. "anthropic/claude-sonnet-4" is a # substring of "anthropic/claude-sonnet-4.6"). # OpenRouter-prefixed models resolve via OpenRouter live API or models.dev. + "claude-fable-5": 1000000, + "claude-fable": 1000000, "claude-opus-4-8": 1000000, "claude-opus-4.8": 1000000, "claude-opus-4-7": 1000000, diff --git a/plugins/model-providers/openrouter/__init__.py b/plugins/model-providers/openrouter/__init__.py index 1b464b42e..1695fce50 100644 --- a/plugins/model-providers/openrouter/__init__.py +++ b/plugins/model-providers/openrouter/__init__.py @@ -10,6 +10,39 @@ logger = logging.getLogger(__name__) _CACHE: list[str] | None = None +# Anthropic model families that still accept an explicit "disable thinking" +# request (the manual ``thinking: {type: "disabled"}`` form OpenRouter emits +# for ``reasoning: {enabled: false}``). Everything Claude 4.6 and newer — +# including future date-stamped / named models (fable, mythos-class, …) — +# mandates reasoning and returns HTTP 400 on any disable form. We therefore +# default *unknown* Anthropic models to "cannot disable" (the modern contract) +# and keep only this explicit legacy allowlist of models that can. Mirrors the +# default-to-newest philosophy in agent/anthropic_adapter._get_anthropic_max_output. +_ANTHROPIC_REASONING_OPTIONAL_SUBSTRINGS = ( + "claude-3", # 3, 3.5, 3.7 + "claude-opus-4-0", "claude-opus-4.0", "claude-opus-4-1", "claude-opus-4.1", + "claude-sonnet-4-0", "claude-sonnet-4.0", + "claude-opus-4-2025", "claude-sonnet-4-2025", # date-stamped 4.0 IDs + "claude-opus-4-5", "claude-opus-4.5", + "claude-sonnet-4-5", "claude-sonnet-4.5", + "claude-haiku-4-5", "claude-haiku-4.5", +) + + +def _anthropic_reasoning_is_mandatory(model: str | None) -> bool: + """Return True for Anthropic models that reject any disable-thinking form. + + Claude 4.6+ (adaptive thinking) and newer named models have no "off" + switch — sending ``reasoning: {enabled: false}`` makes OpenRouter emit + ``thinking: {type: "disabled"}``, which these models 400 on. Unknown / + new Anthropic model names default to mandatory so the next un-numbered + release doesn't reintroduce the 400. + """ + m = (model or "").lower() + if not m.startswith(("anthropic/", "claude")) and "claude" not in m: + return False + return not any(sub in m for sub in _ANTHROPIC_REASONING_OPTIONAL_SUBSTRINGS) + class OpenRouterProfile(ProviderProfile): """OpenRouter aggregator — provider preferences, reasoning config passthrough.""" @@ -85,7 +118,18 @@ class OpenRouterProfile(ProviderProfile): extra_body: dict[str, Any] = {} if supports_reasoning: if reasoning_config is not None: - extra_body["reasoning"] = dict(reasoning_config) + cfg = dict(reasoning_config) + # Reasoning-mandatory Anthropic models (Claude 4.6+ / fable / + # future named models) have no "off" switch. Forwarding + # ``{enabled: false}`` makes OpenRouter emit Anthropic's manual + # ``thinking: {type: "disabled"}``, which those models reject + # with a non-retryable HTTP 400. Omit reasoning entirely so the + # model falls back to its default (adaptive) thinking instead. + disabling = cfg.get("enabled") is False or cfg.get("effort") == "none" + if disabling and _anthropic_reasoning_is_mandatory(model): + pass # leave reasoning unset → adaptive default + else: + extra_body["reasoning"] = cfg else: extra_body["reasoning"] = {"enabled": True, "effort": "medium"} diff --git a/tests/agent/test_anthropic_adapter.py b/tests/agent/test_anthropic_adapter.py index 818a016f4..79c9c286e 100644 --- a/tests/agent/test_anthropic_adapter.py +++ b/tests/agent/test_anthropic_adapter.py @@ -1213,6 +1213,73 @@ class TestBuildAnthropicKwargs: assert _supports_fast_mode("claude-haiku-4-5") is False assert _supports_fast_mode("") is False + def test_fable_class_models_route_as_adaptive_thinking(self): + """Invariant: unknown/new Claude models default to the modern (4.7+) + contract — adaptive thinking, xhigh-capable, sampling-params-forbidden — + without any per-model code change. Named models (claude-fable-5) and + hypothetical future ones must all classify modern; only the explicit + legacy list stays on the manual path. + """ + from agent.anthropic_adapter import ( + _supports_adaptive_thinking, + _supports_xhigh_effort, + _forbids_sampling_params, + _get_anthropic_max_output, + ) + # New / unknown Claude models → modern contract by default. + for m in ( + "claude-fable-5", + "anthropic/claude-fable-5", + "claude-saga-2", # hypothetical future named model + "anthropic/claude-opus-9", # hypothetical future numbered model + ): + assert _supports_adaptive_thinking(m) is True, m + assert _supports_xhigh_effort(m) is True, m + assert _forbids_sampling_params(m) is True, m + # 1M-context reasoning model → highest output ceiling. + assert _get_anthropic_max_output("anthropic/claude-fable-5") == 128_000 + + def test_legacy_claude_stays_on_manual_thinking(self): + """Older Claude families keep the legacy manual-thinking contract.""" + from agent.anthropic_adapter import ( + _supports_adaptive_thinking, + _forbids_sampling_params, + ) + for m in ( + "claude-3-5-sonnet", + "claude-3-7-sonnet", + "anthropic/claude-opus-4.5", + "anthropic/claude-sonnet-4.5", + "claude-haiku-4-5", + ): + assert _supports_adaptive_thinking(m) is False, m + assert _forbids_sampling_params(m) is False, m + + def test_claude_46_is_adaptive_but_not_xhigh_or_no_sampling(self): + """4.6 is adaptive, but predates xhigh and still accepts sampling.""" + from agent.anthropic_adapter import ( + _supports_adaptive_thinking, + _supports_xhigh_effort, + _forbids_sampling_params, + ) + for m in ("claude-opus-4.6", "claude-sonnet-4-6"): + assert _supports_adaptive_thinking(m) is True, m + assert _supports_xhigh_effort(m) is False, m + assert _forbids_sampling_params(m) is False, m + + def test_non_claude_anthropic_models_use_manual_path(self): + """Non-Claude Anthropic-Messages models (minimax, qwen3, kimi) must not + be misclassified as adaptive by the default-to-modern rule.""" + from agent.anthropic_adapter import ( + _supports_adaptive_thinking, + _supports_xhigh_effort, + _forbids_sampling_params, + ) + for m in ("minimax-m2", "qwen3-max", "moonshotai/kimi-k2.5", "glm-4.6"): + assert _supports_adaptive_thinking(m) is False, m + assert _supports_xhigh_effort(m) is False, m + assert _forbids_sampling_params(m) is False, m + def test_fast_mode_omitted_for_unsupported_model(self): """fast_mode=True on Opus 4.7 must NOT inject speed=fast (API 400s).""" kwargs = build_anthropic_kwargs( diff --git a/tests/providers/test_provider_profiles.py b/tests/providers/test_provider_profiles.py index 438eddddf..3b6948e35 100644 --- a/tests/providers/test_provider_profiles.py +++ b/tests/providers/test_provider_profiles.py @@ -169,6 +169,65 @@ class TestOpenRouterProfile: ) assert eb["reasoning"] == {"enabled": False} + def test_reasoning_disable_omitted_for_mandatory_anthropic(self): + """Reasoning-mandatory Anthropic models (4.6+/fable) reject any disable + form: OpenRouter translates ``reasoning: {enabled: false}`` into + Anthropic's ``thinking: {type: disabled}``, which 400s. The profile must + omit ``reasoning`` so the model falls back to adaptive thinking instead. + """ + p = get_provider_profile("openrouter") + for model in ( + "anthropic/claude-fable-5", # new named model + "anthropic/claude-some-future-7", # unknown → default mandatory + "anthropic/claude-opus-4.8", + "anthropic/claude-opus-4.6", + ): + for cfg in ({"enabled": False}, {"effort": "none"}): + eb, _ = p.build_api_kwargs_extras( + reasoning_config=cfg, + supports_reasoning=True, + model=model, + ) + assert "reasoning" not in eb, (model, cfg, eb) + + def test_reasoning_disable_kept_for_legacy_anthropic(self): + """Older Anthropic models still accept an explicit disable form, so the + profile must keep forwarding it.""" + p = get_provider_profile("openrouter") + for model in ( + "anthropic/claude-3.7-sonnet", + "anthropic/claude-opus-4.5", + "anthropic/claude-sonnet-4.5", + ): + eb, _ = p.build_api_kwargs_extras( + reasoning_config={"enabled": False}, + supports_reasoning=True, + model=model, + ) + assert eb["reasoning"] == {"enabled": False}, (model, eb) + + def test_reasoning_disable_kept_for_non_anthropic(self): + """Non-Anthropic models (DeepSeek, Qwen, …) disable reasoning fine; the + Anthropic-mandatory guard must not touch them.""" + p = get_provider_profile("openrouter") + for model in ("deepseek/deepseek-chat", "qwen/qwen3-max", "openai/gpt-5.4"): + eb, _ = p.build_api_kwargs_extras( + reasoning_config={"enabled": False}, + supports_reasoning=True, + model=model, + ) + assert eb["reasoning"] == {"enabled": False}, (model, eb) + + def test_reasoning_enabled_unaffected_for_mandatory_anthropic(self): + """Enabling reasoning on a mandatory model still forwards the config.""" + p = get_provider_profile("openrouter") + eb, _ = p.build_api_kwargs_extras( + reasoning_config={"enabled": True, "effort": "medium"}, + supports_reasoning=True, + model="anthropic/claude-fable-5", + ) + assert eb["reasoning"] == {"enabled": True, "effort": "medium"} + def test_default_reasoning(self): p = get_provider_profile("openrouter") eb, _ = p.build_api_kwargs_extras(supports_reasoning=True)