pi-better-openai 0.1.18 → 0.1.21
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 +90 -1
- package/index.ts +308 -1058
- package/package.json +9 -4
- package/src/codex-auth.ts +136 -0
- package/src/config.ts +297 -2
- package/src/fast-controller.ts +109 -0
- package/src/format.ts +81 -20
- package/src/image.ts +182 -200
- package/src/paths.ts +17 -0
- package/src/pet-footer-controller.ts +627 -0
- package/src/pets.ts +212 -63
- package/src/usage-controller.ts +299 -0
- package/src/usage.ts +99 -48
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ResolvedConfig } from "./config.ts";
|
|
3
|
+
import { maskIdentifier, sanitizeDiagnosticError } from "./format.ts";
|
|
4
|
+
import {
|
|
5
|
+
AUTH_FILE,
|
|
6
|
+
type UsageSnapshot,
|
|
7
|
+
formatResetCountdown,
|
|
8
|
+
formatUsageSnapshot,
|
|
9
|
+
parseUsageSnapshot,
|
|
10
|
+
readCodexAuth,
|
|
11
|
+
requestCodexUsage,
|
|
12
|
+
usageScopeForModel,
|
|
13
|
+
} from "./usage.ts";
|
|
14
|
+
import { currentModelKey } from "./fast-controller.ts";
|
|
15
|
+
|
|
16
|
+
export function isOpenAISubscriptionModel(
|
|
17
|
+
ctx: ExtensionContext,
|
|
18
|
+
cfg: ResolvedConfig,
|
|
19
|
+
isUsingOAuth?: boolean,
|
|
20
|
+
): boolean {
|
|
21
|
+
const model = ctx.model;
|
|
22
|
+
if (!model || (model.provider !== "openai" && model.provider !== "openai-codex")) return false;
|
|
23
|
+
return (
|
|
24
|
+
!cfg.usage.showOnlyOnSubscriptionModels ||
|
|
25
|
+
(isUsingOAuth ?? ctx.modelRegistry.isUsingOAuth(model))
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const STALE_EXTENSION_CONTEXT_MESSAGE = "This extension ctx is stale";
|
|
30
|
+
|
|
31
|
+
function isStaleExtensionContextError(error: unknown): boolean {
|
|
32
|
+
return error instanceof Error && error.message.includes(STALE_EXTENSION_CONTEXT_MESSAGE);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type UsageRefreshOptions = { notify?: boolean; force?: boolean };
|
|
36
|
+
|
|
37
|
+
type QueuedUsageRefresh = {
|
|
38
|
+
ctx: ExtensionContext;
|
|
39
|
+
generation: number;
|
|
40
|
+
modelId?: string;
|
|
41
|
+
notify?: boolean;
|
|
42
|
+
force?: boolean;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export class UsageController {
|
|
46
|
+
private usageSnapshot: UsageSnapshot | undefined;
|
|
47
|
+
private usageUpdatedAt: number | undefined;
|
|
48
|
+
private usageError: string | undefined;
|
|
49
|
+
private usageLastFetchAt: number | undefined;
|
|
50
|
+
private usageTimer: ReturnType<typeof setInterval> | undefined;
|
|
51
|
+
private usageRefreshInFlight = false;
|
|
52
|
+
private queuedUsageRefresh: QueuedUsageRefresh | undefined;
|
|
53
|
+
private shuttingDown = false;
|
|
54
|
+
private usageAbortController: AbortController | undefined;
|
|
55
|
+
private sessionAbortSignal: AbortSignal | undefined;
|
|
56
|
+
private sessionAbortHandler: (() => void) | undefined;
|
|
57
|
+
private sessionGeneration = 0;
|
|
58
|
+
|
|
59
|
+
constructor(
|
|
60
|
+
private readonly getConfig: (ctx: ExtensionContext) => ResolvedConfig,
|
|
61
|
+
private readonly updateFooter: (ctx: ExtensionContext) => void,
|
|
62
|
+
) {}
|
|
63
|
+
|
|
64
|
+
get snapshot(): UsageSnapshot | undefined {
|
|
65
|
+
return this.usageSnapshot;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
statusLine(
|
|
69
|
+
ctx: ExtensionContext,
|
|
70
|
+
cfg = this.getConfig(ctx),
|
|
71
|
+
isUsingOAuth?: boolean,
|
|
72
|
+
): string | undefined {
|
|
73
|
+
return this.usageSnapshot &&
|
|
74
|
+
!this.usageError &&
|
|
75
|
+
this.usageSnapshot.scope === usageScopeForModel(ctx.model?.id) &&
|
|
76
|
+
cfg.usage.enabled &&
|
|
77
|
+
isOpenAISubscriptionModel(ctx, cfg, isUsingOAuth)
|
|
78
|
+
? formatUsageSnapshot(this.usageSnapshot, cfg.usage)
|
|
79
|
+
: undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
formatStatus(ctx: ExtensionContext): string {
|
|
83
|
+
const cfg = this.getConfig(ctx);
|
|
84
|
+
if (!cfg.usage.enabled) return "Usage display is disabled.";
|
|
85
|
+
if (!isOpenAISubscriptionModel(ctx, cfg))
|
|
86
|
+
return "Usage hidden: current model is not an OpenAI subscription model.";
|
|
87
|
+
if (this.usageError) return `Usage unavailable: ${this.usageError}`;
|
|
88
|
+
if (!this.usageSnapshot || this.usageSnapshot.scope !== usageScopeForModel(ctx.model?.id))
|
|
89
|
+
return "Usage unavailable.";
|
|
90
|
+
const stale =
|
|
91
|
+
this.usageUpdatedAt && Date.now() - this.usageUpdatedAt > cfg.usage.refreshIntervalMs * 2
|
|
92
|
+
? ` | stale ${formatResetCountdown((Date.now() - this.usageUpdatedAt) / 1000)}`
|
|
93
|
+
: "";
|
|
94
|
+
return `${formatUsageSnapshot(this.usageSnapshot, cfg.usage)}${stale}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
formatDebug(ctx: ExtensionContext): string {
|
|
98
|
+
const cfg = this.getConfig(ctx);
|
|
99
|
+
const auth = readCodexAuth();
|
|
100
|
+
return [
|
|
101
|
+
`Usage enabled: ${cfg.usage.enabled}`,
|
|
102
|
+
`Current model: ${currentModelKey(ctx)}`,
|
|
103
|
+
`Current model eligible: ${isOpenAISubscriptionModel(ctx, cfg)}`,
|
|
104
|
+
`Requires subscription model: ${cfg.usage.showOnlyOnSubscriptionModels}`,
|
|
105
|
+
`Auth: ${auth ? "found" : "missing"}`,
|
|
106
|
+
`Account ID: ${maskIdentifier(auth?.accountId) ?? "none"}`,
|
|
107
|
+
`Last fetch: ${this.usageLastFetchAt ? new Date(this.usageLastFetchAt).toLocaleTimeString() : "never"}`,
|
|
108
|
+
`Last successful update: ${this.usageUpdatedAt ? new Date(this.usageUpdatedAt).toLocaleTimeString() : "never"}`,
|
|
109
|
+
`Last error: ${this.usageError ?? "none"}`,
|
|
110
|
+
`Refresh interval: ${cfg.usage.refreshIntervalMs}ms`,
|
|
111
|
+
`Endpoint: https://chatgpt.com/backend-api/wham/usage`,
|
|
112
|
+
].join("\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private isGenerationCurrent(generation: number): boolean {
|
|
116
|
+
return !this.shuttingDown && generation === this.sessionGeneration;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private deactivateGeneration(generation: number): void {
|
|
120
|
+
if (generation !== this.sessionGeneration) return;
|
|
121
|
+
this.shuttingDown = true;
|
|
122
|
+
this.sessionGeneration++;
|
|
123
|
+
this.queuedUsageRefresh = undefined;
|
|
124
|
+
this.usageAbortController?.abort();
|
|
125
|
+
this.usageAbortController = undefined;
|
|
126
|
+
this.stopTimer();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private handleStaleContextError(error: unknown, generation: number): boolean {
|
|
130
|
+
if (!isStaleExtensionContextError(error)) return false;
|
|
131
|
+
this.deactivateGeneration(generation);
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async refresh(
|
|
136
|
+
ctx: ExtensionContext,
|
|
137
|
+
modelId?: string,
|
|
138
|
+
options?: UsageRefreshOptions,
|
|
139
|
+
generation = this.sessionGeneration,
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
if (!this.isGenerationCurrent(generation)) return;
|
|
142
|
+
|
|
143
|
+
let resolvedModelId = modelId;
|
|
144
|
+
try {
|
|
145
|
+
if (!ctx.hasUI) return;
|
|
146
|
+
resolvedModelId ??= ctx.model?.id;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
this.handleStaleContextError(error, generation);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (this.usageRefreshInFlight) {
|
|
153
|
+
const queued =
|
|
154
|
+
this.queuedUsageRefresh?.generation === generation ? this.queuedUsageRefresh : undefined;
|
|
155
|
+
this.queuedUsageRefresh = {
|
|
156
|
+
ctx,
|
|
157
|
+
generation,
|
|
158
|
+
modelId: resolvedModelId,
|
|
159
|
+
notify: queued?.notify || options?.notify,
|
|
160
|
+
force: queued?.force || options?.force,
|
|
161
|
+
};
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
this.usageRefreshInFlight = true;
|
|
166
|
+
try {
|
|
167
|
+
const cfg = this.getConfig(ctx);
|
|
168
|
+
if (!this.isGenerationCurrent(generation)) return;
|
|
169
|
+
|
|
170
|
+
if (!cfg.usage.enabled) {
|
|
171
|
+
this.usageSnapshot = undefined;
|
|
172
|
+
this.usageError = "Usage display is disabled.";
|
|
173
|
+
this.updateFooter(ctx);
|
|
174
|
+
if (options?.notify) ctx.ui.notify(this.formatStatus(ctx), "warning");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
if (!isOpenAISubscriptionModel(ctx, cfg)) {
|
|
178
|
+
this.updateFooter(ctx);
|
|
179
|
+
if (options?.notify) ctx.ui.notify(this.formatStatus(ctx), "warning");
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const shouldThrottle =
|
|
183
|
+
!options?.force &&
|
|
184
|
+
!options?.notify &&
|
|
185
|
+
this.usageLastFetchAt !== undefined &&
|
|
186
|
+
Date.now() - this.usageLastFetchAt < cfg.usage.refreshIntervalMs;
|
|
187
|
+
if (shouldThrottle) return;
|
|
188
|
+
this.usageLastFetchAt = Date.now();
|
|
189
|
+
this.usageAbortController = new AbortController();
|
|
190
|
+
const timeoutSignal = AbortSignal.timeout(10_000);
|
|
191
|
+
const signal = ctx.signal
|
|
192
|
+
? AbortSignal.any([ctx.signal, timeoutSignal, this.usageAbortController.signal])
|
|
193
|
+
: AbortSignal.any([timeoutSignal, this.usageAbortController.signal]);
|
|
194
|
+
const data = await requestCodexUsage(ctx, signal);
|
|
195
|
+
if (!this.isGenerationCurrent(generation)) return;
|
|
196
|
+
|
|
197
|
+
this.usageSnapshot = data ? parseUsageSnapshot(data, resolvedModelId) : undefined;
|
|
198
|
+
this.usageUpdatedAt = this.usageSnapshot ? Date.now() : undefined;
|
|
199
|
+
this.usageError = data
|
|
200
|
+
? undefined
|
|
201
|
+
: `Missing openai-codex OAuth credentials in ${AUTH_FILE}.`;
|
|
202
|
+
this.updateFooter(ctx);
|
|
203
|
+
if (options?.notify)
|
|
204
|
+
ctx.ui.notify(this.formatStatus(ctx), this.usageSnapshot ? "info" : "warning");
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (this.handleStaleContextError(error, generation) || !this.isGenerationCurrent(generation))
|
|
207
|
+
return;
|
|
208
|
+
this.usageError = sanitizeDiagnosticError(
|
|
209
|
+
error instanceof Error ? error.message : String(error),
|
|
210
|
+
);
|
|
211
|
+
try {
|
|
212
|
+
this.updateFooter(ctx);
|
|
213
|
+
if (options?.notify) ctx.ui.notify(this.formatStatus(ctx), "warning");
|
|
214
|
+
} catch (secondaryError) {
|
|
215
|
+
if (!this.handleStaleContextError(secondaryError, generation)) {
|
|
216
|
+
this.usageError = sanitizeDiagnosticError(
|
|
217
|
+
secondaryError instanceof Error ? secondaryError.message : String(secondaryError),
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
} finally {
|
|
222
|
+
this.usageAbortController = undefined;
|
|
223
|
+
this.usageRefreshInFlight = false;
|
|
224
|
+
const next = this.queuedUsageRefresh;
|
|
225
|
+
this.queuedUsageRefresh = undefined;
|
|
226
|
+
if (next && !this.shuttingDown && next.generation === this.sessionGeneration) {
|
|
227
|
+
void this.refresh(
|
|
228
|
+
next.ctx,
|
|
229
|
+
next.modelId,
|
|
230
|
+
{ notify: next.notify, force: next.force },
|
|
231
|
+
next.generation,
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
private stopTimer(): void {
|
|
238
|
+
if (this.usageTimer) clearInterval(this.usageTimer);
|
|
239
|
+
this.usageTimer = undefined;
|
|
240
|
+
if (this.sessionAbortSignal && this.sessionAbortHandler) {
|
|
241
|
+
this.sessionAbortSignal.removeEventListener("abort", this.sessionAbortHandler);
|
|
242
|
+
}
|
|
243
|
+
this.sessionAbortSignal = undefined;
|
|
244
|
+
this.sessionAbortHandler = undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
start(ctx: ExtensionContext): void {
|
|
248
|
+
this.usageAbortController?.abort();
|
|
249
|
+
this.queuedUsageRefresh = undefined;
|
|
250
|
+
this.stopTimer();
|
|
251
|
+
const generation = ++this.sessionGeneration;
|
|
252
|
+
this.shuttingDown = false;
|
|
253
|
+
|
|
254
|
+
const cfg = this.getConfig(ctx);
|
|
255
|
+
if (!cfg.usage.enabled) return;
|
|
256
|
+
const sessionSignal = ctx.signal;
|
|
257
|
+
if (sessionSignal?.aborted) {
|
|
258
|
+
this.deactivateGeneration(generation);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
this.sessionAbortSignal = sessionSignal;
|
|
262
|
+
this.sessionAbortHandler = () => {
|
|
263
|
+
this.deactivateGeneration(generation);
|
|
264
|
+
};
|
|
265
|
+
sessionSignal?.addEventListener("abort", this.sessionAbortHandler, { once: true });
|
|
266
|
+
void this.refresh(ctx, undefined, { force: true }, generation);
|
|
267
|
+
this.usageTimer = setInterval(() => {
|
|
268
|
+
if (!this.isGenerationCurrent(generation)) return;
|
|
269
|
+
if (sessionSignal?.aborted) {
|
|
270
|
+
this.deactivateGeneration(generation);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
void this.refresh(ctx, undefined, undefined, generation);
|
|
274
|
+
}, cfg.usage.refreshIntervalMs);
|
|
275
|
+
this.usageTimer.unref?.();
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
restartAfterSettingsChange(ctx: ExtensionContext, cfg: ResolvedConfig): void {
|
|
279
|
+
this.usageAbortController?.abort();
|
|
280
|
+
this.queuedUsageRefresh = undefined;
|
|
281
|
+
this.stopTimer();
|
|
282
|
+
this.sessionGeneration++;
|
|
283
|
+
this.shuttingDown = false;
|
|
284
|
+
if (cfg.usage.enabled) this.start(ctx);
|
|
285
|
+
else {
|
|
286
|
+
this.usageSnapshot = undefined;
|
|
287
|
+
this.usageError = "Usage display is disabled.";
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
shutdown(): void {
|
|
292
|
+
this.shuttingDown = true;
|
|
293
|
+
this.sessionGeneration++;
|
|
294
|
+
this.queuedUsageRefresh = undefined;
|
|
295
|
+
this.usageAbortController?.abort();
|
|
296
|
+
this.usageAbortController = undefined;
|
|
297
|
+
this.stopTimer();
|
|
298
|
+
}
|
|
299
|
+
}
|
package/src/usage.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { getCodexCredentials } from "./codex-auth.ts";
|
|
3
|
+
export { AUTH_FILE, readCodexAuth } from "./codex-auth.ts";
|
|
4
4
|
|
|
5
5
|
export type UsageWindow = {
|
|
6
6
|
used_percent?: number | null;
|
|
@@ -21,6 +21,8 @@ export type CodexUsageResponse = {
|
|
|
21
21
|
};
|
|
22
22
|
|
|
23
23
|
export type UsageSnapshot = {
|
|
24
|
+
capturedAt: number;
|
|
25
|
+
scope: UsageScope;
|
|
24
26
|
fiveHourLeftPercent: number | null;
|
|
25
27
|
sevenDayLeftPercent: number | null;
|
|
26
28
|
fiveHourResetInSeconds: number | null;
|
|
@@ -28,11 +30,42 @@ export type UsageSnapshot = {
|
|
|
28
30
|
isLimited: boolean;
|
|
29
31
|
};
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
+
export type UsageScope = "default" | "spark";
|
|
34
|
+
|
|
33
35
|
export const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
34
36
|
const SPARK_MODEL_ID = "gpt-5.3-codex-spark";
|
|
35
37
|
const SPARK_LIMIT_NAME = "GPT-5.3-Codex-Spark";
|
|
38
|
+
type ResetClockFormatters = {
|
|
39
|
+
time: Intl.DateTimeFormat;
|
|
40
|
+
weekday: Intl.DateTimeFormat;
|
|
41
|
+
date: Intl.DateTimeFormat;
|
|
42
|
+
};
|
|
43
|
+
const RESET_CLOCK_FORMATTER_CACHE_LIMIT = 4;
|
|
44
|
+
const resetClockFormatters = new Map<string, ResetClockFormatters>();
|
|
45
|
+
|
|
46
|
+
function currentTimeZoneKey(date: Date): string {
|
|
47
|
+
const zoneLabel = /\(([^)]+)\)$/.exec(date.toString())?.[1] ?? "";
|
|
48
|
+
return `${process.env.TZ ?? ""}:${date.getTimezoneOffset()}:${zoneLabel}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getResetClockFormatters(now: Date, reset: Date): ResetClockFormatters {
|
|
52
|
+
const timeZoneKey = `${currentTimeZoneKey(now)}:${reset.getTimezoneOffset()}`;
|
|
53
|
+
let formatters = resetClockFormatters.get(timeZoneKey);
|
|
54
|
+
if (!formatters) {
|
|
55
|
+
formatters = {
|
|
56
|
+
time: new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }),
|
|
57
|
+
weekday: new Intl.DateTimeFormat(undefined, { weekday: "short" }),
|
|
58
|
+
date: new Intl.DateTimeFormat(undefined, { month: "numeric", day: "numeric" }),
|
|
59
|
+
};
|
|
60
|
+
resetClockFormatters.set(timeZoneKey, formatters);
|
|
61
|
+
while (resetClockFormatters.size > RESET_CLOCK_FORMATTER_CACHE_LIMIT) {
|
|
62
|
+
const oldestKey = resetClockFormatters.keys().next().value;
|
|
63
|
+
if (oldestKey === undefined) break;
|
|
64
|
+
resetClockFormatters.delete(oldestKey);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return formatters;
|
|
68
|
+
}
|
|
36
69
|
|
|
37
70
|
function asObject(value: unknown): Record<string, unknown> | null {
|
|
38
71
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
@@ -44,12 +77,12 @@ function clampPercent(value: number): number {
|
|
|
44
77
|
}
|
|
45
78
|
|
|
46
79
|
function usedToLeftPercent(value: number | null | undefined): number | null {
|
|
47
|
-
if (typeof value !== "number" || Number.
|
|
80
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return null;
|
|
48
81
|
return clampPercent(100 - value);
|
|
49
82
|
}
|
|
50
83
|
|
|
51
84
|
export function formatResetCountdown(seconds: number | null): string | null {
|
|
52
|
-
if (typeof seconds !== "number" || Number.
|
|
85
|
+
if (typeof seconds !== "number" || !Number.isFinite(seconds)) return null;
|
|
53
86
|
const total = Math.max(0, Math.round(seconds));
|
|
54
87
|
const days = Math.floor(total / 86_400);
|
|
55
88
|
const hours = Math.floor((total % 86_400) / 3_600);
|
|
@@ -64,15 +97,17 @@ export function formatResetCountdown(seconds: number | null): string | null {
|
|
|
64
97
|
function formatResetClock(
|
|
65
98
|
seconds: number | null,
|
|
66
99
|
options?: { includeDate?: boolean },
|
|
100
|
+
now = Date.now(),
|
|
67
101
|
): string | null {
|
|
68
|
-
if (typeof seconds !== "number" || Number.
|
|
69
|
-
const resetDate = new Date(
|
|
70
|
-
const
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
102
|
+
if (typeof seconds !== "number" || !Number.isFinite(seconds)) return null;
|
|
103
|
+
const resetDate = new Date(now + seconds * 1000);
|
|
104
|
+
const currentDate = new Date(now);
|
|
105
|
+
const formatters = getResetClockFormatters(currentDate, resetDate);
|
|
106
|
+
const time = formatters.time.format(resetDate);
|
|
107
|
+
if (!options?.includeDate && resetDate.toDateString() === currentDate.toDateString()) return time;
|
|
108
|
+
const weekday = formatters.weekday.format(resetDate);
|
|
74
109
|
if (!options?.includeDate) return `${weekday} ${time}`;
|
|
75
|
-
const date =
|
|
110
|
+
const date = formatters.date.format(resetDate);
|
|
76
111
|
return `${weekday} ${date} ${time}`;
|
|
77
112
|
}
|
|
78
113
|
|
|
@@ -80,38 +115,24 @@ function formatCompactReset(
|
|
|
80
115
|
label: string,
|
|
81
116
|
seconds: number | null,
|
|
82
117
|
options?: { includeDate?: boolean },
|
|
118
|
+
now = Date.now(),
|
|
83
119
|
): string | null {
|
|
84
120
|
const countdown = formatResetCountdown(seconds);
|
|
85
|
-
const clock = formatResetClock(seconds, options);
|
|
121
|
+
const clock = formatResetClock(seconds, options, now);
|
|
86
122
|
return countdown && clock ? `${label} ↺ ${countdown} - ${clock}` : null;
|
|
87
123
|
}
|
|
88
124
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const auth = JSON.parse(readFileSync(AUTH_FILE, "utf8")) as Record<
|
|
92
|
-
string,
|
|
93
|
-
| {
|
|
94
|
-
type?: string;
|
|
95
|
-
access?: string | null;
|
|
96
|
-
accountId?: string | null;
|
|
97
|
-
account_id?: string | null;
|
|
98
|
-
}
|
|
99
|
-
| undefined
|
|
100
|
-
>;
|
|
101
|
-
const entry = auth["openai-codex"];
|
|
102
|
-
if (entry?.type !== "oauth") return undefined;
|
|
103
|
-
const accessToken = entry.access?.trim();
|
|
104
|
-
const accountId = (entry.accountId ?? entry.account_id)?.trim();
|
|
105
|
-
return accessToken && accountId ? { accessToken, accountId } : undefined;
|
|
106
|
-
} catch {
|
|
107
|
-
return undefined;
|
|
108
|
-
}
|
|
125
|
+
function isAbortSignal(value: unknown): value is AbortSignal {
|
|
126
|
+
return typeof value === "object" && value !== null && "aborted" in value;
|
|
109
127
|
}
|
|
110
128
|
|
|
111
129
|
export async function requestCodexUsage(
|
|
130
|
+
ctxOrSignal?: ExtensionContext | AbortSignal,
|
|
112
131
|
signal?: AbortSignal,
|
|
113
132
|
): Promise<CodexUsageResponse | undefined> {
|
|
114
|
-
const
|
|
133
|
+
const ctx = isAbortSignal(ctxOrSignal) ? undefined : ctxOrSignal;
|
|
134
|
+
const requestSignal = isAbortSignal(ctxOrSignal) ? ctxOrSignal : signal;
|
|
135
|
+
const credentials = await getCodexCredentials(ctx, requestSignal);
|
|
115
136
|
if (!credentials) return undefined;
|
|
116
137
|
const response = await fetch(USAGE_URL, {
|
|
117
138
|
headers: {
|
|
@@ -119,7 +140,7 @@ export async function requestCodexUsage(
|
|
|
119
140
|
authorization: `Bearer ${credentials.accessToken}`,
|
|
120
141
|
"chatgpt-account-id": credentials.accountId,
|
|
121
142
|
},
|
|
122
|
-
signal,
|
|
143
|
+
signal: requestSignal,
|
|
123
144
|
});
|
|
124
145
|
if (!response.ok) throw new Error(`Codex usage request failed (${response.status})`);
|
|
125
146
|
return (await response.json()) as CodexUsageResponse;
|
|
@@ -165,34 +186,45 @@ function findSparkRateLimitBucket(data: CodexUsageResponse): RateLimitBucket | n
|
|
|
165
186
|
return null;
|
|
166
187
|
}
|
|
167
188
|
|
|
168
|
-
function getResetSeconds(window: UsageWindow | null | undefined): number | null {
|
|
169
|
-
if (
|
|
189
|
+
function getResetSeconds(window: UsageWindow | null | undefined, now: number): number | null {
|
|
190
|
+
if (
|
|
191
|
+
typeof window?.reset_after_seconds === "number" &&
|
|
192
|
+
Number.isFinite(window.reset_after_seconds)
|
|
193
|
+
)
|
|
170
194
|
return window.reset_after_seconds;
|
|
171
|
-
if (typeof window?.reset_at !== "number" || Number.
|
|
195
|
+
if (typeof window?.reset_at !== "number" || !Number.isFinite(window.reset_at)) return null;
|
|
172
196
|
const resetAtSeconds =
|
|
173
197
|
window.reset_at > 100_000_000_000 ? window.reset_at / 1000 : window.reset_at;
|
|
174
|
-
return Math.max(0, resetAtSeconds -
|
|
198
|
+
return Math.max(0, resetAtSeconds - now / 1000);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function usageScopeForModel(modelId: string | undefined): UsageScope {
|
|
202
|
+
return modelId === SPARK_MODEL_ID ? "spark" : "default";
|
|
175
203
|
}
|
|
176
204
|
|
|
177
205
|
export function parseUsageSnapshot(
|
|
178
206
|
data: CodexUsageResponse,
|
|
179
207
|
modelId: string | undefined,
|
|
208
|
+
now = Date.now(),
|
|
180
209
|
): UsageSnapshot {
|
|
210
|
+
const scope = usageScopeForModel(modelId);
|
|
181
211
|
const bucket =
|
|
182
|
-
|
|
183
|
-
? findSparkRateLimitBucket(data)
|
|
212
|
+
scope === "spark"
|
|
213
|
+
? (findSparkRateLimitBucket(data) ?? normalizeRateLimitBucket(data.rate_limit))
|
|
184
214
|
: normalizeRateLimitBucket(data.rate_limit);
|
|
185
215
|
return {
|
|
216
|
+
capturedAt: now,
|
|
217
|
+
scope,
|
|
186
218
|
fiveHourLeftPercent: usedToLeftPercent(bucket?.primary_window?.used_percent),
|
|
187
219
|
sevenDayLeftPercent: usedToLeftPercent(bucket?.secondary_window?.used_percent),
|
|
188
|
-
fiveHourResetInSeconds: getResetSeconds(bucket?.primary_window),
|
|
189
|
-
sevenDayResetInSeconds: getResetSeconds(bucket?.secondary_window),
|
|
220
|
+
fiveHourResetInSeconds: getResetSeconds(bucket?.primary_window, now),
|
|
221
|
+
sevenDayResetInSeconds: getResetSeconds(bucket?.secondary_window, now),
|
|
190
222
|
isLimited: bucket?.limit_reached === true || bucket?.allowed === false,
|
|
191
223
|
};
|
|
192
224
|
}
|
|
193
225
|
|
|
194
226
|
export function formatPercent(value: number | null): string {
|
|
195
|
-
return typeof value === "number" &&
|
|
227
|
+
return typeof value === "number" && Number.isFinite(value)
|
|
196
228
|
? `${Math.round(clampPercent(value))}%`
|
|
197
229
|
: "--";
|
|
198
230
|
}
|
|
@@ -200,14 +232,33 @@ export function formatPercent(value: number | null): string {
|
|
|
200
232
|
export function formatUsageSnapshot(
|
|
201
233
|
snapshot: UsageSnapshot,
|
|
202
234
|
options: { showResetTimes: boolean },
|
|
235
|
+
now = Date.now(),
|
|
203
236
|
): string {
|
|
204
237
|
const fiveHour = formatPercent(snapshot.fiveHourLeftPercent);
|
|
205
238
|
const sevenDay = formatPercent(snapshot.sevenDayLeftPercent);
|
|
206
239
|
const resets = options.showResetTimes
|
|
207
240
|
? [
|
|
208
|
-
formatCompactReset(
|
|
209
|
-
|
|
241
|
+
formatCompactReset(
|
|
242
|
+
"5h",
|
|
243
|
+
remainingResetSeconds(snapshot.fiveHourResetInSeconds, snapshot.capturedAt, now),
|
|
244
|
+
undefined,
|
|
245
|
+
now,
|
|
246
|
+
),
|
|
247
|
+
formatCompactReset(
|
|
248
|
+
"7d",
|
|
249
|
+
remainingResetSeconds(snapshot.sevenDayResetInSeconds, snapshot.capturedAt, now),
|
|
250
|
+
{ includeDate: true },
|
|
251
|
+
now,
|
|
252
|
+
),
|
|
210
253
|
].filter((value): value is string => value !== null)
|
|
211
254
|
: [];
|
|
212
255
|
return `Usage: 5h: ${fiveHour} | 7d: ${sevenDay}${resets.length ? ` | ${resets.join(" | ")}` : ""}`;
|
|
213
256
|
}
|
|
257
|
+
|
|
258
|
+
function remainingResetSeconds(
|
|
259
|
+
seconds: number | null,
|
|
260
|
+
capturedAt: number,
|
|
261
|
+
now: number,
|
|
262
|
+
): number | null {
|
|
263
|
+
return seconds === null ? null : seconds - (now - capturedAt) / 1000;
|
|
264
|
+
}
|