pi-provider-kimi-code 0.6.2 → 0.6.5

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/src/usage.ts ADDED
@@ -0,0 +1,366 @@
1
+ import { AuthStorage } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { PROVIDER_ID, getBaseUrl } from "./constants.ts";
4
+ import { getKimiProviderHeaders } from "./device.ts";
5
+ import { refreshKimiAuthToken } from "./oauth.ts";
6
+
7
+ const MEMBERSHIP_LEVEL_NAMES: Record<string, string> = {
8
+ LEVEL_FREE: "Free",
9
+ LEVEL_BASIC: "Adagio",
10
+ LEVEL_STANDARD: "Moderato",
11
+ LEVEL_INTERMEDIATE: "Allegretto",
12
+ LEVEL_ADVANCED: "Allegro",
13
+ LEVEL_PREMIUM: "Vivace",
14
+ };
15
+
16
+ const RESET_TIME_KEYS = [
17
+ "resetTime",
18
+ "reset_time",
19
+ "resetAt",
20
+ "reset_at",
21
+ "resetsAt",
22
+ "resets_at",
23
+ "nextResetTime",
24
+ "next_reset_time",
25
+ ] as const;
26
+
27
+ export interface UsageRow {
28
+ label: string;
29
+ used: number;
30
+ limit: number;
31
+ resetTime?: string;
32
+ }
33
+
34
+ export interface UsageFormatOptions {
35
+ now?: Date;
36
+ timeZone?: string;
37
+ }
38
+
39
+ interface BoosterWalletInfo {
40
+ balanceCents: bigint;
41
+ monthlyChargeLimitEnabled: boolean;
42
+ monthlyChargeLimitCents: bigint;
43
+ monthlyUsedCents: bigint;
44
+ currency: string;
45
+ }
46
+
47
+ const FIXED_POINT_CENTS = 1_000_000n;
48
+
49
+ export function getKimiUsageToken(): string | null {
50
+ const credential = AuthStorage.create().get(PROVIDER_ID);
51
+ if (credential?.type === "oauth" && credential.access) return credential.access;
52
+ const apiKey = process.env.KIMI_API_KEY?.trim();
53
+ return apiKey || null;
54
+ }
55
+
56
+ function toNumber(value: unknown): number | null {
57
+ const number = Number(value);
58
+ return Number.isFinite(number) ? number : null;
59
+ }
60
+
61
+ function toStringValue(value: unknown): string | undefined {
62
+ if (typeof value === "string" && value.trim()) return value;
63
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
64
+ return undefined;
65
+ }
66
+
67
+ function getResetTime(record: Record<string, unknown>): string | undefined {
68
+ for (const key of RESET_TIME_KEYS) {
69
+ const value = toStringValue(record[key]);
70
+ if (value) return value;
71
+ }
72
+ return undefined;
73
+ }
74
+
75
+ function toBigInt(value: unknown): bigint | null {
76
+ if (typeof value === "bigint") return value;
77
+ if (typeof value === "string" && /^-?\d+$/.test(value)) return BigInt(value);
78
+ if (typeof value === "number" && Number.isSafeInteger(value)) return BigInt(value);
79
+ return null;
80
+ }
81
+
82
+ function fixedPointToCents(value: bigint): bigint {
83
+ if (value > 0n && value < FIXED_POINT_CENTS) return 1n;
84
+ return (value + FIXED_POINT_CENTS / 2n) / FIXED_POINT_CENTS;
85
+ }
86
+
87
+ function parseMoney(value: unknown): { cents: bigint; currency: string } | null {
88
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
89
+ const record = value as Record<string, unknown>;
90
+ const cents = toBigInt(record.priceInCents);
91
+ if (cents === null) return null;
92
+ return {
93
+ cents,
94
+ currency: typeof record.currency === "string" ? record.currency : "",
95
+ };
96
+ }
97
+
98
+ function parseBoosterWallet(value: unknown): BoosterWalletInfo | null {
99
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
100
+ const record = value as Record<string, unknown>;
101
+ const balance = record.balance;
102
+ if (typeof balance !== "object" || balance === null || Array.isArray(balance)) return null;
103
+ const balanceRecord = balance as Record<string, unknown>;
104
+ if (balanceRecord.type !== "BOOSTER") return null;
105
+ const amount = toBigInt(balanceRecord.amount);
106
+ if (amount === null || amount < 0n) return null;
107
+
108
+ const amountLeft = toBigInt(balanceRecord.amountLeft);
109
+ const monthlyLimit = parseMoney(record.monthlyChargeLimit);
110
+ const monthlyUsed = parseMoney(record.monthlyUsed);
111
+ return {
112
+ balanceCents: amountLeft === null ? 0n : fixedPointToCents(amountLeft),
113
+ monthlyChargeLimitEnabled: record.monthlyChargeLimitEnabled === true,
114
+ monthlyChargeLimitCents: monthlyLimit?.cents ?? 0n,
115
+ monthlyUsedCents: monthlyUsed?.cents ?? 0n,
116
+ currency: monthlyLimit?.currency || monthlyUsed?.currency || "USD",
117
+ };
118
+ }
119
+
120
+ function formatCurrency(cents: bigint, currency: string): string {
121
+ const symbol =
122
+ currency.toUpperCase() === "USD" ? "$" : currency.toUpperCase() === "CNY" ? "¥" : "";
123
+ const sign = cents < 0n ? "-" : "";
124
+ const absolute = cents < 0n ? -cents : cents;
125
+ const amount = `${sign}${absolute / 100n}.${String(absolute % 100n).padStart(2, "0")}`;
126
+ return symbol ? `${symbol}${amount}` : `${amount} ${currency}`.trim();
127
+ }
128
+
129
+ function formatExtraUsage(info: BoosterWalletInfo): string[] {
130
+ const hasMonthlyLimit = info.monthlyChargeLimitEnabled && info.monthlyChargeLimitCents > 0n;
131
+ const lines = ["Extra Usage"];
132
+ if (hasMonthlyLimit) {
133
+ const used =
134
+ info.monthlyUsedCents < info.monthlyChargeLimitCents
135
+ ? info.monthlyUsedCents
136
+ : info.monthlyChargeLimitCents;
137
+ const percent = Number(
138
+ (used * 100n + info.monthlyChargeLimitCents / 2n) / info.monthlyChargeLimitCents,
139
+ );
140
+ const barUnits = Number((used * 400n) / info.monthlyChargeLimitCents);
141
+ lines.push(`${quotaBar(barUnits, 400)} ${percent}% used`);
142
+ }
143
+ lines.push(`Used this month: ${formatCurrency(info.monthlyUsedCents, info.currency)}`);
144
+ lines.push(
145
+ `Monthly limit: ${hasMonthlyLimit ? formatCurrency(info.monthlyChargeLimitCents, info.currency) : "Unlimited"}`,
146
+ );
147
+ lines.push(`Balance: ${formatCurrency(info.balanceCents, info.currency)}`);
148
+ return lines;
149
+ }
150
+
151
+ export function parseUsageRow(value: unknown, fallbackLabel: string): UsageRow | null {
152
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
153
+ const record = value as Record<string, unknown>;
154
+ const limit = toNumber(record.limit);
155
+ const usedValue = toNumber(record.used);
156
+ const remaining = toNumber(record.remaining);
157
+ const used = usedValue ?? (limit !== null && remaining !== null ? limit - remaining : null);
158
+ if (limit === null && used === null) return null;
159
+ return {
160
+ label: String(record.name || record.title || fallbackLabel),
161
+ used: used ?? 0,
162
+ limit: limit ?? 0,
163
+ ...(getResetTime(record) ? { resetTime: getResetTime(record) } : {}),
164
+ };
165
+ }
166
+
167
+ export function parseUsageSummary(payload: unknown, options: UsageFormatOptions = {}): string {
168
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
169
+ return "Usage: unavailable";
170
+ }
171
+
172
+ const record = payload as Record<string, unknown>;
173
+ const lines: string[] = [];
174
+ const membership = parseMembership(record);
175
+ if (membership) lines.push(membership, "");
176
+
177
+ const summary = parseUsageRow(record.usage, "Current week");
178
+ if (summary)
179
+ lines.push(formatUsageRow({ ...summary, label: normalizeUsageLabel(summary.label) }, options));
180
+
181
+ if (Array.isArray(record.limits)) {
182
+ for (const [index, item] of record.limits.entries()) {
183
+ const itemRecord =
184
+ typeof item === "object" && item !== null && !Array.isArray(item)
185
+ ? (item as Record<string, unknown>)
186
+ : undefined;
187
+ const detail = itemRecord ? (itemRecord.detail ?? itemRecord) : item;
188
+ const fallbackLabel = itemRecord
189
+ ? formatWindowLabel(
190
+ itemRecord.window,
191
+ index === 0 ? "Current 5h window" : `Limit #${index + 1}`,
192
+ )
193
+ : index === 0
194
+ ? "Current 5h window"
195
+ : `Limit #${index + 1}`;
196
+ const row = parseUsageRow(detail, fallbackLabel);
197
+ if (row) {
198
+ if (lines.length > 0) lines.push("");
199
+ lines.push(formatUsageRow(row, options));
200
+ }
201
+ }
202
+ }
203
+
204
+ const extraUsage = parseBoosterWallet(record.boosterWallet);
205
+ if (extraUsage) {
206
+ if (lines.length > 0) lines.push("");
207
+ lines.push(...formatExtraUsage(extraUsage));
208
+ }
209
+
210
+ while (lines.at(-1) === "") lines.pop();
211
+ return lines.length === 0 ? "Usage: no usage data" : lines.join("\n");
212
+ }
213
+
214
+ function normalizeUsageLabel(label: string): string {
215
+ return /weekly|week/i.test(label) ? "Current week" : label;
216
+ }
217
+
218
+ function formatWindowLabel(value: unknown, fallbackLabel: string): string {
219
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return fallbackLabel;
220
+ const record = value as Record<string, unknown>;
221
+ const duration = toNumber(record.duration);
222
+ const unit = String(record.timeUnit ?? record.time_unit ?? "").toUpperCase();
223
+ if (!duration || !unit) return fallbackLabel;
224
+
225
+ const minutes = unit.includes("HOUR")
226
+ ? duration * 60
227
+ : unit.includes("MINUTE")
228
+ ? duration
229
+ : undefined;
230
+ if (!minutes) return fallbackLabel;
231
+ if (minutes % 60 === 0) return `Current ${minutes / 60}h window`;
232
+ return `Current ${minutes}m window`;
233
+ }
234
+
235
+ export function parseMembership(record: Record<string, unknown>): string | null {
236
+ const user = record.user;
237
+ if (typeof user !== "object" || user === null || Array.isArray(user)) return null;
238
+ const membership = (user as Record<string, unknown>).membership;
239
+ if (typeof membership !== "object" || membership === null || Array.isArray(membership)) {
240
+ return null;
241
+ }
242
+ const level = (membership as Record<string, unknown>).level;
243
+ if (typeof level !== "string" || !level) return null;
244
+ const name = MEMBERSHIP_LEVEL_NAMES[level];
245
+ return name ? `Membership: ${name} (${level})` : `Membership: ${level}`;
246
+ }
247
+
248
+ export function formatUsageRow(row: UsageRow, options: UsageFormatOptions = {}): string {
249
+ if (row.limit <= 0) return `${row.label}\n${row.used} used`;
250
+ const used = Math.max(0, Math.min(row.used, row.limit));
251
+ const percent = Math.round((used / row.limit) * 100);
252
+ const lines = [row.label, `${quotaBar(used, row.limit)} ${percent}% used`];
253
+ const reset = formatResetTime(row.resetTime, options);
254
+ if (reset) lines.push(`Resets ${reset}`);
255
+ return lines.join("\n");
256
+ }
257
+
258
+ export function formatResetTime(value: unknown, options: UsageFormatOptions = {}): string | null {
259
+ const text = toStringValue(value);
260
+ if (!text) return null;
261
+ const date = parseDate(text);
262
+ if (!date) return null;
263
+
264
+ const now = options.now ?? new Date();
265
+ const timeZone = options.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC";
266
+ const sameDay = datePart(date, timeZone) === datePart(now, timeZone);
267
+ const time = formatTime(date, timeZone);
268
+ const prefix = sameDay ? "" : `${formatMonthDay(date, timeZone)} at `;
269
+ return `${prefix}${time} (${timeZone})`;
270
+ }
271
+
272
+ function parseDate(value: string): Date | null {
273
+ const timestamp = /^\d+$/.test(value) ? Number(value) : Number.NaN;
274
+ const date = Number.isFinite(timestamp)
275
+ ? new Date(timestamp < 1_000_000_000_000 ? timestamp * 1000 : timestamp)
276
+ : new Date(value);
277
+ return Number.isNaN(date.getTime()) ? null : date;
278
+ }
279
+
280
+ function datePart(date: Date, timeZone: string): string {
281
+ return new Intl.DateTimeFormat("en-US", {
282
+ day: "2-digit",
283
+ month: "2-digit",
284
+ timeZone,
285
+ year: "numeric",
286
+ }).format(date);
287
+ }
288
+
289
+ function formatMonthDay(date: Date, timeZone: string): string {
290
+ return new Intl.DateTimeFormat("en-US", {
291
+ day: "numeric",
292
+ month: "short",
293
+ timeZone,
294
+ }).format(date);
295
+ }
296
+
297
+ function formatTime(date: Date, timeZone: string): string {
298
+ const parts = new Intl.DateTimeFormat("en-US", {
299
+ hour: "numeric",
300
+ hour12: true,
301
+ minute: "2-digit",
302
+ timeZone,
303
+ }).formatToParts(date);
304
+ const hour = parts.find((part) => part.type === "hour")?.value ?? "";
305
+ const minute = parts.find((part) => part.type === "minute")?.value ?? "";
306
+ const dayPeriod = parts.find((part) => part.type === "dayPeriod")?.value.toLowerCase() ?? "";
307
+ return `${hour}:${minute}${dayPeriod}`;
308
+ }
309
+
310
+ function quotaBar(used: number, limit: number): string {
311
+ const width = 50;
312
+ const ratio = Math.max(0, Math.min(1, used / limit));
313
+ const filled = Math.floor(ratio * width);
314
+ const partial = partialBlock(ratio * width - filled);
315
+ const empty = width - filled - (partial ? 1 : 0);
316
+ return `${"█".repeat(filled)}${partial}${" ".repeat(Math.max(0, empty))}`;
317
+ }
318
+
319
+ function partialBlock(value: number): string {
320
+ if (value < 0.125) return "";
321
+ if (value < 0.25) return "▏";
322
+ if (value < 0.375) return "▎";
323
+ if (value < 0.5) return "▍";
324
+ if (value < 0.625) return "▌";
325
+ if (value < 0.75) return "▋";
326
+ if (value < 0.875) return "▊";
327
+ return "▉";
328
+ }
329
+
330
+ export function buildKimiUsageUrl(baseUrl = getBaseUrl("openai")): string {
331
+ const normalized = baseUrl.replace(/\/+$/, "");
332
+ return normalized.endsWith("/v1") ? `${normalized}/usages` : `${normalized}/v1/usages`;
333
+ }
334
+
335
+ function fetchKimiUsage(token: string, signal: AbortSignal): Promise<Response> {
336
+ return fetch(buildKimiUsageUrl(), {
337
+ method: "GET",
338
+ headers: {
339
+ ...getKimiProviderHeaders(),
340
+ Authorization: `Bearer ${token}`,
341
+ },
342
+ signal,
343
+ });
344
+ }
345
+
346
+ export async function fetchKimiUsageSummary(): Promise<string> {
347
+ const token = getKimiUsageToken();
348
+ if (!token) return "Usage: missing credentials. Run /login kimi-coding.";
349
+
350
+ const controller = new AbortController();
351
+ const timeout = setTimeout(() => controller.abort(), 15_000);
352
+ try {
353
+ let response = await fetchKimiUsage(token, controller.signal);
354
+ if (response.status === 401) {
355
+ const refreshed = await refreshKimiAuthToken(token);
356
+ if (refreshed) response = await fetchKimiUsage(refreshed, controller.signal);
357
+ }
358
+ if (!response.ok) return `Usage: fetch failed (${response.status})`;
359
+ return parseUsageSummary(await response.json());
360
+ } catch (error) {
361
+ const message = error instanceof Error ? error.message : String(error);
362
+ return `Usage: fetch failed (${message})`;
363
+ } finally {
364
+ clearTimeout(timeout);
365
+ }
366
+ }