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 +37 -1
- package/package.json +1 -1
- package/test/vision-fallback.test.mjs +90 -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"])] };
|
|
@@ -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
|
@@ -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
|
+
});
|