dsh-tool-vision 0.1.0 → 0.1.1

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.
Files changed (4) hide show
  1. package/README.md +39 -4
  2. package/README.zh.md +94 -39
  3. package/index.js +117 -12
  4. package/package.json +2 -1
package/README.md CHANGED
@@ -3,10 +3,17 @@
3
3
  External vision model for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness).
4
4
 
5
5
  DeepSeek's own models are text-only, and dsh-llm has no multimodal content
6
- block yet. This plugin bridges the gap with a model-facing tool that sends an
7
- image (local file, or http(s) URL) to **any OpenAI-compatible**
8
- `/chat/completions` endpoint that supports `image_url` content parts, and
9
- returns the vision model's textual answer into the agent loop.
6
+ block yet. This plugin bridges the gap in two ways:
7
+
8
+ 1. **`inspect_image` tool** — sends an image (local file, or http(s) URL) to
9
+ **any OpenAI-compatible** `/chat/completions` endpoint that supports
10
+ `image_url` content parts, and returns the vision model's textual answer
11
+ into the agent loop.
12
+ 2. **Image bridge (v0.1.1)** — when the active model is text-only, images
13
+ pasted into the conversation are intercepted on the `llm/stream`
14
+ waterfall, exported to a local file, and replaced with a text hint, so
15
+ the agent picks them up with `inspect_image`. Models listed in
16
+ `multimodalModels` receive image blocks directly instead.
10
17
 
11
18
  - Zero dependencies beyond the dsh SDK — works with any compatible endpoint:
12
19
  OpenAI GPT-4o, Qwen-VL (DashScope), GLM-4V (Zhipu), Moonshot, Gemini
@@ -47,6 +54,34 @@ Or load it from a local path without npm:
47
54
  | `timeoutMs` | `60000` | Per-request timeout. |
48
55
  | `maxImageBytes` | `10MB` | Largest accepted local image. |
49
56
  | `description` | default | Tool description shown to the model. |
57
+ | `bridgeTextOnly` | `true` | Bridge pasted images to text hints for text-only models. |
58
+ | `bridgeExportDir` | temp | Export dir for bridged images (`os.tmpdir()/dsh-vision-bridge`). |
59
+ | `multimodalModels` | `[]` | Model ids that receive image blocks directly (e.g. `mimo-v2.5`). |
60
+
61
+ ## Image bridge setup
62
+
63
+ 1. In your model settings, declare image input on the text models you use
64
+ (pi-ai style), so the harness lets image messages through:
65
+ ```yaml
66
+ llm-pi-ai:
67
+ providers:
68
+ your-provider:
69
+ models:
70
+ - id: deepseek-v4-flash
71
+ input: [text, image]
72
+ ```
73
+ 2. List genuinely multimodal models in the plugin config so they receive
74
+ image blocks untouched:
75
+ ```yaml
76
+ - id: tool-vision
77
+ name: 'dsh-tool-vision'
78
+ config:
79
+ multimodalModels: ['mimo-v2.5', 'grok-4.5']
80
+ ```
81
+
82
+ Then pasting an image while on a text-only model produces a hint like
83
+ `[User sent an image, exported to: <path>. Inspect it with the inspect_image tool...]`
84
+ and the agent inspects it through the configured vision endpoint.
50
85
 
51
86
  Key resolution order: `config.apiKey` → `process.env[apiKeyEnv]` →
52
87
  `process.env.OPENAI_API_KEY`.
package/README.zh.md CHANGED
@@ -1,60 +1,115 @@
1
1
  # dsh-tool-vision
2
2
 
3
- 给 DeepSeek Harness 外接**视觉模型**的工具插件。DeepSeek 自家模型是纯文本的,而 dsh-llm 目前还没有多模态内容块类型——本插件用一条 OpenAI 兼容接口补上这个缺口:
3
+ External vision model for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness).
4
4
 
5
- Agent 调用工具 `inspect_image`,把本地图片(或 http(s) 图片 URL)发给任意兼容 `image_url` 内容块的 `/chat/completions` 端点,把视觉模型的文字回答带回对话。
5
+ DeepSeek's own models are text-only, and dsh-llm has no multimodal content
6
+ block yet. This plugin bridges the gap in two ways:
6
7
 
