@narumitw/pi-usage 0.54.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,7 +1,7 @@
1
1
  {
2
2
  "name": "@narumitw/pi-usage",
3
- "version": "0.54.0",
4
- "description": "Pi extension that shows current-account usage for Codex, Kimi For Coding, GitHub Copilot, OpenRouter, OpenCode Zen, Z.AI, and xAI OAuth subscriptions.",
3
+ "version": "0.58.0",
4
+ "description": "Pi extension that shows current-account usage and DeepSeek API balance for supported providers.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "private": false,
@@ -11,6 +11,9 @@
11
11
  "pi",
12
12
  "usage",
13
13
  "quota",
14
+ "balance",
15
+ "deepseek",
16
+ "fireworks",
14
17
  "codex",
15
18
  "kimi",
16
19
  "kimi-coding",
@@ -34,9 +37,6 @@
34
37
  "./dist/index.ts"
35
38
  ]
36
39
  },
37
- "piExtension": {
38
- "lifecycle": "stable"
39
- },
40
40
  "scripts": {
41
41
  "build": "node scripts/build-runtime.mjs",
42
42
  "check": "npm run build && biome check --vcs-use-ignore-file=false src test scripts package.json tsconfig.json README.md && npm run typecheck",
@@ -50,11 +50,11 @@
50
50
  "@earendil-works/pi-tui": "*"
51
51
  },
52
52
  "devDependencies": {
53
- "@biomejs/biome": "2.5.10",
54
- "@earendil-works/pi-ai": "0.84.3",
55
- "@earendil-works/pi-coding-agent": "0.84.3",
56
- "@earendil-works/pi-tui": "0.84.3",
57
- "@types/node": "26.2.0",
53
+ "@biomejs/biome": "2.5.11",
54
+ "@earendil-works/pi-ai": "0.84.4",
55
+ "@earendil-works/pi-coding-agent": "0.84.4",
56
+ "@earendil-works/pi-tui": "0.84.4",
57
+ "@types/node": "26.4.0",
58
58
  "esbuild": "0.28.2",
59
59
  "typescript": "7.0.2"
60
60
  },
@@ -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
@@ -11,11 +11,19 @@ const VALUE_COLUMN = 29;
11
11
 
