modelmeter-collect 0.6.0 → 0.8.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 (3) hide show
  1. package/lib.mjs +67 -1
  2. package/package.json +1 -5
  3. package/lib.test.mjs +0 -173
package/lib.mjs CHANGED
@@ -42,6 +42,25 @@ export function findLastTokenCount(obj) {
42
42
  return last
43
43
  }
44
44
 
45
+ // Depth-first find of the first string `cwd` in a parsed line (Claude carries it at
46
+ // top level, Codex nests it under payload). Used only to derive a repo identity; the
47
+ // collector hashes it and never sends the raw path.
48
+ export function findCwd(obj) {
49
+ const stack = [obj]
50
+ let guard = 0
51
+ while (stack.length && guard < 5000) {
52
+ guard++
53
+ const d = stack.pop()
54
+ if (Array.isArray(d)) {
55
+ for (const v of d) stack.push(v)
56
+ } else if (d && typeof d === 'object') {
57
+ if (typeof d.cwd === 'string' && d.cwd) return d.cwd
58
+ for (const v of Object.values(d)) stack.push(v)
59
+ }
60
+ }
61
+ return ''
62
+ }
63
+
45
64
  // Detail endpoint from the ingest URL, tolerating a trailing slash, or an override.
46
65
  export function deriveHourlyUrl(ingestUrl, override) {
47
66
  if (override) return override
@@ -294,6 +313,9 @@ export function claudeSessionSummary(text) {
294
313
  let cacheCreate = 0
295
314
  let output = 0
296
315
  let requests = 0
316
+ let firstTs = ''
317
+ let lastTs = ''
318
+ let cwd = ''
297
319
  const contextSeq = []
298
320
  const tools = {}
299
321
  for (const line of String(text).split('\n')) {
@@ -304,9 +326,14 @@ export function claudeSessionSummary(text) {
304
326
  } catch {
305
327
  continue
306
328
  }
329
+ if (!cwd) cwd = findCwd(o)
307
330
  const ev = claudeEventFromLine(o)
308
331
  if (!ev) continue
309
332
  model = ev.model || model
333
+ if (ev.occurredAt) {
334
+ if (!firstTs || ev.occurredAt < firstTs) firstTs = ev.occurredAt
335
+ if (ev.occurredAt > lastTs) lastTs = ev.occurredAt
336
+ }
310
337
  uncached += ev.uncachedInputTokens
311
338
  cacheRead += ev.cacheReadInputTokens
312
339
  cacheCreate += ev.cacheCreationInputTokens
@@ -329,7 +356,7 @@ export function claudeSessionSummary(text) {
329
356
  }
330
357
  }
331
358
  if (requests === 0) return null
332
- return { provider: 'anthropic', model, uncached, cacheRead, cacheCreate, output, requests, contextSeq, tools }
359
+ return { provider: 'anthropic', model, uncached, cacheRead, cacheCreate, output, requests, firstTs, lastTs, cwd, contextSeq, tools }
333
360
  }
334
361
 
335
362
  // Summarize one Codex session from its final cumulative token_count. Codex totals are
@@ -338,6 +365,9 @@ export function codexSessionSummary(text) {
338
365
  let model = 'gpt-5'
339
366
  let totals = null
340
367
  let requests = 0
368
+ let firstTs = ''
369
+ let lastTs = ''
370
+ let cwd = ''
341
371
  const tools = {}
342
372
  for (const line of String(text).split('\n')) {
343
373
  if (!line.trim()) continue
@@ -347,10 +377,15 @@ export function codexSessionSummary(text) {
347
377
  } catch {
348
378
  continue
349
379
  }
380
+ if (!cwd) cwd = findCwd(o)
350
381
  const p = o.payload || o
351
382
  const ptype = p.type || o.type
352
383
  if (typeof o.model === 'string') model = o.model
353
384
  else if (typeof p.model === 'string') model = p.model
385
+ if (typeof o.timestamp === 'string') {
386
+ if (!firstTs || o.timestamp < firstTs) firstTs = o.timestamp
387
+ if (o.timestamp > lastTs) lastTs = o.timestamp
388
+ }
354
389
  const tc = findLastTokenCount(o)
355
390
  if (tc) {
356
391
  totals = tc
@@ -375,11 +410,42 @@ export function codexSessionSummary(text) {
375
410
  cacheCreate: 0,
376
411
  output,
377
412
  requests: Math.max(1, requests),
413
+ firstTs,
414
+ lastTs,
415
+ cwd,
378
416
  contextSeq: [],
379
417
  tools,
380
418
  }
381
419
  }
382
420
 
421
+ // Shape a session summary into the row the collector sends to ingest-hourly. Derives
422
+ // the bloat metrics from the context sequence and the bucket date from the timestamps.
423
+ // Returns null if there is no date to bucket on. The hash is supplied by the caller
424
+ // (the collector hashes the session-file basename, never the path).
425
+ export function sessionSendRow(summary, sessionHash) {
426
+ if (!summary || !sessionHash) return null
427
+ const bucketDate = (summary.lastTs || summary.firstTs || '').slice(0, 10)
428
+ if (!bucketDate) return null
429
+ const bloat = sessionBloat(summary.contextSeq)
430
+ return {
431
+ sessionHash,
432
+ provider: summary.provider,
433
+ model: summary.model,
434
+ bucketDate,
435
+ firstTs: summary.firstTs || '',
436
+ lastTs: summary.lastTs || '',
437
+ requests: summary.requests,
438
+ uncached: summary.uncached,
439
+ cacheRead: summary.cacheRead,
440
+ cacheCreation: summary.cacheCreate,
441
+ output: summary.output,
442
+ maxInputTurn: bloat ? bloat.max : 0,
443
+ first5Avg: bloat ? bloat.first5 : 0,
444
+ last5Avg: bloat ? bloat.last5 : 0,
445
+ toolCounts: summary.tools,
446
+ }
447
+ }
448
+
383
449
  // First-5 vs last-5 average turn size + max, for context-bloat detection.
384
450
  export function sessionBloat(seq) {
385
451
  if (!Array.isArray(seq) || seq.length < 10) return null
package/package.json CHANGED
@@ -1,19 +1,15 @@
1
1
  {
2
2
  "name": "modelmeter-collect",
3
- "version": "0.6.0",
3
+ "version": "0.8.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": {
7
7
  "modelmeter-collect": "cli.mjs"
8
8
  },
9
- "scripts": {
10
- "test": "node --test lib.test.mjs"
11
- },
12
9
  "files": [
13
10
  "cli.mjs",
14
11
  "collect.mjs",
15
12
  "lib.mjs",
16
- "lib.test.mjs",
17
13
  "README.md"
18
14
  ],
19
15
  "engines": {
package/lib.test.mjs DELETED
@@ -1,173 +0,0 @@
1
- import test from 'node:test'
2
- import assert from 'node:assert/strict'
3
-
4
- import {
5
- aggregateDaily,
6
- aggregateHourly,
7
- aggregateTools,
8
- claudeEventFromLine,
9
- codexDelta,
10
- deriveHourlyUrl,
11
- findLastTokenCount,
12
- formatDoctorReport,
13
- mergeDetailBatches,
14
- pruneClaudeState,
15
- } from './lib.mjs'
16
-
17
- test('codexDelta leaves post-token-count tool calls for the next batch', () => {
18
- const toolCalls = [
19
- { ts: '2026-06-18T12:01:00.000Z', group: 'exec_command' },
20
- { ts: '2026-06-18T12:03:00.000Z', group: 'apply_patch' },
21
- ]
22
- const first = codexDelta(
23
- {
24
- totals: { input_tokens: 100, cached_input_tokens: 0, output_tokens: 0, reasoning_output_tokens: 0 },
25
- totalsTs: '2026-06-18T12:02:00.000Z',
26
- maxTs: '2026-06-18T12:03:00.000Z',
27
- model: 'gpt-5',
28
- fileDate: '2026-06-18',
29
- toolCalls,
30
- },
31
- null,
32
- )
33
- assert.deepEqual(
34
- first.toolDeltas.map((d) => d.tool),
35
- ['exec_command'],
36
- )
37
- assert.equal(first.state.lastToolTs, '2026-06-18T12:02:00.000Z')
38
-
39
- const second = codexDelta(
40
- {
41
- totals: { input_tokens: 200, cached_input_tokens: 0, output_tokens: 0, reasoning_output_tokens: 0 },
42
- totalsTs: '2026-06-18T12:04:00.000Z',
43
- maxTs: '2026-06-18T12:04:00.000Z',
44
- model: 'gpt-5',
45
- fileDate: '2026-06-18',
46
- toolCalls,
47
- },
48
- first.state,
49
- )
50
- assert.deepEqual(
51
- second.toolDeltas.map((d) => d.tool),
52
- ['apply_patch'],
53
- )
54
- })
55
-
56
- test('pruneClaudeState keeps newest timestamped ids and normalizes legacy entries', () => {
57
- const pruned = pruneClaudeState(
58
- {
59
- old: { ts: '2026-06-18T10:00:00.000Z' },
60
- legacy: 1,
61
- newest: { ts: '2026-06-18T12:00:00.000Z' },
62
- middle: { ts: '2026-06-18T11:00:00.000Z' },
63
- },
64
- 2,
65
- )
66
- assert.deepEqual(Object.keys(pruned), ['middle', 'newest'])
67
- assert.deepEqual(pruned.middle, { ts: '2026-06-18T11:00:00.000Z' })
68
- })
69
-
70
- test('deriveHourlyUrl fails closed for nonstandard ingest URLs unless overridden', () => {
71
- assert.equal(
72
- deriveHourlyUrl('https://x.test/functions/v1/ingest/'),
73
- 'https://x.test/functions/v1/ingest-hourly',
74
- )
75
- assert.equal(deriveHourlyUrl('https://x.test/custom'), null)
76
- assert.equal(deriveHourlyUrl('https://x.test/custom', 'https://x.test/detail'), 'https://x.test/detail')
77
- })
78
-
79
- test('mergeDetailBatches deduplicates additive rows by bucket', () => {
80
- const merged = mergeDetailBatches(
81
- {
82
- hours: [
83
- {
84
- provider: 'openai',
85
- model: 'gpt-5',
86
- bucketHour: '2026-06-18T12:00:00.000Z',
87
- uncachedInputTokens: 1,
88
- cacheReadInputTokens: 2,
89
- cacheCreationInputTokens: 3,
90
- outputTokens: 4,
91
- numRequests: 1,
92
- },
93
- ],
94
- tools: [{ tool: 'exec_command', bucketDate: '2026-06-18', calls: 1, tokens: 10 }],
95
- },
96
- {
97
- hours: [
98
- {
99
- provider: 'openai',
100
- model: 'gpt-5',
101
- bucketHour: '2026-06-18T12:00:00.000Z',
102
- uncachedInputTokens: 5,
103
- cacheReadInputTokens: 0,
104
- cacheCreationInputTokens: 0,
105
- outputTokens: 6,
106
- numRequests: 1,
107
- },
108
- ],
109
- tools: [{ tool: 'exec_command', bucketDate: '2026-06-18', calls: 2, tokens: 20 }],
110
- },
111
- )
112
- assert.equal(merged.hours.length, 1)
113
- assert.equal(merged.hours[0].uncachedInputTokens, 6)
114
- assert.equal(merged.hours[0].outputTokens, 10)
115
- assert.equal(merged.hours[0].numRequests, 2)
116
- assert.deepEqual(merged.tools, [{ tool: 'exec_command', bucketDate: '2026-06-18', calls: 3, tokens: 30 }])
117
- })
118
-
119
- test('claude parsing and aggregations exclude prompt text', () => {
120
- const event = claudeEventFromLine({
121
- uuid: 'c1',
122
- timestamp: '2026-06-18T12:34:56.000Z',
123
- message: {
124
- role: 'assistant',
125
- model: 'claude-sonnet',
126
- usage: {
127
- input_tokens: 100,
128
- cache_read_input_tokens: 20,
129
- cache_creation_input_tokens: 5,
130
- output_tokens: 30,
131
- },
132
- content: [
133
- { type: 'text', text: 'response text must not be copied' },
134
- { type: 'tool_use', name: 'mcp__supabase__query' },
135
- { type: 'tool_use', name: 'exec_command' },
136
- ],
137
- },
138
- })
139
- assert.equal(event.id, 'c1')
140
- assert.equal(event.provider, 'anthropic')
141
- assert.deepEqual(event.tools, ['mcp__supabase__query', 'exec_command'])
142
- assert.equal(JSON.stringify(event).includes('response text'), false)
143
-
144
- assert.equal(aggregateDaily([event], '2026-06-18')[0].uncachedInputTokens, 100)
145
- assert.equal(aggregateHourly([event], new Date('2026-06-18T13:00:00.000Z').getTime()).length, 1)
146
- assert.deepEqual(
147
- aggregateTools([event], [], '2026-06-18').map((d) => d.tool).sort(),
148
- ['exec_command', 'mcp:supabase'],
149
- )
150
- })
151
-
152
- test('findLastTokenCount and doctor report cover nested Codex usage and privacy copy', () => {
153
- const totals = findLastTokenCount({
154
- payload: [
155
- { type: 'token_count', info: { total_token_usage: { input_tokens: 1 } } },
156
- { nested: { type: 'token_count', info: { total_token_usage: { input_tokens: 2 } } } },
157
- ],
158
- })
159
- assert.deepEqual(totals, { input_tokens: 2 })
160
-
161
- const report = formatDoctorReport({
162
- configPath: '/tmp/config.json',
163
- configFound: true,
164
- token: 'mm_live_abcdefghijklmnop',
165
- ingestUrl: 'https://x.test/functions/v1/ingest',
166
- lookbackDays: 14,
167
- nowMs: 1000,
168
- claude: { dir: '/tmp/claude', found: false },
169
- codex: { dir: '/tmp/codex', found: true, recentCount: 1, lastWriteMs: 1000 },
170
- })
171
- assert.match(report, /mm_live_abcd\.\.\./)
172
- assert.match(report, /never sent: prompts/)
173
- })