A strict lane (the Portal's Fireworks :US models) rejects `reasoning` / `reasoning_details` by name. The in-memory record stopped the 400 for the rest of the process, but `hermes chat --resume` (or a gateway restart) re-learned it with another 400 on its first request. The record now lives in the session's model_config (same seam as the Anthropic rejected-thinking fingerprints) and is loaded once per session_id. Still keyed by (provider, host, model), so sibling models keep every carrier.
243 lines
13 KiB
Python
243 lines
13 KiB
Python
"""Provider reasoning-parameter policy for ``AIAgent``.
|
|
|
|
When ``reasoning`` extra_body is safe to send, LM Studio / Ollama / GitHub Models 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 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/",
|
|
)
|
|
|
|
# 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 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
|
|
|
|
return bool(github_model_reasoning_efforts(self.model))
|
|
except Exception:
|
|
return False
|
|
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 _github_models_reasoning_extra_body(self) -> dict | None:
|
|
"""Format reasoning payload for GitHub Models/OpenAI-compatible routes."""
|
|
try:
|
|
from hermes_cli.models import clamp_github_reasoning_effort, github_model_reasoning_efforts
|
|
except Exception:
|
|
return None
|
|
|
|
supported = github_model_reasoning_efforts(self.model)
|
|
if not supported:
|
|
return None
|
|
|
|
cfg = self.reasoning_config if isinstance(self.reasoning_config, dict) else {}
|
|
if cfg.get("enabled") is False:
|
|
return None
|
|
return {"effort": clamp_github_reasoning_effort(cfg.get("effort"), supported)}
|
|
|
|
_build_assistant_message = _forward("agent.chat_completion_helpers", "build_assistant_message")
|
|
|
|
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:
|
|
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"
|