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/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
+ }