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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
6 changes: 4 additions & 2 deletions docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 7 additions & 4 deletions docs/guide/costs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 18 additions & 6 deletions docs/guide/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
53 changes: 50 additions & 3 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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<SpendEstimate> {
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 {
Expand Down
5 changes: 5 additions & 0 deletions src/demo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
36 changes: 34 additions & 2 deletions src/ledger.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -132,6 +133,11 @@ pub struct Ledger {
pub days: BTreeMap<String, DayState>,
/// session key -> what that session is. Survives between scans.
pub session_meta: BTreeMap<String, SessionMeta>,
/// 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<String, String>,
/// Whether titles were being collected when this ledger was written.
pub titles_enabled: bool,
/// Cumulative counters, reported so a consumer can see dedup ran.
Expand All @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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");
Expand Down
62 changes: 62 additions & 0 deletions src/scan/usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
/// 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<String>,
}

/// What the usage scan read, alongside the ledger it read into.
Expand Down Expand Up @@ -404,6 +408,7 @@ pub fn parse_opencode(id: &str, value: &serde_json::Value) -> Option<Record> {
.and_then(|p| p.get("cwd"))
.and_then(|c| c.as_str())
.map(str::to_string),
plan: None,
})
}

Expand Down Expand Up @@ -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);
}
}
}

Expand Down Expand Up @@ -838,6 +846,7 @@ pub fn parse_claude_code(value: &serde_json::Value) -> Option<Record> {
.get("cwd")
.and_then(|c| c.as_str())
.map(str::to_string),
plan: None,
})
}

Expand Down Expand Up @@ -900,6 +909,11 @@ pub fn parse_codex(value: &serde_json::Value) -> Option<Record> {
// 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),
})
}

Expand Down Expand Up @@ -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
Expand Down
Loading