dsh-tokenlens 1.3.8 → 1.4.0

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,18 +4,23 @@
4
4
 
5
5
  **版本记录约定**:同一发布日只保留一个章节,标题取当日**最后一个**已发布版本号;当日多次发布的改动合并到该章节,并在标题下注明当日实际发布过的版本号(npm 上仍可安装这些独立版本)。若当日已有发布,后续小改动不再单独增加版本号,而是并入下一篇日志。这样日志按天规整、不出现同日多个小版本。
6
6
 
7
- ## 1.3.8 — 2026-11-02
7
+ ## 1.4.0 — 2026-11-02
8
8
 
9
- (当日已发布版本:v1.3.5 / v1.3.6 / v1.3.7 / v1.3.8)
9
+ (当日已发布版本:v1.3.5 / v1.3.6 / v1.3.7 / v1.3.8 / v1.3.9 / v1.4.0)
10
10
 
11
11
  - feat(ui): 供应商管理页可为 **Open Design** 填写「工作区 ID」并持久化到插件配置——此前 workspaceId 只能在 profile patch 或 `OPENDESIGN_WORKSPACE_ID` 里设置,用推理 Key 配好供应商后额度卡片仍报错却无处可改
12
+ - feat(ui): **Control Key 也能在弹窗里直接填**——Open Design 卡片新增密码型输入框,粘贴保存即写入插件自己的 store;输入框是只写的(Host 只回 `hasKey` / `keySource`,secret 永不下发浏览器),保存成功后自动清空并改用「已保存;要替换请重新粘贴」占位提示。小白用户不再需要手改 `.credentials.yaml`
13
+ - feat(ui): 「工作区 ID」输入框支持**直接粘贴整条控制台地址**——`https://open-design.ai/cloud/dashboard?workspaceId=ws_x&tab=billing` 会自动提取出 `ws_x`,避免用户复制整条 URL 后被校验拒绝
14
+ - **fix(open-design): 修掉「配好了仍报错」**——额度窗口原来只查 `billing/coding-plan-usage`,而那条路由由控制台的浏览器 Cookie 会话鉴权,**任何 Control Key 都会被 401**(控制台前端自己也走 cookie)。现在改用**同样接受 Control Key** 的 `billing/funding-check`,Design Plan 的 5 小时 / 7 天窗口正常读出
15
+ - fix(open-design): 改为钱包、窗口、套餐三层**互相独立降级**——先读 `wallet/balance`(余额)与 `billing/summary`(套餐档位,如 GO / PLUS),窗口再由 funding-check 补齐;任一路由不可用只少一块数据,不再整条报错。旧版会把 quota 的 401 当成整体失败,导致钱包也读不到
16
+ - feat(wire): `billing/summary` 的 `membershipTier` 映射为 `plan`(`GO` / `PLUS` / `PRO` / `MAX`),弹窗标题行显示「套餐:GO」
12
17
  - feat(ui): Open Design 额度失败时的引导——详情弹窗的错误条说明「额度查询需要工作区 ID 与 Control Key(不是推理 Key)」,并提供「去配置」按钮直接跳到供应商管理页
13
18
  - feat(host): Host 侧错误文案更具体(区分缺 workspaceId / 缺 Control Key,并指向管理页);`providerUpdate` 接受 `workspaceId`,校验为 1–120 位标识符字符(字母、数字、`. _ : @ -`),路径穿越/空格/超长一律拒绝
14
19
  - feat(wire): `providers[]` 仅对声明了 workspace 的适配器(Open Design)下发 `workspaceId` 字段;其余供应商不下发,客户端据此决定是否渲染输入框(旧 Host 无此字段 ⇒ 不渲染,自然降级)
15
20
  - feat(ui): Open Design 缺配置时 chip 不再按 `missing-key` 静默隐藏,而是显示「待配置」并可点进详情看引导——否则用户既看不到提示也没有入口
16
21
  - fix(tool): Agent 工具 `tokenlens_usage` 的 `updateCheck` 动作此前虽在枚举里却会落到用量读取分支,返回当前供应商的用量或错误;现在正确执行更新检查并返回 `UpdateInfo`
17
22
  - docs: 中英 README 补充 **workspace ID 与 Control Key 到底在哪里取**——控制台的「套餐与余额」页没有独立的 ID 字段,workspace ID 取自地址栏的 `?workspaceId=`(成员邀请预览页也会显示「工作空间 ID」),而 `~/.amr/config.json` 只存 `controlKey` / `runtimeKey` / `apiUrl` / `linkUrl`、不含 workspace ID;Control Key 是桌面端 / `vela login` 写入该文件的 `profiles.prod.controlKey`,用推理 Key 冒充会得到 401
