Skip to content

Commit 998e353

Browse files
VrtxOmegateknium1
authored andcommitted
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).
1 parent c3b2af9 commit 998e353

7 files changed

Lines changed: 118 additions & 13 deletions

File tree

‎cron/scheduler.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3083,14 +3083,17 @@ def run_job(
30833083
if not fb_provider or not fb_model:
30843084
continue
30853085
try:
3086+
from hermes_cli.fallback_config import resolve_entry_api_key
3087+
30863088
fb_kwargs = {
30873089
"requested": fb_provider,
30883090
"target_model": fb_model,
30893091
}
30903092
if entry.get("base_url"):
30913093
fb_kwargs["explicit_base_url"] = entry["base_url"]
3092-
if entry.get("api_key"):
3093-
fb_kwargs["explicit_api_key"] = entry["api_key"]
3094+
fb_api_key = resolve_entry_api_key(entry)
3095+
if fb_api_key:
3096+
fb_kwargs["explicit_api_key"] = fb_api_key
30943097
runtime = resolve_runtime_provider(**fb_kwargs)
30953098
model = fb_model
30963099
logger.info(

‎gateway/run.py‎

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2038,17 +2038,12 @@ def _try_resolve_fallback_provider() -> dict | None:
20382038
return None
20392039
for entry in fb_list:
20402040
try:
2041-
explicit_api_key = entry.get("api_key")
2042-
if not explicit_api_key:
2043-
key_env = str(
2044-
entry.get("key_env") or entry.get("api_key_env") or ""
2045-
).strip()
2046-
if key_env:
2047-
explicit_api_key = os.getenv(key_env, "").strip() or None
2041+
from hermes_cli.fallback_config import resolve_entry_api_key
2042+
20482043
runtime = resolve_runtime_provider(
20492044
requested=entry.get("provider"),
20502045
explicit_base_url=entry.get("base_url"),
2051-
explicit_api_key=explicit_api_key,
2046+
explicit_api_key=resolve_entry_api_key(entry),
20522047
)
20532048
# Log the literal `provider` key from config, not the resolved
20542049
# runtime category — an Ollama fallback resolves through the

‎hermes_cli/cli_agent_setup_mixin.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,15 @@ def _ensure_runtime_credentials(self) -> bool:
5757
if not _fb_provider or not _fb_model:
5858
continue
5959
try:
60-
runtime = resolve_runtime_provider(requested=_fb_provider)
60+
from hermes_cli.fallback_config import resolve_entry_api_key
61+
62+
_fb_kwargs = {"requested": _fb_provider}
63+
if _fb.get("base_url"):
64+
_fb_kwargs["explicit_base_url"] = _fb["base_url"]
65+
_fb_api_key = resolve_entry_api_key(_fb)
66+
if _fb_api_key:
67+
_fb_kwargs["explicit_api_key"] = _fb_api_key
68+
runtime = resolve_runtime_provider(**_fb_kwargs)
6169
logger.warning(
6270
"Primary provider auth failed (%s). Falling through to fallback: %s/%s",
6371
_primary_exc, _fb_provider, _fb_model,

‎hermes_cli/fallback_config.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import os
56
from typing import Any
67

78

@@ -11,6 +12,25 @@ def _normalized_base_url(value: Any) -> str:
1112
return value.strip().rstrip("/")
1213

1314

15+
def resolve_entry_api_key(entry: dict[str, Any] | None) -> str | None:
16+
"""API key for one fallback entry: inline ``api_key``, else ``key_env``.
17+
18+
Mirrors the custom-provider convention (``key_env`` names the env var
19+
holding the key; ``api_key_env`` accepted as an alias). Returns None when
20+
neither yields a non-empty value, letting ``resolve_runtime_provider``
21+
fall through to the provider's standard credential resolution.
22+
"""
23+
if not isinstance(entry, dict):
24+
return None
25+
inline = str(entry.get("api_key") or "").strip()
26+
if inline:
27+
return inline
28+
key_env = str(entry.get("key_env") or entry.get("api_key_env") or "").strip()
29+
if key_env:
30+
return os.getenv(key_env, "").strip() or None
31+
return None
32+
33+
1434
def _iter_fallback_entries(raw: Any) -> list[dict[str, Any]]:
1535
if isinstance(raw, dict):
1636
candidates = [raw]
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Tests for hermes_cli/fallback_config.py — fallback entry API-key resolution."""
2+
3+
from hermes_cli.fallback_config import resolve_entry_api_key
4+
5+
6+
class TestResolveEntryApiKey:
7+
def test_inline_api_key_wins(self, monkeypatch):
8+
monkeypatch.setenv("FB_KEY", "env-key")
9+
entry = {"provider": "custom", "api_key": "inline-key", "key_env": "FB_KEY"}
10+
assert resolve_entry_api_key(entry) == "inline-key"
11+
12+
def test_key_env_resolves_from_environment(self, monkeypatch):
13+
monkeypatch.setenv("FB_KEY", "env-key")
14+
assert resolve_entry_api_key({"key_env": "FB_KEY"}) == "env-key"
15+
16+
def test_api_key_env_alias(self, monkeypatch):
17+
monkeypatch.setenv("FB_ALIAS_KEY", "alias-key")
18+
assert resolve_entry_api_key({"api_key_env": "FB_ALIAS_KEY"}) == "alias-key"
19+
20+
def test_unset_env_var_returns_none(self, monkeypatch):
21+
monkeypatch.delenv("FB_MISSING", raising=False)
22+
# None (not "") lets resolve_runtime_provider fall through to the
23+
# provider's standard credential resolution.
24+
assert resolve_entry_api_key({"key_env": "FB_MISSING"}) is None
25+
26+
def test_empty_env_var_returns_none(self, monkeypatch):
27+
monkeypatch.setenv("FB_EMPTY", " ")
28+
assert resolve_entry_api_key({"key_env": "FB_EMPTY"}) is None
29+
30+
def test_no_key_fields_returns_none(self):
31+
assert resolve_entry_api_key({"provider": "openrouter", "model": "glm"}) is None
32+
33+
def test_non_dict_returns_none(self):
34+
assert resolve_entry_api_key(None) is None
35+
assert resolve_entry_api_key("nope") is None # type: ignore[arg-type]
36+
37+
def test_whitespace_inline_key_falls_through_to_env(self, monkeypatch):
38+
monkeypatch.setenv("FB_KEY", "env-key")
39+
entry = {"api_key": " ", "key_env": "FB_KEY"}
40+
assert resolve_entry_api_key(entry) == "env-key"

‎tests/test_tui_gateway_server.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9597,6 +9597,42 @@ def fake_resolve(**kwargs):
95979597
assert resolution.selected_model == "z-ai/glm-5.2"
95989598
assert resolution.used_fallback is True
95999599

9600+
def test_fallback_entry_key_env_resolves_api_key(self, monkeypatch):
9601+
"""A fallback entry naming its key via key_env passes the resolved
9602+
env value as explicit_api_key (#43861, @VrtxOmega)."""
9603+
from hermes_cli.auth import AuthError
9604+
9605+
monkeypatch.setenv("FB_TEST_KEY", "env-resolved-key")
9606+
captured = {}
9607+
fallback_runtime = {"provider": "openrouter", "api_key": "x"}
9608+
9609+
def fake_resolve(**kwargs):
9610+
if kwargs.get("requested") == "openai-codex":
9611+
raise AuthError("No Codex credentials stored")
9612+
captured.update(kwargs)
9613+
return fallback_runtime
9614+
9615+
monkeypatch.setattr(
9616+
"hermes_cli.runtime_provider.resolve_runtime_provider",
9617+
fake_resolve,
9618+
)
9619+
monkeypatch.setattr(
9620+
server,
9621+
"_load_fallback_model",
9622+
lambda: [
9623+
{
9624+
"provider": "openrouter",
9625+
"model": "z-ai/glm-5.2",
9626+
"key_env": "FB_TEST_KEY",
9627+
}
9628+
],
9629+
)
9630+
resolution = server._resolve_runtime_with_fallback(
9631+
{"requested": "openai-codex"}
9632+
)
9633+
assert resolution.used_fallback is True
9634+
assert captured.get("explicit_api_key") == "env-resolved-key"
9635+
96009636
def test_auth_error_all_fallbacks_fail_raises(self, monkeypatch):
96019637
"""When all fallbacks also fail, re-raise the original AuthError."""
96029638
from hermes_cli.auth import AuthError

‎tui_gateway/server.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4507,14 +4507,17 @@ def _resolve_runtime_with_fallback(
45074507
if not fb_provider or not fb_model:
45084508
continue
45094509
try:
4510+
from hermes_cli.fallback_config import resolve_entry_api_key
4511+
45104512
fb_kwargs: dict = {
45114513
"requested": fb_provider,
45124514
"target_model": fb_model,
45134515
}
45144516
if entry.get("base_url"):
45154517
fb_kwargs["explicit_base_url"] = entry["base_url"]
4516-
if entry.get("api_key"):
4517-
fb_kwargs["explicit_api_key"] = entry["api_key"]
4518+
fb_api_key = resolve_entry_api_key(entry)
4519+
if fb_api_key:
4520+
fb_kwargs["explicit_api_key"] = fb_api_key
45184521
runtime = resolve_runtime_provider(**fb_kwargs)
45194522
import logging
45204523

0 commit comments

Comments
 (0)