Thank you for building and maintaining Codex Usage Ball. It is a very useful way to keep Codex limits visible without repeatedly opening the usage dashboard.
Background
Recent Codex app-server responses do not always contain the historical combination of a five-hour primary window and a weekly secondary window.
The official Codex app-server documentation describes:
rateLimits as a backward-compatible single-bucket view;
rateLimitsByLimitId as a multi-bucket view keyed by an opaque metered limitId;
primary and secondary as nullable windows;
windowDurationMins as the authoritative window duration;
- optional plan, Credits, spend-control, reset-credit, and model-specific data.
Reference: https://learn.chatgpt.com/docs/app-server#6-rate-limits-chatgpt
The public Codex pricing documentation still describes a shared five-hour window and says that additional weekly limits may apply. It also documents separate limits for some models. Therefore, I do not think it would be accurate to claim that the five-hour limit has been permanently removed. The observed difference may be temporary, account-specific, plan-specific, or part of a staged rollout.
Reference: https://learn.chatgpt.com/docs/pricing#what-are-the-usage-limits-for-my-plan
Current problem
I reviewed the current main branch at commit 5c8d2636525ab1b1f930fc7971504aaeab1ae934.
The project already has useful forward-compatible behavior:
- the Rust backend keeps
rateLimitsByLimitId in a HashMap;
- the React UI iterates unknown bucket IDs instead of using a closed enum;
formatWindowName() derives a label from windowDurationMins.
However, some UI behavior still assigns fixed semantics based on the window position:
- the floating ball always treats
primary as the five-hour window and secondary as the seven-day window;
- low-limit notifications use fixed
fiveHour and sevenDay keys and labels;
- the main panel always renders two window cards, including a placeholder for an absent window;
- documentation describes the two rings as permanently representing five-hour and seven-day limits;
- the backward-compatible
rateLimits view is added alongside every entry from rateLimitsByLimitId, which can duplicate the same default bucket.
Relevant code:
- Window and response types:
|
type RateLimitWindow = { |
|
usedPercent: number; |
|
windowDurationMins: number | null; |
|
resetsAt: number | null; |
|
}; |
|
|
|
type CreditsSnapshot = { |
|
balance: string | null; |
|
hasCredits: boolean; |
|
unlimited: boolean; |
|
}; |
|
|
|
type RateLimitSnapshot = { |
|
credits: CreditsSnapshot | null; |
|
limitId: string | null; |
|
limitName: string | null; |
|
planType: string | null; |
|
primary: RateLimitWindow | null; |
|
rateLimitReachedType: string | null; |
|
secondary: RateLimitWindow | null; |
|
}; |
|
|
|
type RateLimitsResponse = { |
|
rateLimits: RateLimitSnapshot; |
|
rateLimitsByLimitId: Record<string, RateLimitSnapshot> | null; |
|
}; |
- Bucket resolution:
|
function remainingPercent(windowData: RateLimitWindow | null) { |
|
if (!windowData) return null; |
|
return 100 - clampPercent(windowData.usedPercent); |
|
} |
|
|
|
function limitName(limit: RateLimitSnapshot | null, text: Copy) { |
|
return limit?.limitName || limit?.limitId || text.defaultRateLimitBucket; |
|
} |
|
|
|
function resolveActiveLimit( |
|
usage: RateLimitsResponse | null, |
|
activeRateLimitId: string, |
|
) { |
|
if (!usage) return null; |
|
if ( |
|
activeRateLimitId && |
|
activeRateLimitId !== DEFAULT_RATE_LIMIT_ID && |
|
usage.rateLimitsByLimitId?.[activeRateLimitId] |
|
) { |
|
return usage.rateLimitsByLimitId[activeRateLimitId]; |
|
} |
|
|
|
return usage.rateLimits; |
|
} |
|
|
|
function resolveRateLimitBucketOptions( |
|
usage: RateLimitsResponse | null, |
|
text: Copy, |
|
) { |
|
const options: RateLimitBucketOption[] = [ |
|
{ id: DEFAULT_RATE_LIMIT_ID, name: text.defaultRateLimitBucket }, |
|
]; |
|
|
|
if (!usage?.rateLimitsByLimitId) return options; |
|
|
|
for (const [limitId, limit] of Object.entries(usage.rateLimitsByLimitId)) { |
|
if (limitId === DEFAULT_RATE_LIMIT_ID) continue; |
|
if (!limit) continue; |
|
options.push({ |
|
id: limitId, |
|
name: limitName(limit, text), |
|
}); |
|
} |
|
|
|
return options; |
- Fixed notification semantics:
|
function useLowLimitNotifications( |
|
activeLimit: RateLimitSnapshot | null, |
|
settings: AppSettings, |
|
text: Copy, |
|
) { |
|
useEffect(() => { |
|
if (!activeLimit) return; |
|
|
|
const threshold = settings.lowNoticeThreshold; |
|
maybeNotifyLowLimit({ |
|
remaining: remainingPercent(activeLimit.primary), |
|
text, |
|
threshold, |
|
windowKey: "fiveHour", |
|
windowName: text.windowFiveHours, |
|
}); |
|
maybeNotifyLowLimit({ |
|
remaining: remainingPercent(activeLimit.secondary), |
|
text, |
|
threshold, |
|
windowKey: "sevenDay", |
|
windowName: text.windowSevenDays, |
|
}); |
|
}, [activeLimit, settings.lowNoticeThreshold, text]); |
- Fixed floating-ball labels:
|
const activeLimit = resolveActiveLimit(usage, settings.activeRateLimitId); |
|
useLowLimitNotifications(activeLimit, settings, text); |
|
const primaryRemaining = remainingPercent(activeLimit?.primary ?? null); |
|
const secondaryRemaining = remainingPercent(activeLimit?.secondary ?? null); |
|
const primaryTone = getTone(primaryRemaining); |
|
const secondaryTone = getTone(secondaryRemaining); |
|
const primaryPercentText = formatBallPercent(primaryRemaining); |
|
const secondaryPercentText = formatBallPercent(secondaryRemaining); |
|
const ballStyle = { |
|
"--ball-primary-progress": `${primaryRemaining ?? 0}`, |
|
"--ball-secondary-progress": `${secondaryRemaining ?? 0}`, |
|
} as CSSProperties; |
|
const ballTitle = `${limitName(activeLimit, text)}:${text.windowFiveHoursShort} ${primaryPercentText} ${text.windowSevenDaysShort} ${secondaryPercentText}`; |
- Fixed two-card rendering:
|
<div className="metrics"> |
|
<WindowMetric |
|
fallbackName={text.shortFallback} |
|
language={settings.language} |
|
text={text} |
|
value={activeLimit?.primary ?? null} |
|
/> |
|
<WindowMetric |
|
fallbackName={text.longFallback} |
|
language={settings.language} |
|
text={text} |
|
value={activeLimit?.secondary ?? null} |
|
/> |
|
</div> |
- Backend response model:
|
#[derive(Debug, Deserialize, Serialize)] |
|
#[serde(rename_all = "camelCase")] |
|
struct CreditsSnapshot { |
|
balance: Option<String>, |
|
has_credits: bool, |
|
unlimited: bool, |
|
} |
|
|
|
#[derive(Debug, Deserialize, Serialize)] |
|
#[serde(rename_all = "camelCase")] |
|
struct RateLimitWindow { |
|
resets_at: Option<i64>, |
|
used_percent: i32, |
|
window_duration_mins: Option<i64>, |
|
} |
|
|
|
#[derive(Debug, Deserialize, Serialize)] |
|
#[serde(rename_all = "camelCase")] |
|
struct RateLimitSnapshot { |
|
credits: Option<CreditsSnapshot>, |
|
limit_id: Option<String>, |
|
limit_name: Option<String>, |
|
plan_type: Option<String>, |
|
primary: Option<RateLimitWindow>, |
|
rate_limit_reached_type: Option<String>, |
|
secondary: Option<RateLimitWindow>, |
|
} |
|
|
|
#[derive(Debug, Deserialize, Serialize)] |
|
#[serde(rename_all = "camelCase")] |
|
struct GetAccountRateLimitsResponse { |
|
rate_limits: RateLimitSnapshot, |
|
rate_limits_by_limit_id: Option<HashMap<String, RateLimitSnapshot>>, |
|
} |
With Codex CLI 0.144.1, I observed a sanitized response shape in which only a weekly window was returned. It occupied primary, while secondary was null.
The values below are placeholders and contain no account identifiers or real usage values:
For this shape, the detailed panel can derive “7-day window” from the duration, but the floating ball still labels the primary value as “5h”. A low-limit notification for the weekly window can also be described as a five-hour notification. The missing secondary window is rendered as another seven-day row with --.
Expected behavior
- Do not assume that
primary always means five hours or that secondary always means weekly.
- Derive every visible window label and notification label from
windowDurationMins.
- Render only windows that were actually returned.
- If no quota window is returned, hide the quota visualization or show a neutral message such as “No quota window returned for this account”; do not imply that the account has exhausted its quota.
- Support a general Codex bucket, weekly-only responses, Credits, spend controls, earned reset credits, and model-specific buckets.
- Preserve unknown
limitId keys and use limitName, then the raw ID, as display fallbacks.
- Avoid showing both the legacy
rateLimits view and its mirrored entry from rateLimitsByLimitId as separate buckets.
- Keep compatibility with older payloads that contain only
rateLimits.primary and rateLimits.secondary.
Suggested implementation
-
Add a normalization layer between the app-server response and the UI.
A normalized bucket could contain:
type DisplayRateLimitBucket = {
id: string;
name: string;
windows: Array<{
usedPercent: number;
windowDurationMins: number | null;
resetsAt: number | null;
}>;
credits: CreditsSnapshot | null;
individualLimit: SpendControlLimitSnapshot | null;
rateLimitReachedType: string | null;
};
-
Prefer rateLimitsByLimitId when it is present and non-empty. Use rateLimits only as the legacy fallback, or deduplicate the legacy view by limitId.
-
Convert non-null primary and secondary values into a window array. Treat their position as transport compatibility, not as semantic meaning.
-
Use one shared duration formatter everywhere:
300 minutes → “5-hour window”
10080 minutes → “Weekly” or “7-day window”
- other values → a generic minute/hour/day label
The floating ball, main panel, accessibility title, and notifications should all use the same formatter.
-
Make the floating ball adaptive:
- zero windows: neutral/unknown state;
- one window: one ring with the actual duration label;
- two windows: two rings based on the returned windows;
- additional model buckets: selectable by their returned name or ID.
-
Key notification state by bucket ID plus window duration, rather than fixed fiveHour and sevenDay keys.
-
Preserve unknown bucket IDs. The existing HashMap / Record behavior already helps with forward compatibility and should be retained.
-
Consider adding optional support for fields currently returned by app-server but not represented in the project response type, especially individualLimit and top-level rateLimitResetCredits.
-
Update the README and product specification so that five-hour and weekly windows are examples of possible windows rather than guaranteed positions.
Compatibility considerations
- Legacy responses containing only
rateLimits should continue to work.
- The historical five-hour
primary plus weekly secondary response should retain the current two-ring appearance.
- When
rateLimitsByLimitId is available, unknown and future IDs should remain visible instead of being filtered through a known-ID list.
- If a previously selected bucket disappears, the UI should fall back to the first returned bucket without losing the unknown bucket ID from storage unnecessarily.
- Missing windows should remain distinguishable from
0% remaining and 100% remaining.
- This change should not be described as confirmation that OpenAI permanently removed the five-hour limit.
Suggested test cases
- Legacy response: five-hour
primary, weekly secondary, no rateLimitsByLimitId.
- Weekly-only response:
primary.windowDurationMins = 10080, secondary = null.
- Five-hour-only response:
primary.windowDurationMins = 300, secondary = null.
- Credits-only or spend-control-only response with both quota windows absent.
rateLimitsByLimitId containing:
- the general Codex bucket;
- a model-specific bucket;
- an unknown future limit ID.
- A multi-bucket response where
rateLimits mirrors one map entry, verifying that it is not displayed twice.
- A bucket with an unfamiliar duration such as 60 or 1440 minutes.
- Missing
limitName, verifying that the opaque limitId remains visible.
- Different Plus, Pro, and Business fixtures with different combinations of windows and Credits.
- Notification tests verifying that a weekly-only
primary window is never called a five-hour window.
- A stale saved bucket selection whose ID is absent from the next response.
rateLimitResetCredits absent, null, count-only, and populated with detail rows.
It may also be useful to move quota parsing and normalization tests away from source-string assertions and test the normalization functions directly with representative fixtures.
Thanks again for maintaining the project. I would be happy to provide additional sanitized response shapes if they would help validate the implementation.
Thank you for building and maintaining Codex Usage Ball. It is a very useful way to keep Codex limits visible without repeatedly opening the usage dashboard.
Background
Recent Codex app-server responses do not always contain the historical combination of a five-hour
primarywindow and a weeklysecondarywindow.The official Codex app-server documentation describes:
rateLimitsas a backward-compatible single-bucket view;rateLimitsByLimitIdas a multi-bucket view keyed by an opaque meteredlimitId;primaryandsecondaryas nullable windows;windowDurationMinsas the authoritative window duration;Reference: https://learn.chatgpt.com/docs/app-server#6-rate-limits-chatgpt
The public Codex pricing documentation still describes a shared five-hour window and says that additional weekly limits may apply. It also documents separate limits for some models. Therefore, I do not think it would be accurate to claim that the five-hour limit has been permanently removed. The observed difference may be temporary, account-specific, plan-specific, or part of a staged rollout.
Reference: https://learn.chatgpt.com/docs/pricing#what-are-the-usage-limits-for-my-plan
Current problem
I reviewed the current
mainbranch at commit5c8d2636525ab1b1f930fc7971504aaeab1ae934.The project already has useful forward-compatible behavior:
rateLimitsByLimitIdin aHashMap;formatWindowName()derives a label fromwindowDurationMins.However, some UI behavior still assigns fixed semantics based on the window position:
primaryas the five-hour window andsecondaryas the seven-day window;fiveHourandsevenDaykeys and labels;rateLimitsview is added alongside every entry fromrateLimitsByLimitId, which can duplicate the same default bucket.Relevant code:
codex-usage-ball/src/App.tsx
Lines 49 to 74 in 5c8d263
codex-usage-ball/src/App.tsx
Lines 365 to 409 in 5c8d263
codex-usage-ball/src/App.tsx
Lines 510 to 533 in 5c8d263
codex-usage-ball/src/App.tsx
Lines 983 to 995 in 5c8d263
codex-usage-ball/src/App.tsx
Lines 1240 to 1253 in 5c8d263
codex-usage-ball/src-tauri/src/lib.rs
Lines 345 to 378 in 5c8d263
With Codex CLI
0.144.1, I observed a sanitized response shape in which only a weekly window was returned. It occupiedprimary, whilesecondarywasnull.The values below are placeholders and contain no account identifiers or real usage values:
{ "rateLimits": { "limitId": "<redacted-limit-id>", "limitName": null, "planType": "<redacted-plan>", "primary": { "usedPercent": "<redacted-number>", "windowDurationMins": 10080, "resetsAt": "<redacted-timestamp>" }, "secondary": null, "credits": { "balance": "<redacted>", "hasCredits": "<redacted-boolean>", "unlimited": "<redacted-boolean>" }, "rateLimitReachedType": null }, "rateLimitsByLimitId": { "<redacted-limit-id>": { "...": "same bucket shape" } }, "rateLimitResetCredits": { "availableCount": "<redacted-number>", "credits": "<redacted-array>" } }For this shape, the detailed panel can derive “7-day window” from the duration, but the floating ball still labels the
primaryvalue as “5h”. A low-limit notification for the weekly window can also be described as a five-hour notification. The missingsecondarywindow is rendered as another seven-day row with--.Expected behavior
primaryalways means five hours or thatsecondaryalways means weekly.windowDurationMins.limitIdkeys and uselimitName, then the raw ID, as display fallbacks.rateLimitsview and its mirrored entry fromrateLimitsByLimitIdas separate buckets.rateLimits.primaryandrateLimits.secondary.Suggested implementation
Add a normalization layer between the app-server response and the UI.
A normalized bucket could contain:
Prefer
rateLimitsByLimitIdwhen it is present and non-empty. UserateLimitsonly as the legacy fallback, or deduplicate the legacy view bylimitId.Convert non-null
primaryandsecondaryvalues into a window array. Treat their position as transport compatibility, not as semantic meaning.Use one shared duration formatter everywhere:
300minutes → “5-hour window”10080minutes → “Weekly” or “7-day window”The floating ball, main panel, accessibility title, and notifications should all use the same formatter.
Make the floating ball adaptive:
Key notification state by bucket ID plus window duration, rather than fixed
fiveHourandsevenDaykeys.Preserve unknown bucket IDs. The existing
HashMap/Recordbehavior already helps with forward compatibility and should be retained.Consider adding optional support for fields currently returned by app-server but not represented in the project response type, especially
individualLimitand top-levelrateLimitResetCredits.Update the README and product specification so that five-hour and weekly windows are examples of possible windows rather than guaranteed positions.
Compatibility considerations
rateLimitsshould continue to work.primaryplus weeklysecondaryresponse should retain the current two-ring appearance.rateLimitsByLimitIdis available, unknown and future IDs should remain visible instead of being filtered through a known-ID list.0% remainingand100% remaining.Suggested test cases
primary, weeklysecondary, norateLimitsByLimitId.primary.windowDurationMins = 10080,secondary = null.primary.windowDurationMins = 300,secondary = null.rateLimitsByLimitIdcontaining:rateLimitsmirrors one map entry, verifying that it is not displayed twice.limitName, verifying that the opaquelimitIdremains visible.primarywindow is never called a five-hour window.rateLimitResetCreditsabsent,null, count-only, and populated with detail rows.It may also be useful to move quota parsing and normalization tests away from source-string assertions and test the normalization functions directly with representative fixtures.
Thanks again for maintaining the project. I would be happy to provide additional sanitized response shapes if they would help validate the implementation.