codeburn/docs/providers/codex.md
Uttam Deb 12d764c134
Account for Codex response usage records (#1571)
* Add Codex usage record accounting regressions

* Read Codex response usage records

* Reparse cached Codex usage after source handover

* Document Codex usage source handover

* Account for Codex in daily cache migration tests

* Cover Codex usage handover after fork replay
2026-09-29 14:33:02 +02:00

198 lines
13 KiB
Markdown

# Codex
OpenAI Codex CLI.
- **Source:** `src/providers/codex.ts`
- **Loading:** eager (`src/providers/index.ts:2`)
- **Test:** `tests/providers/codex.test.ts` (1075 lines)
## Where it reads from
`$CODEX_HOME` if set, otherwise `~/.codex`. Active sessions are nested by date:
```
~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl
```
Archived sessions are stored in a flat directory and are included in usage reports:
```
~/.codex/archived_sessions/rollout-*.jsonl
```
The active-session discovery walk uses strict regex (`^\d{4}$`, `^\d{2}$`) on each path component.
## Storage format
JSONL. Validation of the first line is **structural**: it must parse as JSON, have `type === "session_meta"`, and carry a `payload` that is a plain object (not missing, not a scalar, not an array). Files that fail this check are silently skipped.
`payload.originator` is deliberately **not** part of the check. It is a free-form client identity string, not a format marker: Codex CLI writes `codex-tui` / `codex_exec` / `codex_cli_rs`, Codex Desktop writes `Codex Desktop`, and third-party frontends driving `codex app-server` write their own values (`t3code_desktop`, `JetBrains.IntelliJ IDEA`, ...) into structurally identical rollouts. Gating discovery on the spelling silently dropped those sessions from every report and required a new allowlist entry per client (issues #626, #873). Directory ownership decides the provider instead: `codex.ts` is the only provider that reads `~/.codex`, and the walk only visits `rollout-*.jsonl` under the strict `YYYY/MM/DD` path or `archived_sessions/`. `originator` is still parsed into the session meta entry, but nothing downstream reads it.
Because admission no longer implies a known client, every payload field is treated as untrusted JSON. `payload.cwd` in particular is type-guarded before it reaches `sanitizeProject` (discovery) or `projectPath`/`workingDirectory` (parse): a non-string `cwd` falls back to the `unknown` project instead of throwing out of `discoverSessions`, which `safeDiscoverSessions` would have turned into an empty session list for the *entire* provider.
The first line read is capped at 1 MB (`FIRST_LINE_READ_CAP`). Codex CLI 0.128+ embeds the full system prompt in `session_meta`, which can run 20-27 KB; the cap leaves headroom while bounding memory if a corrupt file has no newline.
## Caching
`src/codex-cache.ts` writes `~/.cache/codeburn/codex-results.v<n>.json` (or `$CODEBURN_CACHE_DIR/codex-results.v<n>.json`). The unsuffixed `codex-results.json` is left for older binaries; a matching-version copy is adopted once and never overwritten. Each entry is keyed by absolute file path and validated against `mtimeMs + sizeBytes`. Cached entries are returned wholesale.
A session that yielded zero parseable lines does **not** write to the cache (`codex.ts:419`); this prevents a transient read failure from pinning an empty result against a fingerprint.
## Deduplication
Forked rollouts copy the parent's history before their own work. Replays with
the parent's original timestamps remain replay records until timestamps reach
the fork's `session_meta`; re-timestamped history is treated as a burst while
each event is within one second of the previous replay event. The first larger
gap ends the burst, so a real first turn 3.5-4.8 seconds after the fork is not
discarded by a fixed five-second window. Replayed cumulative snapshots still
advance the parser's delta baseline without producing calls, and the shared
parent key continues to deduplicate exact replays after the burst. The boundary
state is stored with the incremental Codex resume checkpoint.
Four layers, in order:
1. **Usage-source handover**: newer rollouts emit a top-level `token_usage_record` before the matching `token_count`. Count legacy `token_count` events until the first record with recognized numeric usage counters, then use response records alone and suppress later twins/re-emissions. An empty or malformed record does not switch sources. This captures work that a compacted or interrupted turn's `token_count` can omit.
2. **Byte-identity collapse (#257)**: before that handover, a `token_count` event whose `info` payload is byte-identical to the previous event's is a re-emission of the same event, not a new request, and is skipped regardless of cumulative presence. Measured on public rollouts (53 sessions / 1313 events): 603 are such repeats.
3. **Equal-cumulative guard**: with `total_token_usage.total_tokens` present, an event whose cumulative total equals the predecessor's is skipped.
4. **`seenKeys` cross-session key**: response records use their `response_id` under the fork-parent/session namespace, so replayed records collide with the parent. A record without an id uses its physical path and line offset. Legacy cumulative events use `codex:<forkedFromId|sessionId>:<total>:<input>:<cached>:<output>:<reasoning>`; without cumulative identity they use `codex:record:<path>:<line offset>`.
Estimated events that fall back to char-counting use `codex:<sessionId>:<timestamp>:est<n>`.
## Quirks
- Newer Codex builds emit one `token_usage_record` per model response with a `response_id`. Those records are the preferred usage source from the first usable record onward; earlier `token_count` events in a mixed rollout still count, and malformed records leave the legacy fallback active.
- Older rollouts use `token_count`: `last_token_usage` is used directly; cumulative-only events compute deltas against the prior turn; events with neither usage field estimate from message text length (`CHARS_PER_TOKEN = 4`).
- Sessions can open with a `token_count` event carrying `info: null` (the rate-limit ping); these take the char-count estimate path, not the dedup path, unless a newer usage record has already switched the source.
- `prevCumulativeTotal` is initialized to `null`, not `0`. A session whose first event reports `total = 0` would otherwise be dropped as a "duplicate" of the initial state. `prevInfoIdentity` (the byte-identity string) is persisted in the resume state alongside it.
- `prev*` token counters are advanced on every counted `token_count` event, including ones that used `last_token_usage`. Earlier code only updated them on the fallback branch, which double-counted any session that mixed modes.
- OpenAI counts cached tokens **inside** `input_tokens`. The parser subtracts them so the rest of the codebase can assume Anthropic semantics (cached are separate).
## Live quota (ChatGPT subscription)
Separate from the log parser above: the desktop app and the macOS menubar read
live quota from `GET https://chatgpt.com/backend-api/wham/usage` using the Codex
OAuth token. Two independent implementations of the same decoder, which must be
kept in sync:
- `app/electron/quota/codex.ts`: `decodeCodexUsage()` is the pure, exported decoder.
- `mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift`: `decodeUsage()`.
### Seat-based plans (Plus, Pro, Team)
`rate_limit.primary_window` / `secondary_window` carry `used_percent`,
`reset_at` and `limit_window_seconds`. The window *label* is inferred from the
duration (5-hour, Weekly, …), never from the plan, because window size is dynamic per
account. `additional_rate_limits[]` holds per-model limits (Codex Spark, etc.)
and is only surfaced when utilization is non-zero.
### Credit-metered plans (Business, Edu, Enterprise on flexible pricing)
These workspaces have **no rate-limit windows**: `rate_limit` comes back
`null`. Usage scales with credits, and an admin sets a monthly per-user credit
allowance. That allowance is the account's only limit and lives in
`spend_control`:
```jsonc
"spend_control": {
"reached": false,
"individual_limit": {
"source": "workspace_spend_controls",
"limit": "10000", // string
"used": "3028.9909675121307", // string
"used_percent": 30, // number
"remaining_percent": 70,
"reset_after_seconds": 441896, // time *remaining*, not window length
"reset_at": 1785542400
}
}
```
Notes that have bitten us:
- **Number encodings are mixed within the same object**: `limit` and `used`
arrive as strings while `used_percent` arrives as a number. Every numeric
field is decoded flexibly (number | string) on both sides.
- **`reset_after_seconds` is not the window length.** Pace projection needs the
whole-window duration, so it is derived as the calendar month preceding
`reset_at`, resolved in **UTC**: `reset_at` is a UTC boundary, and a local
calendar would make the month length depend on the viewer's timezone (a
2026-03-01Z reset spans 28 days in UTC but 31 in Toronto).
- Two other positions for this object have been observed in other clients
(top-level `individual_limit`, and nested under `rate_limit`), in both
snake_case and camelCase. All are accepted; `spend_control` wins.
- `credits.has_credits` means the account settles in **credits, not dollars**, so
`credits.balance` must not be rendered with a currency symbol in that case.
`credits.unlimited` means credit-metered but deliberately uncapped.
- **`has_credits` is not "is credit-metered".** The live Enterprise workspace
above is credit-metered (it has a `spend_control` allowance) yet reports
`has_credits: false` with a `null` balance, so the flag tracks whether the
account holds a *credit balance*, which is orthogonal to the allowance. Do not
derive one from the other. The `has_credits: true` rendering path has not been
observed against a real account; if a seat-based account ever reports it
alongside a dollar balance, the footer would drop the `$` and round to whole
units.
### `plan_type` cannot distinguish Business from Enterprise
A live ChatGPT **Enterprise** workspace reports `plan_type: "business"` on this
endpoint, and the `id_token`'s `https://api.openai.com/auth → chatgpt_plan_type`
claim says `"business"` too, even though ChatGPT's own workspace switcher
displays "Enterprise". Neither source carries the distinction, so the label
CodeBurn shows is faithfully what OpenAI returns. Do not try to infer a tier
from the presence of a spend control.
The switcher renders from the accounts endpoints, and **those are not reachable
with a Codex token**, verified against a live Enterprise workspace:
| Endpoint | Result |
| --- | --- |
| `/backend-api/accounts/check/v4-2023-04-27` | 403 |
| `/backend-api/accounts/check` | 403 |
| `/backend-api/me` | 403 |
| `/backend-api/settings/account_user_setting` | 403 |
Not an expiry or a missing-header problem: the same token returns 200 on
`/wham/usage` (and on `/backend-api/gizmo_creator_profile`) in the same run. The
Codex OAuth access token carries scopes `openid profile email offline_access
api.connectors.read api.connectors.invoke` with audience
`https://api.openai.com/v1`, with no ChatGPT web-app account scope, so the accounts
surfaces reject it by design. Adding a `ChatGPT-Account-Id` header does not
change this. **Business is therefore the correct label to display**; closing
this gap would need a different credential, not a different endpoint.
Composite tiers (`enterprise_cbp_usage_based`, `self_serve_business_usage_based`)
*are* normalized down to their base tier before lookup.
### Reset credits
`rate_limit_reset_credits` is carried inline on the usage payload
(`available_count`, and sometimes `applicable_available_count` — how many can be
applied right now). The dedicated `GET /wham/rate-limit-reset-credits` endpoint
is only called when the inline block is absent or non-zero. It is the sole
source of the per-credit list — `id`, `reset_type`, `status`, `granted_at`,
`expires_at` — so the "next expires" caption and the "latest grant" caption are
both omitted on the inline path.
**Banked resets.** OpenAI sometimes grants an account an extra reset out of
band. A credit whose identity (`id`, else its raw `granted_at`) has not been
seen before is a new grant, and the menubar notifies once per credit. The seen
set lives in `codex-banked-resets.json` in the CodeBurn cache directory, written
the same way `subscription-snapshots.json` is. Rules that matter: the first
observation is a baseline, a disappearing credit was spent and is not an event,
and an absent or malformed credits payload is *no opinion* — never an empty
account — so a failed fetch cannot cause a re-announcement on reconnect. The CLI
reads the same inventory from the inline block only; it never calls the
companion endpoint.
Nothing in the payload distinguishes a granted-but-not-yet-usable credit from a
usable one: there is no `available_at`, no pending status, and `granted_at` has
only ever been observed in the past. The earliest warning CodeBurn can give from
this source is therefore "it just landed", not "it lands at 5pm".
## When fixing a bug here
1. Reproduce against a real `rollout-*.jsonl` if you can. Drop a redacted copy under `tests/fixtures/codex/` and reference it from `tests/providers/codex.test.ts`.
2. If the bug is "zero tokens reported", first check whether the file is being skipped by `isValidCodexSession`.
3. If the bug is "tokens counted twice", look at `prevCumulativeTotal` and the prev-counter advancement.
4. If you change the dedup key shape, run `tests/providers/codex.test.ts` and `tests/parser-filter.test.ts` together; cross-provider dedup happens via the global `seenKeys` Set.