NousResearch/hermes-agent

Provider fallback retaining primary credentials or base_url

Cluster 33163 · 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
3items grouped
Known?
Yes. 3 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 resolved, deciding PR #33233. That is separate from the fix event.
Still open
Nothing open on GitHub.

Members concern provider fallback in the agent, where credential pool and base_url state may carry over from the primary provider. One issue reports OpenRouter fallback sending requests to the ChatGPT Codex endpoint with an openrouter/auto model, resulting in HTTP 404; two items share a title about isolating the credential pool on fallback.

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"

1 PR closed without merging

From the close comments: merged via another change 1

  • #33217 fix(agent): isolate credential pool on provider fallback · merged via another change → 2e18160, #33233, #33163
Closed issues · 1
  • Closed#33163Fallback to OpenRouter retains primary's base_url — requests go to ChatGPT Codex with openrouter/auto model → HTTP 404
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
#33233
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
27 May 2026

For your agent

The same facts, typed.