@monotykamary/pi-better-openai 0.1.40 → 0.1.42
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 +1 -0
- package/index.ts +121 -0
- package/package.json +1 -1
- package/src/codex-auth.ts +26 -3
- package/src/config.ts +14 -0
- package/src/multiprovider.ts +66 -0
- package/src/resets.ts +249 -0
- package/src/usage-controller.ts +1 -0
- package/src/usage.ts +17 -2
package/README.md
CHANGED
|
@@ -26,6 +26,7 @@ Usage display, image generation, and live voice require pi's `openai-codex` OAut
|
|
|
26
26
|
2. Verify subscription usage with `/openai-usage`, or open `/openai-settings` and check **Diagnostics**.
|
|
27
27
|
3. The extension reads auth from pi's agent auth store, normally `~/.pi/agent/auth.json`. Do not copy, paste, or commit values from this file.
|
|
28
28
|
4. If `PI_CODING_AGENT_DIR` is set, the auth store, global extension config, and global generated-image directory use that agent directory instead of `~/.pi/agent`. A leading `~/` is expanded to your home directory.
|
|
29
|
+
5. When [pi-multiprovider](https://github.com/monotykamary/pi-multiprovider) 0.4.0+ pools several `openai-codex` accounts, the session's active account (chosen with `/switch-account`) is resolved first for usage display, image generation, web search, and live voice; the usage widget refreshes on every switch. Without that extension, credential resolution is unchanged.
|
|
29
30
|
|
|
30
31
|
## Features
|
|
31
32
|
|
package/index.ts
CHANGED
|
@@ -14,9 +14,17 @@ import {
|
|
|
14
14
|
} from "@earendil-works/pi-tui";
|
|
15
15
|
import { registerOpenAICodexModels } from "./src/codex-models.ts";
|
|
16
16
|
import { CONFIG_BASENAME, STATUS_KEY } from "./src/identity.ts";
|
|
17
|
+
import {
|
|
18
|
+
CODEX_PROVIDER_ID,
|
|
19
|
+
isMultiproviderService,
|
|
20
|
+
MULTIPROVIDER_SERVICE_EVENT,
|
|
21
|
+
setActiveMultiproviderService,
|
|
22
|
+
type MultiproviderService,
|
|
23
|
+
} from "./src/multiprovider.ts";
|
|
17
24
|
import {
|
|
18
25
|
formatTokens,
|
|
19
26
|
redactDiagnosticValue,
|
|
27
|
+
sanitizeDiagnosticError,
|
|
20
28
|
sanitizeStatusText,
|
|
21
29
|
truncateToWidth,
|
|
22
30
|
visibleWidth,
|
|
@@ -54,7 +62,19 @@ import {
|
|
|
54
62
|
formatUsageSnapshot,
|
|
55
63
|
parseUsageSnapshot,
|
|
56
64
|
readCodexAuth,
|
|
65
|
+
requestCodexUsage,
|
|
66
|
+
type UsageSnapshot,
|
|
57
67
|
} from "./src/usage.ts";
|
|
68
|
+
import {
|
|
69
|
+
buildBankedResetConfirmation,
|
|
70
|
+
consumeBankedReset,
|
|
71
|
+
formatBankedResetChoice,
|
|
72
|
+
formatConsumeOutcome,
|
|
73
|
+
newRedeemRequestId,
|
|
74
|
+
requestBankedResetCredits,
|
|
75
|
+
selectBankedResetCredit,
|
|
76
|
+
type BankedResetCredits,
|
|
77
|
+
} from "./src/resets.ts";
|
|
58
78
|
import { registerOpenAIImage, _imageTest } from "./src/image.ts";
|
|
59
79
|
import { registerOpenAIWebSearch, _websearchTest } from "./src/websearch.ts";
|
|
60
80
|
import { registerOpenAILive } from "./src/live/index.ts";
|
|
@@ -114,6 +134,7 @@ class DynamicBorder {
|
|
|
114
134
|
const COMMAND = "fast";
|
|
115
135
|
const OPENAI_STATUS_COMMAND = "openai-usage";
|
|
116
136
|
const OPENAI_SETTINGS_COMMAND = "openai-settings";
|
|
137
|
+
const OPENAI_RESETS_COMMAND = "openai-resets";
|
|
117
138
|
const FLAG = "fast";
|
|
118
139
|
const PET_EMPTY_VALUE = "not selected";
|
|
119
140
|
const SERVICE_TIER = "priority";
|
|
@@ -227,6 +248,32 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
|
|
|
227
248
|
let cachedSessionName: string | undefined;
|
|
228
249
|
const usageController = new UsageController(config, updateFooter);
|
|
229
250
|
const petController = new PetFooterController(config, updateFooter, () => footerInstalled);
|
|
251
|
+
let multiproviderService: MultiproviderService | undefined;
|
|
252
|
+
let unsubscribeMultiprovider: (() => void) | undefined;
|
|
253
|
+
let multiproviderRefreshCtx: ExtensionContext | undefined;
|
|
254
|
+
|
|
255
|
+
// Follow pi-multiprovider's active pooled account for openai-codex. The
|
|
256
|
+
// event re-fires with the same stable object at load and session start; the
|
|
257
|
+
// identity check keeps the change subscription attached exactly once. When
|
|
258
|
+
// the extension is absent, nothing here activates and credential resolution
|
|
259
|
+
// keeps its standalone behavior.
|
|
260
|
+
if (typeof pi.events?.on === "function") {
|
|
261
|
+
pi.events.on(MULTIPROVIDER_SERVICE_EVENT, (value) => {
|
|
262
|
+
if (!isMultiproviderService(value) || value === multiproviderService) return;
|
|
263
|
+
unsubscribeMultiprovider?.();
|
|
264
|
+
multiproviderService = value;
|
|
265
|
+
setActiveMultiproviderService(value);
|
|
266
|
+
unsubscribeMultiprovider = value.onActiveAccountChanged(CODEX_PROVIDER_ID, (event) => {
|
|
267
|
+
void usageController.refresh(event.ctx, undefined, { force: true });
|
|
268
|
+
updateFooter(event.ctx);
|
|
269
|
+
});
|
|
270
|
+
const ctx = multiproviderRefreshCtx;
|
|
271
|
+
if (ctx) {
|
|
272
|
+
void usageController.refresh(ctx, undefined, { force: true });
|
|
273
|
+
updateFooter(ctx);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
230
277
|
|
|
231
278
|
function refresh(ctx: ExtensionContext): ResolvedConfig {
|
|
232
279
|
cachedConfig = resolveConfig(ctx.cwd || process.cwd());
|
|
@@ -350,6 +397,71 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
|
|
|
350
397
|
].join("\n");
|
|
351
398
|
}
|
|
352
399
|
|
|
400
|
+
async function redeemBankedReset(ctx: ExtensionContext): Promise<void> {
|
|
401
|
+
if (!hasTerminalUI(ctx)) {
|
|
402
|
+
ctx.ui.notify("/openai-resets requires an interactive TUI session.", "warning");
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const timeoutSignal = AbortSignal.timeout(15_000);
|
|
406
|
+
const signal = ctx.signal ? AbortSignal.any([ctx.signal, timeoutSignal]) : timeoutSignal;
|
|
407
|
+
let credits: BankedResetCredits | undefined;
|
|
408
|
+
let snapshot: UsageSnapshot | undefined;
|
|
409
|
+
try {
|
|
410
|
+
const [creditResult, usageResult] = await Promise.all([
|
|
411
|
+
requestBankedResetCredits(ctx, signal),
|
|
412
|
+
requestCodexUsage(ctx, signal).catch(() => undefined),
|
|
413
|
+
]);
|
|
414
|
+
credits = creditResult;
|
|
415
|
+
snapshot = usageResult ? parseUsageSnapshot(usageResult, ctx.model?.id) : undefined;
|
|
416
|
+
} catch (error) {
|
|
417
|
+
ctx.ui.notify(
|
|
418
|
+
`Banked reset lookup failed: ${sanitizeDiagnosticError(
|
|
419
|
+
error instanceof Error ? error.message : String(error),
|
|
420
|
+
)}`,
|
|
421
|
+
"error",
|
|
422
|
+
);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
if (!credits || credits.availableCount <= 0) {
|
|
426
|
+
ctx.ui.notify("No banked Codex resets are available for this account.", "info");
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
const available = credits.credits.filter((credit) => credit.status === "available");
|
|
430
|
+
let selected = selectBankedResetCredit(credits.credits);
|
|
431
|
+
if (available.length > 1 && selected) {
|
|
432
|
+
const options = available.map((credit, index) => formatBankedResetChoice(credit, index));
|
|
433
|
+
const chosen = await ctx.ui.select("Redeem which banked reset?", options);
|
|
434
|
+
if (chosen === undefined) {
|
|
435
|
+
ctx.ui.notify("Banked reset redemption cancelled.", "info");
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
selected = available[Math.max(0, options.indexOf(chosen))];
|
|
439
|
+
}
|
|
440
|
+
const confirmation = buildBankedResetConfirmation({
|
|
441
|
+
credit: selected,
|
|
442
|
+
availableCount: credits.availableCount,
|
|
443
|
+
snapshot,
|
|
444
|
+
});
|
|
445
|
+
if (!(await ctx.ui.confirm(confirmation.title, confirmation.message))) {
|
|
446
|
+
ctx.ui.notify("Banked reset redemption cancelled.", "info");
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
try {
|
|
450
|
+
const result = await consumeBankedReset(ctx, selected?.id, newRedeemRequestId(), signal);
|
|
451
|
+
const outcome = formatConsumeOutcome(result);
|
|
452
|
+
ctx.ui.notify(outcome.message, outcome.level);
|
|
453
|
+
void usageController.refresh(ctx, ctx.model?.id, { force: true });
|
|
454
|
+
updateFooter(ctx);
|
|
455
|
+
} catch (error) {
|
|
456
|
+
ctx.ui.notify(
|
|
457
|
+
`Banked reset redemption failed: ${sanitizeDiagnosticError(
|
|
458
|
+
error instanceof Error ? error.message : String(error),
|
|
459
|
+
)}`,
|
|
460
|
+
"error",
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
353
465
|
pi.registerCommand(COMMAND, {
|
|
354
466
|
description: "Toggle OpenAI fast mode",
|
|
355
467
|
handler: async (args, ctx) => {
|
|
@@ -366,6 +478,13 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
|
|
|
366
478
|
},
|
|
367
479
|
});
|
|
368
480
|
|
|
481
|
+
pi.registerCommand(OPENAI_RESETS_COMMAND, {
|
|
482
|
+
description: "Inspect and redeem Codex banked rate-limit resets",
|
|
483
|
+
handler: async (_args, ctx) => {
|
|
484
|
+
await redeemBankedReset(ctx);
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
|
|
369
488
|
function buildPetSettingsItems(cfg: ResolvedConfig): SettingsPickerItem[] {
|
|
370
489
|
return settingsItemsFromDescriptors(PET_SETTING_DESCRIPTORS, cfg, {
|
|
371
490
|
"pets.slug": {
|
|
@@ -1261,6 +1380,7 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
|
|
|
1261
1380
|
pi.on("session_start", (_event, ctx) => {
|
|
1262
1381
|
invalidateContextUsage();
|
|
1263
1382
|
invalidateSessionName();
|
|
1383
|
+
multiproviderRefreshCtx = ctx;
|
|
1264
1384
|
const nextConfig = refresh(ctx);
|
|
1265
1385
|
fastController.initializeForSession(ctx, nextConfig, pi.getFlag(FLAG) === true);
|
|
1266
1386
|
if (
|
|
@@ -1347,6 +1467,7 @@ export default function betterOpenAI(pi: ExtensionAPI): void {
|
|
|
1347
1467
|
pi.on("session_shutdown", () => {
|
|
1348
1468
|
invalidateContextUsage();
|
|
1349
1469
|
invalidateSessionName();
|
|
1470
|
+
multiproviderRefreshCtx = undefined;
|
|
1350
1471
|
usageController.shutdown();
|
|
1351
1472
|
petController.shutdown();
|
|
1352
1473
|
});
|
package/package.json
CHANGED
package/src/codex-auth.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { CODEX_PROVIDER_ID, getActiveMultiproviderService } from "./multiprovider.ts";
|
|
4
5
|
import { piAgentDir } from "./paths.ts";
|
|
5
6
|
|
|
6
7
|
export const AUTH_FILE = join(piAgentDir(), "auth.json");
|
|
@@ -10,8 +11,13 @@ export type CodexCredentials = {
|
|
|
10
11
|
accountId: string;
|
|
11
12
|
};
|
|
12
13
|
|
|
14
|
+
export type CodexCredentialsContext = Pick<
|
|
15
|
+
ExtensionContext,
|
|
16
|
+
"modelRegistry" | "model" | "sessionManager"
|
|
17
|
+
>;
|
|
18
|
+
|
|
13
19
|
export type CodexCredentialsWithSource = CodexCredentials & {
|
|
14
|
-
source: "modelRegistry" | "authFile";
|
|
20
|
+
source: "multiprovider" | "modelRegistry" | "authFile";
|
|
15
21
|
};
|
|
16
22
|
|
|
17
23
|
function waitForSignal<T>(operation: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
@@ -118,11 +124,28 @@ export function readCodexAuth(): CodexCredentials | undefined {
|
|
|
118
124
|
}
|
|
119
125
|
|
|
120
126
|
export async function getCodexCredentials(
|
|
121
|
-
ctx?:
|
|
127
|
+
ctx?: CodexCredentialsContext,
|
|
122
128
|
signal?: AbortSignal,
|
|
123
129
|
): Promise<CodexCredentialsWithSource | undefined> {
|
|
124
130
|
if (signal?.aborted) throw signal.reason ?? new Error("Operation was aborted.");
|
|
125
|
-
|
|
131
|
+
// A pooled account pinned for this session (pi-multiprovider /switch-account)
|
|
132
|
+
// wins over pi's own credential: subscription usage is per-account.
|
|
133
|
+
const multiprovider = getActiveMultiproviderService();
|
|
134
|
+
if (multiprovider && ctx) {
|
|
135
|
+
try {
|
|
136
|
+
const resolved = await waitForSignal(
|
|
137
|
+
multiprovider.resolveActiveAccountAuth(CODEX_PROVIDER_ID, ctx, signal),
|
|
138
|
+
signal,
|
|
139
|
+
);
|
|
140
|
+
const accountId = resolved ? extractAccountIdFromJwt(resolved.accessToken) : undefined;
|
|
141
|
+
if (resolved && accountId) {
|
|
142
|
+
return { accessToken: resolved.accessToken, accountId, source: "multiprovider" };
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// Fall back to pi-owned credential resolution.
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const registryRequest = ctx?.modelRegistry?.getApiKeyForProvider(CODEX_PROVIDER_ID);
|
|
126
149
|
const registryToken = registryRequest
|
|
127
150
|
? await waitForSignal(
|
|
128
151
|
registryRequest.catch(() => undefined),
|
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,66 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
// Soft bridge to pi-multiprovider. When that extension is installed it emits
|
|
4
|
+
// this event with a service object; without it everything here stays inert and
|
|
5
|
+
// credential resolution keeps its standalone behavior.
|
|
6
|
+
export const MULTIPROVIDER_SERVICE_EVENT = "pi-multiprovider:service";
|
|
7
|
+
export const CODEX_PROVIDER_ID = "openai-codex";
|
|
8
|
+
|
|
9
|
+
export type MultiproviderActiveAccount = {
|
|
10
|
+
id: string;
|
|
11
|
+
label: string;
|
|
12
|
+
authKind: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type MultiproviderAccountAuth = {
|
|
16
|
+
accessToken: string;
|
|
17
|
+
label: string;
|
|
18
|
+
source?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type MultiproviderAccountChangedEvent = {
|
|
22
|
+
providerId: string;
|
|
23
|
+
account: MultiproviderActiveAccount | undefined;
|
|
24
|
+
ctx: ExtensionContext;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type MultiproviderServiceContext = Pick<
|
|
28
|
+
ExtensionContext,
|
|
29
|
+
"modelRegistry" | "model" | "sessionManager"
|
|
30
|
+
>;
|
|
31
|
+
|
|
32
|
+
export type MultiproviderService = {
|
|
33
|
+
getActiveAccount(
|
|
34
|
+
providerId: string,
|
|
35
|
+
ctx: MultiproviderServiceContext,
|
|
36
|
+
): Promise<MultiproviderActiveAccount | undefined>;
|
|
37
|
+
resolveActiveAccountAuth(
|
|
38
|
+
providerId: string,
|
|
39
|
+
ctx: MultiproviderServiceContext,
|
|
40
|
+
signal?: AbortSignal,
|
|
41
|
+
): Promise<MultiproviderAccountAuth | undefined>;
|
|
42
|
+
onActiveAccountChanged(
|
|
43
|
+
providerId: string,
|
|
44
|
+
callback: (event: MultiproviderAccountChangedEvent) => void,
|
|
45
|
+
): () => void;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function isMultiproviderService(value: unknown): value is MultiproviderService {
|
|
49
|
+
if (typeof value !== "object" || value === null) return false;
|
|
50
|
+
const candidate = value as Partial<MultiproviderService>;
|
|
51
|
+
return (
|
|
52
|
+
typeof candidate.getActiveAccount === "function" &&
|
|
53
|
+
typeof candidate.resolveActiveAccountAuth === "function" &&
|
|
54
|
+
typeof candidate.onActiveAccountChanged === "function"
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let activeService: MultiproviderService | undefined;
|
|
59
|
+
|
|
60
|
+
export function setActiveMultiproviderService(service: MultiproviderService | undefined): void {
|
|
61
|
+
activeService = service;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getActiveMultiproviderService(): MultiproviderService | undefined {
|
|
65
|
+
return activeService;
|
|
66
|
+
}
|
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
|
+
}
|
package/src/usage-controller.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|