dsh-subusage 0.10.4 → 0.10.6

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.4-blue)](https://www.npmjs.com/package/dsh-subusage)
9
+ [![npm version](https://img.shields.io/badge/npm-0.10.6-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
 
@@ -142,7 +142,8 @@ $env:DEEPSEEK_API_KEY = "<你的 DeepSeek API Key>" # 启动环境;改完需
142
142
  - **设置页路径**:设置 → **订阅用量** → 提供商管理。「没有检测到API的默认隐藏」默认开启;首次没有可见条目时管理区自动展开。关闭的提供商仍可配置凭据,只是不会发起用量请求。
143
143
  - **凭据从哪来**:每一类凭据的**官网入口、环境变量名与界面填入位置**都写在[凭据获取指引](docs/credentials.md)——包括火山的 IAM AK/SK(子用户还要挂 `ArkReadOnlyAccess` 且不限制到项目)、MiMo 的平台会话 Cookie、以及各家 API Key 的对照表。**注意推理 Key 与额度凭据不通用。**
144
144
  - **默认开关策略**:新安装默认开启 10 条、默认关闭 20 条(**Codex 默认关闭**——它的提供方插件自带用量药丸);开关即时保存,升级保留已保存的开关,不重算默认值。默认隐藏只影响显示,不会自动开启被关闭的提供商。
145
- - **火山方舟是一组**:7 条路由共用同一组 IAM AK/SK,所以设置页只呈现**一张卡片、一个开关**(组内最后选的那条作为代表),卡片里写明本机实际装了哪几条。这些 provider id 并没有合并——药丸仍按 id 匹配路由,本机没装的自然不显示。
145
+ - **火山方舟是一组**:7 条路由共用同一组 IAM AK/SK,所以设置页只呈现**一张卡片、一个开关**(组内最后选的那条作为代表),卡片里写明本机实际装了哪几条。这些 provider id 并没有合并——药丸仍按 id 匹配路由。
146
+ - **火山方舟不按路由收起**:它的额度走**账号级管控面**,一组 AK/SK 就能查到名下所有套餐(`GetCodingPlanUsage` 与 `GetAFPUsage` 是同一套协议、同一组凭据)。推理路由装没装只影响模型能不能用,**不影响额度能不能查**——所以只持有 Coding Plan、却没在 DSH 里配 coding-plan 路由时,那条额度照样会显示(前提是配了 AK/SK)。
146
147
  - **凭据来源优先级**(`inherit` 模式):**凭据服务 → 启动环境 → 旧手动配置兜底**;切到自定义(`manual`)模式时只使用保存的手动 Key。界面会区分这三种来源。
147
148
  - **不接受本地 Key 的提供商**:`commandcode`(凭据链属提供方插件)、`xai-oauth`、`openai-codex`(只读各自 CLI 的登录文件),以及 MiMo(Cookie 会话)。
148
149
  - **保存与验证分离**:凭据先确认持久化,再独立做在线验证;检测关闭的提供商只保存凭据、不发起验证,验证失败不代表保存失败。
@@ -164,9 +165,9 @@ $env:DEEPSEEK_API_KEY = "<你的 DeepSeek API Key>" # 启动环境;改完需
164
165
  | MiniMax(国际) | `MINIMAX_API_KEY` | `minimax` 路由;国际站订阅 Key |
165
166
  | MiniMax(中国) | `MINIMAX_CN_API_KEY` | `minimax-cn` 路由;中国站订阅 Key |
166
167
  | Command Code | `COMMANDCODE_API_KEY` | 凭据由提供方插件管理,本页只读继承;兜底读取 `~/.commandcode/auth.json`(`cmd login`) |
167
- | SuperGrok | 无 | `xai-oauth` 路由;只读 dsh-grok-kit / Grok CLI 共享的 OAuth 登录文件 `~/.grok/auth.json`(旧版 `~/.dsh/.xai-oauth-auth.json`),不保存、不刷新 token;API Key(`XAI_API_KEY`)取不到订阅周池 |
168
+ | SuperGrok | 无 | `xai-oauth` 路由;只读 dsh-grok-kit / Grok CLI 共享的 OAuth 登录文件 `~/.grok/auth.json`(旧版 `~/.dsh/.xai-oauth-auth.json`),不保存、**不刷新 token**;access token 过期时直接说明过期时间并提示去刷(不代刷——refresh-token 轮换由 Grok CLI / grok-kit 各自的锁协议管理,第三端刷新会把它们的轮换顶掉)。API Key(`XAI_API_KEY`)取不到订阅周池 |
168
169
  | Codex | 无 | `openai-codex` 路由;只读 Codex CLI 的 ChatGPT 订阅登录文件(`$CODEX_HOME/auth.json` 或 `~/.codex/auth.json`,要求 `auth_mode` 为 `chatgpt`),不保存、不刷新 token |
169
- | 火山方舟 Ark(7 条路由) | `VOLC_ACCESSKEY` + `VOLC_SECRETKEY` | 额度查询要 **IAM Access Key 的 AK/SK 配对**,与推理 API Key(`ARKCLI_*_API_KEY` / `ARK_*_API_KEY`)**不是同一套**;7 条路由共用一组 AK/SK |
170
+ | 火山方舟 Ark(7 条路由) | `VOLC_ACCESSKEY` + `VOLC_SECRETKEY` | 额度查询要 **IAM Access Key 的 AK/SK 配对**,与推理 API Key(`ARKCLI_*_API_KEY` / `ARK_*_API_KEY`)**不是同一套**;7 条路由共用一组 AK/SK。这一组 AK/SK 能**同时**查到 Agent Plan 与 Coding Plan(含团队版席位)——不存在「Coding Plan 专用查询 key」 |
170
171
  | SiliconFlow | `SILICONFLOW_API_KEY` | 余额型:`GET /v1/user/info` 返回账户余额,**与推理是同一把 Key** |
171
172
  | OpenRouter | `OPENROUTER_API_KEY` | key 限额与用量用推理 key 即可;**账户余额需要 management / provisioning key**,普通 key 会被 403 拒——此时静默降级为只显示限额,不判失败 |
172
173
  | Synthetic | `SYNTHETIC_API_KEY` | 模型订阅请求额度 |
@@ -246,9 +247,14 @@ ark-coding-plan-byteplus ARK_CODING_PLAN_BYTEPLUS_API_KEY
246
247
 
247
248
  | 参数 | 类型 | 说明 |
248
249
  |---|---|---|
249
- | `providers` | `string[]`(可选) | 限定 provider id,例如 `["deepseek", "openrouter"]`;省略表示读取全部 |
250
+ | `providers` | `string[]`(可选) | 限定范围。可传本插件的 provider id(`deepseek`、`zai-coding-cn`、`kimi-coding`…),也可直接传厂商名(`zai`、`kimi`、`mimo`、`minimax`、`commandcode`、`grok`、`codex`、`ark`、`deepseek`…);大小写与 `-` `.` `_` 都无关。省略即读取全部 |
250
251
  | `refresh` | `boolean`(可选) | `true` 绕过最多 60 秒的缓存,仅在确需最新数字时使用 |
251
252
 
253
+ 调用约定(**结果永远不为空**):
254
+
255
+ - 模型手里的名字往往不是这里的 provider id(它更可能看到 DSH 的路由名,例如 `zai`),所以匹配是**宽松**的:一个名字命中多条路由时**全部返回**(`zai` → 中国版 + 国际版,`ark` → 7 条),每条各自带着真实 `state`(没启用的如实标 `disabled`),由调用方自己判断要看哪条。
256
+ - 一个名字都认不出时,返回里会**先**给一条说明(写明可用写法),**再附上全部 30 条数据**——数据先给出去,判断交给调用方,而不是回一个空结果。
257
+
252
258
  **Host 服务**(Cordis `Service`,key 为 `subUsage`):
253
259
 
254
260
  ```js
@@ -256,14 +262,15 @@ const service = ctx.get("subUsage");
256
262
  const view = await service.quota({ providerIds: ["deepseek"], force: false });
257
263
  // view = {
258
264
  // updatedAt,
259
- // providers: [{ providerId, label, state, windows, extras, coverage, freshness, lastSuccessAt, error? }]
265
+ // providers: [{ providerId, label, state, windows, extras, coverage?, freshness?, lastSuccessAt?, error? }]
260
266
  // }
261
267
  ```
262
268
 
263
269
  - `state`:`ok` | `no-key` | `no-cookie` | `error` | `disabled`
264
270
  - `windows[].percent` 是**已用**百分比(0–100);`extras` 放余额与套餐名等补充项
265
271
  - 视图**刻意精简**:不返回设置,也不返回 `keySource` / `apiDetected` / 继承变量名等内部状态,第三方消费者拿不到任何与 Key 相关的字段
266
- - `providerIds` 里的未知 id 会被忽略;单家读取失败只影响它自己的条目,其余照常返回
272
+ - 视图里**不出现值为 `undefined` 的字段**:字段缺席表示「这家没有报告」,而不是「报告了空」——带 `undefined` 的键会被调用方的 schema 校验判成型别错误
273
+ - 单家读取失败只影响它自己的条目,其余照常返回
267
274
  - 与设置页共用同一份缓存(TTL 60 秒),频繁调用不会反复请求各家接口
268
275
 
269
276
  ## Troubleshooting / 常见问题
package/lib/client.js CHANGED
@@ -313,14 +313,19 @@ window.__ModuleLoader__.load({
313
313
  return settings?.visibility?.providers?.[id] ?? (PROVIDER_META[id]?.defaultEnabled !== false);
314
314
  }
315
315
  // provider id 就是路由 id,而路由由提供方插件/CLI 按「你实际订阅了什么」写入:例如 arkcli helper
316
- // 只会为真正持有的套餐写路由。所以本机没有 Coding Plan 路由时,就不该默认把 Coding Plan 显示出来
317
- // ——这比「常量里默认开启」更贴近事实。undefined = 拿不到路由表,按「未知」放行,不谎报未安装。
316
+ // 只会为真正持有的套餐写路由。对**按路由取数的**提供商来说,「本机没有这条路由」就等于「没订阅」,
317
+ // 默认收起比「常量里默认开启」更贴近事实。undefined = 拿不到路由表,按「未知」放行,不谎报未安装。
318
+ // 例外是火山方舟:它的额度走账号级管控面(一组 AK/SK 覆盖名下所有套餐),路由只是推理入口 —— 见 providerVisible。
318
319
  function providerRoutable(result, id) { return result?.configured?.[id] !== false; }
319
320
  function providerVisible(settings, id, entry, result) {
320
321
  if (!providerEnabled(settings, id)) return false;
321
322
  // 关掉「没有检测到 API 的默认隐藏」就是要看全部,不再按路由或凭据收起。
322
323
  if (settings?.visibility?.hideWithoutApi === false) return true;
323
- if (!providerRoutable(result, id)) return false;
324
+ // 火山方舟**不按路由收起**:额度是账号级订阅,与「本机装没装这条推理路由」无关——一组 IAM AK/SK
325
+ // 就能查到名下所有套餐(GetCodingPlanUsage 与 GetAFPUsage 同协议同凭据,2026-10-10 实测通过)。
326
+ // 路由只是模型推理入口,「订了 Coding Plan、却没在 DSH 里配 coding-plan 路由」完全正常;
327
+ // 按路由收起会让这类用户凭空少一条真正有数据的额度,看到的是「本机什么都没有」。
328
+ if (!PROVIDER_META[id]?.volc && !providerRoutable(result, id)) return false;
324
329
  return entry?.apiDetected !== false && !["no-key", "no-cookie"].includes(entry?.state);
325
330
  }
326
331
  /** token 数紧凑格式:1,234,567,890 → 1.23B */
package/lib/index.js CHANGED
@@ -85,6 +85,75 @@ const IDS = Object.keys(PROVIDERS);
85
85
  // 用一个组级开关控制整组。这些 id 仍各自存在——药丸要靠 provider id 匹配路由,不能合并。
86
86
  const ARK_IDS = IDS.filter(id => PROVIDERS[id].credentialKind === "volc");
87
87
  const ARK_DEFAULT_ON = ARK_IDS.some(id => PROVIDERS[id].defaultEnabled !== false);
88
+ /**
89
+ * 某条路由当前是否启用。Ark 以**组开关为唯一真源**——7 条路由共用一组 IAM AK/SK、一般用户只
90
+ * 持有一个套餐,所以设置页只给一个开关。这里必须与 Client 的 `providerEnabled` 同语义:
91
+ * 两端口径一旦分叉,界面按组开关显示这条路由,Host 却按逐条值回 `disabled`,
92
+ * 用户拿到的就是一张写着「检测已关闭」的空卡片(这正是 Coding Plan 查不到用量的原因)。
93
+ */
94
+ function providerEnabled(settings, id) {
95
+ if (PROVIDERS[id]?.credentialKind === "volc" && typeof settings?.visibility?.ark === "boolean") return settings.visibility.ark;
96
+ return !!settings?.visibility?.providers?.[id];
97
+ }
98
+ /**
99
+ * 调用方(尤其是模型)手里的「提供商名」通常**不是**本插件的 provider id。模型能看到的往往是
100
+ * DSH 的 llm 路由名或厂商品牌名(`zai`、`grok`、`codex`、`ark`),而这里的 id 是路由 id
101
+ * (`zai-coding-cn`、`xai-oauth`、`openai-codex`…)。只做精确匹配时,`["zai"]` 会被整批过滤掉,
102
+ * 工具返回空列表——看上去像「本机什么都没配」,真正的原因却只是名字没对上。
103
+ * 所以先按别名收敛;一个别名指向多条路由时(`zai` 同时是中国版与国际版)不猜,交给调用方看结果。
104
+ */
105
+ const PROVIDER_ALIASES = {
106
+ zai: ["zai-coding-cn", "zai-coding"], "z.ai": ["zai-coding-cn", "zai-coding"], "z-ai": ["zai-coding-cn", "zai-coding"],
107
+ zhipu: ["zai-coding-cn", "zai-coding"], glm: ["zai-coding-cn", "zai-coding"], "智谱": ["zai-coding-cn", "zai-coding"],
108
+ kimi: ["kimi-coding"], moonshot: ["kimi-coding"], "月之暗面": ["kimi-coding"],
109
+ mimo: ["xiaomi-token-plan-cn"], xiaomi: ["xiaomi-token-plan-cn"], "小米": ["xiaomi-token-plan-cn"],
110
+ minimax: ["minimax-cn", "minimax"], "海螺": ["minimax-cn", "minimax"],
111
+ commandcode: ["commandcode"], "command-code": ["commandcode"], "command code": ["commandcode"],
112
+ supergrok: ["xai-oauth"], grok: ["xai-oauth"], xai: ["xai-oauth"],
113
+ codex: ["openai-codex"], chatgpt: ["openai-codex"], gpt: ["openai-codex"], openai: ["openai-codex"],
114
+ ark: ARK_IDS, volcengine: ARK_IDS, volc: ARK_IDS, "火山": ARK_IDS, "方舟": ARK_IDS, "火山方舟": ARK_IDS, doubao: ARK_IDS, "豆包": ARK_IDS
115
+ };
116
+ /** 查表用的键:大小写与分隔符都无关,`Z.AI` / `zai_coding_cn` / `zai-coding-cn` 视为同一个词。 */
117
+ function normalizeProviderKey(value) { return String(value ?? "").trim().toLowerCase().replace(/[\s._-]+/g, ""); }
118
+ /**
119
+ * 归一化索引:id 与别名都进同一张表,因此「精确 id」只是这张表的一个特例。
120
+ * 一个键可以指向多条路由(`zai` → 中国版 + 国际版,`ark` → 7 条)。
121
+ */
122
+ const PROVIDER_LOOKUP = (() => {
123
+ const map = new Map();
124
+ const add = (key, id) => {
125
+ const k = normalizeProviderKey(key);
126
+ if (!k) return;
127
+ const list = map.get(k) ?? [];
128
+ if (!list.includes(id)) list.push(id);
129
+ map.set(k, list);
130
+ };
131
+ for (const id of IDS) add(id, id);
132
+ for (const [alias, ids] of Object.entries(PROVIDER_ALIASES)) for (const id of ids) if (IDS.includes(id)) add(alias, id);
133
+ return map;
134
+ })();
135
+ /**
136
+ * 把调用方给的任意写法收敛成 provider id,**纯名字解析、不查任何外部状态**。
137
+ * 两条硬约定:
138
+ * 1. 认不出的原串**原样返回**而不是丢掉——调用方要能拿到「这个名字不认识,可用的是……」才能
139
+ * 自我纠正;静默过滤只会让它以为本机没有额度。
140
+ * 2. 一个名字命中多条路由时**全部返回**,各自带着真实状态(没启用的会如实标 disabled)。
141
+ * 例如 `zai` 同时是中国版与国际版、`ark` 是 7 条路由:宁可多给几条数据让调用方自己判断,
142
+ * 也不替它猜一条,更不能返回空。
143
+ * @param requested 调用方给的原始名字列表。
144
+ */
145
+ function resolveProviderIds(requested) {
146
+ const hit = new Set(), unknown = [];
147
+ for (const raw of requested) {
148
+ const name = String(raw ?? "").trim();
149
+ if (!name) continue;
150
+ const ids = PROVIDER_LOOKUP.get(normalizeProviderKey(name));
151
+ if (!ids) { if (!unknown.includes(name)) unknown.push(name); continue; }
152
+ for (const id of ids) hit.add(id);
153
+ }
154
+ // 按登记顺序返回:命中多条时(例如 `ark`)读起来和设置页一致,而不是跟着调用方的输入顺序抖动。
155
+ return { ids: IDS.filter(id => hit.has(id)), unknown };
156
+ }
88
157
  // 火山方舟额度接口的凭据是 IAM Access Key:AK 与 SK 必须同源配对,跨来源拼接只会得到 401。
89
158
  // 变量名沿用火山官方 SDK 习惯;与方舟推理 Key(ARK_*_API_KEY)无关。
90
159
  const VOLC_ACCESS_KEY_ENV = "VOLC_ACCESSKEY";
@@ -140,6 +209,16 @@ function bearerSubject(key) {
140
209
  // ({ version, credential: { access, accountId } })。槽位优先 auth.x.ai,其次旧签发方。
141
210
  function grokAuthFileCredentials(io) {
142
211
  const str = v => typeof v === "string" && v.trim() ? v.trim() : undefined;
212
+ // access token 短命(实测 ~24 小时量级)。登录文件里带着 `expires_at`,必须把它读出来:
213
+ // 不读就只能等上游回 401,界面显示一句「登录或凭据已失效」,而用户那边的 Grok Kit 明明写着「已登录」。
214
+ const expiry = value => {
215
+ if (typeof value === "string" && Number.isFinite(Date.parse(value))) return new Date(value).toISOString();
216
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
217
+ const ms = value > 1e12 ? value : value * 1000;
218
+ return Number.isFinite(new Date(ms).getTime()) ? new Date(ms).toISOString() : undefined;
219
+ }
220
+ return undefined;
221
+ };
143
222
  const read = path => {
144
223
  try {
145
224
  const parsed = JSON.parse(io.readFileSync(path, "utf8"));
@@ -147,7 +226,9 @@ function grokAuthFileCredentials(io) {
147
226
  const envelope = parsed.credential && typeof parsed.credential === "object" && !Array.isArray(parsed.credential) ? parsed.credential : undefined;
148
227
  const pick = (record, userId) => {
149
228
  const key = str(record.key) ?? str(record.access) ?? str(record.access_token);
150
- return key ? { key, userId: str(record.user_id) ?? str(record.principal_id) ?? userId ?? bearerSubject(key) } : undefined;
229
+ if (!key) return undefined;
230
+ const expiresAt = expiry(record.expires_at ?? record.expiresAt);
231
+ return { key, userId: str(record.user_id) ?? str(record.principal_id) ?? userId ?? bearerSubject(key), ...(expiresAt ? { expiresAt } : {}) };
151
232
  };
152
233
  if (envelope) return pick(envelope, str(envelope.accountId));
153
234
  const slots = Object.entries(parsed).filter(([, v]) => v && typeof v === "object" && !Array.isArray(v) && (str(v.key) ?? str(v.access) ?? str(v.access_token)));
@@ -192,7 +273,9 @@ function redactMimo(value, cookie) {
192
273
  return { ...value, ...(Array.isArray(value.extras) ? { extras: value.extras.map(extra => extra.kind === "plan" ? { ...extra, value: sanitize(extra.value) } : extra) } : {}) };
193
274
  }
194
275
  function defaultSettings() {
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])) } };
276
+ // Ark 的逐条默认值必须直接跟随组开关:`load()` 在配置文件还不存在时走的就是这里,若默认值各行其是,
277
+ // 「首次读到 → 保存 → 再读回」会因为归一化把成员值改成组开关的值而算出不同的 revision。
278
+ 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].credentialKind === "volc" ? ARK_DEFAULT_ON : PROVIDERS[id].defaultEnabled !== false])) } };
196
279
  }
