dsh-tokenlens 1.6.4 → 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 +13 -0
- package/PROTOCOL.md +36 -4
- package/README.en.md +2 -2
- package/README.md +7 -3
- package/client.js +74 -8
- package/index.js +39 -1
- package/lib/providers.mjs +162 -0
- package/lib/xiaomi-token-plan.mjs +267 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,19 @@
|
|
|
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
|
+
|
|
7
20
|
## 1.6.4 — 2026-11-04
|
|
8
21
|
|
|
9
22
|
(当日已发布版本:v1.6.2 / v1.6.4。v1.6.1 的混合模型修复未单独发版,已并入本节)
|
package/PROTOCOL.md
CHANGED
|
@@ -439,10 +439,19 @@ with `[path]`.
|
|
|
439
439
|
|
|
440
440
|
Client behaviour:
|
|
441
441
|
|
|
442
|
-
- `missing-key`, `
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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),
|
|
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
|
|
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**;内置
|
|
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
|
-
-
|
|
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`:**
|
|
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,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
|
-
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
:
|
|
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.
|
|
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 ?? '' },
|
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.
|
|
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",
|