dsh-vision-fallback 0.6.0 → 0.7.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 CHANGED
@@ -30,6 +30,13 @@ const DEFAULT_PROMPT = [
30
30
 
31
31
  const Config = z.object({
32
32
  enabled: z.boolean().default(true),
33
+ /**
34
+ * auto:仅当主模型不支持图片输入时才接管(默认)。主模型自带视觉
35
+ * (如 kimi-k3、mimo-v2.5)时图片原样交给它,不再转文字。
36
+ * always:无论主模型是否支持图片都转成文字(旧行为)。
37
+ * never:完全不接管(等于停用视觉桥;纯文本模型收图会照常报"模型不支持")。
38
+ */
39
+ mode: z.union(["auto", "always", "never"]).default("auto"),
33
40
  model: z.string().default("mimo-v2.5"),
34
41
  baseURL: z.string().default("https://opencode.ai/zen/go/v1"),
35
42
  apiKeyRef: z.string().role("credential-ref").default("OPENCODE_GO_API_KEY"),
@@ -229,7 +236,7 @@ function assertVisionConfig(cfg) {
229
236
  }
230
237
 
231
238
  function withImageCapability(info, cfg) {
232
- if (!cfg.enabled) return info;
239
+ if (!cfg.enabled || cfg.mode === "never") return info;
233
240
  const inputModalities = Array.isArray(info?.inputModalities) ? info.inputModalities : ["text"];
234
241
  if (inputModalities.includes("image")) return info;
235
242
  return { ...info, inputModalities: [...new Set([...inputModalities, "text", "image"])] };
@@ -488,7 +495,11 @@ class ReplacementCoordinator {
488
495
  }
489
496
  }
490
497
 
498
+ /** 原始的 resolveModelInfo 实现(绑定原持有者,未被能力覆盖污染)。 */
499
+ let rawResolveModelInfo = null;
500
+
491
501
  function installCapabilityOverride(ctx, current) {
502
+ rawResolveModelInfo = ctx.llm.resolveModelInfo.bind(ctx.llm);
492
503
  const previous = ctx.llm.resolveModelInfo;
493
504
  const overridden = async function (provider, model, signal) {
494
505
  const info = await previous.call(this, provider, model, signal);
@@ -497,9 +508,25 @@ function installCapabilityOverride(ctx, current) {
497
508
  ctx.llm.resolveModelInfo = overridden;
498
509
  return () => {
499
510
  if (ctx.llm.resolveModelInfo === overridden) ctx.llm.resolveModelInfo = previous;
511
+ rawResolveModelInfo = null;
500
512
  };
501
513
  }
502
514
 
515
+ /**
516
+ * 用未被覆盖的 resolveModelInfo 查询主模型的真实图片能力。
517
+ * 解析失败保守返回 false(沿用接管路径,至少不会让请求挂掉)。
518
+ */
519
+ async function modelActuallySupportsImage(provider, model, signal) {
520
+ if (typeof provider !== "string" || typeof model !== "string" || provider === "" || model === "") return false;
521
+ if (typeof rawResolveModelInfo !== "function") return false;
522
+ try {
523
+ const info = await rawResolveModelInfo(provider, model, signal);
524
+ return Array.isArray(info?.inputModalities) && info.inputModalities.includes("image");
525
+ } catch {
526
+ return false;
527
+ }
528
+ }
529
+
503
530
  function apply(ctx, config) {
504
531
  let current = () => config;
505
532
  let settingsService;
@@ -515,6 +542,15 @@ function apply(ctx, config) {
515
542
  ctx.on("agent/pre-step", async ({ agent, signal }, next) => {
516
543
  const decision = await next();
517
544
  if (decision.kind === "reject" || signal.aborted) return decision;
545
+ const cfg = current();
546
+ if (!cfg.enabled || cfg.mode === "never") return decision;
547
+ // auto 模式:主模型自带视觉能力时让它直接看原图,不接管。
548
+ if (cfg.mode === "auto") {
549
+ const header = typeof agent?.session?.requestHeader === "function" ? agent.session.requestHeader() : undefined;
550
+ const provider = header?.config?.provider ?? agent?.options?.provider;
551
+ const model = header?.config?.model ?? agent?.options?.model;
552
+ if (await modelActuallySupportsImage(provider, model, signal)) return decision;
553
+ }
518
554
  const modelMessages = [...agent.session.deriveMessages(), ...decision.messages];
519
555
  const rewritten = await controller.preprocess(modelMessages, modelMessages, signal);
520
556
  replacements.stage(agent.session, modelMessages, rewritten);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vision-fallback",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "DSH 静默视觉增强:主模型照常选择,图片自动交给固定视觉模型后以隐藏上下文返回主模型。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -487,3 +487,93 @@ test("浏览器端只显示静默视觉配置", async () => {
487
487
  globalThis.fetch = previousFetch;
488
488
  }
489
489
  });
490
+
491
+ test("auto 模式按主模型真实视觉能力决定是否接管", async () => {
492
+ const imageBlock = { type: "image", attachment: { attachmentId: "sha256:test", mediaType: "image/png" } };
493
+ let visionRequests = 0;
494
+ const previousFetch = globalThis.fetch;
495
+ globalThis.fetch = async () => {
496
+ visionRequests += 1;
497
+ return {
498
+ ok: true,
499
+ status: 200,
500
+ json: async () => ({ choices: [{ message: { content: "红色" } }] })
501
+ };
502
+ };
503
+
504
+ const makeSignal = () => ({ aborted: false, addEventListener() {}, removeEventListener() {} });
505
+ const makeCtx = (realModalities) => {
506
+ let preStep;
507
+ const ctx = {
508
+ llm: {
509
+ resolveModelInfo: async () => ({ provider: "opencode-go", id: "x", name: "x", inputModalities: realModalities })
510
+ },
511
+ logger: { error() {} },
512
+ effect() {},
513
+ on(name, handler) {
514
+ if (name === "agent/pre-step") preStep = handler;
515
+ },
516
+ inject(deps, cb) {
517
+ if (deps[0] === "settings") {
518
+ cb({
519
+ settings: {
520
+ register: (_ns, _schema, options) => ({ get: () => options.base, watch() {} })
521
+ },
522
+ effect() {}
523
+ });
524
+ } else {
525
+ cb({ webServer: { register: () => () => {} }, effect() {} });
526
+ }
527
+ },
528
+ get(name) {
529
+ if (name === "credentials") return { resolve: async () => ({ value: "sk-test" }) };
530
+ if (name === "attachments") return { readImage: async () => ({ data: Buffer.from("test"), ref: { mediaType: "image/png" } }) };
531
+ return undefined;
532
+ }
533
+ };
534
+ return { ctx, preStep: () => preStep };
535
+ };
536
+
537
+ // 1) 主模型自带视觉 → 不接管,视觉 API 不被调用
538
+ const vision = makeCtx(["text", "image"]);
539
+ apply(vision.ctx, { ...config, mode: "auto" });
540
+ const messages1 = [{ id: "m1", role: "user", source: { kind: "user" }, content: [{ type: "text", text: "看图" }, imageBlock] }];
541
+ const decision1 = { kind: "enter", messages: messages1 };
542
+ const out1 = await vision.preStep()(
543
+ {
544
+ agent: {
545
+ options: { provider: "opencode-go", model: "kimi-k3" },
546
+ session: {
547
+ requestHeader: () => ({ config: { provider: "opencode-go", model: "kimi-k3" } }),
548
+ deriveMessages: () => []
549
+ }
550
+ },
551
+ signal: makeSignal()
552
+ },
553
+ () => decision1
554
+ );
555
+ assert.equal(out1, decision1);
556
+ assert.equal(visionRequests, 0);
557
+
558
+ // 2) 纯文本主模型 → 接管,视觉 API 被调用一次
559
+ const text = makeCtx(["text"]);
560
+ apply(text.ctx, { ...config, mode: "auto" });
561
+ const messages2 = [{ id: "m2", role: "user", source: { kind: "user" }, content: [{ type: "text", text: "看图" }, imageBlock] }];
562
+ const decision2 = { kind: "enter", messages: messages2 };
563
+ await text.preStep()(
564
+ {
565
+ agent: {
566
+ options: { provider: "opencode-go", model: "deepseek-v4-flash" },
567
+ session: {
568
+ requestHeader: () => ({ config: { provider: "opencode-go", model: "deepseek-v4-flash" } }),
569
+ deriveMessages: () => []
570
+ }
571
+ },
572
+ signal: makeSignal()
573
+ },
574
+ () => decision2
575
+ );
576
+ assert.equal(visionRequests, 1);
577
+
578
+ globalThis.fetch = previousFetch;
579
+ });