diff --git a/.github/workflows/dast-smoke.yml b/.github/workflows/dast-smoke.yml index f4e2d65155..23055c46e4 100644 --- a/.github/workflows/dast-smoke.yml +++ b/.github/workflows/dast-smoke.yml @@ -37,7 +37,7 @@ jobs: with: node-version: "24" cache: npm - - run: npm ci + - uses: ./.github/actions/npm-ci-retry - name: Build CLI bundle env: OMNIROUTE_BUILD_BACKEND_ONLY: "1" diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index dd34601323..9047db295e 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -137,7 +137,7 @@ jobs: with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - - run: npm ci + - uses: ./.github/actions/npm-ci-retry # One walk of src/app/api for openapi-routes + docs-symbols (both still fail independently). - run: npm run check:api-docs-refs - name: Docs accuracy (fabricated-docs + i18n mirrors, strict) @@ -181,7 +181,7 @@ jobs: with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - - run: npm ci + - uses: ./.github/actions/npm-ci-retry - name: Restore ESLint file cache uses: actions/cache@v6 with: @@ -430,7 +430,7 @@ jobs: with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - - run: npm ci + - uses: ./.github/actions/npm-ci-retry # WS5.2/5.3: JUnit feeds Trunk Flaky Tests — the fast-path runs on EVERY PR, # which is where flaky-detection volume actually comes from (ci.yml's heavy # jobs only run on the release PR). Advisory upload, own-origin only. @@ -476,7 +476,7 @@ jobs: with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - - run: npm ci + - uses: ./.github/actions/npm-ci-retry # QW-d: fonte única — o mesmo npm script do CI pesado/local. Fecha dois drifts do # comando inline antigo: os dirs `memory` e `usage` estavam FORA do glob (testes # silenciosamente não rodavam no fast path) e o setupPolyfill não era importado. @@ -516,7 +516,7 @@ jobs: with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - - run: npm ci + - uses: ./.github/actions/npm-ci-retry - name: Restore ESLint file cache uses: actions/cache@v6 with: @@ -583,7 +583,7 @@ jobs: with: node-version: ${{ env.CI_NODE_VERSION }} cache: npm - - run: npm ci + - uses: ./.github/actions/npm-ci-retry - name: CHANGELOG integrity (nenhum bullet da base pode sumir no merge-result) run: npm run check:changelog-integrity - name: Agent-skills generator sync (SKILL.md gerado ≡ catálogo) diff --git a/changelog.d/fixes/10415-vision-bridge-combo-reroute.md b/changelog.d/fixes/10415-vision-bridge-combo-reroute.md new file mode 100644 index 0000000000..a3df3019c4 --- /dev/null +++ b/changelog.d/fixes/10415-vision-bridge-combo-reroute.md @@ -0,0 +1 @@ +- **fix(guardrails):** Vision Bridge now reroutes whole requests for named combos whose targets have zero vision-capable models (previously such image requests died with `capability_mismatch` when the describe path could not run), and when the fallback describe path also fails for every image the request degrades to explicit `(unavailable)` stub text instead of preserving images the combo cannot consume ([#10415](https://github.com/diegosouzapw/OmniRoute/pull/10415)) — thanks @rqzbeh diff --git a/scripts/build/prepublish.ts b/scripts/build/prepublish.ts index 6de4af27e2..e8872c0a9a 100644 --- a/scripts/build/prepublish.ts +++ b/scripts/build/prepublish.ts @@ -471,24 +471,66 @@ if (existsSync(opencodePluginSrc) && existsSync(join(opencodePluginSrc, "package // needs the plugin's own devDependencies (typescript, @opencode-ai/plugin // types). Without this install a fresh CI publish fails at this step. if (!existsSync(join(opencodePluginSrc, "node_modules"))) { + // The plugin's node_modules is gitignored, so a fresh CI checkout + // ALWAYS installs here. The registry CDN is intermittently flaky + // (onnxruntime-class ETIMEDOUTs to the Microsoft CDN have repeatedly + // stalled CI npm steps for 20+ minutes), and npm's unbounded fetch + // retries turn a stalled connection into a hang that eats the whole + // job budget. Bound the fetch and retry the install a few times: + // transient network failures fail fast and recover instead of hanging. const npmEntry = resolveBundledNpmEntry("npm-cli.js"); - if (npmEntry) { - execFileSync(process.execPath, [npmEntry, "install", "--no-audit", "--no-fund"], { - cwd: opencodePluginSrc, - stdio: "inherit", - }); - } else if (process.platform !== "win32") { - // No bundled npm entry found (non-standard Node layout). Plain `npm` is - // safe here — the .cmd-shim hazard #8858 guards against is Windows-only. - execFileSync("npm", ["install", "--no-audit", "--no-fund"], { - cwd: opencodePluginSrc, - stdio: "inherit", - }); - } else { - throw new Error( - "npm-cli.js not found next to the running Node binary; cannot install the plugin dependencies without falling back to a .cmd shim." - ); + const installArgs = [ + "install", + "--no-audit", + "--no-fund", + "--fetch-retries=2", + "--fetch-retry-mintimeout=2000", + "--fetch-retry-maxtimeout=30000", + "--fetch-timeout=60000", + ]; + const runPluginInstall = () => { + if (npmEntry) { + execFileSync(process.execPath, [npmEntry, ...installArgs], { + cwd: opencodePluginSrc, + stdio: "inherit", + }); + } else if (process.platform !== "win32") { + // No bundled npm entry found (non-standard Node layout). Plain `npm` is + // safe here — the .cmd-shim hazard #8858 guards against is Windows-only. + execFileSync("npm", installArgs, { + cwd: opencodePluginSrc, + stdio: "inherit", + }); + } else { + throw new Error( + "npm-cli.js not found next to the running Node binary; cannot install the plugin dependencies without falling back to a .cmd shim." + ); + } + }; + const sleepSync = (ms: number) => + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); + let installError: any = null; + for (let attempt = 1; attempt <= 3; attempt++) { + try { + if (attempt > 1) { + console.log( + ` 🔄 @omniroute/opencode-plugin npm install retry (attempt ${attempt}/3)` + ); + } + runPluginInstall(); + installError = null; + break; + } catch (err: any) { + installError = err; + if (attempt < 3) { + console.warn( + ` ⚠️ plugin npm install failed (attempt ${attempt}/3): ${err?.message ?? String(err)} — retrying in 10s` + ); + sleepSync(10_000); + } + } } + if (installError) throw installError; } runBuildTool("tsup", "tsup", [], { cwd: opencodePluginSrc, diff --git a/src/lib/guardrails/visionBridge.ts b/src/lib/guardrails/visionBridge.ts index 1fc032c374..85cd792512 100644 --- a/src/lib/guardrails/visionBridge.ts +++ b/src/lib/guardrails/visionBridge.ts @@ -3,6 +3,9 @@ * Intercepts image-bearing requests to non-vision models. * For individual non-vision models: reroutes to the fastest available vision-capable model. * For combos with non-vision targets: extracts descriptions via vision model and replaces images with text. + * For combos with ZERO vision-capable targets: falls back to whole-request reroute to a + * vision-capable model (same semantics as an individual text-only model), so image + * requests do not die in the combo capability filter when describing is impossible. */ import { BaseGuardrail, type GuardrailContext, type GuardrailResult } from "./base"; @@ -31,7 +34,7 @@ import { export { isProviderConnectionUsable, hasUsableCredentialsForModel }; -type ComboVisionBridgeDecision = "process" | "skip" | "not-combo"; +type ComboVisionBridgeDecision = "process" | "skip" | "not-combo" | "no-vision"; export function resolveVisionComboName(mapping: Record): string | null { const comboName = mapping.comboName ?? mapping.name ?? null; @@ -40,10 +43,15 @@ export function resolveVisionComboName(mapping: Record): string /// Check if a combo model should trigger vision bridge processing. /// Resolves combo targets and returns: -/// - "process" if any target cannot be proven vision-capable +/// - "process" if some (but not all) model targets lack proven vision support /// - "skip" if all model targets can handle images directly +/// - "no-vision" when the combo has model targets but NONE can handle images — +/// the combo behaves like a single text-only model, so the bridge may +/// whole-request reroute to a vision-capable model (mirroring non-combos) /// - "not-combo" when the model is not a combo/mapping -async function getComboVisionBridgeDecision(model: string): Promise { +export async function getComboVisionBridgeDecision( + model: string +): Promise { try { const { getComboByName } = await import("@/lib/localDb"); const { resolveComboForModel } = await import("@/lib/db/modelComboMappings"); @@ -70,7 +78,10 @@ async function getComboVisionBridgeDecision(model: string): Promise; if (s.kind === "combo-ref") return "process"; @@ -79,8 +90,10 @@ async function getComboVisionBridgeDecision(model: string): Promise d === null); - if (allNull && comboVisionBridgeDecision === "process") { + if ( + allNull && + (comboVisionBridgeDecision === "process" || comboVisionBridgeDecision === "no-vision") + ) { for (let i = 0; i < descriptions.length; i++) { descriptions[i] = `[Image ${i + 1}]: (unavailable — no vision-capable provider connected)`; } diff --git a/tests/unit/guardrails/visionBridge-combo-reroute.test.ts b/tests/unit/guardrails/visionBridge-combo-reroute.test.ts new file mode 100644 index 0000000000..7757d028b6 --- /dev/null +++ b/tests/unit/guardrails/visionBridge-combo-reroute.test.ts @@ -0,0 +1,292 @@ +/** + * Vision Bridge × named-combo reroute tests. + * + * Regression: a named combo whose targets have ZERO vision-capable models was + * never reroute-eligible. The bridge only described images for it, and when + * the describe path could not run (unreachable bridge model, failed self-loop, + * missing credentials) the raw images stayed in the payload, the combo + * capability filter excluded every target, and the request died with + * capability_mismatch — "vision bridge does not affect combo models". + * + * Fix under test: `getComboVisionBridgeDecision` returns "no-vision" for a + * combo with model targets but no vision-capable target, and preCall treats + * that decision as reroute-eligible (mirroring non-combo text-only models), + * falling back to describe only when no usable reroute target exists. + */ +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-vb-combo-reroute-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const { VisionBridgeGuardrail, getComboVisionBridgeDecision } = + await import("../../../src/lib/guardrails/visionBridge.ts"); +const { resetGuardrailsForTests } = await import("../../../src/lib/guardrails/registry.ts"); +const { getResolvedModelCapabilities } = await import("../../../src/lib/modelCapabilities.ts"); +const core = await import("../../../src/lib/db/core.ts"); +const combosDb = await import("../../../src/lib/db/combos.ts"); +const mappingsDb = await import("../../../src/lib/db/modelComboMappings.ts"); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +async function createCombo(name, models, overrides = {}) { + return combosDb.createCombo({ + name, + models, + strategy: "priority", + ...overrides, + }); +} + +// ── Fixtures ──────────────────────────────────────────────────────────────── + +const VISION_MODEL = "openai/gpt-4o"; +const TEXT_MODEL_A = "google/gemma-2-27b"; +const TEXT_MODEL_B = "mistral/mistral-large-latest"; + +// Fail loudly if the static vision heuristic drifts: these fixtures drive +// every assertion in this file. +test("fixture models have the expected static vision capability", () => { + assert.equal(getResolvedModelCapabilities(VISION_MODEL).supportsVision, true); + assert.notEqual(getResolvedModelCapabilities(TEXT_MODEL_A).supportsVision, true); + assert.notEqual(getResolvedModelCapabilities(TEXT_MODEL_B).supportsVision, true); +}); + +const mockSettings = { + visionBridgeEnabled: true, + visionBridgeModel: VISION_MODEL, + visionBridgePrompt: "Describe this image concisely.", + visionBridgeTimeout: 30000, + visionBridgeMaxImages: 10, +}; + +let visionCallCount = 0; + +// Each describe-path test uses a UNIQUE prompt: the shared describe cache keys +// on (contentRef, prompt, model), so a reused prompt would serve a cached +// description and skip callVisionModel, breaking the assertion on call count. +function createGuardrail(depsOverrides = {}, prompt = "Describe this image concisely.") { + return new VisionBridgeGuardrail({ + deps: { + getSettings: async () => ({ ...mockSettings, visionBridgePrompt: prompt }), + callVisionModel: async () => { + visionCallCount++; + return "A red circle on a white background"; + }, + // null = fail-open (no credential DB in unit tests), matching the + // existing visionBridge.test.ts convention. + hasUsableCredentials: async () => null, + ...depsOverrides, + }, + }); +} + +const IMAGE_PAYLOAD = { + model: "text-only-combo", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "Describe this image in one sentence." }, + { + type: "image_url", + image_url: { + url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + }, + }, + ], + }, + ], +}; + +function hasImagePart(messages) { + return JSON.stringify(messages).includes("image_url"); +} + +// GuardrailResult types modifiedPayload as `unknown`; the existing +// visionBridge.test.ts casts it the same way. +type ModifiedBody = { model?: string; messages?: unknown[] }; +function asModifiedBody(result: { modifiedPayload?: unknown }): ModifiedBody { + return (result.modifiedPayload ?? {}) as ModifiedBody; +} + +// ── getComboVisionBridgeDecision ──────────────────────────────────────────── + +test("decision: combo with zero vision-capable targets returns 'no-vision'", async () => { + await createCombo("text-only-combo", [ + { provider: "google", model: TEXT_MODEL_A }, + { provider: "mistral", model: TEXT_MODEL_B }, + ]); + assert.equal(await getComboVisionBridgeDecision("text-only-combo"), "no-vision"); +}); + +test("decision: combo with all vision-capable targets returns 'skip'", async () => { + await createCombo("vision-combo", [ + { provider: "openai", model: VISION_MODEL }, + { provider: "anthropic", model: "anthropic/claude-sonnet-4-20250514" }, + ]); + assert.equal(await getComboVisionBridgeDecision("vision-combo"), "skip"); +}); + +test("decision: mixed combo (some vision, some not) returns 'process'", async () => { + await createCombo("mixed-combo", [ + { provider: "openai", model: VISION_MODEL }, + { provider: "google", model: TEXT_MODEL_A }, + ]); + assert.equal(await getComboVisionBridgeDecision("mixed-combo"), "process"); +}); + +test("decision: unknown model returns 'not-combo'", async () => { + assert.equal(await getComboVisionBridgeDecision("not-a-combo"), "not-combo"); +}); + +test("decision: model-combo mapping routes to the combo decision", async () => { + const combo = await createCombo("mapped-text-only", [ + { provider: "google", model: TEXT_MODEL_A }, + ]); + await mappingsDb.createModelComboMapping({ + pattern: "mapped-model-alias", + comboId: combo.id as string, + priority: 20, + description: "test alias", + }); + assert.equal(await getComboVisionBridgeDecision("mapped-model-alias"), "no-vision"); +}); + +// ── preCall: no-vision combo reroutes whole request ───────────────────────── + +test("preCall: zero-vision combo reroutes the whole request to the bridge model", async () => { + resetGuardrailsForTests({ registerDefaults: false }); + await createCombo("text-only-combo", [ + { provider: "google", model: TEXT_MODEL_A }, + { provider: "mistral", model: TEXT_MODEL_B }, + ]); + visionCallCount = 0; + const guardrail = createGuardrail(); + const result = await guardrail.preCall(IMAGE_PAYLOAD, {}); + + assert.equal(result.block, false); + // Rerouted: model swapped to the vision bridge model, image bytes KEPT. + assert.equal(asModifiedBody(result).model, VISION_MODEL); + assert.equal(result.meta.rerouted, true); + assert.equal(result.meta.fromModel, "text-only-combo"); + assert.equal(hasImagePart(asModifiedBody(result).messages), true); + // Describe never ran — no extra vision call. + assert.equal(visionCallCount, 0); +}); + +test("preCall: zero-vision combo falls back to describe when reroute target is unusable", async () => { + resetGuardrailsForTests({ registerDefaults: false }); + await createCombo("text-only-combo", [{ provider: "google", model: TEXT_MODEL_A }]); + visionCallCount = 0; + // Reroute target has no usable credentials → describe path must run. + const guardrail = createGuardrail( + { hasUsableCredentials: async () => false }, + "Describe the fallback image." + ); + const result = await guardrail.preCall(IMAGE_PAYLOAD, {}); + + assert.equal(result.block, false); + assert.equal(result.meta.rerouted, undefined); + // Images replaced with the described text; combo model kept. + assert.equal(asModifiedBody(result).model, "text-only-combo"); + assert.equal(hasImagePart(asModifiedBody(result).messages), false); + assert.equal(visionCallCount, 1); +}); + +test("preCall: no-vision combo, unusable reroute target AND describe failure -> stub text", async () => { + resetGuardrailsForTests({ registerDefaults: false }); + await createCombo("text-only-combo", [{ provider: "google", model: TEXT_MODEL_A }]); + visionCallCount = 0; + // Double failure: the reroute target has no usable credentials AND the + // describe call fails for every image. The allNull stub fallback must fire + // for "no-vision" too — otherwise the raw images stay in the payload, the + // combo capability filter rejects every target, and the original + // capability_mismatch recurs. + const guardrail = createGuardrail( + { + hasUsableCredentials: async () => false, + callVisionModel: async () => { + visionCallCount++; + throw new Error("no vision-capable provider connected"); + }, + }, + "Describe the double-failure image." + ); + const result = await guardrail.preCall(IMAGE_PAYLOAD, {}); + + assert.equal(result.block, false); + assert.equal(result.meta.rerouted, undefined); + // Combo model kept; raw image replaced with the stub text. + assert.equal(asModifiedBody(result).model, "text-only-combo"); + assert.equal(hasImagePart(asModifiedBody(result).messages), false); + assert.match( + JSON.stringify(asModifiedBody(result).messages), + /\(unavailable — no vision-capable provider connected\)/ + ); + assert.equal(visionCallCount, 1); +}); + +test("preCall: zero-vision combo with no images is left untouched", async () => { + resetGuardrailsForTests({ registerDefaults: false }); + await createCombo("text-only-combo", [{ provider: "google", model: TEXT_MODEL_A }]); + visionCallCount = 0; + const guardrail = createGuardrail(); + const result = await guardrail.preCall( + { + model: "text-only-combo", + messages: [{ role: "user", content: "no images here" }], + }, + {} + ); + assert.equal(result.block, false); + assert.equal(result.modifiedPayload, undefined); + assert.equal(visionCallCount, 0); +}); + +// ── preCall: unchanged semantics for other combo shapes ───────────────────── + +test("preCall: all-vision combo still skips the bridge entirely", async () => { + resetGuardrailsForTests({ registerDefaults: false }); + await createCombo("vision-combo", [{ provider: "openai", model: VISION_MODEL }]); + visionCallCount = 0; + const guardrail = createGuardrail(); + const result = await guardrail.preCall({ ...IMAGE_PAYLOAD, model: "vision-combo" }, {}); + assert.equal(result.block, false); + assert.equal(result.modifiedPayload, undefined); + assert.equal(visionCallCount, 0); +}); + +test("preCall: mixed combo keeps the describe path (no reroute, model unchanged)", async () => { + resetGuardrailsForTests({ registerDefaults: false }); + await createCombo("mixed-combo", [ + { provider: "openai", model: VISION_MODEL }, + { provider: "google", model: TEXT_MODEL_A }, + ]); + visionCallCount = 0; + const guardrail = createGuardrail({}, "Describe the mixed-combo image."); + const result = await guardrail.preCall({ ...IMAGE_PAYLOAD, model: "mixed-combo" }, {}); + + assert.equal(result.block, false); + // Mixed combo is NOT reroute-eligible: model stays, images described. + assert.equal(result.meta.rerouted, undefined); + assert.equal(asModifiedBody(result).model, "mixed-combo"); + assert.equal(hasImagePart(asModifiedBody(result).messages), false); + assert.equal(visionCallCount, 1); +});