@llblab/pi-codex-usage 0.3.3 → 0.3.4

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/index.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
2
2
  import { createInterface } from "node:readline";
3
3
 
4
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import type {
5
+ ExtensionAPI,
6
+ ExtensionContext,
7
+ } from "@earendil-works/pi-coding-agent";
5
8
 
6
9
  const CODEX_PROVIDER_ID = "openai-codex";
7
10
  const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
@@ -12,22 +15,22 @@ const STATUS_KEY = "codex-usage";
12
15
  const MAX_ERROR_BODY_CHARS = 600;
13
16
  const STATUS_LABEL_TEXT = "codex";
14
17
  const DUAL_BAR_CHARS = [
15
- " ",
16
- "𜹑",
17
- "𜹒",
18
- "𜹓",
19
- "𜹠",
20
- "𜹡",
21
- "𜹢",
22
- "𜹣",
23
- "𜹰",
24
- "𜹱",
25
- "𜹲",
26
- "𜹳",
27
- "𜺀",
28
- "𜺁",
29
- "𜺂",
30
- "𜺃",
18
+ " ",
19
+ "𜹑",
20
+ "𜹒",
21
+ "𜹓",
22
+ "𜹠",
23
+ "𜹡",
24
+ "𜹢",
25
+ "𜹣",
26
+ "𜹰",
27
+ "𜹱",
28
+ "𜹲",
29
+ "𜹳",
30
+ "𜺀",
31
+ "𜺁",
32
+ "𜺂",
33
+ "𜺃",
31
34
  ];
32
35
 
33
36
  type UsageSource = "pi-auth" | "codex-app-server";
@@ -36,811 +39,815 @@ type PiModel = NonNullable<ExtensionContext["model"]>;
36
39
  export type CodexUsageModel = Pick<PiModel, "id" | "name" | "provider">;
37
40
 
38
41
  type QueryUsageOptions = {
39
- timeoutMs: number;
42
+ timeoutMs: number;
40
43
  };
41
44
 
42
45
  type CachedReport = {
43
- createdAt: number;
44
- report: CodexUsageReport;
46
+ createdAt: number;
47
+ report: CodexUsageReport;
45
48
  };
46
49
 
47
50
  type QueryUsageResult =
48
- | { ok: true; report: CodexUsageReport }
49
- | { ok: false; errors: UsageQueryError[] };
51
+ | { ok: true; report: CodexUsageReport }
52
+ | { ok: false; errors: UsageQueryError[] };
50
53
 
51
54
  type UsageQueryError = {
52
- source: UsageSource;
53
- message: string;
54
- cause?: unknown;
55
+ source: UsageSource;
56
+ message: string;
57
+ cause?: unknown;
55
58
  };
56
59
 
57
60
  export type CodexUsageReport = {
58
- snapshots: NormalizedRateLimitSnapshot[];
61
+ snapshots: NormalizedRateLimitSnapshot[];
59
62
  };
60
63
 
61
64
  export type NormalizedRateLimitSnapshot = {
62
- limitId: string;
63
- primary?: NormalizedRateLimitWindow;
64
- secondary?: NormalizedRateLimitWindow;
65
+ limitId: string;
66
+ primary?: NormalizedRateLimitWindow;
67
+ secondary?: NormalizedRateLimitWindow;
65
68
  };
66
69
 
67
70
  export type NormalizedRateLimitWindow = {
68
- usedPercent: number;
71
+ usedPercent: number;
69
72
  };
70
73
 
71
74
  type RateLimitStatusPayload = {
72
- rate_limit?: unknown;
75
+ rate_limit?: unknown;
73
76
  };
74
77
 
75
78
  type BackendRateLimitDetails = {
76
- primary_window?: unknown;
77
- secondary_window?: unknown;
79
+ primary_window?: unknown;
80
+ secondary_window?: unknown;
78
81
  };
79
82
 
80
83
  type BackendWindowSnapshot = {
81
- used_percent?: unknown;
84
+ used_percent?: unknown;
82
85
  };
83
86
 
84
87
  type AppServerRateLimitResponse = {
85
- rateLimits?: unknown;
88
+ rateLimits?: unknown;
86
89
  };
87
90
 
88
91
  type AppServerRateLimitSnapshot = {
89
- limitId?: unknown;
90
- primary?: unknown;
91
- secondary?: unknown;
92
+ limitId?: unknown;
93
+ primary?: unknown;
94
+ secondary?: unknown;
92
95
  };
93
96
 
94
97
  type AppServerWindowSnapshot = {
95
- usedPercent?: unknown;
98
+ usedPercent?: unknown;
96
99
  };
97
100
 
98
101
  type RpcResponse = {
99
- id?: unknown;
100
- result?: unknown;
101
- error?: { message?: unknown; code?: unknown };
102
+ id?: unknown;
103
+ result?: unknown;
104
+ error?: { message?: unknown; code?: unknown };
102
105
  };
103
106
 
104
107
  type PendingRpc = {
105
- resolve: (value: unknown) => void;
106
- reject: (error: Error) => void;
108
+ resolve: (value: unknown) => void;
109
+ reject: (error: Error) => void;
107
110
  };
108
111
 
