@narumitw/pi-usage 0.58.0 → 0.60.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.
@@ -0,0 +1,64 @@
1
+ import type { MoonshotBalancePayload, UsageMetric, UsageReport } from "../types.js";
2
+
3
+ export type MoonshotProviderId = "moonshotai" | "moonshotai-cn";
4
+
5
+ const PROVIDERS = {
6
+ moonshotai: { name: "Moonshot AI", currency: "USD" },
7
+ "moonshotai-cn": { name: "Moonshot AI CN", currency: "CNY" },
8
+ } as const;
9
+
10
+ export function normalizeMoonshotBalancePayload(
11
+ providerId: MoonshotProviderId,
12
+ payload: MoonshotBalancePayload,
13
+ capturedAt: number,
14
+ ): UsageReport {
15
+ if (payload.code !== 0 || payload.status !== true) {
16
+ throw new Error("Moonshot AI balance response did not report success.");
17
+ }
18
+ const data = asObject(payload.data);
19
+ if (!data) throw new Error("Moonshot AI balance response data was not an object.");
20
+ const provider = PROVIDERS[providerId];
21
+ const available = amount(data.available_balance, "available balance", false);
22
+ const voucher = amount(data.voucher_balance, "voucher balance", false);
23
+ const cash = amount(data.cash_balance, "cash balance", true);
24
+ const metrics: UsageMetric[] = [
25
+ currencyMetric("available-balance", "Available balance", available, provider.currency),
26
+ currencyMetric("voucher-balance", "Voucher balance", voucher, provider.currency),
27
+ currencyMetric("cash-balance", "Cash balance", cash, provider.currency),
28
+ ];
29
+ return {
30
+ providerId,
31
+ providerName: provider.name,
32
+ capturedAt,
33
+ source: "moonshot-balance",
34
+ semantics: { kind: "api-key", label: "Moonshot API account balance" },
35
+ buckets: [],
36
+ metrics,
37
+ };
38
+ }
39
+
40
+ function currencyMetric(
41
+ id: string,
42
+ label: string,
43
+ value: string,
44
+ currency: "CNY" | "USD",
45
+ ): UsageMetric {
46
+ return { id, label, value, unit: "currency", currency };
47
+ }
48
+
49
+ function asObject(value: unknown): Record<string, unknown> | undefined {
50
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
51
+ return value as Record<string, unknown>;
52
+ }
53
+
54
+ function amount(value: unknown, label: string, allowNegative: boolean): string {
55
+ if (
56
+ typeof value !== "number" ||
57
+ !Number.isFinite(value) ||
58
+ (!allowNegative && value < 0) ||
59
+ Math.abs(value) > Number.MAX_SAFE_INTEGER
60
+ ) {
61
+ throw new Error(`Moonshot AI ${label} was not a valid amount.`);
62
+ }
63
+ return String(value);
64
+ }
@@ -0,0 +1,43 @@
1
+ import type { UsageMetric, UsageReport, VercelAIGatewayCreditsPayload } from "../types.js";
2
+
3
+ const DECIMAL_AMOUNT = /^(?:0|[1-9]\d*)(?:\.\d+)?$/u;
4
+
5
+ export function normalizeVercelAIGatewayCreditsPayload(
6
+ payload: VercelAIGatewayCreditsPayload,
7
+ capturedAt: number,
8
+ ): UsageReport {
9
+ const balance = decimalAmount(payload.balance, "balance");
10
+ const totalUsed = decimalAmount(payload.total_used, "total used");
11
+ const metrics: UsageMetric[] = [
12
+ {
13
+ id: "credit-balance",
14
+ label: "Credit balance",
15
+ value: balance,
16
+ unit: "currency",
17
+ currency: "USD",
18
+ },
19
+ {
20
+ id: "lifetime-spend",
21
+ label: "Lifetime spend",
22
+ value: totalUsed,
23
+ unit: "currency",
24
+ currency: "USD",
25
+ },
26
+ ];
27
+ return {
28
+ providerId: "vercel-ai-gateway",
29
+ providerName: "Vercel AI Gateway",
30
+ capturedAt,
31
+ source: "vercel-ai-gateway-credits",
32
+ semantics: { kind: "api-key", label: "AI Gateway credits and lifetime spend" },
33
+ buckets: [],
34
+ metrics,
35
+ };
36
+ }
37
+
38
+ function decimalAmount(value: unknown, label: string): string {
39
+ if (typeof value !== "string" || value.length > 64 || !DECIMAL_AMOUNT.test(value)) {
40
+ throw new Error(`Vercel AI Gateway ${label} was not a valid nonnegative amount.`);
41
+ }
42
+ return value;
43
+ }
@@ -1,5 +1,12 @@
1
1
  import { sanitizeDisplayText } from "../core.js";
