mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-21 06:12:17 +03:00
* 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>
119 lines
4.7 KiB
TypeScript
119 lines
4.7 KiB
TypeScript
/**
|
|
* Vision Bridge credential checks — provider-prefix → node-id resolution.
|
|
*
|
|
* Re-land of the lost fix 932002580 (2026-08-19, never merged). A compatible
|
|
* provider node (openai-compatible-chat-<uuid>) is stored in
|
|
* `provider_connections` under its generated node id, while the
|
|
* operator-facing model id uses the node's configured public prefix
|
|
* (e.g. `skhynix/HCP-Vision-Latest`). `hasUsableCredentialsForModel` queried
|
|
* the bare prefix → 0 rows → false → `getBestVisionModel` discarded the
|
|
* configured fixed model and auto-selected a noauth candidate
|
|
* (cloudflare-playground/moonshotai/kimi-k2.7-code) → whole-request reroute
|
|
* to an unreachable executor → 502 on every image-bearing request.
|
|
*
|
|
* This suite lives in its OWN file/process on purpose: the fix caches the
|
|
* prefix index for 60s, and node:test runs each file in its own process —
|
|
* seeding must happen before the first index read in this process, so the
|
|
* integration test must not share a process with tests that warm the cache
|
|
* against an empty node table.
|
|
*/
|
|
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-cred-prefix-"));
|
|
|
|
// Set before any db import so getDbInstance() picks the temp dir.
|
|
process.env.DATA_DIR = TEST_DATA_DIR;
|
|
|
|
const core = await import("../../../src/lib/db/core.ts");
|
|
const providersDb = await import("../../../src/lib/db/providers.ts");
|
|
const { hasUsableCredentialsForModel, resolveProviderCredentialIds } =
|
|
await import("../../../src/lib/guardrails/visionBridgeCredentials.ts");
|
|
|
|
async function resetStorage() {
|
|
core.resetDbInstance();
|
|
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
|
}
|
|
|
|
test.after(() => {
|
|
core.resetDbInstance();
|
|
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
});
|
|
|
|
// ── resolveProviderCredentialIds (pure prefix→node id resolution) ───────────
|
|
|
|
test("resolveProviderCredentialIds returns the literal provider when no prefix mapping exists", () => {
|
|
assert.deepEqual(resolveProviderCredentialIds("openai", new Map()), ["openai"]);
|
|
});
|
|
|
|
test("resolveProviderCredentialIds appends the mapped node id for a known prefix", () => {
|
|
const prefixToNode = new Map([["skhynix", "openai-compatible-chat-abc-123"]]);
|
|
assert.deepEqual(resolveProviderCredentialIds("skhynix", prefixToNode), [
|
|
"skhynix",
|
|
"openai-compatible-chat-abc-123",
|
|
]);
|
|
});
|
|
|
|
test("resolveProviderCredentialIds dedupes when the mapping targets the literal provider", () => {
|
|
const prefixToNode = new Map([["openai", "openai"]]);
|
|
assert.deepEqual(resolveProviderCredentialIds("openai", prefixToNode), ["openai"]);
|
|
});
|
|
|
|
test("resolveProviderCredentialIds tolerates undefined prefix index", () => {
|
|
assert.deepEqual(resolveProviderCredentialIds("skhynix", undefined), ["skhynix"]);
|
|
});
|
|
|
|
// ── integration: node-id-stored connection found through the prefix (#re-land) ──
|
|
|
|
test("prefix-keyed model finds a row stored under the compatible node id (skhynix reroute regression)", async () => {
|
|
await resetStorage();
|
|
const uniq = `itest-${process.pid}-${Date.now()}`;
|
|
const nodeId = `openai-compatible-chat-${uniq}`;
|
|
const prefix = `skhynix-${uniq}`;
|
|
await providersDb.createProviderNode({
|
|
id: nodeId,
|
|
type: "openai-compatible",
|
|
name: "SK hynix (test)",
|
|
prefix,
|
|
apiType: "chat",
|
|
baseUrl: "http://localhost:1/v1",
|
|
});
|
|
|
|
// Phase 1 (negative): only a banned connection under the node id — the
|
|
// prefix must map to the node and the terminal row must still block it.
|
|
await providersDb.createProviderConnection({
|
|
provider: nodeId,
|
|
authType: "apikey",
|
|
apiKey: "sk-dead-key",
|
|
isActive: true,
|
|
testStatus: "banned",
|
|
});
|
|
assert.equal(
|
|
await hasUsableCredentialsForModel(`${prefix}/HCP-Vision-Latest`),
|
|
false,
|
|
"a banned connection under the mapped node id must not count as usable"
|
|
);
|
|
|
|
// Phase 2 (positive): an active keyed connection under the node id must be
|
|
// found through the prefix mapping. Before the fix this queried
|
|
// provider = "<prefix>" → 0 rows → false, so the Vision Bridge discarded
|
|
// the operator-configured model and auto-selected another provider.
|
|
await providersDb.createProviderConnection({
|
|
provider: nodeId,
|
|
authType: "apikey",
|
|
apiKey: "sk-hynix-key",
|
|
isActive: true,
|
|
testStatus: "active",
|
|
});
|
|
const usable = await hasUsableCredentialsForModel(`${prefix}/HCP-Vision-Latest`);
|
|
assert.equal(
|
|
usable,
|
|
true,
|
|
"prefix-keyed model must find the connection stored under the node id"
|
|
);
|
|
});
|