From 8041be795419ee00890bbc6efd075c1459684acd Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 11 Jul 2026 04:11:03 -0700 Subject: [PATCH] fix(model): keep configured provider authoritative --- hermes_cli/inventory.py | 17 +++++++- hermes_cli/runtime_provider.py | 14 +++++++ tests/hermes_cli/test_inventory.py | 35 +++++++++++++++- .../test_runtime_provider_resolution.py | 41 +++++++++++++++++++ 4 files changed, 105 insertions(+), 2 deletions(-) diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index 486fa8c5ef9..cd90d2a9a9e 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -187,6 +187,14 @@ def build_models_payload( if explicit_only: rows = _filter_explicit_provider_rows(rows, ctx) + # Desktop chat pickers request the explicit subset without the full + # unconfigured provider universe. If the configured current provider + # has lost its credential, list_authenticated_providers() omits it; + # keep that one row visible so the UI can show the saved selection and + # a re-auth affordance instead of appearing to jump to another provider. + rows = list(rows) + _append_unconfigured_rows( + rows, ctx, current_only=True + ) # --- Deduplicate: remove models from aggregators that overlap with # user-defined providers. When a local proxy (e.g. litellm-proxy) @@ -292,7 +300,12 @@ def _apply_capabilities(rows: list[dict]) -> None: # ─── Internal: row post-processing ────────────────────────────────────── -def _append_unconfigured_rows(rows: list[dict], ctx: ConfigContext) -> list[dict]: +def _append_unconfigured_rows( + rows: list[dict], + ctx: ConfigContext, + *, + current_only: bool = False, +) -> list[dict]: """Build fallback rows for canonical providers missing from ``rows``. Most missing canonical providers become empty setup skeletons. The one @@ -310,6 +323,8 @@ def _append_unconfigured_rows(rows: list[dict], ctx: ConfigContext) -> list[dict for entry in CANONICAL_PROVIDERS: if entry.slug.lower() in seen: continue + if current_only and entry.slug.lower() != cur: + continue if entry.slug.lower() == cur: cfg = PROVIDER_REGISTRY.get(entry.slug) auth_type = cfg.auth_type if cfg else "api_key" diff --git a/hermes_cli/runtime_provider.py b/hermes_cli/runtime_provider.py index ddc7ccecd50..dd03707873a 100644 --- a/hermes_cli/runtime_provider.py +++ b/hermes_cli/runtime_provider.py @@ -1989,6 +1989,20 @@ def resolve_runtime_provider( pconfig = PROVIDER_REGISTRY.get(provider) if pconfig and pconfig.auth_type == "api_key": creds = resolve_api_key_provider_credentials(provider) + # An explicitly selected API-key provider is authoritative. Returning + # a runtime with an empty key defers failure until the first request and + # can make a later fallback look like a silent provider switch. Fail at + # resolution so callers surface the missing credential (or consult only + # an explicitly configured fallback chain). LM Studio's no-auth path + # supplies a non-empty placeholder in the credential resolver above. + if not has_usable_secret(creds.get("api_key")): + env_names = ", ".join(pconfig.api_key_env_vars) + hint = f" Set {env_names}." if env_names else "" + raise AuthError( + f"No usable credentials found for provider '{provider}'.{hint}", + provider=provider, + code="missing_api_key", + ) # Honour model.base_url from config.yaml when the configured provider # matches this provider — mirrors the Anthropic path above. Without # this, users who set model.base_url to e.g. api.minimaxi.com/anthropic diff --git a/tests/hermes_cli/test_inventory.py b/tests/hermes_cli/test_inventory.py index 674a8d59070..fd9f25fdefb 100644 --- a/tests/hermes_cli/test_inventory.py +++ b/tests/hermes_cli/test_inventory.py @@ -415,6 +415,24 @@ def test_explicit_only_filters_ambient_credentials_but_keeps_current_and_custom_ "gemini", "custom:lab", ] + + +def test_explicit_only_keeps_unauthenticated_current_provider_visible(): + """Desktop's configured-only picker must retain its saved provider row.""" + ctx = _empty_ctx(provider="deepseek", model="deepseek-v4-pro") + with _list_auth_returning([]): + payload = build_models_payload( + ctx, + explicit_only=True, + picker_hints=True, + ) + + assert [row["slug"] for row in payload["providers"]] == ["deepseek"] + row = payload["providers"][0] + assert row["source"] == "configured-current" + assert row["authenticated"] is False + assert row["models"] == ["deepseek-v4-pro"] + assert "DEEPSEEK_API_KEY" in row["warning"] def test_include_unconfigured_keeps_current_provider_visible_without_credentials(): """If the saved provider is currently unauthenticated, keep a visible row with the saved model so GUI pickers don't silently jump to another @@ -435,6 +453,19 @@ def test_include_unconfigured_keeps_current_provider_visible_without_credentials assert "DEEPSEEK_API_KEY" in deepseek["warning"] assert "saved model only" in deepseek["warning"] + +def test_include_unconfigured_does_not_duplicate_configured_current_row(): + ctx = _empty_ctx(provider="deepseek", model="deepseek-v4-pro") + with _list_auth_returning([]): + payload = build_models_payload( + ctx, + explicit_only=True, + include_unconfigured=True, + picker_hints=True, + ) + + assert sum(row["slug"] == "deepseek" for row in payload["providers"]) == 1 + def test_explicit_only_keeps_moa_when_raw_config_has_enabled_preset(): rows = [ {"slug": "moa", "name": "MoA", "models": ["review"], @@ -468,8 +499,10 @@ def test_explicit_only_keeps_moa_when_raw_config_has_enabled_preset(): ): payload = build_models_payload(ctx, explicit_only=True) - assert [row["slug"] for row in payload["providers"]] == ["moa"] + assert [row["slug"] for row in payload["providers"]] == ["moa", "openrouter"] assert payload["providers"][0]["models"] == ["review"] + assert payload["providers"][1]["source"] == "configured-current" + assert payload["providers"][1]["authenticated"] is False # ─── picker_hints ────────────────────────────────────────────────────── diff --git a/tests/hermes_cli/test_runtime_provider_resolution.py b/tests/hermes_cli/test_runtime_provider_resolution.py index df47efcf839..198fd0488bd 100644 --- a/tests/hermes_cli/test_runtime_provider_resolution.py +++ b/tests/hermes_cli/test_runtime_provider_resolution.py @@ -8,6 +8,47 @@ import pytest from hermes_cli import runtime_provider as rp +def test_configured_api_key_provider_without_key_fails_closed(monkeypatch): + """A saved provider must not resolve as another authenticated provider.""" + monkeypatch.setattr( + rp, + "_get_model_config", + lambda: {"provider": "deepseek", "default": "deepseek-v4-pro"}, + ) + monkeypatch.setattr(rp, "load_pool", lambda _provider: SimpleNamespace(has_credentials=lambda: False)) + monkeypatch.setattr( + "hermes_cli.auth.resolve_api_key_provider_credentials", + lambda _provider: { + "provider": "deepseek", + "api_key": "", + "base_url": "https://api.deepseek.com/v1", + "source": "default", + }, + ) + + with pytest.raises(rp.AuthError, match="No usable credentials.*deepseek"): + rp.resolve_runtime_provider() + + +def test_noauth_lmstudio_still_resolves(monkeypatch): + """The fail-closed key guard preserves LM Studio's no-auth contract.""" + monkeypatch.setattr(rp, "load_pool", lambda _provider: SimpleNamespace(has_credentials=lambda: False)) + monkeypatch.setattr( + "hermes_cli.auth.resolve_api_key_provider_credentials", + lambda _provider: { + "provider": "lmstudio", + "api_key": "lmstudio-noauth", + "base_url": "http://localhost:1234/v1", + "source": "default", + }, + ) + + resolved = rp.resolve_runtime_provider(requested="lmstudio") + + assert resolved["provider"] == "lmstudio" + assert resolved["api_key"] + + def _fake_invoke_jwt(ttl_seconds=3600): header = base64.urlsafe_b64encode(b'{"alg":"none","typ":"JWT"}').decode().rstrip("=") payload = base64.urlsafe_b64encode(