@narumitw/pi-usage 0.57.0 → 0.58.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-usage",
3
- "version": "0.57.0",
3
+ "version": "0.58.0",
4
4
  "description": "Pi extension that shows current-account usage and DeepSeek API balance for supported providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -13,6 +13,7 @@
13
13
  "quota",
14
14
  "balance",
15
15
  "deepseek",
16
+ "fireworks",
16
17
  "codex",
17
18
  "kimi",
18
19
  "kimi-coding",
@@ -24,6 +24,7 @@ export function registerCodexFastMode(
24
24
  pi: ExtensionAPI,
25
25
  settingsRuntime: UsageSettingsRuntime,
26
26
  refreshStatus: (ctx: ExtensionContext) => void,
27
+ options: { registerSessionStart?: boolean } = {},
27
28
  ) {
28
29
  let sessionController = new AbortController();
29
30
  let generation = 0;
@@ -95,41 +96,47 @@ export function registerCodexFastMode(
95
96
  },
96
97
  });
97
98
 
98
- pi.on("session_start", async (_event, ctx) => {
99
+ const prepareSession = (ctx: ExtensionContext): Promise<void> => {
100
+ const sessionId = ctx.sessionManager.getSessionId();
99
101
  generation += 1;
100
102
  sessionController.abort();
101
103
  pendingFastRequests.clear();
102
104
  sessionController = new AbortController();
103
105
  const ownerGeneration = generation;
104
- const sessionId = ctx.sessionManager.getSessionId();
105
- let state: Readonly<UsageSettingsState>;
106
- try {
107
- state = await settingsRuntime.reload(sessionController.signal);
108
- } catch (error) {
109
- if (sessionController.signal.aborted || ownerGeneration !== generation) return;
110
- if (ctx.hasUI) {
106
+ return (async () => {
107
+ let state: Readonly<UsageSettingsState>;
108
+ try {
109
+ state = await settingsRuntime.reload(sessionController.signal);
110
+ } catch (error) {
111
+ if (sessionController.signal.aborted || ownerGeneration !== generation) return;
112
+ if (ctx.hasUI) {
113
+ ctx.ui.notify(
114
+ `Could not load pi-usage.json; using defaults. ${errorMessage(error)}`,
115
+ "warning",
116
+ );
117
+ }
118
+ return;
119
+ }
120
+ if (
121
+ sessionController.signal.aborted ||
122
+ ownerGeneration !== generation ||
123
+ ctx.sessionManager.getSessionId() !== sessionId
124
+ ) {
125
+ return;
126
+ }
127
+ if (ctx.hasUI && state.kind === "invalid") {
111
128
  ctx.ui.notify(
112
- `Could not load pi-usage.json; using defaults. ${errorMessage(error)}`,
129
+ `Invalid pi-usage.json; using defaults without overwriting it. ${state.issue}`,
113
130
  "warning",
114
131
  );
115
132
  }
116
- return;
117
- }
118
- if (
119
- sessionController.signal.aborted ||
120
- ownerGeneration !== generation ||
121
- ctx.sessionManager.getSessionId() !== sessionId
122
- ) {
123
- return;
124
- }
125
- if (ctx.hasUI && state.kind === "invalid") {
126
- ctx.ui.notify(
127
- `Invalid pi-usage.json; using defaults without overwriting it. ${state.issue}`,
128
- "warning",
129
- );
130
- }
131
- refreshStatus(ctx);
132
- });
133
+ refreshStatus(ctx);
134
+ })();
135
+ };
136
+
137
+ if (options.registerSessionStart !== false) {
138
+ pi.on("session_start", async (_event, ctx) => prepareSession(ctx));
139
+ }
133
140
 
134
141
  pi.on("before_provider_request", (event, ctx) => {
135
142
  const rewritten = rewriteCodexFastPayload(
@@ -164,6 +171,7 @@ export function registerCodexFastMode(
164
171
  });
165
172
 
166
173
  return {
174
+ prepareSession,
167
175
  availability(model: PiModel | undefined) {
168
176
  return codexFastAvailability(model, settingsRuntime.get().settings.codexFastMode);
169
177
  },
package/src/format.ts CHANGED
@@ -12,13 +12,18 @@ const VALUE_COLUMN = 29;
12
12
  export function formatUsageReport(report: UsageReport, displayState: UsageDisplayState): string {
13
13
  const stateLabel = displayState === "current" ? "Current" : "Configured";
14
14
  const title =
15
- report.providerId === "deepseek" ? "DeepSeek API Balance" : `${report.providerName} Usage`;
15
+ report.providerId === "deepseek"
16
+ ? "DeepSeek API Balance"
17
+ : report.providerId === "fireworks"
18
+ ? "Fireworks API Spend"
19
+ : `${report.providerName} Usage`;
16
20
  const lines = [`${title} · ${stateLabel}`];
17
21
  if (report.accountLabel) lines.push(`Account: ${report.accountLabel}`);
18
22
  lines.push(`Semantics: ${report.semantics.label}`, "");
19
23
 
20
24
  if (report.providerId === "openai-codex") formatCodexReport(lines, report);
21
25
  else if (report.providerId === "deepseek") formatDeepSeekReport(lines, report);
26
+ else if (report.providerId === "fireworks") formatFireworksReport(lines, report);
22
27
  else if (report.providerId === "github-copilot") formatGitHubCopilotReport(lines, report);
23
28
  else if (report.providerId === "openrouter") formatOpenRouterReport(lines, report);
24
29
  else if (report.providerId === "opencode-go") formatOpenCodeZenReport(lines, report);
@@ -44,6 +49,7 @@ export function formatUsageStatusline(
44
49
  return formatCodexStatusline(report, model, now, showCodexResetCountdown);
45
50
  }
46
51
  if (report.providerId === "deepseek") return formatDeepSeekStatusline(report);
52
+ if (report.providerId === "fireworks") return formatFireworksStatusline(report);
47
53
  if (report.providerId === "github-copilot") return formatGitHubCopilotStatusline(report);
48
54
  if (report.providerId === "openrouter") {
49
55
  const limit = report.buckets.find((bucket) => bucket.id === "key-limit");
@@ -125,6 +131,32 @@ function formatDeepSeekStatusline(report: UsageReport): string {
125
131
  return totals.length > 0 ? `deepseek ${totals.join(" · ")}` : "deepseek balance unavailable";
126
132
  }
127
133
 
134
+ function formatFireworksReport(lines: string[], report: UsageReport): void {
135
+ lines.push(`${"Spend window:".padEnd(VALUE_COLUMN)}Last 30 days (rated)`);
136
+ for (const currency of fireworksCurrencies(report)) {
137
+ lines.push("", `${currency} rated spend:`);
138
+ for (const metric of report.metrics) {
139
+ if (metric.currency !== currency) continue;
140
+ lines.push(`${`${metric.label}:`.padEnd(VALUE_COLUMN)}${currency} ${metric.value}`);
141
+ }
142
+ }
143
+ }
144
+
145
+ function formatFireworksStatusline(report: UsageReport): string {
146
+ const totals = report.metrics.filter((metric) => metric.id.endsWith("-total"));
147
+ if (totals.length === 0) return "fireworks no rated usage";
148
+ return `fireworks ${totals.map((metric) => `${metric.currency} ${metric.value}`).join(" · ")}`;
149
+ }
150
+
151
+ function fireworksCurrencies(report: UsageReport): string[] {
152
+ const currencies: string[] = [];
153
+ for (const metric of report.metrics) {
154
+ if (!metric.currency || currencies.includes(metric.currency)) continue;
155
+ currencies.push(metric.currency);
156
+ }
157
+ return currencies;
158
+ }
159
+
128
160
  function formatGitHubCopilotReport(lines: string[], report: UsageReport): void {
129
161
  const quota = findGitHubCopilotQuota(report);
130
162
  if (!quota || quota.limit === undefined || quota.remaining === undefined) {
package/src/index.ts CHANGED
@@ -34,6 +34,10 @@ export {
34
34
  export { formatProviderStates, formatUsageReport, formatUsageStatusline } from "./format.js";
35
35
  export { normalizeCodexBackendPayload } from "./providers/codex.js";
36
36
  export { normalizeDeepSeekBalancePayload } from "./providers/deepseek.js";
37
+ export {
38
+ normalizeFireworksAccountsPayload,
39
+ normalizeFireworksBillingSummaryPayload,
40
+ } from "./providers/fireworks.js";
37
41
  export { normalizeGitHubCopilotUsagePayload } from "./providers/github-copilot.js";
38
42
  export { normalizeKimiCodingUsagePayload } from "./providers/kimi-coding.js";
39
43
  export { normalizeOpenCodeZenPayload } from "./providers/opencode-zen.js";
@@ -64,6 +68,8 @@ export {
64
68
  } from "./settings.js";
65
69
  export type {
66
70
  DeepSeekBalancePayload,
71
+ FireworksAccountsPayload,
72
+ FireworksBillingSummaryPayload,
67
73
  KimiCodingUsagePayload,
68
74
  ProviderUsageState,
69
75
  ResolvedUsageAuth,
@@ -72,6 +78,7 @@ export type {
72
78
  UsageMetric,
73
79
  UsageModel,
74
80
  UsageProviderAdapter,
81
+ UsageQuerySettings,
75
82
  UsageReport,
76
83
  UsageSemantics,
77
84
  UsageSemanticsKind,
@@ -0,0 +1,198 @@
1
+ import { sanitizeDisplayText } from "../core.js";
2
+ import type {
3
+ FireworksAccountsPayload,
4
+ FireworksBillingSummaryPayload,
5
+ UsageMetric,
6
+ UsageReport,
7
+ } from "../types.js";
8
+
9
+ const NANOS_PER_UNIT = 1_000_000_000n;
10
+ const CURRENCY_PATTERN = /^[A-Z]{3}$/u;
11
+ const ACCOUNT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,127}$/u;
12
+ const INTEGER_PATTERN = /^-?\d+$/u;
13
+ const INT64_MIN = -(2n ** 63n);
14
+ const INT64_MAX = 2n ** 63n - 1n;
15
+ const MAX_UNITS_CHARS = 20;
16
+ const MAX_NANOS_CHARS = 11;
17
+
18
+ const SERIES_KEYS = ["serverless", "dedicated", "training", "other"] as const;
19
+ type SeriesKey = (typeof SERIES_KEYS)[number];
20
+
21
+ const SERIES_LABELS: Readonly<Record<SeriesKey, string>> = {
22
+ serverless: "Serverless",
23
+ dedicated: "Dedicated deployments",
24
+ training: "Training",
25
+ other: "Other",
26
+ };
27
+
28
+ export function isFireworksAccountId(value: unknown): value is string {
29
+ return typeof value === "string" && ACCOUNT_ID_PATTERN.test(value);
30
+ }
31
+
32
+ export function normalizeFireworksAccountsPayload(payload: FireworksAccountsPayload): string[] {
33
+ if (!Array.isArray(payload.accounts)) {
34
+ throw new Error("Fireworks accounts response did not contain an accounts array.");
35
+ }
36
+ const accounts: string[] = [];
37
+ for (const raw of payload.accounts) {
38
+ const account = asObject(raw);
39
+ if (!account) throw new Error("Fireworks accounts response row was not an object.");
40
+ if (typeof account.name !== "string") {
41
+ throw new Error("Fireworks accounts response omitted the account resource name.");
42
+ }
43
+ const match = /^accounts\/([^/]+)$/u.exec(account.name);
44
+ if (!match || !isFireworksAccountId(match[1])) {
45
+ throw new Error("Fireworks accounts response returned an unsafe account resource name.");
46
+ }
47
+ const accountId = match[1];
48
+ if (accounts.includes(accountId)) {
49
+ throw new Error(`Fireworks accounts response repeated ${accountId}.`);
50
+ }
51
+ accounts.push(accountId);
52
+ }
53
+ return accounts;
54
+ }
55
+
56
+ export function normalizeFireworksBillingSummaryPayload(
57
+ payload: FireworksBillingSummaryPayload,
58
+ accountId: string,
59
+ capturedAt: number,
60
+ ): UsageReport {
61
+ if (!isFireworksAccountId(accountId)) {
62
+ throw new Error("Fireworks billing summary received an unsafe account identifier.");
63
+ }
64
+ if (payload.lineItems !== undefined && !Array.isArray(payload.lineItems)) {
65
+ throw new Error("Fireworks billing summary lineItems was not an array.");
66
+ }
67
+
68
+ const totals = new Map<string, Map<SeriesKey, bigint>>();
69
+ for (const raw of payload.lineItems ?? []) {
70
+ const lineItem = asObject(raw);
71
+ if (!lineItem) throw new Error("Fireworks billing line item was not an object.");
72
+ const cost = moneyAmount(lineItem.totalCost, "line item total cost");
73
+ const series = seriesKey(lineItem.series);
74
+ let amounts = totals.get(cost.currency);
75
+ if (!amounts) {
76
+ amounts = new Map<SeriesKey, bigint>();
77
+ totals.set(cost.currency, amounts);
78
+ }
79
+ amounts.set(series, (amounts.get(series) ?? 0n) + cost.amount);
80
+ }
81
+
82
+ const metrics: UsageMetric[] = [];
83
+ for (const [currency, amounts] of totals) {
84
+ metrics.push({
85
+ id: `${currency.toLowerCase()}-total`,
86
+ label: "Total spend",
87
+ value: formatMoneyAmount(sumSeries(amounts)),
88
+ unit: "currency",
89
+ currency,
90
+ });
91
+ for (const series of SERIES_KEYS) {
92
+ const amount = amounts.get(series);
93
+ if (amount === undefined) continue;
94
+ metrics.push({
95
+ id: `${currency.toLowerCase()}-${series}`,
96
+ label: SERIES_LABELS[series],
97
+ value: formatMoneyAmount(amount),
98
+ unit: "currency",
99
+ currency,
100
+ });
101
+ }
102
+ }
103
+
104
+ const notes = [
105
+ "Rated line items may differ from the final invoice once credits or adjustments are applied.",
106
+ ];
107
+ if (metrics.length === 0) {
108
+ notes.push("Fireworks returned no rated line items for the last 30 days.");
109
+ }
110
+
111
+ return {
112
+ providerId: "fireworks",
113
+ providerName: "Fireworks",
114
+ capturedAt,
115
+ source: "fireworks-billing-summary",
116
+ semantics: { kind: "api-key", label: "Fireworks API spend" },
117
+ accountLabel: sanitizeDisplayText(accountId, 80),
118
+ buckets: [],
119
+ metrics,
120
+ notes,
121
+ };
122
+ }
123
+
124
+ type RatedMoney = { currency: string; amount: bigint };
125
+
126
+ function moneyAmount(value: unknown, description: string): RatedMoney {
127
+ const money = asObject(value);
128
+ if (!money) throw new Error(`Fireworks billing ${description} was not a money object.`);
129
+ const currency = typeof money.currencyCode === "string" ? money.currencyCode : undefined;
130
+ if (!currency || !CURRENCY_PATTERN.test(currency)) {
131
+ throw new Error(`Fireworks billing ${description} currency was not an ISO 4217 code.`);
132
+ }
133
+ // proto3 JSON omits zero-valued money fields, so absent components default to zero.
134
+ const units =
135
+ money.units === undefined
136
+ ? 0n
137
+ : integerComponent(money.units, description, "whole units", MAX_UNITS_CHARS);
138
+ if (units < INT64_MIN || units > INT64_MAX) {
139
+ throw new Error(`Fireworks billing ${description} whole units exceeded the int64 range.`);
140
+ }
141
+ const nanos =
142
+ money.nanos === undefined
143
+ ? 0n
144
+ : integerComponent(money.nanos, description, "nano units", MAX_NANOS_CHARS);
145
+ if (nanos <= -NANOS_PER_UNIT || nanos >= NANOS_PER_UNIT) {
146
+ throw new Error(`Fireworks billing ${description} nano units exceeded the Money range.`);
147
+ }
148
+ if ((units > 0n && nanos < 0n) || (units < 0n && nanos > 0n)) {
149
+ throw new Error(`Fireworks billing ${description} mixed unit and nano signs.`);
150
+ }
151
+ return { currency, amount: units * NANOS_PER_UNIT + nanos };
152
+ }
153
+
154
+ function integerComponent(
155
+ value: unknown,
156
+ description: string,
157
+ component: string,
158
+ maxChars: number,
159
+ ): bigint {
160
+ const text =
161
+ typeof value === "number" && Number.isSafeInteger(value)
162
+ ? String(value)
163
+ : typeof value === "string" && INTEGER_PATTERN.test(value)
164
+ ? value
165
+ : undefined;
166
+ if (text === undefined || text.length > maxChars) {
167
+ throw new Error(`Fireworks billing ${description} ${component} was not a bounded integer.`);
168
+ }
169
+ return BigInt(text);
170
+ }
171
+
172
+ function seriesKey(value: unknown): SeriesKey {
173
+ if (value === undefined || value === null) return "other";
174
+ if (typeof value !== "string") throw new Error("Fireworks billing line item series was invalid.");
175
+ if (value === "SERVERLESS") return "serverless";
176
+ if (value === "DEDICATED_DEPLOYMENT") return "dedicated";
177
+ if (value === "TRAINING") return "training";
178
+ return "other";
179
+ }
180
+
181
+ function sumSeries(amounts: ReadonlyMap<SeriesKey, bigint>): bigint {
182
+ let total = 0n;
183
+ for (const amount of amounts.values()) total += amount;
184
+ return total;
185
+ }
186
+
187
+ function formatMoneyAmount(amount: bigint): string {
188
+ const negative = amount < 0n;
189
+ const magnitude = negative ? -amount : amount;
190
+ const units = magnitude / NANOS_PER_UNIT;
191
+ const nanos = (magnitude % NANOS_PER_UNIT).toString().padStart(9, "0").replace(/0+$/u, "");
192
+ return `${negative ? "-" : ""}${units.toString()}${nanos ? `.${nanos}` : ""}`;
193
+ }
194
+
195
+ function asObject(value: unknown): Record<string, unknown> | undefined {
196
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
197
+ return value as Record<string, unknown>;
198
+ }
package/src/query.ts CHANGED
@@ -7,6 +7,11 @@ import {
7
7
  } from "./oauth-credential-source.js";
8
8
  import { normalizeCodexBackendPayload } from "./providers/codex.js";
9
9
  import { normalizeDeepSeekBalancePayload } from "./providers/deepseek.js";
10
+ import {
11
+ isFireworksAccountId,
12
+ normalizeFireworksAccountsPayload,
13
+ normalizeFireworksBillingSummaryPayload,
14
+ } from "./providers/fireworks.js";
10
15
  import { normalizeGitHubCopilotUsagePayload } from "./providers/github-copilot.js";
11
16
  import { normalizeKimiCodingUsagePayload } from "./providers/kimi-coding.js";
12
17
  import { normalizeOpenCodeZenPayload } from "./providers/opencode-zen.js";
@@ -16,6 +21,8 @@ import { normalizeZaiQuotaPayload } from "./providers/zai.js";
16
21
  import type {
17
22
  CodexBackendPayload,
18
23
  DeepSeekBalancePayload,
24
+ FireworksAccountsPayload,
25
+ FireworksBillingSummaryPayload,
19
26
  GitHubCopilotUsagePayload,
20
27
  KimiCodingUsagePayload,
21
28
  OpenCodeZenPayload,
@@ -23,6 +30,7 @@ import type {
23
30
  PiModel,
24
31
  ResolvedUsageAuth,
25
32
  UsageProviderAdapter,
33
+ UsageQuerySettings,
26
34
  UsageReport,
27
35
  XaiBillingPayload,
28
36
  XaiUserPayload,
@@ -31,6 +39,9 @@ import type {
31
39
 
32
40
  const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
33
41
  const DEEPSEEK_BALANCE_URL = "https://api.deepseek.com/user/balance";
42
+ const FIREWORKS_BILLING_SUMMARY_ORIGIN = "https://api.fireworks.ai";
43
+ const FIREWORKS_SPEND_WINDOW_DAYS = 30;
44
+ const FIREWORKS_MAX_ACCOUNT_PAGES = 5;
34
45
  const GITHUB_COPILOT_USAGE_URL = "https://api.github.com/copilot_internal/user";
35
46
  const OPENROUTER_KEY_URL = "https://openrouter.ai/api/v1/key";
36
47
  const OPENCODE_GO_USAGE_URL = "https://opencode.ai/zen/go/v1/usage";
@@ -122,6 +133,35 @@ export const SUPPORTED_ADAPTERS: readonly UsageProviderAdapter[] = [
122
133
  return normalizeOpenRouterKeyPayload(payload as OpenRouterKeyPayload, Date.now());
123
134
  },
124
135
  },
136
+ {
137
+ id: "fireworks",
138
+ displayName: "Fireworks",
139
+ semantics: { kind: "api-key", label: "Fireworks API spend" },
140
+ async query(auth, signal, timeoutMs, guard, settings) {
141
+ if (!guard) throw new Error("Fireworks API spend requires request-boundary revalidation.");
142
+ const startedAt = Date.now();
143
+ await guard();
144
+ const accountId = await resolveFireworksAccountId(
145
+ auth,
146
+ signal,
147
+ remainingTimeout(timeoutMs, startedAt, "resolving the Fireworks account"),
148
+ guard,
149
+ settings?.fireworksAccountId,
150
+ );
151
+ await guard();
152
+ const billingWindowAt = Date.now();
153
+ const payload = (await fetchProviderJson(
154
+ fireworksBillingSummaryUrl(accountId, billingWindowAt),
155
+ auth,
156
+ signal,
157
+ remainingTimeout(timeoutMs, startedAt, "fetching Fireworks rated spend"),
158
+ "Fireworks billing summary endpoint",
159
+ { redirect: "error" },
160
+ )) as FireworksBillingSummaryPayload;
161
+ await guard();
162
+ return normalizeFireworksBillingSummaryPayload(payload, accountId, Date.now());
163
+ },
164
+ },
125
165
  {
126
166
  id: "opencode-go",
127
167
  displayName: "OpenCode Go",
@@ -370,9 +410,10 @@ export async function queryProviderUsage(
370
410
  signal: AbortSignal,
371
411
  timeoutMs: number,
372
412
  guard?: UsageRequestGuard,
413
+ settings?: Readonly<UsageQuerySettings>,
373
414
  ): Promise<UsageReport> {
374
415
  try {
375
- return await adapter.query(auth, signal, timeoutMs, guard);
416
+ return await adapter.query(auth, signal, timeoutMs, guard, settings);
376
417
  } catch (error) {
377
418
  if (isStaleExtensionContextError(error) || isAbortError(error)) throw error;
378
419
  throw new Error(redactUsageError(errorMessage(error), auth.secrets));
@@ -733,6 +774,7 @@ function hasOfficialUrlOrigin(value: string, providerId: string): boolean {
733
774
  const url = new URL(value);
734
775
  if (providerId === "openai-codex") return url.origin === "https://chatgpt.com";
735
776
  if (providerId === "deepseek") return url.origin === "https://api.deepseek.com";
777
+ if (providerId === "fireworks") return url.origin === FIREWORKS_BILLING_SUMMARY_ORIGIN;
736
778
  if (providerId === "openrouter") return url.origin === "https://openrouter.ai";
737
779
  if (providerId === "opencode-go") return url.origin === "https://opencode.ai";
738
780
  if (providerId === "kimi-coding") return url.origin === "https://api.kimi.com";
@@ -771,12 +813,108 @@ function validatedXaiUserId(value: unknown): string {
771
813
  return value;
772
814
  }
773
815
 
774
- function remainingTimeout(timeoutMs: number, startedAt: number): number {
816
+ function remainingTimeout(
817
+ timeoutMs: number,
818
+ startedAt: number,
819
+ description = "fetching xAI consumer usage",
820
+ ): number {
775
821
  const remaining = timeoutMs - (Date.now() - startedAt);
776
- if (remaining <= 0) throw new Error("Timed out while fetching xAI consumer usage.");
822
+ if (remaining <= 0) throw new Error(`Timed out while ${description}.`);
777
823
  return remaining;
778
824
  }
779
825
 
826
+ // Fireworks requires an account slug for its billing endpoints; discover it through the
827
+ // documented account listing, requiring an explicit slug when a key can see several accounts.
828
+ async function resolveFireworksAccountId(
829
+ auth: ResolvedUsageAuth,
830
+ signal: AbortSignal,
831
+ timeoutMs: number,
832
+ guard: () => Promise<void>,
833
+ configuredAccountId: string | undefined,
834
+ ): Promise<string> {
835
+ if (configuredAccountId !== undefined && !isFireworksAccountId(configuredAccountId)) {
836
+ throw new Error("The Fireworks account setting was not a safe account slug.");
837
+ }
838
+ const startedAt = Date.now();
839
+ const accounts: string[] = [];
840
+ let pageToken: string | undefined;
841
+ for (let page = 0; page < FIREWORKS_MAX_ACCOUNT_PAGES; page += 1) {
842
+ await guard();
843
+ const payload = (await fetchProviderJson(
844
+ fireworksAccountsUrl(pageToken),
845
+ auth,
846
+ signal,
847
+ remainingTimeout(timeoutMs, startedAt, "fetching Fireworks accounts"),
848
+ "Fireworks accounts endpoint",
849
+ { redirect: "error" },
850
+ )) as FireworksAccountsPayload;
851
+ for (const accountId of normalizeFireworksAccountsPayload(
852
+ payload as FireworksAccountsPayload,
853
+ )) {
854
+ if (accounts.includes(accountId)) {
855
+ throw new Error(`Fireworks accounts listing repeated ${accountId}.`);
856
+ }
857
+ accounts.push(accountId);
858
+ if (configuredAccountId === accountId) return accountId;
859
+ }
860
+ pageToken = fireworksNextPageToken(payload.nextPageToken);
861
+ if (!pageToken) break;
862
+ }
863
+ if (pageToken) {
864
+ throw new Error(
865
+ configuredAccountId
866
+ ? `The configured Fireworks account was not found within the first ${FIREWORKS_MAX_ACCOUNT_PAGES} listing pages.`
867
+ : `Fireworks account listing exceeded ${FIREWORKS_MAX_ACCOUNT_PAGES} pages; set fireworksAccountId in pi-usage.json to an account returned in those pages.`,
868
+ );
869
+ }
870
+ if (accounts.length === 0) {
871
+ throw new Error("Fireworks account discovery returned no accounts for this API key.");
872
+ }
873
+ if (configuredAccountId) {
874
+ throw new Error(
875
+ "The configured Fireworks account does not match an account visible to this API key.",
876
+ );
877
+ }
878
+ if (accounts.length === 1) return accounts[0] as string;
879
+ const preview = accounts.slice(0, 8).join(", ");
880
+ const suffix = accounts.length > 8 ? ` …and ${accounts.length - 8} more` : "";
881
+ throw new Error(
882
+ `The Fireworks key can see ${accounts.length} accounts (${preview}${suffix}); set fireworksAccountId in pi-usage.json to one of them.`,
883
+ );
884
+ }
885
+
886
+ function fireworksAccountsUrl(pageToken: string | undefined): string {
887
+ const url = new URL("/v1/accounts", FIREWORKS_BILLING_SUMMARY_ORIGIN);
888
+ url.searchParams.set("pageSize", "200");
889
+ if (pageToken !== undefined) url.searchParams.set("pageToken", pageToken);
890
+ return url.toString();
891
+ }
892
+
893
+ function fireworksNextPageToken(value: unknown): string | undefined {
894
+ if (value === undefined || value === null) return undefined;
895
+ if (typeof value !== "string" || !value || value.length > 512) {
896
+ throw new Error("Fireworks accounts listing returned an invalid page token.");
897
+ }
898
+ return value;
899
+ }
900
+
901
+ function fireworksBillingSummaryUrl(accountId: string, startedAt: number): string {
902
+ const dayMs = 24 * 60 * 60 * 1000;
903
+ const dayFloor = (time: number) => `${new Date(time).toISOString().slice(0, 10)}T00:00:00Z`;
904
+ const url = new URL(
905
+ `/v1/accounts/${accountId}/billing/summary`,
906
+ FIREWORKS_BILLING_SUMMARY_ORIGIN,
907
+ );
908
+ // The endpoint aggregates by UTC date; endTime is exclusive, so the window includes today
909
+ // plus the preceding 29 dates.
910
+ url.searchParams.set(
911
+ "startTime",
912
+ dayFloor(startedAt - (FIREWORKS_SPEND_WINDOW_DAYS - 1) * dayMs),
913
+ );
914
+ url.searchParams.set("endTime", dayFloor(startedAt + dayMs));
915
+ return url.toString();
916
+ }
917
+
780
918
  function zaiMonitorUrl(baseUrl: string | undefined): string {
781
919
  const base = baseUrl?.trim();
782
920
  if (!base) throw new Error("Z.AI model base URL is unavailable.");
package/src/settings.ts CHANGED
@@ -3,6 +3,7 @@ import { constants } from "node:fs";
3
3
  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
+ import { isFireworksAccountId } from "./providers/fireworks.js";
6
7
 
7
8
  export const USAGE_SETTINGS_FILE = "pi-usage.json";
8
9
  export const MAX_USAGE_SETTINGS_BYTES = 64 * 1024;
@@ -10,6 +11,7 @@ export const MAX_USAGE_SETTINGS_BYTES = 64 * 1024;
10
11
  export interface UsageSettings {
11
12
  codexFastMode: boolean;
12
13
  codexStatusResetCountdown: boolean;
14
+ fireworksAccountId?: string;
13
15
  }
14
16
 
15
17
  export const DEFAULT_USAGE_SETTINGS: Readonly<UsageSettings> = Object.freeze({
@@ -60,6 +62,12 @@ export function normalizeUsageSettings(value: unknown): UsageSettings | undefine
60
62
  ) {
61
63
  return undefined;
62
64
  }
65
+ if (
66
+ Object.hasOwn(value, "fireworksAccountId") &&
67
+ !isFireworksAccountId(value.fireworksAccountId)
68
+ ) {
69
+ return undefined;
70
+ }
63
71
  return {
64
72
  codexFastMode:
65
73
  typeof value.codexFastMode === "boolean"
@@ -69,6 +77,9 @@ export function normalizeUsageSettings(value: unknown): UsageSettings | undefine
69
77
  typeof value.codexStatusResetCountdown === "boolean"
70
78
  ? value.codexStatusResetCountdown
71
79
  : DEFAULT_USAGE_SETTINGS.codexStatusResetCountdown,
80
+ ...(isFireworksAccountId(value.fireworksAccountId)
81
+ ? { fireworksAccountId: value.fireworksAccountId }
82
+ : {}),
72
83
  };
73
84
  }
74
85
 
@@ -172,7 +183,11 @@ async function saveUsageSettingsPatch(
172
183
  if (latest.kind === "invalid") {
173
184
  throw new Error("Cannot overwrite an invalid pi-usage.json; repair it and reload first");
174
185
  }
175
- const document = { ...latest.document, ...patch };
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;
190
+ }
176
191
  const settings = normalizeUsageSettings(document);
177
192
  if (!settings) throw new Error("Refusing to save invalid pi-usage settings");
178
193
  const directory = dirname(path);
package/src/types.ts CHANGED
@@ -55,6 +55,10 @@ export interface ResolvedUsageAuth {
55
55
  model: PiModel;
56
56
  }
57
57
 
58
+ export interface UsageQuerySettings {
59
+ fireworksAccountId?: string;
60
+ }
61
+
58
62
  export interface UsageProviderAdapter {
59
63
  id: string;
60
64
  displayName: string;
@@ -65,6 +69,7 @@ export interface UsageProviderAdapter {
65
69
  signal: AbortSignal,
66
70
  timeoutMs: number,
67
71
  guard?: () => Promise<void>,
72
+ settings?: Readonly<UsageQuerySettings>,
68
73
  ): Promise<UsageReport>;
69
74
  }
70
75
 
@@ -89,6 +94,15 @@ export type DeepSeekBalancePayload = {
89
94
  balance_infos?: unknown;
90
95
  };
91
96
 
97
+ export type FireworksAccountsPayload = {
98
+ accounts?: unknown;
99
+ nextPageToken?: unknown;
100
+ };
101
+
102
+ export type FireworksBillingSummaryPayload = {
103
+ lineItems?: unknown;
104
+ };
105
+
92
106
  export type GitHubCopilotUsagePayload = {
93
107
  login?: unknown;
94
108
  copilot_plan?: unknown;