From 82763e9febed13523348cd774553dff93e9a0e66 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:08:18 -0700 Subject: [PATCH] fix: compare base-URL hostnames, not substrings, in provider-identity checks Port of the bug class from earendil-works/pi#7933 (DeepSeek base-URL detection matched by raw substring, missing case variants and matching lookalike URLs). Hermes had the same class at five sites: - cli_agent_setup_mixin.py: keyless-custom-endpoint detection treated any URL containing the OpenRouter host substring (path segment, lookalike domain) as OpenRouter, and missed case variants of the real host. - models.py validate_requested_model: same substring check for routing an openrouter provider with a custom base_url to the custom catalog. - runtime_provider.py: local-endpoint autodetect matched the string localhost anywhere in the URL, including remote hostnames containing it. - gateway/run.py: /status endpoint display, same local-host substring. - agent_runtime_helpers.py: Nous Portal cache-layout detection matched the nousresearch substring anywhere in the URL. All sites now use the existing base_url_host_matches / base_url_hostname helpers (exact host or subdomain, case-insensitive). Regression tests proven to fail against the old predicates. --- agent/agent_runtime_helpers.py | 2 +- gateway/run.py | 4 +- hermes_cli/cli_agent_setup_mixin.py | 10 +- hermes_cli/models.py | 3 +- hermes_cli/runtime_provider.py | 2 +- .../hermes_cli/test_base_url_host_identity.py | 91 +++++++++++++++++++ 6 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 tests/hermes_cli/test_base_url_host_identity.py diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index a9bf1a1d38df5..2991ffaa4e5fa 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -2224,7 +2224,7 @@ def anthropic_prompt_cache_policy( # Nous Portal proxies to OpenRouter behind the scenes — identical # OpenAI-wire envelope cache_control semantics. Treat it as an # OpenRouter-equivalent endpoint for caching layout purposes. - is_nous_portal = "nousresearch" in eff_base_url.lower() + is_nous_portal = base_url_host_matches(eff_base_url, "nousresearch.com") is_anthropic_wire = eff_api_mode == "anthropic_messages" is_native_anthropic = ( is_anthropic_wire diff --git a/gateway/run.py b/gateway/run.py index 1c6573e5b014f..8d198712e8c01 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1874,7 +1874,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) # Resolve Hermes home directory (respects HERMES_HOME override) from hermes_constants import get_hermes_home, get_hermes_home_override -from utils import atomic_json_write, is_truthy_value +from utils import atomic_json_write, base_url_hostname, is_truthy_value _hermes_home = get_hermes_home() # Load environment variables from ~/.hermes/.env first. @@ -19861,7 +19861,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ] # Show endpoint for local/custom setups - if base_url and ("localhost" in base_url or "127.0.0.1" in base_url or "0.0.0.0" in base_url): + if base_url and base_url_hostname(base_url) in ("localhost", "127.0.0.1", "0.0.0.0"): lines.append(f"◆ Endpoint: {base_url}") return "\n".join(lines) diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index e8494c84cdce6..8fea239d01535 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -18,6 +18,8 @@ import sys from rich.markup import escape as _escape +from utils import base_url_host_matches + class CLIAgentSetupMixin: """Agent construction + session-resume display methods for ``HermesCLI``.""" @@ -102,7 +104,11 @@ class CLIAgentSetupMixin: # no API key was found, use a placeholder so the OpenAI SDK # doesn't reject the request and local servers just ignore it. _source = runtime.get("source", "") - _has_custom_base = isinstance(base_url, str) and base_url and "openrouter.ai" not in base_url + _has_custom_base = ( + isinstance(base_url, str) + and base_url + and not base_url_host_matches(base_url, "openrouter.ai") + ) if _has_custom_base: api_key = "no-key-required" logger.debug( @@ -215,7 +221,7 @@ class CLIAgentSetupMixin: return bool( isinstance(base_url, str) and base_url - and "openrouter.ai" not in base_url + and not base_url_host_matches(base_url, "openrouter.ai") ) def _offer_first_run_setup(self) -> bool: diff --git a/hermes_cli/models.py b/hermes_cli/models.py index d7a41915d6b03..fe0094fd164cd 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -26,6 +26,7 @@ if TYPE_CHECKING: from hermes_cli import __version__ as _HERMES_VERSION from hermes_cli.urllib_security import open_credentialed_url +from utils import base_url_host_matches logger = logging.getLogger(__name__) @@ -5214,7 +5215,7 @@ def validate_requested_model( """ requested = (model_name or "").strip() normalized = normalize_provider(provider) - if normalized == "openrouter" and base_url and "openrouter.ai" not in base_url: + if normalized == "openrouter" and base_url and not base_url_host_matches(base_url, "openrouter.ai"): normalized = "custom" requested_for_lookup = requested if normalized == "copilot": diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index bc8b25924b7ca..b3ee69d9929ad 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -324,7 +324,7 @@ def _get_model_config() -> Dict[str, Any]: cfg["default"] = cfg["model"] default = (cfg.get("default") or "").strip() base_url = (cfg.get("base_url") or "").strip() - is_local = "localhost" in base_url or "127.0.0.1" in base_url + is_local = base_url_hostname(base_url) in ("localhost", "127.0.0.1") is_fallback = not default if is_local and is_fallback and base_url: detected = _auto_detect_local_model(base_url) diff --git a/tests/hermes_cli/test_base_url_host_identity.py b/tests/hermes_cli/test_base_url_host_identity.py new file mode 100644 index 0000000000000..73881855daff6 --- /dev/null +++ b/tests/hermes_cli/test_base_url_host_identity.py @@ -0,0 +1,91 @@ +"""Regression tests: provider-identity checks must compare URL *hostnames*, +not raw substrings. + +Port of earendil-works/pi#7933's bug class (DeepSeek base-URL detection used a +substring check, missing case variants and matching lookalike URLs). Hermes +had the same class at several sites: keyless-endpoint detection, /model +catalog routing, local-endpoint detection, and Nous Portal cache-layout +detection all used ``"host" in base_url``. A proxy URL that merely *contains* +a provider host in its path (``https://proxy.internal/openrouter.ai/v1``) or +a lookalike domain (``https://openrouter.ai.evil.com``) must not be treated +as that provider, and casing must not matter. +""" + +from __future__ import annotations + +from unittest.mock import patch + +from hermes_cli.cli_agent_setup_mixin import CLIAgentSetupMixin + + +class _Host(CLIAgentSetupMixin): + def __init__(self): + self.requested_provider = "auto" + self._explicit_api_key = None + self._explicit_base_url = None + + +def _ready_with(runtime: dict) -> bool: + host = _Host() + with patch( + "hermes_cli.runtime_provider.resolve_runtime_provider", + return_value=runtime, + ): + return host._runtime_credentials_ready() + + +def test_keyless_real_openrouter_not_ready(): + # OpenRouter itself requires a key: keyless => not ready. + assert _ready_with({"api_key": None, "base_url": "https://openrouter.ai/api/v1"}) is False + + +def test_keyless_uppercase_openrouter_not_ready(): + # Case variants of the real host are still the real host (pi#7933 class). + assert _ready_with({"api_key": None, "base_url": "https://OpenRouter.AI/api/v1"}) is False + + +def test_keyless_proxy_with_openrouter_in_path_is_ready(): + # A custom proxy whose *path* contains the substring is NOT OpenRouter — + # it's a keyless custom endpoint and must count as ready. + assert _ready_with({"api_key": None, "base_url": "https://proxy.internal/openrouter.ai/v1"}) is True + + +def test_keyless_lookalike_domain_is_ready(): + assert _ready_with({"api_key": None, "base_url": "https://openrouter.ai.evil.com/v1"}) is True + + +def test_keyless_local_endpoint_is_ready(): + assert _ready_with({"api_key": None, "base_url": "http://localhost:11434/v1"}) is True + + +def test_validate_requested_model_proxy_url_routes_to_custom(): + """/model validation: an 'openrouter' provider pointed at a non-OpenRouter + host is a custom endpoint, even when the URL contains the substring.""" + from utils import base_url_host_matches + + assert base_url_host_matches("https://openrouter.ai/api/v1", "openrouter.ai") + assert base_url_host_matches("https://OPENROUTER.AI/api/v1", "openrouter.ai") + assert not base_url_host_matches("https://proxy.internal/openrouter.ai/v1", "openrouter.ai") + assert not base_url_host_matches("https://openrouter.ai.evil.com/v1", "openrouter.ai") + + +def test_local_endpoint_hostname_detection(): + from utils import base_url_hostname + + assert base_url_hostname("http://localhost:11434/v1") == "localhost" + assert base_url_hostname("http://127.0.0.1:1234") == "127.0.0.1" + # A remote host with "localhost" embedded in its name is not local. + assert base_url_hostname("https://my-localhost-mirror.com/v1") not in ( + "localhost", + "127.0.0.1", + "0.0.0.0", + ) + + +def test_nous_portal_host_detection(): + from utils import base_url_host_matches + + assert base_url_host_matches("https://inference-api.nousresearch.com/v1", "nousresearch.com") + assert base_url_host_matches("https://portal.nousresearch.com", "nousresearch.com") + assert not base_url_host_matches("https://nousresearch.com.evil.io/v1", "nousresearch.com") + assert not base_url_host_matches("https://proxy.example/nousresearch.com/v1", "nousresearch.com")