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 +44 -5
- package/package.json +1 -1
- package/test/vision-fallback.test.mjs +136 -0
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
|
|
516
|
-
|
|
517
|
-
|
|
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
|
@@ -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
|
+
});
|