@hank-warren/pi-statusline 0.4.2 → 0.6.0
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 +18 -0
- package/README.md +16 -2
- package/index.ts +25 -5
- package/package.json +1 -1
- package/usage.ts +265 -142
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @hank-warren/pi-statusline
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 01994c7: Show the Codex 5-hour window alongside the weekly one. Codex has restored a 5-hour limit next to the weekly one on some plans, and the parser already received that window and deliberately discarded it — it kept only the largest window spanning at least a day — so a real limit went unshown. Both are now rendered like the Claude meter: 5-hour, dim separator, weekly.
|
|
8
|
+
|
|
9
|
+
Which windows exist is a property of the account, so the two slots are filled from each window's span rather than from the plan name: the shortest sub-day window fills the 5-hour slot and the longest multi-day window fills the other. A plan with no 5-hour limit keeps rendering its single weekly number, and a free plan's lone 30-day window keeps rendering in the same place. A payload carrying only a sub-day window now reports it as the 5-hour value instead of mislabelling it as weekly.
|
|
10
|
+
|
|
11
|
+
The host-wide usage cache changes additively: an entry written by an earlier version has no 5-hour value and renders weekly-only until the next poll, and an earlier version ignores the extra one.
|
|
12
|
+
|
|
13
|
+
## 0.5.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- 0e9400e: Point each subscription usage meter at the account behind the main model, so additional provider logins report the headroom actually being spent. Families the main model does not belong to keep showing their base account, and a login used only for background work is never polled.
|
|
18
|
+
|
|
19
|
+
The host-wide usage cache is now keyed by credential id rather than by provider family. Keyed by family, two sessions on two different Anthropic accounts each looked like an account switch to the other, evicting each other's values and re-polling every cycle. Cache files written by earlier versions are discarded, costing one extra poll on upgrade.
|
|
20
|
+
|
|
3
21
|
## 0.4.2
|
|
4
22
|
|
|
5
23
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -71,13 +71,27 @@ Up to 0.2.x this package hardcoded five alias pairs and stripped a `platform-` p
|
|
|
71
71
|
When Pi's `~/.pi/agent/auth.json` contains OAuth credentials for Anthropic (Claude subscription) and/or OpenAI Codex, line 1 shows **percent remaining** for each rate-limit window after the context meter:
|
|
72
72
|
|
|
73
73
|
- ` 97·54` — Claude 5-hour, then weekly remaining percent (Nerd Font `nf-cod-claude` icon). Subscriptions with a model-scoped weekly limit (e.g. Fable) show it as a third number — ` 97·54·24` — and it is omitted when the account has none.
|
|
74
|
-
- `
|
|
74
|
+
- ` 92·99` — Codex 5-hour, then weekly remaining percent (`nf-cod-openai` icon). Which windows exist is a property of the plan, so the slots are filled from the payload's window spans rather than from its plan name: a plan with no 5-hour limit shows the weekly number alone — ` 45` — and a free plan, whose single window is monthly, shows that one number in the same place.
|
|
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
78
|
Usage is fetched from the providers' own usage endpoints with Pi's stored tokens — read-only; tokens are never refreshed or written. Fetches happen on session start and after each turn, throttled to at most once every five minutes (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
|
+
|
|
82
|
+
With a single login per provider — the ordinary case, including logging out and back in as a different Anthropic account — nothing here applies: each meter shows that provider's account, exactly as before.
|
|
83
|
+
|
|
84
|
+
When [`@hank-warren/pi-multi-login`](../pi-multi-login/README.md) has registered additional logins (`anthropic-work`, `openai-codex-alt`), a provider family can hold several accounts at once. Each meter then shows **the account behind the main model**, falling back to the base account (`anthropic`, `openai-codex`) when the main model belongs to the other family. So switching the main model between two Claude logins swaps the Claude meter and leaves the Codex meter alone, and a login used only for background work — such as a `pi-auto-permissions` reviewer, which is never the main model — is never polled at all. There is deliberately no marker for *which* account is shown: the meter tracks whatever you are actually spending.
|
|
85
|
+
|
|
86
|
+
### Polling and the shared cache
|
|
87
|
+
|
|
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
|
+
|
|
90
|
+
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
|
+
|
|
92
|
+
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.
|
|
93
|
+
|
|
94
|
+
Requires a Nerd Font new enough to include the codicon brand glyphs (v3.5.0+); older fonts render them as replacement boxes.
|
|
81
95
|
|
|
82
96
|
## Cache-hit celebration
|
|
83
97
|
|
package/index.ts
CHANGED
|
@@ -71,15 +71,21 @@ export function renderUsageSegment(
|
|
|
71
71
|
palette: StatuslinePalette = DEFAULT_PALETTE,
|
|
72
72
|
): string | undefined {
|
|
73
73
|
const percent = (remaining: number) => styled(bandColor(remaining, palette), `${remaining}`);
|
|
74
|
+
const dot = styled(palette.dim, "\u00b7");
|
|
74
75
|
const parts: string[] = [];
|
|
75
76
|
if (usage.claude) {
|
|
76
|
-
const dot = styled(palette.dim, "\u00b7");
|
|
77
77
|
let claude = `${percent(usage.claude.fiveHour)}${dot}${percent(usage.claude.sevenDay)}`;
|
|
78
78
|
if (usage.claude.scopedWeekly !== undefined) claude += `${dot}${percent(usage.claude.scopedWeekly)}`;
|
|
79
79
|
parts.push(`${styled(palette.text, CLAUDE_ICON)} ${claude}`);
|
|
80
80
|
}
|
|
81
81
|
if (usage.codex) {
|
|
82
|
-
|
|
82
|
+
// Shortest window first, mirroring Claude. Plans without a 5-hour limit
|
|
83
|
+
// leave that slot empty and keep rendering the single long-window number.
|
|
84
|
+
const codex = [usage.codex.fiveHour, usage.codex.weekly]
|
|
85
|
+
.filter((remaining): remaining is number => remaining !== undefined)
|
|
86
|
+
.map(percent)
|
|
87
|
+
.join(dot);
|
|
88
|
+
if (codex.length > 0) parts.push(`${styled(palette.text, OPENAI_ICON)} ${codex}`);
|
|
83
89
|
}
|
|
84
90
|
return parts.length > 0 ? parts.join(" ") : undefined;
|
|
85
91
|
}
|
|
@@ -264,7 +270,10 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
264
270
|
cwdStatusAbort = new AbortController();
|
|
265
271
|
cwdStatusInFlight = undefined;
|
|
266
272
|
runInBackground(refreshCwdStatus(ctx));
|
|
267
|
-
if (settings.showUsage)
|
|
273
|
+
if (settings.showUsage) {
|
|
274
|
+
usageTracker.setActiveProvider(ctx.model?.provider);
|
|
275
|
+
runInBackground(usageTracker.refresh());
|
|
276
|
+
}
|
|
268
277
|
// A hidden worktree line must not pay for git/gh polling.
|
|
269
278
|
if (!settings.showWorktrees) return;
|
|
270
279
|
const next = new SessionWorktreeTracker({
|
|
@@ -446,9 +455,20 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
446
455
|
requestRender?.();
|
|
447
456
|
runInBackground(refreshCwdStatus(ctx));
|
|
448
457
|
if (tracker) runInBackground(tracker.refresh());
|
|
449
|
-
if (settings.showUsage)
|
|
458
|
+
if (settings.showUsage) {
|
|
459
|
+
usageTracker.setActiveProvider(ctx.model?.provider);
|
|
460
|
+
runInBackground(usageTracker.refresh());
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
// The meters follow the main model's account, so a switch between two logins
|
|
464
|
+
// of the same provider family has to re-point the tracker before it repaints.
|
|
465
|
+
pi.on("model_select", (event) => {
|
|
466
|
+
if (settings.showUsage) {
|
|
467
|
+
usageTracker.setActiveProvider(event.model.provider);
|
|
468
|
+
runInBackground(usageTracker.refresh());
|
|
469
|
+
}
|
|
470
|
+
requestRender?.();
|
|
450
471
|
});
|
|
451
|
-
pi.on("model_select", () => requestRender?.());
|
|
452
472
|
pi.on("session_tree", (_event, ctx) => resetTracker(ctx));
|
|
453
473
|
pi.on("session_shutdown", () => {
|
|
454
474
|
cacheCelebration.dispose();
|
package/package.json
CHANGED
package/usage.ts
CHANGED
|
@@ -7,7 +7,12 @@ import { pid } from "node:process";
|
|
|
7
7
|
/** Remaining (not used) integer percents per provider window. */
|
|
8
8
|
export interface UsageSnapshot {
|
|
9
9
|
claude?: { fiveHour: number; sevenDay: number; scopedWeekly?: number };
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Codex reports one or two windows depending on the plan: a short (5-hour)
|
|
12
|
+
* window, a long one, or both. Every field is optional because which of them
|
|
13
|
+
* exists is a property of the account, not of the payload shape.
|
|
14
|
+
*/
|
|
15
|
+
codex?: { fiveHour?: number; weekly?: number };
|
|
11
16
|
}
|
|
12
17
|
|
|
13
18
|
const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
@@ -25,7 +30,7 @@ export const USAGE_REFRESH_INTERVAL_MS = 5 * 60_000;
|
|
|
25
30
|
*/
|
|
26
31
|
export const USAGE_RETRY_INTERVAL_MS = 30_000;
|
|
27
32
|
/**
|
|
28
|
-
* How long
|
|
33
|
+
* How long an account is left alone after it answers 429. Polls are host-wide
|
|
29
34
|
* (see the shared cache below), so a rate limit means the provider itself wants
|
|
30
35
|
* a break rather than that we are racing ourselves.
|
|
31
36
|
*/
|
|
@@ -79,7 +84,19 @@ interface CodexWindow {
|
|
|
79
84
|
limit_window_seconds?: unknown;
|
|
80
85
|
}
|
|
81
86
|
|
|
82
|
-
/**
|
|
87
|
+
/** A window whose span is known, so it can be sorted into a slot. */
|
|
88
|
+
interface SpannedCodexWindow extends CodexWindow {
|
|
89
|
+
limit_window_seconds: number;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Parse the Codex usage payload into the short (sub-day) and long (≥ 1 day)
|
|
94
|
+
* windows. Which ones exist depends on the plan — $20 plans report a 5-hour
|
|
95
|
+
* window alongside the weekly one, larger plans have reported weekly only, and
|
|
96
|
+
* free plans report a single 30-day window that lands in the long slot. So the
|
|
97
|
+
* slots are filled from `limit_window_seconds` rather than from `plan_type`,
|
|
98
|
+
* and an absent window simply leaves its slot empty.
|
|
99
|
+
*/
|
|
83
100
|
export function parseCodexUsage(json: unknown): UsageSnapshot["codex"] | undefined {
|
|
84
101
|
if (typeof json !== "object" || json === null) return undefined;
|
|
85
102
|
const rateLimit = (json as { rate_limit?: unknown }).rate_limit;
|
|
@@ -88,14 +105,27 @@ export function parseCodexUsage(json: unknown): UsageSnapshot["codex"] | undefin
|
|
|
88
105
|
primary_window?: CodexWindow | null;
|
|
89
106
|
secondary_window?: CodexWindow | null;
|
|
90
107
|
};
|
|
108
|
+
// A window without a usable span cannot be placed in either slot.
|
|
91
109
|
const windows = [primary_window, secondary_window].filter(
|
|
92
|
-
(window): window is
|
|
110
|
+
(window): window is SpannedCodexWindow =>
|
|
111
|
+
typeof window === "object" &&
|
|
112
|
+
window !== null &&
|
|
113
|
+
typeof window.limit_window_seconds === "number" &&
|
|
114
|
+
Number.isFinite(window.limit_window_seconds),
|
|
93
115
|
);
|
|
94
|
-
const
|
|
95
|
-
.filter((window) =>
|
|
96
|
-
.sort((a, b) =>
|
|
97
|
-
const
|
|
98
|
-
|
|
116
|
+
const shortest = windows
|
|
117
|
+
.filter((window) => window.limit_window_seconds < ONE_DAY_SECONDS)
|
|
118
|
+
.sort((a, b) => a.limit_window_seconds - b.limit_window_seconds)[0];
|
|
119
|
+
const longest = windows
|
|
120
|
+
.filter((window) => window.limit_window_seconds >= ONE_DAY_SECONDS)
|
|
121
|
+
.sort((a, b) => b.limit_window_seconds - a.limit_window_seconds)[0];
|
|
122
|
+
const fiveHour = toRemaining(shortest?.used_percent);
|
|
123
|
+
const weekly = toRemaining(longest?.used_percent);
|
|
124
|
+
if (fiveHour === undefined && weekly === undefined) return undefined;
|
|
125
|
+
const result: UsageSnapshot["codex"] = {};
|
|
126
|
+
if (fiveHour !== undefined) result.fiveHour = fiveHour;
|
|
127
|
+
if (weekly !== undefined) result.weekly = weekly;
|
|
128
|
+
return result;
|
|
99
129
|
}
|
|
100
130
|
|
|
101
131
|
/** Color band for a remaining percent: >60 green, >40 yellow, >15 orange, else red. */
|
|
@@ -118,124 +148,167 @@ export interface UsageTrackerOptions {
|
|
|
118
148
|
fetchFn?: FetchFn;
|
|
119
149
|
onChange?: () => void;
|
|
120
150
|
now?: () => number;
|
|
151
|
+
/** Provider id of the session's main model; selects which account is polled. */
|
|
152
|
+
activeProvider?: string;
|
|
121
153
|
}
|
|
122
154
|
|
|
123
155
|
type ProviderKey = "claude" | "codex";
|
|
124
156
|
|
|
125
157
|
const PROVIDER_KEYS = ["claude", "codex"] as const;
|
|
126
158
|
|
|
127
|
-
/**
|
|
159
|
+
/** Provider ids whose additional logins (`${base}-${suffix}`) share a meter. */
|
|
160
|
+
export const USAGE_BASE_PROVIDERS: Record<ProviderKey, string> = {
|
|
161
|
+
claude: "anthropic",
|
|
162
|
+
codex: "openai-codex",
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/** Credential id polled for each family. */
|
|
166
|
+
export type UsageAccounts = Record<ProviderKey, string>;
|
|
167
|
+
|
|
168
|
+
function inFamily(providerId: string | undefined, base: string): boolean {
|
|
169
|
+
return providerId === base || (providerId !== undefined && providerId.startsWith(`${base}-`));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The family an account id belongs to, or undefined when it is neither. */
|
|
173
|
+
function familyOf(accountId: string): ProviderKey | undefined {
|
|
174
|
+
return PROVIDER_KEYS.find((key) => inFamily(accountId, USAGE_BASE_PROVIDERS[key]));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Choose the account whose meter each family shows.
|
|
179
|
+
*
|
|
180
|
+
* Additional logins (@hank-warren/pi-multi-login) mean a family can hold several
|
|
181
|
+
* accounts at once. The one worth showing is the one the session is actually
|
|
182
|
+
* spending: the provider of the main model. Every other family falls back to its
|
|
183
|
+
* base account, which is what makes a backup provider's meter stay visible while
|
|
184
|
+
* you are not using it — and what keeps a dedicated background-reviewer login
|
|
185
|
+
* (never the main model) from ever being polled.
|
|
186
|
+
*/
|
|
187
|
+
export function resolveUsageAccounts(activeProvider: string | undefined): UsageAccounts {
|
|
188
|
+
const accounts = { ...USAGE_BASE_PROVIDERS };
|
|
189
|
+
for (const key of PROVIDER_KEYS) {
|
|
190
|
+
if (inFamily(activeProvider, USAGE_BASE_PROVIDERS[key])) accounts[key] = activeProvider as string;
|
|
191
|
+
}
|
|
192
|
+
return accounts;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Non-reversible per-family credential fingerprints; never raw tokens. */
|
|
128
196
|
type ProviderIdentities = Partial<Record<ProviderKey, string>>;
|
|
129
197
|
|
|
130
198
|
function fingerprint(material: string): string {
|
|
131
199
|
return createHash("sha256").update(material).digest("hex").slice(0, 16);
|
|
132
200
|
}
|
|
133
201
|
|
|
202
|
+
interface AuthEntry {
|
|
203
|
+
access?: unknown;
|
|
204
|
+
accountId?: unknown;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
type AuthEntries = Record<string, AuthEntry | undefined>;
|
|
208
|
+
|
|
134
209
|
/**
|
|
135
|
-
* Fingerprint the
|
|
136
|
-
* percentages belong to an account, so a
|
|
137
|
-
* while the
|
|
138
|
-
* after an account switch, yesterday's "1% left"
|
|
210
|
+
* Fingerprint the credential an account would be polled with, or undefined when
|
|
211
|
+
* it cannot be polled at all. Usage percentages belong to an account, so a
|
|
212
|
+
* cached value is only trustworthy while the credential that produced it is
|
|
213
|
+
* still the one in auth.json — after an account switch, yesterday's "1% left"
|
|
214
|
+
* is somebody else's meter.
|
|
139
215
|
*/
|
|
140
|
-
function
|
|
216
|
+
function accountFingerprint(auth: AuthEntries, accountId: string): string | undefined {
|
|
217
|
+
const entry = auth[accountId];
|
|
218
|
+
const access = entry?.access;
|
|
219
|
+
if (typeof access !== "string" || access.length === 0) return undefined;
|
|
220
|
+
if (familyOf(accountId) === "codex") {
|
|
221
|
+
const account = entry?.accountId;
|
|
222
|
+
if (typeof account !== "string" || account.length === 0) return undefined;
|
|
223
|
+
return fingerprint(`${access}\n${account}`);
|
|
224
|
+
}
|
|
225
|
+
return fingerprint(access);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function hasAuth(auth: AuthEntries, accountId: string): boolean {
|
|
229
|
+
return accountFingerprint(auth, accountId) !== undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function credentialIdentities(auth: AuthEntries, accounts: UsageAccounts): ProviderIdentities {
|
|
141
233
|
const identities: ProviderIdentities = {};
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
identities.codex = fingerprint(`${entry?.access as string}\n${entry?.accountId as string}`);
|
|
234
|
+
for (const key of PROVIDER_KEYS) {
|
|
235
|
+
const value = accountFingerprint(auth, accounts[key]);
|
|
236
|
+
if (value !== undefined) identities[key] = value;
|
|
146
237
|
}
|
|
147
238
|
return identities;
|
|
148
239
|
}
|
|
149
240
|
|
|
241
|
+
/** One account's shared poll state. Only the family's own field is ever set. */
|
|
242
|
+
interface UsageAccountEntry {
|
|
243
|
+
/** Credential fingerprint the stored values were fetched with. */
|
|
244
|
+
identity: string;
|
|
245
|
+
/** Last time any process started a poll for this account. */
|
|
246
|
+
attemptedAt: number;
|
|
247
|
+
/** Absolute time before which a rate-limited account must not be polled. */
|
|
248
|
+
backoff?: number;
|
|
249
|
+
claude?: UsageSnapshot["claude"];
|
|
250
|
+
codex?: UsageSnapshot["codex"];
|
|
251
|
+
}
|
|
252
|
+
|
|
150
253
|
/**
|
|
151
254
|
* Host-wide poll state shared by every pi process. Usage percentages are a
|
|
152
|
-
* property of the account, not of a session, so one poll per interval per
|
|
153
|
-
* is both sufficient and necessary: a busy host runs dozens of pi
|
|
154
|
-
* per-process polling stampedes the endpoints into rate limiting
|
|
255
|
+
* property of the account, not of a session, so one poll per interval per
|
|
256
|
+
* account is both sufficient and necessary: a busy host runs dozens of pi
|
|
257
|
+
* processes, and per-process polling stampedes the endpoints into rate limiting
|
|
258
|
+
* everyone.
|
|
259
|
+
*
|
|
260
|
+
* Keyed by credential id rather than by family, because two sessions on the
|
|
261
|
+
* same host may legitimately be using two different Anthropic accounts. Keying
|
|
262
|
+
* by family made each of them look like an account switch to the other, so they
|
|
263
|
+
* evicted each other's values and re-polled on every cycle — the exact
|
|
264
|
+
* stampede this cache exists to prevent.
|
|
155
265
|
*/
|
|
156
266
|
interface UsageCache {
|
|
157
|
-
|
|
158
|
-
attemptedAt: number;
|
|
159
|
-
/** Absolute times before which a rate-limited provider must not be polled. */
|
|
160
|
-
backoff: Partial<Record<ProviderKey, number>>;
|
|
161
|
-
snapshot: UsageSnapshot;
|
|
162
|
-
/** Credential fingerprints the snapshot was fetched with. */
|
|
163
|
-
identity: ProviderIdentities;
|
|
267
|
+
accounts: Record<string, UsageAccountEntry>;
|
|
164
268
|
}
|
|
165
269
|
|
|
166
|
-
const EMPTY_CACHE: UsageCache = {
|
|
167
|
-
attemptedAt: Number.NEGATIVE_INFINITY,
|
|
168
|
-
backoff: {},
|
|
169
|
-
snapshot: {},
|
|
170
|
-
identity: {},
|
|
171
|
-
};
|
|
270
|
+
const EMPTY_CACHE: UsageCache = { accounts: {} };
|
|
172
271
|
|
|
173
272
|
/**
|
|
174
|
-
* Drop
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
273
|
+
* Drop entries whose credential no longer matches auth.json. A token rotation
|
|
274
|
+
* looks the same as an account switch here; the cost of treating it as one is a
|
|
275
|
+
* single immediate re-poll, while trusting a stale identity means showing
|
|
276
|
+
* another account's numbers as if they were current. Logged-out accounts fail
|
|
277
|
+
* the same check, so the file garbage-collects itself.
|
|
178
278
|
*/
|
|
179
|
-
function pruneCache(cache: UsageCache,
|
|
180
|
-
const
|
|
181
|
-
const backoff = { ...cache.backoff };
|
|
279
|
+
function pruneCache(cache: UsageCache, auth: AuthEntries): UsageCache {
|
|
280
|
+
const accounts: Record<string, UsageAccountEntry> = {};
|
|
182
281
|
let changed = false;
|
|
183
|
-
for (const
|
|
184
|
-
if (
|
|
185
|
-
|
|
186
|
-
delete snapshot[key];
|
|
187
|
-
delete backoff[key];
|
|
282
|
+
for (const [accountId, entry] of Object.entries(cache.accounts)) {
|
|
283
|
+
if (accountFingerprint(auth, accountId) === entry.identity) accounts[accountId] = entry;
|
|
284
|
+
else changed = true;
|
|
188
285
|
}
|
|
189
|
-
|
|
190
|
-
// The pruned values also claimed the shared throttle slot; the new account's
|
|
191
|
-
// first poll must not wait out a window started for somebody else.
|
|
192
|
-
return { attemptedAt: Number.NEGATIVE_INFINITY, backoff, snapshot, identity };
|
|
286
|
+
return changed ? { accounts } : cache;
|
|
193
287
|
}
|
|
194
288
|
|
|
195
289
|
function parseUsageCache(json: unknown): UsageCache | undefined {
|
|
196
290
|
if (typeof json !== "object" || json === null) return undefined;
|
|
197
|
-
const
|
|
198
|
-
if (typeof
|
|
199
|
-
const
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if (typeof body.
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
if (typeof body.
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
291
|
+
const rawAccounts = (json as { accounts?: unknown }).accounts;
|
|
292
|
+
if (typeof rawAccounts !== "object" || rawAccounts === null) return undefined;
|
|
293
|
+
const accounts: Record<string, UsageAccountEntry> = {};
|
|
294
|
+
for (const [accountId, value] of Object.entries(rawAccounts as Record<string, unknown>)) {
|
|
295
|
+
if (typeof value !== "object" || value === null) continue;
|
|
296
|
+
const body = value as Record<string, unknown>;
|
|
297
|
+
if (typeof body.identity !== "string") continue;
|
|
298
|
+
if (typeof body.attemptedAt !== "number" || !Number.isFinite(body.attemptedAt)) continue;
|
|
299
|
+
const entry: UsageAccountEntry = { identity: body.identity, attemptedAt: body.attemptedAt };
|
|
300
|
+
if (typeof body.backoff === "number" && Number.isFinite(body.backoff)) entry.backoff = body.backoff;
|
|
301
|
+
if (typeof body.claude === "object" && body.claude !== null) entry.claude = body.claude as UsageSnapshot["claude"];
|
|
302
|
+
if (typeof body.codex === "object" && body.codex !== null) entry.codex = body.codex as UsageSnapshot["codex"];
|
|
303
|
+
accounts[accountId] = entry;
|
|
211
304
|
}
|
|
212
|
-
return {
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
interface AuthEntries {
|
|
216
|
-
anthropic?: { access?: unknown };
|
|
217
|
-
"openai-codex"?: { access?: unknown; accountId?: unknown };
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function hasClaudeAuth(auth: AuthEntries): boolean {
|
|
221
|
-
const access = auth.anthropic?.access;
|
|
222
|
-
return typeof access === "string" && access.length > 0;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function hasCodexAuth(auth: AuthEntries): boolean {
|
|
226
|
-
const entry = auth["openai-codex"];
|
|
227
|
-
return (
|
|
228
|
-
typeof entry?.access === "string" &&
|
|
229
|
-
entry.access.length > 0 &&
|
|
230
|
-
typeof entry.accountId === "string" &&
|
|
231
|
-
entry.accountId.length > 0
|
|
232
|
-
);
|
|
305
|
+
return { accounts };
|
|
233
306
|
}
|
|
234
307
|
|
|
235
308
|
/**
|
|
236
309
|
* Best-effort subscription usage poller. Reads Pi's auth.json for tokens (never
|
|
237
310
|
* refreshes them), fetches both usage endpoints, and keeps the last-known good
|
|
238
|
-
* value per
|
|
311
|
+
* value per account. Refreshes are throttled and must never throw.
|
|
239
312
|
*/
|
|
240
313
|
export class UsageTracker {
|
|
241
314
|
private readonly authPath: string;
|
|
@@ -245,9 +318,10 @@ export class UsageTracker {
|
|
|
245
318
|
private readonly now: () => number;
|
|
246
319
|
private current: UsageSnapshot = {};
|
|
247
320
|
private identities: ProviderIdentities = {};
|
|
321
|
+
private activeProvider: string | undefined;
|
|
248
322
|
private awaitingProvider = true;
|
|
249
323
|
private readonly rateLimited = new Set<ProviderKey>();
|
|
250
|
-
private lastAttempt =
|
|
324
|
+
private lastAttempt: Record<string, number> = {};
|
|
251
325
|
private inFlight: Promise<void> | undefined;
|
|
252
326
|
|
|
253
327
|
constructor(options: UsageTrackerOptions = {}) {
|
|
@@ -256,12 +330,22 @@ export class UsageTracker {
|
|
|
256
330
|
this.fetchFn = options.fetchFn ?? ((url, init) => fetch(url, init));
|
|
257
331
|
this.onChange = options.onChange;
|
|
258
332
|
this.now = options.now ?? Date.now;
|
|
333
|
+
this.activeProvider = options.activeProvider;
|
|
259
334
|
}
|
|
260
335
|
|
|
261
336
|
snapshot(): UsageSnapshot {
|
|
262
337
|
return this.current;
|
|
263
338
|
}
|
|
264
339
|
|
|
340
|
+
/**
|
|
341
|
+
* Point the meters at the session's main model. A switch between two accounts
|
|
342
|
+
* in the same family changes which one is displayed; the next refresh adopts
|
|
343
|
+
* the new account's cached value if there is one, and polls otherwise.
|
|
344
|
+
*/
|
|
345
|
+
setActiveProvider(providerId: string | undefined): void {
|
|
346
|
+
this.activeProvider = providerId;
|
|
347
|
+
}
|
|
348
|
+
|
|
265
349
|
/**
|
|
266
350
|
* Interval until the next allowed attempt: the short retry window while a
|
|
267
351
|
* credentialed provider is still missing a value, the full interval otherwise.
|
|
@@ -285,58 +369,89 @@ export class UsageTracker {
|
|
|
285
369
|
}
|
|
286
370
|
|
|
287
371
|
/**
|
|
288
|
-
* One refresh cycle: adopt whatever another process has already published
|
|
289
|
-
* then poll only
|
|
372
|
+
* One refresh cycle: adopt whatever another process has already published for
|
|
373
|
+
* the selected accounts, then poll only those the shared throttle allows.
|
|
290
374
|
*/
|
|
291
375
|
private async performRefresh(): Promise<void> {
|
|
292
376
|
const auth = (await this.readAuth()) ?? {};
|
|
293
|
-
const
|
|
294
|
-
const
|
|
295
|
-
this.
|
|
377
|
+
const accounts = resolveUsageAccounts(this.activeProvider);
|
|
378
|
+
const identity = credentialIdentities(auth, accounts);
|
|
379
|
+
const cache = pruneCache((await this.readCache()) ?? EMPTY_CACHE, auth);
|
|
380
|
+
this.pruneCurrent(identity, accounts);
|
|
296
381
|
// Another process's values are as good as ours and cost no request, so a new
|
|
297
|
-
// session
|
|
298
|
-
|
|
382
|
+
// session — or a switch back to an account polled earlier — shows real
|
|
383
|
+
// numbers on its very first render.
|
|
384
|
+
this.publish(this.merge(cachedSnapshot(cache, accounts), auth, accounts), auth, accounts, cache);
|
|
299
385
|
|
|
300
386
|
const now = this.now();
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
387
|
+
const interval = this.currentInterval();
|
|
388
|
+
const pollable: Partial<Record<ProviderKey, string>> = {};
|
|
389
|
+
for (const key of PROVIDER_KEYS) {
|
|
390
|
+
const accountId = accounts[key];
|
|
391
|
+
if (!hasAuth(auth, accountId)) continue;
|
|
392
|
+
const entry = cache.accounts[accountId];
|
|
393
|
+
if (now < (entry?.backoff ?? Number.NEGATIVE_INFINITY)) continue;
|
|
394
|
+
const lastAttempt = Math.max(
|
|
395
|
+
this.lastAttempt[accountId] ?? Number.NEGATIVE_INFINITY,
|
|
396
|
+
entry?.attemptedAt ?? Number.NEGATIVE_INFINITY,
|
|
397
|
+
);
|
|
398
|
+
if (now - lastAttempt < interval) continue;
|
|
399
|
+
pollable[key] = accountId;
|
|
400
|
+
}
|
|
401
|
+
if (pollable.claude === undefined && pollable.codex === undefined) return;
|
|
402
|
+
|
|
403
|
+
// Claim each slot before fetching so sibling processes skip this window even
|
|
309
404
|
// if our own request is slow or fails outright. The claim carries the fresh
|
|
310
405
|
// identity so siblings do not re-prune and stampede the same window.
|
|
311
|
-
|
|
406
|
+
const claimed = { ...cache.accounts };
|
|
407
|
+
for (const key of PROVIDER_KEYS) {
|
|
408
|
+
const accountId = pollable[key];
|
|
409
|
+
if (accountId === undefined) continue;
|
|
410
|
+
this.lastAttempt[accountId] = now;
|
|
411
|
+
claimed[accountId] = {
|
|
412
|
+
...claimed[accountId],
|
|
413
|
+
identity: identity[key] as string,
|
|
414
|
+
attemptedAt: now,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
await this.writeCache({ accounts: claimed });
|
|
312
418
|
|
|
313
419
|
const [claude, codex] = await Promise.all([
|
|
314
|
-
|
|
315
|
-
|
|
420
|
+
pollable.claude === undefined ? Promise.resolve(undefined) : this.fetchClaude(auth, pollable.claude),
|
|
421
|
+
pollable.codex === undefined ? Promise.resolve(undefined) : this.fetchCodex(auth, pollable.codex),
|
|
316
422
|
]);
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
423
|
+
|
|
424
|
+
const updatedAccounts = { ...claimed };
|
|
425
|
+
for (const key of PROVIDER_KEYS) {
|
|
426
|
+
const accountId = pollable[key];
|
|
427
|
+
if (accountId === undefined) continue;
|
|
428
|
+
const entry: UsageAccountEntry = { ...(updatedAccounts[accountId] as UsageAccountEntry) };
|
|
429
|
+
const value = key === "claude" ? claude : codex;
|
|
430
|
+
if (this.rateLimited.has(key)) entry.backoff = now + USAGE_RATE_LIMIT_BACKOFF_MS;
|
|
431
|
+
else if (value !== undefined) delete entry.backoff;
|
|
432
|
+
// A failed fetch leaves the last-known value in place rather than blanking
|
|
433
|
+
// the meter for whatever caused one bad response.
|
|
434
|
+
if (claude !== undefined && key === "claude") entry.claude = claude;
|
|
435
|
+
if (codex !== undefined && key === "codex") entry.codex = codex;
|
|
436
|
+
updatedAccounts[accountId] = entry;
|
|
321
437
|
}
|
|
322
438
|
this.rateLimited.clear();
|
|
323
439
|
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
this.publish(next, auth, updated);
|
|
440
|
+
const updated: UsageCache = { accounts: updatedAccounts };
|
|
441
|
+
this.publish(this.merge({ claude, codex }, auth, accounts), auth, accounts, updated);
|
|
327
442
|
await this.writeCache(updated);
|
|
328
443
|
}
|
|
329
444
|
|
|
330
445
|
/**
|
|
331
446
|
* Drop in-memory values fetched with previous credentials so merge() cannot
|
|
332
447
|
* resurrect another account's numbers, and let the changed identity reopen
|
|
333
|
-
* this process's own throttle gate.
|
|
448
|
+
* this process's own throttle gate for the newly selected account.
|
|
334
449
|
*/
|
|
335
|
-
private pruneCurrent(identity: ProviderIdentities): void {
|
|
450
|
+
private pruneCurrent(identity: ProviderIdentities, accounts: UsageAccounts): void {
|
|
336
451
|
const changedKeys = PROVIDER_KEYS.filter((key) => this.identities[key] !== identity[key]);
|
|
337
452
|
this.identities = identity;
|
|
338
453
|
if (changedKeys.length === 0) return;
|
|
339
|
-
this.lastAttempt
|
|
454
|
+
for (const key of changedKeys) delete this.lastAttempt[accounts[key]];
|
|
340
455
|
if (!changedKeys.some((key) => this.current[key] !== undefined)) return;
|
|
341
456
|
const next = { ...this.current };
|
|
342
457
|
for (const key of changedKeys) delete next[key];
|
|
@@ -346,17 +461,17 @@ export class UsageTracker {
|
|
|
346
461
|
}
|
|
347
462
|
|
|
348
463
|
/**
|
|
349
|
-
* Layer fresh values over the last-known ones and drop
|
|
350
|
-
* credentials: a logged-out
|
|
351
|
-
* failed fetch keeps whatever we last saw.
|
|
464
|
+
* Layer fresh values over the last-known ones and drop families whose selected
|
|
465
|
+
* account has no credentials: a logged-out account must disappear immediately,
|
|
466
|
+
* while a failed fetch keeps whatever we last saw.
|
|
352
467
|
*/
|
|
353
|
-
private merge(incoming: UsageSnapshot, auth: AuthEntries): UsageSnapshot {
|
|
468
|
+
private merge(incoming: UsageSnapshot, auth: AuthEntries, accounts: UsageAccounts): UsageSnapshot {
|
|
354
469
|
const next: UsageSnapshot = {};
|
|
355
|
-
if (
|
|
470
|
+
if (hasAuth(auth, accounts.claude)) {
|
|
356
471
|
const claude = incoming.claude ?? this.current.claude;
|
|
357
472
|
if (claude) next.claude = claude;
|
|
358
473
|
}
|
|
359
|
-
if (
|
|
474
|
+
if (hasAuth(auth, accounts.codex)) {
|
|
360
475
|
const codex = incoming.codex ?? this.current.codex;
|
|
361
476
|
if (codex) next.codex = codex;
|
|
362
477
|
}
|
|
@@ -364,13 +479,16 @@ export class UsageTracker {
|
|
|
364
479
|
}
|
|
365
480
|
|
|
366
481
|
/** Adopt a snapshot, recompute the retry gate, and repaint only on a change. */
|
|
367
|
-
private publish(next: UsageSnapshot, auth: AuthEntries, cache: UsageCache): void {
|
|
482
|
+
private publish(next: UsageSnapshot, auth: AuthEntries, accounts: UsageAccounts, cache: UsageCache): void {
|
|
368
483
|
const now = this.now();
|
|
369
|
-
//
|
|
484
|
+
// An account serving 429s is not "pending": retrying it faster is exactly
|
|
370
485
|
// what got us rate limited, so it must not hold the short window open.
|
|
371
|
-
this.awaitingProvider =
|
|
372
|
-
(
|
|
373
|
-
|
|
486
|
+
this.awaitingProvider = PROVIDER_KEYS.some(
|
|
487
|
+
(key) =>
|
|
488
|
+
hasAuth(auth, accounts[key]) &&
|
|
489
|
+
next[key] === undefined &&
|
|
490
|
+
now >= (cache.accounts[accounts[key]]?.backoff ?? 0),
|
|
491
|
+
);
|
|
374
492
|
if (JSON.stringify(next) === JSON.stringify(this.current)) return;
|
|
375
493
|
this.current = next;
|
|
376
494
|
this.onChange?.();
|
|
@@ -418,16 +536,14 @@ export class UsageTracker {
|
|
|
418
536
|
}
|
|
419
537
|
}
|
|
420
538
|
|
|
421
|
-
private async fetchClaude(auth: AuthEntries): Promise<UsageSnapshot["claude"] | undefined> {
|
|
422
|
-
|
|
539
|
+
private async fetchClaude(auth: AuthEntries, accountId: string): Promise<UsageSnapshot["claude"] | undefined> {
|
|
540
|
+
const access = auth[accountId]?.access;
|
|
541
|
+
if (typeof access !== "string" || access.length === 0) return undefined;
|
|
423
542
|
try {
|
|
424
543
|
return parseClaudeUsage(
|
|
425
544
|
await this.fetchJson(
|
|
426
545
|
CLAUDE_USAGE_URL,
|
|
427
|
-
{
|
|
428
|
-
Authorization: `Bearer ${auth.anthropic?.access as string}`,
|
|
429
|
-
"anthropic-beta": "oauth-2025-04-20",
|
|
430
|
-
},
|
|
546
|
+
{ Authorization: `Bearer ${access}`, "anthropic-beta": "oauth-2025-04-20" },
|
|
431
547
|
"claude",
|
|
432
548
|
),
|
|
433
549
|
);
|
|
@@ -436,19 +552,16 @@ export class UsageTracker {
|
|
|
436
552
|
}
|
|
437
553
|
}
|
|
438
554
|
|
|
439
|
-
private async fetchCodex(auth: AuthEntries): Promise<UsageSnapshot["codex"] | undefined> {
|
|
440
|
-
|
|
441
|
-
const
|
|
442
|
-
const
|
|
443
|
-
|
|
555
|
+
private async fetchCodex(auth: AuthEntries, accountId: string): Promise<UsageSnapshot["codex"] | undefined> {
|
|
556
|
+
const entry = auth[accountId];
|
|
557
|
+
const access = entry?.access;
|
|
558
|
+
const account = entry?.accountId;
|
|
559
|
+
if (typeof access !== "string" || typeof account !== "string" || !access || !account) return undefined;
|
|
444
560
|
try {
|
|
445
561
|
return parseCodexUsage(
|
|
446
562
|
await this.fetchJson(
|
|
447
563
|
CODEX_USAGE_URL,
|
|
448
|
-
{
|
|
449
|
-
Authorization: `Bearer ${access}`,
|
|
450
|
-
"chatgpt-account-id": accountId,
|
|
451
|
-
},
|
|
564
|
+
{ Authorization: `Bearer ${access}`, "chatgpt-account-id": account },
|
|
452
565
|
"codex",
|
|
453
566
|
),
|
|
454
567
|
);
|
|
@@ -457,3 +570,13 @@ export class UsageTracker {
|
|
|
457
570
|
}
|
|
458
571
|
}
|
|
459
572
|
}
|
|
573
|
+
|
|
574
|
+
/** The cached values for the currently selected accounts, in display shape. */
|
|
575
|
+
function cachedSnapshot(cache: UsageCache, accounts: UsageAccounts): UsageSnapshot {
|
|
576
|
+
const snapshot: UsageSnapshot = {};
|
|
577
|
+
const claude = cache.accounts[accounts.claude]?.claude;
|
|
578
|
+
const codex = cache.accounts[accounts.codex]?.codex;
|
|
579
|
+
if (claude) snapshot.claude = claude;
|
|
580
|
+
if (codex) snapshot.codex = codex;
|
|
581
|
+
return snapshot;
|
|
582
|
+
}
|