dsh-vision-router 1.4.1 → 1.4.2

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/README.md CHANGED
@@ -15,8 +15,8 @@
15
15
  </p>
16
16
 
17
17
  <p align="center">
18
- <a href="https://github.com/ysr666/dsh-vision-router/releases/tag/v1.4.1"><img src="https://img.shields.io/badge/release-v1.4.1-5B4CF0?style=flat-square" alt="Release v1.4.1" /></a>
19
- <a href="tests"><img src="https://img.shields.io/badge/verified-149%20tests-2EA44F?style=flat-square" alt="Verified: 149 tests" /></a>
18
+ <a href="https://github.com/ysr666/dsh-vision-router/releases/tag/v1.4.2"><img src="https://img.shields.io/badge/release-v1.4.2-5B4CF0?style=flat-square" alt="Release v1.4.2" /></a>
19
+ <a href="tests"><img src="https://img.shields.io/badge/verified-252%20tests-2EA44F?style=flat-square" alt="Verified: 252 tests" /></a>
20
20
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-2EA44F?style=flat-square" alt="License: MIT" /></a>
21
21
  <a href="package.json"><img src="https://img.shields.io/badge/Node.js-%3E%3D22-339933?style=flat-square&amp;logo=nodedotjs&amp;logoColor=white" alt="Node.js >=22" /></a>
22
22
  <img src="https://img.shields.io/badge/runtime-no%20Python-8A2BE2?style=flat-square" alt="No Python" />
@@ -28,9 +28,9 @@
28
28
  <p align="center">💬 <strong>QQ community group: 1105463028</strong></p>
29
29
 
30
30
  > [!WARNING]
31
- > 📌 **Announcement (v1.4.1)**
31
+ > 📌 **Announcement (v1.4.2)**
32
32
  >
33
- > **v1.4.1 now supports** hardened vision failure handling — a single broken vision backend (401 / 429 / outage) can no longer stall a text turn: circuit breaking, shared task budgets and structured failure results keep DeepSeek conversations moving.
33
+ > **v1.4.2 now supports** Oh-DSH Desktop — the plugin no longer races the stock DeepSeek route on the bundled DSH 0.1.0-rc.5 runtime (fixes "DSH runtime exited before readiness"), opencode-go qwen3.6-plus vision calls reach the correct Anthropic endpoint, and "+ Auto Vision" twins keep the picker's reasoning effort across multi-step turns.
34
34
 
35
35
  <p align="center">
36
36
  <img src="assets/vision-demo.gif" width="640" alt="Demo: paste an image, the agent locates the send button with vision_ground / vision_crop / vision_pixel_diff and answers with coordinates" />
@@ -40,6 +40,7 @@
40
40
 
