burner-cc 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,31 @@
1
+ <img src="assets/logo-mark.png" alt="Burner" width="90" />
2
+
3
+ # burner
4
+
5
+ Itemized dollar-and-token cost receipts for Claude Code sessions.
6
+
7
+ ## Usage
8
+
9
+ ```
10
+ node bin/burner-cc.js # latest session -> opens a receipt
11
+ node bin/burner-cc.js <session-id> # specific session (accepts a prefix)
12
+ node bin/burner-cc.js --all # every session found, sliding through one receipt each
13
+ node bin/burner-cc.js list # quick terminal table, no browser
14
+ node bin/burner-cc.js --json # machine-readable, no browser
15
+ ```
16
+
17
+ Once published: `npx burner-cc`, `npx burner-cc --all`, etc.
18
+
19
+ ## How it works
20
+
21
+ - **Discovery** (`src/discover.js`) walks `~/.claude/projects/**/*.jsonl`, matching each top-level session transcript with any `subagents/agent-*.jsonl` files spawned inside it (those are billed separately by the API and get folded into the parent session's receipt).
22
+ - **Parsing** (`src/parse.js`) reads each assistant turn's `usage` block (input/output/cache-write/cache-read tokens) straight from the transcript.
23
+ - **Pricing** (`src/pricing.js`) is fetched automatically — no file to hand-edit. It pulls Anthropic's per-model rates from [LiteLLM's community-maintained pricing JSON](https://github.com/BerriAI/litellm) (sourced from `platform.claude.com/docs/.../pricing`), caches it locally for 6 hours, and falls back to a stale cache or a small bundled snapshot (`src/pricing-fallback.json`) if offline.
24
+ - **Receipt** (`src/build-receipt.js`) groups tokens by model and computes cost per token-type line.
25
+ - **Rendering** (`src/render.js` + `web/`) injects the receipt JSON into `web/receipt.html` and opens it in the default browser. Theme buttons (paper / thermal / matrix / dark) switch CSS variables client-side.
26
+
27
+
28
+
29
+ ## What this is not
30
+
31
+ Not official billing. Composition breakdown by *source* (system prompt vs. skills vs. MCP tool schemas) isn't available from local transcripts alone — that needs request interception, which this tool doesn't do. The receipt instead itemizes by token *type*: fresh input, cache write, cache read, output.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../src/cli.js';
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "burner-cc",
3
+ "version": "0.1.0",
4
+ "description": "Itemized dollar-and-token cost receipts for Claude Code sessions.",
5
+ "type": "module",
6
+ "bin": {
7
+ "burner-cc": "bin/burner-cc.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "web"
13
+ ],
14
+ "keywords": [
15
+ "claude-code",
16
+ "claude",
17
+ "anthropic",
18
+ "cost",
19
+ "tokens",
20
+ "cli",
21
+ "usage",
22
+ "billing"
23
+ ],
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/shoryasethia/burner.git"
27
+ },
28
+ "homepage": "https://github.com/shoryasethia/burner#readme",
29
+ "bugs": {
30
+ "url": "https://github.com/shoryasethia/burner/issues"
31
+ },
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
35
+ "license": "MIT",
36
+ "author": "Shorya"
37
+ }
@@ -0,0 +1,22 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+
5
+ // Claude Code can run on pay-per-token API billing, or on a flat-fee
6
+ // Pro/Max/Team/Enterprise subscription where tokens don't map to dollars
7
+ // the way this tool assumes. There's no reliable local signal for *which*
8
+ // subscription tier someone's on, but ~/.claude/config.json having a
9
+ // primaryApiKey is a reasonable signal that this account bills per token.
10
+ // We only check whether the field exists — its value is never read into
11
+ // anything we log, render, or persist.
12
+ export function detectBillingMode() {
13
+ const configPath = path.join(os.homedir(), '.claude', 'config.json');
14
+ if (!existsSync(configPath)) return 'unknown';
15
+ try {
16
+ const config = JSON.parse(readFileSync(configPath, 'utf-8'));
17
+ const hasApiKey = typeof config.primaryApiKey === 'string' && config.primaryApiKey.length > 0;
18
+ return hasApiKey ? 'api-key' : 'subscription-likely';
19
+ } catch {
20
+ return 'unknown';
21
+ }
22
+ }
@@ -0,0 +1,133 @@
1
+ import { parseTranscript, timeRange, mergeActivity } from './parse.js';
2
+ import { lookupPricing, costFor, loadPricingTable } from './pricing.js';
3
+
4
+ function summarizeTurns(turns, table) {
5
+ // Group by the *priced* model identity (post lookup), not the raw string,
6
+ // so aliases like "sonnet" fold into the concrete model they resolved to.
7
+ const byModel = new Map();
8
+
9
+ for (const t of turns) {
10
+ const priced = lookupPricing(t.model, table);
11
+ const key = priced.displayName;
12
+ if (!byModel.has(key)) {
13
+ byModel.set(key, {
14
+ displayName: priced.displayName,
15
+ fidelity: priced.fidelity,
16
+ rates: priced.rates,
17
+ tokens: { input: 0, output: 0, cacheWrite5m: 0, cacheWrite1h: 0, cacheRead: 0 },
18
+ turnCount: 0,
19
+ });
20
+ }
21
+ const bucket = byModel.get(key);
22
+ bucket.turnCount += 1;
23
+ bucket.tokens.input += t.input;
24
+ bucket.tokens.output += t.output;
25
+ bucket.tokens.cacheWrite5m += t.cacheWrite5m;
26
+ bucket.tokens.cacheWrite1h += t.cacheWrite1h;
27
+ bucket.tokens.cacheRead += t.cacheRead;
28
+ }
29
+
30
+ const models = [];
31
+ for (const bucket of byModel.values()) {
32
+ const cost = costFor(bucket.tokens, bucket.rates);
33
+ models.push({
34
+ displayName: bucket.displayName,
35
+ fidelity: bucket.fidelity,
36
+ turnCount: bucket.turnCount,
37
+ tokens: bucket.tokens,
38
+ cost, // null if unpriced
39
+ });
40
+ }
41
+ return models;
42
+ }
43
+
44
+ function sumCost(models) {
45
+ return models.reduce((acc, m) => {
46
+ if (!m.cost) return acc;
47
+ acc.input += m.cost.input;
48
+ acc.output += m.cost.output;
49
+ acc.cacheWrite += m.cost.cacheWrite;
50
+ acc.cacheRead += m.cost.cacheRead;
51
+ acc.total += m.cost.total;
52
+ return acc;
53
+ }, { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, total: 0 });
54
+ }
55
+
56
+ function sumTokens(models) {
57
+ return models.reduce((acc, m) => {
58
+ acc.input += m.tokens.input;
59
+ acc.output += m.tokens.output;
60
+ acc.cacheWrite5m += m.tokens.cacheWrite5m;
61
+ acc.cacheWrite1h += m.tokens.cacheWrite1h;
62
+ acc.cacheRead += m.tokens.cacheRead;
63
+ return acc;
64
+ }, { input: 0, output: 0, cacheWrite5m: 0, cacheWrite1h: 0, cacheRead: 0 });
65
+ }
66
+
67
+ // Groups subagent runs by their agentType (from the sidecar .meta.json),
68
+ // each with its own real, measured cost — instead of lumping every
69
+ // subagent run into one "SUBAGENTS" blob.
70
+ function summarizeSubagentsByType(subagentFiles, table) {
71
+ const byType = new Map();
72
+
73
+ for (const { file, meta } of subagentFiles) {
74
+ const type = meta?.agentType ?? 'unknown';
75
+ const { turns } = parseTranscript(file);
76
+ if (!byType.has(type)) byType.set(type, { agentType: type, runCount: 0, turns: [] });
77
+ const bucket = byType.get(type);
78
+ bucket.runCount += 1;
79
+ bucket.turns.push(...turns);
80
+ }
81
+
82
+ return [...byType.values()].map((bucket) => ({
83
+ agentType: bucket.agentType,
84
+ runCount: bucket.runCount,
85
+ models: summarizeTurns(bucket.turns, table),
86
+ }));
87
+ }
88
+
89
+ // Builds one receipt object for a discovered session (main transcript +
90
+ // any subagent transcripts spawned within it). `table` is the pricing
91
+ // table already resolved once via loadPricingTable() by the caller.
92
+ export function buildReceipt(session, table) {
93
+ const { turns: mainTurns, activity: mainActivity } = parseTranscript(session.mainFile);
94
+ const subagentParsed = session.subagentFiles.map(({ file }) => parseTranscript(file));
95
+ const subagentTurns = subagentParsed.flatMap((p) => p.turns);
96
+
97
+ const mainModels = summarizeTurns(mainTurns, table);
98
+ const subagentGroups = summarizeSubagentsByType(session.subagentFiles, table);
99
+ const allSubagentModels = subagentGroups.flatMap((g) => g.models);
100
+
101
+ const activity = mergeActivity([mainActivity, ...subagentParsed.map((p) => p.activity)]);
102
+
103
+ const { startedAt, endedAt } = timeRange([...mainTurns, ...subagentTurns]);
104
+ const hasUnpriced = [...mainModels, ...allSubagentModels].some((m) => !m.cost);
105
+
106
+ const totals = {
107
+ tokens: sumTokens([...mainModels, ...allSubagentModels]),
108
+ cost: sumCost([...mainModels, ...allSubagentModels]),
109
+ };
110
+
111
+ return {
112
+ sessionId: session.sessionId,
113
+ title: session.title,
114
+ project: session.project,
115
+ startedAt,
116
+ endedAt,
117
+ turnCount: mainTurns.length,
118
+ subagentCount: session.subagentFiles.length,
119
+ models: mainModels,
120
+ subagentGroups,
121
+ activity,
122
+ totals,
123
+ hasUnpriced,
124
+ pricingSource: table.source,
125
+ };
126
+ }
127
+
128
+ export async function buildAllReceipts(sessions) {
129
+ const table = await loadPricingTable();
130
+ return sessions.map((s) => buildReceipt(s, table));
131
+ }
132
+
133
+ export { loadPricingTable };
package/src/cli.js ADDED
@@ -0,0 +1,81 @@
1
+ import { discoverSessions, findSession, latestSession } from './discover.js';
2
+ import { buildReceipt, loadPricingTable } from './build-receipt.js';
3
+ import { renderReceipts, openInBrowser } from './render.js';
4
+ import { detectBillingMode } from './billing-mode.js';
5
+
6
+ function fmtUsd(n) {
7
+ return '$' + n.toFixed(3);
8
+ }
9
+
10
+ function printList(sessions, table) {
11
+ console.log('SESSION TITLE PROJECT DATE MODELS COST');
12
+ for (const s of sessions) {
13
+ const r = buildReceipt(s, table);
14
+ const date = r.startedAt ? new Date(r.startedAt).toISOString().slice(0, 10) : 'unknown';
15
+ const models = r.models.map((m) => m.displayName.replace(/\s*\(.*\)/, '')).join(', ') || 'none';
16
+ const title = (s.title ?? '').slice(0, 20);
17
+ console.log(
18
+ `${s.sessionId.slice(0, 8)} ${title.padEnd(20)} ${s.project.slice(0, 25).padEnd(25)} ${date.padEnd(12)} ${models.padEnd(8)} ${fmtUsd(r.totals.cost.total)}`
19
+ );
20
+ }
21
+ }
22
+
23
+ async function main() {
24
+ const args = process.argv.slice(2);
25
+ const asJson = args.includes('--json');
26
+ const filtered = args.filter((a) => a !== '--json');
27
+
28
+ if (filtered[0] === 'list') {
29
+ const sessions = discoverSessions();
30
+ if (!sessions.length) {
31
+ console.log('No Claude Code sessions found under ~/.claude/projects.');
32
+ return;
33
+ }
34
+ const table = await loadPricingTable();
35
+ if (asJson) {
36
+ const billingMode = detectBillingMode();
37
+ console.log(JSON.stringify(sessions.map((s) => ({ ...buildReceipt(s, table), billingMode })), null, 2));
38
+ } else {
39
+ printList(sessions, table);
40
+ }
41
+ return;
42
+ }
43
+
44
+ let sessions;
45
+ if (filtered.includes('--all')) {
46
+ sessions = discoverSessions();
47
+ } else if (filtered[0]) {
48
+ const found = findSession(filtered[0]);
49
+ if (!found) {
50
+ console.error(`No session found matching "${filtered[0]}". Try "burner-cc list".`);
51
+ process.exitCode = 1;
52
+ return;
53
+ }
54
+ sessions = [found];
55
+ } else {
56
+ const latest = latestSession();
57
+ if (!latest) {
58
+ console.log('No Claude Code sessions found under ~/.claude/projects.');
59
+ return;
60
+ }
61
+ sessions = [latest];
62
+ }
63
+
64
+ const table = await loadPricingTable();
65
+ const billingMode = detectBillingMode();
66
+ const receipts = sessions.map((s) => ({ ...buildReceipt(s, table), billingMode }));
67
+
68
+ if (asJson) {
69
+ console.log(JSON.stringify(receipts, null, 2));
70
+ return;
71
+ }
72
+
73
+ const outFile = renderReceipts(receipts);
74
+ console.log(`Receipt written to ${outFile}`);
75
+ openInBrowser(outFile);
76
+ }
77
+
78
+ main().catch((err) => {
79
+ console.error('burner-cc failed:', err.message);
80
+ process.exitCode = 1;
81
+ });
@@ -0,0 +1,125 @@
1
+ import { readdirSync, statSync, readFileSync, existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+
5
+ function claudeProjectsRoot() {
6
+ return path.join(os.homedir(), '.claude', 'projects');
7
+ }
8
+
9
+ function walk(dir, out = []) {
10
+ let entries;
11
+ try {
12
+ entries = readdirSync(dir, { withFileTypes: true });
13
+ } catch {
14
+ return out;
15
+ }
16
+ for (const entry of entries) {
17
+ const full = path.join(dir, entry.name);
18
+ if (entry.isDirectory()) {
19
+ walk(full, out);
20
+ } else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
21
+ out.push(full);
22
+ }
23
+ }
24
+ return out;
25
+ }
26
+
27
+ // Each "agent-<hash>.jsonl" subagent transcript has a sibling
28
+ // "agent-<hash>.meta.json" naming its agent type and task description —
29
+ // that's what lets a receipt break subagent cost down by type instead of
30
+ // lumping every subagent run together.
31
+ function readSubagentMeta(subagentFile) {
32
+ const metaPath = subagentFile.replace(/\.jsonl$/, '.meta.json');
33
+ if (!existsSync(metaPath)) return null;
34
+ try {
35
+ return JSON.parse(readFileSync(metaPath, 'utf-8'));
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ // A session's human-readable name isn't the UUID filename — Claude Code
42
+ // logs it as a "custom-title" event when the user runs /rename, falling
43
+ // back to the auto-generated "slug" (e.g. "wise-bubbling-wadler") that's
44
+ // stamped on every line. Either one should be usable to find a session.
45
+ function readSessionTitle(mainFile) {
46
+ let text;
47
+ try {
48
+ text = readFileSync(mainFile, 'utf-8');
49
+ } catch {
50
+ return null;
51
+ }
52
+ let title = null;
53
+ let slug = null;
54
+ for (const line of text.split('\n')) {
55
+ if (!line.trim()) continue;
56
+ if (!slug) {
57
+ const slugMatch = line.match(/"slug":"([^"]*)"/);
58
+ if (slugMatch) slug = slugMatch[1];
59
+ }
60
+ if (line.includes('"type":"custom-title"')) {
61
+ try {
62
+ const parsed = JSON.parse(line);
63
+ if (parsed.customTitle) title = parsed.customTitle;
64
+ } catch {
65
+ // ignore malformed line
66
+ }
67
+ }
68
+ }
69
+ return title || slug || null;
70
+ }
71
+
72
+ // A session is identified by its top-level "<sessionId>.jsonl" file. Any
73
+ // "<sessionId>/subagents/agent-*.jsonl" files sit alongside it and are
74
+ // billed separately by the API, so their cost is real but attributed back
75
+ // to the parent session rather than shown as their own session.
76
+ export function discoverSessions() {
77
+ const root = claudeProjectsRoot();
78
+ const files = walk(root);
79
+
80
+ const sessions = new Map();
81
+
82
+ for (const file of files) {
83
+ const rel = path.relative(root, file);
84
+ const parts = rel.split(path.sep);
85
+ const project = parts[0];
86
+ const isSubagent = parts.includes('subagents');
87
+ const sessionId = isSubagent ? parts[1] : path.basename(file, '.jsonl');
88
+
89
+ if (!sessions.has(sessionId)) {
90
+ sessions.set(sessionId, { sessionId, project, mainFile: null, subagentFiles: [] });
91
+ }
92
+ const entry = sessions.get(sessionId);
93
+ if (isSubagent) {
94
+ entry.subagentFiles.push({ file, meta: readSubagentMeta(file) });
95
+ } else {
96
+ entry.mainFile = file;
97
+ }
98
+ }
99
+
100
+ // Drop entries that only have subagent files but never got a parent
101
+ // transcript picked up (shouldn't normally happen, but be defensive).
102
+ const result = [...sessions.values()].filter((s) => s.mainFile);
103
+
104
+ for (const s of result) {
105
+ s.mtime = statSync(s.mainFile).mtimeMs;
106
+ s.title = readSessionTitle(s.mainFile);
107
+ }
108
+
109
+ result.sort((a, b) => b.mtime - a.mtime);
110
+ return result;
111
+ }
112
+
113
+ export function findSession(idOrPrefix) {
114
+ const all = discoverSessions();
115
+ const needle = idOrPrefix.toLowerCase();
116
+ return (
117
+ all.find((s) => s.sessionId === idOrPrefix) ||
118
+ all.find((s) => s.title?.toLowerCase() === needle) ||
119
+ all.find((s) => s.sessionId.startsWith(idOrPrefix))
120
+ );
121
+ }
122
+
123
+ export function latestSession() {
124
+ return discoverSessions()[0] ?? null;
125
+ }
package/src/parse.js ADDED
@@ -0,0 +1,80 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ // Reads one transcript file and returns a flat list of billed turns:
4
+ // { model, timestamp, input, output, cacheWrite5m, cacheWrite1h, cacheRead }
5
+ // plus a tool-activity tally: { mcp: {server: count}, skills: {name: count}, web: {WebFetch/WebSearch: count} }
6
+ export function parseTranscript(file) {
7
+ let text;
8
+ try {
9
+ text = readFileSync(file, 'utf-8');
10
+ } catch {
11
+ return { turns: [], activity: { mcp: {}, skills: {}, web: {} } };
12
+ }
13
+
14
+ const turns = [];
15
+ const mcp = {};
16
+ const skills = {};
17
+ const web = {};
18
+
19
+ for (const line of text.split('\n')) {
20
+ if (!line.trim()) continue;
21
+ let entry;
22
+ try {
23
+ entry = JSON.parse(line);
24
+ } catch {
25
+ continue;
26
+ }
27
+
28
+ const msg = entry.message;
29
+ if (!msg || msg.type !== 'message' || msg.role !== 'assistant') continue;
30
+
31
+ // Tool-call activity: tallied regardless of whether this line also
32
+ // carries usage, since a message can contain tool_use blocks either way.
33
+ if (Array.isArray(msg.content)) {
34
+ for (const block of msg.content) {
35
+ if (block.type !== 'tool_use') continue;
36
+ if (block.name === 'Skill' && block.input?.skill) {
37
+ skills[block.input.skill] = (skills[block.input.skill] ?? 0) + 1;
38
+ } else if (block.name.startsWith('mcp__')) {
39
+ const server = block.name.split('__')[1] ?? 'unknown';
40
+ mcp[server] = (mcp[server] ?? 0) + 1;
41
+ } else if (block.name === 'WebFetch' || block.name === 'WebSearch') {
42
+ web[block.name] = (web[block.name] ?? 0) + 1;
43
+ }
44
+ }
45
+ }
46
+
47
+ if (!msg.usage) continue;
48
+ if (msg.model === '<synthetic>') continue; // internal zero-usage marker, not a billed turn
49
+
50
+ const u = msg.usage;
51
+ const isAllZero = !u.input_tokens && !u.output_tokens && !u.cache_creation_input_tokens && !u.cache_read_input_tokens;
52
+ if (isAllZero) continue;
53
+ const cacheCreation = u.cache_creation ?? {};
54
+ turns.push({
55
+ model: msg.model ?? 'unknown',
56
+ timestamp: entry.timestamp ?? null,
57
+ input: u.input_tokens ?? 0,
58
+ output: u.output_tokens ?? 0,
59
+ cacheWrite5m: cacheCreation.ephemeral_5m_input_tokens ?? u.cache_creation_input_tokens ?? 0,
60
+ cacheWrite1h: cacheCreation.ephemeral_1h_input_tokens ?? 0,
61
+ cacheRead: u.cache_read_input_tokens ?? 0,
62
+ });
63
+ }
64
+ return { turns, activity: { mcp, skills, web } };
65
+ }
66
+
67
+ export function timeRange(turns) {
68
+ const stamps = turns.map((t) => t.timestamp).filter(Boolean).sort();
69
+ return { startedAt: stamps[0] ?? null, endedAt: stamps[stamps.length - 1] ?? null };
70
+ }
71
+
72
+ export function mergeActivity(activities) {
73
+ const merged = { mcp: {}, skills: {}, web: {} };
74
+ for (const a of activities) {
75
+ for (const [k, v] of Object.entries(a.mcp)) merged.mcp[k] = (merged.mcp[k] ?? 0) + v;
76
+ for (const [k, v] of Object.entries(a.skills)) merged.skills[k] = (merged.skills[k] ?? 0) + v;
77
+ for (const [k, v] of Object.entries(a.web)) merged.web[k] = (merged.web[k] ?? 0) + v;
78
+ }
79
+ return merged;
80
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "_comment": "Last-resort snapshot only, used when there's no network AND no prior cache at ~/.burner-cc-cache/litellm-pricing.json. Normal operation fetches live from LiteLLM's Anthropic-sourced pricing JSON — see src/pricing.js. Values below are USD per million tokens, captured 2026-09-27.",
3
+ "models": {
4
+ "claude-opus-5": { "displayName": "Claude Opus 5", "input": 5, "output": 25, "cacheWrite5m": 6.25, "cacheWrite1h": 10, "cacheRead": 0.5 },
5
+ "claude-sonnet-5": { "displayName": "Claude Sonnet 5", "input": 2, "output": 10, "cacheWrite5m": 2.5, "cacheWrite1h": 4, "cacheRead": 0.2 },
6
+ "claude-haiku-4-5": { "displayName": "Claude Haiku 4.5", "input": 1, "output": 5, "cacheWrite5m": 1.25, "cacheWrite1h": 2, "cacheRead": 0.1 },
7
+ "claude-fable-5": { "displayName": "Claude Fable 5", "input": 10, "output": 50, "cacheWrite5m": 12.5, "cacheWrite1h": 20, "cacheRead": 1 }
8
+ }
9
+ }
package/src/pricing.js ADDED
@@ -0,0 +1,143 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ const cacheDir = path.join(os.homedir(), '.burner-cc-cache');
8
+ const cacheFile = path.join(cacheDir, 'litellm-pricing.json');
9
+ const fallbackFile = path.join(__dirname, 'pricing-fallback.json');
10
+
11
+ const SOURCE_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
12
+ const TTL_MS = 6 * 60 * 60 * 1000; // 6h — pricing changes rarely, this just avoids hammering GitHub.
13
+
14
+ // Anthropic bare-alias shorthands Claude Code sometimes writes instead of a
15
+ // dated/versioned model id (e.g. subagent transcripts saying "sonnet").
16
+ const ALIAS_FAMILIES = ['opus', 'sonnet', 'haiku', 'fable', 'mythos'];
17
+
18
+ function toRates(entry, displayName) {
19
+ const mtok = 1_000_000;
20
+ return {
21
+ displayName,
22
+ input: entry.input_cost_per_token * mtok,
23
+ output: entry.output_cost_per_token * mtok,
24
+ cacheWrite5m: (entry.cache_creation_input_token_cost ?? 0) * mtok,
25
+ cacheWrite1h: (entry.cache_creation_input_token_cost_above_1hr ?? entry.cache_creation_input_token_cost ?? 0) * mtok,
26
+ cacheRead: (entry.cache_read_input_token_cost ?? 0) * mtok,
27
+ };
28
+ }
29
+
30
+ function buildTableFromLiteLLM(raw) {
31
+ const models = {};
32
+ for (const [key, entry] of Object.entries(raw)) {
33
+ if (entry.litellm_provider !== 'anthropic' || entry.mode !== 'chat') continue;
34
+ if (typeof entry.input_cost_per_token !== 'number' || typeof entry.output_cost_per_token !== 'number') continue;
35
+ models[key] = toRates(entry, prettyName(key));
36
+ }
37
+ return models;
38
+ }
39
+
40
+ function prettyName(modelId) {
41
+ const parts = modelId.split('-').filter((p) => p !== 'claude');
42
+ if (!parts.length) return modelId;
43
+ const family = parts[0][0].toUpperCase() + parts[0].slice(1);
44
+ const rest = parts.slice(1).join('.');
45
+ return rest ? `Claude ${family} ${rest}` : `Claude ${family}`;
46
+ }
47
+
48
+ async function fetchLiteLLMTable() {
49
+ const res = await fetch(SOURCE_URL, { signal: AbortSignal.timeout(8000) });
50
+ if (!res.ok) throw new Error(`pricing source returned ${res.status}`);
51
+ const raw = await res.json();
52
+ return buildTableFromLiteLLM(raw);
53
+ }
54
+
55
+ function readCache() {
56
+ if (!existsSync(cacheFile)) return null;
57
+ try {
58
+ return JSON.parse(readFileSync(cacheFile, 'utf-8'));
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ function writeCache(models) {
65
+ mkdirSync(cacheDir, { recursive: true });
66
+ writeFileSync(cacheFile, JSON.stringify({ fetchedAt: Date.now(), models }, null, 2), 'utf-8');
67
+ }
68
+
69
+ function cacheIsFresh(cache) {
70
+ return cache && Date.now() - cache.fetchedAt < TTL_MS;
71
+ }
72
+
73
+ // Resolves the full pricing table once per CLI invocation: fresh cache wins,
74
+ // otherwise re-fetch from LiteLLM's community-maintained (Anthropic-sourced)
75
+ // pricing JSON, falling back to a stale cache or the bundled snapshot if
76
+ // offline. This is the whole point: nobody has to hand-edit prices.
77
+ export async function loadPricingTable() {
78
+ const cache = readCache();
79
+ if (cacheIsFresh(cache)) {
80
+ return { models: cache.models, source: 'cache', fetchedAt: cache.fetchedAt };
81
+ }
82
+
83
+ try {
84
+ const models = await fetchLiteLLMTable();
85
+ writeCache(models);
86
+ return { models, source: 'live', fetchedAt: Date.now() };
87
+ } catch (err) {
88
+ if (cache) {
89
+ return { models: cache.models, source: 'stale-cache', fetchedAt: cache.fetchedAt, error: err.message };
90
+ }
91
+ const fallback = JSON.parse(readFileSync(fallbackFile, 'utf-8'));
92
+ return { models: fallback.models, source: 'bundled-fallback', fetchedAt: null, error: err.message };
93
+ }
94
+ }
95
+
96
+ function resolveAlias(rawModel, models) {
97
+ const family = ALIAS_FAMILIES.find((f) => rawModel === f || rawModel.startsWith(f + '-'));
98
+ if (!family) return null;
99
+ // Pick the newest-looking concrete model in that family: prefer bare
100
+ // "claude-<family>-<N>" ids (no date suffix) and take the highest one.
101
+ const candidates = Object.keys(models).filter((k) => k.startsWith(`claude-${family}-`) && /^claude-[a-z]+-[\d.]+$/.test(k));
102
+ candidates.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
103
+ return candidates[0] ?? null;
104
+ }
105
+
106
+ export function lookupPricing(rawModel, table) {
107
+ if (!rawModel) return { fidelity: 'unknown', rates: null, displayName: 'unknown model' };
108
+ const models = table.models;
109
+
110
+ if (models[rawModel]) {
111
+ return { fidelity: 'measured', rates: models[rawModel], displayName: models[rawModel].displayName };
112
+ }
113
+
114
+ const aliasTarget = resolveAlias(rawModel, models);
115
+ if (aliasTarget) {
116
+ const rates = models[aliasTarget];
117
+ return { fidelity: 'approximate-alias', rates, displayName: `${rates.displayName} (via alias "${rawModel}")` };
118
+ }
119
+
120
+ // Longest-prefix match as a last resort (handles dated variants Claude
121
+ // Code might report that aren't in the pricing source verbatim).
122
+ const prefixMatch = Object.keys(models)
123
+ .filter((k) => rawModel.startsWith(k) || k.startsWith(rawModel))
124
+ .sort((a, b) => b.length - a.length)[0];
125
+ if (prefixMatch) {
126
+ const rates = models[prefixMatch];
127
+ return { fidelity: 'approximate-alias', rates, displayName: `${rates.displayName} (matched from "${rawModel}")` };
128
+ }
129
+
130
+ return { fidelity: 'unpriced', rates: null, displayName: rawModel };
131
+ }
132
+
133
+ export function costFor(usage, rates) {
134
+ if (!rates) return null;
135
+ const mtok = 1_000_000;
136
+ const cacheWrite5m = usage.cacheWrite5m ?? 0;
137
+ const cacheWrite1h = usage.cacheWrite1h ?? 0;
138
+ const input = (usage.input / mtok) * rates.input;
139
+ const output = (usage.output / mtok) * rates.output;
140
+ const cacheWrite = (cacheWrite5m / mtok) * rates.cacheWrite5m + (cacheWrite1h / mtok) * rates.cacheWrite1h;
141
+ const cacheRead = (usage.cacheRead / mtok) * rates.cacheRead;
142
+ return { input, output, cacheWrite, cacheRead, total: input + output + cacheWrite + cacheRead };
143
+ }