109
112
  export default function codexUsage(pi: ExtensionAPI) {
110
- let cache: CachedReport | undefined;
111
- let failedRefreshes = 0;
112
- let statuslineBlinkTimer: TimeoutHandle | undefined;
113
- let statuslineClearTimer: TimeoutHandle | undefined;
114
- let statuslineRefreshTimer: TimeoutHandle | undefined;
115
- let statuslineRequestId = 0;
116
-
117
- const clearStatuslineTimers = () => {
118
- if (statuslineBlinkTimer) clearTimeout(statuslineBlinkTimer);
119
- if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
120
- if (statuslineRefreshTimer) clearTimeout(statuslineRefreshTimer);
121
- statuslineBlinkTimer = undefined;
122
- statuslineClearTimer = undefined;
123
- statuslineRefreshTimer = undefined;
124
- };
125
-
126
- const clearUsageStatusline = (ctx: ExtensionContext) => {
127
- statuslineRequestId += 1;
128
- clearStatuslineTimers();
129
- ctx.ui.setStatus(STATUS_KEY, undefined);
130
- };
131
-
132
- const scheduleTemporaryStatuslineClear = (ctx: ExtensionContext) => {
133
- if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
134
- statuslineClearTimer = setTimeout(() => {
135
- ctx.ui.setStatus(STATUS_KEY, undefined);
136
- statuslineClearTimer = undefined;
137
- }, REFRESH_INTERVAL_MS) as TimeoutHandle;
138
- statuslineClearTimer.unref?.();
139
- };
140
-
141
- const scheduleStatuslineRefresh = (ctx: ExtensionContext) => {
142
- if (statuslineRefreshTimer) clearTimeout(statuslineRefreshTimer);
143
- statuslineRefreshTimer = setTimeout(() => {
144
- void refreshCurrentCodexUsageStatusline(ctx, true);
145
- }, REFRESH_INTERVAL_MS) as TimeoutHandle;
146
- statuslineRefreshTimer.unref?.();
147
- };
148
-
149
- const setUsageStatusline = (
150
- ctx: ExtensionContext,
151
- report: CodexUsageReport,
152
- options: {
153
- autoRefresh: boolean;
154
- blink: boolean;
155
- model: CodexUsageModel | undefined;
156
- },
157
- ) => {
158
- if (statuslineBlinkTimer) clearTimeout(statuslineBlinkTimer);
159
- if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
160
- statuslineBlinkTimer = undefined;
161
- statuslineClearTimer = undefined;
162
- const text = formatCodexUsageStatusline(report, ctx, options.model);
163
- if (options.blink) {
164
- ctx.ui.setStatus(STATUS_KEY, formatEmptyStatuslineBar(ctx));
165
- statuslineBlinkTimer = setTimeout(() => {
166
- ctx.ui.setStatus(STATUS_KEY, text);
167
- statuslineBlinkTimer = undefined;
168
- }, REDRAW_BLINK_MS) as TimeoutHandle;
169
- statuslineBlinkTimer.unref?.();
170
- } else {
171
- ctx.ui.setStatus(STATUS_KEY, text);
172
- }
173
- if (options.autoRefresh) scheduleStatuslineRefresh(ctx);
174
- else scheduleTemporaryStatuslineClear(ctx);
175
- };
176
-
177
- const refreshCurrentCodexUsageStatusline = async (
178
- ctx: ExtensionContext,
179
- force: boolean,
180
- model = ctx.model,
181
- ) => {
182
- if (!isOpenAICodexModel(model)) {
183
- clearUsageStatusline(ctx);
184
- return;
185
- }
186
-
187
- if (!cache) ctx.ui.setStatus(STATUS_KEY, formatEmptyStatuslineBar(ctx));
188
- const requestId = statuslineRequestId + 1;
189
- statuslineRequestId = requestId;
190
- const cached =
191
- cache && Date.now() - cache.createdAt < REFRESH_INTERVAL_MS
192
- ? cache
193
- : undefined;
194
- if (cached && !force) {
195
- setUsageStatusline(ctx, cached.report, {
196
- autoRefresh: true,
197
- blink: false,
198
- model,
199
- });
200
- return;
201
- }
202
-
203
- const result = await queryUsage(ctx, { timeoutMs: DEFAULT_TIMEOUT_MS });
204
- if (requestId !== statuslineRequestId) return;
205
- if (!isOpenAICodexModel(ctx.model)) {
206
- clearUsageStatusline(ctx);
207
- return;
208
- }
209
-
210
- if (!result.ok) {
211
- failedRefreshes += 1;
212
- if (!cache || failedRefreshes >= 5) {
213
- ctx.ui.setStatus(STATUS_KEY, formatStatuslineProblem(ctx, result.errors));
214
- }
215
- scheduleStatuslineRefresh(ctx);
216
- return;
217
- }
218
-
219
- failedRefreshes = 0;
220
- cache = { createdAt: Date.now(), report: result.report };
221
- setUsageStatusline(ctx, result.report, {
222
- autoRefresh: true,
223
- blink: cache !== undefined,
224
- model,
225
- });
226
- };
227
-
228
- pi.on("session_start", (_event, ctx) => {
229
- if (isOpenAICodexModel(ctx.model))
230
- void refreshCurrentCodexUsageStatusline(ctx, false);
231
- else clearUsageStatusline(ctx);
232
- });
233
-
234
- pi.on("session_tree", (_event, ctx) => {
235
- if (isOpenAICodexModel(ctx.model))
236
- void refreshCurrentCodexUsageStatusline(ctx, false);
237
- else clearUsageStatusline(ctx);
238
- });
239
-
240
- pi.on("model_select", (event, ctx) => {
241
- if (isOpenAICodexModel(event.model)) {
242
- void refreshCurrentCodexUsageStatusline(ctx, false, event.model);
243
- } else {
244
- clearUsageStatusline(ctx);
245
- }
246
- });
247
-
248
- pi.on("session_shutdown", (_event, ctx) => clearUsageStatusline(ctx));
113
+ let cache: CachedReport | undefined;
114
+ let failedRefreshes = 0;
115
+ let statuslineBlinkTimer: TimeoutHandle | undefined;
116
+ let statuslineClearTimer: TimeoutHandle | undefined;
117
+ let statuslineRefreshTimer: TimeoutHandle | undefined;
118
+ let statuslineRequestId = 0;
119
+
120
+ const clearStatuslineTimers = () => {
121
+ if (statuslineBlinkTimer) clearTimeout(statuslineBlinkTimer);
122
+ if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
123
+ if (statuslineRefreshTimer) clearTimeout(statuslineRefreshTimer);
124
+ statuslineBlinkTimer = undefined;
125
+ statuslineClearTimer = undefined;
126
+ statuslineRefreshTimer = undefined;
127
+ };
128
+
129
+ const clearUsageStatusline = (ctx: ExtensionContext) => {
130
+ statuslineRequestId += 1;
131
+ clearStatuslineTimers();
132
+ ctx.ui.setStatus(STATUS_KEY, undefined);
133
+ };
134
+
135
+ const scheduleTemporaryStatuslineClear = (ctx: ExtensionContext) => {
136
+ if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
137
+ statuslineClearTimer = setTimeout(() => {
138
+ ctx.ui.setStatus(STATUS_KEY, undefined);
139
+ statuslineClearTimer = undefined;
140
+ }, REFRESH_INTERVAL_MS) as TimeoutHandle;
141
+ statuslineClearTimer.unref?.();
142
+ };
143
+
144
+ const scheduleStatuslineRefresh = (ctx: ExtensionContext) => {
145
+ if (statuslineRefreshTimer) clearTimeout(statuslineRefreshTimer);
146
+ statuslineRefreshTimer = setTimeout(() => {
147
+ void refreshCurrentCodexUsageStatusline(ctx, true);
148
+ }, REFRESH_INTERVAL_MS) as TimeoutHandle;
149
+ statuslineRefreshTimer.unref?.();
150
+ };
151
+
152
+ const setUsageStatusline = (
153
+ ctx: ExtensionContext,
154
+ report: CodexUsageReport,
155
+ options: {
156
+ autoRefresh: boolean;
157
+ blink: boolean;
158
+ model: CodexUsageModel | undefined;
159
+ },
160
+ ) => {
161
+ if (statuslineBlinkTimer) clearTimeout(statuslineBlinkTimer);
162
+ if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
163
+ statuslineBlinkTimer = undefined;
164
+ statuslineClearTimer = undefined;
165
+ const text = formatCodexUsageStatusline(report, ctx, options.model);
166
+ if (options.blink) {
167
+ ctx.ui.setStatus(STATUS_KEY, formatEmptyStatuslineBar(ctx));
168
+ statuslineBlinkTimer = setTimeout(() => {
169
+ ctx.ui.setStatus(STATUS_KEY, text);
170
+ statuslineBlinkTimer = undefined;
171
+ }, REDRAW_BLINK_MS) as TimeoutHandle;
172
+ statuslineBlinkTimer.unref?.();
173
+ } else {
174
+ ctx.ui.setStatus(STATUS_KEY, text);
175
+ }
176
+ if (options.autoRefresh) scheduleStatuslineRefresh(ctx);
177
+ else scheduleTemporaryStatuslineClear(ctx);
178
+ };
179
+
180
+ const refreshCurrentCodexUsageStatusline = async (
181
+ ctx: ExtensionContext,
182
+ force: boolean,
183
+ model = ctx.model,
184
+ ) => {
185
+ if (!isOpenAICodexModel(model)) {
186
+ clearUsageStatusline(ctx);
187
+ return;
188
+ }
189
+
190
+ if (!cache) ctx.ui.setStatus(STATUS_KEY, formatEmptyStatuslineBar(ctx));
191
+ const requestId = statuslineRequestId + 1;
192
+ statuslineRequestId = requestId;
193
+ const cached =
194
+ cache && Date.now() - cache.createdAt < REFRESH_INTERVAL_MS
195
+ ? cache
196
+ : undefined;
197
+ if (cached && !force) {
198
+ setUsageStatusline(ctx, cached.report, {
199
+ autoRefresh: true,
200
+ blink: false,
201
+ model,
202
+ });
203
+ return;
204
+ }
205
+
206
+ const result = await queryUsage(ctx, { timeoutMs: DEFAULT_TIMEOUT_MS });
207
+ if (requestId !== statuslineRequestId) return;
208
+ if (!isOpenAICodexModel(ctx.model)) {
209
+ clearUsageStatusline(ctx);
210
+ return;
211
+ }
212
+
213
+ if (!result.ok) {
214
+ failedRefreshes += 1;
215
+ if (!cache || failedRefreshes >= 5) {
216
+ ctx.ui.setStatus(
217
+ STATUS_KEY,
218
+ formatStatuslineProblem(ctx, result.errors),
219
+ );
220
+ }
221
+ scheduleStatuslineRefresh(ctx);
222
+ return;
223
+ }
224
+
225
+ failedRefreshes = 0;
226
+ cache = { createdAt: Date.now(), report: result.report };
227
+ setUsageStatusline(ctx, result.report, {
228
+ autoRefresh: true,
229
+ blink: cache !== undefined,
230
+ model,
231
+ });
232
+ };
233
+
234
+ pi.on("session_start", (_event, ctx) => {
235
+ if (isOpenAICodexModel(ctx.model))
236
+ void refreshCurrentCodexUsageStatusline(ctx, false);
237
+ else clearUsageStatusline(ctx);
238
+ });
239
+
240
+ pi.on("session_tree", (_event, ctx) => {
241
+ if (isOpenAICodexModel(ctx.model))
242
+ void refreshCurrentCodexUsageStatusline(ctx, false);
243
+ else clearUsageStatusline(ctx);
244
+ });
245
+
246
+ pi.on("model_select", (event, ctx) => {
247
+ if (isOpenAICodexModel(event.model)) {
248
+ void refreshCurrentCodexUsageStatusline(ctx, false, event.model);
249
+ } else {
250
+ clearUsageStatusline(ctx);
251
+ }
252
+ });
253
+
254
+ pi.on("session_shutdown", (_event, ctx) => clearUsageStatusline(ctx));
249
255
  }
250
256
 
251
257
  function isOpenAICodexModel(
252
- model: Pick<PiModel, "provider"> | undefined,
258
+ model: Pick<PiModel, "provider"> | undefined,
253
259
  ): boolean {
254
- return model?.provider === CODEX_PROVIDER_ID;
260
+ return model?.provider === CODEX_PROVIDER_ID;
255
261
  }
256
262
 
257
263
  async function queryUsage(
258
- ctx: ExtensionContext,
259
- options: Pick<QueryUsageOptions, "timeoutMs">,
264
+ ctx: ExtensionContext,
265
+ options: Pick<QueryUsageOptions, "timeoutMs">,
260
266
  ): Promise<QueryUsageResult> {
261
- const errors: UsageQueryError[] = [];
267
+ const errors: UsageQueryError[] = [];
262
268
 
263
- try {
264
- const report = await queryViaPiAuth(ctx, options.timeoutMs);
265
- return { ok: true, report };
266
- } catch (cause) {
267
- errors.push({ source: "pi-auth", message: errorMessage(cause), cause });
268
- }
269
+ try {
270
+ const report = await queryViaPiAuth(ctx, options.timeoutMs);
271
+ return { ok: true, report };
272
+ } catch (cause) {
273
+ errors.push({ source: "pi-auth", message: errorMessage(cause), cause });
274
+ }
269
275
 
270
- try {
271
- const report = await queryViaCodexAppServer(options.timeoutMs);
272
- return { ok: true, report };
273
- } catch (cause) {
274
- errors.push({
275
- source: "codex-app-server",
276
- message: errorMessage(cause),
277
- cause,
278
- });
279
- }
276
+ try {
277
+ const report = await queryViaCodexAppServer(options.timeoutMs);
278
+ return { ok: true, report };
279
+ } catch (cause) {
280
+ errors.push({
281
+ source: "codex-app-server",
282
+ message: errorMessage(cause),
283
+ cause,
284
+ });
285
+ }
280
286
 
281
- return { ok: false, errors };
287
+ return { ok: false, errors };
282
288
  }
283
289
 
284
290
  async function queryViaPiAuth(
285
- ctx: ExtensionContext,
286
- timeoutMs: number,
291
+ ctx: ExtensionContext,
292
+ timeoutMs: number,
287
293
  ): Promise<CodexUsageReport> {
288
- const auth = await resolvePiCodexAuth(ctx);
289
- if (!auth) {
290
- throw new Error(
291
- "No Pi OpenAI Codex subscription auth was available. Use a Pi OpenAI Codex model or run /login for OpenAI ChatGPT Plus/Pro (Codex).",
292
- );
293
- }
294
-
295
- const response = await fetchWithTimeout(
296
- CODEX_USAGE_URL,
297
- { headers: auth.headers },
298
- timeoutMs,
299
- );
300
- const text = await response.text();
301
- if (!response.ok) {
302
- throw new Error(
303
- `Codex usage endpoint returned ${response.status} ${response.statusText}: ${redactErrorBody(text)}`,
304
- );
305
- }
306
-
307
- const payload = parseJsonObject(text, "Codex usage endpoint response");
308
- return normalizeBackendPayload(
309
- payload as RateLimitStatusPayload,
310
- Date.now(),
311
- "pi-auth",
312
- );
294
+ const auth = await resolvePiCodexAuth(ctx);
295
+ if (!auth) {
296
+ throw new Error(
297
+ "No Pi OpenAI Codex subscription auth was available. Use a Pi OpenAI Codex model or run /login for OpenAI ChatGPT Plus/Pro (Codex).",
298
+ );
299
+ }
300
+
301
+ const response = await fetchWithTimeout(
302
+ CODEX_USAGE_URL,
303
+ { headers: auth.headers },
304
+ timeoutMs,
305
+ );
306
+ const text = await response.text();
307
+ if (!response.ok) {
308
+ throw new Error(
309
+ `Codex usage endpoint returned ${response.status} ${response.statusText}: ${redactErrorBody(text)}`,
310
+ );
311
+ }
312
+
313
+ const payload = parseJsonObject(text, "Codex usage endpoint response");
314
+ return normalizeBackendPayload(
315
+ payload as RateLimitStatusPayload,
316
+ Date.now(),
317
+ "pi-auth",
318
+ );
313
319
  }
314
320
 
