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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +40 -0
- package/README.md +75 -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 +243 -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 +562 -35
|
@@ -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
|
+
};
|