@ychris12138/dsh-usage-stats 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +362 -0
- package/SECURITY.md +17 -0
- package/cordis.patch.yml +5 -0
- package/docs/images/usage-panel.svg +176 -0
- package/lib/accounts.js +1272 -0
- package/lib/balance.js +126 -0
- package/lib/client.js +1531 -0
- package/lib/index.js +619 -0
- package/lib/subscriptions.js +610 -0
- package/lib/usage.js +276 -0
- package/package.json +72 -0
- package/scripts/install.mjs +142 -0
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscription-quota module for providers that expose percentage windows.
|
|
3
|
+
*
|
|
4
|
+
* The external interface is deliberately small: callers provide the Harness
|
|
5
|
+
* credentials seam and optional transport/time dependencies, and receive two
|
|
6
|
+
* normalized provider records. Provider credentials, upstream response shapes,
|
|
7
|
+
* parsing quirks, and error mapping remain inside this module.
|
|
8
|
+
*
|
|
9
|
+
* OpenCode Go's documented provider API does not include usage, but its
|
|
10
|
+
* first-party client currently exposes an undocumented Bearer-key endpoint.
|
|
11
|
+
* The adapter prefers that simpler path, can reuse OpenCode's local auth.json,
|
|
12
|
+
* and keeps the authenticated workspace dashboard as a compatibility fallback.
|
|
13
|
+
* Z.ai uses its Coding Plan quota endpoints with a normal API key.
|
|
14
|
+
*
|
|
15
|
+
* @module dsh-usage-stats/subscriptions
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { readFile } from "node:fs/promises";
|
|
19
|
+
import { homedir } from "node:os";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
|
|
22
|
+
const OPENCODE_GO_URL = "https://opencode.ai";
|
|
23
|
+
const OPENCODE_GO_USAGE_URL = `${OPENCODE_GO_URL}/zen/go/v1/usage`;
|
|
24
|
+
const ZAI_HOSTS = {
|
|
25
|
+
global: "https://api.z.ai",
|
|
26
|
+
"bigmodel-cn": "https://open.bigmodel.cn"
|
|
27
|
+
};
|
|
28
|
+
const ZAI_QUOTA_PATH = "/api/monitor/usage/quota/limit";
|
|
29
|
+
const ZAI_SUBSCRIPTION_PATH = "/api/biz/subscription/list";
|
|
30
|
+
const KIMI_USAGE_URL = "https://api.kimi.com/coding/v1/usages";
|
|
31
|
+
const MINIMAX_TOKEN_PLAN_HOSTS = {
|
|
32
|
+
global: "https://www.minimax.io",
|
|
33
|
+
cn: "https://www.minimaxi.com"
|
|
34
|
+
};
|
|
35
|
+
const MINIMAX_LEGACY_HOSTS = {
|
|
36
|
+
global: "https://api.minimax.io",
|
|
37
|
+
cn: "https://api.minimaxi.com"
|
|
38
|
+
};
|
|
39
|
+
const MINIMAX_USAGE_PATH = "/v1/api/openplatform/coding_plan/remains";
|
|
40
|
+
const MINIMAX_TOKEN_PLAN_PATH = "/v1/token_plan/remains";
|
|
41
|
+
const DEFAULT_TIMEOUT_MS = 15000;
|
|
42
|
+
|
|
43
|
+
const REFS = {
|
|
44
|
+
openCodeApiKey: "OPENCODE_GO_API_KEY",
|
|
45
|
+
openCodeCookie: "OPENCODE_GO_AUTH_COOKIE",
|
|
46
|
+
openCodeWorkspace: "OPENCODE_GO_WORKSPACE_ID",
|
|
47
|
+
zaiApiKey: "ZAI_API_KEY",
|
|
48
|
+
zaiRegion: "ZAI_API_REGION",
|
|
49
|
+
kimiApiKey: "KIMI_API_KEY",
|
|
50
|
+
minimaxApiKey: "MINIMAX_API_KEY",
|
|
51
|
+
minimaxRegion: "MINIMAX_API_REGION"
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function numberOrNull(value) {
|
|
55
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
56
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
57
|
+
const parsed = Number(value);
|
|
58
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function clampPercent(value) {
|
|
64
|
+
const parsed = numberOrNull(value);
|
|
65
|
+
return parsed === null ? null : Math.max(0, Math.min(100, parsed));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function round1(value) {
|
|
69
|
+
return Math.round(value * 10) / 10;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function toIso(value) {
|
|
73
|
+
if (value === null || value === void 0 || value === "") return null;
|
|
74
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
75
|
+
const date = new Date(value < 20000000000 ? value * 1000 : value);
|
|
76
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
77
|
+
}
|
|
78
|
+
const date = new Date(String(value));
|
|
79
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function resolveCredential(credentials, ref) {
|
|
83
|
+
if (credentials === void 0 || credentials === null || typeof credentials.resolve !== "function") return "";
|
|
84
|
+
try {
|
|
85
|
+
const hit = await credentials.resolve(ref);
|
|
86
|
+
return typeof hit?.value === "string" ? hit.value.trim() : "";
|
|
87
|
+
} catch {
|
|
88
|
+
return "";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalizedStatus(error) {
|
|
93
|
+
if (error?.name === "TimeoutError" || error?.name === "AbortError") return "unavailable";
|
|
94
|
+
if (error?.providerStatus) return error.providerStatus;
|
|
95
|
+
return error instanceof SyntaxError ? "invalid-response" : "unavailable";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function invalidResponse(message) {
|
|
99
|
+
const error = new Error(message);
|
|
100
|
+
error.providerStatus = "invalid-response";
|
|
101
|
+
return error;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function request(url, init, deps, type) {
|
|
105
|
+
const response = await (deps.fetch ?? fetch)(url, {
|
|
106
|
+
...init,
|
|
107
|
+
signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS)
|
|
108
|
+
});
|
|
109
|
+
if (!response.ok) {
|
|
110
|
+
const error = new Error(`upstream returned HTTP ${response.status}`);
|
|
111
|
+
error.httpStatus = response.status;
|
|
112
|
+
error.providerStatus = response.status === 401 || response.status === 403
|
|
113
|
+
? "unauthorized"
|
|
114
|
+
: response.status === 429 ? "rate-limited" : "unavailable";
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
if (type === "text") return response.text();
|
|
118
|
+
try {
|
|
119
|
+
return await response.json();
|
|
120
|
+
} catch {
|
|
121
|
+
throw invalidResponse("upstream returned invalid JSON");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function sanitizeCookie(raw) {
|
|
126
|
+
let value = String(raw ?? "").trim().replace(/^cookie\s*:\s*/i, "");
|
|
127
|
+
value = value.split(";").map((part) => part.trim()).filter(Boolean).join("; ");
|
|
128
|
+
return value !== "" && !value.includes("=") ? `auth=${value}` : value;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function workspaceIdOf(raw) {
|
|
132
|
+
return String(raw ?? "").match(/wrk_[A-Za-z0-9]+/)?.[0] ?? "";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function looksSignedOut(text) {
|
|
136
|
+
const lower = String(text).toLowerCase();
|
|
137
|
+
return lower.includes("sign in") || lower.includes("login") || lower.includes("auth/authorize") || lower.includes('actor of type "public"');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function goWindowFromObject(value, kind, now) {
|
|
141
|
+
if (value === null || typeof value !== "object") return null;
|
|
142
|
+
const percentSource = value.usagePercent ?? value.usedPercent ?? value.percentUsed ?? value.percentage ?? value.percent;
|
|
143
|
+
let usedPercent = clampPercent(percentSource);
|
|
144
|
+
if (usedPercent === null) {
|
|
145
|
+
const used = numberOrNull(value.used ?? value.consumed);
|
|
146
|
+
const limit = numberOrNull(value.limit ?? value.total ?? value.quota);
|
|
147
|
+
if (used !== null && limit !== null && limit > 0) usedPercent = clampPercent((used / limit) * 100);
|
|
148
|
+
}
|
|
149
|
+
if (usedPercent === null) return null;
|
|
150
|
+
// The dashboard embeds usagePercent as a 0..1 ratio. The Bearer endpoint's
|
|
151
|
+
// `percent` is already 0..100, so only scale ratio-named dashboard fields.
|
|
152
|
+
if (usedPercent <= 1 && usedPercent >= 0 && value.percent === void 0 && percentSource !== void 0) usedPercent *= 100;
|
|
153
|
+
const resetSeconds = numberOrNull(value.resetInSec ?? value.resetInSeconds ?? value.resetSeconds);
|
|
154
|
+
const resetsAt = resetSeconds === null ? toIso(value.resetAt ?? value.resetsAt ?? value.nextReset) : new Date(now + Math.max(0, resetSeconds) * 1000).toISOString();
|
|
155
|
+
return {
|
|
156
|
+
kind,
|
|
157
|
+
usedPercent: round1(clampPercent(usedPercent)),
|
|
158
|
+
remainingPercent: round1(100 - clampPercent(usedPercent)),
|
|
159
|
+
...(resetsAt === null ? {} : { resetsAt })
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseOpenCodeGoApi(body, now) {
|
|
164
|
+
const usage = body?.usage ?? body;
|
|
165
|
+
if (usage === null || typeof usage !== "object") return [];
|
|
166
|
+
return [
|
|
167
|
+
goWindowFromObject(usage.rolling, "session", now),
|
|
168
|
+
goWindowFromObject(usage.weekly, "weekly", now),
|
|
169
|
+
goWindowFromObject(usage.monthly, "monthly", now)
|
|
170
|
+
].filter(Boolean);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function findObject(root, keyword, depth = 0) {
|
|
174
|
+
if (root === null || typeof root !== "object" || depth > 5) return null;
|
|
175
|
+
for (const [key, value] of Object.entries(root)) {
|
|
176
|
+
if (key.toLowerCase().includes(keyword) && value !== null && typeof value === "object") return value;
|
|
177
|
+
}
|
|
178
|
+
for (const value of Object.values(root)) {
|
|
179
|
+
const found = findObject(value, keyword, depth + 1);
|
|
180
|
+
if (found !== null) return found;
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function goWindowFromText(text, key, kind, now) {
|
|
186
|
+
const percent = new RegExp(`${key}[^}]*?usagePercent\\s*[:=]\\s*([0-9]+(?:\\.[0-9]+)?)`, "i").exec(text);
|
|
187
|
+
if (percent === null) return null;
|
|
188
|
+
const reset = new RegExp(`${key}[^}]*?resetInSec\\s*[:=]\\s*([0-9]+)`, "i").exec(text);
|
|
189
|
+
const usedPercent = round1(clampPercent(Number(percent[1])));
|
|
190
|
+
return {
|
|
191
|
+
kind,
|
|
192
|
+
usedPercent,
|
|
193
|
+
remainingPercent: round1(100 - usedPercent),
|
|
194
|
+
...(reset === null ? {} : { resetsAt: new Date(now + Number(reset[1]) * 1000).toISOString() })
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function parseOpenCodeGo(text, now) {
|
|
199
|
+
let windows = [];
|
|
200
|
+
try {
|
|
201
|
+
const root = JSON.parse(text);
|
|
202
|
+
windows = [
|
|
203
|
+
goWindowFromObject(findObject(root, "rolling"), "session", now),
|
|
204
|
+
goWindowFromObject(findObject(root, "weekly") ?? findObject(root, "week"), "weekly", now),
|
|
205
|
+
goWindowFromObject(findObject(root, "monthly") ?? findObject(root, "month"), "monthly", now)
|
|
206
|
+
].filter(Boolean);
|
|
207
|
+
} catch {
|
|
208
|
+
/* The dashboard may embed text/javascript rather than strict JSON. */
|
|
209
|
+
}
|
|
210
|
+
if (!windows.some((window) => window.kind === "session") || !windows.some((window) => window.kind === "weekly")) {
|
|
211
|
+
windows = [
|
|
212
|
+
goWindowFromText(text, "rollingUsage", "session", now),
|
|
213
|
+
goWindowFromText(text, "weeklyUsage", "weekly", now),
|
|
214
|
+
goWindowFromText(text, "monthlyUsage", "monthly", now)
|
|
215
|
+
].filter(Boolean);
|
|
216
|
+
}
|
|
217
|
+
return windows.some((window) => window.kind === "session") && windows.some((window) => window.kind === "weekly") ? windows : [];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function localOpenCodeApiKey(deps) {
|
|
221
|
+
try {
|
|
222
|
+
const home = typeof deps.homedir === "function" ? deps.homedir() : homedir();
|
|
223
|
+
const load = deps.readFile ?? readFile;
|
|
224
|
+
const raw = JSON.parse(await load(join(home, ".local", "share", "opencode", "auth.json"), "utf8"));
|
|
225
|
+
const entry = raw?.["opencode-go"] ?? raw?.opencode;
|
|
226
|
+
return entry?.type === "api" && typeof entry.key === "string" ? entry.key.trim() : "";
|
|
227
|
+
} catch {
|
|
228
|
+
return "";
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function collectOpenCodeGoFromDashboard(cookie, workspaceId, deps) {
|
|
233
|
+
try {
|
|
234
|
+
const text = await request(`${OPENCODE_GO_URL}/workspace/${workspaceId}/go`, {
|
|
235
|
+
headers: {
|
|
236
|
+
cookie,
|
|
237
|
+
accept: "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8"
|
|
238
|
+
}
|
|
239
|
+
}, deps, "text");
|
|
240
|
+
if (looksSignedOut(text)) return { status: "unauthorized", windows: [] };
|
|
241
|
+
const windows = parseOpenCodeGo(text, deps.now());
|
|
242
|
+
return { status: windows.length > 0 ? "ok" : "invalid-response", windows };
|
|
243
|
+
} catch (error) {
|
|
244
|
+
return { status: normalizedStatus(error), windows: [] };
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function collectOpenCodeGo(credentials, deps) {
|
|
249
|
+
const apiKeyRef = deps.apiKeyRef ?? REFS.openCodeApiKey;
|
|
250
|
+
const [configuredApiKey, cookieRaw, workspaceRaw] = await Promise.all([
|
|
251
|
+
resolveCredential(credentials, apiKeyRef),
|
|
252
|
+
resolveCredential(credentials, REFS.openCodeCookie),
|
|
253
|
+
resolveCredential(credentials, REFS.openCodeWorkspace)
|
|
254
|
+
]);
|
|
255
|
+
const apiKey = configuredApiKey || await localOpenCodeApiKey(deps);
|
|
256
|
+
const cookie = sanitizeCookie(cookieRaw);
|
|
257
|
+
const workspaceId = workspaceIdOf(workspaceRaw);
|
|
258
|
+
if (apiKey === "" && (cookie === "" || workspaceId === "")) {
|
|
259
|
+
return {
|
|
260
|
+
id: "opencode-go",
|
|
261
|
+
displayName: "OpenCode Go",
|
|
262
|
+
mode: "subscription",
|
|
263
|
+
status: "not-configured",
|
|
264
|
+
plan: "Go",
|
|
265
|
+
missingCredentials: [apiKeyRef],
|
|
266
|
+
windows: []
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let apiStatus = "unavailable";
|
|
271
|
+
if (apiKey !== "") {
|
|
272
|
+
try {
|
|
273
|
+
const body = await request(OPENCODE_GO_USAGE_URL, {
|
|
274
|
+
headers: { authorization: `Bearer ${apiKey}`, accept: "application/json" }
|
|
275
|
+
}, deps, "json");
|
|
276
|
+
const windows = parseOpenCodeGoApi(body, deps.now());
|
|
277
|
+
if (windows.length > 0) {
|
|
278
|
+
return { id: "opencode-go", displayName: "OpenCode Go", mode: "subscription", status: "ok", plan: "Go", windows };
|
|
279
|
+
}
|
|
280
|
+
apiStatus = "invalid-response";
|
|
281
|
+
} catch (error) {
|
|
282
|
+
apiStatus = normalizedStatus(error);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (cookie !== "" && workspaceId !== "") {
|
|
286
|
+
const dashboard = await collectOpenCodeGoFromDashboard(cookie, workspaceId, deps);
|
|
287
|
+
return { id: "opencode-go", displayName: "OpenCode Go", mode: "subscription", status: dashboard.status, plan: "Go", windows: dashboard.windows };
|
|
288
|
+
}
|
|
289
|
+
return { id: "opencode-go", displayName: "OpenCode Go", mode: "subscription", status: apiStatus, plan: "Go", windows: [] };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function zaiRegionOf(raw, fallback = "global") {
|
|
293
|
+
const value = String(raw || fallback).trim().toLowerCase();
|
|
294
|
+
return value === "bigmodel-cn" || value === "cn" || value.includes("bigmodel.cn") ? "bigmodel-cn" : "global";
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function zaiWindowMinutes(limit) {
|
|
298
|
+
const unit = numberOrNull(limit?.unit);
|
|
299
|
+
const number = numberOrNull(limit?.number);
|
|
300
|
+
if (unit === null || number === null || number <= 0) return null;
|
|
301
|
+
if (unit === 5) return number;
|
|
302
|
+
if (unit === 3) return number * 60;
|
|
303
|
+
if (unit === 1) return number * 24 * 60;
|
|
304
|
+
if (unit === 6) return number * 7 * 24 * 60;
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function zaiUsedPercent(limit) {
|
|
309
|
+
const total = numberOrNull(limit?.usage);
|
|
310
|
+
const remaining = numberOrNull(limit?.remaining);
|
|
311
|
+
const current = numberOrNull(limit?.currentValue ?? limit?.current_value);
|
|
312
|
+
if (total !== null && total > 0) {
|
|
313
|
+
const used = remaining === null ? current : current === null ? total - remaining : Math.max(total - remaining, current);
|
|
314
|
+
if (used !== null) return clampPercent((Math.max(0, Math.min(total, used)) / total) * 100);
|
|
315
|
+
}
|
|
316
|
+
return clampPercent(limit?.percentage ?? limit?.usedPercent ?? limit?.used_percent);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function displayPlan(value) {
|
|
320
|
+
return String(value ?? "").trim().replace(/[_-]+/g, " ").replace(/\s+/g, " ").replace(/\bglm\b/gi, "GLM").replace(/\b\w/g, (char) => char.toUpperCase());
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function zaiPlan(quota, subscription) {
|
|
324
|
+
const row = Array.isArray(subscription?.data) ? subscription.data.find((entry) => entry && typeof entry === "object") : null;
|
|
325
|
+
for (const source of [row, quota?.data]) {
|
|
326
|
+
for (const key of ["product_name", "productName", "plan_name", "planName", "package_name", "packageName", "plan_type", "planType", "level"]) {
|
|
327
|
+
const value = displayPlan(source?.[key]);
|
|
328
|
+
if (value !== "") return value;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return "GLM Coding Plan";
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function zaiWindow(limit, kind, fallbackReset = null) {
|
|
335
|
+
const usedPercent = zaiUsedPercent(limit);
|
|
336
|
+
if (usedPercent === null) return null;
|
|
337
|
+
const resetsAt = toIso(limit.nextResetTime ?? limit.next_reset_time) ?? fallbackReset;
|
|
338
|
+
return {
|
|
339
|
+
kind,
|
|
340
|
+
usedPercent: round1(usedPercent),
|
|
341
|
+
remainingPercent: round1(100 - usedPercent),
|
|
342
|
+
...(resetsAt === null ? {} : { resetsAt }),
|
|
343
|
+
...(numberOrNull(limit.remaining) === null ? {} : { remaining: numberOrNull(limit.remaining) })
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function parseZai(quota, subscription) {
|
|
348
|
+
const limits = Array.isArray(quota?.data?.limits) ? quota.data.limits : [];
|
|
349
|
+
const tokenLimits = limits.filter((limit) => ["TOKENS_LIMIT", "CREDIT_LIMIT"].includes(String(limit?.type ?? limit?.limit_type ?? "").toUpperCase()) && zaiUsedPercent(limit) !== null)
|
|
350
|
+
.sort((a, b) => (zaiWindowMinutes(a) ?? Number.MAX_SAFE_INTEGER) - (zaiWindowMinutes(b) ?? Number.MAX_SAFE_INTEGER));
|
|
351
|
+
const timeLimit = limits.find((limit) => String(limit?.type ?? limit?.limit_type ?? "").toUpperCase() === "TIME_LIMIT" && zaiUsedPercent(limit) !== null) ?? null;
|
|
352
|
+
const first = tokenLimits[0] ?? null;
|
|
353
|
+
const session = tokenLimits.length >= 2 ? first : zaiWindowMinutes(first) !== null && zaiWindowMinutes(first) <= 360 ? first : null;
|
|
354
|
+
const weekly = tokenLimits.length >= 2 ? tokenLimits[tokenLimits.length - 1] : session === null ? first : null;
|
|
355
|
+
const subscriptionRow = Array.isArray(subscription?.data) ? subscription.data[0] : null;
|
|
356
|
+
const renewAt = toIso(subscriptionRow?.next_renew_time ?? subscriptionRow?.nextRenewTime);
|
|
357
|
+
return {
|
|
358
|
+
plan: zaiPlan(quota, subscription),
|
|
359
|
+
windows: [
|
|
360
|
+
session === null ? null : zaiWindow(session, "session"),
|
|
361
|
+
weekly === null ? null : zaiWindow(weekly, "weekly"),
|
|
362
|
+
timeLimit === null ? null : zaiWindow(timeLimit, "billing", renewAt)
|
|
363
|
+
].filter(Boolean)
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function collectZai(credentials, deps) {
|
|
368
|
+
const apiKeyRef = deps.zaiApiKeyRef ?? REFS.zaiApiKey;
|
|
369
|
+
const [apiKey, configuredRegion] = await Promise.all([
|
|
370
|
+
resolveCredential(credentials, apiKeyRef),
|
|
371
|
+
resolveCredential(credentials, REFS.zaiRegion)
|
|
372
|
+
]);
|
|
373
|
+
const region = zaiRegionOf(configuredRegion, deps.zaiDefaultRegion);
|
|
374
|
+
if (apiKey === "") {
|
|
375
|
+
return { id: "zai", displayName: "Z.ai", mode: "subscription", status: "not-configured", plan: "GLM Coding Plan", region, missingCredentials: [apiKeyRef], windows: [] };
|
|
376
|
+
}
|
|
377
|
+
const host = ZAI_HOSTS[region];
|
|
378
|
+
// The Coding Plan endpoint expects the raw API key, unlike the inference API.
|
|
379
|
+
const init = { headers: { authorization: apiKey, accept: "application/json" } };
|
|
380
|
+
try {
|
|
381
|
+
const quota = await request(`${host}${ZAI_QUOTA_PATH}`, init, deps, "json");
|
|
382
|
+
let subscription = null;
|
|
383
|
+
try {
|
|
384
|
+
subscription = await request(`${host}${ZAI_SUBSCRIPTION_PATH}`, init, deps, "json");
|
|
385
|
+
} catch {
|
|
386
|
+
/* Plan label/reset metadata is optional when quota succeeded. */
|
|
387
|
+
}
|
|
388
|
+
const parsed = parseZai(quota, subscription);
|
|
389
|
+
return { id: "zai", displayName: "Z.ai", mode: "subscription", status: parsed.windows.length > 0 ? "ok" : "invalid-response", plan: parsed.plan, region, windows: parsed.windows };
|
|
390
|
+
} catch (error) {
|
|
391
|
+
return { id: "zai", displayName: "Z.ai", mode: "subscription", status: normalizedStatus(error), plan: "GLM Coding Plan", region, windows: [] };
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function limitWindow(value, kind) {
|
|
396
|
+
if (value === null || typeof value !== "object") return null;
|
|
397
|
+
const limit = numberOrNull(value.limit ?? value.total);
|
|
398
|
+
const remaining = numberOrNull(value.remaining);
|
|
399
|
+
if (limit === null || remaining === null || limit <= 0) return null;
|
|
400
|
+
const usedPercent = round1(clampPercent((limit - remaining) / limit * 100));
|
|
401
|
+
const resetsAt = toIso(value.resetTime ?? value.reset_time ?? value.resetsAt);
|
|
402
|
+
return {
|
|
403
|
+
kind,
|
|
404
|
+
usedPercent,
|
|
405
|
+
remainingPercent: round1(100 - usedPercent),
|
|
406
|
+
...(resetsAt === null ? {} : { resetsAt })
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function parseKimi(body) {
|
|
411
|
+
const data = body?.data ?? body;
|
|
412
|
+
const limits = Array.isArray(data?.limits) ? data.limits : [];
|
|
413
|
+
const session = limits.map((entry) => limitWindow(entry?.detail ?? entry, "session")).find(Boolean) ?? null;
|
|
414
|
+
const weekly = limitWindow(data?.usage, "weekly");
|
|
415
|
+
return {
|
|
416
|
+
plan: String(data?.plan ?? data?.planName ?? "Kimi For Coding"),
|
|
417
|
+
windows: [session, weekly].filter(Boolean)
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function collectKimi(credentials, deps) {
|
|
422
|
+
const apiKeyRef = deps.apiKeyRef ?? REFS.kimiApiKey;
|
|
423
|
+
const apiKey = await resolveCredential(credentials, apiKeyRef);
|
|
424
|
+
if (apiKey === "") return { id: "kimi", displayName: "Kimi For Coding", mode: "subscription", status: "not-configured", plan: "Kimi For Coding", missingCredentials: [apiKeyRef], windows: [] };
|
|
425
|
+
try {
|
|
426
|
+
const configured = nonEmptyUrl(deps.baseURL, "/coding/v1/usages") ?? KIMI_USAGE_URL;
|
|
427
|
+
const body = await request(configured, {
|
|
428
|
+
headers: { authorization: `Bearer ${apiKey}`, accept: "application/json" }
|
|
429
|
+
}, deps, "json");
|
|
430
|
+
const parsed = parseKimi(body);
|
|
431
|
+
return { id: "kimi", displayName: "Kimi For Coding", mode: "subscription", status: parsed.windows.length > 0 ? "ok" : "invalid-response", ...parsed };
|
|
432
|
+
} catch (error) {
|
|
433
|
+
return { id: "kimi", displayName: "Kimi For Coding", mode: "subscription", status: normalizedStatus(error), plan: "Kimi For Coding", windows: [] };
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function nonEmptyUrl(value, defaultPath) {
|
|
438
|
+
if (typeof value !== "string" || value.trim() === "") return null;
|
|
439
|
+
try {
|
|
440
|
+
const url = new URL(value);
|
|
441
|
+
return url.pathname === "/" || url.pathname === "" ? new URL(defaultPath, url).href : url.href;
|
|
442
|
+
} catch {
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function minimaxRegionOf(raw, baseURL) {
|
|
448
|
+
const value = String(raw ?? "").trim().toLowerCase();
|
|
449
|
+
if (value === "cn" || value.includes("minimaxi.com") || String(baseURL ?? "").includes("minimaxi.com")) return "cn";
|
|
450
|
+
return "global";
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function resetFromDuration(value, now) {
|
|
454
|
+
const milliseconds = numberOrNull(value);
|
|
455
|
+
if (milliseconds === null || milliseconds < 0) return null;
|
|
456
|
+
const date = new Date(now + milliseconds);
|
|
457
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const MINIMAX_CHAT_MODEL_PATTERN = /^(minimax-m|coding-plan)/i;
|
|
461
|
+
|
|
462
|
+
function minimaxChatEntry(remains) {
|
|
463
|
+
const named = remains.find((entry) => String(entry?.model_name ?? entry?.modelName ?? "").toLowerCase() === "general");
|
|
464
|
+
if (named !== void 0) return named;
|
|
465
|
+
// Newer payload versions name the chat entry after the model itself
|
|
466
|
+
// (e.g. "MiniMax-M3") instead of the "general" resource group.
|
|
467
|
+
return remains.find((entry) => MINIMAX_CHAT_MODEL_PATTERN.test(String(entry?.model_name ?? entry?.modelName ?? "")));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function minimaxWindowRemaining(remainingPercent, totalCount, usageCount, status) {
|
|
471
|
+
const remaining = clampPercent(remainingPercent);
|
|
472
|
+
if (remaining !== null) return remaining;
|
|
473
|
+
// Older payload versions carry real counters instead of percentages;
|
|
474
|
+
// current ones zero the counters, so only trust totals above zero.
|
|
475
|
+
const total = numberOrNull(totalCount);
|
|
476
|
+
const usage = numberOrNull(usageCount);
|
|
477
|
+
if (total !== null && total > 0 && usage !== null) return clampPercent((1 - usage / total) * 100);
|
|
478
|
+
// MiniMax window status: 1 = normal limited, 2 = exhausted, 3 = unlimited.
|
|
479
|
+
// A missing percentage must not hide the window entirely.
|
|
480
|
+
if (status === 2) return 0;
|
|
481
|
+
if (status === 3) return 100;
|
|
482
|
+
return null;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function parseMiniMax(body, now) {
|
|
486
|
+
const statusCode = numberOrNull(body?.base_resp?.status_code ?? body?.baseResp?.statusCode);
|
|
487
|
+
if (statusCode !== null && statusCode !== 0) {
|
|
488
|
+
const message = body?.base_resp?.status_msg ?? body?.baseResp?.statusMsg;
|
|
489
|
+
const suffix = typeof message === "string" && message.trim() !== "" ? `: ${message.trim()}` : "";
|
|
490
|
+
return { windows: [], reason: `base_resp status_code ${statusCode}${suffix}` };
|
|
491
|
+
}
|
|
492
|
+
const remains = Array.isArray(body?.model_remains) ? body.model_remains : Array.isArray(body?.data?.model_remains) ? body.data.model_remains : [];
|
|
493
|
+
if (remains.length === 0) return { windows: [], reason: "response has no model_remains entries" };
|
|
494
|
+
const general = minimaxChatEntry(remains);
|
|
495
|
+
if (general === void 0) return { windows: [], reason: "model_remains has no general/chat-model entry" };
|
|
496
|
+
const intervalRemaining = minimaxWindowRemaining(
|
|
497
|
+
general.current_interval_remaining_percent ?? general.currentIntervalRemainingPercent,
|
|
498
|
+
general.current_interval_total_count ?? general.currentIntervalTotalCount,
|
|
499
|
+
general.current_interval_usage_count ?? general.currentIntervalUsageCount,
|
|
500
|
+
numberOrNull(general.current_interval_status ?? general.currentIntervalStatus)
|
|
501
|
+
);
|
|
502
|
+
const weeklyRemaining = minimaxWindowRemaining(
|
|
503
|
+
general.current_weekly_remaining_percent ?? general.currentWeeklyRemainingPercent,
|
|
504
|
+
general.current_weekly_total_count ?? general.currentWeeklyTotalCount,
|
|
505
|
+
general.current_weekly_usage_count ?? general.currentWeeklyUsageCount,
|
|
506
|
+
numberOrNull(general.current_weekly_status ?? general.currentWeeklyStatus)
|
|
507
|
+
);
|
|
508
|
+
const sessionReset = toIso(general.current_interval_end_time ?? general.currentIntervalEndTime ?? general.current_interval_reset_time)
|
|
509
|
+
?? resetFromDuration(general.remains_time ?? general.remainsTime, now);
|
|
510
|
+
const weeklyReset = toIso(general.current_weekly_end_time ?? general.currentWeeklyEndTime ?? general.current_weekly_reset_time)
|
|
511
|
+
?? resetFromDuration(general.weekly_remains_time ?? general.weeklyRemainsTime, now);
|
|
512
|
+
const windows = [
|
|
513
|
+
intervalRemaining === null ? null : {
|
|
514
|
+
kind: "session",
|
|
515
|
+
usedPercent: round1(100 - intervalRemaining),
|
|
516
|
+
remainingPercent: round1(intervalRemaining),
|
|
517
|
+
...(sessionReset === null ? {} : { resetsAt: sessionReset })
|
|
518
|
+
},
|
|
519
|
+
weeklyRemaining === null ? null : {
|
|
520
|
+
kind: "weekly",
|
|
521
|
+
usedPercent: round1(100 - weeklyRemaining),
|
|
522
|
+
remainingPercent: round1(weeklyRemaining),
|
|
523
|
+
...(weeklyReset === null ? {} : { resetsAt: weeklyReset })
|
|
524
|
+
}
|
|
525
|
+
].filter(Boolean);
|
|
526
|
+
return { windows, reason: windows.length === 0 ? "chat-model entry has no usable quota fields" : null };
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
async function collectMiniMax(credentials, deps) {
|
|
530
|
+
const apiKeyRef = deps.apiKeyRef ?? REFS.minimaxApiKey;
|
|
531
|
+
const [apiKey, configuredRegion] = await Promise.all([
|
|
532
|
+
resolveCredential(credentials, apiKeyRef),
|
|
533
|
+
resolveCredential(credentials, REFS.minimaxRegion)
|
|
534
|
+
]);
|
|
535
|
+
const region = minimaxRegionOf(deps.region ?? configuredRegion, deps.baseURL);
|
|
536
|
+
if (apiKey === "") return { id: "minimax", displayName: "MiniMax Coding Plan", mode: "subscription", status: "not-configured", plan: "MiniMax Coding Plan", region, missingCredentials: [apiKeyRef], windows: [] };
|
|
537
|
+
const configuredUrl = nonEmptyUrl(deps.baseURL, MINIMAX_USAGE_PATH);
|
|
538
|
+
// The token-plan endpoint is served on both the www and api hosts; try the
|
|
539
|
+
// api host before falling back to the legacy coding-plan path.
|
|
540
|
+
const urls = configuredUrl === null ? [
|
|
541
|
+
`${MINIMAX_TOKEN_PLAN_HOSTS[region]}${MINIMAX_TOKEN_PLAN_PATH}`,
|
|
542
|
+
`${MINIMAX_LEGACY_HOSTS[region]}${MINIMAX_TOKEN_PLAN_PATH}`,
|
|
543
|
+
`${MINIMAX_LEGACY_HOSTS[region]}${MINIMAX_USAGE_PATH}`
|
|
544
|
+
] : [configuredUrl];
|
|
545
|
+
try {
|
|
546
|
+
let body = null;
|
|
547
|
+
for (const [index, url] of urls.entries()) {
|
|
548
|
+
try {
|
|
549
|
+
body = await request(url, {
|
|
550
|
+
headers: { authorization: `Bearer ${apiKey}`, accept: "application/json" }
|
|
551
|
+
}, deps, "json");
|
|
552
|
+
break;
|
|
553
|
+
} catch (error) {
|
|
554
|
+
// Route not found or a non-JSON (e.g. HTML) reply means this host
|
|
555
|
+
// does not serve the endpoint; auth and rate-limit failures are
|
|
556
|
+
// real answers and must not fall through to another host.
|
|
557
|
+
const tryNext = error?.httpStatus === 404 || error?.httpStatus === 405 || error?.providerStatus === "invalid-response";
|
|
558
|
+
if (index < urls.length - 1 && tryNext) continue;
|
|
559
|
+
throw error;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
const { windows, reason } = parseMiniMax(body, deps.now());
|
|
563
|
+
return {
|
|
564
|
+
id: "minimax",
|
|
565
|
+
displayName: "MiniMax Coding Plan",
|
|
566
|
+
mode: "subscription",
|
|
567
|
+
status: windows.length > 0 ? "ok" : "invalid-response",
|
|
568
|
+
plan: "MiniMax Coding Plan",
|
|
569
|
+
region,
|
|
570
|
+
windows,
|
|
571
|
+
// Non-sensitive diagnostic so reports can say WHY parsing failed.
|
|
572
|
+
...(windows.length > 0 || reason === null ? {} : { reason })
|
|
573
|
+
};
|
|
574
|
+
} catch (error) {
|
|
575
|
+
return { id: "minimax", displayName: "MiniMax Coding Plan", mode: "subscription", status: normalizedStatus(error), plan: "MiniMax Coding Plan", region, windows: [] };
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** Query one subscription/token-plan adapter. */
|
|
580
|
+
export async function collectSubscription(providerId, credentials, options = {}, deps = {}) {
|
|
581
|
+
const shared = {
|
|
582
|
+
fetch: deps.fetch,
|
|
583
|
+
readFile: deps.readFile,
|
|
584
|
+
homedir: deps.homedir,
|
|
585
|
+
timeoutMs: deps.timeoutMs,
|
|
586
|
+
now: deps.now ?? Date.now,
|
|
587
|
+
apiKeyRef: options.apiKeyRef,
|
|
588
|
+
baseURL: options.baseURL,
|
|
589
|
+
region: options.region
|
|
590
|
+
};
|
|
591
|
+
if (providerId === "opencode-go") return collectOpenCodeGo(credentials, shared);
|
|
592
|
+
if (providerId === "zai") return collectZai(credentials, {
|
|
593
|
+
...shared,
|
|
594
|
+
zaiApiKeyRef: options.apiKeyRef,
|
|
595
|
+
zaiDefaultRegion: options.region ?? "global"
|
|
596
|
+
});
|
|
597
|
+
if (providerId === "kimi") return collectKimi(credentials, shared);
|
|
598
|
+
if (providerId === "minimax") return collectMiniMax(credentials, shared);
|
|
599
|
+
return { id: providerId, displayName: providerId, mode: "subscription", status: "unavailable", windows: [] };
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** Collect every supported subscription provider concurrently. */
|
|
603
|
+
export async function collectSubscriptions(credentials, options = {}, deps = {}) {
|
|
604
|
+
return Promise.all([
|
|
605
|
+
collectSubscription("opencode-go", credentials, { apiKeyRef: options.openCodeApiKeyRef }, deps),
|
|
606
|
+
collectSubscription("zai", credentials, { apiKeyRef: options.zaiApiKeyRef, region: options.zaiDefaultRegion ?? "global" }, deps)
|
|
607
|
+
]);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
export const subscriptionCredentialRefs = { ...REFS };
|