@llblab/pi-codex-usage 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/BACKLOG.md CHANGED
@@ -1,3 +1,7 @@
1
1
  # Backlog
2
2
 
3
- No open work.
3
+ ## Pending Release Verification
4
+
5
+ - [x] Fix stale extension context usage after session replacement or reload. Source: GitHub issue #1. Timers must not keep using captured stale `ctx` after Pi session lifecycle changes.
6
+ - [x] Make usage refresh cache/in-flight state model-aware for Codex vs Spark. A regular Codex cache or request must not temporarily render Spark as `n/a` after model switching.
7
+ - [x] Preserve the active `codex`/`spark` label for failure states. Spark failures should render `spark n/a` or `spark error`, not `codex ...`.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.8.1: Stale Context Hotfix
6
+
7
+ - Fixed stale extension context crashes from delayed statusline timers after Pi session replacement or reload. Impact: timers now ignore stale `ctx` failures instead of letting issue #1 crash Pi after idle/session lifecycle changes.
8
+ - Fixed Codex/Spark refresh state to key in-flight usage lookups by active quota bucket and only reuse cached reports that contain the active bucket. Impact: switching between regular Codex and Spark no longer temporarily renders the active bucket as unavailable because of a stale request/cache.
9
+ - Fixed Spark failure labels. Impact: Spark unavailable/error states now render with the `spark` label instead of falling back to `codex`.
10
+ - Added regression coverage for active-bucket cache reuse and stale extension context error detection.
11
+
12
+ ## 0.8.0: Spark Model Usage Status
13
+
14
+ - Added active-bucket status support for `GPT-5.3-Codex-Spark`. Impact: selecting the Spark Codex subscription model now shows the parallel Spark rate-limit bucket with a `spark` label, while regular Codex models keep the existing `codex` bucket/status behavior.
15
+ - Added parsing for backend `additional_rate_limits`, including the Spark `GPT-5.3-Codex-Spark` / `codex_bengalfox` limit. Impact: Spark quota can be shown from the same Pi-auth usage response path as normal Codex quota instead of relying only on app-server fallback.
16
+
5
17
  ## 0.7.0: Exhausted Quota Visibility
6
18
 
7
19
  - Added exhausted-quota warning background for the statusline quota bar. Impact: when either the 5-hour or weekly Codex window has 0% remaining, the bar keeps its shape but switches from the selected background to the error background for faster visual detection.
