Skip to content

Add opt-in rejection-only quota recovery with automatic reset-credit redemption #657

Description

@luvs01

Area

Multiple areas

What are you trying to accomplish?

Run Codex account recovery from the authoritative upstream rejection, rather than from advisory WHAM usage percentages, and transparently continue the same unexposed turn.

Codex can continue accepting work even when a displayed usage window is at or above 100%. In this mode OpenCodex should keep the selected/affined account until the upstream server actually rejects a request for exhausted usage. On that verified rejection, the user should be able to choose one of two recovery priorities:

  1. Alternate first — retry the same request on an eligible account first; if no alternate succeeds, optionally redeem one reset credit on the original account and retry it once.
  2. Reset first — optionally redeem one reset credit on the current account and retry the same account first; if no credit is available or the reset does not recover it, try an eligible alternate.

The user should not need to type “retry” or reconstruct the current task.

What prevents this today?

Current dev already contains useful pieces, but not the complete policy:

  • autoSwitchThreshold: 0 disables percentage-driven switching, so values at or above 100% can be ignored.
  • Issue #584 / PR #585 adds one same-request alternate-account retry for a pre-stream HTTP 429/402. The gate is status-based, so it does not by itself prove that a reset credit is appropriate.
  • Reset-credit balance and manual redemption already exist for __main__ and Pool accounts, but redemption is initiated separately from the failed turn.
  • If no alternate is eligible, the quota rejection is still returned to Codex. There is no opt-in path that consumes a real upstream reset credit and replays that same request.

Several superficially similar failures already have different handling and must not be collapsed into “spend a reset credit”:

Upstream signal Current dev behavior Required behavior in this feature
Verified usage_limit_exceeded / insufficient_quota, or an equivalent reset-eligible exhaustion response Pre-stream 429/402 may retry once on an alternate account Apply the configured alternate-first or reset-first order; at most one automatic credit per logical turn
Generic 429, throttling, or Retry-After without semantic quota exhaustion Pre-stream status currently qualifies for alternate-account retry; client backoff metadata is preserved/synthesized It may keep existing rate-limit retry/failover behavior, but must not redeem a reset credit without verified exhaustion
503 / server_is_overloaded / “server is busy” / “temporarily unavailable” Existing transient path retries the same replay-safe request with backoff; 500/502/503/504/520/521/522 are covered, and later account failover is governed separately by upstreamFailoverThreshold Keep this transient path separate. Never consume a reset credit. If alternate-after-transient-retries is ever offered, expose it as a separate option
Connection reset / stale keep-alive socket Existing narrow connection retry Keep unchanged; never consume a credit
401/403 credential or permission failure Mark for reauthentication / permission handling Keep separate; never consume a credit
Failure after any response/tool event became visible Terminal or client-visible failure Never auto-replay and never consume a credit for replay, because duplicate output or tool side effects are possible

What should OpenCodex do?

Add an explicit, opt-in quota recovery policy, for example:

{
  "codexQuotaRecovery": {
    "trigger": "upstream-rejection",
    "priority": "alternate-first",
    "autoRedeemResetCredit": true,
    "naturalResetPolicy": {
      "fiveHour": {
        "waitIfResetWithinMinutes": 30,
        "priorityAfterWait": "alternate-first"
      },
      "weekly": {
        "waitIfResetWithinMinutes": 5,
        "priorityAfterWait": "reset-first"
      },
      "graceSeconds": 5,
      "unknownWindow": "alternate-only"
    }
  }
}

The minute values above are examples, not proposed defaults, and 0 disables waiting for that window. This lets a near 5-hour reset wait and resume on the same account, while a weekly exhaustion normally proceeds to alternate/reset-credit recovery. A per-window priorityAfterWait may override the global recovery priority when waiting is disabled, ineligible, cancelled before dispatch, or does not clear the blocker.

priority should accept at least alternate-first and reset-first. Automatic credit redemption must default to off because a reset credit is scarce and irreversible. Once the user explicitly enables it and selects a priority, recovery should run without a per-failure confirmation dialog.

Bounded natural-reset waiting

After a verified pre-stream quota-exhaustion rejection, force-refresh the authoritative quota snapshot and classify every blocking window from its limit_window_seconds and reset_at, not from primary_window / secondary_window position. The recovery path must retain this raw window identity; normalized weekly/monthly fields alone are not sufficient to distinguish a 5-hour window reliably.

Allow a different maximum wait for each window. A practical setup can wait longer for a near 5-hour reset, while weekly exhaustion normally uses the configured alternate/reset-credit flow. This remains rejection-triggered: percentages and reset clocks inform the recovery choice only after the upstream refusal; they never cause proactive switching.

Wait only when every exhausted blocker has a recognized duration, a fresh future reset_at, and is within its own configured cutoff. Wake at the latest blocking reset plus a small grace period; an earlier reset is not enough when another exhausted window would still block the request. Missing, stale, cached-only, or unrecognized window data must not start a timer or automatically spend a credit.

The wait is bounded and in-memory while the original client request remains attached. A protocol-safe keepalive may be used where the client transport requires it, but it must not emit assistant output, response/tool events, terminal events, or any other visible effect. Explicit cancellation, client disconnect, proxy shutdown, or app restart cancels the pending wait and must never cause a later background dispatch, replay, or credit redemption.

On wake, refresh quota again and probe the original account once. If all blockers cleared, replay the same parsed request on that account. If not, continue with the configured per-window/global alternate/reset-credit priority under the existing one-turn bounds. Immediately before any credit redemption, refresh once more and skip redemption if the blocking generation changed or cleared naturally.

Required flow and safeguards:

  • Do not use WHAM percentages or a local usage score as the trigger in upstream-rejection mode. The account may remain selected above 100% until the server refuses it.
  • Before spending a credit, inspect a bounded clone of the pre-stream failure and require a semantic exhaustion signal such as structured usage_limit_exceeded / insufficient_quota or another explicitly allow-listed reset-eligible code. HTTP 429 alone is insufficient.
  • Preserve current handling for generic rate limiting, server overload/busy responses, transient 5xx, connection failures, authentication errors and permissions as described above.
  • Support both __main__ and Pool accounts, using the existing reset-credit endpoint and its outcomes: reset, nothing_to_reset, no_credit, and already_redeemed.
  • For reset or idempotent already_redeemed, refresh the account quota/credit snapshot, clear only the matching exhaustion cooldown, and replay the original request once on that account.
  • For nothing_to_reset or no_credit, continue to the other configured recovery step (if any) without consuming another credit.
  • Use one single-flight recovery operation per account/exhaustion generation. Concurrent rejected requests must share the redemption result rather than spend several credits.
  • Generate one redemption idempotency key per logical recovery and reuse it if the consume call itself is retried.
  • Bound the entire logical turn: no recursive recovery, no account tried more than once, and no more than one credit consumed automatically.
  • Replay only before any assistant output, response/tool-call event, tool result, terminal event, or other externally visible effect. A protocol-only keepalive that the client ignores does not count as model output, but must not survive cancellation. Cancellation before redemption dispatch must stop recovery; once redemption was sent, report its outcome honestly.
  • Preserve the same parsed request and conversation context. If a request depends on previous_response_id, require successful local expansion or another complete replay source; otherwise fail closed with a clear message instead of continuing with missing history.
  • Record sanitized diagnostics: trigger class, recovery priority, attempt number, alternate/reset outcome, and final account role. Do not log email, token material, or raw account identifiers.

Example usage or interface

Quota exhaustion recovery

Trigger
  ● Upstream rejection only (ignore displayed usage percentages)

Recovery priority
  ● Switch account first, then use a reset credit
  ○ Use a reset credit first, then switch account

Automatic reset-credit use
  [x] Enabled  (uses at most one credit for one unexposed turn)

Natural reset waiting                         [Enabled]
  5-hour window: wait up to [30] minutes
  Weekly window: wait up to [ 5] minutes
  After an ineligible/unsuccessful wait: use the configured recovery priority
  Unknown or stale reset time: alternate only; never auto-spend a credit

Pending: Waiting for quota reset at 14:35     [Cancel]

Transient server errors
  Existing retry/backoff policy  [Configure separately]

Example near-reset wait sequence:

account A -> verified 5-hour exhaustion before streaming
          -> authoritative reset is 12 minutes away (within configured wait)
          -> keep this turn attached; no model/tool event is emitted
          -> reset time + grace; refresh quota and probe A once
          -> replay the same request on A; no reset credit is consumed

Example alternate-first sequence:

account A -> verified usage_limit_exceeded before streaming
          -> account B unavailable/exhausted
          -> consume one reset credit for account A
          -> refresh A and replay the same request once
          -> current Codex turn continues

Example server-busy sequence:

account A -> 503 server_is_overloaded
          -> existing bounded same-account retry/backoff
          -> no reset credit is consumed

Alternatives or workarounds

  • Turn Auto-switch off (autoSwitchThreshold: 0) and manually retry after a rejection.
  • Manually choose another account after the failed turn.
  • Open the reset-credit dialog/CLI, redeem a credit, then resend or continue the task.
  • Keep proactive percentage-based switching, which can move away from an account even while upstream still accepts requests.

Additional context

  • The desired signal is the upstream server's actual refusal, not a displayed or cached percentage.
  • PR #585 is the foundation for pre-stream same-request alternate-account retry and should be reused rather than reimplemented.
  • Current error classification already distinguishes insufficient_quota, ordinary rate_limit_error, and server_is_overloaded; automatic credit redemption should use that semantic distinction instead of the existing broad 429/402 status gate.
  • ocx account clear-cooldown only clears local proxy state. It is not a substitute for consuming an upstream reset credit.

Checks

  • I searched existing issues and documentation.
  • This request describes a concrete OpenCodex workflow rather than merely naming a desired technology.
  • I removed secrets and personal data.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions