tokmax 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/LICENSE +21 -0
- package/README.md +96 -0
- package/bin/tokmax.mjs +282 -0
- package/package.json +36 -0
- package/src/adapters/claude-code.mjs +86 -0
- package/src/adapters/codex.mjs +113 -0
- package/src/aggregate.mjs +72 -0
- package/src/pricing.mjs +67 -0
- package/src/publish.mjs +17 -0
- package/src/secrets.mjs +39 -0
- package/src/util.mjs +46 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Eugene Shilow
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# tokmax
|
|
2
|
+
|
|
3
|
+
Считает, сколько токенов ты сжёг в **Codex** и **Claude Code** на этой машине,
|
|
4
|
+
переводит это в **API-equivalent** доллары и публикует агрегат на публичный
|
|
5
|
+
лидерборд [tokenmax.ru](https://tokenmax.ru).
|
|
6
|
+
|
|
7
|
+
Одной командой:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx tokmax <nick>
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
После публикации твой профиль виден на `https://tokenmax.ru/<nick>`.
|
|
14
|
+
|
|
15
|
+
## Что именно уходит наружу (и что — нет)
|
|
16
|
+
|
|
17
|
+
Это инструмент про доверие, поэтому граница жёсткая и проверяемая в коде
|
|
18
|
+
(`src/adapters/*.mjs`):
|
|
19
|
+
|
|
20
|
+
- **Уходит только агрегат:** числа токенов по моделям (`input`, `output`,
|
|
21
|
+
`cacheCreate`, `cacheRead`, `reasoning`), даты дней и метка машины.
|
|
22
|
+
- **Никогда не уходит:** текст промптов, содержимое файлов, вывод инструментов,
|
|
23
|
+
API-ключи, сырые строки логов. Адаптеры читают из лога ровно четыре-пять
|
|
24
|
+
числовых полей usage + id модели + timestamp и выкидывают всё остальное.
|
|
25
|
+
|
|
26
|
+
Открытый исходник — чтобы это можно было прочитать самому, а не верить на слово.
|
|
27
|
+
|
|
28
|
+
## Установка и запуск
|
|
29
|
+
|
|
30
|
+
Нужен Node.js >= 18. Зависимостей в рантайме — ноль (только встроенные модули
|
|
31
|
+
Node и глобальный `fetch`).
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# разово, без установки
|
|
35
|
+
npx tokmax <nick>
|
|
36
|
+
|
|
37
|
+
# или глобально
|
|
38
|
+
npm i -g tokmax
|
|
39
|
+
tokenmax <nick>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Опции
|
|
43
|
+
|
|
44
|
+
```text
|
|
45
|
+
tokenmax <nick> [options]
|
|
46
|
+
|
|
47
|
+
--since YYYY-MM-DD считать только с этого дня (по умолчанию — вся история)
|
|
48
|
+
--key <secret> capability-токен для обновления уже занятого ника
|
|
49
|
+
--api <baseUrl> базовый URL API
|
|
50
|
+
--machine <label> метка машины (по умолчанию hostname)
|
|
51
|
+
--dry-run показать превью и тело запроса, ничего не публиковать
|
|
52
|
+
--yes, -y не спрашивать подтверждение
|
|
53
|
+
--help, -h справка
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`--dry-run` показывает превью и точное тело запроса, которое ушло бы на сервер —
|
|
57
|
+
удобно убедиться глазами, что наружу идут только числа.
|
|
58
|
+
|
|
59
|
+
## Как считается доллар
|
|
60
|
+
|
|
61
|
+
CLI забирает прайсинг с `/api/tmx/pricing` и считает превью **той же формулой**,
|
|
62
|
+
что сервер применяет при публикации, поэтому превью == опубликованное число.
|
|
63
|
+
Стоимость = сумма по моделям от
|
|
64
|
+
`(input·r.input + output·r.output + cacheCreate·r.cacheCreate +
|
|
65
|
+
cacheRead·r.cacheRead + reasoning·r.reasoning) / 1e6`, где `r` — ставки
|
|
66
|
+
per-million для распознанной модели (или fallback для незнакомой).
|
|
67
|
+
|
|
68
|
+
Сервер пересчитывает доллар авторитетно из присланных токенов — CLI ничего не
|
|
69
|
+
может «накрутить».
|
|
70
|
+
|
|
71
|
+
## Откуда берутся числа
|
|
72
|
+
|
|
73
|
+
- **Claude Code:** `~/.claude*/projects/**/*.jsonl` — usage из assistant-сообщений
|
|
74
|
+
(`message.usage`).
|
|
75
|
+
- **Codex:** `~/.codex/sessions/**/*.jsonl` и `~/.codex/archived_sessions/*.jsonl`
|
|
76
|
+
— события `token_count` (дельты per-turn).
|
|
77
|
+
|
|
78
|
+
Каждый источник изолирован в свой адаптер (`src/adapters/`) и работает
|
|
79
|
+
защитно: битые строки и отсутствующие поля просто пропускаются (по умолчанию 0).
|
|
80
|
+
|
|
81
|
+
## Capability-токен
|
|
82
|
+
|
|
83
|
+
При первой публикации ника сервер возвращает секрет, который CLI кладёт в
|
|
84
|
+
`~/.config/tokenmax/<nick>.json` (права `0600`). Он нужен, чтобы
|
|
85
|
+
позже **обновлять** свой ник. Не теряй его; для обновления на другой машине
|
|
86
|
+
передай его через `--key <secret>`.
|
|
87
|
+
|
|
88
|
+
## Endpoint
|
|
89
|
+
|
|
90
|
+
По умолчанию CLI ходит в изолированный tokenmax-деплой
|
|
91
|
+
(`https://chatty-boar-479.convex.site`), где живут hardened-роуты публикации и
|
|
92
|
+
прайсинга. Переопределить можно флагом `--api <baseUrl>`.
|
|
93
|
+
|
|
94
|
+
## Лицензия
|
|
95
|
+
|
|
96
|
+
MIT — см. [LICENSE](LICENSE).
|
package/bin/tokmax.mjs
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
//
|
|
3
|
+
// tokenmax — scan local Codex + Claude Code logs, aggregate per-model
|
|
4
|
+
// token usage, preview the API-equivalent $, and publish the aggregate to the
|
|
5
|
+
// tokenmax leaderboard (tokenmax.ru).
|
|
6
|
+
//
|
|
7
|
+
// Happy path is one command:
|
|
8
|
+
// npx tokmax <nick>
|
|
9
|
+
//
|
|
10
|
+
// SAFETY INVARIANT: only numeric token aggregates per model + dates leave this
|
|
11
|
+
// machine. Never prompt text, file contents, API keys, or raw log lines.
|
|
12
|
+
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import readline from 'node:readline';
|
|
16
|
+
import { readFile } from 'node:fs/promises';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
import { scanClaudeCode } from '../src/adapters/claude-code.mjs';
|
|
20
|
+
import { scanCodex } from '../src/adapters/codex.mjs';
|
|
21
|
+
import { aggregate } from '../src/aggregate.mjs';
|
|
22
|
+
import { fetchPricing, previewCost } from '../src/pricing.mjs';
|
|
23
|
+
import { publish } from '../src/publish.mjs';
|
|
24
|
+
import { loadSecret, saveSecret } from '../src/secrets.mjs';
|
|
25
|
+
|
|
26
|
+
const DEFAULT_API = 'https://chatty-boar-479.convex.site';
|
|
27
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
|
|
29
|
+
function parseArgs(argv) {
|
|
30
|
+
const opts = {
|
|
31
|
+
nick: null,
|
|
32
|
+
since: null,
|
|
33
|
+
key: null,
|
|
34
|
+
api: DEFAULT_API,
|
|
35
|
+
machine: os.hostname(),
|
|
36
|
+
dryRun: false,
|
|
37
|
+
yes: false,
|
|
38
|
+
help: false,
|
|
39
|
+
};
|
|
40
|
+
const rest = [];
|
|
41
|
+
for (let i = 0; i < argv.length; i++) {
|
|
42
|
+
const a = argv[i];
|
|
43
|
+
switch (a) {
|
|
44
|
+
case '--since':
|
|
45
|
+
opts.since = argv[++i];
|
|
46
|
+
break;
|
|
47
|
+
case '--key':
|
|
48
|
+
opts.key = argv[++i];
|
|
49
|
+
break;
|
|
50
|
+
case '--api':
|
|
51
|
+
opts.api = argv[++i];
|
|
52
|
+
break;
|
|
53
|
+
case '--machine':
|
|
54
|
+
opts.machine = argv[++i];
|
|
55
|
+
break;
|
|
56
|
+
case '--dry-run':
|
|
57
|
+
opts.dryRun = true;
|
|
58
|
+
break;
|
|
59
|
+
case '--yes':
|
|
60
|
+
case '-y':
|
|
61
|
+
opts.yes = true;
|
|
62
|
+
break;
|
|
63
|
+
case '--help':
|
|
64
|
+
case '-h':
|
|
65
|
+
opts.help = true;
|
|
66
|
+
break;
|
|
67
|
+
default:
|
|
68
|
+
if (a && a.startsWith('-')) {
|
|
69
|
+
console.error(`Неизвестный флаг: ${a}`);
|
|
70
|
+
process.exit(2);
|
|
71
|
+
}
|
|
72
|
+
rest.push(a);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (!opts.nick && rest.length) opts.nick = rest[0];
|
|
76
|
+
if (opts.api) opts.api = opts.api.replace(/\/+$/, '');
|
|
77
|
+
return opts;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const HELP = `tokmax <nick> [options]
|
|
81
|
+
|
|
82
|
+
Сканирует локальные логи Codex и Claude Code, считает токены по моделям,
|
|
83
|
+
показывает API-equivalent в долларах и публикует агрегат на лидерборд
|
|
84
|
+
tokenmax.ru. Наружу уходят только числа — не логи и не ключи.
|
|
85
|
+
|
|
86
|
+
Options:
|
|
87
|
+
--since YYYY-MM-DD считать только с этого дня (по умолчанию — вся история)
|
|
88
|
+
--key <secret> capability-токен для обновления уже занятого ника
|
|
89
|
+
--api <baseUrl> базовый URL API (по умолчанию tokenmax deployment)
|
|
90
|
+
--machine <label> метка машины (по умолчанию hostname)
|
|
91
|
+
--dry-run показать превью и тело запроса, ничего не публиковать
|
|
92
|
+
--yes, -y не спрашивать подтверждение
|
|
93
|
+
--help, -h показать эту справку`;
|
|
94
|
+
|
|
95
|
+
function fmtUsd(n) {
|
|
96
|
+
return n.toLocaleString('en-US', {
|
|
97
|
+
minimumFractionDigits: 2,
|
|
98
|
+
maximumFractionDigits: 2,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function fmtInt(n) {
|
|
103
|
+
return n.toLocaleString('en-US');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function readPackageVersion() {
|
|
107
|
+
try {
|
|
108
|
+
const pkg = JSON.parse(
|
|
109
|
+
await readFile(path.join(__dirname, '..', 'package.json'), 'utf8'),
|
|
110
|
+
);
|
|
111
|
+
return pkg.version || '0.0.0';
|
|
112
|
+
} catch {
|
|
113
|
+
return '0.0.0';
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function confirm(question) {
|
|
118
|
+
return new Promise((resolve) => {
|
|
119
|
+
const rl = readline.createInterface({
|
|
120
|
+
input: process.stdin,
|
|
121
|
+
output: process.stdout,
|
|
122
|
+
});
|
|
123
|
+
rl.question(question, (answer) => {
|
|
124
|
+
rl.close();
|
|
125
|
+
resolve(/^y(es)?$/i.test(answer.trim()));
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function main() {
|
|
131
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
132
|
+
|
|
133
|
+
if (opts.help) {
|
|
134
|
+
console.log(HELP);
|
|
135
|
+
return 0;
|
|
136
|
+
}
|
|
137
|
+
if (!opts.nick) {
|
|
138
|
+
console.error('Укажи ник: npx tokmax <nick> (--help для справки)');
|
|
139
|
+
return 2;
|
|
140
|
+
}
|
|
141
|
+
if (opts.since && !/^\d{4}-\d{2}-\d{2}$/.test(opts.since)) {
|
|
142
|
+
console.error(`--since должен быть YYYY-MM-DD, получено: ${opts.since}`);
|
|
143
|
+
return 2;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const nick = opts.nick.trim();
|
|
147
|
+
const nickKey = nick.toLowerCase();
|
|
148
|
+
const cliVersion = await readPackageVersion();
|
|
149
|
+
|
|
150
|
+
console.log(`tokmax v${cliVersion} · ник: ${nick}`);
|
|
151
|
+
console.log('Сканирую локальные логи…');
|
|
152
|
+
|
|
153
|
+
const [claude, codex] = await Promise.all([scanClaudeCode(), scanCodex()]);
|
|
154
|
+
|
|
155
|
+
console.log(
|
|
156
|
+
` Claude Code: ${claude.sessionCount} сессий · Codex: ${codex.sessionCount} сессий`,
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
const agg = aggregate([claude, codex], { since: opts.since });
|
|
160
|
+
|
|
161
|
+
if (!agg.models.length || agg.totalTokens === 0) {
|
|
162
|
+
console.error(
|
|
163
|
+
'Не нашёл токенов в логах (с учётом --since). Публиковать нечего.',
|
|
164
|
+
);
|
|
165
|
+
return 1;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
let pricing;
|
|
169
|
+
try {
|
|
170
|
+
pricing = await fetchPricing(opts.api);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
console.error(`Не удалось получить прайсинг: ${err.message}`);
|
|
173
|
+
return 1;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const usd = previewCost(pricing, agg.models);
|
|
177
|
+
|
|
178
|
+
console.log(
|
|
179
|
+
`Период: ${agg.firstDay} → ${agg.lastDay} (всё, что нашлось)` +
|
|
180
|
+
(opts.since ? ` · фильтр --since ${opts.since}` : ''),
|
|
181
|
+
);
|
|
182
|
+
console.log('Модели:');
|
|
183
|
+
for (const m of agg.models) {
|
|
184
|
+
const tot = m.input + m.output + m.cacheCreate + m.cacheRead + m.reasoning;
|
|
185
|
+
console.log(` ${m.tool}/${m.model}: ${fmtInt(tot)} токенов`);
|
|
186
|
+
}
|
|
187
|
+
console.log(`Всего токенов: ${fmtInt(agg.totalTokens)}`);
|
|
188
|
+
console.log(`API-equivalent: $${fmtUsd(usd)}`);
|
|
189
|
+
console.log('Наружу уйдёт только агрегат (числа), не логи и не ключи.');
|
|
190
|
+
|
|
191
|
+
const body = {
|
|
192
|
+
nick,
|
|
193
|
+
cliVersion,
|
|
194
|
+
pricingVersion: pricing.version,
|
|
195
|
+
firstDay: agg.firstDay,
|
|
196
|
+
lastDay: agg.lastDay,
|
|
197
|
+
machineLabel: opts.machine,
|
|
198
|
+
models: agg.models,
|
|
199
|
+
daily: agg.daily,
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
if (opts.dryRun) {
|
|
203
|
+
console.log('\n--dry-run: тело запроса, которое было бы отправлено:');
|
|
204
|
+
console.log(JSON.stringify(body, null, 2));
|
|
205
|
+
console.log('\n(ничего не опубликовано)');
|
|
206
|
+
return 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Capability token: explicit --key wins, else a saved secret for this nick.
|
|
210
|
+
const savedSecret = await loadSecret(nickKey);
|
|
211
|
+
const secret = opts.key || savedSecret;
|
|
212
|
+
if (secret) body.secret = secret;
|
|
213
|
+
|
|
214
|
+
if (!opts.yes) {
|
|
215
|
+
const ok = await confirm(`Опубликовать как ${nick}? [y/N] `);
|
|
216
|
+
if (!ok) {
|
|
217
|
+
console.log('Отменено.');
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const { status, json } = await publish(opts.api, body);
|
|
223
|
+
|
|
224
|
+
if (!json) {
|
|
225
|
+
console.error(`Неожиданный ответ сервера: HTTP ${status}`);
|
|
226
|
+
return 1;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (json.ok) {
|
|
230
|
+
if (json.created && json.secret) {
|
|
231
|
+
const file = await saveSecret(nickKey, {
|
|
232
|
+
nick: json.nick || nickKey,
|
|
233
|
+
secret: json.secret,
|
|
234
|
+
url: json.url,
|
|
235
|
+
createdAt: Date.now(),
|
|
236
|
+
});
|
|
237
|
+
console.log(`\nСохранил capability-токен: ${file} (chmod 600)`);
|
|
238
|
+
console.log('Не теряй его — он нужен для будущих обновлений ника.');
|
|
239
|
+
} else {
|
|
240
|
+
console.log('\nОбновил существующий профиль.');
|
|
241
|
+
}
|
|
242
|
+
console.log(`costUsd: $${fmtUsd(json.costUsd)} · токенов: ${fmtInt(json.totalTokens)}`);
|
|
243
|
+
if (json.suspicious) {
|
|
244
|
+
console.log('⚠️ Сервер пометил сабмит как suspicious (на ручную проверку).');
|
|
245
|
+
}
|
|
246
|
+
console.log(`\n ${json.url}\n`);
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Documented error reasons.
|
|
251
|
+
switch (json.reason) {
|
|
252
|
+
case 'nick_taken':
|
|
253
|
+
console.error(`Ник занят: ${json.message || nick}`);
|
|
254
|
+
if (json.suggestion) console.error(`Попробуй: ${json.suggestion}`);
|
|
255
|
+
console.error(
|
|
256
|
+
'Если это твой ник — обнови через --key <secret> (capability-токен).',
|
|
257
|
+
);
|
|
258
|
+
return 1;
|
|
259
|
+
case 'rate_limited':
|
|
260
|
+
console.error(`Слишком часто: ${json.message || 'rate limited'}`);
|
|
261
|
+
return 1;
|
|
262
|
+
case 'nick_invalid':
|
|
263
|
+
console.error(`Недопустимый ник: ${json.message || nick}`);
|
|
264
|
+
return 1;
|
|
265
|
+
case 'empty_usage':
|
|
266
|
+
console.error('Сервер: пустой агрегат (empty_usage).');
|
|
267
|
+
return 1;
|
|
268
|
+
case 'invalid_payload':
|
|
269
|
+
console.error(`Некорректное тело запроса: ${json.message || ''}`);
|
|
270
|
+
return 1;
|
|
271
|
+
default:
|
|
272
|
+
console.error(`Ошибка публикации (HTTP ${status}): ${JSON.stringify(json)}`);
|
|
273
|
+
return 1;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
main()
|
|
278
|
+
.then((code) => process.exit(code || 0))
|
|
279
|
+
.catch((err) => {
|
|
280
|
+
console.error(`Сбой: ${err && err.stack ? err.stack : err}`);
|
|
281
|
+
process.exit(1);
|
|
282
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tokmax",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Scan your local Codex + Claude Code logs, aggregate per-model token usage, and publish the API-equivalent $ to the tokenmax leaderboard (tokenmax.ru). Aggregates only — never your prompts, files, or keys.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"tokmax": "bin/tokmax.mjs"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/eugeneshilow/tokmax.git"
|
|
16
|
+
},
|
|
17
|
+
"homepage": "https://tokenmax.ru",
|
|
18
|
+
"keywords": [
|
|
19
|
+
"codex",
|
|
20
|
+
"claude-code",
|
|
21
|
+
"tokens",
|
|
22
|
+
"usage",
|
|
23
|
+
"leaderboard",
|
|
24
|
+
"tokenmax",
|
|
25
|
+
"vibecoding"
|
|
26
|
+
],
|
|
27
|
+
"files": [
|
|
28
|
+
"bin/",
|
|
29
|
+
"src/",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"start": "node bin/tokmax.mjs"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Claude Code adapter.
|
|
2
|
+
//
|
|
3
|
+
// Reads ~/.claude*/projects/**/*.jsonl. Each line is a JSON event; assistant
|
|
4
|
+
// messages carry message.usage and message.model plus a top-level ISO
|
|
5
|
+
// timestamp. We extract ONLY numeric token counts + the model id + the day.
|
|
6
|
+
//
|
|
7
|
+
// SAFETY INVARIANT: this module never reads, stores, or transmits prompt text,
|
|
8
|
+
// tool output, file contents, or keys. It pulls four integers and a model name
|
|
9
|
+
// out of `message.usage` / `message.model` and discards everything else.
|
|
10
|
+
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
14
|
+
import { num, isoDay, walkJsonl, lines } from '../util.mjs';
|
|
15
|
+
|
|
16
|
+
/** All ~/.claude* directories that contain a projects/ tree. */
|
|
17
|
+
async function claudeProjectRoots() {
|
|
18
|
+
const home = os.homedir();
|
|
19
|
+
let entries;
|
|
20
|
+
try {
|
|
21
|
+
entries = await readdir(home, { withFileTypes: true });
|
|
22
|
+
} catch {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
const roots = [];
|
|
26
|
+
for (const e of entries) {
|
|
27
|
+
// Match the .claude directory and any sibling like .claude-foo, but not
|
|
28
|
+
// files such as .claude.json.
|
|
29
|
+
if (e.isDirectory() && e.name.startsWith('.claude')) {
|
|
30
|
+
roots.push(path.join(home, e.name, 'projects'));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return roots;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @returns {Promise<{ tool:'claude-code', sessionCount:number, records:Array }>}
|
|
38
|
+
* records: { tool, model, date, input, output, cacheCreate, cacheRead, reasoning }
|
|
39
|
+
*/
|
|
40
|
+
export async function scanClaudeCode() {
|
|
41
|
+
const records = [];
|
|
42
|
+
let sessionCount = 0;
|
|
43
|
+
|
|
44
|
+
for (const root of await claudeProjectRoots()) {
|
|
45
|
+
for await (const file of walkJsonl(root)) {
|
|
46
|
+
let text;
|
|
47
|
+
try {
|
|
48
|
+
text = await readFile(file, 'utf8');
|
|
49
|
+
} catch {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
sessionCount++;
|
|
53
|
+
for (const line of lines(text)) {
|
|
54
|
+
let d;
|
|
55
|
+
try {
|
|
56
|
+
d = JSON.parse(line);
|
|
57
|
+
} catch {
|
|
58
|
+
continue; // skip malformed lines defensively
|
|
59
|
+
}
|
|
60
|
+
if (!d || d.type !== 'assistant') continue;
|
|
61
|
+
const msg = d.message;
|
|
62
|
+
const usage = msg && msg.usage;
|
|
63
|
+
if (!usage) continue;
|
|
64
|
+
|
|
65
|
+
const model = String((msg && msg.model) || 'unknown');
|
|
66
|
+
if (model === '<synthetic>') continue; // not a real billed turn
|
|
67
|
+
|
|
68
|
+
const date = isoDay(d.timestamp);
|
|
69
|
+
if (!date) continue;
|
|
70
|
+
|
|
71
|
+
records.push({
|
|
72
|
+
tool: 'claude-code',
|
|
73
|
+
model,
|
|
74
|
+
date,
|
|
75
|
+
input: num(usage.input_tokens),
|
|
76
|
+
output: num(usage.output_tokens),
|
|
77
|
+
cacheCreate: num(usage.cache_creation_input_tokens),
|
|
78
|
+
cacheRead: num(usage.cache_read_input_tokens),
|
|
79
|
+
reasoning: 0,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { tool: 'claude-code', sessionCount, records };
|
|
86
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Codex adapter.
|
|
2
|
+
//
|
|
3
|
+
// Reads ~/.codex/sessions/**/*.jsonl and ~/.codex/archived_sessions/*.jsonl.
|
|
4
|
+
// Each line is a JSON event with { timestamp, type, payload }.
|
|
5
|
+
//
|
|
6
|
+
// Usage events look like:
|
|
7
|
+
// { type:'event_msg', payload:{ type:'token_count', info:{
|
|
8
|
+
// total_token_usage:{...cumulative...},
|
|
9
|
+
// last_token_usage:{ input_tokens, cached_input_tokens, output_tokens,
|
|
10
|
+
// reasoning_output_tokens, total_tokens } } } }
|
|
11
|
+
// `last_token_usage` is the per-turn DELTA; summing the deltas across a session
|
|
12
|
+
// equals the final `total_token_usage` (verified on real logs), so deltas give
|
|
13
|
+
// correct per-day buckets with no double counting.
|
|
14
|
+
//
|
|
15
|
+
// Codex accounting nests its buckets: input_tokens INCLUDES cached_input_tokens,
|
|
16
|
+
// and output_tokens INCLUDES reasoning_output_tokens (input + output ==
|
|
17
|
+
// total_tokens). The leaderboard server treats input/output/cacheCreate/
|
|
18
|
+
// cacheRead/reasoning as DISJOINT additive buckets, so we split them:
|
|
19
|
+
// cacheRead = cached_input_tokens
|
|
20
|
+
// input = input_tokens - cached_input_tokens
|
|
21
|
+
// reasoning = reasoning_output_tokens
|
|
22
|
+
// output = output_tokens - reasoning_output_tokens
|
|
23
|
+
// cacheCreate = 0 (Codex does not report cache creation separately)
|
|
24
|
+
//
|
|
25
|
+
// The model id is not on the token_count event; it is declared on `turn_context`
|
|
26
|
+
// (payload.model) and `session_meta`, which precede the turn's usage. We track
|
|
27
|
+
// the most recent model id and attribute usage to it.
|
|
28
|
+
//
|
|
29
|
+
// SAFETY INVARIANT: this module never reads, stores, or transmits prompt text,
|
|
30
|
+
// tool output, file contents, or keys — only token integers, the model id, and
|
|
31
|
+
// the day.
|
|
32
|
+
|
|
33
|
+
import os from 'node:os';
|
|
34
|
+
import path from 'node:path';
|
|
35
|
+
import { readFile } from 'node:fs/promises';
|
|
36
|
+
import { num, isoDay, walkJsonl, lines } from '../util.mjs';
|
|
37
|
+
|
|
38
|
+
const DEFAULT_MODEL = 'gpt-5.5';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @returns {Promise<{ tool:'codex', sessionCount:number, records:Array }>}
|
|
42
|
+
*/
|
|
43
|
+
export async function scanCodex() {
|
|
44
|
+
const home = os.homedir();
|
|
45
|
+
const dirs = [
|
|
46
|
+
path.join(home, '.codex', 'sessions'),
|
|
47
|
+
path.join(home, '.codex', 'archived_sessions'),
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
const records = [];
|
|
51
|
+
let sessionCount = 0;
|
|
52
|
+
|
|
53
|
+
for (const dir of dirs) {
|
|
54
|
+
for await (const file of walkJsonl(dir)) {
|
|
55
|
+
let text;
|
|
56
|
+
try {
|
|
57
|
+
text = await readFile(file, 'utf8');
|
|
58
|
+
} catch {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
sessionCount++;
|
|
62
|
+
|
|
63
|
+
let curModel = DEFAULT_MODEL;
|
|
64
|
+
for (const line of lines(text)) {
|
|
65
|
+
let d;
|
|
66
|
+
try {
|
|
67
|
+
d = JSON.parse(line);
|
|
68
|
+
} catch {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
const p = (d && d.payload) || {};
|
|
72
|
+
|
|
73
|
+
// Track the active model from whichever event last declared one.
|
|
74
|
+
if (typeof p.model === 'string' && p.model) {
|
|
75
|
+
curModel = p.model;
|
|
76
|
+
} else if (
|
|
77
|
+
p.collaboration_mode &&
|
|
78
|
+
p.collaboration_mode.settings &&
|
|
79
|
+
typeof p.collaboration_mode.settings.model === 'string' &&
|
|
80
|
+
p.collaboration_mode.settings.model
|
|
81
|
+
) {
|
|
82
|
+
curModel = p.collaboration_mode.settings.model;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (p.type !== 'token_count') continue;
|
|
86
|
+
const info = p.info || {};
|
|
87
|
+
const lt = info.last_token_usage;
|
|
88
|
+
if (!lt) continue;
|
|
89
|
+
|
|
90
|
+
const date = isoDay(d.timestamp);
|
|
91
|
+
if (!date) continue;
|
|
92
|
+
|
|
93
|
+
const inTok = num(lt.input_tokens);
|
|
94
|
+
const cached = num(lt.cached_input_tokens);
|
|
95
|
+
const outTok = num(lt.output_tokens);
|
|
96
|
+
const reasoning = num(lt.reasoning_output_tokens);
|
|
97
|
+
|
|
98
|
+
records.push({
|
|
99
|
+
tool: 'codex',
|
|
100
|
+
model: curModel,
|
|
101
|
+
date,
|
|
102
|
+
input: Math.max(0, inTok - cached),
|
|
103
|
+
output: Math.max(0, outTok - reasoning),
|
|
104
|
+
cacheCreate: 0,
|
|
105
|
+
cacheRead: cached,
|
|
106
|
+
reasoning,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { tool: 'codex', sessionCount, records };
|
|
113
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Merge raw adapter records into the publish payload shape:
|
|
2
|
+
// - models: one disjoint-bucket entry per (tool, model)
|
|
3
|
+
// - daily : per-day { codexTokens, claudeTokens } totals
|
|
4
|
+
// - firstDay / lastDay actually observed (after --since filtering)
|
|
5
|
+
//
|
|
6
|
+
// All inputs are numeric aggregates already — this module is pure arithmetic.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {Array<{records:Array}>} adapterResults
|
|
10
|
+
* @param {{ since?: string|null }} opts since = inclusive YYYY-MM-DD lower bound
|
|
11
|
+
*/
|
|
12
|
+
export function aggregate(adapterResults, { since = null } = {}) {
|
|
13
|
+
const modelMap = new Map(); // `${tool}|${model}` -> bucket
|
|
14
|
+
const dayMap = new Map(); // date -> { date, codexTokens, claudeTokens }
|
|
15
|
+
let firstDay = null;
|
|
16
|
+
let lastDay = null;
|
|
17
|
+
|
|
18
|
+
for (const result of adapterResults) {
|
|
19
|
+
for (const rec of result.records) {
|
|
20
|
+
if (since && rec.date < since) continue;
|
|
21
|
+
|
|
22
|
+
if (!firstDay || rec.date < firstDay) firstDay = rec.date;
|
|
23
|
+
if (!lastDay || rec.date > lastDay) lastDay = rec.date;
|
|
24
|
+
|
|
25
|
+
const key = `${rec.tool}|${rec.model}`;
|
|
26
|
+
let bucket = modelMap.get(key);
|
|
27
|
+
if (!bucket) {
|
|
28
|
+
bucket = {
|
|
29
|
+
model: rec.model,
|
|
30
|
+
tool: rec.tool,
|
|
31
|
+
input: 0,
|
|
32
|
+
output: 0,
|
|
33
|
+
cacheCreate: 0,
|
|
34
|
+
cacheRead: 0,
|
|
35
|
+
reasoning: 0,
|
|
36
|
+
};
|
|
37
|
+
modelMap.set(key, bucket);
|
|
38
|
+
}
|
|
39
|
+
bucket.input += rec.input;
|
|
40
|
+
bucket.output += rec.output;
|
|
41
|
+
bucket.cacheCreate += rec.cacheCreate;
|
|
42
|
+
bucket.cacheRead += rec.cacheRead;
|
|
43
|
+
bucket.reasoning += rec.reasoning;
|
|
44
|
+
|
|
45
|
+
const total =
|
|
46
|
+
rec.input + rec.output + rec.cacheCreate + rec.cacheRead + rec.reasoning;
|
|
47
|
+
let day = dayMap.get(rec.date);
|
|
48
|
+
if (!day) {
|
|
49
|
+
day = { date: rec.date, codexTokens: 0, claudeTokens: 0 };
|
|
50
|
+
dayMap.set(rec.date, day);
|
|
51
|
+
}
|
|
52
|
+
if (rec.tool === 'codex') day.codexTokens += total;
|
|
53
|
+
else day.claudeTokens += total;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const models = [...modelMap.values()].filter(
|
|
58
|
+
(m) =>
|
|
59
|
+
m.input + m.output + m.cacheCreate + m.cacheRead + m.reasoning > 0,
|
|
60
|
+
);
|
|
61
|
+
const daily = [...dayMap.values()].sort((a, b) =>
|
|
62
|
+
a.date < b.date ? -1 : a.date > b.date ? 1 : 0,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const totalTokens = models.reduce(
|
|
66
|
+
(s, m) =>
|
|
67
|
+
s + m.input + m.output + m.cacheCreate + m.cacheRead + m.reasoning,
|
|
68
|
+
0,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
return { models, daily, firstDay, lastDay, totalTokens };
|
|
72
|
+
}
|
package/src/pricing.mjs
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Pricing factpack: fetch the authoritative per-million rates the server uses,
|
|
2
|
+
// resolve a model id to its rates, and compute the API-equivalent $ with the
|
|
3
|
+
// exact same formula the server applies — so the CLI preview equals the
|
|
4
|
+
// published number.
|
|
5
|
+
|
|
6
|
+
/** GET <apiBase>/api/tmx/pricing */
|
|
7
|
+
export async function fetchPricing(apiBase) {
|
|
8
|
+
const res = await fetch(`${apiBase}/api/tmx/pricing`);
|
|
9
|
+
if (!res.ok) throw new Error(`pricing request failed: HTTP ${res.status}`);
|
|
10
|
+
return res.json();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve a model id to its perMillion rates.
|
|
15
|
+
* Order: exact id -> alias -> longest prefix match (family heuristic) -> fallback.
|
|
16
|
+
*/
|
|
17
|
+
export function resolveRates(pricing, model) {
|
|
18
|
+
const id = String(model || '').toLowerCase();
|
|
19
|
+
const models = pricing.models || [];
|
|
20
|
+
|
|
21
|
+
for (const m of models) {
|
|
22
|
+
if (String(m.id).toLowerCase() === id) return m.perMillion;
|
|
23
|
+
}
|
|
24
|
+
for (const m of models) {
|
|
25
|
+
if ((m.aliases || []).some((a) => String(a).toLowerCase() === id)) {
|
|
26
|
+
return m.perMillion;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
// Family heuristic: pick the model whose id/alias is the longest prefix of the
|
|
30
|
+
// requested id (e.g. "claude-opus-4-8-20990101" -> claude-opus-4-8). Longest
|
|
31
|
+
// wins so "gpt-5.5" never collapses to the shorter "gpt-5" alias of a mini.
|
|
32
|
+
let best = null;
|
|
33
|
+
let bestLen = -1;
|
|
34
|
+
for (const m of models) {
|
|
35
|
+
for (const cand of [m.id, ...(m.aliases || [])]) {
|
|
36
|
+
const c = String(cand).toLowerCase();
|
|
37
|
+
if (id.startsWith(c) && c.length > bestLen) {
|
|
38
|
+
best = m.perMillion;
|
|
39
|
+
bestLen = c.length;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (best) return best;
|
|
44
|
+
|
|
45
|
+
return pricing.fallback;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** costUsd for a single model's token buckets, matching the server formula. */
|
|
49
|
+
export function costForModel(rates, tok) {
|
|
50
|
+
return (
|
|
51
|
+
(tok.input * rates.input +
|
|
52
|
+
tok.output * rates.output +
|
|
53
|
+
tok.cacheCreate * rates.cacheCreate +
|
|
54
|
+
tok.cacheRead * rates.cacheRead +
|
|
55
|
+
tok.reasoning * rates.reasoning) /
|
|
56
|
+
1e6
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Sum costUsd across all aggregated model buckets. */
|
|
61
|
+
export function previewCost(pricing, models) {
|
|
62
|
+
let usd = 0;
|
|
63
|
+
for (const m of models) {
|
|
64
|
+
usd += costForModel(resolveRates(pricing, m.model), m);
|
|
65
|
+
}
|
|
66
|
+
return usd;
|
|
67
|
+
}
|
package/src/publish.mjs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Thin POST wrapper for /api/tmx/publish. Returns { status, json } and never
|
|
2
|
+
// throws on non-2xx — the CLI decides how to present each documented reason.
|
|
3
|
+
|
|
4
|
+
export async function publish(apiBase, body) {
|
|
5
|
+
const res = await fetch(`${apiBase}/api/tmx/publish`, {
|
|
6
|
+
method: 'POST',
|
|
7
|
+
headers: { 'content-type': 'application/json' },
|
|
8
|
+
body: JSON.stringify(body),
|
|
9
|
+
});
|
|
10
|
+
let json = null;
|
|
11
|
+
try {
|
|
12
|
+
json = await res.json();
|
|
13
|
+
} catch {
|
|
14
|
+
json = null;
|
|
15
|
+
}
|
|
16
|
+
return { status: res.status, json };
|
|
17
|
+
}
|
package/src/secrets.mjs
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Capability-token storage at ~/.config/tokenmax/<nick>.json.
|
|
2
|
+
// The secret is the only credential that lets a nick be updated later. We store
|
|
3
|
+
// it 0600 in a 0700 directory. No prompts, files, or keys ever go here.
|
|
4
|
+
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { mkdir, readFile, writeFile, chmod } from 'node:fs/promises';
|
|
8
|
+
|
|
9
|
+
function configDir() {
|
|
10
|
+
return path.join(os.homedir(), '.config', 'tokenmax');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function secretFile(nick) {
|
|
14
|
+
return path.join(configDir(), `${nick}.json`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Return the saved secret for nick, or null if none/unreadable. */
|
|
18
|
+
export async function loadSecret(nick) {
|
|
19
|
+
try {
|
|
20
|
+
const text = await readFile(secretFile(nick), 'utf8');
|
|
21
|
+
const data = JSON.parse(text);
|
|
22
|
+
return typeof data.secret === 'string' && data.secret ? data.secret : null;
|
|
23
|
+
} catch {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Persist { nick, secret, url, createdAt } with restrictive permissions.
|
|
30
|
+
* @returns absolute path written
|
|
31
|
+
*/
|
|
32
|
+
export async function saveSecret(nick, data) {
|
|
33
|
+
await mkdir(configDir(), { recursive: true, mode: 0o700 });
|
|
34
|
+
const file = secretFile(nick);
|
|
35
|
+
await writeFile(file, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
|
36
|
+
// mkdir/writeFile mode is umask-masked; force the bits explicitly.
|
|
37
|
+
await chmod(file, 0o600).catch(() => {});
|
|
38
|
+
return file;
|
|
39
|
+
}
|
package/src/util.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Shared, dependency-free helpers used by the adapters and aggregator.
|
|
2
|
+
// SAFETY: nothing here ever touches prompt text, file contents, or keys —
|
|
3
|
+
// only numbers, model id strings, and ISO timestamps.
|
|
4
|
+
|
|
5
|
+
import { readdir } from 'node:fs/promises';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
/** Coerce any value into a finite, non-negative integer token count. */
|
|
9
|
+
export function num(v) {
|
|
10
|
+
const n = Number(v);
|
|
11
|
+
if (!Number.isFinite(n) || n < 0) return 0;
|
|
12
|
+
return Math.round(n);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Extract the YYYY-MM-DD day from an ISO timestamp, or null if unusable. */
|
|
16
|
+
export function isoDay(ts) {
|
|
17
|
+
if (typeof ts !== 'string' || ts.length < 10) return null;
|
|
18
|
+
const day = ts.slice(0, 10);
|
|
19
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(day) ? day : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Recursively yield every *.jsonl file under dir (missing dir => nothing). */
|
|
23
|
+
export async function* walkJsonl(dir) {
|
|
24
|
+
let entries;
|
|
25
|
+
try {
|
|
26
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
27
|
+
} catch {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
for (const e of entries) {
|
|
31
|
+
const p = path.join(dir, e.name);
|
|
32
|
+
if (e.isDirectory()) {
|
|
33
|
+
yield* walkJsonl(p);
|
|
34
|
+
} else if (e.isFile() && e.name.endsWith('.jsonl')) {
|
|
35
|
+
yield p;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Iterate non-empty trimmed lines of a string. */
|
|
41
|
+
export function* lines(text) {
|
|
42
|
+
for (const line of text.split('\n')) {
|
|
43
|
+
const t = line.trim();
|
|
44
|
+
if (t) yield t;
|
|
45
|
+
}
|
|
46
|
+
}
|