From 275d61695ac2296b1f10a6e4bb242de7eae92126 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 09:55:06 +0000 Subject: [PATCH] feat(cost): burn by window, the same for every engine; fold subagents in; price Fable 5.1 right MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `moshcode cost` answered "what did today cost". It now also answers "how fast is it going", with one set of windows for every engine: under the table, a `burn` block shows the last 1 min, 15 min, 1 hour, 4 hours, 8 hours and the report window, each with what the requests inside it cost, that cost per hour of window, and the runs behind it; `--json` carries the rows as `burn`, with the cost split by engine. Every reader now keeps its requests as samples (`{ at, usage, model, engineCost }`) in the shared usage shape, so bucketing is one function over all engines. Codex logs running totals, so a turn is the difference between two; aider stamps only its banner, so a run lands there. Two things the numbers exposed on the way: - Claude Code writes a session's subagent transcripts under `/subagents/` (workflow agents one level deeper), all carrying the parent's sessionId. The reader stopped at the top of the project directory and never saw them — on a box running workflows, about a quarter of the bill. They now fold into the parent's run, deduped by message id across the session because the parent replays a subagent's messages. - The rate card had no `claude-fable-5-1` entry, so the prefix match landed on `claude-fable-5` and priced cache reads at $1.00/MTok. Fable 5.1 reads the cache at $0.25, and on an agent session cache reads are most of the tokens. `claude-fable-5-1` and `claude-mythos-5-1` now carry `cacheRead: 0.25`. Closes #511. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_0138dZBCdJW73LJjT7kuZf8L --- README.md | 27 +++++- src/cli-schema.mjs | 6 +- src/cost-cli.mjs | 33 ++++++- src/cost-pricing.mjs | 17 +++- src/cost.mjs | 207 +++++++++++++++++++++++++++++++++++++++---- test/cost.test.mjs | 178 ++++++++++++++++++++++++++++++++++++- 6 files changed, 440 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 8d4dcc74..cadd8362 100644 --- a/README.md +++ b/README.md @@ -392,6 +392,31 @@ moshcode cost --json # for a script ⚠ no rate for gpt-5.6-sol — tokens counted, cost omitted. ``` +Under the table, **`burn` is the slope**: the same windows for every engine — +last 1 min, 15 min, 1 hour, 4 hours, 8 hours, and the report window — each with +what the requests inside it cost, that cost per hour of window, and the runs +behind it. `cost` says what the day cost; `burn` says whether the next hour will +cost the same, which is the number that decides whether to kill something. +From a box running a herd of claude sessions, `moshcode cost --all --since 8h`: + +``` + burn cost rate runs + last 1 min $1.32~ $79.16/h 2 + last 15 min $38.34~ $153.35/h 8 + last 1 hour $222.03~ $222.03/h 11 + last 4 hours $966.27~ $241.57/h 19 + last 8 hours $1786.84~ $223.35/h 45 + window (8h) $1786.84~ $223.33/h 45 + $3.72 a minute over the window, on average +``` + +The rows come from the same per-request records the table does, priced the +same way, so a claude herd and a codex herd are comparable on one screen. A +window longer than `--since` is left out rather than shown short. Codex logs +running totals, so a turn is the difference between two of them; aider stamps +only the start of a run, so its whole run lands there. `--json` carries the rows +as `burn`, each with its cost split by engine. + **`view` is a link — click it and the PR opens in your browser.** The cost table is where you notice a session that cost $300, and the next thing you want is the thing it produced, which lives on GitHub rather than on this machine. The @@ -408,7 +433,7 @@ click. `moshcode cost --json` always carries `pr` and `prs` in full. | engine | where the number comes from | |---|---| -| claude | per-message `usage` in `~/.claude/projects/**/*.jsonl` | +| claude | per-message `usage` in `~/.claude/projects/**/*.jsonl`; a session's subagents and workflow agents (`/subagents/…`) fold into its row | | codex | cumulative `token_count` events in `~/.codex/sessions/…` | | opencode, privacycode | the per-message `cost` each one computed itself | | aider | the running session total it prints into `.aider.chat.history.md` | diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index ed48b1f7..b1fabc40 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -149,7 +149,7 @@ export const CORE_CLI_COMMANDS = [ name: "cost", group: "runtime", description: "what each session is spending, read from the engines' own logs", - synopsis: [["moshcode cost [name] [--all] [--since 6h] [--json]", "session, engine, model, tokens, cost"]], + synopsis: [["moshcode cost [name] [--all] [--since 6h] [--json]", "session, engine, model, tokens, cost, and the burn by window"]], flags: [ ["--all", "every engine session on disk, herd or not", ""], ["--since ", "how far back to look (30m, 6h, 3d)", "24h"], @@ -168,7 +168,9 @@ export const CORE_CLI_COMMANDS = [ + "`~` was worked out from published rates and is what the tokens WOULD cost on the api; unmarked " + "figures are the engine's own arithmetic. models with no rate show tokens and no cost — add yours " + "to ~/.moshcode/pricing.json. gemini, kimi, deepseek and openagents log nothing readable, " - + "so they report no cost rather than zero.", + + "so they report no cost rather than zero. under the table, `burn` is the same windows for every " + + "engine — last 1 min, 15 min, 1 hour, 4 hours, 8 hours and the report window — with what the " + + "requests inside each cost, that cost per hour, and the runs behind it; --json carries them as `burn`.", }, // Everyone who has used a coding agent's own `/usage` types that word first, // and tokens-and-spend is exactly what `cost` already answers. diff --git a/src/cost-cli.mjs b/src/cost-cli.mjs index aae76cee..388251c0 100644 --- a/src/cost-cli.mjs +++ b/src/cost-cli.mjs @@ -9,7 +9,7 @@ // Nothing is sampled, nothing is proxied, and a number the engine itself // computed is never overwritten by our arithmetic. import { - DEFAULT_WINDOW_MS, UNCOSTED_ENGINES, attributeRuns, engineRuns, + DEFAULT_WINDOW_MS, UNCOSTED_ENGINES, attributeRuns, burn, engineRuns, formatTokens, formatUsd, totals, } from "./cost.mjs"; import { pricingFile } from "./cost-pricing.mjs"; @@ -114,6 +114,29 @@ function renderRuns(runs, { indent = " " } = {}) { ); } +/** + * The burn block: the same windows for every engine, under whatever table was + * just printed and over the same runs. The table answers "what did it cost" at + * one horizon; this is the slope, which is the number that decides whether to + * kill something. Printed only when something in it could be priced — a block + * of dashes says nothing the footer's warning does not. + */ +export function renderBurn(rows, { indent = " " } = {}) { + if (!rows.some((r) => r.cost != null)) return ""; + const body = table( + rows.map((r) => [ + bone(r.label), + costCell(r.cost, r.costSource), + r.perHour == null ? ash("—") : dim(`${formatUsd(r.perHour)}/h`), + dim(String(r.runs)), + ]), + { columns: ["burn", "cost", "rate", "runs"], header: true, indent: indent.length }, + ); + const window = rows.find((r) => r.key === "window"); + if (window?.perMinute == null) return body; + return `${body}\n${indent}${dim(`${formatUsd(window.perMinute)} a minute over the window, on average`)}`; +} + /** * Gather everything once: the roster, the engine runs in the window, and the * attribution between them. Returned whole so `--json`, the table, and the @@ -157,6 +180,9 @@ export async function costCommand(argv = [], { write = console.log } = {}) { const asJson = argv.includes("--json"); const all = argv.includes("--all"); const since = Date.now() - parseWindow(flagValue(argv, "--since")); + // The last burn row is the report window, named the way it was asked for. + const windowLabel = `window (${flagValue(argv, "--since") || "24h"})`; + const burnOver = (runs) => burn(runs, { since, windowLabel }); const engineFlag = flagValue(argv, "--engine"); const engines = engineFlag ? engineFlag.split(",").map((s) => s.trim()).filter(Boolean) : null; const watch = argv.includes("--watch"); @@ -182,6 +208,7 @@ export async function costCommand(argv = [], { write = console.log } = {}) { if (asJson) { write(JSON.stringify({ since, + burn: burnOver([...rows.flatMap((r) => r.runs), ...(name ? [] : report.unattributed)]), sessions: rows.map(({ name: n, engine, cwd, state, models, usage, cost, costSource, unpriced, pr, prs, runs }) => ({ name: n, engine, cwd, state, models, usage, cost, costSource, unpriced, pr, prs, runs: runs.map((r) => ({ id: r.id, model: r.model, usage: r.usage, cost: r.cost, costSource: r.costSource, start: r.start, end: r.end, pr: r.pr ?? null })), @@ -201,6 +228,8 @@ export async function costCommand(argv = [], { write = console.log } = {}) { return EXIT.matched; } write(renderRuns(runs)); + const pace = renderBurn(burnOver(runs)); + if (pace) { write(""); write(pace); } // The runs ARE the rows here, so they are what the total totals. And the // "not tied to a herd session" note would be describing the whole table // back at itself, so it stays off. @@ -215,6 +244,8 @@ export async function costCommand(argv = [], { write = console.log } = {}) { } write(renderCost(rows)); + const pace = renderBurn(burnOver(rows.flatMap((r) => r.runs))); + if (pace) { write(""); write(pace); } footer({ ...report, rows }, write); if (UNCOSTED_ENGINES.some((e) => rows.some((r) => r.engine === e))) { write(info(`${UNCOSTED_ENGINES.join(", ")} keep no usage log moshcode can read — those rows show no cost, not zero cost.`)); diff --git a/src/cost-pricing.mjs b/src/cost-pricing.mjs index 7552dc07..65d88a79 100644 --- a/src/cost-pricing.mjs +++ b/src/cost-pricing.mjs @@ -27,11 +27,22 @@ import { homedir } from "node:os"; * below, which is Anthropic's published relationship (and the only vendor whose * cache pricing this file claims to know). * - * Anthropic rates as published 2026-06; Sonnet 5's introductory $2/$10 runs - * through 2026-08-31 and is deliberately not encoded — an intro rate that - * expires silently would make this table wrong on a date nobody is watching. + * Anthropic rates as published 2026-06 (Fable 5.1 / Mythos 5.1 added 2026-09); + * Sonnet 5's introductory $2/$10 runs through 2026-08-31 and is deliberately + * not encoded — an intro rate that expires silently would make this table + * wrong on a date nobody is watching. */ export const PRICING = { + // Claude Fable 5.1 reads the cache at $0.25/MTok — 0.025× of input, a quarter + // of what CACHE_MULTIPLIERS would derive — so it carries its own `cacheRead`. + // It also needs its own entry: without one the prefix match in rateFor() + // lands on `claude-fable-5` and prices every cache read at four times the + // real rate, and on a long agent session cache reads are most of the tokens, + // so most of the estimate is wrong. Whether Mythos 5.1 shares the read rate + // was open at launch; it inherits Fable's here and ~/.moshcode/pricing.json + // overrides it. + "claude-fable-5-1": { input: 10, output: 50, cacheRead: 0.25 }, + "claude-mythos-5-1": { input: 10, output: 50, cacheRead: 0.25 }, "claude-fable-5": { input: 10, output: 50 }, "claude-mythos-5": { input: 10, output: 50 }, "claude-opus-5": { input: 5, output: 25 }, diff --git a/src/cost.mjs b/src/cost.mjs index 46d7b37a..92f581f3 100644 --- a/src/cost.mjs +++ b/src/cost.mjs @@ -33,6 +33,22 @@ import { EMPTY_USAGE, addUsage, priceUsage, loadUserPricing } from "./cost-prici /** Default reporting window: today's work, not the whole history on disk. */ export const DEFAULT_WINDOW_MS = 24 * 60 * 60 * 1000; +/** + * The horizons `moshcode cost` reports burn over — the same list for every + * engine, so a claude herd and a codex herd read on one scale. The short ones + * answer "how fast is it going right now"; the long ones answer "is this a + * spike or the new normal". burn() appends the report window itself as the + * last row, so the list ends at 8h and the table ends at whatever `--since` + * said. + */ +export const BURN_WINDOWS = [ + { key: "1m", label: "last 1 min", ms: 60e3 }, + { key: "15m", label: "last 15 min", ms: 15 * 60e3 }, + { key: "1h", label: "last 1 hour", ms: 3600e3 }, + { key: "4h", label: "last 4 hours", ms: 4 * 3600e3 }, + { key: "8h", label: "last 8 hours", ms: 8 * 3600e3 }, +]; + const home = () => homedir(); const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0); @@ -104,6 +120,13 @@ const stamp = (value) => { return Number.isFinite(t) ? t : null; }; +/** a − b per field, floored at zero: a running total that restarts must not bill negative tokens. */ +const diffUsage = (a, b) => { + const out = {}; + for (const k of Object.keys(EMPTY_USAGE)) out[k] = Math.max(0, num(a[k]) - num(b[k])); + return out; +}; + /** Paths compare after resolution, so `~/src/api` and `~/src/api/` are one place. */ const samePath = (a, b) => { if (!a || !b) return false; @@ -173,12 +196,18 @@ function claudePrOf(entry) { }; } -/** One Claude Code transcript → one run, or null when it holds no usage. */ -function readClaudeTranscript(file, { since }) { +/** + * One Claude Code transcript → one run, or null when it holds no usage. + * + * `seen` is the set of request ids already counted. It is shared across a + * session's transcripts because the parent replays a subagent's messages when + * it folds the output back in — the same id in two files is one API call. + */ +function readClaudeTranscript(file, { since, seen = new Set() }) { let text; try { text = fs.readFileSync(file, "utf8"); } catch { return null; } - const seen = new Set(); + let counted = 0; const byModel = new Map(); let usage = { ...EMPTY_USAGE }; let engineCost = 0; @@ -188,6 +217,7 @@ function readClaudeTranscript(file, { since }) { let cwd = ""; let id = path.basename(file, ".jsonl"); let pr = null; + const samples = []; for (const line of text.split("\n")) { if (!line || line.charCodeAt(0) !== 123) continue; // fast reject: not "{" @@ -222,18 +252,56 @@ function readClaudeTranscript(file, { since }) { const key = `${message.id || ""}|${entry.requestId || ""}`; if (key !== "|" && seen.has(key)) continue; seen.add(key); + counted += 1; usage = addUsage(usage, one); const model = message.model || "unknown"; byModel.set(model, addUsage(byModel.get(model) || EMPTY_USAGE, one)); - if (Number.isFinite(Number(entry.costUSD))) { engineCost += Number(entry.costUSD); hasEngineCost = true; } + const priced = Number.isFinite(Number(entry.costUSD)) ? Number(entry.costUSD) : null; + if (priced != null) { engineCost += priced; hasEngineCost = true; } + // Kept per request, on the engine's own clock, so burn() can put it in a + // window without a second pass over the transcript. + samples.push({ at, usage: one, model, engineCost: priced }); if (at != null) { start = start == null ? at : Math.min(start, at); end = end == null ? at : Math.max(end, at); } if (entry.cwd) cwd = entry.cwd; if (entry.sessionId) id = entry.sessionId; } - if (!seen.size) return null; - return { engine: "claude", id, cwd, usage, byModel, start, end, pr, engineCost: hasEngineCost ? engineCost : null }; + if (!counted) return null; + return { engine: "claude", id, cwd, usage, byModel, start, end, pr, samples, engineCost: hasEngineCost ? engineCost : null }; +} + +/** + * Every transcript under a project directory, the sessions and what their + * subagents wrote. A session is `.jsonl` beside a directory `/`, and + * that directory holds `subagents/.jsonl` plus, for a workflow, + * `subagents/workflows//.jsonl`. The depth cap is that shape and + * one to spare, so an unexpected tree cannot turn a cost report into a crawl. + */ +function claudeTranscripts(dir) { + const out = []; + const walk = (d, depth) => { + for (const entry of listDir(d)) { + const p = path.join(d, entry.name); + if (entry.isFile() && entry.name.endsWith(".jsonl")) out.push(p); + else if (entry.isDirectory() && depth < 5) walk(p, depth + 1); + } + }; + walk(dir, 0); + return out; +} + +/** Fold one transcript's run into another's: a session and its subagents are one bill. */ +function mergeClaudeRun(into, run) { + into.usage = addUsage(into.usage, run.usage); + for (const [model, u] of run.byModel) into.byModel.set(model, addUsage(into.byModel.get(model) || EMPTY_USAGE, u)); + into.samples.push(...run.samples); + if (run.start != null) into.start = into.start == null ? run.start : Math.min(into.start, run.start); + if (run.end != null) into.end = into.end == null ? run.end : Math.max(into.end, run.end); + if (run.pr && (!into.pr || (run.pr.at ?? 0) >= (into.pr.at ?? 0))) into.pr = run.pr; + if (run.engineCost != null) into.engineCost = (into.engineCost ?? 0) + run.engineCost; + if (!into.cwd && run.cwd) into.cwd = run.cwd; + return into; } function claudeRuns({ since, cwd } = {}) { @@ -243,20 +311,28 @@ function claudeRuns({ since, cwd } = {}) { : listDir(root).filter((e) => e.isDirectory()).map((e) => path.join(root, e.name)); const runs = []; + const seen = new Set(); for (const dir of dirs) { - for (const entry of listDir(dir)) { - if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue; - const file = path.join(dir, entry.name); + // A subagent's transcript records the parent's sessionId, and every request + // in it is one the parent caused, so it folds into the parent's run: a + // workflow that fans out to twenty agents is one bill, not twenty rows — + // and on a box running workflows it is most of the bill, which a reader + // that stops at the top of the directory never sees. + const byId = new Map(); + for (const file of claudeTranscripts(dir)) { // mtime is the cheap gate: a transcript untouched since before the window // cannot contain a request inside it, and there are thousands of these. const stat = safeStat(file); if (!stat || (since != null && stat.mtimeMs < since)) continue; - const run = readClaudeTranscript(file, { since }); + const run = readClaudeTranscript(file, { since, seen }); if (!run) continue; // The slug is lossy, so confirm against the cwd the transcript recorded. if (cwd && run.cwd && !samePath(run.cwd, cwd)) continue; - runs.push({ ...run, cwd: run.cwd || cwd || "" }); + const have = byId.get(run.id); + if (have) mergeClaudeRun(have, run); + else byId.set(run.id, { ...run, cwd: run.cwd || cwd || "" }); } + runs.push(...byId.values()); } return runs; } @@ -301,12 +377,23 @@ function readCodexRollout(file, { since, cwd }) { const tail = tailLines(file); let last = null; + let previous = null; + const samples = []; for (const line of tail) { const entry = parseJson(line); if (!entry) continue; - if (entry.payload?.type === "token_count" && entry.payload?.info) last = entry; - // The model can change mid-rollout; the last turn_context wins. + // The model can change mid-rollout; the last turn_context wins, and a + // turn is priced at whichever model was current when it was recorded. if (entry.type === "turn_context" && entry.payload?.model) model = entry.payload.model; + if (entry.payload?.type !== "token_count" || !entry.payload?.info) continue; + last = entry; + // Each event is a running total, so one turn's usage is the difference + // from the event before it. The first event in the tail carries every turn + // before it; those land on its timestamp, which is as close as a + // cumulative log gets without reading the whole file. + const total = codexUsageOf(entry.payload.info); + samples.push({ at: stamp(entry.timestamp), usage: previous ? diffUsage(total, previous) : total, model: model || "unknown", engineCost: null }); + previous = total; } if (!last) return null; @@ -323,7 +410,7 @@ function readCodexRollout(file, { since, cwd }) { cwd: meta.cwd || cwd || "", usage, byModel: new Map([[model || "unknown", usage]]), - start, end, engineCost: null, + start, end, samples, engineCost: null, }; } @@ -421,7 +508,7 @@ async function opencodeRuns(engine, { since, cwd } = {}) { const run = bySession.get(key) || { engine, id: key, cwd: data.path?.cwd || cwd || "", usage: { ...EMPTY_USAGE }, byModel: new Map(), - start: null, end: null, engineCost: 0, + start: null, end: null, samples: [], engineCost: 0, }; run.usage = addUsage(run.usage, one); const model = data.modelID || "unknown"; @@ -431,6 +518,7 @@ async function opencodeRuns(engine, { since, cwd } = {}) { run.engineCost += num(data.cost); const at = num(row.time_created) || stamp(data.time?.created); if (at) { run.start = run.start == null ? at : Math.min(run.start, at); run.end = run.end == null ? at : Math.max(run.end, at); } + run.samples.push({ at: at || null, usage: one, model, engineCost: num(data.cost) || null }); bySession.set(key, run); } } catch { @@ -543,7 +631,7 @@ export function qwenRuns({ since, cwd } = {}) { run = { engine: "qwen", id, cwd: where, usage: { ...EMPTY_USAGE }, byModel: new Map(), - start: null, end: null, engineCost: null, + start: null, end: null, samples: [], engineCost: null, }; sessions.set(id, run); } @@ -551,6 +639,7 @@ export function qwenRuns({ since, cwd } = {}) { const model = record.model || "unknown"; run.usage = addUsage(run.usage, one); run.byModel.set(model, addUsage(run.byModel.get(model) || EMPTY_USAGE, one)); + run.samples.push({ at, usage: one, model, engineCost: null }); if (at != null) { run.start = run.start == null ? at : Math.min(run.start, at); run.end = run.end == null ? at : Math.max(run.end, at); @@ -585,7 +674,15 @@ export function parseAiderHistory(text, { since } = {}) { const runs = []; const lines = String(text).split("\n"); let current = null; - const close = () => { if (current && (current.engineCost || current.usage.input || current.usage.output)) runs.push(current); current = null; }; + // aider stamps the banner and nothing after it, so a run is one sample at + // its start: burn() can place it, and the README says the placement is coarse. + const close = () => { + if (current && (current.engineCost || current.usage.input || current.usage.output)) { + current.samples = [{ at: current.start, usage: current.usage, model: null, engineCost: current.engineCost || null }]; + runs.push(current); + } + current = null; + }; for (const line of lines) { const banner = /^#\s*aider chat started at\s+(.+?)\s*$/i.exec(line); @@ -765,6 +862,82 @@ export function totals(items = []) { return { cost, usage, unpriced: [...unpriced] }; } +// --------------------------------------------------------------------------- +// Burn — the same windows for every engine +// --------------------------------------------------------------------------- + +/** + * One request's dollars, priced the way its run is: the engine's own figure + * when it wrote one for that request, the rate card otherwise, null when there + * is no rate. `source` says which, so a row can carry the `~`. + */ +function sampleCost(sample, options) { + const own = Number(sample.engineCost); + if (Number.isFinite(own) && own > 0) return { cost: own, source: "engine" }; + const priced = priceUsage(sample.model, sample.usage, options); + return priced == null ? { cost: null, source: null } : { cost: priced, source: "rates" }; +} + +/** + * Spend per window, every engine on one clock. + * + * Each run carries the requests it was made of (`samples`), stamped with the + * time its engine recorded. A row is a window ending `now`: what the requests + * inside it cost, that cost per hour of window, and how many runs made them. + * The report window (`since`) is the last row — the figure the table already + * totals, now with its rate — and a standard window longer than the report + * window is left out rather than shown short, because "last 8 hours" over one + * hour of data is a number that lies. + * + * `perHour` divides by the window, not by the time the agents were busy: a run + * that worked ten minutes of the last hour shows a tenth of its pace, which is + * the pace the bill sees. A request with no price counts toward `runs` and + * `unpriced` and nothing toward `cost`; one with no timestamp cannot be placed + * and is left out. + */ +export function burn(runs = [], { + now = Date.now(), since = null, windows = BURN_WINDOWS, windowLabel = "window", userPricing = loadUserPricing(), +} = {}) { + const rows = windows + .filter((w) => since == null || now - w.ms >= since) + .map((w) => ({ key: w.key, label: w.label, ms: w.ms, from: now - w.ms })); + if (since != null && now > since) rows.push({ key: "window", label: windowLabel, ms: now - since, from: since }); + const acc = rows.map(() => ({ cost: null, ids: new Set(), engines: {}, unpriced: new Set(), sources: new Set() })); + + for (const run of runs) { + for (const sample of run.samples || []) { + if (sample.at == null) continue; + const { cost, source } = sampleCost(sample, { userPricing }); + rows.forEach((row, i) => { + if (sample.at < row.from) return; + const a = acc[i]; + a.ids.add(`${run.engine}:${run.id}`); + if (cost == null) { if (sample.model) a.unpriced.add(sample.model); return; } + a.cost = (a.cost ?? 0) + cost; + a.engines[run.engine] = (a.engines[run.engine] ?? 0) + cost; + a.sources.add(source); + }); + } + } + + return rows.map((row, i) => { + const a = acc[i]; + const hours = row.ms / 3600e3; + return { + key: row.key, label: row.label, ms: row.ms, from: row.from, + cost: a.cost, + perHour: a.cost == null ? null : a.cost / hours, + perMinute: a.cost == null ? null : a.cost / (hours * 60), + runs: a.ids.size, + engines: a.engines, + unpriced: [...a.unpriced], + // One source is that source; a measured price next to an estimated one + // is "mixed", and the weaker claim is the true one for the sum. + costSource: a.sources.size === 0 ? null : a.sources.size === 1 ? [...a.sources][0] : "mixed", + }; + }); +} + // --------------------------------------------------------------------------- // Formatting helpers, shared by the CLI and the bar // --------------------------------------------------------------------------- diff --git a/test/cost.test.mjs b/test/cost.test.mjs index b2fbad25..b5101dde 100644 --- a/test/cost.test.mjs +++ b/test/cost.test.mjs @@ -8,7 +8,7 @@ import { fileURLToPath } from "node:url"; import test from "node:test"; import { - attributeRuns, claudeProjectSlugs, engineRuns, formatTokens, formatUsd, + attributeRuns, burn, claudeProjectSlugs, engineRuns, formatTokens, formatUsd, parseAiderHistory, totals, } from "../src/cost.mjs"; import { addUsage, priceUsage, rateFor, totalTokens } from "../src/cost-pricing.mjs"; @@ -65,6 +65,18 @@ test("pricing", async (t) => { assert.equal(priceUsage("claude-opus-5", { cacheWrite1h: 1e6 }), 10); }); + await t.test("Claude Fable 5.1 reads the cache at a quarter cent, not a tenth of input", () => { + // Without its own entry the prefix match lands on claude-fable-5 and prices + // cache reads at $1.00/MTok — four times the published $0.25, on the token + // class that is most of a long agent session. + assert.equal(priceUsage("claude-fable-5-1", { cacheRead: 1e6 }), 0.25); + assert.equal(priceUsage("claude-fable-5-1[1m]", { cacheRead: 1e6 }), 0.25); + assert.equal(priceUsage("claude-fable-5-1", { input: 1e6, output: 1e6 }), 60); + // Writes still derive from the input rate: a one-hour write is double it. + assert.equal(priceUsage("claude-fable-5-1", { cacheWrite1h: 1e6 }), 20); + assert.equal(priceUsage("claude-fable-5", { cacheRead: 1e6 }), 1); + }); + await t.test("matches dated snapshots and provider prefixes", () => { assert.deepEqual(rateFor("claude-haiku-4-5-20251001"), { input: 1, output: 5 }); assert.deepEqual(rateFor("anthropic/claude-sonnet-5"), { input: 3, output: 15 }); @@ -191,6 +203,44 @@ test("claude transcripts", async (t) => { assert.equal(run.pr, null); })); + await t.test("every request is kept as a sample, on the engine's clock", () => withHome(async (home) => { + const cwd = "/home/anthony/src/api"; + const dir = path.join(home, ".claude", "projects", claudeProjectSlugs(cwd)[0]); + const earlier = new Date(Date.now() - 600e3).toISOString(); + const later = new Date().toISOString(); + write(path.join(dir, "s.jsonl"), [ + claudeAssistant({ id: "m1", requestId: "r1", at: earlier, cwd, sessionId: "s", usage: USAGE }), + claudeAssistant({ id: "m2", requestId: "r2", at: later, cwd, sessionId: "s", usage: USAGE }), + ].join("\n")); + + const [run] = await engineRuns({ since: Date.now() - 3600e3, engines: ["claude"] }); + assert.equal(run.samples.length, 2); + assert.deepEqual(run.samples.map((s) => s.at), [Date.parse(earlier), Date.parse(later)]); + assert.equal(run.samples[0].model, "claude-opus-5"); + assert.equal(run.samples[0].usage.output, 2000); + assert.equal(run.samples[0].engineCost, null); + })); + + await t.test("a session's subagents and workflow agents fold into its run", () => withHome(async (home) => { + const cwd = "/home/anthony/src/api"; + const dir = path.join(home, ".claude", "projects", claudeProjectSlugs(cwd)[0]); + const at = new Date().toISOString(); + write(path.join(dir, "s.jsonl"), claudeAssistant({ id: "m1", requestId: "r1", at, cwd, sessionId: "s", usage: USAGE })); + write(path.join(dir, "s", "subagents", "agent-1.jsonl"), claudeAssistant({ id: "m2", requestId: "r2", at, cwd, sessionId: "s", usage: USAGE })); + write(path.join(dir, "s", "subagents", "workflows", "wf_1", "agent-2.jsonl"), [ + claudeAssistant({ id: "m3", requestId: "r3", at, cwd, sessionId: "s", usage: USAGE }), + // The parent replays a subagent's message when it folds the output in: + // the same id across two files is still one request. + claudeAssistant({ id: "m1", requestId: "r1", at, cwd, sessionId: "s", usage: USAGE }), + ].join("\n")); + + const runs = await engineRuns({ since: Date.now() - 3600e3, engines: ["claude"] }); + assert.equal(runs.length, 1, "one session, however many agents it spawned"); + assert.equal(runs[0].id, "s"); + assert.equal(runs[0].usage.output, 6000); + assert.equal(runs[0].samples.length, 3); + })); + await t.test("a replayed message is counted once", () => withHome(async (home) => { const cwd = "/home/anthony/src/api"; const dir = path.join(home, ".claude", "projects", claudeProjectSlugs(cwd)[0]); @@ -282,6 +332,27 @@ test("codex rollouts", async (t) => { assert.deepEqual(run.unpriced, ["gpt-5.6-sol"]); })); + await t.test("a turn is the difference between two running totals", () => withHome(async (home) => { + const at = new Date().toISOString(); + const day = at.slice(0, 10).split("-"); + write( + path.join(home, ".codex", "sessions", day[0], day[1], day[2], "rollout-x.jsonl"), + rollout({ cwd: "/home/anthony/src/api", at, total: { input_tokens: 1000, cached_input_tokens: 600, cache_write_input_tokens: 50, output_tokens: 200 } }), + ); + + const [run] = await engineRuns({ since: Date.now() - 3600e3, engines: ["codex"] }); + assert.equal(run.samples.length, 2); + // First event: 10 in of which 5 cached → 5 fresh. Second: 400 fresh in all, + // so 395 for this turn — not 400 again. + assert.equal(run.samples[0].usage.input, 5); + assert.equal(run.samples[1].usage.input, 395); + assert.equal(run.samples[1].usage.output, 198); + assert.equal(run.samples[1].usage.cacheRead, 595); + assert.equal(run.samples[1].usage.cacheWrite5m, 49); + assert.equal(run.samples[0].model, "gpt-5.6-sol"); + assert.equal(run.samples[0].at, Date.parse(at)); + })); + await t.test("a rollout in another directory is not this directory's cost", () => withHome(async (home) => { const at = new Date().toISOString(); const day = at.slice(0, 10).split("-"); @@ -453,6 +524,67 @@ test("attribution", async (t) => { }); }); +test("burn", async (t) => { + const NOW = Date.parse("2026-09-13T12:00:00Z"); + const usage = { input: 1e6, output: 0, cacheRead: 0, cacheWrite5m: 0, cacheWrite1h: 0 }; // $5 on claude-opus-5 + const run = (id, samples, over = {}) => ({ engine: "claude", id, samples, ...over }); + const sample = (minutesAgo, over = {}) => ({ at: NOW - minutesAgo * 60e3, usage, model: "claude-opus-5", engineCost: null, ...over }); + const day = { now: NOW, since: NOW - 24 * 3600e3, userPricing: {} }; + + await t.test("every standard window is a row, the report window is last, a longer one is dropped", () => { + const rows = burn([], { now: NOW, since: NOW - 2 * 3600e3, windowLabel: "window (2h)", userPricing: {} }); + assert.deepEqual(rows.map((r) => r.key), ["1m", "15m", "1h", "window"]); + assert.equal(rows.at(-1).label, "window (2h)"); + assert.equal(rows.at(-1).ms, 2 * 3600e3); + assert.deepEqual(burn([], { now: NOW, userPricing: {} }).map((r) => r.key), ["1m", "15m", "1h", "4h", "8h"]); + }); + + await t.test("a request lands in every window that reaches back to it", () => { + const rows = burn([run("a", [sample(0.5), sample(10), sample(50), sample(200), sample(600)])], day); + const by = Object.fromEntries(rows.map((r) => [r.key, r])); + assert.equal(by["1m"].cost, 5); + assert.equal(by["15m"].cost, 10); + assert.equal(by["1h"].cost, 15); + assert.equal(by["4h"].cost, 20); + assert.equal(by["8h"].cost, 20); // 600 minutes is ten hours: outside + assert.equal(by.window.cost, 25); + // Per hour of window, not per hour of activity. + assert.equal(by["1h"].perHour, 15); + assert.equal(by["4h"].perHour, 5); + assert.equal(by.window.perMinute, 25 / (24 * 60)); + assert.equal(by["1m"].costSource, "rates"); + assert.equal(by["1m"].runs, 1); + }); + + await t.test("runs are counted per window and the engine's own price wins", () => { + const rows = burn([ + run("a", [sample(5)]), + run("b", [sample(5, { engineCost: 0.75 })], { engine: "opencode" }), + run("c", [sample(300)]), + ], day); + const by = Object.fromEntries(rows.map((r) => [r.key, r])); + assert.equal(by["15m"].runs, 2); + assert.equal(by["15m"].cost, 5.75); + assert.equal(by["15m"].costSource, "mixed"); + assert.deepEqual(by["15m"].engines, { claude: 5, opencode: 0.75 }); + assert.equal(by.window.runs, 3); + }); + + await t.test("an unpriced request is counted and named, never billed as zero", () => { + const rows = burn([run("x", [sample(1, { model: "gpt-5.6-sol" })], { engine: "codex" })], { now: NOW, since: NOW - 3600e3, userPricing: {} }); + const row = rows.find((r) => r.key === "15m"); + assert.equal(row.cost, null); + assert.equal(row.perHour, null); + assert.equal(row.runs, 1); + assert.deepEqual(row.unpriced, ["gpt-5.6-sol"]); + }); + + await t.test("a sample with no timestamp cannot be placed and is left out", () => { + const rows = burn([run("a", [{ at: null, usage, model: "claude-opus-5", engineCost: null }])], { now: NOW, since: NOW - 3600e3, userPricing: {} }); + assert.ok(rows.every((r) => r.cost == null && r.runs === 0)); + }); +}); + test("moshcode cost", async (t) => { // The CLI is the only surface: `--json` is what a script reads, so it has to // survive the whole path — router, herd verb table, readers, pricing. @@ -500,6 +632,40 @@ test("moshcode cost", async (t) => { assert.match(out, /total\s+\$\d/); })); + await t.test("--json carries the burn windows over the same runs", () => withHome(async (home) => { + const cwd = "/home/anthony/src/api"; + const dir = path.join(home, ".claude", "projects", claudeProjectSlugs(cwd)[0]); + write(path.join(dir, "s.jsonl"), claudeAssistant({ + id: "m", requestId: "r", at: new Date().toISOString(), cwd, sessionId: "s", usage: USAGE, + })); + + const { code, out } = await run(["cost", "--all", "--json", "--since", "1h"], home); + assert.equal(code, 0); + const report = JSON.parse(out); + assert.deepEqual(report.burn.map((b) => b.key), ["1m", "15m", "1h", "window"]); + assert.equal(report.burn.at(-1).label, "window (1h)"); + // One request, written just now: every row holds it, and it is the total. + assert.equal(report.burn[0].cost, report.totals.cost); + assert.equal(report.burn[0].runs, 1); + assert.ok(report.burn[0].perHour > report.burn[0].cost); + assert.deepEqual(Object.keys(report.burn[0].engines), ["claude"]); + })); + + await t.test("the table is followed by the burn block", () => withHome(async (home) => { + const cwd = "/home/anthony/src/api"; + const dir = path.join(home, ".claude", "projects", claudeProjectSlugs(cwd)[0]); + write(path.join(dir, "s.jsonl"), claudeAssistant({ + id: "m", requestId: "r", at: new Date().toISOString(), cwd, sessionId: "s", usage: USAGE, + })); + + const { code, out } = await run(["cost", "--all", "--since", "1h"], home); + assert.equal(code, 0); + assert.match(out, /burn\s+cost\s+rate\s+runs/); + assert.match(out, /last 15 min\s+\$[\d.]+~\s+\$[\d.]+\/h\s+1/); + assert.match(out, /window \(1h\)/); + assert.match(out, /a minute over the window/); + })); + await t.test("an unknown session name is an error, not an empty report", () => withHome(async (home) => { const { code, out } = await run(["cost", "nope"], home); assert.equal(code, 3); @@ -524,9 +690,13 @@ test("moshcode cost", async (t) => { const asUsage = await run(["usage", "--all", "--json", "--since", "1h"], home); const asCost = await run(["cost", "--all", "--json", "--since", "1h"], home); assert.equal(asUsage.code, 0); - // `since` is the wall clock at the moment each ran, so it is the one field - // two identical reports are allowed to disagree about. - const body = ({ since, ...rest }) => rest; + // `since` is the wall clock at the moment each ran, and the burn rows are + // measured from it, so the clock-derived fields are the ones two identical + // reports are allowed to disagree about. Costs and counts are not. + const body = ({ since, burn, ...rest }) => ({ + ...rest, + burn: burn.map(({ from, ms, perHour, perMinute, ...row }) => row), + }); assert.deepEqual(body(JSON.parse(asUsage.out)), body(JSON.parse(asCost.out))); }));