From 77c95cc3bd18216ecc4dec51bcc779743877c9ab Mon Sep 17 00:00:00 2001 From: Adriano Koshiyama Date: Sat, 1 Aug 2026 17:28:50 +0100 Subject: [PATCH] feat(cost): a SPEND card that prices seats, beside a TOKEN COST card The Overview led with SPEND over a figure that is not spend - it is the window's tokens priced at API list rates. That figure keeps its card under the name it deserved, TOKEN COST, with the rate, delta and unpriced qualifiers unchanged. The SPEND card now answers the question its name asks: what the seats behind the usage actually cost per month. Figures come from [cost.subscriptions], or failing that from the plan the tool's own transcripts name - Codex writes rate_limits.plan_type on the same records the token counts come from, so this reads no account state. A detected plan prices at its published list rate via the fallback table CostConfig::monthly already carried, and is marked as an estimate; a tool with usage but no figure floors the total; knowing nothing, the card shows a dash and the config key rather than a guess. The Cost view's subscription table picks up detected plans through the same one line, still suffixed est. Detected plans persist in the ledger - version 5, because a steady-state scan reads no bytes and anything gathered during a read has to survive between scans, the same argument that put session metadata in version 4. The first scan after upgrading re-reads transcripts once. Verified against a real corpus: a fresh scan writes plans:{codex:team} and the card prices it at $30/mo, flagged. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 15 +++ README.md | 6 +- docs/guide/configuration.md | 6 +- docs/guide/costs.md | 11 ++- docs/guide/dashboard.md | 24 +++-- src/app.rs | 53 +++++++++- src/demo.rs | 5 + src/ledger.rs | 36 ++++++- src/scan/usage.rs | 62 ++++++++++++ src/ui/mod.rs | 189 ++++++++++++++++++++++++++++++++++-- surface.example.toml | 6 +- 11 files changed, 386 insertions(+), 27 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7e5880..032114c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,21 @@ publishing empty notes. ## [Unreleased] +### Added + +- **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 + the other question: what the seats behind that usage actually cost per month. + Figures come from `[cost.subscriptions]`, or failing that from the plan a + tool's own transcripts name (Codex writes `rate_limits.plan_type` beside its + token counts), 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 rather than guessing. The + Cost view's subscription table picks up detected plans the same way, still + suffixed `est`. Detected plans persist in the ledger (now version 5, so the + first scan after upgrading re-reads transcripts once). + ## [0.1.0] - 2026-07-28 First release. diff --git a/README.md b/README.md index c6185ec..9ca5ab2 100644 --- a/README.md +++ b/README.md @@ -191,8 +191,10 @@ 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, surface - does not guess your plan — it reads no account state. + `[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. - **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 d80afd1..bc00566 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -107,8 +107,10 @@ 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 gets no subscription row at all. surface reads no account -state, so it cannot know your plan and will not guess one. A configured figure is +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. ## Environment variables diff --git a/docs/guide/costs.md b/docs/guide/costs.md index 2c20234..d48bd6d 100644 --- a/docs/guide/costs.md +++ b/docs/guide/costs.md @@ -75,10 +75,13 @@ claude_code = 100.0 └────────────────────────────────────────────────────────────────────┘ ``` -Without an entry, a tool gets no subscription row. surface reads no account state -— no plan, no billing API, no session token — so it cannot know what you pay and -will not guess. Where a figure comes from a published list price rather than your -config, it is suffixed `est`. +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. ## How each token kind is billed diff --git a/docs/guide/dashboard.md b/docs/guide/dashboard.md index 4c44ad1..0b842b8 100644 --- a/docs/guide/dashboard.md +++ b/docs/guide/dashboard.md @@ -52,21 +52,32 @@ card does nothing at all, and a click with the help overlay open only closes it. ## Overview -Four cards, one chart, three rankings — down the page, not across it. Each card +Five cards, one chart, three rankings — down the page, not across it. Each card is a headline figure with qualifier lines under it, and a card turns amber when one of those is a caveat rather than a count. | Card | Figure | Qualifiers | |---|---|---| -| **SPEND** | Total cost over the window | `over N days · ≈ $X/day`, the period delta, and `▲ N model(s) unpriced` if any | +| **SPEND** | What the seats actually cost, `$X/mo` | Where the figures came from (`N configured seat(s)`, `N plan(s) detected`), the saving against API rates, and `▲ N tool(s) unpriced` if any | +| **TOKEN COST** | The window's tokens at API list rates | `over N days · ≈ $X/day`, the period delta, and `▲ N model(s) unpriced` if any | | **TOKENS** | Everything billable, cache included | Message count, distinct model count | | **TOOLS** | AI tools detected | `▲ N autonomous`, vendor count | | **AI SITES** | Distinct AI domains visited | Visit total, and `▲ N browser(s) unreadable` if any | +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. + ### The rate and the delta -Two derived figures on the SPEND card, and both decline to appear rather than -mislead: +Two derived figures on the TOKEN COST card, and both decline to appear rather +than mislead: - **`≈ $X/day`** divides the total by the window. Per day rather than projected to a month, because the default window *is* a month — a monthly projection came out @@ -268,8 +279,9 @@ same tokens would have cost at API rates: | **SAME AT API RATES** | What that usage would have cost per token | | | `subscription saves $240.00`, or `▲ API would be $88.00 cheaper` | -A tool with no `[cost.subscriptions]` entry gets no row: surface reads no account -state, so it cannot know your plan and will not guess one. See +A tool with no `[cost.subscriptions]` entry and no plan named in its own +transcripts gets no row: surface reads no account state, so a plan it is not +told about — by you or by the transcript — is never guessed. See [Configuration](configuration.md#cost) and [Costs](costs.md). ## Projects diff --git a/src/app.rs b/src/app.rs index 0b908d5..3cd3111 100644 --- a/src/app.rs +++ b/src/app.rs @@ -215,6 +215,32 @@ pub struct SubscriptionRow { pub api_equivalent: f64, } +/// The seat cost behind the window's usage, summed for the SPEND card. +/// +/// A partial answer stays honest by carrying its own gaps: `detected` figures +/// are list-price estimates, and `unpriced_tools` makes the total a floor. +#[derive(Debug, Clone, PartialEq)] +pub struct SpendEstimate { + /// Monthly seat total across the tools with a figure. + pub monthly_usd: f64, + /// What the same tools' window tokens cost at API rates. + pub api_equivalent: f64, + /// Figures from `[cost.subscriptions]`. + pub configured: usize, + /// Figures from a plan the tool's transcripts name, at list price. + pub detected: usize, + /// Tools with usage but no figure at all — the total is a floor. + pub unpriced_tools: usize, +} + +impl SpendEstimate { + /// Some figure is a list price for a detected plan, not something the + /// operator configured. + pub fn estimated(&self) -> bool { + self.detected > 0 + } +} + impl SubscriptionRow { /// Positive when the subscription is cheaper than paying per token. pub fn saving(&self) -> f64 { @@ -716,9 +742,12 @@ impl App { .spend_by_tool() .into_iter() .filter_map(|(tool, api_equivalent)| { - // Plan is unknown to `surface` — it reads no account state — so - // only a configured subscription produces a row. - let (monthly, estimated) = self.cost_config.monthly(&tool, None)?; + // 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); + let (monthly, estimated) = self.cost_config.monthly(&tool, plan)?; Some(SubscriptionRow { tool, monthly, @@ -731,6 +760,24 @@ impl App { rows } + /// What the seats behind this window's usage actually cost, as far as + /// that can be known. `None` when no tool has a figure: the card shows + /// the absence, never a guess. + pub fn spend_estimate(&self) -> Option { + let rows = self.subscriptions(); + if rows.is_empty() { + return None; + } + let configured = rows.iter().filter(|r| !r.estimated).count(); + Some(SpendEstimate { + monthly_usd: rows.iter().map(|r| r.monthly).sum(), + api_equivalent: rows.iter().map(|r| r.api_equivalent).sum(), + configured, + detected: rows.len() - configured, + unpriced_tools: self.spend_by_tool().len() - rows.len(), + }) + } + // ------------------------------------------------------------ navigation pub fn row_count(&self) -> usize { diff --git a/src/demo.rs b/src/demo.rs index cdaecd6..9eadadd 100644 --- a/src/demo.rs +++ b/src/demo.rs @@ -535,6 +535,11 @@ fn usage() -> usage::Usage { } } + // The plans the tools' own transcripts would name, so the SPEND card has + // something to price the seats with. + ledger.observe_plan("claude_code", "max_20x"); + ledger.observe_plan("codex", "team"); + usage::Usage { ledger, tools_read: vec!["claude_code", "codex", "opencode", "ollama"], diff --git a/src/ledger.rs b/src/ledger.rs index 168a5ce..ca1246f 100644 --- a/src/ledger.rs +++ b/src/ledger.rs @@ -24,8 +24,9 @@ use serde::{Deserialize, Serialize}; /// Bump when the on-disk shape changes; an older ledger is discarded. /// /// 2 added per-project attribution to [`DayState`]; 3 added per-session; 4 -/// persisted session metadata, which incremental reads cannot re-derive. -pub const LEDGER_VERSION: u32 = 4; +/// persisted session metadata, which incremental reads cannot re-derive; 5 +/// persisted detected plans, which cannot either. +pub const LEDGER_VERSION: u32 = 5; /// Namespace for session keys, so the same session id under a different /// product yields a different key. @@ -132,6 +133,11 @@ pub struct Ledger { pub days: BTreeMap, /// session key -> what that session is. Survives between scans. pub session_meta: BTreeMap, + /// tool -> the subscription plan its transcripts most recently named, + /// e.g. `codex -> team`. Persisted for the same reason as `session_meta`: + /// a steady-state scan reads no bytes, so anything gathered during a read + /// has to survive between scans or it only exists on cold ones. + pub plans: BTreeMap, /// Whether titles were being collected when this ledger was written. pub titles_enabled: bool, /// Cumulative counters, reported so a consumer can see dedup ran. @@ -147,6 +153,7 @@ impl Default for Ledger { sessions: BTreeMap::new(), days: BTreeMap::new(), session_meta: BTreeMap::new(), + plans: BTreeMap::new(), titles_enabled: false, duplicates_skipped: 0, undedupable_records: 0, @@ -295,6 +302,13 @@ impl Ledger { true } + /// Record the plan a tool's transcript names. Last one wins: records are + /// read in file order, so the latest read reflects a plan change — an + /// upgrade mid-window should show the plan being paid for now. + pub fn observe_plan(&mut self, tool: &str, plan: &str) { + self.plans.insert(tool.to_string(), plan.to_string()); + } + /// Record what a session is. Called during ingest, kept afterwards. pub fn observe_session(&mut self, key: &str, tool: &str, repo: &str, title: Option<&str>) { let entry = self.session_meta.entry(key.to_string()).or_default(); @@ -755,6 +769,24 @@ mod tests { ); } + /// Plans are gathered during a read and steady-state scans read nothing, + /// so a plan that fails to persist exists only on cold scans — the same + /// trap session metadata fell into before it was persisted. + #[test] + fn a_detected_plan_survives_the_round_trip_and_the_last_one_wins() { + let dir = temp_dir("plans"); + let path = ledger_path(&dir); + let mut ledger = Ledger::default(); + ledger.observe_plan("codex", "pro"); + ledger.observe_plan("codex", "team"); + ledger.save(&path).unwrap(); + + assert_eq!( + Ledger::load(&path).plans.get("codex").map(String::as_str), + Some("team") + ); + } + #[test] fn a_corrupt_ledger_is_rebuilt_rather_than_fatal() { let dir = temp_dir("corrupt"); diff --git a/src/scan/usage.rs b/src/scan/usage.rs index 1d612a9..e3598f2 100644 --- a/src/scan/usage.rs +++ b/src/scan/usage.rs @@ -88,6 +88,10 @@ pub struct Record { /// Working directory the record was produced in, when the format carries /// one. Resolved to a repository slug during ingest and never stored. pub cwd: Option, + /// The subscription plan the record names, when the format carries one — + /// Codex writes `rate_limits.plan_type` on every usage record. Not account + /// state: it is read from the same line as the token counts. + pub plan: Option, } /// What the usage scan read, alongside the ledger it read into. @@ -404,6 +408,7 @@ pub fn parse_opencode(id: &str, value: &serde_json::Value) -> Option { .and_then(|p| p.get("cwd")) .and_then(|c| c.as_str()) .map(str::to_string), + plan: None, }) } @@ -499,6 +504,9 @@ fn ingest_file( .unwrap_or_else(|| UNATTRIBUTED.to_string()); apply(ledger, tool, accumulation, &record, &project, &session); ledger.observe_session(&session, tool, &project, header.title.as_deref()); + if let Some(plan) = &record.plan { + ledger.observe_plan(tool, plan); + } } } @@ -794,6 +802,7 @@ pub fn parse_claude_code(value: &serde_json::Value) -> Option { .get("cwd") .and_then(|c| c.as_str()) .map(str::to_string), + plan: None, }) } @@ -856,6 +865,11 @@ pub fn parse_codex(value: &serde_json::Value) -> Option { // Codex names the directory once in the session header, so this is // filled from there during ingest rather than parsed per record. cwd: None, + // `rate_limits.plan_type`, on the same record as the counts. + plan: find_key(value, "plan_type", 0) + .and_then(|p| p.as_str()) + .filter(|p| !p.is_empty()) + .map(str::to_string), }) } @@ -1048,6 +1062,54 @@ mod tests { assert_eq!(r.tokens.cache_read, 10); } + /// A Codex usage record, with the `rate_limits` block real ones carry. + fn codex_line_on_plan(total: u64, plan: &str) -> String { + json!({ + "timestamp": "2026-07-26T12:00:00.000Z", + "session_id": "s1", + "payload": { + "info": { "total_token_usage": { + "input_tokens": total / 2, + "cached_input_tokens": total / 4, + "output_tokens": total / 4, + "total_tokens": total + }}, + "rate_limits": {"limit_id": "codex", "plan_type": plan} + } + }) + .to_string() + + "\n" + } + + #[test] + fn parses_the_plan_codex_names_beside_its_counts() { + let line = codex_line_on_plan(1000, "team"); + let r = parse_line("codex", line.as_bytes()).unwrap(); + assert_eq!(r.plan.as_deref(), Some("team")); + + let r = parse_line("codex", codex_line("s1", 1000).as_bytes()).unwrap(); + assert_eq!(r.plan, None, "no rate_limits block, no plan"); + } + + #[test] + fn a_codex_plan_is_read_into_the_ledger() { + let dir = temp_dir("plan"); + let mut ledger = Ledger::default(); + ingest( + &dir, + "c.jsonl", + "codex", + Accumulation::CumulativePerSession, + &codex_line_on_plan(400, "team"), + &mut ledger, + ); + assert_eq!( + ledger.plans.get("codex").map(String::as_str), + Some("team"), + "the plan the transcript names is kept, keyed by tool" + ); + } + #[test] fn parses_an_opencode_message_row() { // The exact shape observed in a real opencode.db, where the parts are diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 274bcee..3898091 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -462,9 +462,11 @@ fn draw_rankings(frame: &mut Frame, area: Rect, app: &App) { fn draw_cards(frame: &mut Frame, area: Rect, app: &App) { let cards = Layout::default() .direction(Direction::Horizontal) - .constraints([Constraint::Ratio(1, 4); 4]) + .constraints([Constraint::Ratio(1, 5); 5]) .split(area); + draw_spend_card(frame, cards[0], app); + let unpriced = app.unpriced_models(); // A total with nothing to compare it against is the least useful shape a cost // figure can take, so the window line carries a projected rate and the line @@ -503,8 +505,8 @@ fn draw_cards(frame: &mut Frame, area: Rect, app: &App) { card( frame, - cards[0], - "spend", + cards[1], + "token cost", &format_usd(app.total_usd()), &spend_detail, if unpriced > 0 { @@ -516,7 +518,7 @@ fn draw_cards(frame: &mut Frame, area: Rect, app: &App) { card( frame, - cards[1], + cards[2], "tokens", &theme::compact(app.total_tokens()), &[ @@ -531,7 +533,7 @@ fn draw_cards(frame: &mut Frame, area: Rect, app: &App) { let s = &app.scan.tools_summary; card( frame, - cards[2], + cards[3], "tools", &s.detected.to_string(), &[ @@ -549,7 +551,78 @@ fn draw_cards(frame: &mut Frame, area: Rect, app: &App) { }, ); - draw_sites_card(frame, cards[3], app); + draw_sites_card(frame, cards[4], app); +} + +/// What the seats actually cost, as far as that can be known — against the +/// token arithmetic on the TOKEN COST card beside it. +/// +/// Three states, in the house grammar: a configured figure is plain, a +/// detected plan's list price is `≈` an estimate, a tool with usage but no +/// figure makes the total `≥` a floor — and knowing nothing shows `–` and +/// says how to fix it, never a guess. +fn draw_spend_card(frame: &mut Frame, area: Rect, app: &App) { + let Some(estimate) = app.spend_estimate() else { + // Card lines have ~24 columns on a 150-column terminal; every string + // here is written to that width rather than truncated into noise. + card( + frame, + area, + "spend", + "\u{2013}", + &[ + "no seat price known".to_string(), + "set [cost.subscriptions]".to_string(), + "see token cost".to_string(), + ], + theme::DIM, + ); + return; + }; + + let value = format!( + "{}{}{}/mo", + if estimate.unpriced_tools > 0 { + "\u{2265}" + } else { + "" + }, + if estimate.estimated() { "\u{2248}" } else { "" }, + format_usd(estimate.monthly_usd) + ); + + let mut detail = vec![match (estimate.configured, estimate.detected) { + (c, 0) => format!("{c} configured seat(s)"), + (0, d) => format!("{d} plan(s) detected"), + (c, d) => format!("{c} configured \u{b7} {d} detected"), + }]; + // The comparison the Cost view makes per tool, summed: the same tools' + // window tokens at API rates. Signed like `SubscriptionRow::saving`. + let saving = estimate.api_equivalent - estimate.monthly_usd; + detail.push(if saving >= 0.0 { + format!("{} under API rates", format_usd(saving)) + } else { + format!("{} over API rates", format_usd(-saving)) + }); + if estimate.unpriced_tools > 0 { + detail.push(format!( + "\u{25b2} {} tool(s) unpriced", + estimate.unpriced_tools + )); + } + + card( + frame, + area, + "spend", + &value, + &detail, + if estimate.unpriced_tools > 0 { + theme::WARN + } else { + theme::MONEY + }, + ); } #[cfg(feature = "sqlite")] @@ -2149,6 +2222,110 @@ mod tests { assert!(!out.contains("$0.00")); } + /// The SPEND card prices seats, not tokens: nothing known shows `–` and + /// says how to fix it, a detected plan is `≈` an estimate at list price, + /// and a tool without any figure makes the total `≥` a floor. + #[test] + fn the_spend_card_prices_seats_not_tokens() { + // populated(): no [cost.subscriptions] and no detected plan. + let mut app = populated(); + app.set_tab(Tab::Overview); + let out = rendered(&app, 150, 40); + assert!( + out.contains("TOKEN COST"), + "the API arithmetic keeps a card" + ); + assert!(out.contains("no seat price known")); + assert!(!out.contains("/mo"), "no figure is invented"); + } + + #[test] + 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(), + tools: Vec::new(), + #[cfg(feature = "sqlite")] + sites: Default::default(), + usage: crate::scan::usage::Usage { + ledger, + window_days: 30, + ..Default::default() + }, + failed: Vec::new(), + demo: false, + }; + let mut app = App::new( + scan, + Timings::default(), + crate::pricing::Prices::default(), + CostConfig::default(), + ); + app.set_tab(Tab::Overview); + + let out = rendered(&app, 150, 40); + assert!( + out.contains("\u{2248}$30.00/mo"), + "the team list price, flagged an estimate" + ); + assert!(out.contains("1 plan(s) detected")); + } + + #[test] + fn a_tool_without_any_figure_makes_the_spend_card_a_floor() { + let mut ledger = Ledger::default(); + ledger.add( + "2026-07-26", + "claude_code", + "claude-opus-5", + &tokens(1_000, 2_000), + ); + // Usage from a tool with no configured price and no named plan. + ledger.add( + "2026-07-26", + "opencode", + "big-pickle", + &tokens(1_000, 2_000), + ); + + let scan = Scan { + tools_summary: Default::default(), + tools: Vec::new(), + #[cfg(feature = "sqlite")] + sites: Default::default(), + usage: crate::scan::usage::Usage { + ledger, + window_days: 30, + ..Default::default() + }, + failed: Vec::new(), + demo: false, + }; + let mut cost = CostConfig::default(); + cost.subscriptions.insert("claude_code".into(), 150.0); + let mut app = App::new( + scan, + Timings::default(), + crate::pricing::Prices::default(), + cost, + ); + app.set_tab(Tab::Overview); + + let out = rendered(&app, 150, 40); + assert!( + out.contains("\u{2265}$150.00/mo"), + "a configured seat, floored by the tool beside it" + ); + assert!( + !out.contains("\u{2248}$150.00"), + "configured is not an estimate" + ); + assert!(out.contains("1 tool(s) unpriced")); + } + #[test] fn the_projects_view_lists_every_attributed_repository() { let mut app = populated(); diff --git a/surface.example.toml b/surface.example.toml index c5b9cf0..82f8a87 100644 --- a/surface.example.toml +++ b/surface.example.toml @@ -47,8 +47,10 @@ 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 gets no subscription row at all — surface reads no -# account state, so it cannot know your plan and will not guess one. +# 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. [cost.subscriptions] # claude_code = 100.0 # codex = 30.0