@duke-dsh-plugins/dsh-token-stats 1.3.4 → 1.5.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.
- package/README.md +17 -3
- package/client.js +561 -18
- package/index.js +808 -13
- package/package.json +9 -3
- package/typert.host.js +183 -0
package/index.js
CHANGED
|
@@ -25,6 +25,14 @@
|
|
|
25
25
|
*
|
|
26
26
|
* Aggregates are kept per local calendar day per model key
|
|
27
27
|
* (`<provider>::<model>`), which is exactly what the Client charts consume.
|
|
28
|
+
*
|
|
29
|
+
* A second Remote method, `getQuota(provider, force)`, reports one provider's
|
|
30
|
+
* coding-plan quota / balance (minimax / deepseek / kimi / openrouter / zhipu /
|
|
31
|
+
* mimo) for the composer readout — adapted from dsh-musage (MIT). It resolves
|
|
32
|
+
* the user-configured API key through the `credentials` service, GETs the
|
|
33
|
+
* provider's endpoint (global fetch, curl via `subprocess` as fallback), and
|
|
34
|
+
* caches per provider (30 s TTL, exponential backoff on failure). In-memory
|
|
35
|
+
* only; nothing below touches the persisted stats store.
|
|
28
36
|
*/
|
|
29
37
|
|
|
30
38
|
import { Remote, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
|
|
@@ -72,6 +80,521 @@ function markRemoteMethod(instance, method, exportName) {
|
|
|
72
80
|
for (const fn of initializers) fn.call(instance);
|
|
73
81
|
}
|
|
74
82
|
|
|
83
|
+
/* ======================================================================
|
|
84
|
+
* Provider quota / balance (套餐余额) — adapted from dsh-musage
|
|
85
|
+
* (https://github.com/Thedeergod666/dsh-musage, MIT).
|
|
86
|
+
*
|
|
87
|
+
* Per provider: resolve the user's already-configured API key through the
|
|
88
|
+
* DSH `credentials` service (ref naming follows the settings page's
|
|
89
|
+
* `<ROUTE>_API_KEY` derivation, e.g. minimax-cn → MINIMAX_CN_API_KEY;
|
|
90
|
+
* deepseek-official ships an explicit apiKeyEnv: DEEPSEEK_API_KEY), GET the
|
|
91
|
+
* provider's plan/balance endpoint (global fetch first — the host runs on a
|
|
92
|
+
* modern Node/Electron runtime; curl via the `subprocess` service as the
|
|
93
|
+
* fallback), parse into a compact `display` payload, and cache per provider
|
|
94
|
+
* (30 s TTL on success, exponential backoff up to 30 min on failure).
|
|
95
|
+
* ==================================================================== */
|
|
96
|
+
|
|
97
|
+
const QUOTA_CACHE_TTL_MS = 30000;
|
|
98
|
+
const QUOTA_BACKOFF_BASE_MS = 5000;
|
|
99
|
+
const QUOTA_BACKOFF_MAX_MS = 30 * 60 * 1000;
|
|
100
|
+
const QUOTA_REQUEST_TIMEOUT_MS = 15000;
|
|
101
|
+
|
|
102
|
+
const QUOTA_PROVIDERS = {
|
|
103
|
+
minimax: {
|
|
104
|
+
refs: ["MINIMAX_CN_API_KEY", "MINIMAX_EN_API_KEY", "MINIMAX_API_KEY"],
|
|
105
|
+
urls: {
|
|
106
|
+
MINIMAX_CN_API_KEY: "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains",
|
|
107
|
+
MINIMAX_EN_API_KEY: "https://api.minimax.io/v1/api/openplatform/coding_plan/remains",
|
|
108
|
+
MINIMAX_API_KEY: "https://api.minimaxi.com/v1/api/openplatform/coding_plan/remains",
|
|
109
|
+
},
|
|
110
|
+
parse: parseMinimaxResponse,
|
|
111
|
+
},
|
|
112
|
+
deepseek: {
|
|
113
|
+
refs: ["DEEPSEEK_API_KEY", "DEEPSEEK_OFFICIAL_API_KEY"],
|
|
114
|
+
urls: {
|
|
115
|
+
DEEPSEEK_API_KEY: "https://api.deepseek.com/user/balance",
|
|
116
|
+
DEEPSEEK_OFFICIAL_API_KEY: "https://api.deepseek.com/user/balance",
|
|
117
|
+
},
|
|
118
|
+
parse: parseDeepseekBalance,
|
|
119
|
+
},
|
|
120
|
+
kimi: {
|
|
121
|
+
refs: ["KIMI_CODING_API_KEY", "KIMI_API_KEY"],
|
|
122
|
+
urls: {
|
|
123
|
+
KIMI_CODING_API_KEY: "https://api.kimi.com/coding/v1/usages",
|
|
124
|
+
KIMI_API_KEY: "https://api.kimi.com/coding/v1/usages",
|
|
125
|
+
},
|
|
126
|
+
parse: parseKimiResponse,
|
|
127
|
+
},
|
|
128
|
+
openrouter: {
|
|
129
|
+
refs: ["OPENROUTER_API_KEY"],
|
|
130
|
+
urls: {
|
|
131
|
+
OPENROUTER_API_KEY: "https://openrouter.ai/api/v1/credits",
|
|
132
|
+
},
|
|
133
|
+
parse: parseOpenrouterResponse,
|
|
134
|
+
},
|
|
135
|
+
zhipu: {
|
|
136
|
+
refs: ["ZAI_CODING_CN_API_KEY", "ZHIPU_API_KEY"],
|
|
137
|
+
urls: {
|
|
138
|
+
ZAI_CODING_CN_API_KEY: "https://open.bigmodel.cn/api/monitor/usage/quota/limit",
|
|
139
|
+
ZHIPU_API_KEY: "https://open.bigmodel.cn/api/monitor/usage/quota/limit",
|
|
140
|
+
},
|
|
141
|
+
parse: parseZhipuResponse,
|
|
142
|
+
// 智谱特殊: Authorization 不加 "Bearer " 前缀 (来自 Musage zhipu.rs 注释)
|
|
143
|
+
authStyle: "raw",
|
|
144
|
+
},
|
|
145
|
+
// Xiaomi MiMo Token Plan — dashboard admin API(非公开 endpoint):
|
|
146
|
+
// Bearer 实测 401(session 守护),可靠路径是登录 Cookie;单个凭证值先
|
|
147
|
+
// Bearer 后 Cookie 自动重试(对齐 Musage xiaomi.rs 的 BearerThenCookie)。
|
|
148
|
+
// 在 DSH 凭据里把浏览器 DevTools 复制的完整 Cookie 或 API Key 配到
|
|
149
|
+
// XIAOMI_MIMO_API_KEY / XIAOMI_MIMO_COOKIE(或 MIMO_*)任一 ref。
|
|
150
|
+
mimo: {
|
|
151
|
+
refs: ["XIAOMI_MIMO_API_KEY", "XIAOMI_MIMO_COOKIE", "MIMO_API_KEY", "MIMO_COOKIE"],
|
|
152
|
+
urls: {
|
|
153
|
+
XIAOMI_MIMO_API_KEY: "https://platform.xiaomimimo.com/api/v1/tokenPlan/usage",
|
|
154
|
+
XIAOMI_MIMO_COOKIE: "https://platform.xiaomimimo.com/api/v1/tokenPlan/usage",
|
|
155
|
+
MIMO_API_KEY: "https://platform.xiaomimimo.com/api/v1/tokenPlan/usage",
|
|
156
|
+
MIMO_COOKIE: "https://platform.xiaomimimo.com/api/v1/tokenPlan/usage",
|
|
157
|
+
},
|
|
158
|
+
parse: parseMimoResponse,
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
/** Xiaomi MiMo dashboard admin API endpoints(usage + detail,鉴权同一凭证)。 */
|
|
163
|
+
const MIMO_USAGE_URL = "https://platform.xiaomimimo.com/api/v1/tokenPlan/usage";
|
|
164
|
+
const MIMO_DETAIL_URL = "https://platform.xiaomimimo.com/api/v1/tokenPlan/detail";
|
|
165
|
+
|
|
166
|
+
function quotaBackoffMs(streak) {
|
|
167
|
+
if (streak <= 0) return 0;
|
|
168
|
+
return Math.min(QUOTA_BACKOFF_MAX_MS, QUOTA_BACKOFF_BASE_MS * Math.pow(2, streak - 1));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function parseEndTime(v) {
|
|
172
|
+
if (typeof v !== "number") return null;
|
|
173
|
+
if (v >= 1e12 && v <= 4e12) return v;
|
|
174
|
+
return Date.now() + v * 1000;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ----- minimax parser (2026-06-01 起 percent-based / count-based 双 schema) -----
|
|
178
|
+
|
|
179
|
+
function parseMinimaxResponse(body) {
|
|
180
|
+
let json;
|
|
181
|
+
try {
|
|
182
|
+
json = typeof body === "string" ? JSON.parse(body) : body;
|
|
183
|
+
} catch {
|
|
184
|
+
return { ok: false, kind: "parse", message: "JSON 解析失败" };
|
|
185
|
+
}
|
|
186
|
+
const baseResp = json && json.base_resp;
|
|
187
|
+
if (!baseResp || baseResp.status_code !== 0) {
|
|
188
|
+
return {
|
|
189
|
+
ok: false,
|
|
190
|
+
kind: "server_error",
|
|
191
|
+
message: (baseResp && baseResp.status_msg) || "API 返回 base_resp.status_code != 0",
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
const arr = json && json.model_remains;
|
|
195
|
+
if (!Array.isArray(arr) || arr.length === 0) {
|
|
196
|
+
return { ok: false, kind: "parse", message: "model_remains 为空" };
|
|
197
|
+
}
|
|
198
|
+
const entry = arr.find((r) => r && r.model_name === "general") || arr[0];
|
|
199
|
+
if (!entry) return { ok: false, kind: "parse", message: "找不到可用 model_remains 条目" };
|
|
200
|
+
|
|
201
|
+
const fiveHour = parseMinimaxWindow(entry, "current_interval_", "current_interval_usage_count", "current_interval_total_count", "end_time");
|
|
202
|
+
const weekly = parseMinimaxWindow(entry, "current_weekly_", "current_weekly_usage_count", "current_weekly_total_count", "weekly_end_time");
|
|
203
|
+
|
|
204
|
+
if (!fiveHour && !weekly) {
|
|
205
|
+
return { ok: false, kind: "schema_unknown", message: "MiniMax 响应字段都不认识" };
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
ok: true,
|
|
209
|
+
provider: "minimax",
|
|
210
|
+
display: {
|
|
211
|
+
fiveHrPct: fiveHour ? Math.max(0, Math.min(100, Math.round(fiveHour.usedPercent))) : null,
|
|
212
|
+
weeklyPct: weekly ? Math.max(0, Math.min(100, Math.round(weekly.usedPercent))) : null,
|
|
213
|
+
fiveHrResetsIn: fiveHour ? formatResetsIn(fiveHour.resetsAt) : null,
|
|
214
|
+
weeklyResetsIn: weekly ? formatResetsIn(weekly.resetsAt) : null,
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function parseMinimaxWindow(entry, prefix, legacyRemaining, legacyTotal, endTimeKey) {
|
|
220
|
+
const newPercent = entry[prefix + "remaining_percent"];
|
|
221
|
+
const newStatus = entry[prefix + "status"];
|
|
222
|
+
if (typeof newPercent === "number" && newStatus === 1) {
|
|
223
|
+
return {
|
|
224
|
+
usedPercent: Math.max(0, 100 - newPercent),
|
|
225
|
+
resetsAt: parseEndTime(entry[endTimeKey]),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
const total = entry[prefix + "total_count"];
|
|
229
|
+
const remaining = entry[legacyRemaining] || entry[prefix + "usage_count"];
|
|
230
|
+
if (typeof total === "number" && total > 0 && typeof remaining === "number") {
|
|
231
|
+
return {
|
|
232
|
+
usedPercent: Math.max(0, ((total - remaining) / total) * 100),
|
|
233
|
+
resetsAt: parseEndTime(entry[endTimeKey]),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ----- deepseek balance parser -----
|
|
240
|
+
// { "is_available": true,
|
|
241
|
+
// "balance_infos": [ { "currency": "CNY", "total_balance": "43.97",
|
|
242
|
+
// "granted_balance": "0.00", "topped_up_balance": "43.97" } ] }
|
|
243
|
+
|
|
244
|
+
function parseDeepseekBalance(body) {
|
|
245
|
+
let json;
|
|
246
|
+
try {
|
|
247
|
+
json = typeof body === "string" ? JSON.parse(body) : body;
|
|
248
|
+
} catch {
|
|
249
|
+
return { ok: false, kind: "parse", message: "JSON 解析失败" };
|
|
250
|
+
}
|
|
251
|
+
if (!json || typeof json !== "object") {
|
|
252
|
+
return { ok: false, kind: "parse", message: "DeepSeek 响应不是对象" };
|
|
253
|
+
}
|
|
254
|
+
if (json.is_available === false) {
|
|
255
|
+
return { ok: false, kind: "server_error", message: "DeepSeek 账号 is_available=false" };
|
|
256
|
+
}
|
|
257
|
+
const infos = json.balance_infos;
|
|
258
|
+
if (!Array.isArray(infos) || infos.length === 0) {
|
|
259
|
+
return { ok: false, kind: "parse", message: "balance_infos 字段为空" };
|
|
260
|
+
}
|
|
261
|
+
const first = infos[0];
|
|
262
|
+
const totalStr = first && first.total_balance;
|
|
263
|
+
if (typeof totalStr !== "string" && typeof totalStr !== "number") {
|
|
264
|
+
return { ok: false, kind: "parse", message: "balance_infos[0].total_balance 不存在" };
|
|
265
|
+
}
|
|
266
|
+
const balance = parseFloat(totalStr);
|
|
267
|
+
if (!isFinite(balance)) {
|
|
268
|
+
return { ok: false, kind: "parse", message: "balance 解析成数字失败: " + totalStr };
|
|
269
|
+
}
|
|
270
|
+
const currency = (first && first.currency) || "USD";
|
|
271
|
+
return {
|
|
272
|
+
ok: true,
|
|
273
|
+
provider: "deepseek",
|
|
274
|
+
currency,
|
|
275
|
+
display: {
|
|
276
|
+
balanceUsd: balance,
|
|
277
|
+
balanceText: formatBalance(balance, currency),
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ----- kimi parser (5h 窗口 + 7d 窗口) -----
|
|
283
|
+
// { "limits": [ { "detail": { "limit": 100, "remaining": 72, "resetTime": "..." } } ],
|
|
284
|
+
// "usage": { "limit": 1000, "remaining": 742, "resetTime": 1749840000 } }
|
|
285
|
+
|
|
286
|
+
function parseKimiResponse(body) {
|
|
287
|
+
let json;
|
|
288
|
+
try {
|
|
289
|
+
json = typeof body === "string" ? JSON.parse(body) : body;
|
|
290
|
+
} catch {
|
|
291
|
+
return { ok: false, kind: "parse", message: "JSON 解析失败" };
|
|
292
|
+
}
|
|
293
|
+
if (!json || typeof json !== "object") {
|
|
294
|
+
return { ok: false, kind: "parse", message: "Kimi 响应不是对象" };
|
|
295
|
+
}
|
|
296
|
+
if (json.code && json.code !== 200 && json.code !== "200") {
|
|
297
|
+
return { ok: false, kind: "server_error", message: "Kimi 返错: " + (json.code || "?") + " · " + (json.msg || "") };
|
|
298
|
+
}
|
|
299
|
+
const firstLimit = Array.isArray(json.limits) && json.limits[0] && json.limits[0].detail;
|
|
300
|
+
const five = firstLimit || {};
|
|
301
|
+
const fiveHrLimit = Number(five.limit) || 0;
|
|
302
|
+
const fiveHrRemaining = Number(five.remaining) || 0;
|
|
303
|
+
const fiveHrResetsAt = parseKimiResetTime(five.resetTime);
|
|
304
|
+
const week = json.usage || {};
|
|
305
|
+
const weeklyLimit = Number(week.limit) || 0;
|
|
306
|
+
const weeklyRemaining = Number(week.remaining) || 0;
|
|
307
|
+
const weeklyResetsAt = parseKimiResetTime(week.resetTime);
|
|
308
|
+
if (!fiveHrLimit && !weeklyLimit) {
|
|
309
|
+
return { ok: false, kind: "parse", message: "Kimi 响应没有 5h/7d 限额" };
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
ok: true,
|
|
313
|
+
provider: "kimi",
|
|
314
|
+
display: {
|
|
315
|
+
fiveHrPct: fiveHrLimit > 0 ? Math.round(((fiveHrLimit - fiveHrRemaining) / fiveHrLimit) * 100) : null,
|
|
316
|
+
weeklyPct: weeklyLimit > 0 ? Math.round(((weeklyLimit - weeklyRemaining) / weeklyLimit) * 100) : null,
|
|
317
|
+
fiveHrResetsIn: fiveHrResetsAt ? formatResetsIn(fiveHrResetsAt) : null,
|
|
318
|
+
weeklyResetsIn: weeklyResetsAt ? formatResetsIn(weeklyResetsAt) : null,
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function parseKimiResetTime(v) {
|
|
324
|
+
if (typeof v === "number") {
|
|
325
|
+
if (v >= 1e12 && v <= 4e12) return v;
|
|
326
|
+
if (v > 1e9) return v * 1000;
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
if (typeof v === "string" && v.length > 0) {
|
|
330
|
+
const t = Date.parse(v);
|
|
331
|
+
return isNaN(t) ? null : t;
|
|
332
|
+
}
|
|
333
|
+
return null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ----- openrouter parser (total_credits - total_usage) -----
|
|
337
|
+
|
|
338
|
+
function parseOpenrouterResponse(body) {
|
|
339
|
+
let json;
|
|
340
|
+
try {
|
|
341
|
+
json = typeof body === "string" ? JSON.parse(body) : body;
|
|
342
|
+
} catch {
|
|
343
|
+
return { ok: false, kind: "parse", message: "JSON 解析失败" };
|
|
344
|
+
}
|
|
345
|
+
if (!json || typeof json !== "object") {
|
|
346
|
+
return { ok: false, kind: "parse", message: "OpenRouter 响应不是对象" };
|
|
347
|
+
}
|
|
348
|
+
const data = json.data;
|
|
349
|
+
if (!data || typeof data !== "object") {
|
|
350
|
+
return { ok: false, kind: "parse", message: "data 字段缺失" };
|
|
351
|
+
}
|
|
352
|
+
const total = Number(data.total_credits);
|
|
353
|
+
const used = Number(data.total_usage);
|
|
354
|
+
if (!isFinite(total) || !isFinite(used)) {
|
|
355
|
+
return { ok: false, kind: "parse", message: "total_credits / total_usage 不是数字" };
|
|
356
|
+
}
|
|
357
|
+
const remaining = total - used;
|
|
358
|
+
return {
|
|
359
|
+
ok: true,
|
|
360
|
+
provider: "openrouter",
|
|
361
|
+
currency: "USD",
|
|
362
|
+
display: {
|
|
363
|
+
balanceUsd: remaining,
|
|
364
|
+
balanceText: formatBalance(remaining, "USD"),
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ----- zhipu (智谱 GLM Coding Plan) parser -----
|
|
370
|
+
// { "code": 200, "success": true,
|
|
371
|
+
// "data": { "limits": [ { "type": "CREDIT_LIMIT", "unit": 3, "usage": 2000,
|
|
372
|
+
// "remaining": 0, "percentage": 100, "nextResetTime": 1786969101067 }, ... ] } }
|
|
373
|
+
// unit=3 是 5h 窗口, unit=6 是周窗口. percentage 直接是已用 0-100 (服务器算好).
|
|
374
|
+
|
|
375
|
+
function parseZhipuResponse(body) {
|
|
376
|
+
let json;
|
|
377
|
+
try {
|
|
378
|
+
json = typeof body === "string" ? JSON.parse(body) : body;
|
|
379
|
+
} catch {
|
|
380
|
+
return { ok: false, kind: "parse", message: "JSON 解析失败" };
|
|
381
|
+
}
|
|
382
|
+
if (!json || typeof json !== "object") {
|
|
383
|
+
return { ok: false, kind: "parse", message: "智谱响应不是对象" };
|
|
384
|
+
}
|
|
385
|
+
if (json.success === false) {
|
|
386
|
+
return { ok: false, kind: "server_error", message: "智谱 success=false · " + (json.msg || "") };
|
|
387
|
+
}
|
|
388
|
+
const data = json.data;
|
|
389
|
+
if (!data || !Array.isArray(data.limits)) {
|
|
390
|
+
return { ok: false, kind: "parse", message: "data.limits 缺失" };
|
|
391
|
+
}
|
|
392
|
+
const fiveHr = data.limits.find((l) => l && (l.unit === 3 || l.unit === "3"));
|
|
393
|
+
const weekly = data.limits.find((l) => l && (l.unit === 6 || l.unit === "6"));
|
|
394
|
+
if (!fiveHr && !weekly) {
|
|
395
|
+
return { ok: false, kind: "parse", message: "找不到 unit=3 (5h) 或 unit=6 (周) 的 limit" };
|
|
396
|
+
}
|
|
397
|
+
function pickWindow(w) {
|
|
398
|
+
if (!w) return null;
|
|
399
|
+
const limit = Number(w.usage) || 0;
|
|
400
|
+
const remaining = Number(w.remaining) || 0;
|
|
401
|
+
const pct = typeof w.percentage === "number" ? w.percentage : limit > 0 ? Math.round(((limit - remaining) / limit) * 100) : null;
|
|
402
|
+
const resetsAt = parseEndTime(w.nextResetTime);
|
|
403
|
+
return { usedPercent: pct, resetsAt };
|
|
404
|
+
}
|
|
405
|
+
const f = pickWindow(fiveHr);
|
|
406
|
+
const w = pickWindow(weekly);
|
|
407
|
+
return {
|
|
408
|
+
ok: true,
|
|
409
|
+
provider: "zhipu",
|
|
410
|
+
display: {
|
|
411
|
+
fiveHrPct: f ? f.usedPercent : null,
|
|
412
|
+
weeklyPct: w ? w.usedPercent : null,
|
|
413
|
+
fiveHrResetsIn: f && f.resetsAt ? formatResetsIn(f.resetsAt) : null,
|
|
414
|
+
weeklyResetsIn: w && w.resetsAt ? formatResetsIn(w.resetsAt) : null,
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function formatBalance(n, currency) {
|
|
420
|
+
const symbol = currency === "CNY" ? "¥" : currency === "USD" ? "$" : "";
|
|
421
|
+
return symbol + (n >= 100 ? n.toFixed(0) : n.toFixed(2));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ----- xiaomi mimo (小米 MiMo Token Plan) parser -----
|
|
425
|
+
// { "code": 0,
|
|
426
|
+
// "data": {
|
|
427
|
+
// "usage": { "items": [ {"name":"plan_total_token","percent":0.13}, ... ] },
|
|
428
|
+
// "monthUsage": { "items": [ {"name":"month_total_token","percent":0.42} ] } } }
|
|
429
|
+
// percent 是 0-1 小数(×100 得百分比);detail 端给 currentPeriodEnd(UTC 字符串)
|
|
430
|
+
// 与 expired。套餐/总额度相差 <0.5pt 视为同一份额度(去重,对齐 Musage)。
|
|
431
|
+
// 业务 code 40100..40199 等价于 HTTP 401(凭据失效)。
|
|
432
|
+
|
|
433
|
+
function mimoItemPct(item) {
|
|
434
|
+
if (!item || typeof item !== "object") return null;
|
|
435
|
+
if (typeof item.percent === "number" && isFinite(item.percent)) {
|
|
436
|
+
return Math.max(0, Math.min(100, Math.round(item.percent * 100)));
|
|
437
|
+
}
|
|
438
|
+
const used = Number(item.used);
|
|
439
|
+
const limit = Number(item.limit);
|
|
440
|
+
if (isFinite(used) && isFinite(limit) && limit > 0) {
|
|
441
|
+
return Math.max(0, Math.min(100, Math.round((used / limit) * 100)));
|
|
442
|
+
}
|
|
443
|
+
return null;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function parseMimoResponse(body, detailBody) {
|
|
447
|
+
let json;
|
|
448
|
+
try {
|
|
449
|
+
json = typeof body === "string" ? JSON.parse(body) : body;
|
|
450
|
+
} catch {
|
|
451
|
+
return { ok: false, provider: "mimo", kind: "parse", message: "JSON 解析失败" };
|
|
452
|
+
}
|
|
453
|
+
if (!json || typeof json !== "object") {
|
|
454
|
+
return { ok: false, provider: "mimo", kind: "parse", message: "MiMo 响应不是对象" };
|
|
455
|
+
}
|
|
456
|
+
if (typeof json.code === "number" && json.code !== 0) {
|
|
457
|
+
if (json.code >= 40100 && json.code < 40200) {
|
|
458
|
+
return {
|
|
459
|
+
ok: false,
|
|
460
|
+
provider: "mimo",
|
|
461
|
+
kind: "auth_failed",
|
|
462
|
+
message: "MiMo 凭据已失效(Cookie 过期或未登录),请到 platform.xiaomimimo.com 重新复制",
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
return {
|
|
466
|
+
ok: false,
|
|
467
|
+
provider: "mimo",
|
|
468
|
+
kind: "server_error",
|
|
469
|
+
message: "MiMo 业务错误 code=" + json.code + " · " + (json.message || ""),
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
const data = json.data;
|
|
473
|
+
if (!data || typeof data !== "object") {
|
|
474
|
+
return { ok: false, provider: "mimo", kind: "parse", message: "MiMo 响应缺少 data 字段" };
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
let resetsAt = null;
|
|
478
|
+
let expired = false;
|
|
479
|
+
if (detailBody) {
|
|
480
|
+
let detail;
|
|
481
|
+
try {
|
|
482
|
+
detail = typeof detailBody === "string" ? JSON.parse(detailBody) : detailBody;
|
|
483
|
+
} catch {
|
|
484
|
+
detail = null;
|
|
485
|
+
}
|
|
486
|
+
const dd = detail && detail.data;
|
|
487
|
+
if (dd && typeof dd === "object") {
|
|
488
|
+
if (dd.expired === true) expired = true;
|
|
489
|
+
if (typeof dd.currentPeriodEnd === "string" && dd.currentPeriodEnd) {
|
|
490
|
+
const t = Date.parse(dd.currentPeriodEnd.replace(" ", "T") + "Z");
|
|
491
|
+
if (!isNaN(t)) resetsAt = t;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (expired) {
|
|
496
|
+
return {
|
|
497
|
+
ok: false,
|
|
498
|
+
provider: "mimo",
|
|
499
|
+
kind: "plan_expired",
|
|
500
|
+
message: "MiMo Token 套餐已过期,请到 platform.xiaomimimo.com 续费",
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const usageItems = data.usage && Array.isArray(data.usage.items) ? data.usage.items : [];
|
|
505
|
+
const monthItems = data.monthUsage && Array.isArray(data.monthUsage.items) ? data.monthUsage.items : [];
|
|
506
|
+
const findPct = (items, name) => {
|
|
507
|
+
const hit = items.find((it) => it && it.name === name);
|
|
508
|
+
return hit ? mimoItemPct(hit) : null;
|
|
509
|
+
};
|
|
510
|
+
let planPct = findPct(usageItems, "plan_total_token");
|
|
511
|
+
let monthPct = findPct(monthItems, "month_total_token");
|
|
512
|
+
|
|
513
|
+
if (planPct === null && monthPct === null) {
|
|
514
|
+
if (usageItems.length > 0 || monthItems.length > 0) {
|
|
515
|
+
const names = usageItems.concat(monthItems).map((it) => it && it.name).filter(Boolean).join(",");
|
|
516
|
+
return {
|
|
517
|
+
ok: false,
|
|
518
|
+
provider: "mimo",
|
|
519
|
+
kind: "schema_unknown",
|
|
520
|
+
message: "MiMo 响应字段都不认识(items: " + names + ")",
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
return { ok: false, provider: "mimo", kind: "parse", message: "MiMo usage.items 为空(套餐可能已过期)" };
|
|
524
|
+
}
|
|
525
|
+
// 套餐与月度总额度是同一份额度时只显示一行(Musage 0.5pt 去重阈值)
|
|
526
|
+
if (planPct !== null && monthPct !== null && Math.abs(monthPct - planPct) < 0.5) {
|
|
527
|
+
monthPct = null;
|
|
528
|
+
}
|
|
529
|
+
const resetsIn = resetsAt ? formatResetsIn(resetsAt) : null;
|
|
530
|
+
return {
|
|
531
|
+
ok: true,
|
|
532
|
+
provider: "mimo",
|
|
533
|
+
display: {
|
|
534
|
+
fiveHrPct: planPct,
|
|
535
|
+
weeklyPct: monthPct,
|
|
536
|
+
fiveHrResetsIn: planPct !== null ? resetsIn : null,
|
|
537
|
+
weeklyResetsIn: monthPct !== null ? resetsIn : null,
|
|
538
|
+
balanceText: null,
|
|
539
|
+
balanceUsd: null,
|
|
540
|
+
currency: null,
|
|
541
|
+
},
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function formatResetsIn(resetsAtMs) {
|
|
546
|
+
if (typeof resetsAtMs !== "number" || !resetsAtMs) return "";
|
|
547
|
+
const ms = resetsAtMs - Date.now();
|
|
548
|
+
if (ms <= 0) return "即将重置";
|
|
549
|
+
const totalMin = Math.floor(ms / 60000);
|
|
550
|
+
const h = Math.floor(totalMin / 60);
|
|
551
|
+
const m = totalMin % 60;
|
|
552
|
+
if (h > 0) return h + "h" + m + "m 后重置";
|
|
553
|
+
return m + "m 后重置";
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function classifyQuotaHttpStatus(status) {
|
|
557
|
+
if (status === 429) return "rate_limited";
|
|
558
|
+
if (status === 401 || status === 403) return "auth_failed";
|
|
559
|
+
return "server_error";
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function parseCurlOutput(rawText) {
|
|
563
|
+
if (typeof rawText !== "string") return { body: "", statusCode: 0 };
|
|
564
|
+
const lastNl = rawText.lastIndexOf("\n");
|
|
565
|
+
if (lastNl < 0) return { body: rawText, statusCode: 0 };
|
|
566
|
+
const body = rawText.slice(0, lastNl);
|
|
567
|
+
const statusCode = parseInt(rawText.slice(lastNl + 1).trim(), 10);
|
|
568
|
+
if (isNaN(statusCode)) return { body: rawText, statusCode: 0 };
|
|
569
|
+
return { body, statusCode };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** Trim a parser result to the exact wire shape declared in typert.host.js. */
|
|
573
|
+
function quotaWireValue(parsed) {
|
|
574
|
+
if (!parsed || parsed.ok !== true) {
|
|
575
|
+
return {
|
|
576
|
+
ok: false,
|
|
577
|
+
provider: (parsed && parsed.provider) || "",
|
|
578
|
+
kind: (parsed && parsed.kind) || "other",
|
|
579
|
+
message: (parsed && parsed.message) || "未知错误",
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
const d = parsed.display || {};
|
|
583
|
+
return {
|
|
584
|
+
ok: true,
|
|
585
|
+
provider: parsed.provider,
|
|
586
|
+
display: {
|
|
587
|
+
fiveHrPct: typeof d.fiveHrPct === "number" && isFinite(d.fiveHrPct) ? d.fiveHrPct : null,
|
|
588
|
+
weeklyPct: typeof d.weeklyPct === "number" && isFinite(d.weeklyPct) ? d.weeklyPct : null,
|
|
589
|
+
fiveHrResetsIn: typeof d.fiveHrResetsIn === "string" && d.fiveHrResetsIn ? d.fiveHrResetsIn : null,
|
|
590
|
+
weeklyResetsIn: typeof d.weeklyResetsIn === "string" && d.weeklyResetsIn ? d.weeklyResetsIn : null,
|
|
591
|
+
balanceText: typeof d.balanceText === "string" && d.balanceText ? d.balanceText : null,
|
|
592
|
+
balanceUsd: typeof d.balanceUsd === "number" && isFinite(d.balanceUsd) ? d.balanceUsd : null,
|
|
593
|
+
currency: typeof parsed.currency === "string" ? parsed.currency : null,
|
|
594
|
+
},
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
75
598
|
export class TokenStatsService extends TypertRemoteService {
|
|
76
599
|
/**
|
|
77
600
|
* No hard service dependencies: `sessionQuery` is read through `ctx.get()`
|
|
@@ -91,11 +614,18 @@ export class TokenStatsService extends TypertRemoteService {
|
|
|
91
614
|
|
|
92
615
|
/**
|
|
93
616
|
* Cordis class-plugin initializer: runs right after construction, before the
|
|
94
|
-
* service is published. Mark the Remote
|
|
617
|
+
* service is published. Mark the Remote methods, then start collecting.
|
|
618
|
+
*
|
|
619
|
+
* 0.1.7-rc.1 hardening: with dsh 0.1.7, a required plugin whose activation
|
|
620
|
+
* rejects can take the whole profile down (the startup page names the failing
|
|
621
|
+
* plugin and DSH never reaches the chat). So every step after the state
|
|
622
|
+
* fields are initialized is best-effort: a failure leaves the service
|
|
623
|
+
* registered and answering (getStats reports the error) instead of failing
|
|
624
|
+
* activation.
|
|
95
625
|
*/
|
|
96
626
|
[Service.init]() {
|
|
97
|
-
|
|
98
|
-
|
|
627
|
+
// State first: whatever later step fails, the Remote methods below must
|
|
628
|
+
// always find consistent in-memory state to answer with.
|
|
99
629
|
/** dayKey ('YYYY-MM-DD', local) -> Map<modelKey, day aggregate>. */
|
|
100
630
|
this._byDay = new Map();
|
|
101
631
|
/** modelKey -> { key, name, provider }. */
|
|
@@ -109,22 +639,61 @@ export class TokenStatsService extends TypertRemoteService {
|
|
|
109
639
|
this._saveTimer = null;
|
|
110
640
|
this._saving = false;
|
|
111
641
|
|
|
642
|
+
// Quota fetch state: per-provider cache entries + the credential ref that
|
|
643
|
+
// last resolved for each provider. In-memory only (no persistence).
|
|
644
|
+
this._quotaCache = Object.create(null);
|
|
645
|
+
this._quotaActiveRef = Object.create(null);
|
|
646
|
+
this._curlPath = null;
|
|
647
|
+
|
|
648
|
+
try {
|
|
649
|
+
markRemoteMethod(this, "getStats", "getStats");
|
|
650
|
+
} catch (e) {
|
|
651
|
+
this._backfill.error = "getStats 未发布: " + String((e && e.message) || e);
|
|
652
|
+
}
|
|
653
|
+
try {
|
|
654
|
+
markRemoteMethod(this, "getQuota", "getQuota");
|
|
655
|
+
} catch (e) {
|
|
656
|
+
this._backfill.error = "getQuota 未发布: " + String((e && e.message) || e);
|
|
657
|
+
}
|
|
658
|
+
try {
|
|
659
|
+
markRemoteMethod(this, "getAllQuotas", "getAllQuotas");
|
|
660
|
+
} catch (e) {
|
|
661
|
+
this._backfill.error = "getAllQuotas 未发布: " + String((e && e.message) || e);
|
|
662
|
+
}
|
|
663
|
+
|
|
112
664
|
// Restore the previous run's aggregates so a cold start only scans new sessions.
|
|
113
|
-
|
|
665
|
+
try {
|
|
666
|
+
this._loadStore();
|
|
667
|
+
} catch (e) {
|
|
668
|
+
/* missing or corrupt store: start empty and let backfill do a full scan */
|
|
669
|
+
}
|
|
114
670
|
|
|
115
671
|
// Best-effort synchronous flush when the plugin fiber is disposed.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
672
|
+
try {
|
|
673
|
+
this.ctx.effect(() => () => {
|
|
674
|
+
if (this._saveTimer) {
|
|
675
|
+
clearTimeout(this._saveTimer);
|
|
676
|
+
this._saveTimer = null;
|
|
677
|
+
}
|
|
678
|
+
if (this._dirty) this._saveSync();
|
|
679
|
+
});
|
|
680
|
+
} catch (e) {
|
|
681
|
+
this._backfill.error = "持久化刷新未注册: " + String((e && e.message) || e);
|
|
682
|
+
}
|
|
123
683
|
|
|
124
684
|
// Live capture: every committed session append.
|
|
125
|
-
|
|
685
|
+
try {
|
|
686
|
+
this.ctx.on("session/event", (session, event) => this._onSessionEvent(session, event));
|
|
687
|
+
} catch (e) {
|
|
688
|
+
this._backfill.error = "实时采集未注册: " + String((e && e.message) || e);
|
|
689
|
+
}
|
|
126
690
|
|
|
127
|
-
|
|
691
|
+
try {
|
|
692
|
+
const backfill = this._startBackfill();
|
|
693
|
+
if (backfill && typeof backfill.catch === "function") backfill.catch(() => {});
|
|
694
|
+
} catch (e) {
|
|
695
|
+
this._backfill.error = "历史回填启动失败: " + String((e && e.message) || e);
|
|
696
|
+
}
|
|
128
697
|
}
|
|
129
698
|
|
|
130
699
|
// ---- data helpers ---------------------------------------------------------
|
|
@@ -450,6 +1019,232 @@ export class TokenStatsService extends TypertRemoteService {
|
|
|
450
1019
|
},
|
|
451
1020
|
};
|
|
452
1021
|
}
|
|
1022
|
+
|
|
1023
|
+
// ---- quota / balance (套餐余额) --------------------------------------------
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Resolve the API key for one provider through the DSH credentials seam.
|
|
1027
|
+
* Ref candidates follow the settings page's `<ROUTE>_API_KEY` derivation and
|
|
1028
|
+
* each provider's shipped default `apiKeyEnv`; without the credentials
|
|
1029
|
+
* service (headless deployment) the process environment is consulted.
|
|
1030
|
+
*/
|
|
1031
|
+
async _quotaApiKey(provider) {
|
|
1032
|
+
const cfg = QUOTA_PROVIDERS[provider];
|
|
1033
|
+
if (!cfg) return { ref: null, key: null };
|
|
1034
|
+
const credentials = this.ctx.get("credentials");
|
|
1035
|
+
const candidates = this._quotaActiveRef[provider]
|
|
1036
|
+
? [this._quotaActiveRef[provider], ...cfg.refs.filter((r) => r !== this._quotaActiveRef[provider])]
|
|
1037
|
+
: cfg.refs;
|
|
1038
|
+
for (const ref of candidates) {
|
|
1039
|
+
try {
|
|
1040
|
+
if (credentials && typeof credentials.resolve === "function") {
|
|
1041
|
+
const hit = await credentials.resolve(ref);
|
|
1042
|
+
if (hit && hit.value) {
|
|
1043
|
+
this._quotaActiveRef[provider] = ref;
|
|
1044
|
+
return { ref, key: hit.value };
|
|
1045
|
+
}
|
|
1046
|
+
} else if (typeof process !== "undefined" && process.env && process.env[ref]) {
|
|
1047
|
+
this._quotaActiveRef[provider] = ref;
|
|
1048
|
+
return { ref, key: process.env[ref] };
|
|
1049
|
+
}
|
|
1050
|
+
} catch (e) {
|
|
1051
|
+
/* try the next ref */
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
1054
|
+
return { ref: null, key: null };
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
async _resolveCurl() {
|
|
1058
|
+
if (this._curlPath) return this._curlPath;
|
|
1059
|
+
const subprocess = this.ctx.get("subprocess");
|
|
1060
|
+
if (!subprocess || typeof subprocess.resolveExecutable !== "function") {
|
|
1061
|
+
throw new Error("subprocess service 不可用, 且宿主无全局 fetch");
|
|
1062
|
+
}
|
|
1063
|
+
this._curlPath = await subprocess.resolveExecutable("curl");
|
|
1064
|
+
return this._curlPath;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
/** curl fallback for hosts without a global fetch (same shape as musage). */
|
|
1068
|
+
async _curlGet(url, key, authStyle) {
|
|
1069
|
+
const subprocess = this.ctx.get("subprocess");
|
|
1070
|
+
if (!subprocess || typeof subprocess.spawn !== "function") {
|
|
1071
|
+
return { ok: false, kind: "network", message: "subprocess service 不可用, 且宿主无全局 fetch" };
|
|
1072
|
+
}
|
|
1073
|
+
const c = await this._resolveCurl();
|
|
1074
|
+
const authHeader =
|
|
1075
|
+
authStyle === "raw"
|
|
1076
|
+
? "Authorization: " + key
|
|
1077
|
+
: authStyle === "cookie"
|
|
1078
|
+
? "Cookie: " + key
|
|
1079
|
+
: "Authorization: Bearer " + key;
|
|
1080
|
+
const handle = subprocess.spawn({
|
|
1081
|
+
argv: [
|
|
1082
|
+
c, "-sS",
|
|
1083
|
+
"--max-time", String(Math.floor(QUOTA_REQUEST_TIMEOUT_MS / 1000)),
|
|
1084
|
+
"-w", "\n%{http_code}",
|
|
1085
|
+
"-H", authHeader,
|
|
1086
|
+
"-H", "Accept: application/json",
|
|
1087
|
+
url,
|
|
1088
|
+
],
|
|
1089
|
+
cwd: "/",
|
|
1090
|
+
stdio: {
|
|
1091
|
+
stdin: "ignore",
|
|
1092
|
+
stdout: { maxBytes: 8 * 1024 * 1024 },
|
|
1093
|
+
stderr: { maxBytes: 64 * 1024 },
|
|
1094
|
+
},
|
|
1095
|
+
graceMs: QUOTA_REQUEST_TIMEOUT_MS,
|
|
1096
|
+
});
|
|
1097
|
+
const outcome = await handle.done;
|
|
1098
|
+
const stdout = handle.collected && handle.collected.stdout
|
|
1099
|
+
? handle.collected.stdout.readFrom(0)
|
|
1100
|
+
: { text: "" };
|
|
1101
|
+
const stderr = handle.collected && handle.collected.stderr
|
|
1102
|
+
? handle.collected.stderr.readFrom(0)
|
|
1103
|
+
: { text: "" };
|
|
1104
|
+
if (outcome.exitCode !== 0) {
|
|
1105
|
+
return { ok: false, kind: "network", message: "curl 退出 " + outcome.exitCode + " · " + stderr.text.slice(0, 200) };
|
|
1106
|
+
}
|
|
1107
|
+
const { body, statusCode } = parseCurlOutput(stdout.text);
|
|
1108
|
+
if (statusCode === 0) {
|
|
1109
|
+
return { ok: false, kind: "network", message: "curl 输出没拿到 HTTP 状态: " + stdout.text.slice(0, 200) };
|
|
1110
|
+
}
|
|
1111
|
+
if (statusCode !== 200) {
|
|
1112
|
+
return {
|
|
1113
|
+
ok: false,
|
|
1114
|
+
kind: classifyQuotaHttpStatus(statusCode),
|
|
1115
|
+
httpStatus: statusCode,
|
|
1116
|
+
message: "HTTP " + statusCode + " · " + body.slice(0, 200),
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
return { ok: true, body };
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/** GET one provider endpoint with its API key; fetch first, curl fallback. */
|
|
1123
|
+
async _httpGet(url, key, authStyle) {
|
|
1124
|
+
if (typeof fetch === "function") {
|
|
1125
|
+
try {
|
|
1126
|
+
const headers = { accept: "application/json" };
|
|
1127
|
+
// authStyle: undefined → Bearer; "raw" → 原样 Authorization(智谱);
|
|
1128
|
+
// "cookie" → 整串 Cookie 头(小米 MiMo dashboard admin API)。
|
|
1129
|
+
if (authStyle === "cookie") headers.cookie = key;
|
|
1130
|
+
else headers.authorization = authStyle === "raw" ? key : "Bearer " + key;
|
|
1131
|
+
const res = await fetch(url, {
|
|
1132
|
+
method: "GET",
|
|
1133
|
+
headers,
|
|
1134
|
+
signal: AbortSignal.timeout(QUOTA_REQUEST_TIMEOUT_MS),
|
|
1135
|
+
});
|
|
1136
|
+
const body = await res.text();
|
|
1137
|
+
if (res.status !== 200) {
|
|
1138
|
+
return {
|
|
1139
|
+
ok: false,
|
|
1140
|
+
kind: classifyQuotaHttpStatus(res.status),
|
|
1141
|
+
httpStatus: res.status,
|
|
1142
|
+
message: "HTTP " + res.status + " · " + body.slice(0, 200),
|
|
1143
|
+
};
|
|
1144
|
+
}
|
|
1145
|
+
return { ok: true, body };
|
|
1146
|
+
} catch (e) {
|
|
1147
|
+
return { ok: false, kind: "network", message: "fetch 失败: " + String((e && e.message) || e) };
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
return this._curlGet(url, key, authStyle);
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
async _fetchProviderQuota(provider) {
|
|
1154
|
+
const cfg = QUOTA_PROVIDERS[provider];
|
|
1155
|
+
if (!cfg) {
|
|
1156
|
+
return { ok: false, provider, kind: "other", message: "未知 provider: " + provider };
|
|
1157
|
+
}
|
|
1158
|
+
const { ref, key } = await this._quotaApiKey(provider);
|
|
1159
|
+
if (!key) {
|
|
1160
|
+
return {
|
|
1161
|
+
ok: false,
|
|
1162
|
+
provider,
|
|
1163
|
+
kind: "unconfigured",
|
|
1164
|
+
message: "未配置 " + provider + " API Key (在 DSH 模型设置里配置对应 provider)",
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
if (provider === "mimo") return this._fetchMimoQuota(key);
|
|
1168
|
+
const url = cfg.urls[ref] || cfg.urls[cfg.refs[0]];
|
|
1169
|
+
const raw = await this._httpGet(url, key, cfg.authStyle);
|
|
1170
|
+
if (!raw.ok) return { ...raw, provider };
|
|
1171
|
+
return cfg.parse(raw.body);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
/**
|
|
1175
|
+
* Xiaomi MiMo Token Plan(dashboard admin API,契约来自 Musage xiaomi.rs):
|
|
1176
|
+
* 同一凭证值先按 Bearer 试,401/403 时原样改 `Cookie:` 头重试;usage 成功
|
|
1177
|
+
* 后再 best-effort 拉一次 detail(周期结束时间 / 过期标记)。detail 失败
|
|
1178
|
+
* 不影响 usage 结果(只是没有重置倒计时)。
|
|
1179
|
+
*/
|
|
1180
|
+
async _fetchMimoQuota(key) {
|
|
1181
|
+
let raw = await this._httpGet(MIMO_USAGE_URL, key, undefined);
|
|
1182
|
+
let cookie = false;
|
|
1183
|
+
if (!raw.ok && (raw.httpStatus === 401 || raw.httpStatus === 403)) {
|
|
1184
|
+
raw = await this._httpGet(MIMO_USAGE_URL, key, "cookie");
|
|
1185
|
+
cookie = true;
|
|
1186
|
+
}
|
|
1187
|
+
if (!raw.ok) return { ...raw, provider: "mimo" };
|
|
1188
|
+
const detail = await this._httpGet(MIMO_DETAIL_URL, key, cookie ? "cookie" : undefined);
|
|
1189
|
+
return parseMimoResponse(raw.body, detail && detail.ok ? detail.body : null);
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* Cached per-provider quota read. Success caches for 30 s; failures cache
|
|
1194
|
+
* with exponential backoff (5 s → 30 min cap) so a broken provider is not
|
|
1195
|
+
* hammered. `force` drops the cached entry before reading (手动刷新).
|
|
1196
|
+
*/
|
|
1197
|
+
async _getQuotaCached(provider, force) {
|
|
1198
|
+
if (force) this._quotaCache[provider] = null;
|
|
1199
|
+
const c = this._quotaCache[provider];
|
|
1200
|
+
if (c && c.expiresAt > Date.now()) return c.value;
|
|
1201
|
+
const value = quotaWireValue(await this._fetchProviderQuota(provider));
|
|
1202
|
+
if (value.ok) {
|
|
1203
|
+
this._quotaCache[provider] = { value, expiresAt: Date.now() + QUOTA_CACHE_TTL_MS, streak: 0 };
|
|
1204
|
+
} else {
|
|
1205
|
+
const streak = (c ? c.streak : 0) + 1;
|
|
1206
|
+
this._quotaCache[provider] = { value, expiresAt: Date.now() + quotaBackoffMs(streak), streak };
|
|
1207
|
+
}
|
|
1208
|
+
return value;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
/**
|
|
1212
|
+
* Plan quota / balance for one provider (`minimax` | `deepseek` | `kimi` |
|
|
1213
|
+
* `openrouter` | `zhipu` | `mimo`). The composer readout in the Client half
|
|
1214
|
+
* calls this for the session's active provider (and on click with force=true).
|
|
1215
|
+
*/
|
|
1216
|
+
async getQuota(provider, force) {
|
|
1217
|
+
const p = typeof provider === "string" ? provider : "";
|
|
1218
|
+
if (!QUOTA_PROVIDERS[p]) {
|
|
1219
|
+
return { ok: true, value: { ok: false, provider: p, kind: "other", message: "未知 provider: " + p } };
|
|
1220
|
+
}
|
|
1221
|
+
try {
|
|
1222
|
+
return { ok: true, value: await this._getQuotaCached(p, force === true) };
|
|
1223
|
+
} catch (e) {
|
|
1224
|
+
return { ok: true, value: { ok: false, provider: p, kind: "other", message: String((e && e.message) || e) } };
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* Quota snapshot for every supported provider at once (设置页余额区块).
|
|
1230
|
+
* Providers are queried in parallel; each entry is the same wire value
|
|
1231
|
+
* getQuota returns. `force` bypasses every provider's cache.
|
|
1232
|
+
*/
|
|
1233
|
+
async getAllQuotas(force) {
|
|
1234
|
+
const names = Object.keys(QUOTA_PROVIDERS);
|
|
1235
|
+
const values = await Promise.all(
|
|
1236
|
+
names.map(async (p) => {
|
|
1237
|
+
try {
|
|
1238
|
+
return await this._getQuotaCached(p, force === true);
|
|
1239
|
+
} catch (e) {
|
|
1240
|
+
return quotaWireValue({ ok: false, provider: p, kind: "other", message: String((e && e.message) || e) });
|
|
1241
|
+
}
|
|
1242
|
+
}),
|
|
1243
|
+
);
|
|
1244
|
+
const quotas = {};
|
|
1245
|
+
for (let i = 0; i < names.length; i++) quotas[names[i]] = values[i];
|
|
1246
|
+
return { ok: true, value: { quotas } };
|
|
1247
|
+
}
|
|
453
1248
|
}
|
|
454
1249
|
|
|
455
1250
|
export default TokenStatsService;
|