diff --git a/ciris_engine/logic/services/runtime/llm_service/service.py b/ciris_engine/logic/services/runtime/llm_service/service.py index c741b60c0a..b990cd7951 100644 --- a/ciris_engine/logic/services/runtime/llm_service/service.py +++ b/ciris_engine/logic/services/runtime/llm_service/service.py @@ -1547,6 +1547,23 @@ def _build_reasoning_off_extras(base_url: str, model_name: str) -> Dict[str, Any model = (model_name or "").lower() if "openrouter.ai" in base: + # Reasoning-OFF contract: pass `reasoning.enabled=false` so any + # reasoning-capable model that honours it (claude-sonnet-4.6, + # openai/gpt-5-chat, deepseek, etc.) emits zero reasoning tokens + # as before. `effort=minimal` is *not* a disable on those models — + # it's the lowest-effort setting and still emits reasoning tokens. + # + # OpenRouter explicitly rejects `reasoning.enabled=false` for the + # gpt-5 reasoning family with 400 "Reasoning is mandatory for this + # endpoint and cannot be disabled." (verified 2026-05-16 against + # `openai/gpt-5`). For that narrow case fall back to + # `effort=minimal`, which the gpt-5 family honours by emitting + # 0 reasoning tokens (cost ~$0.0002 on a trivial prompt). The + # chat-tuned variant `openai/gpt-5-chat` is reasoning-capable, not + # reasoning-mandatory, and stays on the universal `enabled=false` + # path. + if model.startswith("openai/gpt-5") and "chat" not in model: + return {"reasoning": {"effort": "minimal"}} return {"reasoning": {"enabled": False}} if "together" in base: diff --git a/tests/ciris_engine/logic/services/runtime/llm_service/test_llm_service_coverage.py b/tests/ciris_engine/logic/services/runtime/llm_service/test_llm_service_coverage.py index 7e14297430..6f49ab852e 100644 --- a/tests/ciris_engine/logic/services/runtime/llm_service/test_llm_service_coverage.py +++ b/tests/ciris_engine/logic/services/runtime/llm_service/test_llm_service_coverage.py @@ -325,10 +325,33 @@ def test_deepinfra_carries_vllm_and_reasoning_enabled_keys(self): assert extra_body["chat_template_kwargs"] == {"enable_thinking": False} assert extra_body["reasoning"] == {"enabled": False} - def test_openrouter_carries_reasoning_enabled_false(self): + def test_openrouter_default_carries_reasoning_enabled_false(self): + # Default reasoning-OFF contract on OpenRouter: send `enabled=false` + # so reasoning-capable models (claude, gpt-5-chat, deepseek, …) emit + # zero reasoning tokens. `effort=minimal` is *not* a disable on those + # models, so we only use it for the narrow reasoning-mandatory case + # below. extra_body = self._build_for("https://openrouter.ai/api/v1", "kimi/k2-0130-preview")["extra_body"] assert extra_body["reasoning"] == {"enabled": False} + def test_openrouter_gpt5_uses_effort_minimal(self): + # OpenRouter rejects reasoning.enabled=false for the gpt-5 reasoning + # family with 400 "Reasoning is mandatory for this endpoint and cannot + # be disabled." (verified 2026-05-16 against openai/gpt-5). For that + # narrow case we fall back to effort=minimal, which gpt-5 honours by + # emitting 0 reasoning tokens. + for model in ("openai/gpt-5", "openai/gpt-5-mini", "openai/gpt-5-nano"): + extra_body = self._build_for("https://openrouter.ai/api/v1", model)["extra_body"] + assert extra_body["reasoning"] == {"effort": "minimal"}, model + + def test_openrouter_gpt5_chat_stays_on_enabled_false(self): + # gpt-5-chat is the chat-tuned (reasoning-capable, not mandatory) + # variant and honours reasoning.enabled=false. It must stay on the + # universal disable path so it doesn't accidentally re-enable + # reasoning at lowest-effort under our reasoning-OFF contract. + extra_body = self._build_for("https://openrouter.ai/api/v1", "openai/gpt-5-chat")["extra_body"] + assert extra_body["reasoning"] == {"enabled": False} + def test_local_endpoint_carries_vllm_key(self): extra_body = self._build_for("http://localhost:8080/v1", "llama")["extra_body"] assert extra_body["chat_template_kwargs"] == {"enable_thinking": False} @@ -453,9 +476,11 @@ def test_openrouter_includes_provider_config(self): retry_state=RetryState(), ) - # Verify reasoning is disabled + # gpt-4o is reasoning-capable, not reasoning-mandatory — it honours + # `enabled=false`. Reasoning-mandatory gpt-5 models would take the + # `effort=minimal` fallback instead (covered separately above). assert "extra_body" in extra_kwargs extra_body = extra_kwargs["extra_body"] # Provider config may or may not be present depending on env assert "reasoning" in extra_body - assert extra_body["reasoning"]["enabled"] is False + assert extra_body["reasoning"] == {"enabled": False}