From d7c90dfed7cdd104f775f176d3cf499618b1c1f0 Mon Sep 17 00:00:00 2001 From: Adriano Koshiyama Date: Sat, 1 Aug 2026 20:55:37 +0100 Subject: [PATCH 1/3] feat(cost): read the plan a tool is signed into, and show it on Tools A transcript names the plan that was active when usage was written; the account file names the plan the tool is signed into now, and exists even for a tool that has not run in the window. Two more sources, in a new scan section: - Claude Code caches its seat in ~/.claude.json: oauthAccount.seatTier (team_tier_1), falling back to userRateLimitTier for personal plans, which name no seat. - Codex keeps an OpenID token in ~/.codex/auth.json whose payload names chatgpt_plan_type. The payload is base64url-decoded for that one claim - hand-rolled, fifteen lines against a dependency - and the token is neither verified nor kept. Only the plan's name leaves the parse. The credentials beside it are never stored, logged or shown, nothing is fetched, and the docs that said "reads no account state" now say precisely this instead. Account beats transcript beats nothing; [cost.subscriptions] beats all three. The merged map lives on Scan, so the SPEND card and the Cost view price it, the Tools view shows it in a new PLAN column - the raw slug the tool wrote, a dash when it names none - and --json carries it as tools.list[].plan with its source. Verified on a real machine: claude_code=team_tier_1 and codex=team, both source=account, pricing the SPEND card at ~$60/mo where before only the transcript-named Codex plan priced $30 of it. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 10 ++ README.md | 10 +- docs/guide/configuration.md | 12 +- docs/guide/costs.md | 23 ++- docs/guide/dashboard.md | 14 +- src/app.rs | 19 ++- src/demo.rs | 22 ++- src/main.rs | 11 ++ src/scan/mod.rs | 10 ++ src/scan/plans.rs | 290 ++++++++++++++++++++++++++++++++++++ src/ui/mod.rs | 72 ++++++++- surface.example.toml | 9 +- 12 files changed, 468 insertions(+), 34 deletions(-) create mode 100644 src/scan/plans.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 032114c..d4f3256 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,16 @@ publishing empty notes. ### Added +- **Tools name the plan they are signed into.** The Tools view gains a PLAN + column, and the SPEND card and Cost view price it: Claude Code's + `~/.claude.json` names the seat (`team_tier_1`, or the rate-limit tier for + personal plans), 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. 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. + - **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 diff --git a/README.md b/README.md index 9ca5ab2..d2ec343 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index bc00566..63e140c 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -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 diff --git a/docs/guide/costs.md b/docs/guide/costs.md index d48bd6d..e72d595 100644 --- a/docs/guide/costs.md +++ b/docs/guide/costs.md @@ -75,13 +75,22 @@ 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` (`seatTier`, falling back to `userRateLimitTier` for + personal plans), and 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 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. 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 diff --git a/docs/guide/dashboard.md b/docs/guide/dashboard.md index 0b842b8..b06b5b2 100644 --- a/docs/guide/dashboard.md +++ b/docs/guide/dashboard.md @@ -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 @@ -138,6 +139,7 @@ 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 (`team_tier_1`, `team`) — from its account file, or its transcripts as the fallback. `–` when it names none | | **FOUND BY** | The evidence that produced the detection | **CAN ACT** is the column worth reading first. A chat window that can only talk diff --git a/src/app.rs b/src/app.rs index 3cd3111..9112dc5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -143,6 +143,9 @@ 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, pub evidence: Vec, } @@ -345,6 +348,11 @@ impl App { vendor: d.tool.vendor, kind: d.tool.kind.label(), autonomous: d.tool.autonomous, + plan: self + .scan + .plans + .get(crate::scan::plans::usage_tool_id(d.tool.id)) + .map(|p| p.plan.clone()), evidence: d.evidence.clone(), }) .collect(); @@ -742,11 +750,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, diff --git a/src/demo.rs b/src/demo.rs index 9eadadd..f76a017 100644 --- a/src/demo.rs +++ b/src/demo.rs @@ -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; @@ -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, }, diff --git a/src/main.rs b/src/main.rs index 473a2d7..e07aff4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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::>(), }, diff --git a/src/scan/mod.rs b/src/scan/mod.rs index f165539..46445bb 100644 --- a/src/scan/mod.rs +++ b/src/scan/mod.rs @@ -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; @@ -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, /// 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 @@ -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(); ( @@ -94,6 +103,7 @@ pub fn run(config: &Config, state_dir: &Path) -> (Scan, Timings) { #[cfg(feature = "sqlite")] sites, usage, + plans, failed, demo: false, }, diff --git a/src/scan/plans.rs b/src/scan/plans.rs new file mode 100644 index 0000000..d96b2c5 --- /dev/null +++ b/src/scan/plans.rs @@ -0,0 +1,290 @@ +//! The subscription plan an AI tool's own account file names. +//! +//! Two tools cache, beside their other local state, which plan they are +//! signed into: Claude Code writes an `oauthAccount` block into +//! `~/.claude.json`, and Codex keeps an OpenID token in `~/.codex/auth.json` +//! whose payload carries a `chatgpt_plan_type` claim. Both are files already +//! on this disk — nothing is fetched, and this is still not reading account +//! *state*, only the name of the plan the tool itself wrote down. +//! +//! # What leaves this module +//! +//! The plan's name, per tool. Nothing else. The files parsed here also hold +//! access tokens; those are read into memory only as far as finding the one +//! claim requires, and are never stored, logged, shown or compared. The token +//! signature is not verified — this is not authentication, it is reading a +//! label off a file the tool already trusts. +//! +//! # Why bother, when transcripts also name a plan +//! +//! A transcript names the plan that was active when usage was written; the +//! account file names the plan the tool is signed into *now*, and exists even +//! for a tool that has not run in the window. Where both speak, the account +//! file wins — see the merge in [`crate::scan::run`]. + +use std::collections::BTreeMap; +use std::path::Path; + +/// Where a detected plan came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlanSource { + /// The tool's account file: what it is signed into right now. + Account, + /// A transcript record: what it was on when it last wrote usage. + Transcript, +} + +/// One tool's detected plan. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DetectedPlan { + pub plan: String, + pub source: PlanSource, +} + +/// Plans named by account files, keyed by *usage* tool id. +pub fn scan() -> BTreeMap { + let Some(home) = crate::paths::home() else { + return BTreeMap::new(); + }; + let mut plans = BTreeMap::new(); + if let Some(plan) = claude_seat(&home.join(".claude.json")) { + plans.insert( + "claude_code".to_string(), + DetectedPlan { + plan, + source: PlanSource::Account, + }, + ); + } + if let Some(plan) = codex_plan(&home.join(".codex/auth.json")) { + plans.insert( + "codex".to_string(), + DetectedPlan { + plan, + source: PlanSource::Account, + }, + ); + } + plans +} + +/// Fold transcript-named plans in behind account-named ones. +/// +/// The account file is what the tool is signed into *now*; a transcript names +/// the plan that was active when usage was written. Where both speak, now +/// wins. +pub fn merge_transcripts( + plans: &mut BTreeMap, + transcripts: &BTreeMap, +) { + for (tool, plan) in transcripts { + plans.entry(tool.clone()).or_insert_with(|| DetectedPlan { + plan: plan.clone(), + source: PlanSource::Transcript, + }); + } +} + +/// The usage-ledger tool id for a detection id, where the two disagree. +/// +/// Usage sources predate the tool table and named Codex `codex`; the +/// detection table calls it `openai_codex`. Plans are keyed the usage way +/// because pricing is, so the Tools view translates through this. +pub fn usage_tool_id(detection_id: &str) -> &str { + match detection_id { + "openai_codex" => "codex", + other => other, + } +} + +/// The seat named by Claude Code's `~/.claude.json`. +/// +/// `seatTier` is the seat being paid for (`team_tier_1`); personal plans name +/// no seat, so `userRateLimitTier` (`default_claude_max_5x`) is the fallback. +/// Only `oauthAccount` is looked at — the rest of the file is never walked. +fn claude_seat(path: &Path) -> Option { + let raw = std::fs::read(path).ok()?; + let value: serde_json::Value = serde_json::from_slice(&raw).ok()?; + let account = value.get("oauthAccount")?; + let field = |key: &str| { + account + .get(key) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string) + }; + field("seatTier").or_else(|| field("userRateLimitTier")) +} + +/// The plan named by Codex's `~/.codex/auth.json`. +/// +/// The file holds an OpenID `id_token` whose payload is a base64url JSON +/// object carrying `chatgpt_plan_type` under the `https://api.openai.com/auth` +/// claim. The payload is decoded for that one field; the token is neither +/// verified nor kept. +fn codex_plan(path: &Path) -> Option { + let raw = std::fs::read(path).ok()?; + let value: serde_json::Value = serde_json::from_slice(&raw).ok()?; + let token = value.get("tokens")?.get("id_token")?.as_str()?; + plan_in_id_token(token) +} + +/// `chatgpt_plan_type` out of a JWT, without trusting anything else in it. +fn plan_in_id_token(token: &str) -> Option { + let payload = token.split('.').nth(1)?; + let claims: serde_json::Value = serde_json::from_slice(&base64url(payload)?).ok()?; + claims + .get("https://api.openai.com/auth")? + .get("chatgpt_plan_type")? + .as_str() + .filter(|p| !p.is_empty()) + .map(str::to_string) +} + +/// Base64url without padding, as JWT segments are written. Hand-rolled: +/// fifteen lines against a new dependency for one segment of one file. +fn base64url(s: &str) -> Option> { + let value = |c: u8| -> Option { + match c { + b'A'..=b'Z' => Some(u32::from(c - b'A')), + b'a'..=b'z' => Some(u32::from(c - b'a') + 26), + b'0'..=b'9' => Some(u32::from(c - b'0') + 52), + b'-' => Some(62), + b'_' => Some(63), + _ => None, + } + }; + let mut out = Vec::with_capacity(s.len() * 3 / 4); + for chunk in s.as_bytes().chunks(4) { + if chunk.len() == 1 { + return None; + } + let mut acc = 0u32; + for (i, &c) in chunk.iter().enumerate() { + acc |= value(c)? << (18 - 6 * i); + } + let bytes = [(acc >> 16) as u8, (acc >> 8) as u8, acc as u8]; + out.extend_from_slice(&bytes[..chunk.len() - 1]); + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn temp_file(name: &str, contents: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join("surface-plans"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(name); + std::fs::write(&path, contents).unwrap(); + path + } + + /// A JWT in shape only: unsigned, unverified, exactly like the parser + /// treats the real one. + fn fake_jwt(claims: serde_json::Value) -> String { + fn encode(bytes: &[u8]) -> String { + const ALPHABET: &[u8] = + b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut out = String::new(); + for chunk in bytes.chunks(3) { + let mut acc = 0u32; + for (i, &b) in chunk.iter().enumerate() { + acc |= u32::from(b) << (16 - 8 * i); + } + for i in 0..=chunk.len() { + out.push(ALPHABET[((acc >> (18 - 6 * i)) & 63) as usize] as char); + } + } + out + } + format!( + "{}.{}.x", + encode(b"{}"), + encode(claims.to_string().as_bytes()) + ) + } + + #[test] + fn the_claude_seat_is_read_and_the_rate_tier_is_the_fallback() { + let path = temp_file( + "claude.json", + &json!({"oauthAccount": { + "seatTier": "team_tier_1", + "userRateLimitTier": "default_claude_max_5x", + "emailAddress": "someone@example.com" + }}) + .to_string(), + ); + assert_eq!(claude_seat(&path).as_deref(), Some("team_tier_1")); + + let path = temp_file( + "claude-personal.json", + &json!({"oauthAccount": {"userRateLimitTier": "default_claude_max_5x"}}).to_string(), + ); + assert_eq!( + claude_seat(&path).as_deref(), + Some("default_claude_max_5x"), + "a personal plan names no seat" + ); + + let path = temp_file("claude-empty.json", &json!({"projects": {}}).to_string()); + assert_eq!(claude_seat(&path), None, "signed out is None, not a guess"); + } + + #[test] + fn the_codex_plan_is_read_from_the_token_payload() { + let jwt = fake_jwt(json!({ + "https://api.openai.com/auth": {"chatgpt_plan_type": "team"}, + "email": "someone@example.com" + })); + let path = temp_file( + "auth.json", + &json!({"tokens": {"id_token": jwt}}).to_string(), + ); + assert_eq!(codex_plan(&path).as_deref(), Some("team")); + } + + #[test] + fn a_token_without_the_claim_is_none_rather_than_an_error() { + assert_eq!(plan_in_id_token(&fake_jwt(json!({"sub": "u-1"}))), None); + assert_eq!(plan_in_id_token("not-a-jwt"), None); + assert_eq!(plan_in_id_token(""), None); + } + + #[test] + fn missing_files_scan_to_nothing() { + assert_eq!(claude_seat(Path::new("/nonexistent/claude.json")), None); + assert_eq!(codex_plan(Path::new("/nonexistent/auth.json")), None); + } + + #[test] + fn the_detection_id_translates_to_the_usage_id() { + assert_eq!(usage_tool_id("openai_codex"), "codex"); + assert_eq!(usage_tool_id("claude_code"), "claude_code"); + } + + #[test] + fn an_account_plan_beats_a_transcript_plan_and_gaps_are_filled() { + let mut plans = BTreeMap::from([( + "codex".to_string(), + DetectedPlan { + plan: "team".to_string(), + source: PlanSource::Account, + }, + )]); + let transcripts = BTreeMap::from([ + ("codex".to_string(), "pro".to_string()), + ("claude_code".to_string(), "max_5x".to_string()), + ]); + + merge_transcripts(&mut plans, &transcripts); + + assert_eq!(plans["codex"].plan, "team", "now beats then"); + assert_eq!(plans["codex"].source, PlanSource::Account); + assert_eq!(plans["claude_code"].plan, "max_5x", "gap filled"); + assert_eq!(plans["claude_code"].source, PlanSource::Transcript); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 3898091..71d269a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -822,11 +822,19 @@ fn draw_tools(frame: &mut Frame, area: Rect, app: &App) -> Option { } else { Span::styled("\u{2013}", Style::default().fg(theme::DIM)) }; + // The raw slug the tool wrote (`team_tier_1`), not a prettied + // name: the same string [cost.subscriptions] docs and the price + // table speak, so a reader can act on what they see. + let plan = match &t.plan { + Some(plan) => Span::styled(plan.clone(), Style::default().fg(theme::MUTED)), + None => Span::styled("\u{2013}", Style::default().fg(theme::DIM)), + }; Row::new(vec![ Cell::from(t.name), Cell::from(t.vendor), Cell::from(t.kind), Cell::from(Line::from(flag)), + Cell::from(Line::from(plan)), Cell::from(truncate(&t.evidence.join(", "), 60)), ]) }) @@ -839,10 +847,13 @@ fn draw_tools(frame: &mut Frame, area: Rect, app: &App) -> Option { Constraint::Length(14), Constraint::Length(18), Constraint::Length(14), + Constraint::Length(22), Constraint::Min(20), ], ) - .header(header(&["TOOL", "VENDOR", "KIND", "CAN ACT", "FOUND BY"])) + .header(header(&[ + "TOOL", "VENDOR", "KIND", "CAN ACT", "PLAN", "FOUND BY", + ])) .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED)) .block(panel(&format!( "{} AI tools \u{b7} {} can act on this machine", @@ -2013,6 +2024,7 @@ mod tests { window_days: 30, ..Default::default() }, + plans: Default::default(), failed: Vec::new(), demo: false, }; @@ -2090,6 +2102,7 @@ mod tests { #[cfg(feature = "sqlite")] sites: Default::default(), usage: Default::default(), + plans: Default::default(), failed: Vec::new(), demo: false, }; @@ -2243,7 +2256,6 @@ mod tests { fn a_detected_plan_prices_the_spend_card_as_an_estimate() { let mut ledger = Ledger::default(); ledger.add("2026-07-26", "codex", "gpt-5.6", &tokens(1_000, 2_000)); - ledger.observe_plan("codex", "team"); let scan = Scan { tools_summary: Default::default(), @@ -2255,6 +2267,14 @@ mod tests { window_days: 30, ..Default::default() }, + // As `scan::run` would have merged it, from either source. + plans: std::collections::BTreeMap::from([( + "codex".to_string(), + crate::scan::plans::DetectedPlan { + plan: "team".to_string(), + source: crate::scan::plans::PlanSource::Transcript, + }, + )]), failed: Vec::new(), demo: false, }; @@ -2301,6 +2321,7 @@ mod tests { window_days: 30, ..Default::default() }, + plans: Default::default(), failed: Vec::new(), demo: false, }; @@ -2326,6 +2347,47 @@ mod tests { assert!(out.contains("1 tool(s) unpriced")); } + /// The Tools view names the plan each tool is signed into, as the raw + /// slug the tool wrote — and a tool naming none shows a dash, not a guess. + #[test] + fn the_tools_view_names_the_plan_a_tool_is_on() { + let detected: Vec = tooling::AI_TOOLS + .iter() + .take(3) + .map(|tool| tooling::Detected { + tool, + evidence: vec!["config:~/.x".to_string()], + }) + .collect(); + let scan = Scan { + tools_summary: tooling::summarise(&detected), + tools: detected, + #[cfg(feature = "sqlite")] + sites: Default::default(), + usage: Default::default(), + plans: std::collections::BTreeMap::from([( + "claude_code".to_string(), + crate::scan::plans::DetectedPlan { + plan: "team_tier_1".to_string(), + source: crate::scan::plans::PlanSource::Account, + }, + )]), + failed: Vec::new(), + demo: false, + }; + let mut app = App::new( + scan, + Timings::default(), + crate::pricing::Prices::default(), + CostConfig::default(), + ); + app.set_tab(Tab::Tools); + + let out = rendered(&app, 150, 30); + assert!(out.contains("PLAN"), "the plan column header"); + assert!(out.contains("team_tier_1"), "the slug the tool wrote"); + } + #[test] fn the_projects_view_lists_every_attributed_repository() { let mut app = populated(); @@ -2366,6 +2428,7 @@ mod tests { window_days: 30, ..Default::default() }, + plans: Default::default(), failed: Vec::new(), demo: false, }; @@ -2417,6 +2480,7 @@ mod tests { window_days: 30, ..Default::default() }, + plans: Default::default(), failed: Vec::new(), demo: false, }; @@ -2868,6 +2932,7 @@ mod tests { window_days: 30, ..Default::default() }, + plans: Default::default(), failed: Vec::new(), demo: false, }; @@ -3145,6 +3210,7 @@ mod tests { window_days: 30, ..Default::default() }, + plans: Default::default(), failed: Vec::new(), demo: false, }; @@ -3223,6 +3289,7 @@ mod tests { window_days: 30, ..Default::default() }, + plans: Default::default(), failed: Vec::new(), demo: false, }; @@ -3246,6 +3313,7 @@ mod tests { #[cfg(feature = "sqlite")] sites: Default::default(), usage: Default::default(), + plans: Default::default(), failed: Vec::new(), demo: false, }; diff --git a/surface.example.toml b/surface.example.toml index 82f8a87..c672eaf 100644 --- a/surface.example.toml +++ b/surface.example.toml @@ -47,10 +47,11 @@ window_days = 30 # Monthly spend per tool, in USD. Keys are tool ids as they appear in the Usage # view: claude_code, codex, opencode, gemini_cli, ... # -# Without an entry here, a tool falls back to the plan its own transcripts name, -# priced at that plan's list rate and marked an estimate. A tool naming no plan -# gets no row at all — surface reads no account state, so a plan it is not told -# about is never guessed. An entry here always wins over a detected plan. +# Without an entry here, a tool falls back to the plan it names itself — in its +# account file (~/.claude.json, ~/.codex/auth.json) or beside the token counts +# in its transcripts — priced at that plan's list rate and marked an estimate. +# Only the plan's name is read from those files, never the credentials beside +# it. A tool naming no plan gets no row at all, and an entry here always wins. [cost.subscriptions] # claude_code = 100.0 # codex = 30.0 From 58fd7a85ef0a84015621c2058854ef2d09ef825d Mon Sep 17 00:00:00 2001 From: Adriano Koshiyama Date: Sat, 1 Aug 2026 21:07:01 +0100 Subject: [PATCH 2/3] fix(cost): price a Claude seat by its capacity, and show the price on Tools Pricing the seat slug under-reported a real Team premium seat by 70%: seatTier reads team_tier_1 for standard and premium seats alike ($30 at list), while the seat actually paid for carries Max-class limits - userRateLimitTier: default_claude_max_5x, $100 at list, which is what its operator in fact pays. The rate-limit tier names the capacity the seat buys, so it wins when it carries a known list price; a standard seat's rate tier is unpriceable and falls back to the seat slug, and a personal plan names no seat at all. The known-slug table moves to config::list_price so detection and pricing cannot drift apart. The Tools view gains a $/MO column beside PLAN: plain when the figure is configured, marked with the estimate sign at a plan's list price - which is exactly where a wrong estimate gets seen and corrected with one [cost.subscriptions] line. Estimates can only ever be estimates; the config is the accuracy mechanism, and now the dashboard shows which figures deserve it. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 21 ++++++++------ docs/guide/costs.md | 9 ++++-- docs/guide/dashboard.md | 3 +- src/app.rs | 27 +++++++++++------- src/config.rs | 27 +++++++++++------- src/scan/plans.rs | 42 ++++++++++++++++++++------- src/ui/mod.rs | 63 ++++++++++++++++++++++++++++++++++++++++- 7 files changed, 147 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4f3256..bc280ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,15 +17,18 @@ publishing empty notes. ### Added -- **Tools name the plan they are signed into.** The Tools view gains a PLAN - column, and the SPEND card and Cost view price it: Claude Code's - `~/.claude.json` names the seat (`team_tier_1`, or the rate-limit tier for - personal plans), 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. 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. +- **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. - **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 diff --git a/docs/guide/costs.md b/docs/guide/costs.md index e72d595..be9b59f 100644 --- a/docs/guide/costs.md +++ b/docs/guide/costs.md @@ -79,9 +79,12 @@ 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` (`seatTier`, falling back to `userRateLimitTier` for - personal plans), and Codex's `~/.codex/auth.json` token payload names - `chatgpt_plan_type`. These say what the tool is on *right now*. + 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. diff --git a/docs/guide/dashboard.md b/docs/guide/dashboard.md index b06b5b2..0da0ad5 100644 --- a/docs/guide/dashboard.md +++ b/docs/guide/dashboard.md @@ -139,7 +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 (`team_tier_1`, `team`) — from its account file, or its transcripts as the fallback. `–` when it names none | +| **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 diff --git a/src/app.rs b/src/app.rs index 9112dc5..d9ef43b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -146,6 +146,10 @@ pub struct ToolRow { /// 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, + /// 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, } @@ -343,17 +347,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, - plan: self - .scan - .plans - .get(crate::scan::plans::usage_tool_id(d.tool.id)) - .map(|p| p.plan.clone()), - 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(); diff --git a/src/config.rs b/src/config.rs index c69885f..a341d09 100644 --- a/src/config.rs +++ b/src/config.rs @@ -124,16 +124,23 @@ 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. +pub(crate) fn list_price(plan: &str) -> Option { + match plan.to_lowercase().as_str() { + "pro" | "plus" => Some(20.0), + "max_5x" | "default_claude_max_5x" => Some(100.0), + "max_20x" | "default_claude_max_20x" => Some(200.0), + "team" | "team_tier_1" => Some(30.0), + _ => None, } } diff --git a/src/scan/plans.rs b/src/scan/plans.rs index d96b2c5..94da8b1 100644 --- a/src/scan/plans.rs +++ b/src/scan/plans.rs @@ -99,9 +99,14 @@ pub fn usage_tool_id(detection_id: &str) -> &str { /// The seat named by Claude Code's `~/.claude.json`. /// -/// `seatTier` is the seat being paid for (`team_tier_1`); personal plans name -/// no seat, so `userRateLimitTier` (`default_claude_max_5x`) is the fallback. -/// Only `oauthAccount` is looked at — the rest of the file is never walked. +/// Two fields describe it, and the *capacity* one prices better. A Team +/// premium seat reads `seatTier: team_tier_1` — the same slug as a standard +/// seat — while its `userRateLimitTier: default_claude_max_5x` names the +/// Max-class capacity actually being paid for; pricing by the seat slug +/// under-reported a real premium seat by 70%. So: the rate-limit tier when +/// it carries a known list price, the seat tier otherwise (a standard seat's +/// rate tier is unpriceable, a personal plan names no seat at all). Only +/// `oauthAccount` is looked at — the rest of the file is never walked. fn claude_seat(path: &Path) -> Option { let raw = std::fs::read(path).ok()?; let value: serde_json::Value = serde_json::from_slice(&raw).ok()?; @@ -113,7 +118,11 @@ fn claude_seat(path: &Path) -> Option { .filter(|s| !s.is_empty()) .map(str::to_string) }; - field("seatTier").or_else(|| field("userRateLimitTier")) + let rate = field("userRateLimitTier"); + match rate { + Some(rate) if crate::config::list_price(&rate).is_some() => Some(rate), + rate => field("seatTier").or(rate), + } } /// The plan named by Codex's `~/.codex/auth.json`. @@ -208,7 +217,9 @@ mod tests { } #[test] - fn the_claude_seat_is_read_and_the_rate_tier_is_the_fallback() { + fn a_premium_seat_is_priced_by_its_capacity_not_its_seat_slug() { + // seatTier reads `team_tier_1` for standard and premium seats alike; + // the rate tier is what tells them apart, so it wins when priceable. let path = temp_file( "claude.json", &json!({"oauthAccount": { @@ -218,17 +229,28 @@ mod tests { }}) .to_string(), ); + assert_eq!(claude_seat(&path).as_deref(), Some("default_claude_max_5x")); + } + + #[test] + fn a_standard_seat_keeps_its_seat_slug_and_gaps_stay_honest() { + // A standard seat's rate tier names no priced capacity. + let path = temp_file( + "claude-standard.json", + &json!({"oauthAccount": { + "seatTier": "team_tier_1", + "userRateLimitTier": "default_claude" + }}) + .to_string(), + ); assert_eq!(claude_seat(&path).as_deref(), Some("team_tier_1")); + // A personal plan names no seat at all. let path = temp_file( "claude-personal.json", &json!({"oauthAccount": {"userRateLimitTier": "default_claude_max_5x"}}).to_string(), ); - assert_eq!( - claude_seat(&path).as_deref(), - Some("default_claude_max_5x"), - "a personal plan names no seat" - ); + assert_eq!(claude_seat(&path).as_deref(), Some("default_claude_max_5x")); let path = temp_file("claude-empty.json", &json!({"projects": {}}).to_string()); assert_eq!(claude_seat(&path), None, "signed out is None, not a guess"); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 71d269a..a926064 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -829,12 +829,27 @@ fn draw_tools(frame: &mut Frame, area: Rect, app: &App) -> Option { Some(plan) => Span::styled(plan.clone(), Style::default().fg(theme::MUTED)), None => Span::styled("\u{2013}", Style::default().fg(theme::DIM)), }; + // The price beside the plan, in the same grammar as everywhere + // else: plain when configured, `≈` when it is a list-price + // estimate — which is exactly where a wrong estimate gets seen + // and corrected with a `[cost.subscriptions]` entry. + let seat = match t.monthly { + Some((usd, false)) => { + Span::styled(format_usd(usd), Style::default().fg(theme::MONEY)) + } + Some((usd, true)) => Span::styled( + format!("\u{2248}{}", format_usd(usd)), + Style::default().fg(theme::MUTED), + ), + None => Span::styled("\u{2013}", Style::default().fg(theme::DIM)), + }; Row::new(vec![ Cell::from(t.name), Cell::from(t.vendor), Cell::from(t.kind), Cell::from(Line::from(flag)), Cell::from(Line::from(plan)), + Cell::from(Line::from(seat)), Cell::from(truncate(&t.evidence.join(", "), 60)), ]) }) @@ -848,11 +863,12 @@ fn draw_tools(frame: &mut Frame, area: Rect, app: &App) -> Option { Constraint::Length(18), Constraint::Length(14), Constraint::Length(22), + Constraint::Length(10), Constraint::Min(20), ], ) .header(header(&[ - "TOOL", "VENDOR", "KIND", "CAN ACT", "PLAN", "FOUND BY", + "TOOL", "VENDOR", "KIND", "CAN ACT", "PLAN", "$/MO", "FOUND BY", ])) .row_highlight_style(Style::default().add_modifier(Modifier::REVERSED)) .block(panel(&format!( @@ -2386,6 +2402,51 @@ mod tests { let out = rendered(&app, 150, 30); assert!(out.contains("PLAN"), "the plan column header"); assert!(out.contains("team_tier_1"), "the slug the tool wrote"); + assert!(out.contains("$/MO"), "the seat price column header"); + assert!( + out.contains("\u{2248}$30.00"), + "the plan's list price, marked an estimate" + ); + } + + /// A configured seat price shows plain — the estimate marker is what + /// tells the reader which figures are worth correcting in config. + #[test] + fn a_configured_seat_price_shows_plain_on_the_tools_view() { + let detected: Vec = tooling::AI_TOOLS + .iter() + .take(1) + .map(|tool| tooling::Detected { + tool, + evidence: vec!["config:~/.x".to_string()], + }) + .collect(); + let scan = Scan { + tools_summary: tooling::summarise(&detected), + tools: detected, + #[cfg(feature = "sqlite")] + sites: Default::default(), + usage: Default::default(), + plans: Default::default(), + failed: Vec::new(), + demo: false, + }; + let mut cost = CostConfig::default(); + cost.subscriptions.insert("claude_code".into(), 100.0); + let mut app = App::new( + scan, + Timings::default(), + crate::pricing::Prices::default(), + cost, + ); + app.set_tab(Tab::Tools); + + let out = rendered(&app, 150, 30); + assert!(out.contains("$100.00"), "the configured figure"); + assert!( + !out.contains("\u{2248}$100.00"), + "configured is not an estimate" + ); } #[test] From 3e480e746979fa9bf8b0614bd3ce075ecf431c23 Mon Sep 17 00:00:00 2001 From: Adriano Koshiyama Date: Sat, 1 Aug 2026 21:22:06 +0100 Subject: [PATCH 3/3] fix(cost): ChatGPT Business is $25 monthly since April 2026, not $30 OpenAI repriced Business (ne Team) on 2026-04-02: $25/seat monthly, $20 annual. The shared 'team' arm also priced Claude's team_tier_1, so the two vendors' slugs split into labelled arms - one shared arm was quietly wrong for one vendor. The table carries monthly-billing list rates and says so; annual billing being lower is one more reason every figure from it is marked an estimate and a config entry wins. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 6 +++++- docs/guide/costs.md | 9 ++++++--- src/config.rs | 26 +++++++++++++++++++++++++- src/ui/mod.rs | 4 ++-- 4 files changed, 38 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc280ba..9b0fa3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,11 @@ publishing empty notes. `[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. + `--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 diff --git a/docs/guide/costs.md b/docs/guide/costs.md index be9b59f..bc8da7a 100644 --- a/docs/guide/costs.md +++ b/docs/guide/costs.md @@ -88,9 +88,12 @@ two sources, most current first: - **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 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. Nothing is fetched: these are files +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. diff --git a/src/config.rs b/src/config.rs index a341d09..61165b3 100644 --- a/src/config.rs +++ b/src/config.rs @@ -134,12 +134,23 @@ impl CostConfig { /// 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 { 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" | "team_tier_1" => Some(30.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, } } @@ -228,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(); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index a926064..f4cb73e 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -2304,8 +2304,8 @@ mod tests { let out = rendered(&app, 150, 40); assert!( - out.contains("\u{2248}$30.00/mo"), - "the team list price, flagged an estimate" + out.contains("\u{2248}$25.00/mo"), + "ChatGPT Business's monthly list price, flagged an estimate" ); assert!(out.contains("1 plan(s) detected")); }