315
321
  async function resolvePiCodexAuth(
316
- ctx: ExtensionContext,
322
+ ctx: ExtensionContext,
317
323
  ): Promise<{ headers: Record<string, string> } | undefined> {
318
- const models = codexAuthCandidateModels(ctx);
319
- const errors: string[] = [];
320
-
321
- for (const model of models) {
322
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
323
- if (!auth.ok) {
324
- errors.push(auth.error);
325
- continue;
326
- }
327
-
328
- const headers = { ...(auth.headers ?? {}) };
329
- if (!hasHeader(headers, "Authorization") && auth.apiKey) {
330
- headers.Authorization = `Bearer ${auth.apiKey}`;
331
- }
332
- if (!hasHeader(headers, "User-Agent")) {
333
- headers["User-Agent"] = "pi-codex-usage";
334
- }
335
- if (hasHeader(headers, "Authorization")) {
336
- return { headers };
337
- }
338
- }
339
-
340
- if (errors.length > 0) {
341
- throw new Error(errors.join("; "));
342
- }
343
- return undefined;
324
+ const models = codexAuthCandidateModels(ctx);
325
+ const errors: string[] = [];
326
+
327
+ for (const model of models) {
328
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
329
+ if (!auth.ok) {
330
+ errors.push(auth.error);
331
+ continue;
332
+ }
333
+
334
+ const headers = { ...(auth.headers ?? {}) };
335
+ if (!hasHeader(headers, "Authorization") && auth.apiKey) {
336
+ headers.Authorization = `Bearer ${auth.apiKey}`;
337
+ }
338
+ if (!hasHeader(headers, "User-Agent")) {
339
+ headers["User-Agent"] = "pi-codex-usage";
340
+ }
341
+ if (hasHeader(headers, "Authorization")) {
342
+ return { headers };
343
+ }
344
+ }
345
+
346
+ if (errors.length > 0) {
347
+ throw new Error(errors.join("; "));
348
+ }
349
+ return undefined;
344
350
  }
345
351
 
346
352
  function codexAuthCandidateModels(ctx: ExtensionContext): PiModel[] {
347
- const candidates: PiModel[] = [];
348
- const seen = new Set<string>();
349
- const add = (model: PiModel | undefined) => {
350
- if (!model || model.provider !== CODEX_PROVIDER_ID) return;
351
- const key = `${model.provider}/${model.id}`;
352
- if (seen.has(key)) return;
353
- seen.add(key);
354
- candidates.push(model);
355
- };
356
-
357
- add(ctx.model);
358
- for (const model of ctx.modelRegistry.getAvailable()) add(model);
359
- for (const model of ctx.modelRegistry.getAll()) add(model);
360
- return candidates;
353
+ const candidates: PiModel[] = [];
354
+ const seen = new Set<string>();
355
+ const add = (model: PiModel | undefined) => {
356
+ if (!model || model.provider !== CODEX_PROVIDER_ID) return;
357
+ const key = `${model.provider}/${model.id}`;
358
+ if (seen.has(key)) return;
359
+ seen.add(key);
360
+ candidates.push(model);
361
+ };
362
+
363
+ add(ctx.model);
364
+ for (const model of ctx.modelRegistry.getAvailable()) add(model);
365
+ for (const model of ctx.modelRegistry.getAll()) add(model);
366
+ return candidates;
361
367
  }
362
368
 
363
369
  async function fetchWithTimeout(
364
- url: string,
365
- init: RequestInit,
366
- timeoutMs: number,
370
+ url: string,
371
+ init: RequestInit,
372
+ timeoutMs: number,
367
373
  ): Promise<Response> {
368
- const controller = new AbortController();
369
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
370
- try {
371
- return await fetch(url, { ...init, signal: controller.signal });
372
- } catch (error) {
373
- if (controller.signal.aborted) {
374
- throw new Error(
375
- `Timed out after ${Math.round(timeoutMs / 1000)}s while fetching Codex usage.`,
376
- );
377
- }
378
- throw error;
379
- } finally {
380
- clearTimeout(timeout);
381
- }
374
+ const controller = new AbortController();
375
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
376
+ try {
377
+ return await fetch(url, { ...init, signal: controller.signal });
378
+ } catch (error) {
379
+ if (controller.signal.aborted) {
380
+ throw new Error(
381
+ `Timed out after ${Math.round(timeoutMs / 1000)}s while fetching Codex usage.`,
382
+ );
383
+ }
384
+ throw error;
385
+ } finally {
386
+ clearTimeout(timeout);
387
+ }
382
388
  }
383
389
 
384
390
  async function queryViaCodexAppServer(
385
- timeoutMs: number,
391
+ timeoutMs: number,
386
392
  ): Promise<CodexUsageReport> {
387
- const client = new CodexAppServerClient(timeoutMs);
388
- try {
389
- await client.start();
390
- await client.request("initialize", {
391
- clientInfo: {
392
- name: "pi_codex_usage",
393
- title: "Pi Codex Usage",
394
- version: "0.1.0",
395
- },
396
- capabilities: {
397
- experimentalApi: false,
398
- requestAttestation: false,
399
- optOutNotificationMethods: [],
400
- },
401
- });
402
- client.notify("initialized");
403
- const result = await client.request("account/rateLimits/read", undefined);
404
- return normalizeAppServerResponse(
405
- assertObject(
406
- result,
407
- "account/rateLimits/read result",
408
- ) as AppServerRateLimitResponse,
409
- Date.now(),
410
- );
411
- } finally {
412
- client.dispose();
413
- }
393
+ const client = new CodexAppServerClient(timeoutMs);
394
+ try {
395
+ await client.start();
396
+ await client.request("initialize", {
397
+ clientInfo: {
398
+ name: "pi_codex_usage",
399
+ title: "Pi Codex Usage",
400
+ version: "0.1.0",
401
+ },
402
+ capabilities: {
403
+ experimentalApi: false,
404
+ requestAttestation: false,
405
+ optOutNotificationMethods: [],
406
+ },
407
+ });
408
+ client.notify("initialized");
409
+ const result = await client.request("account/rateLimits/read", undefined);
410
+ return normalizeAppServerResponse(
411
+ assertObject(
412
+ result,
413
+ "account/rateLimits/read result",
414
+ ) as AppServerRateLimitResponse,
415
+ Date.now(),
416
+ );
417
+ } finally {
418
+ client.dispose();
419
+ }
414
420
  }
415
421
 
