modelmeter-collect 0.3.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 +71 -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) {
@@ -143,14 +146,30 @@ function findLastTokenCount(obj) {
143
146
  }
144
147
  return last
145
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
+
146
163
  function scanCodex(files) {
147
164
  for (const file of files) {
148
165
  const m = file.match(/rollout-(\d{4}-\d{2}-\d{2})T[\d-]+-([0-9a-f-]+)\.jsonl$/)
149
166
  if (!m) continue
150
- const date = m[1]
151
167
  const sessionId = m[2]
152
168
  let totals = null
169
+ let totalsTs = null // timestamp of the latest token_count event, for the 5-hour window
170
+ let maxTs = ''
153
171
  let model = 'gpt-5'
172
+ const toolCalls = [] // { ts, group }
154
173
  let text = ''
155
174
  try {
156
175
  text = readFileSync(file, 'utf8')
@@ -165,34 +184,63 @@ function scanCodex(files) {
165
184
  } catch {
166
185
  continue
167
186
  }
168
- const t = findLastTokenCount(o)
169
- 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
170
191
  if (typeof o.model === 'string') model = o.model
171
- 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 })
172
200
  }
173
201
  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)
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)
184
217
  if (dInput + dCached + dOut + dReason > 0) {
218
+ const occurredOn = (totalsTs || '').slice(0, 10) || m[1]
185
219
  events.push({
186
220
  provider: 'openai',
187
221
  model,
188
- occurredOn: date,
222
+ occurredOn,
223
+ occurredAt: totalsTs || undefined, // enables the 5-hour window for Codex
189
224
  uncachedInputTokens: Math.max(0, dInput - dCached),
190
225
  cacheReadInputTokens: dCached,
191
226
  cacheCreationInputTokens: 0,
192
227
  outputTokens: dOut + dReason, // reasoning tokens bill as output
193
228
  numRequests: 1,
194
229
  })
195
- 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 }
196
244
  }
197
245
  }
198
246
  }
@@ -299,6 +347,13 @@ for (const e of events) {
299
347
  byTool.set(key, cur)
300
348
  }
301
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
+ }
302
357
  const toolsPayload = [...byTool.values()]
303
358
 
304
359
  if (payload.length === 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "modelmeter-collect",
3
- "version": "0.3.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": {