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.
Files changed (5) hide show
  1. package/README.md +10 -0
  2. package/cli.mjs +79 -1
  3. package/collect.mjs +99 -205
  4. package/lib.mjs +272 -0
  5. 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 { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'node:fs'
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 { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, readdirSync } from 'node:fs'
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
- const LOOKBACK_DAYS = Number(process.env.MODELMETER_LOOKBACK_DAYS) || 14
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 msg = o.message
111
- if (!msg || msg.role !== 'assistant' || !msg.usage) continue
112
- const id = o.uuid || `${o.timestamp ?? ''}:${msg.id ?? ''}`
113
- if (!id || state.claude[id]) continue
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
- if (!totals) continue
202
- const stored = state.codex[sessionId]
203
- const prevTotal =
204
- (stored && stored.total) ||
205
- (stored && stored.input_tokens != null ? stored : null) || {
206
- input_tokens: 0,
207
- cached_input_tokens: 0,
208
- output_tokens: 0,
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 byKey = new Map()
259
- for (const e of events) {
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 window via a separate, additive
282
- // endpoint. Only events with a real timestamp in the last 8 hours qualify, so a
283
- // backfill scan never pollutes the recent window. (Codex deltas lack per-event
284
- // timestamps, so the 5-hour window is Claude Code for now.)
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. Group MCP tools by server (mcp__server__tool ->
317
- // mcp:server) and keep built-ins by name. Calls are exact; tokens are an even
318
- // split of each turn's usage across the distinct tool groups it called.
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 fetch(INGEST_URL, {
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
- try {
396
- await fetch(HOURLY_URL, {
397
- method: 'POST',
398
- headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
399
- body: JSON.stringify({ source: 'collector', hours: hourly, tools: toolsPayload }),
400
- })
401
- } catch {
402
- // detail (hourly + per-tool) is best-effort; never block the collector on it
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.4.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": {