Skip to content

Commit 6ddc48b

Browse files
wesleysimplicioteknium1
authored andcommitted
fix(fallback): resolve api_key_env in fallback chain entries (carve-out of #22665)
Fallback chain entries with 'api_key_env: ENV_VAR_NAME' weren't being resolved by either the init-time fallback path (line ~1660) or the runtime _try_activate_fallback path (line ~8045). Only literal 'api_key' was honored; the snake_case 'api_key_env' alias documented elsewhere in the config was silently dropped, so a 'provider: custom' fallback with base_url + api_key_env worked as primary but failed as fallback with 'no endpoint credentials found' / 401. Adds 'or fb.get("api_key_env")' to the existing 'key_env' lookup in both call sites, with empty-string-to-None coercion so unset env vars don't poison the resolver. Salvage of #22665's fallback portion. The original PR also bundled gateway-degrade-on-no-adapters changes (those land via the carve-out in #22853 which is the same code) and run_agent.py memory-nudge counter hydration (issue #22357 territory, not mentioned in the title). Drops both bundled pieces; keeps just the api_key_env fix. Closes #5392.
1 parent 246c676 commit 6ddc48b

2 files changed

Lines changed: 113 additions & 2 deletions

File tree

run_agent.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1665,10 +1665,15 @@ def __init__(
16651665
_fb_entries = [fallback_model]
16661666
_fb_resolved = False
16671667
for _fb in _fb_entries:
1668+
_fb_explicit_key = (_fb.get("api_key") or "").strip() or None
1669+
if not _fb_explicit_key:
1670+
_fb_key_env = (_fb.get("key_env") or _fb.get("api_key_env") or "").strip()
1671+
if _fb_key_env:
1672+
_fb_explicit_key = os.getenv(_fb_key_env, "").strip() or None
16681673
_fb_client, _fb_model = resolve_provider_client(
16691674
_fb["provider"], model=_fb["model"], raw_codex=True,
16701675
explicit_base_url=_fb.get("base_url"),
1671-
explicit_api_key=_fb.get("api_key"),
1676+
explicit_api_key=_fb_explicit_key,
16721677
)
16731678
if _fb_client is not None:
16741679
self.provider = _fb["provider"]
@@ -8116,7 +8121,9 @@ def _try_activate_fallback(self, reason: "FailoverReason | None" = None) -> bool
81168121
fb_base_url_hint = (fb.get("base_url") or "").strip() or None
81178122
fb_api_key_hint = (fb.get("api_key") or "").strip() or None
81188123
if not fb_api_key_hint:
8119-
fb_key_env = (fb.get("key_env") or "").strip()
8124+
# key_env and api_key_env are both documented aliases (see
8125+
# _normalize_custom_provider_entry in hermes_cli/config.py).
8126+
fb_key_env = (fb.get("key_env") or fb.get("api_key_env") or "").strip()
81208127
if fb_key_env:
81218128
fb_api_key_hint = os.getenv(fb_key_env, "").strip() or None
81228129
# For Ollama Cloud endpoints, pull OLLAMA_API_KEY from env

tests/run_agent/test_fallback_model.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,3 +405,107 @@ def test_provider_resolves(self, provider, env_var, base_url_fragment):
405405
assert agent.client is mock_client
406406
assert agent.model == "test-model"
407407
assert agent.provider == provider
408+
409+
410+
# =============================================================================
411+
# api_key_env / key_env resolution in fallback entries (#5392)
412+
# =============================================================================
413+
414+
class TestFallbackKeyEnvResolution:
415+
"""Verify that api_key_env and key_env are both resolved from the
416+
environment and forwarded to resolve_provider_client as explicit_api_key.
417+
418+
Before the fix, _try_activate_fallback only checked ``key_env`` and ignored
419+
the ``api_key_env`` alias documented in the custom_providers config schema.
420+
The init-time fallback path never resolved either field.
421+
"""
422+
423+
def test_api_key_env_resolved_at_runtime_fallback(self, monkeypatch):
424+
"""api_key_env in fallback entry must be read from env and passed
425+
as explicit_api_key to resolve_provider_client (#5392)."""
426+
monkeypatch.setenv("MY_GOOGLE_KEY", "google-secret-from-env")
427+
428+
agent = _make_agent(
429+
fallback_model={
430+
"provider": "custom",
431+
"model": "gemini-flash",
432+
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
433+
"api_key_env": "MY_GOOGLE_KEY",
434+
},
435+
)
436+
captured = {}
437+
438+
def _fake_resolve(provider, model=None, raw_codex=False,
439+
explicit_base_url=None, explicit_api_key=None, **kw):
440+
captured["explicit_api_key"] = explicit_api_key
441+
captured["explicit_base_url"] = explicit_base_url
442+
mock = MagicMock()
443+
mock.api_key = explicit_api_key or "no-key"
444+
mock.base_url = explicit_base_url or "https://example.com/v1"
445+
return mock, model
446+
447+
with patch("agent.auxiliary_client.resolve_provider_client", side_effect=_fake_resolve):
448+
result = agent._try_activate_fallback()
449+
450+
assert result is True
451+
assert captured["explicit_api_key"] == "google-secret-from-env", (
452+
"api_key_env value was not resolved and forwarded as explicit_api_key"
453+
)
454+
assert captured["explicit_base_url"] == "https://generativelanguage.googleapis.com/v1beta/openai"
455+
456+
def test_key_env_still_works_at_runtime_fallback(self, monkeypatch):
457+
"""key_env (canonical form) must still be resolved correctly."""
458+
monkeypatch.setenv("MY_PROVIDER_KEY", "secret-via-key-env")
459+
460+
agent = _make_agent(
461+
fallback_model={
462+
"provider": "custom",
463+
"model": "my-model",
464+
"base_url": "https://api.example.com/v1",
465+
"key_env": "MY_PROVIDER_KEY",
466+
},
467+
)
468+
captured = {}
469+
470+
def _fake_resolve(provider, model=None, raw_codex=False,
471+
explicit_base_url=None, explicit_api_key=None, **kw):
472+
captured["explicit_api_key"] = explicit_api_key
473+
mock = MagicMock()
474+
mock.api_key = explicit_api_key or "no-key"
475+
mock.base_url = explicit_base_url or "https://api.example.com/v1"
476+
return mock, model
477+
478+
with patch("agent.auxiliary_client.resolve_provider_client", side_effect=_fake_resolve):
479+
result = agent._try_activate_fallback()
480+
481+
assert result is True
482+
assert captured["explicit_api_key"] == "secret-via-key-env"
483+
484+
def test_api_key_env_unset_does_not_crash(self, monkeypatch):
485+
"""When api_key_env refers to an unset variable, explicit_api_key is None
486+
(not an empty string) so the provider can fall through to its default."""
487+
monkeypatch.delenv("ABSENT_KEY_VAR", raising=False)
488+
489+
agent = _make_agent(
490+
fallback_model={
491+
"provider": "openrouter",
492+
"model": "some/model",
493+
"api_key_env": "ABSENT_KEY_VAR",
494+
},
495+
)
496+
captured = {}
497+
498+
def _fake_resolve(provider, model=None, raw_codex=False,
499+
explicit_base_url=None, explicit_api_key=None, **kw):
500+
captured["explicit_api_key"] = explicit_api_key
501+
mock = MagicMock()
502+
mock.api_key = "fallback-default"
503+
mock.base_url = "https://openrouter.ai/api/v1"
504+
return mock, model
505+
506+
with patch("agent.auxiliary_client.resolve_provider_client", side_effect=_fake_resolve):
507+
agent._try_activate_fallback()
508+
509+
assert captured["explicit_api_key"] is None, (
510+
"Unset api_key_env should yield None, not empty string"
511+
)

0 commit comments

Comments
 (0)