@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/README.md +137 -64
- package/dist/index.ts +783 -173
- package/dist/index.ts.map +4 -4
- package/package.json +10 -10
- package/src/codex-fast-runtime.ts +34 -26
- package/src/format.ts +126 -7
- package/src/index.ts +9 -0
- package/src/providers/deepseek.ts +85 -0
- package/src/providers/fireworks.ts +198 -0
- package/src/query.ts +199 -13
- package/src/settings.ts +26 -6
- package/src/types.ts +19 -0
- package/src/usage-helpers.ts +2 -2
- package/src/usage-settings-ui.ts +130 -39
- package/src/usage.ts +156 -94
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@narumitw/pi-usage",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Pi extension that shows current-account usage
|
|
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.
|
|
54
|
-
"@earendil-works/pi-ai": "0.84.
|
|
55
|
-
"@earendil-works/pi-coding-agent": "0.84.
|
|
56
|
-
"@earendil-works/pi-tui": "0.84.
|
|
57
|
-
"@types/node": "26.
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
`
|
|
129
|
+
`Invalid pi-usage.json; using defaults without overwriting it. ${state.issue}`,
|
|
113
130
|
"warning",
|
|
114
131
|
);
|
|
115
132
|
}
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
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(
|
|
35
|
-
|
|
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(
|
|
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
|
-
|
|
304
|
-
|
|
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
|
+
}
|