dsh-tokenlens 1.6.2 → 1.6.5

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.5 — 2026-11-05
8
+
9
+ (当日已发布版本:v1.6.5。修复 GitHub issue #1)
10
+
11
+ - **fix(ui): 没有额度适配器的供应商不再隐藏芯片**——`unsupported-provider` 按协议会静默隐藏芯片,但芯片同时是**本机 Token 统计的唯一入口**,于是「有数据却永远看不到」:选中 `xiaomi-token-plan-cn` 时 `usage-cache.json` 里躺着 3.19 亿 Token 却无处可查(issue #1 的核心投诉)。现在这类供应商保留一个「**仅本机统计**」芯片(圆点为正常态而非告警),点开即见统计区——统计区本就不依赖快照
12
+ - **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`(人民币余额)
13
+ - feat(providers): **该接口只认控制台 Cookie**,推理 Key(`tp-…`)一律 401 + `loginUrl`。插件复用 Open Design 那套「写一次、永不回显」的密码输入位来存 Cookie,文案明确告知「推理 Key 读不到额度接口」与「Cookie 约 24 小时过期」
14
+ - feat(providers): 会话过期映射为 `unauthorized`(不可重试)并给出可操作指引;未配置 Cookie 时**不发任何网络请求**,直接 `missing-key`
15
+ - feat(providers): 同日多条 `plan_total_token` 共享同一份额度时只计一次(避免把额度翻倍),用量按条累加;无法识别的报文降级为 `parse` 并回显**实际看到的字段名**便于排查
16
+ - feat(providers): 月度窗口优先取 `monthUsage`,`resetsAt` 取 `currentPeriodEnd`,**绝不凭空编造 5 小时/周窗口**;余额读取失败只影响余额本身,不影响额度
17
+ - 测试:336 → 352(Cookie 归一化含多行注入折叠、信封解析、共享额度不翻倍、401/`loginUrl` 双路识别、缺 Cookie 零请求、未知报文降级、控制台 Cookie 输入框渲染与写一次、无适配器芯片可见且统计区可达)
18
+ - **实测确认**(本机 2026-11-05):三个端点均真实存在(非 404),匿名请求返回文档记载的 `{"code":401,"loginUrl":…}`,适配器正确映射为可操作指引。**成功响应的字段解析无法实测**(本机无小米账号),仅按两份独立实现(OmniRoute #14834、CodexBar `docs/mimo.md`)转录
19
+
20
+ ## 1.6.4 — 2026-11-04
21
+
22
+ (当日已发布版本:v1.6.2 / v1.6.4。v1.6.1 的混合模型修复未单独发版,已并入本节)
23
+
24
+ - **fix(open-design): 「本期 Design Plan 累计」改为只统计 Design Plan 额度消耗,排除钱包余额付费**——钱包扣费不消耗 credits(只扣 `balanceUsd`),所以改由**服务端 credits 反推**金额:`usedCredits ÷ creditsPerUsd ÷ 倍率`。实测 150,005 credits ÷ 10,000 ÷ 0.5 = **$30.00**,与「周额度 $30 × 已用比例」交叉验证一致($30 × 100% = $30.00)。此前按本机 Token × 单价计价,会把钱包付费的调用也算进去,导致两个口径混淆
25
+ - feat(open-design): 新增 `localPricedUsd`(含钱包付费的本机估算)单独保留;当它高于 Design Plan 金额时,弹窗追加「另有 $X 由账户余额支付,未计入」,两个口径都清楚可查
26
+ - **fix(ui): 图表模式日期标签对不齐柱子**——原来把第一个标签钉在左边缘、最后一个钉在右边缘,而柱子是等分 flex 单元格居中的:只有 3 天数据时第一个标签在 0% 而柱子中心在 16.7%,标签整体漂移。改为每个标签居中于**自己那根柱子**的正下方(`(i+0.5)/n`),并放开 x 轴 `overflow` 让边缘标签不被裁掉
27
+ - 测试:332 → 335(Design Plan 金额来自 credits 且低于含钱包的本机估算、无倍率时回退到 credits 面值、3 根柱时标签中心为 16.7%/50%/83.3% 且不贴边)
28
+
7
29
  ## 1.6.2 — 2026-11-04
8
30
 
9
31
  (当日已发布版本:v1.6.2。v1.6.1 的混合模型修复未单独发版,已并入本节)
package/PROTOCOL.md CHANGED
@@ -439,10 +439,19 @@ with `[path]`.
439
439
 
440
440
  Client behaviour:
441
441
 
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.
442
+ - `missing-key`, `disabled` → hide the chip (silent).
443
+ - `unsupported-provider` (v1.6.5) → keep a 「仅本机统计 / Local stats only」 chip in a
444
+ **normal** dot state. Rationale: the chip is the only entry point to the local
445
+ token statistics, and those are collected for every harness regardless of
446
+ whether an adapter exists. Hiding the chip made real data unreachable — the
447
+ exact complaint in GitHub issue #1, where `xiaomi-token-plan-cn` had 319M
448
+ tokens in `usage-cache.json` and no way to view them. The dialog's stats
449
+ sections (`buildStatsSummary` / `buildStatsDetails`) do not depend on
450
+ `snapshot`, so they render normally with `snapshot: null`.
451
+ - `missing-key` for a workspace-scoped provider (v1.3.5): a provider whose Host
452
+ payload carries a `workspaceId` field (Open Design) keeps a 「待配置 / Needs setup」
453
+ chip, because hiding it would leave no way to reach the dialog's configuration
454
+ guidance.
446
455
  - everything else → show the chip in a warning state with a retry action.
447
456
 
448
457
  ## Provider selection (client side)
@@ -515,6 +524,29 @@ again; such providers can still be overridden explicitly from the page (the over
515
524
  `VOLCENGINE_ACCESS_KEY_ID` / `VOLCENGINE_SECRET_ACCESS_KEY` (environment or credential service); an Ark
516
525
  API key cannot query plan quota. Responses (`Result.QuotaUsage[]` or `Result.AFP*`) normalize to the
517
526
  canonical 5h/weekly/monthly windows. Missing AK/SK produce a `missing-key` error, never a silent zero.
527
+ - `xiaomi-token-plan-cn` / `-ams` / `-sgp` (v1.6.5): the quota lives on Xiaomi's **console** host, not the
528
+ inference host, and it authenticates with **browser session cookies only**:
529
+
530
+ | route | returns |
531
+ | --- | --- |
532
+ | `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` |
533
+ | `GET https://platform.xiaomimimo.com/api/v1/tokenPlan/detail` | `planCode`, `currentPeriodEnd`, `expired` |
534
+ | `GET https://platform.xiaomimimo.com/api/v1/balance` | account balance (CNY) |
535
+
536
+ The inference key (`tp-…`) is rejected with `401 {"code":401,"loginUrl":"https://account.xiaomi.com/pass/serviceLogin?...&sid=api-platform..."}` —
537
+ verified live against all three routes (they exist; only the success shape is untested, since it needs a
538
+ signed-in account). The cookie (`api-platform_serviceToken` + `userId`) is entered on the management page
539
+ in the same write-only secret slot Open Design uses for its Control Key, labelled 控制台 Cookie, and lasts
540
+ about 24 hours. `normalizeCookie` accepts a bare pair list, a `Cookie:` header, or a multi-line paste
541
+ (newlines are collapsed, because a newline inside a header value is an injection hazard).
542
+
543
+ Normalization rules: `monthUsage` wins as the monthly window; `resetsAt` comes from `currentPeriodEnd`;
544
+ a `401` — whether from the HTTP status or from a `loginUrl` anywhere in the body — maps to
545
+ `unauthorized` (not retryable) with paste instructions; no cookie produces `missing-key` **without any
546
+ network read**; an unparsable payload produces `parse` naming the fields it did see. When Xiaomi lists
547
+ several `plan_total_token` rows that all carry the same non-zero `limit` (one per day/model), the limit is
548
+ counted once while `used` is summed — otherwise the allowance would be inflated. `rolling` and `weekly`
549
+ stay `null`: Xiaomi reports one monthly allowance and the plugin never invents windows.
518
550
 
519
551
  ### Automatic DSH provider sync (v0.18.0)
520
552
 
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
 
@@ -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 352 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
 
@@ -323,12 +323,16 @@ 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
329
  - Command Code 的 `/alpha/*` 是官方 CLI 自用的私有计费路由;免费 / 无方案账户返回空窗口(显示 `—`),插件不会用方案目录猜额度。
329
330
  - 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
331
  - **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 个窗口」);覆盖不完整时百分比仍显示但**不会触发告警配色**,插件没运行期间错过的窗口无法计入。
332
+ - **金额按服务端 credits 反推,只统计 Design Plan 额度消耗**(v1.6.4)。钱包扣费不消耗 credits,只扣 `balanceUsd`,所以「本期 Design Plan 累计」= `usedCredits ÷ creditsPerUsd ÷ 倍率`——实测 `150,005 ÷ 10,000 ÷ 0.5 = $30.00`,与「周额度 $30 × 已用 100%」交叉验证一致。含钱包付费的本机估算另存为 `localPricedUsd`,高于前者时弹窗追加「另有 $X 由账户余额支付,未计入」,两个口径都清楚可查。
333
+ - **月额度的分母随倍率变化**(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 个窗口」);覆盖不完整时百分比仍显示但**不会触发告警配色**。
334
+ - **没有额度适配器的供应商也会保留芯片**(v1.6.5)。`unsupported-provider` 原本静默隐藏芯片,但芯片是本机 Token 统计的唯一入口,于是「有数据却看不到」(如小米 MiMo 账户里有 3 亿 Token 却无处可查)。现在这类供应商显示「**仅本机统计**」芯片,点开即见统计区;本机统计对所有 Harness 都会采集,与该供应商有没有额度接口无关。
335
+ - **小米 MiMo 的额度接口只认控制台 Cookie**(v1.6.5)。推理 Key(`tp-…`)读不到额度接口,插件复用「写一次、永不回显」的密码输入位保存 Cookie,并明确提示约 24 小时过期。会话过期会显示可操作的中文指引,而不是报错码;未配置 Cookie 时不发任何网络请求。
332
336
  - 未配置凭据的供应商 chip 静默隐藏;`zai`、`minimax`、`deepinfra` 属 best-effort 适配(未公开接口,失败时按可重试错误优雅降级)。
333
337
  - 各供应商的元数据(`kind` / `label` / `shortLabel` / `baseURL` / `apiKeyRef` / `consoleUrl`)集中在 `lib/providers.mjs` 的 `PROVIDER_DEFAULTS` 与 `ADAPTERS` 中,新增适配器只需在该文件登记并补测试。
334
338
 
@@ -440,7 +444,7 @@ Host 半注册 `tokenlens_usage`,参数 `action`(默认 `snapshot`)与可
440
444
 
441
445
  ## 验证
442
446
 
443
- - `node --test`:**332 个自动化用例**,覆盖 Host 数据层、供应商适配器、统计折叠、各 harness 读取、Design Plan 周期账本与官方单价计价、窗口锚点切片、工作区自动识别、插件集成与浏览器端渲染 / 交互。
447
+ - `node --test`:**352 个自动化用例**,覆盖 Host 数据层、供应商适配器(含小米 MiMo 控制台 Cookie 适配器)、统计折叠、各 harness 读取、Design Plan 周期账本与额度计价、窗口锚点切片、工作区自动识别、插件集成与浏览器端渲染 / 交互。
444
448
  - 在真实环境中验证过 **5 个 harness** 的数据源;本机统计与独立的 SQLite 查询、独立的日志解析逐项对账(差额为 0),跨 harness 合并与供应商映射算术自洽。
445
449
  - 供应商接口按各官方文档与参考客户端行为验证;未配置凭据时 chip 静默隐藏,接口异常时按可重试错误降级。
446
450
 
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,12 +338,18 @@ 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 累计',
343
349
  designPlanHint:
344
350
  '按官方定价表的模型单价换算本机实际消耗的 Token(不是 usedCredits ÷ 汇率)。Open Design 没有月度用量接口,此值由插件在本机累计观测到的 7 天窗口得出,仅供参考',
345
351
  designPlanMultiplier: '按 {multiplier}× 倍率模型计价',
352
+ designPlanWalletShare: '另有 {amount} 由账户余额支付,未计入',
346
353
  designPlanPartialPricing: '含未收录价格的模型,金额为下限',
347
354
  designPlanComplete: '已覆盖本账期全部窗口',
348
355
  designPlanPartial: '仅统计已观测的 {count} 个窗口',
@@ -420,6 +427,7 @@ window.__ModuleLoader__.load({
420
427
  chipHint: 'Plan usage for the selected provider — click for details',
421
428
  chipUnavailable: 'Usage unavailable',
422
429
  chipNeedsConfig: 'Needs setup',
430
+ chipStatsOnly: 'Local stats only',
423
431
  chipNoUsage: 'No usage',
424
432
  chipRolling: '5h',
425
433
  chipWeekly: 'wk',
@@ -600,12 +608,18 @@ window.__ModuleLoader__.load({
600
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',
601
609
  providerControlKeyPlaceholder: 'Paste the Control Key (never shown again after saving)',
602
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',
603
616
  configureProvider: 'Configure',
604
617
  errorOpenDesignConfig: 'Open Design quota needs a workspace ID and a Control Key (not an inference key)',
605
618
  designPlanLabel: 'Period Design Plan',
606
619
  designPlanHint:
607
620
  'Priced from the official per-model rates over the tokens this machine actually consumed (not usedCredits ÷ rate). Open Design serves no period-usage endpoint, so the window total is accumulated locally; treat it as an estimate.',
608
621
  designPlanMultiplier: 'priced at the {multiplier}× model rate',
622
+ designPlanWalletShare: 'a further {amount} was paid from the balance and is not counted',
609
623
  designPlanPartialPricing: 'includes models with no documented price; the amount is a lower bound',
610
624
  designPlanComplete: 'covers every window of this period',
611
625
  designPlanPartial: 'only {count} observed window(s)',
@@ -1109,6 +1123,9 @@ window.__ModuleLoader__.load({
1109
1123
  monthlyCapUsd: monthlyCapUsd !== null && monthlyCapUsd > 0 ? monthlyCapUsd : null,
1110
1124
  capPercent,
1111
1125
  multiplier: multiplier !== null && multiplier > 0 ? multiplier : null,
1126
+ // v1.6.4: the wallet-inclusive local token estimate, shown only as context
1127
+ // next to the Design-Plan-only figure (they differ by wallet-paid calls).
1128
+ localPricedUsd: numberOrNull(raw.localPricedUsd),
1112
1129
  // v1.6.2: some consumed model had no documented price, so the dollar
1113
1130
  // figure is a lower bound and must not read as complete.
1114
1131
  partial: raw.partial === true,
@@ -1202,6 +1219,9 @@ window.__ModuleLoader__.load({
1202
1219
  // mere presence is the capability flag that renders the input.
1203
1220
  workspaceId,
1204
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,
1205
1225
  }
1206
1226
  }
1207
1227
 
@@ -3004,10 +3024,14 @@ window.__ModuleLoader__.load({
3004
3024
  if (label !== provider.label) update.label = label
3005
3025
  // v1.3.5: the workspace id of a workspace-scoped adapter; an empty string
3006
3026
  // clears the override so the Host falls back to its env var.
3007
- if (provider.supportsWorkspaceId === true) {
3008
- const wireWorkspaceId = typeof provider.workspaceId === 'string' ? provider.workspaceId : ''
3009
- const workspaceId = typeof source.workspaceId === 'string' ? source.workspaceId.trim() : wireWorkspaceId
3010
- 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
+ }
3011
3035
  // v1.3.9: the Control Key is write-only. A typed value is sent once; an
3012
3036
  // empty draft means "leave the stored secret alone", never "clear it".
3013
3037
  const apiKey = typeof source.apiKey === 'string' ? source.apiKey.trim() : ''
@@ -3063,7 +3087,15 @@ window.__ModuleLoader__.load({
3063
3087
  if (field !== 'consoleUrl' && field !== 'shortLabel' && field !== 'label' && field !== 'workspaceId' && field !== 'apiKey') {
3064
3088
  return
3065
3089
  }
3066
- 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
+ }
3067
3099
  if (provider === null || provider === undefined) return
3068
3100
  const current = store.get().management
3069
3101
  const draft = providerDraftOf(provider, current)
@@ -3456,7 +3488,7 @@ window.__ModuleLoader__.load({
3456
3488
  .dsh-tokenlens-chart-bar-wrap[data-chart-hover=true] .dsh-tokenlens-chart-bar{background:#40c877;filter:brightness(1.12);box-shadow:0 0 0 1px rgba(64,200,119,.55)}
3457
3489
  .dsh-tokenlens-chart-crosshair{position:absolute;top:2px;height:160px;z-index:2;pointer-events:none;border-left:1px dashed #d98a1f;opacity:.7}
3458
3490
  .dsh-tokenlens-chart-readout{position:absolute;top:4px;z-index:3;pointer-events:none;max-width:calc(100% - 8px);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;border:1px solid var(--dsw-alias-border-subtle,rgba(127,127,127,.3));border-radius:6px;background:var(--dsw-alias-bg-elevated,#202124);color:var(--dsw-alias-label-primary);font-size:11px;line-height:1.4;padding:2px 6px;box-shadow:0 2px 8px rgba(0,0,0,.25);font-variant-numeric:tabular-nums}
3459
- .dsh-tokenlens-chart-xaxis{position:relative;flex:none;height:14px;min-width:0;max-width:100%;overflow:hidden}
3491
+ .dsh-tokenlens-chart-xaxis{position:relative;flex:none;height:14px;min-width:0;max-width:100%;overflow:visible}
3460
3492
  .dsh-tokenlens-chart-x-label{position:absolute;top:2px;color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:1.2;white-space:nowrap;font-variant-numeric:tabular-nums}
3461
3493
  .dsh-tokenlens-chart-summary{display:flex;flex-wrap:wrap;gap:4px 12px;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:1.4;min-width:0}
3462
3494
  .dsh-tokenlens-chart-summary>span{font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
@@ -3730,9 +3762,29 @@ window.__ModuleLoader__.load({
3730
3762
  return canonicalProviderId(rawId) === 'open-design'
3731
3763
  }
3732
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
+
3733
3784
  /** Whether the chip must stay silent for this state. */
3734
3785
  function chipHidden(state) {
3735
3786
  if (!isHidden(state.error)) return false
3787
+ if (needsStatsEntry(state) === true) return false
3736
3788
  return needsWorkspaceConfigEntry(state) !== true
3737
3789
  }
3738
3790
 
@@ -3841,7 +3893,11 @@ window.__ModuleLoader__.load({
3841
3893
  // A workspace-scoped provider missing its configuration reads as 「待配置」
3842
3894
  // instead of a read failure: nothing upstream failed, setup is incomplete.
3843
3895
  const needsConfig = needsWorkspaceConfigEntry(state)
3844
- 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')
3845
3901
  return h(
3846
3902
  'span',
3847
3903
  { className: 'dsh-tokenlens-anchor', 'data-composer-stat': 'tokenlens' },
@@ -3857,9 +3913,10 @@ window.__ModuleLoader__.load({
3857
3913
  'aria-label': label,
3858
3914
  'aria-haspopup': 'dialog',
3859
3915
  ...(needsConfig ? { 'data-needs-config': 'true' } : {}),
3916
+ ...(statsOnly ? { 'data-stats-only': 'true' } : {}),
3860
3917
  onClick: () => openDialog(sessionId),
3861
3918
  },
3862
- 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 }),
3863
3920
  h('span', { className: 'dsh-tokenlens-brand' }, brand),
3864
3921
  h('span', { className: 'dsh-tokenlens-metric' }, label),
3865
3922
  ),
@@ -4078,6 +4135,15 @@ window.__ModuleLoader__.load({
4078
4135
  if (designPlan.multiplier !== null) {
4079
4136
  notes.push(t('designPlanMultiplier', { multiplier: designPlan.multiplier }))
4080
4137
  }
4138
+ // v1.6.4: when the wallet-inclusive local estimate is HIGHER than the
4139
+ // Design-Plan figure, the difference was paid from the account balance and
4140
+ // must not be presented as Design Plan consumption.
4141
+ if (designPlan.localPricedUsd !== null && designPlan.estimatedUsd !== null) {
4142
+ const walletShare = designPlan.localPricedUsd - designPlan.estimatedUsd
4143
+ if (walletShare > 0.005) {
4144
+ notes.push(t('designPlanWalletShare', { amount: formatMoney(walletShare, 'USD') }))
4145
+ }
4146
+ }
4081
4147
  // v1.6.2: a model without a documented price makes the figure a lower bound.
4082
4148
  // Say it rather than letting the number read as complete.
4083
4149
  if (designPlan.partial === true) notes.push(t('designPlanPartialPricing'))
@@ -4576,16 +4642,17 @@ window.__ModuleLoader__.load({
4576
4642
  ),
4577
4643
  )
4578
4644
  const bars = points.map((point, index) => spec.bar(point, index, { max, hovered: index === hoverIndex }))
4645
+ // v1.6.3: centre every label under ITS OWN bar. The bars are a flex row of
4646
+ // equal cells, so bar i is centred at (i + 0.5) / count of the plot width.
4647
+ // The old code pinned the first label to the left edge and the last to the
4648
+ // right edge, which only lines up when the bars fill the whole width: with
4649
+ // 3 bars the first label sat at 0% while its bar was centred at 16.7%, and
4650
+ // the last sat at 100% while its bar was at 83.3% — the dates drifted off
4651
+ // their columns (reported for a 3-day series).
4579
4652
  const xLabels = chartXLabelIndices(points.length).map((index) => {
4580
4653
  const point = points[index]
4581
4654
  const label = spec.xLabel(point, index)
4582
- const first = index === 0
4583
- const last = index === points.length - 1
4584
- const style = first
4585
- ? { left: '0%' }
4586
- : last
4587
- ? { right: '0%' }
4588
- : { left: `${((index + 0.5) / points.length) * 100}%`, transform: 'translateX(-50%)' }
4655
+ const style = { left: `${((index + 0.5) / points.length) * 100}%`, transform: 'translateX(-50%)' }
4589
4656
  return h(
4590
4657
  'span',
4591
4658
  {
@@ -6366,7 +6433,21 @@ window.__ModuleLoader__.load({
6366
6433
  label: t('providerControlKey'),
6367
6434
  hint: t('providerControlKeyHint'),
6368
6435
  })
6369
- : 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,
6370
6451
  buildProviderNameField(provider, management, t, {
6371
6452
  key: 'shortLabel',
6372
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.2'
87
+ export const PLUGIN_VERSION = '1.6.5'
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 ?? '' },
@@ -266,18 +266,34 @@ export function summarizeDesignPlan(ledger, periodStart, input = null) {
266
266
  windows += 1
267
267
  }
268
268
  const creditsPerUsd = period.creditsPerUsd
269
- // v1.6.0: `creditsPerUsd` conversion is kept ONLY as a fallback. It assumes the
270
- // account burns credits at 1x, but the documented multipliers range 0.25x–4x,
271
- // so it is wrong for any non-1x model (2x too low for DeepSeek V4.1 Flash).
272
- // The caller passes the documented list-price cost instead.
273
269
  const creditsUsd =
274
270
  Number.isSafeInteger(creditsPerUsd) && creditsPerUsd > 0 ? usedCredits / creditsPerUsd : null
275
- const estimatedUsd = Number.isFinite(input?.listPriceUsd) ? input.listPriceUsd : creditsUsd
271
+ const multiplier = Number.isFinite(input?.multiplier) && input.multiplier > 0 ? input.multiplier : null
272
+ // v1.6.4: the Design Plan figure is derived FROM THE SERVER'S credits, not from
273
+ // locally-priced tokens. This is what makes it a Design-Plan-only number:
274
+ // credits are deducted for Design Plan allowance ONLY.
275
+ // Wallet-funded usage never touches them — it debits `balanceUsd` instead.
276
+ //
277
+ // `usedCredits / creditsPerUsd` yields the *credit* value; the documented list
278
+ // price is that divided by the model multiplier, because a 0.5x model burns
279
+ // half a credit per list-price dollar. Verified against the account's own caps:
280
+ // 149,932 credits / 10,000 / 0.5 = $29.99
281
+ // 30 * (149,932 / 150,000) = $29.99 (the 7-day cap is $30)
282
+ // Both agree, which is the cross-check that the formula is right.
283
+ //
284
+ // The locally-priced token total (input.listPriceUsd) is kept as `walletUsd`
285
+ // context: it includes wallet-funded calls, so it can EXCEED this figure. It is
286
+ // reported separately and never mixed into `estimatedUsd`.
287
+ const designPlanUsd =
288
+ creditsUsd === null ? null : multiplier === null ? creditsUsd : creditsUsd / multiplier
289
+ const estimatedUsd = designPlanUsd !== null ? designPlanUsd : Number.isFinite(input?.listPriceUsd) ? input.listPriceUsd : null
276
290
  const since = entries[0][0]
277
291
  const limitPerWindow = entries[entries.length - 1][1].limitCredits
278
292
  const usableWindowLimit = Number.isSafeInteger(limitPerWindow) && limitPerWindow > 0 ? limitPerWindow : null
279
293
  const periodLimitCredits = usableWindowLimit === null ? null : usableWindowLimit * WEEKS_PER_PERIOD
280
294
  const percent = periodLimitCredits === null ? null : (usedCredits / periodLimitCredits) * 100
295
+ // The cap is a list-price cap for the model mix, so compare it against the
296
+ // list-price figure derived from credits.
281
297
  const monthlyCapUsd = Number.isFinite(input?.monthlyCapUsd) ? input.monthlyCapUsd : null
282
298
  const capPercent =
283
299
  estimatedUsd !== null && monthlyCapUsd !== null && monthlyCapUsd > 0
@@ -287,10 +303,13 @@ export function summarizeDesignPlan(ledger, periodStart, input = null) {
287
303
  periodStart: key,
288
304
  periodEnd: period.periodEnd || '',
289
305
  usedCredits,
306
+ // Design-Plan-only dollars (from server credits). Comparable with the caps.
290
307
  estimatedUsd,
308
+ // The wallet-inclusive local estimate, kept for transparency/contrast only.
309
+ localPricedUsd: Number.isFinite(input?.listPriceUsd) ? input.listPriceUsd : null,
291
310
  creditsUsd,
292
311
  monthlyCapUsd,
293
- multiplier: Number.isFinite(input?.multiplier) ? input.multiplier : null,
312
+ multiplier,
294
313
  capPercent,
295
314
  // v1.6.2: true when some consumed model has no documented price, so
296
315
  // `estimatedUsd` is a lower bound and the UI must say so.
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']
@@ -255,6 +265,44 @@ export const PROVIDER_DEFAULTS = {
255
265
  apiKeyRef: 'OPENDESIGN_CONTROL_KEY',
256
266
  consoleUrl: 'https://open-design.ai/cloud/dashboard',
257
267
  },
268
+ // v1.6.5: Xiaomi MiMo's "Token Plan". Three regional inference endpoints share
269
+ // one quota console, and that console (like Open Design's billing routes) only
270
+ // accepts BROWSER SESSION COOKIES — the inference key answers 401 with a
271
+ // `loginUrl`. The cookie therefore lives in the per-provider secret slot, the
272
+ // same place Open Design keeps its Control Key.
273
+ 'xiaomi-token-plan-cn': {
274
+ kind: 'windows',
275
+ label: 'Xiaomi MiMo',
276
+ shortLabel: 'MiMo',
277
+ baseURL: 'https://token-plan-cn.xiaomimimo.com',
278
+ quotaBaseURL: XIAOMI_QUOTA_BASE_URL,
279
+ consoleCookieRef: 'XIAOMI_MIMO_COOKIE',
280
+ apiKeyRef: 'XIAOMI_MIMO_COOKIE',
281
+ supportsConsoleCookie: true,
282
+ consoleUrl: XIAOMI_CONSOLE_URL,
283
+ },
284
+ 'xiaomi-token-plan-ams': {
285
+ kind: 'windows',
286
+ label: 'Xiaomi MiMo (AMS)',
287
+ shortLabel: 'MiMo-A',
288
+ baseURL: 'https://token-plan-ams.xiaomimimo.com',
289
+ quotaBaseURL: XIAOMI_QUOTA_BASE_URL,
290
+ consoleCookieRef: 'XIAOMI_MIMO_COOKIE',
291
+ apiKeyRef: 'XIAOMI_MIMO_COOKIE',
292
+ supportsConsoleCookie: true,
293
+ consoleUrl: XIAOMI_CONSOLE_URL,
294
+ },
295
+ 'xiaomi-token-plan-sgp': {
296
+ kind: 'windows',
297
+ label: 'Xiaomi MiMo (SGP)',
298
+ shortLabel: 'MiMo-S',
299
+ baseURL: 'https://token-plan-sgp.xiaomimimo.com',
300
+ quotaBaseURL: XIAOMI_QUOTA_BASE_URL,
301
+ consoleCookieRef: 'XIAOMI_MIMO_COOKIE',
302
+ apiKeyRef: 'XIAOMI_MIMO_COOKIE',
303
+ supportsConsoleCookie: true,
304
+ consoleUrl: XIAOMI_CONSOLE_URL,
305
+ },
258
306
  hunyuan: {
259
307
  kind: 'balance',
260
308
  label: '腾讯混元',
@@ -1000,12 +1048,126 @@ export function parseOpenDesignSummary(payload) {
1000
1048
  }
1001
1049
  }
1002
1050
 
1051
+ /**
1052
+ * The Xiaomi MiMo "Token Plan" adapter (v1.6.5).
1053
+ *
1054
+ * Reads the plan's real usage from Xiaomi's console host, which authenticates
1055
+ * with browser session cookies only — the inference key answers 401 with a
1056
+ * `loginUrl`, so a "key" cannot be used here at all. The cookie is held in the
1057
+ * same per-provider secret slot Open Design uses for its Control Key.
1058
+ *
1059
+ * Three regions (`cn` / `ams` / `sgp`) share this one console, so they share the
1060
+ * adapter and differ only in their inference `baseURL`.
1061
+ */
1062
+ const xiaomiTokenPlanAdapter = {
1063
+ kind: 'windows',
1064
+ async read({ providerId = 'xiaomi-token-plan-cn', provider, httpJson, now }) {
1065
+ const config = providerConfig(providerId, provider)
1066
+ const rawCookie = provider?.consoleCookie
1067
+ const cookie = normalizeCookie(rawCookie)
1068
+ if (cookie === '') {
1069
+ // A `missing-key` hides the chip by protocol, but the client keeps a
1070
+ // 「仅本机统计」 entry for adapter-less providers and for a configured one the
1071
+ // management page carries the guidance — so this stays actionable.
1072
+ throw new UsageError(
1073
+ 'missing-key',
1074
+ 'Xiaomi MiMo plan usage needs the platform.xiaomimimo.com CONSOLE COOKIE (env XIAOMI_MIMO_COOKIE); ' +
1075
+ 'the inference key cannot read the quota API. Copy the whole Cookie header from the console devtools, ' +
1076
+ 'and note that Xiaomi expires it after about 24 hours.',
1077
+ { retryable: false },
1078
+ )
1079
+ }
1080
+ const quotaBase = String(config.quotaBaseURL ?? XIAOMI_QUOTA_BASE_URL).trim() || XIAOMI_QUOTA_BASE_URL
1081
+ const headers = { accept: 'application/json', cookie }
1082
+ const readJson = async (path) => {
1083
+ try {
1084
+ return await httpJson(joinUrl(quotaBase, path), { method: 'GET', headers })
1085
+ } catch (error) {
1086
+ // The console answers 401 + a loginUrl body when the session lapsed; that
1087
+ // is an expired credential, never a transient network fault.
1088
+ if (error instanceof UsageError && (error.code === 'unauthorized' || error.code === 'forbidden')) {
1089
+ throw new UsageError(
1090
+ 'unauthorized',
1091
+ 'the Xiaomi MiMo console session has expired (its cookie lasts about 24 hours); ' +
1092
+ 'open platform.xiaomimimo.com, copy a fresh Cookie header and paste it into the plugin management page',
1093
+ { retryable: false },
1094
+ )
1095
+ }
1096
+ throw error
1097
+ }
1098
+ }
1099
+
1100
+ const usage = await readJson(XIAOMI_QUOTA_PATHS.usage)
1101
+ if (isLoginRequired(usage)) {
1102
+ throw new UsageError(
1103
+ 'unauthorized',
1104
+ 'the Xiaomi MiMo console session has expired (its cookie lasts about 24 hours); ' +
1105
+ 'open platform.xiaomimimo.com, copy a fresh Cookie header and paste it into the plugin management page',
1106
+ { retryable: false },
1107
+ )
1108
+ }
1109
+ const parsedUsage = parseTokenPlanUsage(usage)
1110
+ // `detail` and `balance` only decorate the read: a plan-period end or a wallet
1111
+ // amount is not worth failing a usable quota for.
1112
+ const detail = await readJson(XIAOMI_QUOTA_PATHS.detail).catch(() => undefined)
1113
+ const detailParsed =
1114
+ detail === undefined
1115
+ ? { planCode: '', resetsAt: null, expired: false }
1116
+ : parseTokenPlanDetail(detail, now)
1117
+ const walletPayload = await readJson(XIAOMI_QUOTA_PATHS.balance).catch(() => undefined)
1118
+ const balance = walletPayload === undefined ? null : parseAccountBalance(walletPayload)
1119
+
1120
+ const { used, limit, percent } = parsedUsage.plan
1121
+ if (used === null && limit === null) {
1122
+ throw new UsageError(
1123
+ 'parse',
1124
+ 'the Xiaomi MiMo quota response carried no recognised token allowance' +
1125
+ (parsedUsage.contract.length > 0 ? ` (fields seen: ${parsedUsage.contract.join(', ')})` : ''),
1126
+ { retryable: false },
1127
+ )
1128
+ }
1129
+ const computed = percent !== null ? percent : usedPercent(used, limit)
1130
+ // The month bucket, when Xiaomi reports one, is the better monthly window.
1131
+ const monthWindow =
1132
+ parsedUsage.month !== null && (parsedUsage.month.used !== null || parsedUsage.month.limit !== null)
1133
+ ? parsedUsage.month
1134
+ : null
1135
+ const monthPercent =
1136
+ monthWindow === null ? null : monthWindow.percent !== null ? monthWindow.percent : usedPercent(monthWindow.used, monthWindow.limit)
1137
+
1138
+ return {
1139
+ kind: 'windows',
1140
+ plan: detailParsed.planCode === '' ? null : { id: detailParsed.planCode.toLowerCase(), label: detailParsed.planCode },
1141
+ windows: windowSet(
1142
+ {
1143
+ monthly: {
1144
+ percent: monthPercent === null ? computed : monthPercent,
1145
+ status: detailParsed.expired === true ? 'unknown' : 'ok',
1146
+ resetsAt: detailParsed.resetsAt,
1147
+ used: monthWindow === null ? used : monthWindow.used,
1148
+ cap: monthWindow === null ? limit : monthWindow.limit,
1149
+ unit: 'tokens',
1150
+ },
1151
+ },
1152
+ now,
1153
+ ),
1154
+ balance,
1155
+ }
1156
+ },
1157
+ }
1158
+
1003
1159
  /**
1004
1160
  * The provider adapter table. `read()` performs the required network reads and
1005
1161
  * returns the normalized result; optional reads (subscription/whoami) are
1006
1162
  * tolerated when they fail, because the usage itself is still valid.
1007
1163
  */
1008
1164
  export const ADAPTERS = {
1165
+ // v1.6.5: three regional Xiaomi MiMo Token Plan endpoints, one shared console
1166
+ // adapter (they differ only in their inference baseURL, which lives in
1167
+ // PROVIDER_DEFAULTS).
1168
+ 'xiaomi-token-plan-cn': xiaomiTokenPlanAdapter,
1169
+ 'xiaomi-token-plan-ams': xiaomiTokenPlanAdapter,
1170
+ 'xiaomi-token-plan-sgp': xiaomiTokenPlanAdapter,
1009
1171
  volcengine: {
1010
1172
  kind: 'windows',
1011
1173
  async read({ provider, fetchJson, httpJson, now }) {
@@ -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.2",
3
+ "version": "1.6.5",
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",