claude-usage-limits 1.9.2 → 1.11.2

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,222 @@
1
+ 'use strict';
2
+
3
+ // The display model: what the status line, the side panel and the VS Code
4
+ // view all draw. Given whatever readings exist - the usage endpoint's answer,
5
+ // Claude Code's own cache, the per-response rate-limit headers the status line
6
+ // receives - plus which model is running, it decides the rows, their colours,
7
+ // which per-model week to show, and what the footer should admit about the
8
+ // freshness of it all.
9
+ //
10
+ // Everything here is a pure function of its input so the three surfaces
11
+ // cannot drift apart.
12
+
13
+ const usage = require('./usage.js');
14
+ const bars = require('./bars.js');
15
+
16
+ const MINUTE = 60 * 1000;
17
+
18
+ // Claude Code's own titles from /usage.
19
+ const TITLES = { five_hour: 'Current session', seven_day: 'Current week (all models)' };
20
+ const SPEND_TITLE = 'Spend limit';
21
+
22
+ // A reading older than this is "cached" in the footer, however it was taken.
23
+ const LIVE_AGE_MS = 5 * MINUTE;
24
+
25
+ function scopedTitle(name) {
26
+ return 'Current week (' + name + ')';
27
+ }
28
+
29
+ function epochMs(value) {
30
+ if (value === null || value === undefined) return null;
31
+ if (typeof value === 'number') {
32
+ if (!Number.isFinite(value)) return null;
33
+ // The status line hands over epoch seconds; the endpoint hands over ISO
34
+ // strings. A number small enough to be seconds is seconds.
35
+ return value < 1e12 ? value * 1000 : value;
36
+ }
37
+ const parsed = Date.parse(String(value));
38
+ return Number.isFinite(parsed) ? parsed : null;
39
+ }
40
+
41
+ function fromHeaders(key, headers, headersAt) {
42
+ if (!headers || typeof headers !== 'object') return null;
43
+ const bucket = headers[key];
44
+ if (!bucket || typeof bucket !== 'object' || typeof bucket.used_percentage !== 'number') return null;
45
+ return {
46
+ percent: bucket.used_percentage,
47
+ resetsAtMs: epochMs(bucket.resets_at),
48
+ at: Number.isFinite(headersAt) ? headersAt : 0,
49
+ source: 'headers',
50
+ };
51
+ }
52
+
53
+ function fromSnapshot(key, utilization, fetchedAtMs, source) {
54
+ if (!utilization || typeof utilization !== 'object') return null;
55
+ const bucket = utilization[key];
56
+ if (!bucket || typeof bucket !== 'object' || typeof bucket.utilization !== 'number') return null;
57
+ return {
58
+ percent: bucket.utilization,
59
+ resetsAtMs: epochMs(bucket.resets_at),
60
+ at: Number.isFinite(fetchedAtMs) ? fetchedAtMs : 0,
61
+ source: source === 'api' || source === 'live' ? 'api' : 'cache',
62
+ };
63
+ }
64
+
65
+ function newer(a, b) {
66
+ if (!a) return b;
67
+ if (!b) return a;
68
+ return a.at >= b.at ? a : b;
69
+ }
70
+
71
+ function percentText(percent, stale, unreported) {
72
+ if (stale) return 'rolling';
73
+ if (unreported) return '?';
74
+ if (!Number.isFinite(percent)) return 'no reading';
75
+ return Math.floor(percent) + '%';
76
+ }
77
+
78
+ function row(key, title, family, picked, now) {
79
+ const percent = picked && Number.isFinite(picked.percent) ? picked.percent : null;
80
+ const resetsAtMs = picked && Number.isFinite(picked.resetsAtMs) ? picked.resetsAtMs : null;
81
+ const msToReset = resetsAtMs === null ? null : resetsAtMs - now;
82
+ const stale = msToReset !== null && msToReset <= 0;
83
+ // Zero with no reset time is a window with nothing in it. From a reading
84
+ // taken just now that is simply true. From an old cache it may equally be a
85
+ // bucket that never reported, and the status line has no way to tell.
86
+ const idle = percent === 0 && resetsAtMs === null;
87
+ const unreported = idle && Boolean(picked) && picked.source === 'cache';
88
+ return {
89
+ key,
90
+ title,
91
+ family: family || null,
92
+ percent,
93
+ percentText: percentText(percent, stale, unreported),
94
+ resetsAtMs,
95
+ msToReset,
96
+ level: bars.level(percent),
97
+ source: picked ? picked.source : null,
98
+ at: picked ? picked.at : null,
99
+ stale,
100
+ idle,
101
+ unreported,
102
+ };
103
+ }
104
+
105
+ function ageText(ageMs) {
106
+ return Number.isFinite(ageMs) ? 'showing the reading from ' + usage.formatDuration(ageMs) + ' ago' : 'no reading yet';
107
+ }
108
+
109
+ function noteFor(outcome, ageMs) {
110
+ if (!outcome || outcome.ok) return null;
111
+ const suffix = ', ' + ageText(ageMs);
112
+ switch (outcome.kind) {
113
+ case 'offline':
114
+ return 'offline' + suffix;
115
+ case 'disabled':
116
+ return 'network off' + suffix;
117
+ case 'unauthorized':
118
+ return 'sign in to Claude Code again' + suffix;
119
+ case 'expired':
120
+ return 'the login has expired, Claude Code renews it on its next call' + suffix;
121
+ case 'forbidden':
122
+ return 'usage is not available for this login' + suffix;
123
+ case 'no_credentials':
124
+ return 'no Claude login found' + suffix;
125
+ case 'rate_limited':
126
+ return 'the usage endpoint is busy, retrying' + suffix;
127
+ default:
128
+ return 'the usage endpoint answered with an error' + suffix;
129
+ }
130
+ }
131
+
132
+ function build(input) {
133
+ const opts = input || {};
134
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
135
+ const env = opts.env || process.env;
136
+ const utilization = opts.utilization && typeof opts.utilization === 'object' ? opts.utilization : null;
137
+ const fetchedAtMs = Number.isFinite(opts.fetchedAtMs) ? opts.fetchedAtMs : null;
138
+ const source = opts.source || (utilization ? 'cache' : null);
139
+ const headers = opts.headers && typeof opts.headers === 'object' ? opts.headers : null;
140
+ const headersAt = Number.isFinite(opts.headersAt) ? opts.headersAt : null;
141
+
142
+ // The model in use decides which per-model week is shown. The status line
143
+ // knows for certain; the setting is the fallback; nothing is hidden when
144
+ // neither says.
145
+ const model = opts.model || opts.settingsModel || env.ANTHROPIC_MODEL || null;
146
+ // When Claude Code has said which model is running, that is the whole
147
+ // answer: adding the setting on top would keep a Fable week on screen after
148
+ // /model moved the session to Opus. The setting is only the fallback.
149
+ const families = usage.familiesInUse(null, null, opts.model ? [opts.model] : [opts.settingsModel, env.ANTHROPIC_MODEL]);
150
+
151
+ const rows = [];
152
+ for (const key of ['five_hour', 'seven_day']) {
153
+ const picked = newer(fromHeaders(key, headers, headersAt), fromSnapshot(key, utilization, fetchedAtMs, source));
154
+ rows.push(row(key, TITLES[key], null, picked, now));
155
+ }
156
+
157
+ const spend = fromHeaders('spend_limit', headers, headersAt);
158
+ if (spend) rows.push(row('spend_limit', SPEND_TITLE, null, spend, now));
159
+
160
+ // A host that meters windows of other lengths (Codex names its own) gets a
161
+ // row per window it reports, after the two everyone has.
162
+ for (const spec of Array.isArray(opts.windowSpecs) ? opts.windowSpecs : []) {
163
+ if (!spec || !spec.key || spec.key === 'five_hour' || spec.key === 'seven_day') continue;
164
+ const picked = fromSnapshot(spec.key, utilization, fetchedAtMs, source);
165
+ if (picked) rows.push(row(spec.key, 'Current ' + (spec.label || spec.key) + ' window', null, picked, now));
166
+ }
167
+
168
+ let fable = null;
169
+ const hidden = [];
170
+ for (const limit of usage.limitWindows(utilization)) {
171
+ if (!limit.family) continue;
172
+ const name = limit.label && limit.label.indexOf('weekly (') === 0 ? limit.label.slice(8, -1) : limit.family;
173
+ const picked = {
174
+ percent: limit.percent,
175
+ resetsAtMs: limit.resetsAt,
176
+ at: Number.isFinite(fetchedAtMs) ? fetchedAtMs : 0,
177
+ source: source === 'api' || source === 'live' ? 'api' : 'cache',
178
+ };
179
+ const built = row(limit.key, scopedTitle(name), limit.family, picked, now);
180
+ if (usage.appliesTo(limit, families)) {
181
+ rows.push(built);
182
+ if (limit.family === 'fable') fable = built;
183
+ } else {
184
+ hidden.push(built);
185
+ }
186
+ }
187
+
188
+ // Freshness is judged on the newest thing shown.
189
+ let newestAt = null;
190
+ for (const item of rows) {
191
+ if (Number.isFinite(item.at) && item.at > 0 && (newestAt === null || item.at > newestAt)) newestAt = item.at;
192
+ }
193
+ const ageMs = newestAt === null ? null : Math.max(0, now - newestAt);
194
+ const hasData = rows.some((item) => item.percent !== null);
195
+ const state = !hasData ? 'none' : ageMs !== null && ageMs < LIVE_AGE_MS ? 'live' : 'cached';
196
+
197
+ const effort = opts.effort ? String(opts.effort).toLowerCase() : null;
198
+ const ultracode = Boolean(opts.ultracode) || effort === 'max' || effort === 'ultracode';
199
+
200
+ return {
201
+ rows,
202
+ fable,
203
+ hidden,
204
+ model,
205
+ modelLabel: opts.modelName || bars.prettyModel(model),
206
+ effort,
207
+ ultracode,
208
+ working: Boolean(opts.working),
209
+ state,
210
+ ageMs,
211
+ note: noteFor(opts.outcome, ageMs),
212
+ };
213
+ }
214
+
215
+ module.exports = {
216
+ TITLES,
217
+ LIVE_AGE_MS,
218
+ scopedTitle,
219
+ epochMs,
220
+ build,
221
+ noteFor,
222
+ };