fix(auth): honor per-entry key_env when resolving fallback providers

A fallback chain entry can name its API key via key_env (or the
api_key_env alias) per the fallback-providers docs, but only the gateway
path resolved it — TUI/desktop, cron, and CLI setup fallbacks ignored it,
so a fallback provider whose key lives in a non-standard env var never
resolved on those surfaces.

Centralize the inline-api_key-then-key_env lookup in
hermes_cli/fallback_config.resolve_entry_api_key() and use it at all four
fallback resolution sites (tui_gateway, cron scheduler, gateway runner,
CLI setup mixin); the CLI mixin also gains the base_url passthrough the
other surfaces already had.

Salvaged from PR #43861 (surgical reapply — the original branch predates
the #65264 fallback restructuring).
This commit is contained in:
Rage Lopez 2026-07-16 06:37:09 -07:00 committed by Teknium
parent c3b2af95e3
commit 998e35313a
7 changed files with 118 additions and 13 deletions

View file

@ -3083,14 +3083,17 @@ def run_job(
if not fb_provider or not fb_model:
continue
try:
from hermes_cli.fallback_config import resolve_entry_api_key
fb_kwargs = {
"requested": fb_provider,
"target_model": fb_model,
}
if entry.get("base_url"):
fb_kwargs["explicit_base_url"] = entry["base_url"]
if entry.get("api_key"):
fb_kwargs["explicit_api_key"] = entry["api_key"]
fb_api_key = resolve_entry_api_key(entry)
if fb_api_key:
fb_kwargs["explicit_api_key"] = fb_api_key
runtime = resolve_runtime_provider(**fb_kwargs)
model = fb_model
logger.info(

View file

@ -2038,17 +2038,12 @@ def _try_resolve_fallback_provider() -> dict | None:
return None
for entry in fb_list:
try:
explicit_api_key = entry.get("api_key")
if not explicit_api_key:
key_env = str(
entry.get("key_env") or entry.get("api_key_env") or ""
).strip()
if key_env:
explicit_api_key = os.getenv(key_env, "").strip() or None
from hermes_cli.fallback_config import resolve_entry_api_key
runtime = resolve_runtime_provider(
requested=entry.get("provider"),
explicit_base_url=entry.get("base_url"),
explicit_api_key=explicit_api_key,
explicit_api_key=resolve_entry_api_key(entry),
)
# Log the literal `provider` key from config, not the resolved
# runtime category — an Ollama fallback resolves through the

View file

@ -57,7 +57,15 @@ class CLIAgentSetupMixin:
if not _fb_provider or not _fb_model:
continue
try:
runtime = resolve_runtime_provider(requested=_fb_provider)
from hermes_cli.fallback_config import resolve_entry_api_key
_fb_kwargs = {"requested": _fb_provider}
if _fb.get("base_url"):
_fb_kwargs["explicit_base_url"] = _fb["base_url"]
_fb_api_key = resolve_entry_api_key(_fb)
if _fb_api_key:
_fb_kwargs["explicit_api_key"] = _fb_api_key
runtime = resolve_runtime_provider(**_fb_kwargs)
logger.warning(
"Primary provider auth failed (%s). Falling through to fallback: %s/%s",
_primary_exc, _fb_provider, _fb_model,

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import os
from typing import Any
@ -11,6 +12,25 @@ def _normalized_base_url(value: Any) -> str:
return value.strip().rstrip("/")
def resolve_entry_api_key(entry: dict[str, Any] | None) -> str | None:
"""API key for one fallback entry: inline ``api_key``, else ``key_env``.
Mirrors the custom-provider convention (``key_env`` names the env var
holding the key; ``api_key_env`` accepted as an alias). Returns None when
neither yields a non-empty value, letting ``resolve_runtime_provider``
fall through to the provider's standard credential resolution.
"""
if not isinstance(entry, dict):
return None
inline = str(entry.get("api_key") or "").strip()
if inline:
return inline
key_env = str(entry.get("key_env") or entry.get("api_key_env") or "").strip()
if key_env:
return os.getenv(key_env, "").strip() or None
return None
def _iter_fallback_entries(raw: Any) -> list[dict[str, Any]]:
if isinstance(raw, dict):
candidates = [raw]

View file

@ -0,0 +1,40 @@
"""Tests for hermes_cli/fallback_config.py — fallback entry API-key resolution."""
from hermes_cli.fallback_config import resolve_entry_api_key
class TestResolveEntryApiKey:
def test_inline_api_key_wins(self, monkeypatch):
monkeypatch.setenv("FB_KEY", "env-key")
entry = {"provider": "custom", "api_key": "inline-key", "key_env": "FB_KEY"}
assert resolve_entry_api_key(entry) == "inline-key"
def test_key_env_resolves_from_environment(self, monkeypatch):
monkeypatch.setenv("FB_KEY", "env-key")
assert resolve_entry_api_key({"key_env": "FB_KEY"}) == "env-key"
def test_api_key_env_alias(self, monkeypatch):
monkeypatch.setenv("FB_ALIAS_KEY", "alias-key")
assert resolve_entry_api_key({"api_key_env": "FB_ALIAS_KEY"}) == "alias-key"
def test_unset_env_var_returns_none(self, monkeypatch):
monkeypatch.delenv("FB_MISSING", raising=False)
# None (not "") lets resolve_runtime_provider fall through to the
# provider's standard credential resolution.
assert resolve_entry_api_key({"key_env": "FB_MISSING"}) is None
def test_empty_env_var_returns_none(self, monkeypatch):
monkeypatch.setenv("FB_EMPTY", " ")
assert resolve_entry_api_key({"key_env": "FB_EMPTY"}) is None
def test_no_key_fields_returns_none(self):
assert resolve_entry_api_key({"provider": "openrouter", "model": "glm"}) is None
def test_non_dict_returns_none(self):
assert resolve_entry_api_key(None) is None
assert resolve_entry_api_key("nope") is None # type: ignore[arg-type]
def test_whitespace_inline_key_falls_through_to_env(self, monkeypatch):
monkeypatch.setenv("FB_KEY", "env-key")
entry = {"api_key": " ", "key_env": "FB_KEY"}
assert resolve_entry_api_key(entry) == "env-key"

View file

@ -9597,6 +9597,42 @@ class TestResolveRuntimeWithFallback:
assert resolution.selected_model == "z-ai/glm-5.2"
assert resolution.used_fallback is True
def test_fallback_entry_key_env_resolves_api_key(self, monkeypatch):
"""A fallback entry naming its key via key_env passes the resolved
env value as explicit_api_key (#43861, @VrtxOmega)."""
from hermes_cli.auth import AuthError
monkeypatch.setenv("FB_TEST_KEY", "env-resolved-key")
captured = {}
fallback_runtime = {"provider": "openrouter", "api_key": "x"}
def fake_resolve(**kwargs):
if kwargs.get("requested") == "openai-codex":
raise AuthError("No Codex credentials stored")
captured.update(kwargs)
return fallback_runtime
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
fake_resolve,
)
monkeypatch.setattr(
server,
"_load_fallback_model",
lambda: [
{
"provider": "openrouter",
"model": "z-ai/glm-5.2",
"key_env": "FB_TEST_KEY",
}
],
)
resolution = server._resolve_runtime_with_fallback(
{"requested": "openai-codex"}
)
assert resolution.used_fallback is True
assert captured.get("explicit_api_key") == "env-resolved-key"
def test_auth_error_all_fallbacks_fail_raises(self, monkeypatch):
"""When all fallbacks also fail, re-raise the original AuthError."""
from hermes_cli.auth import AuthError

View file

@ -4507,14 +4507,17 @@ def _resolve_runtime_with_fallback(
if not fb_provider or not fb_model:
continue
try:
from hermes_cli.fallback_config import resolve_entry_api_key
fb_kwargs: dict = {
"requested": fb_provider,
"target_model": fb_model,
}
if entry.get("base_url"):
fb_kwargs["explicit_base_url"] = entry["base_url"]
if entry.get("api_key"):
fb_kwargs["explicit_api_key"] = entry["api_key"]
fb_api_key = resolve_entry_api_key(entry)
if fb_api_key:
fb_kwargs["explicit_api_key"] = fb_api_key
runtime = resolve_runtime_provider(**fb_kwargs)
import logging