Files
hermes-agent/tests/agent/test_codex_soft_failure_pool_rotation.py
teknium1 d1b283f4a9 fix(codex): rotate the credential pool on Responses HTTP-200 soft failures
The Codex Responses API reports quota exhaustion as HTTP 200 with
`response.status == "failed"` and `error.code == "usage_limit_reached"`.
The SDK never raises, so the exception path's credential-pool rotation
(`recover_after_classification` -> `_recover_with_credential_pool`) never
saw it: `retry_invalid_response` retried the same exhausted account
`max_retries` times and then went straight to cross-provider fallback,
leaving a healthy sibling pool entry unused and the dead entry unmarked.

Fix: `turn_recovery.classify_codex_soft_failure` reshapes `response.error`
as an SDK-style error body and runs it through the existing
`classify_api_error` / `extract_api_error_context` (no second pattern
list); `retry_invalid_response` then tries same-provider pool recovery
FIRST for rate_limit / billing / auth verdicts and falls through to the
existing eager fallback + retry path otherwise. Content-policy and other
non-quota failures never rotate. The `ResponseError` repr that leaked into
the retry trace now shows the provider's message.

Live probe (fake Responses SSE server, 2-entry pool, real AIAgent loop):
before -> 3 requests on tok-A, pool untouched, turn failed;
after  -> 1 soft failure on tok-A, cred-0 exhausted (usage_limit_reached),
          rotated to tok-B, turn completed; content_policy control -> no
          rotation on either side.

Fixes #24159
Salvages #24173 (@jmmaloney4) — recovery order and insertion point; the
hand-rolled pattern classifier is replaced by the shared error classifier.

Co-authored-by: Jack Maloney <jmmaloney4@gmail.com>
2026-09-18 23:41:22 -07:00

105 lines
4.0 KiB
Python

"""Codex Responses HTTP-200 soft failures (``response.status == "failed"``) must reach the
same-provider credential pool before cross-provider fallback (#24159).
The SDK never raises on these, so the exception path's ``_recover_with_credential_pool`` never
sees them; ``retry_invalid_response`` has to classify ``response.error`` itself.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock
from agent.agent_runtime_helpers import recover_with_credential_pool
from agent.credential_pool import STATUS_EXHAUSTED, CredentialPool, PooledCredential
from agent.turn_response_check import retry_invalid_response
from agent.turn_retry_state import TurnRetryState
_BASE_URL = "https://chatgpt.com/backend-api/codex"
def _entry(i: int) -> PooledCredential:
return PooledCredential(
provider="openai-codex", id=f"cred-{i}", label=f"acct-{i}", auth_type="api_key", priority=i,
source="manual", access_token=f"tok-{i}-1234567890", base_url=_BASE_URL,
)
class _Agent:
log_prefix = ""
quiet_mode = True
api_mode = "codex_responses"
provider = "openai-codex"
model = "gpt-5.1-codex"
base_url = _BASE_URL
_fallback_chain = ()
_fallback_index = 0
_credential_pool_revert_id = None
def __init__(self, pool: CredentialPool) -> None:
self._credential_pool = pool
self.api_key = pool.select().access_token
self.swapped_to: list = []
self._try_activate_fallback = MagicMock(return_value=False)
def _recover_with_credential_pool(self, **kwargs):
return recover_with_credential_pool(self, **kwargs)
def _extract_api_error_context(self, error):
from agent.agent_runtime_helpers import extract_api_error_context
return extract_api_error_context(error)
def _swap_credential(self, entry):
self.swapped_to.append(entry.id)
self.api_key = entry.access_token
return True
def _has_pending_fallback(self):
return False
def _clean_error_message(self, msg):
return msg
def __getattr__(self, name):
return lambda *args, **kwargs: None
def _soft_failure(code: str, message: str) -> SimpleNamespace:
# The SDK types ``response.error`` as ``ResponseError(code=..., message=...)``, not a dict.
return SimpleNamespace(status="failed", output=[], output_text="", error=SimpleNamespace(code=code, message=message))
def _run(agent: _Agent, response: SimpleNamespace):
return retry_invalid_response(
agent, response=response, error_details=["response.status=failed"], _retry=TurnRetryState(),
thinking_spinner=None, messages=[], api_messages=[], api_kwargs=None, active_system_prompt=None,
conversation_history=None, retry_count=0, max_retries=3, compression_attempts=0, api_call_count=1,
api_request_id="r", api_start_time=0.0, api_duration=0.4, effective_task_id="t", turn_id="turn",
)
def test_quota_soft_failure_rotates_pool_before_provider_fallback():
pool = CredentialPool("openai-codex", [_entry(0), _entry(1)])
agent = _Agent(pool)
verdict = _run(agent, _soft_failure("usage_limit_reached", "You've hit your usage limit. Try again at 3:00 PM."))
assert verdict.action == "continue"
assert agent.swapped_to == ["cred-1"] and agent.api_key == "tok-1-1234567890"
benched = next(e for e in pool.entries() if e.id == "cred-0")
assert benched.last_status == STATUS_EXHAUSTED and benched.last_error_reason == "usage_limit_reached"
agent._try_activate_fallback.assert_not_called()
def test_content_policy_soft_failure_leaves_pool_alone():
pool = CredentialPool("openai-codex", [_entry(0), _entry(1)])
agent = _Agent(pool)
verdict = _run(agent, _soft_failure("content_policy_violation", "Your request was rejected by our safety system."))
assert verdict.action == "continue" # ordinary invalid-response retry path
assert agent.swapped_to == [] and agent.api_key == "tok-0-1234567890"
assert all(e.last_status is None for e in pool.entries())
agent._try_activate_fallback.assert_called()