diff --git a/agent/reasoning_params.py b/agent/reasoning_params.py index a753cf954c..50eebc04d1 100644 --- a/agent/reasoning_params.py +++ b/agent/reasoning_params.py @@ -4,11 +4,23 @@ When ``reasoning`` extra_body is safe to send, LM Studio / Ollama / GitHub Model ``reasoning_content`` echo families, and strict-API tool-call sanitising. Extracted from ``run_agent.py``; every method resolves through ``AIAgent``'s MRO unchanged. """ +import time from typing import Optional from agent.lazy_forward import forward as _forward, forward_static as _forward_static from utils import base_url_host_matches +# Static OpenRouter fallback when the live /v1/models capability cache is cold. +_OPENROUTER_REASONING_PREFIXES = ( + "deepseek/", "anthropic/", "openai/", "x-ai/", "google/gemini-2", "google/gemma-4", + "qwen/qwen3", "tencent/hy", "xiaomi/", +) + +# Probe results cache per (model, base_url). Definitive values cache permanently; an +# "unknown" value (empty list / None) caches 60s so a transient failure neither sticks +# for the session nor round-trips every turn. +_PROBE_TTL_S = 60 + class ReasoningParamsMixin: """Reasoning-parameter gating and echo policy (see module docstring).""" @@ -19,14 +31,10 @@ class ReasoningParamsMixin: OpenRouter forwards unknown extra_body upstream and some routes 400 on ``reasoning``; gate to known reasoning-capable families and direct Nous Portal. """ - if base_url_host_matches(self._base_url_lower, "nousresearch.com"): + url = self._base_url_lower + if base_url_host_matches(url, "nousresearch.com") or base_url_host_matches(url, "ai-gateway.vercel.sh"): return True - if base_url_host_matches(self._base_url_lower, "ai-gateway.vercel.sh"): - return True - if ( - base_url_host_matches(self._base_url_lower, "models.github.ai") - or base_url_host_matches(self._base_url_lower, "githubcopilot.com") - ): + if base_url_host_matches(url, "models.github.ai") or base_url_host_matches(url, "githubcopilot.com"): try: from hermes_cli.models import github_model_reasoning_efforts @@ -34,22 +42,15 @@ class ReasoningParamsMixin: except Exception: return False if (self.provider or "").strip().lower() == "lmstudio": - opts = self._lmstudio_reasoning_options_cached() # "off-only" (or absent) means no real reasoning capability. - return any(opt and opt != "off" for opt in opts) - # Ollama Cloud: /api/show capabilities are authoritative — emit reasoning_effort only for models - # declaring "thinking". Cached per (model, base_url). - if base_url_host_matches(self._base_url_lower, "ollama.com"): + return any(opt and opt != "off" for opt in self._lmstudio_reasoning_options_cached()) + if base_url_host_matches(url, "ollama.com"): + # Ollama Cloud: /api/show capabilities are authoritative. return self._ollama_supports_thinking_cached() - if not self._is_openrouter_url(): + if not self._is_openrouter_url() or base_url_host_matches(url, "api.mistral.ai"): return False - if base_url_host_matches(self._base_url_lower, "api.mistral.ai"): - return False - - model = (self.model or "").lower() # Live-catalog metadata first (OpenRouter /v1/models supported_parameters) — the static prefix - # allowlist repeatedly went stale one vendor at a time (#75386). Unknown falls back to the static - # list. + # allowlist repeatedly went stale one vendor at a time. Unknown falls back to the static list. try: from hermes_cli.models import ( openrouter_model_reasoning_capabilities, @@ -57,94 +58,58 @@ class ReasoningParamsMixin: ) caps = openrouter_model_reasoning_capabilities(self.model) if caps is None: - # Cache cold — warm in the background; never block this turn on HTTP. - warm_openrouter_reasoning_caps_async() + warm_openrouter_reasoning_caps_async() # cache cold — warm in the background, never block except Exception: caps = None if caps is not None: return bool(caps.get("supports_reasoning")) - reasoning_model_prefixes = ( - "deepseek/", - "anthropic/", - "openai/", - "x-ai/", - "google/gemini-2", - "google/gemma-4", - "qwen/qwen3", - "tencent/hy", - "xiaomi/", - ) - return any(model.startswith(prefix) for prefix in reasoning_model_prefixes) + model = (self.model or "").lower() + return any(model.startswith(prefix) for prefix in _OPENROUTER_REASONING_PREFIXES) + + def _cached_probe(self, cache_attr: str, probe, unknown, definitive): + """Run ``probe(model, base_url, api_key)`` once per (model, base_url) with the module TTL policy. + + ``unknown`` is what a raising probe yields; ``definitive(value)`` decides permanent vs 60s TTL. + """ + cache = getattr(self, cache_attr, None) + if cache is None: + cache = {} + setattr(self, cache_attr, cache) + key = (self.model, self.base_url) + cached = cache.get(key) + if cached is not None: + value, ts = cached + if definitive(value) or (time.monotonic() - ts) < _PROBE_TTL_S: + return value + try: + value = probe(self.model, self.base_url, getattr(self, "api_key", "")) + except Exception: + value = unknown + cache[key] = (value, time.monotonic()) + return value def _lmstudio_reasoning_options_cached(self) -> list[str]: - """Probe LM Studio's published reasoning ``allowed_options`` once per (model, base_url). - - Needed for the supports-reasoning gate and to clamp ``reasoning_effort`` so toggle-style models don't - 400 on ``high``. Non-empty results cache permanently; empty ones (transient failure OR non-reasoning - model) cache with a 60s TTL to avoid a round-trip per turn while retrying soon. - """ - import time as _time - - cache = getattr(self, "_lm_reasoning_opts_cache", None) - if cache is None: - cache = self._lm_reasoning_opts_cache = {} - key = (self.model, self.base_url) - cached = cache.get(key) - if cached is not None: - opts, ts = cached - # Non-empty → permanent. Empty → 60s TTL. - if opts or (_time.monotonic() - ts) < 60: - return opts + """LM Studio's published reasoning ``allowed_options`` (gate + clamp so toggle models don't 400 on ``high``).""" try: from hermes_cli.models import lmstudio_model_reasoning_options - opts = lmstudio_model_reasoning_options( - self.model, self.base_url, getattr(self, "api_key", ""), - ) except Exception: - opts = [] - cache[key] = (opts, _time.monotonic()) - return opts + return [] + return self._cached_probe("_lm_reasoning_opts_cache", lmstudio_model_reasoning_options, [], bool) def _ollama_supports_thinking_cached(self) -> bool: - """Probe Ollama's ``/api/show`` capabilities once per (model, base_url); True only if ``thinking`` is - declared. - - True/False cache permanently; a probe failure (None) caches 60s so an outage neither suppresses - reasoning for the session nor round-trips every turn. - """ - import time as _time - - cache = getattr(self, "_ollama_thinking_cache", None) - if cache is None: - cache = self._ollama_thinking_cache = {} - key = (self.model, self.base_url) - cached = cache.get(key) - if cached is not None: - supported, ts = cached - # Definitive True/False → permanent. Unknown (None) → 60s TTL. - if supported is not None or (_time.monotonic() - ts) < 60: - return bool(supported) + """True only if Ollama's ``/api/show`` declares the ``thinking`` capability.""" try: from hermes_cli.models import ollama_model_supports_thinking - supported = ollama_model_supports_thinking( - self.model, self.base_url, getattr(self, "api_key", "") - ) except Exception: - supported = None - cache[key] = (supported, _time.monotonic()) - return bool(supported) + return False + return bool(self._cached_probe( + "_ollama_thinking_cache", ollama_model_supports_thinking, None, lambda v: v is not None, + )) def _resolve_lmstudio_summary_reasoning_effort(self) -> Optional[str]: - """Resolve a safe top-level ``reasoning_effort`` for LM Studio. - - The iteration-limit summary calls ``chat.completions.create()`` directly, bypassing the transport; - share the helper so effort resolution and clamping cannot drift. - """ + """Safe top-level ``reasoning_effort`` for LM Studio; shared with the iteration-limit summary call.""" from agent.lmstudio_reasoning import resolve_lmstudio_effort - return resolve_lmstudio_effort( - self.reasoning_config, - self._lmstudio_reasoning_options_cached(), - ) + return resolve_lmstudio_effort(self.reasoning_config, self._lmstudio_reasoning_options_cached()) def _github_models_reasoning_extra_body(self) -> dict | None: """Format reasoning payload for GitHub Models/OpenAI-compatible routes.""" @@ -153,30 +118,26 @@ class ReasoningParamsMixin: except Exception: return None - supported_efforts = github_model_reasoning_efforts(self.model) - if not supported_efforts: + supported = github_model_reasoning_efforts(self.model) + if not supported: return None + effort = "medium" if self.reasoning_config and isinstance(self.reasoning_config, dict): if self.reasoning_config.get("enabled") is False: return None - requested_effort = str( - self.reasoning_config.get("effort", "medium") - ).strip().lower() - else: - requested_effort = "medium" + effort = str(self.reasoning_config.get("effort", "medium")).strip().lower() - if requested_effort == "xhigh" and "xhigh" not in supported_efforts and "high" in supported_efforts: - requested_effort = "high" - elif requested_effort not in supported_efforts: - if requested_effort == "minimal" and "low" in supported_efforts: - requested_effort = "low" - elif "medium" in supported_efforts: - requested_effort = "medium" + if effort == "xhigh" and "xhigh" not in supported and "high" in supported: + effort = "high" + elif effort not in supported: + if effort == "minimal" and "low" in supported: + effort = "low" + elif "medium" in supported: + effort = "medium" else: - requested_effort = supported_efforts[0] - - return {"effort": requested_effort} + effort = supported[0] + return {"effort": effort} _build_assistant_message = _forward("agent.chat_completion_helpers", "build_assistant_message") @@ -201,13 +162,10 @@ class ReasoningParamsMixin: return result def _reasoning_echo_opt_in(self) -> bool: - """True when the user opted in to ``reasoning_content`` echo-back for the *current* provider via - config. + """User opted in to ``reasoning_content`` echo-back for the *current* provider (``model.reasoning_echo``). - Covers custom providers / gateways proxying thinking models that the host-based - ``_REASONING_ECHO_RULES`` miss. Per-active-provider: primary from ``model.reasoning_echo``, fallback - from the fallback entry's field, restored by ``restore_primary_runtime()`` — so falling back to a - strict provider still strips it. + Covers custom providers/gateways the host-based echo rules miss. Per-active-provider: fallback + activation swaps the flag and ``restore_primary_runtime()`` restores it, so a strict fallback still strips. """ return bool(getattr(self, "_reasoning_echo_flag", False)) @@ -216,46 +174,27 @@ class ReasoningParamsMixin: """Read ``model.reasoning_echo`` from config; False on any error.""" try: from hermes_cli.config import load_config_readonly - return bool( - (load_config_readonly().get("model") or {}).get("reasoning_echo") - ) + return bool((load_config_readonly().get("model") or {}).get("reasoning_echo")) except Exception: return False + # Echo families are host/provider-driven, not model-name-driven: aggregators re-exporting Kimi reject the + # echo. Rule table: ``message_sanitization._REASONING_ECHO_RULES``. Kimi deliberately passes the raw + # provider and no model (its rule matches exact provider ids + hosts only). def _needs_kimi_tool_reasoning(self) -> bool: - """Return True when the current provider is Kimi / Moonshot thinking mode (requires - ``reasoning_content`` echo). - - Host-driven, not model-name-driven: aggregators re-exporting Kimi reject the echo (#17400). Rule - table: ``message_sanitization.reasoning_echo_family``. - """ + """True when the current provider is Kimi / Moonshot thinking mode.""" from agent.message_sanitization import matches_reasoning_echo_family - return matches_reasoning_echo_family( - "kimi", self.provider, None, self.base_url - ) + return matches_reasoning_echo_family("kimi", self.provider, None, self.base_url) def _needs_deepseek_tool_reasoning(self) -> bool: - """Return True when the current provider is DeepSeek thinking mode (requires ``reasoning_content`` - echo). - - Omitting the echo on replayed assistant tool-call turns is an HTTP 400 (#15250). Rule table: - ``message_sanitization.reasoning_echo_family``. - """ + """True when the current provider is DeepSeek thinking mode (omitting the echo is an HTTP 400).""" from agent.message_sanitization import matches_reasoning_echo_family - return matches_reasoning_echo_family( - "deepseek", (self.provider or "").lower(), self.model, self.base_url - ) + return matches_reasoning_echo_family("deepseek", (self.provider or "").lower(), self.model, self.base_url) def _needs_mimo_tool_reasoning(self) -> bool: - """Return True when the current provider is Xiaomi MiMo thinking mode (requires ``reasoning_content`` - echo). - - Rule table: ``message_sanitization.reasoning_echo_family``. - """ + """True when the current provider is Xiaomi MiMo thinking mode.""" from agent.message_sanitization import matches_reasoning_echo_family - return matches_reasoning_echo_family( - "mimo", (self.provider or "").lower(), self.model, self.base_url - ) + return matches_reasoning_echo_family("mimo", (self.provider or "").lower(), self.model, self.base_url) _copy_reasoning_content_for_api = _forward("agent.agent_runtime_helpers", "copy_reasoning_content_for_api") @@ -263,8 +202,7 @@ class ReasoningParamsMixin: @staticmethod def _sanitize_tool_calls_for_strict_api(api_msg: dict, model: "str | None" = None) -> dict: - """Strip Codex Responses fields (call_id, response_item_id, extra_content) from tool_calls for strict - providers. + """Strip Codex Responses fields (call_id, response_item_id, extra_content) from tool_calls. Strict Chat Completions APIs (Mistral, Fireworks) 400/422 on unknown fields. ``extra_content`` (Gemini thought_signature) is kept only when the outgoing model is Gemini-family (it 400s without it). Builds @@ -274,12 +212,11 @@ class ReasoningParamsMixin: if not isinstance(tool_calls, list): return api_msg from agent.transports.chat_completions import _model_consumes_thought_signature - _STRIP_KEYS = {"call_id", "response_item_id"} + strip = {"call_id", "response_item_id"} if not _model_consumes_thought_signature(model): - _STRIP_KEYS = _STRIP_KEYS | {"extra_content"} + strip.add("extra_content") api_msg["tool_calls"] = [ - {k: v for k, v in tc.items() if k not in _STRIP_KEYS} - if isinstance(tc, dict) else tc + {k: v for k, v in tc.items() if k not in strip} if isinstance(tc, dict) else tc for tc in tool_calls ] return api_msg @@ -287,8 +224,5 @@ class ReasoningParamsMixin: _sanitize_tool_call_arguments = _forward_static("agent.agent_runtime_helpers", "sanitize_tool_call_arguments") def _should_sanitize_tool_calls(self) -> bool: - """Determine if tool_calls need sanitization (True for every non-Codex API). - - Codex Responses fields (call_id, response_item_id) are not Chat Completions schema and 400 elsewhere. - """ + """True for every non-Codex API: Codex Responses fields are not Chat Completions schema and 400 elsewhere.""" return self.api_mode != "codex_responses"