feat: include user_id in rate limit data persisted to session rollout files

Resolved 💬 1 comment Opened Mar 31, 2026 by odrobnik Closed May 10, 2026

feat: include user_id in rate limit data persisted to session rollout files

Problem

Session rollout files (~/.codex/sessions/) contain token_count events with rate_limits data (used percentage, window info, plan type), but there is no indication of which user the quota belongs to.

This matters because quotas are per-user, not per-account. Multiple users on the same ChatGPT Team account each have their own independent rate limits, but they share a single account_id. For example, three Team members sharing a machine all have account_id: "f81d2d74-..." but completely separate quota buckets tied to their individual chatgpt_user_id values.

Today, the only way to determine which user a session belongs to is to inspect ~/.codex/auth.json — but this is a shared mutable file that gets overwritten whenever any Codex session logs in (#6360). If you switch users and resume a session, or have multiple sessions open, auth.json reflects whichever login happened last, not the user of the session you're looking at.

The result: it's impossible to reliably attribute token consumption and quota usage to a specific user by parsing session files alone.

Ideal Solution: Server-side user_id in rate limit responses

The API server already knows exactly which user is making requests — the chatgpt_user_id is present in the JWT access token that the client sends with every request. The server currently returns rate limit data via:

  1. HTTP response headers: x-codex-primary-used-percent, x-codex-secondary-used-percent, x-codex-primary-window-minutes, etc.
  2. SSE events: codex.rate_limits events containing rate_limits, plan_type, credits, and metered_limit_name

Proposed: Add the user identity to both paths:

  • Header: x-codex-user-id (parsed alongside the existing x-codex-* family in codex-api/src/rate_limits.rs)
  • SSE event: user_id field in the codex.rate_limits JSON payload

Since the server is already extracting and validating the JWT to process the request, echoing back the chatgpt_user_id is trivial and authoritative. No client-side guessing required.

On the client side, RateLimitSnapshot gains one optional field:

pub struct RateLimitSnapshot {
    pub limit_id: Option<String>,
    pub limit_name: Option<String>,
    pub primary: Option<RateLimitWindow>,
    pub secondary: Option<RateLimitWindow>,
    pub credits: Option<CreditsSnapshot>,
    pub plan_type: Option<PlanType>,
    pub user_id: Option<String>,        // NEW — e.g. "user-XQL1tOSLLvHXGVA5EtOkaEHa"
}

This flows automatically into rollout JSONL via the existing TokenCountEventEventMsg::TokenCountRolloutItem::EventMsg serialization pipeline.

Client-side workaround (until server support lands)

The client already has the user identity available at runtime. The JWT stored in auth.json (and cached in-memory by AuthManager per session) contains the chatgpt_user_id in its https://api.openai.com/auth claim. Two options:

Option A — Inject user_id into RateLimitSnapshot client-side

When SessionState::set_rate_limits() merges a new snapshot in codex-rs/core/src/state/session.rs, inject the user_id from the in-memory auth state if the snapshot doesn't already carry one from the server. The AuthManager is session-scoped, so it reflects the user who actually started that session, even if auth.json on disk has since been overwritten by another session.

Option B — Emit SessionMeta on resume with user_id

The RolloutItem::SessionMeta variant is already handled at any position in the rollout stream (not just line 1). Emitting a fresh SessionMeta with user_id whenever a session is started or resumed would let tooling attribute subsequent turns to the correct user. This is a lighter touch than modifying every rate limit snapshot, though it requires tooling to correlate turns with the nearest preceding SessionMeta.

Example rollout output (after server-side change)

{
  "type": "event_msg",
  "payload": {
    "type": "token_count",
    "info": {
      "total_token_usage": {
        "input_tokens": 12065,
        "output_tokens": 3253,
        "total_tokens": 15318
      }
    },
    "rate_limits": {
      "limit_id": "codex",
      "primary": { "used_percent": 42.0, "window_minutes": 299, "resets_at": 1711900000 },
      "secondary": { "used_percent": 15.0, "window_minutes": 10079, "resets_at": 1712400000 },
      "plan_type": "team",
      "user_id": "user-XQL1tOSLLvHXGVA5EtOkaEHa"
    }
  }
}

Backward compatibility

Using Option<String> with #[serde(default, skip_serializing_if = "Option::is_none")] ensures:

  • Older rollout files without user_id still parse correctly
  • Older clients ignore the new header/field gracefully

Use Cases

  • Multi-user quota tracking: Tooling like codex-quota can accurately attribute usage per user on shared machines
  • Team usage auditing: Organizations can see which team member consumed how much of their individual quota
  • Multi-user dashboards: Per-user burn rate displays become reliable without fragile timestamp correlation
  • Debugging rate limits: Users can verify which identity was actually used when they hit unexpected limits

Related

  • #6360 — /status shows the wrong user's info when multiple sessions are open (same root cause: identity not tracked per-session)

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