@narumitw/pi-usage 0.49.3 β 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 +26 -5
- package/package.json +6 -3
- package/src/codex-resets.ts +308 -0
- package/src/index.ts +12 -0
- package/src/query.ts +14 -2
- package/src/usage.ts +293 -11
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 ./
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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",
|
|
@@ -35,14 +38,14 @@
|
|
|
35
38
|
},
|
|
36
39
|
"devDependencies": {
|
|
37
40
|
"@biomejs/biome": "2.5.7",
|
|
38
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
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": "
|
|
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
|
-
|
|
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(
|
|
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 (
|
|
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 =
|
|
471
|
-
|
|
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
|
});
|