ai-usage-analyzer 0.3.2 → 0.5.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/src/markdown.js CHANGED
@@ -3,43 +3,51 @@
3
3
  // copies cleanly into issues, PRs, Notion, etc.
4
4
 
5
5
  import {
6
- perProject, perMonth, perWeek, perTool, perToolPerMonth, overall, topSessions, tokenBreakdown,
7
- MONTH_NAMES,
8
- } from './aggregate.js';
9
- import { getToolBarChar, TOOLS } from './tools.js';
6
+ MONTH_NAMES,
7
+ overall,
8
+ perMonth,
9
+ perProject,
10
+ perTool,
11
+ perToolPerMonth,
12
+ perWeek,
13
+ tokenBreakdown,
14
+ topSessions,
15
+ } from "./aggregate.js";
16
+ import { getToolBarChar, TOOLS } from "./tools.js";
10
17
 
11
18
  // ---------------------------------------------------------------------------
12
19
  // Number formatting helpers (re-implemented locally to avoid TUI deps)
13
20
  // ---------------------------------------------------------------------------
14
21
 
15
22
  export function fmtInt(n) {
16
- return Number(n || 0).toLocaleString('en-US');
23
+ return Number(n || 0).toLocaleString("en-US");
17
24
  }
18
25
 
19
26
  export function fmtCompact(n) {
20
27
  const a = Math.abs(n);
21
- if (a >= 1e9) return (n / 1e9).toFixed(2) + 'B';
22
- if (a >= 1e6) return (n / 1e6).toFixed(2) + 'M';
23
- if (a >= 1e3) return (n / 1e3).toFixed(1) + 'K';
28
+ if (a >= 1e9) return (n / 1e9).toFixed(2) + "B";
29
+ if (a >= 1e6) return (n / 1e6).toFixed(2) + "M";
30
+ if (a >= 1e3) return (n / 1e3).toFixed(1) + "K";
24
31
  return String(n || 0);
25
32
  }
26
33
 
