@hank-warren/pi-statusline 0.7.1 → 0.7.3
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 +20 -0
- package/README.md +7 -1
- package/cache-celebration.ts +3 -3
- package/celebration-preview.ts +1 -1
- package/celebration-styles.ts +3 -3
- package/index.ts +1 -1
- package/package.json +2 -2
- package/redraw.ts +1 -1
- package/settings-menu.ts +1 -1
- package/settings.ts +17 -7
- package/usage.ts +273 -72
- package/worktrees.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @hank-warren/pi-statusline
|
|
2
2
|
|
|
3
|
+
## 0.7.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- d0c46a5: Resolve `auth.json`, the usage cache and `statusline-settings.json` through Pi's agent directory (`PI_CODING_AGENT_DIR`) instead of a hardcoded `~/.pi/agent`. Behaviour is unchanged for a normal install; a session started with a scratch agent dir no longer reads the real credentials or writes the real usage cache. `engines.node` now states Pi's own floor, `>=22.19.0`.
|
|
8
|
+
|
|
9
|
+
## 0.7.2
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
15
|
+
**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.
|
|
16
|
+
|
|
17
|
+
**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.
|
|
18
|
+
|
|
19
|
+
**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.
|
|
20
|
+
|
|
21
|
+
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.
|
|
22
|
+
|
|
3
23
|
## 0.7.1
|
|
4
24
|
|
|
5
25
|
### Patch 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 are driven by session start, each turn, and a ten-second tick while the footer is mounted, throttled
|
|
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,8 +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
|
+
|
|
90
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.
|
|
91
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
|
+
|
|
92
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.
|
|
93
99
|
|
|
94
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/cache-celebration.ts
CHANGED
|
@@ -2,7 +2,7 @@ export const CACHE_HIT_THRESHOLD = 0.96;
|
|
|
2
2
|
export const CACHE_CELEBRATION_FRAME_INTERVAL_MS = 60;
|
|
3
3
|
export const CACHE_CELEBRATION_DURATION_MS = 2_000;
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
interface CacheUsage {
|
|
6
6
|
input: number;
|
|
7
7
|
cacheRead: number;
|
|
8
8
|
cacheWrite: number;
|
|
@@ -13,11 +13,11 @@ export interface CacheCelebrationSnapshot {
|
|
|
13
13
|
frame: number;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
interface CacheCelebrationTarget {
|
|
17
17
|
start(percent: number): void;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
interface CacheCelebrationControllerOptions {
|
|
21
21
|
frameIntervalMs?: number;
|
|
22
22
|
durationMs?: number;
|
|
23
23
|
now?: () => number;
|
package/celebration-preview.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { SettingsListTheme } from "@earendil-works/pi-tui";
|
|
|
7
7
|
/** Percentage shown by the settings-menu preview badge. */
|
|
8
8
|
export const PREVIEW_PERCENT = 96;
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
interface CelebrationPreviewOptions {
|
|
11
11
|
frameIntervalMs?: number;
|
|
12
12
|
schedule?: (callback: () => void, intervalMs: number) => unknown;
|
|
13
13
|
cancel?: (handle: unknown) => void;
|
package/celebration-styles.ts
CHANGED
|
@@ -11,13 +11,13 @@ import type { StatuslinePalette } from "./themes.ts";
|
|
|
11
11
|
const RESET = "\x1b[0m";
|
|
12
12
|
const BOLD = "\x1b[1m";
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
interface Rgb {
|
|
15
15
|
r: number;
|
|
16
16
|
g: number;
|
|
17
17
|
b: number;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
const fgCode = ({ r, g, b }: Rgb): string =>
|
|
21
21
|
`\x1b[38;2;${Math.round(r)};${Math.round(g)};${Math.round(b)}m`;
|
|
22
22
|
|
|
23
23
|
/** Parse an SGR true-colour prefix back into channels; anything else reads as white. */
|
|
@@ -68,7 +68,7 @@ function hue(degrees: number): Rgb {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
/** Colour every character of `badge` for one frame. */
|
|
71
|
-
|
|
71
|
+
type CelebrationStyle = (badge: string, frame: number, palette: StatuslinePalette) => string;
|
|
72
72
|
|
|
73
73
|
const uniform = (badge: string, colour: Rgb): string => `${BOLD}${fgCode(colour)}${badge}${RESET}`;
|
|
74
74
|
|
package/index.ts
CHANGED
|
@@ -124,7 +124,7 @@ function renderRepository(
|
|
|
124
124
|
return part;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
|
|
127
|
+
function renderCacheCelebrationLine(
|
|
128
128
|
summary: string,
|
|
129
129
|
celebration: CacheCelebrationSnapshot,
|
|
130
130
|
palette: StatuslinePalette = DEFAULT_PALETTE,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hank-warren/pi-statusline",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"description": "Compact Pi footer statusline with Git/worktree context, token usage, and neon celebrations for exceptional prompt-cache hits.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
},
|
|
24
24
|
"homepage": "https://github.com/hank-warren/pi-extensions/tree/main/packages/pi-statusline#readme",
|
|
25
25
|
"engines": {
|
|
26
|
-
"node": ">=
|
|
26
|
+
"node": ">=22.19.0"
|
|
27
27
|
},
|
|
28
28
|
"pi": {
|
|
29
29
|
"extensions": [
|
package/redraw.ts
CHANGED
package/settings-menu.ts
CHANGED
package/settings.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { dirname, isAbsolute, join } from "node:path";
|
|
4
4
|
import { pid } from "node:process";
|
|
5
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
6
|
import {
|
|
6
7
|
type CelebrationStyleName,
|
|
7
8
|
DEFAULT_CELEBRATION_STYLE,
|
|
@@ -34,7 +35,7 @@ export interface StatuslineSettings extends Record<BooleanSettingKey, boolean> {
|
|
|
34
35
|
repoAliases: Record<string, string>;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
|
|
38
|
+
function defaultWorktreeRoot(home: string = homedir()): string {
|
|
38
39
|
return join(home || homedir(), "repos", "worktrees");
|
|
39
40
|
}
|
|
40
41
|
|
|
@@ -56,8 +57,17 @@ export function defaultSettings(home: string = homedir()): StatuslineSettings {
|
|
|
56
57
|
};
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
/**
|
|
61
|
+
* Where the settings live when the caller names no path.
|
|
62
|
+
*
|
|
63
|
+
* The agent dir, not the home dir: pi honours `PI_CODING_AGENT_DIR`, so a
|
|
64
|
+
* session running against a scratch agent dir must save its statusline settings
|
|
65
|
+
* there rather than into the host's real `~/.pi/agent`. `home` still shapes the
|
|
66
|
+
* settings' *content* (the worktree root default, `~` collapsing) — that is a
|
|
67
|
+
* different thing and keeps its own parameter.
|
|
68
|
+
*/
|
|
69
|
+
export function defaultSettingsPath(): string {
|
|
70
|
+
return join(getAgentDir(), "statusline-settings.json");
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
/** Expand a leading `~` (and `$HOME`) against `home`; other paths are returned as-is. */
|
|
@@ -78,7 +88,7 @@ export function collapseHome(path: string, home: string = homedir()): string {
|
|
|
78
88
|
return path.startsWith(`${base}/`) ? `~/${path.slice(base.length + 1)}` : path;
|
|
79
89
|
}
|
|
80
90
|
|
|
81
|
-
|
|
91
|
+
interface WorktreeRootResult {
|
|
82
92
|
path?: string;
|
|
83
93
|
error?: string;
|
|
84
94
|
}
|
|
@@ -109,7 +119,7 @@ function normalizeAliases(value: unknown): Record<string, string> | undefined {
|
|
|
109
119
|
return aliases;
|
|
110
120
|
}
|
|
111
121
|
|
|
112
|
-
|
|
122
|
+
interface NormalizedSettings {
|
|
113
123
|
settings: StatuslineSettings;
|
|
114
124
|
/** Top-level keys this version does not know about, preserved on write. */
|
|
115
125
|
extra: Record<string, unknown>;
|
|
@@ -225,7 +235,7 @@ export function serializeSettings(
|
|
|
225
235
|
return out;
|
|
226
236
|
}
|
|
227
237
|
|
|
228
|
-
|
|
238
|
+
interface SettingsStoreOptions {
|
|
229
239
|
path?: string;
|
|
230
240
|
home?: string;
|
|
231
241
|
}
|
|
@@ -242,7 +252,7 @@ export class SettingsStore {
|
|
|
242
252
|
|
|
243
253
|
constructor(options: SettingsStoreOptions = {}) {
|
|
244
254
|
this.home = options.home ?? homedir();
|
|
245
|
-
this.path = options.path ?? defaultSettingsPath(
|
|
255
|
+
this.path = options.path ?? defaultSettingsPath();
|
|
246
256
|
this.current = defaultSettings(this.home);
|
|
247
257
|
}
|
|
248
258
|
|
package/usage.ts
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
-
import { homedir } from "node:os";
|
|
4
3
|
import { join } from "node:path";
|
|
5
4
|
import { pid } from "node:process";
|
|
5
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
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,9 +68,10 @@ 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;
|
|
38
77
|
/**
|
|
@@ -45,7 +84,7 @@ export const USAGE_RATE_LIMIT_BACKOFF_MS = 15 * 60_000;
|
|
|
45
84
|
* process's fresh values appear within one tick instead of at the next turn.
|
|
46
85
|
* Request volume is unchanged: both throttles still gate every fetch.
|
|
47
86
|
*/
|
|
48
|
-
|
|
87
|
+
const USAGE_TICK_INTERVAL_MS = 10_000;
|
|
49
88
|
const FETCH_TIMEOUT_MS = 10_000;
|
|
50
89
|
const ONE_DAY_SECONDS = 86_400;
|
|
51
90
|
|
|
@@ -68,8 +107,8 @@ interface ClaudeLimitEntry {
|
|
|
68
107
|
export function parseClaudeUsage(json: unknown): UsageSnapshot["claude"] | undefined {
|
|
69
108
|
if (typeof json !== "object" || json === null) return undefined;
|
|
70
109
|
const body = json as {
|
|
71
|
-
five_hour?: { utilization?: unknown };
|
|
72
|
-
seven_day?: { utilization?: unknown };
|
|
110
|
+
five_hour?: { utilization?: unknown; resets_at?: unknown };
|
|
111
|
+
seven_day?: { utilization?: unknown; resets_at?: unknown };
|
|
73
112
|
limits?: unknown;
|
|
74
113
|
};
|
|
75
114
|
const fiveHour = toRemaining(body.five_hour?.utilization);
|
|
@@ -93,6 +132,18 @@ export function parseClaudeUsage(json: unknown): UsageSnapshot["claude"] | undef
|
|
|
93
132
|
interface CodexWindow {
|
|
94
133
|
used_percent?: unknown;
|
|
95
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;
|
|
96
147
|
}
|
|
97
148
|
|
|
98
149
|
/** A window whose span is known, so it can be sorted into a slot. */
|
|
@@ -109,9 +160,21 @@ interface SpannedCodexWindow extends CodexWindow {
|
|
|
109
160
|
* and an absent window simply leaves its slot empty.
|
|
110
161
|
*/
|
|
111
162
|
export function parseCodexUsage(json: unknown): UsageSnapshot["codex"] | undefined {
|
|
112
|
-
|
|
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 {};
|
|
113
176
|
const rateLimit = (json as { rate_limit?: unknown }).rate_limit;
|
|
114
|
-
if (typeof rateLimit !== "object" || rateLimit === null) return
|
|
177
|
+
if (typeof rateLimit !== "object" || rateLimit === null) return {};
|
|
115
178
|
const { primary_window, secondary_window } = rateLimit as {
|
|
116
179
|
primary_window?: CodexWindow | null;
|
|
117
180
|
secondary_window?: CodexWindow | null;
|
|
@@ -124,19 +187,35 @@ export function parseCodexUsage(json: unknown): UsageSnapshot["codex"] | undefin
|
|
|
124
187
|
typeof window.limit_window_seconds === "number" &&
|
|
125
188
|
Number.isFinite(window.limit_window_seconds),
|
|
126
189
|
);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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)]);
|
|
140
219
|
}
|
|
141
220
|
|
|
142
221
|
/** Color band for a remaining percent: >60 green, >40 yellow, >15 orange, else red. */
|
|
@@ -150,9 +229,31 @@ export function usageBand(remaining: number): "green" | "yellow" | "orange" | "r
|
|
|
150
229
|
type FetchFn = (url: string, init: { headers: Record<string, string>; signal: AbortSignal }) => Promise<{
|
|
151
230
|
ok: boolean;
|
|
152
231
|
status?: number;
|
|
232
|
+
/** Optional so a caller (or a test double) may omit response headers. */
|
|
233
|
+
headers?: { get(name: string): string | null };
|
|
153
234
|
json(): Promise<unknown>;
|
|
154
235
|
}>;
|
|
155
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
|
+
|
|
156
257
|
export interface UsageTrackerOptions {
|
|
157
258
|
authPath?: string;
|
|
158
259
|
cachePath?: string;
|
|
@@ -182,13 +283,25 @@ type ProviderKey = "claude" | "codex";
|
|
|
182
283
|
const PROVIDER_KEYS = ["claude", "codex"] as const;
|
|
183
284
|
|
|
184
285
|
/** Provider ids whose additional logins (`${base}-${suffix}`) share a meter. */
|
|
185
|
-
|
|
286
|
+
const USAGE_BASE_PROVIDERS: Record<ProviderKey, string> = {
|
|
186
287
|
claude: "anthropic",
|
|
187
288
|
codex: "openai-codex",
|
|
188
289
|
};
|
|
189
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
|
+
|
|
190
297
|
/** Credential id polled for each family. */
|
|
191
|
-
|
|
298
|
+
type UsageAccounts = Record<ProviderKey, string>;
|
|
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
|
+
}
|
|
192
305
|
|
|
193
306
|
function inFamily(providerId: string | undefined, base: string): boolean {
|
|
194
307
|
return providerId === base || (providerId !== undefined && providerId.startsWith(`${base}-`));
|
|
@@ -267,6 +380,12 @@ function credentialIdentities(auth: AuthEntries, accounts: UsageAccounts): Provi
|
|
|
267
380
|
interface UsageAccountEntry {
|
|
268
381
|
/** Credential fingerprint the stored values were fetched with. */
|
|
269
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;
|
|
270
389
|
/** Last time any process started a poll for this account. */
|
|
271
390
|
attemptedAt: number;
|
|
272
391
|
/** Absolute time before which a rate-limited account must not be polled. */
|
|
@@ -323,6 +442,7 @@ function parseUsageCache(json: unknown): UsageCache | undefined {
|
|
|
323
442
|
if (typeof body.attemptedAt !== "number" || !Number.isFinite(body.attemptedAt)) continue;
|
|
324
443
|
const entry: UsageAccountEntry = { identity: body.identity, attemptedAt: body.attemptedAt };
|
|
325
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;
|
|
326
446
|
if (typeof body.claude === "object" && body.claude !== null) entry.claude = body.claude as UsageSnapshot["claude"];
|
|
327
447
|
if (typeof body.codex === "object" && body.codex !== null) entry.codex = body.codex as UsageSnapshot["codex"];
|
|
328
448
|
accounts[accountId] = entry;
|
|
@@ -342,10 +462,13 @@ export class UsageTracker {
|
|
|
342
462
|
private readonly onChange?: () => void;
|
|
343
463
|
private readonly now: () => number;
|
|
344
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>> = {};
|
|
345
467
|
private identities: ProviderIdentities = {};
|
|
346
468
|
private activeProvider: string | undefined;
|
|
347
469
|
private awaitingProvider = true;
|
|
348
|
-
|
|
470
|
+
/** Families that answered 429 this cycle, mapped to their `Retry-After`. */
|
|
471
|
+
private readonly rateLimited = new Map<ProviderKey, number | undefined>();
|
|
349
472
|
private lastAttempt: Record<string, number> = {};
|
|
350
473
|
private inFlight: Promise<void> | undefined;
|
|
351
474
|
private readonly tickIntervalMs: number;
|
|
@@ -354,8 +477,11 @@ export class UsageTracker {
|
|
|
354
477
|
private tickHandle: unknown;
|
|
355
478
|
|
|
356
479
|
constructor(options: UsageTrackerOptions = {}) {
|
|
357
|
-
|
|
358
|
-
|
|
480
|
+
// getAgentDir(), not ~/.pi/agent: pi honours PI_CODING_AGENT_DIR, and a
|
|
481
|
+
// hardcoded home path made a session pointed at another agent dir read the
|
|
482
|
+
// wrong credentials and write its usage cache into the host's real one.
|
|
483
|
+
this.authPath = options.authPath ?? join(getAgentDir(), "auth.json");
|
|
484
|
+
this.cachePath = options.cachePath ?? join(getAgentDir(), "statusline-usage.json");
|
|
359
485
|
this.fetchFn = options.fetchFn ?? ((url, init) => fetch(url, init));
|
|
360
486
|
this.onChange = options.onChange;
|
|
361
487
|
this.now = options.now ?? Date.now;
|
|
@@ -398,11 +524,12 @@ export class UsageTracker {
|
|
|
398
524
|
}
|
|
399
525
|
|
|
400
526
|
/**
|
|
401
|
-
* Interval until the next allowed attempt: the short retry
|
|
402
|
-
* credentialed provider is still missing a value,
|
|
527
|
+
* Interval until the next allowed attempt for one family: the short retry
|
|
528
|
+
* window while a credentialed provider is still missing a value, that
|
|
529
|
+
* family's own steady-state interval otherwise.
|
|
403
530
|
*/
|
|
404
|
-
private currentInterval(): number {
|
|
405
|
-
return this.awaitingProvider ? USAGE_RETRY_INTERVAL_MS :
|
|
531
|
+
private currentInterval(key: ProviderKey): number {
|
|
532
|
+
return this.awaitingProvider ? USAGE_RETRY_INTERVAL_MS : PROVIDER_REFRESH_INTERVAL_MS[key];
|
|
406
533
|
}
|
|
407
534
|
|
|
408
535
|
/** Throttled refresh; resolves when the current attempt (if any) settles. */
|
|
@@ -432,21 +559,32 @@ export class UsageTracker {
|
|
|
432
559
|
// Another process's values are as good as ours and cost no request, so a new
|
|
433
560
|
// session — or a switch back to an account polled earlier — shows real
|
|
434
561
|
// numbers on its very first render.
|
|
435
|
-
this.publish(this.merge(cachedSnapshot(cache, accounts), auth, accounts), auth, accounts, cache);
|
|
436
|
-
|
|
437
562
|
const now = this.now();
|
|
438
|
-
|
|
563
|
+
this.publish(this.merge(...cachedSnapshot(cache, accounts, now), auth, accounts, now), auth, accounts, cache);
|
|
564
|
+
|
|
439
565
|
const pollable: Partial<Record<ProviderKey, string>> = {};
|
|
440
566
|
for (const key of PROVIDER_KEYS) {
|
|
441
567
|
const accountId = accounts[key];
|
|
442
568
|
if (!hasAuth(auth, accountId)) continue;
|
|
443
569
|
const entry = cache.accounts[accountId];
|
|
570
|
+
// A backoff outranks everything: a rate-limited endpoint does not want to
|
|
571
|
+
// hear from us at a window boundary either.
|
|
444
572
|
if (now < (entry?.backoff ?? Number.NEGATIVE_INFINITY)) continue;
|
|
445
573
|
const lastAttempt = Math.max(
|
|
446
574
|
this.lastAttempt[accountId] ?? Number.NEGATIVE_INFINITY,
|
|
447
575
|
entry?.attemptedAt ?? Number.NEGATIVE_INFINITY,
|
|
448
576
|
);
|
|
449
|
-
|
|
577
|
+
// Past a window boundary the stored percentages are wrong rather than
|
|
578
|
+
// merely old, so the interval is skipped to republish promptly — but only
|
|
579
|
+
// for a boundary we have not already answered. A failed poll leaves the
|
|
580
|
+
// boundary in the past, so an unconditional override would poll on every
|
|
581
|
+
// tick for as long as the failure lasted. A 429 sets a backoff that stops
|
|
582
|
+
// that; a 500, a timeout or a dropped connection does not. Answering each
|
|
583
|
+
// boundary once hands the account back to the ordinary intervals, which
|
|
584
|
+
// for a provider now holding no value is the short retry window.
|
|
585
|
+
const boundary = entry?.resetsAt;
|
|
586
|
+
const boundaryUnanswered = hasReset(boundary, now) && (boundary as number) > lastAttempt;
|
|
587
|
+
if (!boundaryUnanswered && now - lastAttempt < this.currentInterval(key)) continue;
|
|
450
588
|
pollable[key] = accountId;
|
|
451
589
|
}
|
|
452
590
|
if (pollable.claude === undefined && pollable.codex === undefined) return;
|
|
@@ -467,10 +605,15 @@ export class UsageTracker {
|
|
|
467
605
|
}
|
|
468
606
|
await this.writeCache({ accounts: claimed });
|
|
469
607
|
|
|
470
|
-
const [
|
|
608
|
+
const [claudeResult, codexResult] = await Promise.all([
|
|
471
609
|
pollable.claude === undefined ? Promise.resolve(undefined) : this.fetchClaude(auth, pollable.claude),
|
|
472
610
|
pollable.codex === undefined ? Promise.resolve(undefined) : this.fetchCodex(auth, pollable.codex),
|
|
473
611
|
]);
|
|
612
|
+
const claude = claudeResult?.value;
|
|
613
|
+
const codex = codexResult?.value;
|
|
614
|
+
const incomingResets: Partial<Record<ProviderKey, number>> = {};
|
|
615
|
+
if (claudeResult?.resetsAt !== undefined) incomingResets.claude = claudeResult.resetsAt;
|
|
616
|
+
if (codexResult?.resetsAt !== undefined) incomingResets.codex = codexResult.resetsAt;
|
|
474
617
|
|
|
475
618
|
const updatedAccounts = { ...claimed };
|
|
476
619
|
for (const key of PROVIDER_KEYS) {
|
|
@@ -478,18 +621,33 @@ export class UsageTracker {
|
|
|
478
621
|
if (accountId === undefined) continue;
|
|
479
622
|
const entry: UsageAccountEntry = { ...(updatedAccounts[accountId] as UsageAccountEntry) };
|
|
480
623
|
const value = key === "claude" ? claude : codex;
|
|
481
|
-
|
|
624
|
+
// A `Retry-After` we could use is authoritative; the flat window is only a
|
|
625
|
+
// guess for a 429 that told us nothing. Deliberately a plain assignment:
|
|
626
|
+
// the poll gate above skips any account whose backoff is still in the
|
|
627
|
+
// future, so a value we are about to overwrite is necessarily absent or
|
|
628
|
+
// already expired, and a monotonic max() here would never do anything.
|
|
629
|
+
if (this.rateLimited.has(key)) {
|
|
630
|
+
entry.backoff = this.rateLimited.get(key) ?? now + USAGE_RATE_LIMIT_BACKOFF_MS;
|
|
631
|
+
}
|
|
482
632
|
else if (value !== undefined) delete entry.backoff;
|
|
483
633
|
// A failed fetch leaves the last-known value in place rather than blanking
|
|
484
634
|
// the meter for whatever caused one bad response.
|
|
485
635
|
if (claude !== undefined && key === "claude") entry.claude = claude;
|
|
486
636
|
if (codex !== undefined && key === "codex") entry.codex = codex;
|
|
637
|
+
// The boundary travels with the value it describes: a refreshed value gets
|
|
638
|
+
// the new window, and one that arrived without a usable reset must not
|
|
639
|
+
// inherit the previous window's.
|
|
640
|
+
if (value !== undefined) {
|
|
641
|
+
const resetsAt = incomingResets[key];
|
|
642
|
+
if (resetsAt === undefined) delete entry.resetsAt;
|
|
643
|
+
else entry.resetsAt = resetsAt;
|
|
644
|
+
}
|
|
487
645
|
updatedAccounts[accountId] = entry;
|
|
488
646
|
}
|
|
489
647
|
this.rateLimited.clear();
|
|
490
648
|
|
|
491
649
|
const updated: UsageCache = { accounts: updatedAccounts };
|
|
492
|
-
this.publish(this.merge({ claude, codex }, auth, accounts), auth, accounts, updated);
|
|
650
|
+
this.publish(this.merge({ claude, codex }, incomingResets, auth, accounts, now), auth, accounts, updated);
|
|
493
651
|
await this.writeCache(updated);
|
|
494
652
|
}
|
|
495
653
|
|
|
@@ -503,6 +661,7 @@ export class UsageTracker {
|
|
|
503
661
|
this.identities = identity;
|
|
504
662
|
if (changedKeys.length === 0) return;
|
|
505
663
|
for (const key of changedKeys) delete this.lastAttempt[accounts[key]];
|
|
664
|
+
for (const key of changedKeys) delete this.currentResets[key];
|
|
506
665
|
if (!changedKeys.some((key) => this.current[key] !== undefined)) return;
|
|
507
666
|
const next = { ...this.current };
|
|
508
667
|
for (const key of changedKeys) delete next[key];
|
|
@@ -516,21 +675,44 @@ export class UsageTracker {
|
|
|
516
675
|
* account has no credentials: a logged-out account must disappear immediately,
|
|
517
676
|
* while a failed fetch keeps whatever we last saw.
|
|
518
677
|
*/
|
|
519
|
-
private merge(
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
678
|
+
private merge(
|
|
679
|
+
incoming: UsageSnapshot,
|
|
680
|
+
incomingResets: Partial<Record<ProviderKey, number>>,
|
|
681
|
+
auth: AuthEntries,
|
|
682
|
+
accounts: UsageAccounts,
|
|
683
|
+
now: number,
|
|
684
|
+
): { snapshot: UsageSnapshot; resets: Partial<Record<ProviderKey, number>> } {
|
|
685
|
+
const snapshot: UsageSnapshot = {};
|
|
686
|
+
const resets: Partial<Record<ProviderKey, number>> = {};
|
|
687
|
+
for (const key of PROVIDER_KEYS) {
|
|
688
|
+
if (!hasAuth(auth, accounts[key])) continue;
|
|
689
|
+
if (incoming[key] !== undefined) {
|
|
690
|
+
// biome-ignore lint/suspicious/noExplicitAny: one assignment, two value shapes.
|
|
691
|
+
snapshot[key] = incoming[key] as any;
|
|
692
|
+
if (incomingResets[key] !== undefined) resets[key] = incomingResets[key];
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
// A last-known-good value survives a failed fetch, but not the rollover of
|
|
696
|
+
// the window it describes.
|
|
697
|
+
if (this.current[key] === undefined || hasReset(this.currentResets[key], now)) continue;
|
|
698
|
+
// biome-ignore lint/suspicious/noExplicitAny: one assignment, two value shapes.
|
|
699
|
+
snapshot[key] = this.current[key] as any;
|
|
700
|
+
if (this.currentResets[key] !== undefined) resets[key] = this.currentResets[key];
|
|
528
701
|
}
|
|
529
|
-
return
|
|
702
|
+
return { snapshot, resets };
|
|
530
703
|
}
|
|
531
704
|
|
|
532
705
|
/** Adopt a snapshot, recompute the retry gate, and repaint only on a change. */
|
|
533
|
-
private publish(
|
|
706
|
+
private publish(
|
|
707
|
+
merged: { snapshot: UsageSnapshot; resets: Partial<Record<ProviderKey, number>> },
|
|
708
|
+
auth: AuthEntries,
|
|
709
|
+
accounts: UsageAccounts,
|
|
710
|
+
cache: UsageCache,
|
|
711
|
+
): void {
|
|
712
|
+
const next = merged.snapshot;
|
|
713
|
+
// Boundaries follow their values even when the percentages are unchanged, so
|
|
714
|
+
// a refreshed window cannot be judged against the previous one's deadline.
|
|
715
|
+
this.currentResets = merged.resets;
|
|
534
716
|
const now = this.now();
|
|
535
717
|
// An account serving 429s is not "pending": retrying it faster is exactly
|
|
536
718
|
// what got us rate limited, so it must not hold the short window open.
|
|
@@ -579,7 +761,9 @@ export class UsageTracker {
|
|
|
579
761
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
580
762
|
try {
|
|
581
763
|
const response = await this.fetchFn(url, { headers, signal: controller.signal });
|
|
582
|
-
if (response.status === 429)
|
|
764
|
+
if (response.status === 429) {
|
|
765
|
+
this.rateLimited.set(provider, parseRetryAfter(response.headers?.get("retry-after"), this.now()));
|
|
766
|
+
}
|
|
583
767
|
if (!response.ok) return undefined;
|
|
584
768
|
return await response.json();
|
|
585
769
|
} finally {
|
|
@@ -587,47 +771,64 @@ export class UsageTracker {
|
|
|
587
771
|
}
|
|
588
772
|
}
|
|
589
773
|
|
|
590
|
-
private async fetchClaude(auth: AuthEntries, accountId: string): Promise<
|
|
774
|
+
private async fetchClaude(auth: AuthEntries, accountId: string): Promise<FetchedUsage<"claude"> | undefined> {
|
|
591
775
|
const access = auth[accountId]?.access;
|
|
592
776
|
if (typeof access !== "string" || access.length === 0) return undefined;
|
|
593
777
|
try {
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
"claude",
|
|
599
|
-
),
|
|
778
|
+
const json = await this.fetchJson(
|
|
779
|
+
CLAUDE_USAGE_URL,
|
|
780
|
+
{ Authorization: `Bearer ${access}`, "anthropic-beta": "oauth-2025-04-20" },
|
|
781
|
+
"claude",
|
|
600
782
|
);
|
|
783
|
+
const value = parseClaudeUsage(json);
|
|
784
|
+
return value === undefined ? undefined : { value, resetsAt: claudeResetAt(json) };
|
|
601
785
|
} catch {
|
|
602
786
|
return undefined;
|
|
603
787
|
}
|
|
604
788
|
}
|
|
605
789
|
|
|
606
|
-
private async fetchCodex(auth: AuthEntries, accountId: string): Promise<
|
|
790
|
+
private async fetchCodex(auth: AuthEntries, accountId: string): Promise<FetchedUsage<"codex"> | undefined> {
|
|
607
791
|
const entry = auth[accountId];
|
|
608
792
|
const access = entry?.access;
|
|
609
793
|
const account = entry?.accountId;
|
|
610
794
|
if (typeof access !== "string" || typeof account !== "string" || !access || !account) return undefined;
|
|
611
795
|
try {
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
"codex",
|
|
617
|
-
),
|
|
796
|
+
const json = await this.fetchJson(
|
|
797
|
+
CODEX_USAGE_URL,
|
|
798
|
+
{ Authorization: `Bearer ${access}`, "chatgpt-account-id": account },
|
|
799
|
+
"codex",
|
|
618
800
|
);
|
|
801
|
+
const value = parseCodexUsage(json);
|
|
802
|
+
// Codex may report only a relative reset, which is meaningless without the
|
|
803
|
+
// clock the rest of the tracker runs on.
|
|
804
|
+
return value === undefined ? undefined : { value, resetsAt: codexResetAt(json, this.now()) };
|
|
619
805
|
} catch {
|
|
620
806
|
return undefined;
|
|
621
807
|
}
|
|
622
808
|
}
|
|
623
809
|
}
|
|
624
810
|
|
|
625
|
-
/**
|
|
626
|
-
|
|
811
|
+
/**
|
|
812
|
+
* The cached values for the currently selected accounts, in display shape.
|
|
813
|
+
* Values whose window has already rolled are dropped: adopting another
|
|
814
|
+
* process's stale reading is exactly as wrong as keeping our own.
|
|
815
|
+
*/
|
|
816
|
+
function cachedSnapshot(
|
|
817
|
+
cache: UsageCache,
|
|
818
|
+
accounts: UsageAccounts,
|
|
819
|
+
now: number,
|
|
820
|
+
): [UsageSnapshot, Partial<Record<ProviderKey, number>>] {
|
|
627
821
|
const snapshot: UsageSnapshot = {};
|
|
628
|
-
const
|
|
629
|
-
const
|
|
630
|
-
|
|
631
|
-
if (
|
|
632
|
-
|
|
822
|
+
const resets: Partial<Record<ProviderKey, number>> = {};
|
|
823
|
+
const claudeEntry = cache.accounts[accounts.claude];
|
|
824
|
+
const codexEntry = cache.accounts[accounts.codex];
|
|
825
|
+
if (claudeEntry?.claude && !hasReset(claudeEntry.resetsAt, now)) {
|
|
826
|
+
snapshot.claude = claudeEntry.claude;
|
|
827
|
+
if (claudeEntry.resetsAt !== undefined) resets.claude = claudeEntry.resetsAt;
|
|
828
|
+
}
|
|
829
|
+
if (codexEntry?.codex && !hasReset(codexEntry.resetsAt, now)) {
|
|
830
|
+
snapshot.codex = codexEntry.codex;
|
|
831
|
+
if (codexEntry.resetsAt !== undefined) resets.codex = codexEntry.resetsAt;
|
|
832
|
+
}
|
|
833
|
+
return [snapshot, resets];
|
|
633
834
|
}
|