@narumitw/pi-usage 0.52.3 → 0.54.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.
@@ -0,0 +1,128 @@
1
+ import {
2
+ type ExtensionCommandContext,
3
+ getSettingsListTheme,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import {
6
+ Container,
7
+ Key,
8
+ matchesKey,
9
+ type SettingItem,
10
+ SettingsList,
11
+ Text,
12
+ } from "@earendil-works/pi-tui";
13
+ import { errorMessage } from "./core.js";
14
+ import type { UsageSettings, UsageSettingsRuntime } from "./settings.js";
15
+
16
+ const OFF = "Off";
17
+ const ON = "On";
18
+
19
+ type UsageSettingId = keyof UsageSettings;
20
+
21
+ export async function showUsageSettings(
22
+ ctx: ExtensionCommandContext,
23
+ settingsRuntime: UsageSettingsRuntime,
24
+ parentSignal: AbortSignal,
25
+ isCurrent: () => boolean,
26
+ onApplied: (id: UsageSettingId, previous: boolean, next: boolean) => void,
27
+ ): Promise<boolean> {
28
+ if (ctx.mode !== "tui") {
29
+ if (ctx.hasUI) ctx.ui.notify(`Edit settings manually: ${settingsRuntime.get().path}`, "info");
30
+ return false;
31
+ }
32
+ if (parentSignal.aborted || !isCurrent()) return false;
33
+
34
+ return ctx.ui.custom<boolean>((tui, theme, _keybindings, done) => {
35
+ const localController = new AbortController();
36
+ const signal = AbortSignal.any([parentSignal, localController.signal]);
37
+ let changed = false;
38
+ let closing = false;
39
+ let saveQueue = Promise.resolve();
40
+ const state = settingsRuntime.get();
41
+ const items: SettingItem[] = [
42
+ {
43
+ id: "codexFastMode",
44
+ label: "Codex Fast mode",
45
+ description: "Use faster Codex routing at increased plan allowance consumption.",
46
+ currentValue: state.settings.codexFastMode ? ON : OFF,
47
+ values: [OFF, ON],
48
+ },
49
+ {
50
+ id: "xaiUsage",
51
+ label: "xAI usage",
52
+ description: "Report OAuth subscription allowance and credits.",
53
+ currentValue: state.kind !== "invalid" && state.settings.xaiUsage ? ON : OFF,
54
+ values: [OFF, ON],
55
+ },
56
+ ];
57
+ const container = new Container();
58
+ container.addChild(new Text(theme.fg("accent", theme.bold("pi-usage Settings")), 1, 1));
59
+
60
+ let settingsList: SettingsList;
61
+ const cancel = () => {
62
+ if (closing) return;
63
+ closing = true;
64
+ localController.abort();
65
+ done(changed);
66
+ };
67
+ settingsList = new SettingsList(
68
+ items,
69
+ items.length + 2,
70
+ getSettingsListTheme(),
71
+ (id, value) => {
72
+ if (closing || signal.aborted || !isCurrent()) return;
73
+ const settingId = id as UsageSettingId;
74
+ const requested = value !== OFF;
75
+ saveQueue = saveQueue.then(async () => {
76
+ const previous = settingsRuntime.get().settings[settingId];
77
+ if (settingsRuntime.get().kind === "invalid") {
78
+ const effectivePrevious = settingId === "xaiUsage" ? false : previous;
79
+ settingsList.updateValue(id, displayValue(settingId, effectivePrevious));
80
+ if (!signal.aborted && isCurrent()) {
81
+ ctx.ui.notify("Repair pi-usage.json and reload before changing settings.", "error");
82
+ tui.requestRender();
83
+ }
84
+ return;
85
+ }
86
+ try {
87
+ await settingsRuntime.update({ [settingId]: requested }, signal);
88
+ } catch (error) {
89
+ if (signal.aborted || !isCurrent()) return;
90
+ settingsList.updateValue(id, displayValue(settingId, previous));
91
+ ctx.ui.notify(`Could not save pi-usage.json: ${errorMessage(error)}`, "error");
92
+ tui.requestRender();
93
+ return;
94
+ }
95
+ if (previous !== requested) {
96
+ changed = true;
97
+ onApplied(settingId, previous, requested);
98
+ }
99
+ if (signal.aborted || !isCurrent()) return;
100
+ settingsList.updateValue(id, displayValue(settingId, requested));
101
+ tui.requestRender();
102
+ });
103
+ },
104
+ cancel,
105
+ );
106
+ container.addChild(settingsList);
107
+
108
+ parentSignal.addEventListener("abort", cancel, { once: true });
109
+ return {
110
+ render: (width: number) => container.render(width),
111
+ invalidate: () => container.invalidate(),
112
+ handleInput(data: string) {
113
+ if (closing) return;
114
+ if (matchesKey(data, Key.ctrl("c"))) cancel();
115
+ else settingsList.handleInput(data);
116
+ tui.requestRender();
117
+ },
118
+ dispose() {
119
+ localController.abort();
120
+ parentSignal.removeEventListener("abort", cancel);
121
+ },
122
+ };
123
+ });
124
+ }
125
+
126
+ function displayValue(_id: UsageSettingId, enabled: boolean): string {
127
+ return enabled ? ON : OFF;
128
+ }
package/src/usage.ts CHANGED
@@ -1,3 +1,5 @@
1
+ // This orchestrator intentionally remains over 1,000 lines because its menu, query generations,
2
+ // account cache, cancellation, and session lifecycle share one consistency boundary.
1
3
  import { randomUUID } from "node:crypto";
2
4
  import type {
3
5
  ExtensionAPI,
@@ -20,7 +22,13 @@ import {
20
22
  resetOptionExpiration,
21
23
  resolveCodexResetAuth,
22
24
  } from "./codex-resets.js";
23
- import { awaitWithDeadline, errorMessage, runWithConcurrency, UsageCache } from "./core.js";
25
+ import {
26
+ abortError,
27
+ awaitWithDeadline,
28
+ errorMessage,
29
+ runWithConcurrency,
30
+ UsageCache,
31
+ } from "./core.js";
24
32
  import { formatProviderStates, formatUsageStatusline } from "./format.js";
25
33
  import { createOAuthCredentialCandidateReader } from "./oauth-credential-source.js";
26
34
  import {
@@ -45,6 +53,7 @@ import {
45
53
  providerDisplayName,
46
54
  setBoundedMap,
47
55
  } from "./usage-helpers.js";
56
+ import { showUsageSettings } from "./usage-settings-ui.js";
48
57
 
49
58
  const CACHE_TTL_MS = 5 * 60 * 1000;
50
59
  const DEFAULT_TIMEOUT_MS = 15_000;
@@ -57,6 +66,7 @@ const REFRESH_CURRENT = "Refresh current usage";
57
66
  const VIEW_ANOTHER = "View another configured provider…";
58
67
  const VIEW_ALL = "View all configured providers…";
59
68
  const CLOSE = "Close";
69
+ const SETTINGS = "Settings";
60
70
  const REDEEM_CODEX_RESET = "Redeem usage limit reset…";
61
71
 
62
72
  type UsageExtensionDependencies = {
@@ -92,10 +102,19 @@ export default function usageExtension(
92
102
  let activeCurrentIdentity: string | undefined;
93
103
  let sessionActive = false;
94
104
  let statusGeneration = 0;
105
+ let sessionGeneration = 0;
106
+ let xaiSettingsGeneration = 0;
95
107
  let statusRefreshTimer: ReturnType<typeof setTimeout> | undefined;
96
108
  let statusController: AbortController | undefined;
97
109
  let fastRuntime: ReturnType<typeof registerCodexFastMode>;
98
110
 
111
+ const xaiUsageEnabled = () => {
112
+ const state = settingsRuntime.get();
113
+ return state.kind !== "invalid" && state.settings.xaiUsage;
114
+ };
115
+ const activeAdapterForProvider = (providerId: string | undefined) =>
116
+ adapterForProvider(providerId, xaiUsageEnabled());
117
+
99
118
  const clearStatusTimer = () => {
100
119
  if (statusRefreshTimer) clearTimeout(statusRefreshTimer);
101
120
  statusRefreshTimer = undefined;
@@ -136,6 +155,11 @@ export default function usageExtension(
136
155
  model: PiModel,
137
156
  shouldSchedule: boolean,
138
157
  ) => {
158
+ if (activeAdapterForProvider(model.provider)?.publishesStatusline === false) {
159
+ clearStatusTimer();
160
+ safeSetStatus(ctx, undefined);
161
+ return;
162
+ }
139
163
  if (outcome.state.status === "unsupported") {
140
164
  clearStatusTimer();
141
165
  safeSetStatus(ctx, undefined);
@@ -188,6 +212,10 @@ export default function usageExtension(
188
212
  signal: AbortSignal,
189
213
  ): Promise<QueryOutcome> => {
190
214
  const startedAt = Date.now();
215
+ const expectedSessionGeneration = sessionGeneration;
216
+ const expectedXaiSettingsGeneration = xaiSettingsGeneration;
217
+ const expectedSessionId = ctx.sessionManager.getSessionId();
218
+ const expectedModelIdentity = modelIdentity(ctx.model);
191
219
  let auth: ResolvedUsageAuth | undefined;
192
220
  try {
193
221
  auth = await awaitWithDeadline(
@@ -211,6 +239,16 @@ export default function usageExtension(
211
239
  },
212
240
  };
213
241
  }
242
+ if (
243
+ adapter.id === "xai" &&
244
+ (expectedSessionGeneration !== sessionGeneration ||
245
+ expectedXaiSettingsGeneration !== xaiSettingsGeneration ||
246
+ !xaiUsageEnabled() ||
247
+ ctx.sessionManager.getSessionId() !== expectedSessionId ||
248
+ modelIdentity(ctx.model) !== expectedModelIdentity)
249
+ ) {
250
+ throw abortError();
251
+ }
214
252
  if (!auth) {
215
253
  if (displayState === "current") {
216
254
  transitionCurrentIdentity(`${adapter.id}:unavailable`, adapter.id);
@@ -265,7 +303,40 @@ export default function usageExtension(
265
303
 
266
304
  try {
267
305
  const remainingMs = Math.max(1, DEFAULT_TIMEOUT_MS - (Date.now() - startedAt));
268
- const report = await queryProviderUsage(adapter, auth, signal, remainingMs);
306
+ const guard =
307
+ adapter.id === "xai"
308
+ ? async () => {
309
+ if (
310
+ signal.aborted ||
311
+ expectedSessionGeneration !== sessionGeneration ||
312
+ expectedXaiSettingsGeneration !== xaiSettingsGeneration ||
313
+ !xaiUsageEnabled() ||
314
+ ctx.sessionManager.getSessionId() !== expectedSessionId ||
315
+ modelIdentity(ctx.model) !== expectedModelIdentity
316
+ ) {
317
+ throw abortError();
318
+ }
319
+ const revalidated = await awaitWithDeadline(
320
+ resolveUsageAuth(ctx, adapter, undefined, credentialReader, credentialCandidates),
321
+ signal,
322
+ Math.max(1, DEFAULT_TIMEOUT_MS - (Date.now() - startedAt)),
323
+ "revalidating xAI runtime auth",
324
+ );
325
+ if (
326
+ signal.aborted ||
327
+ expectedSessionGeneration !== sessionGeneration ||
328
+ expectedXaiSettingsGeneration !== xaiSettingsGeneration ||
329
+ !xaiUsageEnabled() ||
330
+ ctx.sessionManager.getSessionId() !== expectedSessionId ||
331
+ modelIdentity(ctx.model) !== expectedModelIdentity ||
332
+ revalidated?.fingerprint !== auth.fingerprint
333
+ ) {
334
+ throw abortError();
335
+ }
336
+ }
337
+ : undefined;
338
+ const report = await queryProviderUsage(adapter, auth, signal, remainingMs, guard);
339
+ if (guard) await guard();
269
340
  if (latestQueries.get(failureKey) === queryId) {
270
341
  cache.set(adapter.id, auth.fingerprint, report);
271
342
  failureBackoff.delete(failureKey);
@@ -314,7 +385,7 @@ export default function usageExtension(
314
385
  force: boolean,
315
386
  signal: AbortSignal,
316
387
  ): Promise<QueryOutcome> => {
317
- const adapter = adapterForProvider(model?.provider);
388
+ const adapter = activeAdapterForProvider(model?.provider);
318
389
  if (!adapter) {
319
390
  const providerId = model?.provider ?? "none";
320
391
  transitionCurrentIdentity(`unsupported:${providerId}`, providerId);
@@ -324,9 +395,12 @@ export default function usageExtension(
324
395
  providerName: providerDisplayName(ctx, providerId),
325
396
  displayState: "current",
326
397
  status: "unsupported",
327
- message: model
328
- ? `Usage reporting is not supported for ${providerDisplayName(ctx, providerId)}.`
329
- : "No model is selected.",
398
+ message:
399
+ providerId === "xai" && !xaiUsageEnabled()
400
+ ? "xAI usage is disabled. Open Settings to enable it."
401
+ : model
402
+ ? `Usage reporting is not supported for ${providerDisplayName(ctx, providerId)}.`
403
+ : "No model is selected.",
330
404
  },
331
405
  };
332
406
  }
@@ -338,13 +412,17 @@ export default function usageExtension(
338
412
  model: PiModel | undefined,
339
413
  force: boolean,
340
414
  ) => {
341
- const adapter = adapterForProvider(model?.provider);
415
+ const adapter = activeAdapterForProvider(model?.provider);
342
416
  if (!adapter || !model) {
343
417
  const providerId = model?.provider ?? "none";
344
418
  transitionCurrentIdentity(`unsupported:${providerId}`, providerId);
345
419
  clearStatus(ctx);
346
420
  return;
347
421
  }
422
+ if (adapter.publishesStatusline === false) {
423
+ clearStatus(ctx);
424
+ return;
425
+ }
348
426
  statusGeneration += 1;
349
427
  const generation = statusGeneration;
350
428
  statusController?.abort();
@@ -416,7 +494,7 @@ export default function usageExtension(
416
494
  if (generation !== statusGeneration || modelIdentity(ctx.model) !== modelIdentity(model)) {
417
495
  return false;
418
496
  }
419
- const adapter = adapterForProvider(model?.provider);
497
+ const adapter = activeAdapterForProvider(model?.provider);
420
498
  if (outcome.authState === "unavailable") {
421
499
  if (!adapter) return false;
422
500
  try {
@@ -524,6 +602,7 @@ export default function usageExtension(
524
602
  | "reset-error";
525
603
  type Action =
526
604
  | "refresh"
605
+ | "settings"
527
606
  | "toggle-fast"
528
607
  | "another"
529
608
  | "all"
@@ -551,6 +630,7 @@ export default function usageExtension(
551
630
  lines: [...formatProviderStates(visibleStates).split("\n"), ...fastLines],
552
631
  items: [
553
632
  { id: "refresh", label: REFRESH_CURRENT, action: "refresh" },
633
+ { id: "settings", label: SETTINGS, action: "settings" },
554
634
  ...(fastAvailability.kind === "available"
555
635
  ? [
556
636
  {
@@ -588,7 +668,7 @@ export default function usageExtension(
588
668
  providers: () => ({
589
669
  kind: "actions",
590
670
  title: "Select a configured provider",
591
- items: configuredAdapters(ctx)
671
+ items: configuredAdapters(ctx, xaiUsageEnabled())
592
672
  .filter((adapter) => adapter.id !== ctx.model?.provider)
593
673
  .map((adapter) => ({
594
674
  id: adapter.id,
@@ -656,6 +736,37 @@ export default function usageExtension(
656
736
  }),
657
737
  },
658
738
  actions: {
739
+ settings: async () => {
740
+ await showUsageSettings(
741
+ ctx,
742
+ settingsRuntime,
743
+ controller.signal,
744
+ () => statusGeneration === menuGeneration && !controller.signal.aborted,
745
+ (id, _previous, next) => {
746
+ if (id !== "xaiUsage") return;
747
+ xaiSettingsGeneration += 1;
748
+ invalidateProviderState("xai");
749
+ if (!next) {
750
+ for (const active of activeControllers) {
751
+ if (active !== controller) active.abort();
752
+ }
753
+ }
754
+ },
755
+ );
756
+ fastState = settingsRuntime.get();
757
+ const revalidated = await queryStableCurrent(
758
+ ctx,
759
+ false,
760
+ controller,
761
+ "Applying usage settings…",
762
+ );
763
+ if (!revalidated) return { kind: "stay" };
764
+ stableCurrent = revalidated;
765
+ current = revalidated.outcome;
766
+ visibleStates = [current.state];
767
+ publishStableCurrent(ctx, revalidated);
768
+ return { kind: "stay" };
769
+ },
659
770
  "toggle-fast": async () => {
660
771
  const availability = fastRuntime.availability(ctx.model);
661
772
  if (availability.kind !== "available" || fastState.kind === "invalid") {
@@ -835,7 +946,7 @@ export default function usageExtension(
835
946
  return { kind: "stay" };
836
947
  },
837
948
  another: async () => {
838
- const others = configuredAdapters(ctx).filter(
949
+ const others = configuredAdapters(ctx, xaiUsageEnabled()).filter(
839
950
  (adapter) => adapter.id !== ctx.model?.provider,
840
951
  );
841
952
  if (others.length === 0) {
@@ -845,7 +956,7 @@ export default function usageExtension(
845
956
  return { kind: "to", screen: "providers" };
846
957
  },
847
958
  provider: async ({ itemId }) => {
848
- const adapter = configuredAdapters(ctx).find(
959
+ const adapter = configuredAdapters(ctx, xaiUsageEnabled()).find(
849
960
  (candidate) => candidate.id === itemId && candidate.id !== ctx.model?.provider,
850
961
  );
851
962
  if (!adapter) return { kind: "back" };
@@ -873,7 +984,7 @@ export default function usageExtension(
873
984
  return { kind: "back" };
874
985
  },
875
986
  all: async () => {
876
- const adapters = configuredAdapters(ctx);
987
+ const adapters = configuredAdapters(ctx, xaiUsageEnabled());
877
988
  const currentProviderId = ctx.model?.provider;
878
989
  const settled = await runMenuOperation(
879
990
  ctx,
@@ -955,6 +1066,8 @@ export default function usageExtension(
955
1066
  },
956
1067
  });
957
1068
  pi.on("session_start", (_event, ctx) => {
1069
+ sessionGeneration += 1;
1070
+ xaiSettingsGeneration += 1;
958
1071
  statusGeneration += 1;
959
1072
  clearStatusTimer();
960
1073
  for (const controller of activeControllers) controller.abort();
@@ -974,6 +1087,8 @@ export default function usageExtension(
974
1087
  });
975
1088
  pi.on("session_shutdown", (_event, ctx) => {
976
1089
  sessionActive = false;
1090
+ sessionGeneration += 1;
1091
+ xaiSettingsGeneration += 1;
977
1092
  statusGeneration += 1;
978
1093
  clearStatusTimer();
979
1094
  for (const controller of activeControllers) controller.abort();