dsh-tool-vision 0.9.0 → 0.9.2
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/LICENSE +23 -23
- package/README.md +58 -0
- package/README.zh.md +326 -278
- package/index.js +1453 -1332
- package/lib/model-probe.js +274 -274
- package/lib/session-header.js +57 -57
- package/lib/vision-tools.js +2485 -2485
- package/package.json +17 -5
package/lib/model-probe.js
CHANGED
|
@@ -1,274 +1,274 @@
|
|
|
1
|
-
// dsh-tool-vision — ground-truth multimodal probing.
|
|
2
|
-
//
|
|
3
|
-
// Everything else in this plugin decides whether a route can see images from
|
|
4
|
-
// what the route SAYS about itself (`inputModalities`, and the `multimodalModels`
|
|
5
|
-
// list a human maintains). A profile can claim `input: [text, image]` just to
|
|
6
|
-
// pass the host's admission gate, and the upstream comment in
|
|
7
|
-
// `dsh-llm-pi-ai` explains why that matters: over-claiming admits an image the
|
|
8
|
-
// provider rejects mid-turn, *after the message is already durable*.
|
|
9
|
-
//
|
|
10
|
-
// This module answers the question empirically instead: it sends a real image
|
|
11
|
-
// to the route's own endpoint and checks whether the model can actually read
|
|
12
|
-
// it. That makes it the only signal here that is not a declaration.
|
|
13
|
-
//
|
|
14
|
-
// Two design rules, both learned the hard way:
|
|
15
|
-
//
|
|
16
|
-
// 1. Never trust a single request. A model that cannot see will happily
|
|
17
|
-
// answer "blue" to an orange square, and a reasoning model will return an
|
|
18
|
-
// EMPTY `content` when `max_tokens` is too small to finish thinking. So
|
|
19
|
-
// every probe runs a text-only CONTROL first (is this route even usable?)
|
|
20
|
-
// and then TWO different colors, and only a model that gets both right is
|
|
21
|
-
// reported as supporting images.
|
|
22
|
-
// 2. Never turn an infrastructure failure into a capability verdict. A 429,
|
|
23
|
-
// a timeout or a dead gateway is `unknown`, not "cannot see images".
|
|
24
|
-
//
|
|
25
|
-
// `verdict` is therefore one of:
|
|
26
|
-
// - "yes" — the control passed and both colors were read correctly.
|
|
27
|
-
// - "no" — the endpoint rejected the image part, or answered without
|
|
28
|
-
// reading it (both colors wrong).
|
|
29
|
-
// - "unknown" — the route could not be probed (network, auth, protocol).
|
|
30
|
-
|
|
31
|
-
import zlib from "node:zlib";
|
|
32
|
-
|
|
33
|
-
/** Probe image edge length; tiny on purpose — the query is one flat color. */
|
|
34
|
-
export const PROBE_IMAGE_SIZE = 32;
|
|
35
|
-
/**
|
|
36
|
-
* Output budget for each probe request. Reasoning models spend this before
|
|
37
|
-
* emitting any `content`, and an exhausted budget yields an empty answer that
|
|
38
|
-
* would otherwise be misread as "cannot see the image".
|
|
39
|
-
*/
|
|
40
|
-
export const PROBE_MAX_TOKENS = 2048;
|
|
41
|
-
/** Per-request timeout for a probe (a probe is interactive; it must not hang). */
|
|
42
|
-
export const PROBE_TIMEOUT_MS = 60000;
|
|
43
|
-
/** Largest response body accepted, so a hostile endpoint cannot exhaust memory. */
|
|
44
|
-
export const PROBE_MAX_BODY_BYTES = 1024 * 1024;
|
|
45
|
-
|
|
46
|
-
/** The two flat colors a probe distinguishes; `accept` is matched against the answer. */
|
|
47
|
-
export const PROBE_COLORS = {
|
|
48
|
-
orange: { rgb: [230, 120, 30], accept: /\borange\b/i },
|
|
49
|
-
blue: { rgb: [30, 90, 230], accept: /\bblue\b/i },
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
/** The question asked for every image probe. One flat color, one word back. */
|
|
53
|
-
export const PROBE_QUESTION = "What color is this image? Answer with one word.";
|
|
54
|
-
|
|
55
|
-
/** Text-only control question; expected answer is compared case-insensitively. */
|
|
56
|
-
export const PROBE_CONTROL_QUESTION = "Reply with the single word: ok";
|
|
57
|
-
export const PROBE_CONTROL_ACCEPT = /\bok\b/i;
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Endpoint errors that mean "this model does not take image parts" rather than
|
|
61
|
-
* "something went wrong just now". Only these turn into a negative verdict.
|
|
62
|
-
*/
|
|
63
|
-
const UNSUPPORTED_IMAGE_RE =
|
|
64
|
-
/does not support image|image input|unsupported content|unsupported_content|invalid content type|image_url|not a vision|vision.{0,12}not support|multimodal.{0,12}not/i;
|
|
65
|
-
|
|
66
|
-
// ── a minimal PNG encoder (no dependencies) ─────────────────────────────────
|
|
67
|
-
|
|
68
|
-
function crc32(buffer) {
|
|
69
|
-
let crc = 0xffffffff;
|
|
70
|
-
for (let index = 0; index < buffer.length; index += 1) {
|
|
71
|
-
crc ^= buffer[index];
|
|
72
|
-
for (let bit = 0; bit < 8; bit += 1) crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
|
|
73
|
-
}
|
|
74
|
-
return (crc ^ 0xffffffff) >>> 0;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function pngChunk(type, data) {
|
|
78
|
-
const length = Buffer.alloc(4);
|
|
79
|
-
length.writeUInt32BE(data.length);
|
|
80
|
-
const tag = Buffer.from(type, "ascii");
|
|
81
|
-
const crc = Buffer.alloc(4);
|
|
82
|
-
crc.writeUInt32BE(crc32(Buffer.concat([tag, data])));
|
|
83
|
-
return Buffer.concat([length, tag, data, crc]);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Encode a solid-color RGB PNG. Hand-rolled so the plugin keeps zero image
|
|
88
|
-
* dependencies on the server half.
|
|
89
|
-
*
|
|
90
|
-
* @param {readonly [number, number, number]} rgb - the fill color.
|
|
91
|
-
* @param {number} [size] - edge length in pixels.
|
|
92
|
-
* @returns {Buffer} PNG bytes.
|
|
93
|
-
*/
|
|
94
|
-
export function makeSolidPng(rgb, size = PROBE_IMAGE_SIZE) {
|
|
95
|
-
const [red, green, blue] = rgb;
|
|
96
|
-
const stride = size * 3 + 1;
|
|
97
|
-
const raw = Buffer.alloc(stride * size);
|
|
98
|
-
for (let y = 0; y < size; y += 1) {
|
|
99
|
-
for (let x = 0; x < size; x += 1) {
|
|
100
|
-
const offset = y * stride + 1 + x * 3;
|
|
101
|
-
raw[offset] = red;
|
|
102
|
-
raw[offset + 1] = green;
|
|
103
|
-
raw[offset + 2] = blue;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
const header = Buffer.alloc(13);
|
|
107
|
-
header.writeUInt32BE(size, 0);
|
|
108
|
-
header.writeUInt32BE(size, 4);
|
|
109
|
-
header[8] = 8; // bit depth
|
|
110
|
-
header[9] = 2; // truecolor
|
|
111
|
-
return Buffer.concat([
|
|
112
|
-
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
113
|
-
pngChunk("IHDR", header),
|
|
114
|
-
pngChunk("IDAT", zlib.deflateSync(raw)),
|
|
115
|
-
pngChunk("IEND", Buffer.alloc(0)),
|
|
116
|
-
]);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** A `data:` URL for one solid color. */
|
|
120
|
-
export function solidColorDataUrl(rgb, size = PROBE_IMAGE_SIZE) {
|
|
121
|
-
return `data:image/png;base64,${makeSolidPng(rgb, size).toString("base64")}`;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// ── the call itself ─────────────────────────────────────────────────────────
|
|
125
|
-
|
|
126
|
-
/** Pull the answer text out of an OpenAI-compatible response body. */
|
|
127
|
-
function readAnswer(body) {
|
|
128
|
-
const message = body?.choices?.[0]?.message;
|
|
129
|
-
const content = typeof message?.content === "string" ? message.content.trim() : "";
|
|
130
|
-
if (content) return content;
|
|
131
|
-
// A reasoning model may leave `content` empty and put everything in
|
|
132
|
-
// `reasoning_content`; either way the text is what we grade.
|
|
133
|
-
const reasoning = typeof message?.reasoning_content === "string" ? message.reasoning_content.trim() : "";
|
|
134
|
-
return reasoning;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* One `chat/completions` request against a route's own endpoint.
|
|
139
|
-
*
|
|
140
|
-
* @returns {Promise<{ok: true, answer: string} | {ok: false, status: number, message: string}>}
|
|
141
|
-
* `ok: false` with a status of 0 for a transport failure.
|
|
142
|
-
*/
|
|
143
|
-
async function probeRequest(target, content, fetchImpl, signal) {
|
|
144
|
-
const base = target.baseURL.endsWith("/") ? target.baseURL : `${target.baseURL}/`;
|
|
145
|
-
const endpoint = new URL("chat/completions", base);
|
|
146
|
-
const controller = new AbortController();
|
|
147
|
-
const timer = setTimeout(() => controller.abort(new Error("probe request timed out")), target.timeoutMs);
|
|
148
|
-
const onAbort = () => controller.abort(signal?.reason);
|
|
149
|
-
if (signal) {
|
|
150
|
-
if (signal.aborted) controller.abort(signal.reason);
|
|
151
|
-
else signal.addEventListener("abort", onAbort, { once: true });
|
|
152
|
-
}
|
|
153
|
-
try {
|
|
154
|
-
const response = await fetchImpl(endpoint, {
|
|
155
|
-
method: "POST",
|
|
156
|
-
headers: {
|
|
157
|
-
"Content-Type": "application/json",
|
|
158
|
-
...(target.apiKey ? { Authorization: `Bearer ${target.apiKey}` } : {}),
|
|
159
|
-
...(target.headers ?? {}),
|
|
160
|
-
},
|
|
161
|
-
body: JSON.stringify({
|
|
162
|
-
model: target.model,
|
|
163
|
-
messages: [{ role: "user", content }],
|
|
164
|
-
max_tokens: target.maxTokens,
|
|
165
|
-
}),
|
|
166
|
-
signal: controller.signal,
|
|
167
|
-
});
|
|
168
|
-
const text = await response.text();
|
|
169
|
-
if (text.length > PROBE_MAX_BODY_BYTES) {
|
|
170
|
-
return { ok: false, status: response.status, message: "probe response body too large" };
|
|
171
|
-
}
|
|
172
|
-
let body = null;
|
|
173
|
-
try {
|
|
174
|
-
body = JSON.parse(text);
|
|
175
|
-
} catch {
|
|
176
|
-
/* non-JSON error page */
|
|
177
|
-
}
|
|
178
|
-
if (!response.ok) {
|
|
179
|
-
return {
|
|
180
|
-
ok: false,
|
|
181
|
-
status: response.status,
|
|
182
|
-
message: String(body?.error?.message ?? body?.message ?? response.statusText ?? text.slice(0, 200)),
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
return { ok: true, answer: readAnswer(body) };
|
|
186
|
-
} catch (error) {
|
|
187
|
-
return { ok: false, status: 0, message: String(error?.message ?? error) };
|
|
188
|
-
} finally {
|
|
189
|
-
clearTimeout(timer);
|
|
190
|
-
signal?.removeEventListener("abort", onAbort);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Probe one route for real image support.
|
|
196
|
-
*
|
|
197
|
-
* Control first, then two colors. BOTH colors must be read correctly, so a
|
|
198
|
-
* model that guesses cannot pass: the two questions are identical and only the
|
|
199
|
-
* pixels differ.
|
|
200
|
-
*
|
|
201
|
-
* @param {object} target - `{baseURL, apiKey, model, api, timeoutMs, maxTokens, fetchImpl, headers}`.
|
|
202
|
-
* @param {AbortSignal} [signal] - caller cancellation.
|
|
203
|
-
* @returns {Promise<{support: 'yes'|'no'|'unknown', detail: string, evidence: object}>}
|
|
204
|
-
*/
|
|
205
|
-
export async function probeModelCapability(target, signal) {
|
|
206
|
-
const fetchImpl = target.fetchImpl ?? fetch;
|
|
207
|
-
const api = target.api;
|
|
208
|
-
if (api !== undefined && api !== null && api !== "openai-completions") {
|
|
209
|
-
// `openai-responses` and friends speak a different wire format; probing
|
|
210
|
-
// them with chat/completions would produce a confident wrong answer.
|
|
211
|
-
return {
|
|
212
|
-
support: "unknown",
|
|
213
|
-
detail: `route protocol "${api}" is not probeable with chat/completions`,
|
|
214
|
-
evidence: {},
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
if (typeof target.baseURL !== "string" || target.baseURL.length === 0) {
|
|
218
|
-
return { support: "unknown", detail: "the route has no baseURL", evidence: {} };
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const control = await probeRequest(target, PROBE_CONTROL_QUESTION, fetchImpl, signal);
|
|
222
|
-
const controlAnswer = control.ok ? control.answer : "";
|
|
223
|
-
const controlPassed = control.ok && PROBE_CONTROL_ACCEPT.test(controlAnswer);
|
|
224
|
-
if (!controlPassed) {
|
|
225
|
-
// The route itself is unusable (bad key, dead gateway, empty answer at a
|
|
226
|
-
// workable token budget). That says nothing about image support.
|
|
227
|
-
return {
|
|
228
|
-
support: "unknown",
|
|
229
|
-
detail: control.ok
|
|
230
|
-
? `control request returned an unusable answer (${controlAnswer.slice(0, 60) || "empty"})`
|
|
231
|
-
: `control request failed with ${control.status || "a transport error"}: ${control.message}`,
|
|
232
|
-
evidence: { control: control.ok ? controlAnswer.slice(0, 120) : control.message },
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
const answers = {};
|
|
237
|
-
for (const [name, spec] of Object.entries(PROBE_COLORS)) {
|
|
238
|
-
const result = await probeRequest(target, [
|
|
239
|
-
{ type: "text", text: PROBE_QUESTION },
|
|
240
|
-
{ type: "image_url", image_url: { url: solidColorDataUrl(spec.rgb) } },
|
|
241
|
-
], fetchImpl, signal);
|
|
242
|
-
if (!result.ok) {
|
|
243
|
-
if (UNSUPPORTED_IMAGE_RE.test(result.message)) {
|
|
244
|
-
return {
|
|
245
|
-
support: "no",
|
|
246
|
-
detail: `the endpoint rejected the image part (${result.status}): ${result.message}`,
|
|
247
|
-
evidence: { control: controlAnswer.slice(0, 120), answers },
|
|
248
|
-
};
|
|
249
|
-
}
|
|
250
|
-
return {
|
|
251
|
-
support: "unknown",
|
|
252
|
-
detail: `image request failed with ${result.status || "a transport error"}: ${result.message}`,
|
|
253
|
-
evidence: { control: controlAnswer.slice(0, 120), answers },
|
|
254
|
-
};
|
|
255
|
-
}
|
|
256
|
-
answers[name] = result.answer.slice(0, 120);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
const correct = Object.entries(PROBE_COLORS).filter(([name, spec]) => spec.accept.test(answers[name] ?? ""));
|
|
260
|
-
if (correct.length === Object.keys(PROBE_COLORS).length) {
|
|
261
|
-
return {
|
|
262
|
-
support: "yes",
|
|
263
|
-
detail: `read both probe colors correctly (${Object.keys(PROBE_COLORS).join(", ")})`,
|
|
264
|
-
evidence: { control: controlAnswer.slice(0, 120), answers },
|
|
265
|
-
};
|
|
266
|
-
}
|
|
267
|
-
return {
|
|
268
|
-
support: "no",
|
|
269
|
-
detail: correct.length === 0
|
|
270
|
-
? "the endpoint answered both image requests without identifying either color"
|
|
271
|
-
: `the endpoint identified only ${correct.length}/${Object.keys(PROBE_COLORS).length} probe colors`,
|
|
272
|
-
evidence: { control: controlAnswer.slice(0, 120), answers },
|
|
273
|
-
};
|
|
274
|
-
}
|
|
1
|
+
// dsh-tool-vision — ground-truth multimodal probing.
|
|
2
|
+
//
|
|
3
|
+
// Everything else in this plugin decides whether a route can see images from
|
|
4
|
+
// what the route SAYS about itself (`inputModalities`, and the `multimodalModels`
|
|
5
|
+
// list a human maintains). A profile can claim `input: [text, image]` just to
|
|
6
|
+
// pass the host's admission gate, and the upstream comment in
|
|
7
|
+
// `dsh-llm-pi-ai` explains why that matters: over-claiming admits an image the
|
|
8
|
+
// provider rejects mid-turn, *after the message is already durable*.
|
|
9
|
+
//
|
|
10
|
+
// This module answers the question empirically instead: it sends a real image
|
|
11
|
+
// to the route's own endpoint and checks whether the model can actually read
|
|
12
|
+
// it. That makes it the only signal here that is not a declaration.
|
|
13
|
+
//
|
|
14
|
+
// Two design rules, both learned the hard way:
|
|
15
|
+
//
|
|
16
|
+
// 1. Never trust a single request. A model that cannot see will happily
|
|
17
|
+
// answer "blue" to an orange square, and a reasoning model will return an
|
|
18
|
+
// EMPTY `content` when `max_tokens` is too small to finish thinking. So
|
|
19
|
+
// every probe runs a text-only CONTROL first (is this route even usable?)
|
|
20
|
+
// and then TWO different colors, and only a model that gets both right is
|
|
21
|
+
// reported as supporting images.
|
|
22
|
+
// 2. Never turn an infrastructure failure into a capability verdict. A 429,
|
|
23
|
+
// a timeout or a dead gateway is `unknown`, not "cannot see images".
|
|
24
|
+
//
|
|
25
|
+
// `verdict` is therefore one of:
|
|
26
|
+
// - "yes" — the control passed and both colors were read correctly.
|
|
27
|
+
// - "no" — the endpoint rejected the image part, or answered without
|
|
28
|
+
// reading it (both colors wrong).
|
|
29
|
+
// - "unknown" — the route could not be probed (network, auth, protocol).
|
|
30
|
+
|
|
31
|
+
import zlib from "node:zlib";
|
|
32
|
+
|
|
33
|
+
/** Probe image edge length; tiny on purpose — the query is one flat color. */
|
|
34
|
+
export const PROBE_IMAGE_SIZE = 32;
|
|
35
|
+
/**
|
|
36
|
+
* Output budget for each probe request. Reasoning models spend this before
|
|
37
|
+
* emitting any `content`, and an exhausted budget yields an empty answer that
|
|
38
|
+
* would otherwise be misread as "cannot see the image".
|
|
39
|
+
*/
|
|
40
|
+
export const PROBE_MAX_TOKENS = 2048;
|
|
41
|
+
/** Per-request timeout for a probe (a probe is interactive; it must not hang). */
|
|
42
|
+
export const PROBE_TIMEOUT_MS = 60000;
|
|
43
|
+
/** Largest response body accepted, so a hostile endpoint cannot exhaust memory. */
|
|
44
|
+
export const PROBE_MAX_BODY_BYTES = 1024 * 1024;
|
|
45
|
+
|
|
46
|
+
/** The two flat colors a probe distinguishes; `accept` is matched against the answer. */
|
|
47
|
+
export const PROBE_COLORS = {
|
|
48
|
+
orange: { rgb: [230, 120, 30], accept: /\borange\b/i },
|
|
49
|
+
blue: { rgb: [30, 90, 230], accept: /\bblue\b/i },
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** The question asked for every image probe. One flat color, one word back. */
|
|
53
|
+
export const PROBE_QUESTION = "What color is this image? Answer with one word.";
|
|
54
|
+
|
|
55
|
+
/** Text-only control question; expected answer is compared case-insensitively. */
|
|
56
|
+
export const PROBE_CONTROL_QUESTION = "Reply with the single word: ok";
|
|
57
|
+
export const PROBE_CONTROL_ACCEPT = /\bok\b/i;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Endpoint errors that mean "this model does not take image parts" rather than
|
|
61
|
+
* "something went wrong just now". Only these turn into a negative verdict.
|
|
62
|
+
*/
|
|
63
|
+
const UNSUPPORTED_IMAGE_RE =
|
|
64
|
+
/does not support image|image input|unsupported content|unsupported_content|invalid content type|image_url|not a vision|vision.{0,12}not support|multimodal.{0,12}not/i;
|
|
65
|
+
|
|
66
|
+
// ── a minimal PNG encoder (no dependencies) ─────────────────────────────────
|
|
67
|
+
|
|
68
|
+
function crc32(buffer) {
|
|
69
|
+
let crc = 0xffffffff;
|
|
70
|
+
for (let index = 0; index < buffer.length; index += 1) {
|
|
71
|
+
crc ^= buffer[index];
|
|
72
|
+
for (let bit = 0; bit < 8; bit += 1) crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
|
|
73
|
+
}
|
|
74
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function pngChunk(type, data) {
|
|
78
|
+
const length = Buffer.alloc(4);
|
|
79
|
+
length.writeUInt32BE(data.length);
|
|
80
|
+
const tag = Buffer.from(type, "ascii");
|
|
81
|
+
const crc = Buffer.alloc(4);
|
|
82
|
+
crc.writeUInt32BE(crc32(Buffer.concat([tag, data])));
|
|
83
|
+
return Buffer.concat([length, tag, data, crc]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Encode a solid-color RGB PNG. Hand-rolled so the plugin keeps zero image
|
|
88
|
+
* dependencies on the server half.
|
|
89
|
+
*
|
|
90
|
+
* @param {readonly [number, number, number]} rgb - the fill color.
|
|
91
|
+
* @param {number} [size] - edge length in pixels.
|
|
92
|
+
* @returns {Buffer} PNG bytes.
|
|
93
|
+
*/
|
|
94
|
+
export function makeSolidPng(rgb, size = PROBE_IMAGE_SIZE) {
|
|
95
|
+
const [red, green, blue] = rgb;
|
|
96
|
+
const stride = size * 3 + 1;
|
|
97
|
+
const raw = Buffer.alloc(stride * size);
|
|
98
|
+
for (let y = 0; y < size; y += 1) {
|
|
99
|
+
for (let x = 0; x < size; x += 1) {
|
|
100
|
+
const offset = y * stride + 1 + x * 3;
|
|
101
|
+
raw[offset] = red;
|
|
102
|
+
raw[offset + 1] = green;
|
|
103
|
+
raw[offset + 2] = blue;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const header = Buffer.alloc(13);
|
|
107
|
+
header.writeUInt32BE(size, 0);
|
|
108
|
+
header.writeUInt32BE(size, 4);
|
|
109
|
+
header[8] = 8; // bit depth
|
|
110
|
+
header[9] = 2; // truecolor
|
|
111
|
+
return Buffer.concat([
|
|
112
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
113
|
+
pngChunk("IHDR", header),
|
|
114
|
+
pngChunk("IDAT", zlib.deflateSync(raw)),
|
|
115
|
+
pngChunk("IEND", Buffer.alloc(0)),
|
|
116
|
+
]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** A `data:` URL for one solid color. */
|
|
120
|
+
export function solidColorDataUrl(rgb, size = PROBE_IMAGE_SIZE) {
|
|
121
|
+
return `data:image/png;base64,${makeSolidPng(rgb, size).toString("base64")}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── the call itself ─────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
/** Pull the answer text out of an OpenAI-compatible response body. */
|
|
127
|
+
function readAnswer(body) {
|
|
128
|
+
const message = body?.choices?.[0]?.message;
|
|
129
|
+
const content = typeof message?.content === "string" ? message.content.trim() : "";
|
|
130
|
+
if (content) return content;
|
|
131
|
+
// A reasoning model may leave `content` empty and put everything in
|
|
132
|
+
// `reasoning_content`; either way the text is what we grade.
|
|
133
|
+
const reasoning = typeof message?.reasoning_content === "string" ? message.reasoning_content.trim() : "";
|
|
134
|
+
return reasoning;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* One `chat/completions` request against a route's own endpoint.
|
|
139
|
+
*
|
|
140
|
+
* @returns {Promise<{ok: true, answer: string} | {ok: false, status: number, message: string}>}
|
|
141
|
+
* `ok: false` with a status of 0 for a transport failure.
|
|
142
|
+
*/
|
|
143
|
+
async function probeRequest(target, content, fetchImpl, signal) {
|
|
144
|
+
const base = target.baseURL.endsWith("/") ? target.baseURL : `${target.baseURL}/`;
|
|
145
|
+
const endpoint = new URL("chat/completions", base);
|
|
146
|
+
const controller = new AbortController();
|
|
147
|
+
const timer = setTimeout(() => controller.abort(new Error("probe request timed out")), target.timeoutMs);
|
|
148
|
+
const onAbort = () => controller.abort(signal?.reason);
|
|
149
|
+
if (signal) {
|
|
150
|
+
if (signal.aborted) controller.abort(signal.reason);
|
|
151
|
+
else signal.addEventListener("abort", onAbort, { once: true });
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
const response = await fetchImpl(endpoint, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
headers: {
|
|
157
|
+
"Content-Type": "application/json",
|
|
158
|
+
...(target.apiKey ? { Authorization: `Bearer ${target.apiKey}` } : {}),
|
|
159
|
+
...(target.headers ?? {}),
|
|
160
|
+
},
|
|
161
|
+
body: JSON.stringify({
|
|
162
|
+
model: target.model,
|
|
163
|
+
messages: [{ role: "user", content }],
|
|
164
|
+
max_tokens: target.maxTokens,
|
|
165
|
+
}),
|
|
166
|
+
signal: controller.signal,
|
|
167
|
+
});
|
|
168
|
+
const text = await response.text();
|
|
169
|
+
if (text.length > PROBE_MAX_BODY_BYTES) {
|
|
170
|
+
return { ok: false, status: response.status, message: "probe response body too large" };
|
|
171
|
+
}
|
|
172
|
+
let body = null;
|
|
173
|
+
try {
|
|
174
|
+
body = JSON.parse(text);
|
|
175
|
+
} catch {
|
|
176
|
+
/* non-JSON error page */
|
|
177
|
+
}
|
|
178
|
+
if (!response.ok) {
|
|
179
|
+
return {
|
|
180
|
+
ok: false,
|
|
181
|
+
status: response.status,
|
|
182
|
+
message: String(body?.error?.message ?? body?.message ?? response.statusText ?? text.slice(0, 200)),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
return { ok: true, answer: readAnswer(body) };
|
|
186
|
+
} catch (error) {
|
|
187
|
+
return { ok: false, status: 0, message: String(error?.message ?? error) };
|
|
188
|
+
} finally {
|
|
189
|
+
clearTimeout(timer);
|
|
190
|
+
signal?.removeEventListener("abort", onAbort);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Probe one route for real image support.
|
|
196
|
+
*
|
|
197
|
+
* Control first, then two colors. BOTH colors must be read correctly, so a
|
|
198
|
+
* model that guesses cannot pass: the two questions are identical and only the
|
|
199
|
+
* pixels differ.
|
|
200
|
+
*
|
|
201
|
+
* @param {object} target - `{baseURL, apiKey, model, api, timeoutMs, maxTokens, fetchImpl, headers}`.
|
|
202
|
+
* @param {AbortSignal} [signal] - caller cancellation.
|
|
203
|
+
* @returns {Promise<{support: 'yes'|'no'|'unknown', detail: string, evidence: object}>}
|
|
204
|
+
*/
|
|
205
|
+
export async function probeModelCapability(target, signal) {
|
|
206
|
+
const fetchImpl = target.fetchImpl ?? fetch;
|
|
207
|
+
const api = target.api;
|
|
208
|
+
if (api !== undefined && api !== null && api !== "openai-completions") {
|
|
209
|
+
// `openai-responses` and friends speak a different wire format; probing
|
|
210
|
+
// them with chat/completions would produce a confident wrong answer.
|
|
211
|
+
return {
|
|
212
|
+
support: "unknown",
|
|
213
|
+
detail: `route protocol "${api}" is not probeable with chat/completions`,
|
|
214
|
+
evidence: {},
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
if (typeof target.baseURL !== "string" || target.baseURL.length === 0) {
|
|
218
|
+
return { support: "unknown", detail: "the route has no baseURL", evidence: {} };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const control = await probeRequest(target, PROBE_CONTROL_QUESTION, fetchImpl, signal);
|
|
222
|
+
const controlAnswer = control.ok ? control.answer : "";
|
|
223
|
+
const controlPassed = control.ok && PROBE_CONTROL_ACCEPT.test(controlAnswer);
|
|
224
|
+
if (!controlPassed) {
|
|
225
|
+
// The route itself is unusable (bad key, dead gateway, empty answer at a
|
|
226
|
+
// workable token budget). That says nothing about image support.
|
|
227
|
+
return {
|
|
228
|
+
support: "unknown",
|
|
229
|
+
detail: control.ok
|
|
230
|
+
? `control request returned an unusable answer (${controlAnswer.slice(0, 60) || "empty"})`
|
|
231
|
+
: `control request failed with ${control.status || "a transport error"}: ${control.message}`,
|
|
232
|
+
evidence: { control: control.ok ? controlAnswer.slice(0, 120) : control.message },
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const answers = {};
|
|
237
|
+
for (const [name, spec] of Object.entries(PROBE_COLORS)) {
|
|
238
|
+
const result = await probeRequest(target, [
|
|
239
|
+
{ type: "text", text: PROBE_QUESTION },
|
|
240
|
+
{ type: "image_url", image_url: { url: solidColorDataUrl(spec.rgb) } },
|
|
241
|
+
], fetchImpl, signal);
|
|
242
|
+
if (!result.ok) {
|
|
243
|
+
if (UNSUPPORTED_IMAGE_RE.test(result.message)) {
|
|
244
|
+
return {
|
|
245
|
+
support: "no",
|
|
246
|
+
detail: `the endpoint rejected the image part (${result.status}): ${result.message}`,
|
|
247
|
+
evidence: { control: controlAnswer.slice(0, 120), answers },
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
support: "unknown",
|
|
252
|
+
detail: `image request failed with ${result.status || "a transport error"}: ${result.message}`,
|
|
253
|
+
evidence: { control: controlAnswer.slice(0, 120), answers },
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
answers[name] = result.answer.slice(0, 120);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const correct = Object.entries(PROBE_COLORS).filter(([name, spec]) => spec.accept.test(answers[name] ?? ""));
|
|
260
|
+
if (correct.length === Object.keys(PROBE_COLORS).length) {
|
|
261
|
+
return {
|
|
262
|
+
support: "yes",
|
|
263
|
+
detail: `read both probe colors correctly (${Object.keys(PROBE_COLORS).join(", ")})`,
|
|
264
|
+
evidence: { control: controlAnswer.slice(0, 120), answers },
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
support: "no",
|
|
269
|
+
detail: correct.length === 0
|
|
270
|
+
? "the endpoint answered both image requests without identifying either color"
|
|
271
|
+
: `the endpoint identified only ${correct.length}/${Object.keys(PROBE_COLORS).length} probe colors`,
|
|
272
|
+
evidence: { control: controlAnswer.slice(0, 120), answers },
|
|
273
|
+
};
|
|
274
|
+
}
|