@narumitw/pi-usage 0.60.7 → 0.60.8

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.
@@ -15,262 +15,251 @@ const FIXED_POINT_UNITS_PER_CENT = 1_000_000;
15
15
  * Kimi Code 676e4d82240855044fe809fea89ce1dbe8e512cf defines `GET /coding/v1/usages`,
16
16
  * numeric-string plan rows, proto-style windows, and 1,000,000 fixed-point units per cent:
17
17
  * https://github.com/MoonshotAI/kimi-code/blob/676e4d82240855044fe809fea89ce1dbe8e512cf/packages/oauth/src/managed-usage.ts
18
+ * Live responses observed on 2026-09-12 omit both `used` and `remaining` on untouched rows and may report only
19
+ * `remaining` once usage starts, so a row with a positive `limit` and no counters means zero usage, and when only
20
+ * one counter is present the other is derived from `limit`. Counters that are present but malformed stay rejected.
18
21
  */
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.");
22
+ export function normalizeKimiCodingUsagePayload(payload: KimiCodingUsagePayload, capturedAt: number): UsageReport {
23
+ const root = asObject(payload);
24
+ if (!root) throw new Error("Kimi Coding usage response was not an object.");
25
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;
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
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
- }
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
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));
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
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
- }
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
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
- };
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 ? { notes: ["Unsupported, malformed, or duplicate plan windows were unavailable."] } : {}),
74
+ };
77
75
  }
78
76
 
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
- };
77
+ function parseUsageRow(value: unknown, windowMinutes: number, label: string): UsageBucket | undefined {
78
+ const row = asObject(value);
79
+ if (!row) return undefined;
80
+ const limit = asNonnegativeInteger(row.limit);
81
+ if (limit === undefined || limit === 0) return undefined;
82
+ const usedRaw = asNonnegativeInteger(row.used);
83
+ const remainingRaw = asNonnegativeInteger(row.remaining);
84
+ // Counters that are present but malformed reject the row instead of silently inventing a count.
85
+ if (
86
+ (row.used !== undefined && usedRaw === undefined) ||
87
+ (row.remaining !== undefined && remainingRaw === undefined)
88
+ ) {
89
+ return undefined;
90
+ }
91
+ // Live responses omit both counters on untouched rows, so a bare `limit` means zero usage.
92
+ const used = usedRaw ?? (remainingRaw === undefined ? 0 : Math.max(0, limit - remainingRaw));
93
+ const remaining = remainingRaw ?? Math.max(0, limit - used);
94
+ const resetsAt = asIsoEpochSeconds(row.resetTime);
95
+ return {
96
+ id: windowId(windowMinutes),
97
+ label,
98
+ used,
99
+ remaining,
100
+ limit,
101
+ unit: "count",
102
+ windowMinutes,
103
+ ...(resetsAt !== undefined ? { resetsAt } : {}),
104
+ };
100
105
  }
101
106
 
102
107
  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;
108
+ const window = asObject(value);
109
+ if (!window) return undefined;
110
+ const duration = asPositiveInteger(window.duration);
111
+ if (duration === undefined) return undefined;
112
+ const multiplier =
113
+ window.timeUnit === "TIME_UNIT_MINUTE"
114
+ ? 1
115
+ : window.timeUnit === "TIME_UNIT_HOUR"
116
+ ? 60
117
+ : window.timeUnit === "TIME_UNIT_DAY"
118
+ ? 1_440
119
+ : window.timeUnit === "TIME_UNIT_WEEK"
120
+ ? 10_080
121
+ : undefined;
122
+ if (multiplier === undefined) return undefined;
123
+ const minutes = duration * multiplier;
124
+ return Number.isSafeInteger(minutes) ? minutes : undefined;
120
125
  }
121
126
 
122
127
  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 [];
128
+ const wallet = asObject(value);
129
+ const balance = asObject(wallet?.balance);
130
+ if (!wallet || !balance || balance.type !== "BOOSTER") return [];
131
+ const totalRaw = asPositiveInteger(balance.amount);
132
+ if (totalRaw === undefined) return [];
133
+ const leftRaw = asNonnegativeInteger(balance.amountLeft) ?? 0;
134
+ const monthlyLimit = parseMoney(wallet.monthlyChargeLimit);
135
+ const monthlyUsed = parseMoney(wallet.monthlyUsed);
136
+ const currencies = new Set(
137
+ [monthlyLimit?.currency, monthlyUsed?.currency].filter((currency): currency is string => currency !== undefined),
138
+ );
139
+ if (currencies.size !== 1) return [];
140
+ const currency = currencies.values().next().value;
141
+ if (!currency) return [];
142
+ const total = fixedPointToMajor(totalRaw);
143
+ const left = fixedPointToMajor(leftRaw);
144
+ if (total === undefined || left === undefined) return [];
142
145
 
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;
146
+ const metrics: UsageMetric[] = [
147
+ { id: "booster-balance", label: "Balance", value: left, unit: "currency", currency },
148
+ { id: "booster-total", label: "Total balance", value: total, unit: "currency", currency },
149
+ ];
150
+ if (monthlyUsed) {
151
+ metrics.push({
152
+ id: "booster-monthly-used",
153
+ label: "Used this month",
154
+ value: monthlyUsed.cents / 100,
155
+ unit: "currency",
156
+ currency,
157
+ });
158
+ }
159
+ if (wallet.monthlyChargeLimitEnabled === false) {
160
+ metrics.push({
161
+ id: "booster-monthly-limit",
162
+ label: "Monthly limit",
163
+ value: "unlimited",
164
+ unit: "currency",
165
+ currency,
166
+ });
167
+ } else if (wallet.monthlyChargeLimitEnabled === true && monthlyLimit) {
168
+ metrics.push({
169
+ id: "booster-monthly-limit",
170
+ label: "Monthly limit",
171
+ value: monthlyLimit.cents / 100,
172
+ unit: "currency",
173
+ currency,
174
+ });
175
+ }
176
+ return metrics;
174
177
  }
