Skip to content

[Bug]: prolite account with secondary_window: null is cached as monthlyPercent: 100 and is never selected #8067

Closed

Description

@smdk000

Summary

A ChatGPT prolite pool account that has a healthy weekly window (used_percent: 0) is cached as monthlyPercent: 100 and therefore classified as exhausted by isCodexQuotaExhausted(). The account is never selected for routing — 0 out of 3554 requests over 24h — even though it is selected and pinned as the active pool account.

Environment

  • opencodex 2.95.0 (also reproduced on 2.93.0)
  • macOS 27.0 / Apple Silicon (M4 Max)
  • Codex client: bundled app-server, routes through openai_base_url = http://127.0.0.1:10100/v1
  • Plan in question: prolite (was free before the plan upgrade)

Upstream truth (GET https://chatgpt.com/backend-api/wham/usage)

plan_type        : prolite
allowed          : true
limit_reached    : false
primary_window   : used_percent = 0, limit_window_seconds = 604800, reset_at = 2026-11-07T14:44:40Z
secondary_window : null

The account is fresh and unused.

What opencodex stores (~/.opencodex/codex-quota-cache.json)

"chatgpt-…8970810465": {
  "weeklyPercent": 0,
  "monthlyPercent": 100,
  "monthlyIsPrimaryWindow": true,
  ...
}

monthlyPercent: 100 has no upstream source — secondary_window is null. It looks synthesized when the secondary window is absent.

Why that blocks the account

src/codex/quota.ts:

const CODEX_EXHAUSTED_USAGE_PERCENT = 100;
// weekly-plan branch checks: [weeklyPercent, monthlyPercent, shortPercent]
return values.some(v => typeof v === "number" && v >= CODEX_EXHAUSTED_USAGE_PERCENT);

weeklyPercent is 0, but the synthesized monthlyPercent: 100 is enough to mark the account exhausted, so it is skipped during pool selection.

Notably the main account carries the same phantom monthlyPercent: 100, but it is rescued one line earlier:

if (allowCredits && hasSpendableCodexCredits(quota)) return false;

The main account has spendable credits, so it survives. A pool account with hasCredits: false (prolite/free) has no such rescue and stays blocked forever.

Reproduction

  1. Add a ChatGPT account on a plan whose usage payload has secondary_window: null (here: prolite).
  2. Run ocx account refresh openai → output shows weekly 0% … monthly 100%.
  3. ocx account list → the account shows selected.
  4. Send traffic through the proxy → every request goes to main; the pool account receives none (verified via usage.jsonl accountLogLabel: 3554 × openai, 0 × this account).
  5. ocx account refresh openai rewrites the same monthlyPercent: 100; restarting the service does not help.

What I tried that does not help

  • ocx account refresh openai (re-writes 100)
  • re-pinning / setting priority (activeCodexAccountPinned already points at this account)
  • ocx account use-remaining openai <id> on — codexAccountUsesRemainingQuota() only feeds auto-switch thresholds and the main-account hard lock, it does not affect isCodexQuotaExhausted()

Suggestion

Either:

  1. Do not emit monthlyPercent when the payload's secondary_window is null (the account has no monthly window at all), or
  2. Track absence explicitly the way shortWindowAbsent already does, and exclude an absent window from the exhaustion check, or
  3. Only treat a window as governing when it is the plan's primary window (monthlyIsPrimaryWindow) and actually observed.

Option 1 looks closest to what quota.ts already does for shortWindowAbsent.

Related

Activity

  1. github-actions commented on Nov 1, 2026

    @github-actions
    Contributor

    Automated translation bookkeeping — detected language: English.

  2. added
    bugSomething isn't working
    account-poolOAuth, credentials, Codex pool, quota, failover, plans
    on Nov 1, 2026
  3. smdk000 commented on Nov 1, 2026

    @smdk000
    Author

    Partial fix confirmed on 2.96.0 — main path is clean, the pool path still synthesizes monthlyPercent

    Retested on 2.96.0 (same machine, macOS 27.0 / arm64) right after release.

    The same ChatGPT prolite subscription is present twice — once as __main__ (it is also the account signed in to the Codex app) and once as a stored pool entry. After ocx account refresh openai the cache now looks like this:

    entry weeklyPercent monthlyPercent monthlyIsPrimaryWindow
    __main__ 30 (absent) —
    chatgpt-…8970810465 (pool) 30 100 true
    chatgpt-…5502746301 (plus) 100 (absent) —

    Upstream for the prolite account is unchanged:

    plan_type        : prolite
    allowed          : true
    limit_reached    : false
    primary_window   : used_percent = 30, limit_window_seconds = 604800
    secondary_window : null
    

    What this shows

    • The main-account writer no longer invents the missing secondary window: __main__ has no monthlyPercent at all. That is exactly the change that removed the false "reached its usage limit" refusals for the main path — 320/326 requests OK over the last 3 hours, no phantom-window refusals.
    • The pool writer still stores monthlyPercent: 100, monthlyIsPrimaryWindow: true for the same account, so that entry is still classified exhausted by isCodexQuotaExhausted() and is never selected.

    In this configuration the user-visible symptom is gone only because the blocked pool entry duplicates the main login. Add any second account on a …_prolite plan and it should reproduce exactly as reported.

    If the two writers share a parser, the fix just needs to reach the pool path as well; if the pool quota comes from the response headers (x-codex-secondary-used-percent / x-codex-secondary-window-minutes) rather than the usage payload, that header path is the one still emitting the synthesized 100.

    Happy to re-test any build — I can check both entries within a minute of ocx account refresh.

  4. lidge-jun commented on Nov 3, 2026

    @lidge-jun
    Owner

    Fixed on dev by #8120 (e35572f): a pool account kept a stale monthly 100

  5. smdk000 commented on Nov 3, 2026

    @smdk000
    Author

    cc @lidge-jun — thanks for landing #8120. One follow-up before 2.97.0 ships:

    Tried the dev fix on top of the published 2.96.0 — pool entry still keeps monthlyPercent: 100

    Since 2.97.0 is not on npm yet, I applied the code half of #8120 (e35572f, src/codex/quota.ts +22/−6) directly to the installed 2.96.0. The published package ships src/ and runs it through bun, so a patched file takes effect after a restart. All four hunks applied cleanly and the proxy boots normally (port up in 4 s).

    After restart + ocx account refresh openai, the carried tuple is still there:

    entry weeklyPercent monthlyPercent monthlyIsPrimaryWindow
    chatgpt-…8970810465 (pool, prolite) 95 100 true
    __main__ (qq.com, pro — different account, now the app login) 84 (absent) —
    chatgpt-…5502746301 (plus) 100 (absent) —

    So the main-account side is already clean on 2.96.0, but the pool side does not retire on a refresh.

    Why it may not be taking effect

    The new gate is:

    return observation.shortWindowAbsent === true
      && !isExplicitMonthlyWindow(primary) && !isExplicitMonthlyWindow(secondary) && tertiary == null
      ? { ...observation, monthlyWindowAbsent: true }
      : observation;

    For this account WHAM returns a single 7-day primary_window and secondary_window: null, so isExplicitMonthlyWindow(primary) should be false (604800 s < 28 d) and tertiary is null. That leaves observation.shortWindowAbsent === true as the only thing that can fail — or the merge never reaches the new else if because the earlier quota.monthlyIsPrimaryWindow === true branch runs first while the stored entry still carries monthlyIsPrimaryWindow: true.

    Questions:

    1. Does retirement require a fresh observation (a probe that carries the full WHAM topology) rather than ocx account refresh? If so, a pool account that is never selected may never get one — which is the exact state this bug leaves it in.
    2. Is the monthlyIsPrimaryWindow: true carried from the previous Free-plan reading enough to route the merge into the first branch and skip the new gate?
    3. Does a plain refresh run poolWhamPolicyObservation? I traced pool-quota-probe.ts:380 → setAccountQuotaFromParsed(..., poolWhamPolicyObservation(data, quota), ...), so it should — happy to be told I patched the wrong copy or missed a second call site.

    I reverted to a pristine 2.96.0 afterwards, so the machine is back to a clean state. Happy to retest any dev build or a 2.97 preview within a minute — I can report both entries right after ocx account refresh openai.

    (Also noted while testing, unrelated: this prolite account is at 95 % of its weekly window with reset on 2026-11-07, so it needs to be usable soon.)

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