197
280
  function parseStored(value) {
198
281
  const s = defaultSettings();
@@ -211,6 +294,10 @@ function parseStored(value) {
211
294
  if (typeof value.keys?.[id] === "string") s.keys[id] = value.keys[id];
212
295
  if (value.keyModes?.[id] === "manual") s.keyModes[id] = "manual";
213
296
  }
297
+ // 组开关是 Ark 的**唯一真源**:逐条值只当作迁移输入,读回后一律跟随组开关。
298
+ // 不归一化就会分叉——「迁移时把任一条开着合并成组开关 true、成员值却保留原样」之后,
299
+ // 界面按组开关显示这条路由,Host 却按成员值判定,用户拿到的是一张写着「检测已关闭」的空卡片。
300
+ for (const id of ARK_IDS) s.visibility.providers[id] = s.visibility.ark;
214
301
  return s;
215
302
  }
216
303
  function publicSettings(s) {
@@ -312,7 +399,7 @@ function volcFailure(action, error) {
312
399
  }
313
400
  async function fetchJson(fetcher, url, headers, lifetimeSignal, init = {}) {
314
401
  let response;
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" }); }
402
+ 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.6", ...headers }, signal: lifetimeSignal ? AbortSignal.any([AbortSignal.timeout(10000), lifetimeSignal]) : AbortSignal.timeout(10000), redirect: "manual" }); }
316
403
  catch { throw failure("subusage/network", "网络请求失败或超时", true); }
317
404
  if (response.status >= 300 && response.status < 400 || response.status === 401 || response.status === 403) throw failure("subusage/auth", "登录或凭据已失效");
318
405
  if (!response.ok) {
@@ -828,9 +915,10 @@ export class SubUsageService extends TypertRemoteService {
828
915
  if (PROVIDERS[id].credentialKind === "volc") return await this.volcIdentity(id, s);
829
916
  if (id === "xai-oauth") {
830
917
  // SuperGrok 只认共享登录文件里的 OAuth Bearer:token 短命轮换,凭据服务/环境变量副本
831
- // 必然过期顶掉新 token,故不走通用 Key 链;user_id 随行给 x-userid 请求头。
918
+ // 必然过期顶掉新 token,故不走通用 Key 链;user_id 随行给 x-userid 请求头,
919
+ // expiresAt 用来在过期时给出「去刷一次」而不是「凭据已失效」。
832
920
  const cred = await this.grokAuth();
833
- return cred?.key ? { key: cred.key, source: "auth-file", ...(cred.userId ? { userId: cred.userId } : {}) } : { key: undefined, source: "none" };
921
+ return cred?.key ? { key: cred.key, source: "auth-file", ...(cred.userId ? { userId: cred.userId } : {}), ...(cred.expiresAt ? { expiresAt: cred.expiresAt } : {}) } : { key: undefined, source: "none" };
834
922
  }
835
923
  if (id === "openai-codex") {
836
924
  // 同上:只读 Codex CLI 的 OAuth 登录文件,token 由 Codex CLI 轮换,第三端刷新会互相顶掉。
@@ -988,6 +1076,13 @@ export class SubUsageService extends TypertRemoteService {
988
1076
  return normalizeCommandCode(credits, subscription);
989
1077
  }
990
1078
  if (id === "xai-oauth") {
1079
+ // 登录文件里的 access token 短命(实测 ~24 小时)。本插件**刻意不代刷**——refresh-token 轮换由
1080
+ // Grok CLI / dsh-grok-kit 各自的锁协议管理,第三端刷新会把它们的轮换顶掉。但「过期」这件事必须
1081
+ // 说清楚:不发这一枪的话,用户看到的是上游 401 转成的「登录或凭据已失效」,而他那边的 Grok Kit
1082
+ // 明明写着「已登录」——两边对不上,只会让人以为插件坏了。
1083
+ if (identity.expiresAt && Date.parse(identity.expiresAt) <= this.now()) {
1084
+ throw failure("subusage/auth", `Grok 登录文件的 access token 已于 ${new Date(identity.expiresAt).toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })} 过期。用一次 Grok CLI,或在 设置 → xAI Grok 里重新登录,都会刷新同一个文件(${GROK_AUTH_FILE});本插件不代刷,以免与它们的 refresh-token 轮换互相顶掉。`);
1085
+ }
991
1086
  // 直连官方 Grok CLI 计费代理;X-XAI-Token-Auth 的取值是 token_header 字符串(xai-grok-cli)
992
1087
  // 而非布尔,x-userid 取登录文件。套餐名走 /v1/settings(subscription_tier_display),
993
1088
  // 失败不阻塞用量(回退 billing 响应里的 subscription_tier)。
@@ -1025,7 +1120,7 @@ export class SubUsageService extends TypertRemoteService {
1025
1120
  catch { this.cache.delete(id); return { ...this.base(id, "none", settings, account), state: "error", errorCode: "subusage/credentials", error: "无法解析凭据" }; }
1026
1121
  if (this.disposed) throw failure("subusage/disposed", "服务已卸载");
1027
1122
  if (startedGeneration !== (this.generations.get(id) ?? 0)) return this.provider(id, this.load(), force, account);
1028
- if (!settings.visibility.providers[id]) {
1123
+ if (!providerEnabled(settings, id)) {
1029
1124
  this.cache.delete(id); this.inflight.delete(id);
1030
1125
  return { ...this.base(id, identity.source, settings, account), state: "disabled", apiDetected: !!identity.key, windows: [], extras: [] };
1031
1126
  }
@@ -1072,23 +1167,37 @@ export class SubUsageService extends TypertRemoteService {
1072
1167
  // 不需要知道本机配了哪些凭据,更不该拿到任何与 Key 相关的字段。
1073
1168
  async quota(request = {}) {
1074
1169
  if (this.disposed) throw failure("subusage/disposed", "服务已卸载");
1075
- const wanted = Array.isArray(request?.providerIds) && request.providerIds.length ? request.providerIds.filter(id => IDS.includes(id)) : [...IDS];
1170
+ const asked = Array.isArray(request?.providerIds) ? request.providerIds.filter(id => typeof id === "string" && id.trim()) : [];
1171
+ const { ids: matched, unknown } = asked.length ? resolveProviderIds(asked) : { ids: [...IDS], unknown: [] };
1172
+ // 一个名字都没认出来,说明调用方手里的是**另一套命名**(最常见的就是 DSH 的 llm 路由名)。
1173
+ // 与其只回一句「不认识」,不如把全量数据一并给出:数据先给出去,怎么判断交给调用方。
1174
+ // 混合输入(认出一部分 + 认不出一部分)则只补上认出的那些,不额外放大请求量。
1175
+ const wanted = matched.length || !unknown.length ? matched : [...IDS];
1076
1176
  const settings = this.load();
1077
1177
  const force = request?.force === true;
1078
1178
  const entries = await Promise.all(wanted.map(id => this.provider(id, settings, force).catch(() => ({ providerId: id, label: PROVIDERS[id].label, state: "error", errorCode: "subusage/internal", error: "读取用量失败" }))));
1179
+ // 认不出的名字要回一条**可读**条目而不是静默丢掉:调用方(尤其是模型)无从知道本插件用的是
1180
+ // 路由 id,丢掉之后它只看到空列表,会当成「本机没配任何额度」——真正的原因只是名字没对上。
1181
+ // 放在最前面,免得淹没在几十条数据后面。
1182
+ const unknownEntries = unknown.map(name => ({
1183
+ providerId: name, label: name, state: "error", windows: [], extras: [], coverage: "partial", freshness: "unknown",
1184
+ error: `不认识提供商名「${name}」${wanted.length === IDS.length && matched.length === 0 ? ",下面是全部提供商的额度" : ""}。可以传本插件的 provider id(例如 deepseek、zai-coding-cn、kimi-coding、xiaomi-token-plan-cn),或直接传厂商名(zai、kimi、mimo、minimax、commandcode、grok、codex、ark、deepseek …)。省略 providers 也会查询全部 ${IDS.length} 条。`
1185
+ }));
1079
1186
  return {
1080
1187
  updatedAt: new Date(this.now()).toISOString(),
1081
- providers: entries.map(entry => ({
1188
+ providers: [...unknownEntries, ...entries.map(entry => ({
1082
1189
  providerId: entry.providerId,
1083
1190
  label: entry.label,
1084
1191
  state: entry.state,
1085
1192
  windows: Array.isArray(entry.windows) ? entry.windows : [],
1086
1193
  extras: Array.isArray(entry.extras) ? entry.extras : [],
1087
- coverage: entry.coverage,
1088
- freshness: entry.freshness,
1089
- lastSuccessAt: entry.lastSuccessAt,
1194
+ // 只输出真正有值的字段:视图里带 `undefined` 的键会被调用方的 schema 校验判成型别错误
1195
+ // (DSH 就会),而且「字段缺席」本来就该表示「这家没报告」,不是「报告了空」。
1196
+ ...(typeof entry.coverage === "string" ? { coverage: entry.coverage } : {}),
1197
+ ...(typeof entry.freshness === "string" ? { freshness: entry.freshness } : {}),
1198
+ ...(typeof entry.lastSuccessAt === "string" ? { lastSuccessAt: entry.lastSuccessAt } : {}),
1090
1199
  ...(entry.error ? { error: entry.error } : {})
1091
- }))
1200
+ }))]
1092
1201
  };
1093
1202
  }
1094
1203
  async read(request) { return this.refresh(request && request.providerIds ? request : { providerIds: IDS, force: false }); }
@@ -1169,11 +1278,15 @@ function registerRemotes(ctx) {
1169
1278
  ctx.inject(["typert"], scope => { scope.effect(() => scope.typert.register({ package: subUsageRemote.package, face: "host", schemas: [], model: { services: [], events: [], objects: [] }, invocations: subUsageRemote.descriptors })); });
1170
1279
  }
1171
1280
  function apply(ctx) { registerRemotes(ctx); ctx.plugin(SubUsageService, {}); registerQuotaTool(ctx); ctx.logger.info("dsh-subusage: usage service ready"); }
1172
- /** 工具结果的人读文本:一行一个 provider,先给窗口再给余额,异常与未配置直说。 */
1281
+ /**
1282
+ * 工具结果的人读文本:一行一个 provider,先给窗口再给余额,异常与未配置直说。
1283
+ * 这段文本才是模型真正读到的东西,所以**任何情况下都不返回空**:读不到数据时也要给出
1284
+ * 「怎么才能读到」的下一步,否则模型只会回一句「没有额度」,而原因其实是个可以修的动作项。
1285
+ */
1173
1286
  function formatQuotaText(value) {
1174
1287
  const providers = Array.isArray(value?.providers) ? value.providers : [];
1175
- if (!providers.length) return "没有可读的提供商额度。";
1176
- return providers.map(entry => {
1288
+ if (!providers.length) return `没有读到任何提供商。请不带 providers 参数再调用一次以查询全部 ${IDS.length} 条;若仍为空,在设置页确认「检测」开关与凭据。`;
1289
+ const lines = providers.map(entry => {
1177
1290
  const head = `${entry.label || entry.providerId} [${entry.state}]`;
1178
1291
  if (entry.state === "no-key" || entry.state === "no-cookie") return `${head}:未配置凭据`;
1179
1292
  if (entry.state === "disabled") return `${head}:检测已关闭`;
@@ -1185,7 +1298,14 @@ function formatQuotaText(value) {
1185
1298
  for (const extra of entry.extras || []) parts.push(`${extra.kind}: ${extra.value}`);
1186
1299
  if (entry.freshness === "stale") parts.push("缓存数据");
1187
1300
  return `${head}:${parts.length ? parts.join(";") : "暂无额度数据"}`;
1188
- }).join("\n");
1301
+ });
1302
+ // 一条额度都没读到时,末尾补一句汇总原因:模型据此才能告诉用户下一步做什么。
1303
+ if (!providers.some(entry => entry.state === "ok")) {
1304
+ const missing = providers.filter(entry => entry.state === "no-key" || entry.state === "no-cookie").length;
1305
+ const off = providers.filter(entry => entry.state === "disabled").length;
1306
+ lines.push(`(这 ${providers.length} 条都没读到额度:未配置凭据 ${missing} 条、检测已关闭 ${off} 条;其余为读取失败。凭据与开关都在 dsh-subusage 设置页,或参考 docs/credentials.md。)`);
1307
+ }
1308
+ return lines.join("\n");
1189
1309
  }
1190
1310
  /**
1191
1311
  * 注册 subusage_quota 工具:模型可以直接问「额度还剩多少」,不必自己逐个厂商请求。
@@ -1195,9 +1315,9 @@ function registerQuotaTool(ctx) {
1195
1315
  ctx.inject(["tools"], toolCtx => {
1196
1316
  toolCtx.tools.register(defineTool({
1197
1317
  name: "subusage_quota",
1198
- description: "Read the remaining quota and balance of the AI subscriptions configured on this machine (DeepSeek, Z.ai, Kimi, MiMo, OpenCode Go, Command Code, Codex, SuperGrok, MiniMax, Volcengine Ark, OpenRouter, SiliconFlow, ...). Read-only: it never returns credentials and never changes settings. Results may come from a cache up to 60 seconds old; pass refresh=true only when the user explicitly needs up-to-date numbers.",
1318
+ description: "Read the remaining quota and balance of the AI subscriptions configured on this machine (DeepSeek, Z.ai, Kimi, MiMo, OpenCode Go, Command Code, Codex, SuperGrok, MiniMax, Volcengine Ark, OpenRouter, SiliconFlow, ...). Call it with no arguments to read every provider — that is the usual call. `providers` is an optional filter that accepts both this plugin's provider ids and plain vendor names (\"zai\", \"kimi\", \"mimo\", \"minimax\", \"commandcode\", \"grok\", \"codex\", \"ark\", \"deepseek\"); one name can match several routes and every match is returned with its own state, and a name that matches nothing still returns the full data plus an explanation instead of an empty result. Read-only: it never returns credentials and never changes settings. Each entry has state (ok | no-key | no-cookie | error | disabled), windows (percent is the USED percentage; absent resetsAt means the provider does not report one) and extras (plan name, balance). Results may come from a cache up to 60 seconds old; pass refresh=true only when the user explicitly needs up-to-date numbers.",
1199
1319
  parameters: {
1200
- providers: { type: "array", items: { type: "string" }, description: "Optional provider ids to limit the query, e.g. [\"deepseek\", \"openrouter\"]. Omit to read every provider." },
1320
+ providers: { type: "array", items: { type: "string" }, description: "Optional filter. Either a provider id this tool reports back (\"deepseek\", \"zai-coding-cn\", \"kimi-coding\") or a plain vendor name (\"zai\", \"kimi\", \"mimo\", \"minimax\", \"commandcode\", \"grok\", \"codex\", \"ark\"). Case and separators do not matter; an unmatched name is reported with the full data rather than dropped." },
1201
1321
  refresh: { type: "boolean", description: "Force a fresh fetch instead of the up-to-60s cache. Use only when the user asks for the latest numbers." }
1202
1322
  },
1203
1323
  output: {
@@ -1214,11 +1334,39 @@ function registerQuotaTool(ctx) {
1214
1334
  type: "object",
1215
1335
  additionalProperties: false,
1216
1336
  properties: {
1217
- providerId: { type: "string", required: true },
1337
+ providerId: { type: "string", required: true, description: "Provider id (the route id), or the unmatched name that was passed in." },
1218
1338
  label: { type: "string", required: true },
1219
1339
  state: { type: "string", required: true, description: "ok | no-key | no-cookie | error | disabled" },
1220
- windows: { type: "array", required: true, description: "Quota windows; percent is the USED percentage (0-100)." },
1221
- extras: { type: "array", required: true, description: "Balances and plan names (kind/value)." },
1340
+ windows: {
1341
+ type: "array", required: true, description: "Quota windows; percent is the USED percentage (0-100).",
1342
+ items: {
1343
+ // 这里刻意用 additionalProperties: true:窗口形态随厂商变化(有的给绝对量、有的给额度线标注),
1344
+ // 收紧成白名单只会让新增字段在运行时被判违规,而这里声明的字段仍照旧受型别约束。
1345
+ type: "object", additionalProperties: true,
1346
+ properties: {
1347
+ kind: { type: "string", required: true, description: "Window length: 5h | week | month | day | 7d | rolling | sub | period." },
1348
+ percent: { type: "number", required: true, description: "USED percentage 0-100; remaining = 100 - percent." },
1349
+ status: { type: "string", required: true, description: "ok | rate-limited" },
1350
+ resetsAt: { type: "string", description: "ISO reset time; absent when the provider does not report one." },
1351
+ groupLabel: { type: "string", description: "Credit line, when the provider has more than one (e.g. Volcengine AFP)." },
1352
+ cascade: { type: "boolean", description: "true when this window is only blocked by a wider exhausted window." },
1353
+ detail: {
1354
+ type: "object", additionalProperties: true, description: "Absolute amounts and scope notes, when the provider reports them.",
1355
+ properties: {
1356
+ used: { type: "number" }, limit: { type: "number" }, remaining: { type: "number" }, unit: { type: "string" },
1357
+ limitSource: { type: "string" }, note: { type: "string" }, blockedBy: { type: "array", items: { type: "string" } }
1358
+ }
1359
+ }
1360
+ }
1361
+ }
1362
+ },
1363
+ extras: {
1364
+ type: "array", required: true, description: "Non-window facts: plan name, balance, seat id, ... (kind/value).",
1365
+ items: { type: "object", additionalProperties: false, properties: {
1366
+ kind: { type: "string", required: true, description: "plan | balance | spend | account | monthly-balance | ..." },
1367
+ value: { type: "string", required: true }
1368
+ } }
1369
+ },
1222
1370
  coverage: { type: "string", description: "complete | partial" },
1223
1371
  freshness: { type: "string", description: "fresh | stale" },
1224
1372
  lastSuccessAt: { type: "string" },
@@ -1233,7 +1381,7 @@ function registerQuotaTool(ctx) {
1233
1381
  execute: async (args, exec) => {
1234
1382
  exec?.signal?.throwIfAborted?.();
1235
1383
  const service = toolCtx.get("subUsage");
1236
- if (!service || typeof service.quota !== "function") throw new Error("dsh-subusage 服务尚未就绪");
1384
+ if (!service || typeof service.quota !== "function") throw new Error("dsh-subusage 服务尚未就绪:Host 端可能还在启动或刚更新完还没重启。请稍后重试;持续失败就重启 DSH。");
1237
1385
  return await service.quota({ providerIds: args?.providers, force: args?.refresh === true });
1238
1386
  }
1239
1387
  }));
package/lib/volcengine.js CHANGED
@@ -110,7 +110,10 @@ const CODING_LEVELS = {
110
110
  /**
111
111
  * 个人版 Coding Plan:Result.QuotaUsage[] 每项一个窗口。
112
112
  * Percent 是**已用百分数**(0–100,实测常见 0.39 这类小值,不能按「≤1 即小数」放大)。
113
- * 接口只给百分比,不返回绝对量,因此不产出 detail(Cap 字段未在任何来源证实存在,不编造)。
113
+ * 接口只给百分比,不返回绝对量,因此不产出 detail。
114
+ * 真实响应里还带 `Cap` / `RewardTotalPercent` / `HasReward`(2026-10-10 用本机 AK/SK 实测:三个窗口
115
+ * 都是 `Cap: 100`、`RewardTotalPercent: 0`、`HasReward: false`)——**字段确实存在**,但没有任何官方
116
+ * 说明它们的语义(像百分比刻度,也像次数上限),所以不展示:不编造单位,也不把 100 读成"还有 100 次"。
114
117
  */
115
118
  export function parseVolcCodingPlan(body) {
116
119
  // 正常走 OpenAPI 的 ResponseMetadata + Result 信封;个别抓包形态把字段放在顶层,两者都接受。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-subusage",
3
3
  "displayName": "订阅用量显示",
4
- "version": "0.10.4",
4
+ "version": "0.10.6",
5
5
  "publishConfig": {
6
6
  "access": "public",
7
7
  "registry": "https://registry.npmjs.org/"