dsh-tool-vision 0.8.1 → 0.9.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 +166 -9
- package/README.zh.md +88 -7
- package/client.js +345 -15
- package/index.js +607 -19
- package/lib/model-probe.js +274 -0
- package/package.json +10 -4
package/index.js
CHANGED
|
@@ -19,9 +19,20 @@
|
|
|
19
19
|
* event at a time, on the first pre-step of the session.
|
|
20
20
|
*
|
|
21
21
|
* The bridged hint points at an exported local copy of the image, which
|
|
22
|
-
* the agent hands to `inspect_image`.
|
|
23
|
-
*
|
|
24
|
-
*
|
|
22
|
+
* the agent hands to `inspect_image`. Which models skip the bridge
|
|
23
|
+
* (v0.9.0) is decided by two independent inputs:
|
|
24
|
+
*
|
|
25
|
+
* - `multimodalModels` — a user-owned list whose meaning is set by
|
|
26
|
+
* `multimodalListMode`: `whitelist` (listed models receive image blocks
|
|
27
|
+
* directly; the historical meaning and the default), `blacklist`
|
|
28
|
+
* (listed models are forced through the bridge), or `off` (list
|
|
29
|
+
* ignored). Entries match the full model id, its bare id after the last
|
|
30
|
+
* `/`, or `provider/id`, and may contain `*` / `?` globs.
|
|
31
|
+
* - `autoDetectMultimodal` — when on, the route's own declared
|
|
32
|
+
* `inputModalities` forms the base set that the list then adds to
|
|
33
|
+
* (whitelist) or subtracts from (blacklist). The declaration is always
|
|
34
|
+
* read from the *unwrapped* `resolveModelInfo`, because the admission
|
|
35
|
+
* wrap below rewrites it for every model.
|
|
25
36
|
*
|
|
26
37
|
* 3. Bridge image preview (v0.4.0, contributed by xing666173 from
|
|
27
38
|
* dsh-bridge-preview, MIT © 2026 xing666173) — the browser half renders
|
|
@@ -43,6 +54,7 @@ import z from "@deepseek-ai/schemastery";
|
|
|
43
54
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
44
55
|
import { registerVisionTools, CONTENT_FILTER_RE } from "./lib/vision-tools.js";
|
|
45
56
|
import { sessionHeaders } from "./lib/session-header.js";
|
|
57
|
+
import { probeModelCapability, PROBE_MAX_TOKENS, PROBE_TIMEOUT_MS } from "./lib/model-probe.js";
|
|
46
58
|
|
|
47
59
|
/** Cordis plugin name. */
|
|
48
60
|
const name = "tool-vision";
|
|
@@ -60,6 +72,16 @@ const BRIDGE_MARKER = "\u200b[bridge]";
|
|
|
60
72
|
|
|
61
73
|
/** Loopback route serving bridged images to the same-origin page. */
|
|
62
74
|
const BRIDGE_PREVIEW_ROUTE = "/plugins/dsh-tool-vision/image";
|
|
75
|
+
/**
|
|
76
|
+
* Loopback JSON route the settings panel reads the configured model catalog
|
|
77
|
+
* from (autocomplete for `multimodalModels` and the current-route readout).
|
|
78
|
+
* Registered on the plugin's own fiber, so it stays up while the master
|
|
79
|
+
* switch is off — a section you cannot configure until you switch it on would
|
|
80
|
+
* be useless.
|
|
81
|
+
*/
|
|
82
|
+
const MODEL_CATALOG_ROUTE = "/plugins/dsh-tool-vision/models";
|
|
83
|
+
/** Per-provider cap on catalog entries returned to the panel. */
|
|
84
|
+
const MODEL_CATALOG_MAX_PER_PROVIDER = 500;
|
|
63
85
|
/** Hard cap for a single served image (defense in depth; export is bounded). */
|
|
64
86
|
const BRIDGE_PREVIEW_MAX_BYTES = 20 * 1024 * 1024;
|
|
65
87
|
/** Extensions the preview route serves (svg/ico intentionally excluded). */
|
|
@@ -78,7 +100,7 @@ const DEFAULT_DESCRIPTION =
|
|
|
78
100
|
"Provide the path to a local image file (absolute, or relative to the current workspace) or an http(s) URL, " +
|
|
79
101
|
"optionally with a specific question. Returns the vision model's textual description or answer. " +
|
|
80
102
|
"Use this whenever you need to read, describe, or extract information from image content, " +
|
|
81
|
-
"
|
|
103
|
+
"including an image that reached you as a bridged text hint.";
|
|
82
104
|
|
|
83
105
|
/** Runtime schema for the tool-vision row. */
|
|
84
106
|
const Config = z.object({
|
|
@@ -111,8 +133,63 @@ const Config = z.object({
|
|
|
111
133
|
bridgeTextOnly: z.boolean().default(true),
|
|
112
134
|
/** Export directory for bridged images; empty = system temp. */
|
|
113
135
|
bridgeExportDir: z.string().default(""),
|
|
114
|
-
/**
|
|
136
|
+
/**
|
|
137
|
+
* Model ids the list below refers to (v0.9.0). Each entry is matched
|
|
138
|
+
* case-insensitively against the full model id (`xiaomi/mimo-v2.5`), its
|
|
139
|
+
* bare id after the last `/` (`mimo-v2.5`), and `provider/id`
|
|
140
|
+
* (`commandcode/xiaomi/mimo-v2.5`), and may use `*` / `?` globs
|
|
141
|
+
* (`*vl*`, `deepseek/*`). A bare id therefore keeps working however the
|
|
142
|
+
* route happens to spell it.
|
|
143
|
+
*/
|
|
115
144
|
multimodalModels: z.array(z.string()).default([]),
|
|
145
|
+
/**
|
|
146
|
+
* How `multimodalModels` is read (v0.9.0):
|
|
147
|
+
*
|
|
148
|
+
* - `whitelist` (default): a listed model receives image blocks directly
|
|
149
|
+
* and is never bridged — the meaning the list always had, so an
|
|
150
|
+
* existing configuration keeps behaving exactly as before.
|
|
151
|
+
* - `blacklist`: a listed model is forced through the bridge even when its
|
|
152
|
+
* route declares image input. This is the correction layer for a model
|
|
153
|
+
* that claims image support its endpoint does not really have.
|
|
154
|
+
* - `off`: the list is ignored entirely; nothing is forced either way.
|
|
155
|
+
*
|
|
156
|
+
* An unknown value falls back to `whitelist`.
|
|
157
|
+
*/
|
|
158
|
+
multimodalListMode: z.string().default("whitelist"),
|
|
159
|
+
/**
|
|
160
|
+
* Let the current route's own declared `inputModalities` drive the bridge
|
|
161
|
+
* decision, so only the models that cannot see images get bridged (v0.9.0,
|
|
162
|
+
* **on by default**). This and `multimodalModels` are one decision, not two:
|
|
163
|
+
* the declaration forms the base set, and the list unions with it
|
|
164
|
+
* (whitelist) or subtracts from it (blacklist).
|
|
165
|
+
*
|
|
166
|
+
* The declaration is only whatever the profile — or the installed catalog —
|
|
167
|
+
* says, and it CAN be wrong: profiles sometimes declare
|
|
168
|
+
* `input: [text, image]` just to pass the host admission gate, and a wrong
|
|
169
|
+
* "yes" sends the image straight to an endpoint that may reject it. The
|
|
170
|
+
* harness's own text-model projection is disabled by `bridgeAutoImage`, so
|
|
171
|
+
* nothing underneath catches that. Two things bound the damage: the first
|
|
172
|
+
* promotion of a route logs a notice naming the escape hatch
|
|
173
|
+
* ({@link warnAutoPromotion}), and naming that route in `multimodalModels`
|
|
174
|
+
* under `blacklist` mode forces the bridge back on. Set this to false to
|
|
175
|
+
* bridge everything the list does not name.
|
|
176
|
+
*
|
|
177
|
+
* The value is always read from the *unwrapped* `resolveModelInfo`, so the
|
|
178
|
+
* admission wrap can never feed its own claim back in as evidence.
|
|
179
|
+
*/
|
|
180
|
+
autoDetectMultimodal: z.boolean().default(true),
|
|
181
|
+
/**
|
|
182
|
+
* Measured image capability per route (v0.9.0), keyed `"provider/model"` with
|
|
183
|
+
* the value `"yes"` or `"no"`. Written by the `vision_probe_model` tool,
|
|
184
|
+
* never by hand — the only entry in this schema that records what a route
|
|
185
|
+
* DOES rather than what it says.
|
|
186
|
+
*
|
|
187
|
+
* It outranks the route's own declaration, because a probe is a real request
|
|
188
|
+
* and a declaration is only a claim. It does NOT outrank `multimodalModels`:
|
|
189
|
+
* that list is explicit human intent, and a person who names a model
|
|
190
|
+
* deserves the last word over an automated measurement.
|
|
191
|
+
*/
|
|
192
|
+
probeResults: z.dict(z.string()).default({}),
|
|
116
193
|
/** Inline preview for bridged images: thumbnail above the hint text in the user bubble (click to zoom). */
|
|
117
194
|
bridgePreview: z.boolean().default(true),
|
|
118
195
|
/** Fallback scan interval for the preview scanner in ms; 0 disables the periodic fallback. */
|
|
@@ -288,7 +365,10 @@ function installAutoImageAdmission(llm, logger) {
|
|
|
288
365
|
return { ...info, inputModalities: [...(mods ?? []), "image"] };
|
|
289
366
|
};
|
|
290
367
|
let installed = false;
|
|
291
|
-
|
|
368
|
+
// The marker carries BOTH functions: the wrapped one proves ownership for
|
|
369
|
+
// dispose, and `original` is the only honest source of a route's real
|
|
370
|
+
// modalities once the wrap is in place (`unwrappedResolveModelInfo`).
|
|
371
|
+
llm[LLM_RESOLVE_WRAP_MARK] = { original, wrapped };
|
|
292
372
|
llm.resolveModelInfo = wrapped;
|
|
293
373
|
installed = true;
|
|
294
374
|
logger?.debug?.("[tool-vision] automatic image admission installed (resolveModelInfo wrapped)");
|
|
@@ -372,22 +452,379 @@ function registerBridgePreviewRoute(ctx, exportDir, logger) {
|
|
|
372
452
|
}), "dsh-tool-vision: bridge preview route");
|
|
373
453
|
}
|
|
374
454
|
|
|
455
|
+
/**
|
|
456
|
+
* Register the loopback JSON route the settings panel reads:
|
|
457
|
+
*
|
|
458
|
+
* { current: {provider, model, direct, source, mode}, providers: [...] }
|
|
459
|
+
*
|
|
460
|
+
* `providers` comes from `llm.listProviders()` + `llm.listModels(provider)` —
|
|
461
|
+
* the routes the harness actually has configured, which is exactly the set a
|
|
462
|
+
* `multimodalModels` entry may name. `listModels` is a different seam from
|
|
463
|
+
* `resolveModelInfo`, so these declared modalities are the profile's own and
|
|
464
|
+
* are NOT contaminated by the admission wrap (a model the wrap advertises as
|
|
465
|
+
* image-capable still reports its real declaration here).
|
|
466
|
+
*
|
|
467
|
+
* Same confinement as the image route: loopback Host only, read-only,
|
|
468
|
+
* `no-store`, and no credentials or endpoints in the payload — provider ids,
|
|
469
|
+
* model ids/names and one boolean each.
|
|
470
|
+
*/
|
|
471
|
+
function registerModelCatalogRoute(ctx, logger, getConfig) {
|
|
472
|
+
const webServer = ctx.get("webServer");
|
|
473
|
+
if (webServer === undefined) {
|
|
474
|
+
logger?.warn?.("[tool-vision] webServer unavailable; model catalog route not registered");
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
ctx.effect(() => webServer.register({
|
|
478
|
+
kind: "exact",
|
|
479
|
+
path: MODEL_CATALOG_ROUTE,
|
|
480
|
+
async handler(req, res) {
|
|
481
|
+
const send = (status, payload) => {
|
|
482
|
+
res.writeHead(status, {
|
|
483
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
484
|
+
"Cache-Control": "no-store",
|
|
485
|
+
});
|
|
486
|
+
res.end(JSON.stringify(payload));
|
|
487
|
+
};
|
|
488
|
+
const host = String(req.headers?.host ?? "");
|
|
489
|
+
if (host !== "" && !/^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/.test(host)) {
|
|
490
|
+
send(403, { error: "forbidden" });
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const llm = ctx.get("llm");
|
|
494
|
+
if (llm === undefined) {
|
|
495
|
+
send(503, { error: "llm service unavailable" });
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
// `listed` is computed HERE, with the same matcher the bridge itself
|
|
499
|
+
// uses, so the panel never reimplements the rule (and can never
|
|
500
|
+
// disagree with the decision it is describing).
|
|
501
|
+
const cfg = typeof getConfig === "function" ? getConfig() : undefined;
|
|
502
|
+
const list = Array.isArray(cfg?.multimodalModels) ? cfg.multimodalModels : [];
|
|
503
|
+
const listMode = normalizeListMode(cfg?.multimodalListMode);
|
|
504
|
+
let routes = [];
|
|
505
|
+
try {
|
|
506
|
+
routes = llm.listProviders() ?? [];
|
|
507
|
+
} catch (error) {
|
|
508
|
+
logger?.debug?.(`[tool-vision] listProviders failed: ${String(error)}`);
|
|
509
|
+
}
|
|
510
|
+
const providers = [];
|
|
511
|
+
for (const entry of routes) {
|
|
512
|
+
const id = entry?.id;
|
|
513
|
+
if (typeof id !== "string" || id.length === 0) continue;
|
|
514
|
+
const record = {
|
|
515
|
+
id,
|
|
516
|
+
name: typeof entry?.name === "string" && entry.name.length > 0 ? entry.name : id,
|
|
517
|
+
models: [],
|
|
518
|
+
};
|
|
519
|
+
try {
|
|
520
|
+
const models = await llm.listModels(id);
|
|
521
|
+
record.models = (models ?? []).slice(0, MODEL_CATALOG_MAX_PER_PROVIDER).map((model) => {
|
|
522
|
+
const modelId = String(model.id);
|
|
523
|
+
const matchedEntries = modelListMatchEntries(list, id, modelId);
|
|
524
|
+
return {
|
|
525
|
+
id: modelId,
|
|
526
|
+
name: typeof model.name === "string" && model.name.length > 0 ? model.name : modelId,
|
|
527
|
+
image: Array.isArray(model.inputModalities) && model.inputModalities.includes("image"),
|
|
528
|
+
listed: matchedEntries.length > 0,
|
|
529
|
+
matchedEntries,
|
|
530
|
+
// What the route was MEASURED doing, when anyone has probed it.
|
|
531
|
+
// `null` means "not probed", which the panel must render
|
|
532
|
+
// differently from a probe that came back negative.
|
|
533
|
+
probe: probeVerdict(cfg, id, modelId) ?? null,
|
|
534
|
+
};
|
|
535
|
+
});
|
|
536
|
+
} catch (error) {
|
|
537
|
+
// One broken route must never blank the whole panel: report it and
|
|
538
|
+
// keep going, so the other providers still list.
|
|
539
|
+
record.error = String(error?.message ?? error);
|
|
540
|
+
}
|
|
541
|
+
providers.push(record);
|
|
542
|
+
}
|
|
543
|
+
send(200, {
|
|
544
|
+
current: { ...lastModelDecision },
|
|
545
|
+
list,
|
|
546
|
+
listMode,
|
|
547
|
+
probeResults: typeof getConfig === "function" ? { ...(getConfig()?.probeResults ?? {}) } : {},
|
|
548
|
+
providers,
|
|
549
|
+
});
|
|
550
|
+
},
|
|
551
|
+
}), "dsh-tool-vision: model catalog route");
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Accepted `multimodalListMode` values; anything else falls back to whitelist. */
|
|
555
|
+
const MULTIMODAL_LIST_MODES = ["off", "whitelist", "blacklist"];
|
|
556
|
+
const DEFAULT_MULTIMODAL_LIST_MODE = "whitelist";
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Normalize a configured list mode. The settings section only ever writes one
|
|
560
|
+
* of {@link MULTIMODAL_LIST_MODES}, but the value can also arrive from a
|
|
561
|
+
* hand-edited `settings.yaml`, so an unknown string is clamped rather than
|
|
562
|
+
* treated as "no mode" (which would silently bridge everything).
|
|
563
|
+
*/
|
|
564
|
+
function normalizeListMode(value) {
|
|
565
|
+
return MULTIMODAL_LIST_MODES.includes(value) ? value : DEFAULT_MULTIMODAL_LIST_MODE;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
const listEntryCache = new Map();
|
|
569
|
+
/**
|
|
570
|
+
* Compile one list entry into an anchored, case-insensitive regexp. A literal
|
|
571
|
+
* entry is just an exact match; `*` and `?` are the only metacharacters, so a
|
|
572
|
+
* model id containing `.` or `+` still matches itself.
|
|
573
|
+
*/
|
|
574
|
+
function listEntryRegExp(entry) {
|
|
575
|
+
const cached = listEntryCache.get(entry);
|
|
576
|
+
if (cached !== undefined) return cached;
|
|
577
|
+
const source = entry
|
|
578
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
579
|
+
.replace(/\*/g, ".*")
|
|
580
|
+
.replace(/\?/g, ".");
|
|
581
|
+
const compiled = new RegExp(`^${source}$`, "i");
|
|
582
|
+
listEntryCache.set(entry, compiled);
|
|
583
|
+
return compiled;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Whether any entry in `patterns` names this route. Each entry is tested
|
|
588
|
+
* against the full model id, its bare id after the last `/`, and
|
|
589
|
+
* `provider/id` — so `mimo-v2.5`, `xiaomi/mimo-v2.5` and
|
|
590
|
+
* `commandcode/xiaomi/mimo-v2.5` all address the same route, and `*vl*`
|
|
591
|
+
* addresses every variant of one family. Exported for unit testing.
|
|
592
|
+
*/
|
|
593
|
+
function modelListMatches(patterns, provider, model) {
|
|
594
|
+
return modelListMatchEntries(patterns, provider, model).length > 0;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* The list entries that address this route. The panel needs *which* entry hit
|
|
599
|
+
* so that unchecking a model removes exactly the entry responsible — matching
|
|
600
|
+
* `mimo-v2.5` against `xiaomi/mimo-v2.5` must not leave an orphan behind, and
|
|
601
|
+
* must not delete an unrelated entry either. Exported for unit testing.
|
|
602
|
+
*/
|
|
603
|
+
function modelListMatchEntries(patterns, provider, model) {
|
|
604
|
+
if (!Array.isArray(patterns) || patterns.length === 0) return [];
|
|
605
|
+
if (typeof model !== "string" || model.length === 0) return [];
|
|
606
|
+
const slash = model.lastIndexOf("/");
|
|
607
|
+
const candidates = [model, slash === -1 ? model : model.slice(slash + 1)];
|
|
608
|
+
if (typeof provider === "string" && provider.length > 0) candidates.push(`${provider}/${model}`);
|
|
609
|
+
const hits = [];
|
|
610
|
+
for (const raw of patterns) {
|
|
611
|
+
if (raw === undefined || raw === null) continue;
|
|
612
|
+
const entry = String(raw).trim();
|
|
613
|
+
if (entry.length === 0) continue;
|
|
614
|
+
const re = listEntryRegExp(entry);
|
|
615
|
+
if (candidates.some((candidate) => re.test(candidate))) hits.push(String(raw));
|
|
616
|
+
}
|
|
617
|
+
return hits;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* The route the session is on: the logged request header first, then the
|
|
622
|
+
* agent's own options. `provider` is optional (older logs may lack it).
|
|
623
|
+
*/
|
|
624
|
+
function currentRoute(agent) {
|
|
625
|
+
const header = agent?.session?.requestHeader?.();
|
|
626
|
+
return {
|
|
627
|
+
provider: header?.config?.provider ?? agent?.options?.provider,
|
|
628
|
+
model: header?.config?.model ?? agent?.options?.model,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* The last decision `currentModelAcceptsImage` made, for the settings panel's
|
|
634
|
+
* "current route" readout (served over {@link MODEL_CATALOG_ROUTE}). A plain
|
|
635
|
+
* mutable record — the panel only ever reads it.
|
|
636
|
+
*/
|
|
637
|
+
const lastModelDecision = {
|
|
638
|
+
provider: undefined,
|
|
639
|
+
model: undefined,
|
|
640
|
+
direct: false,
|
|
641
|
+
source: "none",
|
|
642
|
+
mode: DEFAULT_MULTIMODAL_LIST_MODE,
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* `resolveModelInfo` as it was before {@link installAutoImageAdmission}
|
|
647
|
+
* wrapped it. Auto-detection MUST read through this: the wrap reports image
|
|
648
|
+
* support for *every* model, so reading the wrapped method would make the
|
|
649
|
+
* plugin's own claim the evidence for itself.
|
|
650
|
+
*/
|
|
651
|
+
function unwrappedResolveModelInfo(llm) {
|
|
652
|
+
const marker = llm?.[LLM_RESOLVE_WRAP_MARK];
|
|
653
|
+
const fn = marker?.original ?? llm?.resolveModelInfo;
|
|
654
|
+
return typeof fn === "function" ? fn.bind(llm) : undefined;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Whether the route declares `image` input, read before the admission wrap.
|
|
659
|
+
* `false` and `undefined` are the same answer to the caller ("not known to be
|
|
660
|
+
* multimodal"): a route nobody could resolve is bridged, never guessed.
|
|
661
|
+
*/
|
|
662
|
+
async function routeDeclaresImage(llm, provider, model) {
|
|
663
|
+
const resolve = unwrappedResolveModelInfo(llm);
|
|
664
|
+
if (resolve === undefined || typeof provider !== "string" || typeof model !== "string") {
|
|
665
|
+
return false;
|
|
666
|
+
}
|
|
667
|
+
try {
|
|
668
|
+
const info = await resolve(provider, model);
|
|
669
|
+
return info?.inputModalities?.includes("image") === true;
|
|
670
|
+
} catch {
|
|
671
|
+
return false;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** Routes already warned about, so the notice appears once per route. */
|
|
676
|
+
const autoPromotionWarned = new Set();
|
|
677
|
+
|
|
678
|
+
/** Registry key for one measured route. Provider-qualified: the same model id
|
|
679
|
+
* can be served by two gateways with different real capabilities. */
|
|
680
|
+
function probeKey(provider, model) {
|
|
681
|
+
return `${typeof provider === "string" ? provider : ""}/${model}`;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
* The measured verdict for a route, or `undefined` when it was never probed.
|
|
686
|
+
* Anything other than the two recorded values (a hand-edited settings.yaml, a
|
|
687
|
+
* future format) reads as "not probed" rather than as a guess.
|
|
688
|
+
*/
|
|
689
|
+
function probeVerdict(config, provider, model) {
|
|
690
|
+
const results = config?.probeResults;
|
|
691
|
+
if (results === undefined || results === null || typeof model !== "string" || model.length === 0) {
|
|
692
|
+
return undefined;
|
|
693
|
+
}
|
|
694
|
+
const value = results[probeKey(provider, model)];
|
|
695
|
+
return value === "yes" || value === "no" ? value : undefined;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Resolve the endpoint and credential a route actually sends to, so a probe
|
|
700
|
+
* talks to the same place the model does instead of a copy of it.
|
|
701
|
+
*
|
|
702
|
+
* The pieces come from two seams that a plugin is allowed to read:
|
|
703
|
+
* - `llm.listConfigurableProviders()` names the settings namespace and the
|
|
704
|
+
* path a provider's profile lives at;
|
|
705
|
+
* - `settings.get(ns)` resolves that profile (`baseURL`, `apiKeyEnv`, `api`);
|
|
706
|
+
* - `credentials.resolve(ref)` turns `apiKeyEnv` into the secret itself
|
|
707
|
+
* (`.value`), the same way `dsh-llm-pi-ai` does it for a real call.
|
|
708
|
+
*
|
|
709
|
+
* Read-only, and the secret never leaves this function's caller: the probe
|
|
710
|
+
* result records a verdict, never an endpoint or a key.
|
|
711
|
+
*
|
|
712
|
+
* @throws {Error} with a caller-facing reason when the route is unprobeable.
|
|
713
|
+
*/
|
|
714
|
+
async function resolveProbeTarget(ctx, provider, model) {
|
|
715
|
+
const llm = ctx.get("llm");
|
|
716
|
+
const settings = ctx.get("settings");
|
|
717
|
+
const credentials = ctx.get("credentials");
|
|
718
|
+
if (settings === undefined) {
|
|
719
|
+
throw new Error("the settings service is unavailable, so the route's endpoint cannot be read");
|
|
720
|
+
}
|
|
721
|
+
let entry;
|
|
722
|
+
try {
|
|
723
|
+
entry = (llm?.listConfigurableProviders?.() ?? []).find((candidate) => candidate?.provider === provider);
|
|
724
|
+
} catch {
|
|
725
|
+
/* an adapter that cannot describe its providers simply has no entry */
|
|
726
|
+
}
|
|
727
|
+
const settingsNs = entry?.settingsNs;
|
|
728
|
+
if (typeof settingsNs !== "string" || settingsNs.length === 0) {
|
|
729
|
+
throw new Error(
|
|
730
|
+
`provider "${provider}" does not publish a settings namespace, so its endpoint cannot be resolved for probing`,
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
let profile = settings.get(settingsNs);
|
|
734
|
+
const path = Array.isArray(entry.settingsPath) && entry.settingsPath.length > 0 ? entry.settingsPath : [provider];
|
|
735
|
+
for (const segment of path) profile = profile?.[segment];
|
|
736
|
+
if (profile === undefined || profile === null || typeof profile !== "object") {
|
|
737
|
+
throw new Error(`no stored profile for provider "${provider}" under "${settingsNs}"`);
|
|
738
|
+
}
|
|
739
|
+
const baseURL = typeof profile.baseURL === "string" && profile.baseURL.length > 0 ? profile.baseURL : undefined;
|
|
740
|
+
if (baseURL === undefined) {
|
|
741
|
+
throw new Error(`provider "${provider}" has no baseURL, so its endpoint cannot be probed`);
|
|
742
|
+
}
|
|
743
|
+
let apiKey;
|
|
744
|
+
const ref = profile.apiKeyEnv;
|
|
745
|
+
if (typeof ref === "string" && ref.length > 0) {
|
|
746
|
+
try {
|
|
747
|
+
const record = await credentials?.resolve?.(ref);
|
|
748
|
+
apiKey = typeof record?.value === "string" ? record.value : undefined;
|
|
749
|
+
} catch (error) {
|
|
750
|
+
throw new Error(`stored credential "${ref}" could not be read: ${String(error?.message ?? error)}`);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return { baseURL, apiKey, api: profile.api, model };
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* One-time notice when a route receives images directly *only* because of its
|
|
758
|
+
* own declaration (source `auto`). This is the one failure auto-detection
|
|
759
|
+
* cannot rule out: if the declaration is wrong, the endpoint rejects the image
|
|
760
|
+
* after the message is already durable, and the error alone does not say what
|
|
761
|
+
* to change. Naming the escape hatch the first time it happens turns a
|
|
762
|
+
* confusing 400 into a one-line fix — and now names the probe too, which
|
|
763
|
+
* settles the question instead of asking the user to guess.
|
|
764
|
+
* Read {@link lastModelDecision}; never throws.
|
|
765
|
+
*/
|
|
766
|
+
function warnAutoPromotion(logger) {
|
|
767
|
+
const { provider, model, direct, source } = lastModelDecision;
|
|
768
|
+
if (direct !== true || source !== "auto" || typeof model !== "string" || model.length === 0) return;
|
|
769
|
+
const key = `${typeof provider === "string" ? provider : ""}\u0000${model}`;
|
|
770
|
+
if (autoPromotionWarned.has(key)) return;
|
|
771
|
+
autoPromotionWarned.add(key);
|
|
772
|
+
logger?.warn?.(
|
|
773
|
+
`[tool-vision] "${model}" receives images directly because its route declares image input, ` +
|
|
774
|
+
`which is unverified; run vision_probe_model to measure it, or list it in multimodalModels ` +
|
|
775
|
+
`under multimodalListMode: blacklist to force the bridge`,
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
|
|
375
779
|
/**
|
|
376
780
|
* Whether the session's current model may receive image blocks directly.
|
|
377
|
-
*
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
*
|
|
781
|
+
*
|
|
782
|
+
* Two inputs, deliberately independent:
|
|
783
|
+
*
|
|
784
|
+
* - the base set — the route's own declared `inputModalities`, and only when
|
|
785
|
+
* `autoDetectMultimodal` is on. It is read through
|
|
786
|
+
* {@link unwrappedResolveModelInfo}, never through the admission wrap.
|
|
787
|
+
* - the list — `multimodalModels` under `multimodalListMode`, and the list is
|
|
788
|
+
* always explicit user intent, so it outranks the declaration: whitelist
|
|
789
|
+
* adds to the base set, blacklist subtracts from it.
|
|
790
|
+
*
|
|
383
791
|
* Returns true when bridging is disabled (nothing would be bridged anyway).
|
|
384
792
|
*/
|
|
385
|
-
async function currentModelAcceptsImage(agent, config) {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
793
|
+
async function currentModelAcceptsImage(agent, config, llm) {
|
|
794
|
+
const mode = normalizeListMode(config.multimodalListMode);
|
|
795
|
+
if (!config.bridgeTextOnly) {
|
|
796
|
+
Object.assign(lastModelDecision, { ...currentRoute(agent), direct: true, source: "bridge-off", mode });
|
|
797
|
+
return true;
|
|
798
|
+
}
|
|
799
|
+
const { provider, model } = currentRoute(agent);
|
|
800
|
+
if (!model) {
|
|
801
|
+
Object.assign(lastModelDecision, { provider, model, direct: false, source: "no-route", mode });
|
|
802
|
+
return false;
|
|
803
|
+
}
|
|
804
|
+
const base = config.autoDetectMultimodal
|
|
805
|
+
? await routeDeclaresImage(llm, provider, model)
|
|
806
|
+
: false;
|
|
807
|
+
let direct = base;
|
|
808
|
+
let source = base ? "auto" : "default";
|
|
809
|
+
// A measured verdict outranks the route's own claim: a probe is a real
|
|
810
|
+
// request, a declaration is only a statement. Both directions count, so a
|
|
811
|
+
// route that says "image" and answered without reading the pixels goes back
|
|
812
|
+
// to the bridge without anyone hunting for the right list entry.
|
|
813
|
+
const probed = probeVerdict(config, provider, model);
|
|
814
|
+
if (probed !== undefined) {
|
|
815
|
+
direct = probed === "yes";
|
|
816
|
+
source = probed === "yes" ? "probe-yes" : "probe-no";
|
|
817
|
+
}
|
|
818
|
+
// `off` means the list carries no opinion at all — it is not "empty list
|
|
819
|
+
// under whitelist", it is "no list semantics", so nothing is forced. The
|
|
820
|
+
// human list keeps the last word over both other signals.
|
|
821
|
+
const listed = mode !== "off" && modelListMatches(config.multimodalModels, provider, model);
|
|
822
|
+
if (listed) {
|
|
823
|
+
direct = mode !== "blacklist";
|
|
824
|
+
source = mode === "blacklist" ? "blacklist" : "whitelist";
|
|
825
|
+
}
|
|
826
|
+
Object.assign(lastModelDecision, { provider, model, direct, source, mode });
|
|
827
|
+
return direct;
|
|
391
828
|
}
|
|
392
829
|
|
|
393
830
|
/**
|
|
@@ -443,7 +880,8 @@ function attachPreStepBridge(ctx, getConfig, exportDir) {
|
|
|
443
880
|
const agent = payload?.agent;
|
|
444
881
|
if (!agent?.session) return decision;
|
|
445
882
|
try {
|
|
446
|
-
const acceptsImage = await currentModelAcceptsImage(agent, getConfig());
|
|
883
|
+
const acceptsImage = await currentModelAcceptsImage(agent, getConfig(), ctx.get("llm"));
|
|
884
|
+
warnAutoPromotion(ctx.logger);
|
|
447
885
|
if (!acceptsImage) {
|
|
448
886
|
let repaired = repairedBySession.get(agent.session.id);
|
|
449
887
|
if (!repaired) {
|
|
@@ -570,6 +1008,11 @@ function apply(ctx, config) {
|
|
|
570
1008
|
let current = config;
|
|
571
1009
|
let sourceGetter = null;
|
|
572
1010
|
const getConfig = () => (sourceGetter ? sourceGetter() : current);
|
|
1011
|
+
// The registered settings scope, kept so tools can PERSIST what they measure.
|
|
1012
|
+
// Null whenever no settings provider is mounted (composition-entry-only runs),
|
|
1013
|
+
// in which case a probe still reports its verdict but cannot record it.
|
|
1014
|
+
let settingsScope = null;
|
|
1015
|
+
const getSettingsScope = () => settingsScope;
|
|
573
1016
|
|
|
574
1017
|
// ── master switch: a child fiber owns every registration ──────────────────
|
|
575
1018
|
// `ctx.tools.register`, `ctx.effect` and `ctx.on` are all effects on the fiber
|
|
@@ -671,6 +1114,124 @@ function apply(ctx, config) {
|
|
|
671
1114
|
// (baseURL/apiKey/model). No provider chain, no local models, no extra
|
|
672
1115
|
// settings: everything comes from the existing tool-vision configuration.
|
|
673
1116
|
registerVisionTools(inner, getConfig);
|
|
1117
|
+
|
|
1118
|
+
// ── ground-truth capability probe (v0.9.0) ───────────────────────────
|
|
1119
|
+
// Everything else decides from what a route SAYS; this measures what it
|
|
1120
|
+
// DOES, and records the answer so the bridge decision stops guessing.
|
|
1121
|
+
inner.tools.register(defineTool({
|
|
1122
|
+
name: "vision_probe_model",
|
|
1123
|
+
description:
|
|
1124
|
+
"Measure whether a model's endpoint can actually read images, by sending it a real " +
|
|
1125
|
+
"solid-color image and checking the answer. Use this when a model's declared image " +
|
|
1126
|
+
"support is doubtful (it claims `image` but the provider may reject it, or it declares " +
|
|
1127
|
+
"text-only yet the endpoint may accept images). Runs a text-only control request plus " +
|
|
1128
|
+
"two different colors, so a model that merely guesses cannot pass. The verdict is " +
|
|
1129
|
+
"recorded and from then on decides whether that route's images are sent directly or " +
|
|
1130
|
+
"bridged. Costs a few requests against the model's own endpoint.",
|
|
1131
|
+
parameters: {
|
|
1132
|
+
model: {
|
|
1133
|
+
type: "string",
|
|
1134
|
+
required: true,
|
|
1135
|
+
description: "Model id to probe, e.g. xiaomi/mimo-v2.5. Use the id exactly as dsh has it configured.",
|
|
1136
|
+
},
|
|
1137
|
+
provider: {
|
|
1138
|
+
type: "string",
|
|
1139
|
+
description:
|
|
1140
|
+
"Provider route serving the model (e.g. commandcode). Omit to use the current " +
|
|
1141
|
+
"session's route, or to auto-select when exactly one provider offers the model.",
|
|
1142
|
+
},
|
|
1143
|
+
},
|
|
1144
|
+
output: {
|
|
1145
|
+
schema: { type: "string" },
|
|
1146
|
+
render: (_args, value) => [{ type: "text", text: value }],
|
|
1147
|
+
},
|
|
1148
|
+
async execute(args, exec) {
|
|
1149
|
+
const cfg = getConfig();
|
|
1150
|
+
const requested = String(args.model ?? "").trim();
|
|
1151
|
+
if (requested.length === 0) throw new Error("vision_probe_model: model is required");
|
|
1152
|
+
const llm = inner.get("llm");
|
|
1153
|
+
|
|
1154
|
+
// Resolve which routes offer this model, so an omitted `provider`
|
|
1155
|
+
// is still an exact choice rather than a guess.
|
|
1156
|
+
let candidates = [];
|
|
1157
|
+
try {
|
|
1158
|
+
for (const entry of llm?.listProviders?.() ?? []) {
|
|
1159
|
+
const id = entry?.id;
|
|
1160
|
+
if (typeof id !== "string") continue;
|
|
1161
|
+
const models = await llm.listModels(id).catch(() => []);
|
|
1162
|
+
if ((models ?? []).some((model) => String(model?.id) === requested)) candidates.push(id);
|
|
1163
|
+
}
|
|
1164
|
+
} catch {
|
|
1165
|
+
/* fall through to the route/argument fallbacks below */
|
|
1166
|
+
}
|
|
1167
|
+
let provider = typeof args.provider === "string" && args.provider.length > 0 ? args.provider : undefined;
|
|
1168
|
+
if (provider === undefined) {
|
|
1169
|
+
const here = currentRoute(exec?.agent);
|
|
1170
|
+
if (candidates.length === 1) provider = candidates[0];
|
|
1171
|
+
else if (here.model === requested && typeof here.provider === "string") provider = here.provider;
|
|
1172
|
+
else if (candidates.length > 1) {
|
|
1173
|
+
throw new Error(
|
|
1174
|
+
`vision_probe_model: "${requested}" is served by ${candidates.join(", ")}; pass provider explicitly`,
|
|
1175
|
+
);
|
|
1176
|
+
} else {
|
|
1177
|
+
provider = here.provider;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
if (typeof provider !== "string" || provider.length === 0) {
|
|
1181
|
+
throw new Error(
|
|
1182
|
+
`vision_probe_model: cannot tell which provider serves "${requested}"; pass provider explicitly`,
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
const target = await resolveProbeTarget(inner, provider, requested);
|
|
1187
|
+
const outcome = await probeModelCapability(
|
|
1188
|
+
{
|
|
1189
|
+
baseURL: target.baseURL,
|
|
1190
|
+
apiKey: target.apiKey,
|
|
1191
|
+
api: target.api,
|
|
1192
|
+
model: requested,
|
|
1193
|
+
maxTokens: PROBE_MAX_TOKENS,
|
|
1194
|
+
timeoutMs: Math.max(PROBE_TIMEOUT_MS, Number(cfg.timeoutMs) || 0),
|
|
1195
|
+
headers: sessionHeaders(cfg, exec),
|
|
1196
|
+
},
|
|
1197
|
+
exec.signal,
|
|
1198
|
+
);
|
|
1199
|
+
|
|
1200
|
+
const key = probeKey(provider, requested);
|
|
1201
|
+
let recorded = "not recorded (no settings provider)";
|
|
1202
|
+
if (outcome.support === "yes" || outcome.support === "no") {
|
|
1203
|
+
const scope = getSettingsScope();
|
|
1204
|
+
if (scope !== null) {
|
|
1205
|
+
try {
|
|
1206
|
+
await scope.update({ probeResults: { ...(cfg.probeResults ?? {}), [key]: outcome.support } });
|
|
1207
|
+
recorded = `recorded as "${outcome.support}"`;
|
|
1208
|
+
} catch (error) {
|
|
1209
|
+
recorded = `could not be recorded: ${String(error?.message ?? error)}`;
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
const verdictText = outcome.support === "yes"
|
|
1215
|
+
? "SUPPORTS images"
|
|
1216
|
+
: outcome.support === "no"
|
|
1217
|
+
? "does NOT read images"
|
|
1218
|
+
: "INCONCLUSIVE (the route could not be probed)";
|
|
1219
|
+
const effect = outcome.support === "yes"
|
|
1220
|
+
? "images will be sent to it directly"
|
|
1221
|
+
: outcome.support === "no"
|
|
1222
|
+
? "images will be bridged to inspect_image hints"
|
|
1223
|
+
: "the existing list/declaration rules still apply";
|
|
1224
|
+
const evidence = Object.entries(outcome.evidence ?? {})
|
|
1225
|
+
.map(([name, value]) => ` ${name}: ${value}`)
|
|
1226
|
+
.join("\n");
|
|
1227
|
+
return [
|
|
1228
|
+
`${provider}/${requested}: ${verdictText}`,
|
|
1229
|
+
` ${outcome.detail}`,
|
|
1230
|
+
` ${recorded}; ${effect}`,
|
|
1231
|
+
evidence ? ` evidence:\n${evidence}` : "",
|
|
1232
|
+
].filter(Boolean).join("\n");
|
|
1233
|
+
},
|
|
1234
|
+
}));
|
|
674
1235
|
},
|
|
675
1236
|
});
|
|
676
1237
|
}
|
|
@@ -704,8 +1265,10 @@ function apply(ctx, config) {
|
|
|
704
1265
|
ctx.inject(["settings"], (sctx) => {
|
|
705
1266
|
const scope = sctx.settings.register(NS, Config, { base: config });
|
|
706
1267
|
sourceGetter = () => scope.get();
|
|
1268
|
+
settingsScope = scope;
|
|
707
1269
|
sctx.effect(() => () => {
|
|
708
1270
|
sourceGetter = null;
|
|
1271
|
+
settingsScope = null;
|
|
709
1272
|
});
|
|
710
1273
|
// Hot-apply: `enabled` switches the child fiber, and any field that gates a
|
|
711
1274
|
// registration re-installs it, so none of them needs a dsh restart anymore.
|
|
@@ -716,6 +1279,14 @@ function apply(ctx, config) {
|
|
|
716
1279
|
syncFeatures();
|
|
717
1280
|
});
|
|
718
1281
|
|
|
1282
|
+
// ── model catalog route: settings support, so it lives on THIS fiber ───────
|
|
1283
|
+
// The master switch disposes the feature fiber on purpose (tools, bridge,
|
|
1284
|
+
// preview route), while the settings section itself deliberately stays
|
|
1285
|
+
// mounted — and a section whose list field cannot autocomplete is a section
|
|
1286
|
+
// you cannot configure before switching the plugin on. Registered here so it
|
|
1287
|
+
// outlives `enabled: false`; read-only and secret-free by construction.
|
|
1288
|
+
registerModelCatalogRoute(ctx, ctx.logger, getConfig);
|
|
1289
|
+
|
|
719
1290
|
// No settings provider: the composition entry is the whole configuration.
|
|
720
1291
|
syncFeatures();
|
|
721
1292
|
}
|
|
@@ -727,18 +1298,35 @@ export {
|
|
|
727
1298
|
BRIDGE_PREVIEW_ROUTE,
|
|
728
1299
|
Config,
|
|
729
1300
|
DEFAULT_DESCRIPTION,
|
|
1301
|
+
DEFAULT_MULTIMODAL_LIST_MODE,
|
|
730
1302
|
EXT_BY_MEDIA,
|
|
1303
|
+
MODEL_CATALOG_MAX_PER_PROVIDER,
|
|
1304
|
+
MODEL_CATALOG_ROUTE,
|
|
1305
|
+
MULTIMODAL_LIST_MODES,
|
|
731
1306
|
apply,
|
|
732
1307
|
attachPreStepBridge,
|
|
1308
|
+
autoPromotionWarned,
|
|
733
1309
|
bridgeMessages,
|
|
734
1310
|
currentModelAcceptsImage,
|
|
1311
|
+
currentRoute,
|
|
735
1312
|
deepFreeze,
|
|
736
1313
|
exportImage,
|
|
737
1314
|
hasImageBlock,
|
|
738
1315
|
inject,
|
|
739
1316
|
installAutoImageAdmission,
|
|
1317
|
+
lastModelDecision,
|
|
1318
|
+
modelListMatches,
|
|
1319
|
+
modelListMatchEntries,
|
|
740
1320
|
name,
|
|
1321
|
+
normalizeListMode,
|
|
741
1322
|
parseQuery,
|
|
1323
|
+
probeKey,
|
|
1324
|
+
probeVerdict,
|
|
742
1325
|
registerBridgePreviewRoute,
|
|
1326
|
+
registerModelCatalogRoute,
|
|
743
1327
|
repairLoggedImages,
|
|
1328
|
+
resolveProbeTarget,
|
|
1329
|
+
routeDeclaresImage,
|
|
1330
|
+
unwrappedResolveModelInfo,
|
|
1331
|
+
warnAutoPromotion,
|
|
744
1332
|
};
|