diff --git a/hermes_cli/runtime_provider_backends.py b/hermes_cli/runtime_provider_backends.py index a0792943cd..090a9517ea 100644 --- a/hermes_cli/runtime_provider_backends.py +++ b/hermes_cli/runtime_provider_backends.py @@ -157,18 +157,23 @@ def _resolve_openrouter_runtime( ) ) if is_openrouter_context: + # Same read order as the credential pool and _resolve_api_key_provider_secret + # (.env, then scope-aware os.environ, raw op:// references yielding to the resolved + # scoped value). get_secret_str sees os.environ only, so a key living solely in + # ~/.hermes/.env was lost once the pool entry went exhausted/benched (#117667). # OPENAI_API_KEY is a legacy home for an OpenRouter key. When OPENAI_BASE_URL binds it, it # goes only to that origin: another scheme or port on the same host is another endpoint. # Unbound, openrouter.ai gets it only when it is OpenRouter-shaped (sk-or-), so a real OpenAI # key never reaches a third party. - openai_key = get_secret_str("OPENAI_API_KEY") + from hermes_cli.config import get_env_value_prefer_dotenv + openai_key = get_env_value_prefer_dotenv("OPENAI_API_KEY") or "" openai_base_url = get_secret_str("OPENAI_BASE_URL", "").strip() if base_url_hostname(openai_base_url): openai_origin = base_url_origin(openai_base_url) # empty on a bad port: bound, matches nothing openai_key_ok = bool(openai_origin[1]) and openai_origin == base_url_origin(base_url) else: openai_key_ok = not is_openrouter_url or rp.looks_like_openrouter_key(openai_key) - candidates = [explicit_api_key, get_secret_str("OPENROUTER_API_KEY"), + candidates = [explicit_api_key, get_env_value_prefer_dotenv("OPENROUTER_API_KEY"), openai_key if openai_key_ok else ""] else: # ``model.api_key`` and ``model.key_env`` back a trusted config base_url only; the key_env diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index 6f28ab339c..065732789a 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -726,6 +726,34 @@ def test_openai_key_bound_to_another_host_never_reaches_openrouter(monkeypatch, assert resolved["api_key"] == expected_key +def test_openrouter_env_file_key_survives_exhausted_pool(monkeypatch): + """A key living only in ~/.hermes/.env must survive an exhausted pool entry. + + Regression test for #117667: with the pool's OPENROUTER_API_KEY entry benched + (select() -> None), the terminal resolver read os.environ only, so the documented + .env location was skipped and `hermes chat --provider openrouter` failed with + "No API key found" until the key was exported in the shell. + """ + monkeypatch.setattr(rp, "resolve_provider", lambda *a, **k: "openrouter") + monkeypatch.setattr(rp, "_get_model_config", lambda: {}) + monkeypatch.setattr( + rp, "load_pool", + lambda _provider: SimpleNamespace(has_credentials=lambda: True, select=lambda **_kw: None), + ) + monkeypatch.delenv("OPENAI_BASE_URL", raising=False) + monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False) + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + or_env_name = "OPENROUTER_" + "API_KEY" + monkeypatch.delenv(or_env_name, raising=False) + monkeypatch.setattr( + "hermes_cli.config.load_env", lambda: {or_env_name: "dotenv-or-key"} + ) + + resolved = rp.resolve_runtime_provider(requested="openrouter") + + assert resolved["api_key"] == "dotenv-or-key" + + def test_custom_endpoint_uses_saved_config_base_url_when_env_missing(monkeypatch): """Persisted custom endpoints in config.yaml must still resolve when OPENAI_BASE_URL is absent from the current environment.