416
422
  class CodexAppServerClient {
417
- private child?: ChildProcessWithoutNullStreams;
418
- private nextId = 1;
419
- private stderr = "";
420
- private readonly pending = new Map<number, PendingRpc>();
421
- private startPromise?: Promise<void>;
422
- private exitError?: Error;
423
- private readonly timeoutMs: number;
424
-
425
- constructor(timeoutMs: number) {
426
- this.timeoutMs = timeoutMs;
427
- }
428
-
429
- start(): Promise<void> {
430
- if (this.startPromise) return this.startPromise;
431
-
432
- this.startPromise = new Promise((resolve, reject) => {
433
- const child = spawn("codex", ["app-server", "--listen", "stdio://"], {
434
- stdio: ["pipe", "pipe", "pipe"],
435
- });
436
- this.child = child;
437
-
438
- const startupTimeout = setTimeout(() => {
439
- reject(
440
- new Error(
441
- `Timed out after ${Math.round(this.timeoutMs / 1000)}s starting codex app-server.`,
442
- ),
443
- );
444
- }, this.timeoutMs);
445
-
446
- child.once("spawn", () => {
447
- clearTimeout(startupTimeout);
448
- resolve();
449
- });
450
-
451
- child.once("error", (error) => {
452
- clearTimeout(startupTimeout);
453
- reject(new Error(`Failed to start codex app-server: ${error.message}`));
454
- this.rejectAll(error);
455
- });
456
-
457
- child.once("exit", (code, signal) => {
458
- const suffix = this.stderr
459
- ? ` stderr: ${redactErrorBody(this.stderr)}`
460
- : "";
461
- this.exitError = new Error(
462
- `codex app-server exited before completing the request (code ${code ?? "unknown"}, signal ${signal ?? "none"}).${suffix}`,
463
- );
464
- this.rejectAll(this.exitError);
465
- });
466
-
467
- child.stderr.setEncoding("utf8");
468
- child.stderr.on("data", (chunk: string) => {
469
- this.stderr = truncateEnd(this.stderr + chunk, MAX_ERROR_BODY_CHARS);
470
- });
471
-
472
- const lines = createInterface({ input: child.stdout });
473
- lines.on("line", (line) => this.handleLine(line));
474
- });
475
-
476
- return this.startPromise;
477
- }
478
-
479
- request(method: string, params: unknown): Promise<unknown> {
480
- const child = this.child;
481
- if (!child?.stdin.writable) {
482
- throw new Error("codex app-server is not running.");
483
- }
484
- if (this.exitError) throw this.exitError;
485
-
486
- const id = this.nextId++;
487
- const payload =
488
- params === undefined ? { method, id } : { method, id, params };
489
- const response = new Promise<unknown>((resolve, reject) => {
490
- const timeout = setTimeout(() => {
491
- this.pending.delete(id);
492
- reject(
493
- new Error(
494
- `Timed out after ${Math.round(this.timeoutMs / 1000)}s waiting for ${method}.`,
495
- ),
496
- );
497
- }, this.timeoutMs);
498
-
499
- this.pending.set(id, {
500
- resolve: (value) => {
501
- clearTimeout(timeout);
502
- resolve(value);
503
- },
504
- reject: (error) => {
505
- clearTimeout(timeout);
506
- reject(error);
507
- },
508
- });
509
- });
510
-
511
- child.stdin.write(`${JSON.stringify(payload)}\n`);
512
- return response;
513
- }
514
-
515
- notify(method: string): void {
516
- const child = this.child;
517
- if (!child?.stdin.writable) return;
518
- child.stdin.write(`${JSON.stringify({ method })}\n`);
519
- }
520
-
521
- dispose(): void {
522
- for (const [id, pending] of this.pending) {
523
- pending.reject(new Error(`codex app-server request ${id} cancelled.`));
524
- }
525
- this.pending.clear();
526
-
527
- const child = this.child;
528
- if (!child) return;
529
- child.stdin.end();
530
- if (!child.killed) child.kill();
531
- this.child = undefined;
532
- }
533
-
534
- private handleLine(line: string): void {
535
- let parsed: RpcResponse;
536
- try {
537
- parsed = JSON.parse(line) as RpcResponse;
538
- } catch {
539
- return;
540
- }
541
-
542
- if (typeof parsed.id !== "number") return;
543
- const pending = this.pending.get(parsed.id);
544
- if (!pending) return;
545
- this.pending.delete(parsed.id);
546
-
547
- if (parsed.error) {
548
- const message =
549
- typeof parsed.error.message === "string"
550
- ? parsed.error.message
551
- : "unknown error";
552
- pending.reject(new Error(`codex app-server request failed: ${message}`));
553
- return;
554
- }
555
-
556
- pending.resolve(parsed.result);
557
- }
558
-
559
- private rejectAll(error: Error): void {
560
- for (const pending of this.pending.values()) pending.reject(error);
561
- this.pending.clear();
562
- }
423
+ private child?: ChildProcessWithoutNullStreams;
424
+ private nextId = 1;
425
+ private stderr = "";
426
+ private readonly pending = new Map<number, PendingRpc>();
427
+ private startPromise?: Promise<void>;
428
+ private exitError?: Error;
429
+ private readonly timeoutMs: number;
430
+
431
+ constructor(timeoutMs: number) {
432
+ this.timeoutMs = timeoutMs;
433
+ }
434
+
435
+ start(): Promise<void> {
436
+ if (this.startPromise) return this.startPromise;
437
+
438
+ this.startPromise = new Promise((resolve, reject) => {
439
+ const child = spawn("codex", ["app-server", "--listen", "stdio://"], {
440
+ stdio: ["pipe", "pipe", "pipe"],
441
+ });
442
+ this.child = child;
443
+
444
+ const startupTimeout = setTimeout(() => {
445
+ reject(
446
+ new Error(
447
+ `Timed out after ${Math.round(this.timeoutMs / 1000)}s starting codex app-server.`,
448
+ ),
449
+ );
450
+ }, this.timeoutMs);
451
+
452
+ child.once("spawn", () => {
453
+ clearTimeout(startupTimeout);
454
+ resolve();
455
+ });
456
+
457
+ child.once("error", (error) => {
458
+ clearTimeout(startupTimeout);
459
+ reject(new Error(`Failed to start codex app-server: ${error.message}`));
460
+ this.rejectAll(error);
461
+ });
462
+
463
+ child.once("exit", (code, signal) => {
464
+ const suffix = this.stderr
465
+ ? ` stderr: ${redactErrorBody(this.stderr)}`
466
+ : "";
467
+ this.exitError = new Error(
468
+ `codex app-server exited before completing the request (code ${code ?? "unknown"}, signal ${signal ?? "none"}).${suffix}`,
469
+ );
470
+ this.rejectAll(this.exitError);
471
+ });
472
+
473
+ child.stderr.setEncoding("utf8");
474
+ child.stderr.on("data", (chunk: string) => {
475
+ this.stderr = truncateEnd(this.stderr + chunk, MAX_ERROR_BODY_CHARS);
476
+ });
477
+
478
+ const lines = createInterface({ input: child.stdout });
479
+ lines.on("line", (line) => this.handleLine(line));
480
+ });
481
+
482
+ return this.startPromise;
483
+ }
484
+
485
+ request(method: string, params: unknown): Promise<unknown> {
486
+ const child = this.child;
487
+ if (!child?.stdin.writable) {
488
+ throw new Error("codex app-server is not running.");
489
+ }
490
+ if (this.exitError) throw this.exitError;
491
+
492
+ const id = this.nextId++;
493
+ const payload =
494
+ params === undefined ? { method, id } : { method, id, params };
495
+ const response = new Promise<unknown>((resolve, reject) => {
496
+ const timeout = setTimeout(() => {
497
+ this.pending.delete(id);
498
+ reject(
499
+ new Error(
500
+ `Timed out after ${Math.round(this.timeoutMs / 1000)}s waiting for ${method}.`,
501
+ ),
502
+ );
503
+ }, this.timeoutMs);
504
+
505
+ this.pending.set(id, {
506
+ resolve: (value) => {
507
+ clearTimeout(timeout);
508
+ resolve(value);
509
+ },
510
+ reject: (error) => {
511
+ clearTimeout(timeout);
512
+ reject(error);
513
+ },
514
+ });
515
+ });
516
+
517
+ child.stdin.write(`${JSON.stringify(payload)}\n`);
518
+ return response;
519
+ }
520
+
521
+ notify(method: string): void {
522
+ const child = this.child;
523
+ if (!child?.stdin.writable) return;
524
+ child.stdin.write(`${JSON.stringify({ method })}\n`);
525
+ }
526
+
527
+ dispose(): void {
528
+ for (const [id, pending] of this.pending) {
529
+ pending.reject(new Error(`codex app-server request ${id} cancelled.`));
530
+ }
531
+ this.pending.clear();
532
+
533
+ const child = this.child;
534
+ if (!child) return;
535
+ child.stdin.end();
536
+ if (!child.killed) child.kill();
537
+ this.child = undefined;
538
+ }
539
+
540
+ private handleLine(line: string): void {
541
+ let parsed: RpcResponse;
542
+ try {
543
+ parsed = JSON.parse(line) as RpcResponse;
544
+ } catch {
545
+ return;
546
+ }
547
+
548
+ if (typeof parsed.id !== "number") return;
549
+ const pending = this.pending.get(parsed.id);
550
+ if (!pending) return;
551
+ this.pending.delete(parsed.id);
552
+
553
+ if (parsed.error) {
554
+ const message =
555
+ typeof parsed.error.message === "string"
556
+ ? parsed.error.message
557
+ : "unknown error";
558
+ pending.reject(new Error(`codex app-server request failed: ${message}`));
559
+ return;
560
+ }
561
+
562
+ pending.resolve(parsed.result);
563
+ }
564
+
565
+ private rejectAll(error: Error): void {
566
+ for (const pending of this.pending.values()) pending.reject(error);
567
+ this.pending.clear();
568
+ }
563
569
  }
