modelmeter-collect 0.1.0 → 0.4.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 (2) hide show
  1. package/collect.mjs +165 -16
  2. package/package.json +1 -1
package/collect.mjs CHANGED
@@ -86,6 +86,9 @@ function recentFiles(dir, limit = Infinity) {
86
86
  }
87
87
 
88
88
  const events = []
89
+ // Codex tool attribution is computed per session (its token accounting is periodic,
90
+ // not per-turn), so it is collected here and folded into the tool aggregation later.
91
+ const codexToolDeltas = []
89
92
 
90
93
  // --- Claude Code: assistant turns carry message.usage; dedup by message uuid.
91
94
  function scanClaude(files) {
@@ -110,10 +113,15 @@ function scanClaude(files) {
110
113
  if (!id || state.claude[id]) continue
111
114
  state.claude[id] = 1
112
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
+ : []
113
119
  events.push({
114
120
  provider: 'anthropic',
115
121
  model: msg.model || 'claude-unknown',
116
122
  occurredOn: (o.timestamp || '').slice(0, 10) || undefined,
123
+ occurredAt: o.timestamp || undefined,
124
+ tools: toolNames,
117
125
  uncachedInputTokens: u.input_tokens || 0,
118
126
  cacheReadInputTokens: u.cache_read_input_tokens || 0,
119
127
  cacheCreationInputTokens: u.cache_creation_input_tokens || 0,
@@ -138,14 +146,30 @@ function findLastTokenCount(obj) {
138
146
  }
139
147
  return last
140
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
+
141
163
  function scanCodex(files) {
142
164
  for (const file of files) {
143
165
  const m = file.match(/rollout-(\d{4}-\d{2}-\d{2})T[\d-]+-([0-9a-f-]+)\.jsonl$/)
144
166
  if (!m) continue
145
- const date = m[1]
146
167
  const sessionId = m[2]
147
168
  let totals = null
169
+ let totalsTs = null // timestamp of the latest token_count event, for the 5-hour window
170
+ let maxTs = ''
148
171
  let model = 'gpt-5'
172
+ const toolCalls = [] // { ts, group }
149
173
  let text = ''
150
174
  try {
151
175
  text = readFileSync(file, 'utf8')
@@ -160,34 +184,63 @@ function scanCodex(files) {
160
184
  } catch {
161
185
  continue
162
186
  }
163
- const t = findLastTokenCount(o)
164
- if (t) totals = t
187
+ const ts = typeof o.timestamp === 'string' ? o.timestamp : null
188
+ if (ts && ts > maxTs) maxTs = ts
189
+ const p = o.payload || o
190
+ const ptype = p.type || o.type
165
191
  if (typeof o.model === 'string') model = o.model
166
- else if (typeof o.payload?.model === 'string') model = o.payload.model
192
+ else if (typeof p.model === 'string') model = p.model
193
+ const tc = findLastTokenCount(o)
194
+ if (tc) {
195
+ totals = tc
196
+ if (ts) totalsTs = ts
197
+ }
198
+ const g = codexToolFromEvent(p, ptype)
199
+ if (g) toolCalls.push({ ts: ts || totalsTs || '', group: g })
167
200
  }
168
201
  if (!totals) continue
169
- const prev = state.codex[sessionId] || {
170
- input_tokens: 0,
171
- cached_input_tokens: 0,
172
- output_tokens: 0,
173
- reasoning_output_tokens: 0,
174
- }
175
- const dInput = Math.max(0, (totals.input_tokens || 0) - prev.input_tokens)
176
- const dCached = Math.max(0, (totals.cached_input_tokens || 0) - prev.cached_input_tokens)
177
- const dOut = Math.max(0, (totals.output_tokens || 0) - prev.output_tokens)
178
- const dReason = Math.max(0, (totals.reasoning_output_tokens || 0) - prev.reasoning_output_tokens)
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)
179
217
  if (dInput + dCached + dOut + dReason > 0) {
218
+ const occurredOn = (totalsTs || '').slice(0, 10) || m[1]
180
219
  events.push({
181
220
  provider: 'openai',
182
221
  model,
183
- occurredOn: date,
222
+ occurredOn,
223
+ occurredAt: totalsTs || undefined, // enables the 5-hour window for Codex
184
224
  uncachedInputTokens: Math.max(0, dInput - dCached),
185
225
  cacheReadInputTokens: dCached,
186
226
  cacheCreationInputTokens: 0,
187
227
  outputTokens: dOut + dReason, // reasoning tokens bill as output
188
228
  numRequests: 1,
189
229
  })
190
- state.codex[sessionId] = totals
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 }
191
244
  }
192
245
  }
193
246
  }
@@ -225,6 +278,84 @@ for (const e of events) {
225
278
  }
226
279
  const payload = [...byKey.values()]
227
280
 
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')
315
+
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()]
358
+
228
359
  if (payload.length === 0) {
229
360
  process.exit(0)
230
361
  }
@@ -233,10 +364,13 @@ if (process.env.MODELMETER_DRYRUN) {
233
364
  const tally = {}
234
365
  for (const e of events) tally[e.provider] = (tally[e.provider] || 0) + 1
235
366
  console.log(`DRY RUN: ${events.length} raw events -> ${payload.length} daily rows`, tally)
367
+ console.log(` + ${hourly.length} recent hourly rows, ${toolsPayload.length} tool rows -> ${HOURLY_URL}`)
236
368
  console.log(JSON.stringify(payload, null, 2))
369
+ if (toolsPayload.length) console.log('tools:', JSON.stringify(toolsPayload, null, 2))
237
370
  process.exit(0)
238
371
  }
239
372
 
373
+ let committed = false
240
374
  try {
241
375
  const res = await fetch(INGEST_URL, {
242
376
  method: 'POST',
@@ -246,6 +380,7 @@ try {
246
380
  if (res.ok) {
247
381
  if (!existsSync(MM_DIR)) mkdirSync(MM_DIR, { recursive: true })
248
382
  writeFileSync(STATE_PATH, JSON.stringify(state))
383
+ committed = true
249
384
  console.error(`modelmeter: reported ${payload.length} usage rows`)
250
385
  } else {
251
386
  console.error(`modelmeter: ingest returned ${res.status}`)
@@ -253,4 +388,18 @@ try {
253
388
  } catch (err) {
254
389
  console.error(`modelmeter: ${err.message}`)
255
390
  }
391
+
392
+ // Additive + best-effort: only after the daily batch is committed (state written),
393
+ // so a retry cannot double-count into the hourly window.
394
+ 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
403
+ }
404
+ }
256
405
  process.exit(0)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelmeter-collect",
3
- "version": "0.1.0",
3
+ "version": "0.4.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": {