fix(hooks): dedupe transcript usage by message.id in cost-tracker (~2.5-3x inflation) (#2483)

Claude Code writes one transcript JSONL line per content block, so a
single API response (one message.id) spans multiple assistant lines that
each repeat the same message.usage. sumUsageFromTranscript summed every
line, inflating token totals and estimated_cost_usd roughly 2.5-3x.

Verified on a real session: 704 assistant lines but only 286 unique
message.ids (2.46 lines/response on average); line-summing reported
$866.52 while the deduped total is $332.62. Usage payloads are identical
across lines of the same id (0/286 varied), so counting once per id is
equivalent to taking the last line per id.

Fix: collect usage into a Map keyed by message.id (last line wins) and
sum unique entries. Lines without a message.id (older transcript shapes)
keep the previous per-line behavior via a synthetic key, so existing
tests and old transcripts are unaffected.

Adds a regression test: a response split into 3 content-block lines with
the same message.id is counted exactly once.

Note: rows already written to ~/.claude/metrics/costs.jsonl by the old
code carry inflated token counts and estimates (except rows whose cost
came from the harness-cost cache, where cost is authoritative but token
counts are still inflated). Downstream consumers may want to annotate
history; this change intentionally does not rewrite the raw log.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
AlbertChiu777 2026-07-28 02:14:47 +08:00 committed by GitHub
parent ecb45c1764
commit 536221cf7a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 61 additions and 7 deletions

View file

@ -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();