diff --git a/src/lib/guardrails/visionBridge.ts b/src/lib/guardrails/visionBridge.ts index 3a27fd3d96..1fc032c374 100644 --- a/src/lib/guardrails/visionBridge.ts +++ b/src/lib/guardrails/visionBridge.ts @@ -405,7 +405,11 @@ export class VisionBridgeGuardrail extends BaseGuardrail { const description = cached ?? (await callVision(imagePart.imageUrl, describeConfig)); if (cached === undefined && key && cache) cache.set(key, description); recordBridgeUse("vision", { cacheHit: cached !== undefined }); - return `[Image ${i + 1}]: ${description}`; + const capped = + runtime.maxChars > 0 && description.length > runtime.maxChars + ? description.slice(0, runtime.maxChars) + "…" + : description; + return `[Image ${i + 1}]: ${capped}`; }) ); diff --git a/src/shared/constants/modalityBridgeDefaults.ts b/src/shared/constants/modalityBridgeDefaults.ts index 4fd98b9d61..e0dcc0aab8 100644 --- a/src/shared/constants/modalityBridgeDefaults.ts +++ b/src/shared/constants/modalityBridgeDefaults.ts @@ -15,6 +15,8 @@ export const MODALITY_BRIDGE_DEFAULTS = { cacheEnabled: true, cacheTtlMinutes: 60, cacheMaxEntries: 200, + // 0 = no cap (existing behavior, description is passed through in full). + visionMaxChars: 0, audioEnabled: true, audioModel: "", audioTimeoutMs: 60000, @@ -29,6 +31,7 @@ export interface VisionBridgeRuntimeSettings { prompt: string; timeoutMs: number; maxImages: number; + maxChars: number; cacheEnabled: boolean; cacheTtlMinutes: number; cacheMaxEntries: number; @@ -85,6 +88,7 @@ export function resolveVisionBridgeRuntimeSettings( maxImages: pickNumber(s.modalityBridgeVisionMaxImages, s.visionBridgeMaxImages) ?? VISION_BRIDGE_DEFAULTS.maxImagesPerRequest, + maxChars: pickNumber(s.modalityBridgeVisionMaxChars) ?? MODALITY_BRIDGE_DEFAULTS.visionMaxChars, cacheEnabled: pickBoolean(s.modalityBridgeCacheEnabled) ?? MODALITY_BRIDGE_DEFAULTS.cacheEnabled, cacheTtlMinutes: diff --git a/src/shared/validation/settingsSchemas.ts b/src/shared/validation/settingsSchemas.ts index 167b49dba8..6ac223aaab 100644 --- a/src/shared/validation/settingsSchemas.ts +++ b/src/shared/validation/settingsSchemas.ts @@ -349,6 +349,7 @@ export const updateSettingsSchema = z.object({ modalityBridgeVisionPrompt: z.string().max(5000).optional(), modalityBridgeVisionTimeout: z.number().int().min(1000).max(300000).optional(), modalityBridgeVisionMaxImages: z.number().int().min(1).max(20).optional(), + modalityBridgeVisionMaxChars: z.number().int().min(100).max(50000).optional(), modalityBridgeAudioEnabled: z.boolean().optional(), modalityBridgeAudioModel: z.string().max(200).optional(), modalityBridgeAudioTimeout: z.number().int().min(1000).max(300000).optional(), diff --git a/tests/unit/vision-bridge-maxchars.test.ts b/tests/unit/vision-bridge-maxchars.test.ts new file mode 100644 index 0000000000..90d02eb3a0 --- /dev/null +++ b/tests/unit/vision-bridge-maxchars.test.ts @@ -0,0 +1,110 @@ +/** + * `modalityBridgeVisionMaxChars` — configurable teto for the vision-bridge + * describe-path output. 0 (default) means "no cap" (existing behavior + * unchanged); a positive value truncates the description with a `…` suffix + * before it is spliced back as `[Image N]: `. + * + * Follows the DI harness pattern from `vision-bridge-cc-no-reroute.test.ts` / + * `vision-bridge-mode.test.ts` (settings + vision call injected, no real DB + * dependency for the describe path itself). + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { VisionBridgeGuardrail } = await import("../../src/lib/guardrails/visionBridge.ts"); +const { resolveVisionBridgeRuntimeSettings } = + await import("../../src/shared/constants/modalityBridgeDefaults.ts"); +import type { GuardrailContext } from "../../src/lib/guardrails/base.ts"; +import type { VisionModelConfig } from "../../src/lib/guardrails/visionBridgeHelpers.ts"; + +const TEXT_ONLY_MODEL = "some/text-only-model"; +const LONG_DESCRIPTION = "x".repeat(500); + +function imageBody(uniqueRef: string): Record { + return { + model: TEXT_ONLY_MODEL, + messages: [ + { + role: "user", + content: [ + { type: "text", text: "what is in the image?" }, + { + type: "image_url", + image_url: { + url: `data:image/png;base64,${Buffer.from(uniqueRef).toString("base64")}`, + }, + }, + ], + }, + ], + }; +} + +function findImageDescriptionText(modifiedPayload: unknown): string | undefined { + const body = modifiedPayload as { messages?: Array<{ content?: unknown }> } | undefined; + const messages = body?.messages ?? []; + for (const message of messages) { + const parts = message.content; + if (!Array.isArray(parts)) continue; + for (const part of parts) { + const p = part as { type?: string; text?: string }; + if (p?.type === "text" && typeof p.text === "string" && p.text.startsWith("[Image 1]:")) { + return p.text; + } + } + } + return undefined; +} + +test("modalityBridgeVisionMaxChars=120 caps the description with a … suffix", async () => { + const guardrail = new VisionBridgeGuardrail({ + deps: { + getSettings: async () => ({ + visionBridgeEnabled: true, + modalityBridgeVisionMaxChars: 120, + }), + callVisionModel: async (_imageDataUri: string, _config: VisionModelConfig) => + LONG_DESCRIPTION, + }, + }); + + const body = imageBody("maxchars-cap-test"); + const context: GuardrailContext = { model: TEXT_ONLY_MODEL, log: console }; + const result = await guardrail.preCall(body, context); + + const text = findImageDescriptionText(result.modifiedPayload); + assert.ok(text, "expected a [Image 1]: text part in the modified payload"); + const description = text!.slice("[Image 1]: ".length); + assert.ok( + description.length <= 121, + `capped description should be <= 121 chars (120 + ellipsis), got ${description.length}` + ); + assert.ok(description.endsWith("…"), "capped description must end with an ellipsis suffix"); +}); + +test("no modalityBridgeVisionMaxChars key: description is passed through in full", async () => { + const guardrail = new VisionBridgeGuardrail({ + deps: { + getSettings: async () => ({ + visionBridgeEnabled: true, + }), + callVisionModel: async (_imageDataUri: string, _config: VisionModelConfig) => + LONG_DESCRIPTION, + }, + }); + + const body = imageBody("maxchars-nokey-test"); + const context: GuardrailContext = { model: TEXT_ONLY_MODEL, log: console }; + const result = await guardrail.preCall(body, context); + + const text = findImageDescriptionText(result.modifiedPayload); + assert.ok(text, "expected a [Image 1]: text part in the modified payload"); + const description = text!.slice("[Image 1]: ".length); + assert.equal(description, LONG_DESCRIPTION, "without a cap the description must be untouched"); + assert.ok(!description.endsWith("…"), "unconfigured cap must never add an ellipsis"); +}); + +test("resolveVisionBridgeRuntimeSettings({}) defaults maxChars to 0 (no cap)", () => { + const runtime = resolveVisionBridgeRuntimeSettings({}); + assert.equal(runtime.maxChars, 0); +});