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 +58 -16
- package/README.zh.md +56 -16
- package/index.js +369 -90
- package/lib/catalog-corrections.js +278 -0
- package/lib/client.js +63 -1
- package/package.json +3 -3
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.
|
|
19
|
-
<a href="tests"><img src="https://img.shields.io/badge/verified-
|
|
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&logo=nodedotjs&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.
|
|
31
|
+
> 📌 **Announcement (v1.4.2)**
|
|
32
32
|
>
|
|
33
|
-
> **v1.4.
|
|
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
|
-
|
|
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
|
-
| |
|
|
67
|
-
|
|
68
|
-
|
|
|
69
|
-
|
|
|
70
|
-
|
|
|
71
|
-
|
|
|
72
|
-
|
|
|
73
|
-
|
|
|
74
|
-
|
|
|
75
|
-
|
|
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
|
-
|
|
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.
|
|
19
|
-
<a href="tests"><img src="https://img.shields.io/badge/verified-
|
|
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&logo=nodedotjs&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.
|
|
31
|
+
> 📌 **公告(v1.4.2)**
|
|
32
32
|
>
|
|
33
|
-
> **v1.4.1
|
|
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
|
-
|
|
65
|
+
**一句话讲清区别**:其他 dsh 视觉插件大多"把图片转成文字描述再喂给 DeepSeek"(描述桥,有信息损耗);
|
|
66
|
+
本插件主打"**图片轮直接交给视觉模型看原图**"(路由桥,像素保真),同时内置免 Key 免费模型兜底。
|
|
65
67
|
|
|
66
|
-
| |
|
|
67
|
-
|
|
68
|
-
|
|
|
69
|
-
|
|
|
70
|
-
|
|
|
71
|
-
|
|
|
72
|
-
|
|
|
73
|
-
|
|
|
74
|
-
|
|
|
75
|
-
|
|
68
|
+
| | 手动切换模型 | MCP 视觉桥 | 本插件 |
|
|
69
|
+
|---|---|---|---|
|
|
70
|
+
| 像素保真 | ✅ 完整(切换后) | ❌ 只有文字描述 | ✅ 完整,图片轮内 |
|
|
71
|
+
| 自动化 | ❌ | ✅ | ✅ |
|
|
72
|
+
| 日常模型不受影响 | ❌(整会话被换) | ✅ | ✅ |
|
|
73
|
+
| 供应商失败恢复 | ❌ | ❌ | ✅ 降级链 |
|
|
74
|
+
| 可复用的结构化查询 | — | 部分 | ✅ JSON 模式 + 缓存 |
|
|
75
|
+
| 免费开箱即用 | ❌ | ❌ | ✅ 内置免 Key 免费端点 |
|
|
76
|
+
| 贴合 dsh 组合体系 | — | 外部服务器 | ✅ 一行插件行 |
|
|
77
|
+
|
|
78
|
+
**与现有 dsh 社区方案的差异**(均为优秀项目,各有侧重;描述以各家 README 2026-08 状态为准):
|
|
76
79
|
|
|
77
|
-
|
|
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
|
-
|
|
2421
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3147
|
-
|
|
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
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
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
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
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
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
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
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
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
|
|
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
|
|
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
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
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
|
|
5350
|
-
// document loads or the user edits the
|
|
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) =>
|
|
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.
|
|
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
|
+
}
|