41
41
  - [Why this exists](#why-this-exists)
42
42
  - [How it compares](#how-it-compares)
43
+ - [Acknowledgements](#acknowledgements)
43
44
  - [Quick start](#quick-start)
44
45
  - [Highlights](#highlights)
45
46
  - [How it works](#how-it-works)
@@ -61,20 +62,41 @@ Most DSH vision plugins bridge images to DeepSeek as *text descriptions* — los
61
62
 
62
63
  ## How it compares
63
64
 
64
- The closest alternative is [@anionex/dsh-vision-toolkit](https://github.com/Anionex/dsh-vision-toolkit) (Anionex), a native DSH bundle of the well-known `agent-vision-toolkit` lineage. Both packages ship a `vision-tools` skill and a family of pixel-level tools; they differ in philosophy — **zero-config paste-and-go** versus **agent-driven visual engineering**:
65
+ **One-line take**: most dsh vision plugins turn images into *text descriptions* for DeepSeek
66
+ (description bridge — lossy); this plugin hands the image turn *straight to a vision model*
67
+ (routing bridge — pixel-faithful), with a built-in keyless free fallback.
65
68
 
66
- | | dsh-vision-router | @anionex/dsh-vision-toolkit |
67
- |---|---|---|
68
- | Image Q&A out of the box | ✅ Built-in free chain (anonymous OVHcloud endpoint) — no account, no key | Requires your own vision API key (local pixel tools work without one) |
69
- | Runtime | ✅ Node only — no Python | Python 3.11+ managed runtime |
70
- | Getting an image in | ✅ Pick a “+ Auto Vision” group once, then paste directly | Workspace path + `/vision-tools` command, then explicit tool calls |
71
- | Turn routing | ✅ Image turns switch to vision, text turns switch back to DeepSeek — optional stealth takeover keeps the model picker looking stock | Tool-driven; no whole-turn auto-routing |
72
- | Profiles | Web | Web + Headless |
73
- | Playbooks | The pixel loop: ground → crop → diff → fix → screenshot again | Richer case library (long-screenshot OCR, UI restoration, GUI automation) |
74
- | Tests | 144 | 162 |
75
- | Install | One command | One command (npm) |
69
+ | | Manual model switching | MCP vision bridge | dsh-vision-router |
70
+ |---|---|---|---|
71
+ | Pixel fidelity | ✅ full (when switched) | ❌ text description only | ✅ full, on the image turn |
72
+ | Automatic | ❌ | ✅ | ✅ |
73
+ | Daily model untouched | ❌ (whole session swapped) | ✅ | ✅ |
74
+ | Provider failure recovery | ❌ | ❌ | ✅ fallback chains |
75
+ | Reusable structured queries | — | partial | ✅ JSON mode + caching |
76
+ | Free out-of-the-box | ❌ | ❌ | ✅ built-in keyless endpoint |
77
+ | Fits dsh composition | — | external server | ✅ one plugin row |
78
+
79
+ **Difference from existing dsh community projects** (all excellent, each with its own focus; descriptions reflect their READMEs as of 2026-08):
76
80
 
77
- Both are MIT-licensed and one command away. Pick this plugin when you want images to *just work* with zero setup; pick theirs when you need headless profiles or the extended playbook library. (Feature comparison reflects their README as of 2026-08.)
81
+ | Project | Approach | What this plugin adds |
82
+ |---|---|---|
83
+ | [dsh-vision-sidecar](https://github.com/121103qwq/dsh-vision-sidecar) | Pre-describes images with an external VLM; the description joins the session as a message to DeepSeek; LLM7.io anonymous endpoint by default (OVHcloud listed as a no-key alternative) | Description bridge; this plugin adds raw-image routing, with `vision_describe` covering descriptions on demand |
84
+ | [dsh-vision-proxy](https://github.com/Flyvhidbwo/dsh-vision-proxy) | Wraps a provider route and transcribes images into text in the request stream | Transcription bridge; this plugin wraps no provider — it rewrites routing through `agent/request` waterfalls |
85
+ | [dsh-vision-provider](https://github.com/libinyam/dsh-vision-provider) | Registers `DeepSeek + Vision` combined routes: images are described by the chosen vision model before reaching DeepSeek | Two-model bridge idea; this plugin adds automatic routing, fallback chains and tools on top |
86
+ | [modlens](https://github.com/liustack/modlens) | The first dsh vision plugin; reuses local Claude Code/Codex/OpenCode/Pi logins as vision engines | Engine-reuse idea; this plugin ships its own provider chain and depends on no other local CLI |
87
+ | [dsh-vision-toolkit](https://github.com/Anionex/dsh-vision-toolkit) | Ten intent-aware visual tools (Q&A/OCR/pixel verification/UI restoration), called explicitly on demand | Broader tool set; this plugin adds whole-turn auto-routing and a keyless free fallback |
88
+ | [dsh-tool-vision](https://github.com/Scorp1o117/dsh-tool-vision) | An `inspect_image` tool plus an `agent/pre-step` waterfall bridge (pasted images become tool hints before entering the log) | Similar waterfall bridge; this plugin adds turn routing, fallback chains, caching and the free endpoint |
89
+
90
+ ## Acknowledgements
91
+
92
+ This project borrows ideas from all of the above — especially the keyless free-endpoint
93
+ exploration (LLM7.io and OVHcloud anonymous tiers) by
94
+ [dsh-vision-sidecar](https://github.com/121103qwq/dsh-vision-sidecar). Thanks to the authors of
95
+ [dsh-vision-proxy](https://github.com/Flyvhidbwo/dsh-vision-proxy),
96
+ [dsh-vision-provider](https://github.com/libinyam/dsh-vision-provider),
97
+ [modlens](https://github.com/liustack/modlens),
98
+ [dsh-vision-toolkit](https://github.com/Anionex/dsh-vision-toolkit), and
99
+ [dsh-tool-vision](https://github.com/Scorp1o117/dsh-tool-vision).
78
100
 
79
101
  ## Quick start
80
102
 
@@ -283,6 +305,7 @@ Everything is optional; defaults work out of the box. Edit via the Web card or a
283
305
  | `timeoutMs` | `120000` | per vision call deadline |
284
306
  | `artifactsDir` | `.dsh-vision-router/artifacts` | artifact directory (relative to the session workspace) |
285
307
  | `proxy` / `proxyHosts` | `''` / openrouter hosts | optional proxy for vision provider hosts only |
308
+ | `catalogCorrections` | `true` | built-in catalog-routing corrections: when the installed pi-ai catalog routes a known model to the wrong wire protocol (e.g. `opencode-go/qwen3.6-plus` to OpenAI chat completions while OpenCode Go only serves it on `/v1/messages`), the plugin answers that backend directly over the corrected protocol. Each correction disarms itself once the catalog is fixed upstream |
286
309
 
287
310
  ## Requirements
288
311
 
@@ -317,6 +340,25 @@ npx @deepseek-ai/dsh --profile web --dump-config | grep vision-router
317
340
 
318
341
  When first adding the plugin to an already long-lived Web profile, let that Web process reload the plugin bundle; the host discovers the browser bundle through `dsh.client` at startup. **After the plugin is loaded, model-catalog and wrapper-scope changes hot-update and do not require a restart.**
319
342
 
343
+ ### Oh-DSH Desktop
344
+
345
+ [Oh-DSH Desktop](https://github.com/hust-open-atom-club/oh-dsh) ships its own packaged DSH runtime and its own home layout: the desktop surface runs the `desktop` profile under `~/.ohdsh` and does **not** load ordinary `~/.dsh` profiles. The `--profile web` commands above therefore install into the wrong environment on that product.
346
+
347
+ Install into the desktop profile by pointing `DSH_HOME` at the Oh-DSH home:
348
+
349
+ ```sh
350
+ DSH_HOME=~/.ohdsh npx @deepseek-ai/dsh plugin --profile desktop add dsh-vision-router
351
+ ```
352
+
353
+ (Windows PowerShell: run `$env:DSH_HOME = "$env:USERPROFILE\.ohdsh"` first, then the same command.)
354
+
355
+ > [!WARNING]
356
+ > Oh-DSH Desktop ≤ 0.1.5 bundles DSH `0.1.0-rc.5`. `dsh-vision-router` v1.4.1 and earlier crash that runtime at startup (`configurable provider "deepseek-official" is already declared`, surfacing as `DSH runtime exited before readiness`). Install v1.4.2+.
357
+
358
+ If a broken install already keeps the Desktop from starting, open `~/.ohdsh/profiles/desktop/package.json`, remove the `dsh-vision-router` entry from both `dependencies` and `dsh.profile.bundles`, save, and restart the Desktop.
359
+
360
+ Oh-DSH Desktop's built-in plugin marketplace (search → prepare → isolated preview → apply, with a `previous` snapshot for recovery) also works once the community catalog lists this plugin; do not mix marketplace installs with the direct command above. The bundled `@oh-dsh/vision` (`view_image`) coexists with this plugin — the tool names do not collide.
361
+
320
362
  ### Disable / re-enable
321
363
 
322
364
  ```yaml
package/README.zh.md CHANGED
@@ -15,8 +15,8 @@
15
15
  </p>
16
16
 
17
17
  <p align="center">
18
- <a href="https://github.com/ysr666/dsh-vision-router/releases/tag/v1.4.1"><img src="https://img.shields.io/badge/release-v1.4.1-5B4CF0?style=flat-square" alt="Release v1.4.1" /></a>
19
- <a href="tests"><img src="https://img.shields.io/badge/verified-149%20tests-2EA44F?style=flat-square" alt="Verified: 149 tests" /></a>
18
+ <a href="https://github.com/ysr666/dsh-vision-router/releases/tag/v1.4.2"><img src="https://img.shields.io/badge/release-v1.4.2-5B4CF0?style=flat-square" alt="Release v1.4.2" /></a>
19
+ <a href="tests"><img src="https://img.shields.io/badge/verified-252%20tests-2EA44F?style=flat-square" alt="Verified: 252 tests" /></a>
20
20
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-2EA44F?style=flat-square" alt="License: MIT" /></a>
21
21
  <a href="package.json"><img src="https://img.shields.io/badge/Node.js-%3E%3D22-339933?style=flat-square&amp;logo=nodedotjs&amp;logoColor=white" alt="Node.js >=22" /></a>
22
22
  <img src="https://img.shields.io/badge/runtime-no%20Python-8A2BE2?style=flat-square" alt="No Python" />
@@ -28,9 +28,9 @@
28
28
  <p align="center">💬 <strong>QQ 用户交流群:1105463028</strong></p>
29
29
 
30
30
  > [!WARNING]
31
- > 📌 **公告(v1.4.1)**
31
+ > 📌 **公告(v1.4.2)**
32
32
  >
33
- > **v1.4.1 现已支持**:视觉失败链加固——单个视觉后端(401 / 429 / 故障)不再拖垮文本对话;熔断、共享任务预算与结构化失败结果让对话始终顺畅继续。
33
+ > **v1.4.2 现已支持**:Oh-DSH Desktop 兼容——在内置的 DSH 0.1.0-rc.5 上不再与官方 DeepSeek 路由竞争导致启动崩溃(修复「DSH runtime exited before readiness」);opencode-go qwen3.6-plus 视觉调用走正确的 Anthropic 端点;「+ 自动识图」twin 组在多步对话中保留选择器里的推理等级。
34
34
 
35
35
  <p align="center">
36
36
  <img src="assets/vision-demo.gif" width="640" alt="演示:粘贴图片,Agent 用 vision_ground / vision_crop / vision_pixel_diff 定位发送按钮并给出坐标" />
@@ -40,6 +40,7 @@
40
40
 
41
41
  - [为什么做这个](#为什么做这个)
42
42
  - [对比同类插件](#对比同类插件)
43
+ - [致谢](#致谢)
43
44
  - [快速开始](#快速开始)
44
45
  - [亮点](#亮点)
45
46
  - [工作原理](#工作原理)
@@ -61,20 +62,39 @@
61
62
 
62
63
  ## 对比同类插件
63
64
 
64
- 最接近的同类是 [@anionex/dsh-vision-toolkit](https://github.com/Anionex/dsh-vision-toolkit)(Anionex),它是知名 `agent-vision-toolkit` 系列的 DSH 原生版。两者都提供 `vision-tools` 技能和一组像素级工具,区别在理念:**零配置粘贴即用** vs **Agent 主导的视觉工程**:
65
+ **一句话讲清区别**:其他 dsh 视觉插件大多"把图片转成文字描述再喂给 DeepSeek"(描述桥,有信息损耗);
66
+ 本插件主打"**图片轮直接交给视觉模型看原图**"(路由桥,像素保真),同时内置免 Key 免费模型兜底。
65
67
 
66
- | | dsh-vision-router | @anionex/dsh-vision-toolkit |
67
- |---|---|---|
68
- | 开箱图片问答 | ✅ 内置免费视觉链(OVHcloud 匿名端点),免注册免 Key | 远程工具需自备视觉 API Key(本地像素工具免 Key) |
69
- | 运行时 | ✅ 纯 Node,无需 Python | 需要 Python 3.11+ 受管运行时 |
70
- | 图片怎么进来 | ✅ 选一次「+ 自动识图」模型组后直接粘贴 | 工作区路径 + `/vision-tools` 命令,再显式调用工具 |
71
- | 轮次路由 | ✅ 图片轮切视觉、文本轮切回 DeepSeek——可选隐身接管,模型选择器与官方一致 | 工具驱动,无整轮自动路由 |
72
- | 支持 profile | Web | Web + Headless |
73
- | 玩法库 | 像素循环:定位 → 裁剪 → 对比 → 修复 → 再截图 | 更丰富的案例库(长截图 OCR、UI 还原、GUI 自动化) |
74
- | 测试 | 144 | 162 |
75
- | 安装 | 一条命令 | 一条命令(npm) |
68
+ | | 手动切换模型 | MCP 视觉桥 | 本插件 |
69
+ |---|---|---|---|
70
+ | 像素保真 | ✅ 完整(切换后) | ❌ 只有文字描述 | ✅ 完整,图片轮内 |
71
+ | 自动化 | ❌ | ✅ | ✅ |
72
+ | 日常模型不受影响 | ❌(整会话被换) | ✅ | ✅ |
73
+ | 供应商失败恢复 | ❌ | ❌ | ✅ 降级链 |
74
+ | 可复用的结构化查询 | — | 部分 | ✅ JSON 模式 + 缓存 |
75
+ | 免费开箱即用 | ❌ | ❌ | ✅ 内置免 Key 免费端点 |
76
+ | 贴合 dsh 组合体系 | — | 外部服务器 | ✅ 一行插件行 |
77
+
78
+ **与现有 dsh 社区方案的差异**(均为优秀项目,各有侧重;描述以各家 README 2026-08 状态为准):
76
79
 
77
- 两者都是 MIT 许可、一条命令安装。想要图片**粘贴即用**、零配置就选本插件;需要 Headless 部署或更丰富的案例库,可以看 @anionex/dsh-vision-toolkit。(功能对比以其 README 2026-08 状态为准。)
80
+ | 项目 | 思路 | 本插件的差异 |
81
+ |---|---|---|
82
+ | [dsh-vision-sidecar](https://github.com/121103qwq/dsh-vision-sidecar) | 图片先经外部 VLM 做 OCR/描述,描述作为会话消息交给 DeepSeek;默认 LLM7.io 匿名端点(OVHcloud 为无 Key 备选) | 描述桥方案;本插件提供"原图直看"路由,描述能力由 `vision_describe` 按需替代 |
83
+ | [dsh-vision-proxy](https://github.com/Flyvhidbwo/dsh-vision-proxy) | 包装 provider 路由,请求流里把图片转译成文本再交给 DeepSeek | 转译桥方案;本插件不包装 provider,通过 `agent/request` 瀑布改写路由 |
84
+ | [dsh-vision-provider](https://github.com/libinyam/dsh-vision-provider) | 注册 `DeepSeek + Vision` 组合路由:图片先经所选视觉模型转成描述,再交给 DeepSeek | 双模型桥思路;本插件在此基础上增加自动路由、降级链与工具 |
85
+ | [modlens](https://github.com/liustack/modlens) | 最早的 dsh 视觉插件;复用本机 Claude Code/Codex/OpenCode/Pi 等登录态作为视觉引擎 | 引擎复用思路;本插件自带供应商链,不依赖本机其他 CLI |
86
+ | [dsh-vision-toolkit](https://github.com/Anionex/dsh-vision-toolkit) | 10 个意图化视觉工具(Q&A/OCR/像素校验/UI 还原),按需显式调用 | 工具集更全;本插件多出整轮自动路由与免 Key 免费兜底 |
87
+ | [dsh-tool-vision](https://github.com/Scorp1o117/dsh-tool-vision) | `inspect_image` 工具 + `agent/pre-step` 瀑布图片桥(粘贴图入日志前转成工具提示) | 瀑布桥思路相近;本插件多出轮次路由、降级链、缓存与免费端点 |
88
+
89
+ ## 致谢
90
+
91
+ 本插件借鉴了以上全部社区项目的思路,特别是 [dsh-vision-sidecar](https://github.com/121103qwq/dsh-vision-sidecar)
92
+ 对免注册免 Key 视觉端点的探索(LLM7.io 与 OVHcloud 匿名层)。感谢
93
+ [dsh-vision-proxy](https://github.com/Flyvhidbwo/dsh-vision-proxy)、
94
+ [dsh-vision-provider](https://github.com/libinyam/dsh-vision-provider)、
95
+ [modlens](https://github.com/liustack/modlens)、
96
+ [dsh-vision-toolkit](https://github.com/Anionex/dsh-vision-toolkit)、
97
+ [dsh-tool-vision](https://github.com/Scorp1o117/dsh-tool-vision) 作者们的探索。
78
98
 
79
99
  ## 快速开始
80
100
 
@@ -283,6 +303,7 @@ Web 配置页在 **设置 → 插件 → 插件配置** 下注册「视觉路由
283
303
  | `timeoutMs` | `120000` | 单次视觉调用超时 |
284
304
  | `artifactsDir` | `.dsh-vision-router/artifacts` | 产物目录(相对会话工作区) |
285
305
  | `proxy` / `proxyHosts` | `''` / openrouter 域名 | 仅视觉供应商域名可选的本地代理 |
306
+ | `catalogCorrections` | `true` | 内置目录纠错:当已安装的 pi-ai 目录把已知模型路由到错误协议时(例如 `opencode-go/qwen3.6-plus` 被指向 OpenAI chat completions,而 OpenCode Go 只在 `/v1/messages` 上提供该模型),插件直接按正确协议应答该后端;上游目录修复后每条纠错自动失效 |
286
307
 
287
308
  ## 环境要求
288
309
 
@@ -317,6 +338,25 @@ npx @deepseek-ai/dsh --profile web --dump-config | grep vision-router
317
338
 
318
339
  首次把插件装进已经长期运行的 Web profile 时,需要让 Web 进程重新加载插件本体;宿主在启动时通过 `dsh.client` 声明发现浏览器端包。**插件加载完成后,模型目录与包装范围的变化会热更新,不需要为这些变化重启。**
319
340
 
341
+ ### Oh-DSH Desktop
342
+
343
+ [Oh-DSH Desktop](https://github.com/hust-open-atom-club/oh-dsh) 自带一套独立打包的 DSH 运行时和独立的数据目录:桌面端实际运行的是 `~/.ohdsh` 下的 `desktop` profile,**不会**加载普通 `~/.dsh` 的 profile。因此上面 `--profile web` 的命令在 Oh-DSH Desktop 上会装错环境。
344
+
345
+ 把 `DSH_HOME` 指向 Oh-DSH 的数据目录再安装即可:
346
+
347
+ ```sh
348
+ DSH_HOME=~/.ohdsh npx @deepseek-ai/dsh plugin --profile desktop add dsh-vision-router
349
+ ```
350
+
351
+ (Windows PowerShell 先执行 `$env:DSH_HOME = "$env:USERPROFILE\.ohdsh"`,再运行同一命令。)
352
+
353
+ > [!WARNING]
354
+ > Oh-DSH Desktop ≤ 0.1.5 内置的是 DSH `0.1.0-rc.5`。`dsh-vision-router` v1.4.1 及更早版本会让该运行时在启动时崩溃(报 `configurable provider "deepseek-official" is already declared`,在 Oh-DSH Desktop 里表现为 `DSH runtime exited before readiness`)。请安装 v1.4.2+。
355
+
356
+ 如果错误安装已经导致 Desktop 无法启动:打开 `~/.ohdsh/profiles/desktop/package.json`,从 `dependencies` 和 `dsh.profile.bundles` 中删掉 `dsh-vision-router` 条目,保存后重启 Desktop。
357
+
358
+ Oh-DSH Desktop 内置的插件市场(搜索 → 准备 → 隔离预览 → 应用,并保留 `previous` 快照用于恢复)在社区目录收录本插件后同样可用;不要与上面的直接安装命令混用。其内置的 `@oh-dsh/vision`(`view_image`)与本插件可共存,工具名不冲突。
359
+
320
360
  ### 禁用 / 恢复
321
361
 
322
362
  ```yaml
package/index.js CHANGED
@@ -31,6 +31,11 @@ import { createRequire } from 'node:module'
31
31
  import { pathToFileURL } from 'node:url'
32
32
  import { promisify } from 'node:util'
33
33
  import { appendPromptToImageOnlyMessage, fetchWithOpenAICompatibility } from './lib/http-compat.js'
34
+ import {
35
+ routingCorrectionFor,
36
+ toAnthropicMessages,
37
+ callAnthropicCompatible,
38
+ } from './lib/catalog-corrections.js'
34
39
  import { createCachedUpdateChecker } from './lib/update-check.js'
35
40
  import { detectDshSelfUpdatePlan, runDshPluginUpdate } from './lib/self-update.js'
36
41
  import {
@@ -254,6 +259,13 @@ export const Config = z.object({
254
259
  // id (or "provider/model") per entry. Only consulted for vision BACKEND
255
260
  // capability (the session-side admission stays host-owned).
256
261
  extraVisionModels: z.array(z.string()).default([]),
262
+ // Built-in catalog-routing corrections (see lib/catalog-corrections.js):
263
+ // when the installed pi-ai catalog routes a known provider/model to the
264
+ // wrong wire protocol (e.g. opencode-go/qwen3.6-plus to openai-completions
265
+ // while the gateway only serves it on /v1/messages), the plugin dispatches
266
+ // that pair directly over the corrected protocol instead of the harness
267
+ // adapter. Each correction disarms itself once the catalog entry matches.
268
+ catalogCorrections: z.boolean().default(true),
257
269
  // Client-persisted UI state (issue #78): DSH Desktop serves the Web UI from
258
270
  // a random port on every launch, so origin-scoped localStorage forgets the
259
271
  // first-run onboarding dialog and it re-appeared on every boot. These keys
@@ -1952,6 +1964,15 @@ export function createNativeDeepSeekAdapter(ctx) {
1952
1964
  * turns with continuous multi-step operations.
1953
1965
  */
1954
1966
  export function createWrapperStreamBody(ctx, { imageMemory, delegateProvider, preserveImageInput }) {
1967
+ // issue #103: the reasoning level is a per-session picker choice (the chat
1968
+ // page's bottom-right selector), but the host can drop reasoningEffort from
1969
+ // the later steps of a multi-step turn once the twin metadata lacks a
1970
+ // reasoning.defaultEffort (only step 1 thinks). Remember the last explicit
1971
+ // effort seen per delegate — whatever the user actually picked — and
1972
+ // re-inject it when a later call arrives without one, so every step keeps
1973
+ // the user's chosen level. The vision chain never flows through this body
1974
+ // and keeps its own reasoningEffort: undefined.
1975
+ const lastReasoningEffort = new Map() // "provider\0model" -> last explicit effort
1955
1976
  return {
1956
1977
  async *stream(options) {
1957
1978
  const messages = options.messages ?? []
@@ -2006,8 +2027,21 @@ export function createWrapperStreamBody(ctx, { imageMemory, delegateProvider, pr
2006
2027
  })
2007
2028
  return result.changed ? { ...message, content: result.content } : message
2008
2029
  })
2030
+ // Remember per delegate+model rather than per delegate alone: the
2031
+ // stream boundary carries no session id, so provider+model is the
2032
+ // narrowest scope available and keeps two concurrent sessions on the
2033
+ // same twin from sharing one memory slot.
2034
+ const effortKey = `${delegateProvider}\u0000${options.model ?? ''}`
2035
+ let effort = typeof options.reasoningEffort === 'string' && options.reasoningEffort !== ''
2036
+ ? options.reasoningEffort
2037
+ : undefined
2038
+ if (effort !== undefined) {
2039
+ lastReasoningEffort.set(effortKey, effort)
2040
+ } else {
2041
+ effort = lastReasoningEffort.get(effortKey)
2042
+ }
2009
2043
  yield* ctx.llm.stream({
2010
- ...options,
2044
+ ...(effort === undefined ? options : { ...options, reasoningEffort: effort }),
2011
2045
  provider: delegateProvider,
2012
2046
  messages: rewritten,
2013
2047
  })
@@ -2417,12 +2451,26 @@ export function apply(ctx, config = {}) {
2417
2451
  // ourselves is the only way to keep the DeepSeek models in the picker.
2418
2452
  // The settings card surfaces this condition as a hint, and re-enabling
2419
2453
  // the stock row restores the fully official route.
2420
- const officialRouteAlive = adapterAvailable(ctx.llm, 'deepseek-official')
2421
- const takeoverReason = stealthEnabled ? 'stealth' : officialRouteAlive ? undefined : 'official-unavailable'
2454
+ //
2455
+ // The takeover decision runs AFTER a short settle window, never inside
2456
+ // apply(): entry activation is service-driven, so this row can apply
2457
+ // BEFORE the stock llm-deepseek row (reproduced on DSH 0.1.0-rc.5 hosts,
2458
+ // e.g. Oh-DSH Desktop). Deciding synchronously misreads the not-yet-applied
2459
+ // stock route as dead, and our directory registration then makes the stock
2460
+ // row's own registration throw DUPLICATE_DIRECTORY, killing the whole
2461
+ // runtime before readiness. Once the window elapses, a registered stock
2462
+ // route means hands off; a still-dead route means the row is genuinely
2463
+ // absent/disabled and the takeover is safe.
2464
+ const KEEPALIVE_SETTLE_MS = 2000
2422
2465
  const nativeRoute = 'deepseek-official-native'
2423
2466
  let stealthActive = false
2467
+ let takeoverReason
2424
2468
  let nativeAdapter
2425
- if (takeoverReason !== undefined) {
2469
+ let takeoverAttempted = false
2470
+ const attemptTakeover = (reason) => {
2471
+ if (takeoverAttempted) return
2472
+ takeoverAttempted = true
2473
+ takeoverReason = reason
2426
2474
  try {
2427
2475
  nativeAdapter = createNativeDeepSeekAdapter(ctx)
2428
2476
  const nativeHandle = ctx.llm.registerAdapter([nativeRoute], {
@@ -2470,14 +2518,33 @@ export function apply(ctx, config = {}) {
2470
2518
  /* the stock row may still own the directory entry */
2471
2519
  }
2472
2520
  } catch (error) {
2521
+ nativeAdapter = undefined
2473
2522
  stealthActive = false
2474
2523
  ctx.logger?.warn(
2475
2524
  'vision-router: deepseek-official takeover skipped (%s: %s); keeping the visible wrapper',
2476
- takeoverReason,
2525
+ reason,
2477
2526
  error && error.message ? error.message : String(error),
2478
2527
  )
2479
2528
  }
2480
2529
  }
2530
+ const maybeTakeover = () => {
2531
+ if (!takeoverSettled || stealthActive || takeoverAttempted) return
2532
+ if (adapterAvailable(ctx.llm, 'deepseek-official')) {
2533
+ if (stealthEnabled) {
2534
+ ctx.logger?.warn(
2535
+ 'vision-router: stealth is enabled but the stock deepseek-official route is alive; disable the llm-deepseek row to take it over',
2536
+ )
2537
+ }
2538
+ return
2539
+ }
2540
+ attemptTakeover(stealthEnabled ? 'stealth' : 'official-unavailable')
2541
+ }
2542
+ let takeoverSettled = false
2543
+ const settleTimer = setTimeout(() => {
2544
+ takeoverSettled = true
2545
+ maybeTakeover()
2546
+ }, KEEPALIVE_SETTLE_MS)
2547
+ ctx.effect(() => () => clearTimeout(settleTimer), 'vision-router: takeover settle timer')
2481
2548
  // ── vision-http route: first-class llm route over the OpenAI-compatible
2482
2549
  // http providers. The built-in OVHcloud anonymous endpoint (no account, no
2483
2550
  // key, 2 req/min/IP) is the DEFAULT vision model, so a fresh install works
@@ -2642,7 +2709,13 @@ export function apply(ctx, config = {}) {
2642
2709
  // declares image input so the admission passes, shows up in the model
2643
2710
  // picker as "DeepSeek + 自动识图", and delegates to the real text-provider
2644
2711
  // adapter for anything the waterfalls did not rewrite.
2645
- if (wrapperRoute() !== undefined) {
2712
+ //
2713
+ // The adapter is built unconditionally; whether (and under which name) it
2714
+ // mounts is reconciled reactively against the resolved settings document by
2715
+ // syncRoutingMounts() below, so the card's wrapperRoute/routing switches
2716
+ // take effect without a restart.
2717
+ let wrapperAdapter
2718
+ {
2646
2719
  const WRAPPER_MODEL_IDS = ['deepseek-v4-pro', 'deepseek-v4-flash']
2647
2720
  const wrapName = (name) => name ?? 'DeepSeek'
2648
2721
  const textProviderRoute = () => (stealthActive ? nativeRoute : textProvider().provider)
@@ -2653,7 +2726,7 @@ export function apply(ctx, config = {}) {
2653
2726
  return undefined
2654
2727
  }
2655
2728
  }
2656
- const wrapperAdapter = {
2729
+ wrapperAdapter = {
2657
2730
  providerInfo(provider) {
2658
2731
  return { id: provider, name: 'DeepSeek + 自动识图' }
2659
2732
  },
@@ -2742,9 +2815,6 @@ export function apply(ctx, config = {}) {
2742
2815
  delegateProvider: textProviderRoute(),
2743
2816
  }),
2744
2817
  }
2745
- const handle = ctx.llm.registerAdapter([wrapperRoute()], wrapperAdapter)
2746
- wrapperRegistered = true
2747
- ctx.effect(() => handle, 'vision-router: wrapper route')
2748
2818
  }
2749
2819
 
2750
2820
 
@@ -2817,6 +2887,12 @@ export function apply(ctx, config = {}) {
2817
2887
  return false
2818
2888
  }
2819
2889
  }
2890
+ // issue #103: the twin mirrors the source metadata one-to-one, so any
2891
+ // reasoning.defaultEffort the source advertises is inherited as-is. The
2892
+ // reasoning LEVEL itself stays a per-session picker choice (bottom-right
2893
+ // selector): the wrapper body below preserves it across the twin switch
2894
+ // by remembering the last explicit effort and re-injecting it on the
2895
+ // later steps that arrive without one.
2820
2896
  return {
2821
2897
  providerInfo() {
2822
2898
  const original = originalAdapter()
@@ -2992,6 +3068,27 @@ export function apply(ctx, config = {}) {
2992
3068
  return undefined
2993
3069
  }
2994
3070
  }
3071
+ // Wire facts of one resolved catalog entry — the fingerprint a routing
3072
+ // correction is checked against ({ api, baseUrl }). Undefined when the
3073
+ // provider is not owned by the pi-ai adapter or the entry cannot be read:
3074
+ // corrections fail closed and the normal harness path keeps the call.
3075
+ const resolvedCatalogFactsOf = (provider, model) => {
3076
+ try {
3077
+ const profile = resolvedPiAiProfileOf(provider)
3078
+ const getModels = profile && profile.piProvider && profile.piProvider.getModels
3079
+ if (typeof getModels !== 'function') return undefined
3080
+ const models = getModels.call(profile.piProvider)
3081
+ if (!Array.isArray(models)) return undefined
3082
+ const entry = models.find((candidate) => candidate && String(candidate.id) === String(model))
3083
+ if (!entry) return undefined
3084
+ return {
3085
+ api: typeof entry.api === 'string' ? entry.api : undefined,
3086
+ baseUrl: typeof entry.baseUrl === 'string' ? entry.baseUrl : '',
3087
+ }
3088
+ } catch {
3089
+ return undefined
3090
+ }
3091
+ }
2995
3092
  const channelBridgePlan = (provider, model) => {
2996
3093
  const rawProfile = rawChannelProfileOf(provider)
2997
3094
  const resolvedProfile = resolvedPiAiProfileOf(provider)
@@ -3071,6 +3168,99 @@ export function apply(ctx, config = {}) {
3071
3168
  )
3072
3169
  }
3073
3170
 
3171
+ // ── catalog routing corrections (lib/catalog-corrections.js) ─────────────
3172
+ //
3173
+ // Known provider/model pairs whose installed pi-ai catalog routes them to
3174
+ // the wrong wire protocol (opencode-go/qwen3.6-plus → openai-completions,
3175
+ // while the gateway only serves it on /v1/messages). While the resolved
3176
+ // catalog still shows the broken facts, the pair is dispatched directly
3177
+ // over the corrected protocol; the moment upstream fixes the catalog (or
3178
+ // the user points the route at their own gateway) the correction disarms
3179
+ // itself and the pair returns to the harness path.
3180
+ const routingCorrectionForPair = async (pair) => {
3181
+ if (!pair || current().catalogCorrections === false) return undefined
3182
+ const correction = routingCorrectionFor(
3183
+ resolvedCatalogFactsOf(pair.provider, pair.model),
3184
+ pair.provider,
3185
+ pair.model,
3186
+ )
3187
+ if (correction === undefined) return undefined
3188
+ const rawProfile = rawChannelProfileOf(pair.provider)
3189
+ const resolvedProfile = resolvedPiAiProfileOf(pair.provider)
3190
+ const apiKeyEnv = [rawProfile, resolvedProfile]
3191
+ .map((profile) => (profile && typeof profile.apiKeyEnv === 'string' ? profile.apiKeyEnv : ''))
3192
+ .find((ref) => ref !== '')
3193
+ return {
3194
+ ...correction,
3195
+ plan: {
3196
+ rawProfile,
3197
+ resolvedProfile,
3198
+ transport: {
3199
+ baseURL: correction.baseURL,
3200
+ api: correction.api,
3201
+ ...(apiKeyEnv === undefined ? {} : { apiKeyEnv }),
3202
+ },
3203
+ },
3204
+ }
3205
+ }
3206
+
3207
+ /**
3208
+ * One vision-model answer through a corrected route, or undefined when the
3209
+ * pair has no active correction (callers then use the harness path). The
3210
+ * credential is resolved exactly like the harness adapter would resolve it:
3211
+ * the route's apiKeyEnv first, then the catalog provider's native auth
3212
+ * (process.env.OPENCODE_API_KEY for opencode-go).
3213
+ */
3214
+ const correctedVisionAnswer = async (pair, messages, options = {}) => {
3215
+ const correction = await routingCorrectionForPair(pair)
3216
+ if (correction === undefined) return undefined
3217
+ const apiKey = await resolveChannelApiKey(correction.plan)
3218
+ if (apiKey === undefined || apiKey === '') {
3219
+ throw new Error(
3220
+ `corrected route "${pair.provider}/${pair.model}": channel credential could not be resolved`,
3221
+ )
3222
+ }
3223
+ const attachments = ctx.get('attachments')
3224
+ if (attachments === undefined) {
3225
+ throw new Error('corrected route: the attachment service is not registered')
3226
+ }
3227
+ const bytesOf = async (attachment) => {
3228
+ const stored = await attachments.readImage(attachment)
3229
+ let bytes = stored.data
3230
+ if (downscaleEnabled() && bytes && bytes.length > 0) {
3231
+ bytes = await downscaleImage(bytes, downscaleMaxPixels())
3232
+ }
3233
+ return bytes
3234
+ }
3235
+ const anthropic = await toAnthropicMessages(messages, bytesOf)
3236
+ if (anthropic.messages.length === 0) {
3237
+ throw new Error(`corrected route "${pair.provider}/${pair.model}": no representable content to send`)
3238
+ }
3239
+ return callAnthropicCompatible(
3240
+ { name: pair.provider, baseURL: correction.baseURL, model: pair.model, apiKeyEnv: '' },
3241
+ anthropic.messages,
3242
+ {
3243
+ system: anthropic.system,
3244
+ maxTokens: options.maxTokens ?? 4096,
3245
+ signal: options.signal,
3246
+ apiKey,
3247
+ },
3248
+ )
3249
+ }
3250
+
3251
+ /** Shared single-answer dispatch: corrected route first, harness path otherwise. */
3252
+ const callVisionPair = async (pair, messages, options = {}) => {
3253
+ const corrected = await correctedVisionAnswer(pair, messages, options)
3254
+ if (corrected !== undefined) return corrected
3255
+ return visionAnswer(ctx.llm, {
3256
+ provider: pair.provider,
3257
+ model: pair.model,
3258
+ messages,
3259
+ maxTokens: options.maxTokens ?? 4096,
3260
+ signal: options.signal,
3261
+ })
3262
+ }
3263
+
3074
3264
  const collectVisionBackendCapabilities = async () => {
3075
3265
  const capabilities = {}
3076
3266
  if (typeof ctx.llm.listProviders !== 'function') return capabilities
@@ -3143,8 +3333,13 @@ export function apply(ctx, config = {}) {
3143
3333
  // model-switch retry. To make fallback reliable, image turns are routed to
3144
3334
  // this chain adapter instead; it walks the configured providers itself and
3145
3335
  // only surfaces a failure once every model has failed.
3146
- if (chainRoute() !== undefined && routingEnabled()) {
3147
- const chainAdapter = {
3336
+ //
3337
+ // Built unconditionally; syncRoutingMounts() below mounts it whenever the
3338
+ // resolved settings enable routing, so the card's routing switch takes
3339
+ // effect without a restart.
3340
+ let chainAdapter
3341
+ {
3342
+ chainAdapter = {
3148
3343
  providerInfo(provider) {
3149
3344
  return { id: provider, name: 'Vision Chain' }
3150
3345
  },
@@ -3260,14 +3455,35 @@ export function apply(ctx, config = {}) {
3260
3455
  let failed = false
3261
3456
  let failMessage = 'unknown error'
3262
3457
  try {
3263
- for await (const chunk of ctx.llm.stream({
3264
- ...options,
3265
- provider: pair.provider,
3266
- model: pair.model,
3267
- reasoningEffort: undefined,
3268
- messages,
3269
- signal: combineSignals(options.signal, deadline.signal(), AbortSignal.timeout(timeoutMs())),
3270
- })) {
3458
+ const streamPair = async function* () {
3459
+ // Catalog routing corrections: pairs whose installed catalog
3460
+ // points at the wrong wire protocol are answered directly over
3461
+ // the corrected endpoint instead of the harness adapter.
3462
+ const text = await correctedVisionAnswer(pair, messages, {
3463
+ maxTokens: options.maxTokens ?? 65536,
3464
+ signal: combineSignals(options.signal, deadline.signal(), AbortSignal.timeout(timeoutMs())),
3465
+ })
3466
+ if (text === undefined) {
3467
+ yield* ctx.llm.stream({
3468
+ ...options,
3469
+ provider: pair.provider,
3470
+ model: pair.model,
3471
+ reasoningEffort: undefined,
3472
+ messages,
3473
+ signal: combineSignals(options.signal, deadline.signal(), AbortSignal.timeout(timeoutMs())),
3474
+ })
3475
+ return
3476
+ }
3477
+ if (text !== '') {
3478
+ // Same chunk protocol as the vision-http route: a bare
3479
+ // text-delta without block frames is dropped by assemblers.
3480
+ yield { type: 'block-start', index: 0, blockType: 'text' }
3481
+ yield { type: 'text-delta', index: 0, text }
3482
+ yield { type: 'block-end', index: 0, block: { type: 'text', text } }
3483
+ }
3484
+ yield { type: 'finish', reason: { kind: 'stop' } }
3485
+ }
3486
+ for await (const chunk of streamPair()) {
3271
3487
  if (chunk && chunk.type === 'finish') {
3272
3488
  const kind = chunk.reason && chunk.reason.kind
3273
3489
  if (kind === 'error' || kind === 'aborted') {
@@ -3317,9 +3533,79 @@ export function apply(ctx, config = {}) {
3317
3533
  }
3318
3534
  },
3319
3535
  }
3320
- const handle = ctx.llm.registerAdapter([chainRoute()], chainAdapter)
3321
- ctx.effect(() => handle, 'vision-router: chain route')
3322
3536
  }
3537
+
3538
+ // ── reactive routing mounts ────────────────────────────────────────────────
3539
+ //
3540
+ // Legacy routing used to be composition-gated at apply time while the
3541
+ // settings card exposes the same switches — flipping them mid-session left
3542
+ // the flow half-wired (hooks reading the settings document against mounts
3543
+ // registered from the composition). The wrapper route, the chain route and
3544
+ // the agent/request hook now reconcile against the resolved settings
3545
+ // document: the settings seam below runs one initial sync and re-syncs on
3546
+ // every document change, so toggles take effect immediately.
3547
+ let wrapperRouteHandle
3548
+ let wrapperRouteMounted
3549
+ let chainRouteHandle
3550
+ let chainRouteMounted
3551
+ const syncRoutingMounts = () => {
3552
+ const wantWrapper = wrapperRoute()
3553
+ if (wantWrapper !== wrapperRouteMounted) {
3554
+ if (wrapperRouteHandle) {
3555
+ wrapperRouteHandle()
3556
+ wrapperRouteHandle = undefined
3557
+ wrapperRouteMounted = undefined
3558
+ }
3559
+ wrapperRegistered = false
3560
+ if (wantWrapper !== undefined) {
3561
+ try {
3562
+ wrapperRouteHandle = ctx.llm.registerAdapter([wantWrapper], wrapperAdapter)
3563
+ wrapperRouteMounted = wantWrapper
3564
+ wrapperRegistered = true
3565
+ } catch (error) {
3566
+ if (error && error.code === 'DUPLICATE_ADAPTER') {
3567
+ // Another layer already serves this name: adopt it and keep the
3568
+ // flow functional instead of failing the apply.
3569
+ wrapperRouteMounted = wantWrapper
3570
+ wrapperRegistered = true
3571
+ } else {
3572
+ throw error
3573
+ }
3574
+ }
3575
+ }
3576
+ }
3577
+ const wantChain = routingEnabled() ? chainRoute() : undefined
3578
+ if (wantChain !== chainRouteMounted) {
3579
+ if (chainRouteHandle) {
3580
+ chainRouteHandle()
3581
+ chainRouteHandle = undefined
3582
+ chainRouteMounted = undefined
3583
+ }
3584
+ if (wantChain !== undefined) {
3585
+ try {
3586
+ chainRouteHandle = ctx.llm.registerAdapter([wantChain], chainAdapter)
3587
+ chainRouteMounted = wantChain
3588
+ } catch (error) {
3589
+ if (error && error.code === 'DUPLICATE_ADAPTER') {
3590
+ chainRouteMounted = wantChain
3591
+ } else {
3592
+ throw error
3593
+ }
3594
+ }
3595
+ }
3596
+ }
3597
+ }
3598
+ ctx.effect(
3599
+ () => () => {
3600
+ if (wrapperRouteHandle) wrapperRouteHandle()
3601
+ if (chainRouteHandle) chainRouteHandle()
3602
+ wrapperRouteHandle = undefined
3603
+ chainRouteHandle = undefined
3604
+ wrapperRouteMounted = undefined
3605
+ chainRouteMounted = undefined
3606
+ },
3607
+ 'vision-router: reactive routing mounts',
3608
+ )
3323
3609
  // session -> Map<attachmentId, ref> (uploaded images visible to vision_describe)
3324
3610
  const sessionAttachments = new WeakMap()
3325
3611
  // secondary index by session id string (agent.session object identity can change across turns)
@@ -3717,52 +4003,54 @@ export function apply(ctx, config = {}) {
3717
4003
  return sanitizedToolResults.changed ? { ...decision, messages } : decision
3718
4004
  })
3719
4005
 
3720
- if (routingEnabled()) {
3721
- ctx.on('agent/request', async (payload, next) => {
3722
- const config0 = await next()
3723
- const session = payload.agent && payload.agent.session
3724
- if (!session) return config0
3725
- const state = turnState.get(session)
3726
- if (!state || state.turn !== payload.turn) return config0
3727
- if (!state.hasImage) {
3728
- const events = session.events ?? []
3729
- for (let i = state.startIndex; i < events.length; i++) {
3730
- if (eventHasImage(events[i])) {
3731
- state.hasImage = true
3732
- break
3733
- }
3734
- }
4006
+ // Registered unconditionally and gated at runtime so the settings card's
4007
+ // routing switch takes effect immediately (syncRoutingMounts reconciles the
4008
+ // adapters the same way).
4009
+ ctx.on('agent/request', async (payload, next) => {
4010
+ const config0 = await next()
4011
+ if (!routingEnabled()) return config0
4012
+ const session = payload.agent && payload.agent.session
4013
+ if (!session) return config0
4014
+ const state = turnState.get(session)
4015
+ if (!state || state.turn !== payload.turn) return config0
4016
+ if (!state.hasImage) {
4017
+ const events = session.events ?? []
4018
+ for (let i = state.startIndex; i < events.length; i++) {
4019
+ if (eventHasImage(events[i])) {
4020
+ state.hasImage = true
4021
+ break
3735
4022
  }
3736
- if (!state.hasImage) {
3737
- // Reverse routing: the session's entry model is a vision provider
3738
- // (needed to pass the prompt admission); send text-only turns back
3739
- // to the text provider (DeepSeek) so daily work stays on it.
3740
- if (reverseRoutingEnabled()) {
3741
- const target = reverseRouteTarget(config0, {
3742
- pairs: pairs(),
3743
- wrapperRoute: wrapperRoute(),
3744
- wrapperRegistered,
3745
- textProvider: textProvider(),
3746
- hasAdapter: (provider) => adapterAvailable(ctx.llm, provider),
3747
- })
3748
- if (target !== undefined) {
3749
- return switchRoute(config0, target.provider, target.model)
3750
- }
4023
+ }
4024
+ }
4025
+ if (!state.hasImage) {
4026
+ // Reverse routing: the session's entry model is a vision provider
4027
+ // (needed to pass the prompt admission); send text-only turns back
4028
+ // to the text provider (DeepSeek) so daily work stays on it.
4029
+ if (reverseRoutingEnabled()) {
4030
+ const target = reverseRouteTarget(config0, {
4031
+ pairs: pairs(),
4032
+ wrapperRoute: wrapperRoute(),
4033
+ wrapperRegistered,
4034
+ textProvider: textProvider(),
4035
+ hasAdapter: (provider) => adapterAvailable(ctx.llm, provider),
4036
+ })
4037
+ if (target !== undefined) {
4038
+ return switchRoute(config0, target.provider, target.model)
3751
4039
  }
3752
- return config0
3753
4040
  }
3754
- // Route the image turn to the chain adapter (falls back under our own
3755
- // control), or directly to the first vision model when the chain route
3756
- // is disabled.
3757
- if (chainRoute() !== undefined) {
3758
- if (config0.provider === chainRoute()) return config0
3759
- return switchRoute(config0, chainRoute(), `${pairs()[0].provider}/${pairs()[0].model}`)
3760
- }
3761
- const first = pairs()[0]
3762
- if (first === undefined || config0.provider === first.provider) return config0
3763
- return switchRoute(config0, first.provider, first.model)
3764
- })
3765
- }
4041
+ return config0
4042
+ }
4043
+ // Route the image turn to the chain adapter (falls back under our own
4044
+ // control), or directly to the first vision model when the chain route
4045
+ // is disabled.
4046
+ if (chainRoute() !== undefined) {
4047
+ if (config0.provider === chainRoute()) return config0
4048
+ return switchRoute(config0, chainRoute(), `${pairs()[0].provider}/${pairs()[0].model}`)
4049
+ }
4050
+ const first = pairs()[0]
4051
+ if (first === undefined || config0.provider === first.provider) return config0
4052
+ return switchRoute(config0, first.provider, first.model)
4053
+ })
3766
4054
 
3767
4055
  if (toolEnabled()) {
3768
4056
  const deepToolDefs = []
@@ -3989,13 +4277,7 @@ export function apply(ctx, config = {}) {
3989
4277
  try {
3990
4278
  let messages = baseMessages
3991
4279
  const signal = combineSignals(deadline.signal(), AbortSignal.timeout(timeoutMs()))
3992
- let text = await visionAnswer(ctx.llm, {
3993
- provider: pair.provider,
3994
- model: pair.model,
3995
- messages,
3996
- maxTokens: 4096,
3997
- signal,
3998
- })
4280
+ let text = await callVisionPair(pair, messages, { maxTokens: 4096, signal })
3999
4281
  if (wantJson) {
4000
4282
  for (let attempt = 0; attempt < 2; attempt++) {
4001
4283
  const parsed = extractJson(text)
@@ -4018,13 +4300,7 @@ export function apply(ctx, config = {}) {
4018
4300
  source: { kind: 'plugin', plugin: 'dsh-vision-router' },
4019
4301
  },
4020
4302
  ]
4021
- text = await visionAnswer(ctx.llm, {
4022
- provider: pair.provider,
4023
- model: pair.model,
4024
- messages,
4025
- maxTokens: 4096,
4026
- signal,
4027
- })
4303
+ text = await callVisionPair(pair, messages, { maxTokens: 4096, signal })
4028
4304
  }
4029
4305
  }
4030
4306
  const fallback = `vision_describe: the model did not produce valid JSON. Raw output:\n${text.slice(0, 2000)}`
@@ -4352,15 +4628,14 @@ export function apply(ctx, config = {}) {
4352
4628
  pairCapabilities.set(pairKey, pairCapability)
4353
4629
  }
4354
4630
  try {
4355
- const text = await visionAnswer(ctx.llm, {
4356
- provider: pair.provider,
4357
- model: pair.model,
4358
- messages: [
4359
- { role: 'user', content: [block, { type: 'text', text: instruction }] },
4360
- ],
4361
- maxTokens: 4096,
4362
- signal: combineSignals(deadline.signal(), AbortSignal.timeout(timeoutMs())),
4363
- })
4631
+ const text = await callVisionPair(
4632
+ pair,
4633
+ [{ role: 'user', content: [block, { type: 'text', text: instruction }] }],
4634
+ {
4635
+ maxTokens: 4096,
4636
+ signal: combineSignals(deadline.signal(), AbortSignal.timeout(timeoutMs())),
4637
+ },
4638
+ )
4364
4639
  if (text && text.trim() !== '') return { ok: true, text: text.trim() }
4365
4640
  } catch (error) {
4366
4641
  const classification = classifyVisionFailure(error)
@@ -5337,6 +5612,9 @@ export function apply(ctx, config = {}) {
5337
5612
  base: config,
5338
5613
  })
5339
5614
  current = () => scope.get()
5615
+ // With the settings document now visible, reconcile the routing mounts
5616
+ // (wrapper route, chain route) against the resolved values.
5617
+ syncRoutingMounts()
5340
5618
  sctx.effect(
5341
5619
  () => () => {
5342
5620
  // The settings provider went away: fall back to the composition entry.
@@ -5346,9 +5624,10 @@ export function apply(ctx, config = {}) {
5346
5624
  )
5347
5625
  scope.watch(() => {
5348
5626
  // Most consumers read current() per call, but the wrappedProviders
5349
- // twins are registered eagerly: re-sync them whenever the settings
5350
- // document loads or the user edits the wrappers section.
5627
+ // twins and the routing mounts are registered eagerly: re-sync them
5628
+ // whenever the settings document loads or the user edits the card.
5351
5629
  syncTwins()
5630
+ syncRoutingMounts()
5352
5631
  })
5353
5632
  })
5354
5633
 
@@ -0,0 +1,278 @@
1
+ // Known catalog-routing corrections: provider/model entries where the
2
+ // installed pi-ai model catalog disagrees with the provider's OFFICIAL
3
+ // endpoint table. Each entry names the wire protocol (api) and baseURL the
4
+ // model actually speaks. A correction only engages when the harness-resolved
5
+ // catalog entry still routes that pair to a DIFFERENT protocol — the moment
6
+ // the upstream catalog is fixed, the correction disarms itself and the normal
7
+ // harness path resumes. This is deliberately data, not scattered if-blocks:
8
+ // when a gateway renames or adds an affected model, extend the table.
9
+
10
+ import { kindForHttpStatus } from './vision-resilience.js'
11
+
12
+ export const CATALOG_ROUTING_CORRECTIONS = [
13
+ {
14
+ provider: 'opencode-go',
15
+ model: 'qwen3.6-plus',
16
+ // OpenCode Go official endpoints (https://opencode.ai/docs/go): Qwen3.6
17
+ // Plus is served ONLY through the Anthropic Messages endpoint
18
+ // (https://opencode.ai/zen/go/v1/messages, @ai-sdk/anthropic). The pi-ai
19
+ // catalog still classifies it as openai-completions, so the harness sends
20
+ // vision requests to /v1/chat/completions where the gateway falls back to
21
+ // a different model (user report: MiniMax M3 answered instead).
22
+ api: 'anthropic-messages',
23
+ baseURL: 'https://opencode.ai/zen/go',
24
+ // The exact broken configuration this correction recognizes. A route the
25
+ // user pointed elsewhere (or a catalog fixed upstream) never engages it.
26
+ wrongApi: 'openai-completions',
27
+ wrongBaseURLPrefix: 'opencode.ai/zen/go',
28
+ },
29
+ {
30
+ provider: 'opencode-go',
31
+ model: 'minimax-m2.7',
32
+ // Same misclassification as qwen3.6-plus; the catalog marks it text-only,
33
+ // so it never appears as a vision backend today — corrected for chat use
34
+ // and in case a future catalog declares image input on the wrong protocol.
35
+ api: 'anthropic-messages',
36
+ baseURL: 'https://opencode.ai/zen/go',
37
+ wrongApi: 'openai-completions',
38
+ wrongBaseURLPrefix: 'opencode.ai/zen/go',
39
+ },
40
+ ]
41
+
42
+ /**
43
+ * Decide whether a routing correction engages for one resolved backend.
44
+ *
45
+ * @param facts - the harness-resolved catalog facts for the pair:
46
+ * `{ api, baseUrl }`. Undefined (or an unknown api) fails closed — the
47
+ * plugin never bypasses the harness for a route it cannot fingerprint.
48
+ * @param provider - provider id.
49
+ * @param model - model id.
50
+ * @param enabled - the runtime switch (settings `catalogCorrections`).
51
+ * @returns the correction entry to dispatch through, or undefined to use the
52
+ * normal harness path.
53
+ */
54
+ export function routingCorrectionFor(facts, provider, model, enabled = true) {
55
+ if (enabled === false) return undefined
56
+ if (!facts || typeof facts.api !== 'string' || facts.api === '') return undefined
57
+ const entry = CATALOG_ROUTING_CORRECTIONS.find(
58
+ (candidate) => candidate.provider === provider && candidate.model === model,
59
+ )
60
+ if (entry === undefined) return undefined
61
+ // Upstream fixed: the catalog now agrees with the correction — stand down.
62
+ if (entry.api === facts.api) return undefined
63
+ // The resolved route is broken in some OTHER way (or speaks yet another
64
+ // protocol); not the configuration this correction was written for.
65
+ if (entry.wrongApi !== undefined && entry.wrongApi !== facts.api) return undefined
66
+ // A user route pointed at their own gateway keeps its own routing: only the
67
+ // exact catalog baseURL this correction documents gets intercepted.
68
+ if (entry.wrongBaseURLPrefix !== undefined) {
69
+ const baseUrl = String(facts.baseUrl ?? '')
70
+ if (!baseUrl.includes(entry.wrongBaseURLPrefix)) return undefined
71
+ }
72
+ return entry
73
+ }
74
+
75
+ /** Normalize attachment media types to the set the Anthropic wire accepts. */
76
+ export function anthropicMediaType(mediaType) {
77
+ const value = String(mediaType ?? '').toLowerCase()
78
+ if (value === 'image/jpg') return 'image/jpeg'
79
+ return value
80
+ }
81
+
82
+ function parseArguments(value) {
83
+ if (value && typeof value === 'object' && !Array.isArray(value)) return value
84
+ if (typeof value === 'string' && value.trim() !== '') {
85
+ try {
86
+ const parsed = JSON.parse(value)
87
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed
88
+ } catch {
89
+ /* keep the empty object below */
90
+ }
91
+ }
92
+ return {}
93
+ }
94
+
95
+ async function convertBlocks(blocks, bytesOf, depth = 0) {
96
+ const out = []
97
+ if (!Array.isArray(blocks) || depth > 4) return out
98
+ for (const block of blocks) {
99
+ if (!block) continue
100
+ if (block.type === 'text' && typeof block.text === 'string' && block.text !== '') {
101
+ out.push({ type: 'text', text: block.text })
102
+ } else if (block.type === 'image') {
103
+ if (typeof bytesOf !== 'function') continue
104
+ let bytes
105
+ try {
106
+ bytes = await bytesOf(block.attachment)
107
+ } catch {
108
+ continue // an unreadable image must not sink the whole request
109
+ }
110
+ if (!bytes || bytes.length === 0) continue
111
+ out.push({
112
+ type: 'image',
113
+ source: {
114
+ type: 'base64',
115
+ media_type: anthropicMediaType(block.attachment && block.attachment.mediaType) || 'image/png',
116
+ data: Buffer.from(bytes).toString('base64'),
117
+ },
118
+ })
119
+ } else if (block.type === 'tool-call') {
120
+ out.push({
121
+ type: 'tool_use',
122
+ id: typeof block.id === 'string' ? block.id : `call_${out.length}`,
123
+ name: typeof block.name === 'string' ? block.name : 'tool',
124
+ input: parseArguments(block.arguments),
125
+ })
126
+ } else if (block.type === 'tool-result') {
127
+ const nested = await convertBlocks(block.content, bytesOf, depth + 1)
128
+ if (nested.length > 0) {
129
+ out.push({
130
+ type: 'tool_result',
131
+ tool_use_id: typeof block.toolCallId === 'string' ? block.toolCallId : '',
132
+ content: nested,
133
+ })
134
+ }
135
+ } else if (block.type === 'reasoning' || block.type === 'thinking') {
136
+ // Prior reasoning does not belong in a vision request; skip it.
137
+ continue
138
+ } else if (Array.isArray(block.content)) {
139
+ out.push(...(await convertBlocks(block.content, bytesOf, depth + 1)))
140
+ }
141
+ }
142
+ return out
143
+ }
144
+
145
+ /**
146
+ * Convert harness messages into the Anthropic Messages wire shape.
147
+ * Consecutive same-role messages are merged (the wire requires alternation),
148
+ * system messages fold into the `system` string, and tool results follow the
149
+ * `tool_result` block convention. Unrepresentable blocks are skipped rather
150
+ * than failing the request — vision answers only need the visible content.
151
+ *
152
+ * @param messages - harness chat messages (array-form content blocks).
153
+ * @param bytesOf - async (attachment) => image bytes; image blocks are dropped
154
+ * when it throws or returns nothing.
155
+ * @returns { system, messages } ready for the /v1/messages request body.
156
+ */
157
+ export async function toAnthropicMessages(messages, bytesOf) {
158
+ const system = []
159
+ const wire = []
160
+ const push = (role, content) => {
161
+ if (content.length === 0) return
162
+ const last = wire[wire.length - 1]
163
+ if (last && last.role === role) last.content.push(...content)
164
+ else wire.push({ role, content })
165
+ }
166
+ for (const message of messages ?? []) {
167
+ if (!message) continue
168
+ const role = message.role
169
+ if (role === 'system') {
170
+ const text = await convertBlocks(message.content, bytesOf)
171
+ const joined = text.map((block) => (block.type === 'text' ? block.text : '')).join('\n').trim()
172
+ if (joined !== '') system.push(joined)
173
+ continue
174
+ }
175
+ if (role === 'user') {
176
+ push('user', await convertBlocks(message.content, bytesOf))
177
+ continue
178
+ }
179
+ if (role === 'assistant') {
180
+ push('assistant', await convertBlocks(message.content, bytesOf))
181
+ continue
182
+ }
183
+ // Any other role (tool results carried outside assistant tool-call
184
+ // frames, future harness shapes) folds into the user stream as text —
185
+ // the same posture as the vision-http route's OpenAI conversion.
186
+ if (typeof message.content === 'string') {
187
+ push('user', [{ type: 'text', text: message.content }])
188
+ } else if (Array.isArray(message.content)) {
189
+ push('user', await convertBlocks(message.content, bytesOf))
190
+ }
191
+ }
192
+ // The Anthropic wire rejects a conversation that does not open with a user
193
+ // message (e.g. history starting mid-turn with an assistant reply). Open
194
+ // with a synthetic marker instead of dropping real history.
195
+ if (wire.length > 0 && wire[0].role !== 'user') {
196
+ wire.unshift({ role: 'user', content: [{ type: 'text', text: '(conversation history)' }] })
197
+ }
198
+ return { system: system.join('\n').trim(), messages: wire }
199
+ }
200
+
201
+ /**
202
+ * One non-streaming Anthropic Messages call. Parallel to callOpenAICompatible:
203
+ * same credential seam, same typed failures (status/code/retry-after) so the
204
+ * circuit breaker and fallback chains classify it identically.
205
+ *
206
+ * @param provider - { name, baseURL, model, apiKeyEnv? }; baseURL without the
207
+ * /v1 suffix (the client appends /v1/messages).
208
+ * @param messages - anthropic wire messages (see toAnthropicMessages).
209
+ * @param options - { maxTokens, signal, apiKey, resolveCredential, system }.
210
+ * @returns the joined assistant text content.
211
+ */
212
+ export async function callAnthropicCompatible(provider, messages, options = {}) {
213
+ const apiKeyEnv = typeof provider.apiKeyEnv === 'string' ? provider.apiKeyEnv : ''
214
+ let resolvedApiKey = typeof options.apiKey === 'string' ? options.apiKey : ''
215
+ if (resolvedApiKey === '' && apiKeyEnv !== '') {
216
+ if (typeof options.resolveCredential === 'function') {
217
+ const hit = await options.resolveCredential(apiKeyEnv)
218
+ if (hit) resolvedApiKey = String(hit)
219
+ }
220
+ if (resolvedApiKey === '' && typeof process !== 'undefined' && process.env) {
221
+ resolvedApiKey = process.env[apiKeyEnv] ?? ''
222
+ }
223
+ }
224
+ if (resolvedApiKey === '') {
225
+ throw new Error(`anthropic provider "${provider.name}": api key is not set`)
226
+ }
227
+ const headers = {
228
+ 'content-type': 'application/json',
229
+ 'x-api-key': resolvedApiKey,
230
+ 'anthropic-version': '2023-06-01',
231
+ }
232
+ const body = {
233
+ model: provider.model,
234
+ messages,
235
+ max_tokens: options.maxTokens ?? provider.maxTokens ?? 4096,
236
+ stream: false,
237
+ }
238
+ const system = options.system && String(options.system).trim() !== '' ? options.system : undefined
239
+ const url = `${String(provider.baseURL).replace(/\/+$/, '')}/v1/messages`
240
+ let response
241
+ try {
242
+ response = await fetch(url, {
243
+ method: 'POST',
244
+ headers,
245
+ body: JSON.stringify(system === undefined ? body : { ...body, system }),
246
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
247
+ })
248
+ } catch (error) {
249
+ if (error && error.name === 'AbortError') {
250
+ const abort = new Error(`anthropic provider "${provider.name}": request aborted`)
251
+ abort.name = 'AbortError'
252
+ throw abort
253
+ }
254
+ throw new Error(`anthropic provider "${provider.name}": ${error && error.message ? error.message : String(error)}`)
255
+ }
256
+ if (!response.ok) {
257
+ const detail = (await response.text().catch(() => '')).slice(0, 300)
258
+ const retryAfter = Number(response.headers.get('retry-after'))
259
+ const error = new Error(`anthropic provider "${provider.name}": ${response.status} ${detail}`)
260
+ error.status = response.status
261
+ error.code = kindForHttpStatus(response.status) ?? 'HTTP_PROVIDER_FAILED'
262
+ if (Number.isFinite(retryAfter) && retryAfter > 0) {
263
+ error.providerRetryAfterMs = Math.min(retryAfter * 1000, 60 * 60 * 1000)
264
+ }
265
+ throw error
266
+ }
267
+ const data = await response.json()
268
+ const blocks = Array.isArray(data && data.content) ? data.content : []
269
+ const text = blocks
270
+ .filter((block) => block && block.type === 'text' && typeof block.text === 'string')
271
+ .map((block) => block.text)
272
+ .join('')
273
+ .trim()
274
+ if (text === '') {
275
+ throw new Error(`anthropic provider "${provider.name}": response carried no text content`)
276
+ }
277
+ return text
278
+ }
package/lib/client.js CHANGED
@@ -840,6 +840,11 @@ window.__ModuleLoader__.load({
840
840
  let onboardingSeenMemory = false
841
841
  let visionGuideStepMemory
842
842
  let visionGuideMemoryAuthoritative = false
843
+ // Whether the app-owned settings modal has been seen open during this
844
+ // walkthrough round, plus a grace timer so a host re-render that rebuilds
845
+ // the dialog in place is not mistaken for the user closing it.
846
+ let visionGuidePanelSeen = false
847
+ let visionGuidePanelCloseTimer
843
848
  // Installed by apply(): a narrow, defensive view of the settings scope.
844
849
  // { get, set, unset } never throw, and subscribe returns a disposer (or
845
850
  // undefined when the scope is unavailable — the legacy paths take over).
@@ -1068,6 +1073,11 @@ window.__ModuleLoader__.load({
1068
1073
  const dialogs = Array.from(document.querySelectorAll('[role="dialog"][aria-modal="true"]'))
1069
1074
  return dialogs.find((dialog) => !dialog.closest || !dialog.closest('.vr-onboarding-dialog'))
1070
1075
  }
1076
+ // The settings modal can close by unmounting or by the host hiding it in
1077
+ // place; a panel that is gone or not visibly rendered counts as closed.
1078
+ function guideSettingsPanelOpen() {
1079
+ return guideElementUsable(guideSettingsPanel())
1080
+ }
1071
1081
  function guideElementUsable(el) {
1072
1082
  if (!el || typeof el.getBoundingClientRect !== 'function') return false
1073
1083
  const rect = el.getBoundingClientRect()
@@ -1322,6 +1332,32 @@ window.__ModuleLoader__.load({
1322
1332
  function syncVisionGuidePrompt(t) {
1323
1333
  if (typeof document === 'undefined' || typeof window === 'undefined' || !document.body) return
1324
1334
  const step = readVisionGuideStep()
1335
+ const panelOpen = guideSettingsPanelOpen()
1336
+ if (step !== undefined && visionGuidePanelSeen && !panelOpen) {
1337
+ // The settings panel was open during this walkthrough round and the
1338
+ // user has just closed it: honor the close by ending the round
1339
+ // instead of re-pointing the prompt at the gear and nagging again.
1340
+ // A short grace period absorbs SPA re-renders that rebuild the dialog
1341
+ // in place; if the panel is still gone afterwards, the guide finishes
1342
+ // for good and can never pop back up for this round.
1343
+ clearGuidePromptUI()
1344
+ if (visionGuidePanelCloseTimer === undefined) {
1345
+ visionGuidePanelCloseTimer = window.setTimeout(() => {
1346
+ visionGuidePanelCloseTimer = undefined
1347
+ if (readVisionGuideStep() !== undefined && !guideSettingsPanelOpen()) {
1348
+ finishVisionSettingsGuide()
1349
+ }
1350
+ }, 400)
1351
+ }
1352
+ return
1353
+ }
1354
+ if (panelOpen) {
1355
+ visionGuidePanelSeen = true
1356
+ if (visionGuidePanelCloseTimer !== undefined) {
1357
+ window.clearTimeout(visionGuidePanelCloseTimer)
1358
+ visionGuidePanelCloseTimer = undefined
1359
+ }
1360
+ }
1325
1361
  const phase = guidePhase(step)
1326
1362
  if (phase === 'none' || phase === 'done' || phase === 'suspend') {
1327
1363
  // No floating layer: the walkthrough is over, the in-card callout
@@ -1359,11 +1395,21 @@ window.__ModuleLoader__.load({
1359
1395
  anchorGuidePrompt(visionGuidePrompt, step, phase, rect, menuOpen)
1360
1396
  }
1361
1397
  function startVisionSettingsGuide(t) {
1398
+ visionGuidePanelSeen = false
1399
+ if (visionGuidePanelCloseTimer !== undefined) {
1400
+ window.clearTimeout(visionGuidePanelCloseTimer)
1401
+ visionGuidePanelCloseTimer = undefined
1402
+ }
1362
1403
  writeVisionGuideStep('step1')
1363
1404
  syncVisionGuidePrompt(t)
1364
1405
  notifyVisionGuideChanged()
1365
1406
  }
1366
1407
  function finishVisionSettingsGuide() {
1408
+ visionGuidePanelSeen = false
1409
+ if (visionGuidePanelCloseTimer !== undefined) {
1410
+ window.clearTimeout(visionGuidePanelCloseTimer)
1411
+ visionGuidePanelCloseTimer = undefined
1412
+ }
1367
1413
  writeVisionGuideStep(undefined)
1368
1414
  removeVisionGuidePrompt()
1369
1415
  notifyVisionGuideChanged()
@@ -1383,6 +1429,11 @@ window.__ModuleLoader__.load({
1383
1429
  // snapshot changes (e.g. the first read resolves after page load).
1384
1430
  const unsubscribe = settingsPersistence && settingsPersistence.subscribe(sync)
1385
1431
  return () => {
1432
+ visionGuidePanelSeen = false
1433
+ if (visionGuidePanelCloseTimer !== undefined) {
1434
+ window.clearTimeout(visionGuidePanelCloseTimer)
1435
+ visionGuidePanelCloseTimer = undefined
1436
+ }
1386
1437
  window.removeEventListener(VISION_GUIDE_EVENT, sync)
1387
1438
  window.removeEventListener('popstate', sync)
1388
1439
  window.removeEventListener('resize', sync)
@@ -2002,6 +2053,10 @@ window.__ModuleLoader__.load({
2002
2053
  }
2003
2054
  const save = async () => {
2004
2055
  if (blocked) return
2056
+ // Saving ends the walkthrough: the user has reached the settings card
2057
+ // and acted, so the guide must never nag again afterwards — even when
2058
+ // a hidden write is rejected (dismissal stays page-authoritative).
2059
+ finishGuide()
2005
2060
  setSaving(true)
2006
2061
  setFailed(false)
2007
2062
  setFailedFields([])
@@ -2234,7 +2289,14 @@ window.__ModuleLoader__.load({
2234
2289
  h('select', {
2235
2290
  className: 'vr-input vr-select', value: visionModelVisible(row.provider, row.model) ? row.model : '',
2236
2291
  disabled: editBlocked || !visionProviderVisible(row.provider),
2237
- onChange: (event) => updateChain(index, { provider: row.provider, model: event.target.value }),
2292
+ onChange: (event) => {
2293
+ updateChain(index, { provider: row.provider, model: event.target.value })
2294
+ // Picking a real vision model is an active backend choice:
2295
+ // the walkthrough has done its job, so end it right away
2296
+ // instead of waiting for the in-card button. Picking only a
2297
+ // provider (model still empty) does not finish it.
2298
+ if (row.provider && event.target.value) finishGuide()
2299
+ },
2238
2300
  },
2239
2301
  h('option', { value: '' }, visionProviderVisible(row.provider) ? t('selectModel') : t('pickProviderFirst')),
2240
2302
  modelOptionsOf(visionModelsFor(row.provider)),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vision-router",
3
- "version": "1.4.1",
3
+ "version": "1.4.2",
4
4
  "description": "Eyes for text-only DeepSeek Harness agents: built-in free vision chain (no key) + pixel-level vision tools (Q&A, grounding, crop, pixel diff, colors, OCR, SVG trace, cutout, screenshots). One-command install, no Python, image turns work like ordinary tool-calling turns.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -64,7 +64,7 @@
64
64
  "sharp": "^0.35.3"
65
65
  },
66
66
  "scripts": {
67
- "test": "node --test tests/core.test.js tests/vision-resilience.test.js tests/client.test.js tests/http-compat.test.js tests/update-check.test.js tests/self-update.test.js tests/doctor.test.js tests/doctor-cli.test.js tests/bundle-defaults.test.js tests/file-logger.test.js tests/replay-delegation.test.js tests/logging-ui.test.js tests/manifest-dependencies.test.js"
67
+ "test": "node --test tests/core.test.js tests/vision-resilience.test.js tests/client.test.js tests/http-compat.test.js tests/catalog-corrections.test.js tests/update-check.test.js tests/self-update.test.js tests/doctor.test.js tests/doctor-cli.test.js tests/bundle-defaults.test.js tests/file-logger.test.js tests/replay-delegation.test.js tests/logging-ui.test.js tests/manifest-dependencies.test.js"
68
68
  },
69
69
  "pnpm": {
70
70
  "onlyBuiltDependencies": [
@@ -83,4 +83,4 @@
83
83
  "patch": "./cordis.patch.yml"
84
84
  }
85
85
  }
86
- }
86
+ }