@narumitw/pi-usage 0.52.0 → 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.
@@ -1,6 +1,10 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { readStoredCredential } from "@earendil-works/pi-coding-agent";
3
3
  import { fingerprintResolvedAuth, sanitizeDisplayText } from "./core.js";
4
+ import {
5
+ fallbackOAuthCredentialCandidates,
6
+ type OAuthCredentialCandidateReader,
7
+ } from "./oauth-credential-source.js";
4
8
  import {
5
9
  AUTH_FINGERPRINT_SALT,
6
10
  adapterForProvider,
@@ -98,6 +102,7 @@ export async function resolveCodexResetAuth(
98
102
  ctx: ExtensionContext,
99
103
  salt: Uint8Array = AUTH_FINGERPRINT_SALT,
100
104
  credentialReader: StoredCredentialReader = readStoredCredential,
105
+ candidateReader?: OAuthCredentialCandidateReader,
101
106
  ): Promise<ResolvedUsageAuth> {
102
107
  const model = ctx.model;
103
108
  if (model?.provider !== "openai-codex") {
@@ -112,26 +117,22 @@ export async function resolveCodexResetAuth(
112
117
  }
113
118
  if (!auth) throw new Error("No runtime credential is configured for OpenAI Codex.");
114
119
 
115
- const credential = asObject(credentialReader("openai-codex"));
116
- if (credential?.type !== "oauth") {
117
- throw new Error(
118
- "Usage limit resets require the OpenAI Codex OAuth account configured through Pi /login.",
119
- );
120
- }
121
- const storedAccess = asNonemptyString(credential.access);
122
120
  const resolvedAccess = bearerToken(headerValue(auth.headers, "Authorization")) ?? auth.apiKey;
123
- if (!storedAccess || !resolvedAccess) {
124
- throw new Error("OpenAI Codex OAuth credentials were incomplete.");
125
- }
126
- if (storedAccess !== resolvedAccess) {
127
- throw new Error(
128
- "The active OpenAI Codex runtime account does not match Pi's stored OAuth account.",
129
- );
121
+ if (!resolvedAccess) throw new Error("OpenAI Codex OAuth credentials were incomplete.");
122
+ const resolvedAccountId = codexAccountIdFromAccessToken(resolvedAccess);
123
+ if (!resolvedAccountId) {
124
+ throw new Error("The active OpenAI Codex access token did not contain a valid account ID.");
130
125
  }
131
- const accountId = validHeaderValue(credential.accountId);
132
- if (!accountId)
133
- throw new Error("The OpenAI Codex OAuth credential did not include a valid account ID.");
134
-
126
+ const offered = candidateReader
127
+ ? candidateReader(ctx, "openai-codex")
128
+ : fallbackOAuthCredentialCandidates("openai-codex", credentialReader);
129
+ if (!offered.ok) throw new Error("OpenAI Codex OAuth credential discovery failed closed.");
130
+ const { accountId, storedAccess } = selectCodexResetCredential(
131
+ offered.candidates,
132
+ resolvedAccess,
133
+ resolvedAccountId,
134
+ offered.offeredCount === 0,
135
+ );
135
136
  const authorization = `Bearer ${resolvedAccess}`;
136
137
  const headers = {
137
138
  Authorization: authorization,
@@ -226,6 +227,70 @@ export function normalizeCodexResetCreditsPayload(
226
227
  return { availableCount, options };
227
228
  }
228
229
 
230
+ function selectCodexResetCredential(
231
+ candidates: readonly unknown[],
232
+ resolvedAccess: string,
233
+ resolvedAccountId: string,
234
+ standaloneFallback: boolean,
235
+ ): { accountId: string; storedAccess: string } {
236
+ let sawOAuth = false;
237
+ let sawMatchingAccess = false;
238
+ let sawInvalidAccountId = false;
239
+ const matches = new Map<string, { accountId: string; storedAccess: string }>();
240
+ for (const candidate of candidates) {
241
+ try {
242
+ const credential = asObject(candidate);
243
+ if (credential?.type !== "oauth") continue;
244
+ sawOAuth = true;
245
+ const storedAccess = asNonemptyString(credential.access);
246
+ if (storedAccess !== resolvedAccess) continue;
247
+ sawMatchingAccess = true;
248
+ const accountId = validHeaderValue(credential.accountId);
249
+ const refresh = asNonemptyString(credential.refresh);
250
+ if (!accountId || accountId !== resolvedAccountId || !refresh) {
251
+ sawInvalidAccountId = true;
252
+ continue;
253
+ }
254
+ matches.set(refresh, { accountId, storedAccess });
255
+ } catch {
256
+ // Malformed candidates never authorize a reset request.
257
+ }
258
+ }
259
+ if (sawInvalidAccountId) {
260
+ throw new Error("The OpenAI Codex OAuth credential did not include a valid account ID.");
261
+ }
262
+ if (matches.size > 1) {
263
+ throw new Error("Conflicting OAuth credentials match the active OpenAI Codex runtime account.");
264
+ }
265
+ const match = matches.values().next().value;
266
+ if (match) return match;
267
+ if (!sawOAuth) {
268
+ throw new Error(
269
+ standaloneFallback
270
+ ? "Usage limit resets require the OpenAI Codex OAuth account configured through Pi /login."
271
+ : "Usage limit resets require an OpenAI Codex OAuth account configured through Pi /login or a compatible credential source.",
272
+ );
273
+ }
274
+ if (sawMatchingAccess) throw new Error("OpenAI Codex OAuth credentials were incomplete.");
275
+ throw new Error(
276
+ standaloneFallback
277
+ ? "The active OpenAI Codex runtime account does not match Pi's stored OAuth account."
278
+ : "The active OpenAI Codex runtime account does not match any available OAuth account.",
279
+ );
280
+ }
281
+
282
+ function codexAccountIdFromAccessToken(access: string): string | undefined {
283
+ try {
284
+ const parts = access.split(".");
285
+ if (parts.length !== 3 || !parts[1]) return undefined;
286
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as unknown;
287
+ const claims = asObject(asObject(payload)?.["https://api.openai.com/auth"]);
288
+ return validHeaderValue(claims?.chatgpt_account_id);
289
+ } catch {
290
+ return undefined;
291
+ }
292
+ }
293
+
229
294
  function normalizeResetOption(credit: Record<string, unknown>): CodexResetOption {
230
295
  const creditId = asOpaqueId(credit.id);
231
296
  if (!creditId) throw new Error("Codex reset credits response returned an invalid credit ID.");
@@ -0,0 +1,88 @@
1
+ import type { OAuthCredential } from "@earendil-works/pi-ai";
2
+ import {
3
+ type ExtensionAPI,
4
+ type ExtensionContext,
5
+ readStoredCredential,
6
+ } from "@earendil-works/pi-coding-agent";
7
+
8
+ export const OAUTH_CREDENTIAL_SOURCE_CHANNEL = "oauth:credential-source:v1";
9
+
10
+ export type StoredCredentialReader = (providerId: string) => unknown;
11
+
12
+ export type OAuthCredentialCandidates =
13
+ | { ok: true; candidates: readonly OAuthCredential[]; offeredCount?: number }
14
+ | { ok: false };
15
+
16
+ export type OAuthCredentialCandidateReader = (
17
+ ctx: ExtensionContext,
18
+ providerId: string,
19
+ ) => OAuthCredentialCandidates;
20
+
21
+ export function createOAuthCredentialCandidateReader(
22
+ pi: ExtensionAPI,
23
+ credentialReader: StoredCredentialReader = readStoredCredential,
24
+ ): OAuthCredentialCandidateReader {
25
+ return (ctx, providerId) =>
26
+ collectOAuthCredentialCandidates(pi, ctx, providerId, credentialReader);
27
+ }
28
+
29
+ export function collectOAuthCredentialCandidates(
30
+ pi: Pick<ExtensionAPI, "events">,
31
+ ctx: ExtensionContext,
32
+ providerId: string,
33
+ credentialReader: StoredCredentialReader = readStoredCredential,
34
+ ): OAuthCredentialCandidates {
35
+ const candidates: OAuthCredential[] = [];
36
+ let collecting = true;
37
+ const request = Object.freeze({
38
+ session: ctx.sessionManager,
39
+ provider: providerId,
40
+ offer(candidate: unknown) {
41
+ if (!collecting) return;
42
+ const clone = cloneOAuthCredential(candidate);
43
+ if (clone) candidates.push(clone);
44
+ },
45
+ });
46
+ try {
47
+ pi.events.emit(OAUTH_CREDENTIAL_SOURCE_CHANNEL, request);
48
+ } catch {
49
+ return { ok: false };
50
+ } finally {
51
+ collecting = false;
52
+ }
53
+ const offeredCount = candidates.length;
54
+ try {
55
+ const fallback = cloneOAuthCredential(credentialReader(providerId));
56
+ if (fallback) candidates.push(fallback);
57
+ } catch {
58
+ // A malformed or unavailable standalone credential is equivalent to no fallback.
59
+ }
60
+ return { ok: true, candidates, offeredCount };
61
+ }
62
+
63
+ export function fallbackOAuthCredentialCandidates(
64
+ providerId: string,
65
+ credentialReader: StoredCredentialReader,
66
+ ): OAuthCredentialCandidates {
67
+ try {
68
+ const credential = cloneOAuthCredential(credentialReader(providerId));
69
+ return { ok: true, candidates: credential ? [credential] : [], offeredCount: 0 };
70
+ } catch {
71
+ return { ok: true, candidates: [], offeredCount: 0 };
72
+ }
73
+ }
74
+
75
+ function cloneOAuthCredential(value: unknown): OAuthCredential | undefined {
76
+ try {
77
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
78
+ const clone = structuredClone(value) as OAuthCredential;
79
+ if (!clone || typeof clone !== "object" || Array.isArray(clone)) return undefined;
80
+ if (clone.type !== "oauth") return undefined;
81
+ if (typeof clone.access !== "string" || !clone.access) return undefined;
82
+ if (typeof clone.refresh !== "string" || !clone.refresh) return undefined;
83
+ if (typeof clone.expires !== "number" || !Number.isFinite(clone.expires)) return undefined;
84
+ return clone;
85
+ } catch {
86
+ return undefined;
87
+ }
88
+ }
package/src/query.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  import { randomBytes } from "node:crypto";
2
2
  import { type ExtensionContext, readStoredCredential } from "@earendil-works/pi-coding-agent";
3
3
  import { errorMessage, fingerprintResolvedAuth, redactUsageError } from "./core.js";
4
+ import {
5
+ fallbackOAuthCredentialCandidates,
6
+ type OAuthCredentialCandidateReader,
7
+ } from "./oauth-credential-source.js";
4
8
  import { normalizeCodexBackendPayload } from "./providers/codex.js";
5
9
  import { normalizeGitHubCopilotUsagePayload } from "./providers/github-copilot.js";
6
10
  import { normalizeOpenCodeZenPayload } from "./providers/opencode-zen.js";
@@ -111,6 +115,7 @@ export async function resolveUsageAuth(
111
115
  adapter: UsageProviderAdapter,
112
116
  salt: Uint8Array = AUTH_FINGERPRINT_SALT,
113
117
  credentialReader: StoredCredentialReader = readStoredCredential,
118
+ candidateReader?: OAuthCredentialCandidateReader,
114
119
  ): Promise<ResolvedUsageAuth | undefined> {
115
120
  if (ctx.model?.provider === adapter.id && !hasOfficialOrigin(ctx.model, adapter.id)) {
116
121
  throw new Error(
@@ -144,7 +149,19 @@ export async function resolveUsageAuth(
144
149
  const auth = modelAuth ?? providerResult?.auth;
145
150
  if (!auth) return undefined;
146
151
  if (adapter.id === "github-copilot") {
147
- return resolveGitHubCopilotUsageAuth(auth, model, salt, credentialReader);
152
+ const offered = candidateReader
153
+ ? candidateReader(ctx, adapter.id)
154
+ : fallbackOAuthCredentialCandidates(adapter.id, credentialReader);
155
+ if (!offered.ok) {
156
+ throw new Error("GitHub Copilot OAuth credential discovery failed closed.");
157
+ }
158
+ return resolveGitHubCopilotUsageAuth(
159
+ auth,
160
+ model,
161
+ salt,
162
+ offered.candidates,
163
+ offered.offeredCount === 0,
164
+ );
148
165
  }
149
166
  const authorization = authorizationFrom(auth);
150
167
  if (!authorization) return undefined;
@@ -334,33 +351,73 @@ function resolveGitHubCopilotUsageAuth(
334
351
  auth: RequestAuth,
335
352
  model: PiModel,
336
353
  salt: Uint8Array,
337
- credentialReader: StoredCredentialReader,
354
+ candidates: readonly unknown[],
355
+ standaloneFallback: boolean,
338
356
  ): ResolvedUsageAuth {
339
- const credential = asObject(credentialReader("github-copilot"));
340
- if (credential?.type !== "oauth") {
341
- throw new Error(
342
- "GitHub Copilot usage requires the OAuth account configured through Pi /login.",
343
- );
357
+ const resolvedAccess = bearerToken(headerValue(auth.headers, "Authorization")) ?? auth.apiKey;
358
+ if (!resolvedAccess) throw new Error("GitHub Copilot OAuth credentials were incomplete.");
359
+ let sawOAuth = false;
360
+ let sawMatchingAccess = false;
361
+ let sawIncompleteMatch = false;
362
+ let sawEnterpriseMatch = false;
363
+ const matches = new Map<string, { refresh: string; storedAccess: string }>();
364
+ for (const candidate of candidates) {
365
+ try {
366
+ const credential = asObject(candidate);
367
+ if (credential?.type !== "oauth") continue;
368
+ sawOAuth = true;
369
+ const storedAccess =
370
+ typeof credential.access === "string" && credential.access ? credential.access : undefined;
371
+ if (storedAccess !== resolvedAccess) continue;
372
+ sawMatchingAccess = true;
373
+ const enterpriseUrl = credential.enterpriseUrl;
374
+ if (
375
+ typeof enterpriseUrl === "string" &&
376
+ enterpriseUrl &&
377
+ !isPublicGitHubDomain(enterpriseUrl)
378
+ ) {
379
+ sawEnterpriseMatch = true;
380
+ continue;
381
+ }
382
+ const refresh =
383
+ typeof credential.refresh === "string" && credential.refresh
384
+ ? credential.refresh
385
+ : undefined;
386
+ if (!refresh) {
387
+ sawIncompleteMatch = true;
388
+ continue;
389
+ }
390
+ matches.set(`${storedAccess.length}:${storedAccess}${refresh}`, { refresh, storedAccess });
391
+ } catch {
392
+ // Malformed candidates never authorize a provider request.
393
+ }
344
394
  }
345
- if (
346
- typeof credential.enterpriseUrl === "string" &&
347
- credential.enterpriseUrl &&
348
- !isPublicGitHubDomain(credential.enterpriseUrl)
349
- ) {
395
+ if (sawEnterpriseMatch) {
350
396
  throw new Error("GitHub Copilot usage does not yet support GitHub Enterprise accounts.");
351
397
  }
352
- const refresh = typeof credential.refresh === "string" ? credential.refresh : undefined;
353
- const storedAccess = typeof credential.access === "string" ? credential.access : undefined;
354
- const resolvedAccess = bearerToken(headerValue(auth.headers, "Authorization")) ?? auth.apiKey;
355
- if (!refresh || !storedAccess || !resolvedAccess) {
356
- throw new Error("GitHub Copilot OAuth credentials were incomplete.");
398
+ if (sawIncompleteMatch) throw new Error("GitHub Copilot OAuth credentials were incomplete.");
399
+ if (matches.size > 1) {
400
+ throw new Error(
401
+ "Conflicting OAuth credentials match the active GitHub Copilot runtime account.",
402
+ );
357
403
  }
358
- if (storedAccess !== resolvedAccess) {
404
+ const match = matches.values().next().value;
405
+ if (!match) {
406
+ if (!sawOAuth) {
407
+ throw new Error(
408
+ standaloneFallback
409
+ ? "GitHub Copilot usage requires the OAuth account configured through Pi /login."
410
+ : "GitHub Copilot usage requires an OAuth account configured through Pi /login or a compatible credential source.",
411
+ );
412
+ }
413
+ if (sawMatchingAccess) throw new Error("GitHub Copilot OAuth credentials were incomplete.");
359
414
  throw new Error(
360
- "The active GitHub Copilot runtime account does not match Pi's stored OAuth account.",
415
+ standaloneFallback
416
+ ? "The active GitHub Copilot runtime account does not match Pi's stored OAuth account."
417
+ : "The active GitHub Copilot runtime account does not match any available OAuth account.",
361
418
  );
362
419
  }
363
-
420
+ const { refresh, storedAccess } = match;
364
421
  const authorization = `Bearer ${refresh}`;
365
422
  const headers = {
366
423
  Authorization: authorization,
package/src/usage.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  } from "./codex-resets.js";
23
23
  import { awaitWithDeadline, errorMessage, runWithConcurrency, UsageCache } from "./core.js";
24
24
  import { formatProviderStates, formatUsageStatusline } from "./format.js";
25
+ import { createOAuthCredentialCandidateReader } from "./oauth-credential-source.js";
25
26
  import {
26
27
  adapterForProvider,
27
28
  isStaleExtensionContextError,
@@ -80,6 +81,7 @@ export default function usageExtension(
80
81
  dependencies: UsageExtensionDependencies = {},
81
82
  ) {
82
83
  const credentialReader = dependencies.credentialReader;
84
+ const credentialCandidates = createOAuthCredentialCandidateReader(pi, credentialReader);
83
85
  const createRedemptionId = dependencies.createRedemptionId ?? randomUUID;
84
86
  const settingsRuntime = dependencies.settingsRuntime ?? createUsageSettingsRuntime();
85
87
  const cache = new UsageCache(CACHE_TTL_MS);
@@ -189,7 +191,7 @@ export default function usageExtension(
189
191
  let auth: ResolvedUsageAuth | undefined;
190
192
  try {
191
193
  auth = await awaitWithDeadline(
192
- resolveUsageAuth(ctx, adapter),
194
+ resolveUsageAuth(ctx, adapter, undefined, credentialReader, credentialCandidates),
193
195
  signal,
194
196
  DEFAULT_TIMEOUT_MS,
195
197
  `resolving ${adapter.displayName} runtime auth`,
@@ -419,7 +421,7 @@ export default function usageExtension(
419
421
  if (!adapter) return false;
420
422
  try {
421
423
  const auth = await awaitWithDeadline(
422
- resolveUsageAuth(ctx, adapter),
424
+ resolveUsageAuth(ctx, adapter, undefined, credentialReader, credentialCandidates),
423
425
  signal,
424
426
  DEFAULT_TIMEOUT_MS,
425
427
  `revalidating ${adapter.displayName} runtime auth`,
@@ -438,7 +440,7 @@ export default function usageExtension(
438
440
  if (!adapter) return false;
439
441
  try {
440
442
  const auth = await awaitWithDeadline(
441
- resolveUsageAuth(ctx, adapter),
443
+ resolveUsageAuth(ctx, adapter, undefined, credentialReader, credentialCandidates),
442
444
  signal,
443
445
  DEFAULT_TIMEOUT_MS,
444
446
  `revalidating ${adapter.displayName} runtime auth`,
@@ -677,7 +679,7 @@ export default function usageExtension(
677
679
  async (signal) => {
678
680
  const expectedModel = modelIdentity(ctx.model);
679
681
  const auth = await awaitWithDeadline(
680
- resolveCodexResetAuth(ctx, undefined, credentialReader),
682
+ resolveCodexResetAuth(ctx, undefined, credentialReader, credentialCandidates),
681
683
  signal,
682
684
  DEFAULT_TIMEOUT_MS,
683
685
  "resolving current Codex reset authentication",
@@ -695,7 +697,7 @@ export default function usageExtension(
695
697
  };
696
698
  }
697
699
  const revalidated = await awaitWithDeadline(
698
- resolveCodexResetAuth(ctx, undefined, credentialReader),
700
+ resolveCodexResetAuth(ctx, undefined, credentialReader, credentialCandidates),
699
701
  signal,
700
702
  DEFAULT_TIMEOUT_MS,
701
703
  "revalidating current Codex reset authentication",
@@ -759,7 +761,7 @@ export default function usageExtension(
759
761
  controller.signal,
760
762
  async (signal) => {
761
763
  const auth = await awaitWithDeadline(
762
- resolveCodexResetAuth(ctx, undefined, credentialReader),
764
+ resolveCodexResetAuth(ctx, undefined, credentialReader, credentialCandidates),
763
765
  signal,
764
766
  DEFAULT_TIMEOUT_MS,
765
767
  "revalidating current Codex reset authentication",