package/README.md CHANGED
@@ -15,9 +15,11 @@ This repository is a minimal fork of [`narumiruna/pi-extensions/extensions/pi-co
15
15
  ## Features
16
16
 
17
17
  - Shows an empty statusline bar immediately, then refreshes every 30 seconds while the active Pi model uses `openai-codex`
18
- - Statusline output stays compact, with the `codex` label accented and the quota bar plus weekly reset countdown drawn on a themed background
19
- - When `pi-telegram` is available, the same compact value appears as `codex: <value>` in the `/start` menu status text for active OpenAI Codex subscription models
20
- - Additional returned buckets, including Spark-specific limits, are ignored
18
+ - Statusline output stays compact, with the active usage label accented and the quota bar plus weekly reset countdown drawn on a themed background
19
+ - Regular Codex subscription models show the primary `codex` quota bucket
20
+ - `GPT-5.3-Codex-Spark` shows the parallel Spark quota bucket with the `spark` label
21
+ - When `pi-telegram` is available, the same compact value appears as `codex: <value>` or `spark: <value>` in the `/start` menu status text for active OpenAI Codex subscription models
22
+ - Additional returned buckets unrelated to the active Codex/Spark model are ignored
21
23
  - Pi OpenAI Codex provider auth is used first
22
24
  - Codex CLI app-server remains available as a fallback
23
25
  - Missing auth, subscription, plan, or quota windows are shown as `n/a`, not as an error
@@ -41,12 +43,18 @@ pi install git:github.com/llblab/pi-codex-usage
41
43
 
42
44
  ## Statusline
43
45
 
44
- Normal usage:
46
+ Regular Codex usage:
45
47
 
46
48
  ```text
47
49
  codex ██████▀▀▀▀ 6d
48
50
  ```
49
51
 
52
+ Spark model usage:
53
+
54
+ ```text
55
+ spark ██████████ 7d
56
+ ```
57
+
50
58
  The ten-character bar encodes two twenty-step limits at once: 40 total bits of quota state in 10 terminal cells. Each step is 5%: the top quadrants are the 5-hour limit, and the bottom quadrants are the weekly limit. If either quota window is exhausted, the bar keeps the same shape but switches to the error background color.
51
59
 
52
60
  When the weekly reset time is available, the bar is followed by a countdown. More than a day remains is shown in 144-minute day-tenth steps such as `7d`, `6.9d`, `6.6d`, `5.1d`, `5d`, `3.7d`, `3d`, `2d`, `1.9d`, `1.5d`, and `1.1d`, rounded upward to the next tenth. At 24 hours and below it switches to upward-rounded 6-minute hour-tenth steps such as `24h`, `23.7h`, `20.1h`, `20h`, `19.9h`, `1.4h`, `1.3h`, `1.2h`, `1.1h`, and `1h`. Under an hour it switches to floored minutes, and under a minute to seconds. After the reset timestamp passes, `0s` is held until the next successful quota refresh reports the new weekly window.
@@ -77,9 +85,10 @@ If `@llblab/pi-telegram` is loaded with the public status-line provider API, thi
77
85
 
78
86
  ```text
79
87
  codex: ██████▀▀▀▀ 6d
88
+ spark: ██████████ 7d
80
89
  ```
81
90
 
82
- The value is the same compact quota bar plus weekly reset countdown used by the terminal statusline. If `pi-telegram` is absent, older, or the active model is not a Codex subscription model, no Telegram row is added.
91
+ The value is the same compact quota bar plus weekly reset countdown used by the terminal statusline, and the label follows the active Codex/Spark model. If `pi-telegram` is absent, older, or the active model is not a Codex subscription model, no Telegram row is added.
83
92
 
84
93
  ## Auth
85
94
 
package/index.ts CHANGED
@@ -20,7 +20,11 @@ const REFRESH_INTERVAL_MS = 30 * SECOND_MS;
20
20
  const REDRAW_BLINK_MS = 150;
21
21
  const STATUS_KEY = "aa-codex-usage";
22
22
  const MAX_ERROR_BODY_CHARS = 600;
23
- const STATUS_LABEL_TEXT = "codex";
23
+ const DEFAULT_STATUS_LABEL_TEXT = "codex";
24
+ const SPARK_STATUS_LABEL_TEXT = "spark";
25
+ const CODEX_USAGE_LIMIT_ID = "codex";
26
+ const SPARK_USAGE_LIMIT_ID = "spark";
27
+ const SPARK_MODEL_KEY = "gpt-5.3-codex-spark";
24
28
  const DUAL_BAR_WIDTH = 10;
25
29
  const TELEGRAM_STATUS_IMPORT_SPECIFIERS = [
26
30
  "@llblab/pi-telegram/status",
@@ -49,7 +53,7 @@ type UsageSource = "pi-auth" | "codex-app-server";
49
53
  type TimeoutHandle = ReturnType<typeof setTimeout> & { unref?: () => void };
50
54
  type PiModel = NonNullable<ExtensionContext["model"]>;
51
55
  export type CodexUsageModel = Pick<PiModel, "id" | "name" | "provider">;
52
- type CodexUsageTelegramStatusModel = Pick<PiModel, "provider">;
56
+ type CodexUsageTelegramStatusModel = Pick<PiModel, "id" | "name" | "provider">;
53
57
  type TelegramStatusLineProviderResult =
54
58
  | { label: string; value: string }
55
59
  | undefined;
@@ -99,6 +103,13 @@ export type NormalizedRateLimitWindow = {
99
103
 
100
104
  type RateLimitStatusPayload = {
101
105
  rate_limit?: unknown;
106
+ additional_rate_limits?: unknown;
107
+ };
108
+
109
+ type BackendAdditionalRateLimit = {
110
+ limit_name?: unknown;
111
+ metered_feature?: unknown;
112
+ rate_limit?: unknown;
102
113
  };
103
114
 
104
115
  type BackendRateLimitDetails = {
@@ -152,7 +163,9 @@ type PendingRpc = {
152
163
  export default function codexUsage(pi: ExtensionAPI) {
153
164
  let cache: CachedReport | undefined;
154
165
  let failedRefreshes = 0;
155
- let inFlightUsageQuery: Promise<QueryUsageResult> | undefined;
166
+ let inFlightUsageQuery:
167
+ | { limitId: string; promise: Promise<QueryUsageResult> }
168
+ | undefined;
156
169
  let statuslineBlinkTimer: TimeoutHandle | undefined;
157
170
  let statuslineClearTimer: TimeoutHandle | undefined;
158
171
  let statuslineCountdownTimer: TimeoutHandle | undefined;
@@ -167,8 +180,8 @@ export default function codexUsage(pi: ExtensionAPI) {
167
180
  ({ activeModel }) => {
168
181
  if (!isOpenAICodexModel(activeModel)) return undefined;
169
182
  if (!cache) return undefined;
170
- const value = formatCodexUsageStatusValue(cache.report);
171
- return value ? { label: "codex", value } : undefined;
183
+ const value = formatCodexUsageStatusValue(cache.report, activeModel);
184
+ return value ? { label: activeUsageLabel(activeModel), value } : undefined;
172
185
  },
173
186
  )
174
187
  .then((unregister) => {
@@ -199,8 +212,12 @@ export default function codexUsage(pi: ExtensionAPI) {
199
212
  const scheduleTemporaryStatuslineClear = (ctx: ExtensionContext) => {
200
213
  if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
201
214
  statuslineClearTimer = setTimeout(() => {
202
- ctx.ui.setStatus(STATUS_KEY, undefined);
203
- statuslineClearTimer = undefined;
215
+ try {
216
+ ctx.ui.setStatus(STATUS_KEY, undefined);
217
+ statuslineClearTimer = undefined;
218
+ } catch (error) {
219
+ handleTimerError(error);
220
+ }
204
221
  }, REFRESH_INTERVAL_MS) as TimeoutHandle;
205
222
  statuslineClearTimer.unref?.();
206
223
  };
@@ -208,7 +225,7 @@ export default function codexUsage(pi: ExtensionAPI) {
208
225
  const scheduleStatuslineRefresh = (ctx: ExtensionContext) => {
209
226
  if (statuslineRefreshTimer) clearTimeout(statuslineRefreshTimer);
210
227
  statuslineRefreshTimer = setTimeout(() => {
211
- void refreshCurrentCodexUsageStatusline(ctx, true);
228
+ void refreshCurrentCodexUsageStatusline(ctx, true).catch(handleAsyncTimerError);
212
229
  }, REFRESH_INTERVAL_MS) as TimeoutHandle;
213
230
  statuslineRefreshTimer.unref?.();
214
231
  };
@@ -219,18 +236,22 @@ export default function codexUsage(pi: ExtensionAPI) {
219
236
  model: CodexUsageModel | undefined,
220
237
  ) => {
221
238
  if (statuslineCountdownTimer) clearTimeout(statuslineCountdownTimer);
222
- const delayMs = nextResetCountdownDelayMs(report);
239
+ const delayMs = nextResetCountdownDelayMs(report, Date.now(), model);
223
240
  if (delayMs === undefined) {
224
241
  statuslineCountdownTimer = undefined;
225
242
  return;
226
243
  }
227
244
  statuslineCountdownTimer = setTimeout(() => {
228
- if (isOpenAICodexModel(ctx.model)) {
229
- ctx.ui.setStatus(
230
- STATUS_KEY,
231
- formatCodexUsageStatusline(report, ctx, model),
232
- );
233
- scheduleStatuslineCountdown(ctx, report, model);
245
+ try {
246
+ if (isOpenAICodexModel(ctx.model)) {
247
+ ctx.ui.setStatus(
248
+ STATUS_KEY,
249
+ formatCodexUsageStatusline(report, ctx, model),
250
+ );
251
+ scheduleStatuslineCountdown(ctx, report, model);
252
+ }
253
+ } catch (error) {
254
+ handleTimerError(error);
234
255
  }
235
256
  }, delayMs) as TimeoutHandle;
236
257
  statuslineCountdownTimer.unref?.();
@@ -255,9 +276,13 @@ export default function codexUsage(pi: ExtensionAPI) {
255
276
  if (options.blink) {
256
277
  ctx.ui.setStatus(STATUS_KEY, formatEmptyStatuslineBar(ctx));
257
278
  statuslineBlinkTimer = setTimeout(() => {
258
- ctx.ui.setStatus(STATUS_KEY, text);
259
- scheduleStatuslineCountdown(ctx, report, options.model);
260
- statuslineBlinkTimer = undefined;
279
+ try {
280
+ ctx.ui.setStatus(STATUS_KEY, text);
281
+ scheduleStatuslineCountdown(ctx, report, options.model);
282
+ statuslineBlinkTimer = undefined;
283
+ } catch (error) {
284
+ handleTimerError(error);
285
+ }
261
286
  }, REDRAW_BLINK_MS) as TimeoutHandle;
262
287
  statuslineBlinkTimer.unref?.();
263
288
  } else {
@@ -268,73 +293,91 @@ export default function codexUsage(pi: ExtensionAPI) {
268
293
  else scheduleTemporaryStatuslineClear(ctx);
269
294
  };
270
295
 
271
- const queryCurrentUsage = (ctx: ExtensionContext) => {
272
- if (!inFlightUsageQuery) {
273
- inFlightUsageQuery = queryUsage(ctx, {
274
- timeoutMs: DEFAULT_TIMEOUT_MS,
275
- }).finally(() => {
276
- inFlightUsageQuery = undefined;
277
- });
278
- }
279
- return inFlightUsageQuery;
296
+ const queryCurrentUsage = (
297
+ ctx: ExtensionContext,
298
+ model: CodexUsageModel | undefined,
299
+ ) => {
300
+ const limitId = activeUsageLimitId(model);
301
+ if (inFlightUsageQuery?.limitId === limitId) return inFlightUsageQuery.promise;
302
+ const promise = queryUsage(
303
+ ctx,
304
+ { timeoutMs: DEFAULT_TIMEOUT_MS },
305
+ model,
306
+ ).finally(() => {
307
+ if (inFlightUsageQuery?.promise === promise) inFlightUsageQuery = undefined;
308
+ });
309
+ inFlightUsageQuery = { limitId, promise };
310
+ return promise;
280
311
  };
281
312
 
282
313
  const refreshCurrentCodexUsageStatusline = async (
283
314
  ctx: ExtensionContext,
284
315
  force: boolean,
285
- model = ctx.model,
316
+ model?: CodexUsageModel,
286
317
  ) => {
287
- if (!isOpenAICodexModel(model)) {
288
- clearUsageStatusline(ctx);
289
- return;
290
- }
318
+ try {
319
+ const activeModel = model ?? ctx.model;
320
+ if (!isOpenAICodexModel(activeModel)) {
321
+ clearUsageStatusline(ctx);
322
+ return;
323
+ }
291
324
 
292
- if (!cache) ctx.ui.setStatus(STATUS_KEY, formatEmptyStatuslineBar(ctx));
293
- const requestId = statuslineRequestId + 1;
294
- statuslineRequestId = requestId;
295
- const cached =
296
- cache && Date.now() - cache.createdAt < REFRESH_INTERVAL_MS
297
- ? cache
298
- : undefined;
299
- if (cached && !force) {
300
- setUsageStatusline(ctx, cached.report, {
301
- autoRefresh: true,
302
- blink: false,
303
- model,
304
- });
305
- return;
306
- }
325
+ if (!cache) ctx.ui.setStatus(STATUS_KEY, formatEmptyStatuslineBar(ctx));
326
+ const requestId = statuslineRequestId + 1;
327
+ statuslineRequestId = requestId;
328
+ const cached =
329
+ cache &&
330
+ Date.now() - cache.createdAt < REFRESH_INTERVAL_MS &&
331
+ canReuseCachedReport(cache.report, activeModel)
332
+ ? cache
333
+ : undefined;
334
+ if (cached && !force) {
335
+ setUsageStatusline(ctx, cached.report, {
336
+ autoRefresh: true,
337
+ blink: false,
338
+ model: activeModel,
339
+ });
340
+ return;
341
+ }
307
342
 
308
- const result = await queryCurrentUsage(ctx);
309
- if (requestId !== statuslineRequestId) return;
310
- if (!isOpenAICodexModel(ctx.model)) {
311
- clearUsageStatusline(ctx);
312
- return;
313
- }
343
+ const result = await queryCurrentUsage(ctx, activeModel);
344
+ if (requestId !== statuslineRequestId) return;
345
+ if (!isOpenAICodexModel(ctx.model)) {
346
+ clearUsageStatusline(ctx);
347
+ return;
348
+ }
314
349
 
315
- if (!result.ok) {
316
- failedRefreshes += 1;
317
- if (!cache || failedRefreshes >= 5) {
318
- ctx.ui.setStatus(
319
- STATUS_KEY,
320
- formatStatuslineProblem(ctx, result.errors),
321
- );
350
+ if (!result.ok) {
351
+ failedRefreshes += 1;
352
+ if (!cache || failedRefreshes >= 5) {
353
+ ctx.ui.setStatus(
354
+ STATUS_KEY,
355
+ formatStatuslineProblem(ctx, result.errors, activeModel),
356
+ );
357
+ }
358
+ scheduleStatuslineRefresh(ctx);
359
+ return;
322
360
  }
323
- scheduleStatuslineRefresh(ctx);
324
- return;
325
- }
326
361
 
327
- const previousReport = cache?.report;
328
- const blink = previousReport
329
- ? formatReportBar(previousReport) !== formatReportBar(result.report)
330
- : false;
331
- failedRefreshes = 0;
332
- cache = { createdAt: Date.now(), report: result.report };
333
- setUsageStatusline(ctx, result.report, {
334
- autoRefresh: true,
335
- blink,
336
- model,
337
- });
362
+ const previousReport = cache?.report;
363
+ const blink = previousReport
364
+ ? formatReportBar(previousReport, activeModel) !==
365
+ formatReportBar(result.report, activeModel)
366
+ : false;
367
+ failedRefreshes = 0;
368
+ cache = { createdAt: Date.now(), report: result.report };
369
+ setUsageStatusline(ctx, result.report, {
370
+ autoRefresh: true,
371
+ blink,
372
+ model: activeModel,
373
+ });
374
+ } catch (error) {
375
+ if (isStaleExtensionContextError(error)) {
376
+ clearStatuslineTimers();
377
+ return;
378
+ }
379
+ throw error;
380
+ }
338
381
  };
339
382
 
340
383
  ensureTelegramStatusLineRegistered();
@@ -342,19 +385,19 @@ export default function codexUsage(pi: ExtensionAPI) {
342
385
  pi.on("session_start", (_event, ctx) => {
343
386
  ensureTelegramStatusLineRegistered();
344
387
  if (isOpenAICodexModel(ctx.model))
345
- void refreshCurrentCodexUsageStatusline(ctx, false);
388
+ void refreshCurrentCodexUsageStatusline(ctx, false).catch(handleAsyncTimerError);
346
389
  else clearUsageStatusline(ctx);
347
390
  });
348
391
 
349
392
  pi.on("session_tree", (_event, ctx) => {
350
393
  if (isOpenAICodexModel(ctx.model))
351
- void refreshCurrentCodexUsageStatusline(ctx, false);
394
+ void refreshCurrentCodexUsageStatusline(ctx, false).catch(handleAsyncTimerError);
352
395
  else clearUsageStatusline(ctx);
353
396
  });
354
397
 
355
398
  pi.on("model_select", (event, ctx) => {
356
399
  if (isOpenAICodexModel(event.model)) {
357
- void refreshCurrentCodexUsageStatusline(ctx, false, event.model);
400
+ void refreshCurrentCodexUsageStatusline(ctx, false, event.model).catch(handleAsyncTimerError);
358
401
  } else {
359
402
  clearUsageStatusline(ctx);
360
403
  }
@@ -367,12 +410,45 @@ export default function codexUsage(pi: ExtensionAPI) {
367
410
  });
368
411
  }
369
412
 
413
+ function handleAsyncTimerError(error: unknown): void {
414
+ handleTimerError(error);
415
+ }
416
+
417
+ function handleTimerError(error: unknown): void {
418
+ if (isStaleExtensionContextError(error)) return;
419
+ throw error;
420
+ }
421
+
422
+ export function isStaleExtensionContextError(error: unknown): boolean {
423
+ return error instanceof Error && error.message.includes("ctx is stale");
424
+ }
425
+
370
426
  function isOpenAICodexModel(
371
427
  model: Pick<PiModel, "provider"> | undefined,
372
428
  ): boolean {
373
429
  return model?.provider === CODEX_PROVIDER_ID;
374
430
  }
375
431
 
432
+ function isSparkCodexModel(
433
+ model: Pick<PiModel, "id" | "name" | "provider"> | undefined,
434
+ ): boolean {
435
+ if (!isOpenAICodexModel(model)) return false;
436
+ const key = `${model?.id ?? ""} ${model?.name ?? ""}`.toLowerCase();
437
+ return key.includes(SPARK_MODEL_KEY);
438
+ }
439
+
440
+ function activeUsageLimitId(
441
+ model: Pick<PiModel, "id" | "name" | "provider"> | undefined,
442
+ ): string {
443
+ return isSparkCodexModel(model) ? SPARK_USAGE_LIMIT_ID : CODEX_USAGE_LIMIT_ID;
444
+ }
445
+
446
+ function activeUsageLabel(
447
+ model: Pick<PiModel, "id" | "name" | "provider"> | undefined,
448
+ ): string {
449
+ return isSparkCodexModel(model) ? SPARK_STATUS_LABEL_TEXT : DEFAULT_STATUS_LABEL_TEXT;
450
+ }
451
+
376
452
  async function importTelegramStatusLineModule(): Promise<
377
453
  TelegramStatusLineModule | undefined
378
454
  > {
@@ -401,25 +477,29 @@ async function registerCodexUsageTelegramStatusLine(
401
477
  async function queryUsage(
402
478
  ctx: ExtensionContext,
403
479
  options: Pick<QueryUsageOptions, "timeoutMs">,
480
+ model: CodexUsageModel | undefined,
404
481
  ): Promise<QueryUsageResult> {
405
482
  const errors: UsageQueryError[] = [];
483
+ const sources = isSparkCodexModel(model)
484
+ ? (["codex-app-server", "pi-auth"] as const)
485
+ : (["pi-auth", "codex-app-server"] as const);
406
486
 
407
- try {
408
- const report = await queryViaPiAuth(ctx, options.timeoutMs);
409
- return { ok: true, report };
410
- } catch (cause) {
411
- errors.push({ source: "pi-auth", message: errorMessage(cause), cause });
412
- }
413
-
414
- try {
415
- const report = await queryViaCodexAppServer(options.timeoutMs);
416
- return { ok: true, report };
417
- } catch (cause) {
418
- errors.push({
419
- source: "codex-app-server",
420
- message: errorMessage(cause),
421
- cause,
422
- });
487
+ for (const source of sources) {
488
+ try {
489
+ const report =
490
+ source === "pi-auth"
491
+ ? await queryViaPiAuth(ctx, options.timeoutMs)
492
+ : await queryViaCodexAppServer(options.timeoutMs);
493
+ if (selectUsageSnapshot(report, activeUsageLimitId(model))) {
494
+ return { ok: true, report };
495
+ }
496
+ errors.push({
497
+ source,
498
+ message: `${source} returned no displayable ${activeUsageLabel(model)} rate-limit windows`,
499
+ });
500
+ } catch (cause) {
501
+ errors.push({ source, message: errorMessage(cause), cause });
502
+ }
423
503
  }
424
504
 
425
505
  return { ok: false, errors };
@@ -711,17 +791,42 @@ export function normalizeBackendPayload(
711
791
  _capturedAt: number,
712
792
  _source: UsageSource,
713
793
  ): CodexUsageReport {
714
- const snapshot = normalizeBackendSnapshot(
715
- "codex",
794
+ const snapshots: NormalizedRateLimitSnapshot[] = [];
795
+ const primarySnapshot = normalizeBackendSnapshot(
796
+ CODEX_USAGE_LIMIT_ID,
716
797
  payload.rate_limit,
717
798
  _capturedAt,
718
799
  );
719
- if (!snapshot) {
800
+ if (primarySnapshot) snapshots.push(primarySnapshot);
801
+
802
+ if (Array.isArray(payload.additional_rate_limits)) {
803
+ for (const item of payload.additional_rate_limits) {
804
+ const additional = assertObject(
805
+ item,
806
+ "additional rate limit",
807
+ ) as BackendAdditionalRateLimit;
808
+ const snapshot = normalizeBackendSnapshot(
809
+ backendAdditionalLimitId(additional),
810
+ additional.rate_limit,
811
+ _capturedAt,
812
+ );
813
+ if (snapshot) snapshots.push(snapshot);
814
+ }
815
+ }
816
+
817
+ if (snapshots.length === 0) {
720
818
  throw new Error(
721
819
  "Codex usage endpoint returned no displayable rate-limit windows.",
722
820
  );
723
821
  }
724
- return { snapshots: [snapshot] };
822
+ return { snapshots };
823
+ }
824
+
825
+ function backendAdditionalLimitId(limit: BackendAdditionalRateLimit): string {
826
+ const raw = `${asString(limit.limit_name) ?? ""} ${asString(limit.metered_feature) ?? ""}`;
827
+ return raw.toLowerCase().includes("spark")
828
+ ? SPARK_USAGE_LIMIT_ID
829
+ : normalizedUsageKey(raw) ?? CODEX_USAGE_LIMIT_ID;
725
830
  }
726
831
 
727
832
  function normalizeBackendSnapshot(
@@ -859,15 +964,16 @@ function mergeSnapshot(
859
964
  export function formatCodexUsageStatusline(
860
965
  report: CodexUsageReport,
861
966
  ctx: ExtensionContext,
862
- _model?: CodexUsageModel,
967
+ model?: CodexUsageModel,
863
968
  ): string {
864
- const value = formatCodexUsageStatusValue(report);
865
- if (!value) return formatStatuslineText(ctx, "n/a");
969
+ const value = formatCodexUsageStatusValue(report, model);
970
+ if (!value) return formatStatuslineText(ctx, "n/a", model);
866
971
  const [bar, countdown] = value.split(" ", 2);
867
972
  const barText = formatStatuslineBarText(
868
973
  ctx,
869
974
  bar ?? "",
870
- hasExhaustedQuotaWindow(report) ? "toolErrorBg" : "selectedBg",
975
+ hasExhaustedQuotaWindow(report, model) ? "toolErrorBg" : "selectedBg",
976
+ model,
871
977
  );
872
978
  return countdown
873
979
  ? `${barText} ${ctx.ui.theme.fg("dim", countdown)}`
@@ -882,31 +988,40 @@ export function formatCodexUsageBar(
882
988
 
883
989
  export function formatCodexUsageStatusValue(
884
990
  report: CodexUsageReport,
991
+ modelOrNow?: CodexUsageModel | number,
885
992
  now = Date.now(),
886
993
  ): string | undefined {
887
- const snapshot = selectPrimaryCodexSnapshot(report);
994
+ const model = typeof modelOrNow === "number" ? undefined : modelOrNow;
995
+ const capturedNow = typeof modelOrNow === "number" ? modelOrNow : now;
996
+ const snapshot = selectActiveUsageSnapshot(report, model);
888
997
  if (!snapshot || (!snapshot.primary && !snapshot.secondary)) return undefined;
889
998
  const bar = formatDualLimitBar(snapshot.primary, snapshot.secondary);
890
999
  if (isQuotaWindowExhausted(snapshot.primary) && snapshot.primary?.resetAt) {
891
- const primaryCountdown = formatResetCountdown(snapshot.primary.resetAt, now);
1000
+ const primaryCountdown = formatResetCountdown(
1001
+ snapshot.primary.resetAt,
1002
+ capturedNow,
1003
+ );
892
1004
  const weeklyCountdown = snapshot.secondary?.resetAt
893
- ? formatResetCountdown(snapshot.secondary.resetAt, now)
1005
+ ? formatResetCountdown(snapshot.secondary.resetAt, capturedNow)
894
1006
  : undefined;
895
1007
  return weeklyCountdown
896
1008
  ? `${bar} ${primaryCountdown}/${weeklyCountdown}`
897
1009
  : `${bar} ${primaryCountdown}`;
898
1010
  }
899
- const countdown = formatWeeklyResetCountdown(report, now);
1011
+ const countdown = formatWeeklyResetCountdown(report, model, capturedNow);
900
1012
  return countdown ? `${bar} ${countdown}` : bar;
901
1013
  }
902
1014
 
903
1015
  export function formatWeeklyResetCountdown(
904
1016
  report: CodexUsageReport,
1017
+ modelOrNow?: CodexUsageModel | number,
905
1018
  now = Date.now(),
906
1019
  ): string | undefined {
907
- const resetAt = selectPrimaryCodexSnapshot(report)?.secondary?.resetAt;
1020
+ const model = typeof modelOrNow === "number" ? undefined : modelOrNow;
1021
+ const capturedNow = typeof modelOrNow === "number" ? modelOrNow : now;
1022
+ const resetAt = selectActiveUsageSnapshot(report, model)?.secondary?.resetAt;
908
1023
  if (resetAt === undefined) return undefined;
909
- return formatResetCountdown(resetAt, now);
1024
+ return formatResetCountdown(resetAt, capturedNow);
910
1025
  }
911
1026
 
912
1027
  export function formatResetCountdown(
@@ -930,8 +1045,9 @@ export function formatResetCountdown(
930
1045
  export function nextResetCountdownDelayMs(
931
1046
  report: CodexUsageReport,
932
1047
  now = Date.now(),
1048
+ model?: CodexUsageModel,
933
1049
  ): number | undefined {
934
- const snapshot = selectPrimaryCodexSnapshot(report);
1050
+ const snapshot = selectActiveUsageSnapshot(report, model);
935
1051
  const resetTimes = [snapshot?.secondary?.resetAt];
936
1052
  if (isQuotaWindowExhausted(snapshot?.primary)) {
937
1053
  resetTimes.push(snapshot?.primary?.resetAt);
@@ -972,14 +1088,21 @@ function formatTenths(value: number): string {
972
1088
  return value % 10 === 0 ? String(value / 10) : (value / 10).toFixed(1);
973
1089
  }
974
1090
 
975
- function formatReportBar(report: CodexUsageReport): string | undefined {
976
- const snapshot = selectPrimaryCodexSnapshot(report);
1091
+ function formatReportBar(
1092
+ report: CodexUsageReport,
1093
+ model?: CodexUsageModel,
1094
+ ): string | undefined {
1095
+ const snapshot = selectActiveUsageSnapshot(report, model);
977
1096
  if (!snapshot || (!snapshot.primary && !snapshot.secondary)) return undefined;
978
1097
  return formatDualLimitBar(snapshot.primary, snapshot.secondary);
979
1098
  }
980
1099
 
981
- function formatStatuslineText(ctx: ExtensionContext, value: string): string {
982
- const label = ctx.ui.theme.fg("accent", STATUS_LABEL_TEXT);
1100
+ function formatStatuslineText(
1101
+ ctx: ExtensionContext,
1102
+ value: string,
1103
+ model?: CodexUsageModel,
1104
+ ): string {
1105
+ const label = ctx.ui.theme.fg("accent", activeUsageLabel(model));
983
1106
  return `${label} ${ctx.ui.theme.fg("dim", value)}`;
984
1107
  }
985
1108
 
@@ -987,8 +1110,9 @@ function formatStatuslineBarText(
987
1110
  ctx: ExtensionContext,
988
1111
  bar: string,
989
1112
  background: "selectedBg" | "toolErrorBg" = "selectedBg",
1113
+ model?: CodexUsageModel,
990
1114
  ): string {
991
- const label = ctx.ui.theme.fg("accent", STATUS_LABEL_TEXT);
1115
+ const label = ctx.ui.theme.fg("accent", activeUsageLabel(model));
992
1116
  const value = ctx.ui.theme.bg(background, ctx.ui.theme.fg("dim", bar));
993
1117
  return `${label} ${value}`;
994
1118
  }
@@ -1000,8 +1124,9 @@ function formatEmptyStatuslineBar(ctx: ExtensionContext): string {
1000
1124
  function formatStatuslineProblem(
1001
1125
  ctx: ExtensionContext,
1002
1126
  errors: UsageQueryError[],
1127
+ model?: CodexUsageModel,
1003
1128
  ): string {
1004
- const label = ctx.ui.theme.fg("accent", STATUS_LABEL_TEXT);
1129
+ const label = ctx.ui.theme.fg("accent", activeUsageLabel(model));
1005
1130
  const value = isUsageUnavailable(errors)
1006
1131
  ? ctx.ui.theme.fg("muted", "n/a")
1007
1132
  : ctx.ui.theme.fg("error", "error");
@@ -1030,12 +1155,6 @@ function isUnavailableError(error: UsageQueryError): boolean {
1030
1155
  );
1031
1156
  }
1032
1157
 
1033
- function selectPrimaryCodexSnapshot(
1034
- report: CodexUsageReport,
1035
- ): NormalizedRateLimitSnapshot | undefined {
1036
- return report.snapshots.find(isPrimaryCodexSnapshot);
1037
- }
1038
-
1039
1158
  function normalizedUsageKey(value: string | undefined): string | undefined {
1040
1159
  const key = value
1041
1160
  ?.toLowerCase()
@@ -1090,18 +1209,39 @@ function isQuotaWindowExhausted(
1090
1209
  return window !== undefined && remainingPercent(window) <= 0;
1091
1210
  }
1092
1211
 
1093
- function hasExhaustedQuotaWindow(report: CodexUsageReport): boolean {
1094
- const snapshot = selectPrimaryCodexSnapshot(report);
1212
+ function hasExhaustedQuotaWindow(
1213
+ report: CodexUsageReport,
1214
+ model?: CodexUsageModel,
1215
+ ): boolean {
1216
+ const snapshot = selectActiveUsageSnapshot(report, model);
1095
1217
  return (
1096
1218
  isQuotaWindowExhausted(snapshot?.primary) ||
1097
1219
  isQuotaWindowExhausted(snapshot?.secondary)
1098
1220
  );
1099
1221
  }
1100
1222
 
1101
- function isPrimaryCodexSnapshot(
1102
- snapshot: NormalizedRateLimitSnapshot,
1223
+ export function canReuseCachedReport(
1224
+ report: CodexUsageReport,
1225
+ model: CodexUsageModel | undefined,
1103
1226
  ): boolean {
1104
- return normalizedUsageKey(snapshot.limitId) === "codex";
1227
+ return selectActiveUsageSnapshot(report, model) !== undefined;
1228
+ }
1229
+
1230
+ function selectActiveUsageSnapshot(
1231
+ report: CodexUsageReport,
1232
+ model: CodexUsageModel | undefined,
1233
+ ): NormalizedRateLimitSnapshot | undefined {
1234
+ return selectUsageSnapshot(report, activeUsageLimitId(model));
1235
+ }
1236
+
1237
+ function selectUsageSnapshot(
1238
+ report: CodexUsageReport,
1239
+ limitId: string,
1240
+ ): NormalizedRateLimitSnapshot | undefined {
1241
+ const normalizedLimitId = normalizedUsageKey(limitId);
1242
+ return report.snapshots.find(
1243
+ (snapshot) => normalizedUsageKey(snapshot.limitId) === normalizedLimitId,
1244
+ );
1105
1245
  }
1106
1246
 
1107
1247
  function clampPercent(value: number): number {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-codex-usage",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
4
4
  "private": false,
5
5
  "description": "Minimal Pi extension that shows primary Codex ChatGPT subscription usage limits.",
6
6
  "keywords": [