@narumitw/pi-usage 0.52.3 → 0.54.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.52.3",
4
- "description": "Pi extension that shows current-account usage for Codex, GitHub Copilot, OpenRouter, and OpenCode Zen.",
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.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "private": false,
@@ -12,10 +12,16 @@
12
12
  "usage",
13
13
  "quota",
14
14
  "codex",
15
+ "kimi",
16
+ "kimi-coding",
15
17
  "copilot",
16
18
  "openrouter",
17
19
  "opencode",
18
- "zen"
20
+ "zen",
21
+ "zai",
22
+ "glm",
23
+ "xai",
24
+ "grok"
19
25
  ],
20
26
  "files": [
21
27
  "src",
@@ -40,12 +46,14 @@
40
46
  },
41
47
  "peerDependencies": {
42
48
  "@earendil-works/pi-ai": "*",
43
- "@earendil-works/pi-coding-agent": "*"
49
+ "@earendil-works/pi-coding-agent": "*",
50
+ "@earendil-works/pi-tui": "*"
44
51
  },
45
52
  "devDependencies": {
46
53
  "@biomejs/biome": "2.5.10",
47
54
  "@earendil-works/pi-ai": "0.84.3",
48
55
  "@earendil-works/pi-coding-agent": "0.84.3",
56
+ "@earendil-works/pi-tui": "0.84.3",
49
57
  "@types/node": "26.2.0",
50
58
  "esbuild": "0.28.2",
51
59
  "typescript": "7.0.2"
package/src/format.ts CHANGED
@@ -19,7 +19,11 @@ export function formatUsageReport(report: UsageReport, displayState: UsageDispla
19
19
  else if (report.providerId === "github-copilot") formatGitHubCopilotReport(lines, report);
20
20
  else if (report.providerId === "openrouter") formatOpenRouterReport(lines, report);
21
21
  else if (report.providerId === "opencode-go") formatOpenCodeZenReport(lines, report);
22
- else formatGenericReport(lines, report);
22
+ else if (report.providerId === "kimi-coding") formatKimiCodingReport(lines, report);
23
+ else if (report.providerId === "xai") formatXaiReport(lines, report);
24
+ else if (report.providerId === "zai" || report.providerId === "zai-coding-cn") {
25
+ formatZaiReport(lines, report);
26
+ } else formatGenericReport(lines, report);
23
27
 
24
28
  if (report.notes) {
25
29
  for (const note of report.notes) lines.push(note);
@@ -37,6 +41,7 @@ export function formatUsageStatusline(report: UsageReport, model?: UsageModel):
37
41
  if (typeof total?.value === "number") return `openrouter ${formatUsd(total.value)} used`;
38
42
  }
39
43
  if (report.providerId === "opencode-go") return formatOpenCodeZenStatusline(report);
44
+ if (report.providerId === "kimi-coding") return formatKimiCodingStatusline(report);
40
45
  return undefined;
41
46
  }
42
47
 
@@ -160,6 +165,117 @@ function formatOpenCodeZenStatusline(report: UsageReport): string | undefined {
160
165
  return parts.length > 1 ? parts.join(" ") : undefined;
161
166
  }
162
167
 
168
+ function formatKimiCodingReport(lines: string[], report: UsageReport): void {
169
+ for (const bucket of report.buckets) {
170
+ const reset = bucket.resetsAt ? ` (resets ${formatReset(bucket.resetsAt)})` : "";
171
+ if (bucket.used === undefined || bucket.limit === undefined) {
172
+ lines.push(`${`${bucket.label}:`.padEnd(VALUE_COLUMN)}unavailable${reset}`);
173
+ continue;
174
+ }
175
+ lines.push(
176
+ `${`${bucket.label}:`.padEnd(VALUE_COLUMN)}${bucket.used} of ${bucket.limit} used · ${percentRemaining(bucket)}% left${reset}`,
177
+ );
178
+ }
179
+ const balance = report.metrics.find((metric) => metric.id === "booster-balance");
180
+ const total = report.metrics.find((metric) => metric.id === "booster-total");
181
+ const monthlyUsed = report.metrics.find((metric) => metric.id === "booster-monthly-used");
182
+ const monthlyLimit = report.metrics.find((metric) => metric.id === "booster-monthly-limit");
183
+ if (!balance && !monthlyUsed && !monthlyLimit) return;
184
+ lines.push("", "Extra usage wallet:");
185
+ if (balance) {
186
+ const totalSuffix = total ? ` of ${formatCurrencyMetric(total)}` : "";
187
+ lines.push(`${"Balance:".padEnd(VALUE_COLUMN)}${formatCurrencyMetric(balance)}${totalSuffix}`);
188
+ }
189
+ if (monthlyUsed) {
190
+ lines.push(`${"Used this month:".padEnd(VALUE_COLUMN)}${formatCurrencyMetric(monthlyUsed)}`);
191
+ }
192
+ if (monthlyLimit) {
193
+ lines.push(`${"Monthly limit:".padEnd(VALUE_COLUMN)}${formatCurrencyMetric(monthlyLimit)}`);
194
+ }
195
+ }
196
+
197
+ function formatKimiCodingStatusline(report: UsageReport): string | undefined {
198
+ const fiveHour = report.buckets.find((bucket) => bucket.id === "five-hour");
199
+ const weekly = report.buckets.find((bucket) => bucket.id === "weekly");
200
+ const subWindow = fiveHour ?? report.buckets.find((bucket) => bucket.id !== "weekly");
201
+ const selected = [subWindow, weekly].filter(
202
+ (bucket, index, buckets): bucket is UsageBucket =>
203
+ bucket !== undefined && buckets.indexOf(bucket) === index,
204
+ );
205
+ const parts = ["kimi"];
206
+ for (const bucket of selected) {
207
+ if (!bucket.limit || bucket.remaining === undefined) continue;
208
+ const fallback = bucket.id === "weekly" ? "weekly" : "5h";
209
+ parts.push(
210
+ `${percentRemaining(bucket)}% ${formatWindowLabel(bucket.windowMinutes, fallback, true)}`,
211
+ );
212
+ }
213
+ return parts.length > 1 ? parts.join(" ") : undefined;
214
+ }
215
+
216
+ function formatCurrencyMetric(metric: UsageReport["metrics"][number]): string {
217
+ if (typeof metric.value !== "number") return String(metric.value);
218
+ if (!metric.currency) return "unavailable";
219
+ if (metric.currency === "USD") return `$${metric.value.toFixed(2)}`;
220
+ if (metric.currency === "CNY") return `¥${metric.value.toFixed(2)}`;
221
+ return `${metric.value.toFixed(2)} ${metric.currency}`;
222
+ }
223
+
224
+ function formatXaiReport(lines: string[], report: UsageReport): void {
225
+ const included = report.buckets.find((bucket) => bucket.id === "included-allowance");
226
+ if (included) {
227
+ let value = "unavailable";
228
+ if (included.unit === "percent" && included.used !== undefined) {
229
+ value = `${included.used}% used`;
230
+ if (included.remaining !== undefined) value += ` · ${included.remaining}% left`;
231
+ } else if (included.used !== undefined) {
232
+ value = `${formatUsd(included.used)} used`;
233
+ if (included.limit !== undefined) value += ` of ${formatUsd(included.limit)}`;
234
+ } else if (included.limit !== undefined) {
235
+ value = `usage unavailable · ${formatUsd(included.limit)} limit`;
236
+ }
237
+ const period = included.period ? ` · ${included.period}` : "";
238
+ const reset = included.resetsAt ? ` (resets ${formatReset(included.resetsAt)})` : "";
239
+ lines.push(`${"Included allowance:".padEnd(VALUE_COLUMN)}${value}${period}${reset}`);
240
+ }
241
+ const onDemand = report.buckets.find((bucket) => bucket.id === "on-demand");
242
+ if (onDemand) {
243
+ let value =
244
+ onDemand.used === undefined ? "usage unavailable" : `${formatUsd(onDemand.used)} used`;
245
+ if (onDemand.limit !== undefined) value += ` of ${formatUsd(onDemand.limit)} cap`;
246
+ lines.push(`${"On-demand usage:".padEnd(VALUE_COLUMN)}${value}`);
247
+ }
248
+ for (const metric of report.metrics) {
249
+ lines.push(
250
+ `${`${metric.label}:`.padEnd(VALUE_COLUMN)}${formatMetricValue(metric.value, metric.unit)}`,
251
+ );
252
+ }
253
+ }
254
+
255
+ function formatZaiReport(lines: string[], report: UsageReport): void {
256
+ for (const bucket of report.buckets) {
257
+ const reset = bucket.resetsAt ? ` (resets ${formatReset(bucket.resetsAt)})` : "";
258
+ let value = "unavailable";
259
+ if (bucket.unit === "percent" && bucket.used !== undefined) {
260
+ value = `${bucket.used}% used`;
261
+ if (bucket.remaining !== undefined) value += ` · ${bucket.remaining}% left`;
262
+ } else if (bucket.used !== undefined && bucket.limit !== undefined) {
263
+ value = `${bucket.used} of ${bucket.limit} used`;
264
+ if (bucket.remaining !== undefined) value += ` · ${bucket.remaining} left`;
265
+ } else if (bucket.used !== undefined) {
266
+ value = `${bucket.used} used`;
267
+ } else if (bucket.remaining !== undefined) {
268
+ value = `${bucket.remaining} left`;
269
+ }
270
+ lines.push(`${`${bucket.label}:`.padEnd(VALUE_COLUMN)}${value}${reset}`);
271
+ }
272
+ for (const metric of report.metrics) {
273
+ lines.push(
274
+ `${`${metric.label}:`.padEnd(VALUE_COLUMN)}${formatMetricValue(metric.value, metric.unit)}`,
275
+ );
276
+ }
277
+ }
278
+
163
279
  function formatGenericReport(lines: string[], report: UsageReport): void {
164
280
  for (const bucket of report.buckets) {
165
281
  lines.push(
package/src/index.ts CHANGED
@@ -34,8 +34,11 @@ export {
34
34
  export { formatProviderStates, formatUsageReport, formatUsageStatusline } from "./format.js";
35
35
  export { normalizeCodexBackendPayload } from "./providers/codex.js";
36
36
  export { normalizeGitHubCopilotUsagePayload } from "./providers/github-copilot.js";
37
+ export { normalizeKimiCodingUsagePayload } from "./providers/kimi-coding.js";
37
38
  export { normalizeOpenCodeZenPayload } from "./providers/opencode-zen.js";
38
39
  export { normalizeOpenRouterKeyPayload } from "./providers/openrouter.js";
40
+ export { normalizeXaiBillingPayload } from "./providers/xai.js";
41
+ export { normalizeZaiQuotaPayload } from "./providers/zai.js";
39
42
  export {
40
43
  adapterForProvider,
41
44
  isStaleExtensionContextError,
@@ -43,6 +46,8 @@ export {
43
46
  queryProviderUsage,
44
47
  resolveUsageAuth,
45
48
  SUPPORTED_ADAPTERS,
49
+ usageAdapters,
50
+ XAI_ADAPTER,
46
51
  } from "./query.js";
47
52
  export type {
48
53
  UsageSettings,
@@ -57,6 +62,7 @@ export {
57
62
  usageSettingsPath,
58
63
  } from "./settings.js";
59
64
  export type {
65
+ KimiCodingUsagePayload,
60
66
  ProviderUsageState,
61
67
  ResolvedUsageAuth,
62
68
  UsageBucket,
@@ -68,5 +74,7 @@ export type {
68
74
  UsageSemantics,
69
75
  UsageSemanticsKind,
70
76
  UsageUnit,
77
+ XaiBillingPayload,
78
+ XaiUserPayload,
71
79
  } from "./types.js";
72
80
  export { default } from "./usage.js";
@@ -0,0 +1,276 @@
1
+ import { sanitizeDisplayText } from "../core.js";
2
+ import type { KimiCodingUsagePayload, UsageBucket, UsageMetric, UsageReport } from "../types.js";
3
+
4
+ const FIVE_HOUR_WINDOW_MINUTES = 300;
5
+ const DAILY_WINDOW_MINUTES = 1_440;
6
+ const WEEKLY_WINDOW_MINUTES = 10_080;
7
+ const FIXED_POINT_UNITS_PER_CENT = 1_000_000;
8
+
9
+ /**
10
+ * Source contract revalidated on 2026-08-27.
11
+ * Pi c49906ec77788625aacbdc53ebca6fbe65bd20f5 defines provider `kimi-coding`,
12
+ * `https://api.kimi.com/coding`, API-key auth, and OAuth Bearer auth:
13
+ * https://github.com/earendil-works/pi/blob/c49906ec77788625aacbdc53ebca6fbe65bd20f5/packages/ai/src/providers/kimi-coding.ts
14
+ * https://github.com/earendil-works/pi/blob/c49906ec77788625aacbdc53ebca6fbe65bd20f5/packages/ai/src/auth/oauth/kimi-coding.ts
15
+ * Kimi Code 676e4d82240855044fe809fea89ce1dbe8e512cf defines `GET /coding/v1/usages`,
16
+ * numeric-string plan rows, proto-style windows, and 1,000,000 fixed-point units per cent:
17
+ * https://github.com/MoonshotAI/kimi-code/blob/676e4d82240855044fe809fea89ce1dbe8e512cf/packages/oauth/src/managed-usage.ts
18
+ */
19
+ export function normalizeKimiCodingUsagePayload(
20
+ payload: KimiCodingUsagePayload,
21
+ capturedAt: number,
22
+ ): UsageReport {
23
+ const root = asObject(payload);
24
+ if (!root) throw new Error("Kimi Coding usage response was not an object.");
25
+
26
+ const candidates: UsageBucket[] = [];
27
+ let omittedWindow = false;
28
+ const summary = parseUsageRow(root.usage, WEEKLY_WINDOW_MINUTES, "Weekly window");
29
+ if (summary) candidates.push(summary);
30
+ else if (root.usage !== undefined) omittedWindow = true;
31
+
32
+ if (Array.isArray(root.limits)) {
33
+ for (const raw of root.limits) {
34
+ const item = asObject(raw);
35
+ const windowMinutes = parseWindowMinutes(item?.window);
36
+ const label = sanitizedLabel(item?.name);
37
+ const bucket =
38
+ windowMinutes === undefined
39
+ ? undefined
40
+ : parseUsageRow(item?.detail, windowMinutes, label ?? defaultWindowLabel(windowMinutes));
41
+ if (bucket) candidates.push(bucket);
42
+ else omittedWindow = true;
43
+ }
44
+ } else if (root.limits !== undefined) {
45
+ omittedWindow = true;
46
+ }
47
+
48
+ const buckets: UsageBucket[] = [];
49
+ const byWindow = new Map<number, UsageBucket[]>();
50
+ for (const bucket of candidates) {
51
+ const windowMinutes = bucket.windowMinutes as number;
52
+ byWindow.set(windowMinutes, [...(byWindow.get(windowMinutes) ?? []), bucket]);
53
+ }
54
+ for (const rows of byWindow.values()) {
55
+ if (rows.length === 1) buckets.push(rows[0] as UsageBucket);
56
+ else omittedWindow = true;
57
+ }
58
+ buckets.sort((left, right) => (left.windowMinutes ?? 0) - (right.windowMinutes ?? 0));
59
+
60
+ const metrics = parseBoosterWallet(root.boosterWallet);
61
+ if (buckets.length === 0 && metrics.length === 0) {
62
+ throw new Error("Kimi Coding usage endpoint returned no displayable usage data.");
63
+ }
64
+
65
+ return {
66
+ providerId: "kimi-coding",
67
+ providerName: "Kimi For Coding",
68
+ capturedAt,
69
+ source: "kimi-managed-usage",
70
+ semantics: { kind: "consumer-subscription", label: "Kimi Coding Plan usage" },
71
+ buckets,
72
+ metrics,
73
+ ...(omittedWindow
74
+ ? { notes: ["Unsupported, malformed, or duplicate plan windows were unavailable."] }
75
+ : {}),
76
+ };
77
+ }
78
+
79
+ function parseUsageRow(
80
+ value: unknown,
81
+ windowMinutes: number,
82
+ label: string,
83
+ ): UsageBucket | undefined {
84
+ const row = asObject(value);
85
+ if (!row) return undefined;
86
+ const used = asNonnegativeInteger(row.used);
87
+ const limit = asNonnegativeInteger(row.limit);
88
+ if (used === undefined || limit === undefined || limit === 0) return undefined;
89
+ const resetsAt = asIsoEpochSeconds(row.resetTime);
90
+ return {
91
+ id: windowId(windowMinutes),
92
+ label,
93
+ used,
94
+ remaining: Math.max(0, limit - used),
95
+ limit,
96
+ unit: "count",
97
+ windowMinutes,
98
+ ...(resetsAt !== undefined ? { resetsAt } : {}),
99
+ };
100
+ }
101
+
102
+ function parseWindowMinutes(value: unknown): number | undefined {
103
+ const window = asObject(value);
104
+ if (!window) return undefined;
105
+ const duration = asPositiveInteger(window.duration);
106
+ if (duration === undefined) return undefined;
107
+ const multiplier =
108
+ window.timeUnit === "TIME_UNIT_MINUTE"
109
+ ? 1
110
+ : window.timeUnit === "TIME_UNIT_HOUR"
111
+ ? 60
112
+ : window.timeUnit === "TIME_UNIT_DAY"
113
+ ? 1_440
114
+ : window.timeUnit === "TIME_UNIT_WEEK"
115
+ ? 10_080
116
+ : undefined;
117
+ if (multiplier === undefined) return undefined;
118
+ const minutes = duration * multiplier;
119
+ return Number.isSafeInteger(minutes) ? minutes : undefined;
120
+ }
121
+
122
+ function parseBoosterWallet(value: unknown): UsageMetric[] {
123
+ const wallet = asObject(value);
124
+ const balance = asObject(wallet?.balance);
125
+ if (!wallet || !balance || balance.type !== "BOOSTER") return [];
126
+ const totalRaw = asPositiveInteger(balance.amount);
127
+ if (totalRaw === undefined) return [];
128
+ const leftRaw = asNonnegativeInteger(balance.amountLeft) ?? 0;
129
+ const monthlyLimit = parseMoney(wallet.monthlyChargeLimit);
130
+ const monthlyUsed = parseMoney(wallet.monthlyUsed);
131
+ const currencies = new Set(
132
+ [monthlyLimit?.currency, monthlyUsed?.currency].filter(
133
+ (currency): currency is string => currency !== undefined,
134
+ ),
135
+ );
136
+ if (currencies.size !== 1) return [];
137
+ const currency = currencies.values().next().value;
138
+ if (!currency) return [];
139
+ const total = fixedPointToMajor(totalRaw);
140
+ const left = fixedPointToMajor(leftRaw);
141
+ if (total === undefined || left === undefined) return [];
142
+
143
+ const metrics: UsageMetric[] = [
144
+ { id: "booster-balance", label: "Balance", value: left, unit: "currency", currency },
145
+ { id: "booster-total", label: "Total balance", value: total, unit: "currency", currency },
146
+ ];
147
+ if (monthlyUsed) {
148
+ metrics.push({
149
+ id: "booster-monthly-used",
150
+ label: "Used this month",
151
+ value: monthlyUsed.cents / 100,
152
+ unit: "currency",
153
+ currency,
154
+ });
155
+ }
156
+ if (wallet.monthlyChargeLimitEnabled === false) {
157
+ metrics.push({
158
+ id: "booster-monthly-limit",
159
+ label: "Monthly limit",
160
+ value: "unlimited",
161
+ unit: "currency",
162
+ currency,
163
+ });
164
+ } else if (wallet.monthlyChargeLimitEnabled === true && monthlyLimit) {
165
+ metrics.push({
166
+ id: "booster-monthly-limit",
167
+ label: "Monthly limit",
168
+ value: monthlyLimit.cents / 100,
169
+ unit: "currency",
170
+ currency,
171
+ });
172
+ }
173
+ return metrics;
174
+ }
175
+
176
+ function parseMoney(value: unknown): { cents: number; currency: string } | undefined {
177
+ const money = asObject(value);
178
+ if (!money) return undefined;
179
+ const cents = asNonnegativeInteger(money.priceInCents);
180
+ if (cents === undefined) return undefined;
181
+ const currency = asCurrency(money.currency);
182
+ if (!currency) return undefined;
183
+ return { cents, currency };
184
+ }
185
+
186
+ function fixedPointToMajor(value: number): number | undefined {
187
+ const cents = value / FIXED_POINT_UNITS_PER_CENT;
188
+ const roundedCents = cents > 0 && cents < 1 ? 1 : Math.round(cents);
189
+ const major = roundedCents / 100;
190
+ return Number.isSafeInteger(roundedCents) && Number.isFinite(major) ? major : undefined;
191
+ }
192
+
193
+ function asObject(value: unknown): Record<string, unknown> | undefined {
194
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
195
+ return value as Record<string, unknown>;
196
+ }
197
+
198
+ function asNonnegativeInteger(value: unknown): number | undefined {
199
+ if (typeof value === "string" && !/^\d+$/u.test(value)) return undefined;
200
+ const number = typeof value === "string" ? Number(value) : value;
201
+ if (typeof number !== "number" || !Number.isSafeInteger(number) || number < 0) return undefined;
202
+ return number;
203
+ }
204
+
205
+ function asPositiveInteger(value: unknown): number | undefined {
206
+ const number = asNonnegativeInteger(value);
207
+ return number !== undefined && number > 0 ? number : undefined;
208
+ }
209
+
210
+ function asIsoEpochSeconds(value: unknown): number | undefined {
211
+ if (typeof value !== "string") return undefined;
212
+ const match =
213
+ /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|([+-])(\d{2}):(\d{2}))$/u.exec(
214
+ value,
215
+ );
216
+ if (!match) return undefined;
217
+ const [
218
+ ,
219
+ yearText,
220
+ monthText,
221
+ dayText,
222
+ hourText,
223
+ minuteText,
224
+ secondText,
225
+ ,
226
+ offsetHour,
227
+ offsetMinute,
228
+ ] = match;
229
+ const year = Number(yearText);
230
+ const month = Number(monthText);
231
+ const day = Number(dayText);
232
+ const hour = Number(hourText);
233
+ const minute = Number(minuteText);
234
+ const second = Number(secondText);
235
+ if (
236
+ month < 1 ||
237
+ month > 12 ||
238
+ day < 1 ||
239
+ day > new Date(Date.UTC(year, month, 0)).getUTCDate() ||
240
+ hour > 23 ||
241
+ minute > 59 ||
242
+ second > 59 ||
243
+ (offsetHour !== undefined && Number(offsetHour) > 23) ||
244
+ (offsetMinute !== undefined && Number(offsetMinute) > 59)
245
+ ) {
246
+ return undefined;
247
+ }
248
+ const millis = Date.parse(value);
249
+ return Number.isFinite(millis) && millis >= 0 ? Math.floor(millis / 1000) : undefined;
250
+ }
251
+
252
+ function sanitizedLabel(value: unknown): string | undefined {
253
+ if (typeof value !== "string") return undefined;
254
+ return sanitizeDisplayText(value, 80) || undefined;
255
+ }
256
+
257
+ function asCurrency(value: unknown): string | undefined {
258
+ if (typeof value !== "string") return undefined;
259
+ const currency = sanitizeDisplayText(value, 3).toUpperCase();
260
+ return /^[A-Z]{3}$/u.test(currency) ? currency : undefined;
261
+ }
262
+
263
+ function windowId(minutes: number): string {
264
+ if (minutes === FIVE_HOUR_WINDOW_MINUTES) return "five-hour";
265
+ if (minutes === DAILY_WINDOW_MINUTES) return "daily";
266
+ if (minutes === WEEKLY_WINDOW_MINUTES) return "weekly";
267
+ return `window-${minutes}-minutes`;
268
+ }
269
+
270
+ function defaultWindowLabel(minutes: number): string {
271
+ if (minutes === WEEKLY_WINDOW_MINUTES) return "Weekly window";
272
+ if (minutes % 10_080 === 0) return `${minutes / 10_080}w window`;
273
+ if (minutes % 1_440 === 0) return `${minutes / 1_440}d window`;
274
+ if (minutes % 60 === 0) return `${minutes / 60}h window`;
275
+ return `${minutes}m window`;
276
+ }
@@ -0,0 +1,186 @@
1
+ import { sanitizeDisplayText } from "../core.js";
2
+ import type { UsageBucket, UsageMetric, UsageReport, XaiBillingPayload } from "../types.js";
3
+
4
+ const MAX_SAFE_CENTS = Number.MAX_SAFE_INTEGER;
5
+
6
+ export function normalizeXaiBillingPayload(
7
+ payload: XaiBillingPayload,
8
+ subscriptionTier: unknown,
9
+ capturedAt: number,
10
+ ): UsageReport {
11
+ const configValue = payload.config;
12
+ if (configValue !== null && configValue !== undefined && !isRecord(configValue)) {
13
+ throw new Error("xAI billing response config was not an object or null.");
14
+ }
15
+ const config = isRecord(configValue) ? configValue : undefined;
16
+ const buckets: UsageBucket[] = [];
17
+ const metrics: UsageMetric[] = [];
18
+ const notes: string[] = [];
19
+
20
+ if (config) {
21
+ const period = normalizePeriod(
22
+ config.currentPeriod,
23
+ config.billingPeriodStart,
24
+ config.billingPeriodEnd,
25
+ );
26
+ const preferredPercent = optionalPercent(config.creditUsagePercent, "creditUsagePercent");
27
+ if (preferredPercent !== undefined) {
28
+ buckets.push({
29
+ id: "included-allowance",
30
+ label: "Included allowance",
31
+ used: preferredPercent,
32
+ remaining: 100 - preferredPercent,
33
+ unit: "percent",
34
+ ...period,
35
+ });
36
+ } else {
37
+ const limit = optionalUsd(config.monthlyLimit, "monthlyLimit");
38
+ const used = optionalUsd(config.used, "used");
39
+ if (limit !== undefined || used !== undefined) {
40
+ buckets.push({
41
+ id: "included-allowance",
42
+ label: "Included allowance",
43
+ ...(limit !== undefined ? { limit } : {}),
44
+ ...(used !== undefined ? { used } : {}),
45
+ ...(limit !== undefined && used !== undefined ? { remaining: limit - used } : {}),
46
+ unit: "usd",
47
+ ...period,
48
+ });
49
+ } else if (period.period || period.resetsAt !== undefined) {
50
+ buckets.push({
51
+ id: "included-allowance",
52
+ label: "Included allowance",
53
+ unit: "percent",
54
+ ...period,
55
+ });
56
+ }
57
+ }
58
+
59
+ const onDemandCap = optionalUsd(config.onDemandCap, "onDemandCap");
60
+ const onDemandUsed = optionalUsd(config.onDemandUsed, "onDemandUsed");
61
+ if (onDemandCap !== undefined || onDemandUsed !== undefined) {
62
+ buckets.push({
63
+ id: "on-demand",
64
+ label: "On-demand usage",
65
+ ...(onDemandCap !== undefined ? { limit: onDemandCap } : {}),
66
+ ...(onDemandUsed !== undefined ? { used: onDemandUsed } : {}),
67
+ ...(onDemandCap !== undefined && onDemandUsed !== undefined
68
+ ? { remaining: onDemandCap - onDemandUsed }
69
+ : {}),
70
+ unit: "usd",
71
+ });
72
+ }
73
+
74
+ const prepaidBalance = optionalUsd(config.prepaidBalance, "prepaidBalance");
75
+ if (prepaidBalance !== undefined) {
76
+ metrics.push({
77
+ id: "prepaid-balance",
78
+ label: "Prepaid balance",
79
+ value: prepaidBalance,
80
+ unit: "usd",
81
+ });
82
+ }
83
+ }
84
+
85
+ const tier = optionalTier(subscriptionTier);
86
+ if (tier) metrics.push({ id: "subscription-tier", label: "Plan tier", value: tier });
87
+ if (!config) notes.push("No xAI consumer billing configuration is available for this account.");
88
+ else if (buckets.length === 0 && metrics.length === 0) {
89
+ notes.push("The xAI consumer billing response contained no displayable usage fields.");
90
+ }
91
+
92
+ return {
93
+ providerId: "xai",
94
+ providerName: "xAI",
95
+ capturedAt,
96
+ source: "cli-chat-proxy.grok.com consumer billing",
97
+ semantics: {
98
+ kind: "consumer-subscription",
99
+ label: "xAI consumer subscription usage",
100
+ },
101
+ buckets,
102
+ metrics,
103
+ ...(notes.length > 0 ? { notes } : {}),
104
+ };
105
+ }
106
+
107
+ function normalizePeriod(
108
+ currentPeriod: unknown,
109
+ legacyStart: unknown,
110
+ legacyEnd: unknown,
111
+ ): Pick<UsageBucket, "period" | "resetsAt"> {
112
+ if (currentPeriod !== undefined && currentPeriod !== null && !isRecord(currentPeriod)) {
113
+ throw new Error("xAI billing currentPeriod was not an object or null.");
114
+ }
115
+ if (isRecord(currentPeriod)) {
116
+ const type = optionalString(currentPeriod.type, "currentPeriod.type");
117
+ const start = optionalTimestamp(currentPeriod.start, "currentPeriod.start");
118
+ const end = optionalTimestamp(currentPeriod.end, "currentPeriod.end");
119
+ return {
120
+ ...(periodLabel(type, start) ? { period: periodLabel(type, start) } : {}),
121
+ ...(end !== undefined ? { resetsAt: end } : {}),
122
+ };
123
+ }
124
+ const start = optionalTimestamp(legacyStart, "billingPeriodStart");
125
+ const end = optionalTimestamp(legacyEnd, "billingPeriodEnd");
126
+ return {
127
+ ...(start !== undefined ? { period: "Monthly" } : {}),
128
+ ...(end !== undefined ? { resetsAt: end } : {}),
129
+ };
130
+ }
131
+
132
+ function periodLabel(type: string | undefined, start: number | undefined): string | undefined {
133
+ if (type === "USAGE_PERIOD_TYPE_WEEKLY") return "Weekly";
134
+ if (type === "USAGE_PERIOD_TYPE_MONTHLY") return "Monthly";
135
+ if (type)
136
+ return sanitizeDisplayText(type.replace(/^USAGE_PERIOD_TYPE_/u, "").replaceAll("_", " "), 40);
137
+ return start === undefined ? undefined : "Current period";
138
+ }
139
+
140
+ function optionalUsd(value: unknown, field: string): number | undefined {
141
+ if (value === undefined || value === null) return undefined;
142
+ if (!isRecord(value)) throw new Error(`xAI billing ${field} was not a cent wrapper.`);
143
+ const cents = value.val === undefined ? 0 : value.val;
144
+ if (!Number.isSafeInteger(cents) || Math.abs(cents as number) > MAX_SAFE_CENTS) {
145
+ throw new Error(`xAI billing ${field}.val was not a safe signed integer.`);
146
+ }
147
+ return (cents as number) / 100;
148
+ }
149
+
150
+ function optionalPercent(value: unknown, field: string): number | undefined {
151
+ if (value === undefined || value === null) return undefined;
152
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0 || value > 100) {
153
+ throw new Error(`xAI billing ${field} was outside 0–100.`);
154
+ }
155
+ return value;
156
+ }
157
+
158
+ function optionalTimestamp(value: unknown, field: string): number | undefined {
159
+ if (value === undefined || value === null) return undefined;
160
+ if (typeof value !== "string" || value.length > 80) {
161
+ throw new Error(`xAI billing ${field} was not a bounded timestamp.`);
162
+ }
163
+ const milliseconds = Date.parse(value);
164
+ if (!Number.isFinite(milliseconds)) throw new Error(`xAI billing ${field} was invalid.`);
165
+ return Math.floor(milliseconds / 1000);
166
+ }
167
+
168
+ function optionalString(value: unknown, field: string): string | undefined {
169
+ if (value === undefined || value === null) return undefined;
170
+ if (typeof value !== "string" || value.length > 80) {
171
+ throw new Error(`xAI billing ${field} was not a bounded string.`);
172
+ }
173
+ return value;
174
+ }
175
+
176
+ function optionalTier(value: unknown): string | undefined {
177
+ if (value === undefined || value === null) return undefined;
178
+ if (typeof value !== "string" || value.length > 160) {
179
+ throw new Error("xAI subscription tier was not a bounded string or null.");
180
+ }
181
+ return sanitizeDisplayText(value, 80) || undefined;
182
+ }
183
+
184
+ function isRecord(value: unknown): value is Record<string, unknown> {
185
+ return typeof value === "object" && value !== null && !Array.isArray(value);
186
+ }