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 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.6
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
- and CommandCode credit balance plus 5-hour/weekly usage windows for
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 uses `GET /wham/usage`;
288
- CommandCode uses `GET /alpha/billing/credits` (a private endpoint its CLI
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
- Codex show both each quota's window length and its `nextResetTime`/`reset_at`
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bermudis-pi-goodies",
3
- "version": "0.27.6",
3
+ "version": "0.27.7",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/bermudi/agent-extensions.git",
@@ -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
- /** How long the post-/login poller waits for the new credential to appear,
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(token: string, signal: AbortSignal): Promise<Balance>;
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
- export function parseCodexAccountId(token: string): string | null {
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
- const payload = asRecord(JSON.parse(decodeBase64Url(parts[1])));
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
- async function fetchCodexQuota(
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(provider: string, token: string): string {
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" ? (parseCodexAccountId(token) ?? token) : token;
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 authTransitionTimer: ReturnType<typeof setTimeout> | undefined;
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
- /** Login/logout is delivered as input before the command changes auth. */
1434
- function invalidateAuthTransition(provider: string): void {
1435
- if (provider !== displayedProvider) return;
1436
- refreshGeneration++;
1437
- refreshInFlight = false;
1438
- refreshController?.abort();
1439
- refreshController = undefined;
1440
- displayedCacheKey = undefined;
1441
- clearBalance();
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
- function scheduleAuthTransitionCheck(
1445
- ctx: ExtensionContext,
1446
- provider: string,
1447
- previousCacheKey: string | undefined,
1448
- ): void {
1449
- if (authTransitionTimer !== undefined) clearTimer(authTransitionTimer);
1450
- let attempts = 0;
1451
- // Deliberately not gated on refreshGeneration: any unrelated refresh
1452
- // (turn_end, agent_settled, an idle poll) landing while the user is still
1453
- // completing /login would otherwise kill the poller, and that refresh
1454
- // resolved the OLD credential before it started, so nothing would pick up
1455
- // the new one. Only a session switch (activeContext) ends the poll early.
1456
- const check = async (): Promise<void> => {
1457
- attempts++;
1458
- if (activeContext !== ctx) return;
1459
- let token: string | undefined;
1460
- try {
1461
- token = await ctx.modelRegistry.getApiKeyForProvider(provider);
1462
- } catch (error) {
1463
- // pi's getApiKeyForProvider swallows keychain errors and resolves to
1464
- // undefined, so this catch only fires if that contract changes (the
1465
- // test double rejects to exercise it). Log the failure — a silent
1466
- // catch here would hide exactly the breakage that stalls the
1467
- // post-/login footer — while keeping the retry-until-cap flow.
1468
- reportFailure(
1469
- "provider_balance_warning",
1470
- `[provider-balance] keychain read for "${provider}" failed while waiting for the /login credential swap (attempt ${attempts}/${AUTH_TRANSITION_MAX_ATTEMPTS}): ${describeError(error)}`,
1471
- );
1472
- if (attempts < AUTH_TRANSITION_MAX_ATTEMPTS) {
1473
- authTransitionTimer = setTimer(() => void check(), 1_000);
1474
- } else {
1475
- authTransitionTimer = undefined;
1476
- }
1477
- return;
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
- if (activeContext !== ctx) return;
1480
- const currentCacheKey = token
1481
- ? balanceCacheKey(provider, token)
1482
- : undefined;
1483
- if (currentCacheKey === previousCacheKey) {
1484
- if (attempts < AUTH_TRANSITION_MAX_ATTEMPTS) {
1485
- authTransitionTimer = setTimer(() => void check(), 1_000);
1486
- } else {
1487
- authTransitionTimer = undefined;
1488
- }
1489
- return;
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
- authTransitionTimer = undefined;
1492
- void refreshForModel(ctx, ctx.model);
1493
- };
1494
- authTransitionTimer = setTimer(() => void check(), 250);
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
- const tokenCacheKey = balanceCacheKey(providerId, token);
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(token, controller.signal);
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) !== cacheKey ||
1657
- balanceCacheKey(providerId, confirmedToken) !== cacheKey
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") scheduleIdleRefresh();
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
- pi.on("input", (event, ctx) => {
1843
- const match = /^\/(login|logout)(?:\s+(\S+))?$/.exec(event.text.trim());
1844
- if (!match) return;
1845
- const provider = match[2] ?? ctx.model?.provider;
1846
- if (!provider || provider !== ctx.model?.provider) return;
1847
- const previousCacheKey = displayedCacheKey;
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 (authTransitionTimer !== undefined) clearTimer(authTransitionTimer);
1940
- authTransitionTimer = undefined;
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
  }