@mars-sea/dsh-commandcode-provider 0.10.2 → 0.10.4

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,7 +4,47 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## [Unreleased]
7
+ ## [0.10.4] - 2026-09-11
8
+
9
+ ### Added
10
+ - **A Command Code page in the dsh-TUI settings screen** ([#28](https://github.com/Mars-Sea/dsh-commandcode-provider/issues/28)). The terminal front door now lists Command Code under `/settings`, with the API key, the API base, the out-of-plan model filter, the model allowlist, the active account, and the command language. The key is a write-only credential field: the literal goes to the credential store and never into a settings document, and the field shows only whether one is configured. Until now a TUI-only user had no way to enter a key at all — dsh-TUI's own `/provider` wizard manages its `llm-pi-ai` routes exclusively, and the web Models page was the only surface that could write it. The page appears only on a profile that mounts dsh-TUI; everywhere else the plugin stays a plain LLM-provider bundle. Every model list and account choice reads live settings, so a change lands on the next request with no restart.
11
+
12
+ ### Changed
13
+ - **Per-release compatibility now covers dsh `0.1.5-rc.1`.** `dsh.compatibility.dshReleases` declares it `compatible` alongside `0.1.2-rc.1`, `0.1.3-alpha.1`, `0.1.3-alpha.2` and `0.1.5-alpha.1`, so the DSH STORE can list the plugin for the current Harness release. Verified on both surfaces: the web profile runs the plugin on this engine (settings page, Models-page card, chat and the `/commandcode` dashboard), and a dsh-TUI 0.10.0 profile selects and serves Command Code there. An older release keeps its own record rather than being replaced, so a user who has not moved to the newest engine still sees the listing.
14
+
15
+ ### Security
16
+ - **A literal API key in the composition config no longer rides a settings response.** The `apiKey` fields (top-level and per extra account) are now declared `role('secret')`, so the harness strips them from every settings descriptor it serves. The settings page was already writing keys through the credentials seam — which is what keeps them out of a settings document — but a key written by hand into `cordis.patch.yml` or `settings.yaml` was returned to the browser verbatim, including on a remote-Host setup where that means another machine.
17
+ - **A web page can no longer cancel a login in progress.** The loopback callback's denial branch now checks the login's state token before it ends the attempt, matching the official CLI. A denial is terminal, and the browser sends a `text/plain` POST as a CORS simple request regardless of the origin allowlist, so any open page could previously kill the attempt (and show "authorization denied") by blindly posting `{"error":"access_denied"}` to the loopback port. A denial that carries the real token still ends the attempt, as the Studio's own flow does.
18
+
19
+ ### Fixed
20
+ - **Cancelling a login now really cancels it.** The key delivered by the callback is validated against `/alpha/whoami` before it is stored, and cancelling during that check was ignored: the credential was written anyway and the state flipped back to success while the page (which had already stopped polling) kept showing "cancelled". The completion now re-checks that it still owns the attempt — before writing the key and before publishing any status — so a cancel or a newly started attempt wins.
21
+ - **"Sign in" after a completed callback starts a fresh attempt.** Once the callback is consumed the loopback server is closed, but the status still read `waiting`, so the next sign-in handed back the previous attempt's URL — a link to a dead port. A `waiting` status whose server is gone is now retired instead of reused.
22
+ - **Saving the settings page no longer deletes account entries it cannot show.** The accounts list is written whole, and the page rebuilt it from the rows it displays — so a composition-config entry carrying a literal `apiKey` (which has no row) was silently deleted by any save, and the literal key of the entries it could show was stripped. The write now starts from the stored list and only rewrites the rows the page manages, so unknown entries and literal keys survive.
23
+ - **A failed save no longer throws away what you typed.** Two staged edits were cleared even when the failing write never ran: an account's label reverted to the generated name (and was then persisted that way on the retry), and a routing-rule edit was silently reverted with the page left clean — no dirty marker and no way to retry. Both are now kept until the stored section proves the write landed. Rule drafts are still dropped once the rules write itself lands, because the positional row ids shift and a kept draft would be applied to the wrong row.
24
+ - **The "clean up stale models" button no longer empties the allowlist while the catalog is missing.** Before the first catalog fetch lands — and after a failure — the catalog is empty, so every selected model looked retired and one click cleared the whole list. The cleanup, and its hint, are now shown only when a catalog is actually in hand; the explicit "show all" action remains available.
25
+ - **Whitespace around a stored API key no longer breaks multi-account rotation.** The account pool marked a rejected key exactly as it was stored, while the request path trims it (a key from the credentials seam, a `.env` line, or a shell export picks up surrounding whitespace), so a 429/401 mark landed on a string nothing looked up again: the same account was offered again, the account card showed no mark, and the usage endpoints failed while chat kept working. Keys are now normalized once, where a slot's key enters the pool.
26
+ - **Images returned by a tool are no longer invisible to Vision models** ([#30](https://github.com/Mars-Sea/dsh-commandcode-provider/issues/30)). When the agent read a local image with `read_image` (an absolute path in the prompt) instead of receiving it as an attachment or pasted image, the request replayed only the tool's metadata text — the nested `image` block inside the tool result was flattened away, so the model answered that it could see no pixels. Both transports now carry the image's bytes in a user message right after the tool message, introduced by an `Attached image(s) from tool result:` note: the image stays outside the tool result, where neither `/alpha/generate`'s text-only tool output nor Chat Completions' `role: 'tool'` content could hold it. A tool result that repeats one attachment sends it once, an image-only result no longer produces an empty tool message, and text-only tool results are byte-for-byte unchanged.
27
+ - **Test coverage for the above**, plus the previously unpinned paths they exposed: account writes preserving composition entries, label/rule drafts across every failure order, the login callback's authentication and attempt ownership, credential normalization in the pool, and the catalog-readiness gate (each verified by reverting its fix and watching the new test fail).
28
+
29
+ ## [0.10.3] - 2026-09-10
30
+
31
+ ### Changed
32
+
33
+ - **Synced with the official command-code@1.53.0 CLI** (2026-09-10: "Add new deepseek/deepseek-v4.1-flash model"). `COMMAND_CODE_CLI_VERSION` is now `1.53.0`. The only catalog change is the new DeepSeek V4.1 Flash (Go-tier, Vision, 1M context, selectable `low` / `high` / `max` reasoning efforts, same hourly schedule as the other DeepSeek models: $0.15/$0.60 off-peak, $0.30/$1.20 peak, 01–04 & 06–10 UTC Mon–Fri). Wire protocol, endpoints, subscription plan maps, every other plan tier, and every deal are unchanged; `gpt-6-astra` remains a CLI/pricing/docs-only model (absent from the public Provider catalog, as before) and was deliberately kept.
34
+ - **DeepSeek V4.1 Flash is now selectable** — a Go-tier Vision model (1M context) with the `Peak`/`Half` hourly marker and the `Image` badge in the picker.
35
+ - **Synced with the official command-code@1.52.0 CLI** (2026-09-10). `COMMAND_CODE_CLI_VERSION` is now `1.52.0`. Upstream moved 1.51.3 → 1.52.0 the same day; the CLI changelog page still stops at 1.51.0, so the snapshot was read from the bundled model table. The only catalog change is the new free `inclusionai/ling-3.0-flash-sante:free` (Ling 3.0 Flash Sante, 262K context, text-only, automatic reasoning, 100 requests/day). Wire protocol, endpoints, effort map, subscription plan maps, peak/off-peak membership and schedule, and every other deal are unchanged; `gpt-6-astra` remains a CLI/pricing/docs-only model (absent from the public Provider catalog, as before) and was deliberately kept.
36
+ - **Ling 3.0 Flash Sante is now selectable** — a free Go-tier model on every plan (262K context, text-only, reasons automatically), shown with the `FREE` badge at the top of the picker. It succeeds the retired `inclusionai/ling-3.0-flash-free` promo.
37
+ - **Synced with the official command-code@1.51.3 CLI** (2026-09-09). `COMMAND_CODE_CLI_VERSION` is now `1.51.3`. Upstream moved 1.50.0 → 1.50.1 (CLI-local changes only: stable process title, UNIX-socket idle/working/blocked status) → 1.51.0 (briefly added DeepSeek V4.1 Flash Beta behind a 2026-09-10 expiry gate) → 1.51.2 (removed the beta model from the bundle entirely, so no snapshot entry is needed) → 1.51.3 (see below). Re-verified against the official sources: wire protocol (both the `/alpha/generate` CLI transport and the `/provider/v1/chat/completions` Provider transport), endpoints, auth flow, model catalog, plan tiers, subscription plan maps, and deals are all identical to 1.50.0; the peak/off-peak *hours* are unchanged, but the same page now also restricts them to weekdays — see Fixed. There is no CLI changelog entry for 1.51.1–1.51.3 yet, so the 1.51.3 snapshot was read from the bundled model table.
38
+ - **MiniMax M3 now offers selectable reasoning effort** (`low` / `medium` / `high`). The official 1.51.3 bundle gave the model effort levels where it previously let Command Code choose the depth automatically, so the picker's effort selector now lists them (its hidden free sibling gained the same set in the bundle).
39
+ - **The "Visible models" card is now "Model allowlist".** The label and hint on the settings page (zh/en) and in both READMEs say what the card does — checked models are the only ones the picker lists, and checking nothing shows everything — instead of relying on the word "visible". The stored `visibleModels` config key is unchanged, so existing allowlists keep working with no migration.
40
+ - **Per-release compatibility now covers dsh `0.1.5-alpha.1`.** `dsh.compatibility.dshReleases` declares it `compatible` alongside `0.1.2-rc.1`, `0.1.3-alpha.1` and `0.1.3-alpha.2`, so the DSH STORE can list the plugin for the current Harness release.
41
+ - **The build config uses the current tsdown option shape** (`outExtensions` and `deps.neverBundle` instead of the removed `outExtension` / `external`). The external set is byte-for-byte the same list as before, so the published `lib/` output is unchanged in behavior; the change only silences the deprecated-option path on current tsdown.
42
+
43
+ ### Fixed
44
+
45
+ - **The usage report no longer prints a raw success-rate float.** `/commandcode` and the settings page's Account usage card rendered the upstream value verbatim, so a rate of `99.965552876334` showed up as a wall of digits. Both the Host command and the browser card now round to at most two decimals and trim trailing zeros — `99.97`, `100`, `97.6` — through one shared formatting rule on each side.
46
+ - **DeepSeek peak/off-peak pricing now follows the official weekday rule.** Peak rates (01:00–04:00 and 06:00–10:00 UTC, full price) apply **Monday to Friday only** — Saturday and Sunday are charged completely off-peak for all 24 hours. The picker previously derived `Peak`/`Half` from the UTC hour alone, so it showed `Peak` on weekend mornings when the model actually billed at half price. Peak hours are now 35 per week (7 per weekday, 0 on weekends).
47
+ - **DeepSeek V4 Flash Fast no longer shows a bogus `Peak`/`Half` marker.** Its official pricing row is flat ($0.28/$0.56/$0.07) with no `timeOfDay` block, so it is not billed by the hour; the picker now shows just `Go · 1M`. The hourly-priced set is DeepSeek V4 Pro, V4 Flash, V4 Flash Vision (exp), and — from command-code@1.53.0 — V4.1 Flash. The upstream snapshot procedure was tightened to read the pricing page's embedded model JSON instead of the rendered rows, whose annotations sit one row off when flattened to text.
8
48
 
9
49
  ## [0.10.2] - 2026-09-08
10
50
 
package/README.md CHANGED
@@ -18,12 +18,13 @@ Unofficial [DeepSeek Harness](https://deepseek-harness.github.io/deepseek-harnes
18
18
 
19
19
  - **Plugin bundle** — install into any dsh profile with `dsh plugin add`; registers a `commandcode` provider route with a live model catalog.
20
20
  - **Dedicated settings page** — API key, connection options, a live account-usage card, and a "Hide out-of-plan models" toggle.
21
+ - **Works in the terminal too** — the same install serves a [dsh-TUI](#terminal-ui-dsh-tui) profile, with its own **`/settings` → Command Code** page for the API key and the model controls.
21
22
  - **Models-page key card** — the **Settings → Models → Command Code** card carries the key status, a paste field, and the sign-in button inline.
22
23
  - **In-browser sign-in for keys** — start the official authorization flow (the same one `cmd login` runs) from the settings page; the approved key lands in the local credential service automatically. Manual paste remains the fallback.
23
24
  - **Multi-account rotation** — when one account hits its usage limit, requests switch to the next account automatically. See [Account rotation](#account-rotation).
24
25
  - **Flexible API key setup** — via the settings page, an environment variable, or the official CLI login file.
25
26
  - **Model-picker annotations** — minimum plan, active deal or `FREE` badge, peak/off-peak state, image support, and context window; free models listed first.
26
- - **Plan-aware picker** — models above your subscription tier are hidden by default (toggleable); an optional **Visible models** allowlist keeps only your favorites in the picker.
27
+ - **Plan-aware picker** — models above your subscription tier are hidden by default (toggleable); an optional **Model allowlist** keeps only your favorites in the picker.
27
28
  - **Reasoning-effort support** — models with selectable reasoning effort levels expose them in the picker.
28
29
  - **Image input** — Vision-capable models accept images.
29
30
  - **Web search** — the dsh `web_search` tool is backed by the Command Code Provider API (`/alpha/web-search`) with the same key/endpoint as chat, so no separate search key or base URL is needed. See [Web search](#web-search).
@@ -78,6 +79,47 @@ Or skip the CLI: click **Sign in to Command Code** under **Settings → Command
78
79
 
79
80
  After restart, enter your API key in **Settings → Command Code** and save; **Settings → Models** shows a **Command Code** card, and the model picker lists the live catalog under **commandcode**. Send a message with a model your plan includes.
80
81
 
82
+ ## Terminal UI (dsh-TUI)
83
+
84
+ The plugin also works under a terminal front door — no separate install and no extra configuration:
85
+
86
+ ```sh
87
+ dsh plugin --profile dsh-tui add @mars-sea/dsh-commandcode-provider
88
+ ```
89
+
90
+ Then pick the provider in the model selector, or name it directly:
91
+
92
+ ```text
93
+ /model commandcode/deepseek/deepseek-v4.1-flash
94
+ ```
95
+
96
+ `/model` lists every registered provider, with Command Code's live catalog and its plan/deal/context annotations. The `/commandcode` usage dashboard works there too.
97
+
98
+ **Entering the key.** The TUI has no web Models page, so the plugin declares its own page in the TUI settings screen — **`/settings` → Command Code** — with the API key, the API base, the out-of-plan model filter, the model allowlist, the active account, and the command language. The key field is write-only: it shows whether a key is configured and writes what you type to the credential store, never to a settings document. The same page is the only place a TUI-only user needs to visit.
99
+
100
+ Alternatively, set the key outside the TUI — any of these work, in this order of precedence:
101
+
102
+ ```sh
103
+ export COMMANDCODE_API_KEY="user_..." # launching environment
104
+ cmd login # writes ~/.commandcode/auth.json
105
+ ```
106
+
107
+ **Making Command Code the default.** dsh-TUI pins its own agent route, and its `agent-default-model` setting does not override it. To start every session on Command Code, override the `agent-loop` row in your profile patch (`$DSH_HOME/profiles/dsh-tui/cordis.patch.yml`):
108
+
109
+ ```yaml
110
+ - id: agent-loop
111
+ inject: [tuiStartup]
112
+ config:
113
+ agents:
114
+ - id: main
115
+ provider: commandcode
116
+ model: deepseek/deepseek-v4.1-flash
117
+ reasoningEffort: max
118
+ cwd: !!js process.cwd()
119
+ ```
120
+
121
+ **Engine version.** The plugin needs a dsh engine that exports `ToolCallId` from `@deepseek-ai/dsh-llm` — **dsh 0.1.2-alpha.3 or later**. That includes the engine dsh-TUI recommends (0.1.2-rc.1) and every later release, but *not* the oldest engines its peer range nominally allows: on dsh 0.1.0-rc.6 or 0.1.1-rc.2 the plugin's module import fails and the TUI will not start. Upgrade the engine, or use the web profile.
122
+
81
123
  ## Usage dashboard
82
124
 
83
125
  The plugin registers a `/commandcode` slash command showing per-account usage:
@@ -95,7 +137,7 @@ With several Command Code subscriptions, the plugin **switches to the next accou
95
137
  - **Setup** — use the **Account rotation** card at Settings → **Command Code** to add accounts with a label and API key; the top-level key always serves first as the `default` account.
96
138
  - **Manual switching** — the **Active account** dropdown pins a preferred account; if it is exhausted, requests fall back to other accounts and return once its window resets.
97
139
  - **Route models to accounts** — the **Route models to accounts** card picks catalog models (multi-select, fetched from the live catalog) and routes them to an account. A request whose model is in a rule serves from that account while it is usable; an exhausted or invalid routed account falls back to the normal rotation. Rules match in list order — the first hit wins.
98
- - **Show only favorite models** — the **Visible models** card keeps only the checked models in the model picker; unchecked shows all (the default).
140
+ - **Show only favorite models** — the **Model allowlist** card keeps only the checked models in the model picker; unchecked shows all (the default).
99
141
  - **Status** — the **Account usage** card and `/commandcode` report per-account state.
100
142
 
101
143
  The equivalent YAML (`$DSH_HOME/settings.yaml` or composition config):
package/README.zh-CN.md CHANGED
@@ -18,12 +18,13 @@
18
18
 
19
19
  - **插件包**:一条 `dsh plugin add` 命令安装到任意 dsh 配置,注册 `commandcode` provider 路由,带实时模型目录。
20
20
  - **专属设置页**:API key 输入、连接参数、实时「账户用量」卡片和「隐藏套餐外模型」开关。
21
+ - **终端界面同样可用**:同一次安装即可服务 [dsh-TUI](#终端界面dsh-tui) 配置,终端里有自己的 **`/settings` → Command Code** 页面来填 key 和调模型。
21
22
  - **Models 页快捷卡片**:**设置 → Models → Command Code** 卡片内直接显示 key 状态、粘贴输入框和登录按钮。
22
23
  - **浏览器内登录获取 key**:设置页一键发起官方授权(与 `cmd login` 同一流程),完成后密钥自动写入本机凭据服务,无需手动创建或粘贴;不可用时随时退回手动粘贴。
23
24
  - **多账户轮换**:一个账户用量打满后,请求自动切换到下一个账户。详见[多账户轮换](#多账户轮换)。
24
25
  - **key 配置灵活**:设置页填写、环境变量或官方 CLI 登录文件均可。
25
26
  - **模型选择器标注**:每个模型标注最低套餐、折扣/FREE 徽章、峰谷时段、图片支持与上下文长度,免费模型置顶。
26
- - **按套餐过滤**:默认隐藏超出订阅套餐的模型,可一键关闭;「显示的模型」白名单可进一步只保留常用模型。
27
+ - **按套餐过滤**:默认隐藏超出订阅套餐的模型,可一键关闭;「模型白名单」可进一步只保留常用模型。
27
28
  - **推理强度支持**:支持推理强度的模型可在选择器中选择档位。
28
29
  - **图片输入**:Vision 模型支持发送图片。
29
30
  - **联网搜索**:dsh 的 `web_search` 工具由 Command Code Provider API(`/alpha/web-search`)承载,复用聊天同一个 key 与端点,无需单独配置搜索 key 或地址。详见[联网搜索](#联网搜索)。
@@ -76,6 +77,47 @@ cmd login # macOS/Linux;Windows 原生版:cmdc login
76
77
 
77
78
  重启后,在 **设置 → Command Code** 填入 API key 并保存;**设置 → Models** 出现 **Command Code** 卡片,模型选择器在 **commandcode** 下列出实时目录。选择套餐内包含的模型发送消息即可。
78
79
 
80
+ ## 终端界面(dsh-TUI)
81
+
82
+ 插件同样支持终端前端,无需单独安装、无需额外配置:
83
+
84
+ ```sh
85
+ dsh plugin --profile dsh-tui add @mars-sea/dsh-commandcode-provider
86
+ ```
87
+
88
+ 之后在模型选择器里选,或者直接指定:
89
+
90
+ ```text
91
+ /model commandcode/deepseek/deepseek-v4.1-flash
92
+ ```
93
+
94
+ `/model` 会列出所有已注册的 provider,Command Code 的实时目录连同套餐/优惠/上下文标注一起出现,`/commandcode` 用量面板在终端里同样可用。
95
+
96
+ **填写 API key。** 终端没有网页版 Models 页面,所以插件会在终端设置页里声明自己的页面 —— **`/settings` → Command Code** —— 包含 API key、API 地址、隐藏套餐外模型、模型白名单、当前账号和命令语言。key 字段是只写的:它只显示"是否已配置",输入的内容写进凭据库,不会写进任何 settings 文档。只用终端的用户只需要访问这一个页面。
97
+
98
+ 也可以在终端外配置 key,以下三种方式按优先级生效:
99
+
100
+ ```sh
101
+ export COMMANDCODE_API_KEY="user_..." # 启动环境变量
102
+ cmd login # 写入 ~/.commandcode/auth.json
103
+ ```
104
+
105
+ **把 Command Code 设为默认模型。** dsh-TUI 写死了自己的 agent 路由,它的 `agent-default-model` 设置不会覆盖它。要让每个会话默认走 Command Code,请在自己的 profile patch(`$DSH_HOME/profiles/dsh-tui/cordis.patch.yml`)里覆盖 `agent-loop` 行:
106
+
107
+ ```yaml
108
+ - id: agent-loop
109
+ inject: [tuiStartup]
110
+ config:
111
+ agents:
112
+ - id: main
113
+ provider: commandcode
114
+ model: deepseek/deepseek-v4.1-flash
115
+ reasoningEffort: max
116
+ cwd: !!js process.cwd()
117
+ ```
118
+
119
+ **引擎版本要求。** 插件需要导出了 `ToolCallId` 的 dsh 引擎,即 **dsh 0.1.2-alpha.3 或更高**。这覆盖了 dsh-TUI 推荐的引擎(0.1.2-rc.1)以及之后的所有版本,但不包含它 peer 范围里名义上允许的最老引擎:在 dsh 0.1.0-rc.6 / 0.1.1-rc.2 上,插件的模块导入会失败,TUI 无法启动。请升级引擎,或改用 web profile。
120
+
79
121
  ## 用量面板
80
122
 
81
123
  插件注册了 `/commandcode` 斜杠命令,显示各账户的用量状态:
@@ -93,7 +135,7 @@ cmd login # macOS/Linux;Windows 原生版:cmdc login
93
135
  - **配置**:在 **设置 → Command Code** 的「多账户轮换」卡片添加账户并填写备注名与 API key;顶层 key 始终是第一顺位的 `default` 账户。
94
136
  - **手动切换**:卡片上的「当前使用账户」下拉框可指定优先账户;所选账户耗尽时自动回落到其他账户,窗口重置后自动恢复。
95
137
  - **按模型切换账户**:在「按模型切换账户」卡片从实时模型目录**多选**模型并固定到某个账户。请求的模型在规则列表中且该账户可用时使用该账户;账户耗尽或密钥失效时自动回落到常规轮换。规则按列表顺序匹配,第一条命中生效。
96
- - **只显示常用模型**:在「显示的模型」卡片勾选要保留的模型,模型选择器只列出这些;不勾选则显示全部(默认行为不变)。
138
+ - **只显示常用模型**:在「模型白名单」卡片勾选要保留的模型,模型选择器只列出这些;不勾选则显示全部(默认行为不变)。
97
139
  - **状态展示**:「账户用量」卡片与 `/commandcode` 均按账户分别显示状态。
98
140
 
99
141
  等价的 YAML(`$DSH_HOME/settings.yaml` 或组合配置):
package/lib/client.js CHANGED
@@ -312,6 +312,14 @@ window.__ModuleLoader__.load({
312
312
  return b.every((id) => set.has(id));
313
313
  }
314
314
  /**
315
+ * Order-sensitive content fingerprint of the stored routing rules. Stored rule
316
+ * ids are positional (`rule-<index>`), so equality of this fingerprint across a
317
+ * save is exactly the statement "no rules write landed and no row shifted".
318
+ */
319
+ function ruleFingerprint(rules) {
320
+ return JSON.stringify(rules.map((rule) => [rule.models, rule.account]));
321
+ }
322
+ /**
315
323
  * Controller bridging the `llm-commandcode` scope and the credentials domain
316
324
  * onto the page. Public API mirrors the harness's CardForm actions, so the
317
325
  * component stays thin.
@@ -351,6 +359,12 @@ window.__ModuleLoader__.load({
351
359
  * would delete the wrong row).
352
360
  */
353
361
  removedRuleIds = /* @__PURE__ */ new Map();
362
+ /**
363
+ * Fingerprint of the stored routing rules when the last `save()` started;
364
+ * `undefined` outside a save. Lets reconcile distinguish a landed rules
365
+ * write from a save that failed before reaching it.
366
+ */
367
+ rulesBeforeSave = void 0;
354
368
  /** Staged visible-model allowlist (undefined = no draft). */
355
369
  visibleModelsDraft = void 0;
356
370
  /** The catalog the model editors offer (Host-side). */
@@ -646,6 +660,7 @@ window.__ModuleLoader__.load({
646
660
  }
647
661
  this.saving = true;
648
662
  this.failed = false;
663
+ this.rulesBeforeSave = ruleFingerprint(this.storedRules());
649
664
  this.publish();
650
665
  let landed = true;
651
666
  for (const run of [
@@ -682,10 +697,10 @@ window.__ModuleLoader__.load({
682
697
  }
683
698
  /**
684
699
  * Drop account staging the stored section already reflects: additions whose
685
- * ref is now stored, removals whose ref is gone, and label drafts matching
686
- * the stored label. Key drafts are kept — a landed key write is idempotent
687
- * on retry, and the draft carries the user's intent when it was the
688
- * accounts write that failed.
700
+ * ref is now stored, removals whose ref is gone, and label drafts that the
701
+ * stored label proves landed. Key drafts are kept — a landed key write is
702
+ * idempotent on retry, and the draft carries the user's intent when it was
703
+ * the accounts write that failed.
689
704
  */
690
705
  reconcileAccountStaging() {
691
706
  const stored = new Set(this.storedExtras().map((extra) => extra.ref));
@@ -693,7 +708,7 @@ window.__ModuleLoader__.load({
693
708
  for (const ref of [...this.removedRefs]) if (!stored.has(ref)) this.removedRefs.delete(ref);
694
709
  for (const [ref, text] of [...this.labelDrafts]) {
695
710
  const storedLabel = this.storedExtras().find((extra) => extra.ref === ref)?.label;
696
- if (storedLabel === void 0 || storedLabel === text.trim()) this.labelDrafts.delete(ref);
711
+ if (storedLabel !== void 0 && storedLabel === text.trim()) this.labelDrafts.delete(ref);
697
712
  }
698
713
  for (const ref of [...this.keyClears]) if (this.credentialStates.get(ref)?.configured !== true) this.keyClears.delete(ref);
699
714
  }
@@ -861,14 +876,25 @@ window.__ModuleLoader__.load({
861
876
  this.catalogFailed = true;
862
877
  }).then(() => this.publish());
863
878
  }
864
- /** The stored extra accounts from the settings section (`accounts`). */
865
- storedExtras() {
879
+ /** The raw `accounts` array of the stored section, verbatim. */
880
+ rawStoredAccounts() {
866
881
  const raw = this.scope.getSnapshot().value?.accounts;
867
882
  if (!Array.isArray(raw)) return [];
883
+ return raw.filter((entry) => typeof entry === "object" && entry !== null && !Array.isArray(entry));
884
+ }
885
+ /**
886
+ * The stored extra accounts from the settings section (`accounts`): the rows
887
+ * this page can address, i.e. the ones carrying a credential reference.
888
+ *
889
+ * Entries the page cannot name are deliberately NOT listed here but are also
890
+ * never dropped — `writeAccounts()` rebuilds the stored list from
891
+ * {@link rawStoredAccounts} and only rewrites the reference-carrying entries
892
+ * it manages (see the note there), so a literal-key entry stays in the
893
+ * document and simply has no row.
894
+ */
895
+ storedExtras() {
868
896
  const out = [];
869
- for (const entry of raw) {
870
- if (typeof entry !== "object" || entry === null || Array.isArray(entry)) continue;
871
- const record = entry;
897
+ for (const record of this.rawStoredAccounts()) {
872
898
  const ref = record.apiKeyEnv;
873
899
  if (typeof ref !== "string" || ref === "") continue;
874
900
  const label = record.label;
@@ -945,20 +971,54 @@ window.__ModuleLoader__.load({
945
971
  runs.push(() => this.writeAccounts());
946
972
  return runs;
947
973
  }
948
- /** Persist the staged accounts list into the settings section. */
974
+ /**
975
+ * Persist the staged accounts list into the settings section.
976
+ *
977
+ * The stored list is the base — NOT a list rebuilt from this page's rows.
978
+ * A composition-config entry may carry a literal `apiKey` (or a shape this
979
+ * page does not know), and the settings layer replaces the whole array, so a
980
+ * rebuilt list would silently delete every entry the page cannot name along
981
+ * with the literal keys of the entries it can. Entries are therefore carried
982
+ * over verbatim and only the reference-carrying rows are rewritten (label
983
+ * draft applied, staged removals dropped, staged additions appended).
984
+ */
949
985
  async writeAccounts() {
950
- const base = [...this.storedExtras().filter((extra) => !this.removedRefs.has(extra.ref)), ...this.addedAccounts];
951
- const seen = /* @__PURE__ */ new Set();
952
- const list = base.filter((extra) => !seen.has(extra.ref) && (seen.add(extra.ref), true)).map((extra) => {
953
- const draft = this.labelDrafts.get(extra.ref)?.trim();
954
- return {
955
- label: draft !== void 0 && draft !== "" ? draft : extra.label,
956
- apiKeyEnv: extra.ref
957
- };
958
- });
986
+ const removed = this.removedRefs;
987
+ const written = /* @__PURE__ */ new Set();
988
+ const list = [];
989
+ for (const entry of this.rawStoredAccounts()) {
990
+ const ref = entry.apiKeyEnv;
991
+ if (typeof ref !== "string" || ref === "") {
992
+ list.push({ ...entry });
993
+ continue;
994
+ }
995
+ if (removed.has(ref) || written.has(ref)) continue;
996
+ written.add(ref);
997
+ list.push(this.accountEntry(ref, entry));
998
+ }
999
+ for (const extra of this.addedAccounts) {
1000
+ if (written.has(extra.ref)) continue;
1001
+ written.add(extra.ref);
1002
+ list.push(this.accountEntry(extra.ref, extra));
1003
+ }
959
1004
  await this.scope.set("accounts", list);
960
- const after = this.storedExtras();
961
- return after.length === list.length && list.every((item, index) => after[index]?.ref === item.apiKeyEnv);
1005
+ const after = this.rawStoredAccounts();
1006
+ return after.length === list.length && list.every((item, index) => after[index]?.apiKeyEnv === item.apiKeyEnv);
1007
+ }
1008
+ /**
1009
+ * One written account entry: the stored/added facts plus the label draft.
1010
+ * `fallback` contributes the non-managed fields (a stored entry's literal
1011
+ * `apiKey`, or any future field) so a rewrite never strips them.
1012
+ */
1013
+ accountEntry(ref, fallback) {
1014
+ const draft = this.labelDrafts.get(ref)?.trim();
1015
+ const base = "ref" in fallback ? { label: fallback.label } : { ...fallback };
1016
+ const storedLabel = base.label;
1017
+ return {
1018
+ ...base,
1019
+ label: draft !== void 0 && draft !== "" ? draft : typeof storedLabel === "string" && storedLabel !== "" ? storedLabel : ref,
1020
+ apiKeyEnv: ref
1021
+ };
962
1022
  }
963
1023
  /** The stored routing rules from the settings section (`modelAccountRules`). */
964
1024
  storedRules() {
@@ -1034,6 +1094,7 @@ window.__ModuleLoader__.load({
1034
1094
  const stored = this.storedRules();
1035
1095
  this.addedRules = this.addedRules.filter((added) => !stored.some((rule) => sameModels(added.models, rule.models) && added.account === rule.account));
1036
1096
  for (const [id, snapshot] of [...this.removedRuleIds]) if (!stored.some((rule) => sameModels(snapshot.models, rule.models) && snapshot.account === rule.account)) this.removedRuleIds.delete(id);
1097
+ if (this.rulesBeforeSave !== void 0 && ruleFingerprint(stored) === this.rulesBeforeSave) return;
1037
1098
  this.ruleDrafts.clear();
1038
1099
  }
1039
1100
  /** The stored visible-model allowlist (`visibleModels`); empty = show all. */
@@ -1260,6 +1321,16 @@ window.__ModuleLoader__.load({
1260
1321
  if (value >= 1e3) return `${(value / 1e3).toFixed(1)}K`;
1261
1322
  return String(value);
1262
1323
  }
1324
+ /**
1325
+ * Format a success-rate percentage (already in percent units, e.g. 99.96):
1326
+ * at most two decimals, trailing zeros trimmed — `100` stays `100`, not
1327
+ * `100.00`, and the raw upstream float `99.965552876334` becomes `99.97`.
1328
+ * The `%` suffix is appended by the caller (the card and the dashboard line
1329
+ * both compose it).
1330
+ */
1331
+ function formatSuccessRate(value) {
1332
+ return String(Number(value.toFixed(2)));
1333
+ }
1263
1334
  /** One window's fill ratio in [0, 1]; 0 when uncapped. */
1264
1335
  function windowRatio(used, cap) {
1265
1336
  if (cap <= 0) return 0;
@@ -1956,9 +2027,31 @@ window.__ModuleLoader__.load({
1956
2027
  function toggleModelSelection(selected, modelId) {
1957
2028
  return selected.includes(modelId) ? selected.filter((value) => value !== modelId) : [...selected, modelId];
1958
2029
  }
2030
+ /**
2031
+ * Whether the catalog is trustworthy enough to call an unlisted selection
2032
+ * "retired". FALSE while the first fetch is still in flight and after a
2033
+ * failure, because the catalog is empty then and every selected id would look
2034
+ * stale — which turns the one-click stale cleanup into a button that silently
2035
+ * empties the allowlist. A successfully loaded but empty catalog is treated as
2036
+ * untrustworthy too: an empty list is far more likely a Host problem than every
2037
+ * model being retired at once, and the explicit "show all" entry covers the
2038
+ * user who really wants to clear the list.
2039
+ */
2040
+ function catalogIsReady(readiness) {
2041
+ return readiness.catalogIds.length > 0 && !readiness.catalogFailed;
2042
+ }
2043
+ /**
2044
+ * Selected ids the loaded catalog no longer carries, in selection order.
2045
+ * Callers gate user-visible "stale" affordances on {@link catalogIsReady} —
2046
+ * the list itself is informational.
2047
+ */
2048
+ function staleModelIds(selected, readiness) {
2049
+ const catalogIds = new Set(readiness.catalogIds);
2050
+ return selected.filter((id) => !catalogIds.has(id));
2051
+ }
1959
2052
  //#endregion
1960
2053
  //#region package.json
1961
- var version = "0.10.2";
2054
+ var version = "0.10.4";
1962
2055
  var repository = {
1963
2056
  "type": "git",
1964
2057
  "url": "git+https://github.com/Mars-Sea/dsh-commandcode-provider.git"
@@ -2605,7 +2698,7 @@ window.__ModuleLoader__.load({
2605
2698
  }),
2606
2699
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UsageStat, {
2607
2700
  label: t("usageSuccessRate"),
2608
- value: `${report.usage.successRate}%`
2701
+ value: `${formatSuccessRate(report.usage.successRate)}%`
2609
2702
  }),
2610
2703
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UsageStat, {
2611
2704
  label: t("usageCost"),
@@ -3180,8 +3273,12 @@ window.__ModuleLoader__.load({
3180
3273
  if (key === "ruleModelCount") return t("visibleModelsCount", params);
3181
3274
  return t(key, params);
3182
3275
  };
3183
- const catalogIds = new Set(state.catalogModels.map((model) => model.id));
3184
- const staleIds = state.visibleModels.filter((id) => !catalogIds.has(id));
3276
+ const readiness = {
3277
+ catalogIds: state.catalogModels.map((model) => model.id),
3278
+ catalogFailed: state.catalogFailed
3279
+ };
3280
+ const staleIds = staleModelIds(state.visibleModels, readiness);
3281
+ const catalogReady = catalogIsReady(readiness);
3185
3282
  return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3186
3283
  className: "cc-card",
3187
3284
  "aria-label": t("visibleModelsTitle"),
@@ -3195,11 +3292,11 @@ window.__ModuleLoader__.load({
3195
3292
  children: t("visibleModelsTitle")
3196
3293
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3197
3294
  className: "cc-badges",
3198
- children: [staleIds.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3295
+ children: [catalogReady && staleIds.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3199
3296
  type: "button",
3200
3297
  className: "cc-reset",
3201
3298
  disabled,
3202
- onClick: () => onSelect(state.visibleModels.filter((id) => catalogIds.has(id))),
3299
+ onClick: () => onSelect(state.visibleModels.filter((id) => !staleIds.includes(id))),
3203
3300
  children: t("visibleModelsCleanStale", { count: staleIds.length })
3204
3301
  }) : null, count > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
3205
3302
  type: "button",
@@ -3218,7 +3315,7 @@ window.__ModuleLoader__.load({
3218
3315
  className: "cc-invalid",
3219
3316
  children: t("rulesCatalogFailed")
3220
3317
  }) : null,
3221
- staleIds.length > 0 && !state.catalogFailed ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3318
+ staleIds.length > 0 && catalogReady ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
3222
3319
  className: "cc-hint",
3223
3320
  children: t("visibleModelsStaleHint", { count: staleIds.length })
3224
3321
  }) : null,
@@ -3683,9 +3780,9 @@ window.__ModuleLoader__.load({
3683
3780
  modelSearchPlaceholder: "搜索模型…",
3684
3781
  modelSearchEmpty: "没有匹配的模型。",
3685
3782
  modelStale: "已下架",
3686
- visibleModelsTitle: "显示的模型",
3687
- visibleModelsHint: "只在模型选择器中显示勾选的模型;不勾选则显示全部。保存后下次打开选择器即生效。",
3688
- visibleModelsPick: "选择要显示的模型…",
3783
+ visibleModelsTitle: "模型白名单",
3784
+ visibleModelsHint: "勾选要保留的模型,模型选择器就只列出这些;一个都不勾选时则显示全部模型。保存后,下次打开模型选择器生效。",
3785
+ visibleModelsPick: "选择要保留的模型…",
3689
3786
  visibleModelsCount: "已选 {count} 个模型",
3690
3787
  visibleModelsShowAll: "显示全部",
3691
3788
  visibleModelsStaleHint: "有 {count} 个已选模型在目录中找不到了(可能已下架),不影响其他模型;可清理或保留。",
@@ -3815,9 +3912,9 @@ window.__ModuleLoader__.load({
3815
3912
  modelSearchPlaceholder: "Search models…",
3816
3913
  modelSearchEmpty: "No matching models.",
3817
3914
  modelStale: "Retired",
3818
- visibleModelsTitle: "Visible models",
3819
- visibleModelsHint: "Show only the checked models in model pickers; unchecked shows all. Applies the next time a picker opens after saving.",
3820
- visibleModelsPick: "Select models to show…",
3915
+ visibleModelsTitle: "Model allowlist",
3916
+ visibleModelsHint: "Check the models you want to keep, and model pickers will list only those. If nothing is checked, every model is shown. After saving, the change applies the next time you open a model picker.",
3917
+ visibleModelsPick: "Select models to keep…",
3821
3918
  visibleModelsCount: "{count} model(s) selected",
3822
3919
  visibleModelsShowAll: "Show all",
3823
3920
  visibleModelsStaleHint: "{count} selected model(s) are no longer in the catalog (possibly retired); other models are unaffected. Clean them up or keep them.",