diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index b1ef005713..46be9f3e83 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -1870,8 +1870,8 @@ def build_api_kwargs(agent, api_messages: list, tools_for_api: list | None = Non base_url_host_matches(agent._base_url_lower, "models.github.ai") or base_url_host_matches(agent._base_url_lower, "githubcopilot.com") ) - _is_nous = "nousresearch" in agent._base_url_lower - _is_nvidia = "integrate.api.nvidia.com" in agent._base_url_lower + _is_nous = base_url_host_matches(agent._base_url_lower, "nousresearch.com") + _is_nvidia = base_url_host_matches(agent._base_url_lower, "integrate.api.nvidia.com") _is_kimi = ( base_url_host_matches(agent.base_url, "api.kimi.com") or base_url_host_matches(agent.base_url, "moonshot.ai") diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index e97e757ae9..7bdfa3f055 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -5255,7 +5255,7 @@ def run_conversation( if ( status_code == 413 and isinstance(agent.base_url, str) - and "models.inference.ai.azure.com" in agent.base_url + and base_url_host_matches(agent.base_url, "models.inference.ai.azure.com") ): agent._vprint( f"{agent.log_prefix} 💡 GitHub Models free tier (models.inference.ai.azure.com) caps every", diff --git a/agent/usage_pricing.py b/agent/usage_pricing.py index c2186eb437..c955bdc7f0 100644 --- a/agent/usage_pricing.py +++ b/agent/usage_pricing.py @@ -8,7 +8,7 @@ from decimal import Decimal from typing import Any, Dict, Literal, Optional from agent.model_metadata import fetch_endpoint_model_metadata, fetch_model_metadata -from utils import base_url_host_matches +from utils import base_url_host_matches, base_url_hostname logger = logging.getLogger(__name__) @@ -1098,7 +1098,7 @@ def resolve_billing_route( # Fireworks model ids look like accounts/fireworks/models/; # rsplit("/", 1)[-1] yields just which is what the dict keys on. return BillingRoute(provider="fireworks", model=model.rsplit("/", 1)[-1], base_url=base_url or "", billing_mode="official_docs_snapshot") - if provider_name in {"custom", "local"} or (base and "localhost" in base): + if provider_name in {"custom", "local"} or (base and base_url_hostname(base) in ("localhost", "127.0.0.1")): return BillingRoute(provider=provider_name or "custom", model=model, base_url=base_url or "", billing_mode="unknown") return BillingRoute(provider=provider_name or "unknown", model=model.split("/")[-1] if model else "", base_url=base_url or "", billing_mode="unknown") diff --git a/cli.py b/cli.py index ea222d3b8b..0423450370 100644 --- a/cli.py +++ b/cli.py @@ -224,7 +224,7 @@ from hermes_cli.browser_connect import ( try_launch_chrome_debug, ) from hermes_cli.env_loader import load_hermes_dotenv -from utils import base_url_host_matches, fast_safe_load +from utils import base_url_host_matches, base_url_hostname, fast_safe_load _hermes_home = get_hermes_home() _project_env = Path(__file__).parent / '.env' @@ -4604,7 +4604,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): # Auto-detect model from local server if still on default if self.model == _DEFAULT_CONFIG_MODEL: _base_url = (_model_config.get("base_url") or "") if isinstance(_model_config, dict) else "" - if "localhost" in _base_url or "127.0.0.1" in _base_url: + if base_url_hostname(_base_url) in ("localhost", "127.0.0.1"): from hermes_cli.runtime_provider import _auto_detect_local_model _detected = _auto_detect_local_model(_base_url) if _detected: @@ -7834,11 +7834,18 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): f"[dim] Hermes needs at least {MINIMUM_CONTEXT_LENGTH:,} tokens. Tool schemas + system prompt use a large fixed prefix.[/]" ) base_url = getattr(self, "base_url", "") or "" - if "11434" in base_url or "ollama" in base_url.lower(): + from urllib.parse import urlparse as _urlparse + try: + _parsed = _urlparse(base_url if "://" in base_url else f"//{base_url}") + _port = _parsed.port + except ValueError: + _port = None + _host = base_url_hostname(base_url) + if _port == 11434 or "ollama" in _host: self._console_print( f"[dim] Ollama fix: OLLAMA_CONTEXT_LENGTH={MINIMUM_CONTEXT_LENGTH} ollama serve[/]" ) - elif "1234" in base_url: + elif _port == 1234: self._console_print( "[dim] LM Studio fix: Set context length in model settings → reload model[/]" ) diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index d6cd312c99..3299843d2a 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -45,6 +45,7 @@ from agent.models_dev import ( get_model_info, list_provider_models, ) +from utils import base_url_host_matches, base_url_hostname # Providers whose picker model list should NOT be capped by max_models. # OpenCode Zen / Go are aggregators whose full catalogs (70+ models each) must @@ -1651,7 +1652,7 @@ def switch_model( is_custom = ( current_provider in {"custom", "local"} or current_provider.startswith("custom:") - or ("localhost" in _base or "127.0.0.1" in _base) + or base_url_hostname(_base) in ("localhost", "127.0.0.1") ) if ( @@ -2798,7 +2799,7 @@ def list_authenticated_providers( # explicit models: dict — avoid a misleading zero count in /model. if not models_list: url_lower = str(api_url).strip().lower() - if "api.openai.com" in url_lower: + if base_url_host_matches(url_lower, "api.openai.com"): fb = curated.get("openai") or [] if fb: models_list = list(fb) diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index e51c4638e8..92cb28c845 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -1735,7 +1735,7 @@ def resolve_runtime_provider( # return provider="custom" with chat_completions api_mode and no valid key). # Instead, use the Azure key directly with anthropic_messages api_mode. _eff_base = (explicit_base_url or "").strip() - if requested_provider == "anthropic" and base_url_host_matches(_eff_base, "openai.azure.com"): + if requested_provider == "anthropic" and base_url_host_matches(_eff_base, "azure.com"): _azure_key = ( (explicit_api_key or "").strip() or _getenv("AZURE_ANTHROPIC_KEY", "").strip() @@ -2075,8 +2075,8 @@ def resolve_runtime_provider( # would find the Claude Code OAuth token first (priority 3) and return # that instead, causing 401s. Detect Azure endpoints and use the env # key directly to bypass the OAuth priority chain. - _is_azure_endpoint = base_url_host_matches(base_url, "openai.azure.com") or ( - cfg_base_url and base_url_host_matches(cfg_base_url, "openai.azure.com") + _is_azure_endpoint = base_url_host_matches(base_url, "azure.com") or ( + cfg_base_url and base_url_host_matches(cfg_base_url, "azure.com") ) if _is_azure_endpoint: # Honor user-specified env var hints on the model config before diff --git a/run_agent.py b/run_agent.py index 61414c59af..86aee2e53a 100644 --- a/run_agent.py +++ b/run_agent.py @@ -1359,7 +1359,7 @@ class AIAgent: url = str(base_url).lower() else: url = getattr(self, "_base_url_lower", "") or "" - return "openai.azure.com" in url + return base_url_host_matches(url, "openai.azure.com") def _is_github_copilot_url(self, base_url: str = None) -> bool: """Return True when a base URL targets GitHub Copilot's OpenAI-compatible API.""" @@ -1514,8 +1514,8 @@ class AIAgent: def _is_copilot_url(self) -> bool: """Return True when the base URL targets GitHub Copilot or GitHub Models.""" return ( - "api.githubcopilot.com" in self._base_url_lower - or "models.github.ai" in self._base_url_lower + base_url_host_matches(self._base_url_lower, "api.githubcopilot.com") + or base_url_host_matches(self._base_url_lower, "models.github.ai") ) def _is_copilot_provider(self) -> bool: @@ -6003,7 +6003,7 @@ class AIAgent: # Azure endpoints use static API keys — OAuth token rotation doesn't apply. # Refreshing would pick up ~/.claude/.credentials.json OAuth token and break auth. _base = getattr(self, "_anthropic_base_url", "") or "" - if "azure.com" in _base: + if base_url_host_matches(_base, "azure.com"): return False try: @@ -7260,19 +7260,20 @@ class AIAgent: }: return True base = (getattr(self, "base_url", "") or "").lower() + host = base_url_hostname(base) return ( - "dashscope" in base - or "aliyuncs" in base - or "minimax" in base - or "opencode.ai/zen/" in base - or "bigmodel.cn" in base - or "xiaomimimo.com" in base + "dashscope" in host + or base_url_host_matches(base, "aliyuncs.com") + or "minimax" in host + or (base_url_host_matches(base, "opencode.ai") and "/zen/" in base) + or base_url_host_matches(base, "bigmodel.cn") + or base_url_host_matches(base, "xiaomimimo.com") # Vertex AI OpenAI-compat endpoint — Gemini model ids keep dots # (e.g. google/gemini-3.5-flash); the hyphenated form is wrong. - or "aiplatform.googleapis.com" in base + or base_url_host_matches(base, "aiplatform.googleapis.com") # AWS Bedrock runtime endpoints — defense-in-depth when # ``provider`` is unset but ``base_url`` still names Bedrock. - or "bedrock-runtime." in base + or host.startswith("bedrock-runtime.") ) def _is_qwen_portal(self) -> bool: @@ -7376,9 +7377,9 @@ class AIAgent: # has it; gemma3 / qwen3-coder don't. Cached per (model, base_url). if base_url_host_matches(self._base_url_lower, "ollama.com"): return self._ollama_supports_thinking_cached() - if "openrouter" not in self._base_url_lower: + if not self._is_openrouter_url(): return False - if "api.mistral.ai" in self._base_url_lower: + if base_url_host_matches(self._base_url_lower, "api.mistral.ai"): return False model = (self.model or "").lower() diff --git a/tests/hermes_cli/test_base_url_host_identity.py b/tests/hermes_cli/test_base_url_host_identity.py index 73881855da..de409899bf 100644 --- a/tests/hermes_cli/test_base_url_host_identity.py +++ b/tests/hermes_cli/test_base_url_host_identity.py @@ -89,3 +89,68 @@ def test_nous_portal_host_detection(): 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") + + +# ── Widened class coverage (follow-up to #85737) ───────────────────────────── + + +def test_azure_endpoint_detection_host_anchored(): + """Azure detection (runtime_provider + run_agent) must be host-anchored: + a path or lookalike containing 'azure.com'/'openai.azure.com' is not Azure.""" + from utils import base_url_host_matches + + assert base_url_host_matches("https://myres.openai.azure.com/openai/v1", "azure.com") + assert base_url_host_matches("https://myres.openai.azure.com/openai/v1", "openai.azure.com") + assert not base_url_host_matches("https://proxy.corp/openai.azure.com/v1", "azure.com") + assert not base_url_host_matches("https://azure.com.evil.net/v1", "azure.com") + assert not base_url_host_matches("https://notazure.com/v1", "azure.com") + + +def test_run_agent_azure_url_predicate(): + from run_agent import AIAgent + + probe = object.__new__(AIAgent) + assert probe._is_azure_openai_url("https://myres.openai.azure.com/openai/v1") is True + assert probe._is_azure_openai_url("https://proxy.internal/openai.azure.com/v1") is False + assert probe._is_azure_openai_url("https://openai.azure.com.evil.io/v1") is False + + +def test_run_agent_copilot_url_predicate(): + from run_agent import AIAgent + + probe = object.__new__(AIAgent) + probe._base_url_lower = "https://api.githubcopilot.com/v1" + assert probe._is_copilot_url() is True + probe._base_url_lower = "https://proxy.test/api.githubcopilot.com/v1" + assert probe._is_copilot_url() is False + probe._base_url_lower = "https://models.github.ai/inference" + assert probe._is_copilot_url() is True + probe._base_url_lower = "https://models.github.ai.evil.com/v1" + assert probe._is_copilot_url() is False + + +def test_dotted_model_name_provider_allowlist_host_anchored(): + from run_agent import AIAgent + + probe = object.__new__(AIAgent) + probe.provider = "" + probe.base_url = "https://open.bigmodel.cn/api/paas/v4" + assert probe._anthropic_preserve_dots() is True + probe.base_url = "https://gateway.example.com/bigmodel.cn/v4" + assert probe._anthropic_preserve_dots() is False + probe.base_url = "https://aiplatform.googleapis.com/v1" + assert probe._anthropic_preserve_dots() is True + probe.base_url = "https://evil.io/aiplatform.googleapis.com/v1" + assert probe._anthropic_preserve_dots() is False + + +def test_figma_remote_mcp_host_anchored(): + from tools.mcp_oauth import _is_figma_remote_mcp + + assert _is_figma_remote_mcp(server_url="https://mcp.figma.com/mcp") is True + assert _is_figma_remote_mcp(server_url="https://www.figma.com/mcp") is True + assert _is_figma_remote_mcp(server_url="https://evil.example/mcp.figma.com/mcp") is False + assert _is_figma_remote_mcp(server_url="https://figma.com.evil.io/mcp") is False + # Name fallback still host-checks the URL when one is present. + assert _is_figma_remote_mcp(server_name="figma", server_url="https://phish.example/figma") is False + assert _is_figma_remote_mcp(server_name="figma") is True diff --git a/tools/mcp_oauth.py b/tools/mcp_oauth.py index c4770b3c77..72955fdfde 100644 --- a/tools/mcp_oauth.py +++ b/tools/mcp_oauth.py @@ -1143,10 +1143,13 @@ def _is_figma_remote_mcp( """True when this MCP server is Figma's hosted remote endpoint.""" url = (server_url or "").lower() name = (server_name or "").lower() - if "mcp.figma.com" in url or "figma.com/mcp" in url: + from utils import base_url_host_matches, base_url_hostname + if base_url_host_matches(url, "mcp.figma.com") or ( + base_url_host_matches(url, "figma.com") and "/mcp" in url + ): return True # Name-only match only when the URL isn't some other host called figma-*. - if "figma" in name and (not url or "figma" in url): + if "figma" in name and (not url or "figma" in base_url_hostname(url)): return True return False diff --git a/tools/skills_hub.py b/tools/skills_hub.py index b4bca1e5c8..51340e6479 100644 --- a/tools/skills_hub.py +++ b/tools/skills_hub.py @@ -3254,7 +3254,8 @@ class BrowseShSource(SkillSource): pass source_url = item.get("sourceUrl", "") if isinstance(item, dict) else "" - if source_url and "raw.githubusercontent.com" in source_url: + from utils import base_url_host_matches + if source_url and base_url_host_matches(source_url, "raw.githubusercontent.com"): return source_url return None