# NousResearch/hermes-agent · Provider config and cache staleness fixes

## Open now

- Nothing open on GitHub.

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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #62596. Verify it addresses your case.
- **Recommended:** read PR #62596 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-62596`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #57065: \[Bug\] provider_models_cache retains stale providers; silent fallback when API key missing

## Prior & competing PRs

- **#62596: fix(model): keep configured provider authoritative**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `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:
  - `hermes_cli/inventory.py`

    ```diff
    @@ -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)
    ```

    ```diff
    @@ -292,16 +300,62 @@ def _apply_capabilities(rows: list[dict]) -> None:
     # ─── Internal: row post-processing ──────────────────────────────────────
     
     
    -def _append_unconfigured_rows(rows: list[dict], ctx: ConfigContext) -> list[dict]:
    -    """Build skeleton rows for canonical providers missing from ``rows``."""
    +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
    +    exception is the *current* configured provider: if config.yaml still points
    +    at it but credentials are presently unavailable, keep a visible row carrying
    +    the saved model so GUI pickers don't silently snap to some other provider.
    +    """
    +    from hermes_cli.auth import PROVIDER_REGISTRY
         from hermes_cli.models import CANONICAL_PROVIDERS, _PROVIDER_LABELS
     
         seen = {r["slug"].lower() for r in rows}
         cur = (ctx.current_provider or "").lower()
    +    cur_model = str(ctx.current_model or "").strip()
         extras: 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"
    +            key_env = (
    +                cfg.api_key_env_vars[0]
    +                if (cfg and cfg.api_key_env_vars)
    +                else ""
    +            )
    +            warning = (
    +                f"Configured provider missing usable credentials; paste {key_env} to reactivate. "
    +                "Showing the saved model only."
    +                if auth_type == "api_key" and key_env
    +                else "Configured provider is not authenticated; run `hermes model` to reactivate. "
    +                "Showing the saved model only."
    +            )
    +            extras.append(
    +                {
    +                    "slug": entry.slug,
    +                    "name": _PROVIDER_LABELS.get(entry.slug, entry.label),
    +                    "is_current": True,
    +                    "is_user_defined": False,
    +                    "models": [cur_model] if cur_model else [],
    +                    "total_models": 1 if cur_model else 0,
    +                    "source": "configured-current",
    +                    "authenticated": False,
    +                    "auth_type": auth_type,
    +                    "key_env": key_env,
    +                    "warning": warning,
    +                }
    +            )
    +            continue
             extras.append(
                 {
                     "slug": entry.slug,
    ```
  - `hermes_cli/runtime_provider.py`

    ```diff
    @@ -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
    ```
  - `tests/hermes_cli/test_inventory.py`

    ```diff
    @@ -417,6 +417,55 @@ def test_explicit_only_filters_ambient_credentials_but_keeps_current_and_custom_
         ]
     
     
    +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
    +    authenticated provider."""
    +    ctx = _empty_ctx(provider="deepseek", model="deepseek-v4-pro")
    +    with _list_auth_returning([]):
    +        payload = build_models_payload(
    +            ctx, include_unconfigured=True, picker_hints=True,
    +        )
    +
    +    deepseek = next(r for r in payload["providers"] if r["slug"] == "deepseek")
    +    assert deepseek["source"] == "configured-current"
    +    assert deepseek["is_current"] is True
    +    assert deepseek["authenticated"] is False
    +    assert deepseek["models"] == ["deepseek-v4-pro"]
    +    assert deepseek["total_models"] == 1
    +    assert deepseek["auth_type"] == "api_key"
    +    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"],
    ```

    ```diff
    @@ -450,10 +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 ──────────────────────────────────────────────────────
     
     
    ```
  - `tests/hermes_cli/test_runtime_provider_resolution.py`

    ```diff
    @@ -8,6 +8,47 @@
     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=[redacted:secret-assignment] 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=[redacted:secret-assignment] False))
    +    monkeypatch.setattr(
    +        "hermes_cli.auth.resolve_api_key_provider_credentials",
    +        lambda _provider: {
    +            "provider": "lmstudio",
    +            "api_key": "[redacted:secret-assignment]",
    +            "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(
    ```
- **#57198: fix(cli): keep current provider visible in model pickers**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#62596`

## Fix status

- Merged to main in PR #62596 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-11`.
- Subsystem: `agent`
