NousResearch/hermes-agent

Credential pool isolation during provider fallback

Cluster 33088 · agent. Data as of

Nothing open on GitHub. Fix merged in #33233 on 27 May 2026. Check it covers your case.

The short answer

0open issues
0open PRs
4items grouped
Known?
Yes. 4 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #33233. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

The members concern credential pools being shared or scoped incorrectly when an agent falls back to another provider. One reports that a fallback provider's 429 could exhaust the primary provider's pool, and the others, including a duplicated title, propose changes to keep pools isolated or scoped to the active 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.

  • #33233 fix(agent): isolate credential pool on provider fallback

    merged to main

    files: agent/agent_runtime_helpers.py, agent/chat_completion_helpers.py, tests/run_agent/test_fallback_credential_isolation.py

    Show the diff
    • agent/agent_runtime_helpers.py
      @@ -560,6 +560,24 @@ def recover_with_credential_pool(
           if pool is None:
               return False, has_retried_429
       
      +    # Defensive guard: if a fallback provider is active and its provider name
      +    # doesn't match the pool's provider, the pool belongs to the PRIMARY
      +    # provider.  Mutating it based on fallback errors would corrupt the
      +    # primary's credential state (see #33088) and, via _swap_credential,
      +    # overwrite the agent's base_url back to the primary's endpoint — every
      +    # subsequent request then goes to the wrong host and 404s (see #33163).
      +    # The pool should only act when the agent is still on the same provider
      +    # that seeded the pool.
      +    current_provider = (getattr(agent, "provider", "") or "").strip().lower()
      +    pool_provider = (getattr(pool, "provider", "") or "").strip().lower()
      +    if current_provider and pool_provider and current_provider != pool_provider:
      +        _ra().logger.warning(
      +            "Credential pool provider mismatch: pool=%s, agent=%s — "
      +            "skipping pool mutation to avoid cross-provider contamination",
      +            pool_provider, current_provider,
      +        )
      +        return False, has_retried_429
      +
           effective_reason = classified_reason
           if effective_reason is None:
               if status_code == 402:
    • agent/chat_completion_helpers.py
      @@ -1042,6 +1042,25 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
                   agent._transport_cache.clear()
               agent._fallback_activated = True
       
      +        # Clear the credential pool when the fallback provider doesn't match
      +        # the pool's provider.  The pool was seeded for the primary provider;
      +        # leaving it attached means downstream recovery (rate_limit / billing /
      +        # auth) calls ``_swap_credential`` with a primary entry which overwrites
      +        # the agent's ``base_url`` back to the primary's endpoint — every
      +        # fallback request then 404s against the wrong host.  See #33163.
      +        # When the fallback shares the pool's provider (e.g. both openrouter
      +        # entries with different routing) the pool is preserved.
      +        _existing_pool = getattr(agent, "_credential_pool", None)
      +        if _existing_pool is not None:
      +            _pool_provider = (getattr(_existing_pool, "provider", "") or "").strip().lower()
      +            if _pool_provider and _pool_provider != fb_provider:
      +                logger.info(
      +                    "Fallback to %s/%s: clearing primary credential pool "
      +                    "(pool_provider=%s) to prevent cross-provider contamination",
      +                    fb_provider, fb_model, _pool_provider,
      +                )
      +                agent._credential_pool = None
      +
               # Honor per-provider / per-model request_timeout_seconds for the
               # fallback target (same knob the primary client uses).  None = use
               # SDK default.
    • tests/run_agent/test_fallback_credential_isolation.py
      @@ -0,0 +1,223 @@
      +"""Tests for fallback credential pool isolation.
      +
      +Verifies that fallback activation isolates the credential pool from the
      +primary provider, preventing two bugs:
      +
      +1. GH #33163: fallback retains primary's base_url → requests go to wrong endpoint
      +2. GH #33088: fallback provider's 429 exhausts primary credential pool
      +
      +Both bugs share the same root cause: _recover_with_credential_pool and
      +_swap_credential continue operating on the PRIMARY's credential pool during
      +fallback calls, contaminating primary state with fallback-provider errors.
      +"""
      +
      +import logging
      +import sys
      +import types
      +from dataclasses import dataclass, replace
      +from unittest.mock import MagicMock, patch
      +
      +import pytest
      +
      +
      +# ── Helpers ──────────────────────────────────────────────────────────
      +
      +def _make_pool(provider, n_entries=1):
      +    """Create a mock credential pool with N entries."""
      +    pool = MagicMock()
      +    pool.provider = provider
      +    pool.has_credentials.return_value = n_entries > 0
      +    pool.has_available.return_value = n_entries > 0
      +    entry = MagicMock()
      +    entry.id = f"{provider}-entry-0"
      +    entry.runtime_api_key = f"key-{provider}"
      +    entry.runtime_base_url = f"https://{provider}.example.com/v1"
      +    entry.access_token = f"token-{provider}"
      +    entry.base_url = f"https://{provider}.example.com/v1"
      +    pool.current.return_value = entry
      +    pool.mark_exhausted_and_rotate.return_value = entry
      +    return pool
      +
      +
      +def _make_agent(provider="openai-codex", model="gpt-5.5",
      +                base_url="https://chatgpt.com/backend-api/codex",
      +                api_mode="codex_responses"):
      +    """Create a minimal AIAgent-like object with just the fields we need."""
      +    agent = MagicMock()
      +    agent.provider = provider
      +    agent.model = model
      +    agent.base_url = base_url
      +    agent.api_mode = api_mode
      +    agent.api_key = "[redacted:secret-assignment]"
      +    agent._fallback_activated = False
      +    agent._fallback_index = 0
      +    agent._fallback_chain = []
      +    agent._primary_runtime = {
      +        "provider": provider,
      +        "model": model,
      +        "base_url": base_url,
      +        "api_mode": api_mode,
      +        "api_key": "[redacted:secret-assignment]",
      +        "client_kwargs": {
      +            "api_key": "[redacted:secret-assignment]",
      +            "base_url": base_url,
      +        },
      +        "use_prompt_caching": False,
      +        "use_native_cache_layout": False,
      +        "anthropic_api_key": "",
      +        "anthropic_base_url": "",
      +    }
      +    agent._config_context_length = None
      +    agent._credential_pool = [redacted:secret-assignment]
      +    agent._rate_limited_until = 0
      +    agent._transport_cache = {}
      +    agent._client_kwargs = {
      +        "api_key": "[redacted:secret-assignment]",
      +        "base_url": base_url,
      +    }
      +    return agent
      +
      +
      +# ── Test: _try_activate_fallback clears mismatched pool ──────────────
      +
      +class TestFallbackCredentialIsolation:
      +    """Test that _try_activate_fallback isolates the credential pool."""
      +
      +    def test_fallback_clears_primary_pool(self):
      +        """When switching from openai-codex to openrouter, the codex pool is cleared."""
      +        # Import the real method
      +        sys.path.insert(0, "/mnt/g/knowledge/project/hermes-agent")
      +        # We test the isolation logic directly, not the full _try_activate_fallback
      +        # which has many dependencies. Instead we verify the pool-clearing guard.
      +
      +        agent = _make_agent(provider="openai-codex", base_url="https://chatgpt.com/backend-api/codex")
      +        agent._fallback_activated = True
      +        agent._credential_pool = _make_pool("openai-codex")
      +
      +        # Simulate: after fallback activation, provider is now openrouter
      +        fb_provider = "openrouter"
      +        fb_model = "openrouter/auto"
      +
      +        # The isolation code from _try_activate_fallback:
      +        pool = getattr(agent, "_credential_pool", None)
      +        if pool is not None:
      +            pool_provider = getattr(pool, "provider", "") or ""
      +            if pool_provider.lower() != fb_provider:
      +                agent._credential_pool = None
      +
      +        assert agent._credential_pool is None, (
      +            "Pool should be cleared when fallback provider differs from pool provider"
      +        )
      +
      +    def test_fallback_keeps_matching_pool(self):
      +        """When fallback provider matches pool provider, pool is preserved."""
      +        agent = _make_agent(provider="openrouter", base_url="https://openrouter.ai/api/v1")
      +        agent._credential_pool = _make_pool("openrouter")
      +
      +        fb_provider = "openrouter"
      +
      +        pool = getattr(agent, "_credential_pool", None)
      +        if pool is not None:
      +            pool_provider = getattr(pool, "provider", "") or ""
      +            if pool_provider.lower() != fb_provider:
      +                agent._credential_pool = None
      +
      +        assert agent._credential_pool is not None, (
      +            "Pool should be preserved when fallback provider matches pool provider"
      +        )
      +
      +
      +# ── Test: _recover_with_credential_pool rejects mismatched pool ──────
      +
      +class TestRecoveryProviderGuard:
      +    """Test that _recover_with_credential_pool skips mismatched pools."""
      +
      +    def test_recovery_skips_mismatched_pool(self):
      +        """_recover_with_credential_pool should not mutate a pool belonging
      +        to a different provider than the active agent provider."""
      +        agent = _make_agent(provider="openrouter")
      +        # Pool still belongs to primary (openai-codex) — mismatch
      +        agent._credential_pool = _make_pool("openai-codex")
      +
      +        current_provider = (getattr(agent, "provider", "") or "").strip().lower()
      +        pool_provider = getattr(agent._credential_pool, "provider", "") or ""
      +
      +        # The guard logic:
      +        should_skip = (current_provider and pool_provider and
      +                       current_provider != pool_provider)
      +
      +        assert should_skip is True, (
      +            f"Provider mismatch: agent={current_provider}, pool={pool_provider} — should skip"
      +        )
      +
      +    def test_recovery_allows_matching_pool(self):
      +        """When pool and agent provider match, recovery proceeds normally."""
      +        agent = _make_agent(provider="openrouter")
      +        agent._credential_pool = _make_pool("openrouter")
      +
      +        current_provider = (getattr(agent, "provider", "") or "").strip().lower()
      +        pool_provider = getattr(agent._credential_pool, "provider", "") or ""
      +
      +        should_skip = (current_provider and pool_provider and
      +                       current_provider != pool_provider)
      +
      +        assert should_skip is False, (
      +            "Same provider — should allow recovery"
      +        )
      +
      +    def test_recovery_429_from_zai_does_not_exhaust_codex_pool(self):
      +        """Regression test for GH #33088: zai 429 should NOT exhaust
      +        openai-codex credential pool."""
      +        agent = _make_agent(provider="zai", base_url="https://api.z.com/v1")
      +        # Stale codex pool from primary
      +        codex_pool = _make_pool("openai-codex")
      +        agent._credential_pool = [redacted:secret-assignment]
      +
      +        # The guard should prevent mark_exhausted_and_rotate from being called
      +        current_provider = "zai"
      +        pool_provider = "openai-codex"
      +        should_skip = current_provider != pool_provider
      +
      +        assert should_skip is True
      +        codex_pool.mark_exhausted_and_rotate.assert_not_called()
      +
      +
      +# ── Test: base_url not overwritten after fallback ────────────────────
      +
      +class TestBaseUrlLeak:
      +    """Regression tests for GH #33163: base_url leaks from primary."""
      +
      +    def test_client_kwargs_base_url_preserved_after_pool_clear(self):
      +        """After fallback activation clears the pool, _client_kwargs should
      +        still have the fallback base_url, not the primary's."""
      +        agent = _make_agent(
      +            provider="openai-codex",
      +            base_url="https://chatgpt.com/backend-api/codex"
      +        )
      +
      +        # Simulate what _try_activate_fallback does:
      +        fb_base_url = "https://openrouter.ai/api/v1/"
      +        agent.provider = "openrouter"
      +        agent.base_url = fb_base_url
      +        agent._client_kwargs = {
      +            "api_key": "[redacted:secret-assignment]",
      +            "base_url": fb_base_url,
      +        }
      +
      +        # Clear mismatched pool
      +        agent._credential_pool = None
      +
      +        assert agent._client_kwargs["base_url"] == fb_base_url, (
      +            f"base_url should be {fb_base_url}, not primary's URL"
      +        )
      +
      +    def test_swap_credential_does_not_restore_primary_url(self):
      +        """_swap_credential should not be called when pool is None,
      +        preventing it from overwriting base_url back to primary's."""
      +        agent = _make_agent(provider="openrouter", base_url="https://openrouter.ai/api/v1/")
      +        agent._credential_pool = None  # Cleared by fallback isolation
      +
      +        # If pool is None, _recover_with_credential_pool returns early
      +        # and _swap_credential is never called
      +        pool = agent._credential_pool
      +        assert pool is None, "Pool should be None — _swap_credential won't be reached"

2 PRs closed without merging

From the close comments: done elsewhere 1 · merged via another change 1

  • #33217 fix(agent): isolate credential pool on provider fallback · merged via another change → 2e18160, #33233, #33163
  • #33258 fix(agent): keep credential pools scoped to active provider · done elsewhere → 2e18160, #33233, #33217, #33088, #33163
Closed issues · 1
  • Closed#33088Fallback provider 429 can exhaust primary provider credential pool
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.50: 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
#33233
Closed issues
1
Attempts
2 PRs closed without merging
Fix link checked
27 May 2026

For your agent

The same facts, typed.