dsh-tool-vision 0.8.0 → 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/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`. Models listed in
23
- * `multimodalModels` (or whose resolved `inputModalities` include
24
- * "image") receive image blocks directly and are never bridged.
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
- "since the main model is text-only.";
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
- /** Model ids that receive image blocks directly (never bridged). */
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
- llm[LLM_RESOLVE_WRAP_MARK] = true;
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
- * Uses the last logged request header first, then the agent's own options,
378
- * and consults ONLY the `multimodalModels` whitelist — never the model's
379
- * declared `inputModalities`, because profiles routinely declare
380
- * `input: [text, image]` on text-only models to pass the harness's prompt
381
- * admission check (that declaration says nothing about whether the upstream
382
- * endpoint really accepts `image_url` parts).
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
- if (!config.bridgeTextOnly) return true;
387
- const header = agent?.session?.requestHeader?.();
388
- const model = header?.config?.model ?? agent?.options?.model;
389
- if (!model) return false;
390
- return config.multimodalModels.includes(model);
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
  };