@duke-dsh-plugins/dsh-token-stats 1.3.3 → 1.4.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 +14 -3
- package/client.js +454 -3
- package/index.js +591 -0
- package/package.json +4 -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
|
+
* for the composer readout — adapted from dsh-musage (MIT). It resolves the
|
|
32
|
+
* 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,381 @@ 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
|
+
};
|
|
146
|
+
|
|
147
|
+
function quotaBackoffMs(streak) {
|
|
148
|
+
if (streak <= 0) return 0;
|
|
149
|
+
return Math.min(QUOTA_BACKOFF_MAX_MS, QUOTA_BACKOFF_BASE_MS * Math.pow(2, streak - 1));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function parseEndTime(v) {
|
|
153
|
+
if (typeof v !== "number") return null;
|
|
154
|
+
if (v >= 1e12 && v <= 4e12) return v;
|
|
155
|
+
return Date.now() + v * 1000;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ----- minimax parser (2026-06-01 起 percent-based / count-based 双 schema) -----
|
|
159
|
+
|
|
160
|
+
function parseMinimaxResponse(body) {
|
|
161
|
+
let json;
|
|
162
|
+
try {
|
|
163
|
+
json = typeof body === "string" ? JSON.parse(body) : body;
|
|
164
|
+
} catch {
|
|
165
|
+
return { ok: false, kind: "parse", message: "JSON 解析失败" };
|
|
166
|
+
}
|
|
167
|
+
const baseResp = json && json.base_resp;
|
|
168
|
+
if (!baseResp || baseResp.status_code !== 0) {
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
kind: "server_error",
|
|
172
|
+
message: (baseResp && baseResp.status_msg) || "API 返回 base_resp.status_code != 0",
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const arr = json && json.model_remains;
|
|
176
|
+
if (!Array.isArray(arr) || arr.length === 0) {
|
|
177
|
+
return { ok: false, kind: "parse", message: "model_remains 为空" };
|
|
178
|
+
}
|
|
179
|
+
const entry = arr.find((r) => r && r.model_name === "general") || arr[0];
|
|
180
|
+
if (!entry) return { ok: false, kind: "parse", message: "找不到可用 model_remains 条目" };
|
|
181
|
+
|
|
182
|
+
const fiveHour = parseMinimaxWindow(entry, "current_interval_", "current_interval_usage_count", "current_interval_total_count", "end_time");
|
|
183
|
+
const weekly = parseMinimaxWindow(entry, "current_weekly_", "current_weekly_usage_count", "current_weekly_total_count", "weekly_end_time");
|
|
184
|
+
|
|
185
|
+
if (!fiveHour && !weekly) {
|
|
186
|
+
return { ok: false, kind: "schema_unknown", message: "MiniMax 响应字段都不认识" };
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
ok: true,
|
|
190
|
+
provider: "minimax",
|
|
191
|
+
display: {
|
|
192
|
+
fiveHrPct: fiveHour ? Math.max(0, Math.min(100, Math.round(fiveHour.usedPercent))) : null,
|
|
193
|
+
weeklyPct: weekly ? Math.max(0, Math.min(100, Math.round(weekly.usedPercent))) : null,
|
|
194
|
+
fiveHrResetsIn: fiveHour ? formatResetsIn(fiveHour.resetsAt) : null,
|
|
195
|
+
weeklyResetsIn: weekly ? formatResetsIn(weekly.resetsAt) : null,
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function parseMinimaxWindow(entry, prefix, legacyRemaining, legacyTotal, endTimeKey) {
|
|
201
|
+
const newPercent = entry[prefix + "remaining_percent"];
|
|
202
|
+
const newStatus = entry[prefix + "status"];
|
|
203
|
+
if (typeof newPercent === "number" && newStatus === 1) {
|
|
204
|
+
return {
|
|
205
|
+
usedPercent: Math.max(0, 100 - newPercent),
|
|
206
|
+
resetsAt: parseEndTime(entry[endTimeKey]),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const total = entry[prefix + "total_count"];
|
|
210
|
+
const remaining = entry[legacyRemaining] || entry[prefix + "usage_count"];
|
|
211
|
+
if (typeof total === "number" && total > 0 && typeof remaining === "number") {
|
|
212
|
+
return {
|
|
213
|
+
usedPercent: Math.max(0, ((total - remaining) / total) * 100),
|
|
214
|
+
resetsAt: parseEndTime(entry[endTimeKey]),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ----- deepseek balance parser -----
|
|
221
|
+
// { "is_available": true,
|
|
222
|
+
// "balance_infos": [ { "currency": "CNY", "total_balance": "43.97",
|
|
223
|
+
// "granted_balance": "0.00", "topped_up_balance": "43.97" } ] }
|
|
224
|
+
|
|
225
|
+
function parseDeepseekBalance(body) {
|
|
226
|
+
let json;
|
|
227
|
+
try {
|
|
228
|
+
json = typeof body === "string" ? JSON.parse(body) : body;
|
|
229
|
+
} catch {
|
|
230
|
+
return { ok: false, kind: "parse", message: "JSON 解析失败" };
|
|
231
|
+
}
|
|
232
|
+
if (!json || typeof json !== "object") {
|
|
233
|
+
return { ok: false, kind: "parse", message: "DeepSeek 响应不是对象" };
|
|
234
|
+
}
|
|
235
|
+
if (json.is_available === false) {
|
|
236
|
+
return { ok: false, kind: "server_error", message: "DeepSeek 账号 is_available=false" };
|
|
237
|
+
}
|
|
238
|
+
const infos = json.balance_infos;
|
|
239
|
+
if (!Array.isArray(infos) || infos.length === 0) {
|
|
240
|
+
return { ok: false, kind: "parse", message: "balance_infos 字段为空" };
|
|
241
|
+
}
|
|
242
|
+
const first = infos[0];
|
|
243
|
+
const totalStr = first && first.total_balance;
|
|
244
|
+
if (typeof totalStr !== "string" && typeof totalStr !== "number") {
|
|
245
|
+
return { ok: false, kind: "parse", message: "balance_infos[0].total_balance 不存在" };
|
|
246
|
+
}
|
|
247
|
+
const balance = parseFloat(totalStr);
|
|
248
|
+
if (!isFinite(balance)) {
|
|
249
|
+
return { ok: false, kind: "parse", message: "balance 解析成数字失败: " + totalStr };
|
|
250
|
+
}
|
|
251
|
+
const currency = (first && first.currency) || "USD";
|
|
252
|
+
return {
|
|
253
|
+
ok: true,
|
|
254
|
+
provider: "deepseek",
|
|
255
|
+
currency,
|
|
256
|
+
display: {
|
|
257
|
+
balanceUsd: balance,
|
|
258
|
+
balanceText: formatBalance(balance, currency),
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ----- kimi parser (5h 窗口 + 7d 窗口) -----
|
|
264
|
+
// { "limits": [ { "detail": { "limit": 100, "remaining": 72, "resetTime": "..." } } ],
|
|
265
|
+
// "usage": { "limit": 1000, "remaining": 742, "resetTime": 1749840000 } }
|
|
266
|
+
|
|
267
|
+
function parseKimiResponse(body) {
|
|
268
|
+
let json;
|
|
269
|
+
try {
|
|
270
|
+
json = typeof body === "string" ? JSON.parse(body) : body;
|
|
271
|
+
} catch {
|
|
272
|
+
return { ok: false, kind: "parse", message: "JSON 解析失败" };
|
|
273
|
+
}
|
|
274
|
+
if (!json || typeof json !== "object") {
|
|
275
|
+
return { ok: false, kind: "parse", message: "Kimi 响应不是对象" };
|
|
276
|
+
}
|
|
277
|
+
if (json.code && json.code !== 200 && json.code !== "200") {
|
|
278
|
+
return { ok: false, kind: "server_error", message: "Kimi 返错: " + (json.code || "?") + " · " + (json.msg || "") };
|
|
279
|
+
}
|
|
280
|
+
const firstLimit = Array.isArray(json.limits) && json.limits[0] && json.limits[0].detail;
|
|
281
|
+
const five = firstLimit || {};
|
|
282
|
+
const fiveHrLimit = Number(five.limit) || 0;
|
|
283
|
+
const fiveHrRemaining = Number(five.remaining) || 0;
|
|
284
|
+
const fiveHrResetsAt = parseKimiResetTime(five.resetTime);
|
|
285
|
+
const week = json.usage || {};
|
|
286
|
+
const weeklyLimit = Number(week.limit) || 0;
|
|
287
|
+
const weeklyRemaining = Number(week.remaining) || 0;
|
|
288
|
+
const weeklyResetsAt = parseKimiResetTime(week.resetTime);
|
|
289
|
+
if (!fiveHrLimit && !weeklyLimit) {
|
|
290
|
+
return { ok: false, kind: "parse", message: "Kimi 响应没有 5h/7d 限额" };
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
ok: true,
|
|
294
|
+
provider: "kimi",
|
|
295
|
+
display: {
|
|
296
|
+
fiveHrPct: fiveHrLimit > 0 ? Math.round(((fiveHrLimit - fiveHrRemaining) / fiveHrLimit) * 100) : null,
|
|
297
|
+
weeklyPct: weeklyLimit > 0 ? Math.round(((weeklyLimit - weeklyRemaining) / weeklyLimit) * 100) : null,
|
|
298
|
+
fiveHrResetsIn: fiveHrResetsAt ? formatResetsIn(fiveHrResetsAt) : null,
|
|
299
|
+
weeklyResetsIn: weeklyResetsAt ? formatResetsIn(weeklyResetsAt) : null,
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function parseKimiResetTime(v) {
|
|
305
|
+
if (typeof v === "number") {
|
|
306
|
+
if (v >= 1e12 && v <= 4e12) return v;
|
|
307
|
+
if (v > 1e9) return v * 1000;
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
if (typeof v === "string" && v.length > 0) {
|
|
311
|
+
const t = Date.parse(v);
|
|
312
|
+
return isNaN(t) ? null : t;
|
|
313
|
+
}
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ----- openrouter parser (total_credits - total_usage) -----
|
|
318
|
+
|
|
319
|
+
function parseOpenrouterResponse(body) {
|
|
320
|
+
let json;
|
|
321
|
+
try {
|
|
322
|
+
json = typeof body === "string" ? JSON.parse(body) : body;
|
|
323
|
+
} catch {
|
|
324
|
+
return { ok: false, kind: "parse", message: "JSON 解析失败" };
|
|
325
|
+
}
|
|
326
|
+
if (!json || typeof json !== "object") {
|
|
327
|
+
return { ok: false, kind: "parse", message: "OpenRouter 响应不是对象" };
|
|
328
|
+
}
|
|
329
|
+
const data = json.data;
|
|
330
|
+
if (!data || typeof data !== "object") {
|
|
331
|
+
return { ok: false, kind: "parse", message: "data 字段缺失" };
|
|
332
|
+
}
|
|
333
|
+
const total = Number(data.total_credits);
|
|
334
|
+
const used = Number(data.total_usage);
|
|
335
|
+
if (!isFinite(total) || !isFinite(used)) {
|
|
336
|
+
return { ok: false, kind: "parse", message: "total_credits / total_usage 不是数字" };
|
|
337
|
+
}
|
|
338
|
+
const remaining = total - used;
|
|
339
|
+
return {
|
|
340
|
+
ok: true,
|
|
341
|
+
provider: "openrouter",
|
|
342
|
+
currency: "USD",
|
|
343
|
+
display: {
|
|
344
|
+
balanceUsd: remaining,
|
|
345
|
+
balanceText: formatBalance(remaining, "USD"),
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ----- zhipu (智谱 GLM Coding Plan) parser -----
|
|
351
|
+
// { "code": 200, "success": true,
|
|
352
|
+
// "data": { "limits": [ { "type": "CREDIT_LIMIT", "unit": 3, "usage": 2000,
|
|
353
|
+
// "remaining": 0, "percentage": 100, "nextResetTime": 1786969101067 }, ... ] } }
|
|
354
|
+
// unit=3 是 5h 窗口, unit=6 是周窗口. percentage 直接是已用 0-100 (服务器算好).
|
|
355
|
+
|
|
356
|
+
function parseZhipuResponse(body) {
|
|
357
|
+
let json;
|
|
358
|
+
try {
|
|
359
|
+
json = typeof body === "string" ? JSON.parse(body) : body;
|
|
360
|
+
} catch {
|
|
361
|
+
return { ok: false, kind: "parse", message: "JSON 解析失败" };
|
|
362
|
+
}
|
|
363
|
+
if (!json || typeof json !== "object") {
|
|
364
|
+
return { ok: false, kind: "parse", message: "智谱响应不是对象" };
|
|
365
|
+
}
|
|
366
|
+
if (json.success === false) {
|
|
367
|
+
return { ok: false, kind: "server_error", message: "智谱 success=false · " + (json.msg || "") };
|
|
368
|
+
}
|
|
369
|
+
const data = json.data;
|
|
370
|
+
if (!data || !Array.isArray(data.limits)) {
|
|
371
|
+
return { ok: false, kind: "parse", message: "data.limits 缺失" };
|
|
372
|
+
}
|
|
373
|
+
const fiveHr = data.limits.find((l) => l && (l.unit === 3 || l.unit === "3"));
|
|
374
|
+
const weekly = data.limits.find((l) => l && (l.unit === 6 || l.unit === "6"));
|
|
375
|
+
if (!fiveHr && !weekly) {
|
|
376
|
+
return { ok: false, kind: "parse", message: "找不到 unit=3 (5h) 或 unit=6 (周) 的 limit" };
|
|
377
|
+
}
|
|
378
|
+
function pickWindow(w) {
|
|
379
|
+
if (!w) return null;
|
|
380
|
+
const limit = Number(w.usage) || 0;
|
|
381
|
+
const remaining = Number(w.remaining) || 0;
|
|
382
|
+
const pct = typeof w.percentage === "number" ? w.percentage : limit > 0 ? Math.round(((limit - remaining) / limit) * 100) : null;
|
|
383
|
+
const resetsAt = parseEndTime(w.nextResetTime);
|
|
384
|
+
return { usedPercent: pct, resetsAt };
|
|
385
|
+
}
|
|
386
|
+
const f = pickWindow(fiveHr);
|
|
387
|
+
const w = pickWindow(weekly);
|
|
388
|
+
return {
|
|
389
|
+
ok: true,
|
|
390
|
+
provider: "zhipu",
|
|
391
|
+
display: {
|
|
392
|
+
fiveHrPct: f ? f.usedPercent : null,
|
|
393
|
+
weeklyPct: w ? w.usedPercent : null,
|
|
394
|
+
fiveHrResetsIn: f && f.resetsAt ? formatResetsIn(f.resetsAt) : null,
|
|
395
|
+
weeklyResetsIn: w && w.resetsAt ? formatResetsIn(w.resetsAt) : null,
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function formatBalance(n, currency) {
|
|
401
|
+
const symbol = currency === "CNY" ? "¥" : currency === "USD" ? "$" : "";
|
|
402
|
+
return symbol + (n >= 100 ? n.toFixed(0) : n.toFixed(2));
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function formatResetsIn(resetsAtMs) {
|
|
406
|
+
if (typeof resetsAtMs !== "number" || !resetsAtMs) return "";
|
|
407
|
+
const ms = resetsAtMs - Date.now();
|
|
408
|
+
if (ms <= 0) return "即将重置";
|
|
409
|
+
const totalMin = Math.floor(ms / 60000);
|
|
410
|
+
const h = Math.floor(totalMin / 60);
|
|
411
|
+
const m = totalMin % 60;
|
|
412
|
+
if (h > 0) return h + "h" + m + "m 后重置";
|
|
413
|
+
return m + "m 后重置";
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function classifyQuotaHttpStatus(status) {
|
|
417
|
+
if (status === 429) return "rate_limited";
|
|
418
|
+
if (status === 401 || status === 403) return "auth_failed";
|
|
419
|
+
return "server_error";
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function parseCurlOutput(rawText) {
|
|
423
|
+
if (typeof rawText !== "string") return { body: "", statusCode: 0 };
|
|
424
|
+
const lastNl = rawText.lastIndexOf("\n");
|
|
425
|
+
if (lastNl < 0) return { body: rawText, statusCode: 0 };
|
|
426
|
+
const body = rawText.slice(0, lastNl);
|
|
427
|
+
const statusCode = parseInt(rawText.slice(lastNl + 1).trim(), 10);
|
|
428
|
+
if (isNaN(statusCode)) return { body: rawText, statusCode: 0 };
|
|
429
|
+
return { body, statusCode };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Trim a parser result to the exact wire shape declared in typert.host.js. */
|
|
433
|
+
function quotaWireValue(parsed) {
|
|
434
|
+
if (!parsed || parsed.ok !== true) {
|
|
435
|
+
return {
|
|
436
|
+
ok: false,
|
|
437
|
+
provider: (parsed && parsed.provider) || "",
|
|
438
|
+
kind: (parsed && parsed.kind) || "other",
|
|
439
|
+
message: (parsed && parsed.message) || "未知错误",
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
const d = parsed.display || {};
|
|
443
|
+
return {
|
|
444
|
+
ok: true,
|
|
445
|
+
provider: parsed.provider,
|
|
446
|
+
display: {
|
|
447
|
+
fiveHrPct: typeof d.fiveHrPct === "number" && isFinite(d.fiveHrPct) ? d.fiveHrPct : null,
|
|
448
|
+
weeklyPct: typeof d.weeklyPct === "number" && isFinite(d.weeklyPct) ? d.weeklyPct : null,
|
|
449
|
+
fiveHrResetsIn: typeof d.fiveHrResetsIn === "string" && d.fiveHrResetsIn ? d.fiveHrResetsIn : null,
|
|
450
|
+
weeklyResetsIn: typeof d.weeklyResetsIn === "string" && d.weeklyResetsIn ? d.weeklyResetsIn : null,
|
|
451
|
+
balanceText: typeof d.balanceText === "string" && d.balanceText ? d.balanceText : null,
|
|
452
|
+
balanceUsd: typeof d.balanceUsd === "number" && isFinite(d.balanceUsd) ? d.balanceUsd : null,
|
|
453
|
+
currency: typeof parsed.currency === "string" ? parsed.currency : null,
|
|
454
|
+
},
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
75
458
|
export class TokenStatsService extends TypertRemoteService {
|
|
76
459
|
/**
|
|
77
460
|
* No hard service dependencies: `sessionQuery` is read through `ctx.get()`
|
|
@@ -95,6 +478,8 @@ export class TokenStatsService extends TypertRemoteService {
|
|
|
95
478
|
*/
|
|
96
479
|
[Service.init]() {
|
|
97
480
|
markRemoteMethod(this, "getStats", "getStats");
|
|
481
|
+
markRemoteMethod(this, "getQuota", "getQuota");
|
|
482
|
+
markRemoteMethod(this, "getAllQuotas", "getAllQuotas");
|
|
98
483
|
|
|
99
484
|
/** dayKey ('YYYY-MM-DD', local) -> Map<modelKey, day aggregate>. */
|
|
100
485
|
this._byDay = new Map();
|
|
@@ -109,6 +494,12 @@ export class TokenStatsService extends TypertRemoteService {
|
|
|
109
494
|
this._saveTimer = null;
|
|
110
495
|
this._saving = false;
|
|
111
496
|
|
|
497
|
+
// Quota fetch state: per-provider cache entries + the credential ref that
|
|
498
|
+
// last resolved for each provider. In-memory only (no persistence).
|
|
499
|
+
this._quotaCache = Object.create(null);
|
|
500
|
+
this._quotaActiveRef = Object.create(null);
|
|
501
|
+
this._curlPath = null;
|
|
502
|
+
|
|
112
503
|
// Restore the previous run's aggregates so a cold start only scans new sessions.
|
|
113
504
|
this._loadStore();
|
|
114
505
|
|
|
@@ -450,6 +841,206 @@ export class TokenStatsService extends TypertRemoteService {
|
|
|
450
841
|
},
|
|
451
842
|
};
|
|
452
843
|
}
|
|
844
|
+
|
|
845
|
+
// ---- quota / balance (套餐余额) --------------------------------------------
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* Resolve the API key for one provider through the DSH credentials seam.
|
|
849
|
+
* Ref candidates follow the settings page's `<ROUTE>_API_KEY` derivation and
|
|
850
|
+
* each provider's shipped default `apiKeyEnv`; without the credentials
|
|
851
|
+
* service (headless deployment) the process environment is consulted.
|
|
852
|
+
*/
|
|
853
|
+
async _quotaApiKey(provider) {
|
|
854
|
+
const cfg = QUOTA_PROVIDERS[provider];
|
|
855
|
+
if (!cfg) return { ref: null, key: null };
|
|
856
|
+
const credentials = this.ctx.get("credentials");
|
|
857
|
+
const candidates = this._quotaActiveRef[provider]
|
|
858
|
+
? [this._quotaActiveRef[provider], ...cfg.refs.filter((r) => r !== this._quotaActiveRef[provider])]
|
|
859
|
+
: cfg.refs;
|
|
860
|
+
for (const ref of candidates) {
|
|
861
|
+
try {
|
|
862
|
+
if (credentials && typeof credentials.resolve === "function") {
|
|
863
|
+
const hit = await credentials.resolve(ref);
|
|
864
|
+
if (hit && hit.value) {
|
|
865
|
+
this._quotaActiveRef[provider] = ref;
|
|
866
|
+
return { ref, key: hit.value };
|
|
867
|
+
}
|
|
868
|
+
} else if (typeof process !== "undefined" && process.env && process.env[ref]) {
|
|
869
|
+
this._quotaActiveRef[provider] = ref;
|
|
870
|
+
return { ref, key: process.env[ref] };
|
|
871
|
+
}
|
|
872
|
+
} catch (e) {
|
|
873
|
+
/* try the next ref */
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
return { ref: null, key: null };
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
async _resolveCurl() {
|
|
880
|
+
if (this._curlPath) return this._curlPath;
|
|
881
|
+
const subprocess = this.ctx.get("subprocess");
|
|
882
|
+
if (!subprocess || typeof subprocess.resolveExecutable !== "function") {
|
|
883
|
+
throw new Error("subprocess service 不可用, 且宿主无全局 fetch");
|
|
884
|
+
}
|
|
885
|
+
this._curlPath = await subprocess.resolveExecutable("curl");
|
|
886
|
+
return this._curlPath;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** curl fallback for hosts without a global fetch (same shape as musage). */
|
|
890
|
+
async _curlGet(url, key, authStyle) {
|
|
891
|
+
const subprocess = this.ctx.get("subprocess");
|
|
892
|
+
if (!subprocess || typeof subprocess.spawn !== "function") {
|
|
893
|
+
return { ok: false, kind: "network", message: "subprocess service 不可用, 且宿主无全局 fetch" };
|
|
894
|
+
}
|
|
895
|
+
const c = await this._resolveCurl();
|
|
896
|
+
const authHeader = authStyle === "raw" ? "Authorization: " + key : "Authorization: Bearer " + key;
|
|
897
|
+
const handle = subprocess.spawn({
|
|
898
|
+
argv: [
|
|
899
|
+
c, "-sS",
|
|
900
|
+
"--max-time", String(Math.floor(QUOTA_REQUEST_TIMEOUT_MS / 1000)),
|
|
901
|
+
"-w", "\n%{http_code}",
|
|
902
|
+
"-H", authHeader,
|
|
903
|
+
"-H", "Accept: application/json",
|
|
904
|
+
url,
|
|
905
|
+
],
|
|
906
|
+
cwd: "/",
|
|
907
|
+
stdio: {
|
|
908
|
+
stdin: "ignore",
|
|
909
|
+
stdout: { maxBytes: 8 * 1024 * 1024 },
|
|
910
|
+
stderr: { maxBytes: 64 * 1024 },
|
|
911
|
+
},
|
|
912
|
+
graceMs: QUOTA_REQUEST_TIMEOUT_MS,
|
|
913
|
+
});
|
|
914
|
+
const outcome = await handle.done;
|
|
915
|
+
const stdout = handle.collected && handle.collected.stdout
|
|
916
|
+
? handle.collected.stdout.readFrom(0)
|
|
917
|
+
: { text: "" };
|
|
918
|
+
const stderr = handle.collected && handle.collected.stderr
|
|
919
|
+
? handle.collected.stderr.readFrom(0)
|
|
920
|
+
: { text: "" };
|
|
921
|
+
if (outcome.exitCode !== 0) {
|
|
922
|
+
return { ok: false, kind: "network", message: "curl 退出 " + outcome.exitCode + " · " + stderr.text.slice(0, 200) };
|
|
923
|
+
}
|
|
924
|
+
const { body, statusCode } = parseCurlOutput(stdout.text);
|
|
925
|
+
if (statusCode === 0) {
|
|
926
|
+
return { ok: false, kind: "network", message: "curl 输出没拿到 HTTP 状态: " + stdout.text.slice(0, 200) };
|
|
927
|
+
}
|
|
928
|
+
if (statusCode !== 200) {
|
|
929
|
+
return {
|
|
930
|
+
ok: false,
|
|
931
|
+
kind: classifyQuotaHttpStatus(statusCode),
|
|
932
|
+
httpStatus: statusCode,
|
|
933
|
+
message: "HTTP " + statusCode + " · " + body.slice(0, 200),
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
return { ok: true, body };
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** GET one provider endpoint with its API key; fetch first, curl fallback. */
|
|
940
|
+
async _httpGet(url, key, authStyle) {
|
|
941
|
+
if (typeof fetch === "function") {
|
|
942
|
+
try {
|
|
943
|
+
const res = await fetch(url, {
|
|
944
|
+
method: "GET",
|
|
945
|
+
headers: {
|
|
946
|
+
authorization: authStyle === "raw" ? key : "Bearer " + key,
|
|
947
|
+
accept: "application/json",
|
|
948
|
+
},
|
|
949
|
+
signal: AbortSignal.timeout(QUOTA_REQUEST_TIMEOUT_MS),
|
|
950
|
+
});
|
|
951
|
+
const body = await res.text();
|
|
952
|
+
if (res.status !== 200) {
|
|
953
|
+
return {
|
|
954
|
+
ok: false,
|
|
955
|
+
kind: classifyQuotaHttpStatus(res.status),
|
|
956
|
+
httpStatus: res.status,
|
|
957
|
+
message: "HTTP " + res.status + " · " + body.slice(0, 200),
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
return { ok: true, body };
|
|
961
|
+
} catch (e) {
|
|
962
|
+
return { ok: false, kind: "network", message: "fetch 失败: " + String((e && e.message) || e) };
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
return this._curlGet(url, key, authStyle);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
async _fetchProviderQuota(provider) {
|
|
969
|
+
const cfg = QUOTA_PROVIDERS[provider];
|
|
970
|
+
if (!cfg) {
|
|
971
|
+
return { ok: false, provider, kind: "other", message: "未知 provider: " + provider };
|
|
972
|
+
}
|
|
973
|
+
const { ref, key } = await this._quotaApiKey(provider);
|
|
974
|
+
if (!key) {
|
|
975
|
+
return {
|
|
976
|
+
ok: false,
|
|
977
|
+
provider,
|
|
978
|
+
kind: "unconfigured",
|
|
979
|
+
message: "未配置 " + provider + " API Key (在 DSH 模型设置里配置对应 provider)",
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
const url = cfg.urls[ref] || cfg.urls[cfg.refs[0]];
|
|
983
|
+
const raw = await this._httpGet(url, key, cfg.authStyle);
|
|
984
|
+
if (!raw.ok) return { ...raw, provider };
|
|
985
|
+
return cfg.parse(raw.body);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* Cached per-provider quota read. Success caches for 30 s; failures cache
|
|
990
|
+
* with exponential backoff (5 s → 30 min cap) so a broken provider is not
|
|
991
|
+
* hammered. `force` drops the cached entry before reading (手动刷新).
|
|
992
|
+
*/
|
|
993
|
+
async _getQuotaCached(provider, force) {
|
|
994
|
+
if (force) this._quotaCache[provider] = null;
|
|
995
|
+
const c = this._quotaCache[provider];
|
|
996
|
+
if (c && c.expiresAt > Date.now()) return c.value;
|
|
997
|
+
const value = quotaWireValue(await this._fetchProviderQuota(provider));
|
|
998
|
+
if (value.ok) {
|
|
999
|
+
this._quotaCache[provider] = { value, expiresAt: Date.now() + QUOTA_CACHE_TTL_MS, streak: 0 };
|
|
1000
|
+
} else {
|
|
1001
|
+
const streak = (c ? c.streak : 0) + 1;
|
|
1002
|
+
this._quotaCache[provider] = { value, expiresAt: Date.now() + quotaBackoffMs(streak), streak };
|
|
1003
|
+
}
|
|
1004
|
+
return value;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Plan quota / balance for one provider (`minimax` | `deepseek` | `kimi` |
|
|
1009
|
+
* `openrouter` | `zhipu`). The composer readout in the Client half calls
|
|
1010
|
+
* this for the session's active provider (and on click with force=true).
|
|
1011
|
+
*/
|
|
1012
|
+
async getQuota(provider, force) {
|
|
1013
|
+
const p = typeof provider === "string" ? provider : "";
|
|
1014
|
+
if (!QUOTA_PROVIDERS[p]) {
|
|
1015
|
+
return { ok: true, value: { ok: false, provider: p, kind: "other", message: "未知 provider: " + p } };
|
|
1016
|
+
}
|
|
1017
|
+
try {
|
|
1018
|
+
return { ok: true, value: await this._getQuotaCached(p, force === true) };
|
|
1019
|
+
} catch (e) {
|
|
1020
|
+
return { ok: true, value: { ok: false, provider: p, kind: "other", message: String((e && e.message) || e) } };
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
/**
|
|
1025
|
+
* Quota snapshot for every supported provider at once (设置页余额区块).
|
|
1026
|
+
* Providers are queried in parallel; each entry is the same wire value
|
|
1027
|
+
* getQuota returns. `force` bypasses every provider's cache.
|
|
1028
|
+
*/
|
|
1029
|
+
async getAllQuotas(force) {
|
|
1030
|
+
const names = Object.keys(QUOTA_PROVIDERS);
|
|
1031
|
+
const values = await Promise.all(
|
|
1032
|
+
names.map(async (p) => {
|
|
1033
|
+
try {
|
|
1034
|
+
return await this._getQuotaCached(p, force === true);
|
|
1035
|
+
} catch (e) {
|
|
1036
|
+
return quotaWireValue({ ok: false, provider: p, kind: "other", message: String((e && e.message) || e) });
|
|
1037
|
+
}
|
|
1038
|
+
}),
|
|
1039
|
+
);
|
|
1040
|
+
const quotas = {};
|
|
1041
|
+
for (let i = 0; i < names.length; i++) quotas[names[i]] = values[i];
|
|
1042
|
+
return { ok: true, value: { quotas } };
|
|
1043
|
+
}
|
|
453
1044
|
}
|
|
454
1045
|
|
|
455
1046
|
export default TokenStatsService;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duke-dsh-plugins/dsh-token-stats",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Token usage statistics for DeepSeek Harness
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "Token usage statistics for DeepSeek Harness (7/30-day per-model charts + yearly heatmap) plus an inline coding-plan balance/quota readout in the composer (minimax/deepseek/kimi/openrouter/zhipu).",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/MoonlitDropOfBlood/dsh-token-stats.git"
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
},
|
|
44
44
|
"scripts": {
|
|
45
45
|
"check": "node --check index.js && node --check client.js && node --check typert.host.js",
|
|
46
|
-
"smoke": "node scripts/smoke-store.mjs"
|
|
46
|
+
"smoke": "node scripts/smoke-store.mjs",
|
|
47
|
+
"smoke:quota": "node scripts/smoke-quota.mjs"
|
|
47
48
|
}
|
|
48
49
|
}
|