@narumitw/pi-usage 0.60.0 → 0.60.2

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
@@ -37,7 +37,11 @@ import {
37
37
  queryProviderUsage,
38
38
  resolveUsageAuth,
39
39
  } from "./query.js";
40
- import { createUsageSettingsRuntime, type UsageSettingsRuntime } from "./settings.js";
40
+ import {
41
+ createUsageSettingsRuntime,
42
+ type UsageSettingsRuntime,
43
+ type UsageSettingsState,
44
+ } from "./settings.js";
41
45
  import type {
42
46
  PiModel,
43
47
  ProviderUsageState,
@@ -54,6 +58,11 @@ import {
54
58
  setBoundedMap,
55
59
  } from "./usage-helpers.js";
56
60
  import { showUsageSettings } from "./usage-settings-ui.js";
61
+ import {
62
+ createUsageTargetSelectOptions,
63
+ listUsageTargets,
64
+ resolveUsageTarget,
65
+ } from "./usage-targets.js";
57
66
 
58
67
  const CACHE_TTL_MS = 5 * 60 * 1000;
59
68
  const STATUS_COUNTDOWN_REFRESH_MS = 60 * 1000;
@@ -80,8 +89,13 @@ type QueryOutcome = {
80
89
  state: ProviderUsageState;
81
90
  fingerprint?: string;
82
91
  authState?: "unavailable";
92
+ rememberedTargetId?: string;
83
93
  };
84
94
 
95
+ class UsageTargetSelectionChangedError extends Error {
96
+ override readonly name = "UsageTargetSelectionChangedError";
97
+ }
98
+
85
99
  type StableCurrent = {
86
100
  outcome: QueryOutcome;
87
101
  model: PiModel | undefined;
@@ -171,12 +185,14 @@ export default function usageExtension(
171
185
  return;
172
186
  }
173
187
  if (outcome.state.status !== "ready") {
174
- if (
175
- safeSetStatus(
176
- ctx,
177
- outcome.state.status === "auth-unavailable" ? "auth unavailable" : "usage error",
178
- )
179
- ) {
188
+ const chip =
189
+ outcome.state.status === "auth-unavailable"
190
+ ? "auth unavailable"
191
+ : outcome.state.status === "selection-required"
192
+ ? "selection required"
193
+ : `usage err: ${outcome.state.message.slice(0, 50)}`;
194
+
195
+ if (safeSetStatus(ctx, chip)) {
180
196
  if (shouldSchedule && sessionActive) scheduleStatusRefresh(ctx, model);
181
197
  }
182
198
  return;
@@ -248,10 +264,10 @@ export default function usageExtension(
248
264
  const expectedSessionGeneration = sessionGeneration;
249
265
  const expectedSessionId = ctx.sessionManager.getSessionId();
250
266
  const expectedModelIdentity = modelIdentity(ctx.model);
251
- const expectedFireworksAccountId =
252
- adapter.id === "fireworks" ? settingsRuntime.get().settings.fireworksAccountId : undefined;
253
- const querySettings =
254
- adapter.id === "fireworks" ? { fireworksAccountId: expectedFireworksAccountId } : undefined;
267
+ const expectedTargetId = adapter.targets
268
+ ? settingsRuntime.get().settings.selectedTargets[adapter.id]
269
+ : undefined;
270
+ const providerName = providerDisplayName(ctx, adapter.id);
255
271
  let auth: ResolvedUsageAuth | undefined;
256
272
  try {
257
273
  auth = await awaitWithDeadline(
@@ -268,32 +284,33 @@ export default function usageExtension(
268
284
  return {
269
285
  state: {
270
286
  providerId: adapter.id,
271
- providerName: adapter.displayName,
287
+ providerName,
272
288
  displayState,
273
289
  status: isTimeoutError(error) ? "query-failed" : "auth-unavailable",
274
290
  message: errorMessage(error),
275
291
  },
276
292
  };
277
293
  }
278
- const requiresRequestBoundaryGuard = [
279
- "baseten",
280
- "deepseek",
281
- "fireworks",
282
- "minimax",
283
- "minimax-cn",
284
- "moonshotai",
285
- "moonshotai-cn",
286
- "vercel-ai-gateway",
287
- "xai",
288
- "zai",
289
- "zai-coding-cn",
290
- ].includes(adapter.id);
294
+ const requiresRequestBoundaryGuard =
295
+ adapter.targets !== undefined ||
296
+ [
297
+ "baseten",
298
+ "deepseek",
299
+ "minimax",
300
+ "minimax-cn",
301
+ "moonshotai",
302
+ "moonshotai-cn",
303
+ "vercel-ai-gateway",
304
+ "xai",
305
+ "zai",
306
+ "zai-coding-cn",
307
+ ].includes(adapter.id);
291
308
  const requestContextChanged = () =>
292
309
  expectedSessionGeneration !== sessionGeneration ||
293
310
  ctx.sessionManager.getSessionId() !== expectedSessionId ||
294
311
  modelIdentity(ctx.model) !== expectedModelIdentity ||
295
- (adapter.id === "fireworks" &&
296
- settingsRuntime.get().settings.fireworksAccountId !== expectedFireworksAccountId);
312
+ (adapter.targets !== undefined &&
313
+ settingsRuntime.get().settings.selectedTargets[adapter.id] !== expectedTargetId);
297
314
  if (requiresRequestBoundaryGuard && requestContextChanged()) throw abortError();
298
315
  if (!auth) {
299
316
  if (displayState === "current") {
@@ -302,101 +319,142 @@ export default function usageExtension(
302
319
  return {
303
320
  state: {
304
321
  providerId: adapter.id,
305
- providerName: adapter.displayName,
322
+ providerName,
306
323
  displayState,
307
324
  status: "auth-unavailable",
308
- message: `No runtime credential is configured for ${adapter.displayName}.`,
325
+ message: `No runtime credential is configured for ${providerName}.`,
309
326
  },
310
327
  authState: "unavailable",
311
328
  };
312
329
  }
313
- const queryFingerprint =
314
- adapter.id === "fireworks"
315
- ? `${auth.fingerprint}:account:${expectedFireworksAccountId ?? "auto"}`
316
- : auth.fingerprint;
317
- if (displayState === "current") {
318
- transitionCurrentIdentity(`${adapter.id}:${queryFingerprint}`, adapter.id);
319
- }
320
-
321
- const cached = !force ? cache.get(adapter.id, queryFingerprint) : undefined;
322
- if (cached) {
323
- return {
324
- state: {
325
- providerId: adapter.id,
326
- providerName: adapter.displayName,
327
- displayState,
328
- status: "ready",
329
- report: cached,
330
- },
331
- fingerprint: auth.fingerprint,
332
- };
333
- }
334
-
335
- const failureKey = `${adapter.id}:${queryFingerprint}`;
336
- const previousFailure = failureBackoff.get(failureKey);
337
- if (!force && previousFailure && previousFailure.until > Date.now()) {
338
- return {
339
- state: {
340
- providerId: adapter.id,
341
- providerName: adapter.displayName,
342
- displayState,
343
- status: "query-failed",
344
- message: previousFailure.message,
345
- },
346
- fingerprint: auth.fingerprint,
347
- };
348
- }
349
- failureBackoff.delete(failureKey);
350
- querySequence += 1;
351
- const queryId = querySequence;
352
- setBoundedMap(latestQueries, failureKey, queryId, MAX_ACCOUNT_STATES);
353
-
354
330
  let retryableAuthChanged = false;
331
+ const guard = async () => {
332
+ if (signal.aborted || requestContextChanged()) throw abortError();
333
+ if (!requiresRequestBoundaryGuard) return;
334
+ const revalidated = await awaitWithDeadline(
335
+ resolveUsageAuth(ctx, adapter, undefined, credentialReader, credentialCandidates),
336
+ signal,
337
+ Math.max(1, deadlineAt - Date.now()),
338
+ `revalidating ${providerName} runtime auth`,
339
+ );
340
+ if (signal.aborted || requestContextChanged()) throw abortError();
341
+ if (revalidated?.fingerprint !== auth.fingerprint) {
342
+ if (["deepseek", "minimax", "minimax-cn"].includes(adapter.id)) {
343
+ retryableAuthChanged = true;
344
+ throw new Error(`${providerName} runtime credential changed during the usage query.`);
345
+ }
346
+ throw abortError();
347
+ }
348
+ };
349
+ let queryFingerprint = adapter.targets
350
+ ? `${auth.fingerprint}:target:${expectedTargetId ?? "unresolved"}`
351
+ : auth.fingerprint;
352
+ let failureKey = `${adapter.id}:${queryFingerprint}`;
353
+ let queryId: number | undefined;
355
354
  try {
356
- const remainingMs = Math.max(1, deadlineAt - Date.now());
357
- const guard = requiresRequestBoundaryGuard
358
- ? async () => {
359
- if (signal.aborted || requestContextChanged()) throw abortError();
360
- const revalidated = await awaitWithDeadline(
361
- resolveUsageAuth(ctx, adapter, undefined, credentialReader, credentialCandidates),
362
- signal,
363
- Math.max(1, deadlineAt - Date.now()),
364
- `revalidating ${adapter.displayName} runtime auth`,
365
- );
366
- if (signal.aborted || requestContextChanged()) throw abortError();
367
- if (revalidated?.fingerprint !== auth.fingerprint) {
368
- if (["deepseek", "minimax", "minimax-cn"].includes(adapter.id)) {
369
- retryableAuthChanged = true;
370
- throw new Error(
371
- `${adapter.displayName} runtime credential changed during the usage query.`,
372
- );
373
- }
374
- throw abortError();
375
- }
376
- }
377
- : undefined;
378
- const report = await queryProviderUsage(
355
+ const previousDiscoveryFailure = failureBackoff.get(failureKey);
356
+ if (!force && previousDiscoveryFailure && previousDiscoveryFailure.until > Date.now()) {
357
+ return {
358
+ state: {
359
+ providerId: adapter.id,
360
+ providerName,
361
+ displayState,
362
+ status: "query-failed",
363
+ message: previousDiscoveryFailure.message,
364
+ },
365
+ fingerprint: auth.fingerprint,
366
+ rememberedTargetId: expectedTargetId,
367
+ };
368
+ }
369
+ const target = await resolveUsageTarget(
379
370
  adapter,
380
371
  auth,
372
+ expectedTargetId,
381
373
  signal,
382
- remainingMs,
374
+ Math.max(1, deadlineAt - Date.now()),
383
375
  guard,
384
- querySettings,
385
376
  );
386
- if (guard) await guard();
377
+ if (target.kind === "selection-required") {
378
+ if (displayState === "current") {
379
+ transitionCurrentIdentity(`${adapter.id}:${queryFingerprint}`, adapter.id);
380
+ }
381
+ return {
382
+ state: {
383
+ providerId: adapter.id,
384
+ providerName,
385
+ displayState,
386
+ status: "selection-required",
387
+ singularLabel: adapter.targets?.singularLabel ?? "target",
388
+ pluralLabel: adapter.targets?.pluralLabel ?? "targets",
389
+ choices: target.choices,
390
+ },
391
+ fingerprint: auth.fingerprint,
392
+ rememberedTargetId: expectedTargetId,
393
+ };
394
+ }
395
+ queryFingerprint = adapter.targets
396
+ ? `${auth.fingerprint}:target:${target.targetId ?? "none"}`
397
+ : auth.fingerprint;
398
+ failureKey = `${adapter.id}:${queryFingerprint}`;
399
+ if (displayState === "current") {
400
+ transitionCurrentIdentity(`${adapter.id}:${queryFingerprint}`, adapter.id);
401
+ }
402
+ const cached = !force ? cache.get(adapter.id, queryFingerprint) : undefined;
403
+ if (cached) {
404
+ return {
405
+ state: {
406
+ providerId: adapter.id,
407
+ providerName,
408
+ displayState,
409
+ status: "ready",
410
+ report: cached,
411
+ },
412
+ fingerprint: auth.fingerprint,
413
+ rememberedTargetId: expectedTargetId,
414
+ };
415
+ }
416
+ const previousFailure = failureBackoff.get(failureKey);
417
+ if (!force && previousFailure && previousFailure.until > Date.now()) {
418
+ return {
419
+ state: {
420
+ providerId: adapter.id,
421
+ providerName,
422
+ displayState,
423
+ status: "query-failed",
424
+ message: previousFailure.message,
425
+ },
426
+ fingerprint: auth.fingerprint,
427
+ rememberedTargetId: expectedTargetId,
428
+ };
429
+ }
430
+ failureBackoff.delete(failureKey);
431
+ querySequence += 1;
432
+ queryId = querySequence;
433
+ setBoundedMap(latestQueries, failureKey, queryId, MAX_ACCOUNT_STATES);
434
+ const report = await queryProviderUsage(
435
+ adapter,
436
+ auth,
437
+ signal,
438
+ Math.max(1, deadlineAt - Date.now()),
439
+ requiresRequestBoundaryGuard ? guard : undefined,
440
+ target.targetId,
441
+ );
442
+ if (requiresRequestBoundaryGuard) await guard();
443
+ const effectiveReport = { ...report, providerName };
387
444
  if (latestQueries.get(failureKey) === queryId) {
388
- cache.set(adapter.id, queryFingerprint, report);
445
+ cache.set(adapter.id, queryFingerprint, effectiveReport);
389
446
  failureBackoff.delete(failureKey);
390
447
  }
391
448
  return {
392
449
  state: {
393
450
  providerId: adapter.id,
394
- providerName: adapter.displayName,
451
+ providerName,
395
452
  displayState,
396
453
  status: "ready",
397
- report,
454
+ report: effectiveReport,
398
455
  },
399
456
  fingerprint: auth.fingerprint,
457
+ rememberedTargetId: expectedTargetId,
400
458
  };
401
459
  } catch (error) {
402
460
  if (isStaleExtensionContextError(error) || isAbortError(error)) throw error;
@@ -407,7 +465,9 @@ export default function usageExtension(
407
465
  !requestContextChanged() &&
408
466
  Date.now() < deadlineAt
409
467
  ) {
410
- if (latestQueries.get(failureKey) === queryId) latestQueries.delete(failureKey);
468
+ if (queryId !== undefined && latestQueries.get(failureKey) === queryId) {
469
+ latestQueries.delete(failureKey);
470
+ }
411
471
  return queryAdapterState(
412
472
  ctx,
413
473
  adapter,
@@ -423,7 +483,7 @@ export default function usageExtension(
423
483
  for (const [key, failure] of failureBackoff) {
424
484
  if (failure.until <= now) failureBackoff.delete(key);
425
485
  }
426
- if (latestQueries.get(failureKey) === queryId) {
486
+ if (queryId === undefined || latestQueries.get(failureKey) === queryId) {
427
487
  setBoundedMap(
428
488
  failureBackoff,
429
489
  failureKey,
@@ -434,16 +494,62 @@ export default function usageExtension(
434
494
  return {
435
495
  state: {
436
496
  providerId: adapter.id,
437
- providerName: adapter.displayName,
497
+ providerName,
438
498
  displayState,
439
499
  status: "query-failed",
440
500
  message,
441
501
  },
442
502
  fingerprint: auth.fingerprint,
503
+ rememberedTargetId: expectedTargetId,
443
504
  };
444
505
  }
445
506
  };
446
507
 
508
+ const loadTargetChoices = async (
509
+ ctx: ExtensionContext,
510
+ adapter: UsageProviderAdapter,
511
+ signal: AbortSignal,
512
+ ) => {
513
+ if (!adapter.targets) throw new Error("Provider does not support usage targets.");
514
+ const expectedSessionGeneration = sessionGeneration;
515
+ const expectedSessionId = ctx.sessionManager.getSessionId();
516
+ const expectedModel = modelIdentity(ctx.model);
517
+ const expectedTargetId = settingsRuntime.get().settings.selectedTargets[adapter.id];
518
+ const deadlineAt = Date.now() + DEFAULT_TIMEOUT_MS;
519
+ const changed = () =>
520
+ expectedSessionGeneration !== sessionGeneration ||
521
+ ctx.sessionManager.getSessionId() !== expectedSessionId ||
522
+ modelIdentity(ctx.model) !== expectedModel ||
523
+ settingsRuntime.get().settings.selectedTargets[adapter.id] !== expectedTargetId;
524
+ const auth = await awaitWithDeadline(
525
+ resolveUsageAuth(ctx, adapter, undefined, credentialReader, credentialCandidates),
526
+ signal,
527
+ Math.max(1, deadlineAt - Date.now()),
528
+ `resolving ${providerDisplayName(ctx, adapter.id)} runtime auth`,
529
+ );
530
+ if (!auth || signal.aborted || changed()) throw abortError();
531
+ const guard = async () => {
532
+ if (signal.aborted || changed()) throw abortError();
533
+ const revalidated = await awaitWithDeadline(
534
+ resolveUsageAuth(ctx, adapter, undefined, credentialReader, credentialCandidates),
535
+ signal,
536
+ Math.max(1, deadlineAt - Date.now()),
537
+ `revalidating ${providerDisplayName(ctx, adapter.id)} runtime auth`,
538
+ );
539
+ if (signal.aborted || changed() || revalidated?.fingerprint !== auth.fingerprint) {
540
+ throw abortError();
541
+ }
542
+ };
543
+ const choices = await listUsageTargets(
544
+ adapter,
545
+ auth,
546
+ signal,
547
+ Math.max(1, deadlineAt - Date.now()),
548
+ guard,
549
+ );
550
+ return { choices, fingerprint: auth.fingerprint };
551
+ };
552
+
447
553
  const queryCurrentState = async (
448
554
  ctx: ExtensionContext,
449
555
  model: PiModel | undefined,
@@ -516,7 +622,9 @@ export default function usageExtension(
516
622
  ) => {
517
623
  void refreshCurrentStatus(ctx, model, force).catch((error) => {
518
624
  if (isStaleExtensionContextError(error) || isAbortError(error)) return;
519
- safeSetStatus(ctx, "usage error");
625
+ const message = errorMessage(error);
626
+ console.error("[pi-usage] refresh failed:", message);
627
+ safeSetStatus(ctx, `usage err: ${message.slice(0, 50)}`);
520
628
  });
521
629
  };
522
630
 
@@ -558,6 +666,10 @@ export default function usageExtension(
558
666
  return false;
559
667
  }
560
668
  const adapter = adapterForProvider(model?.provider);
669
+ const selectionStillCurrent =
670
+ !adapter?.targets ||
671
+ settingsRuntime.get().settings.selectedTargets[adapter.id] === outcome.rememberedTargetId;
672
+ if (!selectionStillCurrent) return false;
561
673
  if (outcome.authState === "unavailable") {
562
674
  if (!adapter) return false;
563
675
  try {
@@ -570,6 +682,9 @@ export default function usageExtension(
570
682
  return (
571
683
  generation === statusGeneration &&
572
684
  modelIdentity(ctx.model) === modelIdentity(model) &&
685
+ (!adapter.targets ||
686
+ settingsRuntime.get().settings.selectedTargets[adapter.id] ===
687
+ outcome.rememberedTargetId) &&
573
688
  auth === undefined
574
689
  );
575
690
  } catch (error) {
@@ -589,6 +704,9 @@ export default function usageExtension(
589
704
  return (
590
705
  generation === statusGeneration &&
591
706
  modelIdentity(ctx.model) === modelIdentity(model) &&
707
+ (!adapter.targets ||
708
+ settingsRuntime.get().settings.selectedTargets[adapter.id] ===
709
+ outcome.rememberedTargetId) &&
592
710
  auth?.fingerprint === outcome.fingerprint
593
711
  );
594
712
  } catch (error) {
@@ -654,6 +772,121 @@ export default function usageExtension(
654
772
  let redemptionId: string | undefined;
655
773
  let resetOutcome: CodexResetOutcome | undefined;
656
774
  let resetFailure: string | undefined;
775
+ const actionableTargetState = (): ProviderUsageState | undefined => {
776
+ if (visibleStates.length !== 1) return undefined;
777
+ const state = visibleStates[0];
778
+ return state &&
779
+ (state.status === "ready" || state.status === "selection-required") &&
780
+ adapterForProvider(state.providerId)?.targets
781
+ ? state
782
+ : undefined;
783
+ };
784
+ const promptForTarget = async (
785
+ state: ProviderUsageState,
786
+ stateFingerprint?: string,
787
+ ): Promise<boolean> => {
788
+ const adapter = adapterForProvider(state.providerId);
789
+ if (!adapter?.targets) return false;
790
+ const expectedRememberedTargetId =
791
+ settingsRuntime.get().settings.selectedTargets[adapter.id];
792
+ const snapshot =
793
+ state.status === "selection-required" && stateFingerprint
794
+ ? { choices: state.choices, fingerprint: stateFingerprint }
795
+ : await runMenuOperation(
796
+ ctx,
797
+ `Loading ${adapter.targets.pluralLabel}…`,
798
+ controller.signal,
799
+ (signal) => loadTargetChoices(ctx, adapter, signal),
800
+ );
801
+ if (!snapshot || controller.signal.aborted || statusGeneration !== menuGeneration) {
802
+ return false;
803
+ }
804
+ const selectOptions = createUsageTargetSelectOptions(snapshot.choices);
805
+ const selected = await ctx.ui.select(
806
+ `Select ${adapter.targets.singularLabel} for ${providerDisplayName(ctx, adapter.id)}`,
807
+ [...selectOptions.options],
808
+ { signal: controller.signal },
809
+ );
810
+ if (
811
+ selected === undefined ||
812
+ controller.signal.aborted ||
813
+ statusGeneration !== menuGeneration ||
814
+ settingsRuntime.get().settings.selectedTargets[adapter.id] !== expectedRememberedTargetId
815
+ ) {
816
+ return false;
817
+ }
818
+ const targetId = selectOptions.targetIdFor(selected);
819
+ if (!targetId) return false;
820
+ const revalidated = await runMenuOperation(
821
+ ctx,
822
+ `Revalidating ${adapter.targets.singularLabel}…`,
823
+ controller.signal,
824
+ (signal) => loadTargetChoices(ctx, adapter, signal),
825
+ );
826
+ if (
827
+ !revalidated ||
828
+ controller.signal.aborted ||
829
+ statusGeneration !== menuGeneration ||
830
+ settingsRuntime.get().settings.selectedTargets[adapter.id] !== expectedRememberedTargetId
831
+ ) {
832
+ return false;
833
+ }
834
+ if (
835
+ revalidated.fingerprint !== snapshot.fingerprint ||
836
+ !revalidated.choices.some((choice) => choice.id === targetId)
837
+ ) {
838
+ ctx.ui.notify(
839
+ `${providerDisplayName(ctx, adapter.id)} ${adapter.targets.pluralLabel} changed; choose again.`,
840
+ "warning",
841
+ );
842
+ return false;
843
+ }
844
+ let saved: Readonly<UsageSettingsState> | undefined;
845
+ try {
846
+ saved = await runMenuOperation(
847
+ ctx,
848
+ `Saving ${adapter.targets.singularLabel}…`,
849
+ controller.signal,
850
+ (signal) =>
851
+ settingsRuntime.updateSelectedTarget(adapter.id, targetId, signal, async () => {
852
+ let published: Awaited<ReturnType<typeof loadTargetChoices>>;
853
+ try {
854
+ published = await loadTargetChoices(ctx, adapter, signal);
855
+ } catch (error) {
856
+ if (
857
+ signal.aborted ||
858
+ controller.signal.aborted ||
859
+ statusGeneration !== menuGeneration ||
860
+ isStaleExtensionContextError(error)
861
+ ) {
862
+ throw error;
863
+ }
864
+ throw new UsageTargetSelectionChangedError();
865
+ }
866
+ if (
867
+ published.fingerprint !== snapshot.fingerprint ||
868
+ !published.choices.some((choice) => choice.id === targetId)
869
+ ) {
870
+ throw new UsageTargetSelectionChangedError();
871
+ }
872
+ }),
873
+ );
874
+ } catch (error) {
875
+ if (error instanceof UsageTargetSelectionChangedError) {
876
+ ctx.ui.notify(
877
+ `${providerDisplayName(ctx, adapter.id)} ${adapter.targets.pluralLabel} changed; choose again.`,
878
+ "warning",
879
+ );
880
+ return false;
881
+ }
882
+ throw error;
883
+ }
884
+ if (!saved || controller.signal.aborted || statusGeneration !== menuGeneration) {
885
+ return false;
886
+ }
887
+ invalidateProviderState(adapter.id);
888
+ return true;
889
+ };
657
890
  const { defineMenu, runMenu } = await import("@narumitw/pi-tui-kit");
658
891
  if (controller.signal.aborted || statusGeneration !== menuGeneration) return;
659
892
  type Screen =
@@ -666,6 +899,7 @@ export default function usageExtension(
666
899
  type Action =
667
900
  | "refresh"
668
901
  | "settings"
902
+ | "target"
669
903
  | "toggle-fast"
670
904
  | "another"
671
905
  | "all"
@@ -681,6 +915,8 @@ export default function usageExtension(
681
915
  screens: {
682
916
  main: () => {
683
917
  const fastAvailability = fastRuntime.availability(ctx.model);
918
+ const targetState = actionableTargetState();
919
+ const targetAdapter = adapterForProvider(targetState?.providerId);
684
920
  const fastLines =
685
921
  fastAvailability.kind === "available"
686
922
  ? [`Fast mode: ${fastAvailability.enabled ? "On" : "Off"}`, FAST_USAGE_WARNING]
@@ -694,6 +930,15 @@ export default function usageExtension(
694
930
  items: [
695
931
  { id: "refresh", label: REFRESH_CURRENT, action: "refresh" },
696
932
  { id: "settings", label: SETTINGS, action: "settings" },
933
+ ...(targetState && targetAdapter?.targets
934
+ ? [
935
+ {
936
+ id: "target",
937
+ label: `${targetState.status === "selection-required" ? "Select" : "Change"} ${targetAdapter.targets.singularLabel}…`,
938
+ action: "target" as const,
939
+ },
940
+ ]
941
+ : []),
697
942
  ...(fastAvailability.kind === "available"
698
943
  ? [
699
944
  {
@@ -735,7 +980,7 @@ export default function usageExtension(
735
980
  .filter((adapter) => adapter.id !== ctx.model?.provider)
736
981
  .map((adapter) => ({
737
982
  id: adapter.id,
738
- label: adapter.displayName,
983
+ label: providerDisplayName(ctx, adapter.id),
739
984
  action: "provider" as const,
740
985
  })),
741
986
  hint: "back",
@@ -799,6 +1044,61 @@ export default function usageExtension(
799
1044
  }),
800
1045
  },
801
1046
  actions: {
1047
+ target: async () => {
1048
+ const targetState = actionableTargetState();
1049
+ if (!targetState) return { kind: "rejected" };
1050
+ try {
1051
+ const stateFingerprint =
1052
+ targetState === current.state ? current.fingerprint : undefined;
1053
+ if (!(await promptForTarget(targetState, stateFingerprint))) {
1054
+ return { kind: "stay" };
1055
+ }
1056
+ const adapter = adapterForProvider(targetState.providerId);
1057
+ if (!adapter) return { kind: "rejected" };
1058
+ if (targetState.providerId === ctx.model?.provider) {
1059
+ const refreshed = await queryStableCurrent(
1060
+ ctx,
1061
+ true,
1062
+ controller,
1063
+ `Checking ${providerDisplayName(ctx, adapter.id)} usage…`,
1064
+ );
1065
+ if (!refreshed) return { kind: "stay" };
1066
+ stableCurrent = refreshed;
1067
+ current = refreshed.outcome;
1068
+ visibleStates = [current.state];
1069
+ publishStableCurrent(ctx, refreshed);
1070
+ return { kind: "stay" };
1071
+ }
1072
+ const outcome = await runMenuOperation(
1073
+ ctx,
1074
+ `Checking ${providerDisplayName(ctx, adapter.id)} usage…`,
1075
+ controller.signal,
1076
+ (signal) => queryAdapterState(ctx, adapter, "configured", true, signal),
1077
+ );
1078
+ if (!outcome) return { kind: "stay" };
1079
+ const revalidated = await queryStableCurrent(
1080
+ ctx,
1081
+ false,
1082
+ controller,
1083
+ "Revalidating current usage…",
1084
+ );
1085
+ if (!revalidated) return { kind: "stay" };
1086
+ stableCurrent = revalidated;
1087
+ current = revalidated.outcome;
1088
+ visibleStates = [
1089
+ outcome.state.providerId === current.state.providerId
1090
+ ? current.state
1091
+ : { ...outcome.state, displayState: "configured" },
1092
+ ];
1093
+ return { kind: "stay" };
1094
+ } catch (error) {
1095
+ if (isAbortError(error) || isStaleExtensionContextError(error)) {
1096
+ return { kind: "stay" };
1097
+ }
1098
+ ctx.ui.notify(`Could not select target: ${errorMessage(error)}`, "error");
1099
+ return { kind: "stay" };
1100
+ }
1101
+ },
802
1102
  settings: async () => {
803
1103
  await showUsageSettings(
804
1104
  ctx,
@@ -1023,13 +1323,25 @@ export default function usageExtension(
1023
1323
  (candidate) => candidate.id === itemId && candidate.id !== ctx.model?.provider,
1024
1324
  );
1025
1325
  if (!adapter) return { kind: "back" };
1026
- const outcome = await runMenuOperation(
1326
+ let outcome = await runMenuOperation(
1027
1327
  ctx,
1028
- `Checking ${adapter.displayName} usage…`,
1328
+ `Checking ${providerDisplayName(ctx, adapter.id)} usage…`,
1029
1329
  controller.signal,
1030
1330
  (signal) => queryAdapterState(ctx, adapter, "configured", false, signal),
1031
1331
  );
1032
1332
  if (!outcome) return { kind: "back" };
1333
+ if (outcome.state.status === "selection-required") {
1334
+ if (!(await promptForTarget(outcome.state, outcome.fingerprint))) {
1335
+ return { kind: "back" };
1336
+ }
1337
+ outcome = await runMenuOperation(
1338
+ ctx,
1339
+ `Checking ${providerDisplayName(ctx, adapter.id)} usage…`,
1340
+ controller.signal,
1341
+ (signal) => queryAdapterState(ctx, adapter, "configured", true, signal),
1342
+ );
1343
+ if (!outcome) return { kind: "back" };
1344
+ }
1033
1345
  const revalidated = await queryStableCurrent(
1034
1346
  ctx,
1035
1347
  false,
@@ -1076,7 +1388,7 @@ export default function usageExtension(
1076
1388
  const adapter = adapters[index] as UsageProviderAdapter;
1077
1389
  return {
1078
1390
  providerId: adapter.id,
1079
- providerName: adapter.displayName,
1391
+ providerName: providerDisplayName(ctx, adapter.id),
1080
1392
  displayState: "configured",
1081
1393
  status: "query-failed",
1082
1394
  message: errorMessage(result.reason),