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/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 files = walkJsonl(rootDir, "rollout-");
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
- return { records: results.filter(Boolean), errors };
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 lastUsage = null;
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 u = info.total_token_usage;
202
- if (u) lastUsage = u;
203
- const m = info.model || p.model;
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 || !lastUsage) return null;
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
- const tokensInput = lastUsage.input_tokens || 0;
214
- const tokensOutput = lastUsage.output_tokens || 0;
215
- const tokensCacheRead = lastUsage.cached_input_tokens || 0;
216
- const tokensReasoning = lastUsage.reasoning_output_tokens || 0;
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: path.split("-").pop().replace(".jsonl", ""),
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
- cost: 0,
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
- // dedupe by `message.id` (Claude Code emits streaming duplicates) and sum
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 seenIds = new Set();
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
- if (id) {
351
- if (seenIds.has(id)) continue;
352
- seenIds.add(id);
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
- cost: 0,
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" || d.key === "mimocode") {
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
- if (tot.cost > 0) {
135
- out.push(`**Cost**: ${fmtCost(tot.cost)} (opencode only) `);
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 ? "$" + r.cost.toFixed(4) : "—"} | ${mdEscape(shortModel(r.model))} | \`${mdEscape(proj)}\` | ${mdEscape(r.title || "—")} |`,
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 (OpenCode only)`,
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
+ }