dsh-subusage 0.10.2 → 0.10.4

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.2-blue)](https://www.npmjs.com/package/dsh-subusage)
9
+ [![npm version](https://img.shields.io/badge/npm-0.10.4-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
  |---|---|---|---|---|
@@ -123,11 +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
143
  - **凭据从哪来**:每一类凭据的**官网入口、环境变量名与界面填入位置**都写在[凭据获取指引](docs/credentials.md)——包括火山的 IAM AK/SK(子用户还要挂 `ArkReadOnlyAccess` 且不限制到项目)、MiMo 的平台会话 Cookie、以及各家 API Key 的对照表。**注意推理 Key 与额度凭据不通用。**
130
- - **默认开关策略**:新安装默认开启 11 条、默认关闭 19 条;开关即时保存,升级保留已保存的开关,不重算默认值。默认隐藏只影响显示,不会自动开启被关闭的提供商。
144
+ - **默认开关策略**:新安装默认开启 10 条、默认关闭 20 条(**Codex 默认关闭**——它的提供方插件自带用量药丸);开关即时保存,升级保留已保存的开关,不重算默认值。默认隐藏只影响显示,不会自动开启被关闭的提供商。
145
+ - **火山方舟是一组**:7 条路由共用同一组 IAM AK/SK,所以设置页只呈现**一张卡片、一个开关**(组内最后选的那条作为代表),卡片里写明本机实际装了哪几条。这些 provider id 并没有合并——药丸仍按 id 匹配路由,本机没装的自然不显示。
131
146
  - **凭据来源优先级**(`inherit` 模式):**凭据服务 → 启动环境 → 旧手动配置兜底**;切到自定义(`manual`)模式时只使用保存的手动 Key。界面会区分这三种来源。
132
147
  - **不接受本地 Key 的提供商**:`commandcode`(凭据链属提供方插件)、`xai-oauth`、`openai-codex`(只读各自 CLI 的登录文件),以及 MiMo(Cookie 会话)。
133
148
  - **保存与验证分离**:凭据先确认持久化,再独立做在线验证;检测关闭的提供商只保存凭据、不发起验证,验证失败不代表保存失败。
package/lib/client.js CHANGED
@@ -264,7 +264,8 @@ window.__ModuleLoader__.load({
264
264
  "xai-oauth": { label: "SuperGrok", short: "SuperGrok", envName: "", managedByPlugin: true },
265
265
  // Codex / ChatGPT 订阅:凭据是 Codex CLI 的 OAuth 登录文件,只读、不刷新、不接受本地 Key。
266
266
  // 默认开启:它对齐的是 DSH 内置的 openai-codex 路由,没登录时会被「默认隐藏」自动收起。
267
- "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 },
268
269
  // 国际版默认关闭,中国版保持默认开启。
269
270
  "minimax": { label: "MiniMax (International)", short: "MiniMax", envName: "MINIMAX_API_KEY", defaultEnabled: false },
270
271
  "minimax-cn": { label: "MiniMax (CN)", short: "MiniMax CN", envName: "MINIMAX_CN_API_KEY" },
@@ -300,7 +301,17 @@ window.__ModuleLoader__.load({
300
301
  };
301
302
  // 顺序只影响展示:DeepSeek 放在末尾以免打乱既有条目位置;药丸匹配靠 provider id,与顺序无关。
302
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"];
303
- 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
+ }
304
315
  // provider id 就是路由 id,而路由由提供方插件/CLI 按「你实际订阅了什么」写入:例如 arkcli helper
305
316
  // 只会为真正持有的套餐写路由。所以本机没有 Coding Plan 路由时,就不该默认把 Coding Plan 显示出来
306
317
  // ——这比「常量里默认开启」更贴近事实。undefined = 拿不到路由表,按「未知」放行,不谎报未安装。
@@ -880,10 +891,27 @@ window.__ModuleLoader__.load({
880
891
  );
881
892
  }
882
893
 
883
- /** 单窗口行(药丸弹层与设置页共用):徽标 + 已用% 主数字 + 进度条 + 明细行。 */
894
+ /**
895
+ * 单窗口行(药丸弹层与设置页共用):徽标 + 已用% 主数字 + 进度条 + 明细行。
896
+ * 明细分两行:用量/适用范围一行,重置时间一行。挤在一行时「重置于 …(还有 …)」
897
+ * 会在窄弹层里被从中间折断,读起来像属性和数值分了家。
898
+ */
884
899
  function UsageWindowRow({ w, t, getLocale, uncertain = false }) {
885
900
  const tier = uncertain && w.status !== "rate-limited" ? { icon: "?", badge: "badgeUnknown", color: "#b7791f", rank: 2 } : usageTier(w);
886
901
  const blocked = w.status === "rate-limited";
902
+ const usageLine = [
903
+ w.detail && typeof w.detail.used === "number" && typeof w.detail.limit === "number" && w.detail.limit > 0
904
+ ? t("detailUsed").replace("{used}", fmtTokens(w.detail.used)).replace("{limit}", fmtTokens(w.detail.limit)).replace("{unit}", w.detail.unit || "")
905
+ : null,
906
+ // 不同额度线之间不可比(例如 AFP 的日限额只覆盖视觉 / 语音模型与 Harness):如实标出适用范围,
907
+ // 否则"日比周还高"会被读成插件算错。
908
+ w.detail && w.detail.note ? w.detail.note : null,
909
+ w.detail && w.detail.limitSource === "plan-snapshot" ? t("planSnapshotLimit") : null
910
+ ].filter(Boolean).join(" · ");
911
+ const resetParts = [
912
+ resetLine(w, t, getLocale),
913
+ w.cascade ? t("cascadeLimited") + (w.detail && w.detail.blockedBy ? ` [${w.detail.blockedBy.join(", ")}]` : "") : null
914
+ ].filter(Boolean).join(" · ");
887
915
  return h("div", {
888
916
  style: {
889
917
  marginTop: 8, padding: "8px 10px", borderRadius: 8,
@@ -907,21 +935,27 @@ window.__ModuleLoader__.load({
907
935
  max: 100, value: Math.min(100, w.percent),
908
936
  style: { width: "100%", height: 6, marginTop: 6, accentColor: tier.color }
909
937
  }),
910
- h("div", { style: { fontSize: 11, opacity: 0.7, marginTop: 4 } },
911
- [
912
- w.detail && typeof w.detail.used === "number" && typeof w.detail.limit === "number" && w.detail.limit > 0
913
- ? t("detailUsed").replace("{used}", fmtTokens(w.detail.used)).replace("{limit}", fmtTokens(w.detail.limit)).replace("{unit}", w.detail.unit || "")
914
- : null,
915
- // 不同额度线之间不可比(例如 AFP 的日限额只覆盖视觉 / 语音模型与 Harness):如实标出适用范围,
916
- // 否则"日比周还高"会被读成插件算错。
917
- w.detail && w.detail.note ? w.detail.note : null,
918
- w.detail && w.detail.limitSource === "plan-snapshot" ? t("planSnapshotLimit") : null,
919
- resetLine(w, t, getLocale),
920
- w.cascade ? t("cascadeLimited") + (w.detail && w.detail.blockedBy ? ` [${w.detail.blockedBy.join(", ")}]` : "") : null
921
- ].filter(Boolean).join(" · "))
938
+ usageLine || resetParts
939
+ ? h("div", { style: { fontSize: 11, opacity: 0.7, marginTop: 4 } },
940
+ usageLine ? h("div", { style: { overflowWrap: "anywhere" } }, usageLine) : null,
941
+ resetParts ? h("div", { style: { marginTop: usageLine ? 2 : 0, overflowWrap: "anywhere" } }, resetParts) : null)
942
+ : null
922
943
  );
923
944
  }
924
945
 
946
+ /**
947
+ * 窗口列表:额度线切换时插一条分组标题。AFP 的日限额只覆盖视觉 / 语音模型与 Harness,
948
+ * 与文本模型的 5 小时 / 周 / 月不是同一条线,混排会让人把两条线的配额当成一组互相比较。
949
+ * 弹层与设置页共用,避免只有其中一处带分组。
950
+ */
951
+ function UsageWindowList({ windows, t, getLocale, uncertain = false }) {
952
+ return (windows || []).map((w, index) => h(react.Fragment, { key: w.kind },
953
+ w.groupLabel && w.groupLabel !== windows[index - 1]?.groupLabel
954
+ ? h("p", { role: "note", style: { margin: index ? "14px 0 0" : "6px 0 0", fontSize: 11, opacity: 0.6 } }, w.groupLabel)
955
+ : null,
956
+ h(UsageWindowRow, { w, t, getLocale, uncertain })));
957
+ }
958
+
925
959
  function UsagePill({ providerId, label, readEntry, t, getLocale, cc, setCommandCodeAccount }) {
926
960
  const [snapshot, setSnapshot] = react.useState(null);
927
961
  const [failed, setFailed] = react.useState(null);
@@ -1075,11 +1109,7 @@ window.__ModuleLoader__.load({
1075
1109
  : null,
1076
1110
  // 额度线切换时插一条分组标题:AFP 的日限额只覆盖视觉 / 语音模型与 Harness,与文本模型的
1077
1111
  // 5 小时 / 周 / 月不是同一条线,混排会让人把两条线的配额当成一组来互相比较。
1078
- windows.map((w, index) => h(react.Fragment, { key: w.kind },
1079
- w.groupLabel && w.groupLabel !== windows[index - 1]?.groupLabel
1080
- ? h("p", { style: { margin: index ? "14px 0 0" : "6px 0 0", fontSize: 11, opacity: 0.6 } }, w.groupLabel)
1081
- : null,
1082
- h(UsageWindowRow, { w, t, getLocale, uncertain: degraded }))),
1112
+ UsageWindowList({ windows, t, getLocale, uncertain: degraded }),
1083
1113
  entry && entry.extras && entry.extras.length > 0
1084
1114
  ? h("div", { style: { marginTop: 10, opacity: 0.85 } },
1085
1115
  entry.extras.map((x) => h("div", { key: x.kind }, `${extraLabel(x, t)}: ${x.value}`)))
@@ -1400,12 +1430,18 @@ window.__ModuleLoader__.load({
1400
1430
  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")}`),
1401
1431
  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.")),
1402
1432
  // 启用的排到最前面,省得每次往下翻;组内保持 PROVIDER_ORDER 的既有顺序(sort 稳定)。
1403
- 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 => {
1433
+ // Ark 的 7 条路由合并成一张卡片:只渲染本机装了的那条作代表(都没装就渲染第一条),
1434
+ // 由它统一控制整组的检测开关与那组共享的 AK/SK。
1435
+ 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 => {
1436
+ const isArk = Boolean(PROVIDER_META[id].volc), arkPresent = isArk ? ARK_IDS.filter(key => providerRoutable(result, key)) : [];
1404
1437
  const enabled = providerEnabled(result.settings, id), e = entries.find(item => item.providerId === id), editing = tab === id && editorOpen;
1438
+ // Ark 卡片代表整组:标题用组名、开关写组级字段,并说明本机实际装了哪几条路由。
1439
+ const title = isArk ? text("火山方舟(Ark)", "Volcengine Ark") : PROVIDER_META[id].short;
1440
+ 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;
1405
1441
  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 } },
1406
- h("span", { style: { flex: 1, minWidth: 80 } }, PROVIDER_META[id].short),
1407
- 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")),
1408
- h("span", { style: { width: "100%", fontSize: 12, opacity: .75 } }, !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")),
1442
+ h("span", { style: { flex: 1, minWidth: 80 } }, title),
1443
+ 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")),
1444
+ 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")),
1409
1445
  h("details", { id: `subusage-credentials-${id}`, open: editing, style: { width: "100%" } },
1410
1446
  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")),
1411
1447
  tab === id ? credentialEditor : null));
@@ -1452,7 +1488,7 @@ window.__ModuleLoader__.load({
1452
1488
  h("span", { style: { flex: 1, minWidth: 150, fontSize: 12 } }, usageAction === "mimo-relogin" ? text("Cookie 已过期,重新登录即可恢复。", "Cookie expired — sign in again to restore.") : usageAction === "mimo-login" ? text("需要登录 MiMo(或 Cookie 即将到期)。", "MiMo sign-in needed (or the cookie is about to expire).") : text("这家还没有可用的凭据。", "No usable credentials for this provider.")),
1453
1489
  usageAction !== "credentials" ? h("button", { type: "button", style: primary, disabled: busy || loginActive, onClick: startLogin }, usageAction === "mimo-relogin" ? text("重新登录", "Sign in again") : text("登录并自动导入", "Sign in & import")) : null,
1454
1490
  h("button", { type: "button", style: usageAction === "credentials" ? primary : btnStyle, disabled: busy, onClick: () => revealEditor(usageId) }, usageAction === "credentials" ? text("配置凭据", "Configure credentials") : text("手动填写", "Enter manually"))) : null,
1455
- canShowUsage(usageEntry) && (usageEntry.windows || []).length ? (usageEntry.windows || []).map((w) => h(UsageWindowRow, { key: w.kind, w, t, getLocale, uncertain: usageEntry.coverage === "partial" || usageEntry.freshness === "stale" || usageEntry.state !== "ok" })) : h("p", { style: { opacity: .75 } }, usageEntry ? t("noUsageData") : t("loading")),
1491
+ canShowUsage(usageEntry) && (usageEntry.windows || []).length ? UsageWindowList({ windows: usageEntry.windows, t, getLocale, uncertain: usageEntry.coverage === "partial" || usageEntry.freshness === "stale" || usageEntry.state !== "ok" }) : h("p", { style: { opacity: .75 } }, usageEntry ? t("noUsageData") : t("loading")),
1456
1492
  canShowUsage(usageEntry) && usageEntry.extras ? h("p", { style: { opacity: .75 } }, usageEntry.extras.map((x) => `${extraLabel(x, t)}: ${x.value}`).join(" · ")) : null,
1457
1493
  usageEntry?.lastAttemptAt ? h("p", { style: { opacity: .65 } }, `${t("lastUpdated")} ${new Date(usageEntry.lastAttemptAt).toLocaleString(getLocale())}`) : null
1458
1494
  ) : 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.2", ...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.4", ...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) {
@@ -873,7 +883,9 @@ export class SubUsageService extends TypertRemoteService {
873
883
  if (apiError) throw volcFailure(action, apiError);
874
884
  return response;
875
885
  };
876
- const toWindows = parsed => cascadeRateLimited(parsed.windows.map(w => windowRow(w.kind, w.percent, w.resetsAt, w.percent >= 100 ? "rate-limited" : undefined, w.detail)));
886
+ // 额度线分组标注(AFP 的日限额只覆盖视觉 / 语音模型与 Harness)由 volcengine.js 的解析器产出,
887
+ // 这里是把它带进 wire 的唯一通道:windowRow 只认五个字段,漏掉 groupLabel 会让前端静默失去分组。
888
+ const toWindows = parsed => cascadeRateLimited(parsed.windows.map(w => ({ ...windowRow(w.kind, w.percent, w.resetsAt, w.percent >= 100 ? "rate-limited" : undefined, w.detail), ...(w.groupLabel ? { groupLabel: w.groupLabel } : {}) })));
877
889
  // 企业版/团队版:额度挂在席位上,先取 SeatID 再查该席位。
878
890
  if (spec.volcSeat) {
879
891
  const seatIds = parseVolcSeatIds(await call("ListSeatInfos", { Filter: {}, Scene: spec.volcScene, PageNum: 1, PageSize: 100 }));
@@ -1121,9 +1133,11 @@ export class SubUsageService extends TypertRemoteService {
1121
1133
  const id = patch.providerId;
1122
1134
  // 火山方舟两个 provider 共用一组 AK/SK:改凭据要让所有用它的条目失效,不能只失效当前标签页。
1123
1135
  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];
1136
+ 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
1137
  if (patch.visibility) {
1126
1138
  if (patch.visibility.hideWithoutApi !== undefined) stored.visibility.hideWithoutApi = patch.visibility.hideWithoutApi;
1139
+ // 组开关同时落到组内每一条:这样旧字段(逐条开关)与组开关始终一致,回退版本也不会读到矛盾状态。
1140
+ 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
1141
  Object.assign(stored.visibility.providers, patch.visibility.providers);
1128
1142
  }
1129
1143
  if (patch.keyMode !== undefined) stored.keyModes[id] = patch.keyMode;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-subusage",
3
3
  "displayName": "订阅用量显示",
4
- "version": "0.10.2",
4
+ "version": "0.10.4",
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",