zhiwuli0228/dsh-image-router ↗★ 0
dsh-image-router
DeepSeek Harness plugin: analyse images out of band with a vision model — pasted images are digested into text before admission and a describe_image tool covers image paths, all without ever changing the session's model.
安装
npx -p @deepseek-ai/dsh dsh plugin --profile web add github:zhiwuli0228/dsh-image-router说明文档
阅读完整 README ↗配置视觉路由:两条路,都不需要手写 YAML
图片发给谁,由 vision 决定。它有两档,装好后可以在 设置 → 插件 → 插件配置 的卡片里直接切换:
① 用这个部署里已经配好的模型
卡片会列出支持图片输入的模型供点选,不用记 provider id:
- 优先读
$DSH_HOME/settings.yaml里llm-pi-ai段已经声明了models的路由(这正是设置 → 模型页写的那些); - 否则逐个 provider 调用
remote.llm.discoverModels('image-router', { provider }),由宿主侧的图片能力 oracle 回答(见下); - 下拉里找不到就手填
provider/model(目录型的路由可以服务卡片枚举不到的模型)。
图片能力 oracle 是怎么来的:浏览器唯一的模型类接口是 remote.llm.discoverModels(settingsNs, request),且只能按命名空间提问。本插件因此在自己的命名空间 image-router 上注册了一个 discovery handler,用 ctx.llm.listModels(provider) 读每条路由的模型,再按 inputModalities 过滤:
- 模型显式声明了模态 → 声明说了算(声明
['text']的即使 id 里有vision也剔除); - 路由什么都没披露 → 退回到 id 里的视觉家族特征(
vision/vl/omni/4o/gemini…),因为目录型路由未必公开这个字段; - 未知/不可达路由 → 空列表,不抛错。
实测:对 deepseek-official(4 个模型)只返回 2 个图片模型(deepseek-flash、deepseek-v4-flash-vision-exp);审计写 discover provider=… models=4 imageCapable=2。
对应的配置就是:
- id: image-router
config:
vision: { provider: qwen-token-plan-cn, model: qwen3.8-flash }
② 自定义端点:填 baseURL + 模型 + API Key
没有现成的视觉模型?卡片里切到「自定义端点」,只填三样东西:地址、模型名、API Key。
插件不自己实现协议,也不自己存密钥 —— 它把这三样翻译成上游本来就有的东西:
| 你填的 | 落到哪里 |
|---|---|
| baseURL / 协议 / 模型 / 显示名 | $DSH_HOME/settings.yaml → llm-pi-ai.providers.image-router-vision(只写这一条路径,你原有的 provider 一个字节都不动) |
| API Key | 凭据库 $DSH_HOME/.credentials.yaml → refs.IMAGE_ROUTER_VISION_API_KEY;路由里只留引用名 |
| 图片能力声明 | 模型条目上的 input: [text, image] |
也就是说:线协议、模型发现、图片投影、重试全都由 @deepseek-ai/dsh-llm-pi-ai 负责,本插件只是"把卡片上的三个字段写成 DSH 的配置"。因此 dsh web 不需要重启,下一次判定就用新路由。
- id: image-router
config:
vision:
endpoint:
baseURL: https://gateway.example/v1
model: gpt-4o-mini
api: openai-completions # 可选:openai-completions / openai-responses / anthropic-messages
apiKey: sk-… # 可选:写入凭据库后即从配置里消失(只写不读)
几个值得知道的细节:
endpoint优先于provider/model;两者都在时用显式的provider/model,所以老配置不会被动改变行为。- API Key 是只写字段:Host 收到后写进凭据库;组合 base 与卡片草稿都会剥掉它,所以它既不回显、也不写进本插件的配置、也不进 trace。这条是实测修出来的 —— 早先的实现让
describe()返回的已解析值带着密钥,明文于是落进了$DSH_HOME/settings.yaml的image-router.vision.endpoint.apiKey。现在有两道独立防线:configBase剥掉endpoint.apiKey(设置节本身不可能持有密钥),卡片编辑器用withoutKey(...)播种(旧版本写下的密钥不会被回填进草稿,也就不会被下一次保存写回去)。 - 凭据存成引用名:
$DSH_HOME/.credentials.yaml的refs.IMAGE_ROUTER_VISION_API_KEY(可用apiKeyEnv改名)。选引用名而不是泛型 key,是因为 pi-ai 解析apiKeyEnv走的正是这一层 ——credentialRef(name)→ctx.credentials.resolve(...),与你原有的QWEN_TOKEN_PLAN_CN_API_KEY同一种形态;泛型 key 在这一层解析不到。 - 别关掉「声明支持图片输入」:pi-ai 对自定义路由的默认模态是
["text"],关掉之后发过去的图片会被投影成占位文字,端点收不到图。 - 想在设置 → 模型里管理这条路由也行:它就是一条普通的
llm-pi-ai路由,displayName、超时、协议都能在那边继续改。 - 卡片会在审计文件里留一行
endpoint-route-live … modalities=text+image,用来回答"我配的端点到底生效了吗"。
在图形界面里配置
插件注册了一个设置命名空间 image-router,并自带浏览器一半:装上后 设置 → 插件 → 插件配置 里会出现一张 image-router · 图片旁路识别 卡片。
卡片里的 vision 路由有两档(详见上面「配置视觉路由」):
- ① 已配置模型:从
llm-pi-ai设置段 + 宿主侧图片能力 oracle(remote.llm.discoverModels('image-router', …))汇总出的下拉,只列支持图片的模型,点选即可,另有provider/model手填兜底。 - ② 自定义端点:填
baseURL/model/apiKey(+ 可选协议、显示名、凭据引用名)。保存后由宿主写进llm-pi-ai与凭据库,不重启即可用。
| 卡片字段 | 生效时机 |
|---|---|
mode、vision(两档都算)、instruction、maxTokens、timeoutMs、label | 保存后立即生效 —— 下一次提示词、下一次旁路调用、下一次工具调用就用新值,不需要重启 |
tool(是否注册 describe_image)、traceFile(审计文件路径) | 挂载时确定,改动需要重启 dsh web(卡片里没有这两项,免得承诺做不到的事) |
- 卡片显示的是当前生效值:profile 补丁层的配置作为该设置节的 base,卡片里的保存只是叠在它之上的一层覆盖(落在
$DSH_HOME/settings.yaml的image-router:段)。 - 写坏不会破坏正在工作的插件:校验失败的覆盖会被忽略,继续用上一份好配置,并在审计文件里记一行
settings-invalid …。 - 端点档只写自己那一条路径:用
settings.mutate('llm-pi-ai', [{op:'set', path:['providers','image-router-vision'], value}]),你原有的 provider 不会被整体覆盖;撤掉端点档时发的是对应的unset,凭据保留(可能还想复用)。这条路径上的set会替换整个 profile 对象,所以写入前先settings.get('llm-pi-ai')读回现值做合并 —— 你在「设置 → 模型」给这条路由加过的headers、compat、timeoutMs不会被一次保存抹掉(实测:预置的x-tenant头与 5000ms 超时在保存后仍在,而models被重建为本轮的值)。 - 两半必须用同一个 namespace:宿主
lib/settings.js的SETTINGS_NAMESPACE与浏览器client/client.js的NAMESPACE。 - 宿主侧只走
ctx.inject(['settings'])+settings.register(ns, schema, { base }):绝不 import@deepseek-ai/dsh-settings的命名导出 —— 上游删过installSettingsSection,而缺失的命名导出是模块求值期 SyntaxError,会让宿主启动失败退出 1(dshmarket 踩过这个坑)。 - 浏览器一半靠
ctx.remote(来自dsh-api-remotes,已写进本包dsh.client.inject)读取模型清单;没有远端服务的部署会退化成纯手填,卡片照常渲染。 - 浏览器一半的
inject必须是空数组,两个服务都靠可选注入拿:ctx.inject(['slots','settingsScope'], …)。slots不是包名、也不在任何客户端清单里(它是隐式提供的服务),而声明了加载器满足不了的依赖会让 entry 永久 pending 且毫无提示 ——apply从不执行、卡片从不注册、页面什么都不显示、控制台一片干净。这正是本插件卡片长期不出现的两个原因之一(另一个是下面这条)。宿主半边用的是同一手法,所以薄部署也能激活。 - 只有注册了命名空间且有浏览器一半注册
settings.plugin.item卡片的插件才会出现在那个页面:两者缺一都不会渲染任何东西。该 tab 的实现是namespaces.map(ns => renderSlot('settings.plugin.item', {}, { entryKey: ns }))—— 所以"宿主 serve 的命名空间"与"卡片 claim 的 key"必须完全一致。 - 激活状态可观测:浏览器里读
window.__imageRouter会得到{applied, slots, scope, slotDispatched, registered, error},逐步说明走到了哪一步。这条路径上的失败方式全是静默的,所以状态必须可读、不能靠猜。
3. 在 profile 的 cordis.patch.yml 里写配置覆盖(完整示例见 examples/cordis.patch.yml)
没有现成的视觉模型也可以只给端点,见下面「配置视觉路由」的 ②。
### B. 从 GitHub 安装(等价形态)
```powershell
# 3. 在 profile 的 cordis.patch.yml 里写配置覆盖(完整示例见 examples/cordis.patch.yml)
# 之后同样加进 dsh.profile.bundles,并在 profile 的 cordis.patch.yml 写同 id 的配置覆盖
装好后这些配置项也能在 设置 → 插件 → 插件配置 的卡片里改(见下),保存即时生效。
然后重启 dsh web。
C. 本地开发 / 不想装依赖:按路径挂载
零安装,只要把仓库放在磁盘上:
## 配置项
| 键 | 默认 | 说明 |
|---|---|---|
| `mode` | `digest` | `digest`=旁路分析并替换文字(不改模型);`switch`=临时借用视觉路由 |
| `vision` | 必填(二选一) | 旁路调用的路由。**①** `provider` / `model` / 可选 `reasoningEffort`;**②** `endpoint: { baseURL, model, api?, name?, apiKey?, apiKeyEnv?, images? }` —— 由宿主翻译成上游 `llm-pi-ai` 的一条路由。两者同时存在时 ① 优先 |
| `instruction` | 内置(提取文字 + 描述画面) | 给视觉模型的指令 |
| `maxTokens` | `900` | 分析结果上限 |
| `timeoutMs` | `120000` | 单次旁路调用的超时 |
| `label` | `true` | 在替换文本前加 `[图片分析 · provider/model]` 标记 |
| `tool` | `true` | 是否注册 `describe_image`(按路径按需分析图片的模型工具) |
| `traceFile` | 无 | 审计文件:挂载、每次图片分析、工具调用、失败原因 |
| `dryRun` | `false` | 只写审计、不改提示词、不调视觉模型 |
| `text` / `holdTurns` / `sticky` | — | **仅 switch 模式**:归还兜底路由、图片后再保持视觉模型的轮数、永不归还 |
| `imageExtensions` / `hint` | — | **仅 switch 模式**:文本里的图片扩展名/正则判定 |