Skip to content

fix(agent): rotate Codex credentials on model entitlement rejection - #71973

Open
kilhyeonjun wants to merge 6 commits into
NousResearch:mainfrom
kilhyeonjun:fix/codex-entitlement-failover
Open

fix(agent): rotate Codex credentials on model entitlement rejection#71973
kilhyeonjun wants to merge 6 commits into
NousResearch:mainfrom
kilhyeonjun:fix/codex-entitlement-failover

Conversation

@kilhyeonjun

Copy link
Copy Markdown

Summary

  • classify the exact normalized ChatGPT-account Codex model rejection as a distinct entitlement failover reason
  • persist model-scoped unavailability on the exact active openai-codex pool entry and rotate once to the next eligible credential
  • fail closed on conflicting/missing credential attribution, preserve other-model eligibility, and clear model markers through the existing explicit pool reset lifecycle
  • retain existing provider fallback after every same-provider credential rejects the requested model

Fixes #71970

Root cause

The account-specific Codex HTTP 400 was treated as a generic request/format failure. Credential-pool recovery therefore had no narrow reason or model-scoped state with which to rotate a different credential, even when another configured ChatGPT subscription could use the requested model.

Safety boundaries

  • only the anchored, whitespace/case-normalized response The '<model>' model is not supported when using Codex with a ChatGPT account. is classified as entitlement
  • arbitrary HTTP 400 responses do not rotate credentials
  • HTTP 503 concurrency/overload remains transient and is not persisted as credential death
  • 401 refresh, 429 reset-aware recovery, provider mismatch guards, and cross-provider fallback behavior are unchanged
  • model markers are scoped to the exact pool entry + model; no token, account id, or stable identity is logged

Tests

scripts/run_tests.sh tests/agent/test_credential_pool.py tests/agent/test_error_classifier.py tests/run_agent/test_credential_pool_interrupt.py tests/run_agent/test_auth_provider_failover.py -q
# 321 passed, 0 failed; 7.2s runner wall

uv run ruff check agent/error_classifier.py agent/credential_pool.py agent/agent_runtime_helpers.py tests/agent/test_error_classifier.py tests/agent/test_credential_pool.py tests/run_agent/test_credential_pool_interrupt.py
# All checks passed

uv run python -m py_compile agent/error_classifier.py agent/credential_pool.py agent/agent_runtime_helpers.py
# passed

ty check on the touched large modules reports the repository's existing advisory diagnostics; the blocking Ruff gate and focused behavioral suites pass. Upstream CI can provide the canonical base-vs-head type-diagnostic diff.

Related work

This complements static model compatibility fixes #61660 / #61665 and credential-pool work #31032 / #47096 / #68520. It is intentionally separate from quota-based usage_limit_reached handling in #45646 / #46915.

Platform

Tested on macOS arm64 with Python 3.12 through the repository uv environment. No live credential or provider request was used by the tests.

@alt-glitch alt-glitch added type/bug Something isn't working comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint provider/openai OpenAI / Codex Responses API area/auth Authentication, OAuth, credential pools P2 Medium — degraded but workaround exists labels Jul 26, 2026
@PRATHAMESH75

Copy link
Copy Markdown
Contributor

Reviewed against the #71970 spec (static review, not run locally). This looks correct and complete — it maps cleanly onto every expected-behavior bullet in the issue:

  • Exact-only classification_CHATGPT_ACCOUNT_MODEL_REJECTION.fullmatch is ^…$-anchored with a whitespace-free model token, so prefix …/… suffix correctly stay format_error (covered by test_400_nonexact_chatgpt_account_text_remains_format_error), and the structured-code check still wins over the text match (test_400_structured_replay_code_beats_chatgpt_account_text). Non-goal "arbitrary 400 must not rotate" is respected.
  • Model-scoped persistence, credential stays healthymark_entitlement_unavailable_and_rotate only appends to extra["unavailable_models"], so the credential remains selectable for other models; the rotate candidate filter excludes the failed model, so the same credential can't be re-picked for it.
  • Fail closed on conflicting attribution — the credential_id+api_key_hint consistency gate returning None (and test_codex_entitlement_requires_consistent_id_and_key_attribution) matches "fail closed when the active credential cannot be attributed exactly."
  • Rotate once, then retain provider fallback — when every eligible entry is blocked, candidates empties → None_rotate_failed_credential returns (False, …), preserving the existing provider fallback (test_codex_entitlement_rotates_once_then_allows_provider_fallback).
  • Reset lifecyclereset_statuses now clears the unavailable_models marker, so markers only clear through the explicit pool reset.

One minor, non-blocking note: when neither credential_id nor api_key_hint is supplied, the method falls through to _current_unlocked() rather than failing closed. In practice the caller (agent_runtime_helpers.py:955-969) almost always populates at least one hint, so this is an unlikely path — but if strict "no attribution ⇒ no marking" is intended, an early if not identity_supplied: return None would make that explicit. Not a merge blocker.

