ai-usage-analyzer 0.4.0 → 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 +22 -14
- package/package.json +8 -6
- package/src/aggregate.js +7 -0
- package/src/live.js +132 -0
- package/src/loaders.js +89 -23
- package/src/markdown.js +12 -10
- package/src/pricing.js +49 -0
- package/src/quota.js +194 -0
- package/src/render.js +17 -12
- package/src/server.js +67 -0
- package/src/tools.js +5 -5
- package/src/tui.js +24 -8
- 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
|
@@ -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,8 @@ export function renderMarkdown({
|
|
|
131
132
|
out.push(`**Range**: ${dateRange[0] ?? "—"} → ${dateRange[1] ?? "—"} `);
|
|
132
133
|
out.push(`**Sessions**: ${fmtInt(records.length)} `);
|
|
133
134
|
out.push(`**Total tokens**: ${fmtCompact(tot.tokensTotal)} `);
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
135
|
+
out.push(`**Cost**: ${fmtCost(tot.cost, tot.costStatus)} `);
|
|
136
|
+
if (tot.unknownModels.length) out.push(`**Unpriced models**: ${tot.unknownModels.map(mdEscape).join(", ")} `);
|
|
137
137
|
} else {
|
|
138
138
|
out.push(`**No session data available** — only detection status below.`);
|
|
139
139
|
}
|
|
@@ -174,11 +174,11 @@ export function renderMarkdown({
|
|
|
174
174
|
out.push(`|---|---:|---:|---:|---:|`);
|
|
175
175
|
for (const t of tools) {
|
|
176
176
|
out.push(
|
|
177
|
-
`| ${mdEscape(t.tool)} | ${fmtInt(t.n)} | ${fmtCompact(t.tokensTotal)} | ${fmtCompact(t.avg)} | ${fmtCost(t.cost)} |`,
|
|
177
|
+
`| ${mdEscape(t.tool)} | ${fmtInt(t.n)} | ${fmtCompact(t.tokensTotal)} | ${fmtCompact(t.avg)} | ${fmtCost(t.cost, t.costStatus)} |`,
|
|
178
178
|
);
|
|
179
179
|
}
|
|
180
180
|
out.push(
|
|
181
|
-
`| **TOTAL** | **${fmtInt(records.length)}** | **${fmtCompact(tot.tokensTotal)}** | **${fmtCompact(tot.avg)}** | **${fmtCost(tot.cost)}** |`,
|
|
181
|
+
`| **TOTAL** | **${fmtInt(records.length)}** | **${fmtCompact(tot.tokensTotal)}** | **${fmtCompact(tot.avg)}** | **${fmtCost(tot.cost, tot.costStatus)}** |`,
|
|
182
182
|
);
|
|
183
183
|
out.push(``);
|
|
184
184
|
|
|
@@ -245,7 +245,7 @@ export function renderMarkdown({
|
|
|
245
245
|
for (const p of toolRows) {
|
|
246
246
|
const cache = p.tokensCacheRead + p.tokensCacheWrite;
|
|
247
247
|
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)} |`,
|
|
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
249
|
);
|
|
250
250
|
}
|
|
251
251
|
out.push(``);
|
|
@@ -316,7 +316,7 @@ export function renderMarkdown({
|
|
|
316
316
|
top.forEach((r, i) => {
|
|
317
317
|
const proj = compactHome(r.project);
|
|
318
318
|
out.push(
|
|
319
|
-
`| ${i + 1} | ${fmtCompact(r.tokensTotal)} | ${fmtCompact(r.tokensInput)} | ${fmtCompact(r.tokensOutput)} | ${r.cost
|
|
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
320
|
);
|
|
321
321
|
});
|
|
322
322
|
out.push(``);
|
|
@@ -325,6 +325,8 @@ export function renderMarkdown({
|
|
|
325
325
|
// Notes ---------------------------------------------------------------
|
|
326
326
|
out.push(`## Notes`);
|
|
327
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(``);
|
|
328
330
|
out.push(`### Token Definitions`);
|
|
329
331
|
out.push(``);
|
|
330
332
|
out.push(`- **Input** — prompt tokens sent to the model`);
|
|
@@ -333,7 +335,7 @@ export function renderMarkdown({
|
|
|
333
335
|
`- **Cache Read** — prompt tokens served from the provider's cache (cheap)`,
|
|
334
336
|
);
|
|
335
337
|
out.push(
|
|
336
|
-
`- **Cache Write** — prompt tokens cached for future use
|
|
338
|
+
`- **Cache Write** — prompt tokens cached for future use`,
|
|
337
339
|
);
|
|
338
340
|
out.push(`- **Reasoning** — extended thinking / chain-of-thought tokens`);
|
|
339
341
|
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
|
+
}
|
package/src/render.js
CHANGED
|
@@ -72,9 +72,10 @@ export function fmtCompact(n) {
|
|
|
72
72
|
return String(n);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
export function fmtCost(n) {
|
|
75
|
+
export function fmtCost(n, status) {
|
|
76
|
+
if (status === "unavailable") return "n/a";
|
|
76
77
|
if (!n) return "—";
|
|
77
|
-
return "$" + Number(n).toFixed(2);
|
|
78
|
+
return (status === "estimated" || status === "partial" ? "~" : "") + "$" + Number(n).toFixed(2) + (status === "partial" ? "*" : "");
|
|
78
79
|
}
|
|
79
80
|
function fmtDuration(ms) {
|
|
80
81
|
if (!ms || ms <= 0) return chalk.dim("—");
|
|
@@ -175,6 +176,7 @@ export function renderHeader({
|
|
|
175
176
|
totalSessions,
|
|
176
177
|
totalTokens,
|
|
177
178
|
totalCost,
|
|
179
|
+
costStatus,
|
|
178
180
|
dateRange,
|
|
179
181
|
}) {
|
|
180
182
|
const title = gradient.pastel.multiline("◆ AI TOKEN ANALYZER ◆");
|
|
@@ -183,7 +185,7 @@ export function renderHeader({
|
|
|
183
185
|
" • " +
|
|
184
186
|
chalk.bold.cyan(`${fmtCompact(totalTokens)} total tokens`) +
|
|
185
187
|
(totalCost
|
|
186
|
-
? " • " + chalk.bold.yellow(
|
|
188
|
+
? " • " + chalk.bold.yellow(`${fmtCost(totalCost, costStatus)} USD`)
|
|
187
189
|
: "");
|
|
188
190
|
const range =
|
|
189
191
|
dateRange[0] && dateRange[1]
|
|
@@ -338,7 +340,7 @@ export function renderOverview(records, detections) {
|
|
|
338
340
|
fmtInt(p.n),
|
|
339
341
|
fmtCompact(p.tokensTotal),
|
|
340
342
|
fmtCompact(p.avg),
|
|
341
|
-
fmtCost(p.cost),
|
|
343
|
+
fmtCost(p.cost, p.costStatus),
|
|
342
344
|
]);
|
|
343
345
|
}
|
|
344
346
|
pt.push([
|
|
@@ -346,7 +348,7 @@ export function renderOverview(records, detections) {
|
|
|
346
348
|
chalk.bold(fmtInt(records.length)),
|
|
347
349
|
chalk.bold(fmtCompact(t.tokensTotal)),
|
|
348
350
|
chalk.bold(fmtCompact(t.avg)),
|
|
349
|
-
chalk.bold(fmtCost(t.cost)),
|
|
351
|
+
chalk.bold(fmtCost(t.cost, t.costStatus)),
|
|
350
352
|
]);
|
|
351
353
|
|
|
352
354
|
const body =
|
|
@@ -354,7 +356,9 @@ export function renderOverview(records, detections) {
|
|
|
354
356
|
"\n\n" +
|
|
355
357
|
chalk.bold.underline("Token Breakdown") +
|
|
356
358
|
"\n" +
|
|
357
|
-
bt.toString()
|
|
359
|
+
bt.toString() +
|
|
360
|
+
"\n\n" + chalk.dim("~ estimated API cost; * partial; n/a unavailable") +
|
|
361
|
+
(t.unknownModels.length ? "\n" + chalk.dim(`Unpriced models: ${t.unknownModels.map(m => String(m).replace(/[\x00-\x1f\x7f-\x9f]/g, "")).join(", ")}`) : "");
|
|
358
362
|
return boxen(body, {
|
|
359
363
|
title: chalk.bold("Overview"),
|
|
360
364
|
borderStyle: "round",
|
|
@@ -526,7 +530,7 @@ export function renderProjectSessions(records, projectName) {
|
|
|
526
530
|
colorize(r.tool, toolColor(r.tool)),
|
|
527
531
|
dur,
|
|
528
532
|
fmtCompact(r.tokensTotal),
|
|
529
|
-
r.cost
|
|
533
|
+
fmtCost(r.cost, r.costStatus),
|
|
530
534
|
titleStr,
|
|
531
535
|
]);
|
|
532
536
|
} else {
|
|
@@ -539,7 +543,7 @@ export function renderProjectSessions(records, projectName) {
|
|
|
539
543
|
fmtCompact(r.tokensInput),
|
|
540
544
|
fmtCompact(r.tokensOutput),
|
|
541
545
|
fmtCompact(cache),
|
|
542
|
-
r.cost
|
|
546
|
+
fmtCost(r.cost, r.costStatus),
|
|
543
547
|
titleStr,
|
|
544
548
|
]);
|
|
545
549
|
}
|
|
@@ -600,7 +604,7 @@ export function renderPerTool(records) {
|
|
|
600
604
|
}
|
|
601
605
|
row.push(
|
|
602
606
|
fmtCompact(p.tokensTotal),
|
|
603
|
-
fmtCost(p.cost),
|
|
607
|
+
fmtCost(p.cost, p.costStatus),
|
|
604
608
|
fmtCompact(p.avg),
|
|
605
609
|
bar(p.tokensTotal, max, barW),
|
|
606
610
|
);
|
|
@@ -796,7 +800,7 @@ export function renderTopSessions(records, n = 5) {
|
|
|
796
800
|
chalk.bold(String(i + 1)),
|
|
797
801
|
chalk.bold.yellow(fmtCompact(r.tokensTotal)),
|
|
798
802
|
`${fmtCompact(r.tokensInput)}/${fmtCompact(r.tokensOutput)}`,
|
|
799
|
-
r.cost
|
|
803
|
+
fmtCost(r.cost, r.costStatus),
|
|
800
804
|
truncEnd(r.title, 30) || "—",
|
|
801
805
|
]);
|
|
802
806
|
});
|
|
@@ -830,7 +834,7 @@ export function renderTopSessions(records, n = 5) {
|
|
|
830
834
|
chalk.bold.yellow(fmtCompact(r.tokensTotal)),
|
|
831
835
|
fmtCompact(r.tokensInput),
|
|
832
836
|
fmtCompact(r.tokensOutput),
|
|
833
|
-
r.cost
|
|
837
|
+
fmtCost(r.cost, r.costStatus),
|
|
834
838
|
shortModel(r.model),
|
|
835
839
|
truncEnd(r.project, 30),
|
|
836
840
|
truncEnd(r.title, 40) || "—",
|
|
@@ -850,6 +854,7 @@ export function renderTopSessions(records, n = 5) {
|
|
|
850
854
|
|
|
851
855
|
export function renderNotes(detections, errors) {
|
|
852
856
|
const lines = [];
|
|
857
|
+
lines.push(chalk.dim("Costs: ~ estimated API rates; * partial, excludes unpriced models; n/a unavailable."));
|
|
853
858
|
lines.push(chalk.bold.underline("Token definitions"));
|
|
854
859
|
lines.push(
|
|
855
860
|
` • ${chalk.cyan("Input")} — prompt tokens (what was sent to the model)`,
|
|
@@ -861,7 +866,7 @@ export function renderNotes(detections, errors) {
|
|
|
861
866
|
` • ${chalk.cyan("Cache Read")} — prompt tokens served from provider cache (cheap)`,
|
|
862
867
|
);
|
|
863
868
|
lines.push(
|
|
864
|
-
` • ${chalk.cyan("Cache Write")} — prompt tokens cached for future use
|
|
869
|
+
` • ${chalk.cyan("Cache Write")} — prompt tokens cached for future use`,
|
|
865
870
|
);
|
|
866
871
|
lines.push(
|
|
867
872
|
` • ${chalk.yellow("Reasoning")} — extended thinking / chain-of-thought tokens`,
|
package/src/server.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { detectAll } from './detectors.js';
|
|
5
|
+
import { loadAll } from './loaders.js';
|
|
6
|
+
|
|
7
|
+
const assets = new Map([
|
|
8
|
+
['/', ['../web/index.html', 'text/html; charset=utf-8']],
|
|
9
|
+
['/app.js', ['../web/app.js', 'text/javascript; charset=utf-8']],
|
|
10
|
+
['/aggregate.js', ['./aggregate.js', 'text/javascript; charset=utf-8']],
|
|
11
|
+
['/style.css', ['../web/style.css', 'text/css; charset=utf-8']],
|
|
12
|
+
['/favicon.svg', ['../web/favicon.svg', 'image/svg+xml']],
|
|
13
|
+
['/gsap.js', [import.meta.resolve('gsap/dist/gsap.min.js'), 'text/javascript; charset=utf-8']],
|
|
14
|
+
['/fonts/manrope.woff2', ['../web/fonts/manrope.woff2', 'font/woff2']],
|
|
15
|
+
['/icons/claude.svg', ['../web/icons/claude.svg', 'image/svg+xml']],
|
|
16
|
+
['/icons/openai.svg', ['../web/icons/openai.svg', 'image/svg+xml']],
|
|
17
|
+
['/icons/opencode.svg', ['../web/icons/opencode.svg', 'image/svg+xml']],
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
export function createDashboardServer() {
|
|
21
|
+
return createServer(async (req, res) => {
|
|
22
|
+
const host = req.headers.host || '';
|
|
23
|
+
if (!/^127\.0\.0\.1:\d+$/.test(host) || (req.headers.origin && req.headers.origin !== `http://${host}`) || req.headers['sec-fetch-site'] === 'cross-site') {
|
|
24
|
+
res.writeHead(403).end('Forbidden');
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
28
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
29
|
+
res.setHeader('Content-Security-Policy', "default-src 'self'; style-src 'self'; script-src 'self'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'");
|
|
30
|
+
if (req.method !== 'GET') {
|
|
31
|
+
res.writeHead(405, { Allow: 'GET' }).end();
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
const path = new URL(req.url, `http://${host}`).pathname;
|
|
36
|
+
if (path === '/api/usage') {
|
|
37
|
+
const detections = detectAll();
|
|
38
|
+
const { records, errors } = await loadAll(detections);
|
|
39
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }).end(JSON.stringify({ detections, records, errors, generatedAt: new Date().toISOString() }));
|
|
40
|
+
} else if (assets.has(path)) {
|
|
41
|
+
const [file, type] = assets.get(path);
|
|
42
|
+
const content = await readFile(new URL(file, import.meta.url));
|
|
43
|
+
res.writeHead(200, { 'Content-Type': type }).end(content);
|
|
44
|
+
} else {
|
|
45
|
+
res.writeHead(404).end('Not found');
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
res.writeHead(500, { 'Content-Type': 'application/json' }).end(JSON.stringify({ error: 'Could not load local usage data.' }));
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function startDashboard() {
|
|
54
|
+
const server = createDashboardServer();
|
|
55
|
+
await new Promise((resolve, reject) => {
|
|
56
|
+
server.once('error', reject);
|
|
57
|
+
server.listen(0, '127.0.0.1', resolve);
|
|
58
|
+
});
|
|
59
|
+
const url = `http://127.0.0.1:${server.address().port}/`;
|
|
60
|
+
console.log(`AI Usage Analyzer is running at ${url}\nPress Ctrl+C to stop.`);
|
|
61
|
+
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
62
|
+
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
|
63
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore' });
|
|
64
|
+
child.on('error', () => {});
|
|
65
|
+
child.unref();
|
|
66
|
+
return server;
|
|
67
|
+
}
|
package/src/tools.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// data, also wire a loader branch in src/loaders.js.
|
|
9
9
|
|
|
10
10
|
import { statSync, readdirSync, existsSync } from 'node:fs';
|
|
11
|
-
import { join } from 'node:path';
|
|
11
|
+
import { basename, dirname, join } from 'node:path';
|
|
12
12
|
import { homedir, platform } from 'node:os';
|
|
13
13
|
import { env } from 'node:process';
|
|
14
14
|
import { createRequire } from 'node:module';
|
|
@@ -112,10 +112,10 @@ export const TOOLS = [
|
|
|
112
112
|
barChar: '▓',
|
|
113
113
|
kind: 'jsonl-rollout',
|
|
114
114
|
envVar: 'CODEX_HOME',
|
|
115
|
-
candidatePaths: () =>
|
|
116
|
-
env.CODEX_HOME
|
|
117
|
-
|
|
118
|
-
count: countJsonlRecursive,
|
|
115
|
+
candidatePaths: () => env.CODEX_HOME
|
|
116
|
+
? [env.CODEX_HOME]
|
|
117
|
+
: [join(HOME, '.codex', 'sessions'), join(HOME, '.codex', 'archived_sessions')],
|
|
118
|
+
count: path => countJsonlRecursive(path) + (basename(path) === 'sessions' ? countJsonlRecursive(join(dirname(path), 'archived_sessions')) : 0),
|
|
119
119
|
hasTokens: true,
|
|
120
120
|
description: '~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl',
|
|
121
121
|
},
|
package/src/tui.js
CHANGED
|
@@ -5,6 +5,7 @@ import chalk from "chalk";
|
|
|
5
5
|
import process from "node:process";
|
|
6
6
|
import readline from "node:readline";
|
|
7
7
|
import { perProject } from "./aggregate.js";
|
|
8
|
+
import { renderQuota } from "./quota.js";
|
|
8
9
|
import {
|
|
9
10
|
CURSOR_MARK,
|
|
10
11
|
renderDetections,
|
|
@@ -25,11 +26,14 @@ function visLen(s) {
|
|
|
25
26
|
return s.replace(ANSI_RE, "").length;
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
export async function runInteractive({ records, detections, errors, topN }) {
|
|
29
|
+
export async function runInteractive({ records, detections, errors, topN, quotaPromise, quotaController, liveTailer }) {
|
|
29
30
|
if (!process.stdout.isTTY || !process.stdin.isTTY) return false;
|
|
30
31
|
|
|
31
32
|
const PROJECTS_VIEW = 1; // index into VIEWS
|
|
32
33
|
|
|
34
|
+
let liveRate = liveTailer ? liveTailer.tick() : null;
|
|
35
|
+
let quotas = null;
|
|
36
|
+
let active = true;
|
|
33
37
|
const VIEWS = [
|
|
34
38
|
{
|
|
35
39
|
key: "1",
|
|
@@ -53,6 +57,7 @@ export async function runInteractive({ records, detections, errors, topN }) {
|
|
|
53
57
|
},
|
|
54
58
|
{ key: "8", label: "Detections", fn: () => renderDetections(detections) },
|
|
55
59
|
{ key: "9", label: "Notes", fn: () => renderNotes(detections, errors) },
|
|
60
|
+
{ key: "0", label: "Quota", fn: () => renderQuota(quotas) },
|
|
56
61
|
];
|
|
57
62
|
|
|
58
63
|
// Project drill-down state (only active when current === PROJECTS_VIEW).
|
|
@@ -128,6 +133,7 @@ export async function runInteractive({ records, detections, errors, topN }) {
|
|
|
128
133
|
" ←/→ h/l switch ↑/↓ j/k scroll PgUp/PgDn g/G top/bot q quit",
|
|
129
134
|
);
|
|
130
135
|
}
|
|
136
|
+
if (liveRate !== null) hint = chalk.dim(` ${Math.round(liveRate)} tokens/min (10m) `) + hint;
|
|
131
137
|
const gap = Math.max(1, w - visLen(hint) - visLen(pct));
|
|
132
138
|
return chalk.bgBlack(hint + " ".repeat(gap) + pct);
|
|
133
139
|
}
|
|
@@ -195,20 +201,29 @@ export async function runInteractive({ records, detections, errors, topN }) {
|
|
|
195
201
|
process.stdout.write("\x1b[?1049h\x1b[?25l");
|
|
196
202
|
draw(true);
|
|
197
203
|
|
|
198
|
-
|
|
199
|
-
|
|
204
|
+
const liveTimer = liveTailer ? setInterval(() => { liveRate = liveTailer.tick(); if (active) draw(false); }, 5000) : null;
|
|
205
|
+
const onResize = () => draw(true);
|
|
206
|
+
process.stdout.on("resize", onResize);
|
|
207
|
+
if (quotaPromise) quotaPromise.then((result) => {
|
|
208
|
+
quotas = result;
|
|
209
|
+
if (active && current === 9) { loadView(); draw(false); }
|
|
200
210
|
});
|
|
201
211
|
|
|
202
212
|
return new Promise((resolve) => {
|
|
203
213
|
function quit() {
|
|
204
214
|
// Leave alternate screen — original terminal content is restored automatically.
|
|
215
|
+
active = false;
|
|
216
|
+
if (liveTimer) clearInterval(liveTimer);
|
|
217
|
+
quotaController?.abort();
|
|
218
|
+
process.stdout.removeListener("resize", onResize);
|
|
219
|
+
process.stdin.removeListener("keypress", onKeypress);
|
|
205
220
|
process.stdout.write("\x1b[?25h\x1b[?1049l");
|
|
206
221
|
process.stdin.setRawMode(false);
|
|
207
222
|
process.stdin.pause();
|
|
208
|
-
resolve();
|
|
223
|
+
resolve(true);
|
|
209
224
|
}
|
|
210
225
|
|
|
211
|
-
|
|
226
|
+
function onKeypress(_str, key) {
|
|
212
227
|
if (!key) return;
|
|
213
228
|
const usable = usableRows();
|
|
214
229
|
const maxScroll = Math.max(0, lines.length - usable);
|
|
@@ -270,8 +285,8 @@ export async function runInteractive({ records, detections, errors, topN }) {
|
|
|
270
285
|
draw(false);
|
|
271
286
|
return;
|
|
272
287
|
}
|
|
273
|
-
if (_str && /^[
|
|
274
|
-
const idx = parseInt(_str, 10) - 1;
|
|
288
|
+
if (_str && /^[0-9]$/.test(_str)) {
|
|
289
|
+
const idx = _str === "0" ? 9 : parseInt(_str, 10) - 1;
|
|
275
290
|
if (idx < VIEWS.length) {
|
|
276
291
|
current = idx;
|
|
277
292
|
if (current === PROJECTS_VIEW) proj.mode = "list";
|
|
@@ -305,6 +320,7 @@ export async function runInteractive({ records, detections, errors, topN }) {
|
|
|
305
320
|
}
|
|
306
321
|
|
|
307
322
|
if (moved) draw(false);
|
|
308
|
-
}
|
|
323
|
+
}
|
|
324
|
+
process.stdin.on("keypress", onKeypress);
|
|
309
325
|
});
|
|
310
326
|
}
|