Files
OmniRoute/tests/unit/guardrails/visionBridgeHelpers.callVisionModel.test.ts
rinseaid 458ab1aac0 fix(vision): preserve high detail for inline images (#10554)
* fix(vision): preserve high detail for inline images

* fix(vision): scope high-detail image default to OpenCode clients

defaultImageDetail() was applied at prepareUpstreamBody, the shared
upstream-body prep path for every provider and format, not just the
OpenCode path the fix targets. Gate it on isOpencodeClient (the
existing User-Agent/x-opencode-* header signal already used for
bypassDefaultToolLimit at this call site) so non-OpenCode callers keep
the provider's own image detail default. Adds a regression test
covering a non-OpenCode caller against the same opencode-zen provider.

* fix(vision): document and test the global vs OpenCode-only detail scope

The OpenCode-only high-detail default in chatCore/upstreamBody.ts
(defaultImageDetail, gated on isOpencodeClient) forwards the caller's
own image_url.detail and was already correctly scoped in a prior
commit on this branch.

The internal vision-bridge describe self-loop (visionBridgeHelpers.ts)
is architecturally global: VisionBridgeGuardrail runs for every
caller/provider whenever the target model lacks vision support, and
there is no client-identity signal at that layer to gate on. Its
describe prompt explicitly asks the vision model to transcribe visible
text, so requesting "high" detail unconditionally is justified on its
own merits (OCR accuracy), independent of the OpenCode motivation.

Adds a compatibility assertion proving the Anthropic wire-format
branch of the same describe self-loop carries no `detail` field (it
has no such concept) and is therefore unaffected by this default, and
documents the split (OpenCode-only forwarding vs. global describe
default) in docs/security/GUARDRAILS.md.

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

---------

Co-authored-by: rinseaid <rinseaid@rinseaid.net>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-08-18 10:52:11 -03:00

420 lines
13 KiB
TypeScript

/**
* Tests for callVisionModel helper function.
*/
import test from "node:test";
import assert from "node:assert/strict";
import dns from "node:dns";
import { callVisionModel, type VisionModelConfig } from "@/lib/guardrails/visionBridgeHelpers";
import { createProviderConnection } from "@/lib/db/providers";
import { resetDbInstance } from "@/lib/db/core";
// Store original fetch
const originalFetch = globalThis.fetch;
// Stub DNS for fetchRemoteImage's GHSA-cmhj-wh2f-9cgx DNS-rebinding guard
// (assertHostnameResolvesPublic in src/shared/network/remoteImageFetch.ts).
// These tests mock globalThis.fetch with example.com hosts that don't actually
// resolve in CI; the call path (callVisionModel -> fetchRemoteImageAsDataUri)
// does not expose a way to inject a `lookup` stub through to fetchRemoteImage,
// so we monkey-patch dns.promises.lookup with a pass-through public-IP
// resolver. Node --test runs each test file in its own process, so this
// rebinding does not leak across files.
const originalDnsLookup = dns.promises.lookup;
(dns.promises as { lookup: unknown }).lookup = (async (
_hostname: string,
options?: { all?: boolean }
) => {
const record = { address: "203.0.113.1", family: 4 };
return options && options.all ? [record] : record;
}) as typeof dns.promises.lookup;
process.on("exit", () => {
(dns.promises as { lookup: unknown }).lookup = originalDnsLookup;
});
// (#8430) getBestVisionModel now validates that a `fixedModel` has a usable
// connection (via hasUsableCredentialsForModel, which queries the real DB)
// before returning it — an unreachable fixedModel falls through to
// auto-selection and, with nothing else configured either, resolves to `null`,
// which callVisionModel turns into a hard "No vision-capable provider
// connected" error before it ever reaches the HTTP call these tests mock.
// The router/credential-selection logic itself is already covered by
// visionBridgeRouter.test.ts and repro-8430.test.ts; these tests exercise
// callVisionModel's own request/response handling, so they just need one
// usable connection seeded per provider they use ("openai/gpt-4o-mini",
// "anthropic/claude-3-haiku") so getBestVisionModel resolves the requested
// fixedModel unchanged instead of null.
test.before(async () => {
await createProviderConnection({
provider: "openai",
authType: "apikey",
name: "vision-bridge-test-openai",
apiKey: "sk-test-openai",
});
await createProviderConnection({
provider: "anthropic",
authType: "apikey",
name: "vision-bridge-test-anthropic",
apiKey: "sk-test-anthropic",
});
});
test.after(() => {
resetDbInstance();
});
test("callVisionModel returns description on success", async () => {
// Mock global fetch
const mockResponse = {
ok: true,
json: async () => ({
choices: [{ message: { content: "A beautiful sunset over the ocean" } }],
}),
};
globalThis.fetch = async () => mockResponse as unknown as Response;
try {
const config: VisionModelConfig = {
model: "openai/gpt-4o-mini",
prompt: "Describe this image",
timeoutMs: 30000,
maxImages: 10,
};
const result = await callVisionModel(
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
config
);
assert.strictEqual(result, "A beautiful sunset over the ocean");
} finally {
// Restore original fetch
globalThis.fetch = originalFetch;
}
});
test("callVisionModel can route a catalog model through the OmniRoute self-loop", async () => {
let capturedUrl = "";
let capturedBody: Record<string, unknown> = {};
let capturedHeaders: Record<string, string> = {};
const fetchImpl: typeof fetch = async (input, init) => {
capturedUrl = String(input);
capturedBody = JSON.parse(String(init?.body));
capturedHeaders = (init?.headers ?? {}) as Record<string, string>;
return Response.json({ choices: [{ message: { content: "GREEN_SCENE_2" } }] });
};
const result = await callVisionModel("data:image/png;base64,iVBORw0KGgo", {
model: "openai/gpt-4o-mini",
prompt: "Describe this frame",
timeoutMs: 30000,
maxImages: 1,
routeThroughOmniRoute: true,
fetchImpl,
});
const url = new URL(capturedUrl);
assert.equal(url.hostname, "localhost");
assert.equal(url.pathname, "/v1/chat/completions");
assert.equal(capturedBody.model, "openai/gpt-4o-mini");
assert.equal(capturedHeaders["x-omniroute-admission-bypass"], "internal");
assert.match(capturedHeaders["x-omniroute-disabled-guardrails"], /video-bridge/);
assert.equal(result, "GREEN_SCENE_2");
});
test("callVisionModel throws on HTTP error", async () => {
const mockResponse = {
ok: false,
status: 500,
text: async () => "Internal Server Error",
};
globalThis.fetch = async () => mockResponse as unknown as Response;
try {
const config: VisionModelConfig = {
model: "openai/gpt-4o-mini",
prompt: "Describe this image",
timeoutMs: 30000,
maxImages: 10,
};
await assert.rejects(
async () => await callVisionModel("data:image/png;base64,iVBORw0KGgo", config),
/Vision API error 500/
);
} finally {
globalThis.fetch = originalFetch;
}
});
test("callVisionModel throws on API error response", async () => {
const mockResponse = {
ok: true,
json: async () => ({
error: { message: "Invalid API key" },
}),
};
globalThis.fetch = async () => mockResponse as unknown as Response;
try {
const config: VisionModelConfig = {
model: "openai/gpt-4o-mini",
prompt: "Describe this image",
timeoutMs: 30000,
maxImages: 10,
};
await assert.rejects(
async () => await callVisionModel("data:image/png;base64,iVBORw0KGgo", config),
/Invalid API key/
);
} finally {
globalThis.fetch = originalFetch;
}
});
test("callVisionModel throws on empty response", async () => {
const mockResponse = {
ok: true,
json: async () => ({
choices: [{}],
}),
};
globalThis.fetch = async () => mockResponse as unknown as Response;
try {
const config: VisionModelConfig = {
model: "openai/gpt-4o-mini",
prompt: "Describe this image",
timeoutMs: 30000,
maxImages: 10,
};
await assert.rejects(
async () => await callVisionModel("data:image/png;base64,iVBORw0KGgo", config),
/empty or invalid/
);
} finally {
globalThis.fetch = originalFetch;
}
});
test("callVisionModel trims whitespace from response", async () => {
const mockResponse = {
ok: true,
json: async () => ({
choices: [{ message: { content: " A test description " } }],
}),
};
globalThis.fetch = async () => mockResponse as unknown as Response;
try {
const config: VisionModelConfig = {
model: "openai/gpt-4o-mini",
prompt: "Describe this image",
timeoutMs: 30000,
maxImages: 10,
};
const result = await callVisionModel("data:image/png;base64,iVBORw0KGgo", config);
assert.strictEqual(result, "A test description");
} finally {
globalThis.fetch = originalFetch;
}
});
test("callVisionModel passes custom API key", async () => {
let capturedHeaders: Record<string, string> = {};
const mockResponse = {
ok: true,
json: async () => ({
choices: [{ message: { content: "Description" } }],
}),
};
globalThis.fetch = async (url: URL | RequestInfo, init?: RequestInit) => {
if (init?.headers) {
capturedHeaders = init.headers as Record<string, string>;
}
return mockResponse as unknown as Response;
};
try {
const config: VisionModelConfig = {
model: "openai/gpt-4o-mini",
prompt: "Describe this image",
timeoutMs: 30000,
maxImages: 10,
};
await callVisionModel("data:image/png;base64,iVBORw0KGgo", config, "sk-custom-key");
assert.strictEqual(capturedHeaders["Authorization"], "Bearer sk-custom-key");
} finally {
globalThis.fetch = originalFetch;
}
});
test("callVisionModel uses correct request body format", async () => {
let capturedBody: Record<string, unknown> = {};
const mockResponse = {
ok: true,
json: async () => ({
choices: [{ message: { content: "Description" } }],
}),
};
globalThis.fetch = async (url: URL | RequestInfo, init?: RequestInit) => {
if (init?.body) {
capturedBody = JSON.parse(init.body as string);
}
return mockResponse as unknown as Response;
};
try {
const config: VisionModelConfig = {
model: "openai/gpt-4o-mini",
prompt: "What is in this image?",
timeoutMs: 30000,
maxImages: 10,
};
const imageUri = "data:image/png;base64,test123";
await callVisionModel(imageUri, config);
// Verify request structure
assert.strictEqual(capturedBody.model, "gpt-4o-mini");
assert.strictEqual(
capturedBody.stream,
false,
"the JSON parser requires the internal vision request to opt out of SSE"
);
assert.ok(Array.isArray(capturedBody.messages));
assert.strictEqual((capturedBody.messages as unknown[]).length, 1);
const message = (capturedBody.messages as Array<{ role: string; content: unknown[] }>)[0];
assert.strictEqual(message.role, "user");
assert.ok(Array.isArray(message.content));
assert.strictEqual(message.content.length, 2);
// First content is image_url
const imagePart = message.content[0] as {
type: string;
image_url: { url: string; detail: string };
};
assert.strictEqual(imagePart.type, "image_url");
assert.strictEqual(imagePart.image_url.url, imageUri);
assert.strictEqual(imagePart.image_url.detail, "high");
// Second content is text prompt
const textPart = message.content[1] as { type: string; text: string };
assert.strictEqual(textPart.type, "text");
assert.strictEqual(textPart.text, "What is in this image?");
} finally {
globalThis.fetch = originalFetch;
}
});
test("callVisionModel fetches remote images before Anthropic requests", async () => {
const fetchCalls: Array<{ url: string; init?: RequestInit }> = [];
globalThis.fetch = async (url: URL | RequestInfo, init?: RequestInit) => {
const requestUrl = String(url);
fetchCalls.push({ url: requestUrl, init });
if (requestUrl === "https://cdn.example.com/cat.png") {
return new Response(Buffer.from("cat-image-bytes"), {
status: 200,
headers: { "Content-Type": "image/png" },
});
}
return new Response(
JSON.stringify({
content: [{ type: "text", text: "A cat sitting on a chair" }],
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
}
);
};
try {
const config: VisionModelConfig = {
model: "anthropic/claude-3-haiku",
prompt: "Describe this image",
timeoutMs: 30000,
maxImages: 10,
};
const result = await callVisionModel("https://cdn.example.com/cat.png", config, "sk-ant");
assert.strictEqual(result, "A cat sitting on a chair");
assert.strictEqual(fetchCalls.length, 2);
assert.strictEqual(fetchCalls[0].url, "https://cdn.example.com/cat.png");
assert.strictEqual(fetchCalls[1].url, "https://api.anthropic.com/v1/messages");
const anthropicBody = JSON.parse(fetchCalls[1].init?.body as string);
const imagePart = anthropicBody.messages[0].content[0];
const imageSource = imagePart.source;
assert.strictEqual(imageSource.type, "base64");
assert.strictEqual(imageSource.media_type, "image/png");
assert.strictEqual(imageSource.data, Buffer.from("cat-image-bytes").toString("base64"));
// Compatibility guard for the global (not OpenCode-scoped) `detail: "high"`
// default added to the OpenAI-compatible describe path: Anthropic's wire
// format has no `detail` concept, so the describe self-loop must not leak
// an OpenAI-only field into the Anthropic request body.
assert.strictEqual(imagePart.detail, undefined);
assert.strictEqual(imageSource.detail, undefined);
} finally {
globalThis.fetch = originalFetch;
}
});
test("callVisionModel propagates an external abort to fetch and stops before fallback", async () => {
const controller = new AbortController();
let fetchCalls = 0;
let fetchSignal: AbortSignal | null = null;
globalThis.fetch = async (_url: URL | RequestInfo, init?: RequestInit) => {
fetchCalls += 1;
fetchSignal = init?.signal instanceof AbortSignal ? init.signal : null;
controller.abort();
const error = new Error("private aborted request detail");
error.name = "AbortError";
throw error;
};
try {
const config: VisionModelConfig = {
model: "openai/gpt-4o-mini",
prompt: "Describe this image",
timeoutMs: 30_000,
maxImages: 10,
signal: controller.signal,
};
await assert.rejects(
() =>
callVisionModel(
"data:image/png;base64,iVBORw0KGgo",
config,
"sk-test",
{ maxFallbackAttempts: 2 },
{
hasUsableCredentials: async (model) =>
model === "openai/gpt-4o-mini" || model.startsWith("anthropic/"),
}
),
/timed out|aborted/i
);
assert.equal(fetchCalls, 1, "an aborted parent request must not try a fallback model");
assert.equal(fetchSignal?.aborted, true, "the parent abort must reach the active fetch");
} finally {
globalThis.fetch = originalFetch;
}
});