dsh-subusage 0.8.1 → 0.8.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
  ## 安装与快速开始
8
8
 
9
- 当前仓库版本 **0.8.1**,可从 GitHub Release 获取。npm 自动发布采用 Trusted Publishing(GitHub OIDC);可用版本见 [npm](https://www.npmjs.com/package/dsh-subusage)。安装时使用目标 DSH 部署提供的插件管理器或 CLI,并启用本 bundle:
9
+ 当前仓库版本 **0.8.3**,可从 GitHub Release 获取。npm 自动发布采用 Trusted Publishing(GitHub OIDC);可用版本见 [npm](https://www.npmjs.com/package/dsh-subusage)。安装时使用目标 DSH 部署提供的插件管理器或 CLI,并启用本 bundle:
10
10
 
11
11
  | 来源 | 安装标识或下载地址 |
12
12
  |---|---|
@@ -29,7 +29,7 @@
29
29
 
30
30
  ### 药丸
31
31
 
32
- 选中已开启且未被默认隐藏的模型商时显示,例如 `✓ 余 87%`、`⚠ 7d 余 12%`、`✕ 7d 已达限额`。多窗口取最差一窗,点击看完整详情。Command Code 药丸弹层底部可切换提供方插件的服务账户(自动轮换 / 默认账户 / 各额外账户,与其设置页同一开关,即时生效);弹层额度数字始终为默认账户(顶层 Key)的用量,不随切换改变。关闭默认隐藏后,缺凭据时显示配置指引;可保留的临时网络错误会明确标注缓存,而非冒充当前成功。
32
+ 选中已开启且未被默认隐藏的模型商时显示,例如 `✓ 余 87%`、`⚠ 7d 余 12%`、`✕ 7d 已达限额`。多窗口取最差一窗,点击看完整详情。Command Code 药丸弹层底部可切换提供方插件的服务账户(自动轮换 / 默认账户 / 各额外账户,与其设置页同一开关,即时生效);切换后药丸与弹层上方的用量同步改为所选账户的额度(自动轮换时仍显示默认账户/顶层 Key 的用量,弹层有标注说明)。关闭默认隐藏后,缺凭据时显示配置指引;可保留的临时网络错误会明确标注缓存,而非冒充当前成功。
33
33
 
34
34
  ### 设置
35
35
 
@@ -51,7 +51,7 @@
51
51
  | Z.ai 国际 | `ZAI_CODING_API_KEY` | `zai-coding`,默认关闭,独立国际 Coding Plan Key |
52
52
  | Synthetic | `SYNTHETIC_API_KEY` | `synthetic`,默认关闭,模型订阅请求额度 |
53
53
  | NanoGPT | `NANOGPT_API_KEY` | `nanogpt`,默认关闭;也可手动填写 Usage only 管理令牌 |
54
- | Kimi | `KIMI_CODING_API_KEY` | 需要 Kimi Coding Key,不是 Moonshot 开平台 Key |
54
+ | Kimi | `KIMI_CODING_API_KEY` | 需要 Kimi Coding Key,不是 Moonshot 开平台 Key;窗口集合按套餐体系下发(老套餐 5 小时 + 7 天,新套餐 Go / Plus 为 5 小时 + 月度) |
55
55
  | MiMo | 不使用 API Key | 通过控制台 Cookie 会话读取 |
56
56
  | OpenCode Go | `OPENCODE_API_KEY` | OpenCode Go Key |
57
57
  | MiniMax 国际 | `MINIMAX_API_KEY` | `minimax` 路由;国际站订阅 Key |
@@ -100,7 +100,7 @@ JSON 中有域名的条目按 `platform.xiaomimimo.com` 的 Cookie 域规则过
100
100
  | Z.ai 国际 | 5 小时、每周(按响应) | Token / Credits 配额,区域凭据独立 |
101
101
  | Synthetic | 滚动订阅池 | 已用/总请求次数、重置时间 |
102
102
  | NanoGPT | 每日、每周、试用周期(按响应) | 输入 Token 数、重置时间;不包含图像额度 |
103
- | Kimi | 5 小时、7 天 | 已用百分比、套餐档 |
103
+ | Kimi | 5 小时、7 天、月度(按账户下发) | 已用百分比、套餐档 |
104
104
  | MiMo | 本周期额度池 | 用量明细、重置时间、套餐与余额 |
105
105
  | OpenCode Go | 滚动、每周、每月 | 已用百分比、重置时间 |
106
106
  | MiniMax 国际 / 中国 | 短周期(通常 5 小时)、每周 | 通用/编程池已用百分比、可信计数明细、重置时间 |
@@ -108,6 +108,8 @@ JSON 中有域名的条目按 `platform.xiaomimimo.com` 的 Cookie 域规则过
108
108
 
109
109
  非公开控制台接口可能变更;缺失或非法百分比不会当作零用量。部分数据、未知额度和暂时失败有明确状态,不能据此保证推理接口一定可用。
110
110
 
111
+ Kimi 的窗口集合随套餐体系变化:老套餐(节奏命名,如 Allegro)返回 5 小时与 7 天,新套餐(Go / Plus 命名)返回 5 小时与月度总额,因此同一插件在不同账号上显示的窗口数可以不同;月度池里的 Code 份额不是独立预算,不单独成窗。插件按接口实际下发的窗口解析,不要求固定集合。
112
+
111
113
 
112
114
  ## 常见问题
113
115
 
@@ -116,6 +118,7 @@ JSON 中有域名的条目按 `platform.xiaomimimo.com` 的 Cookie 域规则过
116
118
  | 设置页没有提供商标签 | 展开提供商管理,确认开关和凭据;无凭据的条目默认隐藏,可临时关闭自动隐藏查看指引 |
117
119
  | 已配置凭据却没有药丸 | 确认当前模型的提供商 ID 与支持表一致、检测已开启;自定义 ID 可在设置页查看,但不会自动映射 |
118
120
  | 显示认证失效 | 检查是否用了对应产品/区域的订阅 Key;MiMo 重新登录或导入 Cookie;旧额度不会当作有效数据保留 |
121
+ | Kimi 缺少 7 天窗口,或提示结构错误 | 窗口集合按套餐体系下发:老套餐(节奏命名,如 Allegro)为 5 小时 + 7 天,新套餐(Go / Plus 命名)为 5 小时 + 月度总额。0.8.3 起按实际下发的窗口解析,不再要求 7 天窗口;升级后仍报「Invalid Kimi usage response」即为未识别的字段结构,插件不会猜测额度,可回报该响应 |
119
122
  | 显示缓存、部分数据或额度未知 | 查看更新时间和错误说明;缓存来自之前的成功读取,部分数据不保证有可用额度,余额也不等于订阅余量 |
120
123
  | 刷新后数字暂未变化 | 成功结果按提供商缓存 60 秒;手动刷新可跳过普通 TTL,但仍遵守限流退避和 Retry-After |
121
124
  | MiMo 自动登录无法启动 | Host 所在机器需安装 Google Chrome 并有桌面环境;远程或无桌面部署使用手动导入 |
@@ -124,7 +127,7 @@ JSON 中有域名的条目按 `platform.xiaomimimo.com` 的 Cookie 域规则过
124
127
  | 保存成功但验证失败 | 凭据已保存,检查网络、区域或账号后重试;保存与在线验证分别反馈 |
125
128
  | 保存提示配置已改变 | 另一窗口或实例更新了设置;重新读取配置后再编辑,避免旧表单覆盖新值 |
126
129
  | 升级后仍是旧界面或 RPC 不匹配 | 确认运行中的 Host 和 Client 都已重载;仅刷新页面不能保证 Host 升级 |
127
- | Command Code 额度与实际账户不同 | 弹层额度始终读取默认顶层账户,不随账户切换改变;可在药丸弹层切换提供方插件的服务账户(activeAccount),多账户轮换或提供方自定义 apiBase 仍不跟随 |
130
+ | Command Code 额度与实际账户不同 | 可在药丸弹层切换提供方插件的服务账户(activeAccount),切换后药丸与弹层用量同步显示所选账户的额度;「自动轮换」时仍显示默认账户(顶层 Key)的用量,不跟随轮换中的实际服务账户,提供方自定义 apiBase 也不跟随 |
128
131
 
129
132
  ## 提供商 ID 与默认开关
130
133
 
package/lib/client.js CHANGED
@@ -106,13 +106,15 @@ window.__ModuleLoader__.load({
106
106
  pillNeedCommandcode: "需配置 —— 可在 设置 → Command Code 登录、运行 cmd login,或导出 COMMANDCODE_API_KEY 后重试。",
107
107
  accountLabel: "账户",
108
108
  commandcodeManaged: "凭据由 Command Code 提供方插件(设置 → Command Code)管理;本页只读、不保存凭据。用量由本插件直接拉取其计费接口,取用同一凭据来源(凭据服务 COMMANDCODE_API_KEY → 启动环境 → ~/.commandcode/auth.json)。",
109
- commandcodeHelp: "并行读取 /alpha/billing/credits 和 /alpha/billing/subscriptions(默认 API 地址 https://api.commandcode.ai)。月总额按已知套餐快照推算,未知套餐只显示月剩余;月池耗尽不代表已购/赠送池不可用。多账户轮换或固定 activeAccount 时这里显示默认账户(顶层 Key)的额度,可能与实际服务账户不同;插件里自定义 apiBase 不会被跟随。",
109
+ commandcodeHelp: "并行读取 /alpha/billing/credits 和 /alpha/billing/subscriptions(默认 API 地址 https://api.commandcode.ai)。月总额按已知套餐快照推算,未知套餐只显示月剩余;月池耗尽不代表已购/赠送池不可用。显示药丸弹层中选择的账户的额度(未选择时为默认账户/顶层 Key),可能与实际服务账户不同;插件里自定义 apiBase 不会被跟随。",
110
110
  ccAccountsTitle: "Command Code 账户",
111
111
  ccAccountAuto: "自动轮换",
112
112
  ccAccountDefault: "默认账户",
113
113
  ccAccountActive: "使用中",
114
- ccAccountsHint: "切换对 Command Code 提供方插件全局生效(与其设置页同一开关);上方额度数字始终是默认账户(顶层 Key)的用量,不随切换改变。",
114
+ ccAccountsHint: "切换对 Command Code 提供方插件全局生效(与其设置页同一开关);上方用量同步切换为所选账户的额度。",
115
115
  ccAccountsUnavailable: "账户列表不可用(需要 Command Code 提供方插件在运行)",
116
+ ccUsageAccount: "上方用量:{account}",
117
+ ccUsageAutoNote: "自动轮换时上方用量仍是默认账户(顶层 Key),不跟随实际服务账户",
116
118
  ccSwitching: "切换中…",
117
119
  ccSwitchFailed: "切换失败"
118
120
  };
@@ -204,13 +206,15 @@ window.__ModuleLoader__.load({
204
206
  pillNeedCommandcode: "Setup required — sign in under Settings → Command Code, run cmd login, or export COMMANDCODE_API_KEY and retry.",
205
207
  accountLabel: "Account",
206
208
  commandcodeManaged: "Credentials are managed by the Command Code provider plugin (Settings → Command Code); this page is read-only and stores nothing. Usage is fetched directly by this plugin from its billing endpoint, using the same credential sources (credential service COMMANDCODE_API_KEY → launch environment → ~/.commandcode/auth.json).",
207
- commandcodeHelp: "Parallel requests to /alpha/billing/credits and /alpha/billing/subscriptions (default API base https://api.commandcode.ai). Monthly totals use a known-plan snapshot; unknown plans show remaining credits only. Monthly-pool depletion does not imply purchased/free credits are unusable. With account rotation or a pinned activeAccount this shows the default (top-level key) account's quota, which may differ from the serving account; a custom apiBase in the provider plugin is not followed.",
209
+ commandcodeHelp: "Parallel requests to /alpha/billing/credits and /alpha/billing/subscriptions (default API base https://api.commandcode.ai). Monthly totals use a known-plan snapshot; unknown plans show remaining credits only. Monthly-pool depletion does not imply purchased/free credits are unusable. Shows the quota of the account selected in the pill popover (the default/top-level key account when none is selected), which may differ from the serving account; a custom apiBase in the provider plugin is not followed.",
208
210
  ccAccountsTitle: "Command Code accounts",
209
211
  ccAccountAuto: "Automatic (rotation order)",
210
212
  ccAccountDefault: "Default account",
211
213
  ccAccountActive: "in use",
212
- ccAccountsHint: "Switching applies globally to the Command Code provider plugin (same switch as its settings page); the quota numbers above always reflect the default (top-level key) account and do not follow the switch.",
214
+ ccAccountsHint: "Switching applies globally to the Command Code provider plugin (same switch as its settings page); the usage above switches to the selected account's quota.",
213
215
  ccAccountsUnavailable: "Account list unavailable (requires the Command Code provider plugin to be running)",
216
+ ccUsageAccount: "Usage above: {account}",
217
+ ccUsageAutoNote: "with automatic rotation the usage above stays on the default (top-level key) account, not the serving one",
214
218
  ccSwitching: "Switching…",
215
219
  ccSwitchFailed: "Switch failed"
216
220
  };
@@ -402,7 +406,7 @@ window.__ModuleLoader__.load({
402
406
  namespace: "subUsage",
403
407
  method: "read",
404
408
  invocation: { kind: "direct" },
405
- parameters: [],
409
+ parameters: [{ name: "request", wire: "request", source: "json", acceptsUndefined: true, codec: queryCodec }],
406
410
  result: resultCodec
407
411
  },
408
412
  {
@@ -527,6 +531,9 @@ window.__ModuleLoader__.load({
527
531
  let snapshot = { updatedAt: null, settings: null, configured: {}, entries: [] };
528
532
  let disposed = false, initial = null, timer = null, epoch = 0, sequence = 0, metadataSequence = 0;
529
533
  let loginGeneration = 0, loginJobId = null, loginPending = false;
534
+ // Command Code 显示账户:弹层账户区选中的账户("" = 默认账户/顶层 Key)。
535
+ // 所有 commandcode 读取(readAll/refresh/定时刷新)都携带它,切换后用量同步跟随。
536
+ let ccAccount = "";
530
537
  const mergedLoginJobs = new Set();
531
538
  const providerSequence = new Map();
532
539
  const markRequest = (ids) => { const version = ++sequence; for (const id of ids) providerSequence.set(id, version); return { version, ids }; };
@@ -555,7 +562,7 @@ window.__ModuleLoader__.load({
555
562
  const missing = unique.filter((id) => !inflight.has(id));
556
563
  if (missing.length) {
557
564
  const startedEpoch = epoch, request = markRequest(missing);
558
- const task = Promise.resolve().then(() => call("refresh", { providerIds: missing, force: !!force })).then((value) => startedEpoch === epoch ? merge(value, request) : snapshot).catch((error) => {
565
+ const task = Promise.resolve().then(() => call("refresh", { providerIds: missing, force: !!force, commandCodeAccount: ccAccount })).then((value) => startedEpoch === epoch ? merge(value, request) : snapshot).catch((error) => {
559
566
  // 传输失败不能擅自 retainPrevious:只有 Host 明确标 stale 才保留。
560
567
  if (startedEpoch === epoch) merge({ entries: missing.map((providerId) => ({ providerId, state: "error", error: error instanceof Error ? error.message : String(error), freshness: "unknown", coverage: "partial", windows: [], extras: [] })) }, request);
561
568
  throw error;
@@ -594,12 +601,24 @@ window.__ModuleLoader__.load({
594
601
  }
595
602
  return value;
596
603
  };
604
+ const setCommandCodeAccount = (id) => {
605
+ const next = typeof id === "string" ? id.trim() : "";
606
+ if (disposed || ccAccount === next) return Promise.resolve(snapshot);
607
+ ccAccount = next;
608
+ // 账户变了:旧账户的在途/缓存数据立即失效,强制按新账户重拉。
609
+ invalidate(["commandcode"]);
610
+ // invalidate 只重写快照不通知:补一次 emit 让药丸立即进入加载态,
611
+ // 避免强制刷新返回前「标注已是新账户、数字还是旧账户」的瞬态错标。
612
+ emit();
613
+ return refresh(["commandcode"], true);
614
+ };
597
615
  return {
598
616
  subscribe, getSnapshot: () => snapshot, refresh, activate,
617
+ getCommandCodeAccount: () => ccAccount, setCommandCodeAccount,
599
618
  startMimoLogin: async (request) => { const generation = ++loginGeneration, startedEpoch = epoch; loginJobId = null; return acceptLogin(await call("startMimoLogin", request), generation, startedEpoch); },
600
619
  getMimoLoginStatus: async () => { const generation = loginGeneration, startedEpoch = epoch, jobId = loginPending ? loginJobId : null; return acceptLogin(await call("getMimoLoginStatus"), generation, startedEpoch, jobId); },
601
620
  cancelMimoLogin: async (request) => { if (loginJobId && request.jobId !== loginJobId) return null; const generation = ++loginGeneration, startedEpoch = epoch; return acceptLogin(await call("cancelMimoLogin", request), generation, startedEpoch, request.jobId); },
602
- readAll: () => { if (!initial) { const startedEpoch = epoch, request = markRequest(PROVIDER_ORDER); initial = Promise.resolve().then(() => call("read")).then((value) => startedEpoch === epoch ? merge(value, request) : snapshot).catch((error) => { initial = null; throw error; }); } return initial.then(() => snapshot); },
621
+ readAll: () => { if (!initial) { const startedEpoch = epoch, request = markRequest(PROVIDER_ORDER); initial = Promise.resolve().then(() => call("read", { providerIds: PROVIDER_ORDER, force: false, commandCodeAccount: ccAccount })).then((value) => startedEpoch === epoch ? merge(value, request) : snapshot).catch((error) => { initial = null; throw error; }); } return initial.then(() => snapshot); },
603
622
  save: async (patch) => {
604
623
  const value = await call("save", patch); epoch++;
605
624
  if (patch.visibility) { const entries = snapshot.entries; markRequest(PROVIDER_ORDER); return merge({ ...value, entries }, null, true); }
@@ -628,8 +647,10 @@ window.__ModuleLoader__.load({
628
647
 
629
648
  // ── Command Code 账户切换(对齐提供方插件的账户显示)────────────────────
630
649
  // 账户列表与 id 规则对齐 @mars-sea/dsh-commandcode-provider 的 slots():
631
- // 默认账户 id 固定 "default";额外账户 id 取其 apiKeyEnv(凭据引用名),
632
- // 缺失时按位置回退 account-N。enrollment 进行中的账户不进列表(与其一致)。
650
+ // 默认账户 id 固定 "default";额外账户 id 取其 apiKeyEnv(凭据引用名)。
651
+ // 与 slots() 的差异:仅配置 literal apiKey(无凭据引用)的槽位不进列表——
652
+ // 其 Key 存在提供方配置内,本插件无法按引用解析,列出只会选中即报 no-key。
653
+ // enrollment 进行中的账户不进列表(与其一致)。
633
654
  const COMMANDCODE_SETTINGS_NS = "llm-commandcode";
634
655
  function commandCodeAccountList(value) {
635
656
  const list = [{ id: "default", label: "default" }];
@@ -779,7 +800,7 @@ window.__ModuleLoader__.load({
779
800
  );
780
801
  }
781
802
 
782
- function UsagePill({ providerId, label, readEntry, t, getLocale, cc }) {
803
+ function UsagePill({ providerId, label, readEntry, t, getLocale, cc, setCommandCodeAccount }) {
783
804
  const [snapshot, setSnapshot] = react.useState(null);
784
805
  const [failed, setFailed] = react.useState(null);
785
806
  const [failState, setFailState] = react.useState(void 0);
@@ -790,6 +811,14 @@ window.__ModuleLoader__.load({
790
811
  // Command Code 账户区:cc 由装配层注入(提供方插件未运行时为 null,区块整体隐藏)。
791
812
  const ccState = react.useSyncExternalStore(cc ? cc.subscribe : () => () => {}, cc ? cc.getSnapshot : () => null);
792
813
  react.useEffect(() => { if (open && cc) void cc.load(); }, [open, cc]);
814
+ // 挂载即读一次账户配置:提供方插件固定了账户时,药丸本体不必等弹层打开就跟随。
815
+ react.useEffect(() => { if (providerId === "commandcode" && cc) void cc.load(); }, [providerId, cc]);
816
+ // 显示账户跟随账户区的当前固定项(含切换成功后的回读):ready 才同步,
817
+ // unavailable/loading 保持现状;store 内部对相同账户去重,不产生多余请求。
818
+ react.useEffect(() => {
819
+ if (providerId !== "commandcode" || !ccState || !setCommandCodeAccount) return;
820
+ if (ccState.status === "ready") void setCommandCodeAccount(ccState.activeId);
821
+ }, [providerId, setCommandCodeAccount, ccState && ccState.status, ccState && ccState.activeId]);
793
822
  react.useEffect(() => {
794
823
  let alive = true;
795
824
  let busy = false;
@@ -916,6 +945,12 @@ window.__ModuleLoader__.load({
916
945
  cacheAge(rawEntry, t) ? h("p", { style: { color: "#b7791f" } }, cacheAge(rawEntry, t)) : null,
917
946
  // 到期提醒常驻弹层:即使用量正常,也让用户看见 Cookie 还剩多久。
918
947
  expiry ? h("p", { role: "status", style: { margin: "6px 0 0", color: expiring ? expiry.color : "inherit" } }, `${expiry.label} · ${t("mimoCookieExpiresAt").replace("{time}", new Date(expiry.expiresAt).toLocaleString(getLocale()))}`) : null,
948
+ // 用量账户标注:账户区 ready 时说明上方数字属于哪个账户;自动轮换时明确不跟随实际服务账户。
949
+ providerId === "commandcode" && ccState && ccState.status === "ready"
950
+ ? h("p", { style: { margin: "6px 0 0", fontSize: 11, opacity: 0.75 } },
951
+ t("ccUsageAccount").replace("{account}", ccState.activeId === "" || ccState.activeId === "default" ? t("ccAccountDefault") : (ccState.accounts.find((a) => a.id === ccState.activeId)?.label ?? ccState.activeId)),
952
+ ccState.activeId === "" ? h("span", { style: { opacity: 0.85 } }, ` · ${t("ccUsageAutoNote")}`) : null)
953
+ : null,
919
954
  windows.map((w) => h(UsageWindowRow, { key: w.kind, w, t, getLocale, uncertain: degraded })),
920
955
  entry && entry.extras && entry.extras.length > 0
921
956
  ? h("div", { style: { marginTop: 10, opacity: 0.85 } },
@@ -1263,13 +1298,13 @@ window.__ModuleLoader__.load({
1263
1298
  ctx.inject(["modelDirectories"], (scope) => {
1264
1299
  try {
1265
1300
  scope.slots.inject("conversation.input.right", () => scope.slots.register({ name: "conversation.input.right", id: "subusage-usage", order: 1001,
1266
- inject: (sessionId) => ({ store: scope.modelDirectories.directoryFor(sessionId).store, readEntry, t, getLocale, cc }) }, SubusagePillEntry));
1301
+ inject: (sessionId) => ({ store: scope.modelDirectories.directoryFor(sessionId).store, readEntry, t, getLocale, cc, setCommandCodeAccount: usageStore.setCommandCodeAccount }) }, SubusagePillEntry));
1267
1302
  } catch (error) { console.error("[dsh-subusage] 药丸注册失败:", error); }
1268
1303
  });
1269
1304
  }
1270
1305
 
1271
1306
  /** 槽包装:先看当前模型的 provider,再分发到对应药丸。 */
1272
- function SubusagePillEntry({ store, readEntry, t, getLocale, cc }) {
1307
+ function SubusagePillEntry({ store, readEntry, t, getLocale, cc, setCommandCodeAccount }) {
1273
1308
  const state = react.useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
1274
1309
  const provider = state.current && state.current.provider;
1275
1310
  const info = PROVIDER_ORDER.includes(provider) ? PROVIDER_META[provider] : null;
@@ -1286,7 +1321,8 @@ window.__ModuleLoader__.load({
1286
1321
  readEntry: reader,
1287
1322
  t,
1288
1323
  getLocale,
1289
- cc
1324
+ cc,
1325
+ setCommandCodeAccount
1290
1326
  });
1291
1327
  }
1292
1328
 
package/lib/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // dsh-subusage Host:秘密仅保存在 Host,远端只暴露 provider 补丁和公开设置。
2
2
  import { RemoteError, TypertRemoteService } from "@deepseek-ai/dsh-typert-protocol";
3
- import { credentialRef } from "@deepseek-ai/dsh-credentials";
3
+ import { credentialRef, isCredentialRefName } from "@deepseek-ai/dsh-credentials";
4
4
  import { launchEnvironmentOf } from "@deepseek-ai/dsh-launch-environment";
5
5
  import { chmodSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
6
  import { createHash } from "node:crypto";
@@ -133,15 +133,21 @@ function parseSettings(value) {
133
133
  return out;
134
134
  }
135
135
  function parseQuery(value) {
136
+ // request 在 wire 层可选(read 描述符声明 acceptsUndefined):缺参/undefined/null
137
+ // = 全量默认账户查询,兼容旧 client bundle 的无参 read。
138
+ if (value === undefined || value === null) return { providerIds: [...IDS], force: false, commandCodeAccount: "" };
136
139
  if (!value || !Array.isArray(value.providerIds) || value.providerIds.some(id => !IDS.includes(id)) || typeof value.force !== "boolean") throw invalid("Invalid refresh request");
137
- return { providerIds: [...new Set(value.providerIds)], force: value.force };
140
+ // commandCodeAccount 是可选的显示账户选择:空串/缺省 = 默认账户(顶层 Key);
141
+ // 额外账户 id 即其凭据引用名(apiKeyEnv),由 identity 按引用解析。
142
+ if (value.commandCodeAccount !== undefined && typeof value.commandCodeAccount !== "string") throw invalid("Invalid account selection");
143
+ return { providerIds: [...new Set(value.providerIds)], force: value.force, commandCodeAccount: typeof value.commandCodeAccount === "string" ? value.commandCodeAccount.trim() : "" };
138
144
  }
139
145
  function failure(code, message, retryable = false, retryAfterMs) {
140
146
  return new RemoteError(code, message, { retryable, retainPrevious: retryable, ...(retryAfterMs !== undefined ? { retryAfterMs } : {}) });
141
147
  }
142
148
  async function fetchJson(fetcher, url, headers, lifetimeSignal) {
143
149
  let response;
144
- try { response = await fetcher(url, { headers: { accept: "application/json", "user-agent": "dsh-subusage/0.8.1", ...headers }, signal: lifetimeSignal ? AbortSignal.any([AbortSignal.timeout(10000), lifetimeSignal]) : AbortSignal.timeout(10000), redirect: "manual" }); }
150
+ try { response = await fetcher(url, { headers: { accept: "application/json", "user-agent": "dsh-subusage/0.8.3", ...headers }, signal: lifetimeSignal ? AbortSignal.any([AbortSignal.timeout(10000), lifetimeSignal]) : AbortSignal.timeout(10000), redirect: "manual" }); }
145
151
  catch { throw failure("subusage/network", "网络请求失败或超时", true); }
146
152
  if (response.status >= 300 && response.status < 400 || response.status === 401 || response.status === 403) throw failure("subusage/auth", "登录或凭据已失效");
147
153
  if (!response.ok) {
@@ -179,10 +185,32 @@ function cascadeRateLimited(windows) {
179
185
  return windows;
180
186
  }
181
187
  export function normalizeKimi(body) {
182
- // 已知旧版 usages 和新版 usage/limits 的显式命名窗口;不推断未知字段。
183
- const u = body?.usages ?? body?.usage ?? body?.limits;
184
- if (!u || !u.limit_5h || !u.limit_7d) throw failure("subusage/response", "Invalid Kimi usage response");
185
- return cascadeRateLimited(["5h", "7d"].map(kind => { const row = u[`limit_${kind}`]; if (!numeric(row.used_ratio)) throw failure("subusage/response", "Invalid Kimi used_ratio"); return windowRow(kind, row.used_ratio * 100, row.reset_time); }));
188
+ // 上游 /coding/v1/usages 有两代形态:新版 usages 比例池(limit_5h / limit_7d /
189
+ // limit_month_total),旧版顶层 usage + limits[](绝对 limit / used / remaining)。
190
+ // 只有 300 分钟窗口在所有形态都下发,因此没有哪个窗口名可以当必需字段:现行账户
191
+ // 下发 5h + 月池而省略 limit_7d,旧账户则完全没有 usages。不推断未知字段。
192
+ const pools = [body?.usages, body?.usage, body?.limits].filter(v => v && typeof v === "object" && !Array.isArray(v));
193
+ const windows = [];
194
+ // limit_month_code 是 month_total 的 Code 份额而非独立预算,不作为独立窗口。
195
+ for (const [field, kind] of [["limit_5h", "5h"], ["limit_7d", "7d"], ["limit_month_total", "month"]]) {
196
+ const row = pools.map(pool => pool[field]).find(v => v !== undefined);
197
+ if (row === undefined) continue;
198
+ if (!numeric(row.used_ratio)) throw failure("subusage/response", "Invalid Kimi used_ratio");
199
+ windows.push(windowRow(kind, row.used_ratio * 100, row.reset_time));
200
+ }
201
+ // 无比例池时退回绝对值:limits[] 的 300 分钟项是 5 小时窗口,顶层 usage 是周额度。
202
+ if (!windows.length) {
203
+ const absolute = (row, kind) => {
204
+ const limit = Number(row?.limit), remaining = Number(row?.remaining);
205
+ if (!Number.isFinite(limit) || limit <= 0 || !Number.isFinite(remaining)) return;
206
+ windows.push(windowRow(kind, (limit - remaining) / limit * 100, row.resetTime));
207
+ };
208
+ const burst = (Array.isArray(body?.limits) ? body.limits : []).find(row => row?.window?.duration === 300 && row.window.timeUnit === "TIME_UNIT_MINUTE");
209
+ if (burst) absolute(burst.detail, "5h");
210
+ if (body?.usage && typeof body.usage === "object") absolute(body.usage, "week");
211
+ }
212
+ if (!windows.length) throw failure("subusage/response", "Invalid Kimi usage response");
213
+ return cascadeRateLimited(windows);
186
214
  }
187
215
  export function normalizeZai(body) {
188
216
  const data = body?.data;
@@ -341,7 +369,7 @@ const loginCodec = codec("SubUsageLoginState", value => {
341
369
  export const subUsageRemote = {
342
370
  package: name,
343
371
  descriptors: [
344
- { id: `${name}#subUsage/read`, service: "subUsage", namespace: "subUsage", method: "read", invocation: { kind: "direct" }, parameters: [], result: resultCodec },
372
+ { id: `${name}#subUsage/read`, service: "subUsage", namespace: "subUsage", method: "read", invocation: { kind: "direct" }, parameters: [{ name: "request", wire: "request", source: "json", acceptsUndefined: true, codec: codec("SubUsageQuery", parseQuery) }], result: resultCodec },
345
373
  ...[["refresh", "request", "SubUsageQuery", parseQuery], ["save", "settings", "SubUsageSettings", parseSettings]].map(([method, param, type, parse]) => ({ id: `${name}#subUsage/${method}`, service: "subUsage", namespace: "subUsage", method, invocation: { kind: "direct" }, parameters: [{ name: param, wire: param, source: "json", codec: codec(type, parse) }], result: resultCodec })),
346
374
  { id: `${name}#subUsage/getMimoLoginStatus`, service: "subUsage", namespace: "subUsage", method: "getMimoLoginStatus", invocation: { kind: "direct" }, parameters: [], result: loginCodec },
347
375
  ...[["startMimoLogin", "MimoLoginStart", parseLoginStart], ["cancelMimoLogin", "MimoLoginCancel", parseLoginCancel]].map(([method, type, parse]) => ({ id: `${name}#subUsage/${method}`, service: "subUsage", namespace: "subUsage", method, invocation: { kind: "direct" }, parameters: [{ name: "request", wire: "request", source: "json", codec: codec(type, parse) }], result: loginCodec }))
@@ -357,6 +385,10 @@ export class SubUsageService extends TypertRemoteService {
357
385
  this.now = options.now ?? Date.now;
358
386
  this.credentials = options.resolveCredentials ?? (async id => await ctx.get("credentials")?.resolve(credentialRef(PROVIDERS[id].envName)));
359
387
  this.environment = options.resolveEnvironment ?? (id => launchEnvironmentOf(ctx).get(PROVIDERS[id].envName));
388
+ // 额外账户按凭据引用名(apiKeyEnv)解析,与提供方插件 slots()/resolveRef 同一通道;
389
+ // 默认账户仍走上面的 envName 链(凭据服务 → 启动环境 → ~/.commandcode/auth.json)。
390
+ this.accountCredentials = options.resolveAccountCredentials ?? (async ref => await ctx.get("credentials")?.resolve(credentialRef(ref)));
391
+ this.accountEnvironment = options.resolveAccountEnvironment ?? (ref => launchEnvironmentOf(ctx).get(ref));
360
392
  this.authFile = options.resolveAuthFile ?? (async () => commandCodeAuthFileKey(this.io));
361
393
  this.cache = new Map(); this.inflight = new Map(); this.generations = new Map(); this.fingerprints = new Map(); this.disposed = false; this.controller = new AbortController();
362
394
  this.login = new MimoLogin({
@@ -379,9 +411,19 @@ export class SubUsageService extends TypertRemoteService {
379
411
  catch (e) { if (e?.code === "ENOENT") return defaultSettings(); throw failure("subusage/config", "无法读取订阅设置"); }
380
412
  }
381
413
  result(settings, entries) { return { updatedAt: new Date(this.now()).toISOString(), settings: publicSettings(settings), configured: detectConfigured(this.ctx), entries }; }
382
- async identity(id, s) {
414
+ async identity(id, s, account) {
383
415
  if (id === "xiaomi-token-plan-cn") return { key: s.xiaomi.cookie.trim(), source: s.xiaomi.cookie.trim() ? "cookie" : "none" };
384
416
  if (!PROVIDERS[id].managedByPlugin && s.keyModes[id] === "manual") return { key: s.keys[id]?.trim(), source: s.keys[id]?.trim() ? "manual" : "none" };
417
+ if (id === "commandcode" && account && account !== "default") {
418
+ // 额外账户:id 即凭据引用名(apiKeyEnv)。引用名不合法(不可能来自提供方
419
+ // 插件的 slots,但 client 输入不可信)按未配置处理,不让 credentialRef 抛错。
420
+ if (!isCredentialRefName(account)) return { key: undefined, source: "none" };
421
+ const hit = await this.accountCredentials(account);
422
+ if (typeof hit?.value === "string" && hit.value.trim()) return { key: hit.value.trim(), source: "credentials" };
423
+ const env = await this.accountEnvironment(account);
424
+ if (typeof env?.value === "string" && env.value.trim()) return { key: env.value.trim(), source: "env" };
425
+ return { key: undefined, source: "none" };
426
+ }
385
427
  const hit = await this.credentials(id);
386
428
  if (typeof hit?.value === "string" && hit.value.trim()) return { key: hit.value.trim(), source: "credentials" };
387
429
  const env = await this.environment(id);
@@ -432,23 +474,27 @@ export class SubUsageService extends TypertRemoteService {
432
474
  if (usageError) data.usageError = usageError;
433
475
  return data;
434
476
  }
435
- base(id, source, settings) {
477
+ base(id, source, settings, account = "") {
436
478
  // MiMo 会话 Cookie 自签发起 24 小时有效;到期时间随每条 entry 下发,供药丸与设置页倒计时。
437
479
  const expiresAt = id === "xiaomi-token-plan-cn" ? isoStamp(settings?.xiaomi?.expiresAt) : null;
438
- return { providerId: id, label: PROVIDERS[id].label, envName: PROVIDERS[id].envName, keySource: source, apiDetected: source === "none" ? null : true, coverage: "partial", freshness: "unknown", lastSuccessAt: null, lastAttemptAt: new Date(this.now()).toISOString(), retryable: false, retainPrevious: false, ...(expiresAt ? { cookieExpiresAt: expiresAt } : {}) };
480
+ // commandcode 记录本条用量所属账户(default 或额外账户引用名),供弹层标注与测试核对。
481
+ const shown = id === "commandcode" ? { account: account !== "" ? account : "default" } : {};
482
+ return { providerId: id, label: PROVIDERS[id].label, envName: PROVIDERS[id].envName, keySource: source, apiDetected: source === "none" ? null : true, coverage: "partial", freshness: "unknown", lastSuccessAt: null, lastAttemptAt: new Date(this.now()).toISOString(), retryable: false, retainPrevious: false, ...shown, ...(expiresAt ? { cookieExpiresAt: expiresAt } : {}) };
439
483
  }
440
- async provider(id, settings, force) {
484
+ async provider(id, settings, force, account = "") {
441
485
  const startedGeneration = this.generations.get(id) ?? 0;
442
486
  let identity;
443
- try { identity = await this.identity(id, settings); }
444
- catch { this.cache.delete(id); return { ...this.base(id, "none", settings), state: "error", errorCode: "subusage/credentials", error: "无法解析凭据" }; }
487
+ try { identity = await this.identity(id, settings, account); }
488
+ catch { this.cache.delete(id); return { ...this.base(id, "none", settings, account), state: "error", errorCode: "subusage/credentials", error: "无法解析凭据" }; }
445
489
  if (this.disposed) throw failure("subusage/disposed", "服务已卸载");
446
- if (startedGeneration !== (this.generations.get(id) ?? 0)) return this.provider(id, this.load(), force);
490
+ if (startedGeneration !== (this.generations.get(id) ?? 0)) return this.provider(id, this.load(), force, account);
447
491
  if (!settings.visibility.providers[id]) {
448
492
  this.cache.delete(id); this.inflight.delete(id);
449
- return { ...this.base(id, identity.source, settings), state: "disabled", apiDetected: !!identity.key, windows: [], extras: [] };
493
+ return { ...this.base(id, identity.source, settings, account), state: "disabled", apiDetected: !!identity.key, windows: [], extras: [] };
450
494
  }
451
- const fingerprint = hash([identity.key ?? "", identity.source, settings.keyModes[id], id === "zai-coding-cn" ? settings.zai : null]);
495
+ // fingerprint 计入账户选择:切换账户后同 id 缓存/inflight 不串账(不同账户 key 必不同);
496
+ // 空串与 "default" 同为默认账户,归一后共享缓存。
497
+ const fingerprint = hash([identity.key ?? "", identity.source, settings.keyModes[id], id === "zai-coding-cn" ? settings.zai : null, id === "commandcode" ? (account && account !== "default" ? account : "default") : null]);
452
498
  if (this.fingerprints.has(id) && this.fingerprints.get(id) !== fingerprint) this.generations.set(id, (this.generations.get(id) ?? 0) + 1);
453
499
  this.fingerprints.set(id, fingerprint);
454
500
  const generation = this.generations.get(id) ?? 0;
@@ -461,8 +507,8 @@ export class SubUsageService extends TypertRemoteService {
461
507
  const resetDue = previous?.entry.windows?.some(w => Number.isFinite(Date.parse(w.resetsAt)) && Date.parse(w.resetsAt) > previous.at && Date.parse(w.resetsAt) <= now);
462
508
  if (previous && (now < previous.retryAt || !resetDue && !force && now - previous.at < TTL)) return previous.entry;
463
509
  const promise = (async () => {
464
- const base = this.base(id, identity.source, settings); let entry; let retryAt = 0; let failures = previous?.failures ?? 0;
465
- if (!identity.key) entry = { ...base, apiDetected: false, state: id === "xiaomi-token-plan-cn" ? "no-cookie" : "no-key", error: id === "xiaomi-token-plan-cn" ? "需登录或粘贴 Cookie" : id === "commandcode" ? "未检测到 Key:可在 设置 → Command Code 登录、运行 cmd login,或使用 COMMANDCODE_API_KEY(凭据服务/启动环境)" : "未配置 API Key", errorCode: "subusage/no-key" };
510
+ const base = this.base(id, identity.source, settings, account); let entry; let retryAt = 0; let failures = previous?.failures ?? 0;
511
+ if (!identity.key) entry = { ...base, apiDetected: false, state: id === "xiaomi-token-plan-cn" ? "no-cookie" : "no-key", error: id === "xiaomi-token-plan-cn" ? "需登录或粘贴 Cookie" : id === "commandcode" ? (account && account !== "default" ? `未检测到账户 ${account} 的 Key:确认提供方插件已保存该账户的凭据(凭据服务或启动环境中无引用 ${account})` : "未检测到 Key:可在 设置 → Command Code 登录、运行 cmd login,或使用 COMMANDCODE_API_KEY(凭据服务/启动环境)") : "未配置 API Key", errorCode: "subusage/no-key" };
466
512
  else try {
467
513
  const data = await this.fetchProvider(id, identity, settings);
468
514
  const usageError = data.usageError; delete data.usageError;
@@ -478,19 +524,19 @@ export class SubUsageService extends TypertRemoteService {
478
524
  if (temporary) retryAt = this.now() + Math.max(e.details?.retryAfterMs ?? 0, Math.min(300000, 1000 * 2 ** Math.min(failures, 8)));
479
525
  }
480
526
  if (!this.disposed && generation === (this.generations.get(id) ?? 0)) this.cache.set(id, { fingerprint, entry, at: this.now(), retryAt, failures });
481
- if (this.disposed || generation !== (this.generations.get(id) ?? 0)) return { ...this.base(id, "none", settings), state: "error", errorCode: "subusage/config-changed", error: "读取期间配置已改变,请刷新" };
527
+ if (this.disposed || generation !== (this.generations.get(id) ?? 0)) return { ...this.base(id, "none", settings, account), state: "error", errorCode: "subusage/config-changed", error: "读取期间配置已改变,请刷新" };
482
528
  return entry;
483
529
  })();
484
530
  this.inflight.set(id, { fingerprint, generation, promise });
485
531
  try { return await promise; } finally { if (this.inflight.get(id)?.promise === promise) this.inflight.delete(id); }
486
532
  }
487
- async read() { return this.refresh({ providerIds: IDS, force: false }); }
533
+ async read(request) { return this.refresh(request && request.providerIds ? request : { providerIds: IDS, force: false }); }
488
534
  async refresh(request) {
489
535
  if (this.disposed) throw failure("subusage/disposed", "服务已卸载");
490
536
  const query = parseQuery(request); const settings = this.load();
491
- const entries = await Promise.all(query.providerIds.map(id => this.provider(id, settings, query.force).catch(() => ({ ...this.base(id, "none", settings), state: "error", errorCode: "subusage/internal", error: "读取用量失败" }))));
537
+ const entries = await Promise.all(query.providerIds.map(id => this.provider(id, settings, query.force, query.commandCodeAccount).catch(() => ({ ...this.base(id, "none", settings, query.commandCodeAccount), state: "error", errorCode: "subusage/internal", error: "读取用量失败" }))));
492
538
  const latest = this.load();
493
- if (hash(latest) !== hash(settings)) return this.result(latest, query.providerIds.map(id => ({ ...this.base(id, "none", latest), state: "error", errorCode: "subusage/config-changed", error: "读取期间配置已改变,请刷新" })));
539
+ if (hash(latest) !== hash(settings)) return this.result(latest, query.providerIds.map(id => ({ ...this.base(id, "none", latest, query.commandCodeAccount), state: "error", errorCode: "subusage/config-changed", error: "读取期间配置已改变,请刷新" })));
494
540
  return this.result(latest, entries);
495
541
  }
496
542
  startMimoLogin(request) { return this.login.start(parseLoginStart(request)); }
@@ -506,7 +552,7 @@ export class SubUsageService extends TypertRemoteService {
506
552
  const entry = { ...this.base(id, "cookie", stored), ...data, state: "ok", coverage: data.coverage ?? "partial", freshness: "fresh", lastSuccessAt: new Date(this.now()).toISOString(), ...(usageError ? { errorCode: usageError.code, error: "额度暂时无法读取", retryable: usageError.details?.retryable === true } : {}) };
507
553
  // 响应内容也不得回显本次 Cookie(服务端字段属于不可信输入)。
508
554
  const safeEntry = redactMimo(entry, cookie);
509
- const fingerprint = hash([cookie, "cookie", stored.keyModes[id], null]);
555
+ const fingerprint = hash([cookie, "cookie", stored.keyModes[id], null, null]);
510
556
  this.fingerprints.set(id, fingerprint); this.cache.set(id, { fingerprint, entry: safeEntry, at: this.now(), retryAt: usageError ? this.now() + 15000 : 0, failures: 0 });
511
557
  this.loginResultMaterial = this.mimoMaterial(stored);
512
558
  return this.result(stored, [safeEntry]);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-subusage",
3
3
  "displayName": "订阅用量显示",
4
- "version": "0.8.1",
4
+ "version": "0.8.3",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "registry": "https://registry.npmjs.org/"