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