Files
OmniRoute/tests/unit/guardrails/visionBridgeHelpers.replaceImageParts.test.ts
initguru 9bc7eb8fd2 fix(vision-bridge): nested tool_result images + provider-prefix credential check (#12903)
* fix(vision-bridge): extract/replace images nested inside tool_result content

Claude Code sends tool_result images as {type:"image",source:{base64}}
nested inside a tool_result's content array, not as top-level content
parts. The vision-bridge guardrail's extractImageParts filtered nested
hits out (!p.nested), so these images were silently dropped — a
text-only executor then received a request with no image and returned
HTTP 400.

Port the path-based nested extraction/replace fix:
- MediaPart gains a path field: the key/index chain from
  message.content[partIndex] down to the media object itself.
- inspect() tracks the path through recursion; pushPart stamps it.
- extractImageParts drops the !p.nested gate and emits path for nested
  hits (extract↔replace contract preserved: same order, every hit
  replaceable).
- replaceImageParts rewrites via detectMediaParts: top-level hits swap
  their content slot, nested hits walk MediaPart.path via the new
  replaceObjectAtPath helper.
- ensureBase64ImagesForClaudeWire skips nested hits (.filter(!p.path))
  to keep its sequential index map aligned.

TDD: 7 failing tests (path field, nested extract, nested replace,
document order) → 47/47 pass. typecheck:core clean.

* fix(vision-bridge): resolve provider prefix to node id for credential check

Re-land 932002580 (2026-08-19), which was never merged: it branched off
7acddd91a and fell outside the group-D reimplementation range (4f01fba68
re-picked only cf4dfc868). The same root cause now surfaces on the reroute
path (visionBridgeRerouteTextOnly=true): hasUsableCredentialsForModel
queried provider_connections with the bare node prefix "skhynix" → 0 rows
→ false → getBestVisionModel discarded the configured fixed model and
auto-selected cloudflare-playground/moonshotai/kimi-k2.7-code → Playwright
chromium missing → 502 on every image-bearing request.

- resolveProviderCredentialIds: literal prefix + prefix-index mapped node
  id (no-op dedup), composed after #10760's alias→canonical
  resolveProviderId.
- getPrefixToNode: 60s-cached getProviderPrefixIndex lookup, fail-open
  null.
- hasUsableCredentialsForModel: loop the resolved provider ids and return
  true when any has a usable active connection; noauth empty-set
  semantics (#10702) preserved.

TDD: resolveProviderCredentialIds 4/4 + skhynix node-id integration test
(RED confirmed: false !== true on the reroute regression). Focused
regression green: visionBridgeCredentials 10/10, vision-bridge reroute/
credentials suite 12/12, vision-bridge policy/mode/cache 18/18,
visionBridgeRouter 16/16. typecheck:core clean.

---------

Co-authored-by: Jihyun Son <jihyun.son@sk.com>
Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
2026-09-17 02:30:54 -03:00

351 lines
11 KiB
TypeScript

/**
* Tests for replaceImageParts helper function.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { replaceImageParts } from "@/lib/guardrails/visionBridgeHelpers";
test("replaceImageParts replaces single image with description", () => {
const body = {
model: "minimax/minimax-01",
messages: [
{
role: "user",
content: [
{ type: "text", text: "What is in this image?" },
{ type: "image_url", image_url: { url: "https://example.com/image.png" } },
],
},
],
};
const descriptions = ["[Image 1]: A beautiful sunset"];
const result = replaceImageParts(body, descriptions);
// Should have original text preserved
const content = result.messages[0].content as Array<{ type: string; text?: string }>;
assert.strictEqual(content[0].type, "text");
assert.strictEqual(content[0].text, "What is in this image?");
// Should have description instead of image
assert.strictEqual(content[1].type, "text");
assert.strictEqual(content[1].text, "[Image 1]: A beautiful sunset");
});
test("replaceImageParts replaces multiple images with descriptions", () => {
const body = {
model: "minimax/minimax-01",
messages: [
{
role: "user",
content: [
{ type: "text", text: "Compare these images" },
{ type: "image_url", image_url: { url: "https://example.com/A.png" } },
{ type: "image_url", image_url: { url: "https://example.com/B.png" } },
{ type: "image_url", image_url: { url: "https://example.com/C.png" } },
],
},
],
};
const descriptions = ["[Image 1]: A cat", "[Image 2]: A dog", "[Image 3]: A bird"];
const result = replaceImageParts(body, descriptions);
const content = result.messages[0].content as Array<{ type: string; text?: string }>;
assert.strictEqual(content[0].type, "text");
assert.strictEqual(content[0].text, "Compare these images");
assert.strictEqual(content[1].type, "text");
assert.strictEqual(content[1].text, "[Image 1]: A cat");
assert.strictEqual(content[2].type, "text");
assert.strictEqual(content[2].text, "[Image 2]: A dog");
assert.strictEqual(content[3].type, "text");
assert.strictEqual(content[3].text, "[Image 3]: A bird");
});
test("replaceImageParts handles empty descriptions array", () => {
const body = {
model: "minimax/minimax-01",
messages: [
{
role: "user",
content: [
{ type: "text", text: "Hello" },
{ type: "image_url", image_url: { url: "https://example.com/image.png" } },
],
},
],
};
const result = replaceImageParts(body, []);
// Original should be unchanged
const content = result.messages[0].content as Array<{ type: string }>;
assert.strictEqual(content[1].type, "image_url");
});
test("replaceImageParts preserves non-image content", () => {
const body = {
model: "minimax/minimax-01",
messages: [
{
role: "system",
content: "You are a helpful assistant.",
},
{
role: "user",
content: [
{ type: "text", text: "Analyze this" },
{ type: "image_url", image_url: { url: "https://example.com/image.png" } },
],
},
{
role: "assistant",
content: "I can see the image shows a sunset.",
},
],
};
const result = replaceImageParts(body, ["[Image 1]: A sunset over the ocean"]);
// System message should be unchanged
assert.strictEqual(result.messages[0].content, "You are a helpful assistant.");
// Assistant message should be unchanged
assert.strictEqual(result.messages[2].content, "I can see the image shows a sunset.");
});
test("replaceImageParts handles base64 images", () => {
const body = {
model: "minimax/minimax-01",
messages: [
{
role: "user",
content: [
{
type: "image",
source: {
type: "base64",
media_type: "image/png",
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==",
},
},
],
},
],
};
const descriptions = ["[Image 1]: A red circle"];
const result = replaceImageParts(body, descriptions);
const content = result.messages[0].content as Array<{ type: string; text?: string }>;
assert.strictEqual(content[0].type, "text");
assert.strictEqual(content[0].text, "[Image 1]: A red circle");
});
test("replaceImageParts handles undefined descriptions", () => {
const body = {
model: "test",
messages: [
{
role: "user",
content: [
{ type: "text", text: "Hello" },
{ type: "image_url", image_url: { url: "https://example.com/image.png" } },
],
},
],
};
const result = replaceImageParts(body, undefined as unknown as string[]);
// Should return original body when descriptions is undefined
const content = result.messages[0].content as Array<{ type: string }>;
assert.strictEqual(content[1].type, "image_url");
});
test("replaceImageParts handles empty messages array", () => {
const body = {
model: "test",
messages: [],
};
const descriptions = ["[Image 1]: Description"];
const result = replaceImageParts(body, descriptions);
assert.deepStrictEqual(result.messages, []);
});
test("replaceImageParts handles messages without content array", () => {
const body = {
model: "test",
messages: [
{ role: "user", content: "Just a text message" },
{
role: "user",
content: [{ type: "image_url", image_url: { url: "https://example.com/image.png" } }],
},
],
};
const descriptions = ["[Image 1]: Description"];
const result = replaceImageParts(body, descriptions);
// First message (string content) should be unchanged
assert.strictEqual(result.messages[0].content, "Just a text message");
// Second message should have image replaced
const content = result.messages[1].content as Array<{ type: string; text?: string }>;
assert.strictEqual(content[0].type, "text");
assert.strictEqual(content[0].text, "[Image 1]: Description");
});
test("replaceImageParts does not modify original body", () => {
const body = {
model: "minimax/minimax-01",
messages: [
{
role: "user",
content: [
{ type: "text", text: "Original" },
{ type: "image_url", image_url: { url: "https://example.com/image.png" } },
],
},
],
};
const descriptions = ["[Image 1]: Modified"];
replaceImageParts(body, descriptions);
// Original should be unchanged
const content = body.messages[0].content as Array<{ type: string }>;
assert.strictEqual(content[1].type, "image_url");
});
test("replaceImageParts handles mixed images and text", () => {
const body = {
model: "test",
messages: [
{
role: "user",
content: [
{ type: "image_url", image_url: { url: "https://example.com/first.png" } },
{ type: "text", text: "between images" },
{ type: "image_url", image_url: { url: "https://example.com/second.png" } },
],
},
],
};
const descriptions = ["[Image 1]: First image", "[Image 2]: Second image"];
const result = replaceImageParts(body, descriptions);
const content = result.messages[0].content as Array<{ type: string; text?: string }>;
assert.strictEqual(content[0].type, "text");
assert.strictEqual(content[0].text, "[Image 1]: First image");
assert.strictEqual(content[1].type, "text");
assert.strictEqual(content[1].text, "between images");
assert.strictEqual(content[2].type, "text");
assert.strictEqual(content[2].text, "[Image 2]: Second image");
});
test("replaceImageParts replaces an image nested inside tool_result content", () => {
const body = {
model: "deepseek-ai/DeepSeek-V4-Flash-0731",
messages: [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_01",
content: [
{
type: "image",
source: { type: "base64", media_type: "image/png", data: "AAA=" },
},
],
},
{ type: "text", text: "[Image description]" },
],
},
],
};
const result = replaceImageParts(body, ["[Image 1]: A screenshot of the OmniRoute dashboard"]);
const toolResult = result.messages[0].content[0] as {
type: string;
content: Array<{ type: string; text?: string }>;
};
assert.strictEqual(toolResult.type, "tool_result");
assert.deepStrictEqual(toolResult.content, [
{ type: "text", text: "[Image 1]: A screenshot of the OmniRoute dashboard" },
]);
// Sibling text preserved.
assert.strictEqual(result.messages[0].content[1].type, "text");
assert.strictEqual(result.messages[0].content[1].text, "[Image description]");
});
test("replaceImageParts consumes descriptions in document order for nested and top-level images", () => {
const body = {
model: "deepseek-ai/DeepSeek-V4-Flash-0731",
messages: [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "a",
content: [{ type: "image_url", image_url: { url: "https://x/a.png" } }],
},
{ type: "image_url", image_url: { url: "https://x/b.png" } },
{
type: "tool_result",
tool_use_id: "c",
content: [{ type: "image_url", image_url: { url: "https://x/c.png" } }],
},
],
},
],
};
const result = replaceImageParts(body, ["DA", "DB", "DC"]);
const content = result.messages[0].content as Array<{
type: string;
content?: Array<{ type: string; text?: string }>;
text?: string;
}>;
// part[0] tool_result → nested a → DA
assert.strictEqual(content[0].content?.[0].text, "DA");
// part[1] top-level b → DB
assert.strictEqual(content[1].text, "DB");
// part[2] tool_result → nested c → DC
assert.strictEqual(content[2].content?.[0].text, "DC");
});
test("replaceImageParts keeps a nested image when its description is null (#4012)", () => {
const body = {
model: "deepseek-ai/DeepSeek-V4-Flash-0731",
messages: [
{
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "a",
content: [{ type: "image_url", image_url: { url: "https://x/a.png" } }],
},
],
},
],
};
const result = replaceImageParts(body, [null]);
const toolResult = result.messages[0].content[0] as { content: Array<{ type: string }> };
assert.strictEqual(toolResult.content[0].type, "image_url");
});