564
570
 
565
571
  export function normalizeBackendPayload(
566
- payload: RateLimitStatusPayload,
567
- _capturedAt: number,
568
- _source: UsageSource,
572
+ payload: RateLimitStatusPayload,
573
+ _capturedAt: number,
574
+ _source: UsageSource,
569
575
  ): CodexUsageReport {
570
- const snapshot = normalizeBackendSnapshot("codex", payload.rate_limit);
571
- if (!snapshot) {
572
- throw new Error(
573
- "Codex usage endpoint returned no displayable rate-limit windows.",
574
- );
575
- }
576
- return { snapshots: [snapshot] };
576
+ const snapshot = normalizeBackendSnapshot("codex", payload.rate_limit);
577
+ if (!snapshot) {
578
+ throw new Error(
579
+ "Codex usage endpoint returned no displayable rate-limit windows.",
580
+ );
581
+ }
582
+ return { snapshots: [snapshot] };
577
583
  }
578
584
 
579
585
  function normalizeBackendSnapshot(
580
- limitId: string,
581
- rateLimit: unknown,
586
+ limitId: string,
587
+ rateLimit: unknown,
582
588
  ): NormalizedRateLimitSnapshot | undefined {
583
- if (rateLimit === null || rateLimit === undefined) return undefined;
584
- const details = assertObject(
585
- rateLimit,
586
- "rate limit",
587
- ) as BackendRateLimitDetails;
588
- const primary = normalizeBackendWindow(details.primary_window);
589
- const secondary = normalizeBackendWindow(details.secondary_window);
590
- if (!primary && !secondary) return undefined;
591
- return { limitId, primary, secondary };
589
+ if (rateLimit === null || rateLimit === undefined) return undefined;
590
+ const details = assertObject(
591
+ rateLimit,
592
+ "rate limit",
593
+ ) as BackendRateLimitDetails;
594
+ const primary = normalizeBackendWindow(details.primary_window);
595
+ const secondary = normalizeBackendWindow(details.secondary_window);
596
+ if (!primary && !secondary) return undefined;
597
+ return { limitId, primary, secondary };
592
598
  }
593
599
 
594
600
  function normalizeBackendWindow(
595
- value: unknown,
601
+ value: unknown,
596
602
  ): NormalizedRateLimitWindow | undefined {
597
- if (value === null || value === undefined) return undefined;
598
- const window = assertObject(
599
- value,
600
- "rate-limit window",
601
- ) as BackendWindowSnapshot;
602
- const usedPercent = asNumber(window.used_percent);
603
- if (usedPercent === undefined) return undefined;
604
- return { usedPercent };
603
+ if (value === null || value === undefined) return undefined;
604
+ const window = assertObject(
605
+ value,
606
+ "rate-limit window",
607
+ ) as BackendWindowSnapshot;
608
+ const usedPercent = asNumber(window.used_percent);
609
+ if (usedPercent === undefined) return undefined;
610
+ return { usedPercent };
605
611
  }
606
612
 
607
613
  export function normalizeAppServerResponse(
608
- response: AppServerRateLimitResponse,
609
- _capturedAt: number,
614
+ response: AppServerRateLimitResponse,
615
+ _capturedAt: number,
610
616
  ): CodexUsageReport {
611
- const snapshots: NormalizedRateLimitSnapshot[] = [];
612
- const addSnapshot = (raw: unknown, fallbackId: string) => {
613
- const snapshot = normalizeAppServerSnapshot(raw, fallbackId);
614
- if (!snapshot) return;
615
- const existingIndex = snapshots.findIndex(
616
- (item) => item.limitId === snapshot.limitId,
617
- );
618
- if (existingIndex >= 0)
619
- snapshots[existingIndex] = mergeSnapshot(
620
- snapshots[existingIndex],
621
- snapshot,
622
- );
623
- else snapshots.push(snapshot);
624
- };
625
-
626
- addSnapshot(response.rateLimits, "codex");
627
- if (snapshots.length === 0) {
628
- throw new Error(
629
- "codex app-server returned no displayable rate-limit windows.",
630
- );
631
- }
632
-
633
- return { snapshots };
617
+ const snapshots: NormalizedRateLimitSnapshot[] = [];
618
+ const addSnapshot = (raw: unknown, fallbackId: string) => {
619
+ const snapshot = normalizeAppServerSnapshot(raw, fallbackId);
620
+ if (!snapshot) return;
621
+ const existingIndex = snapshots.findIndex(
622
+ (item) => item.limitId === snapshot.limitId,
623
+ );
624
+ if (existingIndex >= 0)
625
+ snapshots[existingIndex] = mergeSnapshot(
626
+ snapshots[existingIndex],
627
+ snapshot,
628
+ );
629
+ else snapshots.push(snapshot);
630
+ };
631
+
632
+ addSnapshot(response.rateLimits, "codex");
633
+ if (snapshots.length === 0) {
634
+ throw new Error(
635
+ "codex app-server returned no displayable rate-limit windows.",
636
+ );
637
+ }
638
+
639
+ return { snapshots };
634
640
  }
635
641
 
