fix(fallback): attach credential pool after provider switch

When automatic fallback activates a provider that differs from the
primary, try_activate_fallback() cleared the primary's pool (to avoid
cross-provider base_url contamination, #33163) but never loaded the
fallback provider's own pool. The fallback then ran with no pool, so
rate_limit/billing/auth recovery couldn't rotate its credentials.

After clearing a mismatched pool, load_pool(fb_provider) and attach it
when it has credentials, so provider-specific rotation continues to
work on the fallback target.
This commit is contained in:
herbalizer404 2026-06-27 04:07:59 -07:00 committed by Teknium
parent 635841d210
commit 3fe16e3cd5
2 changed files with 76 additions and 13 deletions

View file

@ -11,8 +11,8 @@ _swap_credential continue operating on the PRIMARY's credential pool during
fallback calls, contaminating primary state with fallback-provider errors.
"""
import sys
from unittest.mock import MagicMock
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
@ -81,10 +81,8 @@ class TestFallbackCredentialIsolation:
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.
# We test the isolation logic directly here as a minimal guard; the
# integration-style test below calls the real fallback activator.
agent = _make_agent(provider="openai-codex", base_url="https://chatgpt.com/backend-api/codex")
agent._fallback_activated = True
@ -122,6 +120,53 @@ class TestFallbackCredentialIsolation:
"Pool should be preserved when fallback provider matches pool provider"
)
def test_fallback_attaches_matching_pool_after_clear(self):
"""Provider-switch fallback should attach the fallback provider's pool."""
from agent.chat_completion_helpers import try_activate_fallback
agent = _make_agent(
provider="ollama-cloud",
model="glm-5.2",
base_url="https://ollama.com/v1",
api_mode="chat_completions",
)
agent._fallback_chain = [{"provider": "openai-codex", "model": "gpt-5.5"}]
agent._credential_pool = _make_pool("ollama-cloud")
agent._buffer_status = MagicMock()
agent._is_azure_openai_url.return_value = False
agent._is_direct_openai_url.return_value = False
agent._provider_model_requires_responses_api.return_value = False
agent._anthropic_prompt_cache_policy.return_value = (False, False)
agent._ensure_lmstudio_runtime_loaded = MagicMock()
agent._replace_primary_openai_client = MagicMock()
agent.context_compressor = None
fallback_client = SimpleNamespace(
api_key="codex-key",
base_url="https://chatgpt.com/backend-api/codex",
_custom_headers={},
)
fallback_pool = _make_pool("openai-codex")
with patch(
"agent.auxiliary_client.resolve_provider_client",
return_value=(fallback_client, "gpt-5.5"),
) as resolve_provider_client, patch(
"agent.credential_pool.load_pool",
return_value=fallback_pool,
) as load_pool:
assert try_activate_fallback(agent) is True
resolve_provider_client.assert_called_once()
load_pool.assert_called_once_with("openai-codex")
assert agent.provider == "openai-codex"
assert agent.model == "gpt-5.5"
assert agent.base_url == "https://chatgpt.com/backend-api/codex"
assert agent.api_mode == "codex_responses"
assert agent._credential_pool is fallback_pool
assert agent._credential_pool.provider == "openai-codex"
assert agent._transport_cache == {}
# ── Test: _recover_with_credential_pool rejects mismatched pool ──────