@llblab/pi-codex-usage 0.4.1 → 0.5.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/AGENTS.md +4 -0
- package/BACKLOG.md +1 -1
- package/CHANGELOG.md +12 -0
- package/README.md +5 -3
- package/banner.png +0 -0
- package/index.ts +242 -38
- package/package.json +3 -2
package/AGENTS.md
CHANGED
|
@@ -11,3 +11,7 @@
|
|
|
11
11
|
- `Compact dual bar`: Encode the 5-hour and weekly quota windows in the ten-character separated-sextant bar.
|
|
12
12
|
- Trigger: Changing statusline formatting.
|
|
13
13
|
- Action: Keep a fixed-width `xxxxxxxxxx` bar where top sextants represent the 5-hour window and bottom sextants represent the weekly window, with 20 steps per window.
|
|
14
|
+
|
|
15
|
+
- `Weekly reset countdown`: Append the weekly reset countdown only when the secondary window exposes a reset time.
|
|
16
|
+
- Trigger: Changing reset-time normalization or statusline refresh cadence.
|
|
17
|
+
- Action: Keep `d` labels rounded upward in 144-minute day-tenth steps, `h`/`m`/`s` labels floored, and hold `0s` until a successful quota refresh reports the next window.
|
package/BACKLOG.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
# Backlog
|
|
2
2
|
|
|
3
|
-
- [ ] `Runtime smoke test` Verify the extension inside a live Pi session against
|
|
3
|
+
- [ ] `Runtime smoke test` Verify the extension inside a live Pi session against missing auth, missing subscription, and transient network failure. Usable subscription with weekly reset countdown has been checked against real Codex auth.
|
|
4
4
|
- [ ] `Statusline polish` Tune the successful-refresh blink duration if the current 150ms redraw feels too visible or too subtle in the Pi footer.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.5.1: Non-Codex Bucket Hotfix
|
|
6
|
+
|
|
7
|
+
- Fixed non-Codex app-server quota buckets so Spark-only or unrelated limits are ignored instead of being displayed as Codex quota.
|
|
8
|
+
|
|
9
|
+
## 0.5.0: Weekly Reset Countdown
|
|
10
|
+
|
|
11
|
+
- Added refresh request coalescing so repeated statusline events share one quota lookup instead of spawning parallel provider/fallback requests. Impact: transient failures and busy session-tree updates no longer amplify Codex usage polling work.
|
|
12
|
+
- Hardened quota parsing and failure classification by accepting array-shaped app-server rate limits, treating `n/a` as valid only when every failed source reports an unavailable auth/plan/quota state, and adding `node:test` coverage for normalization, bar formatting, and failure classification. Impact: real fallback/runtime failures are surfaced as `error` while expected unavailable states still show `n/a`.
|
|
13
|
+
- Added a weekly reset countdown after the quota bar when the secondary Codex window exposes a reset timestamp, including 144-minute day-tenth steps, exact boundary redraw scheduling, hour/minute/second bucket formatting, and `0s` holdover until the next successful quota refresh. Impact: the statusline now shows both remaining quota and time until the weekly bucket cycles.
|
|
14
|
+
|
|
3
15
|
## 0.4.1: Stable Startup Bar
|
|
4
16
|
|
|
5
17
|
- Fixed the initial empty statusline bar to use ten non-trimmed blank glyph cells through the same formatting path as the populated quota bar. Impact: the footer background stays at ten cells during startup before the first quota values arrive.
|
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|

|
|
6
6
|
|
|
7
|
-
This repository is a minimal fork of [`narumiruna/pi-extensions/extensions/pi-codex-usage`](https://github.com/narumiruna/pi-extensions/tree/main/extensions/pi-codex-usage). It keeps the auth and quota-fetching path, but intentionally narrows the interface to the primary Codex 5-hour and weekly
|
|
7
|
+
This repository is a minimal fork of [`narumiruna/pi-extensions/extensions/pi-codex-usage`](https://github.com/narumiruna/pi-extensions/tree/main/extensions/pi-codex-usage). It keeps the auth and quota-fetching path, but intentionally narrows the interface to the primary Codex 5-hour and weekly limits.
|
|
8
8
|
|
|
9
9
|
## Start Here
|
|
10
10
|
|
|
@@ -15,7 +15,7 @@ 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 `codex` label accented and the quota bar drawn on a themed background
|
|
18
|
+
- Statusline output stays compact, with the `codex` label accented and the quota bar plus weekly reset countdown drawn on a themed background
|
|
19
19
|
- Additional returned buckets, including Spark-specific limits, are ignored
|
|
20
20
|
- Pi OpenAI Codex provider auth is used first
|
|
21
21
|
- Codex CLI app-server remains available as a fallback
|
|
@@ -43,11 +43,13 @@ pi install git:github.com/llblab/pi-codex-usage
|
|
|
43
43
|
Normal usage:
|
|
44
44
|
|
|
45
45
|
```text
|
|
46
|
-
codex ██████▀▀▀▀
|
|
46
|
+
codex ██████▀▀▀▀ 6d
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
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.
|
|
50
50
|
|
|
51
|
+
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`, `1.1d`, and `1d`, rounded upward to the next tenth. Under a day it switches to floored hours, under an hour 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.
|
|
52
|
+
|
|
51
53
|
Unavailable because Codex auth or subscription quota is not available:
|
|
52
54
|
|
|
53
55
|
```text
|
package/banner.png
CHANGED
|
Binary file
|
package/index.ts
CHANGED
|
@@ -9,7 +9,12 @@ import type {
|
|
|
9
9
|
const CODEX_PROVIDER_ID = "openai-codex";
|
|
10
10
|
const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
11
11
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
12
|
-
const
|
|
12
|
+
const SECOND_MS = 1000;
|
|
13
|
+
const MINUTE_MS = 60 * SECOND_MS;
|
|
14
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
15
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
16
|
+
const DAY_TENTH_MS = 144 * MINUTE_MS;
|
|
17
|
+
const REFRESH_INTERVAL_MS = 30 * SECOND_MS;
|
|
13
18
|
const REDRAW_BLINK_MS = 150;
|
|
14
19
|
const STATUS_KEY = "aa-codex-usage";
|
|
15
20
|
const MAX_ERROR_BODY_CHARS = 600;
|
|
@@ -52,7 +57,7 @@ type QueryUsageResult =
|
|
|
52
57
|
| { ok: true; report: CodexUsageReport }
|
|
53
58
|
| { ok: false; errors: UsageQueryError[] };
|
|
54
59
|
|
|
55
|
-
type UsageQueryError = {
|
|
60
|
+
export type UsageQueryError = {
|
|
56
61
|
source: UsageSource;
|
|
57
62
|
message: string;
|
|
58
63
|
cause?: unknown;
|
|
@@ -70,6 +75,7 @@ export type NormalizedRateLimitSnapshot = {
|
|
|
70
75
|
|
|
71
76
|
export type NormalizedRateLimitWindow = {
|
|
72
77
|
usedPercent: number;
|
|
78
|
+
resetAt?: number;
|
|
73
79
|
};
|
|
74
80
|
|
|
75
81
|
type RateLimitStatusPayload = {
|
|
@@ -83,6 +89,13 @@ type BackendRateLimitDetails = {
|
|
|
83
89
|
|
|
84
90
|
type BackendWindowSnapshot = {
|
|
85
91
|
used_percent?: unknown;
|
|
92
|
+
reset_at?: unknown;
|
|
93
|
+
resets_at?: unknown;
|
|
94
|
+
reset_time?: unknown;
|
|
95
|
+
end_time?: unknown;
|
|
96
|
+
ends_at?: unknown;
|
|
97
|
+
expires_at?: unknown;
|
|
98
|
+
reset_after_seconds?: unknown;
|
|
86
99
|
};
|
|
87
100
|
|
|
88
101
|
type AppServerRateLimitResponse = {
|
|
@@ -97,6 +110,13 @@ type AppServerRateLimitSnapshot = {
|
|
|
97
110
|
|
|
98
111
|
type AppServerWindowSnapshot = {
|
|
99
112
|
usedPercent?: unknown;
|
|
113
|
+
resetAt?: unknown;
|
|
114
|
+
resetsAt?: unknown;
|
|
115
|
+
resetTime?: unknown;
|
|
116
|
+
endTime?: unknown;
|
|
117
|
+
endsAt?: unknown;
|
|
118
|
+
expiresAt?: unknown;
|
|
119
|
+
resetAfterSeconds?: unknown;
|
|
100
120
|
};
|
|
101
121
|
|
|
102
122
|
type RpcResponse = {
|
|
@@ -113,17 +133,21 @@ type PendingRpc = {
|
|
|
113
133
|
export default function codexUsage(pi: ExtensionAPI) {
|
|
114
134
|
let cache: CachedReport | undefined;
|
|
115
135
|
let failedRefreshes = 0;
|
|
136
|
+
let inFlightUsageQuery: Promise<QueryUsageResult> | undefined;
|
|
116
137
|
let statuslineBlinkTimer: TimeoutHandle | undefined;
|
|
117
138
|
let statuslineClearTimer: TimeoutHandle | undefined;
|
|
139
|
+
let statuslineCountdownTimer: TimeoutHandle | undefined;
|
|
118
140
|
let statuslineRefreshTimer: TimeoutHandle | undefined;
|
|
119
141
|
let statuslineRequestId = 0;
|
|
120
142
|
|
|
121
143
|
const clearStatuslineTimers = () => {
|
|
122
144
|
if (statuslineBlinkTimer) clearTimeout(statuslineBlinkTimer);
|
|
123
145
|
if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
|
|
146
|
+
if (statuslineCountdownTimer) clearTimeout(statuslineCountdownTimer);
|
|
124
147
|
if (statuslineRefreshTimer) clearTimeout(statuslineRefreshTimer);
|
|
125
148
|
statuslineBlinkTimer = undefined;
|
|
126
149
|
statuslineClearTimer = undefined;
|
|
150
|
+
statuslineCountdownTimer = undefined;
|
|
127
151
|
statuslineRefreshTimer = undefined;
|
|
128
152
|
};
|
|
129
153
|
|
|
@@ -150,6 +174,29 @@ export default function codexUsage(pi: ExtensionAPI) {
|
|
|
150
174
|
statuslineRefreshTimer.unref?.();
|
|
151
175
|
};
|
|
152
176
|
|
|
177
|
+
const scheduleStatuslineCountdown = (
|
|
178
|
+
ctx: ExtensionContext,
|
|
179
|
+
report: CodexUsageReport,
|
|
180
|
+
model: CodexUsageModel | undefined,
|
|
181
|
+
) => {
|
|
182
|
+
if (statuslineCountdownTimer) clearTimeout(statuslineCountdownTimer);
|
|
183
|
+
const delayMs = nextResetCountdownDelayMs(report);
|
|
184
|
+
if (delayMs === undefined) {
|
|
185
|
+
statuslineCountdownTimer = undefined;
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
statuslineCountdownTimer = setTimeout(() => {
|
|
189
|
+
if (isOpenAICodexModel(ctx.model)) {
|
|
190
|
+
ctx.ui.setStatus(
|
|
191
|
+
STATUS_KEY,
|
|
192
|
+
formatCodexUsageStatusline(report, ctx, model),
|
|
193
|
+
);
|
|
194
|
+
scheduleStatuslineCountdown(ctx, report, model);
|
|
195
|
+
}
|
|
196
|
+
}, delayMs) as TimeoutHandle;
|
|
197
|
+
statuslineCountdownTimer.unref?.();
|
|
198
|
+
};
|
|
199
|
+
|
|
153
200
|
const setUsageStatusline = (
|
|
154
201
|
ctx: ExtensionContext,
|
|
155
202
|
report: CodexUsageReport,
|
|
@@ -161,23 +208,38 @@ export default function codexUsage(pi: ExtensionAPI) {
|
|
|
161
208
|
) => {
|
|
162
209
|
if (statuslineBlinkTimer) clearTimeout(statuslineBlinkTimer);
|
|
163
210
|
if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
|
|
211
|
+
if (statuslineCountdownTimer) clearTimeout(statuslineCountdownTimer);
|
|
164
212
|
statuslineBlinkTimer = undefined;
|
|
165
213
|
statuslineClearTimer = undefined;
|
|
214
|
+
statuslineCountdownTimer = undefined;
|
|
166
215
|
const text = formatCodexUsageStatusline(report, ctx, options.model);
|
|
167
216
|
if (options.blink) {
|
|
168
217
|
ctx.ui.setStatus(STATUS_KEY, formatEmptyStatuslineBar(ctx));
|
|
169
218
|
statuslineBlinkTimer = setTimeout(() => {
|
|
170
219
|
ctx.ui.setStatus(STATUS_KEY, text);
|
|
220
|
+
scheduleStatuslineCountdown(ctx, report, options.model);
|
|
171
221
|
statuslineBlinkTimer = undefined;
|
|
172
222
|
}, REDRAW_BLINK_MS) as TimeoutHandle;
|
|
173
223
|
statuslineBlinkTimer.unref?.();
|
|
174
224
|
} else {
|
|
175
225
|
ctx.ui.setStatus(STATUS_KEY, text);
|
|
226
|
+
scheduleStatuslineCountdown(ctx, report, options.model);
|
|
176
227
|
}
|
|
177
228
|
if (options.autoRefresh) scheduleStatuslineRefresh(ctx);
|
|
178
229
|
else scheduleTemporaryStatuslineClear(ctx);
|
|
179
230
|
};
|
|
180
231
|
|
|
232
|
+
const queryCurrentUsage = (ctx: ExtensionContext) => {
|
|
233
|
+
if (!inFlightUsageQuery) {
|
|
234
|
+
inFlightUsageQuery = queryUsage(ctx, {
|
|
235
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
236
|
+
}).finally(() => {
|
|
237
|
+
inFlightUsageQuery = undefined;
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return inFlightUsageQuery;
|
|
241
|
+
};
|
|
242
|
+
|
|
181
243
|
const refreshCurrentCodexUsageStatusline = async (
|
|
182
244
|
ctx: ExtensionContext,
|
|
183
245
|
force: boolean,
|
|
@@ -204,7 +266,7 @@ export default function codexUsage(pi: ExtensionAPI) {
|
|
|
204
266
|
return;
|
|
205
267
|
}
|
|
206
268
|
|
|
207
|
-
const result = await
|
|
269
|
+
const result = await queryCurrentUsage(ctx);
|
|
208
270
|
if (requestId !== statuslineRequestId) return;
|
|
209
271
|
if (!isOpenAICodexModel(ctx.model)) {
|
|
210
272
|
clearUsageStatusline(ctx);
|
|
@@ -578,7 +640,11 @@ export function normalizeBackendPayload(
|
|
|
578
640
|
_capturedAt: number,
|
|
579
641
|
_source: UsageSource,
|
|
580
642
|
): CodexUsageReport {
|
|
581
|
-
const snapshot = normalizeBackendSnapshot(
|
|
643
|
+
const snapshot = normalizeBackendSnapshot(
|
|
644
|
+
"codex",
|
|
645
|
+
payload.rate_limit,
|
|
646
|
+
_capturedAt,
|
|
647
|
+
);
|
|
582
648
|
if (!snapshot) {
|
|
583
649
|
throw new Error(
|
|
584
650
|
"Codex usage endpoint returned no displayable rate-limit windows.",
|
|
@@ -590,20 +656,25 @@ export function normalizeBackendPayload(
|
|
|
590
656
|
function normalizeBackendSnapshot(
|
|
591
657
|
limitId: string,
|
|
592
658
|
rateLimit: unknown,
|
|
659
|
+
capturedAt: number,
|
|
593
660
|
): NormalizedRateLimitSnapshot | undefined {
|
|
594
661
|
if (rateLimit === null || rateLimit === undefined) return undefined;
|
|
595
662
|
const details = assertObject(
|
|
596
663
|
rateLimit,
|
|
597
664
|
"rate limit",
|
|
598
665
|
) as BackendRateLimitDetails;
|
|
599
|
-
const primary = normalizeBackendWindow(details.primary_window);
|
|
600
|
-
const secondary = normalizeBackendWindow(
|
|
666
|
+
const primary = normalizeBackendWindow(details.primary_window, capturedAt);
|
|
667
|
+
const secondary = normalizeBackendWindow(
|
|
668
|
+
details.secondary_window,
|
|
669
|
+
capturedAt,
|
|
670
|
+
);
|
|
601
671
|
if (!primary && !secondary) return undefined;
|
|
602
672
|
return { limitId, primary, secondary };
|
|
603
673
|
}
|
|
604
674
|
|
|
605
675
|
function normalizeBackendWindow(
|
|
606
676
|
value: unknown,
|
|
677
|
+
capturedAt: number,
|
|
607
678
|
): NormalizedRateLimitWindow | undefined {
|
|
608
679
|
if (value === null || value === undefined) return undefined;
|
|
609
680
|
const window = assertObject(
|
|
@@ -612,7 +683,19 @@ function normalizeBackendWindow(
|
|
|
612
683
|
) as BackendWindowSnapshot;
|
|
613
684
|
const usedPercent = asNumber(window.used_percent);
|
|
614
685
|
if (usedPercent === undefined) return undefined;
|
|
615
|
-
|
|
686
|
+
const resetAt = asResetTime(
|
|
687
|
+
[
|
|
688
|
+
window.reset_at,
|
|
689
|
+
window.resets_at,
|
|
690
|
+
window.reset_time,
|
|
691
|
+
window.end_time,
|
|
692
|
+
window.ends_at,
|
|
693
|
+
window.expires_at,
|
|
694
|
+
],
|
|
695
|
+
window.reset_after_seconds,
|
|
696
|
+
capturedAt,
|
|
697
|
+
);
|
|
698
|
+
return resetAt === undefined ? { usedPercent } : { usedPercent, resetAt };
|
|
616
699
|
}
|
|
617
700
|
|
|
618
701
|
export function normalizeAppServerResponse(
|
|
@@ -621,7 +704,7 @@ export function normalizeAppServerResponse(
|
|
|
621
704
|
): CodexUsageReport {
|
|
622
705
|
const snapshots: NormalizedRateLimitSnapshot[] = [];
|
|
623
706
|
const addSnapshot = (raw: unknown, fallbackId: string) => {
|
|
624
|
-
const snapshot = normalizeAppServerSnapshot(raw, fallbackId);
|
|
707
|
+
const snapshot = normalizeAppServerSnapshot(raw, fallbackId, _capturedAt);
|
|
625
708
|
if (!snapshot) return;
|
|
626
709
|
const existingIndex = snapshots.findIndex(
|
|
627
710
|
(item) => item.limitId === snapshot.limitId,
|
|
@@ -634,7 +717,11 @@ export function normalizeAppServerResponse(
|
|
|
634
717
|
else snapshots.push(snapshot);
|
|
635
718
|
};
|
|
636
719
|
|
|
637
|
-
|
|
720
|
+
if (Array.isArray(response.rateLimits)) {
|
|
721
|
+
for (const item of response.rateLimits) addSnapshot(item, "codex");
|
|
722
|
+
} else {
|
|
723
|
+
addSnapshot(response.rateLimits, "codex");
|
|
724
|
+
}
|
|
638
725
|
if (snapshots.length === 0) {
|
|
639
726
|
throw new Error(
|
|
640
727
|
"codex app-server returned no displayable rate-limit windows.",
|
|
@@ -647,6 +734,7 @@ export function normalizeAppServerResponse(
|
|
|
647
734
|
function normalizeAppServerSnapshot(
|
|
648
735
|
raw: unknown,
|
|
649
736
|
fallbackId: string,
|
|
737
|
+
capturedAt: number,
|
|
650
738
|
): NormalizedRateLimitSnapshot | undefined {
|
|
651
739
|
if (raw === null || raw === undefined) return undefined;
|
|
652
740
|
const snapshot = assertObject(
|
|
@@ -654,14 +742,15 @@ function normalizeAppServerSnapshot(
|
|
|
654
742
|
"app-server rate-limit snapshot",
|
|
655
743
|
) as AppServerRateLimitSnapshot;
|
|
656
744
|
const limitId = asString(snapshot.limitId) ?? fallbackId;
|
|
657
|
-
const primary = normalizeAppServerWindow(snapshot.primary);
|
|
658
|
-
const secondary = normalizeAppServerWindow(snapshot.secondary);
|
|
745
|
+
const primary = normalizeAppServerWindow(snapshot.primary, capturedAt);
|
|
746
|
+
const secondary = normalizeAppServerWindow(snapshot.secondary, capturedAt);
|
|
659
747
|
if (!primary && !secondary) return undefined;
|
|
660
748
|
return { limitId, primary, secondary };
|
|
661
749
|
}
|
|
662
750
|
|
|
663
751
|
function normalizeAppServerWindow(
|
|
664
752
|
value: unknown,
|
|
753
|
+
capturedAt: number,
|
|
665
754
|
): NormalizedRateLimitWindow | undefined {
|
|
666
755
|
if (value === null || value === undefined) return undefined;
|
|
667
756
|
const window = assertObject(
|
|
@@ -670,7 +759,19 @@ function normalizeAppServerWindow(
|
|
|
670
759
|
) as AppServerWindowSnapshot;
|
|
671
760
|
const usedPercent = asNumber(window.usedPercent);
|
|
672
761
|
if (usedPercent === undefined) return undefined;
|
|
673
|
-
|
|
762
|
+
const resetAt = asResetTime(
|
|
763
|
+
[
|
|
764
|
+
window.resetAt,
|
|
765
|
+
window.resetsAt,
|
|
766
|
+
window.resetTime,
|
|
767
|
+
window.endTime,
|
|
768
|
+
window.endsAt,
|
|
769
|
+
window.expiresAt,
|
|
770
|
+
],
|
|
771
|
+
window.resetAfterSeconds,
|
|
772
|
+
capturedAt,
|
|
773
|
+
);
|
|
774
|
+
return resetAt === undefined ? { usedPercent } : { usedPercent, resetAt };
|
|
674
775
|
}
|
|
675
776
|
|
|
676
777
|
function mergeSnapshot(
|
|
@@ -690,7 +791,81 @@ export function formatCodexUsageStatusline(
|
|
|
690
791
|
_model?: CodexUsageModel,
|
|
691
792
|
): string {
|
|
692
793
|
const bar = formatReportBar(report);
|
|
693
|
-
|
|
794
|
+
if (!bar) return formatStatuslineText(ctx, "n/a");
|
|
795
|
+
const countdown = formatWeeklyResetCountdown(report);
|
|
796
|
+
const barText = formatStatuslineBarText(ctx, bar);
|
|
797
|
+
return countdown
|
|
798
|
+
? `${barText} ${ctx.ui.theme.fg("dim", countdown)}`
|
|
799
|
+
: barText;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
export function formatCodexUsageBar(
|
|
803
|
+
report: CodexUsageReport,
|
|
804
|
+
): string | undefined {
|
|
805
|
+
return formatReportBar(report);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
export function formatWeeklyResetCountdown(
|
|
809
|
+
report: CodexUsageReport,
|
|
810
|
+
now = Date.now(),
|
|
811
|
+
): string | undefined {
|
|
812
|
+
const resetAt = selectPrimaryCodexSnapshot(report)?.secondary?.resetAt;
|
|
813
|
+
if (resetAt === undefined) return undefined;
|
|
814
|
+
return formatResetCountdown(resetAt, now);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
export function formatResetCountdown(
|
|
818
|
+
resetAt: number,
|
|
819
|
+
now = Date.now(),
|
|
820
|
+
): string {
|
|
821
|
+
const remainingMs = Math.max(0, resetAt - now);
|
|
822
|
+
if (remainingMs >= DAY_MS) {
|
|
823
|
+
const dayTenths = Math.max(10, Math.ceil(remainingMs / DAY_TENTH_MS));
|
|
824
|
+
return `${formatTenths(dayTenths)}d`;
|
|
825
|
+
}
|
|
826
|
+
if (remainingMs >= HOUR_MS) return `${Math.floor(remainingMs / HOUR_MS)}h`;
|
|
827
|
+
if (remainingMs >= MINUTE_MS)
|
|
828
|
+
return `${Math.floor(remainingMs / MINUTE_MS)}m`;
|
|
829
|
+
return `${Math.floor(remainingMs / SECOND_MS)}s`;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
export function nextResetCountdownDelayMs(
|
|
833
|
+
report: CodexUsageReport,
|
|
834
|
+
now = Date.now(),
|
|
835
|
+
): number | undefined {
|
|
836
|
+
const resetAt = selectPrimaryCodexSnapshot(report)?.secondary?.resetAt;
|
|
837
|
+
if (resetAt === undefined) return undefined;
|
|
838
|
+
return nextResetCountdownDelayForRemainingMs(resetAt - now);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
export function nextResetCountdownDelayForRemainingMs(
|
|
842
|
+
remainingMs: number,
|
|
843
|
+
): number | undefined {
|
|
844
|
+
if (remainingMs <= 0) return undefined;
|
|
845
|
+
if (remainingMs >= DAY_MS) {
|
|
846
|
+
const dayTenths = Math.max(10, Math.ceil(remainingMs / DAY_TENTH_MS));
|
|
847
|
+
return Math.max(1, remainingMs - (dayTenths - 1) * DAY_TENTH_MS);
|
|
848
|
+
}
|
|
849
|
+
if (remainingMs >= HOUR_MS) {
|
|
850
|
+
return Math.max(
|
|
851
|
+
1,
|
|
852
|
+
remainingMs - Math.floor(remainingMs / HOUR_MS) * HOUR_MS + 1,
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
if (remainingMs >= MINUTE_MS) {
|
|
856
|
+
return Math.max(
|
|
857
|
+
1,
|
|
858
|
+
remainingMs - Math.floor(remainingMs / MINUTE_MS) * MINUTE_MS + 1,
|
|
859
|
+
);
|
|
860
|
+
}
|
|
861
|
+
return Math.max(
|
|
862
|
+
1,
|
|
863
|
+
remainingMs - Math.floor(remainingMs / SECOND_MS) * SECOND_MS + 1,
|
|
864
|
+
);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function formatTenths(value: number): string {
|
|
868
|
+
return value % 10 === 0 ? String(value / 10) : (value / 10).toFixed(1);
|
|
694
869
|
}
|
|
695
870
|
|
|
696
871
|
function formatReportBar(report: CodexUsageReport): string | undefined {
|
|
@@ -706,10 +881,7 @@ function formatStatuslineText(ctx: ExtensionContext, value: string): string {
|
|
|
706
881
|
|
|
707
882
|
function formatStatuslineBarText(ctx: ExtensionContext, bar: string): string {
|
|
708
883
|
const label = ctx.ui.theme.fg("accent", STATUS_LABEL_TEXT);
|
|
709
|
-
const value = ctx.ui.theme.bg(
|
|
710
|
-
"selectedBg",
|
|
711
|
-
ctx.ui.theme.fg("dim", bar),
|
|
712
|
-
);
|
|
884
|
+
const value = ctx.ui.theme.bg("selectedBg", ctx.ui.theme.fg("dim", bar));
|
|
713
885
|
return `${label} ${value}`;
|
|
714
886
|
}
|
|
715
887
|
|
|
@@ -722,36 +894,38 @@ function formatStatuslineProblem(
|
|
|
722
894
|
errors: UsageQueryError[],
|
|
723
895
|
): string {
|
|
724
896
|
const label = ctx.ui.theme.fg("accent", STATUS_LABEL_TEXT);
|
|
725
|
-
const value =
|
|
897
|
+
const value = isUsageUnavailable(errors)
|
|
726
898
|
? ctx.ui.theme.fg("muted", "n/a")
|
|
727
899
|
: ctx.ui.theme.fg("error", "error");
|
|
728
900
|
return `${label} ${value}`;
|
|
729
901
|
}
|
|
730
902
|
|
|
731
|
-
function
|
|
732
|
-
return errors.
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
)
|
|
748
|
-
|
|
903
|
+
export function isUsageUnavailable(errors: UsageQueryError[]): boolean {
|
|
904
|
+
return errors.length > 0 && errors.every(isUnavailableError);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function isUnavailableError(error: UsageQueryError): boolean {
|
|
908
|
+
const message = error.message.toLowerCase();
|
|
909
|
+
return (
|
|
910
|
+
message.includes("no pi openai codex subscription auth") ||
|
|
911
|
+
message.includes("no displayable rate-limit windows") ||
|
|
912
|
+
message.includes("returned no displayable rate-limit windows") ||
|
|
913
|
+
message.includes("returned 401") ||
|
|
914
|
+
message.includes("returned 403") ||
|
|
915
|
+
message.includes("unauthorized") ||
|
|
916
|
+
message.includes("forbidden") ||
|
|
917
|
+
message.includes("subscription") ||
|
|
918
|
+
message.includes("no active plan") ||
|
|
919
|
+
message.includes("plan unavailable") ||
|
|
920
|
+
message.includes("quota unavailable") ||
|
|
921
|
+
message.includes("rate limits unavailable")
|
|
922
|
+
);
|
|
749
923
|
}
|
|
750
924
|
|
|
751
925
|
function selectPrimaryCodexSnapshot(
|
|
752
926
|
report: CodexUsageReport,
|
|
753
927
|
): NormalizedRateLimitSnapshot | undefined {
|
|
754
|
-
return report.snapshots.find(isPrimaryCodexSnapshot)
|
|
928
|
+
return report.snapshots.find(isPrimaryCodexSnapshot);
|
|
755
929
|
}
|
|
756
930
|
|
|
757
931
|
function normalizedUsageKey(value: string | undefined): string | undefined {
|
|
@@ -782,7 +956,9 @@ function formatDualLimitBar(
|
|
|
782
956
|
return value;
|
|
783
957
|
}
|
|
784
958
|
|
|
785
|
-
function filledTwentieths(
|
|
959
|
+
function filledTwentieths(
|
|
960
|
+
window: NormalizedRateLimitWindow | undefined,
|
|
961
|
+
): number {
|
|
786
962
|
if (!window) return 0;
|
|
787
963
|
return Math.round(remainingPercent(window) / 5);
|
|
788
964
|
}
|
|
@@ -840,6 +1016,34 @@ function asNumber(value: unknown): number | undefined {
|
|
|
840
1016
|
return undefined;
|
|
841
1017
|
}
|
|
842
1018
|
|
|
1019
|
+
function asResetTime(
|
|
1020
|
+
absoluteValues: unknown[],
|
|
1021
|
+
relativeSeconds: unknown,
|
|
1022
|
+
capturedAt: number,
|
|
1023
|
+
): number | undefined {
|
|
1024
|
+
for (const value of absoluteValues) {
|
|
1025
|
+
const timestamp = asTimestampMs(value);
|
|
1026
|
+
if (timestamp !== undefined) return timestamp;
|
|
1027
|
+
}
|
|
1028
|
+
const seconds = asNumber(relativeSeconds);
|
|
1029
|
+
if (seconds === undefined || seconds < 0) return undefined;
|
|
1030
|
+
return capturedAt + seconds * SECOND_MS;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function asTimestampMs(value: unknown): number | undefined {
|
|
1034
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
1035
|
+
if (value <= 0) return undefined;
|
|
1036
|
+
return value < 10_000_000_000 ? value * SECOND_MS : value;
|
|
1037
|
+
}
|
|
1038
|
+
if (typeof value === "string" && value.trim()) {
|
|
1039
|
+
const numeric = Number(value);
|
|
1040
|
+
if (Number.isFinite(numeric)) return asTimestampMs(numeric);
|
|
1041
|
+
const parsed = Date.parse(value);
|
|
1042
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
1043
|
+
}
|
|
1044
|
+
return undefined;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
843
1047
|
function hasHeader(headers: Record<string, string>, name: string): boolean {
|
|
844
1048
|
return Object.keys(headers).some(
|
|
845
1049
|
(key) => key.toLowerCase() === name.toLowerCase(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llblab/pi-codex-usage",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Minimal Pi extension that shows primary Codex ChatGPT subscription usage limits.",
|
|
6
6
|
"keywords": [
|
|
@@ -26,9 +26,10 @@
|
|
|
26
26
|
},
|
|
27
27
|
"scripts": {
|
|
28
28
|
"check": "node --experimental-strip-types -e \"await import('./index.ts'); console.log('pi-codex-usage: extension import ok')\"",
|
|
29
|
+
"test": "node --experimental-strip-types --test test/*.test.ts",
|
|
29
30
|
"typecheck": "tsc --noEmit",
|
|
30
31
|
"pack:dry": "npm pack --dry-run",
|
|
31
|
-
"validate": "npm run typecheck && npm run check && npm run pack:dry"
|
|
32
|
+
"validate": "npm run typecheck && npm run test && npm run check && npm run pack:dry"
|
|
32
33
|
},
|
|
33
34
|
"files": [
|
|
34
35
|
"index.ts",
|