7
- - 注册在**全局工具层**:进程内所有 Agent 都能调用 `inspect_image`
8
- - 支持任意 OpenAI 兼容视觉端点:OpenAI GPT-4o / Qwen-VL (DashScope) / GLM-4V (智谱) / Moonshot / Gemini 兼容端点 / Ollama 本地等
9
- - 本地图片自动转 base64 data URL,检查格式与大小上限
8
+ 1. **`inspect_image` tool** — sends an image (local file, or http(s) URL) to
9
+ **any OpenAI-compatible** `/chat/completions` endpoint that supports
10
+ `image_url` content parts, and returns the vision model's textual answer
11
+ into the agent loop.
12
+ 2. **Image bridge (v0.1.1)** — when the active model is text-only, images
13
+ pasted into the conversation are intercepted on the `llm/stream`
14
+ waterfall, exported to a local file, and replaced with a text hint, so
15
+ the agent picks them up with `inspect_image`. Models listed in
16
+ `multimodalModels` receive image blocks directly instead.
10
17
 
11
- ## 配置
18
+ - Zero dependencies beyond the dsh SDK — works with any compatible endpoint:
19
+ OpenAI GPT-4o, Qwen-VL (DashScope), GLM-4V (Zhipu), Moonshot, Gemini
20
+ compatible endpoints, local Ollama, etc.
21
+ - Registered on the **global tools layer**: every agent in the process can
22
+ call `inspect_image`.
12
23
 
13
- | 字段 | 默认值 | 含义 |
14
- |---|---|---|
15
- | `baseURL` | `https://api.openai.com/v1` | OpenAI 兼容 API 基地址 |
16
- | `apiKey` | `''` | API 密钥(优先于环境变量) |
17
- | `apiKeyEnv` | `VISION_API_KEY` | 存放密钥的环境变量名 |
18
- | `model` | `gpt-4o-mini` | 视觉模型 id |
19
- | `maxTokens` | `1024` | 视觉调用最大输出 token |
20
- | `timeoutMs` | `60000` | 单次请求超时 |
21
- | `maxImageBytes` | `10MB` | 本地图片大小上限 |
22
- | `description` | 默认描述 | 工具描述(模型可见) |
24
+ ## Install
23
25
 
24
- 密钥解析顺序:`config.apiKey` → `process.env[apiKeyEnv]` → `process.env.OPENAI_API_KEY`。
26
+ Mount in a profile patch (`$DSH_HOME/profiles/<name>/cordis.patch.yml`):
25
27
 
26
- ## 挂载
28
+ ```yaml
29
+ - insert:
30
+ - id: tool-vision
31
+ name: 'dsh-tool-vision' # after: pnpm add dsh-tool-vision in the profile
32
+ config:
33
+ baseURL: 'https://api.openai.com/v1'
34
+ apiKeyEnv: 'VISION_API_KEY'
35
+ model: 'gpt-4o-mini'
36
+ ```
27
37
 
28
- 在 profile 的 `cordis.patch.yml` 里 insert:
38
+ Or load it from a local path without npm:
29
39
 
30
40
  ```yaml
31
- - insert:
32
41
  - id: tool-vision
33
42
  name: './plugins/dsh-tool-vision/index.js'
34
- config:
35
- baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1'
36
- apiKeyEnv: 'DASHSCOPE_API_KEY'
37
- model: 'qwen-vl-plus'
38
43
  ```
39
44
 
40
- ## 工具:`inspect_image`
45
+ ## Config
41
46
 
42
- | 参数 | 必填 | 说明 |
47
+ | Field | Default | Meaning |
43
48
  |---|---|---|
