@narumitw/pi-usage 0.59.0 → 0.60.1

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;
@@ -174,7 +188,11 @@ export default function usageExtension(
174
188
  if (
175
189
  safeSetStatus(
176
190
  ctx,
177
- outcome.state.status === "auth-unavailable" ? "auth unavailable" : "usage error",
191
+ outcome.state.status === "auth-unavailable"
192
+ ? "auth unavailable"
193
+ : outcome.state.status === "selection-required"
194
+ ? "selection required"
195
+ : "usage error",
178
196
  )
179
197
  ) {
180
198
  if (shouldSchedule && sessionActive) scheduleStatusRefresh(ctx, model);
@@ -248,10 +266,10 @@ export default function usageExtension(
248
266
  const expectedSessionGeneration = sessionGeneration;
249
267
  const expectedSessionId = ctx.sessionManager.getSessionId();
250
268
  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;
269
+ const expectedTargetId = adapter.targets
270
+ ? settingsRuntime.get().settings.selectedTargets[adapter.id]
271
+ : undefined;
272
+ const providerName = providerDisplayName(ctx, adapter.id);
255
273
  let auth: ResolvedUsageAuth | undefined;
256
274
  try {
257
275
  auth = await awaitWithDeadline(
@@ -268,30 +286,33 @@ export default function usageExtension(
268
286
  return {
269
287
  state: {
270
288
  providerId: adapter.id,
271
- providerName: adapter.displayName,
289
+ providerName,
272
290
  displayState,
273
291
  status: isTimeoutError(error) ? "query-failed" : "auth-unavailable",
274
292
  message: errorMessage(error),
275
293
  },
276
294
  };
277
295
  }
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
- ].includes(adapter.id);
296
+ const requiresRequestBoundaryGuard =
297
+ adapter.targets !== undefined ||
298
+ [
299
+ "baseten",
300
+ "deepseek",
301
+ "minimax",
302
+ "minimax-cn",
303
+ "moonshotai",
304
+ "moonshotai-cn",
305
+ "vercel-ai-gateway",
306
+ "xai",
307
+ "zai",
308
+ "zai-coding-cn",
309
+ ].includes(adapter.id);
289
310
  const requestContextChanged = () =>
290
311
  expectedSessionGeneration !== sessionGeneration ||
291
312
  ctx.sessionManager.getSessionId() !== expectedSessionId ||
292
313
  modelIdentity(ctx.model) !== expectedModelIdentity ||
293
- (adapter.id === "fireworks" &&
294
- settingsRuntime.get().settings.fireworksAccountId !== expectedFireworksAccountId);
314
+ (adapter.targets !== undefined &&
315
+ settingsRuntime.get().settings.selectedTargets[adapter.id] !== expectedTargetId);
295
316
  if (requiresRequestBoundaryGuard && requestContextChanged()) throw abortError();
296
317
  if (!auth) {
297
318
  if (displayState === "current") {
@@ -300,101 +321,142 @@ export default function usageExtension(
300
321
  return {
301
322
  state: {
302
323
  providerId: adapter.id,
303
- providerName: adapter.displayName,
324
+ providerName,
304
325
  displayState,
305
326
  status: "auth-unavailable",
306
- message: `No runtime credential is configured for ${adapter.displayName}.`,
327
+ message: `No runtime credential is configured for ${providerName}.`,
307
328
  },
308
329
  authState: "unavailable",
309
330
  };
310
331
  }
311
- const queryFingerprint =
312
- adapter.id === "fireworks"
313
- ? `${auth.fingerprint}:account:${expectedFireworksAccountId ?? "auto"}`
314
- : auth.fingerprint;
315
- if (displayState === "current") {
316
- transitionCurrentIdentity(`${adapter.id}:${queryFingerprint}`, adapter.id);
317
- }
318
-
319
- const cached = !force ? cache.get(adapter.id, queryFingerprint) : undefined;
320
- if (cached) {
321
- return {
322
- state: {
323
- providerId: adapter.id,
324
- providerName: adapter.displayName,
325
- displayState,
326
- status: "ready",
327
- report: cached,
328
- },
329
- fingerprint: auth.fingerprint,
330
- };
331
- }
332
-
333
- const failureKey = `${adapter.id}:${queryFingerprint}`;
334
- const previousFailure = failureBackoff.get(failureKey);
335
- if (!force && previousFailure && previousFailure.until > Date.now()) {
336
- return {
337
- state: {
338
- providerId: adapter.id,
339
- providerName: adapter.displayName,
340
- displayState,
341
- status: "query-failed",
342
- message: previousFailure.message,
343
- },
344
- fingerprint: auth.fingerprint,
345
- };
346
- }
347
- failureBackoff.delete(failureKey);
348
- querySequence += 1;
349
- const queryId = querySequence;
350
- setBoundedMap(latestQueries, failureKey, queryId, MAX_ACCOUNT_STATES);
351
-
352
332
  let retryableAuthChanged = false;
333
+ const guard = async () => {
334
+ if (signal.aborted || requestContextChanged()) throw abortError();
335
+ if (!requiresRequestBoundaryGuard) return;
336
+ const revalidated = await awaitWithDeadline(
337
+ resolveUsageAuth(ctx, adapter, undefined, credentialReader, credentialCandidates),
338
+ signal,
339
+ Math.max(1, deadlineAt - Date.now()),
340
+ `revalidating ${providerName} runtime auth`,
341
+ );
342
+ if (signal.aborted || requestContextChanged()) throw abortError();
343
+ if (revalidated?.fingerprint !== auth.fingerprint) {
344
+ if (["deepseek", "minimax", "minimax-cn"].includes(adapter.id)) {
345
+ retryableAuthChanged = true;
346
+ throw new Error(`${providerName} runtime credential changed during the usage query.`);
347
+ }
348
+ throw abortError();
349
+ }
350
+ };
351
+ let queryFingerprint = adapter.targets
352
+ ? `${auth.fingerprint}:target:${expectedTargetId ?? "unresolved"}`
353
+ : auth.fingerprint;
354
+ let failureKey = `${adapter.id}:${queryFingerprint}`;
355
+ let queryId: number | undefined;
353
356
  try {
354
- const remainingMs = Math.max(1, deadlineAt - Date.now());
355
- const guard = requiresRequestBoundaryGuard
356
- ? async () => {
357
- if (signal.aborted || requestContextChanged()) throw abortError();
358
- const revalidated = await awaitWithDeadline(
359
- resolveUsageAuth(ctx, adapter, undefined, credentialReader, credentialCandidates),
360
- signal,
361
- Math.max(1, deadlineAt - Date.now()),
362
- `revalidating ${adapter.displayName} runtime auth`,
363
- );
364
- if (signal.aborted || requestContextChanged()) throw abortError();
365
- if (revalidated?.fingerprint !== auth.fingerprint) {
366
- if (["deepseek", "minimax", "minimax-cn"].includes(adapter.id)) {
367
- retryableAuthChanged = true;
368
- throw new Error(
369
- `${adapter.displayName} runtime credential changed during the usage query.`,
370
- );
371
- }
372
- throw abortError();
373
- }
374
- }
375
- : undefined;
376
- const report = await queryProviderUsage(
357
+ const previousDiscoveryFailure = failureBackoff.get(failureKey);
358
+ if (!force && previousDiscoveryFailure && previousDiscoveryFailure.until > Date.now()) {
359
+ return {
360
+ state: {
361
+ providerId: adapter.id,
362
+ providerName,
363
+ displayState,
364
+ status: "query-failed",
365
+ message: previousDiscoveryFailure.message,
366
+ },
367
+ fingerprint: auth.fingerprint,
368
+ rememberedTargetId: expectedTargetId,
369
+ };
370
+ }
371
+ const target = await resolveUsageTarget(
377
372
  adapter,
378
373
  auth,
374
+ expectedTargetId,
379
375
  signal,
380
- remainingMs,
376
+ Math.max(1, deadlineAt - Date.now()),
381
377
  guard,
382
- querySettings,
383
378
  );
384
- if (guard) await guard();
379
+ if (target.kind === "selection-required") {
380
+ if (displayState === "current") {
381
+ transitionCurrentIdentity(`${adapter.id}:${queryFingerprint}`, adapter.id);
382
+ }
383
+ return {
384
+ state: {
385
+ providerId: adapter.id,
386
+ providerName,
387
+ displayState,
388
+ status: "selection-required",
389
+ singularLabel: adapter.targets?.singularLabel ?? "target",
390
+ pluralLabel: adapter.targets?.pluralLabel ?? "targets",
391
+ choices: target.choices,
392
+ },
393
+ fingerprint: auth.fingerprint,
394
+ rememberedTargetId: expectedTargetId,
395
+ };
396
+ }
397
+ queryFingerprint = adapter.targets
398
+ ? `${auth.fingerprint}:target:${target.targetId ?? "none"}`
399
+ : auth.fingerprint;
400
+ failureKey = `${adapter.id}:${queryFingerprint}`;
401
+ if (displayState === "current") {
402
+ transitionCurrentIdentity(`${adapter.id}:${queryFingerprint}`, adapter.id);
403
+ }
404
+ const cached = !force ? cache.get(adapter.id, queryFingerprint) : undefined;
405
+ if (cached) {
406
+ return {
407
+ state: {
408
+ providerId: adapter.id,
409
+ providerName,
410
+ displayState,
411
+ status: "ready",
412
+ report: cached,
413
+ },
414
+ fingerprint: auth.fingerprint,
415
+ rememberedTargetId: expectedTargetId,
416
+ };
417
+ }
418
+ const previousFailure = failureBackoff.get(failureKey);
419
+ if (!force && previousFailure && previousFailure.until > Date.now()) {
420
+ return {
421
+ state: {
422
+ providerId: adapter.id,
423
+ providerName,
424
+ displayState,
425
+ status: "query-failed",
426
+ message: previousFailure.message,
427
+ },
428
+ fingerprint: auth.fingerprint,
429
+ rememberedTargetId: expectedTargetId,
430
+ };
431
+ }
432
+ failureBackoff.delete(failureKey);
433
+ querySequence += 1;
434
+ queryId = querySequence;
435
+ setBoundedMap(latestQueries, failureKey, queryId, MAX_ACCOUNT_STATES);
436
+ const report = await queryProviderUsage(
437
+ adapter,
438
+ auth,
439
+ signal,
440
+ Math.max(1, deadlineAt - Date.now()),
441
+ requiresRequestBoundaryGuard ? guard : undefined,
442
+ target.targetId,
443
+ );
444
+ if (requiresRequestBoundaryGuard) await guard();
445
+ const effectiveReport = { ...report, providerName };
385
446
  if (latestQueries.get(failureKey) === queryId) {
386
- cache.set(adapter.id, queryFingerprint, report);
447
+ cache.set(adapter.id, queryFingerprint, effectiveReport);
387
448
  failureBackoff.delete(failureKey);
388
449
  }
389
450
  return {
390
451
  state: {
391
452
  providerId: adapter.id,
392
- providerName: adapter.displayName,
453
+ providerName,
393
454
  displayState,
394
455
  status: "ready",
395
- report,
456
+ report: effectiveReport,
396
457
  },
397
458
  fingerprint: auth.fingerprint,
459
+ rememberedTargetId: expectedTargetId,
398
460
  };
399
461
  } catch (error) {
400
462
  if (isStaleExtensionContextError(error) || isAbortError(error)) throw error;
@@ -405,7 +467,9 @@ export default function usageExtension(
405
467
  !requestContextChanged() &&
406
468
  Date.now() < deadlineAt
407
469
  ) {
408
- if (latestQueries.get(failureKey) === queryId) latestQueries.delete(failureKey);
470
+ if (queryId !== undefined && latestQueries.get(failureKey) === queryId) {
471
+ latestQueries.delete(failureKey);
472
+ }
409
473
  return queryAdapterState(
410
474
  ctx,
411
475
  adapter,
@@ -421,7 +485,7 @@ export default function usageExtension(
421
485
  for (const [key, failure] of failureBackoff) {
422
486
  if (failure.until <= now) failureBackoff.delete(key);
423
487
  }
424
- if (latestQueries.get(failureKey) === queryId) {
488
+ if (queryId === undefined || latestQueries.get(failureKey) === queryId) {
425
489
  setBoundedMap(
426
490
  failureBackoff,
427
491
  failureKey,
@@ -432,16 +496,62 @@ export default function usageExtension(
432
496
  return {
433
497
  state: {
434
498
  providerId: adapter.id,
435
- providerName: adapter.displayName,
499
+ providerName,
436
500
  displayState,
437
501
  status: "query-failed",
438
502
  message,
439
503
  },
440
504
  fingerprint: auth.fingerprint,
505
+ rememberedTargetId: expectedTargetId,
441
506
  };
442
507
  }
443
508
  };
444
509
 
510
+ const loadTargetChoices = async (
511
+ ctx: ExtensionContext,
512
+ adapter: UsageProviderAdapter,
513
+ signal: AbortSignal,
514
+ ) => {
515
+ if (!adapter.targets) throw new Error("Provider does not support usage targets.");
516
+ const expectedSessionGeneration = sessionGeneration;
517
+ const expectedSessionId = ctx.sessionManager.getSessionId();
518
+ const expectedModel = modelIdentity(ctx.model);
519
+ const expectedTargetId = settingsRuntime.get().settings.selectedTargets[adapter.id];
520
+ const deadlineAt = Date.now() + DEFAULT_TIMEOUT_MS;
521
+ const changed = () =>
522
+ expectedSessionGeneration !== sessionGeneration ||
523
+ ctx.sessionManager.getSessionId() !== expectedSessionId ||
524
+ modelIdentity(ctx.model) !== expectedModel ||
525
+ settingsRuntime.get().settings.selectedTargets[adapter.id] !== expectedTargetId;
526
+ const auth = await awaitWithDeadline(
527
+ resolveUsageAuth(ctx, adapter, undefined, credentialReader, credentialCandidates),
528
+ signal,
529
+ Math.max(1, deadlineAt - Date.now()),
530
+ `resolving ${providerDisplayName(ctx, adapter.id)} runtime auth`,
531
+ );
532
+ if (!auth || signal.aborted || changed()) throw abortError();
533
+ const guard = async () => {
534
+ if (signal.aborted || changed()) throw abortError();
535
+ const revalidated = await awaitWithDeadline(
536
+ resolveUsageAuth(ctx, adapter, undefined, credentialReader, credentialCandidates),
537
+ signal,
538
+ Math.max(1, deadlineAt - Date.now()),
539
+ `revalidating ${providerDisplayName(ctx, adapter.id)} runtime auth`,
540
+ );
541
+ if (signal.aborted || changed() || revalidated?.fingerprint !== auth.fingerprint) {
542
+ throw abortError();
543
+ }
544
+ };
545
+ const choices = await listUsageTargets(
546
+ adapter,
547
+ auth,
548
+ signal,
549
+ Math.max(1, deadlineAt - Date.now()),
550
+ guard,
551
+ );
552
+ return { choices, fingerprint: auth.fingerprint };
553
+ };
554
+
445
555
  const queryCurrentState = async (
446
556
  ctx: ExtensionContext,
447
557
  model: PiModel | undefined,
@@ -556,6 +666,10 @@ export default function usageExtension(
556
666
  return false;
557
667
  }
558
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;
559
673
  if (outcome.authState === "unavailable") {
560
674
  if (!adapter) return false;
561
675
  try {
@@ -568,6 +682,9 @@ export default function usageExtension(
568
682
  return (
569
683
  generation === statusGeneration &&
570
684
  modelIdentity(ctx.model) === modelIdentity(model) &&
685
+ (!adapter.targets ||
686
+ settingsRuntime.get().settings.selectedTargets[adapter.id] ===
687
+ outcome.rememberedTargetId) &&
571
688
  auth === undefined
572
689
  );
573
690
  } catch (error) {
@@ -587,6 +704,9 @@ export default function usageExtension(
587
704
  return (
588
705
  generation === statusGeneration &&
589
706
  modelIdentity(ctx.model) === modelIdentity(model) &&
707
+ (!adapter.targets ||
708
+ settingsRuntime.get().settings.selectedTargets[adapter.id] ===
709
+ outcome.rememberedTargetId) &&
590
710
  auth?.fingerprint === outcome.fingerprint
591
711
  );
592
712
  } catch (error) {
@@ -652,6 +772,121 @@ export default function usageExtension(
652
772
  let redemptionId: string | undefined;
653
773
  let resetOutcome: CodexResetOutcome | undefined;
654
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
+ };
655
890
  const { defineMenu, runMenu } = await import("@narumitw/pi-tui-kit");
656
891
  if (controller.signal.aborted || statusGeneration !== menuGeneration) return;
657
892
  type Screen =
@@ -664,6 +899,7 @@ export default function usageExtension(
664
899
  type Action =
665
900
  | "refresh"
666
901
  | "settings"
902
+ | "target"
667
903
  | "toggle-fast"
668
904
  | "another"
669
905
  | "all"
@@ -679,6 +915,8 @@ export default function usageExtension(
679
915
  screens: {
680
916
  main: () => {
681
917
  const fastAvailability = fastRuntime.availability(ctx.model);
918
+ const targetState = actionableTargetState();
919
+ const targetAdapter = adapterForProvider(targetState?.providerId);
682
920
  const fastLines =
683
921
  fastAvailability.kind === "available"
684
922
  ? [`Fast mode: ${fastAvailability.enabled ? "On" : "Off"}`, FAST_USAGE_WARNING]
@@ -692,6 +930,15 @@ export default function usageExtension(
692
930
  items: [
693
931
  { id: "refresh", label: REFRESH_CURRENT, action: "refresh" },
694
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
+ : []),
695
942
  ...(fastAvailability.kind === "available"
696
943
  ? [
697
944
  {
@@ -733,7 +980,7 @@ export default function usageExtension(
733
980
  .filter((adapter) => adapter.id !== ctx.model?.provider)
734
981
  .map((adapter) => ({
735
982
  id: adapter.id,
736
- label: adapter.displayName,
983
+ label: providerDisplayName(ctx, adapter.id),
737
984
  action: "provider" as const,
738
985
  })),
739
986
  hint: "back",
@@ -797,6 +1044,61 @@ export default function usageExtension(
797
1044
  }),
798
1045
  },
799
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
+ },
800
1102
  settings: async () => {
801
1103
  await showUsageSettings(
802
1104
  ctx,
@@ -1021,13 +1323,25 @@ export default function usageExtension(
1021
1323
  (candidate) => candidate.id === itemId && candidate.id !== ctx.model?.provider,
1022
1324
  );
1023
1325
  if (!adapter) return { kind: "back" };
1024
- const outcome = await runMenuOperation(
1326
+ let outcome = await runMenuOperation(
1025
1327
  ctx,
1026
- `Checking ${adapter.displayName} usage…`,
1328
+ `Checking ${providerDisplayName(ctx, adapter.id)} usage…`,
1027
1329
  controller.signal,
1028
1330
  (signal) => queryAdapterState(ctx, adapter, "configured", false, signal),
1029
1331
  );
1030
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
+ }
1031
1345
  const revalidated = await queryStableCurrent(
1032
1346
  ctx,
1033
1347
  false,
@@ -1074,7 +1388,7 @@ export default function usageExtension(
1074
1388
  const adapter = adapters[index] as UsageProviderAdapter;
1075
1389
  return {
1076
1390
  providerId: adapter.id,
1077
- providerName: adapter.displayName,
1391
+ providerName: providerDisplayName(ctx, adapter.id),
1078
1392
  displayState: "configured",
1079
1393
  status: "query-failed",
1080
1394
  message: errorMessage(result.reason),