@viraatdas/rudder 2.14.59 → 2.14.60
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 +14 -1
- package/dist/board/board.css +1 -1
- package/dist/board/board.js +2 -2
- package/dist/board/daemon.js +41 -3
- package/dist/board/daemon.js.map +1 -1
- package/dist/native/darwin-arm64/rudder-native +0 -0
- package/dist/native/darwin-x64/rudder-native +0 -0
- package/dist/native/linux-x64/rudder-native +0 -0
- package/dist/usage.d.ts +190 -0
- package/dist/usage.js +757 -0
- package/dist/usage.js.map +1 -0
- package/package.json +2 -1
package/dist/usage.js
ADDED
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage accounting for the web dashboard (`U` in the TUI, `/usage` on the board).
|
|
3
|
+
*
|
|
4
|
+
* Three independent sources, all local to this machine:
|
|
5
|
+
*
|
|
6
|
+
* - **Quota**: the providers' own usage endpoints, called with the sign-ins the
|
|
7
|
+
* CLIs already hold (Claude Code's OAuth token from the keychain or
|
|
8
|
+
* `.credentials.json`; Codex's `auth.json`). Rudder never sees a password and
|
|
9
|
+
* never stores a token; it reads what the CLI stored and forwards it once.
|
|
10
|
+
* - **Token costs**: the CLIs' own session logs (`~/.claude/projects`,
|
|
11
|
+
* `~/.codex/sessions`), priced at API list rates. Subscription usage is not
|
|
12
|
+
* billed per token, so this is "what the same work would cost at API rates",
|
|
13
|
+
* plus how much prompt caching saved.
|
|
14
|
+
* - **Machine**: `ps` for the agent processes and rudder itself, `os` for the
|
|
15
|
+
* system RAM split.
|
|
16
|
+
*/
|
|
17
|
+
import { execFile } from "node:child_process";
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import fsp from "node:fs/promises";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import readline from "node:readline";
|
|
23
|
+
import { promisify } from "node:util";
|
|
24
|
+
const execFileAsync = promisify(execFile);
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Pricing. Mirrors native/src/usage.rs so the TUI's /usage and the dashboard
|
|
27
|
+
// agree; approximate list rates, not billing-grade.
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
export function priceFor(model) {
|
|
30
|
+
const m = model.toLowerCase();
|
|
31
|
+
if (m.includes("fable") || m.includes("opus")) {
|
|
32
|
+
return { input: 15, output: 75, cacheWrite: 18.75, cacheRead: 1.5 };
|
|
33
|
+
}
|
|
34
|
+
if (m.includes("sonnet")) {
|
|
35
|
+
return { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 };
|
|
36
|
+
}
|
|
37
|
+
if (m.includes("haiku")) {
|
|
38
|
+
return { input: 0.8, output: 4, cacheWrite: 1, cacheRead: 0.08 };
|
|
39
|
+
}
|
|
40
|
+
if (m.includes("claude")) {
|
|
41
|
+
return { input: 3, output: 15, cacheWrite: 3.75, cacheRead: 0.3 };
|
|
42
|
+
}
|
|
43
|
+
if (m.startsWith("gpt-5") || m.startsWith("gpt-6") || m.startsWith("o3")) {
|
|
44
|
+
return { input: 10, output: 30, cacheWrite: 0, cacheRead: 1 };
|
|
45
|
+
}
|
|
46
|
+
if (m.startsWith("gpt-4o-mini")) {
|
|
47
|
+
return { input: 0.15, output: 0.6, cacheWrite: 0, cacheRead: 0.075 };
|
|
48
|
+
}
|
|
49
|
+
if (m.startsWith("gpt-4o")) {
|
|
50
|
+
return { input: 2.5, output: 10, cacheWrite: 0, cacheRead: 1.25 };
|
|
51
|
+
}
|
|
52
|
+
if (m.startsWith("o1")) {
|
|
53
|
+
return { input: 15, output: 60, cacheWrite: 0, cacheRead: 7.5 };
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
export function emptyTotals() {
|
|
58
|
+
return { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, cost: 0, cacheSavings: 0, calls: 0 };
|
|
59
|
+
}
|
|
60
|
+
export function addEvent(totals, event) {
|
|
61
|
+
const price = priceFor(event.model) ?? { input: 0, output: 0, cacheWrite: 0, cacheRead: 0 };
|
|
62
|
+
totals.input += event.input;
|
|
63
|
+
totals.output += event.output;
|
|
64
|
+
totals.cacheWrite += event.cacheWrite;
|
|
65
|
+
totals.cacheRead += event.cacheRead;
|
|
66
|
+
totals.calls += 1;
|
|
67
|
+
totals.cost +=
|
|
68
|
+
(event.input * price.input +
|
|
69
|
+
event.output * price.output +
|
|
70
|
+
event.cacheWrite * price.cacheWrite +
|
|
71
|
+
event.cacheRead * price.cacheRead) /
|
|
72
|
+
1_000_000;
|
|
73
|
+
totals.cacheSavings += (event.cacheRead * Math.max(0, price.input - price.cacheRead)) / 1_000_000;
|
|
74
|
+
}
|
|
75
|
+
function mergeTotals(into, from) {
|
|
76
|
+
into.input += from.input;
|
|
77
|
+
into.output += from.output;
|
|
78
|
+
into.cacheWrite += from.cacheWrite;
|
|
79
|
+
into.cacheRead += from.cacheRead;
|
|
80
|
+
into.cost += from.cost;
|
|
81
|
+
into.cacheSavings += from.cacheSavings;
|
|
82
|
+
into.calls += from.calls;
|
|
83
|
+
}
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Session log locations.
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
export function claudeConfigDir() {
|
|
88
|
+
return path.resolve(process.env.CLAUDE_CONFIG_DIR?.trim() || path.join(os.homedir(), ".claude"));
|
|
89
|
+
}
|
|
90
|
+
export function codexHomeDir() {
|
|
91
|
+
return path.resolve(process.env.CODEX_HOME?.trim() || path.join(os.homedir(), ".codex"));
|
|
92
|
+
}
|
|
93
|
+
export function rangeSince(range, now = new Date()) {
|
|
94
|
+
const days = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
|
95
|
+
const since = new Date(now);
|
|
96
|
+
since.setHours(0, 0, 0, 0);
|
|
97
|
+
since.setDate(since.getDate() - (days - 1));
|
|
98
|
+
return since;
|
|
99
|
+
}
|
|
100
|
+
/** Local calendar day for a timestamp, as YYYY-MM-DD. */
|
|
101
|
+
export function localDay(ts) {
|
|
102
|
+
const d = typeof ts === "string" ? new Date(ts) : ts;
|
|
103
|
+
if (Number.isNaN(d.getTime()))
|
|
104
|
+
return "";
|
|
105
|
+
const y = d.getFullYear();
|
|
106
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
107
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
108
|
+
return `${y}-${m}-${day}`;
|
|
109
|
+
}
|
|
110
|
+
async function listJsonl(root, depth) {
|
|
111
|
+
const out = [];
|
|
112
|
+
const walk = async (dir, left) => {
|
|
113
|
+
let entries;
|
|
114
|
+
try {
|
|
115
|
+
entries = await fsp.readdir(dir, { withFileTypes: true });
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
const full = path.join(dir, entry.name);
|
|
122
|
+
if (entry.isDirectory()) {
|
|
123
|
+
if (left > 0)
|
|
124
|
+
await walk(full, left - 1);
|
|
125
|
+
}
|
|
126
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
127
|
+
out.push(full);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
await walk(root, depth);
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
const fileCache = new Map();
|
|
135
|
+
async function eachLine(file, onLine) {
|
|
136
|
+
const stream = fs.createReadStream(file, { encoding: "utf8" });
|
|
137
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY });
|
|
138
|
+
try {
|
|
139
|
+
for await (const line of rl) {
|
|
140
|
+
onLine(line);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
rl.close();
|
|
145
|
+
stream.destroy();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function num(value) {
|
|
149
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Claude Code writes one line per assistant message, and a streamed message can
|
|
153
|
+
* appear several times (same `message.id` + `requestId`) with growing usage; the
|
|
154
|
+
* last line is the complete one, so later occurrences replace earlier ones.
|
|
155
|
+
*/
|
|
156
|
+
export async function parseClaudeLog(file) {
|
|
157
|
+
const byKey = new Map();
|
|
158
|
+
let order = 0;
|
|
159
|
+
const keyed = [];
|
|
160
|
+
await eachLine(file, (line) => {
|
|
161
|
+
if (!line.includes('"assistant"'))
|
|
162
|
+
return;
|
|
163
|
+
let value;
|
|
164
|
+
try {
|
|
165
|
+
value = JSON.parse(line);
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (value?.type !== "assistant")
|
|
171
|
+
return;
|
|
172
|
+
const message = value.message;
|
|
173
|
+
const usage = message?.usage;
|
|
174
|
+
if (!usage || typeof usage !== "object")
|
|
175
|
+
return;
|
|
176
|
+
const model = String(message.model ?? "unknown");
|
|
177
|
+
if (model === "<synthetic>")
|
|
178
|
+
return;
|
|
179
|
+
const ts = String(value.timestamp ?? "");
|
|
180
|
+
if (!ts)
|
|
181
|
+
return;
|
|
182
|
+
const id = String(message.id ?? "");
|
|
183
|
+
const requestId = String(value.requestId ?? "");
|
|
184
|
+
const key = id && requestId ? `${id}:${requestId}` : `line:${order}`;
|
|
185
|
+
const event = {
|
|
186
|
+
ts,
|
|
187
|
+
provider: "claude",
|
|
188
|
+
model,
|
|
189
|
+
cwd: String(value.cwd ?? ""),
|
|
190
|
+
sessionId: String(value.sessionId ?? path.basename(file, ".jsonl")),
|
|
191
|
+
input: num(usage.input_tokens),
|
|
192
|
+
output: num(usage.output_tokens),
|
|
193
|
+
cacheWrite: num(usage.cache_creation_input_tokens),
|
|
194
|
+
cacheRead: num(usage.cache_read_input_tokens),
|
|
195
|
+
};
|
|
196
|
+
if (!byKey.has(key))
|
|
197
|
+
keyed.push({ key, order });
|
|
198
|
+
byKey.set(key, event);
|
|
199
|
+
order += 1;
|
|
200
|
+
});
|
|
201
|
+
return keyed.map(({ key }) => byKey.get(key)).filter(Boolean);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Codex writes `token_count` events carrying a cumulative `total_token_usage`;
|
|
205
|
+
* each event's delta from the previous one is one response's usage, attributed
|
|
206
|
+
* to that event's timestamp and the model of the latest `turn_context`.
|
|
207
|
+
* Cached input is a subset of input, so billable input = input − cached.
|
|
208
|
+
*/
|
|
209
|
+
export async function parseCodexLog(file) {
|
|
210
|
+
const events = [];
|
|
211
|
+
let cwd = "";
|
|
212
|
+
let sessionId = path.basename(file, ".jsonl");
|
|
213
|
+
let model = "unknown-codex";
|
|
214
|
+
let prev = { input: 0, cached: 0, output: 0 };
|
|
215
|
+
await eachLine(file, (line) => {
|
|
216
|
+
let value;
|
|
217
|
+
try {
|
|
218
|
+
value = JSON.parse(line);
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const payload = value?.payload;
|
|
224
|
+
switch (value?.type) {
|
|
225
|
+
case "session_meta":
|
|
226
|
+
if (typeof payload?.cwd === "string")
|
|
227
|
+
cwd = payload.cwd;
|
|
228
|
+
if (typeof payload?.id === "string")
|
|
229
|
+
sessionId = payload.id;
|
|
230
|
+
break;
|
|
231
|
+
case "turn_context":
|
|
232
|
+
if (typeof payload?.model === "string")
|
|
233
|
+
model = payload.model;
|
|
234
|
+
if (typeof payload?.cwd === "string" && !cwd)
|
|
235
|
+
cwd = payload.cwd;
|
|
236
|
+
break;
|
|
237
|
+
case "event_msg": {
|
|
238
|
+
if (payload?.type !== "token_count")
|
|
239
|
+
return;
|
|
240
|
+
const total = payload?.info?.total_token_usage;
|
|
241
|
+
if (!total || typeof total !== "object")
|
|
242
|
+
return;
|
|
243
|
+
const cur = {
|
|
244
|
+
input: num(total.input_tokens),
|
|
245
|
+
cached: num(total.cached_input_tokens),
|
|
246
|
+
output: num(total.output_tokens),
|
|
247
|
+
};
|
|
248
|
+
// A cumulative counter that went backwards is a fresh counter
|
|
249
|
+
// (compaction / resumed thread): count the new total from zero.
|
|
250
|
+
const base = cur.input < prev.input || cur.output < prev.output ? { input: 0, cached: 0, output: 0 } : prev;
|
|
251
|
+
const dInput = cur.input - base.input;
|
|
252
|
+
const dCached = cur.cached - base.cached;
|
|
253
|
+
const dOutput = cur.output - base.output;
|
|
254
|
+
prev = cur;
|
|
255
|
+
if (dInput <= 0 && dOutput <= 0)
|
|
256
|
+
return;
|
|
257
|
+
events.push({
|
|
258
|
+
ts: String(value.timestamp ?? ""),
|
|
259
|
+
provider: "codex",
|
|
260
|
+
model,
|
|
261
|
+
cwd,
|
|
262
|
+
sessionId,
|
|
263
|
+
input: Math.max(0, dInput - Math.max(0, dCached)),
|
|
264
|
+
output: Math.max(0, dOutput),
|
|
265
|
+
cacheWrite: 0,
|
|
266
|
+
cacheRead: Math.max(0, dCached),
|
|
267
|
+
});
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
default:
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
return events;
|
|
275
|
+
}
|
|
276
|
+
async function cachedEvents(file, parse) {
|
|
277
|
+
let stat;
|
|
278
|
+
try {
|
|
279
|
+
stat = await fsp.stat(file);
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return [];
|
|
283
|
+
}
|
|
284
|
+
const hit = fileCache.get(file);
|
|
285
|
+
if (hit && hit.mtimeMs === stat.mtimeMs && hit.size === stat.size)
|
|
286
|
+
return hit.events;
|
|
287
|
+
const events = await parse(file);
|
|
288
|
+
fileCache.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, events });
|
|
289
|
+
return events;
|
|
290
|
+
}
|
|
291
|
+
const gitRootCache = new Map();
|
|
292
|
+
/** Nearest ancestor (inclusive) that is a git/jj checkout, or null. Cached per dir. */
|
|
293
|
+
export function nearestRepoRoot(cwd, exists = fs.existsSync) {
|
|
294
|
+
if (!cwd || !path.isAbsolute(cwd))
|
|
295
|
+
return null;
|
|
296
|
+
const chain = [];
|
|
297
|
+
let dir = cwd;
|
|
298
|
+
let found = null;
|
|
299
|
+
for (;;) {
|
|
300
|
+
const hit = gitRootCache.get(dir);
|
|
301
|
+
if (hit !== undefined) {
|
|
302
|
+
found = hit;
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
chain.push(dir);
|
|
306
|
+
if (exists(path.join(dir, ".git")) || exists(path.join(dir, ".jj"))) {
|
|
307
|
+
found = dir;
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
const parent = path.dirname(dir);
|
|
311
|
+
if (parent === dir)
|
|
312
|
+
break;
|
|
313
|
+
dir = parent;
|
|
314
|
+
}
|
|
315
|
+
for (const d of chain)
|
|
316
|
+
gitRootCache.set(d, found);
|
|
317
|
+
return found;
|
|
318
|
+
}
|
|
319
|
+
/** Rudder worker workspaces are jj workspaces INSIDE the repo, under this dir. */
|
|
320
|
+
const AGENT_WORKSPACES_SEGMENT = "/.rudder-workspaces/";
|
|
321
|
+
/**
|
|
322
|
+
* Which workspace a session belongs to. The session's own checkout names it:
|
|
323
|
+
* a Rudder worker workspace (`<repo>/.rudder-workspaces/<name>`) counts for
|
|
324
|
+
* `<repo>`, any other cwd for its nearest git/jj root, a bare directory for
|
|
325
|
+
* itself. A registered project wins only when its root IS that checkout —
|
|
326
|
+
* a project registered at `~/code` must not swallow every repo beneath it —
|
|
327
|
+
* or when the cwd is not inside any checkout at all.
|
|
328
|
+
*/
|
|
329
|
+
export function projectFor(cwd, projects, repoRootOf = nearestRepoRoot) {
|
|
330
|
+
const resolved = (cwd || "").replace(/\/+$/, "") || "(unknown)";
|
|
331
|
+
const wsAt = resolved.indexOf(AGENT_WORKSPACES_SEGMENT);
|
|
332
|
+
const root = wsAt > 0 ? resolved.slice(0, wsAt) : repoRootOf(resolved);
|
|
333
|
+
const within = (dir, parent) => dir === parent || dir.startsWith(`${parent}/`);
|
|
334
|
+
let best = null;
|
|
335
|
+
for (const project of projects) {
|
|
336
|
+
const projectRoot = project.repoRoot.replace(/\/+$/, "");
|
|
337
|
+
const name = project.name || path.basename(projectRoot);
|
|
338
|
+
if (root ? root === projectRoot : within(resolved, projectRoot)) {
|
|
339
|
+
if (!best || projectRoot.length > best.key.length)
|
|
340
|
+
best = { key: projectRoot, name, slug: project.slug };
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (best)
|
|
344
|
+
return best;
|
|
345
|
+
if (root)
|
|
346
|
+
return { key: root, name: path.basename(root) || root, slug: null };
|
|
347
|
+
return { key: resolved, name: path.basename(resolved) || resolved, slug: null };
|
|
348
|
+
}
|
|
349
|
+
export async function scanTokenUsage(opts) {
|
|
350
|
+
const now = opts.now ?? new Date();
|
|
351
|
+
const since = rangeSince(opts.range, now);
|
|
352
|
+
const sinceMs = since.getTime();
|
|
353
|
+
const claudeRoot = opts.claudeProjectsRoot ?? path.join(claudeConfigDir(), "projects");
|
|
354
|
+
const codexRoot = opts.codexSessionsRoot ?? path.join(codexHomeDir(), "sessions");
|
|
355
|
+
const projects = opts.projects ?? [];
|
|
356
|
+
const files = [];
|
|
357
|
+
for (const file of await listJsonl(claudeRoot, 3))
|
|
358
|
+
files.push({ file, provider: "claude" });
|
|
359
|
+
for (const file of await listJsonl(codexRoot, 4))
|
|
360
|
+
files.push({ file, provider: "codex" });
|
|
361
|
+
const events = [];
|
|
362
|
+
let scanned = 0;
|
|
363
|
+
for (const { file, provider } of files) {
|
|
364
|
+
let stat;
|
|
365
|
+
try {
|
|
366
|
+
stat = await fsp.stat(file);
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
// A log's mtime is at or after its last event: older files cannot hold
|
|
372
|
+
// events in range and are skipped without being read.
|
|
373
|
+
if (stat.mtimeMs < sinceMs)
|
|
374
|
+
continue;
|
|
375
|
+
scanned += 1;
|
|
376
|
+
const parsed = await cachedEvents(file, provider === "claude" ? parseClaudeLog : parseCodexLog);
|
|
377
|
+
for (const event of parsed) {
|
|
378
|
+
const t = new Date(event.ts).getTime();
|
|
379
|
+
if (Number.isNaN(t) || t < sinceMs)
|
|
380
|
+
continue;
|
|
381
|
+
events.push(event);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
const days = new Map();
|
|
385
|
+
for (let d = new Date(since); d.getTime() <= now.getTime(); d.setDate(d.getDate() + 1)) {
|
|
386
|
+
const day = localDay(d);
|
|
387
|
+
days.set(day, { day, claude: emptyTotals(), codex: emptyTotals() });
|
|
388
|
+
}
|
|
389
|
+
const providers = { claude: emptyTotals(), codex: emptyTotals() };
|
|
390
|
+
const models = new Map();
|
|
391
|
+
const projectRows = new Map();
|
|
392
|
+
const total = emptyTotals();
|
|
393
|
+
for (const event of events) {
|
|
394
|
+
const day = localDay(event.ts);
|
|
395
|
+
const bucket = days.get(day);
|
|
396
|
+
if (bucket)
|
|
397
|
+
addEvent(bucket[event.provider], event);
|
|
398
|
+
addEvent(providers[event.provider], event);
|
|
399
|
+
addEvent(total, event);
|
|
400
|
+
const modelKey = `${event.provider}:${event.model}`;
|
|
401
|
+
let model = models.get(modelKey);
|
|
402
|
+
if (!model) {
|
|
403
|
+
model = { row: { provider: event.provider, model: event.model, totals: emptyTotals(), sessions: 0 }, sessions: new Set() };
|
|
404
|
+
models.set(modelKey, model);
|
|
405
|
+
}
|
|
406
|
+
addEvent(model.row.totals, event);
|
|
407
|
+
model.sessions.add(event.sessionId);
|
|
408
|
+
const project = projectFor(event.cwd, projects);
|
|
409
|
+
let entry = projectRows.get(project.key);
|
|
410
|
+
if (!entry) {
|
|
411
|
+
entry = { row: { ...project, totals: emptyTotals(), sessions: [] }, sessions: new Map() };
|
|
412
|
+
projectRows.set(project.key, entry);
|
|
413
|
+
}
|
|
414
|
+
addEvent(entry.row.totals, event);
|
|
415
|
+
let session = entry.sessions.get(event.sessionId);
|
|
416
|
+
if (!session) {
|
|
417
|
+
session = {
|
|
418
|
+
id: event.sessionId,
|
|
419
|
+
provider: event.provider,
|
|
420
|
+
model: event.model,
|
|
421
|
+
startedAt: event.ts,
|
|
422
|
+
lastAt: event.ts,
|
|
423
|
+
cwd: event.cwd,
|
|
424
|
+
totals: emptyTotals(),
|
|
425
|
+
};
|
|
426
|
+
entry.sessions.set(event.sessionId, session);
|
|
427
|
+
}
|
|
428
|
+
if (event.ts < session.startedAt)
|
|
429
|
+
session.startedAt = event.ts;
|
|
430
|
+
if (event.ts > session.lastAt) {
|
|
431
|
+
session.lastAt = event.ts;
|
|
432
|
+
session.model = event.model;
|
|
433
|
+
}
|
|
434
|
+
addEvent(session.totals, event);
|
|
435
|
+
}
|
|
436
|
+
const byCost = (a, b) => b.totals.cost - a.totals.cost;
|
|
437
|
+
const modelRows = [...models.values()]
|
|
438
|
+
.map(({ row, sessions }) => ({ ...row, sessions: sessions.size }))
|
|
439
|
+
.sort(byCost);
|
|
440
|
+
const projectList = [...projectRows.values()]
|
|
441
|
+
.map(({ row, sessions }) => ({ ...row, sessions: [...sessions.values()].sort(byCost).slice(0, 25) }))
|
|
442
|
+
.sort(byCost);
|
|
443
|
+
return {
|
|
444
|
+
range: opts.range,
|
|
445
|
+
since: since.toISOString(),
|
|
446
|
+
generatedAt: now.toISOString(),
|
|
447
|
+
days: [...days.values()],
|
|
448
|
+
providers,
|
|
449
|
+
total,
|
|
450
|
+
models: modelRows,
|
|
451
|
+
projects: projectList,
|
|
452
|
+
scannedFiles: scanned,
|
|
453
|
+
pricingNote: "Estimated at API list rates from the CLIs' local session logs. Subscription usage is not billed per token; this is what the same work would cost on the API.",
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
const QUOTA_TTL_MS = 5 * 60 * 1000;
|
|
457
|
+
let quotaCache = null;
|
|
458
|
+
async function readClaudeCredentials() {
|
|
459
|
+
let raw = null;
|
|
460
|
+
const file = path.join(claudeConfigDir(), ".credentials.json");
|
|
461
|
+
try {
|
|
462
|
+
raw = await fsp.readFile(file, "utf8");
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
raw = null;
|
|
466
|
+
}
|
|
467
|
+
if (raw === null && process.platform === "darwin") {
|
|
468
|
+
try {
|
|
469
|
+
const { stdout } = await execFileAsync("security", ["find-generic-password", "-s", "Claude Code-credentials", "-w"], {
|
|
470
|
+
timeout: 5000,
|
|
471
|
+
});
|
|
472
|
+
raw = stdout;
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
raw = null;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
if (!raw)
|
|
479
|
+
return null;
|
|
480
|
+
try {
|
|
481
|
+
const parsed = JSON.parse(raw);
|
|
482
|
+
const oauth = parsed?.claudeAiOauth;
|
|
483
|
+
if (!oauth?.accessToken)
|
|
484
|
+
return null;
|
|
485
|
+
return {
|
|
486
|
+
accessToken: String(oauth.accessToken),
|
|
487
|
+
expiresAt: typeof oauth.expiresAt === "number" ? oauth.expiresAt : null,
|
|
488
|
+
subscriptionType: typeof oauth.subscriptionType === "string" ? oauth.subscriptionType : null,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
catch {
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
function windowLabel(seconds) {
|
|
496
|
+
if (seconds >= 6 * 24 * 3600 && seconds <= 8 * 24 * 3600)
|
|
497
|
+
return "Weekly";
|
|
498
|
+
if (seconds === 5 * 3600)
|
|
499
|
+
return "Session (5h)";
|
|
500
|
+
if (seconds % 3600 === 0)
|
|
501
|
+
return `${seconds / 3600}h`;
|
|
502
|
+
return `${Math.round(seconds / 60)}m`;
|
|
503
|
+
}
|
|
504
|
+
function planLabel(tier, fallback) {
|
|
505
|
+
const t = (tier ?? "").toLowerCase();
|
|
506
|
+
const m = t.match(/max_(\d+)x/);
|
|
507
|
+
if (m)
|
|
508
|
+
return `Max ${m[1]}x`;
|
|
509
|
+
if (t.includes("max"))
|
|
510
|
+
return "Max";
|
|
511
|
+
if (t.includes("pro"))
|
|
512
|
+
return "Pro";
|
|
513
|
+
if (t.includes("team"))
|
|
514
|
+
return "Team";
|
|
515
|
+
if (t.includes("enterprise"))
|
|
516
|
+
return "Enterprise";
|
|
517
|
+
if (fallback)
|
|
518
|
+
return fallback.charAt(0).toUpperCase() + fallback.slice(1);
|
|
519
|
+
return null;
|
|
520
|
+
}
|
|
521
|
+
/** Pure: Claude's `/api/oauth/usage` body → meters. */
|
|
522
|
+
export function parseClaudeQuota(usage, profile, fallbackPlan) {
|
|
523
|
+
const windows = [];
|
|
524
|
+
const push = (label, w) => {
|
|
525
|
+
if (!w || typeof w !== "object" || typeof w.utilization !== "number")
|
|
526
|
+
return;
|
|
527
|
+
windows.push({ label, percent: Math.max(0, Math.min(100, Math.round(w.utilization))), resetsAt: w.resets_at ?? null });
|
|
528
|
+
};
|
|
529
|
+
push("Session (5h)", usage?.five_hour);
|
|
530
|
+
push("Weekly", usage?.seven_day);
|
|
531
|
+
push("Weekly · Opus", usage?.seven_day_opus);
|
|
532
|
+
push("Weekly · Sonnet", usage?.seven_day_sonnet);
|
|
533
|
+
const extra = usage?.extra_usage;
|
|
534
|
+
if (extra?.is_enabled && typeof extra.utilization === "number") {
|
|
535
|
+
windows.push({ label: "Extra usage", percent: Math.round(extra.utilization), resetsAt: null });
|
|
536
|
+
}
|
|
537
|
+
const email = typeof profile?.account?.email === "string" ? profile.account.email : null;
|
|
538
|
+
const plan = planLabel(profile?.organization?.rate_limit_tier, fallbackPlan);
|
|
539
|
+
return { email, plan, windows, error: windows.length ? null : "this plan does not expose limits" };
|
|
540
|
+
}
|
|
541
|
+
async function fetchClaudeQuota() {
|
|
542
|
+
const fetchedAt = new Date().toISOString();
|
|
543
|
+
const base = { provider: "claude", email: null, plan: null, windows: [], fetchedAt };
|
|
544
|
+
const creds = await readClaudeCredentials();
|
|
545
|
+
if (!creds)
|
|
546
|
+
return { ...base, error: "not signed in — run `claude` and log in" };
|
|
547
|
+
if (creds.expiresAt && creds.expiresAt < Date.now()) {
|
|
548
|
+
return { ...base, plan: planLabel(null, creds.subscriptionType), error: "sign-in expired — run `claude` once to refresh it" };
|
|
549
|
+
}
|
|
550
|
+
const headers = {
|
|
551
|
+
Authorization: `Bearer ${creds.accessToken}`,
|
|
552
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
553
|
+
"User-Agent": "rudder-usage",
|
|
554
|
+
};
|
|
555
|
+
try {
|
|
556
|
+
const [usageRes, profileRes] = await Promise.all([
|
|
557
|
+
fetch("https://api.anthropic.com/api/oauth/usage", { headers, signal: AbortSignal.timeout(15_000) }),
|
|
558
|
+
fetch("https://api.anthropic.com/api/oauth/profile", { headers, signal: AbortSignal.timeout(15_000) }),
|
|
559
|
+
]);
|
|
560
|
+
if (usageRes.status === 401 || usageRes.status === 403) {
|
|
561
|
+
return { ...base, error: "sign-in rejected — run `claude` once to refresh it" };
|
|
562
|
+
}
|
|
563
|
+
if (!usageRes.ok)
|
|
564
|
+
return { ...base, error: `usage endpoint returned ${usageRes.status}` };
|
|
565
|
+
const usage = await usageRes.json();
|
|
566
|
+
const profile = profileRes.ok ? await profileRes.json() : null;
|
|
567
|
+
return { ...base, ...parseClaudeQuota(usage, profile, creds.subscriptionType) };
|
|
568
|
+
}
|
|
569
|
+
catch (error) {
|
|
570
|
+
return { ...base, error: `could not reach api.anthropic.com (${String(error?.message ?? error)})` };
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
async function readCodexCredentials() {
|
|
574
|
+
try {
|
|
575
|
+
const raw = await fsp.readFile(path.join(codexHomeDir(), "auth.json"), "utf8");
|
|
576
|
+
const parsed = JSON.parse(raw);
|
|
577
|
+
const token = parsed?.tokens?.access_token;
|
|
578
|
+
if (typeof token !== "string" || !token)
|
|
579
|
+
return null;
|
|
580
|
+
return { accessToken: token, accountId: typeof parsed.tokens.account_id === "string" ? parsed.tokens.account_id : null };
|
|
581
|
+
}
|
|
582
|
+
catch {
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
/** Pure: Codex's `/backend-api/wham/usage` body → meters. */
|
|
587
|
+
export function parseCodexQuota(body) {
|
|
588
|
+
const windows = [];
|
|
589
|
+
const pushWindow = (prefix, w) => {
|
|
590
|
+
if (!w || typeof w !== "object" || typeof w.used_percent !== "number")
|
|
591
|
+
return;
|
|
592
|
+
const label = windowLabel(num(w.limit_window_seconds));
|
|
593
|
+
windows.push({
|
|
594
|
+
label: prefix ? `${prefix} · ${label}` : label,
|
|
595
|
+
percent: Math.max(0, Math.min(100, Math.round(w.used_percent))),
|
|
596
|
+
resetsAt: typeof w.reset_at === "number" ? new Date(w.reset_at * 1000).toISOString() : null,
|
|
597
|
+
});
|
|
598
|
+
};
|
|
599
|
+
pushWindow("", body?.rate_limit?.primary_window);
|
|
600
|
+
pushWindow("", body?.rate_limit?.secondary_window);
|
|
601
|
+
for (const extra of Array.isArray(body?.additional_rate_limits) ? body.additional_rate_limits : []) {
|
|
602
|
+
const name = typeof extra?.limit_name === "string" ? extra.limit_name : "extra";
|
|
603
|
+
pushWindow(name, extra?.rate_limit?.primary_window);
|
|
604
|
+
pushWindow(name, extra?.rate_limit?.secondary_window);
|
|
605
|
+
}
|
|
606
|
+
const email = typeof body?.email === "string" ? body.email : null;
|
|
607
|
+
const plan = planLabel(null, typeof body?.plan_type === "string" ? body.plan_type : null);
|
|
608
|
+
return { email, plan, windows, error: windows.length ? null : "this plan does not expose limits" };
|
|
609
|
+
}
|
|
610
|
+
async function fetchCodexQuota() {
|
|
611
|
+
const fetchedAt = new Date().toISOString();
|
|
612
|
+
const base = { provider: "codex", email: null, plan: null, windows: [], fetchedAt };
|
|
613
|
+
const creds = await readCodexCredentials();
|
|
614
|
+
if (!creds)
|
|
615
|
+
return { ...base, error: "not signed in — run `codex login`" };
|
|
616
|
+
const headers = {
|
|
617
|
+
Authorization: `Bearer ${creds.accessToken}`,
|
|
618
|
+
"User-Agent": "rudder-usage",
|
|
619
|
+
};
|
|
620
|
+
if (creds.accountId)
|
|
621
|
+
headers["chatgpt-account-id"] = creds.accountId;
|
|
622
|
+
try {
|
|
623
|
+
const res = await fetch("https://chatgpt.com/backend-api/wham/usage", { headers, signal: AbortSignal.timeout(15_000) });
|
|
624
|
+
if (res.status === 401 || res.status === 403)
|
|
625
|
+
return { ...base, error: "sign-in expired — run `codex login`" };
|
|
626
|
+
if (!res.ok)
|
|
627
|
+
return { ...base, error: `usage endpoint returned ${res.status}` };
|
|
628
|
+
return { ...base, ...parseCodexQuota(await res.json()) };
|
|
629
|
+
}
|
|
630
|
+
catch (error) {
|
|
631
|
+
return { ...base, error: `could not reach chatgpt.com (${String(error?.message ?? error)})` };
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
/** A failed lookup is retried soon, not cached for the full five minutes. */
|
|
635
|
+
const QUOTA_ERROR_TTL_MS = 20 * 1000;
|
|
636
|
+
export async function fetchQuota(opts = {}) {
|
|
637
|
+
if (!opts.force && quotaCache) {
|
|
638
|
+
const ttl = quotaCache.report.accounts.some((a) => a.error) ? QUOTA_ERROR_TTL_MS : QUOTA_TTL_MS;
|
|
639
|
+
if (Date.now() - quotaCache.at < ttl)
|
|
640
|
+
return quotaCache.report;
|
|
641
|
+
}
|
|
642
|
+
const accounts = await Promise.all([fetchClaudeQuota(), fetchCodexQuota()]);
|
|
643
|
+
const report = { accounts, fetchedAt: new Date().toISOString(), ttlSeconds: QUOTA_TTL_MS / 1000 };
|
|
644
|
+
quotaCache = { at: Date.now(), report };
|
|
645
|
+
return report;
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Classify by the EXECUTABLE, not the whole command line: a Claude worker's
|
|
649
|
+
* argv carries `--settings ~/.rudder/signals/...`, so matching "rudder"
|
|
650
|
+
* anywhere in the line filed every agent under rudder. For interpreters
|
|
651
|
+
* (`node`, `bun`) the script path is the executable.
|
|
652
|
+
*/
|
|
653
|
+
export function classifyCommand(command) {
|
|
654
|
+
const argv = command.trim().split(/\s+/);
|
|
655
|
+
let exe = (argv[0] ?? "").toLowerCase();
|
|
656
|
+
if (/(^|\/)(node|bun|deno)(\d+)?$/.test(exe) && argv[1])
|
|
657
|
+
exe = argv[1].toLowerCase();
|
|
658
|
+
const base = exe.split("/").pop() ?? "";
|
|
659
|
+
if (base === "rudder-native" || base === "rudder" || exe.includes("/rudder/dist/") || exe.includes("@viraatdas/rudder/")) {
|
|
660
|
+
return "rudder";
|
|
661
|
+
}
|
|
662
|
+
if (base === "claude" || exe.includes("/claude/versions/") || exe.includes("@anthropic-ai/claude-code"))
|
|
663
|
+
return "claude";
|
|
664
|
+
if (base === "codex" || base === "codex.js" || exe.includes("@openai/codex") || exe.includes("codex computer use.app") || exe.includes("codex-code-mode-host")) {
|
|
665
|
+
return "codex";
|
|
666
|
+
}
|
|
667
|
+
if (base === "opencode" || exe.includes("/.opencode/"))
|
|
668
|
+
return "opencode";
|
|
669
|
+
return "other";
|
|
670
|
+
}
|
|
671
|
+
/** Pure: `ps -axo pid=,ppid=,pcpu=,rss=,etime=,args=` → agent-related rows. */
|
|
672
|
+
export function parsePsOutput(text) {
|
|
673
|
+
const rows = [];
|
|
674
|
+
for (const line of text.split("\n")) {
|
|
675
|
+
const m = line.trim().match(/^(\d+)\s+(\d+)\s+([\d.]+)\s+(\d+)\s+(\S+)\s+(.*)$/);
|
|
676
|
+
if (!m)
|
|
677
|
+
continue;
|
|
678
|
+
const command = m[6] ?? "";
|
|
679
|
+
const kind = classifyCommand(command);
|
|
680
|
+
if (kind === "other")
|
|
681
|
+
continue;
|
|
682
|
+
rows.push({
|
|
683
|
+
pid: Number(m[1]),
|
|
684
|
+
ppid: Number(m[2]),
|
|
685
|
+
kind,
|
|
686
|
+
cpu: Number(m[3]),
|
|
687
|
+
rss: Number(m[4]) * 1024,
|
|
688
|
+
elapsed: m[5] ?? "",
|
|
689
|
+
command,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
return rows.sort((a, b) => b.cpu - a.cpu || b.rss - a.rss);
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* `os.freemem()` on macOS counts only truly free pages, so a healthy Mac reads
|
|
696
|
+
* as 99% used. vm_stat's inactive + speculative + purgeable pages are
|
|
697
|
+
* reclaimable and count as available, which is what Activity Monitor shows.
|
|
698
|
+
*/
|
|
699
|
+
export function parseVmStat(text) {
|
|
700
|
+
const pageSize = Number(text.match(/page size of (\d+) bytes/)?.[1] ?? 0);
|
|
701
|
+
if (!pageSize)
|
|
702
|
+
return null;
|
|
703
|
+
const page = (name) => Number(text.match(new RegExp(`${name}:\\s+(\\d+)`))?.[1] ?? 0);
|
|
704
|
+
const available = page("Pages free") + page("Pages inactive") + page("Pages speculative") + page("Pages purgeable");
|
|
705
|
+
return available * pageSize;
|
|
706
|
+
}
|
|
707
|
+
async function availableMemory() {
|
|
708
|
+
if (process.platform === "darwin") {
|
|
709
|
+
try {
|
|
710
|
+
const { stdout } = await execFileAsync("vm_stat", [], { timeout: 3000 });
|
|
711
|
+
const available = parseVmStat(stdout);
|
|
712
|
+
if (available !== null)
|
|
713
|
+
return available;
|
|
714
|
+
}
|
|
715
|
+
catch {
|
|
716
|
+
// fall through to os.freemem
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
return os.freemem();
|
|
720
|
+
}
|
|
721
|
+
export async function machineResources() {
|
|
722
|
+
let processes = [];
|
|
723
|
+
try {
|
|
724
|
+
const { stdout } = await execFileAsync("ps", ["-axo", "pid=,ppid=,pcpu=,rss=,etime=,args="], {
|
|
725
|
+
timeout: 5000,
|
|
726
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
727
|
+
});
|
|
728
|
+
processes = parsePsOutput(stdout);
|
|
729
|
+
}
|
|
730
|
+
catch {
|
|
731
|
+
processes = [];
|
|
732
|
+
}
|
|
733
|
+
const byKind = {
|
|
734
|
+
rudder: { cpu: 0, rss: 0, count: 0 },
|
|
735
|
+
claude: { cpu: 0, rss: 0, count: 0 },
|
|
736
|
+
codex: { cpu: 0, rss: 0, count: 0 },
|
|
737
|
+
opencode: { cpu: 0, rss: 0, count: 0 },
|
|
738
|
+
other: { cpu: 0, rss: 0, count: 0 },
|
|
739
|
+
};
|
|
740
|
+
for (const row of processes) {
|
|
741
|
+
byKind[row.kind].cpu += row.cpu;
|
|
742
|
+
byKind[row.kind].rss += row.rss;
|
|
743
|
+
byKind[row.kind].count += 1;
|
|
744
|
+
}
|
|
745
|
+
const self = processes.find((p) => p.pid === process.pid);
|
|
746
|
+
return {
|
|
747
|
+
at: new Date().toISOString(),
|
|
748
|
+
cpus: os.cpus().length,
|
|
749
|
+
load: os.loadavg(),
|
|
750
|
+
totalMem: os.totalmem(),
|
|
751
|
+
freeMem: await availableMemory(),
|
|
752
|
+
rudder: { cpu: self?.cpu ?? 0, rss: self?.rss ?? process.memoryUsage().rss, pid: process.pid },
|
|
753
|
+
processes: processes.slice(0, 60),
|
|
754
|
+
byKind,
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
//# sourceMappingURL=usage.js.map
|