@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.
Files changed (4) hide show
  1. package/README.md +5 -2
  2. package/client.js +129 -37
  3. package/index.js +229 -25
  4. 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;60s 轮询,点击立即刷新 |
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
- await ctx.remote.$mount(CLIENT_REMOTE);
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
- const styleTag = document.createElement("style");
217
- styleTag.textContent = CSS;
218
- document.head.appendChild(styleTag);
219
- ctx.effect(() => () => styleTag.remove());
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
- ctx.effect(() => registerSettingsNavIcon(SETTINGS_LABEL));
224
-
225
- // ctx.get() reads the service without the property-accessor inject guard.
226
- const remote = ctx.get("remote.tokenStats");
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
- // minimax / kimi / zhipu: 5h + 7d 窗口已用百分比
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
- ["5h 窗口", d.fiveHrPct, d.fiveHrResetsIn],
663
- ["7d 窗口", d.weeklyPct, d.weeklyResetsIn],
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
- ["5h 窗口", d.fiveHrPct, d.fiveHrResetsIn],
871
- ["7d 窗口", d.weeklyPct, d.weeklyResetsIn],
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
- ctx.slots.inject("settings.section", () =>
1097
- ctx.slots.register(
1098
- { name: "settings.section", id: "token-stats", order: 25, label: () => SETTINGS_LABEL },
1099
- TokenStatsPage,
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
- ctx.inject(["slots", "modelDirectories"], (scope) => {
1110
- scope.slots.inject("conversation.input.right", () =>
1111
- scope.slots.register(
1112
- { name: "conversation.input.right", id: "token-stats-quota", order: 0, label: "Token quota" },
1113
- (props) =>
1114
- React.createElement(QuotaReadout, {
1115
- sessionId: props && props.sessionId,
1116
- models: scope.modelDirectories,
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 the
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 method, then start collecting.
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
- markRemoteMethod(this, "getStats", "getStats");
481
- markRemoteMethod(this, "getQuota", "getQuota");
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
- this._loadStore();
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
- this.ctx.effect(() => () => {
508
- if (this._saveTimer) {
509
- clearTimeout(this._saveTimer);
510
- this._saveTimer = null;
511
- }
512
- if (this._dirty) this._saveSync();
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
- this.ctx.on("session/event", (session, event) => this._onSessionEvent(session, event));
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
- this._startBackfill();
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 = authStyle === "raw" ? "Authorization: " + key : "Authorization: Bearer " + key;
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 calls
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.4.0",
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
  }