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.
- package/collect.mjs +165 -16
- 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
|
|
164
|
-
if (
|
|
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
|
|
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
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
const
|
|
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
|
|
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
|
-
|
|
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