From 894859c9fc0efad0308d2ff0ec04d5df13bea7db Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:26:16 -0800 Subject: [PATCH] fix(providers): a profile missing its declared key says so instead of a 401 A named profile served by a multiplexing gateway reads provider keys only from its own .env (by design). When a custom endpoint declares key_env and the variable is missing from that scope (e.g. exported only in the server's shell), resolution substituted the keyless placeholder `no-key-required`, sent it, and the user saw "custom rejected your API key ... HTTP 401", naming neither the variable nor the profile. Resolution now refuses that runtime before any request with a typed AuthError(missing_api_key) naming the provider, the variable, the profile, and `hermes -p NAME config set VAR `; it also says when the variable exists only in the server's environment. Custom blocks with no key_env (Ollama, llama.cpp, vLLM) keep the placeholder. Registry API-key providers' existing "Set X." error gains the same profile hint on named profiles. The TUI/Desktop init-failure wrapper no longer prints ".." after a message ending in a period. --- hermes_cli/runtime_provider.py | 4 + hermes_cli/runtime_provider_missing_key.py | 87 +++++++++++++++++++ ...st_runtime_provider_missing_profile_key.py | 76 ++++++++++++++++ tui_gateway/user_messages.py | 7 +- 4 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 hermes_cli/runtime_provider_missing_key.py create mode 100644 tests/hermes_cli/test_runtime_provider_missing_profile_key.py diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index da99b41c20..608dea2637 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -901,7 +901,9 @@ def _api_key_provider_runtime(provider, pconfig, requested_provider, model_cfg, # to the first request and make a later fallback look like a silent provider switch. if not has_usable_secret(creds.get("api_key")): from hermes_cli.runtime_readiness import missing_credentials_suffix + from hermes_cli.runtime_provider_missing_key import named_profile_key_hint hint = (f" Set {', '.join(pconfig.api_key_env_vars)}." if pconfig.api_key_env_vars else "") + missing_credentials_suffix(provider) + hint += named_profile_key_hint(pconfig.api_key_env_vars) raise AuthError(f"No usable credentials found for provider '{provider}'.{hint}", provider=provider, code="missing_api_key") # Honour model.base_url when the configured provider matches (e.g. api.minimaxi.com China endpoint). base_url = _actual_url(provider, _config_base_url_for_provider(model_cfg, provider) or creds.get("base_url", "").rstrip("/")) @@ -1070,6 +1072,8 @@ def resolve_runtime_provider(*, requested: Optional[str] = None, explicit_api_ke _raise_if_local_alias_missing_endpoint(requested_provider, explicit_base_url) runtime = next(r for r in _ladder_rungs(requested_provider, explicit_api_key, explicit_base_url, target_model) if r) _raise_for_credentialless_bare_custom(requested_provider, runtime) + from hermes_cli.runtime_provider_missing_key import raise_if_declared_key_missing + raise_if_declared_key_missing(requested_provider, runtime, _get_model_config(), _FROZEN_RUNTIME_CONFIG.get()) # model.openai_runtime is applied ONCE, after the ladder: every rung (pool, OAuth store, # explicit --api-key/--base-url, env key) hardcodes the wire api_mode for openai/openai-codex, # so applying the opt-in inside one rung left the others on codex_responses (#115169). diff --git a/hermes_cli/runtime_provider_missing_key.py b/hermes_cli/runtime_provider_missing_key.py new file mode 100644 index 0000000000..548d68fd89 --- /dev/null +++ b/hermes_cli/runtime_provider_missing_key.py @@ -0,0 +1,87 @@ +"""A provider key the active profile's secret scope does not have, said before any request. + +Custom resolution substitutes the ``no-key-required`` placeholder for an empty key so keyless local +servers (Ollama, llama.cpp, vLLM) work. When the endpoint's config DECLARES a key variable +(``key_env`` / ``api_key_env``) and it resolves to nothing, the request can only be refused, and the +refusal reads "rejected your API key" although no key was sent. Under a multiplexing server a named +profile reads keys only from its own ``.env``, never the server's process environment (secret scope +is per profile by design), so a key exported in the server's shell is invisible to it. The error +names the provider, the variable, the profile and the command that puts the key where it is read. +""" + +from __future__ import annotations + +import os +from typing import Any, Optional + +from agent.secret_scope import get_secret_str + +NO_KEY_PLACEHOLDER = "no-key-required" + + +def _declared_key_env(entry: Optional[dict[str, Any]]) -> str: + if not isinstance(entry, dict): + return "" + return str(entry.get("key_env") or entry.get("api_key_env") or "").strip() + + +def _declared_custom_key(requested_provider: str, base_url: str, model_cfg: dict[str, Any], + config: Optional[dict[str, Any]]) -> tuple[str, str]: + """``(label, key_env)`` of the config block that declared a key variable for this custom + endpoint, else ``("", "")``: the owning ``providers:`` / ``custom_providers:`` entry (by name, + then URL), then ``model.key_env`` when ``model.base_url`` is this endpoint.""" + from agent.custom_entry_pool import custom_entry_for_endpoint + name = requested_provider if requested_provider not in ("", "auto", "custom") else None + match = custom_entry_for_endpoint(base_url, name, config) + if match is not None and (key_env := _declared_key_env(match[1])): + return f"custom provider '{match[1].get('name') or match[0]}'", key_env + cfg_url = str(model_cfg.get("base_url") or "").strip().rstrip("/") + if cfg_url and cfg_url == base_url.strip().rstrip("/") and (key_env := _declared_key_env(model_cfg)): + return "the custom endpoint in config.yaml (model.key_env)", key_env + return "", "" + + +def where_to_set_key(env_var: str) -> str: + """Sentence naming the profile whose ``.env`` must hold ``env_var`` and the command that writes + it there. Says so when the variable exists only in this process's environment while a routed + (named) profile is served: that is the copy users hit after exporting keys in an SSH shell.""" + from agent.secret_scope import serves_routed_profile + from hermes_constants import display_hermes_home, get_hermes_home, profile_cli_selector, profile_name_for_home + profile = profile_name_for_home(get_hermes_home()) or "default" + env_path = f"{display_hermes_home()}/.env" + hint = (f"Add it to profile '{profile}': run `hermes {profile_cli_selector()}config set {env_var} ` " + f"(stored in {env_path}).") + if serves_routed_profile() and os.environ.get(env_var): + hint = (f"{env_var} is set in this server's environment, but profile '{profile}' reads keys only " + f"from its own .env. {hint}") + return hint + + +def named_profile_key_hint(env_vars) -> str: + """``" "`` for a registry provider's missing key while a NAMED profile is + active, else ``""``: the default profile also reads the process environment, so the plain + "Set X." already tells it everything.""" + from hermes_constants import get_hermes_home, profile_name_for_home + names = [str(v) for v in (env_vars or ()) if str(v or "").strip()] + if not names or profile_name_for_home(get_hermes_home()) in (None, "default"): + return "" + return f" {where_to_set_key(names[0])}" + + +def raise_if_declared_key_missing(requested_provider: Optional[str], runtime: dict[str, Any], model_cfg: dict[str, Any], + config: Optional[dict[str, Any]] = None) -> None: + """Refuse a custom runtime carrying the placeholder when its config declared a key variable + that resolves to nothing in the active profile's scope. A block that declares no key variable + keeps the placeholder: that IS the keyless local-server configuration. ``config`` is a frozen + route's config (the entry that owned the endpoint when the session froze it). Typed ``AuthError`` + (``missing_api_key``) so every caller's fallback chain still advances.""" + if runtime.get("provider") != "custom" or runtime.get("api_key") != NO_KEY_PLACEHOLDER: + return + label, key_env = _declared_custom_key(str(requested_provider or "").strip().lower(), + str(runtime.get("base_url") or ""), model_cfg, config) + if not key_env or get_secret_str(key_env, "").strip(): + return + from hermes_cli.auth import AuthError + raise AuthError(f"No API key for {label}: it reads {key_env}, which is not set for this profile, so the " + f"request was not sent. {where_to_set_key(key_env)}", + provider=str(requested_provider or "custom"), code="missing_api_key") diff --git a/tests/hermes_cli/test_runtime_provider_missing_profile_key.py b/tests/hermes_cli/test_runtime_provider_missing_profile_key.py new file mode 100644 index 0000000000..186a6c3f35 --- /dev/null +++ b/tests/hermes_cli/test_runtime_provider_missing_profile_key.py @@ -0,0 +1,76 @@ +"""A named profile served by a multiplexing gateway reads provider keys only from its own ``.env``. + +When the endpoint's config declares ``key_env`` and that variable is missing from the profile's +scope (it may exist in the server's process environment, which the profile must not read), the +turn fails before any request with the provider, the variable, the profile and the command that +stores the key, instead of sending the ``no-key-required`` placeholder and reporting the provider's +401 as "rejected your API key". A custom block with no ``key_env`` is a keyless local server and +keeps the placeholder. +""" + +import json + +import pytest + +from agent import secret_scope + + +def _named_profile(tmp_path, monkeypatch, *, key_env: bool, dotenv: str = ""): + home = tmp_path / ".hermes" / "profiles" / "beta" + home.mkdir(parents=True) + model = {"default": "fake-model", "provider": "custom", "base_url": "http://127.0.0.1:18991/v1"} + entry = {"name": "FakeUp", "base_url": "http://127.0.0.1:18991/v1"} + if key_env: + entry["key_env"] = "CANARY_KEY" + (home / "config.yaml").write_text(json.dumps({"model": model, "custom_providers": [entry]})) + (home / ".env").write_text(dotenv) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("HERMES_HOME", str(home)) + # The key lives only in the server's process environment: invisible to a routed profile. + monkeypatch.setenv("CANARY_KEY", "canary-process-env") + return home + + +@pytest.fixture +def multiplexed(): + tokens = [] + + def bind(home): + tokens.append(secret_scope.set_secret_scope(secret_scope.build_profile_secret_scope(home))) + + secret_scope.set_multiplex_active(True) + try: + yield bind + finally: + for token in reversed(tokens): + secret_scope.reset_secret_scope(token) + secret_scope.set_multiplex_active(False) + + +def test_declared_key_missing_from_profile_scope_fails_before_the_request(tmp_path, monkeypatch, multiplexed): + from hermes_cli.auth import AuthError + from hermes_cli.runtime_provider import resolve_runtime_provider + + multiplexed(_named_profile(tmp_path, monkeypatch, key_env=True)) + with pytest.raises(AuthError) as excinfo: + resolve_runtime_provider() + message = str(excinfo.value) + assert excinfo.value.code == "missing_api_key" + for needle in ("FakeUp", "CANARY_KEY", "'beta'", "hermes -p beta config set CANARY_KEY"): + assert needle in message + assert "server's environment" in message # the key the user exported is named as unreadable here + assert "canary-process-env" not in message + + +@pytest.mark.parametrize("key_env,dotenv,expected_key", [ + (False, "", "no-key-required"), # keyless local server: placeholder, no error + (True, "CANARY_KEY=canary-profile-dotenv\n", "canary-profile-dotenv"), # key in the profile's .env +], ids=["keyless-local-endpoint", "key-in-profile-dotenv"]) +def test_keyless_endpoint_and_profile_key_still_resolve(tmp_path, monkeypatch, multiplexed, key_env, dotenv, + expected_key): + from hermes_cli.runtime_provider import resolve_runtime_provider + + multiplexed(_named_profile(tmp_path, monkeypatch, key_env=key_env, dotenv=dotenv)) + runtime = resolve_runtime_provider() + assert runtime["provider"] == "custom" + assert runtime["api_key"] == expected_key diff --git a/tui_gateway/user_messages.py b/tui_gateway/user_messages.py index 009271bd0e..f2d3225d79 100644 --- a/tui_gateway/user_messages.py +++ b/tui_gateway/user_messages.py @@ -121,11 +121,12 @@ _AUTH_LOCK_TIMEOUT_PREFIXES = ( def agent_init_failed_message(exc: Any) -> str: - if any(prefix in str(exc) for prefix in _AUTH_LOCK_TIMEOUT_PREFIXES): - return (f"Hermes could not start the assistant for this session. Details: {exc}. " + detail = str(exc).rstrip(". ") # resolver messages end in a sentence; no ".." before the hint + if any(prefix in detail for prefix in _AUTH_LOCK_TIMEOUT_PREFIXES): + return (f"Hermes could not start the assistant for this session. Details: {detail}. " "Wait for the other process to release the lock (or exit it — check for a running " "dashboard or background hermes process), then retry.") - return (f"Hermes could not start the assistant for this session. Details: {exc}. " + return (f"Hermes could not start the assistant for this session. Details: {detail}. " "Check the model and provider with /model, or run `hermes setup` in a terminal to reconfigure.")