From fa11b11cf5649dc7f014081c82e144b60fb14f91 Mon Sep 17 00:00:00 2001 From: Telos Date: Wed, 22 Apr 2026 21:25:23 -0700 Subject: [PATCH] fix: propagate key_env from custom_providers into ProviderDef resolve_custom_provider() previously returned api_key_env_vars=() for every custom provider entry, silently dropping the configured key_env field. This caused 401 errors for any custom provider that required an API key via environment variable (e.g. Xiaomi MiMo Token Plan, self-hosted OpenAI-compatible servers). The key_env field is already documented in _VALID_CUSTOM_PROVIDER_FIELDS and normalized by normalize_custom_provider_entry(), so this was just an oversight in the ProviderDef construction. Also adds a regression test that verifies key_env is properly propagated into the resolved ProviderDef. --- hermes_cli/providers.py | 15 ++++-- .../test_model_switch_custom_providers.py | 50 +++++++++++++++++++ 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index e8ab185ab50..0c2a4518315 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -639,7 +639,7 @@ def resolve_custom_provider( # from a prior model-switch bug), fall back to the first custom # provider entry so existing configs self-heal. (GH #17478) bare_custom_fallback = requested == "custom" - first_valid = None + first_valid: Optional[Tuple[str, str, Tuple[str, ...]]] = None for entry in custom_providers: if not isinstance(entry, dict): @@ -655,9 +655,14 @@ def resolve_custom_provider( if not display_name or not api_url: continue + key_env = (entry.get("key_env") or "").strip() + env_vars: List[str] = [] + if key_env: + env_vars.append(key_env) + # Stash the first valid entry for bare-"custom" fallback if first_valid is None: - first_valid = (display_name, api_url) + first_valid = (display_name, api_url, tuple(env_vars)) slug = custom_provider_slug(display_name) if requested not in {display_name.lower(), slug}: @@ -667,7 +672,7 @@ def resolve_custom_provider( id=slug, name=display_name, transport="openai_chat", - api_key_env_vars=(), + api_key_env_vars=tuple(env_vars), base_url=api_url, is_aggregator=False, auth_type="api_key", @@ -676,13 +681,13 @@ def resolve_custom_provider( # Self-heal: bare "custom" matched nothing — return first valid entry if bare_custom_fallback and first_valid: - dname, aurl = first_valid + dname, aurl, denv = first_valid slug = custom_provider_slug(dname) return ProviderDef( id=slug, name=dname, transport="openai_chat", - api_key_env_vars=(), + api_key_env_vars=denv, base_url=aurl, is_aggregator=False, auth_type="api_key", diff --git a/tests/hermes_cli/test_model_switch_custom_providers.py b/tests/hermes_cli/test_model_switch_custom_providers.py index 2456af11db9..11a7613abfe 100644 --- a/tests/hermes_cli/test_model_switch_custom_providers.py +++ b/tests/hermes_cli/test_model_switch_custom_providers.py @@ -337,6 +337,7 @@ def test_list_dedupes_dict_model_matching_singular_default(monkeypatch): + # ───────────────────────────────────────────────────────────────────────────── # #9210: group custom_providers by (base_url, api_key) in /model picker # ───────────────────────────────────────────────────────────────────────────── @@ -791,3 +792,52 @@ def test_custom_providers_discover_models_false_string_is_normalised(monkeypatch assert gateway_prov is not None assert calls == [], "string 'false' must disable live discovery" assert gateway_prov["models"] == ["only-model"] + + +def test_resolve_custom_provider_passes_key_env(): + """resolve_custom_provider should propagate key_env into api_key_env_vars. + + Regression: previously api_key_env_vars was always (), silently dropping + the configured env var and causing 401s on every request. + """ + from hermes_cli.providers import resolve_custom_provider + + resolved = resolve_custom_provider( + "custom:token-plan", + custom_providers=[ + { + "name": "token-plan", + "base_url": "https://token-plan-sgp.xiaomimimo.com/v1", + "key_env": "XIAOMI_MIMO_API_KEY", + "model": "mimo-v2-pro", + } + ], + ) + + assert resolved is not None + assert resolved.api_key_env_vars == ("XIAOMI_MIMO_API_KEY",) + assert resolved.base_url == "https://token-plan-sgp.xiaomimimo.com/v1" + + +def test_resolve_custom_provider_bare_custom_self_heal_passes_key_env(): + """The bare-'custom' self-heal path must also propagate key_env. + + A corrupt stored provider of the bare string 'custom' falls back to the + first valid entry; that fallback previously hardcoded api_key_env_vars=(), + dropping the env var just like the named-match path did. + """ + from hermes_cli.providers import resolve_custom_provider + + resolved = resolve_custom_provider( + "custom", + custom_providers=[ + { + "name": "token-plan", + "base_url": "https://token-plan-sgp.xiaomimimo.com/v1", + "key_env": "XIAOMI_MIMO_API_KEY", + } + ], + ) + + assert resolved is not None + assert resolved.api_key_env_vars == ("XIAOMI_MIMO_API_KEY",)