src/usage.js (7857 bytes)
1 // Token use and price for each project. Claude Code writes one JSONL transcript for each session in 2 // ~/.claude/projects. Each answer of the model carries its model name and its token counts, and each 3 // line carries the folder of the session. This module adds those numbers up for each day, project, 4 // and model, and it puts a price on them. 5 // 6 // The scan is incremental: usage-scan.json keeps the size of each transcript that it read, so a new 7 // scan reads only the new bytes. `ideamine usage` prints the table, and the dashboard shows it. 8 // 9 // The price is the price of the API. A Claude subscription does not bill for each token, so for a 10 // subscription the number is what the same work would cost through the API. 11 12 import fs from 'node:fs'; 13 import os from 'node:os'; 14 import path from 'node:path'; 15 import * as store from './store.js'; 16 17 /** 18 * Price in dollars for each million tokens (2026-09-22). `read` is the price of a cache read. 19 * A cache write costs 1.25 times the input price with the 5-minute lifetime, and 2 times with the 20 * 1-hour lifetime. 21 */ 22 export const PRICES = { 23 'claude-fable-5-1': { input: 10, output: 50, read: 0.25 }, 24 'claude-mythos-5-1': { input: 10, output: 50, read: 0.25 }, 25 'claude-fable-5': { input: 10, output: 50, read: 1 }, 26 'claude-mythos-5': { input: 10, output: 50, read: 1 }, 27 'claude-opus-5-5': { input: 4, output: 20, read: 0.2 }, 28 'claude-opus-5': { input: 5, output: 25, read: 0.5 }, 29 'claude-opus-4-8': { input: 5, output: 25, read: 0.5 }, 30 'claude-opus-4-7': { input: 5, output: 25, read: 0.5 }, 31 'claude-opus-4-6': { input: 5, output: 25, read: 0.5 }, 32 'claude-sonnet-5': { input: 2, output: 10, read: 0.2 }, 33 'claude-sonnet-4-6': { input: 3, output: 15, read: 0.3 }, 34 'claude-haiku-4-5': { input: 1, output: 5, read: 0.1 }, 35 }; 36 37 const MILLION = 1e6; 38 const WRITE_5M = 1.25; // times the input price 39 const WRITE_1H = 2; 40 41 const statePath = () => path.join(store.home(), 'usage-scan.json'); 42 43 /** The name of the model without the date at its end, for example claude-haiku-4-5-20251001. */ 44 export function modelName(raw) { 45 return String(raw || '') 46 .trim() 47 .replace(/-\d{8}$/, ''); 48 } 49 50 /** The price of one row in dollars. An unknown model costs 0, and `priced` says so. */ 51 export function cost(row) { 52 const p = PRICES[modelName(row.model)]; 53 if (!p) return 0; 54 const write = (row.write5m || 0) * p.input * WRITE_5M + (row.write1h || 0) * p.input * WRITE_1H; 55 return ((row.input || 0) * p.input + (row.output || 0) * p.output + (row.read || 0) * p.read + write) / MILLION; 56 } 57 58 /** True when the price list knows this model. */ 59 export const priced = (model) => Boolean(PRICES[modelName(model)]); 60 61 const rowKey = (r) => `${r.day}|${r.project}|${r.model}`; 62 const emptyRow = (day, project, model) => ({ day, project, model, messages: 0, input: 0, output: 0, read: 0, write5m: 0, write1h: 0 }); 63 64 /** 65 * Add the numbers of one transcript line to `rows`. A line without token counts, or from a model 66 * that answered nothing, changes nothing. 67 */ 68 export function addLine(rows, entry, seen) { 69 const usage = entry?.message?.usage; 70 if (!usage || entry.type !== 'assistant') return false; 71 const model = modelName(entry.message.model); 72 if (!model || model === '<synthetic>') return false; 73 const id = entry.message.id; 74 if (id && seen) { 75 if (seen.has(id)) return false; // the same answer twice in one file 76 seen.add(id); 77 } 78 const day = String(entry.timestamp || '').slice(0, 10); 79 if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) return false; 80 const project = entry.cwd || 'unknown'; 81 const key = `${day}|${project}|${model}`; 82 const row = rows.get(key) || emptyRow(day, project, model); 83 const created = usage.cache_creation || {}; 84 row.messages += 1; 85 row.input += usage.input_tokens || 0; 86 row.output += usage.output_tokens || 0; 87 row.read += usage.cache_read_input_tokens || 0; 88 // Older transcripts have only the total of the cache writes. Those count as the 5-minute price. 89 const w1h = created.ephemeral_1h_input_tokens || 0; 90 const w5m = created.ephemeral_5m_input_tokens ?? Math.max(0, (usage.cache_creation_input_tokens || 0) - w1h); 91 row.write5m += w5m; 92 row.write1h += w1h; 93 rows.set(key, row); 94 return true; 95 } 96 97 /** Read one transcript from byte `from` to its end, and add its lines to `rows`. */ 98 function readFile(file, from, rows) { 99 const fd = fs.openSync(file, 'r'); 100 try { 101 const size = fs.fstatSync(fd).size; 102 if (size <= from) return size; 103 const buf = Buffer.alloc(size - from); 104 fs.readSync(fd, buf, 0, buf.length, from); 105 const text = buf.toString('utf8'); 106 // A read that starts inside a line drops that half line, and the next scan gets the whole file. 107 const seen = new Set(); 108 for (const line of text.split('\n')) { 109 if (!line.startsWith('{')) continue; 110 try { 111 addLine(rows, JSON.parse(line), seen); 112 } catch { 113 // A line that a crash cut in half, or a line that is not JSON. 114 } 115 } 116 return size; 117 } finally { 118 fs.closeSync(fd); 119 } 120 } 121 122 function readState() { 123 try { 124 const state = JSON.parse(fs.readFileSync(statePath(), 'utf8')); 125 if (state && state.version === 1) return state; 126 } catch { 127 // No scan yet, or a file that another version wrote. 128 } 129 return { version: 1, files: {}, rows: [] }; 130 } 131 132 function writeState(state) { 133 fs.mkdirSync(store.home(), { recursive: true }); 134 store.writeAtomic(statePath(), `${JSON.stringify(state)}\n`); 135 } 136 137 /** The folder with the transcripts of Claude Code, the same one that the prompt log reads. */ 138 export const transcriptsDir = () => path.join(process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude'), 'projects'); 139 140 /** 141 * Read the new bytes of each transcript, add them to the numbers from before, and save the state. 142 * Resolves to { rows, files, read }: the rows for each day, project, and model, how many transcripts 143 * there are, and how many of them had new bytes. 144 */ 145 export function scan({ dir = transcriptsDir() } = {}) { 146 const state = readState(); 147 const rows = new Map(state.rows.map((r) => [rowKey(r), r])); 148 let files = []; 149 try { 150 files = fs.readdirSync(dir, { recursive: true }).filter((f) => String(f).endsWith('.jsonl')); 151 } catch { 152 return { rows: [...rows.values()], files: 0, read: 0 }; 153 } 154 let read = 0; 155 for (const name of files) { 156 const file = path.join(dir, String(name)); 157 const from = state.files[String(name)] || 0; 158 let size; 159 try { 160 size = readFile(file, from, rows); 161 } catch { 162 continue; // a file that another process writes, or that is gone 163 } 164 if (size !== from) read += 1; 165 state.files[String(name)] = size; 166 } 167 state.rows = [...rows.values()]; 168 writeState(state); 169 return { rows: state.rows, files: files.length, read }; 170 } 171 172 /** The rows of the last `days` days, newest day first. 0 days means every day. */ 173 export function since(rows, days) { 174 if (!days) return rows; 175 const day = new Date(Date.now() - days * 86400000).toISOString().slice(0, 10); 176 return rows.filter((r) => r.day >= day); 177 } 178 179 /** One row for each project: the tokens, the price, and the price for each model. */ 180 export function byProject(rows) { 181 const out = new Map(); 182 for (const r of rows) { 183 const p = out.get(r.project) || { project: r.project, messages: 0, input: 0, output: 0, read: 0, write5m: 0, write1h: 0, cost: 0, models: {} }; 184 for (const k of ['messages', 'input', 'output', 'read', 'write5m', 'write1h']) p[k] += r[k] || 0; 185 const m = p.models[r.model] || { model: r.model, messages: 0, input: 0, output: 0, read: 0, write5m: 0, write1h: 0, cost: 0 }; 186 for (const k of ['messages', 'input', 'output', 'read', 'write5m', 'write1h']) m[k] += r[k] || 0; 187 m.cost += cost(r); 188 p.models[r.model] = m; 189 p.cost += cost(r); 190 out.set(r.project, p); 191 } 192 return [...out.values()].sort((a, b) => b.cost - a.cost); 193 }