…e page
Codex CLI writes a `rate_limits` object into its `token_count` events
(up to two windows, a short `primary` window and a longer `secondary`
one, e.g. 5 hours and 7 days) alongside the plan type and credit
balance. agentsview now extracts that object during Codex parsing,
persists each observation, and surfaces it on the Usage page as a
"Rate limits" section: one card per window showing used percent, time
to reset, plan type, credit balance, and a small history chart of
used percent over the selected date range.
Schema and identity
Observations are stored in a new `rate_limit_snapshots` table
(SQLite only, alongside cursor_usage_events and the Codex
incremental-import tables -- out of scope for the SQLite/PostgreSQL/
DuckDB parity rule). The table is vendor-keyed (`vendor`, `'codex'`
for every row today) from the start rather than Codex-specific,
because a second PR building on this one adds a Claude rate-limit
poller that needs the same table: shipping the vendor-neutral shape
now means that PR only adds rows, instead of carrying a migration
that renames the table and backfills a `vendor` column for a table
that had not even shipped to users yet. `account_id`, `account_label`,
`scope_label`, and `details` are reserved the same way for a vendor
whose rate-limit source has that shape, and stay empty on every Codex
row.
Codex rollouts carry no stable per-account identifier -- no account
id, user id, email, or org field appears in session_meta or
token_count payloads (see docs/internal/session-format-sources.md) --
so a Codex snapshot's identity is (machine, limit_id, plan_type,
window_kind) rather than an account. Rows are written with INSERT OR
IGNORE against a unique dedup_key (source session id + observed
timestamp + limit id + window kind), never a delete-then-reinsert, so
both a full parse and an incremental parse converge on the same rows
without duplicating or losing history. session_id is nullable (ON
DELETE SET NULL) so a row survives its source session being deleted.
resets_at is also nullable end to end -- the parser type, the table,
and the API response all preserve a genuinely unknown reset time as
null rather than flattening it to 0, which previously would have
shown as a bogus countdown to the unix epoch.
Because this backfills history from existing rollout files, the
parser data version is bumped (to 108) so an archive written by an
older binary gets a full reparse and picks up the rate_limits history
its rollout files already contained, instead of silently staying
empty until the next observation.
The "current" query (LatestRateLimitSnapshots) resolves "latest" per
(vendor, machine, account_id, limit_id, plan_type) bucket, one level
above window_kind, and returns every window belonging to that bucket's
single newest observation, rather than ranking each window_kind
independently: the latter let a window that stopped being reported
(e.g. a session moving from primary+secondary windows to primary-only)
keep showing its last-known row indefinitely. The winning observation
is further disambiguated by a persisted observation_key column, stored
once at insert time from the then-current session_id and independent
of the nullable session_id foreign key: two different sessions on one
machine and account can legitimately report the same limit/plan at the
exact same instant (Codex's rate limit is account-wide), and using
session_id itself for this at query time broke either when two such
sessions were both deleted (their now-NULL session_ids would compare
equal again) or, if that NULL case was special-cased per row instead,
when one deleted session's own primary+secondary windows could no
longer be told apart from each other. A key fixed at write time and
never recomputed avoids both failure modes.
The history endpoint downsamples a wide date range instead of
returning every matching observation: `max_points` (default 500) caps
the response, dividing the range into that many equal-width time
buckets and keeping only the most recently observed row per bucket, so
a long-lived window's full history does not grow the response (or
RateLimitHistoryChart's point count) unboundedly. The chart itself
requests a smaller point budget sized for its own sparkline-sized
rendering.
API and frontend
New routes `GET /api/v1/rate-limits/current` and `.../history` accept
`vendor`, `account_id`, and `machine` filters (`agent` is kept as a
deprecated alias for `vendor`, matching the shared session filter's
comma-separated selection semantics via
`RateLimitAgentMatchesVendor`); `history` additionally accepts
`limit_id`, `window`, `since`, `until`, and `max_points`. PostgreSQL and
DuckDB implement the read side as no-ops, so the section is simply
hidden when either backend is the active read store.
The frontend groups cards by vendor and then by account; since Codex
reports no account identity, its account group is keyed by machine
instead, so two machines syncing the same account still render as two
groups. A window with no known reset time shows "Reset time unknown"
instead of a countdown. The history chart now plots observed time on
a real time scale (it previously used a categorical point scale that
spaced every observation evenly by index, which visually flattened
bursts and gaps in the actual observation cadence).
Robustness fixes folded in from post-implementation review
- A full resync now copies existing rate_limit_snapshots rows into
the replacement archive, the same way model pricing is copied, so
previously observed windows survive the swap instead of going empty
until the next observation. This copy runs after orphaned sessions
are restored, not before: session_id is a foreign key, and a
snapshot belonging to an orphaned session would otherwise violate
it during the copy (INSERT OR IGNORE does not suppress a
foreign-key violation the way it suppresses a duplicate), silently
losing every row in the table rather than just that session's. The
copy also NULLs session_id for any row whose session still does not
exist in the destination even after orphan restoration -- one
superseded by a reparse under a different id, or one excluded as
parser-excluded -- rather than copying that id unchanged and hitting
the same constraint; dedup_key and observation_key are preserved
from the source unchanged either way. This history cannot be
reconstructed once lost, so a failed copy aborts the swap instead of
merely warning.
- A rate_limits payload missing limit_id is skipped rather than
failing the whole write -- since this write is normally part of a
larger session write, the old behavior could fail an entire
session's ingestion over one malformed rate-limit entry.
- The machine and agent/vendor filters use the same comma-separated
IN/contains semantics as the rest of the shared session filters,
and rate-limit history queries parse and normalize since/until as
RFC3339Nano rather than comparing raw request strings against the
stored UTC column.
- The incremental-parse cursor seed (a prefix scan that reconstructs
cursor state without ever returning a session result) no longer
accumulates rate-limit observations while scanning: they were never
read out of that path, so collecting them grew memory with the
whole scanned prefix on every incremental-parse cache miss instead
of staying bounded.
docs/agents/storage.md and docs/token-usage.md are updated to
describe the new table and endpoints.
Preserves Codex rate-limit observations and adds Usage-page cards showing
utilization, reset times, plan, credits, and history.
A vendor-keyed SQLite table backs the current and history endpoints. Indexed
latest-window lookups and SQL downsampling avoid loading full history into Go.
Replay and resync preserve observations without duplicating them.
Codex rollouts lack an account ID, so their cards group by machine. Rate-limit data
is SQLite-only. The data-version bump causes a source reparse on the next sync to
backfill existing history.
Review
internal/db/rate_limit_snapshots.go, the Codex parser and sync paths, andfrontend/src/lib/stores/ratelimits.svelte.ts.