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.
- package/lib.mjs +67 -1
- package/package.json +1 -5
- 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.
|
|
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
|
-
})
|