dsh-subusage 0.10.0 → 0.10.1

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.0-blue)](https://www.npmjs.com/package/dsh-subusage)
9
+ [![npm version](https://img.shields.io/badge/npm-0.10.1-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
 
package/lib/client.js CHANGED
@@ -282,9 +282,15 @@ window.__ModuleLoader__.load({
282
282
  // 顺序只影响展示:DeepSeek 放在末尾以免打乱既有条目位置;药丸匹配靠 provider id,与顺序无关。
283
283
  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
284
  function providerEnabled(settings, id) { return settings?.visibility?.providers?.[id] ?? (PROVIDER_META[id]?.defaultEnabled !== false); }
285
- function providerVisible(settings, id, entry) {
285
+ // provider id 就是路由 id,而路由由提供方插件/CLI 按「你实际订阅了什么」写入:例如 arkcli helper
286
+ // 只会为真正持有的套餐写路由。所以本机没有 Coding Plan 路由时,就不该默认把 Coding Plan 显示出来
287
+ // ——这比「常量里默认开启」更贴近事实。undefined = 拿不到路由表,按「未知」放行,不谎报未安装。
288
+ function providerRoutable(result, id) { return result?.configured?.[id] !== false; }
289
+ function providerVisible(settings, id, entry, result) {
286
290
  if (!providerEnabled(settings, id)) return false;
291
+ // 关掉「没有检测到 API 的默认隐藏」就是要看全部,不再按路由或凭据收起。
287
292
  if (settings?.visibility?.hideWithoutApi === false) return true;
293
+ if (!providerRoutable(result, id)) return false;
288
294
  return entry?.apiDetected !== false && !["no-key", "no-cookie"].includes(entry?.state);
289
295
  }
290
296
  /** token 数紧凑格式:1,234,567,890 → 1.23B */
@@ -642,7 +648,7 @@ window.__ModuleLoader__.load({
642
648
  };
643
649
  const refresh = (ids, force = false) => {
644
650
  if (disposed) return Promise.reject(new Error("Client 已卸载"));
645
- const unique = [...new Set(ids)].filter((id) => PROVIDER_ORDER.includes(id) && providerEnabled(snapshot.settings, id));
651
+ const unique = [...new Set(ids)].filter((id) => PROVIDER_ORDER.includes(id) && providerEnabled(snapshot.settings, id) && providerRoutable(snapshot, id));
646
652
  const missing = unique.filter((id) => !inflight.has(id));
647
653
  if (missing.length) {
648
654
  const startedEpoch = epoch, request = markRequest(missing);
@@ -711,7 +717,7 @@ window.__ModuleLoader__.load({
711
717
  },
712
718
  reader: (id) => {
713
719
  if (!readers.has(id)) {
714
- const current = () => { const e = snapshot.entries.find((item) => item.providerId === id); return { ...(e || { providerId: id, state: "loading", windows: [] }), visible: providerVisible(snapshot.settings, id, e) }; };
720
+ const current = () => { const e = snapshot.entries.find((item) => item.providerId === id); return { ...(e || { providerId: id, state: "loading", windows: [] }), visible: providerVisible(snapshot.settings, id, e, snapshot) }; };
715
721
  const fn = async (force = false) => { await refresh([id], force); return current(); };
716
722
  fn.subscribe = (cb) => subscribe(() => cb(current()));
717
723
  fn.activate = () => activate(id);
@@ -1245,7 +1251,7 @@ window.__ModuleLoader__.load({
1245
1251
  // 待处理的排前面;组内保持登记顺序,所以顺序不会自己抖动。
1246
1252
  const visibleIds = PROVIDER_ORDER
1247
1253
  .map((id, index) => ({ id, index, rank: usageAttentionRank(entries.find(e => e.providerId === id), t) }))
1248
- .filter(item => providerVisible(result.settings, item.id, entries.find(e => e.providerId === item.id)))
1254
+ .filter(item => providerVisible(result.settings, item.id, entries.find(e => e.providerId === item.id), result))
1249
1255
  .sort((a, b) => b.rank - a.rank || a.index - b.index)
1250
1256
  .map(item => item.id);
1251
1257
  const success = entries.filter((e) => visibleIds.includes(e.providerId) && e.state === "ok" && e.freshness !== "stale").length;
@@ -1344,7 +1350,8 @@ window.__ModuleLoader__.load({
1344
1350
  !editorOpen && notice ? h("p", { role: "status", style: { color: notice.ok ? "inherit" : "#dc2626" } }, notice.text) : null,
1345
1351
  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")}`),
1346
1352
  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.")),
1347
- h("div", { style: { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 240px), 1fr))", gap: 8 } }, PROVIDER_ORDER.map(id => {
1353
+ // 启用的排到最前面,省得每次往下翻;组内保持 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 => {
1348
1355
  const enabled = providerEnabled(result.settings, id), e = entries.find(item => item.providerId === id), editing = tab === id && editorOpen;
1349
1356
  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 } },
1350
1357
  h("span", { style: { flex: 1, minWidth: 80 } }, PROVIDER_META[id].short),
package/lib/index.js CHANGED
@@ -227,7 +227,9 @@ function parseSettings(value) {
227
227
  }
228
228
  return { expectedRevision: value.expectedRevision, visibility };
229
229
  }
230
- if (!value || typeof value !== "object" || !IDS.includes(value.providerId)) throw invalid("Invalid provider patch");
230
+ // 这里只校验形状:未知 provider(新版 Client + 还没重启的旧 Host)交给 persist 给可读错误,
231
+ // 否则用户只会看到网关的 "boundary validation" 通用报错,无从判断该重启还是该改配置。
232
+ if (!value || typeof value !== "object" || typeof value.providerId !== "string" || !value.providerId) throw invalid("Invalid provider patch");
231
233
  const out = { providerId: value.providerId };
232
234
  if (value.expectedRevision !== undefined) { if (typeof value.expectedRevision !== "string") throw invalid("Invalid revision"); out.expectedRevision = value.expectedRevision; }
233
235
  if (value.keyMode !== undefined) { if (!["inherit", "manual"].includes(value.keyMode)) throw invalid("Invalid key mode"); out.keyMode = value.keyMode; }
@@ -273,12 +275,16 @@ function parseSettings(value) {
273
275
  function parseQuery(value) {
274
276
  // request 在 wire 层可选(read 描述符声明 acceptsUndefined):缺参/undefined/null
275
277
  // = 全量默认账户查询,兼容旧 client bundle 的无参 read。
276
- if (value === undefined || value === null) return { providerIds: [...IDS], force: false, commandCodeAccount: "" };
277
- if (!value || !Array.isArray(value.providerIds) || value.providerIds.some(id => !IDS.includes(id)) || typeof value.force !== "boolean") throw invalid("Invalid refresh request");
278
+ if (value === undefined || value === null) return { providerIds: [...IDS], unknown: [], force: false, commandCodeAccount: "" };
279
+ // 未知 id **过滤而不是整体拒绝**:provider 清单随版本演进,而 Host 需要重启、Client 刷新即生效,
280
+ // 这个版本窗口期里客户端必然带着本 Host 还不认识的 id 过来。整体拒绝会让用户一片空白;
281
+ // 过滤掉未知项并单独回一条可读原因(见 refresh),既保住其它提供商,也说清了为什么。
282
+ if (!value || !Array.isArray(value.providerIds) || typeof value.force !== "boolean") throw invalid("Invalid refresh request");
278
283
  // commandCodeAccount 是可选的显示账户选择:空串/缺省 = 默认账户(顶层 Key);
279
284
  // 额外账户 id 即其凭据引用名(apiKeyEnv),由 identity 按引用解析。
280
285
  if (value.commandCodeAccount !== undefined && typeof value.commandCodeAccount !== "string") throw invalid("Invalid account selection");
281
- return { providerIds: [...new Set(value.providerIds)], force: value.force, commandCodeAccount: typeof value.commandCodeAccount === "string" ? value.commandCodeAccount.trim() : "" };
286
+ const requested = [...new Set(value.providerIds)].filter(id => typeof id === "string");
287
+ return { providerIds: requested.filter(id => IDS.includes(id)), unknown: requested.filter(id => !IDS.includes(id)), force: value.force, commandCodeAccount: typeof value.commandCodeAccount === "string" ? value.commandCodeAccount.trim() : "" };
282
288
  }
283
289
  function failure(code, message, retryable = false, retryAfterMs) {
284
290
  return new RemoteError(code, message, { retryable, retainPrevious: retryable, ...(retryAfterMs !== undefined ? { retryAfterMs } : {}) });
@@ -296,7 +302,7 @@ function volcFailure(action, error) {
296
302
  }
297
303
  async function fetchJson(fetcher, url, headers, lifetimeSignal, init = {}) {
298
304
  let response;
299
- 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.0", ...headers }, signal: lifetimeSignal ? AbortSignal.any([AbortSignal.timeout(10000), lifetimeSignal]) : AbortSignal.timeout(10000), redirect: "manual" }); }
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" }); }
300
306
  catch { throw failure("subusage/network", "网络请求失败或超时", true); }
301
307
  if (response.status >= 300 && response.status < 400 || response.status === 401 || response.status === 403) throw failure("subusage/auth", "登录或凭据已失效");
302
308
  if (!response.ok) {
@@ -744,7 +750,7 @@ export function normalizeLitellm(keyBody, budgetBody) {
744
750
  }
745
751
  function detectConfigured(ctx) {
746
752
  try { const ids = new Set(ctx.llm.listProviders().map(p => p.id)); return Object.fromEntries(IDS.map(id => [id, ids.has(id)])); }
747
- catch { return Object.fromEntries(IDS.map(id => [id, false])); }
753
+ catch { return undefined; }
748
754
  }
749
755
  const codec = (type, parse) => ({ mode: "strict", typeSymbol: `dsh-subusage#${type}`, schema: { parse }, create: () => ({ parse }) });
750
756
  const resultCodec = codec("SubUsageResult", value => { if (!value || !Array.isArray(value.entries) || !value.settings || !value.configured || typeof value.updatedAt !== "string") throw invalid("Invalid result"); return value; });
@@ -1078,9 +1084,12 @@ export class SubUsageService extends TypertRemoteService {
1078
1084
  if (this.disposed) throw failure("subusage/disposed", "服务已卸载");
1079
1085
  const query = parseQuery(request); const settings = this.load();
1080
1086
  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: "读取用量失败" }))));
1087
+ // 客户端请求了本 Host 不认识的 provider(装了新版但还没重启 DSH):回一条可读原因,
1088
+ // 否则它只会显示成「无数据」,用户无从判断是该重启还是该配凭据。
1089
+ const unknownEntries = (query.unknown ?? []).map(id => ({ providerId: id, label: id, state: "error", errorCode: "subusage/unknown-provider", error: "本机运行中的 Host 版本较旧,不认识该提供商;重启 DSH 后生效", apiDetected: null, coverage: "partial", freshness: "unknown", keySource: "none", retryable: false, retainPrevious: false, windows: [], extras: [] }));
1081
1090
  const latest = this.load();
1082
- 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: "读取期间配置已改变,请刷新" })));
1083
- return this.result(latest, entries);
1091
+ 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: "读取期间配置已改变,请刷新" })), ...unknownEntries]);
1092
+ return this.result(latest, [...entries, ...unknownEntries]);
1084
1093
  }
1085
1094
  startMimoLogin(request) { return this.login.start(parseLoginStart(request)); } getMimoLoginStatus() { return this.login.status(); }
1086
1095
  cancelMimoLogin(request) { return this.login.cancel(parseLoginCancel(request)); }
@@ -1105,6 +1114,9 @@ export class SubUsageService extends TypertRemoteService {
1105
1114
  persist(settings, cookieCap) {
1106
1115
  if (this.disposed) throw failure("subusage/disposed", "服务已卸载");
1107
1116
  const patch = parseSettings(settings); const stored = this.load();
1117
+ // 未知 provider:这是「客户端已更新、Host 还没重启」的版本窗口期,给出可读原因而不是静默写入垃圾配置。
1118
+ // 只改可见性的 patch 不带 providerId,不能因此被拒。
1119
+ if (patch.providerId !== undefined && !IDS.includes(patch.providerId)) throw failure("subusage/unknown-provider", "本机运行中的 Host 版本较旧,不认识该提供商;重启 DSH 后生效");
1108
1120
  if (patch.expectedRevision !== undefined && patch.expectedRevision !== hash(stored)) throw failure("subusage/revision-conflict", "设置已被其他窗口修改,请重新读取");
1109
1121
  const id = patch.providerId;
1110
1122
  // 火山方舟两个 provider 共用一组 AK/SK:改凭据要让所有用它的条目失效,不能只失效当前标签页。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-subusage",
3
3
  "displayName": "订阅用量显示",
4
- "version": "0.10.0",
4
+ "version": "0.10.1",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "registry": "https://registry.npmjs.org/"