@monotykamary/pi-better-openai 0.1.41 → 0.1.43

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
@@ -24,6 +24,7 @@ import {
24
24
  import {
25
25
  formatTokens,
26
26
  redactDiagnosticValue,
27
+ sanitizeDiagnosticError,
27
28
  sanitizeStatusText,
28
29
  truncateToWidth,
29
30
  visibleWidth,
@@ -62,6 +63,15 @@ import {
62
63
  parseUsageSnapshot,
63
64
  readCodexAuth,
64
65
  } from "./src/usage.ts";
66
+ import {
67
+ buildBankedResetConfirmation,
68
+ consumeBankedReset,
69
+ formatBankedResetChoice,
70
+ formatConsumeOutcome,
71
+ newRedeemRequestId,
72
+ selectBankedResetCredit,
73
+ } from "./src/resets.ts";
74
+ import { ResetController } from "./src/reset-controller.ts";
65
75
  import { registerOpenAIImage, _imageTest } from "./src/image.ts";
66
76
  import { registerOpenAIWebSearch, _websearchTest } from "./src/websearch.ts";
67
77
  import { registerOpenAILive } from "./src/live/index.ts";
@@ -77,7 +87,7 @@ import {
77
87
  _petsTest,
78
88
  } from "./src/pets.ts";
79
89
  import { FastController, modelList, supportsFast } from "./src/fast-controller.ts";
80
- import { UsageController } from "./src/usage-controller.ts";
90
+ import { isOpenAISubscriptionModel, UsageController } from "./src/usage-controller.ts";
81
91
  import { PetFooterController } from "./src/pet-footer-controller.ts";
82
92
  import {
83
93
  abbreviateHomePath,
@@ -121,6 +131,7 @@ class DynamicBorder {
121
131
  const COMMAND = "fast";
122
132
  const OPENAI_STATUS_COMMAND = "openai-usage";
123
133
  const OPENAI_SETTINGS_COMMAND = "openai-settings";
134
+ const OPENAI_RESETS_COMMAND = "openai-resets";
124
135
  const FLAG = "fast";
125
136
  const PET_EMPTY_VALUE = "not selected";
126
137
  const SERVICE_TIER = "priority";
@@ -233,6 +244,7 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
233
244
  let cachedSessionNameLeafId: string | null | undefined;
234
245
  let cachedSessionName: string | undefined;
235
246
  const usageController = new UsageController(config, updateFooter);
247
+ const resetController = new ResetController();
236
248
  const petController = new PetFooterController(config, updateFooter, () => footerInstalled);
237
249
  let multiproviderService: MultiproviderService | undefined;
238
250
  let unsubscribeMultiprovider: (() => void) | undefined;
@@ -251,6 +263,7 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
251
263
  setActiveMultiproviderService(value);
252
264
  unsubscribeMultiprovider = value.onActiveAccountChanged(CODEX_PROVIDER_ID, (event) => {
253
265
  void usageController.refresh(event.ctx, undefined, { force: true });
266
+ void resetController.refresh(event.ctx, { force: true }).catch(() => {});
254
267
  updateFooter(event.ctx);
255
268
  });
256
269
  const ctx = multiproviderRefreshCtx;
@@ -383,6 +396,65 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
383
396
  ].join("\n");
384
397
  }
385
398
 
399
+ async function redeemBankedReset(ctx: ExtensionContext): Promise<void> {
400
+ if (!hasTerminalUI(ctx)) {
401
+ ctx.ui.notify("/openai-resets requires an interactive TUI session.", "warning");
402
+ return;
403
+ }
404
+ let credits = resetController.snapshot?.credits;
405
+ if (!credits) {
406
+ await resetController.refresh(ctx, { force: true }).catch(() => {});
407
+ credits = resetController.snapshot?.credits;
408
+ }
409
+ if (!credits) {
410
+ const reason = resetController.lastError;
411
+ ctx.ui.notify(`Banked reset lookup failed${reason ? `: ${reason}` : "."}`, "error");
412
+ return;
413
+ }
414
+ void resetController.refresh(ctx).catch(() => {});
415
+ if (credits.availableCount <= 0) {
416
+ ctx.ui.notify("No banked Codex resets are available for this account.", "info");
417
+ return;
418
+ }
419
+ const available = credits.credits.filter((credit) => credit.status === "available");
420
+ let selected = selectBankedResetCredit(credits.credits);
421
+ if (available.length > 1 && selected) {
422
+ const options = available.map((credit, index) => formatBankedResetChoice(credit, index));
423
+ const chosen = await ctx.ui.select("Redeem which banked reset?", options);
424
+ if (chosen === undefined) {
425
+ ctx.ui.notify("Banked reset redemption cancelled.", "info");
426
+ return;
427
+ }
428
+ selected = available[Math.max(0, options.indexOf(chosen))];
429
+ }
430
+ const confirmation = buildBankedResetConfirmation({
431
+ credit: selected,
432
+ availableCount: credits.availableCount,
433
+ snapshot: usageController.snapshot,
434
+ });
435
+ if (!(await ctx.ui.confirm(confirmation.title, confirmation.message))) {
436
+ ctx.ui.notify("Banked reset redemption cancelled.", "info");
437
+ return;
438
+ }
439
+ try {
440
+ const timeoutSignal = AbortSignal.timeout(15_000);
441
+ const signal = ctx.signal ? AbortSignal.any([ctx.signal, timeoutSignal]) : timeoutSignal;
442
+ const result = await consumeBankedReset(ctx, selected?.id, newRedeemRequestId(), signal);
443
+ const outcome = formatConsumeOutcome(result);
444
+ ctx.ui.notify(outcome.message, outcome.level);
445
+ void usageController.refresh(ctx, ctx.model?.id, { force: true });
446
+ void resetController.refresh(ctx, { force: true }).catch(() => {});
447
+ updateFooter(ctx);
448
+ } catch (error) {
449
+ ctx.ui.notify(
450
+ `Banked reset redemption failed: ${sanitizeDiagnosticError(
451
+ error instanceof Error ? error.message : String(error),
452
+ )}`,
453
+ "error",
454
+ );
455
+ }
456
+ }
457
+
386
458
  pi.registerCommand(COMMAND, {
387
459
  description: "Toggle OpenAI fast mode",
388
460
  handler: async (args, ctx) => {
@@ -399,6 +471,13 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
399
471
  },
400
472
  });
401
473
 
474
+ pi.registerCommand(OPENAI_RESETS_COMMAND, {
475
+ description: "Inspect and redeem Codex banked rate-limit resets",
476
+ handler: async (_args, ctx) => {
477
+ await redeemBankedReset(ctx);
478
+ },
479
+ });
480
+
402
481
  function buildPetSettingsItems(cfg: ResolvedConfig): SettingsPickerItem[] {
403
482
  return settingsItemsFromDescriptors(PET_SETTING_DESCRIPTORS, cfg, {
404
483
  "pets.slug": {
@@ -1307,6 +1386,8 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1307
1386
  updateFooter(ctx);
1308
1387
  if (hasTerminalUI(ctx) && nextConfig.pets.enabled) void petController.refresh(ctx, nextConfig);
1309
1388
  usageController.start(ctx);
1389
+ if (nextConfig.usage.enabled && isOpenAISubscriptionModel(ctx, nextConfig))
1390
+ resetController.start(ctx);
1310
1391
  if (fastController.active) ctx.ui.notify(fastController.stateText(ctx, nextConfig), "info");
1311
1392
  });
1312
1393
 
@@ -1383,6 +1464,7 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
1383
1464
  invalidateSessionName();
1384
1465
  multiproviderRefreshCtx = undefined;
1385
1466
  usageController.shutdown();
1467
+ resetController.stop();
1386
1468
  petController.shutdown();
1387
1469
  });
1388
1470
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@monotykamary/pi-better-openai",
3
- "version": "0.1.41",
3
+ "version": "0.1.43",
4
4
  "description": "Improve OpenAI in pi with fast mode, usage stats, realtime voice, image generation, and footer polish.",
5
5
  "keywords": [
6
6
  "fast",
package/src/config.ts CHANGED
@@ -58,6 +58,7 @@ export type UsageConfig = {
58
58
  refreshIntervalMs?: number;
59
59
  showOnlyOnSubscriptionModels?: boolean;
60
60
  showResetTimes?: boolean;
61
+ showBankedResets?: boolean;
61
62
  };
62
63
 
63
64
  export type FooterConfig = {
@@ -140,6 +141,7 @@ export const DEFAULT_USAGE_CONFIG: Required<UsageConfig> = {
140
141
  refreshIntervalMs: 60_000,
141
142
  showOnlyOnSubscriptionModels: true,
142
143
  showResetTimes: true,
144
+ showBankedResets: true,
143
145
  };
144
146
 
145
147
  export const DEFAULT_FOOTER_CONFIG: Required<FooterConfig> = {
@@ -296,6 +298,16 @@ export const USAGE_SETTING_DESCRIPTORS: readonly SettingsOptionDescriptor[] = [
296
298
  description: "Include compact reset countdowns and local reset times.",
297
299
  parse: booleanSetting,
298
300
  },
301
+ {
302
+ id: "usage.showBankedResets",
303
+ section: "usage",
304
+ key: "showBankedResets",
305
+ label: "Banked reset count",
306
+ currentValue: (cfg) => String(cfg.usage.showBankedResets),
307
+ values: ["true", "false"],
308
+ description: "Show the available banked Codex reset count in the usage status line.",
309
+ parse: booleanSetting,
310
+ },
299
311
  ];
300
312
 
301
313
  export const IMAGE_SETTING_DESCRIPTORS: readonly SettingsOptionDescriptor[] = [
@@ -629,6 +641,8 @@ export function readConfig(path: string): ConfigFile | undefined {
629
641
  config.usage.showOnlyOnSubscriptionModels = parsed.usage.showOnlyOnSubscriptionModels;
630
642
  if (typeof parsed.usage.showResetTimes === "boolean")
631
643
  config.usage.showResetTimes = parsed.usage.showResetTimes;
644
+ if (typeof parsed.usage.showBankedResets === "boolean")
645
+ config.usage.showBankedResets = parsed.usage.showBankedResets;
632
646
  }
633
647
  if (
634
648
  isRecord(parsed.footer) &&
@@ -0,0 +1,97 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { sanitizeDiagnosticError } from "./format.ts";
3
+ import { type BankedResetCredits, requestBankedResetCredits } from "./resets.ts";
4
+
5
+ export const BANKED_RESET_CACHE_TTL_MS = 5 * 60_000;
6
+ const BANKED_RESET_REQUEST_TIMEOUT_MS = 10_000;
7
+
8
+ export type BankedResetCache = {
9
+ credits: BankedResetCredits;
10
+ updatedAt: number;
11
+ };
12
+
13
+ // Keeps the banked-reset credit list warm so /openai-resets opens without
14
+ // paying the credential-resolution + wham round trip. The cache is prefetched
15
+ // at session start, refreshed on a TTL, and served even when stale (a stale
16
+ // open still kicks off a background refresh; the backend re-validates every
17
+ // redemption server-side, so a stale offer can never be consumed twice).
18
+ export class ResetController {
19
+ private cache: BankedResetCache | undefined;
20
+ private error: string | undefined;
21
+ private lastFetchAt: number | undefined;
22
+ private inFlight: Promise<void> | undefined;
23
+ private timer: ReturnType<typeof setInterval> | undefined;
24
+ private sessionSignal: AbortSignal | undefined;
25
+ private sessionAbortHandler: (() => void) | undefined;
26
+
27
+ get snapshot(): BankedResetCache | undefined {
28
+ return this.cache;
29
+ }
30
+
31
+ get lastError(): string | undefined {
32
+ return this.error;
33
+ }
34
+
35
+ isFresh(now = Date.now()): boolean {
36
+ return this.cache !== undefined && now - this.cache.updatedAt < BANKED_RESET_CACHE_TTL_MS;
37
+ }
38
+
39
+ refresh(ctx: ExtensionContext, options?: { force?: boolean }): Promise<void> {
40
+ if (this.inFlight) return this.inFlight;
41
+ if (
42
+ !options?.force &&
43
+ this.lastFetchAt !== undefined &&
44
+ Date.now() - this.lastFetchAt < BANKED_RESET_CACHE_TTL_MS
45
+ )
46
+ return Promise.resolve();
47
+ this.lastFetchAt = Date.now();
48
+ const timeoutSignal = AbortSignal.timeout(BANKED_RESET_REQUEST_TIMEOUT_MS);
49
+ const signal = ctx.signal ? AbortSignal.any([ctx.signal, timeoutSignal]) : timeoutSignal;
50
+ const task = (async () => {
51
+ try {
52
+ const credits = await requestBankedResetCredits(ctx, signal);
53
+ if (credits) {
54
+ this.cache = { credits, updatedAt: Date.now() };
55
+ this.error = undefined;
56
+ } else {
57
+ this.error = "OpenAI Codex credentials unavailable.";
58
+ }
59
+ } catch (error) {
60
+ this.error = sanitizeDiagnosticError(
61
+ error instanceof Error ? error.message : String(error),
62
+ );
63
+ } finally {
64
+ this.inFlight = undefined;
65
+ }
66
+ })();
67
+ this.inFlight = task;
68
+ return task;
69
+ }
70
+
71
+ start(ctx: ExtensionContext): void {
72
+ this.stop();
73
+ if (ctx.signal?.aborted) return;
74
+ this.sessionSignal = ctx.signal;
75
+ this.sessionAbortHandler = () => this.stop();
76
+ ctx.signal?.addEventListener("abort", this.sessionAbortHandler, { once: true });
77
+ void this.refresh(ctx).catch(() => {});
78
+ this.timer = setInterval(() => {
79
+ if (ctx.signal?.aborted) {
80
+ this.stop();
81
+ return;
82
+ }
83
+ void this.refresh(ctx).catch(() => {});
84
+ }, BANKED_RESET_CACHE_TTL_MS);
85
+ this.timer.unref?.();
86
+ }
87
+
88
+ stop(): void {
89
+ if (this.timer) clearInterval(this.timer);
90
+ this.timer = undefined;
91
+ if (this.sessionSignal && this.sessionAbortHandler) {
92
+ this.sessionSignal.removeEventListener("abort", this.sessionAbortHandler);
93
+ }
94
+ this.sessionSignal = undefined;
95
+ this.sessionAbortHandler = undefined;
96
+ }
97
+ }
package/src/resets.ts ADDED
@@ -0,0 +1,249 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { getCodexCredentials } from "./codex-auth.ts";
4
+ import { formatPercent, type UsageSnapshot } from "./usage.ts";
5
+
6
+ export const RESET_CREDITS_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
7
+ export const CONSUME_RESET_URL =
8
+ "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume";
9
+
10
+ export type BankedResetStatus = "available" | "redeeming" | "redeemed" | "unknown";
11
+
12
+ export type BankedResetCredit = {
13
+ id: string;
14
+ resetType: string | null;
15
+ status: BankedResetStatus;
16
+ grantedAtMs: number | null;
17
+ expiresAtMs: number | null;
18
+ title: string | null;
19
+ description: string | null;
20
+ };
21
+
22
+ export type BankedResetCredits = {
23
+ availableCount: number;
24
+ applicableCount: number | null;
25
+ credits: BankedResetCredit[];
26
+ };
27
+
28
+ export type ConsumeBankedResetCode =
29
+ | "reset"
30
+ | "nothing_to_reset"
31
+ | "no_credit"
32
+ | "already_redeemed";
33
+
34
+ export type ConsumeBankedResetResult = {
35
+ code: ConsumeBankedResetCode;
36
+ windowsReset: number | null;
37
+ };
38
+
39
+ function isRecord(value: unknown): value is Record<string, unknown> {
40
+ return typeof value === "object" && value !== null && !Array.isArray(value);
41
+ }
42
+
43
+ function isAbortSignal(value: unknown): value is AbortSignal {
44
+ return typeof value === "object" && value !== null && "aborted" in value;
45
+ }
46
+
47
+ function asOptionalInt(value: unknown): number | null {
48
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : null;
49
+ }
50
+
51
+ function readText(value: unknown): string | null {
52
+ return typeof value === "string" && value.trim() ? value : null;
53
+ }
54
+
55
+ function parseTimestampMs(value: unknown): number | null {
56
+ if (typeof value === "number" && Number.isFinite(value)) {
57
+ return value > 100_000_000_000 ? value : value * 1000;
58
+ }
59
+ if (typeof value === "string" && value.trim()) {
60
+ const parsed = Date.parse(value);
61
+ return Number.isFinite(parsed) ? parsed : null;
62
+ }
63
+ return null;
64
+ }
65
+
66
+ function parseCreditRow(row: unknown): BankedResetCredit | undefined {
67
+ if (!isRecord(row)) return undefined;
68
+ const id = typeof row.id === "string" && row.id ? row.id : undefined;
69
+ if (!id) return undefined;
70
+ const statusValue = row.status;
71
+ const status: BankedResetStatus =
72
+ statusValue === "available" || statusValue === "redeeming" || statusValue === "redeemed"
73
+ ? statusValue
74
+ : "unknown";
75
+ return {
76
+ id,
77
+ resetType: readText(row.reset_type),
78
+ status,
79
+ grantedAtMs: parseTimestampMs(row.granted_at),
80
+ expiresAtMs: parseTimestampMs(row.expires_at),
81
+ title: readText(row.title),
82
+ description: readText(row.description),
83
+ };
84
+ }
85
+
86
+ export function parseBankedResetCredits(payload: unknown): BankedResetCredits {
87
+ if (!isRecord(payload)) return { availableCount: 0, applicableCount: null, credits: [] };
88
+ const rows = Array.isArray(payload.credits) ? payload.credits : [];
89
+ const credits = rows
90
+ .map(parseCreditRow)
91
+ .filter((credit): credit is BankedResetCredit => credit !== undefined);
92
+ const applicableRaw = payload.applicable_available_count;
93
+ return {
94
+ availableCount: asOptionalInt(payload.available_count) ?? 0,
95
+ applicableCount: applicableRaw === undefined ? null : asOptionalInt(applicableRaw),
96
+ credits,
97
+ };
98
+ }
99
+
100
+ export function parseConsumeBankedResetResult(payload: unknown): ConsumeBankedResetResult {
101
+ if (!isRecord(payload)) throw new Error("Codex reset response was malformed.");
102
+ const code = payload.code;
103
+ if (
104
+ code !== "reset" &&
105
+ code !== "nothing_to_reset" &&
106
+ code !== "no_credit" &&
107
+ code !== "already_redeemed"
108
+ )
109
+ throw new Error("Unexpected Codex reset response code.");
110
+ return { code, windowsReset: asOptionalInt(payload.windows_reset) };
111
+ }
112
+
113
+ export function availableBankedResetCredits(
114
+ credits: readonly BankedResetCredit[],
115
+ ): BankedResetCredit[] {
116
+ return credits.filter((credit) => credit.status === "available");
117
+ }
118
+
119
+ // Soonest-expiring available credit first: banked resets expire, so spend the
120
+ // one closest to expiry. Credits without an expiry sort last.
121
+ export function selectBankedResetCredit(
122
+ credits: readonly BankedResetCredit[],
123
+ ): BankedResetCredit | undefined {
124
+ return [...availableBankedResetCredits(credits)].sort((a, b) => {
125
+ const aExpiry = a.expiresAtMs ?? Number.POSITIVE_INFINITY;
126
+ const bExpiry = b.expiresAtMs ?? Number.POSITIVE_INFINITY;
127
+ if (aExpiry !== bExpiry) return aExpiry - bExpiry;
128
+ return (a.grantedAtMs ?? 0) - (b.grantedAtMs ?? 0);
129
+ })[0];
130
+ }
131
+
132
+ function formatResetTimestamp(ms: number | null): string {
133
+ if (ms === null) return "unknown";
134
+ return new Date(ms).toLocaleString(undefined, {
135
+ month: "short",
136
+ day: "numeric",
137
+ hour: "numeric",
138
+ minute: "2-digit",
139
+ });
140
+ }
141
+
142
+ export function formatBankedResetChoice(credit: BankedResetCredit, index: number): string {
143
+ const title = credit.title ?? "Codex banked reset";
144
+ const expires =
145
+ credit.expiresAtMs === null
146
+ ? "no expiry"
147
+ : `expires ${formatResetTimestamp(credit.expiresAtMs)}`;
148
+ return `${index + 1}. ${title} · ${expires}`;
149
+ }
150
+
151
+ export function formatConsumeOutcome(result: ConsumeBankedResetResult): {
152
+ message: string;
153
+ level: "info" | "warning";
154
+ } {
155
+ switch (result.code) {
156
+ case "reset":
157
+ return {
158
+ message: `Banked reset redeemed${result.windowsReset === null ? "" : ` — ${result.windowsReset} usage window${result.windowsReset === 1 ? "" : "s"} refreshed`}.`,
159
+ level: "info",
160
+ };
161
+ case "nothing_to_reset":
162
+ return {
163
+ message: "Nothing to reset — Codex reported no eligible usage window.",
164
+ level: "warning",
165
+ };
166
+ case "no_credit":
167
+ return { message: "No banked Codex resets remain available.", level: "warning" };
168
+ case "already_redeemed":
169
+ return { message: "That banked reset was already redeemed.", level: "warning" };
170
+ }
171
+ }
172
+
173
+ export function buildBankedResetConfirmation(options: {
174
+ credit?: BankedResetCredit;
175
+ availableCount: number;
176
+ snapshot?: Pick<UsageSnapshot, "fiveHourLeftPercent" | "sevenDayLeftPercent">;
177
+ }): { title: string; message: string } {
178
+ const credit = options.credit;
179
+ const lines: string[] = [credit?.title ?? "Codex banked rate-limit reset"];
180
+ if (credit?.description) lines.push(credit.description);
181
+ lines.push("");
182
+ if (credit?.grantedAtMs != null)
183
+ lines.push(`Granted: ${formatResetTimestamp(credit.grantedAtMs)}`);
184
+ lines.push(`Expires: ${formatResetTimestamp(credit?.expiresAtMs ?? null)}`);
185
+ lines.push(`Available: ${options.availableCount}`);
186
+ const windows: string[] = [];
187
+ if (options.snapshot?.fiveHourLeftPercent != null)
188
+ windows.push(`5h ${formatPercent(options.snapshot.fiveHourLeftPercent)} left`);
189
+ if (options.snapshot?.sevenDayLeftPercent != null)
190
+ windows.push(`7d ${formatPercent(options.snapshot.sevenDayLeftPercent)} left`);
191
+ if (windows.length > 0) lines.push(`Current usage: ${windows.join(" · ")}`);
192
+ lines.push("");
193
+ lines.push(
194
+ "This resets your 5-hour and weekly Codex usage windows immediately and cannot be undone.",
195
+ );
196
+ return { title: "Redeem banked Codex reset?", message: lines.join("\n") };
197
+ }
198
+
199
+ export async function requestBankedResetCredits(
200
+ ctxOrSignal?: ExtensionContext | AbortSignal,
201
+ signal?: AbortSignal,
202
+ ): Promise<BankedResetCredits | undefined> {
203
+ const ctx = isAbortSignal(ctxOrSignal) ? undefined : ctxOrSignal;
204
+ const requestSignal = isAbortSignal(ctxOrSignal) ? ctxOrSignal : signal;
205
+ const credentials = await getCodexCredentials(ctx, requestSignal);
206
+ if (!credentials) return undefined;
207
+ const response = await fetch(RESET_CREDITS_URL, {
208
+ headers: {
209
+ accept: "*/*",
210
+ authorization: `Bearer ${credentials.accessToken}`,
211
+ "chatgpt-account-id": credentials.accountId,
212
+ },
213
+ signal: requestSignal,
214
+ });
215
+ if (!response.ok) throw new Error(`Codex reset credit request failed (${response.status})`);
216
+ return parseBankedResetCredits(await response.json());
217
+ }
218
+
219
+ export async function consumeBankedReset(
220
+ ctx: ExtensionContext | undefined,
221
+ creditId: string | undefined,
222
+ redeemRequestId: string,
223
+ signal?: AbortSignal,
224
+ ): Promise<ConsumeBankedResetResult> {
225
+ const credentials = await getCodexCredentials(ctx, signal);
226
+ if (!credentials)
227
+ throw new Error("OpenAI Codex authentication is unavailable. Run /login first.");
228
+ const response = await fetch(CONSUME_RESET_URL, {
229
+ method: "POST",
230
+ headers: {
231
+ accept: "*/*",
232
+ authorization: `Bearer ${credentials.accessToken}`,
233
+ "chatgpt-account-id": credentials.accountId,
234
+ "content-type": "application/json",
235
+ },
236
+ body: JSON.stringify(
237
+ creditId
238
+ ? { credit_id: creditId, redeem_request_id: redeemRequestId }
239
+ : { redeem_request_id: redeemRequestId },
240
+ ),
241
+ signal,
242
+ });
243
+ if (!response.ok) throw new Error(`Codex reset consume request failed (${response.status})`);
244
+ return parseConsumeBankedResetResult(await response.json());
245
+ }
246
+
247
+ export function newRedeemRequestId(): string {
248
+ return randomUUID();
249
+ }
@@ -113,6 +113,7 @@ export class UsageController {
113
113
  `Last successful update: ${this.usageUpdatedAt ? new Date(this.usageUpdatedAt).toLocaleTimeString() : "never"}`,
114
114
  `Last error: ${this.usageError ?? "none"}`,
115
115
  `Refresh interval: ${cfg.usage.refreshIntervalMs}ms`,
116
+ `Banked resets: ${this.usageSnapshot?.bankedResets ?? "n/a"}`,
116
117
  `Endpoint: https://chatgpt.com/backend-api/wham/usage`,
117
118
  ].join("\n");
118
119
  }
package/src/usage.ts CHANGED
@@ -19,6 +19,7 @@ export type RateLimitBucket = {
19
19
  export type CodexUsageResponse = {
20
20
  rate_limit?: RateLimitBucket | null;
21
21
  additional_rate_limits?: Record<string, unknown> | unknown[] | null;
22
+ rate_limit_reset_credits?: { available_count?: number | null } | null;
22
23
  };
23
24
 
24
25
  export type UsageSnapshot = {
@@ -29,6 +30,7 @@ export type UsageSnapshot = {
29
30
  fiveHourResetInSeconds: number | null;
30
31
  sevenDayResetInSeconds: number | null;
31
32
  isLimited: boolean;
33
+ bankedResets: number | null;
32
34
  };
33
35
 
34
36
  export type UsageScope = "default" | "spark";
@@ -82,6 +84,11 @@ function usedToLeftPercent(value: number | null | undefined): number | null {
82
84
  return clampPercent(100 - value);
83
85
  }
84
86
 
87
+ function readBankedResetCount(data: CodexUsageResponse): number | null {
88
+ const raw = asObject(data.rate_limit_reset_credits)?.available_count;
89
+ return typeof raw === "number" && Number.isInteger(raw) && raw >= 0 ? raw : null;
90
+ }
91
+
85
92
  export function formatResetCountdown(seconds: number | null): string | null {
86
93
  if (typeof seconds !== "number" || !Number.isFinite(seconds)) return null;
87
94
  const total = Math.max(0, Math.round(seconds));
@@ -236,6 +243,7 @@ export function parseUsageSnapshot(
236
243
  fiveHourResetInSeconds: weeklyOnly ? null : getResetSeconds(primaryWindow, now),
237
244
  sevenDayResetInSeconds: getResetSeconds(weeklyOnly ? primaryWindow : secondaryWindow, now),
238
245
  isLimited: bucket?.limit_reached === true || bucket?.allowed === false,
246
+ bankedResets: readBankedResetCount(data),
239
247
  };
240
248
  }
241
249
 
@@ -247,7 +255,7 @@ export function formatPercent(value: number | null): string {
247
255
 
248
256
  export function formatUsageSnapshot(
249
257
  snapshot: UsageSnapshot,
250
- options: { showResetTimes: boolean },
258
+ options: { showResetTimes: boolean; showBankedResets?: boolean },
251
259
  now = Date.now(),
252
260
  ): string {
253
261
  const windows = [
@@ -283,7 +291,14 @@ export function formatUsageSnapshot(
283
291
  )
284
292
  .filter((value): value is string => value !== null)
285
293
  : [];
286
- return `Usage: ${usage}${resets.length ? ` · ${resets.join(" · ")}` : ""}`;
294
+ const banked =
295
+ options.showBankedResets === false ? null : formatBankedResetsSuffix(snapshot.bankedResets);
296
+ return `Usage: ${usage}${resets.length ? ` · ${resets.join(" · ")}` : ""}${banked ? ` · ${banked}` : ""}`;
297
+ }
298
+
299
+ export function formatBankedResetsSuffix(count: number | null): string | null {
300
+ if (typeof count !== "number" || !Number.isInteger(count) || count <= 0) return null;
301
+ return `${count} banked reset${count === 1 ? "" : "s"}`;
287
302
  }
288
303
 
289
304
  function remainingResetSeconds(