claude-usage-limits 1.6.0 → 1.7.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.
@@ -0,0 +1,855 @@
1
+ 'use strict';
2
+
3
+ // The Codex reader.
4
+ //
5
+ // Codex turns out to keep both of the things this plugin needs in one place.
6
+ // Every session writes a rollout under ~/.codex/sessions, one JSON object per
7
+ // line, and each model request appends a `token_count` event carrying:
8
+ //
9
+ // info.last_token_usage what that request cost, in tokens
10
+ // rate_limits the account meter, as percentages with reset times
11
+ //
12
+ // So the rollouts are Claude's ~/.claude.json and ~/.claude/projects at once:
13
+ // the newest `rate_limits` is the snapshot, and the `last_token_usage` records
14
+ // are the pace. That means the window arithmetic in usage.js works unchanged;
15
+ // only the two readers below are different.
16
+ //
17
+ // Reading files is the fast path and costs nothing. `refresh()` asks Codex
18
+ // itself for a live reading, which takes about a second and starts a child
19
+ // process, so it is reserved for the times the newest rollout has gone stale.
20
+
21
+ const fs = require('fs');
22
+ const os = require('os');
23
+ const path = require('path');
24
+ const readline = require('readline');
25
+
26
+ const host = require('./host.js');
27
+
28
+ const MINUTE = 60 * 1000;
29
+ const HOUR = 60 * MINUTE;
30
+ const DAY = 24 * HOUR;
31
+
32
+ // Codex reports up to two windows, a primary and a secondary, and which of them
33
+ // exist is a property of the plan rather than a constant.
34
+ //
35
+ // Plus and Pro both get the five-hour window, and a weekly one "may apply" on
36
+ // top. Enterprise and Edu on flexible pricing get neither: usage scales with
37
+ // credits instead, so the account reports no rolling window at all. Business
38
+ // tiers have been seen reporting none as well. And it moves: the five-hour
39
+ // window was withdrawn from Plus and later reinstated.
40
+ //
41
+ // So nothing here is assumed. A slot the payload does not carry produces no
42
+ // window, the length of a window comes from the payload, and the key it is
43
+ // filed under is derived from that length rather than from which slot it
44
+ // arrived in. Filing a seven-day window under `five_hour` because it happened
45
+ // to be the primary would price a point of one window with the cost of another.
46
+ const SLOTS = [
47
+ { slot: 'primary', span: 5 * HOUR },
48
+ { slot: 'secondary', span: 7 * DAY },
49
+ ];
50
+
51
+ // Tokens are not all worth the same, and the meter is moved by what they cost
52
+ // rather than by how many there are. These weights are relative, not money:
53
+ // what matters downstream is only the ratio between the classes, because the
54
+ // calibration step divides the total by the meter's own percentage and so
55
+ // cancels the scale out. Output is the dear one, cached input the cheap one.
56
+ const WEIGHTS = { input: 1, cached: 0.1, cacheWrite: 1.25, output: 8 };
57
+
58
+ // What each plan means for how careful to be. Which windows a plan actually
59
+ // gets is never taken from here: that is read from the payload, because it
60
+ // differs by plan and has changed more than once. This is only the advice.
61
+ const POOLED =
62
+ 'Seats are pooled and the allowance is a workspace setting. Confirm headroom ' +
63
+ 'with whoever administers it before planning a long job around these numbers.';
64
+
65
+ const PLANS = {
66
+ free: {
67
+ label: 'ChatGPT Free',
68
+ advice:
69
+ 'Free has the least room of any plan, and whichever window it reports ' +
70
+ 'will bind almost at once. Do one thing at a time and land it.',
71
+ },
72
+ go: {
73
+ label: 'ChatGPT Go',
74
+ advice:
75
+ 'Go sits just above Free. Expect the shorter window to bind first and ' +
76
+ 'size the job before starting it.',
77
+ },
78
+ plus: {
79
+ label: 'ChatGPT Plus',
80
+ advice:
81
+ 'Plus has room for ordinary work, but a long agentic run will find the ' +
82
+ 'five-hour window well before the weekly one. Size the job first.',
83
+ },
84
+ prolite: {
85
+ label: 'ChatGPT Pro Lite',
86
+ advice: 'Pro Lite has more room than Plus. Watch whichever window is reported as higher.',
87
+ },
88
+ pro: {
89
+ label: 'ChatGPT Pro',
90
+ advice:
91
+ 'Pro has the five-hour window too, with far more in it. It rarely binds, ' +
92
+ 'but a heavy day still reaches it, so do not assume it cannot.',
93
+ },
94
+ business: { label: 'ChatGPT Business', advice: POOLED },
95
+ self_serve_business_prolite: { label: 'ChatGPT Business', advice: POOLED },
96
+ self_serve_business_usage_based: {
97
+ label: 'ChatGPT Business',
98
+ advice:
99
+ 'This workspace is billed by usage rather than capped, so the limit is ' +
100
+ 'cost rather than a window. Watch the credits, not a percentage.',
101
+ },
102
+ team: { label: 'ChatGPT Team', advice: POOLED },
103
+ enterprise: { label: 'ChatGPT Enterprise', advice: POOLED },
104
+ ent26: { label: 'ChatGPT Enterprise', advice: POOLED },
105
+ enterprise_cbp_automation: { label: 'ChatGPT Enterprise', advice: POOLED },
106
+ enterprise_cbp_usage_based: {
107
+ label: 'ChatGPT Enterprise',
108
+ advice:
109
+ 'On flexible pricing there is no rolling window at all: usage scales ' +
110
+ 'with credits, so the credit balance is the budget to plan against.',
111
+ },
112
+ edu: { label: 'ChatGPT Edu', advice: POOLED },
113
+ edu_plus: { label: 'ChatGPT Edu', advice: POOLED },
114
+ edu_pro: { label: 'ChatGPT Edu', advice: POOLED },
115
+ };
116
+
117
+ function homeDir() {
118
+ return host.codexHome();
119
+ }
120
+
121
+ function sessionsDir() {
122
+ return path.join(homeDir(), 'sessions');
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Finding codex itself
127
+ // ---------------------------------------------------------------------------
128
+
129
+ // The Codex desktop app does not put codex.exe on PATH. It installs it under a
130
+ // content-hashed directory that changes with every update, which is why the
131
+ // first port of this reader could never find it and reported "not signed in" on
132
+ // a machine that was signed in. Look where it actually lives, newest first.
133
+ function windowsCandidates() {
134
+ const local = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
135
+ const root = path.join(local, 'OpenAI', 'Codex', 'bin');
136
+ let names = [];
137
+ try {
138
+ names = fs.readdirSync(root, { withFileTypes: true })
139
+ .filter((entry) => entry.isDirectory())
140
+ .map((entry) => entry.name);
141
+ } catch (err) {
142
+ return [];
143
+ }
144
+
145
+ const found = [];
146
+ for (const name of names) {
147
+ const file = path.join(root, name, 'codex.exe');
148
+ try {
149
+ found.push({ file, at: fs.statSync(file).mtimeMs });
150
+ } catch (err) {
151
+ // A half-written or superseded install directory.
152
+ }
153
+ }
154
+ return found.sort((a, b) => b.at - a.at).map((entry) => entry.file);
155
+ }
156
+
157
+ // The app records the path it is using in its own config, which is the most
158
+ // reliable pointer of all when it is there.
159
+ function fromConfig() {
160
+ let raw;
161
+ try {
162
+ raw = fs.readFileSync(path.join(homeDir(), 'config.toml'), 'utf8');
163
+ } catch (err) {
164
+ return null;
165
+ }
166
+ const match = /^\s*CODEX_CLI_PATH\s*=\s*['"](.+?)['"]\s*$/m.exec(raw);
167
+ return match ? match[1] : null;
168
+ }
169
+
170
+ function onPath() {
171
+ const name = process.platform === 'win32' ? 'codex.exe' : 'codex';
172
+ const dirs = String(process.env.PATH || '').split(path.delimiter).filter(Boolean);
173
+ const found = [];
174
+ for (const dir of dirs) {
175
+ const file = path.join(dir, name);
176
+ // A .cmd or .ps1 shim cannot be spawned without a shell, so only take the
177
+ // real executable.
178
+ if (host.exists(file)) found.push(file);
179
+ }
180
+ return found;
181
+ }
182
+
183
+ function findExecutable(override) {
184
+ const candidates = [];
185
+ if (override) candidates.push(override);
186
+ if (process.env.USAGE_LIMITS_CODEX) candidates.push(process.env.USAGE_LIMITS_CODEX);
187
+ if (process.env.CODEX_CLI_PATH) candidates.push(process.env.CODEX_CLI_PATH);
188
+
189
+ const configured = fromConfig();
190
+ if (configured) candidates.push(configured);
191
+
192
+ if (process.platform === 'win32') {
193
+ candidates.push(...windowsCandidates());
194
+ } else {
195
+ candidates.push(path.join(homeDir(), 'bin', 'codex'));
196
+ candidates.push('/usr/local/bin/codex');
197
+ candidates.push('/opt/homebrew/bin/codex');
198
+ }
199
+ candidates.push(...onPath());
200
+
201
+ for (const file of candidates) {
202
+ if (file && host.exists(file)) return file;
203
+ }
204
+ return null;
205
+ }
206
+
207
+ // ---------------------------------------------------------------------------
208
+ // Reading the rollouts
209
+ // ---------------------------------------------------------------------------
210
+
211
+ function rolloutFiles(since) {
212
+ const root = sessionsDir();
213
+ const files = [];
214
+
215
+ // Rollouts are filed under sessions/YYYY/MM/DD, so walking is cheap, but
216
+ // guard the depth anyway rather than trusting the layout.
217
+ const walk = (dir, depth) => {
218
+ if (depth > 5) return;
219
+ let entries = [];
220
+ try {
221
+ entries = fs.readdirSync(dir, { withFileTypes: true });
222
+ } catch (err) {
223
+ return;
224
+ }
225
+ for (const entry of entries) {
226
+ const full = path.join(dir, entry.name);
227
+ if (entry.isDirectory()) {
228
+ walk(full, depth + 1);
229
+ continue;
230
+ }
231
+ if (!entry.name.endsWith('.jsonl')) continue;
232
+ try {
233
+ const at = fs.statSync(full).mtimeMs;
234
+ // A file untouched since before the window opened holds nothing for it.
235
+ if (Number.isFinite(since) && at < since) continue;
236
+ files.push({ file: full, at });
237
+ } catch (err) {
238
+ // Deleted between the listing and the stat.
239
+ }
240
+ }
241
+ };
242
+
243
+ walk(root, 0);
244
+ return files.sort((a, b) => a.at - b.at);
245
+ }
246
+
247
+ // The session id is in the first line of the rollout, and also in its name.
248
+ // Take it from the name, which costs nothing and is right either way.
249
+ function sessionOf(file) {
250
+ const match = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i
251
+ .exec(path.basename(file));
252
+ return match ? match[1] : null;
253
+ }
254
+
255
+ function weigh(usage) {
256
+ if (!usage) return 0;
257
+ const input = Number(usage.input_tokens) || 0;
258
+ const cached = Number(usage.cached_input_tokens) || 0;
259
+ const written = Number(usage.cache_write_input_tokens) || 0;
260
+ const output = Number(usage.output_tokens) || 0;
261
+ // input_tokens is the whole prompt including whatever was served from cache,
262
+ // so the uncached part is the difference. Reasoning tokens are already
263
+ // counted inside output_tokens.
264
+ const fresh = Math.max(0, input - cached);
265
+ return (
266
+ fresh * WEIGHTS.input +
267
+ cached * WEIGHTS.cached +
268
+ written * WEIGHTS.cacheWrite +
269
+ output * WEIGHTS.output
270
+ ) / 1e6;
271
+ }
272
+
273
+ function partsOf(usage) {
274
+ if (!usage) return { input: 0, cacheWrite: 0, cacheRead: 0, output: 0, reasoning: 0 };
275
+ const input = Number(usage.input_tokens) || 0;
276
+ const cached = Number(usage.cached_input_tokens) || 0;
277
+ return {
278
+ input: Math.max(0, input - cached),
279
+ cacheWrite: Number(usage.cache_write_input_tokens) || 0,
280
+ cacheRead: cached,
281
+ output: Number(usage.output_tokens) || 0,
282
+ // Codex reports the same thing Claude does under a different name, and the
283
+ // same way round: inside output rather than beside it. Its own totals prove
284
+ // it, with total_tokens coming to input plus output on 11,064 of 11,133
285
+ // recorded turns. So it is carried for reporting and never added to a sum.
286
+ reasoning: Number(usage.reasoning_output_tokens) || 0,
287
+ };
288
+ }
289
+
290
+ function tokensOf(usage) {
291
+ if (!usage) return 0;
292
+ const total = Number(usage.total_tokens);
293
+ if (Number.isFinite(total) && total > 0) return total;
294
+ const parts = partsOf(usage);
295
+ return parts.input + parts.cacheWrite + parts.cacheRead + parts.output;
296
+ }
297
+
298
+ // The model and effort in force are announced once per turn in a `turn_context`
299
+ // line, not repeated on each request, so a reader has to carry the last one
300
+ // forward. Without that every row in the report reads "unknown".
301
+ function contextFrom(line) {
302
+ if (line.indexOf('"turn_context"') === -1) return null;
303
+ let entry;
304
+ try {
305
+ entry = JSON.parse(line);
306
+ } catch (err) {
307
+ return null;
308
+ }
309
+ if (!entry || entry.type !== 'turn_context' || !entry.payload) return null;
310
+ return {
311
+ model: typeof entry.payload.model === 'string' ? entry.payload.model : '',
312
+ effort: typeof entry.payload.effort === 'string' ? entry.payload.effort : null,
313
+ };
314
+ }
315
+
316
+ // One rollout line to an event, or null if it is not a billable request.
317
+ function eventFrom(line, file, project, context) {
318
+ if (line.indexOf('"token_count"') === -1) return null;
319
+ let entry;
320
+ try {
321
+ entry = JSON.parse(line);
322
+ } catch (err) {
323
+ return null;
324
+ }
325
+ const payload = entry && entry.payload;
326
+ if (!payload || payload.type !== 'token_count') return null;
327
+
328
+ const at = Date.parse(entry.timestamp);
329
+ if (!Number.isFinite(at)) return null;
330
+
331
+ const usage = payload.info && payload.info.last_token_usage;
332
+ if (!usage) return null;
333
+
334
+ return {
335
+ at,
336
+ model: (context && context.model) || '',
337
+ effort: (context && context.effort) || null,
338
+ cost: weigh(usage),
339
+ tokens: tokensOf(usage),
340
+ parts: partsOf(usage),
341
+ project: project || null,
342
+ sessionId: sessionOf(file),
343
+ // Carried so the newest meter reading can be picked out of the same pass.
344
+ meter: payload.rate_limits || null,
345
+ };
346
+ }
347
+
348
+ // Codex names the rollout's working directory in its first line. Reading one
349
+ // line per file is cheap and it is what makes the "projects" table mean
350
+ // something.
351
+ function projectOf(file) {
352
+ let raw;
353
+ try {
354
+ const handle = fs.openSync(file, 'r');
355
+ const buffer = Buffer.alloc(4096);
356
+ const read = fs.readSync(handle, buffer, 0, buffer.length, 0);
357
+ fs.closeSync(handle);
358
+ raw = buffer.slice(0, read).toString('utf8');
359
+ } catch (err) {
360
+ return null;
361
+ }
362
+ const newline = raw.indexOf('\n');
363
+ if (newline === -1) return null;
364
+ let entry;
365
+ try {
366
+ entry = JSON.parse(raw.slice(0, newline));
367
+ } catch (err) {
368
+ return null;
369
+ }
370
+ const cwd = entry && entry.payload && entry.payload.cwd;
371
+ return typeof cwd === 'string' ? cwd : null;
372
+ }
373
+
374
+ async function readEvents(since) {
375
+ const files = rolloutFiles(since);
376
+ const events = [];
377
+
378
+ for (const entry of files) {
379
+ const project = projectOf(entry.file);
380
+ const stream = fs.createReadStream(entry.file, { encoding: 'utf8' });
381
+ const lines = readline.createInterface({ input: stream, crlfDelay: Infinity });
382
+ let context = null;
383
+ try {
384
+ for await (const line of lines) {
385
+ const next = contextFrom(line);
386
+ if (next) {
387
+ context = next;
388
+ continue;
389
+ }
390
+ const event = eventFrom(line, entry.file, project, context);
391
+ if (event && event.at >= since) events.push(event);
392
+ }
393
+ } catch (err) {
394
+ // A half-written line at the tail of a live session is expected.
395
+ } finally {
396
+ lines.close();
397
+ stream.destroy();
398
+ }
399
+ }
400
+
401
+ events.sort((a, b) => a.at - b.at);
402
+ return events;
403
+ }
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // The meter
407
+ // ---------------------------------------------------------------------------
408
+
409
+ function isoOf(seconds) {
410
+ const value = Number(seconds);
411
+ if (!Number.isFinite(value) || value <= 0) return null;
412
+ return new Date(value * 1000).toISOString();
413
+ }
414
+
415
+ // Turn one `rate_limits` payload into the shape the window table already reads,
416
+ // so nothing downstream has to know which host it came from.
417
+ // A window is filed under what it actually is. The two lengths the shared table
418
+ // already knows keep its keys, so the calibration learned for a 5-hour window
419
+ // under Claude Code and under Codex stay comparable in shape; anything else is
420
+ // named by its own length so it gets a calibration of its own.
421
+ function keyFor(span, taken) {
422
+ const preferred =
423
+ span === 5 * HOUR ? 'five_hour' : span === 7 * DAY ? 'seven_day' : 'window_' + Math.round(span / MINUTE) + 'm';
424
+ if (!taken || !taken.has(preferred)) return preferred;
425
+ // Two windows of the same length would otherwise overwrite each other.
426
+ let suffix = 2;
427
+ while (taken.has(preferred + '_' + suffix)) suffix += 1;
428
+ return preferred + '_' + suffix;
429
+ }
430
+
431
+ // One reading of one window, with the key it belongs under already worked out.
432
+ //
433
+ // Both the report and the calibration have to agree about which window is
434
+ // which, and they have to keep agreeing as the payload changes shape. Deriving
435
+ // the key in two places is how they stop agreeing: the calibration was once
436
+ // looking the key up in a table that no longer had one, and quietly measured
437
+ // nothing at all. So it is derived here, once.
438
+ function readingsOf(meter) {
439
+ if (!meter || typeof meter !== 'object') return [];
440
+ const taken = new Set();
441
+ const readings = [];
442
+
443
+ for (const entry of SLOTS) {
444
+ const window = meter[entry.slot];
445
+ if (!window || typeof window !== 'object') continue;
446
+ const percent = Number(window.used_percent);
447
+ if (!Number.isFinite(percent)) continue;
448
+
449
+ const minutes = Number(window.window_minutes);
450
+ const span = Number.isFinite(minutes) && minutes > 0 ? minutes * MINUTE : entry.span;
451
+ const key = keyFor(span, taken);
452
+ taken.add(key);
453
+ readings.push({
454
+ key,
455
+ slot: entry.slot,
456
+ span,
457
+ label: labelFor(span, entry.slot),
458
+ percent,
459
+ resetsAt: Number(window.resets_at) || null,
460
+ });
461
+ }
462
+ return readings;
463
+ }
464
+
465
+ function utilizationFrom(meter) {
466
+ if (!meter || typeof meter !== 'object') return null;
467
+
468
+ const utilization = {};
469
+ const specs = [];
470
+
471
+ for (const reading of readingsOf(meter)) {
472
+ utilization[reading.key] = {
473
+ // The meter reports fractional percentages; the rest of the code expects
474
+ // whole numbers, the way Claude's own snapshot reports them.
475
+ utilization: Math.round(reading.percent),
476
+ resets_at: isoOf(reading.resetsAt),
477
+ };
478
+ specs.push({ key: reading.key, label: reading.label, span: reading.span });
479
+ }
480
+
481
+ // No windows is a real answer, not a missing one: on flexible pricing the
482
+ // account has no rolling limit and usage scales with credits. Returning null
483
+ // here would throw away the plan and the credit balance, which on such an
484
+ // account are the only figures there are.
485
+ const credits = meter.credits && typeof meter.credits === 'object' ? meter.credits : null;
486
+ return {
487
+ windowless: specs.length === 0,
488
+ utilization: specs.length ? utilization : null,
489
+ specs,
490
+ planType: typeof meter.plan_type === 'string' ? meter.plan_type : null,
491
+ // Codex says outright when a limit has already been hit, which is a firmer
492
+ // signal than a rounded percentage and must not be rounded away.
493
+ reachedType:
494
+ typeof meter.rate_limit_reached_type === 'string' ? meter.rate_limit_reached_type : null,
495
+ spendControlReached: meter.spend_control_reached === true,
496
+ credits: credits
497
+ ? {
498
+ enabled: credits.has_credits === true || credits.unlimited === true,
499
+ everEnabled: credits.has_credits === true,
500
+ limitReached: false,
501
+ used: null,
502
+ limit: null,
503
+ percent: null,
504
+ unlimited: credits.unlimited === true,
505
+ balance: credits.balance === undefined ? null : credits.balance,
506
+ currency: 'credits',
507
+ disabledReason: null,
508
+ }
509
+ : null,
510
+ };
511
+ }
512
+
513
+ // A span the table already has a name for keeps that name; anything else is
514
+ // described by its own length rather than mislabelled as one of the two. The
515
+ // slot name is the last resort, because "primary" at least does not claim a
516
+ // duration the window may not have.
517
+ function labelFor(span, fallback) {
518
+ if (span === 5 * HOUR) return '5-hour';
519
+ if (span === 7 * DAY) return 'weekly';
520
+ const hours = span / HOUR;
521
+ if (hours >= 24 && hours % 24 === 0) return hours / 24 + '-day';
522
+ if (hours >= 1 && hours % 1 === 0) return hours + '-hour';
523
+ const minutes = Math.round(span / MINUTE);
524
+ return minutes > 0 ? minutes + '-minute' : fallback || 'window';
525
+ }
526
+
527
+ function planFrom(planType) {
528
+ const id = String(planType || '').toLowerCase();
529
+ const known = PLANS[id];
530
+ if (known) return { id, label: known.label, advice: known.advice };
531
+ return {
532
+ id: id || 'unknown',
533
+ // Show whatever was reported rather than "unknown" for a plan name that is
534
+ // simply new.
535
+ label: id ? 'ChatGPT ' + id : 'unknown',
536
+ advice: null,
537
+ };
538
+ }
539
+
540
+ // What one point of a window costs, measured rather than assumed.
541
+ //
542
+ // Claude Code has to infer this: its meter and its transcripts are separate
543
+ // files, so the price of a point is derived from a snapshot and everything
544
+ // spent around it. Codex writes both into the same record, which makes the
545
+ // measurement direct. Every request logs the meter as it stood and what that
546
+ // request cost, so the price of a point is the spend between two readings
547
+ // divided by how far the meter moved between them.
548
+ //
549
+ // The earlier port asked the user to count every turn by hand and refused to
550
+ // estimate without it, which is why it never produced a turn figure at all.
551
+ const MIN_POINTS_MOVED = 2;
552
+ const MIN_SAMPLE_TURNS = 5;
553
+
554
+ function calibrate(events, key, now) {
555
+ const readings = [];
556
+ for (const event of events) {
557
+ if (!event.meter) continue;
558
+ // The same assignment the report uses, so the window being calibrated is
559
+ // certainly the window being reported.
560
+ const reading = readingsOf(event.meter).find((one) => one.key === key);
561
+ if (!reading) continue;
562
+ readings.push({ at: event.at, percent: reading.percent, resetsAt: reading.resetsAt });
563
+ }
564
+ if (readings.length < 2) return null;
565
+
566
+ // Only inside one window instance. A reset makes the meter fall, and pairing
567
+ // across it would measure a negative pace or price a point at almost nothing.
568
+ const last = readings[readings.length - 1];
569
+ let first = last;
570
+ for (let index = readings.length - 1; index >= 0; index -= 1) {
571
+ const reading = readings[index];
572
+ if (reading.resetsAt !== last.resetsAt) break;
573
+ if (reading.percent > last.percent) break;
574
+ first = reading;
575
+ }
576
+
577
+ const moved = last.percent - first.percent;
578
+ if (moved < MIN_POINTS_MOVED) return null;
579
+
580
+ // The spend that moved it is what happened after the first reading was taken,
581
+ // up to and including the last.
582
+ let cost = 0;
583
+ let turns = 0;
584
+ for (const event of events) {
585
+ if (event.at <= first.at || event.at > last.at) continue;
586
+ cost += event.cost;
587
+ turns += 1;
588
+ }
589
+ if (turns < MIN_SAMPLE_TURNS || cost <= 0) return null;
590
+
591
+ return { usdPerPercent: cost / moved, turns, percent: Math.round(last.percent) };
592
+ }
593
+
594
+ // The newest meter reading in the rollouts, and when it was taken.
595
+ function latestMeter(events) {
596
+ for (let index = events.length - 1; index >= 0; index -= 1) {
597
+ if (events[index].meter) return { meter: events[index].meter, at: events[index].at };
598
+ }
599
+ return null;
600
+ }
601
+
602
+ // Scanning only the newest few rollouts, for the meter alone. `collect` runs on
603
+ // the status-line path where a full scan would be far too slow.
604
+ function meterFromDisk() {
605
+ const files = rolloutFiles(NaN).slice(-12).reverse();
606
+ for (const entry of files) {
607
+ let raw;
608
+ try {
609
+ raw = fs.readFileSync(entry.file, 'utf8');
610
+ } catch (err) {
611
+ continue;
612
+ }
613
+ const lines = raw.split('\n');
614
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
615
+ const line = lines[index];
616
+ if (!line || line.indexOf('"token_count"') === -1) continue;
617
+ let parsed;
618
+ try {
619
+ parsed = JSON.parse(line);
620
+ } catch (err) {
621
+ continue;
622
+ }
623
+ const meter = parsed && parsed.payload && parsed.payload.rate_limits;
624
+ const at = Date.parse(parsed && parsed.timestamp);
625
+ if (meter && Number.isFinite(at)) return { meter, at };
626
+ }
627
+ }
628
+ return null;
629
+ }
630
+
631
+ function collect(now, options) {
632
+ const found = (options && options.meter) || meterFromDisk();
633
+ const mapped = found ? utilizationFrom(found.meter) : null;
634
+ const plan = planFrom(mapped && mapped.planType);
635
+
636
+ return {
637
+ now,
638
+ host: host.CODEX,
639
+ // Codex quotes a percentage of an allowance, never a price, so there is no
640
+ // honest money column to print.
641
+ money: false,
642
+ accountFile: sessionsDir(),
643
+ plan: plan.label,
644
+ planId: plan.id,
645
+ planTier: null,
646
+ planAdvice: plan.advice,
647
+ snapshotAgeMs: found ? now - found.at : null,
648
+ snapshotFetchedAt: found ? found.at : null,
649
+ utilization: mapped ? mapped.utilization : null,
650
+ // The account was read and genuinely reports no rolling window, which is
651
+ // what flexible pricing looks like. That is a different thing from having
652
+ // found nothing to read, and it needs to be said differently.
653
+ windowless: Boolean(mapped && mapped.windowless),
654
+ windowSpecs: mapped ? mapped.specs : null,
655
+ reachedType: mapped ? mapped.reachedType : null,
656
+ spendControlReached: Boolean(mapped && mapped.spendControlReached),
657
+ codexCredits: mapped ? mapped.credits : null,
658
+ settings: {
659
+ model: readConfigValue('model') || 'default',
660
+ effortLevel: readConfigValue('model_reasoning_effort') || 'default',
661
+ },
662
+ extraUsage: null,
663
+ };
664
+ }
665
+
666
+ function readConfigValue(key) {
667
+ let raw;
668
+ try {
669
+ raw = fs.readFileSync(path.join(homeDir(), 'config.toml'), 'utf8');
670
+ } catch (err) {
671
+ return null;
672
+ }
673
+ // Only the top-level table; a key of the same name inside a section is a
674
+ // different setting.
675
+ const head = raw.split(/^\s*\[/m)[0];
676
+ const match = new RegExp('^\\s*' + key + '\\s*=\\s*[\'"](.+?)[\'"]\\s*$', 'm').exec(head);
677
+ return match ? match[1] : null;
678
+ }
679
+
680
+ // ---------------------------------------------------------------------------
681
+ // A live reading
682
+ // ---------------------------------------------------------------------------
683
+
684
+ // Ask Codex for the meter now, rather than taking the newest one it happened to
685
+ // write. Costs about a second and a child process, so it is for when the
686
+ // rollout reading has gone stale, not for every prompt.
687
+ function refresh(options) {
688
+ const settings = options || {};
689
+ const executable = findExecutable(settings.codexPath);
690
+ if (!executable) {
691
+ return Promise.reject(
692
+ Object.assign(new Error('Codex was not found on this machine.'), { code: 'CODEX_NOT_FOUND' })
693
+ );
694
+ }
695
+
696
+ const childProcess = require('child_process');
697
+ return new Promise((resolve, reject) => {
698
+ let child;
699
+ try {
700
+ child = childProcess.spawn(executable, ['app-server'], {
701
+ shell: false,
702
+ windowsHide: true,
703
+ stdio: ['pipe', 'pipe', 'ignore'],
704
+ });
705
+ } catch (err) {
706
+ reject(Object.assign(new Error('Codex could not be started.'), { code: 'CODEX_START_FAILED' }));
707
+ return;
708
+ }
709
+
710
+ let settled = false;
711
+ let buffer = '';
712
+ let bytes = 0;
713
+ const limit = 2 * 1024 * 1024;
714
+
715
+ const finish = (err, value) => {
716
+ if (settled) return;
717
+ settled = true;
718
+ clearTimeout(timer);
719
+ try {
720
+ child.stdin.end();
721
+ } catch (error) {
722
+ // Already gone.
723
+ }
724
+ // Closing stdin is how app-server is asked to stop. Kill only if it does
725
+ // not take the hint, and only ever this one process.
726
+ const grace = setTimeout(() => {
727
+ try {
728
+ child.kill();
729
+ } catch (error) {
730
+ // Already gone.
731
+ }
732
+ }, 1000);
733
+ if (grace.unref) grace.unref();
734
+ if (err) reject(err);
735
+ else resolve(value);
736
+ };
737
+
738
+ const timer = setTimeout(
739
+ () => finish(Object.assign(new Error('Codex did not answer in time.'), { code: 'CODEX_TIMEOUT' })),
740
+ Number.isFinite(settings.timeoutMs) ? settings.timeoutMs : 15000
741
+ );
742
+
743
+ child.once('error', (err) =>
744
+ finish(
745
+ Object.assign(new Error('Codex could not be started.'), {
746
+ code: err && err.code === 'ENOENT' ? 'CODEX_NOT_FOUND' : 'CODEX_START_FAILED',
747
+ })
748
+ )
749
+ );
750
+ child.once('close', () =>
751
+ finish(
752
+ Object.assign(new Error('Codex closed before reporting its limits.'), {
753
+ code: 'CODEX_CLOSED',
754
+ })
755
+ )
756
+ );
757
+
758
+ child.stdout.setEncoding('utf8');
759
+ child.stdout.on('data', (chunk) => {
760
+ bytes += chunk.length;
761
+ if (bytes > limit) {
762
+ finish(Object.assign(new Error('Codex sent too much.'), { code: 'CODEX_OUTPUT_LIMIT' }));
763
+ return;
764
+ }
765
+ buffer += chunk;
766
+ let newline;
767
+ while ((newline = buffer.indexOf('\n')) !== -1) {
768
+ const line = buffer.slice(0, newline).trim();
769
+ buffer = buffer.slice(newline + 1);
770
+ if (!line) continue;
771
+ let message;
772
+ try {
773
+ message = JSON.parse(line);
774
+ } catch (err) {
775
+ continue;
776
+ }
777
+ // A server-initiated request is answered with a refusal and nothing
778
+ // else; this client never approves anything or hands over credentials.
779
+ if (message && message.method && message.id !== undefined) {
780
+ write({
781
+ id: message.id,
782
+ error: { code: -32601, message: 'This reader does not support server requests.' },
783
+ });
784
+ continue;
785
+ }
786
+ if (!message || message.id === undefined) continue;
787
+ if (message.id === 1 && message.result) {
788
+ write({ method: 'initialized', params: {} });
789
+ write({ method: 'account/rateLimits/read', id: 2 });
790
+ continue;
791
+ }
792
+ if (message.id === 2) {
793
+ if (message.error) {
794
+ finish(
795
+ Object.assign(new Error('Codex would not report its limits.'), {
796
+ code: 'CODEX_LIMITS_UNAVAILABLE',
797
+ })
798
+ );
799
+ return;
800
+ }
801
+ const limits = message.result && message.result.rateLimits;
802
+ finish(null, { at: Date.now(), meter: limits || null });
803
+ return;
804
+ }
805
+ }
806
+ });
807
+
808
+ const write = (message) => {
809
+ try {
810
+ child.stdin.write(JSON.stringify(message) + '\n');
811
+ } catch (err) {
812
+ finish(Object.assign(new Error('Codex stopped listening.'), { code: 'CODEX_CLOSED' }));
813
+ }
814
+ };
815
+
816
+ write({
817
+ method: 'initialize',
818
+ id: 1,
819
+ params: {
820
+ clientInfo: { name: 'usage-limits', title: 'Usage Limits', version: '1.0.0' },
821
+ },
822
+ });
823
+ });
824
+ }
825
+
826
+ module.exports = {
827
+ SLOTS,
828
+ WEIGHTS,
829
+ PLANS,
830
+ homeDir,
831
+ sessionsDir,
832
+ findExecutable,
833
+ windowsCandidates,
834
+ fromConfig,
835
+ rolloutFiles,
836
+ sessionOf,
837
+ weigh,
838
+ partsOf,
839
+ tokensOf,
840
+ contextFrom,
841
+ eventFrom,
842
+ readEvents,
843
+ keyFor,
844
+ readingsOf,
845
+ utilizationFrom,
846
+ labelFor,
847
+ planFrom,
848
+ latestMeter,
849
+ meterFromDisk,
850
+ calibrate,
851
+ MIN_POINTS_MOVED,
852
+ MIN_SAMPLE_TURNS,
853
+ collect,
854
+ refresh,
855
+ };