claude-usage-limits 1.6.1 → 1.7.1
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +40 -0
- package/README.md +70 -7
- package/agents/openai.yaml +4 -0
- package/bin/cli.js +16 -3
- package/commands/check.md +7 -2
- package/hooks/hooks.json +12 -1
- package/package.json +5 -3
- package/skills/usage-limits/SKILL.md +113 -16
- package/skills/usage-limits/agents/openai.yaml +4 -0
- package/skills/usage-limits/scripts/brief.js +252 -52
- package/skills/usage-limits/scripts/codex.js +855 -0
- package/skills/usage-limits/scripts/host.js +96 -0
- package/skills/usage-limits/scripts/install-codex-hook.js +448 -0
- package/skills/usage-limits/scripts/pulse.js +227 -0
- package/skills/usage-limits/scripts/usage.js +574 -50
|
@@ -21,6 +21,28 @@ const os = require('os');
|
|
|
21
21
|
const path = require('path');
|
|
22
22
|
const readline = require('readline');
|
|
23
23
|
|
|
24
|
+
const host = require('./host.js');
|
|
25
|
+
const codex = require('./codex.js');
|
|
26
|
+
|
|
27
|
+
// Which agent's meter to read. Resolved once from the command line or the
|
|
28
|
+
// environment, because a process that changed its mind halfway through would
|
|
29
|
+
// mix one host's percentages with the other's turns.
|
|
30
|
+
let activeHost = null;
|
|
31
|
+
|
|
32
|
+
function currentHost() {
|
|
33
|
+
if (!activeHost) activeHost = host.detect(process.argv.slice(2), process.env);
|
|
34
|
+
return activeHost;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function setHost(name) {
|
|
38
|
+
activeHost = host.normalise(name) || host.CLAUDE;
|
|
39
|
+
return activeHost;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isCodex() {
|
|
43
|
+
return currentHost() === host.CODEX;
|
|
44
|
+
}
|
|
45
|
+
|
|
24
46
|
const MINUTE = 60 * 1000;
|
|
25
47
|
const HOUR = 60 * MINUTE;
|
|
26
48
|
const DAY = 24 * HOUR;
|
|
@@ -206,7 +228,7 @@ function tokensOf(usage) {
|
|
|
206
228
|
// The four token classes, kept apart because they are priced differently
|
|
207
229
|
// and because knowing the split is what makes the totals reasonable about.
|
|
208
230
|
function tokenParts(usage) {
|
|
209
|
-
if (!usage) return { input: 0, cacheWrite: 0, cacheRead: 0, output: 0 };
|
|
231
|
+
if (!usage) return { input: 0, cacheWrite: 0, cacheRead: 0, output: 0, reasoning: 0 };
|
|
210
232
|
const creation = usage.cache_creation || {};
|
|
211
233
|
const written =
|
|
212
234
|
usage.cache_creation_input_tokens ||
|
|
@@ -216,6 +238,16 @@ function tokenParts(usage) {
|
|
|
216
238
|
cacheWrite: written,
|
|
217
239
|
cacheRead: usage.cache_read_input_tokens || 0,
|
|
218
240
|
output: usage.output_tokens || 0,
|
|
241
|
+
// Reasoning is not a fifth class of token, it is a slice of the fourth.
|
|
242
|
+
// Measured over 1,565 turns of this account's transcripts, thinking never
|
|
243
|
+
// once exceeded output, so it is counted inside it and must not be added to
|
|
244
|
+
// any total: doing that would price every thinking turn twice.
|
|
245
|
+
//
|
|
246
|
+
// It is worth carrying separately all the same. Output is the dearest class
|
|
247
|
+
// there is, reasoning is about half of it, and it is the one part of the
|
|
248
|
+
// bill a setting can change. The skill has always said so; this is the
|
|
249
|
+
// number that says how much.
|
|
250
|
+
reasoning: (usage.output_tokens_details && usage.output_tokens_details.thinking_tokens) || 0,
|
|
219
251
|
};
|
|
220
252
|
}
|
|
221
253
|
|
|
@@ -233,6 +265,37 @@ function eventFrom(line, seen, project) {
|
|
|
233
265
|
const at = Date.parse(entry.timestamp);
|
|
234
266
|
if (!Number.isFinite(at)) return null;
|
|
235
267
|
|
|
268
|
+
// A request the limit refused is written like an assistant turn, with a
|
|
269
|
+
// synthetic model and a usage block of zeros. Two things follow.
|
|
270
|
+
//
|
|
271
|
+
// It is not a turn, and counting it as one dilutes the measured cost per turn
|
|
272
|
+
// with free ones, which makes the remaining headroom read longer than it is.
|
|
273
|
+
//
|
|
274
|
+
// And it is the only place the account says outright which window stopped the
|
|
275
|
+
// work and when that window comes back. The cached snapshot reports the
|
|
276
|
+
// 5-hour bucket as 0% with a null reset on this plan, so without reading
|
|
277
|
+
// these there is nothing at all to anchor that window to.
|
|
278
|
+
const quota = entry.quotaLimits;
|
|
279
|
+
if (entry.isApiErrorMessage && quota && typeof quota === 'object') {
|
|
280
|
+
const resetsAt = Number(quota.resetsAt);
|
|
281
|
+
return {
|
|
282
|
+
at,
|
|
283
|
+
model: '',
|
|
284
|
+
effort: null,
|
|
285
|
+
cost: 0,
|
|
286
|
+
tokens: 0,
|
|
287
|
+
parts: { input: 0, cacheWrite: 0, cacheRead: 0, output: 0, reasoning: 0 },
|
|
288
|
+
project: project || null,
|
|
289
|
+
sessionId: entry.sessionId || null,
|
|
290
|
+
rejected: {
|
|
291
|
+
status: typeof quota.status === 'string' ? quota.status : null,
|
|
292
|
+
key: typeof quota.rateLimitType === 'string' ? quota.rateLimitType : null,
|
|
293
|
+
// Seconds on the wire, milliseconds everywhere in here.
|
|
294
|
+
resetsAt: Number.isFinite(resetsAt) && resetsAt > 0 ? resetsAt * 1000 : null,
|
|
295
|
+
},
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
236
299
|
// A resumed or forked session repeats earlier turns in a new file.
|
|
237
300
|
const id = (entry.message.id || '') + '|' + (entry.requestId || '');
|
|
238
301
|
if (id !== '|' && seen) {
|
|
@@ -253,6 +316,11 @@ function eventFrom(line, seen, project) {
|
|
|
253
316
|
}
|
|
254
317
|
|
|
255
318
|
async function readEvents(since) {
|
|
319
|
+
if (isCodex()) return codex.readEvents(since);
|
|
320
|
+
return readClaudeEvents(since);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function readClaudeEvents(since) {
|
|
256
324
|
const root = path.join(configDir(), 'projects');
|
|
257
325
|
let dirs = [];
|
|
258
326
|
try {
|
|
@@ -309,7 +377,7 @@ async function readEvents(since) {
|
|
|
309
377
|
function totals(events) {
|
|
310
378
|
let cost = 0;
|
|
311
379
|
let tokens = 0;
|
|
312
|
-
const parts = { input: 0, cacheWrite: 0, cacheRead: 0, output: 0 };
|
|
380
|
+
const parts = { input: 0, cacheWrite: 0, cacheRead: 0, output: 0, reasoning: 0 };
|
|
313
381
|
for (const event of events) {
|
|
314
382
|
cost += event.cost;
|
|
315
383
|
tokens += event.tokens;
|
|
@@ -318,6 +386,7 @@ function totals(events) {
|
|
|
318
386
|
parts.cacheWrite += event.parts.cacheWrite;
|
|
319
387
|
parts.cacheRead += event.parts.cacheRead;
|
|
320
388
|
parts.output += event.parts.output;
|
|
389
|
+
parts.reasoning += event.parts.reasoning || 0;
|
|
321
390
|
}
|
|
322
391
|
}
|
|
323
392
|
return { cost, tokens, turns: events.length, parts };
|
|
@@ -335,7 +404,7 @@ function byModel(events) {
|
|
|
335
404
|
turns: 0,
|
|
336
405
|
tokens: 0,
|
|
337
406
|
cost: 0,
|
|
338
|
-
parts: { input: 0, cacheWrite: 0, cacheRead: 0, output: 0 },
|
|
407
|
+
parts: { input: 0, cacheWrite: 0, cacheRead: 0, output: 0, reasoning: 0 },
|
|
339
408
|
});
|
|
340
409
|
}
|
|
341
410
|
const row = rows.get(id);
|
|
@@ -347,6 +416,7 @@ function byModel(events) {
|
|
|
347
416
|
row.parts.cacheWrite += event.parts.cacheWrite;
|
|
348
417
|
row.parts.cacheRead += event.parts.cacheRead;
|
|
349
418
|
row.parts.output += event.parts.output;
|
|
419
|
+
row.parts.reasoning += event.parts.reasoning || 0;
|
|
350
420
|
}
|
|
351
421
|
}
|
|
352
422
|
const list = [...rows.values()].sort((a, b) => b.cost - a.cost);
|
|
@@ -382,7 +452,22 @@ function typicalTurnCost(recentEvents, windowEvents, allEvents, minSample) {
|
|
|
382
452
|
.filter((cost) => Number.isFinite(cost) && cost > 0)
|
|
383
453
|
.sort((a, b) => a - b);
|
|
384
454
|
if (!costs.length) return null;
|
|
385
|
-
|
|
455
|
+
|
|
456
|
+
// The middle turn resists a freak one, which is the point, but it is the
|
|
457
|
+
// wrong statistic for counting how many more turns fit. Turn costs are
|
|
458
|
+
// skewed: most are cheap, a few are far dearer, and they get dearer still as
|
|
459
|
+
// the context grows. The remaining budget is divided by the *average*, so
|
|
460
|
+
// taking the middle one systematically promises more turns than there are.
|
|
461
|
+
// Measured on the window that ran out on 2026-08-30: 182 turns promised, 110
|
|
462
|
+
// actually left.
|
|
463
|
+
//
|
|
464
|
+
// Trimming both ends and averaging what is left keeps the resistance to a
|
|
465
|
+
// single $7 turn while respecting the skew, and errs toward under-promising,
|
|
466
|
+
// which is the safe direction for a budget.
|
|
467
|
+
const cut = costs.length >= MIN_PACE_SAMPLE ? Math.max(1, Math.round(costs.length * 0.1)) : 0;
|
|
468
|
+
const kept = cut > 0 ? costs.slice(cut, costs.length - cut) : costs;
|
|
469
|
+
const middle = kept.length ? kept : costs;
|
|
470
|
+
return middle.reduce((sum, cost) => sum + cost, 0) / middle.length;
|
|
386
471
|
}
|
|
387
472
|
|
|
388
473
|
function dominantEffort(events) {
|
|
@@ -398,6 +483,36 @@ function dominantEffort(events) {
|
|
|
398
483
|
return best ? best[0] : null;
|
|
399
484
|
}
|
|
400
485
|
|
|
486
|
+
// What the thinking actually cost, rather than what it is generally said to
|
|
487
|
+
// cost. Reasoning is billed as output, so it is priced at the output rate of
|
|
488
|
+
// whichever models did the thinking, weighted by how much each of them did.
|
|
489
|
+
//
|
|
490
|
+
// This deliberately does not try to tell an `ultrathink` turn from a high
|
|
491
|
+
// effort setting from a model that simply chose to think. They are the same
|
|
492
|
+
// spend and the same lever, and the transcript does not reliably separate them
|
|
493
|
+
// anyway. What matters is how much of the bill is reasoning.
|
|
494
|
+
function reasoningSpend(models, tokens) {
|
|
495
|
+
const total = tokens && Number.isFinite(tokens.reasoning) ? tokens.reasoning : 0;
|
|
496
|
+
const output = tokens && Number.isFinite(tokens.output) ? tokens.output : 0;
|
|
497
|
+
if (total <= 0 || output <= 0) return null;
|
|
498
|
+
|
|
499
|
+
let cost = 0;
|
|
500
|
+
let priced = 0;
|
|
501
|
+
for (const row of models || []) {
|
|
502
|
+
const amount = row.parts && Number.isFinite(row.parts.reasoning) ? row.parts.reasoning : 0;
|
|
503
|
+
if (amount <= 0) continue;
|
|
504
|
+
cost += (amount * rateFor(row.model).output) / 1e6;
|
|
505
|
+
priced += amount;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return {
|
|
509
|
+
tokens: total,
|
|
510
|
+
shareOfOutput: total / output,
|
|
511
|
+
// Only claim a price when the models that did the thinking were priced.
|
|
512
|
+
cost: priced > 0 ? cost : null,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
401
516
|
// Which project directory the spend went to. Claude Code names these after
|
|
402
517
|
// the working directory, so they are recognisable even though the mangling
|
|
403
518
|
// is not reversible.
|
|
@@ -537,8 +652,12 @@ function shareOf(sessions, sessionId) {
|
|
|
537
652
|
// whatever slice happens to be to hand. A thin baseline prices a point badly
|
|
538
653
|
// and every correction built on it inherits the error: a 24 minute old
|
|
539
654
|
// snapshot once turned a window truly at 70 per cent into a confident 82.
|
|
655
|
+
// Kept beside whichever agent it describes. The two hosts happen to use the
|
|
656
|
+
// same window keys, so a shared file would price a Codex point with what a
|
|
657
|
+
// Claude point costs and be wrong on both.
|
|
540
658
|
function calibrationFile() {
|
|
541
|
-
|
|
659
|
+
const dir = isCodex() ? codex.homeDir() : configDir();
|
|
660
|
+
return path.join(dir, 'usage-limits-calibration.json');
|
|
542
661
|
}
|
|
543
662
|
|
|
544
663
|
function readCalibration() {
|
|
@@ -559,6 +678,53 @@ function writeCalibration(all) {
|
|
|
559
678
|
}
|
|
560
679
|
}
|
|
561
680
|
|
|
681
|
+
// Everything learned about a budget belongs to the plan it was learned on.
|
|
682
|
+
//
|
|
683
|
+
// A point of a window is a share of an allowance, so changing the allowance
|
|
684
|
+
// changes what a point is worth, and every figure derived from the old one is
|
|
685
|
+
// then wrong by the ratio between the plans. Upgrading Pro to Max 5x is roughly
|
|
686
|
+
// a fivefold move: a calibration saying a point costs $0.40 keeps being applied
|
|
687
|
+
// to a point now worth several times that, and the turn estimates built on it
|
|
688
|
+
// are wrong in the direction that promises room there is not.
|
|
689
|
+
//
|
|
690
|
+
// There is no timestamp anywhere that says when the plan changed.
|
|
691
|
+
// `subscriptionCreatedAt` is the original signup, not the upgrade. So the plan
|
|
692
|
+
// is stamped onto the calibration instead, and a stamp that no longer matches
|
|
693
|
+
// is itself the proof that it moved.
|
|
694
|
+
function calibrationForPlan(all, planId) {
|
|
695
|
+
const kept = {};
|
|
696
|
+
let dropped = false;
|
|
697
|
+
for (const key of Object.keys(all || {})) {
|
|
698
|
+
const entry = all[key];
|
|
699
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
700
|
+
// A stamp that no longer matches is proof the plan moved, and worth saying
|
|
701
|
+
// so: the reading on disk was measured against the other allowance too.
|
|
702
|
+
if (entry.plan && planId && entry.plan !== planId) {
|
|
703
|
+
dropped = true;
|
|
704
|
+
continue;
|
|
705
|
+
}
|
|
706
|
+
// An entry saved before the stamp existed cannot be shown to belong to this
|
|
707
|
+
// plan. Keeping it would be assuming the answer, and the cost of assuming
|
|
708
|
+
// wrong is the whole bug this guards: a point priced for Pro applied to a
|
|
709
|
+
// Max window, promising several times the turns that exist. It is dropped
|
|
710
|
+
// instead, which costs one relearn and says "unknown" in the meantime.
|
|
711
|
+
// Unknown is not claimed as a plan change, because it is not evidence of
|
|
712
|
+
// one; every install upgrading to this version passes through here once.
|
|
713
|
+
if (!entry.plan && planId) continue;
|
|
714
|
+
kept[key] = entry;
|
|
715
|
+
}
|
|
716
|
+
return { learned: kept, planChanged: dropped };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function stampPlan(all, planId) {
|
|
720
|
+
if (!planId) return all;
|
|
721
|
+
const stamped = {};
|
|
722
|
+
for (const key of Object.keys(all || {})) {
|
|
723
|
+
stamped[key] = Object.assign({}, all[key], { plan: planId });
|
|
724
|
+
}
|
|
725
|
+
return stamped;
|
|
726
|
+
}
|
|
727
|
+
|
|
562
728
|
// A sample is better when it rests on more turns. Percentages read in whole
|
|
563
729
|
// numbers, so a bigger percentage also divides more precisely.
|
|
564
730
|
function betterCalibration(current, candidate) {
|
|
@@ -640,11 +806,18 @@ function buildWindow(spec, snapshot, events, now, options) {
|
|
|
640
806
|
// was truly at 88% was reported at 49%. Calibrate on spend up to the reading
|
|
641
807
|
// only, or the very spend being accounted for inflates the price per point
|
|
642
808
|
// and shrinks its own correction.
|
|
809
|
+
// A reading with no reset time cannot be told apart from a current one by
|
|
810
|
+
// looking at it, so age is the only guide. Once the snapshot is older than
|
|
811
|
+
// the window itself, whatever it says describes a window that has since
|
|
812
|
+
// rolled over at least once, and quoting it as current is how a long gap
|
|
813
|
+
// ends up reported as a full budget.
|
|
814
|
+
window.snapshotOlderThanWindow =
|
|
815
|
+
Number.isFinite(extra.fetchedAt) && now - extra.fetchedAt >= spec.span;
|
|
816
|
+
|
|
643
817
|
let percent = rawPercent;
|
|
644
818
|
let sinceSnapshot = 0;
|
|
645
819
|
if (
|
|
646
820
|
rawPercent !== null &&
|
|
647
|
-
rawPercent > 0 &&
|
|
648
821
|
!window.stale &&
|
|
649
822
|
Number.isFinite(extra.fetchedAt) &&
|
|
650
823
|
extra.fetchedAt > start
|
|
@@ -656,13 +829,29 @@ function buildWindow(spec, snapshot, events, now, options) {
|
|
|
656
829
|
// turns makes it far too cheap, and every dollar spent since then is then
|
|
657
830
|
// divided by that, which is how a window truly at 55% got corrected all the
|
|
658
831
|
// way to a confident 100.
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
832
|
+
//
|
|
833
|
+
// A reading of exactly 0 cannot price itself at all: there is no meter
|
|
834
|
+
// movement to divide the spend by. That used to end the correction here,
|
|
835
|
+
// which was the worst place to stop, because 0% is what a window reads
|
|
836
|
+
// right after a reset and therefore what a snapshot most often goes stale
|
|
837
|
+
// holding. The learned price covers it: what a point costs is a property
|
|
838
|
+
// of the plan, not of this reading.
|
|
839
|
+
const selfPriced =
|
|
840
|
+
rawPercent > 0 && upTo.cost > 0 && upTo.turns >= MIN_BASELINE_TURNS
|
|
841
|
+
? { usdPerPercent: upTo.cost / rawPercent, turns: upTo.turns, percent: rawPercent }
|
|
842
|
+
: null;
|
|
843
|
+
if (selfPriced) window.calibration = selfPriced;
|
|
844
|
+
|
|
845
|
+
const known = extra.knownCalibration;
|
|
846
|
+
const usable =
|
|
847
|
+
known && Number.isFinite(known.usdPerPercent) && known.usdPerPercent > 0 ? known : null;
|
|
848
|
+
// Trust the better-sampled of the two, whichever that is.
|
|
849
|
+
const chosen =
|
|
850
|
+
selfPriced && usable
|
|
851
|
+
? (usable.turns > selfPriced.turns ? usable : selfPriced)
|
|
852
|
+
: selfPriced || usable;
|
|
853
|
+
|
|
854
|
+
if (after.cost > 0 && chosen) {
|
|
666
855
|
const pricePerPoint = chosen.usdPerPercent;
|
|
667
856
|
sinceSnapshot = after.cost / pricePerPoint;
|
|
668
857
|
|
|
@@ -682,13 +871,27 @@ function buildWindow(spec, snapshot, events, now, options) {
|
|
|
682
871
|
}
|
|
683
872
|
}
|
|
684
873
|
|
|
685
|
-
|
|
874
|
+
// Pricing a point off this window's own spend only works when this machine
|
|
875
|
+
// did most of that spending. It often has not: another device, a cloud task,
|
|
876
|
+
// or simply a window that opened before the local history did, and then a
|
|
877
|
+
// meter reading 61% divides by almost nothing and every remaining point looks
|
|
878
|
+
// free. The visible symptom is a window with plenty left reporting one turn
|
|
879
|
+
// of headroom, which is worse advice than reporting none.
|
|
880
|
+
// A couple of turns cannot account for a meter already well into the window,
|
|
881
|
+
// so when the two disagree that badly it is the local history that is
|
|
882
|
+
// incomplete, not the meter.
|
|
883
|
+
const thin = spent.turns < MIN_BASELINE_TURNS && percent >= UNEXPLAINED_PERCENT;
|
|
884
|
+
const measured = percent !== null && percent > 0 && spent.cost > 0 && !thin;
|
|
885
|
+
const derived = measured ? spent.cost / percent : null;
|
|
886
|
+
const known = extra.knownCalibration;
|
|
686
887
|
const priced =
|
|
687
888
|
derived !== null
|
|
688
889
|
? derived
|
|
689
890
|
: Number.isFinite(extra.usdPerPercent) && extra.usdPerPercent > 0
|
|
690
891
|
? extra.usdPerPercent
|
|
691
|
-
:
|
|
892
|
+
: known && Number.isFinite(known.usdPerPercent) && known.usdPerPercent > 0
|
|
893
|
+
? known.usdPerPercent
|
|
894
|
+
: null;
|
|
692
895
|
|
|
693
896
|
if (percent !== null && priced !== null) {
|
|
694
897
|
window.usdPerPercent = priced;
|
|
@@ -730,6 +933,23 @@ function buildWindow(spec, snapshot, events, now, options) {
|
|
|
730
933
|
return window;
|
|
731
934
|
}
|
|
732
935
|
|
|
936
|
+
// The most recent time the account actually refused work, per window. This is
|
|
937
|
+
// ground truth: not an estimate of where the budget stands but a record of it
|
|
938
|
+
// having run out, with the window named and its reset time attached.
|
|
939
|
+
function lastRejections(events) {
|
|
940
|
+
const byKey = new Map();
|
|
941
|
+
for (const event of events || []) {
|
|
942
|
+
const rejected = event && event.rejected;
|
|
943
|
+
if (!rejected || rejected.status !== 'rejected') continue;
|
|
944
|
+
const key = rejected.key || 'unknown';
|
|
945
|
+
const seen = byKey.get(key);
|
|
946
|
+
if (!seen || event.at > seen.at) {
|
|
947
|
+
byKey.set(key, { key, at: event.at, resetsAt: rejected.resetsAt });
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
return byKey;
|
|
951
|
+
}
|
|
952
|
+
|
|
733
953
|
// Which window binds is about what stops you soonest. It says nothing about
|
|
734
954
|
// what stopping costs. Running out of a 5-hour window waits hours; running out
|
|
735
955
|
// of the weekly one waits days. So a weekly window near the wall is worth
|
|
@@ -851,7 +1071,40 @@ function detectPlan(oauth) {
|
|
|
851
1071
|
};
|
|
852
1072
|
}
|
|
853
1073
|
|
|
1074
|
+
// The snapshot carries buckets this table has never heard of, and it gains more
|
|
1075
|
+
// over time: alongside five_hour and seven_day there are per-product and
|
|
1076
|
+
// codenamed limits that come and go. Dropping them silently means a limit that
|
|
1077
|
+
// is actually biting never gets mentioned, so anything carrying real spend is
|
|
1078
|
+
// reported even though there is no span to price it against.
|
|
1079
|
+
const KNOWN_KEYS = new Set(['five_hour', 'seven_day', 'seven_day_opus', 'seven_day_sonnet']);
|
|
1080
|
+
const NOT_A_WINDOW = new Set(['extra_usage', 'spend', 'limits', 'member_dashboard_available']);
|
|
1081
|
+
|
|
1082
|
+
function otherLimits(utilization, threshold) {
|
|
1083
|
+
if (!utilization) return [];
|
|
1084
|
+
const floor = Number.isFinite(threshold) ? threshold : 1;
|
|
1085
|
+
const rows = [];
|
|
1086
|
+
for (const key of Object.keys(utilization)) {
|
|
1087
|
+
if (KNOWN_KEYS.has(key) || NOT_A_WINDOW.has(key)) continue;
|
|
1088
|
+
const bucket = utilization[key];
|
|
1089
|
+
if (!bucket || typeof bucket.utilization !== 'number') continue;
|
|
1090
|
+
if (bucket.utilization < floor) continue;
|
|
1091
|
+
const resetsAt = bucket.resets_at ? Date.parse(bucket.resets_at) : null;
|
|
1092
|
+
rows.push({
|
|
1093
|
+
key,
|
|
1094
|
+
label: key.replace(/_/g, ' '),
|
|
1095
|
+
percentUsed: bucket.utilization,
|
|
1096
|
+
resetsAt: Number.isFinite(resetsAt) ? resetsAt : null,
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
return rows.sort((a, b) => b.percentUsed - a.percentUsed);
|
|
1100
|
+
}
|
|
1101
|
+
|
|
854
1102
|
function collect(now) {
|
|
1103
|
+
if (isCodex()) return codex.collect(now);
|
|
1104
|
+
return collectClaude(now);
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
function collectClaude(now) {
|
|
855
1108
|
const account = readJson(accountFile()) || {};
|
|
856
1109
|
const settings = readJson(path.join(configDir(), 'settings.json')) || {};
|
|
857
1110
|
const cache = account.cachedUsageUtilization || null;
|
|
@@ -861,6 +1114,8 @@ function collect(now) {
|
|
|
861
1114
|
|
|
862
1115
|
return {
|
|
863
1116
|
now,
|
|
1117
|
+
host: host.CLAUDE,
|
|
1118
|
+
money: true,
|
|
864
1119
|
accountFile: accountFile(),
|
|
865
1120
|
plan: plan.label,
|
|
866
1121
|
planId: plan.id,
|
|
@@ -892,6 +1147,10 @@ const SATURATION_LIMIT = 105;
|
|
|
892
1147
|
// Fewer turns than this before the snapshot and a point cannot be priced.
|
|
893
1148
|
const MIN_BASELINE_TURNS = 5;
|
|
894
1149
|
|
|
1150
|
+
// Past this much of a window, a handful of local turns is not what spent it,
|
|
1151
|
+
// so their total is not a fair price for a point.
|
|
1152
|
+
const UNEXPLAINED_PERCENT = 20;
|
|
1153
|
+
|
|
895
1154
|
function reconstructWindow(spec, snapshot, events, now) {
|
|
896
1155
|
if (!snapshot || typeof snapshot.utilization !== 'number') return null;
|
|
897
1156
|
if (snapshot.utilization <= 0) return null;
|
|
@@ -930,20 +1189,90 @@ function reconstructWindow(spec, snapshot, events, now) {
|
|
|
930
1189
|
};
|
|
931
1190
|
}
|
|
932
1191
|
|
|
1192
|
+
// The snapshot can be older than the window it describes without ever looking
|
|
1193
|
+
// stale, because a window with no reset time has nothing to compare against.
|
|
1194
|
+
// That is the ordinary case after a long gap: the 5-hour reading was taken
|
|
1195
|
+
// seven hours ago and says 0%, the window running now started two hours ago,
|
|
1196
|
+
// and the reading is about a window that no longer exists. Adding spend to it
|
|
1197
|
+
// is not the fix, because none of that spend is inside the window it describes.
|
|
1198
|
+
// Rebuilding is: the learned price per point turns this window's own spend
|
|
1199
|
+
// straight into a percentage.
|
|
1200
|
+
function reconstructUnanchored(spec, window, events, now, learned) {
|
|
1201
|
+
if (!window || window.stale) return null;
|
|
1202
|
+
if (!window.snapshotOlderThanWindow) return null;
|
|
1203
|
+
// Only worth doing when the reading is low enough to be the thing that is
|
|
1204
|
+
// wrong. A high reading that is old is already alarming and is left alone.
|
|
1205
|
+
if (window.percentUsed === null || window.percentUsed > 5) return null;
|
|
1206
|
+
if (!learned || !Number.isFinite(learned.usdPerPercent) || learned.usdPerPercent <= 0) {
|
|
1207
|
+
return null;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
const start = window.windowStart;
|
|
1211
|
+
const live = totals(events.filter((event) => event.at >= start && event.at <= now));
|
|
1212
|
+
if (live.cost <= 0) return null;
|
|
1213
|
+
|
|
1214
|
+
const raw = live.cost / learned.usdPerPercent;
|
|
1215
|
+
if (raw < 1) return null;
|
|
1216
|
+
// Same rule as every other rebuild: past this it is the calibration that is
|
|
1217
|
+
// full rather than the window, and claiming a spent budget is worse than
|
|
1218
|
+
// admitting the reading could not be rebuilt.
|
|
1219
|
+
if (raw > SATURATION_LIMIT) return null;
|
|
1220
|
+
|
|
1221
|
+
return buildWindow(
|
|
1222
|
+
spec,
|
|
1223
|
+
{ utilization: Math.min(100, Math.round(raw)), resets_at: null },
|
|
1224
|
+
events,
|
|
1225
|
+
now,
|
|
1226
|
+
{ estimated: true, windowStart: start, usdPerPercent: learned.usdPerPercent }
|
|
1227
|
+
);
|
|
1228
|
+
}
|
|
1229
|
+
|
|
933
1230
|
// No snapshot at all means no windows, which is what tells the report to
|
|
934
1231
|
// explain itself rather than print a table of dashes.
|
|
935
|
-
function buildWindows(utilization, events, now, fetchedAt, learned) {
|
|
1232
|
+
function buildWindows(utilization, events, now, fetchedAt, learned, specs, rejections) {
|
|
936
1233
|
if (!utilization) return [];
|
|
937
|
-
|
|
1234
|
+
const refused = rejections || new Map();
|
|
1235
|
+
// Claude Code's windows are fixed and known. Codex reports the length of each
|
|
1236
|
+
// of its two windows in the payload, so it hands its own spans in rather than
|
|
1237
|
+
// having them assumed.
|
|
1238
|
+
const table = specs && specs.length ? specs : WINDOWS;
|
|
1239
|
+
return table.map((spec) => {
|
|
938
1240
|
const snapshot = utilization[spec.key];
|
|
939
1241
|
// The per-model weekly windows only exist on some plans.
|
|
940
1242
|
if (spec.key !== 'five_hour' && spec.key !== 'seven_day' && !snapshot) return null;
|
|
941
1243
|
|
|
942
|
-
const
|
|
1244
|
+
const known = learned ? learned[spec.key] : null;
|
|
1245
|
+
const refusal = refused.get(spec.key);
|
|
1246
|
+
|
|
1247
|
+
// With no reset time the window has to be treated as rolling, which starts
|
|
1248
|
+
// it five hours ago and sweeps in whatever the window before it spent. When
|
|
1249
|
+
// the account has refused work on this window, its reset time is known
|
|
1250
|
+
// exactly: a refusal in the future is this window's own reset, and one in
|
|
1251
|
+
// the past is the moment the window running now began.
|
|
1252
|
+
let anchored = snapshot;
|
|
1253
|
+
let windowStart;
|
|
1254
|
+
if (snapshot && !snapshot.resets_at && refusal && Number.isFinite(refusal.resetsAt)) {
|
|
1255
|
+
if (refusal.resetsAt > now) {
|
|
1256
|
+
anchored = Object.assign({}, snapshot, {
|
|
1257
|
+
resets_at: new Date(refusal.resetsAt).toISOString(),
|
|
1258
|
+
});
|
|
1259
|
+
} else if (now - refusal.resetsAt < spec.span) {
|
|
1260
|
+
windowStart = refusal.resetsAt;
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
const window = buildWindow(spec, anchored, events, now, {
|
|
943
1265
|
fetchedAt,
|
|
944
|
-
knownCalibration:
|
|
1266
|
+
knownCalibration: known,
|
|
1267
|
+
...(windowStart === undefined ? {} : { windowStart }),
|
|
945
1268
|
});
|
|
946
|
-
if (
|
|
1269
|
+
if (refusal) {
|
|
1270
|
+
window.refusedAt = refusal.at;
|
|
1271
|
+
window.refusedResetsAt = refusal.resetsAt;
|
|
1272
|
+
}
|
|
1273
|
+
if (!window.stale) {
|
|
1274
|
+
return reconstructUnanchored(spec, window, events, now, known) || window;
|
|
1275
|
+
}
|
|
947
1276
|
|
|
948
1277
|
// Rolled over. Rebuild from local history rather than going blind on it.
|
|
949
1278
|
const rebuilt = reconstructWindow(spec, snapshot, events, now);
|
|
@@ -976,33 +1305,72 @@ function sessionSpend(events, sessionId) {
|
|
|
976
1305
|
}
|
|
977
1306
|
|
|
978
1307
|
async function report(now, options) {
|
|
979
|
-
|
|
1308
|
+
let base = collect(now);
|
|
980
1309
|
// A stale snapshot can put a window's start slightly further back than
|
|
981
1310
|
// seven days, so give the scan a day of slack.
|
|
982
1311
|
const earliest = now - 8 * DAY;
|
|
983
|
-
const
|
|
1312
|
+
const all = await readEvents(earliest);
|
|
1313
|
+
// A refused request is a record of the limit, not a turn against it, so it is
|
|
1314
|
+
// kept apart from everything that measures spend or pace.
|
|
1315
|
+
const rejections = lastRejections(all);
|
|
1316
|
+
const events = all.filter((event) => !event.rejected);
|
|
1317
|
+
|
|
1318
|
+
// Codex carries the meter inside the same records as the turns, so the full
|
|
1319
|
+
// scan can find a newer reading than the quick one collect() does. Reuse it
|
|
1320
|
+
// rather than scanning twice or reporting the older of the two.
|
|
1321
|
+
if (isCodex()) {
|
|
1322
|
+
const live = options && options.codexMeter;
|
|
1323
|
+
const latest = live || codex.latestMeter(events);
|
|
1324
|
+
if (latest && (live || !base.snapshotFetchedAt || latest.at > base.snapshotFetchedAt)) {
|
|
1325
|
+
base = codex.collect(now, { meter: latest });
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
const onDisk = readCalibration();
|
|
1330
|
+
// Anything learned on a different plan is void, and its absence is what makes
|
|
1331
|
+
// the report say so rather than quietly pricing this plan with the last one's
|
|
1332
|
+
// numbers.
|
|
1333
|
+
const calibrated = calibrationForPlan(onDisk, base.planId);
|
|
1334
|
+
const learned = Object.assign({}, calibrated.learned);
|
|
1335
|
+
|
|
1336
|
+
// Codex logs the meter next to every request, so the price of a point can be
|
|
1337
|
+
// measured outright instead of inferred. A measurement from this session
|
|
1338
|
+
// beats anything remembered from an earlier one.
|
|
1339
|
+
if (isCodex()) {
|
|
1340
|
+
for (const spec of base.windowSpecs || []) {
|
|
1341
|
+
const measured = codex.calibrate(events, spec.key, now);
|
|
1342
|
+
if (measured) learned[spec.key] = betterCalibration(learned[spec.key], measured);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
984
1345
|
|
|
985
|
-
const learned = readCalibration();
|
|
986
1346
|
const windows = buildWindows(
|
|
987
1347
|
base.utilization,
|
|
988
1348
|
events,
|
|
989
1349
|
now,
|
|
990
1350
|
base.snapshotFetchedAt,
|
|
991
|
-
learned
|
|
1351
|
+
learned,
|
|
1352
|
+
base.windowSpecs,
|
|
1353
|
+
// A refusal describes the allowance that refused it. After a plan change
|
|
1354
|
+
// that allowance is gone, so anchoring the new window to it, or warning
|
|
1355
|
+
// that the room "ran out last time", is describing a budget that no longer
|
|
1356
|
+
// exists.
|
|
1357
|
+
calibrated.planChanged ? new Map() : rejections
|
|
992
1358
|
);
|
|
993
1359
|
|
|
994
1360
|
// Keep the best sample seen so far, so a thin baseline never has to guess.
|
|
995
1361
|
const updated = Object.assign({}, learned);
|
|
996
|
-
let changed = false;
|
|
997
1362
|
for (const window of windows) {
|
|
998
1363
|
if (!window.calibration) continue;
|
|
999
|
-
const best = betterCalibration(
|
|
1000
|
-
if (best
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1364
|
+
const best = betterCalibration(updated[window.key], window.calibration);
|
|
1365
|
+
if (best) updated[window.key] = best;
|
|
1366
|
+
}
|
|
1367
|
+
// Compared against what is actually on disk, so a measurement taken during
|
|
1368
|
+
// this run is saved too rather than only the ones inferred from a window.
|
|
1369
|
+
let changed = calibrated.planChanged;
|
|
1370
|
+
for (const key of Object.keys(updated)) {
|
|
1371
|
+
if (updated[key] !== onDisk[key]) changed = true;
|
|
1004
1372
|
}
|
|
1005
|
-
if (changed) writeCalibration(updated);
|
|
1373
|
+
if (changed) writeCalibration(stampPlan(updated, base.planId));
|
|
1006
1374
|
|
|
1007
1375
|
const recentEvents = events.filter((event) => event.at >= now - HOUR);
|
|
1008
1376
|
const recent = totals(recentEvents);
|
|
@@ -1011,19 +1379,32 @@ async function report(now, options) {
|
|
|
1011
1379
|
const scopeStart = binding ? binding.windowStart : now - 7 * DAY;
|
|
1012
1380
|
const scoped = events.filter((event) => event.at >= scopeStart && event.at <= now);
|
|
1013
1381
|
const scopedTotals = totals(scoped);
|
|
1382
|
+
// Worked out once and used twice: the table of models, and the price of the
|
|
1383
|
+
// reasoning inside it.
|
|
1384
|
+
const scopedModels = byModel(scoped);
|
|
1014
1385
|
|
|
1015
1386
|
return Object.assign({}, base, {
|
|
1016
1387
|
windows,
|
|
1017
1388
|
binding,
|
|
1018
|
-
|
|
1389
|
+
otherLimits: otherLimits(base.utilization),
|
|
1390
|
+
// The plan moved since anything was last learned about it, so the cached
|
|
1391
|
+
// percentage was measured against a different allowance and everything
|
|
1392
|
+
// derived from the old one has been dropped.
|
|
1393
|
+
planChanged: calibrated.planChanged,
|
|
1394
|
+
// The last time the account actually refused work, so a report taken just
|
|
1395
|
+
// after a cutoff says so rather than describing the fresh window as though
|
|
1396
|
+
// nothing happened.
|
|
1397
|
+
lastRefusal: [...rejections.values()].sort((a, b) => b.at - a.at)[0] || null,
|
|
1398
|
+
credits: base.codexCredits || creditsFrom(base.utilization),
|
|
1019
1399
|
sessions: activeSessions(events, now, CONCURRENT_WINDOW_MS),
|
|
1020
1400
|
session: sessionSpend(events, options && options.sessionId),
|
|
1021
1401
|
staleWindows: windows.filter((w) => w.stale).length,
|
|
1022
1402
|
rates: costPercentiles(recentEvents.length >= 5 ? recentEvents : scoped),
|
|
1023
1403
|
resumeAt: binding ? binding.resetsAt : null,
|
|
1024
|
-
models:
|
|
1404
|
+
models: scopedModels,
|
|
1025
1405
|
projects: byProject(scoped),
|
|
1026
1406
|
tokens: scopedTotals.parts,
|
|
1407
|
+
reasoning: reasoningSpend(scopedModels, scopedTotals.parts),
|
|
1027
1408
|
scopeLabel: binding ? binding.label : 'last 7 days',
|
|
1028
1409
|
recent: {
|
|
1029
1410
|
turns: recent.turns,
|
|
@@ -1087,7 +1468,9 @@ function statusLine(collected) {
|
|
|
1087
1468
|
|
|
1088
1469
|
const now = collected.now || Date.now();
|
|
1089
1470
|
const parts = [];
|
|
1090
|
-
for (const spec of
|
|
1471
|
+
for (const spec of collected.windowSpecs && collected.windowSpecs.length
|
|
1472
|
+
? collected.windowSpecs
|
|
1473
|
+
: WINDOWS) {
|
|
1091
1474
|
const snapshot = utilization[spec.key];
|
|
1092
1475
|
if (!snapshot || typeof snapshot.utilization !== 'number') continue;
|
|
1093
1476
|
const resetsAt = snapshot.resets_at ? Date.parse(snapshot.resets_at) : null;
|
|
@@ -1097,11 +1480,16 @@ function statusLine(collected) {
|
|
|
1097
1480
|
percent: snapshot.utilization,
|
|
1098
1481
|
msToReset,
|
|
1099
1482
|
stale: msToReset !== null && msToReset <= 0,
|
|
1483
|
+
// Zero with no reset time is not an empty window, it is a bucket that is
|
|
1484
|
+
// not reporting: a real window at 0% has just reset and says when it will
|
|
1485
|
+
// do so again. The status line cannot scan transcripts to find out which,
|
|
1486
|
+
// so it must not print the flattering reading as though it were measured.
|
|
1487
|
+
unreported: snapshot.utilization === 0 && !Number.isFinite(resetsAt),
|
|
1100
1488
|
});
|
|
1101
1489
|
}
|
|
1102
1490
|
if (!parts.length) return '';
|
|
1103
1491
|
|
|
1104
|
-
const trusted = parts.filter((part) => !part.stale);
|
|
1492
|
+
const trusted = parts.filter((part) => !part.stale && !part.unreported);
|
|
1105
1493
|
const worst = trusted.length
|
|
1106
1494
|
? trusted.reduce((a, b) => (b.percent > a.percent ? b : a))
|
|
1107
1495
|
: null;
|
|
@@ -1109,8 +1497,10 @@ function statusLine(collected) {
|
|
|
1109
1497
|
.map((part) =>
|
|
1110
1498
|
part.stale
|
|
1111
1499
|
? part.label + ' rolling'
|
|
1112
|
-
: part.
|
|
1113
|
-
|
|
1500
|
+
: part.unreported
|
|
1501
|
+
? part.label + ' ?'
|
|
1502
|
+
: part.label + ' ' + part.percent + '%' +
|
|
1503
|
+
(part.msToReset === null ? '' : ' ' + formatDuration(part.msToReset))
|
|
1114
1504
|
)
|
|
1115
1505
|
.join(' ');
|
|
1116
1506
|
|
|
@@ -1119,7 +1509,11 @@ function statusLine(collected) {
|
|
|
1119
1509
|
|
|
1120
1510
|
function render(data) {
|
|
1121
1511
|
const lines = [];
|
|
1122
|
-
|
|
1512
|
+
// Codex meters an allowance and never quotes a price, so its report has no
|
|
1513
|
+
// honest money column. Everything else in the table means the same thing on
|
|
1514
|
+
// both hosts.
|
|
1515
|
+
const money = data.money !== false;
|
|
1516
|
+
lines.push((data.host === host.CODEX ? 'Codex usage' : 'Claude Code usage'));
|
|
1123
1517
|
lines.push('');
|
|
1124
1518
|
lines.push(' Plan ' + data.plan);
|
|
1125
1519
|
lines.push(
|
|
@@ -1127,10 +1521,20 @@ function render(data) {
|
|
|
1127
1521
|
(data.snapshotAgeMs === null ? 'none on disk' : formatDuration(data.snapshotAgeMs) + ' old')
|
|
1128
1522
|
);
|
|
1129
1523
|
lines.push(' Settings model=' + data.settings.model + ' effort=' + data.settings.effortLevel);
|
|
1524
|
+
if (data.planChanged) {
|
|
1525
|
+
lines.push(' Plan change this is a different plan from the one the figures below');
|
|
1526
|
+
lines.push(' were learned on, so what a point of a window is worth has');
|
|
1527
|
+
lines.push(' changed with it. The cached reading may predate the change:');
|
|
1528
|
+
lines.push(' run /usage for one measured against this plan.');
|
|
1529
|
+
}
|
|
1130
1530
|
const credits = data.credits;
|
|
1131
|
-
if (credits) {
|
|
1531
|
+
if (credits && credits.unlimited) {
|
|
1532
|
+
lines.push(' Credits unlimited');
|
|
1533
|
+
} else if (credits) {
|
|
1132
1534
|
if (!credits.enabled) {
|
|
1133
1535
|
lines.push(' Credits off, work stops when the plan allowance runs out');
|
|
1536
|
+
} else if (!money) {
|
|
1537
|
+
lines.push(' Credits on, balance ' + (credits.balance === null ? 'unknown' : credits.balance));
|
|
1134
1538
|
} else {
|
|
1135
1539
|
const amounts =
|
|
1136
1540
|
credits.used === null
|
|
@@ -1147,14 +1551,31 @@ function render(data) {
|
|
|
1147
1551
|
lines.push('');
|
|
1148
1552
|
|
|
1149
1553
|
if (!data.windows.length) {
|
|
1554
|
+
// Two different situations, and telling them apart matters. Nothing to read
|
|
1555
|
+
// is a setup problem. Nothing to report is the correct answer on a plan
|
|
1556
|
+
// whose usage scales with credits rather than resetting on a clock.
|
|
1557
|
+
if (data.windowless) {
|
|
1558
|
+
lines.push(' This account reports no rolling usage window.');
|
|
1559
|
+
lines.push(' On flexible pricing there is no percentage to run down: usage scales');
|
|
1560
|
+
lines.push(' with credits, so the credit balance above is the budget to plan against.');
|
|
1561
|
+
if (data.planAdvice) {
|
|
1562
|
+
lines.push('');
|
|
1563
|
+
lines.push(data.planAdvice);
|
|
1564
|
+
}
|
|
1565
|
+
return lines.join('\n');
|
|
1566
|
+
}
|
|
1150
1567
|
lines.push(' No usage snapshot in ' + (data.accountFile || '~/.claude.json') + '.');
|
|
1151
|
-
lines.push(
|
|
1568
|
+
lines.push(
|
|
1569
|
+
data.host === host.CODEX
|
|
1570
|
+
? ' Run a Codex turn once so it writes one, or --refresh to ask for it now.'
|
|
1571
|
+
: ' Run /usage once inside Claude Code to populate it, then try again.'
|
|
1572
|
+
);
|
|
1152
1573
|
return lines.join('\n');
|
|
1153
1574
|
}
|
|
1154
1575
|
|
|
1155
1576
|
lines.push(
|
|
1156
1577
|
' ' + pad('Window', 15) + padLeft('Used', 6) + padLeft('Resets in', 12) +
|
|
1157
|
-
padLeft('Left', 10) + padLeft('Turns left', 12)
|
|
1578
|
+
(money ? padLeft('Left', 10) : '') + padLeft('Turns left', 12)
|
|
1158
1579
|
);
|
|
1159
1580
|
for (const window of data.windows) {
|
|
1160
1581
|
const marker = data.binding && window.key === data.binding.key ? ' <- binding' : '';
|
|
@@ -1169,13 +1590,30 @@ function render(data) {
|
|
|
1169
1590
|
6
|
|
1170
1591
|
) +
|
|
1171
1592
|
padLeft(formatDuration(window.msToReset), 12) +
|
|
1172
|
-
padLeft(formatUSD(window.remainingUSD), 10) +
|
|
1593
|
+
(money ? padLeft(formatUSD(window.remainingUSD), 10) : '') +
|
|
1173
1594
|
padLeft(window.turnsLeft === null ? '-' : '~' + formatCount(window.turnsLeft), 12) +
|
|
1174
1595
|
marker
|
|
1175
1596
|
);
|
|
1176
1597
|
}
|
|
1177
1598
|
lines.push('');
|
|
1178
1599
|
|
|
1600
|
+
// A bucket with no span cannot be priced or projected, but saying nothing
|
|
1601
|
+
// about one that is nearly full would be the worse failure.
|
|
1602
|
+
if (data.otherLimits && data.otherLimits.length) {
|
|
1603
|
+
lines.push(' Other limits reported by the account');
|
|
1604
|
+
for (const row of data.otherLimits) {
|
|
1605
|
+
lines.push(
|
|
1606
|
+
' ' + pad(' ' + row.label, 24) + padLeft(row.percentUsed + '%', 6) +
|
|
1607
|
+
padLeft(
|
|
1608
|
+
Number.isFinite(row.resetsAt) ? formatDuration(row.resetsAt - data.now) : '-',
|
|
1609
|
+
12
|
|
1610
|
+
)
|
|
1611
|
+
);
|
|
1612
|
+
}
|
|
1613
|
+
lines.push(' No window length is reported for these, so they are not projected.');
|
|
1614
|
+
lines.push('');
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1179
1617
|
if (data.models && data.models.length) {
|
|
1180
1618
|
lines.push(' Models in the ' + (data.scopeLabel || 'window') + ' window');
|
|
1181
1619
|
lines.push(
|
|
@@ -1184,14 +1622,15 @@ function render(data) {
|
|
|
1184
1622
|
);
|
|
1185
1623
|
for (const row of data.models) {
|
|
1186
1624
|
lines.push(
|
|
1187
|
-
' ' + pad(' ' + row.model + (row.estimated ? ' *' : ''), 24) +
|
|
1625
|
+
' ' + pad(' ' + row.model + (money && row.estimated ? ' *' : ''), 24) +
|
|
1188
1626
|
padLeft(row.turns, 7) +
|
|
1189
1627
|
padLeft(formatTokens(row.tokens), 10) +
|
|
1190
1628
|
padLeft(formatTokens(row.parts.output), 9) +
|
|
1191
1629
|
padLeft(Math.round(row.share * 100) + '%', 8)
|
|
1192
1630
|
);
|
|
1193
1631
|
}
|
|
1194
|
-
|
|
1632
|
+
// The footnote is about the price table, which only the Claude reader uses.
|
|
1633
|
+
if (money && data.models.some((row) => row.estimated)) {
|
|
1195
1634
|
lines.push(' * no published rate for this one yet, priced at the family average');
|
|
1196
1635
|
}
|
|
1197
1636
|
if (data.tokens) {
|
|
@@ -1201,6 +1640,19 @@ function render(data) {
|
|
|
1201
1640
|
', cache read ' + formatTokens(data.tokens.cacheRead) +
|
|
1202
1641
|
', output ' + formatTokens(data.tokens.output)
|
|
1203
1642
|
);
|
|
1643
|
+
// Output is the dearest class and reasoning is usually about half of it,
|
|
1644
|
+
// which makes this the largest number on the report that a setting can
|
|
1645
|
+
// actually move. Saying so without measuring it was advice; this is the
|
|
1646
|
+
// measurement.
|
|
1647
|
+
const reasoning = data.reasoning;
|
|
1648
|
+
if (reasoning && reasoning.tokens > 0) {
|
|
1649
|
+
lines.push(
|
|
1650
|
+
' Of that output, ' + formatTokens(reasoning.tokens) + ' was reasoning (' +
|
|
1651
|
+
Math.round(reasoning.shareOfOutput * 100) + '%' +
|
|
1652
|
+
(money && reasoning.cost !== null ? ', about ' + formatUSD(reasoning.cost) : '') +
|
|
1653
|
+
'), the part effort controls.'
|
|
1654
|
+
);
|
|
1655
|
+
}
|
|
1204
1656
|
}
|
|
1205
1657
|
lines.push('');
|
|
1206
1658
|
}
|
|
@@ -1234,8 +1686,8 @@ function render(data) {
|
|
|
1234
1686
|
|
|
1235
1687
|
if (data.recent.turns) {
|
|
1236
1688
|
lines.push(
|
|
1237
|
-
' Recent pace ' + data.recent.turns + ' turns in the last hour
|
|
1238
|
-
formatUSD(data.recent.usdPerTurn) + ' per turn' +
|
|
1689
|
+
' Recent pace ' + data.recent.turns + ' turns in the last hour' +
|
|
1690
|
+
(money ? ', ' + formatUSD(data.recent.usdPerTurn) + ' per turn' : '') +
|
|
1239
1691
|
(data.recent.effort ? ', effort ' + data.recent.effort : '')
|
|
1240
1692
|
);
|
|
1241
1693
|
} else {
|
|
@@ -1265,6 +1717,34 @@ function render(data) {
|
|
|
1265
1717
|
lines.push(' Note the meter reads in whole percent, so a low reading is a wide bracket');
|
|
1266
1718
|
}
|
|
1267
1719
|
|
|
1720
|
+
// A report taken shortly after a cutoff should say so. The percentages
|
|
1721
|
+
// describe the window running now and look perfectly healthy, which is
|
|
1722
|
+
// exactly why the fact that work was stopped an hour ago has to be stated
|
|
1723
|
+
// rather than left to be inferred from a number that no longer shows it.
|
|
1724
|
+
if (data.lastRefusal && Number.isFinite(data.lastRefusal.at) &&
|
|
1725
|
+
data.now - data.lastRefusal.at < 12 * HOUR) {
|
|
1726
|
+
const window = data.windows.find((one) => one.key === data.lastRefusal.key);
|
|
1727
|
+
lines.push(
|
|
1728
|
+
' Cut off ' + formatDuration(data.now - data.lastRefusal.at) + ' ago the ' +
|
|
1729
|
+
((window && window.label) || data.lastRefusal.key) + ' limit refused work' +
|
|
1730
|
+
(Number.isFinite(data.lastRefusal.resetsAt)
|
|
1731
|
+
? ', and came back at ' + formatClock(data.lastRefusal.resetsAt)
|
|
1732
|
+
: '')
|
|
1733
|
+
);
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
if (data.windows.some((window) => window.snapshotOlderThanWindow && !window.stale)) {
|
|
1737
|
+
lines.push(
|
|
1738
|
+
' Note the snapshot is older than one of these windows, so its reading'
|
|
1739
|
+
);
|
|
1740
|
+
lines.push(
|
|
1741
|
+
' describes a window that has since rolled over. ' +
|
|
1742
|
+
(data.host === host.CODEX
|
|
1743
|
+
? 'Run --refresh for a live one.'
|
|
1744
|
+
: 'Run /usage for a fresh one.')
|
|
1745
|
+
);
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1268
1748
|
lines.push('');
|
|
1269
1749
|
lines.push(verdictLine(data.binding));
|
|
1270
1750
|
const binding = data.binding;
|
|
@@ -1341,9 +1821,11 @@ function renderForecast(data, turns) {
|
|
|
1341
1821
|
}
|
|
1342
1822
|
lines.push('');
|
|
1343
1823
|
lines.push(
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
'
|
|
1824
|
+
data.money === false
|
|
1825
|
+
? ' Priced from ' + data.rates.sample + ' recent turns, cheapest to dearest.'
|
|
1826
|
+
: ' Priced from ' + data.rates.sample + ' recent turns: ' +
|
|
1827
|
+
formatUSD(data.rates.median) + ' typical, ' + formatUSD(data.rates.high) +
|
|
1828
|
+
' at the expensive end.'
|
|
1347
1829
|
);
|
|
1348
1830
|
|
|
1349
1831
|
const blocked = rows.filter((row) => !row.fits);
|
|
@@ -1359,6 +1841,15 @@ function renderForecast(data, turns) {
|
|
|
1359
1841
|
' It fits, but only if nothing goes wrong. Order the work so the valuable ' +
|
|
1360
1842
|
'part lands first.'
|
|
1361
1843
|
);
|
|
1844
|
+
} else if (data.sessions && data.sessions.length > 1) {
|
|
1845
|
+
// The percentages say it fits, and they would be right if this were the
|
|
1846
|
+
// only thing spending. It is not: the budget drains while these turns run,
|
|
1847
|
+
// so a verdict of "room for this" on its own is the one that gets someone
|
|
1848
|
+
// cut off mid-job.
|
|
1849
|
+
lines.push(
|
|
1850
|
+
' It fits on its own, but ' + data.sessions.length + ' sessions are spending this ' +
|
|
1851
|
+
'budget at once, so it will be gone sooner than these figures alone suggest.'
|
|
1852
|
+
);
|
|
1362
1853
|
} else {
|
|
1363
1854
|
lines.push(' There is room for this. No need to work around the limit.');
|
|
1364
1855
|
}
|
|
@@ -1371,14 +1862,37 @@ function renderForecast(data, turns) {
|
|
|
1371
1862
|
}
|
|
1372
1863
|
|
|
1373
1864
|
async function main(argv) {
|
|
1865
|
+
// Settle the host before anything reads a file, so one run never mixes one
|
|
1866
|
+
// agent's percentages with the other's turns.
|
|
1867
|
+
setHost(host.detect(argv, process.env));
|
|
1868
|
+
|
|
1374
1869
|
// The status line runs on every redraw, so it must not scan transcripts.
|
|
1375
1870
|
if (argv.indexOf('--status') !== -1) {
|
|
1376
1871
|
process.stdout.write(statusLine(collect(Date.now())) + '\n');
|
|
1377
1872
|
return 0;
|
|
1378
1873
|
}
|
|
1379
1874
|
|
|
1875
|
+
// Codex writes its meter into the session rollouts, so the cached reading is
|
|
1876
|
+
// only as fresh as the last request it made. Asking Codex itself is a second
|
|
1877
|
+
// and a child process, which is why it is opt-in rather than the default.
|
|
1878
|
+
let codexMeter = null;
|
|
1879
|
+
if (argv.indexOf('--refresh') !== -1) {
|
|
1880
|
+
if (!isCodex()) {
|
|
1881
|
+
process.stderr.write('usage: --refresh applies to Codex. Run /usage in Claude Code.\n');
|
|
1882
|
+
return 2;
|
|
1883
|
+
}
|
|
1884
|
+
try {
|
|
1885
|
+
codexMeter = await codex.refresh();
|
|
1886
|
+
} catch (err) {
|
|
1887
|
+
process.stderr.write(
|
|
1888
|
+
'usage: could not read a live figure from Codex (' +
|
|
1889
|
+
((err && err.code) || 'unknown') + '). Falling back to the newest one on disk.\n'
|
|
1890
|
+
);
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1380
1894
|
const wantsJson = argv.indexOf('--json') !== -1;
|
|
1381
|
-
const data = await report(Date.now());
|
|
1895
|
+
const data = await report(Date.now(), { codexMeter });
|
|
1382
1896
|
|
|
1383
1897
|
const forecastAt = argv.indexOf('--forecast');
|
|
1384
1898
|
if (forecastAt !== -1) {
|
|
@@ -1410,6 +1924,12 @@ if (require.main === module) {
|
|
|
1410
1924
|
|
|
1411
1925
|
module.exports = {
|
|
1412
1926
|
main,
|
|
1927
|
+
setHost,
|
|
1928
|
+
currentHost,
|
|
1929
|
+
isCodex,
|
|
1930
|
+
otherLimits,
|
|
1931
|
+
collectClaude,
|
|
1932
|
+
readClaudeEvents,
|
|
1413
1933
|
RATES,
|
|
1414
1934
|
WINDOWS,
|
|
1415
1935
|
rateFor,
|
|
@@ -1425,9 +1945,12 @@ module.exports = {
|
|
|
1425
1945
|
SATURATION_LIMIT,
|
|
1426
1946
|
MIN_BASELINE_TURNS,
|
|
1427
1947
|
buildWindows,
|
|
1948
|
+
lastRejections,
|
|
1428
1949
|
bindingWindow,
|
|
1429
1950
|
criticalOthers,
|
|
1430
1951
|
betterCalibration,
|
|
1952
|
+
calibrationForPlan,
|
|
1953
|
+
stampPlan,
|
|
1431
1954
|
calibrationFile,
|
|
1432
1955
|
CRITICAL_PERCENT,
|
|
1433
1956
|
dominantEffort,
|
|
@@ -1446,6 +1969,7 @@ module.exports = {
|
|
|
1446
1969
|
collect,
|
|
1447
1970
|
detectPlan,
|
|
1448
1971
|
costPercentiles,
|
|
1972
|
+
reasoningSpend,
|
|
1449
1973
|
forecastWindow,
|
|
1450
1974
|
renderForecast,
|
|
1451
1975
|
creditsFrom,
|