modelmeter-collect 0.3.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 +117 -168
  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')) }
@@ -86,6 +136,9 @@ function recentFiles(dir, limit = Infinity) {
86
136
  }
87
137
 
88
138
  const events = []
139
+ // Codex tool attribution is computed per session (its token accounting is periodic,
140
+ // not per-turn), so it is collected here and folded into the tool aggregation later.
141
+ const codexToolDeltas = []
89
142
 
90
143
  // --- Claude Code: assistant turns carry message.usage; dedup by message uuid.
91
144
  function scanClaude(files) {
@@ -104,53 +157,25 @@ function scanClaude(files) {
104
157
  } catch {
105
158
  continue
106
159
  }
107
- const msg = o.message
108
- if (!msg || msg.role !== 'assistant' || !msg.usage) continue
109
- const id = o.uuid || `${o.timestamp ?? ''}:${msg.id ?? ''}`
110
- if (!id || state.claude[id]) continue
111
- state.claude[id] = 1
112
- const u = msg.usage
113
- const toolNames = Array.isArray(msg.content)
114
- ? msg.content.filter((b) => b && b.type === 'tool_use').map((b) => b.name).filter(Boolean)
115
- : []
116
- events.push({
117
- provider: 'anthropic',
118
- model: msg.model || 'claude-unknown',
119
- occurredOn: (o.timestamp || '').slice(0, 10) || undefined,
120
- occurredAt: o.timestamp || undefined,
121
- tools: toolNames,
122
- uncachedInputTokens: u.input_tokens || 0,
123
- cacheReadInputTokens: u.cache_read_input_tokens || 0,
124
- cacheCreationInputTokens: u.cache_creation_input_tokens || 0,
125
- outputTokens: u.output_tokens || 0,
126
- numRequests: 1,
127
- })
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)
128
164
  }
129
165
  }
130
166
  }
131
167
 
132
168
  // --- Codex: cumulative token_count events; report per-session delta.
133
- function findLastTokenCount(obj) {
134
- let last = null
135
- const stack = [obj]
136
- while (stack.length) {
137
- const d = stack.pop()
138
- if (Array.isArray(d)) stack.push(...d)
139
- else if (d && typeof d === 'object') {
140
- if (d.type === 'token_count' && d.info?.total_token_usage) last = d.info.total_token_usage
141
- for (const v of Object.values(d)) stack.push(v)
142
- }
143
- }
144
- return last
145
- }
146
169
  function scanCodex(files) {
147
170
  for (const file of files) {
148
171
  const m = file.match(/rollout-(\d{4}-\d{2}-\d{2})T[\d-]+-([0-9a-f-]+)\.jsonl$/)
149
172
  if (!m) continue
150
- const date = m[1]
151
173
  const sessionId = m[2]
152
174
  let totals = null
175
+ let totalsTs = null // timestamp of the latest token_count event, for the 5-hour window
176
+ let maxTs = ''
153
177
  let model = 'gpt-5'
178
+ const toolCalls = [] // { ts, group }
154
179
  let text = ''
155
180
  try {
156
181
  text = readFileSync(file, 'utf8')
@@ -165,34 +190,28 @@ function scanCodex(files) {
165
190
  } catch {
166
191
  continue
167
192
  }
168
- const t = findLastTokenCount(o)
169
- if (t) totals = t
193
+ const ts = typeof o.timestamp === 'string' ? o.timestamp : null
194
+ if (ts && ts > maxTs) maxTs = ts
195
+ const p = o.payload || o
196
+ const ptype = p.type || o.type
170
197
  if (typeof o.model === 'string') model = o.model
171
- else if (typeof o.payload?.model === 'string') model = o.payload.model
198
+ else if (typeof p.model === 'string') model = p.model
199
+ const tc = findLastTokenCount(o)
200
+ if (tc) {
201
+ totals = tc
202
+ if (ts) totalsTs = ts
203
+ }
204
+ const g = codexToolFromEvent(p, ptype)
205
+ if (g) toolCalls.push({ ts: ts || totalsTs || '', group: g })
172
206
  }
173
- if (!totals) continue
174
- const prev = state.codex[sessionId] || {
175
- input_tokens: 0,
176
- cached_input_tokens: 0,
177
- output_tokens: 0,
178
- reasoning_output_tokens: 0,
179
- }
180
- const dInput = Math.max(0, (totals.input_tokens || 0) - prev.input_tokens)
181
- const dCached = Math.max(0, (totals.cached_input_tokens || 0) - prev.cached_input_tokens)
182
- const dOut = Math.max(0, (totals.output_tokens || 0) - prev.output_tokens)
183
- const dReason = Math.max(0, (totals.reasoning_output_tokens || 0) - prev.reasoning_output_tokens)
184
- if (dInput + dCached + dOut + dReason > 0) {
185
- events.push({
186
- provider: 'openai',
187
- model,
188
- occurredOn: date,
189
- uncachedInputTokens: Math.max(0, dInput - dCached),
190
- cacheReadInputTokens: dCached,
191
- cacheCreationInputTokens: 0,
192
- outputTokens: dOut + dReason, // reasoning tokens bill as output
193
- numRequests: 1,
194
- })
195
- state.codex[sessionId] = totals
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
196
215
  }
197
216
  }
198
217
  }
@@ -207,99 +226,17 @@ if (hookInput?.transcript_path) {
207
226
  }
208
227
 
209
228
  // Collapse to one row per (provider, model, day) so the request stays small.
