{"repo":"NousResearch/hermes-agent","public_id":"57065","display_label":"Provider config and cache staleness fixes","summary":"These items address bugs where stale provider data persists in the model cache and the configured provider loses authority, including a silent fallback when an API key is absent and a CLI picker failing to show the current provider.","subsystem":"agent","counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":62596,"merged_at":"2026-07-11T12:43:08+00:00"},"resolution":{"state":"resolved","by":"pr-62596"},"freshness":{"state":"verified","last_verified_at":"2026-07-11T12:43:08+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":57065,"pr_number":62596,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-11T12:43:08+00:00"}],"members":[{"kind":"issue","number":57065,"title":"[Bug] provider_models_cache retains stale providers; silent fallback when API key missing","url":null,"state":"closed"},{"kind":"pr","number":57198,"title":"fix(cli): keep current provider visible in model pickers","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#62596","number":62596}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":62596,"title":"fix(model): keep configured provider authoritative","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/inventory.py","hermes_cli/runtime_provider.py","tests/hermes_cli/test_inventory.py","tests/hermes_cli/test_runtime_provider_resolution.py"],"fix_hunks":[{"file":"hermes_cli/inventory.py","hunks":["@@ -187,6 +187,14 @@ def build_models_payload(\n \n     if explicit_only:\n         rows = _filter_explicit_provider_rows(rows, ctx)\n+        # Desktop chat pickers request the explicit subset without the full\n+        # unconfigured provider universe. If the configured current provider\n+        # has lost its credential, list_authenticated_providers() omits it;\n+        # keep that one row visible so the UI can show the saved selection and\n+        # a re-auth affordance instead of appearing to jump to another provider.\n+        rows = list(rows) + _append_unconfigured_rows(\n+            rows, ctx, current_only=True\n+        )\n \n     # --- Deduplicate: remove models from aggregators that overlap with\n     # user-defined providers.  When a local proxy (e.g. litellm-proxy)","@@ -292,16 +300,62 @@ def _apply_capabilities(rows: list[dict]) -> None:\n # ─── Internal: row post-processing ──────────────────────────────────────\n \n \n-def _append_unconfigured_rows(rows: list[dict], ctx: ConfigContext) -> list[dict]:\n-    \"\"\"Build skeleton rows for canonical providers missing from ``rows``.\"\"\"\n+def _append_unconfigured_rows(\n+    rows: list[dict],\n+    ctx: ConfigContext,\n+    *,\n+    current_only: bool = False,\n+) -> list[dict]:\n+    \"\"\"Build fallback rows for canonical providers missing from ``rows``.\n+\n+    Most missing canonical providers become empty setup skeletons. The one\n+    exception is the *current* configured provider: if config.yaml still points\n+    at it but credentials are presently unavailable, keep a visible row carrying\n+    the saved model so GUI pickers don't silently snap to some other provider.\n+    \"\"\"\n+    from hermes_cli.auth import PROVIDER_REGISTRY\n     from hermes_cli.models import CANONICAL_PROVIDERS, _PROVIDER_LABELS\n \n     seen = {r[\"slug\"].lower() for r in rows}\n     cur = (ctx.current_provider or \"\").lower()\n+    cur_model = str(ctx.current_model or \"\").strip()\n     extras: list[dict] = []\n     for entry in CANONICAL_PROVIDERS:\n         if entry.slug.lower() in seen:\n             continue\n+        if current_only and entry.slug.lower() != cur:\n+            continue\n+        if entry.slug.lower() == cur:\n+            cfg = PROVIDER_REGISTRY.get(entry.slug)\n+            auth_type = cfg.auth_type if cfg else \"api_key\"\n+            key_env = (\n+                cfg.api_key_env_vars[0]\n+                if (cfg and cfg.api_key_env_vars)\n+                else \"\"\n+            )\n+            warning = (\n+                f\"Configured provider missing usable credentials; paste {key_env} to reactivate. \"\n+                \"Showing the saved model only.\"\n+                if auth_type == \"api_key\" and key_env\n+                else \"Configured provider is not authenticated; run `hermes model` to reactivate. \"\n+                \"Showing the saved model only.\"\n+            )\n+            extras.append(\n+                {\n+                    \"slug\": entry.slug,\n+                    \"name\": _PROVIDER_LABELS.get(entry.slug, entry.label),\n+                    \"is_current\": True,\n+                    \"is_user_defined\": False,\n+                    \"models\": [cur_model] if cur_model else [],\n+                    \"total_models\": 1 if cur_model else 0,\n+                    \"source\": \"configured-current\",\n+                    \"authenticated\": False,\n+                    \"auth_type\": auth_type,\n+                    \"key_env\": key_env,\n+                    \"warning\": warning,\n+                }\n+            )\n+            continue\n         extras.append(\n             {\n                 \"slug\": entry.slug,"]},{"file":"hermes_cli/runtime_provider.py","hunks":["@@ -1989,6 +1989,20 @@ def resolve_runtime_provider(\n     pconfig = PROVIDER_REGISTRY.get(provider)\n     if pconfig and pconfig.auth_type == \"api_key\":\n         creds = resolve_api_key_provider_credentials(provider)\n+        # An explicitly selected API-key provider is authoritative. Returning\n+        # a runtime with an empty key defers failure until the first request and\n+        # can make a later fallback look like a silent provider switch. Fail at\n+        # resolution so callers surface the missing credential (or consult only\n+        # an explicitly configured fallback chain). LM Studio's no-auth path\n+        # supplies a non-empty placeholder in the credential resolver above.\n+        if not has_usable_secret(creds.get(\"api_key\")):\n+            env_names = \", \".join(pconfig.api_key_env_vars)\n+            hint = f\" Set {env_names}.\" if env_names else \"\"\n+            raise AuthError(\n+                f\"No usable credentials found for provider '{provider}'.{hint}\",\n+                provider=provider,\n+                code=\"missing_api_key\",\n+            )\n         # Honour model.base_url from config.yaml when the configured provider\n         # matches this provider — mirrors the Anthropic path above.  Without\n         # this, users who set model.base_url to e.g. api.minimaxi.com/anthropic"]},{"file":"tests/hermes_cli/test_inventory.py","hunks":["@@ -417,6 +417,55 @@ def test_explicit_only_filters_ambient_credentials_but_keeps_current_and_custom_\n     ]\n \n \n+def test_explicit_only_keeps_unauthenticated_current_provider_visible():\n+    \"\"\"Desktop's configured-only picker must retain its saved provider row.\"\"\"\n+    ctx = _empty_ctx(provider=\"deepseek\", model=\"deepseek-v4-pro\")\n+    with _list_auth_returning([]):\n+        payload = build_models_payload(\n+            ctx,\n+            explicit_only=True,\n+            picker_hints=True,\n+        )\n+\n+    assert [row[\"slug\"] for row in payload[\"providers\"]] == [\"deepseek\"]\n+    row = payload[\"providers\"][0]\n+    assert row[\"source\"] == \"configured-current\"\n+    assert row[\"authenticated\"] is False\n+    assert row[\"models\"] == [\"deepseek-v4-pro\"]\n+    assert \"DEEPSEEK_API_KEY\" in row[\"warning\"]\n+def test_include_unconfigured_keeps_current_provider_visible_without_credentials():\n+    \"\"\"If the saved provider is currently unauthenticated, keep a visible row\n+    with the saved model so GUI pickers don't silently jump to another\n+    authenticated provider.\"\"\"\n+    ctx = _empty_ctx(provider=\"deepseek\", model=\"deepseek-v4-pro\")\n+    with _list_auth_returning([]):\n+        payload = build_models_payload(\n+            ctx, include_unconfigured=True, picker_hints=True,\n+        )\n+\n+    deepseek = next(r for r in payload[\"providers\"] if r[\"slug\"] == \"deepseek\")\n+    assert deepseek[\"source\"] == \"configured-current\"\n+    assert deepseek[\"is_current\"] is True\n+    assert deepseek[\"authenticated\"] is False\n+    assert deepseek[\"models\"] == [\"deepseek-v4-pro\"]\n+    assert deepseek[\"total_models\"] == 1\n+    assert deepseek[\"auth_type\"] == \"api_key\"\n+    assert \"DEEPSEEK_API_KEY\" in deepseek[\"warning\"]\n+    assert \"saved model only\" in deepseek[\"warning\"]\n+\n+\n+def test_include_unconfigured_does_not_duplicate_configured_current_row():\n+    ctx = _empty_ctx(provider=\"deepseek\", model=\"deepseek-v4-pro\")\n+    with _list_auth_returning([]):\n+        payload = build_models_payload(\n+            ctx,\n+            explicit_only=True,\n+            include_unconfigured=True,\n+            picker_hints=True,\n+        )\n+\n+    assert sum(row[\"slug\"] == \"deepseek\" for row in payload[\"providers\"]) == 1\n+\n def test_explicit_only_keeps_moa_when_raw_config_has_enabled_preset():\n     rows = [\n         {\"slug\": \"moa\", \"name\": \"MoA\", \"models\": [\"review\"],","@@ -450,10 +499,10 @@ def test_explicit_only_keeps_moa_when_raw_config_has_enabled_preset():\n     ):\n         payload = build_models_payload(ctx, explicit_only=True)\n \n-    assert [row[\"slug\"] for row in payload[\"providers\"]] == [\"moa\"]\n+    assert [row[\"slug\"] for row in payload[\"providers\"]] == [\"moa\", \"openrouter\"]\n     assert payload[\"providers\"][0][\"models\"] == [\"review\"]\n-\n-\n+    assert payload[\"providers\"][1][\"source\"] == \"configured-current\"\n+    assert payload[\"providers\"][1][\"authenticated\"] is False\n # ─── picker_hints ──────────────────────────────────────────────────────\n \n "]},{"file":"tests/hermes_cli/test_runtime_provider_resolution.py","hunks":["@@ -8,6 +8,47 @@\n from hermes_cli import runtime_provider as rp\n \n \n+def test_configured_api_key_provider_without_key_fails_closed(monkeypatch):\n+    \"\"\"A saved provider must not resolve as another authenticated provider.\"\"\"\n+    monkeypatch.setattr(\n+        rp,\n+        \"_get_model_config\",\n+        lambda: {\"provider\": \"deepseek\", \"default\": \"deepseek-v4-pro\"},\n+    )\n+    monkeypatch.setattr(rp, \"load_pool\", lambda _provider: SimpleNamespace(has_credentials=[redacted:secret-assignment] False))\n+    monkeypatch.setattr(\n+        \"hermes_cli.auth.resolve_api_key_provider_credentials\",\n+        lambda _provider: {\n+            \"provider\": \"deepseek\",\n+            \"api_key\": \"\",\n+            \"base_url\": \"https://api.deepseek.com/v1\",\n+            \"source\": \"default\",\n+        },\n+    )\n+\n+    with pytest.raises(rp.AuthError, match=\"No usable credentials.*deepseek\"):\n+        rp.resolve_runtime_provider()\n+\n+\n+def test_noauth_lmstudio_still_resolves(monkeypatch):\n+    \"\"\"The fail-closed key guard preserves LM Studio's no-auth contract.\"\"\"\n+    monkeypatch.setattr(rp, \"load_pool\", lambda _provider: SimpleNamespace(has_credentials=[redacted:secret-assignment] False))\n+    monkeypatch.setattr(\n+        \"hermes_cli.auth.resolve_api_key_provider_credentials\",\n+        lambda _provider: {\n+            \"provider\": \"lmstudio\",\n+            \"api_key\": \"[redacted:secret-assignment]\",\n+            \"base_url\": \"http://localhost:1234/v1\",\n+            \"source\": \"default\",\n+        },\n+    )\n+\n+    resolved = rp.resolve_runtime_provider(requested=\"lmstudio\")\n+\n+    assert resolved[\"provider\"] == \"lmstudio\"\n+    assert resolved[\"api_key\"]\n+\n+\n def _fake_invoke_jwt(ttl_seconds=3600):\n     header = base64.urlsafe_b64encode(b'{\"alg\":\"none\",\"typ\":\"JWT\"}').decode().rstrip(\"=\")\n     payload = base64.urlsafe_b64encode(\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}