Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,23 @@ publishing empty notes.

### Added

- **Tools name the plan they are signed into, and what the seat costs.** The
Tools view gains PLAN and `$/MO` columns, and the SPEND card and Cost view
price the same figure: Claude Code's `~/.claude.json` names the seat —
priced by its rate-limit tier when that names a known capacity, because a
Team premium seat reads the same seat slug as a standard one and pricing by
it under-reported a real premium seat by 70% — and Codex's
`~/.codex/auth.json` token payload names `chatgpt_plan_type`. The account
file — what the tool is on *now* — beats the plan its transcripts name, and
`[cost.subscriptions]` beats both; the `≈` on an estimated figure marks
exactly what one config line makes exact. Only the plan's name is read from
those files, never the credentials beside it, and nothing is fetched.
`--json` carries it as `tools.list[].plan` with its source. The built-in
table prices ChatGPT's `team` plan at $25 after OpenAI's April 2026
repricing of Business, splitting it from Claude's `team_tier_1` at $30 —
the table carries monthly-billing list rates, and annual billing being
lower is one more reason every figure from it is marked an estimate.

- **A SPEND card that prices seats, and a TOKEN COST card that prices tokens.**
The Overview's old SPEND figure — the window's tokens at API list rates — now
sits under the name it deserved, **TOKEN COST**. The **SPEND** card answers
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,10 +191,12 @@ as zero AI usage. Full detail in
- **Local models are free**, and shown as `local` rather than `$0.00`, because the
two mean different things.
- **These are API list rates.** Put what you actually pay in
`[cost.subscriptions]` and the Cost view compares the two. Without that, a plan
the tool's own transcripts name (Codex writes one beside its token counts) is
priced at its list rate and marked an estimate — surface still reads no account
state, and a tool naming no plan is never guessed at.
`[cost.subscriptions]` and the Cost view compares the two. Without that, the
plan a tool itself names — in the account file it keeps on this disk, or
beside the token counts in its transcripts — is priced at its list rate and
marked an estimate. The plan's name is the only thing read from those files,
never the credentials beside it, and a tool naming no plan is never guessed
at.
- **Cache reads are billed at cache rates** and reasoning tokens at output rates,
which is how the providers that distinguish them do it.

Expand Down
12 changes: 7 additions & 5 deletions docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,11 +107,13 @@ codex = 30.0
Keys are tool ids as they appear in the Usage view — `claude_code`, `codex`,
`opencode`, `gemini_cli`, … — and values are **monthly** USD.

A tool with no entry falls back to the plan its own transcripts name, if any —
Codex writes one beside its token counts — priced at that plan's published list
rate. surface reads no account state, so a tool that names no plan and has no
entry gets no row rather than a guess. A configured figure always wins and is
used as given; a list-price fallback is labelled `est` wherever it is shown.
A tool with no entry falls back to the plan it names itself, if any — its
account file first (`~/.claude.json`, `~/.codex/auth.json`), its transcripts
otherwise — priced at that plan's published list rate. Only the plan's name is
read from those files, never the credentials beside it, and a tool that names
no plan and has no entry gets no row rather than a guess. A configured figure
always wins and is used as given; a list-price fallback is labelled `est`
wherever it is shown.

## Environment variables

