diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index 37dc514bcf..026b318299 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -491,7 +491,8 @@ def _openrouter_should_use_pool(requested_provider, model_cfg, explicit_api_key, """OpenRouter pool only for a plain openrouter/auto request with no custom endpoint or override.""" cfg_base_url = str(model_cfg.get("base_url") or "").strip() env_base_urls = _getenv("OPENAI_BASE_URL", "").strip() or _getenv("OPENROUTER_BASE_URL", "").strip() - has_custom_endpoint = bool(explicit_base_url or env_base_urls or (cfg_base_url and _cfg_provider(model_cfg) in {"auto", "custom"})) + has_custom_endpoint = bool(explicit_base_url or env_base_urls + or (cfg_base_url and _cfg_provider(model_cfg) in {"auto", "custom", "openrouter"})) return requested_provider in {"openrouter", "auto"} and not has_custom_endpoint and not bool(explicit_api_key or explicit_base_url) diff --git a/hermes_cli/runtime_provider_backends.py b/hermes_cli/runtime_provider_backends.py index 4735efe23c..75ed38b67e 100644 --- a/hermes_cli/runtime_provider_backends.py +++ b/hermes_cli/runtime_provider_backends.py @@ -131,6 +131,8 @@ def _resolve_openrouter_runtime( use_config_base_url = bool(cfg_base_url.strip()) and not explicit_base_url and ( (requested_norm == "auto" and cfg_provider in ("", "auto")) or (requested_norm == "custom" and rp._config_base_url_trustworthy_for_bare_custom(cfg_base_url, cfg_provider)) + # provider: openrouter + base_url in config.yaml is a deliberate mirror/proxy (#10622). + or (requested_norm == "openrouter" and cfg_provider == "openrouter") ) 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).rstrip("/") @@ -138,11 +140,16 @@ def _resolve_openrouter_runtime( # prefer OPENROUTER_API_KEY (issue #289). When hitting a custom endpoint (e.g. Z.ai, local LLM), prefer # OPENAI_API_KEY so the OpenRouter key doesn't leak to an unrelated provider (issues #420, #560). is_openrouter_url = base_url_host_matches(base_url, "openrouter.ai") - # Explicitly-configured OpenRouter mirrors (OPENROUTER_BASE_URL + provider=openrouter) still - # count as OpenRouter for key selection. + # Explicitly-configured OpenRouter mirrors (OPENROUTER_BASE_URL, or a config.yaml base_url under + # provider: openrouter) still count as OpenRouter for key selection — otherwise the mirror's host + # fails the openrouter.ai match and the generic custom-endpoint branch never selects + # OPENROUTER_API_KEY for it (#10622). is_openrouter_context = is_openrouter_url or ( - requested_norm == "openrouter" and (env_openrouter_base_url or base_url == env_openrouter_base_url) - and base_url == (env_openrouter_base_url or "").rstrip("/") + requested_norm == "openrouter" and ( + (use_config_base_url and cfg_provider == "openrouter") + or ((env_openrouter_base_url or base_url == env_openrouter_base_url) + and base_url == (env_openrouter_base_url or "").rstrip("/")) + ) ) if is_openrouter_context: candidates = [explicit_api_key, rp._getenv("OPENROUTER_API_KEY"), rp._getenv("OPENAI_API_KEY")] diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index 35865157c6..9023a3cb80 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -868,6 +868,71 @@ def test_explicit_openrouter_skips_openai_base_url(monkeypatch): assert resolved["api_key"] == "or-test-key" +def test_explicit_openrouter_honors_config_base_url_mirror(monkeypatch): + """requested='openrouter' + config provider='openrouter' + config base_url must + resolve to that mirror AND still select OPENROUTER_API_KEY for it — a + config-sourced mirror is an OpenRouter context for credential selection just + like the OPENROUTER_BASE_URL env path already is. Regression test for #10622.""" + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter") + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: { + "provider": "openrouter", + "base_url": "https://openrouter-mirror.example.com/api/v1", + }, + ) + monkeypatch.setattr(rp, "load_pool", lambda _provider: SimpleNamespace(has_credentials=lambda: False)) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.setenv("OPENROUTER_API_KEY", "router-key") + + resolved = rp.resolve_runtime_provider(requested="openrouter") + + assert resolved["provider"] == "openrouter" + assert resolved["base_url"] == "https://openrouter-mirror.example.com/api/v1" + assert resolved["api_key"] == "router-key" + + +def test_explicit_openrouter_config_mirror_bypasses_pool(monkeypatch): + """A config.yaml mirror under provider='openrouter' is a custom endpoint: the + OpenRouter credential pool must be skipped rather than silently routing the + request to openrouter.ai with a pooled key (#10622).""" + class _Entry: + access_token = "pool-key" + source = "manual" + base_url = "https://openrouter.ai/api/v1" + + class _Pool: + def has_credentials(self): + return True + + def select(self): + return _Entry() + + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter") + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: { + "provider": "openrouter", + "base_url": "https://openrouter-mirror.example.com/api/v1", + }, + ) + monkeypatch.setattr(rp, "load_pool", lambda _provider: _Pool()) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.setenv("OPENROUTER_API_KEY", "router-key") + + resolved = rp.resolve_runtime_provider(requested="openrouter") + + assert resolved["base_url"] == "https://openrouter-mirror.example.com/api/v1" + assert resolved["api_key"] == "router-key" + assert resolved.get("credential_pool") is None + +