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.
- package/collect.mjs +94 -0
- 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