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 +31 -0
- package/bin/burner-cc.js +2 -0
- package/package.json +37 -0
- package/src/billing-mode.js +22 -0
- package/src/build-receipt.js +133 -0
- package/src/cli.js +81 -0
- package/src/discover.js +125 -0
- package/src/parse.js +80 -0
- package/src/pricing-fallback.json +9 -0
- package/src/pricing.js +143 -0
- package/src/render.js +36 -0
- package/web/logo-mark.png +0 -0
- package/web/logo-with-text.png +0 -0
- package/web/receipt.css +298 -0
- package/web/receipt.html +31 -0
- package/web/receipt.js +315 -0
- package/web/vendor/html2canvas.min.js +20 -0
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.
|
package/bin/burner-cc.js
ADDED
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
|
+
});
|
package/src/discover.js
ADDED
|
@@ -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
|
+
}
|