44
- | `path` | ✅ | 图片路径(绝对路径,或相对当前 workspace)或 http(s) URL |
45
- | `question` | – | 针对图片的具体问题;缺省为"详细描述" |
46
- | `detail` | – | `auto` / `low` / `high`,图片分辨率提示 |
49
+ | `baseURL` | `https://api.openai.com/v1` | OpenAI-compatible API base URL. |
50
+ | `apiKey` | `''` | API key (takes precedence over env). |
51
+ | `apiKeyEnv` | `VISION_API_KEY` | Env var holding the key. |
52
+ | `model` | `gpt-4o-mini` | Vision model id. |
53
+ | `maxTokens` | `1024` | Max output tokens. |
54
+ | `timeoutMs` | `60000` | Per-request timeout. |
55
+ | `maxImageBytes` | `10MB` | Largest accepted local image. |
56
+ | `description` | default | Tool description shown to the model. |
57
+ | `bridgeTextOnly` | `true` | Bridge pasted images to text hints for text-only models. |
58
+ | `bridgeExportDir` | temp | Export dir for bridged images (`os.tmpdir()/dsh-vision-bridge`). |
59
+ | `multimodalModels` | `[]` | Model ids that receive image blocks directly (e.g. `mimo-v2.5`). |
60
+
61
+ ## Image bridge setup
62
+
63
+ 1. In your model settings, declare image input on the text models you use
64
+ (pi-ai style), so the harness lets image messages through:
65
+ ```yaml
66
+ llm-pi-ai:
67
+ providers:
68
+ your-provider:
69
+ models:
70
+ - id: deepseek-v4-flash
71
+ input: [text, image]
72
+ ```
73
+ 2. List genuinely multimodal models in the plugin config so they receive
74
+ image blocks untouched:
75
+ ```yaml
76
+ - id: tool-vision
77
+ name: 'dsh-tool-vision'
78
+ config:
79
+ multimodalModels: ['mimo-v2.5', 'grok-4.5']
80
+ ```
81
+
82
+ Then pasting an image while on a text-only model produces a hint like
83
+ `[User sent an image, exported to: <path>. Inspect it with the inspect_image tool...]`
84
+ and the agent inspects it through the configured vision endpoint.
85
+
86
+ Key resolution order: `config.apiKey` → `process.env[apiKeyEnv]` →
87
+ `process.env.OPENAI_API_KEY`.
88
+
89
+ ## Tool: `inspect_image`
90
+
91
+ | Arg | Required | Meaning |
92
+ |---|---|---|
93
+ | `path` | ✅ | Image path (absolute, or relative to the current workspace) or http(s) URL. |
94
+ | `question` | – | Optional specific question about the image. |
95
+ | `detail` | – | `auto` / `low` / `high` resolution hint. |
96
+
97
+ Example endpoints (`baseURL`):
98
+
99
+ - **OpenAI**: `https://api.openai.com/v1` — `gpt-4o`, `gpt-4o-mini`
100
+ - **Alibaba DashScope (Qwen-VL)**: `https://dashscope.aliyuncs.com/compatible-mode/v1` — `qwen-vl-plus`, `qwen-vl-max`
101
+ - **Zhipu (GLM-4V)**: `https://open.bigmodel.cn/api/paas/v4` — `glm-4v-flash` (free tier), `glm-4v-plus`
102
+ - **Moonshot (Kimi)**: `https://api.moonshot.cn/v1` — `moonshot-v1-8k-vision-preview`
103
+ - **Ollama local**: `http://localhost:11434/v1` — `llama3.2-vision` (no key)
47
104
 
48
- 示例端点(`baseURL` 写法):
105
+ ## Limitations
49
106
 
50
- - **OpenAI**:`https://api.openai.com/v1`,模型 `gpt-4o` / `gpt-4o-mini`
51
- - **阿里云百炼 (Qwen-VL)**:`https://dashscope.aliyuncs.com/compatible-mode/v1`,模型 `qwen-vl-plus` / `qwen-vl-max`
52
- - **智谱 (GLM-4V)**:`https://open.bigmodel.cn/api/paas/v4`,模型 `glm-4v-flash`(免费档)/ `glm-4v-plus`
53
- - **Moonshot (Kimi)**:`https://api.moonshot.cn/v1`,模型 `moonshot-v1-8k-vision-preview`
54
- - **Ollama 本地**:`http://localhost:11434/v1`,模型 `llama3.2-vision`(无需 key)
107
+ - The image enters the conversation as a text description (a transcript, not
108
+ pixels) — pixel-precise tasks may be inaccurate.
109
+ - Images are base64-transferred; mind privacy and size limits.
110
+ - Independent of the dsh-llm routing/retry system; failures return clear
111
+ errors to the agent.
55
112
 
