refactor(agent/reasoning_params): unify LM Studio/Ollama probe caches, flatten gate ladder

This commit is contained in:
Teknium
2026-09-02 18:25:16 -07:00
parent 113f04616b
commit d49ec401e3

View File

@@ -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"