@narumitw/pi-usage 0.61.1 → 0.62.0

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.
@@ -9,7 +9,7 @@ const ON = "On";
9
9
  const REMAINING = "Remaining";
10
10
  const USED = "Used";
11
11
 
12
- type UsageSettingId = "codexFastMode" | "codexStatusResetCountdown" | "codexStatusPercentage";
12
+ type UsageSettingId = "codexFastMode" | "codexStatusResetCountdown" | "codexStatusPercentage" | "openaiCompanionUsage";
13
13
 
14
14
  function settingValueLabel(id: UsageSettingId, value: UsageSettings[UsageSettingId]): string {
15
15
  if (id === "codexStatusPercentage") return value === "used" ? USED : REMAINING;
@@ -29,11 +29,12 @@ export async function showUsageSettings(
29
29
  }
30
30
  const { HorizontalRule, renderBoundedFrame } = await import("@narumitw/pi-tui-kit");
31
31
  if (parentSignal.aborted || !isCurrent()) return false;
32
- return (
33
- (await ctx.ui.custom<boolean>((tui, theme, _keybindings, done) => {
32
+ let changed = false;
33
+ let selectedId: UsageSettingId | undefined;
34
+ for (;;) {
35
+ const result = await ctx.ui.custom<boolean | "consent">((tui, theme, _keybindings, done) => {
34
36
  const localController = new AbortController();
35
37
  const signal = AbortSignal.any([parentSignal, localController.signal]);
36
- let changed = false;
37
38
  let closing = false;
38
39
  let saveQueue = Promise.resolve();
39
40
  const state = settingsRuntime.get();
@@ -59,18 +60,28 @@ export async function showUsageSettings(
59
60
  currentValue: settingValueLabel("codexStatusPercentage", state.settings.codexStatusPercentage),
60
61
  values: [REMAINING, USED],
61
62
  },
63
+ {
64
+ id: "openaiCompanionUsage",
65
+ label: "Experimental ChatGPT companion usage",
66
+ description:
67
+ "Read undocumented plan/app usage using a same-account Codex login; matching registration does not prove identity.",
68
+ currentValue: state.settings.openaiCompanionUsage ? ON : OFF,
69
+ values: [OFF, ON],
70
+ },
62
71
  ];
63
72
  const rule = new HorizontalRule({ ruleStyle: (text) => theme.fg("border", text) });
64
73
 
65
74
  let settingsList: SettingsList;
66
- const cancel = () => {
75
+ const close = (result: boolean | "consent") => {
67
76
  if (closing) return;
68
77
  closing = true;
69
78
  localController.abort();
70
- done(changed);
79
+ done(result);
71
80
  };
81
+ const cancel = () => close(changed);
72
82
  const queueUpdate = <Id extends UsageSettingId>(id: Id, requested: UsageSettings[Id], display: string) => {
73
83
  saveQueue = saveQueue.then(async () => {
84
+ if (signal.aborted || !isCurrent()) return;
74
85
  const previous = settingsRuntime.get().settings[id];
75
86
  if (settingsRuntime.get().kind === "invalid") {
76
87
  settingsList.updateValue(id, settingValueLabel(id, previous));
@@ -81,6 +92,14 @@ export async function showUsageSettings(
81
92
  return;
82
93
  }
83
94
  try {
95
+ if (id === "openaiCompanionUsage" && requested === true && previous !== true) {
96
+ // Pi's confirm dialog replaces a non-overlay custom screen and restores
97
+ // the editor, not that screen. Complete/dispose Settings first; after
98
+ // consent, mount a fresh instance with the initiating row selected.
99
+ selectedId = id;
100
+ close("consent");
101
+ return;
102
+ }
84
103
  const patch: Partial<UsageSettings> = {};
85
104
  patch[id] = requested;
86
105
  await settingsRuntime.update(patch, signal);
@@ -91,6 +110,8 @@ export async function showUsageSettings(
91
110
  tui.requestRender();
92
111
  return;
93
112
  }
113
+ // A rename can win a cancellation race. Apply owned lifecycle cleanup after a
114
+ // durable save, but let the owner guard its session before touching UI.
94
115
  if (previous !== requested) {
95
116
  changed = true;
96
117
  onApplied(id);
@@ -106,15 +127,17 @@ export async function showUsageSettings(
106
127
  getSettingsListTheme(),
107
128
  (id, value) => {
108
129
  if (closing || signal.aborted || !isCurrent()) return;
130
+ selectedId = id as UsageSettingId;
109
131
  if (id === "codexStatusPercentage") {
110
132
  queueUpdate(id, value === USED ? "used" : "remaining", value);
111
- } else if (id === "codexFastMode" || id === "codexStatusResetCountdown") {
133
+ } else if (id === "codexFastMode" || id === "codexStatusResetCountdown" || id === "openaiCompanionUsage") {
112
134
  queueUpdate(id, value === ON, value);
113
135
  }
114
136
  },
115
137
  cancel,
116
138
  );
117
139
 
140
+ if (selectedId) settingsList.selectItem(selectedId);
118
141
  parentSignal.addEventListener("abort", cancel, { once: true });
119
142
  return {
120
143
  render(width: number) {
@@ -147,6 +170,25 @@ export async function showUsageSettings(
147
170
  parentSignal.removeEventListener("abort", cancel);
148
171
  },
149
172
  };
150
- })) ?? false
151
- );
173
+ });
174
+ if (result !== "consent" || parentSignal.aborted || !isCurrent()) return changed;
175
+ try {
176
+ const accepted = await ctx.ui.confirm(
177
+ "Enable experimental ChatGPT companion usage?",
178
+ "Requires /login openai-codex with the same ChatGPT account/workspace as native OpenAI. Only the companion token and its matching account ID are sent to undocumented ChatGPT usage endpoints. Registration matching is not independent proof of the same user/workspace. No reset or allowance mutations are performed.",
179
+ { signal: parentSignal },
180
+ );
181
+ if (parentSignal.aborted || !isCurrent()) return changed;
182
+ if (accepted && !settingsRuntime.get().settings.openaiCompanionUsage) {
183
+ await settingsRuntime.update({ openaiCompanionUsage: true }, parentSignal);
184
+ changed = true;
185
+ // A durable rename may win cancellation; the owner guards lifecycle cleanup.
186
+ onApplied("openaiCompanionUsage");
187
+ }
188
+ } catch (error) {
189
+ if (parentSignal.aborted || !isCurrent()) return changed;
190
+ ctx.ui.notify(`Could not enable ChatGPT companion usage: ${errorMessage(error)}`, "error");
191
+ }
192
+ if (parentSignal.aborted || !isCurrent()) return changed;
193
+ }
152
194
  }
package/src/usage.ts CHANGED
@@ -21,7 +21,13 @@ import {
21
21
  import { abortError, awaitWithDeadline, errorMessage, runWithConcurrency, UsageCache } from "./core.js";
22
22
  import { formatProviderStates, formatUsageStatusline } from "./format.js";
23
23
  import { createOAuthCredentialCandidateReader } from "./oauth-credential-source.js";
24
- import { adapterForProvider, isStaleExtensionContextError, queryProviderUsage, resolveUsageAuth } from "./query.js";
24
+ import { UnsupportedOpenAIUsageAuthError } from "./providers/openai-chatgpt.js";
25
+ import {
26
+ adapterForProvider,
27
+ isStaleExtensionContextError,
28
+ queryProviderUsage,
29
+ resolveUsageAuth as resolveRuntimeUsageAuth,
30
+ } from "./query.js";
25
31
  import { createUsageSettingsRuntime, type UsageSettingsRuntime, type UsageSettingsState } from "./settings.js";
26
32
  import type {
27
33
  PiModel,
@@ -65,7 +71,7 @@ type UsageExtensionDependencies = {
65
71
  type QueryOutcome = {
66
72
  state: ProviderUsageState;
67
73
  fingerprint?: string;
68
- authState?: "unavailable";
74
+ authState?: "unavailable" | "unsupported" | "failed";
69
75
  rememberedTargetId?: string;
70
76
  };
71
77
 
@@ -83,10 +89,20 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
83
89
  const credentialCandidates = createOAuthCredentialCandidateReader(pi, credentialReader);
84
90
  const createRedemptionId = dependencies.createRedemptionId ?? randomUUID;
85
91
  const settingsRuntime = dependencies.settingsRuntime ?? createUsageSettingsRuntime();
92
+ const resolveUsageAuth: typeof resolveRuntimeUsageAuth = (ctx, adapter, salt, reader, candidates) =>
93
+ resolveRuntimeUsageAuth(
94
+ ctx,
95
+ adapter,
96
+ salt,
97
+ reader,
98
+ candidates,
99
+ settingsRuntime.get().settings.openaiCompanionUsage,
100
+ );
86
101
  const cache = new UsageCache(CACHE_TTL_MS);
87
102
  const failureBackoff = new Map<string, { until: number; message: string }>();
88
103
  const latestQueries = new Map<string, number>();
89
104
  const activeControllers = new Set<AbortController>();
105
+ const nativeQueryControllers = new Set<AbortController>();
90
106
  let querySequence = 0;
91
107
  let activeCurrentIdentity: string | undefined;
92
108
  let sessionActive = false;
@@ -182,7 +198,7 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
182
198
  if (shouldSchedule && sessionActive) scheduleStatusRefresh(ctx, model);
183
199
  if (
184
200
  sessionActive &&
185
- showCodexResetCountdown &&
201
+ (showCodexResetCountdown || outcome.state.report.source === "openai-chatgpt-companion") &&
186
202
  outcome.state.report.buckets.some(
187
203
  (bucket) => bucket.resetsAt !== undefined && Number.isFinite(bucket.resetsAt) && bucket.resetsAt * 1_000 > now,
188
204
  )
@@ -219,7 +235,7 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
219
235
  activeCurrentIdentity = nextIdentity;
220
236
  };
221
237
 
222
- const queryAdapterState = async (
238
+ const queryAdapterStateInternal = async (
223
239
  ctx: ExtensionContext,
224
240
  adapter: UsageProviderAdapter,
225
241
  displayState: UsageDisplayState,
@@ -232,6 +248,7 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
232
248
  const expectedSessionId = ctx.sessionManager.getSessionId();
233
249
  const expectedModelIdentity = modelIdentity(ctx.model);
234
250
  const expectedTargetId = adapter.targets ? settingsRuntime.get().settings.selectedTargets[adapter.id] : undefined;
251
+ const expectedCompanionUsage = settingsRuntime.get().settings.openaiCompanionUsage;
235
252
  const providerName = providerDisplayName(ctx, adapter.id);
236
253
  let auth: ResolvedUsageAuth | undefined;
237
254
  try {
@@ -243,6 +260,13 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
243
260
  );
244
261
  } catch (error) {
245
262
  if (isStaleExtensionContextError(error) || isAbortError(error)) throw error;
263
+ if (adapter.id === "openai") invalidateProviderState(adapter.id);
264
+ const authState =
265
+ error instanceof UnsupportedOpenAIUsageAuthError
266
+ ? "unsupported"
267
+ : adapter.id === "openai" && !isTimeoutError(error)
268
+ ? "failed"
269
+ : undefined;
246
270
  if (displayState === "current") {
247
271
  transitionCurrentIdentity(`${adapter.id}:auth-error`, adapter.id);
248
272
  }
@@ -251,9 +275,11 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
251
275
  providerId: adapter.id,
252
276
  providerName,
253
277
  displayState,
254
- status: isTimeoutError(error) ? "query-failed" : "auth-unavailable",
278
+ status:
279
+ authState === "unsupported" ? "unsupported" : isTimeoutError(error) ? "query-failed" : "auth-unavailable",
255
280
  message: errorMessage(error),
256
281
  },
282
+ ...(authState ? { authState } : {}),
257
283
  };
258
284
  }
259
285
  const requiresRequestBoundaryGuard =
@@ -265,6 +291,7 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
265
291
  "minimax-cn",
266
292
  "moonshotai",
267
293
  "moonshotai-cn",
294
+ "openai",
268
295
  "vercel-ai-gateway",
269
296
  "xai",
270
297
  "zai",
@@ -274,10 +301,12 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
274
301
  expectedSessionGeneration !== sessionGeneration ||
275
302
  ctx.sessionManager.getSessionId() !== expectedSessionId ||
276
303
  modelIdentity(ctx.model) !== expectedModelIdentity ||
304
+ (adapter.id === "openai" && settingsRuntime.get().settings.openaiCompanionUsage !== expectedCompanionUsage) ||
277
305
  (adapter.targets !== undefined &&
278
306
  settingsRuntime.get().settings.selectedTargets[adapter.id] !== expectedTargetId);
279
307
  if (requiresRequestBoundaryGuard && requestContextChanged()) throw abortError();
280
308
  if (!auth) {
309
+ if (adapter.id === "openai") invalidateProviderState(adapter.id);
281
310
  if (displayState === "current") {
282
311
  transitionCurrentIdentity(`${adapter.id}:unavailable`, adapter.id);
283
312
  }
@@ -292,6 +321,7 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
292
321
  authState: "unavailable",
293
322
  };
294
323
  }
324
+ if (adapter.id === "openai" && !auth.openaiCompanion) invalidateProviderState(adapter.id);
295
325
  let retryableAuthChanged = false;
296
326
  const guard = async () => {
297
327
  if (signal.aborted || requestContextChanged()) throw abortError();
@@ -456,6 +486,25 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
456
486
  }
457
487
  };
458
488
 
489
+ const queryAdapterState = async (...args: Parameters<typeof queryAdapterStateInternal>): Promise<QueryOutcome> => {
490
+ const [ctx, adapter, displayState, force, signal, ...rest] = args;
491
+ if (adapter.id !== "openai") return queryAdapterStateInternal(...args);
492
+ const controller = new AbortController();
493
+ nativeQueryControllers.add(controller);
494
+ try {
495
+ return await queryAdapterStateInternal(
496
+ ctx,
497
+ adapter,
498
+ displayState,
499
+ force,
500
+ AbortSignal.any([signal, controller.signal]),
501
+ ...rest,
502
+ );
503
+ } finally {
504
+ nativeQueryControllers.delete(controller);
505
+ }
506
+ };
507
+
459
508
  const loadTargetChoices = async (ctx: ExtensionContext, adapter: UsageProviderAdapter, signal: AbortSignal) => {
460
509
  if (!adapter.targets) throw new Error("Provider does not support usage targets.");
461
510
  const expectedSessionGeneration = sessionGeneration;
@@ -598,11 +647,11 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
598
647
  if (generation !== statusGeneration || modelIdentity(ctx.model) !== modelIdentity(model)) {
599
648
  return false;
600
649
  }
601
- const adapter = adapterForProvider(model?.provider);
650
+ const adapter = adapterForProvider(outcome.state.providerId);
602
651
  const selectionStillCurrent =
603
652
  !adapter?.targets || settingsRuntime.get().settings.selectedTargets[adapter.id] === outcome.rememberedTargetId;
604
653
  if (!selectionStillCurrent) return false;
605
- if (outcome.authState === "unavailable") {
654
+ if (outcome.authState) {
606
655
  if (!adapter) return false;
607
656
  try {
608
657
  const auth = await awaitWithDeadline(
@@ -616,11 +665,27 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
616
665
  modelIdentity(ctx.model) === modelIdentity(model) &&
617
666
  (!adapter.targets ||
618
667
  settingsRuntime.get().settings.selectedTargets[adapter.id] === outcome.rememberedTargetId) &&
668
+ outcome.authState === "unavailable" &&
619
669
  auth === undefined
620
670
  );
621
671
  } catch (error) {
622
672
  if (isAbortError(error) || isStaleExtensionContextError(error)) throw error;
623
- return false;
673
+ // Stable failures are still useful. A recovered credential or changed reason must
674
+ // retry, but confirming the same native failure must not create a refresh loop.
675
+ const unchangedFailure =
676
+ (outcome.authState === "unsupported" && error instanceof UnsupportedOpenAIUsageAuthError) ||
677
+ (outcome.authState === "failed" &&
678
+ outcome.state.status === "auth-unavailable" &&
679
+ !(error instanceof UnsupportedOpenAIUsageAuthError) &&
680
+ !isTimeoutError(error) &&
681
+ errorMessage(error) === outcome.state.message);
682
+ return (
683
+ unchangedFailure &&
684
+ generation === statusGeneration &&
685
+ modelIdentity(ctx.model) === modelIdentity(model) &&
686
+ (!adapter.targets ||
687
+ settingsRuntime.get().settings.selectedTargets[adapter.id] === outcome.rememberedTargetId)
688
+ );
624
689
  }
625
690
  }
626
691
  if (!outcome.fingerprint) return true;
@@ -645,6 +710,27 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
645
710
  }
646
711
  };
647
712
 
713
+ const queryStableAdapterState = async (
714
+ ctx: ExtensionContext,
715
+ adapter: UsageProviderAdapter,
716
+ displayState: UsageDisplayState,
717
+ force: boolean,
718
+ signal: AbortSignal,
719
+ ): Promise<QueryOutcome> => {
720
+ if (adapter.id !== "openai") return queryAdapterState(ctx, adapter, displayState, force, signal);
721
+ // Configured/all-provider views must confirm native auth failures too, independently
722
+ // of the selected provider's subsequent revalidation.
723
+ for (let attempt = 0; attempt < 3; attempt += 1) {
724
+ const model = ctx.model;
725
+ const generation = statusGeneration;
726
+ const outcome = await queryAdapterState(ctx, adapter, displayState, force, signal);
727
+ if (signal.aborted) throw abortError();
728
+ if (await outcomeStillCurrent(ctx, model, generation, outcome, signal)) return outcome;
729
+ force = false;
730
+ }
731
+ throw new Error("OpenAI runtime authentication kept changing; reopen /usage to retry.");
732
+ };
733
+
648
734
  const queryStableCurrent = async (
649
735
  ctx: ExtensionCommandContext,
650
736
  force: boolean,
@@ -976,7 +1062,7 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
976
1062
  ctx,
977
1063
  `Checking ${providerDisplayName(ctx, adapter.id)} usage…`,
978
1064
  controller.signal,
979
- (signal) => queryAdapterState(ctx, adapter, "configured", true, signal),
1065
+ (signal) => queryStableAdapterState(ctx, adapter, "configured", true, signal),
980
1066
  );
981
1067
  if (!outcome) return { kind: "stay" };
982
1068
  const revalidated = await queryStableCurrent(ctx, false, controller, "Revalidating current usage…");
@@ -1004,6 +1090,20 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
1004
1090
  controller.signal,
1005
1091
  () => statusGeneration === menuGeneration && !controller.signal.aborted,
1006
1092
  (id) => {
1093
+ if (
1094
+ id === "openaiCompanionUsage" &&
1095
+ statusGeneration === menuGeneration &&
1096
+ !controller.signal.aborted
1097
+ ) {
1098
+ for (const active of nativeQueryControllers) active.abort();
1099
+ invalidateProviderState("openai");
1100
+ if (ctx.model?.provider === "openai") {
1101
+ statusController?.abort();
1102
+ statusController = undefined;
1103
+ clearStatusTimers();
1104
+ safeSetStatus(ctx, undefined);
1105
+ }
1106
+ }
1007
1107
  if (
1008
1108
  (id === "codexStatusResetCountdown" || id === "codexStatusPercentage") &&
1009
1109
  stableCurrent &&
@@ -1189,7 +1289,7 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
1189
1289
  ctx,
1190
1290
  `Checking ${providerDisplayName(ctx, adapter.id)} usage…`,
1191
1291
  controller.signal,
1192
- (signal) => queryAdapterState(ctx, adapter, "configured", false, signal),
1292
+ (signal) => queryStableAdapterState(ctx, adapter, "configured", false, signal),
1193
1293
  );
1194
1294
  if (!outcome) return { kind: "back" };
1195
1295
  if (outcome.state.status === "selection-required") {
@@ -1200,12 +1300,23 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
1200
1300
  ctx,
1201
1301
  `Checking ${providerDisplayName(ctx, adapter.id)} usage…`,
1202
1302
  controller.signal,
1203
- (signal) => queryAdapterState(ctx, adapter, "configured", true, signal),
1303
+ (signal) => queryStableAdapterState(ctx, adapter, "configured", true, signal),
1204
1304
  );
1205
1305
  if (!outcome) return { kind: "back" };
1206
1306
  }
1207
1307
  const revalidated = await queryStableCurrent(ctx, false, controller, "Revalidating current usage…");
1208
1308
  if (!revalidated) return { kind: "back" };
1309
+ // Current-provider revalidation can await unrelated work after the configured
1310
+ // native report was checked. Refresh its identity at the publication boundary.
1311
+ if (adapter.id === "openai") {
1312
+ outcome = await runMenuOperation(
1313
+ ctx,
1314
+ "Revalidating ChatGPT companion usage…",
1315
+ controller.signal,
1316
+ (signal) => queryStableAdapterState(ctx, adapter, "configured", false, signal),
1317
+ );
1318
+ if (!outcome) return { kind: "back" };
1319
+ }
1209
1320
  stableCurrent = revalidated;
1210
1321
  current = revalidated.outcome;
1211
1322
  visibleStates = [
@@ -1227,7 +1338,7 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
1227
1338
  adapters,
1228
1339
  ALL_PROVIDER_CONCURRENCY,
1229
1340
  (adapter, _index, workerSignal) =>
1230
- queryAdapterState(
1341
+ queryStableAdapterState(
1231
1342
  ctx,
1232
1343
  adapter,
1233
1344
  adapter.id === currentProviderId ? "current" : "configured",
@@ -1253,6 +1364,22 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
1253
1364
  });
1254
1365
  const revalidated = await queryStableCurrent(ctx, false, controller, "Revalidating current usage…");
1255
1366
  if (!revalidated) return { kind: "stay" };
1367
+ const nativeIndex = adapters.findIndex(
1368
+ (adapter) => adapter.id === "openai" && adapter.id !== currentProviderId,
1369
+ );
1370
+ if (nativeIndex >= 0) {
1371
+ const nativeAdapter = adapters[nativeIndex];
1372
+ if (nativeAdapter) {
1373
+ const native = await runMenuOperation(
1374
+ ctx,
1375
+ "Revalidating ChatGPT companion usage…",
1376
+ controller.signal,
1377
+ (signal) => queryStableAdapterState(ctx, nativeAdapter, "configured", false, signal),
1378
+ );
1379
+ if (!native) return { kind: "stay" };
1380
+ queriedStates[nativeIndex] = native.state;
1381
+ }
1382
+ }
1256
1383
  stableCurrent = revalidated;
1257
1384
  current = revalidated.outcome;
1258
1385
  visibleStates = [
@@ -1309,6 +1436,8 @@ export default function usageExtension(pi: ExtensionAPI, dependencies: UsageExte
1309
1436
  startStatusRefresh(ctx, ctx.model, false);
1310
1437
  });
1311
1438
  pi.on("model_select", (event, ctx) => {
1439
+ // Cancel read-only native queries, not confirmed legacy reset mutations.
1440
+ for (const controller of nativeQueryControllers) controller.abort();
1312
1441
  startStatusRefresh(ctx, event.model, false);
1313
1442
  });
1314
1443
  pi.on("turn_start", (_event, ctx) => {