2
- import type { UsageBucket, UsageMetric, UsageReport, ZaiQuotaPayload } from "../types.js";
2
+ import type {
3
+ UsageBucket,
4
+ UsageMetric,
5
+ UsageReport,
6
+ ZaiPlanInfo,
7
+ ZaiQuotaPayload,
8
+ ZaiSubscriptionPayload,
9
+ } from "../types.js";
3
10
 
4
11
  const FIVE_HOUR_WINDOW_MINUTES = 300;
5
12
  const WEEKLY_WINDOW_MINUTES = 10_080;
@@ -9,6 +16,7 @@ export function normalizeZaiQuotaPayload(
9
16
  providerName: string,
10
17
  payload: ZaiQuotaPayload,
11
18
  capturedAt: number,
19
+ plan?: ZaiPlanInfo,
12
20
  ): UsageReport {
13
21
  const data = asObject(payload.data);
14
22
  if (!data) throw new Error("Z.AI quota response data was not an object.");
@@ -26,14 +34,20 @@ export function normalizeZaiQuotaPayload(
26
34
  addCountBucket(buckets, limit, "mcp-monthly", "MCP monthly allowance");
27
35
  addUsageDetailMetrics(metrics, limit.usageDetails);
28
36
  } else if (isPlanUsage && unit === 3) {
29
- addPercentBucket(buckets, limit, "five-hour", "5h window", FIVE_HOUR_WINDOW_MINUTES);
37
+ addPercentBucket(
38
+ buckets,
39
+ limit,
40
+ "five-hour",
41
+ sessionWindowLabel(limit),
42
+ sessionWindowMinutes(limit),
43
+ );
30
44
  } else if (isPlanUsage && unit === 6) {
31
45
  const used = asNonnegativeNumber(limit.currentValue);
32
46
  const quota = asNonnegativeNumber(limit.usage);
33
47
  if (used !== undefined && quota !== undefined) {
34
- addCountBucket(buckets, limit, "weekly", "Weekly window", WEEKLY_WINDOW_MINUTES);
48
+ addCountBucket(buckets, limit, "weekly", "Weekly window", weeklyWindowMinutes(limit));
35
49
  } else {
36
- addPercentBucket(buckets, limit, "weekly", "Weekly window", WEEKLY_WINDOW_MINUTES);
50
+ addPercentBucket(buckets, limit, "weekly", "Weekly window", weeklyWindowMinutes(limit));
37
51
  }
38
52
  }
39
53
  }
