@narumitw/pi-usage 0.60.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/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
+ }
package/src/types.ts CHANGED
@@ -53,23 +53,51 @@ export interface ResolvedUsageAuth {
53
53
  fingerprint: string;
54
54
  secrets: string[];
55
55
  model: PiModel;
56
+ auth?: {
57
+ apiKey?: string;
58
+ headers?: Record<string, string | null>;
59
+ baseUrl?: string;
60
+ };
61
+ env?: Record<string, string>;
62
+ source?: string;
63
+ effectiveBaseUrl?: string;
56
64
  }
57
65
 
58
66
  export interface UsageQuerySettings {
59
67
  fireworksAccountId?: string;
60
68
  }
61
69
 
70
+ export type UsageRequestGuard = () => Promise<void>;
71
+
72
+ export interface UsageProviderTarget {
73
+ id: string;
74
+ label: string;
75
+ description?: string;
76
+ }
77
+
78
+ export interface UsageTargetResolver {
79
+ singularLabel: string;
80
+ pluralLabel: string;
81
+ list(
82
+ auth: ResolvedUsageAuth,
83
+ signal: AbortSignal,
84
+ timeoutMs: number,
85
+ guard: UsageRequestGuard,
86
+ ): Promise<readonly UsageProviderTarget[]>;
87
+ }
88
+
62
89
  export interface UsageProviderAdapter {
63
90
  id: string;
64
91
  displayName: string;
65
92
  semantics: UsageSemantics;
66
93
  publishesStatusline?: boolean;
94
+ targets?: UsageTargetResolver;
67
95
  query(
68
96
  auth: ResolvedUsageAuth,
69
97
  signal: AbortSignal,
70
98
  timeoutMs: number,
71
- guard?: () => Promise<void>,
72
- settings?: Readonly<UsageQuerySettings>,
99
+ guard?: UsageRequestGuard,
100
+ targetId?: string,
73
101
  ): Promise<UsageReport>;
74
102
  }
75
103
 
@@ -81,6 +109,15 @@ export type ProviderUsageState =
81
109
  status: "ready";
82
110
  report: UsageReport;
83
111
  }
