@duke-dsh-plugins/dsh-token-stats 1.5.5 → 1.6.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 (3) hide show
  1. package/client.js +28 -7
  2. package/index.js +242 -54
  3. package/package.json +7 -5
package/client.js CHANGED
@@ -18,8 +18,16 @@
18
18
  * (`ctx.remote.tokenStats.getStats()/getQuota()`), published by the Host half
19
19
  * in `index.js`.
20
20
  */
21
+ /**
22
+ * This package's client-module id. It is both the `__ModuleLoader__.load` id and
23
+ * the HMR style owner (`data-plugin`) — client-modules keys style ownership off the
24
+ * registered id, so the two must stay the same string. See the `claimStyles` note
25
+ * where the stylesheet is created.
26
+ */
27
+ const CLIENT_BUNDLE_ID = "@duke-dsh-plugins/dsh-token-stats";
28
+
21
29
  window.__ModuleLoader__.load({
22
- id: "@duke-dsh-plugins/dsh-token-stats",
30
+ id: CLIENT_BUNDLE_ID,
23
31
  factory: (require) => {
24
32
  var module = { exports: {} };
25
33
  var exports = module.exports;
@@ -260,6 +268,13 @@ window.__ModuleLoader__.load({
260
268
  try {
261
269
  styleTag = document.createElement("style");
262
270
  styleTag.textContent = CSS;
271
+ // dsh 0.2.0 client-modules runs `claimStyles(ownerId)` during
272
+ // materialization, which claims every *untagged* <style> in the document
273
+ // for the materializing package. `apply()` is async and appends this tag
274
+ // after that sweep, so without an explicit owner it would stay untagged
275
+ // and a later package could adopt it — and `removeOwnedStyles(thatId)`
276
+ // would then strip our stylesheet on its HMR invalidation. Claim it now.
277
+ styleTag.setAttribute("data-plugin", CLIENT_BUNDLE_ID);
263
278
  document.head.appendChild(styleTag);
264
279
  ctx.effect(() => () => styleTag && styleTag.remove());
265
280
  } catch (e) {
@@ -667,16 +682,16 @@ window.__ModuleLoader__.load({
667
682
  if (lower.includes("mimo") || lower.includes("xiaomi")) return "mimo";
668
683
  return null;
669
684
  }
670
- // 凭据设置行写入的 ref — 与 host QUOTA_PROVIDERS.refs 首选项对齐。
671
- // mimo 写 XIAOMI_MIMO_COOKIE(README 文档路径),不触碰 LLM 路由的
672
- // API Key ref(host probe 顺序 Cookie 优先,v1.5.4)。
685
+ // 凭据设置行写入的 ref — 与 host QUOTA_PROVIDERS.apiKeyRefs 首选项对齐。
686
+ // mimo 写 XIAOMI_MIMO_API_KEY(v1.6.0 起推荐:Bearer /v1/user/balance
687
+ // 与路由 LLM API Key 同源,稳定不易过期;Cookie 仍可作为兜底探测)。
673
688
  const QUOTA_CRED_REFS = {
674
689
  minimax: "MINIMAX_CN_API_KEY",
675
690
  deepseek: "DEEPSEEK_API_KEY",
676
691
  kimi: "KIMI_CODING_API_KEY",
677
692
  openrouter: "OPENROUTER_API_KEY",
678
693
  zhipu: "ZAI_CODING_CN_API_KEY",
679
- mimo: "XIAOMI_MIMO_COOKIE",
694
+ mimo: "XIAOMI_MIMO_API_KEY",
680
695
  };
681
696
  const QUOTA_PROVIDER_ORDER = ["minimax", "deepseek", "kimi", "openrouter", "zhipu", "mimo"];
682
697
 
@@ -708,8 +723,12 @@ window.__ModuleLoader__.load({
708
723
  return [labelEl, React.createElement("span", { key: "b", style: strong }, txt)];
709
724
  }
710
725
  if (provider === "mimo") {
711
- // MiMo Token Plan:套餐已用% + 本月总额度%(detail 缺失时可能只有一行)
726
+ // MiMo:PAYG 显示 CNY 余额,Token Plan 显示 套餐% + 本月%。
712
727
  const parts = [labelEl];
728
+ if (typeof d.balanceText === "string" && d.balanceText) {
729
+ parts.push(React.createElement("span", { key: "bal", style: strong }, d.balanceText));
730
+ return parts;
731
+ }
713
732
  if (typeof d.fiveHrPct === "number") {
714
733
  parts.push(React.createElement("span", { key: "plan", style: strong }, "套餐 " + d.fiveHrPct + "%"));
715
734
  }
@@ -1089,7 +1108,9 @@ window.__ModuleLoader__.load({
1089
1108
  type: "password",
1090
1109
  className: "ts-cred-input",
1091
1110
  placeholder: p === "mimo"
1092
- ? (isAuthFailed ? "凭据已失效:重新粘贴 Cookie 或 API Key" : "粘贴登录 Cookie 或 API Key")
1111
+ ? (isAuthFailed
1112
+ ? "凭据失效:粘贴新的 API Key(推荐)或 Cookie"
1113
+ : "推荐粘贴 API Key (tp-...),与 LLM 路由同源不易过期;Cookie 也能用")
1093
1114
  : "粘贴 API Key",
1094
1115
  value: credDrafts[p] || "",
1095
1116
  onChange: (e) => setCredDrafts((d) => ({ ...d, [p]: e.target.value })),
package/index.js CHANGED
@@ -142,33 +142,40 @@ 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;route 派生
150
- // 名(设置页按 route 生成的 XIAOMI_TOKEN_PLAN_CN_API_KEY/_COOKIE,与
151
- // 其他 provider 的 MINIMAX_CN_* 同规则)同样识别。
152
- // Cookie 类 ref 排最前:README 的可靠路径是 Cookie,且 Token 统计页「凭据
153
- // 设置」写入 XIAOMI_MIMO_COOKIE——已存在但失效的 API Key(如 route 派生名
154
- // 下的 tp- key 被 dashboard 401 拒)不能挡住用户新粘贴 Cookie(v1.5.4)。
145
+ // Xiaomi MiMo Token Plan — 多端点探测 (v1.6.0 起):
146
+ // 可靠路径是用户给 LLM 路由配的 API Key(tp-..., Bearer 鉴权)走
147
+ // /v1/user/balance 或 /api/v1/tokenPlan/usage (Bearer 端点)。
148
+ // dashboard Cookie 路径(platform.xiaomimimo.com)作为兜底——Cookie 容易
149
+ // 过期,但仍有用户在用。Endpoint 链见 _fetchMimoQuota。
150
+ // 凭据 ref 命名沿用设置页派生规则:
151
+ // - apiKey 走 Bearer: XIAOMI_MIMO_API_KEY / XIAOMI_TOKEN_PLAN_CN_API_KEY / MIMO_API_KEY
152
+ // - cookie 走 Cookie: XIAOMI_MIMO_COOKIE / XIAOMI_TOKEN_PLAN_CN_COOKIE / MIMO_COOKIE
153
+ // 凭据设置 UI 默认写入 XIAOMI_MIMO_API_KEY (v1.6.0 起推荐路径);
154
+ // 已有 Cookie 的旧用户不受影响(探测链自动用 Cookie 兜底)。
155
155
  mimo: {
156
- refs: ["XIAOMI_MIMO_COOKIE", "XIAOMI_TOKEN_PLAN_CN_COOKIE", "XIAOMI_MIMO_API_KEY", "XIAOMI_TOKEN_PLAN_CN_API_KEY", "MIMO_COOKIE", "MIMO_API_KEY"],
157
- urls: {
158
- XIAOMI_TOKEN_PLAN_CN_API_KEY: "https://platform.xiaomimimo.com/api/v1/tokenPlan/usage",
159
- XIAOMI_TOKEN_PLAN_CN_COOKIE: "https://platform.xiaomimimo.com/api/v1/tokenPlan/usage",
160
- XIAOMI_MIMO_API_KEY: "https://platform.xiaomimimo.com/api/v1/tokenPlan/usage",
161
- XIAOMI_MIMO_COOKIE: "https://platform.xiaomimimo.com/api/v1/tokenPlan/usage",
162
- MIMO_API_KEY: "https://platform.xiaomimimo.com/api/v1/tokenPlan/usage",
163
- MIMO_COOKIE: "https://platform.xiaomimimo.com/api/v1/tokenPlan/usage",
164
- },
156
+ // Bearer 鉴权 ref 候选 (DSH 模型设置里配的 LLM 路由 API Key / tp-...)
157
+ apiKeyRefs: ["XIAOMI_MIMO_API_KEY", "XIAOMI_TOKEN_PLAN_CN_API_KEY", "MIMO_API_KEY"],
158
+ // Cookie 鉴权 ref 候选 (浏览器 dashboard session, Bearer 失败时的兜底)
159
+ cookieRefs: ["XIAOMI_MIMO_COOKIE", "XIAOMI_TOKEN_PLAN_CN_COOKIE", "MIMO_COOKIE"],
165
160
  parse: parseMimoResponse,
166
161
  },
167
162
  };
168
163
 
169
- /** Xiaomi MiMo dashboard admin API endpoints(usage + detail,鉴权同一凭证)。 */
170
- const MIMO_USAGE_URL = "https://platform.xiaomimimo.com/api/v1/tokenPlan/usage";
171
- const MIMO_DETAIL_URL = "https://platform.xiaomimimo.com/api/v1/tokenPlan/detail";
164
+ /** Xiaomi MiMo 多端点常量 — usage 走 Bearer/Cookie 双探, balance 走 Bearer (PAYG + Token Plan SGP)。
165
+ * 协议来源:https://docs.rs/quotas/latest/src/quotas/providers/mimo.rs.html (clankercode/quotas, MIT)
166
+ * 与 https://github.com/steipete/CodexBar/blob/main/docs/mimo.md (CodexBar, MIT)。
167
+ * Bearer 鉴权用 LLM 路由 API Key (tp-...) 即可,Cookie 是 dashboard session 兜底。 */
168
+ const MIMO_PLATFORM = "https://platform.xiaomimimo.com";
169
+ const MIMO_PAYG_BASE = "https://api.xiaomimimo.com";
170
+ const MIMO_TOKEN_PLAN_SGP = "https://token-plan-sgp.xiaomimimo.com";
171
+ /** /api/v1/tokenPlan/usage + /detail(Bearer 或 Cookie,套餐 % + 重置时间)。 */
172
+ const MIMO_USAGE_URL = `${MIMO_PLATFORM}/api/v1/tokenPlan/usage`;
173
+ const MIMO_DETAIL_URL = `${MIMO_PLATFORM}/api/v1/tokenPlan/detail`;
174
+ const MIMO_TOKEN_PLAN_SGP_USAGE_URL = `${MIMO_TOKEN_PLAN_SGP}/api/v1/tokenPlan/usage`;
175
+ const MIMO_TOKEN_PLAN_SGP_DETAIL_URL = `${MIMO_TOKEN_PLAN_SGP}/api/v1/tokenPlan/detail`;
176
+ /** /v1/user/balance(仅 Bearer:Token Plan token_balance/token_limit 或 PAYG CNY balance)。 */
177
+ const MIMO_PAYG_BALANCE_URL = `${MIMO_PAYG_BASE}/v1/user/balance`;
178
+ const MIMO_TOKEN_PLAN_SGP_BALANCE_URL = `${MIMO_TOKEN_PLAN_SGP}/v1/user/balance`;
172
179
 
173
180
  function quotaBackoffMs(streak) {
174
181
  if (streak <= 0) return 0;
@@ -466,7 +473,7 @@ function parseMimoResponse(body, detailBody) {
466
473
  ok: false,
467
474
  provider: "mimo",
468
475
  kind: "auth_failed",
469
- message: "MiMo 凭据已失效(Cookie 过期或未登录),请到 platform.xiaomimimo.com 重新复制",
476
+ message: "MiMo 凭据已失效(API Key 或 Cookie 过期),请到 platform.xiaomimimo.com 续费或重新生成",
470
477
  };
471
478
  }
472
479
  return {
@@ -549,6 +556,101 @@ function parseMimoResponse(body, detailBody) {
549
556
  };
550
557
  }
551
558
 
559
+ // ----- xiaomi mimo /v1/user/balance parser (Bearer-only, 优先路径) -----
560
+ // PAYG 账号:
561
+ // { data: { balance: "12.50", charge_balance: "10.00",
562
+ // granted_balance: "2.50", plan: "PAYG" } }
563
+ // Token Plan 账号:
564
+ // { data: { token_balance: 800000, token_limit: 1000000,
565
+ // plan_name: "Pro" } }
566
+ // balance 可能是字符串或数字(来自 Rust 端 serde 默认行为)。
567
+ // 数据本身已含 plan 字段,无需再拉 detail。
568
+
569
+ function parseMimoBalanceResponse(body /*, detailBody — unused, balance 端点不含 detail */) {
570
+ let json;
571
+ try {
572
+ json = typeof body === "string" ? JSON.parse(body) : body;
573
+ } catch {
574
+ return { ok: false, provider: "mimo", kind: "parse", message: "MiMo balance 响应 JSON 解析失败" };
575
+ }
576
+ if (!json || typeof json !== "object") {
577
+ return { ok: false, provider: "mimo", kind: "parse", message: "MiMo balance 响应不是对象" };
578
+ }
579
+ if (typeof json.code === "number" && json.code !== 0) {
580
+ if (json.code >= 40100 && json.code < 40200) {
581
+ return {
582
+ ok: false,
583
+ provider: "mimo",
584
+ kind: "auth_failed",
585
+ message: "MiMo API Key 已失效,请到 platform.xiaomimimo.com 重新生成",
586
+ };
587
+ }
588
+ return {
589
+ ok: false,
590
+ provider: "mimo",
591
+ kind: "server_error",
592
+ message: "MiMo 业务错误 code=" + json.code + " · " + (json.message || ""),
593
+ };
594
+ }
595
+ const data = (json.data && typeof json.data === "object") ? json.data : json;
596
+
597
+ // Token Plan: token_balance + token_limit 存在 → 套餐已用 %
598
+ const tokBal = parseMimoNumberField(data.token_balance);
599
+ const tokLim = parseMimoNumberField(data.token_limit);
600
+ if (tokLim !== null && tokLim > 0 && tokBal !== null) {
601
+ const used = Math.max(0, tokLim - tokBal);
602
+ const pct = Math.round((used / tokLim) * 100);
603
+ return {
604
+ ok: true,
605
+ provider: "mimo",
606
+ display: {
607
+ fiveHrPct: Math.max(0, Math.min(100, pct)),
608
+ weeklyPct: null,
609
+ fiveHrResetsIn: null,
610
+ weeklyResetsIn: null,
611
+ balanceText: null,
612
+ balanceUsd: null,
613
+ currency: null,
614
+ },
615
+ };
616
+ }
617
+
618
+ // PAYG: balance 字段(CNY)→ 余额文本
619
+ const bal = parseMimoNumberField(data.balance);
620
+ if (bal !== null && bal > 0) {
621
+ return {
622
+ ok: true,
623
+ provider: "mimo",
624
+ currency: "CNY",
625
+ display: {
626
+ balanceText: formatBalance(bal, "CNY"),
627
+ balanceUsd: null,
628
+ fiveHrPct: null,
629
+ weeklyPct: null,
630
+ fiveHrResetsIn: null,
631
+ weeklyResetsIn: null,
632
+ currency: "CNY",
633
+ },
634
+ };
635
+ }
636
+
637
+ return {
638
+ ok: false,
639
+ provider: "mimo",
640
+ kind: "parse",
641
+ message: "MiMo balance 响应缺少 token_balance/token_limit 或 balance 字段",
642
+ };
643
+ }
644
+
645
+ function parseMimoNumberField(v) {
646
+ if (typeof v === "number" && isFinite(v)) return v;
647
+ if (typeof v === "string") {
648
+ const n = parseFloat(v);
649
+ return isFinite(n) ? n : null;
650
+ }
651
+ return null;
652
+ }
653
+
552
654
  function formatResetsIn(resetsAtMs) {
553
655
  if (typeof resetsAtMs !== "number" || !resetsAtMs) return "";
554
656
  const ms = resetsAtMs - Date.now();
@@ -1035,30 +1137,63 @@ export class TokenStatsService extends TypertRemoteService {
1035
1137
  * each provider's shipped default `apiKeyEnv`; without the credentials
1036
1138
  * service (headless deployment) the process environment is consulted.
1037
1139
  */
1140
+ /**
1141
+ * Resolve credentials for one provider through the DSH credentials seam.
1142
+ * Ref candidates follow the settings page's `<ROUTE>_API_KEY` derivation and
1143
+ * each provider's shipped default `apiKeyEnv`; without the credentials
1144
+ * service (headless deployment) the process environment is consulted.
1145
+ *
1146
+ * v1.6.0 起,mimo 同时持有 API Key (Bearer) + Cookie (dashboard session) 两类
1147
+ * 凭据;其他 provider 只用 API Key。返回值总是
1148
+ * { apiKey, apiKeyRef, cookie, cookieRef },
1149
+ * 未配置那一路为 null。`_quotaActiveRef[provider + ":api" | ":cookie"]` 按
1150
+ * provider+kind 缓存上次命中的 ref(保持粘性,但不互锁)。
1151
+ */
1038
1152
  async _quotaApiKey(provider) {
1039
1153
  const cfg = QUOTA_PROVIDERS[provider];
1040
- if (!cfg) return { ref: null, key: null };
1154
+ if (!cfg) return { apiKey: null, apiKeyRef: null, cookie: null, cookieRef: null };
1041
1155
  const credentials = this.ctx.get("credentials");
1042
- const candidates = this._quotaActiveRef[provider]
1043
- ? [this._quotaActiveRef[provider], ...cfg.refs.filter((r) => r !== this._quotaActiveRef[provider])]
1044
- : cfg.refs;
1045
- for (const ref of candidates) {
1156
+ // 兼容新旧两种配置形态:新的是 {apiKeyRefs, cookieRefs},旧的是单数组 refs。
1157
+ const apiKeyRefList = cfg.apiKeyRefs || cfg.refs || [];
1158
+ const cookieRefList = cfg.cookieRefs || [];
1159
+
1160
+ const resolveOne = async (ref) => {
1046
1161
  try {
1047
1162
  if (credentials && typeof credentials.resolve === "function") {
1048
1163
  const hit = await credentials.resolve(ref);
1049
- if (hit && hit.value) {
1050
- this._quotaActiveRef[provider] = ref;
1051
- return { ref, key: hit.value };
1052
- }
1164
+ if (hit && hit.value) return hit.value;
1053
1165
  } else if (typeof process !== "undefined" && process.env && process.env[ref]) {
1054
- this._quotaActiveRef[provider] = ref;
1055
- return { ref, key: process.env[ref] };
1166
+ return process.env[ref];
1056
1167
  }
1057
1168
  } catch (e) {
1058
1169
  /* try the next ref */
1059
1170
  }
1060
- }
1061
- return { ref: null, key: null };
1171
+ return null;
1172
+ };
1173
+
1174
+ const resolveList = async (refList, activeKey) => {
1175
+ const active = this._quotaActiveRef[activeKey];
1176
+ const candidates = active
1177
+ ? [active, ...refList.filter((r) => r !== active)]
1178
+ : refList;
1179
+ for (const ref of candidates) {
1180
+ const v = await resolveOne(ref);
1181
+ if (v) {
1182
+ this._quotaActiveRef[activeKey] = ref;
1183
+ return { ref, value: v };
1184
+ }
1185
+ }
1186
+ return { ref: null, value: null };
1187
+ };
1188
+
1189
+ const api = await resolveList(apiKeyRefList, provider + ":api");
1190
+ const ck = await resolveList(cookieRefList, provider + ":cookie");
1191
+ return {
1192
+ apiKey: api.value,
1193
+ apiKeyRef: api.ref,
1194
+ cookie: ck.value,
1195
+ cookieRef: ck.ref,
1196
+ };
1062
1197
  }
1063
1198
 
1064
1199
  async _resolveCurl() {
@@ -1162,8 +1297,9 @@ export class TokenStatsService extends TypertRemoteService {
1162
1297
  if (!cfg) {
1163
1298
  return { ok: false, provider, kind: "other", message: "未知 provider: " + provider };
1164
1299
  }
1165
- const { ref, key } = await this._quotaApiKey(provider);
1166
- if (!key) {
1300
+ const { apiKey, apiKeyRef, cookie } = await this._quotaApiKey(provider);
1301
+ if (provider === "mimo") return this._fetchMimoQuota(apiKey, cookie);
1302
+ if (!apiKey) {
1167
1303
  return {
1168
1304
  ok: false,
1169
1305
  provider,
@@ -1171,29 +1307,81 @@ export class TokenStatsService extends TypertRemoteService {
1171
1307
  message: "未配置 " + provider + " API Key (在 DSH 模型设置里配置对应 provider)",
1172
1308
  };
1173
1309
  }
1174
- if (provider === "mimo") return this._fetchMimoQuota(key);
1175
- const url = cfg.urls[ref] || cfg.urls[cfg.refs[0]];
1176
- const raw = await this._httpGet(url, key, cfg.authStyle);
1310
+ const refList = cfg.apiKeyRefs || cfg.refs || [];
1311
+ const url = cfg.urls[apiKeyRef] || cfg.urls[refList[0]];
1312
+ const raw = await this._httpGet(url, apiKey, cfg.authStyle);
1177
1313
  if (!raw.ok) return { ...raw, provider };
1178
1314
  return cfg.parse(raw.body);
1179
1315
  }
1180
1316
 
1181
1317
  /**
1182
- * Xiaomi MiMo Token Plan(dashboard admin API,契约来自 Musage xiaomi.rs):
1183
- * 同一凭证值先按 Bearer 试,401/403 时原样改 `Cookie:` 头重试;usage 成功
1184
- * 后再 best-effort 拉一次 detail(周期结束时间 / 过期标记)。detail 失败
1185
- * 不影响 usage 结果(只是没有重置倒计时)。
1318
+ * Xiaomi MiMo 多端点探测链 (v1.6.0 起,对齐 quotas crate mimo.rs + CodexBar):
1319
+ * Bearer (API Key, 稳定优先):
1320
+ * 1. token-plan-sgp/v1/user/balance (Token Plan token_balance/token_limit)
1321
+ * 2. api.xiaomimimo.com/v1/user/balance (PAYG CNY balance)
1322
+ * 3. token-plan-sgp/api/v1/tokenPlan/usage (Token Plan 套餐 % + 月度 %)
1323
+ * 4. platform/api/v1/tokenPlan/usage (Bearer 平台端点)
1324
+ * Cookie (dashboard session, 兜底):
1325
+ * 5. platform/api/v1/tokenPlan/usage (经典 dashboard 路径)
1326
+ * 6. token-plan-sgp/api/v1/tokenPlan/usage (SGP dashboard 路径)
1327
+ * 每个端点 HTTP 200 后用对应 parser 解析;业务 401xx → auth_failed, 进入下一
1328
+ * 个端点;parse 失败(非凭据)→ 直接返回。usage 端点成功后再 best-effort 拉
1329
+ * detail(同鉴权方式)补 reset 时间。balance 端点本身包含 plan 字段,不再
1330
+ * 拉 detail。
1186
1331
  */
1187
- async _fetchMimoQuota(key) {
1188
- let raw = await this._httpGet(MIMO_USAGE_URL, key, undefined);
1189
- let cookie = false;
1190
- if (!raw.ok && (raw.httpStatus === 401 || raw.httpStatus === 403)) {
1191
- raw = await this._httpGet(MIMO_USAGE_URL, key, "cookie");
1192
- cookie = true;
1332
+ async _fetchMimoQuota(apiKey, cookie) {
1333
+ if (!apiKey && !cookie) {
1334
+ return {
1335
+ ok: false,
1336
+ provider: "mimo",
1337
+ kind: "unconfigured",
1338
+ message: "未配置 MiMo API Key 或 Cookie(在 DSH 模型设置里配置对应 provider)",
1339
+ };
1340
+ }
1341
+
1342
+ // 探测链 — Bearer 在前, Cookie 在后。`detailUrl === null` 表示该端点
1343
+ // 不需要 detail(balance 端点本身已含 plan 信息)。
1344
+ const chain = [];
1345
+ if (apiKey) {
1346
+ chain.push({ auth: "bearer", url: MIMO_TOKEN_PLAN_SGP_BALANCE_URL, parser: parseMimoBalanceResponse, detailUrl: null, tag: "balance-sgp" });
1347
+ chain.push({ auth: "bearer", url: MIMO_PAYG_BALANCE_URL, parser: parseMimoBalanceResponse, detailUrl: null, tag: "balance-payg" });
1348
+ chain.push({ auth: "bearer", url: MIMO_TOKEN_PLAN_SGP_USAGE_URL, parser: parseMimoResponse, detailUrl: MIMO_TOKEN_PLAN_SGP_DETAIL_URL, tag: "usage-sgp" });
1349
+ chain.push({ auth: "bearer", url: MIMO_USAGE_URL, parser: parseMimoResponse, detailUrl: MIMO_DETAIL_URL, tag: "usage-platform" });
1350
+ }
1351
+ if (cookie) {
1352
+ chain.push({ auth: "cookie", url: MIMO_USAGE_URL, parser: parseMimoResponse, detailUrl: MIMO_DETAIL_URL, tag: "usage-platform" });
1353
+ chain.push({ auth: "cookie", url: MIMO_TOKEN_PLAN_SGP_USAGE_URL, parser: parseMimoResponse, detailUrl: MIMO_TOKEN_PLAN_SGP_DETAIL_URL, tag: "usage-sgp" });
1354
+ }
1355
+
1356
+ let lastError = null;
1357
+ for (const step of chain) {
1358
+ const cred = step.auth === "cookie" ? cookie : apiKey;
1359
+ const raw = await this._httpGet(step.url, cred, step.auth === "cookie" ? "cookie" : undefined);
1360
+ if (!raw.ok) {
1361
+ lastError = { ...raw, provider: "mimo" };
1362
+ continue;
1363
+ }
1364
+ // usage 端点: 先拉 detail (expired 标记 + reset 时间). detail 失败不影响
1365
+ // usage 解析, 留空 detail 让 parser 走"无 detail"分支. balance 端点
1366
+ // (detailUrl === null) 本身已含 plan, 不再请求 detail.
1367
+ let detailBody = null;
1368
+ if (step.detailUrl) {
1369
+ const d = await this._httpGet(step.detailUrl, cred, step.auth === "cookie" ? "cookie" : undefined);
1370
+ if (d.ok) detailBody = d.body;
1371
+ }
1372
+ const parsed = step.parser(raw.body, detailBody);
1373
+ if (!parsed.ok) {
1374
+ if (parsed.kind === "auth_failed") {
1375
+ // 凭据失效 → 进入下一端点.
1376
+ lastError = { ...parsed, provider: "mimo" };
1377
+ continue;
1378
+ }
1379
+ // 其他失败(parse / server_error):直接返回,不必继续探测。
1380
+ return parsed;
1381
+ }
1382
+ return parsed;
1193
1383
  }
1194
- if (!raw.ok) return { ...raw, provider: "mimo" };
1195
- const detail = await this._httpGet(MIMO_DETAIL_URL, key, cookie ? "cookie" : undefined);
1196
- return parseMimoResponse(raw.body, detail && detail.ok ? detail.body : null);
1384
+ return lastError || { ok: false, provider: "mimo", kind: "other", message: "MiMo 所有端点探测均失败" };
1197
1385
  }
1198
1386
 
1199
1387
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duke-dsh-plugins/dsh-token-stats",
3
- "version": "1.5.5",
3
+ "version": "1.6.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",
@@ -27,7 +27,7 @@
27
27
  ],
28
28
  "license": "MIT",
29
29
  "engines": {
30
- "dsh": ">=0.1.4-rc.2 <0.2.0"
30
+ "dsh": ">=0.1.4-rc.2 <0.3.0"
31
31
  },
32
32
  "dsh": {
33
33
  "bundle": {
@@ -39,8 +39,8 @@
39
39
  },
40
40
  "peerDependencies": {
41
41
  "@deepseek-ai/cordis": "^4.0.1",
42
- "@deepseek-ai/dsh": ">=0.1.4-rc.2 <0.2.0",
43
- "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.7"
42
+ "@deepseek-ai/dsh": ">=0.1.4-rc.2 <0.3.0",
43
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.7 || ^0.2.0-rc.1"
44
44
  },
45
45
  "dependencies": {
46
46
  "zod": "^4.4.3"
@@ -50,6 +50,8 @@
50
50
  "smoke": "node scripts/smoke-store.mjs",
51
51
  "smoke:quota": "node scripts/smoke-quota.mjs",
52
52
  "smoke:robust": "node scripts/smoke-robust-host.mjs && node scripts/smoke-robust-client.mjs",
53
- "smoke:typert": "node scripts/smoke-typert.mjs"
53
+ "smoke:typert": "node scripts/smoke-typert.mjs",
54
+ "smoke:compat": "node scripts/smoke-compat.mjs",
55
+ "e2e": "npm run check && npm run smoke && npm run smoke:quota && npm run smoke:robust && npm run smoke:typert && npm run smoke:compat && node scripts/repro-client-mount.mjs && node scripts/repro-strict-dispatch.mjs && node scripts/repro-profile-boot.mjs"
54
56
  }
55
57
  }