forked from affaan-m/ECC
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcost-tracker.js
More file actions
executable file
·239 lines (210 loc) · 8.49 KB
/
Copy pathcost-tracker.js
File metadata and controls
executable file
·239 lines (210 loc) · 8.49 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
#!/usr/bin/env node
/**
* Cost Tracker Hook (v2)
*
* Reads transcript_path from Stop hook stdin, sums usage across all
* assistant turns in the session JSONL, and appends one row to
* ~/.claude/metrics/costs.jsonl.
*
* Stop hook stdin payload: { session_id, transcript_path, cwd, hook_event_name, ... }
* The Stop payload does NOT include `usage` or `model` directly. The previous
* version of this hook expected those fields and silently produced zero-filled
* rows (verified: 2,340 rows captured with 0.0% non-zero token rate over 52
* days). The fix is to read the transcript file Claude Code already passes us.
*
* JSONL assistant entry shape (per Claude Code):
* { type: "assistant", message: { model, usage: { input_tokens, output_tokens,
* cache_creation_input_tokens, cache_read_input_tokens } } }
*
* Cumulative behavior: Stop fires per assistant response, not per session.
* Each row therefore represents the cumulative session total up to that point.
* To get per-session cost, take the last row per session_id. To get per-day
* spend, aggregate.
*
* Harness-cost contract (optional, opt-in by the statusline):
* If the user's statusline (which receives `cost.total_cost_usd` directly
* from Claude Code) writes `{ts, cost_usd}` to
* `<os.tmpdir()>/harness-cost-<session_id>.json` on each render, this hook
* prefers that authoritative value over the transcript-sum estimate when
* the cache is fresh (≤ 300s). The transcript-sum is kept as a safe
* fallback because:
* - the hard-coded rate table cannot represent Opus 4.7's >200K-token
* 2x tier or the 1h-cache 2x tier (under-counts on long sessions);
* - summing the full transcript double-counts work done across
* `--resume` boundaries while `cost.total_cost_usd` is per-process.
* Absent a writer, behavior is unchanged.
*/
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { ensureDir, appendFile, getClaudeDir } = require('../lib/utils');
const { sanitizeSessionId } = require('../lib/session-bridge');
const HARNESS_COST_MAX_AGE_SECONDS = 300;
/**
* Read authoritative harness cost from the per-session cache file.
* @param {string} sessionId
* @param {number} maxAgeSeconds
* @returns {number|null} cost in USD, or null on miss / stale / parse error
*/
function readHarnessCost(sessionId, maxAgeSeconds) {
if (!sessionId) return null;
try {
const fp = path.join(os.tmpdir(), `harness-cost-${sessionId}.json`);
if (!fs.existsSync(fp)) return null;
const obj = JSON.parse(fs.readFileSync(fp, 'utf8'));
const ts = Number(obj && obj.ts);
const cost = Number(obj && obj.cost_usd);
if (!Number.isFinite(ts) || !Number.isFinite(cost) || cost < 0) return null;
const age = Math.floor(Date.now() / 1000) - ts;
if (age < 0 || age > maxAgeSeconds) return null;
return cost;
} catch {
return null;
}
}
// Approximate per-1M-token billing rates (USD).
// Cache creation: 1.25x input rate. Cache read: 0.1x input rate.
const RATE_TABLE = {
haiku: { in: 0.80, out: 4.0, cacheWrite: 1.00, cacheRead: 0.08 },
sonnet: { in: 3.00, out: 15.0, cacheWrite: 3.75, cacheRead: 0.30 },
opus: { in: 15.00, out: 75.0, cacheWrite: 18.75, cacheRead: 1.50 }
};
function getRates(model) {
const m = String(model || '').toLowerCase();
if (m.includes('haiku')) return RATE_TABLE.haiku;
if (m.includes('opus')) return RATE_TABLE.opus;
return RATE_TABLE.sonnet;
}
function toNumber(v) {
const n = Number(v);
return Number.isFinite(n) ? n : 0;
}
/**
* 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;
try {
content = fs.readFileSync(transcriptPath, 'utf8');
} catch {
return null;
}
const usageById = new Map();
let syntheticKey = 0;
let model = 'unknown';
for (const line of content.split('\n')) {
if (!line.trim()) continue;
let entry;
try { entry = JSON.parse(line); } catch { continue; }
if (entry.type !== 'assistant') continue;
const msg = entry.message;
if (!msg || !msg.usage) continue;
// 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);
}
return { inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, model };
}
// 1MB, matching the other Stop hooks. The Stop payload carries
// last_assistant_message, which routinely exceeded the old 64KB cap and
// made this hook echo a JSON document cut mid-stream (#2090).
const MAX_STDIN = 1024 * 1024;
let raw = '';
let truncated = false;
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (raw.length < MAX_STDIN) {
const remaining = MAX_STDIN - raw.length;
raw += chunk.substring(0, remaining);
if (chunk.length > remaining) truncated = true;
} else {
truncated = true;
}
});
process.stdin.on('end', () => {
try {
const input = raw.trim() ? JSON.parse(raw) : {};
const transcriptPath = (typeof input.transcript_path === 'string' && input.transcript_path)
? input.transcript_path
: process.env.CLAUDE_TRANSCRIPT_PATH || null;
const sessionId =
sanitizeSessionId(input.session_id) ||
sanitizeSessionId(process.env.ECC_SESSION_ID) ||
sanitizeSessionId(process.env.CLAUDE_SESSION_ID) ||
'default';
let usageTotals = null;
if (transcriptPath && fs.existsSync(transcriptPath)) {
usageTotals = sumUsageFromTranscript(transcriptPath);
}
const {
inputTokens = 0,
outputTokens = 0,
cacheWriteTokens = 0,
cacheReadTokens = 0,
model = 'unknown'
} = usageTotals || {};
const rates = getRates(model);
const transcriptCostUsd = Math.round((
(inputTokens / 1e6) * rates.in +
(outputTokens / 1e6) * rates.out +
(cacheWriteTokens / 1e6) * rates.cacheWrite +
(cacheReadTokens / 1e6) * rates.cacheRead
) * 1e6) / 1e6;
// Prefer the harness's authoritative `cost.total_cost_usd` when the
// statusline has written it to the per-session cache (see contract in
// the file header). The harness number reflects API-billed truth
// (correct rates, 1h-cache 2x, >200K tier 2x) and is per-process so it
// does not drift across `--resume`. Cache miss → transcript-sum.
const harnessCost = readHarnessCost(sessionId, HARNESS_COST_MAX_AGE_SECONDS);
const estimatedCostUsd = harnessCost !== null
? Math.round(harnessCost * 1e6) / 1e6
: transcriptCostUsd;
const metricsDir = path.join(getClaudeDir(), 'metrics');
ensureDir(metricsDir);
const row = {
timestamp: new Date().toISOString(),
session_id: sessionId,
transcript_path: transcriptPath || '',
model,
input_tokens: inputTokens,
output_tokens: outputTokens,
cache_write_tokens: cacheWriteTokens,
cache_read_tokens: cacheReadTokens,
estimated_cost_usd: estimatedCostUsd
};
appendFile(path.join(metricsDir, 'costs.jsonl'), `${JSON.stringify(row)}\n`);
} catch {
// Non-blocking — never fail the Stop hook.
}
// Pass stdin through (ECC hook convention) — but never echo truncated
// stdin: invalid JSON on stdout is reported as a Stop hook failure (#2090).
if (truncated) {
process.stderr.write('[Hook] cost-tracker: stdin exceeded 1MB; suppressing pass-through (fail-open)\n');
return;
}
process.stdout.write(raw);
});