Files
hermes-agent/tests/run_agent/test_auth_provider_failover.py
T
Teknium 2a95791992 simplify(compat): run_agent/model_tools/toolsets/acp/providers — drop 42 re-exports/aliases, repoint 15 callers + 99 test files
run_agent.py: delete the `# noqa: F401` re-export block (agent.process_bootstrap
OpenAI/_SafeWriter/_get_proxy_*, model_tools get_tool_definitions/
handle_function_call/check_toolset_requirements, FailoverReason,
_qwen_portal_headers/_routermint_headers, session_persistence names,
estimate_request_tokens_rough, ContextCompressor + friends, jittered_backoff,
prompt_builder names, message_sanitization names, tool_dispatch_helpers
names) — 41 names run_agent never used itself — and the `_STREAM_DIAG_HEADERS`
back-compat class alias (no in-tree reader). run_agent now imports only what
it uses (get_toolset_for_tool, is_local_endpoint, coalesce/uniquify tool-call
ids, cleanup_vm/get_active_env from terminal_tool_lifecycle).

agent/*: `_ra().X` late-binds that only reached a re-export now import the
defining module directly (agent_runtime_helpers -> process_bootstrap.OpenAI,
model_tools.handle_function_call, session_persistence._safe_session_filename_component;
agent_init -> model_tools.get_tool_definitions/check_toolset_requirements,
_lazy_headers("agent.client_lifecycle", ...) for qwen/routermint;
system_prompt -> agent.prompt_builder / model_tools directly, dropping its
own _ra() shim and the `_r` parameter threading). `_ra()` stays for
run_agent-resident names (logger, AIAgent, _hermes_home, _set_interrupt, ...).

toolsets.py: remove resolve_multiple_toolsets (shim-only, restored by
34abf954bd); tests/test_toolsets.py pins the same union behavior via
resolve_toolset over each name.

providers/__init__.py: drop the OMIT_TEMPERATURE re-export (no callers via the
package); ProviderProfile stays because __init__ uses it for annotations —
2 tests repointed to providers.base.

agent/iteration_budget.py: drop the "run_agent re-exports the class"
docstring pointer; 4 tests import IterationBudget from its home.

model_tools.py (arg_coercion names), agent/tool_executor.py, and
hermes_cli/cli_session_mixin.py repoints landed via a sibling commit on this
shared worktree.

Callers repointed: gateway/run.py, hermes_cli/cli_chat_turn_mixin.py,
hermes_cli/cli_tui_mixin.py, tui_gateway/session_workdir.py,
agent/transports/codex.py (one-line imports) + comment pointers in
tools/file_state.py, tools/schema_sanitizer.py, scripts/tool_search_livetest.py.
Tests: patch("run_agent.X") / monkeypatch.setattr(run_agent, "X") /
`from run_agent import X` -> defining module across 99 test files.
2026-09-03 13:28:22 -07:00

124 lines
4.8 KiB
Python

"""Auth-failure provider failover (conversation loop).
A 401/403 that survives the per-provider credential-refresh attempt
(revoked OAuth, blocked/expired key, an account pinned to a dead/staging
endpoint) must escalate to the configured fallback chain instead of
thrashing on the same dead credential every turn.
Before the fix, the conversation loop's generic failover dispatch only
fired for ``{rate_limit, billing}`` reasons; ``auth`` / ``auth_permanent``
fell through to "switch providers manually" advice and never called
``_try_activate_fallback()``. These tests pin:
1. 401/403 classify as auth (``classified.is_auth`` True).
2. ``_try_activate_fallback`` advances the chain on an auth reason.
3. The one-shot guard flag exists on TurnRetryState.
"""
from unittest.mock import MagicMock, patch
from run_agent import AIAgent
from agent.error_classifier import classify_api_error, FailoverReason
from agent.turn_retry_state import TurnRetryState
def _make_agent(fallback_model=None):
with (
patch("model_tools.get_tool_definitions", return_value=[]),
patch("model_tools.check_toolset_requirements", return_value={}),
patch("agent.process_bootstrap.OpenAI"),
):
agent = AIAgent(
api_key="test-key",
base_url="https://openrouter.ai/api/v1",
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
fallback_model=fallback_model,
)
agent.client = MagicMock()
return agent
def _mock_client(base_url="https://openrouter.ai/api/v1", api_key="fb-key"):
mock = MagicMock()
mock.base_url = base_url
mock.api_key = api_key
return mock
def _auth_error(status=401, msg="Your API key is invalid, blocked or out of funds."):
err = Exception(f"Error code: {status} - {msg}")
err.status_code = status
return err
class TestAuthErrorClassification:
def test_401_is_auth(self):
c = classify_api_error(_auth_error(401))
assert c.reason in {FailoverReason.auth, FailoverReason.auth_permanent}
assert c.is_auth is True
def test_500_is_not_auth(self):
err = Exception("Error code: 500 - internal server error")
err.status_code = 500
c = classify_api_error(err)
assert c.is_auth is False
class TestAuthFailoverGuardFlag:
def test_flag_defaults_false(self):
assert TurnRetryState().auth_failover_attempted is False
class TestAuthFailoverActivation:
"""The decision the loop makes on a persistent auth failure: when a
fallback chain exists and the guard hasn't fired, escalate to it."""
def _should_failover(self, agent, classified, retry):
# Mirror the exact gating condition added to conversation_loop.py.
return (
classified.is_auth
and not retry.auth_failover_attempted
and agent._fallback_index < len(agent._fallback_chain)
)
def test_auth_failover_fires_when_chain_present(self):
agent = _make_agent(fallback_model=[{"provider": "openai", "model": "gpt-4o"}])
retry = TurnRetryState()
classified = classify_api_error(_auth_error(401))
assert self._should_failover(agent, classified, retry) is True
# And the activation primitive actually advances on an auth reason.
with patch(
"agent.auxiliary_client.resolve_provider_client",
return_value=(_mock_client(), "gpt-4o"),
):
advanced = agent._try_activate_fallback(reason=classified.reason)
assert advanced is True
assert agent._fallback_index == 1
def test_no_failover_without_chain(self):
"""A user with no fallback configured (the common case for the
original incident) does NOT failover — falls through to the
existing terminal handling + troubleshooting advice."""
agent = _make_agent(fallback_model=None)
retry = TurnRetryState()
classified = classify_api_error(_auth_error(401))
assert self._should_failover(agent, classified, retry) is False
def test_guard_blocks_repeat_failover(self):
agent = _make_agent(fallback_model=[{"provider": "openai", "model": "gpt-4o"}])
retry = TurnRetryState()
retry.auth_failover_attempted = True # already escalated this attempt
classified = classify_api_error(_auth_error(401))
assert self._should_failover(agent, classified, retry) is False
def test_non_auth_error_does_not_trigger_auth_failover(self):
agent = _make_agent(fallback_model=[{"provider": "openai", "model": "gpt-4o"}])
retry = TurnRetryState()
err = Exception("Error code: 500 - internal server error")
err.status_code = 500
classified = classify_api_error(err)
assert self._should_failover(agent, classified, retry) is False