@narumitw/pi-usage 0.49.3 → 0.51.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.
package/src/usage.ts CHANGED
@@ -1,24 +1,34 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import type {
2
3
  ExtensionAPI,
3
4
  ExtensionCommandContext,
4
5
  ExtensionContext,
5
6
  } from "@earendil-works/pi-coding-agent";
7
+ import { FAST_USAGE_WARNING, registerCodexFastMode } from "./codex-fast-runtime.js";
6
8
  import {
7
- awaitWithDeadline,
8
- errorMessage,
9
- runWithConcurrency,
10
- sanitizeDisplayText,
11
- UsageCache,
12
- } from "./core.js";
9
+ type CodexResetAvailability,
10
+ type CodexResetOption,
11
+ type CodexResetOutcome,
12
+ codexResetActionDescription,
13
+ codexResetCount,
14
+ consumeCodexResetCredit,
15
+ formatCodexResetOutcome,
16
+ genericCodexResetOption,
17
+ listCodexResetCredits,
18
+ resetConfirmationLines,
19
+ resetLabel,
20
+ resetOptionExpiration,
21
+ resolveCodexResetAuth,
22
+ } from "./codex-resets.js";
23
+ import { awaitWithDeadline, errorMessage, runWithConcurrency, UsageCache } from "./core.js";
13
24
  import { formatProviderStates, formatUsageStatusline } from "./format.js";
14
25
  import {
15
26
  adapterForProvider,
16
27
  isStaleExtensionContextError,
17
- providerIsConfigured,
18
28
  queryProviderUsage,
19
29
  resolveUsageAuth,
20
- SUPPORTED_ADAPTERS,
21
30
  } from "./query.js";
