@duke-dsh-plugins/dsh-token-stats 1.5.5 → 1.7.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 -3
- package/client.js +84 -14
- package/index.js +376 -68
- package/package.json +11 -6
package/README.md
CHANGED
|
@@ -61,11 +61,13 @@ 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
|
|
64
|
+
- 小米 MiMo 默认显示 `今日 X · 7d Y`(**本地用量统计,零凭据、永不过期**);若在「套餐余额」里填了 dashboard 登录 Cookie,则升级为官方 `剩余 X%` + 周重置倒计时(见下);
|
|
65
65
|
- 切换模型时自动切换 provider;每 60s 自动刷新,**点击读数立即强制刷新**;
|
|
66
66
|
- 未配置对应 API Key 或拉取失败时显示 `⚠`(悬停查看原因);当前 provider 不在支持列表时不占位。
|
|
67
67
|
|
|
68
|
-
> **MiMo
|
|
68
|
+
> **MiMo(小米)配置**:**默认零配置**——读数来自本插件自己的会话日志聚合(今日 / 近 7 天消耗 token 数),不发任何 MiMo 请求、不会过期。缺点是拿不到套餐百分比(官方没开放任何 API Key 配额接口:`token-plan-*` 与 `api.` 主机只有 OpenAI 兼容的 `/v1` 推理 API,实测所有 Bearer 配额路径均 404;key 还与区域强绑定,CN key 在 SGP/AMS 主机一律 401)。
|
|
69
|
+
>
|
|
70
|
+
> 想要**官方剩余用量百分比**,唯一路径是 dashboard 账号会话:打开 platform.xiaomimimo.com 登录后,F12 → Network 任一 `/api/v1/*` 请求 → 复制完整 `Cookie` 请求头(含 `api-platform_serviceToken` 与 `userId`)→ 粘到「设置 → Token 统计 → 套餐余额 → **MiMo 官方剩余用量(可选)**」保存(写入 `XIAOMI_MIMO_COOKIE`)。插件内置最小 cookie jar:服务端若在响应里 `Set-Cookie` 续发会话会被自动吸收并写回凭据库(跨轮询、跨重启生效);**Cookie 彻底失效时自动静默退回本地用量,不会报错**。composer 读数按 route id 匹配(`xiaomi-mimo` / `xiaomimimo` / `xiaomi-token-plan-cn` / 名字含 xiaomi 或 mimo 均可)。
|
|
69
71
|
|
|
70
72
|
## 工作原理
|
|
71
73
|
|
|
@@ -88,7 +90,7 @@ TokenStatsService.getQuota() ← ctx.remote.tokenStats.getQuota(provider, forc
|
|
|
88
90
|
- 数据按**本地日历天** × **模型**(`provider::model`)聚合;`total = input + output + cacheRead + cacheWrite`。
|
|
89
91
|
- 历史回填只统计插件启动前发生的调用,实时监听只统计启动后的,两者通过每个会话的事件序号水位线合并,不会重复。
|
|
90
92
|
- 模型/模型来自 `assistant/message` 的 `message.source`(kind = 'model'),无需自行解析请求头。
|
|
91
|
-
- 套餐余额:Host 半经 `credentials` 服务解析用户在模型设置里已配置的 API Key(引用命名遵循 `<ROUTE>_API_KEY` 约定),GET provider 官方端点(宿主全局 fetch 优先,无 fetch 时回退 `subprocess` curl);30s 缓存 + 失败指数退避(5s→30min
|
|
93
|
+
- 套餐余额:Host 半经 `credentials` 服务解析用户在模型设置里已配置的 API Key(引用命名遵循 `<ROUTE>_API_KEY` 约定),GET provider 官方端点(宿主全局 fetch 优先,无 fetch 时回退 `subprocess` curl);30s 缓存 + 失败指数退避(5s→30min),不落盘。**小米 MiMo 是例外**:它没有 API Key 配额接口,默认用本地会话日志聚合出用量,填了 dashboard Cookie 才升级为官方剩余用量 %。
|
|
92
94
|
|
|
93
95
|
## 目录结构
|
|
94
96
|
|
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:
|
|
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,9 +682,11 @@ 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.
|
|
671
|
-
// mimo 写 XIAOMI_MIMO_COOKIE(
|
|
672
|
-
//
|
|
685
|
+
// 凭据设置行写入的 ref — 与 host QUOTA_PROVIDERS.apiKeyRefs 首选项对齐。
|
|
686
|
+
// mimo 写 XIAOMI_MIMO_COOKIE(v1.7.0 起):MiMo 没有开放任何 API Key
|
|
687
|
+
// 配额接口(token-plan-* 主机只有 /v1 推理 API,实测见 scripts/probe-mimo.mjs),
|
|
688
|
+
// 只有 dashboard 账号会话 Cookie 能拿到官方「剩余用量 %」。不填也不影响
|
|
689
|
+
// —— host 会退回本地用量统计。
|
|
673
690
|
const QUOTA_CRED_REFS = {
|
|
674
691
|
minimax: "MINIMAX_CN_API_KEY",
|
|
675
692
|
deepseek: "DEEPSEEK_API_KEY",
|
|
@@ -708,8 +725,14 @@ window.__ModuleLoader__.load({
|
|
|
708
725
|
return [labelEl, React.createElement("span", { key: "b", style: strong }, txt)];
|
|
709
726
|
}
|
|
710
727
|
if (provider === "mimo") {
|
|
711
|
-
// MiMo
|
|
728
|
+
// MiMo:官方读数显示「剩余 X%」(balanceText),本地用量显示
|
|
729
|
+
// 「今日 X · 7d Y」——行内位置窄,只取前半段,完整内容在悬停面板。
|
|
712
730
|
const parts = [labelEl];
|
|
731
|
+
if (typeof d.balanceText === "string" && d.balanceText) {
|
|
732
|
+
const compact = d.balanceText.split(" · ")[0];
|
|
733
|
+
parts.push(React.createElement("span", { key: "bal", style: strong }, compact));
|
|
734
|
+
return parts;
|
|
735
|
+
}
|
|
713
736
|
if (typeof d.fiveHrPct === "number") {
|
|
714
737
|
parts.push(React.createElement("span", { key: "plan", style: strong }, "套餐 " + d.fiveHrPct + "%"));
|
|
715
738
|
}
|
|
@@ -738,20 +761,24 @@ window.__ModuleLoader__.load({
|
|
|
738
761
|
if (props.ok && props.display) {
|
|
739
762
|
const d = props.display;
|
|
740
763
|
if (typeof d.balanceText === "string" && d.balanceText) {
|
|
764
|
+
// mimo 的 balanceText 可能是「本地用量」也可能是官方「剩余 X%」。
|
|
765
|
+
const cap =
|
|
766
|
+
props.provider === "mimo"
|
|
767
|
+
? (typeof d.weeklyPct === "number" ? "官方剩余用量" : "本地用量(近 7 天 / 今日)")
|
|
768
|
+
: "账户余额" + (d.currency ? " (" + d.currency + ")" : "");
|
|
741
769
|
rows.push(
|
|
742
770
|
React.createElement(
|
|
743
771
|
"div", { key: "bal", className: "ts-quota-pop-row" },
|
|
744
|
-
React.createElement("span", null,
|
|
772
|
+
React.createElement("span", null, cap),
|
|
745
773
|
React.createElement("b", null, d.balanceText),
|
|
746
774
|
),
|
|
747
775
|
);
|
|
748
776
|
}
|
|
777
|
+
// mimo 官方读数只有一个 Token Plan 窗口(/api/v1/user/usage 的
|
|
778
|
+
// percent 是「剩余」,host 已换算成已用 %);本地用量模式没有窗口。
|
|
749
779
|
const windows =
|
|
750
780
|
props.provider === "mimo"
|
|
751
|
-
? [
|
|
752
|
-
["套餐已用", d.fiveHrPct, d.fiveHrResetsIn],
|
|
753
|
-
["本月总额度", d.weeklyPct, d.weeklyResetsIn],
|
|
754
|
-
]
|
|
781
|
+
? [["Token Plan", d.weeklyPct, d.weeklyResetsIn]]
|
|
755
782
|
: [
|
|
756
783
|
["5h 窗口", d.fiveHrPct, d.fiveHrResetsIn],
|
|
757
784
|
["7d 窗口", d.weeklyPct, d.weeklyResetsIn],
|
|
@@ -951,12 +978,17 @@ window.__ModuleLoader__.load({
|
|
|
951
978
|
}
|
|
952
979
|
const d = v.display || {};
|
|
953
980
|
if (typeof d.balanceText === "string" && d.balanceText) {
|
|
981
|
+
// mimo 复用 balanceText 承载两种读数:本地用量(无窗口百分比)或
|
|
982
|
+
// 官方「剩余 X%」(有 weeklyPct),所以标题不能写死「账户余额」。
|
|
983
|
+
const head = p === "mimo"
|
|
984
|
+
? (typeof d.weeklyPct === "number" ? "Token Plan" : "本地用量")
|
|
985
|
+
: "账户余额" + (d.currency ? " · " + d.currency : "");
|
|
954
986
|
return React.createElement(
|
|
955
987
|
"div", { className: "ts-card ts-quota-card" },
|
|
956
988
|
React.createElement(
|
|
957
989
|
"div", { className: "ts-quota-card-head" },
|
|
958
990
|
React.createElement("span", { className: "ts-quota-card-name" }, label),
|
|
959
|
-
React.createElement("span", { className: "ts-quota-card-status" },
|
|
991
|
+
React.createElement("span", { className: "ts-quota-card-status" }, head),
|
|
960
992
|
),
|
|
961
993
|
React.createElement("div", { className: "ts-quota-card-value" }, d.balanceText),
|
|
962
994
|
);
|
|
@@ -1050,7 +1082,14 @@ window.__ModuleLoader__.load({
|
|
|
1050
1082
|
const needsCred = names.filter(
|
|
1051
1083
|
(p) => quotas[p] && quotas[p].ok === false && (quotas[p].kind === "unconfigured" || quotas[p].kind === "auth_failed"),
|
|
1052
1084
|
);
|
|
1053
|
-
|
|
1085
|
+
// mimo 零凭据可用(本地用量),所以不会落进 needsCred;这里在它还处于
|
|
1086
|
+
// 本地模式时补一个**可选**的 Cookie 入口——粘了就能升级成官方剩余用量 %。
|
|
1087
|
+
const mimo = quotas.mimo;
|
|
1088
|
+
const mimoLocalMode =
|
|
1089
|
+
!!(mimo && mimo.ok === true) &&
|
|
1090
|
+
typeof mimo.display === "object" &&
|
|
1091
|
+
typeof mimo.display.weeklyPct !== "number";
|
|
1092
|
+
if (configured.length === 0 && needsCred.length === 0 && !mimoLocalMode) return null;
|
|
1054
1093
|
return React.createElement(
|
|
1055
1094
|
"div", { className: "ts-block" },
|
|
1056
1095
|
React.createElement(
|
|
@@ -1081,7 +1120,6 @@ window.__ModuleLoader__.load({
|
|
|
1081
1120
|
"div", { className: "ts-cred-block" },
|
|
1082
1121
|
React.createElement("div", { className: "ts-block-title" }, "凭据设置(粘贴后保存,自动重新拉取)"),
|
|
1083
1122
|
needsCred.map((p) => {
|
|
1084
|
-
const isAuthFailed = quotas[p] && quotas[p].kind === "auth_failed";
|
|
1085
1123
|
return React.createElement(
|
|
1086
1124
|
"div", { key: p, className: "ts-cred-row" },
|
|
1087
1125
|
React.createElement("span", { className: "ts-cred-label" }, quotaProviderLabel(p)),
|
|
@@ -1089,7 +1127,7 @@ window.__ModuleLoader__.load({
|
|
|
1089
1127
|
type: "password",
|
|
1090
1128
|
className: "ts-cred-input",
|
|
1091
1129
|
placeholder: p === "mimo"
|
|
1092
|
-
?
|
|
1130
|
+
? "粘贴 platform.xiaomimimo.com 的 Cookie 请求头(含 api-platform_serviceToken 与 userId)"
|
|
1093
1131
|
: "粘贴 API Key",
|
|
1094
1132
|
value: credDrafts[p] || "",
|
|
1095
1133
|
onChange: (e) => setCredDrafts((d) => ({ ...d, [p]: e.target.value })),
|
|
@@ -1110,6 +1148,38 @@ window.__ModuleLoader__.load({
|
|
|
1110
1148
|
}),
|
|
1111
1149
|
)
|
|
1112
1150
|
: null,
|
|
1151
|
+
// mimo 专用:可选的 dashboard Cookie —— 粘了把本地用量升级成官方
|
|
1152
|
+
// 「剩余用量 % + 周重置」;不粘也能正常显示本地用量统计。
|
|
1153
|
+
mimoLocalMode
|
|
1154
|
+
? React.createElement(
|
|
1155
|
+
"div", { className: "ts-cred-block" },
|
|
1156
|
+
React.createElement("div", { className: "ts-block-title" }, "MiMo 官方剩余用量(可选)"),
|
|
1157
|
+
React.createElement(
|
|
1158
|
+
"div", { className: "ts-cred-row" },
|
|
1159
|
+
React.createElement("span", { className: "ts-cred-label" }, "Cookie"),
|
|
1160
|
+
React.createElement("input", {
|
|
1161
|
+
type: "password",
|
|
1162
|
+
className: "ts-cred-input",
|
|
1163
|
+
placeholder:
|
|
1164
|
+
"MiMo 未开放 API Key 配额接口;登录 platform.xiaomimimo.com 后从开发者工具复制完整 Cookie 请求头(含 api-platform_serviceToken 与 userId),可显示官方剩余 %(会过期,失效后自动退回本地用量)",
|
|
1165
|
+
value: credDrafts.mimo || "",
|
|
1166
|
+
onChange: (e) => setCredDrafts((d) => ({ ...d, mimo: e.target.value })),
|
|
1167
|
+
onKeyDown: (e) => {
|
|
1168
|
+
if (e.key === "Enter") saveCred("mimo", credDrafts.mimo || "");
|
|
1169
|
+
},
|
|
1170
|
+
}),
|
|
1171
|
+
React.createElement(
|
|
1172
|
+
"button", {
|
|
1173
|
+
className: "ts-cred-save",
|
|
1174
|
+
disabled: !!credSaving.mimo || !(credDrafts.mimo && credDrafts.mimo.trim()),
|
|
1175
|
+
onClick: () => saveCred("mimo", credDrafts.mimo || ""),
|
|
1176
|
+
},
|
|
1177
|
+
credSaving.mimo ? "保存中…" : "保存",
|
|
1178
|
+
),
|
|
1179
|
+
credMsgs.mimo ? React.createElement("span", { className: "ts-cred-msg" }, credMsgs.mimo) : null,
|
|
1180
|
+
),
|
|
1181
|
+
)
|
|
1182
|
+
: null,
|
|
1113
1183
|
);
|
|
1114
1184
|
}
|
|
1115
1185
|
|
package/index.js
CHANGED
|
@@ -142,33 +142,61 @@ const QUOTA_PROVIDERS = {
|
|
|
142
142
|
// 智谱特殊: Authorization 不加 "Bearer " 前缀 (来自 Musage zhipu.rs 注释)
|
|
143
143
|
authStyle: "raw",
|
|
144
144
|
},
|
|
145
|
-
// Xiaomi MiMo Token Plan —
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
145
|
+
// Xiaomi MiMo Token Plan — **零凭据可用**(v1.7.0 起重写)。
|
|
146
|
+
//
|
|
147
|
+
// 实测(2026-09,scripts/probe-mimo.mjs / probe-mimo-nocookie.mjs):
|
|
148
|
+
// · token-plan-{cn,sgp,ams} 与 api. 主机**只有 OpenAI 兼容的 /v1 推理 API**,
|
|
149
|
+
// `/v1/user/balance`、`/api/v1/tokenPlan/usage`、`/api/v1/user/usage` 全部
|
|
150
|
+
// 404 —— MiMo 没有给 API Key 开任何配额接口。
|
|
151
|
+
// · key 与区域强绑定(CN key 在 sgp/ams/api 一律 401 invalid_key),所以也不
|
|
152
|
+
// 能"换主机重试"。
|
|
153
|
+
// · 唯一存在的配额接口是 platform dashboard 的 `/api/v1/user/usage`
|
|
154
|
+
// (`{percent, resetDate}`,percent = **剩余**百分比;也是 MiMo 桌面端
|
|
155
|
+
// IPC `mimo:getUserUsage` 的同一条链路),但它只认小米账号会话 Cookie。
|
|
156
|
+
// 拿 tp- key 冒充 serviceToken / X-Service-Token / ?apiKey= 全部 401。
|
|
157
|
+
// → v1.6.0 用户看到的 401 正是"把 API Key 打了只认 Cookie 的 dashboard",
|
|
158
|
+
// 与 key 是否有效无关。
|
|
159
|
+
//
|
|
160
|
+
// 因此不再使用 API Key 读配额(`apiKeyRefs` 已移除):默认走**本地用量**
|
|
161
|
+
// (本插件已有的会话日志聚合,零凭据、零过期),配置了 Cookie 时自动升级为
|
|
162
|
+
// 官方「剩余用量 % + 周重置」,Cookie 失效则静默退回本地读数。
|
|
155
163
|
mimo: {
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
},
|
|
164
|
+
// dashboard 会话 Cookie(唯一能拿到官方套餐 % 的凭据)
|
|
165
|
+
cookieRefs: ["XIAOMI_MIMO_COOKIE", "XIAOMI_TOKEN_PLAN_CN_COOKIE", "MIMO_COOKIE"],
|
|
165
166
|
parse: parseMimoResponse,
|
|
166
167
|
},
|
|
167
168
|
};
|
|
168
169
|
|
|
169
|
-
/** Xiaomi MiMo
|
|
170
|
-
|
|
171
|
-
|
|
170
|
+
/** Xiaomi MiMo 常量 —— 唯一可用的配额接口在 dashboard,且只认账号会话 Cookie。
|
|
171
|
+
* 协议来源:MiMo 桌面端 IPC `mimo:getUserUsage`(逆向记录见
|
|
172
|
+
* https://github.com/ethanweave/mimo-composer-token-status/blob/main/docs/INVESTIGATION.md)、
|
|
173
|
+
* CodexBar docs/mimo.md(MIT)、quotas crate mimo.rs(Unlicense)。*/
|
|
174
|
+
const MIMO_PLATFORM = "https://platform.xiaomimimo.com";
|
|
175
|
+
/** 官方桌面端同款:GET {accountBase}/user/usage → { percent(剩余), resetDate }。*/
|
|
176
|
+
const MIMO_USER_USAGE_URL = `${MIMO_PLATFORM}/api/v1/user/usage`;
|
|
177
|
+
/** 旧版套餐明细端点(保留兜底:部分账号只有它给数;已用比例 + 重置时间)。*/
|
|
178
|
+
const MIMO_USAGE_URL = `${MIMO_PLATFORM}/api/v1/tokenPlan/usage`;
|
|
179
|
+
const MIMO_DETAIL_URL = `${MIMO_PLATFORM}/api/v1/tokenPlan/detail`;
|
|
180
|
+
/** dashboard 必需 Cookie 名(CodexBar docs/mimo.md):缺失就给明确指引。 */
|
|
181
|
+
const MIMO_COOKIE_REQUIRED = ["api-platform_serviceToken", "userId"];
|
|
182
|
+
/** dashboard admin API 需要的额外请求头(quotas crate mimo.rs 用 accept-language: en)。*/
|
|
183
|
+
const MIMO_PLATFORM_HEADERS = { "accept-language": "en" };
|
|
184
|
+
|
|
185
|
+
/** `"a=1; b=2"` → Map{name: value}(容忍空格、容忍值里有 `=`)。 */
|
|
186
|
+
function cookieStringToMap(s) {
|
|
187
|
+
const out = new Map();
|
|
188
|
+
for (const part of String(s || "").split(";")) {
|
|
189
|
+
const i = part.indexOf("=");
|
|
190
|
+
if (i <= 0) continue;
|
|
191
|
+
out.set(part.slice(0, i).trim(), part.slice(i + 1).trim());
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Map → `"a=1; b=2"`。 */
|
|
197
|
+
function cookieMapToString(m) {
|
|
198
|
+
return [...m].map(([k, v]) => k + "=" + v).join("; ");
|
|
199
|
+
}
|
|
172
200
|
|
|
173
201
|
function quotaBackoffMs(streak) {
|
|
174
202
|
if (streak <= 0) return 0;
|
|
@@ -466,7 +494,7 @@ function parseMimoResponse(body, detailBody) {
|
|
|
466
494
|
ok: false,
|
|
467
495
|
provider: "mimo",
|
|
468
496
|
kind: "auth_failed",
|
|
469
|
-
message: "MiMo 凭据已失效(Cookie
|
|
497
|
+
message: "MiMo 凭据已失效(API Key 或 Cookie 过期),请到 platform.xiaomimimo.com 续费或重新生成",
|
|
470
498
|
};
|
|
471
499
|
}
|
|
472
500
|
return {
|
|
@@ -549,6 +577,86 @@ function parseMimoResponse(body, detailBody) {
|
|
|
549
577
|
};
|
|
550
578
|
}
|
|
551
579
|
|
|
580
|
+
// ----- xiaomi mimo /api/v1/user/usage parser (v1.7.0,官方桌面端同款链路) -----
|
|
581
|
+
// { "percent": 78, "resetDate": "2026-09-16" }
|
|
582
|
+
// **percent 是「剩余」百分比**(账户菜单「剩余用量 78%」),与上面
|
|
583
|
+
// tokenPlan/usage 的 items[].percent(**已用**比例)语义相反,别混用。
|
|
584
|
+
// wire 里 fiveHrPct/weeklyPct 一律是「已用 %」,所以取 100 - percent。
|
|
585
|
+
|
|
586
|
+
function parseMimoUserUsage(body) {
|
|
587
|
+
let json;
|
|
588
|
+
try {
|
|
589
|
+
json = typeof body === "string" ? JSON.parse(body) : body;
|
|
590
|
+
} catch {
|
|
591
|
+
return { ok: false, provider: "mimo", kind: "parse", message: "MiMo 响应 JSON 解析失败" };
|
|
592
|
+
}
|
|
593
|
+
if (!json || typeof json !== "object") {
|
|
594
|
+
return { ok: false, provider: "mimo", kind: "parse", message: "MiMo 响应不是对象" };
|
|
595
|
+
}
|
|
596
|
+
if (typeof json.code === "number" && json.code !== 0) {
|
|
597
|
+
if (json.code === 401 || (json.code >= 40100 && json.code < 40200)) {
|
|
598
|
+
return {
|
|
599
|
+
ok: false,
|
|
600
|
+
provider: "mimo",
|
|
601
|
+
kind: "auth_failed",
|
|
602
|
+
message: "MiMo Cookie 已失效,请重新登录 platform.xiaomimimo.com 并复制 Cookie 请求头",
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
return {
|
|
606
|
+
ok: false,
|
|
607
|
+
provider: "mimo",
|
|
608
|
+
kind: "server_error",
|
|
609
|
+
message: "MiMo 业务错误 code=" + json.code + " · " + (json.message || ""),
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
const data = json.data && typeof json.data === "object" ? json.data : json;
|
|
613
|
+
|
|
614
|
+
const raw = Number(data.percent);
|
|
615
|
+
if (!isFinite(raw)) {
|
|
616
|
+
return {
|
|
617
|
+
ok: false,
|
|
618
|
+
provider: "mimo",
|
|
619
|
+
kind: "parse",
|
|
620
|
+
message: "MiMo 响应缺少 percent 字段(keys: " + Object.keys(data).join(",") + ")",
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
const remaining = Math.max(0, Math.min(100, Math.round(raw)));
|
|
624
|
+
const used = 100 - remaining;
|
|
625
|
+
|
|
626
|
+
// resetDate 形如 "2026-09-16"(本地日),按当天 23:59:59 解释为到期时刻。
|
|
627
|
+
let resetsAt = null;
|
|
628
|
+
if (typeof data.resetDate === "string" && data.resetDate) {
|
|
629
|
+
const iso = data.resetDate.length <= 10 ? data.resetDate + "T23:59:59" : data.resetDate;
|
|
630
|
+
const t = Date.parse(iso);
|
|
631
|
+
if (!isNaN(t)) resetsAt = t;
|
|
632
|
+
}
|
|
633
|
+
const resetsIn = resetsAt ? formatResetsIn(resetsAt) : null;
|
|
634
|
+
|
|
635
|
+
return {
|
|
636
|
+
ok: true,
|
|
637
|
+
provider: "mimo",
|
|
638
|
+
display: {
|
|
639
|
+
// 剩余用量是单一窗口(账户菜单标「1 周」),映射到 7d 窗口位
|
|
640
|
+
weeklyPct: used,
|
|
641
|
+
weeklyResetsIn: resetsIn,
|
|
642
|
+
fiveHrPct: null,
|
|
643
|
+
fiveHrResetsIn: null,
|
|
644
|
+
balanceText: "剩余 " + remaining + "%",
|
|
645
|
+
balanceUsd: null,
|
|
646
|
+
currency: null,
|
|
647
|
+
},
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** 12345678 → "12.3M"(读数行/卡片用的紧凑 token 计数)。 */
|
|
652
|
+
function formatTokenCount(n) {
|
|
653
|
+
if (!isFinite(n) || n <= 0) return "0";
|
|
654
|
+
if (n >= 1e9) return (n / 1e9).toFixed(n >= 1e10 ? 0 : 1) + "B";
|
|
655
|
+
if (n >= 1e6) return (n / 1e6).toFixed(n >= 1e7 ? 0 : 1) + "M";
|
|
656
|
+
if (n >= 1e3) return (n / 1e3).toFixed(n >= 1e4 ? 0 : 1) + "K";
|
|
657
|
+
return String(Math.round(n));
|
|
658
|
+
}
|
|
659
|
+
|
|
552
660
|
function formatResetsIn(resetsAtMs) {
|
|
553
661
|
if (typeof resetsAtMs !== "number" || !resetsAtMs) return "";
|
|
554
662
|
const ms = resetsAtMs - Date.now();
|
|
@@ -1035,30 +1143,63 @@ export class TokenStatsService extends TypertRemoteService {
|
|
|
1035
1143
|
* each provider's shipped default `apiKeyEnv`; without the credentials
|
|
1036
1144
|
* service (headless deployment) the process environment is consulted.
|
|
1037
1145
|
*/
|
|
1146
|
+
/**
|
|
1147
|
+
* Resolve credentials for one provider through the DSH credentials seam.
|
|
1148
|
+
* Ref candidates follow the settings page's `<ROUTE>_API_KEY` derivation and
|
|
1149
|
+
* each provider's shipped default `apiKeyEnv`; without the credentials
|
|
1150
|
+
* service (headless deployment) the process environment is consulted.
|
|
1151
|
+
*
|
|
1152
|
+
* v1.6.0 起,mimo 同时持有 API Key (Bearer) + Cookie (dashboard session) 两类
|
|
1153
|
+
* 凭据;其他 provider 只用 API Key。返回值总是
|
|
1154
|
+
* { apiKey, apiKeyRef, cookie, cookieRef },
|
|
1155
|
+
* 未配置那一路为 null。`_quotaActiveRef[provider + ":api" | ":cookie"]` 按
|
|
1156
|
+
* provider+kind 缓存上次命中的 ref(保持粘性,但不互锁)。
|
|
1157
|
+
*/
|
|
1038
1158
|
async _quotaApiKey(provider) {
|
|
1039
1159
|
const cfg = QUOTA_PROVIDERS[provider];
|
|
1040
|
-
if (!cfg) return {
|
|
1160
|
+
if (!cfg) return { apiKey: null, apiKeyRef: null, cookie: null, cookieRef: null };
|
|
1041
1161
|
const credentials = this.ctx.get("credentials");
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1162
|
+
// 兼容新旧两种配置形态:新的是 {apiKeyRefs, cookieRefs},旧的是单数组 refs。
|
|
1163
|
+
const apiKeyRefList = cfg.apiKeyRefs || cfg.refs || [];
|
|
1164
|
+
const cookieRefList = cfg.cookieRefs || [];
|
|
1165
|
+
|
|
1166
|
+
const resolveOne = async (ref) => {
|
|
1046
1167
|
try {
|
|
1047
1168
|
if (credentials && typeof credentials.resolve === "function") {
|
|
1048
1169
|
const hit = await credentials.resolve(ref);
|
|
1049
|
-
if (hit && hit.value)
|
|
1050
|
-
this._quotaActiveRef[provider] = ref;
|
|
1051
|
-
return { ref, key: hit.value };
|
|
1052
|
-
}
|
|
1170
|
+
if (hit && hit.value) return hit.value;
|
|
1053
1171
|
} else if (typeof process !== "undefined" && process.env && process.env[ref]) {
|
|
1054
|
-
|
|
1055
|
-
return { ref, key: process.env[ref] };
|
|
1172
|
+
return process.env[ref];
|
|
1056
1173
|
}
|
|
1057
1174
|
} catch (e) {
|
|
1058
1175
|
/* try the next ref */
|
|
1059
1176
|
}
|
|
1060
|
-
|
|
1061
|
-
|
|
1177
|
+
return null;
|
|
1178
|
+
};
|
|
1179
|
+
|
|
1180
|
+
const resolveList = async (refList, activeKey) => {
|
|
1181
|
+
const active = this._quotaActiveRef[activeKey];
|
|
1182
|
+
const candidates = active
|
|
1183
|
+
? [active, ...refList.filter((r) => r !== active)]
|
|
1184
|
+
: refList;
|
|
1185
|
+
for (const ref of candidates) {
|
|
1186
|
+
const v = await resolveOne(ref);
|
|
1187
|
+
if (v) {
|
|
1188
|
+
this._quotaActiveRef[activeKey] = ref;
|
|
1189
|
+
return { ref, value: v };
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
return { ref: null, value: null };
|
|
1193
|
+
};
|
|
1194
|
+
|
|
1195
|
+
const api = await resolveList(apiKeyRefList, provider + ":api");
|
|
1196
|
+
const ck = await resolveList(cookieRefList, provider + ":cookie");
|
|
1197
|
+
return {
|
|
1198
|
+
apiKey: api.value,
|
|
1199
|
+
apiKeyRef: api.ref,
|
|
1200
|
+
cookie: ck.value,
|
|
1201
|
+
cookieRef: ck.ref,
|
|
1202
|
+
};
|
|
1062
1203
|
}
|
|
1063
1204
|
|
|
1064
1205
|
async _resolveCurl() {
|
|
@@ -1072,7 +1213,7 @@ export class TokenStatsService extends TypertRemoteService {
|
|
|
1072
1213
|
}
|
|
1073
1214
|
|
|
1074
1215
|
/** curl fallback for hosts without a global fetch (same shape as musage). */
|
|
1075
|
-
async _curlGet(url, key, authStyle) {
|
|
1216
|
+
async _curlGet(url, key, authStyle, extraHeaders) {
|
|
1076
1217
|
const subprocess = this.ctx.get("subprocess");
|
|
1077
1218
|
if (!subprocess || typeof subprocess.spawn !== "function") {
|
|
1078
1219
|
return { ok: false, kind: "network", message: "subprocess service 不可用, 且宿主无全局 fetch" };
|
|
@@ -1084,15 +1225,19 @@ export class TokenStatsService extends TypertRemoteService {
|
|
|
1084
1225
|
: authStyle === "cookie"
|
|
1085
1226
|
? "Cookie: " + key
|
|
1086
1227
|
: "Authorization: Bearer " + key;
|
|
1228
|
+
const argv = [
|
|
1229
|
+
c, "-sS",
|
|
1230
|
+
"--max-time", String(Math.floor(QUOTA_REQUEST_TIMEOUT_MS / 1000)),
|
|
1231
|
+
"-w", "\n%{http_code}",
|
|
1232
|
+
"-H", authHeader,
|
|
1233
|
+
"-H", "Accept: application/json",
|
|
1234
|
+
];
|
|
1235
|
+
for (const [name, value] of Object.entries(extraHeaders || {})) {
|
|
1236
|
+
argv.push("-H", name + ": " + value);
|
|
1237
|
+
}
|
|
1238
|
+
argv.push(url);
|
|
1087
1239
|
const handle = subprocess.spawn({
|
|
1088
|
-
argv
|
|
1089
|
-
c, "-sS",
|
|
1090
|
-
"--max-time", String(Math.floor(QUOTA_REQUEST_TIMEOUT_MS / 1000)),
|
|
1091
|
-
"-w", "\n%{http_code}",
|
|
1092
|
-
"-H", authHeader,
|
|
1093
|
-
"-H", "Accept: application/json",
|
|
1094
|
-
url,
|
|
1095
|
-
],
|
|
1240
|
+
argv,
|
|
1096
1241
|
cwd: "/",
|
|
1097
1242
|
stdio: {
|
|
1098
1243
|
stdin: "ignore",
|
|
@@ -1126,11 +1271,13 @@ export class TokenStatsService extends TypertRemoteService {
|
|
|
1126
1271
|
return { ok: true, body };
|
|
1127
1272
|
}
|
|
1128
1273
|
|
|
1129
|
-
/** GET one provider endpoint with its API key; fetch first, curl fallback.
|
|
1130
|
-
|
|
1274
|
+
/** GET one provider endpoint with its API key; fetch first, curl fallback.
|
|
1275
|
+
* `extraHeaders` carries provider-specific headers (e.g. MiMo dashboard 的
|
|
1276
|
+
* accept-language: en),fetch 与 curl 两条路径都要带上。 */
|
|
1277
|
+
async _httpGet(url, key, authStyle, extraHeaders) {
|
|
1131
1278
|
if (typeof fetch === "function") {
|
|
1132
1279
|
try {
|
|
1133
|
-
const headers = { accept: "application/json" };
|
|
1280
|
+
const headers = { accept: "application/json", ...(extraHeaders || {}) };
|
|
1134
1281
|
// authStyle: undefined → Bearer; "raw" → 原样 Authorization(智谱);
|
|
1135
1282
|
// "cookie" → 整串 Cookie 头(小米 MiMo dashboard admin API)。
|
|
1136
1283
|
if (authStyle === "cookie") headers.cookie = key;
|
|
@@ -1141,20 +1288,25 @@ export class TokenStatsService extends TypertRemoteService {
|
|
|
1141
1288
|
signal: AbortSignal.timeout(QUOTA_REQUEST_TIMEOUT_MS),
|
|
1142
1289
|
});
|
|
1143
1290
|
const body = await res.text();
|
|
1291
|
+
// 带回 Set-Cookie:mimo 的 cookie jar 靠它接住服务端续发的会话
|
|
1292
|
+
// (浏览器自动存,我们此前每次重发死 cookie,等于放弃了续期)。
|
|
1293
|
+
const setCookie = typeof res.headers.getSetCookie === "function" ? res.headers.getSetCookie() : [];
|
|
1144
1294
|
if (res.status !== 200) {
|
|
1145
1295
|
return {
|
|
1146
1296
|
ok: false,
|
|
1147
1297
|
kind: classifyQuotaHttpStatus(res.status),
|
|
1148
1298
|
httpStatus: res.status,
|
|
1299
|
+
setCookie,
|
|
1149
1300
|
message: "HTTP " + res.status + " · " + body.slice(0, 200),
|
|
1150
1301
|
};
|
|
1151
1302
|
}
|
|
1152
|
-
return { ok: true, body };
|
|
1303
|
+
return { ok: true, body, setCookie };
|
|
1153
1304
|
} catch (e) {
|
|
1154
1305
|
return { ok: false, kind: "network", message: "fetch 失败: " + String((e && e.message) || e) };
|
|
1155
1306
|
}
|
|
1156
1307
|
}
|
|
1157
|
-
|
|
1308
|
+
// curl 回退路径不带 Set-Cookie(无法维持会话续期,仅影响 mimo 的 cookie 升级)。
|
|
1309
|
+
return this._curlGet(url, key, authStyle, extraHeaders);
|
|
1158
1310
|
}
|
|
1159
1311
|
|
|
1160
1312
|
async _fetchProviderQuota(provider) {
|
|
@@ -1162,8 +1314,9 @@ export class TokenStatsService extends TypertRemoteService {
|
|
|
1162
1314
|
if (!cfg) {
|
|
1163
1315
|
return { ok: false, provider, kind: "other", message: "未知 provider: " + provider };
|
|
1164
1316
|
}
|
|
1165
|
-
const {
|
|
1166
|
-
if (
|
|
1317
|
+
const { apiKey, apiKeyRef, cookie, cookieRef } = await this._quotaApiKey(provider);
|
|
1318
|
+
if (provider === "mimo") return this._fetchMimoQuota(apiKey, apiKeyRef, cookie, cookieRef);
|
|
1319
|
+
if (!apiKey) {
|
|
1167
1320
|
return {
|
|
1168
1321
|
ok: false,
|
|
1169
1322
|
provider,
|
|
@@ -1171,29 +1324,184 @@ export class TokenStatsService extends TypertRemoteService {
|
|
|
1171
1324
|
message: "未配置 " + provider + " API Key (在 DSH 模型设置里配置对应 provider)",
|
|
1172
1325
|
};
|
|
1173
1326
|
}
|
|
1174
|
-
|
|
1175
|
-
const url = cfg.urls[
|
|
1176
|
-
const raw = await this._httpGet(url,
|
|
1327
|
+
const refList = cfg.apiKeyRefs || cfg.refs || [];
|
|
1328
|
+
const url = cfg.urls[apiKeyRef] || cfg.urls[refList[0]];
|
|
1329
|
+
const raw = await this._httpGet(url, apiKey, cfg.authStyle);
|
|
1177
1330
|
if (!raw.ok) return { ...raw, provider };
|
|
1178
1331
|
return cfg.parse(raw.body);
|
|
1179
1332
|
}
|
|
1180
1333
|
|
|
1181
1334
|
/**
|
|
1182
|
-
*
|
|
1183
|
-
*
|
|
1184
|
-
*
|
|
1185
|
-
|
|
1335
|
+
* MiMo 本地用量(零凭据、零过期):从会话日志聚合里筛出 MiMo 模型,
|
|
1336
|
+
* 算「今日 / 近 7 天(含今天)」消耗 token。provider 判定与 client 的
|
|
1337
|
+
* PROVIDER_ALIASES 同一套包含回退(route id 用户自由命名)。
|
|
1338
|
+
*/
|
|
1339
|
+
_mimoLocalUsage() {
|
|
1340
|
+
const isMimo = (meta, key) => {
|
|
1341
|
+
const p = String((meta && meta.provider) || "").toLowerCase();
|
|
1342
|
+
if (p) return p.includes("mimo") || p.includes("xiaomi");
|
|
1343
|
+
const n = String((meta && meta.name) || key || "").toLowerCase();
|
|
1344
|
+
return n.includes("mimo");
|
|
1345
|
+
};
|
|
1346
|
+
const now = Date.now();
|
|
1347
|
+
const todayKey = this._dayKeyOf(now);
|
|
1348
|
+
const weekCut = this._dayKeyOf(now - 6 * DAY_MS);
|
|
1349
|
+
let today = 0;
|
|
1350
|
+
let week = 0;
|
|
1351
|
+
for (const [dk, day] of this._byDay) {
|
|
1352
|
+
if (dk > todayKey) continue;
|
|
1353
|
+
let dayTotal = 0;
|
|
1354
|
+
for (const [key, rec] of day) {
|
|
1355
|
+
const meta = this._modelMeta.get(key);
|
|
1356
|
+
if (!isMimo(meta, key)) continue;
|
|
1357
|
+
dayTotal += rec.total || 0;
|
|
1358
|
+
}
|
|
1359
|
+
if (dk === todayKey) today = dayTotal;
|
|
1360
|
+
if (dk >= weekCut) week += dayTotal;
|
|
1361
|
+
}
|
|
1362
|
+
return { today, week };
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
/**
|
|
1366
|
+
* 最小 cookie jar:把响应 `Set-Cookie` 里属于 MiMo 的条目吸收进当前 cookie 串,
|
|
1367
|
+
* 并写回 DSH 凭据库,使服务端续发的会话能跨轮询、跨重启生效。
|
|
1368
|
+
*
|
|
1369
|
+
* 只吸收两类名字:jar 里已存在的(续期/轮换同一条 cookie),以及 MiMo 已知的那几个
|
|
1370
|
+
* (防止服务端塞进无关 cookie 污染用户粘贴的内容)。没有变化则不写盘。
|
|
1371
|
+
*
|
|
1372
|
+
* @returns {Promise<string|null>} 合并后的新 cookie 串;无变化返回 null。
|
|
1186
1373
|
*/
|
|
1187
|
-
async
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1374
|
+
async _absorbMimoCookie(cookieRef, current, setCookieHeaders) {
|
|
1375
|
+
if (!Array.isArray(setCookieHeaders) || setCookieHeaders.length === 0) return null;
|
|
1376
|
+
const jar = cookieStringToMap(current);
|
|
1377
|
+
const known = new Set([...MIMO_COOKIE_REQUIRED, "api-platform_ph", "api-platform_slh"]);
|
|
1378
|
+
let changed = false;
|
|
1379
|
+
for (const raw of setCookieHeaders) {
|
|
1380
|
+
// 只取第一段 `name=value`,后面的 Path/HttpOnly/Expires 是属性不是 cookie
|
|
1381
|
+
const pair = String(raw).split(";")[0];
|
|
1382
|
+
const i = pair.indexOf("=");
|
|
1383
|
+
if (i <= 0) continue;
|
|
1384
|
+
const name = pair.slice(0, i).trim();
|
|
1385
|
+
if (!name) continue;
|
|
1386
|
+
if (!jar.has(name) && !known.has(name)) continue;
|
|
1387
|
+
const value = pair.slice(i + 1).trim();
|
|
1388
|
+
// 浏览器语义:空值或 Max-Age=0 表示**删除**该 cookie,不是把它置空
|
|
1389
|
+
// (置空会发出 `api-platform_serviceToken=`,等于带着坏 cookie 去请求)。
|
|
1390
|
+
if (value === "" || /;\s*max-age\s*=\s*0/i.test(raw)) {
|
|
1391
|
+
if (jar.delete(name)) changed = true;
|
|
1392
|
+
continue;
|
|
1393
|
+
}
|
|
1394
|
+
if (jar.get(name) === value) continue;
|
|
1395
|
+
jar.set(name, value);
|
|
1396
|
+
changed = true;
|
|
1193
1397
|
}
|
|
1194
|
-
if (!
|
|
1195
|
-
const
|
|
1196
|
-
|
|
1398
|
+
if (!changed) return null;
|
|
1399
|
+
const next = cookieMapToString(jar);
|
|
1400
|
+
const credentials = this.ctx.get("credentials");
|
|
1401
|
+
// 服务端把会话 cookie 全清掉了 = 会话没了:删掉凭据比留个空串更贴近浏览器,
|
|
1402
|
+
// 下一轮会自然退回本地用量,而不是带着空 cookie 反复 401。
|
|
1403
|
+
if (!next) {
|
|
1404
|
+
try {
|
|
1405
|
+
if (cookieRef && credentials && typeof credentials.unset === "function") {
|
|
1406
|
+
await credentials.unset(cookieRef);
|
|
1407
|
+
}
|
|
1408
|
+
} catch {
|
|
1409
|
+
/* 写回失败不影响本次读数 */
|
|
1410
|
+
}
|
|
1411
|
+
return null;
|
|
1412
|
+
}
|
|
1413
|
+
// 写回官方凭据库(宿主侧 credentials.set);失败只影响跨重启续期,本进程内
|
|
1414
|
+
// 仍用 next 继续请求,所以吞掉异常。
|
|
1415
|
+
try {
|
|
1416
|
+
if (cookieRef && credentials && typeof credentials.set === "function") {
|
|
1417
|
+
await credentials.set(cookieRef, next);
|
|
1418
|
+
}
|
|
1419
|
+
} catch {
|
|
1420
|
+
/* 写回失败不影响本次读数 */
|
|
1421
|
+
}
|
|
1422
|
+
return next;
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
/** MiMo 读数(v1.7.0):官方 Cookie 优先,失败静默退回本地用量。 */
|
|
1426
|
+
async _fetchMimoQuota(_apiKey, _apiKeyRef, cookie, cookieRef) {
|
|
1427
|
+
if (cookie) {
|
|
1428
|
+
const official = await this._fetchMimoOfficial(cookie, cookieRef);
|
|
1429
|
+
if (official.ok) return official;
|
|
1430
|
+
// Cookie 失效(auth_failed)→ 不打扰用户,静默退回本地用量。
|
|
1431
|
+
// 其余(套餐过期 / 解析失败 / 限流 / 5xx)都是真实状态,如实上报。
|
|
1432
|
+
if (official.kind !== "auth_failed") return official;
|
|
1433
|
+
}
|
|
1434
|
+
const local = this._mimoLocalUsage();
|
|
1435
|
+
return {
|
|
1436
|
+
ok: true,
|
|
1437
|
+
provider: "mimo",
|
|
1438
|
+
display: {
|
|
1439
|
+
fiveHrPct: null,
|
|
1440
|
+
weeklyPct: null,
|
|
1441
|
+
fiveHrResetsIn: null,
|
|
1442
|
+
weeklyResetsIn: null,
|
|
1443
|
+
balanceText: "今日 " + formatTokenCount(local.today) + " · 7d " + formatTokenCount(local.week),
|
|
1444
|
+
balanceUsd: null,
|
|
1445
|
+
currency: null,
|
|
1446
|
+
},
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
/**
|
|
1451
|
+
* 官方 dashboard 配额(仅 Cookie):主用桌面端同款 `/api/v1/user/usage`
|
|
1452
|
+
* (percent=剩余 + resetDate),失败再退到旧版 `/api/v1/tokenPlan/usage`
|
|
1453
|
+
* + `/detail`(已用比例)。两条都不通 = Cookie 失效。
|
|
1454
|
+
*/
|
|
1455
|
+
async _fetchMimoOfficial(cookie, cookieRef) {
|
|
1456
|
+
const missing = MIMO_COOKIE_REQUIRED.filter((n) => cookie.indexOf(n) < 0);
|
|
1457
|
+
if (missing.length === MIMO_COOKIE_REQUIRED.length) {
|
|
1458
|
+
return {
|
|
1459
|
+
ok: false,
|
|
1460
|
+
provider: "mimo",
|
|
1461
|
+
kind: "auth_failed",
|
|
1462
|
+
message:
|
|
1463
|
+
"MiMo Cookie 缺少 " + MIMO_COOKIE_REQUIRED.join(" / ") +
|
|
1464
|
+
":登录 platform.xiaomimimo.com 后从开发者工具复制完整 Cookie 请求头",
|
|
1465
|
+
};
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
// 每个响应后立刻吸收 Set-Cookie:服务端续发的会话要马上用于后续请求
|
|
1469
|
+
// (browser jar 的等价行为),否则同一轮里还在用旧 token。
|
|
1470
|
+
const jar = async (res) => {
|
|
1471
|
+
const next = await this._absorbMimoCookie(cookieRef, cookie, res && res.setCookie);
|
|
1472
|
+
if (next) cookie = next;
|
|
1473
|
+
return res;
|
|
1474
|
+
};
|
|
1475
|
+
|
|
1476
|
+
const user = await jar(await this._httpGet(MIMO_USER_USAGE_URL, cookie, "cookie", MIMO_PLATFORM_HEADERS));
|
|
1477
|
+
if (user.ok) {
|
|
1478
|
+
const parsed = parseMimoUserUsage(user.body);
|
|
1479
|
+
if (parsed.ok) return parsed;
|
|
1480
|
+
// 业务码 401 = 会话已死,不用再试旧端点。
|
|
1481
|
+
if (parsed.kind === "auth_failed") return parsed;
|
|
1482
|
+
} else if (user.httpStatus === 401 || user.httpStatus === 403) {
|
|
1483
|
+
return { ...user, provider: "mimo" };
|
|
1484
|
+
} else if (user.kind === "rate_limited" || (user.httpStatus >= 500 && user.httpStatus < 600)) {
|
|
1485
|
+
return { ...user, provider: "mimo" };
|
|
1486
|
+
}
|
|
1487
|
+
// 404(该账号没有这个端点)或其它 → 退到旧版 tokenPlan/usage + detail。
|
|
1488
|
+
// detail 必须先拉(expired 标记 + 重置时间要在 usage 解析前拿到)。
|
|
1489
|
+
const detail = await jar(await this._httpGet(MIMO_DETAIL_URL, cookie, "cookie", MIMO_PLATFORM_HEADERS));
|
|
1490
|
+
const usage = await jar(await this._httpGet(MIMO_USAGE_URL, cookie, "cookie", MIMO_PLATFORM_HEADERS));
|
|
1491
|
+
if (usage.ok) {
|
|
1492
|
+
const parsed = parseMimoResponse(usage.body, detail.ok ? detail.body : null);
|
|
1493
|
+
// plan_expired / schema_unknown / parse 都是"拿到了但内容不对",如实上报;
|
|
1494
|
+
// 只有 auth_failed 才继续降级。
|
|
1495
|
+
if (parsed.ok || parsed.kind !== "auth_failed") return parsed;
|
|
1496
|
+
}
|
|
1497
|
+
return {
|
|
1498
|
+
ok: false,
|
|
1499
|
+
provider: "mimo",
|
|
1500
|
+
kind: "auth_failed",
|
|
1501
|
+
message:
|
|
1502
|
+
"MiMo Cookie 已失效(" + MIMO_USER_USAGE_URL.replace("https://", "") +
|
|
1503
|
+
" 返回 " + (user.httpStatus || "异常") + "):重新登录 platform.xiaomimimo.com 后复制 Cookie",
|
|
1504
|
+
};
|
|
1197
1505
|
}
|
|
1198
1506
|
|
|
1199
1507
|
/**
|
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 (7/30-day per-model charts + yearly heatmap) plus an inline coding-plan balance/quota readout in the composer (minimax/deepseek/kimi/openrouter/zhipu).",
|
|
3
|
+
"version": "1.7.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/mimo; mimo falls back to local usage since MiMo exposes no API-key quota API).",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/MoonlitDropOfBlood/dsh-token-stats.git"
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
],
|
|
28
28
|
"license": "MIT",
|
|
29
29
|
"engines": {
|
|
30
|
-
"dsh": ">=0.1.4-rc.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.
|
|
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,11 @@
|
|
|
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
|
+
"probe:mimo": "node scripts/probe-mimo.mjs",
|
|
56
|
+
"probe:mimo:nocookie": "node scripts/probe-mimo-nocookie.mjs",
|
|
57
|
+
"probe:mimo:session": "node scripts/probe-mimo-session.mjs",
|
|
58
|
+
"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
59
|
}
|
|
55
60
|
}
|