@hank-warren/pi-statusline 0.4.1 → 0.5.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 +14 -0
- package/README.md +33 -1
- package/index.ts +17 -3
- package/package.json +1 -1
- package/settings.ts +11 -0
- package/usage.ts +227 -134
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# @hank-warren/pi-statusline
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
|
|
9
|
+
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.
|
|
10
|
+
|
|
11
|
+
## 0.4.2
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- 1e25d6b: Guard against a setting being added without being registered for persistence. `SETTING_KEYS` feeds the per-key diff behind every save, so a key missing from it applied live and then silently failed to persist. Omitting one is now a `tsc` error naming the key, plus a test asserting the list matches `defaultSettings()`. Adds an "Adding a setting" checklist and the cross-version compatibility rules to the README.
|
|
16
|
+
|
|
3
17
|
## 0.4.1
|
|
4
18
|
|
|
5
19
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -30,6 +30,26 @@ Settings live in a single global file, `~/.pi/agent/statusline-settings.json`, w
|
|
|
30
30
|
|
|
31
31
|
Saves are per-key rather than whole-file: a change writes only the fields it actually touched over whatever is on disk at that moment. Sessions load settings once at startup, so a whole-file write would let a session that started hours ago revert edits it never saw — including hand edits and changes made in another session. Opening `/statusline` also re-reads the file first, so the menu always edits current state. Two sessions changing the *same* field are still last-writer-wins; everything else merges.
|
|
32
32
|
|
|
33
|
+
### Adding a setting
|
|
34
|
+
|
|
35
|
+
A setting is persisted, validated, diffed, and rendered in separate places, so add it to all of them:
|
|
36
|
+
|
|
37
|
+
1. `StatuslineSettings` in `settings.ts` — plus `BOOLEAN_SETTING_KEYS` if it is a toggle.
|
|
38
|
+
2. `defaultSettings()`.
|
|
39
|
+
3. `normalizeSettings()` — the `known` key set, and a parse branch that falls back to the default for an invalid value rather than discarding the whole file.
|
|
40
|
+
4. `serializeSettings()` — write it only when it differs from its default, keeping the file sparse.
|
|
41
|
+
5. `SETTING_KEYS`.
|
|
42
|
+
6. A row in `buildSettingItems()` and a branch in `applySettingChange()` in `settings-menu.ts`, placed in the order the element renders.
|
|
43
|
+
7. Live-apply handling in `applySettings()` in `index.ts`, if the change needs more than a repaint (disposing a poller, forcing a full redraw on a row-count change).
|
|
44
|
+
|
|
45
|
+
Steps 1 and 5 are enforced: omitting the key from `SETTING_KEYS` fails `npm run typecheck` by name, and a test asserts it matches the keys of `defaultSettings()`. Nothing enforces steps 3, 4, 6 or 7 — a setting missing from `serializeSettings` applies live and never persists.
|
|
46
|
+
|
|
47
|
+
Compatibility rules, because old and new versions share one file:
|
|
48
|
+
|
|
49
|
+
- **Never change a key's type or meaning — add a sibling key.** This is why `showCacheCelebration` stayed a boolean when it gained animation styles: an older version reading a repurposed key falls back to its default and can write that fallback back.
|
|
50
|
+
- **Unknown keys survive an older version; unknown *values* do not.** A theme name a reader does not recognise falls back to `default`, and a whole-file write from that reader drops the choice. Extending a cosmetic enum is fine; encoding behaviour in one is riskier than adding a key.
|
|
51
|
+
- **Keep settings independent.** Per-key saves mean two fields can be written by different sessions at different times, so resolve any relationship between settings at render time, not on disk.
|
|
52
|
+
|
|
33
53
|
## Themes
|
|
34
54
|
|
|
35
55
|
| Name | Notes |
|
|
@@ -57,7 +77,19 @@ Numbers are colored by remaining headroom: green above 60, yellow 41–60, orang
|
|
|
57
77
|
|
|
58
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.
|
|
59
79
|
|
|
60
|
-
|
|
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
|
+
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.
|
|
91
|
+
|
|
92
|
+
Requires a Nerd Font new enough to include the codicon brand glyphs (v3.5.0+); older fonts render them as replacement boxes.
|
|
61
93
|
|
|
62
94
|
## Cache-hit celebration
|
|
63
95
|
|
package/index.ts
CHANGED
|
@@ -264,7 +264,10 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
264
264
|
cwdStatusAbort = new AbortController();
|
|
265
265
|
cwdStatusInFlight = undefined;
|
|
266
266
|
runInBackground(refreshCwdStatus(ctx));
|
|
267
|
-
if (settings.showUsage)
|
|
267
|
+
if (settings.showUsage) {
|
|
268
|
+
usageTracker.setActiveProvider(ctx.model?.provider);
|
|
269
|
+
runInBackground(usageTracker.refresh());
|
|
270
|
+
}
|
|
268
271
|
// A hidden worktree line must not pay for git/gh polling.
|
|
269
272
|
if (!settings.showWorktrees) return;
|
|
270
273
|
const next = new SessionWorktreeTracker({
|
|
@@ -446,9 +449,20 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
446
449
|
requestRender?.();
|
|
447
450
|
runInBackground(refreshCwdStatus(ctx));
|
|
448
451
|
if (tracker) runInBackground(tracker.refresh());
|
|
449
|
-
if (settings.showUsage)
|
|
452
|
+
if (settings.showUsage) {
|
|
453
|
+
usageTracker.setActiveProvider(ctx.model?.provider);
|
|
454
|
+
runInBackground(usageTracker.refresh());
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
// The meters follow the main model's account, so a switch between two logins
|
|
458
|
+
// of the same provider family has to re-point the tracker before it repaints.
|
|
459
|
+
pi.on("model_select", (event) => {
|
|
460
|
+
if (settings.showUsage) {
|
|
461
|
+
usageTracker.setActiveProvider(event.model.provider);
|
|
462
|
+
runInBackground(usageTracker.refresh());
|
|
463
|
+
}
|
|
464
|
+
requestRender?.();
|
|
450
465
|
});
|
|
451
|
-
pi.on("model_select", () => requestRender?.());
|
|
452
466
|
pi.on("session_tree", (_event, ctx) => resetTracker(ctx));
|
|
453
467
|
pi.on("session_shutdown", () => {
|
|
454
468
|
cacheCelebration.dispose();
|
package/package.json
CHANGED
package/settings.ts
CHANGED
|
@@ -176,6 +176,17 @@ export const SETTING_KEYS = [
|
|
|
176
176
|
|
|
177
177
|
export type SettingKey = (typeof SETTING_KEYS)[number];
|
|
178
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Compile-time completeness guard.
|
|
181
|
+
*
|
|
182
|
+
* `satisfies readonly (keyof StatuslineSettings)[]` above only proves the listed
|
|
183
|
+
* keys are real; it does not prove every key is listed. That gap is silent and
|
|
184
|
+
* expensive: SETTING_KEYS feeds changedSettingKeys, which feeds every save, so a
|
|
185
|
+
* setting missing from it works all session and then vanishes on restart.
|
|
186
|
+
*/
|
|
187
|
+
type Unlisted<Key extends never> = Key;
|
|
188
|
+
type _EverySettingKeyIsListed = Unlisted<Exclude<keyof StatuslineSettings, SettingKey>>;
|
|
189
|
+
|
|
179
190
|
/** The keys one edit actually touched; the unit of a merging save. */
|
|
180
191
|
export function changedSettingKeys(
|
|
181
192
|
previous: StatuslineSettings,
|
package/usage.ts
CHANGED
|
@@ -25,7 +25,7 @@ export const USAGE_REFRESH_INTERVAL_MS = 5 * 60_000;
|
|
|
25
25
|
*/
|
|
26
26
|
export const USAGE_RETRY_INTERVAL_MS = 30_000;
|
|
27
27
|
/**
|
|
28
|
-
* How long
|
|
28
|
+
* How long an account is left alone after it answers 429. Polls are host-wide
|
|
29
29
|
* (see the shared cache below), so a rate limit means the provider itself wants
|
|
30
30
|
* a break rather than that we are racing ourselves.
|
|
31
31
|
*/
|
|
@@ -118,124 +118,167 @@ export interface UsageTrackerOptions {
|
|
|
118
118
|
fetchFn?: FetchFn;
|
|
119
119
|
onChange?: () => void;
|
|
120
120
|
now?: () => number;
|
|
121
|
+
/** Provider id of the session's main model; selects which account is polled. */
|
|
122
|
+
activeProvider?: string;
|
|
121
123
|
}
|
|
122
124
|
|
|
123
125
|
type ProviderKey = "claude" | "codex";
|
|
124
126
|
|
|
125
127
|
const PROVIDER_KEYS = ["claude", "codex"] as const;
|
|
126
128
|
|
|
127
|
-
/**
|
|
129
|
+
/** Provider ids whose additional logins (`${base}-${suffix}`) share a meter. */
|
|
130
|
+
export const USAGE_BASE_PROVIDERS: Record<ProviderKey, string> = {
|
|
131
|
+
claude: "anthropic",
|
|
132
|
+
codex: "openai-codex",
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/** Credential id polled for each family. */
|
|
136
|
+
export type UsageAccounts = Record<ProviderKey, string>;
|
|
137
|
+
|
|
138
|
+
function inFamily(providerId: string | undefined, base: string): boolean {
|
|
139
|
+
return providerId === base || (providerId !== undefined && providerId.startsWith(`${base}-`));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The family an account id belongs to, or undefined when it is neither. */
|
|
143
|
+
function familyOf(accountId: string): ProviderKey | undefined {
|
|
144
|
+
return PROVIDER_KEYS.find((key) => inFamily(accountId, USAGE_BASE_PROVIDERS[key]));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Choose the account whose meter each family shows.
|
|
149
|
+
*
|
|
150
|
+
* Additional logins (@hank-warren/pi-multi-login) mean a family can hold several
|
|
151
|
+
* accounts at once. The one worth showing is the one the session is actually
|
|
152
|
+
* spending: the provider of the main model. Every other family falls back to its
|
|
153
|
+
* base account, which is what makes a backup provider's meter stay visible while
|
|
154
|
+
* you are not using it — and what keeps a dedicated background-reviewer login
|
|
155
|
+
* (never the main model) from ever being polled.
|
|
156
|
+
*/
|
|
157
|
+
export function resolveUsageAccounts(activeProvider: string | undefined): UsageAccounts {
|
|
158
|
+
const accounts = { ...USAGE_BASE_PROVIDERS };
|
|
159
|
+
for (const key of PROVIDER_KEYS) {
|
|
160
|
+
if (inFamily(activeProvider, USAGE_BASE_PROVIDERS[key])) accounts[key] = activeProvider as string;
|
|
161
|
+
}
|
|
162
|
+
return accounts;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Non-reversible per-family credential fingerprints; never raw tokens. */
|
|
128
166
|
type ProviderIdentities = Partial<Record<ProviderKey, string>>;
|
|
129
167
|
|
|
130
168
|
function fingerprint(material: string): string {
|
|
131
169
|
return createHash("sha256").update(material).digest("hex").slice(0, 16);
|
|
132
170
|
}
|
|
133
171
|
|
|
172
|
+
interface AuthEntry {
|
|
173
|
+
access?: unknown;
|
|
174
|
+
accountId?: unknown;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
type AuthEntries = Record<string, AuthEntry | undefined>;
|
|
178
|
+
|
|
134
179
|
/**
|
|
135
|
-
* Fingerprint the
|
|
136
|
-
* percentages belong to an account, so a
|
|
137
|
-
* while the
|
|
138
|
-
* after an account switch, yesterday's "1% left"
|
|
180
|
+
* Fingerprint the credential an account would be polled with, or undefined when
|
|
181
|
+
* it cannot be polled at all. Usage percentages belong to an account, so a
|
|
182
|
+
* cached value is only trustworthy while the credential that produced it is
|
|
183
|
+
* still the one in auth.json — after an account switch, yesterday's "1% left"
|
|
184
|
+
* is somebody else's meter.
|
|
139
185
|
*/
|
|
140
|
-
function
|
|
186
|
+
function accountFingerprint(auth: AuthEntries, accountId: string): string | undefined {
|
|
187
|
+
const entry = auth[accountId];
|
|
188
|
+
const access = entry?.access;
|
|
189
|
+
if (typeof access !== "string" || access.length === 0) return undefined;
|
|
190
|
+
if (familyOf(accountId) === "codex") {
|
|
191
|
+
const account = entry?.accountId;
|
|
192
|
+
if (typeof account !== "string" || account.length === 0) return undefined;
|
|
193
|
+
return fingerprint(`${access}\n${account}`);
|
|
194
|
+
}
|
|
195
|
+
return fingerprint(access);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function hasAuth(auth: AuthEntries, accountId: string): boolean {
|
|
199
|
+
return accountFingerprint(auth, accountId) !== undefined;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function credentialIdentities(auth: AuthEntries, accounts: UsageAccounts): ProviderIdentities {
|
|
141
203
|
const identities: ProviderIdentities = {};
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
identities.codex = fingerprint(`${entry?.access as string}\n${entry?.accountId as string}`);
|
|
204
|
+
for (const key of PROVIDER_KEYS) {
|
|
205
|
+
const value = accountFingerprint(auth, accounts[key]);
|
|
206
|
+
if (value !== undefined) identities[key] = value;
|
|
146
207
|
}
|
|
147
208
|
return identities;
|
|
148
209
|
}
|
|
149
210
|
|
|
211
|
+
/** One account's shared poll state. Only the family's own field is ever set. */
|
|
212
|
+
interface UsageAccountEntry {
|
|
213
|
+
/** Credential fingerprint the stored values were fetched with. */
|
|
214
|
+
identity: string;
|
|
215
|
+
/** Last time any process started a poll for this account. */
|
|
216
|
+
attemptedAt: number;
|
|
217
|
+
/** Absolute time before which a rate-limited account must not be polled. */
|
|
218
|
+
backoff?: number;
|
|
219
|
+
claude?: UsageSnapshot["claude"];
|
|
220
|
+
codex?: UsageSnapshot["codex"];
|
|
221
|
+
}
|
|
222
|
+
|
|
150
223
|
/**
|
|
151
224
|
* 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
|
|
225
|
+
* property of the account, not of a session, so one poll per interval per
|
|
226
|
+
* account is both sufficient and necessary: a busy host runs dozens of pi
|
|
227
|
+
* processes, and per-process polling stampedes the endpoints into rate limiting
|
|
228
|
+
* everyone.
|
|
229
|
+
*
|
|
230
|
+
* Keyed by credential id rather than by family, because two sessions on the
|
|
231
|
+
* same host may legitimately be using two different Anthropic accounts. Keying
|
|
232
|
+
* by family made each of them look like an account switch to the other, so they
|
|
233
|
+
* evicted each other's values and re-polled on every cycle — the exact
|
|
234
|
+
* stampede this cache exists to prevent.
|
|
155
235
|
*/
|
|
156
236
|
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;
|
|
237
|
+
accounts: Record<string, UsageAccountEntry>;
|
|
164
238
|
}
|
|
165
239
|
|
|
166
|
-
const EMPTY_CACHE: UsageCache = {
|
|
167
|
-
attemptedAt: Number.NEGATIVE_INFINITY,
|
|
168
|
-
backoff: {},
|
|
169
|
-
snapshot: {},
|
|
170
|
-
identity: {},
|
|
171
|
-
};
|
|
240
|
+
const EMPTY_CACHE: UsageCache = { accounts: {} };
|
|
172
241
|
|
|
173
242
|
/**
|
|
174
|
-
* Drop
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
243
|
+
* Drop entries whose credential no longer matches auth.json. A token rotation
|
|
244
|
+
* looks the same as an account switch here; the cost of treating it as one is a
|
|
245
|
+
* single immediate re-poll, while trusting a stale identity means showing
|
|
246
|
+
* another account's numbers as if they were current. Logged-out accounts fail
|
|
247
|
+
* the same check, so the file garbage-collects itself.
|
|
178
248
|
*/
|
|
179
|
-
function pruneCache(cache: UsageCache,
|
|
180
|
-
const
|
|
181
|
-
const backoff = { ...cache.backoff };
|
|
249
|
+
function pruneCache(cache: UsageCache, auth: AuthEntries): UsageCache {
|
|
250
|
+
const accounts: Record<string, UsageAccountEntry> = {};
|
|
182
251
|
let changed = false;
|
|
183
|
-
for (const
|
|
184
|
-
if (
|
|
185
|
-
|
|
186
|
-
delete snapshot[key];
|
|
187
|
-
delete backoff[key];
|
|
252
|
+
for (const [accountId, entry] of Object.entries(cache.accounts)) {
|
|
253
|
+
if (accountFingerprint(auth, accountId) === entry.identity) accounts[accountId] = entry;
|
|
254
|
+
else changed = true;
|
|
188
255
|
}
|
|
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 };
|
|
256
|
+
return changed ? { accounts } : cache;
|
|
193
257
|
}
|
|
194
258
|
|
|
195
259
|
function parseUsageCache(json: unknown): UsageCache | undefined {
|
|
196
260
|
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
|
-
}
|
|
261
|
+
const rawAccounts = (json as { accounts?: unknown }).accounts;
|
|
262
|
+
if (typeof rawAccounts !== "object" || rawAccounts === null) return undefined;
|
|
263
|
+
const accounts: Record<string, UsageAccountEntry> = {};
|
|
264
|
+
for (const [accountId, value] of Object.entries(rawAccounts as Record<string, unknown>)) {
|
|
265
|
+
if (typeof value !== "object" || value === null) continue;
|
|
266
|
+
const body = value as Record<string, unknown>;
|
|
267
|
+
if (typeof body.identity !== "string") continue;
|
|
268
|
+
if (typeof body.attemptedAt !== "number" || !Number.isFinite(body.attemptedAt)) continue;
|
|
269
|
+
const entry: UsageAccountEntry = { identity: body.identity, attemptedAt: body.attemptedAt };
|
|
270
|
+
if (typeof body.backoff === "number" && Number.isFinite(body.backoff)) entry.backoff = body.backoff;
|
|
271
|
+
if (typeof body.claude === "object" && body.claude !== null) entry.claude = body.claude as UsageSnapshot["claude"];
|
|
272
|
+
if (typeof body.codex === "object" && body.codex !== null) entry.codex = body.codex as UsageSnapshot["codex"];
|
|
273
|
+
accounts[accountId] = entry;
|
|
211
274
|
}
|
|
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
|
-
);
|
|
275
|
+
return { accounts };
|
|
233
276
|
}
|
|
234
277
|
|
|
235
278
|
/**
|
|
236
279
|
* Best-effort subscription usage poller. Reads Pi's auth.json for tokens (never
|
|
237
280
|
* refreshes them), fetches both usage endpoints, and keeps the last-known good
|
|
238
|
-
* value per
|
|
281
|
+
* value per account. Refreshes are throttled and must never throw.
|
|
239
282
|
*/
|
|
240
283
|
export class UsageTracker {
|
|
241
284
|
private readonly authPath: string;
|
|
@@ -245,9 +288,10 @@ export class UsageTracker {
|
|
|
245
288
|
private readonly now: () => number;
|
|
246
289
|
private current: UsageSnapshot = {};
|
|
247
290
|
private identities: ProviderIdentities = {};
|
|
291
|
+
private activeProvider: string | undefined;
|
|
248
292
|
private awaitingProvider = true;
|
|
249
293
|
private readonly rateLimited = new Set<ProviderKey>();
|
|
250
|
-
private lastAttempt =
|
|
294
|
+
private lastAttempt: Record<string, number> = {};
|
|
251
295
|
private inFlight: Promise<void> | undefined;
|
|
252
296
|
|
|
253
297
|
constructor(options: UsageTrackerOptions = {}) {
|
|
@@ -256,12 +300,22 @@ export class UsageTracker {
|
|
|
256
300
|
this.fetchFn = options.fetchFn ?? ((url, init) => fetch(url, init));
|
|
257
301
|
this.onChange = options.onChange;
|
|
258
302
|
this.now = options.now ?? Date.now;
|
|
303
|
+
this.activeProvider = options.activeProvider;
|
|
259
304
|
}
|
|
260
305
|
|
|
261
306
|
snapshot(): UsageSnapshot {
|
|
262
307
|
return this.current;
|
|
263
308
|
}
|
|
264
309
|
|
|
310
|
+
/**
|
|
311
|
+
* Point the meters at the session's main model. A switch between two accounts
|
|
312
|
+
* in the same family changes which one is displayed; the next refresh adopts
|
|
313
|
+
* the new account's cached value if there is one, and polls otherwise.
|
|
314
|
+
*/
|
|
315
|
+
setActiveProvider(providerId: string | undefined): void {
|
|
316
|
+
this.activeProvider = providerId;
|
|
317
|
+
}
|
|
318
|
+
|
|
265
319
|
/**
|
|
266
320
|
* Interval until the next allowed attempt: the short retry window while a
|
|
267
321
|
* credentialed provider is still missing a value, the full interval otherwise.
|
|
@@ -285,58 +339,89 @@ export class UsageTracker {
|
|
|
285
339
|
}
|
|
286
340
|
|
|
287
341
|
/**
|
|
288
|
-
* One refresh cycle: adopt whatever another process has already published
|
|
289
|
-
* then poll only
|
|
342
|
+
* One refresh cycle: adopt whatever another process has already published for
|
|
343
|
+
* the selected accounts, then poll only those the shared throttle allows.
|
|
290
344
|
*/
|
|
291
345
|
private async performRefresh(): Promise<void> {
|
|
292
346
|
const auth = (await this.readAuth()) ?? {};
|
|
293
|
-
const
|
|
294
|
-
const
|
|
295
|
-
this.
|
|
347
|
+
const accounts = resolveUsageAccounts(this.activeProvider);
|
|
348
|
+
const identity = credentialIdentities(auth, accounts);
|
|
349
|
+
const cache = pruneCache((await this.readCache()) ?? EMPTY_CACHE, auth);
|
|
350
|
+
this.pruneCurrent(identity, accounts);
|
|
296
351
|
// Another process's values are as good as ours and cost no request, so a new
|
|
297
|
-
// session
|
|
298
|
-
|
|
352
|
+
// session — or a switch back to an account polled earlier — shows real
|
|
353
|
+
// numbers on its very first render.
|
|
354
|
+
this.publish(this.merge(cachedSnapshot(cache, accounts), auth, accounts), auth, accounts, cache);
|
|
299
355
|
|
|
300
356
|
const now = this.now();
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
357
|
+
const interval = this.currentInterval();
|
|
358
|
+
const pollable: Partial<Record<ProviderKey, string>> = {};
|
|
359
|
+
for (const key of PROVIDER_KEYS) {
|
|
360
|
+
const accountId = accounts[key];
|
|
361
|
+
if (!hasAuth(auth, accountId)) continue;
|
|
362
|
+
const entry = cache.accounts[accountId];
|
|
363
|
+
if (now < (entry?.backoff ?? Number.NEGATIVE_INFINITY)) continue;
|
|
364
|
+
const lastAttempt = Math.max(
|
|
365
|
+
this.lastAttempt[accountId] ?? Number.NEGATIVE_INFINITY,
|
|
366
|
+
entry?.attemptedAt ?? Number.NEGATIVE_INFINITY,
|
|
367
|
+
);
|
|
368
|
+
if (now - lastAttempt < interval) continue;
|
|
369
|
+
pollable[key] = accountId;
|
|
370
|
+
}
|
|
371
|
+
if (pollable.claude === undefined && pollable.codex === undefined) return;
|
|
372
|
+
|
|
373
|
+
// Claim each slot before fetching so sibling processes skip this window even
|
|
309
374
|
// if our own request is slow or fails outright. The claim carries the fresh
|
|
310
375
|
// identity so siblings do not re-prune and stampede the same window.
|
|
311
|
-
|
|
376
|
+
const claimed = { ...cache.accounts };
|
|
377
|
+
for (const key of PROVIDER_KEYS) {
|
|
378
|
+
const accountId = pollable[key];
|
|
379
|
+
if (accountId === undefined) continue;
|
|
380
|
+
this.lastAttempt[accountId] = now;
|
|
381
|
+
claimed[accountId] = {
|
|
382
|
+
...claimed[accountId],
|
|
383
|
+
identity: identity[key] as string,
|
|
384
|
+
attemptedAt: now,
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
await this.writeCache({ accounts: claimed });
|
|
312
388
|
|
|
313
389
|
const [claude, codex] = await Promise.all([
|
|
314
|
-
|
|
315
|
-
|
|
390
|
+
pollable.claude === undefined ? Promise.resolve(undefined) : this.fetchClaude(auth, pollable.claude),
|
|
391
|
+
pollable.codex === undefined ? Promise.resolve(undefined) : this.fetchCodex(auth, pollable.codex),
|
|
316
392
|
]);
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
393
|
+
|
|
394
|
+
const updatedAccounts = { ...claimed };
|
|
395
|
+
for (const key of PROVIDER_KEYS) {
|
|
396
|
+
const accountId = pollable[key];
|
|
397
|
+
if (accountId === undefined) continue;
|
|
398
|
+
const entry: UsageAccountEntry = { ...(updatedAccounts[accountId] as UsageAccountEntry) };
|
|
399
|
+
const value = key === "claude" ? claude : codex;
|
|
400
|
+
if (this.rateLimited.has(key)) entry.backoff = now + USAGE_RATE_LIMIT_BACKOFF_MS;
|
|
401
|
+
else if (value !== undefined) delete entry.backoff;
|
|
402
|
+
// A failed fetch leaves the last-known value in place rather than blanking
|
|
403
|
+
// the meter for whatever caused one bad response.
|
|
404
|
+
if (claude !== undefined && key === "claude") entry.claude = claude;
|
|
405
|
+
if (codex !== undefined && key === "codex") entry.codex = codex;
|
|
406
|
+
updatedAccounts[accountId] = entry;
|
|
321
407
|
}
|
|
322
408
|
this.rateLimited.clear();
|
|
323
409
|
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
this.publish(next, auth, updated);
|
|
410
|
+
const updated: UsageCache = { accounts: updatedAccounts };
|
|
411
|
+
this.publish(this.merge({ claude, codex }, auth, accounts), auth, accounts, updated);
|
|
327
412
|
await this.writeCache(updated);
|
|
328
413
|
}
|
|
329
414
|
|
|
330
415
|
/**
|
|
331
416
|
* Drop in-memory values fetched with previous credentials so merge() cannot
|
|
332
417
|
* resurrect another account's numbers, and let the changed identity reopen
|
|
333
|
-
* this process's own throttle gate.
|
|
418
|
+
* this process's own throttle gate for the newly selected account.
|
|
334
419
|
*/
|
|
335
|
-
private pruneCurrent(identity: ProviderIdentities): void {
|
|
420
|
+
private pruneCurrent(identity: ProviderIdentities, accounts: UsageAccounts): void {
|
|
336
421
|
const changedKeys = PROVIDER_KEYS.filter((key) => this.identities[key] !== identity[key]);
|
|
337
422
|
this.identities = identity;
|
|
338
423
|
if (changedKeys.length === 0) return;
|
|
339
|
-
this.lastAttempt
|
|
424
|
+
for (const key of changedKeys) delete this.lastAttempt[accounts[key]];
|
|
340
425
|
if (!changedKeys.some((key) => this.current[key] !== undefined)) return;
|
|
341
426
|
const next = { ...this.current };
|
|
342
427
|
for (const key of changedKeys) delete next[key];
|
|
@@ -346,17 +431,17 @@ export class UsageTracker {
|
|
|
346
431
|
}
|
|
347
432
|
|
|
348
433
|
/**
|
|
349
|
-
* Layer fresh values over the last-known ones and drop
|
|
350
|
-
* credentials: a logged-out
|
|
351
|
-
* failed fetch keeps whatever we last saw.
|
|
434
|
+
* Layer fresh values over the last-known ones and drop families whose selected
|
|
435
|
+
* account has no credentials: a logged-out account must disappear immediately,
|
|
436
|
+
* while a failed fetch keeps whatever we last saw.
|
|
352
437
|
*/
|
|
353
|
-
private merge(incoming: UsageSnapshot, auth: AuthEntries): UsageSnapshot {
|
|
438
|
+
private merge(incoming: UsageSnapshot, auth: AuthEntries, accounts: UsageAccounts): UsageSnapshot {
|
|
354
439
|
const next: UsageSnapshot = {};
|
|
355
|
-
if (
|
|
440
|
+
if (hasAuth(auth, accounts.claude)) {
|
|
356
441
|
const claude = incoming.claude ?? this.current.claude;
|
|
357
442
|
if (claude) next.claude = claude;
|
|
358
443
|
}
|
|
359
|
-
if (
|
|
444
|
+
if (hasAuth(auth, accounts.codex)) {
|
|
360
445
|
const codex = incoming.codex ?? this.current.codex;
|
|
361
446
|
if (codex) next.codex = codex;
|
|
362
447
|
}
|
|
@@ -364,13 +449,16 @@ export class UsageTracker {
|
|
|
364
449
|
}
|
|
365
450
|
|
|
366
451
|
/** Adopt a snapshot, recompute the retry gate, and repaint only on a change. */
|
|
367
|
-
private publish(next: UsageSnapshot, auth: AuthEntries, cache: UsageCache): void {
|
|
452
|
+
private publish(next: UsageSnapshot, auth: AuthEntries, accounts: UsageAccounts, cache: UsageCache): void {
|
|
368
453
|
const now = this.now();
|
|
369
|
-
//
|
|
454
|
+
// An account serving 429s is not "pending": retrying it faster is exactly
|
|
370
455
|
// what got us rate limited, so it must not hold the short window open.
|
|
371
|
-
this.awaitingProvider =
|
|
372
|
-
(
|
|
373
|
-
|
|
456
|
+
this.awaitingProvider = PROVIDER_KEYS.some(
|
|
457
|
+
(key) =>
|
|
458
|
+
hasAuth(auth, accounts[key]) &&
|
|
459
|
+
next[key] === undefined &&
|
|
460
|
+
now >= (cache.accounts[accounts[key]]?.backoff ?? 0),
|
|
461
|
+
);
|
|
374
462
|
if (JSON.stringify(next) === JSON.stringify(this.current)) return;
|
|
375
463
|
this.current = next;
|
|
376
464
|
this.onChange?.();
|
|
@@ -418,16 +506,14 @@ export class UsageTracker {
|
|
|
418
506
|
}
|
|
419
507
|
}
|
|
420
508
|
|
|
421
|
-
private async fetchClaude(auth: AuthEntries): Promise<UsageSnapshot["claude"] | undefined> {
|
|
422
|
-
|
|
509
|
+
private async fetchClaude(auth: AuthEntries, accountId: string): Promise<UsageSnapshot["claude"] | undefined> {
|
|
510
|
+
const access = auth[accountId]?.access;
|
|
511
|
+
if (typeof access !== "string" || access.length === 0) return undefined;
|
|
423
512
|
try {
|
|
424
513
|
return parseClaudeUsage(
|
|
425
514
|
await this.fetchJson(
|
|
426
515
|
CLAUDE_USAGE_URL,
|
|
427
|
-
{
|
|
428
|
-
Authorization: `Bearer ${auth.anthropic?.access as string}`,
|
|
429
|
-
"anthropic-beta": "oauth-2025-04-20",
|
|
430
|
-
},
|
|
516
|
+
{ Authorization: `Bearer ${access}`, "anthropic-beta": "oauth-2025-04-20" },
|
|
431
517
|
"claude",
|
|
432
518
|
),
|
|
433
519
|
);
|
|
@@ -436,19 +522,16 @@ export class UsageTracker {
|
|
|
436
522
|
}
|
|
437
523
|
}
|
|
438
524
|
|
|
439
|
-
private async fetchCodex(auth: AuthEntries): Promise<UsageSnapshot["codex"] | undefined> {
|
|
440
|
-
|
|
441
|
-
const
|
|
442
|
-
const
|
|
443
|
-
|
|
525
|
+
private async fetchCodex(auth: AuthEntries, accountId: string): Promise<UsageSnapshot["codex"] | undefined> {
|
|
526
|
+
const entry = auth[accountId];
|
|
527
|
+
const access = entry?.access;
|
|
528
|
+
const account = entry?.accountId;
|
|
529
|
+
if (typeof access !== "string" || typeof account !== "string" || !access || !account) return undefined;
|
|
444
530
|
try {
|
|
445
531
|
return parseCodexUsage(
|
|
446
532
|
await this.fetchJson(
|
|
447
533
|
CODEX_USAGE_URL,
|
|
448
|
-
{
|
|
449
|
-
Authorization: `Bearer ${access}`,
|
|
450
|
-
"chatgpt-account-id": accountId,
|
|
451
|
-
},
|
|
534
|
+
{ Authorization: `Bearer ${access}`, "chatgpt-account-id": account },
|
|
452
535
|
"codex",
|
|
453
536
|
),
|
|
454
537
|
);
|
|
@@ -457,3 +540,13 @@ export class UsageTracker {
|
|
|
457
540
|
}
|
|
458
541
|
}
|
|
459
542
|
}
|
|
543
|
+
|
|
544
|
+
/** The cached values for the currently selected accounts, in display shape. */
|
|
545
|
+
function cachedSnapshot(cache: UsageCache, accounts: UsageAccounts): UsageSnapshot {
|
|
546
|
+
const snapshot: UsageSnapshot = {};
|
|
547
|
+
const claude = cache.accounts[accounts.claude]?.claude;
|
|
548
|
+
const codex = cache.accounts[accounts.codex]?.codex;
|
|
549
|
+
if (claude) snapshot.claude = claude;
|
|
550
|
+
if (codex) snapshot.codex = codex;
|
|
551
|
+
return snapshot;
|
|
552
|
+
}
|