@hk_net/pi-usage-bars 0.5.0 → 0.6.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.
@@ -1,1274 +1,1341 @@
1
- import * as fs from "node:fs";
2
- import * as os from "node:os";
3
- import * as path from "node:path";
4
-
5
- export type ProviderKey =
6
- | "codex"
7
- | "claude"
8
- | "zai"
9
- | "zai-cn"
10
- | "kimi"
11
- | "minimax"
12
- | "minimax-cn"
13
- | "openrouter"
14
- | "deepseek"
15
- | "moonshot"
16
- | "moonshot-cn";
17
- export type PiProviderId =
18
- | "openai-codex"
19
- | "anthropic"
20
- | "zai"
21
- | "zai-coding-cn"
22
- | "kimi-coding"
23
- | "minimax"
24
- | "minimax-cn"
25
- | "openrouter"
26
- | "deepseek"
27
- | "moonshotai"
28
- | "moonshotai-cn";
29
-
30
- export interface AccountBalance {
31
- amount: number;
32
- unit: string;
33
- label: string;
34
- }
35
-
36
- export interface AccountSpend {
37
- unit: string;
38
- daily?: number;
39
- weekly?: number;
40
- monthly?: number;
41
- lifetime?: number;
42
- }
43
-
44
- export interface UsageData {
45
- session: number;
46
- weekly: number;
47
- quotaHidden?: boolean;
48
- accountBalance?: AccountBalance;
49
- accountBalanceDetails?: AccountBalance[];
50
- accountSpend?: AccountSpend;
51
- sessionResetsIn?: string;
52
- weeklyResetsIn?: string;
53
- sessionResetsAt?: string;
54
- weeklyResetsAt?: string;
55
- extraSpend?: number;
56
- extraLimit?: number;
57
- sessionLabel?: string;
58
- weeklyLabel?: string;
59
- sessionHidden?: boolean;
60
- weeklyHidden?: boolean;
61
- notice?: string;
62
- warning?: string;
63
- stale?: boolean;
64
- fetchedAt?: number;
65
- error?: string;
66
- }
67
-
68
- export type UsageByProvider = Record<ProviderKey, UsageData | null>;
69
- export type UsageTokens = Partial<Record<ProviderKey, string>>;
70
-
71
- export interface UsageEndpoints {
72
- zai: string;
73
- zaiCn: string;
74
- kimi: string;
75
- minimax: string;
76
- minimaxLegacy: string;
77
- minimaxCn: string;
78
- minimaxCnLegacy: string;
79
- openRouterCredits: string;
80
- openRouterKey: string;
81
- deepSeekBalance: string;
82
- moonshotBalance: string;
83
- moonshotCnBalance: string;
84
- }
85
-
86
- export interface HeadersLike {
87
- get(name: string): string | null;
88
- }
89
-
90
- export interface FetchResponseLike {
91
- ok: boolean;
92
- status: number;
93
- headers?: HeadersLike;
94
- json(): Promise<unknown>;
95
- }
96
-
97
- export type FetchLike = (input: string, init?: RequestInit) => Promise<FetchResponseLike>;
98
-
99
- export interface RequestConfig {
100
- fetchFn?: FetchLike;
101
- timeoutMs?: number;
102
- signal?: AbortSignal;
103
- }
104
-
105
- export interface FetchConfig extends RequestConfig {
106
- endpoints?: UsageEndpoints;
107
- env?: NodeJS.ProcessEnv;
108
- }
109
-
110
- export interface FetchAllUsagesConfig extends FetchConfig {
111
- cacheFile?: string;
112
- nowMs?: number;
113
- }
114
-
115
- export interface ClaudeUsageFetchConfig extends RequestConfig {
116
- cacheFile?: string;
117
- nowMs?: number;
118
- }
119
-
120
- interface JsonRequestSuccess {
121
- ok: true;
122
- data: unknown;
123
- status: number;
124
- headers?: HeadersLike;
125
- }
126
-
127
- interface JsonRequestError {
128
- ok: false;
129
- error: string;
130
- status: number | null;
131
- headers?: HeadersLike;
132
- }
133
-
134
- type JsonRequestResult = JsonRequestSuccess | JsonRequestError;
135
-
136
- interface ClaudeUsageAttemptResult {
137
- usage: UsageData;
138
- status: number | null;
139
- retryAfterMs: number | null;
140
- }
141
-
142
- interface ClaudeUsageCacheState {
143
- lastSuccess?: UsageData;
144
- lastSuccessAt?: number;
145
- cooldownUntil?: number;
146
- consecutive429s?: number;
147
- lastError?: string;
148
- }
149
-
150
- interface UsageBarsCacheFile {
151
- version: 1;
152
- claude?: ClaudeUsageCacheState;
153
- }
154
-
155
- const DEFAULT_FETCH_TIMEOUT_MS = 12_000;
156
- const CLAUDE_SHARED_FRESH_TTL_MS = 2 * 60 * 1000;
157
- const CLAUDE_BASE_BACKOFF_MS = 2 * 60 * 1000;
158
- const CLAUDE_MAX_BACKOFF_MS = 30 * 60 * 1000;
159
- const CLAUDE_LOCK_WAIT_MS = 4_000;
160
- const CLAUDE_LOCK_POLL_MS = 125;
161
- const CLAUDE_LOCK_STALE_MS = 20_000;
162
-
163
- export const DEFAULT_USAGE_CACHE_FILE = path.join(os.tmpdir(), "pi", "usage-bars-cache.json");
164
- export const DEFAULT_ZAI_USAGE_ENDPOINT = "https://api.z.ai/api/monitor/usage/quota/limit";
165
- export const DEFAULT_ZAI_CN_USAGE_ENDPOINT = "https://open.bigmodel.cn/api/monitor/usage/quota/limit";
166
- export const DEFAULT_KIMI_USAGE_ENDPOINT = "https://api.kimi.com/coding/v1/usages";
167
- export const DEFAULT_MINIMAX_USAGE_ENDPOINT = "https://api.minimax.io/v1/token_plan/remains";
168
- export const DEFAULT_MINIMAX_LEGACY_USAGE_ENDPOINT = "https://api.minimax.io/v1/api/openplatform/coding_plan/remains";
169
- export const DEFAULT_MINIMAX_CN_USAGE_ENDPOINT = "https://api.minimaxi.com/v1/token_plan/remains";
170
- export const DEFAULT_MINIMAX_CN_LEGACY_USAGE_ENDPOINT = "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains";
171
- export const DEFAULT_OPENROUTER_CREDITS_ENDPOINT = "https://openrouter.ai/api/v1/credits";
172
- export const DEFAULT_OPENROUTER_KEY_ENDPOINT = "https://openrouter.ai/api/v1/key";
173
- export const DEFAULT_DEEPSEEK_BALANCE_ENDPOINT = "https://api.deepseek.com/user/balance";
174
- export const DEFAULT_MOONSHOT_BALANCE_ENDPOINT = "https://api.moonshot.ai/v1/users/me/balance";
175
- export const DEFAULT_MOONSHOT_CN_BALANCE_ENDPOINT = "https://api.moonshot.cn/v1/users/me/balance";
176
-
177
- export function resolveUsageEndpoints(env: NodeJS.ProcessEnv = process.env): UsageEndpoints {
178
- const configured = (value: string | undefined, fallback: string) => {
179
- const trimmed = value?.trim();
180
- return trimmed || fallback;
181
- };
182
-
183
- return {
184
- zai: configured(env.PI_ZAI_USAGE_ENDPOINT, DEFAULT_ZAI_USAGE_ENDPOINT),
185
- zaiCn: configured(env.PI_ZAI_CODING_CN_USAGE_ENDPOINT, DEFAULT_ZAI_CN_USAGE_ENDPOINT),
186
- kimi: configured(env.PI_KIMI_USAGE_ENDPOINT, DEFAULT_KIMI_USAGE_ENDPOINT),
187
- minimax: configured(env.PI_MINIMAX_USAGE_ENDPOINT, DEFAULT_MINIMAX_USAGE_ENDPOINT),
188
- minimaxLegacy: configured(env.PI_MINIMAX_LEGACY_USAGE_ENDPOINT, DEFAULT_MINIMAX_LEGACY_USAGE_ENDPOINT),
189
- minimaxCn: configured(env.PI_MINIMAX_CN_USAGE_ENDPOINT, DEFAULT_MINIMAX_CN_USAGE_ENDPOINT),
190
- minimaxCnLegacy: configured(env.PI_MINIMAX_CN_LEGACY_USAGE_ENDPOINT, DEFAULT_MINIMAX_CN_LEGACY_USAGE_ENDPOINT),
191
- openRouterCredits: configured(env.PI_OPENROUTER_CREDITS_ENDPOINT, DEFAULT_OPENROUTER_CREDITS_ENDPOINT),
192
- openRouterKey: configured(env.PI_OPENROUTER_KEY_ENDPOINT, DEFAULT_OPENROUTER_KEY_ENDPOINT),
193
- deepSeekBalance: configured(env.PI_DEEPSEEK_BALANCE_ENDPOINT, DEFAULT_DEEPSEEK_BALANCE_ENDPOINT),
194
- moonshotBalance: configured(env.PI_MOONSHOT_BALANCE_ENDPOINT, DEFAULT_MOONSHOT_BALANCE_ENDPOINT),
195
- moonshotCnBalance: configured(env.PI_MOONSHOT_CN_BALANCE_ENDPOINT, DEFAULT_MOONSHOT_CN_BALANCE_ENDPOINT),
196
- };
197
- }
198
-
199
- function toErrorMessage(error: unknown, externalSignal?: AbortSignal): string {
200
- if (error instanceof Error) {
201
- if (error.name === "AbortError") {
202
- return externalSignal?.aborted ? "request cancelled" : "request timeout";
203
- }
204
- return error.message || String(error);
205
- }
206
- return String(error);
207
- }
208
-
209
- function asObject(value: unknown): Record<string, unknown> | null {
210
- if (!value || typeof value !== "object") return null;
211
- return value as Record<string, unknown>;
212
- }
213
-
214
- function normalizeUsagePair(session: number, weekly: number): { session: number; weekly: number } {
215
- const clean = (value: number) => Number.isFinite(value) ? Number(value.toFixed(2)) : 0;
216
- return { session: clean(session), weekly: clean(weekly) };
217
- }
218
-
219
- function getHeader(headers: HeadersLike | undefined, name: string): string | null {
220
- if (!headers) return null;
221
- try {
222
- return headers.get(name);
223
- } catch {
224
- return null;
225
- }
226
- }
227
-
228
- function combineSignals(timeoutSignal: AbortSignal | undefined, externalSignal: AbortSignal | undefined): AbortSignal | undefined {
229
- if (timeoutSignal && externalSignal) return AbortSignal.any([timeoutSignal, externalSignal]);
230
- return timeoutSignal ?? externalSignal;
231
- }
232
-
233
- async function requestJson(url: string, init: RequestInit, config: RequestConfig = {}): Promise<JsonRequestResult> {
234
- const fetchFn = config.fetchFn ?? (fetch as unknown as FetchLike);
235
- const timeoutMs = config.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
236
- const timeoutController = timeoutMs > 0 ? new AbortController() : undefined;
237
- const timeout = timeoutController
238
- ? setTimeout(() => timeoutController.abort(), timeoutMs)
239
- : undefined;
240
- const signal = combineSignals(timeoutController?.signal, config.signal);
241
-
242
- try {
243
- if (config.signal?.aborted) {
244
- return { ok: false, error: "request cancelled", status: null };
245
- }
246
-
247
- const response = await fetchFn(url, { ...init, signal });
248
- if (!response.ok) {
249
- return { ok: false, error: `HTTP ${response.status}`, status: response.status, headers: response.headers };
250
- }
251
-
252
- try {
253
- return { ok: true, data: await response.json(), status: response.status, headers: response.headers };
254
- } catch {
255
- return { ok: false, error: "invalid JSON response", status: response.status, headers: response.headers };
256
- }
257
- } catch (error) {
258
- return { ok: false, error: toErrorMessage(error, config.signal), status: null };
259
- } finally {
260
- if (timeout !== undefined) clearTimeout(timeout);
261
- }
262
- }
263
-
264
- export function formatDuration(seconds: number): string {
265
- if (!Number.isFinite(seconds) || seconds <= 0) return "now";
266
- const days = Math.floor(seconds / 86400);
267
- const hours = Math.floor((seconds % 86400) / 3600);
268
- const minutes = Math.floor((seconds % 3600) / 60);
269
- if (days > 0 && hours > 0) return `${days}d ${hours}h`;
270
- if (days > 0) return `${days}d`;
271
- if (hours > 0 && minutes > 0) return `${hours}h ${minutes}m`;
272
- if (hours > 0) return `${hours}h`;
273
- if (minutes > 0) return `${minutes}m`;
274
- return "<1m";
275
- }
276
-
277
- export function formatResetsAt(isoDate: string, nowMs = Date.now()): string {
278
- const resetTime = new Date(isoDate).getTime();
279
- if (!Number.isFinite(resetTime)) return "";
280
- return formatDuration(Math.max(0, resetTime - nowMs) / 1000);
281
- }
282
-
283
- export function parseRetryAfterMs(value: string | null | undefined, nowMs = Date.now()): number | null {
284
- if (!value) return null;
285
- const numeric = Number(value);
286
- if (Number.isFinite(numeric) && numeric >= 0) return numeric * 1000;
287
- const dateMs = new Date(value).getTime();
288
- return Number.isFinite(dateMs) ? Math.max(0, dateMs - nowMs) : null;
289
- }
290
-
291
- function readUsageCache(cacheFile = DEFAULT_USAGE_CACHE_FILE): UsageBarsCacheFile {
292
- try {
293
- const parsed = JSON.parse(fs.readFileSync(cacheFile, "utf-8"));
294
- if (parsed?.version === 1 && typeof parsed === "object") return parsed as UsageBarsCacheFile;
295
- } catch {
296
- // Invalid or missing caches are treated as empty.
297
- }
298
- return { version: 1 };
299
- }
300
-
301
- function writeUsageCache(cache: UsageBarsCacheFile, cacheFile = DEFAULT_USAGE_CACHE_FILE): boolean {
302
- try {
303
- const directory = path.dirname(cacheFile);
304
- if (!fs.existsSync(directory)) fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
305
- const temporaryPath = `${cacheFile}.tmp-${process.pid}-${Date.now()}`;
306
- fs.writeFileSync(temporaryPath, JSON.stringify(cache, null, 2), { mode: 0o600 });
307
- fs.renameSync(temporaryPath, cacheFile);
308
- return true;
309
- } catch {
310
- return false;
311
- }
312
- }
313
-
314
- function sleep(ms: number, signal?: AbortSignal): Promise<void> {
315
- if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
316
- return new Promise((resolve, reject) => {
317
- const timer = setTimeout(resolve, ms);
318
- signal?.addEventListener("abort", () => {
319
- clearTimeout(timer);
320
- reject(new DOMException("Aborted", "AbortError"));
321
- }, { once: true });
322
- });
323
- }
324
-
325
- function ensureParentDir(filePath: string): void {
326
- const directory = path.dirname(filePath);
327
- if (!fs.existsSync(directory)) fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
328
- }
329
-
330
- function safeUnlink(filePath: string): void {
331
- try {
332
- fs.unlinkSync(filePath);
333
- } catch {
334
- // Ignore cleanup races.
335
- }
336
- }
337
-
338
- async function acquireFileLock(lockFile: string, signal?: AbortSignal): Promise<(() => void) | null> {
339
- ensureParentDir(lockFile);
340
- const startedAt = Date.now();
341
-
342
- while (Date.now() - startedAt <= CLAUDE_LOCK_WAIT_MS) {
343
- if (signal?.aborted) return null;
344
- try {
345
- const fd = fs.openSync(lockFile, "wx", 0o600);
346
- fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, createdAt: Date.now() }));
347
- fs.closeSync(fd);
348
- return () => safeUnlink(lockFile);
349
- } catch (error: unknown) {
350
- if ((error as NodeJS.ErrnoException)?.code !== "EEXIST") return null;
351
- try {
352
- const stat = fs.statSync(lockFile);
353
- if (Date.now() - stat.mtimeMs >= CLAUDE_LOCK_STALE_MS) {
354
- safeUnlink(lockFile);
355
- continue;
356
- }
357
- } catch {
358
- continue;
359
- }
360
- try {
361
- await sleep(CLAUDE_LOCK_POLL_MS, signal);
362
- } catch {
363
- return null;
364
- }
365
- }
366
- }
367
-
368
- return null;
369
- }
370
-
371
- export function readPercentCandidate(value: unknown): number | null {
372
- if (typeof value !== "number" || !Number.isFinite(value)) return null;
373
- if (value >= 0 && value <= 1) return Number.isInteger(value) ? value : value * 100;
374
- return value >= 0 && value <= 100 ? value : null;
375
- }
376
-
377
- export function readLimitPercent(limit: unknown): number | null {
378
- const value = asObject(limit);
379
- const direct = [
380
- value?.percentage,
381
- value?.utilization,
382
- value?.used_percent,
383
- value?.usedPercent,
384
- value?.usagePercent,
385
- value?.usage_percent,
386
- ].map(readPercentCandidate).find((candidate) => candidate !== null);
387
- if (direct !== undefined) return direct;
388
-
389
- const current = typeof value?.currentValue === "number" ? value.currentValue : null;
390
- const remaining = typeof value?.remaining === "number" ? value.remaining : null;
391
- if (current !== null && remaining !== null && current + remaining > 0) {
392
- return (current / (current + remaining)) * 100;
393
- }
394
- return null;
395
- }
396
-
397
- export function extractUsageFromPayload(payload: unknown): { session: number; weekly: number } | null {
398
- const data = payload as any;
399
- const limitArrays = [data?.data?.limits, data?.limits, data?.quota?.limits, data?.data?.quota?.limits];
400
- const limits = limitArrays.find(Array.isArray) as unknown[] | undefined;
401
-
402
- if (limits) {
403
- const byType = (types: string[]) => limits.find((entry) => {
404
- const type = String((entry as any)?.type || "").toUpperCase();
405
- return types.includes(type);
406
- });
407
- const session = readLimitPercent(byType(["TIME_LIMIT", "SESSION_LIMIT", "REQUEST_LIMIT", "RPM_LIMIT", "RPD_LIMIT"]));
408
- const weekly = readLimitPercent(byType(["TOKENS_LIMIT", "TOKEN_LIMIT", "WEEK_LIMIT", "WEEKLY_LIMIT", "TPM_LIMIT", "DAILY_LIMIT"]));
409
- if (session !== null && weekly !== null) return normalizeUsagePair(session, weekly);
410
- }
411
-
412
- const sessionCandidates = [
413
- data?.session,
414
- data?.sessionPercent,
415
- data?.session_percent,
416
- data?.five_hour?.utilization,
417
- data?.rate_limit?.primary_window?.used_percent,
418
- data?.limits?.session?.utilization,
419
- data?.usage?.session,
420
- data?.data?.session,
421
- data?.data?.sessionPercent,
422
- data?.data?.session_percent,
423
- data?.data?.usage?.session,
424
- data?.quota?.session?.percentage,
425
- data?.data?.quota?.session?.percentage,
426
- ];
427
- const weeklyCandidates = [
428
- data?.weekly,
429
- data?.weeklyPercent,
430
- data?.weekly_percent,
431
- data?.seven_day?.utilization,
432
- data?.rate_limit?.secondary_window?.used_percent,
433
- data?.limits?.weekly?.utilization,
434
- data?.usage?.weekly,
435
- data?.data?.weekly,
436
- data?.data?.weeklyPercent,
437
- data?.data?.weekly_percent,
438
- data?.data?.usage?.weekly,
439
- data?.quota?.weekly?.percentage,
440
- data?.data?.quota?.weekly?.percentage,
441
- data?.quota?.daily?.percentage,
442
- data?.data?.quota?.daily?.percentage,
443
- ];
444
-
445
- const session = sessionCandidates.map(readPercentCandidate).find((candidate) => candidate !== null);
446
- const weekly = weeklyCandidates.map(readPercentCandidate).find((candidate) => candidate !== null);
447
- return session === undefined || weekly === undefined ? null : normalizeUsagePair(session, weekly);
448
- }
449
-
450
- function hydrateUsageResets(usage: UsageData, nowMs = Date.now()): UsageData {
451
- return {
452
- ...usage,
453
- sessionResetsIn: usage.sessionResetsAt ? formatResetsAt(usage.sessionResetsAt, nowMs) : usage.sessionResetsIn,
454
- weeklyResetsIn: usage.weeklyResetsAt ? formatResetsAt(usage.weeklyResetsAt, nowMs) : usage.weeklyResetsIn,
455
- };
456
- }
457
-
458
- function snapshotUsage(usage: UsageData, nowMs = Date.now()): UsageData {
459
- return {
460
- session: usage.session,
461
- weekly: usage.weekly,
462
- quotaHidden: usage.quotaHidden,
463
- accountBalance: usage.accountBalance,
464
- accountBalanceDetails: usage.accountBalanceDetails,
465
- accountSpend: usage.accountSpend,
466
- sessionResetsAt: usage.sessionResetsAt,
467
- weeklyResetsAt: usage.weeklyResetsAt,
468
- sessionResetsIn: usage.sessionResetsIn,
469
- weeklyResetsIn: usage.weeklyResetsIn,
470
- extraSpend: usage.extraSpend,
471
- extraLimit: usage.extraLimit,
472
- sessionLabel: usage.sessionLabel,
473
- weeklyLabel: usage.weeklyLabel,
474
- sessionHidden: usage.sessionHidden,
475
- weeklyHidden: usage.weeklyHidden,
476
- notice: usage.notice,
477
- fetchedAt: usage.fetchedAt ?? nowMs,
478
- };
479
- }
480
-
481
- function staleCachedUsage(cached: UsageData, warning: string, nowMs = Date.now()): UsageData {
482
- return { ...hydrateUsageResets(snapshotUsage(cached, nowMs), nowMs), stale: true, warning };
483
- }
484
-
485
- function readClaudeCacheState(cacheFile = DEFAULT_USAGE_CACHE_FILE): ClaudeUsageCacheState {
486
- return readUsageCache(cacheFile).claude ?? {};
487
- }
488
-
489
- function writeClaudeCacheState(state: ClaudeUsageCacheState, cacheFile = DEFAULT_USAGE_CACHE_FILE): boolean {
490
- const cache = readUsageCache(cacheFile);
491
- cache.claude = state;
492
- return writeUsageCache(cache, cacheFile);
493
- }
494
-
495
- function clearClaudeCooldown(state: ClaudeUsageCacheState): ClaudeUsageCacheState {
496
- return { ...state, cooldownUntil: undefined, consecutive429s: 0, lastError: undefined };
497
- }
498
-
499
- function computeClaudeBackoffMs(state: ClaudeUsageCacheState, retryAfterMs: number | null): number {
500
- if (retryAfterMs !== null && retryAfterMs > 0) {
501
- return Math.min(CLAUDE_MAX_BACKOFF_MS, Math.max(CLAUDE_BASE_BACKOFF_MS, retryAfterMs));
502
- }
503
- const count = Math.max(1, state.consecutive429s ?? 0);
504
- return Math.min(CLAUDE_MAX_BACKOFF_MS, CLAUDE_BASE_BACKOFF_MS * 2 ** Math.max(0, count - 1));
505
- }
506
-
507
- function cooldownMessage(untilMs: number, nowMs = Date.now()): string {
508
- return `rate limited; retry in ${formatDuration(Math.max(0, untilMs - nowMs) / 1000)}`;
509
- }
510
-
511
- function readClaudeCacheOutcome(cacheFile = DEFAULT_USAGE_CACHE_FILE, nowMs = Date.now()): UsageData | null {
512
- const state = readClaudeCacheState(cacheFile);
513
- if (state.cooldownUntil && state.cooldownUntil > nowMs) {
514
- const warning = cooldownMessage(state.cooldownUntil, nowMs);
515
- return state.lastSuccess
516
- ? staleCachedUsage(state.lastSuccess, warning, nowMs)
517
- : { session: 0, weekly: 0, error: warning };
518
- }
519
- if (state.lastSuccess && state.lastSuccessAt && nowMs - state.lastSuccessAt <= CLAUDE_SHARED_FRESH_TTL_MS) {
520
- return hydrateUsageResets(snapshotUsage(state.lastSuccess, state.lastSuccessAt), nowMs);
521
- }
522
- return null;
523
- }
524
-
525
- export function parseCodexRateLimit(data: any): UsageData {
526
- const rateLimit = data?.rate_limit ?? data?.rate_limits;
527
- const primary = rateLimit?.primary_window ?? rateLimit?.primary ?? rateLimit?.five_hour;
528
- const secondary = rateLimit?.secondary_window ?? rateLimit?.secondary ?? rateLimit?.weekly;
529
-
530
- let sessionWindow: any = null;
531
- let weeklyWindow: any = null;
532
- for (const [position, window] of [["primary", primary], ["secondary", secondary]] as const) {
533
- if (!window || typeof window !== "object") continue;
534
- const duration = window.limit_window_seconds;
535
- if (typeof duration === "number" && Number.isFinite(duration)) {
536
- // Some Codex accounts return their seven-day quota as primary_window
537
- // and omit secondary_window, so position alone does not identify it.
538
- if (duration >= 2 * 24 * 60 * 60) weeklyWindow ??= window;
539
- else sessionWindow ??= window;
540
- } else if (position === "primary") {
541
- sessionWindow ??= window;
542
- } else {
543
- weeklyWindow ??= window;
544
- }
545
- }
546
-
547
- const reset = (window: any) =>
548
- typeof window?.reset_after_seconds === "number" ? formatDuration(window.reset_after_seconds) : undefined;
549
-
550
- return {
551
- session: readPercentCandidate(sessionWindow?.used_percent) ?? 0,
552
- weekly: readPercentCandidate(weeklyWindow?.used_percent) ?? 0,
553
- ...(!sessionWindow ? { sessionHidden: true } : {}),
554
- ...(!weeklyWindow ? { weeklyHidden: true } : {}),
555
- sessionResetsIn: reset(sessionWindow),
556
- weeklyResetsIn: reset(weeklyWindow),
557
- };
558
- }
559
-
560
- export async function fetchCodexUsage(token: string, config: RequestConfig = {}): Promise<UsageData> {
561
- const result = await requestJson(
562
- "https://chatgpt.com/backend-api/wham/usage",
563
- { headers: { Authorization: `Bearer ${token}` } },
564
- config,
565
- );
566
- if (!result.ok) return { session: 0, weekly: 0, error: result.error };
567
- return parseCodexRateLimit(result.data);
568
- }
569
-
570
- async function fetchClaudeUsageAttempt(
571
- token: string,
572
- config: RequestConfig = {},
573
- nowMs = Date.now(),
574
- ): Promise<ClaudeUsageAttemptResult> {
575
- const result = await requestJson(
576
- "https://api.anthropic.com/api/oauth/usage",
577
- {
578
- headers: {
579
- Authorization: `Bearer ${token}`,
580
- "anthropic-beta": "oauth-2025-04-20",
581
- },
582
- },
583
- config,
584
- );
585
- const retryAfterMs = parseRetryAfterMs(getHeader(result.headers, "retry-after"), nowMs);
586
- if (!result.ok) {
587
- return { usage: { session: 0, weekly: 0, error: result.error }, status: result.status, retryAfterMs };
588
- }
589
-
590
- const data = result.data as any;
591
- const usage: UsageData = hydrateUsageResets({
592
- session: readPercentCandidate(data?.five_hour?.utilization) ?? 0,
593
- weekly: readPercentCandidate(data?.seven_day?.utilization) ?? 0,
594
- sessionResetsAt: typeof data?.five_hour?.resets_at === "string" ? data.five_hour.resets_at : undefined,
595
- weeklyResetsAt: typeof data?.seven_day?.resets_at === "string" ? data.seven_day.resets_at : undefined,
596
- fetchedAt: nowMs,
597
- }, nowMs);
598
-
599
- if (data?.extra_usage?.is_enabled) {
600
- usage.extraSpend = typeof data.extra_usage.used_credits === "number" ? data.extra_usage.used_credits : undefined;
601
- usage.extraLimit = typeof data.extra_usage.monthly_limit === "number" ? data.extra_usage.monthly_limit : undefined;
602
- }
603
- return { usage, status: result.status, retryAfterMs };
604
- }
605
-
606
- export async function fetchClaudeUsage(token: string, config: RequestConfig = {}): Promise<UsageData> {
607
- return (await fetchClaudeUsageAttempt(token, config)).usage;
608
- }
609
-
610
- export async function fetchClaudeUsageWithFallback(
611
- token: string,
612
- config: ClaudeUsageFetchConfig = {},
613
- ): Promise<UsageData> {
614
- const cacheFile = config.cacheFile ?? DEFAULT_USAGE_CACHE_FILE;
615
- const nowMs = config.nowMs ?? Date.now();
616
- const cachedOutcome = readClaudeCacheOutcome(cacheFile, nowMs);
617
- if (cachedOutcome) return cachedOutcome;
618
- if (config.signal?.aborted) return { session: 0, weekly: 0, error: "request cancelled" };
619
-
620
- const lockFile = `${cacheFile}.claude.lock`;
621
- const releaseLock = await acquireFileLock(lockFile, config.signal);
622
- if (!releaseLock) {
623
- const waitedOutcome = readClaudeCacheOutcome(cacheFile, nowMs);
624
- if (waitedOutcome) return waitedOutcome;
625
- if (config.signal?.aborted) return { session: 0, weekly: 0, error: "request cancelled" };
626
- }
627
-
628
- try {
629
- const lockOutcome = readClaudeCacheOutcome(cacheFile, nowMs);
630
- if (lockOutcome) return lockOutcome;
631
-
632
- let state = readClaudeCacheState(cacheFile);
633
- const attempt = await fetchClaudeUsageAttempt(token, config, nowMs);
634
- if (!attempt.usage.error) {
635
- state = clearClaudeCooldown(state);
636
- state.lastSuccess = snapshotUsage(attempt.usage, nowMs);
637
- state.lastSuccessAt = nowMs;
638
- writeClaudeCacheState(state, cacheFile);
639
- return attempt.usage;
640
- }
641
-
642
- if (attempt.status === 429) {
643
- const consecutive429s = Math.max(1, (state.consecutive429s ?? 0) + 1);
644
- const cooldownUntil = nowMs + computeClaudeBackoffMs({ ...state, consecutive429s }, attempt.retryAfterMs);
645
- state = { ...state, cooldownUntil, consecutive429s, lastError: attempt.usage.error };
646
- writeClaudeCacheState(state, cacheFile);
647
- return state.lastSuccess
648
- ? staleCachedUsage(state.lastSuccess, cooldownMessage(cooldownUntil, nowMs), nowMs)
649
- : { session: 0, weekly: 0, error: `${attempt.usage.error}; ${cooldownMessage(cooldownUntil, nowMs)}` };
650
- }
651
-
652
- return attempt.usage;
653
- } finally {
654
- releaseLock?.();
655
- }
656
- }
657
-
658
- function readNumber(value: unknown): number | null {
659
- if (typeof value === "number" && Number.isFinite(value)) return value;
660
- if (typeof value === "string" && value.trim()) {
661
- const parsed = Number(value);
662
- if (Number.isFinite(parsed)) return parsed;
663
- }
664
- return null;
665
- }
666
-
667
- function usedPercentFromCounts(
668
- value: Record<string, unknown> | null | undefined,
669
- options: { remainingPercent?: string; used?: string; total?: string; remaining?: string } = {},
670
- ): number | null {
671
- if (!value) return null;
672
- const remainingPercent = readNumber(value[options.remainingPercent ?? "remaining_percent"]);
673
- if (remainingPercent !== null) return Math.max(0, Math.min(100, 100 - remainingPercent));
674
-
675
- const total = readNumber(value[options.total ?? "limit"]);
676
- const used = readNumber(value[options.used ?? "used"]);
677
- const remaining = readNumber(value[options.remaining ?? "remaining"]);
678
- if (total === null || total <= 0) return null;
679
- if (used !== null) return Math.max(0, Math.min(100, used / total * 100));
680
- if (remaining !== null) return Math.max(0, Math.min(100, (total - remaining) / total * 100));
681
- return null;
682
- }
683
-
684
- function normalizeIsoDate(value: unknown): string | undefined {
685
- if (typeof value !== "string" || !value.trim()) return undefined;
686
- const normalized = value.trim().replace(/(\.\d{3})\d+(?=Z|[+-]\d\d:\d\d$)/, "$1");
687
- return Number.isFinite(new Date(normalized).getTime()) ? normalized : undefined;
688
- }
689
-
690
- function isoFromEpoch(value: unknown): string | undefined {
691
- const raw = readNumber(value);
692
- if (raw === null || raw <= 0) return undefined;
693
- const milliseconds = raw > 1_000_000_000_000 ? raw : raw * 1000;
694
- const date = new Date(milliseconds);
695
- return Number.isFinite(date.getTime()) ? date.toISOString() : undefined;
696
- }
697
-
698
- function resetFromRemains(value: unknown, nowMs: number): string | undefined {
699
- const raw = readNumber(value);
700
- if (raw === null || raw <= 0) return undefined;
701
- const milliseconds = raw > 1_000_000 ? raw : raw * 1000;
702
- return new Date(nowMs + milliseconds).toISOString();
703
- }
704
-
705
- export function extractKimiUsageFromPayload(payload: unknown, nowMs = Date.now()): UsageData | null {
706
- const root = asObject(payload);
707
- if (!root) return null;
708
- const webUsages = Array.isArray(root.usages) ? root.usages : undefined;
709
- const codingUsage = webUsages?.map(asObject).find((entry) =>
710
- String(entry?.scope ?? "").toUpperCase() === "FEATURE_CODING") ?? root;
711
- const dataRows = Array.isArray(codingUsage.data) ? codingUsage.data.map(asObject).filter(Boolean) : [];
712
- const usage = asObject(codingUsage.usage) ?? asObject(codingUsage.detail) ??
713
- dataRows.find((entry) => String(entry?.model_name ?? entry?.modelName ?? "").toLowerCase() === "all");
714
- const limits = Array.isArray(codingUsage.limits)
715
- ? codingUsage.limits
716
- : dataRows.filter((entry) => entry !== usage);
717
- const sessionLimit = limits.map(asObject).find((entry) => {
718
- const window = asObject(entry?.window);
719
- const duration = readNumber(window?.duration);
720
- const unit = String(window?.timeUnit ?? window?.time_unit ?? "").toUpperCase();
721
- return duration === 300 && unit.includes("MINUTE");
722
- }) ?? limits.map(asObject).find((entry) => entry !== null);
723
- const sessionDetail = asObject(sessionLimit?.detail) ?? sessionLimit;
724
-
725
- const session = usedPercentFromCounts(sessionDetail);
726
- const weekly = usedPercentFromCounts(usage);
727
- if (session === null || weekly === null) return null;
728
-
729
- const sessionReset = normalizeIsoDate(sessionDetail?.resetTime ?? sessionDetail?.reset_at ?? sessionDetail?.reset_time);
730
- const weeklyReset = normalizeIsoDate(usage?.resetTime ?? usage?.reset_at ?? usage?.reset_time);
731
- return hydrateUsageResets({
732
- ...normalizeUsagePair(session, weekly),
733
- sessionLabel: "5-hour",
734
- weeklyLabel: "Weekly",
735
- sessionResetsAt: sessionReset,
736
- weeklyResetsAt: weeklyReset,
737
- }, nowMs);
738
- }
739
-
740
- export async function fetchKimiUsage(token: string, config: FetchConfig = {}): Promise<UsageData> {
741
- const endpoints = config.endpoints ?? resolveUsageEndpoints(config.env);
742
- const result = await requestJson(endpoints.kimi, {
743
- headers: {
744
- Authorization: `Bearer ${token}`,
745
- "User-Agent": "KimiCLI/1.5",
746
- },
747
- }, config);
748
- if (!result.ok) return { session: 0, weekly: 0, error: result.error };
749
- return extractKimiUsageFromPayload(result.data) ?? {
750
- session: 0,
751
- weekly: 0,
752
- error: "unrecognized response shape",
753
- };
754
- }
755
-
756
- interface MiniMaxWindow {
757
- percent: number;
758
- resetsAt?: string;
759
- }
760
-
761
- function pickHighestWindow(windows: MiniMaxWindow[]): MiniMaxWindow | undefined {
762
- return windows.reduce<MiniMaxWindow | undefined>((highest, window) =>
763
- !highest || window.percent > highest.percent ? window : highest, undefined);
764
- }
765
-
766
- function miniMaxResetAt(value: Record<string, unknown>, prefix: "current" | "weekly", nowMs: number): string | undefined {
767
- const end = prefix === "current"
768
- ? value.end_time ?? value.endTime
769
- : value.weekly_end_time ?? value.weeklyEndTime;
770
- const remains = prefix === "current"
771
- ? value.remains_time ?? value.remainsTime
772
- : value.weekly_remains_time ?? value.weeklyRemainsTime;
773
- const resetsAt = prefix === "current"
774
- ? value.current_resets_at ?? value.currentResetsAt
775
- : value.weekly_resets_at ?? value.weeklyResetsAt;
776
- return normalizeIsoDate(resetsAt) ?? isoFromEpoch(end) ?? resetFromRemains(remains, nowMs);
777
- }
778
-
779
- function extractMiniMaxCreditBalance(payload: unknown): AccountBalance | undefined {
780
- const root = asObject(payload);
781
- const data = asObject(root?.data) ?? root;
782
- if (!data) return undefined;
783
- const amount = readNumber(
784
- data.points_balance ?? data.pointsBalance ??
785
- data.point_balance ?? data.pointBalance ??
786
- data.credits_balance ?? data.creditsBalance ??
787
- data.credit_balance ?? data.creditBalance,
788
- );
789
- return amount === null ? undefined : { amount, unit: "credits", label: "Credit balance" };
790
- }
791
-
792
- export function extractMiniMaxUsageFromPayload(payload: unknown, nowMs = Date.now()): UsageData | null {
793
- const root = asObject(payload);
794
- const data = asObject(root?.data) ?? root;
795
- if (!data) return null;
796
- const accountBalance = extractMiniMaxCreditBalance(payload);
797
-
798
- const intervalWindows: MiniMaxWindow[] = [];
799
- const weeklyWindows: MiniMaxWindow[] = [];
800
- if (Array.isArray(data.services)) {
801
- for (const rawService of data.services) {
802
- const service = asObject(rawService);
803
- if (!service) continue;
804
- const directPercent = readPercentCandidate(readNumber(service.percent));
805
- const percent = directPercent ?? usedPercentFromCounts(service, { total: "limit", used: "usage" });
806
- if (percent === null) continue;
807
- const windowType = String(service.window_type ?? service.windowType ?? "").toLowerCase();
808
- const resetsAt = normalizeIsoDate(service.resets_at ?? service.reset_time ?? service.end_time);
809
- (windowType.includes("week") ? weeklyWindows : intervalWindows).push({ percent, resetsAt });
810
- }
811
- }
812
-
813
- if (Array.isArray(data.model_remains ?? data.modelRemains)) {
814
- for (const rawModel of (data.model_remains ?? data.modelRemains) as unknown[]) {
815
- const raw = asObject(rawModel);
816
- if (!raw) continue;
817
- const model: Record<string, unknown> = {
818
- ...raw,
819
- current_interval_remaining_percent:
820
- raw.current_interval_remaining_percent ?? raw.currentIntervalRemainingPercent,
821
- current_interval_total_count: raw.current_interval_total_count ?? raw.currentIntervalTotalCount,
822
- current_interval_usage_count: raw.current_interval_usage_count ?? raw.currentIntervalUsageCount,
823
- current_interval_status: raw.current_interval_status ?? raw.currentIntervalStatus,
824
- current_weekly_remaining_percent:
825
- raw.current_weekly_remaining_percent ?? raw.currentWeeklyRemainingPercent,
826
- current_weekly_total_count: raw.current_weekly_total_count ?? raw.currentWeeklyTotalCount,
827
- current_weekly_usage_count: raw.current_weekly_usage_count ?? raw.currentWeeklyUsageCount,
828
- current_weekly_status: raw.current_weekly_status ?? raw.currentWeeklyStatus,
829
- };
830
- const unavailable = (prefix: "interval" | "weekly") =>
831
- readNumber(model[`current_${prefix}_status`]) === 3 &&
832
- (readNumber(model[`current_${prefix}_remaining_percent`]) ?? 0) >= 100 &&
833
- (readNumber(model[`current_${prefix}_total_count`]) ?? 0) === 0 &&
834
- (readNumber(model[`current_${prefix}_usage_count`]) ?? 0) === 0;
835
- const interval = unavailable("interval") ? null : usedPercentFromCounts(model, {
836
- remainingPercent: "current_interval_remaining_percent",
837
- total: "current_interval_total_count",
838
- remaining: "current_interval_usage_count",
839
- });
840
- if (interval !== null) {
841
- intervalWindows.push({ percent: interval, resetsAt: miniMaxResetAt(model, "current", nowMs) });
842
- }
843
- const weekly = unavailable("weekly") ? null : usedPercentFromCounts(model, {
844
- remainingPercent: "current_weekly_remaining_percent",
845
- total: "current_weekly_total_count",
846
- remaining: "current_weekly_usage_count",
847
- });
848
- if (weekly !== null) {
849
- weeklyWindows.push({ percent: weekly, resetsAt: miniMaxResetAt(model, "weekly", nowMs) });
850
- }
851
- }
852
- }
853
-
854
- const session = pickHighestWindow(intervalWindows);
855
- const weekly = pickHighestWindow(weeklyWindows);
856
- if (!session) {
857
- return accountBalance
858
- ? { session: 0, weekly: 0, quotaHidden: true, accountBalance }
859
- : null;
860
- }
861
- return hydrateUsageResets({
862
- session: Number(session.percent.toFixed(2)),
863
- accountBalance,
864
- weekly: Number((weekly?.percent ?? 0).toFixed(2)),
865
- sessionLabel: "Interval",
866
- weeklyLabel: "Weekly",
867
- weeklyHidden: !weekly,
868
- sessionResetsAt: session.resetsAt,
869
- weeklyResetsAt: weekly?.resetsAt,
870
- }, nowMs);
871
- }
872
-
873
- function miniMaxPayloadStatus(payload: unknown): number | null {
874
- const root = asObject(payload);
875
- const data = asObject(root?.data);
876
- const baseResponse = asObject(data?.base_resp ?? data?.baseResp ?? root?.base_resp ?? root?.baseResp);
877
- return readNumber(baseResponse?.status_code ?? baseResponse?.statusCode);
878
- }
879
-
880
- function miniMaxPayloadError(payload: unknown): string | null {
881
- const root = asObject(payload);
882
- const data = asObject(root?.data);
883
- const baseResponse = asObject(data?.base_resp ?? data?.baseResp ?? root?.base_resp ?? root?.baseResp);
884
- const status = miniMaxPayloadStatus(payload);
885
- if (status === null || status === 0) return null;
886
- const message = baseResponse?.status_msg ?? baseResponse?.statusMessage;
887
- return typeof message === "string" && message.trim()
888
- ? `API ${status}: ${message.trim()}`
889
- : `API ${status}`;
890
- }
891
-
892
- export async function fetchMiniMaxUsage(
893
- token: string,
894
- provider: "minimax" | "minimax-cn" = "minimax",
895
- config: FetchConfig = {},
896
- ): Promise<UsageData> {
897
- const endpoints = config.endpoints ?? resolveUsageEndpoints(config.env);
898
- const candidates = provider === "minimax-cn"
899
- ? [endpoints.minimaxCn, endpoints.minimaxCnLegacy]
900
- : [endpoints.minimax, endpoints.minimaxLegacy];
901
- let lastError = "usage request failed";
902
- let credentialError: string | undefined;
903
- let noActiveTokenPlan = false;
904
-
905
- for (const endpoint of [...new Set(candidates)]) {
906
- const result = await requestJson(endpoint, {
907
- headers: {
908
- Authorization: `Bearer ${token}`,
909
- Accept: "application/json",
910
- },
911
- }, config);
912
- if (!result.ok) {
913
- lastError = result.error;
914
- if (result.status === 401 || result.status === 403) credentialError ??= result.error;
915
- if (config.signal?.aborted) break;
916
- continue;
917
- }
918
- const payloadStatus = miniMaxPayloadStatus(result.data);
919
- const payloadError = miniMaxPayloadError(result.data);
920
- const usage = extractMiniMaxUsageFromPayload(result.data);
921
- if (usage && (!payloadError || usage.quotaHidden)) return usage;
922
- if (payloadStatus === 2062) {
923
- noActiveTokenPlan = true;
924
- continue;
925
- }
926
- if (payloadError) {
927
- lastError = payloadError;
928
- continue;
929
- }
930
- lastError = "unrecognized response shape";
931
- }
932
-
933
- if (noActiveTokenPlan) {
934
- return {
935
- session: 0,
936
- weekly: 0,
937
- quotaHidden: true,
938
- notice: "No active Token Plan · check Credit balance in the MiniMax console",
939
- };
940
- }
941
- return { session: 0, weekly: 0, error: credentialError ?? lastError };
942
- }
943
-
944
- export function extractOpenRouterUsageFromPayloads(
945
- creditsPayload: unknown,
946
- keyPayload: unknown,
947
- ): UsageData | null {
948
- const credits = asObject(asObject(creditsPayload)?.data) ?? asObject(creditsPayload);
949
- const key = asObject(asObject(keyPayload)?.data) ?? asObject(keyPayload);
950
-
951
- const totalCredits = readNumber(credits?.total_credits ?? credits?.totalCredits);
952
- const totalUsage = readNumber(credits?.total_usage ?? credits?.totalUsage);
953
- const accountBalance = totalCredits !== null && totalUsage !== null
954
- ? {
955
- amount: Number((totalCredits - totalUsage).toFixed(6)),
956
- unit: "USD",
957
- label: "Balance",
958
- }
959
- : undefined;
960
-
961
- const spendValues = {
962
- daily: readNumber(key?.usage_daily ?? key?.usageDaily),
963
- weekly: readNumber(key?.usage_weekly ?? key?.usageWeekly),
964
- monthly: readNumber(key?.usage_monthly ?? key?.usageMonthly),
965
- lifetime: readNumber(key?.usage),
966
- };
967
- const accountSpend = Object.values(spendValues).some((value) => value !== null)
968
- ? {
969
- unit: "USD",
970
- daily: spendValues.daily ?? undefined,
971
- weekly: spendValues.weekly ?? undefined,
972
- monthly: spendValues.monthly ?? undefined,
973
- lifetime: spendValues.lifetime ?? undefined,
974
- }
975
- : undefined;
976
-
977
- const limit = readNumber(key?.limit);
978
- const remaining = readNumber(key?.limit_remaining ?? key?.limitRemaining);
979
- const limitUsed = limit !== null && limit > 0 && remaining !== null
980
- ? Math.max(0, Math.min(limit, limit - remaining))
981
- : null;
982
- const limitPercent = limitUsed !== null && limit !== null ? limitUsed / limit * 100 : null;
983
- if (!accountBalance && !accountSpend && limitPercent === null) return null;
984
-
985
- return {
986
- session: limitPercent === null ? 0 : Number(limitPercent.toFixed(2)),
987
- weekly: 0,
988
- quotaHidden: limitPercent === null,
989
- weeklyHidden: true,
990
- sessionLabel: "Key limit",
991
- accountBalance,
992
- accountSpend,
993
- };
994
- }
995
-
996
- export async function fetchOpenRouterUsage(token: string, config: FetchConfig = {}): Promise<UsageData> {
997
- const endpoints = config.endpoints ?? resolveUsageEndpoints(config.env);
998
- const headers = { Authorization: `Bearer ${token}`, Accept: "application/json" };
999
- const [creditsResult, keyResult] = await Promise.all([
1000
- requestJson(endpoints.openRouterCredits, { headers }, config),
1001
- requestJson(endpoints.openRouterKey, { headers }, config),
1002
- ]);
1003
- const usage = extractOpenRouterUsageFromPayloads(
1004
- creditsResult.ok ? creditsResult.data : undefined,
1005
- keyResult.ok ? keyResult.data : undefined,
1006
- );
1007
- if (usage) return usage;
1008
-
1009
- const errors = [
1010
- creditsResult.ok ? undefined : `credits: ${creditsResult.error}`,
1011
- keyResult.ok ? undefined : `key: ${keyResult.error}`,
1012
- ].filter((value): value is string => Boolean(value));
1013
- return {
1014
- session: 0,
1015
- weekly: 0,
1016
- error: errors.length > 0 ? errors.join("; ") : "unrecognized response shape",
1017
- };
1018
- }
1019
-
1020
- export function extractDeepSeekBalanceFromPayload(payload: unknown): UsageData | null {
1021
- const root = asObject(payload);
1022
- const rawBalances = Array.isArray(root?.balance_infos) ? root.balance_infos : [];
1023
- const balances = rawBalances.map(asObject).filter((value): value is Record<string, unknown> => value !== null);
1024
- if (balances.length === 0) return null;
1025
-
1026
- const parsed = balances.flatMap((balance) => {
1027
- const unit = typeof balance.currency === "string" ? balance.currency.toUpperCase() : "USD";
1028
- const total = readNumber(balance.total_balance ?? balance.totalBalance);
1029
- if (total === null) return [];
1030
- return [{
1031
- total: { amount: total, unit, label: "Total balance" } satisfies AccountBalance,
1032
- toppedUp: readNumber(balance.topped_up_balance ?? balance.toppedUpBalance),
1033
- granted: readNumber(balance.granted_balance ?? balance.grantedBalance),
1034
- }];
1035
- });
1036
- const primary = parsed[0];
1037
- if (!primary) return null;
1038
-
1039
- const details: AccountBalance[] = [];
1040
- if (primary.toppedUp !== null) {
1041
- details.push({ amount: primary.toppedUp, unit: primary.total.unit, label: "Topped up" });
1042
- }
1043
- if (primary.granted !== null) {
1044
- details.push({ amount: primary.granted, unit: primary.total.unit, label: "Granted" });
1045
- }
1046
- for (const additional of parsed.slice(1)) details.push(additional.total);
1047
-
1048
- return {
1049
- session: 0,
1050
- weekly: 0,
1051
- quotaHidden: true,
1052
- accountBalance: primary.total,
1053
- accountBalanceDetails: details,
1054
- warning: root?.is_available === false ? "Balance is not currently available for API use" : undefined,
1055
- };
1056
- }
1057
-
1058
- export async function fetchDeepSeekBalance(token: string, config: FetchConfig = {}): Promise<UsageData> {
1059
- const endpoints = config.endpoints ?? resolveUsageEndpoints(config.env);
1060
- const result = await requestJson(endpoints.deepSeekBalance, {
1061
- headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
1062
- }, config);
1063
- if (!result.ok) return { session: 0, weekly: 0, error: result.error };
1064
- return extractDeepSeekBalanceFromPayload(result.data) ?? {
1065
- session: 0,
1066
- weekly: 0,
1067
- error: "unrecognized response shape",
1068
- };
1069
- }
1070
-
1071
- export function extractMoonshotBalanceFromPayload(
1072
- payload: unknown,
1073
- provider: "moonshot" | "moonshot-cn" = "moonshot",
1074
- ): UsageData | null {
1075
- const root = asObject(payload);
1076
- const data = asObject(root?.data) ?? root;
1077
- if (!data) return null;
1078
- const available = readNumber(data.available_balance ?? data.availableBalance);
1079
- if (available === null) return null;
1080
- const cash = readNumber(data.cash_balance ?? data.cashBalance);
1081
- const voucher = readNumber(data.voucher_balance ?? data.voucherBalance);
1082
- const unit = provider === "moonshot-cn" ? "CNY" : "USD";
1083
- const details: AccountBalance[] = [];
1084
- if (cash !== null) details.push({ amount: cash, unit, label: "Cash" });
1085
- if (voucher !== null) details.push({ amount: voucher, unit, label: "Voucher" });
1086
-
1087
- return {
1088
- session: 0,
1089
- weekly: 0,
1090
- quotaHidden: true,
1091
- accountBalance: { amount: available, unit, label: "Available balance" },
1092
- accountBalanceDetails: details,
1093
- warning: available <= 0 ? "Balance exhausted; inference requests may be rejected" : undefined,
1094
- };
1095
- }
1096
-
1097
- export async function fetchMoonshotBalance(
1098
- token: string,
1099
- provider: "moonshot" | "moonshot-cn" = "moonshot",
1100
- config: FetchConfig = {},
1101
- ): Promise<UsageData> {
1102
- const endpoints = config.endpoints ?? resolveUsageEndpoints(config.env);
1103
- const endpoint = provider === "moonshot-cn" ? endpoints.moonshotCnBalance : endpoints.moonshotBalance;
1104
- const result = await requestJson(endpoint, {
1105
- headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
1106
- }, config);
1107
- if (!result.ok) return { session: 0, weekly: 0, error: result.error };
1108
- return extractMoonshotBalanceFromPayload(result.data, provider) ?? {
1109
- session: 0,
1110
- weekly: 0,
1111
- error: "unrecognized response shape",
1112
- };
1113
- }
1114
-
1115
- /** Parse ZAI limits where unit 3 is the five-hour window and unit 6 the weekly window. */
1116
- export function extractZaiUsageFromPayload(payload: unknown, nowMs = Date.now()): UsageData | null {
1117
- const data = payload as any;
1118
- const arrays = [data?.data?.limits, data?.limits, data?.quota?.limits, data?.data?.quota?.limits];
1119
- const limits = arrays.find(Array.isArray) as any[] | undefined;
1120
- if (!limits?.length) return null;
1121
-
1122
- // CREDIT_LIMIT (GLM Coding Plan "lite"/credit-based tiers) reports the same
1123
- // unit/percentage/nextResetTime shape as TOKENS_LIMIT, so treat both as the
1124
- // quota windows for session (unit 3) and weekly (unit 6).
1125
- const tokenLimits = limits.filter((entry) => {
1126
- const type = String(entry?.type || "").toUpperCase();
1127
- return type === "TOKENS_LIMIT" || type === "CREDIT_LIMIT";
1128
- });
1129
- const sessionEntry = tokenLimits.find((entry) => entry?.unit === 3);
1130
- const weeklyEntry = tokenLimits.find((entry) => entry?.unit === 6);
1131
- if (!sessionEntry || !weeklyEntry) return null;
1132
-
1133
- const session = readPercentCandidate(sessionEntry.percentage);
1134
- const weekly = readPercentCandidate(weeklyEntry.percentage);
1135
- if (session === null || weekly === null) return null;
1136
- const normalized = normalizeUsagePair(session, weekly);
1137
-
1138
- return {
1139
- ...normalized,
1140
- sessionResetsIn: typeof sessionEntry.nextResetTime === "number" && sessionEntry.nextResetTime > 0
1141
- ? formatDuration(Math.max(0, sessionEntry.nextResetTime - nowMs) / 1000)
1142
- : undefined,
1143
- weeklyResetsIn: typeof weeklyEntry.nextResetTime === "number" && weeklyEntry.nextResetTime > 0
1144
- ? formatDuration(Math.max(0, weeklyEntry.nextResetTime - nowMs) / 1000)
1145
- : undefined,
1146
- };
1147
- }
1148
-
1149
- export async function fetchZaiUsage(
1150
- token: string,
1151
- provider: "zai" | "zai-cn" = "zai",
1152
- config: FetchConfig = {},
1153
- ): Promise<UsageData> {
1154
- const endpoints = config.endpoints ?? resolveUsageEndpoints(config.env);
1155
- const endpoint = provider === "zai-cn" ? endpoints.zaiCn : endpoints.zai;
1156
- const result = await requestJson(endpoint, { headers: { Authorization: `Bearer ${token}` } }, config);
1157
- if (!result.ok) return { session: 0, weekly: 0, error: result.error };
1158
-
1159
- const zaiUsage = extractZaiUsageFromPayload(result.data);
1160
- if (zaiUsage) return zaiUsage;
1161
- return extractUsageFromPayload(result.data) ?? { session: 0, weekly: 0, error: "unrecognized response shape" };
1162
- }
1163
-
1164
- export function detectProvider(
1165
- model: { provider?: string } | string | undefined | null,
1166
- ): ProviderKey | null {
1167
- if (!model || typeof model === "string") return null;
1168
- switch ((model.provider || "").toLowerCase()) {
1169
- case "openai-codex": return "codex";
1170
- case "anthropic": return "claude";
1171
- case "zai": return "zai";
1172
- case "zai-coding-cn": return "zai-cn";
1173
- case "kimi-coding": return "kimi";
1174
- case "minimax": return "minimax";
1175
- case "minimax-cn": return "minimax-cn";
1176
- case "openrouter": return "openrouter";
1177
- case "deepseek": return "deepseek";
1178
- case "moonshotai": return "moonshot";
1179
- case "moonshotai-cn": return "moonshot-cn";
1180
- default: return null;
1181
- }
1182
- }
1183
-
1184
- export function providerToPiProviderId(provider: ProviderKey): PiProviderId {
1185
- switch (provider) {
1186
- case "codex": return "openai-codex";
1187
- case "claude": return "anthropic";
1188
- case "zai": return "zai";
1189
- case "zai-cn": return "zai-coding-cn";
1190
- case "kimi": return "kimi-coding";
1191
- case "minimax": return "minimax";
1192
- case "minimax-cn": return "minimax-cn";
1193
- case "openrouter": return "openrouter";
1194
- case "deepseek": return "deepseek";
1195
- case "moonshot": return "moonshotai";
1196
- case "moonshot-cn": return "moonshotai-cn";
1197
- }
1198
- }
1199
-
1200
- export function clampPercent(value: number): number {
1201
- if (!Number.isFinite(value)) return 0;
1202
- return Math.max(0, Math.min(100, Math.round(value)));
1203
- }
1204
-
1205
- export function colorForPercent(value: number): "success" | "warning" | "error" {
1206
- if (value >= 90) return "error";
1207
- if (value >= 70) return "warning";
1208
- return "success";
1209
- }
1210
-
1211
- export async function fetchAllUsages(
1212
- tokens: UsageTokens,
1213
- config: FetchAllUsagesConfig = {},
1214
- ): Promise<UsageByProvider> {
1215
- const endpoints = config.endpoints ?? resolveUsageEndpoints(config.env);
1216
- const results: UsageByProvider = {
1217
- codex: null,
1218
- claude: null,
1219
- zai: null,
1220
- "zai-cn": null,
1221
- kimi: null,
1222
- minimax: null,
1223
- "minimax-cn": null,
1224
- openrouter: null,
1225
- deepseek: null,
1226
- moonshot: null,
1227
- "moonshot-cn": null,
1228
- };
1229
- const tasks: Promise<void>[] = [];
1230
-
1231
- const assign = (provider: ProviderKey, request: Promise<UsageData>) => {
1232
- tasks.push(request.then((usage) => {
1233
- results[provider] = usage;
1234
- }).catch((error) => {
1235
- results[provider] = { session: 0, weekly: 0, error: toErrorMessage(error, config.signal) };
1236
- }));
1237
- };
1238
-
1239
- if (tokens.codex) assign("codex", fetchCodexUsage(tokens.codex, config));
1240
- if (tokens.claude) {
1241
- assign("claude", fetchClaudeUsageWithFallback(tokens.claude, {
1242
- ...config,
1243
- cacheFile: config.cacheFile,
1244
- nowMs: config.nowMs,
1245
- }));
1246
- }
1247
- if (tokens.zai) assign("zai", fetchZaiUsage(tokens.zai, "zai", { ...config, endpoints }));
1248
- if (tokens["zai-cn"]) assign("zai-cn", fetchZaiUsage(tokens["zai-cn"], "zai-cn", { ...config, endpoints }));
1249
- if (tokens.kimi) assign("kimi", fetchKimiUsage(tokens.kimi, { ...config, endpoints }));
1250
- if (tokens.minimax) assign("minimax", fetchMiniMaxUsage(tokens.minimax, "minimax", { ...config, endpoints }));
1251
- if (tokens["minimax-cn"]) {
1252
- assign("minimax-cn", fetchMiniMaxUsage(tokens["minimax-cn"], "minimax-cn", { ...config, endpoints }));
1253
- }
1254
- if (tokens.openrouter) assign("openrouter", fetchOpenRouterUsage(tokens.openrouter, { ...config, endpoints }));
1255
- if (tokens.deepseek) assign("deepseek", fetchDeepSeekBalance(tokens.deepseek, { ...config, endpoints }));
1256
- if (tokens.moonshot) assign("moonshot", fetchMoonshotBalance(tokens.moonshot, "moonshot", { ...config, endpoints }));
1257
- if (tokens["moonshot-cn"]) {
1258
- assign("moonshot-cn", fetchMoonshotBalance(tokens["moonshot-cn"], "moonshot-cn", { ...config, endpoints }));
1259
- }
1260
-
1261
- await Promise.all(tasks);
1262
-
1263
- // Pi intentionally uses MOONSHOT_API_KEY for both regional providers. When one
1264
- // key works in only one region, hide the expected regional auth failure from
1265
- // the all-provider view while preserving active-provider polling behavior.
1266
- if (tokens.moonshot && tokens.moonshot === tokens["moonshot-cn"]) {
1267
- if (results.moonshot && !results.moonshot.error && results["moonshot-cn"]?.error) {
1268
- results["moonshot-cn"] = null;
1269
- } else if (results["moonshot-cn"] && !results["moonshot-cn"].error && results.moonshot?.error) {
1270
- results.moonshot = null;
1271
- }
1272
- }
1273
- return results;
1274
- }
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+
5
+ export type ProviderKey =
6
+ | "codex"
7
+ | "claude"
8
+ | "zai"
9
+ | "zai-cn"
10
+ | "kimi"
11
+ | "minimax"
12
+ | "minimax-cn"
13
+ | "openrouter"
14
+ | "deepseek"
15
+ | "moonshot"
16
+ | "moonshot-cn"
17
+ | "baseten";
18
+ export type PiProviderId =
19
+ | "openai-codex"
20
+ | "anthropic"
21
+ | "zai"
22
+ | "zai-coding-cn"
23
+ | "kimi-coding"
24
+ | "minimax"
25
+ | "minimax-cn"
26
+ | "openrouter"
27
+ | "deepseek"
28
+ | "moonshotai"
29
+ | "moonshotai-cn"
30
+ | "baseten";
31
+
32
+ export interface AccountBalance {
33
+ amount: number;
34
+ unit: string;
35
+ label: string;
36
+ }
37
+
38
+ export interface AccountSpend {
39
+ unit: string;
40
+ daily?: number;
41
+ weekly?: number;
42
+ monthly?: number;
43
+ lifetime?: number;
44
+ }
45
+
46
+ export interface UsageData {
47
+ session: number;
48
+ weekly: number;
49
+ quotaHidden?: boolean;
50
+ accountBalance?: AccountBalance;
51
+ accountBalanceDetails?: AccountBalance[];
52
+ accountUsage?: AccountBalance;
53
+ accountSpend?: AccountSpend;
54
+ sessionResetsIn?: string;
55
+ weeklyResetsIn?: string;
56
+ sessionResetsAt?: string;
57
+ weeklyResetsAt?: string;
58
+ extraSpend?: number;
59
+ extraLimit?: number;
60
+ sessionLabel?: string;
61
+ weeklyLabel?: string;
62
+ sessionHidden?: boolean;
63
+ weeklyHidden?: boolean;
64
+ notice?: string;
65
+ warning?: string;
66
+ stale?: boolean;
67
+ fetchedAt?: number;
68
+ error?: string;
69
+ }
70
+
71
+ export type UsageByProvider = Record<ProviderKey, UsageData | null>;
72
+ export type UsageTokens = Partial<Record<ProviderKey, string>>;
73
+
74
+ export interface UsageEndpoints {
75
+ zai: string;
76
+ zaiCn: string;
77
+ kimi: string;
78
+ minimax: string;
79
+ minimaxLegacy: string;
80
+ minimaxCn: string;
81
+ minimaxCnLegacy: string;
82
+ openRouterCredits: string;
83
+ openRouterKey: string;
84
+ deepSeekBalance: string;
85
+ moonshotBalance: string;
86
+ moonshotCnBalance: string;
87
+ basetenUsage: string;
88
+ }
89
+
90
+ export interface HeadersLike {
91
+ get(name: string): string | null;
92
+ }
93
+
94
+ export interface FetchResponseLike {
95
+ ok: boolean;
96
+ status: number;
97
+ headers?: HeadersLike;
98
+ json(): Promise<unknown>;
99
+ }
100
+
101
+ export type FetchLike = (input: string, init?: RequestInit) => Promise<FetchResponseLike>;
102
+
103
+ export interface RequestConfig {
104
+ fetchFn?: FetchLike;
105
+ timeoutMs?: number;
106
+ signal?: AbortSignal;
107
+ }
108
+
109
+ export interface FetchConfig extends RequestConfig {
110
+ endpoints?: UsageEndpoints;
111
+ env?: NodeJS.ProcessEnv;
112
+ }
113
+
114
+ export interface FetchAllUsagesConfig extends FetchConfig {
115
+ cacheFile?: string;
116
+ nowMs?: number;
117
+ }
118
+
119
+ export interface ClaudeUsageFetchConfig extends RequestConfig {
120
+ cacheFile?: string;
121
+ nowMs?: number;
122
+ }
123
+
124
+ export interface BasetenUsageFetchConfig extends FetchConfig {
125
+ nowMs?: number;
126
+ }
127
+
128
+ interface JsonRequestSuccess {
129
+ ok: true;
130
+ data: unknown;
131
+ status: number;
132
+ headers?: HeadersLike;
133
+ }
134
+
135
+ interface JsonRequestError {
136
+ ok: false;
137
+ error: string;
138
+ status: number | null;
139
+ headers?: HeadersLike;
140
+ }
141
+
142
+ type JsonRequestResult = JsonRequestSuccess | JsonRequestError;
143
+
144
+ interface ClaudeUsageAttemptResult {
145
+ usage: UsageData;
146
+ status: number | null;
147
+ retryAfterMs: number | null;
148
+ }
149
+
150
+ interface ClaudeUsageCacheState {
151
+ lastSuccess?: UsageData;
152
+ lastSuccessAt?: number;
153
+ cooldownUntil?: number;
154
+ consecutive429s?: number;
155
+ lastError?: string;
156
+ }
157
+
158
+ interface UsageBarsCacheFile {
159
+ version: 1;
160
+ claude?: ClaudeUsageCacheState;
161
+ }
162
+
163
+ const DEFAULT_FETCH_TIMEOUT_MS = 12_000;
164
+ const CLAUDE_SHARED_FRESH_TTL_MS = 2 * 60 * 1000;
165
+ const CLAUDE_BASE_BACKOFF_MS = 2 * 60 * 1000;
166
+ const CLAUDE_MAX_BACKOFF_MS = 30 * 60 * 1000;
167
+ const CLAUDE_LOCK_WAIT_MS = 4_000;
168
+ const CLAUDE_LOCK_POLL_MS = 125;
169
+ const CLAUDE_LOCK_STALE_MS = 20_000;
170
+
171
+ export const DEFAULT_USAGE_CACHE_FILE = path.join(os.tmpdir(), "pi", "usage-bars-cache.json");
172
+ export const DEFAULT_ZAI_USAGE_ENDPOINT = "https://api.z.ai/api/monitor/usage/quota/limit";
173
+ export const DEFAULT_ZAI_CN_USAGE_ENDPOINT = "https://open.bigmodel.cn/api/monitor/usage/quota/limit";
174
+ export const DEFAULT_KIMI_USAGE_ENDPOINT = "https://api.kimi.com/coding/v1/usages";
175
+ export const DEFAULT_MINIMAX_USAGE_ENDPOINT = "https://api.minimax.io/v1/token_plan/remains";
176
+ export const DEFAULT_MINIMAX_LEGACY_USAGE_ENDPOINT = "https://api.minimax.io/v1/api/openplatform/coding_plan/remains";
177
+ export const DEFAULT_MINIMAX_CN_USAGE_ENDPOINT = "https://api.minimaxi.com/v1/token_plan/remains";
178
+ export const DEFAULT_MINIMAX_CN_LEGACY_USAGE_ENDPOINT = "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains";
179
+ export const DEFAULT_OPENROUTER_CREDITS_ENDPOINT = "https://openrouter.ai/api/v1/credits";
180
+ export const DEFAULT_OPENROUTER_KEY_ENDPOINT = "https://openrouter.ai/api/v1/key";
181
+ export const DEFAULT_DEEPSEEK_BALANCE_ENDPOINT = "https://api.deepseek.com/user/balance";
182
+ export const DEFAULT_MOONSHOT_BALANCE_ENDPOINT = "https://api.moonshot.ai/v1/users/me/balance";
183
+ export const DEFAULT_MOONSHOT_CN_BALANCE_ENDPOINT = "https://api.moonshot.cn/v1/users/me/balance";
184
+ export const DEFAULT_BASETEN_USAGE_ENDPOINT = "https://api.baseten.co/v1/billing/usage_summary";
185
+
186
+ export function resolveUsageEndpoints(env: NodeJS.ProcessEnv = process.env): UsageEndpoints {
187
+ const configured = (value: string | undefined, fallback: string) => {
188
+ const trimmed = value?.trim();
189
+ return trimmed || fallback;
190
+ };
191
+
192
+ return {
193
+ zai: configured(env.PI_ZAI_USAGE_ENDPOINT, DEFAULT_ZAI_USAGE_ENDPOINT),
194
+ zaiCn: configured(env.PI_ZAI_CODING_CN_USAGE_ENDPOINT, DEFAULT_ZAI_CN_USAGE_ENDPOINT),
195
+ kimi: configured(env.PI_KIMI_USAGE_ENDPOINT, DEFAULT_KIMI_USAGE_ENDPOINT),
196
+ minimax: configured(env.PI_MINIMAX_USAGE_ENDPOINT, DEFAULT_MINIMAX_USAGE_ENDPOINT),
197
+ minimaxLegacy: configured(env.PI_MINIMAX_LEGACY_USAGE_ENDPOINT, DEFAULT_MINIMAX_LEGACY_USAGE_ENDPOINT),
198
+ minimaxCn: configured(env.PI_MINIMAX_CN_USAGE_ENDPOINT, DEFAULT_MINIMAX_CN_USAGE_ENDPOINT),
199
+ minimaxCnLegacy: configured(env.PI_MINIMAX_CN_LEGACY_USAGE_ENDPOINT, DEFAULT_MINIMAX_CN_LEGACY_USAGE_ENDPOINT),
200
+ openRouterCredits: configured(env.PI_OPENROUTER_CREDITS_ENDPOINT, DEFAULT_OPENROUTER_CREDITS_ENDPOINT),
201
+ openRouterKey: configured(env.PI_OPENROUTER_KEY_ENDPOINT, DEFAULT_OPENROUTER_KEY_ENDPOINT),
202
+ deepSeekBalance: configured(env.PI_DEEPSEEK_BALANCE_ENDPOINT, DEFAULT_DEEPSEEK_BALANCE_ENDPOINT),
203
+ moonshotBalance: configured(env.PI_MOONSHOT_BALANCE_ENDPOINT, DEFAULT_MOONSHOT_BALANCE_ENDPOINT),
204
+ moonshotCnBalance: configured(env.PI_MOONSHOT_CN_BALANCE_ENDPOINT, DEFAULT_MOONSHOT_CN_BALANCE_ENDPOINT),
205
+ basetenUsage: configured(env.PI_BASETEN_USAGE_ENDPOINT, DEFAULT_BASETEN_USAGE_ENDPOINT),
206
+ };
207
+ }
208
+
209
+ function toErrorMessage(error: unknown, externalSignal?: AbortSignal): string {
210
+ if (error instanceof Error) {
211
+ if (error.name === "AbortError") {
212
+ return externalSignal?.aborted ? "request cancelled" : "request timeout";
213
+ }
214
+ return error.message || String(error);
215
+ }
216
+ return String(error);
217
+ }
218
+
219
+ function asObject(value: unknown): Record<string, unknown> | null {
220
+ if (!value || typeof value !== "object") return null;
221
+ return value as Record<string, unknown>;
222
+ }
223
+
224
+ function normalizeUsagePair(session: number, weekly: number): { session: number; weekly: number } {
225
+ const clean = (value: number) => Number.isFinite(value) ? Number(value.toFixed(2)) : 0;
226
+ return { session: clean(session), weekly: clean(weekly) };
227
+ }
228
+
229
+ function getHeader(headers: HeadersLike | undefined, name: string): string | null {
230
+ if (!headers) return null;
231
+ try {
232
+ return headers.get(name);
233
+ } catch {
234
+ return null;
235
+ }
236
+ }
237
+
238
+ function combineSignals(timeoutSignal: AbortSignal | undefined, externalSignal: AbortSignal | undefined): AbortSignal | undefined {
239
+ if (timeoutSignal && externalSignal) return AbortSignal.any([timeoutSignal, externalSignal]);
240
+ return timeoutSignal ?? externalSignal;
241
+ }
242
+
243
+ async function requestJson(url: string, init: RequestInit, config: RequestConfig = {}): Promise<JsonRequestResult> {
244
+ const fetchFn = config.fetchFn ?? (fetch as unknown as FetchLike);
245
+ const timeoutMs = config.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
246
+ const timeoutController = timeoutMs > 0 ? new AbortController() : undefined;
247
+ const timeout = timeoutController
248
+ ? setTimeout(() => timeoutController.abort(), timeoutMs)
249
+ : undefined;
250
+ const signal = combineSignals(timeoutController?.signal, config.signal);
251
+
252
+ try {
253
+ if (config.signal?.aborted) {
254
+ return { ok: false, error: "request cancelled", status: null };
255
+ }
256
+
257
+ const response = await fetchFn(url, { ...init, signal });
258
+ if (!response.ok) {
259
+ return { ok: false, error: `HTTP ${response.status}`, status: response.status, headers: response.headers };
260
+ }
261
+
262
+ try {
263
+ return { ok: true, data: await response.json(), status: response.status, headers: response.headers };
264
+ } catch {
265
+ return { ok: false, error: "invalid JSON response", status: response.status, headers: response.headers };
266
+ }
267
+ } catch (error) {
268
+ return { ok: false, error: toErrorMessage(error, config.signal), status: null };
269
+ } finally {
270
+ if (timeout !== undefined) clearTimeout(timeout);
271
+ }
272
+ }
273
+
274
+ export function formatDuration(seconds: number): string {
275
+ if (!Number.isFinite(seconds) || seconds <= 0) return "now";
276
+ const days = Math.floor(seconds / 86400);
277
+ const hours = Math.floor((seconds % 86400) / 3600);
278
+ const minutes = Math.floor((seconds % 3600) / 60);
279
+ if (days > 0 && hours > 0) return `${days}d ${hours}h`;
280
+ if (days > 0) return `${days}d`;
281
+ if (hours > 0 && minutes > 0) return `${hours}h ${minutes}m`;
282
+ if (hours > 0) return `${hours}h`;
283
+ if (minutes > 0) return `${minutes}m`;
284
+ return "<1m";
285
+ }
286
+
287
+ export function formatResetsAt(isoDate: string, nowMs = Date.now()): string {
288
+ const resetTime = new Date(isoDate).getTime();
289
+ if (!Number.isFinite(resetTime)) return "";
290
+ return formatDuration(Math.max(0, resetTime - nowMs) / 1000);
291
+ }
292
+
293
+ export function parseRetryAfterMs(value: string | null | undefined, nowMs = Date.now()): number | null {
294
+ if (!value) return null;
295
+ const numeric = Number(value);
296
+ if (Number.isFinite(numeric) && numeric >= 0) return numeric * 1000;
297
+ const dateMs = new Date(value).getTime();
298
+ return Number.isFinite(dateMs) ? Math.max(0, dateMs - nowMs) : null;
299
+ }
300
+
301
+ function readUsageCache(cacheFile = DEFAULT_USAGE_CACHE_FILE): UsageBarsCacheFile {
302
+ try {
303
+ const parsed = JSON.parse(fs.readFileSync(cacheFile, "utf-8"));
304
+ if (parsed?.version === 1 && typeof parsed === "object") return parsed as UsageBarsCacheFile;
305
+ } catch {
306
+ // Invalid or missing caches are treated as empty.
307
+ }
308
+ return { version: 1 };
309
+ }
310
+
311
+ function writeUsageCache(cache: UsageBarsCacheFile, cacheFile = DEFAULT_USAGE_CACHE_FILE): boolean {
312
+ try {
313
+ const directory = path.dirname(cacheFile);
314
+ if (!fs.existsSync(directory)) fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
315
+ const temporaryPath = `${cacheFile}.tmp-${process.pid}-${Date.now()}`;
316
+ fs.writeFileSync(temporaryPath, JSON.stringify(cache, null, 2), { mode: 0o600 });
317
+ fs.renameSync(temporaryPath, cacheFile);
318
+ return true;
319
+ } catch {
320
+ return false;
321
+ }
322
+ }
323
+
324
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
325
+ if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
326
+ return new Promise((resolve, reject) => {
327
+ const timer = setTimeout(resolve, ms);
328
+ signal?.addEventListener("abort", () => {
329
+ clearTimeout(timer);
330
+ reject(new DOMException("Aborted", "AbortError"));
331
+ }, { once: true });
332
+ });
333
+ }
334
+
335
+ function ensureParentDir(filePath: string): void {
336
+ const directory = path.dirname(filePath);
337
+ if (!fs.existsSync(directory)) fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
338
+ }
339
+
340
+ function safeUnlink(filePath: string): void {
341
+ try {
342
+ fs.unlinkSync(filePath);
343
+ } catch {
344
+ // Ignore cleanup races.
345
+ }
346
+ }
347
+
348
+ async function acquireFileLock(lockFile: string, signal?: AbortSignal): Promise<(() => void) | null> {
349
+ ensureParentDir(lockFile);
350
+ const startedAt = Date.now();
351
+
352
+ while (Date.now() - startedAt <= CLAUDE_LOCK_WAIT_MS) {
353
+ if (signal?.aborted) return null;
354
+ try {
355
+ const fd = fs.openSync(lockFile, "wx", 0o600);
356
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, createdAt: Date.now() }));
357
+ fs.closeSync(fd);
358
+ return () => safeUnlink(lockFile);
359
+ } catch (error: unknown) {
360
+ if ((error as NodeJS.ErrnoException)?.code !== "EEXIST") return null;
361
+ try {
362
+ const stat = fs.statSync(lockFile);
363
+ if (Date.now() - stat.mtimeMs >= CLAUDE_LOCK_STALE_MS) {
364
+ safeUnlink(lockFile);
365
+ continue;
366
+ }
367
+ } catch {
368
+ continue;
369
+ }
370
+ try {
371
+ await sleep(CLAUDE_LOCK_POLL_MS, signal);
372
+ } catch {
373
+ return null;
374
+ }
375
+ }
376
+ }
377
+
378
+ return null;
379
+ }
380
+
381
+ export function readPercentCandidate(value: unknown): number | null {
382
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
383
+ if (value >= 0 && value <= 1) return Number.isInteger(value) ? value : value * 100;
384
+ return value >= 0 && value <= 100 ? value : null;
385
+ }
386
+
387
+ export function readLimitPercent(limit: unknown): number | null {
388
+ const value = asObject(limit);
389
+ const direct = [
390
+ value?.percentage,
391
+ value?.utilization,
392
+ value?.used_percent,
393
+ value?.usedPercent,
394
+ value?.usagePercent,
395
+ value?.usage_percent,
396
+ ].map(readPercentCandidate).find((candidate) => candidate !== null);
397
+ if (direct !== undefined) return direct;
398
+
399
+ const current = typeof value?.currentValue === "number" ? value.currentValue : null;
400
+ const remaining = typeof value?.remaining === "number" ? value.remaining : null;
401
+ if (current !== null && remaining !== null && current + remaining > 0) {
402
+ return (current / (current + remaining)) * 100;
403
+ }
404
+ return null;
405
+ }
406
+
407
+ export function extractUsageFromPayload(payload: unknown): { session: number; weekly: number } | null {
408
+ const data = payload as any;
409
+ const limitArrays = [data?.data?.limits, data?.limits, data?.quota?.limits, data?.data?.quota?.limits];
410
+ const limits = limitArrays.find(Array.isArray) as unknown[] | undefined;
411
+
412
+ if (limits) {
413
+ const byType = (types: string[]) => limits.find((entry) => {
414
+ const type = String((entry as any)?.type || "").toUpperCase();
415
+ return types.includes(type);
416
+ });
417
+ const session = readLimitPercent(byType(["TIME_LIMIT", "SESSION_LIMIT", "REQUEST_LIMIT", "RPM_LIMIT", "RPD_LIMIT"]));
418
+ const weekly = readLimitPercent(byType(["TOKENS_LIMIT", "TOKEN_LIMIT", "WEEK_LIMIT", "WEEKLY_LIMIT", "TPM_LIMIT", "DAILY_LIMIT"]));
419
+ if (session !== null && weekly !== null) return normalizeUsagePair(session, weekly);
420
+ }
421
+
422
+ const sessionCandidates = [
423
+ data?.session,
424
+ data?.sessionPercent,
425
+ data?.session_percent,
426
+ data?.five_hour?.utilization,
427
+ data?.rate_limit?.primary_window?.used_percent,
428
+ data?.limits?.session?.utilization,
429
+ data?.usage?.session,
430
+ data?.data?.session,
431
+ data?.data?.sessionPercent,
432
+ data?.data?.session_percent,
433
+ data?.data?.usage?.session,
434
+ data?.quota?.session?.percentage,
435
+ data?.data?.quota?.session?.percentage,
436
+ ];
437
+ const weeklyCandidates = [
438
+ data?.weekly,
439
+ data?.weeklyPercent,
440
+ data?.weekly_percent,
441
+ data?.seven_day?.utilization,
442
+ data?.rate_limit?.secondary_window?.used_percent,
443
+ data?.limits?.weekly?.utilization,
444
+ data?.usage?.weekly,
445
+ data?.data?.weekly,
446
+ data?.data?.weeklyPercent,
447
+ data?.data?.weekly_percent,
448
+ data?.data?.usage?.weekly,
449
+ data?.quota?.weekly?.percentage,
450
+ data?.data?.quota?.weekly?.percentage,
451
+ data?.quota?.daily?.percentage,
452
+ data?.data?.quota?.daily?.percentage,
453
+ ];
454
+
455
+ const session = sessionCandidates.map(readPercentCandidate).find((candidate) => candidate !== null);
456
+ const weekly = weeklyCandidates.map(readPercentCandidate).find((candidate) => candidate !== null);
457
+ return session === undefined || weekly === undefined ? null : normalizeUsagePair(session, weekly);
458
+ }
459
+
460
+ function hydrateUsageResets(usage: UsageData, nowMs = Date.now()): UsageData {
461
+ return {
462
+ ...usage,
463
+ sessionResetsIn: usage.sessionResetsAt ? formatResetsAt(usage.sessionResetsAt, nowMs) : usage.sessionResetsIn,
464
+ weeklyResetsIn: usage.weeklyResetsAt ? formatResetsAt(usage.weeklyResetsAt, nowMs) : usage.weeklyResetsIn,
465
+ };
466
+ }
467
+
468
+ function snapshotUsage(usage: UsageData, nowMs = Date.now()): UsageData {
469
+ return {
470
+ session: usage.session,
471
+ weekly: usage.weekly,
472
+ quotaHidden: usage.quotaHidden,
473
+ accountBalance: usage.accountBalance,
474
+ accountBalanceDetails: usage.accountBalanceDetails,
475
+ accountUsage: usage.accountUsage,
476
+ accountSpend: usage.accountSpend,
477
+ sessionResetsAt: usage.sessionResetsAt,
478
+ weeklyResetsAt: usage.weeklyResetsAt,
479
+ sessionResetsIn: usage.sessionResetsIn,
480
+ weeklyResetsIn: usage.weeklyResetsIn,
481
+ extraSpend: usage.extraSpend,
482
+ extraLimit: usage.extraLimit,
483
+ sessionLabel: usage.sessionLabel,
484
+ weeklyLabel: usage.weeklyLabel,
485
+ sessionHidden: usage.sessionHidden,
486
+ weeklyHidden: usage.weeklyHidden,
487
+ notice: usage.notice,
488
+ fetchedAt: usage.fetchedAt ?? nowMs,
489
+ };
490
+ }
491
+
492
+ function staleCachedUsage(cached: UsageData, warning: string, nowMs = Date.now()): UsageData {
493
+ return { ...hydrateUsageResets(snapshotUsage(cached, nowMs), nowMs), stale: true, warning };
494
+ }
495
+
496
+ function readClaudeCacheState(cacheFile = DEFAULT_USAGE_CACHE_FILE): ClaudeUsageCacheState {
497
+ return readUsageCache(cacheFile).claude ?? {};
498
+ }
499
+
500
+ function writeClaudeCacheState(state: ClaudeUsageCacheState, cacheFile = DEFAULT_USAGE_CACHE_FILE): boolean {
501
+ const cache = readUsageCache(cacheFile);
502
+ cache.claude = state;
503
+ return writeUsageCache(cache, cacheFile);
504
+ }
505
+
506
+ function clearClaudeCooldown(state: ClaudeUsageCacheState): ClaudeUsageCacheState {
507
+ return { ...state, cooldownUntil: undefined, consecutive429s: 0, lastError: undefined };
508
+ }
509
+
510
+ function computeClaudeBackoffMs(state: ClaudeUsageCacheState, retryAfterMs: number | null): number {
511
+ if (retryAfterMs !== null && retryAfterMs > 0) {
512
+ return Math.min(CLAUDE_MAX_BACKOFF_MS, Math.max(CLAUDE_BASE_BACKOFF_MS, retryAfterMs));
513
+ }
514
+ const count = Math.max(1, state.consecutive429s ?? 0);
515
+ return Math.min(CLAUDE_MAX_BACKOFF_MS, CLAUDE_BASE_BACKOFF_MS * 2 ** Math.max(0, count - 1));
516
+ }
517
+
518
+ function cooldownMessage(untilMs: number, nowMs = Date.now()): string {
519
+ return `rate limited; retry in ${formatDuration(Math.max(0, untilMs - nowMs) / 1000)}`;
520
+ }
521
+
522
+ function readClaudeCacheOutcome(cacheFile = DEFAULT_USAGE_CACHE_FILE, nowMs = Date.now()): UsageData | null {
523
+ const state = readClaudeCacheState(cacheFile);
524
+ if (state.cooldownUntil && state.cooldownUntil > nowMs) {
525
+ const warning = cooldownMessage(state.cooldownUntil, nowMs);
526
+ return state.lastSuccess
527
+ ? staleCachedUsage(state.lastSuccess, warning, nowMs)
528
+ : { session: 0, weekly: 0, error: warning };
529
+ }
530
+ if (state.lastSuccess && state.lastSuccessAt && nowMs - state.lastSuccessAt <= CLAUDE_SHARED_FRESH_TTL_MS) {
531
+ return hydrateUsageResets(snapshotUsage(state.lastSuccess, state.lastSuccessAt), nowMs);
532
+ }
533
+ return null;
534
+ }
535
+
536
+ export function parseCodexRateLimit(data: any): UsageData {
537
+ const rateLimit = data?.rate_limit ?? data?.rate_limits;
538
+ const primary = rateLimit?.primary_window ?? rateLimit?.primary ?? rateLimit?.five_hour;
539
+ const secondary = rateLimit?.secondary_window ?? rateLimit?.secondary ?? rateLimit?.weekly;
540
+
541
+ let sessionWindow: any = null;
542
+ let weeklyWindow: any = null;
543
+ for (const [position, window] of [["primary", primary], ["secondary", secondary]] as const) {
544
+ if (!window || typeof window !== "object") continue;
545
+ const duration = window.limit_window_seconds;
546
+ if (typeof duration === "number" && Number.isFinite(duration)) {
547
+ // Some Codex accounts return their seven-day quota as primary_window
548
+ // and omit secondary_window, so position alone does not identify it.
549
+ if (duration >= 2 * 24 * 60 * 60) weeklyWindow ??= window;
550
+ else sessionWindow ??= window;
551
+ } else if (position === "primary") {
552
+ sessionWindow ??= window;
553
+ } else {
554
+ weeklyWindow ??= window;
555
+ }
556
+ }
557
+
558
+ const reset = (window: any) =>
559
+ typeof window?.reset_after_seconds === "number" ? formatDuration(window.reset_after_seconds) : undefined;
560
+
561
+ return {
562
+ session: readPercentCandidate(sessionWindow?.used_percent) ?? 0,
563
+ weekly: readPercentCandidate(weeklyWindow?.used_percent) ?? 0,
564
+ ...(!sessionWindow ? { sessionHidden: true } : {}),
565
+ ...(!weeklyWindow ? { weeklyHidden: true } : {}),
566
+ sessionResetsIn: reset(sessionWindow),
567
+ weeklyResetsIn: reset(weeklyWindow),
568
+ };
569
+ }
570
+
571
+ export async function fetchCodexUsage(token: string, config: RequestConfig = {}): Promise<UsageData> {
572
+ const result = await requestJson(
573
+ "https://chatgpt.com/backend-api/wham/usage",
574
+ { headers: { Authorization: `Bearer ${token}` } },
575
+ config,
576
+ );
577
+ if (!result.ok) return { session: 0, weekly: 0, error: result.error };
578
+ return parseCodexRateLimit(result.data);
579
+ }
580
+
581
+ async function fetchClaudeUsageAttempt(
582
+ token: string,
583
+ config: RequestConfig = {},
584
+ nowMs = Date.now(),
585
+ ): Promise<ClaudeUsageAttemptResult> {
586
+ const result = await requestJson(
587
+ "https://api.anthropic.com/api/oauth/usage",
588
+ {
589
+ headers: {
590
+ Authorization: `Bearer ${token}`,
591
+ "anthropic-beta": "oauth-2025-04-20",
592
+ },
593
+ },
594
+ config,
595
+ );
596
+ const retryAfterMs = parseRetryAfterMs(getHeader(result.headers, "retry-after"), nowMs);
597
+ if (!result.ok) {
598
+ return { usage: { session: 0, weekly: 0, error: result.error }, status: result.status, retryAfterMs };
599
+ }
600
+
601
+ const data = result.data as any;
602
+ const usage: UsageData = hydrateUsageResets({
603
+ session: readPercentCandidate(data?.five_hour?.utilization) ?? 0,
604
+ weekly: readPercentCandidate(data?.seven_day?.utilization) ?? 0,
605
+ sessionResetsAt: typeof data?.five_hour?.resets_at === "string" ? data.five_hour.resets_at : undefined,
606
+ weeklyResetsAt: typeof data?.seven_day?.resets_at === "string" ? data.seven_day.resets_at : undefined,
607
+ fetchedAt: nowMs,
608
+ }, nowMs);
609
+
610
+ if (data?.extra_usage?.is_enabled) {
611
+ usage.extraSpend = typeof data.extra_usage.used_credits === "number" ? data.extra_usage.used_credits : undefined;
612
+ usage.extraLimit = typeof data.extra_usage.monthly_limit === "number" ? data.extra_usage.monthly_limit : undefined;
613
+ }
614
+ return { usage, status: result.status, retryAfterMs };
615
+ }
616
+
617
+ export async function fetchClaudeUsage(token: string, config: RequestConfig = {}): Promise<UsageData> {
618
+ return (await fetchClaudeUsageAttempt(token, config)).usage;
619
+ }
620
+
621
+ export async function fetchClaudeUsageWithFallback(
622
+ token: string,
623
+ config: ClaudeUsageFetchConfig = {},
624
+ ): Promise<UsageData> {
625
+ const cacheFile = config.cacheFile ?? DEFAULT_USAGE_CACHE_FILE;
626
+ const nowMs = config.nowMs ?? Date.now();
627
+ const cachedOutcome = readClaudeCacheOutcome(cacheFile, nowMs);
628
+ if (cachedOutcome) return cachedOutcome;
629
+ if (config.signal?.aborted) return { session: 0, weekly: 0, error: "request cancelled" };
630
+
631
+ const lockFile = `${cacheFile}.claude.lock`;
632
+ const releaseLock = await acquireFileLock(lockFile, config.signal);
633
+ if (!releaseLock) {
634
+ const waitedOutcome = readClaudeCacheOutcome(cacheFile, nowMs);
635
+ if (waitedOutcome) return waitedOutcome;
636
+ if (config.signal?.aborted) return { session: 0, weekly: 0, error: "request cancelled" };
637
+ }
638
+
639
+ try {
640
+ const lockOutcome = readClaudeCacheOutcome(cacheFile, nowMs);
641
+ if (lockOutcome) return lockOutcome;
642
+
643
+ let state = readClaudeCacheState(cacheFile);
644
+ const attempt = await fetchClaudeUsageAttempt(token, config, nowMs);
645
+ if (!attempt.usage.error) {
646
+ state = clearClaudeCooldown(state);
647
+ state.lastSuccess = snapshotUsage(attempt.usage, nowMs);
648
+ state.lastSuccessAt = nowMs;
649
+ writeClaudeCacheState(state, cacheFile);
650
+ return attempt.usage;
651
+ }
652
+
653
+ if (attempt.status === 429) {
654
+ const consecutive429s = Math.max(1, (state.consecutive429s ?? 0) + 1);
655
+ const cooldownUntil = nowMs + computeClaudeBackoffMs({ ...state, consecutive429s }, attempt.retryAfterMs);
656
+ state = { ...state, cooldownUntil, consecutive429s, lastError: attempt.usage.error };
657
+ writeClaudeCacheState(state, cacheFile);
658
+ return state.lastSuccess
659
+ ? staleCachedUsage(state.lastSuccess, cooldownMessage(cooldownUntil, nowMs), nowMs)
660
+ : { session: 0, weekly: 0, error: `${attempt.usage.error}; ${cooldownMessage(cooldownUntil, nowMs)}` };
661
+ }
662
+
663
+ return attempt.usage;
664
+ } finally {
665
+ releaseLock?.();
666
+ }
667
+ }
668
+
669
+ function readNumber(value: unknown): number | null {
670
+ if (typeof value === "number" && Number.isFinite(value)) return value;
671
+ if (typeof value === "string" && value.trim()) {
672
+ const parsed = Number(value);
673
+ if (Number.isFinite(parsed)) return parsed;
674
+ }
675
+ return null;
676
+ }
677
+
678
+ function usedPercentFromCounts(
679
+ value: Record<string, unknown> | null | undefined,
680
+ options: { remainingPercent?: string; used?: string; total?: string; remaining?: string } = {},
681
+ ): number | null {
682
+ if (!value) return null;
683
+ const remainingPercent = readNumber(value[options.remainingPercent ?? "remaining_percent"]);
684
+ if (remainingPercent !== null) return Math.max(0, Math.min(100, 100 - remainingPercent));
685
+
686
+ const total = readNumber(value[options.total ?? "limit"]);
687
+ const used = readNumber(value[options.used ?? "used"]);
688
+ const remaining = readNumber(value[options.remaining ?? "remaining"]);
689
+ if (total === null || total <= 0) return null;
690
+ if (used !== null) return Math.max(0, Math.min(100, used / total * 100));
691
+ if (remaining !== null) return Math.max(0, Math.min(100, (total - remaining) / total * 100));
692
+ return null;
693
+ }
694
+
695
+ function normalizeIsoDate(value: unknown): string | undefined {
696
+ if (typeof value !== "string" || !value.trim()) return undefined;
697
+ const normalized = value.trim().replace(/(\.\d{3})\d+(?=Z|[+-]\d\d:\d\d$)/, "$1");
698
+ return Number.isFinite(new Date(normalized).getTime()) ? normalized : undefined;
699
+ }
700
+
701
+ function isoFromEpoch(value: unknown): string | undefined {
702
+ const raw = readNumber(value);
703
+ if (raw === null || raw <= 0) return undefined;
704
+ const milliseconds = raw > 1_000_000_000_000 ? raw : raw * 1000;
705
+ const date = new Date(milliseconds);
706
+ return Number.isFinite(date.getTime()) ? date.toISOString() : undefined;
707
+ }
708
+
709
+ function resetFromRemains(value: unknown, nowMs: number): string | undefined {
710
+ const raw = readNumber(value);
711
+ if (raw === null || raw <= 0) return undefined;
712
+ const milliseconds = raw > 1_000_000 ? raw : raw * 1000;
713
+ return new Date(nowMs + milliseconds).toISOString();
714
+ }
715
+
716
+ export function extractKimiUsageFromPayload(payload: unknown, nowMs = Date.now()): UsageData | null {
717
+ const root = asObject(payload);
718
+ if (!root) return null;
719
+ const webUsages = Array.isArray(root.usages) ? root.usages : undefined;
720
+ const codingUsage = webUsages?.map(asObject).find((entry) =>
721
+ String(entry?.scope ?? "").toUpperCase() === "FEATURE_CODING") ?? root;
722
+ const dataRows = Array.isArray(codingUsage.data) ? codingUsage.data.map(asObject).filter(Boolean) : [];
723
+ const usage = asObject(codingUsage.usage) ?? asObject(codingUsage.detail) ??
724
+ dataRows.find((entry) => String(entry?.model_name ?? entry?.modelName ?? "").toLowerCase() === "all");
725
+ const limits = Array.isArray(codingUsage.limits)
726
+ ? codingUsage.limits
727
+ : dataRows.filter((entry) => entry !== usage);
728
+ const sessionLimit = limits.map(asObject).find((entry) => {
729
+ const window = asObject(entry?.window);
730
+ const duration = readNumber(window?.duration);
731
+ const unit = String(window?.timeUnit ?? window?.time_unit ?? "").toUpperCase();
732
+ return duration === 300 && unit.includes("MINUTE");
733
+ }) ?? limits.map(asObject).find((entry) => entry !== null);
734
+ const sessionDetail = asObject(sessionLimit?.detail) ?? sessionLimit;
735
+
736
+ const session = usedPercentFromCounts(sessionDetail);
737
+ const weekly = usedPercentFromCounts(usage);
738
+ if (session === null || weekly === null) return null;
739
+
740
+ const sessionReset = normalizeIsoDate(sessionDetail?.resetTime ?? sessionDetail?.reset_at ?? sessionDetail?.reset_time);
741
+ const weeklyReset = normalizeIsoDate(usage?.resetTime ?? usage?.reset_at ?? usage?.reset_time);
742
+ return hydrateUsageResets({
743
+ ...normalizeUsagePair(session, weekly),
744
+ sessionLabel: "5-hour",
745
+ weeklyLabel: "Weekly",
746
+ sessionResetsAt: sessionReset,
747
+ weeklyResetsAt: weeklyReset,
748
+ }, nowMs);
749
+ }
750
+
751
+ export async function fetchKimiUsage(token: string, config: FetchConfig = {}): Promise<UsageData> {
752
+ const endpoints = config.endpoints ?? resolveUsageEndpoints(config.env);
753
+ const result = await requestJson(endpoints.kimi, {
754
+ headers: {
755
+ Authorization: `Bearer ${token}`,
756
+ "User-Agent": "KimiCLI/1.5",
757
+ },
758
+ }, config);
759
+ if (!result.ok) return { session: 0, weekly: 0, error: result.error };
760
+ return extractKimiUsageFromPayload(result.data) ?? {
761
+ session: 0,
762
+ weekly: 0,
763
+ error: "unrecognized response shape",
764
+ };
765
+ }
766
+
767
+ interface MiniMaxWindow {
768
+ percent: number;
769
+ resetsAt?: string;
770
+ }
771
+
772
+ function pickHighestWindow(windows: MiniMaxWindow[]): MiniMaxWindow | undefined {
773
+ return windows.reduce<MiniMaxWindow | undefined>((highest, window) =>
774
+ !highest || window.percent > highest.percent ? window : highest, undefined);
775
+ }
776
+
777
+ function miniMaxResetAt(value: Record<string, unknown>, prefix: "current" | "weekly", nowMs: number): string | undefined {
778
+ const end = prefix === "current"
779
+ ? value.end_time ?? value.endTime
780
+ : value.weekly_end_time ?? value.weeklyEndTime;
781
+ const remains = prefix === "current"
782
+ ? value.remains_time ?? value.remainsTime
783
+ : value.weekly_remains_time ?? value.weeklyRemainsTime;
784
+ const resetsAt = prefix === "current"
785
+ ? value.current_resets_at ?? value.currentResetsAt
786
+ : value.weekly_resets_at ?? value.weeklyResetsAt;
787
+ return normalizeIsoDate(resetsAt) ?? isoFromEpoch(end) ?? resetFromRemains(remains, nowMs);
788
+ }
789
+
790
+ function extractMiniMaxCreditBalance(payload: unknown): AccountBalance | undefined {
791
+ const root = asObject(payload);
792
+ const data = asObject(root?.data) ?? root;
793
+ if (!data) return undefined;
794
+ const amount = readNumber(
795
+ data.points_balance ?? data.pointsBalance ??
796
+ data.point_balance ?? data.pointBalance ??
797
+ data.credits_balance ?? data.creditsBalance ??
798
+ data.credit_balance ?? data.creditBalance,
799
+ );
800
+ return amount === null ? undefined : { amount, unit: "credits", label: "Credit balance" };
801
+ }
802
+
803
+ export function extractMiniMaxUsageFromPayload(payload: unknown, nowMs = Date.now()): UsageData | null {
804
+ const root = asObject(payload);
805
+ const data = asObject(root?.data) ?? root;
806
+ if (!data) return null;
807
+ const accountBalance = extractMiniMaxCreditBalance(payload);
808
+
809
+ const intervalWindows: MiniMaxWindow[] = [];
810
+ const weeklyWindows: MiniMaxWindow[] = [];
811
+ if (Array.isArray(data.services)) {
812
+ for (const rawService of data.services) {
813
+ const service = asObject(rawService);
814
+ if (!service) continue;
815
+ const directPercent = readPercentCandidate(readNumber(service.percent));
816
+ const percent = directPercent ?? usedPercentFromCounts(service, { total: "limit", used: "usage" });
817
+ if (percent === null) continue;
818
+ const windowType = String(service.window_type ?? service.windowType ?? "").toLowerCase();
819
+ const resetsAt = normalizeIsoDate(service.resets_at ?? service.reset_time ?? service.end_time);
820
+ (windowType.includes("week") ? weeklyWindows : intervalWindows).push({ percent, resetsAt });
821
+ }
822
+ }
823
+
824
+ if (Array.isArray(data.model_remains ?? data.modelRemains)) {
825
+ for (const rawModel of (data.model_remains ?? data.modelRemains) as unknown[]) {
826
+ const raw = asObject(rawModel);
827
+ if (!raw) continue;
828
+ const model: Record<string, unknown> = {
829
+ ...raw,
830
+ current_interval_remaining_percent:
831
+ raw.current_interval_remaining_percent ?? raw.currentIntervalRemainingPercent,
832
+ current_interval_total_count: raw.current_interval_total_count ?? raw.currentIntervalTotalCount,
833
+ current_interval_usage_count: raw.current_interval_usage_count ?? raw.currentIntervalUsageCount,
834
+ current_interval_status: raw.current_interval_status ?? raw.currentIntervalStatus,
835
+ current_weekly_remaining_percent:
836
+ raw.current_weekly_remaining_percent ?? raw.currentWeeklyRemainingPercent,
837
+ current_weekly_total_count: raw.current_weekly_total_count ?? raw.currentWeeklyTotalCount,
838
+ current_weekly_usage_count: raw.current_weekly_usage_count ?? raw.currentWeeklyUsageCount,
839
+ current_weekly_status: raw.current_weekly_status ?? raw.currentWeeklyStatus,
840
+ };
841
+ const unavailable = (prefix: "interval" | "weekly") =>
842
+ readNumber(model[`current_${prefix}_status`]) === 3 &&
843
+ (readNumber(model[`current_${prefix}_remaining_percent`]) ?? 0) >= 100 &&
844
+ (readNumber(model[`current_${prefix}_total_count`]) ?? 0) === 0 &&
845
+ (readNumber(model[`current_${prefix}_usage_count`]) ?? 0) === 0;
846
+ const interval = unavailable("interval") ? null : usedPercentFromCounts(model, {
847
+ remainingPercent: "current_interval_remaining_percent",
848
+ total: "current_interval_total_count",
849
+ remaining: "current_interval_usage_count",
850
+ });
851
+ if (interval !== null) {
852
+ intervalWindows.push({ percent: interval, resetsAt: miniMaxResetAt(model, "current", nowMs) });
853
+ }
854
+ const weekly = unavailable("weekly") ? null : usedPercentFromCounts(model, {
855
+ remainingPercent: "current_weekly_remaining_percent",
856
+ total: "current_weekly_total_count",
857
+ remaining: "current_weekly_usage_count",
858
+ });
859
+ if (weekly !== null) {
860
+ weeklyWindows.push({ percent: weekly, resetsAt: miniMaxResetAt(model, "weekly", nowMs) });
861
+ }
862
+ }
863
+ }
864
+
865
+ const session = pickHighestWindow(intervalWindows);
866
+ const weekly = pickHighestWindow(weeklyWindows);
867
+ if (!session) {
868
+ return accountBalance
869
+ ? { session: 0, weekly: 0, quotaHidden: true, accountBalance }
870
+ : null;
871
+ }
872
+ return hydrateUsageResets({
873
+ session: Number(session.percent.toFixed(2)),
874
+ accountBalance,
875
+ weekly: Number((weekly?.percent ?? 0).toFixed(2)),
876
+ sessionLabel: "Interval",
877
+ weeklyLabel: "Weekly",
878
+ weeklyHidden: !weekly,
879
+ sessionResetsAt: session.resetsAt,
880
+ weeklyResetsAt: weekly?.resetsAt,
881
+ }, nowMs);
882
+ }
883
+
884
+ function miniMaxPayloadStatus(payload: unknown): number | null {
885
+ const root = asObject(payload);
886
+ const data = asObject(root?.data);
887
+ const baseResponse = asObject(data?.base_resp ?? data?.baseResp ?? root?.base_resp ?? root?.baseResp);
888
+ return readNumber(baseResponse?.status_code ?? baseResponse?.statusCode);
889
+ }
890
+
891
+ function miniMaxPayloadError(payload: unknown): string | null {
892
+ const root = asObject(payload);
893
+ const data = asObject(root?.data);
894
+ const baseResponse = asObject(data?.base_resp ?? data?.baseResp ?? root?.base_resp ?? root?.baseResp);
895
+ const status = miniMaxPayloadStatus(payload);
896
+ if (status === null || status === 0) return null;
897
+ const message = baseResponse?.status_msg ?? baseResponse?.statusMessage;
898
+ return typeof message === "string" && message.trim()
899
+ ? `API ${status}: ${message.trim()}`
900
+ : `API ${status}`;
901
+ }
902
+
903
+ export async function fetchMiniMaxUsage(
904
+ token: string,
905
+ provider: "minimax" | "minimax-cn" = "minimax",
906
+ config: FetchConfig = {},
907
+ ): Promise<UsageData> {
908
+ const endpoints = config.endpoints ?? resolveUsageEndpoints(config.env);
909
+ const candidates = provider === "minimax-cn"
910
+ ? [endpoints.minimaxCn, endpoints.minimaxCnLegacy]
911
+ : [endpoints.minimax, endpoints.minimaxLegacy];
912
+ let lastError = "usage request failed";
913
+ let credentialError: string | undefined;
914
+ let noActiveTokenPlan = false;
915
+
916
+ for (const endpoint of [...new Set(candidates)]) {
917
+ const result = await requestJson(endpoint, {
918
+ headers: {
919
+ Authorization: `Bearer ${token}`,
920
+ Accept: "application/json",
921
+ },
922
+ }, config);
923
+ if (!result.ok) {
924
+ lastError = result.error;
925
+ if (result.status === 401 || result.status === 403) credentialError ??= result.error;
926
+ if (config.signal?.aborted) break;
927
+ continue;
928
+ }
929
+ const payloadStatus = miniMaxPayloadStatus(result.data);
930
+ const payloadError = miniMaxPayloadError(result.data);
931
+ const usage = extractMiniMaxUsageFromPayload(result.data);
932
+ if (usage && (!payloadError || usage.quotaHidden)) return usage;
933
+ if (payloadStatus === 2062) {
934
+ noActiveTokenPlan = true;
935
+ continue;
936
+ }
937
+ if (payloadError) {
938
+ lastError = payloadError;
939
+ continue;
940
+ }
941
+ lastError = "unrecognized response shape";
942
+ }
943
+
944
+ if (noActiveTokenPlan) {
945
+ return {
946
+ session: 0,
947
+ weekly: 0,
948
+ quotaHidden: true,
949
+ notice: "No active Token Plan · check Credit balance in the MiniMax console",
950
+ };
951
+ }
952
+ return { session: 0, weekly: 0, error: credentialError ?? lastError };
953
+ }
954
+
955
+ export function extractOpenRouterUsageFromPayloads(
956
+ creditsPayload: unknown,
957
+ keyPayload: unknown,
958
+ ): UsageData | null {
959
+ const credits = asObject(asObject(creditsPayload)?.data) ?? asObject(creditsPayload);
960
+ const key = asObject(asObject(keyPayload)?.data) ?? asObject(keyPayload);
961
+
962
+ const totalCredits = readNumber(credits?.total_credits ?? credits?.totalCredits);
963
+ const totalUsage = readNumber(credits?.total_usage ?? credits?.totalUsage);
964
+ const accountBalance = totalCredits !== null && totalUsage !== null
965
+ ? {
966
+ amount: Number((totalCredits - totalUsage).toFixed(6)),
967
+ unit: "USD",
968
+ label: "Balance",
969
+ }
970
+ : undefined;
971
+
972
+ const spendValues = {
973
+ daily: readNumber(key?.usage_daily ?? key?.usageDaily),
974
+ weekly: readNumber(key?.usage_weekly ?? key?.usageWeekly),
975
+ monthly: readNumber(key?.usage_monthly ?? key?.usageMonthly),
976
+ lifetime: readNumber(key?.usage),
977
+ };
978
+ const accountSpend = Object.values(spendValues).some((value) => value !== null)
979
+ ? {
980
+ unit: "USD",
981
+ daily: spendValues.daily ?? undefined,
982
+ weekly: spendValues.weekly ?? undefined,
983
+ monthly: spendValues.monthly ?? undefined,
984
+ lifetime: spendValues.lifetime ?? undefined,
985
+ }
986
+ : undefined;
987
+
988
+ const limit = readNumber(key?.limit);
989
+ const remaining = readNumber(key?.limit_remaining ?? key?.limitRemaining);
990
+ const limitUsed = limit !== null && limit > 0 && remaining !== null
991
+ ? Math.max(0, Math.min(limit, limit - remaining))
992
+ : null;
993
+ const limitPercent = limitUsed !== null && limit !== null ? limitUsed / limit * 100 : null;
994
+ if (!accountBalance && !accountSpend && limitPercent === null) return null;
995
+
996
+ return {
997
+ session: limitPercent === null ? 0 : Number(limitPercent.toFixed(2)),
998
+ weekly: 0,
999
+ quotaHidden: limitPercent === null,
1000
+ weeklyHidden: true,
1001
+ sessionLabel: "Key limit",
1002
+ accountBalance,
1003
+ accountSpend,
1004
+ };
1005
+ }
1006
+
1007
+ export async function fetchOpenRouterUsage(token: string, config: FetchConfig = {}): Promise<UsageData> {
1008
+ const endpoints = config.endpoints ?? resolveUsageEndpoints(config.env);
1009
+ const headers = { Authorization: `Bearer ${token}`, Accept: "application/json" };
1010
+ const [creditsResult, keyResult] = await Promise.all([
1011
+ requestJson(endpoints.openRouterCredits, { headers }, config),
1012
+ requestJson(endpoints.openRouterKey, { headers }, config),
1013
+ ]);
1014
+ const usage = extractOpenRouterUsageFromPayloads(
1015
+ creditsResult.ok ? creditsResult.data : undefined,
1016
+ keyResult.ok ? keyResult.data : undefined,
1017
+ );
1018
+ if (usage) return usage;
1019
+
1020
+ const errors = [
1021
+ creditsResult.ok ? undefined : `credits: ${creditsResult.error}`,
1022
+ keyResult.ok ? undefined : `key: ${keyResult.error}`,
1023
+ ].filter((value): value is string => Boolean(value));
1024
+ return {
1025
+ session: 0,
1026
+ weekly: 0,
1027
+ error: errors.length > 0 ? errors.join("; ") : "unrecognized response shape",
1028
+ };
1029
+ }
1030
+
1031
+ export function extractDeepSeekBalanceFromPayload(payload: unknown): UsageData | null {
1032
+ const root = asObject(payload);
1033
+ const rawBalances = Array.isArray(root?.balance_infos) ? root.balance_infos : [];
1034
+ const balances = rawBalances.map(asObject).filter((value): value is Record<string, unknown> => value !== null);
1035
+ if (balances.length === 0) return null;
1036
+
1037
+ const parsed = balances.flatMap((balance) => {
1038
+ const unit = typeof balance.currency === "string" ? balance.currency.toUpperCase() : "USD";
1039
+ const total = readNumber(balance.total_balance ?? balance.totalBalance);
1040
+ if (total === null) return [];
1041
+ return [{
1042
+ total: { amount: total, unit, label: "Total balance" } satisfies AccountBalance,
1043
+ toppedUp: readNumber(balance.topped_up_balance ?? balance.toppedUpBalance),
1044
+ granted: readNumber(balance.granted_balance ?? balance.grantedBalance),
1045
+ }];
1046
+ });
1047
+ const primary = parsed[0];
1048
+ if (!primary) return null;
1049
+
1050
+ const details: AccountBalance[] = [];
1051
+ if (primary.toppedUp !== null) {
1052
+ details.push({ amount: primary.toppedUp, unit: primary.total.unit, label: "Topped up" });
1053
+ }
1054
+ if (primary.granted !== null) {
1055
+ details.push({ amount: primary.granted, unit: primary.total.unit, label: "Granted" });
1056
+ }
1057
+ for (const additional of parsed.slice(1)) details.push(additional.total);
1058
+
1059
+ return {
1060
+ session: 0,
1061
+ weekly: 0,
1062
+ quotaHidden: true,
1063
+ accountBalance: primary.total,
1064
+ accountBalanceDetails: details,
1065
+ warning: root?.is_available === false ? "Balance is not currently available for API use" : undefined,
1066
+ };
1067
+ }
1068
+
1069
+ export async function fetchDeepSeekBalance(token: string, config: FetchConfig = {}): Promise<UsageData> {
1070
+ const endpoints = config.endpoints ?? resolveUsageEndpoints(config.env);
1071
+ const result = await requestJson(endpoints.deepSeekBalance, {
1072
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
1073
+ }, config);
1074
+ if (!result.ok) return { session: 0, weekly: 0, error: result.error };
1075
+ return extractDeepSeekBalanceFromPayload(result.data) ?? {
1076
+ session: 0,
1077
+ weekly: 0,
1078
+ error: "unrecognized response shape",
1079
+ };
1080
+ }
1081
+
1082
+ export function extractBasetenUsageFromPayload(payload: unknown): UsageData | null {
1083
+ const root = asObject(payload);
1084
+ if (!root) return null;
1085
+ const sections = [root.dedicated_usage, root.training_usage, root.model_apis_usage].map(asObject);
1086
+ const creditsUsed = sections
1087
+ .map((section) => readNumber(section?.credits_used))
1088
+ .filter((value): value is number => value !== null);
1089
+ if (creditsUsed.length === 0) return null;
1090
+
1091
+ return {
1092
+ session: 0,
1093
+ weekly: 0,
1094
+ quotaHidden: true,
1095
+ accountUsage: {
1096
+ amount: Number(creditsUsed.reduce((sum, value) => sum + value, 0).toFixed(6)),
1097
+ unit: "credits",
1098
+ label: "Credits used this month",
1099
+ },
1100
+ };
1101
+ }
1102
+
1103
+ function basetenMonthRange(nowMs: number): { startDate: string; endDate: string } {
1104
+ const now = new Date(nowMs);
1105
+ return {
1106
+ startDate: new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)).toISOString(),
1107
+ endDate: now.toISOString(),
1108
+ };
1109
+ }
1110
+
1111
+ export async function fetchBasetenUsage(token: string, config: BasetenUsageFetchConfig = {}): Promise<UsageData> {
1112
+ const endpoints = config.endpoints ?? resolveUsageEndpoints(config.env);
1113
+ const { startDate, endDate } = basetenMonthRange(config.nowMs ?? Date.now());
1114
+ let url: string;
1115
+ try {
1116
+ const parsed = new URL(endpoints.basetenUsage);
1117
+ parsed.searchParams.set("start_date", startDate);
1118
+ parsed.searchParams.set("end_date", endDate);
1119
+ url = parsed.toString();
1120
+ } catch {
1121
+ return { session: 0, weekly: 0, error: "invalid Baseten usage endpoint" };
1122
+ }
1123
+ const result = await requestJson(url, {
1124
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
1125
+ }, config);
1126
+ if (!result.ok) return { session: 0, weekly: 0, error: result.error };
1127
+ return extractBasetenUsageFromPayload(result.data) ?? {
1128
+ session: 0,
1129
+ weekly: 0,
1130
+ error: "unrecognized response shape",
1131
+ };
1132
+ }
1133
+
1134
+ export function extractMoonshotBalanceFromPayload(
1135
+ payload: unknown,
1136
+ provider: "moonshot" | "moonshot-cn" = "moonshot",
1137
+ ): UsageData | null {
1138
+ const root = asObject(payload);
1139
+ const data = asObject(root?.data) ?? root;
1140
+ if (!data) return null;
1141
+ const available = readNumber(data.available_balance ?? data.availableBalance);
1142
+ if (available === null) return null;
1143
+ const cash = readNumber(data.cash_balance ?? data.cashBalance);
1144
+ const voucher = readNumber(data.voucher_balance ?? data.voucherBalance);
1145
+ const unit = provider === "moonshot-cn" ? "CNY" : "USD";
1146
+ const details: AccountBalance[] = [];
1147
+ if (cash !== null) details.push({ amount: cash, unit, label: "Cash" });
1148
+ if (voucher !== null) details.push({ amount: voucher, unit, label: "Voucher" });
1149
+
1150
+ return {
1151
+ session: 0,
1152
+ weekly: 0,
1153
+ quotaHidden: true,
1154
+ accountBalance: { amount: available, unit, label: "Available balance" },
1155
+ accountBalanceDetails: details,
1156
+ warning: available <= 0 ? "Balance exhausted; inference requests may be rejected" : undefined,
1157
+ };
1158
+ }
1159
+
1160
+ export async function fetchMoonshotBalance(
1161
+ token: string,
1162
+ provider: "moonshot" | "moonshot-cn" = "moonshot",
1163
+ config: FetchConfig = {},
1164
+ ): Promise<UsageData> {
1165
+ const endpoints = config.endpoints ?? resolveUsageEndpoints(config.env);
1166
+ const endpoint = provider === "moonshot-cn" ? endpoints.moonshotCnBalance : endpoints.moonshotBalance;
1167
+ const result = await requestJson(endpoint, {
1168
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
1169
+ }, config);
1170
+ if (!result.ok) return { session: 0, weekly: 0, error: result.error };
1171
+ return extractMoonshotBalanceFromPayload(result.data, provider) ?? {
1172
+ session: 0,
1173
+ weekly: 0,
1174
+ error: "unrecognized response shape",
1175
+ };
1176
+ }
1177
+
1178
+ /** Parse ZAI limits where unit 3 is the five-hour window and unit 6 the weekly window. */
1179
+ export function extractZaiUsageFromPayload(payload: unknown, nowMs = Date.now()): UsageData | null {
1180
+ const data = payload as any;
1181
+ const arrays = [data?.data?.limits, data?.limits, data?.quota?.limits, data?.data?.quota?.limits];
1182
+ const limits = arrays.find(Array.isArray) as any[] | undefined;
1183
+ if (!limits?.length) return null;
1184
+
1185
+ // CREDIT_LIMIT (GLM Coding Plan "lite"/credit-based tiers) reports the same
1186
+ // unit/percentage/nextResetTime shape as TOKENS_LIMIT, so treat both as the
1187
+ // quota windows for session (unit 3) and weekly (unit 6).
1188
+ const tokenLimits = limits.filter((entry) => {
1189
+ const type = String(entry?.type || "").toUpperCase();
1190
+ return type === "TOKENS_LIMIT" || type === "CREDIT_LIMIT";
1191
+ });
1192
+ const sessionEntry = tokenLimits.find((entry) => entry?.unit === 3);
1193
+ const weeklyEntry = tokenLimits.find((entry) => entry?.unit === 6);
1194
+ if (!sessionEntry || !weeklyEntry) return null;
1195
+
1196
+ const session = readPercentCandidate(sessionEntry.percentage);
1197
+ const weekly = readPercentCandidate(weeklyEntry.percentage);
1198
+ if (session === null || weekly === null) return null;
1199
+ const normalized = normalizeUsagePair(session, weekly);
1200
+
1201
+ return {
1202
+ ...normalized,
1203
+ sessionResetsIn: typeof sessionEntry.nextResetTime === "number" && sessionEntry.nextResetTime > 0
1204
+ ? formatDuration(Math.max(0, sessionEntry.nextResetTime - nowMs) / 1000)
1205
+ : undefined,
1206
+ weeklyResetsIn: typeof weeklyEntry.nextResetTime === "number" && weeklyEntry.nextResetTime > 0
1207
+ ? formatDuration(Math.max(0, weeklyEntry.nextResetTime - nowMs) / 1000)
1208
+ : undefined,
1209
+ };
1210
+ }
1211
+
1212
+ export async function fetchZaiUsage(
1213
+ token: string,
1214
+ provider: "zai" | "zai-cn" = "zai",
1215
+ config: FetchConfig = {},
1216
+ ): Promise<UsageData> {
1217
+ const endpoints = config.endpoints ?? resolveUsageEndpoints(config.env);
1218
+ const endpoint = provider === "zai-cn" ? endpoints.zaiCn : endpoints.zai;
1219
+ const result = await requestJson(endpoint, { headers: { Authorization: `Bearer ${token}` } }, config);
1220
+ if (!result.ok) return { session: 0, weekly: 0, error: result.error };
1221
+
1222
+ const zaiUsage = extractZaiUsageFromPayload(result.data);
1223
+ if (zaiUsage) return zaiUsage;
1224
+ return extractUsageFromPayload(result.data) ?? { session: 0, weekly: 0, error: "unrecognized response shape" };
1225
+ }
1226
+
1227
+ export function detectProvider(
1228
+ model: { provider?: string } | string | undefined | null,
1229
+ ): ProviderKey | null {
1230
+ if (!model || typeof model === "string") return null;
1231
+ switch ((model.provider || "").toLowerCase()) {
1232
+ case "openai-codex": return "codex";
1233
+ case "anthropic": return "claude";
1234
+ case "zai": return "zai";
1235
+ case "zai-coding-cn": return "zai-cn";
1236
+ case "kimi-coding": return "kimi";
1237
+ case "minimax": return "minimax";
1238
+ case "minimax-cn": return "minimax-cn";
1239
+ case "openrouter": return "openrouter";
1240
+ case "deepseek": return "deepseek";
1241
+ case "moonshotai": return "moonshot";
1242
+ case "moonshotai-cn": return "moonshot-cn";
1243
+ case "baseten": return "baseten";
1244
+ default: return null;
1245
+ }
1246
+ }
1247
+
1248
+ export function providerToPiProviderId(provider: ProviderKey): PiProviderId {
1249
+ switch (provider) {
1250
+ case "codex": return "openai-codex";
1251
+ case "claude": return "anthropic";
1252
+ case "zai": return "zai";
1253
+ case "zai-cn": return "zai-coding-cn";
1254
+ case "kimi": return "kimi-coding";
1255
+ case "minimax": return "minimax";
1256
+ case "minimax-cn": return "minimax-cn";
1257
+ case "openrouter": return "openrouter";
1258
+ case "deepseek": return "deepseek";
1259
+ case "moonshot": return "moonshotai";
1260
+ case "moonshot-cn": return "moonshotai-cn";
1261
+ case "baseten": return "baseten";
1262
+ }
1263
+ }
1264
+
1265
+ export function clampPercent(value: number): number {
1266
+ if (!Number.isFinite(value)) return 0;
1267
+ return Math.max(0, Math.min(100, Math.round(value)));
1268
+ }
1269
+
1270
+ export function colorForPercent(value: number): "success" | "warning" | "error" {
1271
+ if (value >= 90) return "error";
1272
+ if (value >= 70) return "warning";
1273
+ return "success";
1274
+ }
1275
+
1276
+ export async function fetchAllUsages(
1277
+ tokens: UsageTokens,
1278
+ config: FetchAllUsagesConfig = {},
1279
+ ): Promise<UsageByProvider> {
1280
+ const endpoints = config.endpoints ?? resolveUsageEndpoints(config.env);
1281
+ const results: UsageByProvider = {
1282
+ codex: null,
1283
+ claude: null,
1284
+ zai: null,
1285
+ "zai-cn": null,
1286
+ kimi: null,
1287
+ minimax: null,
1288
+ "minimax-cn": null,
1289
+ openrouter: null,
1290
+ deepseek: null,
1291
+ moonshot: null,
1292
+ "moonshot-cn": null,
1293
+ baseten: null,
1294
+ };
1295
+ const tasks: Promise<void>[] = [];
1296
+
1297
+ const assign = (provider: ProviderKey, request: Promise<UsageData>) => {
1298
+ tasks.push(request.then((usage) => {
1299
+ results[provider] = usage;
1300
+ }).catch((error) => {
1301
+ results[provider] = { session: 0, weekly: 0, error: toErrorMessage(error, config.signal) };
1302
+ }));
1303
+ };
1304
+
1305
+ if (tokens.codex) assign("codex", fetchCodexUsage(tokens.codex, config));
1306
+ if (tokens.claude) {
1307
+ assign("claude", fetchClaudeUsageWithFallback(tokens.claude, {
1308
+ ...config,
1309
+ cacheFile: config.cacheFile,
1310
+ nowMs: config.nowMs,
1311
+ }));
1312
+ }
1313
+ if (tokens.zai) assign("zai", fetchZaiUsage(tokens.zai, "zai", { ...config, endpoints }));
1314
+ if (tokens["zai-cn"]) assign("zai-cn", fetchZaiUsage(tokens["zai-cn"], "zai-cn", { ...config, endpoints }));
1315
+ if (tokens.kimi) assign("kimi", fetchKimiUsage(tokens.kimi, { ...config, endpoints }));
1316
+ if (tokens.minimax) assign("minimax", fetchMiniMaxUsage(tokens.minimax, "minimax", { ...config, endpoints }));
1317
+ if (tokens["minimax-cn"]) {
1318
+ assign("minimax-cn", fetchMiniMaxUsage(tokens["minimax-cn"], "minimax-cn", { ...config, endpoints }));
1319
+ }
1320
+ if (tokens.openrouter) assign("openrouter", fetchOpenRouterUsage(tokens.openrouter, { ...config, endpoints }));
1321
+ if (tokens.deepseek) assign("deepseek", fetchDeepSeekBalance(tokens.deepseek, { ...config, endpoints }));
1322
+ if (tokens.moonshot) assign("moonshot", fetchMoonshotBalance(tokens.moonshot, "moonshot", { ...config, endpoints }));
1323
+ if (tokens["moonshot-cn"]) {
1324
+ assign("moonshot-cn", fetchMoonshotBalance(tokens["moonshot-cn"], "moonshot-cn", { ...config, endpoints }));
1325
+ }
1326
+ if (tokens.baseten) assign("baseten", fetchBasetenUsage(tokens.baseten, { ...config, endpoints }));
1327
+
1328
+ await Promise.all(tasks);
1329
+
1330
+ // Pi intentionally uses MOONSHOT_API_KEY for both regional providers. When one
1331
+ // key works in only one region, hide the expected regional auth failure from
1332
+ // the all-provider view while preserving active-provider polling behavior.
1333
+ if (tokens.moonshot && tokens.moonshot === tokens["moonshot-cn"]) {
1334
+ if (results.moonshot && !results.moonshot.error && results["moonshot-cn"]?.error) {
1335
+ results["moonshot-cn"] = null;
1336
+ } else if (results["moonshot-cn"] && !results["moonshot-cn"].error && results.moonshot?.error) {
1337
+ results.moonshot = null;
1338
+ }
1339
+ }
1340
+ return results;
1341
+ }