Skip to content

Attribute Codex activity to its plan and disclose multi-account totals - #97

Merged
tsouth89 merged 1 commit into
mainfrom
feat/plan-attribution-disclosure
Jul 21, 2026
Merged

Attribute Codex activity to its plan and disclose multi-account totals#97
tsouth89 merged 1 commit into
mainfrom
feat/plan-attribution-disclosure

Conversation

@tsouth89

@tsouth89 tsouth89 commented Jul 21, 2026

Copy link
Copy Markdown
Owner

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:

  • Codex session_meta: session_id, cwd, originator, cli_version, model_provider, gitno email, account id, or org. The only user/email hits anywhere were GitHub MCP tool output embedded in conversations.
  • Claude JSONL: 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_type on token_count events — 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 bucketed unattributed when a rollout never declares one. The Charts page discloses when totals span more than one plan.

Measured on the maintainer's machine: prolite 73.9%, team 19.1%, plus 7.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: prolite and plus are the same account after a $20 → $100 upgrade, while team is 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:

  • Timing: plus (Jul 7–13) and prolite (Jul 10–21) overlap, so "sequential = upgrade" is dead.
  • Path: C:\projects is 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::Value parse entirely and is what real logs take — declaring rate_limits only 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, unattributed stays visible, and the split reconciles with the period total.
  • End-to-end validated against 97 real rollouts (throwaway harness, not committed).
  • Disclosure rendered against the real stylesheet before shipping.

Shared 659 passed, desktop 395 passed, frontend 278 passed. Clippy -D warnings clean on both crates, fmt and tsc --noEmit clean.

Summary by CodeRabbit

  • New Features

    • Usage charts now show token activity grouped by subscription plan.
    • Added a warning when local usage includes multiple plans or accounts, clarifying that totals may not represent only the signed-in account.
    • Plan details are captured from usage records, including unattributed activity.
  • Style

    • Added highlighted styling for the multi-plan usage notice.

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.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

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

@coderabbitai

coderabbitai Bot commented Jul 21, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

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

Changes

Plan usage attribution

Layer / File(s) Summary
Capture plan metadata from Codex logs
rust/src/core/jsonl_scanner.rs
The JSONL scanner tracks rate_limits.plan_type and attaches the current plan to emitted usage records through both parsing paths.
Aggregate and merge plan token buckets
rust/src/codex_costs.rs, rust/src/cost_scanner.rs
Codex token aggregation assigns declared plans or unattributed, while summary merging preserves plan buckets across periods. Updated tests cover plan splits and existing record fixtures.
Expose plan breakdown in local usage summaries
apps/desktop-tauri/src-tauri/src/commands/chart.rs, apps/desktop-tauri/src-tauri/src/powertoys.rs
Local usage DTOs and summary-loading paths expose normalized, sorted plan rows, with serialization and cache fixtures updated.
Render multi-plan usage disclosure
apps/desktop-tauri/src/types/bridge.ts, apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx, apps/desktop-tauri/src/styles.css
Bridge types carry plan breakdowns, and charts render a styled warning with per-plan shares when multiple plans have positive usage.

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
Loading

Possibly related PRs

  • tsouth89/ceiling#52: Shares the Codex cost/token aggregation path in rust/src/codex_costs.rs.
  • tsouth89/ceiling#54: Modifies the same usage aggregation pipeline with a different breakdown dimension.
  • tsouth89/ceiling#96: Updates local usage calculations and provider-normalized token metrics in chart.rs.

Suggested reviewers: finesssee

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: attributing Codex usage to plans and warning when totals span multiple accounts/plans.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/plan-attribution-disclosure

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 4826478 and 1ccf1f7.

📒 Files selected for processing (8)
  • apps/desktop-tauri/src-tauri/src/commands/chart.rs
  • apps/desktop-tauri/src-tauri/src/powertoys.rs
  • apps/desktop-tauri/src/styles.css
  • apps/desktop-tauri/src/surfaces/settings/providers/sections/charts/ChartsSection.tsx
  • apps/desktop-tauri/src/types/bridge.ts
  • rust/src/codex_costs.rs
  • rust/src/core/jsonl_scanner.rs
  • rust/src/cost_scanner.rs

Comment on lines +140 to +143
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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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-L96
  • apps/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.

Comment on lines +387 to +394
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());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ 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

@tsouth89
tsouth89 merged commit 2139450 into main Jul 21, 2026
10 of 11 checks passed
@tsouth89
tsouth89 deleted the feat/plan-attribution-disclosure branch July 21, 2026 21:19
tsouth89 added a commit that referenced this pull request Jul 21, 2026
…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>
@tsouth89 tsouth89 mentioned this pull request Jul 21, 2026
tsouth89 added a commit that referenced this pull request Jul 21, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant