# NousResearch/hermes-agent · Credential pool isolation during provider fallback

## Open now

- 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.

## TL;DR

- 4 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #33233. Verify it addresses your case.
- **Recommended:** read PR #33233 (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: `unresolved`. 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.50`: 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

- #33088: Fallback provider 429 can exhaust primary provider credential pool

## Prior & competing PRs

- **#33233: fix(agent): isolate credential pool on provider fallback**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `agent/agent_runtime_helpers.py`, `agent/chat_completion_helpers.py`, `tests/run_agent/test_fallback_credential_isolation.py`
  - fix hunks:
  - `agent/agent_runtime_helpers.py`

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

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

    ```diff
    @@ -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"
    ```
- **#33217: fix(agent): isolate credential pool on provider fallback**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `2e181602a`, `#33233`, `#33163`
- **#33258: fix(agent): keep credential pools scoped to active provider**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `2e181602a`, `#33233`, `#33217`, `#33088`, `#33163`

## Fix status

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