diff --git a/CHANGELOG.md b/CHANGELOG.md index 302a70a..33fd3cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,18 @@ publishing empty notes. ### 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). - **The Projects view names the tools behind each repository.** Each project row ends in a `TOOLS` column listing the tools whose sessions ran there, behind the same series texture the usage chart keys that tool by — so two 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 d52ab5c..a1ae6e0 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` — or `at API list rates` when none are, because the figure is list-rate arithmetic, not a bill | +| **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` — or `at API list rates` when none are, because the figure is list-rate arithmetic, not a bill | | **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 0aec37b..6b1dab6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -224,6 +224,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 { @@ -743,9 +769,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, @@ -758,6 +787,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 d0b585f..a88b637 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(); @@ -773,6 +787,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 8cb8e9d..631c633 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, }) } @@ -509,6 +514,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); + } } } @@ -838,6 +846,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, }) } @@ -900,6 +909,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), }) } @@ -1144,6 +1158,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 23e6032..288041b 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 @@ -507,8 +509,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 { @@ -520,7 +522,7 @@ fn draw_cards(frame: &mut Frame, area: Rect, app: &App) { card( frame, - cards[1], + cards[2], "tokens", &theme::compact(app.total_tokens()), &[ @@ -535,7 +537,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(), &[ @@ -553,7 +555,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")] @@ -2176,11 +2249,116 @@ mod tests { assert!(!out.contains("$0.00")); } - /// The SPEND card names what its dollars are: list-rate arithmetic when - /// every model is priced, the unpriced caveat when one is not — one line, - /// never both. + /// 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")); + } + + /// The TOKEN COST card names what its dollars are: list-rate arithmetic + /// when every model is priced, the unpriced caveat when one is not — one + /// line, never both. Renamed with the card, and rendered at 150: five + /// cards narrow each one, and the qualifier must survive whole. #[test] - fn the_spend_card_qualifies_its_dollars() { + fn the_token_cost_card_qualifies_its_dollars() { let all_priced = crate::pricing::Prices::parse( r#"{"claude-opus-5": {"input_cost_per_token": 0.000005, "output_cost_per_token": 0.000025}, @@ -2190,14 +2368,14 @@ mod tests { .expect("a two-model table parses"); let mut app = populated_with(all_priced); app.set_tab(Tab::Overview); - let out = rendered(&app, 120, 40); + let out = rendered(&app, 150, 40); assert!(out.contains("at API list rates"), "the qualifier line"); assert!(!out.contains("unpriced"), "nothing is unpriced"); // One of the fixture's two models is missing from this table. let mut app = populated_with(prices_for_a_model()); app.set_tab(Tab::Overview); - let out = rendered(&app, 120, 40); + let out = rendered(&app, 150, 40); assert!(out.contains("unpriced"), "the caveat takes the line"); assert!(!out.contains("at API list rates"), "one line, never both"); } 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