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
- 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
Open PRs0
Fixes on record
-
#62596 fix(model): keep configured provider authoritative
files:
hermes_cli/inventory.py,hermes_cli/runtime_provider.py,tests/hermes_cli/test_inventory.py,tests/hermes_cli/test_runtime_provider_resolution.pyShow 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.00means 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 LabAt a glance
- Subsystem
- agent
- Merged PRs
- #62596
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 11 Jul 2026