Skip to content

Adapt quota UI to dynamic Codex rate-limit windows and buckets #1

Description

@Jeakcey

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:

    codex-usage-ball/src/App.tsx

    Lines 1240 to 1253 in 5c8d263

    <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:

{
  "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 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

  1. 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;
    };
  2. Prefer rateLimitsByLimitId when it is present and non-empty. Use rateLimits only as the legacy fallback, or deduplicate the legacy view by limitId.

  3. Convert non-null primary and secondary values into a window array. Treat their position as transport compatibility, not as semantic meaning.

  4. 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.

  5. 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.
  6. Key notification state by bucket ID plus window duration, rather than fixed fiveHour and sevenDay keys.

  7. Preserve unknown bucket IDs. The existing HashMap / Record behavior already helps with forward compatibility and should be retained.

  8. 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.

  9. 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

  1. Legacy response: five-hour primary, weekly secondary, no rateLimitsByLimitId.
  2. Weekly-only response: primary.windowDurationMins = 10080, secondary = null.
  3. Five-hour-only response: primary.windowDurationMins = 300, secondary = null.
  4. Credits-only or spend-control-only response with both quota windows absent.
  5. rateLimitsByLimitId containing:
    • the general Codex bucket;
    • a model-specific bucket;
    • an unknown future limit ID.
  6. A multi-bucket response where rateLimits mirrors one map entry, verifying that it is not displayed twice.
  7. A bucket with an unfamiliar duration such as 60 or 1440 minutes.
  8. Missing limitName, verifying that the opaque limitId remains visible.
  9. Different Plus, Pro, and Business fixtures with different combinations of windows and Credits.
  10. Notification tests verifying that a weekly-only primary window is never called a five-hour window.
  11. A stale saved bucket selection whose ID is absent from the next response.
  12. 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.

Activity

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

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions