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 <key>`; 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.
This commit is contained in:
Teknium committed 2026-11-02 06:57:42 -08:00
1 parent fbf6c6c91d
commit 894859c9fc
4 files changed
+171 -3

No files matched your search

+4
View File
@@ -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).
@@ -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} <your key>` "
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:
"""``" <where_to_set_key>"`` 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")
@@ -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
+4 -3
View File
@@ -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.")