diff --git a/scripts/hooks/cost-tracker.js b/scripts/hooks/cost-tracker.js index 8eb605274..3de1eaaec 100755 --- a/scripts/hooks/cost-tracker.js +++ b/scripts/hooks/cost-tracker.js @@ -92,6 +92,13 @@ function toNumber(v) { * Scan the session JSONL and sum token usage across all assistant turns. * Returns { inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, model } * or null on read failure. + * + * Claude Code writes one JSONL line per content block, so a single API + * response (one message.id) spans multiple assistant lines that each repeat + * the same message.usage. Summing every line inflates totals ~2.5-3x + * (verified: a session with 704 assistant lines had only 286 unique + * message.ids — $867 line-summed vs $333 deduped). Usage is therefore + * counted once per message.id, keeping the last line seen for each id. */ function sumUsageFromTranscript(transcriptPath) { let content; @@ -101,10 +108,8 @@ function sumUsageFromTranscript(transcriptPath) { return null; } - let inputTokens = 0; - let outputTokens = 0; - let cacheWriteTokens = 0; - let cacheReadTokens = 0; + const usageById = new Map(); + let syntheticKey = 0; let model = 'unknown'; for (const line of content.split('\n')) { @@ -116,13 +121,26 @@ function sumUsageFromTranscript(transcriptPath) { const msg = entry.message; if (!msg || !msg.usage) continue; - const u = msg.usage; + // Lines without a message.id (older transcript shapes) keep the previous + // per-line behavior via a synthetic key. + const key = (typeof msg.id === 'string' && msg.id) + ? msg.id + : `__line_${++syntheticKey}`; + usageById.set(key, msg.usage); + + if (msg.model && msg.model !== 'unknown') model = msg.model; + } + + let inputTokens = 0; + let outputTokens = 0; + let cacheWriteTokens = 0; + let cacheReadTokens = 0; + + for (const u of usageById.values()) { inputTokens += toNumber(u.input_tokens); outputTokens += toNumber(u.output_tokens); cacheWriteTokens += toNumber(u.cache_creation_input_tokens); cacheReadTokens += toNumber(u.cache_read_input_tokens); - - if (msg.model && msg.model !== 'unknown') model = msg.model; } return { inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, model }; diff --git a/tests/hooks/cost-tracker.test.js b/tests/hooks/cost-tracker.test.js index 117e4db23..8f652d7a1 100644 --- a/tests/hooks/cost-tracker.test.js +++ b/tests/hooks/cost-tracker.test.js @@ -129,6 +129,42 @@ function runTests() { fs.rmSync(tmpHome, { recursive: true, force: true }); }) ? passed++ : failed++); + // 2b. Dedupes usage by message.id (one API response = many JSONL lines) + (test('counts usage once per message.id across multi-line responses', () => { + const tmpHome = makeTempDir(); + const transcriptPath = path.join(tmpHome, 'session.jsonl'); + const sharedUsage = { + input_tokens: 1000, + output_tokens: 500, + cache_creation_input_tokens: 200, + cache_read_input_tokens: 300, + }; + writeTranscript(transcriptPath, [ + // One API response split into 3 content-block lines, all carrying the + // same message.id and the same usage — must be counted exactly once. + { type: 'assistant', message: { id: 'msg_01AAA', model: 'claude-sonnet-4-20250514', usage: sharedUsage } }, + { type: 'assistant', message: { id: 'msg_01AAA', model: 'claude-sonnet-4-20250514', usage: sharedUsage } }, + { type: 'assistant', message: { id: 'msg_01AAA', model: 'claude-sonnet-4-20250514', usage: sharedUsage } }, + // A second, distinct response. + { type: 'assistant', message: { id: 'msg_01BBB', model: 'claude-sonnet-4-20250514', usage: { input_tokens: 25, output_tokens: 5 } } }, + ]); + + const result = runScript( + { session_id: 'dedupe-session', transcript_path: transcriptPath }, + withTempHome(tmpHome) + ); + assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); + + const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'costs.jsonl'); + const row = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim()); + assert.strictEqual(row.input_tokens, 1025, 'Expected msg_01AAA usage counted once, not 3x'); + assert.strictEqual(row.output_tokens, 505, 'Expected msg_01AAA usage counted once, not 3x'); + assert.strictEqual(row.cache_write_tokens, 200, 'Expected cache write counted once per message.id'); + assert.strictEqual(row.cache_read_tokens, 300, 'Expected cache read counted once per message.id'); + + fs.rmSync(tmpHome, { recursive: true, force: true }); + }) ? passed++ : failed++); + // 3. Handles empty input gracefully (test('handles empty input gracefully', () => { const tmpHome = makeTempDir();