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/README.md +30 -15
- package/bin/ai-usage.js +66 -35
- package/package.json +8 -6
- package/src/aggregate.js +48 -21
- package/src/live.js +132 -0
- package/src/loaders.js +259 -114
- package/src/markdown.js +133 -70
- package/src/pricing.js +49 -0
- package/src/quota.js +194 -0
- package/src/render.js +512 -189
- package/src/server.js +67 -0
- package/src/tools.js +5 -5
- package/src/tui.js +326 -0
- package/web/app.js +952 -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 +199 -0
- package/web/style.css +324 -0
package/src/markdown.js
CHANGED
|
@@ -3,43 +3,51 @@
|
|
|
3
3
|
// copies cleanly into issues, PRs, Notion, etc.
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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(
|
|
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) +
|
|
22
|
-
if (a >= 1e6) return (n / 1e6).toFixed(2) +
|
|
23
|
-
if (a >= 1e3) return (n / 1e3).toFixed(1) +
|
|
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 (
|
|
29
|
-
|
|
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) ?
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
59
|
-
|
|
60
|
-
|
|
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) {
|
|
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,
|
|
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 ===
|
|
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 {
|
|
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(
|
|
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 = [],
|
|
98
|
-
|
|
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(
|
|
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] ??
|
|
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
|
-
|
|
117
|
-
|
|
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 ===
|
|
131
|
-
const path = d.path ?
|
|
132
|
-
const count = d.count ? fmtInt(d.count) :
|
|
133
|
-
const tok = d.hasTokens
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
out.push(
|
|
171
|
-
|
|
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(
|
|
181
|
-
|
|
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
|
-
|
|
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
|
-
.
|
|
190
|
-
.
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
280
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
+
}
|