@narumitw/pi-usage 0.52.1 β†’ 0.52.2

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
@@ -1,28 +1,26 @@
1
- # πŸ“Š pi-usage β€” Provider Usage for Pi
1
+ # πŸ“Š pi-usage β€” Check Provider Usage and Codex Fast Mode
2
2
 
3
3
  [![npm](https://img.shields.io/npm/v/@narumitw/pi-usage)](https://www.npmjs.com/package/@narumitw/pi-usage) [![Pi extension](https://img.shields.io/badge/Pi-extension-blue)](https://pi.dev) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
4
4
 
5
- `@narumitw/pi-usage` is a native [Pi coding agent](https://pi.dev) extension that adds an interactive `/usage` command for reading usage from the account Pi is actually using and a `/fast` shortcut for supported OpenAI Codex models. It supports Codex ChatGPT subscription windows, GitHub Copilot allowances, and OpenRouter API-key spend limits without pretending those limits have the same semantics.
5
+ Check the limits and usage for the provider account Pi is actually using, and toggle Fast mode for supported OpenAI Codex models.
6
+
7
+ The extension reports each provider's native semantics instead of presenting unlike quotas as equivalent.
6
8
 
7
9
  ## ✨ Features
8
10
 
9
- - Opens one interactive `/usage` menu with current state and next actions.
10
- - Automatically queries the selected model provider and active runtime account.
11
- - Supports OpenAI Codex subscription windows, resets, credits, and model-specific buckets.
12
- - Toggles persistent Codex Fast routing through `/fast` or the contextual `/usage` action.
13
- - Redeems earned Codex usage-limit resets for the active, matching Pi OAuth account with fresh availability, explicit confirmation, and idempotent retry.
14
- - Supports GitHub Copilot AI Credits, legacy premium requests, Free chat quota, additional usage, percentage, and reset time.
15
- - Supports OpenRouter per-key credit limits plus daily, weekly, monthly, and all-time spend.
16
- - Provides explicit refresh, another-provider, and all-configured-provider actions.
17
- - Runs manually requested all-provider queries with concurrency limited to two and preserves partial results.
18
- - Labels only the selected model provider as `Current`; other results are `Configured`.
19
- - Keeps the compact statusline scoped to the current provider and runtime account.
20
- - Isolates its five-minute in-memory cache by provider and a process-salted credential fingerprint.
21
- - Resolves runtime credentials through Pi; for Copilot only, reads Pi's stored OAuth credential through Pi's public credential API and verifies that it matches the active runtime account.
11
+ - Shows current-account usage and next actions through `/usage`.
12
+ - Supports OpenAI Codex subscription windows, credits, resets, and model-specific buckets.
13
+ - Supports GitHub Copilot allowances and OpenRouter per-key limits and spend windows.
14
+ - Toggles persistent Codex Fast routing through `/fast` or the contextual usage menu.
15
+ - Redeems eligible Codex resets only after fresh account matching and explicit confirmation.
16
+ - Refreshes one or all configured providers with bounded concurrency and partial-result preservation.
17
+ - Keeps statusline and cache data scoped to the current provider and runtime account.
18
+ - Resolves credentials through Pi or the process-local OAuth credential-source protocol and validates the effective provider endpoint before sending them.
22
19
 
23
20
  ## πŸ“¦ Install
24
21
 
25
22
  Requires Pi 0.81.0 or newer so the extension can validate the effective base URL attached to resolved provider auth before sending credentials to an official usage endpoint.
23
+ The v1 credential-source interoperability path is characterized against Pi 0.84.3; other runtimes retain standalone fallback but do not receive the protocol timing guarantee.
26
24
 
27
25
  ```bash
28
26
  pi install npm:@narumitw/pi-usage
@@ -34,17 +32,21 @@ Try without installing permanently:
34
32
  pi -e npm:@narumitw/pi-usage
35
33
  ```
36
34
 
37
- Try this package locally from the repository root:
35
+ Build and try this package locally from the repository root:
38
36
 
39
37
  ```bash
38
+ npm --workspace @narumitw/pi-usage run build
40
39
  pi -e ./packages/pi-usage
41
40
  ```
42
41
 
43
- The package declares `dist/index.ts`, so an unbuilt local checkout must run `npm --workspace @narumitw/pi-usage run build` before Pi loads the package directory.
42
+ The package declares `dist/index.ts`, so an unbuilt local checkout must run the build before Pi loads the package directory.
43
+
44
+ ## πŸš€ Quick start
44
45
 
45
- `just try usage` runs that build automatically.
46
+ Run `/usage` in TUI or RPC mode to inspect the current provider, refresh usage, or choose another configured provider.
47
+ Use `/fast` separately to toggle Fast mode for a supported current Codex model.
46
48
 
47
- ## πŸš€ Usage
49
+ ## πŸ’¬ Commands
48
50
 
49
51
  Run:
50
52
 
@@ -52,8 +54,7 @@ Run:
52
54
  /usage
53
55
  ```
54
56
 
55
- In TUI or RPC mode, the standard menu first queries the current model provider and presents its state
56
- with these actions:
57
+ In TUI or RPC mode, the standard menu first queries the current model provider and presents its state with these actions:
57
58
 
58
59
  ```text
59
60
  Refresh current usage
@@ -65,19 +66,18 @@ Close
65
66
  ```
66
67
 
67
68
  There are intentionally no `/usage --refresh`, `/usage <provider>`, or `/usage --all` argument paths.
68
- Cross-provider traffic requires an explicit interactive choice. Escape returns from provider selection
69
- and closes the root menu. Print and JSON modes reject `/usage` observably because they cannot host the
70
- interactive flow. The cancellable live-query progress view remains extension-owned because it streams
71
- provider work and supports in-flight abort rather than presenting a standard menu screen.
72
-
73
- For the current OpenAI Codex provider, **Redeem usage limit reset…** checks fresh earned-reset
74
- details, lets you select a reset when details are available, and shows the exact reset before asking
75
- for confirmation. **No, go back** is the safe default and cancellation before confirmation sends no
76
- mutation. After confirmation, the reset operation cannot be cancelled from its progress view; session
77
- replacement or shutdown still aborts owned work. A transport failure offers **Try again** with the
78
- same redemption request ID so the backend can treat an uncertain retry idempotently. Successful,
79
- already-completed, not-needed, and no-credit outcomes are reported separately, then usage and the
80
- statusline are refreshed for the still-current account.
69
+ Cross-provider traffic requires an explicit interactive choice.
70
+ Escape returns from provider selection and closes the root menu.
71
+ Print and JSON modes reject `/usage` observably because they cannot host the interactive flow.
72
+ The cancellable live-query progress view remains extension-owned because it streams provider work and supports in-flight abort rather than presenting a standard menu screen.
73
+
74
+ For the current OpenAI Codex provider, **Redeem usage limit reset…** checks fresh earned-reset details, lets you select a reset when details are available, and shows the exact reset before asking for confirmation.
75
+ **No, go back** is the safe default and cancellation before confirmation sends no mutation.
76
+ After confirmation, the reset operation cannot be cancelled from its progress view; session replacement or shutdown still aborts owned work.
77
+ A transport failure offers **Try again** with the same redemption request ID so the backend can treat an uncertain retry idempotently.
78
+ Successful, already-completed, not-needed, and no-credit outcomes are reported separately, then usage and the statusline are refreshed for the still-current account.
79
+
80
+ ## βš™οΈ Settings
81
81
 
82
82
  ### Codex Fast mode
83
83
 
@@ -109,14 +109,14 @@ Repair or remove an invalid file, then run `/reload` before trying the toggle ag
109
109
  - Reset mutation: `POST /wham/rate-limit-reset-credits/consume` with a unique redemption request ID and, when available, the selected opaque credit ID
110
110
  - Statusline examples: `codex 59% 5h 61% wk`, `codex fast 59% 5h`, or `codex spark 100% 5h`
111
111
 
112
- The statusline selects a returned bucket that matches the current Codex model when one is available. Unlike `pi-codex-usage`, this successor intentionally has no Codex CLI fallback because the CLI may be logged into a different account than Pi's active runtime account.
112
+ The statusline selects a returned bucket that matches the current Codex model when one is available.
113
+ Unlike `pi-codex-usage`, this successor intentionally has no Codex CLI fallback because the CLI may be logged into a different account than Pi's active runtime account.
113
114
 
114
- Reset redemption is available only when Codex is the current provider and Pi's freshly resolved access
115
- token exactly matches its stored OpenAI Codex OAuth credential. `pi-usage` forwards only the bearer
116
- authorization and matching `chatgpt-account-id` to the official ChatGPT origin. API-key credentials,
117
- configured-but-not-current Codex accounts, account changes during the flow, and custom/proxy origins
118
- fail before mutation. Backend-provided titles and descriptions are sanitized for terminal display;
119
- opaque credit and account IDs are never shown or persisted by the extension.
115
+ Reset redemption is available only when Codex is the current provider and Pi's freshly resolved access token exactly matches an OAuth credential from Pi's stored login or a compatible credential source.
116
+ `pi-usage` forwards only the bearer authorization and matching `chatgpt-account-id` to the official ChatGPT origin.
117
+ API-key credentials, configured-but-not-current Codex accounts, account changes during the flow, and custom/proxy origins fail before mutation.
118
+ Backend-provided titles and descriptions are sanitized for terminal display.
119
+ Opaque credit and account IDs are never shown or persisted by the extension.
120
120
 
121
121
  ### GitHub Copilot
122
122
 
@@ -126,7 +126,12 @@ opaque credit and account IDs are never shown or persisted by the extension.
126
126
  - Displayed data: entitlement, remaining allowance, percentage, reset time, plan, and any additional usage beyond the included allowance
127
127
  - Statusline examples: `copilot credits 1200/1500 80%`, `copilot 245/300 82%`, or `copilot chat 40/50 80%`
128
128
 
129
- GitHub's quota endpoint requires the original GitHub OAuth token rather than the short-lived Copilot inference token exposed by runtime auth. `pi-usage` therefore supports Copilot accounts created through Pi's `/login` flow, reads that stored credential through Pi's public API, and uses it only when its short-lived access token matches the active runtime credential. API-key credentials, account mismatches, GitHub Enterprise accounts, and proxy/custom provider origins fail closed. The detailed report follows the endpoint's `token_based_billing` marker so AI credits are not mislabeled as legacy premium requests, and it reports overage without treating a negative included balance as a malformed response.
129
+ GitHub's quota endpoint requires the original GitHub OAuth token rather than the short-lived Copilot inference token exposed by runtime auth.
130
+ `pi-usage` supports Copilot accounts created through Pi's `/login` flow and named accounts offered by a compatible `oauth:credential-source:v1` owner.
131
+ It uses a candidate only when its short-lived access token exactly matches the freshly resolved active runtime credential.
132
+ Duplicate equivalent candidates are harmless, while conflicting matches fail closed without choosing by extension load order.
133
+ API-key credentials, account mismatches, GitHub Enterprise accounts, and proxy/custom provider origins fail closed.
134
+ The detailed report follows the endpoint's `token_based_billing` marker so AI credits are not mislabeled as legacy premium requests, and it reports overage without treating a negative included balance as a malformed response.
130
135
 
131
136
  ### OpenRouter
132
137
 
@@ -136,7 +141,8 @@ GitHub's quota endpoint requires the original GitHub OAuth token rather than the
136
141
  - Displayed data: key label when safely returned, optional per-key limit and remaining amount, reset period, and daily/weekly/monthly/all-time spend
137
142
  - Statusline examples: `openrouter $74.50 left` or `openrouter $25.50 used`
138
143
 
139
- The extension does not call OpenRouter's account-level `/credits` endpoint because that operation requires a separate management key. OpenRouter documents the distinction between credit and rate limits in its [API limits guide](https://openrouter.ai/docs/api_reference/limits).
144
+ The extension does not call OpenRouter's account-level `/credits` endpoint because that operation requires a separate management key.
145
+ OpenRouter documents the distinction between credit and rate limits in its [API limits guide](https://openrouter.ai/docs/api_reference/limits).
140
146
 
141
147
  ### OpenCode Go (Zen)
142
148
 
@@ -150,19 +156,28 @@ The usage endpoint is derived from the model's base URL (`…/zen/go/v1/usage`)
150
156
 
151
157
  ## 🧭 Current and configured accounts
152
158
 
153
- `Current` means the provider and credential used by Pi's selected model. `Configured` means Pi reports runtime auth for another supported provider; it does not mean that provider is active.
159
+ `Current` means the provider and credential used by Pi's selected model.
160
+ `Configured` means Pi reports runtime auth for another supported provider; it does not mean that provider is active.
154
161
 
155
- The extension does not enumerate multiple accounts inside one provider and does not switch accounts. Account selection remains owned by Pi or an account-management extension. After the active runtime credential changes, the next command, turn, or scheduled refresh resolves auth again and cannot reuse another account's cached report.
162
+ The extension does not enumerate multiple accounts inside one provider and does not switch accounts.
163
+ Account selection remains owned by Pi or an account-management extension.
164
+ A compatible credential owner may offer the verified active named account through the versioned process-local protocol without exposing its account label or storage.
165
+ Without such an owner, `pi-usage` retains its standalone Pi `auth.json` behavior.
166
+ An older or incompatible owner degrades to the existing authentication-unavailable result when the stored login does not match runtime auth.
167
+ After the active runtime credential changes, the next command, turn, or scheduled refresh resolves auth again and cannot reuse another account's cached report.
156
168
 
157
169
  ## πŸ“Š Statusline behavior
158
170
 
159
- The `usage` status item is active only for the selected model provider. It refreshes every five minutes while the session remains on a supported provider and is cleared when the model changes to an unsupported provider.
171
+ The `usage` status item is active only for the selected model provider.
172
+ It refreshes every five minutes while the session remains on a supported provider and is cleared when the model changes to an unsupported provider.
160
173
 
161
- Manual another-provider and all-provider queries never publish to the statusline. `@narumitw/pi-statusline` supplies the default `πŸ“Š` icon; `pi-usage` publishes text-only values.
174
+ Manual another-provider and all-provider queries never publish to the statusline.
175
+ `@narumitw/pi-statusline` supplies the default `πŸ“Š` icon; `pi-usage` publishes text-only values.
162
176
 
163
177
  ## πŸ”„ Migrating from pi-codex-usage
164
178
 
165
- `pi-codex-usage` is deprecated and its source is archived under `deprecated/`. To migrate one installation:
179
+ `pi-codex-usage` is deprecated and its source is archived under `deprecated/`.
180
+ To migrate one installation:
166
181
 
167
182
  ```bash
168
183
  pi remove npm:@narumitw/pi-codex-usage
@@ -178,15 +193,25 @@ Behavior changes:
178
193
  - Codex CLI fallback is removed to preserve active-runtime-account correctness.
179
194
  - The status key changes from `codex-usage` to `usage`.
180
195
 
196
+ ## πŸ”’ Security and privacy
197
+
198
+ Credential candidates are collected synchronously in memory and are not cached, persisted, logged, formatted, or appended to the Pi session.
199
+ The protocol carries no account name or extension identity.
200
+ Only the selected provider's exact runtime match is used, and secrets are sent only to the validated official provider origin.
201
+ Pi extensions run with the user's process privileges, so the shared event bus is not a security boundary between installed extensions.
202
+ Install only trusted extensions because any installed extension may already read user files and process memory.
203
+ Protocol v1 interoperability is characterized for the repository's supported Pi runtime; an absent or incompatible peer preserves standalone fallback and fail-closed mismatch behavior.
204
+
181
205
  ## 🚧 Limitations
182
206
 
183
207
  - Only providers with a meaningful usage source and verifiable Pi runtime auth are supported.
184
208
  - GitHub Copilot quota and OpenAI Codex reset redemption use undocumented provider endpoints that may change without notice.
185
- - Codex reset redemption requires a current ChatGPT OAuth login created through Pi; Codex API keys cannot redeem earned subscription resets.
209
+ - Codex reset redemption requires a current ChatGPT OAuth credential from Pi's login or a compatible credential source; Codex API keys cannot redeem earned subscription resets.
186
210
  - Credentials resolved for custom provider base URLs are never forwarded to the providers' official usage endpoints; effective auth origin validation requires Pi 0.81.0 or newer.
187
211
  - Provider reports are snapshots and may themselves be delayed by the provider.
188
212
  - OpenRouter successful inference responses do not expose proactive request-rate counters; `/usage` reports the documented per-key credit/spend fields instead.
189
- - A provider may not return a safe human-readable account identity. In that case the provider and runtime credential state remain visible without exposing secrets.
213
+ - A provider may not return a safe human-readable account identity.
214
+ In that case the provider and runtime credential state remain visible without exposing secrets.
190
215
  - Immediate account-change events are not available from Pi; auth is re-resolved before commands, turns, and scheduled refreshes.
191
216
  - Fast model support is intentionally conservative and may require an extension update when Codex adds or removes service tiers.
192
217
  - Another later-loaded extension can replace the final provider payload, so arbitrary third-party payload-rewrite conflicts cannot be prevented.
@@ -206,6 +231,7 @@ packages/pi-usage/
206
231
  β”‚ β”œβ”€β”€ settings.ts # Validated user settings and atomic persistence
207
232
  β”‚ β”œβ”€β”€ usage-helpers.ts # Small orchestration helpers
208
233
  β”‚ β”œβ”€β”€ query.ts # Runtime auth resolution and bounded provider queries
234
+ β”‚ β”œβ”€β”€ oauth-credential-source.ts # Ephemeral OAuth candidate collection
209
235
  β”‚ β”œβ”€β”€ codex-resets.ts # Codex reset auth, API contracts, and normalization
210
236
  β”‚ β”œβ”€β”€ format.ts # Provider-aware notifications and statusline text
211
237
  β”‚ β”œβ”€β”€ core.ts # Cache, concurrency, fingerprint, and redaction helpers
@@ -228,4 +254,5 @@ Pi extension, Pi coding agent, usage, quota, OpenAI Codex usage, ChatGPT subscri
228
254
 
229
255
  ## πŸ“„ License
230
256
 
231
- MIT. See [`LICENSE`](./LICENSE).
257
+ MIT.
258
+ See [`LICENSE`](./LICENSE).
package/dist/index.ts CHANGED
@@ -86,7 +86,7 @@ function costsEqual(left, right) {
86
86
  }
87
87
 
88
88
  // src/codex-resets.ts
89
- import { readStoredCredential as readStoredCredential2 } from "@earendil-works/pi-coding-agent";
89
+ import { readStoredCredential as readStoredCredential3 } from "@earendil-works/pi-coding-agent";
90
90
 
91
91
  // src/core.ts
92
92
  import { createHmac } from "node:crypto";
@@ -266,9 +266,67 @@ function truncate(value, maxChars) {
266
266
  return `${value.slice(0, maxChars - 1)}\u2026`;
267
267
  }
268
268
 
269
+ // src/oauth-credential-source.ts
270
+ import {
271
+ readStoredCredential
272
+ } from "@earendil-works/pi-coding-agent";
273
+ var OAUTH_CREDENTIAL_SOURCE_CHANNEL = "oauth:credential-source:v1";
274
+ function createOAuthCredentialCandidateReader(pi, credentialReader = readStoredCredential) {
275
+ return (ctx, providerId) => collectOAuthCredentialCandidates(pi, ctx, providerId, credentialReader);
276
+ }
277
+ function collectOAuthCredentialCandidates(pi, ctx, providerId, credentialReader = readStoredCredential) {
278
+ const candidates = [];
279
+ let collecting = true;
280
+ const request = Object.freeze({
281
+ session: ctx.sessionManager,
282
+ provider: providerId,
283
+ offer(candidate) {
284
+ if (!collecting) return;
285
+ const clone = cloneOAuthCredential(candidate);
286
+ if (clone) candidates.push(clone);
287
+ }
288
+ });
289
+ try {
290
+ pi.events.emit(OAUTH_CREDENTIAL_SOURCE_CHANNEL, request);
291
+ } catch {
292
+ return { ok: false };
293
+ } finally {
294
+ collecting = false;
295
+ }
296
+ const offeredCount = candidates.length;
297
+ try {
298
+ const fallback = cloneOAuthCredential(credentialReader(providerId));
299
+ if (fallback) candidates.push(fallback);
300
+ } catch {
301
+ }
302
+ return { ok: true, candidates, offeredCount };
303
+ }
304
+ function fallbackOAuthCredentialCandidates(providerId, credentialReader) {
305
+ try {
306
+ const credential = cloneOAuthCredential(credentialReader(providerId));
307
+ return { ok: true, candidates: credential ? [credential] : [], offeredCount: 0 };
308
+ } catch {
309
+ return { ok: true, candidates: [], offeredCount: 0 };
310
+ }
311
+ }
312
+ function cloneOAuthCredential(value) {
313
+ try {
314
+ if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
315
+ const clone = structuredClone(value);
316
+ if (!clone || typeof clone !== "object" || Array.isArray(clone)) return void 0;
317
+ if (clone.type !== "oauth") return void 0;
318
+ if (typeof clone.access !== "string" || !clone.access) return void 0;
319
+ if (typeof clone.refresh !== "string" || !clone.refresh) return void 0;
320
+ if (typeof clone.expires !== "number" || !Number.isFinite(clone.expires)) return void 0;
321
+ return clone;
322
+ } catch {
323
+ return void 0;
324
+ }
325
+ }
326
+
269
327
  // src/query.ts
270
328
  import { randomBytes } from "node:crypto";
271
- import { readStoredCredential } from "@earendil-works/pi-coding-agent";
329
+ import { readStoredCredential as readStoredCredential2 } from "@earendil-works/pi-coding-agent";
272
330
 
273
331
  // src/providers/codex.ts
274
332
  function normalizeCodexBackendPayload(payload, capturedAt) {
@@ -705,7 +763,7 @@ function adapterForProvider(providerId) {
705
763
  function isStaleExtensionContextError(error) {
706
764
  return error instanceof Error && error.message.includes("This extension ctx is stale after session replacement or reload");
707
765
  }
708
- async function resolveUsageAuth(ctx, adapter, salt = AUTH_FINGERPRINT_SALT, credentialReader = readStoredCredential) {
766
+ async function resolveUsageAuth(ctx, adapter, salt = AUTH_FINGERPRINT_SALT, credentialReader = readStoredCredential2, candidateReader) {
709
767
  if (ctx.model?.provider === adapter.id && !hasOfficialOrigin(ctx.model, adapter.id)) {
710
768
  throw new Error(
711
769
  `${adapter.displayName} usage cannot send a custom provider base URL credential to the official usage endpoint.`
@@ -734,7 +792,17 @@ async function resolveUsageAuth(ctx, adapter, salt = AUTH_FINGERPRINT_SALT, cred
734
792
  const auth = modelAuth ?? providerResult?.auth;
735
793
  if (!auth) return void 0;
736
794
  if (adapter.id === "github-copilot") {
737
- return resolveGitHubCopilotUsageAuth(auth, model, salt, credentialReader);
795
+ const offered = candidateReader ? candidateReader(ctx, adapter.id) : fallbackOAuthCredentialCandidates(adapter.id, credentialReader);
796
+ if (!offered.ok) {
797
+ throw new Error("GitHub Copilot OAuth credential discovery failed closed.");
798
+ }
799
+ return resolveGitHubCopilotUsageAuth(
800
+ auth,
801
+ model,
802
+ salt,
803
+ offered.candidates,
804
+ offered.offeredCount === 0
805
+ );
738
806
  }
739
807
  const authorization = authorizationFrom(auth);
740
808
  if (!authorization) return void 0;
@@ -875,27 +943,58 @@ async function readBoundedResponse(response, maxBytes, truncateOverflow, descrip
875
943
  const text = new TextDecoder().decode(body);
876
944
  return truncated ? `${text}\u2026` : text;
877
945
  }
878
- function resolveGitHubCopilotUsageAuth(auth, model, salt, credentialReader) {
879
- const credential = asObject5(credentialReader("github-copilot"));
880
- if (credential?.type !== "oauth") {
881
- throw new Error(
882
- "GitHub Copilot usage requires the OAuth account configured through Pi /login."
883
- );
946
+ function resolveGitHubCopilotUsageAuth(auth, model, salt, candidates, standaloneFallback) {
947
+ const resolvedAccess = bearerToken(headerValue(auth.headers, "Authorization")) ?? auth.apiKey;
948
+ if (!resolvedAccess) throw new Error("GitHub Copilot OAuth credentials were incomplete.");
949
+ let sawOAuth = false;
950
+ let sawMatchingAccess = false;
951
+ let sawIncompleteMatch = false;
952
+ let sawEnterpriseMatch = false;
953
+ const matches = /* @__PURE__ */ new Map();
954
+ for (const candidate of candidates) {
955
+ try {
956
+ const credential = asObject5(candidate);
957
+ if (credential?.type !== "oauth") continue;
958
+ sawOAuth = true;
959
+ const storedAccess2 = typeof credential.access === "string" && credential.access ? credential.access : void 0;
960
+ if (storedAccess2 !== resolvedAccess) continue;
961
+ sawMatchingAccess = true;
962
+ const enterpriseUrl = credential.enterpriseUrl;
963
+ if (typeof enterpriseUrl === "string" && enterpriseUrl && !isPublicGitHubDomain(enterpriseUrl)) {
964
+ sawEnterpriseMatch = true;
965
+ continue;
966
+ }
967
+ const refresh2 = typeof credential.refresh === "string" && credential.refresh ? credential.refresh : void 0;
968
+ if (!refresh2) {
969
+ sawIncompleteMatch = true;
970
+ continue;
971
+ }
972
+ matches.set(`${storedAccess2.length}:${storedAccess2}${refresh2}`, { refresh: refresh2, storedAccess: storedAccess2 });
973
+ } catch {
974
+ }
884
975
  }
885
- if (typeof credential.enterpriseUrl === "string" && credential.enterpriseUrl && !isPublicGitHubDomain(credential.enterpriseUrl)) {
976
+ if (sawEnterpriseMatch) {
886
977
  throw new Error("GitHub Copilot usage does not yet support GitHub Enterprise accounts.");
887
978
  }
888
- const refresh = typeof credential.refresh === "string" ? credential.refresh : void 0;
889
- const storedAccess = typeof credential.access === "string" ? credential.access : void 0;
890
- const resolvedAccess = bearerToken(headerValue(auth.headers, "Authorization")) ?? auth.apiKey;
891
- if (!refresh || !storedAccess || !resolvedAccess) {
892
- throw new Error("GitHub Copilot OAuth credentials were incomplete.");
979
+ if (sawIncompleteMatch) throw new Error("GitHub Copilot OAuth credentials were incomplete.");
980
+ if (matches.size > 1) {
981
+ throw new Error(
982
+ "Conflicting OAuth credentials match the active GitHub Copilot runtime account."
983
+ );
893
984
  }
894
- if (storedAccess !== resolvedAccess) {
985
+ const match = matches.values().next().value;
986
+ if (!match) {
987
+ if (!sawOAuth) {
988
+ throw new Error(
989
+ standaloneFallback ? "GitHub Copilot usage requires the OAuth account configured through Pi /login." : "GitHub Copilot usage requires an OAuth account configured through Pi /login or a compatible credential source."
990
+ );
991
+ }
992
+ if (sawMatchingAccess) throw new Error("GitHub Copilot OAuth credentials were incomplete.");
895
993
  throw new Error(
896
- "The active GitHub Copilot runtime account does not match Pi's stored OAuth account."
994
+ standaloneFallback ? "The active GitHub Copilot runtime account does not match Pi's stored OAuth account." : "The active GitHub Copilot runtime account does not match any available OAuth account."
897
995
  );
898
996
  }
997
+ const { refresh, storedAccess } = match;
899
998
  const authorization = `Bearer ${refresh}`;
900
999
  const headers = {
901
1000
  Authorization: authorization,
@@ -1014,7 +1113,7 @@ function formatCodexResetOutcome(outcome, remainingCount) {
1014
1113
  function resetLabel(count) {
1015
1114
  return count === 1 ? "usage limit reset" : "usage limit resets";
1016
1115
  }
1017
- async function resolveCodexResetAuth(ctx, salt = AUTH_FINGERPRINT_SALT, credentialReader = readStoredCredential2) {
1116
+ async function resolveCodexResetAuth(ctx, salt = AUTH_FINGERPRINT_SALT, credentialReader = readStoredCredential3, candidateReader) {
1018
1117
  const model = ctx.model;
1019
1118
  if (model?.provider !== "openai-codex") {
1020
1119
  throw new Error("Usage limit resets require the current model to use OpenAI Codex.");
@@ -1027,25 +1126,20 @@ async function resolveCodexResetAuth(ctx, salt = AUTH_FINGERPRINT_SALT, credenti
1027
1126
  throw new Error("The current model changed while resolving Codex reset authentication.");
1028
1127
  }
1029
1128
  if (!auth) throw new Error("No runtime credential is configured for OpenAI Codex.");
1030
- const credential = asObject6(credentialReader("openai-codex"));
1031
- if (credential?.type !== "oauth") {
1032
- throw new Error(
1033
- "Usage limit resets require the OpenAI Codex OAuth account configured through Pi /login."
1034
- );
1035
- }
1036
- const storedAccess = asNonemptyString(credential.access);
1037
1129
  const resolvedAccess = bearerToken2(headerValue2(auth.headers, "Authorization")) ?? auth.apiKey;
1038
- if (!storedAccess || !resolvedAccess) {
1039
- throw new Error("OpenAI Codex OAuth credentials were incomplete.");
1040
- }
1041
- if (storedAccess !== resolvedAccess) {
1042
- throw new Error(
1043
- "The active OpenAI Codex runtime account does not match Pi's stored OAuth account."
1044
- );
1045
- }
1046
- const accountId = validHeaderValue(credential.accountId);
1047
- if (!accountId)
1048
- throw new Error("The OpenAI Codex OAuth credential did not include a valid account ID.");
1130
+ if (!resolvedAccess) throw new Error("OpenAI Codex OAuth credentials were incomplete.");
1131
+ const resolvedAccountId = codexAccountIdFromAccessToken(resolvedAccess);
1132
+ if (!resolvedAccountId) {
1133
+ throw new Error("The active OpenAI Codex access token did not contain a valid account ID.");
1134
+ }
1135
+ const offered = candidateReader ? candidateReader(ctx, "openai-codex") : fallbackOAuthCredentialCandidates("openai-codex", credentialReader);
1136
+ if (!offered.ok) throw new Error("OpenAI Codex OAuth credential discovery failed closed.");
1137
+ const { accountId, storedAccess } = selectCodexResetCredential(
1138
+ offered.candidates,
1139
+ resolvedAccess,
1140
+ resolvedAccountId,
1141
+ offered.offeredCount === 0
1142
+ );
1049
1143
  const authorization = `Bearer ${resolvedAccess}`;
1050
1144
  const headers = {
1051
1145
  Authorization: authorization,
@@ -1114,6 +1208,58 @@ function normalizeCodexResetCreditsPayload(payload) {
1114
1208
  }
1115
1209
  return { availableCount, options };
1116
1210
  }
1211
+ function selectCodexResetCredential(candidates, resolvedAccess, resolvedAccountId, standaloneFallback) {
1212
+ let sawOAuth = false;
1213
+ let sawMatchingAccess = false;
1214
+ let sawInvalidAccountId = false;
1215
+ const matches = /* @__PURE__ */ new Map();
1216
+ for (const candidate of candidates) {
1217
+ try {
1218
+ const credential = asObject6(candidate);
1219
+ if (credential?.type !== "oauth") continue;
1220
+ sawOAuth = true;
1221
+ const storedAccess = asNonemptyString(credential.access);
1222
+ if (storedAccess !== resolvedAccess) continue;
1223
+ sawMatchingAccess = true;
1224
+ const accountId = validHeaderValue(credential.accountId);
1225
+ const refresh = asNonemptyString(credential.refresh);
1226
+ if (!accountId || accountId !== resolvedAccountId || !refresh) {
1227
+ sawInvalidAccountId = true;
1228
+ continue;
1229
+ }
1230
+ matches.set(refresh, { accountId, storedAccess });
1231
+ } catch {
1232
+ }
1233
+ }
1234
+ if (sawInvalidAccountId) {
1235
+ throw new Error("The OpenAI Codex OAuth credential did not include a valid account ID.");
1236
+ }
1237
+ if (matches.size > 1) {
1238
+ throw new Error("Conflicting OAuth credentials match the active OpenAI Codex runtime account.");
1239
+ }
1240
+ const match = matches.values().next().value;
1241
+ if (match) return match;
1242
+ if (!sawOAuth) {
1243
+ throw new Error(
1244
+ standaloneFallback ? "Usage limit resets require the OpenAI Codex OAuth account configured through Pi /login." : "Usage limit resets require an OpenAI Codex OAuth account configured through Pi /login or a compatible credential source."
1245
+ );
1246
+ }
1247
+ if (sawMatchingAccess) throw new Error("OpenAI Codex OAuth credentials were incomplete.");
1248
+ throw new Error(
1249
+ standaloneFallback ? "The active OpenAI Codex runtime account does not match Pi's stored OAuth account." : "The active OpenAI Codex runtime account does not match any available OAuth account."
1250
+ );
1251
+ }
1252
+ function codexAccountIdFromAccessToken(access) {
1253
+ try {
1254
+ const parts = access.split(".");
1255
+ if (parts.length !== 3 || !parts[1]) return void 0;
1256
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
1257
+ const claims = asObject6(asObject6(payload)?.["https://api.openai.com/auth"]);
1258
+ return validHeaderValue(claims?.chatgpt_account_id);
1259
+ } catch {
1260
+ return void 0;
1261
+ }
1262
+ }
1117
1263
  function normalizeResetOption(credit) {
1118
1264
  const creditId = asOpaqueId(credit.id);
1119
1265
  if (!creditId) throw new Error("Codex reset credits response returned an invalid credit ID.");
@@ -1784,6 +1930,7 @@ var CLOSE = "Close";
1784
1930
  var REDEEM_CODEX_RESET = "Redeem usage limit reset\u2026";
1785
1931
  function usageExtension(pi, dependencies = {}) {
1786
1932
  const credentialReader = dependencies.credentialReader;
1933
+ const credentialCandidates = createOAuthCredentialCandidateReader(pi, credentialReader);
1787
1934
  const createRedemptionId = dependencies.createRedemptionId ?? randomUUID2;
1788
1935
  const settingsRuntime = dependencies.settingsRuntime ?? createUsageSettingsRuntime();
1789
1936
  const cache = new UsageCache(CACHE_TTL_MS);
@@ -1872,7 +2019,7 @@ function usageExtension(pi, dependencies = {}) {
1872
2019
  let auth;
1873
2020
  try {
1874
2021
  auth = await awaitWithDeadline(
1875
- resolveUsageAuth(ctx, adapter),
2022
+ resolveUsageAuth(ctx, adapter, void 0, credentialReader, credentialCandidates),
1876
2023
  signal,
1877
2024
  DEFAULT_TIMEOUT_MS,
1878
2025
  `resolving ${adapter.displayName} runtime auth`
@@ -2067,7 +2214,7 @@ function usageExtension(pi, dependencies = {}) {
2067
2214
  if (!adapter) return false;
2068
2215
  try {
2069
2216
  const auth = await awaitWithDeadline(
2070
- resolveUsageAuth(ctx, adapter),
2217
+ resolveUsageAuth(ctx, adapter, void 0, credentialReader, credentialCandidates),
2071
2218
  signal,
2072
2219
  DEFAULT_TIMEOUT_MS,
2073
2220
  `revalidating ${adapter.displayName} runtime auth`
@@ -2082,7 +2229,7 @@ function usageExtension(pi, dependencies = {}) {
2082
2229
  if (!adapter) return false;
2083
2230
  try {
2084
2231
  const auth = await awaitWithDeadline(
2085
- resolveUsageAuth(ctx, adapter),
2232
+ resolveUsageAuth(ctx, adapter, void 0, credentialReader, credentialCandidates),
2086
2233
  signal,
2087
2234
  DEFAULT_TIMEOUT_MS,
2088
2235
  `revalidating ${adapter.displayName} runtime auth`
@@ -2269,7 +2416,7 @@ function usageExtension(pi, dependencies = {}) {
2269
2416
  async (signal) => {
2270
2417
  const expectedModel = modelIdentity(ctx.model);
2271
2418
  const auth = await awaitWithDeadline(
2272
- resolveCodexResetAuth(ctx, void 0, credentialReader),
2419
+ resolveCodexResetAuth(ctx, void 0, credentialReader, credentialCandidates),
2273
2420
  signal,
2274
2421
  DEFAULT_TIMEOUT_MS,
2275
2422
  "resolving current Codex reset authentication"
@@ -2287,7 +2434,7 @@ function usageExtension(pi, dependencies = {}) {
2287
2434
  };
2288
2435
  }
2289
2436
  const revalidated = await awaitWithDeadline(
2290
- resolveCodexResetAuth(ctx, void 0, credentialReader),
2437
+ resolveCodexResetAuth(ctx, void 0, credentialReader, credentialCandidates),
2291
2438
  signal,
2292
2439
  DEFAULT_TIMEOUT_MS,
2293
2440
  "revalidating current Codex reset authentication"
@@ -2346,7 +2493,7 @@ function usageExtension(pi, dependencies = {}) {
2346
2493
  controller.signal,
2347
2494
  async (signal) => {
2348
2495
  const auth = await awaitWithDeadline(
2349
- resolveCodexResetAuth(ctx, void 0, credentialReader),
2496
+ resolveCodexResetAuth(ctx, void 0, credentialReader, credentialCandidates),
2350
2497
  signal,
2351
2498
  DEFAULT_TIMEOUT_MS,
2352
2499
  "revalidating current Codex reset authentication"