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.
- package/README.md +78 -0
- package/cli.mjs +97 -0
- package/collect.mjs +256 -0
- 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
|
+
}
|