modelmeter-collect 0.4.0 → 0.6.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/README.md +17 -0
- package/cli.mjs +157 -1
- package/collect.mjs +138 -220
- package/lib.mjs +503 -0
- package/lib.test.mjs +173 -0
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -28,6 +28,22 @@ MODELMETER_DRYRUN=1 npx modelmeter-collect
|
|
|
28
28
|
`init` writes `~/.modelmeter/config.json` (chmod 600) with your token and the ingest URL.
|
|
29
29
|
Prefer env vars? Set `MODELMETER_TOKEN` and `MODELMETER_INGEST_URL` and skip `init`.
|
|
30
30
|
|
|
31
|
+
## Check your setup
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx modelmeter-collect doctor # which logs were found, last activity, config
|
|
35
|
+
npx modelmeter-collect doctor --recommendations # local optimization tips from your logs
|
|
36
|
+
npx modelmeter-collect doctor --payload # + the exact JSON that would be sent
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`doctor` confirms it found your Claude Code and Codex logs and shows precisely what leaves
|
|
40
|
+
your machine: model names, token counts, and tool/MCP names only. Never prompts or keys.
|
|
41
|
+
|
|
42
|
+
`doctor --recommendations` scores your recent sessions locally (nothing is sent) and prints
|
|
43
|
+
optimization tips: cache reuse, a dominant MCP server, output verbosity, and context bloat
|
|
44
|
+
(a session whose per-turn context kept growing). All computed from token counts, never your
|
|
45
|
+
prompt text.
|
|
46
|
+
|
|
31
47
|
## Keep it live (per prompt)
|
|
32
48
|
|
|
33
49
|
**Claude Code** — add a `Stop` hook (fires after every response). It passes the session
|
|
@@ -71,6 +87,7 @@ does the same job.)
|
|
|
71
87
|
| --- | --- | --- |
|
|
72
88
|
| `MODELMETER_TOKEN` | from config file | Your `mm_live_...` ingest token |
|
|
73
89
|
| `MODELMETER_INGEST_URL` | from config file | The ingest endpoint |
|
|
90
|
+
| `MODELMETER_HOURLY_INGEST_URL` | derived from ingest URL | Detail endpoint for recent hourly/tool rows |
|
|
74
91
|
| `MODELMETER_LOOKBACK_DAYS` | `14` | How many days of logs to scan |
|
|
75
92
|
| `MODELMETER_DRYRUN` | unset | When set, print the payload instead of sending |
|
|
76
93
|
|
package/cli.mjs
CHANGED
|
@@ -6,10 +6,24 @@
|
|
|
6
6
|
// npx modelmeter-collect init <mm_live_token> # one-time: save the token
|
|
7
7
|
// npx modelmeter-collect # scan local logs and report
|
|
8
8
|
// MODELMETER_DRYRUN=1 npx modelmeter-collect # preview without sending
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
readFileSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
existsSync,
|
|
14
|
+
chmodSync,
|
|
15
|
+
readdirSync,
|
|
16
|
+
statSync,
|
|
17
|
+
} from 'node:fs'
|
|
10
18
|
import { homedir } from 'node:os'
|
|
11
19
|
import { join, dirname } from 'node:path'
|
|
12
20
|
import { fileURLToPath } from 'node:url'
|
|
21
|
+
import {
|
|
22
|
+
formatDoctorReport,
|
|
23
|
+
claudeSessionSummary,
|
|
24
|
+
codexSessionSummary,
|
|
25
|
+
buildLocalRecommendations,
|
|
26
|
+
} from './lib.mjs'
|
|
13
27
|
|
|
14
28
|
const HOME = homedir()
|
|
15
29
|
const MM_DIR = join(HOME, '.modelmeter')
|
|
@@ -47,12 +61,17 @@ function printHelp() {
|
|
|
47
61
|
|
|
48
62
|
Usage:
|
|
49
63
|
npx modelmeter-collect init <token> [--url <ingest-url>]
|
|
64
|
+
npx modelmeter-collect doctor [--payload]
|
|
50
65
|
npx modelmeter-collect scan local logs and report
|
|
51
66
|
npx modelmeter-collect --help
|
|
52
67
|
|
|
53
68
|
Commands:
|
|
54
69
|
init Save your ingest token to ~/.modelmeter/config.json (chmod 600).
|
|
55
70
|
Pass the token as an argument or via MODELMETER_TOKEN.
|
|
71
|
+
doctor Check your setup: which logs were found, last activity, config
|
|
72
|
+
status, and exactly what would be sent. Add --recommendations for
|
|
73
|
+
local optimization tips (cache, MCP, output, context bloat), or
|
|
74
|
+
--payload for the raw JSON (token counts only, never transcript text).
|
|
56
75
|
(none) Scan Claude Code + Codex logs and report token counts. Deduped,
|
|
57
76
|
so it is safe to run repeatedly. MODELMETER_DRYRUN=1 previews only.
|
|
58
77
|
|
|
@@ -93,5 +112,142 @@ if (cmd === 'init' || cmd === 'setup') {
|
|
|
93
112
|
process.exit(0)
|
|
94
113
|
}
|
|
95
114
|
|
|
115
|
+
// Count .jsonl session files (recent + newest mtime) under a logs directory.
|
|
116
|
+
function discoverLogs(dir, cutoffMs) {
|
|
117
|
+
try {
|
|
118
|
+
statSync(dir)
|
|
119
|
+
} catch {
|
|
120
|
+
return { dir, found: false }
|
|
121
|
+
}
|
|
122
|
+
let recentCount = 0
|
|
123
|
+
let lastWriteMs = 0
|
|
124
|
+
const stack = [dir]
|
|
125
|
+
while (stack.length) {
|
|
126
|
+
const d = stack.pop()
|
|
127
|
+
let entries = []
|
|
128
|
+
try {
|
|
129
|
+
entries = readdirSync(d, { withFileTypes: true })
|
|
130
|
+
} catch {
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
for (const e of entries) {
|
|
134
|
+
const p = join(d, e.name)
|
|
135
|
+
if (e.isDirectory()) stack.push(p)
|
|
136
|
+
else if (e.isFile() && p.endsWith('.jsonl')) {
|
|
137
|
+
let m = 0
|
|
138
|
+
try {
|
|
139
|
+
m = statSync(p).mtimeMs
|
|
140
|
+
} catch {
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
if (m > lastWriteMs) lastWriteMs = m
|
|
144
|
+
if (m >= cutoffMs) recentCount++
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { dir, found: true, recentCount, lastWriteMs }
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Recent .jsonl session file paths under a logs directory, newest first, capped.
|
|
152
|
+
function recentSessionFiles(dir, cutoffMs, limit) {
|
|
153
|
+
try {
|
|
154
|
+
statSync(dir)
|
|
155
|
+
} catch {
|
|
156
|
+
return []
|
|
157
|
+
}
|
|
158
|
+
const out = []
|
|
159
|
+
const stack = [dir]
|
|
160
|
+
while (stack.length) {
|
|
161
|
+
const d = stack.pop()
|
|
162
|
+
let entries = []
|
|
163
|
+
try {
|
|
164
|
+
entries = readdirSync(d, { withFileTypes: true })
|
|
165
|
+
} catch {
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
for (const e of entries) {
|
|
169
|
+
const p = join(d, e.name)
|
|
170
|
+
if (e.isDirectory()) stack.push(p)
|
|
171
|
+
else if (e.isFile() && p.endsWith('.jsonl')) {
|
|
172
|
+
let m = 0
|
|
173
|
+
try {
|
|
174
|
+
m = statSync(p).mtimeMs
|
|
175
|
+
} catch {
|
|
176
|
+
continue
|
|
177
|
+
}
|
|
178
|
+
if (m >= cutoffMs) out.push({ p, m })
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return out
|
|
183
|
+
.sort((a, b) => b.m - a.m)
|
|
184
|
+
.slice(0, limit)
|
|
185
|
+
.map((x) => x.p)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function printRecommendations(cutoffMs) {
|
|
189
|
+
const summaries = []
|
|
190
|
+
const sources = [
|
|
191
|
+
[join(HOME, '.claude', 'projects'), claudeSessionSummary],
|
|
192
|
+
[join(HOME, '.codex', 'sessions'), codexSessionSummary],
|
|
193
|
+
]
|
|
194
|
+
for (const [dir, summarize] of sources) {
|
|
195
|
+
for (const file of recentSessionFiles(dir, cutoffMs, 200)) {
|
|
196
|
+
let text = ''
|
|
197
|
+
try {
|
|
198
|
+
text = readFileSync(file, 'utf8')
|
|
199
|
+
} catch {
|
|
200
|
+
continue
|
|
201
|
+
}
|
|
202
|
+
const summary = summarize(text)
|
|
203
|
+
if (summary) summaries.push(summary)
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
console.log('\nLocal recommendations (computed from your logs, nothing sent):')
|
|
207
|
+
const recs = buildLocalRecommendations(summaries)
|
|
208
|
+
if (recs.length === 0) {
|
|
209
|
+
console.log(' Nothing flagged. Your recent usage looks efficient.')
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
for (const r of recs) {
|
|
213
|
+
const mark = r.level === 'warn' ? '!' : r.level === 'ok' ? '+' : '-'
|
|
214
|
+
console.log(` ${mark} ${r.text}`)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (cmd === 'doctor') {
|
|
219
|
+
const cfg = readConfig()
|
|
220
|
+
const lookbackDays = 14
|
|
221
|
+
const nowMs = Date.now()
|
|
222
|
+
const cutoffMs = nowMs - lookbackDays * 86_400_000
|
|
223
|
+
console.log(
|
|
224
|
+
formatDoctorReport({
|
|
225
|
+
configPath: CONFIG_PATH,
|
|
226
|
+
configFound: existsSync(CONFIG_PATH),
|
|
227
|
+
token: process.env.MODELMETER_TOKEN || cfg.token,
|
|
228
|
+
ingestUrl: process.env.MODELMETER_INGEST_URL || cfg.ingestUrl,
|
|
229
|
+
lookbackDays,
|
|
230
|
+
nowMs,
|
|
231
|
+
claude: discoverLogs(join(HOME, '.claude', 'projects'), cutoffMs),
|
|
232
|
+
codex: discoverLogs(join(HOME, '.codex', 'sessions'), cutoffMs),
|
|
233
|
+
}),
|
|
234
|
+
)
|
|
235
|
+
if (args.includes('--recommendations')) {
|
|
236
|
+
printRecommendations(cutoffMs)
|
|
237
|
+
process.exit(0)
|
|
238
|
+
}
|
|
239
|
+
if (args.includes('--payload')) {
|
|
240
|
+
console.log('\nNext batch (dry run, nothing is sent):')
|
|
241
|
+
process.env.MODELMETER_DRYRUN = '1'
|
|
242
|
+
await runCollector() // prints the exact payload (counts only), then exits
|
|
243
|
+
} else {
|
|
244
|
+
console.log(
|
|
245
|
+
'\nRun `npx modelmeter-collect doctor --recommendations` for local optimization tips, or',
|
|
246
|
+
)
|
|
247
|
+
console.log('`doctor --payload` to preview the exact JSON that would be sent.')
|
|
248
|
+
process.exit(0)
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
96
252
|
// Default: scan and report.
|
|
97
253
|
await runCollector()
|
package/collect.mjs
CHANGED
|
@@ -9,15 +9,50 @@
|
|
|
9
9
|
//
|
|
10
10
|
// Config: MODELMETER_TOKEN + MODELMETER_INGEST_URL from env, or ~/.modelmeter/config.json
|
|
11
11
|
// { "token": "mm_live_...", "ingestUrl": "https://<ref>.supabase.co/functions/v1/ingest" }
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
readFileSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
mkdirSync,
|
|
16
|
+
existsSync,
|
|
17
|
+
statSync,
|
|
18
|
+
readdirSync,
|
|
19
|
+
chmodSync,
|
|
20
|
+
} from 'node:fs'
|
|
13
21
|
import { homedir } from 'node:os'
|
|
14
22
|
import { join } from 'node:path'
|
|
23
|
+
import {
|
|
24
|
+
findLastTokenCount,
|
|
25
|
+
codexToolFromEvent,
|
|
26
|
+
deriveHourlyUrl,
|
|
27
|
+
claudeEventFromLine,
|
|
28
|
+
codexDelta,
|
|
29
|
+
aggregateDaily,
|
|
30
|
+
aggregateHourly,
|
|
31
|
+
aggregateTools,
|
|
32
|
+
pruneClaudeState,
|
|
33
|
+
mergeDetailBatches,
|
|
34
|
+
} from './lib.mjs'
|
|
15
35
|
|
|
16
36
|
const HOME = homedir()
|
|
17
37
|
const MM_DIR = join(HOME, '.modelmeter')
|
|
18
38
|
const STATE_PATH = join(MM_DIR, 'collector-state.json')
|
|
19
39
|
const CONFIG_PATH = join(MM_DIR, 'config.json')
|
|
20
|
-
|
|
40
|
+
// Clamp the lookback to a sane range so a bad env var cannot scan nothing
|
|
41
|
+
// (negative) or traverse months of logs (huge).
|
|
42
|
+
const RAW_LOOKBACK = Number(process.env.MODELMETER_LOOKBACK_DAYS)
|
|
43
|
+
const LOOKBACK_DAYS =
|
|
44
|
+
Number.isFinite(RAW_LOOKBACK) && RAW_LOOKBACK > 0 ? Math.min(RAW_LOOKBACK, 90) : 14
|
|
45
|
+
if (
|
|
46
|
+
process.env.MODELMETER_LOOKBACK_DAYS !== undefined &&
|
|
47
|
+
(!Number.isFinite(RAW_LOOKBACK) || RAW_LOOKBACK <= 0 || RAW_LOOKBACK > 90)
|
|
48
|
+
) {
|
|
49
|
+
console.error(`modelmeter: MODELMETER_LOOKBACK_DAYS out of range, using ${LOOKBACK_DAYS}`)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const FETCH_TIMEOUT_MS = 8000
|
|
53
|
+
// Cap the per-message dedup set so the state file cannot grow without bound. Older
|
|
54
|
+
// entries fall out of the lookback window, so dropping them is safe.
|
|
55
|
+
const CLAUDE_STATE_CAP = 200_000
|
|
21
56
|
|
|
22
57
|
let cfg = {}
|
|
23
58
|
try {
|
|
@@ -29,13 +64,46 @@ const TOKEN = process.env.MODELMETER_TOKEN || cfg.token
|
|
|
29
64
|
const INGEST_URL = process.env.MODELMETER_INGEST_URL || cfg.ingestUrl
|
|
30
65
|
if (!TOKEN || !INGEST_URL) process.exit(0) // not configured: do nothing, never block
|
|
31
66
|
|
|
32
|
-
|
|
67
|
+
// POST JSON with a hard timeout so a stuck network path can never hang a Stop
|
|
68
|
+
// hook or pile up scheduled collectors. Callers handle the thrown abort/error.
|
|
69
|
+
async function postJson(url, body) {
|
|
70
|
+
const controller = new AbortController()
|
|
71
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
|
72
|
+
try {
|
|
73
|
+
return await fetch(url, {
|
|
74
|
+
method: 'POST',
|
|
75
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
76
|
+
body: JSON.stringify(body),
|
|
77
|
+
signal: controller.signal,
|
|
78
|
+
})
|
|
79
|
+
} finally {
|
|
80
|
+
clearTimeout(timer)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
let state = { claude: {}, codex: {}, pendingDetail: { hours: [], tools: [] } }
|
|
33
85
|
try {
|
|
34
|
-
state = {
|
|
86
|
+
state = {
|
|
87
|
+
claude: {},
|
|
88
|
+
codex: {},
|
|
89
|
+
pendingDetail: { hours: [], tools: [] },
|
|
90
|
+
...JSON.parse(readFileSync(STATE_PATH, 'utf8')),
|
|
91
|
+
}
|
|
35
92
|
} catch {
|
|
36
93
|
// first run
|
|
37
94
|
}
|
|
38
95
|
|
|
96
|
+
function saveState() {
|
|
97
|
+
if (!existsSync(MM_DIR)) mkdirSync(MM_DIR, { recursive: true })
|
|
98
|
+
state.claude = pruneClaudeState(state.claude, CLAUDE_STATE_CAP)
|
|
99
|
+
writeFileSync(STATE_PATH, JSON.stringify(state))
|
|
100
|
+
try {
|
|
101
|
+
chmodSync(STATE_PATH, 0o600) // usage metadata is not secret, but keep it owner-only
|
|
102
|
+
} catch {
|
|
103
|
+
// best effort on platforms without POSIX perms
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
39
107
|
// --- invocation detection -------------------------------------------------
|
|
40
108
|
let hookInput = null
|
|
41
109
|
if (!process.stdin.isTTY) {
|
|
@@ -107,59 +175,15 @@ function scanClaude(files) {
|
|
|
107
175
|
} catch {
|
|
108
176
|
continue
|
|
109
177
|
}
|
|
110
|
-
const
|
|
111
|
-
if (!
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
state.claude[id] = 1
|
|
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
|
-
: []
|
|
119
|
-
events.push({
|
|
120
|
-
provider: 'anthropic',
|
|
121
|
-
model: msg.model || 'claude-unknown',
|
|
122
|
-
occurredOn: (o.timestamp || '').slice(0, 10) || undefined,
|
|
123
|
-
occurredAt: o.timestamp || undefined,
|
|
124
|
-
tools: toolNames,
|
|
125
|
-
uncachedInputTokens: u.input_tokens || 0,
|
|
126
|
-
cacheReadInputTokens: u.cache_read_input_tokens || 0,
|
|
127
|
-
cacheCreationInputTokens: u.cache_creation_input_tokens || 0,
|
|
128
|
-
outputTokens: u.output_tokens || 0,
|
|
129
|
-
numRequests: 1,
|
|
130
|
-
})
|
|
178
|
+
const ev = claudeEventFromLine(o)
|
|
179
|
+
if (!ev || !ev.id || state.claude[ev.id]) continue
|
|
180
|
+
state.claude[ev.id] = { ts: ev.occurredAt || ev.occurredOn || '' }
|
|
181
|
+
events.push(ev)
|
|
131
182
|
}
|
|
132
183
|
}
|
|
133
184
|
}
|
|
134
185
|
|
|
135
186
|
// --- Codex: cumulative token_count events; report per-session delta.
|
|
136
|
-
function findLastTokenCount(obj) {
|
|
137
|
-
let last = null
|
|
138
|
-
const stack = [obj]
|
|
139
|
-
while (stack.length) {
|
|
140
|
-
const d = stack.pop()
|
|
141
|
-
if (Array.isArray(d)) stack.push(...d)
|
|
142
|
-
else if (d && typeof d === 'object') {
|
|
143
|
-
if (d.type === 'token_count' && d.info?.total_token_usage) last = d.info.total_token_usage
|
|
144
|
-
for (const v of Object.values(d)) stack.push(v)
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
return last
|
|
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
|
-
|
|
163
187
|
function scanCodex(files) {
|
|
164
188
|
for (const file of files) {
|
|
165
189
|
const m = file.match(/rollout-(\d{4}-\d{2}-\d{2})T[\d-]+-([0-9a-f-]+)\.jsonl$/)
|
|
@@ -198,49 +222,14 @@ function scanCodex(files) {
|
|
|
198
222
|
const g = codexToolFromEvent(p, ptype)
|
|
199
223
|
if (g) toolCalls.push({ ts: ts || totalsTs || '', group: g })
|
|
200
224
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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)
|
|
217
|
-
if (dInput + dCached + dOut + dReason > 0) {
|
|
218
|
-
const occurredOn = (totalsTs || '').slice(0, 10) || m[1]
|
|
219
|
-
events.push({
|
|
220
|
-
provider: 'openai',
|
|
221
|
-
model,
|
|
222
|
-
occurredOn,
|
|
223
|
-
occurredAt: totalsTs || undefined, // enables the 5-hour window for Codex
|
|
224
|
-
uncachedInputTokens: Math.max(0, dInput - dCached),
|
|
225
|
-
cacheReadInputTokens: dCached,
|
|
226
|
-
cacheCreationInputTokens: 0,
|
|
227
|
-
outputTokens: dOut + dReason, // reasoning tokens bill as output
|
|
228
|
-
numRequests: 1,
|
|
229
|
-
})
|
|
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 }
|
|
225
|
+
const { event, toolDeltas, state: nextState } = codexDelta(
|
|
226
|
+
{ totals, totalsTs, maxTs, model, fileDate: m[1], toolCalls },
|
|
227
|
+
state.codex[sessionId],
|
|
228
|
+
)
|
|
229
|
+
if (event) {
|
|
230
|
+
events.push(event)
|
|
231
|
+
for (const d of toolDeltas) codexToolDeltas.push(d)
|
|
232
|
+
state.codex[sessionId] = nextState
|
|
244
233
|
}
|
|
245
234
|
}
|
|
246
235
|
}
|
|
@@ -255,108 +244,22 @@ if (hookInput?.transcript_path) {
|
|
|
255
244
|
}
|
|
256
245
|
|
|
257
246
|
// Collapse to one row per (provider, model, day) so the request stays small.
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
const date = e.occurredOn || new Date().toISOString().slice(0, 10)
|
|
261
|
-
const key = `${e.provider}|${e.model}|${date}`
|
|
262
|
-
const cur = byKey.get(key) || {
|
|
263
|
-
provider: e.provider,
|
|
264
|
-
model: e.model,
|
|
265
|
-
occurredOn: date,
|
|
266
|
-
uncachedInputTokens: 0,
|
|
267
|
-
cacheReadInputTokens: 0,
|
|
268
|
-
cacheCreationInputTokens: 0,
|
|
269
|
-
outputTokens: 0,
|
|
270
|
-
numRequests: 0,
|
|
271
|
-
}
|
|
272
|
-
cur.uncachedInputTokens += e.uncachedInputTokens || 0
|
|
273
|
-
cur.cacheReadInputTokens += e.cacheReadInputTokens || 0
|
|
274
|
-
cur.cacheCreationInputTokens += e.cacheCreationInputTokens || 0
|
|
275
|
-
cur.outputTokens += e.outputTokens || 0
|
|
276
|
-
cur.numRequests += e.numRequests || 1
|
|
277
|
-
byKey.set(key, cur)
|
|
278
|
-
}
|
|
279
|
-
const payload = [...byKey.values()]
|
|
247
|
+
const today = new Date().toISOString().slice(0, 10)
|
|
248
|
+
const payload = aggregateDaily(events, today)
|
|
280
249
|
|
|
281
|
-
// Recent hourly buckets feed the 5-hour rolling
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
|
|
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')
|
|
250
|
+
// Recent hourly buckets (last 8h, timestamped events only) feed the 5-hour rolling
|
|
251
|
+
// window via a separate, additive endpoint, so a backfill cannot pollute it.
|
|
252
|
+
const hourly = aggregateHourly(events, Date.now())
|
|
253
|
+
const HOURLY_URL = deriveHourlyUrl(INGEST_URL, process.env.MODELMETER_HOURLY_INGEST_URL)
|
|
315
254
|
|
|
316
|
-
// Per-tool / per-MCP attribution.
|
|
317
|
-
//
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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()]
|
|
255
|
+
// Per-tool / per-MCP attribution. Claude even-splits each turn's tokens across the
|
|
256
|
+
// tools it called; Codex contributes precomputed deltas. Calls are exact.
|
|
257
|
+
const toolsPayload = aggregateTools(events, codexToolDeltas, today)
|
|
258
|
+
const currentDetail = { hours: hourly, tools: toolsPayload }
|
|
259
|
+
const detailBatch = mergeDetailBatches(state.pendingDetail, currentDetail)
|
|
260
|
+
const hasDetail = detailBatch.hours.length > 0 || detailBatch.tools.length > 0
|
|
358
261
|
|
|
359
|
-
if (payload.length === 0) {
|
|
262
|
+
if (payload.length === 0 && !hasDetail) {
|
|
360
263
|
process.exit(0)
|
|
361
264
|
}
|
|
362
265
|
|
|
@@ -365,41 +268,56 @@ if (process.env.MODELMETER_DRYRUN) {
|
|
|
365
268
|
for (const e of events) tally[e.provider] = (tally[e.provider] || 0) + 1
|
|
366
269
|
console.log(`DRY RUN: ${events.length} raw events -> ${payload.length} daily rows`, tally)
|
|
367
270
|
console.log(` + ${hourly.length} recent hourly rows, ${toolsPayload.length} tool rows -> ${HOURLY_URL}`)
|
|
271
|
+
if (state.pendingDetail?.hours?.length || state.pendingDetail?.tools?.length) {
|
|
272
|
+
console.log(
|
|
273
|
+
` + pending retry rows: ${state.pendingDetail.hours?.length || 0} hourly, ${state.pendingDetail.tools?.length || 0} tool`,
|
|
274
|
+
)
|
|
275
|
+
}
|
|
368
276
|
console.log(JSON.stringify(payload, null, 2))
|
|
369
277
|
if (toolsPayload.length) console.log('tools:', JSON.stringify(toolsPayload, null, 2))
|
|
370
278
|
process.exit(0)
|
|
371
279
|
}
|
|
372
280
|
|
|
373
281
|
let committed = false
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
console.error(`modelmeter: ingest returned ${res.status}`)
|
|
282
|
+
if (payload.length > 0) {
|
|
283
|
+
try {
|
|
284
|
+
const res = await postJson(INGEST_URL, { source: 'collector', events: payload })
|
|
285
|
+
if (res.ok) {
|
|
286
|
+
saveState()
|
|
287
|
+
committed = true
|
|
288
|
+
console.error(`modelmeter: reported ${payload.length} usage rows`)
|
|
289
|
+
} else {
|
|
290
|
+
console.error(`modelmeter: ingest returned ${res.status}`)
|
|
291
|
+
}
|
|
292
|
+
} catch (err) {
|
|
293
|
+
console.error(`modelmeter: ${err.message}`)
|
|
387
294
|
}
|
|
388
|
-
}
|
|
389
|
-
|
|
295
|
+
} else {
|
|
296
|
+
committed = true // retrying previously committed detail rows
|
|
390
297
|
}
|
|
391
298
|
|
|
392
|
-
// Additive
|
|
393
|
-
//
|
|
394
|
-
if (committed &&
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
299
|
+
// Additive detail rows are sent only after daily usage is committed. If the detail
|
|
300
|
+
// endpoint fails, keep the merged batch in state and retry on the next run.
|
|
301
|
+
if (committed && hasDetail) {
|
|
302
|
+
if (!HOURLY_URL) {
|
|
303
|
+
state.pendingDetail = detailBatch
|
|
304
|
+
saveState()
|
|
305
|
+
console.error('modelmeter: detail ingest URL could not be derived; set MODELMETER_HOURLY_INGEST_URL')
|
|
306
|
+
process.exit(0)
|
|
307
|
+
}
|
|
308
|
+
let detailSent = false
|
|
309
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
310
|
+
try {
|
|
311
|
+
const res = await postJson(HOURLY_URL, { source: 'collector', ...detailBatch })
|
|
312
|
+
if (res.ok) {
|
|
313
|
+
detailSent = true
|
|
314
|
+
break
|
|
315
|
+
}
|
|
316
|
+
} catch {
|
|
317
|
+
// fall through to one retry, then give up
|
|
318
|
+
}
|
|
403
319
|
}
|
|
320
|
+
state.pendingDetail = detailSent ? { hours: [], tools: [] } : detailBatch
|
|
321
|
+
saveState()
|
|
404
322
|
}
|
|
405
323
|
process.exit(0)
|
package/lib.mjs
ADDED
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
// Pure, unit-tested core of the collector. All file I/O, networking, and state
|
|
2
|
+
// persistence live in collect.mjs; everything here is deterministic given its
|
|
3
|
+
// inputs, so it can be fixture-tested (see lib.test.mjs).
|
|
4
|
+
|
|
5
|
+
// Group a Claude tool name: MCP tools (mcp__server__tool) collapse to mcp:server,
|
|
6
|
+
// built-ins keep their name.
|
|
7
|
+
export function toolGroup(name) {
|
|
8
|
+
if (typeof name !== 'string' || !name) return 'unknown'
|
|
9
|
+
if (name.startsWith('mcp__')) {
|
|
10
|
+
const parts = name.split('__')
|
|
11
|
+
return parts[1] ? `mcp:${parts[1]}` : 'mcp:unknown'
|
|
12
|
+
}
|
|
13
|
+
return name
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Group a Codex tool event. Built-in calls carry a plain name; MCP calls carry an
|
|
17
|
+
// invocation with a server. Returns a group key, or null if it is not a tool call.
|
|
18
|
+
export function codexToolFromEvent(payload, ptype) {
|
|
19
|
+
if (ptype === 'function_call' || ptype === 'custom_tool_call') {
|
|
20
|
+
return typeof payload.name === 'string' && payload.name ? payload.name : null
|
|
21
|
+
}
|
|
22
|
+
if (ptype === 'mcp_tool_call_end' || ptype === 'mcp_tool_call_begin') {
|
|
23
|
+
const inv = payload.invocation || {}
|
|
24
|
+
const server = inv.server || inv.server_name
|
|
25
|
+
return server ? `mcp:${server}` : 'mcp'
|
|
26
|
+
}
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Depth-first find of the last token_count usage block in a Codex line.
|
|
31
|
+
export function findLastTokenCount(obj) {
|
|
32
|
+
let last = null
|
|
33
|
+
const stack = [obj]
|
|
34
|
+
while (stack.length) {
|
|
35
|
+
const d = stack.pop()
|
|
36
|
+
if (Array.isArray(d)) stack.push(...d)
|
|
37
|
+
else if (d && typeof d === 'object') {
|
|
38
|
+
if (d.type === 'token_count' && d.info?.total_token_usage) last = d.info.total_token_usage
|
|
39
|
+
for (const v of Object.values(d)) stack.push(v)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return last
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Detail endpoint from the ingest URL, tolerating a trailing slash, or an override.
|
|
46
|
+
export function deriveHourlyUrl(ingestUrl, override) {
|
|
47
|
+
if (override) return override
|
|
48
|
+
try {
|
|
49
|
+
const u = new URL(ingestUrl)
|
|
50
|
+
u.pathname = u.pathname.replace(/\/ingest\/?$/, '/ingest-hourly')
|
|
51
|
+
return u.toString()
|
|
52
|
+
} catch {
|
|
53
|
+
return ingestUrl.replace(/\/ingest\/?$/, '/ingest-hourly')
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Build an event from one parsed Claude transcript line, or null if it is not an
|
|
58
|
+
// assistant message with usage. Carries an `id` for the caller to dedup on.
|
|
59
|
+
export function claudeEventFromLine(o) {
|
|
60
|
+
const msg = o && o.message
|
|
61
|
+
if (!msg || msg.role !== 'assistant' || !msg.usage) return null
|
|
62
|
+
const u = msg.usage
|
|
63
|
+
const tools = Array.isArray(msg.content)
|
|
64
|
+
? msg.content
|
|
65
|
+
.filter((b) => b && b.type === 'tool_use')
|
|
66
|
+
.map((b) => b.name)
|
|
67
|
+
.filter(Boolean)
|
|
68
|
+
: []
|
|
69
|
+
return {
|
|
70
|
+
id: o.uuid || `${o.timestamp ?? ''}:${msg.id ?? ''}`,
|
|
71
|
+
provider: 'anthropic',
|
|
72
|
+
model: msg.model || 'claude-unknown',
|
|
73
|
+
occurredOn: (o.timestamp || '').slice(0, 10) || undefined,
|
|
74
|
+
occurredAt: o.timestamp || undefined,
|
|
75
|
+
tools,
|
|
76
|
+
uncachedInputTokens: u.input_tokens || 0,
|
|
77
|
+
cacheReadInputTokens: u.cache_read_input_tokens || 0,
|
|
78
|
+
cacheCreationInputTokens: u.cache_creation_input_tokens || 0,
|
|
79
|
+
outputTokens: u.output_tokens || 0,
|
|
80
|
+
numRequests: 1,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Codex cumulative-delta + tool attribution. Given what a session walk collected and
|
|
85
|
+
// the previous session state, returns the new event (or null), the tool deltas, and
|
|
86
|
+
// the next state. Tool calls are only attributed up to the token-accounted watermark
|
|
87
|
+
// (totalsTs), so calls after the latest token_count are left for the next run.
|
|
88
|
+
export function codexDelta({ totals, totalsTs, maxTs, model, fileDate, toolCalls }, prev) {
|
|
89
|
+
if (!totals) return { event: null, toolDeltas: [], state: prev ?? null }
|
|
90
|
+
const prevTotal =
|
|
91
|
+
(prev && prev.total) ||
|
|
92
|
+
(prev && prev.input_tokens != null ? prev : null) || {
|
|
93
|
+
input_tokens: 0,
|
|
94
|
+
cached_input_tokens: 0,
|
|
95
|
+
output_tokens: 0,
|
|
96
|
+
reasoning_output_tokens: 0,
|
|
97
|
+
}
|
|
98
|
+
const prevLastTs = (prev && prev.lastTs) || ''
|
|
99
|
+
const dInput = Math.max(0, (totals.input_tokens || 0) - prevTotal.input_tokens)
|
|
100
|
+
const dCached = Math.max(0, (totals.cached_input_tokens || 0) - prevTotal.cached_input_tokens)
|
|
101
|
+
const dOut = Math.max(0, (totals.output_tokens || 0) - prevTotal.output_tokens)
|
|
102
|
+
const dReason = Math.max(0, (totals.reasoning_output_tokens || 0) - prevTotal.reasoning_output_tokens)
|
|
103
|
+
if (dInput + dCached + dOut + dReason <= 0) {
|
|
104
|
+
return { event: null, toolDeltas: [], state: prev ?? null }
|
|
105
|
+
}
|
|
106
|
+
const newTotalTokens = Math.max(0, dInput - dCached) + dCached + (dOut + dReason)
|
|
107
|
+
const occurredOn = (totalsTs || '').slice(0, 10) || fileDate
|
|
108
|
+
const event = {
|
|
109
|
+
provider: 'openai',
|
|
110
|
+
model: model || 'gpt-5',
|
|
111
|
+
occurredOn,
|
|
112
|
+
occurredAt: totalsTs || undefined,
|
|
113
|
+
uncachedInputTokens: Math.max(0, dInput - dCached),
|
|
114
|
+
cacheReadInputTokens: dCached,
|
|
115
|
+
cacheCreationInputTokens: 0,
|
|
116
|
+
outputTokens: dOut + dReason,
|
|
117
|
+
numRequests: 1,
|
|
118
|
+
}
|
|
119
|
+
// Tool calls are deduped by the maxTs watermark: each is counted exactly once, in
|
|
120
|
+
// the run where it is first seen, and never recounted. Calls are exact. The
|
|
121
|
+
// even-split token figure is an estimate; a tail call's tokens can land in a later
|
|
122
|
+
// run's batch, but no call is ever dropped.
|
|
123
|
+
const newCalls = (toolCalls || []).filter((c) => c.ts && c.ts > prevLastTs)
|
|
124
|
+
const toolDeltas = []
|
|
125
|
+
if (newCalls.length > 0 && newTotalTokens > 0) {
|
|
126
|
+
const share = Math.round(newTotalTokens / newCalls.length)
|
|
127
|
+
for (const c of newCalls) {
|
|
128
|
+
toolDeltas.push({
|
|
129
|
+
tool: c.group,
|
|
130
|
+
bucketDate: (c.ts || '').slice(0, 10) || occurredOn,
|
|
131
|
+
calls: 1,
|
|
132
|
+
tokens: share,
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return { event, toolDeltas, state: { total: totals, lastTs: maxTs || totalsTs || '' } }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Show enough of the token to recognize it, never the secret part.
|
|
140
|
+
export function maskToken(token) {
|
|
141
|
+
if (!token) return '(not set)'
|
|
142
|
+
return token.length > 12 ? `${token.slice(0, 12)}...` : token
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function relAgo(ms, nowMs) {
|
|
146
|
+
if (!ms) return 'never'
|
|
147
|
+
const s = Math.max(0, Math.round((nowMs - ms) / 1000))
|
|
148
|
+
if (s < 60) return `${s}s ago`
|
|
149
|
+
if (s < 3600) return `${Math.round(s / 60)}m ago`
|
|
150
|
+
if (s < 86_400) return `${Math.round(s / 3600)}h ago`
|
|
151
|
+
return `${Math.round(s / 86_400)}d ago`
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Render the `doctor` report from gathered facts (pure, so it is unit-tested).
|
|
155
|
+
// info: { configPath, configFound, token, ingestUrl, lookbackDays, nowMs,
|
|
156
|
+
// claude/codex: { dir, found, recentCount, lastWriteMs } }
|
|
157
|
+
export function formatDoctorReport(info) {
|
|
158
|
+
const lines = ['modelmeter-collect doctor', '']
|
|
159
|
+
lines.push(`Config ${info.configPath}`)
|
|
160
|
+
lines.push(` status: ${info.configFound ? 'found' : 'not found'}`)
|
|
161
|
+
lines.push(` token: ${maskToken(info.token)}`)
|
|
162
|
+
lines.push(` ingest URL: ${info.ingestUrl || '(not set)'}`)
|
|
163
|
+
lines.push('')
|
|
164
|
+
for (const [label, d] of [
|
|
165
|
+
['Claude Code', info.claude],
|
|
166
|
+
['Codex', info.codex],
|
|
167
|
+
]) {
|
|
168
|
+
lines.push(`${label} ${d.dir}`)
|
|
169
|
+
if (!d.found) {
|
|
170
|
+
lines.push(' logs: not found')
|
|
171
|
+
} else {
|
|
172
|
+
const n = d.recentCount
|
|
173
|
+
lines.push(` logs: found, ${n} session file${n === 1 ? '' : 's'} in the last ${info.lookbackDays} days`)
|
|
174
|
+
lines.push(` last write: ${relAgo(d.lastWriteMs, info.nowMs)}`)
|
|
175
|
+
}
|
|
176
|
+
lines.push('')
|
|
177
|
+
}
|
|
178
|
+
lines.push('Privacy')
|
|
179
|
+
lines.push(' sent: model names, token counts, tool and MCP names, dates')
|
|
180
|
+
lines.push(' never sent: prompts, responses, file contents, API keys')
|
|
181
|
+
return lines.join('\n')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Collapse events to one daily row per (provider, model, date).
|
|
185
|
+
export function aggregateDaily(events, today) {
|
|
186
|
+
const byKey = new Map()
|
|
187
|
+
for (const e of events) {
|
|
188
|
+
const date = e.occurredOn || today
|
|
189
|
+
const key = `${e.provider}|${e.model}|${date}`
|
|
190
|
+
const cur = byKey.get(key) || {
|
|
191
|
+
provider: e.provider,
|
|
192
|
+
model: e.model,
|
|
193
|
+
occurredOn: date,
|
|
194
|
+
uncachedInputTokens: 0,
|
|
195
|
+
cacheReadInputTokens: 0,
|
|
196
|
+
cacheCreationInputTokens: 0,
|
|
197
|
+
outputTokens: 0,
|
|
198
|
+
numRequests: 0,
|
|
199
|
+
}
|
|
200
|
+
cur.uncachedInputTokens += e.uncachedInputTokens || 0
|
|
201
|
+
cur.cacheReadInputTokens += e.cacheReadInputTokens || 0
|
|
202
|
+
cur.cacheCreationInputTokens += e.cacheCreationInputTokens || 0
|
|
203
|
+
cur.outputTokens += e.outputTokens || 0
|
|
204
|
+
cur.numRequests += e.numRequests || 1
|
|
205
|
+
byKey.set(key, cur)
|
|
206
|
+
}
|
|
207
|
+
return [...byKey.values()]
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Recent hourly buckets for the 5-hour window. Only events with a real timestamp in
|
|
211
|
+
// the lookback window qualify, so a backfill cannot pollute the recent window.
|
|
212
|
+
export function aggregateHourly(events, nowMs, lookbackMs = 8 * 3_600_000) {
|
|
213
|
+
const cutoff = nowMs - lookbackMs
|
|
214
|
+
const byHour = new Map()
|
|
215
|
+
for (const e of events) {
|
|
216
|
+
if (!e.occurredAt) continue
|
|
217
|
+
const t = new Date(e.occurredAt).getTime()
|
|
218
|
+
if (Number.isNaN(t) || t < cutoff) continue
|
|
219
|
+
const d = new Date(t)
|
|
220
|
+
d.setMinutes(0, 0, 0)
|
|
221
|
+
const hourIso = d.toISOString()
|
|
222
|
+
const key = `${e.provider}|${e.model}|${hourIso}`
|
|
223
|
+
const cur = byHour.get(key) || {
|
|
224
|
+
provider: e.provider,
|
|
225
|
+
model: e.model,
|
|
226
|
+
bucketHour: hourIso,
|
|
227
|
+
uncachedInputTokens: 0,
|
|
228
|
+
cacheReadInputTokens: 0,
|
|
229
|
+
cacheCreationInputTokens: 0,
|
|
230
|
+
outputTokens: 0,
|
|
231
|
+
numRequests: 0,
|
|
232
|
+
}
|
|
233
|
+
cur.uncachedInputTokens += e.uncachedInputTokens || 0
|
|
234
|
+
cur.cacheReadInputTokens += e.cacheReadInputTokens || 0
|
|
235
|
+
cur.cacheCreationInputTokens += e.cacheCreationInputTokens || 0
|
|
236
|
+
cur.outputTokens += e.outputTokens || 0
|
|
237
|
+
cur.numRequests += e.numRequests || 1
|
|
238
|
+
byHour.set(key, cur)
|
|
239
|
+
}
|
|
240
|
+
return [...byHour.values()]
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Per-tool / MCP rows. Claude events carry a `tools` array (even-split tokens across
|
|
244
|
+
// the distinct groups a turn called); Codex contributes precomputed tool deltas.
|
|
245
|
+
export function aggregateTools(events, codexToolDeltas = [], today) {
|
|
246
|
+
const byTool = new Map()
|
|
247
|
+
const add = (tool, date, calls, tokens) => {
|
|
248
|
+
const key = `${tool}|${date}`
|
|
249
|
+
const cur = byTool.get(key) || { tool, bucketDate: date, calls: 0, tokens: 0 }
|
|
250
|
+
cur.calls += calls
|
|
251
|
+
cur.tokens += tokens
|
|
252
|
+
byTool.set(key, cur)
|
|
253
|
+
}
|
|
254
|
+
for (const e of events) {
|
|
255
|
+
if (!Array.isArray(e.tools) || e.tools.length === 0) continue
|
|
256
|
+
const date = e.occurredOn || today
|
|
257
|
+
const callsByGroup = new Map()
|
|
258
|
+
for (const name of e.tools) {
|
|
259
|
+
const g = toolGroup(name)
|
|
260
|
+
callsByGroup.set(g, (callsByGroup.get(g) || 0) + 1)
|
|
261
|
+
}
|
|
262
|
+
const eventTokens =
|
|
263
|
+
(e.uncachedInputTokens || 0) +
|
|
264
|
+
(e.cacheReadInputTokens || 0) +
|
|
265
|
+
(e.cacheCreationInputTokens || 0) +
|
|
266
|
+
(e.outputTokens || 0)
|
|
267
|
+
const tokenShare = Math.round(eventTokens / callsByGroup.size)
|
|
268
|
+
for (const [g, calls] of callsByGroup) add(g, date, calls, tokenShare)
|
|
269
|
+
}
|
|
270
|
+
for (const d of codexToolDeltas) add(d.tool, d.bucketDate, d.calls, d.tokens)
|
|
271
|
+
return [...byTool.values()]
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// --- Local recommendations: session summaries + scoring for `doctor
|
|
275
|
+
// --recommendations`. Computed entirely from local logs, no network. ---
|
|
276
|
+
|
|
277
|
+
function pct(x) {
|
|
278
|
+
return `${Math.round(x * 100)}%`
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function fmtTok(value) {
|
|
282
|
+
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(1)}B`
|
|
283
|
+
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
|
|
284
|
+
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`
|
|
285
|
+
return String(Math.round(value))
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Summarize one Claude session file into per-session totals, the per-turn context
|
|
289
|
+
// size sequence (for bloat detection), and tool token attribution.
|
|
290
|
+
export function claudeSessionSummary(text) {
|
|
291
|
+
let model = 'claude-unknown'
|
|
292
|
+
let uncached = 0
|
|
293
|
+
let cacheRead = 0
|
|
294
|
+
let cacheCreate = 0
|
|
295
|
+
let output = 0
|
|
296
|
+
let requests = 0
|
|
297
|
+
const contextSeq = []
|
|
298
|
+
const tools = {}
|
|
299
|
+
for (const line of String(text).split('\n')) {
|
|
300
|
+
if (!line.trim()) continue
|
|
301
|
+
let o
|
|
302
|
+
try {
|
|
303
|
+
o = JSON.parse(line)
|
|
304
|
+
} catch {
|
|
305
|
+
continue
|
|
306
|
+
}
|
|
307
|
+
const ev = claudeEventFromLine(o)
|
|
308
|
+
if (!ev) continue
|
|
309
|
+
model = ev.model || model
|
|
310
|
+
uncached += ev.uncachedInputTokens
|
|
311
|
+
cacheRead += ev.cacheReadInputTokens
|
|
312
|
+
cacheCreate += ev.cacheCreationInputTokens
|
|
313
|
+
output += ev.outputTokens
|
|
314
|
+
requests += 1
|
|
315
|
+
contextSeq.push(ev.uncachedInputTokens + ev.cacheReadInputTokens + ev.cacheCreationInputTokens)
|
|
316
|
+
const turnTokens =
|
|
317
|
+
ev.uncachedInputTokens + ev.cacheReadInputTokens + ev.cacheCreationInputTokens + ev.outputTokens
|
|
318
|
+
const groups = new Map()
|
|
319
|
+
for (const name of ev.tools) {
|
|
320
|
+
const g = toolGroup(name)
|
|
321
|
+
groups.set(g, (groups.get(g) || 0) + 1)
|
|
322
|
+
}
|
|
323
|
+
const share = groups.size > 0 ? Math.round(turnTokens / groups.size) : 0
|
|
324
|
+
for (const [g, calls] of groups) {
|
|
325
|
+
const cur = tools[g] || { tokens: 0, calls: 0 }
|
|
326
|
+
cur.tokens += share
|
|
327
|
+
cur.calls += calls
|
|
328
|
+
tools[g] = cur
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (requests === 0) return null
|
|
332
|
+
return { provider: 'anthropic', model, uncached, cacheRead, cacheCreate, output, requests, contextSeq, tools }
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Summarize one Codex session from its final cumulative token_count. Codex totals are
|
|
336
|
+
// cumulative, so there is no reliable per-turn context sequence (bloat is Claude-only).
|
|
337
|
+
export function codexSessionSummary(text) {
|
|
338
|
+
let model = 'gpt-5'
|
|
339
|
+
let totals = null
|
|
340
|
+
let requests = 0
|
|
341
|
+
const tools = {}
|
|
342
|
+
for (const line of String(text).split('\n')) {
|
|
343
|
+
if (!line.trim()) continue
|
|
344
|
+
let o
|
|
345
|
+
try {
|
|
346
|
+
o = JSON.parse(line)
|
|
347
|
+
} catch {
|
|
348
|
+
continue
|
|
349
|
+
}
|
|
350
|
+
const p = o.payload || o
|
|
351
|
+
const ptype = p.type || o.type
|
|
352
|
+
if (typeof o.model === 'string') model = o.model
|
|
353
|
+
else if (typeof p.model === 'string') model = p.model
|
|
354
|
+
const tc = findLastTokenCount(o)
|
|
355
|
+
if (tc) {
|
|
356
|
+
totals = tc
|
|
357
|
+
requests += 1
|
|
358
|
+
}
|
|
359
|
+
const g = codexToolFromEvent(p, ptype)
|
|
360
|
+
if (g) {
|
|
361
|
+
const cur = tools[g] || { tokens: 0, calls: 0 }
|
|
362
|
+
cur.calls += 1
|
|
363
|
+
tools[g] = cur
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (!totals) return null
|
|
367
|
+
const cacheRead = totals.cached_input_tokens || 0
|
|
368
|
+
const uncached = Math.max(0, (totals.input_tokens || 0) - cacheRead)
|
|
369
|
+
const output = (totals.output_tokens || 0) + (totals.reasoning_output_tokens || 0)
|
|
370
|
+
return {
|
|
371
|
+
provider: 'openai',
|
|
372
|
+
model,
|
|
373
|
+
uncached,
|
|
374
|
+
cacheRead,
|
|
375
|
+
cacheCreate: 0,
|
|
376
|
+
output,
|
|
377
|
+
requests: Math.max(1, requests),
|
|
378
|
+
contextSeq: [],
|
|
379
|
+
tools,
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// First-5 vs last-5 average turn size + max, for context-bloat detection.
|
|
384
|
+
export function sessionBloat(seq) {
|
|
385
|
+
if (!Array.isArray(seq) || seq.length < 10) return null
|
|
386
|
+
const avg = (arr) => Math.round(arr.reduce((a, b) => a + b, 0) / arr.length)
|
|
387
|
+
return { first5: avg(seq.slice(0, 5)), last5: avg(seq.slice(-5)), max: Math.max(...seq) }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Turn session summaries into local recommendations. Pure, so the whole engine is
|
|
391
|
+
// fixture-tested. Returns [{ kind, level, text }] in cache -> mcp -> output -> bloat order.
|
|
392
|
+
export function buildLocalRecommendations(summaries) {
|
|
393
|
+
const recs = []
|
|
394
|
+
const list = (summaries || []).filter(Boolean)
|
|
395
|
+
if (list.length === 0) return recs
|
|
396
|
+
|
|
397
|
+
let uncached = 0
|
|
398
|
+
let cacheRead = 0
|
|
399
|
+
let cacheCreate = 0
|
|
400
|
+
let output = 0
|
|
401
|
+
let requests = 0
|
|
402
|
+
const tools = {}
|
|
403
|
+
for (const s of list) {
|
|
404
|
+
uncached += s.uncached
|
|
405
|
+
cacheRead += s.cacheRead
|
|
406
|
+
cacheCreate += s.cacheCreate
|
|
407
|
+
output += s.output
|
|
408
|
+
requests += s.requests
|
|
409
|
+
for (const [g, v] of Object.entries(s.tools || {})) {
|
|
410
|
+
const cur = tools[g] || { tokens: 0, calls: 0 }
|
|
411
|
+
cur.tokens += v.tokens
|
|
412
|
+
cur.calls += v.calls
|
|
413
|
+
tools[g] = cur
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
const inputTotal = uncached + cacheRead + cacheCreate
|
|
417
|
+
const total = inputTotal + output
|
|
418
|
+
|
|
419
|
+
// 1. Cache effectiveness.
|
|
420
|
+
if (inputTotal > 0) {
|
|
421
|
+
const readRatio = cacheRead / inputTotal
|
|
422
|
+
const createRatio = cacheCreate / inputTotal
|
|
423
|
+
const uncachedRatio = uncached / inputTotal
|
|
424
|
+
if (createRatio > 0.3 && readRatio < createRatio) {
|
|
425
|
+
recs.push({
|
|
426
|
+
kind: 'cache',
|
|
427
|
+
level: 'warn',
|
|
428
|
+
text: `High cache creation, low reuse: ${pct(createRatio)} of input is cache writes vs ${pct(
|
|
429
|
+
readRatio,
|
|
430
|
+
)} reads. Keep your prompt prefix byte-for-byte stable so it gets reused.`,
|
|
431
|
+
})
|
|
432
|
+
} else if (uncachedRatio > 0.5) {
|
|
433
|
+
recs.push({
|
|
434
|
+
kind: 'cache',
|
|
435
|
+
level: 'warn',
|
|
436
|
+
text: `Repeated uncached context: ${pct(
|
|
437
|
+
uncachedRatio,
|
|
438
|
+
)} of input pays full price. Move stable content (system prompt, tools, examples) into a cached prefix.`,
|
|
439
|
+
})
|
|
440
|
+
} else if (readRatio >= 0.6) {
|
|
441
|
+
recs.push({
|
|
442
|
+
kind: 'cache',
|
|
443
|
+
level: 'ok',
|
|
444
|
+
text: `Good cache reuse: ${pct(readRatio)} of input is cached reads.`,
|
|
445
|
+
})
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// 2. MCP / tool ranking.
|
|
450
|
+
const toolArr = Object.entries(tools)
|
|
451
|
+
.map(([tool, v]) => ({ tool, ...v }))
|
|
452
|
+
.sort((a, b) => b.tokens - a.tokens)
|
|
453
|
+
const toolTotal = toolArr.reduce((n, t) => n + t.tokens, 0)
|
|
454
|
+
const topMcp = toolArr.find((t) => t.tool.startsWith('mcp:'))
|
|
455
|
+
if (topMcp && toolTotal > 0 && topMcp.tokens / toolTotal >= 0.25) {
|
|
456
|
+
recs.push({
|
|
457
|
+
kind: 'mcp',
|
|
458
|
+
level: 'warn',
|
|
459
|
+
text: `${topMcp.tool} is ${pct(
|
|
460
|
+
topMcp.tokens / toolTotal,
|
|
461
|
+
)} of tool-attributed usage. Disable it when you are not actively using it.`,
|
|
462
|
+
})
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// 3. Output verbosity.
|
|
466
|
+
if (total > 0 && output / total > 0.4) {
|
|
467
|
+
recs.push({
|
|
468
|
+
kind: 'output',
|
|
469
|
+
level: 'warn',
|
|
470
|
+
text: `Output is ${pct(
|
|
471
|
+
output / total,
|
|
472
|
+
)} of usage. Ask for patch-only responses or short summaries; output is the priciest token tier.`,
|
|
473
|
+
})
|
|
474
|
+
} else if (requests > 0 && Math.round(output / requests) > 5000) {
|
|
475
|
+
recs.push({
|
|
476
|
+
kind: 'output',
|
|
477
|
+
level: 'info',
|
|
478
|
+
text: `Responses average ${fmtTok(
|
|
479
|
+
Math.round(output / requests),
|
|
480
|
+
)} output tokens. A max_tokens cap or terser prompt trims the priciest tier.`,
|
|
481
|
+
})
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// 4. Context bloat (Claude sessions carry a per-turn sequence).
|
|
485
|
+
let worst = null
|
|
486
|
+
for (const s of list) {
|
|
487
|
+
const b = sessionBloat(s.contextSeq)
|
|
488
|
+
if (b && b.last5 > b.first5 * 2 && b.last5 > 30_000 && (!worst || b.last5 > worst.last5)) {
|
|
489
|
+
worst = b
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (worst) {
|
|
493
|
+
recs.push({
|
|
494
|
+
kind: 'bloat',
|
|
495
|
+
level: 'warn',
|
|
496
|
+
text: `A recent session's context grew from ${fmtTok(worst.first5)} to ${fmtTok(
|
|
497
|
+
worst.last5,
|
|
498
|
+
)} tokens per turn. Start a fresh session or ask the model to summarize state.`,
|
|
499
|
+
})
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return recs
|
|
503
|
+
}
|
package/lib.test.mjs
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
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
|
+
})
|
package/package.json
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "modelmeter-collect",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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
|
+
},
|
|
9
12
|
"files": [
|
|
10
13
|
"cli.mjs",
|
|
11
14
|
"collect.mjs",
|
|
15
|
+
"lib.mjs",
|
|
16
|
+
"lib.test.mjs",
|
|
12
17
|
"README.md"
|
|
13
18
|
],
|
|
14
19
|
"engines": {
|