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/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
- if (tot.cost > 0) {
135
- out.push(`**Cost**: ${fmtCost(tot.cost)} (opencode only) `);
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 ? "$" + r.cost.toFixed(4) : "—"} | ${mdEscape(shortModel(r.model))} | \`${mdEscape(proj)}\` | ${mdEscape(r.title || "—")} |`,
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 (OpenCode only)`,
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(`$${totalCost.toFixed(2)} USD`)
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 ? "$" + r.cost.toFixed(4) : "—",
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 ? "$" + r.cost.toFixed(4) : "—",
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 ? "$" + r.cost.toFixed(4) : "—",
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 ? "$" + r.cost.toFixed(4) : "—",
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 (OpenCode only)`,
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 || join(HOME, '.codex', 'sessions'),
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
- process.stdout.on("resize", () => {
199
- draw(true);
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
- process.stdin.on("keypress", (_str, key) => {
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 && /^[1-9]$/.test(_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
  }