@narumitw/pi-usage 0.24.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 ADDED
@@ -0,0 +1,676 @@
1
+ import {
2
+ BorderedLoader,
3
+ type ExtensionAPI,
4
+ type ExtensionCommandContext,
5
+ type ExtensionContext,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import {
8
+ awaitWithDeadline,
9
+ errorMessage,
10
+ runWithConcurrency,
11
+ sanitizeDisplayText,
12
+ UsageCache,
13
+ } from "./core.js";
14
+ import { formatProviderStates, formatUsageStatusline } from "./format.js";
15
+ import {
16
+ adapterForProvider,
17
+ isStaleExtensionContextError,
18
+ providerIsConfigured,
19
+ queryProviderUsage,
20
+ resolveUsageAuth,
21
+ SUPPORTED_ADAPTERS,
22
+ } from "./query.js";
23
+ import type {
24
+ PiModel,
25
+ ProviderUsageState,
26
+ ResolvedUsageAuth,
27
+ UsageDisplayState,
28
+ UsageProviderAdapter,
29
+ } from "./types.js";
30
+
31
+ const CACHE_TTL_MS = 5 * 60 * 1000;
32
+ const DEFAULT_TIMEOUT_MS = 15_000;
33
+ const ALL_PROVIDER_CONCURRENCY = 2;
34
+ const FAILURE_BACKOFF_MS = 30_000;
35
+ const MAX_ACCOUNT_STATES = 32;
36
+ const STATUS_KEY = "usage";
37
+
38
+ const REFRESH_CURRENT = "Refresh current usage";
39
+ const VIEW_ANOTHER = "View another configured provider…";
40
+ const VIEW_ALL = "View all configured providers…";
41
+ const CLOSE = "Close";
42
+ const MENU_ACTIONS = [REFRESH_CURRENT, VIEW_ANOTHER, VIEW_ALL, CLOSE];
43
+
44
+ type QueryOutcome = {
45
+ state: ProviderUsageState;
46
+ fingerprint?: string;
47
+ authState?: "unavailable";
48
+ };
49
+
50
+ type StableCurrent = {
51
+ outcome: QueryOutcome;
52
+ model: PiModel | undefined;
53
+ };
54
+
55
+ type LoaderResult<T> = { ok: true; value: T } | { ok: false; error: unknown };
56
+
57
+ export default function usageExtension(pi: ExtensionAPI) {
58
+ const cache = new UsageCache(CACHE_TTL_MS);
59
+ const failureBackoff = new Map<string, { until: number; message: string }>();
60
+ const latestQueries = new Map<string, number>();
61
+ const activeControllers = new Set<AbortController>();
62
+ let querySequence = 0;
63
+ let activeCurrentIdentity: string | undefined;
64
+ let sessionActive = false;
65
+ let statusGeneration = 0;
66
+ let statusRefreshTimer: ReturnType<typeof setTimeout> | undefined;
67
+ let statusController: AbortController | undefined;
68
+
69
+ const clearStatusTimer = () => {
70
+ if (statusRefreshTimer) clearTimeout(statusRefreshTimer);
71
+ statusRefreshTimer = undefined;
72
+ };
73
+
74
+ const safeSetStatus = (ctx: ExtensionContext, value: string | undefined): boolean => {
75
+ try {
76
+ ctx.ui.setStatus(STATUS_KEY, value);
77
+ return true;
78
+ } catch (error) {
79
+ if (isStaleExtensionContextError(error)) return false;
80
+ throw error;
81
+ }
82
+ };
83
+
84
+ const clearStatus = (ctx: ExtensionContext) => {
85
+ statusGeneration += 1;
86
+ statusController?.abort();
87
+ statusController = undefined;
88
+ clearStatusTimer();
89
+ safeSetStatus(ctx, undefined);
90
+ };
91
+
92
+ const scheduleStatusRefresh = (ctx: ExtensionContext, model: PiModel) => {
93
+ clearStatusTimer();
94
+ const generation = statusGeneration;
95
+ statusRefreshTimer = setTimeout(() => {
96
+ statusRefreshTimer = undefined;
97
+ if (!sessionActive || generation !== statusGeneration) return;
98
+ startStatusRefresh(ctx, model, true);
99
+ }, CACHE_TTL_MS);
100
+ statusRefreshTimer.unref?.();
101
+ };
102
+
103
+ const publishStatus = (
104
+ ctx: ExtensionContext,
105
+ outcome: QueryOutcome,
106
+ model: PiModel,
107
+ shouldSchedule: boolean,
108
+ ) => {
109
+ if (outcome.state.status === "unsupported") {
110
+ clearStatusTimer();
111
+ safeSetStatus(ctx, undefined);
112
+ return;
113
+ }
114
+ if (outcome.state.status !== "ready") {
115
+ if (
116
+ safeSetStatus(
117
+ ctx,
118
+ outcome.state.status === "auth-unavailable" ? "auth unavailable" : "usage error",
119
+ )
120
+ ) {
121
+ if (shouldSchedule && sessionActive) scheduleStatusRefresh(ctx, model);
122
+ }
123
+ return;
124
+ }
125
+ const value = formatUsageStatusline(outcome.state.report, model);
126
+ if (!safeSetStatus(ctx, value)) return;
127
+ if (shouldSchedule && sessionActive) scheduleStatusRefresh(ctx, model);
128
+ };
129
+
130
+ const transitionCurrentIdentity = (nextIdentity: string, providerId: string) => {
131
+ if (!activeCurrentIdentity || activeCurrentIdentity === nextIdentity) {
132
+ activeCurrentIdentity = nextIdentity;
133
+ return;
134
+ }
135
+ const previousProviderId = activeCurrentIdentity.split(":", 1)[0] ?? "";
136
+ for (const id of new Set([previousProviderId, providerId])) {
137
+ if (!id) continue;
138
+ cache.clearProvider(id);
139
+ for (const key of failureBackoff.keys()) {
140
+ if (key.startsWith(`${id}:`)) failureBackoff.delete(key);
141
+ }
142
+ for (const key of latestQueries.keys()) {
143
+ if (key.startsWith(`${id}:`)) latestQueries.delete(key);
144
+ }
145
+ }
146
+ activeCurrentIdentity = nextIdentity;
147
+ };
148
+
149
+ const queryAdapterState = async (
150
+ ctx: ExtensionContext,
151
+ adapter: UsageProviderAdapter,
152
+ displayState: UsageDisplayState,
153
+ force: boolean,
154
+ signal: AbortSignal,
155
+ ): Promise<QueryOutcome> => {
156
+ const startedAt = Date.now();
157
+ let auth: ResolvedUsageAuth | undefined;
158
+ try {
159
+ auth = await awaitWithDeadline(
160
+ resolveUsageAuth(ctx, adapter),
161
+ signal,
162
+ DEFAULT_TIMEOUT_MS,
163
+ `resolving ${adapter.displayName} runtime auth`,
164
+ );
165
+ } catch (error) {
166
+ if (isStaleExtensionContextError(error) || isAbortError(error)) throw error;
167
+ if (displayState === "current") {
168
+ transitionCurrentIdentity(`${adapter.id}:auth-error`, adapter.id);
169
+ }
170
+ return {
171
+ state: {
172
+ providerId: adapter.id,
173
+ providerName: adapter.displayName,
174
+ displayState,
175
+ status: isTimeoutError(error) ? "query-failed" : "auth-unavailable",
176
+ message: errorMessage(error),
177
+ },
178
+ };
179
+ }
180
+ if (!auth) {
181
+ if (displayState === "current") {
182
+ transitionCurrentIdentity(`${adapter.id}:unavailable`, adapter.id);
183
+ }
184
+ return {
185
+ state: {
186
+ providerId: adapter.id,
187
+ providerName: adapter.displayName,
188
+ displayState,
189
+ status: "auth-unavailable",
190
+ message: `No runtime credential is configured for ${adapter.displayName}.`,
191
+ },
192
+ authState: "unavailable",
193
+ };
194
+ }
195
+ if (displayState === "current") {
196
+ transitionCurrentIdentity(`${adapter.id}:${auth.fingerprint}`, adapter.id);
197
+ }
198
+
199
+ const cached = !force ? cache.get(adapter.id, auth.fingerprint) : undefined;
200
+ if (cached) {
201
+ return {
202
+ state: {
203
+ providerId: adapter.id,
204
+ providerName: adapter.displayName,
205
+ displayState,
206
+ status: "ready",
207
+ report: cached,
208
+ },
209
+ fingerprint: auth.fingerprint,
210
+ };
211
+ }
212
+
213
+ const failureKey = `${adapter.id}:${auth.fingerprint}`;
214
+ const previousFailure = failureBackoff.get(failureKey);
215
+ if (!force && previousFailure && previousFailure.until > Date.now()) {
216
+ return {
217
+ state: {
218
+ providerId: adapter.id,
219
+ providerName: adapter.displayName,
220
+ displayState,
221
+ status: "query-failed",
222
+ message: previousFailure.message,
223
+ },
224
+ fingerprint: auth.fingerprint,
225
+ };
226
+ }
227
+ failureBackoff.delete(failureKey);
228
+ querySequence += 1;
229
+ const queryId = querySequence;
230
+ setBoundedMap(latestQueries, failureKey, queryId, MAX_ACCOUNT_STATES);
231
+
232
+ try {
233
+ const remainingMs = Math.max(1, DEFAULT_TIMEOUT_MS - (Date.now() - startedAt));
234
+ const report = await queryProviderUsage(adapter, auth, signal, remainingMs);
235
+ if (latestQueries.get(failureKey) === queryId) {
236
+ cache.set(adapter.id, auth.fingerprint, report);
237
+ failureBackoff.delete(failureKey);
238
+ }
239
+ return {
240
+ state: {
241
+ providerId: adapter.id,
242
+ providerName: adapter.displayName,
243
+ displayState,
244
+ status: "ready",
245
+ report,
246
+ },
247
+ fingerprint: auth.fingerprint,
248
+ };
249
+ } catch (error) {
250
+ if (isStaleExtensionContextError(error) || isAbortError(error)) throw error;
251
+ const message = errorMessage(error);
252
+ const now = Date.now();
253
+ for (const [key, failure] of failureBackoff) {
254
+ if (failure.until <= now) failureBackoff.delete(key);
255
+ }
256
+ if (latestQueries.get(failureKey) === queryId) {
257
+ setBoundedMap(
258
+ failureBackoff,
259
+ failureKey,
260
+ { until: now + FAILURE_BACKOFF_MS, message },
261
+ MAX_ACCOUNT_STATES,
262
+ );
263
+ }
264
+ return {
265
+ state: {
266
+ providerId: adapter.id,
267
+ providerName: adapter.displayName,
268
+ displayState,
269
+ status: "query-failed",
270
+ message,
271
+ },
272
+ fingerprint: auth.fingerprint,
273
+ };
274
+ }
275
+ };
276
+
277
+ const queryCurrentState = async (
278
+ ctx: ExtensionContext,
279
+ model: PiModel | undefined,
280
+ force: boolean,
281
+ signal: AbortSignal,
282
+ ): Promise<QueryOutcome> => {
283
+ const adapter = adapterForProvider(model?.provider);
284
+ if (!adapter) {
285
+ const providerId = model?.provider ?? "none";
286
+ transitionCurrentIdentity(`unsupported:${providerId}`, providerId);
287
+ return {
288
+ state: {
289
+ providerId,
290
+ providerName: providerDisplayName(ctx, providerId),
291
+ displayState: "current",
292
+ status: "unsupported",
293
+ message: model
294
+ ? `Usage reporting is not supported for ${providerDisplayName(ctx, providerId)}.`
295
+ : "No model is selected.",
296
+ },
297
+ };
298
+ }
299
+ return queryAdapterState(ctx, adapter, "current", force, signal);
300
+ };
301
+
302
+ const refreshCurrentStatus = async (
303
+ ctx: ExtensionContext,
304
+ model: PiModel | undefined,
305
+ force: boolean,
306
+ ) => {
307
+ const adapter = adapterForProvider(model?.provider);
308
+ if (!adapter || !model) {
309
+ const providerId = model?.provider ?? "none";
310
+ transitionCurrentIdentity(`unsupported:${providerId}`, providerId);
311
+ clearStatus(ctx);
312
+ return;
313
+ }
314
+ statusGeneration += 1;
315
+ const generation = statusGeneration;
316
+ statusController?.abort();
317
+ const controller = new AbortController();
318
+ statusController = controller;
319
+ activeControllers.add(controller);
320
+ try {
321
+ if (!safeSetStatus(ctx, "checking")) return;
322
+ const outcome = await queryCurrentState(ctx, model, force, controller.signal);
323
+ if (!sessionActive || generation !== statusGeneration || controller.signal.aborted) return;
324
+ if (!(await outcomeStillCurrent(ctx, model, generation, outcome, controller.signal))) {
325
+ if (sessionActive && generation === statusGeneration) {
326
+ queueMicrotask(() => startStatusRefresh(ctx, ctx.model, false));
327
+ }
328
+ return;
329
+ }
330
+ publishStatus(ctx, outcome, model, true);
331
+ } finally {
332
+ activeControllers.delete(controller);
333
+ if (statusController === controller) statusController = undefined;
334
+ }
335
+ };
336
+
337
+ const startStatusRefresh = (
338
+ ctx: ExtensionContext,
339
+ model: PiModel | undefined,
340
+ force: boolean,
341
+ ) => {
342
+ void refreshCurrentStatus(ctx, model, force).catch((error) => {
343
+ if (isStaleExtensionContextError(error) || isAbortError(error)) return;
344
+ safeSetStatus(ctx, "usage error");
345
+ });
346
+ };
347
+
348
+ const runMenuOperation = async <T>(
349
+ ctx: ExtensionCommandContext,
350
+ label: string,
351
+ parentSignal: AbortSignal,
352
+ operation: (signal: AbortSignal) => Promise<T>,
353
+ ): Promise<T | undefined> => {
354
+ if (ctx.mode !== "tui") return operation(parentSignal);
355
+ const result = await ctx.ui.custom<LoaderResult<T> | null>((tui, theme, _keybindings, done) => {
356
+ const loader = new BorderedLoader(tui, theme, label);
357
+ let finished = false;
358
+ const finish = (value: LoaderResult<T> | null) => {
359
+ if (finished) return;
360
+ finished = true;
361
+ done(value);
362
+ };
363
+ loader.onAbort = () => finish(null);
364
+ const signal = AbortSignal.any([parentSignal, loader.signal]);
365
+ void operation(signal)
366
+ .then((value) => finish({ ok: true, value }))
367
+ .catch((error) => {
368
+ if (isAbortError(error)) finish(null);
369
+ else finish({ ok: false, error });
370
+ });
371
+ return loader;
372
+ });
373
+ if (!result) return undefined;
374
+ if (!result.ok) throw result.error;
375
+ return result.value;
376
+ };
377
+
378
+ const outcomeStillCurrent = async (
379
+ ctx: ExtensionContext,
380
+ model: PiModel | undefined,
381
+ generation: number,
382
+ outcome: QueryOutcome,
383
+ signal: AbortSignal,
384
+ ): Promise<boolean> => {
385
+ if (generation !== statusGeneration || modelIdentity(ctx.model) !== modelIdentity(model)) {
386
+ return false;
387
+ }
388
+ const adapter = adapterForProvider(model?.provider);
389
+ if (outcome.authState === "unavailable") {
390
+ if (!adapter) return false;
391
+ try {
392
+ const auth = await awaitWithDeadline(
393
+ resolveUsageAuth(ctx, adapter),
394
+ signal,
395
+ DEFAULT_TIMEOUT_MS,
396
+ `revalidating ${adapter.displayName} runtime auth`,
397
+ );
398
+ return (
399
+ generation === statusGeneration &&
400
+ modelIdentity(ctx.model) === modelIdentity(model) &&
401
+ auth === undefined
402
+ );
403
+ } catch (error) {
404
+ if (isAbortError(error) || isStaleExtensionContextError(error)) throw error;
405
+ return false;
406
+ }
407
+ }
408
+ if (!outcome.fingerprint) return true;
409
+ if (!adapter) return false;
410
+ try {
411
+ const auth = await awaitWithDeadline(
412
+ resolveUsageAuth(ctx, adapter),
413
+ signal,
414
+ DEFAULT_TIMEOUT_MS,
415
+ `revalidating ${adapter.displayName} runtime auth`,
416
+ );
417
+ return (
418
+ generation === statusGeneration &&
419
+ modelIdentity(ctx.model) === modelIdentity(model) &&
420
+ auth?.fingerprint === outcome.fingerprint
421
+ );
422
+ } catch (error) {
423
+ if (isAbortError(error) || isStaleExtensionContextError(error)) throw error;
424
+ return false;
425
+ }
426
+ };
427
+
428
+ const queryStableCurrent = async (
429
+ ctx: ExtensionCommandContext,
430
+ force: boolean,
431
+ controller: AbortController,
432
+ label: string,
433
+ ): Promise<StableCurrent | undefined> => {
434
+ for (let attempt = 0; attempt < 3; attempt += 1) {
435
+ const model = ctx.model;
436
+ const generation = statusGeneration;
437
+ const result = await runMenuOperation(ctx, label, controller.signal, async (signal) => {
438
+ const outcome = await queryCurrentState(ctx, model, force, signal);
439
+ return {
440
+ outcome,
441
+ stable: await outcomeStillCurrent(ctx, model, generation, outcome, signal),
442
+ };
443
+ });
444
+ if (!result) return undefined;
445
+ if (result.stable) return { outcome: result.outcome, model };
446
+ force = false;
447
+ }
448
+ ctx.ui.notify("The active model or account kept changing; reopen /usage to retry.", "warning");
449
+ return undefined;
450
+ };
451
+
452
+ const publishStableCurrent = (ctx: ExtensionCommandContext, current: StableCurrent) => {
453
+ if (current.model) publishStatus(ctx, current.outcome, current.model, sessionActive);
454
+ else safeSetStatus(ctx, undefined);
455
+ };
456
+
457
+ const showMenu = async (ctx: ExtensionCommandContext): Promise<void> => {
458
+ if (!ctx.hasUI) {
459
+ ctx.ui.notify("/usage requires an interactive Pi mode.", "warning");
460
+ return;
461
+ }
462
+ statusGeneration += 1;
463
+ statusController?.abort();
464
+ statusController = undefined;
465
+ clearStatusTimer();
466
+ const controller = new AbortController();
467
+ activeControllers.add(controller);
468
+ try {
469
+ let stableCurrent = await queryStableCurrent(
470
+ ctx,
471
+ false,
472
+ controller,
473
+ "Checking current usage…",
474
+ );
475
+ if (!stableCurrent) return;
476
+ publishStableCurrent(ctx, stableCurrent);
477
+ let current = stableCurrent.outcome;
478
+ let visibleStates: ProviderUsageState[] = [current.state];
479
+
480
+ while (!controller.signal.aborted) {
481
+ const action = await ctx.ui.select(formatProviderStates(visibleStates), [...MENU_ACTIONS], {
482
+ signal: controller.signal,
483
+ });
484
+ if (!action || action === CLOSE) return;
485
+ if (action === REFRESH_CURRENT) {
486
+ stableCurrent = await queryStableCurrent(
487
+ ctx,
488
+ true,
489
+ controller,
490
+ "Refreshing current usage…",
491
+ );
492
+ if (!stableCurrent) continue;
493
+ publishStableCurrent(ctx, stableCurrent);
494
+ current = stableCurrent.outcome;
495
+ visibleStates = [current.state];
496
+ continue;
497
+ }
498
+ if (action === VIEW_ANOTHER) {
499
+ const others = configuredAdapters(ctx).filter(
500
+ (adapter) => adapter.id !== ctx.model?.provider,
501
+ );
502
+ if (others.length === 0) {
503
+ ctx.ui.notify("No other supported provider has configured runtime auth.", "info");
504
+ continue;
505
+ }
506
+ const choice = await ctx.ui.select(
507
+ "Select a configured provider",
508
+ others.map((adapter) => adapter.displayName),
509
+ { signal: controller.signal },
510
+ );
511
+ const adapter = others.find((candidate) => candidate.displayName === choice);
512
+ if (!adapter) continue;
513
+ const outcome = await runMenuOperation(
514
+ ctx,
515
+ `Checking ${adapter.displayName} usage…`,
516
+ controller.signal,
517
+ (signal) => queryAdapterState(ctx, adapter, "configured", false, signal),
518
+ );
519
+ if (!outcome) continue;
520
+ const revalidated = await queryStableCurrent(
521
+ ctx,
522
+ false,
523
+ controller,
524
+ "Revalidating current usage…",
525
+ );
526
+ if (!revalidated) continue;
527
+ stableCurrent = revalidated;
528
+ current = revalidated.outcome;
529
+ visibleStates =
530
+ outcome.state.providerId === current.state.providerId
531
+ ? [current.state]
532
+ : [current.state, { ...outcome.state, displayState: "configured" }];
533
+ continue;
534
+ }
535
+ if (action === VIEW_ALL) {
536
+ const adapters = configuredAdapters(ctx);
537
+ const currentProviderId = ctx.model?.provider;
538
+ const settled = await runMenuOperation(
539
+ ctx,
540
+ "Checking configured provider usage…",
541
+ controller.signal,
542
+ (signal) =>
543
+ runWithConcurrency(
544
+ adapters,
545
+ ALL_PROVIDER_CONCURRENCY,
546
+ (adapter, _index, workerSignal) =>
547
+ queryAdapterState(
548
+ ctx,
549
+ adapter,
550
+ adapter.id === currentProviderId ? "current" : "configured",
551
+ true,
552
+ workerSignal,
553
+ ),
554
+ signal,
555
+ ),
556
+ );
557
+ if (!settled) continue;
558
+ const queriedStates: ProviderUsageState[] = settled.map((result, index) => {
559
+ if (result.status === "fulfilled") {
560
+ return { ...result.value.state, displayState: "configured" };
561
+ }
562
+ const adapter = adapters[index] as UsageProviderAdapter;
563
+ return {
564
+ providerId: adapter.id,
565
+ providerName: adapter.displayName,
566
+ displayState: "configured",
567
+ status: "query-failed",
568
+ message: errorMessage(result.reason),
569
+ };
570
+ });
571
+ const revalidated = await queryStableCurrent(
572
+ ctx,
573
+ false,
574
+ controller,
575
+ "Revalidating current usage…",
576
+ );
577
+ if (!revalidated) continue;
578
+ stableCurrent = revalidated;
579
+ current = revalidated.outcome;
580
+ visibleStates = [
581
+ current.state,
582
+ ...queriedStates.filter((state) => state.providerId !== current.state.providerId),
583
+ ];
584
+ }
585
+ }
586
+ } finally {
587
+ controller.abort();
588
+ activeControllers.delete(controller);
589
+ }
590
+ };
591
+
592
+ const commandHandler = async (args: string, ctx: ExtensionCommandContext) => {
593
+ if (args.trim()) {
594
+ ctx.ui.notify("/usage does not accept arguments; choose an action from its menu.", "warning");
595
+ return;
596
+ }
597
+ try {
598
+ await showMenu(ctx);
599
+ } catch (error) {
600
+ if (isStaleExtensionContextError(error) || isAbortError(error)) return;
601
+ throw error;
602
+ }
603
+ };
604
+
605
+ pi.registerCommand("usage", {
606
+ description: "Show usage for the current runtime account",
607
+ handler: commandHandler,
608
+ });
609
+ pi.registerCommand("codex-status", {
610
+ description: "Open /usage (temporary compatibility alias)",
611
+ handler: commandHandler,
612
+ });
613
+
614
+ pi.on("session_start", (_event, ctx) => {
615
+ sessionActive = true;
616
+ startStatusRefresh(ctx, ctx.model, false);
617
+ });
618
+ pi.on("session_tree", (_event, ctx) => {
619
+ startStatusRefresh(ctx, ctx.model, false);
620
+ });
621
+ pi.on("model_select", (event, ctx) => {
622
+ startStatusRefresh(ctx, event.model, false);
623
+ });
624
+ pi.on("turn_start", (_event, ctx) => {
625
+ startStatusRefresh(ctx, ctx.model, false);
626
+ });
627
+ pi.on("session_shutdown", (_event, ctx) => {
628
+ sessionActive = false;
629
+ statusGeneration += 1;
630
+ clearStatusTimer();
631
+ for (const controller of activeControllers) controller.abort();
632
+ activeControllers.clear();
633
+ statusController = undefined;
634
+ cache.clear();
635
+ failureBackoff.clear();
636
+ latestQueries.clear();
637
+ activeCurrentIdentity = undefined;
638
+ safeSetStatus(ctx, undefined);
639
+ });
640
+ }
641
+
642
+ function configuredAdapters(ctx: ExtensionContext): UsageProviderAdapter[] {
643
+ return SUPPORTED_ADAPTERS.filter(
644
+ (adapter) => adapter.id === ctx.model?.provider || providerIsConfigured(ctx, adapter.id),
645
+ );
646
+ }
647
+
648
+ function providerDisplayName(ctx: ExtensionContext, providerId: string): string {
649
+ try {
650
+ return sanitizeDisplayText(ctx.modelRegistry.getProviderDisplayName(providerId), 80);
651
+ } catch {
652
+ return sanitizeDisplayText(providerId, 80);
653
+ }
654
+ }
655
+
656
+ function setBoundedMap<T>(map: Map<string, T>, key: string, value: T, limit: number): void {
657
+ map.delete(key);
658
+ while (map.size >= limit) {
659
+ const oldest = map.keys().next().value;
660
+ if (oldest === undefined) break;
661
+ map.delete(oldest);
662
+ }
663
+ map.set(key, value);
664
+ }
665
+
666
+ function modelIdentity(model: PiModel | undefined): string | undefined {
667
+ return model ? `${model.provider}/${model.id}` : undefined;
668
+ }
669
+
670
+ function isAbortError(error: unknown): boolean {
671
+ return error instanceof Error && error.name === "AbortError";
672
+ }
673
+
674
+ function isTimeoutError(error: unknown): boolean {
675
+ return error instanceof Error && error.name === "TimeoutError";
676
+ }