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).fix/verification-admin-route-recovery
parent
c3b2af95e3
commit
998e35313a
|
|
@ -3083,14 +3083,17 @@ def run_job(
|
||||||
if not fb_provider or not fb_model:
|
if not fb_provider or not fb_model:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
|
from hermes_cli.fallback_config import resolve_entry_api_key
|
||||||
|
|
||||||
fb_kwargs = {
|
fb_kwargs = {
|
||||||
"requested": fb_provider,
|
"requested": fb_provider,
|
||||||
"target_model": fb_model,
|
"target_model": fb_model,
|
||||||
}
|
}
|
||||||
if entry.get("base_url"):
|
if entry.get("base_url"):
|
||||||
fb_kwargs["explicit_base_url"] = entry["base_url"]
|
fb_kwargs["explicit_base_url"] = entry["base_url"]
|
||||||
if entry.get("api_key"):
|
fb_api_key = resolve_entry_api_key(entry)
|
||||||
fb_kwargs["explicit_api_key"] = entry["api_key"]
|
if fb_api_key:
|
||||||
|
fb_kwargs["explicit_api_key"] = fb_api_key
|
||||||
runtime = resolve_runtime_provider(**fb_kwargs)
|
runtime = resolve_runtime_provider(**fb_kwargs)
|
||||||
model = fb_model
|
model = fb_model
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|
|
||||||
|
|
@ -2038,17 +2038,12 @@ def _try_resolve_fallback_provider() -> dict | None:
|
||||||
return None
|
return None
|
||||||
for entry in fb_list:
|
for entry in fb_list:
|
||||||
try:
|
try:
|
||||||
explicit_api_key = entry.get("api_key")
|
from hermes_cli.fallback_config import resolve_entry_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
|
|
||||||
runtime = resolve_runtime_provider(
|
runtime = resolve_runtime_provider(
|
||||||
requested=entry.get("provider"),
|
requested=entry.get("provider"),
|
||||||
explicit_base_url=entry.get("base_url"),
|
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
|
# Log the literal `provider` key from config, not the resolved
|
||||||
# runtime category — an Ollama fallback resolves through the
|
# runtime category — an Ollama fallback resolves through the
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,15 @@ class CLIAgentSetupMixin:
|
||||||
if not _fb_provider or not _fb_model:
|
if not _fb_provider or not _fb_model:
|
||||||
continue
|
continue
|
||||||
try:
|
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(
|
logger.warning(
|
||||||
"Primary provider auth failed (%s). Falling through to fallback: %s/%s",
|
"Primary provider auth failed (%s). Falling through to fallback: %s/%s",
|
||||||
_primary_exc, _fb_provider, _fb_model,
|
_primary_exc, _fb_provider, _fb_model,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -11,6 +12,25 @@ def _normalized_base_url(value: Any) -> str:
|
||||||
return value.strip().rstrip("/")
|
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]]:
|
def _iter_fallback_entries(raw: Any) -> list[dict[str, Any]]:
|
||||||
if isinstance(raw, dict):
|
if isinstance(raw, dict):
|
||||||
candidates = [raw]
|
candidates = [raw]
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -9597,6 +9597,42 @@ class TestResolveRuntimeWithFallback:
|
||||||
assert resolution.selected_model == "z-ai/glm-5.2"
|
assert resolution.selected_model == "z-ai/glm-5.2"
|
||||||
assert resolution.used_fallback is True
|
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):
|
def test_auth_error_all_fallbacks_fail_raises(self, monkeypatch):
|
||||||
"""When all fallbacks also fail, re-raise the original AuthError."""
|
"""When all fallbacks also fail, re-raise the original AuthError."""
|
||||||
from hermes_cli.auth import AuthError
|
from hermes_cli.auth import AuthError
|
||||||
|
|
|
||||||
|
|
@ -4507,14 +4507,17 @@ def _resolve_runtime_with_fallback(
|
||||||
if not fb_provider or not fb_model:
|
if not fb_provider or not fb_model:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
|
from hermes_cli.fallback_config import resolve_entry_api_key
|
||||||
|
|
||||||
fb_kwargs: dict = {
|
fb_kwargs: dict = {
|
||||||
"requested": fb_provider,
|
"requested": fb_provider,
|
||||||
"target_model": fb_model,
|
"target_model": fb_model,
|
||||||
}
|
}
|
||||||
if entry.get("base_url"):
|
if entry.get("base_url"):
|
||||||
fb_kwargs["explicit_base_url"] = entry["base_url"]
|
fb_kwargs["explicit_base_url"] = entry["base_url"]
|
||||||
if entry.get("api_key"):
|
fb_api_key = resolve_entry_api_key(entry)
|
||||||
fb_kwargs["explicit_api_key"] = entry["api_key"]
|
if fb_api_key:
|
||||||
|
fb_kwargs["explicit_api_key"] = fb_api_key
|
||||||
runtime = resolve_runtime_provider(**fb_kwargs)
|
runtime = resolve_runtime_provider(**fb_kwargs)
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue