Skip to content

Instantly share code, notes, and snippets.

@kevingosse
Created April 15, 2026 10:05
Show Gist options
  • Select an option

  • Save kevingosse/290c8ec123bc679a316f05db69b1227c to your computer and use it in GitHub Desktop.

Select an option

Save kevingosse/290c8ec123bc679a316f05db69b1227c to your computer and use it in GitHub Desktop.
#!/usr/bin/env node
import { readFileSync, readdirSync, statSync, existsSync } from 'fs';
import { join, basename } from 'path';
import { homedir } from 'os';
// ─── Pricing (per million tokens) ────────────────────────────────────────────
// From https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching
// Cache write: 1.25x base input (5m), 2x base input (1h)
// Cache read: 0.1x base input (SAME for both TTLs — no multiplier on reads)
const PRICING = {
'opus46': {
name: 'Opus 4.6/4.5',
input: 5, output: 25,
cache_write_5m: 6.25, cache_read: 0.50,
cache_write_1h: 10,
},
'opus4': {
name: 'Opus 4/4.1 (legacy)',
input: 15, output: 75,
cache_write_5m: 18.75, cache_read: 1.50,
cache_write_1h: 30,
},
'sonnet': {
name: 'Sonnet 4.x',
input: 3, output: 15,
cache_write_5m: 3.75, cache_read: 0.30,
cache_write_1h: 6,
},
'haiku': {
name: 'Haiku 4.5',
input: 1, output: 5,
cache_write_5m: 1.25, cache_read: 0.10,
cache_write_1h: 2,
},
};
// Map model IDs from JSONL to pricing tiers
function getPricing(modelId) {
if (!modelId || modelId === '<synthetic>') return PRICING.opus46; // fallback
if (modelId.includes('opus-4-6') || modelId.includes('opus-4-5')) return PRICING.opus46;
if (modelId.includes('opus')) return PRICING.opus4;
if (modelId.includes('sonnet')) return PRICING.sonnet;
if (modelId.includes('haiku')) return PRICING.haiku;
return PRICING.opus46; // default fallback
}
// ─── CLI parsing ─────────────────────────────────────────────────────────────
function parseArgs(argv) {
const args = {
path: join(homedir(), '.claude'),
// model auto-detected from JSONL
help: false,
project: null, // filter to specific project
session: null, // filter to specific session ID
top: 0, // show top N sessions by savings
byProject: false, // aggregate by project
};
for (let i = 2; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--help' || arg === '-h') args.help = true;
else if (arg === '--by-project') args.byProject = true;
else if (arg === '--path' && argv[i + 1]) args.path = argv[++i];
else if (arg === '--project' && argv[i + 1]) args.project = argv[++i];
else if (arg === '--session' && argv[i + 1]) args.session = argv[++i];
else if (arg === '--top' && argv[i + 1]) args.top = parseInt(argv[++i], 10);
else {
console.error(`Unknown argument: ${arg}`);
process.exit(1);
}
}
return args;
}
function printHelp() {
console.log(`
Cache Cost Analyzer — Compare Claude Code cache TTL strategies
Usage: node analyze.mjs [options]
Options:
--path <dir> Claude data directory (default: ~/.claude)
--project <name> Filter to a specific project (substring match)
--session <id> Analyze a single session by ID (substring match)
--by-project Aggregate costs by project (with grand total)
--top <N> Show top N sessions by 1h savings
--help, -h Show this help
Examples:
node analyze.mjs --by-project
node analyze.mjs --project assistant
node analyze.mjs --session 083b
`);
}
// ─── JSONL scanning ──────────────────────────────────────────────────────────
function findJsonlFiles(basePath, projectFilter) {
const projectsDir = join(basePath, 'projects');
if (!existsSync(projectsDir)) {
console.error(`Projects directory not found: ${projectsDir}`);
process.exit(1);
}
const results = [];
for (const proj of readdirSync(projectsDir)) {
const projDir = join(projectsDir, proj);
if (!statSync(projDir).isDirectory()) continue;
if (projectFilter && !proj.toLowerCase().includes(projectFilter.toLowerCase())) continue;
for (const file of readdirSync(projDir)) {
if (!file.endsWith('.jsonl')) continue;
results.push({
project: proj,
sessionId: file.replace('.jsonl', ''),
filePath: join(projDir, file),
});
}
}
return results;
}
// ─── Session parsing ─────────────────────────────────────────────────────────
function parseSession(filePath) {
const content = readFileSync(filePath, 'utf8');
const lines = content.split('\n').filter(l => l.trim());
const assistantMsgs = [];
for (const line of lines) {
try {
const obj = JSON.parse(line);
if (obj.type === 'assistant' && obj.message?.usage) {
const u = obj.message.usage;
assistantMsgs.push({
timestamp: new Date(obj.timestamp),
input: u.input_tokens || 0,
cacheWrite: u.cache_creation_input_tokens || 0,
cacheRead: u.cache_read_input_tokens || 0,
output: u.output_tokens || 0,
model: obj.message.model || 'unknown',
cacheDetail: u.cache_creation || null,
});
}
} catch (e) { /* skip malformed lines */ }
}
return deduplicateStreaming(assistantMsgs);
}
function deduplicateStreaming(msgs) {
if (msgs.length === 0) return [];
const deduped = [];
let group = [msgs[0]];
for (let i = 1; i < msgs.length; i++) {
const prev = msgs[i - 1];
const curr = msgs[i];
// Same API call if input breakdown is identical
if (curr.input === prev.input &&
curr.cacheWrite === prev.cacheWrite &&
curr.cacheRead === prev.cacheRead) {
group.push(curr);
} else {
// Keep the last message in the group (has final output count)
deduped.push(group[group.length - 1]);
group = [curr];
}
}
deduped.push(group[group.length - 1]);
return deduped;
}
// ─── Base cache detection ────────────────────────────────────────────────────
// The "base cache" is the shared system prompt that's always in cache
// regardless of TTL. We detect it as the minimum cache_read across
// cache-miss turns (turns with gap > 5min where cache_read drops to base).
function detectBaseCache(turns) {
let minCacheRead = Infinity;
// First turn always shows base cache (nothing else is cached yet for this session)
if (turns.length > 0) {
minCacheRead = turns[0].cacheRead;
}
// Also check turns after long gaps (cache misses)
for (let i = 1; i < turns.length; i++) {
const gap = (turns[i].timestamp - turns[i - 1].timestamp) / 1000;
if (gap > 300) {
minCacheRead = Math.min(minCacheRead, turns[i].cacheRead);
}
}
return minCacheRead === Infinity ? 0 : minCacheRead;
}
// ─── Cost simulation ─────────────────────────────────────────────────────────
function simulateCosts(turns) {
const baseCache = detectBaseCache(turns);
const results = {
turns: [],
totals: {
noCache: 0,
fiveMin: 0,
oneHour: 0,
totalInput: 0,
totalOutput: 0,
totalApiCalls: turns.length,
cacheMisses5m: 0,
cacheMisses1h: 0,
duration: 0,
},
baseCache,
};
if (turns.length === 0) return results;
results.totals.duration =
(turns[turns.length - 1].timestamp - turns[0].timestamp) / 1000;
let prevTotal = 0;
let prevInput = 0;
for (let i = 0; i < turns.length; i++) {
const turn = turns[i];
const pricing = getPricing(turn.model);
const total = turn.input + turn.cacheWrite + turn.cacheRead;
const gap = i === 0 ? Infinity :
(turn.timestamp - turns[i - 1].timestamp) / 1000;
// ── No-cache scenario: everything is plain input ──
const noCacheCost =
(total * pricing.input + turn.output * pricing.output) / 1_000_000;
// ── 5-min scenario: use actual data ──
const fiveMinCost =
(turn.input * pricing.input +
turn.cacheWrite * pricing.cache_write_5m +
turn.cacheRead * pricing.cache_read +
turn.output * pricing.output) / 1_000_000;
// ── 1-hour scenario: simulate ──
let sim1hCr, sim1hCw;
if (i === 0 || gap >= 3600) {
// Full cache miss (first turn or gap >= 1 hour)
sim1hCr = baseCache;
sim1hCw = Math.max(total - turn.input - sim1hCr, 0);
results.totals.cacheMisses1h++;
if (i > 0) results.totals.cacheMisses5m++;
} else if (gap >= 300) {
// Cache miss in 5-min, but HIT in 1-hour
// Use simulated previous cache state
const prevCached = prevTotal - prevInput;
sim1hCr = Math.min(prevCached, total - turn.input);
sim1hCw = Math.max(total - turn.input - sim1hCr, 0);
results.totals.cacheMisses5m++;
} else {
// Cache hit in both — use actual breakdown
sim1hCr = turn.cacheRead;
sim1hCw = turn.cacheWrite;
}
const oneHourCost =
(turn.input * pricing.input +
sim1hCw * pricing.cache_write_1h +
sim1hCr * pricing.cache_read +
turn.output * pricing.output) / 1_000_000;
const turnResult = {
index: i + 1,
timestamp: turn.timestamp,
gap: gap === Infinity ? null : gap,
total,
input: turn.input,
cacheWrite: turn.cacheWrite,
cacheRead: turn.cacheRead,
output: turn.output,
sim1hCr,
sim1hCw,
noCacheCost,
fiveMinCost,
oneHourCost,
};
results.turns.push(turnResult);
results.totals.noCache += noCacheCost;
results.totals.fiveMin += fiveMinCost;
results.totals.oneHour += oneHourCost;
results.totals.totalInput += total;
results.totals.totalOutput += turn.output;
prevTotal = total;
prevInput = turn.input;
}
// Count 5-min misses: also include first turn
results.totals.cacheMisses5m += 1; // first turn is always a "miss"
return results;
}
// ─── Formatting ──────────────────────────────────────────────────────────────
function fmt$(n) {
if (Math.abs(n) < 0.01) return '$0.00';
return '$' + n.toFixed(2);
}
function fmtPct(a, b) {
if (b === 0) return 'N/A';
const pct = ((a - b) / b) * 100;
return (pct >= 0 ? '+' : '') + pct.toFixed(1) + '%';
}
function fmtDuration(seconds) {
if (seconds < 60) return `${Math.round(seconds)}s`;
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
const h = Math.floor(seconds / 3600);
const m = Math.round((seconds % 3600) / 60);
return `${h}h${m > 0 ? m + 'm' : ''}`;
}
function fmtTokens(n) {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
return String(n);
}
function pad(s, len, align = 'right') {
s = String(s);
if (align === 'left') return s.padEnd(len);
return s.padStart(len);
}
// ─── Table output ────────────────────────────────────────────────────────────
function printSessionTable(sessions, showTop) {
// Sort by 1h savings descending
const sorted = [...sessions].sort((a, b) =>
(b.totals.fiveMin - b.totals.oneHour) - (a.totals.fiveMin - a.totals.oneHour)
);
const display = showTop > 0 ? sorted.slice(0, showTop) : sorted;
const cols = [
{ key: 'project', label: 'Project', width: 30, align: 'left' },
{ key: 'session', label: 'Session', width: 8, align: 'left' },
{ key: 'turns', label: 'Turns', width: 6 },
{ key: 'duration', label: 'Duration', width: 9 },
{ key: 'noCache', label: 'No Cache', width: 10 },
{ key: 'fiveMin', label: '5min TTL', width: 10 },
{ key: 'oneHour', label: '1h TTL', width: 10 },
{ key: 'save0v5', label: '0 vs 5m', width: 9 },
{ key: 'save1v5', label: '1h vs 5m', width: 9 },
];
// Header
const header = cols.map(c => pad(c.label, c.width, c.align)).join(' │ ');
const sep = cols.map(c => '─'.repeat(c.width)).join('─┼─');
console.log(header);
console.log(sep);
// Rows
for (const s of display) {
const row = {
project: s.project.replace(/^[A-Z]--/, '').slice(0, 30),
session: s.sessionId.slice(0, 8),
turns: String(s.totals.totalApiCalls),
duration: fmtDuration(s.totals.duration),
noCache: fmt$(s.totals.noCache),
fiveMin: fmt$(s.totals.fiveMin),
oneHour: fmt$(s.totals.oneHour),
save0v5: fmtPct(s.totals.noCache, s.totals.fiveMin),
save1v5: fmtPct(s.totals.oneHour, s.totals.fiveMin),
};
console.log(cols.map(c => pad(row[c.key], c.width, c.align)).join(' │ '));
}
console.log(sep);
}
function printSummary(sessions) {
const totals = sessions.reduce((acc, s) => {
acc.noCache += s.totals.noCache;
acc.fiveMin += s.totals.fiveMin;
acc.oneHour += s.totals.oneHour;
acc.totalApiCalls += s.totals.totalApiCalls;
return acc;
}, { noCache: 0, fiveMin: 0, oneHour: 0, totalApiCalls: 0 });
console.log(` No cache: ${fmt$(totals.noCache)} | 5-min TTL: ${fmt$(totals.fiveMin)} | 1-hour TTL: ${fmt$(totals.oneHour)} (${fmtPct(totals.oneHour, totals.fiveMin)} vs 5m)`);
let totalGaps = 0, gapCount = 0, longGaps5m = 0, longGaps1h = 0;
for (const s of sessions) {
for (let i = 1; i < s.results.turns.length; i++) {
const gap = s.results.turns[i].gap;
if (gap !== null) {
totalGaps += gap;
gapCount++;
if (gap >= 300) longGaps5m++;
if (gap >= 3600) longGaps1h++;
}
}
}
if (gapCount > 0) {
console.log(` Avg gap: ${fmtDuration(totalGaps / gapCount)} | Gaps >5m: ${longGaps5m}/${gapCount} (${(longGaps5m/gapCount*100).toFixed(1)}%) | Gaps >1h: ${longGaps1h}/${gapCount} (${(longGaps1h/gapCount*100).toFixed(1)}%)`);
}
console.log('');
}
function printProjectTable(sessions) {
// Group sessions by project
const projects = new Map();
for (const s of sessions) {
const name = s.project.replace(/^[A-Z]--/, '');
if (!projects.has(name)) {
projects.set(name, { sessions: 0, turns: 0, noCache: 0, fiveMin: 0, oneHour: 0 });
}
const p = projects.get(name);
p.sessions++;
p.turns += s.totals.totalApiCalls;
p.noCache += s.totals.noCache;
p.fiveMin += s.totals.fiveMin;
p.oneHour += s.totals.oneHour;
}
// Sort by 5-min cost descending
const sorted = [...projects.entries()].sort((a, b) => b[1].fiveMin - a[1].fiveMin);
const cols = [
{ key: 'project', label: 'Project', width: 35, align: 'left' },
{ key: 'sess', label: 'Sess', width: 5 },
{ key: 'turns', label: 'Turns', width: 6 },
{ key: 'noCache', label: 'No Cache', width: 10 },
{ key: 'fiveMin', label: '5min TTL', width: 10 },
{ key: 'oneHour', label: '1h TTL', width: 10 },
{ key: 'save1v5', label: '1h vs 5m', width: 9 },
];
console.log('═══ PER-PROJECT COSTS ═══\n');
const header = cols.map(c => pad(c.label, c.width, c.align)).join(' │ ');
const sep = cols.map(c => '─'.repeat(c.width)).join('─┼─');
console.log(header);
console.log(sep);
let totNoCache = 0, totFiveMin = 0, totOneHour = 0, totSess = 0, totTurns = 0;
for (const [name, p] of sorted) {
const row = {
project: name.slice(0, 35),
sess: String(p.sessions),
turns: String(p.turns),
noCache: fmt$(p.noCache),
fiveMin: fmt$(p.fiveMin),
oneHour: fmt$(p.oneHour),
save1v5: fmtPct(p.oneHour, p.fiveMin),
};
console.log(cols.map(c => pad(row[c.key], c.width, c.align)).join(' │ '));
totNoCache += p.noCache;
totFiveMin += p.fiveMin;
totOneHour += p.oneHour;
totSess += p.sessions;
totTurns += p.turns;
}
console.log(sep);
const totalRow = {
project: 'TOTAL',
sess: String(totSess),
turns: String(totTurns),
noCache: fmt$(totNoCache),
fiveMin: fmt$(totFiveMin),
oneHour: fmt$(totOneHour),
save1v5: fmtPct(totOneHour, totFiveMin),
};
console.log(cols.map(c => pad(totalRow[c.key], c.width, c.align)).join(' │ '));
console.log('');
}
// ─── Main ────────────────────────────────────────────────────────────────────
function main() {
const args = parseArgs(process.argv);
if (args.help) {
printHelp();
process.exit(0);
}
console.log(`\n🔍 Scanning ${args.path}/projects/ for conversations...`);
const files = findJsonlFiles(args.path, args.project);
if (args.session) {
const filtered = files.filter(f =>
f.sessionId.toLowerCase().includes(args.session.toLowerCase())
);
files.length = 0;
files.push(...filtered);
}
if (files.length === 0) {
console.error('No JSONL files found. Check --path and --project filters.');
process.exit(1);
}
console.log(` Found ${files.length} session file(s) across ${new Set(files.map(f => f.project)).size} project(s)\n`);
// Parse and analyze each session
const sessions = [];
for (const file of files) {
const turns = parseSession(file.filePath);
if (turns.length === 0) continue;
const results = simulateCosts(turns);
sessions.push({
project: file.project,
sessionId: file.sessionId,
turns,
results,
totals: results.totals,
});
}
if (sessions.length === 0) {
console.log('No sessions with assistant messages found.');
process.exit(0);
}
if (args.byProject) {
printProjectTable(sessions);
} else {
console.log('═══ PER-SESSION COSTS ═══\n');
printSessionTable(sessions, args.top);
}
// Compact summary (always shown)
printSummary(sessions);
console.log('');
}
main();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment