dsh-tokenlens 1.6.4 → 1.6.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,28 @@
4
4
 
5
5
  **版本记录约定**:同一发布日只保留一个章节,标题取当日**最后一个**已发布版本号;当日多次发布的改动合并到该章节,并在标题下注明当日实际发布过的版本号(npm 上仍可安装这些独立版本)。若当日已有发布,后续小改动不再单独增加版本号,而是并入下一篇日志。这样日志按天规整、不出现同日多个小版本。
6
6
 
7
+ ## 1.6.6 — 2026-11-05
8
+
9
+ (当日已发布版本:v1.6.5 / v1.6.6)
10
+
11
+ - **feat(commandcode): 补上月用量百分比**——该平台付费套餐**不下发** `monthlyCreditsGranted`(额度分母),此前月窗口只能是 `unknown`。现在同时读 `GET /alpha/usage/summary`(控制台自己用的那个接口),用两个字段反推额度:`grant = credits.monthlyCredits(余额)+ totalMonthlyCredits(本期消费)`。实测 `69.1272003646 + 0.8727996354 = 70.0000000000`(多时点复核均精确 70),与控制台前端内置的 `totalCredits: 70` 一致
12
+ - feat(commandcode): 月窗口因此同时具备**已用 / 额度 / 百分比 / 重置时间**(`$0.99 / $70.00 · 1.4%`),与 5 小时、每周两个窗口口径一致
13
+ - **fix(commandcode): 不再把 `windowLimits.limited` 当作告警状态**——它是「本套餐带滚动窗口限制」的**能力标志**,同层另有 `exceeded`。原来映射成 `status: "limited"`,而客户端把任何非 `ok` 状态渲染为告警,导致用量仅 6% 时三个窗口全被涂成红色。现在只有各窗口 `exceeded === true` 才置为非 `ok`,其余按百分比自身分级
14
+ - feat(commandcode): 套餐名映射补全 `individual-goat → GOAT`、`individual-pro/v1 → Pro`、`individual-max → Max`、`individual-ultra → Ultra`(此前 GOAT 直接显示原始 id)
15
+ - **口径说明**:月额度分母**不随所选模型变化**。GOAT 宣传「Up to $100」并给出按模型额度(DeepSeek V4.1 Flash 为 $100),那是同一份额度在折价模型上的「等效可用金额」,额度池始终是 `totalCredits: 70` 这一个数。若让分母跟随所选模型,切换模型时百分比会跳动而账户并未变化,故按固定池计算
16
+ - 测试:352 → 355(月额度反推、分母不随模型变化、已上报额度优先于反推、`limited` 是能力标志而非状态、月窗口渲染与配色)
17
+
18
+ ### v1.6.5 同日内容(修复 GitHub issue #1)
19
+
20
+ - **fix(ui): 没有额度适配器的供应商不再隐藏芯片**——`unsupported-provider` 按协议会静默隐藏芯片,但芯片同时是**本机 Token 统计的唯一入口**,于是「有数据却永远看不到」:选中 `xiaomi-token-plan-cn` 时 `usage-cache.json` 里躺着 3.19 亿 Token 却无处可查(issue #1 的核心投诉)。现在这类供应商保留一个「**仅本机统计**」芯片(圆点为正常态而非告警),点开即见统计区——统计区本就不依赖快照
21
+ - **feat(providers): 新增小米 MiMo「Token Plan」额度适配器**(`xiaomi-token-plan-cn` / `-ams` / `-sgp` 三个区域)——额度接口在控制台域 `platform.xiaomimimo.com`:`/api/v1/tokenPlan/usage`(`plan_total_token` 与 `monthUsage`)、`/api/v1/tokenPlan/detail`(`planCode`、`currentPeriodEnd`)、`/api/v1/balance`(人民币余额)
22
+ - feat(providers): **该接口只认控制台 Cookie**,推理 Key(`tp-…`)一律 401 + `loginUrl`。插件复用 Open Design 那套「写一次、永不回显」的密码输入位来存 Cookie,文案明确告知「推理 Key 读不到额度接口」与「Cookie 约 24 小时过期」
23
+ - feat(providers): 会话过期映射为 `unauthorized`(不可重试)并给出可操作指引;未配置 Cookie 时**不发任何网络请求**,直接 `missing-key`
24
+ - feat(providers): 同日多条 `plan_total_token` 共享同一份额度时只计一次(避免把额度翻倍),用量按条累加;无法识别的报文降级为 `parse` 并回显**实际看到的字段名**便于排查
25
+ - feat(providers): 月度窗口优先取 `monthUsage`,`resetsAt` 取 `currentPeriodEnd`,**绝不凭空编造 5 小时/周窗口**;余额读取失败只影响余额本身,不影响额度
26
+ - 测试:336 → 352(Cookie 归一化含多行注入折叠、信封解析、共享额度不翻倍、401/`loginUrl` 双路识别、缺 Cookie 零请求、未知报文降级、控制台 Cookie 输入框渲染与写一次、无适配器芯片可见且统计区可达)
27
+ - **实测确认**(本机 2026-11-05):三个端点均真实存在(非 404),匿名请求返回文档记载的 `{"code":401,"loginUrl":…}`,适配器正确映射为可操作指引。**成功响应的字段解析无法实测**(本机无小米账号),仅按两份独立实现(OmniRoute #14834、CodexBar `docs/mimo.md`)转录
28
+
7
29
  ## 1.6.4 — 2026-11-04
8
30
 
9
31
  (当日已发布版本:v1.6.2 / v1.6.4。v1.6.1 的混合模型修复未单独发版,已并入本节)
package/PROTOCOL.md CHANGED
@@ -101,7 +101,7 @@ Provider semantics:
101
101
  | provider id | kind | windows | extra |
102
102
  |---|---|---|---|
103
103
  | `opencode-go` | `windows` | rolling/weekly/monthly percentages | — |
104
- | `commandcode` | `windows` | rolling = 5-hour `used/cap`, weekly = `used/cap`, monthly = `(granted - remaining)/granted` (only when the API reports the grant) | `plan`, `credits` (USD), `account` |
104
+ | `commandcode` | `windows` | rolling = 5-hour `used/cap`, weekly = `used/cap`, monthly = `used/granted` where `granted` is the API-reported grant, else `credits.monthlyCredits + /alpha/usage/summary.totalMonthlyCredits` (v1.6.6) | `plan`, `credits` (USD), `account` |
105
105
  | `deepseek` (picker id `deepseek-official`) | `balance` | `null` | `balance` (API-key account balance, CNY/USD) |
106
106
  | `deepseek-account` | `balance` | `null` | `balance` from the composed Platform account service (signed-in wallets) |
107
107
  | `open-design` | `windows` | rolling = 5-hour Design Plan window, weekly = 7-day window, monthly = 30-day window when reported (used / limit credits, capped at 100%) | `balance` (independent wallet balance, USD) |
@@ -109,6 +109,35 @@ Provider semantics:
109
109
  `balance` is `{ amount, currency }` plus, for the account provider,
110
110
  `wallets: [{currency, amount}]` and `bonus: [{currency, amount}]`.
111
111
 
112
+ ### Command Code's monthly percentage (v1.6.6)
113
+
114
+ `commandcode` reads four routes: `/alpha/billing/credits`, `/alpha/billing/subscriptions`,
115
+ `/alpha/whoami`, and `/alpha/usage/summary` — the last one is the aggregate the provider's
116
+ own console renders, and it is what makes the monthly percentage computable, because a paid
117
+ plan does **not** send `monthlyCreditsGranted`:
118
+
119
+ ```
120
+ granted = credits.monthlyCredits (remaining) + usage.totalMonthlyCredits (period spend)
121
+ ```
122
+
123
+ Verified live on `individual-goat`: `69.1272003646 + 0.8727996354 = 70.0000000000`, and the
124
+ console's own bundle declares `totalCredits: 70` for that plan. The only imprecision is that
125
+ `/alpha/usage/summary` lags by a fraction of a cent, so the derived grant can read ~$0.003
126
+ under the pool for a moment; it self-corrects on the next poll. A reported grant always wins
127
+ over the derivation, and when neither is available the monthly `percent` stays `null` — the
128
+ plugin never sizes a month from a plan catalog.
129
+
130
+ **The denominator is deliberately model-independent.** GOAT advertises "up to $100" and lists
131
+ per-model allowances ($100 on DeepSeek V4.1 Flash, $60 on several others) because the same
132
+ credits stretch further on a discounted model; the pool itself is one number. Letting the
133
+ picker change the denominator would move the meter while the account did not change, so
134
+ `parseCommandCode` takes no model input.
135
+
136
+ `windowLimits.limited` is a **capability flag** ("this plan paces you with rolling windows"),
137
+ not a state: the live payload carries `limited: true` together with `fiveHour.exceeded: false`.
138
+ Only a per-window `exceeded === true` produces a non-`ok` status, because the client renders
139
+ any other status as an alert (which made a 6%-used account look critical before v1.6.6).
140
+
112
141
  A `windows` snapshot may also carry a non-null `balance` (Open Design reports
113
142
  allowance windows and a wallet independently); the Client renders it as one extra
114
143
  row at the end of the capacity card and appends the amount to the chip tooltip.
@@ -439,10 +468,19 @@ with `[path]`.
439
468
 
440
469
  Client behaviour:
441
470
 
442
- - `missing-key`, `unsupported-provider`, `disabled` → hide the chip (silent).
443
- The one exception (v1.3.5): a provider whose Host payload carries a `workspaceId`
444
- field (Open Design) keeps a 「待配置 / Needs setup」 chip on `missing-key`, because
445
- hiding it would leave no way to reach the dialog's configuration guidance.
471
+ - `missing-key`, `disabled` → hide the chip (silent).
472
+ - `unsupported-provider` (v1.6.5) → keep a 「仅本机统计 / Local stats only」 chip in a
473
+ **normal** dot state. Rationale: the chip is the only entry point to the local
474
+ token statistics, and those are collected for every harness regardless of
475
+ whether an adapter exists. Hiding the chip made real data unreachable — the
476
+ exact complaint in GitHub issue #1, where `xiaomi-token-plan-cn` had 319M
477
+ tokens in `usage-cache.json` and no way to view them. The dialog's stats
478
+ sections (`buildStatsSummary` / `buildStatsDetails`) do not depend on
479
+ `snapshot`, so they render normally with `snapshot: null`.
480
+ - `missing-key` for a workspace-scoped provider (v1.3.5): a provider whose Host
481
+ payload carries a `workspaceId` field (Open Design) keeps a 「待配置 / Needs setup」
482
+ chip, because hiding it would leave no way to reach the dialog's configuration
483
+ guidance.
446
484
  - everything else → show the chip in a warning state with a retry action.
447
485
 
448
486
  ## Provider selection (client side)
@@ -515,6 +553,29 @@ again; such providers can still be overridden explicitly from the page (the over
515
553
  `VOLCENGINE_ACCESS_KEY_ID` / `VOLCENGINE_SECRET_ACCESS_KEY` (environment or credential service); an Ark
516
554
  API key cannot query plan quota. Responses (`Result.QuotaUsage[]` or `Result.AFP*`) normalize to the
517
555
  canonical 5h/weekly/monthly windows. Missing AK/SK produce a `missing-key` error, never a silent zero.
556
+ - `xiaomi-token-plan-cn` / `-ams` / `-sgp` (v1.6.5): the quota lives on Xiaomi's **console** host, not the
557
+ inference host, and it authenticates with **browser session cookies only**:
558
+
559
+ | route | returns |
560
+ | --- | --- |
561
+ | `GET https://platform.xiaomimimo.com/api/v1/tokenPlan/usage` | `data.usage.items[].plan_total_token` / `compensation_total_token` (`{used, limit, percent}`), `data.monthUsage.items[].month_total_token` |
562
+ | `GET https://platform.xiaomimimo.com/api/v1/tokenPlan/detail` | `planCode`, `currentPeriodEnd`, `expired` |
563
+ | `GET https://platform.xiaomimimo.com/api/v1/balance` | account balance (CNY) |
564
+
565
+ The inference key (`tp-…`) is rejected with `401 {"code":401,"loginUrl":"https://account.xiaomi.com/pass/serviceLogin?...&sid=api-platform..."}` —
566
+ verified live against all three routes (they exist; only the success shape is untested, since it needs a
567
+ signed-in account). The cookie (`api-platform_serviceToken` + `userId`) is entered on the management page
568
+ in the same write-only secret slot Open Design uses for its Control Key, labelled 控制台 Cookie, and lasts
569
+ about 24 hours. `normalizeCookie` accepts a bare pair list, a `Cookie:` header, or a multi-line paste
570
+ (newlines are collapsed, because a newline inside a header value is an injection hazard).
571
+
572
+ Normalization rules: `monthUsage` wins as the monthly window; `resetsAt` comes from `currentPeriodEnd`;
573
+ a `401` — whether from the HTTP status or from a `loginUrl` anywhere in the body — maps to
574
+ `unauthorized` (not retryable) with paste instructions; no cookie produces `missing-key` **without any
575
+ network read**; an unparsable payload produces `parse` naming the fields it did see. When Xiaomi lists
576
+ several `plan_total_token` rows that all carry the same non-zero `limit` (one per day/model), the limit is
577
+ counted once while `used` is summed — otherwise the allowance would be inflated. `rolling` and `weekly`
578
+ stay `null`: Xiaomi reports one monthly allowance and the plugin never invents windows.
518
579
 
519
580
  ### Automatic DSH provider sync (v0.18.0)
520
581
 
package/README.en.md CHANGED
@@ -23,7 +23,7 @@ Everything is computed locally: zero runtime dependencies, no external database,
23
23
  - **Data and chart modes** — tables, a daily trend chart and ring charts with breakdowns by harness, provider, top model and unmapped providers. In chart mode, clicking a day's bar drills into that day's 24 hourly bars; the 明细 rings switch between today / last 7 days / last X days (or last 30 days) / all time.
24
24
  - **Provider management page** — enabled / built-in / all filters, enable switches, credential status, console links, label editing, connection tests, subscription badges and plugin settings (label style, default view/scope, details expansion, default 明细 donut window, startup update check — on by default — with a "check now" action).
25
25
  - **Auto-discovery and agent tool** — providers configured in DSH Settings → Model Services are detected by route id or base-URL host and reuse their DSH credentials; `tokenlens_usage` lets the model query snapshots, local stats and diagnostics.
26
- - **Local-only** — 5 harnesses (DSH, ZCode, OpenCode CLI, WorkBuddy / CodeBuddy CLI, Qoder CN CLI), 25 built-in providers (16 with plan / balance adapters), read-only local data, no telemetry.
26
+ - **Local-only** — 5 harnesses (DSH, ZCode, OpenCode CLI, WorkBuddy / CodeBuddy CLI, Qoder CN CLI), 28 built-in providers (19 with plan / balance adapters), read-only local data, no telemetry.
27
27
 
28
28
  ## Screenshots
29
29
 
@@ -136,7 +136,7 @@ Host bridge route: `/api/tokenlens`; agent tool: `tokenlens_usage`.
136
136
  ## Limitations
137
137
 
138
138
  - Provider usage endpoints are mostly private / undocumented and may change at any time; the plugin degrades gracefully (retryable errors, hidden chips, per-row fallbacks) but availability is not guaranteed.
139
- - Plan capacity shows native server values only: no token → money conversion; Command Code monthly percentage is not guessed when no grant is reported.
139
+ - Plan capacity shows native server values only: no token → money conversion. Command Code's monthly percentage (v1.6.6) derives the grant as `credits.monthlyCredits + /alpha/usage/summary.totalMonthlyCredits` when the plan reports none; the denominator is the fixed plan pool and deliberately does **not** follow the selected model.
140
140
  - "Estimated consumable" is derived from local tokens and the plan percentage; it is only a hint, not a provider commitment.
141
141
  - The startup update check is on by default (switchable off) and compares the **latest npm release** only; commits on the GitHub repository are not detected — a git-installed plugin must be uninstalled and reinstalled to update.
142
142
  - Local stats cover connected harnesses on this machine only; uninstalled harnesses are hidden by default (`stats.showUninstalled: true` shows them). Workspace token badges are experimental.
@@ -144,7 +144,7 @@ Host bridge route: `/api/tokenlens`; agent tool: `tokenlens_usage`.
144
144
 
145
145
  ## Development
146
146
 
147
- Node.js ≥ 24, zero runtime dependencies: `node --test` runs the 280 tests.
147
+ Node.js ≥ 24, zero runtime dependencies: `node --test` runs the 355 tests.
148
148
 
149
149
  Release: `node scripts/bump-version.mjs <version> "<note>"` updates `package.json`, `PLUGIN_VERSION` and `CHANGELOG.md`; see [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md).
150
150
 
package/README.md CHANGED
@@ -45,7 +45,7 @@
45
45
  - **自动识别**:在 DSH「设置 → 模型服务」中配置过的供应商会自动出现在管理页,并按路由 id 或 baseURL 主机匹配内置用量适配器,自动继承 DSH 中的凭据,无需重复填写。
46
46
  - **Agent 工具**:注册 `tokenlens_usage`,模型可以自行查询套餐容量与本机用量。
47
47
 
48
- 支持 5 个 Harness:**DSH、ZCode、OpenCode CLI、WorkBuddy / CodeBuddy CLI、Qoder CN CLI**;内置 25 家供应商元数据,其中 16 家带套餐 / 余额适配器。
48
+ 支持 5 个 Harness:**DSH、ZCode、OpenCode CLI、WorkBuddy / CodeBuddy CLI、Qoder CN CLI**;内置 28 家供应商元数据,其中 19 家带套餐 / 余额适配器。
49
49
 
50
50
  ## 效果截图
51
51
 
@@ -308,7 +308,7 @@ Control Key 是 **Open Design 桌面端/CLI 登录时写入本机的会话凭据
308
308
  | 供应商 | 数据来源(Key 或服务) | 展示 |
309
309
  |---|---|---|
310
310
  | `opencode-go` / `opencode` | `GET https://opencode.ai/inference/go/v1/usage`(`OPENCODE_GO_API_KEY`) | 5 小时 / 本周 / 本月 已用百分比 + 重置时间 |
311
- | `commandcode` | `api.commandcode.ai/alpha/billing/{credits,subscriptions}` + `/alpha/whoami`(`COMMANDCODE_API_KEY`) | 5 小时 / 每周 `$已用 / $上限`;月度额度;方案名 |
311
+ | `commandcode` | `api.commandcode.ai/alpha/billing/{credits,subscriptions}` + `/alpha/whoami` + `/alpha/usage/summary`(`COMMANDCODE_API_KEY`) | 5 小时 / 每周 `$已用 / $上限`;月度 `$已用 / $额度 · %`(v1.6.6);方案名 |
312
312
  | `deepseek`(选择器里的 `deepseek-official`) | `GET https://api.deepseek.com/user/balance`(`DEEPSEEK_API_KEY`) | 账户余额 |
313
313
  | `deepseek-account` | DSH 组合内的 `deepseekAccount` 服务 | 钱包余额(含赠送) |
314
314
  | `openrouter` | `GET https://openrouter.ai/api/v1/credits`(管理 Key;普通 Key 可用 `/api/v1/key`) | 剩余额度(USD) |
@@ -323,12 +323,17 @@ Control Key 是 **Open Design 桌面端/CLI 登录时写入本机的会话凭据
323
323
  | `zhipu-cn` | `GET https://open.bigmodel.cn/api/monitor/usage/quota/limit`(`ZHIPU_API_KEY`) | GLM Coding Plan:5 小时 / 本周 / 每月 百分比 + 方案 |
324
324
  | `volcengine` | Coding Plan / AgentPlan:`POST https://open.volcengineapi.com/?Action=GetCodingPlanUsage&Version=2024-01-01`(Signature V4,`VOLCENGINE_ACCESS_KEY_ID` + `VOLCENGINE_SECRET_ACCESS_KEY`) | 5 小时 / 本周 / 每月 百分比 + 档位 |
325
325
  | `open-design` | 只读计费接口:`GET …/api/v1/workspaces/{workspaceId}/wallet/balance`(钱包)+ `…/billing/funding-check`(Design Plan 窗口,位于 `codingPlan` 内)+ `/api/v1/billing/summary`(套餐档位),workspace ID + Control Key:`OPENDESIGN_CONTROL_KEY` / `VELA_CONTROL_KEY`,非推理 Key | Design Plan 的 5 小时 / 7 天 已用百分比 + 重置时间;套餐档位;钱包余额(USD) |
326
+ | `xiaomi-token-plan-cn` / `-ams` / `-sgp` | 控制台额度接口:`GET https://platform.xiaomimimo.com/api/v1/tokenPlan/usage` + `/tokenPlan/detail`(周期结束时间、方案码)+ `/api/v1/balance`(人民币余额)。**只认控制台 Cookie**(`XIAOMI_MIMO_COOKIE`),推理 Key 一律 401 | Token Plan 的每月 已用百分比 + 重置时间;方案码;账户余额(CNY) |
326
327
 
327
328
  - OpenCode 的额度定义见[官方文档](https://opencode.ai/docs/go/#usage-limits)(5h = 月度额度的 20%、周 = 50%、月 = 100%);接口只返回百分比,插件也只画百分比。
328
- - Command Code 的 `/alpha/*` 是官方 CLI 自用的私有计费路由;免费 / 无方案账户返回空窗口(显示 `—`),插件不会用方案目录猜额度。
329
+ - Command Code 的 `/alpha/*` 是官方 CLI 自用的私有计费路由;免费 / 无方案账户返回空窗口(显示 `—`),插件不会用方案目录猜额度。**月度百分比**(v1.6.6)在套餐不下发额度时由 `credits.monthlyCredits(余额)+ /alpha/usage/summary 的 totalMonthlyCredits(本期消费)` 反推额度后计算,实测与该平台控制台一致;分母是**固定的额度池,不随所选模型变化**——GOAT 宣传的「Up to $100」与按模型额度(DeepSeek V4.1 Flash $100)只是同一份额度在折价模型上的等效值,若让分母跟随模型,切换模型时百分比会跳动而账户未变。
330
+ - **「有限额」不等于「已超限」**(v1.6.6)。Command Code 的 `windowLimits.limited` 是「本套餐带滚动窗口」的能力标志,真正的状态在同层 `exceeded`;插件此前把它当状态输出,会让用量仅 6% 的三个窗口全被涂成告警色,现已只有 `exceeded === true` 才告警。
329
331
  - Open Design 的窗口按账户实际返回的时长渲染:Go 同时有 5 小时和 7 天窗口,Plus / Pro / Max 只有 7 天窗口;钱包余额是独立于窗口的 USD 额度,用尽后继续扣钱包。查询要求工作区权限,鉴权失败会明确显示错误而不是 0。**只需填一个 Control Key**:工作区 ID 是可选项(v1.5.3),插件会用 Key 调 `/api/v1/workspaces/current` 自动识别你的工作区;只有账号有多个工作区、需要指定其中一个时才在管理页填「工作区 ID」。真正缺 Key 时弹窗会指出并给出「去配置」入口。窗口、余额、套餐档位是**三层独立读取**(v1.4.0):任一接口不可用时只少那一块数据,不会整套报错。
330
332
  - **chip 的「月 N%」与「本期 Design Plan 累计」是插件本机算的估算值**(v1.5.0 / v1.6.0)。Open Design 有月度额度(Go 为每月 $120 = 4 × 每周 $30),但**没有「本账期已用」接口**——控制台的 `/billing/usage`、`/billing/coding-plan-usage` 都靠浏览器 Cookie 鉴权,Control Key 一律 401。于是插件累加它观测到的 **7 天窗口**(只记 7 天,因为 5 小时窗口测量的是同一笔消耗,相加会重复计数)。
331
- - **金额按官方定价表的模型单价计算**(v1.6.0)。原来用 `usedCredits ÷ creditsPerUsd`,隐含「按 1× 倍率消耗」,而官方倍率跨度 0.25×–4×,对 DeepSeek V4.1 Flash(0.5×)会少算一半、对 GPT-6 Luna(4×)会多算 4 倍。现在按每个模型的实际 Token × 官方单价(输入/输出/缓存读分别计价)累加,弹窗显示「已用 / 月额度 + 百分比」(如 `$27.31 / $120.00 22.8%`)并注明「按 0.5× 倍率模型计价」——**只用 0.5× 模型可用满完整 $120**,1× 只能到 $60,2× 到 $30,4× 到 $15。弹窗会标注覆盖情况(「已覆盖本账期全部窗口」或「仅统计已观测的 N 个窗口」);覆盖不完整时百分比仍显示但**不会触发告警配色**,插件没运行期间错过的窗口无法计入。
333
+ - **金额按服务端 credits 反推,只统计 Design Plan 额度消耗**(v1.6.4)。钱包扣费不消耗 credits,只扣 `balanceUsd`,所以「本期 Design Plan 累计」= `usedCredits ÷ creditsPerUsd ÷ 倍率`——实测 `150,005 ÷ 10,000 ÷ 0.5 = $30.00`,与「周额度 $30 × 已用 100%」交叉验证一致。含钱包付费的本机估算另存为 `localPricedUsd`,高于前者时弹窗追加「另有 $X 由账户余额支付,未计入」,两个口径都清楚可查。
334
+ - **月额度的分母随倍率变化**(v1.6.0 / v1.6.2)。官方定价表把月额度写成**列表价美元**且按倍率消耗:只用 0.5× 模型(如 DeepSeek V4.1 Flash)可动用**完整 $120**,1× 用 $60,2× 用 $30,4× 用 $15。混合用量按实际消耗的 credits 加权取等效倍率(`$60 ÷ m_eff`),而不是简单取最高倍率——否则偶尔用一次 4× 模型就会把额度误报成 $15。弹窗注明「按 X× 倍率模型计价」以及覆盖情况(「已覆盖本账期全部窗口」或「仅统计已观测的 N 个窗口」);覆盖不完整时百分比仍显示但**不会触发告警配色**。
335
+ - **没有额度适配器的供应商也会保留芯片**(v1.6.5)。`unsupported-provider` 原本静默隐藏芯片,但芯片是本机 Token 统计的唯一入口,于是「有数据却看不到」(如小米 MiMo 账户里有 3 亿 Token 却无处可查)。现在这类供应商显示「**仅本机统计**」芯片,点开即见统计区;本机统计对所有 Harness 都会采集,与该供应商有没有额度接口无关。
336
+ - **小米 MiMo 的额度接口只认控制台 Cookie**(v1.6.5)。推理 Key(`tp-…`)读不到额度接口,插件复用「写一次、永不回显」的密码输入位保存 Cookie,并明确提示约 24 小时过期。会话过期会显示可操作的中文指引,而不是报错码;未配置 Cookie 时不发任何网络请求。
332
337
  - 未配置凭据的供应商 chip 静默隐藏;`zai`、`minimax`、`deepinfra` 属 best-effort 适配(未公开接口,失败时按可重试错误优雅降级)。
333
338
  - 各供应商的元数据(`kind` / `label` / `shortLabel` / `baseURL` / `apiKeyRef` / `consoleUrl`)集中在 `lib/providers.mjs` 的 `PROVIDER_DEFAULTS` 与 `ADAPTERS` 中,新增适配器只需在该文件登记并补测试。
334
339
 
@@ -426,7 +431,7 @@ Host 半注册 `tokenlens_usage`,参数 `action`(默认 `snapshot`)与可
426
431
  ## 已知限制
427
432
 
428
433
  - 各供应商的用量接口多为**未公开的私有接口**,可能随时变更;插件对此做了优雅降级(可重试错误、静默隐藏、行级降级),但不保证长期可用。
429
- - 套餐容量只显示服务端原生数字:不做 token → 金额换算;Command Code 未报告 grant 时不计算月度百分比。
434
+ - 套餐容量只显示服务端原生数字:不做 token → 金额换算;Command Code 的月度百分比由「余额 + 本期消费」反推额度后计算(v1.6.6),两者都拿不到时才留空。
430
435
  - 「预计可消耗」是基于本机 Token 与套餐百分比的**估算**,仅供参考,不代表供应商承诺的额度。
431
436
  - 「启动时检查更新」默认开启(可在插件设置关闭),只对比 **npm 上的最新发布版本**;GitHub 仓库的提交更新不会被感知,需要卸载重装 git 安装的版本才会更新。
432
437
  - 本机 Token 统计只覆盖**已接入 harness**(当前 DSH / ZCode / OpenCode CLI / WorkBuddy / Qoder CN),只统计本机的本地记录。
@@ -440,7 +445,7 @@ Host 半注册 `tokenlens_usage`,参数 `action`(默认 `snapshot`)与可
440
445
 
441
446
  ## 验证
442
447
 
443
- - `node --test`:**332 个自动化用例**,覆盖 Host 数据层、供应商适配器、统计折叠、各 harness 读取、Design Plan 周期账本与官方单价计价、窗口锚点切片、工作区自动识别、插件集成与浏览器端渲染 / 交互。
448
+ - `node --test`:**355 个自动化用例**,覆盖 Host 数据层、供应商适配器(含小米 MiMo 控制台 Cookie 适配器、Command Code 月度额度反推)、统计折叠、各 harness 读取、Design Plan 周期账本与额度计价、窗口锚点切片、工作区自动识别、插件集成与浏览器端渲染 / 交互。
444
449
  - 在真实环境中验证过 **5 个 harness** 的数据源;本机统计与独立的 SQLite 查询、独立的日志解析逐项对账(差额为 0),跨 harness 合并与供应商映射算术自洽。
445
450
  - 供应商接口按各官方文档与参考客户端行为验证;未配置凭据时 chip 静默隐藏,接口异常时按可重试错误降级。
446
451
 
package/client.js CHANGED
@@ -158,6 +158,7 @@ window.__ModuleLoader__.load({
158
158
  chipHint: '当前模型供应商的套餐用量,点击查看详情',
159
159
  chipUnavailable: '用量读取失败',
160
160
  chipNeedsConfig: '待配置',
161
+ chipStatsOnly: '仅本机统计',
161
162
  chipNoUsage: '暂无用量',
162
163
  chipRolling: '5时',
163
164
  chipWeekly: '周',
@@ -337,6 +338,11 @@ window.__ModuleLoader__.load({
337
338
  providerControlKeyHint: '本机 Open Design 登录后写在 ~/.amr/config.json 的 profiles.prod.controlKey;不是 Cloud API Keys 页的推理 Key',
338
339
  providerControlKeyPlaceholder: '粘贴 Control Key(保存后不再显示)',
339
340
  providerControlKeySaved: '已保存;要替换请重新粘贴',
341
+ providerConsoleCookie: '控制台 Cookie',
342
+ providerConsoleCookieHint:
343
+ '登录 platform.xiaomimimo.com 后,从浏览器开发者工具复制完整的 Cookie 请求头(含 api-platform_serviceToken 与 userId)。推理 Key 读不到额度接口;Cookie 约 24 小时后过期,过期后重新粘贴即可',
344
+ providerConsoleCookiePlaceholder: '粘贴控制台 Cookie(保存后不再显示)',
345
+ providerConsoleCookieSaved: '已保存;过期后重新粘贴以刷新',
340
346
  configureProvider: '去配置',
341
347
  errorOpenDesignConfig: 'Open Design 额度查询需要工作区 ID 与 Control Key(不是推理 Key)',
342
348
  designPlanLabel: '本期 Design Plan 累计',
@@ -421,6 +427,7 @@ window.__ModuleLoader__.load({
421
427
  chipHint: 'Plan usage for the selected provider — click for details',
422
428
  chipUnavailable: 'Usage unavailable',
423
429
  chipNeedsConfig: 'Needs setup',
430
+ chipStatsOnly: 'Local stats only',
424
431
  chipNoUsage: 'No usage',
425
432
  chipRolling: '5h',
426
433
  chipWeekly: 'wk',
@@ -601,6 +608,11 @@ window.__ModuleLoader__.load({
601
608
  providerControlKeyHint: 'Written to ~/.amr/config.json as profiles.prod.controlKey after signing in to Open Design; not the inference key from the Cloud API Keys page',
602
609
  providerControlKeyPlaceholder: 'Paste the Control Key (never shown again after saving)',
603
610
  providerControlKeySaved: 'Saved; paste again to replace it',
611
+ providerConsoleCookie: 'Console cookie',
612
+ providerConsoleCookieHint:
613
+ 'Sign in at platform.xiaomimimo.com, then copy the full Cookie request header from your browser devtools (it carries api-platform_serviceToken and userId). The inference key cannot read the quota API, and the cookie expires after about 24 hours — just paste a fresh one when it does',
614
+ providerConsoleCookiePlaceholder: 'Paste the console cookie (never shown again after saving)',
615
+ providerConsoleCookieSaved: 'Saved; paste a fresh one when it expires',
604
616
  configureProvider: 'Configure',
605
617
  errorOpenDesignConfig: 'Open Design quota needs a workspace ID and a Control Key (not an inference key)',
606
618
  designPlanLabel: 'Period Design Plan',
@@ -1207,6 +1219,9 @@ window.__ModuleLoader__.load({
1207
1219
  // mere presence is the capability flag that renders the input.
1208
1220
  workspaceId,
1209
1221
  supportsWorkspaceId: Object.prototype.hasOwnProperty.call(raw, 'workspaceId'),
1222
+ // v1.6.5: cookie-authenticated consoles (Xiaomi MiMo) reuse the same
1223
+ // write-only secret slot, labelled as a cookie instead of a key.
1224
+ supportsConsoleCookie: raw.supportsConsoleCookie === true,
1210
1225
  }
1211
1226
  }
1212
1227
 
@@ -3009,10 +3024,14 @@ window.__ModuleLoader__.load({
3009
3024
  if (label !== provider.label) update.label = label
3010
3025
  // v1.3.5: the workspace id of a workspace-scoped adapter; an empty string
3011
3026
  // clears the override so the Host falls back to its env var.
3012
- if (provider.supportsWorkspaceId === true) {
3013
- const wireWorkspaceId = typeof provider.workspaceId === 'string' ? provider.workspaceId : ''
3014
- const workspaceId = typeof source.workspaceId === 'string' ? source.workspaceId.trim() : wireWorkspaceId
3015
- if (workspaceId !== wireWorkspaceId) update.workspaceId = workspaceId
3027
+ // v1.6.5: a console-cookie adapter (Xiaomi MiMo) has no workspace id but
3028
+ // uses the same write-only secret slot, so it must send `apiKey` too.
3029
+ if (provider.supportsWorkspaceId === true || provider.supportsConsoleCookie === true) {
3030
+ if (provider.supportsWorkspaceId === true) {
3031
+ const wireWorkspaceId = typeof provider.workspaceId === 'string' ? provider.workspaceId : ''
3032
+ const workspaceId = typeof source.workspaceId === 'string' ? source.workspaceId.trim() : wireWorkspaceId
3033
+ if (workspaceId !== wireWorkspaceId) update.workspaceId = workspaceId
3034
+ }
3016
3035
  // v1.3.9: the Control Key is write-only. A typed value is sent once; an
3017
3036
  // empty draft means "leave the stored secret alone", never "clear it".
3018
3037
  const apiKey = typeof source.apiKey === 'string' ? source.apiKey.trim() : ''
@@ -3068,7 +3087,15 @@ window.__ModuleLoader__.load({
3068
3087
  if (field !== 'consoleUrl' && field !== 'shortLabel' && field !== 'label' && field !== 'workspaceId' && field !== 'apiKey') {
3069
3088
  return
3070
3089
  }
3071
- if ((field === 'workspaceId' || field === 'apiKey') && provider.supportsWorkspaceId !== true) return
3090
+ // v1.6.5: a console-cookie adapter (Xiaomi MiMo) writes the same write-only
3091
+ // secret slot, so it must be allowed to update `apiKey` as well.
3092
+ if (
3093
+ (field === 'workspaceId' || field === 'apiKey') &&
3094
+ provider.supportsWorkspaceId !== true &&
3095
+ provider.supportsConsoleCookie !== true
3096
+ ) {
3097
+ return
3098
+ }
3072
3099
  if (provider === null || provider === undefined) return
3073
3100
  const current = store.get().management
3074
3101
  const draft = providerDraftOf(provider, current)
@@ -3735,9 +3762,29 @@ window.__ModuleLoader__.load({
3735
3762
  return canonicalProviderId(rawId) === 'open-design'
3736
3763
  }
3737
3764
 
3765
+ /**
3766
+ * Whether this state is a provider the plugin has NO usage adapter for
3767
+ * (v1.6.5).
3768
+ *
3769
+ * `unsupported-provider` hides the chip by protocol, but the chip is also the
3770
+ * ONLY entry point to the local-token statistics — and those are collected
3771
+ * for every harness, adapter or not. Hiding it therefore made real data
3772
+ * unreachable: selecting e.g. `xiaomi-token-plan-cn` showed nothing at all
3773
+ * even though `usage-cache.json` held 319M tokens for it (GitHub issue #1).
3774
+ * Such a provider keeps a chip that says 仅本机统计 and opens the dialog,
3775
+ * whose stats sections do not depend on a snapshot.
3776
+ */
3777
+ function needsStatsEntry(state) {
3778
+ if (normalizeSnapshot(state.snapshot) !== null) return false
3779
+ return (
3780
+ state.error !== null && typeof state.error === 'object' && state.error.code === 'unsupported-provider'
3781
+ )
3782
+ }
3783
+
3738
3784
  /** Whether the chip must stay silent for this state. */
3739
3785
  function chipHidden(state) {
3740
3786
  if (!isHidden(state.error)) return false
3787
+ if (needsStatsEntry(state) === true) return false
3741
3788
  return needsWorkspaceConfigEntry(state) !== true
3742
3789
  }
3743
3790
 
@@ -3846,7 +3893,11 @@ window.__ModuleLoader__.load({
3846
3893
  // A workspace-scoped provider missing its configuration reads as 「待配置」
3847
3894
  // instead of a read failure: nothing upstream failed, setup is incomplete.
3848
3895
  const needsConfig = needsWorkspaceConfigEntry(state)
3849
- const label = needsConfig ? t('chipNeedsConfig') : t('chipUnavailable')
3896
+ // v1.6.5: an adapter-less provider is not a failure either — the plugin
3897
+ // simply has no quota endpoint for it, while its local token statistics are
3898
+ // real. Say 「仅本机统计」 so the chip reads as an entry point, not an error.
3899
+ const statsOnly = needsStatsEntry(state)
3900
+ const label = needsConfig ? t('chipNeedsConfig') : statsOnly ? t('chipStatsOnly') : t('chipUnavailable')
3850
3901
  return h(
3851
3902
  'span',
3852
3903
  { className: 'dsh-tokenlens-anchor', 'data-composer-stat': 'tokenlens' },
@@ -3862,9 +3913,10 @@ window.__ModuleLoader__.load({
3862
3913
  'aria-label': label,
3863
3914
  'aria-haspopup': 'dialog',
3864
3915
  ...(needsConfig ? { 'data-needs-config': 'true' } : {}),
3916
+ ...(statsOnly ? { 'data-stats-only': 'true' } : {}),
3865
3917
  onClick: () => openDialog(sessionId),
3866
3918
  },
3867
- h('span', { className: 'dsh-tokenlens-dot', 'data-level': 'warning', 'aria-hidden': true }),
3919
+ h('span', { className: 'dsh-tokenlens-dot', 'data-level': statsOnly ? 'normal' : 'warning', 'aria-hidden': true }),
3868
3920
  h('span', { className: 'dsh-tokenlens-brand' }, brand),
3869
3921
  h('span', { className: 'dsh-tokenlens-metric' }, label),
3870
3922
  ),
@@ -6381,7 +6433,21 @@ window.__ModuleLoader__.load({
6381
6433
  label: t('providerControlKey'),
6382
6434
  hint: t('providerControlKeyHint'),
6383
6435
  })
6384
- : null,
6436
+ : // v1.6.5: Xiaomi MiMo's quota console is cookie-authenticated, so it gets
6437
+ // the same write-only slot with a cookie-specific label and hint (the
6438
+ // inference key is explicitly NOT usable, and the cookie expires daily).
6439
+ provider.supportsConsoleCookie === true
6440
+ ? buildProviderSecretField(provider, management, t, {
6441
+ key: 'apiKey',
6442
+ className: 'dsh-tokenlens-controlkey-input dsh-tokenlens-consolecookie-input',
6443
+ dataAttr: 'data-provider-consolecookie',
6444
+ field: 'apiKey',
6445
+ placeholder:
6446
+ provider.hasKey === true ? t('providerConsoleCookieSaved') : t('providerConsoleCookiePlaceholder'),
6447
+ label: t('providerConsoleCookie'),
6448
+ hint: t('providerConsoleCookieHint'),
6449
+ })
6450
+ : null,
6385
6451
  buildProviderNameField(provider, management, t, {
6386
6452
  key: 'shortLabel',
6387
6453
  className: 'dsh-tokenlens-shortlabel-input',
package/index.js CHANGED
@@ -84,7 +84,7 @@ export const inject = []
84
84
  export const PLUGIN_NAME = 'dsh-tokenlens'
85
85
 
86
86
  /** Plugin version; mirrors package.json (kept here so diagnostics need no fs read). */
87
- export const PLUGIN_VERSION = '1.6.4'
87
+ export const PLUGIN_VERSION = '1.6.6'
88
88
 
89
89
  /** Documented defaults; every field of the raw row config is optional. */
90
90
  const DEFAULTS = {
@@ -788,6 +788,10 @@ export function apply(ctx, rawConfig) {
788
788
  // v1.3.5: only adapters whose defaults declare a workspace (Open Design)
789
789
  // advertise the field, so the management page renders it exactly there.
790
790
  const supportsWorkspaceId = Object.prototype.hasOwnProperty.call(PROVIDER_DEFAULTS[id], 'workspaceId')
791
+ // v1.6.5: cookie-authenticated consoles (Xiaomi MiMo) advertise the same
792
+ // write-only secret field, but label it as a cookie so the hint can explain
793
+ // why a key will not work and that it expires daily.
794
+ const supportsConsoleCookie = PROVIDER_DEFAULTS[id].supportsConsoleCookie === true
791
795
  providers.push({
792
796
  providerId: id,
793
797
  label: provider.label,
@@ -802,6 +806,7 @@ export function apply(ctx, rawConfig) {
802
806
  ...(supportsWorkspaceId
803
807
  ? { workspaceId: typeof provider.workspaceId === 'string' ? provider.workspaceId : '' }
804
808
  : {}),
809
+ ...(supportsConsoleCookie ? { supportsConsoleCookie: true } : {}),
805
810
  builtin: true,
806
811
  // Configured through DSH model settings: the management page should
807
812
  // inherit the key/base URL instead of asking for them again.
@@ -1196,6 +1201,39 @@ export function apply(ctx, rawConfig) {
1196
1201
  // An empty workspaceId is fine: the adapter resolves it from the key.
1197
1202
  provider = { ...provider, workspaceId, controlKey }
1198
1203
  keyInfo = { ref: controlRef, source: controlSource, detail: controlDetail }
1204
+ } else if (PROVIDER_DEFAULTS[id]?.supportsConsoleCookie === true) {
1205
+ // v1.6.5: Xiaomi MiMo's quota console takes a browser session cookie, not a
1206
+ // key (the inference key answers 401 with a `loginUrl`). Resolution mirrors
1207
+ // the Open Design Control Key: the management page's write-only secret slot
1208
+ // first, then the named ref, then the env var.
1209
+ const refs = []
1210
+ for (const ref of [provider.consoleCookieRef, 'XIAOMI_MIMO_COOKIE']) {
1211
+ if (typeof ref === 'string' && ref.trim() !== '' && !refs.includes(ref.trim())) refs.push(ref.trim())
1212
+ }
1213
+ let cookie = typeof provider.apiKey === 'string' ? provider.apiKey.trim() : ''
1214
+ let cookieRef = refs[0] ?? 'XIAOMI_MIMO_COOKIE'
1215
+ let cookieSource = cookie === '' ? 'none' : 'config'
1216
+ let cookieDetail = cookie === '' ? 'no Xiaomi MiMo console cookie configured' : 'plugin config'
1217
+ if (cookie === '') {
1218
+ for (const ref of refs) {
1219
+ const resolved = await resolveApiKey({
1220
+ config: { apiKeyRef: ref },
1221
+ credentials: ctx.get('credentials'),
1222
+ env: process.env,
1223
+ })
1224
+ const value = typeof resolved.value === 'string' ? resolved.value.trim() : ''
1225
+ if (value === '') continue
1226
+ cookie = value
1227
+ cookieRef = resolved.ref ?? ref
1228
+ cookieSource = resolved.source
1229
+ cookieDetail = resolved.detail
1230
+ break
1231
+ }
1232
+ }
1233
+ // An empty cookie is passed through: the adapter raises the actionable
1234
+ // `missing-key` message that names the console and the 24h lifetime.
1235
+ provider = { ...provider, consoleCookie: cookie }
1236
+ keyInfo = { ref: cookieRef, source: cookieSource, detail: cookieDetail }
1199
1237
  } else if (typeof provider.apiKeyRef === 'string' && provider.apiKeyRef.trim() !== '') {
1200
1238
  const key = await resolveApiKey({
1201
1239
  config: { apiKeyRef: provider.apiKeyRef, apiKey: provider.apiKey ?? '' },
package/lib/providers.mjs CHANGED
@@ -19,6 +19,16 @@
19
19
  */
20
20
  import { createHash, createHmac } from 'node:crypto'
21
21
  import { normalizePercent, parseTimestamp, UsageError } from './go-usage.mjs'
22
+ import {
23
+ XIAOMI_CONSOLE_URL,
24
+ XIAOMI_QUOTA_BASE_URL,
25
+ XIAOMI_QUOTA_PATHS,
26
+ isLoginRequired,
27
+ normalizeCookie,
28
+ parseAccountBalance,
29
+ parseTokenPlanDetail,
30
+ parseTokenPlanUsage,
31
+ } from './xiaomi-token-plan.mjs'
22
32
 
23
33
  /** Canonical window ids, in display order. */
24
34
  export const WINDOW_IDS = ['rolling', 'weekly', 'monthly']
@@ -75,6 +85,10 @@ export const PROVIDER_DEFAULTS = {
75
85
  creditsPath: '/alpha/billing/credits',
76
86
  subscriptionsPath: '/alpha/billing/subscriptions',
77
87
  whoamiPath: '/alpha/whoami',
88
+ // v1.6.6: the console's own usage aggregate. Carries `totalMonthlyCredits`,
89
+ // the period spend the monthly percentage needs (the credits route has no
90
+ // grant field on a paid plan).
91
+ usageSummaryPath: '/alpha/usage/summary',
78
92
  apiKeyRef: 'COMMANDCODE_API_KEY',
79
93
  consoleUrl: 'https://commandcode.ai/',
80
94
  },
@@ -255,6 +269,44 @@ export const PROVIDER_DEFAULTS = {
255
269
  apiKeyRef: 'OPENDESIGN_CONTROL_KEY',
256
270
  consoleUrl: 'https://open-design.ai/cloud/dashboard',
257
271
  },
272
+ // v1.6.5: Xiaomi MiMo's "Token Plan". Three regional inference endpoints share
273
+ // one quota console, and that console (like Open Design's billing routes) only
274
+ // accepts BROWSER SESSION COOKIES — the inference key answers 401 with a
275
+ // `loginUrl`. The cookie therefore lives in the per-provider secret slot, the
276
+ // same place Open Design keeps its Control Key.
277
+ 'xiaomi-token-plan-cn': {
278
+ kind: 'windows',
279
+ label: 'Xiaomi MiMo',
280
+ shortLabel: 'MiMo',
281
+ baseURL: 'https://token-plan-cn.xiaomimimo.com',
282
+ quotaBaseURL: XIAOMI_QUOTA_BASE_URL,
283
+ consoleCookieRef: 'XIAOMI_MIMO_COOKIE',
284
+ apiKeyRef: 'XIAOMI_MIMO_COOKIE',
285
+ supportsConsoleCookie: true,
286
+ consoleUrl: XIAOMI_CONSOLE_URL,
287
+ },
288
+ 'xiaomi-token-plan-ams': {
289
+ kind: 'windows',
290
+ label: 'Xiaomi MiMo (AMS)',
291
+ shortLabel: 'MiMo-A',
292
+ baseURL: 'https://token-plan-ams.xiaomimimo.com',
293
+ quotaBaseURL: XIAOMI_QUOTA_BASE_URL,
294
+ consoleCookieRef: 'XIAOMI_MIMO_COOKIE',
295
+ apiKeyRef: 'XIAOMI_MIMO_COOKIE',
296
+ supportsConsoleCookie: true,
297
+ consoleUrl: XIAOMI_CONSOLE_URL,
298
+ },
299
+ 'xiaomi-token-plan-sgp': {
300
+ kind: 'windows',
301
+ label: 'Xiaomi MiMo (SGP)',
302
+ shortLabel: 'MiMo-S',
303
+ baseURL: 'https://token-plan-sgp.xiaomimimo.com',
304
+ quotaBaseURL: XIAOMI_QUOTA_BASE_URL,
305
+ consoleCookieRef: 'XIAOMI_MIMO_COOKIE',
306
+ apiKeyRef: 'XIAOMI_MIMO_COOKIE',
307
+ supportsConsoleCookie: true,
308
+ consoleUrl: XIAOMI_CONSOLE_URL,
309
+ },
258
310
  hunyuan: {
259
311
  kind: 'balance',
260
312
  label: '腾讯混元',
@@ -323,6 +375,11 @@ export function canonicalProviderId(providerId) {
323
375
  const COMMANDCODE_PLAN_LABELS = {
324
376
  'individual-go-v1': 'Go',
325
377
  'individual-go': 'Go',
378
+ 'individual-goat': 'GOAT',
379
+ 'individual-pro': 'Pro',
380
+ 'individual-pro-v1': 'Pro',
381
+ 'individual-max': 'Max',
382
+ 'individual-ultra': 'Ultra',
326
383
  }
327
384
 
328
385
  /** A friendly plan label for a Command Code plan id (raw id when unknown). */
@@ -419,31 +476,42 @@ export function parseOpencodeGo(payload, now = Date.now()) {
419
476
  * windowLimits: { limited, fiveHour: {cap,used,resetAt}|null, weekly: … } }`
420
477
  * `subscription`: `{ success, data: { planId, status, currentPeriodEnd }|null }`
421
478
  * `whoami`: `{ success, user: { name, userName } }`
479
+ * `usageSummary` (v1.6.6): `GET /alpha/usage/summary` —
480
+ * `{ totalMonthlyCredits, totalCredits, periodBasis, periodStart, … }`. Its
481
+ * `totalMonthlyCredits` is what makes the MONTHLY percentage computable.
422
482
  *
423
483
  * Semantics verified against the live API and the CodexBar reference client:
424
- * `monthlyCredits` is the REMAINING monthly credit (USD), `granted` is the
425
- * plan's monthly grant; the windows' `used`/`cap` are USD, `resetAt` is epoch
426
- * seconds or milliseconds.
484
+ * `monthlyCredits` is the REMAINING monthly credit (USD), `monthlyCreditsGranted`
485
+ * is the plan's monthly grant when present (a paid GOAT plan omits it, which is
486
+ * why `usageSummary` is consulted); the windows' `used`/`cap` are USD, `resetAt`
487
+ * is epoch seconds or milliseconds.
427
488
  */
428
- export function parseCommandCode({ credits, subscription, whoami } = {}, now = Date.now()) {
489
+ export function parseCommandCode({ credits, subscription, whoami, usageSummary } = {}, now = Date.now()) {
429
490
  const document = credits !== null && typeof credits === 'object' ? credits : {}
430
491
  const credit = document.credits !== null && typeof document.credits === 'object' ? document.credits : {}
431
492
  const limits = document.windowLimits !== null && typeof document.windowLimits === 'object'
432
493
  ? document.windowLimits
433
494
  : {}
434
- const limited = limits.limited === true || limits.exceeded === true
435
- const windowStatus = limited ? 'limited' : 'ok'
495
+ // v1.6.6: `windowLimits.limited` is a CAPABILITY flag ("this plan paces you
496
+ // with rolling windows"), not a state — the live GOAT payload carries
497
+ // `limited: true` while every `fiveHour.exceeded` / `weekly.exceeded` is false.
498
+ // Mapping it onto `status` painted every window as a warning (PROTOCOL: any
499
+ // status that is not "ok" renders as an alert), so a 6%-used account looked
500
+ // alarming. The real state lives in the per-window `exceeded` flags.
501
+ const planExceeded = limits.exceeded === true
436
502
  const windowFrom = (raw) => {
437
503
  if (raw === null || typeof raw !== 'object') return null
504
+ const exceeded = raw.exceeded === true
438
505
  return {
439
506
  percent: usedPercent(raw.used, raw.cap),
440
- status: windowStatus,
507
+ status: exceeded ? 'limited' : 'ok',
441
508
  resetsAt: raw.resetAt,
442
509
  used: toNumber(raw.used),
443
510
  cap: toNumber(raw.cap),
444
511
  unit: 'USD',
445
512
  }
446
513
  }
514
+ const anyExceeded = () => planExceeded
447
515
  const windows = windowSet(
448
516
  { rolling: windowFrom(limits.fiveHour), weekly: windowFrom(limits.weekly) },
449
517
  now,
@@ -456,21 +524,52 @@ export function parseCommandCode({ credits, subscription, whoami } = {}, now = D
456
524
  const plan = planId === null ? null : { id: planId, label: commandcodePlanLabel(planId) }
457
525
 
458
526
  const remaining = toNumber(credit.monthlyCredits)
459
- // Only the API-reported grant sizes the monthly percentage: this plugin never
460
- // guesses a grant from a plan catalog (the live free-tier response omits it).
461
- const granted = toNumber(credit.monthlyCreditsGranted)
462
527
  const purchased = toNumber(credit.purchasedCredits)
528
+ // v1.6.6: the monthly percentage.
529
+ //
530
+ // `/alpha/billing/credits` does NOT send a grant (`monthlyCreditsGranted` is
531
+ // simply absent on a paid plan), but two API fields together pin it down:
532
+ // `credits.monthlyCredits` is the pool REMAINING and
533
+ // `/alpha/usage/summary`'s `totalMonthlyCredits` is the period's SPEND:
534
+ //
535
+ // grant = remaining + periodSpend
536
+ //
537
+ // Verified live on `individual-goat` at several moments:
538
+ // 69.1272003646 + 0.8727996354 = 70.0000000000
539
+ // 69.1568775436 + 0.8431224564 = 70.0000000000
540
+ // 69.085104928 + 0.9119659668 = 69.99707 (summary lags a fraction)
541
+ // and the console's own plan catalog declares `totalCredits: 70` for GOAT, so
542
+ // the derivation is not a guess.
543
+ //
544
+ // The denominator is deliberately MODEL-INDEPENDENT. GOAT advertises "up to
545
+ // $100 in credits" and shows per-model allowances ($100 on DeepSeek V4.1 Flash,
546
+ // $60 on others) because the same credits stretch further on a discounted
547
+ // model — but the pool itself stays one number whatever model is selected
548
+ // (hence "up to"). Letting the picker change the denominator would make the
549
+ // meter jump while the account did not move, so it never does that here.
550
+ const summary = usageSummary !== null && typeof usageSummary === 'object' ? usageSummary : {}
551
+ const periodSpend = toNumber(summary.totalMonthlyCredits) ?? toNumber(summary.totalCredits)
552
+ const reportedGrant = toNumber(credit.monthlyCreditsGranted)
553
+ const granted =
554
+ reportedGrant !== null && reportedGrant > 0
555
+ ? reportedGrant
556
+ : remaining !== null && periodSpend !== null
557
+ ? remaining + periodSpend
558
+ : null
559
+ const monthlyUsed = granted !== null && remaining !== null ? Math.max(0, granted - remaining) : periodSpend
463
560
  const monthlyPercent =
464
- remaining !== null && granted !== null && granted > 0
465
- ? normalizePercent(((granted - remaining) / granted) * 100)
561
+ granted !== null && granted > 0 && monthlyUsed !== null
562
+ ? normalizePercent((monthlyUsed / granted) * 100)
466
563
  : null
467
564
  windows.monthly = makeWindow(
468
565
  'monthly',
469
566
  {
470
567
  percent: monthlyPercent,
471
- status: monthlyPercent === null ? 'unknown' : windowStatus,
568
+ // Same rule as the rolling windows: only a real exhaustion is a status, and
569
+ // the percentage itself already drives the warning/critical colour.
570
+ status: monthlyPercent === null ? 'unknown' : anyExceeded() ? 'limited' : 'ok',
472
571
  resetsAt: data?.currentPeriodEnd ?? null,
473
- used: remaining !== null && granted !== null && granted > 0 ? Math.max(0, granted - remaining) : null,
572
+ used: monthlyUsed,
474
573
  cap: granted,
475
574
  unit: 'USD',
476
575
  },
@@ -1000,12 +1099,126 @@ export function parseOpenDesignSummary(payload) {
1000
1099
  }
1001
1100
  }
1002
1101
 
1102
+ /**
1103
+ * The Xiaomi MiMo "Token Plan" adapter (v1.6.5).
1104
+ *
1105
+ * Reads the plan's real usage from Xiaomi's console host, which authenticates
1106
+ * with browser session cookies only — the inference key answers 401 with a
1107
+ * `loginUrl`, so a "key" cannot be used here at all. The cookie is held in the
1108
+ * same per-provider secret slot Open Design uses for its Control Key.
1109
+ *
1110
+ * Three regions (`cn` / `ams` / `sgp`) share this one console, so they share the
1111
+ * adapter and differ only in their inference `baseURL`.
1112
+ */
1113
+ const xiaomiTokenPlanAdapter = {
1114
+ kind: 'windows',
1115
+ async read({ providerId = 'xiaomi-token-plan-cn', provider, httpJson, now }) {
1116
+ const config = providerConfig(providerId, provider)
1117
+ const rawCookie = provider?.consoleCookie
1118
+ const cookie = normalizeCookie(rawCookie)
1119
+ if (cookie === '') {
1120
+ // A `missing-key` hides the chip by protocol, but the client keeps a
1121
+ // 「仅本机统计」 entry for adapter-less providers and for a configured one the
1122
+ // management page carries the guidance — so this stays actionable.
1123
+ throw new UsageError(
1124
+ 'missing-key',
1125
+ 'Xiaomi MiMo plan usage needs the platform.xiaomimimo.com CONSOLE COOKIE (env XIAOMI_MIMO_COOKIE); ' +
1126
+ 'the inference key cannot read the quota API. Copy the whole Cookie header from the console devtools, ' +
1127
+ 'and note that Xiaomi expires it after about 24 hours.',
1128
+ { retryable: false },
1129
+ )
1130
+ }
1131
+ const quotaBase = String(config.quotaBaseURL ?? XIAOMI_QUOTA_BASE_URL).trim() || XIAOMI_QUOTA_BASE_URL
1132
+ const headers = { accept: 'application/json', cookie }
1133
+ const readJson = async (path) => {
1134
+ try {
1135
+ return await httpJson(joinUrl(quotaBase, path), { method: 'GET', headers })
1136
+ } catch (error) {
1137
+ // The console answers 401 + a loginUrl body when the session lapsed; that
1138
+ // is an expired credential, never a transient network fault.
1139
+ if (error instanceof UsageError && (error.code === 'unauthorized' || error.code === 'forbidden')) {
1140
+ throw new UsageError(
1141
+ 'unauthorized',
1142
+ 'the Xiaomi MiMo console session has expired (its cookie lasts about 24 hours); ' +
1143
+ 'open platform.xiaomimimo.com, copy a fresh Cookie header and paste it into the plugin management page',
1144
+ { retryable: false },
1145
+ )
1146
+ }
1147
+ throw error
1148
+ }
1149
+ }
1150
+
1151
+ const usage = await readJson(XIAOMI_QUOTA_PATHS.usage)
1152
+ if (isLoginRequired(usage)) {
1153
+ throw new UsageError(
1154
+ 'unauthorized',
1155
+ 'the Xiaomi MiMo console session has expired (its cookie lasts about 24 hours); ' +
1156
+ 'open platform.xiaomimimo.com, copy a fresh Cookie header and paste it into the plugin management page',
1157
+ { retryable: false },
1158
+ )
1159
+ }
1160
+ const parsedUsage = parseTokenPlanUsage(usage)
1161
+ // `detail` and `balance` only decorate the read: a plan-period end or a wallet
1162
+ // amount is not worth failing a usable quota for.
1163
+ const detail = await readJson(XIAOMI_QUOTA_PATHS.detail).catch(() => undefined)
1164
+ const detailParsed =
1165
+ detail === undefined
1166
+ ? { planCode: '', resetsAt: null, expired: false }
1167
+ : parseTokenPlanDetail(detail, now)
1168
+ const walletPayload = await readJson(XIAOMI_QUOTA_PATHS.balance).catch(() => undefined)
1169
+ const balance = walletPayload === undefined ? null : parseAccountBalance(walletPayload)
1170
+
1171
+ const { used, limit, percent } = parsedUsage.plan
1172
+ if (used === null && limit === null) {
1173
+ throw new UsageError(
1174
+ 'parse',
1175
+ 'the Xiaomi MiMo quota response carried no recognised token allowance' +
1176
+ (parsedUsage.contract.length > 0 ? ` (fields seen: ${parsedUsage.contract.join(', ')})` : ''),
1177
+ { retryable: false },
1178
+ )
1179
+ }
1180
+ const computed = percent !== null ? percent : usedPercent(used, limit)
1181
+ // The month bucket, when Xiaomi reports one, is the better monthly window.
1182
+ const monthWindow =
1183
+ parsedUsage.month !== null && (parsedUsage.month.used !== null || parsedUsage.month.limit !== null)
1184
+ ? parsedUsage.month
1185
+ : null
1186
+ const monthPercent =
1187
+ monthWindow === null ? null : monthWindow.percent !== null ? monthWindow.percent : usedPercent(monthWindow.used, monthWindow.limit)
1188
+
1189
+ return {
1190
+ kind: 'windows',
1191
+ plan: detailParsed.planCode === '' ? null : { id: detailParsed.planCode.toLowerCase(), label: detailParsed.planCode },
1192
+ windows: windowSet(
1193
+ {
1194
+ monthly: {
1195
+ percent: monthPercent === null ? computed : monthPercent,
1196
+ status: detailParsed.expired === true ? 'unknown' : 'ok',
1197
+ resetsAt: detailParsed.resetsAt,
1198
+ used: monthWindow === null ? used : monthWindow.used,
1199
+ cap: monthWindow === null ? limit : monthWindow.limit,
1200
+ unit: 'tokens',
1201
+ },
1202
+ },
1203
+ now,
1204
+ ),
1205
+ balance,
1206
+ }
1207
+ },
1208
+ }
1209
+
1003
1210
  /**
1004
1211
  * The provider adapter table. `read()` performs the required network reads and
1005
1212
  * returns the normalized result; optional reads (subscription/whoami) are
1006
1213
  * tolerated when they fail, because the usage itself is still valid.
1007
1214
  */
1008
1215
  export const ADAPTERS = {
1216
+ // v1.6.5: three regional Xiaomi MiMo Token Plan endpoints, one shared console
1217
+ // adapter (they differ only in their inference baseURL, which lives in
1218
+ // PROVIDER_DEFAULTS).
1219
+ 'xiaomi-token-plan-cn': xiaomiTokenPlanAdapter,
1220
+ 'xiaomi-token-plan-ams': xiaomiTokenPlanAdapter,
1221
+ 'xiaomi-token-plan-sgp': xiaomiTokenPlanAdapter,
1009
1222
  volcengine: {
1010
1223
  kind: 'windows',
1011
1224
  async read({ provider, fetchJson, httpJson, now }) {
@@ -1159,11 +1372,17 @@ export const ADAPTERS = {
1159
1372
  async read({ providerId = 'commandcode', provider, fetchJson, now }) {
1160
1373
  const config = providerConfig(providerId, provider)
1161
1374
  const credits = await fetchJson(joinUrl(config.baseURL, config.creditsPath))
1162
- const [subscription, whoami] = await Promise.all([
1375
+ // v1.6.6: the usage aggregate is what makes the monthly percentage
1376
+ // computable, but it is not essential to the rest of the read — a failure
1377
+ // only costs that one number, so it is tolerated like subscriptions/whoami.
1378
+ const [subscription, whoami, usageSummary] = await Promise.all([
1163
1379
  fetchJson(joinUrl(config.baseURL, config.subscriptionsPath)).catch(() => undefined),
1164
1380
  fetchJson(joinUrl(config.baseURL, config.whoamiPath)).catch(() => undefined),
1381
+ config.usageSummaryPath === undefined
1382
+ ? undefined
1383
+ : fetchJson(joinUrl(config.baseURL, config.usageSummaryPath)).catch(() => undefined),
1165
1384
  ])
1166
- return { kind: 'windows', ...parseCommandCode({ credits, subscription, whoami }, now) }
1385
+ return { kind: 'windows', ...parseCommandCode({ credits, subscription, whoami, usageSummary }, now) }
1167
1386
  },
1168
1387
  },
1169
1388
  deepseek: {
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Xiaomi MiMo "Token Plan" quota parsing (v1.6.5).
3
+ *
4
+ * Xiaomi exposes the plan's real usage on its **console** host, not the inference
5
+ * host:
6
+ *
7
+ * GET https://platform.xiaomimimo.com/api/v1/tokenPlan/usage
8
+ * GET https://platform.xiaomimimo.com/api/v1/tokenPlan/detail
9
+ * GET https://platform.xiaomimimo.com/api/v1/balance
10
+ *
11
+ * All three authenticate with the browser's console **session cookies**
12
+ * (`api-platform_serviceToken` + `userId`) sent as a raw `Cookie:` header. The
13
+ * inference key (`tp-...`) is rejected with
14
+ * `{"code":401,"loginUrl":"https://account.xiaomi.com/pass/serviceLogin?...&sid=api-platform..."}`
15
+ * — the same cookie-only shape as Open Design's billing routes. The cookie lasts
16
+ * about 24 hours, so an expired session is a normal, expected state and must read
17
+ * as guidance, not as a crash.
18
+ *
19
+ * This module is deliberately dependency-free and I/O-free: it only shapes
20
+ * payloads the adapter fetched. Everything is defensive, because none of these
21
+ * shapes are formally documented — they were transcribed from two independent
22
+ * clients (OmniRoute issue #14834, steipete/CodexBar docs/mimo.md), and Xiaomi may
23
+ * change them without notice. A wrong shape must degrade to `null`, never throw.
24
+ */
25
+
26
+ /** The console origin that serves the quota API. */
27
+ export const XIAOMI_QUOTA_BASE_URL = 'https://platform.xiaomimimo.com'
28
+
29
+ /** The console routes, relative to {@link XIAOMI_QUOTA_BASE_URL}. */
30
+ export const XIAOMI_QUOTA_PATHS = {
31
+ usage: '/api/v1/tokenPlan/usage',
32
+ detail: '/api/v1/tokenPlan/detail',
33
+ balance: '/api/v1/balance',
34
+ }
35
+
36
+ /** The console page a user opens to copy the cookie from. */
37
+ export const XIAOMI_CONSOLE_URL = 'https://platform.xiaomimimo.com'
38
+
39
+ /**
40
+ * How long a pasted console cookie is expected to work, in milliseconds.
41
+ *
42
+ * Xiaomi's own cookie declaration puts the session at 24 hours; the plugin uses
43
+ * this only to explain an `unauthorized` read, never to pre-empt one.
44
+ */
45
+ export const XIAOMI_COOKIE_TTL_MS = 24 * 60 * 60 * 1000
46
+
47
+ /** A finite non-negative number, or `null`. */
48
+ function num(value) {
49
+ if (typeof value === 'number') return Number.isFinite(value) && value >= 0 ? value : null
50
+ if (typeof value === 'string' && value.trim() !== '') {
51
+ const parsed = Number(value.trim())
52
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : null
53
+ }
54
+ return null
55
+ }
56
+
57
+ /** A non-empty trimmed string, or `''`. */
58
+ function str(value) {
59
+ return typeof value === 'string' ? value.trim() : ''
60
+ }
61
+
62
+ /** `data` of a payload, tolerating a payload that already IS the data. */
63
+ function dataOf(payload) {
64
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return null
65
+ const data = payload.data
66
+ return data !== null && typeof data === 'object' && !Array.isArray(data) ? data : payload
67
+ }
68
+
69
+ /**
70
+ * Normalize a pasted cookie into a `Cookie:` header value.
71
+ *
72
+ * Accepts three shapes people actually produce when copying from devtools:
73
+ * 1. the bare pair(s) `api-platform_serviceToken=abc; userId=123`
74
+ * 2. the full header `Cookie: api-platform_serviceToken=abc; userId=123`
75
+ * 3. a `document.cookie` string same as (1)
76
+ *
77
+ * @returns the header value, or `''` when nothing usable was pasted.
78
+ */
79
+ export function normalizeCookie(raw) {
80
+ if (typeof raw !== 'string') return ''
81
+ let value = raw.trim()
82
+ if (value === '') return ''
83
+ // Accept a pasted "Cookie:" header prefix (any casing).
84
+ value = value.replace(/^cookie\s*:\s*/i, '')
85
+ // Collapse newlines/tabs a multi-line copy can introduce: a header value must
86
+ // stay on one line, and a stray newline would be a header-injection hazard.
87
+ value = value.replace(/[\r\n\t]+/g, ' ').trim()
88
+ // A cookie value is `name=value` pairs separated by `;`. Require at least one.
89
+ if (!/[^=;\s]+=[^;]*/.test(value)) return ''
90
+ return value
91
+ }
92
+
93
+ /**
94
+ * Whether a payload is Xiaomi's "you are not signed in" answer.
95
+ *
96
+ * Detected by the documented 401 body carrying a `loginUrl` (checked anywhere in
97
+ * the JSON, since the envelope nesting is not guaranteed) or by a bare 401 code.
98
+ */
99
+ export function isLoginRequired(payload, status = null) {
100
+ if (status === 401) return true
101
+ if (payload === null || typeof payload !== 'object') return false
102
+ if (num(payload.code) === 401) return true
103
+ let text = ''
104
+ try {
105
+ text = JSON.stringify(payload)
106
+ } catch {
107
+ return false
108
+ }
109
+ return text.includes('loginUrl') || text.includes('/pass/serviceLogin')
110
+ }
111
+
112
+ /**
113
+ * Read one `{used, limit, percent}` triple.
114
+ * @returns `{ used, limit, percent }` with `null`s for anything unusable.
115
+ */
116
+ function readTriple(node) {
117
+ if (node === null || typeof node !== 'object' || Array.isArray(node)) {
118
+ return { used: null, limit: null, percent: null }
119
+ }
120
+ const used = num(node.used)
121
+ const limit = num(node.limit)
122
+ const percent = num(node.percent)
123
+ return { used, limit, percent: percent === null || percent > 1000 ? null : percent }
124
+ }
125
+
126
+ /**
127
+ * Combine the per-item rows of one bucket.
128
+ *
129
+ * Xiaomi lists items per plan/model but attaches the plan-level allowance to each,
130
+ * so the totals are NOT simply summed: when every item repeats the same non-zero
131
+ * `limit` it is one account-level allowance and is counted once; only genuinely
132
+ * different limits are added. `used` is always summed, because usage is disjoint
133
+ * per item.
134
+ *
135
+ * @returns `{ used, limit, percent }` (all `null` when no row had usable numbers).
136
+ */
137
+ function combineItems(items) {
138
+ const rows = Array.isArray(items) ? items.map(readTriple) : []
139
+ const usable = rows.filter((row) => row.used !== null || row.limit !== null)
140
+ if (usable.length === 0) return { used: null, limit: null, percent: null }
141
+
142
+ let used = 0
143
+ let sawUsed = false
144
+ for (const row of usable) {
145
+ if (row.used === null) continue
146
+ used += row.used
147
+ sawUsed = true
148
+ }
149
+
150
+ const limits = usable.map((row) => row.limit).filter((value) => value !== null)
151
+ let limit = null
152
+ if (limits.length > 0) {
153
+ const allSame = limits.every((value) => value === limits[0])
154
+ limit = allSame ? limits[0] : limits.reduce((sum, value) => sum + value, 0)
155
+ }
156
+
157
+ // Prefer the server's own percentage only when the rows agree on one value;
158
+ // otherwise it cannot be attributed to the combined row.
159
+ const percents = usable.map((row) => row.percent).filter((value) => value !== null)
160
+ const percent =
161
+ percents.length > 0 && percents.every((value) => Math.abs(value - percents[0]) < 1e-6) ? percents[0] : null
162
+
163
+ return { used: sawUsed ? used : null, limit, percent }
164
+ }
165
+
166
+ /**
167
+ * Parse `GET /api/v1/tokenPlan/usage`.
168
+ *
169
+ * Envelope (per OmniRoute #14834):
170
+ * ```jsonc
171
+ * { "data": {
172
+ * "usage": { "items": [ { "plan_total_token": { "used": N, "limit": M, "percent": P },
173
+ * "compensation_total_token": { "used": n, "limit": m, "percent": p } } ] },
174
+ * "monthUsage":{ "items": [ { "month_total_token": { "used": N, "limit": M, "percent": P } } ] }
175
+ * } }
176
+ * ```
177
+ *
178
+ * @returns `{ plan, month, compensation, contract }` — `plan` is the primary
179
+ * allowance this plugin reports; the rest is context. `contract` lists the
180
+ * token kinds actually found, so an empty result can be explained.
181
+ */
182
+ export function parseTokenPlanUsage(payload) {
183
+ const data = dataOf(payload)
184
+ const contract = []
185
+ if (data === null) return { plan: { used: null, limit: null, percent: null }, month: null, compensation: null, contract }
186
+
187
+ const planItems = []
188
+ const monthItems = []
189
+ const compensationItems = []
190
+ for (const container of [data.usage, data.monthUsage, data]) {
191
+ if (container === null || typeof container !== 'object') continue
192
+ if (Array.isArray(container.items)) {
193
+ for (const item of container.items) {
194
+ if (item === null || typeof item !== 'object') continue
195
+ if (item.plan_total_token !== undefined) planItems.push(item.plan_total_token)
196
+ if (item.month_total_token !== undefined) monthItems.push(item.month_total_token)
197
+ if (item.compensation_total_token !== undefined) compensationItems.push(item.compensation_total_token)
198
+ for (const key of Object.keys(item)) {
199
+ if (!contract.includes(key)) contract.push(key)
200
+ }
201
+ }
202
+ }
203
+ }
204
+
205
+ const plan = combineItems(planItems)
206
+ const month = monthItems.length === 0 ? null : combineItems(monthItems)
207
+ const compensation = compensationItems.length === 0 ? null : combineItems(compensationItems)
208
+ return { plan, month, compensation, contract }
209
+ }
210
+
211
+ /** Epoch milliseconds from an ISO string, epoch number, or number-as-string. */
212
+ function instantMs(value) {
213
+ const numeric = num(value)
214
+ if (numeric !== null) {
215
+ // Seconds vs milliseconds: anything below ~year 2286 in ms is a seconds value.
216
+ return numeric < 1e12 ? Math.round(numeric * 1000) : Math.round(numeric)
217
+ }
218
+ const text = str(value)
219
+ if (text === '') return null
220
+ const parsed = Date.parse(text)
221
+ return Number.isFinite(parsed) ? parsed : null
222
+ }
223
+
224
+ /**
225
+ * Parse `GET /api/v1/tokenPlan/detail`.
226
+ *
227
+ * @returns `{ planCode, resetsAt, expired }` — `resetsAt` is an ISO string or
228
+ * `null`; `expired` is `true` only when the payload says so.
229
+ */
230
+ export function parseTokenPlanDetail(payload, now = Date.now()) {
231
+ void now
232
+ const data = dataOf(payload)
233
+ if (data === null) return { planCode: '', resetsAt: null, expired: false }
234
+ const planCode = str(data.planCode) || str(data.plan_code) || str(data.planName)
235
+ const endMs =
236
+ instantMs(data.currentPeriodEnd) ??
237
+ instantMs(data.current_period_end) ??
238
+ instantMs(data.periodEnd) ??
239
+ instantMs(data.expireTime)
240
+ return {
241
+ planCode,
242
+ resetsAt: endMs === null ? null : new Date(endMs).toISOString(),
243
+ expired: data.expired === true,
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Parse `GET /api/v1/balance` (CNY).
249
+ *
250
+ * @returns `{ amount, currency }` or `null` when no number was found. Xiaomi
251
+ * reports CNY; the currency code is passed through when the payload names one
252
+ * but never guessed into a different unit.
253
+ */
254
+ export function parseAccountBalance(payload) {
255
+ const data = dataOf(payload)
256
+ if (data === null) return null
257
+ const amount =
258
+ num(data.balance) ??
259
+ num(data.amount) ??
260
+ num(data.availableBalance) ??
261
+ num(data.totalBalance) ??
262
+ num(data.balanceCny) ??
263
+ null
264
+ if (amount === null) return null
265
+ const currency = str(data.currency).toUpperCase() || (str(data.unit).toUpperCase() === 'CNY' ? 'CNY' : 'CNY')
266
+ return { amount, currency: currency === '' ? 'CNY' : currency }
267
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tokenlens",
3
- "version": "1.6.4",
3
+ "version": "1.6.6",
4
4
  "type": "module",
5
5
  "description": "dsh-TokenLens (词元透镜) — provider-aware plan capacity plus local token statistics (today/7d/30d/all-time, by harness/provider/model/workspace) for DSH.",
6
6
  "license": "MIT",