@llblab/pi-codex-usage 0.7.0 → 0.8.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/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.8.0: Spark Model Usage Status
6
+
7
+ - 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.
8
+ - 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.
9
+
5
10
  ## 0.7.0: Exhausted Quota Visibility
6
11
 
7
12
  - 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 = {
@@ -167,8 +178,8 @@ export default function codexUsage(pi: ExtensionAPI) {
167
178
  ({ activeModel }) => {
168
179
  if (!isOpenAICodexModel(activeModel)) return undefined;
169
180
  if (!cache) return undefined;
170
- const value = formatCodexUsageStatusValue(cache.report);
171
- return value ? { label: "codex", value } : undefined;
181
+ const value = formatCodexUsageStatusValue(cache.report, activeModel);
182
+ return value ? { label: activeUsageLabel(activeModel), value } : undefined;
172
183
  },
173
184
  )
174
185
  .then((unregister) => {
@@ -219,7 +230,7 @@ export default function codexUsage(pi: ExtensionAPI) {
219
230
  model: CodexUsageModel | undefined,
220
231
  ) => {
221
232
  if (statuslineCountdownTimer) clearTimeout(statuslineCountdownTimer);
222
- const delayMs = nextResetCountdownDelayMs(report);
233
+ const delayMs = nextResetCountdownDelayMs(report, Date.now(), model);
223
234
  if (delayMs === undefined) {
224
235
  statuslineCountdownTimer = undefined;
225
236
  return;
@@ -268,11 +279,16 @@ export default function codexUsage(pi: ExtensionAPI) {
268
279
  else scheduleTemporaryStatuslineClear(ctx);
269
280
  };
270
281
 
271
- const queryCurrentUsage = (ctx: ExtensionContext) => {
282
+ const queryCurrentUsage = (
283
+ ctx: ExtensionContext,
284
+ model: CodexUsageModel | undefined,
285
+ ) => {
272
286
  if (!inFlightUsageQuery) {
273
- inFlightUsageQuery = queryUsage(ctx, {
274
- timeoutMs: DEFAULT_TIMEOUT_MS,
275
- }).finally(() => {
287
+ inFlightUsageQuery = queryUsage(
288
+ ctx,
289
+ { timeoutMs: DEFAULT_TIMEOUT_MS },
290
+ model,
291
+ ).finally(() => {
276
292
  inFlightUsageQuery = undefined;
277
293
  });
278
294
  }
@@ -305,7 +321,7 @@ export default function codexUsage(pi: ExtensionAPI) {
305
321
  return;
306
322
  }
307
323
 
308
- const result = await queryCurrentUsage(ctx);
324
+ const result = await queryCurrentUsage(ctx, model);
309
325
  if (requestId !== statuslineRequestId) return;
310
326
  if (!isOpenAICodexModel(ctx.model)) {
311
327
  clearUsageStatusline(ctx);
@@ -373,6 +389,26 @@ function isOpenAICodexModel(
373
389
  return model?.provider === CODEX_PROVIDER_ID;
374
390
  }
375
391
 
392
+ function isSparkCodexModel(
393
+ model: Pick<PiModel, "id" | "name" | "provider"> | undefined,
394
+ ): boolean {
395
+ if (!isOpenAICodexModel(model)) return false;
396
+ const key = `${model?.id ?? ""} ${model?.name ?? ""}`.toLowerCase();
397
+ return key.includes(SPARK_MODEL_KEY);
398
+ }
399
+
400
+ function activeUsageLimitId(
401
+ model: Pick<PiModel, "id" | "name" | "provider"> | undefined,
402
+ ): string {
403
+ return isSparkCodexModel(model) ? SPARK_USAGE_LIMIT_ID : CODEX_USAGE_LIMIT_ID;
404
+ }
405
+
406
+ function activeUsageLabel(
407
+ model: Pick<PiModel, "id" | "name" | "provider"> | undefined,
408
+ ): string {
409
+ return isSparkCodexModel(model) ? SPARK_STATUS_LABEL_TEXT : DEFAULT_STATUS_LABEL_TEXT;
410
+ }
411
+
376
412
  async function importTelegramStatusLineModule(): Promise<
377
413
  TelegramStatusLineModule | undefined
378
414
  > {
@@ -401,25 +437,29 @@ async function registerCodexUsageTelegramStatusLine(
401
437
  async function queryUsage(
402
438
  ctx: ExtensionContext,
403
439
  options: Pick<QueryUsageOptions, "timeoutMs">,
440
+ model: CodexUsageModel | undefined,
404
441
  ): Promise<QueryUsageResult> {
405
442
  const errors: UsageQueryError[] = [];
443
+ const sources = isSparkCodexModel(model)
444
+ ? (["codex-app-server", "pi-auth"] as const)
445
+ : (["pi-auth", "codex-app-server"] as const);
406
446
 
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
- });
447
+ for (const source of sources) {
448
+ try {
449
+ const report =
450
+ source === "pi-auth"
451
+ ? await queryViaPiAuth(ctx, options.timeoutMs)
452
+ : await queryViaCodexAppServer(options.timeoutMs);
453
+ if (selectUsageSnapshot(report, activeUsageLimitId(model))) {
454
+ return { ok: true, report };
455
+ }
456
+ errors.push({
457
+ source,
458
+ message: `${source} returned no displayable ${activeUsageLabel(model)} rate-limit windows`,
459
+ });
460
+ } catch (cause) {
461
+ errors.push({ source, message: errorMessage(cause), cause });
462
+ }
423
463
  }
424
464
 
425
465
  return { ok: false, errors };
@@ -711,17 +751,42 @@ export function normalizeBackendPayload(
711
751
  _capturedAt: number,
712
752
  _source: UsageSource,
713
753
  ): CodexUsageReport {
714
- const snapshot = normalizeBackendSnapshot(
715
- "codex",
754
+ const snapshots: NormalizedRateLimitSnapshot[] = [];
755
+ const primarySnapshot = normalizeBackendSnapshot(
756
+ CODEX_USAGE_LIMIT_ID,
716
757
  payload.rate_limit,
717
758
  _capturedAt,
718
759
  );
719
- if (!snapshot) {
760
+ if (primarySnapshot) snapshots.push(primarySnapshot);
761
+
762
+ if (Array.isArray(payload.additional_rate_limits)) {
763
+ for (const item of payload.additional_rate_limits) {
764
+ const additional = assertObject(
765
+ item,
766
+ "additional rate limit",
767
+ ) as BackendAdditionalRateLimit;
768
+ const snapshot = normalizeBackendSnapshot(
769
+ backendAdditionalLimitId(additional),
770
+ additional.rate_limit,
771
+ _capturedAt,
772
+ );
773
+ if (snapshot) snapshots.push(snapshot);
774
+ }
775
+ }
776
+
777
+ if (snapshots.length === 0) {
720
778
  throw new Error(
721
779
  "Codex usage endpoint returned no displayable rate-limit windows.",
722
780
  );
723
781
  }
724
- return { snapshots: [snapshot] };
782
+ return { snapshots };
783
+ }
784
+
785
+ function backendAdditionalLimitId(limit: BackendAdditionalRateLimit): string {
786
+ const raw = `${asString(limit.limit_name) ?? ""} ${asString(limit.metered_feature) ?? ""}`;
787
+ return raw.toLowerCase().includes("spark")
788
+ ? SPARK_USAGE_LIMIT_ID
789
+ : normalizedUsageKey(raw) ?? CODEX_USAGE_LIMIT_ID;
725
790
  }
726
791
 
727
792
  function normalizeBackendSnapshot(
@@ -859,15 +924,16 @@ function mergeSnapshot(
859
924
  export function formatCodexUsageStatusline(
860
925
  report: CodexUsageReport,
861
926
  ctx: ExtensionContext,
862
- _model?: CodexUsageModel,
927
+ model?: CodexUsageModel,
863
928
  ): string {
864
- const value = formatCodexUsageStatusValue(report);
865
- if (!value) return formatStatuslineText(ctx, "n/a");
929
+ const value = formatCodexUsageStatusValue(report, model);
930
+ if (!value) return formatStatuslineText(ctx, "n/a", model);
866
931
  const [bar, countdown] = value.split(" ", 2);
867
932
  const barText = formatStatuslineBarText(
868
933
  ctx,
869
934
  bar ?? "",
870
- hasExhaustedQuotaWindow(report) ? "toolErrorBg" : "selectedBg",
935
+ hasExhaustedQuotaWindow(report, model) ? "toolErrorBg" : "selectedBg",
936
+ model,
871
937
  );
872
938
  return countdown
873
939
  ? `${barText} ${ctx.ui.theme.fg("dim", countdown)}`
@@ -882,31 +948,40 @@ export function formatCodexUsageBar(
882
948
 
883
949
  export function formatCodexUsageStatusValue(
884
950
  report: CodexUsageReport,
951
+ modelOrNow?: CodexUsageModel | number,
885
952
  now = Date.now(),
886
953
  ): string | undefined {
887
- const snapshot = selectPrimaryCodexSnapshot(report);
954
+ const model = typeof modelOrNow === "number" ? undefined : modelOrNow;
955
+ const capturedNow = typeof modelOrNow === "number" ? modelOrNow : now;
956
+ const snapshot = selectActiveUsageSnapshot(report, model);
888
957
  if (!snapshot || (!snapshot.primary && !snapshot.secondary)) return undefined;
889
958
  const bar = formatDualLimitBar(snapshot.primary, snapshot.secondary);
890
959
  if (isQuotaWindowExhausted(snapshot.primary) && snapshot.primary?.resetAt) {
891
- const primaryCountdown = formatResetCountdown(snapshot.primary.resetAt, now);
960
+ const primaryCountdown = formatResetCountdown(
961
+ snapshot.primary.resetAt,
962
+ capturedNow,
963
+ );
892
964
  const weeklyCountdown = snapshot.secondary?.resetAt
893
- ? formatResetCountdown(snapshot.secondary.resetAt, now)
965
+ ? formatResetCountdown(snapshot.secondary.resetAt, capturedNow)
894
966
  : undefined;
895
967
  return weeklyCountdown
896
968
  ? `${bar} ${primaryCountdown}/${weeklyCountdown}`
897
969
  : `${bar} ${primaryCountdown}`;
898
970
  }
899
- const countdown = formatWeeklyResetCountdown(report, now);
971
+ const countdown = formatWeeklyResetCountdown(report, model, capturedNow);
900
972
  return countdown ? `${bar} ${countdown}` : bar;
901
973
  }
902
974
 
903
975
  export function formatWeeklyResetCountdown(
904
976
  report: CodexUsageReport,
977
+ modelOrNow?: CodexUsageModel | number,
905
978
  now = Date.now(),
906
979
  ): string | undefined {
907
- const resetAt = selectPrimaryCodexSnapshot(report)?.secondary?.resetAt;
980
+ const model = typeof modelOrNow === "number" ? undefined : modelOrNow;
981
+ const capturedNow = typeof modelOrNow === "number" ? modelOrNow : now;
982
+ const resetAt = selectActiveUsageSnapshot(report, model)?.secondary?.resetAt;
908
983
  if (resetAt === undefined) return undefined;
909
- return formatResetCountdown(resetAt, now);
984
+ return formatResetCountdown(resetAt, capturedNow);
910
985
  }
911
986
 
912
987
  export function formatResetCountdown(
@@ -930,8 +1005,9 @@ export function formatResetCountdown(
930
1005
  export function nextResetCountdownDelayMs(
931
1006
  report: CodexUsageReport,
932
1007
  now = Date.now(),
1008
+ model?: CodexUsageModel,
933
1009
  ): number | undefined {
934
- const snapshot = selectPrimaryCodexSnapshot(report);
1010
+ const snapshot = selectActiveUsageSnapshot(report, model);
935
1011
  const resetTimes = [snapshot?.secondary?.resetAt];
936
1012
  if (isQuotaWindowExhausted(snapshot?.primary)) {
937
1013
  resetTimes.push(snapshot?.primary?.resetAt);
@@ -972,14 +1048,21 @@ function formatTenths(value: number): string {
972
1048
  return value % 10 === 0 ? String(value / 10) : (value / 10).toFixed(1);
973
1049
  }
974
1050
 
975
- function formatReportBar(report: CodexUsageReport): string | undefined {
976
- const snapshot = selectPrimaryCodexSnapshot(report);
1051
+ function formatReportBar(
1052
+ report: CodexUsageReport,
1053
+ model?: CodexUsageModel,
1054
+ ): string | undefined {
1055
+ const snapshot = selectActiveUsageSnapshot(report, model);
977
1056
  if (!snapshot || (!snapshot.primary && !snapshot.secondary)) return undefined;
978
1057
  return formatDualLimitBar(snapshot.primary, snapshot.secondary);
979
1058
  }
980
1059
 
981
- function formatStatuslineText(ctx: ExtensionContext, value: string): string {
982
- const label = ctx.ui.theme.fg("accent", STATUS_LABEL_TEXT);
1060
+ function formatStatuslineText(
1061
+ ctx: ExtensionContext,
1062
+ value: string,
1063
+ model?: CodexUsageModel,
1064
+ ): string {
1065
+ const label = ctx.ui.theme.fg("accent", activeUsageLabel(model));
983
1066
  return `${label} ${ctx.ui.theme.fg("dim", value)}`;
984
1067
  }
985
1068
 
@@ -987,8 +1070,9 @@ function formatStatuslineBarText(
987
1070
  ctx: ExtensionContext,
988
1071
  bar: string,
989
1072
  background: "selectedBg" | "toolErrorBg" = "selectedBg",
1073
+ model?: CodexUsageModel,
990
1074
  ): string {
991
- const label = ctx.ui.theme.fg("accent", STATUS_LABEL_TEXT);
1075
+ const label = ctx.ui.theme.fg("accent", activeUsageLabel(model));
992
1076
  const value = ctx.ui.theme.bg(background, ctx.ui.theme.fg("dim", bar));
993
1077
  return `${label} ${value}`;
994
1078
  }
@@ -1001,7 +1085,7 @@ function formatStatuslineProblem(
1001
1085
  ctx: ExtensionContext,
1002
1086
  errors: UsageQueryError[],
1003
1087
  ): string {
1004
- const label = ctx.ui.theme.fg("accent", STATUS_LABEL_TEXT);
1088
+ const label = ctx.ui.theme.fg("accent", DEFAULT_STATUS_LABEL_TEXT);
1005
1089
  const value = isUsageUnavailable(errors)
1006
1090
  ? ctx.ui.theme.fg("muted", "n/a")
1007
1091
  : ctx.ui.theme.fg("error", "error");
@@ -1030,12 +1114,6 @@ function isUnavailableError(error: UsageQueryError): boolean {
1030
1114
  );
1031
1115
  }
1032
1116
 
1033
- function selectPrimaryCodexSnapshot(
1034
- report: CodexUsageReport,
1035
- ): NormalizedRateLimitSnapshot | undefined {
1036
- return report.snapshots.find(isPrimaryCodexSnapshot);
1037
- }
1038
-
1039
1117
  function normalizedUsageKey(value: string | undefined): string | undefined {
1040
1118
  const key = value
1041
1119
  ?.toLowerCase()
@@ -1090,18 +1168,32 @@ function isQuotaWindowExhausted(
1090
1168
  return window !== undefined && remainingPercent(window) <= 0;
1091
1169
  }
1092
1170
 
1093
- function hasExhaustedQuotaWindow(report: CodexUsageReport): boolean {
1094
- const snapshot = selectPrimaryCodexSnapshot(report);
1171
+ function hasExhaustedQuotaWindow(
1172
+ report: CodexUsageReport,
1173
+ model?: CodexUsageModel,
1174
+ ): boolean {
1175
+ const snapshot = selectActiveUsageSnapshot(report, model);
1095
1176
  return (
1096
1177
  isQuotaWindowExhausted(snapshot?.primary) ||
1097
1178
  isQuotaWindowExhausted(snapshot?.secondary)
1098
1179
  );
1099
1180
  }
1100
1181
 
1101
- function isPrimaryCodexSnapshot(
1102
- snapshot: NormalizedRateLimitSnapshot,
1103
- ): boolean {
1104
- return normalizedUsageKey(snapshot.limitId) === "codex";
1182
+ function selectActiveUsageSnapshot(
1183
+ report: CodexUsageReport,
1184
+ model: CodexUsageModel | undefined,
1185
+ ): NormalizedRateLimitSnapshot | undefined {
1186
+ return selectUsageSnapshot(report, activeUsageLimitId(model));
1187
+ }
1188
+
1189
+ function selectUsageSnapshot(
1190
+ report: CodexUsageReport,
1191
+ limitId: string,
1192
+ ): NormalizedRateLimitSnapshot | undefined {
1193
+ const normalizedLimitId = normalizedUsageKey(limitId);
1194
+ return report.snapshots.find(
1195
+ (snapshot) => normalizedUsageKey(snapshot.limitId) === normalizedLimitId,
1196
+ );
1105
1197
  }
1106
1198
 
1107
1199
  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.0",
4
4
  "private": false,
5
5
  "description": "Minimal Pi extension that shows primary Codex ChatGPT subscription usage limits.",
6
6
  "keywords": [