@@ -43,7 +57,12 @@ export function normalizeZaiQuotaPayload(
43
57
 
44
58
  const notes: string[] = [];
45
59
  const level = asString(data.level);
46
- if (level) notes.push(`Plan: ${level}`);
60
+ const planLabel = plan?.name ?? level;
61
+ if (planLabel) {
62
+ notes.push(
63
+ plan?.renewsAt ? `Plan: ${planLabel} · renews ${plan.renewsAt}` : `Plan: ${planLabel}`,
64
+ );
65
+ }
47
66
 
48
67
  return {
49
68
  providerId,
@@ -57,6 +76,79 @@ export function normalizeZaiQuotaPayload(
57
76
  };
58
77
  }
59
78
 
79
+ // The undocumented subscription endpoint can include historical products. Prefer the current,
80
+ // valid product and fail closed to the quota plan level when only explicitly inactive products exist.
81
+ export function normalizeZaiSubscriptionPayload(
82
+ payload: ZaiSubscriptionPayload,
83
+ ): ZaiPlanInfo | undefined {
84
+ if (payload.success === false) return undefined;
85
+ if (typeof payload.code === "number" && payload.code !== 0 && payload.code !== 200) {
86
+ return undefined;
87
+ }
88
+ if (!Array.isArray(payload.data)) return undefined;
89
+
90
+ const candidates: Array<{
91
+ plan: ZaiPlanInfo;
92
+ status?: string;
93
+ inCurrentPeriod?: boolean;
94
+ }> = [];
95
+ for (const raw of payload.data) {
96
+ const entry = asObject(raw);
97
+ if (!entry) continue;
98
+ const name = asString(entry.productName);
99
+ if (!name) continue;
100
+ const renewsAt = planRenewalDate(entry.nextRenewTime);
101
+ const status = asString(entry.status)?.toUpperCase();
102
+ const inCurrentPeriod = asBoolean(entry.inCurrentPeriod);
103
+ candidates.push({
104
+ plan: { name, ...(renewsAt !== undefined ? { renewsAt } : {}) },
105
+ ...(status !== undefined ? { status } : {}),
106
+ ...(inCurrentPeriod !== undefined ? { inCurrentPeriod } : {}),
107
+ });
108
+ }
109
+
110
+ const hasStateMetadata = candidates.some(
111
+ (candidate) => candidate.status !== undefined || candidate.inCurrentPeriod !== undefined,
112
+ );
113
+ if (!hasStateMetadata) return candidates[0]?.plan;
114
+ return (
115
+ candidates.find(
116
+ (candidate) => candidate.inCurrentPeriod === true && candidate.status === "VALID",
117
+ )?.plan ??
118
+ candidates.find(
119
+ (candidate) => candidate.inCurrentPeriod === true && candidate.status === undefined,
120
+ )?.plan ??
121
+ candidates.find(
122
+ (candidate) => candidate.status === "VALID" && candidate.inCurrentPeriod === undefined,
123
+ )?.plan
124
+ );
125
+ }
126
+
127
+ function planRenewalDate(value: unknown): string | undefined {
128
+ if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}/u.test(value)) return value.slice(0, 10);
129
+ const millis = asNonnegativeNumber(value);
130
+ if (millis === undefined || millis === 0) return undefined;
131
+ return new Date(millis).toISOString().slice(0, 10);
132
+ }
133
+
134
+ // Z.AI encodes each window as a (unit, number) pair — unit 3 counts hours and unit 6 counts
135
+ // weeks — so the payload's number drives the window length and session label; the established
136
+ // 5-hour and weekly constants remain the fallback when the payload omits it.
137
+ function sessionWindowMinutes(limit: Record<string, unknown>): number {
138
+ const hours = asPositiveNumber(limit.number);
139
+ return hours === undefined ? FIVE_HOUR_WINDOW_MINUTES : Math.round(hours * 60);
140
+ }
141
+
142
+ function sessionWindowLabel(limit: Record<string, unknown>): string {
143
+ const minutes = sessionWindowMinutes(limit);
144
+ return minutes === FIVE_HOUR_WINDOW_MINUTES ? "5h window" : `${Math.round(minutes / 60)}h window`;
145
+ }
146
+
147
+ function weeklyWindowMinutes(limit: Record<string, unknown>): number {
148
+ const weeks = asPositiveNumber(limit.number);
149
+ return weeks === undefined ? WEEKLY_WINDOW_MINUTES : Math.round(weeks * WEEKLY_WINDOW_MINUTES);
150
+ }
151
+
60
152
  function addPercentBucket(
61
153
  buckets: UsageBucket[],
62
154
  limit: Record<string, unknown>,
@@ -130,6 +222,18 @@ function asNonnegativeNumber(value: unknown): number | undefined {
130
222
  return value;
131
223
  }
132
224
 
225
+ function asPositiveNumber(value: unknown): number | undefined {
226
+ const number = asNonnegativeNumber(value);
227
+ return number !== undefined && number > 0 ? number : undefined;
228
+ }
229
+
230
+ function asBoolean(value: unknown): boolean | undefined {
231
+ if (typeof value === "boolean") return value;
232
+ if (value === 1) return true;
233
+ if (value === 0) return false;
234
+ return undefined;
235
+ }
236
+
133
237
  function asEpochSeconds(value: unknown): number | undefined {
134
238
  const millis = asNonnegativeNumber(value);
135
239
  if (millis === undefined) return undefined;