bermudis-pi-goodies 0.27.6 → 0.27.7
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/README.md +14 -6
- package/package.json +1 -1
- package/provider-balance.ts +353 -108
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@ extensions. One entry point, thirteen independent features.
|
|
|
25
25
|
First install, pinned to a published version:
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
|
-
pi install npm:bermudis-pi-goodies@0.27.
|
|
28
|
+
pi install npm:bermudis-pi-goodies@0.27.7
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
Picking up a new release on an existing install (`pi install` is not the
|
|
@@ -279,15 +279,23 @@ catalog no more than every four hours. Balance and quota requests run in the bac
|
|
|
279
279
|
never delay session startup, model selection, or post-run input readiness.
|
|
280
280
|
The footer also reads OpenRouter remaining credits for `openrouter`, z.ai GLM
|
|
281
281
|
Coding Plan token quota for `zai` (Global) and `zai-coding-cn` (BigModel China),
|
|
282
|
-
OpenAI Codex's ChatGPT subscription quota when using `openai-codex` OAuth,
|
|
283
|
-
|
|
282
|
+
OpenAI Codex's ChatGPT subscription quota when using `openai-codex` OAuth, and
|
|
283
|
+
CommandCode credit balance plus 5-hour/weekly usage windows for
|
|
284
284
|
`commandcode` and `commandcode-anthropic` (same account, same key);
|
|
285
285
|
it skips platform API-key auth because that has no ChatGPT subscription quota.
|
|
286
|
+
For `openai` OAuth (Sign in with ChatGPT), OpenAI rejects the new token on its
|
|
287
|
+
usage endpoints (`401 rejected_by_access_enforcement`), so the footer reads the
|
|
288
|
+
plan windows through a companion `openai-codex` login on the same ChatGPT
|
|
289
|
+
account. The active app's `client_id` must appear exactly once in
|
|
290
|
+
`GET /wham/usage/chatpass/apps` queried with the Codex token; otherwise no
|
|
291
|
+
balance is shown rather than another account's quota. Run `/login openai-codex`
|
|
292
|
+
once with the same account and keep `openai` as the active model.
|
|
286
293
|
OpenRouter uses `GET /api/v1/credits`; z.ai uses
|
|
287
|
-
`GET /api/monitor/usage/quota/limit`; Codex
|
|
288
|
-
|
|
294
|
+
`GET /api/monitor/usage/quota/limit`; Codex and OpenAI use `GET /wham/usage`
|
|
295
|
+
(plus `/chatpass/apps` for OpenAI); CommandCode uses
|
|
296
|
+
`GET /alpha/billing/credits` (a private endpoint its CLI
|
|
289
297
|
also uses; `COMMANDCODE_API_URL` can override the base URL). z.ai, Codex, and
|
|
290
|
-
|
|
298
|
+
CommandCode show both each quota's window length and its `nextResetTime`/`reset_at`
|
|
291
299
|
countdown (when supplied), and `CODEX_API_URL` or `CHATGPT_BASE_URL` can
|
|
292
300
|
override the Codex base URL.
|
|
293
301
|
|
package/package.json
CHANGED
package/provider-balance.ts
CHANGED
|
@@ -51,6 +51,14 @@ const CODEX_API_BASE = (
|
|
|
51
51
|
"https://chatgpt.com/backend-api"
|
|
52
52
|
).replace(/\/+$/, "");
|
|
53
53
|
const CODEX_USAGE_ENDPOINT = `${CODEX_API_BASE}/wham/usage`;
|
|
54
|
+
/**
|
|
55
|
+
* App registrations for the ChatGPT account behind a Codex credential. The
|
|
56
|
+
* new `openai` (Sign in with ChatGPT) token cannot read usage itself — the
|
|
57
|
+
* backend rejects it with 401 rejected_by_access_enforcement — so usage is
|
|
58
|
+
* read with a companion `openai-codex` login. Matching the new login's
|
|
59
|
+
* client_id here proves both logins belong to the same ChatGPT account.
|
|
60
|
+
*/
|
|
61
|
+
const CODEX_CHATPASS_APPS_ENDPOINT = `${CODEX_USAGE_ENDPOINT}/chatpass/apps`;
|
|
54
62
|
const CODEX_AUTH_CLAIM = "https://api.openai.com/auth";
|
|
55
63
|
const COMMANDCODE_API_BASE = (
|
|
56
64
|
process.env.COMMANDCODE_API_URL || "https://api.commandcode.ai"
|
|
@@ -66,16 +74,20 @@ const IDLE_REFRESH_INTERVAL_MS = 60_000;
|
|
|
66
74
|
const IDLE_REFRESH_JITTER_MS = 15_000;
|
|
67
75
|
/** Minimum time between turn_end/delegate:usage-triggered balance refreshes. See throttledRefresh. */
|
|
68
76
|
const TURN_REFRESH_MIN_INTERVAL_MS = 30_000;
|
|
77
|
+
/** How often the identity watchdog compares the live credentials against the
|
|
78
|
+
* balance shown in the footer. Pi's built-in /login and /logout commands are
|
|
79
|
+
* dispatched by the TUI before extension input events run and fire no
|
|
80
|
+
* extension-visible event when a model is already selected, so the only
|
|
81
|
+
* supported way to notice a credential swap is re-reading the model registry.
|
|
82
|
+
* Each tick costs one or two local credential-store reads (network only when
|
|
83
|
+
* an OAuth token is inside its refresh window, which is shared and locked).
|
|
84
|
+
* 5s keeps the post-login footer lag well under the old 60–75s worst case
|
|
85
|
+
* (the next idle poll) without meaningful cost. */
|
|
86
|
+
export const IDENTITY_WATCHDOG_INTERVAL_MS = 5_000;
|
|
69
87
|
/** On session start, adopt a sibling/previous session's cache entry younger
|
|
70
88
|
* than this instead of refetching — rapid session switches then cost nothing. */
|
|
71
89
|
const SESSION_START_CACHE_ADOPT_MS = 15_000;
|
|
72
|
-
|
|
73
|
-
* at 1s per attempt. Kilo device logins take 30–60s of human time (browser
|
|
74
|
-
* round-trip), so a ~9s window (the old 10-attempt cap) usually expired
|
|
75
|
-
* before the user finished and the footer kept the old account's balance
|
|
76
|
-
* until the next idle poll. 90s covers the human with margin; the poll is a
|
|
77
|
-
* local keychain/auth-store read, not a network request. */
|
|
78
|
-
export const AUTH_TRANSITION_MAX_ATTEMPTS = 90;
|
|
90
|
+
|
|
79
91
|
/** After a failed provider fetch, idle polls machine-wide back off for this
|
|
80
92
|
* long via a shared marker in the balance cache. */
|
|
81
93
|
export const FAILURE_BACKOFF_MS = 60_000;
|
|
@@ -110,9 +122,27 @@ const BALANCE_CACHE_MAX_ENTRIES = 256;
|
|
|
110
122
|
const BALANCE_CACHE_MAX_BYTES = 1_000_000;
|
|
111
123
|
const BALANCE_CACHE_ACCOUNT_DIR_PATTERN = /^[a-f0-9]{64}$/;
|
|
112
124
|
|
|
125
|
+
export interface BalanceFetchOptions {
|
|
126
|
+
/**
|
|
127
|
+
* Credential for `companionProvider`, resolved by refreshBalance from the
|
|
128
|
+
* same model registry. Never persisted; used only for this request.
|
|
129
|
+
*/
|
|
130
|
+
companionToken?: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
113
133
|
export interface BalanceAdapter {
|
|
114
|
-
fetch(
|
|
134
|
+
fetch(
|
|
135
|
+
token: string,
|
|
136
|
+
signal: AbortSignal,
|
|
137
|
+
options?: BalanceFetchOptions,
|
|
138
|
+
): Promise<Balance>;
|
|
115
139
|
requiresOAuth?: boolean;
|
|
140
|
+
/**
|
|
141
|
+
* Provider whose login is required to read this balance. `openai` needs an
|
|
142
|
+
* `openai-codex` companion on the same ChatGPT account because OpenAI
|
|
143
|
+
* rejects Sign in with ChatGPT tokens on the usage endpoints.
|
|
144
|
+
*/
|
|
145
|
+
companionProvider?: string;
|
|
116
146
|
}
|
|
117
147
|
|
|
118
148
|
interface CodexQuotaWindow {
|
|
@@ -175,22 +205,53 @@ function decodeBase64Url(value: string): string {
|
|
|
175
205
|
return new TextDecoder().decode(bytes);
|
|
176
206
|
}
|
|
177
207
|
|
|
178
|
-
|
|
208
|
+
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
|
179
209
|
const parts = token.split(".");
|
|
180
210
|
if (parts.length !== 3 || !parts[1]) return null;
|
|
181
211
|
|
|
182
212
|
try {
|
|
183
|
-
|
|
184
|
-
const auth = payload ? asRecord(payload[CODEX_AUTH_CLAIM]) : null;
|
|
185
|
-
const accountId = auth?.chatgpt_account_id;
|
|
186
|
-
return typeof accountId === "string" && accountId.trim()
|
|
187
|
-
? accountId.trim()
|
|
188
|
-
: null;
|
|
213
|
+
return asRecord(JSON.parse(decodeBase64Url(parts[1])));
|
|
189
214
|
} catch {
|
|
190
215
|
return null;
|
|
191
216
|
}
|
|
192
217
|
}
|
|
193
218
|
|
|
219
|
+
export function parseCodexAccountId(token: string): string | null {
|
|
220
|
+
const payload = decodeJwtPayload(token);
|
|
221
|
+
const auth = payload ? asRecord(payload[CODEX_AUTH_CLAIM]) : null;
|
|
222
|
+
const accountId = auth?.chatgpt_account_id;
|
|
223
|
+
return typeof accountId === "string" && accountId.trim()
|
|
224
|
+
? accountId.trim()
|
|
225
|
+
: null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Identity of the application registration behind an `openai` OAuth login.
|
|
230
|
+
* The legacy Codex account lists this ID under /wham/usage/chatpass/apps,
|
|
231
|
+
* which is how the companion credential is proven to be the same account.
|
|
232
|
+
*/
|
|
233
|
+
export function parseOpenAIClientId(token: string): string | null {
|
|
234
|
+
const payload = decodeJwtPayload(token);
|
|
235
|
+
const clientId = payload?.client_id;
|
|
236
|
+
return typeof clientId === "string" && clientId.trim()
|
|
237
|
+
? clientId.trim()
|
|
238
|
+
: null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Exactly one registration must match: never fall back to another app. */
|
|
242
|
+
export function openAIAppIsRegistered(
|
|
243
|
+
value: unknown,
|
|
244
|
+
clientId: string,
|
|
245
|
+
): boolean {
|
|
246
|
+
const payload = asRecord(value);
|
|
247
|
+
const items = Array.isArray(payload?.items) ? payload.items : [];
|
|
248
|
+
let matches = 0;
|
|
249
|
+
for (const candidate of items) {
|
|
250
|
+
if (stringProperty(candidate, "id") === clientId) matches++;
|
|
251
|
+
}
|
|
252
|
+
return matches === 1;
|
|
253
|
+
}
|
|
254
|
+
|
|
194
255
|
function parseCodexQuotaWindow(value: unknown): CodexQuotaWindow | null {
|
|
195
256
|
const usedPercent = numericProperty(value, "used_percent");
|
|
196
257
|
const windowSeconds = numericProperty(value, "limit_window_seconds");
|
|
@@ -586,23 +647,26 @@ async function fetchZaiCodingCnQuota(
|
|
|
586
647
|
return fetchZaiQuotaAt(ZAI_CODING_CN_QUOTA_ENDPOINT, token, signal);
|
|
587
648
|
}
|
|
588
649
|
|
|
589
|
-
|
|
650
|
+
function codexUsageHeaders(
|
|
590
651
|
token: string,
|
|
652
|
+
accountId: string,
|
|
653
|
+
): Record<string, string> {
|
|
654
|
+
return {
|
|
655
|
+
Authorization: `Bearer ${token}`,
|
|
656
|
+
Accept: "application/json",
|
|
657
|
+
"User-Agent": "pi",
|
|
658
|
+
originator: "pi",
|
|
659
|
+
"chatgpt-account-id": accountId,
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async function fetchCodexQuotaAt(
|
|
664
|
+
token: string,
|
|
665
|
+
accountId: string,
|
|
591
666
|
signal: AbortSignal,
|
|
592
667
|
): Promise<Balance> {
|
|
593
|
-
const accountId = parseCodexAccountId(token);
|
|
594
|
-
if (!accountId) {
|
|
595
|
-
throw new Error("Codex access token did not contain an account ID");
|
|
596
|
-
}
|
|
597
|
-
|
|
598
668
|
const response = await fetch(CODEX_USAGE_ENDPOINT, {
|
|
599
|
-
headers:
|
|
600
|
-
Authorization: `Bearer ${token}`,
|
|
601
|
-
Accept: "application/json",
|
|
602
|
-
"User-Agent": "pi",
|
|
603
|
-
originator: "pi",
|
|
604
|
-
"chatgpt-account-id": accountId,
|
|
605
|
-
},
|
|
669
|
+
headers: codexUsageHeaders(token, accountId),
|
|
606
670
|
signal: timeoutSignal(BALANCE_FETCH_TIMEOUT_MS, signal),
|
|
607
671
|
});
|
|
608
672
|
if (!response.ok) {
|
|
@@ -616,6 +680,61 @@ async function fetchCodexQuota(
|
|
|
616
680
|
return codexQuotaToBalance(quota);
|
|
617
681
|
}
|
|
618
682
|
|
|
683
|
+
async function fetchCodexQuota(
|
|
684
|
+
token: string,
|
|
685
|
+
signal: AbortSignal,
|
|
686
|
+
): Promise<Balance> {
|
|
687
|
+
const accountId = parseCodexAccountId(token);
|
|
688
|
+
if (!accountId) {
|
|
689
|
+
throw new Error("Codex access token did not contain an account ID");
|
|
690
|
+
}
|
|
691
|
+
return fetchCodexQuotaAt(token, accountId, signal);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* `openai` (Sign in with ChatGPT) tokens are rejected by ChatGPT's usage
|
|
696
|
+
* backend. Read the plan windows with the companion `openai-codex` login
|
|
697
|
+
* instead, but only after /chatpass/apps confirms that the active app
|
|
698
|
+
* registration belongs to that Codex account — otherwise the footer would
|
|
699
|
+
* show a different account's quota.
|
|
700
|
+
*/
|
|
701
|
+
export async function fetchOpenAIQuota(
|
|
702
|
+
token: string,
|
|
703
|
+
signal: AbortSignal,
|
|
704
|
+
options?: BalanceFetchOptions,
|
|
705
|
+
): Promise<Balance> {
|
|
706
|
+
const companionToken = options?.companionToken;
|
|
707
|
+
if (!companionToken) {
|
|
708
|
+
throw new Error("OpenAI quota requires an openai-codex companion login");
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const clientId = parseOpenAIClientId(token);
|
|
712
|
+
if (!clientId) {
|
|
713
|
+
throw new Error("OpenAI access token did not contain an application ID");
|
|
714
|
+
}
|
|
715
|
+
const accountId = parseCodexAccountId(companionToken);
|
|
716
|
+
if (!accountId) {
|
|
717
|
+
throw new Error("OpenAI Codex access token did not contain an account ID");
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const registrationResponse = await fetch(CODEX_CHATPASS_APPS_ENDPOINT, {
|
|
721
|
+
headers: codexUsageHeaders(companionToken, accountId),
|
|
722
|
+
signal: timeoutSignal(BALANCE_FETCH_TIMEOUT_MS, signal),
|
|
723
|
+
});
|
|
724
|
+
if (!registrationResponse.ok) {
|
|
725
|
+
throw new Error(
|
|
726
|
+
`OpenAI app registration request failed: ${registrationResponse.status}`,
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
if (!openAIAppIsRegistered(await registrationResponse.json(), clientId)) {
|
|
730
|
+
throw new Error(
|
|
731
|
+
"OpenAI app registration was not found in the companion Codex account; sign in to openai and openai-codex with the same ChatGPT account",
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
return fetchCodexQuotaAt(companionToken, accountId, signal);
|
|
736
|
+
}
|
|
737
|
+
|
|
619
738
|
// --- CommandCode -----------------------------------------------------------
|
|
620
739
|
// CommandCode documents only chat/messages/models endpoints, but its CLI
|
|
621
740
|
// drives `/usage` from private alpha endpoints on the same host with the
|
|
@@ -742,6 +861,11 @@ const BALANCE_ADAPTERS: Readonly<Record<string, BalanceAdapter>> = {
|
|
|
742
861
|
zai: { fetch: fetchZaiQuota },
|
|
743
862
|
"zai-coding-cn": { fetch: fetchZaiCodingCnQuota },
|
|
744
863
|
"openai-codex": { fetch: fetchCodexQuota, requiresOAuth: true },
|
|
864
|
+
openai: {
|
|
865
|
+
fetch: fetchOpenAIQuota,
|
|
866
|
+
requiresOAuth: true,
|
|
867
|
+
companionProvider: "openai-codex",
|
|
868
|
+
},
|
|
745
869
|
// Both CommandCode transports share one account and one key.
|
|
746
870
|
commandcode: { fetch: fetchCommandcodeBalance },
|
|
747
871
|
"commandcode-anthropic": { fetch: fetchCommandcodeBalance },
|
|
@@ -758,17 +882,29 @@ interface BalanceCacheEntry {
|
|
|
758
882
|
* Keep accounts isolated without persisting the credential itself. Sessions
|
|
759
883
|
* using the same credential get the same cache key and can share a reading.
|
|
760
884
|
*/
|
|
761
|
-
export function balanceCacheKey(
|
|
885
|
+
export function balanceCacheKey(
|
|
886
|
+
provider: string,
|
|
887
|
+
token: string,
|
|
888
|
+
companionToken?: string,
|
|
889
|
+
): string {
|
|
762
890
|
// Codex access tokens rotate, but their account ID is stable. Other
|
|
763
891
|
// providers expose no account identifier here, so the token is the best
|
|
764
892
|
// available identity. Include the endpoint because custom backends can use
|
|
765
893
|
// overlapping account IDs while reporting unrelated balances.
|
|
894
|
+
//
|
|
895
|
+
// `openai` balances belong to the companion Codex account but are scoped to
|
|
896
|
+
// the active app registration, so key on both identities: a different Codex
|
|
897
|
+
// account or a different `openai` login must not share a reading.
|
|
766
898
|
const identity =
|
|
767
|
-
provider === "openai-codex"
|
|
899
|
+
provider === "openai-codex"
|
|
900
|
+
? (parseCodexAccountId(token) ?? token)
|
|
901
|
+
: provider === "openai" && companionToken
|
|
902
|
+
? `${parseCodexAccountId(companionToken) ?? companionToken}:${parseOpenAIClientId(token) ?? token}`
|
|
903
|
+
: token;
|
|
768
904
|
const endpoint =
|
|
769
905
|
provider === "kilo"
|
|
770
906
|
? KILO_BALANCE_ENDPOINT
|
|
771
|
-
: provider === "openai-codex"
|
|
907
|
+
: provider === "openai-codex" || provider === "openai"
|
|
772
908
|
? CODEX_USAGE_ENDPOINT
|
|
773
909
|
: provider === "openrouter"
|
|
774
910
|
? OPENROUTER_CREDITS_ENDPOINT
|
|
@@ -1408,13 +1544,21 @@ export default function providerBalance(
|
|
|
1408
1544
|
/** Provider and account the currently displayed balance belongs to. */
|
|
1409
1545
|
let displayedProvider: string | undefined;
|
|
1410
1546
|
let displayedCacheKey: string | undefined;
|
|
1547
|
+
/**
|
|
1548
|
+
* Cache key of the companion credential backing the displayed balance
|
|
1549
|
+
* (`openai` -> `openai-codex`). Lets `/login openai-codex` wait for the new
|
|
1550
|
+
* companion before refreshing the active `openai` row.
|
|
1551
|
+
*/
|
|
1552
|
+
let displayedCompanionCacheKey: string | undefined;
|
|
1553
|
+
/** Providers already told (once per session) that a companion login is missing. */
|
|
1554
|
+
const companionWarningsLogged = new Set<string>();
|
|
1411
1555
|
let refreshGeneration = 0;
|
|
1412
1556
|
let refreshInFlight = false;
|
|
1413
1557
|
let refreshController: AbortController | undefined;
|
|
1414
1558
|
/** Stamp shared by every throttled refresh trigger (turn_end, delegate:usage). */
|
|
1415
1559
|
let lastThrottledRefreshAt: number | undefined;
|
|
1416
1560
|
let idleRefreshTimer: ReturnType<typeof setTimeout> | undefined;
|
|
1417
|
-
let
|
|
1561
|
+
let identityWatchdogTimer: ReturnType<typeof setTimeout> | undefined;
|
|
1418
1562
|
let requestRender: (() => void) | undefined;
|
|
1419
1563
|
/** True while our footer component is the one mounted in the TUI. */
|
|
1420
1564
|
let footerInstalled = false;
|
|
@@ -1430,68 +1574,104 @@ export default function providerBalance(
|
|
|
1430
1574
|
requestRender?.();
|
|
1431
1575
|
}
|
|
1432
1576
|
|
|
1433
|
-
/**
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1577
|
+
/**
|
|
1578
|
+
* Missing companion is an expected state, not a fetch failure: no balance is
|
|
1579
|
+
* shown and no failure marker is written. Record it once per session so the
|
|
1580
|
+
* reason survives outside the footer (which simply stays blank).
|
|
1581
|
+
*/
|
|
1582
|
+
function noteCompanionUnavailable(
|
|
1583
|
+
providerId: string,
|
|
1584
|
+
companionProvider: string,
|
|
1585
|
+
): void {
|
|
1586
|
+
if (companionWarningsLogged.has(providerId)) return;
|
|
1587
|
+
companionWarningsLogged.add(providerId);
|
|
1588
|
+
logGoodiesEvent({
|
|
1589
|
+
type: "provider_balance_unavailable",
|
|
1590
|
+
provider: providerId,
|
|
1591
|
+
companion: companionProvider,
|
|
1592
|
+
message: `${providerId} ChatGPT usage needs a companion ${companionProvider} login on the same account; run /login ${companionProvider} with that account.`,
|
|
1593
|
+
});
|
|
1442
1594
|
}
|
|
1443
1595
|
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1596
|
+
/**
|
|
1597
|
+
* Periodically compare the LIVE credentials against the account the shown
|
|
1598
|
+
* balance was fetched for, and refresh on any mismatch.
|
|
1599
|
+
*
|
|
1600
|
+
* This replaced an `input`-event hook for /login|/logout: pi's built-in
|
|
1601
|
+
* command dispatcher (interactive-mode setupEditorSubmitHandler) handles
|
|
1602
|
+
* both and returns before extension input handlers run, and the login
|
|
1603
|
+
* completion path emits no extension-visible event when a model is already
|
|
1604
|
+
* selected. The hook could therefore never fire from the TUI. Re-reading
|
|
1605
|
+
* the model registry is a supported API, catches every credential-change
|
|
1606
|
+
* path (TUI /login, /logout, another session's login on the same machine,
|
|
1607
|
+
* a rotated token), and needs no assumptions about command routing.
|
|
1608
|
+
*/
|
|
1609
|
+
function scheduleIdentityWatchdog(): void {
|
|
1610
|
+
if (identityWatchdogTimer !== undefined) clearTimer(identityWatchdogTimer);
|
|
1611
|
+
identityWatchdogTimer = setTimer(async () => {
|
|
1612
|
+
identityWatchdogTimer = undefined;
|
|
1613
|
+
await checkDisplayedIdentity();
|
|
1614
|
+
if (activeContext !== undefined) scheduleIdentityWatchdog();
|
|
1615
|
+
}, IDENTITY_WATCHDOG_INTERVAL_MS);
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
async function checkDisplayedIdentity(): Promise<void> {
|
|
1619
|
+
const ctx = activeContext;
|
|
1620
|
+
if (ctx === undefined || ctx.mode !== "tui") return;
|
|
1621
|
+
const provider = displayedProvider;
|
|
1622
|
+
if (provider === undefined) return;
|
|
1623
|
+
const adapter = adapters[provider];
|
|
1624
|
+
if (!adapter) return;
|
|
1625
|
+
// An in-flight refresh re-resolves identity after its fetch (and its
|
|
1626
|
+
// mismatch branch triggers a follow-up refresh), so it already covers any
|
|
1627
|
+
// swap this tick could have seen.
|
|
1628
|
+
if (refreshInFlight) return;
|
|
1629
|
+
let token: string | undefined;
|
|
1630
|
+
let companionToken: string | undefined;
|
|
1631
|
+
try {
|
|
1632
|
+
token = await ctx.modelRegistry.getApiKeyForProvider(provider);
|
|
1633
|
+
if (token && adapter.companionProvider) {
|
|
1634
|
+
companionToken = (
|
|
1635
|
+
await ctx.modelRegistry.getProviderAuth(adapter.companionProvider)
|
|
1636
|
+
)?.auth.apiKey;
|
|
1478
1637
|
}
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1638
|
+
} catch (error) {
|
|
1639
|
+
// Boundary: pi's getApiKeyForProvider swallows keychain errors and
|
|
1640
|
+
// resolves to undefined, so a rejection here means that contract broke
|
|
1641
|
+
// (the test double rejects to exercise it). This runs in a timer
|
|
1642
|
+
// callback — rethrowing would escape as an unhandled rejection — so the
|
|
1643
|
+
// failure is reported to the structured log and the watchdog keeps
|
|
1644
|
+
// ticking; a silent catch would strand the footer on a stale account.
|
|
1645
|
+
reportFailure(
|
|
1646
|
+
"provider_balance_warning",
|
|
1647
|
+
`[provider-balance] identity watchdog read for "${provider}" failed: ${describeError(error)}`,
|
|
1648
|
+
);
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
const model = ctx.model;
|
|
1652
|
+
const oauthUsable =
|
|
1653
|
+
!adapter.requiresOAuth ||
|
|
1654
|
+
(model !== undefined &&
|
|
1655
|
+
model.provider === provider &&
|
|
1656
|
+
ctx.modelRegistry.isUsingOAuth(model));
|
|
1657
|
+
let expectedCacheKey: string | undefined;
|
|
1658
|
+
let expectedCompanionCacheKey: string | undefined;
|
|
1659
|
+
if (token !== undefined && oauthUsable) {
|
|
1660
|
+
expectedCacheKey = balanceCacheKey(provider, token, companionToken);
|
|
1661
|
+
if (adapter.companionProvider && companionToken !== undefined) {
|
|
1662
|
+
expectedCompanionCacheKey = balanceCacheKey(
|
|
1663
|
+
adapter.companionProvider,
|
|
1664
|
+
companionToken,
|
|
1665
|
+
);
|
|
1490
1666
|
}
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1667
|
+
}
|
|
1668
|
+
if (
|
|
1669
|
+
expectedCacheKey === displayedCacheKey &&
|
|
1670
|
+
expectedCompanionCacheKey === displayedCompanionCacheKey
|
|
1671
|
+
) {
|
|
1672
|
+
return;
|
|
1673
|
+
}
|
|
1674
|
+
void refreshForModel(ctx, model);
|
|
1495
1675
|
}
|
|
1496
1676
|
|
|
1497
1677
|
/** Refresh the balance for whatever model is active. Provider-agnostic: the
|
|
@@ -1529,6 +1709,7 @@ export default function providerBalance(
|
|
|
1529
1709
|
if (provider !== displayedProvider) {
|
|
1530
1710
|
displayedProvider = provider;
|
|
1531
1711
|
displayedCacheKey = undefined;
|
|
1712
|
+
displayedCompanionCacheKey = undefined;
|
|
1532
1713
|
clearBalance();
|
|
1533
1714
|
}
|
|
1534
1715
|
|
|
@@ -1547,6 +1728,7 @@ export default function providerBalance(
|
|
|
1547
1728
|
!ctx.modelRegistry.isUsingOAuth(model))
|
|
1548
1729
|
) {
|
|
1549
1730
|
displayedCacheKey = undefined;
|
|
1731
|
+
displayedCompanionCacheKey = undefined;
|
|
1550
1732
|
clearBalance();
|
|
1551
1733
|
return;
|
|
1552
1734
|
}
|
|
@@ -1564,6 +1746,7 @@ export default function providerBalance(
|
|
|
1564
1746
|
} catch (error) {
|
|
1565
1747
|
if (generation === refreshGeneration) {
|
|
1566
1748
|
displayedCacheKey = undefined;
|
|
1749
|
+
displayedCompanionCacheKey = undefined;
|
|
1567
1750
|
clearBalance();
|
|
1568
1751
|
}
|
|
1569
1752
|
throw error;
|
|
@@ -1571,11 +1754,43 @@ export default function providerBalance(
|
|
|
1571
1754
|
if (generation !== refreshGeneration) return;
|
|
1572
1755
|
if (!token) {
|
|
1573
1756
|
displayedCacheKey = undefined;
|
|
1757
|
+
displayedCompanionCacheKey = undefined;
|
|
1574
1758
|
identityPending = false;
|
|
1575
1759
|
clearBalance();
|
|
1576
1760
|
return;
|
|
1577
1761
|
}
|
|
1578
|
-
|
|
1762
|
+
// `openai` usage is read with the legacy `openai-codex` login. Resolve
|
|
1763
|
+
// it through the model registry (not auth.json) so its token is
|
|
1764
|
+
// refreshed and scoped exactly like a normal request.
|
|
1765
|
+
let companionToken: string | undefined;
|
|
1766
|
+
if (adapter.companionProvider) {
|
|
1767
|
+
try {
|
|
1768
|
+
companionToken = (
|
|
1769
|
+
await ctx.modelRegistry.getProviderAuth(adapter.companionProvider)
|
|
1770
|
+
)?.auth.apiKey;
|
|
1771
|
+
} catch (error) {
|
|
1772
|
+
if (generation === refreshGeneration) {
|
|
1773
|
+
displayedCacheKey = undefined;
|
|
1774
|
+
displayedCompanionCacheKey = undefined;
|
|
1775
|
+
clearBalance();
|
|
1776
|
+
}
|
|
1777
|
+
throw error;
|
|
1778
|
+
}
|
|
1779
|
+
if (generation !== refreshGeneration) return;
|
|
1780
|
+
if (!companionToken) {
|
|
1781
|
+
displayedCacheKey = undefined;
|
|
1782
|
+
displayedCompanionCacheKey = undefined;
|
|
1783
|
+
identityPending = false;
|
|
1784
|
+
clearBalance();
|
|
1785
|
+
noteCompanionUnavailable(providerId, adapter.companionProvider);
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
displayedCompanionCacheKey = balanceCacheKey(
|
|
1789
|
+
adapter.companionProvider,
|
|
1790
|
+
companionToken,
|
|
1791
|
+
);
|
|
1792
|
+
}
|
|
1793
|
+
const tokenCacheKey = balanceCacheKey(providerId, token, companionToken);
|
|
1579
1794
|
cacheKey = tokenCacheKey;
|
|
1580
1795
|
if (displayedCacheKey !== tokenCacheKey) clearBalance();
|
|
1581
1796
|
displayedCacheKey = tokenCacheKey;
|
|
@@ -1609,7 +1824,11 @@ export default function providerBalance(
|
|
|
1609
1824
|
}
|
|
1610
1825
|
}
|
|
1611
1826
|
|
|
1612
|
-
const nextBalance = await adapter.fetch(
|
|
1827
|
+
const nextBalance = await adapter.fetch(
|
|
1828
|
+
token,
|
|
1829
|
+
controller.signal,
|
|
1830
|
+
companionToken ? { companionToken } : undefined,
|
|
1831
|
+
);
|
|
1613
1832
|
const observedAtMs = now();
|
|
1614
1833
|
if (generation !== refreshGeneration) return;
|
|
1615
1834
|
|
|
@@ -1621,6 +1840,7 @@ export default function providerBalance(
|
|
|
1621
1840
|
(!model || !ctx.modelRegistry.isUsingOAuth(model))
|
|
1622
1841
|
) {
|
|
1623
1842
|
displayedCacheKey = undefined;
|
|
1843
|
+
displayedCompanionCacheKey = undefined;
|
|
1624
1844
|
clearBalance();
|
|
1625
1845
|
return;
|
|
1626
1846
|
}
|
|
@@ -1630,17 +1850,31 @@ export default function providerBalance(
|
|
|
1630
1850
|
// flicker proportional to keychain latency. The re-check only needs to
|
|
1631
1851
|
// clear the display if it FAILS (handled below); a passing check just
|
|
1632
1852
|
// commits the fresh balance on top of the old one.
|
|
1853
|
+
// Re-read every credential the cache key covers before accepting the
|
|
1854
|
+
// fetch, and read the ACTIVE token LAST — twice. Any credential change
|
|
1855
|
+
// must land in one of the awaits below to go undetected: before the
|
|
1856
|
+
// companion read (mismatched composite key), or between the two active
|
|
1857
|
+
// reads (double read disagrees). After the second active read resolves,
|
|
1858
|
+
// the remaining checks and the commit are synchronous, so there is no
|
|
1859
|
+
// window left in which a swap could slip through and paint the previous
|
|
1860
|
+
// account's quota. Reading the companion after the active token (as this
|
|
1861
|
+
// block once did) reopened exactly such a window after the double read.
|
|
1633
1862
|
let currentToken: string | undefined;
|
|
1634
1863
|
let confirmedToken: string | undefined;
|
|
1864
|
+
let companionTokenAfter: string | undefined;
|
|
1635
1865
|
try {
|
|
1866
|
+
if (adapter.companionProvider) {
|
|
1867
|
+
companionTokenAfter = (
|
|
1868
|
+
await ctx.modelRegistry.getProviderAuth(adapter.companionProvider)
|
|
1869
|
+
)?.auth.apiKey;
|
|
1870
|
+
}
|
|
1636
1871
|
currentToken = await ctx.modelRegistry.getApiKeyForProvider(providerId);
|
|
1637
|
-
// A credential can change while the first final lookup is pending.
|
|
1638
|
-
// Resolve it once more before accepting the provider response.
|
|
1639
1872
|
confirmedToken =
|
|
1640
1873
|
await ctx.modelRegistry.getApiKeyForProvider(providerId);
|
|
1641
1874
|
} catch (error) {
|
|
1642
1875
|
if (generation === refreshGeneration) {
|
|
1643
1876
|
displayedCacheKey = undefined;
|
|
1877
|
+
displayedCompanionCacheKey = undefined;
|
|
1644
1878
|
clearBalance();
|
|
1645
1879
|
}
|
|
1646
1880
|
throw error;
|
|
@@ -1653,11 +1887,14 @@ export default function providerBalance(
|
|
|
1653
1887
|
(!currentModel || !ctx.modelRegistry.isUsingOAuth(currentModel))) ||
|
|
1654
1888
|
!currentToken ||
|
|
1655
1889
|
!confirmedToken ||
|
|
1656
|
-
balanceCacheKey(providerId, currentToken) !==
|
|
1657
|
-
|
|
1890
|
+
balanceCacheKey(providerId, currentToken, companionTokenAfter) !==
|
|
1891
|
+
cacheKey ||
|
|
1892
|
+
balanceCacheKey(providerId, confirmedToken, companionTokenAfter) !==
|
|
1893
|
+
cacheKey
|
|
1658
1894
|
) {
|
|
1659
1895
|
if (generation === refreshGeneration) {
|
|
1660
1896
|
displayedCacheKey = undefined;
|
|
1897
|
+
displayedCompanionCacheKey = undefined;
|
|
1661
1898
|
clearBalance();
|
|
1662
1899
|
if (currentToken || confirmedToken) {
|
|
1663
1900
|
void refreshForModel(ctx, currentModel);
|
|
@@ -1667,6 +1904,12 @@ export default function providerBalance(
|
|
|
1667
1904
|
}
|
|
1668
1905
|
|
|
1669
1906
|
identityPending = false;
|
|
1907
|
+
if (adapter.companionProvider && companionTokenAfter) {
|
|
1908
|
+
displayedCompanionCacheKey = balanceCacheKey(
|
|
1909
|
+
adapter.companionProvider,
|
|
1910
|
+
companionTokenAfter,
|
|
1911
|
+
);
|
|
1912
|
+
}
|
|
1670
1913
|
// Recovery detected: clear the marker so sibling idle polls resume.
|
|
1671
1914
|
clearBalanceFailureMarker(cacheKey, cacheDir);
|
|
1672
1915
|
const persisted = writeCachedBalance(
|
|
@@ -1827,9 +2070,13 @@ export default function providerBalance(
|
|
|
1827
2070
|
pi.on("session_start", (_event, ctx) => {
|
|
1828
2071
|
activeContext = ctx;
|
|
1829
2072
|
activeThinkingLevel = restoredThinkingLevel(ctx);
|
|
2073
|
+
companionWarningsLogged.clear();
|
|
1830
2074
|
installFooter(ctx);
|
|
1831
2075
|
cleanupBalanceCache(cacheDir, now());
|
|
1832
|
-
if (ctx.mode === "tui")
|
|
2076
|
+
if (ctx.mode === "tui") {
|
|
2077
|
+
scheduleIdleRefresh();
|
|
2078
|
+
scheduleIdentityWatchdog();
|
|
2079
|
+
}
|
|
1833
2080
|
// Footer data is supplemental. Never hold up session readiness on network.
|
|
1834
2081
|
// Adopt a seconds-old sibling/previous reading instead of refetching, so
|
|
1835
2082
|
// rapid session switches and staggered process starts don't stack
|
|
@@ -1839,15 +2086,12 @@ export default function providerBalance(
|
|
|
1839
2086
|
});
|
|
1840
2087
|
});
|
|
1841
2088
|
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
invalidateAuthTransition(provider);
|
|
1849
|
-
scheduleAuthTransitionCheck(ctx, provider, previousCacheKey);
|
|
1850
|
-
});
|
|
2089
|
+
// NOTE: there is deliberately no `input` handler here for /login or
|
|
2090
|
+
// /logout. Pi's TUI dispatches those built-in commands directly in the
|
|
2091
|
+
// editor submit handler and returns before extension input events are
|
|
2092
|
+
// emitted, so such a hook can never fire. Credential changes (either login
|
|
2093
|
+
// of the active provider or of the openai-codex companion) are picked up by
|
|
2094
|
+
// the identity watchdog instead.
|
|
1851
2095
|
|
|
1852
2096
|
pi.on("model_select", (event, ctx) => {
|
|
1853
2097
|
activeContext = ctx;
|
|
@@ -1936,8 +2180,8 @@ export default function providerBalance(
|
|
|
1936
2180
|
lastThrottledRefreshAt = undefined;
|
|
1937
2181
|
if (idleRefreshTimer !== undefined) clearTimer(idleRefreshTimer);
|
|
1938
2182
|
idleRefreshTimer = undefined;
|
|
1939
|
-
if (
|
|
1940
|
-
|
|
2183
|
+
if (identityWatchdogTimer !== undefined) clearTimer(identityWatchdogTimer);
|
|
2184
|
+
identityWatchdogTimer = undefined;
|
|
1941
2185
|
activeContext = undefined;
|
|
1942
2186
|
activeThinkingLevel = "off";
|
|
1943
2187
|
requestRender = undefined;
|
|
@@ -1947,5 +2191,6 @@ export default function providerBalance(
|
|
|
1947
2191
|
balanceFetchedAt = undefined;
|
|
1948
2192
|
displayedProvider = undefined;
|
|
1949
2193
|
displayedCacheKey = undefined;
|
|
2194
|
+
displayedCompanionCacheKey = undefined;
|
|
1950
2195
|
});
|
|
1951
2196
|
}
|