636
642
  function normalizeAppServerSnapshot(
637
- raw: unknown,
638
- fallbackId: string,
643
+ raw: unknown,
644
+ fallbackId: string,
639
645
  ): NormalizedRateLimitSnapshot | undefined {
640
- if (raw === null || raw === undefined) return undefined;
641
- const snapshot = assertObject(
642
- raw,
643
- "app-server rate-limit snapshot",
644
- ) as AppServerRateLimitSnapshot;
645
- const limitId = asString(snapshot.limitId) ?? fallbackId;
646
- const primary = normalizeAppServerWindow(snapshot.primary);
647
- const secondary = normalizeAppServerWindow(snapshot.secondary);
648
- if (!primary && !secondary) return undefined;
649
- return { limitId, primary, secondary };
646
+ if (raw === null || raw === undefined) return undefined;
647
+ const snapshot = assertObject(
648
+ raw,
649
+ "app-server rate-limit snapshot",
650
+ ) as AppServerRateLimitSnapshot;
651
+ const limitId = asString(snapshot.limitId) ?? fallbackId;
652
+ const primary = normalizeAppServerWindow(snapshot.primary);
653
+ const secondary = normalizeAppServerWindow(snapshot.secondary);
654
+ if (!primary && !secondary) return undefined;
655
+ return { limitId, primary, secondary };
650
656
  }
651
657
 
652
658
  function normalizeAppServerWindow(
653
- value: unknown,
659
+ value: unknown,
654
660
  ): NormalizedRateLimitWindow | undefined {
655
- if (value === null || value === undefined) return undefined;
656
- const window = assertObject(
657
- value,
658
- "app-server rate-limit window",
659
- ) as AppServerWindowSnapshot;
660
- const usedPercent = asNumber(window.usedPercent);
661
- if (usedPercent === undefined) return undefined;
662
- return { usedPercent };
661
+ if (value === null || value === undefined) return undefined;
662
+ const window = assertObject(
663
+ value,
664
+ "app-server rate-limit window",
665
+ ) as AppServerWindowSnapshot;
666
+ const usedPercent = asNumber(window.usedPercent);
667
+ if (usedPercent === undefined) return undefined;
668
+ return { usedPercent };
663
669
  }
664
670
 
665
671
  function mergeSnapshot(
666
- left: NormalizedRateLimitSnapshot,
667
- right: NormalizedRateLimitSnapshot,
672
+ left: NormalizedRateLimitSnapshot,
673
+ right: NormalizedRateLimitSnapshot,
668
674
  ): NormalizedRateLimitSnapshot {
669
- return {
670
- limitId: right.limitId || left.limitId,
671
- primary: right.primary ?? left.primary,
672
- secondary: right.secondary ?? left.secondary,
673
- };
675
+ return {
676
+ limitId: right.limitId || left.limitId,
677
+ primary: right.primary ?? left.primary,
678
+ secondary: right.secondary ?? left.secondary,
679
+ };
674
680
  }
675
681
 
676
682
  export function formatCodexUsageStatusline(
677
- report: CodexUsageReport,
678
- ctx: ExtensionContext,
679
- _model?: CodexUsageModel,
683
+ report: CodexUsageReport,
684
+ ctx: ExtensionContext,
685
+ _model?: CodexUsageModel,
680
686
  ): string {
681
- const snapshot = selectPrimaryCodexSnapshot(report);
682
- if (!snapshot) return formatStatuslineText(ctx, "n/a");
687
+ const snapshot = selectPrimaryCodexSnapshot(report);
688
+ if (!snapshot) return formatStatuslineText(ctx, "n/a");
683
689
 
684
- if (!snapshot.primary && !snapshot.secondary) return formatStatuslineText(ctx, "n/a");
685
- return formatStatuslineText(
686
- ctx,
687
- `[${formatDualLimitBar(snapshot.primary, snapshot.secondary)}]`,
688
- );
690
+ if (!snapshot.primary && !snapshot.secondary)
691
+ return formatStatuslineText(ctx, "n/a");
692
+ return formatStatuslineText(
693
+ ctx,
694
+ `[${formatDualLimitBar(snapshot.primary, snapshot.secondary)}]`,
695
+ );
689
696
  }
690
697
 
691
698
  function formatStatuslineText(ctx: ExtensionContext, value: string): string {
692
- const label = ctx.ui.theme.fg("accent", STATUS_LABEL_TEXT);
693
- return `${label} ${ctx.ui.theme.fg("muted", value)}`;
699
+ const label = ctx.ui.theme.fg("accent", STATUS_LABEL_TEXT);
700
+ return `${label} ${ctx.ui.theme.fg("muted", value)}`;
694
701
  }
695
702
 
696
703
  function formatEmptyStatuslineBar(ctx: ExtensionContext): string {
697
- return formatStatuslineText(ctx, "[\u00a0\u00a0\u00a0\u00a0\u00a0]");
704
+ return formatStatuslineText(ctx, "[\u00a0\u00a0\u00a0\u00a0\u00a0]");
698
705
  }
699
706
 
700
707
  function formatStatuslineProblem(
701
- ctx: ExtensionContext,
702
- errors: UsageQueryError[],
708
+ ctx: ExtensionContext,
709
+ errors: UsageQueryError[],
703
710
  ): string {
704
- const label = ctx.ui.theme.fg("accent", STATUS_LABEL_TEXT);
705
- const value = isUnavailable(errors)
706
- ? ctx.ui.theme.fg("muted", "n/a")
707
- : ctx.ui.theme.fg("error", "error");
708
- return `${label} ${value}`;
711
+ const label = ctx.ui.theme.fg("accent", STATUS_LABEL_TEXT);
712
+ const value = isUnavailable(errors)
713
+ ? ctx.ui.theme.fg("muted", "n/a")
714
+ : ctx.ui.theme.fg("error", "error");
715
+ return `${label} ${value}`;
709
716
  }
710
717
 
711
718
  function isUnavailable(errors: UsageQueryError[]): boolean {
712
- return errors.some((error) => {
713
- const message = error.message.toLowerCase();
714
- return (
715
- message.includes("no pi openai codex subscription auth") ||
716
- message.includes("no displayable rate-limit windows") ||
717
- message.includes("returned no displayable rate-limit windows") ||
718
- message.includes("returned 401") ||
719
- message.includes("returned 403") ||
720
- message.includes("unauthorized") ||
721
- message.includes("forbidden") ||
722
- message.includes("subscription") ||
723
- message.includes("no active plan") ||
724
- message.includes("plan unavailable") ||
725
- message.includes("quota unavailable") ||
726
- message.includes("rate limits unavailable")
727
- );
728
- });
719
+ return errors.some((error) => {
720
+ const message = error.message.toLowerCase();
721
+ return (
722
+ message.includes("no pi openai codex subscription auth") ||
723
+ message.includes("no displayable rate-limit windows") ||
724
+ message.includes("returned no displayable rate-limit windows") ||
725
+ message.includes("returned 401") ||
726
+ message.includes("returned 403") ||
727
+ message.includes("unauthorized") ||
728
+ message.includes("forbidden") ||
729
+ message.includes("subscription") ||
730
+ message.includes("no active plan") ||
731
+ message.includes("plan unavailable") ||
732
+ message.includes("quota unavailable") ||
733
+ message.includes("rate limits unavailable")
734
+ );
735
+ });
729
736
  }
730
737
 
731
738
  function selectPrimaryCodexSnapshot(
732
- report: CodexUsageReport,
739
+ report: CodexUsageReport,
733
740
  ): NormalizedRateLimitSnapshot | undefined {
734
- return report.snapshots.find(isPrimaryCodexSnapshot) ?? report.snapshots[0];
741
+ return report.snapshots.find(isPrimaryCodexSnapshot) ?? report.snapshots[0];
735
742
  }
