modelmeter-collect 0.1.0 → 0.3.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 +94 -0
  2. package/package.json +1 -1
package/collect.mjs CHANGED
@@ -110,10 +110,15 @@ function scanClaude(files) {
110
110
  if (!id || state.claude[id]) continue
111
111
  state.claude[id] = 1
112
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
+ : []
113
116
  events.push({
114
117
  provider: 'anthropic',
115
118
  model: msg.model || 'claude-unknown',
116
119
  occurredOn: (o.timestamp || '').slice(0, 10) || undefined,
120
+ occurredAt: o.timestamp || undefined,
121
+ tools: toolNames,
117
122
  uncachedInputTokens: u.input_tokens || 0,
118
123
  cacheReadInputTokens: u.cache_read_input_tokens || 0,
119
124
  cacheCreationInputTokens: u.cache_creation_input_tokens || 0,
@@ -225,6 +230,77 @@ for (const e of events) {
225
230
  }
226
231
  const payload = [...byKey.values()]
227
232
 
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')
267
+
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()]
303
+
228
304
  if (payload.length === 0) {
229
305
  process.exit(0)
230
306
  }
@@ -233,10 +309,13 @@ if (process.env.MODELMETER_DRYRUN) {
233
309
  const tally = {}
234
310
  for (const e of events) tally[e.provider] = (tally[e.provider] || 0) + 1
235
311
  console.log(`DRY RUN: ${events.length} raw events -> ${payload.length} daily rows`, tally)
312
+ console.log(` + ${hourly.length} recent hourly rows, ${toolsPayload.length} tool rows -> ${HOURLY_URL}`)
236
313
  console.log(JSON.stringify(payload, null, 2))
314
+ if (toolsPayload.length) console.log('tools:', JSON.stringify(toolsPayload, null, 2))
237
315
  process.exit(0)
238
316
  }
239
317
 
318
+ let committed = false
240
319
  try {
241
320
  const res = await fetch(INGEST_URL, {
242
321
  method: 'POST',
@@ -246,6 +325,7 @@ try {
246
325
  if (res.ok) {
247
326
  if (!existsSync(MM_DIR)) mkdirSync(MM_DIR, { recursive: true })
248
327
  writeFileSync(STATE_PATH, JSON.stringify(state))
328
+ committed = true
249
329
  console.error(`modelmeter: reported ${payload.length} usage rows`)
250
330
  } else {
251
331
  console.error(`modelmeter: ingest returned ${res.status}`)
@@ -253,4 +333,18 @@ try {
253
333
  } catch (err) {
254
334
  console.error(`modelmeter: ${err.message}`)
255
335
  }
336
+
337
+ // Additive + best-effort: only after the daily batch is committed (state written),
338
+ // so a retry cannot double-count into the hourly window.
339
+ 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
348
+ }
349
+ }
256
350
  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.3.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": {