@llblab/pi-codex-usage 0.7.0 → 0.8.1
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/BACKLOG.md +5 -1
- package/CHANGELOG.md +12 -0
- package/README.md +14 -5
- package/index.ts +265 -125
- package/package.json +1 -1
package/BACKLOG.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
1
|
# Backlog
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## Pending Release Verification
|
|
4
|
+
|
|
5
|
+
- [x] Fix stale extension context usage after session replacement or reload. Source: GitHub issue #1. Timers must not keep using captured stale `ctx` after Pi session lifecycle changes.
|
|
6
|
+
- [x] Make usage refresh cache/in-flight state model-aware for Codex vs Spark. A regular Codex cache or request must not temporarily render Spark as `n/a` after model switching.
|
|
7
|
+
- [x] Preserve the active `codex`/`spark` label for failure states. Spark failures should render `spark n/a` or `spark error`, not `codex ...`.
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.8.1: Stale Context Hotfix
|
|
6
|
+
|
|
7
|
+
- Fixed stale extension context crashes from delayed statusline timers after Pi session replacement or reload. Impact: timers now ignore stale `ctx` failures instead of letting issue #1 crash Pi after idle/session lifecycle changes.
|
|
8
|
+
- Fixed Codex/Spark refresh state to key in-flight usage lookups by active quota bucket and only reuse cached reports that contain the active bucket. Impact: switching between regular Codex and Spark no longer temporarily renders the active bucket as unavailable because of a stale request/cache.
|
|
9
|
+
- Fixed Spark failure labels. Impact: Spark unavailable/error states now render with the `spark` label instead of falling back to `codex`.
|
|
10
|
+
- Added regression coverage for active-bucket cache reuse and stale extension context error detection.
|
|
11
|
+
|
|
12
|
+
## 0.8.0: Spark Model Usage Status
|
|
13
|
+
|
|
14
|
+
- Added active-bucket status support for `GPT-5.3-Codex-Spark`. Impact: selecting the Spark Codex subscription model now shows the parallel Spark rate-limit bucket with a `spark` label, while regular Codex models keep the existing `codex` bucket/status behavior.
|
|
15
|
+
- Added parsing for backend `additional_rate_limits`, including the Spark `GPT-5.3-Codex-Spark` / `codex_bengalfox` limit. Impact: Spark quota can be shown from the same Pi-auth usage response path as normal Codex quota instead of relying only on app-server fallback.
|
|
16
|
+
|
|
5
17
|
## 0.7.0: Exhausted Quota Visibility
|
|
6
18
|
|
|
7
19
|
- Added exhausted-quota warning background for the statusline quota bar. Impact: when either the 5-hour or weekly Codex window has 0% remaining, the bar keeps its shape but switches from the selected background to the error background for faster visual detection.
|
package/README.md
CHANGED
|
@@ -15,9 +15,11 @@ This repository is a minimal fork of [`narumiruna/pi-extensions/extensions/pi-co
|
|
|
15
15
|
## Features
|
|
16
16
|
|
|
17
17
|
- Shows an empty statusline bar immediately, then refreshes every 30 seconds while the active Pi model uses `openai-codex`
|
|
18
|
-
- Statusline output stays compact, with the
|
|
19
|
-
-
|
|
20
|
-
-
|
|
18
|
+
- Statusline output stays compact, with the active usage label accented and the quota bar plus weekly reset countdown drawn on a themed background
|
|
19
|
+
- Regular Codex subscription models show the primary `codex` quota bucket
|
|
20
|
+
- `GPT-5.3-Codex-Spark` shows the parallel Spark quota bucket with the `spark` label
|
|
21
|
+
- When `pi-telegram` is available, the same compact value appears as `codex: <value>` or `spark: <value>` in the `/start` menu status text for active OpenAI Codex subscription models
|
|
22
|
+
- Additional returned buckets unrelated to the active Codex/Spark model are ignored
|
|
21
23
|
- Pi OpenAI Codex provider auth is used first
|
|
22
24
|
- Codex CLI app-server remains available as a fallback
|
|
23
25
|
- Missing auth, subscription, plan, or quota windows are shown as `n/a`, not as an error
|
|
@@ -41,12 +43,18 @@ pi install git:github.com/llblab/pi-codex-usage
|
|
|
41
43
|
|
|
42
44
|
## Statusline
|
|
43
45
|
|
|
44
|
-
|
|
46
|
+
Regular Codex usage:
|
|
45
47
|
|
|
46
48
|
```text
|
|
47
49
|
codex ██████▀▀▀▀ 6d
|
|
48
50
|
```
|
|
49
51
|
|
|
52
|
+
Spark model usage:
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
spark ██████████ 7d
|
|
56
|
+
```
|
|
57
|
+
|
|
50
58
|
The ten-character bar encodes two twenty-step limits at once: 40 total bits of quota state in 10 terminal cells. Each step is 5%: the top quadrants are the 5-hour limit, and the bottom quadrants are the weekly limit. If either quota window is exhausted, the bar keeps the same shape but switches to the error background color.
|
|
51
59
|
|
|
52
60
|
When the weekly reset time is available, the bar is followed by a countdown. More than a day remains is shown in 144-minute day-tenth steps such as `7d`, `6.9d`, `6.6d`, `5.1d`, `5d`, `3.7d`, `3d`, `2d`, `1.9d`, `1.5d`, and `1.1d`, rounded upward to the next tenth. At 24 hours and below it switches to upward-rounded 6-minute hour-tenth steps such as `24h`, `23.7h`, `20.1h`, `20h`, `19.9h`, `1.4h`, `1.3h`, `1.2h`, `1.1h`, and `1h`. Under an hour it switches to floored minutes, and under a minute to seconds. After the reset timestamp passes, `0s` is held until the next successful quota refresh reports the new weekly window.
|
|
@@ -77,9 +85,10 @@ If `@llblab/pi-telegram` is loaded with the public status-line provider API, thi
|
|
|
77
85
|
|
|
78
86
|
```text
|
|
79
87
|
codex: ██████▀▀▀▀ 6d
|
|
88
|
+
spark: ██████████ 7d
|
|
80
89
|
```
|
|
81
90
|
|
|
82
|
-
The value is the same compact quota bar plus weekly reset countdown used by the terminal statusline. If `pi-telegram` is absent, older, or the active model is not a Codex subscription model, no Telegram row is added.
|
|
91
|
+
The value is the same compact quota bar plus weekly reset countdown used by the terminal statusline, and the label follows the active Codex/Spark model. If `pi-telegram` is absent, older, or the active model is not a Codex subscription model, no Telegram row is added.
|
|
83
92
|
|
|
84
93
|
## Auth
|
|
85
94
|
|
package/index.ts
CHANGED
|
@@ -20,7 +20,11 @@ const REFRESH_INTERVAL_MS = 30 * SECOND_MS;
|
|
|
20
20
|
const REDRAW_BLINK_MS = 150;
|
|
21
21
|
const STATUS_KEY = "aa-codex-usage";
|
|
22
22
|
const MAX_ERROR_BODY_CHARS = 600;
|
|
23
|
-
const
|
|
23
|
+
const DEFAULT_STATUS_LABEL_TEXT = "codex";
|
|
24
|
+
const SPARK_STATUS_LABEL_TEXT = "spark";
|
|
25
|
+
const CODEX_USAGE_LIMIT_ID = "codex";
|
|
26
|
+
const SPARK_USAGE_LIMIT_ID = "spark";
|
|
27
|
+
const SPARK_MODEL_KEY = "gpt-5.3-codex-spark";
|
|
24
28
|
const DUAL_BAR_WIDTH = 10;
|
|
25
29
|
const TELEGRAM_STATUS_IMPORT_SPECIFIERS = [
|
|
26
30
|
"@llblab/pi-telegram/status",
|
|
@@ -49,7 +53,7 @@ type UsageSource = "pi-auth" | "codex-app-server";
|
|
|
49
53
|
type TimeoutHandle = ReturnType<typeof setTimeout> & { unref?: () => void };
|
|
50
54
|
type PiModel = NonNullable<ExtensionContext["model"]>;
|
|
51
55
|
export type CodexUsageModel = Pick<PiModel, "id" | "name" | "provider">;
|
|
52
|
-
type CodexUsageTelegramStatusModel = Pick<PiModel, "provider">;
|
|
56
|
+
type CodexUsageTelegramStatusModel = Pick<PiModel, "id" | "name" | "provider">;
|
|
53
57
|
type TelegramStatusLineProviderResult =
|
|
54
58
|
| { label: string; value: string }
|
|
55
59
|
| undefined;
|
|
@@ -99,6 +103,13 @@ export type NormalizedRateLimitWindow = {
|
|
|
99
103
|
|
|
100
104
|
type RateLimitStatusPayload = {
|
|
101
105
|
rate_limit?: unknown;
|
|
106
|
+
additional_rate_limits?: unknown;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
type BackendAdditionalRateLimit = {
|
|
110
|
+
limit_name?: unknown;
|
|
111
|
+
metered_feature?: unknown;
|
|
112
|
+
rate_limit?: unknown;
|
|
102
113
|
};
|
|
103
114
|
|
|
104
115
|
type BackendRateLimitDetails = {
|
|
@@ -152,7 +163,9 @@ type PendingRpc = {
|
|
|
152
163
|
export default function codexUsage(pi: ExtensionAPI) {
|
|
153
164
|
let cache: CachedReport | undefined;
|
|
154
165
|
let failedRefreshes = 0;
|
|
155
|
-
let inFlightUsageQuery:
|
|
166
|
+
let inFlightUsageQuery:
|
|
167
|
+
| { limitId: string; promise: Promise<QueryUsageResult> }
|
|
168
|
+
| undefined;
|
|
156
169
|
let statuslineBlinkTimer: TimeoutHandle | undefined;
|
|
157
170
|
let statuslineClearTimer: TimeoutHandle | undefined;
|
|
158
171
|
let statuslineCountdownTimer: TimeoutHandle | undefined;
|
|
@@ -167,8 +180,8 @@ export default function codexUsage(pi: ExtensionAPI) {
|
|
|
167
180
|
({ activeModel }) => {
|
|
168
181
|
if (!isOpenAICodexModel(activeModel)) return undefined;
|
|
169
182
|
if (!cache) return undefined;
|
|
170
|
-
const value = formatCodexUsageStatusValue(cache.report);
|
|
171
|
-
return value ? { label:
|
|
183
|
+
const value = formatCodexUsageStatusValue(cache.report, activeModel);
|
|
184
|
+
return value ? { label: activeUsageLabel(activeModel), value } : undefined;
|
|
172
185
|
},
|
|
173
186
|
)
|
|
174
187
|
.then((unregister) => {
|
|
@@ -199,8 +212,12 @@ export default function codexUsage(pi: ExtensionAPI) {
|
|
|
199
212
|
const scheduleTemporaryStatuslineClear = (ctx: ExtensionContext) => {
|
|
200
213
|
if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
|
|
201
214
|
statuslineClearTimer = setTimeout(() => {
|
|
202
|
-
|
|
203
|
-
|
|
215
|
+
try {
|
|
216
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
217
|
+
statuslineClearTimer = undefined;
|
|
218
|
+
} catch (error) {
|
|
219
|
+
handleTimerError(error);
|
|
220
|
+
}
|
|
204
221
|
}, REFRESH_INTERVAL_MS) as TimeoutHandle;
|
|
205
222
|
statuslineClearTimer.unref?.();
|
|
206
223
|
};
|
|
@@ -208,7 +225,7 @@ export default function codexUsage(pi: ExtensionAPI) {
|
|
|
208
225
|
const scheduleStatuslineRefresh = (ctx: ExtensionContext) => {
|
|
209
226
|
if (statuslineRefreshTimer) clearTimeout(statuslineRefreshTimer);
|
|
210
227
|
statuslineRefreshTimer = setTimeout(() => {
|
|
211
|
-
void refreshCurrentCodexUsageStatusline(ctx, true);
|
|
228
|
+
void refreshCurrentCodexUsageStatusline(ctx, true).catch(handleAsyncTimerError);
|
|
212
229
|
}, REFRESH_INTERVAL_MS) as TimeoutHandle;
|
|
213
230
|
statuslineRefreshTimer.unref?.();
|
|
214
231
|
};
|
|
@@ -219,18 +236,22 @@ export default function codexUsage(pi: ExtensionAPI) {
|
|
|
219
236
|
model: CodexUsageModel | undefined,
|
|
220
237
|
) => {
|
|
221
238
|
if (statuslineCountdownTimer) clearTimeout(statuslineCountdownTimer);
|
|
222
|
-
const delayMs = nextResetCountdownDelayMs(report);
|
|
239
|
+
const delayMs = nextResetCountdownDelayMs(report, Date.now(), model);
|
|
223
240
|
if (delayMs === undefined) {
|
|
224
241
|
statuslineCountdownTimer = undefined;
|
|
225
242
|
return;
|
|
226
243
|
}
|
|
227
244
|
statuslineCountdownTimer = setTimeout(() => {
|
|
228
|
-
|
|
229
|
-
ctx.
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
245
|
+
try {
|
|
246
|
+
if (isOpenAICodexModel(ctx.model)) {
|
|
247
|
+
ctx.ui.setStatus(
|
|
248
|
+
STATUS_KEY,
|
|
249
|
+
formatCodexUsageStatusline(report, ctx, model),
|
|
250
|
+
);
|
|
251
|
+
scheduleStatuslineCountdown(ctx, report, model);
|
|
252
|
+
}
|
|
253
|
+
} catch (error) {
|
|
254
|
+
handleTimerError(error);
|
|
234
255
|
}
|
|
235
256
|
}, delayMs) as TimeoutHandle;
|
|
236
257
|
statuslineCountdownTimer.unref?.();
|
|
@@ -255,9 +276,13 @@ export default function codexUsage(pi: ExtensionAPI) {
|
|
|
255
276
|
if (options.blink) {
|
|
256
277
|
ctx.ui.setStatus(STATUS_KEY, formatEmptyStatuslineBar(ctx));
|
|
257
278
|
statuslineBlinkTimer = setTimeout(() => {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
279
|
+
try {
|
|
280
|
+
ctx.ui.setStatus(STATUS_KEY, text);
|
|
281
|
+
scheduleStatuslineCountdown(ctx, report, options.model);
|
|
282
|
+
statuslineBlinkTimer = undefined;
|
|
283
|
+
} catch (error) {
|
|
284
|
+
handleTimerError(error);
|
|
285
|
+
}
|
|
261
286
|
}, REDRAW_BLINK_MS) as TimeoutHandle;
|
|
262
287
|
statuslineBlinkTimer.unref?.();
|
|
263
288
|
} else {
|
|
@@ -268,73 +293,91 @@ export default function codexUsage(pi: ExtensionAPI) {
|
|
|
268
293
|
else scheduleTemporaryStatuslineClear(ctx);
|
|
269
294
|
};
|
|
270
295
|
|
|
271
|
-
const queryCurrentUsage = (
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
296
|
+
const queryCurrentUsage = (
|
|
297
|
+
ctx: ExtensionContext,
|
|
298
|
+
model: CodexUsageModel | undefined,
|
|
299
|
+
) => {
|
|
300
|
+
const limitId = activeUsageLimitId(model);
|
|
301
|
+
if (inFlightUsageQuery?.limitId === limitId) return inFlightUsageQuery.promise;
|
|
302
|
+
const promise = queryUsage(
|
|
303
|
+
ctx,
|
|
304
|
+
{ timeoutMs: DEFAULT_TIMEOUT_MS },
|
|
305
|
+
model,
|
|
306
|
+
).finally(() => {
|
|
307
|
+
if (inFlightUsageQuery?.promise === promise) inFlightUsageQuery = undefined;
|
|
308
|
+
});
|
|
309
|
+
inFlightUsageQuery = { limitId, promise };
|
|
310
|
+
return promise;
|
|
280
311
|
};
|
|
281
312
|
|
|
282
313
|
const refreshCurrentCodexUsageStatusline = async (
|
|
283
314
|
ctx: ExtensionContext,
|
|
284
315
|
force: boolean,
|
|
285
|
-
model
|
|
316
|
+
model?: CodexUsageModel,
|
|
286
317
|
) => {
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
318
|
+
try {
|
|
319
|
+
const activeModel = model ?? ctx.model;
|
|
320
|
+
if (!isOpenAICodexModel(activeModel)) {
|
|
321
|
+
clearUsageStatusline(ctx);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
291
324
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
325
|
+
if (!cache) ctx.ui.setStatus(STATUS_KEY, formatEmptyStatuslineBar(ctx));
|
|
326
|
+
const requestId = statuslineRequestId + 1;
|
|
327
|
+
statuslineRequestId = requestId;
|
|
328
|
+
const cached =
|
|
329
|
+
cache &&
|
|
330
|
+
Date.now() - cache.createdAt < REFRESH_INTERVAL_MS &&
|
|
331
|
+
canReuseCachedReport(cache.report, activeModel)
|
|
332
|
+
? cache
|
|
333
|
+
: undefined;
|
|
334
|
+
if (cached && !force) {
|
|
335
|
+
setUsageStatusline(ctx, cached.report, {
|
|
336
|
+
autoRefresh: true,
|
|
337
|
+
blink: false,
|
|
338
|
+
model: activeModel,
|
|
339
|
+
});
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
307
342
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
343
|
+
const result = await queryCurrentUsage(ctx, activeModel);
|
|
344
|
+
if (requestId !== statuslineRequestId) return;
|
|
345
|
+
if (!isOpenAICodexModel(ctx.model)) {
|
|
346
|
+
clearUsageStatusline(ctx);
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
314
349
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
350
|
+
if (!result.ok) {
|
|
351
|
+
failedRefreshes += 1;
|
|
352
|
+
if (!cache || failedRefreshes >= 5) {
|
|
353
|
+
ctx.ui.setStatus(
|
|
354
|
+
STATUS_KEY,
|
|
355
|
+
formatStatuslineProblem(ctx, result.errors, activeModel),
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
scheduleStatuslineRefresh(ctx);
|
|
359
|
+
return;
|
|
322
360
|
}
|
|
323
|
-
scheduleStatuslineRefresh(ctx);
|
|
324
|
-
return;
|
|
325
|
-
}
|
|
326
361
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
362
|
+
const previousReport = cache?.report;
|
|
363
|
+
const blink = previousReport
|
|
364
|
+
? formatReportBar(previousReport, activeModel) !==
|
|
365
|
+
formatReportBar(result.report, activeModel)
|
|
366
|
+
: false;
|
|
367
|
+
failedRefreshes = 0;
|
|
368
|
+
cache = { createdAt: Date.now(), report: result.report };
|
|
369
|
+
setUsageStatusline(ctx, result.report, {
|
|
370
|
+
autoRefresh: true,
|
|
371
|
+
blink,
|
|
372
|
+
model: activeModel,
|
|
373
|
+
});
|
|
374
|
+
} catch (error) {
|
|
375
|
+
if (isStaleExtensionContextError(error)) {
|
|
376
|
+
clearStatuslineTimers();
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
throw error;
|
|
380
|
+
}
|
|
338
381
|
};
|
|
339
382
|
|
|
340
383
|
ensureTelegramStatusLineRegistered();
|
|
@@ -342,19 +385,19 @@ export default function codexUsage(pi: ExtensionAPI) {
|
|
|
342
385
|
pi.on("session_start", (_event, ctx) => {
|
|
343
386
|
ensureTelegramStatusLineRegistered();
|
|
344
387
|
if (isOpenAICodexModel(ctx.model))
|
|
345
|
-
void refreshCurrentCodexUsageStatusline(ctx, false);
|
|
388
|
+
void refreshCurrentCodexUsageStatusline(ctx, false).catch(handleAsyncTimerError);
|
|
346
389
|
else clearUsageStatusline(ctx);
|
|
347
390
|
});
|
|
348
391
|
|
|
349
392
|
pi.on("session_tree", (_event, ctx) => {
|
|
350
393
|
if (isOpenAICodexModel(ctx.model))
|
|
351
|
-
void refreshCurrentCodexUsageStatusline(ctx, false);
|
|
394
|
+
void refreshCurrentCodexUsageStatusline(ctx, false).catch(handleAsyncTimerError);
|
|
352
395
|
else clearUsageStatusline(ctx);
|
|
353
396
|
});
|
|
354
397
|
|
|
355
398
|
pi.on("model_select", (event, ctx) => {
|
|
356
399
|
if (isOpenAICodexModel(event.model)) {
|
|
357
|
-
void refreshCurrentCodexUsageStatusline(ctx, false, event.model);
|
|
400
|
+
void refreshCurrentCodexUsageStatusline(ctx, false, event.model).catch(handleAsyncTimerError);
|
|
358
401
|
} else {
|
|
359
402
|
clearUsageStatusline(ctx);
|
|
360
403
|
}
|
|
@@ -367,12 +410,45 @@ export default function codexUsage(pi: ExtensionAPI) {
|
|
|
367
410
|
});
|
|
368
411
|
}
|
|
369
412
|
|
|
413
|
+
function handleAsyncTimerError(error: unknown): void {
|
|
414
|
+
handleTimerError(error);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function handleTimerError(error: unknown): void {
|
|
418
|
+
if (isStaleExtensionContextError(error)) return;
|
|
419
|
+
throw error;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
export function isStaleExtensionContextError(error: unknown): boolean {
|
|
423
|
+
return error instanceof Error && error.message.includes("ctx is stale");
|
|
424
|
+
}
|
|
425
|
+
|
|
370
426
|
function isOpenAICodexModel(
|
|
371
427
|
model: Pick<PiModel, "provider"> | undefined,
|
|
372
428
|
): boolean {
|
|
373
429
|
return model?.provider === CODEX_PROVIDER_ID;
|
|
374
430
|
}
|
|
375
431
|
|
|
432
|
+
function isSparkCodexModel(
|
|
433
|
+
model: Pick<PiModel, "id" | "name" | "provider"> | undefined,
|
|
434
|
+
): boolean {
|
|
435
|
+
if (!isOpenAICodexModel(model)) return false;
|
|
436
|
+
const key = `${model?.id ?? ""} ${model?.name ?? ""}`.toLowerCase();
|
|
437
|
+
return key.includes(SPARK_MODEL_KEY);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function activeUsageLimitId(
|
|
441
|
+
model: Pick<PiModel, "id" | "name" | "provider"> | undefined,
|
|
442
|
+
): string {
|
|
443
|
+
return isSparkCodexModel(model) ? SPARK_USAGE_LIMIT_ID : CODEX_USAGE_LIMIT_ID;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function activeUsageLabel(
|
|
447
|
+
model: Pick<PiModel, "id" | "name" | "provider"> | undefined,
|
|
448
|
+
): string {
|
|
449
|
+
return isSparkCodexModel(model) ? SPARK_STATUS_LABEL_TEXT : DEFAULT_STATUS_LABEL_TEXT;
|
|
450
|
+
}
|
|
451
|
+
|
|
376
452
|
async function importTelegramStatusLineModule(): Promise<
|
|
377
453
|
TelegramStatusLineModule | undefined
|
|
378
454
|
> {
|
|
@@ -401,25 +477,29 @@ async function registerCodexUsageTelegramStatusLine(
|
|
|
401
477
|
async function queryUsage(
|
|
402
478
|
ctx: ExtensionContext,
|
|
403
479
|
options: Pick<QueryUsageOptions, "timeoutMs">,
|
|
480
|
+
model: CodexUsageModel | undefined,
|
|
404
481
|
): Promise<QueryUsageResult> {
|
|
405
482
|
const errors: UsageQueryError[] = [];
|
|
483
|
+
const sources = isSparkCodexModel(model)
|
|
484
|
+
? (["codex-app-server", "pi-auth"] as const)
|
|
485
|
+
: (["pi-auth", "codex-app-server"] as const);
|
|
406
486
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
cause,
|
|
422
|
-
}
|
|
487
|
+
for (const source of sources) {
|
|
488
|
+
try {
|
|
489
|
+
const report =
|
|
490
|
+
source === "pi-auth"
|
|
491
|
+
? await queryViaPiAuth(ctx, options.timeoutMs)
|
|
492
|
+
: await queryViaCodexAppServer(options.timeoutMs);
|
|
493
|
+
if (selectUsageSnapshot(report, activeUsageLimitId(model))) {
|
|
494
|
+
return { ok: true, report };
|
|
495
|
+
}
|
|
496
|
+
errors.push({
|
|
497
|
+
source,
|
|
498
|
+
message: `${source} returned no displayable ${activeUsageLabel(model)} rate-limit windows`,
|
|
499
|
+
});
|
|
500
|
+
} catch (cause) {
|
|
501
|
+
errors.push({ source, message: errorMessage(cause), cause });
|
|
502
|
+
}
|
|
423
503
|
}
|
|
424
504
|
|
|
425
505
|
return { ok: false, errors };
|
|
@@ -711,17 +791,42 @@ export function normalizeBackendPayload(
|
|
|
711
791
|
_capturedAt: number,
|
|
712
792
|
_source: UsageSource,
|
|
713
793
|
): CodexUsageReport {
|
|
714
|
-
const
|
|
715
|
-
|
|
794
|
+
const snapshots: NormalizedRateLimitSnapshot[] = [];
|
|
795
|
+
const primarySnapshot = normalizeBackendSnapshot(
|
|
796
|
+
CODEX_USAGE_LIMIT_ID,
|
|
716
797
|
payload.rate_limit,
|
|
717
798
|
_capturedAt,
|
|
718
799
|
);
|
|
719
|
-
if (
|
|
800
|
+
if (primarySnapshot) snapshots.push(primarySnapshot);
|
|
801
|
+
|
|
802
|
+
if (Array.isArray(payload.additional_rate_limits)) {
|
|
803
|
+
for (const item of payload.additional_rate_limits) {
|
|
804
|
+
const additional = assertObject(
|
|
805
|
+
item,
|
|
806
|
+
"additional rate limit",
|
|
807
|
+
) as BackendAdditionalRateLimit;
|
|
808
|
+
const snapshot = normalizeBackendSnapshot(
|
|
809
|
+
backendAdditionalLimitId(additional),
|
|
810
|
+
additional.rate_limit,
|
|
811
|
+
_capturedAt,
|
|
812
|
+
);
|
|
813
|
+
if (snapshot) snapshots.push(snapshot);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if (snapshots.length === 0) {
|
|
720
818
|
throw new Error(
|
|
721
819
|
"Codex usage endpoint returned no displayable rate-limit windows.",
|
|
722
820
|
);
|
|
723
821
|
}
|
|
724
|
-
return { snapshots
|
|
822
|
+
return { snapshots };
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function backendAdditionalLimitId(limit: BackendAdditionalRateLimit): string {
|
|
826
|
+
const raw = `${asString(limit.limit_name) ?? ""} ${asString(limit.metered_feature) ?? ""}`;
|
|
827
|
+
return raw.toLowerCase().includes("spark")
|
|
828
|
+
? SPARK_USAGE_LIMIT_ID
|
|
829
|
+
: normalizedUsageKey(raw) ?? CODEX_USAGE_LIMIT_ID;
|
|
725
830
|
}
|
|
726
831
|
|
|
727
832
|
function normalizeBackendSnapshot(
|
|
@@ -859,15 +964,16 @@ function mergeSnapshot(
|
|
|
859
964
|
export function formatCodexUsageStatusline(
|
|
860
965
|
report: CodexUsageReport,
|
|
861
966
|
ctx: ExtensionContext,
|
|
862
|
-
|
|
967
|
+
model?: CodexUsageModel,
|
|
863
968
|
): string {
|
|
864
|
-
const value = formatCodexUsageStatusValue(report);
|
|
865
|
-
if (!value) return formatStatuslineText(ctx, "n/a");
|
|
969
|
+
const value = formatCodexUsageStatusValue(report, model);
|
|
970
|
+
if (!value) return formatStatuslineText(ctx, "n/a", model);
|
|
866
971
|
const [bar, countdown] = value.split(" ", 2);
|
|
867
972
|
const barText = formatStatuslineBarText(
|
|
868
973
|
ctx,
|
|
869
974
|
bar ?? "",
|
|
870
|
-
hasExhaustedQuotaWindow(report) ? "toolErrorBg" : "selectedBg",
|
|
975
|
+
hasExhaustedQuotaWindow(report, model) ? "toolErrorBg" : "selectedBg",
|
|
976
|
+
model,
|
|
871
977
|
);
|
|
872
978
|
return countdown
|
|
873
979
|
? `${barText} ${ctx.ui.theme.fg("dim", countdown)}`
|
|
@@ -882,31 +988,40 @@ export function formatCodexUsageBar(
|
|
|
882
988
|
|
|
883
989
|
export function formatCodexUsageStatusValue(
|
|
884
990
|
report: CodexUsageReport,
|
|
991
|
+
modelOrNow?: CodexUsageModel | number,
|
|
885
992
|
now = Date.now(),
|
|
886
993
|
): string | undefined {
|
|
887
|
-
const
|
|
994
|
+
const model = typeof modelOrNow === "number" ? undefined : modelOrNow;
|
|
995
|
+
const capturedNow = typeof modelOrNow === "number" ? modelOrNow : now;
|
|
996
|
+
const snapshot = selectActiveUsageSnapshot(report, model);
|
|
888
997
|
if (!snapshot || (!snapshot.primary && !snapshot.secondary)) return undefined;
|
|
889
998
|
const bar = formatDualLimitBar(snapshot.primary, snapshot.secondary);
|
|
890
999
|
if (isQuotaWindowExhausted(snapshot.primary) && snapshot.primary?.resetAt) {
|
|
891
|
-
const primaryCountdown = formatResetCountdown(
|
|
1000
|
+
const primaryCountdown = formatResetCountdown(
|
|
1001
|
+
snapshot.primary.resetAt,
|
|
1002
|
+
capturedNow,
|
|
1003
|
+
);
|
|
892
1004
|
const weeklyCountdown = snapshot.secondary?.resetAt
|
|
893
|
-
? formatResetCountdown(snapshot.secondary.resetAt,
|
|
1005
|
+
? formatResetCountdown(snapshot.secondary.resetAt, capturedNow)
|
|
894
1006
|
: undefined;
|
|
895
1007
|
return weeklyCountdown
|
|
896
1008
|
? `${bar} ${primaryCountdown}/${weeklyCountdown}`
|
|
897
1009
|
: `${bar} ${primaryCountdown}`;
|
|
898
1010
|
}
|
|
899
|
-
const countdown = formatWeeklyResetCountdown(report,
|
|
1011
|
+
const countdown = formatWeeklyResetCountdown(report, model, capturedNow);
|
|
900
1012
|
return countdown ? `${bar} ${countdown}` : bar;
|
|
901
1013
|
}
|
|
902
1014
|
|
|
903
1015
|
export function formatWeeklyResetCountdown(
|
|
904
1016
|
report: CodexUsageReport,
|
|
1017
|
+
modelOrNow?: CodexUsageModel | number,
|
|
905
1018
|
now = Date.now(),
|
|
906
1019
|
): string | undefined {
|
|
907
|
-
const
|
|
1020
|
+
const model = typeof modelOrNow === "number" ? undefined : modelOrNow;
|
|
1021
|
+
const capturedNow = typeof modelOrNow === "number" ? modelOrNow : now;
|
|
1022
|
+
const resetAt = selectActiveUsageSnapshot(report, model)?.secondary?.resetAt;
|
|
908
1023
|
if (resetAt === undefined) return undefined;
|
|
909
|
-
return formatResetCountdown(resetAt,
|
|
1024
|
+
return formatResetCountdown(resetAt, capturedNow);
|
|
910
1025
|
}
|
|
911
1026
|
|
|
912
1027
|
export function formatResetCountdown(
|
|
@@ -930,8 +1045,9 @@ export function formatResetCountdown(
|
|
|
930
1045
|
export function nextResetCountdownDelayMs(
|
|
931
1046
|
report: CodexUsageReport,
|
|
932
1047
|
now = Date.now(),
|
|
1048
|
+
model?: CodexUsageModel,
|
|
933
1049
|
): number | undefined {
|
|
934
|
-
const snapshot =
|
|
1050
|
+
const snapshot = selectActiveUsageSnapshot(report, model);
|
|
935
1051
|
const resetTimes = [snapshot?.secondary?.resetAt];
|
|
936
1052
|
if (isQuotaWindowExhausted(snapshot?.primary)) {
|
|
937
1053
|
resetTimes.push(snapshot?.primary?.resetAt);
|
|
@@ -972,14 +1088,21 @@ function formatTenths(value: number): string {
|
|
|
972
1088
|
return value % 10 === 0 ? String(value / 10) : (value / 10).toFixed(1);
|
|
973
1089
|
}
|
|
974
1090
|
|
|
975
|
-
function formatReportBar(
|
|
976
|
-
|
|
1091
|
+
function formatReportBar(
|
|
1092
|
+
report: CodexUsageReport,
|
|
1093
|
+
model?: CodexUsageModel,
|
|
1094
|
+
): string | undefined {
|
|
1095
|
+
const snapshot = selectActiveUsageSnapshot(report, model);
|
|
977
1096
|
if (!snapshot || (!snapshot.primary && !snapshot.secondary)) return undefined;
|
|
978
1097
|
return formatDualLimitBar(snapshot.primary, snapshot.secondary);
|
|
979
1098
|
}
|
|
980
1099
|
|
|
981
|
-
function formatStatuslineText(
|
|
982
|
-
|
|
1100
|
+
function formatStatuslineText(
|
|
1101
|
+
ctx: ExtensionContext,
|
|
1102
|
+
value: string,
|
|
1103
|
+
model?: CodexUsageModel,
|
|
1104
|
+
): string {
|
|
1105
|
+
const label = ctx.ui.theme.fg("accent", activeUsageLabel(model));
|
|
983
1106
|
return `${label} ${ctx.ui.theme.fg("dim", value)}`;
|
|
984
1107
|
}
|
|
985
1108
|
|
|
@@ -987,8 +1110,9 @@ function formatStatuslineBarText(
|
|
|
987
1110
|
ctx: ExtensionContext,
|
|
988
1111
|
bar: string,
|
|
989
1112
|
background: "selectedBg" | "toolErrorBg" = "selectedBg",
|
|
1113
|
+
model?: CodexUsageModel,
|
|
990
1114
|
): string {
|
|
991
|
-
const label = ctx.ui.theme.fg("accent",
|
|
1115
|
+
const label = ctx.ui.theme.fg("accent", activeUsageLabel(model));
|
|
992
1116
|
const value = ctx.ui.theme.bg(background, ctx.ui.theme.fg("dim", bar));
|
|
993
1117
|
return `${label} ${value}`;
|
|
994
1118
|
}
|
|
@@ -1000,8 +1124,9 @@ function formatEmptyStatuslineBar(ctx: ExtensionContext): string {
|
|
|
1000
1124
|
function formatStatuslineProblem(
|
|
1001
1125
|
ctx: ExtensionContext,
|
|
1002
1126
|
errors: UsageQueryError[],
|
|
1127
|
+
model?: CodexUsageModel,
|
|
1003
1128
|
): string {
|
|
1004
|
-
const label = ctx.ui.theme.fg("accent",
|
|
1129
|
+
const label = ctx.ui.theme.fg("accent", activeUsageLabel(model));
|
|
1005
1130
|
const value = isUsageUnavailable(errors)
|
|
1006
1131
|
? ctx.ui.theme.fg("muted", "n/a")
|
|
1007
1132
|
: ctx.ui.theme.fg("error", "error");
|
|
@@ -1030,12 +1155,6 @@ function isUnavailableError(error: UsageQueryError): boolean {
|
|
|
1030
1155
|
);
|
|
1031
1156
|
}
|
|
1032
1157
|
|
|
1033
|
-
function selectPrimaryCodexSnapshot(
|
|
1034
|
-
report: CodexUsageReport,
|
|
1035
|
-
): NormalizedRateLimitSnapshot | undefined {
|
|
1036
|
-
return report.snapshots.find(isPrimaryCodexSnapshot);
|
|
1037
|
-
}
|
|
1038
|
-
|
|
1039
1158
|
function normalizedUsageKey(value: string | undefined): string | undefined {
|
|
1040
1159
|
const key = value
|
|
1041
1160
|
?.toLowerCase()
|
|
@@ -1090,18 +1209,39 @@ function isQuotaWindowExhausted(
|
|
|
1090
1209
|
return window !== undefined && remainingPercent(window) <= 0;
|
|
1091
1210
|
}
|
|
1092
1211
|
|
|
1093
|
-
function hasExhaustedQuotaWindow(
|
|
1094
|
-
|
|
1212
|
+
function hasExhaustedQuotaWindow(
|
|
1213
|
+
report: CodexUsageReport,
|
|
1214
|
+
model?: CodexUsageModel,
|
|
1215
|
+
): boolean {
|
|
1216
|
+
const snapshot = selectActiveUsageSnapshot(report, model);
|
|
1095
1217
|
return (
|
|
1096
1218
|
isQuotaWindowExhausted(snapshot?.primary) ||
|
|
1097
1219
|
isQuotaWindowExhausted(snapshot?.secondary)
|
|
1098
1220
|
);
|
|
1099
1221
|
}
|
|
1100
1222
|
|
|
1101
|
-
function
|
|
1102
|
-
|
|
1223
|
+
export function canReuseCachedReport(
|
|
1224
|
+
report: CodexUsageReport,
|
|
1225
|
+
model: CodexUsageModel | undefined,
|
|
1103
1226
|
): boolean {
|
|
1104
|
-
return
|
|
1227
|
+
return selectActiveUsageSnapshot(report, model) !== undefined;
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function selectActiveUsageSnapshot(
|
|
1231
|
+
report: CodexUsageReport,
|
|
1232
|
+
model: CodexUsageModel | undefined,
|
|
1233
|
+
): NormalizedRateLimitSnapshot | undefined {
|
|
1234
|
+
return selectUsageSnapshot(report, activeUsageLimitId(model));
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
function selectUsageSnapshot(
|
|
1238
|
+
report: CodexUsageReport,
|
|
1239
|
+
limitId: string,
|
|
1240
|
+
): NormalizedRateLimitSnapshot | undefined {
|
|
1241
|
+
const normalizedLimitId = normalizedUsageKey(limitId);
|
|
1242
|
+
return report.snapshots.find(
|
|
1243
|
+
(snapshot) => normalizedUsageKey(snapshot.limitId) === normalizedLimitId,
|
|
1244
|
+
);
|
|
1105
1245
|
}
|
|
1106
1246
|
|
|
1107
1247
|
function clampPercent(value: number): number {
|