From 3a67a7be55550f59888b2a01ae32952b73e724e4 Mon Sep 17 00:00:00 2001 From: AIalliAI <285906080+AIalliAI@users.noreply.github.com> Date: Sat, 13 Jun 2026 20:22:52 +0000 Subject: [PATCH] fix(model-switch): don't treat an exhausted credential pool as authenticated An aggregator whose pooled credentials are all exhausted/dead still counted as an authenticated provider during no-provider /model resolution. It then won the model-name match, was set as the sticky session provider, and poisoned every later switch with "empty API key" errors while still routing through the dead aggregator. list_authenticated_providers now requires a pool to have at least one available entry (has_available, not has_credentials / bare key presence) at all three credential-pool gates. Simple token-style entries that don't parse into exhaustion-tracked entries keep the prior behaviour, so providers whose creds live only in the auth-store credential_pool still appear. Fixes #45759 --- hermes_cli/model_switch.py | 23 +++++- ..._authenticated_providers_exhausted_pool.py | 78 +++++++++++++++++++ 2 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 tests/hermes_cli/test_authenticated_providers_exhausted_pool.py diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 954fcc0ac36f..711a20d025df 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -1698,6 +1698,20 @@ def list_authenticated_providers( store = _load_auth_store() if store and store.get("credential_pool", {}).get(hermes_id): has_creds = True + # ...but if it parses into a full pool whose every entry is + # exhausted/dead, it has no usable credential right now, so + # don't treat it as authenticated -- otherwise an aggregator + # whose quota is spent hijacks no-provider model resolution + # and sticks as the session provider (#45759). Simple + # token-style entries (no exhaustion-tracked entries) keep + # the prior behaviour. + try: + from agent.credential_pool import load_pool as _load_pool + _pool = _load_pool(hermes_id) + if _pool.has_credentials() and not _pool.has_available(): + has_creds = False + except Exception: + pass except Exception: pass if not has_creds: @@ -1788,7 +1802,10 @@ def list_authenticated_providers( try: from agent.credential_pool import load_pool pool = load_pool(hermes_slug) - if pool.has_credentials(): + # has_available(), not has_credentials(): an all-exhausted pool + # holds entries but no usable credential, so it must not count + # as authenticated (#45759). + if pool.has_available(): has_creds = True except Exception as exc: logger.debug("Credential pool check failed for %s: %s", hermes_slug, exc) @@ -1929,7 +1946,9 @@ def list_authenticated_providers( try: from agent.credential_pool import load_pool _cp_pool = load_pool(_cp.slug) - if _cp_pool.has_credentials(): + # has_available(): exclude pools whose every entry is exhausted + # (#45759). + if _cp_pool.has_available(): _cp_has_creds = True except Exception: pass diff --git a/tests/hermes_cli/test_authenticated_providers_exhausted_pool.py b/tests/hermes_cli/test_authenticated_providers_exhausted_pool.py new file mode 100644 index 000000000000..e1f4daf96886 --- /dev/null +++ b/tests/hermes_cli/test_authenticated_providers_exhausted_pool.py @@ -0,0 +1,78 @@ +"""Regression test for #45759. + +An all-exhausted credential pool holds entries but no *usable* credential. +``list_authenticated_providers`` must not treat such a provider as +authenticated -- otherwise an aggregator whose quota is spent gets matched +during no-provider ``/model`` resolution, wins the model name, and sticks as +the session provider (the "sticky provider fallback pollution" bug). +""" + +import pytest + + +class _FakePool: + def __init__(self, available: bool): + self._available = available + + def has_credentials(self) -> bool: + # The pool still holds entries... + return True + + def has_available(self) -> bool: + # ...but none of them are usable when exhausted/dead. + return self._available + + +def _patch_opencode_pool(monkeypatch, *, available: bool): + """Make the opencode-go aggregator look configured but with a pool whose + only credential is (un)available, depending on ``available``.""" + import hermes_cli.auth as auth + import agent.credential_pool as cp + + monkeypatch.setattr( + auth, + "_load_auth_store", + lambda: { + "version": 1, + "providers": {}, + "active_provider": None, + "credential_pool": {"opencode-go": {"entries": [{"id": "x"}]}}, + }, + ) + monkeypatch.setattr( + cp, + "load_pool", + lambda provider: _FakePool(available if provider == "opencode-go" else True), + ) + + +@pytest.fixture(autouse=True) +def _strip_provider_env(monkeypatch): + """Don't let real provider keys in the environment authenticate providers + through a different code path than the pool gate under test.""" + import os + + for key in list(os.environ): + if "OPENCODE" in key or key.endswith("_API_KEY"): + monkeypatch.delenv(key, raising=False) + + +def test_exhausted_pool_provider_is_not_authenticated(monkeypatch): + """The fix: an exhausted pool is NOT authenticated. Fails on main, where + the gate accepted any stored pool entry regardless of usability.""" + from hermes_cli.model_switch import get_authenticated_provider_slugs + + _patch_opencode_pool(monkeypatch, available=False) + slugs = get_authenticated_provider_slugs(current_provider="alibaba") + assert "opencode-go" not in slugs + + +def test_pool_provider_with_available_credential_is_authenticated(monkeypatch): + """Control: with a usable credential the provider IS authenticated, proving + the test drives the credential gate rather than excluding it for some other + reason.""" + from hermes_cli.model_switch import get_authenticated_provider_slugs + + _patch_opencode_pool(monkeypatch, available=True) + slugs = get_authenticated_provider_slugs(current_provider="alibaba") + assert "opencode-go" in slugs