diff --git a/plugins/model-providers/openrouter/__init__.py b/plugins/model-providers/openrouter/__init__.py index 1695fce5024..0a612c89654 100644 --- a/plugins/model-providers/openrouter/__init__.py +++ b/plugins/model-providers/openrouter/__init__.py @@ -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"} diff --git a/tests/providers/test_profile_wiring.py b/tests/providers/test_profile_wiring.py index 047b3eb9bd2..a8a11c4d91f 100644 --- a/tests/providers/test_profile_wiring.py +++ b/tests/providers/test_profile_wiring.py @@ -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, ) diff --git a/tests/providers/test_provider_profiles.py b/tests/providers/test_provider_profiles.py index 3b6948e35aa..f9729ed72cc 100644 --- a/tests/providers/test_provider_profiles.py +++ b/tests/providers/test_provider_profiles.py @@ -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") diff --git a/tests/providers/test_transport_parity.py b/tests/providers/test_transport_parity.py index f42972547af..dec63edf8eb 100644 --- a/tests/providers/test_transport_parity.py +++ b/tests/providers/test_transport_parity.py @@ -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"),