736
743
 
737
744
  function normalizedUsageKey(value: string | undefined): string | undefined {
738
- const key = value
739
- ?.toLowerCase()
740
- .replace(/[^a-z0-9]+/g, "-")
741
- .replace(/^-+|-+$/g, "");
742
- return key || undefined;
745
+ const key = value
746
+ ?.toLowerCase()
747
+ .replace(/[^a-z0-9]+/g, "-")
748
+ .replace(/^-+|-+$/g, "");
749
+ return key || undefined;
743
750
  }
744
751
 
745
752
  function formatDualLimitBar(
746
- primary: NormalizedRateLimitWindow | undefined,
747
- secondary: NormalizedRateLimitWindow | undefined,
753
+ primary: NormalizedRateLimitWindow | undefined,
754
+ secondary: NormalizedRateLimitWindow | undefined,
748
755
  ): string {
749
- const primaryParts = filledTenths(primary);
750
- const secondaryParts = filledTenths(secondary);
751
- let value = "";
752
- for (let index = 0; index < 5; index++) {
753
- const leftPart = index * 2 + 1;
754
- const rightPart = leftPart + 1;
755
- let mask = 0;
756
- if (primaryParts >= leftPart) mask |= 1;
757
- if (primaryParts >= rightPart) mask |= 2;
758
- if (secondaryParts >= leftPart) mask |= 4;
759
- if (secondaryParts >= rightPart) mask |= 8;
760
- value += DUAL_BAR_CHARS[mask];
761
- }
762
- return value;
756
+ const primaryParts = filledTenths(primary);
757
+ const secondaryParts = filledTenths(secondary);
758
+ let value = "";
759
+ for (let index = 0; index < 5; index++) {
760
+ const leftPart = index * 2 + 1;
761
+ const rightPart = leftPart + 1;
762
+ let mask = 0;
763
+ if (primaryParts >= leftPart) mask |= 1;
764
+ if (primaryParts >= rightPart) mask |= 2;
765
+ if (secondaryParts >= leftPart) mask |= 4;
766
+ if (secondaryParts >= rightPart) mask |= 8;
767
+ value += DUAL_BAR_CHARS[mask];
768
+ }
769
+ return value;
763
770
  }
764
771
 
765
772
  function filledTenths(window: NormalizedRateLimitWindow | undefined): number {
766
- if (!window) return 0;
767
- return Math.round(remainingPercent(window) / 10);
773
+ if (!window) return 0;
774
+ return Math.round(remainingPercent(window) / 10);
768
775
  }
769
776
 
770
777
  function remainingPercent(window: NormalizedRateLimitWindow): number {
771
- return 100 - clampPercent(window.usedPercent);
778
+ return 100 - clampPercent(window.usedPercent);
772
779
  }
773
780
 
774
781
  function isPrimaryCodexSnapshot(
775
- snapshot: NormalizedRateLimitSnapshot,
782
+ snapshot: NormalizedRateLimitSnapshot,
776
783
  ): boolean {
777
- return normalizedUsageKey(snapshot.limitId) === "codex";
784
+ return normalizedUsageKey(snapshot.limitId) === "codex";
778
785
  }
779
786
 
780
787
  function clampPercent(value: number): number {
781
- if (!Number.isFinite(value)) return 0;
782
- return Math.min(100, Math.max(0, value));
788
+ if (!Number.isFinite(value)) return 0;
789
+ return Math.min(100, Math.max(0, value));
783
790
  }
784
791
 
785
792
  function parseJsonObject(
786
- text: string,
787
- description: string,
793
+ text: string,
794
+ description: string,
788
795
  ): Record<string, unknown> {
789
- let parsed: unknown;
790
- try {
791
- parsed = JSON.parse(text) as unknown;
792
- } catch (error) {
793
- throw new Error(
794
- `${description} was not valid JSON: ${errorMessage(error)}`,
795
- );
796
- }
797
- return assertObject(parsed, description);
796
+ let parsed: unknown;
797
+ try {
798
+ parsed = JSON.parse(text) as unknown;
799
+ } catch (error) {
800
+ throw new Error(
801
+ `${description} was not valid JSON: ${errorMessage(error)}`,
802
+ );
803
+ }
804
+ return assertObject(parsed, description);
798
805
  }
799
806
 
800
807
  function assertObject(
801
- value: unknown,
802
- description: string,
808
+ value: unknown,
809
+ description: string,
803
810
  ): Record<string, unknown> {
804
- if (!value || typeof value !== "object" || Array.isArray(value)) {
805
- throw new Error(`${description} was not an object.`);
806
- }
807
- return value as Record<string, unknown>;
811
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
812
+ throw new Error(`${description} was not an object.`);
813
+ }
814
+ return value as Record<string, unknown>;
808
815
  }
809
816
 
810
817
  function asString(value: unknown): string | undefined {
811
- return typeof value === "string" ? value : undefined;
818
+ return typeof value === "string" ? value : undefined;
812
819
  }
813
820
 
814
821
  function asNumber(value: unknown): number | undefined {
815
- if (typeof value === "number" && Number.isFinite(value)) return value;
816
- if (typeof value === "string" && value.trim()) {
817
- const parsed = Number(value);
818
- return Number.isFinite(parsed) ? parsed : undefined;
819
- }
820
- return undefined;
822
+ if (typeof value === "number" && Number.isFinite(value)) return value;
823
+ if (typeof value === "string" && value.trim()) {
824
+ const parsed = Number(value);
825
+ return Number.isFinite(parsed) ? parsed : undefined;
826
+ }
827
+ return undefined;
821
828
  }
822
829
 
823
830
  function hasHeader(headers: Record<string, string>, name: string): boolean {
824
- return Object.keys(headers).some(
825
- (key) => key.toLowerCase() === name.toLowerCase(),
826
- );
831
+ return Object.keys(headers).some(
832
+ (key) => key.toLowerCase() === name.toLowerCase(),
833
+ );
827
834
  }
828
835
 
829
836
  function redactErrorBody(body: string): string {
830
- return truncateEnd(
831
- body
832
- .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer <redacted>")
833
- .replace(/"access_token"\s*:\s*"[^"]+"/gi, '"access_token":"<redacted>"')
834
- .trim(),
835
- MAX_ERROR_BODY_CHARS,
836
- );
837
+ return truncateEnd(
838
+ body
839
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer <redacted>")
840
+ .replace(/"access_token"\s*:\s*"[^"]+"/gi, '"access_token":"<redacted>"')
841
+ .trim(),
842
+ MAX_ERROR_BODY_CHARS,
843
+ );
837
844
  }
838
845
 
839
846
  function truncateEnd(value: string, maxChars: number): string {
840
- if (value.length <= maxChars) return value;
841
- return `${value.slice(0, maxChars - 1)}…`;
847
+ if (value.length <= maxChars) return value;
848
+ return `${value.slice(0, maxChars - 1)}…`;
842
849
  }
843
850
 
844
851
  function errorMessage(error: unknown): string {
845
- return error instanceof Error ? error.message : String(error);
852
+ return error instanceof Error ? error.message : String(error);
846
853
  }