diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 4e04f75d7d..ebdbfe6056 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -36,6 +36,29 @@ def _normalize_custom_provider_name(value: str) -> str: return value.strip().lower().replace(" ", "-") +def _loopback_hostname(host: str) -> bool: + h = (host or "").lower().rstrip(".") + return h in {"localhost", "127.0.0.1", "::1", "0.0.0.0"} + + +def _config_base_url_trustworthy_for_bare_custom(cfg_base_url: str, cfg_provider: str) -> bool: + """Decide whether ``model.base_url`` may back bare ``custom`` runtime resolution. + + GitHub #14676: the model picker can select Custom while ``model.provider`` still reflects a + previous provider. Reject non-loopback URLs unless the YAML provider is already ``custom``, + so a stale OpenRouter/Z.ai base_url cannot hijack local ``custom`` sessions. + """ + cfg_provider_norm = (cfg_provider or "").strip().lower() + bu = (cfg_base_url or "").strip() + if not bu: + return False + if cfg_provider_norm == "custom": + return True + if base_url_host_matches(bu, "openrouter.ai"): + return False + return _loopback_hostname(base_url_hostname(bu)) + + def _detect_api_mode_for_url(base_url: str) -> Optional[str]: """Auto-detect api_mode from the resolved base URL. @@ -472,6 +495,7 @@ def _resolve_openrouter_runtime( cfg_provider = cfg_provider.strip().lower() env_openrouter_base_url = os.getenv("OPENROUTER_BASE_URL", "").strip() + env_custom_base_url = os.getenv("CUSTOM_BASE_URL", "").strip() # Use config base_url when available and the provider context matches. # OPENAI_BASE_URL env var is no longer consulted — config.yaml is @@ -481,11 +505,14 @@ def _resolve_openrouter_runtime( if requested_norm == "auto": if not cfg_provider or cfg_provider == "auto": use_config_base_url = True - elif requested_norm == "custom" and cfg_provider == "custom": + elif requested_norm == "custom" and _config_base_url_trustworthy_for_bare_custom( + cfg_base_url, cfg_provider + ): use_config_base_url = True base_url = ( (explicit_base_url or "").strip() + or env_custom_base_url or (cfg_base_url.strip() if use_config_base_url else "") or env_openrouter_base_url or OPENROUTER_BASE_URL diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index 9d2232f39c..a81dc9f5e2 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -536,6 +536,72 @@ def test_custom_endpoint_explicit_custom_prefers_config_key(monkeypatch): assert resolved["api_key"] == "sk-vllm-key" +def test_bare_custom_uses_loopback_model_base_url_when_provider_not_custom(monkeypatch): + """Regression for #14676: /model can select Custom while YAML still lists another provider.""" + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter") + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: { + "provider": "openrouter", + "base_url": "http://127.0.0.1:8082/v1", + "default": "my-local-model", + }, + ) + monkeypatch.delenv("CUSTOM_BASE_URL", raising=False) + monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + monkeypatch.setenv("OPENAI_API_KEY", "openai-key") + + resolved = rp.resolve_runtime_provider(requested="custom") + + assert resolved["provider"] == "custom" + assert resolved["base_url"] == "http://127.0.0.1:8082/v1" + assert resolved["api_key"] == "openai-key" + + +def test_bare_custom_custom_base_url_env_overrides_remote_yaml(monkeypatch): + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter") + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: { + "provider": "openrouter", + "base_url": "https://api.openrouter.ai/api/v1", + }, + ) + monkeypatch.setenv("CUSTOM_BASE_URL", "http://localhost:9999/v1") + monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False) + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + + resolved = rp.resolve_runtime_provider(requested="custom") + + assert resolved["provider"] == "custom" + assert resolved["base_url"] == "http://localhost:9999/v1" + + +def test_bare_custom_does_not_trust_non_loopback_when_provider_not_custom(monkeypatch): + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter") + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: { + "provider": "openrouter", + "base_url": "https://remote.example.com/v1", + }, + ) + monkeypatch.delenv("CUSTOM_BASE_URL", raising=False) + monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False) + monkeypatch.setenv("OPENROUTER_API_KEY", "or-key") + + resolved = rp.resolve_runtime_provider(requested="custom") + + assert resolved["provider"] == "custom" + assert "openrouter.ai" in resolved["base_url"] + assert "remote.example.com" not in resolved["base_url"] + + def test_named_custom_provider_uses_saved_credentials(monkeypatch): monkeypatch.delenv("OPENAI_API_KEY", raising=False) monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)