fix(anthropic): default new Claude models to the modern thinking contract (#42991)
New Anthropic models without a recognized version substring (claude-fable-5
and future named/numbered releases) were classified as legacy and routed down
the manual-thinking path, which made OpenRouter emit thinking.type.disabled —
a form reasoning-mandatory Claude models reject with a non-retryable HTTP 400.
Invert the brittle version-substring allowlists to default-to-modern (mirroring
_get_anthropic_max_output): unknown Claude models get the adaptive/xhigh/
no-sampling contract, with an explicit legacy list for older families. Non-Claude
Anthropic-Messages models (minimax, qwen3, …) keep the manual path.
- anthropic_adapter: _supports_adaptive_thinking / _supports_xhigh_effort /
_forbids_sampling_params now default unknown Claude models to modern; legacy
families enumerated in _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS.
- openrouter profile: omit reasoning entirely (→ adaptive default) instead of
forwarding {enabled:false} for reasoning-mandatory Anthropic models; legacy
Anthropic + all non-Anthropic models still pass the disable form through.
- model_metadata + output-limit table: register claude-fable-5 (1M ctx, 128K out).
Tests assert the invariant ("unknown Claude model -> modern contract; legacy
stays manual; non-Claude unaffected"), not specific model names.
This commit is contained in:
parent
39b76d9013
commit
1febb08240
5 changed files with 248 additions and 20 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue