@narumitw/pi-usage 0.49.2 β†’ 0.50.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,6 +9,7 @@
9
9
  - Opens one interactive `/usage` menu with current state and next actions.
10
10
  - Automatically queries the selected model provider and active runtime account.
11
11
  - Supports OpenAI Codex subscription windows, resets, credits, and model-specific buckets.
12
+ - Redeems earned Codex usage-limit resets for the active, matching Pi OAuth account with fresh availability, explicit confirmation, and idempotent retry.
12
13
  - Supports GitHub Copilot AI Credits, legacy premium requests, Free chat quota, additional usage, percentage, and reset time.
13
14
  - Supports OpenRouter per-key credit limits plus daily, weekly, monthly, and all-time spend.
14
15
  - Provides explicit refresh, another-provider, and all-configured-provider actions.
@@ -35,7 +36,7 @@ pi -e npm:@narumitw/pi-usage
35
36
  Try this package locally from the repository root:
36
37
 
37
38
  ```bash
38
- pi -e ./extensions/pi-usage
39
+ pi -e ./packages/pi-usage
39
40
  ```
40
41
 
41
42
  ## πŸš€ Usage
@@ -51,6 +52,7 @@ with these actions:
51
52
 
52
53
  ```text
53
54
  Refresh current usage
55
+ Redeem usage limit reset… # Current Codex OAuth accounts only
54
56
  View another configured provider…
55
57
  View all configured providers…
56
58
  Close
@@ -62,18 +64,35 @@ and closes the root menu. Print and JSON modes reject `/usage` observably becaus
62
64
  interactive flow. The cancellable live-query progress view remains extension-owned because it streams
63
65
  provider work and supports in-flight abort rather than presenting a standard menu screen.
64
66
 
67
+ For the current OpenAI Codex provider, **Redeem usage limit reset…** checks fresh earned-reset
68
+ details, lets you select a reset when details are available, and shows the exact reset before asking
69
+ for confirmation. **No, go back** is the safe default and cancellation before confirmation sends no
70
+ mutation. After confirmation, the reset operation cannot be cancelled from its progress view; session
71
+ replacement or shutdown still aborts owned work. A transport failure offers **Try again** with the
72
+ same redemption request ID so the backend can treat an uncertain retry idempotently. Successful,
73
+ already-completed, not-needed, and no-credit outcomes are reported separately, then usage and the
74
+ statusline are refreshed for the still-current account.
75
+
65
76
  ## πŸ“‹ Provider semantics
66
77
 
67
78
  ### OpenAI Codex
68
79
 
69
80
  - Provider ID: `openai-codex`
70
81
  - Semantics: ChatGPT consumer subscription limits
71
- - Source: the Codex usage endpoint using Pi's resolved runtime authorization
82
+ - Source: the Codex usage and earned-reset endpoints using Pi's resolved runtime authorization
72
83
  - Displayed data: returned duration-based windows, resets, credits, earned usage-limit resets, and additional model buckets
84
+ - Reset mutation: `POST /wham/rate-limit-reset-credits/consume` with a unique redemption request ID and, when available, the selected opaque credit ID
73
85
  - Statusline examples: `codex 59% 5h 61% wk` or `codex spark 100% 5h`
74
86
 
75
87
  The statusline selects a returned bucket that matches the current Codex model when one is available. Unlike `pi-codex-usage`, this successor intentionally has no Codex CLI fallback because the CLI may be logged into a different account than Pi's active runtime account.
76
88
 
89
+ Reset redemption is available only when Codex is the current provider and Pi's freshly resolved access
90
+ token exactly matches its stored OpenAI Codex OAuth credential. `pi-usage` forwards only the bearer
91
+ authorization and matching `chatgpt-account-id` to the official ChatGPT origin. API-key credentials,
92
+ configured-but-not-current Codex accounts, account changes during the flow, and custom/proxy origins
93
+ fail before mutation. Backend-provided titles and descriptions are sanitized for terminal display;
94
+ opaque credit and account IDs are never shown or persisted by the extension.
95
+
77
96
  ### GitHub Copilot
78
97
 
79
98
  - Provider ID: `github-copilot`
@@ -127,7 +146,8 @@ Behavior changes:
127
146
  ## 🚧 Limitations
128
147
 
129
148
  - Only providers with a meaningful usage source and verifiable Pi runtime auth are supported.
130
- - GitHub Copilot quota uses an undocumented GitHub endpoint that may change without notice.
149
+ - GitHub Copilot quota and OpenAI Codex reset redemption use undocumented provider endpoints that may change without notice.
150
+ - Codex reset redemption requires a current ChatGPT OAuth login created through Pi; Codex API keys cannot redeem earned subscription resets.
131
151
  - Credentials resolved for custom provider base URLs are never forwarded to the providers' official usage endpoints; effective auth origin validation requires Pi 0.81.0 or newer.
132
152
  - Provider reports are snapshots and may themselves be delayed by the provider.
133
153
  - OpenRouter successful inference responses do not expose proactive request-rate counters; `/usage` reports the documented per-key credit/spend fields instead.
@@ -137,11 +157,12 @@ Behavior changes:
137
157
  ## πŸ—‚οΈ Package layout
138
158
 
139
159
  ```txt
140
- extensions/pi-usage/
160
+ packages/pi-usage/
141
161
  β”œβ”€β”€ src/
142
162
  β”‚ β”œβ”€β”€ index.ts # Pi package entrypoint and helper export barrel
143
163
  β”‚ β”œβ”€β”€ usage.ts # Menu, cache, and lifecycle orchestration
144
- β”‚ β”œβ”€β”€ query.ts # Runtime auth resolution and provider queries
164
+ β”‚ β”œβ”€β”€ query.ts # Runtime auth resolution and bounded provider queries
165
+ β”‚ β”œβ”€β”€ codex-resets.ts # Codex reset auth, API contracts, and normalization
145
166
  β”‚ β”œβ”€β”€ format.ts # Provider-aware notifications and statusline text
146
167
  β”‚ β”œβ”€β”€ core.ts # Cache, concurrency, fingerprint, and redaction helpers
147
168
  β”‚ β”œβ”€β”€ providers/ # Codex, GitHub Copilot, and OpenRouter normalization adapters
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-usage",
3
- "version": "0.49.2",
3
+ "version": "0.50.0",
4
4
  "description": "Pi extension that shows current-account usage for Codex, GitHub Copilot, and OpenRouter.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,6 +25,9 @@
25
25
  "./src/index.ts"
26
26
  ]
27
27
  },
28
+ "piExtension": {
29
+ "lifecycle": "stable"
30
+ },
28
31
  "scripts": {
29
32
  "check": "biome check --vcs-use-ignore-file=false src test package.json tsconfig.json README.md && npm run typecheck",
30
33
  "format": "biome check --write --vcs-use-ignore-file=false src test package.json tsconfig.json README.md",
@@ -34,15 +37,15 @@
34
37
  "@earendil-works/pi-coding-agent": "*"
35
38
  },
36
39
  "devDependencies": {
37
- "@biomejs/biome": "2.5.6",
38
- "@earendil-works/pi-coding-agent": "0.83.0",
40
+ "@biomejs/biome": "2.5.7",
41
+ "@earendil-works/pi-coding-agent": "0.84.1",
39
42
  "@types/node": "26.1.2",
40
43
  "typescript": "7.0.2"
41
44
  },
42
45
  "repository": {
43
46
  "type": "git",
44
47
  "url": "https://github.com/narumiruna/pi-extensions",
45
- "directory": "extensions/pi-usage"
48
+ "directory": "packages/pi-usage"
46
49
  },
47
50
  "dependencies": {
48
51
  "@narumitw/pi-tui-kit": "^0.49.1"
@@ -0,0 +1,308 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { readStoredCredential } from "@earendil-works/pi-coding-agent";
3
+ import { fingerprintResolvedAuth, sanitizeDisplayText } from "./core.js";
4
+ import {
5
+ AUTH_FINGERPRINT_SALT,
6
+ adapterForProvider,
7
+ fetchProviderJson,
8
+ resolveUsageAuth,
9
+ } from "./query.js";
10
+ import type { ResolvedUsageAuth, UsageReport } from "./types.js";
11
+
12
+ const CODEX_RESET_CREDITS_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
13
+ const CODEX_RESET_CONSUME_URL = `${CODEX_RESET_CREDITS_URL}/consume`;
14
+ const MAX_RESET_OPTIONS = 32;
15
+ const MAX_CREDIT_ID_CHARS = 1_024;
16
+
17
+ type StoredCredentialReader = (providerId: string) => unknown;
18
+
19
+ export type CodexResetOutcomeCode = "reset" | "nothing_to_reset" | "no_credit" | "already_redeemed";
20
+
21
+ export interface CodexResetOption {
22
+ creditId?: string;
23
+ title: string;
24
+ description: string;
25
+ expiresAt?: number;
26
+ }
27
+
28
+ export interface CodexResetAvailability {
29
+ availableCount: number;
30
+ options: CodexResetOption[];
31
+ }
32
+
33
+ export interface CodexResetOutcome {
34
+ code: CodexResetOutcomeCode;
35
+ windowsReset: number;
36
+ }
37
+
38
+ export function codexResetCount(report: UsageReport): number | undefined {
39
+ const value = report.metrics.find((metric) => metric.id === "reset-credits")?.value;
40
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : undefined;
41
+ }
42
+
43
+ export function codexResetActionDescription(report: UsageReport): string {
44
+ const count = codexResetCount(report);
45
+ if (count === undefined) return "Check reset availability.";
46
+ if (count === 0) return "No usage limit resets available.";
47
+ return `You have ${count} ${resetLabel(count)} available.`;
48
+ }
49
+
50
+ export function genericCodexResetOption(): CodexResetOption {
51
+ return {
52
+ title: "Full reset",
53
+ description: "Reset your current usage limits.",
54
+ };
55
+ }
56
+
57
+ export function resetOptionExpiration(option: CodexResetOption): string {
58
+ if (option.expiresAt === undefined) return "Does not expire.";
59
+ const expiration = new Date(option.expiresAt * 1_000);
60
+ if (Number.isNaN(expiration.getTime())) return "Expiration unavailable.";
61
+ return `Expires ${expiration.toLocaleString()}.`;
62
+ }
63
+
64
+ export function resetConfirmationLines(option: CodexResetOption | undefined): string[] {
65
+ if (!option) return ["The selected reset is unavailable."];
66
+ return [
67
+ option.title,
68
+ resetOptionExpiration(option),
69
+ option.description,
70
+ "This consumes one earned reset for the current OpenAI Codex account.",
71
+ ];
72
+ }
73
+
74
+ export function formatCodexResetOutcome(
75
+ outcome: CodexResetOutcome | undefined,
76
+ remainingCount: number | undefined,
77
+ ): string {
78
+ const remaining =
79
+ remainingCount === undefined
80
+ ? ""
81
+ : ` You have ${remainingCount} ${resetLabel(remainingCount)} left.`;
82
+ if (!outcome) return "You don't have any usage limit resets available.";
83
+ if (outcome.code === "reset") return `Usage reset.${remaining}`.trim();
84
+ if (outcome.code === "already_redeemed") {
85
+ return `Usage reset was already completed.${remaining}`.trim();
86
+ }
87
+ if (outcome.code === "nothing_to_reset") {
88
+ return "Your usage does not need a reset right now.";
89
+ }
90
+ return "No usage limit resets are available.";
91
+ }
92
+
93
+ export function resetLabel(count: number): string {
94
+ return count === 1 ? "usage limit reset" : "usage limit resets";
95
+ }
96
+
97
+ export async function resolveCodexResetAuth(
98
+ ctx: ExtensionContext,
99
+ salt: Uint8Array = AUTH_FINGERPRINT_SALT,
100
+ credentialReader: StoredCredentialReader = readStoredCredential,
101
+ ): Promise<ResolvedUsageAuth> {
102
+ const model = ctx.model;
103
+ if (model?.provider !== "openai-codex") {
104
+ throw new Error("Usage limit resets require the current model to use OpenAI Codex.");
105
+ }
106
+ const expectedModel = `${model.provider}/${model.id}`;
107
+ const adapter = adapterForProvider("openai-codex");
108
+ if (!adapter) throw new Error("OpenAI Codex usage support is unavailable.");
109
+ const auth = await resolveUsageAuth(ctx, adapter, salt, credentialReader);
110
+ if (`${ctx.model?.provider}/${ctx.model?.id}` !== expectedModel) {
111
+ throw new Error("The current model changed while resolving Codex reset authentication.");
112
+ }
113
+ if (!auth) throw new Error("No runtime credential is configured for OpenAI Codex.");
114
+
115
+ const credential = asObject(credentialReader("openai-codex"));
116
+ if (credential?.type !== "oauth") {
117
+ throw new Error(
118
+ "Usage limit resets require the OpenAI Codex OAuth account configured through Pi /login.",
119
+ );
120
+ }
121
+ const storedAccess = asNonemptyString(credential.access);
122
+ const resolvedAccess = bearerToken(headerValue(auth.headers, "Authorization")) ?? auth.apiKey;
123
+ if (!storedAccess || !resolvedAccess) {
124
+ throw new Error("OpenAI Codex OAuth credentials were incomplete.");
125
+ }
126
+ if (storedAccess !== resolvedAccess) {
127
+ throw new Error(
128
+ "The active OpenAI Codex runtime account does not match Pi's stored OAuth account.",
129
+ );
130
+ }
131
+ const accountId = validHeaderValue(credential.accountId);
132
+ if (!accountId)
133
+ throw new Error("The OpenAI Codex OAuth credential did not include a valid account ID.");
134
+
135
+ const authorization = `Bearer ${resolvedAccess}`;
136
+ const headers = {
137
+ Authorization: authorization,
138
+ "chatgpt-account-id": accountId,
139
+ };
140
+ return {
141
+ apiKey: resolvedAccess,
142
+ headers,
143
+ fingerprint: fingerprintResolvedAuth({ headers }, salt),
144
+ secrets: [
145
+ ...new Set([...auth.secrets, storedAccess, resolvedAccess, authorization, accountId]),
146
+ ],
147
+ model: auth.model,
148
+ };
149
+ }
150
+
151
+ export async function listCodexResetCredits(
152
+ auth: ResolvedUsageAuth,
153
+ signal: AbortSignal,
154
+ timeoutMs: number,
155
+ ): Promise<CodexResetAvailability> {
156
+ const payload = await fetchProviderJson(
157
+ CODEX_RESET_CREDITS_URL,
158
+ auth,
159
+ signal,
160
+ timeoutMs,
161
+ "Codex usage-limit reset endpoint",
162
+ );
163
+ return normalizeCodexResetCreditsPayload(payload);
164
+ }
165
+
166
+ export async function consumeCodexResetCredit(
167
+ auth: ResolvedUsageAuth,
168
+ option: CodexResetOption,
169
+ redeemRequestId: string,
170
+ signal: AbortSignal,
171
+ timeoutMs: number,
172
+ ): Promise<CodexResetOutcome> {
173
+ if (!redeemRequestId) throw new Error("Codex reset redemption request ID must not be empty.");
174
+ const payload = await fetchProviderJson(
175
+ CODEX_RESET_CONSUME_URL,
176
+ auth,
177
+ signal,
178
+ timeoutMs,
179
+ "Codex usage-limit reset consume endpoint",
180
+ {
181
+ method: "POST",
182
+ body: {
183
+ redeem_request_id: redeemRequestId,
184
+ ...(option.creditId ? { credit_id: option.creditId } : {}),
185
+ },
186
+ },
187
+ );
188
+ const code = payload.code;
189
+ if (!isCodexResetOutcomeCode(code)) {
190
+ throw new Error("Codex reset consume endpoint returned an unknown outcome code.");
191
+ }
192
+ const windowsReset =
193
+ payload.windows_reset === undefined ? 0 : nonnegativeInteger(payload.windows_reset);
194
+ if (windowsReset === undefined) {
195
+ throw new Error("Codex reset consume endpoint returned an invalid windows_reset value.");
196
+ }
197
+ return { code, windowsReset };
198
+ }
199
+
200
+ export function normalizeCodexResetCreditsPayload(
201
+ payload: Record<string, unknown>,
202
+ ): CodexResetAvailability {
203
+ const availableCount = nonnegativeInteger(payload.available_count);
204
+ if (availableCount === undefined) {
205
+ throw new Error("Codex reset credits response returned an invalid available_count.");
206
+ }
207
+ const rawCredits = payload.credits;
208
+ if (rawCredits !== undefined && !Array.isArray(rawCredits)) {
209
+ throw new Error("Codex reset credits response returned invalid credits.");
210
+ }
211
+
212
+ const options = (rawCredits ?? [])
213
+ .map(asObject)
214
+ .filter((credit): credit is Record<string, unknown> => Boolean(credit))
215
+ .filter((credit) => credit.status === "available" && credit.reset_type === "codex_rate_limits")
216
+ .map(normalizeResetOption)
217
+ .sort(
218
+ (left, right) =>
219
+ (left.expiresAt ?? Number.MAX_SAFE_INTEGER) - (right.expiresAt ?? Number.MAX_SAFE_INTEGER),
220
+ )
221
+ .slice(0, Math.min(availableCount, MAX_RESET_OPTIONS));
222
+
223
+ if (availableCount > 0 && options.length === 0) {
224
+ options.push(genericCodexResetOption());
225
+ }
226
+ return { availableCount, options };
227
+ }
228
+
229
+ function normalizeResetOption(credit: Record<string, unknown>): CodexResetOption {
230
+ const creditId = asOpaqueId(credit.id);
231
+ if (!creditId) throw new Error("Codex reset credits response returned an invalid credit ID.");
232
+ let expiresAt: number | undefined;
233
+ if (credit.expires_at !== undefined && credit.expires_at !== null) {
234
+ if (typeof credit.expires_at !== "string") {
235
+ throw new Error("Codex reset credits response returned an invalid expiration time.");
236
+ }
237
+ const parsed = Date.parse(credit.expires_at);
238
+ if (!Number.isFinite(parsed)) {
239
+ throw new Error("Codex reset credits response returned an invalid expiration time.");
240
+ }
241
+ expiresAt = Math.floor(parsed / 1_000);
242
+ }
243
+ const title = displayString(credit.title) ?? "Full reset";
244
+ const description = displayString(credit.description) ?? "Reset your current usage limits.";
245
+ return {
246
+ creditId,
247
+ title,
248
+ description,
249
+ ...(expiresAt === undefined ? {} : { expiresAt }),
250
+ };
251
+ }
252
+
253
+ function isCodexResetOutcomeCode(value: unknown): value is CodexResetOutcomeCode {
254
+ return (
255
+ value === "reset" ||
256
+ value === "nothing_to_reset" ||
257
+ value === "no_credit" ||
258
+ value === "already_redeemed"
259
+ );
260
+ }
261
+
262
+ function asObject(value: unknown): Record<string, unknown> | undefined {
263
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
264
+ return value as Record<string, unknown>;
265
+ }
266
+
267
+ function asNonemptyString(value: unknown): string | undefined {
268
+ return typeof value === "string" && value.length > 0 ? value : undefined;
269
+ }
270
+
271
+ function asOpaqueId(value: unknown): string | undefined {
272
+ if (typeof value !== "string" || value.length === 0 || value.length > MAX_CREDIT_ID_CHARS) {
273
+ return undefined;
274
+ }
275
+ return value;
276
+ }
277
+
278
+ function displayString(value: unknown): string | undefined {
279
+ if (typeof value !== "string") return undefined;
280
+ return sanitizeDisplayText(value, 160) || undefined;
281
+ }
282
+
283
+ function validHeaderValue(value: unknown): string | undefined {
284
+ if (typeof value !== "string" || !value || value.length > 512) return undefined;
285
+ if (/[^\x20-\x7e]/u.test(value)) return undefined;
286
+ return value;
287
+ }
288
+
289
+ function nonnegativeInteger(value: unknown): number | undefined {
290
+ const parsed =
291
+ typeof value === "number"
292
+ ? value
293
+ : typeof value === "string" && value.trim()
294
+ ? Number(value)
295
+ : Number.NaN;
296
+ if (!Number.isSafeInteger(parsed) || parsed < 0) return undefined;
297
+ return parsed;
298
+ }
299
+
300
+ function bearerToken(authorization: string | undefined): string | undefined {
301
+ return /^Bearer\s+(.+)$/iu.exec(authorization ?? "")?.[1];
302
+ }
303
+
304
+ function headerValue(headers: Record<string, string>, name: string): string | undefined {
305
+ return Object.entries(headers).find(
306
+ ([candidate]) => candidate.toLowerCase() === name.toLowerCase(),
307
+ )?.[1];
308
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,15 @@
1
+ export type {
2
+ CodexResetAvailability,
3
+ CodexResetOption,
4
+ CodexResetOutcome,
5
+ CodexResetOutcomeCode,
6
+ } from "./codex-resets.js";
7
+ export {
8
+ consumeCodexResetCredit,
9
+ listCodexResetCredits,
10
+ normalizeCodexResetCreditsPayload,
11
+ resolveCodexResetAuth,
12
+ } from "./codex-resets.js";
1
13
  export {
2
14
  abortError,
3
15
  awaitWithDeadline,
package/src/query.ts CHANGED
@@ -182,12 +182,16 @@ function candidateModels(ctx: ExtensionContext, providerId: string): PiModel[] {
182
182
  return candidates;
183
183
  }
184
184
 
185
- async function fetchProviderJson(
185
+ export async function fetchProviderJson(
186
186
  url: string,
187
187
  auth: ResolvedUsageAuth,
188
188
  signal: AbortSignal,
189
189
  timeoutMs: number,
190
190
  description: string,
191
+ request: {
192
+ method?: "GET" | "POST";
193
+ body?: Record<string, unknown>;
194
+ } = {},
191
195
  ): Promise<Record<string, unknown>> {
192
196
  const controller = new AbortController();
193
197
  let timedOut = false;
@@ -201,7 +205,15 @@ async function fetchProviderJson(
201
205
  try {
202
206
  const headers = { ...auth.headers };
203
207
  if (!hasHeader(headers, "User-Agent")) headers["User-Agent"] = "pi-usage";
204
- const response = await fetch(url, { headers, signal: controller.signal });
208
+ if (request.body && !hasHeader(headers, "Content-Type")) {
209
+ headers["Content-Type"] = "application/json";
210
+ }
211
+ const response = await fetch(url, {
212
+ method: request.method ?? "GET",
213
+ headers,
214
+ ...(request.body ? { body: JSON.stringify(request.body) } : {}),
215
+ signal: controller.signal,
216
+ });
205
217
  if (controller.signal.aborted)
206
218
  throw Object.assign(new Error("Usage query aborted."), { name: "AbortError" });
207
219
  const text = await readBoundedResponse(
package/src/usage.ts CHANGED
@@ -1,8 +1,24 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import type {
2
3
  ExtensionAPI,
3
4
  ExtensionCommandContext,
4
5
  ExtensionContext,
5
6
  } from "@earendil-works/pi-coding-agent";
7
+ import {
8
+ type CodexResetAvailability,
9
+ type CodexResetOption,
10
+ type CodexResetOutcome,
11
+ codexResetActionDescription,
12
+ codexResetCount,
13
+ consumeCodexResetCredit,
14
+ formatCodexResetOutcome,
15
+ genericCodexResetOption,
16
+ listCodexResetCredits,
17
+ resetConfirmationLines,
18
+ resetLabel,
19
+ resetOptionExpiration,
20
+ resolveCodexResetAuth,
21
+ } from "./codex-resets.js";
6
22
  import {
7
23
  awaitWithDeadline,
8
24
  errorMessage,
@@ -38,6 +54,12 @@ const REFRESH_CURRENT = "Refresh current usage";
38
54
  const VIEW_ANOTHER = "View another configured provider…";
39
55
  const VIEW_ALL = "View all configured providers…";
40
56
  const CLOSE = "Close";
57
+ const REDEEM_CODEX_RESET = "Redeem usage limit reset…";
58
+
59
+ type UsageExtensionDependencies = {
60
+ credentialReader?: (providerId: string) => unknown;
61
+ createRedemptionId?: () => string;
62
+ };
41
63
 
42
64
  type QueryOutcome = {
43
65
  state: ProviderUsageState;
@@ -50,7 +72,12 @@ type StableCurrent = {
50
72
  model: PiModel | undefined;
51
73
  };
52
74
 
53
- export default function usageExtension(pi: ExtensionAPI) {
75
+ export default function usageExtension(
76
+ pi: ExtensionAPI,
77
+ dependencies: UsageExtensionDependencies = {},
78
+ ) {
79
+ const credentialReader = dependencies.credentialReader;
80
+ const createRedemptionId = dependencies.createRedemptionId ?? randomUUID;
54
81
  const cache = new UsageCache(CACHE_TTL_MS);
55
82
  const failureBackoff = new Map<string, { until: number; message: string }>();
56
83
  const latestQueries = new Map<string, number>();
@@ -123,6 +150,16 @@ export default function usageExtension(pi: ExtensionAPI) {
123
150
  if (shouldSchedule && sessionActive) scheduleStatusRefresh(ctx, model);
124
151
  };
125
152
 
153
+ const invalidateProviderState = (providerId: string) => {
154
+ cache.clearProvider(providerId);
155
+ for (const key of failureBackoff.keys()) {
156
+ if (key.startsWith(`${providerId}:`)) failureBackoff.delete(key);
157
+ }
158
+ for (const key of latestQueries.keys()) {
159
+ if (key.startsWith(`${providerId}:`)) latestQueries.delete(key);
160
+ }
161
+ };
162
+
126
163
  const transitionCurrentIdentity = (nextIdentity: string, providerId: string) => {
127
164
  if (!activeCurrentIdentity || activeCurrentIdentity === nextIdentity) {
128
165
  activeCurrentIdentity = nextIdentity;
@@ -130,14 +167,7 @@ export default function usageExtension(pi: ExtensionAPI) {
130
167
  }
131
168
  const previousProviderId = activeCurrentIdentity.split(":", 1)[0] ?? "";
132
169
  for (const id of new Set([previousProviderId, providerId])) {
133
- if (!id) continue;
134
- cache.clearProvider(id);
135
- for (const key of failureBackoff.keys()) {
136
- if (key.startsWith(`${id}:`)) failureBackoff.delete(key);
137
- }
138
- for (const key of latestQueries.keys()) {
139
- if (key.startsWith(`${id}:`)) latestQueries.delete(key);
140
- }
170
+ if (id) invalidateProviderState(id);
141
171
  }
142
172
  activeCurrentIdentity = nextIdentity;
143
173
  };
@@ -346,12 +376,14 @@ export default function usageExtension(pi: ExtensionAPI) {
346
376
  label: string,
347
377
  parentSignal: AbortSignal,
348
378
  operation: (signal: AbortSignal) => Promise<T>,
379
+ cancellable = true,
349
380
  ): Promise<T | undefined> => {
350
381
  const { runTask } = await import("@narumitw/pi-tui-kit");
351
382
  if (parentSignal.aborted) return undefined;
352
383
  const result = await runTask(ctx, {
353
384
  label,
354
385
  signal: parentSignal,
386
+ cancellable,
355
387
  onError: () => undefined,
356
388
  task: ({ signal }) => operation(signal),
357
389
  });
@@ -465,10 +497,33 @@ export default function usageExtension(pi: ExtensionAPI) {
465
497
  publishStableCurrent(ctx, stableCurrent);
466
498
  let current = stableCurrent.outcome;
467
499
  let visibleStates: ProviderUsageState[] = [current.state];
500
+ let resetAvailability: CodexResetAvailability | undefined;
501
+ let selectedReset: CodexResetOption | undefined;
502
+ let resetAuthFingerprint: string | undefined;
503
+ let resetModelIdentity: string | undefined;
504
+ let redemptionId: string | undefined;
505
+ let resetOutcome: CodexResetOutcome | undefined;
506
+ let resetFailure: string | undefined;
468
507
  const { defineMenu, runMenu } = await import("@narumitw/pi-tui-kit");
469
508
  if (controller.signal.aborted || statusGeneration !== menuGeneration) return;
470
- type Screen = "main" | "providers";
471
- type Action = "refresh" | "another" | "all" | "provider";
509
+ type Screen =
510
+ | "main"
511
+ | "providers"
512
+ | "reset-picker"
513
+ | "reset-confirm"
514
+ | "reset-result"
515
+ | "reset-error";
516
+ type Action =
517
+ | "refresh"
518
+ | "another"
519
+ | "all"
520
+ | "provider"
521
+ | "open-resets"
522
+ | "select-reset"
523
+ | "cancel-reset"
524
+ | "consume-reset"
525
+ | "back-to-usage"
526
+ | "back-to-resets";
472
527
  const menu = defineMenu<undefined, Screen, Action, ExtensionCommandContext>({
473
528
  start: "main",
474
529
  screens: {
@@ -478,6 +533,17 @@ export default function usageExtension(pi: ExtensionAPI) {
478
533
  lines: formatProviderStates(visibleStates).split("\n"),
479
534
  items: [
480
535
  { id: "refresh", label: REFRESH_CURRENT, action: "refresh" },
536
+ ...(current.state.status === "ready" && current.state.providerId === "openai-codex"
537
+ ? [
538
+ {
539
+ id: "open-resets",
540
+ label: REDEEM_CODEX_RESET,
541
+ description: codexResetActionDescription(current.state.report),
542
+ disabled: codexResetCount(current.state.report) === 0,
543
+ action: "open-resets" as const,
544
+ },
545
+ ]
546
+ : []),
481
547
  { id: "another", label: VIEW_ANOTHER, action: "another" },
482
548
  { id: "all", label: VIEW_ALL, action: "all" },
483
549
  { id: "close", label: CLOSE, close: true },
@@ -496,8 +562,219 @@ export default function usageExtension(pi: ExtensionAPI) {
496
562
  })),
497
563
  hint: "back",
498
564
  }),
565
+ "reset-picker": () => ({
566
+ kind: "choice",
567
+ title: "Usage limit resets",
568
+ lines: [
569
+ `${resetAvailability?.availableCount ?? 0} ${resetLabel(resetAvailability?.availableCount ?? 0)} available.`,
570
+ ],
571
+ items: (resetAvailability?.options ?? []).map((option, index) => ({
572
+ id: `reset-${index}`,
573
+ label: option.title,
574
+ description: resetOptionExpiration(option),
575
+ details: [option.description],
576
+ })),
577
+ action: "select-reset",
578
+ initialItemId: "reset-0",
579
+ hint: "back",
580
+ }),
581
+ "reset-confirm": () => ({
582
+ kind: "actions",
583
+ title: "Use this reset?",
584
+ lines: resetConfirmationLines(selectedReset),
585
+ items: [
586
+ { id: "cancel-reset", label: "No, go back", action: "cancel-reset" },
587
+ { id: "consume-reset", label: "Yes, use reset", action: "consume-reset" },
588
+ ],
589
+ hint: "back",
590
+ }),
591
+ "reset-result": () => ({
592
+ kind: "actions",
593
+ title: "Usage limit resets",
594
+ lines: [
595
+ formatCodexResetOutcome(
596
+ resetOutcome,
597
+ current.state.status === "ready"
598
+ ? codexResetCount(current.state.report)
599
+ : undefined,
600
+ ),
601
+ ],
602
+ items: [
603
+ {
604
+ id: "back-to-usage",
605
+ label: "Back to usage",
606
+ action: "back-to-usage",
607
+ },
608
+ { id: "close", label: CLOSE, close: true },
609
+ ],
610
+ hint: "back",
611
+ }),
612
+ "reset-error": () => ({
613
+ kind: "actions",
614
+ title: "Usage limit resets",
615
+ lines: [resetFailure ?? "Couldn't reset usage. Please try again."],
616
+ items: [
617
+ { id: "consume-reset", label: "Try again", action: "consume-reset" },
618
+ { id: "back-to-resets", label: "Back", action: "back-to-resets" },
619
+ ],
620
+ hint: "back",
621
+ }),
499
622
  },
500
623
  actions: {
624
+ "open-resets": async () => {
625
+ const summaryCount =
626
+ current.state.status === "ready" && current.state.providerId === "openai-codex"
627
+ ? codexResetCount(current.state.report)
628
+ : undefined;
629
+ try {
630
+ const loaded = await runMenuOperation(
631
+ ctx,
632
+ "Checking usage limit resets…",
633
+ controller.signal,
634
+ async (signal) => {
635
+ const expectedModel = modelIdentity(ctx.model);
636
+ const auth = await awaitWithDeadline(
637
+ resolveCodexResetAuth(ctx, undefined, credentialReader),
638
+ signal,
639
+ DEFAULT_TIMEOUT_MS,
640
+ "resolving current Codex reset authentication",
641
+ );
642
+ let availability: CodexResetAvailability;
643
+ try {
644
+ availability = await listCodexResetCredits(auth, signal, DEFAULT_TIMEOUT_MS);
645
+ } catch (error) {
646
+ if (isAbortError(error) || summaryCount === undefined || summaryCount <= 0) {
647
+ throw error;
648
+ }
649
+ availability = {
650
+ availableCount: summaryCount,
651
+ options: [genericCodexResetOption()],
652
+ };
653
+ }
654
+ const revalidated = await awaitWithDeadline(
655
+ resolveCodexResetAuth(ctx, undefined, credentialReader),
656
+ signal,
657
+ DEFAULT_TIMEOUT_MS,
658
+ "revalidating current Codex reset authentication",
659
+ );
660
+ if (
661
+ modelIdentity(ctx.model) !== expectedModel ||
662
+ revalidated.fingerprint !== auth.fingerprint
663
+ ) {
664
+ throw new Error(
665
+ "The active Codex model or account changed while loading usage limit resets.",
666
+ );
667
+ }
668
+ return { availability, auth, expectedModel };
669
+ },
670
+ );
671
+ if (!loaded) return { kind: "stay" };
672
+ resetAvailability = loaded.availability;
673
+ resetAuthFingerprint = loaded.auth.fingerprint;
674
+ resetModelIdentity = loaded.expectedModel;
675
+ selectedReset = undefined;
676
+ redemptionId = undefined;
677
+ resetOutcome = undefined;
678
+ resetFailure = undefined;
679
+ return {
680
+ kind: "to",
681
+ screen: loaded.availability.availableCount > 0 ? "reset-picker" : "reset-result",
682
+ };
683
+ } catch (error) {
684
+ if (isAbortError(error) || isStaleExtensionContextError(error)) {
685
+ return { kind: "stay" };
686
+ }
687
+ ctx.ui.notify(`Couldn't load usage limit resets: ${errorMessage(error)}`, "error");
688
+ return { kind: "stay" };
689
+ }
690
+ },
691
+ "select-reset": ({ itemId }) => {
692
+ const index = Number(itemId.replace(/^reset-/u, ""));
693
+ const option = Number.isSafeInteger(index)
694
+ ? resetAvailability?.options[index]
695
+ : undefined;
696
+ if (!option) return { kind: "rejected" };
697
+ selectedReset = option;
698
+ redemptionId = undefined;
699
+ resetFailure = undefined;
700
+ return { kind: "to", screen: "reset-confirm" };
701
+ },
702
+ "cancel-reset": () => ({ kind: "back" }),
703
+ "consume-reset": async () => {
704
+ if (!selectedReset || !resetAuthFingerprint || !resetModelIdentity) {
705
+ return { kind: "rejected" };
706
+ }
707
+ try {
708
+ redemptionId ??= createRedemptionId();
709
+ const attemptId = redemptionId;
710
+ const option = selectedReset;
711
+ const expectedFingerprint = resetAuthFingerprint;
712
+ const expectedModel = resetModelIdentity;
713
+ const result = await runMenuOperation(
714
+ ctx,
715
+ "Resetting your usage…",
716
+ controller.signal,
717
+ async (signal) => {
718
+ const auth = await awaitWithDeadline(
719
+ resolveCodexResetAuth(ctx, undefined, credentialReader),
720
+ signal,
721
+ DEFAULT_TIMEOUT_MS,
722
+ "revalidating current Codex reset authentication",
723
+ );
724
+ if (
725
+ modelIdentity(ctx.model) !== expectedModel ||
726
+ auth.fingerprint !== expectedFingerprint
727
+ ) {
728
+ throw new Error(
729
+ "The active Codex model or account changed; the reset was not used.",
730
+ );
731
+ }
732
+ const outcome = await consumeCodexResetCredit(
733
+ auth,
734
+ option,
735
+ attemptId,
736
+ signal,
737
+ DEFAULT_TIMEOUT_MS,
738
+ );
739
+ invalidateProviderState("openai-codex");
740
+ const model = ctx.model;
741
+ if (modelIdentity(model) !== expectedModel) return { outcome };
742
+ const refreshed = await queryCurrentState(ctx, model, true, signal);
743
+ const stable = await outcomeStillCurrent(
744
+ ctx,
745
+ model,
746
+ menuGeneration,
747
+ refreshed,
748
+ signal,
749
+ );
750
+ return stable ? { outcome, refreshed, model } : { outcome };
751
+ },
752
+ false,
753
+ );
754
+ if (!result) return { kind: "close" };
755
+ resetOutcome = result.outcome;
756
+ resetFailure = undefined;
757
+ if (result.refreshed && result.model) {
758
+ stableCurrent = { outcome: result.refreshed, model: result.model };
759
+ current = result.refreshed;
760
+ visibleStates = [current.state];
761
+ publishStableCurrent(ctx, stableCurrent);
762
+ }
763
+ return { kind: "to", screen: "reset-result" };
764
+ } catch (error) {
765
+ if (isAbortError(error) || isStaleExtensionContextError(error)) {
766
+ return { kind: "close" };
767
+ }
768
+ resetFailure = `Couldn't reset usage: ${errorMessage(error)}. Try again with the same request.`;
769
+ return { kind: "to", screen: "reset-error" };
770
+ }
771
+ },
772
+ "back-to-usage": () => ({ kind: "to", screen: "main" }),
773
+ "back-to-resets": () => {
774
+ redemptionId = undefined;
775
+ resetFailure = undefined;
776
+ return { kind: "to", screen: "reset-picker" };
777
+ },
501
778
  refresh: async () => {
502
779
  const refreshed = await queryStableCurrent(
503
780
  ctx,
@@ -633,6 +910,11 @@ export default function usageExtension(pi: ExtensionAPI) {
633
910
  });
634
911
 
635
912
  pi.on("session_start", (_event, ctx) => {
913
+ statusGeneration += 1;
914
+ clearStatusTimer();
915
+ for (const controller of activeControllers) controller.abort();
916
+ activeControllers.clear();
917
+ statusController = undefined;
636
918
  sessionActive = true;
637
919
  startStatusRefresh(ctx, ctx.model, false);
638
920
  });