mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
1 parent
fbf6c6c91d
commit
894859c9fc
4 files changed
+171
-3
No files matched your search
@@ -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
|
||||
@@ -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.")
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user