18
- - 测试:280 → 286(workspace ID 校验/持久化/清空、管理页字段渲染与保存、缺 workspaceId 的 Host 报错与引导按钮、待配置 chip、工具 updateCheck 分发)
23
+ - 测试:280 → 292(workspace ID 校验/持久化/清空与 URL 提取、Control Key 只写保存与不回声、funding-check 嵌套文档解析、窗口不可用时的钱包降级、summary 缺失时不臆造档位、待配置 chip、工具 updateCheck 分发)
19
24
 
20
25
  ## 1.3.4 — 2026-11-01
21
26
 
package/PROTOCOL.md CHANGED
@@ -116,10 +116,27 @@ row at the end of the capacity card and appends the amount to the chip tooltip.
116
116
  **workspace id** (the console URL's `?workspaceId=` value) and a **Control Key**
117
117
  (the `profiles.prod.controlKey` the desktop app / `vela login` writes to
118
118
  `~/.amr/config.json`). The inference key from the Cloud API Keys page answers
119
- HTTP 401 on the billing endpoints. Missing either value is a `missing-key` error;
120
- the workspace id may be set from the management page (persisted in
121
- `providers.json`, v1.3.5) and the Control Key comes from `OPENDESIGN_CONTROL_KEY`,
122
- `VELA_CONTROL_KEY`, the credential store, or that page's key field.
119
+ HTTP 401 on the billing endpoints. Missing either value is a `missing-key` error.
120
+ Since v1.3.9 both may be entered on the management page: `workspaceId` is
121
+ persisted in `providers.json`, and the Control Key is written through the same
122
+ `apiKey` override field the store already supported (write-only — the Host never
123
+ returns it, only `hasKey`/`keySource`). The environment/credential sources
124
+ (`OPENDESIGN_CONTROL_KEY`, `VELA_CONTROL_KEY`, `OPENDESIGN_WORKSPACE_ID`) still
125
+ work and the page override wins.
126
+
127
+ Reads, in independent layers (v1.4.0) — each may fail without voiding the others:
128
+
129
+ | read | path | provides |
130
+ |---|---|---|
131
+ | wallet | `GET /api/v1/workspaces/{id}/wallet/balance` | `balance` (USD, authoritative) |
132
+ | plan windows | `GET /api/v1/workspaces/{id}/billing/funding-check` | `windows` from its nested `codingPlan` (`eligible`, `tier`, 5h/7d credit windows) |
133
+ | plan tier | `GET /api/v1/billing/summary` | `plan` from `membershipTier` (`go` → `GO`) |
134
+
135
+ `billing/coding-plan-usage` carries the same plan document at the top level but is
136
+ served by the console's **cookie-authenticated** route: a Control Key always gets
137
+ HTTP 401 there (the console UI itself authenticates with a session cookie), so it
138
+ is only a fallback. The wallet is therefore always read first; the plan endpoint
139
+ must not turn a readable balance into a hard error.
123
140
 
124
141
  Rules:
125
142
 
package/README.en.md CHANGED
@@ -114,6 +114,8 @@ Open Design (OpenDesign) is the exception: its read-only billing API needs a **w
114
114
  - *Workspace ID* — the console has **no dedicated field for it**; it appears in the address bar: open 套餐与余额 or 成员 and read `open-design.ai/cloud/dashboard?workspaceId=<the id>`. A member-invite preview page also lists it as 「工作空间 ID」. `~/.amr/config.json` stores only the keys/URLs, not the workspace id.
115
115
  - *Control Key* — a session credential that the Open Design desktop app / `vela` CLI writes to `~/.amr/config.json` when you sign in (`profiles.prod.controlKey`). Sign in once with the desktop app, or run `vela login` and approve the browser prompt, then copy that value (or point the plugin at it). Pasting the inference key instead makes the quota call answer HTTP 401.
116
116
 
117
+ Both values can be entered right in the plugin (simplest for non-technical users): usage dialog → 管理 → 供应商配置 → expand **Open Design**. The workspace-ID field also accepts a whole pasted console URL (`...?workspaceId=xxx` is extracted), and the Control Key field is a write-only password input — saving it stores the secret in the plugin's own store, and the Host never echoes it back (only `hasKey`). Press 测试连接 afterwards. Alternatively put `OPENDESIGN_CONTROL_KEY` in `$DSH_HOME/.credentials.yaml` `refs:` (or the environment) and the workspace id in `OPENDESIGN_WORKSPACE_ID`.
118
+
117
119
  See the [Chinese README](README.md) for the full configuration reference, preview and provider tables.
118
120
 
119
121
  ## Supported providers and harnesses
package/README.md CHANGED
@@ -231,8 +231,11 @@ Control Key 是 **Open Design 桌面端/CLI 登录时写入本机的会话凭据
231
231
 
232
232
  拿到后两种放法:
233
233
 
234
- - 写进 `$DSH_HOME/.credentials.yaml` 的 `refs:` 下:`OPENDESIGN_CONTROL_KEY: <control key>`(或环境变量 `OPENDESIGN_CONTROL_KEY` / `VELA_CONTROL_KEY`);
235
- - workspace ID 在插件的[供应商管理页](#供应商管理) Open Design 卡片里填写,或用 `OPENDESIGN_WORKSPACE_ID` 环境变量。
234
+ - **最简单(推荐给不折腾的用户)**:全部在插件里填——用量弹窗 →「管理」→「供应商配置」→ 展开 **Open Design**:
235
+ - 「工作区 ID」:直接粘贴控制台地址整条也行(`...?workspaceId=xxx` 会自动提取出 ID);
236
+ - 「Control Key」:粘贴后保存,输入框是密码型、保存后不再显示(Host 只回 `hasKey`,secret 永不下发浏览器)。
237
+ 保存后点「测试连接」,出现 5 小时 / 7 天百分比与钱包余额即成功。
238
+ - **或写配置文件**:Control Key 写进 `$DSH_HOME/.credentials.yaml` 的 `refs:` 下 `OPENDESIGN_CONTROL_KEY: <control key>`(或环境变量 `OPENDESIGN_CONTROL_KEY` / `VELA_CONTROL_KEY`);workspace ID 用 `OPENDESIGN_WORKSPACE_ID` 环境变量或 profile 配置。
236
239
 
237
240
  > 这也解释了最容易踩的坑:**用推理 Key 填 Control Key** 会让额度查询返回 401。插件已经区分这两种凭据,缺哪一项都会指名道姓地提示。
238
241
 
@@ -318,11 +321,11 @@ Control Key 是 **Open Design 桌面端/CLI 登录时写入本机的会话凭据
318
321
  | `minimax` | `GET https://api.minimax.io/v1/api/openplatform/coding_plan/remains`(订阅 Key;国内站 `api.minimaxi.com`) | 5 小时 / 每周 已用百分比(best-effort) |
319
322
  | `zhipu-cn` | `GET https://open.bigmodel.cn/api/monitor/usage/quota/limit`(`ZHIPU_API_KEY`) | GLM Coding Plan:5 小时 / 本周 / 每月 百分比 + 方案 |
320
323
  | `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 小时 / 本周 / 每月 百分比 + 档位 |
321
- | `open-design` | 只读计费接口:`GET https://amr-api.open-design.ai/api/v1/workspaces/{workspaceId}/billing/coding-plan-usage` + `…/wallet/balance`(workspace ID + Control Key:`OPENDESIGN_CONTROL_KEY` / `VELA_CONTROL_KEY`,非推理 Key) | Design Plan 的 5 小时 / 7 天 已用百分比 + 重置时间;钱包余额(USD) |
324
+ | `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) |
322
325
 
323
326
  - OpenCode 的额度定义见[官方文档](https://opencode.ai/docs/go/#usage-limits)(5h = 月度额度的 20%、周 = 50%、月 = 100%);接口只返回百分比,插件也只画百分比。
324
327
  - Command Code 的 `/alpha/*` 是官方 CLI 自用的私有计费路由;免费 / 无方案账户返回空窗口(显示 `—`),插件不会用方案目录猜额度。
325
- - Open Design 的窗口按账户实际返回的时长渲染:Go 同时有 5 小时和 7 天窗口,Plus / Pro / Max 只有 7 天窗口;钱包余额是独立于窗口的 USD 额度,用尽后继续扣钱包。查询要求工作区权限,鉴权失败会明确显示错误而不是 0;工作区 ID 与 Control Key 缺任一项时,弹窗会指出缺哪一项并给出「去配置」入口(v1.3.5)。
328
+ - Open Design 的窗口按账户实际返回的时长渲染:Go 同时有 5 小时和 7 天窗口,Plus / Pro / Max 只有 7 天窗口;钱包余额是独立于窗口的 USD 额度,用尽后继续扣钱包。查询要求工作区权限,鉴权失败会明确显示错误而不是 0;工作区 ID 与 Control Key 缺任一项时,弹窗会指出缺哪一项并给出「去配置」入口(v1.3.5)。窗口、余额、套餐档位是**三层独立读取**(v1.4.0):任一接口不可用时只少那一块数据,不会整套报错。
326
329
  - 未配置凭据的供应商 chip 静默隐藏;`zai`、`minimax`、`deepinfra` 属 best-effort 适配(未公开接口,失败时按可重试错误优雅降级)。
327
330
  - 各供应商的元数据(`kind` / `label` / `shortLabel` / `baseURL` / `apiKeyRef` / `consoleUrl`)集中在 `lib/providers.mjs` 的 `PROVIDER_DEFAULTS` 与 `ADAPTERS` 中,新增适配器只需在该文件登记并补测试。
328
331
 
@@ -434,7 +437,7 @@ Host 半注册 `tokenlens_usage`,参数 `action`(默认 `snapshot`)与可
434
437
 
435
438
  ## 验证
436
439
 
437
- - `node --test`:**286 个自动化用例**,覆盖 Host 数据层、供应商适配器、统计折叠、各 harness 读取、插件集成与浏览器端渲染 / 交互。
440
+ - `node --test`:**292 个自动化用例**,覆盖 Host 数据层、供应商适配器、统计折叠、各 harness 读取、插件集成与浏览器端渲染 / 交互。
438
441
  - 在真实环境中验证过 **5 个 harness** 的数据源;本机统计与独立的 SQLite 查询、独立的日志解析逐项对账(差额为 0),跨 harness 合并与供应商映射算术自洽。
439
442
  - 供应商接口按各官方文档与参考客户端行为验证;未配置凭据时 chip 静默隐藏,接口异常时按可重试错误降级。
440
443
 
package/client.js CHANGED
@@ -331,8 +331,12 @@ window.__ModuleLoader__.load({
331
331
  providerConsoleURLPlaceholder: '留空则使用内置默认地址',
332
332
  providerWorkspaceId: '工作区 ID(Workspace ID)',
333
333
  providerWorkspaceIdHint: 'Open Design Cloud 工作区标识;额度查询需要',
334
- providerWorkspaceIdPlaceholder: '留空则使用环境变量 OPENDESIGN_WORKSPACE_ID',
334
+ providerWorkspaceIdPlaceholder: '粘贴工作区 ID,或直接粘贴控制台地址(自动提取)',
335
335
  providerWorkspaceIdTooLong: '工作区 ID 最多 120 个字符',
336
+ providerControlKey: 'Control Key',
337
+ providerControlKeyHint: '本机 Open Design 登录后写在 ~/.amr/config.json 的 profiles.prod.controlKey;不是 Cloud API Keys 页的推理 Key',
338
+ providerControlKeyPlaceholder: '粘贴 Control Key(保存后不再显示)',
339
+ providerControlKeySaved: '已保存;要替换请重新粘贴',
336
340
  configureProvider: '去配置',
337
341
  errorOpenDesignConfig: 'Open Design 额度查询需要工作区 ID 与 Control Key(不是推理 Key)',
338
342
  providerSave: '保存',
@@ -582,8 +586,12 @@ window.__ModuleLoader__.load({
582
586
  providerConsoleURL: 'Console URL',
583
587
  providerWorkspaceId: 'Workspace ID',
584
588
  providerWorkspaceIdHint: 'The Open Design Cloud workspace identifier the quota query is scoped to',
585
- providerWorkspaceIdPlaceholder: 'Empty uses the OPENDESIGN_WORKSPACE_ID environment variable',
589
+ providerWorkspaceIdPlaceholder: 'Paste the workspace ID, or the console URL (it is extracted)',
586
590
  providerWorkspaceIdTooLong: 'The workspace ID may be at most 120 characters',
591
+ providerControlKey: 'Control Key',
592
+ 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',
593
+ providerControlKeyPlaceholder: 'Paste the Control Key (never shown again after saving)',
594
+ providerControlKeySaved: 'Saved; paste again to replace it',
587
595
  configureProvider: 'Configure',
588
596
  errorOpenDesignConfig: 'Open Design quota needs a workspace ID and a Control Key (not an inference key)',
589
597
  providerConsoleURLPlaceholder: 'Leave empty for the built-in default',
@@ -2329,6 +2337,10 @@ window.__ModuleLoader__.load({
2329
2337
  const clearedDraft = hasField('consoleUrl')
2330
2338
  const resetNames = !clearedDraft && (hasField('shortLabel') || hasField('label'))
2331
2339
  const resetWorkspace = hasField('workspaceId')
2340
+ // v1.3.9: a saved secret is never echoed back, so the typed value is
2341
+ // dropped after the Host confirms it (the placeholder then reports
2342
+ // "already saved" through the answered hasKey flag).
2343
+ const resetSecret = hasField('apiKey')
2332
2344
  const rowTest = { ...current.rowTest }
2333
2345
  delete rowTest[providerId]
2334
2346
  // Adopt the answered list; a Host that omits it must not blank the page.
@@ -2336,7 +2348,7 @@ window.__ModuleLoader__.load({
2336
2348
  let nextDrafts = current.drafts
2337
2349
  if (clearedDraft) {
2338
2350
  nextDrafts = mapWithout(current.drafts, providerId)
2339
- } else if (resetNames || resetWorkspace) {
2351
+ } else if (resetNames || resetWorkspace || resetSecret) {
2340
2352
  const draft = current.drafts[providerId]
2341
2353
  const answered = adoptsList
2342
2354
  ? parsed.providers.find((entry) => entry.providerId === providerId)
@@ -2355,6 +2367,7 @@ window.__ModuleLoader__.load({
2355
2367
  ? answered.workspaceId
2356
2368
  : draft.workspaceId
2357
2369
  }
2370
+ if (resetSecret) next.apiKey = ''
2358
2371
  nextDrafts = mapWith(current.drafts, providerId, next)
2359
2372
  }
2360
2373
  }
@@ -2843,6 +2856,9 @@ window.__ModuleLoader__.load({
2843
2856
  shortLabel: provider.shortLabel,
2844
2857
  label: provider.label,
2845
2858
  workspaceId: typeof provider.workspaceId === 'string' ? provider.workspaceId : '',
2859
+ // A secret is never seeded from the wire: it starts empty and only
2860
+ // carries what the user just typed (v1.3.9).
2861
+ apiKey: '',
2846
2862
  }
2847
2863
  }
2848
2864
 
@@ -2869,6 +2885,10 @@ window.__ModuleLoader__.load({
2869
2885
  const wireWorkspaceId = typeof provider.workspaceId === 'string' ? provider.workspaceId : ''
2870
2886
  const workspaceId = typeof source.workspaceId === 'string' ? source.workspaceId.trim() : wireWorkspaceId
2871
2887
  if (workspaceId !== wireWorkspaceId) update.workspaceId = workspaceId
2888
+ // v1.3.9: the Control Key is write-only. A typed value is sent once; an
2889
+ // empty draft means "leave the stored secret alone", never "clear it".
2890
+ const apiKey = typeof source.apiKey === 'string' ? source.apiKey.trim() : ''
2891
+ if (apiKey !== '') update.apiKey = apiKey
2872
2892
  }
2873
2893
  return update
2874
2894
  }
@@ -2893,21 +2913,43 @@ window.__ModuleLoader__.load({
2893
2913
  shortLabel: provider.shortLabel,
2894
2914
  label: provider.label,
2895
2915
  workspaceId: typeof provider.workspaceId === 'string' ? provider.workspaceId : '',
2916
+ apiKey: '',
2896
2917
  }),
2897
2918
  })
2898
2919
  }
2899
2920
 
2900
- /** Record one typed editor value (names, the display-only console URL, the workspace id). */
2921
+ /**
2922
+ * Accept a pasted console URL and pull the workspace id out of it (v1.3.9):
2923
+ * beginners copy the whole address bar, so `…?workspaceId=abc&x=1` must
2924
+ * reduce to `abc` instead of being rejected for containing slashes.
2925
+ */
2926
+ function extractWorkspaceId(raw) {
2927
+ const text = typeof raw === 'string' ? raw.trim() : ''
2928
+ if (text === '') return ''
2929
+ const match = /[?&]workspaceId=([^&#\s]+)/i.exec(text)
2930
+ if (match === null) return text
2931
+ try {
2932
+ return decodeURIComponent(match[1])
2933
+ } catch {
2934
+ return match[1]
2935
+ }
2936
+ }
2937
+
2938
+ /** Record one typed editor value (names, the display-only console URL, the workspace id, a typed secret). */
2901
2939
  function updateProviderDraft(provider, field, value) {
2902
- if (field !== 'consoleUrl' && field !== 'shortLabel' && field !== 'label' && field !== 'workspaceId') return
2903
- if (field === 'workspaceId' && provider.supportsWorkspaceId !== true) return
2940
+ if (field !== 'consoleUrl' && field !== 'shortLabel' && field !== 'label' && field !== 'workspaceId' && field !== 'apiKey') {
2941
+ return
2942
+ }
2943
+ if ((field === 'workspaceId' || field === 'apiKey') && provider.supportsWorkspaceId !== true) return
2904
2944
  if (provider === null || provider === undefined) return
2905
2945
  const current = store.get().management
2906
2946
  const draft = providerDraftOf(provider, current)
2907
2947
  patchManagement({
2908
2948
  drafts: mapWith(current.drafts, provider.providerId, {
2909
2949
  ...draft,
2910
- [field]: typeof value === 'string' ? value : '',
2950
+ // v1.3.9: a pasted console URL is reduced to its workspace id as the
2951
+ // user types, so the save never posts a URL or gets rejected.
2952
+ [field]: field === 'workspaceId' ? extractWorkspaceId(value) : typeof value === 'string' ? value : '',
2911
2953
  }),
2912
2954
  })
2913
2955
  }
@@ -5943,6 +5985,43 @@ window.__ModuleLoader__.load({
5943
5985
  return (typeof value === 'string' ? value.trim() : '').length
5944
5986
  }
5945
5987
 
5988
+ /**
5989
+ * One write-only credential field (v1.3.9).
5990
+ *
5991
+ * The Host never sends a stored secret back, so the input always starts empty
5992
+ * and its placeholder reports whether a value is already saved. Typing a new
5993
+ * value stages `{apiKey}` on the draft; the save button then sends it once,
5994
+ * and a successful save clears it again (the Host keeps the secret).
5995
+ */
5996
+ function buildProviderSecretField(provider, management, t, options) {
5997
+ const providerId = provider.providerId
5998
+ const draft = providerDraftOf(provider, management)
5999
+ const disabled = management.busy[providerId] !== undefined
6000
+ const value = typeof draft[options.field] === 'string' ? draft[options.field] : ''
6001
+ return h(
6002
+ 'div',
6003
+ { key: options.key, className: 'dsh-tokenlens-field' },
6004
+ h(
6005
+ 'div',
6006
+ { className: 'dsh-tokenlens-field-head' },
6007
+ h('span', { className: 'dsh-tokenlens-field-label' }, options.label),
6008
+ h('span', { className: 'dsh-tokenlens-field-hint' }, options.hint),
6009
+ ),
6010
+ h('input', {
6011
+ type: 'password',
6012
+ className: `dsh-tokenlens-input ${options.className}`,
6013
+ [options.dataAttr]: providerId,
6014
+ value,
6015
+ placeholder: options.placeholder,
6016
+ disabled,
6017
+ autoComplete: 'off',
6018
+ spellCheck: false,
6019
+ 'aria-label': options.label,
6020
+ onChange: (event) => updateProviderDraft(provider, options.field, event?.target?.value),
6021
+ }),
6022
+ )
6023
+ }
6024
+
5946
6025
  /**
5947
6026
  * One editable name field (v0.12.0): a text input capped at the Host limit,
5948
6027
  * with a permanent limit hint and an inline error while the draft is longer
@@ -6072,6 +6151,20 @@ window.__ModuleLoader__.load({
6072
6151
  : buildDshUnmanagedRow(provider, t)
6073
6152
  const fields = [
6074
6153
  sourceRow,
6154
+ // v1.3.9: Open Design's Control Key cannot come from the DSH model
6155
+ // settings (that route holds the inference key, which the billing API
6156
+ // rejects), so this one provider gets a write-only credential input.
6157
+ provider.supportsWorkspaceId === true
6158
+ ? buildProviderSecretField(provider, management, t, {
6159
+ key: 'apiKey',
6160
+ className: 'dsh-tokenlens-controlkey-input',
6161
+ dataAttr: 'data-provider-controlkey',
6162
+ field: 'apiKey',
6163
+ placeholder: provider.hasKey === true ? t('providerControlKeySaved') : t('providerControlKeyPlaceholder'),
6164
+ label: t('providerControlKey'),
6165
+ hint: t('providerControlKeyHint'),
6166
+ })
6167
+ : null,
6075
6168
  buildProviderNameField(provider, management, t, {
6076
6169
  key: 'shortLabel',
6077
6170
  className: 'dsh-tokenlens-shortlabel-input',
package/index.js CHANGED
@@ -76,7 +76,7 @@ export const inject = []
76
76
  export const PLUGIN_NAME = 'dsh-tokenlens'
77
77
 
78
78
  /** Plugin version; mirrors package.json (kept here so diagnostics need no fs read). */
79
- export const PLUGIN_VERSION = '1.3.8'
79
+ export const PLUGIN_VERSION = '1.4.0'
80
80
 
81
81
  /** Documented defaults; every field of the raw row config is optional. */
82
82
  const DEFAULTS = {
package/lib/providers.mjs CHANGED
@@ -243,6 +243,14 @@ export const PROVIDER_DEFAULTS = {
243
243
  // The read-only billing API is scoped to one workspace and authenticated
244
244
  // with a Control Key (not the inference API key used for model calls).
245
245
  workspaceId: '',
246
+ // The Design Plan windows live behind a cookie-authenticated console route
247
+ // (`billing/coding-plan-usage` answers 401 to a Control Key). The same plan
248
+ // document is served by `billing/funding-check`, which DOES accept the
249
+ // Control Key, so that is the quota source here.
250
+ usagePath: '/api/v1/workspaces/{workspaceId}/billing/funding-check',
251
+ // The account-scoped read that carries the plan tier when the quota route is
252
+ // unavailable.
253
+ summaryPath: '/api/v1/billing/summary',
246
254
  controlKeyRef: 'OPENDESIGN_CONTROL_KEY',
247
255
  apiKeyRef: 'OPENDESIGN_CONTROL_KEY',
248
256
  consoleUrl: 'https://open-design.ai/cloud/dashboard',
@@ -786,13 +794,21 @@ function openDesignCredits(value) {
786
794
  * and repeated durations keep only the first canonical window.
787
795
  */
788
796
  export function parseOpenDesignUsage(payload, workspaceId, now = Date.now()) {
789
- const document = payload !== null && typeof payload === 'object' && !Array.isArray(payload) ? payload : null
790
- if (document === null) throw new UsageError('parse', 'the OpenDesign quota response is not an object')
797
+ const outer = payload !== null && typeof payload === 'object' && !Array.isArray(payload) ? payload : null
798
+ if (outer === null) throw new UsageError('parse', 'the OpenDesign quota response is not an object')
799
+ // Two documents carry the same plan shape: the console route answers it at the
800
+ // top level, while `billing/funding-check` nests it under `codingPlan`.
801
+ const nested = outer.codingPlan !== null && typeof outer.codingPlan === 'object' && !Array.isArray(outer.codingPlan)
802
+ const document = nested ? outer.codingPlan : outer
803
+ // funding-check omits `eligible` when there is no plan (its `funding` field
804
+ // then reports `wallet`). Read a missing flag on the nested document as "not
805
+ // eligible" instead of a parse failure, without mutating the input.
806
+ const eligible = document.eligible === undefined && nested ? false : document.eligible
791
807
  const expected = String(workspaceId ?? '').trim()
792
808
  if (typeof document.workspaceId !== 'string' || document.workspaceId !== expected) {
793
809
  throw new UsageError('parse', 'the OpenDesign quota response belongs to a different workspace')
794
810
  }
795
- if (typeof document.eligible !== 'boolean') {
811
+ if (typeof eligible !== 'boolean') {
796
812
  throw new UsageError('parse', 'the OpenDesign quota response has no eligibility flag')
797
813
  }
798
814
  if (!Array.isArray(document.windows) || document.windows.length > 32) {
@@ -831,7 +847,7 @@ export function parseOpenDesignUsage(payload, workspaceId, now = Date.now()) {
831
847
  }
832
848
  const windows = windowSet(inputs, now)
833
849
  const usable = WINDOW_IDS.some((id) => windows[id] !== null)
834
- if (document.eligible === false) {
850
+ if (eligible === false) {
835
851
  if (usable) throw new UsageError('parse', 'the OpenDesign quota response reports an ineligible plan with windows')
836
852
  return { windows: windowSet({}, now), plan: null }
837
853
  }
@@ -870,6 +886,23 @@ export function parseOpenDesignWallet(payload, workspaceId) {
870
886
  }
871
887
  }
872
888
 
889
+ /**
890
+ * Parse the OpenDesign account-scoped `billing/summary` document (v1.4.0).
891
+ *
892
+ * Only the plan tier is surfaced: credit buckets use a Command-Code-specific
893
+ * wire shape (remaining/granted/purchased) that OpenDesign's
894
+ * subscription/recharge split does not map onto, and the wallet already carries
895
+ * the spendable amount. Everything is optional — a missing tier is null, never
896
+ * invented.
897
+ */
898
+ export function parseOpenDesignSummary(payload) {
899
+ const document = payload !== null && typeof payload === 'object' && !Array.isArray(payload) ? payload : null
900
+ if (document === null) return { plan: null }
901
+ const tier = typeof document.membershipTier === 'string' ? document.membershipTier.trim() : ''
902
+ const plan = tier === '' ? null : { id: tier, label: tier.toUpperCase() }
903
+ return { plan }
904
+ }
905
+
873
906
  /**
874
907
  * The provider adapter table. `read()` performs the required network reads and
875
908
  * returns the normalized result; optional reads (subscription/whoami) are
@@ -932,21 +965,53 @@ export const ADAPTERS = {
932
965
  authorization: `Bearer ${controlKey}`,
933
966
  'x-vela-workspace-id': workspaceId,
934
967
  }
935
- const usage = await httpJson(`${workspaceBase}/billing/coding-plan-usage`, { method: 'GET', headers })
936
- const parsed = parseOpenDesignUsage(usage, workspaceId, now)
968
+ // v1.4.0: the funding-check document carries the Design Plan windows
969
+ // (inside `codingPlan`) and the wallet balance in one authenticated read.
970
+ // `billing/coding-plan-usage` is a cookie-authenticated console route that
971
+ // answers 401 to a Control Key, so it is tried only as a fallback.
972
+ const funding = await httpJson(
973
+ joinUrl(config.baseURL, config.usagePath.replace('{workspaceId}', encodeURIComponent(workspaceId))),
974
+ { method: 'GET', headers },
975
+ ).catch(() => undefined)
976
+ // Keep the wallet read as the independent balance source: funding-check
977
+ // reports the same amount, but the wallet route is the authoritative one
978
+ // (and works even when the plan document is missing).
937
979
  const wallet = await httpJson(`${workspaceBase}/wallet/balance`, { method: 'GET', headers })
938
- const usageMember =
939
- usage !== null && typeof usage === 'object' && typeof usage.workspaceMemberId === 'string'
940
- ? usage.workspaceMemberId
941
- : undefined
942
980
  const walletParsed = parseOpenDesignWallet(wallet, workspaceId)
943
- if (usageMember !== undefined && usageMember !== walletParsed.workspaceMemberId) {
944
- throw new UsageError('parse', 'the OpenDesign wallet and quota responses name different workspace members')
981
+ let parsed = { windows: null, plan: null }
982
+ const usage =
983
+ funding !== undefined
984
+ ? funding
985
+ : await httpJson(`${workspaceBase}/billing/coding-plan-usage`, { method: 'GET', headers }).catch(
986
+ (error) => {
987
+ // A rejected quota route is not a failed read: the wallet half is
988
+ // real data. Anything else (network, timeout, 5xx) still propagates.
989
+ if (error instanceof UsageError && (error.code === 'unauthorized' || error.code === 'forbidden')) {
990
+ return undefined
991
+ }
992
+ throw error
993
+ },
994
+ )
995
+ if (usage !== undefined) {
996
+ parsed = parseOpenDesignUsage(usage, workspaceId, now)
997
+ const usageMember =
998
+ usage !== null && typeof usage === 'object' && typeof usage.workspaceMemberId === 'string'
999
+ ? usage.workspaceMemberId
1000
+ : undefined
1001
+ if (usageMember !== undefined && usageMember !== walletParsed.workspaceMemberId) {
1002
+ throw new UsageError('parse', 'the OpenDesign wallet and quota responses name different workspace members')
1003
+ }
945
1004
  }
1005
+ // `billing/summary` is the account-scoped read that carries the plan tier.
1006
+ // It is display-only: a failure must not void the wallet we already have.
1007
+ const summary = await httpJson(joinUrl(config.baseURL, config.summaryPath), { method: 'GET', headers }).catch(
1008
+ () => undefined,
1009
+ )
1010
+ const parsedSummary = parseOpenDesignSummary(summary)
946
1011
  return {
947
1012
  kind: 'windows',
948
1013
  windows: parsed.windows,
949
- plan: parsed.plan,
1014
+ plan: parsed.plan ?? parsedSummary.plan,
950
1015
  balance: walletParsed.balance,
951
1016
  }
952
1017
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tokenlens",
3
- "version": "1.3.8",
3
+ "version": "1.4.0",
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",