Skip to content

fix(codex): classify quota windows by duration instead of position - #468

Merged
nguyenphutrong merged 2 commits into
nguyenphutrong:masterfrom
giaBaoJS:fix/codex-quota-window-classification
Aug 13, 2026
Merged

fix(codex): classify quota windows by duration instead of position#468
nguyenphutrong merged 2 commits into
nguyenphutrong:masterfrom
giaBaoJS:fix/codex-quota-window-classification

Conversation

@giaBaoJS

Copy link
Copy Markdown
Contributor

Summary

Codex free accounts can return only a weekly window in primary_window (limit_window_seconds: 604800) with secondary_window: null. Quotio's original mapping assumed primary_window == Session and secondary_window == Weekly, so the weekly quota was rendered as a "Session" bucket with a multi-day reset time.

#434 introduced duration-based classification via limit_window_seconds, which fixes the reported payload, but two gaps remained:

  • When limit_window_seconds is missing from a window, classification silently falls back to the positional session/weekly labels, reproducing the exact mislabel from this issue. The reset_after_seconds field that disambiguates this (a 5h session window can never reset days out) was not decoded at all.
  • A monthly-scale window (e.g. 30 days) would be folded into "Weekly" because no monthly kind existed.

There was also no test coverage locking any of this behavior in.

Changes

  • CodexUsageMapper.standardWindowKind now classifies in this order:
    1. limit_window_seconds: <= 1 day -> Session, >= 20 days -> Monthly, >= 6 days -> Weekly
    2. When the window length is missing, a reset_after_seconds horizon beyond one day identifies a Weekly window
    3. Only then fall back to the legacy positional labels
  • Decode reset_after_seconds on WindowSnapshot
  • Add codex-monthly bucket id and its "Monthly" display name
  • Add QuotioTests/CodexUsageMapperTests covering:
    • the exact free-account payload from the issue -> a single Weekly bucket at 85% used, no Session bucket, no fabricated second bucket
    • a paid payload (5h primary + 7-day secondary) -> unchanged Session/Weekly labels
    • secondary_window: null with a genuine 5h primary -> single Session bucket
    • missing limit_window_seconds classified via reset_after_seconds
    • positional fallback preserved when no duration signal exists
    • monthly window classification and duplicate-kind deduplication

Buckets are only emitted for windows the API actually returned, so a weekly-only free account shows exactly one Weekly row.

Validation

  • xcodebuild -project Quotio.xcodeproj -scheme Quotio -configuration Debug build succeeds
  • xcodebuild -project Quotio.xcodeproj -scheme Quotio -configuration Debug test -destination 'platform=macOS': 73 tests passed, 0 failed (7 new)
  • git diff --check clean

Fixes #356

@nguyenphutrong nguyenphutrong left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks — the exact free-account payload from #356 is mapped correctly, paid 5h/7d behavior is preserved, and the regression coverage is useful. I found one standards issue and one scope/correctness concern before this is merge-ready:

  1. The new codex-monthly display value is hardcoded as "Monthly". This is user-facing text, and the repository requires the existing localization path. quota.metric.monthly already exists and is already used by zai-monthly, so please use "quota.metric.monthly".localizedStatic() here and adjust the test accordingly.

  2. Monthly classification is not part of #356 and the >= 20 days threshold is not backed by an upstream payload in the PR. It changes the existing rule where long windows are weekly, while the issue specifically establishes a 7-day weekly window. Please either add real API evidence for this separate bucket/threshold or remove the monthly branch from this fix. The reset_after_seconds fallback should also be documented as a heuristic: it is remaining time, not window length, so it cannot reliably distinguish weekly from monthly and cannot identify a weekly window that is less than a day from reset.

The effective merge with current master builds successfully and all 78 tests pass. The issue payload itself is handled correctly; these requested changes keep the fix localized and evidence-based.

Address review feedback on nguyenphutrong#468:

- Remove the monthly window bucket and its `>= 20 days` threshold. Issue
  nguyenphutrong#356 establishes a 7-day weekly window; a separate monthly bucket is out
  of scope for this fix and belongs in its own change. This also drops the
  `codex-monthly` kind and its hardcoded "Monthly" display name, so no
  unlocalized user-facing string is introduced.
