dsh-subusage 0.10.1 → 0.10.3

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 CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  > **English.** dsh-subusage shows subscription quota and balance for the AI providers you already use, as a pill next to the model selector in DeepSeek Harness. Provider switches and credentials live in one settings page, and other plugins or agents can read the same snapshot through a read-only quota API.
8
8
 
9
- [![npm version](https://img.shields.io/badge/npm-0.10.1-blue)](https://www.npmjs.com/package/dsh-subusage)
9
+ [![npm version](https://img.shields.io/badge/npm-0.10.3-blue)](https://www.npmjs.com/package/dsh-subusage)
10
10
  [![license](https://img.shields.io/badge/license-MIT-green)](#license--security--许可与安全)
11
11
  [![DSH](https://img.shields.io/badge/DSH-%3E%3D0.2.0--rc.1%20%3C0.3.0--0-informational)](#compatibility--兼容性)
12
12
 
@@ -24,7 +24,7 @@
24
24
 
25
25
  ## 支持的数据 / Supported data
26
26
 
27
- 共 **30 条 provider id**:新安装**默认开启 11 条、默认关闭 19 条**(数字与清单取自 [`lib/index.js`](lib/index.js) 顶部的 `PROVIDERS`,展示顺序与短名取自 [`lib/client.js`](lib/client.js) 顶部的 `PROVIDER_META` / `PROVIDER_ORDER`)。
27
+ 共 **30 条 provider id**:新安装**默认开启 10 条、默认关闭 20 条**(数字与清单取自 [`lib/index.js`](lib/index.js) 顶部的 `PROVIDERS`,展示顺序与短名取自 [`lib/client.js`](lib/client.js) 顶部的 `PROVIDER_META` / `PROVIDER_ORDER`)。
28
28
 
29
29
  | 提供商 | provider id | 额度类型 | 鉴权 | 默认 |
30
30
  |---|---|---|---|---|
@@ -109,7 +109,7 @@ dsh plugin --profile <profile> remove dsh-subusage
109
109
 
110
110
  1. 安装并启用 bundle,按部署方式重载或重启。
111
111
  2. 打开 **设置 → 订阅用量 → 提供商管理**,开启需要的提供商(默认关闭的 19 条需手动开启)。
112
- 3. 为它配置凭据:在卡片里展开「连接与凭据」填 Key / AK-SK,或把 Key 放进凭据服务 / 启动环境;MiMo 用「登录并自动导入」。
112
+ 3. 为它配置凭据:在卡片里展开「连接与凭据」填 Key / AK-SK,或把 Key 放进凭据服务 / 启动环境;MiMo 用「登录并自动导入」。**不知道去哪拿、或填了不生效** → 见[凭据获取指引](docs/credentials.md)。
113
113
  4. 回到会话,选中该提供商的模型:输入区左侧出现余量药丸(绿色成功 / 红色失败),点开看窗口明细。
114
114
  5. 排障顺序:点「立即刷新」→ 看设置页顶部 `X/Y 家数据获取成功`(可点击,跳到第一家没读成功的)。
115
115
 
@@ -123,10 +123,26 @@ $env:DEEPSEEK_API_KEY = "<你的 DeepSeek API Key>" # 启动环境;改完需
123
123
 
124
124
  重启后在会话中选中 DeepSeek 模型,药丸显示账户余额;也可以直接让 Agent 调用 `subusage_quota(providers: ["deepseek"])` 验证(无需界面)。
125
125
 
126
+ ## 前置:这几家需要提供方插件
127
+
128
+ 绝大多数提供商只要一把 API Key 就能用。但下面几家要先装**提供方插件**——它们负责注册模型路由、完成登录并持有凭据;本插件只**读取**同一份凭据,不代为登录、也不保存它们。
129
+
130
+ | 提供商 | 前置提供方插件 | 凭据从哪来 |
131
+ | --- | --- | --- |
132
+ | Command Code | [`@mars-sea/dsh-commandcode-provider`](https://www.npmjs.com/package/@mars-sea/dsh-commandcode-provider) | 在该插件的设置页登录,或运行 `cmd login`(兜底读 `~/.commandcode/auth.json`) |
133
+ | SuperGrok | [`dsh-grok-kit`](https://github.com/KouzakiUmi/dsh-grok-kit) | 运行 `grok login`,登录文件 `~/.grok/auth.json` |
134
+ | Codex(ChatGPT 订阅) | Codex 提供方插件(如 `dsh-codex-connect`) | 运行 `codex login`,登录文件 `$CODEX_HOME/auth.json` 或 `~/.codex/auth.json` |
135
+
136
+ > **Codex 默认关闭**:它的提供方插件本身就带一个用量药丸,两个并排只是重复信息。想要本插件这一份(比如想把 Codex 和别家放在一起看)可以在**提供商管理**里手动开启。
137
+ >
138
+ > 这几家的**凭据不在本插件的设置页里**:请用上表的登录方式。缺登录时本插件的卡片会直接给出对应的登录命令。
139
+
126
140
  ## Configuration / 配置
127
141
 
128
142
  - **设置页路径**:设置 → **订阅用量** → 提供商管理。「没有检测到API的默认隐藏」默认开启;首次没有可见条目时管理区自动展开。关闭的提供商仍可配置凭据,只是不会发起用量请求。
129
- - **默认开关策略**:新安装默认开启 11 条、默认关闭 19 条;开关即时保存,升级保留已保存的开关,不重算默认值。默认隐藏只影响显示,不会自动开启被关闭的提供商。
143
+ - **凭据从哪来**:每一类凭据的**官网入口、环境变量名与界面填入位置**都写在[凭据获取指引](docs/credentials.md)——包括火山的 IAM AK/SK(子用户还要挂 `ArkReadOnlyAccess` 且不限制到项目)、MiMo 的平台会话 Cookie、以及各家 API Key 的对照表。**注意推理 Key 与额度凭据不通用。**
144
+ - **默认开关策略**:新安装默认开启 10 条、默认关闭 20 条(**Codex 默认关闭**——它的提供方插件自带用量药丸);开关即时保存,升级保留已保存的开关,不重算默认值。默认隐藏只影响显示,不会自动开启被关闭的提供商。
145
+ - **火山方舟是一组**:7 条路由共用同一组 IAM AK/SK,所以设置页只呈现**一张卡片、一个开关**(组内最后选的那条作为代表),卡片里写明本机实际装了哪几条。这些 provider id 并没有合并——药丸仍按 id 匹配路由,本机没装的自然不显示。
130
146
  - **凭据来源优先级**(`inherit` 模式):**凭据服务 → 启动环境 → 旧手动配置兜底**;切到自定义(`manual`)模式时只使用保存的手动 Key。界面会区分这三种来源。
131
147
  - **不接受本地 Key 的提供商**:`commandcode`(凭据链属提供方插件)、`xai-oauth`、`openai-codex`(只读各自 CLI 的登录文件),以及 MiMo(Cookie 会话)。
132
148
  - **保存与验证分离**:凭据先确认持久化,再独立做在线验证;检测关闭的提供商只保存凭据、不发起验证,验证失败不代表保存失败。
@@ -256,6 +272,8 @@ const view = await service.quota({ providerIds: ["deepseek"], force: false });
256
272
  |---|---|
257
273
  | 药丸不显示 | 依次确认:当前模型的 provider id 是否与上表一致(自定义 id 不会自动映射)→ 该家检测开关是否开启 → 是否被「没有检测到API的默认隐藏」收起(可在设置页临时关掉它)→ 运行中的 Host 与 Client 是否都已重载 |
258
274
  | 设置页没有提供商标签 | 展开提供商管理,确认开关和凭据;无凭据的条目默认隐藏,可临时关闭自动隐藏查看指引 |
275
+ | 提示「无凭据 / 未检测到 Key」 | 先确认填的是**额度凭据**而不是推理 Key(两者不通用,见[凭据获取指引](docs/credentials.md)),再按该家小节核对来源、环境变量与权限 |
276
+ | 提示「已保存」但仍是「无凭据」 | 凭据编辑要点「**保存设置**」才提交(提供商开关才是立即保存);确认后若仍为空,把界面文案反馈上来 |
259
277
  | 某家读取失败,其他家正常 | 单家失败只影响自己的条目。按文案分类处理:认证类(401 / 凭据被拒)换对应产品与区域的凭据;权限 / 未订阅类(403)检查账号权限与套餐;限流类等待退避后重试 |
260
278
  | 显示认证失效 | 检查是否用了对应产品 / 区域的订阅 Key;MiMo 重新登录或导入 Cookie;旧额度不会当作有效数据保留 |
261
279
  | MiMo Cookie 过期 | 会话 Cookie 自签发起 24 小时有效。药丸与设置页会在 2 小时内 / 30 分钟内分别变黄、变橙,到期显示「Cookie 已过期,请重新登录」;重新登录或重新导入即重新计时。倒计时只是提醒,真实失效以官方接口返回为准 |
@@ -305,7 +323,7 @@ node scripts/render-ui-preview.mjs 530 nanogpt light pill
305
323
  node scripts/check-browser-runtime.mjs # 需要工作区可解析 playwright-core 且本机装有 Google Chrome
306
324
  ```
307
325
 
308
- 文档入口:[开发与验证](docs/development.md)(RPC 契约、缓存与凭据约定、离线预览与实机验收范围)、[提供商清单与额度语义](docs/providers.md)、[提供商覆盖与取舍](docs/provider-coverage.md)、[UX 设计](docs/design-ux.md)、[发布与市场收录](docs/publish.md)、[更新记录](docs/changelog.md)、[审查记录](docs/code-review.md)。
326
+ 文档入口:[凭据获取指引](docs/credentials.md)(每类凭据的官网入口、环境变量与填入位置、报错对照)、[开发与验证](docs/development.md)(RPC 契约、缓存与凭据约定、离线预览与实机验收范围)、[提供商清单与额度语义](docs/providers.md)、[提供商覆盖与取舍](docs/provider-coverage.md)、[UX 设计](docs/design-ux.md)、[发布与市场收录](docs/publish.md)、[更新记录](docs/changelog.md)、[审查记录](docs/code-review.md)。
309
327
 
310
328
  > 打包说明:`package.json` 的 `files` 白名单是 `lib` / `locale` / `cordis.patch.yml` / `README.md`,因此 `assets/title.svg`、`assets/screenshots/` 与 `docs/` **不进 npm 包**——顶部 title 图与下面的界面预览只在 GitHub 页面显示。改动白名单属于发布决策,需单独处理。
311
329
 
package/lib/client.js CHANGED
@@ -74,6 +74,15 @@ window.__ModuleLoader__.load({
74
74
  wperiod: "本周期",
75
75
  wsub: "本周期",
76
76
  wrolling: "滚动",
77
+ // 药丸上的窗口短标签(只在有多个窗口时才出现):不要用 W / M / D 这类缩写让人猜,直接给中文单位。
78
+ s5h: "5时",
79
+ s7d: "7天",
80
+ sday: "日",
81
+ sweek: "周",
82
+ smonth: "月",
83
+ speriod: "周期",
84
+ ssub: "订阅",
85
+ srolling: "滚动",
77
86
  wall: "全部",
78
87
  detailUsed: "已用 {used} / 总计 {limit} {unit}",
79
88
  detailReset: "重置于 {time}",
@@ -178,6 +187,15 @@ window.__ModuleLoader__.load({
178
187
  wperiod: "Period",
179
188
  wsub: "Monthly",
180
189
  wrolling: "Rolling",
190
+ // Pill window tags (only shown when there are several windows): spell out the unit rather than D/W/M.
191
+ s5h: "5h",
192
+ s7d: "7d",
193
+ sday: "1d",
194
+ sweek: "1w",
195
+ smonth: "1m",
196
+ speriod: "cycle",
197
+ ssub: "sub",
198
+ srolling: "roll",
181
199
  wall: "All",
182
200
  detailUsed: "Used {used} / total {limit} {unit}",
183
201
  detailReset: "Resets {time}",
@@ -228,7 +246,8 @@ window.__ModuleLoader__.load({
228
246
  };
229
247
  const NS = "settings.subusage";
230
248
  const KIND_LABEL = { "5h": "w5h", "7d": "w7d", day: "wday", week: "wweek", month: "wmonth", period: "wperiod", sub: "wsub", rolling: "wrolling" };
231
- const KIND_SHORT = { "5h": "5h", "7d": "7d", day: "D", week: "W", month: "M", period: "P", sub: "Sub", rolling: "R" };
249
+ // 药丸的窗口短标签存的是 locale key:让人看 "W"/"M"/"D" 去猜是哪个窗口,不如直接给单位。
250
+ const KIND_SHORT = { "5h": "s5h", "7d": "s7d", day: "sday", week: "sweek", month: "smonth", period: "speriod", sub: "ssub", rolling: "srolling" };
232
251
  const PROVIDER_META = {
233
252
  // 余额型:接口给账户余额而不是订阅窗口,药丸用余额兜底文案显示。
234
253
  "deepseek": { label: "DeepSeek", short: "DeepSeek", envName: "DEEPSEEK_API_KEY" },
@@ -245,7 +264,8 @@ window.__ModuleLoader__.load({
245
264
  "xai-oauth": { label: "SuperGrok", short: "SuperGrok", envName: "", managedByPlugin: true },
246
265
  // Codex / ChatGPT 订阅:凭据是 Codex CLI 的 OAuth 登录文件,只读、不刷新、不接受本地 Key。
247
266
  // 默认开启:它对齐的是 DSH 内置的 openai-codex 路由,没登录时会被「默认隐藏」自动收起。
248
- "openai-codex": { label: "Codex (ChatGPT)", short: "Codex", envName: "", managedByPlugin: true },
267
+ // 默认关闭:Codex 的提供方插件自带用量药丸,两个并排只是重复信息(仍可手动开启)。
268
+ "openai-codex": { label: "Codex (ChatGPT)", short: "Codex", envName: "", managedByPlugin: true, defaultEnabled: false },
249
269
  // 国际版默认关闭,中国版保持默认开启。
250
270
  "minimax": { label: "MiniMax (International)", short: "MiniMax", envName: "MINIMAX_API_KEY", defaultEnabled: false },
251
271
  "minimax-cn": { label: "MiniMax (CN)", short: "MiniMax CN", envName: "MINIMAX_CN_API_KEY" },
@@ -281,7 +301,17 @@ window.__ModuleLoader__.load({
281
301
  };
282
302
  // 顺序只影响展示:DeepSeek 放在末尾以免打乱既有条目位置;药丸匹配靠 provider id,与顺序无关。
283
303
  const PROVIDER_ORDER = ["zai-coding-cn", "kimi-coding", "xiaomi-token-plan-cn", "opencode-go", "commandcode", "openai-codex", "xai-oauth", "minimax", "minimax-cn", "zai-coding", "synthetic", "nanogpt", "ark-coding-plan-cn", "ark-agent-plan-cn", "ark-coding-plan-byteplus", "siliconflow", "deepseek", "openrouter", "novita", "hyperbolic", "deepinfra", "chutes", "ollama-cloud", "vercel-ai-gateway", "zenmux", "litellm", "arkcli-agent-plan", "arkcli-coding-plan", "arkcli-agent-plan-team", "arkcli-coding-plan-team"];
284
- function providerEnabled(settings, id) { return settings?.visibility?.providers?.[id] ?? (PROVIDER_META[id]?.defaultEnabled !== false); }
304
+ // 火山方舟的 7 条路由共用同一组 IAM AK/SK,而一般用户只持有一个套餐,所以设置页只给一张卡片、
305
+ // 一个开关(组开关落在 Host 的 visibility.ark 上)。这些 id 不能真正合并——药丸靠 provider id 匹配路由。
306
+ const ARK_IDS = PROVIDER_ORDER.filter(id => PROVIDER_META[id]?.volc);
307
+ function providerEnabled(settings, id) {
308
+ if (PROVIDER_META[id]?.volc) {
309
+ // 组开关优先;旧配置里没有它时按「组内任一条开着」合并过来,全新配置则沿用默认。
310
+ if (typeof settings?.visibility?.ark === "boolean") return settings.visibility.ark;
311
+ return ARK_IDS.some(key => settings?.visibility?.providers?.[key] === true) || ARK_IDS.every(key => settings?.visibility?.providers?.[key] === undefined);
312
+ }
313
+ return settings?.visibility?.providers?.[id] ?? (PROVIDER_META[id]?.defaultEnabled !== false);
314
+ }
285
315
  // provider id 就是路由 id,而路由由提供方插件/CLI 按「你实际订阅了什么」写入:例如 arkcli helper
286
316
  // 只会为真正持有的套餐写路由。所以本机没有 Coding Plan 路由时,就不该默认把 Coding Plan 显示出来
287
317
  // ——这比「常量里默认开启」更贴近事实。undefined = 拿不到路由表,按「未知」放行,不谎报未安装。
@@ -347,7 +377,8 @@ window.__ModuleLoader__.load({
347
377
  }
348
378
  /** 药丸文案:永远答「还能用多少」(余),限额/连坐直说。多窗带窗口短名。 */
349
379
  function pillText(w, multi, t) {
350
- const short = multi ? `${KIND_SHORT[w.kind] ?? w.kind} ` : "";
380
+ // 短标签走 locale(中文「周 / 月 / 日」,英文 1w / 1m / 1d),不让人猜 W 是哪个窗口。
381
+ const short = multi ? `${t(KIND_SHORT[w.kind] ?? "srolling")} ` : "";
351
382
  if (w.status === "rate-limited" && !w.cascade) return `${short}${t("pillLimited")}`;
352
383
  if (w.status === "rate-limited") return `${short}${t("pillBlocked")}`;
353
384
  return `${short}${t("pillRemaining").replace("{p}", String(Math.round(Math.max(0, 100 - w.percent) * 10) / 10))}`;
@@ -892,6 +923,9 @@ window.__ModuleLoader__.load({
892
923
  w.detail && typeof w.detail.used === "number" && typeof w.detail.limit === "number" && w.detail.limit > 0
893
924
  ? t("detailUsed").replace("{used}", fmtTokens(w.detail.used)).replace("{limit}", fmtTokens(w.detail.limit)).replace("{unit}", w.detail.unit || "")
894
925
  : null,
926
+ // 不同额度线之间不可比(例如 AFP 的日限额只覆盖视觉 / 语音模型与 Harness):如实标出适用范围,
927
+ // 否则"日比周还高"会被读成插件算错。
928
+ w.detail && w.detail.note ? w.detail.note : null,
895
929
  w.detail && w.detail.limitSource === "plan-snapshot" ? t("planSnapshotLimit") : null,
896
930
  resetLine(w, t, getLocale),
897
931
  w.cascade ? t("cascadeLimited") + (w.detail && w.detail.blockedBy ? ` [${w.detail.blockedBy.join(", ")}]` : "") : null
@@ -1050,7 +1084,13 @@ window.__ModuleLoader__.load({
1050
1084
  t("ccUsageAccount").replace("{account}", ccState.activeId === "" || ccState.activeId === "default" ? t("ccAccountDefault") : (ccState.accounts.find((a) => a.id === ccState.activeId)?.label ?? ccState.activeId)),
1051
1085
  ccState.activeId === "" ? h("span", { style: { opacity: 0.85 } }, ` · ${t("ccUsageAutoNote")}`) : null)
1052
1086
  : null,
1053
- windows.map((w) => h(UsageWindowRow, { key: w.kind, w, t, getLocale, uncertain: degraded })),
1087
+ // 额度线切换时插一条分组标题:AFP 的日限额只覆盖视觉 / 语音模型与 Harness,与文本模型的
1088
+ // 5 小时 / 周 / 月不是同一条线,混排会让人把两条线的配额当成一组来互相比较。
1089
+ windows.map((w, index) => h(react.Fragment, { key: w.kind },
1090
+ w.groupLabel && w.groupLabel !== windows[index - 1]?.groupLabel
1091
+ ? h("p", { style: { margin: index ? "14px 0 0" : "6px 0 0", fontSize: 11, opacity: 0.6 } }, w.groupLabel)
1092
+ : null,
1093
+ h(UsageWindowRow, { w, t, getLocale, uncertain: degraded }))),
1054
1094
  entry && entry.extras && entry.extras.length > 0
1055
1095
  ? h("div", { style: { marginTop: 10, opacity: 0.85 } },
1056
1096
  entry.extras.map((x) => h("div", { key: x.kind }, `${extraLabel(x, t)}: ${x.value}`)))
@@ -1075,6 +1115,9 @@ window.__ModuleLoader__.load({
1075
1115
  function draftFor(settings, id) {
1076
1116
  return { keyMode: settings && settings.keyModes && settings.keyModes[id] || "inherit", keyAction: "keep", key: "", cookieAction: "keep", cookie: "", zai: { type: 1, organization: "", project: "", ...(settings && settings.zai) }, volc: { accessKey: "", secret: "", clear: false }, litellm: { baseUrl: settings && settings.litellm && settings.litellm.baseUrl || "" } };
1077
1117
  }
1118
+ // 从网页/控制台复制值时常带上换行、制表或零宽字符,而这些字段本身都不含空白——
1119
+ // 剔除它们比「拒绝保存」省事得多:拒绝只会让用户觉得「填了没用」。
1120
+ function stripInvisible(raw) { return String(raw).replace(/[\s\u200b-\u200d\ufeff]/g, ""); }
1078
1121
  function settingsPatch(id, draft, revision) {
1079
1122
  if (typeof revision !== "string" || !revision) throw new Error("请先加载最新设置,再编辑保存 / Load settings before saving");
1080
1123
  const patch = { providerId: id, expectedRevision: revision };
@@ -1087,10 +1130,11 @@ window.__ModuleLoader__.load({
1087
1130
  if (draft.volc.clear) patch.volc = { accessKeyId: { action: "clear" }, secretAccessKey: { action: "clear" } };
1088
1131
  else {
1089
1132
  const volc = {};
1090
- const accessKey = draft.volc.accessKey.trim(), secret = draft.volc.secret.trim();
1133
+ // 从控制台复制 AK/SK 时经常带上换行、制表或零宽字符,而 AK/SK 本身不含任何空白,
1134
+ // 所以这里**剔除**这些不可见字符而不是拒绝保存——拒绝过严会让用户只看到"没反应"。
1135
+ const accessKey = stripInvisible(draft.volc.accessKey), secret = stripInvisible(draft.volc.secret);
1091
1136
  for (const [field, value] of [["accessKeyId", accessKey], ["secretAccessKey", secret]]) {
1092
1137
  if (!value) continue;
1093
- if (/[\x00-\x1f\x7f]/.test(value)) throw new Error("Access Key 不能包含控制字符");
1094
1138
  volc[field] = { action: "replace", value };
1095
1139
  }
1096
1140
  if (Object.keys(volc).length) patch.volc = volc;
@@ -1104,12 +1148,13 @@ window.__ModuleLoader__.load({
1104
1148
  patch.keyMode = draft.keyMode;
1105
1149
  patch.keyUpdate = { action: draft.keyAction };
1106
1150
  if (draft.keyAction === "replace") {
1107
- if (!draft.key.trim() || /[\r\n\x00-\x1f\x7f]/.test(draft.key)) throw new Error("API Key 不能为空或包含控制字符");
1108
- patch.keyUpdate.value = draft.key.trim();
1151
+ const key = stripInvisible(draft.key);
1152
+ if (!key) throw new Error("API Key 不能为空");
1153
+ patch.keyUpdate.value = key;
1109
1154
  }
1110
- if (id === "zai-coding-cn") patch.zai = { ...draft.zai };
1155
+ if (id === "zai-coding-cn") patch.zai = { ...draft.zai, organization: stripInvisible(draft.zai.organization), project: stripInvisible(draft.zai.project) };
1111
1156
  // LiteLLM 的代理地址不是秘密,原样提交;空串表示清空(请求时会按「未配置」提示)。
1112
- if (id === "litellm") patch.litellm = { baseUrl: draft.litellm.baseUrl.trim() };
1157
+ if (id === "litellm") patch.litellm = { baseUrl: stripInvisible(draft.litellm.baseUrl) };
1113
1158
  }
1114
1159
  return patch;
1115
1160
  }
@@ -1230,9 +1275,14 @@ window.__ModuleLoader__.load({
1230
1275
  const patch = settingsPatch(tab, draft, revisionRef.current);
1231
1276
  const saved = await usageStore.save(patch);
1232
1277
  if (!aliveRef.current) return;
1278
+ // 填了 AK/SK 却只得到一句「已保存」是最误导人的失败方式:这里如实回显到底存没存上。
1279
+ const intendedVolc = Boolean(PROVIDER_META[tab].volc && (patch.volc?.accessKeyId || patch.volc?.secretAccessKey));
1280
+ const storedVolc = Boolean(saved.settings?.volc?.hasAccessKeyId || saved.settings?.volc?.hasSecretAccessKey);
1233
1281
  dirtyRef.current = false; setDirty(false);
1234
1282
  setDraft(draftFor(saved.settings, tab)); revisionRef.current = saved.settings && saved.settings.revision;
1235
- setNotice({ ok: true, text: t("saved") });
1283
+ setNotice(intendedVolc && !storedVolc
1284
+ ? { ok: false, text: text("设置已保存,但你填的 AK/SK 没有写入。请重试;或改用环境变量 VOLC_ACCESSKEY / VOLC_SECRETKEY(重启后生效)。", "Settings saved, but the AK/SK you entered was not stored. Retry, or use the VOLC_ACCESSKEY / VOLC_SECRETKEY environment variables (takes effect after restart).") }
1285
+ : { ok: true, text: t("saved") });
1236
1286
  if (!providerEnabled(saved.settings, tab)) return;
1237
1287
  // 保存已成功落盘;独立验证失败不改写成“保存失败”。
1238
1288
  try {
@@ -1291,6 +1341,11 @@ window.__ModuleLoader__.load({
1291
1341
  hasSaved && draft[kind + "Action"] !== "clear" ? h("button", { type: "button", style: btnStyle, disabled: kind === "cookie" && loginActive, onClick: () => { editDraft({ [kind + "Action"]: "clear", [kind]: "" }); setNotice({ ok: false, text: text("待清除:保存设置后将删除已保存的敏感凭据;取消编辑可保留。", "Pending removal: saving will delete the saved secret. Cancel edits to keep it.") }); } }, text(kind === "cookie" ? "清除登录凭据" : "清除", kind === "cookie" ? "Clear login credentials" : "Clear")) : null,
1292
1342
  draft[kind + "Action"] === "clear" ? h("span", { role: "status" }, text("将删除凭据(尚未保存)", "Credential will be deleted (not saved yet)")) : null
1293
1343
  );
1344
+ // 界面里那些「要先去别处才能拿到」的凭据,直接给出官方入口与本仓库指引,
1345
+ // 省得用户猜菜单藏在哪一层——顺带把「推理 Key ≠ 额度凭据」讲清楚。
1346
+ const CREDENTIALS_DOC = "https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/credentials.md";
1347
+ const credentialsGuide = h("a", { href: CREDENTIALS_DOC, target: "_blank", rel: "noopener noreferrer" }, text("获取与配置指引", "Get & configure guide"));
1348
+ const externalLink = (href, label) => h("a", { href, target: "_blank", rel: "noopener noreferrer" }, label);
1294
1349
  const credentialEditor = h(react.Fragment, null,
1295
1350
  notice ? h("p", { role: "status", style: { color: notice.ok ? "inherit" : "#dc2626" } }, notice.text) : null,
1296
1351
  h("div", { style: { marginTop: 10 } },
@@ -1302,6 +1357,8 @@ window.__ModuleLoader__.load({
1302
1357
  tab === "commandcode" ? h(react.Fragment, null, h("p", null, t("commandcodeManaged"))) : tab === "xai-oauth" ? h(react.Fragment, null, h("p", null, t("grokManaged"))) : tab === "openai-codex" ? h(react.Fragment, null,
1303
1358
  h("p", null, text("只读 Codex CLI 的 ChatGPT 订阅登录($CODEX_HOME/auth.json 或 ~/.codex/auth.json):本页不保存凭据、不刷新 token。请用 codex login 登录;API Key 模式(auth_mode 不是 chatgpt)取不到订阅额度。", "Reads the Codex CLI ChatGPT subscription login ($CODEX_HOME/auth.json or ~/.codex/auth.json) read-only: this page never saves credentials or refreshes tokens. Sign in with codex login; API-key mode (auth_mode not chatgpt) cannot read subscription quota."))) : PROVIDER_META[tab].volc ? h(react.Fragment, null,
1304
1359
  h("p", null, text("额度查询要用火山引擎 IAM 的 Access Key(AK/SK 配对,权限建议只给方舟只读),与推理用的方舟 API Key 不是同一套;Ark 的这几条路由共用这一组凭据。", "Quota lookup needs a Volcengine IAM Access Key pair (Ark read-only is enough). This is NOT the Ark inference API key; every Ark route here shares this pair.")),
1360
+ // 用户通常已经登录着控制台,直接给入口比让他自己翻菜单快得多。
1361
+ h("p", { style: { fontSize: 12 } }, externalLink("https://console.volcengine.com/iam/keymanage", text("打开火山引擎「API 访问密钥」创建 AK/SK", "Open Volcengine “API access keys”")), " · ", credentialsGuide),
1305
1362
  h("div", { role: "group", "aria-label": text("Key 来源模式", "Key source mode"), style: { display: "flex", gap: 8, flexWrap: "wrap" } },
1306
1363
  ["inherit", "manual"].map((mode) => h("button", { key: mode, type: "button", "aria-pressed": draft.keyMode === mode, style: draft.keyMode === mode ? primary : btnStyle, onClick: () => editDraft({ keyMode: mode }) }, `${draft.keyMode === mode ? "✓ " : ""}${mode === "inherit" ? text("继承凭据 / 环境变量", "Inherit credentials / environment") : text("手动 AK/SK", "Manual AK/SK")}`))),
1307
1364
  h("p", { style: { fontSize: 12 } }, text("继承变量:", "Inherited variables: "), h("code", null, "VOLC_ACCESSKEY"), " / ", h("code", null, "VOLC_SECRETKEY")),
@@ -1317,7 +1374,7 @@ window.__ModuleLoader__.load({
1317
1374
  ({ launching: text("正在启动登录浏览器…", "Launching login browser…"), waiting: text("请在浏览器中完成登录…", "Complete sign-in in the browser…"), verifying: text("正在验证并导入…", "Verifying & importing…") })[login.state],
1318
1375
  h("button", { type: "button", style: btnStyle, disabled: !login.jobId, onClick: cancelLogin }, text("取消登录", "Cancel login"))) : null,
1319
1376
  login.state === "error" || login.state === "cancelled" ? h("p", { role: "status" }, login.state === "cancelled" ? text("登录已取消,可重新登录或手动导入。", "Login cancelled. Retry or import manually.") : text("自动登录失败,可重试或手动导入。", "Automatic login failed. Retry or import manually.") + (login.message ? " " + login.message : "")) : null,
1320
- h("p", null, h("a", { href: "https://platform.xiaomimimo.com", target: "_blank", rel: "noopener noreferrer" }, text("仅打开 MiMo 官网(不会自动导入)", "Open MiMo website only (no automatic import)"))),
1377
+ h("p", { style: { fontSize: 12 } }, externalLink("https://platform.xiaomimimo.com/console/plan-manage", text("仅打开 MiMo 控制台套餐页(不会自动导入)", "Open the MiMo console only (no automatic import)")), " · ", credentialsGuide),
1321
1378
  h("p", null, result.settings && result.settings.xiaomi && result.settings.xiaomi.hasCookie ? text("已保存 Cookie(不回填)", "Cookie saved (never filled back)") : t("statusNoCookie")),
1322
1379
  cookieLifetimeLine(result.settings && result.settings.xiaomi, t, getLocale),
1323
1380
  h("p", { style: { fontSize: 12, opacity: .75 } }, t("mimoCookieTtl")),
@@ -1330,14 +1387,17 @@ window.__ModuleLoader__.load({
1330
1387
  h("div", { role: "group", "aria-label": text("Key 来源模式", "Key source mode"), style: { display: "flex", gap: 8, flexWrap: "wrap" } },
1331
1388
  ["inherit", "manual"].map((mode) => h("button", { key: mode, type: "button", "aria-pressed": draft.keyMode === mode, style: draft.keyMode === mode ? primary : btnStyle, onClick: () => editDraft({ keyMode: mode }) }, `${draft.keyMode === mode ? "✓ " : ""}${mode === "inherit" ? text("继承凭据 / 环境变量", "Inherit credentials / environment") : text("手动 Key", "Manual key")}`))),
1332
1389
  h("p", { style: { fontSize: 12 } }, result.settings && result.settings.hasKeys && result.settings.hasKeys[tab] ? text("已保存 Key(不回填)", "Key saved (never filled back)") : text("未保存手动 Key", "No manual key saved")),
1390
+ h("p", { style: { fontSize: 12 } }, credentialsGuide, " ", text("(含各家 API Key 的获取入口与环境变量名)", "(provider API-key sources and env var names)")),
1333
1391
  credentialButtons("key", result.settings && result.settings.hasKeys && result.settings.hasKeys[tab]),
1334
1392
  draft.keyAction === "replace" ? h("label", null, t("keyLabel"), h("input", { type: "password", autoComplete: "new-password", value: draft.key, disabled: busy, onChange: (e) => editDraft({ key: e.target.value }), style: inputStyle })) : null),
1335
1393
  tab === "litellm" ? h(react.Fragment, null,
1336
1394
  h("label", null, text("代理地址", "Proxy base URL"), h("input", { autoComplete: "off", spellCheck: false, value: draft.litellm.baseUrl, disabled: busy, placeholder: "https://litellm.example.com", onChange: (e) => editDraft({ litellm: { ...draft.litellm, baseUrl: e.target.value } }), style: inputStyle })),
1337
- h("p", { style: { fontSize: 12, opacity: .75 } }, text("管理端点在 proxy 根:末尾的 /v1 会被自动去掉。虚拟 Key 与推理用的是同一把。", "Admin endpoints live at the proxy root: a trailing /v1 is stripped. The virtual key is the same one you use for inference."))) : null,
1395
+ h("p", { style: { fontSize: 12, opacity: .75 } }, text("管理端点在 proxy 根:末尾的 /v1 会被自动去掉。虚拟 Key 与推理用的是同一把。", "Admin endpoints live at the proxy root: a trailing /v1 is stripped. The virtual key is the same one you use for inference.")),
1396
+ h("p", { style: { fontSize: 12 } }, credentialsGuide)) : null,
1338
1397
  tab === "zai-coding-cn" ? h(react.Fragment, null,
1339
1398
  h("div", { role: "radiogroup", "aria-label": t("zaiType"), style: { display: "flex", gap: 8, flexWrap: "wrap" } }, [1, 2].map(type => h("button", { key: type, type: "button", role: "radio", "aria-checked": draft.zai.type === type, style: draft.zai.type === type ? primary : btnStyle, disabled: busy, onClick: () => editDraft({ zai: { ...draft.zai, type } }) }, t(type === 1 ? "zaiTypePersonal" : "zaiTypeTeam")))),
1340
- draft.zai.type === 2 ? ["organization", "project"].map((name) => h("label", { key: name }, t(name === "organization" ? "zaiOrg" : "zaiProject"), h("input", { value: draft.zai[name] || "", disabled: busy, style: inputStyle, onChange: (e) => editDraft({ zai: { ...draft.zai, [name]: e.target.value } }) }))) : null) : null,
1399
+ draft.zai.type === 2 ? ["organization", "project"].map((name) => h("label", { key: name }, t(name === "organization" ? "zaiOrg" : "zaiProject"), h("input", { value: draft.zai[name] || "", disabled: busy, style: inputStyle, onChange: (e) => editDraft({ zai: { ...draft.zai, [name]: e.target.value } }) }))) : null,
1400
+ draft.zai.type === 2 ? h("p", { style: { fontSize: 12 } }, credentialsGuide, " ", text("(团队档的组织 / 项目 ID 在哪拿)", "(where to find team org / project IDs)")) : null) : null,
1341
1401
  dirty ? h("div", { style: { display: "flex", gap: 8, marginTop: 10 } }, h("button", { type: "button", disabled: busy || (tab === "xiaomi-token-plan-cn" && loginActive), style: primary, onClick: onSave }, t(busy ? "saving" : "save")), h("button", { type: "button", disabled: busy, style: btnStyle, onClick: cancel }, text("取消编辑", "Cancel edits"))) : null)),
1342
1402
  h("details", { style: { marginTop: 12 } }, h("summary", { style: { cursor: "pointer" } }, text("技术信息与帮助", "Technical information & help")),
1343
1403
  h("code", null, tab), tab !== "xiaomi-token-plan-cn" && meta.envName ? h("p", null, text("继承变量:", "Inherited variable: "), h("code", null, meta.envName)) : null, h("p", null, t("hint")), ["minimax", "minimax-cn"].includes(tab) ? h("p", null, text("需要对应区域的 Token Plan / Coding Plan 订阅 Key,不是按量计费 Key。读取通用/编程池的剩余百分比;零总计、无上限或未报告的窗口不会显示为零用量,周加量不折算为额外百分比。", "Use a Token Plan / Coding Plan subscription key for the selected region, not a pay-as-you-go key. Shows the general/coding pool; zero-total, unlimited or absent windows are not displayed as zero usage. Weekly boosts are not converted into extra percentage.")) : null, tab === "zai-coding-cn" ? h("p", null, t("zaiOrgHint")) : null, tab === "commandcode" ? h("p", null, t("commandcodeHelp")) : null, tab === "xai-oauth" ? h("p", null, t("grokHelp")) : null,
@@ -1351,12 +1411,18 @@ window.__ModuleLoader__.load({
1351
1411
  h("button", { type: "button", role: "switch", "aria-checked": hideWithoutApi, disabled: busy || dirty || !result.settings?.revision, "aria-label": text("没有检测到API的默认隐藏", "Hide providers without detected API credentials"), style: hideWithoutApi ? primary : btnStyle, onClick: () => saveVisibility({ hideWithoutApi: !hideWithoutApi }) }, `${hideWithoutApi ? "✓ " : "○ "}${text("没有检测到API的默认隐藏", "Hide providers without detected API credentials")}`),
1352
1412
  h("p", { style: { fontSize: 12, opacity: .75 } }, text("检测依据为可用 Key 或 MiMo Cookie 是否存在;认证失败和网络错误不会当成未配置 API。隐藏的提供商仍可在这里配置。", "Detection checks for a Key or MiMo Cookie, not request success. Auth/network errors are not treated as missing API credentials. Hidden providers remain configurable here.")),
1353
1413
  // 启用的排到最前面,省得每次往下翻;组内保持 PROVIDER_ORDER 的既有顺序(sort 稳定)。
1354
- h("div", { style: { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 240px), 1fr))", gap: 8 } }, [...PROVIDER_ORDER].sort((a, b) => Number(!providerEnabled(result.settings, a)) - Number(!providerEnabled(result.settings, b))).map(id => {
1414
+ // Ark 的 7 条路由合并成一张卡片:只渲染本机装了的那条作代表(都没装就渲染第一条),
1415
+ // 由它统一控制整组的检测开关与那组共享的 AK/SK。
1416
+ h("div", { style: { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 240px), 1fr))", gap: 8 } }, [...PROVIDER_ORDER].filter(id => !PROVIDER_META[id]?.volc || id === (ARK_IDS.find(key => providerRoutable(result, key)) ?? ARK_IDS[0])).sort((a, b) => Number(!providerEnabled(result.settings, a)) - Number(!providerEnabled(result.settings, b))).map(id => {
1417
+ const isArk = Boolean(PROVIDER_META[id].volc), arkPresent = isArk ? ARK_IDS.filter(key => providerRoutable(result, key)) : [];
1355
1418
  const enabled = providerEnabled(result.settings, id), e = entries.find(item => item.providerId === id), editing = tab === id && editorOpen;
1419
+ // Ark 卡片代表整组:标题用组名、开关写组级字段,并说明本机实际装了哪几条路由。
1420
+ const title = isArk ? text("火山方舟(Ark)", "Volcengine Ark") : PROVIDER_META[id].short;
1421
+ const arkNote = isArk ? text(`本机已装 ${arkPresent.length ? arkPresent.map(key => PROVIDER_META[key].short).join(" / ") : "0 条"},该组共 ${ARK_IDS.length} 条路由定义`, `${arkPresent.length} of ${ARK_IDS.length} Ark routes installed`) : null;
1356
1422
  return h("div", { key: id, style: { gridColumn: editing ? "1 / -1" : undefined, display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center", border: "1px solid rgba(128,128,128,.25)", borderRadius: 8, padding: 8 } },
1357
- h("span", { style: { flex: 1, minWidth: 80 } }, PROVIDER_META[id].short),
1358
- h("button", { type: "button", role: "switch", "aria-checked": enabled, disabled: busy || dirty || !result.settings?.revision, "aria-label": `${text("启用", "Enable")} ${PROVIDER_META[id].short}`, style: enabled ? primary : btnStyle, onClick: () => saveVisibility({ providers: { [id]: !enabled } }) }, enabled ? text("已开启", "On") : text("已关闭", "Off")),
1359
- h("span", { style: { width: "100%", fontSize: 12, opacity: .75 } }, !enabled ? text("检测已关闭", "Detection disabled") : e?.apiDetected === false || ["no-key", "no-cookie"].includes(e?.state) ? text(hideWithoutApi ? "未检测到 API · 默认隐藏" : "未检测到 API", "No API credentials detected") : e?.apiDetected === true || (e?.keySource && e.keySource !== "none") ? text("已检测到 API / 凭据", "API credentials detected") : text("待检测", "Detection pending")),
1423
+ h("span", { style: { flex: 1, minWidth: 80 } }, title),
1424
+ h("button", { type: "button", role: "switch", "aria-checked": enabled, disabled: busy || dirty || !result.settings?.revision, "aria-label": `${text("启用", "Enable")} ${title}`, style: enabled ? primary : btnStyle, onClick: () => saveVisibility(isArk ? { ark: !enabled } : { providers: { [id]: !enabled } }) }, enabled ? text("已开启", "On") : text("已关闭", "Off")),
1425
+ h("span", { style: { width: "100%", fontSize: 12, opacity: .75 } }, arkNote ? arkNote + " · " : "", !enabled ? text("检测已关闭", "Detection disabled") : !providerRoutable(result, id) ? text("本机没装这条路由:提供方插件或 CLI 没有注册它,开了也读不到东西", "Route not installed on this machine: no provider plugin or CLI registered it, so enabling it reads nothing") : e?.apiDetected === false || ["no-key", "no-cookie"].includes(e?.state) ? text(hideWithoutApi ? "未检测到 API · 默认隐藏" : "未检测到 API", "No API credentials detected") : e?.apiDetected === true || (e?.keySource && e.keySource !== "none") ? text("已检测到 API / 凭据", "API credentials detected") : text("待检测", "Detection pending")),
1360
1426
  h("details", { id: `subusage-credentials-${id}`, open: editing, style: { width: "100%" } },
1361
1427
  h("summary", { "aria-label": `${text("连接与凭据", "Connection & credentials")} ${PROVIDER_META[id].short}`, style: { cursor: "pointer" }, onClick: event => { event.preventDefault(); if (busy) return; if (editing) setEditorOpen(false); else openEditor(id); } }, text(id === "xiaomi-token-plan-cn" ? "登录与凭据" : PROVIDER_META[id].managedByPlugin ? "凭据来源" : "连接与凭据", id === "xiaomi-token-plan-cn" ? "Login & credentials" : PROVIDER_META[id].managedByPlugin ? "Credential source" : "Connection & credentials")),
1362
1428
  tab === id ? credentialEditor : null));
package/lib/index.js CHANGED
@@ -34,7 +34,8 @@ const PROVIDERS = {
34
34
  "xai-oauth": { label: "SuperGrok", envName: "", managedByPlugin: true },
35
35
  // OpenAI Codex / ChatGPT 订阅:路由由 DSH 内置的 openai-codex(或 dsh-codex-connect 等插件)提供,
36
36
  // 额度凭据只认 Codex CLI 的 OAuth 登录文件,不提供本地凭据编辑。
37
- "openai-codex": { label: "Codex (ChatGPT)", envName: "", managedByPlugin: true },
37
+ // 默认关闭:Codex 的提供方插件自己就带用量药丸,两个药丸并排只是重复信息(仍可手动开启)。
38
+ "openai-codex": { label: "Codex (ChatGPT)", envName: "", managedByPlugin: true, defaultEnabled: false },
38
39
  // 火山方舟:方舟路由由两种方式写入——官方 CLI(arkcli helper,见下)或官方插件
39
40
  // @volcengine/ark-plan-api(legacy 段)。**provider id 必须与写入方逐字一致**,
40
41
  // 否则选中方舟模型时药丸不会出现。额度读取走管控面 OpenAPI,凭据是 IAM 的 AK/SK 配对
@@ -80,6 +81,10 @@ const PROVIDERS = {
80
81
  "litellm": { label: "LiteLLM", envName: "LITELLM_API_KEY", defaultEnabled: false }
81
82
  };
82
83
  const IDS = Object.keys(PROVIDERS);
84
+ // 火山方舟的路由共用同一组 IAM AK/SK,而一般用户只持有一个套餐;设置页把它们合并成一张卡片,
85
+ // 用一个组级开关控制整组。这些 id 仍各自存在——药丸要靠 provider id 匹配路由,不能合并。
86
+ const ARK_IDS = IDS.filter(id => PROVIDERS[id].credentialKind === "volc");
87
+ const ARK_DEFAULT_ON = ARK_IDS.some(id => PROVIDERS[id].defaultEnabled !== false);
83
88
  // 火山方舟额度接口的凭据是 IAM Access Key:AK 与 SK 必须同源配对,跨来源拼接只会得到 401。
84
89
  // 变量名沿用火山官方 SDK 习惯;与方舟推理 Key(ARK_*_API_KEY)无关。
85
90
  const VOLC_ACCESS_KEY_ENV = "VOLC_ACCESSKEY";
@@ -187,7 +192,7 @@ function redactMimo(value, cookie) {
187
192
  return { ...value, ...(Array.isArray(value.extras) ? { extras: value.extras.map(extra => extra.kind === "plan" ? { ...extra, value: sanitize(extra.value) } : extra) } : {}) };
188
193
  }
189
194
  function defaultSettings() {
190
- return { zai: { type: 1, organization: "", project: "" }, volc: { accessKeyId: "", secretAccessKey: "" }, litellm: { baseUrl: "" }, xiaomi: { cookie: "", loginAt: 0, expiresAt: 0 }, keys: {}, keyModes: Object.fromEntries(IDS.map(id => [id, "inherit"])), visibility: { hideWithoutApi: true, providers: Object.fromEntries(IDS.map(id => [id, PROVIDERS[id].defaultEnabled !== false])) } };
195
+ return { zai: { type: 1, organization: "", project: "" }, volc: { accessKeyId: "", secretAccessKey: "" }, litellm: { baseUrl: "" }, xiaomi: { cookie: "", loginAt: 0, expiresAt: 0 }, keys: {}, keyModes: Object.fromEntries(IDS.map(id => [id, "inherit"])), visibility: { hideWithoutApi: true, ark: ARK_DEFAULT_ON, providers: Object.fromEntries(IDS.map(id => [id, PROVIDERS[id].defaultEnabled !== false])) } };
191
196
  }
192
197
  function parseStored(value) {
193
198
  const s = defaultSettings();
@@ -197,6 +202,9 @@ function parseStored(value) {
197
202
  if (value.litellm && typeof value.litellm === "object" && !Array.isArray(value.litellm) && typeof value.litellm.baseUrl === "string") s.litellm.baseUrl = value.litellm.baseUrl;
198
203
  if (value.xiaomi && typeof value.xiaomi === "object") { if (typeof value.xiaomi.cookie === "string") s.xiaomi.cookie = value.xiaomi.cookie; for (const field of ["loginAt", "expiresAt"]) if (Number.isSafeInteger(value.xiaomi[field]) && value.xiaomi[field] > 0) s.xiaomi[field] = value.xiaomi[field]; }
199
204
  if (typeof value.visibility?.hideWithoutApi === "boolean") s.visibility.hideWithoutApi = value.visibility.hideWithoutApi;
205
+ // 组级开关:旧配置里 Ark 的每条路由各存了一份,缺省时按「任一条开着」合并过来,迁移不留痕迹。
206
+ if (typeof value.visibility?.ark === "boolean") s.visibility.ark = value.visibility.ark;
207
+ else if (ARK_IDS.some(id => value.visibility?.providers?.[id] === true)) s.visibility.ark = true;
200
208
  for (const id of IDS) {
201
209
  if (typeof value.visibility?.providers?.[id] === "boolean") s.visibility.providers[id] = value.visibility.providers[id];
202
210
  if (PROVIDERS[id].managedByPlugin) continue;
@@ -206,7 +214,7 @@ function parseStored(value) {
206
214
  return s;
207
215
  }
208
216
  function publicSettings(s) {
209
- return { revision: hash(s), zai: { ...s.zai }, volc: { hasAccessKeyId: !!s.volc.accessKeyId?.trim(), hasSecretAccessKey: !!s.volc.secretAccessKey?.trim() }, litellm: { baseUrl: s.litellm.baseUrl }, xiaomi: { hasCookie: !!s.xiaomi.cookie.trim(), loginAt: isoStamp(s.xiaomi.loginAt), expiresAt: isoStamp(s.xiaomi.expiresAt) }, hasKeys: Object.fromEntries(IDS.map(id => [id, !!s.keys[id]?.trim()])), keyModes: { ...s.keyModes }, visibility: { hideWithoutApi: s.visibility.hideWithoutApi, providers: { ...s.visibility.providers } } };
217
+ return { revision: hash(s), zai: { ...s.zai }, volc: { hasAccessKeyId: !!s.volc.accessKeyId?.trim(), hasSecretAccessKey: !!s.volc.secretAccessKey?.trim() }, litellm: { baseUrl: s.litellm.baseUrl }, xiaomi: { hasCookie: !!s.xiaomi.cookie.trim(), loginAt: isoStamp(s.xiaomi.loginAt), expiresAt: isoStamp(s.xiaomi.expiresAt) }, hasKeys: Object.fromEntries(IDS.map(id => [id, !!s.keys[id]?.trim()])), keyModes: { ...s.keyModes }, visibility: { hideWithoutApi: s.visibility.hideWithoutApi, ark: s.visibility.ark, providers: { ...s.visibility.providers } } };
210
218
  }
211
219
  function invalid(message) { return new RemoteError("subusage/invalid-request", message, { retryable: false, retainPrevious: false }); }
212
220
  function parseUpdate(update) {
@@ -218,9 +226,11 @@ function parseSettings(value) {
218
226
  if (value?.visibility !== undefined) {
219
227
  const v = value.visibility;
220
228
  if (!v || typeof v !== "object" || Array.isArray(v) || Object.keys(value).some(k => !["visibility", "expectedRevision"].includes(k)) || typeof value.expectedRevision !== "string" || !value.expectedRevision) throw invalid("Invalid visibility patch");
221
- if (Object.keys(v).some(k => !["providers", "hideWithoutApi"].includes(k)) || !Object.keys(v).length) throw invalid("Invalid visibility fields");
229
+ if (Object.keys(v).some(k => !["providers", "hideWithoutApi", "ark"].includes(k)) || !Object.keys(v).length) throw invalid("Invalid visibility fields");
222
230
  const visibility = {};
223
231
  if (v.hideWithoutApi !== undefined) { if (typeof v.hideWithoutApi !== "boolean") throw invalid("Invalid auto-hide switch"); visibility.hideWithoutApi = v.hideWithoutApi; }
232
+ // Ark 组级开关:设置页只呈现这一个,由它统一控制组内全部路由。
233
+ if (v.ark !== undefined) { if (typeof v.ark !== "boolean") throw invalid("Invalid Ark group switch"); visibility.ark = v.ark; }
224
234
  if (v.providers !== undefined) {
225
235
  if (!v.providers || typeof v.providers !== "object" || Array.isArray(v.providers) || !Object.keys(v.providers).length || Object.entries(v.providers).some(([id, enabled]) => !IDS.includes(id) || typeof enabled !== "boolean")) throw invalid("Invalid provider switches");
226
236
  visibility.providers = { ...v.providers };
@@ -302,7 +312,7 @@ function volcFailure(action, error) {
302
312
  }
303
313
  async function fetchJson(fetcher, url, headers, lifetimeSignal, init = {}) {
304
314
  let response;
305
- try { response = await fetcher(url, { method: init.method ?? "GET", ...(init.body !== undefined ? { body: init.body } : {}), headers: { accept: "application/json", "user-agent": "dsh-subusage/0.10.1", ...headers }, signal: lifetimeSignal ? AbortSignal.any([AbortSignal.timeout(10000), lifetimeSignal]) : AbortSignal.timeout(10000), redirect: "manual" }); }
315
+ try { response = await fetcher(url, { method: init.method ?? "GET", ...(init.body !== undefined ? { body: init.body } : {}), headers: { accept: "application/json", "user-agent": "dsh-subusage/0.10.3", ...headers }, signal: lifetimeSignal ? AbortSignal.any([AbortSignal.timeout(10000), lifetimeSignal]) : AbortSignal.timeout(10000), redirect: "manual" }); }
306
316
  catch { throw failure("subusage/network", "网络请求失败或超时", true); }
307
317
  if (response.status >= 300 && response.status < 400 || response.status === 401 || response.status === 403) throw failure("subusage/auth", "登录或凭据已失效");
308
318
  if (!response.ok) {
@@ -1121,9 +1131,11 @@ export class SubUsageService extends TypertRemoteService {
1121
1131
  const id = patch.providerId;
1122
1132
  // 火山方舟两个 provider 共用一组 AK/SK:改凭据要让所有用它的条目失效,不能只失效当前标签页。
1123
1133
  const sharedVolc = patch.accessKeyUpdate !== undefined || patch.secretKeyUpdate !== undefined;
1124
- const affected = patch.visibility ? Object.keys(patch.visibility.providers ?? {}).filter(key => stored.visibility.providers[key] !== patch.visibility.providers[key]) : sharedVolc ? IDS.filter(key => PROVIDERS[key].credentialKind === "volc") : [id];
1134
+ const affected = patch.visibility ? [...Object.keys(patch.visibility.providers ?? {}).filter(key => stored.visibility.providers[key] !== patch.visibility.providers[key]), ...(patch.visibility.ark !== undefined && patch.visibility.ark !== stored.visibility.ark ? ARK_IDS : [])] : sharedVolc ? IDS.filter(key => PROVIDERS[key].credentialKind === "volc") : [id];
1125
1135
  if (patch.visibility) {
1126
1136
  if (patch.visibility.hideWithoutApi !== undefined) stored.visibility.hideWithoutApi = patch.visibility.hideWithoutApi;
1137
+ // 组开关同时落到组内每一条:这样旧字段(逐条开关)与组开关始终一致,回退版本也不会读到矛盾状态。
1138
+ if (patch.visibility.ark !== undefined) { stored.visibility.ark = patch.visibility.ark; for (const key of ARK_IDS) stored.visibility.providers[key] = patch.visibility.ark; }
1127
1139
  Object.assign(stored.visibility.providers, patch.visibility.providers);
1128
1140
  }
1129
1141
  if (patch.keyMode !== undefined) stored.keyModes[id] = patch.keyMode;
package/lib/mimo-login.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
 
3
3
  export const MIMO_ORIGIN = "https://platform.xiaomimimo.com";
4
+ // 登录要落到控制台的套餐管理页:站点首页不触发登录,用户还得自己点进控制台才有登录入口。
5
+ // 这一页也正是读取 tokenPlan/detail 与 tokenPlan/usage 的地方。
6
+ export const MIMO_LOGIN_URL = `${MIMO_ORIGIN}/console/plan-manage`;
4
7
  export const MIMO_API_URLS = ["balance", "tokenPlan/detail", "tokenPlan/usage"].map(path => `${MIMO_ORIGIN}/api/v1/${path}`);
5
8
  const ACTIVE = new Set(["launching", "waiting", "verifying"]);
6
9
  const REQUIRED = ["api-platform_serviceToken", "userId"];
@@ -84,7 +87,10 @@ export class MimoLogin {
84
87
  page.on?.("close", () => this.finish(job, "cancelled", undefined, "登录窗口已关闭,未保存 Cookie"));
85
88
  if (!this.alive(job)) return;
86
89
  phase = "navigate";
87
- await page.goto(MIMO_ORIGIN, { waitUntil: "domcontentloaded", timeout: 30000 });
90
+ // 先落控制台套餐页以直接触发登录;控制台历史上出现过 502(仅有首页可用),
91
+ // 那时回退到首页,至少让用户能自己在站内导航,而不是停在一个打不开的页面上。
92
+ try { await page.goto(MIMO_LOGIN_URL, { waitUntil: "domcontentloaded", timeout: 30000 }); }
93
+ catch { await page.goto(MIMO_ORIGIN, { waitUntil: "domcontentloaded", timeout: 30000 }); }
88
94
  if (!this.alive(job)) return;
89
95
  job.public.state = "waiting"; job.public.message = "请在官方窗口自行完成登录;账户验证通过后保存,额度状态另行显示";
90
96
  phase = "verify";
package/lib/volcengine.js CHANGED
@@ -128,7 +128,15 @@ export function parseVolcCodingPlan(body) {
128
128
  }
129
129
 
130
130
  // Agent Plan 的 AFP 窗口:Quota / Used 是绝对值,比例由两者算出。
131
- const AFP_WINDOWS = [["AFPFiveHour", "5h"], ["AFPDaily", "day"], ["AFPWeekly", "week"], ["AFPMonthly", "month"]];
131
+ // 顺序即展示顺序,第三列是**额度线分组**:文本 / 向量模型走 5 小时 → 周 → 月(同一条线),
132
+ // 而日限额只在视觉模型、语音模型与 Harness 上生效,是另一条线。两组各自成组展示并标注影响范围,
133
+ // 否则把两条线的配额并排放在一起,谁都会把"日比周还高"读成插件算错了。
134
+ const AFP_WINDOWS = [
135
+ ["AFPFiveHour", "5h", "文本 / 向量模型"],
136
+ ["AFPWeekly", "week", "文本 / 向量模型"],
137
+ ["AFPMonthly", "month", "文本 / 向量模型"],
138
+ ["AFPDaily", "day", "视觉 / 语音模型与 Harness"]
139
+ ];
132
140
 
133
141
  /**
134
142
  * 个人版 Agent Plan:Result 下四个滚动窗口。
@@ -139,14 +147,14 @@ export function parseVolcAgentPlan(body) {
139
147
  if (!result || typeof result !== "object") return { subscribed: false, windows: [], plan: undefined };
140
148
  const windows = [];
141
149
  let reported = false;
142
- for (const [field, kind] of AFP_WINDOWS) {
150
+ for (const [field, kind, groupLabel] of AFP_WINDOWS) {
143
151
  const row = result[field];
144
152
  if (!row || typeof row !== "object") continue;
145
153
  reported = true;
146
154
  const quota = amount(row.Quota), used = amount(row.Used);
147
155
  if (quota === undefined || used === undefined) throw new Error("Volcengine AFP window is invalid");
148
156
  if (quota <= 0) continue;
149
- windows.push({ kind, percent: Math.min(100, used / quota * 100), resetsAt: fromEpoch(row.ResetTime, "milliseconds"), detail: { used, limit: quota, unit: "AFP" } });
157
+ windows.push({ kind, percent: Math.min(100, used / quota * 100), resetsAt: fromEpoch(row.ResetTime, "milliseconds"), groupLabel, detail: { used, limit: quota, unit: "AFP" } });
150
158
  }
151
159
  const plan = typeof result.PlanType === "string" && result.PlanType.trim() ? result.PlanType.trim() : undefined;
152
160
  return { subscribed: windows.length > 0 || reported, windows, plan };
@@ -168,14 +176,16 @@ export function parseVolcSeatAfp(body) {
168
176
  const row = rows[0] && typeof rows[0] === "object" ? rows[0] : {};
169
177
  const windows = [];
170
178
  let reported = false;
171
- for (const [field, kind] of AFP_WINDOWS) {
179
+ // 席位版 Agent Plan 与个人版共用同一组 AFP 窗口(含那条只覆盖视觉 / 语音模型与 Harness 的日限额),
180
+ // 所以分组标注也要一起带上,否则席位卡片里同样会把两条额度线混着显示。
181
+ for (const [field, kind, groupLabel] of AFP_WINDOWS) {
172
182
  const window = row[field];
173
183
  if (!window || typeof window !== "object") continue;
174
184
  reported = true;
175
185
  const quota = amount(window.Quota), used = amount(window.Used);
176
186
  if (quota === undefined || used === undefined) throw new Error("Volcengine seat AFP window is invalid");
177
187
  if (quota <= 0) continue;
178
- windows.push({ kind, percent: Math.min(100, used / quota * 100), resetsAt: fromEpoch(window.ResetTime, "milliseconds"), detail: { used, limit: quota, unit: "AFP" } });
188
+ windows.push({ kind, percent: Math.min(100, used / quota * 100), resetsAt: fromEpoch(window.ResetTime, "milliseconds"), groupLabel, detail: { used, limit: quota, unit: "AFP" } });
179
189
  }
180
190
  const plan = typeof row.PlanType === "string" && row.PlanType.trim() ? row.PlanType.trim() : undefined;
181
191
  const seatId = typeof row.SeatID === "string" && row.SeatID.trim() ? row.SeatID.trim() : undefined;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-subusage",
3
3
  "displayName": "订阅用量显示",
4
- "version": "0.10.1",
4
+ "version": "0.10.3",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "registry": "https://registry.npmjs.org/"
@@ -10,7 +10,7 @@
10
10
  "type": "git",
11
11
  "url": "git+https://github.com/KouzakiUmi/dsh-subusage.git"
12
12
  },
13
- "description": "显示 DeepSeek 官方余额、火山方舟(arkcli 官方 CLI 路由与旧插件路由、Agent/Coding Plan 个人版与企业版席位)、Z.ai(中国与国际)、Kimi、MiMo、OpenCode Go、Command Code、SuperGrok、Codex(ChatGPT 订阅)、MiniMax(中国)、Synthetic、NanoGPT、SiliconFlow、OpenRouter、Novita、Hyperbolic、DeepInfra、Chutes、Ollama Cloud、Vercel AI Gateway、ZenMux、LiteLLM 的订阅余量与余额;共享刷新与凭据管理,默认关闭 19 家。",
13
+ "description": "显示 DeepSeek 官方余额、火山方舟(arkcli 官方 CLI 路由与旧插件路由、Agent/Coding Plan 个人版与企业版席位,设置页合并为一组)、Z.ai(中国与国际)、Kimi、MiMo、OpenCode Go、Command Code、SuperGrok、Codex(ChatGPT 订阅,默认关闭)、MiniMax(中国)、Synthetic、NanoGPT、SiliconFlow、OpenRouter、Novita、Hyperbolic、DeepInfra、Chutes、Ollama Cloud、Vercel AI Gateway、ZenMux、LiteLLM 的订阅余量与余额;共享刷新与凭据管理,默认关闭 20 家。",
14
14
  "keywords": [
15
15
  "dsh",
16
16
  "dsh-plugin",