Verdict: fixes the issue as specified, with genuine failing-then-green regression coverage. Nice work @kilhyeonjun.

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the focused classifier and credential-pool work. The reported gap is real on current main: HTTP 400 reaches _classify_400() at agent/error_classifier.py:1107 and currently falls through to generic format_error at :1430.

Problems

  • agent/credential_pool.py:1948 in this diff falls back to _current_unlocked() when no credential identity is supplied. That contradicts the stated fail-closed boundary and can mark an unrelated credential; current recovery explicitly documents why the shared current pointer is unsafe for attribution in agent/agent_runtime_helpers.py:980-990.
  • The persisted marker is not used by normal selection. Current CredentialPool.select() accepts no model (agent/credential_pool.py:1593) and _available_entries() does not inspect unavailable_models (:1603-1726), so reloading the pool can select the previously rejected account for the same model.

Suggested changes

  • Require an exactly resolved credential identity before writing a marker, with a no-hint regression test.
  • Thread the requested model through normal selection and add a reload test proving model-specific exclusion while retaining eligibility for another model.
  • Cover the all-marked case that returns control to provider fallback.

Automated hermes-sweeper review.

Comment thread agent/credential_pool.py
if entry is None and identity_supplied:
return None
entry = entry or self._current_unlocked()
if entry is None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fallback violates the stated fail-closed rule: when neither hint identifies the issuer, _current_unlocked() is shared mutable state and may be a different healthy account. Return None unless an exact ID or key hint resolved the entry.

Comment thread agent/credential_pool.py
self._replace_entry(entry, updated)
self._persist()
candidates = [
candidate for candidate in self._available_entries()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This filters only the immediate recovery call. CredentialPool.select() on current main has no model argument and _available_entries() ignores unavailable_models, so a reloaded pool can select this marker-bearing credential again for the same model. Make the normal selection path model-aware and add a reload regression test.

@teknium1 teknium1 added sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 30, 2026
@GottZ

GottZ commented Aug 3, 2026

Copy link
Copy Markdown

This was generated by AI during triage.

Summary

Twelve PRs address distinct layers of the same Codex model-compatibility failure family: provider/model normalization, auxiliary routing, authenticated and fallback catalogs, error classification, and credential-scoped entitlement recovery. The diffs range from already-landed catalog and routing fixes to open patches that still need current-main updates or correctness fixes before they can close the remaining issues.

Related pull requests

Duplicates

#695 and #696 are substantially the same #651 normalization fix and were superseded by 95b1130. #17544 and #17765 address the same #17533 auxiliary fallback failure, with #17765 replacing the stale-constant approach. #23175 and #33424 remove the same dead fallback slugs for #23097, with #33424 landed; #23189 covers the separate live-catalog synthesis path. The catalog portion of #69022 duplicates #61665, while its classifier portion overlaps the narrower entitlement classification in #71973.

Suggested consolidation

Author action: rebase onto main, or split out the part that can merge. For #71973, first resolve the blocking contributor review by removing _current_unlocked() attribution fallback, threading the requested model through normal and reloaded pool selection, and adding a reload regression test; keep its exact classifier and model-scoped reset behavior as the salvage path. Keep #61665 open as the focused #61660 catalog correction, keep #23189 open for authoritative live-catalog handling after its stale fallback test is updated, and keep #52509 open only after its direct cli.py:5338 fallback and current GPT-5.6 expectations are fixed. The already-closed duplicate chains remain #695/#69695b1130, #17544#17765, and #23175#33424/e8955f222cecb6ed7ac3f0c541b9b5b02d22843f; #69022 can remain closed, with only its focused classifier evidence salvaged where useful.

Complex graph

flowchart LR
    classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
    classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
    classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
    classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
    classDef best stroke-width:3px,stroke:#b45309
    classDef target stroke-width:3px,stroke:#4338ca
    I61660(["issue #61660 (open)"])
    I71970(["issue #71970 (open)"])
    P71973["PR #71973 (open)"]
    P71973 -.->|partial| I61660
    P71973 -->|best fix| I71970
    class I61660 open
    class I71970 open
    class P71973 open
    class P71973 best
    class P71973 target
    click I61660 "https://github.com/NousResearch/hermes-agent/issues/61660"
    click I71970 "https://github.com/NousResearch/hermes-agent/issues/71970"
    click P71973 "https://github.com/NousResearch/hermes-agent/pull/71973"
Loading

Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed (state tag in the node label).

Cross-PR triage: Reviewed 12 pull requests and 7 issues in this complex. Each diff was read against this issue; Assessment working set: 103 kB of PR diffs, 49 kB of issue/PR text, 25 kB of discussion (28 comments), 24 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists provider/openai OpenAI / Codex Responses API sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/bug Something isn't working

Projects

None yet

5 participants