fix(agent): fail closed when Codex account-model entitlement 400s exhaust the chain - #106482
liuhao1024 wants to merge 1 commit into
Conversation
…aust the chain
A Codex ChatGPT-account 400 ('The X model is not supported when using Codex
with a ChatGPT account.') names the model, so with a single credential the
slug is dead for that account. The fallback walk still re-selected it and
restore_primary_runtime switched back to the primary at the start of every
turn, announcing an unverified 'Primary model restored' — the two warnings
alternated forever with zero delivered answers (NousResearch#106475).
Record the rejected (provider, model) pair on the non-retryable client-error
path (only when no multi-credential pool exists — rotation covers that case,
NousResearch#71970), skip rejected entries during the fallback walk, and gate
restore_primary_runtime on the primary's slug so the session fails closed
with the terminal entitlement error instead of oscillating. Fixes NousResearch#106475.
AI review — fix(agent): entitlement 400 fails closedSummary: Single-credential Codex ChatGPT-account 400s ("model is not supported when using codex with a chatgpt account") now record Strengths
Non-blocking
Verdict: Non-blocking. Correct fail-closed direction. |
|
Thanks for the review. On the non-blocking notes: the 400 body doesn't expose a machine-readable code today, so the narrow string+trigger match is the tightest available — if a stable code surfaces it can replace the string. The same-provider self-heal skip is a good follow-up candidate once the fail-closed path has some field time. |
|
Incorporated into #106549, merged to main as Closed as part of the 2026-09-09 issue-queue salvage batch; if any hunk of yours that mattered was dropped, say so here and it gets its own follow-up. |
What does this PR do?
When both the primary model and every
fallback_providersentry name anopenai-codexmodel the ChatGPT-account credential is not entitled to, Hermes oscillates forever: the primary call returns the account-specific 400, the fallback activates, the fallback returns the same 400, and at the start of the next turnrestore_primary_runtimeswitches back to the primary and announces "✅ Primary model restored" — a recovery that was never probed. Users see the two warnings alternate indefinitely with zero delivered answers while the service reports active (#106475).This PR fails closed instead. On the non-retryable client-error path, a Codex ChatGPT-account entitlement 400 (
The '<model>' model is not supported when using Codex with a ChatGPT account.) records the rejected(provider, model)pair as dead for the session. The fallback walk then skips rejected entries, andrestore_primary_runtimerefuses to switch back to a rejected primary — the session stays put and surfaces the terminal entitlement error once, with a user-visible pointer to/model, instead of looping.The marker is deliberately scoped to the single-credential case: when the provider has a multi-credential pool, another account may be entitled to the same slug, so the decision is left to credential rotation (#71970 / #71973). Rejected pairs live only in memory and are cleared on restart, matching the session-scoped
_unavailable_fallback_keyssemantics.Related Issue
Fixes #106475
Type of Change
Changes Made
agent/chat_completion_helpers.py— add_mark_entitlement_rejected_model(detect the normalized Codex entitlement 400 string, record the pair once, emit one user-visible notice) and_is_entitlement_rejected(matches the configured or normalized slug form);_should_skip_fallback_candidatenow skips rejected entries.agent/turn_api_error.py— call the marker on the non-retryable client-error branch, before the fallback walk.agent/agent_runtime_helpers.py—restore_primary_runtimereturns False (stays on the fallback, no restore notice) when the primary's slug was rejected as unentitled.tests/run_agent/test_entitlement_fail_closed.py— new: marker fires only on the Codex entitlement 400 (not 429/500/other 400s/403), multi-credential pools are left to rotation, rejected entries are skipped in both slug forms, restore is gated for a rejected primary and still restores normally for unrelated models.How to Test
pytest tests/run_agent/test_entitlement_fail_closed.py -q— 8 passed.pytest tests/run_agent/test_provider_fallback.py tests/run_agent/test_primary_runtime_restore.py tests/run_agent/test_24996_fallback_exhaustion_cooldown.py tests/run_agent/test_conversation_fallback_state.py tests/run_agent/test_fallback_credential_isolation.py tests/run_agent/test_fallback_api_mode_preservation.py tests/run_agent/test_nous_429_fallback_reentry.py tests/run_agent/test_nous_fallback_unavailable.py tests/run_agent/test_init_fallback_on_exhausted_pool.py tests/run_agent/test_32646_fallback_429_after_timeout.py tests/run_agent/test_image_rejection_fallback.py -q— all pass (no fallback/restore regressions).Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests passDocumentation & Housekeeping
docs/, docstrings) — or N/Acli-config.yaml.exampleif I added/changed config keys — or N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — or N/A