dsh-model-input-toggle 0.0.0-stage → 1.0.0
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 +353 -2
- package/cordis.patch.yml +13 -0
- package/lib/client.js +731 -0
- package/lib/index.js +892 -0
- package/package.json +48 -4
- package/tools/settings-semantics.mjs +68 -0
- package/tools/test-client.mjs +954 -0
- package/tools/test-host.mjs +761 -0
- package/tools/test.mjs +27 -0
- package/tools/verify-endpoint.mjs +478 -0
package/tools/test.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dsh-model-input-toggle — run every test in this directory.
|
|
4
|
+
*
|
|
5
|
+
* The suites are plain Node scripts with no test framework, so this simply runs
|
|
6
|
+
* them in sequence and aggregates the exit status.
|
|
7
|
+
*
|
|
8
|
+
* Run: node tools/test.mjs
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
|
|
15
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const suites = ["test-host.mjs", "test-client.mjs"];
|
|
17
|
+
|
|
18
|
+
let failed = 0;
|
|
19
|
+
for (const suite of suites) {
|
|
20
|
+
process.stdout.write(`\n══════ ${suite} ══════\n`);
|
|
21
|
+
// `stdio: "inherit"` keeps the child's own output streaming straight through.
|
|
22
|
+
const result = spawnSync(process.execPath, [join(here, suite)], { stdio: "inherit" });
|
|
23
|
+
if (result.status !== 0) failed += 1;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
process.stdout.write(`\n${failed === 0 ? "ALL SUITES PASSED" : `${failed} SUITE(S) FAILED`}\n`);
|
|
27
|
+
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* dsh-verify-model-image — standalone endpoint capability checker.
|
|
4
|
+
*
|
|
5
|
+
* WHY A SEPARATE TOOL
|
|
6
|
+
* -------------------
|
|
7
|
+
* `input: ["text","image"]` and `inputModalities: ["text","image"]` are
|
|
8
|
+
* DECLARATIONS. They tell DSH "this model accepts images", which stops DSH from
|
|
9
|
+
* intercepting the message. They cannot make the endpoint accept it. If the
|
|
10
|
+
* remote API does not implement OpenAI-style `image_url` parts, the visible
|
|
11
|
+
* failure simply moves from a local block to `HTTP 400`.
|
|
12
|
+
*
|
|
13
|
+
* This script answers the only question that actually matters: does THIS
|
|
14
|
+
* endpoint, at THIS baseURL, with THIS key, accept a real image-bearing request
|
|
15
|
+
* for THIS model? It is deliberately independent of the DSH process, so it can
|
|
16
|
+
* be run before trusting a checkbox (in CI, in a shell, or from the plugin's
|
|
17
|
+
* 「验证端点」 button, which performs the same probe on the host side).
|
|
18
|
+
*
|
|
19
|
+
* It reads provider settings from $DSH_HOME/settings.yaml when available so the
|
|
20
|
+
* common case needs no arguments; explicit flags always win.
|
|
21
|
+
*
|
|
22
|
+
* USAGE
|
|
23
|
+
* node tools/verify-endpoint.mjs --provider agnes --model agnes-3.0-flash
|
|
24
|
+
* node tools/verify-endpoint.mjs --base-url https://api.example.com/v1 \
|
|
25
|
+
* --model my-vision-model --api-key sk-...
|
|
26
|
+
* node tools/verify-endpoint.mjs --provider jiuling --model buddy:glm-5.3-flash --json
|
|
27
|
+
*
|
|
28
|
+
* EXIT CODES
|
|
29
|
+
* 0 the endpoint accepted the image (200)
|
|
30
|
+
* 1 the endpoint rejected the image (400/422) — the declaration is a lie
|
|
31
|
+
* 2 the probe could not decide (auth, network, 404, timeout, bad input)
|
|
32
|
+
*
|
|
33
|
+
* @module dsh-model-input-toggle/tools/verify-endpoint
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { readFileSync } from "node:fs";
|
|
37
|
+
import { join } from "node:path";
|
|
38
|
+
import { homedir } from "node:os";
|
|
39
|
+
|
|
40
|
+
/** Solid 64x64 PNG. A valid image payload that costs almost no tokens. */
|
|
41
|
+
const PROBE_PNG_B64 =
|
|
42
|
+
"iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAeklEQVR4nO3PUQkAIBTAwBfHiIY1jCH8OITBAtxm7fN1wwUNaEEDWtCAFjSgBQ1oQQNa0IAWNKAFDWhBA1rQgBY0oAUNaEEDWtCAFjSgBQ1oQQNa0IAWNKAFDWhBA1rQgBY0oAUNaEEDWtCAFjSgBQ1oQQNa0IAWPHYBhBLBWprx9KEAAAAASUVORK5CYII=";
|
|
43
|
+
|
|
44
|
+
/** Sentinel returned instead of throwing, so every exit path is explicit. */
|
|
45
|
+
const USAGE = `dsh-verify-model-image — 验证模型端点是否真的接受图片输入
|
|
46
|
+
|
|
47
|
+
用法:
|
|
48
|
+
node tools/verify-endpoint.mjs --provider <route> --model <id> [选项]
|
|
49
|
+
node tools/verify-endpoint.mjs --base-url <url> --model <id> --api-key <key> [选项]
|
|
50
|
+
|
|
51
|
+
选项:
|
|
52
|
+
--provider <route> llm-pi-ai 中的 provider 路由名(从 settings.yaml 读取 baseURL/密钥)
|
|
53
|
+
--model <id> 要验证的模型 id(必填)
|
|
54
|
+
--base-url <url> 直接指定端点基址(覆盖 --provider 的读取结果)
|
|
55
|
+
--api-key <key> 直接指定密钥(覆盖凭据解析)
|
|
56
|
+
--api-key-env <name> 指定凭据环境变量名
|
|
57
|
+
--protocol <name> 协议,默认 openai-completions
|
|
58
|
+
--timeout <ms> 单次请求超时,默认 45000
|
|
59
|
+
--json 以 JSON 输出结果
|
|
60
|
+
-h, --help 显示本帮助
|
|
61
|
+
|
|
62
|
+
退出码: 0=端点接受图片 1=端点拒绝图片(400/422) 2=无法判定
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
/** Parse `--flag value` / `--flag=value` / boolean `--flag` argv. */
|
|
66
|
+
function parseArgs(argv) {
|
|
67
|
+
const args = { _: [] };
|
|
68
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
69
|
+
const token = argv[i];
|
|
70
|
+
if (!token.startsWith("--") && token !== "-h") {
|
|
71
|
+
args._.push(token);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const eq = token.indexOf("=");
|
|
75
|
+
if (eq > 0) {
|
|
76
|
+
args[token.slice(2, eq)] = token.slice(eq + 1);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const key = token === "-h" ? "help" : token.slice(2);
|
|
80
|
+
const next = argv[i + 1];
|
|
81
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
82
|
+
args[key] = next;
|
|
83
|
+
i += 1;
|
|
84
|
+
} else {
|
|
85
|
+
args[key] = true;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return args;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Absolute path of the DSH home directory. */
|
|
92
|
+
function dshHome() {
|
|
93
|
+
return process.env.DSH_HOME && process.env.DSH_HOME.length > 0
|
|
94
|
+
? process.env.DSH_HOME
|
|
95
|
+
: join(homedir(), ".dsh");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Extract one provider's `baseURL`, `apiKeyEnv` and `api` from settings.yaml.
|
|
100
|
+
*
|
|
101
|
+
* This is a deliberately narrow reader: it looks for the requested route under
|
|
102
|
+
* the `llm-pi-ai.providers` block and takes only flat scalar keys, so it never
|
|
103
|
+
* needs to understand the model lists. Anything it does not find stays
|
|
104
|
+
* undefined and the caller falls back to explicit flags.
|
|
105
|
+
*
|
|
106
|
+
* @returns {{baseURL?: string, apiKeyEnv?: string, api?: string}}
|
|
107
|
+
*/
|
|
108
|
+
function readProviderFromSettings(route) {
|
|
109
|
+
let text;
|
|
110
|
+
try {
|
|
111
|
+
text = readFileSync(join(dshHome(), "settings.yaml"), "utf8");
|
|
112
|
+
} catch {
|
|
113
|
+
return {};
|
|
114
|
+
}
|
|
115
|
+
const lines = text.split(/\r?\n/);
|
|
116
|
+
const found = {};
|
|
117
|
+
// Track indentation to find "<route>:" inside llm-pi-ai.providers.
|
|
118
|
+
let inPiAi = false;
|
|
119
|
+
let inProviders = false;
|
|
120
|
+
let inRoute = false;
|
|
121
|
+
let routeIndent = -1;
|
|
122
|
+
for (const line of lines) {
|
|
123
|
+
if (line.trim().length === 0 || line.trimStart().startsWith("#")) continue;
|
|
124
|
+
const indent = line.length - line.trimStart().length;
|
|
125
|
+
const trimmed = line.trim();
|
|
126
|
+
if (indent === 0) {
|
|
127
|
+
inPiAi = trimmed === "llm-pi-ai:";
|
|
128
|
+
inProviders = false;
|
|
129
|
+
inRoute = false;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (!inPiAi) continue;
|
|
133
|
+
if (!inProviders) {
|
|
134
|
+
inProviders = trimmed === "providers:";
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (!inRoute) {
|
|
138
|
+
if (trimmed === `${route}:`) {
|
|
139
|
+
inRoute = true;
|
|
140
|
+
routeIndent = indent;
|
|
141
|
+
}
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
// A key at or below the route's own indent ends the route block.
|
|
145
|
+
if (indent <= routeIndent) {
|
|
146
|
+
inRoute = false;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const match = /^(baseURL|apiKeyEnv|api):\s*(.*)$/.exec(trimmed);
|
|
150
|
+
if (match === null) continue;
|
|
151
|
+
let value = match[2].trim();
|
|
152
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
153
|
+
value = value.slice(1, -1);
|
|
154
|
+
}
|
|
155
|
+
if (value.length > 0) found[match[1]] = value;
|
|
156
|
+
}
|
|
157
|
+
return found;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Resolve a credential from the environment, then the DSH credentials file. */
|
|
161
|
+
function resolveApiKey(apiKeyEnv) {
|
|
162
|
+
if (apiKeyEnv === undefined) return undefined;
|
|
163
|
+
if (typeof process.env[apiKeyEnv] === "string" && process.env[apiKeyEnv].length > 0) {
|
|
164
|
+
return process.env[apiKeyEnv];
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const text = readFileSync(join(dshHome(), ".credentials.yaml"), "utf8");
|
|
168
|
+
// Minimal scan for "<ref>:" followed by a scalar value.
|
|
169
|
+
const lines = text.split(/\r?\n/);
|
|
170
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
171
|
+
const match = new RegExp(`^\\s*${apiKeyEnv.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}:\\s*(.*)$`).exec(lines[i]);
|
|
172
|
+
if (match === null) continue;
|
|
173
|
+
let value = match[1].trim();
|
|
174
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
175
|
+
value = value.slice(1, -1);
|
|
176
|
+
}
|
|
177
|
+
if (value.length > 0) return value;
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
// no credential store: the caller reports the miss
|
|
181
|
+
}
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Map a protocol name onto a chat-completions path, when supported. */
|
|
186
|
+
function completionPath(protocol) {
|
|
187
|
+
switch (protocol) {
|
|
188
|
+
case "openai-completions":
|
|
189
|
+
case "openai-chat-completions":
|
|
190
|
+
case undefined:
|
|
191
|
+
return "/chat/completions";
|
|
192
|
+
default:
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Extract the sentence a human should read from an endpoint response.
|
|
199
|
+
*
|
|
200
|
+
* Gateways frequently wrap the upstream error as a JSON *string* inside the
|
|
201
|
+
* message ("CodeBuddy 上游 HTTP 400:{"code":11128,"msg":"…"}"). Dumping that
|
|
202
|
+
* envelope buries the one useful sentence, so when the message contains JSON,
|
|
203
|
+
* pull out its `msg` (most precise) or localized `displayMsg` instead.
|
|
204
|
+
*
|
|
205
|
+
* @param {string} raw - response body, or an already-extracted message.
|
|
206
|
+
* @returns {string} a single-line, length-capped explanation.
|
|
207
|
+
*/
|
|
208
|
+
function friendlyDetail(raw) {
|
|
209
|
+
const text = String(raw).replace(/\s+/g, " ").trim();
|
|
210
|
+
const start = text.indexOf("{");
|
|
211
|
+
if (start >= 0) {
|
|
212
|
+
try {
|
|
213
|
+
const inner = JSON.parse(text.slice(start));
|
|
214
|
+
const pick = (value) => (typeof value === "string" && value.length > 0 ? value : undefined);
|
|
215
|
+
const chosen = pick(inner?.msg)
|
|
216
|
+
?? pick(inner?.displayMsg?.zh)
|
|
217
|
+
?? pick(inner?.displayMsg?.en)
|
|
218
|
+
?? pick(inner?.error?.message);
|
|
219
|
+
if (chosen !== undefined) {
|
|
220
|
+
const prefix = text.slice(0, start).replace(/[::]\s*$/, "").trim();
|
|
221
|
+
return (prefix.length > 0 ? `${prefix}:${chosen}` : chosen).slice(0, 300);
|
|
222
|
+
}
|
|
223
|
+
} catch {
|
|
224
|
+
// Not JSON, or truncated JSON: fall through to the raw text.
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return text.slice(0, 300);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Process exit code, recorded so the dispatcher can drain before exiting. */
|
|
231
|
+
let exitCode = 0;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Report the result. Deliberately does NOT call `process.exit()`: on Windows,
|
|
235
|
+
* terminating while undici is still tearing down the sockets of two sequential
|
|
236
|
+
* requests trips a libuv assertion (`UV_HANDLE_CLOSING`) and reports a garbage
|
|
237
|
+
* exit code. The code is recorded here and applied after the dispatcher closes.
|
|
238
|
+
*
|
|
239
|
+
* @param {number} code - intended process exit code.
|
|
240
|
+
*/
|
|
241
|
+
function finish(code, result, asJson) {
|
|
242
|
+
if (asJson) {
|
|
243
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
244
|
+
} else {
|
|
245
|
+
const mark = result.verdict === "supported"
|
|
246
|
+
? "✓"
|
|
247
|
+
: result.verdict === "rejected"
|
|
248
|
+
? "✕"
|
|
249
|
+
: result.verdict === "inconclusive"
|
|
250
|
+
? "?"
|
|
251
|
+
: "!";
|
|
252
|
+
process.stdout.write(`${mark} ${result.message}\n`);
|
|
253
|
+
if (result.url !== undefined) process.stdout.write(` 请求地址: ${result.url}\n`);
|
|
254
|
+
if (result.model !== undefined) process.stdout.write(` 模型: ${result.model}\n`);
|
|
255
|
+
if (result.detail !== undefined) process.stdout.write(` 端点响应: ${result.detail}\n`);
|
|
256
|
+
}
|
|
257
|
+
exitCode = code;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Entry point. */
|
|
261
|
+
async function main() {
|
|
262
|
+
const args = parseArgs(process.argv.slice(2));
|
|
263
|
+
const asJson = args.json === true;
|
|
264
|
+
if (args.help === true || args.model === undefined) {
|
|
265
|
+
process.stdout.write(USAGE);
|
|
266
|
+
process.exit(args.help === true ? 0 : 2);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const model = String(args.model);
|
|
270
|
+
const route = args.provider === undefined ? undefined : String(args.provider);
|
|
271
|
+
const fromSettings = route === undefined ? {} : readProviderFromSettings(route);
|
|
272
|
+
|
|
273
|
+
const baseURL = args["base-url"] !== undefined ? String(args["base-url"]) : fromSettings.baseURL;
|
|
274
|
+
const protocol = args.protocol !== undefined
|
|
275
|
+
? String(args.protocol)
|
|
276
|
+
: fromSettings.api ?? "openai-completions";
|
|
277
|
+
const apiKeyEnv = args["api-key-env"] !== undefined ? String(args["api-key-env"]) : fromSettings.apiKeyEnv;
|
|
278
|
+
const apiKey = args["api-key"] !== undefined ? String(args["api-key"]) : resolveApiKey(apiKeyEnv);
|
|
279
|
+
const timeoutMs = args.timeout === undefined ? 45000 : Number(args.timeout);
|
|
280
|
+
|
|
281
|
+
if (baseURL === undefined || baseURL.length === 0) {
|
|
282
|
+
finish(2, {
|
|
283
|
+
ok: false,
|
|
284
|
+
verdict: "no-base-url",
|
|
285
|
+
model,
|
|
286
|
+
message: route === undefined
|
|
287
|
+
? "缺少 --base-url,且未指定可读取配置的 --provider。"
|
|
288
|
+
: `provider "${route}" 在 settings.yaml 中没有 baseURL,请用 --base-url 指定。`
|
|
289
|
+
}, asJson);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const path = completionPath(protocol);
|
|
294
|
+
if (path === undefined) {
|
|
295
|
+
finish(2, {
|
|
296
|
+
ok: false,
|
|
297
|
+
verdict: "unsupported-protocol",
|
|
298
|
+
model,
|
|
299
|
+
message: `协议 "${protocol}" 不支持本工具的探测(仅支持 openai-completions)。`
|
|
300
|
+
}, asJson);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (apiKey === undefined || apiKey.length === 0) {
|
|
305
|
+
finish(2, {
|
|
306
|
+
ok: false,
|
|
307
|
+
verdict: "no-credential",
|
|
308
|
+
model,
|
|
309
|
+
message: apiKeyEnv === undefined
|
|
310
|
+
? "未提供 --api-key,且该 provider 没有配置 apiKeyEnv。"
|
|
311
|
+
: `未能解析凭据 "${apiKeyEnv}":请设置该环境变量或用 --api-key 指定。`
|
|
312
|
+
}, asJson);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const url = `${baseURL.replace(/\/+$/, "")}${path}`;
|
|
317
|
+
// Lead with a system message: some gateways (the CodeBuddy proxy behind this
|
|
318
|
+
// profile's jiuling route, for one) reject any request whose first message is
|
|
319
|
+
// not a system prompt — before they ever inspect the image. Without this the
|
|
320
|
+
// probe measures the gateway's envelope rules instead of image support.
|
|
321
|
+
const SYS = { role: "system", content: "You are a helpful assistant." };
|
|
322
|
+
const body = {
|
|
323
|
+
model,
|
|
324
|
+
max_tokens: 16,
|
|
325
|
+
messages: [
|
|
326
|
+
SYS,
|
|
327
|
+
{
|
|
328
|
+
role: "user",
|
|
329
|
+
content: [
|
|
330
|
+
{ type: "text", text: "Reply with the single word: ok" },
|
|
331
|
+
{ type: "image_url", image_url: { url: `data:image/png;base64,${PROBE_PNG_B64}` } }
|
|
332
|
+
]
|
|
333
|
+
}
|
|
334
|
+
]
|
|
335
|
+
};
|
|
336
|
+
// Identical control request with no image, used to tell an image-specific
|
|
337
|
+
// rejection apart from a request the endpoint would have refused anyway.
|
|
338
|
+
const controlBody = {
|
|
339
|
+
model,
|
|
340
|
+
max_tokens: 16,
|
|
341
|
+
messages: [SYS, { role: "user", content: "Reply with the single word: ok" }]
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const controller = new AbortController();
|
|
345
|
+
const timer = setTimeout(() => controller.abort(), Number.isFinite(timeoutMs) ? timeoutMs : 45000);
|
|
346
|
+
try {
|
|
347
|
+
const attempt = async (payload) => {
|
|
348
|
+
const response = await fetch(url, {
|
|
349
|
+
method: "POST",
|
|
350
|
+
headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
|
|
351
|
+
body: JSON.stringify(payload),
|
|
352
|
+
signal: controller.signal
|
|
353
|
+
});
|
|
354
|
+
return { status: response.status, ok: response.ok, text: await response.text() };
|
|
355
|
+
};
|
|
356
|
+
// Gateways nest the useful sentence inside wrapped JSON; collapse
|
|
357
|
+
// whitespace and cap it so the output stays readable.
|
|
358
|
+
// Unwrap the outer envelope first, then the JSON string gateways often
|
|
359
|
+
// nest inside `error.message`, so the reported detail is one readable
|
|
360
|
+
// sentence rather than a wall of escaped JSON.
|
|
361
|
+
const detailOf = (text) => {
|
|
362
|
+
let parsed;
|
|
363
|
+
try {
|
|
364
|
+
parsed = JSON.parse(text);
|
|
365
|
+
} catch {
|
|
366
|
+
parsed = undefined;
|
|
367
|
+
}
|
|
368
|
+
const message = typeof parsed?.error?.message === "string" ? parsed.error.message : text;
|
|
369
|
+
return friendlyDetail(message);
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
const probe = await attempt(body);
|
|
373
|
+
const detail = detailOf(probe.text);
|
|
374
|
+
|
|
375
|
+
if (probe.ok) {
|
|
376
|
+
finish(0, {
|
|
377
|
+
ok: true,
|
|
378
|
+
status: probe.status,
|
|
379
|
+
verdict: "supported",
|
|
380
|
+
model,
|
|
381
|
+
url,
|
|
382
|
+
message: `端点接受了带图片的请求(HTTP ${probe.status}):该模型确实支持视觉输入。`
|
|
383
|
+
}, asJson);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (probe.status === 401 || probe.status === 403) {
|
|
387
|
+
finish(2, {
|
|
388
|
+
ok: false,
|
|
389
|
+
status: probe.status,
|
|
390
|
+
verdict: "unauthorized",
|
|
391
|
+
model,
|
|
392
|
+
url,
|
|
393
|
+
detail,
|
|
394
|
+
message: `凭据被拒绝(HTTP ${probe.status}):请检查 API 密钥。`
|
|
395
|
+
}, asJson);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (probe.status === 404) {
|
|
399
|
+
finish(2, {
|
|
400
|
+
ok: false,
|
|
401
|
+
status: probe.status,
|
|
402
|
+
verdict: "not-found",
|
|
403
|
+
model,
|
|
404
|
+
url,
|
|
405
|
+
detail,
|
|
406
|
+
message: `端点不存在(HTTP 404):请检查 baseURL 与协议。`
|
|
407
|
+
}, asJson);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Non-200: re-send without the image to find out whether the image is
|
|
412
|
+
// actually what failed. Only an image-specific failure is reported as
|
|
413
|
+
// "does not support images"; anything else is inconclusive, not a verdict.
|
|
414
|
+
let control = null;
|
|
415
|
+
try {
|
|
416
|
+
control = await attempt(controlBody);
|
|
417
|
+
} catch {
|
|
418
|
+
control = null;
|
|
419
|
+
}
|
|
420
|
+
if (control !== null && control.ok) {
|
|
421
|
+
finish(1, {
|
|
422
|
+
ok: true,
|
|
423
|
+
status: probe.status,
|
|
424
|
+
verdict: "rejected",
|
|
425
|
+
model,
|
|
426
|
+
url,
|
|
427
|
+
detail,
|
|
428
|
+
message: `端点拒绝了图片输入(HTTP ${probe.status}),而同一请求去掉图片后返回 HTTP 200:`
|
|
429
|
+
+ `该模型确实不支持视觉输入,建议取消勾选。`
|
|
430
|
+
}, asJson);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const controlNote = control === null
|
|
434
|
+
? "去掉图片的对照请求也未能完成"
|
|
435
|
+
: `去掉图片的同一请求仍然返回 HTTP ${control.status}`;
|
|
436
|
+
finish(2, {
|
|
437
|
+
ok: false,
|
|
438
|
+
status: probe.status,
|
|
439
|
+
verdict: "inconclusive",
|
|
440
|
+
model,
|
|
441
|
+
url,
|
|
442
|
+
detail,
|
|
443
|
+
message: `端点返回 HTTP ${probe.status},但这与图片无关:${controlNote},`
|
|
444
|
+
+ "说明该端点拒绝了这次请求本身(模型未开通、网关策略、参数不符等)。"
|
|
445
|
+
+ "请先确认该模型可正常对话,再验证图片能力。"
|
|
446
|
+
}, asJson);
|
|
447
|
+
} catch (error) {
|
|
448
|
+
const aborted = error?.name === "AbortError";
|
|
449
|
+
finish(2, {
|
|
450
|
+
ok: false,
|
|
451
|
+
status: -1,
|
|
452
|
+
verdict: aborted ? "timeout" : "network",
|
|
453
|
+
model,
|
|
454
|
+
url,
|
|
455
|
+
message: aborted
|
|
456
|
+
? `验证超时(${(Number.isFinite(timeoutMs) ? timeoutMs : 45000) / 1000} 秒)。`
|
|
457
|
+
: `无法连接端点:${String(error?.message ?? error)}`
|
|
458
|
+
}, asJson);
|
|
459
|
+
} finally {
|
|
460
|
+
clearTimeout(timer);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
await main();
|
|
465
|
+
|
|
466
|
+
// Close undici's dispatcher before letting the process end. Without this,
|
|
467
|
+
// exiting while its sockets are still being torn down trips a libuv assertion
|
|
468
|
+
// on Windows (`!(handle->flags & UV_HANDLE_CLOSING)`) and the shell sees a
|
|
469
|
+
// garbage exit code such as -1073740791 instead of the intended one.
|
|
470
|
+
try {
|
|
471
|
+
const dispatcher = globalThis[Symbol.for("undici.globalDispatcher.1")];
|
|
472
|
+
if (dispatcher !== undefined && typeof dispatcher.close === "function") {
|
|
473
|
+
await dispatcher.close();
|
|
474
|
+
}
|
|
475
|
+
} catch {
|
|
476
|
+
// A dispatcher that refuses to close must not change the reported verdict.
|
|
477
|
+
}
|
|
478
|
+
process.exitCode = exitCode;
|