31
+ import { createUsageSettingsRuntime, type UsageSettingsRuntime } from "./settings.js";
22
32
  import type {
23
33
  PiModel,
24
34
  ProviderUsageState,
@@ -26,6 +36,14 @@ import type {
26
36
  UsageDisplayState,
27
37
  UsageProviderAdapter,
28
38
  } from "./types.js";
39
+ import {
40
+ configuredAdapters,
41
+ isAbortError,
42
+ isTimeoutError,
43
+ modelIdentity,
44
+ providerDisplayName,
45
+ setBoundedMap,
46
+ } from "./usage-helpers.js";
29
47
 
30
48
  const CACHE_TTL_MS = 5 * 60 * 1000;
31
49
  const DEFAULT_TIMEOUT_MS = 15_000;
@@ -38,6 +56,13 @@ const REFRESH_CURRENT = "Refresh current usage";
38
56
  const VIEW_ANOTHER = "View another configured provider…";
39
57
  const VIEW_ALL = "View all configured providers…";
40
58
  const CLOSE = "Close";
59
+ const REDEEM_CODEX_RESET = "Redeem usage limit reset…";
60
+
61
+ type UsageExtensionDependencies = {
62
+ credentialReader?: (providerId: string) => unknown;
63
+ createRedemptionId?: () => string;
64
+ settingsRuntime?: UsageSettingsRuntime;
65
+ };
41
66
 
42
67
  type QueryOutcome = {
43
68
  state: ProviderUsageState;
@@ -50,7 +75,13 @@ type StableCurrent = {
50
75
  model: PiModel | undefined;
51
76
  };
52
77
 
53
- export default function usageExtension(pi: ExtensionAPI) {
78
+ export default function usageExtension(
79
+ pi: ExtensionAPI,
80
+ dependencies: UsageExtensionDependencies = {},
81
+ ) {
82
+ const credentialReader = dependencies.credentialReader;
83
+ const createRedemptionId = dependencies.createRedemptionId ?? randomUUID;
84
+ const settingsRuntime = dependencies.settingsRuntime ?? createUsageSettingsRuntime();
54
85
  const cache = new UsageCache(CACHE_TTL_MS);
55
86
  const failureBackoff = new Map<string, { until: number; message: string }>();
56
87
  const latestQueries = new Map<string, number>();
@@ -61,6 +92,7 @@ export default function usageExtension(pi: ExtensionAPI) {
61
92
  let statusGeneration = 0;
62
93
  let statusRefreshTimer: ReturnType<typeof setTimeout> | undefined;
63
94
  let statusController: AbortController | undefined;
95
+ let fastRuntime: ReturnType<typeof registerCodexFastMode>;
64
96
 
65
97
  const clearStatusTimer = () => {
66
98
  if (statusRefreshTimer) clearTimeout(statusRefreshTimer);
@@ -118,11 +150,22 @@ export default function usageExtension(pi: ExtensionAPI) {
118
150
  }
119
151
  return;
120
152
  }
121
- const value = formatUsageStatusline(outcome.state.report, model);
153
+ const rawValue = formatUsageStatusline(outcome.state.report, model);
154
+ const value = rawValue ? fastRuntime.decorateStatus(model, rawValue) : undefined;
122
155
  if (!safeSetStatus(ctx, value)) return;
123
156
  if (shouldSchedule && sessionActive) scheduleStatusRefresh(ctx, model);
124
157
  };
125
158
 
159
+ const invalidateProviderState = (providerId: string) => {
160
+ cache.clearProvider(providerId);
161
+ for (const key of failureBackoff.keys()) {
162
+ if (key.startsWith(`${providerId}:`)) failureBackoff.delete(key);
163
+ }
164
+ for (const key of latestQueries.keys()) {
165
+ if (key.startsWith(`${providerId}:`)) latestQueries.delete(key);
166
+ }
167
+ };
168
+
126
169
  const transitionCurrentIdentity = (nextIdentity: string, providerId: string) => {
127
170
  if (!activeCurrentIdentity || activeCurrentIdentity === nextIdentity) {
128
171
  activeCurrentIdentity = nextIdentity;
@@ -130,14 +173,7 @@ export default function usageExtension(pi: ExtensionAPI) {
130
173
  }
131
174
  const previousProviderId = activeCurrentIdentity.split(":", 1)[0] ?? "";
132
175
  for (const id of new Set([previousProviderId, providerId])) {
133
- if (!id) continue;
134
- cache.clearProvider(id);
135
- for (const key of failureBackoff.keys()) {
136
- if (key.startsWith(`${id}:`)) failureBackoff.delete(key);
137
- }
138
- for (const key of latestQueries.keys()) {
139
- if (key.startsWith(`${id}:`)) latestQueries.delete(key);
140
- }
176
+ if (id) invalidateProviderState(id);
141
177
  }
142
178
  activeCurrentIdentity = nextIdentity;
143
179
  };
@@ -346,12 +382,14 @@ export default function usageExtension(pi: ExtensionAPI) {
346
382
  label: string,
347
383
  parentSignal: AbortSignal,
348
384
  operation: (signal: AbortSignal) => Promise<T>,
385
+ cancellable = true,
349
386
  ): Promise<T | undefined> => {
350
387
  const { runTask } = await import("@narumitw/pi-tui-kit");
351
388
  if (parentSignal.aborted) return undefined;
352
389
  const result = await runTask(ctx, {
353
390
  label,
354
391
  signal: parentSignal,
392
+ cancellable,
355
393
  onError: () => undefined,
356
394
  task: ({ signal }) => operation(signal),
357
395
  });
@@ -465,25 +503,86 @@ export default function usageExtension(pi: ExtensionAPI) {
465
503
  publishStableCurrent(ctx, stableCurrent);
466
504
  let current = stableCurrent.outcome;
467
505
  let visibleStates: ProviderUsageState[] = [current.state];
506
+ let fastState = settingsRuntime.get();
507
+ let resetAvailability: CodexResetAvailability | undefined;
508
+ let selectedReset: CodexResetOption | undefined;
509
+ let resetAuthFingerprint: string | undefined;
510
+ let resetModelIdentity: string | undefined;
511
+ let redemptionId: string | undefined;
512
+ let resetOutcome: CodexResetOutcome | undefined;
513
+ let resetFailure: string | undefined;
468
514
  const { defineMenu, runMenu } = await import("@narumitw/pi-tui-kit");
469
515
  if (controller.signal.aborted || statusGeneration !== menuGeneration) return;
470
- type Screen = "main" | "providers";
471
- type Action = "refresh" | "another" | "all" | "provider";
516
+ type Screen =
517
+ | "main"
518
+ | "providers"
519
+ | "reset-picker"
520
+ | "reset-confirm"
521
+ | "reset-result"
522
+ | "reset-error";
523
+ type Action =
524
+ | "refresh"
525
+ | "toggle-fast"
526
+ | "another"
527
+ | "all"
528
+ | "provider"
529
+ | "open-resets"
530
+ | "select-reset"
531
+ | "cancel-reset"
532
+ | "consume-reset"
533
+ | "back-to-usage"
534
+ | "back-to-resets";
472
535
  const menu = defineMenu<undefined, Screen, Action, ExtensionCommandContext>({
473
536
  start: "main",
474
537
  screens: {
475
- main: () => ({
476
- kind: "actions",
477
- title: "Provider usage",
478
- lines: formatProviderStates(visibleStates).split("\n"),
479
- items: [
480
- { id: "refresh", label: REFRESH_CURRENT, action: "refresh" },
481
- { id: "another", label: VIEW_ANOTHER, action: "another" },
482
- { id: "all", label: VIEW_ALL, action: "all" },
483
- { id: "close", label: CLOSE, close: true },
484
- ],
485
- hint: "close",
486
- }),
538
+ main: () => {
539
+ const fastAvailability = fastRuntime.availability(ctx.model);
540
+ const fastLines =
541
+ fastAvailability.kind === "available"
542
+ ? [`Fast mode: ${fastAvailability.enabled ? "On" : "Off"}`, FAST_USAGE_WARNING]
543
+ : fastAvailability.kind === "unavailable"
544
+ ? [`Fast mode: Unavailable · ${fastAvailability.reason}`]
545
+ : [];
546
+ return {
547
+ kind: "actions",
548
+ title: "Provider usage",
549
+ lines: [...formatProviderStates(visibleStates).split("\n"), ...fastLines],
550
+ items: [
551
+ { id: "refresh", label: REFRESH_CURRENT, action: "refresh" },
552
+ ...(fastAvailability.kind === "available"
553
+ ? [
554
+ {
555
+ id: "toggle-fast",
556
+ label: fastAvailability.enabled
557
+ ? "Turn Fast mode off"
558
+ : "Turn Fast mode on",
559
+ description:
560
+ fastState.kind === "invalid"
561
+ ? "Repair pi-usage.json and reload before changing Fast mode."
562
+ : FAST_USAGE_WARNING,
563
+ disabled: fastState.kind === "invalid",
564
+ action: "toggle-fast" as const,
565
+ },
566
+ ]
567
+ : []),
568
+ ...(current.state.status === "ready" && current.state.providerId === "openai-codex"
569
+ ? [
570
+ {
571
+ id: "open-resets",
572
+ label: REDEEM_CODEX_RESET,
573
+ description: codexResetActionDescription(current.state.report),
574
+ disabled: codexResetCount(current.state.report) === 0,
575
+ action: "open-resets" as const,
576
+ },
577
+ ]
578
+ : []),
579
+ { id: "another", label: VIEW_ANOTHER, action: "another" },
580
+ { id: "all", label: VIEW_ALL, action: "all" },
581
+ { id: "close", label: CLOSE, close: true },
582
+ ],
583
+ hint: "close",
584
+ };
585
+ },
487
586
  providers: () => ({
488
587
  kind: "actions",
489
588
  title: "Select a configured provider",
@@ -496,8 +595,229 @@ export default function usageExtension(pi: ExtensionAPI) {
496
595
  })),
497
596
  hint: "back",
498
597
  }),
598
+ "reset-picker": () => ({
599
+ kind: "choice",
600
+ title: "Usage limit resets",
601
+ lines: [
602
+ `${resetAvailability?.availableCount ?? 0} ${resetLabel(resetAvailability?.availableCount ?? 0)} available.`,
603
+ ],
604
+ items: (resetAvailability?.options ?? []).map((option, index) => ({
605
+ id: `reset-${index}`,
606
+ label: option.title,
607
+ description: resetOptionExpiration(option),
608
+ details: [option.description],
609
+ })),
610
+ action: "select-reset",
611
+ initialItemId: "reset-0",
612
+ hint: "back",
613
+ }),
614
+ "reset-confirm": () => ({
615
+ kind: "actions",
616
+ title: "Use this reset?",
617
+ lines: resetConfirmationLines(selectedReset),
618
+ items: [
619
+ { id: "cancel-reset", label: "No, go back", action: "cancel-reset" },
620
+ { id: "consume-reset", label: "Yes, use reset", action: "consume-reset" },
621
+ ],
622
+ hint: "back",
623
+ }),
624
+ "reset-result": () => ({
625
+ kind: "actions",
626
+ title: "Usage limit resets",
627
+ lines: [
628
+ formatCodexResetOutcome(
629
+ resetOutcome,
630
+ current.state.status === "ready"
631
+ ? codexResetCount(current.state.report)
632
+ : undefined,
633
+ ),
634
+ ],
635
+ items: [
636
+ {
637
+ id: "back-to-usage",
638
+ label: "Back to usage",
639
+ action: "back-to-usage",
640
+ },
641
+ { id: "close", label: CLOSE, close: true },
642
+ ],
643
+ hint: "back",
644
+ }),
645
+ "reset-error": () => ({
646
+ kind: "actions",
647
+ title: "Usage limit resets",
648
+ lines: [resetFailure ?? "Couldn't reset usage. Please try again."],
649
+ items: [
650
+ { id: "consume-reset", label: "Try again", action: "consume-reset" },
651
+ { id: "back-to-resets", label: "Back", action: "back-to-resets" },
652
+ ],
653
+ hint: "back",
654
+ }),
499
655
  },
500
656
  actions: {
657
+ "toggle-fast": async () => {
658
+ const availability = fastRuntime.availability(ctx.model);
659
+ if (availability.kind !== "available" || fastState.kind === "invalid") {
660
+ return { kind: "rejected" };
661
+ }
662
+ const changed = await fastRuntime.toggle(ctx, !availability.enabled, controller.signal);
663
+ if (!changed) return { kind: "rejected" };
664
+ fastState = settingsRuntime.get();
665
+ return { kind: "stay" };
666
+ },
667
+ "open-resets": async () => {
668
+ const summaryCount =
669
+ current.state.status === "ready" && current.state.providerId === "openai-codex"
670
+ ? codexResetCount(current.state.report)
671
+ : undefined;
672
+ try {
673
+ const loaded = await runMenuOperation(
674
+ ctx,
675
+ "Checking usage limit resets…",
676
+ controller.signal,
677
+ async (signal) => {
678
+ const expectedModel = modelIdentity(ctx.model);
679
+ const auth = await awaitWithDeadline(
680
+ resolveCodexResetAuth(ctx, undefined, credentialReader),
681
+ signal,
682
+ DEFAULT_TIMEOUT_MS,
683
+ "resolving current Codex reset authentication",
684
+ );
685
+ let availability: CodexResetAvailability;
686
+ try {
687
+ availability = await listCodexResetCredits(auth, signal, DEFAULT_TIMEOUT_MS);
688
+ } catch (error) {
689
+ if (isAbortError(error) || summaryCount === undefined || summaryCount <= 0) {
690
+ throw error;
691
+ }
692
+ availability = {
693
+ availableCount: summaryCount,
694
+ options: [genericCodexResetOption()],
695
+ };
696
+ }
697
+ const revalidated = await awaitWithDeadline(
698
+ resolveCodexResetAuth(ctx, undefined, credentialReader),
699
+ signal,
700
+ DEFAULT_TIMEOUT_MS,
701
+ "revalidating current Codex reset authentication",
702
+ );
703
+ if (
704
+ modelIdentity(ctx.model) !== expectedModel ||
705
+ revalidated.fingerprint !== auth.fingerprint
706
+ ) {
707
+ throw new Error(
708
+ "The active Codex model or account changed while loading usage limit resets.",
709
+ );
710
+ }
711
+ return { availability, auth, expectedModel };
712
+ },
713
+ );
714
+ if (!loaded) return { kind: "stay" };
715
+ resetAvailability = loaded.availability;
716
+ resetAuthFingerprint = loaded.auth.fingerprint;
717
+ resetModelIdentity = loaded.expectedModel;
718
+ selectedReset = undefined;
719
+ redemptionId = undefined;
720
+ resetOutcome = undefined;
721
+ resetFailure = undefined;
722
+ return {
723
+ kind: "to",
724
+ screen: loaded.availability.availableCount > 0 ? "reset-picker" : "reset-result",
725
+ };
726
+ } catch (error) {
727
+ if (isAbortError(error) || isStaleExtensionContextError(error)) {
728
+ return { kind: "stay" };
729
+ }
730
+ ctx.ui.notify(`Couldn't load usage limit resets: ${errorMessage(error)}`, "error");
731
+ return { kind: "stay" };
732
+ }
733
+ },
734
+ "select-reset": ({ itemId }) => {
735
+ const index = Number(itemId.replace(/^reset-/u, ""));
736
+ const option = Number.isSafeInteger(index)
737
+ ? resetAvailability?.options[index]
738
+ : undefined;
739
+ if (!option) return { kind: "rejected" };
740
+ selectedReset = option;
741
+ redemptionId = undefined;
742
+ resetFailure = undefined;
743
+ return { kind: "to", screen: "reset-confirm" };
744
+ },
745
+ "cancel-reset": () => ({ kind: "back" }),
746
+ "consume-reset": async () => {
747
+ if (!selectedReset || !resetAuthFingerprint || !resetModelIdentity) {
748
+ return { kind: "rejected" };
749
+ }
750
+ try {
751
+ redemptionId ??= createRedemptionId();
752
+ const attemptId = redemptionId;
753
+ const option = selectedReset;
754
+ const expectedFingerprint = resetAuthFingerprint;
755
+ const expectedModel = resetModelIdentity;
756
+ const result = await runMenuOperation(
757
+ ctx,
758
+ "Resetting your usage…",
759
+ controller.signal,
760
+ async (signal) => {
761
+ const auth = await awaitWithDeadline(
762
+ resolveCodexResetAuth(ctx, undefined, credentialReader),
763
+ signal,
764
+ DEFAULT_TIMEOUT_MS,
765
+ "revalidating current Codex reset authentication",
766
+ );
767
+ if (
768
+ modelIdentity(ctx.model) !== expectedModel ||
769
+ auth.fingerprint !== expectedFingerprint
770
+ ) {
771
+ throw new Error(
772
+ "The active Codex model or account changed; the reset was not used.",
773
+ );
774
+ }
775
+ const outcome = await consumeCodexResetCredit(
776
+ auth,
777
+ option,
778
+ attemptId,
779
+ signal,
780
+ DEFAULT_TIMEOUT_MS,
781
+ );
782
+ invalidateProviderState("openai-codex");
783
+ const model = ctx.model;
784
+ if (modelIdentity(model) !== expectedModel) return { outcome };
785
+ const refreshed = await queryCurrentState(ctx, model, true, signal);
786
+ const stable = await outcomeStillCurrent(
787
+ ctx,
788
+ model,
789
+ menuGeneration,
790
+ refreshed,
791
+ signal,
792
+ );
793
+ return stable ? { outcome, refreshed, model } : { outcome };
794
+ },
795
+ false,
796
+ );
797
+ if (!result) return { kind: "close" };
798
+ resetOutcome = result.outcome;
799
+ resetFailure = undefined;
800
+ if (result.refreshed && result.model) {
801
+ stableCurrent = { outcome: result.refreshed, model: result.model };
802
+ current = result.refreshed;
803
+ visibleStates = [current.state];
804
+ publishStableCurrent(ctx, stableCurrent);
805
+ }
806
+ return { kind: "to", screen: "reset-result" };
807
+ } catch (error) {
808
+ if (isAbortError(error) || isStaleExtensionContextError(error)) {
809
+ return { kind: "close" };
810
+ }
811
+ resetFailure = `Couldn't reset usage: ${errorMessage(error)}. Try again with the same request.`;
812
+ return { kind: "to", screen: "reset-error" };
813
+ }
814
+ },
815
+ "back-to-usage": () => ({ kind: "to", screen: "main" }),
816
+ "back-to-resets": () => {
817
+ redemptionId = undefined;
818
+ resetFailure = undefined;
819
+ return { kind: "to", screen: "reset-picker" };
820
+ },
501
821
  refresh: async () => {
502
822
  const refreshed = await queryStableCurrent(
503
823
  ctx,
@@ -614,25 +934,30 @@ export default function usageExtension(pi: ExtensionAPI) {
614
934
  }
615
935
  };
616
936
 
617
- const commandHandler = async (args: string, ctx: ExtensionCommandContext) => {
618
- if (args.trim()) {
619
- ctx.ui.notify("/usage does not accept arguments; choose an action from its menu.", "warning");
620
- return;
621
- }
622
- try {
623
- await showMenu(ctx);
624
- } catch (error) {
625
- if (isStaleExtensionContextError(error) || isAbortError(error)) return;
626
- throw error;
627
- }
628
- };
629
-
630
937
  pi.registerCommand("usage", {
631
938
  description: "Show usage for the current runtime account",
632
- handler: commandHandler,
939
+ handler: async (args, ctx) => {
940
+ if (args.trim()) {
941
+ ctx.ui.notify(
942
+ "/usage does not accept arguments; choose an action from its menu.",
943
+ "warning",
944
+ );
945
+ return;
946
+ }
947
+ try {
948
+ await showMenu(ctx);
949
+ } catch (error) {
950
+ if (isStaleExtensionContextError(error) || isAbortError(error)) return;
951
+ throw error;
952
+ }
953
+ },
633
954
  });
634
-
635
955
  pi.on("session_start", (_event, ctx) => {
956
+ statusGeneration += 1;
957
+ clearStatusTimer();
958
+ for (const controller of activeControllers) controller.abort();
959
+ activeControllers.clear();
960
+ statusController = undefined;
636
961
  sessionActive = true;
637
962
  startStatusRefresh(ctx, ctx.model, false);
638
963
  });
@@ -658,40 +983,8 @@ export default function usageExtension(pi: ExtensionAPI) {
658
983
  activeCurrentIdentity = undefined;
659
984
  safeSetStatus(ctx, undefined);
660
985
  });
661
- }
662
986
 
663
- function configuredAdapters(ctx: ExtensionContext): UsageProviderAdapter[] {
664
- return SUPPORTED_ADAPTERS.filter(
665
- (adapter) => adapter.id === ctx.model?.provider || providerIsConfigured(ctx, adapter.id),
987
+ fastRuntime = registerCodexFastMode(pi, settingsRuntime, (ctx) =>
988
+ startStatusRefresh(ctx, ctx.model, false),
666
989
  );
667
990
  }
668
-
669
- function providerDisplayName(ctx: ExtensionContext, providerId: string): string {
670
- try {
671
- return sanitizeDisplayText(ctx.modelRegistry.getProviderDisplayName(providerId), 80);
672
- } catch {
673
- return sanitizeDisplayText(providerId, 80);
674
- }
675
- }
676
-
677
- function setBoundedMap<T>(map: Map<string, T>, key: string, value: T, limit: number): void {
678
- map.delete(key);
679
- while (map.size >= limit) {
680
- const oldest = map.keys().next().value;
681
- if (oldest === undefined) break;
682
- map.delete(oldest);
683
- }
684
- map.set(key, value);
685
- }
686
-
687
- function modelIdentity(model: PiModel | undefined): string | undefined {
688
- return model ? `${model.provider}/${model.id}` : undefined;
689
- }
690
-
691
- function isAbortError(error: unknown): boolean {
692
- return error instanceof Error && error.name === "AbortError";
693
- }
694
-
695
- function isTimeoutError(error: unknown): boolean {
696
- return error instanceof Error && error.name === "TimeoutError";
697
- }