@narumitw/pi-codex-usage 0.14.0 → 0.15.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/README.md CHANGED
@@ -9,8 +9,10 @@ Use it when you want a quick Codex-style usage summary without leaving Pi or req
9
9
  ## ✨ Features
10
10
 
11
11
  - Adds a `/codex-status` command to Pi.
12
- - Shows Codex plan, 5-hour and weekly usage windows, reset times, and credits.
12
+ - Shows Codex usage windows, reset times, credits, and earned usage-limit resets.
13
+ - Labels each window from its reported duration (for example, 5-hour or weekly).
13
14
  - Displays additional usage buckets when the Codex backend returns them.
15
+ - Reads the authoritative available reset count from the current Codex usage contract.
14
16
  - Automatically shows a compact statusline item while the current Pi model uses `openai-codex`.
15
17
  - Uses Pi's own OpenAI Codex subscription auth first.
16
18
  - Falls back to `codex app-server --listen stdio://` only when Pi auth is unavailable.
@@ -59,6 +61,8 @@ information on rate limits and credits
59
61
  GPT-5.3-Codex-Spark limit:
60
62
  5h limit: [████████████████████] 100% left (resets 19:16)
61
63
  Weekly limit: [████████████████████] 100% left (resets 00:10 on 21 May)
64
+
65
+ Usage limit resets: 2 available
62
66
  ```
63
67
 
64
68
  ## 📊 Statusline behavior
@@ -70,7 +74,7 @@ codex 59% 5h 61% wk
70
74
  codex spark 100% 5h 100% wk
71
75
  ```
72
76
 
73
- `@narumitw/pi-statusline` adds the default `📊` icon unless configured otherwise. The statusline value uses the cached usage snapshot and refreshes every five minutes while the current model remains `openai-codex`.
77
+ `@narumitw/pi-statusline` adds the default `📊` icon unless configured otherwise. The statusline value uses the cached usage snapshot and refreshes every five minutes while the current model remains `openai-codex`. Window labels come from the duration reported by Codex, with 5-hour/weekly fallbacks for older responses that omit it.
74
78
  When the selected model has its own returned usage bucket, such as `gpt-5.3-codex-spark`, the statusline switches to that bucket instead of the default `codex` bucket.
75
79
  Switching away from an OpenAI Codex model clears the item.
76
80
 
@@ -85,6 +89,8 @@ Use `/codex-status --no-statusline` for a one-off notification without updating
85
89
 
86
90
  This means Codex CLI is optional. Users who already use a Pi OpenAI Codex model or have logged in to Pi with ChatGPT Plus/Pro subscription auth can use the direct Pi-auth path.
87
91
 
92
+ The direct `/wham/usage` response can include the snake_case `rate_limit_reset_credits` summary. Current Codex app-server responses expose the same authoritative count as camelCase `rateLimitResetCredits` and may also include capped detail rows. The extension accepts both forms and keeps the compact statusline focused on rate-limit windows.
93
+
88
94
  The extension does not read Pi or Codex auth files directly, and it does not expose bearer tokens in error messages.
89
95
 
90
96
  ## 🚧 Limitations
@@ -92,6 +98,7 @@ The extension does not read Pi or Codex auth files directly, and it does not exp
92
98
  - OpenAI API keys are not ChatGPT Codex subscription auth and do not expose this quota.
93
99
  - Usage data is a snapshot. Statusline and command results are cached for five minutes unless `--refresh` is used.
94
100
  - The fallback path requires Codex CLI to be installed and logged in.
101
+ - `/codex-status` reports earned usage-limit resets but does not redeem them.
95
102
 
96
103
  ## 🗂️ Package layout
97
104
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-codex-usage",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Pi extension that shows Codex ChatGPT subscription usage without requiring Codex CLI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,19 +1,6 @@
1
- import type {
2
- ExtensionAPI,
3
- ExtensionCommandContext,
4
- ExtensionContext,
5
- } from "@earendil-works/pi-coding-agent";
6
- import {
7
- formatCodexUsageReport,
8
- formatCodexUsageStatusline,
9
- formatQueryErrors,
10
- showReport,
11
- } from "./format.js";
12
- import {
13
- isOpenAICodexModel,
14
- isStaleExtensionContextError,
15
- queryUsage,
16
- } from "./query.js";
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { formatCodexUsageStatusline, formatQueryErrors, showReport } from "./format.js";
3
+ import { isOpenAICodexModel, isStaleExtensionContextError, queryUsage } from "./query.js";
17
4
  import type {
18
5
  CachedReport,
19
6
  CodexUsageModel,
@@ -34,7 +21,11 @@ interface CommandArgumentCompletion {
34
21
 
35
22
  const COMMAND_COMPLETIONS: readonly CommandArgumentCompletion[] = [
36
23
  { value: "--refresh", label: "--refresh", description: "Refresh usage instead of cached data" },
37
- { value: "--no-statusline", label: "--no-statusline", description: "Do not update the statusline" },
24
+ {
25
+ value: "--no-statusline",
26
+ label: "--no-statusline",
27
+ description: "Do not update the statusline",
28
+ },
38
29
  {
39
30
  value: "--clear-statusline",
40
31
  label: "--clear-statusline",
@@ -192,8 +183,7 @@ export default function codexUsage(pi: ExtensionAPI) {
192
183
  }
193
184
 
194
185
  let keepStatusline = false;
195
- const statuslineStarted =
196
- options.value.statusline && setStatuslineValue(ctx, "checking");
186
+ const statuslineStarted = options.value.statusline && setStatuslineValue(ctx, "checking");
197
187
  try {
198
188
  const result = await queryUsage(ctx, options.value);
199
189
  if (!result.ok) {
@@ -333,6 +323,8 @@ export type {
333
323
  CodexUsageModel,
334
324
  CodexUsageReport,
335
325
  NormalizedCredits,
326
+ NormalizedRateLimitResetCredit,
327
+ NormalizedRateLimitResetCredits,
336
328
  NormalizedRateLimitSnapshot,
337
329
  NormalizedRateLimitWindow,
338
330
  } from "./types.js";
package/src/format.ts CHANGED
@@ -31,13 +31,20 @@ export function formatCodexUsageReport(report: CodexUsageReport, _cacheAgeMs?: n
31
31
  if (!isPrimaryCodexSnapshot(snapshot)) {
32
32
  lines.push(` ${label} limit:`);
33
33
  }
34
- if (snapshot.primary) lines.push(formatWindowLine("5h limit:", snapshot.primary));
35
- if (snapshot.secondary) lines.push(formatWindowLine("Weekly limit:", snapshot.secondary));
34
+ if (snapshot.primary) lines.push(formatWindowLine(snapshot.primary, "5h"));
35
+ if (snapshot.secondary) lines.push(formatWindowLine(snapshot.secondary, "weekly"));
36
36
  if (!snapshot.primary && !snapshot.secondary) {
37
37
  lines.push(" Limits unavailable for this account");
38
38
  }
39
39
  }
40
40
 
41
+ if (report.resetCredits) {
42
+ if (report.snapshots.length > 0) lines.push("");
43
+ lines.push(
44
+ ` ${"Usage limit resets:".padEnd(LIMIT_VALUE_COLUMN)}${report.resetCredits.availableCount} available`,
45
+ );
46
+ }
47
+
41
48
  return lines.join("\n");
42
49
  }
43
50
 
@@ -49,8 +56,16 @@ export function formatCodexUsageStatusline(
49
56
  if (!snapshot) return "usage unavailable";
50
57
 
51
58
  const parts = [formatStatuslinePrefix(snapshot)];
52
- if (snapshot.primary) parts.push(`${formatRemainingPercent(snapshot.primary)} 5h`);
53
- if (snapshot.secondary) parts.push(`${formatRemainingPercent(snapshot.secondary)} wk`);
59
+ if (snapshot.primary) {
60
+ parts.push(
61
+ `${formatRemainingPercent(snapshot.primary)} ${formatWindowLabel(snapshot.primary, "5h", true)}`,
62
+ );
63
+ }
64
+ if (snapshot.secondary) {
65
+ parts.push(
66
+ `${formatRemainingPercent(snapshot.secondary)} ${formatWindowLabel(snapshot.secondary, "weekly", true)}`,
67
+ );
68
+ }
54
69
  if (parts.length === 1 && snapshot.credits) parts.push(formatCredits(snapshot.credits));
55
70
  return parts.join(" ");
56
71
  }
@@ -171,10 +186,34 @@ function isPrimaryCodexSnapshot(snapshot: NormalizedRateLimitSnapshot): boolean
171
186
  );
172
187
  }
173
188
 
174
- function formatWindowLine(label: string, window: NormalizedRateLimitWindow): string {
189
+ function formatWindowLine(
190
+ window: NormalizedRateLimitWindow,
191
+ fallback: "5h" | "weekly",
192
+ ): string {
193
+ const label = `${formatWindowLabel(window, fallback, false)} limit:`;
175
194
  return ` ${label.padEnd(LIMIT_VALUE_COLUMN)}${formatWindow(window)}`;
176
195
  }
177
196
 
197
+ function formatWindowLabel(
198
+ window: NormalizedRateLimitWindow,
199
+ fallback: "5h" | "weekly",
200
+ compact: boolean,
201
+ ): string {
202
+ const minutes = window.windowMinutes;
203
+ if (!minutes || !Number.isFinite(minutes) || minutes <= 0) {
204
+ return compact && fallback === "weekly" ? "wk" : capitalize(fallback);
205
+ }
206
+ if (minutes === 10_080) return compact ? "wk" : "Weekly";
207
+ if (minutes % 10_080 === 0) return `${minutes / 10_080}w`;
208
+ if (minutes % 1_440 === 0) return `${minutes / 1_440}d`;
209
+ if (minutes % 60 === 0) return `${minutes / 60}h`;
210
+ return `${minutes}m`;
211
+ }
212
+
213
+ function capitalize(value: string): string {
214
+ return `${value[0]?.toUpperCase() ?? ""}${value.slice(1)}`;
215
+ }
216
+
178
217
  function formatWindow(window: NormalizedRateLimitWindow): string {
179
218
  const remaining = 100 - clampPercent(window.usedPercent);
180
219
  const reset = window.resetsAt ? ` (resets ${formatReset(window.resetsAt)})` : "";
@@ -222,37 +261,6 @@ export function formatQueryErrors(errors: UsageQueryError[]): string {
222
261
  return lines.join("\n");
223
262
  }
224
263
 
225
- function formatPlanType(planType: string): string {
226
- const key = planType
227
- .replace(/([a-z])([A-Z])/g, "$1_$2")
228
- .toLowerCase()
229
- .replace(/[^a-z0-9]+/g, "_");
230
- if (key === "pro_lite" || key === "prolite") return "Pro Lite";
231
- if (key === "team" || key === "self_serve_business_usage_based" || key === "business") {
232
- return "Business";
233
- }
234
- if (key === "enterprise_cbp_usage_based") return "Enterprise";
235
-
236
- const normalized = planType
237
- .replace(/([a-z])([A-Z])/g, "$1 $2")
238
- .replace(/[_-]+/g, " ")
239
- .trim();
240
- if (!normalized) return planType;
241
- return normalized
242
- .split(/\s+/)
243
- .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
244
- .join(" ");
245
- }
246
-
247
- function formatDuration(milliseconds: number): string {
248
- const seconds = Math.max(0, Math.round(milliseconds / 1000));
249
- if (seconds < 60) return `${seconds}s`;
250
- const minutes = Math.round(seconds / 60);
251
- if (minutes < 60) return `${minutes}m`;
252
- const hours = Math.round(minutes / 60);
253
- return `${hours}h`;
254
- }
255
-
256
264
  function formatNumber(value: number, fallback: string): string {
257
265
  if (!Number.isFinite(value)) return fallback;
258
266
  return new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value);
package/src/normalize.ts CHANGED
@@ -1,14 +1,19 @@
1
1
  import type {
2
2
  AppServerCreditsSnapshot,
3
+ AppServerRateLimitResetCredit,
4
+ AppServerRateLimitResetCredits,
3
5
  AppServerRateLimitResponse,
4
6
  AppServerRateLimitSnapshot,
5
7
  AppServerWindowSnapshot,
6
8
  BackendAdditionalRateLimit,
7
9
  BackendCreditsSnapshot,
8
10
  BackendRateLimitDetails,
11
+ BackendRateLimitResetCredits,
9
12
  BackendWindowSnapshot,
10
13
  CodexUsageReport,
11
14
  NormalizedCredits,
15
+ NormalizedRateLimitResetCredit,
16
+ NormalizedRateLimitResetCredits,
12
17
  NormalizedRateLimitSnapshot,
13
18
  NormalizedRateLimitWindow,
14
19
  RateLimitStatusPayload,
@@ -29,27 +34,30 @@ export function normalizeBackendPayload(
29
34
  ? payload.additional_rate_limits
30
35
  : [];
31
36
  for (const item of additional) {
32
- const additionalLimit = assertObject(
33
- item,
34
- "additional rate limit",
35
- ) as BackendAdditionalRateLimit;
37
+ const additionalLimit = asObject(item) as BackendAdditionalRateLimit | undefined;
38
+ if (!additionalLimit) continue;
36
39
  const limitId =
37
40
  asString(additionalLimit.metered_feature) ?? asString(additionalLimit.limit_name);
38
41
  if (!limitId) continue;
39
- const snapshot = normalizeBackendSnapshot(
40
- limitId,
41
- asString(additionalLimit.limit_name),
42
- additionalLimit.rate_limit,
43
- undefined,
44
- );
45
- if (snapshot) snapshots.push(snapshot);
42
+ try {
43
+ const snapshot = normalizeBackendSnapshot(
44
+ limitId,
45
+ asString(additionalLimit.limit_name),
46
+ additionalLimit.rate_limit,
47
+ undefined,
48
+ );
49
+ if (snapshot) snapshots.push(snapshot);
50
+ } catch {
51
+ // Optional additional buckets must not hide otherwise usable primary/reset usage.
52
+ }
46
53
  }
47
54
 
48
- if (snapshots.length === 0) {
49
- throw new Error("Codex usage endpoint returned no displayable rate-limit windows.");
55
+ const resetCredits = normalizeBackendRateLimitResetCredits(payload.rate_limit_reset_credits);
56
+ if (snapshots.length === 0 && !resetCredits) {
57
+ throw new Error("Codex usage endpoint returned no displayable usage data.");
50
58
  }
51
59
 
52
- return { source, capturedAt, planType, snapshots };
60
+ return { source, capturedAt, planType, snapshots, resetCredits };
53
61
  }
54
62
 
55
63
  function normalizeBackendSnapshot(
@@ -95,13 +103,27 @@ function normalizeBackendCredits(value: unknown): NormalizedCredits | undefined
95
103
  return { hasCredits, unlimited, balance: asString(credits.balance) };
96
104
  }
97
105
 
106
+ function normalizeBackendRateLimitResetCredits(
107
+ value: unknown,
108
+ ): NormalizedRateLimitResetCredits | undefined {
109
+ const resetCredits = asObject(value) as BackendRateLimitResetCredits | undefined;
110
+ const availableCount = asNonnegativeInteger(resetCredits?.available_count);
111
+ return availableCount === undefined ? undefined : { availableCount };
112
+ }
113
+
98
114
  export function normalizeAppServerResponse(
99
115
  response: AppServerRateLimitResponse,
100
116
  capturedAt: number,
101
117
  ): CodexUsageReport {
102
118
  const snapshots: NormalizedRateLimitSnapshot[] = [];
103
- const addSnapshot = (raw: unknown, fallbackId: string) => {
104
- const snapshot = normalizeAppServerSnapshot(raw, fallbackId);
119
+ const addSnapshot = (raw: unknown, fallbackId: string, optional = false) => {
120
+ let snapshot: NormalizedRateLimitSnapshot | undefined;
121
+ try {
122
+ snapshot = normalizeAppServerSnapshot(raw, fallbackId);
123
+ } catch (error) {
124
+ if (optional) return;
125
+ throw error;
126
+ }
105
127
  if (!snapshot) return;
106
128
  const existingIndex = snapshots.findIndex((item) => item.limitId === snapshot.limitId);
107
129
  if (existingIndex >= 0)
@@ -110,18 +132,26 @@ export function normalizeAppServerResponse(
110
132
  };
111
133
 
112
134
  addSnapshot(response.rateLimits, "codex");
113
- if (response.rateLimitsByLimitId && typeof response.rateLimitsByLimitId === "object") {
114
- for (const [limitId, raw] of Object.entries(response.rateLimitsByLimitId)) {
115
- addSnapshot(raw, limitId);
135
+ const snapshotsByLimitId = asObject(response.rateLimitsByLimitId);
136
+ if (snapshotsByLimitId) {
137
+ for (const [limitId, raw] of Object.entries(snapshotsByLimitId)) {
138
+ if (limitId) addSnapshot(raw, limitId, true);
116
139
  }
117
140
  }
118
141
 
119
- if (snapshots.length === 0) {
120
- throw new Error("codex app-server returned no displayable rate-limit windows.");
142
+ const resetCredits = normalizeAppServerRateLimitResetCredits(response.rateLimitResetCredits);
143
+ if (snapshots.length === 0 && !resetCredits) {
144
+ throw new Error("codex app-server returned no displayable usage data.");
121
145
  }
122
146
 
123
147
  const planType = asAppServerPlanType(response.rateLimits);
124
- return { source: "codex-app-server", capturedAt, planType, snapshots };
148
+ return {
149
+ source: "codex-app-server",
150
+ capturedAt,
151
+ planType,
152
+ snapshots,
153
+ resetCredits,
154
+ };
125
155
  }
126
156
 
127
157
  function asAppServerPlanType(raw: unknown): string | undefined {
@@ -172,6 +202,49 @@ function normalizeAppServerCredits(value: unknown): NormalizedCredits | undefine
172
202
  return { hasCredits, unlimited, balance: asString(credits.balance) };
173
203
  }
174
204
 
205
+ function normalizeAppServerRateLimitResetCredits(
206
+ value: unknown,
207
+ ): NormalizedRateLimitResetCredits | undefined {
208
+ const resetCredits = asObject(value) as AppServerRateLimitResetCredits | undefined;
209
+ const availableCount = asNonnegativeInteger(resetCredits?.availableCount);
210
+ if (availableCount === undefined) return undefined;
211
+
212
+ const rawCredits = resetCredits?.credits;
213
+ if (!Array.isArray(rawCredits)) return { availableCount };
214
+
215
+ const credits = rawCredits
216
+ .map(normalizeAppServerRateLimitResetCredit)
217
+ .filter((credit): credit is NormalizedRateLimitResetCredit => credit !== undefined)
218
+ .slice(0, availableCount);
219
+ if (rawCredits.length > 0 && credits.length === 0 && availableCount > 0) {
220
+ return { availableCount };
221
+ }
222
+ return { availableCount, credits };
223
+ }
224
+
225
+ function normalizeAppServerRateLimitResetCredit(
226
+ value: unknown,
227
+ ): NormalizedRateLimitResetCredit | undefined {
228
+ const credit = asObject(value) as AppServerRateLimitResetCredit | undefined;
229
+ const id = asString(credit?.id);
230
+ if (!id) return undefined;
231
+
232
+ const normalized: NormalizedRateLimitResetCredit = { id };
233
+ const resetType = asString(credit?.resetType);
234
+ const status = asString(credit?.status);
235
+ const grantedAt = asNumber(credit?.grantedAt);
236
+ const expiresAt = asNumber(credit?.expiresAt);
237
+ const title = asString(credit?.title);
238
+ const description = asString(credit?.description);
239
+ if (resetType !== undefined) normalized.resetType = resetType;
240
+ if (status !== undefined) normalized.status = status;
241
+ if (grantedAt !== undefined) normalized.grantedAt = grantedAt;
242
+ if (expiresAt !== undefined) normalized.expiresAt = expiresAt;
243
+ if (title !== undefined) normalized.title = title;
244
+ if (description !== undefined) normalized.description = description;
245
+ return normalized;
246
+ }
247
+
175
248
  function mergeSnapshot(
176
249
  left: NormalizedRateLimitSnapshot,
177
250
  right: NormalizedRateLimitSnapshot,
@@ -186,9 +259,13 @@ function mergeSnapshot(
186
259
  }
187
260
 
188
261
  function assertObject(value: unknown, description: string): Record<string, unknown> {
189
- if (!value || typeof value !== "object" || Array.isArray(value)) {
190
- throw new Error(`${description} was not an object.`);
191
- }
262
+ const object = asObject(value);
263
+ if (!object) throw new Error(`${description} was not an object.`);
264
+ return object;
265
+ }
266
+
267
+ function asObject(value: unknown): Record<string, unknown> | undefined {
268
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
192
269
  return value as Record<string, unknown>;
193
270
  }
194
271
 
@@ -208,3 +285,9 @@ function asNumber(value: unknown): number | undefined {
208
285
  function asBoolean(value: unknown): boolean | undefined {
209
286
  return typeof value === "boolean" ? value : undefined;
210
287
  }
288
+
289
+ function asNonnegativeInteger(value: unknown): number | undefined {
290
+ const parsed = asNumber(value);
291
+ if (parsed === undefined || !Number.isSafeInteger(parsed)) return undefined;
292
+ return Math.max(0, parsed);
293
+ }
package/src/types.ts CHANGED
@@ -31,6 +31,22 @@ export type CodexUsageReport = {
31
31
  capturedAt: number;
32
32
  planType?: string;
33
33
  snapshots: NormalizedRateLimitSnapshot[];
34
+ resetCredits?: NormalizedRateLimitResetCredits;
35
+ };
36
+
37
+ export type NormalizedRateLimitResetCredits = {
38
+ availableCount: number;
39
+ credits?: NormalizedRateLimitResetCredit[];
40
+ };
41
+
42
+ export type NormalizedRateLimitResetCredit = {
43
+ id: string;
44
+ resetType?: string;
45
+ status?: string;
46
+ grantedAt?: number;
47
+ expiresAt?: number;
48
+ title?: string;
49
+ description?: string;
34
50
  };
35
51
 
36
52
  export type NormalizedRateLimitSnapshot = {
@@ -58,6 +74,11 @@ export type RateLimitStatusPayload = {
58
74
  rate_limit?: unknown;
59
75
  additional_rate_limits?: unknown;
60
76
  credits?: unknown;
77
+ rate_limit_reset_credits?: unknown;
78
+ };
79
+
80
+ export type BackendRateLimitResetCredits = {
81
+ available_count?: unknown;
61
82
  };
62
83
 
63
84
  export type BackendRateLimitDetails = {
@@ -86,6 +107,22 @@ export type BackendCreditsSnapshot = {
86
107
  export type AppServerRateLimitResponse = {
87
108
  rateLimits?: unknown;
88
109
  rateLimitsByLimitId?: unknown;
110
+ rateLimitResetCredits?: unknown;
111
+ };
112
+
113
+ export type AppServerRateLimitResetCredits = {
114
+ availableCount?: unknown;
115
+ credits?: unknown;
116
+ };
117
+
118
+ export type AppServerRateLimitResetCredit = {
119
+ id?: unknown;
120
+ resetType?: unknown;
121
+ status?: unknown;
122
+ grantedAt?: unknown;
123
+ expiresAt?: unknown;
124
+ title?: unknown;
125
+ description?: unknown;
89
126
  };
90
127
 
91
128
  export type AppServerRateLimitSnapshot = {