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.
This commit is contained in:
parent
423f92e607
commit
82763e9feb
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
Loading…
Reference in New Issue