Expand Down
29 changes: 22 additions & 7 deletions docs/guide/costs.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,28 @@ claude_code = 100.0
└────────────────────────────────────────────────────────────────────┘
```

Without an entry, one more source is tried before giving up: the plan a tool's
own transcripts name. Codex writes `rate_limits.plan_type` beside its token
counts, and a plan named there is priced at its published list rate — suffixed
`est` in the table and marked `≈` on the SPEND card, because a list price is an
estimate of your bill, not your bill. surface still reads no account state — no
billing API, no session token — so a tool that names no plan gets no row rather
than a guess, and a configured entry always beats a detected plan.
Without an entry, the plan the tool itself names is tried before giving up —
two sources, most current first:

- **The tool's account file.** Claude Code caches the seat it is signed into
in `~/.claude.json` — priced by its `userRateLimitTier` when that names a
known capacity (`default_claude_max_5x`), because a Team premium seat reads
the same `seatTier` as a standard one and pricing by the seat slug
under-reported it by 70%; the seat tier is the fallback. Codex's
`~/.codex/auth.json` token payload names `chatgpt_plan_type`. These say what
the tool is on *right now*.
- **Its transcripts.** Codex also writes `rate_limits.plan_type` beside its
token counts — the plan that was active when the usage was written.

A plan named either way is priced at its published **monthly** list rate —
suffixed `est` in the table and marked `≈` on the SPEND card, because a list
price is an estimate of your bill, not your bill. Annual billing is lower
(ChatGPT Business bills $20 per seat annually against the $25 monthly rate),
and negotiated or legacy rates differ too — which is exactly what the config
entry is for. Nothing is fetched: these are files
already on this disk, the plan's *name* is the only thing read out of them —
never the credentials beside it — and a tool that names no plan gets no row
rather than a guess. A configured entry always beats a detected plan.

## How each token kind is billed

Expand Down
15 changes: 9 additions & 6 deletions docs/guide/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,13 @@ one of those is a caveat rather than a count.
SPEND and TOKEN COST are different questions about the same tokens. TOKEN COST
is arithmetic — the window's usage priced per token at list rates. SPEND is
what is actually paid for the seats behind that usage: a figure from
`[cost.subscriptions]` is shown plainly; one from a plan the tool's own
transcripts name (Codex writes `plan_type` beside its token counts) is priced
at that plan's list rate and marked `≈` an estimate; a tool with usage but no
figure makes the total `≥` a floor. Knowing nothing, the card shows `–` and
says how to configure it — it never guesses, and it still reads no account
state.
`[cost.subscriptions]` is shown plainly; one from a plan the tool itself names
— its account file first (`~/.claude.json`, `~/.codex/auth.json`), its
transcripts as the fallback — is priced at that plan's list rate and marked
`≈` an estimate; a tool with usage but no figure makes the total `≥` a floor.
Knowing nothing, the card shows `–` and says how to configure it. Nothing is
fetched, nothing is guessed, and the plan's *name* is the only thing read from
those files — never the credentials that sit beside it.

### The rate and the delta

Expand Down Expand Up @@ -138,6 +139,8 @@ One row per detected tool.
| **VENDOR** | Who ships it |
| **KIND** | `assistant`, `coding agent`, `autonomous agent`, `editor`, `extension`, `local runtime` |
| **CAN ACT** | Whether it can execute code or take actions on this machine |
| **PLAN** | The subscription plan the tool is signed into, as the raw slug it wrote (`default_claude_max_5x`, `team`) — from its account file, or its transcripts as the fallback. `–` when it names none |
| **$/MO** | What that seat costs per month: plain from `[cost.subscriptions]`, `≈` at the plan's list price. The `≈` is the invitation — a figure that looks wrong is fixed with one config line |
| **FOUND BY** | The evidence that produced the detection |

**CAN ACT** is the column worth reading first. A chat window that can only talk
Expand Down
36 changes: 25 additions & 11 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,13 @@ pub struct ToolRow {
pub vendor: &'static str,
pub kind: &'static str,
pub autonomous: bool,
/// The subscription plan the tool is on, when its account file or
/// transcripts name one. The raw slug, as the tool wrote it.
pub plan: Option<String>,
/// What that seat costs per month — `[cost.subscriptions]` if set, the
/// plan's list price otherwise — and whether it is an estimate. Shown
/// beside the plan so a wrong estimate is visible where it can be fixed.
pub monthly: Option<(f64, bool)>,
pub evidence: Vec<String>,
}

Expand Down Expand Up @@ -349,12 +356,18 @@ impl App {
.scan
.tools
.iter()
.map(|d| ToolRow {
name: d.tool.name,
vendor: d.tool.vendor,
kind: d.tool.kind.label(),
autonomous: d.tool.autonomous,
evidence: d.evidence.clone(),
.map(|d| {
let usage_id = crate::scan::plans::usage_tool_id(d.tool.id);
let plan = self.scan.plans.get(usage_id).map(|p| p.plan.clone());
ToolRow {
name: d.tool.name,
vendor: d.tool.vendor,
kind: d.tool.kind.label(),
autonomous: d.tool.autonomous,
monthly: self.cost_config.monthly(usage_id, plan.as_deref()),
plan,
evidence: d.evidence.clone(),
}
})
.collect();

Expand Down Expand Up @@ -769,11 +782,12 @@ impl App {
.spend_by_tool()
.into_iter()
.filter_map(|(tool, api_equivalent)| {
// A configured subscription wins. Otherwise the plan the
// tool's own transcripts name is priced at its list rate and
// flagged as an estimate — still no account state read, and a
// tool naming no plan stays absent rather than guessed at.
let plan = self.ledger().plans.get(&tool).map(String::as_str);
// A configured subscription wins. Otherwise the plan the tool
// itself names — its account file first, its transcripts as
// the fallback, merged in `scan::run` — is priced at its list
// rate and flagged as an estimate. A tool naming no plan
// stays absent rather than guessed at.
let plan = self.scan.plans.get(&tool).map(|p| p.plan.as_str());
let (monthly, estimated) = self.cost_config.monthly(&tool, plan)?;
Some(SubscriptionRow {
tool,
Expand Down
51 changes: 41 additions & 10 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,16 +124,34 @@ impl CostConfig {
if let Some(configured) = self.subscriptions.get(tool) {
return Some((*configured, false));
}
let plan = plan?.to_lowercase();
let listed = match plan.as_str() {
"pro" | "plus" => 20.0,
"max_5x" | "default_claude_max_5x" => 100.0,
"max_20x" => 200.0,
"team" | "team_tier_1" => 30.0,
// Enterprise and API-key access have no list price to assume.
_ => return None,
};
Some((listed, true))
Some((list_price(plan?)?, true))
}
}

/// The published list price for a plan slug, if it has one.
///
/// Also how plan *detection* decides which of two slugs is worth keeping —
/// see [`crate::scan::plans`] — so an unknown plan returns `None` rather than
/// a guess in both places. Enterprise and API-key access have no list price
/// to assume.
///
/// These are **monthly-billing** list rates; annual billing is lower (ChatGPT
/// Business is $20/seat annual against the $25 here), which is one more
/// reason every figure from this table is marked an estimate and a
/// `[cost.subscriptions]` entry wins. Each slug belongs to one vendor's
/// namespace — a collision with a different price would need this table keyed
/// by tool as well, so keep them labelled.
pub(crate) fn list_price(plan: &str) -> Option<f64> {
match plan.to_lowercase().as_str() {
// Claude: seat and rate-limit tiers.
"pro" | "plus" => Some(20.0),
"max_5x" | "default_claude_max_5x" => Some(100.0),
"max_20x" | "default_claude_max_20x" => Some(200.0),
"team_tier_1" => Some(30.0),
// ChatGPT: `chatgpt_plan_type`. Business (né Team) was repriced
// 2026-04-02 from $30 to $25 monthly.
"team" => Some(25.0),
_ => None,
}
}

Expand Down Expand Up @@ -221,6 +239,19 @@ mod tests {
);
}

#[test]
fn the_two_team_slugs_price_by_their_own_vendor() {
// ChatGPT Business (`team`) was repriced to $25 monthly in April
// 2026; Claude's `team_tier_1` seat stays $30. One shared arm was
// quietly wrong for one vendor.
let cost = CostConfig::default();
assert_eq!(cost.monthly("codex", Some("team")), Some((25.0, true)));
assert_eq!(
cost.monthly("claude_code", Some("team_tier_1")),
Some((30.0, true))
);
}

#[test]
fn an_unknown_plan_is_none_rather_than_a_guess() {
let cost = CostConfig::default();
Expand Down
22 changes: 21 additions & 1 deletion src/demo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,14 @@
//! is derived from today — so two runs on the same day produce the same
//! dashboard. That matters for screenshots and for reproducing a layout bug.

use std::collections::BTreeMap;

use chrono::{Datelike, Duration, Utc, Weekday};

use crate::ledger::{Ledger, Tokens};
#[cfg(feature = "sqlite")]
use crate::scan::sites;
use crate::scan::{tooling, usage, Scan, Timings};
use crate::scan::{plans, tooling, usage, Scan, Timings};

/// Days of history the demo covers. Matches the default usage window.
const WINDOW_DAYS: u64 = 30;
Expand All @@ -51,6 +53,24 @@ pub fn scan() -> (Scan, Timings) {
#[cfg(feature = "sqlite")]
sites: sites(),
usage: usage(),
// The same plans the seeded transcripts and account files would
// name on a real machine of this shape.
plans: BTreeMap::from([
(
"claude_code".to_string(),
plans::DetectedPlan {
plan: "max_20x".to_string(),
source: plans::PlanSource::Account,
},
),
(
"codex".to_string(),
plans::DetectedPlan {
plan: "team".to_string(),
source: plans::PlanSource::Transcript,
},
),
]),
failed: Vec::new(),
demo: true,
},
Expand Down
11 changes: 11 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,17 @@ fn print_json(scan: &scan::Scan, timings: &scan::Timings, prices: &pricing::Pric
"vendor": d.tool.vendor,
"kind": d.tool.kind,
"autonomous": d.tool.autonomous,
// The plan slug the tool itself names, and where it said so
// ("account" or "transcript"). Null when it names none.
"plan": scan.plans
.get(crate::scan::plans::usage_tool_id(d.tool.id))
.map(|p| json!({
"name": p.plan,
"source": match p.source {
crate::scan::plans::PlanSource::Account => "account",
crate::scan::plans::PlanSource::Transcript => "transcript",
},
})),
"evidence": d.evidence,
})).collect::<Vec<_>>(),
},
Expand Down
10 changes: 10 additions & 0 deletions src/scan/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@
//! release profile deliberately does not set `panic = "abort"`.

pub mod apps;
pub mod plans;
#[cfg(feature = "sqlite")]
pub mod sites;
pub mod tooling;
pub mod usage;

use std::collections::BTreeMap;
use std::panic::{catch_unwind, AssertUnwindSafe};
use std::path::Path;
use std::time::Instant;
Expand All @@ -32,6 +34,9 @@ pub struct Scan {
#[cfg(feature = "sqlite")]
pub sites: sites::Sites,
pub usage: usage::Usage,
/// The subscription plan each tool is on, keyed by usage tool id. Account
/// files first, transcripts filling the gaps — see [`plans`].
pub plans: BTreeMap<String, plans::DetectedPlan>,
/// Sections that panicked, by name. Empty is the normal case.
pub failed: Vec<&'static str>,
/// Built by [`crate::demo`] rather than read off this machine. Never true
Expand Down Expand Up @@ -85,6 +90,10 @@ pub fn run(config: &Config, state_dir: &Path) -> (Scan, Timings) {
.unwrap_or_default();
timings.usage_ms = mark.elapsed().as_millis();

// Two file reads; not worth a timing of its own.
let mut plans = section("plans", &mut failed, plans::scan).unwrap_or_default();
plans::merge_transcripts(&mut plans, &usage.ledger.plans);

timings.total_ms = started.elapsed().as_millis();

(
Expand All @@ -94,6 +103,7 @@ pub fn run(config: &Config, state_dir: &Path) -> (Scan, Timings) {
#[cfg(feature = "sqlite")]
sites,
usage,
plans,
failed,
demo: false,
},
Expand Down
Loading