mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
fix(agent): a plugin claiming a host never replaces core's reasoning gate there
R4 W6. _supports_reasoning_extra_body asked the host-claiming profile (profile_for_host) before the LM Studio / Ollama Cloud / OpenRouter gates, and _build_chat_completions_kwargs swapped the generic reasoning body for the profile's payload on any claimed host. A plugin listing openrouter.ai or ollama.com in hostnames for catalog/aux routing (without overriding reasoning_payload) silently turned reasoning off on those routes. _reasoning_endpoint_profile() is the one lookup for both: it returns the claiming profile except on endpoints core gates itself (Nous Portal, Vercel AI Gateway, Ollama Cloud, OpenRouter, the lmstudio provider). Documented on ProviderProfile.hostnames. No in-tree profile claims those hosts; Copilot hosts are unaffected.
This commit is contained in:
1 parent
5af8fda75f
commit
f4a935b47a
4 files changed
+68
-9
No files matched your search
@@ -1442,8 +1442,7 @@ def _build_chat_completions_kwargs(agent, api_messages, tools_for_api, reasoning
|
||||
_is_or = agent._is_openrouter_url()
|
||||
_host = agent._base_url_lower
|
||||
_is_lmstudio = (agent.provider or "").strip().lower() == "lmstudio"
|
||||
from providers import profile_for_host
|
||||
_profile_endpoint = profile_for_host(str(agent.base_url or "")) is not None
|
||||
_profile_endpoint = agent._reasoning_endpoint_profile() is not None
|
||||
|
||||
# _fixed_temperature_for_model may return the OMIT_TEMPERATURE sentinel
|
||||
# (temperature omitted entirely), a numeric override, or None.
|
||||
|
||||
@@ -17,6 +17,9 @@ _OPENROUTER_REASONING_PREFIXES = (
|
||||
"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.
|
||||
@@ -92,9 +95,7 @@ class ReasoningParamsMixin:
|
||||
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
|
||||
from providers import profile_for_host
|
||||
|
||||
if (profile := profile_for_host(str(getattr(self, "base_url", None) or ""))) is not None:
|
||||
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,
|
||||
@@ -137,13 +138,23 @@ class ReasoningParamsMixin:
|
||||
return False
|
||||
return bool(_cached_probe(self, "_ollama_thinking_cache", ollama_model_supports_thinking, None, lambda v: v is not None))
|
||||
|
||||
def _endpoint_reasoning_payload(self) -> dict | None:
|
||||
"""``reasoning_payload`` of the profile claiming this endpoint for the agent's model and
|
||||
``reasoning_config`` (``providers.profile_for_host``), or None when no profile claims it."""
|
||||
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 = profile_for_host(str(base_url or ""))
|
||||
profile = self._reasoning_endpoint_profile()
|
||||
if profile is None:
|
||||
return None
|
||||
from agent.provider_route_hooks import ask_profile
|
||||
|
||||
@@ -596,6 +596,8 @@ class ProviderProfile:
|
||||
# ── Lane L4: catalog / metadata seams ─────────────────────
|
||||
# Hosts (exact or any subdomain) that serve THIS provider's API, so a ``custom`` endpoint or an
|
||||
# auto-routed aux client at one of them is recognised as this provider (``providers.profile_for_host``).
|
||||
# It also makes ``reasoning_payload`` the reasoning gate there, except on hosts core gates itself
|
||||
# (OpenRouter, Ollama Cloud, Nous Portal, Vercel AI Gateway, the ``lmstudio`` provider).
|
||||
hostnames: tuple = ()
|
||||
# Hosts that identify this provider's catalog for model metadata (context length, models.dev) but
|
||||
# take other credentials; matched only by ``profile_for_host(url, metadata=True)``.
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""A plugin claiming a host in ``hostnames`` (catalog / aux routing) never replaces the reasoning gate core
|
||||
owns for that host (OpenRouter, Ollama Cloud): only hosts no core gate covers follow ``reasoning_payload``."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def claiming_profile(request):
|
||||
import providers
|
||||
from providers.base import ProviderProfile
|
||||
|
||||
providers.list_providers() # discovery first, so the fake survives it
|
||||
providers.register_provider(ProviderProfile(name="hostclaimer", hostnames=("openrouter.ai", "ollama.com")))
|
||||
|
||||
def _cleanup():
|
||||
providers._REGISTRY.pop("hostclaimer", None)
|
||||
providers._SOURCES.pop("hostclaimer", None)
|
||||
providers._PROVIDER_LIST_CACHE = None
|
||||
|
||||
request.addfinalizer(_cleanup)
|
||||
|
||||
|
||||
def _agent(provider, base_url, model):
|
||||
from run_agent import AIAgent
|
||||
|
||||
with patch("agent.process_bootstrap.OpenAI") as mock_openai:
|
||||
mock_openai.return_value = MagicMock()
|
||||
return AIAgent(api_key="k", base_url=base_url, provider=provider, api_mode="chat_completions", model=model,
|
||||
quiet_mode=True, skip_context_files=True, skip_memory=True, enabled_toolsets=[],
|
||||
reasoning_config={"enabled": True, "effort": "high"})
|
||||
|
||||
|
||||
def test_openrouter_keeps_its_reasoning_gate_and_body_when_a_plugin_claims_the_host(claiming_profile):
|
||||
agent = _agent("openrouter", "https://openrouter.ai/api/v1", "anthropic/claude-sonnet-4.5")
|
||||
with patch("hermes_cli.models_reasoning_caps.openrouter_model_reasoning_capabilities",
|
||||
return_value={"supports_reasoning": True}):
|
||||
assert agent._supports_reasoning_extra_body() is True
|
||||
extra_body = agent._build_api_kwargs([{"role": "user", "content": "x"}]).get("extra_body") or {}
|
||||
assert extra_body.get("reasoning") == {"enabled": True, "effort": "high"}
|
||||
|
||||
|
||||
def test_ollama_cloud_keeps_its_capability_gate_when_a_plugin_claims_the_host(claiming_profile):
|
||||
agent = _agent("custom", "https://ollama.com/v1", "gpt-oss:120b")
|
||||
with patch.object(type(agent), "_ollama_supports_thinking_cached", return_value=True):
|
||||
assert agent._supports_reasoning_extra_body() is True
|
||||
Reference in new issue
Block a user