NousResearch/hermes-agent

Provider config and cache staleness fixes

Cluster 57065 · agent. Data as of

Nothing open on GitHub. Fix merged in #62596 on 11 Jul 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #62596. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this resolved, deciding PR #62596. That is separate from the fix event.
Still open
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.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #62596 fix(model): keep configured provider authoritative

    merged to main

    files: hermes_cli/inventory.py, hermes_cli/runtime_provider.py, tests/hermes_cli/test_inventory.py, tests/hermes_cli/test_runtime_provider_resolution.py

    Show the diff
    • 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,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
      @@ -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
      @@ -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"],
      @@ -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
      @@ -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(

1 PR closed without merging

From the close comments: merged via another change 1

  • #57198 fix(cli): keep current provider visible in model pickers · merged via another change → #62596
Closed issues · 1
  • Closed#57065[Bug] provider_models_cache retains stale providers; silent fallback when API key missing
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#62596
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
11 Jul 2026

For your agent

The same facts, typed.