210
- const byKey = new Map()
211
- for (const e of events) {
212
- const date = e.occurredOn || new Date().toISOString().slice(0, 10)
213
- const key = `${e.provider}|${e.model}|${date}`
214
- const cur = byKey.get(key) || {
215
- provider: e.provider,
216
- model: e.model,
217
- occurredOn: date,
218
- uncachedInputTokens: 0,
219
- cacheReadInputTokens: 0,
220
- cacheCreationInputTokens: 0,
221
- outputTokens: 0,
222
- numRequests: 0,
223
- }
224
- cur.uncachedInputTokens += e.uncachedInputTokens || 0
225
- cur.cacheReadInputTokens += e.cacheReadInputTokens || 0
226
- cur.cacheCreationInputTokens += e.cacheCreationInputTokens || 0
227
- cur.outputTokens += e.outputTokens || 0
228
- cur.numRequests += e.numRequests || 1
229
- byKey.set(key, cur)
230
- }
231
- const payload = [...byKey.values()]
229
+ const today = new Date().toISOString().slice(0, 10)
230
+ const payload = aggregateDaily(events, today)
232
231
 
233
- // Recent hourly buckets feed the 5-hour rolling window via a separate, additive
234
- // endpoint. Only events with a real timestamp in the last 8 hours qualify, so a
235
- // backfill scan never pollutes the recent window. (Codex deltas lack per-event
236
- // timestamps, so the 5-hour window is Claude Code for now.)
237
- const HOUR_MS = 3_600_000
238
- const recentCutoff = Date.now() - 8 * HOUR_MS
239
- const byHour = new Map()
240
- for (const e of events) {
241
- if (!e.occurredAt) continue
242
- const t = new Date(e.occurredAt).getTime()
243
- if (Number.isNaN(t) || t < recentCutoff) continue
244
- const d = new Date(t)
245
- d.setMinutes(0, 0, 0)
246
- const hourIso = d.toISOString()
247
- const key = `${e.provider}|${e.model}|${hourIso}`
248
- const cur = byHour.get(key) || {
249
- provider: e.provider,
250
- model: e.model,
251
- bucketHour: hourIso,
252
- uncachedInputTokens: 0,
253
- cacheReadInputTokens: 0,
254
- cacheCreationInputTokens: 0,
255
- outputTokens: 0,
256
- numRequests: 0,
257
- }
258
- cur.uncachedInputTokens += e.uncachedInputTokens || 0
259
- cur.cacheReadInputTokens += e.cacheReadInputTokens || 0
260
- cur.cacheCreationInputTokens += e.cacheCreationInputTokens || 0
261
- cur.outputTokens += e.outputTokens || 0
262
- cur.numRequests += e.numRequests || 1
263
- byHour.set(key, cur)
264
- }
265
- const hourly = [...byHour.values()]
266
- 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)
267
236
 
268
- // Per-tool / per-MCP attribution. Group MCP tools by server (mcp__server__tool ->
269
- // mcp:server) and keep built-ins by name. Calls are exact; tokens are an even
270
- // split of each turn's usage across the distinct tool groups it called.
271
- function toolGroup(name) {
272
- if (typeof name !== 'string' || !name) return 'unknown'
273
- if (name.startsWith('mcp__')) {
274
- const parts = name.split('__')
275
- return parts[1] ? `mcp:${parts[1]}` : 'mcp:unknown'
276
- }
277
- return name
278
- }
279
- const byTool = new Map()
280
- for (const e of events) {
281
- if (!Array.isArray(e.tools) || e.tools.length === 0) continue
282
- const date = e.occurredOn || new Date().toISOString().slice(0, 10)
283
- const callsByGroup = new Map()
284
- for (const name of e.tools) {
285
- const g = toolGroup(name)
286
- callsByGroup.set(g, (callsByGroup.get(g) || 0) + 1)
287
- }
288
- const eventTokens =
289
- (e.uncachedInputTokens || 0) +
290
- (e.cacheReadInputTokens || 0) +
291
- (e.cacheCreationInputTokens || 0) +
292
- (e.outputTokens || 0)
293
- const tokenShare = Math.round(eventTokens / callsByGroup.size)
294
- for (const [g, calls] of callsByGroup) {
295
- const key = `${g}|${date}`
296
- const cur = byTool.get(key) || { tool: g, bucketDate: date, calls: 0, tokens: 0 }
297
- cur.calls += calls
298
- cur.tokens += tokenShare
299
- byTool.set(key, cur)
300
- }
301
- }
302
- 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)
303
240
 
304
241
  if (payload.length === 0) {
305
242
  process.exit(0)
@@ -317,14 +254,21 @@ if (process.env.MODELMETER_DRYRUN) {
317
254
 
318
255
  let committed = false
319
256
  try {
320
- const res = await fetch(INGEST_URL, {
321
- method: 'POST',
322
- headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
323
- body: JSON.stringify({ source: 'collector', events: payload }),
324
- })
257
+ const res = await postJson(INGEST_URL, { source: 'collector', events: payload })
325
258
  if (res.ok) {
326
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
+ }
327
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
+ }
328
272
  committed = true
329
273
  console.error(`modelmeter: reported ${payload.length} usage rows`)
330
274
  } else {
@@ -335,16 +279,21 @@ try {
335
279
  }
336
280
 
337
281
  // Additive + best-effort: only after the daily batch is committed (state written),
338
- // 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).
339
285
  if (committed && (hourly.length > 0 || toolsPayload.length > 0)) {
340
- try {
341
- await fetch(HOURLY_URL, {
342
- method: 'POST',
343
- headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
344
- body: JSON.stringify({ source: 'collector', hours: hourly, tools: toolsPayload }),
345
- })
346
- } catch {
347
- // 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
+ }
348
297
  }
349
298
  }
350
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.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": {