12
12
  export function formatUsageReport(report: UsageReport, displayState: UsageDisplayState): string {
13
13
  const stateLabel = displayState === "current" ? "Current" : "Configured";
14
- const lines = [`${report.providerName} Usage · ${stateLabel}`];
14
+ const title =
15
+ report.providerId === "deepseek"
16
+ ? "DeepSeek API Balance"
17
+ : report.providerId === "fireworks"
18
+ ? "Fireworks API Spend"
19
+ : `${report.providerName} Usage`;
20
+ const lines = [`${title} · ${stateLabel}`];
15
21
  if (report.accountLabel) lines.push(`Account: ${report.accountLabel}`);
16
22
  lines.push(`Semantics: ${report.semantics.label}`, "");
17
23
 
18
24
  if (report.providerId === "openai-codex") formatCodexReport(lines, report);
25
+ else if (report.providerId === "deepseek") formatDeepSeekReport(lines, report);
26
+ else if (report.providerId === "fireworks") formatFireworksReport(lines, report);
19
27
  else if (report.providerId === "github-copilot") formatGitHubCopilotReport(lines, report);
20
28
  else if (report.providerId === "openrouter") formatOpenRouterReport(lines, report);
21
29
  else if (report.providerId === "opencode-go") formatOpenCodeZenReport(lines, report);
@@ -31,8 +39,17 @@ export function formatUsageReport(report: UsageReport, displayState: UsageDispla
31
39
  return lines.join("\n").trimEnd();
32
40
  }
33
41
 
34
- export function formatUsageStatusline(report: UsageReport, model?: UsageModel): string | undefined {
35
- if (report.providerId === "openai-codex") return formatCodexStatusline(report, model);
42
+ export function formatUsageStatusline(
43
+ report: UsageReport,
44
+ model?: UsageModel,
45
+ now = Date.now(),
46
+ showCodexResetCountdown = true,
47
+ ): string | undefined {
48
+ if (report.providerId === "openai-codex") {
49
+ return formatCodexStatusline(report, model, now, showCodexResetCountdown);
50
+ }
51
+ if (report.providerId === "deepseek") return formatDeepSeekStatusline(report);
52
+ if (report.providerId === "fireworks") return formatFireworksStatusline(report);
36
53
  if (report.providerId === "github-copilot") return formatGitHubCopilotStatusline(report);
37
54
  if (report.providerId === "openrouter") {
38
55
  const limit = report.buckets.find((bucket) => bucket.id === "key-limit");
@@ -42,6 +59,9 @@ export function formatUsageStatusline(report: UsageReport, model?: UsageModel):
42
59
  }
43
60
  if (report.providerId === "opencode-go") return formatOpenCodeZenStatusline(report);
44
61
  if (report.providerId === "kimi-coding") return formatKimiCodingStatusline(report);
62
+ if (report.providerId === "zai" || report.providerId === "zai-coding-cn") {
63
+ return formatZaiStatusline(report);
64
+ }
45
65
  return undefined;
46
66
  }
47
67
 
@@ -84,6 +104,59 @@ function formatCodexReport(lines: string[], report: UsageReport): void {
84
104
  }
85
105
  }
86
106
 
107
+ function formatDeepSeekReport(lines: string[], report: UsageReport): void {
108
+ const availability = report.metrics.find((metric) => metric.id === "api-availability");
109
+ lines.push(
110
+ `${"API calls:".padEnd(VALUE_COLUMN)}${availability?.value === "available" ? "Available" : "Unavailable"}`,
111
+ );
112
+ for (const currency of ["CNY", "USD"]) {
113
+ const metrics = report.metrics.filter((metric) => metric.currency === currency);
114
+ if (metrics.length === 0) continue;
115
+ lines.push("", `${currency} balance:`);
116
+ for (const metric of metrics) {
117
+ lines.push(`${`${metric.label}:`.padEnd(VALUE_COLUMN)}${currency} ${metric.value}`);
118
+ }
119
+ }
120
+ }
121
+
122
+ function formatDeepSeekStatusline(report: UsageReport): string {
123
+ const availability = report.metrics.find((metric) => metric.id === "api-availability");
124
+ if (availability?.value !== "available") return "deepseek API unavailable";
125
+ const totals = ["CNY", "USD"].flatMap((currency) => {
126
+ const metric = report.metrics.find(
127
+ (candidate) => candidate.id === `${currency.toLowerCase()}-total`,
128
+ );
129
+ return metric ? [`${currency} ${metric.value}`] : [];
130
+ });
131
+ return totals.length > 0 ? `deepseek ${totals.join(" · ")}` : "deepseek balance unavailable";
132
+ }
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
+
87
160
  function formatGitHubCopilotReport(lines: string[], report: UsageReport): void {
88
161
  const quota = findGitHubCopilotQuota(report);
89
162
  if (!quota || quota.limit === undefined || quota.remaining === undefined) {
@@ -213,6 +286,22 @@ function formatKimiCodingStatusline(report: UsageReport): string | undefined {
213
286
  return parts.length > 1 ? parts.join(" ") : undefined;
214
287
  }
215
288
 
289
+ function formatZaiStatusline(report: UsageReport): string | undefined {
290
+ const selected = [
291
+ report.buckets.find((bucket) => bucket.id === "five-hour"),
292
+ report.buckets.find((bucket) => bucket.id === "weekly"),
293
+ ];
294
+ const parts = ["zai"];
295
+ for (const bucket of selected) {
296
+ if (!bucket?.limit || bucket.remaining === undefined) continue;
297
+ const fallback = bucket.id === "weekly" ? "weekly" : "5h";
298
+ parts.push(
299
+ `${percentRemaining(bucket)}% ${formatWindowLabel(bucket.windowMinutes, fallback, true)}`,
300
+ );
301
+ }
302
+ return parts.length > 1 ? parts.join(" ") : undefined;
303
+ }
304
+
216
305
  function formatCurrencyMetric(metric: UsageReport["metrics"][number]): string {
217
306
  if (typeof metric.value !== "number") return String(metric.value);
218
307
  if (!metric.currency) return "unavailable";
@@ -289,7 +378,12 @@ function formatGenericReport(lines: string[], report: UsageReport): void {
289
378
  }
290
379
  }
291
380
 
292
- function formatCodexStatusline(report: UsageReport, model?: UsageModel): string | undefined {
381
+ function formatCodexStatusline(
382
+ report: UsageReport,
383
+ model?: UsageModel,
384
+ now = Date.now(),
385
+ showResetCountdown = true,
386
+ ): string | undefined {
293
387
  const group = selectCodexGroup(report, model);
294
388
  if (!group) return formatCodexCreditsStatus(report);
295
389
  const buckets = report.buckets.filter((bucket) => (bucket.groupId ?? bucket.id) === group);
@@ -299,10 +393,15 @@ function formatCodexStatusline(report: UsageReport, model?: UsageModel): string
299
393
  ];
300
394
  for (const bucket of buckets) {
301
395
  if (bucket.remaining === undefined) continue;
396
+ const percent = `${clampPercent(bucket.remaining).toFixed(0)}%`;
302
397
  const fallback = bucket.id.endsWith(":secondary") ? "weekly" : "5h";
303
- parts.push(
304
- `${clampPercent(bucket.remaining).toFixed(0)}% ${formatWindowLabel(bucket.windowMinutes, fallback, true)}`,
305
- );
398
+ const window = formatWindowLabel(bucket.windowMinutes, fallback, true);
399
+ if (!showResetCountdown) {
400
+ parts.push(`${percent} ${window}`);
401
+ continue;
402
+ }
403
+ const reset = formatResetCountdown(bucket.resetsAt, now);
404
+ parts.push(`${percent} ${reset ? `↻ ${reset}` : window}`);
306
405
  }
307
406
  return parts.length > 1 ? parts.join(" ") : formatCodexCreditsStatus(report);
308
407
  }
@@ -405,6 +504,26 @@ function formatWindowLabel(
405
504
  return `${minutes}m`;
406
505
  }
407
506
 
507
+ function formatResetCountdown(resetsAt: number | undefined, now: number): string | undefined {
508
+ if (resetsAt === undefined || !Number.isFinite(resetsAt) || !Number.isFinite(now))
509
+ return undefined;
510
+ const totalMinutes = Math.max(0, Math.ceil((resetsAt * 1_000 - now) / 60_000));
511
+ const days = Math.floor(totalMinutes / 1_440);
512
+ const hours = Math.floor((totalMinutes % 1_440) / 60);
513
+ const minutes = totalMinutes % 60;
514
+ if (days > 0) {
515
+ return [
516
+ `${String(days)}d`,
517
+ hours > 0 ? `${String(hours)}h` : minutes > 0 ? `${String(minutes)}m` : "",
518
+ ]
519
+ .filter(Boolean)
520
+ .join("");
521
+ }
522
+ if (hours > 0)
523
+ return [`${String(hours)}h`, minutes > 0 ? `${String(minutes)}m` : ""].filter(Boolean).join("");
524
+ return `${String(minutes)}m`;
525
+ }
526
+
408
527
  function formatMetricValue(value: number | string, unit: UsageBucket["unit"] | undefined): string {
409
528
  if (unit === "usd" && typeof value === "number") return formatUsd(value);
410
529
  return String(value);
package/src/index.ts CHANGED
@@ -33,6 +33,11 @@ export {
33
33
  } from "./core.js";
34
34
  export { formatProviderStates, formatUsageReport, formatUsageStatusline } from "./format.js";
35
35
  export { normalizeCodexBackendPayload } from "./providers/codex.js";
36
+ export { normalizeDeepSeekBalancePayload } from "./providers/deepseek.js";
37
+ export {
38
+ normalizeFireworksAccountsPayload,
39
+ normalizeFireworksBillingSummaryPayload,
40
+ } from "./providers/fireworks.js";
36
41
  export { normalizeGitHubCopilotUsagePayload } from "./providers/github-copilot.js";
37
42
  export { normalizeKimiCodingUsagePayload } from "./providers/kimi-coding.js";
38
43
  export { normalizeOpenCodeZenPayload } from "./providers/opencode-zen.js";
@@ -62,6 +67,9 @@ export {
62
67
  usageSettingsPath,
63
68
  } from "./settings.js";
64
69
  export type {
70
+ DeepSeekBalancePayload,
71
+ FireworksAccountsPayload,
72
+ FireworksBillingSummaryPayload,
65
73
  KimiCodingUsagePayload,
66
74
  ProviderUsageState,
67
75
  ResolvedUsageAuth,
@@ -70,6 +78,7 @@ export type {
70
78
  UsageMetric,
71
79
  UsageModel,
72
80
  UsageProviderAdapter,
81
+ UsageQuerySettings,
73
82
  UsageReport,
74
83
  UsageSemantics,
75
84
  UsageSemanticsKind,
@@ -0,0 +1,85 @@
1
+ import type { DeepSeekBalancePayload, UsageMetric, UsageReport } from "../types.js";
2
+
3
+ const CURRENCIES = ["CNY", "USD"] as const;
4
+ type DeepSeekCurrency = (typeof CURRENCIES)[number];
5
+
6
+ const BALANCE_FIELDS = [
7
+ ["total", "Total balance", "total_balance"],
8
+ ["granted", "Granted balance", "granted_balance"],
9
+ ["topped-up", "Topped-up balance", "topped_up_balance"],
10
+ ] as const;
11
+
12
+ export function normalizeDeepSeekBalancePayload(
13
+ payload: DeepSeekBalancePayload,
14
+ capturedAt: number,
15
+ ): UsageReport {
16
+ if (typeof payload.is_available !== "boolean") {
17
+ throw new Error("DeepSeek API balance response availability was not a boolean.");
18
+ }
19
+ if (!Array.isArray(payload.balance_infos) || payload.balance_infos.length === 0) {
20
+ throw new Error("DeepSeek API balance response returned no balance information.");
21
+ }
22
+
23
+ const balances = new Map<DeepSeekCurrency, Record<string, unknown>>();
24
+ for (const raw of payload.balance_infos) {
25
+ const balance = asObject(raw);
26
+ if (!balance) throw new Error("DeepSeek API balance row was not an object.");
27
+ const currency = deepSeekCurrency(balance.currency);
28
+ if (!currency) throw new Error("DeepSeek API balance row returned an unsupported currency.");
29
+ if (balances.has(currency)) {
30
+ throw new Error(`DeepSeek API balance response repeated ${currency}.`);
31
+ }
32
+ for (const [, label, field] of BALANCE_FIELDS) {
33
+ if (!decimalAmount(balance[field])) {
34
+ throw new Error(`DeepSeek API balance ${label.toLowerCase()} was not a valid amount.`);
35
+ }
36
+ }
37
+ balances.set(currency, balance);
38
+ }
39
+
40
+ const metrics: UsageMetric[] = [
41
+ {
42
+ id: "api-availability",
43
+ label: "API calls",
44
+ value: payload.is_available ? "available" : "unavailable",
45
+ },
46
+ ];
47
+ for (const currency of CURRENCIES) {
48
+ const balance = balances.get(currency);
49
+ if (!balance) continue;
50
+ for (const [id, label, field] of BALANCE_FIELDS) {
51
+ metrics.push({
52
+ id: `${currency.toLowerCase()}-${id}`,
53
+ label,
54
+ value: balance[field] as string,
55
+ unit: "currency",
56
+ currency,
57
+ });
58
+ }
59
+ }
60
+
61
+ return {
62
+ providerId: "deepseek",
63
+ providerName: "DeepSeek",
64
+ capturedAt,
65
+ source: "deepseek-balance",
66
+ semantics: { kind: "api-key", label: "DeepSeek API balance" },
67
+ buckets: [],
68
+ metrics,
69
+ };
70
+ }
71
+
72
+ function asObject(value: unknown): Record<string, unknown> | undefined {
73
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
74
+ return value as Record<string, unknown>;
75
+ }
76
+
77
+ function deepSeekCurrency(value: unknown): DeepSeekCurrency | undefined {
78
+ return CURRENCIES.find((currency) => currency === value);
79
+ }
80
+
81
+ function decimalAmount(value: unknown): value is string {
82
+ return (
83
+ typeof value === "string" && value.length <= 64 && /^(?:0|[1-9]\d*)(?:\.\d+)?$/u.test(value)
84
+ );
85
+ }
@@ -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
+ }