mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-15 19:52:50 +03:00
feat(bridge): configurable describe output cap (modalityBridgeVisionMaxChars)
This commit is contained in:
@@ -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}`;
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(),
|
||||
|
||||
110
tests/unit/vision-bridge-maxchars.test.ts
Normal file
110
tests/unit/vision-bridge-maxchars.test.ts
Normal file
@@ -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]: <description>`.
|
||||
*
|
||||
* 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<string, unknown> {
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user