27
- function fmtCost(n) {
28
- if (!n) return '—';
29
- return '$' + Number(n).toFixed(2);
34
+ function fmtCost(n, status) {
35
+ if (status === "unavailable") return "unavailable";
36
+ if (!n) return "—";
37
+ return (status === "estimated" || status === "partial" ? "~" : "") + "$" + Number(n).toFixed(2) + (status === "partial" ? " (partial)" : "");
30
38
  }
31
39
 
32
40
  function compactHome(p) {
33
- if (!p) return '—';
34
- const home = process.env.HOME || '';
35
- return p.startsWith(home) ? '~' + p.slice(home.length) : p;
41
+ if (!p) return "—";
42
+ const home = process.env.HOME || "";
43
+ return p.startsWith(home) ? "~" + p.slice(home.length) : p;
36
44
  }
37
45
 
38
46
  function bar(value, max, width) {
39
- if (!max || max <= 0) return '░'.repeat(width);
47
+ if (!max || max <= 0) return "░".repeat(width);
40
48
  const pct = Math.max(0, Math.min(1, value / max));
41
49
  const filled = Math.round(pct * width);
42
- return '█'.repeat(filled) + '░'.repeat(width - filled);
50
+ return "█".repeat(filled) + "░".repeat(width - filled);
43
51
  }
44
52
 
45
53
  // Markdown has no color, so each tool's stacked-bar segment is shown with
@@ -47,17 +55,18 @@ function bar(value, max, width) {
47
55
  // src/tools.js alongside its color.
48
56
  function stackedBar(byTool, width) {
49
57
  const total = Object.values(byTool).reduce((a, b) => a + b, 0);
50
- if (total <= 0 || width <= 0) return ' '.repeat(Math.max(0, width));
58
+ if (total <= 0 || width <= 0) return " ".repeat(Math.max(0, width));
51
59
  const entries = Object.entries(byTool)
52
60
  .filter(([, v]) => v > 0)
53
61
  .sort((a, b) => b[1] - a[1]);
54
- let out = '';
62
+ let out = "";
55
63
  let x = 0;
56
64
  for (let i = 0; i < entries.length; i++) {
57
65
  const [tool, value] = entries[i];
58
- const segW = i === entries.length - 1
59
- ? width - x
60
- : Math.round((value / total) * width);
66
+ const segW =
67
+ i === entries.length - 1
68
+ ? width - x
69
+ : Math.round((value / total) * width);
61
70
  if (segW <= 0) continue;
62
71
  out += getToolBarChar(tool).repeat(segW);
63
72
  x += segW;
@@ -65,28 +74,32 @@ function stackedBar(byTool, width) {
65
74
  return out;
66
75
  }
67
76
 
68
- function pct(x) { return (x * 100).toFixed(1) + '%'; }
77
+ function pct(x) {
78
+ return (x * 100).toFixed(1) + "%";
79
+ }
69
80
 
70
81
  function mdEscape(s) {
71
- if (s == null) return '';
72
- return String(s).replace(/\|/g, '\\|').replace(/\n/g, ' ');
82
+ if (s == null) return "";
83
+ return String(s).replace(/\|/g, "\\|").replace(/\n/g, " ");
73
84
  }
74
85
 
75
86
  function shortModel(m) {
76
- if (!m) return '—';
77
- if (typeof m === 'string' && m.trim().startsWith('{')) {
87
+ if (!m) return "—";
88
+ if (typeof m === "string" && m.trim().startsWith("{")) {
78
89
  try {
79
90
  const d = JSON.parse(m);
80
91
  const id = d.id || d.model || m;
81
92
  const prov = d.providerId || d.provider;
82
- return id + (prov ? ` (${prov})` : '');
83
- } catch { return m.slice(0, 30); }
93
+ return id + (prov ? ` (${prov})` : "");
94
+ } catch {
95
+ return m.slice(0, 30);
96
+ }
84
97
  }
85
98
  return m;
86
99
  }
87
100
 
88
101
  function today() {
89
- return new Date().toISOString().split('T')[0];
102
+ return new Date().toISOString().split("T")[0];
90
103
  }
91
104
 
92
105
  // ---------------------------------------------------------------------------
@@ -94,8 +107,12 @@ function today() {
94
107
  // ---------------------------------------------------------------------------
95
108
 
96
109
  export function renderMarkdown({
97
- records = [], detections = [], errors = [],
98
- dateRange = [null, null], topN = 5, generatedAt = null,
110
+ records = [],
111
+ detections = [],
112
+ errors = [],
113
+ dateRange = [null, null],
114
+ topN = 5,
115
+ generatedAt = null,
99
116
  }) {
100
117
  const out = [];
101
118
  const tot = overall(records);
@@ -106,16 +123,17 @@ export function renderMarkdown({
106
123
  // Header --------------------------------------------------------------
107
124
  out.push(`# AI Token Usage Report`);
108
125
  out.push(``);
109
- out.push(`> Generated by [\`ai-usage-analyzer\`](https://github.com/adetxt/ai-usage-analyzer) on ${generatedAt ? generatedAt.split('T')[0] : today()}`);
126
+ out.push(
127
+ `> Generated by [\`ai-usage-analyzer\`](https://github.com/adetxt/ai-usage-analyzer) on ${generatedAt ? generatedAt.split("T")[0] : today()}`,
128
+ );
110
129
  out.push(``);
111
130
 
112
131
  if (hasData) {
113
- out.push(`**Range**: ${dateRange[0] ?? '—'} → ${dateRange[1] ?? '—'} `);
132
+ out.push(`**Range**: ${dateRange[0] ?? "—"} → ${dateRange[1] ?? "—"} `);
114
133
  out.push(`**Sessions**: ${fmtInt(records.length)} `);
115
134
  out.push(`**Total tokens**: ${fmtCompact(tot.tokensTotal)} `);
116
- if (tot.cost > 0) {
117
- out.push(`**Cost**: ${fmtCost(tot.cost)} (opencode only) `);
118
- }
135
+ out.push(`**Cost**: ${fmtCost(tot.cost, tot.costStatus)} `);
136
+ if (tot.unknownModels.length) out.push(`**Unpriced models**: ${tot.unknownModels.map(mdEscape).join(", ")} `);
119
137
  } else {
120
138
  out.push(`**No session data available** — only detection status below.`);
121
139
  }
@@ -127,13 +145,13 @@ export function renderMarkdown({
127
145
  out.push(`| Tool | Status | Path | Count | Tokens |`);
128
146
  out.push(`|---|---|---|---:|---|`);
129
147
  for (const d of detections) {
130
- const status = d.status === 'present' ? '✅ present' : '❌ absent';
131
- const path = d.path ? '`' + mdEscape(compactHome(d.path)) + '`' : '—';
132
- const count = d.count ? fmtInt(d.count) : '—';
133
- const tok = d.hasTokens
134
- ? (d.count ? '✅' : '—')
135
- : 'n/a';
136
- out.push(`| ${mdEscape(d.name)} | ${status} | ${path} | ${count} | ${tok} |`);
148
+ const status = d.status === "present" ? "✅ present" : "❌ absent";
149
+ const path = d.path ? "`" + mdEscape(compactHome(d.path)) + "`" : "—";
150
+ const count = d.count ? fmtInt(d.count) : "—";
151
+ const tok = d.hasTokens ? (d.count ? "✅" : "—") : "n/a";
152
+ out.push(
153
+ `| ${mdEscape(d.name)} | ${status} | ${path} | ${count} | ${tok} |`,
154
+ );
137
155
  }
138
156
  out.push(``);
139
157
 
@@ -144,7 +162,7 @@ export function renderMarkdown({
144
162
  for (const e of errors) out.push(`- ${mdEscape(e)}`);
145
163
  out.push(``);
146
164
  }
147
- return out.join('\n');
165
+ return out.join("\n");
148
166
  }
149
167
 
150
168
  // Overview ------------------------------------------------------------
@@ -155,20 +173,34 @@ export function renderMarkdown({
155
173
  out.push(`| Tool | Sessions | Total | Avg/sess | Cost |`);
156
174
  out.push(`|---|---:|---:|---:|---:|`);
157
175
  for (const t of tools) {
158
- out.push(`| ${mdEscape(t.tool)} | ${fmtInt(t.n)} | ${fmtCompact(t.tokensTotal)} | ${fmtCompact(t.avg)} | ${fmtCost(t.cost)} |`);
176
+ out.push(
177
+ `| ${mdEscape(t.tool)} | ${fmtInt(t.n)} | ${fmtCompact(t.tokensTotal)} | ${fmtCompact(t.avg)} | ${fmtCost(t.cost, t.costStatus)} |`,
178
+ );
159
179
  }
160
- out.push(`| **TOTAL** | **${fmtInt(records.length)}** | **${fmtCompact(tot.tokensTotal)}** | **${fmtCompact(tot.avg)}** | **${fmtCost(tot.cost)}** |`);
180
+ out.push(
181
+ `| **TOTAL** | **${fmtInt(records.length)}** | **${fmtCompact(tot.tokensTotal)}** | **${fmtCompact(tot.avg)}** | **${fmtCost(tot.cost, tot.costStatus)}** |`,
182
+ );
161
183
  out.push(``);
162
184
 
163
185
  out.push(`### Token Breakdown`);
164
186
  out.push(``);
165
187
  out.push(`| Type | Tokens | Share |`);
166
188
  out.push(`|---|---:|---:|`);
167
- out.push(`| Input | ${fmtCompact(breakdown.input)} | ${pct(breakdown.ratios.input)} |`);
168
- out.push(`| Output | ${fmtCompact(breakdown.output)} | ${pct(breakdown.ratios.output)} |`);
169
- out.push(`| Cache Read | ${fmtCompact(breakdown.cacheRead)} | ${pct(breakdown.ratios.cacheRead)} |`);
170
- out.push(`| Cache Write | ${fmtCompact(breakdown.cacheWrite)} | ${pct(breakdown.ratios.cacheWrite)} |`);
171
- out.push(`| Reasoning | ${fmtCompact(breakdown.reasoning)} | ${pct(breakdown.ratios.reasoning)} |`);
189
+ out.push(
190
+ `| Input | ${fmtCompact(breakdown.input)} | ${pct(breakdown.ratios.input)} |`,
191
+ );
192
+ out.push(
193
+ `| Output | ${fmtCompact(breakdown.output)} | ${pct(breakdown.ratios.output)} |`,
194
+ );
195
+ out.push(
196
+ `| Cache Read | ${fmtCompact(breakdown.cacheRead)} | ${pct(breakdown.ratios.cacheRead)} |`,
197
+ );
198
+ out.push(
199
+ `| Cache Write | ${fmtCompact(breakdown.cacheWrite)} | ${pct(breakdown.ratios.cacheWrite)} |`,
200
+ );
201
+ out.push(
202
+ `| Reasoning | ${fmtCompact(breakdown.reasoning)} | ${pct(breakdown.ratios.reasoning)} |`,
203
+ );
172
204
  out.push(`| **Total** | **${fmtCompact(breakdown.total)}** | **100.0%** |`);
173
205
  out.push(``);
174
206
 
@@ -177,18 +209,25 @@ export function renderMarkdown({
177
209
  if (projects.length > 0) {
178
210
  out.push(`## Per Project`);
179
211
  out.push(``);
180
- out.push(`| Project | Sessions | Input | Output | Cache | Total | Dist |`);
181
- out.push(`|---|---:|---:|---:|---:|---:|---|`);
212
+ out.push(
213
+ `| Project | Sessions | By Tool | Input | Output | Cache | Total | Dist |`,
214
+ );
215
+ out.push(`|---|---:|---|---:|---:|---:|---:|---|`);
182
216
  for (const p of projects) {
183
217
  const cache = p.tokensCacheRead + p.tokensCacheWrite;
184
218
  const proj = compactHome(p.project);
185
- out.push(`| \`${mdEscape(proj)}\` | ${fmtInt(p.n)} | ${fmtCompact(p.tokensInput)} | ${fmtCompact(p.tokensOutput)} | ${fmtCompact(cache)} | ${fmtCompact(p.tokensTotal)} | ${stackedBar(p.byTool, 20)} |`);
219
+ const nByToolStr = Object.entries(p.nByTool)
220
+ .sort((a, b) => b[1] - a[1])
221
+ .map(([tool, count]) => `${tool}×${count}`)
222
+ .join(" ");
223
+ out.push(
224
+ `| \`${mdEscape(proj)}\` | ${fmtInt(p.n)} | ${mdEscape(nByToolStr)} | ${fmtCompact(p.tokensInput)} | ${fmtCompact(p.tokensOutput)} | ${fmtCompact(cache)} | ${fmtCompact(p.tokensTotal)} | ${stackedBar(p.byTool, 20)} |`,
225
+ );
186
226
  }
187
227
  out.push(``);
188
- const legend = TOOLS
189
- .filter(t => t.hasTokens)
190
- .map(t => `\`${t.barChar}\` ${t.label}`)
191
- .join(' · ');
228
+ const legend = TOOLS.filter((t) => t.hasTokens)
229
+ .map((t) => `\`${t.barChar}\` ${t.label}`)
230
+ .join(" · ");
192
231
  out.push(`_Bar legend: ${legend} · \`·\` other_`);
193
232
  out.push(``);
194
233
  }
@@ -199,11 +238,15 @@ export function renderMarkdown({
199
238
  const max = toolRows[0].tokensTotal;
200
239
  out.push(`## Per Tool`);
201
240
  out.push(``);
202
- out.push(`| Tool | Sessions | Input | Output | Cache | Total | Cost | Avg/sess | Dist |`);
241
+ out.push(
242
+ `| Tool | Sessions | Input | Output | Cache | Total | Cost | Avg/sess | Dist |`,
243
+ );
203
244
  out.push(`|---|---:|---:|---:|---:|---:|---:|---:|---|`);
204
245
  for (const p of toolRows) {
205
246
  const cache = p.tokensCacheRead + p.tokensCacheWrite;
206
- out.push(`| ${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)} |`);
247
+ out.push(
248
+ `| ${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
+ );
207
250
  }
208
251
  out.push(``);
209
252
  }
@@ -214,14 +257,18 @@ export function renderMarkdown({
214
257
  const max = tpmRows[0].tokensTotal;
215
258
  out.push(`## Per Tool per Month`);
216
259
  out.push(``);
217
- out.push(`| Tool | Month | Sessions | Input | Output | Cache | Total | Dist |`);
260
+ out.push(
261
+ `| Tool | Month | Sessions | Input | Output | Cache | Total | Dist |`,
262
+ );
218
263
  out.push(`|---|---|---:|---:|---:|---:|---:|---|`);
219
264
  for (const p of tpmRows) {
220
265
  const cache = p.tokensCacheRead + p.tokensCacheWrite;
221
266
  const yyyy = p.month.slice(0, 4);
222
267
  const mm = p.month.slice(5, 7);
223
268
  const label = `${MONTH_NAMES[mm] || mm} ${yyyy}`;
224
- out.push(`| ${mdEscape(p.tool)} | ${label} | ${fmtInt(p.n)} | ${fmtCompact(p.tokensInput)} | ${fmtCompact(p.tokensOutput)} | ${fmtCompact(cache)} | ${fmtCompact(p.tokensTotal)} | ${bar(p.tokensTotal, max, 18)} |`);
269
+ out.push(
270
+ `| ${mdEscape(p.tool)} | ${label} | ${fmtInt(p.n)} | ${fmtCompact(p.tokensInput)} | ${fmtCompact(p.tokensOutput)} | ${fmtCompact(cache)} | ${fmtCompact(p.tokensTotal)} | ${bar(p.tokensTotal, max, 18)} |`,
271
+ );
225
272
  }
226
273
  out.push(``);
227
274
  }
@@ -237,7 +284,9 @@ export function renderMarkdown({
237
284
  const yyyy = m.month.slice(0, 4);
238
285
  const mm = m.month.slice(5, 7);
239
286
  const label = `${MONTH_NAMES[mm] || mm} ${yyyy}`;
240
- out.push(`| ${label} | ${fmtInt(m.n)} | ${fmtCompact(m.tokensInput)} | ${fmtCompact(m.tokensOutput)} | ${fmtCompact(m.tokensTotal)} | ${stackedBar(m.byTool, 20)} |`);
287
+ out.push(
288
+ `| ${label} | ${fmtInt(m.n)} | ${fmtCompact(m.tokensInput)} | ${fmtCompact(m.tokensOutput)} | ${fmtCompact(m.tokensTotal)} | ${stackedBar(m.byTool, 20)} |`,
289
+ );
241
290
  }
242
291
  out.push(``);
243
292
  }
@@ -250,7 +299,9 @@ export function renderMarkdown({
250
299
  out.push(`| Week | Sessions | Input | Output | Total | Dist |`);
251
300
  out.push(`|---|---:|---:|---:|---:|---|`);
252
301
  for (const w of weeks) {
253
- out.push(`| ${w.week} | ${fmtInt(w.n)} | ${fmtCompact(w.tokensInput)} | ${fmtCompact(w.tokensOutput)} | ${fmtCompact(w.tokensTotal)} | ${stackedBar(w.byTool, 18)} |`);
302
+ out.push(
303
+ `| ${w.week} | ${fmtInt(w.n)} | ${fmtCompact(w.tokensInput)} | ${fmtCompact(w.tokensOutput)} | ${fmtCompact(w.tokensTotal)} | ${stackedBar(w.byTool, 18)} |`,
304
+ );
254
305
  }
255
306
  out.push(``);
256
307
  }
@@ -264,7 +315,9 @@ export function renderMarkdown({
264
315
  out.push(`|---:|---:|---:|---:|---:|---|---|---|`);
265
316
  top.forEach((r, i) => {
266
317
  const proj = compactHome(r.project);
267
- out.push(`| ${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 || '—')} |`);
318
+ out.push(
319
+ `| ${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
+ );
268
321
  });
269
322
  out.push(``);
270
323
  }
@@ -272,16 +325,24 @@ export function renderMarkdown({
272
325
  // Notes ---------------------------------------------------------------
273
326
  out.push(`## Notes`);
274
327
  out.push(``);
328
+ 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.`);
329
+ out.push(``);
275
330
  out.push(`### Token Definitions`);
276
331
  out.push(``);
277
332
  out.push(`- **Input** — prompt tokens sent to the model`);
278
333
  out.push(`- **Output** — completion tokens generated by the model`);
279
- out.push(`- **Cache Read** — prompt tokens served from the provider's cache (cheap)`);
280
- out.push(`- **Cache Write** — prompt tokens cached for future use (OpenCode only)`);
334
+ out.push(
335
+ `- **Cache Read** — prompt tokens served from the provider's cache (cheap)`,
336
+ );
337
+ out.push(
338
+ `- **Cache Write** — prompt tokens cached for future use`,
339
+ );
281
340
  out.push(`- **Reasoning** — extended thinking / chain-of-thought tokens`);
282
341
  out.push(``);
283
342
 
284
- const withTokens = detections.filter(d => d.hasTokens && d.status === 'present');
343
+ const withTokens = detections.filter(
344
+ (d) => d.hasTokens && d.status === "present",
345
+ );
285
346
  if (withTokens.length > 0) {
286
347
  out.push(`### Detected Tools With Token Data`);
287
348
  out.push(``);
@@ -291,7 +352,9 @@ export function renderMarkdown({
291
352
  out.push(``);
292
353
  }
293
354
 
294
- const noTokens = detections.filter(d => !d.hasTokens && d.status === 'present');
355
+ const noTokens = detections.filter(
356
+ (d) => !d.hasTokens && d.status === "present",
357
+ );
295
358
  if (noTokens.length > 0) {
296
359
  out.push(`### Detected Tools Without Token Data`);
297
360
  out.push(``);
@@ -313,5 +376,5 @@ export function renderMarkdown({
313
376
  out.push(``);
314
377
  }
315
378
 
316
- return out.join('\n');
379
+ return out.join("\n");
317
380
  }
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
+ }