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.
- package/README.md +39 -4
- package/README.zh.md +94 -39
- package/index.js +117 -12
- 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
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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 in two ways:
|
|
6
7
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
45
|
+
## Config
|
|
41
46
|
|
|
42
|
-
|
|
|
47
|
+
| Field | Default | Meaning |
|
|
43
48
|
|---|---|---|
|
|
44
|
-
| `
|
|
45
|
-
| `
|
|
46
|
-
| `
|
|
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
|
-
|
|
105
|
+
## Limitations
|
|
49
106
|
|
|
50
|
-
-
|
|
51
|
-
|
|
52
|
-
-
|
|
53
|
-
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
11
|
-
*
|
|
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
|
|
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 {
|
|
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.
|
|
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
|
+
|