@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/query.ts CHANGED
@@ -1,3 +1,5 @@
1
+ // This module intentionally keeps adapter transport beside resolved-auth and origin validation;
2
+ // separating that security boundary would duplicate request and redaction policy across providers.
1
3
  import { randomBytes } from "node:crypto";
2
4
  import { type ExtensionContext, readStoredCredential } from "@earendil-works/pi-coding-agent";
3
5
  import { errorMessage, fingerprintResolvedAuth, redactUsageError } from "./core.js";
@@ -8,11 +10,7 @@ import {
8
10
  import { normalizeBasetenBillingUsagePayload } from "./providers/baseten.js";
9
11
  import { normalizeCodexBackendPayload } from "./providers/codex.js";
10
12
  import { normalizeDeepSeekBalancePayload } from "./providers/deepseek.js";
11
- import {
12
- isFireworksAccountId,
13
- normalizeFireworksAccountsPayload,
14
- normalizeFireworksBillingSummaryPayload,
15
- } from "./providers/fireworks.js";
13
+ import { createFireworksAdapter } from "./providers/fireworks.js";
16
14
  import { normalizeGitHubCopilotUsagePayload } from "./providers/github-copilot.js";
17
15
  import { normalizeKimiCodingUsagePayload } from "./providers/kimi-coding.js";
18
16
  import {
@@ -30,8 +28,6 @@ import type {
30
28
  BasetenBillingUsagePayload,
31
29
  CodexBackendPayload,
32
30
  DeepSeekBalancePayload,
33
- FireworksAccountsPayload,
34
- FireworksBillingSummaryPayload,
35
31
  GitHubCopilotUsagePayload,
36
32
  KimiCodingUsagePayload,
37
33
  MiniMaxUsagePayload,
@@ -43,6 +39,7 @@ import type {
43
39
  UsageProviderAdapter,
44
40
  UsageQuerySettings,
45
41
  UsageReport,
42
+ UsageRequestGuard,
46
43
  VercelAIGatewayCreditsPayload,
47
44
  XaiBillingPayload,
48
45
  XaiUserPayload,
@@ -50,14 +47,12 @@ import type {
50
47
  ZaiQuotaPayload,
51
48
  ZaiSubscriptionPayload,
52
49
  } from "./types.js";
50
+ import { resolveUsageTarget } from "./usage-targets.js";
53
51
 
54
52
  const BASETEN_BILLING_USAGE_URL = "https://api.baseten.co/v1/billing/usage_summary";
55
53
  const BASETEN_USAGE_WINDOW_DAYS = 30;
56
54
  const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
57
55
  const DEEPSEEK_BALANCE_URL = "https://api.deepseek.com/user/balance";
58
- const FIREWORKS_BILLING_SUMMARY_ORIGIN = "https://api.fireworks.ai";
59
- const FIREWORKS_SPEND_WINDOW_DAYS = 30;
60
- const FIREWORKS_MAX_ACCOUNT_PAGES = 5;
61
56
  const GITHUB_COPILOT_USAGE_URL = "https://api.github.com/copilot_internal/user";
62
57
  const OPENROUTER_KEY_URL = "https://openrouter.ai/api/v1/key";
63
58
  const VERCEL_AI_GATEWAY_CREDITS_URL = "https://ai-gateway.vercel.sh/v1/credits";
@@ -84,8 +79,6 @@ const MAX_ERROR_BODY_BYTES = 4 * 1024;
84
79
 
85
80
  export const AUTH_FINGERPRINT_SALT = randomBytes(32);
86
81
 
87
- export type UsageRequestGuard = () => Promise<void>;
88
-
89
82
  export const SUPPORTED_ADAPTERS: readonly UsageProviderAdapter[] = [
90
83
  {
91
84
  id: "baseten",
@@ -201,35 +194,7 @@ export const SUPPORTED_ADAPTERS: readonly UsageProviderAdapter[] = [
201
194
  return normalizeVercelAIGatewayCreditsPayload(payload, Date.now());
202
195
  },
203
196
  },
204
- {
205
- id: "fireworks",
206
- displayName: "Fireworks",
207
- semantics: { kind: "api-key", label: "Fireworks API spend" },
208
- async query(auth, signal, timeoutMs, guard, settings) {
209
- if (!guard) throw new Error("Fireworks API spend requires request-boundary revalidation.");
210
- const startedAt = Date.now();
211
- await guard();
212
- const accountId = await resolveFireworksAccountId(
213
- auth,
214
- signal,
215
- remainingTimeout(timeoutMs, startedAt, "resolving the Fireworks account"),
216
- guard,
217
- settings?.fireworksAccountId,
218
- );
219
- await guard();
220
- const billingWindowAt = Date.now();
221
- const payload = (await fetchProviderJson(
222
- fireworksBillingSummaryUrl(accountId, billingWindowAt),
223
- auth,
224
- signal,
225
- remainingTimeout(timeoutMs, startedAt, "fetching Fireworks rated spend"),
226
- "Fireworks billing summary endpoint",
227
- { redirect: "error" },
228
- )) as FireworksBillingSummaryPayload;
229
- await guard();
230
- return normalizeFireworksBillingSummaryPayload(payload, accountId, Date.now());
231
- },
232
- },
197
+ createFireworksAdapter(fetchProviderJson),
233
198
  {
234
199
  id: "opencode-go",
235
200
  displayName: "OpenCode Go",
@@ -402,6 +367,12 @@ export async function resolveUsageAuth(
402
367
  if (!model) return undefined;
403
368
  // SAFETY: Pi exposes the required auth methods at runtime, and checks below narrow them before use.
404
369
  const registry = ctx.modelRegistry as unknown as UsageAuthRegistry;
370
+ const provider = registry.getProvider?.(adapter.id);
371
+ if (provider?.baseUrl && !hasOfficialUrlOrigin(provider.baseUrl, adapter.id)) {
372
+ throw new Error(
373
+ `${adapter.displayName} usage cannot send an overridden provider credential to the official usage endpoint.`,
374
+ );
375
+ }
405
376
  let modelAuth: RequestAuth | undefined;
406
377
  const currentModel = ctx.model?.provider === adapter.id ? ctx.model : undefined;
407
378
  const resolveCurrentModelAuth = async (): Promise<RequestAuth | undefined> => {
@@ -429,8 +400,46 @@ export async function resolveUsageAuth(
429
400
  // Providers with credential-change retries read selected-model auth last so a rotation during
430
401
  // provider-origin validation cannot leave the earlier credential queued for the usage request.
431
402
  if (resolveSelectedAuthLast) modelAuth = await resolveCurrentModelAuth();
403
+ if (modelAuth?.baseUrl && !hasOfficialUrlOrigin(modelAuth.baseUrl, adapter.id)) {
404
+ throw new Error(
405
+ `${adapter.displayName} usage cannot send model-resolved proxy credentials to the official usage endpoint.`,
406
+ );
407
+ }
432
408
  const auth = modelAuth ?? providerResult?.auth;
433
409
  if (!auth) return undefined;
410
+ const finalize = (resolved: ResolvedUsageAuth): ResolvedUsageAuth => {
411
+ const preservedAuth = { ...(providerResult?.auth ?? auth) };
412
+ const env = providerResult?.env ?? modelAuth?.env;
413
+ const source = providerResult?.source;
414
+ const effectiveBaseUrl =
415
+ modelAuth?.baseUrl ?? providerResult?.auth.baseUrl ?? provider?.baseUrl ?? model.baseUrl;
416
+ const redactionInputs = [
417
+ preservedAuth.apiKey,
418
+ ...Object.values(preservedAuth.headers ?? {}),
419
+ ...Object.values(env ?? {}),
420
+ modelAuth?.apiKey,
421
+ ...Object.values(modelAuth?.headers ?? {}),
422
+ ].filter((value): value is string => typeof value === "string" && value.length > 0);
423
+ return {
424
+ ...resolved,
425
+ auth: preservedAuth,
426
+ ...(env ? { env: { ...env } } : {}),
427
+ ...(source ? { source } : {}),
428
+ effectiveBaseUrl,
429
+ secrets: [...new Set([...resolved.secrets, ...redactionInputs])],
430
+ fingerprint: fingerprintResolvedAuth(
431
+ {
432
+ apiKey: resolved.apiKey,
433
+ headers: resolved.headers,
434
+ baseUrl: effectiveBaseUrl,
435
+ env,
436
+ source,
437
+ providerAuth: preservedAuth,
438
+ },
439
+ salt,
440
+ ),
441
+ };
442
+ };
434
443
  if (adapter.id === "github-copilot") {
435
444
  const offered = candidateReader
436
445
  ? candidateReader(ctx, adapter.id)
@@ -438,12 +447,14 @@ export async function resolveUsageAuth(
438
447
  if (!offered.ok) {
439
448
  throw new Error("GitHub Copilot OAuth credential discovery failed closed.");
440
449
  }
441
- return resolveGitHubCopilotUsageAuth(
442
- auth,
443
- model,
444
- salt,
445
- offered.candidates,
446
- offered.offeredCount === 0,
450
+ return finalize(
451
+ resolveGitHubCopilotUsageAuth(
452
+ auth,
453
+ model,
454
+ salt,
455
+ offered.candidates,
456
+ offered.offeredCount === 0,
457
+ ),
447
458
  );
448
459
  }
449
460
  if (adapter.id === "xai") {
@@ -451,7 +462,7 @@ export async function resolveUsageAuth(
451
462
  ? candidateReader(ctx, adapter.id)
452
463
  : fallbackOAuthCredentialCandidates(adapter.id, credentialReader);
453
464
  if (!offered.ok) throw new Error("xAI OAuth credential discovery failed closed.");
454
- return resolveXaiUsageAuth(auth, model, salt, offered.candidates);
465
+ return finalize(resolveXaiUsageAuth(auth, model, salt, offered.candidates));
455
466
  }
456
467
  if (adapter.id === "deepseek") {
457
468
  const resolvedAuthorization = authorizationFrom(auth);
@@ -459,10 +470,10 @@ export async function resolveUsageAuth(
459
470
  if (!access) throw new Error("DeepSeek API balance requires Bearer authentication.");
460
471
  const authorization = `Bearer ${access}`;
461
472
  const headers = { Authorization: authorization };
462
- return {
473
+ return finalize({
463
474
  apiKey: access,
464
475
  headers,
465
- fingerprint: fingerprintResolvedAuth({ headers }, salt),
476
+ fingerprint: "",
466
477
  secrets: [
467
478
  access,
468
479
  auth.apiKey,
@@ -471,7 +482,7 @@ export async function resolveUsageAuth(
471
482
  authorization,
472
483
  ].filter((value): value is string => Boolean(value)),
473
484
  model,
474
- };
485
+ });
475
486
  }
476
487
  const authorization = authorizationFrom(auth);
477
488
  if (!authorization) return undefined;
@@ -479,13 +490,13 @@ export async function resolveUsageAuth(
479
490
  const secrets = [auth.apiKey, headerValue(auth.headers, "Authorization"), authorization].filter(
480
491
  (value): value is string => Boolean(value),
481
492
  );
482
- return {
493
+ return finalize({
483
494
  apiKey: auth.apiKey,
484
495
  headers,
485
- fingerprint: fingerprintResolvedAuth({ headers }, salt),
496
+ fingerprint: "",
486
497
  secrets,
487
498
  model,
488
- };
499
+ });
489
500
  }
490
501
 
491
502
  export async function queryProviderUsage(
@@ -494,10 +505,46 @@ export async function queryProviderUsage(
494
505
  signal: AbortSignal,
495
506
  timeoutMs: number,
496
507
  guard?: UsageRequestGuard,
497
- settings?: Readonly<UsageQuerySettings>,
508
+ targetOrSettings?: string | Readonly<UsageQuerySettings>,
498
509
  ): Promise<UsageReport> {
510
+ const startedAt = Date.now();
511
+ let targetId =
512
+ typeof targetOrSettings === "string"
513
+ ? targetOrSettings
514
+ : adapter.id === "fireworks"
515
+ ? targetOrSettings?.fireworksAccountId
516
+ : undefined;
517
+ let resolvedLegacyFireworksTarget = false;
499
518
  try {
500
- return await adapter.query(auth, signal, timeoutMs, guard, settings);
519
+ if (
520
+ adapter.id === "fireworks" &&
521
+ typeof targetOrSettings !== "string" &&
522
+ adapter.targets &&
523
+ guard
524
+ ) {
525
+ const target = await resolveUsageTarget(
526
+ adapter,
527
+ auth,
528
+ targetId,
529
+ signal,
530
+ remainingTimeout(timeoutMs, startedAt, "resolving the Fireworks account"),
531
+ guard,
532
+ );
533
+ if (target.kind === "selection-required") {
534
+ throw new Error("Fireworks account selection is required.");
535
+ }
536
+ targetId = target.targetId;
537
+ resolvedLegacyFireworksTarget = true;
538
+ }
539
+ return await adapter.query(
540
+ auth,
541
+ signal,
542
+ resolvedLegacyFireworksTarget
543
+ ? remainingTimeout(timeoutMs, startedAt, `querying ${adapter.displayName} usage`)
544
+ : timeoutMs,
545
+ guard,
546
+ targetId,
547
+ );
501
548
  } catch (error) {
502
549
  if (isStaleExtensionContextError(error) || isAbortError(error)) throw error;
503
550
  throw new Error(redactUsageError(errorMessage(error), auth.secrets));
@@ -696,17 +743,22 @@ async function readBoundedResponse(
696
743
  type RequestAuth = {
697
744
  apiKey?: string;
698
745
  headers?: Record<string, string | null>;
746
+ baseUrl?: string;
747
+ env?: Record<string, string>;
699
748
  };
700
749
 
701
750
  type StoredCredentialReader = (providerId: string) => unknown;
702
751
 
703
752
  type UsageAuthRegistry = {
753
+ getProvider?(providerId: string): { baseUrl?: string } | undefined;
704
754
  getApiKeyAndHeaders?(
705
755
  model: PiModel,
706
756
  ): Promise<({ ok: true } & RequestAuth) | { ok: false; error: string }>;
707
757
  getProviderAuth?(providerId: string): Promise<
708
758
  | {
709
- auth: RequestAuth & { baseUrl?: string };
759
+ auth: RequestAuth;
760
+ env?: Record<string, string>;
761
+ source?: string;
710
762
  }
711
763
  | undefined
712
764
  >;
@@ -902,7 +954,7 @@ function hasOfficialUrlOrigin(value: string, providerId: string): boolean {
902
954
  }
903
955
  if (providerId === "openai-codex") return url.origin === "https://chatgpt.com";
904
956
  if (providerId === "deepseek") return url.origin === "https://api.deepseek.com";
905
- if (providerId === "fireworks") return url.origin === FIREWORKS_BILLING_SUMMARY_ORIGIN;
957
+ if (providerId === "fireworks") return url.origin === "https://api.fireworks.ai";
906
958
  if (providerId === "openrouter") return url.origin === "https://openrouter.ai";
907
959
  if (providerId === "vercel-ai-gateway") return url.origin === "https://ai-gateway.vercel.sh";
908
960
  if (providerId === "opencode-go") return url.origin === "https://opencode.ai";
@@ -1014,98 +1066,6 @@ function remainingTimeout(
1014
1066
  return remaining;
1015
1067
  }
1016
1068
 
1017
- // Fireworks requires an account slug for its billing endpoints; discover it through the
1018
- // documented account listing, requiring an explicit slug when a key can see several accounts.
1019
- async function resolveFireworksAccountId(
1020
- auth: ResolvedUsageAuth,
1021
- signal: AbortSignal,
1022
- timeoutMs: number,
1023
- guard: () => Promise<void>,
1024
- configuredAccountId: string | undefined,
1025
- ): Promise<string> {
1026
- if (configuredAccountId !== undefined && !isFireworksAccountId(configuredAccountId)) {
1027
- throw new Error("The Fireworks account setting was not a safe account slug.");
1028
- }
1029
- const startedAt = Date.now();
1030
- const accounts: string[] = [];
1031
- let pageToken: string | undefined;
1032
- for (let page = 0; page < FIREWORKS_MAX_ACCOUNT_PAGES; page += 1) {
1033
- await guard();
1034
- const payload = (await fetchProviderJson(
1035
- fireworksAccountsUrl(pageToken),
1036
- auth,
1037
- signal,
1038
- remainingTimeout(timeoutMs, startedAt, "fetching Fireworks accounts"),
1039
- "Fireworks accounts endpoint",
1040
- { redirect: "error" },
1041
- )) as FireworksAccountsPayload;
1042
- for (const accountId of normalizeFireworksAccountsPayload(
1043
- payload as FireworksAccountsPayload,
1044
- )) {
1045
- if (accounts.includes(accountId)) {
1046
- throw new Error(`Fireworks accounts listing repeated ${accountId}.`);
1047
- }
1048
- accounts.push(accountId);
1049
- if (configuredAccountId === accountId) return accountId;
1050
- }
1051
- pageToken = fireworksNextPageToken(payload.nextPageToken);
1052
- if (!pageToken) break;
1053
- }
1054
- if (pageToken) {
1055
- throw new Error(
1056
- configuredAccountId
1057
- ? `The configured Fireworks account was not found within the first ${FIREWORKS_MAX_ACCOUNT_PAGES} listing pages.`
1058
- : `Fireworks account listing exceeded ${FIREWORKS_MAX_ACCOUNT_PAGES} pages; set fireworksAccountId in pi-usage.json to an account returned in those pages.`,
1059
- );
1060
- }
1061
- if (accounts.length === 0) {
1062
- throw new Error("Fireworks account discovery returned no accounts for this API key.");
1063
- }
1064
- if (configuredAccountId) {
1065
- throw new Error(
1066
- "The configured Fireworks account does not match an account visible to this API key.",
1067
- );
1068
- }
1069
- if (accounts.length === 1) return accounts[0] as string;
1070
- const preview = accounts.slice(0, 8).join(", ");
1071
- const suffix = accounts.length > 8 ? ` …and ${accounts.length - 8} more` : "";
1072
- throw new Error(
1073
- `The Fireworks key can see ${accounts.length} accounts (${preview}${suffix}); set fireworksAccountId in pi-usage.json to one of them.`,
1074
- );
1075
- }
1076
-
1077
- function fireworksAccountsUrl(pageToken: string | undefined): string {
1078
- const url = new URL("/v1/accounts", FIREWORKS_BILLING_SUMMARY_ORIGIN);
1079
- url.searchParams.set("pageSize", "200");
1080
- if (pageToken !== undefined) url.searchParams.set("pageToken", pageToken);
1081
- return url.toString();
1082
- }
1083
-
1084
- function fireworksNextPageToken(value: unknown): string | undefined {
1085
- if (value === undefined || value === null) return undefined;
1086
- if (typeof value !== "string" || !value || value.length > 512) {
1087
- throw new Error("Fireworks accounts listing returned an invalid page token.");
1088
- }
1089
- return value;
1090
- }
1091
-
1092
- function fireworksBillingSummaryUrl(accountId: string, startedAt: number): string {
1093
- const dayMs = 24 * 60 * 60 * 1000;
1094
- const dayFloor = (time: number) => `${new Date(time).toISOString().slice(0, 10)}T00:00:00Z`;
1095
- const url = new URL(
1096
- `/v1/accounts/${accountId}/billing/summary`,
1097
- FIREWORKS_BILLING_SUMMARY_ORIGIN,
1098
- );
1099
- // The endpoint aggregates by UTC date; endTime is exclusive, so the window includes today
1100
- // plus the preceding 29 dates.
1101
- url.searchParams.set(
1102
- "startTime",
1103
- dayFloor(startedAt - (FIREWORKS_SPEND_WINDOW_DAYS - 1) * dayMs),
1104
- );
1105
- url.searchParams.set("endTime", dayFloor(startedAt + dayMs));
1106
- return url.toString();
1107
- }
1108
-
1109
1069
  function zaiOrigin(baseUrl: string | undefined): string {
1110
1070
  const base = baseUrl?.trim();
1111
1071
  if (!base) throw new Error("Z.AI model base URL is unavailable.");
package/src/settings.ts CHANGED
@@ -4,6 +4,7 @@ import { chmod, mkdir, open, rename, rm, writeFile } from "node:fs/promises";
4
4
  import { basename, dirname, join } from "node:path";
5
5
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
6
  import { isFireworksAccountId } from "./providers/fireworks.js";
7
+ import { isBoundedTargetId } from "./usage-targets.js";
7
8
 
8
9
  export const USAGE_SETTINGS_FILE = "pi-usage.json";
9
10
  export const MAX_USAGE_SETTINGS_BYTES = 64 * 1024;
@@ -11,12 +12,13 @@ export const MAX_USAGE_SETTINGS_BYTES = 64 * 1024;
11
12
  export interface UsageSettings {
12
13
  codexFastMode: boolean;
13
14
  codexStatusResetCountdown: boolean;
14
- fireworksAccountId?: string;
15
+ selectedTargets: Record<string, string>;
15
16
  }
16
17
 
17
18
  export const DEFAULT_USAGE_SETTINGS: Readonly<UsageSettings> = Object.freeze({
18
19
  codexFastMode: false,
19
20
  codexStatusResetCountdown: true,
21
+ selectedTargets: Object.freeze({}),
20
22
  });
21
23
 
22
24
  export interface UsageSettingsState {
@@ -27,6 +29,8 @@ export interface UsageSettingsState {
27
29
  issue?: string;
28
30
  }
29
31
 
32
+ export type UsageTargetPublicationCheck = () => Promise<void>;
33
+
30
34
  export interface UsageSettingsRuntime {
31
35
  get(): Readonly<UsageSettingsState>;
32
36
  reload(signal?: AbortSignal): Promise<Readonly<UsageSettingsState>>;
@@ -34,6 +38,12 @@ export interface UsageSettingsRuntime {
34
38
  patch: Partial<UsageSettings>,
35
39
  signal?: AbortSignal,
36
40
  ): Promise<Readonly<UsageSettingsState>>;
41
+ updateSelectedTarget(
42
+ providerId: string,
43
+ targetId: string,
44
+ signal?: AbortSignal,
45
+ checkPublishedSelection?: UsageTargetPublicationCheck,
46
+ ): Promise<Readonly<UsageSettingsState>>;
37
47
  flush(): Promise<void>;
38
48
  }
39
49
 
@@ -68,6 +78,12 @@ export function normalizeUsageSettings(value: unknown): UsageSettings | undefine
68
78
  ) {
69
79
  return undefined;
70
80
  }
81
+ const selectedTargets = normalizeSelectedTargets(value.selectedTargets);
82
+ if (Object.hasOwn(value, "selectedTargets") && !selectedTargets) return undefined;
83
+ const effectiveTargets = { ...(selectedTargets ?? {}) };
84
+ if (!effectiveTargets.fireworks && isFireworksAccountId(value.fireworksAccountId)) {
85
+ effectiveTargets.fireworks = value.fireworksAccountId;
86
+ }
71
87
  return {
72
88
  codexFastMode:
73
89
  typeof value.codexFastMode === "boolean"
@@ -77,9 +93,7 @@ export function normalizeUsageSettings(value: unknown): UsageSettings | undefine
77
93
  typeof value.codexStatusResetCountdown === "boolean"
78
94
  ? value.codexStatusResetCountdown
79
95
  : DEFAULT_USAGE_SETTINGS.codexStatusResetCountdown,
80
- ...(isFireworksAccountId(value.fireworksAccountId)
81
- ? { fireworksAccountId: value.fireworksAccountId }
82
- : {}),
96
+ selectedTargets: effectiveTargets,
83
97
  };
84
98
  }
85
99
 
@@ -169,6 +183,39 @@ export function createUsageSettingsRuntime(
169
183
  state = saved;
170
184
  return structuredClone(state);
171
185
  }),
186
+ updateSelectedTarget: (providerId, targetId, signal, checkPublishedSelection) =>
187
+ enqueue(async () => {
188
+ const transaction = await saveUsageTargetSelection(
189
+ path,
190
+ providerId,
191
+ targetId,
192
+ operations,
193
+ signal,
194
+ );
195
+ try {
196
+ await checkPublishedSelection?.();
197
+ throwIfAborted(signal);
198
+ } catch (error) {
199
+ try {
200
+ await restoreUsageSettingsState(
201
+ path,
202
+ transaction.saved,
203
+ transaction.previous,
204
+ operations,
205
+ );
206
+ state = transaction.previous;
207
+ } catch (rollbackError) {
208
+ state = await loadUsageSettings(path);
209
+ throw new AggregateError(
210
+ [error, rollbackError],
211
+ "Target selection changed after publication and pi-usage.json rollback failed",
212
+ );
213
+ }
214
+ throw error;
215
+ }
216
+ state = transaction.saved;
217
+ return structuredClone(state);
218
+ }),
172
219
  flush: () => queue,
173
220
  };
174
221
  }
@@ -178,16 +225,63 @@ async function saveUsageSettingsPatch(
178
225
  patch: Partial<UsageSettings>,
179
226
  operations: UsageSettingsFileOperations,
180
227
  signal?: AbortSignal,
228
+ ): Promise<UsageSettingsState> {
229
+ return saveUsageSettingsDocument(
230
+ path,
231
+ (document) => {
232
+ for (const [key, value] of Object.entries(patch)) {
233
+ if (value === undefined) delete document[key];
234
+ else document[key] = value;
235
+ }
236
+ },
237
+ operations,
238
+ signal,
239
+ );
240
+ }
241
+
242
+ async function saveUsageTargetSelection(
243
+ path: string,
244
+ providerId: string,
245
+ targetId: string,
246
+ operations: UsageSettingsFileOperations,
247
+ signal?: AbortSignal,
248
+ ): Promise<{ saved: UsageSettingsState; previous: UsageSettingsState }> {
249
+ if (!isProviderId(providerId) || !isBoundedTargetId(targetId)) {
250
+ throw new Error("Refusing to save an invalid usage target selection");
251
+ }
252
+ const previous = await loadUsageSettings(path, signal);
253
+ const saved = await saveUsageSettingsDocument(
254
+ path,
255
+ (document) => {
256
+ document.selectedTargets = {
257
+ ...(normalizeSelectedTargets(document.selectedTargets) ?? {}),
258
+ [providerId]: targetId,
259
+ };
260
+ if (providerId === "fireworks") delete document.fireworksAccountId;
261
+ },
262
+ operations,
263
+ signal,
264
+ previous,
265
+ );
266
+ return { saved, previous };
267
+ }
268
+
269
+ async function saveUsageSettingsDocument(
270
+ path: string,
271
+ mutate: (document: Record<string, unknown>) => void,
272
+ operations: UsageSettingsFileOperations,
273
+ signal?: AbortSignal,
274
+ expected?: UsageSettingsState,
181
275
  ): Promise<UsageSettingsState> {
182
276
  const latest = await loadUsageSettings(path, signal);
183
277
  if (latest.kind === "invalid") {
184
278
  throw new Error("Cannot overwrite an invalid pi-usage.json; repair it and reload first");
185
279
  }
186
- const document = { ...latest.document };
187
- for (const [key, value] of Object.entries(patch)) {
188
- if (value === undefined) delete document[key];
189
- else document[key] = value;
280
+ if (expected && !sameUsageSettingsDocument(latest, expected)) {
281
+ throw new Error("pi-usage.json changed while saving; retry the action");
190
282
  }
283
+ const document = { ...latest.document };
284
+ mutate(document);
191
285
  const settings = normalizeUsageSettings(document);
192
286
  if (!settings) throw new Error("Refusing to save invalid pi-usage settings");
193
287
  const directory = dirname(path);
@@ -218,6 +312,44 @@ async function saveUsageSettingsPatch(
218
312
  return { kind: "loaded", path, settings, document };
219
313
  }
220
314
 
315
+ async function restoreUsageSettingsState(
316
+ path: string,
317
+ published: UsageSettingsState,
318
+ previous: UsageSettingsState,
319
+ operations: UsageSettingsFileOperations,
320
+ ): Promise<void> {
321
+ if (previous.kind === "missing") {
322
+ const current = await loadUsageSettings(path);
323
+ if (!sameUsageSettingsDocument(current, published)) {
324
+ throw new Error("pi-usage.json changed before target selection rollback");
325
+ }
326
+ await rm(path);
327
+ return;
328
+ }
329
+ if (previous.kind !== "loaded" || !previous.document) {
330
+ throw new Error("Cannot restore invalid prior pi-usage.json settings");
331
+ }
332
+ await saveUsageSettingsDocument(
333
+ path,
334
+ (document) => {
335
+ for (const key of Object.keys(document)) delete document[key];
336
+ Object.assign(document, previous.document);
337
+ },
338
+ operations,
339
+ undefined,
340
+ published,
341
+ );
342
+ }
343
+
344
+ function sameUsageSettingsDocument(
345
+ left: Pick<UsageSettingsState, "kind" | "document">,
346
+ right: Pick<UsageSettingsState, "kind" | "document">,
347
+ ): boolean {
348
+ return (
349
+ left.kind === right.kind && JSON.stringify(left.document) === JSON.stringify(right.document)
350
+ );
351
+ }
352
+
221
353
  async function chmodPrivate(path: string): Promise<void> {
222
354
  await chmod(path, 0o600);
223
355
  }
@@ -233,3 +365,18 @@ function isRecord(value: unknown): value is Record<string, unknown> {
233
365
  function isNodeError(error: unknown): error is NodeJS.ErrnoException {
234
366
  return error instanceof Error && "code" in error;
235
367
  }
368
+
369
+ function normalizeSelectedTargets(value: unknown): Record<string, string> | undefined {
370
+ if (value === undefined) return {};
371
+ if (!isRecord(value)) return undefined;
372
+ const targets: Record<string, string> = {};
373
+ for (const [providerId, targetId] of Object.entries(value)) {
374
+ if (!isProviderId(providerId) || !isBoundedTargetId(targetId)) return undefined;
375
+ targets[providerId] = targetId;
376
+ }
377
+ return targets;
378
+ }
379
+
380
+ function isProviderId(value: string): boolean {
381
+ return /^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$/u.test(value);
382
+ }