56
- ## 局限
113
+ ## License
57
114
 
58
- - 图片内容以文字描述进入对话,Agent 得到的是"转述"而非像素——精确到像素的任务(如测量)可能不够准
59
- - 图片会在请求中 base64 传输,注意隐私与大小
60
- - 不依赖 dsh-llm 服务,因此不走模型路由/重试体系;失败会以明确的错误信息返回给 Agent
115
+ MIT
package/index.js CHANGED
@@ -1,24 +1,29 @@
1
1
  /**
2
2
  * dsh-tool-vision — external vision model for DeepSeek Harness.
3
3
  *
4
- * DeepSeek's own models are text-only, and dsh-llm has no multimodal content
5
- * block type yet. This plugin bridges the gap with a model-facing tool that
6
- * sends an image (local file, or http(s) URL) to any OpenAI-compatible
7
- * chat/completions endpoint that supports `image_url` content parts, and
8
- * returns the vision model's textual answer into the agent loop.
4
+ * Two capabilities:
9
5
  *
10
- * Registered on the GLOBAL tools layer, so every agent in the process can
11
- * call `inspect_image`.
6
+ * 1. `inspect_image` tool — sends an image (local file, or http(s) URL) to
7
+ * any OpenAI-compatible chat/completions endpoint that supports
8
+ * `image_url` content parts, and returns the vision model's text answer.
9
+ *
10
+ * 2. Image bridge — when the current model is text-only (e.g. DeepSeek),
11
+ * pasted images in user messages are intercepted on the `llm/stream`
12
+ * waterfall, exported to a local file, and replaced with a text hint
13
+ * pointing at that path, so the agent can hand the image to
14
+ * `inspect_image`. Models listed in `multimodalModels` are left
15
+ * untouched (they receive image blocks directly).
12
16
  */
13
- import { readFile, stat } from "node:fs/promises";
14
- import { extname, isAbsolute, resolve as resolvePath } from "node:path";
17
+ import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
18
+ import { extname, isAbsolute, join, resolve as resolvePath } from "node:path";
19
+ import os from "node:os";
15
20
  import z from "@deepseek-ai/schemastery";
16
21
  import { defineTool } from "@deepseek-ai/dsh-tools";
17
22
 
18
23
  /** Cordis plugin name. */
19
24
  const name = "tool-vision";
20
- /** The tool registry this row contributes to. */
21
- const inject = ["tools"];
25
+ /** The tool registry, the llm seam (waterfall), and the attachment store. */
26
+ const inject = ["tools", "llm", "attachments"];
22
27
 
23
28
  const DEFAULT_DESCRIPTION =
24
29
  "Analyze an image using an external vision-capable model through an OpenAI-compatible API. " +
@@ -45,6 +50,12 @@ const Config = z.object({
45
50
  maxImageBytes: z.number().default(10 * 1024 * 1024),
46
51
  /** Tool description shown to the model; overrides the default. */
47
52
  description: z.string().default(DEFAULT_DESCRIPTION),
53
+ /** Bridge pasted images to text hints when the current model is text-only. */
54
+ bridgeTextOnly: z.boolean().default(true),
55
+ /** Export directory for bridged images; empty = system temp. */
56
+ bridgeExportDir: z.string().default(""),
57
+ /** Model ids that receive image blocks directly (never bridged). */
58
+ multimodalModels: z.array(z.string()).default([]),
48
59
  });
49
60
 
