@duke-dsh-plugins/dsh-token-stats 1.4.0 → 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 +5 -2
- package/client.js +129 -37
- package/index.js +229 -25
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
| 🗂 汇总卡片 | 区间总 Tokens / 输入(含缓存) / 输出 |
|
|
28
28
|
| 💾 本地持久化 | 聚合结果落盘 `<DSH_HOME>/data/dsh-token-stats/stats.json`,冷启动只扫描新会话、秒开;删除该文件可强制全量重扫 |
|
|
29
29
|
| ⏱ 自动刷新 | 页面打开期间每 30s 刷新;历史回填期间每 2s 轮询进度 |
|
|
30
|
-
| 💰 套餐余额 | 输入框工具行(model 选择器左侧)内联显示当前 provider 的**余额/套餐用量**,跟随当前模型自动切换;支持 minimax / deepseek / kimi / openrouter / zhipu
|
|
30
|
+
| 💰 套餐余额 | 输入框工具行(model 选择器左侧)内联显示当前 provider 的**余额/套餐用量**,跟随当前模型自动切换;支持 minimax / deepseek / kimi / openrouter / zhipu / mimo(小米);60s 轮询,点击立即刷新 |
|
|
31
31
|
| 🌗 主题适配 | 全部使用 DSH 设计 token,明暗主题自动跟随 |
|
|
32
32
|
|
|
33
33
|
> 套餐余额功能参考自 [dsh-musage](https://github.com/Thedeergod666/dsh-musage)(MIT),API Key 直接复用 DSH 模型设置里已配置的凭据(`credentials` 服务),无需重复配置。
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
dsh plugin --profile web add /path/to/dsh-token-stats
|
|
44
44
|
|
|
45
45
|
# 正式发布:从 GitHub Release tarball 安装
|
|
46
|
-
dsh plugin --profile web add https://github.com/MoonlitDropOfBlood/dsh-token-stats/releases/download/v1.4.0/dsh-token-stats-1.4.0.tgz
|
|
46
|
+
dsh plugin --profile web add https://github.com/MoonlitDropOfBlood/dsh-token-stats/releases/download/v1.4.0/duke-dsh-plugins-dsh-token-stats-1.4.0.tgz
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
重启 DSH 后,打开 DSH Web UI 的设置(侧栏底部),左侧导航会出现 **Token 统计** 页。
|
|
@@ -61,9 +61,12 @@ dsh plugin --profile web add https://github.com/MoonlitDropOfBlood/dsh-token-sta
|
|
|
61
61
|
4. **会话输入框工具行**(model 选择器左侧)内联显示当前 provider 的套餐余额:
|
|
62
62
|
- DeepSeek / OpenRouter 显示余额(如 `¥43.97` / `$12.50`);
|
|
63
63
|
- MiniMax / Kimi / 智谱 显示 `5h X% | 7d Y%`(5 小时 / 7 天窗口已用百分比,悬停查看重置时间);
|
|
64
|
+
- 小米 MiMo 显示 `套餐 X% | 本月 Y%`(Token Plan 已用百分比,悬停查看重置时间;需在凭据里配 platform.xiaomimimo.com 的登录 Cookie,见下);
|
|
64
65
|
- 切换模型时自动切换 provider;每 60s 自动刷新,**点击读数立即强制刷新**;
|
|
65
66
|
- 未配置对应 API Key 或拉取失败时显示 `⚠`(悬停查看原因);当前 provider 不在支持列表时不占位。
|
|
66
67
|
|
|
68
|
+
> **MiMo(小米)配置**:MiMo 的用量走 dashboard admin API(非公开 endpoint),纯 API Key(Bearer)实测会被 401 拒绝,可靠凭证是浏览器登录 Cookie。打开 platform.xiaomimimo.com → 订阅管理,F12 → Network 任一 `/api/v1/tokenPlan/*` 请求 → 复制完整 `Cookie` 请求头值,粘到 DSH 凭据的 `XIAOMI_MIMO_COOKIE`(或把 API Key 配到 `XIAOMI_MIMO_API_KEY`,插件会先 Bearer 后 Cookie 自动重试)。
|
|
69
|
+
|
|
67
70
|
## 工作原理
|
|
68
71
|
|
|
69
72
|
```
|
package/client.js
CHANGED
|
@@ -209,21 +209,55 @@ window.__ModuleLoader__.load({
|
|
|
209
209
|
};
|
|
210
210
|
|
|
211
211
|
async function apply(ctx) {
|
|
212
|
+
// 0.1.7-rc.1 hardening: a client bundle that rejects can keep the whole
|
|
213
|
+
// web app from reaching the conversation page. Every optional step below
|
|
214
|
+
// degrades independently so this plugin never blocks DSH boot; the last
|
|
215
|
+
// resort catch keeps apply() itself from rejecting.
|
|
216
|
+
try {
|
|
217
|
+
await applyInner(ctx);
|
|
218
|
+
} catch (e) {
|
|
219
|
+
// eslint-disable-next-line no-console
|
|
220
|
+
(typeof console !== "undefined" && console.error ? console.error : () => {})(
|
|
221
|
+
"[dsh-token-stats] client apply failed; plugin disabled for this session:",
|
|
222
|
+
e,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
async function applyInner(ctx) {
|
|
212
228
|
// Mount the tokenStats namespace before anything touches it; the mount's
|
|
213
|
-
// lifetime is bound to this plugin's context by $mount itself.
|
|
214
|
-
|
|
229
|
+
// lifetime is bound to this plugin's context by $mount itself. A mount
|
|
230
|
+
// failure (descriptor schema drift in a future dsh) downgrades the UI to
|
|
231
|
+
// an error state instead of failing the whole bundle.
|
|
232
|
+
let remote = null;
|
|
233
|
+
try {
|
|
234
|
+
await ctx.remote.$mount(CLIENT_REMOTE);
|
|
235
|
+
remote = ctx.get("remote.tokenStats") || null;
|
|
236
|
+
} catch (e) {
|
|
237
|
+
// eslint-disable-next-line no-console
|
|
238
|
+
(typeof console !== "undefined" && console.warn ? console.warn : () => {})(
|
|
239
|
+
"[dsh-token-stats] remote.$mount failed; stats page will show an error state:",
|
|
240
|
+
e,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
215
243
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
244
|
+
let styleTag = null;
|
|
245
|
+
try {
|
|
246
|
+
styleTag = document.createElement("style");
|
|
247
|
+
styleTag.textContent = CSS;
|
|
248
|
+
document.head.appendChild(styleTag);
|
|
249
|
+
ctx.effect(() => () => styleTag && styleTag.remove());
|
|
250
|
+
} catch (e) {
|
|
251
|
+
/* cosmetic only */
|
|
252
|
+
}
|
|
220
253
|
|
|
221
254
|
// Mark our settings-nav row so the CSS above replaces the shell's
|
|
222
255
|
// fallback gear (no icon field exists in settings.section yet).
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
256
|
+
try {
|
|
257
|
+
ctx.effect(() => registerSettingsNavIcon(SETTINGS_LABEL));
|
|
258
|
+
} catch (e) {
|
|
259
|
+
/* cosmetic only */
|
|
260
|
+
}
|
|
227
261
|
|
|
228
262
|
const PALETTE = [
|
|
229
263
|
"#5b8cff", "#f26d7a", "#3ecf8e", "#f5b942", "#a78bfa", "#38bdf8",
|
|
@@ -603,8 +637,12 @@ window.__ModuleLoader__.load({
|
|
|
603
637
|
"openrouter": "openrouter",
|
|
604
638
|
"zai-coding-cn": "zhipu",
|
|
605
639
|
"zhipu": "zhipu",
|
|
640
|
+
// Xiaomi MiMo:route id 由用户添加 provider 时命名,覆盖常见拼法。
|
|
641
|
+
"xiaomi-mimo": "mimo",
|
|
642
|
+
"xiaomimimo": "mimo",
|
|
643
|
+
"mimo": "mimo",
|
|
606
644
|
};
|
|
607
|
-
const QUOTA_PROVIDER_ORDER = ["minimax", "deepseek", "kimi", "openrouter", "zhipu"];
|
|
645
|
+
const QUOTA_PROVIDER_ORDER = ["minimax", "deepseek", "kimi", "openrouter", "zhipu", "mimo"];
|
|
608
646
|
|
|
609
647
|
function quotaProviderLabel(p) {
|
|
610
648
|
if (p === "minimax") return "MiniMax";
|
|
@@ -612,6 +650,7 @@ window.__ModuleLoader__.load({
|
|
|
612
650
|
if (p === "kimi") return "Kimi";
|
|
613
651
|
if (p === "openrouter") return "OpenRouter";
|
|
614
652
|
if (p === "zhipu") return "Zhipu";
|
|
653
|
+
if (p === "mimo") return "MiMo";
|
|
615
654
|
return p;
|
|
616
655
|
}
|
|
617
656
|
|
|
@@ -632,7 +671,20 @@ window.__ModuleLoader__.load({
|
|
|
632
671
|
const txt = d.balanceText || (typeof d.balanceUsd === "number" ? "$" + d.balanceUsd.toFixed(2) : "—");
|
|
633
672
|
return [labelEl, React.createElement("span", { key: "b", style: strong }, txt)];
|
|
634
673
|
}
|
|
635
|
-
|
|
674
|
+
if (provider === "mimo") {
|
|
675
|
+
// MiMo Token Plan:套餐已用% + 本月总额度%(detail 缺失时可能只有一行)
|
|
676
|
+
const parts = [labelEl];
|
|
677
|
+
if (typeof d.fiveHrPct === "number") {
|
|
678
|
+
parts.push(React.createElement("span", { key: "plan", style: strong }, "套餐 " + d.fiveHrPct + "%"));
|
|
679
|
+
}
|
|
680
|
+
if (typeof d.weeklyPct === "number") {
|
|
681
|
+
if (parts.length > 1) parts.push(React.createElement("span", { key: "sep", style: { opacity: 0.5, fontSize: 10 } }, "|"));
|
|
682
|
+
parts.push(React.createElement("span", { key: "month", style: strong }, "本月 " + d.weeklyPct + "%"));
|
|
683
|
+
}
|
|
684
|
+
if (parts.length === 1) parts.push(React.createElement("span", { key: "na", style: strong }, "—"));
|
|
685
|
+
return parts;
|
|
686
|
+
}
|
|
687
|
+
// minimax / kimi / zhipu: 5h + 7d 双窗口套餐
|
|
636
688
|
const five = typeof d.fiveHrPct === "number" ? d.fiveHrPct + "%" : "—";
|
|
637
689
|
const week = typeof d.weeklyPct === "number" ? d.weeklyPct + "%" : "—";
|
|
638
690
|
return [
|
|
@@ -658,10 +710,16 @@ window.__ModuleLoader__.load({
|
|
|
658
710
|
),
|
|
659
711
|
);
|
|
660
712
|
}
|
|
661
|
-
const windows =
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
713
|
+
const windows =
|
|
714
|
+
props.provider === "mimo"
|
|
715
|
+
? [
|
|
716
|
+
["套餐已用", d.fiveHrPct, d.fiveHrResetsIn],
|
|
717
|
+
["本月总额度", d.weeklyPct, d.weeklyResetsIn],
|
|
718
|
+
]
|
|
719
|
+
: [
|
|
720
|
+
["5h 窗口", d.fiveHrPct, d.fiveHrResetsIn],
|
|
721
|
+
["7d 窗口", d.weeklyPct, d.weeklyResetsIn],
|
|
722
|
+
];
|
|
665
723
|
for (const w of windows) {
|
|
666
724
|
if (typeof w[1] !== "number") continue;
|
|
667
725
|
rows.push(
|
|
@@ -866,10 +924,16 @@ window.__ModuleLoader__.load({
|
|
|
866
924
|
React.createElement("div", { className: "ts-quota-card-value" }, d.balanceText),
|
|
867
925
|
);
|
|
868
926
|
}
|
|
869
|
-
const windows =
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
927
|
+
const windows =
|
|
928
|
+
p === "mimo"
|
|
929
|
+
? [
|
|
930
|
+
["套餐已用", d.fiveHrPct, d.fiveHrResetsIn],
|
|
931
|
+
["本月总额度", d.weeklyPct, d.weeklyResetsIn],
|
|
932
|
+
]
|
|
933
|
+
: [
|
|
934
|
+
["5h 窗口", d.fiveHrPct, d.fiveHrResetsIn],
|
|
935
|
+
["7d 窗口", d.weeklyPct, d.weeklyResetsIn],
|
|
936
|
+
];
|
|
873
937
|
return React.createElement(
|
|
874
938
|
"div", { className: "ts-card ts-quota-card" },
|
|
875
939
|
React.createElement(
|
|
@@ -952,6 +1016,10 @@ window.__ModuleLoader__.load({
|
|
|
952
1016
|
|
|
953
1017
|
const load = React.useCallback(async () => {
|
|
954
1018
|
try {
|
|
1019
|
+
if (!remote) {
|
|
1020
|
+
setError("tokenStats Remote 未就绪(宿主插件可能加载失败)");
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
955
1023
|
const res = await remote.getStats();
|
|
956
1024
|
if (res && res.ok) {
|
|
957
1025
|
// The Remote gateway returns `res.value` = the Host method's full
|
|
@@ -1093,12 +1161,20 @@ window.__ModuleLoader__.load({
|
|
|
1093
1161
|
}
|
|
1094
1162
|
|
|
1095
1163
|
// Settings entry: a full page under the sidebar Settings panel.
|
|
1096
|
-
|
|
1097
|
-
ctx.slots.
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1164
|
+
try {
|
|
1165
|
+
ctx.slots.inject("settings.section", () =>
|
|
1166
|
+
ctx.slots.register(
|
|
1167
|
+
{ name: "settings.section", id: "token-stats", order: 25, label: () => SETTINGS_LABEL },
|
|
1168
|
+
TokenStatsPage,
|
|
1169
|
+
),
|
|
1170
|
+
);
|
|
1171
|
+
} catch (e) {
|
|
1172
|
+
// eslint-disable-next-line no-console
|
|
1173
|
+
(typeof console !== "undefined" && console.warn ? console.warn : () => {})(
|
|
1174
|
+
"[dsh-token-stats] settings.section registration failed:",
|
|
1175
|
+
e,
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1102
1178
|
|
|
1103
1179
|
// Composer quota readout (余额/套餐用量), same seat as dsh-musage:
|
|
1104
1180
|
// `conversation.input.right`, immediately left of the model select. Only
|
|
@@ -1106,18 +1182,34 @@ window.__ModuleLoader__.load({
|
|
|
1106
1182
|
// per-session model directory the readout follows); scoped inject keeps
|
|
1107
1183
|
// deployments without it completely unaffected.
|
|
1108
1184
|
if (typeof ctx.inject === "function") {
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1185
|
+
try {
|
|
1186
|
+
ctx.inject(["slots", "modelDirectories"], (scope) => {
|
|
1187
|
+
try {
|
|
1188
|
+
scope.slots.inject("conversation.input.right", () =>
|
|
1189
|
+
scope.slots.register(
|
|
1190
|
+
{ name: "conversation.input.right", id: "token-stats-quota", order: 0, label: "Token quota" },
|
|
1191
|
+
(props) =>
|
|
1192
|
+
React.createElement(QuotaReadout, {
|
|
1193
|
+
sessionId: props && props.sessionId,
|
|
1194
|
+
models: scope.modelDirectories,
|
|
1195
|
+
}),
|
|
1196
|
+
),
|
|
1197
|
+
);
|
|
1198
|
+
} catch (e) {
|
|
1199
|
+
// eslint-disable-next-line no-console
|
|
1200
|
+
(typeof console !== "undefined" && console.warn ? console.warn : () => {})(
|
|
1201
|
+
"[dsh-token-stats] conversation.input.right registration failed:",
|
|
1202
|
+
e,
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
1205
|
+
});
|
|
1206
|
+
} catch (e) {
|
|
1207
|
+
// eslint-disable-next-line no-console
|
|
1208
|
+
(typeof console !== "undefined" && console.warn ? console.warn : () => {})(
|
|
1209
|
+
"[dsh-token-stats] modelDirectories scoped inject failed:",
|
|
1210
|
+
e,
|
|
1119
1211
|
);
|
|
1120
|
-
}
|
|
1212
|
+
}
|
|
1121
1213
|
}
|
|
1122
1214
|
}
|
|
1123
1215
|
|
package/index.js
CHANGED
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
* (`<provider>::<model>`), which is exactly what the Client charts consume.
|
|
28
28
|
*
|
|
29
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
|
|
32
|
-
* user-configured API key through the `credentials` service, GETs the
|
|
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
33
|
* provider's endpoint (global fetch, curl via `subprocess` as fallback), and
|
|
34
34
|
* caches per provider (30 s TTL, exponential backoff on failure). In-memory
|
|
35
35
|
* only; nothing below touches the persisted stats store.
|
|
@@ -142,8 +142,27 @@ const QUOTA_PROVIDERS = {
|
|
|
142
142
|
// 智谱特殊: Authorization 不加 "Bearer " 前缀 (来自 Musage zhipu.rs 注释)
|
|
143
143
|
authStyle: "raw",
|
|
144
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
|
+
},
|
|
145
160
|
};
|
|
146
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
|
+
|
|
147
166
|
function quotaBackoffMs(streak) {
|
|
148
167
|
if (streak <= 0) return 0;
|
|
149
168
|
return Math.min(QUOTA_BACKOFF_MAX_MS, QUOTA_BACKOFF_BASE_MS * Math.pow(2, streak - 1));
|
|
@@ -402,6 +421,127 @@ function formatBalance(n, currency) {
|
|
|
402
421
|
return symbol + (n >= 100 ? n.toFixed(0) : n.toFixed(2));
|
|
403
422
|
}
|
|
404
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
|
+
|
|
405
545
|
function formatResetsIn(resetsAtMs) {
|
|
406
546
|
if (typeof resetsAtMs !== "number" || !resetsAtMs) return "";
|
|
407
547
|
const ms = resetsAtMs - Date.now();
|
|
@@ -474,13 +614,18 @@ export class TokenStatsService extends TypertRemoteService {
|
|
|
474
614
|
|
|
475
615
|
/**
|
|
476
616
|
* Cordis class-plugin initializer: runs right after construction, before the
|
|
477
|
-
* 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.
|
|
478
625
|
*/
|
|
479
626
|
[Service.init]() {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
markRemoteMethod(this, "getAllQuotas", "getAllQuotas");
|
|
483
|
-
|
|
627
|
+
// State first: whatever later step fails, the Remote methods below must
|
|
628
|
+
// always find consistent in-memory state to answer with.
|
|
484
629
|
/** dayKey ('YYYY-MM-DD', local) -> Map<modelKey, day aggregate>. */
|
|
485
630
|
this._byDay = new Map();
|
|
486
631
|
/** modelKey -> { key, name, provider }. */
|
|
@@ -500,22 +645,55 @@ export class TokenStatsService extends TypertRemoteService {
|
|
|
500
645
|
this._quotaActiveRef = Object.create(null);
|
|
501
646
|
this._curlPath = null;
|
|
502
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
|
+
|
|
503
664
|
// Restore the previous run's aggregates so a cold start only scans new sessions.
|
|
504
|
-
|
|
665
|
+
try {
|
|
666
|
+
this._loadStore();
|
|
667
|
+
} catch (e) {
|
|
668
|
+
/* missing or corrupt store: start empty and let backfill do a full scan */
|
|
669
|
+
}
|
|
505
670
|
|
|
506
671
|
// Best-effort synchronous flush when the plugin fiber is disposed.
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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
|
+
}
|
|
514
683
|
|
|
515
684
|
// Live capture: every committed session append.
|
|
516
|
-
|
|
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
|
+
}
|
|
517
690
|
|
|
518
|
-
|
|
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
|
+
}
|
|
519
697
|
}
|
|
520
698
|
|
|
521
699
|
// ---- data helpers ---------------------------------------------------------
|
|
@@ -893,7 +1071,12 @@ export class TokenStatsService extends TypertRemoteService {
|
|
|
893
1071
|
return { ok: false, kind: "network", message: "subprocess service 不可用, 且宿主无全局 fetch" };
|
|
894
1072
|
}
|
|
895
1073
|
const c = await this._resolveCurl();
|
|
896
|
-
const authHeader =
|
|
1074
|
+
const authHeader =
|
|
1075
|
+
authStyle === "raw"
|
|
1076
|
+
? "Authorization: " + key
|
|
1077
|
+
: authStyle === "cookie"
|
|
1078
|
+
? "Cookie: " + key
|
|
1079
|
+
: "Authorization: Bearer " + key;
|
|
897
1080
|
const handle = subprocess.spawn({
|
|
898
1081
|
argv: [
|
|
899
1082
|
c, "-sS",
|
|
@@ -940,12 +1123,14 @@ export class TokenStatsService extends TypertRemoteService {
|
|
|
940
1123
|
async _httpGet(url, key, authStyle) {
|
|
941
1124
|
if (typeof fetch === "function") {
|
|
942
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;
|
|
943
1131
|
const res = await fetch(url, {
|
|
944
1132
|
method: "GET",
|
|
945
|
-
headers
|
|
946
|
-
authorization: authStyle === "raw" ? key : "Bearer " + key,
|
|
947
|
-
accept: "application/json",
|
|
948
|
-
},
|
|
1133
|
+
headers,
|
|
949
1134
|
signal: AbortSignal.timeout(QUOTA_REQUEST_TIMEOUT_MS),
|
|
950
1135
|
});
|
|
951
1136
|
const body = await res.text();
|
|
@@ -979,12 +1164,31 @@ export class TokenStatsService extends TypertRemoteService {
|
|
|
979
1164
|
message: "未配置 " + provider + " API Key (在 DSH 模型设置里配置对应 provider)",
|
|
980
1165
|
};
|
|
981
1166
|
}
|
|
1167
|
+
if (provider === "mimo") return this._fetchMimoQuota(key);
|
|
982
1168
|
const url = cfg.urls[ref] || cfg.urls[cfg.refs[0]];
|
|
983
1169
|
const raw = await this._httpGet(url, key, cfg.authStyle);
|
|
984
1170
|
if (!raw.ok) return { ...raw, provider };
|
|
985
1171
|
return cfg.parse(raw.body);
|
|
986
1172
|
}
|
|
987
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
|
+
|
|
988
1192
|
/**
|
|
989
1193
|
* Cached per-provider quota read. Success caches for 30 s; failures cache
|
|
990
1194
|
* with exponential backoff (5 s → 30 min cap) so a broken provider is not
|
|
@@ -1006,8 +1210,8 @@ export class TokenStatsService extends TypertRemoteService {
|
|
|
1006
1210
|
|
|
1007
1211
|
/**
|
|
1008
1212
|
* Plan quota / balance for one provider (`minimax` | `deepseek` | `kimi` |
|
|
1009
|
-
* `openrouter` | `zhipu`). The composer readout in the Client half
|
|
1010
|
-
* this for the session's active provider (and on click with force=true).
|
|
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).
|
|
1011
1215
|
*/
|
|
1012
1216
|
async getQuota(provider, force) {
|
|
1013
1217
|
const p = typeof provider === "string" ? provider : "";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duke-dsh-plugins/dsh-token-stats",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
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",
|
|
@@ -26,6 +26,9 @@
|
|
|
26
26
|
"cordis.patch.yml"
|
|
27
27
|
],
|
|
28
28
|
"license": "MIT",
|
|
29
|
+
"engines": {
|
|
30
|
+
"dsh": ">=0.1.4-rc.2 <0.2.0"
|
|
31
|
+
},
|
|
29
32
|
"dsh": {
|
|
30
33
|
"bundle": {
|
|
31
34
|
"patch": "./cordis.patch.yml"
|
|
@@ -36,6 +39,7 @@
|
|
|
36
39
|
},
|
|
37
40
|
"peerDependencies": {
|
|
38
41
|
"@deepseek-ai/cordis": "^4.0.1",
|
|
42
|
+
"@deepseek-ai/dsh": ">=0.1.4-rc.2 <0.2.0",
|
|
39
43
|
"@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.7"
|
|
40
44
|
},
|
|
41
45
|
"dependencies": {
|
|
@@ -44,6 +48,7 @@
|
|
|
44
48
|
"scripts": {
|
|
45
49
|
"check": "node --check index.js && node --check client.js && node --check typert.host.js",
|
|
46
50
|
"smoke": "node scripts/smoke-store.mjs",
|
|
47
|
-
"smoke:quota": "node scripts/smoke-quota.mjs"
|
|
51
|
+
"smoke:quota": "node scripts/smoke-quota.mjs",
|
|
52
|
+
"smoke:robust": "node scripts/smoke-robust-host.mjs && node scripts/smoke-robust-client.mjs"
|
|
48
53
|
}
|
|
49
54
|
}
|