ai_api / src /lib /guardrails /visionBridge.ts
Yogesh
initial deploy
cd8bd0a
Raw
History Blame Contribute Delete
8.76 kB
/**
* Vision Bridge Guardrail.
* Intercepts image-bearing requests to non-vision models,
* extracts descriptions via vision model, and replaces images with text.
*/
import { BaseGuardrail, type GuardrailContext, type GuardrailResult } from "./base";
import { getSettings as defaultGetSettings } from "@/lib/db/settings";
import { getResolvedModelCapabilities } from "@/lib/modelCapabilities";
import {
extractImageParts,
callVisionModel as defaultCallVisionModel,
replaceImageParts,
} from "./visionBridgeHelpers";
import {
VISION_BRIDGE_DEFAULTS,
getVisionBridgeConfig,
isVisionBridgeForcedModel,
} from "@/shared/constants/visionBridgeDefaults";
type ComboVisionBridgeDecision = "process" | "skip" | "not-combo";
/// Check if a combo model should trigger vision bridge processing.
/// Resolves combo targets and returns:
/// - "process" if any target cannot be proven vision-capable
/// - "skip" if all model targets can handle images directly
/// - "not-combo" when the model is not a combo/mapping
async function getComboVisionBridgeDecision(model: string): Promise<ComboVisionBridgeDecision> {
try {
const { getComboByName } = await import("@/lib/localDb");
const { resolveComboForModel } = await import("@/lib/db/modelComboMappings");
// 1. Try to find combo by exact name match
let combo = await getComboByName(model);
// 2. If no exact match, try model-combo mapping
if (!combo) {
const mapping = await resolveComboForModel(model);
if (!mapping) return "not-combo";
const comboName = mapping.comboName ?? mapping.name ?? null;
if (!comboName) return "not-combo";
combo = await getComboByName(comboName);
}
if (!combo) return "not-combo";
// 3. Get the combo's models (target steps)
const rawModels = (combo as Record<string, unknown>).models;
if (!Array.isArray(rawModels)) return "process";
// 4. Check each target for vision support
// combo-ref → conservative (process images)
// model step with no native vision → process images
// all model steps with native vision → safe to skip
let hasModelStep = false;
for (const step of rawModels) {
const s = step as Record<string, unknown>;
if (s.kind === "combo-ref") return "process";
if (s.kind === "model") {
hasModelStep = true;
const targetModel = s.model;
if (typeof targetModel === "string") {
const caps = getResolvedModelCapabilities(targetModel);
if (caps.supportsVision !== true) {
return "process";
}
} else {
return "process";
}
}
}
// All model steps support vision — safe to skip
if (hasModelStep) return "skip";
// No recognizable steps — don't force bridge
return "not-combo";
} catch {
// On error, try to process images (conservative)
return "process";
}
}
export interface VisionBridgeDependencies {
getSettings?: () => Promise<Record<string, unknown>>;
callVisionModel?: (
imageDataUri: string,
config: import("./visionBridgeHelpers").VisionModelConfig,
apiKey?: string
) => Promise<string>;
/** Override combo-target vision check — return true to force processing, false to skip. */
checkModelHasComboMapping?: (model: string) => Promise<boolean>;
}
export class VisionBridgeGuardrail extends BaseGuardrail {
name = "vision-bridge";
priority = 5;
private readonly deps: VisionBridgeDependencies;
constructor(options?: { enabled?: boolean; deps?: VisionBridgeDependencies }) {
super("vision-bridge", { priority: 5, enabled: options?.enabled });
this.deps = options?.deps ?? {};
}
async preCall(payload: unknown, context: GuardrailContext): Promise<GuardrailResult<unknown>> {
// 1. Check if disabled at guardrail level
if (!this.enabled) {
return { block: false };
}
// 2. Check disabled via context (header, body, API key)
if (context.disabledGuardrails?.includes("vision-bridge")) {
return { block: false };
}
// 3. Get model from context or payload
const model =
context.model || ((payload as Record<string, unknown>)?.model as string | undefined);
if (!model) {
return { block: false };
}
const forceVisionBridge = isVisionBridgeForcedModel(model);
// 4. Check if model supports vision
const capabilities = getResolvedModelCapabilities(model);
const comboVisionBridgeDecision = forceVisionBridge
? "process"
: this.deps.checkModelHasComboMapping
? (await this.deps.checkModelHasComboMapping(model))
? "process"
: "skip"
: await getComboVisionBridgeDecision(model);
if (comboVisionBridgeDecision === "skip") {
return { block: false };
}
if (capabilities.supportsVision === true && !forceVisionBridge) {
// The request model supports vision natively, but check if a
// model-combo mapping routes this model through a combo where
// some targets may NOT support vision. In that case, the vision
// bridge must process images so combo targets can describe them.
if (comboVisionBridgeDecision !== "process") {
return { block: false };
}
// Combo mapping found — fall through to process images
}
// 5. Get body and check for messages
const body = payload as Record<string, unknown>;
const messages = body?.messages;
if (!Array.isArray(messages) || messages.length === 0) {
return { block: false };
}
// 6. Check for images using helper (extractImageParts returns empty if no images)
const imageParts = extractImageParts(messages as Parameters<typeof extractImageParts>[0]);
if (imageParts.length === 0) {
return { block: false };
}
// 7. Get settings (injectable for testing)
const getSettings = this.deps.getSettings ?? defaultGetSettings;
let settings: Record<string, unknown> = {};
try {
settings = await getSettings();
} catch {
// If getSettings fails, use defaults
}
// 8. Check if Vision Bridge is enabled in settings
const enabled = settings.visionBridgeEnabled ?? VISION_BRIDGE_DEFAULTS.enabled;
if (!enabled) {
return { block: false };
}
// 9. Get configuration
const config = getVisionBridgeConfig({
visionBridgeEnabled: settings.visionBridgeEnabled as boolean | undefined,
visionBridgeModel: settings.visionBridgeModel as string | undefined,
visionBridgePrompt: settings.visionBridgePrompt as string | undefined,
visionBridgeTimeout: settings.visionBridgeTimeout as number | undefined,
visionBridgeMaxImages: settings.visionBridgeMaxImages as number | undefined,
});
// 10. Limit images
const limitedParts = imageParts.slice(0, config.maxImages);
// 11. Call vision model for each image in parallel (injectable for testing)
const callVision = this.deps.callVisionModel ?? defaultCallVisionModel;
const logger = context.log;
const startTime = Date.now();
// Process all images in parallel using Promise.allSettled for fail-partial behavior
const results = await Promise.allSettled(
limitedParts.map(async (imagePart, i) => {
const description = await callVision(imagePart.imageUrl, config);
return `[Image ${i + 1}]: ${description}`;
})
);
// Collect descriptions maintaining original order. A failed describe yields
// `null` so the original image is preserved downstream (#4012) — replacing it
// with an "(unavailable)" stub silently destroyed images for vision-capable
// upstreams whose capability OmniRoute couldn't prove from the registry.
const descriptions: (string | null)[] = results.map((result, i) => {
if (result.status === "fulfilled") {
return result.value;
}
const message =
result.reason instanceof Error ? result.reason.message : String(result.reason);
logger?.warn?.("VISION-BRIDGE", `Failed to get description for image ${i + 1}: ${message}`);
return null;
});
// 12. Replace image parts with text descriptions (null → keep original image)
const modifiedBody = replaceImageParts(
body as Parameters<typeof replaceImageParts>[0],
descriptions
);
const processingTime = Date.now() - startTime;
return {
block: false,
modifiedPayload: modifiedBody,
meta: {
imagesProcessed: descriptions.length,
// Keep meta observability stable: report a human label for failures.
descriptions: descriptions.map((d, i) => d ?? `[Image ${i + 1}]: (unavailable)`),
processingTimeMs: processingTime,
visionModel: config.model,
},
};
}
}