50
61
  const MIME_BY_EXT = {
@@ -59,6 +70,73 @@ const MIME_BY_EXT = {
59
70
  ".ico": "image/x-icon",
60
71
  };
61
72
 
73
+ const EXT_BY_MEDIA = {
74
+ "image/png": ".png",
75
+ "image/jpeg": ".jpg",
76
+ "image/webp": ".webp",
77
+ "image/gif": ".gif",
78
+ };
79
+
80
+ /** True when any message carries an image content block. */
81
+ function hasImageBlock(messages) {
82
+ return (messages ?? []).some((m) =>
83
+ Array.isArray(m?.content) && m.content.some((b) => b?.type === "image"),
84
+ );
85
+ }
86
+
87
+ /** Export one attachment to disk; returns the file path (cached per process). */
88
+ const exportedPaths = new Map();
89
+ async function exportImage(attachment, ctx, dir) {
90
+ const cached = exportedPaths.get(attachment.attachmentId);
91
+ if (cached) return cached;
92
+ const { data } = await ctx.attachments.readImage(attachment);
93
+ const ext = EXT_BY_MEDIA[attachment.mediaType] ?? ".img";
94
+ const safeName = attachment.name
95
+ ? attachment.name
96
+ .replace(/\.[^.]+$/, "")
97
+ .replace(/[^\w\-]+/g, "_")
98
+ .replace(/^_+|_+$/g, "")
99
+ .slice(0, 40)
100
+ : "";
101
+ const base = (safeName ? `${safeName}_` : "") + attachment.attachmentId.slice(0, 12);
102
+ const path = join(dir, `${base}${ext}`);
103
+ await writeFile(path, data);
104
+ exportedPaths.set(attachment.attachmentId, path);
105
+ return path;
106
+ }
107
+
108
+ /**
109
+ * Replace image content blocks with text hints pointing at exported files.
110
+ * Exported for unit testing; `ctx` only needs `attachments`.
111
+ */
112
+ async function bridgeMessages(messages, ctx, dir) {
113
+ const next = [];
114
+ for (const message of messages) {
115
+ const content = message?.content;
116
+ if (!Array.isArray(content) || !content.some((b) => b?.type === "image")) {
117
+ next.push(message);
118
+ continue;
119
+ }
120
+ const blocks = [];
121
+ for (const block of content) {
122
+ if (block?.type !== "image") {
123
+ blocks.push(block);
124
+ continue;
125
+ }
126
+ const path = await exportImage(block.attachment, ctx, dir);
127
+ const name = block.attachment.name ? ` (${block.attachment.name})` : "";
128
+ blocks.push({
129
+ type: "text",
130
+ text:
131
+ `[User sent an image${name}, exported to: ${path}. ` +
132
+ `Inspect it with the inspect_image tool to see its content.]`,
133
+ });
134
+ }
135
+ next.push({ ...message, content: blocks });
136
+ }
137
+ return next;
138
+ }
139
+
62
140
  function resolveApiKey(config) {
63
141
  if (config.apiKey) return config.apiKey;
64
142
  if (config.apiKeyEnv) {
@@ -146,6 +224,23 @@ async function callVision(config, imageUrl, question, detail, signal) {
146
224
  }
147
225
 
148
226
  function apply(ctx, config) {
227
+ // ── image bridge: text-only models get exported paths instead of pixels ──
228
+ if (config.bridgeTextOnly) {
229
+ const exportDir = config.bridgeExportDir || join(os.tmpdir(), "dsh-vision-bridge");
230
+ mkdir(exportDir, { recursive: true }).catch(() => {});
231
+ ctx.on("llm/stream", async function* (options, next) {
232
+ let effective = options;
233
+ try {
234
+ if (options?.messages && hasImageBlock(options.messages) && !config.multimodalModels.includes(options.model)) {
235
+ effective = { ...options, messages: await bridgeMessages(options.messages, ctx, exportDir) };
236
+ }
237
+ } catch (error) {
238
+ ctx.logger.warn(`[tool-vision] image bridge failed: ${String(error)}`);
239
+ }
240
+ yield* next(effective);
241
+ });
242
+ }
243
+
149
244
  ctx.tools.register(defineTool({
150
245
  name: "inspect_image",
151
246
  description: config.description,
@@ -178,4 +273,14 @@ function apply(ctx, config) {
178
273
  }));
179
274
  }
180
275
 
181
- export { Config, DEFAULT_DESCRIPTION, apply, inject, name };
276
+ export {
277
+ Config,
278
+ DEFAULT_DESCRIPTION,
279
+ EXT_BY_MEDIA,
280
+ apply,
281
+ bridgeMessages,
282
+ exportImage,
283
+ hasImageBlock,
284
+ inject,
285
+ name,
286
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-tool-vision",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "External vision model for DeepSeek Harness: inspect_image sends local images or http(s) image URLs to any OpenAI-compatible chat/completions endpoint and returns the vision model's text answer.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -42,3 +42,4 @@
42
42
  }
43
43
  }
44
44
 
45
+