Attribute Codex activity to its plan and disclose multi-account totals - #97
Conversation
Local logs carry no account identity. Verified against 97 real rollouts: Codex session_meta has no email, account id, or org, and Claude's JSONL has none either and no plan field at all. So a machine's activity cannot be split by account from log content, and pairing one account's quota with every log on the machine overstates it. Codex does emit rate_limits.plan_type on token_count events, which is the only available proxy. Each token delta now carries the plan in force when it was billed, carried forward within a rollout because a session can span a switch, and bucketed as "unattributed" when a rollout never declares one. The charts disclose when the totals span more than one plan instead of letting them read as the signed-in account's. The plan is deliberately a disclosure, not a filter. It is wrong in both directions: two accounts on one plan look identical, and one account changing plans looks like two. On the maintainer's machine prolite and plus are in fact the same account after an upgrade, while team is a different one, and neither timing nor working directory separates them (the plan date ranges overlap, and C:\projects is used by all three). Choosing for the user would be a guess, so the totals stay whole and say what they contain. Measured there: prolite 73.9%, team 19.1%, plus 7.0%, so about a quarter of what was presented as one account's usage was not. Capture had to be added to the fast parse path as well as the full one. The fast path skips the serde_json::Value parse entirely and is what real logs take, so declaring rate_limits only on the slow path left every record unattributed against real data.
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
ceiling | 1ccf1f7 | Commit Preview URL Branch Preview URL |
Jul 21 2026, 09:13 PM |
📝 WalkthroughWalkthroughCodex logs now capture subscription plans, cost summaries aggregate tokens by plan, and desktop local-usage charts expose plan breakdowns. The charts display a warning when local usage spans multiple plans or accounts. ChangesPlan usage attribution
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant JSONLScanner
participant CodexCosts
participant CostSummary
participant ChartCommand
participant ChartsSection
JSONLScanner->>CodexCosts: emit usage records with optional plan
CodexCosts->>CostSummary: aggregate tokens by normalized plan
CostSummary->>ChartCommand: provide merged plan token buckets
ChartCommand->>ChartsSection: return local plan breakdown
ChartsSection->>ChartsSection: render multi-plan usage warning
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In
`@apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx`:
- Around line 140-143: Replace the account-inference wording with “not
account-scoped” language that distinguishes plan/attribution buckets from
accounts. In ChartsSection.tsx, remove “not just the signed-in account”; update
chart.rs and bridge.ts documentation at the specified ranges to avoid describing
multiple entries as multiple accounts.
In `@rust/src/core/jsonl_scanner.rs`:
- Around line 387-394: Update plan-state handling in
rust/src/core/jsonl_scanner.rs#L387-L394 and
rust/src/core/jsonl_scanner.rs#L430-L437 so rate_limits.plan_type is applied
after replay-gate handling but before each path’s zero-delta and day-range
exits; preserve existing billing behavior for nonzero deltas. Add a focused
deterministic Rust regression fixture near the scanner tests covering a
zero-delta plan switch followed by a billed delta without rate_limits, verifying
the billed record uses the new plan.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: ae598d6e-43a2-40ad-82a6-2415894c778c
📒 Files selected for processing (8)
apps/desktop-tauri/src-tauri/src/commands/chart.rsapps/desktop-tauri/src-tauri/src/powertoys.rsapps/desktop-tauri/src/styles.cssapps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsxapps/desktop-tauri/src/types/bridge.tsrust/src/codex_costs.rsrust/src/core/jsonl_scanner.rsrust/src/cost_scanner.rs
| These totals cover <strong>{plans.length} plans</strong> seen on this | ||
| machine ({plans.map((p) => `${p.plan} ${share(p.tokens)}%`).join(", ")}), | ||
| not just the signed-in account. Local logs do not record which account | ||
| produced them. |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Do not infer account identity from plan buckets.
A single account can switch plans, and unattributed is also a bucket. Therefore multiple rows establish only that local usage spans multiple attribution buckets—not that it came from accounts other than the signed-in one. Use “not account-scoped” wording and distinguish plan buckets from accounts.
apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx#L140-L143: remove “not just the signed-in account.”apps/desktop-tauri/src-tauri/src/commands/chart.rs#L93-L96: avoid documenting multiple entries as multiple accounts.apps/desktop-tauri/src/types/bridge.ts#L638-L639: align the bridge documentation with the same limitation.
📍 Affects 3 files
apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx#L140-L143(this comment)apps/desktop-tauri/src-tauri/src/commands/chart.rs#L93-L96apps/desktop-tauri/src/types/bridge.ts#L638-L639
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx`
around lines 140 - 143, Replace the account-inference wording with “not
account-scoped” language that distinguishes plan/attribution buckets from
accounts. In ChartsSection.tsx, remove “not just the signed-in account”; update
chart.rs and bridge.ts documentation at the specified ranges to avoid describing
multiple entries as multiple accounts.
| if let Some(plan) = payload | ||
| .get("rate_limits") | ||
| .and_then(|limits| limits.get("plan_type")) | ||
| .and_then(|plan| plan.as_str()) | ||
| .filter(|plan| !plan.trim().is_empty()) | ||
| { | ||
| self.current_plan = Some(plan.to_string()); | ||
| } |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Capture plan state before zero-delta suppression.
A token_count that announces a new plan but emits no token delta never updates current_plan; the next billed record without rate_limits is attributed to the prior plan or unattributed. Update plan state after replay-gate handling but before any zero-delta/range return, in both paths, and add a regression fixture for a zero-delta plan switch followed by a billed delta.
rust/src/core/jsonl_scanner.rs#L387-L394: update the slow-path state before its zero-delta and day-range exits.rust/src/core/jsonl_scanner.rs#L430-L437: update the fast-path state before its zero-delta exit.
As per coding guidelines, “Add or extend focused Rust tests near the changed module; use deterministic samples or fixtures for parser and fetcher changes where practical.”
📍 Affects 1 file
rust/src/core/jsonl_scanner.rs#L387-L394(this comment)rust/src/core/jsonl_scanner.rs#L430-L437
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@rust/src/core/jsonl_scanner.rs` around lines 387 - 394, Update plan-state
handling in rust/src/core/jsonl_scanner.rs#L387-L394 and
rust/src/core/jsonl_scanner.rs#L430-L437 so rate_limits.plan_type is applied
after replay-gate handling but before each path’s zero-delta and day-range
exits; preserve existing billing behavior for nonzero deltas. Add a focused
deterministic Rust regression fixture near the scanner tests covering a
zero-delta plan switch followed by a billed delta without rate_limits, verifying
the billed record uses the new plan.
Source: Coding guidelines
…count (#98) Addresses both CodeRabbit findings on #97. Both were legitimate. ## 1. Data integrity: plan lost on a zero-delta line (Major) A `token_count` can announce a new plan while billing nothing. The plan was read **after** the zero-delta and day-range returns, so such a line exited first and the next billed record inherited the **previous** plan. Capture now happens in **both** parse paths immediately after the replay gate, before any early return. Deliberately still after the gate, so a child session's replayed parent history cannot install a stale plan. Regression test `zero_delta_plan_switch_still_attributes_the_next_billed_record`: a line that announces `team` with unchanged cumulative totals, followed by a billed delta carrying no `rate_limits`, must attribute to `team`. My first attempt at that fixture was wrong — I used `last_token_usage` (a per-request delta) where the zero-delta case needs `total_token_usage` (cumulative). The test failed loudly, which is what it's for. ## 2. Correctness: the notice inferred account identity (Minor) The copy said "not just the signed-in account", which infers accounts from plan buckets — the exact overclaim this work exists to prevent. Several plans prove several **plans**. One account changing plans looks identical, as on this machine where `prolite` and `plus` are one account after an upgrade. Now reads: *These totals are **not account-scoped**. Local logs record the plan but not the account, and this machine shows 3 plans (…).* Backend and bridge docs aligned. ## Two edge cases found while checking the rest - **`unattributed` could falsely trigger the notice.** It's a bucket, not a plan, so one plan plus unlabeled records would have rendered "2 plans". It can no longer trigger alone, and shows as "unlabeled" when it accompanies a real split. Sub-1% shares render "<1%" instead of "0%". - **Incremental parsing would drop the plan on resume** — state seeds model and totals but not plan. Every caller passes offset 0 today so nothing is broken; noted at the seam so enabling it later doesn't silently lose attribution. Verified both notice states render against the real stylesheet. Shared 660 passed, desktop 395, frontend 278. Clippy `-D warnings`, fmt, tsc all clean. Co-authored-by: tsouth89 <tsouth89@users.noreply.github.com>
Release prep. Version bumped in all five tracked locations (`version.env` incl. BUILD_NUMBER 100→101, both Rust manifests, `package.json`, `tauri.conf.json`) plus `Cargo.lock`. ## What ships **Added** — multi-plan disclosure (SOU-297 step 1, #97/#98). Codex records the plan behind each request, so when a machine's totals span more than one plan the Charts page says so rather than letting them read as the signed-in account's. **Fixed** - Codex cache rate double-counted cached input, showing ~49% for a model that was really ~97% (SOU-295, #96). - Archived Codex sessions were missing from the Charts page, reset windows, and API value card (SOU-296, #96). - Cache percentages now say which window they measure (SOU-300, #99). Also folds in a one-line doc fix flagged during the #90 review: `CostUsageReport.current_windows` said "token totals" after it started carrying cost too. ## Note for release notes The archived-sessions fix will **increase** existing users' totals if they archive Codex tasks — on the maintainer's machine 81 of 97 rollouts were archived and therefore invisible. That is a correction, not a regression, and the notes say so. ## Validation `local-check.ps1 -All -Version 1.4.0` passes; release-doctor reports all five version locations at 1.4.0 and the changelog entry present, with only the expected pre-tag warnings (no local tag, no local assets dir). Co-authored-by: tsouth89 <tsouth89@users.noreply.github.com>
Step 1 of the multi-account work (SOU-297, now a child of SOU-285).
What the logs actually contain
Investigated 97 real Codex rollouts plus a Claude sample:
session_meta:session_id,cwd,originator,cli_version,model_provider,git— no email, account id, or org. The onlyuser/emailhits anywhere were GitHub MCP tool output embedded in conversations.parentUuid,agentId,cwd,sessionId,gitBranch,userType— also no identity, and no plan field at all.So activity cannot be split by account from log content, for any provider. Pairing one account's quota with every log on the machine overstates it.
What this does
Codex emits
rate_limits.plan_typeontoken_countevents — the only available proxy. Each token delta now carries the plan in force when it was billed, carried forward within a rollout (a session can span a switch — 3 of the 97 do), and bucketedunattributedwhen a rollout never declares one. The Charts page discloses when totals span more than one plan.Measured on the maintainer's machine:
prolite73.9%,team19.1%,plus7.0% — about a quarter of what was presented as one account's usage was not.Why disclosure and not a filter
The plan is wrong in both directions, and the maintainer's own setup proves it:
proliteandplusare the same account after a $20 → $100 upgrade, whileteamis genuinely different. Filtering to the current plan would silently delete their own history.I tested both automatic ways to tell those apart and both fail:
plus(Jul 7–13) andprolite(Jul 10–21) overlap, so "sequential = upgrade" is dead.C:\projectsis used by all three plans, so cwd can't separate work from personal.Choosing for the user would be a guess. The totals stay whole and say what they contain.
One thing worth knowing
Capture had to be added to the fast parse path as well as the full one. The fast path skips the
serde_json::Valueparse entirely and is what real logs take — declaringrate_limitsonly on the slow path left 100% of real records unattributed. Caught by running the scanner against real logs; the unit test alone passed because it constructed records with the plan pre-set.Tests
summary_splits_tokens_by_plan_and_keeps_undeclared_visible— per-record attribution, mid-rollout switch,unattributedstays visible, and the split reconciles with the period total.Shared 659 passed, desktop 395 passed, frontend 278 passed. Clippy
-D warningsclean on both crates, fmt andtsc --noEmitclean.Summary by CodeRabbit
New Features
Style