ai-usage-analyzer 0.4.0 → 0.5.1
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 +38 -19
- package/bin/ai-usage.js +24 -19
- package/package.json +8 -8
- package/src/aggregate.js +24 -2
- package/src/live.js +132 -0
- package/src/loaders.js +90 -27
- package/src/markdown.js +13 -10
- package/src/pricing.js +49 -0
- package/src/quota.js +194 -0
- package/src/render.js +20 -14
- package/src/server.js +68 -0
- package/src/tools.js +5 -49
- package/src/tui.js +24 -8
- package/web/app.js +1059 -0
- package/web/dates.js +35 -0
- package/web/favicon.svg +1 -0
- package/web/fonts/OFL.txt +93 -0
- package/web/fonts/manrope.woff2 +0 -0
- package/web/icons/claude.svg +1 -0
- package/web/icons/openai.svg +1 -0
- package/web/icons/opencode.svg +1 -0
- package/web/index.html +233 -0
- package/web/style.css +369 -0
package/src/loaders.js
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Unified record shape:
|
|
4
4
|
// {
|
|
5
|
-
// tool: 'opencode' | 'codex' | 'mimocode' | string,
|
|
6
5
|
// sessionId: string,
|
|
7
6
|
// project: string, // working directory or '(unknown)'
|
|
8
7
|
// title: string,
|
|
@@ -16,9 +15,10 @@
|
|
|
16
15
|
// }
|
|
17
16
|
|
|
18
17
|
import { createReadStream, readdirSync } from "node:fs";
|
|
19
|
-
import { join } from "node:path";
|
|
18
|
+
import { basename, dirname, join } from "node:path";
|
|
20
19
|
import { createInterface } from "node:readline";
|
|
21
20
|
import { DatabaseSync } from "node:sqlite";
|
|
21
|
+
import { estimateUsage } from "./pricing.js";
|
|
22
22
|
|
|
23
23
|
// Run fn over every item in items with at most `limit` in-flight at once.
|
|
24
24
|
async function mapConcurrent(items, fn, limit) {
|
|
@@ -70,6 +70,11 @@ function parseIsoMs(s) {
|
|
|
70
70
|
return Number.isFinite(t) ? t : null;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
function tokenCount(value) {
|
|
74
|
+
const n = Number(value);
|
|
75
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
73
78
|
function compactHome(p) {
|
|
74
79
|
if (!p) return "(unknown)";
|
|
75
80
|
const home = process.env.HOME || "";
|
|
@@ -77,7 +82,6 @@ function compactHome(p) {
|
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
// ---------------------------------------------------------------------------
|
|
80
|
-
// SQLite loader (OpenCode + MimoCode share schema)
|
|
81
85
|
// ---------------------------------------------------------------------------
|
|
82
86
|
|
|
83
87
|
function loadOpencodeStyleSqlite(dbPath, toolKey) {
|
|
@@ -88,7 +92,6 @@ function loadOpencodeStyleSqlite(dbPath, toolKey) {
|
|
|
88
92
|
return { records: [], error: `cannot open ${dbPath}: ${e.message}` };
|
|
89
93
|
}
|
|
90
94
|
|
|
91
|
-
// Introspect session table for token columns (MimoCode schema is a subset)
|
|
92
95
|
const cols = db.prepare(`PRAGMA table_info(session)`).all();
|
|
93
96
|
const colSet = new Set(cols.map((c) => c.name));
|
|
94
97
|
const hasTokens =
|
|
@@ -144,6 +147,7 @@ function loadOpencodeStyleSqlite(dbPath, toolKey) {
|
|
|
144
147
|
tokensReasoning: r.tokens_reasoning || 0,
|
|
145
148
|
tokensTotal: tot,
|
|
146
149
|
cost: r.cost || 0,
|
|
150
|
+
costStatus: r.cost == null ? "unavailable" : "recorded",
|
|
147
151
|
model: r.model || "",
|
|
148
152
|
});
|
|
149
153
|
}
|
|
@@ -156,7 +160,9 @@ function loadOpencodeStyleSqlite(dbPath, toolKey) {
|
|
|
156
160
|
|
|
157
161
|
async function loadCodexRollouts(rootDir) {
|
|
158
162
|
const errors = [];
|
|
159
|
-
const
|
|
163
|
+
const roots = basename(rootDir) === "sessions" ? [rootDir, join(dirname(rootDir), "archived_sessions")] : [rootDir];
|
|
164
|
+
const files = [...new Set(roots.flatMap((root) => walkJsonl(root, "rollout-")))];
|
|
165
|
+
files.sort((a, b) => Number(a.split(/[\\/]/).includes("archived_sessions")) - Number(b.split(/[\\/]/).includes("archived_sessions")) || a.localeCompare(b));
|
|
160
166
|
const results = await mapConcurrent(
|
|
161
167
|
files,
|
|
162
168
|
async (f) => {
|
|
@@ -169,15 +175,20 @@ async function loadCodexRollouts(rootDir) {
|
|
|
169
175
|
},
|
|
170
176
|
48,
|
|
171
177
|
);
|
|
172
|
-
|
|
178
|
+
const sessions = new Map();
|
|
179
|
+
for (const record of results) if (record && !sessions.has(record.sessionId)) sessions.set(record.sessionId, record);
|
|
180
|
+
return { records: [...sessions.values()], errors };
|
|
173
181
|
}
|
|
174
182
|
|
|
175
183
|
async function parseCodexRollout(path) {
|
|
176
184
|
// Use a streaming reader to avoid loading huge files into memory.
|
|
177
|
-
// The 'token_count' event accumulates usage; we take the LAST one (final snapshot).
|
|
178
185
|
let sessionMeta = null;
|
|
179
|
-
let
|
|
186
|
+
let previous = null;
|
|
187
|
+
let forkWaiting = false;
|
|
188
|
+
let inheritedTotal = null;
|
|
189
|
+
const usageEvents = [];
|
|
180
190
|
let lastModel = "";
|
|
191
|
+
let provider = "openai";
|
|
181
192
|
let lastEvTs = null;
|
|
182
193
|
|
|
183
194
|
const stream = createReadStream(path, { encoding: "utf8" });
|
|
@@ -195,31 +206,73 @@ async function parseCodexRollout(path) {
|
|
|
195
206
|
const evTs = parseIsoMs(ev.timestamp);
|
|
196
207
|
if (evTs) lastEvTs = evTs;
|
|
197
208
|
if (t === "session_meta") {
|
|
198
|
-
sessionMeta = { ts: ev.timestamp, cwd: p.cwd || p.CWD || "" };
|
|
209
|
+
sessionMeta = { ts: ev.timestamp, cwd: p.cwd || p.CWD || "", id: typeof p.id === "string" ? p.id : null };
|
|
210
|
+
forkWaiting = Boolean(p.forked_from_id);
|
|
211
|
+
if (typeof p.model_provider === "string" && p.model_provider) provider = p.model_provider;
|
|
212
|
+
} else if (t === "turn_context") {
|
|
213
|
+
forkWaiting = false;
|
|
214
|
+
lastModel = p.model_info?.slug || p.model || p.model_name || lastModel;
|
|
199
215
|
} else if (t === "event_msg" && p.type === "token_count") {
|
|
200
216
|
const info = p.info || {};
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
const
|
|
217
|
+
const fields = ['input_tokens', 'output_tokens', 'cached_input_tokens', 'reasoning_output_tokens'];
|
|
218
|
+
const normalize = (u) => u && Object.fromEntries(fields.map((field) => [field, tokenCount(u[field] ?? (field === 'cached_input_tokens' ? u.cache_read_input_tokens : 0))]));
|
|
219
|
+
const total = normalize(info.total_token_usage);
|
|
220
|
+
const last = normalize(info.last_token_usage);
|
|
221
|
+
if (forkWaiting) {
|
|
222
|
+
if (total) {
|
|
223
|
+
previous = total;
|
|
224
|
+
inheritedTotal = info.total_token_usage.total_tokens ?? null;
|
|
225
|
+
}
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (inheritedTotal !== null && info.total_token_usage?.total_tokens === inheritedTotal) {
|
|
229
|
+
if (total) previous = total;
|
|
230
|
+
inheritedTotal = null;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
inheritedTotal = null;
|
|
234
|
+
let delta = null;
|
|
235
|
+
if (total && previous) {
|
|
236
|
+
if (fields.every((field) => total[field] === previous[field])) continue;
|
|
237
|
+
const regressed = fields.some((field) => total[field] < previous[field]);
|
|
238
|
+
if (regressed) {
|
|
239
|
+
const sum = (u) => fields.reduce((n, field) => n + u[field], 0);
|
|
240
|
+
if (last && sum(total) > 0 && sum(last) > 0 && (sum(total) * 100 >= sum(previous) * 98 || sum(total) + sum(last) * 2 >= sum(previous))) continue;
|
|
241
|
+
delta = last;
|
|
242
|
+
} else {
|
|
243
|
+
delta = last || Object.fromEntries(fields.map((field) => [field, total[field] - previous[field]]));
|
|
244
|
+
}
|
|
245
|
+
} else {
|
|
246
|
+
delta = last || total;
|
|
247
|
+
}
|
|
248
|
+
if (total) previous = total;
|
|
249
|
+
else if (last && previous) previous = Object.fromEntries(fields.map((field) => [field, previous[field] + last[field]]));
|
|
250
|
+
const m = info.model || info.model_name || p.model;
|
|
204
251
|
if (m) lastModel = m;
|
|
252
|
+
if (delta) usageEvents.push({ model: lastModel, provider, aggregated: !last, usage: delta });
|
|
205
253
|
}
|
|
206
254
|
}
|
|
207
|
-
if (!sessionMeta || !
|
|
255
|
+
if (!sessionMeta || !usageEvents.length) return null;
|
|
208
256
|
|
|
209
257
|
const ts = parseIsoMs(sessionMeta.ts);
|
|
210
258
|
if (!ts) return null;
|
|
211
259
|
const durationMs = lastEvTs && lastEvTs > ts ? lastEvTs - ts : 0;
|
|
212
260
|
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
261
|
+
let tokensInput = 0, tokensOutput = 0, tokensCacheRead = 0, tokensReasoning = 0;
|
|
262
|
+
for (const { usage } of usageEvents) {
|
|
263
|
+
const cached = Math.min(usage.input_tokens, usage.cached_input_tokens);
|
|
264
|
+
const reasoning = Math.min(usage.output_tokens, usage.reasoning_output_tokens);
|
|
265
|
+
tokensInput += usage.input_tokens - cached;
|
|
266
|
+
tokensOutput += usage.output_tokens - reasoning;
|
|
267
|
+
tokensCacheRead += cached;
|
|
268
|
+
tokensReasoning += reasoning;
|
|
269
|
+
}
|
|
217
270
|
const tot = tokensInput + tokensOutput + tokensCacheRead + tokensReasoning;
|
|
218
271
|
if (tot === 0) return null;
|
|
219
272
|
|
|
220
273
|
return {
|
|
221
274
|
tool: "codex",
|
|
222
|
-
sessionId:
|
|
275
|
+
sessionId: sessionMeta.id || basename(path).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?=\.jsonl$)/i)?.[0] || basename(path, ".jsonl"),
|
|
223
276
|
project: compactHome(sessionMeta.cwd),
|
|
224
277
|
title: "",
|
|
225
278
|
week: isoWeekKey(ts),
|
|
@@ -232,8 +285,9 @@ async function parseCodexRollout(path) {
|
|
|
232
285
|
tokensCacheWrite: 0, // codex doesn't expose cache_write separately
|
|
233
286
|
tokensReasoning,
|
|
234
287
|
tokensTotal: tot,
|
|
235
|
-
|
|
288
|
+
...estimateUsage("codex", usageEvents),
|
|
236
289
|
model: lastModel,
|
|
290
|
+
usageEvents,
|
|
237
291
|
};
|
|
238
292
|
}
|
|
239
293
|
|
|
@@ -294,8 +348,7 @@ function walkClaudeSessions(root) {
|
|
|
294
348
|
//
|
|
295
349
|
// Each top-level file under projects/<encoded-cwd>/<UUID>.jsonl is one session.
|
|
296
350
|
// Per assistant line, `message.usage` carries the per-turn token counts; we
|
|
297
|
-
//
|
|
298
|
-
// across unique messages. cwd / model / timestamp come from the line itself.
|
|
351
|
+
// merge streamed usage by message and request, then sum unique responses. cwd / model / timestamp come from the line itself.
|
|
299
352
|
|
|
300
353
|
async function loadClaudeJsonl(rootDir) {
|
|
301
354
|
const errors = [];
|
|
@@ -317,7 +370,9 @@ async function loadClaudeJsonl(rootDir) {
|
|
|
317
370
|
|
|
318
371
|
async function parseClaudeSession(path) {
|
|
319
372
|
const sessionId = path.split("/").pop().replace(".jsonl", "");
|
|
320
|
-
const
|
|
373
|
+
const usages = new Map();
|
|
374
|
+
const usageModels = new Map();
|
|
375
|
+
let anonymous = 0;
|
|
321
376
|
let cwd = "";
|
|
322
377
|
let model = "";
|
|
323
378
|
let firstTs = null;
|
|
@@ -347,15 +402,21 @@ async function parseClaudeSession(path) {
|
|
|
347
402
|
const usage = msg.usage;
|
|
348
403
|
if (!usage) continue;
|
|
349
404
|
const id = msg.id;
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
405
|
+
const key = id ? JSON.stringify([id, ev.requestId || null]) : `anonymous:${anonymous++}`;
|
|
406
|
+
const previous = usages.get(key) || {};
|
|
407
|
+
const merged = {};
|
|
408
|
+
for (const field of ["input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens"]) {
|
|
409
|
+
merged[field] = Math.max(tokenCount(previous[field]), tokenCount(usage[field]));
|
|
353
410
|
}
|
|
411
|
+
usages.set(key, merged);
|
|
412
|
+
if (msg.model) usageModels.set(key, msg.model);
|
|
354
413
|
if (!cwd && typeof ev.cwd === "string") cwd = ev.cwd;
|
|
355
414
|
if (!model && typeof msg.model === "string") model = msg.model;
|
|
356
415
|
const evTs = parseIsoMs(ev.timestamp);
|
|
357
416
|
if (firstTs === null && evTs !== null) firstTs = evTs;
|
|
358
417
|
if (evTs !== null) lastTs = evTs;
|
|
418
|
+
}
|
|
419
|
+
for (const usage of usages.values()) {
|
|
359
420
|
tokensInput += usage.input_tokens || 0;
|
|
360
421
|
tokensOutput += usage.output_tokens || 0;
|
|
361
422
|
tokensCacheRead += usage.cache_read_input_tokens || 0;
|
|
@@ -367,6 +428,7 @@ async function parseClaudeSession(path) {
|
|
|
367
428
|
const ts = firstTs ?? Date.now();
|
|
368
429
|
const durationMs = firstTs !== null && lastTs !== null ? lastTs - firstTs : 0;
|
|
369
430
|
|
|
431
|
+
const usageEvents = [...usages].map(([key, usage]) => ({ model: usageModels.get(key) || "", usage }));
|
|
370
432
|
return {
|
|
371
433
|
tool: "claude",
|
|
372
434
|
sessionId,
|
|
@@ -382,8 +444,9 @@ async function parseClaudeSession(path) {
|
|
|
382
444
|
tokensCacheWrite,
|
|
383
445
|
tokensReasoning: 0,
|
|
384
446
|
tokensTotal: tot,
|
|
385
|
-
|
|
447
|
+
...estimateUsage("claude", usageEvents),
|
|
386
448
|
model,
|
|
449
|
+
usageEvents,
|
|
387
450
|
};
|
|
388
451
|
}
|
|
389
452
|
|
|
@@ -398,7 +461,7 @@ export async function loadAll(detections) {
|
|
|
398
461
|
const tasks = detections
|
|
399
462
|
.filter((d) => d.status === "present")
|
|
400
463
|
.map((d) => {
|
|
401
|
-
if (d.key === "opencode"
|
|
464
|
+
if (d.key === "opencode") {
|
|
402
465
|
return Promise.resolve({
|
|
403
466
|
d,
|
|
404
467
|
r: loadOpencodeStyleSqlite(d.path, d.key),
|
package/src/markdown.js
CHANGED
|
@@ -31,9 +31,10 @@ export function fmtCompact(n) {
|
|
|
31
31
|
return String(n || 0);
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
function fmtCost(n) {
|
|
34
|
+
function fmtCost(n, status) {
|
|
35
|
+
if (status === "unavailable") return "unavailable";
|
|
35
36
|
if (!n) return "—";
|
|
36
|
-
return "$" + Number(n).toFixed(2);
|
|
37
|
+
return (status === "estimated" || status === "partial" ? "~" : "") + "$" + Number(n).toFixed(2) + (status === "partial" ? " (partial)" : "");
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
function compactHome(p) {
|
|
@@ -131,9 +132,9 @@ export function renderMarkdown({
|
|
|
131
132
|
out.push(`**Range**: ${dateRange[0] ?? "—"} → ${dateRange[1] ?? "—"} `);
|
|
132
133
|
out.push(`**Sessions**: ${fmtInt(records.length)} `);
|
|
133
134
|
out.push(`**Total tokens**: ${fmtCompact(tot.tokensTotal)} `);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
135
|
+
out.push(`**Average daily tokens**: ${fmtCompact(tot.avgDailyTokens)} `);
|
|
136
|
+
out.push(`**Cost**: ${fmtCost(tot.cost, tot.costStatus)} `);
|
|
137
|
+
if (tot.unknownModels.length) out.push(`**Unpriced models**: ${tot.unknownModels.map(mdEscape).join(", ")} `);
|
|
137
138
|
} else {
|
|
138
139
|
out.push(`**No session data available** — only detection status below.`);
|
|
139
140
|
}
|
|
@@ -174,11 +175,11 @@ export function renderMarkdown({
|
|
|
174
175
|
out.push(`|---|---:|---:|---:|---:|`);
|
|
175
176
|
for (const t of tools) {
|
|
176
177
|
out.push(
|
|
177
|
-
`| ${mdEscape(t.tool)} | ${fmtInt(t.n)} | ${fmtCompact(t.tokensTotal)} | ${fmtCompact(t.avg)} | ${fmtCost(t.cost)} |`,
|
|
178
|
+
`| ${mdEscape(t.tool)} | ${fmtInt(t.n)} | ${fmtCompact(t.tokensTotal)} | ${fmtCompact(t.avg)} | ${fmtCost(t.cost, t.costStatus)} |`,
|
|
178
179
|
);
|
|
179
180
|
}
|
|
180
181
|
out.push(
|
|
181
|
-
`| **TOTAL** | **${fmtInt(records.length)}** | **${fmtCompact(tot.tokensTotal)}** | **${fmtCompact(tot.avg)}** | **${fmtCost(tot.cost)}** |`,
|
|
182
|
+
`| **TOTAL** | **${fmtInt(records.length)}** | **${fmtCompact(tot.tokensTotal)}** | **${fmtCompact(tot.avg)}** | **${fmtCost(tot.cost, tot.costStatus)}** |`,
|
|
182
183
|
);
|
|
183
184
|
out.push(``);
|
|
184
185
|
|
|
@@ -245,7 +246,7 @@ export function renderMarkdown({
|
|
|
245
246
|
for (const p of toolRows) {
|
|
246
247
|
const cache = p.tokensCacheRead + p.tokensCacheWrite;
|
|
247
248
|
out.push(
|
|
248
|
-
`| ${mdEscape(p.tool)} | ${fmtInt(p.n)} | ${fmtCompact(p.tokensInput)} | ${fmtCompact(p.tokensOutput)} | ${fmtCompact(cache)} | ${fmtCompact(p.tokensTotal)} | ${fmtCost(p.cost)} | ${fmtCompact(p.avg)} | ${bar(p.tokensTotal, max, 20)} |`,
|
|
249
|
+
`| ${mdEscape(p.tool)} | ${fmtInt(p.n)} | ${fmtCompact(p.tokensInput)} | ${fmtCompact(p.tokensOutput)} | ${fmtCompact(cache)} | ${fmtCompact(p.tokensTotal)} | ${fmtCost(p.cost, p.costStatus)} | ${fmtCompact(p.avg)} | ${bar(p.tokensTotal, max, 20)} |`,
|
|
249
250
|
);
|
|
250
251
|
}
|
|
251
252
|
out.push(``);
|
|
@@ -316,7 +317,7 @@ export function renderMarkdown({
|
|
|
316
317
|
top.forEach((r, i) => {
|
|
317
318
|
const proj = compactHome(r.project);
|
|
318
319
|
out.push(
|
|
319
|
-
`| ${i + 1} | ${fmtCompact(r.tokensTotal)} | ${fmtCompact(r.tokensInput)} | ${fmtCompact(r.tokensOutput)} | ${r.cost
|
|
320
|
+
`| ${i + 1} | ${fmtCompact(r.tokensTotal)} | ${fmtCompact(r.tokensInput)} | ${fmtCompact(r.tokensOutput)} | ${fmtCost(r.cost, r.costStatus)} | ${mdEscape(shortModel(r.model))} | \`${mdEscape(proj)}\` | ${mdEscape(r.title || "—")} |`,
|
|
320
321
|
);
|
|
321
322
|
});
|
|
322
323
|
out.push(``);
|
|
@@ -325,6 +326,8 @@ export function renderMarkdown({
|
|
|
325
326
|
// Notes ---------------------------------------------------------------
|
|
326
327
|
out.push(`## Notes`);
|
|
327
328
|
out.push(``);
|
|
329
|
+
out.push(`Costs marked ~ are API-equivalent estimates; partial totals exclude unpriced usage. Recorded costs come from local tool data and are not necessarily subscription charges.`);
|
|
330
|
+
out.push(``);
|
|
328
331
|
out.push(`### Token Definitions`);
|
|
329
332
|
out.push(``);
|
|
330
333
|
out.push(`- **Input** — prompt tokens sent to the model`);
|
|
@@ -333,7 +336,7 @@ export function renderMarkdown({
|
|
|
333
336
|
`- **Cache Read** — prompt tokens served from the provider's cache (cheap)`,
|
|
334
337
|
);
|
|
335
338
|
out.push(
|
|
336
|
-
`- **Cache Write** — prompt tokens cached for future use
|
|
339
|
+
`- **Cache Write** — prompt tokens cached for future use`,
|
|
337
340
|
);
|
|
338
341
|
out.push(`- **Reasoning** — extended thinking / chain-of-thought tokens`);
|
|
339
342
|
out.push(``);
|
package/src/pricing.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export const PRICING_VERSION = '2026-10-27';
|
|
2
|
+
|
|
3
|
+
const PRICES = {
|
|
4
|
+
claude: [
|
|
5
|
+
[/^claude-opus-4-[5678](?:-\d{8})?$/, [5, 25, 0.5, 6.25]],
|
|
6
|
+
[/^claude-sonnet-4-6(?:-\d{8})?$/, [3, 15, 0.3, 3.75]],
|
|
7
|
+
[/^claude-sonnet-4-5(?:-\d{8})?$/, [3, 15, 0.3, 3.75], 200000],
|
|
8
|
+
[/^claude-sonnet-4(?:-\d{8})?$/, [3, 15, 0.3, 3.75], 200000],
|
|
9
|
+
[/^claude-haiku-4-5(?:-\d{8})?$/, [1, 5, 0.1, 1.25]],
|
|
10
|
+
[/^claude-3-5-haiku(?:-\d{8})?$/, [0.8, 4, 0.08, 1]],
|
|
11
|
+
],
|
|
12
|
+
codex: [
|
|
13
|
+
[/^gpt-5-5(?:-codex)?(?:-\d{4}-\d{2}-\d{2})?$/, [5, 30, 0.5, 0], 272000],
|
|
14
|
+
[/^gpt-5-4-mini(?:-\d{4}-\d{2}-\d{2})?$/, [0.75, 4.5, 0.075, 0]],
|
|
15
|
+
[/^gpt-5-4-nano(?:-\d{4}-\d{2}-\d{2})?$/, [0.2, 1.25, 0.02, 0]],
|
|
16
|
+
[/^gpt-5-4-pro(?:-\d{4}-\d{2}-\d{2})?$/, [30, 180, 0, 0], 272000],
|
|
17
|
+
[/^gpt-5-4(?:-codex)?(?:-\d{4}-\d{2}-\d{2})?$/, [2.5, 15, 0.25, 0], 272000],
|
|
18
|
+
[/^gpt-5-[23](?:-codex)?(?:-\d{4}-\d{2}-\d{2})?$/, [1.75, 14, 0.175, 0]],
|
|
19
|
+
[/^gpt-5-1(?:-codex)?(?:-\d{4}-\d{2}-\d{2})?$/, [1.25, 10, 0.125, 0]],
|
|
20
|
+
[/^gpt-5-1-codex-mini(?:-\d{4}-\d{2}-\d{2})?$/, [0.25, 2, 0.025, 0]],
|
|
21
|
+
[/^gpt-5-mini(?:-\d{4}-\d{2}-\d{2})?$/, [0.25, 2, 0.025, 0]],
|
|
22
|
+
[/^gpt-5-nano(?:-\d{4}-\d{2}-\d{2})?$/, [0.05, 0.4, 0.005, 0]],
|
|
23
|
+
[/^gpt-5(?:(?:-codex)?(?:-\d{4}-\d{2}-\d{2})?)$/, [1.25, 10, 0.125, 0]],
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function estimateUsage(tool, events) {
|
|
28
|
+
let cost = 0;
|
|
29
|
+
let priced = 0;
|
|
30
|
+
const unknown = new Set();
|
|
31
|
+
for (const { model, usage, provider, aggregated, ambiguousModel } of events) {
|
|
32
|
+
const name = String(model || '').toLowerCase().replace(/[._ ]/g, '-').split('/').pop();
|
|
33
|
+
const price = (PRICES[tool] || []).find(([pattern]) => pattern.test(name));
|
|
34
|
+
if (!price || (tool === 'codex' && provider && provider.toLowerCase() !== 'openai')) { unknown.add(model || '(unknown model)'); continue; }
|
|
35
|
+
if (aggregated || ambiguousModel) { unknown.add(`${model || '(unknown model)'} (aggregate usage)`); continue; }
|
|
36
|
+
const [inputRate, outputRate, cacheRate, writeRate] = price[1];
|
|
37
|
+
const nonnegative = value => Number.isFinite(Number(value)) ? Math.max(0, Number(value)) : 0;
|
|
38
|
+
const rawInput = nonnegative(usage.input_tokens);
|
|
39
|
+
const cache = nonnegative(tool === 'codex' ? usage.cached_input_tokens : usage.cache_read_input_tokens);
|
|
40
|
+
const input = tool === 'codex' ? Math.max(0, rawInput - cache) : rawInput;
|
|
41
|
+
const output = nonnegative(usage.output_tokens);
|
|
42
|
+
const write = nonnegative(usage.cache_creation_input_tokens);
|
|
43
|
+
const prompt = rawInput + (tool === "claude" ? cache + write : 0);
|
|
44
|
+
const long = price[2] && prompt > price[2];
|
|
45
|
+
cost += (input * inputRate * (long ? 2 : 1) + output * outputRate * (long ? 1.5 : 1) + cache * cacheRate * (long ? 2 : 1) + write * writeRate * (long ? 2 : 1)) / 1000000;
|
|
46
|
+
priced++;
|
|
47
|
+
}
|
|
48
|
+
return { cost, costStatus: unknown.size ? (priced ? 'partial' : 'unavailable') : 'estimated', unknownModels: [...unknown] };
|
|
49
|
+
}
|
package/src/quota.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { readFile, open, rename, unlink, stat } from 'node:fs/promises';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile);
|
|
9
|
+
|
|
10
|
+
function number(value) {
|
|
11
|
+
if (value === null || value === undefined || value === '') return null;
|
|
12
|
+
const n = Number(value);
|
|
13
|
+
return Number.isFinite(n) ? n : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function resetTime(value) {
|
|
17
|
+
const seconds = number(value);
|
|
18
|
+
if (seconds === null || seconds <= 0) return null;
|
|
19
|
+
const date = new Date(seconds * 1000);
|
|
20
|
+
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseCodexQuota(data) {
|
|
24
|
+
const windows = [];
|
|
25
|
+
function add(rate, prefix = '') {
|
|
26
|
+
for (const w of [rate?.primary_window, rate?.secondary_window]) {
|
|
27
|
+
if (!w || number(w.used_percent) === null) continue;
|
|
28
|
+
const duration = number(w.limit_window_seconds);
|
|
29
|
+
const name = duration === 18000 ? 'Session' : duration === 604800 ? 'Weekly' : 'Limit';
|
|
30
|
+
windows.push({ label: prefix ? `${prefix} ${name}` : name, usedPercent: number(w.used_percent), resetAt: resetTime(w.reset_at) });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
add(data.rate_limit);
|
|
34
|
+
for (const extra of data.additional_rate_limits || []) {
|
|
35
|
+
const name = extra.limit_name || extra.metered_feature || 'Additional';
|
|
36
|
+
add(extra.rate_limit, /spark/i.test(name) ? 'Codex Spark' : name);
|
|
37
|
+
}
|
|
38
|
+
const spend = data.spend_control?.individual_limit;
|
|
39
|
+
if (spend) {
|
|
40
|
+
const used = number(spend.used), limit = number(spend.limit);
|
|
41
|
+
const percent = number(spend.used_percent) ?? (limit > 0 && used !== null ? used / limit * 100 : null);
|
|
42
|
+
if (percent !== null) windows.push({ label: 'Monthly spend', usedPercent: percent, resetAt: resetTime(spend.reset_at) });
|
|
43
|
+
}
|
|
44
|
+
return { windows, plan: data.plan_type || null };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parseClaudeQuota(data) {
|
|
48
|
+
const windows = [];
|
|
49
|
+
for (const [field, label] of [['five_hour', 'Session'], ['seven_day', 'Weekly'], ['seven_day_oauth_apps', 'OAuth Apps'], ['seven_day_sonnet', 'Sonnet'], ['seven_day_opus', 'Opus']]) {
|
|
50
|
+
const w = data[field];
|
|
51
|
+
if (w && number(w.utilization) !== null) windows.push({ label, usedPercent: number(w.utilization), resetAt: w.resets_at || null });
|
|
52
|
+
}
|
|
53
|
+
for (const [fields, label] of [
|
|
54
|
+
[['seven_day_design', 'seven_day_claude_design', 'claude_design', 'design', 'seven_day_omelette', 'omelette', 'omelette_promotional'], 'Designs'],
|
|
55
|
+
[['seven_day_routines', 'seven_day_claude_routines', 'claude_routines', 'routines', 'routine', 'seven_day_cowork', 'cowork'], 'Daily Routines'],
|
|
56
|
+
]) {
|
|
57
|
+
const w = fields.map(field => data[field]).find(Boolean);
|
|
58
|
+
if (w && number(w.utilization) !== null) windows.push({ label, usedPercent: number(w.utilization), resetAt: w.resets_at || null });
|
|
59
|
+
}
|
|
60
|
+
const extra = data.extra_usage;
|
|
61
|
+
const limit = number(extra?.monthly_limit), used = number(extra?.used_credits);
|
|
62
|
+
const percent = number(extra?.utilization) ?? (limit > 0 && used !== null ? used / limit * 100 : null);
|
|
63
|
+
if (extra?.is_enabled && percent !== null) windows.push({ label: 'Extra usage', usedPercent: percent, resetAt: null, used: used === null ? null : used / 100, limit: limit === null ? null : limit / 100, currency: extra.currency || 'USD' });
|
|
64
|
+
return { windows };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function fetchQuota(provider, credentials, fetcher = fetch, signal, versionReader) {
|
|
68
|
+
if (provider !== 'claude' && provider !== 'codex') throw new Error('Unsupported quota provider');
|
|
69
|
+
if (!credentials?.accessToken) throw new Error('No OAuth access token available');
|
|
70
|
+
const claude = provider === 'claude';
|
|
71
|
+
const headers = { Authorization: `Bearer ${credentials.accessToken}`, Accept: 'application/json' };
|
|
72
|
+
if (claude) {
|
|
73
|
+
headers['anthropic-beta'] = 'oauth-2025-04-20';
|
|
74
|
+
let version = '2.1.0';
|
|
75
|
+
try {
|
|
76
|
+
const reader = versionReader || (async () => (await execFileAsync('claude', ['--version'], { timeout: 1000, maxBuffer: 4096, signal })).stdout);
|
|
77
|
+
const output = await reader();
|
|
78
|
+
version = String(output).match(/\b\d+\.\d+\.\d+\b/)?.[0] || version;
|
|
79
|
+
} catch {}
|
|
80
|
+
signal?.throwIfAborted();
|
|
81
|
+
headers['User-Agent'] = `claude-code/${version}`;
|
|
82
|
+
} else if (credentials.accountId) {
|
|
83
|
+
headers['ChatGPT-Account-Id'] = credentials.accountId;
|
|
84
|
+
}
|
|
85
|
+
const response = await fetcher(claude ? 'https://api.anthropic.com/api/oauth/usage' : 'https://chatgpt.com/backend-api/wham/usage', { headers, signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30000)]) : AbortSignal.timeout(30000) });
|
|
86
|
+
if (response.status === 401 || response.status === 403) {
|
|
87
|
+
throw new Error(claude ? 'Claude login is invalid or lacks access; run claude to log in again' : 'Codex login is invalid; run codex login');
|
|
88
|
+
}
|
|
89
|
+
if (!response.ok) throw new Error(`Quota request failed (HTTP ${response.status})`);
|
|
90
|
+
const data = await response.json();
|
|
91
|
+
const result = { provider, ...(claude ? parseClaudeQuota(data) : parseCodexQuota(data)) };
|
|
92
|
+
if (!claude && credentials.idToken && credentials.idToken.length < 65536) {
|
|
93
|
+
try {
|
|
94
|
+
const claims = JSON.parse(Buffer.from(credentials.idToken.split('.')[1], 'base64url').toString('utf8'));
|
|
95
|
+
if (typeof claims.email === 'string') result.email = claims.email;
|
|
96
|
+
if (!result.plan && typeof claims['https://api.openai.com/auth']?.chatgpt_plan_type === 'string') result.plan = claims['https://api.openai.com/auth'].chatgpt_plan_type;
|
|
97
|
+
} catch {}
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function readCredentials(provider, env = process.env, options = {}) {
|
|
103
|
+
if (provider === 'codex') {
|
|
104
|
+
const file = join(env.CODEX_HOME || join(homedir(), '.codex'), 'auth.json');
|
|
105
|
+
const auth = JSON.parse(await readFile(file, 'utf8'));
|
|
106
|
+
if (auth.OPENAI_API_KEY) throw new Error('OAuth usage limits require codex login');
|
|
107
|
+
return { accessToken: auth.tokens?.access_token, refreshToken: auth.tokens?.refresh_token, idToken: auth.tokens?.id_token, accountId: auth.tokens?.account_id, lastRefresh: auth.last_refresh, authPath: file };
|
|
108
|
+
}
|
|
109
|
+
if (provider !== 'claude') throw new Error('Unsupported quota provider');
|
|
110
|
+
const token = env.TOKCAT_CLAUDE_OAUTH_TOKEN || env.CODEXBAR_CLAUDE_OAUTH_TOKEN;
|
|
111
|
+
if (token) return { accessToken: token };
|
|
112
|
+
let raw;
|
|
113
|
+
if ((options.platform || process.platform) === 'darwin') {
|
|
114
|
+
const getKeychain = options.getKeychain || (async () => (await execFileAsync('/usr/bin/security', ['find-generic-password', '-s', 'Claude Code-credentials', '-w'], { timeout: 5000, maxBuffer: 1024 * 1024, signal: options.signal })).stdout);
|
|
115
|
+
try { raw = await getKeychain(); }
|
|
116
|
+
catch (error) {
|
|
117
|
+
if (options.signal?.aborted) throw error;
|
|
118
|
+
if (error.killed || error.code === 'ETIMEDOUT') throw new Error('Claude Keychain access timed out; check the permission prompt');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!raw) {
|
|
122
|
+
const file = join(env.CLAUDE_HOME || join(homedir(), '.claude'), '.credentials.json');
|
|
123
|
+
raw = await readFile(file, 'utf8');
|
|
124
|
+
}
|
|
125
|
+
const oauth = JSON.parse(raw).claudeAiOauth || {};
|
|
126
|
+
if (oauth.expiresAt && oauth.expiresAt <= Date.now()) throw new Error('Claude token expired; run claude to refresh it');
|
|
127
|
+
if (oauth.scopes && !oauth.scopes.includes('user:profile')) throw new Error('Claude login lacks user:profile scope');
|
|
128
|
+
return { accessToken: oauth.accessToken };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function refreshCodexCredentials(path, fetcher = fetch, signal) {
|
|
132
|
+
const original = await readFile(path, 'utf8');
|
|
133
|
+
const auth = JSON.parse(original);
|
|
134
|
+
if (!auth.tokens?.refresh_token) throw new Error('Codex refresh token unavailable; run codex login');
|
|
135
|
+
const response = await fetcher('https://auth.openai.com/oauth/token', {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
138
|
+
body: JSON.stringify({ client_id: 'app_EMoamEEZ73f0CkXaXp7hrann', grant_type: 'refresh_token', refresh_token: auth.tokens.refresh_token, scope: 'openid profile email' }),
|
|
139
|
+
signal: signal ? AbortSignal.any([signal, AbortSignal.timeout(30000)]) : AbortSignal.timeout(30000),
|
|
140
|
+
});
|
|
141
|
+
if (!response.ok) throw new Error('Codex refresh failed; run codex login');
|
|
142
|
+
const tokens = await response.json();
|
|
143
|
+
if (!tokens.access_token) throw new Error('Codex refresh returned incomplete credentials; run codex login');
|
|
144
|
+
auth.tokens = { ...auth.tokens, access_token: tokens.access_token, refresh_token: tokens.refresh_token || auth.tokens.refresh_token, ...(tokens.id_token ? { id_token: tokens.id_token } : {}) };
|
|
145
|
+
auth.last_refresh = new Date().toISOString();
|
|
146
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
147
|
+
let handle;
|
|
148
|
+
try {
|
|
149
|
+
const mode = (await stat(path)).mode & 0o600;
|
|
150
|
+
handle = await open(temp, 'wx', mode || 0o600);
|
|
151
|
+
await handle.writeFile(JSON.stringify(auth, null, 2));
|
|
152
|
+
await handle.sync();
|
|
153
|
+
await handle.close();
|
|
154
|
+
handle = null;
|
|
155
|
+
if (await readFile(path, 'utf8') !== original) throw new Error('Codex credentials changed during refresh; run codex login if needed');
|
|
156
|
+
await rename(temp, path);
|
|
157
|
+
} finally {
|
|
158
|
+
if (handle) await handle.close();
|
|
159
|
+
await unlink(temp).catch(() => {});
|
|
160
|
+
}
|
|
161
|
+
return { accessToken: auth.tokens.access_token, refreshToken: auth.tokens.refresh_token, idToken: auth.tokens.id_token, accountId: auth.tokens.account_id, lastRefresh: auth.last_refresh, authPath: path };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function collectQuotas(options = {}) {
|
|
165
|
+
const read = options.read || ((provider) => readCredentials(provider, process.env, { signal: options.signal }));
|
|
166
|
+
const refresh = options.refresh || ((path) => refreshCodexCredentials(path, fetch));
|
|
167
|
+
const request = options.fetch || ((provider, credentials) => fetchQuota(provider, credentials, fetch, options.signal));
|
|
168
|
+
return Promise.all(['claude', 'codex'].map(async (provider) => {
|
|
169
|
+
try {
|
|
170
|
+
let credentials = await read(provider);
|
|
171
|
+
options.signal?.throwIfAborted();
|
|
172
|
+
const refreshedAt = Date.parse(credentials.lastRefresh);
|
|
173
|
+
if (provider === 'codex' && (!Number.isFinite(refreshedAt) || Date.now() - refreshedAt > 8 * 86400000)) {
|
|
174
|
+
credentials = await refresh(credentials.authPath);
|
|
175
|
+
}
|
|
176
|
+
options.signal?.throwIfAborted();
|
|
177
|
+
return await request(provider, credentials);
|
|
178
|
+
} catch (error) {
|
|
179
|
+
const safe = /^(Claude token expired|Claude login lacks|Claude login is invalid|Codex login is invalid|Claude Keychain access|OAuth usage limits|Codex refresh|Codex credentials changed|No OAuth access token|Quota request failed)/;
|
|
180
|
+
const message = safe.test(error.message || '') ? error.message : 'Quota unavailable; check your login and network connection';
|
|
181
|
+
return { provider, windows: [], error: message };
|
|
182
|
+
}
|
|
183
|
+
}));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function renderQuota(quotas) {
|
|
187
|
+
if (!quotas) return 'Loading quota information…';
|
|
188
|
+
return quotas.map(({ provider, windows, error, email, plan }) => {
|
|
189
|
+
const name = provider === 'claude' ? 'Claude' : 'Codex';
|
|
190
|
+
const lines = error ? [error] : windows.length ? windows.map((w) => `${w.label}: ${w.usedPercent}% used${w.used != null && w.limit != null ? ` · ${w.currency} ${w.used.toFixed(2)} / ${w.limit.toFixed(2)}` : ''}${w.resetAt ? ` · resets ${w.resetAt}` : ''}`) : ['No quota windows available'];
|
|
191
|
+
const identity = [email, plan].filter(Boolean).join(' · ');
|
|
192
|
+
return `${name}${identity ? ` (${identity})` : ''}\n${lines.map((line) => ` ${line}`).join('\n')}`;
|
|
193
|
+
}).join('\n\n').replace(/[\x00-\x09\x0b-\x1f\x7f-\x9f]/g, '');
|
|
194
|
+
}
|