- Document the `reset_after_seconds` fallback as a heuristic. It reports
  the time remaining in a window, not the window's length, so it is only a
  lower bound: it rules out the 5h session window but cannot determine the
  actual window length, and a weekly window less than a day from resetting
  falls through to the positional fallback.

The issue nguyenphutrong#356 payload regression test is unchanged and still green.
@giaBaoJS

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Both points are addressed in 4c1baf7; the PR now only touches CodexUsageMapper.swift and its tests.

2. Monthly classification — removed from this fix.

I did go looking for upstream evidence first, and I want to report honestly that I found some, because it affects how you may want to handle the follow-up.

  • steipete/CodexBar#2592 contains a captured Codex rate_limits payload with primary.window_minutes: 43200 (30 days) and secondary: null on a plan_type: "free" account, with the reporter's dated table showing the same account's primary was 10080 (7 days) on three prior dates. The reproduction they describe is a lapsed-then-resumed Plus subscription.
  • That capture is from the codex CLI's rollout-*.jsonl, not from /wham/usage directly. The two carry the same quantity: CodexBar reads /wham/usage (limit_window_seconds, CodexOAuthUsageFetcher.swift) and normalizes it in CodexReconciledState.swift as windowMinutes: window.limitWindowSeconds / 60. So 43200 minutes is 2_592_000 seconds in our field.
  • CodexBar shipped duration-based classification with a 30-day lane in PR #2600 (merged 2026-08-05). Nihondo/AgentLimits, which also reads /wham/usage, detects the same shape independently.
  • Important caveat: OpenAI does not document this. Using Codex with your ChatGPT plan describes only the rolling 5-hour window and a weekly limit. The 30-day window is undocumented backend behavior seen on free/lapsed accounts.

I still removed the branch, because your scope objection stands on its own and I think it is the right call regardless of the evidence: issue #356 is specifically about a 7-day window arriving in primary_window, the 30-day shape is a distinct upstream variant with a different reproduction, and my original >= 20 days threshold was invented rather than derived from the observed 2_592_000. Removing it also means this PR no longer changes the existing rule that long windows are weekly. A 30-day window still maps to Weekly on this branch — that is master's current behavior, unchanged by this PR, and a pre-existing mislabel rather than a regression I am introducing.

Happy to open a separate issue and PR for the monthly lane citing the above, with its own regression test using the real 2_592_000 value, if you would like it tracked.

1. Localization.

Resolved by the removal: the codex-monthly kind and its hardcoded "Monthly" display name are both gone, so AntigravityQuotaFetcher.swift is now byte-identical to master and this PR introduces no user-facing string at all. The only quoted string left in the diff is the "reset_after_seconds" JSON coding key. If the monthly lane lands later it will use "quota.metric.monthly".localizedStatic() as you specified, matching zai-monthly.

reset_after_seconds fallback — documented as a heuristic.

Rewrote the comment to state the limitation accurately rather than assert it as a fact:

// Heuristic, used only when the authoritative `limit_window_seconds` is
// absent. `reset_after_seconds` is the time REMAINING in the window, not
// the window's length, so it is only ever a lower bound: a horizon of
// more than a day rules out the 5h session window, but it cannot tell how
// long the window actually is, and a weekly window that is less than a day
// from resetting is indistinguishable from a session one and falls through
// to the positional fallback below.

testMissingDurationSignalsFallBackToPositionalLabels now explicitly documents that blind spot as the expected behavior.

Verification. xcodebuild -project Quotio.xcodeproj -scheme Quotio -configuration Debug build → BUILD SUCCEEDED. xcodebuild test -project Quotio.xcodeproj -scheme Quotio -destination 'platform=macOS' → TEST SUCCEEDED. CodexUsageMapperTests is 6 tests (the monthly case removed), all passing, including the unchanged exact-payload regression test from #356.

@nguyenphutrong nguyenphutrong left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

The requested changes are resolved. The speculative monthly classification and hardcoded label are gone, the exact #356 payload is classified from limit_window_seconds, and reset_after_seconds is now used only as a documented conservative lower-bound heuristic with positional fallback for ambiguous cases. The synthetic merge builds and the targeted Codex mapper tests pass.

@nguyenphutrong
nguyenphutrong merged commit fa01ed9 into nguyenphutrong:master Aug 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Codex free quota mislabels weekly window as Session

2 participants