Files
OmniRoute/tests/unit/guardrails/vision-bridge-callmodel.test.ts
Gustavo Costa 763ed82d3c fix(guardrails): Vision Bridge describe-fallback ignores unreachable candidates (#8433)
* fix(guardrails): Vision Bridge describe-fallback ignores unreachable candidates

getVisionCapableModels() scanned the entire static PROVIDER_MODELS catalog
for a describe-fallback model without checking whether the provider has a
usable active connection on this instance. On an instance with no `openai`
provider connected, this let the hardcoded default `openai/gpt-4o-mini` win
selection every time, the describe call would fail, and
replaceImageParts()'s describe-failure fallback (#4012) intentionally
preserves the raw image part — which then reaches a non-vision backend and
gets rejected with an opaque upstream error (e.g. "unknown variant
`image_url`, expected `text`").

Extract the credential-usability check already used by the whole-request
reroute path (visionBridge.ts's hasUsableCredentialsForModel /
isProviderConnectionUsable) into a shared visionBridgeCredentials.ts module,
and apply the same check to the describe-fallback candidate list in
visionBridgeRouter.ts. A confirmed-unusable connection (`false`) excludes a
candidate; an indeterminate result (`null`, e.g. no DB) fails open to
preserve existing behavior.

getVisionCapableModels/getBestVisionModel/getFallbackModels become async to
support the credential lookup; call sites and the existing unit test suite
are updated accordingly, plus new coverage for the exclusion behavior.

* fix(guardrails): fix flaky credential-mock race and move Vision Bridge router tests to a CI-blocking runner

The two new assertions added in this PR (excludes a candidate with no
usable active connection / selects a credentialed candidate over an
uncredentialed one) were correct — the failure was a genuine Vitest
race: getVisionCapableModels() fans out to hasUsableCredentialsForModel()
once per catalog entry via Promise.all, and dozens of concurrent
first-load `await import("@/lib/db/providers")` calls for the same
specifier under vi.mock() nondeterministically resolved against the real
module instead of the mock for some callers. Memoize the dynamic import
in visionBridgeCredentials.ts so it resolves exactly once and is reused,
which removes the race entirely (and is cheaper at runtime too).

Also: tests/unit/guardrails/visionBridgeRouter.test.tsx was never
collected by any CI-blocking gate — `test:unit`'s guardrails glob is
`*.test.ts` only, and `test:vitest` (vitest.mcp.config.ts) doesn't
include this directory; only the advisory `test:vitest:ui` picked it up.
Moved the suite to visionBridgeRouter.test.ts under node:test, threading
an optional `deps.hasUsableCredentials` injection point through
getBestVisionModel()/getFallbackModels() (consistent with the existing
deps pattern in visionBridge.ts) since this project's native test runner
has no supported ESM module-mocking mechanism.

Running the full guardrails suite together also surfaced that this PR's
own credential-exclusion feature silently broke the pre-existing
vision-bridge-callmodel.test.ts fallback-retry test: with zero seeded
provider connections in that test's isolated DATA_DIR, every fallback
candidate is now confirmed-unusable and excluded, leaving no fallback to
retry. Seeded one credentialed connection there so the fallback-retry
mechanics stay independent of the (unrelated) credential filter.

Refs #8433

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
2026-07-26 12:11:19 -03:00

133 lines
4.7 KiB
TypeScript

/**
* callVisionModel fallback behavior — Integration test (PR #3377, Rule #18)
*
* Verifies that when the primary vision model fails, callVisionModel falls
* through to the next model in the fallback list, and that when ALL models
* fail it throws the last error (not a silent empty result).
*
* Run: node --import tsx/esm --test tests/unit/guardrails/vision-bridge-callmodel.test.ts
*/
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-vision-bridge-")
);
process.env.DATA_DIR = TEST_DATA_DIR;
// Prevent vision bridge from routing through a real API
process.env.VISION_BRIDGE_ENABLED = "false";
const { callVisionModel } = await import(
"../../../src/lib/guardrails/visionBridgeHelpers.ts"
);
const { createProviderConnection } = await import("../../../src/lib/db/providers.ts");
// PR #8433 taught getFallbackModels() to exclude any candidate without a
// usable active connection (see visionBridgeRouter.ts::getVisionCapableModels).
// This test's isolated DATA_DIR starts with zero provider connections, so
// without a seeded connection every fallback candidate is confirmed
// unusable and callVisionModel has nothing left to retry — seed one
// credentialed connection so the fallback-retry mechanics under test here
// stay independent of that (unrelated) credential-filtering behavior.
await createProviderConnection({
provider: "anthropic",
authType: "apikey",
name: "vision-bridge-callmodel-test-fallback",
apiKey: "sk-test-anthropic-fallback",
isActive: true,
});
const originalFetch = globalThis.fetch;
test.after(() => {
globalThis.fetch = originalFetch;
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test.afterEach(() => {
globalThis.fetch = originalFetch;
});
// Helper: build a minimal OpenAI-compat image data URI
const TINY_PNG = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
test("callVisionModel falls through to next model when primary fails", async () => {
let fetchCallCount = 0;
// The fallback candidate can legitimately resolve to either an OpenAI-compatible
// model (POST .../chat/completions, { choices: [{ message: { content } }] }) or an
// Anthropic model (POST .../v1/messages, { content: [{ type: "text", text }] }) —
// vision-bridge router priority (#7204) now ranks credentialed providers (openai/
// anthropic) ahead of opencode-*, so the mock must match whichever shape the
// fallback attempt actually requests instead of assuming OpenAI's shape.
const FALLBACK_TEXT = "fallback model description";
globalThis.fetch = async (url: RequestInfo | URL, _init?: RequestInit) => {
fetchCallCount++;
if (fetchCallCount === 1) {
// First call (primary model) — simulate API error
throw new Error("mock: primary model unavailable");
}
// Second call (fallback model) — return a valid response shaped for whichever
// API the fallback model actually calls.
const urlStr = typeof url === "string" ? url : url.toString();
const isAnthropicCall = urlStr.includes("/v1/messages");
const body = isAnthropicCall
? JSON.stringify({ content: [{ type: "text", text: FALLBACK_TEXT }] })
: JSON.stringify({ choices: [{ message: { content: FALLBACK_TEXT } }] });
return new Response(body, {
status: 200,
headers: { "Content-Type": "application/json" },
});
};
const result = await callVisionModel(
TINY_PNG,
{ model: "openai/gpt-4o-mini", prompt: "Describe this image." },
"sk-test-key",
{ fixedModel: "openai/gpt-4o-mini", maxFallbackAttempts: 2 }
);
assert.equal(
fetchCallCount,
2,
"must have attempted exactly 2 models (primary + 1 fallback)"
);
assert.equal(
result,
FALLBACK_TEXT,
"must return the fallback model's response"
);
});
test("callVisionModel throws when ALL models fail", async () => {
let fetchCallCount = 0;
globalThis.fetch = async () => {
fetchCallCount++;
throw new Error(`mock: model-${fetchCallCount} unavailable`);
};
await assert.rejects(
() =>
callVisionModel(
TINY_PNG,
{ model: "openai/gpt-4o-mini", prompt: "Describe this image." },
"sk-test-key",
{ fixedModel: "openai/gpt-4o-mini", maxFallbackAttempts: 2 }
),
(err: Error) => {
assert.ok(
err.message.includes("unavailable") || err.message.includes("All vision models failed"),
`error should indicate failure, got: ${err.message}`
);
return true;
}
);
assert.ok(fetchCallCount >= 1, "must have attempted at least 1 model");
});