mirror of
https://github.com/Jeuners/ECC.git
synced 2026-09-09 15:02:30 +02:00
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:
parent
ecb45c1764
commit
536221cf7a
2 changed files with 61 additions and 7 deletions
|
|
@ -92,6 +92,13 @@ function toNumber(v) {
|
||||||
* Scan the session JSONL and sum token usage across all assistant turns.
|
* Scan the session JSONL and sum token usage across all assistant turns.
|
||||||
* Returns { inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, model }
|
* Returns { inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, model }
|
||||||
* or null on read failure.
|
* 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) {
|
function sumUsageFromTranscript(transcriptPath) {
|
||||||
let content;
|
let content;
|
||||||
|
|
@ -101,10 +108,8 @@ function sumUsageFromTranscript(transcriptPath) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let inputTokens = 0;
|
const usageById = new Map();
|
||||||
let outputTokens = 0;
|
let syntheticKey = 0;
|
||||||
let cacheWriteTokens = 0;
|
|
||||||
let cacheReadTokens = 0;
|
|
||||||
let model = 'unknown';
|
let model = 'unknown';
|
||||||
|
|
||||||
for (const line of content.split('\n')) {
|
for (const line of content.split('\n')) {
|
||||||
|
|
@ -116,13 +121,26 @@ function sumUsageFromTranscript(transcriptPath) {
|
||||||
const msg = entry.message;
|
const msg = entry.message;
|
||||||
if (!msg || !msg.usage) continue;
|
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);
|
inputTokens += toNumber(u.input_tokens);
|
||||||
outputTokens += toNumber(u.output_tokens);
|
outputTokens += toNumber(u.output_tokens);
|
||||||
cacheWriteTokens += toNumber(u.cache_creation_input_tokens);
|
cacheWriteTokens += toNumber(u.cache_creation_input_tokens);
|
||||||
cacheReadTokens += toNumber(u.cache_read_input_tokens);
|
cacheReadTokens += toNumber(u.cache_read_input_tokens);
|
||||||
|
|
||||||
if (msg.model && msg.model !== 'unknown') model = msg.model;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, model };
|
return { inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, model };
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,42 @@ function runTests() {
|
||||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||||
}) ? passed++ : failed++);
|
}) ? 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
|
// 3. Handles empty input gracefully
|
||||||
(test('handles empty input gracefully', () => {
|
(test('handles empty input gracefully', () => {
|
||||||
const tmpHome = makeTempDir();
|
const tmpHome = makeTempDir();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue