Created
April 15, 2026 10:05
-
-
Save kevingosse/290c8ec123bc679a316f05db69b1227c to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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