Skip to content

[Bug]: stored monthlyPercent: 100 with no upstream source is never retired for the main account (pool path fixed by #8120) #8596

Open

Description

@cottonthread

Client or integration

Codex App / Codex CLI through the local proxy (native ChatGPT main-account route),
verified with a direct POST /v1/responses call.

Area

Authentication and account pool

Summary

On opencodex 2.100.2 (npm latest), the main account is permanently
blocked by a stored monthlyPercent: 100 tuple that has no upstream source.

GET https://chatgpt.com/backend-api/wham/usage for this account returns
secondary_window: null and primary_window.used_percent: 1. The cache
nevertheless holds monthlyPercent: 100 with monthlyIsPrimaryWindow: true, so
isCodexQuotaExhausted() treats the account as spent and every native request is
refused locally with the credit message, attempts: 0, in under 15 ms.

This is the main-account half of #8067 / #8120. #8120 retired a carried monthly
window for pool accounts only and its own description states:

The marker is derived only in poolWhamPolicyObservation ...
Main-account observations keep their existing monthly retention rules.

That exclusion is what this report is about. It is not a request to re-open
#8067; the pool path is genuinely fixed. The main path was deliberately left on
the old retention rule, and on that path the consequence is not a mis-sorted bar
but a main account the proxy refuses to use at all, with a message that blames
the upstream.

Forced quota refreshes advance updatedAt and correctly refresh weeklyPercent,
but leave the phantom monthly tuple byte-identical, so nothing on the request
path can ever clear it. Removing the six monthly fields by hand (with the proxy
stopped) restored the account immediately: the next request returned HTTP 200 and
was attributed to main in usage.jsonl.

Reproduction

  1. Native main ChatGPT login whose usage payload has no secondary window
    (here: plan_type: "plus", primary_window.limit_window_seconds: 604800).

  2. The cache holds a monthly tuple. Here it read:

    ~/.opencodex/codex-quota-cache.json -> quotas.__main__:

    {
      "updatedAt": 1794480955534,
      "credits": { "observedAt": 1794480955533, "hasCredits": false,
                   "unlimited": false, "overageLimitReached": false,
                   "allowed": true, "balance": 0 },
      "weeklyPercent": 1,
      "weeklyResetAt": 1794921556,
      "monthlyPercent": 100,
      "monthlyResetAt": 1795237830,
      "monthlyIsPrimaryWindow": true,
      "resetCredits": 0
    }

    mainPolicyQuota.quota held the same fields.

  3. ocx account refresh openai -> stdout prints only
    main <email> plus weekly 1% resets 2026-11-17T13:19:16.000Z.
    updatedAt moves 1794480796128 -> 1794480955534 and weeklyPercent refreshes
    to 1, while monthlyPercent / monthlyResetAt / monthlyIsPrimaryWindow do
    not change. A complete WHAM observation with secondary_window: null was
    therefore received and did not retire the tuple.

  4. Send one native request:

    curl -s -o /tmp/r.json -w 'HTTP=%{http_code} %{time_total}s\n' \
      -X POST http://127.0.0.1:10100/v1/responses \
      -H "x-opencodex-api-key: $KEY" -H 'Content-Type: application/json' \
      -d '{"model":"gpt-6-astra","input":[{"role":"user","content":[{"type":"input_text","text":"say OK"}]}],"max_output_tokens":16,"stream":false}'

    -> HTTP=429 0.013s, body:
    Codex main account reached its usage limit, and spending ChatGPT credits is off or no fresh spendable balance is available. ...

    Corresponding usage.jsonl row: status 429, durationMs 12,
    failureStage: "headers-only", failureCause: "rate-limit",
    errorCode: "rate_limit_exceeded", attempts: 0,
    routeDecision.selected: { provider: "openai", model: "gpt-6-astra", reason: "native-family" }.

  5. Remove the six monthly fields from both quotas.__main__ and
    mainPolicyQuota.quota with the proxy stopped, then start it.

    -> the same request returns HTTP=200 2.27s, output_text: "OK", and the
    usage.jsonl row carries accountLogLabel: "main". has("monthlyPercent")
    is false for both objects, and a later cache rewrite at 12:07:14 (triggered
    by the successful request) does not reintroduce it.

Expected behaviour

Option A (mirror #8120): prove monthly-window absence for the main account too.
When a complete, authenticated main WHAM observation reports no window with a
monthly duration and no tertiary window, the stored monthly tuple should be
retired -- the same proof #8120 already uses for pool accounts via
poolWhamPolicyObservation.

Option B (narrower): if the main policy snapshot must keep its evidence for
hard-lock purposes, then at least stop using a monthly tuple with no observed
upstream counterpart
as an exhaustion verdict for request admission, and make
the refusal say that a local reading is in conflict with upstream rather than
claiming the upstream limit was reached.

Either way, a locally refused request should not tell the operator that the
upstream usage limit was reached when the recorded blocking window has no
upstream counterpart at all.

Actual behaviour

monthlyPercent: 100 is carried forward across every fresh observation, the
account is classified as exhausted, and the refusal surfaces as an ordinary
credit/quota 429. There is no CLI command, setting or refresh that clears it.

Impact note: with the main account pinned to the end of the selection order as a
reserve, the failure only appears once every pool account is drained -- which
makes it look intermittent, and it lasts until the stored tuple is edited by hand.

Why the existing fixes do not cover this

Workaround

Stop the proxy first -- a plain edit while it runs is overwritten within ~1 s
because the running process rewrites the file from memory (observed: the desktop
supervisor restarted the proxy and read the old value at 12:04:34, the edit
landed at 12:04:37, the file was rewritten at 12:04:38). Run the three steps as
one command chain so the desktop supervisor cannot start the proxy in the gap:

C=~/.opencodex/codex-quota-cache.json
cp "$C" "$C.bak.$(date +%Y%m%d%H%M%S)"
ocx stop && \
  jq -c 'del(.quotas["__main__"].monthlyPercent,
             .quotas["__main__"].monthlyResetAt,
             .quotas["__main__"].monthlyIsPrimaryWindow,
             .mainPolicyQuota.quota.monthlyPercent,
             .mainPolicyQuota.quota.monthlyResetAt,
             .mainPolicyQuota.quota.monthlyIsPrimaryWindow)' \
     "$C" > /tmp/q.json && mv /tmp/q.json "$C" && \
  ocx start

Do not delete the whole file: that discards every account's readings and history.
Do not use ocx account reset-credits --consume for this -- it redeems a real
reset ticket and mutates upstream billing state.

Detecting a recurrence:

jq -r '.quotas | to_entries[]
  | "\(.key)\t weekly=\(.value.weeklyPercent//"-")\t monthly=\(.value.monthlyPercent//"-")"' \
  ~/.opencodex/codex-quota-cache.json

A monthly=100 on a row whose upstream payload has secondary_window: null is
this bug.

Version

@bitkyc08/opencodex 2.100.2, OpenCodex Desktop 2.100.2. npm dist-tags at
report time: latest=2.100.2, preview=2.99.0-preview.20261108.

Operating system

macOS (Apple Silicon).

Provider and model

openai native ChatGPT main login (authMode: "forward", plan plus);
reproduced with gpt-6-astra. The inconsistent meter is account-level, not
model-specific: upstream model_usage reported
{"gpt-6-astra": {"available": true, "available_at": null, "credits_would_enable": false}} at the same moment the request was refused
locally.

Logs or error output

# upstream truth, same minute as the refusal (identifiers removed)
GET /backend-api/wham/usage
{
  "plan_type": "plus",
  "rate_limit": { "allowed": true, "limit_reached": false,
    "primary_window": { "used_percent": 1, "limit_window_seconds": 604800,
                        "reset_after_seconds": 440656, "reset_at": 1794921556 },
    "secondary_window": null },
  "model_usage": { "gpt-6-astra": { "available": true, "available_at": null,
                                    "credits_would_enable": false } },
  "credits": { "has_credits": false, "unlimited": false,
               "overage_limit_reached": false, "balance": "0" },
  "spend_control": { "reached": false, "individual_limit": null },
  "rate_limit_reached_type": null
}

# GET /api/codex-auth/accounts  (main row)
"mainAccountHardLock": { "enabled": false, "state": "off",
                         "thresholds": { "short": 90, "long": 98 } }
"quota": { "resetCredits": 0, "weeklyPercent": 1, "weeklyResetAt": 1794921556,
           "updatedAt": 1794480796128,
           "credits": { "hasCredits": false, "allowed": true, "balance": 0 } }

# GET /api/codex-auth/quota  -> .quotas.__main__  (live process view, so the tuple
# is in memory as well as on disk)
{"updatedAt":1794480955534,"weeklyPercent":1,"weeklyResetAt":1794921556,
 "monthlyPercent":100,"monthlyResetAt":1795237830,
 "monthlyIsPrimaryWindow":true,"resetCredits":0}

# usage.jsonl, the refused request
{"status":429,"model":"gpt-6-astra","durationMs":12,
 "failureStage":"headers-only","failureCause":"rate-limit",
 "errorCode":"rate_limit_exceeded","attempts":0,
 "admissionKind":"loopback",
 "routeDecision":{"routeKind":"native",
   "selected":{"provider":"openai","model":"gpt-6-astra","reason":"native-family"}}}

# before / after the manual fix
BEFORE: file has_monthly=true   policy has_monthly=true   request -> HTTP=429 0.013s
AFTER:  file has_monthly=false  policy has_monthly=false  request -> HTTP=200 2.27s
        usage.jsonl accountLogLabel: "main"

No credentials, tokens, emails, account identifiers, cookies, request bodies or
conversation content are included. mainAccountHardLock.enabled is false on
this install and creditsAfterLimit is false, so neither the hard lock nor the
credits-consent policy can be the cause here.

Redacted configuration

{
  "providers": { "openai": { "adapter": "openai-responses",
                             "baseUrl": "https://chatgpt.com/backend-api/codex",
                             "codexAccountMode": "pool",
                             "authMode": "forward" } },
  "codexMainAccountHardLock": false,
  "accountPoolStrategy": "quota"
}

The main account is __main__ and is not listed in codexAccounts. Trigger
context: both stored pool accounts were simultaneously exhausted (one at
shortPercent: 100, one at weeklyPercent: 100), so traffic fell through to the
main account and hit the stale tuple. The defect itself does not require a pool --
a single-account setup would refuse every request.

Checks

Activity

  1. added
    bugSomething isn't working
    account-poolOAuth, credentials, Codex pool, quota, failover, plans
    on Nov 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    account-poolOAuth, credentials, Codex pool, quota, failover, plansbugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions