@hank-warren/pi-statusline 0.7.0 → 0.7.2
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/CHANGELOG.md +24 -0
- package/README.md +9 -1
- package/index.ts +13 -1
- package/package.json +1 -1
- package/usage.ts +315 -66
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,29 @@
|
|
|
1
1
|
# @hank-warren/pi-statusline
|
|
2
2
|
|
|
3
|
+
## 0.7.2
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 7f2143a: Make the subscription usage meters accurate as well as prompt. Three changes to how the poller treats each endpoint and its own cache, following the ten-second tick shipped in 0.7.1.
|
|
8
|
+
|
|
9
|
+
**Codex gets its own sixty-second interval.** Both endpoints shared one five-minute spacing, written for the stricter of the two. OpenAI's own Codex CLI polls its usage endpoint every sixty seconds and independent tools converge on the same figure, so the Codex meter had been five times more conservative than the vendor's own client for no reason beyond sharing a constant. Anthropic's endpoint, which rate-limits far harder, keeps the five minutes it needs.
|
|
10
|
+
|
|
11
|
+
**A `Retry-After` is now honoured.** Any 429 previously got a flat fifteen-minute backoff, ignoring the header saying when to come back; a provider asking for sixty seconds now gets sixty seconds, and one asking for an hour is no longer cut short by our guess. Both forms are accepted, delta-seconds and HTTP-date. A value that is not in the future is discarded rather than clamped, because Anthropic's usage endpoint is widely reported to answer `retry-after: 0` while still refusing requests — obeying it literally would retry straight back into the limit that produced it. The flat window remains the fallback for a 429 that says nothing.
|
|
12
|
+
|
|
13
|
+
**Cached values expire at their window boundary, not just by age.** Each cache entry now records the soonest moment any of its windows rolls over, from Anthropic's `resets_at` or Codex's `reset_at`/`reset_after_seconds`. Once that moment passes the stored percentages describe the window that just _ended_ — not merely stale but wrong, and invisible to any age-based expiry. Such a value is no longer adopted from the shared cache or kept as a last-known-good, and its account skips the poll interval so the post-reset numbers appear promptly. A boundary is answered exactly once rather than on every tick: a failed poll leaves the boundary in the past, so an unconditional override would re-poll continuously for as long as the failure lasted — a 429 sets a backoff that stops that, but a 500 or a timeout does not. An active rate-limit backoff still outranks a boundary, and a value carrying no boundary — which is what an entry written by an earlier statusline looks like — never expires this way.
|
|
14
|
+
|
|
15
|
+
The cache file stays compatible in both directions: the reset boundary is an additive field that older readers ignore, and an entry without one behaves exactly as before.
|
|
16
|
+
|
|
17
|
+
## 0.7.1
|
|
18
|
+
|
|
19
|
+
### Patch Changes
|
|
20
|
+
|
|
21
|
+
- 950c926: Tick the subscription usage meters on a clock instead of on turn boundaries. The tracker only refreshed from session start, the end of a turn, and a model switch, while the poll gate that spaces requests is host-wide and five minutes wide. The moment a meter actually moved was therefore the first turn in this session ending more than five minutes after any pi process on the host last polled that account — which made updates look arbitrary, and froze an idle session's numbers indefinitely while the five-hour window was visibly recovering.
|
|
22
|
+
|
|
23
|
+
`UsageTracker` now owns an unref'd ten-second tick, started with the footer and stopped with the footer, with the usage setting, or at session shutdown. Request volume is unchanged, because a throttled refresh already issues no request: it reads `auth.json` and the shared cache, publishes whatever another process has already fetched, finds the gate closed and returns. The tick costs two small local reads and buys the two things the turn-driven cadence could not — an idle session's meters keep moving, and a sibling process's fresh values are adopted within one tick rather than waiting for this session's next turn to end. The poll itself now also fires when the five-minute window opens rather than at the first turn to end after it.
|
|
24
|
+
|
|
25
|
+
Nothing about the meters' appearance, the poll interval, the rate-limit backoff or the shared cache format changes.
|
|
26
|
+
|
|
3
27
|
## 0.7.0
|
|
4
28
|
|
|
5
29
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -75,7 +75,7 @@ When Pi's `~/.pi/agent/auth.json` contains OAuth credentials for Anthropic (Clau
|
|
|
75
75
|
|
|
76
76
|
Numbers are colored by remaining headroom: green above 60, yellow 41–60, orange 16–40, red at 15 and below.
|
|
77
77
|
|
|
78
|
-
Usage is fetched from the providers' own usage endpoints with Pi's stored tokens — read-only; tokens are never refreshed or written. Fetches
|
|
78
|
+
Usage is fetched from the providers' own usage endpoints with Pi's stored tokens — read-only; tokens are never refreshed or written. Fetches are driven by session start, each turn, and a ten-second tick while the footer is mounted, throttled per provider — five minutes for Claude, sixty seconds for Codex (the Anthropic usage endpoint rate-limits aggressively), and are strictly best-effort: on any failure the last-known value is kept, and providers without credentials (or before the first successful fetch) are simply omitted, leaving the statusline exactly as before. While a provider that *does* have credentials still has no value, the throttle drops to 30 seconds — Pi only refreshes an expired OAuth access token when that provider is first used, so a session starting with a stale Anthropic token would otherwise show no Claude meter for a full interval.
|
|
79
79
|
|
|
80
80
|
### Which account each meter shows
|
|
81
81
|
|
|
@@ -87,6 +87,14 @@ When [`@hank-warren/pi-multi-login`](../pi-multi-login/README.md) has registered
|
|
|
87
87
|
|
|
88
88
|
Polling is host-wide, not per-session. Usage percentages describe the account rather than the session, and a busy machine runs dozens of pi processes, so every process shares `~/.pi/agent/statusline-usage.json`, written atomically via a temp file and rename. It is keyed by **credential id**, one entry per account, each holding that account's last good values plus the time its last poll was *started*. A session adopts the cached values for its selected accounts on first refresh — so the meters are populated before it has issued a single request, and switching back to an account polled earlier repaints with no request at all — and only polls an account whose timestamp is older than the interval. Keying by account rather than by provider family is what lets two sessions on two different Anthropic logins coexist: keyed by family, each looked like an account switch to the other, so they evicted each other's values and re-polled every cycle.
|
|
89
89
|
|
|
90
|
+
The two intervals differ because the endpoints do. OpenAI's own Codex CLI polls `wham/usage` every sixty seconds, and independent community tools converge on the same figure, so matching the first-party client is well inside what that endpoint expects. Anthropic's usage endpoint is the opposite — it rate-limits hard enough that a whole ecosystem of statusline tools has been stuck in permanent 429 loops — so Claude keeps the conservative five minutes. The two had shared one interval only because it was written for the stricter of them, leaving the Codex meter five times more conservative than the vendor's own client for no reason.
|
|
91
|
+
|
|
92
|
+
The ten-second tick does not increase request volume; it only stops the meters from being pinned to turn boundaries. A throttled refresh issues no request at all — it reads `auth.json` and the shared cache, publishes whatever another process has already fetched, finds the poll gate closed and returns — so the tick costs two small local reads and buys the two things the previous turn-driven cadence could not: an **idle** session's meters keep moving (quota recovers on a wall clock, not on your turns), and a sibling process's fresh values are adopted within one tick instead of waiting for this session's next turn to end. It also means the poll itself happens when the five-minute window opens rather than at the first turn that ends after it. The tick is `unref`ed, runs only in TUI mode, and stops with the footer — so turning the usage segment off still stops all polling.
|
|
93
|
+
|
|
94
|
+
A provider answering **429** is left alone before being polled again. When the response carries a usable `Retry-After` — either form, delta-seconds or an HTTP-date — that instruction is authoritative and is used exactly. Otherwise a flat fifteen-minute window applies, because a 429 that says nothing about when to return is only telling us the endpoint wants a break. A `Retry-After` that is not in the future is discarded rather than clamped: Anthropic's usage endpoint is widely reported to answer `retry-after: 0` while still refusing requests, so obeying it literally would retry straight back into the limit that produced it. Any successful poll clears the block.
|
|
95
|
+
|
|
96
|
+
Cached values also expire at a **window boundary**, not just by age. Each entry records the soonest moment any of its windows rolls over — from Anthropic's `resets_at`, or Codex's `reset_at` (absolute, preferred) or `reset_after_seconds` (relative, resolved against our clock). Once that moment passes, the stored percentages describe the window that just *ended*: they are not merely old, they are wrong, and no age-based expiry can see it. Such a value is never adopted from the cache and never kept as a last-known-good, and the account becomes pollable immediately rather than waiting out its interval, so the post-reset numbers appear promptly. An active 429 backoff still outranks a boundary — a rate-limited endpoint is the last thing to argue with the moment its window turns over. A value carrying no boundary at all, which is what an entry written by an older statusline looks like, never expires this way.
|
|
97
|
+
|
|
90
98
|
Cache entries stay compatible in both directions: a Codex entry written by a statusline older than 0.6.0 carries no 5-hour value, so a newer one renders it as a single weekly number until its own next poll fills the second slot, and an older reader ignores the extra value entirely.
|
|
91
99
|
|
|
92
100
|
An account answering `429` is parked for fifteen minutes (tracked per account, so a rate-limited Anthropic never stops codex from updating) and stops counting as pending, since retrying harder is what earns the rate limit in the first place. Every cache entry is keyed to a fingerprint (a sha256 prefix, never the token itself) of the credential that fetched it: switching accounts — or rotating a token — discards that entry's numbers and backoff and polls immediately, so an exhausted old account's meters never masquerade as the new account's. A logged-out account fails the same check, so the file garbage-collects itself.
|
package/index.ts
CHANGED
|
@@ -304,7 +304,13 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
304
304
|
} else if (!previous.showWorktrees || !tracker || previous.worktreeRoot !== next.worktreeRoot) {
|
|
305
305
|
resetTracker(ctx);
|
|
306
306
|
}
|
|
307
|
-
if (next.showUsage && !previous.showUsage)
|
|
307
|
+
if (next.showUsage && !previous.showUsage) {
|
|
308
|
+
runInBackground(usageTracker.refresh());
|
|
309
|
+
usageTracker.start();
|
|
310
|
+
} else if (!next.showUsage && previous.showUsage) {
|
|
311
|
+
// A hidden meter must not keep polling, matching the worktree line above.
|
|
312
|
+
usageTracker.stop();
|
|
313
|
+
}
|
|
308
314
|
if (!next.showCacheCelebration) cacheCelebration.dispose();
|
|
309
315
|
// Dropping a row leaves a stale one behind in fullscreen mode.
|
|
310
316
|
if (previous.showSessionId !== next.showSessionId || previous.showWorktrees !== next.showWorktrees) {
|
|
@@ -406,6 +412,10 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
406
412
|
// Fullscreen mode never repaints unchanged rows; the session id line is
|
|
407
413
|
// static, so it needs periodic forced redraws to shed stale cells.
|
|
408
414
|
fullRedraw.attach(tui);
|
|
415
|
+
// The meters are wall-clock quantities, not per-turn ones: an idle session
|
|
416
|
+
// still needs them to move, and a sibling process's poll is worth adopting
|
|
417
|
+
// before the next turn ends.
|
|
418
|
+
if (settings.showUsage) usageTracker.start();
|
|
409
419
|
const stopBranchUpdates = footerData.onBranchChange(() => {
|
|
410
420
|
runInBackground(refreshCwdStatus(ctx));
|
|
411
421
|
tui.requestRender();
|
|
@@ -417,6 +427,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
417
427
|
cacheCelebration.dispose();
|
|
418
428
|
celebrationPreview.dispose();
|
|
419
429
|
fullRedraw.detach();
|
|
430
|
+
usageTracker.stop();
|
|
420
431
|
requestRender = undefined;
|
|
421
432
|
},
|
|
422
433
|
invalidate(): void {},
|
|
@@ -479,6 +490,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
479
490
|
pi.on("session_shutdown", () => {
|
|
480
491
|
cacheCelebration.dispose();
|
|
481
492
|
fullRedraw.detach();
|
|
493
|
+
usageTracker.stop();
|
|
482
494
|
tracker?.dispose();
|
|
483
495
|
tracker = undefined;
|
|
484
496
|
cwdStatusAbort?.abort();
|
package/package.json
CHANGED
package/usage.ts
CHANGED
|
@@ -4,7 +4,15 @@ import { homedir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { pid } from "node:process";
|
|
6
6
|
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* Remaining (not used) integer percents per provider window.
|
|
9
|
+
*
|
|
10
|
+
* `resetsAt` is the soonest moment any of that provider's windows rolls over,
|
|
11
|
+
* as epoch milliseconds. It is what makes a cached value falsifiable: once that
|
|
12
|
+
* boundary passes, the percentages describe the *previous* window and are not
|
|
13
|
+
* merely old but wrong. Absent when the payload carried no usable reset time,
|
|
14
|
+
* which is also what an entry written by an older statusline looks like.
|
|
15
|
+
*/
|
|
8
16
|
export interface UsageSnapshot {
|
|
9
17
|
claude?: { fiveHour: number; sevenDay: number; scopedWeekly?: number };
|
|
10
18
|
/**
|
|
@@ -15,6 +23,27 @@ export interface UsageSnapshot {
|
|
|
15
23
|
codex?: { fiveHour?: number; weekly?: number };
|
|
16
24
|
}
|
|
17
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Whether a reset boundary has passed. A missing boundary never expires, which
|
|
28
|
+
* is what an entry written by an older statusline looks like.
|
|
29
|
+
*/
|
|
30
|
+
function hasReset(resetsAt: number | undefined, now: number): boolean {
|
|
31
|
+
return resetsAt !== undefined && now >= resetsAt;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The soonest of a set of candidate reset times, ignoring unusable ones. */
|
|
35
|
+
function earliestReset(candidates: (number | undefined)[]): number | undefined {
|
|
36
|
+
const usable = candidates.filter((value): value is number => value !== undefined && Number.isFinite(value));
|
|
37
|
+
return usable.length === 0 ? undefined : Math.min(...usable);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Anthropic reports reset times as an ISO 8601 string. */
|
|
41
|
+
function parseIsoReset(value: unknown): number | undefined {
|
|
42
|
+
if (typeof value !== "string") return undefined;
|
|
43
|
+
const parsed = Date.parse(value);
|
|
44
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
18
47
|
const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
19
48
|
const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
20
49
|
/**
|
|
@@ -22,6 +51,15 @@ const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
|
22
51
|
* (429) aggressively, so keep this well above a per-turn cadence.
|
|
23
52
|
*/
|
|
24
53
|
export const USAGE_REFRESH_INTERVAL_MS = 5 * 60_000;
|
|
54
|
+
/**
|
|
55
|
+
* Minimum spacing for the Codex endpoint, which is markedly more permissive
|
|
56
|
+
* than Anthropic's. OpenAI's own Codex CLI polls this exact endpoint on a
|
|
57
|
+
* 60-second interval (`ChatWidget::prefetch_rate_limits`), and independent
|
|
58
|
+
* community tools converge on the same figure, so matching the first-party
|
|
59
|
+
* client is well inside what the endpoint expects. The two providers had
|
|
60
|
+
* shared one interval purely because it was written for the stricter of them.
|
|
61
|
+
*/
|
|
62
|
+
export const CODEX_USAGE_REFRESH_INTERVAL_MS = 60_000;
|
|
25
63
|
/**
|
|
26
64
|
* Shorter spacing used while a credentialed provider still has no value. Pi
|
|
27
65
|
* refreshes an expired OAuth token only when that provider is first used, so a
|
|
@@ -30,11 +68,23 @@ export const USAGE_REFRESH_INTERVAL_MS = 5 * 60_000;
|
|
|
30
68
|
*/
|
|
31
69
|
export const USAGE_RETRY_INTERVAL_MS = 30_000;
|
|
32
70
|
/**
|
|
33
|
-
* How long an account is left alone after it answers 429
|
|
34
|
-
* (see the shared cache below), so a rate
|
|
35
|
-
* a break rather than that we are racing
|
|
71
|
+
* How long an account is left alone after it answers 429 *without* telling us
|
|
72
|
+
* when to come back. Polls are host-wide (see the shared cache below), so a rate
|
|
73
|
+
* limit means the provider itself wants a break rather than that we are racing
|
|
74
|
+
* ourselves. A usable `Retry-After` always wins over this guess.
|
|
36
75
|
*/
|
|
37
76
|
export const USAGE_RATE_LIMIT_BACKOFF_MS = 15 * 60_000;
|
|
77
|
+
/**
|
|
78
|
+
* How often a bound tracker calls itself. This is deliberately far shorter than
|
|
79
|
+
* the poll interval, because a throttled `refresh()` issues no request at all:
|
|
80
|
+
* it reads auth.json and the shared cache, publishes whatever another process
|
|
81
|
+
* has already fetched, finds the poll gate closed, and returns. So the tick
|
|
82
|
+
* costs two small local reads and buys two things the previous turn-driven
|
|
83
|
+
* cadence could not — an idle session's meters keep moving, and a sibling
|
|
84
|
+
* process's fresh values appear within one tick instead of at the next turn.
|
|
85
|
+
* Request volume is unchanged: both throttles still gate every fetch.
|
|
86
|
+
*/
|
|
87
|
+
export const USAGE_TICK_INTERVAL_MS = 10_000;
|
|
38
88
|
const FETCH_TIMEOUT_MS = 10_000;
|
|
39
89
|
const ONE_DAY_SECONDS = 86_400;
|
|
40
90
|
|
|
@@ -57,8 +107,8 @@ interface ClaudeLimitEntry {
|
|
|
57
107
|
export function parseClaudeUsage(json: unknown): UsageSnapshot["claude"] | undefined {
|
|
58
108
|
if (typeof json !== "object" || json === null) return undefined;
|
|
59
109
|
const body = json as {
|
|
60
|
-
five_hour?: { utilization?: unknown };
|
|
61
|
-
seven_day?: { utilization?: unknown };
|
|
110
|
+
five_hour?: { utilization?: unknown; resets_at?: unknown };
|
|
111
|
+
seven_day?: { utilization?: unknown; resets_at?: unknown };
|
|
62
112
|
limits?: unknown;
|
|
63
113
|
};
|
|
64
114
|
const fiveHour = toRemaining(body.five_hour?.utilization);
|
|
@@ -82,6 +132,18 @@ export function parseClaudeUsage(json: unknown): UsageSnapshot["claude"] | undef
|
|
|
82
132
|
interface CodexWindow {
|
|
83
133
|
used_percent?: unknown;
|
|
84
134
|
limit_window_seconds?: unknown;
|
|
135
|
+
/** Absolute reset, Unix seconds. Preferred over the relative form. */
|
|
136
|
+
reset_at?: unknown;
|
|
137
|
+
/** Relative reset, seconds from the moment the payload was produced. */
|
|
138
|
+
reset_after_seconds?: unknown;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** A Codex window's reset time as epoch ms, absolute form preferred. */
|
|
142
|
+
function codexReset(window: CodexWindow | undefined, now: number): number | undefined {
|
|
143
|
+
if (window === undefined) return undefined;
|
|
144
|
+
if (typeof window.reset_at === "number" && Number.isFinite(window.reset_at)) return window.reset_at * 1_000;
|
|
145
|
+
const after = window.reset_after_seconds;
|
|
146
|
+
return typeof after === "number" && Number.isFinite(after) ? now + after * 1_000 : undefined;
|
|
85
147
|
}
|
|
86
148
|
|
|
87
149
|
/** A window whose span is known, so it can be sorted into a slot. */
|
|
@@ -98,9 +160,21 @@ interface SpannedCodexWindow extends CodexWindow {
|
|
|
98
160
|
* and an absent window simply leaves its slot empty.
|
|
99
161
|
*/
|
|
100
162
|
export function parseCodexUsage(json: unknown): UsageSnapshot["codex"] | undefined {
|
|
101
|
-
|
|
163
|
+
const { shortest, longest } = slotCodexWindows(json);
|
|
164
|
+
const fiveHour = toRemaining(shortest?.used_percent);
|
|
165
|
+
const weekly = toRemaining(longest?.used_percent);
|
|
166
|
+
if (fiveHour === undefined && weekly === undefined) return undefined;
|
|
167
|
+
const result: UsageSnapshot["codex"] = {};
|
|
168
|
+
if (fiveHour !== undefined) result.fiveHour = fiveHour;
|
|
169
|
+
if (weekly !== undefined) result.weekly = weekly;
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Sort a Codex payload's windows into the short and long display slots. */
|
|
174
|
+
function slotCodexWindows(json: unknown): { shortest?: SpannedCodexWindow; longest?: SpannedCodexWindow } {
|
|
175
|
+
if (typeof json !== "object" || json === null) return {};
|
|
102
176
|
const rateLimit = (json as { rate_limit?: unknown }).rate_limit;
|
|
103
|
-
if (typeof rateLimit !== "object" || rateLimit === null) return
|
|
177
|
+
if (typeof rateLimit !== "object" || rateLimit === null) return {};
|
|
104
178
|
const { primary_window, secondary_window } = rateLimit as {
|
|
105
179
|
primary_window?: CodexWindow | null;
|
|
106
180
|
secondary_window?: CodexWindow | null;
|
|
@@ -113,19 +187,35 @@ export function parseCodexUsage(json: unknown): UsageSnapshot["codex"] | undefin
|
|
|
113
187
|
typeof window.limit_window_seconds === "number" &&
|
|
114
188
|
Number.isFinite(window.limit_window_seconds),
|
|
115
189
|
);
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
190
|
+
return {
|
|
191
|
+
shortest: windows
|
|
192
|
+
.filter((window) => window.limit_window_seconds < ONE_DAY_SECONDS)
|
|
193
|
+
.sort((a, b) => a.limit_window_seconds - b.limit_window_seconds)[0],
|
|
194
|
+
longest: windows
|
|
195
|
+
.filter((window) => window.limit_window_seconds >= ONE_DAY_SECONDS)
|
|
196
|
+
.sort((a, b) => b.limit_window_seconds - a.limit_window_seconds)[0],
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The soonest moment any window in a Claude payload rolls over, epoch ms.
|
|
202
|
+
* Kept out of the parsed value on purpose: this is cache-lifetime metadata,
|
|
203
|
+
* never rendered, and folding it into the display shape would make every
|
|
204
|
+
* snapshot assertion carry a timestamp.
|
|
205
|
+
*/
|
|
206
|
+
export function claudeResetAt(json: unknown): number | undefined {
|
|
207
|
+
if (typeof json !== "object" || json === null) return undefined;
|
|
208
|
+
const body = json as { five_hour?: { resets_at?: unknown }; seven_day?: { resets_at?: unknown } };
|
|
209
|
+
return earliestReset([parseIsoReset(body.five_hour?.resets_at), parseIsoReset(body.seven_day?.resets_at)]);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The soonest moment any slotted Codex window rolls over, epoch ms. Needs the
|
|
214
|
+
* clock because Codex may report only a relative `reset_after_seconds`.
|
|
215
|
+
*/
|
|
216
|
+
export function codexResetAt(json: unknown, now: number): number | undefined {
|
|
217
|
+
const { shortest, longest } = slotCodexWindows(json);
|
|
218
|
+
return earliestReset([codexReset(shortest, now), codexReset(longest, now)]);
|
|
129
219
|
}
|
|
130
220
|
|
|
131
221
|
/** Color band for a remaining percent: >60 green, >40 yellow, >15 orange, else red. */
|
|
@@ -139,9 +229,31 @@ export function usageBand(remaining: number): "green" | "yellow" | "orange" | "r
|
|
|
139
229
|
type FetchFn = (url: string, init: { headers: Record<string, string>; signal: AbortSignal }) => Promise<{
|
|
140
230
|
ok: boolean;
|
|
141
231
|
status?: number;
|
|
232
|
+
/** Optional so a caller (or a test double) may omit response headers. */
|
|
233
|
+
headers?: { get(name: string): string | null };
|
|
142
234
|
json(): Promise<unknown>;
|
|
143
235
|
}>;
|
|
144
236
|
|
|
237
|
+
/**
|
|
238
|
+
* Absolute time a `Retry-After` header points at, or undefined when it is
|
|
239
|
+
* absent, unparseable, or already in the past. Both forms in RFC 9110 are
|
|
240
|
+
* accepted: delta-seconds (`Retry-After: 120`) and an HTTP-date.
|
|
241
|
+
*
|
|
242
|
+
* A value that is not in the future is deliberately discarded rather than
|
|
243
|
+
* clamped to now. Anthropic's usage endpoint is documented by its users to
|
|
244
|
+
* answer `retry-after: 0` while continuing to refuse requests, so obeying it
|
|
245
|
+
* literally would retry straight into the limit that produced it.
|
|
246
|
+
*/
|
|
247
|
+
export function parseRetryAfter(value: string | null | undefined, now: number): number | undefined {
|
|
248
|
+
if (typeof value !== "string") return undefined;
|
|
249
|
+
const trimmed = value.trim();
|
|
250
|
+
if (trimmed.length === 0) return undefined;
|
|
251
|
+
const seconds = Number(trimmed);
|
|
252
|
+
const at = Number.isFinite(seconds) ? now + seconds * 1_000 : Date.parse(trimmed);
|
|
253
|
+
if (!Number.isFinite(at) || at <= now) return undefined;
|
|
254
|
+
return at;
|
|
255
|
+
}
|
|
256
|
+
|
|
145
257
|
export interface UsageTrackerOptions {
|
|
146
258
|
authPath?: string;
|
|
147
259
|
cachePath?: string;
|
|
@@ -150,6 +262,20 @@ export interface UsageTrackerOptions {
|
|
|
150
262
|
now?: () => number;
|
|
151
263
|
/** Provider id of the session's main model; selects which account is polled. */
|
|
152
264
|
activeProvider?: string;
|
|
265
|
+
tickIntervalMs?: number;
|
|
266
|
+
schedule?: (callback: () => void, intervalMs: number) => unknown;
|
|
267
|
+
cancel?: (handle: unknown) => void;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function defaultSchedule(callback: () => void, intervalMs: number): unknown {
|
|
271
|
+
const timer = setInterval(callback, intervalMs);
|
|
272
|
+
// A usage meter is cosmetic; it must never hold the process open at exit.
|
|
273
|
+
(timer as { unref?: () => void }).unref?.();
|
|
274
|
+
return timer;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function defaultCancel(handle: unknown): void {
|
|
278
|
+
clearInterval(handle as ReturnType<typeof setInterval>);
|
|
153
279
|
}
|
|
154
280
|
|
|
155
281
|
type ProviderKey = "claude" | "codex";
|
|
@@ -162,9 +288,21 @@ export const USAGE_BASE_PROVIDERS: Record<ProviderKey, string> = {
|
|
|
162
288
|
codex: "openai-codex",
|
|
163
289
|
};
|
|
164
290
|
|
|
291
|
+
/** Steady-state poll spacing per family; each endpoint has its own tolerance. */
|
|
292
|
+
const PROVIDER_REFRESH_INTERVAL_MS: Record<ProviderKey, number> = {
|
|
293
|
+
claude: USAGE_REFRESH_INTERVAL_MS,
|
|
294
|
+
codex: CODEX_USAGE_REFRESH_INTERVAL_MS,
|
|
295
|
+
};
|
|
296
|
+
|
|
165
297
|
/** Credential id polled for each family. */
|
|
166
298
|
export type UsageAccounts = Record<ProviderKey, string>;
|
|
167
299
|
|
|
300
|
+
/** A freshly fetched value together with the boundary it is valid until. */
|
|
301
|
+
interface FetchedUsage<K extends ProviderKey> {
|
|
302
|
+
value: NonNullable<UsageSnapshot[K]>;
|
|
303
|
+
resetsAt?: number;
|
|
304
|
+
}
|
|
305
|
+
|
|
168
306
|
function inFamily(providerId: string | undefined, base: string): boolean {
|
|
169
307
|
return providerId === base || (providerId !== undefined && providerId.startsWith(`${base}-`));
|
|
170
308
|
}
|
|
@@ -242,6 +380,12 @@ function credentialIdentities(auth: AuthEntries, accounts: UsageAccounts): Provi
|
|
|
242
380
|
interface UsageAccountEntry {
|
|
243
381
|
/** Credential fingerprint the stored values were fetched with. */
|
|
244
382
|
identity: string;
|
|
383
|
+
/**
|
|
384
|
+
* Epoch ms at which the soonest window in the stored value rolls over. Past
|
|
385
|
+
* it the percentages describe the previous window, so they are wrong rather
|
|
386
|
+
* than merely old. Absent on entries written before this existed.
|
|
387
|
+
*/
|
|
388
|
+
resetsAt?: number;
|
|
245
389
|
/** Last time any process started a poll for this account. */
|
|
246
390
|
attemptedAt: number;
|
|
247
391
|
/** Absolute time before which a rate-limited account must not be polled. */
|
|
@@ -298,6 +442,7 @@ function parseUsageCache(json: unknown): UsageCache | undefined {
|
|
|
298
442
|
if (typeof body.attemptedAt !== "number" || !Number.isFinite(body.attemptedAt)) continue;
|
|
299
443
|
const entry: UsageAccountEntry = { identity: body.identity, attemptedAt: body.attemptedAt };
|
|
300
444
|
if (typeof body.backoff === "number" && Number.isFinite(body.backoff)) entry.backoff = body.backoff;
|
|
445
|
+
if (typeof body.resetsAt === "number" && Number.isFinite(body.resetsAt)) entry.resetsAt = body.resetsAt;
|
|
301
446
|
if (typeof body.claude === "object" && body.claude !== null) entry.claude = body.claude as UsageSnapshot["claude"];
|
|
302
447
|
if (typeof body.codex === "object" && body.codex !== null) entry.codex = body.codex as UsageSnapshot["codex"];
|
|
303
448
|
accounts[accountId] = entry;
|
|
@@ -317,12 +462,19 @@ export class UsageTracker {
|
|
|
317
462
|
private readonly onChange?: () => void;
|
|
318
463
|
private readonly now: () => number;
|
|
319
464
|
private current: UsageSnapshot = {};
|
|
465
|
+
/** Reset boundary of each value in `current`, so it can expire in place. */
|
|
466
|
+
private currentResets: Partial<Record<ProviderKey, number>> = {};
|
|
320
467
|
private identities: ProviderIdentities = {};
|
|
321
468
|
private activeProvider: string | undefined;
|
|
322
469
|
private awaitingProvider = true;
|
|
323
|
-
|
|
470
|
+
/** Families that answered 429 this cycle, mapped to their `Retry-After`. */
|
|
471
|
+
private readonly rateLimited = new Map<ProviderKey, number | undefined>();
|
|
324
472
|
private lastAttempt: Record<string, number> = {};
|
|
325
473
|
private inFlight: Promise<void> | undefined;
|
|
474
|
+
private readonly tickIntervalMs: number;
|
|
475
|
+
private readonly schedule: (callback: () => void, intervalMs: number) => unknown;
|
|
476
|
+
private readonly cancel: (handle: unknown) => void;
|
|
477
|
+
private tickHandle: unknown;
|
|
326
478
|
|
|
327
479
|
constructor(options: UsageTrackerOptions = {}) {
|
|
328
480
|
this.authPath = options.authPath ?? join(homedir(), ".pi", "agent", "auth.json");
|
|
@@ -331,12 +483,34 @@ export class UsageTracker {
|
|
|
331
483
|
this.onChange = options.onChange;
|
|
332
484
|
this.now = options.now ?? Date.now;
|
|
333
485
|
this.activeProvider = options.activeProvider;
|
|
486
|
+
this.tickIntervalMs = options.tickIntervalMs ?? USAGE_TICK_INTERVAL_MS;
|
|
487
|
+
this.schedule = options.schedule ?? defaultSchedule;
|
|
488
|
+
this.cancel = options.cancel ?? defaultCancel;
|
|
334
489
|
}
|
|
335
490
|
|
|
336
491
|
snapshot(): UsageSnapshot {
|
|
337
492
|
return this.current;
|
|
338
493
|
}
|
|
339
494
|
|
|
495
|
+
/**
|
|
496
|
+
* Begin ticking. Idempotent, so the caller may start from whichever lifecycle
|
|
497
|
+
* event happens first without tracking whether it already did.
|
|
498
|
+
*/
|
|
499
|
+
start(): void {
|
|
500
|
+
if (this.tickHandle !== undefined) return;
|
|
501
|
+
this.tickHandle = this.schedule(() => {
|
|
502
|
+
// refresh() swallows its own failures and no-ops while one is in flight.
|
|
503
|
+
void this.refresh();
|
|
504
|
+
}, this.tickIntervalMs);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Stop ticking. Safe to call when never started, and leaves values in place. */
|
|
508
|
+
stop(): void {
|
|
509
|
+
if (this.tickHandle === undefined) return;
|
|
510
|
+
this.cancel(this.tickHandle);
|
|
511
|
+
this.tickHandle = undefined;
|
|
512
|
+
}
|
|
513
|
+
|
|
340
514
|
/**
|
|
341
515
|
* Point the meters at the session's main model. A switch between two accounts
|
|
342
516
|
* in the same family changes which one is displayed; the next refresh adopts
|
|
@@ -347,11 +521,12 @@ export class UsageTracker {
|
|
|
347
521
|
}
|
|
348
522
|
|
|
349
523
|
/**
|
|
350
|
-
* Interval until the next allowed attempt: the short retry
|
|
351
|
-
* credentialed provider is still missing a value,
|
|
524
|
+
* Interval until the next allowed attempt for one family: the short retry
|
|
525
|
+
* window while a credentialed provider is still missing a value, that
|
|
526
|
+
* family's own steady-state interval otherwise.
|
|
352
527
|
*/
|
|
353
|
-
private currentInterval(): number {
|
|
354
|
-
return this.awaitingProvider ? USAGE_RETRY_INTERVAL_MS :
|
|
528
|
+
private currentInterval(key: ProviderKey): number {
|
|
529
|
+
return this.awaitingProvider ? USAGE_RETRY_INTERVAL_MS : PROVIDER_REFRESH_INTERVAL_MS[key];
|
|
355
530
|
}
|
|
356
531
|
|
|
357
532
|
/** Throttled refresh; resolves when the current attempt (if any) settles. */
|
|
@@ -381,21 +556,32 @@ export class UsageTracker {
|
|
|
381
556
|
// Another process's values are as good as ours and cost no request, so a new
|
|
382
557
|
// session — or a switch back to an account polled earlier — shows real
|
|
383
558
|
// numbers on its very first render.
|
|
384
|
-
this.publish(this.merge(cachedSnapshot(cache, accounts), auth, accounts), auth, accounts, cache);
|
|
385
|
-
|
|
386
559
|
const now = this.now();
|
|
387
|
-
|
|
560
|
+
this.publish(this.merge(...cachedSnapshot(cache, accounts, now), auth, accounts, now), auth, accounts, cache);
|
|
561
|
+
|
|
388
562
|
const pollable: Partial<Record<ProviderKey, string>> = {};
|
|
389
563
|
for (const key of PROVIDER_KEYS) {
|
|
390
564
|
const accountId = accounts[key];
|
|
391
565
|
if (!hasAuth(auth, accountId)) continue;
|
|
392
566
|
const entry = cache.accounts[accountId];
|
|
567
|
+
// A backoff outranks everything: a rate-limited endpoint does not want to
|
|
568
|
+
// hear from us at a window boundary either.
|
|
393
569
|
if (now < (entry?.backoff ?? Number.NEGATIVE_INFINITY)) continue;
|
|
394
570
|
const lastAttempt = Math.max(
|
|
395
571
|
this.lastAttempt[accountId] ?? Number.NEGATIVE_INFINITY,
|
|
396
572
|
entry?.attemptedAt ?? Number.NEGATIVE_INFINITY,
|
|
397
573
|
);
|
|
398
|
-
|
|
574
|
+
// Past a window boundary the stored percentages are wrong rather than
|
|
575
|
+
// merely old, so the interval is skipped to republish promptly — but only
|
|
576
|
+
// for a boundary we have not already answered. A failed poll leaves the
|
|
577
|
+
// boundary in the past, so an unconditional override would poll on every
|
|
578
|
+
// tick for as long as the failure lasted. A 429 sets a backoff that stops
|
|
579
|
+
// that; a 500, a timeout or a dropped connection does not. Answering each
|
|
580
|
+
// boundary once hands the account back to the ordinary intervals, which
|
|
581
|
+
// for a provider now holding no value is the short retry window.
|
|
582
|
+
const boundary = entry?.resetsAt;
|
|
583
|
+
const boundaryUnanswered = hasReset(boundary, now) && (boundary as number) > lastAttempt;
|
|
584
|
+
if (!boundaryUnanswered && now - lastAttempt < this.currentInterval(key)) continue;
|
|
399
585
|
pollable[key] = accountId;
|
|
400
586
|
}
|
|
401
587
|
if (pollable.claude === undefined && pollable.codex === undefined) return;
|
|
@@ -416,10 +602,15 @@ export class UsageTracker {
|
|
|
416
602
|
}
|
|
417
603
|
await this.writeCache({ accounts: claimed });
|
|
418
604
|
|
|
419
|
-
const [
|
|
605
|
+
const [claudeResult, codexResult] = await Promise.all([
|
|
420
606
|
pollable.claude === undefined ? Promise.resolve(undefined) : this.fetchClaude(auth, pollable.claude),
|
|
421
607
|
pollable.codex === undefined ? Promise.resolve(undefined) : this.fetchCodex(auth, pollable.codex),
|
|
422
608
|
]);
|
|
609
|
+
const claude = claudeResult?.value;
|
|
610
|
+
const codex = codexResult?.value;
|
|
611
|
+
const incomingResets: Partial<Record<ProviderKey, number>> = {};
|
|
612
|
+
if (claudeResult?.resetsAt !== undefined) incomingResets.claude = claudeResult.resetsAt;
|
|
613
|
+
if (codexResult?.resetsAt !== undefined) incomingResets.codex = codexResult.resetsAt;
|
|
423
614
|
|
|
424
615
|
const updatedAccounts = { ...claimed };
|
|
425
616
|
for (const key of PROVIDER_KEYS) {
|
|
@@ -427,18 +618,33 @@ export class UsageTracker {
|
|
|
427
618
|
if (accountId === undefined) continue;
|
|
428
619
|
const entry: UsageAccountEntry = { ...(updatedAccounts[accountId] as UsageAccountEntry) };
|
|
429
620
|
const value = key === "claude" ? claude : codex;
|
|
430
|
-
|
|
621
|
+
// A `Retry-After` we could use is authoritative; the flat window is only a
|
|
622
|
+
// guess for a 429 that told us nothing. Deliberately a plain assignment:
|
|
623
|
+
// the poll gate above skips any account whose backoff is still in the
|
|
624
|
+
// future, so a value we are about to overwrite is necessarily absent or
|
|
625
|
+
// already expired, and a monotonic max() here would never do anything.
|
|
626
|
+
if (this.rateLimited.has(key)) {
|
|
627
|
+
entry.backoff = this.rateLimited.get(key) ?? now + USAGE_RATE_LIMIT_BACKOFF_MS;
|
|
628
|
+
}
|
|
431
629
|
else if (value !== undefined) delete entry.backoff;
|
|
432
630
|
// A failed fetch leaves the last-known value in place rather than blanking
|
|
433
631
|
// the meter for whatever caused one bad response.
|
|
434
632
|
if (claude !== undefined && key === "claude") entry.claude = claude;
|
|
435
633
|
if (codex !== undefined && key === "codex") entry.codex = codex;
|
|
634
|
+
// The boundary travels with the value it describes: a refreshed value gets
|
|
635
|
+
// the new window, and one that arrived without a usable reset must not
|
|
636
|
+
// inherit the previous window's.
|
|
637
|
+
if (value !== undefined) {
|
|
638
|
+
const resetsAt = incomingResets[key];
|
|
639
|
+
if (resetsAt === undefined) delete entry.resetsAt;
|
|
640
|
+
else entry.resetsAt = resetsAt;
|
|
641
|
+
}
|
|
436
642
|
updatedAccounts[accountId] = entry;
|
|
437
643
|
}
|
|
438
644
|
this.rateLimited.clear();
|
|
439
645
|
|
|
440
646
|
const updated: UsageCache = { accounts: updatedAccounts };
|
|
441
|
-
this.publish(this.merge({ claude, codex }, auth, accounts), auth, accounts, updated);
|
|
647
|
+
this.publish(this.merge({ claude, codex }, incomingResets, auth, accounts, now), auth, accounts, updated);
|
|
442
648
|
await this.writeCache(updated);
|
|
443
649
|
}
|
|
444
650
|
|
|
@@ -452,6 +658,7 @@ export class UsageTracker {
|
|
|
452
658
|
this.identities = identity;
|
|
453
659
|
if (changedKeys.length === 0) return;
|
|
454
660
|
for (const key of changedKeys) delete this.lastAttempt[accounts[key]];
|
|
661
|
+
for (const key of changedKeys) delete this.currentResets[key];
|
|
455
662
|
if (!changedKeys.some((key) => this.current[key] !== undefined)) return;
|
|
456
663
|
const next = { ...this.current };
|
|
457
664
|
for (const key of changedKeys) delete next[key];
|
|
@@ -465,21 +672,44 @@ export class UsageTracker {
|
|
|
465
672
|
* account has no credentials: a logged-out account must disappear immediately,
|
|
466
673
|
* while a failed fetch keeps whatever we last saw.
|
|
467
674
|
*/
|
|
468
|
-
private merge(
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
675
|
+
private merge(
|
|
676
|
+
incoming: UsageSnapshot,
|
|
677
|
+
incomingResets: Partial<Record<ProviderKey, number>>,
|
|
678
|
+
auth: AuthEntries,
|
|
679
|
+
accounts: UsageAccounts,
|
|
680
|
+
now: number,
|
|
681
|
+
): { snapshot: UsageSnapshot; resets: Partial<Record<ProviderKey, number>> } {
|
|
682
|
+
const snapshot: UsageSnapshot = {};
|
|
683
|
+
const resets: Partial<Record<ProviderKey, number>> = {};
|
|
684
|
+
for (const key of PROVIDER_KEYS) {
|
|
685
|
+
if (!hasAuth(auth, accounts[key])) continue;
|
|
686
|
+
if (incoming[key] !== undefined) {
|
|
687
|
+
// biome-ignore lint/suspicious/noExplicitAny: one assignment, two value shapes.
|
|
688
|
+
snapshot[key] = incoming[key] as any;
|
|
689
|
+
if (incomingResets[key] !== undefined) resets[key] = incomingResets[key];
|
|
690
|
+
continue;
|
|
691
|
+
}
|
|
692
|
+
// A last-known-good value survives a failed fetch, but not the rollover of
|
|
693
|
+
// the window it describes.
|
|
694
|
+
if (this.current[key] === undefined || hasReset(this.currentResets[key], now)) continue;
|
|
695
|
+
// biome-ignore lint/suspicious/noExplicitAny: one assignment, two value shapes.
|
|
696
|
+
snapshot[key] = this.current[key] as any;
|
|
697
|
+
if (this.currentResets[key] !== undefined) resets[key] = this.currentResets[key];
|
|
477
698
|
}
|
|
478
|
-
return
|
|
699
|
+
return { snapshot, resets };
|
|
479
700
|
}
|
|
480
701
|
|
|
481
702
|
/** Adopt a snapshot, recompute the retry gate, and repaint only on a change. */
|
|
482
|
-
private publish(
|
|
703
|
+
private publish(
|
|
704
|
+
merged: { snapshot: UsageSnapshot; resets: Partial<Record<ProviderKey, number>> },
|
|
705
|
+
auth: AuthEntries,
|
|
706
|
+
accounts: UsageAccounts,
|
|
707
|
+
cache: UsageCache,
|
|
708
|
+
): void {
|
|
709
|
+
const next = merged.snapshot;
|
|
710
|
+
// Boundaries follow their values even when the percentages are unchanged, so
|
|
711
|
+
// a refreshed window cannot be judged against the previous one's deadline.
|
|
712
|
+
this.currentResets = merged.resets;
|
|
483
713
|
const now = this.now();
|
|
484
714
|
// An account serving 429s is not "pending": retrying it faster is exactly
|
|
485
715
|
// what got us rate limited, so it must not hold the short window open.
|
|
@@ -528,7 +758,9 @@ export class UsageTracker {
|
|
|
528
758
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
529
759
|
try {
|
|
530
760
|
const response = await this.fetchFn(url, { headers, signal: controller.signal });
|
|
531
|
-
if (response.status === 429)
|
|
761
|
+
if (response.status === 429) {
|
|
762
|
+
this.rateLimited.set(provider, parseRetryAfter(response.headers?.get("retry-after"), this.now()));
|
|
763
|
+
}
|
|
532
764
|
if (!response.ok) return undefined;
|
|
533
765
|
return await response.json();
|
|
534
766
|
} finally {
|
|
@@ -536,47 +768,64 @@ export class UsageTracker {
|
|
|
536
768
|
}
|
|
537
769
|
}
|
|
538
770
|
|
|
539
|
-
private async fetchClaude(auth: AuthEntries, accountId: string): Promise<
|
|
771
|
+
private async fetchClaude(auth: AuthEntries, accountId: string): Promise<FetchedUsage<"claude"> | undefined> {
|
|
540
772
|
const access = auth[accountId]?.access;
|
|
541
773
|
if (typeof access !== "string" || access.length === 0) return undefined;
|
|
542
774
|
try {
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
"claude",
|
|
548
|
-
),
|
|
775
|
+
const json = await this.fetchJson(
|
|
776
|
+
CLAUDE_USAGE_URL,
|
|
777
|
+
{ Authorization: `Bearer ${access}`, "anthropic-beta": "oauth-2025-04-20" },
|
|
778
|
+
"claude",
|
|
549
779
|
);
|
|
780
|
+
const value = parseClaudeUsage(json);
|
|
781
|
+
return value === undefined ? undefined : { value, resetsAt: claudeResetAt(json) };
|
|
550
782
|
} catch {
|
|
551
783
|
return undefined;
|
|
552
784
|
}
|
|
553
785
|
}
|
|
554
786
|
|
|
555
|
-
private async fetchCodex(auth: AuthEntries, accountId: string): Promise<
|
|
787
|
+
private async fetchCodex(auth: AuthEntries, accountId: string): Promise<FetchedUsage<"codex"> | undefined> {
|
|
556
788
|
const entry = auth[accountId];
|
|
557
789
|
const access = entry?.access;
|
|
558
790
|
const account = entry?.accountId;
|
|
559
791
|
if (typeof access !== "string" || typeof account !== "string" || !access || !account) return undefined;
|
|
560
792
|
try {
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
"codex",
|
|
566
|
-
),
|
|
793
|
+
const json = await this.fetchJson(
|
|
794
|
+
CODEX_USAGE_URL,
|
|
795
|
+
{ Authorization: `Bearer ${access}`, "chatgpt-account-id": account },
|
|
796
|
+
"codex",
|
|
567
797
|
);
|
|
798
|
+
const value = parseCodexUsage(json);
|
|
799
|
+
// Codex may report only a relative reset, which is meaningless without the
|
|
800
|
+
// clock the rest of the tracker runs on.
|
|
801
|
+
return value === undefined ? undefined : { value, resetsAt: codexResetAt(json, this.now()) };
|
|
568
802
|
} catch {
|
|
569
803
|
return undefined;
|
|
570
804
|
}
|
|
571
805
|
}
|
|
572
806
|
}
|
|
573
807
|
|
|
574
|
-
/**
|
|
575
|
-
|
|
808
|
+
/**
|
|
809
|
+
* The cached values for the currently selected accounts, in display shape.
|
|
810
|
+
* Values whose window has already rolled are dropped: adopting another
|
|
811
|
+
* process's stale reading is exactly as wrong as keeping our own.
|
|
812
|
+
*/
|
|
813
|
+
function cachedSnapshot(
|
|
814
|
+
cache: UsageCache,
|
|
815
|
+
accounts: UsageAccounts,
|
|
816
|
+
now: number,
|
|
817
|
+
): [UsageSnapshot, Partial<Record<ProviderKey, number>>] {
|
|
576
818
|
const snapshot: UsageSnapshot = {};
|
|
577
|
-
const
|
|
578
|
-
const
|
|
579
|
-
|
|
580
|
-
if (
|
|
581
|
-
|
|
819
|
+
const resets: Partial<Record<ProviderKey, number>> = {};
|
|
820
|
+
const claudeEntry = cache.accounts[accounts.claude];
|
|
821
|
+
const codexEntry = cache.accounts[accounts.codex];
|
|
822
|
+
if (claudeEntry?.claude && !hasReset(claudeEntry.resetsAt, now)) {
|
|
823
|
+
snapshot.claude = claudeEntry.claude;
|
|
824
|
+
if (claudeEntry.resetsAt !== undefined) resets.claude = claudeEntry.resetsAt;
|
|
825
|
+
}
|
|
826
|
+
if (codexEntry?.codex && !hasReset(codexEntry.resetsAt, now)) {
|
|
827
|
+
snapshot.codex = codexEntry.codex;
|
|
828
|
+
if (codexEntry.resetsAt !== undefined) resets.codex = codexEntry.resetsAt;
|
|
829
|
+
}
|
|
830
|
+
return [snapshot, resets];
|
|
582
831
|
}
|