fix(fusion): apply vision-compatibility filter to fusion panel + judge (port from 9router#3378)

Every non-fusion combo strategy runs candidates through
filterTargetsByRequestCompatibility before dispatch, which excludes a target
whose vision support cannot be confirmed `=== true` for an image-bearing
request (#8332). tryFusionDispatch resolved its panel via the raw
resolveComboTargets() and skipped that filter entirely, so a panel member (or
explicit judge) with an unrecognized model id — capability lookup miss,
supportsVision resolves to something other than true — still received the
unmodified image body while a "confirmed vision" voice silently dropped out
of the panel.

Apply the same exclusion to the fusion panel and to an explicit judgeModel:
when the request requires vision, drop targets without confirmed support
(falling back to a capable panel member for the judge, same as an
operator-hidden judge already does), and fail closed with capability_mismatch
if the whole panel is excluded.

Co-authored-by: anojndr <anojndr@users.noreply.github.com>
This commit is contained in:
Markus Hartung
2026-08-19 08:48:54 -03:00
parent b754e44e26
commit 0e6f844e1e
3 changed files with 206 additions and 5 deletions

View File

@@ -167,6 +167,7 @@ _Living section — regenerated 2026-08-12 from all cycle commits (cycle open `e
### 🐛 Bug Fixes
- **fusion**: fusion combos now apply the same vision-compatibility filter as every other combo strategy — a panel member or judge whose vision support cannot be confirmed is excluded from an image-bearing request instead of silently receiving it (port from upstream decolua/9router#3378)
- **providers**: honor `PATCH /api/providers/[id]` so `omniroute providers rotate` stops 405ing (the OpenAPI spec and CLI already use PATCH) (PR #10366)
- **executors**: fix internal timeout misclassified as client disconnect (499) for 7 niche executors — pass TimeoutError reason to controller.abort() (#8197 side-finding)
- test(combo): guard auto/best-free never leaks the combo name as a model (#7754)

View File

@@ -16,11 +16,18 @@ import { getCachedProviderConnections } from "../../../src/lib/db/readCache";
import { getCircuitBreaker } from "../../../src/shared/utils/circuitBreaker";
import { fisherYatesShuffle, getNextFromDeck } from "../../../src/shared/utils/shuffleDeck";
import { handleFusionChat, type FusionTuning } from "../fusion.ts";
import { getResolvedModelCapabilities } from "../modelCapabilities.ts";
import { errorResponseWithComboDiagnostics } from "../../utils/error.ts";
import { parseModel } from "../model.ts";
import { handlePipelineChat, type PipelineStep } from "../pipeline.ts";
import type { resolveComboSetupConfig } from "../comboConfig.ts";
import { clampComboDepth, MAX_GLOBAL_ATTEMPTS, resolveDelayMs } from "./comboPredicates.ts";
import { resolveComboRuntimeUnits, resolveComboTargets } from "./comboStructure.ts";
import {
deriveRequestCompatibilityRequirements,
isVisionIncompatibleTarget,
resolveComboRuntimeUnits,
resolveComboTargets,
} from "./comboStructure.ts";
import { isComboModelVisible } from "./comboVisibility.ts";
import { buildFusionHandleSingleModel, extractFusionPanelSpec } from "./fusionPanel.ts";
import {
@@ -393,14 +400,27 @@ export async function tryFusionDispatch(args: {
}): Promise<Response | null> {
const { cfg, combo, config, strategy, log } = args;
const configuredJudge = typeof cfg.judgeModel === "string" ? cfg.judgeModel : undefined;
const judgeFusionRequirements = deriveRequestCompatibilityRequirements(args.body);
// #3378: the judge stays in the original conversation (full history, including
// any image_url blocks) — a judge whose vision support cannot be confirmed is
// exactly as unsafe as an unconfirmed panel member (#8332). Drop it the same
// way an operator-hidden judge is dropped below, so fusion falls back to a
// (vision-confirmed) panel member instead of silently losing the image for
// the synthesis step.
const judgeLacksConfirmedVision =
judgeFusionRequirements.requiresVision &&
!!configuredJudge &&
getResolvedModelCapabilities(configuredJudge).supportsVision !== true;
// The panel is filtered for hidden models by resolveComboTargets, but the
// explicit judge is a bare string that never passes through it (#8878). Drop a
// hidden judge so fusion falls back to a surviving panel member instead of
// dispatching a model the operator hid.
const judgeModel =
configuredJudge && !isComboModelVisible(configuredJudge, null, args.hiddenModelsByProvider)
? undefined
: configuredJudge;
configuredJudge &&
!judgeLacksConfirmedVision &&
isComboModelVisible(configuredJudge, null, args.hiddenModelsByProvider)
? configuredJudge
: undefined;
const fusionTuning =
cfg.fusionTuning && typeof cfg.fusionTuning === "object"
? (cfg.fusionTuning as FusionTuning)
@@ -413,12 +433,50 @@ export async function tryFusionDispatch(args: {
}
if (strategy !== "fusion") return null;
const resolvedFusionTargets = resolveComboTargets(
const allResolvedFusionTargets = resolveComboTargets(
combo,
args.allCombos,
clampComboDepth(config.maxComboDepth),
args.hiddenModelsByProvider
);
// #3378 (ported from upstream decolua/9router): every non-fusion combo
// strategy runs candidates through filterTargetsByRequestCompatibility before
// dispatch, which excludes a target whose vision support cannot be *confirmed*
// `=== true` for an image-bearing request (#8332 — unknown is treated the same
// as unsupported, never silently forwarded). Fusion resolved its panel via the
// raw target list and skipped that filter entirely, so a panel member with an
// unrecognized model id (capability lookup misses -> supportsVision !== true)
// still received the unmodified image body while the panel silently lost a
// "confirmed vision" voice. Apply the same exclusion here so the fusion panel
// only fans an image request out to targets with confirmed vision support.
const fusionRequirements = judgeFusionRequirements;
const resolvedFusionTargets = fusionRequirements.requiresVision
? allResolvedFusionTargets.filter(
(target) => !isVisionIncompatibleTarget(target, fusionRequirements)
)
: allResolvedFusionTargets;
if (fusionRequirements.requiresVision && resolvedFusionTargets.length === 0) {
log.warn(
"COMBO",
`Combo "${combo.name}" fusion panel has no target with confirmed vision support for this image request — every candidate was excluded (#3378)`
);
return errorResponseWithComboDiagnostics(
400,
`No target in combo ${combo.name} has confirmed vision support for this image request`,
{
poolSize: allResolvedFusionTargets.length,
attempted: 0,
excluded: allResolvedFusionTargets.map((target) => ({
provider: target.provider,
model: target.modelStr,
reason: "vision",
})),
attemptOrder: [],
terminalReason: "capability_mismatch",
},
{ code: "capability_mismatch", type: "invalid_request_error" }
);
}
// extractFusionPanelSpec only understands model strings / combo refs, so the
// resolved targets have to be flattened before it runs. Keep them indexed so
// the panel can be rehydrated below — dispatching the bare strings strips

View File

@@ -0,0 +1,142 @@
// Regression guard for upstream decolua/9router#3378: "Fusion combo sometimes
// can't see images even when all models support vision".
//
// Every non-fusion combo strategy runs the request through
// filterTargetsByRequestCompatibility (comboStructure.ts) before dispatch, which
// treats a target whose vision support is not *confirmed* `=== true` (unknown OR
// false) as vision-incompatible and excludes it (#8332). The fusion dispatch
// branch (dispatchPrelude.ts::tryFusionDispatch) resolves its panel via the raw
// resolveComboTargets() and skips that compat filter entirely — so a panel
// member whose model id is unrecognized by the capability registry (and thus
// resolves to supportsVision !== true) still receives the unmodified
// image-bearing body, without any signal that its capability could not be
// confirmed.
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-fusion-vision-3378-"));
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "fusion-vision-3378-test-secret";
const { handleComboChat } = await import("../../open-sse/services/combo.ts");
const { saveModelsDevCapabilities, clearModelsDevCapabilities } = await import(
"../../src/lib/modelsDevSync.ts"
);
const { resetAllComboMetrics } = await import("../../open-sse/services/comboMetrics.ts");
const { resetAllCircuitBreakers } = await import("../../src/shared/utils/circuitBreaker.ts");
const { resetAll: resetAllSemaphores } = await import(
"../../open-sse/services/rateLimitSemaphore.ts"
);
const core = await import("../../src/lib/db/core.ts");
function createLog() {
return { info: () => {}, warn: () => {}, error: () => {}, debug: () => {} };
}
function okResponse(content: string) {
return new Response(JSON.stringify({ choices: [{ message: { role: "assistant", content } }] }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
function capabilityEntry(overrides: Record<string, unknown> = {}) {
return {
tool_call: true,
reasoning: false,
attachment: false,
structured_output: true,
temperature: true,
modalities_input: JSON.stringify(["text"]),
modalities_output: JSON.stringify(["text"]),
knowledge_cutoff: null,
release_date: null,
last_updated: null,
status: null,
family: null,
open_weights: false,
limit_context: 128000,
limit_input: 128000,
limit_output: 4096,
interleaved_field: null,
...overrides,
};
}
const imageRequestBody = {
messages: [
{
role: "user",
content: [
{ type: "text", text: "What is in this image?" },
{ type: "image_url", image_url: { url: "https://example.com/cat.png" } },
],
},
],
};
test.beforeEach(() => {
resetAllComboMetrics();
resetAllCircuitBreakers();
resetAllSemaphores();
clearModelsDevCapabilities();
});
test.after(() => {
resetAllComboMetrics();
resetAllCircuitBreakers();
resetAllSemaphores();
clearModelsDevCapabilities();
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
if (ORIGINAL_DATA_DIR === undefined) {
delete process.env.DATA_DIR;
} else {
process.env.DATA_DIR = ORIGINAL_DATA_DIR;
}
});
test(
"fusion panel must not dispatch an image_url request to a member whose vision " +
"support cannot be confirmed (#3378)",
async () => {
// fusion-vision-a is confirmed vision-capable. fusion-unknown has no
// capability entry at all (unrecognized id) -> getResolvedModelCapabilities
// resolves supportsVision to something other than `true`, exactly like the
// "unknown id silently treated as no vision" failure mode from the upstream
// report.
saveModelsDevCapabilities({
openai: {
"fusion-vision-a": capabilityEntry({ attachment: true }),
},
});
const dispatched: string[] = [];
const result = await handleComboChat({
body: imageRequestBody,
combo: {
name: "fusion-vision-panel-3378",
strategy: "fusion",
models: ["openai/fusion-vision-a", "openai/fusion-unknown"],
config: { judgeModel: "openai/fusion-vision-a" },
},
handleSingleModel: async (_body, modelStr) => {
dispatched.push(modelStr);
return okResponse(`answer from ${modelStr}`);
},
log: createLog(),
settings: {},
allCombos: [],
});
assert.ok(result.status < 500, "combo call should not hard-fail");
assert.ok(
!dispatched.includes("openai/fusion-unknown"),
"a panel member with unconfirmed vision support must never receive the raw image_url body"
);
}
);