fix(openrouter): never send reasoning field for adaptive Anthropic models (#43012)

The previous fix (#42991) only omitted reasoning when it was being disabled.
But reasoning-mandatory Anthropic models (Claude 4.6+, fable) 400 with
thinking.type.disabled on EVERY tool-continuation turn even when reasoning is
enabled: chat_completions never replays signed thinking blocks, so the prior
assistant tool_call has no thinking, and OpenRouter resolves "reasoning
requested but history has none" by emitting thinking.type.disabled — which
these models reject. Result: first turn works, every turn after the first tool
call dies (HTTP 400, non-retryable).

OpenRouter ignores reasoning.effort for adaptive Anthropic models anyway (the
model self-decides), so the reasoning field is pointless for them on every turn
and harmful on tool-replay turns. Omit it entirely → adaptive default.

- openrouter profile: drop the reasoning field for reasoning-mandatory Anthropic
  models regardless of enabled/disabled; legacy Anthropic + non-Anthropic models
  unchanged.
- tests: assert omission across enabled/disabled/effort variants; parity tests
  switched to a non-Anthropic reasoning model (deepseek) since Anthropic 4.6+ no
  longer carries a reasoning field.

Verified live end-to-end: a tool-replay turn on anthropic/claude-fable-5 with
reasoning enabled now builds extra_body=None and returns HTTP 200 (was 400).
This commit is contained in:
Siddharth Balyan 2026-06-10 00:18:23 +05:30 committed by GitHub
parent ba44de06da
commit 46fedef07f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 59 additions and 27 deletions

View file

@ -117,19 +117,25 @@ class OpenRouterProfile(ProviderProfile):
"""
extra_body: dict[str, Any] = {}
if supports_reasoning:
if reasoning_config is not None:
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
# Reasoning-mandatory Anthropic models (Claude 4.6+ / fable /
# future named models) use *adaptive* thinking: the model decides
# how much to think, and OpenRouter ignores ``reasoning.effort`` for
# them entirely. Sending any ``reasoning`` field is therefore both
# pointless and actively harmful:
# - ``{enabled: false}`` → OpenRouter emits Anthropic's manual
# ``thinking: {type: "disabled"}``, which these models 400 on.
# - any enabled form, on a tool-continuation turn whose prior
# assistant tool_call carries no thinking block (chat_completions
# never replays signed thinking blocks), ALSO makes OpenRouter
# emit ``thinking: {type: "disabled"}`` → the same 400 on every
# turn after the first tool call.
# The only reliable behavior is to omit ``reasoning`` and let the
# model default to adaptive. See hermes-agent#42991 (disable case)
# and the tool-replay follow-up.
if _anthropic_reasoning_is_mandatory(model):
pass # omit reasoning entirely → adaptive default
elif reasoning_config is not None:
extra_body["reasoning"] = dict(reasoning_config)
else:
extra_body["reasoning"] = {"enabled": True, "effort": "medium"}

View file

@ -124,11 +124,11 @@ class TestOpenRouterProfileParity:
def test_reasoning_full_config(self, transport):
rc = {"enabled": True, "effort": "high"}
legacy = transport.build_kwargs(
model="anthropic/claude-sonnet-4.6", messages=_msgs(), tools=None,
model="deepseek/deepseek-chat", messages=_msgs(), tools=None,
provider_profile=get_provider_profile("openrouter"), supports_reasoning=True, reasoning_config=rc,
)
profile = transport.build_kwargs(
model="anthropic/claude-sonnet-4.6", messages=_msgs(), tools=None,
model="deepseek/deepseek-chat", messages=_msgs(), tools=None,
provider_profile=get_provider_profile("openrouter"),
supports_reasoning=True, reasoning_config=rc,
)
@ -136,11 +136,11 @@ class TestOpenRouterProfileParity:
def test_default_reasoning(self, transport):
legacy = transport.build_kwargs(
model="anthropic/claude-sonnet-4.6", messages=_msgs(), tools=None,
model="deepseek/deepseek-chat", messages=_msgs(), tools=None,
provider_profile=get_provider_profile("openrouter"), supports_reasoning=True,
)
profile = transport.build_kwargs(
model="anthropic/claude-sonnet-4.6", messages=_msgs(), tools=None,
model="deepseek/deepseek-chat", messages=_msgs(), tools=None,
provider_profile=get_provider_profile("openrouter"),
supports_reasoning=True,
)

View file

@ -218,15 +218,27 @@ class TestOpenRouterProfile:
)
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."""
def test_reasoning_omitted_for_mandatory_anthropic_even_when_enabled(self):
"""Reasoning-mandatory Anthropic models (4.6+/fable) use adaptive
thinking OpenRouter ignores reasoning.effort for them, and sending any
reasoning field makes OpenRouter emit thinking.type.disabled on
tool-continuation turns (whose assistant tool_calls carry no thinking
block), 400ing every turn after the first tool call. The profile must
omit reasoning entirely so the model defaults to adaptive.
"""
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"}
for cfg in (
{"enabled": True, "effort": "medium"},
{"enabled": True, "effort": "xhigh"},
{"effort": "high"},
{"enabled": True},
):
eb, _ = p.build_api_kwargs_extras(
reasoning_config=cfg,
supports_reasoning=True,
model="anthropic/claude-fable-5",
)
assert "reasoning" not in eb, (cfg, eb)
def test_default_reasoning(self):
p = get_provider_profile("openrouter")

View file

@ -160,7 +160,7 @@ class TestOpenRouterParity:
"""OpenRouter passes the FULL reasoning_config dict, not just effort."""
rc = {"enabled": True, "effort": "high"}
kw = transport.build_kwargs(
model="anthropic/claude-sonnet-4.6",
model="deepseek/deepseek-chat",
messages=_simple_messages(),
tools=None,
provider_profile=get_provider_profile("openrouter"),
@ -169,10 +169,24 @@ class TestOpenRouterParity:
)
assert kw["extra_body"]["reasoning"] == rc
def test_reasoning_omitted_for_mandatory_anthropic(self, transport):
"""Adaptive-thinking Anthropic models (4.6+/fable) get NO reasoning
field sending one makes OpenRouter emit thinking.type.disabled on
tool-replay turns, which the model 400s on."""
kw = transport.build_kwargs(
model="anthropic/claude-sonnet-4.6",
messages=_simple_messages(),
tools=None,
provider_profile=get_provider_profile("openrouter"),
supports_reasoning=True,
reasoning_config={"enabled": True, "effort": "high"},
)
assert "reasoning" not in kw.get("extra_body", {})
def test_default_reasoning_when_no_config(self, transport):
"""When supports_reasoning=True but no config, adds default."""
kw = transport.build_kwargs(
model="anthropic/claude-sonnet-4.6",
model="deepseek/deepseek-chat",
messages=_simple_messages(),
tools=None,
provider_profile=get_provider_profile("openrouter"),