@vibe-cafe/vibe-usage 0.10.34 → 0.11.1

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,504 @@
1
+ import {
2
+ accessSync,
3
+ closeSync,
4
+ constants as fsConstants,
5
+ fsyncSync,
6
+ mkdirSync,
7
+ openSync,
8
+ readFileSync,
9
+ renameSync,
10
+ rmdirSync,
11
+ statSync,
12
+ unlinkSync,
13
+ writeFileSync,
14
+ } from 'node:fs';
15
+ import { homedir } from 'node:os';
16
+ import { dirname, join } from 'node:path';
17
+ import { attachCacheScope } from '../cache.js';
18
+ import { quotaResult } from '../schema.js';
19
+
20
+ const PRODUCT_ID = 'kimi-code';
21
+ const DEFAULT_USAGE_URL = 'https://api.kimi.com/coding/v1/usages';
22
+ const DEFAULT_OAUTH_HOST = 'https://auth.kimi.com';
23
+ const KIMI_CODE_CLIENT_ID = '17e5f671-d194-4dfb-9706-5516cb48c098';
24
+ const MIN_REFRESH_THRESHOLD_SECONDS = 300;
25
+ const REFRESH_THRESHOLD_RATIO = 0.5;
26
+ const RETRYABLE_REFRESH_STATUSES = new Set([429, 500, 502, 503, 504]);
27
+ const REFRESH_LOCK_RETRIES = 50;
28
+ const REFRESH_LOCK_RETRY_MS = 100;
29
+ const REFRESH_LOCK_STALE_MS = 120_000;
30
+
31
+ class RefreshUnauthorizedError extends Error {}
32
+ class RefreshRetryableError extends Error {}
33
+ class RefreshNonRetryableError extends Error {}
34
+ class RefreshPersistenceError extends Error {}
35
+
36
+ const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds));
37
+
38
+ function number(value) {
39
+ if (value === null || value === undefined || value === '') return null;
40
+ const parsed = Number(value);
41
+ return Number.isFinite(parsed) ? parsed : null;
42
+ }
43
+
44
+ function resetDate(data, now = new Date()) {
45
+ for (const key of ['reset_at', 'resetAt', 'reset_time', 'resetTime']) {
46
+ const value = data?.[key];
47
+ if (value === null || value === undefined || value === '') continue;
48
+ if (typeof value === 'number') {
49
+ const millis = value > 10_000_000_000 ? value : value * 1000;
50
+ if (Number.isFinite(millis)) return new Date(millis);
51
+ }
52
+ const millis = Date.parse(String(value));
53
+ if (!Number.isNaN(millis)) return new Date(millis);
54
+ }
55
+ const seconds = number(data?.reset_in ?? data?.resetIn ?? data?.ttl);
56
+ return seconds !== null && seconds > 0 ? new Date(now.getTime() + seconds * 1000) : null;
57
+ }
58
+
59
+ function durationSeconds(item, detail) {
60
+ const window = item?.window && typeof item.window === 'object' ? item.window : {};
61
+ const duration = number(window.duration ?? item?.duration ?? detail?.duration);
62
+ if (duration === null || duration <= 0) return null;
63
+ const unit = String(window.timeUnit ?? item?.timeUnit ?? detail?.timeUnit ?? '').toUpperCase();
64
+ if (unit.includes('MINUTE')) return duration * 60;
65
+ if (unit.includes('HOUR')) return duration * 3600;
66
+ if (unit.includes('DAY')) return duration * 86400;
67
+ if (unit.includes('WEEK')) return duration * 7 * 86400;
68
+ return duration;
69
+ }
70
+
71
+ function labelFor(item, detail, index) {
72
+ for (const key of ['name', 'title', 'scope']) {
73
+ const value = item?.[key] ?? detail?.[key];
74
+ if (typeof value === 'string' && value.trim()) return value.trim();
75
+ }
76
+ const seconds = durationSeconds(item, detail);
77
+ if (seconds && seconds % (7 * 86400) === 0) return `${seconds / (7 * 86400)}w`;
78
+ if (seconds && seconds % 86400 === 0) return `${seconds / 86400}d`;
79
+ if (seconds && seconds % 3600 === 0) return `${seconds / 3600}h`;
80
+ if (seconds && seconds % 60 === 0) return `${seconds / 60}m`;
81
+ return `Quota ${index + 1}`;
82
+ }
83
+
84
+ function meterFrom(data, item, index, defaultLabel, now) {
85
+ if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
86
+ const limit = number(data.limit);
87
+ let used = number(data.used);
88
+ if (used === null && limit !== null) {
89
+ const remaining = number(data.remaining);
90
+ if (remaining !== null) used = limit - remaining;
91
+ }
92
+ if (limit === null || limit <= 0 || used === null) return null;
93
+ const label = String(data.name || data.title || defaultLabel).trim();
94
+ const rawIdentifier = String(data.id || item?.id || label)
95
+ .trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
96
+ const meter = {
97
+ id: `${index}-${rawIdentifier || 'quota'}`,
98
+ label,
99
+ utilization: Math.max(0, Math.min(100, used / limit * 100)),
100
+ };
101
+ const resetsAt = resetDate(data, now) || resetDate(item, now);
102
+ if (resetsAt) meter.resetsAt = resetsAt.toISOString();
103
+ const seconds = durationSeconds(item, data);
104
+ if (seconds) meter.windowSeconds = seconds;
105
+ return meter;
106
+ }
107
+
108
+ export function parseKimiUsage(payload, now = new Date()) {
109
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
110
+ throw new Error('Kimi usage response is not an object');
111
+ }
112
+ const meters = [];
113
+ if (payload.usage && typeof payload.usage === 'object' && !Array.isArray(payload.usage)) {
114
+ const summary = meterFrom(payload.usage, payload.usage, 0, 'Weekly', now);
115
+ if (summary) meters.push(summary);
116
+ }
117
+ if (Array.isArray(payload.limits)) {
118
+ const offset = meters.length;
119
+ for (const [index, item] of payload.limits.entries()) {
120
+ if (!item || typeof item !== 'object' || Array.isArray(item)) continue;
121
+ const detail = item.detail && typeof item.detail === 'object' && !Array.isArray(item.detail)
122
+ ? item.detail : item;
123
+ const meter = meterFrom(detail, item, index + offset,
124
+ labelFor(item, detail, index), now);
125
+ if (meter) meters.push(meter);
126
+ }
127
+ }
128
+ const seen = new Set();
129
+ return meters.filter(meter => {
130
+ const key = `${meter.label}\0${meter.windowSeconds || ''}`;
131
+ if (seen.has(key)) return false;
132
+ seen.add(key);
133
+ return true;
134
+ });
135
+ }
136
+
137
+ export function kimiCredentialPath(environment = process.env, home = homedir()) {
138
+ const shareDirectory = environment.KIMI_SHARE_DIR?.trim() || join(home, '.kimi');
139
+ return join(shareDirectory, 'credentials', 'kimi-code.json');
140
+ }
141
+
142
+ function readCredentials(path) {
143
+ try {
144
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
145
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
146
+ } catch {
147
+ return null;
148
+ }
149
+ }
150
+
151
+ function accessToken(credentials) {
152
+ return typeof credentials?.access_token === 'string' ? credentials.access_token.trim() : '';
153
+ }
154
+
155
+ function refreshToken(credentials) {
156
+ return typeof credentials?.refresh_token === 'string' ? credentials.refresh_token.trim() : '';
157
+ }
158
+
159
+ function refreshThreshold(credentials) {
160
+ const expiresIn = number(credentials?.expires_in);
161
+ return Math.max(
162
+ MIN_REFRESH_THRESHOLD_SECONDS,
163
+ expiresIn !== null && expiresIn > 0 ? expiresIn * REFRESH_THRESHOLD_RATIO : 0,
164
+ );
165
+ }
166
+
167
+ function shouldRefresh(credentials, now, force = false) {
168
+ if (force) return true;
169
+ if (!accessToken(credentials)) return true;
170
+ const expiresAt = number(credentials?.expires_at);
171
+ if (expiresAt === null || expiresAt <= 0) return false;
172
+ return expiresAt * 1000 - now.getTime() <= refreshThreshold(credentials) * 1000;
173
+ }
174
+
175
+ function credentialsWereRotated(latest, previous) {
176
+ const latestRefresh = refreshToken(latest);
177
+ const previousRefresh = refreshToken(previous);
178
+ if (latestRefresh && latestRefresh !== previousRefresh) return true;
179
+ return Boolean(accessToken(latest) && accessToken(latest) !== accessToken(previous));
180
+ }
181
+
182
+ function atomicWriteCredentials(path, credentials) {
183
+ const temporary = `${path}.${process.pid}.${Date.now()}.tmp`;
184
+ let descriptor = -1;
185
+ try {
186
+ descriptor = openSync(temporary, 'wx', 0o600);
187
+ writeFileSync(descriptor, `${JSON.stringify(credentials)}\n`, 'utf8');
188
+ fsyncSync(descriptor);
189
+ closeSync(descriptor);
190
+ descriptor = -1;
191
+ renameSync(temporary, path);
192
+ } catch (error) {
193
+ if (descriptor >= 0) {
194
+ try { closeSync(descriptor); } catch {}
195
+ }
196
+ try { unlinkSync(temporary); } catch {}
197
+ throw error;
198
+ }
199
+ }
200
+
201
+ function assertCredentialStoreWritable(path) {
202
+ // Check this before rotating a refresh token. A successful refresh can
203
+ // invalidate the old token, so discovering a read-only credential store only
204
+ // after the network request could log the user out of Kimi Code.
205
+ try {
206
+ accessSync(dirname(path), fsConstants.W_OK);
207
+ } catch {
208
+ throw new RefreshPersistenceError();
209
+ }
210
+ }
211
+
212
+ function lockPathFor(credentialsPath) {
213
+ return `${credentialsPath}.vibe-usage-refresh-lock`;
214
+ }
215
+
216
+ function processIsAlive(pid) {
217
+ if (!Number.isInteger(pid) || pid <= 0) return false;
218
+ try {
219
+ process.kill(pid, 0);
220
+ return true;
221
+ } catch (error) {
222
+ return error?.code === 'EPERM';
223
+ }
224
+ }
225
+
226
+ function removeAbandonedLock(path, nowMs) {
227
+ try {
228
+ let owner = null;
229
+ try { owner = JSON.parse(readFileSync(join(path, 'owner.json'), 'utf8')); } catch {}
230
+ const isOrphan = owner?.pid && !processIsAlive(Number(owner.pid));
231
+ const isStale = nowMs - statSync(path).mtimeMs > REFRESH_LOCK_STALE_MS;
232
+ if (!isOrphan && !isStale) return false;
233
+ try { unlinkSync(join(path, 'owner.json')); } catch {}
234
+ rmdirSync(path);
235
+ return true;
236
+ } catch {
237
+ return false;
238
+ }
239
+ }
240
+
241
+ async function acquireRefreshLock(credentialsPath, sleepImpl) {
242
+ const path = lockPathFor(credentialsPath);
243
+ for (let attempt = 0; attempt <= REFRESH_LOCK_RETRIES; attempt += 1) {
244
+ try {
245
+ mkdirSync(path, { mode: 0o700 });
246
+ try {
247
+ writeFileSync(join(path, 'owner.json'), JSON.stringify({ pid: process.pid }), {
248
+ encoding: 'utf8',
249
+ mode: 0o600,
250
+ });
251
+ } catch {
252
+ try { unlinkSync(join(path, 'owner.json')); } catch {}
253
+ try { rmdirSync(path); } catch {}
254
+ throw new RefreshPersistenceError();
255
+ }
256
+ return () => {
257
+ try { unlinkSync(join(path, 'owner.json')); } catch {}
258
+ try { rmdirSync(path); } catch {}
259
+ };
260
+ } catch (error) {
261
+ if (error instanceof RefreshPersistenceError) throw error;
262
+ if (error?.code !== 'EEXIST') throw new RefreshPersistenceError();
263
+ if (removeAbandonedLock(path, Date.now())) continue;
264
+ if (attempt < REFRESH_LOCK_RETRIES) await sleepImpl(REFRESH_LOCK_RETRY_MS);
265
+ }
266
+ }
267
+ throw new RefreshRetryableError();
268
+ }
269
+
270
+ function refreshedCredentials(payload, previous, now) {
271
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
272
+ throw new RefreshRetryableError();
273
+ }
274
+ const nextAccessToken = typeof payload.access_token === 'string' ? payload.access_token.trim() : '';
275
+ const nextRefreshToken = typeof payload.refresh_token === 'string'
276
+ ? payload.refresh_token.trim() : refreshToken(previous);
277
+ const expiresIn = number(payload.expires_in);
278
+ if (!nextAccessToken || !nextRefreshToken || expiresIn === null || expiresIn <= 0) {
279
+ throw new RefreshRetryableError();
280
+ }
281
+ return {
282
+ access_token: nextAccessToken,
283
+ refresh_token: nextRefreshToken,
284
+ expires_at: now.getTime() / 1000 + expiresIn,
285
+ scope: typeof payload.scope === 'string' ? payload.scope : String(previous.scope || ''),
286
+ token_type: typeof payload.token_type === 'string'
287
+ ? payload.token_type : String(previous.token_type || 'Bearer'),
288
+ expires_in: expiresIn,
289
+ };
290
+ }
291
+
292
+ async function requestTokenRefresh({
293
+ credentials,
294
+ fetchImpl,
295
+ oauthURL,
296
+ now,
297
+ timeoutMs,
298
+ sleepImpl,
299
+ }) {
300
+ let lastError;
301
+ for (let attempt = 0; attempt < 3; attempt += 1) {
302
+ try {
303
+ const response = await fetchImpl(oauthURL, {
304
+ method: 'POST',
305
+ headers: {
306
+ Accept: 'application/json',
307
+ 'Content-Type': 'application/x-www-form-urlencoded',
308
+ },
309
+ body: new URLSearchParams({
310
+ client_id: KIMI_CODE_CLIENT_ID,
311
+ grant_type: 'refresh_token',
312
+ refresh_token: refreshToken(credentials),
313
+ }),
314
+ signal: AbortSignal.timeout(timeoutMs),
315
+ });
316
+ let payload = {};
317
+ try { payload = await response.json(); } catch {}
318
+ if (response.status === 401 || response.status === 403) {
319
+ throw new RefreshUnauthorizedError();
320
+ }
321
+ if (!response.ok) {
322
+ if (payload?.error === 'invalid_grant') throw new RefreshUnauthorizedError();
323
+ if (!RETRYABLE_REFRESH_STATUSES.has(response.status)) throw new RefreshNonRetryableError();
324
+ lastError = new RefreshRetryableError();
325
+ } else {
326
+ return refreshedCredentials(payload, credentials, now);
327
+ }
328
+ } catch (error) {
329
+ if (error instanceof RefreshUnauthorizedError) throw error;
330
+ if (error instanceof RefreshNonRetryableError) throw new RefreshRetryableError();
331
+ lastError = error;
332
+ }
333
+ if (attempt < 2) await sleepImpl(2 ** attempt * 1000);
334
+ }
335
+ throw new RefreshRetryableError(undefined, { cause: lastError });
336
+ }
337
+
338
+ async function ensureFreshCredentials({
339
+ credentialsPath,
340
+ credentials,
341
+ fetchImpl,
342
+ oauthURL,
343
+ now,
344
+ timeoutMs,
345
+ sleepImpl,
346
+ force = false,
347
+ }) {
348
+ if (!shouldRefresh(credentials, now, force)) return credentials;
349
+ if (!refreshToken(credentials)) return credentials;
350
+
351
+ let release;
352
+ try {
353
+ release = await acquireRefreshLock(credentialsPath, sleepImpl);
354
+ const latest = readCredentials(credentialsPath) || credentials;
355
+ if (credentialsWereRotated(latest, credentials)) return latest;
356
+ if (!shouldRefresh(latest, now, force)) return latest;
357
+ assertCredentialStoreWritable(credentialsPath);
358
+
359
+ let refreshed;
360
+ try {
361
+ refreshed = await requestTokenRefresh({
362
+ credentials: latest,
363
+ fetchImpl,
364
+ oauthURL,
365
+ now,
366
+ timeoutMs,
367
+ sleepImpl,
368
+ });
369
+ } catch (error) {
370
+ if (error instanceof RefreshUnauthorizedError) {
371
+ // A Kimi process may have rotated and persisted the token while our
372
+ // request was in flight. Re-read once before reporting a stale refresh
373
+ // token as rejected.
374
+ await sleepImpl(1000);
375
+ const concurrent = readCredentials(credentialsPath);
376
+ if (concurrent && credentialsWereRotated(concurrent, latest)) return concurrent;
377
+ }
378
+ throw error;
379
+ }
380
+
381
+ const concurrent = readCredentials(credentialsPath);
382
+ if (concurrent && credentialsWereRotated(concurrent, latest)) return concurrent;
383
+ try {
384
+ atomicWriteCredentials(credentialsPath, refreshed);
385
+ } catch {
386
+ throw new RefreshPersistenceError();
387
+ }
388
+ return refreshed;
389
+ } finally {
390
+ release?.();
391
+ }
392
+ }
393
+
394
+ export async function fetchKimiCodeQuota({
395
+ environment = process.env,
396
+ home = homedir(),
397
+ fetchImpl = globalThis.fetch,
398
+ usageURL = DEFAULT_USAGE_URL,
399
+ oauthURL = `${(environment.KIMI_CODE_OAUTH_HOST || environment.KIMI_OAUTH_HOST
400
+ || DEFAULT_OAUTH_HOST).replace(/\/$/, '')}/api/oauth/token`,
401
+ now = new Date(),
402
+ timeoutMs = 10_000,
403
+ sleepImpl = sleep,
404
+ } = {}) {
405
+ const credentialsPath = kimiCredentialPath(environment, home);
406
+ let credentials = readCredentials(credentialsPath);
407
+ if (!credentials) {
408
+ return quotaResult({ id: PRODUCT_ID, status: 'missing_credentials',
409
+ message: 'Kimi Code is not logged in', fetchedAt: now });
410
+ }
411
+
412
+ try {
413
+ credentials = await ensureFreshCredentials({
414
+ credentialsPath,
415
+ credentials,
416
+ fetchImpl,
417
+ oauthURL,
418
+ now,
419
+ timeoutMs,
420
+ sleepImpl,
421
+ });
422
+ } catch (error) {
423
+ const token = accessToken(credentials);
424
+ if (error instanceof RefreshUnauthorizedError) {
425
+ return attachCacheScope(quotaResult({ id: PRODUCT_ID, status: 'unauthorized',
426
+ message: 'Kimi Code login refresh was rejected', fetchedAt: now }), token);
427
+ }
428
+ const message = error instanceof RefreshPersistenceError
429
+ ? 'Kimi Code could not securely save the refreshed login'
430
+ : 'Kimi Code login refresh failed';
431
+ return attachCacheScope(quotaResult({ id: PRODUCT_ID, status: 'retryable_error',
432
+ message, fetchedAt: now }), token);
433
+ }
434
+
435
+ let token = accessToken(credentials);
436
+ if (!token) {
437
+ return quotaResult({ id: PRODUCT_ID, status: 'missing_credentials',
438
+ message: 'Kimi Code access token is missing', fetchedAt: now });
439
+ }
440
+ const expiresAt = number(credentials.expires_at);
441
+ if (expiresAt !== null && expiresAt > 0 && expiresAt * 1000 <= now.getTime()) {
442
+ return attachCacheScope(quotaResult({ id: PRODUCT_ID, status: 'expired_credentials',
443
+ message: 'Kimi Code access token is expired and no refresh token is available',
444
+ fetchedAt: now }), token);
445
+ }
446
+
447
+ try {
448
+ let response = await fetchImpl(usageURL, {
449
+ method: 'GET',
450
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
451
+ signal: AbortSignal.timeout(timeoutMs),
452
+ });
453
+ if (response.status === 401 && refreshToken(credentials)) {
454
+ try {
455
+ credentials = await ensureFreshCredentials({
456
+ credentialsPath,
457
+ credentials,
458
+ fetchImpl,
459
+ oauthURL,
460
+ now,
461
+ timeoutMs,
462
+ sleepImpl,
463
+ force: true,
464
+ });
465
+ token = accessToken(credentials);
466
+ response = await fetchImpl(usageURL, {
467
+ method: 'GET',
468
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
469
+ signal: AbortSignal.timeout(timeoutMs),
470
+ });
471
+ } catch (error) {
472
+ if (error instanceof RefreshUnauthorizedError) {
473
+ return attachCacheScope(quotaResult({ id: PRODUCT_ID, status: 'unauthorized',
474
+ message: 'Kimi Code login refresh was rejected', fetchedAt: now }), token);
475
+ }
476
+ const message = error instanceof RefreshPersistenceError
477
+ ? 'Kimi Code could not securely save the refreshed login'
478
+ : 'Kimi Code login refresh failed';
479
+ return attachCacheScope(quotaResult({ id: PRODUCT_ID, status: 'retryable_error',
480
+ message, fetchedAt: now }), token);
481
+ }
482
+ }
483
+ if (response.status === 401 || response.status === 403) {
484
+ return quotaResult({ id: PRODUCT_ID, status: 'unauthorized',
485
+ message: 'Kimi Code rejected the saved login', fetchedAt: now });
486
+ }
487
+ if (!response.ok) {
488
+ return attachCacheScope(quotaResult({ id: PRODUCT_ID, status: 'retryable_error',
489
+ message: `Kimi usage API returned HTTP ${response.status}`, fetchedAt: now }), token);
490
+ }
491
+ const meters = parseKimiUsage(await response.json(), now);
492
+ return attachCacheScope(quotaResult({
493
+ id: PRODUCT_ID,
494
+ status: meters.length ? 'ok' : 'no_data',
495
+ meters,
496
+ fetchedAt: now,
497
+ dataAsOf: now,
498
+ }), token);
499
+ } catch (error) {
500
+ return attachCacheScope(quotaResult({ id: PRODUCT_ID, status: 'retryable_error',
501
+ message: error?.name === 'TimeoutError' ? 'Kimi usage request timed out' : 'Kimi usage request failed',
502
+ fetchedAt: now }), token);
503
+ }
504
+ }
@@ -0,0 +1,143 @@
1
+ import { attachCacheScope } from '../cache.js';
2
+ import { quotaResult } from '../schema.js';
3
+
4
+ const PRODUCT_ID = 'zcode';
5
+ const BIGMODEL_USAGE_URL = 'https://open.bigmodel.cn/api/monitor/usage/quota/limit';
6
+ const ZAI_USAGE_URL = 'https://api.z.ai/api/monitor/usage/quota/limit';
7
+
8
+ function credential(environment) {
9
+ const bigModelToken = environment.BIGMODEL_API_KEY?.trim();
10
+ if (bigModelToken) {
11
+ return {
12
+ token: bigModelToken,
13
+ region: 'bigmodel',
14
+ providerName: 'BigModel',
15
+ usageURL: BIGMODEL_USAGE_URL,
16
+ };
17
+ }
18
+ const zaiToken = environment.Z_AI_API_KEY?.trim();
19
+ if (zaiToken) {
20
+ return {
21
+ token: zaiToken,
22
+ region: 'zai',
23
+ providerName: 'Z.ai',
24
+ usageURL: ZAI_USAGE_URL,
25
+ };
26
+ }
27
+ return null;
28
+ }
29
+
30
+ function integer(value) {
31
+ return Number.isInteger(value) ? value : null;
32
+ }
33
+
34
+ function parseLimit(raw, now, index) {
35
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
36
+ if (!['TOKENS_LIMIT', 'CREDIT_LIMIT', 'TIME_LIMIT'].includes(raw.type)) return null;
37
+ const percentage = integer(raw.percentage);
38
+ const unit = integer(raw.unit);
39
+ const count = integer(raw.number);
40
+ if (percentage === null || unit === null || count === null) return null;
41
+
42
+ const usage = integer(raw.usage);
43
+ const current = integer(raw.currentValue);
44
+ const remaining = integer(raw.remaining);
45
+ let utilization = percentage;
46
+ if (usage !== null && usage > 0) {
47
+ let used = current;
48
+ if (remaining !== null) used = Math.max(usage - remaining, current ?? usage - remaining);
49
+ if (used !== null) utilization = used * 100 / usage;
50
+ }
51
+ utilization = Math.max(0, Math.min(100, utilization));
52
+
53
+ const minutesPerUnit = { 1: 1440, 3: 60, 5: 1, 6: 10080 };
54
+ let windowMinutes = count > 0 ? count * (minutesPerUnit[unit] || 0) : 0;
55
+ if (raw.type === 'TIME_LIMIT' && unit === 5 && count === 1) {
56
+ // The API uses this marker for the monthly MCP pool.
57
+ windowMinutes = 30 * 24 * 60;
58
+ }
59
+ const isFiveHour = raw.type !== 'TIME_LIMIT' && windowMinutes === 300;
60
+ const resetMillis = integer(raw.nextResetTime);
61
+ const plausibleReset = resetMillis !== null
62
+ && (!isFiveHour || resetMillis <= now.getTime() + (5 * 3600 + 60) * 1000);
63
+ const typeName = raw.type === 'TIME_LIMIT'
64
+ ? 'MCP'
65
+ : raw.type === 'CREDIT_LIMIT' ? 'Credits' : 'Tokens';
66
+ let label = typeName;
67
+ if (raw.type !== 'TIME_LIMIT' && windowMinutes === 300) label = '5h';
68
+ else if (raw.type !== 'TIME_LIMIT' && windowMinutes === 10080) label = '7d';
69
+ else if (raw.type !== 'TIME_LIMIT' && windowMinutes && windowMinutes % 1440 === 0) {
70
+ label = `${windowMinutes / 1440}d`;
71
+ }
72
+ const meter = {
73
+ id: `${index}-${raw.type.toLowerCase()}-${unit}-${count}`,
74
+ label,
75
+ utilization,
76
+ };
77
+ if (windowMinutes > 0) meter.windowSeconds = windowMinutes * 60;
78
+ if (plausibleReset) meter.resetsAt = new Date(resetMillis).toISOString();
79
+ return { meter, windowMinutes, type: raw.type };
80
+ }
81
+
82
+ export function parseZaiQuota(payload, now = new Date()) {
83
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)
84
+ || payload.success !== true || payload.code !== 200
85
+ || !payload.data || typeof payload.data !== 'object'
86
+ || !Array.isArray(payload.data.limits)) {
87
+ throw new Error('Invalid Z.ai quota response');
88
+ }
89
+ const parsed = payload.data.limits.map((raw, index) => parseLimit(raw, now, index)).filter(Boolean);
90
+ const planLimits = parsed
91
+ .filter(item => item.type === 'TOKENS_LIMIT' || item.type === 'CREDIT_LIMIT')
92
+ .sort((a, b) => (a.windowMinutes || Number.MAX_SAFE_INTEGER)
93
+ - (b.windowMinutes || Number.MAX_SAFE_INTEGER));
94
+ const mcp = parsed.filter(item => item.type === 'TIME_LIMIT').pop();
95
+ const ordered = [...planLimits];
96
+ if (mcp) ordered.push(mcp);
97
+ const planLabel = ['planName', 'plan', 'plan_type', 'packageName', 'level']
98
+ .map(key => payload.data[key])
99
+ .find(value => typeof value === 'string' && value.trim());
100
+ return { meters: ordered.map(item => item.meter), planLabel: planLabel?.trim() };
101
+ }
102
+
103
+ export async function fetchZaiQuota({
104
+ environment = process.env,
105
+ fetchImpl = globalThis.fetch,
106
+ usageURL,
107
+ now = new Date(),
108
+ timeoutMs = 10_000,
109
+ } = {}) {
110
+ const selected = credential(environment);
111
+ if (!selected) {
112
+ return quotaResult({ id: PRODUCT_ID, status: 'missing_credentials',
113
+ message: 'ZCode API key is not configured', fetchedAt: now });
114
+ }
115
+ const { token, region, providerName } = selected;
116
+ const endpoint = usageURL || selected.usageURL;
117
+ const cacheCredential = `${region}:${token}`;
118
+ try {
119
+ const response = await fetchImpl(endpoint, {
120
+ method: 'GET',
121
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
122
+ signal: AbortSignal.timeout(timeoutMs),
123
+ });
124
+ if (response.status === 401 || response.status === 403) {
125
+ return quotaResult({ id: PRODUCT_ID, status: 'unauthorized',
126
+ message: `${providerName} rejected the API key`, fetchedAt: now });
127
+ }
128
+ if (!response.ok) {
129
+ return attachCacheScope(quotaResult({ id: PRODUCT_ID, status: 'retryable_error',
130
+ message: `${providerName} quota API returned HTTP ${response.status}`, fetchedAt: now }),
131
+ cacheCredential);
132
+ }
133
+ const { meters, planLabel } = parseZaiQuota(await response.json(), now);
134
+ return attachCacheScope(quotaResult({ id: PRODUCT_ID, status: meters.length ? 'ok' : 'no_data',
135
+ meters, planLabel, fetchedAt: now, dataAsOf: now }), cacheCredential);
136
+ } catch (error) {
137
+ return attachCacheScope(quotaResult({ id: PRODUCT_ID, status: 'retryable_error',
138
+ message: error?.name === 'TimeoutError'
139
+ ? `${providerName} quota request timed out`
140
+ : `${providerName} quota request failed`,
141
+ fetchedAt: now }), cacheCredential);
142
+ }
143
+ }