112
+ | {
113
+ providerId: string;
114
+ providerName: string;
115
+ displayState: UsageDisplayState;
116
+ status: "selection-required";
117
+ singularLabel: string;
118
+ pluralLabel: string;
119
+ choices: readonly UsageProviderTarget[];
120
+ }
84
121
  | {
85
122
  providerId: string;
86
123
  providerName: string;
@@ -11,16 +11,12 @@ import {
11
11
  Text,
12
12
  } from "@earendil-works/pi-tui";
13
13
  import { errorMessage } from "./core.js";
14
- import { isFireworksAccountId } from "./providers/fireworks.js";
15
- import type { UsageSettings, UsageSettingsRuntime } from "./settings.js";
14
+ import type { UsageSettingsRuntime } from "./settings.js";
16
15
 
17
- const AUTO = "Auto";
18
- const EDIT = "Edit…";
19
16
  const OFF = "Off";
20
17
  const ON = "On";
21
18
 
22
- type UsageSettingId = keyof UsageSettings;
23
- type SettingsScreenResult = { changed: boolean; editFireworksAccount: boolean };
19
+ type UsageSettingId = "codexFastMode" | "codexStatusResetCountdown";
24
20
 
25
21
  export async function showUsageSettings(
26
22
  ctx: ExtensionCommandContext,
@@ -33,187 +29,96 @@ export async function showUsageSettings(
33
29
  if (ctx.hasUI) ctx.ui.notify(`Edit settings manually: ${settingsRuntime.get().path}`, "info");
34
30
  return false;
35
31
  }
36
- let changed = false;
37
- while (!parentSignal.aborted && isCurrent()) {
38
- const result = await showSettingsList(ctx, settingsRuntime, parentSignal, isCurrent, onApplied);
39
- if (!result) return changed;
40
- changed ||= result.changed;
41
- if (!result.editFireworksAccount) return changed;
42
- changed ||= await editFireworksAccount(
43
- ctx,
44
- settingsRuntime,
45
- parentSignal,
46
- isCurrent,
47
- onApplied,
48
- );
49
- }
50
- return changed;
51
- }
32
+ return (
33
+ (await ctx.ui.custom<boolean>((tui, theme, _keybindings, done) => {
34
+ const localController = new AbortController();
35
+ const signal = AbortSignal.any([parentSignal, localController.signal]);
36
+ let changed = false;
37
+ let closing = false;
38
+ let saveQueue = Promise.resolve();
39
+ const state = settingsRuntime.get();
40
+ const items: SettingItem[] = [
41
+ {
42
+ id: "codexFastMode",
43
+ label: "Codex Fast mode",
44
+ description: "Use faster Codex routing at increased plan allowance consumption.",
45
+ currentValue: state.settings.codexFastMode ? ON : OFF,
46
+ values: [OFF, ON],
47
+ },
48
+ {
49
+ id: "codexStatusResetCountdown",
50
+ label: "Codex reset countdown",
51
+ description: "Show time remaining until each Codex usage limit resets.",
52
+ currentValue: state.settings.codexStatusResetCountdown ? ON : OFF,
53
+ values: [OFF, ON],
54
+ },
55
+ ];
56
+ const container = new Container();
57
+ container.addChild(new Text(theme.fg("accent", theme.bold("pi-usage Settings")), 1, 1));
52
58
 
53
- async function showSettingsList(
54
- ctx: ExtensionCommandContext,
55
- settingsRuntime: UsageSettingsRuntime,
56
- parentSignal: AbortSignal,
57
- isCurrent: () => boolean,
58
- onApplied: (id: UsageSettingId) => void,
59
- ): Promise<SettingsScreenResult | undefined> {
60
- return ctx.ui.custom<SettingsScreenResult>((tui, theme, _keybindings, done) => {
61
- const localController = new AbortController();
62
- const signal = AbortSignal.any([parentSignal, localController.signal]);
63
- let changed = false;
64
- let closing = false;
65
- let saveQueue = Promise.resolve();
66
- const state = settingsRuntime.get();
67
- const fireworksValue = state.settings.fireworksAccountId ?? AUTO;
68
- const items: SettingItem[] = [
69
- {
70
- id: "codexFastMode",
71
- label: "Codex Fast mode",
72
- description: "Use faster Codex routing at increased plan allowance consumption.",
73
- currentValue: state.settings.codexFastMode ? ON : OFF,
74
- values: [OFF, ON],
75
- },
76
- {
77
- id: "codexStatusResetCountdown",
78
- label: "Codex reset countdown",
79
- description: "Show time remaining until each Codex usage limit resets.",
80
- currentValue: state.settings.codexStatusResetCountdown ? ON : OFF,
81
- values: [OFF, ON],
82
- },
83
- {
84
- id: "fireworksAccountId",
85
- label: "Fireworks account",
86
- description: "Select Edit to enter a visible account slug, or submit blank to clear it.",
87
- currentValue: fireworksValue,
88
- values: state.settings.fireworksAccountId
89
- ? [state.settings.fireworksAccountId, EDIT]
90
- : [AUTO, EDIT],
91
- },
92
- ];
93
- const container = new Container();
94
- container.addChild(new Text(theme.fg("accent", theme.bold("pi-usage Settings")), 1, 1));
95
-
96
- let settingsList: SettingsList;
97
- const cancel = () => {
98
- if (closing) return;
99
- closing = true;
100
- localController.abort();
101
- done({ changed, editFireworksAccount: false });
102
- };
103
- const queueUpdate = (
104
- id: UsageSettingId,
105
- requested: UsageSettings[UsageSettingId],
106
- display: string,
107
- ) => {
108
- saveQueue = saveQueue.then(async () => {
109
- const previous = settingsRuntime.get().settings[id];
110
- if (settingsRuntime.get().kind === "invalid") {
111
- settingsList.updateValue(id, displaySetting(id, previous));
112
- if (!signal.aborted && isCurrent()) {
113
- ctx.ui.notify("Repair pi-usage.json and reload before changing settings.", "error");
59
+ let settingsList: SettingsList;
60
+ const cancel = () => {
61
+ if (closing) return;
62
+ closing = true;
63
+ localController.abort();
64
+ done(changed);
65
+ };
66
+ const queueUpdate = (id: UsageSettingId, requested: boolean, display: string) => {
67
+ saveQueue = saveQueue.then(async () => {
68
+ const previous = settingsRuntime.get().settings[id];
69
+ if (settingsRuntime.get().kind === "invalid") {
70
+ settingsList.updateValue(id, previous ? ON : OFF);
71
+ if (!signal.aborted && isCurrent()) {
72
+ ctx.ui.notify("Repair pi-usage.json and reload before changing settings.", "error");
73
+ tui.requestRender();
74
+ }
75
+ return;
76
+ }
77
+ try {
78
+ await settingsRuntime.update({ [id]: requested }, signal);
79
+ } catch (error) {
80
+ if (signal.aborted || !isCurrent()) return;
81
+ settingsList.updateValue(id, previous ? ON : OFF);
82
+ ctx.ui.notify(`Could not save pi-usage.json: ${errorMessage(error)}`, "error");
114
83
  tui.requestRender();
84
+ return;
85
+ }
86
+ if (previous !== requested) {
87
+ changed = true;
88
+ onApplied(id);
115
89
  }
116
- return;
117
- }
118
- try {
119
- await settingsRuntime.update({ [id]: requested }, signal);
120
- } catch (error) {
121
90
  if (signal.aborted || !isCurrent()) return;
122
- settingsList.updateValue(id, displaySetting(id, previous));
123
- ctx.ui.notify(`Could not save pi-usage.json: ${errorMessage(error)}`, "error");
91
+ settingsList.updateValue(id, display);
124
92
  tui.requestRender();
125
- return;
126
- }
127
- if (previous !== requested) {
128
- changed = true;
129
- onApplied(id);
130
- }
131
- if (signal.aborted || !isCurrent()) return;
132
- settingsList.updateValue(id, display);
133
- tui.requestRender();
134
- });
135
- };
136
- settingsList = new SettingsList(
137
- items,
138
- items.length + 2,
139
- getSettingsListTheme(),
140
- (id, value) => {
141
- if (closing || signal.aborted || !isCurrent()) return;
142
- if (id === "fireworksAccountId") {
143
- if (value === EDIT) {
144
- saveQueue = saveQueue.then(() => {
145
- if (closing || signal.aborted || !isCurrent()) return;
146
- closing = true;
147
- done({ changed, editFireworksAccount: true });
148
- });
149
- }
150
- return;
151
- }
152
- const settingId = id as "codexFastMode" | "codexStatusResetCountdown";
153
- queueUpdate(settingId, value !== OFF, value);
154
- },
155
- cancel,
156
- );
157
- container.addChild(settingsList);
93
+ });
94
+ };
95
+ settingsList = new SettingsList(
96
+ items,
97
+ items.length + 2,
98
+ getSettingsListTheme(),
99
+ (id, value) => {
100
+ if (closing || signal.aborted || !isCurrent()) return;
101
+ queueUpdate(id as UsageSettingId, value !== OFF, value);
102
+ },
103
+ cancel,
104
+ );
105
+ container.addChild(settingsList);
158
106
 
159
- parentSignal.addEventListener("abort", cancel, { once: true });
160
- return {
161
- render: (width: number) => container.render(width),
162
- invalidate: () => container.invalidate(),
163
- handleInput(data: string) {
164
- if (closing) return;
165
- if (matchesKey(data, Key.ctrl("c"))) cancel();
166
- else settingsList.handleInput(data);
167
- tui.requestRender();
168
- },
169
- dispose() {
170
- localController.abort();
171
- parentSignal.removeEventListener("abort", cancel);
172
- },
173
- };
174
- });
175
- }
176
-
177
- async function editFireworksAccount(
178
- ctx: ExtensionCommandContext,
179
- settingsRuntime: UsageSettingsRuntime,
180
- signal: AbortSignal,
181
- isCurrent: () => boolean,
182
- onApplied: (id: UsageSettingId) => void,
183
- ): Promise<boolean> {
184
- while (!signal.aborted && isCurrent()) {
185
- const state = settingsRuntime.get();
186
- if (state.kind === "invalid") {
187
- ctx.ui.notify("Repair pi-usage.json and reload before changing settings.", "error");
188
- return false;
189
- }
190
- const entered = await ctx.ui.input(
191
- "Fireworks account slug · submit blank for Auto",
192
- state.settings.fireworksAccountId ?? "Example: acme",
193
- { signal },
194
- );
195
- if (signal.aborted || !isCurrent() || entered === undefined) return false;
196
- const normalized = entered.trim();
197
- const requested = normalized || undefined;
198
- if (requested !== undefined && !isFireworksAccountId(requested)) {
199
- ctx.ui.notify("Enter a URL-safe Fireworks account slug.", "warning");
200
- continue;
201
- }
202
- if (requested === state.settings.fireworksAccountId) return false;
203
- try {
204
- await settingsRuntime.update({ fireworksAccountId: requested }, signal);
205
- } catch (error) {
206
- if (signal.aborted || !isCurrent()) return false;
207
- ctx.ui.notify(`Could not save pi-usage.json: ${errorMessage(error)}`, "error");
208
- return false;
209
- }
210
- onApplied("fireworksAccountId");
211
- return true;
212
- }
213
- return false;
214
- }
215
-
216
- function displaySetting(id: UsageSettingId, value: UsageSettings[UsageSettingId]): string {
217
- if (id === "fireworksAccountId") return typeof value === "string" ? value : AUTO;
218
- return value ? ON : OFF;
107
+ parentSignal.addEventListener("abort", cancel, { once: true });
108
+ return {
109
+ render: (width: number) => container.render(width),
110
+ invalidate: () => container.invalidate(),
111
+ handleInput(data: string) {
112
+ if (closing) return;
113
+ if (matchesKey(data, Key.ctrl("c"))) cancel();
114
+ else settingsList.handleInput(data);
115
+ tui.requestRender();
116
+ },
117
+ dispose() {
118
+ localController.abort();
119
+ parentSignal.removeEventListener("abort", cancel);
120
+ },
121
+ };
122
+ })) ?? false
123
+ );
219
124
  }