175
178
 
176
179
  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 };
180
+ const money = asObject(value);
181
+ if (!money) return undefined;
182
+ const cents = asNonnegativeInteger(money.priceInCents);
183
+ if (cents === undefined) return undefined;
184
+ const currency = asCurrency(money.currency);
185
+ if (!currency) return undefined;
186
+ return { cents, currency };
184
187
  }
185
188
 
186
189
  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;
190
+ const cents = value / FIXED_POINT_UNITS_PER_CENT;
191
+ const roundedCents = cents > 0 && cents < 1 ? 1 : Math.round(cents);
192
+ const major = roundedCents / 100;
193
+ return Number.isSafeInteger(roundedCents) && Number.isFinite(major) ? major : undefined;
191
194
  }
192
195
 
193
196
  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>;
197
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
198
+ return value as Record<string, unknown>;
196
199
  }
197
200
 
198
201
  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;
202
+ if (typeof value === "string" && !/^\d+$/u.test(value)) return undefined;
203
+ const number = typeof value === "string" ? Number(value) : value;
204
+ if (typeof number !== "number" || !Number.isSafeInteger(number) || number < 0) return undefined;
205
+ return number;
203
206
  }
204
207
 
205
208
  function asPositiveInteger(value: unknown): number | undefined {
206
- const number = asNonnegativeInteger(value);
207
- return number !== undefined && number > 0 ? number : undefined;
209
+ const number = asNonnegativeInteger(value);
210
+ return number !== undefined && number > 0 ? number : undefined;
208
211
  }
209
212
 
210
213
  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;
214
+ if (typeof value !== "string") return undefined;
215
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|([+-])(\d{2}):(\d{2}))$/u.exec(value);
216
+ if (!match) return undefined;
217
+ const [, yearText, monthText, dayText, hourText, minuteText, secondText, , offsetHour, offsetMinute] = match;
218
+ const year = Number(yearText);
219
+ const month = Number(monthText);
220
+ const day = Number(dayText);
221
+ const hour = Number(hourText);
222
+ const minute = Number(minuteText);
223
+ const second = Number(secondText);
224
+ if (
225
+ month < 1 ||
226
+ month > 12 ||
227
+ day < 1 ||
228
+ day > new Date(Date.UTC(year, month, 0)).getUTCDate() ||
229
+ hour > 23 ||
230
+ minute > 59 ||
231
+ second > 59 ||
232
+ (offsetHour !== undefined && Number(offsetHour) > 23) ||
233
+ (offsetMinute !== undefined && Number(offsetMinute) > 59)
234
+ ) {
235
+ return undefined;
236
+ }
237
+ const millis = Date.parse(value);
238
+ return Number.isFinite(millis) && millis >= 0 ? Math.floor(millis / 1000) : undefined;
250
239
  }
251
240
 
252
241
  function sanitizedLabel(value: unknown): string | undefined {
253
- if (typeof value !== "string") return undefined;
254
- return sanitizeDisplayText(value, 80) || undefined;
242
+ if (typeof value !== "string") return undefined;
243
+ return sanitizeDisplayText(value, 80) || undefined;
255
244
  }
256
245
 
257
246
  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;
247
+ if (typeof value !== "string") return undefined;
248
+ const currency = sanitizeDisplayText(value, 3).toUpperCase();
249
+ return /^[A-Z]{3}$/u.test(currency) ? currency : undefined;
261
250
  }
262
251
 
263
252
  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`;
253
+ if (minutes === FIVE_HOUR_WINDOW_MINUTES) return "five-hour";
254
+ if (minutes === DAILY_WINDOW_MINUTES) return "daily";
255
+ if (minutes === WEEKLY_WINDOW_MINUTES) return "weekly";
256
+ return `window-${minutes}-minutes`;
268
257
  }
269
258
 
270
259
  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`;
260
+ if (minutes === WEEKLY_WINDOW_MINUTES) return "Weekly window";
261
+ if (minutes % 10_080 === 0) return `${minutes / 10_080}w window`;
262
+ if (minutes % 1_440 === 0) return `${minutes / 1_440}d window`;
263
+ if (minutes % 60 === 0) return `${minutes / 60}h window`;
264
+ return `${minutes}m window`;
276
265
  }