modelmeter-collect 0.1.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 (4) hide show
  1. package/README.md +78 -0
  2. package/cli.mjs +97 -0
  3. package/collect.mjs +256 -0
  4. package/package.json +32 -0
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # modelmeter-collect
2
+
3
+ Report LLM token usage to [ModelMeter](https://modelmeter.dev) from the session logs
4
+ that **subscription** coding tools already write to your disk. No API key, no billing
5
+ access. It sends only model names and token counts. **Never your prompts, never your keys.**
6
+
7
+ - **Claude Code** — `~/.claude/projects/**/*.jsonl` (per-message `usage`)
8
+ - **Codex** — `~/.codex/sessions/**/*.jsonl` (`token_count` events)
9
+
10
+ It dedupes (state in `~/.modelmeter/collector-state.json`), so every run is safe to repeat.
11
+
12
+ Not covered: the **ChatGPT** consumer app (no per-message token data exists) and **Cursor**
13
+ on a Pro plan (usage stays on Cursor's servers).
14
+
15
+ ## Quick start
16
+
17
+ ```bash
18
+ # 1. Grab an ingest token from the Providers tab at https://modelmeter.dev, then:
19
+ npx modelmeter-collect init mm_live_xxxxxxxx
20
+
21
+ # 2. Backfill the last couple of weeks:
22
+ npx modelmeter-collect
23
+
24
+ # Preview what would be sent, without sending:
25
+ MODELMETER_DRYRUN=1 npx modelmeter-collect
26
+ ```
27
+
28
+ `init` writes `~/.modelmeter/config.json` (chmod 600) with your token and the ingest URL.
29
+ Prefer env vars? Set `MODELMETER_TOKEN` and `MODELMETER_INGEST_URL` and skip `init`.
30
+
31
+ ## Keep it live (per prompt)
32
+
33
+ **Claude Code** — add a `Stop` hook (fires after every response). It passes the session
34
+ transcript on stdin, so the collector reads just that one file:
35
+
36
+ ```json
37
+ { "hooks": { "Stop": [ { "hooks": [ { "type": "command",
38
+ "command": "npx -y modelmeter-collect" } ] } ] } }
39
+ ```
40
+
41
+ **Codex** — its single `notify` slot is often already used, so the scheduled job below
42
+ picks up Codex within ~a minute. If your `notify` is free:
43
+ `notify = ["npx", "-y", "modelmeter-collect"]`.
44
+
45
+ ## Scheduled backstop (macOS launchd, every 60s)
46
+
47
+ Catches Codex and anything a hook missed. Light scan (`MODELMETER_LOOKBACK_DAYS=1`); it
48
+ no-ops when there is nothing new. Save as `~/Library/LaunchAgents/dev.modelmeter.collector.plist`
49
+ and `launchctl load -w` it. Use an absolute `npx` path (`which npx`):
50
+
51
+ ```xml
52
+ <?xml version="1.0" encoding="UTF-8"?>
53
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
54
+ <plist version="1.0"><dict>
55
+ <key>Label</key><string>dev.modelmeter.collector</string>
56
+ <key>ProgramArguments</key>
57
+ <array><string>/opt/homebrew/bin/npx</string><string>-y</string><string>modelmeter-collect</string></array>
58
+ <key>EnvironmentVariables</key><dict><key>MODELMETER_LOOKBACK_DAYS</key><string>1</string></dict>
59
+ <key>StartInterval</key><integer>60</integer>
60
+ <key>RunAtLoad</key><true/>
61
+ <key>StandardErrorPath</key><string>/Users/you/.modelmeter/collector.log</string>
62
+ </dict></plist>
63
+ ```
64
+
65
+ (Linux: a 60s `systemd` timer or a `* * * * *` cron line calling `npx -y modelmeter-collect`
66
+ does the same job.)
67
+
68
+ ## Configuration
69
+
70
+ | Env var | Default | Meaning |
71
+ | --- | --- | --- |
72
+ | `MODELMETER_TOKEN` | from config file | Your `mm_live_...` ingest token |
73
+ | `MODELMETER_INGEST_URL` | from config file | The ingest endpoint |
74
+ | `MODELMETER_LOOKBACK_DAYS` | `14` | How many days of logs to scan |
75
+ | `MODELMETER_DRYRUN` | unset | When set, print the payload instead of sending |
76
+
77
+ Requires Node 18+ (built-in `fetch`). The token lives only in `~/.modelmeter/config.json`,
78
+ never in the hook command or the plist.
package/cli.mjs ADDED
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ // modelmeter-collect: configure and run the ModelMeter local usage collector.
3
+ // Reports only model names + token counts from local Claude Code / Codex logs.
4
+ // Never prompts, never keys.
5
+ //
6
+ // npx modelmeter-collect init <mm_live_token> # one-time: save the token
7
+ // npx modelmeter-collect # scan local logs and report
8
+ // MODELMETER_DRYRUN=1 npx modelmeter-collect # preview without sending
9
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync } from 'node:fs'
10
+ import { homedir } from 'node:os'
11
+ import { join, dirname } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+
14
+ const HOME = homedir()
15
+ const MM_DIR = join(HOME, '.modelmeter')
16
+ const CONFIG_PATH = join(MM_DIR, 'config.json')
17
+ const DEFAULT_INGEST_URL = 'https://tqgmrixeadjkggybordj.supabase.co/functions/v1/ingest'
18
+
19
+ const args = process.argv.slice(2)
20
+ const cmd = args[0]
21
+
22
+ function readConfig() {
23
+ try {
24
+ return JSON.parse(readFileSync(CONFIG_PATH, 'utf8'))
25
+ } catch {
26
+ return {}
27
+ }
28
+ }
29
+
30
+ function writeConfig(cfg) {
31
+ if (!existsSync(MM_DIR)) mkdirSync(MM_DIR, { recursive: true })
32
+ writeFileSync(CONFIG_PATH, `${JSON.stringify(cfg, null, 2)}\n`)
33
+ try {
34
+ chmodSync(CONFIG_PATH, 0o600)
35
+ } catch {
36
+ // best effort: platforms without POSIX perms still get the file
37
+ }
38
+ }
39
+
40
+ function flag(name) {
41
+ const i = args.indexOf(name)
42
+ return i >= 0 ? args[i + 1] : undefined
43
+ }
44
+
45
+ function printHelp() {
46
+ console.log(`modelmeter-collect — report local LLM token usage to ModelMeter
47
+
48
+ Usage:
49
+ npx modelmeter-collect init <token> [--url <ingest-url>]
50
+ npx modelmeter-collect scan local logs and report
51
+ npx modelmeter-collect --help
52
+
53
+ Commands:
54
+ init Save your ingest token to ~/.modelmeter/config.json (chmod 600).
55
+ Pass the token as an argument or via MODELMETER_TOKEN.
56
+ (none) Scan Claude Code + Codex logs and report token counts. Deduped,
57
+ so it is safe to run repeatedly. MODELMETER_DRYRUN=1 previews only.
58
+
59
+ It sends only model names and token counts. Never your prompts, never your keys.
60
+ Get an ingest token from the Providers tab at https://modelmeter.dev`)
61
+ }
62
+
63
+ async function runCollector() {
64
+ // Delegate to the collector, which reads ~/.modelmeter/config.json or env.
65
+ const here = dirname(fileURLToPath(import.meta.url))
66
+ await import(join(here, 'collect.mjs'))
67
+ }
68
+
69
+ if (cmd === 'help' || args.includes('--help') || args.includes('-h')) {
70
+ printHelp()
71
+ process.exit(0)
72
+ }
73
+
74
+ if (cmd === 'init' || cmd === 'setup') {
75
+ const positional = args[1] && !args[1].startsWith('-') ? args[1] : undefined
76
+ const token = positional || process.env.MODELMETER_TOKEN
77
+ const url = flag('--url') || flag('--ingest-url') || readConfig().ingestUrl || DEFAULT_INGEST_URL
78
+ if (!token) {
79
+ console.error('No token provided. Run: npx modelmeter-collect init <mm_live_token>')
80
+ console.error('Get one from the Providers tab at https://modelmeter.dev')
81
+ process.exit(1)
82
+ }
83
+ if (!token.startsWith('mm_live_')) {
84
+ console.error('That does not look like a ModelMeter ingest token (expected mm_live_...).')
85
+ process.exit(1)
86
+ }
87
+ writeConfig({ token, ingestUrl: url })
88
+ console.log(`Saved ${CONFIG_PATH} (chmod 600).`)
89
+ console.log('')
90
+ console.log('Next:')
91
+ console.log(' 1. Backfill now: npx modelmeter-collect')
92
+ console.log(' 2. Keep it live: see the hook + scheduled-job snippets in the README.')
93
+ process.exit(0)
94
+ }
95
+
96
+ // Default: scan and report.
97
+ await runCollector()
package/collect.mjs ADDED
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env node
2
+ // ModelMeter local usage collector for SUBSCRIPTION coding tools (no API key).
3
+ // Reports token counts (never prompts/keys) from local logs to ModelMeter.
4
+ //
5
+ // Invocation modes (all safe to combine; dedup prevents double counting):
6
+ // - Claude Code Stop/SessionEnd hook: JSON on stdin with transcript_path -> that file only (fast)
7
+ // - Codex notify: JSON event as last argv -> newest Codex session only (fast)
8
+ // - scheduled / manual (no stdin, no arg): full 14-day scan of both tools
9
+ //
10
+ // Config: MODELMETER_TOKEN + MODELMETER_INGEST_URL from env, or ~/.modelmeter/config.json
11
+ // { "token": "mm_live_...", "ingestUrl": "https://<ref>.supabase.co/functions/v1/ingest" }
12
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, statSync, readdirSync } from 'node:fs'
13
+ import { homedir } from 'node:os'
14
+ import { join } from 'node:path'
15
+
16
+ const HOME = homedir()
17
+ const MM_DIR = join(HOME, '.modelmeter')
18
+ const STATE_PATH = join(MM_DIR, 'collector-state.json')
19
+ const CONFIG_PATH = join(MM_DIR, 'config.json')
20
+ const LOOKBACK_DAYS = Number(process.env.MODELMETER_LOOKBACK_DAYS) || 14
21
+
22
+ let cfg = {}
23
+ try {
24
+ cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'))
25
+ } catch {
26
+ cfg = {}
27
+ }
28
+ const TOKEN = process.env.MODELMETER_TOKEN || cfg.token
29
+ const INGEST_URL = process.env.MODELMETER_INGEST_URL || cfg.ingestUrl
30
+ if (!TOKEN || !INGEST_URL) process.exit(0) // not configured: do nothing, never block
31
+
32
+ let state = { claude: {}, codex: {} }
33
+ try {
34
+ state = { claude: {}, codex: {}, ...JSON.parse(readFileSync(STATE_PATH, 'utf8')) }
35
+ } catch {
36
+ // first run
37
+ }
38
+
39
+ // --- invocation detection -------------------------------------------------
40
+ let hookInput = null
41
+ if (!process.stdin.isTTY) {
42
+ try {
43
+ const s = readFileSync(0, 'utf8')
44
+ if (s.trim()) hookInput = JSON.parse(s)
45
+ } catch {
46
+ hookInput = null
47
+ }
48
+ }
49
+ let codexNotify = null
50
+ const lastArg = process.argv[process.argv.length - 1]
51
+ if (lastArg && lastArg.trim().startsWith('{')) {
52
+ try {
53
+ codexNotify = JSON.parse(lastArg)
54
+ } catch {
55
+ codexNotify = null
56
+ }
57
+ }
58
+
59
+ // --- file discovery -------------------------------------------------------
60
+ const cutoff = Date.now() - LOOKBACK_DAYS * 86_400_000
61
+ function* walk(dir) {
62
+ let entries = []
63
+ try {
64
+ entries = readdirSync(dir, { withFileTypes: true })
65
+ } catch {
66
+ return
67
+ }
68
+ for (const e of entries) {
69
+ const p = join(dir, e.name)
70
+ if (e.isDirectory()) yield* walk(p)
71
+ else if (e.isFile() && p.endsWith('.jsonl')) yield p
72
+ }
73
+ }
74
+ function recentFiles(dir, limit = Infinity) {
75
+ const out = []
76
+ for (const p of walk(dir)) {
77
+ try {
78
+ const m = statSync(p).mtimeMs
79
+ if (m >= cutoff) out.push([m, p])
80
+ } catch {
81
+ // ignore
82
+ }
83
+ }
84
+ out.sort((a, b) => b[0] - a[0])
85
+ return out.slice(0, limit).map(([, p]) => p)
86
+ }
87
+
88
+ const events = []
89
+
90
+ // --- Claude Code: assistant turns carry message.usage; dedup by message uuid.
91
+ function scanClaude(files) {
92
+ for (const file of files) {
93
+ let text = ''
94
+ try {
95
+ text = readFileSync(file, 'utf8')
96
+ } catch {
97
+ continue
98
+ }
99
+ for (const line of text.split('\n')) {
100
+ if (!line.trim()) continue
101
+ let o
102
+ try {
103
+ o = JSON.parse(line)
104
+ } catch {
105
+ continue
106
+ }
107
+ const msg = o.message
108
+ if (!msg || msg.role !== 'assistant' || !msg.usage) continue
109
+ const id = o.uuid || `${o.timestamp ?? ''}:${msg.id ?? ''}`
110
+ if (!id || state.claude[id]) continue
111
+ state.claude[id] = 1
112
+ const u = msg.usage
113
+ events.push({
114
+ provider: 'anthropic',
115
+ model: msg.model || 'claude-unknown',
116
+ occurredOn: (o.timestamp || '').slice(0, 10) || undefined,
117
+ uncachedInputTokens: u.input_tokens || 0,
118
+ cacheReadInputTokens: u.cache_read_input_tokens || 0,
119
+ cacheCreationInputTokens: u.cache_creation_input_tokens || 0,
120
+ outputTokens: u.output_tokens || 0,
121
+ numRequests: 1,
122
+ })
123
+ }
124
+ }
125
+ }
126
+
127
+ // --- Codex: cumulative token_count events; report per-session delta.
128
+ function findLastTokenCount(obj) {
129
+ let last = null
130
+ const stack = [obj]
131
+ while (stack.length) {
132
+ const d = stack.pop()
133
+ if (Array.isArray(d)) stack.push(...d)
134
+ else if (d && typeof d === 'object') {
135
+ if (d.type === 'token_count' && d.info?.total_token_usage) last = d.info.total_token_usage
136
+ for (const v of Object.values(d)) stack.push(v)
137
+ }
138
+ }
139
+ return last
140
+ }
141
+ function scanCodex(files) {
142
+ for (const file of files) {
143
+ const m = file.match(/rollout-(\d{4}-\d{2}-\d{2})T[\d-]+-([0-9a-f-]+)\.jsonl$/)
144
+ if (!m) continue
145
+ const date = m[1]
146
+ const sessionId = m[2]
147
+ let totals = null
148
+ let model = 'gpt-5'
149
+ let text = ''
150
+ try {
151
+ text = readFileSync(file, 'utf8')
152
+ } catch {
153
+ continue
154
+ }
155
+ for (const line of text.split('\n')) {
156
+ if (!line.trim()) continue
157
+ let o
158
+ try {
159
+ o = JSON.parse(line)
160
+ } catch {
161
+ continue
162
+ }
163
+ const t = findLastTokenCount(o)
164
+ if (t) totals = t
165
+ if (typeof o.model === 'string') model = o.model
166
+ else if (typeof o.payload?.model === 'string') model = o.payload.model
167
+ }
168
+ if (!totals) continue
169
+ const prev = state.codex[sessionId] || {
170
+ input_tokens: 0,
171
+ cached_input_tokens: 0,
172
+ output_tokens: 0,
173
+ reasoning_output_tokens: 0,
174
+ }
175
+ const dInput = Math.max(0, (totals.input_tokens || 0) - prev.input_tokens)
176
+ const dCached = Math.max(0, (totals.cached_input_tokens || 0) - prev.cached_input_tokens)
177
+ const dOut = Math.max(0, (totals.output_tokens || 0) - prev.output_tokens)
178
+ const dReason = Math.max(0, (totals.reasoning_output_tokens || 0) - prev.reasoning_output_tokens)
179
+ if (dInput + dCached + dOut + dReason > 0) {
180
+ events.push({
181
+ provider: 'openai',
182
+ model,
183
+ occurredOn: date,
184
+ uncachedInputTokens: Math.max(0, dInput - dCached),
185
+ cacheReadInputTokens: dCached,
186
+ cacheCreationInputTokens: 0,
187
+ outputTokens: dOut + dReason, // reasoning tokens bill as output
188
+ numRequests: 1,
189
+ })
190
+ state.codex[sessionId] = totals
191
+ }
192
+ }
193
+ }
194
+
195
+ if (hookInput?.transcript_path) {
196
+ scanClaude([hookInput.transcript_path]) // Claude Code hook: just this session
197
+ } else if (codexNotify) {
198
+ scanCodex(recentFiles(join(HOME, '.codex', 'sessions'), 2)) // Codex notify: newest session(s)
199
+ } else {
200
+ scanClaude(recentFiles(join(HOME, '.claude', 'projects')))
201
+ scanCodex(recentFiles(join(HOME, '.codex', 'sessions')))
202
+ }
203
+
204
+ // Collapse to one row per (provider, model, day) so the request stays small.
205
+ const byKey = new Map()
206
+ for (const e of events) {
207
+ const date = e.occurredOn || new Date().toISOString().slice(0, 10)
208
+ const key = `${e.provider}|${e.model}|${date}`
209
+ const cur = byKey.get(key) || {
210
+ provider: e.provider,
211
+ model: e.model,
212
+ occurredOn: date,
213
+ uncachedInputTokens: 0,
214
+ cacheReadInputTokens: 0,
215
+ cacheCreationInputTokens: 0,
216
+ outputTokens: 0,
217
+ numRequests: 0,
218
+ }
219
+ cur.uncachedInputTokens += e.uncachedInputTokens || 0
220
+ cur.cacheReadInputTokens += e.cacheReadInputTokens || 0
221
+ cur.cacheCreationInputTokens += e.cacheCreationInputTokens || 0
222
+ cur.outputTokens += e.outputTokens || 0
223
+ cur.numRequests += e.numRequests || 1
224
+ byKey.set(key, cur)
225
+ }
226
+ const payload = [...byKey.values()]
227
+
228
+ if (payload.length === 0) {
229
+ process.exit(0)
230
+ }
231
+
232
+ if (process.env.MODELMETER_DRYRUN) {
233
+ const tally = {}
234
+ for (const e of events) tally[e.provider] = (tally[e.provider] || 0) + 1
235
+ console.log(`DRY RUN: ${events.length} raw events -> ${payload.length} daily rows`, tally)
236
+ console.log(JSON.stringify(payload, null, 2))
237
+ process.exit(0)
238
+ }
239
+
240
+ try {
241
+ const res = await fetch(INGEST_URL, {
242
+ method: 'POST',
243
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
244
+ body: JSON.stringify({ source: 'collector', events: payload }),
245
+ })
246
+ if (res.ok) {
247
+ if (!existsSync(MM_DIR)) mkdirSync(MM_DIR, { recursive: true })
248
+ writeFileSync(STATE_PATH, JSON.stringify(state))
249
+ console.error(`modelmeter: reported ${payload.length} usage rows`)
250
+ } else {
251
+ console.error(`modelmeter: ingest returned ${res.status}`)
252
+ }
253
+ } catch (err) {
254
+ console.error(`modelmeter: ${err.message}`)
255
+ }
256
+ process.exit(0)
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "modelmeter-collect",
3
+ "version": "0.1.0",
4
+ "description": "Report LLM token usage from local Claude Code / Codex logs to ModelMeter. Token counts only, never prompts or keys.",
5
+ "type": "module",
6
+ "bin": {
7
+ "modelmeter-collect": "cli.mjs"
8
+ },
9
+ "files": [
10
+ "cli.mjs",
11
+ "collect.mjs",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=18"
16
+ },
17
+ "license": "MIT",
18
+ "keywords": [
19
+ "llm",
20
+ "tokens",
21
+ "token-usage",
22
+ "claude-code",
23
+ "codex",
24
+ "modelmeter"
25
+ ],
26
+ "homepage": "https://modelmeter.dev",
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/newtorob/modelmeter.git",
30
+ "directory": "tools/modelmeter-collect"
31
+ }
32
+ }