Files
hermes-agent/hermes_cli/runtime_provider_missing_key.py
Teknium 30375b51bc fix(providers): refuse only a synthesized placeholder for the selected entry
Review findings on #147761: the missing-profile-key refusal fired for a
custom provider on model.openai_runtime=codex_app_server (which owns its
auth), for a caller-supplied explicit_api_key='no-key-required', and lost
the selected entry when explicit_base_url replaced its URL (back to the
misleading 401). The check now runs on the final api_mode and skips
transports that never send the key (codex_app_server, bedrock_converse),
skips any caller-supplied key, and identifies a named-entry runtime by
its selected entry rather than by URL.
2026-11-02 06:57:42 -08:00

109 lines
6.6 KiB
Python

"""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, runtime: dict[str, Any], 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, then
``model.key_env`` when ``model.base_url`` is this endpoint. A runtime the named-entry rung built
(``source: custom_provider:<name>``) took its credential from the SELECTED entry even when an
explicit base_url replaced the entry's URL, so that entry is found by name alone; any other
runtime (direct alias, URL-matched bare custom) is owned by whichever entry serves its URL."""
from agent.custom_entry_pool import custom_entry_for_endpoint, named_custom_entry
base_url = str(runtime.get("base_url") or "")
name = requested_provider if requested_provider not in ("", "auto", "custom") else None
from_named_entry = str(runtime.get("source") or "").startswith("custom_provider:")
match = named_custom_entry(name, config) if name and from_named_entry else 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])}"
# Transports that never send the resolved key: the codex app-server authenticates with its own login,
# Bedrock with the AWS SDK credential chain (agent_init builds neither client from ``api_key``).
_KEYLESS_API_MODES = frozenset({"codex_app_server", "bedrock_converse"})
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, *, explicit_api_key: Optional[str] = None) -> None:
"""Refuse a custom runtime carrying the placeholder when its config declared a key variable
that resolves to nothing in a ROUTED profile's scope (a profile served by a process launched
for another, which never reads that process's environment). A single-profile process keeps
the placeholder plus ``_key_env_secret``'s warning: there the variable is read from the
process environment too, and callers rely on the placeholder rather than an error. A block
that declares no key variable keeps the placeholder: that IS the keyless local-server
configuration. Only a placeholder the resolver SYNTHESIZED is refused: a caller-supplied key
(even the literal ``no-key-required``, the same value a set key variable may hold) and a
transport that never sends the key (``_KEYLESS_API_MODES``, judged on the final api_mode) pass.
``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."""
from agent.secret_scope import serves_routed_profile
if runtime.get("provider") != "custom" or runtime.get("api_key") != NO_KEY_PLACEHOLDER:
return
if str(explicit_api_key or "").strip() or runtime.get("api_mode") in _KEYLESS_API_MODES:
return
if not serves_routed_profile():
return
label, key_env = _declared_custom_key(str(requested_provider or "").strip().lower(), runtime, 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")