Files
hermes-agent/tests/agent/test_codex_model_entitlement_rotation.py
teknium1 4a43cc50ae fix: pin entitlement rotation through recover_with_credential_pool; bench until reset
The rotation test drove pool.mark_exhausted_and_rotate directly, so reverting the
recover_with_credential_pool model_entitlement branch stayed green. It now drives the
production recovery entry point with the classifier verdict and asserts (True, False),
the swap to cred-1 and the model-only bench on cred-0.

A ChatGPT-account entitlement is a plan property, not a quota window: the model bench
now lasts until the explicit reset path (hermes auth reset) clears model_cooldowns,
instead of re-probing the unentitled account hourly on the 429 TTL (#71970).
2026-09-19 09:37:23 -07:00

103 lines
4.8 KiB
Python

"""A Codex ChatGPT-account model entitlement 400 rotates to the next pool credential (#71970).
The exact normalized rejection benches only (credential, model) and hands the next eligible entry
back; every other 400 stays a plain request failure. Once every entry rejects the model, the
single-credential handling from #106475 takes over.
"""
import json
import time
import types
from unittest.mock import MagicMock
import pytest
from agent.agent_runtime_helpers import recover_with_credential_pool
from agent.error_classifier import FailoverReason, classify_api_error
MODEL = "gpt-5.3-codex"
OTHER_MODEL = "gpt-5.3-codex-mini"
TOKENS = ("tok-account-a", "tok-account-b")
class _Err(Exception):
def __init__(self, status, body):
self.status_code = status
self.body = body
self.response = types.SimpleNamespace(status_code=status, headers={}, text=json.dumps(body), json=lambda: body)
self.message = f"Error code: {status} - {json.dumps(body)}"
super().__init__(self.message)
def _entitlement_400():
return _Err(400, {"detail": f"The '{MODEL}' model is not supported when using Codex with a ChatGPT account."})
@pytest.fixture
def pool(tmp_path, monkeypatch):
root = tmp_path / "hermes-root"
root.mkdir()
(tmp_path / "fakehome").mkdir()
monkeypatch.setenv("HOME", str(tmp_path / "fakehome"))
monkeypatch.setenv("HERMES_HOME", str(root))
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
import hermes_constants
hermes_constants._default_hermes_root_memo = None # type: ignore[attr-defined]
(root / "auth.json").write_text(json.dumps({"credential_pool": {"openai-codex": [
{"id": f"cred-{i}", "label": f"acct-{i}", "auth_type": "oauth", "priority": i, "source": "manual",
"access_token": tok, "refresh_token": f"rt-{i}", "expires_at_ms": 4_000_000_000_000}
for i, tok in enumerate(TOKENS)
]}}))
from agent.credential_pool import load_pool
return load_pool("openai-codex")
def test_entitlement_400_benches_only_that_model_and_rotates(pool):
verdict = classify_api_error(_entitlement_400(), provider="openai-codex", model=MODEL)
assert verdict.reason == FailoverReason.model_entitlement
assert verdict.should_rotate_credential and verdict.should_fallback and not verdict.retryable
generic = classify_api_error(_Err(400, {"detail": "Invalid request: bad field"}), provider="openai-codex", model=MODEL)
assert generic.reason == FailoverReason.format_error and not generic.should_rotate_credential
# Drive the production recovery entry point (turn recovery -> recover_with_credential_pool),
# not the pool directly: the classifier verdict must reach the model-scoped bench.
assert pool.select(model=MODEL).id == "cred-0"
agent = types.SimpleNamespace(
provider="openai-codex", model=MODEL, base_url="https://chatgpt.com/backend-api/codex",
api_key=TOKENS[0], _credential_pool=pool, _credential_pool_entry_id="cred-0",
_swap_credential=MagicMock(return_value=True),
)
assert recover_with_credential_pool(
agent, status_code=400, has_retried_429=False, classified_reason=verdict.reason,
) == (True, False)
agent._swap_credential.assert_called_once()
assert agent._swap_credential.call_args.args[0].id == "cred-1"
first = pool.entries()[0]
assert first.last_status is None # credential-wide state untouched: other models stay usable
assert set(first.model_cooldowns) == {MODEL}
# An entitlement is a plan property, not a window: no hourly re-probe, only reset clears it.
assert first.model_cooldowns[MODEL] > time.time() + 24 * 3600
assert pool.select(model=OTHER_MODEL).id == "cred-0"
assert pool.reset_statuses() >= 1 and not pool.entries()[0].model_cooldowns
def test_all_entries_rejecting_falls_back_to_session_marker(pool):
from agent.fallback_cooldown import _is_entitlement_rejected, _mark_entitlement_rejected_model
agent = types.SimpleNamespace(
provider="openai-codex", model=MODEL, _credential_pool=pool,
_buffer_diagnostic_status=lambda *_a, **_k: None,
)
pool.mark_exhausted_and_rotate(
status_code=400, api_key_hint=TOKENS[0], credential_id="cred-0", failure_reason="model_entitlement", model=MODEL,
)
# cred-1 is still eligible for the model: rotation owns the recovery, no session-wide marker.
assert _mark_entitlement_rejected_model(agent, _entitlement_400()) is False
assert not _is_entitlement_rejected(agent, "openai-codex", MODEL)
assert pool.mark_exhausted_and_rotate(
status_code=400, api_key_hint=TOKENS[1], credential_id="cred-1", failure_reason="model_entitlement", model=MODEL,
) is None
assert _mark_entitlement_rejected_model(agent, _entitlement_400()) is True
assert _is_entitlement_rejected(agent, "openai-codex", MODEL)