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/lib/index.js
ADDED
|
@@ -0,0 +1,892 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-model-input-toggle — host half.
|
|
3
|
+
*
|
|
4
|
+
* Adds a "this model accepts images" switch to the官方 Models settings page by
|
|
5
|
+
* writing the modality declaration the adapters already understand:
|
|
6
|
+
*
|
|
7
|
+
* llm-pi-ai providers.<route>.models[].input = ["text","image"]
|
|
8
|
+
* llm-deepseek models[].inputModalities = ["text","image"]
|
|
9
|
+
*
|
|
10
|
+
* WHY THIS EXISTS
|
|
11
|
+
* ---------------
|
|
12
|
+
* `input` / `inputModalities` are pure DECLARATIONS. DSH uses them to decide
|
|
13
|
+
* whether to reject an image before it ever reaches the provider. Declaring
|
|
14
|
+
* image support therefore does not create it: if the endpoint cannot actually
|
|
15
|
+
* accept OpenAI-style `image_url` parts, the failure moves from "blocked by
|
|
16
|
+
* DSH" to "HTTP 400 from the endpoint". That is why this plugin ships a
|
|
17
|
+
* `verify` method that sends a real image-bearing request and reports what the
|
|
18
|
+
* endpoint actually did.
|
|
19
|
+
*
|
|
20
|
+
* WRITE STRATEGY (and why it is not array-indexed)
|
|
21
|
+
* ------------------------------------------------
|
|
22
|
+
* `ctx.settings.mutate` applies path ops through `applyPathOp`, which only
|
|
23
|
+
* descends into PLAIN OBJECTS. Any path that steps through an array silently
|
|
24
|
+
* stops at the deepest object, so `.../models/0/input` would write nothing.
|
|
25
|
+
* Every write here therefore targets a whole leaf the op can actually replace:
|
|
26
|
+
*
|
|
27
|
+
* - an existing user `models` array is rebuilt (every other field preserved)
|
|
28
|
+
* and written wholesale to `providers.<route>.models`;
|
|
29
|
+
* - when the route carries NO user `models` list, the entry is written to
|
|
30
|
+
* `providers.<route>.modelOverrides.<id>.input` instead.
|
|
31
|
+
*
|
|
32
|
+
* Those two branches matter: llm-pi-ai rejects `modelOverrides` as invalid when
|
|
33
|
+
* a `models` list is present ("models already replaces the served catalog"), and
|
|
34
|
+
* writing a full `models` list where the user had none would materialize the
|
|
35
|
+
* installed catalog into the user's config. Each branch is the minimal correct
|
|
36
|
+
* write for its shape.
|
|
37
|
+
*
|
|
38
|
+
* @module dsh-model-input-toggle
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/** Plugin identity for cordis.yml rows. */
|
|
42
|
+
export const name = "dsh-model-input-toggle";
|
|
43
|
+
|
|
44
|
+
/** Services required before mounting: the route table and its trust fence. */
|
|
45
|
+
export const inject = ["webServer", "webRuntime"];
|
|
46
|
+
|
|
47
|
+
/** Route prefix owned by this plugin. */
|
|
48
|
+
const API_PREFIX = "/dsh-model-input-toggle/api";
|
|
49
|
+
|
|
50
|
+
/** Settings namespace and model-field name for each supported adapter. */
|
|
51
|
+
const PI = { ns: "llm-pi-ai", field: "input" };
|
|
52
|
+
const DS = { ns: "llm-deepseek", field: "inputModalities" };
|
|
53
|
+
|
|
54
|
+
/** Modality lists this plugin writes. */
|
|
55
|
+
const TEXT = ["text"];
|
|
56
|
+
const TEXT_IMAGE = ["text", "image"];
|
|
57
|
+
|
|
58
|
+
/** Max accepted request body. */
|
|
59
|
+
const MAX_BODY_BYTES = 256 * 1024;
|
|
60
|
+
|
|
61
|
+
/** Wall-clock budget for one endpoint verification request. */
|
|
62
|
+
const VERIFY_TIMEOUT_MS = 45000;
|
|
63
|
+
|
|
64
|
+
/** A 1x1-ish solid PNG used as the probe image (valid, tiny, no network fetch). */
|
|
65
|
+
const PROBE_PNG_B64 =
|
|
66
|
+
"iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAIAAAAlC+aJAAAAeklEQVR4nO3PUQkAIBTAwBfHiIY1jCH8OITBAtxm7fN1wwUNaEEDWtCAFjSgBQ1oQQNa0IAWNKAFDWhBA1rQgBY0oAUNaEEDWtCAFjSgBQ1oQQNa0IAWNKAFDWhBA1rQgBY0oAUNaEEDWtCAFjSgBQ1oQQNa0IAWPHYBhBLBWprx9KEAAAAASUVORK5CYII=";
|
|
67
|
+
|
|
68
|
+
// ── small helpers ──────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
/** A non-empty array of strings, or undefined. Empty means "not declared". */
|
|
71
|
+
function nonEmpty(value) {
|
|
72
|
+
return Array.isArray(value) && value.length > 0 ? value : undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Plain JSON deep clone (settings sections are always lossless JSON). */
|
|
76
|
+
function clone(value) {
|
|
77
|
+
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Whether one resolved model declares image input. */
|
|
81
|
+
function hasImage(list) {
|
|
82
|
+
return Array.isArray(list) && list.includes("image");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Append one model id to a list, ignoring blanks and duplicates. */
|
|
86
|
+
function pushId(ids, id) {
|
|
87
|
+
if (typeof id === "string" && id.length > 0 && !ids.includes(id)) ids.push(id);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Best-effort human message from an unknown thrown value. */
|
|
91
|
+
function messageOf(error) {
|
|
92
|
+
if (error === null || error === undefined) return "unknown error";
|
|
93
|
+
if (typeof error === "string") return error;
|
|
94
|
+
const message = error.message;
|
|
95
|
+
return typeof message === "string" && message.length > 0 ? message : String(error);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── trust fence (same contract as dsh-agconfig / dsh-agimage) ──────────────
|
|
99
|
+
// Loopback-or-trusted Host header plus same-origin browser markers. This is a
|
|
100
|
+
// DNS-rebinding / cross-site defense, not authentication: the route can write
|
|
101
|
+
// settings and spend an endpoint request, so it must never be reachable from a
|
|
102
|
+
// cross-site page.
|
|
103
|
+
|
|
104
|
+
/** Normalized URL of a Host-header authority, or undefined when unparsable. */
|
|
105
|
+
function parseAuthority(authority) {
|
|
106
|
+
try {
|
|
107
|
+
return new URL(`http://${authority}`);
|
|
108
|
+
} catch {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Whether a normalized URL hostname names the local loopback authority. */
|
|
114
|
+
function isLoopbackHostname(hostname) {
|
|
115
|
+
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
116
|
+
const parts = hostname.split(".");
|
|
117
|
+
return parts.length === 4
|
|
118
|
+
&& parts[0] === "127"
|
|
119
|
+
&& parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Canonical authority form: hostname, or hostname:port when a port was written. */
|
|
123
|
+
function canonicalAuthority(entry, entryUrl) {
|
|
124
|
+
const port = entryUrl.port !== "" ? entryUrl.port : new URL(`https://${entry}`).port;
|
|
125
|
+
return port === "" ? entryUrl.hostname : `${entryUrl.hostname}:${port}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Whether a trustedHosts entry matches this request authority. */
|
|
129
|
+
function isTrustedAuthority(hostUrl, trustedHosts) {
|
|
130
|
+
return trustedHosts.some((entry) => {
|
|
131
|
+
const entryUrl = parseAuthority(entry);
|
|
132
|
+
if (entryUrl === undefined) return false;
|
|
133
|
+
return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
|
|
134
|
+
? entryUrl.hostname === hostUrl.hostname
|
|
135
|
+
: entryUrl.host === hostUrl.host;
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Whether one request may reach the plugin routes. */
|
|
140
|
+
function isTrustedApiRequest(req, trustedHosts) {
|
|
141
|
+
const host = typeof req.headers.host === "string" ? req.headers.host : undefined;
|
|
142
|
+
if (host === undefined) return false;
|
|
143
|
+
const hostUrl = parseAuthority(host);
|
|
144
|
+
if (hostUrl === undefined) return false;
|
|
145
|
+
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false;
|
|
146
|
+
if (req.headers["sec-fetch-site"] === "cross-site") return false;
|
|
147
|
+
const origin = req.headers.origin;
|
|
148
|
+
if (origin === undefined) return true;
|
|
149
|
+
try {
|
|
150
|
+
return new URL(origin).host === hostUrl.host;
|
|
151
|
+
} catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── JSON body / response helpers ───────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
const PAYLOAD_TOO_LARGE = Symbol("payload-too-large");
|
|
159
|
+
|
|
160
|
+
/** Read a JSON request body, capped at MAX_BODY_BYTES. */
|
|
161
|
+
function readJsonBody(req) {
|
|
162
|
+
return new Promise((resolve) => {
|
|
163
|
+
const chunks = [];
|
|
164
|
+
let size = 0;
|
|
165
|
+
let aborted = false;
|
|
166
|
+
req.on("data", (chunk) => {
|
|
167
|
+
size += chunk.length;
|
|
168
|
+
if (size > MAX_BODY_BYTES && !aborted) {
|
|
169
|
+
aborted = true;
|
|
170
|
+
req.destroy();
|
|
171
|
+
resolve(PAYLOAD_TOO_LARGE);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (!aborted) chunks.push(chunk);
|
|
175
|
+
});
|
|
176
|
+
req.on("end", () => {
|
|
177
|
+
if (aborted) return;
|
|
178
|
+
try {
|
|
179
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
180
|
+
} catch {
|
|
181
|
+
resolve(null);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
req.on("error", () => {
|
|
185
|
+
if (!aborted) resolve(null);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Write a JSON response. */
|
|
191
|
+
function writeJson(res, status, value) {
|
|
192
|
+
res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" });
|
|
193
|
+
res.end(JSON.stringify(value));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A structured refusal the client turns into an inline message. */
|
|
197
|
+
function fail(code, message) {
|
|
198
|
+
return { ok: false, code, message };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── settings access ────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Read one adapter namespace raw.
|
|
205
|
+
*
|
|
206
|
+
* `describe({ redactSecrets: false })` is the only surface that returns BOTH
|
|
207
|
+
* the resolved value (schema defaults + base + user, deep-frozen) and the raw
|
|
208
|
+
* user section this plugin must edit. The raw section is required because a
|
|
209
|
+
* faithful rewrite of a `models` array has to start from what the user wrote,
|
|
210
|
+
* not from the materialized resolution.
|
|
211
|
+
*
|
|
212
|
+
* @returns {{revision:number, value:object, user:object} | undefined}
|
|
213
|
+
*/
|
|
214
|
+
function readNamespace(settings, ns) {
|
|
215
|
+
let descriptors;
|
|
216
|
+
try {
|
|
217
|
+
descriptors = settings.describe({ redactSecrets: false });
|
|
218
|
+
} catch (error) {
|
|
219
|
+
throw new Error(`无法读取设置命名空间(${ns}):${messageOf(error)}`);
|
|
220
|
+
}
|
|
221
|
+
const found = descriptors.find((entry) => entry.ns === ns);
|
|
222
|
+
if (found === undefined) {
|
|
223
|
+
throw new Error(`设置命名空间 "${ns}" 未注册:对应适配器插件没有挂载。`);
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
revision: found.revision,
|
|
227
|
+
value: found.value !== null && typeof found.value === "object" ? found.value : {},
|
|
228
|
+
user: found.user !== null && typeof found.user === "object" ? found.user : {}
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Resolve one credential reference through the service, then the environment. */
|
|
233
|
+
async function resolveSecret(ctx, ref) {
|
|
234
|
+
if (typeof ref !== "string" || ref.length === 0) return undefined;
|
|
235
|
+
const credentials = ctx.get("credentials");
|
|
236
|
+
if (credentials !== undefined) {
|
|
237
|
+
try {
|
|
238
|
+
const resolved = await credentials.resolve(ref);
|
|
239
|
+
if (resolved !== undefined && typeof resolved.value === "string" && resolved.value.length > 0) {
|
|
240
|
+
return resolved.value;
|
|
241
|
+
}
|
|
242
|
+
} catch {
|
|
243
|
+
// fall through to the environment
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const fromEnv = process.env[ref];
|
|
247
|
+
return typeof fromEnv === "string" && fromEnv.length > 0 ? fromEnv : undefined;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Resolve a model's effective modalities through the live registry.
|
|
252
|
+
*
|
|
253
|
+
* This is the authoritative answer: it is the same `resolveModelInfo` the
|
|
254
|
+
* request path uses to decide whether an image may be sent. Reading it back
|
|
255
|
+
* after a write is what turns "the YAML changed" into "the adapter agrees".
|
|
256
|
+
*
|
|
257
|
+
* @returns {Promise<string[] | undefined>} modalities, or undefined when the
|
|
258
|
+
* route/model cannot be resolved yet.
|
|
259
|
+
*/
|
|
260
|
+
async function effectiveModalities(llm, provider, modelId) {
|
|
261
|
+
if (llm === undefined) return undefined;
|
|
262
|
+
try {
|
|
263
|
+
const info = await llm.resolveModelInfo(provider, modelId);
|
|
264
|
+
return Array.isArray(info.inputModalities) ? info.inputModalities.map(String) : undefined;
|
|
265
|
+
} catch {
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ── model inventory ────────────────────────────────────────────────────────
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Build the model inventory for one pi-ai provider route.
|
|
274
|
+
*
|
|
275
|
+
* Model ids come from the user's own `models` list when it exists (that list is
|
|
276
|
+
* the served catalog), then from `modelOverrides` keys, then from the live
|
|
277
|
+
* registry — so a catalog-backed route still lists what it actually serves.
|
|
278
|
+
*/
|
|
279
|
+
async function describePiAiRoute(llm, route, resolvedProvider, userProvider, catalogBacked) {
|
|
280
|
+
const defaultInput = nonEmpty(resolvedProvider?.defaultInput) ?? TEXT;
|
|
281
|
+
const resolvedModels = Array.isArray(resolvedProvider?.models) ? resolvedProvider.models : [];
|
|
282
|
+
const userModels = Array.isArray(userProvider?.models) ? userProvider.models : [];
|
|
283
|
+
const userOverrides = userProvider?.modelOverrides !== null && typeof userProvider?.modelOverrides === "object"
|
|
284
|
+
? userProvider.modelOverrides
|
|
285
|
+
: {};
|
|
286
|
+
|
|
287
|
+
const ids = [];
|
|
288
|
+
for (const model of resolvedModels) pushId(ids, model?.id);
|
|
289
|
+
for (const id of Object.keys(userOverrides)) pushId(ids, id);
|
|
290
|
+
// A catalog-backed route whose resolved config mirrors no `models` list has
|
|
291
|
+
// no enumerable source here, and `llm.resolveModelInfo` requires an exact
|
|
292
|
+
// id — so such a route renders no rows rather than guessing ids. Providers
|
|
293
|
+
// the user declared always list their models.
|
|
294
|
+
|
|
295
|
+
const models = [];
|
|
296
|
+
for (const id of ids) {
|
|
297
|
+
const findById = (model) => model !== null && typeof model === "object" && model.id === id;
|
|
298
|
+
const resolved = resolvedModels.find(findById);
|
|
299
|
+
const userModel = userModels.find(findById);
|
|
300
|
+
const userOverride = userOverrides[id];
|
|
301
|
+
|
|
302
|
+
// Where the model's image capability currently comes from:
|
|
303
|
+
// - 'model' the user declared `input` on a `models` entry
|
|
304
|
+
// - 'modelOverrides' the user declared `input` in `modelOverrides`
|
|
305
|
+
// - 'catalog' only the installed pi-ai catalog declares it
|
|
306
|
+
// - 'defaultInput' nothing declares it; the provider default applies
|
|
307
|
+
// `locked` follows from that: a catalog declaration is product data this
|
|
308
|
+
// plugin refuses to overwrite, so such a row is shown checked and inert.
|
|
309
|
+
const declaredOnModel = nonEmpty(userModel?.[PI.field]) !== undefined;
|
|
310
|
+
const declaredOnOverride = nonEmpty(userOverride?.[PI.field]) !== undefined;
|
|
311
|
+
const source = declaredOnModel
|
|
312
|
+
? "model"
|
|
313
|
+
: declaredOnOverride
|
|
314
|
+
? "modelOverrides"
|
|
315
|
+
: catalogBacked
|
|
316
|
+
? "catalog"
|
|
317
|
+
: "defaultInput";
|
|
318
|
+
|
|
319
|
+
const declaredInput = nonEmpty(userModel?.[PI.field]) ?? nonEmpty(userOverride?.[PI.field]);
|
|
320
|
+
const effective = (await effectiveModalities(llm, route, id))
|
|
321
|
+
?? declaredInput
|
|
322
|
+
?? (catalogBacked ? nonEmpty(resolved?.[PI.field]) : undefined)
|
|
323
|
+
?? defaultInput;
|
|
324
|
+
|
|
325
|
+
models.push({
|
|
326
|
+
id,
|
|
327
|
+
name: typeof (userModel?.name ?? resolved?.name) === "string" ? (userModel?.name ?? resolved?.name) : id,
|
|
328
|
+
effective: [...effective],
|
|
329
|
+
source,
|
|
330
|
+
locked: source === "catalog"
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
return models;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Build the model inventory for the built-in llm-deepseek namespace. */
|
|
337
|
+
async function describeDeepseekModels(ctx, llm, resolvedValue, userValue) {
|
|
338
|
+
const field = DS.field;
|
|
339
|
+
const resolvedModels = Array.isArray(resolvedValue?.models) ? resolvedValue.models : [];
|
|
340
|
+
const userModels = Array.isArray(userValue?.models) ? userValue.models : [];
|
|
341
|
+
const ids = [];
|
|
342
|
+
for (const model of resolvedModels) {
|
|
343
|
+
if (typeof model?.id === "string" && !ids.includes(model.id)) ids.push(model.id);
|
|
344
|
+
}
|
|
345
|
+
for (const model of userModels) {
|
|
346
|
+
if (typeof model?.id === "string" && !ids.includes(model.id)) ids.push(model.id);
|
|
347
|
+
}
|
|
348
|
+
const models = [];
|
|
349
|
+
for (const id of ids) {
|
|
350
|
+
const resolved = resolvedModels.find((model) => model?.id === id);
|
|
351
|
+
const userModel = userModels.find((model) => model?.id === id);
|
|
352
|
+
const declaredInput = nonEmpty(userModel?.[field]);
|
|
353
|
+
// No user declaration means the built-in catalog supplied it.
|
|
354
|
+
const source = declaredInput !== undefined ? "model" : "catalog";
|
|
355
|
+
const effective = (await effectiveModalities(llm, "deepseek-official", id))
|
|
356
|
+
?? declaredInput
|
|
357
|
+
?? nonEmpty(resolved?.[field])
|
|
358
|
+
?? TEXT;
|
|
359
|
+
models.push({
|
|
360
|
+
id,
|
|
361
|
+
name: typeof (userModel?.name ?? resolved?.name) === "string" ? (userModel?.name ?? resolved?.name) : id,
|
|
362
|
+
effective: [...effective],
|
|
363
|
+
source,
|
|
364
|
+
locked: source === "catalog"
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
return models;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Full inventory for the settings page: every provider route this plugin can
|
|
372
|
+
* extend, with each model's effective modalities, the source of that
|
|
373
|
+
* declaration, and whether the row is locked.
|
|
374
|
+
*
|
|
375
|
+
* Either adapter may legitimately be absent, so a missing namespace is not a
|
|
376
|
+
* failure; only "neither adapter is readable" is reported as one.
|
|
377
|
+
*/
|
|
378
|
+
async function listInventory(ctx) {
|
|
379
|
+
const settings = ctx.get("settings");
|
|
380
|
+
if (settings === undefined) {
|
|
381
|
+
return fail("no-settings", "settings 服务不可用:无法读取模型配置。");
|
|
382
|
+
}
|
|
383
|
+
const llm = ctx.get("llm");
|
|
384
|
+
const providers = [];
|
|
385
|
+
const problems = [];
|
|
386
|
+
|
|
387
|
+
// ── llm-pi-ai ──
|
|
388
|
+
try {
|
|
389
|
+
const pi = readNamespace(settings, PI.ns);
|
|
390
|
+
let directory = [];
|
|
391
|
+
if (llm !== undefined) {
|
|
392
|
+
try {
|
|
393
|
+
directory = llm.listConfigurableProviders();
|
|
394
|
+
} catch {
|
|
395
|
+
directory = [];
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
const routes = [];
|
|
399
|
+
for (const entry of directory) {
|
|
400
|
+
if (entry !== null && typeof entry === "object" && entry.settingsNs === PI.ns
|
|
401
|
+
&& typeof entry.provider === "string" && !routes.includes(entry.provider)) {
|
|
402
|
+
routes.push(entry.provider);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
// A route the directory has not published (dormant declaration) still
|
|
406
|
+
// exists in the user's config, so union both sources.
|
|
407
|
+
for (const route of Object.keys(pi.user.providers ?? {})) {
|
|
408
|
+
if (!routes.includes(route)) routes.push(route);
|
|
409
|
+
}
|
|
410
|
+
const catalogBacked = new Map();
|
|
411
|
+
for (const entry of directory) {
|
|
412
|
+
if (entry !== null && typeof entry === "object" && typeof entry.provider === "string") {
|
|
413
|
+
// `declared === false` => the installed pi-ai catalog knows this
|
|
414
|
+
// route; anything else was declared by the user.
|
|
415
|
+
catalogBacked.set(entry.provider, entry.declared !== true);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
for (const route of routes) {
|
|
419
|
+
const resolvedProvider = pi.value.providers?.[route];
|
|
420
|
+
const userProvider = pi.user.providers?.[route];
|
|
421
|
+
if (resolvedProvider === undefined && userProvider === undefined) continue;
|
|
422
|
+
providers.push({
|
|
423
|
+
router: "pi-ai",
|
|
424
|
+
namespace: PI.ns,
|
|
425
|
+
provider: route,
|
|
426
|
+
displayName: typeof resolvedProvider?.displayName === "string" ? resolvedProvider.displayName : route,
|
|
427
|
+
baseURL: typeof resolvedProvider?.baseURL === "string" ? resolvedProvider.baseURL : undefined,
|
|
428
|
+
api: typeof resolvedProvider?.api === "string" ? resolvedProvider.api : undefined,
|
|
429
|
+
apiKeyEnv: typeof resolvedProvider?.apiKeyEnv === "string" ? resolvedProvider.apiKeyEnv : undefined,
|
|
430
|
+
models: await describePiAiRoute(llm, route, resolvedProvider, userProvider, catalogBacked.get(route) === true)
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
} catch (error) {
|
|
434
|
+
problems.push(`llm-pi-ai: ${messageOf(error)}`);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// ── llm-deepseek ──
|
|
438
|
+
try {
|
|
439
|
+
const ds = readNamespace(settings, DS.ns);
|
|
440
|
+
const models = await describeDeepseekModels(ctx, llm, ds.value, ds.user);
|
|
441
|
+
if (models.length > 0) {
|
|
442
|
+
providers.push({
|
|
443
|
+
router: "deepseek",
|
|
444
|
+
namespace: DS.ns,
|
|
445
|
+
provider: "deepseek-official",
|
|
446
|
+
displayName: "DeepSeek",
|
|
447
|
+
baseURL: typeof ds.value?.baseURL === "string" ? ds.value.baseURL : undefined,
|
|
448
|
+
api: typeof ds.value?.protocol === "string" ? ds.value.protocol : undefined,
|
|
449
|
+
apiKeyEnv: typeof ds.value?.apiKeyEnv === "string" ? ds.value.apiKeyEnv : undefined,
|
|
450
|
+
models
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
} catch (error) {
|
|
454
|
+
problems.push(`llm-deepseek: ${messageOf(error)}`);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
if (providers.length === 0 && problems.length > 0) {
|
|
458
|
+
return fail("unreadable", `无法读取模型配置。${problems.join(";")}`);
|
|
459
|
+
}
|
|
460
|
+
return { ok: true, providers, warnings: problems.length > 0 ? problems : undefined };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ── the write ──────────────────────────────────────────────────────────────
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Compute the `mutate` ops for one toggle.
|
|
467
|
+
*
|
|
468
|
+
* @returns {{ops: object[]} | {error: object}}
|
|
469
|
+
*/
|
|
470
|
+
function planPiAiWrite(settings, provider, modelId, enable) {
|
|
471
|
+
const raw = readNamespace(settings, PI.ns);
|
|
472
|
+
const resolved = raw.value;
|
|
473
|
+
const user = raw.user;
|
|
474
|
+
|
|
475
|
+
const resolvedProvider = resolved.providers?.[provider];
|
|
476
|
+
const userProvider = user.providers?.[provider];
|
|
477
|
+
if (resolvedProvider === undefined && userProvider === undefined) {
|
|
478
|
+
return { error: fail("provider-missing", `provider "${provider}" 不存在于 llm-pi-ai 配置中。`) };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const desired = enable ? TEXT_IMAGE : TEXT;
|
|
482
|
+
const userModels = Array.isArray(userProvider?.models) ? userProvider.models : [];
|
|
483
|
+
|
|
484
|
+
if (userModels.length > 0) {
|
|
485
|
+
const index = userModels.findIndex((model) => model !== null && typeof model === "object" && model.id === modelId);
|
|
486
|
+
if (index < 0) {
|
|
487
|
+
return {
|
|
488
|
+
error: fail(
|
|
489
|
+
"model-missing",
|
|
490
|
+
`模型 "${modelId}" 不在 provider "${provider}" 的 models 列表中;请先在设置页添加该模型。`
|
|
491
|
+
)
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
// Rebuild the whole list so every sibling field survives verbatim. The
|
|
495
|
+
// op sets a leaf array, which is the deepest node applyPathOp can
|
|
496
|
+
// replace — an array index in the path would write nothing.
|
|
497
|
+
const models = clone(userModels);
|
|
498
|
+
models[index] = { ...models[index], [PI.field]: [...desired] };
|
|
499
|
+
return { ops: [{ op: "set", path: ["providers", provider, "models"], value: models }] };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// No user models list: llm-pi-ai serves this route from its catalog and
|
|
503
|
+
// allows per-model `modelOverrides`. (Declaring a full `models` list here
|
|
504
|
+
// would fold the installed catalog into the user's config.)
|
|
505
|
+
//
|
|
506
|
+
// No existence check is performed: the resolved config does not necessarily
|
|
507
|
+
// enumerate catalog models, so absence is not proof of nonexistence. An id
|
|
508
|
+
// the catalog genuinely does not describe is surfaced by the adapter's own
|
|
509
|
+
// diagnostics, and the post-write verification below reports it.
|
|
510
|
+
return {
|
|
511
|
+
ops: [{ op: "set", path: ["providers", provider, "modelOverrides", modelId, PI.field], value: [...desired] }]
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Compute the `mutate` ops for one llm-deepseek toggle. */
|
|
516
|
+
function planDeepseekWrite(settings, modelId, enable) {
|
|
517
|
+
const raw = readNamespace(settings, DS.ns);
|
|
518
|
+
const resolvedModels = Array.isArray(raw.value?.models) ? raw.value.models : [];
|
|
519
|
+
const userModels = Array.isArray(raw.user?.models) ? raw.user.models : [];
|
|
520
|
+
const desired = enable ? TEXT_IMAGE : TEXT;
|
|
521
|
+
|
|
522
|
+
if (userModels.length > 0) {
|
|
523
|
+
const index = userModels.findIndex((model) => model?.id === modelId);
|
|
524
|
+
if (index < 0) return { error: fail("model-missing", `模型 "${modelId}" 不存在于 llm-deepseek 的 models 列表中。`) };
|
|
525
|
+
const models = clone(userModels);
|
|
526
|
+
models[index] = { ...models[index], [DS.field]: [...desired] };
|
|
527
|
+
return { ops: [{ op: "set", path: ["models"], value: models }] };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Nothing user-authored yet: materialize the resolved catalog with the one
|
|
531
|
+
// edited entry, because `models` has no per-entry override mechanism here.
|
|
532
|
+
const index = resolvedModels.findIndex((model) => model?.id === modelId);
|
|
533
|
+
if (index < 0) return { error: fail("model-missing", `模型 "${modelId}" 不存在于 llm-deepseek 中。`) };
|
|
534
|
+
const models = clone(resolvedModels);
|
|
535
|
+
models[index] = { ...models[index], [DS.field]: [...desired] };
|
|
536
|
+
return { ops: [{ op: "set", path: ["models"], value: models }] };
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Set whether one model declares image input.
|
|
541
|
+
*
|
|
542
|
+
* Reads the current config, plans a minimal op, persists it with the revision
|
|
543
|
+
* it read (so a concurrent edit is detected rather than clobbered), retries
|
|
544
|
+
* once against a fresh read on conflict, then verifies the declaration through
|
|
545
|
+
* the live adapter.
|
|
546
|
+
*/
|
|
547
|
+
async function setModelImageInput(ctx, args) {
|
|
548
|
+
const settings = ctx.get("settings");
|
|
549
|
+
if (settings === undefined) return fail("no-settings", "settings 服务不可用:无法写入模型配置。");
|
|
550
|
+
|
|
551
|
+
const namespace = typeof args.namespace === "string" ? args.namespace : undefined;
|
|
552
|
+
const provider = typeof args.provider === "string" ? args.provider : undefined;
|
|
553
|
+
const modelId = typeof args.modelId === "string" ? args.modelId : undefined;
|
|
554
|
+
const enable = args.supportsImage === true;
|
|
555
|
+
|
|
556
|
+
if (namespace !== PI.ns && namespace !== DS.ns) {
|
|
557
|
+
return fail("unsupported-namespace", `不支持的设置命名空间 "${String(namespace)}"。`);
|
|
558
|
+
}
|
|
559
|
+
if (provider === undefined || provider.length === 0 || modelId === undefined || modelId.length === 0) {
|
|
560
|
+
return fail("bad-request", "provider 与 modelId 均为必填。");
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
564
|
+
let planned;
|
|
565
|
+
let revision;
|
|
566
|
+
try {
|
|
567
|
+
const descriptor = settings.describe({ redactSecrets: false }).find((entry) => entry.ns === namespace);
|
|
568
|
+
if (descriptor === undefined) return fail("no-namespace", `设置命名空间 "${namespace}" 未注册。`);
|
|
569
|
+
revision = descriptor.revision;
|
|
570
|
+
planned = namespace === PI.ns
|
|
571
|
+
? planPiAiWrite(settings, provider, modelId, enable)
|
|
572
|
+
: planDeepseekWrite(settings, modelId, enable);
|
|
573
|
+
} catch (error) {
|
|
574
|
+
return fail("read-failed", messageOf(error));
|
|
575
|
+
}
|
|
576
|
+
if (planned.error !== undefined) return planned.error;
|
|
577
|
+
|
|
578
|
+
try {
|
|
579
|
+
await settings.mutate(namespace, planned.ops, revision);
|
|
580
|
+
} catch (error) {
|
|
581
|
+
const code = error?.code;
|
|
582
|
+
if (code === "SETTINGS_CONFLICT" && attempt === 0) continue; // re-read and retry once
|
|
583
|
+
if (code === "SETTINGS_CONFLICT") {
|
|
584
|
+
return fail("conflict", "配置已被其它操作修改,重试后仍然冲突。请在设置页面手动刷新后重试。");
|
|
585
|
+
}
|
|
586
|
+
return fail("write-failed", messageOf(error));
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Verification: ask the adapter that will serve the request whether it
|
|
590
|
+
// now agrees. This is the difference between "we wrote a field" and
|
|
591
|
+
// "the image will no longer be intercepted".
|
|
592
|
+
const llm = ctx.get("llm");
|
|
593
|
+
const route = namespace === PI.ns ? provider : "deepseek-official";
|
|
594
|
+
const verified = await effectiveModalities(llm, route, modelId);
|
|
595
|
+
return {
|
|
596
|
+
ok: true,
|
|
597
|
+
namespace,
|
|
598
|
+
provider: route,
|
|
599
|
+
modelId,
|
|
600
|
+
requested: enable,
|
|
601
|
+
declared: verified === undefined ? undefined : [...verified],
|
|
602
|
+
verified: verified === undefined ? false : hasImage(verified) === enable,
|
|
603
|
+
verifiedReason: verified === undefined
|
|
604
|
+
? "适配器尚未返回该模型的元数据(provider 可能未激活)。配置已写入,重启或刷新后生效。"
|
|
605
|
+
: undefined
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
return fail("conflict", "配置冲突。");
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ── endpoint verification ──────────────────────────────────────────────────
|
|
612
|
+
|
|
613
|
+
/** Map a pi-ai protocol name onto a chat-completions path, when supported. */
|
|
614
|
+
function completionPath(api) {
|
|
615
|
+
switch (api) {
|
|
616
|
+
case "openai-completions":
|
|
617
|
+
case "openai-chat-completions":
|
|
618
|
+
case undefined:
|
|
619
|
+
return "/chat/completions";
|
|
620
|
+
default:
|
|
621
|
+
return undefined;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Send one real image-bearing request to the configured endpoint.
|
|
627
|
+
*
|
|
628
|
+
* `input: ["text","image"]` only removes DSH's own interception. Whether the
|
|
629
|
+
* endpoint accepts an OpenAI-style `image_url` part is a separate, empirical
|
|
630
|
+
* fact — which is exactly what this measures. A 200 proves the endpoint
|
|
631
|
+
* accepted the payload; a 400 proves the declaration is a lie for this route.
|
|
632
|
+
*/
|
|
633
|
+
async function verifyEndpoint(ctx, args) {
|
|
634
|
+
const settings = ctx.get("settings");
|
|
635
|
+
if (settings === undefined) return fail("no-settings", "settings 服务不可用。");
|
|
636
|
+
const provider = typeof args.provider === "string" ? args.provider : "";
|
|
637
|
+
const modelId = typeof args.modelId === "string" ? args.modelId : "";
|
|
638
|
+
const namespace = typeof args.namespace === "string" ? args.namespace : "";
|
|
639
|
+
if (provider.length === 0 || modelId.length === 0) return fail("bad-request", "provider 与 modelId 均为必填。");
|
|
640
|
+
|
|
641
|
+
let baseURL;
|
|
642
|
+
let api;
|
|
643
|
+
let apiKeyEnv;
|
|
644
|
+
try {
|
|
645
|
+
if (namespace === PI.ns) {
|
|
646
|
+
const raw = readNamespace(settings, PI.ns);
|
|
647
|
+
const resolved = raw.value.providers?.[provider];
|
|
648
|
+
if (resolved === undefined) return fail("provider-missing", `provider "${provider}" 不存在。`);
|
|
649
|
+
baseURL = resolved.baseURL;
|
|
650
|
+
api = resolved.api ?? "openai-completions";
|
|
651
|
+
apiKeyEnv = resolved.apiKeyEnv;
|
|
652
|
+
} else {
|
|
653
|
+
const raw = readNamespace(settings, DS.ns);
|
|
654
|
+
if (!Array.isArray(raw.value?.models) || !raw.value.models.some((model) => model?.id === modelId)) {
|
|
655
|
+
return fail("model-missing", `模型 "${modelId}" 不存在于 llm-deepseek 中。`);
|
|
656
|
+
}
|
|
657
|
+
baseURL = raw.value.baseURL;
|
|
658
|
+
api = "openai-completions";
|
|
659
|
+
apiKeyEnv = raw.value.apiKeyEnv;
|
|
660
|
+
}
|
|
661
|
+
} catch (error) {
|
|
662
|
+
return fail("read-failed", messageOf(error));
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (typeof baseURL !== "string" || baseURL.length === 0) {
|
|
666
|
+
return fail("no-base-url", `provider "${provider}" 没有配置 baseURL,无法验证端点。`);
|
|
667
|
+
}
|
|
668
|
+
const path = completionPath(api);
|
|
669
|
+
if (path === undefined) {
|
|
670
|
+
return fail(
|
|
671
|
+
"unsupported-protocol",
|
|
672
|
+
`协议 "${String(api)}" 不支持自动验证(仅支持 openai-completions)。请改用带图片的真实请求手工验证。`
|
|
673
|
+
);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const key = await resolveSecret(ctx, apiKeyEnv);
|
|
677
|
+
if (key === undefined) {
|
|
678
|
+
return fail("no-credential", `未能解析凭据 "${String(apiKeyEnv)}":请在设置页填写该 provider 的 API 密钥。`);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
const url = baseURL.replace(/\/+$/, "") + path;
|
|
682
|
+
// Some gateways — this profile's jiuling/CodeBuddy proxy among them — reject
|
|
683
|
+
// any request whose first message is not a system prompt, before they ever
|
|
684
|
+
// look at the image. Leading with one keeps the probe about image support
|
|
685
|
+
// instead of about the gateway's envelope rules.
|
|
686
|
+
const SYS = { role: "system", content: "You are a helpful assistant." };
|
|
687
|
+
const imageRequest = {
|
|
688
|
+
model: modelId,
|
|
689
|
+
max_tokens: 16,
|
|
690
|
+
messages: [
|
|
691
|
+
SYS,
|
|
692
|
+
{
|
|
693
|
+
role: "user",
|
|
694
|
+
content: [
|
|
695
|
+
{ type: "text", text: "Reply with the single word: ok" },
|
|
696
|
+
{ type: "image_url", image_url: { url: `data:image/png;base64,${PROBE_PNG_B64}` } }
|
|
697
|
+
]
|
|
698
|
+
}
|
|
699
|
+
]
|
|
700
|
+
};
|
|
701
|
+
// The control request is identical except that it carries no image. It is
|
|
702
|
+
// what makes a non-200 answerable: a rejection that disappears when the
|
|
703
|
+
// image is removed proves the endpoint cannot take images, while a rejection
|
|
704
|
+
// of BOTH proves the probe never got far enough to test the image at all.
|
|
705
|
+
// Without this, any gateway or model-availability error would be misreported
|
|
706
|
+
// as "this model does not support images".
|
|
707
|
+
const controlRequest = {
|
|
708
|
+
model: modelId,
|
|
709
|
+
max_tokens: 16,
|
|
710
|
+
messages: [SYS, { role: "user", content: "Reply with the single word: ok" }]
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
const controller = new AbortController();
|
|
714
|
+
const timer = setTimeout(() => controller.abort(), VERIFY_TIMEOUT_MS);
|
|
715
|
+
try {
|
|
716
|
+
const attempt = async (payload) => {
|
|
717
|
+
const response = await fetch(url, {
|
|
718
|
+
method: "POST",
|
|
719
|
+
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
|
|
720
|
+
body: JSON.stringify(payload),
|
|
721
|
+
signal: controller.signal
|
|
722
|
+
});
|
|
723
|
+
return { status: response.status, ok: response.ok, text: await response.text() };
|
|
724
|
+
};
|
|
725
|
+
// Gateways frequently wrap the upstream error as a JSON *string* inside
|
|
726
|
+
// the message ("CodeBuddy 上游 HTTP 400:{"code":11128,"msg":"…"}"). Dumping
|
|
727
|
+
// that envelope buries the one useful sentence, so unwrap the outer JSON,
|
|
728
|
+
// then any JSON nested inside it, and prefer the most precise field.
|
|
729
|
+
const friendlyDetail = (raw) => {
|
|
730
|
+
const text = String(raw).replace(/\s+/g, " ").trim();
|
|
731
|
+
const start = text.indexOf("{");
|
|
732
|
+
if (start >= 0) {
|
|
733
|
+
try {
|
|
734
|
+
const inner = JSON.parse(text.slice(start));
|
|
735
|
+
const pick = (value) => (typeof value === "string" && value.length > 0 ? value : undefined);
|
|
736
|
+
const chosen = pick(inner?.msg)
|
|
737
|
+
?? pick(inner?.displayMsg?.zh)
|
|
738
|
+
?? pick(inner?.displayMsg?.en)
|
|
739
|
+
?? pick(inner?.error?.message);
|
|
740
|
+
if (chosen !== undefined) {
|
|
741
|
+
const prefix = text.slice(0, start).replace(/[::]\s*$/, "").trim();
|
|
742
|
+
return (prefix.length > 0 ? `${prefix}:${chosen}` : chosen).slice(0, 300);
|
|
743
|
+
}
|
|
744
|
+
} catch {
|
|
745
|
+
// Not JSON, or truncated JSON: fall through to the raw text.
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
return text.slice(0, 300);
|
|
749
|
+
};
|
|
750
|
+
const detailOf = (text) => {
|
|
751
|
+
let parsed;
|
|
752
|
+
try {
|
|
753
|
+
parsed = JSON.parse(text);
|
|
754
|
+
} catch {
|
|
755
|
+
parsed = undefined;
|
|
756
|
+
}
|
|
757
|
+
const message = typeof parsed?.error?.message === "string" ? parsed.error.message : text;
|
|
758
|
+
return friendlyDetail(message);
|
|
759
|
+
};
|
|
760
|
+
|
|
761
|
+
const probe = await attempt(imageRequest);
|
|
762
|
+
const detail = detailOf(probe.text);
|
|
763
|
+
|
|
764
|
+
if (probe.ok) {
|
|
765
|
+
return {
|
|
766
|
+
ok: true,
|
|
767
|
+
status: probe.status,
|
|
768
|
+
verdict: "supported",
|
|
769
|
+
message: "端点接受了带图片的请求(HTTP 200),该模型确实支持视觉输入。"
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
if (probe.status === 401 || probe.status === 403) {
|
|
773
|
+
return {
|
|
774
|
+
ok: false,
|
|
775
|
+
status: probe.status,
|
|
776
|
+
verdict: "unauthorized",
|
|
777
|
+
message: `凭据被拒绝(HTTP ${probe.status}):请检查 API 密钥。`
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
if (probe.status === 404) {
|
|
781
|
+
return {
|
|
782
|
+
ok: false,
|
|
783
|
+
status: probe.status,
|
|
784
|
+
verdict: "not-found",
|
|
785
|
+
message: `端点不存在(HTTP 404):请检查 baseURL 与协议。请求地址:${url}`
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Non-200. Decide whether the IMAGE is what failed by re-sending the same
|
|
790
|
+
// request with the image removed. Only an image-specific failure may be
|
|
791
|
+
// reported as "the endpoint does not accept images".
|
|
792
|
+
let control = null;
|
|
793
|
+
try {
|
|
794
|
+
control = await attempt(controlRequest);
|
|
795
|
+
} catch {
|
|
796
|
+
control = null;
|
|
797
|
+
}
|
|
798
|
+
if (control !== null && control.ok) {
|
|
799
|
+
return {
|
|
800
|
+
ok: true,
|
|
801
|
+
status: probe.status,
|
|
802
|
+
verdict: "rejected",
|
|
803
|
+
message: `端点拒绝了图片输入(HTTP ${probe.status}),而同一请求去掉图片后返回 HTTP 200:`
|
|
804
|
+
+ `该模型确实不支持视觉输入,建议取消勾选。端点说明:${detail}`
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
const controlNote = control === null
|
|
808
|
+
? "去掉图片的对照请求也未能完成"
|
|
809
|
+
: `去掉图片的同一请求仍然返回 HTTP ${control.status}`;
|
|
810
|
+
return {
|
|
811
|
+
ok: false,
|
|
812
|
+
status: probe.status,
|
|
813
|
+
verdict: "inconclusive",
|
|
814
|
+
message: `端点返回 HTTP ${probe.status},但这与图片无关:${controlNote},`
|
|
815
|
+
+ "说明该端点拒绝了这次请求本身(模型未开通、网关策略、参数不符等)。"
|
|
816
|
+
+ `请先确认该模型可正常对话,再验证图片能力。端点说明:${detail}`
|
|
817
|
+
};
|
|
818
|
+
} catch (error) {
|
|
819
|
+
const aborted = error?.name === "AbortError";
|
|
820
|
+
return {
|
|
821
|
+
ok: false,
|
|
822
|
+
status: -1,
|
|
823
|
+
verdict: aborted ? "timeout" : "network",
|
|
824
|
+
message: aborted
|
|
825
|
+
? `验证超时(${VERIFY_TIMEOUT_MS / 1000} 秒)。`
|
|
826
|
+
: `无法连接端点:${messageOf(error)}`
|
|
827
|
+
};
|
|
828
|
+
} finally {
|
|
829
|
+
clearTimeout(timer);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// ── route handling ─────────────────────────────────────────────────────────
|
|
834
|
+
|
|
835
|
+
/** Dispatch one fenced API request. */
|
|
836
|
+
async function handleApi(ctx, req, res) {
|
|
837
|
+
if (req.method !== "POST") {
|
|
838
|
+
writeJson(res, 405, fail("method-error", "method not allowed"));
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
const contentType = typeof req.headers["content-type"] === "string" ? req.headers["content-type"].toLowerCase() : "";
|
|
842
|
+
if (!contentType.startsWith("application/json")) {
|
|
843
|
+
writeJson(res, 415, fail("unsupported-media-type", "content-type must be application/json"));
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
const payload = await readJsonBody(req);
|
|
847
|
+
if (payload === PAYLOAD_TOO_LARGE) {
|
|
848
|
+
writeJson(res, 413, fail("payload-too-large", "request body too large"));
|
|
849
|
+
return;
|
|
850
|
+
}
|
|
851
|
+
if (payload === null || typeof payload !== "object" || typeof payload.method !== "string") {
|
|
852
|
+
writeJson(res, 400, fail("bad-request", "bad request"));
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
switch (payload.method) {
|
|
857
|
+
case "list":
|
|
858
|
+
writeJson(res, 200, await listInventory(ctx));
|
|
859
|
+
return;
|
|
860
|
+
case "set":
|
|
861
|
+
writeJson(res, 200, await setModelImageInput(ctx, payload));
|
|
862
|
+
return;
|
|
863
|
+
case "verify":
|
|
864
|
+
writeJson(res, 200, await verifyEndpoint(ctx, payload));
|
|
865
|
+
return;
|
|
866
|
+
default:
|
|
867
|
+
writeJson(res, 404, fail("not-found", `unknown method "${payload.method}"`));
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Host loader entry: mount the fenced settings bridge.
|
|
873
|
+
* @param ctx - host cordis context (webServer, webRuntime).
|
|
874
|
+
*/
|
|
875
|
+
export function apply(ctx) {
|
|
876
|
+
ctx.effect(() => ctx.webServer.register({
|
|
877
|
+
kind: "prefix",
|
|
878
|
+
path: API_PREFIX,
|
|
879
|
+
handler: async (req, res) => {
|
|
880
|
+
if (!isTrustedApiRequest(req, ctx.webRuntime.trustedHosts)) {
|
|
881
|
+
writeJson(res, 403, fail("forbidden", "forbidden"));
|
|
882
|
+
return;
|
|
883
|
+
}
|
|
884
|
+
try {
|
|
885
|
+
await handleApi(ctx, req, res);
|
|
886
|
+
} catch (error) {
|
|
887
|
+
console.error("[dsh-model-input-toggle] api error:", error);
|
|
888
|
+
writeJson(res, 500, fail("internal", messageOf(error)));
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}), "dsh-model-input-toggle: model input settings API");
|
|
892
|
+
}
|