"""Provider reasoning-parameter policy for ``AIAgent``. When ``reasoning`` extra_body is safe to send, LM Studio / Ollama / endpoint-profile capability probes, ``reasoning_content`` echo families, and strict-API tool-call sanitising. Extracted from ``run_agent.py``; every method resolves through ``AIAgent``'s MRO unchanged. """ import logging import time from agent.lazy_forward import forward as _forward, forward_static as _forward_static from agent.message_sanitization import matches_reasoning_echo_family 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/", ) # Hosts whose reasoning gate core owns: a plugin claiming one in ``hostnames`` never replaces it. _CORE_REASONING_GATED_HOSTS = ("nousresearch.com", "ai-gateway.vercel.sh", "ollama.com") # 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 def _cached_probe(agent, cache_attr: str, probe, unknown, definitive): """``probe(model, base_url, api_key)`` once per (model, base_url); ``unknown`` is what a raising probe yields, ``definitive(value)`` decides permanent vs 60s TTL.""" cache = getattr(agent, cache_attr, None) if cache is None: cache = {} setattr(agent, cache_attr, cache) key = (agent.model, agent.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(agent.model, agent.base_url, getattr(agent, "api_key", "")) except Exception: value = unknown cache[key] = (value, time.monotonic()) return value def unset_reasoning_default(agent) -> dict | None: """Reasoning config for a main-loop request whose ``agent.reasoning_effort`` is unset. Asks the active provider profile (``ProviderProfile.default_reasoning_config``; the custom / OpenAI-compatible profile answers medium) so a route's own default never silently applies — kimi-k3 behind a relay defaults to ``max``, 3x the reasoning tokens of medium. Resolved at request time, so ``/model`` and fallback activation re-evaluate it. None keeps the field off the wire: a non chat-completions transport, a profile without a default (those decide inside ``build_api_kwargs_extras``), a model the catalog / ``model_overrides`` mark ``supports_reasoning: false``, or a local Ollama model whose ``/api/show`` lacks ``thinking``. """ if getattr(agent, "api_mode", None) != "chat_completions": return None provider = str(getattr(agent, "provider", "") or "") model = str(getattr(agent, "model", "") or "") try: from providers import get_provider_profile profile = get_provider_profile(provider) default = profile.default_reasoning_config(model) if profile is not None else None except Exception: return None if not default: return None try: from agent.models_dev import get_model_capabilities caps = get_model_capabilities(provider, model, allow_network=False) except Exception: caps = None if caps is not None and caps.supports_reasoning is False: return None # ``_ollama_num_ctx`` is only ever set for a server detected as Ollama (agent_init); Ollama # 400s ``reasoning_effort`` on a model pulled without the thinking capability. if getattr(agent, "_ollama_num_ctx", None) and not agent._ollama_supports_thinking_cached(): return None return dict(default) class ReasoningParamsMixin: """Reasoning-parameter gating and echo policy (see module docstring).""" def _supports_reasoning_extra_body(self) -> bool: """True when reasoning extra_body is safe to send: OpenRouter forwards unknown extra_body upstream and some routes 400 on ``reasoning``, so gate to known reasoning-capable families and direct Nous Portal.""" 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 (profile := self._reasoning_endpoint_profile()) is not None: # A claimed endpoint gates on its own catalog: reasoning goes out iff it declares a payload. from agent.provider_route_hooks import ask_profile return ask_profile(profile, "reasoning_payload", None, self.model, None, base_url=self.base_url, scope=self) is not None if (self.provider or "").strip().lower() == "lmstudio": # "off-only" (or absent) means no real reasoning capability. 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() or base_url_host_matches(url, "api.mistral.ai"): return False # Live-catalog metadata first (OpenRouter /v1/models supported_parameters) — the static prefix # allowlist repeatedly went stale one vendor at a time. Unknown falls back to the static list. try: from hermes_cli.models_reasoning_caps import openrouter_model_reasoning_capabilities, warm_openrouter_reasoning_caps_async caps = openrouter_model_reasoning_capabilities(self.model) if caps is None: 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")) model = (self.model or "").lower() return any(model.startswith(prefix) for prefix in _OPENROUTER_REASONING_PREFIXES) def _lmstudio_reasoning_options_cached(self) -> list[str]: """LM Studio's published reasoning ``allowed_options`` (gate + clamp so toggle models don't 400 on ``high``).""" try: from hermes_cli.models_local import lmstudio_model_reasoning_options except Exception: return [] return _cached_probe(self, "_lm_reasoning_opts_cache", lmstudio_model_reasoning_options, [], bool) def _ollama_supports_thinking_cached(self) -> bool: """True only if Ollama's ``/api/show`` declares the ``thinking`` capability.""" try: from hermes_cli.models_local import ollama_model_supports_thinking except Exception: return False return bool(_cached_probe(self, "_ollama_thinking_cache", ollama_model_supports_thinking, None, lambda v: v is not None)) def _reasoning_endpoint_profile(self): """The profile claiming this endpoint (``providers.profile_for_host``) that gates its reasoning, or None. Endpoints core gates itself (Nous Portal, Vercel AI Gateway, LM Studio, Ollama Cloud, OpenRouter) keep core's gate even when a plugin claims their host for metadata or auxiliary routing.""" url = self._base_url_lower if ((self.provider or "").strip().lower() == "lmstudio" or self._is_openrouter_url() or any(base_url_host_matches(url, host) for host in _CORE_REASONING_GATED_HOSTS)): return None from providers import profile_for_host return profile_for_host(str(getattr(self, "base_url", None) or "")) def _endpoint_reasoning_payload(self) -> dict | None: """``reasoning_payload`` of the profile gating this endpoint's reasoning for the agent's model and ``reasoning_config`` (``_reasoning_endpoint_profile``), or None when no profile does.""" base_url = getattr(self, "base_url", None) profile = self._reasoning_endpoint_profile() if profile is None: return None from agent.provider_route_hooks import ask_profile return ask_profile(profile, "reasoning_payload", None, self.model, self.reasoning_config, base_url=str(base_url), scope=self) def _build_assistant_message(self, assistant_message, finish_reason: str) -> dict: """``build_assistant_message``; a held turn's row with visible text comes back typed hidden (``withhold_held_row``) until ``finish_text_response`` publishes it as the final. The row is stamped with the response's reasoning count: the replay price of its (encrypted) reasoning.""" from agent.chat_completion_helpers import build_assistant_message from agent.message_metadata import stamp_reasoning_tokens from agent.stream_delivery import withhold_held_row msg = build_assistant_message(self, assistant_message, finish_reason) stamp_reasoning_tokens(msg, getattr(self, "_last_response_reasoning_tokens", 0)) return withhold_held_row(self, msg) def _reasoning_replay_route(self): """Reasoning replay decision for the active route (``message_sanitization.reasoning_replay_route``). Cached per (api_mode, provider, model, base_url, opt-in, rejected keys) — called many times per turn — and recomputed after ``switch_model()`` / fallback activation or a recorded field rejection, the only events allowed to change the replayed bytes mid-session. """ from agent.message_sanitization import reasoning_replay_route, rejected_reasoning_carriers rejected = rejected_reasoning_carriers(self) key = (getattr(self, "api_mode", None), self.provider, self.model, getattr(self, "_base_url_lower", self.base_url), self._reasoning_echo_opt_in(), rejected) cached = getattr(self, "_reasoning_route_cache", None) if cached is not None and cached[0] == key: return cached[1] route = reasoning_replay_route(key[0], self.provider, self.model, self.base_url, echo_opt_in=key[4], rejected=rejected) self._reasoning_route_cache = (key, route) return route def _needs_thinking_reasoning_pad(self) -> bool: """True for the must-echo tier: DeepSeek / Kimi / MiMo thinking 400 on a replayed tool-call turn without a non-empty ``reasoning_content`` (#15250, #17400), plus a ``model.reasoning_echo`` opt-in for gateways proxying them. Never true on a route that does not read the field.""" return self._reasoning_replay_route().pad def _reasoning_echo_opt_in(self) -> bool: """``model.reasoning_echo``: treat the *current* provider as must-echo (pad tool-call turns) — for gateways proxying DeepSeek/Kimi/MiMo that the host rules miss. Replay itself is on by default; fallback activation swaps the flag and ``restore_primary_runtime()`` restores it.""" return bool(getattr(self, "_reasoning_echo_flag", False)) @staticmethod def _read_reasoning_echo_from_config() -> bool: """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")) except Exception as exc: # health: allow BLE001 -- a config failure must fall back, not fail init # Log the class, never the config value or exception traceback. logging.getLogger("run_agent").warning( "Agent configuration for model.reasoning_echo could not be loaded (%s); " "using the default reasoning echo setting. Check the active Hermes Python " "environment and dependencies; run 'hermes config check' after " "configuration imports work again.", type(exc).__name__, ) 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: """True when the current provider is Kimi / Moonshot thinking mode.""" return matches_reasoning_echo_family("kimi", self.provider, None, self.base_url) def _needs_deepseek_tool_reasoning(self) -> bool: """True when the current provider is DeepSeek thinking mode (omitting the echo is an HTTP 400). DeepSeek V4 thinking mode requires ``reasoning_content`` on every assistant tool-call turn; omitting it causes HTTP 400 when the message is replayed in a subsequent API request (#15250). """ return matches_reasoning_echo_family("deepseek", (self.provider or "").lower(), self.model, self.base_url) def _needs_mimo_tool_reasoning(self) -> bool: """True when the current provider is Xiaomi MiMo thinking mode.""" 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") _reapply_reasoning_echo_for_provider = _forward("agent.agent_runtime_helpers", "reapply_reasoning_echo_for_provider") @staticmethod def _sanitize_tool_calls_for_strict_api(api_msg: dict, model: str | None = None, *, base_url: str | None = None, provider: str | None = None) -> dict: """Strip Codex Responses fields from tool_calls for strict Chat Completions APIs (Mistral, Fireworks 400/422 on unknown fields). ``extra_content`` (Gemini thought_signature) is kept only on Gemini routes (model family, Gemini host or provider). Builds new dicts so the internal history keeps the Codex fields for a later fallback.""" tool_calls = api_msg.get("tool_calls") if not isinstance(tool_calls, list): return api_msg from agent.transports.chat_completions import _model_consumes_thought_signature keep_signature = _model_consumes_thought_signature(model, base_url, provider) strip = {"call_id", "response_item_id"} | (set() if keep_signature else {"extra_content"}) api_msg["tool_calls"] = [{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 _sanitize_tool_call_arguments = _forward_static("agent.agent_runtime_helpers", "sanitize_tool_call_arguments") def _should_sanitize_tool_calls(self) -> bool: """True for every non-Codex API: Codex Responses fields are not Chat Completions schema and 400 elsewhere.""" return self.api_mode != "codex_responses"