@narumitw/pi-usage 0.52.1 → 0.52.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-usage",
3
- "version": "0.52.1",
3
+ "version": "0.52.3",
4
4
  "description": "Pi extension that shows current-account usage for Codex, GitHub Copilot, OpenRouter, and OpenCode Zen.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -43,11 +43,11 @@
43
43
  "@earendil-works/pi-coding-agent": "*"
44
44
  },
45
45
  "devDependencies": {
46
- "@biomejs/biome": "2.5.8",
47
- "@earendil-works/pi-ai": "0.84.2",
48
- "@earendil-works/pi-coding-agent": "0.84.2",
46
+ "@biomejs/biome": "2.5.10",
47
+ "@earendil-works/pi-ai": "0.84.3",
48
+ "@earendil-works/pi-coding-agent": "0.84.3",
49
49
  "@types/node": "26.2.0",
50
- "esbuild": "0.28.1",
50
+ "esbuild": "0.28.2",
51
51
  "typescript": "7.0.2"
52
52
  },
53
53
  "repository": {
@@ -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";
@@ -19,6 +23,7 @@ import type {
19
23
  const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
20
24
  const GITHUB_COPILOT_USAGE_URL = "https://api.github.com/copilot_internal/user";
21
25
  const OPENROUTER_KEY_URL = "https://openrouter.ai/api/v1/key";
26
+ const OPENCODE_GO_USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
22
27
  const MAX_SUCCESS_BODY_BYTES = 64 * 1024;
23
28
  const MAX_ERROR_BODY_BYTES = 4 * 1024;
24
29
 
@@ -82,7 +87,7 @@ export const SUPPORTED_ADAPTERS: readonly UsageProviderAdapter[] = [
82
87
  semantics: { kind: "consumer-subscription", label: "OpenCode Zen plan usage" },
83
88
  async query(auth, signal, timeoutMs) {
84
89
  const payload = await fetchProviderJson(
85
- opencodeUsageUrl(auth.model.baseUrl),
90
+ OPENCODE_GO_USAGE_URL,
86
91
  auth,
87
92
  signal,
88
93
  timeoutMs,
@@ -111,6 +116,7 @@ export async function resolveUsageAuth(
111
116
  adapter: UsageProviderAdapter,
112
117
  salt: Uint8Array = AUTH_FINGERPRINT_SALT,
113
118
  credentialReader: StoredCredentialReader = readStoredCredential,
119
+ candidateReader?: OAuthCredentialCandidateReader,
114
120
  ): Promise<ResolvedUsageAuth | undefined> {
115
121
  if (ctx.model?.provider === adapter.id && !hasOfficialOrigin(ctx.model, adapter.id)) {
116
122
  throw new Error(
@@ -122,6 +128,7 @@ export async function resolveUsageAuth(
122
128
  hasOfficialOrigin(candidate, adapter.id),
123
129
  );
124
130
  if (!model) return undefined;
131
+ // SAFETY: Pi exposes the required auth methods at runtime, and checks below narrow them before use.
125
132
  const registry = ctx.modelRegistry as unknown as UsageAuthRegistry;
126
133
  let modelAuth: RequestAuth | undefined;
127
134
  if (ctx.model?.provider === adapter.id && typeof registry.getApiKeyAndHeaders === "function") {
@@ -144,7 +151,19 @@ export async function resolveUsageAuth(
144
151
  const auth = modelAuth ?? providerResult?.auth;
145
152
  if (!auth) return undefined;
146
153
  if (adapter.id === "github-copilot") {
147
- return resolveGitHubCopilotUsageAuth(auth, model, salt, credentialReader);
154
+ const offered = candidateReader
155
+ ? candidateReader(ctx, adapter.id)
156
+ : fallbackOAuthCredentialCandidates(adapter.id, credentialReader);
157
+ if (!offered.ok) {
158
+ throw new Error("GitHub Copilot OAuth credential discovery failed closed.");
159
+ }
160
+ return resolveGitHubCopilotUsageAuth(
161
+ auth,
162
+ model,
163
+ salt,
164
+ offered.candidates,
165
+ offered.offeredCount === 0,
166
+ );
148
167
  }
149
168
  const authorization = authorizationFrom(auth);
150
169
  if (!authorization) return undefined;
@@ -334,33 +353,73 @@ function resolveGitHubCopilotUsageAuth(
334
353
  auth: RequestAuth,
335
354
  model: PiModel,
336
355
  salt: Uint8Array,
337
- credentialReader: StoredCredentialReader,
356
+ candidates: readonly unknown[],
357
+ standaloneFallback: boolean,
338
358
  ): 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
- );
359
+ const resolvedAccess = bearerToken(headerValue(auth.headers, "Authorization")) ?? auth.apiKey;
360
+ if (!resolvedAccess) throw new Error("GitHub Copilot OAuth credentials were incomplete.");
361
+ let sawOAuth = false;
362
+ let sawMatchingAccess = false;
363
+ let sawIncompleteMatch = false;
364
+ let sawEnterpriseMatch = false;
365
+ const matches = new Map<string, { refresh: string; storedAccess: string }>();
366
+ for (const candidate of candidates) {
367
+ try {
368
+ const credential = asObject(candidate);
369
+ if (credential?.type !== "oauth") continue;
370
+ sawOAuth = true;
371
+ const storedAccess =
372
+ typeof credential.access === "string" && credential.access ? credential.access : undefined;
373
+ if (storedAccess !== resolvedAccess) continue;
374
+ sawMatchingAccess = true;
375
+ const enterpriseUrl = credential.enterpriseUrl;
376
+ if (
377
+ typeof enterpriseUrl === "string" &&
378
+ enterpriseUrl &&
379
+ !isPublicGitHubDomain(enterpriseUrl)
380
+ ) {
381
+ sawEnterpriseMatch = true;
382
+ continue;
383
+ }
384
+ const refresh =
385
+ typeof credential.refresh === "string" && credential.refresh
386
+ ? credential.refresh
387
+ : undefined;
388
+ if (!refresh) {
389
+ sawIncompleteMatch = true;
390
+ continue;
391
+ }
392
+ matches.set(`${storedAccess.length}:${storedAccess}${refresh}`, { refresh, storedAccess });
393
+ } catch {
394
+ // Malformed candidates never authorize a provider request.
395
+ }
344
396
  }
345
- if (
346
- typeof credential.enterpriseUrl === "string" &&
347
- credential.enterpriseUrl &&
348
- !isPublicGitHubDomain(credential.enterpriseUrl)
349
- ) {
397
+ if (sawEnterpriseMatch) {
350
398
  throw new Error("GitHub Copilot usage does not yet support GitHub Enterprise accounts.");
351
399
  }
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.");
400
+ if (sawIncompleteMatch) throw new Error("GitHub Copilot OAuth credentials were incomplete.");
401
+ if (matches.size > 1) {
402
+ throw new Error(
403
+ "Conflicting OAuth credentials match the active GitHub Copilot runtime account.",
404
+ );
357
405
  }
358
- if (storedAccess !== resolvedAccess) {
406
+ const match = matches.values().next().value;
407
+ if (!match) {
408
+ if (!sawOAuth) {
409
+ throw new Error(
410
+ standaloneFallback
411
+ ? "GitHub Copilot usage requires the OAuth account configured through Pi /login."
412
+ : "GitHub Copilot usage requires an OAuth account configured through Pi /login or a compatible credential source.",
413
+ );
414
+ }
415
+ if (sawMatchingAccess) throw new Error("GitHub Copilot OAuth credentials were incomplete.");
359
416
  throw new Error(
360
- "The active GitHub Copilot runtime account does not match Pi's stored OAuth account.",
417
+ standaloneFallback
418
+ ? "The active GitHub Copilot runtime account does not match Pi's stored OAuth account."
419
+ : "The active GitHub Copilot runtime account does not match any available OAuth account.",
361
420
  );
362
421
  }
363
-
422
+ const { refresh, storedAccess } = match;
364
423
  const authorization = `Bearer ${refresh}`;
365
424
  const headers = {
366
425
  Authorization: authorization,
@@ -436,12 +495,6 @@ function hasHeader(headers: Record<string, string>, name: string): boolean {
436
495
  return Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase());
437
496
  }
438
497
 
439
- function opencodeUsageUrl(baseUrl: string | undefined): string {
440
- const base = baseUrl?.trim().replace(/\/+$/u, "");
441
- if (!base) throw new Error("OpenCode Go model base URL is unavailable.");
442
- return `${base}/usage`;
443
- }
444
-
445
498
  function isAbortError(error: unknown): boolean {
446
499
  return error instanceof Error && error.name === "AbortError";
447
500
  }
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",