dsh-subusage 0.11.0 → 0.11.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
@@ -7,7 +7,7 @@
7
7
 
8
8
  dsh-subusage 把你订阅的 30 家模型服务商额度装进 DeepSeek Harness 的输入框旁边——不切控制台、不猜百分比、不等报错。
9
9
 
10
- [![npm version](https://img.shields.io/badge/npm-0.11.0-blue)](https://www.npmjs.com/package/dsh-subusage)
10
+ [![npm version](https://img.shields.io/badge/npm-0.11.1-blue)](https://www.npmjs.com/package/dsh-subusage)
11
11
  [![license](https://img.shields.io/badge/license-MIT-green)](#license--security--许可与安全)
12
12
  [![DSH](https://img.shields.io/badge/DSH-0.2.x-informational)](#compatibility--兼容性)
13
13
 
@@ -23,7 +23,7 @@
23
23
  | 额度散落在五六家控制台,查一次余额要开五个标签页 | 药丸常驻输入框旁:选中哪家模型就看哪家,多窗口自动取最差一窗 |
24
24
  | Agent 跑到一半撞上 429,才知道额度用尽 | 弹层先给结论再给依据:「即将用尽 · 最紧张:每周 · 剩 5% · 3 天后重置」 |
25
25
  | Cookie、OAuth 悄悄过期,报错才发现 | MiMo Cookie 提前 2 小时变黄、30 分钟变橙、过期变红;认证失效直说原因与修法 |
26
- | 每家插件各管各的凭据,格式五花八门 | 一个设置页集中管理 30 条路由的开关与凭据,缺什么补什么 |
26
+ | 每家插件各管各的凭据,格式五花八门 | 一个设置页集中管理 27 条路由的开关与凭据,缺什么补什么 |
27
27
 
28
28
  ## 15 种状态,一眼分清
29
29
 
@@ -87,7 +87,7 @@ $env:DEEPSEEK_API_KEY = "<你的 DeepSeek API Key>" # 启动环境;改完需
87
87
 
88
88
  ## 支持的数据 / Supported data
89
89
 
90
- 共 **30 条 provider id**:新安装**默认开启 10 条、默认关闭 20 条**(数字与清单取自 [`lib/index.js`](lib/index.js) 顶部的 `PROVIDERS`,展示顺序与短名取自 [`lib/client.js`](lib/client.js) 顶部的 `PROVIDER_META` / `PROVIDER_ORDER`)。
90
+ 共 **27 条 provider id**:新安装**默认开启 10 条、默认关闭 17 条**(数字与清单取自 [`lib/index.js`](lib/index.js) 顶部的 `PROVIDERS`,展示顺序与短名取自 [`lib/client.js`](lib/client.js) 顶部的 `PROVIDER_META` / `PROVIDER_ORDER`)。
91
91
 
92
92
  | 提供商 | provider id | 额度类型 | 鉴权 | 默认 |
93
93
  |---|---|---|---|---|
@@ -97,8 +97,8 @@ $env:DEEPSEEK_API_KEY = "<你的 DeepSeek API Key>" # 启动环境;改完需
97
97
  | Xiaomi MiMo | `xiaomi-token-plan-cn` | 周期额度池 | 官方平台 Cookie(含登录流程) | 开 |
98
98
  | OpenCode Go | `opencode-go` | 订阅窗口 | Bearer | 开 |
99
99
  | Command Code | `commandcode` | 5 小时 / 周 / 月额度池 | 提供方插件的凭据链 | 开 |
100
- | SuperGrok | `xai-oauth` | 周期池 | 只读 Grok CLI 的 OAuth 登录文件 | 开 |
101
- | Codex (ChatGPT) | `openai-codex` | ChatGPT 订阅窗口 | 只读 Codex CLI 的 OAuth 登录文件 | 开 |
100
+ | SuperGrok | `xai-oauth` | 周期池 | 共享 Grok CLI 登录文件,过期经 grok-kit 续约 | 开 |
101
+ | Codex (ChatGPT) | `openai-codex` | ChatGPT 订阅窗口 | 只读 Codex CLI 的 OAuth 登录文件 | 关 |
102
102
  | MiniMax(中国) | `minimax-cn` | 短周期 / 周 | Bearer | 开 |
103
103
  | ARK Agent Plan (arkcli) | `arkcli-agent-plan` | 订阅窗口(AFP) | IAM AK/SK 签名 | 开 |
104
104
  | ARK Coding Plan (arkcli) | `arkcli-coding-plan` | 订阅窗口 | IAM AK/SK 签名 | 开 |
@@ -108,7 +108,6 @@ $env:DEEPSEEK_API_KEY = "<你的 DeepSeek API Key>" # 启动环境;改完需
108
108
  | MiniMax(国际) | `minimax` | 短周期 / 周 | Bearer | 关 |
109
109
  | ARK Agent Plan Team (arkcli) | `arkcli-agent-plan-team` | 席位额度(AFP) | IAM AK/SK 签名 | 关 |
110
110
  | ARK Coding Plan Team (arkcli) | `arkcli-coding-plan-team` | 席位额度 | IAM AK/SK 签名 | 关 |
111
- | Ark Coding Plan(BytePlus,旧插件路由) | `ark-coding-plan-byteplus` | 订阅窗口 | IAM AK/SK 签名 | 关 |
112
111
  | SiliconFlow | `siliconflow` | 余额 | Bearer,与推理同一把 Key | 关 |
113
112
  | OpenRouter | `openrouter` | 限额窗口 + 余额 | 推理 Key(余额需 management / provisioning key) | 关 |
114
113
  | Novita AI | `novita` | 余额 | Bearer | 关 |
@@ -145,7 +144,7 @@ $env:DEEPSEEK_API_KEY = "<你的 DeepSeek API Key>" # 启动环境;改完需
145
144
 
146
145
  其余提供商**只经过桩测试**——响应形态取自公开文档、第三方实现或抓包,**没有在真实账号上跑通过**:
147
146
 
148
- > Z.ai(中国与国际)、OpenCode Go、MiniMax(中国与国际)、Synthetic、NanoGPT、SiliconFlow、DeepSeek、OpenRouter、Novita、Hyperbolic、DeepInfra、Chutes、Ollama Cloud、Vercel AI Gateway、ZenMux、LiteLLM,以及火山方舟的**团队版席位**与 **BytePlus 站点**。
147
+ > Z.ai(中国与国际)、OpenCode Go、MiniMax(中国与国际)、Synthetic、NanoGPT、SiliconFlow、DeepSeek、OpenRouter、Novita、Hyperbolic、DeepInfra、Chutes、Ollama Cloud、Vercel AI Gateway、ZenMux、LiteLLM,以及火山方舟的**团队版席位**。
149
148
 
150
149
  它们可能可用,也可能因为接口变更、字段差异或权限要求而失败。**本插件只保证上面那张表里的**。
151
150
 
@@ -227,8 +226,8 @@ dsh plugin --profile <profile> remove dsh-subusage
227
226
 
228
227
  - **设置页路径**:设置 → **订阅用量** → 提供商管理。「没有检测到API的默认隐藏」默认开启;首次没有可见条目时管理区自动展开。关闭的提供商仍可配置凭据,只是不会发起用量请求。
229
228
  - **凭据从哪来**:每一类凭据的**官网入口、环境变量名与界面填入位置**都写在[凭据获取指引](docs/credentials.md)——包括火山的 IAM AK/SK(子用户还要挂 `ArkReadOnlyAccess` 且不限制到项目)、MiMo 的平台会话 Cookie、以及各家 API Key 的对照表。**注意推理 Key 与额度凭据不通用。**
230
- - **默认开关策略**:新安装默认开启 10 条、默认关闭 20 条(**Codex 默认关闭**——它的提供方插件自带用量药丸);开关即时保存,升级保留已保存的开关,不重算默认值。默认隐藏只影响显示,不会自动开启被关闭的提供商。
231
- - **火山方舟是一组**:5 条路由共用同一组 IAM AK/SK,所以设置页只呈现**一张卡片、一个开关**(组内最后选的那条作为代表),卡片里写明本机实际装了哪几条。这些 provider id 并没有合并——药丸仍按 id 匹配路由。旧插件 `@volcengine/ark-plan-api` 的两个国内路由(`ark-coding-plan-cn` / `ark-agent-plan-cn`)**已移除**:它们与 arkcli 的路由查同一份订阅,并列显示只会让人以为买了两个套餐;装了旧插件的用户,药丸仍按那两个 id 显示同一份数据(入口归并到 arkcli 路由)。
229
+ - **默认开关策略**:新安装默认开启 10 条、默认关闭 17 条(**Codex 默认关闭**——它的提供方插件自带用量药丸);开关即时保存,升级保留已保存的开关,不重算默认值。默认隐藏只影响显示,不会自动开启被关闭的提供商。
230
+ - **火山方舟是一组**:4 条路由共用同一组 IAM AK/SK,所以设置页只呈现**一张卡片、一个开关**(本机已装的第一条路由作为代表,都没装则用第一条),卡片里写明本机实际装了哪几条。这些 provider id 并没有合并——药丸仍按 id 匹配路由。旧插件 `@volcengine/ark-plan-api` 的两个国内路由(`ark-coding-plan-cn` / `ark-agent-plan-cn`)**已移除**:它们与 arkcli 的路由查同一份订阅,并列显示只会让人以为买了两个套餐;装了旧插件的用户,药丸仍按那两个 id 显示同一份数据(入口归并到 arkcli 路由)。
232
231
  - **火山方舟不按路由收起**:它的额度走**账号级管控面**,一组 AK/SK 就能查到名下所有套餐(`GetCodingPlanUsage` 与 `GetAFPUsage` 是同一套协议、同一组凭据)。推理路由装没装只影响模型能不能用,**不影响额度能不能查**——所以只持有 Coding Plan、却没在 DSH 里配 coding-plan 路由时,那条额度照样会显示(前提是配了 AK/SK)。
233
232
  - **凭据来源优先级**(`inherit` 模式):**凭据服务 → 启动环境 → 旧手动配置兜底**;切到自定义(`manual`)模式时只使用保存的手动 Key。界面会区分这三种来源。
234
233
  - **不接受本地 Key 的提供商**:`commandcode`(凭据链属提供方插件)、`xai-oauth`、`openai-codex`(只读各自 CLI 的登录文件),以及 MiMo(Cookie 会话)。
@@ -251,9 +250,9 @@ dsh plugin --profile <profile> remove dsh-subusage
251
250
  | MiniMax(国际) | `MINIMAX_API_KEY` | `minimax` 路由;国际站订阅 Key |
252
251
  | MiniMax(中国) | `MINIMAX_CN_API_KEY` | `minimax-cn` 路由;中国站订阅 Key |
253
252
  | Command Code | `COMMANDCODE_API_KEY` | 凭据由提供方插件管理,本页只读继承;兜底读取 `~/.commandcode/auth.json`(`cmd login`) |
254
- | 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`)取不到订阅周池 |
253
+ | SuperGrok | 无 | `xai-oauth` 路由;读取 dsh-grok-kit / Grok CLI 共享的 OAuth 登录文件 `~/.grok/auth.json`(旧版 `~/.dsh/.xai-oauth-auth.json`),不保存凭据。access token 过期时**借 dsh-grok-kit(≥0.2.0)的官方登录链自动续约**——同一文件、同一把写锁、compare-and-write,不会顶掉共享登录态(本插件绝不自行裸 POST 刷新);未安装 grok-kit 或续约失败时才提示手动重新登录。API Key(`XAI_API_KEY`)取不到订阅周池 |
255
254
  | Codex | 无 | `openai-codex` 路由;只读 Codex CLI 的 ChatGPT 订阅登录文件(`$CODEX_HOME/auth.json` 或 `~/.codex/auth.json`,要求 `auth_mode` 为 `chatgpt`),不保存、不刷新 token |
256
- | 火山方舟 Ark(5 条路由) | `VOLC_ACCESSKEY` + `VOLC_SECRETKEY` | 额度查询要 **IAM Access Key 的 AK/SK 配对**,与推理 API Key(`ARKCLI_*_API_KEY` / `ARK_*_API_KEY`)**不是同一套**;5 条路由共用一组 AK/SK。这一组 AK/SK 能**同时**查到 Agent Plan 与 Coding Plan(含团队版席位)——不存在「Coding Plan 专用查询 key」 |
255
+ | 火山方舟 Ark(4 条路由) | `VOLC_ACCESSKEY` + `VOLC_SECRETKEY` | 额度查询要 **IAM Access Key 的 AK/SK 配对**,与推理 API Key(`ARKCLI_*_API_KEY` / `ARK_*_API_KEY`)**不是同一套**;5 条路由共用一组 AK/SK。这一组 AK/SK 能**同时**查到 Agent Plan 与 Coding Plan(含团队版席位)——不存在「Coding Plan 专用查询 key」 |
257
256
  | SiliconFlow | `SILICONFLOW_API_KEY` | 余额型:`GET /v1/user/info` 返回账户余额,**与推理是同一把 Key** |
258
257
  | OpenRouter | `OPENROUTER_API_KEY` | key 限额与用量用推理 key 即可;**账户余额需要 management / provisioning key**,普通 key 会被 403 拒——此时静默降级为只显示限额,不判失败 |
259
258
  | Synthetic | `SYNTHETIC_API_KEY` | 模型订阅请求额度 |
@@ -297,7 +296,6 @@ arkcli-agent-plan ARKCLI_AGENT_PLAN_API_KEY (额度用 VOLC_ACCESS
297
296
  arkcli-coding-plan ARKCLI_CODING_PLAN_API_KEY
298
297
  arkcli-agent-plan-team ARKCLI_AGENT_PLAN_TEAM_API_KEY
299
298
  arkcli-coding-plan-team ARKCLI_CODING_PLAN_TEAM_API_KEY
300
- ark-coding-plan-byteplus ARK_CODING_PLAN_BYTEPLUS_API_KEY
301
299
  ```
302
300
 
303
301
  解析顺序(`inherit` 模式):**凭据服务 → 启动环境 → 旧手动配置兜底**;自定义(`manual`)模式只使用保存的手动 Key。Cookie 与登录文件型凭据不走这条链,见 [MiMo 登录与 Cookie](docs/providers.md#mimo-登录与-cookie) 与 [Codex ChatGPT 订阅凭据](docs/providers.md#codex-chatgpt-订阅凭据)。修改启动环境后是否需要重启取决于目标部署,不能把「我已经改了环境变量」当作运行进程已经读到。
@@ -312,7 +310,7 @@ ark-coding-plan-byteplus ARK_CODING_PLAN_BYTEPLUS_API_KEY
312
310
  |---|---|---|
313
311
  | `~/.dsh/dsh-subusage.json` | 本插件自己的配置:开关、手动 Key、MiMo Cookie、Z.ai 组织 / 项目、LiteLLM 地址、火山 AK/SK、计时元数据 | 读写;POSIX 上新建目录 `0700`、临时文件 `0600`,替换前再收紧 |
314
312
  | `~/.codex/auth.json`(或 `$CODEX_HOME/auth.json`) | Codex CLI 的 ChatGPT 登录(要求 `auth_mode: chatgpt`) | **只读**,不保存、不刷新 |
315
- | `~/.grok/auth.json`(回退 `~/.dsh/.xai-oauth-auth.json`) | Grok CLI / dsh-grok-kit 的 OAuth 登录 | **只读**,不保存、不刷新 |
313
+ | `~/.grok/auth.json`(回退 `~/.dsh/.xai-oauth-auth.json`) | Grok CLI / dsh-grok-kit 的 OAuth 登录 | 平时只读;过期时经 grok-kit 官方链续约并写回同一文件,不自行裸刷 |
316
314
  | `~/.commandcode/auth.json` | Command Code CLI(`cmd login`)的 Key | **只读**兜底 |
317
315
 
318
316
  **网络访问**:只连上面清单里各家自己的额度 / 余额端点,以及 `platform.xiaomimimo.com`(MiMo 用量与登录)、`api.commandcode.ai`、`cli-chat-proxy.grok.com`、`chatgpt.com/backend-api`、火山管控面 `open.volcengineapi.com`,外加你自己填写的 LiteLLM proxy 地址。完整域名表见 [网络端点](docs/providers.md#网络端点)。MiMo 自动登录会另外打开官方平台页面,由你在该页面自行输入密码与验证码。**本插件没有自己的服务器,不做遥测,也不把你的数据发给任何第三方。**
@@ -346,9 +344,9 @@ ark-coding-plan-byteplus ARK_CODING_PLAN_BYTEPLUS_API_KEY
346
344
 
347
345
  调用约定(**结果永远不为空**):
348
346
 
349
- - 模型手里的名字往往不是这里的 provider id(它更可能看到 DSH 的路由名,例如 `zai`),所以匹配是**宽松**的:一个名字命中多条路由时**全部返回**(`zai` → 中国版 + 国际版,`ark` → 5 条),每条各自带着真实 `state`(没启用的如实标 `disabled`),由调用方自己判断要看哪条。
350
- - 一个名字都认不出时,返回里会**先**给一条说明(写明可用写法),**再附上全部 30 条数据**——数据先给出去,判断交给调用方,而不是回一个空结果。
351
- - **点名的家原样返回,其余按「有没有数据」收起**:`providers: ["kimi"]` 时被点中的家即使没配凭据也照原样给出(用户问的正是「它为什么没数据」);**不点名时**——以及名字一个都没认出来、走全量兜底时——省略检测已关闭、未配置凭据、以及接口没报告任何额度的条目,省略条数按**家**写进 `omitted`(同一家的多个 Command Code 账户只算一家),否则三十来条路由会把真正的数字淹掉。**读取失败的条目会保留**——那是「这次没读到」,不是「没配置」,省略掉只会把排障方向带偏;认不出的名字自己那行也保留。
347
+ - 模型手里的名字往往不是这里的 provider id(它更可能看到 DSH 的路由名,例如 `zai`),所以匹配是**宽松**的:一个名字命中多条路由时**全部返回**(`zai` → 中国版 + 国际版,`ark` → 4 条),每条各自带着真实 `state`(没启用的如实标 `disabled`),由调用方自己判断要看哪条。
348
+ - 一个名字都认不出时,返回里会**先**给一条说明(写明可用写法),**再附上全部 27 条数据**——数据先给出去,判断交给调用方,而不是回一个空结果。
349
+ - **点名的家原样返回,其余按「有没有数据」收起**:`providers: ["kimi"]` 时被点中的家即使没配凭据也照原样给出(用户问的正是「它为什么没数据」);**不点名时**——以及名字一个都没认出来、走全量兜底时——省略检测已关闭、未配置凭据、以及接口没报告任何额度的条目,省略条数按**家**写进 `omitted`(同一家的多个 Command Code 账户只算一家),否则二十多条路由会把真正的数字淹掉。**读取失败的条目会保留**——那是「这次没读到」,不是「没配置」,省略掉只会把排障方向带偏;认不出的名字自己那行也保留。
352
350
  - **Command Code 按账户各回一条**:账户清单读自提供方插件的 `llm-commandcode` 设置,每条带 `account`(账户名)、`accountActive`(当前激活的那个)与 `accountMode`(`fixed` 固定账户 / `auto` 自动轮换),激活账户排在最前。「还剩多少」与「现在实际用哪个账户」一次问清。
353
351
 
354
352
  **Host 服务**(Cordis `Service`,key 为 `subUsage`):
@@ -392,7 +390,7 @@ const view = await service.quota({ providerIds: ["deepseek"], force: false });
392
390
  | 保存提示配置已改变 | 另一窗口或实例更新了设置;重新读取配置后再编辑,避免旧表单覆盖新值 |
393
391
  | 升级后仍是旧界面或 RPC 不匹配 | 确认运行中的 Host 和 Client 都已重载;仅刷新页面不能保证 Host 升级 |
394
392
  | Command Code 额度与实际账户不同 | 在 设置 → 订阅用量 的 Command Code 面板或用量的药丸弹层里都能切换提供方插件的服务账户(同一开关):「自动轮换」时显示默认账户(顶层 Key)的用量,不跟随轮换中的实际服务账户,提供方自定义 `apiBase` 也不跟随。想一次看全部账户的余量与当前激活项,可让 Agent 调 `subusage_quota(providers: ["commandcode"])` |
395
- | SuperGrok 提示认证失效 | 登录文件里的 OAuth token 会过期;在 设置 → Grok Kit 重新登录、运行 `grok login`,或让 Grok 侧使用一次以刷新共享登录文件后重试。API Key(`XAI_API_KEY`)取不到订阅周池 |
393
+ | SuperGrok 提示认证失效 | access token 过期时插件会先经 dsh-grok-kit(≥0.2.0)的官方登录链自动续约同一共享登录文件,通常无需操作;只有未安装/旧版 dsh-grok-kit 或续约失败(如 refresh_token 已失效)才需在 设置 → Grok Kit 重新登录或运行 `grok login` 后重试。API Key(`XAI_API_KEY`)取不到订阅周池 |
396
394
  | 火山方舟报签名或权限错误 | AK 与 SK 必须来自同一个 IAM 用户(跨来源拼凑只会 401 且看不出根因);确认用的是 IAM Access Key 而不是方舟推理 API Key;403 多为未订阅或权限不足 |
397
395
 
398
396
  ## Development / 开发
package/lib/client.js CHANGED
@@ -128,7 +128,7 @@ window.__ModuleLoader__.load({
128
128
  accountLabel: "账户",
129
129
  commandcodeManaged: "凭据由 Command Code 提供方插件(设置 → Command Code)管理;本页只读、不保存凭据。用量由本插件直接拉取其计费接口,取用同一凭据来源(凭据服务 COMMANDCODE_API_KEY → 启动环境 → ~/.commandcode/auth.json)。",
130
130
  commandcodeHelp: "并行读取 /alpha/billing/credits 和 /alpha/billing/subscriptions(默认 API 地址 https://api.commandcode.ai)。月总额按已知套餐快照推算,未知套餐只显示月剩余;月池耗尽不代表已购/赠送池不可用。显示选中账户的额度(未选择时为默认账户/顶层 Key),可能与实际服务账户不同;插件里自定义 apiBase 不会被跟随。账户切换就在下方「Command Code 账户」区,与药丸弹层同一开关;模型查询额度工具时会同时给出全部账户的余量与当前激活项。",
131
- grokManaged: "凭据由 dsh-grok-kit / Grok CLI 的 OAuth 登录文件(~/.grok/auth.json)管理;本页只读、不保存凭据、不刷新 token。用量由本插件取同一凭据直连 Grok CLI 计费代理(cli-chat-proxy.grok.com)拉取。",
131
+ grokManaged: "凭据由 dsh-grok-kit / Grok CLI 的 OAuth 登录文件(~/.grok/auth.json)管理;本页不保存凭据,平时只读该文件。access token 过期时,本插件借 dsh-grok-kit 的官方登录链自动续约(同一文件、同一把写锁,不会顶掉它的登录态);未安装 dsh-grok-kit 或续约失败时才提示手动重新登录。用量由本插件取同一凭据直连 Grok CLI 计费代理(cli-chat-proxy.grok.com)拉取。",
132
132
  grokHelp: "并行读取 /v1/billing?format=credits 与 /v1/settings。creditUsagePercent 是统一用量池的已用百分比(通常为周池,重置于 currentPeriod.end,剩余 = 100 − 已用);套餐名取 subscription_tier_display;已购加量余额 prepaidBalance 以美元显示。API Key(XAI_API_KEY)路线取不到订阅周池,必须 OAuth 登录。",
133
133
  ccAccountsTitle: "Command Code 账户",
134
134
  ccAccountAuto: "自动轮换",
@@ -139,7 +139,29 @@ window.__ModuleLoader__.load({
139
139
  ccUsageAccount: "上方用量:{account}",
140
140
  ccUsageAutoNote: "自动轮换时上方用量仍是默认账户(顶层 Key),不跟随实际服务账户",
141
141
  ccSwitching: "切换中…",
142
- ccSwitchFailed: "切换失败"
142
+ ccSwitchFailed: "切换失败",
143
+ // Command Code 自动轮换池健康度(其余账户用量由 Host 并行读取,各自缓存 60 秒)。
144
+ ccPoolTitle: "轮换账户池",
145
+ ccPoolAllHealthy: "{n} 个账户均可轮换使用",
146
+ ccPoolCapacity: "{available}/{total} 个账户可用,{blocked} 个已达限额",
147
+ ccPoolUnusable: "{unusable} 个账户无可用凭据或暂时读不到",
148
+ ccPoolExhausted: "轮换账户均已达限额,新请求很可能被拒绝",
149
+ ccPoolExhaustedShort: "池已满限额",
150
+ ccPoolClickToPin: "点击可固定到此账户",
151
+ // Host 失败原因按 errorCode 本地化(Host message 为中文兜底,原文保留在节点 title 供排障)。
152
+ errNetwork: "网络请求失败或超时,请稍后重试",
153
+ errHttp: "服务暂时不可用,请稍后重试",
154
+ errRateLimit: "请求过于频繁,已按服务端要求退避,请稍后再试",
155
+ errAuth: "登录或凭据已失效,请检查或重新配置",
156
+ errCredentials: "凭据无法解析,请检查凭据来源",
157
+ errIdentityTimeout: "凭据解析超时,请稍后重试",
158
+ errResponse: "用量响应无法解析(上游接口可能已变更)",
159
+ errConfig: "本机订阅设置无法读取或保存",
160
+ errRevisionConflict: "设置已被其他窗口修改,请重新读取后再保存",
161
+ errConfigChanged: "读取期间配置已改变,请刷新",
162
+ errUnknownProvider: "本机运行中的 Host 版本较旧,不认识该提供商;重启 DSH 后生效",
163
+ errTimeout: "查询超时,请稍后重试或减少查询家数",
164
+ errDisposed: "服务已卸载"
143
165
  };
144
166
  const en = {
145
167
  nav: "Subscription usage",
@@ -250,7 +272,7 @@ window.__ModuleLoader__.load({
250
272
  accountLabel: "Account",
251
273
  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).",
252
274
  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 selected account's quota (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. Account switching lives in the “Command Code accounts” block below — the same switch as the pill popover; the quota tool reports every account plus which one is active.",
253
- grokManaged: "Credentials are the OAuth login file shared by dsh-grok-kit / Grok CLI (~/.grok/auth.json); this page is read-only — it stores nothing and never refreshes the token. Usage is fetched directly by this plugin from the Grok CLI billing proxy (cli-chat-proxy.grok.com) with the same credential.",
275
+ grokManaged: "Credentials are the OAuth login file shared by dsh-grok-kit / Grok CLI (~/.grok/auth.json); this page stores nothing and only reads the file. When the access token expires, this plugin renews it through dsh-grok-kit's (≥0.2.0) official sign-in chain — same file, same write lock, so the shared login cannot be invalidated by this renewal; it only asks for a manual re-login when dsh-grok-kit is absent or renewal fails. Usage is fetched directly from the Grok CLI billing proxy (cli-chat-proxy.grok.com) with the same credential.",
254
276
  grokHelp: "Parallel requests to /v1/billing?format=credits and /v1/settings. creditUsagePercent is the USED percentage of the unified usage pool (usually weekly, resetting at currentPeriod.end; remaining = 100 − used). The plan name comes from subscription_tier_display; the purchased top-up balance prepaidBalance is shown in USD. An API key (XAI_API_KEY) cannot read the subscription pool — OAuth sign-in is required.",
255
277
  ccAccountsTitle: "Command Code accounts",
256
278
  ccAccountAuto: "Automatic (rotation order)",
@@ -261,7 +283,29 @@ window.__ModuleLoader__.load({
261
283
  ccUsageAccount: "Usage above: {account}",
262
284
  ccUsageAutoNote: "with automatic rotation the usage above stays on the default (top-level key) account, not the serving one",
263
285
  ccSwitching: "Switching…",
264
- ccSwitchFailed: "Switch failed"
286
+ ccSwitchFailed: "Switch failed",
287
+ // Command Code rotation-pool health (the Host reads the other accounts in parallel, each cached 60s).
288
+ ccPoolTitle: "Rotation account pool",
289
+ ccPoolAllHealthy: "All {n} accounts are available",
290
+ ccPoolCapacity: "{available}/{total} accounts available, {blocked} at their limit",
291
+ ccPoolUnusable: "{unusable} account(s) have no usable credentials or could not be read",
292
+ ccPoolExhausted: "Every rotation account is at its limit; new requests are likely to be rejected",
293
+ ccPoolExhaustedShort: "pool at limit",
294
+ ccPoolClickToPin: "Click to pin to this account",
295
+ // Host failure reasons localized by errorCode (the Host message stays Chinese; the raw text remains in node title for debugging).
296
+ errNetwork: "Network request failed or timed out; retry shortly",
297
+ errHttp: "The service is temporarily unavailable; retry shortly",
298
+ errRateLimit: "Rate limited; backing off as the server asked, retry shortly",
299
+ errAuth: "Sign-in or credentials were rejected; check or re-enter them",
300
+ errCredentials: "Credentials could not be resolved; check the credential source",
301
+ errIdentityTimeout: "Credential resolution timed out; retry shortly",
302
+ errResponse: "The usage response could not be parsed (the upstream API may have changed)",
303
+ errConfig: "The local subscription settings could not be read or saved",
304
+ errRevisionConflict: "Settings were changed in another window; reload before saving",
305
+ errConfigChanged: "Settings changed during the read; refresh",
306
+ errUnknownProvider: "The running Host is older and does not know this provider; restart DSH to take effect",
307
+ errTimeout: "The query timed out; retry shortly or query fewer providers",
308
+ errDisposed: "The service has been unloaded"
265
309
  };
266
310
  const NS = "settings.subusage";
267
311
  const KIND_LABEL = { "5h": "w5h", "7d": "w7d", day: "wday", week: "wweek", month: "wmonth", period: "wperiod", sub: "wsub", rolling: "wrolling" };
@@ -282,7 +326,6 @@ window.__ModuleLoader__.load({
282
326
  // SuperGrok / X Premium 订阅(dsh-grok-kit 的 xai-oauth 路由):OAuth 登录文件凭据,无继承变量。
283
327
  "xai-oauth": { label: "SuperGrok", short: "SuperGrok", envName: "", managedByPlugin: true },
284
328
  // Codex / ChatGPT 订阅:凭据是 Codex CLI 的 OAuth 登录文件,只读、不刷新、不接受本地 Key。
285
- // 默认开启:它对齐的是 DSH 内置的 openai-codex 路由,没登录时会被「默认隐藏」自动收起。
286
329
  // 默认关闭:Codex 的提供方插件自带用量药丸,两个并排只是重复信息(仍可手动开启)。
287
330
  "openai-codex": { label: "Codex (ChatGPT)", short: "Codex", envName: "", managedByPlugin: true, defaultEnabled: false },
288
331
  // 国际版默认关闭,中国版保持默认开启。
@@ -319,7 +362,7 @@ window.__ModuleLoader__.load({
319
362
  };
320
363
  // 顺序只影响展示:DeepSeek 放在末尾以免打乱既有条目位置;药丸匹配靠 provider id,与顺序无关。
321
364
  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", "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"];
322
- // 火山方舟的 7 条路由共用同一组 IAM AK/SK,而一般用户只持有一个套餐,所以设置页只给一张卡片、
365
+ // 火山方舟的 4 条路由共用同一组 IAM AK/SK,而一般用户只持有一个套餐,所以设置页只给一张卡片、
323
366
  // 一个开关(组开关落在 Host 的 visibility.ark 上)。这些 id 不能真正合并——药丸靠 provider id 匹配路由。
324
367
  const ARK_IDS = PROVIDER_ORDER.filter(id => PROVIDER_META[id]?.volc);
325
368
  /**
@@ -349,7 +392,7 @@ window.__ModuleLoader__.load({
349
392
  if (settings?.visibility?.hideWithoutApi === false) return true;
350
393
  // 火山方舟**不按推理路由收起**:额度是账号级订阅,路由装没装与额度无关——一组 IAM AK/SK 就能
351
394
  // 查到名下所有套餐(GetCodingPlanUsage 与 GetAFPUsage 同协议同凭据)。但它**要按接口回报的
352
- // 订阅状态收起**:7 条路由一律列出来,用户根本分不清哪个是哪个,而其中多数是「未检测到该套餐订阅」。
395
+ // 订阅状态收起**:4 条路由一律列出来,用户根本分不清哪个是哪个,而其中多数是「未检测到该套餐订阅」。
353
396
  // `subscribed === undefined` 表示还没拉到数据,按「未知」放行——不能因为一次没拉到就把有订阅的藏起来。
354
397
  if (PROVIDER_META[id]?.volc) {
355
398
  if (entry?.subscribed === false) return false;
@@ -467,8 +510,11 @@ window.__ModuleLoader__.load({
467
510
  if (!value || typeof value !== "object") throw new Error("Invalid dsh-subusage settings");
468
511
  if (value.visibility !== undefined) {
469
512
  const v = value.visibility;
470
- if (!v || typeof v !== "object" || Array.isArray(v) || Object.keys(value).some(k => !["visibility", "expectedRevision"].includes(k)) || typeof value.expectedRevision !== "string" || !value.expectedRevision || !Object.keys(v).length || Object.keys(v).some(k => !["providers", "hideWithoutApi"].includes(k))) throw new Error("Invalid visibility patch");
513
+ // 白名单必须与 Host 的 parseSettings 逐字对齐:火山组开关走 visibility.ark(Host 合法字段),
514
+ // 漏掉它会让两端 schema 漂移(当前网关出站不校验,但任何复用本 parser 的出站校验都会致障)。
515
+ if (!v || typeof v !== "object" || Array.isArray(v) || Object.keys(value).some(k => !["visibility", "expectedRevision"].includes(k)) || typeof value.expectedRevision !== "string" || !value.expectedRevision || !Object.keys(v).length || Object.keys(v).some(k => !["providers", "hideWithoutApi", "ark"].includes(k))) throw new Error("Invalid visibility patch");
471
516
  if (v.hideWithoutApi !== undefined && typeof v.hideWithoutApi !== "boolean") throw new Error("Invalid auto-hide switch");
517
+ if (v.ark !== undefined && typeof v.ark !== "boolean") throw new Error("Invalid Ark group switch");
472
518
  if (v.providers !== undefined && (!v.providers || typeof v.providers !== "object" || Array.isArray(v.providers) || !Object.keys(v.providers).length || Object.entries(v.providers).some(([id, enabled]) => !PROVIDER_ORDER.includes(id) || typeof enabled !== "boolean"))) throw new Error("Invalid provider switches");
473
519
  }
474
520
  return value;
@@ -614,6 +660,31 @@ window.__ModuleLoader__.load({
614
660
  if (entry.coverage === "partial") return t("statusPartial");
615
661
  return t("statusOk");
616
662
  }
663
+ /**
664
+ * Host 的 failure message 是中文兜底(含错误特有的排障细节),英文界面直接渲染会中英混杂。
665
+ * 按稳定的 errorCode 出当前语言文案;认不出的 code 或传输层错误(无 code)保留原文——
666
+ * 原文可能携带只有它才有的信息(如 HTTP 状态、火山 Action 名),调用方把它放进节点 title。
667
+ */
668
+ const ERROR_TEXT_KEY = {
669
+ "subusage/network": "errNetwork",
670
+ "subusage/http": "errHttp",
671
+ "subusage/rate-limit": "errRateLimit",
672
+ "subusage/auth": "errAuth",
673
+ "subusage/credentials": "errCredentials",
674
+ "subusage/identity-timeout": "errIdentityTimeout",
675
+ "subusage/response": "errResponse",
676
+ "subusage/config": "errConfig",
677
+ "subusage/revision-conflict": "errRevisionConflict",
678
+ "subusage/config-changed": "errConfigChanged",
679
+ "subusage/unknown-provider": "errUnknownProvider",
680
+ "subusage/timeout": "errTimeout",
681
+ "subusage/disposed": "errDisposed"
682
+ };
683
+ function localError(errorCode, raw, t) {
684
+ const key = typeof errorCode === "string" ? ERROR_TEXT_KEY[errorCode] : undefined;
685
+ if (key) return t(key);
686
+ return typeof raw === "string" && raw ? raw : t("statusError");
687
+ }
617
688
  /**
618
689
  * 需要用户行动时的内联入口类型。设置页的「状态」在上(用量面板)、「操作」在下(提供商管理),
619
690
  * 出问题时用户得先去下面找到对应厂商才能修——这里让问题旁边直接出现修复动作。
@@ -690,7 +761,14 @@ window.__ModuleLoader__.load({
690
761
  };
691
762
  const refresh = (ids, force = false) => {
692
763
  if (disposed) return Promise.reject(new Error("Client 已卸载"));
693
- const unique = [...new Set(ids)].filter((id) => PROVIDER_ORDER.includes(id) && providerEnabled(snapshot.settings, id) && providerRoutable(snapshot, id));
764
+ // Ark 必须豁免路由检查:它的额度是账号级管控面订阅(一组 AK/SK 查名下全部套餐),
765
+ // providerVisible 对 volc 同样不看推理路由——这里若按 providerRoutable 过滤,
766
+ // 「有套餐但本机没装对应 arkcli 路由」的条目在激活刷新、重置到期补拉与定时刷新时
767
+ // 全部静默不发请求,用户点「刷新当前」也空转,只有「刷新全部」走 read 才能更新。
768
+ const unique = [...new Set(ids)].filter((id) => {
769
+ if (!PROVIDER_ORDER.includes(id) || !providerEnabled(snapshot.settings, id)) return false;
770
+ return PROVIDER_META[id]?.volc ? true : providerRoutable(snapshot, id);
771
+ });
694
772
  const start = (id) => {
695
773
  if (inflight.has(id)) return inflight.get(id);
696
774
  const startedEpoch = epoch, request = markRequest([id]);
@@ -785,12 +863,6 @@ window.__ModuleLoader__.load({
785
863
  };
786
864
  }
787
865
 
788
- // ── 用量药丸(匹配当前选中模型的 provider)──────────────────────────────
789
- function severity(percent, status) {
790
- if (status === "rate-limited" || percent >= 100) return "limited";
791
- return percent >= 80 ? "high" : void 0;
792
- }
793
-
794
866
  // ── Command Code 账户切换(对齐提供方插件的账户显示)────────────────────
795
867
  // 账户列表与 id 规则对齐 @mars-sea/dsh-commandcode-provider 的 slots():
796
868
  // 默认账户 id 固定 "default";额外账户 id 取其 apiKeyEnv(凭据引用名)。
@@ -948,6 +1020,53 @@ window.__ModuleLoader__.load({
948
1020
  );
949
1021
  }
950
1022
 
1023
+ /**
1024
+ * Command Code 自动轮换池健康摘要(弹层与设置页共用):
1025
+ * 数据来自 Host 挂在 commandcode entry 上的 accountPool(auto 模式才有)。
1026
+ * 不简单取「最差账户」染红——账户间是独立配额,单个限额轮换器会避开它;
1027
+ * 只有全部有凭据账户都限额(exhausted)才是真的要 429。点账户行可固定到它。
1028
+ */
1029
+ function CommandCodePoolSummary({ pool, cc, t }) {
1030
+ if (!pool || pool.mode !== "auto" || !Array.isArray(pool.accounts) || pool.total <= 1) return null;
1031
+ const summary = pool.exhausted
1032
+ ? t("ccPoolExhausted")
1033
+ : pool.blockedCount > 0
1034
+ ? t("ccPoolCapacity").replace("{available}", pool.available).replace("{total}", pool.total).replace("{blocked}", pool.blockedCount)
1035
+ : t("ccPoolAllHealthy").replace("{n}", pool.total);
1036
+ const tone = pool.exhausted ? "#ef4444" : pool.blockedCount > 0 ? "#eab308" : "#22c55e";
1037
+ const rowFor = (account) => {
1038
+ const worst = worstWindow(account.windows || []);
1039
+ const tier = account.usable || account.blocked ? usageTier(worst || { percent: 0, status: "ok" }) : null;
1040
+ const blocked = account.blocked;
1041
+ const unusable = !account.usable && !account.blocked;
1042
+ const dot = statusGlyph(unusable ? NEUTRAL : (tier ? tier.color : NEUTRAL), unusable ? "?" : blocked ? "✕" : (tier ? tier.icon : "?"), 14);
1043
+ const detail = unusable
1044
+ ? (account.state === "error" ? t("statusError") : account.state === "disabled" ? t("statusDisabled") : account.state === "no-cookie" ? t("statusNoCookie") : t("statusNoKey"))
1045
+ : blocked
1046
+ ? t("pillLimited")
1047
+ : worst ? pillText(worst, (account.windows || []).length > 1, t)
1048
+ : t("noUsageData");
1049
+ const canPin = cc && account.id !== "default";
1050
+ return h("button", {
1051
+ key: account.id, type: "button", disabled: !canPin,
1052
+ title: canPin ? t("ccPoolClickToPin") : undefined,
1053
+ onClick: canPin ? () => void cc.switchTo(account.id) : undefined,
1054
+ style: { display: "flex", alignItems: "center", gap: 6, width: "100%", textAlign: "left", marginTop: 4, padding: "3px 6px", borderRadius: 6, border: "1px solid rgba(128,128,128,.25)", background: "transparent", color: "inherit", font: "inherit", fontSize: 11, cursor: canPin ? "pointer" : "default" }
1055
+ },
1056
+ dot,
1057
+ h("span", { style: { flex: 1, minWidth: 0, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" } }, account.id === "default" ? t("ccAccountDefault") : account.label),
1058
+ h("span", { style: { opacity: 0.75, whiteSpace: "nowrap" } }, detail));
1059
+ };
1060
+ return h("div", { style: { marginTop: 10, padding: "8px 10px", borderRadius: 10, border: `1px solid ${alpha(tone, ".35")}`, background: alpha(tone, ".07") } },
1061
+ h("div", { style: { display: "flex", alignItems: "center", gap: 6, fontSize: 11.5, fontWeight: 600, color: textTone(tone) } },
1062
+ h("span", null, t("ccPoolTitle")),
1063
+ h("span", { style: { flex: 1 } }),
1064
+ h("span", { style: { fontWeight: 500, opacity: 0.9 } }, `${pool.available}/${pool.total}`)),
1065
+ h("p", { role: pool.exhausted ? "alert" : "status", style: { margin: "3px 0 0", fontSize: 11, color: textTone(tone) } }, summary),
1066
+ pool.unusableCount > 0 ? h("p", { style: { margin: "2px 0 0", fontSize: 10.5, opacity: 0.7 } }, t("ccPoolUnusable").replace("{unusable}", pool.unusableCount)) : null,
1067
+ h("div", { style: { marginTop: 4 } }, pool.accounts.map(rowFor)));
1068
+ }
1069
+
951
1070
  // ── 视觉原语:扁平 + 状态色(药丸 / 弹层 / 设置页共用同一套)────────────────
952
1071
  // 注意:下面这些都是**普通函数调用**而不是 React 组件元素——它们不用 Hook,直接内联展开,
953
1072
  // 测试里的 React 桩也能看到它们渲染出的文字。
@@ -1030,7 +1149,8 @@ window.__ModuleLoader__.load({
1030
1149
  return { ...base, key: "config", color: INFO, icon: "⚙", title: t(state === "no-key" ? "statusNoKey" : "statusNoCookie"), sub: needConfig, short: t(state === "no-key" ? "needConfigShort" : "statusNoCookie"), tinted: true };
1031
1150
  }
1032
1151
  if (!entry) {
1033
- return { ...base, key: "error", color: "#ef4444", icon: "!", title: t("statusError"), sub: failed || (rawEntry && rawEntry.error) || "", short: t("statusError"), tinted: true };
1152
+ const rawReason = failed || (rawEntry && rawEntry.error) || "";
1153
+ return { ...base, key: "error", color: "#ef4444", icon: "!", title: t("statusError"), sub: localError(rawEntry?.errorCode, rawReason, t), rawReason, short: t("statusError"), tinted: true };
1034
1154
  }
1035
1155
  if (degraded) {
1036
1156
  // 过期缓存即使 state=error 也要叫「缓存数据」:读取失败是原因,用户看到的是上次的数字。
@@ -1206,8 +1326,14 @@ window.__ModuleLoader__.load({
1206
1326
  }, [open]);
1207
1327
  const rawEntry = snapshot && snapshot.entry;
1208
1328
  if (rawEntry?.visible === false || rawEntry?.state === "disabled") return null;
1329
+ // Command Code 自动轮换:默认账户健康不代表池健康。所有有凭据账户都限额(exhausted)
1330
+ // 时,用池里一条 rate-limited 窗口喂给 computeStatus,让药丸/横幅如实染红;部分受限不染红
1331
+ // (轮换器会避开该账户),只在弹层池摘要里提示容量降级。
1332
+ const pool = providerId === "commandcode" ? (rawEntry?.accountPool ?? null) : null;
1333
+ const exhaustedWindow = pool?.exhausted ? (pool.accounts.flatMap(a => a.windows || []).find(w => w.status === "rate-limited") ?? null) : null;
1334
+ const statusEntry = exhaustedWindow ? { ...rawEntry, state: "ok", coverage: "complete", freshness: "fresh", windows: [exhaustedWindow] } : rawEntry;
1209
1335
  // 整体状态由 computeStatus 一处判定:药丸、弹层横幅、设置页横幅读的是同一个结论。
1210
- const st = computeStatus(rawEntry, { failed, failState, providerId, t, getLocale });
1336
+ const st = computeStatus(statusEntry, { failed, failState, providerId, t, getLocale });
1211
1337
  const { entry, windows, worst, degraded, expiry, expiring } = st;
1212
1338
  const summary = worst ? pillText(worst, windows.length > 1, t) : entry ? (balanceText(entry, t) ?? t("noUsageData")) : t("providerUnavailable");
1213
1339
  // 药丸只回答「订阅现在怎么样」:状态图形 + 至多一个词。正常时只剩彩色余量条——
@@ -1252,7 +1378,7 @@ window.__ModuleLoader__.load({
1252
1378
  planName(entry) ? h("span", { style: { fontSize: 11, fontWeight: 600, padding: "1px 8px", borderRadius: 999, background: "rgba(128,128,128,.16)", whiteSpace: "nowrap" } }, planName(entry)) : null),
1253
1379
  // 状态横幅:先给结论(充足 / 偏少 / 即将用尽 / 已用尽 / 需配置 / 失败…)与一行依据。
1254
1380
  statusBanner(st),
1255
- failed && st.key !== "error" && !st.needConfig ? h("p", { style: { color: "#f87171", margin: "8px 0 0", whiteSpace: "pre-wrap" } }, failed) : null,
1381
+ failed && st.key !== "error" && !st.needConfig ? h("p", { role: "alert", title: failed, style: { color: "#f87171", margin: "8px 0 0", whiteSpace: "pre-wrap" } }, localError(rawEntry?.errorCode, failed, t)) : null,
1256
1382
  // Cookie 到期常驻弹层:即使用量正常,也让用户看见 Cookie 还剩多久(已在横幅里说明时不重复)。
1257
1383
  expiry && st.key !== "cookie" ? h("p", { role: "status", style: { margin: "8px 0 0", fontSize: 11, color: expiring ? textTone(expiry.color) : "inherit", opacity: expiring ? 1 : 0.75 } }, `${expiry.label} · ${t("mimoCookieExpiresAt").replace("{time}", new Date(expiry.expiresAt).toLocaleString(getLocale()))}`) : null,
1258
1384
  // 用量账户标注:账户区 ready 时说明上方数字属于哪个账户;自动轮换时明确不跟随实际服务账户。
@@ -1261,6 +1387,9 @@ window.__ModuleLoader__.load({
1261
1387
  t("ccUsageAccount").replace("{account}", ccState.activeId === "" || ccState.activeId === "default" ? t("ccAccountDefault") : (ccState.accounts.find((a) => a.id === ccState.activeId)?.label ?? ccState.activeId)),
1262
1388
  ccState.activeId === "" ? h("span", { style: { opacity: 0.85 } }, ` · ${t("ccUsageAutoNote")}`) : null)
1263
1389
  : null,
1390
+ // 自动轮换池健康度:默认账户之外的账户也可能已限额,池耗尽时药丸已染红,这里给出逐账户明细。
1391
+ // 直接函数调用(非 React 元素),与 UsageWindowList 等视觉原语同约定,测试桩可见展开内容。
1392
+ pool ? CommandCodePoolSummary({ pool, cc, t }) : null,
1264
1393
  // 额度线切换时插一条分组标题:AFP 的日限额只覆盖视觉 / 语音模型与 Harness,与文本模型的
1265
1394
  // 5 小时 / 周 / 月不是同一条线,混排会让人把两条线的配额当成一组来互相比较。
1266
1395
  UsageWindowList({ windows, t, getLocale, uncertain: degraded }),
@@ -1365,14 +1494,14 @@ window.__ModuleLoader__.load({
1365
1494
  const text = (cn, en) => local ? cn : en;
1366
1495
  react.useEffect(() => {
1367
1496
  let alive = true;
1368
- void usageStore.readAll().then(() => { if (alive) setLoadError(null); }).catch((error) => { if (alive) setLoadError(String(error.message || error)); });
1497
+ void usageStore.readAll().then(() => { if (alive) setLoadError(null); }).catch((error) => { if (alive) setLoadError(localError(error.code, String(error.message || error), t)); });
1369
1498
  return () => { alive = false; };
1370
1499
  }, [usageStore]);
1371
1500
  react.useEffect(() => {
1372
1501
  let alive = true;
1373
1502
  const enabled = editorOpen && providerEnabled(result.settings, tab);
1374
1503
  const deactivate = enabled ? usageStore.activate(tab) : () => {};
1375
- void usageStore.readAll().then(() => { if (alive && enabled && providerEnabled(usageStore.getSnapshot().settings, tab)) return usageStore.refresh([tab], false); }).catch((error) => { if (alive) setLoadError(String(error.message || error)); });
1504
+ void usageStore.readAll().then(() => { if (alive && enabled && providerEnabled(usageStore.getSnapshot().settings, tab)) return usageStore.refresh([tab], false); }).catch((error) => { if (alive) setLoadError(localError(error.code, String(error.message || error), t)); });
1376
1505
  return () => { alive = false; deactivate(); };
1377
1506
  }, [usageStore, tab, editorOpen, providerEnabled(result.settings, tab)]);
1378
1507
  react.useEffect(() => {
@@ -1433,7 +1562,7 @@ window.__ModuleLoader__.load({
1433
1562
  const refresh = async (all) => {
1434
1563
  setRefreshing(true);
1435
1564
  try { if (!result.settings) await usageStore.readAll(); else await usageStore.refresh(all ? PROVIDER_ORDER : usageId ? [usageId] : [], true); if (aliveRef.current) setLoadError(null); }
1436
- catch (error) { if (aliveRef.current) setLoadError(String(error.message || error)); }
1565
+ catch (error) { if (aliveRef.current) setLoadError(localError(error.code, String(error.message || error), t)); }
1437
1566
  finally { if (aliveRef.current) setRefreshing(false); }
1438
1567
  };
1439
1568
  const saveVisibility = async (visibility) => {
@@ -1448,7 +1577,7 @@ window.__ModuleLoader__.load({
1448
1577
  // 新启用条目先重新探测凭据;保存成功不与接口验证混为一谈。
1449
1578
  const enabled = Object.entries(visibility.providers || {}).filter(([, value]) => value).map(([id]) => id);
1450
1579
  if (enabled.length) { try { await usageStore.refresh(enabled, false); } catch { if (aliveRef.current) setLoadError(text("设置已保存;用量刷新失败,可稍后重试。", "Settings saved; usage refresh failed. Retry later.")); } }
1451
- } catch (error) { if (aliveRef.current) setNotice({ ok: false, text: String(error.message || error) }); }
1580
+ } catch (error) { if (aliveRef.current) setNotice({ ok: false, text: localError(error.code, String(error.message || error), t) }); }
1452
1581
  finally { if (aliveRef.current) setBusy(false); }
1453
1582
  };
1454
1583
  const onSave = async () => {
@@ -1473,16 +1602,19 @@ window.__ModuleLoader__.load({
1473
1602
  const current = verified.entries.find((e) => e.providerId === tab);
1474
1603
  if (aliveRef.current) setLoadError(current && current.state === "ok" ? null : text("已保存;验证获取失败:", "Saved; validation fetch failed: ") + (current && current.error || statusText(current, t)));
1475
1604
  } catch (error) { if (aliveRef.current) setLoadError(text("已保存;验证获取失败:", "Saved; validation fetch failed: ") + String(error.message || error)); }
1476
- } catch (error) { if (aliveRef.current) setNotice({ ok: false, text: String(error.message || error) }); }
1605
+ } catch (error) { if (aliveRef.current) setNotice({ ok: false, text: localError(error.code, String(error.message || error), t) }); }
1477
1606
  finally { if (aliveRef.current) setBusy(false); }
1478
1607
  };
1479
1608
  const inputStyle = { width: "100%", minWidth: 0, boxSizing: "border-box", padding: "6px 8px", borderRadius: 6, border: "1px solid rgba(128,128,128,.4)", background: "transparent", color: "inherit", font: "inherit" };
1480
1609
  const btnStyle = { padding: "6px 10px", borderRadius: 6, border: "1px solid rgba(128,128,128,.35)", background: "transparent", color: "inherit", cursor: "pointer", font: "inherit" };
1481
1610
  const primary = { ...btnStyle, background: "rgba(59,130,246,.12)", borderColor: "rgba(59,130,246,.65)" };
1482
1611
  const entries = result.entries || [];
1612
+ // 首次挂载时 settings 还没读到(readAll 在飞行中),providerVisible 对缺失一律「未知放行」,
1613
+ // 会让标签栏先闪现全部默认开启的提供商;未初始化期间标签栏、空状态与计数都不渲染。
1614
+ const initialized = !!result.settings;
1483
1615
  const entry = entries.find((e) => e.providerId === tab);
1484
1616
  // 待处理的排前面;组内保持登记顺序,所以顺序不会自己抖动。
1485
- const visibleIds = PROVIDER_ORDER
1617
+ const visibleIds = !initialized ? [] : PROVIDER_ORDER
1486
1618
  .map((id, index) => ({ id, index, rank: usageAttentionRank(entries.find(e => e.providerId === id), t) }))
1487
1619
  .filter(item => providerVisible(result.settings, item.id, entries.find(e => e.providerId === item.id), result))
1488
1620
  .sort((a, b) => b.rank - a.rank || a.index - b.index)
@@ -1499,7 +1631,11 @@ window.__ModuleLoader__.load({
1499
1631
  const usageMeta = PROVIDER_META[usageId];
1500
1632
  // 需要行动时(缺凭据/凭据被拒/Cookie 即将到期)在用量面板里内联修复入口。
1501
1633
  const usageAction = usageActionKind(usageEntry, usageId, t);
1502
- const usageStatus = computeStatus(usageEntry, { failed: usageEntry && usageEntry.state !== "ok" ? usageEntry.error || usageEntry.state : null, failState: usageEntry && usageEntry.state, providerId: usageId, t, getLocale });
1634
+ // Command Code 自动轮换:池耗尽时横幅按池中一条限额窗口染红(与药丸同一结论)。
1635
+ const usagePool = usageId === "commandcode" ? (usageEntry?.accountPool ?? null) : null;
1636
+ const usageExhaustedWindow = usagePool?.exhausted ? (usagePool.accounts.flatMap(a => a.windows || []).find(w => w.status === "rate-limited") ?? null) : null;
1637
+ const usageStatusEntry = usageExhaustedWindow ? { ...usageEntry, state: "ok", coverage: "complete", freshness: "fresh", windows: [usageExhaustedWindow] } : usageEntry;
1638
+ const usageStatus = computeStatus(usageStatusEntry, { failed: usageEntry && usageEntry.state !== "ok" ? usageEntry.error || usageEntry.state : null, failState: usageEntry && usageEntry.state, providerId: usageId, t, getLocale });
1503
1639
  // 行动按钮把人送到对应厂商的凭据编辑器:展开管理区、打开该行、滚动过去。
1504
1640
  const revealEditor = (target) => {
1505
1641
  openEditor(target);
@@ -1610,7 +1746,7 @@ window.__ModuleLoader__.load({
1610
1746
  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")}`),
1611
1747
  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.")),
1612
1748
  // 启用的排到最前面,省得每次往下翻;组内保持 PROVIDER_ORDER 的既有顺序(sort 稳定)。
1613
- // Ark 的 7 条路由合并成一张卡片:只渲染本机装了的那条作代表(都没装就渲染第一条),
1749
+ // Ark 的 4 条路由合并成一张卡片:只渲染本机装了的那条作代表(都没装就渲染第一条),
1614
1750
  // 由它统一控制整组的检测开关与那组共享的 AK/SK。
1615
1751
  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 => {
1616
1752
  const isArk = Boolean(PROVIDER_META[id].volc), arkPresent = isArk ? ARK_IDS.filter(key => providerRoutable(result, key)) : [];
@@ -1631,20 +1767,20 @@ window.__ModuleLoader__.load({
1631
1767
  return h("div", { ref: root, style: { padding: 16, maxWidth: 720, minWidth: 0, font: "13px/1.6 sans-serif", containerType: "inline-size", containerName: "subusage" } },
1632
1768
  h("div", { style: { display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" } },
1633
1769
  h("h2", { style: { margin: 0, fontSize: 16 } }, t("title")),
1634
- h("button", {
1770
+ initialized ? h("button", {
1635
1771
  type: "button",
1636
1772
  disabled: !troubledIds.length,
1637
1773
  onClick: () => { if (troubledIds.length) chooseUsage(troubledIds[0]); },
1638
1774
  title: troubledIds.length ? text(`跳到 ${PROVIDER_META[troubledIds[0]].short}(共 ${troubledIds.length} 家待处理)`, `Jump to ${PROVIDER_META[troubledIds[0]].short} (${troubledIds.length} need attention)`) : text("全部获取成功", "All fetched"),
1639
1775
  "aria-label": text(`${success}/${visibleIds.length} 家数据获取成功;${troubledIds.length ? `点击跳到 ${PROVIDER_META[troubledIds[0]].short}` : "没有待处理项"}`, `${success}/${visibleIds.length} providers fetched; ${troubledIds.length ? `click to jump to ${PROVIDER_META[troubledIds[0]].short}` : "nothing needs attention"}`),
1640
1776
  style: { ...btnStyle, padding: "2px 8px", fontSize: 12, opacity: .85, cursor: troubledIds.length ? "pointer" : "default", color: troubledIds.length ? "#b7791f" : "inherit" }
1641
- }, text(`${success}/${visibleIds.length} 家数据获取成功`, `${success}/${visibleIds.length} providers fetched`)),
1777
+ }, text(`${success}/${visibleIds.length} 家数据获取成功`, `${success}/${visibleIds.length} providers fetched`)) : null,
1642
1778
  h("button", { type: "button", disabled: busy || refreshing || !usageId, style: { ...primary, cursor: busy || refreshing || !usageId ? "default" : "pointer" }, onClick: () => refresh(false) }, refreshing ? t("refreshing") : text("刷新当前", "Refresh current")),
1643
1779
  h("button", { type: "button", disabled: busy || refreshing, style: { ...btnStyle, cursor: busy || refreshing ? "default" : "pointer" }, onClick: () => refresh(true) }, refreshing ? t("refreshing") : text("刷新全部", "Refresh all"))
1644
1780
  ),
1645
1781
  loadError ? h("p", { role: "alert", style: { color: "#dc2626", overflowWrap: "anywhere" } }, loadError) : null,
1646
- !visibleIds.length ? h("p", { role: "status" }, text("没有可显示的提供商,请展开下方「提供商管理」开启、配置 API,或关闭默认隐藏。", "No visible providers. Expand Provider management below to enable a provider, configure API credentials, or turn off auto-hide.")) : null,
1647
- h("div", { className: "subusage-provider-tabs", role: "tablist", "aria-label": text("模型商", "Providers"), style: { display: "flex", flexWrap: "wrap", gap: 4, marginTop: 14 } },
1782
+ initialized && !visibleIds.length ? h("p", { role: "status" }, text("没有可显示的提供商,请展开下方「提供商管理」开启、配置 API,或关闭默认隐藏。", "No visible providers. Expand Provider management below to enable a provider, configure API credentials, or turn off auto-hide.")) : null,
1783
+ initialized ? h("div", { className: "subusage-provider-tabs", role: "tablist", "aria-label": text("模型商", "Providers"), style: { display: "flex", flexWrap: "wrap", gap: 4, marginTop: 14 } },
1648
1784
  visibleIds.map((id, index) => {
1649
1785
  const e = entries.find((item) => item.providerId === id), active = usageId === id;
1650
1786
  // 标签圆标与面板/药丸读同一个 computeStatus:缺凭据是蓝 ⚙ 而不是红 !,
@@ -1661,7 +1797,7 @@ window.__ModuleLoader__.load({
1661
1797
  const button = event.currentTarget.parentElement.querySelectorAll('[role="tab"]')[next]; if (button) button.focus();
1662
1798
  }, style: { ...btnStyle, display: "inline-flex", alignItems: "center", gap: 6, minWidth: 0, padding: "5px 12px 5px 8px", whiteSpace: "nowrap", fontSize: 12, fontWeight: active ? 600 : 400, borderRadius: 999, border: "1px solid transparent", background: active ? alpha(INFO, ".16") : "transparent" }
1663
1799
  }, statusGlyph(tabStatus.color, tabStatus.icon, 14), PROVIDER_META[id].short);
1664
- })),
1800
+ })) : null,
1665
1801
  !!usageId ? h("section", { role: "tabpanel", id: "subusage-provider-panel", "aria-label": usageMeta.label, style: { marginTop: 12, border: "1px solid rgba(128,128,128,.25)", borderRadius: 14, padding: 16 } },
1666
1802
  h("div", { style: { display: "flex", alignItems: "center", gap: 8 } },
1667
1803
  h("strong", { style: { fontSize: 14 } }, usageMeta.label),
@@ -1669,13 +1805,16 @@ window.__ModuleLoader__.load({
1669
1805
  planName(usageEntry) ? h("span", { style: { fontSize: 11, fontWeight: 600, padding: "1px 8px", borderRadius: 999, background: "rgba(128,128,128,.16)", whiteSpace: "nowrap" } }, planName(usageEntry)) : null),
1670
1806
  // 一眼结论:与药丸、弹层同一个状态判定。读取成功只说明接口健康,额度档位另有结论。
1671
1807
  statusBanner(usageStatus),
1672
- usageEntry && usageEntry.error && usageStatus.key !== "error" ? h("p", { role: "alert", style: { color: "#dc2626", overflowWrap: "anywhere" } }, usageEntry.error) : null,
1808
+ usageEntry && usageEntry.error && usageStatus.key !== "error" ? h("p", { role: "alert", title: usageEntry.error, style: { color: "#dc2626", overflowWrap: "anywhere" } }, localError(usageEntry.errorCode, usageEntry.error, t)) : null,
1673
1809
  usageAction ? h("div", { role: "group", "aria-label": text("建议操作", "Suggested action"), style: { display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap", margin: "10px 0", padding: "8px 10px", border: "1px solid rgba(234,179,8,.45)", borderRadius: 8, background: "rgba(234,179,8,.08)" } },
1674
1810
  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.")),
1675
1811
  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,
1676
1812
  h("button", { type: "button", style: usageAction === "credentials" ? primary : btnStyle, disabled: busy, onClick: () => revealEditor(usageId) }, usageAction === "credentials" ? text("配置凭据", "Configure credentials") : text("手动填写", "Enter manually"))) : null,
1813
+ // 自动轮换池健康度(与弹层同一函数):池耗尽时上方横幅已按池中限额窗口染红。
1814
+ usagePool ? CommandCodePoolSummary({ pool: usagePool, cc, t }) : null,
1677
1815
  // 无窗口时的占位只给「成功但既无窗口也无余额」:余额/加载/需配置/失败等状态横幅已经说明,不再叠加「暂无额度数据」。
1678
- canShowUsage(usageEntry) && (usageEntry.windows || []).length ? UsageWindowList({ windows: usageEntry.windows, t, getLocale, uncertain: usageEntry.coverage === "partial" || usageEntry.freshness === "stale" || usageEntry.state !== "ok" })
1816
+ // 窗口列表用 usageStatusEntry:池耗尽时它携带合成的限额窗口(非耗尽时与 usageEntry 同一对象)。
1817
+ canShowUsage(usageStatusEntry) && (usageStatusEntry.windows || []).length ? UsageWindowList({ windows: usageStatusEntry.windows, t, getLocale, uncertain: usageStatusEntry.coverage === "partial" || usageStatusEntry.freshness === "stale" || usageStatusEntry.state !== "ok" })
1679
1818
  : usageStatus.key === "balance" && !(usageStatus.entry && balanceText(usageStatus.entry, t)) ? h("p", { style: { opacity: .75 } }, t("noUsageData")) : null,
1680
1819
  canShowUsage(usageEntry) && usageEntry.extras && usageEntry.extras.some((x) => x.kind !== "plan")
1681
1820
  ? h("div", { style: { marginTop: 10, padding: "6px 12px", borderRadius: 10, background: "rgba(128,128,128,.07)" } },
package/lib/index.js CHANGED
@@ -11,6 +11,7 @@ import { MimoLogin } from "./mimo-login.js";
11
11
  import { parseVolcAgentPlan, parseVolcCodingPlan, parseVolcSeatAfp, parseVolcSeatCoding, parseVolcSeatIds, volcError, volcSignature } from "./volcengine.js";
12
12
 
13
13
  const name = "dsh-subusage";
14
+ const VERSION = "0.11.1";
14
15
  const inject = ["llm"];
15
16
  const PROVIDERS = {
16
17
  "zai-coding-cn": { label: "Z.ai", envName: "ZAI_CODING_CN_API_KEY" },
@@ -41,7 +42,7 @@ const PROVIDERS = {
41
42
  // 否则选中方舟模型时药丸不会出现。额度读取走管控面 OpenAPI,凭据是 IAM 的 AK/SK 配对
42
43
  // (HMAC-SHA256 签名),与这些路由的推理 API Key 是两套:拿推理 Key 去查额度会被上游拒绝
43
44
  // ("GetAFPUsage requires Volcengine Ark SSO STS")。
44
- // 7 条 volc 路由共用一组 AK/SK(settings.volc):个人版各自固定一个 Action,
45
+ // 4 条 volc 路由共用一组 AK/SK(settings.volc):个人版各自固定一个 Action,
45
46
  // 企业版/团队版走 ListSeatInfos → GetSeatAFPUsage / GetSeatInfoUsage 两步。
46
47
  // ── arkcli(官方 CLI)安装的路由 ──────────────────────────────────────────
47
48
  // 官方 CLI 的 `arkcli helper configure deepseek-harness` 把 provider 写成
@@ -87,7 +88,7 @@ const IDS = Object.keys(PROVIDERS);
87
88
  const ARK_IDS = IDS.filter(id => PROVIDERS[id].credentialKind === "volc");
88
89
  const ARK_DEFAULT_ON = ARK_IDS.some(id => PROVIDERS[id].defaultEnabled !== false);
89
90
  /**
90
- * 某条路由当前是否启用。Ark 以**组开关为唯一真源**——7 条路由共用一组 IAM AK/SK、一般用户只
91
+ * 某条路由当前是否启用。Ark 以**组开关为唯一真源**——4 条路由共用一组 IAM AK/SK、一般用户只
91
92
  * 持有一个套餐,所以设置页只给一个开关。这里必须与 Client 的 `providerEnabled` 同语义:
92
93
  * 两端口径一旦分叉,界面按组开关显示这条路由,Host 却按逐条值回 `disabled`,
93
94
  * 用户拿到的就是一张写着「检测已关闭」的空卡片(这正是 Coding Plan 查不到用量的原因)。
@@ -118,7 +119,7 @@ const PROVIDER_ALIASES = {
118
119
  function normalizeProviderKey(value) { return String(value ?? "").trim().toLowerCase().replace(/[\s._-]+/g, ""); }
119
120
  /**
120
121
  * 归一化索引:id 与别名都进同一张表,因此「精确 id」只是这张表的一个特例。
121
- * 一个键可以指向多条路由(`zai` → 中国版 + 国际版,`ark` → 7 条)。
122
+ * 一个键可以指向多条路由(`zai` → 中国版 + 国际版,`ark` → 4 条)。
122
123
  */
123
124
  const PROVIDER_LOOKUP = (() => {
124
125
  const map = new Map();
@@ -139,7 +140,7 @@ const PROVIDER_LOOKUP = (() => {
139
140
  * 1. 认不出的原串**原样返回**而不是丢掉——调用方要能拿到「这个名字不认识,可用的是……」才能
140
141
  * 自我纠正;静默过滤只会让它以为本机没有额度。
141
142
  * 2. 一个名字命中多条路由时**全部返回**,各自带着真实状态(没启用的会如实标 disabled)。
142
- * 例如 `zai` 同时是中国版与国际版、`ark` 是 7 条路由:宁可多给几条数据让调用方自己判断,
143
+ * 例如 `zai` 同时是中国版与国际版、`ark` 是 4 条路由:宁可多给几条数据让调用方自己判断,
143
144
  * 也不替它猜一条,更不能返回空。
144
145
  * @param requested 调用方给的原始名字列表。
145
146
  */
@@ -192,7 +193,8 @@ function commandCodeAuthFileKey(io) {
192
193
  }
193
194
  // SuperGrok / X Premium 订阅(dsh-grok-kit 的 xai-oauth 路由):用量走官方 Grok CLI 的计费
194
195
  // 代理(转发后端 GetGrokCreditsConfig)。凭据是 dsh-grok-kit / Grok CLI 共享登录文件里的
195
- // OAuth Bearer(短命轮换),只读不写、不自行刷新,避免与两端的 refresh-token 轮换互相顶掉。
196
+ // OAuth Bearer(短命轮换):平时只读;token 过期时借 dsh-grok-kit 的官方会话链(getAuth)经同一
197
+ // 把写锁续约并写回同一文件,绝不自行裸 POST 刷新——后者才会顶掉两端的 refresh-token 轮换。
196
198
  const GROK_API_BASE = "https://cli-chat-proxy.grok.com/v1";
197
199
  // 官方 GrokComConfig.token_header 默认值:必须是该字符串,nginx 鉴权子请求才路由到 OAuth。
198
200
  const GROK_TOKEN_AUTH = "xai-grok-cli";
@@ -200,6 +202,21 @@ const GROK_CLIENT_VERSION = "1.0.13";
200
202
  const GROK_AUTH_FILE = join(homedir(), ".grok", "auth.json");
201
203
  // dsh-grok-kit 旧版私存路径(其 resolveXaiOAuthStorePath 的回退分支)。
202
204
  const XAI_OAUTH_AUTH_FILE = join(homedir(), ".dsh", ".xai-oauth-auth.json");
205
+ // dsh-grok-kit 是兄弟插件而非本插件依赖:动态加载,解析不到(未安装 / 开发期 link 布局)即降级。
206
+ // 0.2.0 起导出 XaiOAuthSession;其 models.getAuth("xai") 与 grok-kit 聊天/搜索走同一条官方 OAuth
207
+ // 刷新链——共享文件 store、写锁内二次检查与 compare-and-write,多个消费者并发也只刷新一次,
208
+ // 借它刷新不会顶掉 refresh-token 轮换(自己裸 POST 才会)。只缓存成功解析:失败(未安装、
209
+ // 旧版本)下次过期时重试,用户中途安装/升级 grok-kit 后无需重启 DSH。
210
+ let grokKitModule;
211
+ function loadGrokKit() {
212
+ if (grokKitModule) return Promise.resolve(grokKitModule);
213
+ return import("dsh-grok-kit")
214
+ .then(mod => {
215
+ if (mod && typeof mod.XaiOAuthSession === "function") grokKitModule = mod;
216
+ return grokKitModule;
217
+ })
218
+ .catch(() => undefined);
219
+ }
203
220
  // Bearer 的 JWT payload 里有 sub(= user_id);登录文件缺 user_id 时兜底取它。
204
221
  function bearerSubject(key) {
205
222
  try {
@@ -414,9 +431,12 @@ function volcFailure(action, error) {
414
431
  }
415
432
  async function fetchJson(fetcher, url, headers, lifetimeSignal, init = {}) {
416
433
  let response;
417
- 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.10", ...headers }, signal: lifetimeSignal ? AbortSignal.any([AbortSignal.timeout(10000), lifetimeSignal]) : AbortSignal.timeout(10000), redirect: "manual" }); }
434
+ try { response = await fetcher(url, { method: init.method ?? "GET", ...(init.body !== undefined ? { body: init.body } : {}), headers: { accept: "application/json", "user-agent": `dsh-subusage/${VERSION}`, ...headers }, signal: lifetimeSignal ? AbortSignal.any([AbortSignal.timeout(10000), lifetimeSignal]) : AbortSignal.timeout(10000), redirect: "manual" }); }
418
435
  catch { throw failure("subusage/network", "网络请求失败或超时", true); }
419
- if (response.status >= 300 && response.status < 400 || response.status === 401 || response.status === 403) throw failure("subusage/auth", "登录或凭据已失效");
436
+ // 3xx(redirect: "manual" 下的域名迁移/跳转)不是鉴权失败,与 401/403 分开映射,
437
+ // 避免用户被误导去重登录;两个条件都加显式括号,不依赖 &&/|| 优先级。
438
+ if ((response.status >= 300 && response.status < 400)) throw failure("subusage/response", "服务端返回了意外的重定向响应(HTTP 3xx)");
439
+ if ((response.status === 401 || response.status === 403)) throw failure("subusage/auth", "登录或凭据已失效");
420
440
  if (!response.ok) {
421
441
  const retry = response.status === 408 || response.status === 429 || response.status >= 500;
422
442
  const raw = response.headers?.get?.("retry-after");
@@ -927,6 +947,8 @@ export class SubUsageService extends TypertRemoteService {
927
947
  this.authFile = options.resolveAuthFile ?? (async () => commandCodeAuthFileKey(this.io));
928
948
  // SuperGrok 只读共享登录文件(~/.grok/auth.json 等)取 OAuth Bearer;可注入仅供隔离测试。
929
949
  this.grokAuth = options.resolveGrokAuth ?? (async () => grokAuthFileCredentials(this.io));
950
+ // 过期时借 dsh-grok-kit 的官方会话链刷新同一登录文件;解析不到兄弟插件时返回 undefined 并降级。
951
+ this.resolveGrokKit = options.resolveGrokKit ?? loadGrokKit;
930
952
  // Codex 只读共享登录文件($CODEX_HOME/auth.json 或 ~/.codex/auth.json)取 OAuth access token。
931
953
  this.codexAuth = options.resolveCodexAuth ?? (async () => codexAuthFileCredentials(this.io, { CODEX_HOME: (await this.environmentByName("CODEX_HOME"))?.value }));
932
954
  this.cache = new Map(); this.inflight = new Map(); this.generations = new Map(); this.fingerprints = new Map(); this.disposed = false; this.controller = new AbortController();
@@ -998,6 +1020,33 @@ export class SubUsageService extends TypertRemoteService {
998
1020
  if (typeof envAk?.value === "string" && envAk.value.trim() && typeof envSk?.value === "string" && envSk.value.trim()) return { key: envAk.value.trim(), secret: envSk.value.trim(), source: "env" };
999
1021
  return ak && sk ? { key: ak, secret: sk, source: "manual" } : { key: undefined, secret: undefined, source: "none" };
1000
1022
  }
1023
+ /**
1024
+ * Grok 登录文件的 access token 过期时,借 dsh-grok-kit 的官方会话链续约:getAuth("xai")
1025
+ * 在共享凭据 store 的写锁内二次检查并刷新,新 token 由官方链写回同一个 ~/.grok/auth.json,
1026
+ * 与 grok-kit 聊天/搜索/Grok CLI 是同一套锁协议,不会顶掉 refresh-token 轮换。
1027
+ * 不直接信任 getAuth 返回值:无论刷新由本进程还是并发的另一进程完成,一律重读登录文件拿新 token 与新过期时间。
1028
+ * 回到 { renewed:true, identity };兄弟插件不可用时 unavailable:true;刷新尝试失败时带 refreshError。
1029
+ */
1030
+ async renewGrokIdentity(identity, signal) {
1031
+ if (!identity.expiresAt || Date.parse(identity.expiresAt) > this.now()) return { renewed: false, unavailable: true, identity };
1032
+ let kit;
1033
+ try { kit = await this.resolveGrokKit(); } catch { kit = undefined; }
1034
+ if (!kit?.XaiOAuthSession) return { renewed: false, unavailable: true, identity };
1035
+ let session;
1036
+ let refreshError;
1037
+ try {
1038
+ session = new kit.XaiOAuthSession();
1039
+ // pi-ai 内部有 15s 刷新超时与 2s 文件锁预算;外层再兜 20s 总预算,避免挂死药丸/工具刷新。
1040
+ const deadline = AbortSignal.timeout(20000);
1041
+ await session.models.getAuth("xai", { signal: signal ? AbortSignal.any([signal, deadline]) : deadline });
1042
+ } catch (error) { refreshError = error; }
1043
+ finally { try { session?.dispose?.(); } catch { /* 会话只持有进程内模型缓存,dispose 失败无后果 */ } }
1044
+ const cred = await this.grokAuth();
1045
+ if (cred?.key && cred.key !== identity.key && (!cred.expiresAt || Date.parse(cred.expiresAt) > this.now())) {
1046
+ return { renewed: true, identity: { key: cred.key, source: "auth-file", ...(cred.userId ? { userId: cred.userId } : {}), ...(cred.expiresAt ? { expiresAt: cred.expiresAt } : {}) } };
1047
+ }
1048
+ return { renewed: false, unavailable: false, refreshError, identity };
1049
+ }
1001
1050
  async fetchProvider(id, identity, settings, signal = this.controller.signal) {
1002
1051
  const get = (url, headers, init) => fetchJson(this.fetcher, url, headers, signal, init);
1003
1052
  // 火山方舟额度走管控面 OpenAPI:POST 根路径 + Action/Version 在 query,鉴权是 AK/SK 签名。
@@ -1051,7 +1100,7 @@ export class SubUsageService extends TypertRemoteService {
1051
1100
  // HTTP 200 但窗口为空是「未订阅」,不是 0% 用量:显式说明,不画零额度的假象。
1052
1101
  else if (!windows.length) extras.push({ kind: "plan", value: parsed.subscribed ? "本周期暂无窗口数据" : "未检测到该套餐订阅" });
1053
1102
  // 订阅状态要**结构化**带出去,不能只写进 extras 文案:同一组 AK/SK 能查到名下所有套餐,
1054
- // 界面上若把 7 条路由一律列出来,用户根本分不清哪个是哪个。前端据此收起没订阅的那些。
1103
+ // 界面上若把 4 条路由一律列出来,用户根本分不清哪个是哪个。前端据此收起没订阅的那些。
1055
1104
  return { windows, extras, coverage: windows.length ? "complete" : "partial", subscribed: parsed.subscribed === true };
1056
1105
  }
1057
1106
  const headers = { authorization: `Bearer ${identity.key}` };
@@ -1124,17 +1173,25 @@ export class SubUsageService extends TypertRemoteService {
1124
1173
  return normalizeCommandCode(credits, subscription);
1125
1174
  }
1126
1175
  if (id === "xai-oauth") {
1127
- // 登录文件里的 access token 短命(实测 ~24 小时)。本插件**刻意不代刷**——refresh-token 轮换由
1128
- // Grok CLI / dsh-grok-kit 各自的锁协议管理,第三端刷新会把它们的轮换顶掉。但「过期」这件事必须
1129
- // 说清楚:不发这一枪的话,用户看到的是上游 401 转成的「登录或凭据已失效」,而他那边的 Grok Kit
1130
- // 明明写着「已登录」——两边对不上,只会让人以为插件坏了。
1131
- if (identity.expiresAt && Date.parse(identity.expiresAt) <= this.now()) {
1132
- 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 轮换互相顶掉。`);
1176
+ // 登录文件里的 access token 短命(实测 6 小时量级),而 grok-kit 的自动刷新是懒触发:
1177
+ // 只有它自己聊天/搜索遇到过期才刷,没有后台定时器。本插件每分钟读一次用量,是最常发现
1178
+ // 过期的一方——若只读不刷,就会出现「Grok Kit 显示已登录、用量卡片显示已过期」的割裂。
1179
+ // 过期时借 grok-kit 的官方会话链续约(同一文件、同一把写锁、compare-and-write,不顶轮换);
1180
+ // 兄弟插件不可用或刷新失败时,才退回可行动的手动提示。
1181
+ let grok = identity;
1182
+ if (grok.expiresAt && Date.parse(grok.expiresAt) <= this.now()) {
1183
+ const outcome = await this.renewGrokIdentity(grok, signal);
1184
+ if (outcome.renewed) grok = outcome.identity;
1185
+ else if (outcome.unavailable) throw failure("subusage/auth", `Grok 登录文件的 access token 已于 ${new Date(identity.expiresAt).toLocaleString("zh-CN")} 过期,但本机未能加载 dsh-grok-kit(≥0.2.0)的自动刷新链。用一次 Grok CLI(登录后发一条消息),或在 设置 → xAI Grok 里重新登录,都会刷新同一个文件(${GROK_AUTH_FILE})。`);
1186
+ else {
1187
+ const reason = outcome.refreshError?.message ? String(outcome.refreshError.message).slice(0, 200) : "登录刷新链未返回新凭据";
1188
+ throw failure("subusage/auth", `Grok 登录已于 ${new Date(identity.expiresAt).toLocaleString("zh-CN")} 过期,尝试经 dsh-grok-kit 自动刷新失败:${reason}。通常需要重新登录:在 设置 → xAI Grok 重新登录,或运行 grok login(同一登录文件 ${GROK_AUTH_FILE})。`);
1189
+ }
1133
1190
  }
1134
1191
  // 直连官方 Grok CLI 计费代理;X-XAI-Token-Auth 的取值是 token_header 字符串(xai-grok-cli)
1135
1192
  // 而非布尔,x-userid 取登录文件。套餐名走 /v1/settings(subscription_tier_display),
1136
1193
  // 失败不阻塞用量(回退 billing 响应里的 subscription_tier)。
1137
- const grokHeaders = { ...headers, "x-xai-token-auth": GROK_TOKEN_AUTH, "x-grok-client-version": GROK_CLIENT_VERSION, ...(identity.userId ? { "x-userid": identity.userId } : {}) };
1194
+ const grokHeaders = { authorization: `Bearer ${grok.key}`, "x-xai-token-auth": GROK_TOKEN_AUTH, "x-grok-client-version": GROK_CLIENT_VERSION, ...(grok.userId ? { "x-userid": grok.userId } : {}) };
1138
1195
  const [billing, settings] = await Promise.all([
1139
1196
  get(`${GROK_API_BASE}/billing?format=credits`, grokHeaders),
1140
1197
  get(`${GROK_API_BASE}/settings`, grokHeaders).catch(() => undefined)
@@ -1191,6 +1248,50 @@ export class SubUsageService extends TypertRemoteService {
1191
1248
  return { mode: activeId === "" ? "auto" : "fixed", activeId, accounts: accounts.map(account => ({ ...account, active: account.id === activeId })) };
1192
1249
  } catch { return null; }
1193
1250
  }
1251
+ /**
1252
+ * 自动轮换模式下的**轮换池健康摘要**:UI 通道(read/refresh)默认只回顶层 Key 单账户,
1253
+ * 用户看到的是乐观数字,但实际请求会落到池中任意账户——默认账户健康不代表池健康
1254
+ * (其余账户可能已全部 5h 限额)。这里复用各账户自己的缓存并行读一遍,把摘要挂到默认
1255
+ * 账户 entry 上,Client 仅在 auto 模式渲染。固定模式(activeId 非空)返回 null。
1256
+ *
1257
+ * 业务口径(不能简单取「池中最紧张账户」):各账户是**独立配额**,轮换器会避开已限额的
1258
+ * 账户,所以单账户限额不是立即失败信号。只有「所有有凭据的账户都限额」才是池耗尽:
1259
+ * healthy = ok 且没有 rate-limited 窗口
1260
+ * blocked = ok 但至少一个窗口 rate-limited(容量降级,仍可能成功)
1261
+ * unusable = no-key/no-cookie/disabled/error(读不到或没凭据,不计入有效容量)
1262
+ * 池里没有任何有凭据的账户时 exhausted=false(那是「未配置」,由默认 entry 的 no-key 表达)。
1263
+ */
1264
+ async commandCodePool(settings, force, defaultEntry) {
1265
+ const plan = this.commandCodeAccountPlan();
1266
+ if (!plan || plan.mode !== "auto") return null;
1267
+ // 默认账户那条 entry 已由调用方读过(避免重复一发),其余账户各自命中 provider() 的 TTL/
1268
+ // inflight 缓存,因此每分钟的自动刷新不会真的打 N 次计费接口。
1269
+ const readOne = async (account) => {
1270
+ if (account.id === "default") return defaultEntry;
1271
+ try { return await this.provider("commandcode", settings, force, account.id); }
1272
+ catch { return { ...this.base("commandcode", "none", settings, account.id), state: "error", errorCode: "subusage/internal", error: "读取用量失败", windows: [], extras: [] }; }
1273
+ };
1274
+ const entries = await Promise.all(plan.accounts.map(account => readOne(account)));
1275
+ const accounts = plan.accounts.map((account, index) => {
1276
+ const entry = entries[index];
1277
+ const windows = Array.isArray(entry?.windows) ? entry.windows : [];
1278
+ const ok = entry?.state === "ok";
1279
+ return {
1280
+ id: account.id, label: account.label, active: account.active === true,
1281
+ state: entry?.state ?? "error", errorCode: entry?.errorCode, windows,
1282
+ // usable:读到了且没有任何限额窗口;blocked:读到了但有窗口限额(容量降级,仍可能成功)。
1283
+ usable: ok && !windows.some(w => w.status === "rate-limited"),
1284
+ blocked: ok && windows.some(w => w.status === "rate-limited")
1285
+ };
1286
+ });
1287
+ // 「有效容量」= usable + blocked(真的有凭据且读到了额度的账户);no-key/error/disabled 不算。
1288
+ const capable = accounts.filter(a => a.usable || a.blocked);
1289
+ const available = accounts.filter(a => a.usable).length;
1290
+ const blockedCount = accounts.filter(a => a.blocked).length;
1291
+ const unusableCount = accounts.length - available - blockedCount;
1292
+ const exhausted = capable.length > 0 && capable.every(a => a.blocked);
1293
+ return { mode: "auto", total: accounts.length, available, blockedCount, unusableCount, exhausted, accounts };
1294
+ }
1194
1295
  /** 按 id 失效一家 provider 的**全部账户**缓存:凭据/开关变化不能只影响默认账户那一条。 */
1195
1296
  invalidateProvider(id) {
1196
1297
  const keys = new Set([id]);
@@ -1333,6 +1434,16 @@ export class SubUsageService extends TypertRemoteService {
1333
1434
  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: [] }));
1334
1435
  const latest = this.load();
1335
1436
  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]);
1437
+ // Command Code 自动轮换:把轮换池健康摘要挂到默认账户(顶层 Key)entry 上。
1438
+ // 仅当本次查询的就是默认账户(commandCodeAccount 为空)时——查某个额外账户时不挂,
1439
+ // 那条 entry 代表的是被点名的账户本身。提供方插件未运行 / 固定账户时 pool 为 null,原样返回。
1440
+ if (query.providerIds.includes("commandcode") && (!query.commandCodeAccount || query.commandCodeAccount === "default")) {
1441
+ const ccIndex = entries.findIndex(e => e && e.providerId === "commandcode");
1442
+ if (ccIndex >= 0) {
1443
+ const pool = await this.commandCodePool(latest, query.force, entries[ccIndex]);
1444
+ if (pool) entries[ccIndex] = { ...entries[ccIndex], accountPool: pool };
1445
+ }
1446
+ }
1336
1447
  return this.result(latest, [...entries, ...unknownEntries]);
1337
1448
  }
1338
1449
  startMimoLogin(request) { return this.login.start(parseLoginStart(request)); } getMimoLoginStatus() { return this.login.status(); }
@@ -1565,5 +1676,7 @@ function registerQuotaTool(ctx) {
1565
1676
  }));
1566
1677
  });
1567
1678
  }
1568
- export { apply, inject, name };
1679
+ // IDS / PROVIDERS 仅供双端契约测试(test-contract.mjs)核对 Client 的 PROVIDER_META/PROVIDER_ORDER,
1680
+ // 防止新增厂商只改一端:漏改 Host 药丸取不到数、漏改 Client 则设置页与药丸缺项,且都不会报错。
1681
+ export { apply, inject, name, IDS, PROVIDERS };
1569
1682
  export default { name, inject, apply };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-subusage",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "displayName": "订阅用量显示",
5
5
  "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 的订阅余量与余额;共享刷新与凭据管理,默认关闭 17 家。",
6
6
  "keywords": [