@narumitw/pi-usage 0.53.0 → 0.57.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,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,8 +53,10 @@ 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;
59
+ const STATUS_COUNTDOWN_REFRESH_MS = 60 * 1000;
50
60
  const DEFAULT_TIMEOUT_MS = 15_000;
51
61
  const ALL_PROVIDER_CONCURRENCY = 2;
52
62
  const FAILURE_BACKOFF_MS = 30_000;
@@ -57,6 +67,7 @@ const REFRESH_CURRENT = "Refresh current usage";
57
67
  const VIEW_ANOTHER = "View another configured provider…";
58
68
  const VIEW_ALL = "View all configured providers…";
59
69
  const CLOSE = "Close";
70
+ const SETTINGS = "Settings";
60
71
  const REDEEM_CODEX_RESET = "Redeem usage limit reset…";
61
72
 
62
73
  type UsageExtensionDependencies = {
@@ -92,15 +103,27 @@ export default function usageExtension(
92
103
  let activeCurrentIdentity: string | undefined;
93
104
  let sessionActive = false;
94
105
  let statusGeneration = 0;
106
+ let sessionGeneration = 0;
95
107
  let statusRefreshTimer: ReturnType<typeof setTimeout> | undefined;
108
+ let statusCountdownTimer: ReturnType<typeof setTimeout> | undefined;
96
109
  let statusController: AbortController | undefined;
97
110
  let fastRuntime: ReturnType<typeof registerCodexFastMode>;
98
111
 
99
- const clearStatusTimer = () => {
112
+ const clearStatusRefreshTimer = () => {
100
113
  if (statusRefreshTimer) clearTimeout(statusRefreshTimer);
101
114
  statusRefreshTimer = undefined;
102
115
  };
103
116
 
117
+ const clearStatusCountdownTimer = () => {
118
+ if (statusCountdownTimer) clearTimeout(statusCountdownTimer);
119
+ statusCountdownTimer = undefined;
120
+ };
121
+
122
+ const clearStatusTimers = () => {
123
+ clearStatusRefreshTimer();
124
+ clearStatusCountdownTimer();
125
+ };
126
+
104
127
  const safeSetStatus = (ctx: ExtensionContext, value: string | undefined): boolean => {
105
128
  try {
106
129
  ctx.ui.setStatus(STATUS_KEY, value);
@@ -115,12 +138,12 @@ export default function usageExtension(
115
138
  statusGeneration += 1;
116
139
  statusController?.abort();
117
140
  statusController = undefined;
118
- clearStatusTimer();
141
+ clearStatusTimers();
119
142
  safeSetStatus(ctx, undefined);
120
143
  };
121
144
 
122
145
  const scheduleStatusRefresh = (ctx: ExtensionContext, model: PiModel) => {
123
- clearStatusTimer();
146
+ clearStatusRefreshTimer();
124
147
  const generation = statusGeneration;
125
148
  statusRefreshTimer = setTimeout(() => {
126
149
  statusRefreshTimer = undefined;
@@ -136,13 +159,14 @@ export default function usageExtension(
136
159
  model: PiModel,
137
160
  shouldSchedule: boolean,
138
161
  ) => {
162
+ clearStatusCountdownTimer();
139
163
  if (adapterForProvider(model.provider)?.publishesStatusline === false) {
140
- clearStatusTimer();
164
+ clearStatusRefreshTimer();
141
165
  safeSetStatus(ctx, undefined);
142
166
  return;
143
167
  }
144
168
  if (outcome.state.status === "unsupported") {
145
- clearStatusTimer();
169
+ clearStatusRefreshTimer();
146
170
  safeSetStatus(ctx, undefined);
147
171
  return;
148
172
  }
@@ -157,10 +181,43 @@ export default function usageExtension(
157
181
  }
158
182
  return;
159
183
  }
160
- const rawValue = formatUsageStatusline(outcome.state.report, model);
184
+ const showCodexResetCountdown =
185
+ outcome.state.report.providerId === "openai-codex" &&
186
+ settingsRuntime.get().settings.codexStatusResetCountdown;
187
+ const now = Date.now();
188
+ const rawValue = formatUsageStatusline(
189
+ outcome.state.report,
190
+ model,
191
+ now,
192
+ showCodexResetCountdown,
193
+ );
161
194
  const value = rawValue ? fastRuntime.decorateStatus(model, rawValue) : undefined;
162
195
  if (!safeSetStatus(ctx, value)) return;
163
196
  if (shouldSchedule && sessionActive) scheduleStatusRefresh(ctx, model);
197
+ if (
198
+ sessionActive &&
199
+ showCodexResetCountdown &&
200
+ outcome.state.report.buckets.some(
201
+ (bucket) =>
202
+ bucket.resetsAt !== undefined &&
203
+ Number.isFinite(bucket.resetsAt) &&
204
+ bucket.resetsAt * 1_000 > now,
205
+ )
206
+ ) {
207
+ const generation = statusGeneration;
208
+ statusCountdownTimer = setTimeout(() => {
209
+ statusCountdownTimer = undefined;
210
+ if (
211
+ !sessionActive ||
212
+ generation !== statusGeneration ||
213
+ modelIdentity(ctx.model) !== modelIdentity(model)
214
+ ) {
215
+ return;
216
+ }
217
+ publishStatus(ctx, outcome, model, false);
218
+ }, STATUS_COUNTDOWN_REFRESH_MS);
219
+ statusCountdownTimer.unref?.();
220
+ }
164
221
  };
165
222
 
166
223
  const invalidateProviderState = (providerId: string) => {
@@ -191,14 +248,18 @@ export default function usageExtension(
191
248
  displayState: UsageDisplayState,
192
249
  force: boolean,
193
250
  signal: AbortSignal,
251
+ authRetry = 0,
252
+ deadlineAt = Date.now() + DEFAULT_TIMEOUT_MS,
194
253
  ): Promise<QueryOutcome> => {
195
- const startedAt = Date.now();
254
+ const expectedSessionGeneration = sessionGeneration;
255
+ const expectedSessionId = ctx.sessionManager.getSessionId();
256
+ const expectedModelIdentity = modelIdentity(ctx.model);
196
257
  let auth: ResolvedUsageAuth | undefined;
197
258
  try {
198
259
  auth = await awaitWithDeadline(
199
260
  resolveUsageAuth(ctx, adapter, undefined, credentialReader, credentialCandidates),
200
261
  signal,
201
- DEFAULT_TIMEOUT_MS,
262
+ Math.max(1, deadlineAt - Date.now()),
202
263
  `resolving ${adapter.displayName} runtime auth`,
203
264
  );
204
265
  } catch (error) {
@@ -216,6 +277,12 @@ export default function usageExtension(
216
277
  },
217
278
  };
218
279
  }
280
+ const requiresRequestBoundaryGuard = adapter.id === "deepseek" || adapter.id === "xai";
281
+ const requestContextChanged = () =>
282
+ expectedSessionGeneration !== sessionGeneration ||
283
+ ctx.sessionManager.getSessionId() !== expectedSessionId ||
284
+ modelIdentity(ctx.model) !== expectedModelIdentity;
285
+ if (requiresRequestBoundaryGuard && requestContextChanged()) throw abortError();
219
286
  if (!auth) {
220
287
  if (displayState === "current") {
221
288
  transitionCurrentIdentity(`${adapter.id}:unavailable`, adapter.id);
@@ -268,9 +335,30 @@ export default function usageExtension(
268
335
  const queryId = querySequence;
269
336
  setBoundedMap(latestQueries, failureKey, queryId, MAX_ACCOUNT_STATES);
270
337
 
338
+ let deepSeekAuthChanged = false;
271
339
  try {
272
- const remainingMs = Math.max(1, DEFAULT_TIMEOUT_MS - (Date.now() - startedAt));
273
- const report = await queryProviderUsage(adapter, auth, signal, remainingMs);
340
+ const remainingMs = Math.max(1, deadlineAt - Date.now());
341
+ const guard = requiresRequestBoundaryGuard
342
+ ? async () => {
343
+ if (signal.aborted || requestContextChanged()) throw abortError();
344
+ const revalidated = await awaitWithDeadline(
345
+ resolveUsageAuth(ctx, adapter, undefined, credentialReader, credentialCandidates),
346
+ signal,
347
+ Math.max(1, deadlineAt - Date.now()),
348
+ `revalidating ${adapter.displayName} runtime auth`,
349
+ );
350
+ if (signal.aborted || requestContextChanged()) throw abortError();
351
+ if (revalidated?.fingerprint !== auth.fingerprint) {
352
+ if (adapter.id === "deepseek") {
353
+ deepSeekAuthChanged = true;
354
+ throw new Error("DeepSeek runtime credential changed during the balance query.");
355
+ }
356
+ throw abortError();
357
+ }
358
+ }
359
+ : undefined;
360
+ const report = await queryProviderUsage(adapter, auth, signal, remainingMs, guard);
361
+ if (guard) await guard();
274
362
  if (latestQueries.get(failureKey) === queryId) {
275
363
  cache.set(adapter.id, auth.fingerprint, report);
276
364
  failureBackoff.delete(failureKey);
@@ -287,6 +375,24 @@ export default function usageExtension(
287
375
  };
288
376
  } catch (error) {
289
377
  if (isStaleExtensionContextError(error) || isAbortError(error)) throw error;
378
+ if (
379
+ deepSeekAuthChanged &&
380
+ authRetry === 0 &&
381
+ !signal.aborted &&
382
+ !requestContextChanged() &&
383
+ Date.now() < deadlineAt
384
+ ) {
385
+ if (latestQueries.get(failureKey) === queryId) latestQueries.delete(failureKey);
386
+ return queryAdapterState(
387
+ ctx,
388
+ adapter,
389
+ displayState,
390
+ true,
391
+ signal,
392
+ authRetry + 1,
393
+ deadlineAt,
394
+ );
395
+ }
290
396
  const message = errorMessage(error);
291
397
  const now = Date.now();
292
398
  for (const [key, failure] of failureBackoff) {
@@ -356,6 +462,7 @@ export default function usageExtension(
356
462
  }
357
463
  statusGeneration += 1;
358
464
  const generation = statusGeneration;
465
+ clearStatusCountdownTimer();
359
466
  statusController?.abort();
360
467
  const controller = new AbortController();
361
468
  statusController = controller;
@@ -500,7 +607,7 @@ export default function usageExtension(
500
607
  const menuGeneration = statusGeneration;
501
608
  statusController?.abort();
502
609
  statusController = undefined;
503
- clearStatusTimer();
610
+ clearStatusTimers();
504
611
  const controller = new AbortController();
505
612
  activeControllers.add(controller);
506
613
  try {
@@ -533,6 +640,7 @@ export default function usageExtension(
533
640
  | "reset-error";
534
641
  type Action =
535
642
  | "refresh"
643
+ | "settings"
536
644
  | "toggle-fast"
537
645
  | "another"
538
646
  | "all"
@@ -560,6 +668,7 @@ export default function usageExtension(
560
668
  lines: [...formatProviderStates(visibleStates).split("\n"), ...fastLines],
561
669
  items: [
562
670
  { id: "refresh", label: REFRESH_CURRENT, action: "refresh" },
671
+ { id: "settings", label: SETTINGS, action: "settings" },
563
672
  ...(fastAvailability.kind === "available"
564
673
  ? [
565
674
  {
@@ -665,6 +774,37 @@ export default function usageExtension(
665
774
  }),
666
775
  },
667
776
  actions: {
777
+ settings: async () => {
778
+ await showUsageSettings(
779
+ ctx,
780
+ settingsRuntime,
781
+ controller.signal,
782
+ () => statusGeneration === menuGeneration && !controller.signal.aborted,
783
+ (id) => {
784
+ if (
785
+ id === "codexStatusResetCountdown" &&
786
+ stableCurrent &&
787
+ statusGeneration === menuGeneration &&
788
+ !controller.signal.aborted
789
+ ) {
790
+ publishStableCurrent(ctx, stableCurrent);
791
+ }
792
+ },
793
+ );
794
+ fastState = settingsRuntime.get();
795
+ const revalidated = await queryStableCurrent(
796
+ ctx,
797
+ false,
798
+ controller,
799
+ "Applying usage settings…",
800
+ );
801
+ if (!revalidated) return { kind: "stay" };
802
+ stableCurrent = revalidated;
803
+ current = revalidated.outcome;
804
+ visibleStates = [current.state];
805
+ publishStableCurrent(ctx, revalidated);
806
+ return { kind: "stay" };
807
+ },
668
808
  "toggle-fast": async () => {
669
809
  const availability = fastRuntime.availability(ctx.model);
670
810
  if (availability.kind !== "available" || fastState.kind === "invalid") {
@@ -964,8 +1104,9 @@ export default function usageExtension(
964
1104
  },
965
1105
  });
966
1106
  pi.on("session_start", (_event, ctx) => {
1107
+ sessionGeneration += 1;
967
1108
  statusGeneration += 1;
968
- clearStatusTimer();
1109
+ clearStatusTimers();
969
1110
  for (const controller of activeControllers) controller.abort();
970
1111
  activeControllers.clear();
971
1112
  statusController = undefined;
@@ -983,8 +1124,9 @@ export default function usageExtension(
983
1124
  });
984
1125
  pi.on("session_shutdown", (_event, ctx) => {
985
1126
  sessionActive = false;
1127
+ sessionGeneration += 1;
986
1128
  statusGeneration += 1;
987
- clearStatusTimer();
1129
+ clearStatusTimers();
988
1130
  for (const controller of activeControllers) controller.abort();
989
1131
  activeControllers.clear();
990
1132
  statusController = undefined;