modelmeter-collect 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/cli.mjs +79 -1
- package/collect.mjs +99 -205
- package/lib.mjs +272 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -28,6 +28,16 @@ MODELMETER_DRYRUN=1 npx modelmeter-collect
|
|
|
28
28
|
`init` writes `~/.modelmeter/config.json` (chmod 600) with your token and the ingest URL.
|
|
29
29
|
Prefer env vars? Set `MODELMETER_TOKEN` and `MODELMETER_INGEST_URL` and skip `init`.
|
|
30
30
|
|
|
31
|
+
## Check your setup
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx modelmeter-collect doctor # which logs were found, last activity, config
|
|
35
|
+
npx modelmeter-collect doctor --payload # + the exact JSON that would be sent
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`doctor` confirms it found your Claude Code and Codex logs and shows precisely what leaves
|
|
39
|
+
your machine: model names, token counts, and tool/MCP names only. Never prompts or keys.
|
|
40
|
+
|
|
31
41
|
## Keep it live (per prompt)
|
|
32
42
|
|
|
33
43
|
**Claude Code** — add a `Stop` hook (fires after every response). It passes the session
|
package/cli.mjs
CHANGED
|
@@ -6,10 +6,19 @@
|
|
|
6
6
|
// npx modelmeter-collect init <mm_live_token> # one-time: save the token
|
|
7
7
|
// npx modelmeter-collect # scan local logs and report
|
|
8
8
|
// MODELMETER_DRYRUN=1 npx modelmeter-collect # preview without sending
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
readFileSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
existsSync,
|
|
14
|
+
chmodSync,
|
|
15
|
+
readdirSync,
|
|
16
|
+
statSync,
|
|
17
|
+
} from 'node:fs'
|
|
10
18
|
import { homedir } from 'node:os'
|
|
11
19
|
import { join, dirname } from 'node:path'
|
|
12
20
|
import { fileURLToPath } from 'node:url'
|
|
21
|
+
import { formatDoctorReport } from './lib.mjs'
|
|
13
22
|
|
|
14
23
|
const HOME = homedir()
|
|
15
24
|
const MM_DIR = join(HOME, '.modelmeter')
|
|
@@ -47,12 +56,16 @@ function printHelp() {
|
|
|
47
56
|
|
|
48
57
|
Usage:
|
|
49
58
|
npx modelmeter-collect init <token> [--url <ingest-url>]
|
|
59
|
+
npx modelmeter-collect doctor [--payload]
|
|
50
60
|
npx modelmeter-collect scan local logs and report
|
|
51
61
|
npx modelmeter-collect --help
|
|
52
62
|
|
|
53
63
|
Commands:
|
|
54
64
|
init Save your ingest token to ~/.modelmeter/config.json (chmod 600).
|
|
55
65
|
Pass the token as an argument or via MODELMETER_TOKEN.
|
|
66
|
+
doctor Check your setup: which logs were found, last activity, config
|
|
67
|
+
status, and exactly what would be sent. Add --payload for the raw
|
|
68
|
+
JSON (token counts only, never transcript text).
|
|
56
69
|
(none) Scan Claude Code + Codex logs and report token counts. Deduped,
|
|
57
70
|
so it is safe to run repeatedly. MODELMETER_DRYRUN=1 previews only.
|
|
58
71
|
|
|
@@ -93,5 +106,70 @@ if (cmd === 'init' || cmd === 'setup') {
|
|
|
93
106
|
process.exit(0)
|
|
94
107
|
}
|
|
95
108
|
|
|
109
|
+
// Count .jsonl session files (recent + newest mtime) under a logs directory.
|
|
110
|
+
function discoverLogs(dir, cutoffMs) {
|
|
111
|
+
try {
|
|
112
|
+
statSync(dir)
|
|
113
|
+
} catch {
|
|
114
|
+
return { dir, found: false }
|
|
115
|
+
}
|
|
116
|
+
let recentCount = 0
|
|
117
|
+
let lastWriteMs = 0
|
|
118
|
+
const stack = [dir]
|
|
119
|
+
while (stack.length) {
|
|
120
|
+
const d = stack.pop()
|
|
121
|
+
let entries = []
|
|
122
|
+
try {
|
|
123
|
+
entries = readdirSync(d, { withFileTypes: true })
|
|
124
|
+
} catch {
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
for (const e of entries) {
|
|
128
|
+
const p = join(d, e.name)
|
|
129
|
+
if (e.isDirectory()) stack.push(p)
|
|
130
|
+
else if (e.isFile() && p.endsWith('.jsonl')) {
|
|
131
|
+
let m = 0
|
|
132
|
+
try {
|
|
133
|
+
m = statSync(p).mtimeMs
|
|
134
|
+
} catch {
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
if (m > lastWriteMs) lastWriteMs = m
|
|
138
|
+
if (m >= cutoffMs) recentCount++
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { dir, found: true, recentCount, lastWriteMs }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (cmd === 'doctor') {
|
|
146
|
+
const cfg = readConfig()
|
|
147
|
+
const lookbackDays = 14
|
|
148
|
+
const nowMs = Date.now()
|
|
149
|
+
const cutoffMs = nowMs - lookbackDays * 86_400_000
|
|
150
|
+
console.log(
|
|
151
|
+
formatDoctorReport({
|
|
152
|
+
configPath: CONFIG_PATH,
|
|
153
|
+
configFound: existsSync(CONFIG_PATH),
|
|
154
|
+
token: process.env.MODELMETER_TOKEN || cfg.token,
|
|
155
|
+
ingestUrl: process.env.MODELMETER_INGEST_URL || cfg.ingestUrl,
|
|
156
|
+
lookbackDays,
|
|
157
|
+
nowMs,
|
|
158
|
+
claude: discoverLogs(join(HOME, '.claude', 'projects'), cutoffMs),
|
|
159
|
+
codex: discoverLogs(join(HOME, '.codex', 'sessions'), cutoffMs),
|
|
160
|
+
}),
|
|
161
|
+
)
|
|
162
|
+
if (args.includes('--payload')) {
|
|
163
|
+
console.log('\nNext batch (dry run, nothing is sent):')
|
|
164
|
+
process.env.MODELMETER_DRYRUN = '1'
|
|
165
|
+
await runCollector() // prints the exact payload (counts only), then exits
|
|
166
|
+
} else {
|
|
167
|
+
console.log(
|
|
168
|
+
'\nRun `npx modelmeter-collect doctor --payload` to preview the exact JSON that would be sent.',
|
|
169
|
+
)
|
|
170
|
+
process.exit(0)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
96
174
|
// Default: scan and report.
|
|
97
175
|
await runCollector()
|
package/collect.mjs
CHANGED
|
@@ -9,15 +9,48 @@
|
|
|
9
9
|
//
|
|
10
10
|
// Config: MODELMETER_TOKEN + MODELMETER_INGEST_URL from env, or ~/.modelmeter/config.json
|
|
11
11
|
// { "token": "mm_live_...", "ingestUrl": "https://<ref>.supabase.co/functions/v1/ingest" }
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
readFileSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
mkdirSync,
|
|
16
|
+
existsSync,
|
|
17
|
+
statSync,
|
|
18
|
+
readdirSync,
|
|
19
|
+
chmodSync,
|
|
20
|
+
} from 'node:fs'
|
|
13
21
|
import { homedir } from 'node:os'
|
|
14
22
|
import { join } from 'node:path'
|
|
23
|
+
import {
|
|
24
|
+
findLastTokenCount,
|
|
25
|
+
codexToolFromEvent,
|
|
26
|
+
deriveHourlyUrl,
|
|
27
|
+
claudeEventFromLine,
|
|
28
|
+
codexDelta,
|
|
29
|
+
aggregateDaily,
|
|
30
|
+
aggregateHourly,
|
|
31
|
+
aggregateTools,
|
|
32
|
+
} from './lib.mjs'
|
|
15
33
|
|
|
16
34
|
const HOME = homedir()
|
|
17
35
|
const MM_DIR = join(HOME, '.modelmeter')
|
|
18
36
|
const STATE_PATH = join(MM_DIR, 'collector-state.json')
|
|
19
37
|
const CONFIG_PATH = join(MM_DIR, 'config.json')
|
|
20
|
-
|
|
38
|
+
// Clamp the lookback to a sane range so a bad env var cannot scan nothing
|
|
39
|
+
// (negative) or traverse months of logs (huge).
|
|
40
|
+
const RAW_LOOKBACK = Number(process.env.MODELMETER_LOOKBACK_DAYS)
|
|
41
|
+
const LOOKBACK_DAYS =
|
|
42
|
+
Number.isFinite(RAW_LOOKBACK) && RAW_LOOKBACK > 0 ? Math.min(RAW_LOOKBACK, 90) : 14
|
|
43
|
+
if (
|
|
44
|
+
process.env.MODELMETER_LOOKBACK_DAYS !== undefined &&
|
|
45
|
+
(!Number.isFinite(RAW_LOOKBACK) || RAW_LOOKBACK <= 0 || RAW_LOOKBACK > 90)
|
|
46
|
+
) {
|
|
47
|
+
console.error(`modelmeter: MODELMETER_LOOKBACK_DAYS out of range, using ${LOOKBACK_DAYS}`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const FETCH_TIMEOUT_MS = 8000
|
|
51
|
+
// Cap the per-message dedup set so the state file cannot grow without bound. Older
|
|
52
|
+
// entries fall out of the lookback window, so dropping them is safe.
|
|
53
|
+
const CLAUDE_STATE_CAP = 200_000
|
|
21
54
|
|
|
22
55
|
let cfg = {}
|
|
23
56
|
try {
|
|
@@ -29,6 +62,23 @@ const TOKEN = process.env.MODELMETER_TOKEN || cfg.token
|
|
|
29
62
|
const INGEST_URL = process.env.MODELMETER_INGEST_URL || cfg.ingestUrl
|
|
30
63
|
if (!TOKEN || !INGEST_URL) process.exit(0) // not configured: do nothing, never block
|
|
31
64
|
|
|
65
|
+
// POST JSON with a hard timeout so a stuck network path can never hang a Stop
|
|
66
|
+
// hook or pile up scheduled collectors. Callers handle the thrown abort/error.
|
|
67
|
+
async function postJson(url, body) {
|
|
68
|
+
const controller = new AbortController()
|
|
69
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
|
70
|
+
try {
|
|
71
|
+
return await fetch(url, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
74
|
+
body: JSON.stringify(body),
|
|
75
|
+
signal: controller.signal,
|
|
76
|
+
})
|
|
77
|
+
} finally {
|
|
78
|
+
clearTimeout(timer)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
32
82
|
let state = { claude: {}, codex: {} }
|
|
33
83
|
try {
|
|
34
84
|
state = { claude: {}, codex: {}, ...JSON.parse(readFileSync(STATE_PATH, 'utf8')) }
|
|
@@ -107,59 +157,15 @@ function scanClaude(files) {
|
|
|
107
157
|
} catch {
|
|
108
158
|
continue
|
|
109
159
|
}
|
|
110
|
-
const
|
|
111
|
-
if (!
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
state.claude[id] = 1
|
|
115
|
-
const u = msg.usage
|
|
116
|
-
const toolNames = Array.isArray(msg.content)
|
|
117
|
-
? msg.content.filter((b) => b && b.type === 'tool_use').map((b) => b.name).filter(Boolean)
|
|
118
|
-
: []
|
|
119
|
-
events.push({
|
|
120
|
-
provider: 'anthropic',
|
|
121
|
-
model: msg.model || 'claude-unknown',
|
|
122
|
-
occurredOn: (o.timestamp || '').slice(0, 10) || undefined,
|
|
123
|
-
occurredAt: o.timestamp || undefined,
|
|
124
|
-
tools: toolNames,
|
|
125
|
-
uncachedInputTokens: u.input_tokens || 0,
|
|
126
|
-
cacheReadInputTokens: u.cache_read_input_tokens || 0,
|
|
127
|
-
cacheCreationInputTokens: u.cache_creation_input_tokens || 0,
|
|
128
|
-
outputTokens: u.output_tokens || 0,
|
|
129
|
-
numRequests: 1,
|
|
130
|
-
})
|
|
160
|
+
const ev = claudeEventFromLine(o)
|
|
161
|
+
if (!ev || !ev.id || state.claude[ev.id]) continue
|
|
162
|
+
state.claude[ev.id] = 1
|
|
163
|
+
events.push(ev)
|
|
131
164
|
}
|
|
132
165
|
}
|
|
133
166
|
}
|
|
134
167
|
|
|
135
168
|
// --- Codex: cumulative token_count events; report per-session delta.
|
|
136
|
-
function findLastTokenCount(obj) {
|
|
137
|
-
let last = null
|
|
138
|
-
const stack = [obj]
|
|
139
|
-
while (stack.length) {
|
|
140
|
-
const d = stack.pop()
|
|
141
|
-
if (Array.isArray(d)) stack.push(...d)
|
|
142
|
-
else if (d && typeof d === 'object') {
|
|
143
|
-
if (d.type === 'token_count' && d.info?.total_token_usage) last = d.info.total_token_usage
|
|
144
|
-
for (const v of Object.values(d)) stack.push(v)
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
return last
|
|
148
|
-
}
|
|
149
|
-
// Codex tool names are plain (exec_command, apply_patch, ...); MCP calls carry an
|
|
150
|
-
// invocation with a server name. Returns a group key, or null if not a tool call.
|
|
151
|
-
function codexToolFromEvent(payload, ptype) {
|
|
152
|
-
if (ptype === 'function_call' || ptype === 'custom_tool_call') {
|
|
153
|
-
return typeof payload.name === 'string' && payload.name ? payload.name : null
|
|
154
|
-
}
|
|
155
|
-
if (ptype === 'mcp_tool_call_end' || ptype === 'mcp_tool_call_begin') {
|
|
156
|
-
const inv = payload.invocation || {}
|
|
157
|
-
const server = inv.server || inv.server_name
|
|
158
|
-
return server ? `mcp:${server}` : 'mcp'
|
|
159
|
-
}
|
|
160
|
-
return null
|
|
161
|
-
}
|
|
162
|
-
|
|
163
169
|
function scanCodex(files) {
|
|
164
170
|
for (const file of files) {
|
|
165
171
|
const m = file.match(/rollout-(\d{4}-\d{2}-\d{2})T[\d-]+-([0-9a-f-]+)\.jsonl$/)
|
|
@@ -198,49 +204,14 @@ function scanCodex(files) {
|
|
|
198
204
|
const g = codexToolFromEvent(p, ptype)
|
|
199
205
|
if (g) toolCalls.push({ ts: ts || totalsTs || '', group: g })
|
|
200
206
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
reasoning_output_tokens: 0,
|
|
210
|
-
}
|
|
211
|
-
const prevLastTs = (stored && stored.lastTs) || ''
|
|
212
|
-
const dInput = Math.max(0, (totals.input_tokens || 0) - prevTotal.input_tokens)
|
|
213
|
-
const dCached = Math.max(0, (totals.cached_input_tokens || 0) - prevTotal.cached_input_tokens)
|
|
214
|
-
const dOut = Math.max(0, (totals.output_tokens || 0) - prevTotal.output_tokens)
|
|
215
|
-
const dReason = Math.max(0, (totals.reasoning_output_tokens || 0) - prevTotal.reasoning_output_tokens)
|
|
216
|
-
const newTotalTokens = Math.max(0, dInput - dCached) + dCached + (dOut + dReason)
|
|
217
|
-
if (dInput + dCached + dOut + dReason > 0) {
|
|
218
|
-
const occurredOn = (totalsTs || '').slice(0, 10) || m[1]
|
|
219
|
-
events.push({
|
|
220
|
-
provider: 'openai',
|
|
221
|
-
model,
|
|
222
|
-
occurredOn,
|
|
223
|
-
occurredAt: totalsTs || undefined, // enables the 5-hour window for Codex
|
|
224
|
-
uncachedInputTokens: Math.max(0, dInput - dCached),
|
|
225
|
-
cacheReadInputTokens: dCached,
|
|
226
|
-
cacheCreationInputTokens: 0,
|
|
227
|
-
outputTokens: dOut + dReason, // reasoning tokens bill as output
|
|
228
|
-
numRequests: 1,
|
|
229
|
-
})
|
|
230
|
-
// Tool calls new since the last run; even-split this run's new tokens across them.
|
|
231
|
-
const newCalls = toolCalls.filter((c) => c.ts && c.ts > prevLastTs)
|
|
232
|
-
if (newCalls.length > 0 && newTotalTokens > 0) {
|
|
233
|
-
const share = Math.round(newTotalTokens / newCalls.length)
|
|
234
|
-
for (const c of newCalls) {
|
|
235
|
-
codexToolDeltas.push({
|
|
236
|
-
tool: c.group,
|
|
237
|
-
bucketDate: (c.ts || '').slice(0, 10) || occurredOn,
|
|
238
|
-
calls: 1,
|
|
239
|
-
tokens: share,
|
|
240
|
-
})
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
state.codex[sessionId] = { total: totals, lastTs: maxTs }
|
|
207
|
+
const { event, toolDeltas, state: nextState } = codexDelta(
|
|
208
|
+
{ totals, totalsTs, maxTs, model, fileDate: m[1], toolCalls },
|
|
209
|
+
state.codex[sessionId],
|
|
210
|
+
)
|
|
211
|
+
if (event) {
|
|
212
|
+
events.push(event)
|
|
213
|
+
for (const d of toolDeltas) codexToolDeltas.push(d)
|
|
214
|
+
state.codex[sessionId] = nextState
|
|
244
215
|
}
|
|
245
216
|
}
|
|
246
217
|
}
|
|
@@ -255,106 +226,17 @@ if (hookInput?.transcript_path) {
|
|
|
255
226
|
}
|
|
256
227
|
|
|
257
228
|
// Collapse to one row per (provider, model, day) so the request stays small.
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
const date = e.occurredOn || new Date().toISOString().slice(0, 10)
|
|
261
|
-
const key = `${e.provider}|${e.model}|${date}`
|
|
262
|
-
const cur = byKey.get(key) || {
|
|
263
|
-
provider: e.provider,
|
|
264
|
-
model: e.model,
|
|
265
|
-
occurredOn: date,
|
|
266
|
-
uncachedInputTokens: 0,
|
|
267
|
-
cacheReadInputTokens: 0,
|
|
268
|
-
cacheCreationInputTokens: 0,
|
|
269
|
-
outputTokens: 0,
|
|
270
|
-
numRequests: 0,
|
|
271
|
-
}
|
|
272
|
-
cur.uncachedInputTokens += e.uncachedInputTokens || 0
|
|
273
|
-
cur.cacheReadInputTokens += e.cacheReadInputTokens || 0
|
|
274
|
-
cur.cacheCreationInputTokens += e.cacheCreationInputTokens || 0
|
|
275
|
-
cur.outputTokens += e.outputTokens || 0
|
|
276
|
-
cur.numRequests += e.numRequests || 1
|
|
277
|
-
byKey.set(key, cur)
|
|
278
|
-
}
|
|
279
|
-
const payload = [...byKey.values()]
|
|
229
|
+
const today = new Date().toISOString().slice(0, 10)
|
|
230
|
+
const payload = aggregateDaily(events, today)
|
|
280
231
|
|
|
281
|
-
// Recent hourly buckets feed the 5-hour rolling
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
const HOUR_MS = 3_600_000
|
|
286
|
-
const recentCutoff = Date.now() - 8 * HOUR_MS
|
|
287
|
-
const byHour = new Map()
|
|
288
|
-
for (const e of events) {
|
|
289
|
-
if (!e.occurredAt) continue
|
|
290
|
-
const t = new Date(e.occurredAt).getTime()
|
|
291
|
-
if (Number.isNaN(t) || t < recentCutoff) continue
|
|
292
|
-
const d = new Date(t)
|
|
293
|
-
d.setMinutes(0, 0, 0)
|
|
294
|
-
const hourIso = d.toISOString()
|
|
295
|
-
const key = `${e.provider}|${e.model}|${hourIso}`
|
|
296
|
-
const cur = byHour.get(key) || {
|
|
297
|
-
provider: e.provider,
|
|
298
|
-
model: e.model,
|
|
299
|
-
bucketHour: hourIso,
|
|
300
|
-
uncachedInputTokens: 0,
|
|
301
|
-
cacheReadInputTokens: 0,
|
|
302
|
-
cacheCreationInputTokens: 0,
|
|
303
|
-
outputTokens: 0,
|
|
304
|
-
numRequests: 0,
|
|
305
|
-
}
|
|
306
|
-
cur.uncachedInputTokens += e.uncachedInputTokens || 0
|
|
307
|
-
cur.cacheReadInputTokens += e.cacheReadInputTokens || 0
|
|
308
|
-
cur.cacheCreationInputTokens += e.cacheCreationInputTokens || 0
|
|
309
|
-
cur.outputTokens += e.outputTokens || 0
|
|
310
|
-
cur.numRequests += e.numRequests || 1
|
|
311
|
-
byHour.set(key, cur)
|
|
312
|
-
}
|
|
313
|
-
const hourly = [...byHour.values()]
|
|
314
|
-
const HOURLY_URL = INGEST_URL.replace(/\/ingest$/, '/ingest-hourly')
|
|
232
|
+
// Recent hourly buckets (last 8h, timestamped events only) feed the 5-hour rolling
|
|
233
|
+
// window via a separate, additive endpoint, so a backfill cannot pollute it.
|
|
234
|
+
const hourly = aggregateHourly(events, Date.now())
|
|
235
|
+
const HOURLY_URL = deriveHourlyUrl(INGEST_URL, process.env.MODELMETER_HOURLY_INGEST_URL)
|
|
315
236
|
|
|
316
|
-
// Per-tool / per-MCP attribution.
|
|
317
|
-
//
|
|
318
|
-
|
|
319
|
-
function toolGroup(name) {
|
|
320
|
-
if (typeof name !== 'string' || !name) return 'unknown'
|
|
321
|
-
if (name.startsWith('mcp__')) {
|
|
322
|
-
const parts = name.split('__')
|
|
323
|
-
return parts[1] ? `mcp:${parts[1]}` : 'mcp:unknown'
|
|
324
|
-
}
|
|
325
|
-
return name
|
|
326
|
-
}
|
|
327
|
-
const byTool = new Map()
|
|
328
|
-
for (const e of events) {
|
|
329
|
-
if (!Array.isArray(e.tools) || e.tools.length === 0) continue
|
|
330
|
-
const date = e.occurredOn || new Date().toISOString().slice(0, 10)
|
|
331
|
-
const callsByGroup = new Map()
|
|
332
|
-
for (const name of e.tools) {
|
|
333
|
-
const g = toolGroup(name)
|
|
334
|
-
callsByGroup.set(g, (callsByGroup.get(g) || 0) + 1)
|
|
335
|
-
}
|
|
336
|
-
const eventTokens =
|
|
337
|
-
(e.uncachedInputTokens || 0) +
|
|
338
|
-
(e.cacheReadInputTokens || 0) +
|
|
339
|
-
(e.cacheCreationInputTokens || 0) +
|
|
340
|
-
(e.outputTokens || 0)
|
|
341
|
-
const tokenShare = Math.round(eventTokens / callsByGroup.size)
|
|
342
|
-
for (const [g, calls] of callsByGroup) {
|
|
343
|
-
const key = `${g}|${date}`
|
|
344
|
-
const cur = byTool.get(key) || { tool: g, bucketDate: date, calls: 0, tokens: 0 }
|
|
345
|
-
cur.calls += calls
|
|
346
|
-
cur.tokens += tokenShare
|
|
347
|
-
byTool.set(key, cur)
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
for (const d of codexToolDeltas) {
|
|
351
|
-
const key = `${d.tool}|${d.bucketDate}`
|
|
352
|
-
const cur = byTool.get(key) || { tool: d.tool, bucketDate: d.bucketDate, calls: 0, tokens: 0 }
|
|
353
|
-
cur.calls += d.calls
|
|
354
|
-
cur.tokens += d.tokens
|
|
355
|
-
byTool.set(key, cur)
|
|
356
|
-
}
|
|
357
|
-
const toolsPayload = [...byTool.values()]
|
|
237
|
+
// Per-tool / per-MCP attribution. Claude even-splits each turn's tokens across the
|
|
238
|
+
// tools it called; Codex contributes precomputed deltas. Calls are exact.
|
|
239
|
+
const toolsPayload = aggregateTools(events, codexToolDeltas, today)
|
|
358
240
|
|
|
359
241
|
if (payload.length === 0) {
|
|
360
242
|
process.exit(0)
|
|
@@ -372,14 +254,21 @@ if (process.env.MODELMETER_DRYRUN) {
|
|
|
372
254
|
|
|
373
255
|
let committed = false
|
|
374
256
|
try {
|
|
375
|
-
const res = await
|
|
376
|
-
method: 'POST',
|
|
377
|
-
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
378
|
-
body: JSON.stringify({ source: 'collector', events: payload }),
|
|
379
|
-
})
|
|
257
|
+
const res = await postJson(INGEST_URL, { source: 'collector', events: payload })
|
|
380
258
|
if (res.ok) {
|
|
381
259
|
if (!existsSync(MM_DIR)) mkdirSync(MM_DIR, { recursive: true })
|
|
260
|
+
const claudeIds = Object.keys(state.claude)
|
|
261
|
+
if (claudeIds.length > CLAUDE_STATE_CAP) {
|
|
262
|
+
const next = {}
|
|
263
|
+
for (const id of claudeIds.slice(-CLAUDE_STATE_CAP)) next[id] = 1
|
|
264
|
+
state.claude = next
|
|
265
|
+
}
|
|
382
266
|
writeFileSync(STATE_PATH, JSON.stringify(state))
|
|
267
|
+
try {
|
|
268
|
+
chmodSync(STATE_PATH, 0o600) // usage metadata is not secret, but keep it owner-only
|
|
269
|
+
} catch {
|
|
270
|
+
// best effort on platforms without POSIX perms
|
|
271
|
+
}
|
|
383
272
|
committed = true
|
|
384
273
|
console.error(`modelmeter: reported ${payload.length} usage rows`)
|
|
385
274
|
} else {
|
|
@@ -390,16 +279,21 @@ try {
|
|
|
390
279
|
}
|
|
391
280
|
|
|
392
281
|
// Additive + best-effort: only after the daily batch is committed (state written),
|
|
393
|
-
// so a retry cannot double-count into the hourly window.
|
|
282
|
+
// so a retry cannot double-count into the hourly window. The daily state is already
|
|
283
|
+
// committed, so these detail rows will not be resent; retry once to cover a transient
|
|
284
|
+
// failure, then give up (the window self-heals as new data flows).
|
|
394
285
|
if (committed && (hourly.length > 0 || toolsPayload.length > 0)) {
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
286
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
287
|
+
try {
|
|
288
|
+
const res = await postJson(HOURLY_URL, {
|
|
289
|
+
source: 'collector',
|
|
290
|
+
hours: hourly,
|
|
291
|
+
tools: toolsPayload,
|
|
292
|
+
})
|
|
293
|
+
if (res.ok) break
|
|
294
|
+
} catch {
|
|
295
|
+
// fall through to one retry, then give up
|
|
296
|
+
}
|
|
403
297
|
}
|
|
404
298
|
}
|
|
405
299
|
process.exit(0)
|
package/lib.mjs
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// Pure, unit-tested core of the collector. All file I/O, networking, and state
|
|
2
|
+
// persistence live in collect.mjs; everything here is deterministic given its
|
|
3
|
+
// inputs, so it can be fixture-tested (see lib.test.mjs).
|
|
4
|
+
|
|
5
|
+
// Group a Claude tool name: MCP tools (mcp__server__tool) collapse to mcp:server,
|
|
6
|
+
// built-ins keep their name.
|
|
7
|
+
export function toolGroup(name) {
|
|
8
|
+
if (typeof name !== 'string' || !name) return 'unknown'
|
|
9
|
+
if (name.startsWith('mcp__')) {
|
|
10
|
+
const parts = name.split('__')
|
|
11
|
+
return parts[1] ? `mcp:${parts[1]}` : 'mcp:unknown'
|
|
12
|
+
}
|
|
13
|
+
return name
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Group a Codex tool event. Built-in calls carry a plain name; MCP calls carry an
|
|
17
|
+
// invocation with a server. Returns a group key, or null if it is not a tool call.
|
|
18
|
+
export function codexToolFromEvent(payload, ptype) {
|
|
19
|
+
if (ptype === 'function_call' || ptype === 'custom_tool_call') {
|
|
20
|
+
return typeof payload.name === 'string' && payload.name ? payload.name : null
|
|
21
|
+
}
|
|
22
|
+
if (ptype === 'mcp_tool_call_end' || ptype === 'mcp_tool_call_begin') {
|
|
23
|
+
const inv = payload.invocation || {}
|
|
24
|
+
const server = inv.server || inv.server_name
|
|
25
|
+
return server ? `mcp:${server}` : 'mcp'
|
|
26
|
+
}
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Depth-first find of the last token_count usage block in a Codex line.
|
|
31
|
+
export function findLastTokenCount(obj) {
|
|
32
|
+
let last = null
|
|
33
|
+
const stack = [obj]
|
|
34
|
+
while (stack.length) {
|
|
35
|
+
const d = stack.pop()
|
|
36
|
+
if (Array.isArray(d)) stack.push(...d)
|
|
37
|
+
else if (d && typeof d === 'object') {
|
|
38
|
+
if (d.type === 'token_count' && d.info?.total_token_usage) last = d.info.total_token_usage
|
|
39
|
+
for (const v of Object.values(d)) stack.push(v)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return last
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Detail endpoint from the ingest URL, tolerating a trailing slash, or an override.
|
|
46
|
+
export function deriveHourlyUrl(ingestUrl, override) {
|
|
47
|
+
if (override) return override
|
|
48
|
+
try {
|
|
49
|
+
const u = new URL(ingestUrl)
|
|
50
|
+
u.pathname = u.pathname.replace(/\/ingest\/?$/, '/ingest-hourly')
|
|
51
|
+
return u.toString()
|
|
52
|
+
} catch {
|
|
53
|
+
return ingestUrl.replace(/\/ingest\/?$/, '/ingest-hourly')
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Build an event from one parsed Claude transcript line, or null if it is not an
|
|
58
|
+
// assistant message with usage. Carries an `id` for the caller to dedup on.
|
|
59
|
+
export function claudeEventFromLine(o) {
|
|
60
|
+
const msg = o && o.message
|
|
61
|
+
if (!msg || msg.role !== 'assistant' || !msg.usage) return null
|
|
62
|
+
const u = msg.usage
|
|
63
|
+
const tools = Array.isArray(msg.content)
|
|
64
|
+
? msg.content
|
|
65
|
+
.filter((b) => b && b.type === 'tool_use')
|
|
66
|
+
.map((b) => b.name)
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
: []
|
|
69
|
+
return {
|
|
70
|
+
id: o.uuid || `${o.timestamp ?? ''}:${msg.id ?? ''}`,
|
|
71
|
+
provider: 'anthropic',
|
|
72
|
+
model: msg.model || 'claude-unknown',
|
|
73
|
+
occurredOn: (o.timestamp || '').slice(0, 10) || undefined,
|
|
74
|
+
occurredAt: o.timestamp || undefined,
|
|
75
|
+
tools,
|
|
76
|
+
uncachedInputTokens: u.input_tokens || 0,
|
|
77
|
+
cacheReadInputTokens: u.cache_read_input_tokens || 0,
|
|
78
|
+
cacheCreationInputTokens: u.cache_creation_input_tokens || 0,
|
|
79
|
+
outputTokens: u.output_tokens || 0,
|
|
80
|
+
numRequests: 1,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Codex cumulative-delta + tool attribution. Given what a session walk collected and
|
|
85
|
+
// the previous session state, returns the new event (or null), the tool deltas, and
|
|
86
|
+
// the next state. Tool calls are only attributed up to the token-accounted watermark
|
|
87
|
+
// (totalsTs), so calls after the latest token_count are left for the next run.
|
|
88
|
+
export function codexDelta({ totals, totalsTs, maxTs, model, fileDate, toolCalls }, prev) {
|
|
89
|
+
if (!totals) return { event: null, toolDeltas: [], state: prev ?? null }
|
|
90
|
+
const prevTotal =
|
|
91
|
+
(prev && prev.total) ||
|
|
92
|
+
(prev && prev.input_tokens != null ? prev : null) || {
|
|
93
|
+
input_tokens: 0,
|
|
94
|
+
cached_input_tokens: 0,
|
|
95
|
+
output_tokens: 0,
|
|
96
|
+
reasoning_output_tokens: 0,
|
|
97
|
+
}
|
|
98
|
+
const prevLastTs = (prev && prev.lastTs) || ''
|
|
99
|
+
const dInput = Math.max(0, (totals.input_tokens || 0) - prevTotal.input_tokens)
|
|
100
|
+
const dCached = Math.max(0, (totals.cached_input_tokens || 0) - prevTotal.cached_input_tokens)
|
|
101
|
+
const dOut = Math.max(0, (totals.output_tokens || 0) - prevTotal.output_tokens)
|
|
102
|
+
const dReason = Math.max(0, (totals.reasoning_output_tokens || 0) - prevTotal.reasoning_output_tokens)
|
|
103
|
+
if (dInput + dCached + dOut + dReason <= 0) {
|
|
104
|
+
return { event: null, toolDeltas: [], state: prev ?? null }
|
|
105
|
+
}
|
|
106
|
+
const newTotalTokens = Math.max(0, dInput - dCached) + dCached + (dOut + dReason)
|
|
107
|
+
const occurredOn = (totalsTs || '').slice(0, 10) || fileDate
|
|
108
|
+
const event = {
|
|
109
|
+
provider: 'openai',
|
|
110
|
+
model: model || 'gpt-5',
|
|
111
|
+
occurredOn,
|
|
112
|
+
occurredAt: totalsTs || undefined,
|
|
113
|
+
uncachedInputTokens: Math.max(0, dInput - dCached),
|
|
114
|
+
cacheReadInputTokens: dCached,
|
|
115
|
+
cacheCreationInputTokens: 0,
|
|
116
|
+
outputTokens: dOut + dReason,
|
|
117
|
+
numRequests: 1,
|
|
118
|
+
}
|
|
119
|
+
// Tool calls are deduped by the maxTs watermark: each is counted exactly once, in
|
|
120
|
+
// the run where it is first seen, and never recounted. Calls are exact. The
|
|
121
|
+
// even-split token figure is an estimate; a tail call's tokens can land in a later
|
|
122
|
+
// run's batch, but no call is ever dropped.
|
|
123
|
+
const newCalls = (toolCalls || []).filter((c) => c.ts && c.ts > prevLastTs)
|
|
124
|
+
const toolDeltas = []
|
|
125
|
+
if (newCalls.length > 0 && newTotalTokens > 0) {
|
|
126
|
+
const share = Math.round(newTotalTokens / newCalls.length)
|
|
127
|
+
for (const c of newCalls) {
|
|
128
|
+
toolDeltas.push({
|
|
129
|
+
tool: c.group,
|
|
130
|
+
bucketDate: (c.ts || '').slice(0, 10) || occurredOn,
|
|
131
|
+
calls: 1,
|
|
132
|
+
tokens: share,
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { event, toolDeltas, state: { total: totals, lastTs: maxTs || totalsTs || '' } }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Show enough of the token to recognize it, never the secret part.
|
|
140
|
+
export function maskToken(token) {
|
|
141
|
+
if (!token) return '(not set)'
|
|
142
|
+
return token.length > 12 ? `${token.slice(0, 12)}...` : token
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function relAgo(ms, nowMs) {
|
|
146
|
+
if (!ms) return 'never'
|
|
147
|
+
const s = Math.max(0, Math.round((nowMs - ms) / 1000))
|
|
148
|
+
if (s < 60) return `${s}s ago`
|
|
149
|
+
if (s < 3600) return `${Math.round(s / 60)}m ago`
|
|
150
|
+
if (s < 86_400) return `${Math.round(s / 3600)}h ago`
|
|
151
|
+
return `${Math.round(s / 86_400)}d ago`
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Render the `doctor` report from gathered facts (pure, so it is unit-tested).
|
|
155
|
+
// info: { configPath, configFound, token, ingestUrl, lookbackDays, nowMs,
|
|
156
|
+
// claude/codex: { dir, found, recentCount, lastWriteMs } }
|
|
157
|
+
export function formatDoctorReport(info) {
|
|
158
|
+
const lines = ['modelmeter-collect doctor', '']
|
|
159
|
+
lines.push(`Config ${info.configPath}`)
|
|
160
|
+
lines.push(` status: ${info.configFound ? 'found' : 'not found'}`)
|
|
161
|
+
lines.push(` token: ${maskToken(info.token)}`)
|
|
162
|
+
lines.push(` ingest URL: ${info.ingestUrl || '(not set)'}`)
|
|
163
|
+
lines.push('')
|
|
164
|
+
for (const [label, d] of [
|
|
165
|
+
['Claude Code', info.claude],
|
|
166
|
+
['Codex', info.codex],
|
|
167
|
+
]) {
|
|
168
|
+
lines.push(`${label} ${d.dir}`)
|
|
169
|
+
if (!d.found) {
|
|
170
|
+
lines.push(' logs: not found')
|
|
171
|
+
} else {
|
|
172
|
+
const n = d.recentCount
|
|
173
|
+
lines.push(` logs: found, ${n} session file${n === 1 ? '' : 's'} in the last ${info.lookbackDays} days`)
|
|
174
|
+
lines.push(` last write: ${relAgo(d.lastWriteMs, info.nowMs)}`)
|
|
175
|
+
}
|
|
176
|
+
lines.push('')
|
|
177
|
+
}
|
|
178
|
+
lines.push('Privacy')
|
|
179
|
+
lines.push(' sent: model names, token counts, tool and MCP names, dates')
|
|
180
|
+
lines.push(' never sent: prompts, responses, file contents, API keys')
|
|
181
|
+
return lines.join('\n')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Collapse events to one daily row per (provider, model, date).
|
|
185
|
+
export function aggregateDaily(events, today) {
|
|
186
|
+
const byKey = new Map()
|
|
187
|
+
for (const e of events) {
|
|
188
|
+
const date = e.occurredOn || today
|
|
189
|
+
const key = `${e.provider}|${e.model}|${date}`
|
|
190
|
+
const cur = byKey.get(key) || {
|
|
191
|
+
provider: e.provider,
|
|
192
|
+
model: e.model,
|
|
193
|
+
occurredOn: date,
|
|
194
|
+
uncachedInputTokens: 0,
|
|
195
|
+
cacheReadInputTokens: 0,
|
|
196
|
+
cacheCreationInputTokens: 0,
|
|
197
|
+
outputTokens: 0,
|
|
198
|
+
numRequests: 0,
|
|
199
|
+
}
|
|
200
|
+
cur.uncachedInputTokens += e.uncachedInputTokens || 0
|
|
201
|
+
cur.cacheReadInputTokens += e.cacheReadInputTokens || 0
|
|
202
|
+
cur.cacheCreationInputTokens += e.cacheCreationInputTokens || 0
|
|
203
|
+
cur.outputTokens += e.outputTokens || 0
|
|
204
|
+
cur.numRequests += e.numRequests || 1
|
|
205
|
+
byKey.set(key, cur)
|
|
206
|
+
}
|
|
207
|
+
return [...byKey.values()]
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Recent hourly buckets for the 5-hour window. Only events with a real timestamp in
|
|
211
|
+
// the lookback window qualify, so a backfill cannot pollute the recent window.
|
|
212
|
+
export function aggregateHourly(events, nowMs, lookbackMs = 8 * 3_600_000) {
|
|
213
|
+
const cutoff = nowMs - lookbackMs
|
|
214
|
+
const byHour = new Map()
|
|
215
|
+
for (const e of events) {
|
|
216
|
+
if (!e.occurredAt) continue
|
|
217
|
+
const t = new Date(e.occurredAt).getTime()
|
|
218
|
+
if (Number.isNaN(t) || t < cutoff) continue
|
|
219
|
+
const d = new Date(t)
|
|
220
|
+
d.setMinutes(0, 0, 0)
|
|
221
|
+
const hourIso = d.toISOString()
|
|
222
|
+
const key = `${e.provider}|${e.model}|${hourIso}`
|
|
223
|
+
const cur = byHour.get(key) || {
|
|
224
|
+
provider: e.provider,
|
|
225
|
+
model: e.model,
|
|
226
|
+
bucketHour: hourIso,
|
|
227
|
+
uncachedInputTokens: 0,
|
|
228
|
+
cacheReadInputTokens: 0,
|
|
229
|
+
cacheCreationInputTokens: 0,
|
|
230
|
+
outputTokens: 0,
|
|
231
|
+
numRequests: 0,
|
|
232
|
+
}
|
|
233
|
+
cur.uncachedInputTokens += e.uncachedInputTokens || 0
|
|
234
|
+
cur.cacheReadInputTokens += e.cacheReadInputTokens || 0
|
|
235
|
+
cur.cacheCreationInputTokens += e.cacheCreationInputTokens || 0
|
|
236
|
+
cur.outputTokens += e.outputTokens || 0
|
|
237
|
+
cur.numRequests += e.numRequests || 1
|
|
238
|
+
byHour.set(key, cur)
|
|
239
|
+
}
|
|
240
|
+
return [...byHour.values()]
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Per-tool / MCP rows. Claude events carry a `tools` array (even-split tokens across
|
|
244
|
+
// the distinct groups a turn called); Codex contributes precomputed tool deltas.
|
|
245
|
+
export function aggregateTools(events, codexToolDeltas = [], today) {
|
|
246
|
+
const byTool = new Map()
|
|
247
|
+
const add = (tool, date, calls, tokens) => {
|
|
248
|
+
const key = `${tool}|${date}`
|
|
249
|
+
const cur = byTool.get(key) || { tool, bucketDate: date, calls: 0, tokens: 0 }
|
|
250
|
+
cur.calls += calls
|
|
251
|
+
cur.tokens += tokens
|
|
252
|
+
byTool.set(key, cur)
|
|
253
|
+
}
|
|
254
|
+
for (const e of events) {
|
|
255
|
+
if (!Array.isArray(e.tools) || e.tools.length === 0) continue
|
|
256
|
+
const date = e.occurredOn || today
|
|
257
|
+
const callsByGroup = new Map()
|
|
258
|
+
for (const name of e.tools) {
|
|
259
|
+
const g = toolGroup(name)
|
|
260
|
+
callsByGroup.set(g, (callsByGroup.get(g) || 0) + 1)
|
|
261
|
+
}
|
|
262
|
+
const eventTokens =
|
|
263
|
+
(e.uncachedInputTokens || 0) +
|
|
264
|
+
(e.cacheReadInputTokens || 0) +
|
|
265
|
+
(e.cacheCreationInputTokens || 0) +
|
|
266
|
+
(e.outputTokens || 0)
|
|
267
|
+
const tokenShare = Math.round(eventTokens / callsByGroup.size)
|
|
268
|
+
for (const [g, calls] of callsByGroup) add(g, date, calls, tokenShare)
|
|
269
|
+
}
|
|
270
|
+
for (const d of codexToolDeltas) add(d.tool, d.bucketDate, d.calls, d.tokens)
|
|
271
|
+
return [...byTool.values()]
|
|
272
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "modelmeter-collect",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Report LLM token usage from local Claude Code / Codex logs to ModelMeter. Token counts only, never prompts or keys.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"cli.mjs",
|
|
11
11
|
"collect.mjs",
|
|
12
|
+
"lib.mjs",
|
|
12
13
|
"README.md"
|
|
13
14
|
],
|
|
14
15
|
"engines": {
|