dsh-vision-router 1.4.2 → 1.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.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>
18
+ <a href="https://github.com/ysr666/dsh-vision-router/releases/tag/v1.4.4"><img src="https://img.shields.io/badge/release-v1.4.4-5B4CF0?style=flat-square" alt="Release v1.4.4" /></a>
19
+ <a href="tests"><img src="https://img.shields.io/badge/verified-257%20tests-2EA44F?style=flat-square" alt="Verified: 257 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.2)**
31
+ > 📌 **Announcement (v1.4.4)**
32
32
  >
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.
33
+ > **v1.4.4:** Fixes the blank DSH rc.6 plugin settings page by declaring the client services required by settingsScope.
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" />
@@ -42,6 +42,7 @@
42
42
  - [How it compares](#how-it-compares)
43
43
  - [Acknowledgements](#acknowledgements)
44
44
  - [Quick start](#quick-start)
45
+ - [Free vision key channels](#free-vision-key-channels)
45
46
  - [Highlights](#highlights)
46
47
  - [How it works](#how-it-works)
47
48
  - [Tools](#tools)
@@ -143,7 +144,7 @@ The auto-vision group follows the live DSH model catalog. Adding models or chang
143
144
 
144
145
  After choosing the “+ Auto Vision” group, paste or upload an image normally. By default the complete vision tool schema is stable from session start, so the agent can immediately use `vision_describe`, `vision_ground`, `vision_crop`, and the rest across multiple steps when needed.
145
146
 
146
- The built-in anonymous OVH vision fallback is already configured, so normal image use needs no signup or API key. **The lower-right chat picker selects only the brain/conversation model**; vision backends do not belong there. Advanced options live under **Settings → Plugins → Plugin config → 视觉路由(自动识图)**: each vision-backend row selects one image-capable user model already configured under **Settings → Models**. Leaving every user row empty is valid; the OVH chain remains the final fallback. `Vision HTTP` is an internal transport route, not a model group users should select.
147
+ The built-in anonymous OVH vision fallback is already configured, so normal image use needs no signup or API key. **The lower-right chat picker selects only the brain/conversation model**; vision backends do not belong there. Advanced options live under **Settings → Plugins → Plugin config → 视觉路由(自动识图)**: each vision-backend row may select any callable generative user model already configured under **Settings → Models**. DSH image-capability metadata is advisory only: undeclared or text-only-labelled models remain selectable and show a warning. At runtime Vision Router always tries the provider's registered DSH adapter first — including WebSocket, RPC and private transports — and falls through on a real failure. The direct compatibility bridge is used only when an http(s) OpenAI Chat Completions endpoint is positively identified. Leaving every user row empty is valid; the OVH chain remains the final fallback. `Vision HTTP` is an internal transport route, not a model group users should select.
147
148
 
148
149
  ### See it in action
149
150
 
@@ -154,6 +155,27 @@ The built-in anonymous OVH vision fallback is already configured, so normal imag
154
155
  <img src="assets/dsh-conversation-image-qa-result.png" width="49%" alt="The agent's structured answer describing the image content." />
155
156
  </p>
156
157
 
158
+ ## Free vision key channels
159
+
160
+ The built-in OVH fallback is anonymous by design, and OVH caps anonymous use at **2 requests/minute per IP per model**. If that feels tight, every channel below offers **free vision models with much higher quotas** — all of them are free to register, and none charges for the free tier. Free policies rotate often; treat this table as an August 2026 snapshot and double-check each provider's console before relying on it.
161
+
162
+ | Channel | Free vision model(s) | Free quota | CN direct? | Where to get the key |
163
+ |---|---|---|---|---|
164
+ | OVHcloud AI Endpoints (access key) | `Qwen2.5-VL-72B-Instruct` — the same endpoint the built-in fallback uses | **400 req/min** per project per model (vs 2 anonymous) | ✅ | OVH account → Public Cloud project (attach a payment method; free models are not charged) → AI Endpoints access key |
165
+ | Zhipu (bigmodel.cn) | `glm-4.6v-flash` · `glm-4.1v-thinking-flash` · `glm-4v-flash` — three permanently free models; chaining them triples capacity | uncapped tokens | ✅ | open.bigmodel.cn → API keys |
166
+ | DashScope (Aliyun) | `qwen3-vl-flash` (limited-time free) and the Qwen-VL series | new users: 1M tokens per model series / 90 days | ✅ | bailian.console.aliyun.com |
167
+ | Intern AI (Shanghai AI Lab) | `internvl-latest` · `internvl3.5-latest` | 30 RPM, **90M tokens/month** | ✅ | chat.intern-ai.org.cn |
168
+ | Groq | `meta-llama/llama-4-scout-17b-16e-instruct` (native multimodal, up to 5 images) | 30 RPM / 14,400 req/day, no card | ❌ proxy | console.groq.com |
169
+ | Google AI Studio | `gemini-2.5-flash` · `gemini-2.5-flash-lite` | 10–30 RPM / 500–1,500 req/day | ❌ proxy | aistudio.google.com |
170
+ | NVIDIA NIM | `meta/llama-3.2-11b-vision-instruct` · `nvidia/nemotron-nano-12b-v2-vl` | 40 RPM, no card | ⚠️ | build.nvidia.com |
171
+ | OpenCode Zen | `mimo-v2.5-free` (vision + code) | 30 RPM / 500 req/day | ⚠️ | opencode.ai/zen |
172
+ | OpenRouter | `google/gemma-4-26b-a4b-it:free` · `google/gemma-4-31b-it:free` | 50 req/day on unpaid accounts | ❌ proxy | openrouter.ai |
173
+
174
+ Any of these channels can join the vision chain as an `httpProviders` entry (key in the matching environment variable or `~/.dsh/.credentials.yaml`), and the chain tries your entries before the anonymous fallback.
175
+
176
+ > [!NOTE]
177
+ > Free-tier policies change without notice — Cerebras retired its free tier in July 2026 (now a one-time $5 credit), SambaNova's free tier is down to 20 requests/day, and Hugging Face's is $0.10/month. Third-party “`:free` relay” aggregators are deliberately not listed: they rotate quickly, lack SLAs, and some resell quota in ways that violate upstream terms.
178
+
157
179
  ## Highlights
158
180
 
159
181
  - **Original pixels, real answers.** The vision chain reads the image at original resolution (auto-downscaled only to protect latency/quota); the agent's question travels with the image, so answers are about *your* question, not a generic description.
@@ -226,7 +248,7 @@ The vision tools try backends in order and surface an error only after all of th
226
248
 
227
249
  1. **User vision models**: one per settings row, top to bottom; only models under **Settings → Models** that explicitly declare image input are shown;
228
250
  2. **Advanced custom HTTP vision endpoints**: legacy/advanced `httpProviders`, when present, run after the user models;
229
- 3. **Built-in anonymous OVH fallback**: always last and never exposed in a model picker. The current quality-first chain is `Qwen3.5-397B-A17B` → `Qwen2.5-VL-72B-Instruct` → `Qwen3.6-27B` → `Mistral-Small-3.2-24B-Instruct-2506` → `Qwen3.5-9B`. OVH anonymous limits are **2 requests/minute per IP per model**. The five models have independent buckets, so spreading requests across them is about **10 RPM in theory**, subject to OVH's actual rate limiting. No signup or API key is required.
251
+ 3. **Built-in anonymous OVH fallback**: always last and never exposed in a model picker. The current quality-first chain is `Qwen3.5-397B-A17B` → `Qwen2.5-VL-72B-Instruct` → `Qwen3.6-27B` → `Mistral-Small-3.2-24B-Instruct-2506` → `Qwen3.5-9B`. OVH anonymous limits are **2 requests/minute per IP per model**. The five models have independent buckets, so spreading requests across them is about **10 RPM in theory**, subject to OVH's actual rate limiting. No signup or API key is required. Want more headroom? See [Free vision key channels](#free-vision-key-channels) — a free OVH access key lifts this same endpoint to 400 requests/minute.
230
252
 
231
253
  > [!IMPORTANT]
232
254
  > This “vision chain” is the **eyes** used by Vision Router: each settings row selects one user vision model, while the lower-right chat picker selects the **brain/conversation model**. The two are deliberately separate. Text-only DeepSeek/opencode models are filtered out of the vision-backend dropdown, and the internal `Vision HTTP` transport route is no longer exposed to users.
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.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>
18
+ <a href="https://github.com/ysr666/dsh-vision-router/releases/tag/v1.4.4"><img src="https://img.shields.io/badge/release-v1.4.4-5B4CF0?style=flat-square" alt="Release v1.4.4" /></a>
19
+ <a href="tests"><img src="https://img.shields.io/badge/verified-257%20tests-2EA44F?style=flat-square" alt="Verified: 257 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.2)**
31
+ > 📌 **公告(v1.4.4)**
32
32
  >
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 组在多步对话中保留选择器里的推理等级。
33
+ > **v1.4.4:修复 DSH rc.6 插件配置页空白。**
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 定位发送按钮并给出坐标" />
@@ -42,6 +42,7 @@
42
42
  - [对比同类插件](#对比同类插件)
43
43
  - [致谢](#致谢)
44
44
  - [快速开始](#快速开始)
45
+ - [免费视觉 Key 渠道](#免费视觉-key-渠道)
45
46
  - [亮点](#亮点)
46
47
  - [工作原理](#工作原理)
47
48
  - [工具](#工具)
@@ -141,7 +142,7 @@ opencode-go + 自动识图 ← 发图片时选这个
141
142
 
142
143
  选好「+ 自动识图」模型组后,直接往对话里贴图即可。默认情况下完整视觉工具表从会话开始就保持稳定,Agent 可直接调用 `vision_describe`、`vision_ground`、`vision_crop` 等工具看图,需要时连续多步操作。
143
144
 
144
- 默认已经有内置 OVH 匿名视觉兜底,无需注册、无需 Key。**聊天页右下角只选择“脑子/会话模型”**;视觉模型不要在那里选。高级配置在 **设置 → 插件 → 插件配置 → 视觉路由(自动识图)**:视觉后端链每一行只选择一个你在 **设置 → 模型** 中已经配置且支持图片输入的用户模型;一行都不填也可以,OVH 免费链会固定在最后兜底。插件内部的 `Vision HTTP` 只是传输实现,不是用户需要选择的模型组。
145
+ 默认已经有内置 OVH 匿名视觉兜底,无需注册、无需 Key。**聊天页右下角只选择“脑子/会话模型”**;视觉模型不要在那里选。高级配置在 **设置 → 插件 → 插件配置 → 视觉路由(自动识图)**:视觉后端链每一行都可以选择 **设置 → 模型** 中任意可调用的生成式用户模型。DSH 的图片能力声明现在只作提示:未声明图片能力、甚至被标成仅文本的模型也会列出并给出警告。运行时永远先通过该供应商已注册的 DSH adapter 实际调用,因此 WebSocket、RPC 和私有协议都保留原生传输;只有明确识别为 http(s) OpenAI Chat Completions 的渠道才可能进入 HTTP 直连兼容兜底。实际调用失败后自动尝试下一后端;一行都不填也可以,OVH 免费链会固定在最后兜底。插件内部的 `Vision HTTP` 只是传输实现,不是用户需要选择的模型组。
145
146
 
146
147
  ### 实际效果
147
148
 
@@ -152,6 +153,27 @@ opencode-go + 自动识图 ← 发图片时选这个
152
153
  <img src="assets/dsh-conversation-image-qa-result.png" width="49%" alt="Agent 对图片内容的结构化解读。" />
153
154
  </p>
154
155
 
156
+ ## 免费视觉 Key 渠道
157
+
158
+ 内置 OVH 兜底是匿名设计,OVH 对匿名访问的限制是**每 IP、每模型 2 次/分钟**。觉得不够用时,下面这些渠道都有**免费且额度大得多的视觉模型**——全部免费注册,无需为免费档付费。免费政策轮换频繁,下表是 2026 年 8 月快照,依赖前请以各家控制台为准。
159
+
160
+ | 渠道 | 免费视觉模型 | 免费额度 | 大陆直连 | Key 领取 |
161
+ |---|---|---|---|---|
162
+ | OVHcloud AI Endpoints(access key) | `Qwen2.5-VL-72B-Instruct`——与内置兜底同一个端点 | **400 次/分钟**/项目/模型(对比匿名 2 次/分钟) | ✅ | 注册 OVH 账号 → Public Cloud 项目(需挂支付方式;免费模型不扣费)→ AI Endpoints access key |
163
+ | 智谱(bigmodel.cn) | `glm-4.6v-flash` · `glm-4.1v-thinking-flash` · `glm-4v-flash`——三个永久免费模型,串起来容量 ×3 | token 不限量 | ✅ | open.bigmodel.cn → API keys |
164
+ | 阿里云百炼 | `qwen3-vl-flash`(限免)与 Qwen-VL 系列 | 新用户每模型系列 100 万 token / 90 天 | ✅ | bailian.console.aliyun.com |
165
+ | Intern AI(上海AI实验室) | `internvl-latest` · `internvl3.5-latest` | 30 RPM,**9000 万 token/月** | ✅ | chat.intern-ai.org.cn |
166
+ | Groq | `meta-llama/llama-4-scout-17b-16e-instruct`(原生多模态,最多 5 张图) | 30 RPM / 14,400 次/天,免卡 | ❌ 需代理 | console.groq.com |
167
+ | Google AI Studio | `gemini-2.5-flash` · `gemini-2.5-flash-lite` | 10–30 RPM / 500–1,500 次/天 | ❌ 需代理 | aistudio.google.com |
168
+ | NVIDIA NIM | `meta/llama-3.2-11b-vision-instruct` · `nvidia/nemotron-nano-12b-v2-vl` | 40 RPM,免卡 | ⚠️ | build.nvidia.com |
169
+ | OpenCode Zen | `mimo-v2.5-free`(视觉 + 代码) | 30 RPM / 500 次/天 | ⚠️ | opencode.ai/zen |
170
+ | OpenRouter | `google/gemma-4-26b-a4b-it:free` · `google/gemma-4-31b-it:free` | 未充值账户 50 次/天 | ❌ 需代理 | openrouter.ai |
171
+
172
+ 以上渠道都能以 `httpProviders` 条目加入视觉链(Key 放对应环境变量或 `~/.dsh/.credentials.yaml`),链路会先尝试你的条目、再落到匿名兜底。
173
+
174
+ > [!NOTE]
175
+ > 免费政策随时可能调整——Cerebras 已在 2026 年 7 月取消免费档(改为一次性 $5 赠金),SambaNova 免费档收紧到 20 次/天,Hugging Face 只剩 $0.10/月。第三方“`:free` 中转”聚合站刻意不列入:轮换频繁、无 SLA,部分还存在违反上游条款的转售行为。
176
+
155
177
  ## 亮点
156
178
 
157
179
  - **原图像素,真实答案。** 视觉链按原始分辨率读图(仅为保护延迟/额度自动缩放);你的问题随图一起发送,答案围绕*你的问题*,而不是一段泛泛的描述。
@@ -224,7 +246,7 @@ vision_long_screenshot_ocr image="chat-log.png" chunkHeight=1200 overlap=120
224
246
 
225
247
  1. **用户视觉模型**:设置卡里一行一个,从上到下;只显示 **设置 → 模型** 中明确声明支持 image 输入的模型;
226
248
  2. **高级自定义 HTTP 视觉端点**:如果旧配置/高级配置中存在 `httpProviders`,在用户模型之后尝试;
227
- 3. **内置 OVH 匿名免费兜底**:固定最后尝试,不需要出现在任何模型选择器里。当前内置链按质量优先为 `Qwen3.5-397B-A17B` → `Qwen2.5-VL-72B-Instruct` → `Qwen3.6-27B` → `Mistral-Small-3.2-24B-Instruct-2506` → `Qwen3.5-9B`。OVH 匿名限额为 **每 IP、每模型 2 次/分钟**;5 个模型是独立限额,因此理论上分散请求可到约 **10 次/分钟**,实际仍以 OVH 当时的限流为准。免注册、免 Key。
249
+ 3. **内置 OVH 匿名免费兜底**:固定最后尝试,不需要出现在任何模型选择器里。当前内置链按质量优先为 `Qwen3.5-397B-A17B` → `Qwen2.5-VL-72B-Instruct` → `Qwen3.6-27B` → `Mistral-Small-3.2-24B-Instruct-2506` → `Qwen3.5-9B`。OVH 匿名限额为 **每 IP、每模型 2 次/分钟**;5 个模型是独立限额,因此理论上分散请求可到约 **10 次/分钟**,实际仍以 OVH 当时的限流为准。免注册、免 Key。想提额度?详见[免费视觉 Key 渠道](#免费视觉-key-渠道)——同一个端点挂免费 access key 后是 400 次/分钟。
228
250
 
229
251
  > [!IMPORTANT]
230
252
  > 这里的“视觉链”是 Vision Router 调用的**眼睛**:设置页里每一行只选一个用户视觉模型;聊天页右下角选择的是**脑子/会话模型**,两者完全分开。纯文本 DeepSeek / opencode 不会出现在视觉后端下拉里;内部 `Vision HTTP` 也不会再暴露给用户。
package/index.js CHANGED
@@ -45,6 +45,7 @@ import {
45
45
  createVisionCircuitBreaker,
46
46
  createVisionTurnMemory,
47
47
  buildVisionFailure,
48
+ ensureSentencePunctuation,
48
49
  resultCodeForKinds,
49
50
  qwenKeyEndpointHint,
50
51
  kindForHttpStatus,
@@ -2171,32 +2172,51 @@ export function decideVisionBackendCapability(info, provider, model, extraVision
2171
2172
  const forced =
2172
2173
  modelId !== '' &&
2173
2174
  extras.some((entry) => entry === modelId || (providerId !== '' && entry === `${providerId}/${modelId}`))
2174
- // Manual override is deliberately strongest: advanced users can still
2175
- // force an unusual endpoint that our role/name heuristics reject.
2175
+
2176
+ // Capability metadata is ADVISORY. A user-selected generative model is
2177
+ // allowed to prove itself by an actual adapter call even when DSH omitted
2178
+ // image metadata or explicitly reports text-only input. The only hard gate
2179
+ // here is structural: endpoints that cannot produce an assistant answer
2180
+ // (embedding/reranker) are never valid vision backends.
2176
2181
  if (forced) {
2177
- return { image: true, inputModalities: [...new Set([...inputModalities, 'image'])], inferred: 'override', reason: undefined }
2182
+ return {
2183
+ image: true,
2184
+ attemptable: true,
2185
+ inputModalities: [...new Set([...inputModalities, 'image'])],
2186
+ inferred: 'override',
2187
+ reason: undefined,
2188
+ }
2178
2189
  }
2179
- // A model can consume images and still be the wrong KIND of endpoint
2180
- // for this plugin: embedding/reranking produces no assistant answer.
2181
2190
  if (modelId !== '' && looksLikeNonGenerativeVisionModel(modelId)) {
2182
2191
  return {
2183
2192
  image: false,
2193
+ attemptable: false,
2184
2194
  inputModalities,
2185
2195
  inferred: false,
2186
2196
  reason: 'model name indicates an embedding/reranker endpoint, not a generative vision backend',
2187
2197
  }
2188
2198
  }
2189
2199
  if (inputModalities.includes('image')) {
2190
- return { image: true, inputModalities, inferred: false, reason: undefined }
2200
+ return { image: true, attemptable: true, inputModalities, inferred: false, reason: undefined }
2191
2201
  }
2192
2202
  if (modelId !== '' && looksLikeVisionModel(modelId)) {
2193
- return { image: true, inputModalities: [...new Set([...inputModalities, 'image'])], inferred: 'name', reason: undefined }
2203
+ return {
2204
+ image: true,
2205
+ attemptable: true,
2206
+ inputModalities: [...new Set([...inputModalities, 'image'])],
2207
+ inferred: 'name',
2208
+ reason: undefined,
2209
+ }
2194
2210
  }
2195
2211
  return {
2196
2212
  image: false,
2213
+ attemptable: true,
2197
2214
  inputModalities,
2198
2215
  inferred: false,
2199
- reason: 'model metadata does not declare image input',
2216
+ reason:
2217
+ inputModalities.length > 0
2218
+ ? 'model metadata declares no image input'
2219
+ : 'model metadata does not declare image input',
2200
2220
  }
2201
2221
  }
2202
2222
 
@@ -2239,6 +2259,19 @@ export function resolveChannelBridgeTransport(rawProfile, resolvedProfile, model
2239
2259
  }
2240
2260
  }
2241
2261
 
2262
+ /** True only for a transport we can safely send through fetch + Chat Completions. */
2263
+ export function isOpenAIHttpBridgeTransport(transport) {
2264
+ if (!transport || transport.api !== 'openai-completions' || typeof transport.baseURL !== 'string') {
2265
+ return false
2266
+ }
2267
+ try {
2268
+ const url = new URL(transport.baseURL)
2269
+ return url.protocol === 'http:' || url.protocol === 'https:'
2270
+ } catch {
2271
+ return false
2272
+ }
2273
+ }
2274
+
2242
2275
  export function apply(ctx, config = {}) {
2243
2276
  // Route sharp version diagnostics (issue #75) through the harness logger
2244
2277
  // instead of console.warn, so the warning lands in the server log.
@@ -3005,28 +3038,37 @@ export function apply(ctx, config = {}) {
3005
3038
 
3006
3039
  const resolveVisionBackendCapability = async (provider, model) => {
3007
3040
  if (typeof provider !== 'string' || provider === '' || typeof model !== 'string' || model === '') {
3008
- return { image: false, inputModalities: [], reason: 'missing provider/model' }
3041
+ return { image: false, attemptable: false, inputModalities: [], reason: 'missing provider/model' }
3009
3042
  }
3010
3043
  if (provider !== HTTP_ROUTE && isGeneratedVisionWrapperRoute(provider)) {
3011
- return { image: false, inputModalities: [], reason: 'generated auto-vision wrapper, not a vision backend' }
3044
+ return {
3045
+ image: false,
3046
+ attemptable: false,
3047
+ inputModalities: [],
3048
+ reason: 'generated auto-vision wrapper, not a vision backend',
3049
+ }
3012
3050
  }
3013
3051
  if (!adapterAvailable(ctx.llm, provider)) {
3014
- return { image: false, inputModalities: [], reason: 'provider adapter is not registered' }
3052
+ return {
3053
+ image: false,
3054
+ attemptable: false,
3055
+ inputModalities: [],
3056
+ reason: 'provider adapter is not registered',
3057
+ }
3015
3058
  }
3016
3059
  try {
3017
3060
  const info = await ctx.llm.resolveModelInfo(provider, model)
3018
3061
  return decideVisionBackendCapability(info, provider, model, current().extraVisionModels)
3019
3062
  } catch (error) {
3020
- // Metadata lookup failed (custom model lists often do): fall back to
3021
- // the user override list and the name heuristic before declaring the
3022
- // model text-only.
3063
+ // Custom/WebSocket/private adapters can be perfectly callable while
3064
+ // their model metadata is incomplete or not resolvable. Preserve the
3065
+ // structural decision and surface the lookup failure only as advisory
3066
+ // diagnostics; the real adapter call is the source of truth.
3023
3067
  const fallback = decideVisionBackendCapability(undefined, provider, model, current().extraVisionModels)
3024
- if (fallback.image) return fallback
3025
- return {
3026
- image: false,
3027
- inputModalities: [],
3028
- reason: error && error.message ? error.message : String(error),
3068
+ if (!fallback.image) {
3069
+ fallback.reason = `capability metadata unavailable: ${error && error.message ? error.message : String(error)}`
3029
3070
  }
3071
+ return fallback
3030
3072
  }
3031
3073
  }
3032
3074
 
@@ -3096,12 +3138,16 @@ export function apply(ctx, config = {}) {
3096
3138
  if (!transport.baseURL) {
3097
3139
  return { ok: false, reason: 'no resolved channel baseURL', rawProfile, resolvedProfile, transport }
3098
3140
  }
3099
- // callOpenAICompatible speaks Chat Completions. Never send another
3100
- // provider protocol through this bridge just because its name looks visual.
3101
- if (transport.api !== 'openai-completions') {
3141
+ // This compatibility bridge is deliberately transport-specific. The
3142
+ // normal path always delegates to DSH's registered adapter, which may be
3143
+ // HTTP, WebSocket, RPC or a private protocol. Only a positively identified
3144
+ // http(s) OpenAI Chat Completions endpoint may bypass it.
3145
+ if (!isOpenAIHttpBridgeTransport(transport)) {
3102
3146
  return {
3103
3147
  ok: false,
3104
- reason: `channel protocol ${transport.api || 'unknown'} is not OpenAI Chat Completions`,
3148
+ reason:
3149
+ `channel transport ${transport.api || 'unknown'} @ ${transport.baseURL || 'unknown'} ` +
3150
+ 'is not an http(s) OpenAI Chat Completions endpoint',
3105
3151
  rawProfile,
3106
3152
  resolvedProfile,
3107
3153
  transport,
@@ -3261,6 +3307,56 @@ export function apply(ctx, config = {}) {
3261
3307
  })
3262
3308
  }
3263
3309
 
3310
+ const configuredVisionPairKeys = () =>
3311
+ new Set(
3312
+ pairs()
3313
+ .filter((pair) => pair && pair.provider !== HTTP_ROUTE)
3314
+ .map((pair) => `${pair.provider}/${pair.model}`),
3315
+ )
3316
+
3317
+ const mayUseDirectChannelBridge = (pair, capability, classification) => {
3318
+ if (!pair || !classification) return false
3319
+ const kind = classification.kind
3320
+ if (
3321
+ kind !== VISION_FAILURE_KINDS.INVALID_REQUEST &&
3322
+ kind !== VISION_FAILURE_KINDS.NETWORK &&
3323
+ kind !== VISION_FAILURE_KINDS.OTHER
3324
+ ) return false
3325
+ // Explicit selection is permission to TRY an undeclared model. Inferred /
3326
+ // manual-override backends keep the legacy bridge behavior. Transport is
3327
+ // still fail-closed: WebSocket/private adapters never get converted to
3328
+ // HTTP because channelBridgePlan() must positively identify http(s) +
3329
+ // OpenAI Chat Completions before a direct request is made.
3330
+ if (!configuredVisionPairKeys().has(`${pair.provider}/${pair.model}`) && !(capability && capability.inferred)) {
3331
+ return false
3332
+ }
3333
+ return channelBridgePlan(pair.provider, pair.model).ok === true
3334
+ }
3335
+
3336
+ const callVisionPairWithOptionalBridge = async (pair, messages, options = {}) => {
3337
+ try {
3338
+ return await callVisionPair(pair, messages, options)
3339
+ } catch (error) {
3340
+ const classification = classifyVisionFailure(error)
3341
+ const capability =
3342
+ options.capability ?? (await resolveVisionBackendCapability(pair.provider, pair.model))
3343
+ if (
3344
+ mayUseDirectChannelBridge(pair, capability, classification) &&
3345
+ Array.isArray(options.bridgeBlocks) &&
3346
+ typeof options.bridgeInstruction === 'string'
3347
+ ) {
3348
+ return directChannelVisionAnswer(
3349
+ pair.provider,
3350
+ pair.model,
3351
+ options.bridgeBlocks,
3352
+ options.bridgeInstruction,
3353
+ options.signal,
3354
+ )
3355
+ }
3356
+ throw error
3357
+ }
3358
+ }
3359
+
3264
3360
  const collectVisionBackendCapabilities = async () => {
3265
3361
  const capabilities = {}
3266
3362
  if (typeof ctx.llm.listProviders !== 'function') return capabilities
@@ -3273,7 +3369,6 @@ export function apply(ctx, config = {}) {
3273
3369
  for (const entry of providers) {
3274
3370
  const provider = entry && typeof entry.id === 'string' ? entry.id : ''
3275
3371
  if (provider === '') continue
3276
- if (provider !== HTTP_ROUTE && isGeneratedVisionWrapperRoute(provider)) continue
3277
3372
  let listed = []
3278
3373
  try {
3279
3374
  const registration = ctx.llm.registration(provider)
@@ -3294,11 +3389,11 @@ export function apply(ctx, config = {}) {
3294
3389
  }
3295
3390
 
3296
3391
 
3297
- // Build the tool-side adapter chain from real image-capable models already
3298
- // registered in DSH. Explicit non-HTTP backend rows keep their configured
3299
- // order, then any other native multimodal models are appended. Generated
3300
- // + 自动识图 wrappers and plugin-owned routes are not real visual backends;
3301
- // direct HTTP/OVH remains the final fallback layer.
3392
+ // Build the tool-side adapter chain. Explicit rows are user intent: every
3393
+ // structurally callable generative backend gets a real adapter attempt even
3394
+ // when DSH does not declare image input. Auto-discovery remains conservative
3395
+ // and only appends models positively identified as visual. This avoids
3396
+ // silently trying every text model while making custom providers reliable.
3302
3397
  const resolveToolVisionPairs = async () => {
3303
3398
  const out = []
3304
3399
  const seen = new Set()
@@ -3313,14 +3408,14 @@ export function apply(ctx, config = {}) {
3313
3408
  if (!pair || pair.provider === HTTP_ROUTE) continue
3314
3409
  if (!adapterAvailable(ctx.llm, pair.provider)) continue
3315
3410
  const capability = await resolveVisionBackendCapability(pair.provider, pair.model)
3316
- if (capability.image) add(pair.provider, pair.model)
3411
+ if (capability.attemptable !== false) add(pair.provider, pair.model)
3317
3412
  }
3318
3413
 
3319
3414
  const capabilities = await collectVisionBackendCapabilities()
3320
3415
  for (const [provider, models] of Object.entries(capabilities)) {
3321
3416
  if (provider === HTTP_ROUTE || ownRoutes().has(provider)) continue
3322
3417
  for (const [model, capability] of Object.entries(models ?? {})) {
3323
- if (capability && capability.image) add(provider, model)
3418
+ if (capability && capability.attemptable !== false && capability.image) add(provider, model)
3324
3419
  }
3325
3420
  }
3326
3421
  return out
@@ -3349,8 +3444,9 @@ export function apply(ctx, config = {}) {
3349
3444
  async listModels() {
3350
3445
  const entries = []
3351
3446
  for (const pair of pairs()) {
3447
+ if (!adapterAvailable(ctx.llm, pair.provider)) continue
3352
3448
  const capability = await resolveVisionBackendCapability(pair.provider, pair.model)
3353
- if (!capability.image) continue
3449
+ if (capability.attemptable === false) continue
3354
3450
  entries.push({
3355
3451
  provider: chainRoute(),
3356
3452
  id: `${pair.provider}/${pair.model}`,
@@ -3425,18 +3521,26 @@ export function apply(ctx, config = {}) {
3425
3521
  continue
3426
3522
  }
3427
3523
  const capability = await resolveVisionBackendCapability(pair.provider, pair.model)
3428
- if (!capability.image) {
3524
+ if (capability.attemptable === false) {
3429
3525
  failures.push(
3430
- `${pair.provider}/${pair.model}: not an image-capable backend (${capability.reason ?? 'unknown capability'})`,
3526
+ `${pair.provider}/${pair.model}: structurally unavailable (${capability.reason ?? 'unknown reason'})`,
3431
3527
  )
3432
3528
  ctx.logger?.warn(
3433
- 'vision-router: chain skips %s/%s (not image-capable: %s)',
3529
+ 'vision-router: chain skips %s/%s (structurally unavailable: %s)',
3434
3530
  pair.provider,
3435
3531
  pair.model,
3436
- capability.reason ?? 'unknown capability',
3532
+ capability.reason ?? 'unknown reason',
3437
3533
  )
3438
3534
  continue
3439
3535
  }
3536
+ if (!capability.image) {
3537
+ ctx.logger?.info(
3538
+ 'vision-router: chain tries %s/%s despite advisory image capability (%s)',
3539
+ pair.provider,
3540
+ pair.model,
3541
+ capability.reason ?? 'not declared',
3542
+ )
3543
+ }
3440
3544
  let budget = defaultBudget
3441
3545
  try {
3442
3546
  const info = await ctx.llm.resolveModelInfo(pair.provider, pair.model)
@@ -4215,9 +4319,9 @@ export function apply(ctx, config = {}) {
4215
4319
  continue
4216
4320
  }
4217
4321
  const capability = await resolveVisionBackendCapability(pair.provider, pair.model)
4218
- if (!capability.image) {
4322
+ if (capability.attemptable === false) {
4219
4323
  rejectedPairs.push(
4220
- `${pair.provider}/${pair.model}: not an image-capable backend (${capability.reason ?? 'unknown capability'})`,
4324
+ `${pair.provider}/${pair.model}: structurally unavailable (${capability.reason ?? 'unknown reason'})`,
4221
4325
  )
4222
4326
  }
4223
4327
  }
@@ -4277,7 +4381,14 @@ export function apply(ctx, config = {}) {
4277
4381
  try {
4278
4382
  let messages = baseMessages
4279
4383
  const signal = combineSignals(deadline.signal(), AbortSignal.timeout(timeoutMs()))
4280
- let text = await callVisionPair(pair, messages, { maxTokens: 4096, signal })
4384
+ const capability = await resolveVisionBackendCapability(pair.provider, pair.model)
4385
+ let text = await callVisionPairWithOptionalBridge(pair, messages, {
4386
+ maxTokens: 4096,
4387
+ signal,
4388
+ capability,
4389
+ bridgeBlocks: blocks,
4390
+ bridgeInstruction: promptText,
4391
+ })
4281
4392
  if (wantJson) {
4282
4393
  for (let attempt = 0; attempt < 2; attempt++) {
4283
4394
  const parsed = extractJson(text)
@@ -4300,7 +4411,14 @@ export function apply(ctx, config = {}) {
4300
4411
  source: { kind: 'plugin', plugin: 'dsh-vision-router' },
4301
4412
  },
4302
4413
  ]
4303
- text = await callVisionPair(pair, messages, { maxTokens: 4096, signal })
4414
+ text = await callVisionPairWithOptionalBridge(pair, messages, {
4415
+ maxTokens: 4096,
4416
+ signal,
4417
+ capability,
4418
+ bridgeBlocks: blocks,
4419
+ bridgeInstruction:
4420
+ promptText + '\n\nThat output was not valid JSON. Respond with ONLY a valid JSON object now.',
4421
+ })
4304
4422
  }
4305
4423
  }
4306
4424
  const fallback = `vision_describe: the model did not produce valid JSON. Raw output:\n${text.slice(0, 2000)}`
@@ -4429,7 +4547,9 @@ export function apply(ctx, config = {}) {
4429
4547
  // must see a retryable=false result code, not an "occasional glitch".
4430
4548
  const reason =
4431
4549
  errors.length > 0
4432
- ? `All vision models failed: ${errors.slice(0, 6).join(' | ')}${errors.length > 6 ? ` | … ${errors.length - 6} more` : ''}.`
4550
+ ? ensureSentencePunctuation(
4551
+ `All vision models failed: ${errors.slice(0, 6).join(' | ')}${errors.length > 6 ? ` | … ${errors.length - 6} more` : ''}`,
4552
+ )
4433
4553
  : 'No vision-capable backend is configured.'
4434
4554
  const failure = await visionFailureResult(
4435
4555
  scope,
@@ -4595,9 +4715,9 @@ export function apply(ctx, config = {}) {
4595
4715
  }
4596
4716
  const capability = await resolveVisionBackendCapability(pair.provider, pair.model)
4597
4717
  pairCapabilities.set(`${pair.provider}/${pair.model}`, capability)
4598
- if (!capability.image) {
4718
+ if (capability.attemptable === false) {
4599
4719
  errors.push(
4600
- `${pair.provider}/${pair.model}: not an image-capable backend (${capability.reason ?? 'unknown capability'})`,
4720
+ `${pair.provider}/${pair.model}: structurally unavailable (${capability.reason ?? 'unknown reason'})`,
4601
4721
  )
4602
4722
  }
4603
4723
  }
@@ -4628,53 +4748,21 @@ export function apply(ctx, config = {}) {
4628
4748
  pairCapabilities.set(pairKey, pairCapability)
4629
4749
  }
4630
4750
  try {
4631
- const text = await callVisionPair(
4751
+ const text = await callVisionPairWithOptionalBridge(
4632
4752
  pair,
4633
4753
  [{ role: 'user', content: [block, { type: 'text', text: instruction }] }],
4634
4754
  {
4635
4755
  maxTokens: 4096,
4636
4756
  signal: combineSignals(deadline.signal(), AbortSignal.timeout(timeoutMs())),
4757
+ capability: pairCapability,
4758
+ bridgeBlocks: [block],
4759
+ bridgeInstruction: instruction,
4637
4760
  },
4638
4761
  )
4639
4762
  if (text && text.trim() !== '') return { ok: true, text: text.trim() }
4640
4763
  } catch (error) {
4641
4764
  const classification = classifyVisionFailure(error)
4642
4765
  visionBreaker.record(pairKey, fingerprint, classification, scope)
4643
- // Channels whose catalog does not declare image input reject images
4644
- // at the adapter wire. When the backend was recognized by the name
4645
- // inference or the extraVisionModels override, call the channel's
4646
- // OpenAI-compatible endpoint directly with its own baseURL and
4647
- // credential before giving up on this pair. Deterministic failures
4648
- // (AUTH / RATE_LIMIT / TIMEOUT) are NOT bridged: the bridge uses the
4649
- // same endpoint and credential, so it would fail identically.
4650
- const capability = pairCapabilities.get(pairKey)
4651
- const bridged =
4652
- capability &&
4653
- capability.inferred &&
4654
- (classification.kind === VISION_FAILURE_KINDS.INVALID_REQUEST ||
4655
- classification.kind === VISION_FAILURE_KINDS.NETWORK ||
4656
- classification.kind === VISION_FAILURE_KINDS.OTHER)
4657
- if (bridged) {
4658
- try {
4659
- const direct = await directChannelVisionAnswer(
4660
- pair.provider,
4661
- pair.model,
4662
- [block],
4663
- instruction,
4664
- combineSignals(deadline.signal(), AbortSignal.timeout(timeoutMs())),
4665
- )
4666
- if (direct && direct.trim() !== '') return { ok: true, text: direct.trim() }
4667
- } catch (bridgeError) {
4668
- const bridgeClassification = classifyVisionFailure(bridgeError)
4669
- visionBreaker.record(pairKey, fingerprint, bridgeClassification, scope)
4670
- recordFailure(
4671
- pairKey,
4672
- bridgeClassification,
4673
- `direct channel fallback failed (${bridgeError && bridgeError.message ? bridgeError.message : String(bridgeError)})`,
4674
- )
4675
- continue
4676
- }
4677
- }
4678
4766
  recordFailure(pairKey, classification, error && error.message ? error.message : String(error))
4679
4767
  }
4680
4768
  }
@@ -5640,8 +5728,10 @@ export function apply(ctx, config = {}) {
5640
5728
  const started = Date.now()
5641
5729
  let first
5642
5730
  for (const pair of pairs()) {
5731
+ if (!pair) continue
5732
+ if (pair.provider !== HTTP_ROUTE && !adapterAvailable(ctx.llm, pair.provider)) continue
5643
5733
  const capability = await resolveVisionBackendCapability(pair.provider, pair.model)
5644
- if (capability.image) {
5734
+ if (capability.attemptable !== false) {
5645
5735
  first = pair
5646
5736
  break
5647
5737
  }
package/lib/client.js CHANGED
@@ -40,7 +40,7 @@ window.__ModuleLoader__.load({
40
40
  guidePromptNavBody: '在设置面板左侧的导航里,点击被高亮的「插件」入口(或点「下一步」自动进入)。进入插件页后,我会自动展开 Vision Router 并定位到「视觉后端链」。',
41
41
  guidePromptCancel: '结束引导',
42
42
  guideChainTitle: '第 3 步 · 这里就是视觉模型',
43
- guideChainBody: '上面的每一行都是你自己的视觉模型,从上到下依次尝试;可以全部留空。内置 OVH 免费链固定在最后自动兜底。这里不会修改聊天页右下角的会话/文字模型。选好后点击页面底部「保存」。',
43
+ guideChainBody: '上面的每一行都是你选择的视觉后端候选,从上到下依次实际尝试;可以全部留空。DSH 的图片能力声明现在只作提示,不再决定能不能选。内置 OVH 免费链固定在最后自动兜底。选好后点击页面底部「保存」。',
44
44
  guideDone: '完成引导',
45
45
  pending: '未保存',
46
46
  readOnly: '当前设置提供方只读。',
@@ -60,7 +60,7 @@ window.__ModuleLoader__.load({
60
60
  builtinFallbackDisabled: '已关闭',
61
61
  builtinFallbackBody: 'OVHcloud 匿名视觉链共 {count} 个模型,首选 {primary}。匿名限额为每 IP、每模型 2 次/分钟;5 个模型独立限额,理论合计约 10 次/分钟,实际以 OVH 限流为准。它固定在上面用户模型之后尝试,免注册、免 Key。',
62
62
  chainLabel: '视觉后端链(给识图工具用)',
63
- chainHint: '上面每一行只选择一个你在「设置 → 模型」中已经配置的用户视觉模型(声明支持图片、按名称识别、或在下方「额外视觉模型」里手动指定);从上到下依次尝试。可以一行都不填,下方内置 OVH 免费兜底仍会工作。',
63
+ chainHint: '每行可选择「设置 → 模型」中的任意可调用生成式模型;DSH 的图片能力声明只用于提示。调用时优先走该供应商已注册的 DSH adapter(包括 WebSocket / RPC / 私有协议),失败自动尝试下一行;只有明确识别为 http(s) OpenAI Chat Completions 时才会尝试 HTTP 直连兼容兜底。',
64
64
  addFallback: '+ 添加备用视觉模型',
65
65
  remove: '移除',
66
66
  removeTitle: '移除这一行',
@@ -75,9 +75,12 @@ window.__ModuleLoader__.load({
75
75
  catalogErrorEnvelope: '模型目录接口返回失败',
76
76
  catalogError: '模型目录不可用(',
77
77
  catalogFallback: '),模型字段已退回手动输入。',
78
- visionCapsLoading: '正在验证哪些模型真正支持图片输入…',
79
- visionCapsError: '视觉能力元数据暂时不可用;为防止误选,暂不提供用户视觉模型下拉。内置 OVH 免费兜底仍可用。',
80
- visionCapsFiltered: '视觉后端下拉只显示声明支持图片、或按名称/手动指定被识别为视觉的模型。',
78
+ visionCapsLoading: '正在读取模型图片能力声明(仅用于提示,不影响可选模型)…',
79
+ visionCapsError: '视觉能力元数据暂时不可用;模型仍可选择,实际可用性将由供应商已注册的 DSH adapter 在调用时验证。',
80
+ visionCapsFiltered: '模型能力声明只用于提示,不再作为准入条件;未声明图片能力或标成仅文本的生成式模型也可选择,调用失败会自动回退。',
81
+ visionCapabilityUndeclaredWarning: '⚠️ DSH 未声明此模型支持图片输入。Vision Router 会先通过该供应商已注册的适配器实际尝试;失败后自动切换到下一视觉后端。',
82
+ visionCapabilityTextOnlyWarning: '⚠️ DSH 将此模型标记为仅文本。仍可尝试;如果底层实际上不支持图片,调用失败后会自动切换到下一视觉后端。',
83
+ visionCapabilityUnknownWarning: '⚠️ 无法读取此模型的图片能力声明。仍会优先通过 DSH 已注册的适配器实际尝试;失败后自动回退。',
81
84
  visionCapsEmptyTitle: '没有可选的用户视觉模型',
82
85
  visionCapsEmptyBody: '检测到 {count} 个用户模型,但它们都没有被 DSH 明确标记为支持图片,因此已被安全隐藏。',
83
86
  visionCapsHiddenPrefix: '被隐藏的模型:',
@@ -86,7 +89,7 @@ window.__ModuleLoader__.load({
86
89
  visionCapsHiddenMore: '另有 {count} 个模型未显示',
87
90
  visionCapsMissingImageHint: '如果这里有你刚在「设置 → 模型 → 添加自定义提供方」中添加的视觉模型:可以在高级设置「额外视觉模型」的下拉里选中它直接启用;或按 DSH 的方式在 $DSH_HOME/settings.yaml 为该模型补上 input: [text, image](或为整个提供方补 defaultInput: [text, image])。DSH 当前 Web 表单不会写入这个字段。',
88
91
  visionCapsRetry: '重新检测模型',
89
- chainInvalidCurrent: '当前保存的视觉后端不支持图片或无法验证,已从下拉列表隐藏,运行时也会跳过:',
92
+ chainInvalidCurrent: '当前保存的后端已不在可调用模型目录中,或属于非生成式/递归路由,运行时会跳过:',
90
93
  retryCatalog: '重试加载目录',
91
94
  advanced: '高级设置',
92
95
  groupTextModel: '文本模型(仅特殊场景)',
@@ -95,12 +98,12 @@ window.__ModuleLoader__.load({
95
98
  '「图片轮整轮自动路由」且会话入口为视觉路由时,作为文字轮的回退模型。',
96
99
  groupBehavior: '行为开关',
97
100
  groupParams: '参数',
98
- groupVisionOverrides: '视觉模型识别',
99
- extraVisionModelsLabel: '额外视觉模型(强制按视觉模型处理)',
101
+ groupVisionOverrides: '视觉能力标记(可选)',
102
+ extraVisionModelsLabel: '额外视觉模型(仅覆盖能力标记,可选)',
100
103
  extraVisionModelsHint:
101
- '下拉里列出的是能力过滤时被排除的模型(目录未声明图片输入或无法验证),例如智谱官方渠道(open.bigmodel.cn)的模型。' +
102
- '选中的模型会被强制按视觉模型处理:出现在上面的视觉后端下拉里并被识别工具接受;渠道适配器拒绝图片时插件会直连其 OpenAI 兼容端点。' +
103
- '留空恢复默认。常见视觉模型(如 glm-4.6v、qwen-vl、gpt-4o 等)已按名称自动识别,无需在此选择。',
104
+ '这个设置不再用于“解锁”下拉或允许调用;所有可调用生成式模型本来就能被选择。' +
105
+ '只有当你希望把某个未声明图片能力的模型明确标记为视觉模型时才需要填写。' +
106
+ '实际调用仍优先走 DSH adapter;只有明确的 http(s) OpenAI Chat Completions 渠道才可能使用直连兼容兜底。',
104
107
  groupRoutes: '路由名',
105
108
  groupProxy: '代理',
106
109
  proxyLabel: '代理地址',
@@ -196,7 +199,7 @@ window.__ModuleLoader__.load({
196
199
  wrapAllModels: '全部模型(不选 = 包装全部)',
197
200
  addWrapper: '+ 添加手动包装',
198
201
  textProviders: '视觉后端链',
199
- textProvidersHint: '每行一个真正支持图片输入的「provider/model」,从上到下失败回退;不要填写纯文本模型。留空清除用户覆盖。',
202
+ textProvidersHint: '每行一个可调用生成式「provider/model」,从上到下失败回退。图片能力声明只作提示;运行时先走 DSH adapter,失败自动回退。留空清除用户覆盖。',
200
203
  textTextProvider: '文本模型',
201
204
  textTextProviderHint: '格式「provider/model」。',
202
205
  presentingImage: '正在准备图片…',
@@ -234,7 +237,7 @@ window.__ModuleLoader__.load({
234
237
  guidePromptNavBody: 'In the settings panel’s left navigation, click the highlighted “Plugins” entry (or press “Next”). Once that page is open, I will expand Vision Router and take you to “Vision backend chain”.',
235
238
  guidePromptCancel: 'End guide',
236
239
  guideChainTitle: 'Step 3 · This is the vision model',
237
- guideChainBody: 'Each row above is one of your own vision models, tried top to bottom; you may leave them all empty. The built-in OVH chain remains the automatic final fallback. This does not change the session/text model in the lower-right chat selector. Click “Save” after choosing.',
240
+ guideChainBody: 'Each row is a vision-backend candidate you chose and will be tried in order. Rows may all stay empty. DSH image-capability metadata is advisory only and no longer decides what you may select. The built-in OVH free chain remains the final fallback. Save when done.',
238
241
  guideDone: 'Finish guide',
239
242
  pending: 'Unsaved',
240
243
  readOnly: 'The active settings provider is read-only.',
@@ -254,7 +257,7 @@ window.__ModuleLoader__.load({
254
257
  builtinFallbackDisabled: 'Disabled',
255
258
  builtinFallbackBody: 'The anonymous OVHcloud vision chain contains {count} models, starting with {primary}. Anonymous limits are 2 requests/minute per IP per model; five independent model buckets are about 10 RPM in theory, subject to OVH rate limiting. It always runs after your user models and needs no signup or API key.',
256
259
  chainLabel: 'Vision backend chain (used by the vision tools)',
257
- chainHint: 'Each row selects one user vision model already configured under Settings → Models (declaring image input, recognized by name, or listed in “Extra vision models” below). Rows are tried top to bottom. You may leave them all empty; the built-in OVH fallback below still works.',
260
+ chainHint: 'Each row may select any callable generative model from Settings → Models; DSH image-capability metadata is advisory only. Calls always go through the registered DSH adapter first (including WebSocket, RPC, or private transports), then fail over to the next row. The direct HTTP compatibility bridge is considered only for a positively identified http(s) OpenAI Chat Completions endpoint.',
258
261
  addFallback: '+ Add vision fallback',
259
262
  remove: 'Remove',
260
263
  removeTitle: 'Remove this row',
@@ -269,9 +272,12 @@ window.__ModuleLoader__.load({
269
272
  catalogErrorEnvelope: 'The model catalog endpoint failed',
270
273
  catalogError: 'Model catalog unavailable (',
271
274
  catalogFallback: '); model fields fell back to free-text input.',
272
- visionCapsLoading: 'Checking which models genuinely accept image input…',
273
- visionCapsError: 'Vision capability metadata is unavailable; user vision-model choices are hidden to prevent bad selections. The built-in OVH fallback still works.',
274
- visionCapsFiltered: 'The vision-backend dropdown shows only models that declare image input or are recognized as vision models by name / manual override.',
275
+ visionCapsLoading: 'Reading model image-capability metadata (advisory only; it does not hide selectable models)…',
276
+ visionCapsError: 'Vision capability metadata is temporarily unavailable; models remain selectable and the registered DSH adapter will verify actual support when called.',
277
+ visionCapsFiltered: 'Capability metadata is advisory, not an admission gate. Generative models with undeclared or text-only metadata remain selectable and failures automatically fall through.',
278
+ visionCapabilityUndeclaredWarning: '⚠️ DSH does not declare image input for this model. Vision Router will try the provider\'s registered adapter first and automatically fall through on failure.',
279
+ visionCapabilityTextOnlyWarning: '⚠️ DSH marks this model as text-only. You may still try it; if the underlying model rejects images, Vision Router automatically falls through.',
280
+ visionCapabilityUnknownWarning: '⚠️ Image-capability metadata is unavailable for this model. Vision Router will still try the registered DSH adapter first and fall through on failure.',
275
281
  visionCapsEmptyTitle: 'No selectable user vision models',
276
282
  visionCapsEmptyBody: 'DSH reported {count} user models, but none are explicitly marked as accepting images, so Vision Router hid them safely.',
277
283
  visionCapsHiddenPrefix: 'Hidden models:',
@@ -280,7 +286,7 @@ window.__ModuleLoader__.load({
280
286
  visionCapsHiddenMore: '{count} more models not shown',
281
287
  visionCapsMissingImageHint: 'If one of these is a vision model you just added through Settings → Models → Add custom provider: select it in the “Extra vision models” dropdown under Advanced to enable it directly; or follow the DSH way and add input: [text, image] to that model in $DSH_HOME/settings.yaml (or defaultInput: [text, image] to the provider). The current DSH Web form does not write this field.',
282
288
  visionCapsRetry: 'Re-detect models',
283
- chainInvalidCurrent: 'This saved vision backend does not support images or could not be verified. It is hidden from the dropdown and skipped at runtime: ',
289
+ chainInvalidCurrent: 'This saved backend is no longer callable, or is a non-generative/recursive route, so runtime will skip it: ',
284
290
  retryCatalog: 'Retry catalog',
285
291
  advanced: 'Advanced settings',
286
292
  groupTextModel: 'Text model (special cases only)',
@@ -289,15 +295,12 @@ window.__ModuleLoader__.load({
289
295
  'text-turn fallback when whole-turn routing is on and the session entry is a vision route.',
290
296
  groupBehavior: 'Behavior',
291
297
  groupParams: 'Parameters',
292
- groupVisionOverrides: 'Vision model recognition',
293
- extraVisionModelsLabel: 'Extra vision models (force-treat as vision)',
298
+ groupVisionOverrides: 'Vision capability labels (optional)',
299
+ extraVisionModelsLabel: 'Extra vision models (capability-label override, optional)',
294
300
  extraVisionModelsHint:
295
- 'The dropdown lists the models the capability filter excluded (image input not declared or not verifiable) — ' +
296
- 'e.g. channels like the Zhipu official API at open.bigmodel.cn. Selected models are force-treated as vision ' +
297
- 'backends: they appear in the dropdown above, are accepted by the vision tools, and when the channel adapter ' +
298
- 'refuses images the plugin calls the channel\'s OpenAI-compatible endpoint directly. Leave empty to restore the ' +
299
- 'default. Well-known vision models (glm-4.6v, qwen-vl, gpt-4o, …) are recognized by name automatically and ' +
300
- 'need no selection here.',
301
+ 'This setting no longer unlocks the picker or admission: every callable generative model is selectable already. ' +
302
+ 'Use it only when you want to explicitly label an undeclared model as visual. ' +
303
+ 'Runtime still tries the DSH adapter first; only a confirmed http(s) OpenAI Chat Completions channel may use the direct compatibility bridge.',
301
304
  groupRoutes: 'Route names',
302
305
  groupProxy: 'Proxy',
303
306
  proxyLabel: 'Proxy URL',
@@ -396,7 +399,7 @@ window.__ModuleLoader__.load({
396
399
  wrapAllModels: 'All models (empty = wrap all)',
397
400
  addWrapper: '+ Add manual wrapper',
398
401
  textProviders: 'Vision backend chain',
399
- textProvidersHint: 'One genuinely image-capable "provider/model" per line, top-down failover. Do not put text-only models here. Empty clears the override.',
402
+ textProvidersHint: 'One callable generative "provider/model" per line, top-down failover. Image-capability metadata is advisory; the registered DSH adapter is tried first and failures fall through. Empty clears the override.',
400
403
  textTextProvider: 'Text model',
401
404
  textTextProviderHint: 'Format "provider/model".',
402
405
  presentingImage: 'Preparing image…',
@@ -431,33 +434,39 @@ window.__ModuleLoader__.load({
431
434
  function filterVisionBackendGroups(groups, capabilities) {
432
435
  const caps = capabilities && typeof capabilities === 'object' ? capabilities : {}
433
436
  return (Array.isArray(groups) ? groups : [])
437
+ .filter((group) =>
438
+ group &&
439
+ typeof group.id === 'string' &&
440
+ group.id !== 'vision-http' &&
441
+ group.id !== 'vision-chain' &&
442
+ !group.id.endsWith('-vision'),
443
+ )
434
444
  .map((group) => {
435
- const models = (group && Array.isArray(group.models) ? group.models : []).filter((model) => {
436
- if (!model || typeof model.id !== 'string') return false
437
- if (group.id === 'vision-http') return false
438
- return !!(caps[group.id] && caps[group.id][model.id] && caps[group.id][model.id].image === true)
445
+ const models = (Array.isArray(group.models) ? group.models : []).filter((model) => {
446
+ if (!model || typeof model.id !== 'string' || model.id === '') return false
447
+ const capability = caps[group.id] && caps[group.id][model.id]
448
+ // Missing/negative image metadata is advisory. Only a positive
449
+ // structural rejection (non-generative endpoint, generated wrapper,
450
+ // missing adapter) removes an entry from the picker.
451
+ return !(capability && capability.attemptable === false)
439
452
  })
440
453
  return { ...group, models }
441
454
  })
442
- .filter((group) => group && group.models.length > 0)
455
+ .filter((group) => group.models.length > 0)
443
456
  }
444
457
 
458
+ // Retained for the optional capability-label override editor. These models
459
+ // are no longer hidden from the backend chain; they merely lack a positive
460
+ // image declaration/inference and can be explicitly relabelled by experts.
445
461
  function collectFilteredVisionBackends(groups, capabilities) {
446
462
  const caps = capabilities && typeof capabilities === 'object' ? capabilities : {}
447
- const hidden = []
448
- for (const group of Array.isArray(groups) ? groups : []) {
449
- if (
450
- !group ||
451
- typeof group.id !== 'string' ||
452
- group.id === 'vision-http' ||
453
- group.id === 'vision-chain' ||
454
- group.id.endsWith('-vision')
455
- ) continue
463
+ const uncertain = []
464
+ for (const group of filterVisionBackendGroups(groups, caps)) {
456
465
  for (const model of Array.isArray(group.models) ? group.models : []) {
457
- if (!model || typeof model.id !== 'string' || model.id === '') continue
458
466
  const capability = caps[group.id] && caps[group.id][model.id]
467
+ if (capability && capability.attemptable === false) continue
459
468
  if (capability && capability.image === true) continue
460
- hidden.push({
469
+ uncertain.push({
461
470
  provider: group.id,
462
471
  model: model.id,
463
472
  reason: capability && typeof capability.reason === 'string' ? capability.reason : undefined,
@@ -466,7 +475,17 @@ window.__ModuleLoader__.load({
466
475
  })
467
476
  }
468
477
  }
469
- return hidden
478
+ return uncertain
479
+ }
480
+
481
+ function visionCapabilityWarningKey(capability, status) {
482
+ if (status === 'loading' || status === 'idle') return undefined
483
+ if (status === 'error' || !capability) return 'visionCapabilityUnknownWarning'
484
+ if (capability.attemptable === false || capability.image === true) return undefined
485
+ const modalities = Array.isArray(capability.inputModalities) ? capability.inputModalities : []
486
+ return modalities.length > 0 && !modalities.includes('image')
487
+ ? 'visionCapabilityTextOnlyWarning'
488
+ : 'visionCapabilityUndeclaredWarning'
470
489
  }
471
490
 
472
491
  // ── field specs ──────────────────────────────────────────────────────────
@@ -1649,11 +1668,8 @@ window.__ModuleLoader__.load({
1649
1668
  // scroll-adjacent re-renders) walks every group × model of the catalog —
1650
1669
  // hundreds of entries — and regressed the settings card's smoothness.
1651
1670
  const hiddenVisionBackends = useMemo(
1652
- () =>
1653
- visionCaps.status === 'ready'
1654
- ? collectFilteredVisionBackends(catalog.groups, visionCaps.capabilities)
1655
- : [],
1656
- [catalog.groups, visionCaps.status, visionCaps.capabilities],
1671
+ () => collectFilteredVisionBackends(catalog.groups, visionCaps.capabilities),
1672
+ [catalog.groups, visionCaps.capabilities],
1657
1673
  )
1658
1674
  // Hooks must live at the component top level — the editor below is a
1659
1675
  // conditionally rendered plain function, so it must NOT call hooks
@@ -1931,9 +1947,6 @@ window.__ModuleLoader__.load({
1931
1947
  const half = rows.some((row) => row && (row.provider ? !row.model : !!row.model))
1932
1948
  if (half) return undefined
1933
1949
  const filled = rows.filter((row) => row && row.provider && row.model)
1934
- if (visionCaps.status === 'ready' && filled.some((row) => !visionModelVisible(row.provider, row.model))) {
1935
- return undefined
1936
- }
1937
1950
  return filled.length > 0 ? { value: filled } : { clear: true }
1938
1951
  }
1939
1952
  const value = parseProviders(text)
@@ -2253,10 +2266,20 @@ window.__ModuleLoader__.load({
2253
2266
  const chainEditor = () => {
2254
2267
  const value = format('providers')
2255
2268
  const rows = Array.isArray(value) && value.length > 0 ? value : [{ provider: '', model: '' }]
2256
- const invalidRows =
2257
- visionCaps.status === 'ready'
2258
- ? rows.filter((row) => row && row.provider && row.model && !visionModelVisible(row.provider, row.model))
2259
- : []
2269
+ const invalidRows = catalogReady
2270
+ ? rows.filter((row) => row && row.provider && row.model && !visionModelVisible(row.provider, row.model))
2271
+ : []
2272
+ const advisoryRows = rows
2273
+ .filter((row) => row && row.provider && row.model && visionModelVisible(row.provider, row.model))
2274
+ .map((row) => {
2275
+ const capability =
2276
+ visionCaps.capabilities && visionCaps.capabilities[row.provider]
2277
+ ? visionCaps.capabilities[row.provider][row.model]
2278
+ : undefined
2279
+ const warningKey = visionCapabilityWarningKey(capability, visionCaps.status)
2280
+ return warningKey ? { ...row, warningKey } : undefined
2281
+ })
2282
+ .filter(Boolean)
2260
2283
  const updateChain = (index, next) => {
2261
2284
  const list = rows.map((row) => ({ ...row }))
2262
2285
  list[index] = next
@@ -2307,6 +2330,11 @@ window.__ModuleLoader__.load({
2307
2330
  }, t('remove')),
2308
2331
  ),
2309
2332
  ),
2333
+ advisoryRows.map((row) =>
2334
+ h('p', { className: 'vr-hint vr-stealth-notice', key: `cap-${row.provider}/${row.model}` },
2335
+ `${row.provider}/${row.model} — ${t(row.warningKey)}`,
2336
+ ),
2337
+ ),
2310
2338
  invalidRows.length > 0
2311
2339
  ? h('p', { className: 'vr-invalid' },
2312
2340
  t('chainInvalidCurrent') + ' ' + invalidRows.map((row) => row.provider + '/' + row.model).join('、'))
@@ -2762,7 +2790,6 @@ window.__ModuleLoader__.load({
2762
2790
  : visionCaps.status === 'ready'
2763
2791
  ? h('p', { className: 'vr-hint' }, t('visionCapsFiltered'))
2764
2792
  : null,
2765
- emptyVisionModelsPanel(),
2766
2793
  catalogReady
2767
2794
  ? chainEditor()
2768
2795
  : h('div', {
@@ -3122,10 +3149,11 @@ window.__ModuleLoader__.load({
3122
3149
  }
3123
3150
 
3124
3151
  exports.apply = apply
3125
- exports.inject = ['settingsScope', 'slots', 'locale', 'sessions']
3152
+ exports.inject = ['settingsScope', 'slots', 'locale', 'sessions', 'connection', 'remote']
3126
3153
  exports.unwrapModelsResult = unwrapModelsResult
3127
3154
  exports.filterVisionBackendGroups = filterVisionBackendGroups
3128
3155
  exports.collectFilteredVisionBackends = collectFilteredVisionBackends
3156
+ exports.visionCapabilityWarningKey = visionCapabilityWarningKey
3129
3157
  exports.normalizeVisionChainRows = normalizeVisionChainRows
3130
3158
  exports.jsonValueEqual = jsonValueEqual
3131
3159
  exports.commitSettingsPlan = commitSettingsPlan
@@ -50,6 +50,13 @@ export const VISION_DO_NOT_RETRY_ADVICE =
50
50
  'reworded question — rephrasing cannot fix an auth, rate-limit or infrastructure failure. ' +
51
51
  'Answer from the information you already have and continue the text task; tell the user vision is temporarily unavailable.'
52
52
 
53
+ /** Ensure a user-visible failure sentence ends with exactly one terminal mark. */
54
+ export function ensureSentencePunctuation(value) {
55
+ const text = String(value ?? '').trim()
56
+ if (text === '') return ''
57
+ return /[.!?。!?]$/.test(text) ? text : `${text}.`
58
+ }
59
+
53
60
  const AUTH_PATTERNS = [/\b401\b/, /\b403\b/, /unauthorized/i, /invalid api[ -]?key/i, /forbidden/i, /authentication/i, /check the api key/i]
54
61
  const RATE_LIMIT_PATTERNS = [/\b429\b/, /rate.?limit/i, /quota temporarily/i, /too many requests/i]
55
62
  const TIMEOUT_PATTERNS = [/abort/i, /timeout/i, /etimedout/i, /timed ?out/i, /deadline exceeded/i]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vision-router",
3
- "version": "1.4.2",
3
+ "version": "1.4.4",
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/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"
67
+ "test": "node --test tests/core.test.js tests/capability-advisory.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": [
@@ -76,11 +76,13 @@
76
76
  "platform": "web",
77
77
  "inject": [
78
78
  "@deepseek-ai/dsh-client-ui-settings",
79
- "@deepseek-ai/dsh-client-runtime"
79
+ "@deepseek-ai/dsh-client-runtime",
80
+ "@deepseek-ai/dsh-client-connection",
81
+ "@deepseek-ai/dsh-api-remotes"
80
82
  ]
81
83
  },
82
84
  "bundle": {
83
85
  "patch": "./cordis.patch.yml"
84
86
  }
85
87
  }
86
- }
88
+ }