dsh-vision-fallback 0.5.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"])] };
@@ -449,13 +456,16 @@ class ReplacementCoordinator {
449
456
  replacements = new Map();
450
457
  this.pending.set(session, replacements);
451
458
  }
459
+ const activeMessageIds = new Set(originalMessages.map((message) => message.id));
460
+ for (const messageId of replacements.keys()) {
461
+ if (!activeMessageIds.has(messageId)) replacements.delete(messageId);
462
+ }
452
463
  for (let index = 0; index < originalMessages.length; index += 1) {
453
464
  const original = originalMessages[index];
454
465
  const rewritten = rewrittenMessages[index];
455
466
  if (original === rewritten || !contentHasImage(original.content ?? [])) continue;
456
467
  replacements.set(original.id, rewritten);
457
468
  }
458
- if (replacements.size > 128) replacements.clear();
459
469
  }
460
470
 
461
471
  onSessionEvent(session, event) {
@@ -485,7 +495,11 @@ class ReplacementCoordinator {
485
495
  }
486
496
  }
487
497
 
498
+ /** 原始的 resolveModelInfo 实现(绑定原持有者,未被能力覆盖污染)。 */
499
+ let rawResolveModelInfo = null;
500
+
488
501
  function installCapabilityOverride(ctx, current) {
502
+ rawResolveModelInfo = ctx.llm.resolveModelInfo.bind(ctx.llm);
489
503
  const previous = ctx.llm.resolveModelInfo;
490
504
  const overridden = async function (provider, model, signal) {
491
505
  const info = await previous.call(this, provider, model, signal);
@@ -494,9 +508,25 @@ function installCapabilityOverride(ctx, current) {
494
508
  ctx.llm.resolveModelInfo = overridden;
495
509
  return () => {
496
510
  if (ctx.llm.resolveModelInfo === overridden) ctx.llm.resolveModelInfo = previous;
511
+ rawResolveModelInfo = null;
497
512
  };
498
513
  }
499
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
+
500
530
  function apply(ctx, config) {
501
531
  let current = () => config;
502
532
  let settingsService;
@@ -512,9 +542,18 @@ function apply(ctx, config) {
512
542
  ctx.on("agent/pre-step", async ({ agent, signal }, next) => {
513
543
  const decision = await next();
514
544
  if (decision.kind === "reject" || signal.aborted) return decision;
515
- const contextMessages = [...agent.session.deriveMessages(), ...decision.messages];
516
- const rewritten = await controller.preprocess(decision.messages, contextMessages, signal);
517
- replacements.stage(agent.session, decision.messages, rewritten);
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
+ }
554
+ const modelMessages = [...agent.session.deriveMessages(), ...decision.messages];
555
+ const rewritten = await controller.preprocess(modelMessages, modelMessages, signal);
556
+ replacements.stage(agent.session, modelMessages, rewritten);
518
557
  return decision;
519
558
  });
520
559
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-vision-fallback",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "DSH 静默视觉增强:主模型照常选择,图片自动交给固定视觉模型后以隐藏上下文返回主模型。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -198,6 +198,52 @@ test("surface replacement 事件不会再次触发替换", async () => {
198
198
  assert.deepEqual(appends[0].options.surfaceOp, { op: "replace", start: 12, end: 12 });
199
199
  });
200
200
 
201
+ test("历史工具结果中的图片只以文字形式进入模型视图", async () => {
202
+ const originalFetch = globalThis.fetch;
203
+ globalThis.fetch = async () => ({
204
+ ok: true,
205
+ json: async () => ({ choices: [{ message: { content: "这是主题插件的界面截图" } }] })
206
+ });
207
+ const historicalToolResult = userMessage("tool-result-1", [{
208
+ type: "tool-result",
209
+ toolCallId: "read-image-1",
210
+ content: [
211
+ { type: "text", text: "图片读取成功" },
212
+ { type: "image", attachment }
213
+ ],
214
+ isError: false
215
+ }]);
216
+ const current = userMessage("u2", [{ type: "text", text: "这是什么插件?" }]);
217
+ const surface = [historicalToolResult];
218
+ const session = {
219
+ deriveMessages: () => surface,
220
+ append() {
221
+ throw new Error("历史消息投影不应改写界面事件");
222
+ }
223
+ };
224
+ const controller = new VisionFallbackController(createServiceContext(), () => config);
225
+ const coordinator = new ReplacementCoordinator({ error() {} });
226
+
227
+ try {
228
+ const modelMessages = [...session.deriveMessages(), current];
229
+ const rewritten = await controller.preprocess(modelMessages, modelMessages);
230
+ coordinator.stage(session, modelMessages, rewritten);
231
+
232
+ const projected = session.deriveMessages();
233
+ assert.equal(projected.length, 1);
234
+ assert.equal(projected[0].id, historicalToolResult.id);
235
+ assert.equal(projected[0].content[0].type, "tool-result");
236
+ assert.equal(projected[0].content[0].content[1].type, "text");
237
+ assert.match(projected[0].content[0].content[1].text, /这是主题插件的界面截图/);
238
+ assert.equal(JSON.stringify(projected).includes('"type":"image"'), false);
239
+ assert.equal(surface[0], historicalToolResult);
240
+ assert.equal(surface[0].content[0].content[1].type, "image");
241
+ } finally {
242
+ coordinator.dispose();
243
+ globalThis.fetch = originalFetch;
244
+ }
245
+ });
246
+
201
247
  test("图片能力覆盖可随开关变化并能恢复", async () => {
202
248
  let current = config;
203
249
  const original = async (provider, model) => ({
@@ -441,3 +487,93 @@ test("浏览器端只显示静默视觉配置", async () => {
441
487
  globalThis.fetch = previousFetch;
442
488
  }
443
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
+ });