dsh-subusage 0.11.1 → 0.11.2

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
@@ -1,13 +1,13 @@
1
1
  <div align="center">
2
- <img src="https://raw.githubusercontent.com/KouzakiUmi/dsh-subusage/main/assets/title.png" width="100%" alt="dsh-subusage —— 模型选择器旁的余量药丸,右侧是同一界面的浅色与深色用量弹层(真实渲染截图)">
2
+ <img src="https://raw.githubusercontent.com/KouzakiUmi/dsh-subusage/main/assets/title.png" width="100%" alt="dsh-subusage —— 模型选择器旁的余量药丸,右侧是同一界面的浅色与深色用量弹层(真实组件离线渲染示例)">
3
3
 
4
4
  # 订阅用量显示
5
5
 
6
6
  **选中的模型还剩多少额度,输入框旁边一眼可见。**
7
7
 
8
- dsh-subusage 把你订阅的 30 家模型服务商额度装进 DeepSeek Harness 的输入框旁边——不切控制台、不猜百分比、不等报错。
8
+ dsh-subusage 把已支持的 27 条模型提供商路由额度装进 DeepSeek Harness 的输入框旁边——不切控制台、不猜百分比、不等报错。
9
9
 
10
- [![npm version](https://img.shields.io/badge/npm-0.11.1-blue)](https://www.npmjs.com/package/dsh-subusage)
10
+ [![npm version](https://img.shields.io/badge/npm-0.11.2-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
 
@@ -25,9 +25,9 @@
25
25
  | Cookie、OAuth 悄悄过期,报错才发现 | MiMo Cookie 提前 2 小时变黄、30 分钟变橙、过期变红;认证失效直说原因与修法 |
26
26
  | 每家插件各管各的凭据,格式五花八门 | 一个设置页集中管理 27 条路由的开关与凭据,缺什么补什么 |
27
27
 
28
- ## 15 种状态,一眼分清
28
+ ## 状态,一眼分清
29
29
 
30
- 药丸不写字也能传达状态:每个额度窗口一根彩条(绿 / 黄 / 橙 / 红,满格 = 100% 剩余),需要留意时才追加一个词。下面是全部状态的离线预览(虚构用量数据):
30
+ 药丸不写字也能传达状态:每个额度窗口一根彩条(绿 / 黄 / 橙 / 红,满格 = 100% 剩余),需要留意时才追加一个词。下面是已发布基础状态的离线预览(虚构用量数据);0.11.2 新增的续用/预算/能力范围状态见下节:
31
31
 
32
32
  <img src="https://raw.githubusercontent.com/KouzakiUmi/dsh-subusage/main/assets/screenshots/pill-states-light.png" width="100%" alt="药丸 15 种状态(浅色,离线预览)">
33
33
 
@@ -50,10 +50,22 @@
50
50
 
51
51
  本插件**不注册模型路由**,药丸按 provider id 匹配;因此使用哪个插件提供该模型路由不影响匹配,id 对不上时才不显示。
52
52
 
53
- ## 30 家提供商,默认开箱即用 10 家
53
+ ## 27 条提供商路由,默认开启 10 条
54
54
 
55
55
  DeepSeek、火山方舟(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——订阅窗口、余额、周期额度池都支持。**新安装默认开启 10 条**,其余在设置页一键开启;完整清单、额度类型与鉴权方式见[下方技术文档](#支持的数据--supported-data)。
56
56
 
57
+ ## 额度与续用:0.11.2 的关键修正
58
+
59
+ - **Codex Pro / ProLite / ProMax**:合法单周窗口正常展示,不再因没有 5 小时窗而黄警;多工作区查询携带账户身份,企业支出限制与 Credits 单独保留。
60
+ - **ZenMux / Novita / LiteLLM**:补齐 ZenMux 周窗;Novita 接受官方金额字符串;LiteLLM 的 Key、用户、团队预算分别展示,任何一层都不会被更宽松的预算覆盖。
61
+ - **包含额度耗尽不等于服务停摆**:Command Code 有已购余额时显示“按量续用”;NanoGPT 使用接口路由建议,未知余额/策略明确待确认。其他厂商未核验的续用条件不作保证。
62
+ - **轮换与能力范围**:自动轮换池尚有健康账户,不因默认账户限额报全池耗尽;AFP 文本周限制不连坐日能力池,共享月额度仍影响各能力。部分能力耗尽只提示范围,不冒充整个套餐不可用。
63
+ - **数据完整性**:合法单窗、无上限和纯余额成功不是“不完整”;真实端点读取失败仍可标部分覆盖。金额或窗口未知时不编造百分比。
64
+
65
+ 以上均已通过离线回归;不代表真实企业席位、新旧私有接口或付费资格已经在线验收。详细契约与仍待核验范围见[开发说明](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/development.md#额度语义修正0112)。
66
+
67
+ [快速开始](#快速开始) · [支持清单](#支持的数据--supported-data) · [实测范围](#support-status--实测覆盖) · [凭据指引](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/credentials.md) · [更新记录](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/changelog.md)
68
+
57
69
  ## 快速开始
58
70
 
59
71
  ```console
@@ -61,7 +73,7 @@ dsh plugin --profile <profile> add dsh-subusage
61
73
  ```
62
74
 
63
75
  1. 安装并启用 bundle,按部署方式重载或重启(只刷新页面不保证 Host 升级)。
64
- 2. 打开 **设置 → 订阅用量 → 提供商管理**,开启需要的提供商并填凭据——每类凭据的官网入口与填入位置见[凭据获取指引](docs/credentials.md);MiMo 用「登录并自动导入」。
76
+ 2. 打开 **设置 → 订阅用量 → 提供商管理**,开启需要的提供商并填凭据——每类凭据的官网入口与填入位置见[凭据获取指引](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/credentials.md);MiMo 用「登录并自动导入」。
65
77
  3. 回到会话,选中该提供商的模型:输入区左侧出现药丸,点开看窗口明细。
66
78
  4. 排障:点「立即刷新」→ 看设置页顶部 `X/Y 家数据获取成功`(可点击,直接跳到第一家没读成功的)。
67
79
 
@@ -116,11 +128,11 @@ $env:DEEPSEEK_API_KEY = "<你的 DeepSeek API Key>" # 启动环境;改完需
116
128
  | Chutes | `chutes` | 通用限额窗口 | Bearer | 关 |
117
129
  | Ollama Cloud | `ollama-cloud` | session / 周 / 月 | 裸 `Authorization`(不加 `Bearer`) | 关 |
118
130
  | Vercel AI Gateway | `vercel-ai-gateway` | 余额 | Bearer | 关 |
119
- | ZenMux | `zenmux` | 5 小时滚动窗口 + PAYG 余额 | **Management API Key** | 关 |
120
- | LiteLLM | `litellm` | 预算 + 花费 | 虚拟 Key + **用户自填 proxy 地址** | 关 |
131
+ | ZenMux | `zenmux` | 5 小时 / 周窗口 + PAYG 余额 | **Management API Key** | 关 |
132
+ | LiteLLM | `litellm` | Key / 用户 / 团队预算 + 花费 | 虚拟 Key + **用户自填 proxy 地址** | 关 |
121
133
 
122
- - 各家的**窗口语义、明细字段与单位换算**(例如 ZenMux / Ollama 的 0–1 小数、Novita 的 1/10000 USD、Kimi 随套餐变化的窗口集合)见 [提供商清单与额度语义](docs/providers.md)。
123
- - 评估过但**不接入**的厂商与理由见 [提供商覆盖与取舍](docs/provider-coverage.md)(含已由用户决策跳过的 Anthropic)。
134
+ - 各家的**窗口语义、明细字段与单位换算**(例如 ZenMux / Ollama 的 0–1 小数、Novita 的 1/10000 USD、Kimi 随套餐变化的窗口集合)见 [提供商清单与额度语义](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/providers.md)。
135
+ - 评估过但**不接入**的厂商与理由见 [提供商覆盖与取舍](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/provider-coverage.md)(含已由用户决策跳过的 Anthropic)。
124
136
 
125
137
  非公开控制台接口可能变更;缺失或非法百分比不会被当作零用量;「读取成功」只表示接口读取健康度,不保证仍有额度。
126
138
 
@@ -146,13 +158,13 @@ $env:DEEPSEEK_API_KEY = "<你的 DeepSeek API Key>" # 启动环境;改完需
146
158
 
147
159
  > Z.ai(中国与国际)、OpenCode Go、MiniMax(中国与国际)、Synthetic、NanoGPT、SiliconFlow、DeepSeek、OpenRouter、Novita、Hyperbolic、DeepInfra、Chutes、Ollama Cloud、Vercel AI Gateway、ZenMux、LiteLLM,以及火山方舟的**团队版席位**。
148
160
 
149
- 它们可能可用,也可能因为接口变更、字段差异或权限要求而失败。**本插件只保证上面那张表里的**。
161
+ 它们可能可用,也可能因为接口变更、字段差异或权限要求而失败。上表也只是既有账号、当时版本的验证记录,不是持续可用性保证。
150
162
 
151
163
  如果你用了其中之一,不管成功还是失败,都欢迎[开 issue](https://github.com/KouzakiUmi/dsh-subusage/issues)——附上提供商名称、错误文案与 DSH 版本即可(**不要贴 Key 或 Cookie**)。失败和成功一样有用:失败帮我们修适配,成功帮我们把这一行挪进上表。
152
164
 
153
165
  ### 主要风险
154
166
 
155
- - **额度数字仅供参考,不要当计费依据。** 各家的额度接口都是未公开或半公开的,字段语义可能随时变化;本插件不做任何换算,读到什么显示什么。
167
+ - **额度数字仅供参考,不要当计费依据。** 部分额度接口未公开或半公开,字段语义可能变化;插件会按已核验单位换算金额与百分比,不猜缺失额度。
156
168
  - **未验证的提供商会随接口变化而失效**(见上一节)。失效时在设置页关掉那一家的检测即可,其余不受影响;欢迎开 issue 帮我们补上。
157
169
  - **凭据以明文落盘**:本插件的配置不是加密凭据库;Windows 上真正的访问边界取决于目录 ACL,`chmod` 不能替代它。
158
170
  - **火山方舟是账号级读取**:一组 IAM AK/SK 能查到该账号名下**所有**套餐(Agent Plan、Coding Plan、团队席位)。把装了本插件的机器或配置文件交给别人时请注意这一点。
@@ -166,11 +178,11 @@ $env:DEEPSEEK_API_KEY = "<你的 DeepSeek API Key>" # 启动环境;改完需
166
178
  | 核心 peer 范围 | `@deepseek-ai/dsh` 及各核心包均为 `>=0.2.0-rc.1 <0.2.1-0 \|\| >=0.2.1-0 <0.3.0-0` | [`package.json`](package.json) 的 `peerDependencies` |
167
179
  | Node.js | `>=22.19.0` | `engines` 与 `dsh.compatibility.node` |
168
180
  | 已验证的 Core 版本 | `0.2.0-rc.2`、`0.2.1-alpha.1`、`0.2.1-alpha.2` | 实机运行;逐版本状态与验证方式见 `dsh.compatibility.dshReleases` |
169
- | 最后验证日期 | `2026-10-11` | [开发与验证说明](docs/development.md) |
181
+ | 最后验证日期 | `2026-10-11` | [开发与验证说明](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/development.md) |
170
182
 
171
183
  > **范围里那个 `||` 分支不是冗余。** node-semver 只有在范围中**某个比较符与该版本的 `major.minor.patch` 元组完全一致、且自身带预发布标签**时,才放行预发布版本。所以 `>=0.2.0-rc.1 <0.3.0-0` 这种看起来覆盖 0.2.x 的写法会**静默排除** `0.2.1-alpha.1`——而它正是当前活跃版本。必须为每个 minor 元组各写一个带预发布标签的分支。
172
184
 
173
- `dsh.compatibility.dshReleases` 逐版本声明已知状态:**只有实际验证过的标 `compatible`**,其余一律 `unknown`——没有证据不猜。本插件只依赖 Core 的**公开扩展 API 面**(Cordis 服务 `llm`/slots/locale/remote/settings/modelDirectories、4 个核心包的公开导出、client 基座的公开模块),不碰内部组件、不依赖桌面壳版本——这是 0.2.x 各版本实际全兼容的原因;`dshReleases` 记录的是**实测证据**,不是依赖声明。实际验证范围(在线接口、真实账号、浏览器像素验收等)见 [开发与验证说明](docs/development.md)。这是树外 Host / Client bundle,通过 `cordis.patch.yml` 插入,不修改 DSH 核心、安装树或 ASAR。
185
+ `dsh.compatibility.dshReleases` 逐版本声明已知状态:**只有实际验证过的标 `compatible`**,其余一律 `unknown`——没有证据不猜。本插件只依赖 Core 的**公开扩展 API 面**(Cordis 服务 `llm`/slots/locale/remote/settings/modelDirectories、4 个核心包的公开导出、client 基座的公开模块),不碰内部组件、不依赖桌面壳版本——这是 0.2.x 各版本实际全兼容的原因;`dshReleases` 记录的是**实测证据**,不是依赖声明。实际验证范围(在线接口、真实账号、浏览器像素验收等)见 [开发与验证说明](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/development.md)。这是树外 Host / Client bundle,通过 `cordis.patch.yml` 插入,不修改 DSH 核心、安装树或 ASAR。
174
186
 
175
187
  ## Install / 安装
176
188
 
@@ -225,7 +237,7 @@ dsh plugin --profile <profile> remove dsh-subusage
225
237
  ## Configuration / 配置
226
238
 
227
239
  - **设置页路径**:设置 → **订阅用量** → 提供商管理。「没有检测到API的默认隐藏」默认开启;首次没有可见条目时管理区自动展开。关闭的提供商仍可配置凭据,只是不会发起用量请求。
228
- - **凭据从哪来**:每一类凭据的**官网入口、环境变量名与界面填入位置**都写在[凭据获取指引](docs/credentials.md)——包括火山的 IAM AK/SK(子用户还要挂 `ArkReadOnlyAccess` 且不限制到项目)、MiMo 的平台会话 Cookie、以及各家 API Key 的对照表。**注意推理 Key 与额度凭据不通用。**
240
+ - **凭据从哪来**:每一类凭据的**官网入口、环境变量名与界面填入位置**都写在[凭据获取指引](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/credentials.md)——包括火山的 IAM AK/SK(子用户还要挂 `ArkReadOnlyAccess` 且不限制到项目)、MiMo 的平台会话 Cookie、以及各家 API Key 的对照表。**注意推理 Key 与额度凭据不通用。**
229
241
  - **默认开关策略**:新安装默认开启 10 条、默认关闭 17 条(**Codex 默认关闭**——它的提供方插件自带用量药丸);开关即时保存,升级保留已保存的开关,不重算默认值。默认隐藏只影响显示,不会自动开启被关闭的提供商。
230
242
  - **火山方舟是一组**:4 条路由共用同一组 IAM AK/SK,所以设置页只呈现**一张卡片、一个开关**(本机已装的第一条路由作为代表,都没装则用第一条),卡片里写明本机实际装了哪几条。这些 provider id 并没有合并——药丸仍按 id 匹配路由。旧插件 `@volcengine/ark-plan-api` 的两个国内路由(`ark-coding-plan-cn` / `ark-agent-plan-cn`)**已移除**:它们与 arkcli 的路由查同一份订阅,并列显示只会让人以为买了两个套餐;装了旧插件的用户,药丸仍按那两个 id 显示同一份数据(入口归并到 arkcli 路由)。
231
243
  - **火山方舟不按路由收起**:它的额度走**账号级管控面**,一组 AK/SK 就能查到名下所有套餐(`GetCodingPlanUsage` 与 `GetAFPUsage` 是同一套协议、同一组凭据)。推理路由装没装只影响模型能不能用,**不影响额度能不能查**——所以只持有 Coding Plan、却没在 DSH 里配 coding-plan 路由时,那条额度照样会显示(前提是配了 AK/SK)。
@@ -252,7 +264,7 @@ dsh plugin --profile <profile> remove dsh-subusage
252
264
  | Command Code | `COMMANDCODE_API_KEY` | 凭据由提供方插件管理,本页只读继承;兜底读取 `~/.commandcode/auth.json`(`cmd login`) |
253
265
  | 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`)取不到订阅周池 |
254
266
  | Codex | 无 | `openai-codex` 路由;只读 Codex CLI 的 ChatGPT 订阅登录文件(`$CODEX_HOME/auth.json` 或 `~/.codex/auth.json`,要求 `auth_mode` 为 `chatgpt`),不保存、不刷新 token |
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」 |
267
+ | 火山方舟 Ark(4 条路由) | `VOLC_ACCESSKEY` + `VOLC_SECRETKEY` | 额度查询要 **IAM Access Key 的 AK/SK 配对**,与推理 API Key(`ARKCLI_*_API_KEY` / `ARK_*_API_KEY`)**不是同一套**;4 条路由共用一组 AK/SK。这一组 AK/SK 能**同时**查到 Agent Plan 与 Coding Plan(含团队版席位)——不存在「Coding Plan 专用查询 key」 |
256
268
  | SiliconFlow | `SILICONFLOW_API_KEY` | 余额型:`GET /v1/user/info` 返回账户余额,**与推理是同一把 Key** |
257
269
  | OpenRouter | `OPENROUTER_API_KEY` | key 限额与用量用推理 key 即可;**账户余额需要 management / provisioning key**,普通 key 会被 403 拒——此时静默降级为只显示限额,不判失败 |
258
270
  | Synthetic | `SYNTHETIC_API_KEY` | 模型订阅请求额度 |
@@ -261,12 +273,12 @@ dsh plugin --profile <profile> remove dsh-subusage
261
273
  | Hyperbolic | `HYPERBOLIC_API_KEY` | 余额型;金额单位是**美分** |
262
274
  | DeepInfra | `DEEPINFRA_API_KEY` | 余额型 |
263
275
  | Chutes | `CHUTES_API_KEY` | 通用限额窗口 |
264
- | Ollama Cloud | `OLLAMA_API_KEY` | 余额型;鉴权是**裸 `Authorization`**(本插件已按其要求发送) |
276
+ | Ollama Cloud | `OLLAMA_API_KEY` | 订阅窗口型;鉴权是**裸 `Authorization`**(本插件已按其要求发送) |
265
277
  | Vercel AI Gateway | `AI_GATEWAY_API_KEY` | 余额型;金额是**十进制字符串** |
266
278
  | ZenMux | `ZENMUX_MANAGEMENT_API_KEY` | 额度端点**只认 Management API Key**(推理 key 不适用),所以变量名单独区分。在 <https://zenmux.ai/platform/management> 创建该 key |
267
279
  | LiteLLM | `LITELLM_API_KEY` + 代理地址 | 自建网关:除虚拟 Key(**与推理同一把**)还要在凭据区填写自己的 proxy 地址。管理端点在 **proxy 根**——填了 `/v1` 也会被去掉,不会拼成 `/v1/key/info` |
268
280
 
269
- 按 provider id 的字面映射(补齐上表按语义分组、未逐条列出的 5 条火山路由,逐条对应 `PROVIDERS[id].envName`):
281
+ 按 provider id 的字面映射(补齐上表按语义分组、未逐条列出的 4 条火山路由,逐条对应 `PROVIDERS[id].envName`):
270
282
 
271
283
  ```text
272
284
  deepseek DEEPSEEK_API_KEY
@@ -298,7 +310,7 @@ arkcli-agent-plan-team ARKCLI_AGENT_PLAN_TEAM_API_KEY
298
310
  arkcli-coding-plan-team ARKCLI_CODING_PLAN_TEAM_API_KEY
299
311
  ```
300
312
 
301
- 解析顺序(`inherit` 模式):**凭据服务 → 启动环境 → 旧手动配置兜底**;自定义(`manual`)模式只使用保存的手动 Key。Cookie 与登录文件型凭据不走这条链,见 [MiMo 登录与 Cookie](docs/providers.md#mimo-登录与-cookie) 与 [Codex ChatGPT 订阅凭据](docs/providers.md#codex-chatgpt-订阅凭据)。修改启动环境后是否需要重启取决于目标部署,不能把「我已经改了环境变量」当作运行进程已经读到。
313
+ 解析顺序(`inherit` 模式):**凭据服务 → 启动环境 → 旧手动配置兜底**;自定义(`manual`)模式只使用保存的手动 Key。Cookie 与登录文件型凭据不走这条链,见 [MiMo 登录与 Cookie](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/providers.md#mimo-登录与-cookie) 与 [Codex ChatGPT 订阅凭据](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/providers.md#codex-chatgpt-订阅凭据)。修改启动环境后是否需要重启取决于目标部署,不能把「我已经改了环境变量」当作运行进程已经读到。
302
314
 
303
315
  </details>
304
316
 
@@ -313,7 +325,7 @@ arkcli-coding-plan-team ARKCLI_CODING_PLAN_TEAM_API_KEY
313
325
  | `~/.grok/auth.json`(回退 `~/.dsh/.xai-oauth-auth.json`) | Grok CLI / dsh-grok-kit 的 OAuth 登录 | 平时只读;过期时经 grok-kit 官方链续约并写回同一文件,不自行裸刷 |
314
326
  | `~/.commandcode/auth.json` | Command Code CLI(`cmd login`)的 Key | **只读**兜底 |
315
327
 
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 自动登录会另外打开官方平台页面,由你在该页面自行输入密码与验证码。**本插件没有自己的服务器,不做遥测,也不把你的数据发给任何第三方。**
328
+ **网络访问**:只连上面清单里各家自己的额度 / 余额端点,以及 `platform.xiaomimimo.com`(MiMo 用量与登录)、`api.commandcode.ai`、`cli-chat-proxy.grok.com`、`chatgpt.com/backend-api`、火山管控面 `open.volcengineapi.com`,外加你自己填写的 LiteLLM proxy 地址。完整域名表见 [网络端点](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/providers.md#网络端点)。MiMo 自动登录会另外打开官方平台页面,由你在该页面自行输入密码与验证码。**本插件没有自己的服务器,不做遥测,也不把你的数据发给任何第三方。**
317
329
 
318
330
  **命令、进程与安装期行为**
319
331
 
@@ -342,10 +354,10 @@ arkcli-coding-plan-team ARKCLI_CODING_PLAN_TEAM_API_KEY
342
354
  | `providers` | `string[]`(可选) | 限定范围。可传本插件的 provider id(`deepseek`、`zai-coding-cn`、`kimi-coding`…),也可直接传厂商名(`zai`、`kimi`、`mimo`、`minimax`、`commandcode`、`grok`、`codex`、`ark`、`deepseek`…);大小写与 `-` `.` `_` 都无关。省略即读取全部 |
343
355
  | `refresh` | `boolean`(可选) | `true` 绕过最多 60 秒的缓存,仅在确需最新数字时使用 |
344
356
 
345
- 调用约定(**结果永远不为空**):
357
+ 调用约定(无可读条目时返回汇总与下一步,不伪造额度):
346
358
 
347
359
  - 模型手里的名字往往不是这里的 provider id(它更可能看到 DSH 的路由名,例如 `zai`),所以匹配是**宽松**的:一个名字命中多条路由时**全部返回**(`zai` → 中国版 + 国际版,`ark` → 4 条),每条各自带着真实 `state`(没启用的如实标 `disabled`),由调用方自己判断要看哪条。
348
- - 一个名字都认不出时,返回里会**先**给一条说明(写明可用写法),**再附上全部 27 条数据**——数据先给出去,判断交给调用方,而不是回一个空结果。
360
+ - 一个名字都认不出时,返回里会**先**给一条说明(写明可用写法),**再读取全部路由,并按下述规则保留有数据或读取失败的条目**——数据先给出去,判断交给调用方,而不是回一个空结果。
349
361
  - **点名的家原样返回,其余按「有没有数据」收起**:`providers: ["kimi"]` 时被点中的家即使没配凭据也照原样给出(用户问的正是「它为什么没数据」);**不点名时**——以及名字一个都没认出来、走全量兜底时——省略检测已关闭、未配置凭据、以及接口没报告任何额度的条目,省略条数按**家**写进 `omitted`(同一家的多个 Command Code 账户只算一家),否则二十多条路由会把真正的数字淹掉。**读取失败的条目会保留**——那是「这次没读到」,不是「没配置」,省略掉只会把排障方向带偏;认不出的名字自己那行也保留。
350
362
  - **Command Code 按账户各回一条**:账户清单读自提供方插件的 `llm-commandcode` 设置,每条带 `account`(账户名)、`accountActive`(当前激活的那个)与 `accountMode`(`fixed` 固定账户 / `auto` 自动轮换),激活账户排在最前。「还剩多少」与「现在实际用哪个账户」一次问清。
351
363
 
@@ -357,12 +369,13 @@ const view = await service.quota({ providerIds: ["deepseek"], force: false });
357
369
  // view = {
358
370
  // updatedAt,
359
371
  // providers: [{ providerId, label, state, windows, extras, coverage?, freshness?, lastSuccessAt?, error?,
360
- // account?, accountActive?, accountMode? }]
372
+ // account?, accountActive?, accountMode?, availability?, availabilityReason? }]
361
373
  // }
362
374
  ```
363
375
 
364
376
  - `state`:`ok` | `no-key` | `no-cookie` | `error` | `disabled`
365
- - `windows[].percent` 是**已用**百分比(0–100);`extras` 放余额与套餐名等补充项
377
+ - `windows[].percent` 是**已用**百分比(0–100);`extras` 放余额与套餐名等补充项。`budgetScope` / `detail.scope` 区分多预算和能力范围
378
+ - `availability?` 为 `included | paygo | blocked | unknown`,`availabilityReason?` 解释依据;读取成功、余额或健康百分比不能单独保证续用资格
366
379
  - `account` / `accountActive` / `accountMode` 只在多账户的 Command Code 上出现(见下);此时同一条路由会有**多条**条目(每个账户一条,`providerId` 相同),消费方按 `account` 区分
367
380
  - 视图**刻意精简**:不返回设置,也不返回 `keySource` / `apiDetected` / 继承变量名等内部状态,第三方消费者拿不到任何与 Key 相关的字段。多账户路由(Command Code)连错误文案里的**凭据引用名**也会换成账户标签(`Account 2`),内部 `apiKeyEnv` 只留在本机界面那条通道里(用户要靠它定位该往哪个 ref 写凭据)
368
381
  - 视图里**不出现值为 `undefined` 的字段**:字段缺席表示「这家没有报告」,而不是「报告了空」——带 `undefined` 的键会被调用方的 schema 校验判成型别错误
@@ -376,7 +389,7 @@ const view = await service.quota({ providerIds: ["deepseek"], force: false });
376
389
  |---|---|
377
390
  | 药丸不显示 | 依次确认:当前模型的 provider id 是否与上表一致(自定义 id 不会自动映射)→ 该家检测开关是否开启 → 是否被「没有检测到API的默认隐藏」收起(可在设置页临时关掉它)→ 运行中的 Host 与 Client 是否都已重载 |
378
391
  | 设置页没有提供商标签 | 展开提供商管理,确认开关和凭据;无凭据的条目默认隐藏,可临时关闭自动隐藏查看指引 |
379
- | 提示「无凭据 / 未检测到 Key」 | 先确认填的是**额度凭据**而不是推理 Key(两者不通用,见[凭据获取指引](docs/credentials.md)),再按该家小节核对来源、环境变量与权限 |
392
+ | 提示「无凭据 / 未检测到 Key」 | 先确认填的是**额度凭据**而不是推理 Key(两者不通用,见[凭据获取指引](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/credentials.md)),再按该家小节核对来源、环境变量与权限 |
380
393
  | 提示「已保存」但仍是「无凭据」 | 凭据编辑要点「**保存设置**」才提交(提供商开关才是立即保存);确认后若仍为空,把界面文案反馈上来 |
381
394
  | 某家读取失败,其他家正常 | 单家失败只影响自己的条目。按文案分类处理:认证类(401 / 凭据被拒)换对应产品与区域的凭据;权限 / 未订阅类(403)检查账号权限与套餐;限流类等待退避后重试 |
382
395
  | 显示认证失效 | 检查是否用了对应产品 / 区域的订阅 Key;MiMo 重新登录或导入 Cookie;旧额度不会当作有效数据保留 |
@@ -427,9 +440,9 @@ node scripts/render-ui-preview.mjs 530 nanogpt light pill
427
440
  node scripts/check-browser-runtime.mjs # 需要工作区可解析 playwright-core 且本机装有 Google Chrome
428
441
  ```
429
442
 
430
- 文档入口:[凭据获取指引](docs/credentials.md)(每类凭据的官网入口、环境变量与填入位置、报错对照)、[开发与验证](docs/development.md)(RPC 契约、缓存与凭据约定、离线预览与实机验收范围)、[提供商清单与额度语义](docs/providers.md)、[提供商覆盖与取舍](docs/provider-coverage.md)、[UX 设计](docs/design-ux.md)、[发布与市场收录](docs/publish.md)、[更新记录](docs/changelog.md)、[审查记录](docs/code-review.md)。
443
+ 文档入口:[凭据获取指引](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/credentials.md)(每类凭据的官网入口、环境变量与填入位置、报错对照)、[开发与验证](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/development.md)(RPC 契约、缓存与凭据约定、离线预览与实机验收范围)、[提供商清单与额度语义](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/providers.md)、[提供商覆盖与取舍](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/provider-coverage.md)、[UX 设计](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/design-ux.md)、[发布与市场收录](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/publish.md)、[更新记录](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/changelog.md)、[审查记录](https://github.com/KouzakiUmi/dsh-subusage/blob/main/docs/code-review.md)。
431
444
 
432
- > 打包说明:`package.json` 的 `files` 白名单是 `lib` / `cordis.patch.yml` / `README.md` / `LICENSE`,因此 `assets/title.svg`、`assets/screenshots/` 与 `docs/` **不进 npm 包**——顶部 title 图与下面的界面预览只在 GitHub 页面显示。改动白名单属于发布决策,需单独处理。
445
+ > 打包说明:`package.json` 的 `files` 白名单是 `lib` / `cordis.patch.yml` / `README.md` / `LICENSE`,因此 `assets/title.svg`、`assets/screenshots/` 与 `docs/` **不进 npm 包**——图片通过 GitHub 绝对地址加载,npm 页面无需把图片打进包也能显示。改动白名单属于发布决策,需单独处理。
433
446
 
434
447
  ### 界面预览
435
448
 
@@ -443,9 +456,22 @@ node scripts/check-browser-runtime.mjs # 需要工作区可解析 playwright-c
443
456
 
444
457
  ![浅色 NanoGPT 用量弹层(离线测试预览)](https://raw.githubusercontent.com/KouzakiUmi/dsh-subusage/main/assets/screenshots/usage-popover-light.png)
445
458
 
446
- 药丸 15 种状态一览(浅色 / 深色)见[开头的能力展示](#15-种状态一眼分清)。
459
+ 药丸 15 种状态一览(浅色 / 深色)见[开头的能力展示](#状态一眼分清)。
460
+
461
+ 截图声明见根目录 [`screenshots.json`](screenshots.json)。上述四张保留 0.10.11 的基础界面预览;下方三张为 0.11.2 新增语义状态的真实组件离线预览(虚构用量、桩网络、无凭据),底部水印明确标注「非运行中的 DSH 截图」。真实 DSH Loader、实际账号在线接口与多账户映射仍未验收,截图不代表已通过这些检查。
462
+
463
+ ### 新增:额度耗尽与整体续用分开表达
447
464
 
448
- 截图声明见根目录 [`screenshots.json`](screenshots.json)。这些图都是**当前版本**的离线组件预览(虚构用量、桩网络、无凭据),已随本版本重做,底部水印明确标注「非运行中的 DSH 截图」。真实 DSH Loader、实际账号在线接口与多账户映射仍未验收,截图不代表已通过这些检查。
465
+ <details>
466
+ <summary>查看 0.11.2 的按量续用、轮换池与部分能力限制(虚构数据)</summary>
467
+
468
+ ![包含额度耗尽但可按量续用](https://raw.githubusercontent.com/KouzakiUmi/dsh-subusage/main/assets/screenshots/paygo-dark.png)
469
+
470
+ ![默认账户限额但轮换池仍可用](https://raw.githubusercontent.com/KouzakiUmi/dsh-subusage/main/assets/screenshots/account-pool-dark.png)
471
+
472
+ ![日能力池耗尽不等于全部套餐不可用](https://raw.githubusercontent.com/KouzakiUmi/dsh-subusage/main/assets/screenshots/scoped-quota-dark.png)
473
+
474
+ </details>
449
475
 
450
476
  ## License & security / 许可与安全
451
477
 
package/lib/client.js CHANGED
@@ -122,6 +122,17 @@ window.__ModuleLoader__.load({
122
122
  pillCookieSoon: "Cookie {rest}后到期",
123
123
  mimoCookieExpiresAt: "{time} 到期",
124
124
  noUsageData: "暂无额度数据",
125
+ statusPaygo: "可按量续用",
126
+ statusPaygoSub: "已购余额或计费接口支持按量续用;实际请求与余额仍以服务端为准",
127
+ statusIncludedAdvice: "基础额度已用尽,接口仍允许订阅续用",
128
+ statusAvailabilityUnknown: "续用条件待确认",
129
+ statusRestricted: "账户或预算已受限",
130
+ statusScopeLimited: "部分能力额度已用尽",
131
+ statusPoolAvailable: "轮换池仍有可用账户", statusPoolAvailableShort: "池可用", scopeAllShort: "共享额度",
132
+ statusPoolUnknown: "轮换池可用性待确认",
133
+ budgetKey: "Key 预算", budgetUser: "用户预算", budgetTeam: "团队预算", budgetScope: "上级预算",
134
+ noteLabel: "说明", creditStatusLabel: "Credits 状态", statusUnlimited: "已报告的套餐窗口不设限",
135
+ statusPaygoShort: "按量续用", statusIncludedShort: "仍可续用", statusUnknownShort: "续用待确认", statusScopeShort: "部分能力用尽", statusRestrictedShort: "已受限",
125
136
  providerUnavailable: "用量暂不可用",
126
137
  pillNeedCommandcode: "需配置 —— 可在 设置 → Command Code 登录、运行 cmd login,或导出 COMMANDCODE_API_KEY 后重试。",
127
138
  pillNeedGrok: "需登录 —— 可在 设置 → Grok Kit(xAI OAuth)登录或运行 grok login(共享登录文件 ~/.grok/auth.json)。",
@@ -266,6 +277,17 @@ window.__ModuleLoader__.load({
266
277
  pillCookieSoon: "Cookie expires in {rest}",
267
278
  mimoCookieExpiresAt: "Expires {time}",
268
279
  noUsageData: "No quota data",
280
+ statusPaygo: "Paid usage available",
281
+ statusPaygoSub: "Purchased balance or billing advice permits paid usage; the server and actual balance remain authoritative",
282
+ statusIncludedAdvice: "Base quota used up; subscription usage still advised",
283
+ statusAvailabilityUnknown: "Continuation conditions not confirmed",
284
+ statusRestricted: "Account or budget restricted",
285
+ statusScopeLimited: "Some capability quotas used up",
286
+ statusPoolAvailable: "Rotation pool still has usable accounts", statusPoolAvailableShort: "pool usable", scopeAllShort: "shared quota",
287
+ statusPoolUnknown: "Rotation pool availability not confirmed",
288
+ budgetKey: "Key budget", budgetUser: "User budget", budgetTeam: "Team budget", budgetScope: "Parent budget",
289
+ noteLabel: "Note", creditStatusLabel: "Credits status", statusUnlimited: "Reported plan windows are unlimited",
290
+ statusPaygoShort: "paid usage", statusIncludedShort: "usage advised", statusUnknownShort: "check continuation", statusScopeShort: "some quotas used up", statusRestrictedShort: "restricted",
269
291
  providerUnavailable: "Usage unavailable",
270
292
  pillNeedCommandcode: "Setup required — sign in under Settings → Command Code, run cmd login, or export COMMANDCODE_API_KEY and retry.",
271
293
  pillNeedGrok: "Login required — sign in under Settings → Grok Kit (xAI OAuth) or run grok login (shared login file ~/.grok/auth.json).",
@@ -446,12 +468,12 @@ window.__ModuleLoader__.load({
446
468
  }
447
469
  /** extras 标签:plan / account / balance 三类,未知类型回退为余额。 */
448
470
  function extraLabel(x, t) {
449
- return x.kind === "plan" ? t("planLabel") : x.kind === "account" ? t("accountLabel") : x.kind === "monthly-balance" ? t("monthlyBalanceLabel") : x.kind === "spend" ? t("spendLabel") : t("balanceLabel");
471
+ return x.kind === "plan" ? t("planLabel") : x.kind === "account" ? t("accountLabel") : x.kind === "monthly-balance" ? t("monthlyBalanceLabel") : ["note", "quota-status"].includes(x.kind) ? t("noteLabel") : x.kind === "credit-status" ? t("creditStatusLabel") : x.kind === "spend" || x.kind.endsWith("-spend") ? t("spendLabel") : t("balanceLabel");
450
472
  }
451
473
  /** 余额兜底文案:没有额度窗口但接口报告了余额时(余额型 provider、或额度接口暂时只给余额),余额就是唯一信息。 */
452
474
  function balanceText(entry, t) {
453
- const row = entry && Array.isArray(entry.extras) ? entry.extras.find(x => x.kind === "balance") : undefined;
454
- return row ? `${t("balanceLabel")} ${row.value}` : null;
475
+ const row = entry && Array.isArray(entry.extras) ? entry.extras.find(x => x.kind === "balance") ?? entry.extras.find(x => x.kind === "spend") : undefined;
476
+ return row ? `${extraLabel(row, t)} ${row.value}` : null;
455
477
  }
456
478
  /** 时长紧凑文案:窗口重置倒计时与 Cookie 剩余有效期共用同一档位。 */
457
479
  function durationText(ms, t) {
@@ -710,13 +732,11 @@ window.__ModuleLoader__.load({
710
732
  */
711
733
  function usageAttentionRank(entry, t) {
712
734
  if (!entry) return 0;
713
- if (entry.state === "error") return 3;
714
- const expiry = cookieExpiry(entry, t);
715
- if (expiry && expiry.expired) return 3;
716
- if ((entry.windows || []).some(w => w.status === "rate-limited")) return 2;
717
- if (expiry && expiry.rank >= 2) return 2;
718
- if (entry.freshness === "stale" || entry.coverage === "partial") return 1;
719
- return 0;
735
+ const status = computeStatus(entry, { providerId: entry.providerId, t, getLocale: () => undefined });
736
+ if (status.key === "error" || status.key === "cookie" && status.expiry?.expired) return 3;
737
+ if (["blocked", "restricted", "cookie"].includes(status.key)) return 2;
738
+ if (["paygo", "included", "unknown", "scope", "pool-unknown", "degraded"].includes(status.key)) return 1;
739
+ return status.key === "pool" && (entry.accountPool.blockedCount || entry.accountPool.unusableCount) ? 1 : 0;
720
740
  }
721
741
  function providerDescription(entry, t) {
722
742
  const windows = canShowUsage(entry) ? entry.windows || [] : [];
@@ -1030,21 +1050,21 @@ window.__ModuleLoader__.load({
1030
1050
  if (!pool || pool.mode !== "auto" || !Array.isArray(pool.accounts) || pool.total <= 1) return null;
1031
1051
  const summary = pool.exhausted
1032
1052
  ? t("ccPoolExhausted")
1033
- : pool.blockedCount > 0
1053
+ : pool.blockedCount > 0 || pool.unusableCount > 0
1034
1054
  ? t("ccPoolCapacity").replace("{available}", pool.available).replace("{total}", pool.total).replace("{blocked}", pool.blockedCount)
1035
1055
  : t("ccPoolAllHealthy").replace("{n}", pool.total);
1036
- const tone = pool.exhausted ? "#ef4444" : pool.blockedCount > 0 ? "#eab308" : "#22c55e";
1056
+ const tone = pool.exhausted ? "#ef4444" : pool.blockedCount > 0 || pool.unusableCount > 0 ? "#eab308" : "#22c55e";
1037
1057
  const rowFor = (account) => {
1038
1058
  const worst = worstWindow(account.windows || []);
1039
- const tier = account.usable || account.blocked ? usageTier(worst || { percent: 0, status: "ok" }) : null;
1059
+ const tier = account.availability === "paygo" ? { color: "#eab308", icon: "ℹ" } : account.usable || account.blocked ? usageTier(worst || { percent: 0, status: "ok" }) : null;
1040
1060
  const blocked = account.blocked;
1041
1061
  const unusable = !account.usable && !account.blocked;
1042
1062
  const dot = statusGlyph(unusable ? NEUTRAL : (tier ? tier.color : NEUTRAL), unusable ? "?" : blocked ? "✕" : (tier ? tier.icon : "?"), 14);
1043
1063
  const detail = unusable
1044
- ? (account.state === "error" ? t("statusError") : account.state === "disabled" ? t("statusDisabled") : account.state === "no-cookie" ? t("statusNoCookie") : t("statusNoKey"))
1064
+ ? (account.availability === "unknown" ? t("statusUnknownShort") : account.state === "error" ? t("statusError") : account.state === "disabled" ? t("statusDisabled") : account.state === "no-cookie" ? t("statusNoCookie") : t("statusNoKey"))
1045
1065
  : blocked
1046
1066
  ? t("pillLimited")
1047
- : worst ? pillText(worst, (account.windows || []).length > 1, t)
1067
+ : account.availability === "paygo" ? t("statusPaygoShort") : worst ? pillText(worst, (account.windows || []).length > 1, t)
1048
1068
  : t("noUsageData");
1049
1069
  const canPin = cc && account.id !== "default";
1050
1070
  return h("button", {
@@ -1126,7 +1146,7 @@ window.__ModuleLoader__.load({
1126
1146
  // 倒计时只用于展示,永远不能盖过官方接口的判定:凭据已被拒绝(state=error)时主文案仍是失败原因;
1127
1147
  // 例外是已过期——它本身就是「请重新登录」的行动项。
1128
1148
  const expiryActionable = expiring && (!(rawEntry && rawEntry.state === "error") || expiry.expired);
1129
- const windowName = (w) => t(KIND_LABEL[w.kind] ?? "wall");
1149
+ const windowName = (w) => w.budgetScope ? t({ key: "budgetKey", user: "budgetUser", team: "budgetTeam", scope: "budgetScope" }[w.budgetScope] ?? "budgetScope") : t(KIND_LABEL[w.kind] ?? "wall") + (w.detail?.scope === "all" ? ` · ${t("scopeAllShort")}` : "");
1130
1150
  const windowShort = (w) => multi ? `${t(KIND_SHORT[w.kind] ?? "srolling")} ` : "";
1131
1151
  const windowSub = (w) => {
1132
1152
  const head = t(multi ? "statusWorst" : "statusOnly").replace("{w}", windowName(w)).replace("{p}", String(windowLeft(w)));
@@ -1138,6 +1158,17 @@ window.__ModuleLoader__.load({
1138
1158
  const base = { entry, windows, worst, worstTier, multi, degraded, expiry, expiring, expiryActionable, needConfig };
1139
1159
  if ((!rawEntry && !failed && !failState) || state === "loading") return { ...base, key: "loading", color: NEUTRAL, icon: "…", title: t("loading"), sub: "", short: "", tinted: false };
1140
1160
  if (state === "disabled") return { ...base, key: "disabled", color: NEUTRAL, icon: "–", title: t("statusDisabled"), sub: "", short: t("statusDisabled"), tinted: false };
1161
+ const pool = rawEntry?.accountPool;
1162
+ if (pool?.mode === "auto" && pool.available > 0) return { ...base, key: "pool", color: pool.blockedCount || pool.unusableCount ? "#eab308" : "#22c55e", icon: "✓", title: t("statusPoolAvailable"), sub: `${pool.available}/${pool.total}`, short: pool.blockedCount || pool.unusableCount ? `${pool.available}/${pool.total} ${t("statusPoolAvailableShort")}` : "", tinted: !!(pool.blockedCount || pool.unusableCount) };
1163
+ if (pool?.mode === "auto" && !pool.exhausted && pool.accounts?.some(a => a.state === "error" || a.availability === "unknown")) return { ...base, key: "pool-unknown", color: "#eab308", icon: "?", title: t("statusPoolUnknown"), sub: "", short: t("statusPoolUnknown"), tinted: true };
1164
+ const freshAdvice = rawEntry?.state === "ok" && rawEntry.freshness !== "stale";
1165
+ if (freshAdvice && rawEntry.availability === "blocked" && (rawEntry.availabilityReason !== "quota-exhausted" || !worstTier || worstTier.rank < 4)) return { ...base, key: "restricted", color: "#ef4444", icon: "✕", title: t("statusRestricted"), sub: "", short: t("statusRestrictedShort"), tinted: true };
1166
+ if (freshAdvice && (["paygo", "unknown"].includes(rawEntry.availability) || rawEntry.availability === "included" && worstTier?.rank >= 4)) {
1167
+ const title = t(rawEntry.availability === "paygo" ? "statusPaygo" : rawEntry.availability === "included" ? "statusIncludedAdvice" : "statusAvailabilityUnknown");
1168
+ return { ...base, key: rawEntry.availability, color: "#eab308", icon: rawEntry.availability === "unknown" ? "?" : "ℹ", title, sub: rawEntry.availability === "paygo" ? t("statusPaygoSub") : worst ? windowSub(worst) : "", short: t(rawEntry.availability === "paygo" ? "statusPaygoShort" : rawEntry.availability === "included" ? "statusIncludedShort" : "statusUnknownShort"), tinted: true };
1169
+ }
1170
+ const scopeLimited = worstTier?.rank >= 4 && windows.some(w => w.status !== "rate-limited" && w.detail?.scope && w.detail.scope !== "all") && windows.filter(w => w.status === "rate-limited").every(w => w.detail?.scope && w.detail.scope !== "all");
1171
+ if (scopeLimited) return { ...base, key: "scope", color: "#eab308", icon: "⚠", title: t("statusScopeLimited"), sub: windowSub(worst), short: t("statusScopeShort"), tinted: true };
1141
1172
  if (worstTier && worstTier.rank >= 4) {
1142
1173
  const title = t(worst.cascade ? "statusBlocked" : "statusExhausted");
1143
1174
  return { ...base, key: "blocked", color: worstTier.color, icon: "✕", title, sub: windowSub(worst), short: windowShort(worst) + t(worst.cascade ? "pillBlocked" : "pillLimited"), tinted: true };
@@ -1164,6 +1195,7 @@ window.__ModuleLoader__.load({
1164
1195
  if (worstTier.rank === 2) return { ...base, key: "low", color: worstTier.color, icon: "⚠", title: t("statusLow"), sub, short: windowShort(worst) + t(worstTier.badge), tinted: true };
1165
1196
  return { ...base, key: "ok", color: worstTier.color, icon: "✓", title: t("statusPlenty"), sub, short: "", tinted: false };
1166
1197
  }
1198
+ if (entry.extras?.some(e => e.kind === "quota-status" && e.value === "unlimited")) return { ...base, key: "unlimited", color: NEUTRAL, icon: "ℹ", title: t("statusUnlimited"), sub: "", short: t("statusUnlimited"), tinted: false };
1167
1199
  // 成功但没有额度窗口:只有余额(或什么都没有)。余额就是唯一信息,不画成「充足」。
1168
1200
  const balance = balanceText(entry, t);
1169
1201
  return { ...base, key: "balance", color: NEUTRAL, icon: "ℹ", title: balance ?? t("noUsageData"), sub: "", short: balance ?? t("noUsageData"), tinted: false };
@@ -1218,7 +1250,7 @@ window.__ModuleLoader__.load({
1218
1250
  style: { marginTop: 8, padding: "10px 12px", borderRadius: 10, background: blocked ? alpha(tier.color, ".09") : "rgba(128,128,128,.07)" }
1219
1251
  },
1220
1252
  h("div", { style: { display: "flex", alignItems: "center", gap: 8 } },
1221
- h("span", { style: { fontSize: 12.5, fontWeight: 600 } }, t(KIND_LABEL[w.kind] ?? "wall")),
1253
+ h("span", { style: { fontSize: 12.5, fontWeight: 600 } }, w.budgetScope ? t({ key: "budgetKey", user: "budgetUser", team: "budgetTeam", scope: "budgetScope" }[w.budgetScope] ?? "budgetScope") : t(KIND_LABEL[w.kind] ?? "wall") + (w.detail?.scope === "all" ? ` · ${t("scopeAllShort")}` : "")),
1222
1254
  alert ? statusChip(t(tier.badge), tier.color) : null,
1223
1255
  h("span", { style: { flex: 1 } }),
1224
1256
  h("strong", { style: { fontSize: 18, lineHeight: 1.1, color: alert ? textTone(tier.color) : "inherit", whiteSpace: "nowrap" } }, `${left}%`),
@@ -1239,7 +1271,7 @@ window.__ModuleLoader__.load({
1239
1271
  * 弹层与设置页共用,避免只有其中一处带分组。
1240
1272
  */
1241
1273
  function UsageWindowList({ windows, t, getLocale, uncertain = false }) {
1242
- return (windows || []).map((w, index) => h(react.Fragment, { key: w.kind },
1274
+ return (windows || []).map((w, index) => h(react.Fragment, { key: `${w.budgetScope ?? w.kind}:${index}` },
1243
1275
  w.groupLabel && w.groupLabel !== windows[index - 1]?.groupLabel
1244
1276
  ? h("p", { role: "note", style: { margin: index ? "14px 0 0" : "6px 0 0", fontSize: 11, opacity: 0.6 } }, w.groupLabel)
1245
1277
  : null,
package/lib/index.js CHANGED
@@ -11,7 +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
+ const VERSION = "0.11.2";
15
15
  const inject = ["llm"];
16
16
  const PROVIDERS = {
17
17
  "zai-coding-cn": { label: "Z.ai", envName: "ZAI_CODING_CN_API_KEY" },
@@ -437,7 +437,7 @@ async function fetchJson(fetcher, url, headers, lifetimeSignal, init = {}) {
437
437
  // 避免用户被误导去重登录;两个条件都加显式括号,不依赖 &&/|| 优先级。
438
438
  if ((response.status >= 300 && response.status < 400)) throw failure("subusage/response", "服务端返回了意外的重定向响应(HTTP 3xx)");
439
439
  if ((response.status === 401 || response.status === 403)) throw failure("subusage/auth", "登录或凭据已失效");
440
- if (!response.ok) {
440
+ if (!response.ok && !(init.notSubscribed404 === true && response.status === 404)) {
441
441
  const retry = response.status === 408 || response.status === 429 || response.status >= 500;
442
442
  const raw = response.headers?.get?.("retry-after");
443
443
  const retryAfterMs = raw ? (/^\d+(\.\d+)?$/.test(raw) ? Number(raw) * 1000 : Math.max(0, Date.parse(raw) - Date.now())) : undefined;
@@ -453,7 +453,8 @@ async function fetchJson(fetcher, url, headers, lifetimeSignal, init = {}) {
453
453
  } catch (e) { if (e instanceof RemoteError) throw e; throw failure("subusage/network", "读取响应失败", true); }
454
454
  let body;
455
455
  try { body = JSON.parse(text); } catch { throw failure("subusage/response", "响应不是合法 JSON"); }
456
- if ([401, 403, "401", "403"].includes(body?.code)) throw failure("subusage/auth", "登录或凭据已失效");
456
+ if (response.status === 404 && init.notSubscribed404 === true && !/^(NotFound\.BillingType|OperationDenied\.NotSubscribed)$/.test(body?.ResponseMetadata?.Error?.Code ?? "")) throw failure("subusage/http", "HTTP 404");
457
+ if ([401, 403, "401", "403"].includes(body?.code)) throw failure("subusage/auth", "登录或凭据已失效");
457
458
  return body;
458
459
  }
459
460
  function numeric(v) { return typeof v === "number" && Number.isFinite(v) && v >= 0; }
@@ -466,7 +467,10 @@ const RANK = { month: 0, sub: 0, period: 0, week: 1, "7d": 1, day: 2, "5h": 2, r
466
467
  function cascadeRateLimited(windows) {
467
468
  const roots = windows.filter(w => w.status === "rate-limited");
468
469
  for (const w of windows) {
469
- const blockers = roots.filter(root => (RANK[root.kind] ?? 3) < (RANK[w.kind] ?? 3));
470
+ const blockers = roots.filter(root => {
471
+ const sourceScope = root.detail?.scope ?? "all", targetScope = w.detail?.scope ?? "all";
472
+ return (sourceScope === "all" || sourceScope === targetScope) && (RANK[root.kind] ?? 3) < (RANK[w.kind] ?? 3);
473
+ });
470
474
  if (blockers.length) { if (w.status !== "rate-limited") w.cascade = true; w.status = "rate-limited"; w.detail = { ...w.detail, blockedBy: [...new Set(blockers.map(root => root.kind))] }; }
471
475
  }
472
476
  return windows;
@@ -558,7 +562,12 @@ export function normalizeNanoGpt(body, balanceBody) {
558
562
  const extras = [{ kind: "plan", value: body.state }];
559
563
  const usd = typeof balanceBody?.usd_balance === "string" && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(balanceBody.usd_balance.trim()) ? balanceBody.usd_balance.trim() : undefined;
560
564
  if (usd) extras.push({ kind: "balance", value: `${usd} USD` });
561
- return { windows: cascadeRateLimited(windows), extras, coverage: windows.length && !partial ? "complete" : "partial" };
565
+ const routing = body?.routing;
566
+ const mode = routing?.recommendedMode;
567
+ const availability = mode === "unavailable" ? { availability: "blocked", availabilityReason: "routing-policy" }
568
+ : mode === "subscription" ? { availability: routing.subscriptionQuotaAvailable === true ? "included" : "unknown", availabilityReason: "routing-advice" }
569
+ : mode === "paygo" ? { availability: usd !== undefined && Number.isFinite(Number(usd)) && Number(usd) > 0 && routing.paidSpendPolicyAllowsBalance !== false ? "paygo" : "unknown", availabilityReason: "routing-advice" } : {};
570
+ return { windows: cascadeRateLimited(windows), extras, coverage: (windows.length || usd !== undefined || mode === "unavailable") && !partial ? "complete" : "partial", ...availability };
562
571
  }
563
572
  // 官方 CLI 的计数消歧规则:有 remaining_percent 时按它校准;旧响应 usage_count 是剩余。
564
573
  // https://github.com/MiniMax-AI/cli/blob/main/src/utils/quota.ts
@@ -602,7 +611,8 @@ export function normalizeMinimax(body) {
602
611
  const reset = numeric(endTime) && endTime > 0 && Number.isFinite(new Date(endTime).getTime()) ? new Date(endTime).toISOString() : undefined;
603
612
  windows.push(windowRow(kind, usedPercent, reset, undefined, detail));
604
613
  }
605
- return { windows: cascadeRateLimited(windows), extras: [], coverage: windows.length === 2 ? "complete" : "partial" };
614
+ const unlimited = !windows.length && row.current_interval_status === 3 && row.current_weekly_status === 3;
615
+ return { windows: cascadeRateLimited(windows), extras: unlimited ? [{ kind: "quota-status", value: "unlimited" }] : [], coverage: windows.length || [row.current_interval_status, row.current_weekly_status].includes(3) ? "complete" : "partial" };
606
616
  }
607
617
  export function normalizeCommandCode(body, subscriptionBody) {
608
618
  // 输入是 Command Code 计费接口(/alpha/billing/credits)的原始响应体。
@@ -642,7 +652,11 @@ export function normalizeCommandCode(body, subscriptionBody) {
642
652
  if (reported.length) extras.push({ kind: "balance", value: `${Math.round(reported.reduce((sum, v) => sum + Math.max(0, v), 0) * 100) / 100} credits` });
643
653
  // 月池耗尽并不证明已购/赠送池或短窗口也不可用;只级联接口的周/5小时限制。
644
654
  cascadeRateLimited(windows.filter(w => w.kind !== "month"));
645
- return { windows, extras, coverage: hasMonthlyBalance && monthlyCap !== undefined ? "complete" : "partial" };
655
+ const exhausted = windows.some(w => w.status === "rate-limited");
656
+ const purchased = credits.purchasedCredits;
657
+ const availability = exhausted && numeric(purchased) && purchased > 0 ? { availability: "paygo", availabilityReason: "paid-credits" }
658
+ : exhausted ? { availability: "blocked", availabilityReason: "quota-exhausted" } : {};
659
+ return { windows, extras, coverage: hasMonthlyBalance || windows.length || reported.length ? "complete" : "partial", ...availability };
646
660
  }
647
661
  // SuperGrok / X Premium 统一用量池:GetGrokCreditsConfig 响应(官方 grok CLI 的
648
662
  // /v1/billing?format=credits)。creditUsagePercent 是“已用”百分比(0-100),剩余由
@@ -688,11 +702,14 @@ export function normalizeSiliconFlow(body) {
688
702
  }
689
703
  // Codex / ChatGPT 订阅用量:rate_limit.primary_window 与 secondary_window 各含 used_percent 与
690
704
  // resets_at。免费档的次窗口是 30 天,所以有 window_seconds 时按它判窗口名,缺失时按位置回退 5h / 7d。
691
- // credits 只在真正报告了可用余额时显示:has_credits=false、unlimited=true、balance="0" 都不显示,
705
+ // credits 只在真正报告了可用余额时显示:has_credits=false、balance="0" 不画余额;unlimited 单独保留状态,
692
706
  // 不把「无额度」画成 0。
693
707
  // https://raw.githubusercontent.com/steipete/CodexBar/main/docs/codex.md
694
708
  export function normalizeCodexUsage(body) {
695
709
  const windows = [];
710
+ const plan = typeof body?.plan_type === "string" ? body.plan_type.trim() : "";
711
+ // 当前 Pro 三档无短窗;未知档位的缺失时长不按主窗口位置猜成 5h。
712
+ const weeklyOnly = ["pro", "prolite", "promax"].includes(plan.toLowerCase());
696
713
  const windowKind = (row, fallback) => {
697
714
  const seconds = row?.window_seconds ?? row?.limit_window_seconds;
698
715
  if (!Number.isFinite(seconds) || seconds <= 0) return fallback;
@@ -709,18 +726,35 @@ export function normalizeCodexUsage(body) {
709
726
  windows.push(windowRow(windowKind(row, fallback), row.used_percent, reset));
710
727
  };
711
728
  const limits = body?.rate_limit && typeof body.rate_limit === "object" && !Array.isArray(body.rate_limit) ? body.rate_limit : {};
712
- push(limits.primary_window, "5h");
713
- push(limits.secondary_window, "7d");
714
- // HTTP 200 但没有任何窗口 = 该账号没有可读的订阅额度,不是 0% 用量。
715
- if (!windows.length) return { windows: [], extras: [{ kind: "plan", value: "未检测到订阅额度窗口" }], coverage: "partial" };
729
+ const positional = !plan || ["plus", "team", "business"].includes(plan.toLowerCase());
730
+ push(limits.primary_window, weeklyOnly ? "week" : positional ? "5h" : "rolling");
731
+ push(limits.secondary_window, weeklyOnly ? "week" : positional ? "7d" : "rolling");
716
732
  const extras = [];
717
- // 实测:plan_type 给套餐档位(如 "plus"),值得显示。
718
- const plan = typeof body?.plan_type === "string" && body.plan_type.trim() ? body.plan_type.trim() : "";
719
733
  if (plan) extras.push({ kind: "plan", value: plan });
720
734
  const credits = body?.credits && typeof body.credits === "object" && !Array.isArray(body.credits) ? body.credits : {};
721
735
  const balance = typeof credits.balance === "string" && credits.balance.trim() ? credits.balance.trim() : typeof credits.balance === "number" ? String(credits.balance) : "";
722
- if (credits.has_credits === true && credits.unlimited !== true && balance && balance !== "0") extras.push({ kind: "balance", value: balance });
723
- return { windows: cascadeRateLimited(windows), extras, coverage: windows.length >= 2 ? "complete" : "partial" };
736
+ const creditBalance = balance && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(balance) && Number.isFinite(Number(balance)) ? Number(balance) : undefined;
737
+ if (credits.has_credits === true && credits.unlimited !== true && creditBalance !== undefined && creditBalance > 0) extras.push({ kind: "balance", value: balance });
738
+ if (credits.has_credits === true && credits.unlimited === true) extras.push({ kind: "credit-status", value: "unlimited" });
739
+ const control = body?.spend_control;
740
+ const member = control?.individual_limit;
741
+ const memberLimit = Number(member?.limit), memberUsed = member?.used == null ? NaN : Number(member.used);
742
+ if (member && Number.isFinite(memberLimit) && memberLimit > 0 && Number.isFinite(memberUsed) && memberUsed >= 0) {
743
+ const reset = numeric(member.reset_at) && member.reset_at > 0 ? new Date(member.reset_at * 1000).toISOString() : undefined;
744
+ windows.push({ ...windowRow("period", memberUsed / memberLimit * 100, reset, undefined, { used: memberUsed, limit: memberLimit, unit: "credits", scope: "member-budget" }), groupLabel: "Member budget" });
745
+ }
746
+ const reached = body?.rate_limit_reached_type?.kind;
747
+ const restricted = control?.reached === true || ["workspace_owner_credits_depleted", "workspace_member_credits_depleted", "workspace_owner_usage_limit_reached", "workspace_member_usage_limit_reached"].includes(reached);
748
+ const exhausted = windows.some(w => w.status === "rate-limited") || limits.limit_reached === true || limits.allowed === false;
749
+ const creditFallback = credits.has_credits === true && (credits.unlimited === true || creditBalance > 0);
750
+ const availability = restricted ? { availability: "blocked", availabilityReason: "spend-control" }
751
+ : exhausted && creditFallback ? { availability: "unknown", availabilityReason: "credit-eligibility" }
752
+ : exhausted ? { availability: "blocked", availabilityReason: "quota-exhausted" } : {};
753
+ if (!windows.length && !extras.some(e => e.kind !== "plan") && !restricted) extras.push({ kind: "note", value: "未检测到订阅额度窗口" });
754
+ const reported = windows.length || extras.some(e => e.kind !== "plan" && e.kind !== "note") || restricted;
755
+ // 额外特性池未按所选模型映射前,不声称已完整覆盖它们。
756
+ const partial = Array.isArray(body?.additional_rate_limits) && body.additional_rate_limits.length > 0;
757
+ return { windows: cascadeRateLimited(windows), extras, coverage: reported && !partial ? "complete" : "partial", ...availability };
724
758
  }
725
759
  // DeepSeek 官方余额(余额型):GET https://api.deepseek.com/user/balance,Bearer 与推理同一把 Key。
726
760
  // 响应 { is_available, balance_infos: [{ currency, total_balance, granted_balance, topped_up_balance }] },
@@ -761,14 +795,17 @@ export function normalizeOpenRouter(keyBody, creditBody) {
761
795
  const total = num(credits?.total_credits), spent = num(credits?.total_usage);
762
796
  if (total !== undefined && spent !== undefined) extras.push({ kind: "balance", value: `${Math.round(Math.max(0, total - spent) * 100) / 100} USD` });
763
797
  if (data.is_free_tier === true) extras.push({ kind: "plan", value: "Free tier" });
764
- if (!windows.length && !extras.length) throw failure("subusage/response", "OpenRouter 响应没有可用的限额或余额");
765
- return { windows: cascadeRateLimited(windows), extras, coverage: windows.length ? "complete" : "partial" };
798
+ if (!windows.length && num(data.usage) !== undefined) extras.push({ kind: "spend", value: `${Math.round(data.usage * 100) / 100} USD` });
799
+ if (!windows.length && !extras.length) throw failure("subusage/response", "OpenRouter 响应没有可用的限额、余额或用量");
800
+ return { windows: cascadeRateLimited(windows), extras, coverage: windows.length || extras.some(e => e.kind !== "plan") ? "complete" : "partial" };
766
801
  }
767
802
  // ── 余额型聚合商(各家单位与形状都不同,逐个显式换算,不做通用折算)────────────────
768
803
  /** Novita:金额单位是 1/10000 USD(10000 = $1.00)。 */
769
804
  export function normalizeNovitaBalance(body) {
770
- const num = value => typeof value === "number" && Number.isFinite(value) ? value : undefined;
771
- const available = num(body?.availableBalance);
805
+ // 官方金额为十进制字符串;不接受空串、指数或十六进制等非金额写法。
806
+ const raw = body?.availableBalance;
807
+ const available = typeof raw === "number" && Number.isFinite(raw) ? raw
808
+ : typeof raw === "string" && /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)$/.test(raw.trim()) && Number.isFinite(Number(raw)) ? Number(raw) : undefined;
772
809
  if (available === undefined) throw failure("subusage/response", "Invalid Novita balance response");
773
810
  return { windows: [], extras: [{ kind: "balance", value: `${Math.round(available / 100) / 100} USD` }], coverage: "complete" };
774
811
  }
@@ -809,7 +846,7 @@ export function normalizeOllamaCloudUsage(body) {
809
846
  windows.push(windowRow(kind, Math.min(100, usage * 100)));
810
847
  }
811
848
  if (!windows.length) throw failure("subusage/response", "Ollama Cloud 响应没有可用的额度窗口");
812
- return { windows: cascadeRateLimited(windows), extras: [], coverage: windows.length >= 3 ? "complete" : "partial" };
849
+ return { windows: cascadeRateLimited(windows), extras: [], coverage: "complete" };
813
850
  }
814
851
  /** Vercel AI Gateway:balance / total_used 是十进制字符串;没有限额也没有重置,只显示金额。 */
815
852
  export function normalizeVercelCredits(body) {
@@ -826,12 +863,14 @@ export function normalizeZenmux(subscriptionBody, balanceBody) {
826
863
  const num = value => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
827
864
  const windows = [], extras = [];
828
865
  const data = subscriptionBody?.data && typeof subscriptionBody.data === "object" && !Array.isArray(subscriptionBody.data) ? subscriptionBody.data : {};
829
- const quota = data.quota_5_hour && typeof data.quota_5_hour === "object" ? data.quota_5_hour : {};
830
- const usage = num(quota.usage_percentage);
831
- if (usage !== undefined) {
866
+ for (const [field, kind] of [["quota_5_hour", "5h"], ["quota_7_day", "week"]]) {
867
+ const quota = data[field];
868
+ if (quota === undefined || quota === null) continue;
869
+ if (typeof quota !== "object" || Array.isArray(quota) || num(quota.usage_percentage) === undefined) throw failure("subusage/response", "Invalid ZenMux quota window");
832
870
  const detail = num(quota.used_flows) !== undefined && num(quota.max_flows) !== undefined ? { used: quota.used_flows, limit: quota.max_flows, unit: "flows" } : undefined;
833
- windows.push(windowRow("5h", Math.min(100, usage * 100), typeof quota.resets_at === "string" ? quota.resets_at : undefined, undefined, detail));
871
+ windows.push(windowRow(kind, quota.usage_percentage * 100, typeof quota.resets_at === "string" ? quota.resets_at : undefined, undefined, detail));
834
872
  }
873
+ // quota_monthly 只有上限,未报告实时用量;不从短窗拼造月百分比。
835
874
  const tier = data.plan && typeof data.plan.tier === "string" ? data.plan.tier.trim() : "";
836
875
  if (tier) extras.push({ kind: "plan", value: tier });
837
876
  const balance = balanceBody?.data && typeof balanceBody.data === "object" && !Array.isArray(balanceBody.data) ? balanceBody.data : {};
@@ -841,7 +880,7 @@ export function normalizeZenmux(subscriptionBody, balanceBody) {
841
880
  extras.push({ kind: "balance", value: `${Math.round(total * 100) / 100} ${currency}`.trim() });
842
881
  }
843
882
  if (!windows.length && !extras.length) throw failure("subusage/response", "ZenMux 响应没有可用的配额或余额");
844
- return { windows: cascadeRateLimited(windows), extras, coverage: windows.length ? "complete" : "partial" };
883
+ return { windows: cascadeRateLimited(windows), extras, coverage: windows.length || total !== undefined ? "complete" : "partial", ...(["suspended", "banned"].includes(data.account_status) ? { availability: "blocked", availabilityReason: "account-restricted" } : {}) };
845
884
  }
846
885
  /**
847
886
  * LiteLLM 的管理端点在 **proxy 根**(不是 /v1):去掉尾部斜杠与常见的 /v1 后缀,
@@ -858,7 +897,7 @@ export function litellmRoot(baseUrl) {
858
897
  }
859
898
  /**
860
899
  * LiteLLM:`/key/info` 给 key 自身的预算与花费,`/user/info`|`/team/info` 给更完整的视图。
861
- * 有预算上限才画进度;**没有上限时不编造百分比**,只如实显示已用金额。
900
+ * 各预算同时生效,分别保留;**没有上限时不编造百分比**,只如实显示已用金额。
862
901
  * 接口不给重置时刻时不显示重置(别把金额渲染成倒计时)。
863
902
  * https://docs.litellm.ai/docs/proxy/virtual_keys
864
903
  */
@@ -868,17 +907,25 @@ export function normalizeLitellm(keyBody, budgetBody) {
868
907
  // 真实响应是 `{ key, info: {...} }`;也接受直接把字段放在顶层。
869
908
  const keyInfo = pick(keyBody?.info) ?? pick(keyBody);
870
909
  if (!keyInfo) throw failure("subusage/response", "Invalid LiteLLM key info response");
871
- const scope = pick(budgetBody) ?? {};
872
- const spend = num(scope.spend) ?? num(keyInfo.spend);
873
- const maxBudget = num(scope.max_budget) ?? num(keyInfo.max_budget);
874
- const resetAt = typeof scope.budget_reset_at === "string" ? scope.budget_reset_at : typeof keyInfo.budget_reset_at === "string" ? keyInfo.budget_reset_at : undefined;
910
+ const scopes = [["key", keyInfo]];
911
+ for (const body of Array.isArray(budgetBody) ? budgetBody : [budgetBody]) {
912
+ if (pick(body?.team_info)) scopes.push(["team", body.team_info]);
913
+ else if (pick(body?.user_info)) scopes.push(["user", body.user_info]);
914
+ else if (pick(body)) scopes.push(["scope", body]);
915
+ }
916
+ const windows = [], extras = [];
875
917
  const round = value => Math.round(value * 100) / 100;
876
- if (spend !== undefined && maxBudget !== undefined && maxBudget > 0) {
877
- return { windows: [windowRow("period", Math.min(100, spend / maxBudget * 100), resetAt, undefined, { used: round(spend), limit: round(maxBudget), unit: "USD" })], extras: [], coverage: "complete" };
918
+ for (const [scope, row] of scopes) {
919
+ const spend = num(row.spend), maxBudget = num(row.max_budget);
920
+ if (spend === undefined || spend < 0) continue;
921
+ if (maxBudget !== undefined && maxBudget > 0) {
922
+ const resetAt = typeof row.budget_reset_at === "string" ? row.budget_reset_at : undefined;
923
+ windows.push({ ...windowRow("period", spend / maxBudget * 100, resetAt, undefined, { used: round(spend), limit: round(maxBudget), unit: "USD" }), ...(scopes.length > 1 ? { budgetScope: scope } : {}) });
924
+ } else extras.push({ kind: scope === "key" ? "spend" : `${scope}-spend`, value: `${round(spend)} USD` });
878
925
  }
879
- // 没有预算上限:没有百分比可算,如实给金额。
880
- if (spend !== undefined) return { windows: [], extras: [{ kind: "spend", value: `${round(spend)} USD` }], coverage: "complete" };
881
- throw failure("subusage/response", "LiteLLM 响应没有可用的预算或花费");
926
+ if (!windows.length && !extras.length) throw failure("subusage/response", "LiteLLM 响应没有可用的预算或花费");
927
+ // 各预算同时约束,不用更宽松的上级覆盖 Key。
928
+ return { windows, extras, coverage: "complete" };
882
929
  }
883
930
  function detectConfigured(ctx) {
884
931
  try { const ids = new Set(ctx.llm.listProviders().map(p => p.id)); return Object.fromEntries(IDS.map(id => [id, ids.has(id)])); }
@@ -1055,7 +1102,7 @@ export class SubUsageService extends TypertRemoteService {
1055
1102
  const call = async (action, payload) => {
1056
1103
  const body = payload === undefined ? "{}" : JSON.stringify(payload);
1057
1104
  const signed = volcSignature({ accessKeyId: identity.key, secretAccessKey: identity.secret, action, host: spec.volcHost, region: spec.volcRegion, body });
1058
- const response = await get(signed.url, signed.headers, { method: "POST", body });
1105
+ const response = await get(signed.url, signed.headers, { method: "POST", body, notSubscribed404: true });
1059
1106
  const apiError = volcError(response);
1060
1107
  // 「这个账号没有这份套餐」不是故障,是答案:实测没有 BytePlus Coding Plan 的账号会返回
1061
1108
  // HTTP 404 `NotFound.BillingType`(coding plan config is not exist),`OperationDenied.NotSubscribed`
@@ -1157,10 +1204,14 @@ export class SubUsageService extends TypertRemoteService {
1157
1204
  const keyInfo = await get(`${root}/key/info`, headers);
1158
1205
  const own = keyInfo?.info && typeof keyInfo.info === "object" && !Array.isArray(keyInfo.info) ? keyInfo.info : {};
1159
1206
  // 更完整的预算视图在 user/team info;拿不到就退回 key 自身的 info。
1160
- const scoped = typeof own.team_id === "string" && own.team_id.trim() ? `/team/info?team_id=${encodeURIComponent(own.team_id.trim())}`
1161
- : typeof own.user_id === "string" && own.user_id.trim() ? `/user/info?user_id=${encodeURIComponent(own.user_id.trim())}` : "";
1162
- const budget = scoped ? await get(`${root}${scoped}`, headers).catch(() => undefined) : undefined;
1163
- return normalizeLitellm(keyInfo, budget);
1207
+ const scopes = [["team", own.team_id], ["user", own.user_id]].filter(([, value]) => typeof value === "string" && value.trim());
1208
+ const budgets = await Promise.all(scopes.map(async ([scope, value]) => {
1209
+ const body = await get(`${root}/${scope}/info?${scope}_id=${encodeURIComponent(value.trim())}`, headers).catch(() => undefined);
1210
+ return body ? body[`${scope}_info`] ? body : { [`${scope}_info`]: body } : undefined;
1211
+ }));
1212
+ const data = normalizeLitellm(keyInfo, budgets);
1213
+ if (budgets.some(body => body === undefined)) data.coverage = "partial";
1214
+ return data;
1164
1215
  }
1165
1216
  if (id === "commandcode") {
1166
1217
  // 与提供方插件同一凭据来源,但直连计费接口并行拉取:其 report() 会对每个账户
@@ -1170,7 +1221,9 @@ export class SubUsageService extends TypertRemoteService {
1170
1221
  get(`${COMMAND_CODE_API_BASE}/alpha/billing/credits`, billingHeaders),
1171
1222
  get(`${COMMAND_CODE_API_BASE}/alpha/billing/subscriptions`, billingHeaders).catch(() => undefined)
1172
1223
  ]);
1173
- return normalizeCommandCode(credits, subscription);
1224
+ const data = normalizeCommandCode(credits, subscription);
1225
+ if (subscription === undefined) data.coverage = "partial";
1226
+ return data;
1174
1227
  }
1175
1228
  if (id === "xai-oauth") {
1176
1229
  // 登录文件里的 access token 短命(实测 6 小时量级),而 grok-kit 的自动刷新是懒触发:
@@ -1200,7 +1253,7 @@ export class SubUsageService extends TypertRemoteService {
1200
1253
  }
1201
1254
  if (id === "openai-codex") {
1202
1255
  // ChatGPT 后端私有接口:Bearer 是 Codex CLI 的 OAuth access token,不是 OPENAI_API_KEY。
1203
- return normalizeCodexUsage(await get(`${CODEX_API_BASE}/wham/usage`, headers));
1256
+ return normalizeCodexUsage(await get(`${CODEX_API_BASE}/wham/usage`, { ...headers, ...(identity.accountId ? { "ChatGPT-Account-Id": identity.accountId } : {}) }));
1204
1257
  }
1205
1258
  const base = "https://platform.xiaomimimo.com/api/v1";
1206
1259
  const h = { cookie: identity.key, origin: "https://platform.xiaomimimo.com", referer: "https://platform.xiaomimimo.com/#/console/balance", "x-timezone": "UTC+08:00" };
@@ -1276,12 +1329,16 @@ export class SubUsageService extends TypertRemoteService {
1276
1329
  const entry = entries[index];
1277
1330
  const windows = Array.isArray(entry?.windows) ? entry.windows : [];
1278
1331
  const ok = entry?.state === "ok";
1332
+ const paid = entry?.availability === "paygo";
1333
+ const limited = entry?.availability === "blocked" || windows.some(w => w.status === "rate-limited");
1334
+ const known = paid || entry?.coverage !== "partial" && windows.length > 0;
1279
1335
  return {
1280
1336
  id: account.id, label: account.label, active: account.active === true,
1281
1337
  state: entry?.state ?? "error", errorCode: entry?.errorCode, windows,
1282
1338
  // usable:读到了且没有任何限额窗口;blocked:读到了但有窗口限额(容量降级,仍可能成功)。
1283
- usable: ok && !windows.some(w => w.status === "rate-limited"),
1284
- blocked: ok && windows.some(w => w.status === "rate-limited")
1339
+ usable: ok && (paid || known && !limited),
1340
+ ...(ok && !known && !limited ? { availability: "unknown" } : entry?.availability ? { availability: entry.availability } : {}),
1341
+ blocked: ok && !paid && limited
1285
1342
  };
1286
1343
  });
1287
1344
  // 「有效容量」= usable + blocked(真的有凭据且读到了额度的账户);no-key/error/disabled 不算。
@@ -1289,7 +1346,7 @@ export class SubUsageService extends TypertRemoteService {
1289
1346
  const available = accounts.filter(a => a.usable).length;
1290
1347
  const blockedCount = accounts.filter(a => a.blocked).length;
1291
1348
  const unusableCount = accounts.length - available - blockedCount;
1292
- const exhausted = capable.length > 0 && capable.every(a => a.blocked);
1349
+ const exhausted = capable.length > 0 && capable.every(a => a.blocked) && !accounts.some(a => a.state === "error" || a.availability === "unknown");
1293
1350
  return { mode: "auto", total: accounts.length, available, blockedCount, unusableCount, exhausted, accounts };
1294
1351
  }
1295
1352
  /** 按 id 失效一家 provider 的**全部账户**缓存:凭据/开关变化不能只影响默认账户那一条。 */
@@ -1330,7 +1387,7 @@ export class SubUsageService extends TypertRemoteService {
1330
1387
  }
1331
1388
  // fingerprint 计入账户选择:切换账户后同 id 缓存/inflight 不串账(不同账户 key 必不同);
1332
1389
  // 空串与 "default" 同为默认账户,归一后共享缓存。
1333
- const fingerprint = hash([identity.key ?? "", identity.secret ?? "", identity.source, settings.keyModes[id], id === "zai-coding-cn" ? settings.zai : null, id === "commandcode" ? (account && account !== "default" ? account : "default") : null]);
1390
+ const fingerprint = hash([identity.key ?? "", identity.secret ?? "", identity.source, settings.keyModes[id], id === "zai-coding-cn" ? settings.zai : null, id === "commandcode" ? (account && account !== "default" ? account : "default") : id === "openai-codex" ? identity.accountId ?? null : null]);
1334
1391
  if (this.fingerprints.has(cacheKey) && this.fingerprints.get(cacheKey) !== fingerprint) this.generations.set(cacheKey, (this.generations.get(cacheKey) ?? 0) + 1);
1335
1392
  this.fingerprints.set(cacheKey, fingerprint);
1336
1393
  const generation = this.generations.get(cacheKey) ?? 0;
@@ -1415,6 +1472,8 @@ export class SubUsageService extends TypertRemoteService {
1415
1472
  // 只输出真正有值的字段:视图里带 `undefined` 的键会被调用方的 schema 校验判成型别错误
1416
1473
  // (DSH 就会),而且「字段缺席」本来就该表示「这家没报告」,不是「报告了空」。
1417
1474
  ...(typeof entry.coverage === "string" ? { coverage: entry.coverage } : {}),
1475
+ ...(typeof entry.availability === "string" ? { availability: entry.availability } : {}),
1476
+ ...(typeof entry.availabilityReason === "string" ? { availabilityReason: entry.availabilityReason } : {}),
1418
1477
  ...(typeof entry.freshness === "string" ? { freshness: entry.freshness } : {}),
1419
1478
  ...(typeof entry.lastSuccessAt === "string" ? { lastSuccessAt: entry.lastSuccessAt } : {}),
1420
1479
  // 对外视图里不能出现内部凭据引用名:no-key 文案嵌了它,这里换成账户标签
@@ -1535,7 +1594,8 @@ function formatQuotaText(value) {
1535
1594
  if (entry.state !== "ok") return `${head}:${entry.error || "读取失败"}`;
1536
1595
  const parts = [];
1537
1596
  for (const window of entry.windows || []) {
1538
- parts.push(`${window.kind} ${window.status === "rate-limited" ? "已达限额" : `${Math.round(Math.max(0, 100 - window.percent) * 10) / 10}% 剩余`}${window.resetsAt ? `(重置 ${window.resetsAt})` : ""}`);
1597
+ const scope = window.budgetScope ? `${window.budgetScope} 预算` : ({ text: "文本/向量", "daily-capabilities": "日额度适用能力", all: "全部能力共享" }[window.detail?.scope] || window.groupLabel);
1598
+ parts.push(`${scope ? scope + " · " : ""}${window.kind} ${window.status === "rate-limited" ? "已达限额" : `${Math.round(Math.max(0, 100 - window.percent) * 10) / 10}% 剩余`}${window.resetsAt ? `(重置 ${window.resetsAt})` : ""}`);
1539
1599
  }
1540
1600
  // 账户名已经在标题行里了;再打一遍只会把额度挤出视野。
1541
1601
  // **只对多账户行跳过**(顶层 account 字段 = Command Code 的账户标注):火山方舟的席位标注
@@ -1543,7 +1603,9 @@ function formatQuotaText(value) {
1543
1603
  // 信息,跟着一起丢就等于把额度说成不属于任何席位。
1544
1604
  const skipAccountExtra = typeof entry.account === "string" && entry.account !== "";
1545
1605
  for (const extra of entry.extras || []) { if (skipAccountExtra && extra.kind === "account") continue; parts.push(`${extra.kind}: ${extra.value}`); }
1546
- if (entry.freshness === "stale") parts.push("缓存数据");
1606
+ const advice = { included: "接口仍允许订阅续用(基础额度可能已用尽)", paygo: "包含额度用尽时可按量续用,实际余额与服务端为准", blocked: "账户/预算受限或包含额度已用尽", unknown: "续用条件待确认,不保证可调用" };
1607
+ if (advice[entry.availability]) parts.push(advice[entry.availability]);
1608
+ if (entry.freshness === "stale") parts.push("缓存数据");
1547
1609
  return `${head}:${parts.length ? parts.join(";") : "暂无额度数据"}`;
1548
1610
  });
1549
1611
  // 一条额度都没读到时,末尾补一句汇总原因:模型据此才能告诉用户下一步做什么。
@@ -1578,7 +1640,8 @@ function hasQuotaData(entry) {
1578
1640
  if (!entry) return false;
1579
1641
  if (entry.state === "error") return true;
1580
1642
  if (entry.state !== "ok") return false;
1581
- if (Array.isArray(entry.windows) && entry.windows.length) return true;
1643
+ if (typeof entry.availability === "string") return true;
1644
+ if (Array.isArray(entry.windows) && entry.windows.length) return true;
1582
1645
  const metadata = typeof entry.account === "string" && entry.account !== "" ? ["account", "plan"] : ["plan"];
1583
1646
  return (Array.isArray(entry.extras) ? entry.extras : []).some(item => !metadata.includes(item?.kind));
1584
1647
  }
@@ -1642,7 +1705,9 @@ function registerQuotaTool(ctx) {
1642
1705
  value: { type: "string", required: true }
1643
1706
  } }
1644
1707
  },
1645
- coverage: { type: "string", description: "complete | partial" },
1708
+ coverage: { type: "string", description: "complete | partial — completeness of applicable reported quota facts, not a count of windows." },
1709
+ availability: { type: "string", description: "Optional advisory: included | paygo | blocked | unknown. Window exhaustion alone need not block paid usage; absent means no separate billing-route evidence." },
1710
+ availabilityReason: { type: "string", description: "Machine-readable basis for availability; no credentials or raw upstream objects." },
1646
1711
  freshness: { type: "string", description: "fresh | stale" },
1647
1712
  lastSuccessAt: { type: "string" },
1648
1713
  account: { type: "string", description: "Account label this entry reports (Command Code only, when the provider has several accounts)." },
package/lib/volcengine.js CHANGED
@@ -157,7 +157,7 @@ export function parseVolcAgentPlan(body) {
157
157
  const quota = amount(row.Quota), used = amount(row.Used);
158
158
  if (quota === undefined || used === undefined) throw new Error("Volcengine AFP window is invalid");
159
159
  if (quota <= 0) continue;
160
- windows.push({ kind, percent: Math.min(100, used / quota * 100), resetsAt: fromEpoch(row.ResetTime, "milliseconds"), groupLabel, detail: { used, limit: quota, unit: "AFP" } });
160
+ windows.push({ kind, percent: Math.min(100, used / quota * 100), resetsAt: fromEpoch(row.ResetTime, "milliseconds"), groupLabel, detail: { used, limit: quota, unit: "AFP", scope: kind === "month" ? "all" : kind === "day" ? "daily-capabilities" : "text" } });
161
161
  }
162
162
  const plan = typeof result.PlanType === "string" && result.PlanType.trim() ? result.PlanType.trim() : undefined;
163
163
  return { subscribed: windows.length > 0 || reported, windows, plan };
@@ -188,7 +188,7 @@ export function parseVolcSeatAfp(body) {
188
188
  const quota = amount(window.Quota), used = amount(window.Used);
189
189
  if (quota === undefined || used === undefined) throw new Error("Volcengine seat AFP window is invalid");
190
190
  if (quota <= 0) continue;
191
- windows.push({ kind, percent: Math.min(100, used / quota * 100), resetsAt: fromEpoch(window.ResetTime, "milliseconds"), groupLabel, detail: { used, limit: quota, unit: "AFP" } });
191
+ windows.push({ kind, percent: Math.min(100, used / quota * 100), resetsAt: fromEpoch(window.ResetTime, "milliseconds"), groupLabel, detail: { used, limit: quota, unit: "AFP", scope: kind === "month" ? "all" : kind === "day" ? "daily-capabilities" : "text" } });
192
192
  }
193
193
  const plan = typeof row.PlanType === "string" && row.PlanType.trim() ? row.PlanType.trim() : undefined;
194
194
  const seatId = typeof row.SeatID === "string" && row.SeatID.trim() ? row.SeatID.trim() : undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-subusage",
3
- "version": "0.11.1",
3
+ "version": "0.11.2",
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": [