Merge remote-tracking branch 'origin/release/v3.8.50' into feat/radar-key-input

This commit is contained in:
diegosouzapw
2026-08-08 06:36:38 -03:00
24 changed files with 1886 additions and 116 deletions

View File

@@ -5294,6 +5294,19 @@ paths:
"200":
description: Caches cleared
/api/modality-bridge/stats:
get:
tags: [System]
summary: Get Modality Bridge telemetry
description: In-memory per-modality bridge counters (bridged, cacheHits, failures, lastUsedAt). Counters reset on process restart.
security:
- ManagementSessionAuth: []
responses:
"200":
description: Per-modality bridge stats (vision, audio)
"401":
description: Unauthorized
/api/cache/stats:
get:
tags: [System]

View File

@@ -573,6 +573,7 @@ Response example:
| `/api/rate-limits` | GET | Per-account rate limits |
| `/api/monitoring/health` | GET | Health check + provider summary (`catalogCount`, `configuredCount`, `activeCount`, `monitoredCount`) |
| `/api/cache/stats` | GET/DELETE | Cache stats / clear |
| `/api/modality-bridge/stats` | GET | In-memory Modality Bridge telemetry — per-modality `bridged`/`cacheHits`/`failures`/`lastUsedAt` counters (reset on restart; management auth) |
### Backup & Export/Import

View File

@@ -1,13 +1,13 @@
---
title: "Guardrails"
version: 3.8.40
lastUpdated: 2026-06-28
version: 3.8.50
lastUpdated: 2026-08-07
---
# Guardrails
> **Source of truth:** `src/lib/guardrails/`
> **Last updated:** 2026-06-28 — v3.8.40 (injection-guard coverage + 16 KB scan bound + red-team)
> **Last updated:** 2026-08-07 — v3.8.50 (Modality Bridge PR-1: mode selector, task-aware prompt, describe cache, transparency header + stats)
Guardrails enforce safety, policy, and content transformations at the boundary
between OmniRoute and upstream providers. Each guardrail can inspect (and
@@ -32,31 +32,107 @@ The registry auto-loads four guardrails in priority order on import
Lower priority numbers run **first**.
### Vision Bridge (`visionBridge.ts`)
### Vision Bridge (`visionBridge.ts`) — Modality Bridge PR-1
Intercepts image-bearing requests aimed at **non-vision models** and replaces
the image parts with text descriptions produced by a configurable vision model
before the upstream call. This lets text-only providers transparently handle
Intercepts image-bearing requests aimed at **non-vision models** and either
reroutes the whole request to a vision-capable model or replaces the image
parts with text descriptions produced by a configurable vision model before
the upstream call. This lets text-only providers transparently handle
multimodal payloads.
Flow:
1. Skip if the target model already supports vision (unless it appears in the
forced-bridge list `isVisionBridgeForcedModel`).
2. Extract image parts via `extractImageParts(messages)`. Skip if none.
`extractImageParts` recognizes all three image shapes: OpenAI `image_url`,
Anthropic base64 `source.type:"base64"`, and Anthropic URL
`source.type:"url"` — so Claude-Code-compatible clients (e.g. Zoo Code)
sending `{ type: "image", source: { type: "url", url } }` are described
instead of silently dropped.
3. Load runtime config from `getSettings()` (`visionBridgeEnabled`,
`visionBridgeModel`, `visionBridgePrompt`, `visionBridgeTimeout`,
`visionBridgeMaxImages`).
4. Cap images at `maxImages`, call the vision model **in parallel**
(`Promise.allSettled`), and inject `[Image N]: <description>` text parts
in their place — failed images become `[Image N]: (unavailable)`.
5. Return `modifiedPayload` + meta (`imagesProcessed`, `processingTimeMs`,
`visionModel`).
2. Extract image parts via `extractImageParts(messages)`
(`visionBridgeHelpers.ts`), which delegates to the **unified media
detector** `detectMediaParts()` in `open-sse/utils/mediaParts.ts` — the
single source of truth shared with the combo compatibility filter.
Extraction is allowlisted to top-level parts of the shapes
`replaceImageParts` can splice back (the extract↔replace contract): OpenAI
`image_url`, Anthropic base64 `source.type:"base64"`, Anthropic URL
`source.type:"url"`, and Responses API `input_image`. Nested hits and
indicator-only shapes are combo-filter material and are never extracted.
Skip if none found.
3. Resolve runtime config via `resolveVisionBridgeRuntimeSettings()`
(`src/shared/constants/modalityBridgeDefaults.ts`): new `modalityBridge*`
settings keys win; legacy `visionBridge*` keys remain a **one-cycle
fallback** (rollback window). Skip before any media traversal when the
bridge is disabled.
4. Mode selector (`modalityBridgeVisionMode`, see table below) decides
reroute vs describe. Reroute returns `modifiedPayload` with only `model`
swapped, plus meta `{ rerouted, fromModel, toModel, imagesKept }`.
5. Describe path: cap images at `maxImages`, compose the task-aware prompt,
consult the describe cache, call the vision model **in parallel**
(`Promise.allSettled`), and inject `[Image N]: <description>` text parts in
their place. A failed describe yields `null` and the original image part is
**preserved** (#4012) — except on the combo describe path when every
describe failed, where a confirmed non-vision upstream gets an
`(unavailable — no vision-capable provider connected)` stub instead (#8430).
6. Return `modifiedPayload` + meta (`imagesProcessed`, `descriptions`,
`processingTimeMs`, `visionModel`).
#### Mode selector (`modalityBridgeVisionMode`)
| Mode | Default | Behavior |
| ---------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `auto` | ✔ | Legacy heuristic, untouched (#6640/#7204): non-combo/`auto/` models reroute to the best vision model unless the original model already has usable credentials (then describe); combo targets always describe. |
| `describe` | | Always describe — the reroute block is skipped entirely; the user's chosen model always answers. |
| `reroute` | | Force reroute: the keep-credentialed-model guard is bypassed. The reroute-**target** credential guard still applies — when no usable vision target exists, the request falls through to describe so raw images never reach a text-only backend (#8430). |
Forced modes short-circuit **before** the auto heuristic runs; `auto` behavior
is byte-identical to the pre-PR-1 guardrail.
#### Task-aware describe prompt (`modalityBridgeVisionTaskAware`)
Default **true**. `composeVisionPrompt()` (`visionBridgeHelpers.ts`) appends
the text of the **last user message** (truncated to 500 chars) to the base
describe prompt, steering the description toward what the user actually asked
(codex-vision-proxy pattern) and asking the vision model to transcribe visible
text. With the flag off — or no user text — the base prompt is used unchanged.
#### Describe cache (`modalityBridge/bridgeCache.ts`)
In-memory LRU + TTL cache for describe outputs, shared process-wide.
Key = `sha256(imageRef + composedPrompt + configuredBridgeModel)` with
length-prefix framing (no field-boundary collisions). The model component is
the **configured** bridge model, not the model that actually answered —
`callVisionModel` may fall back internally, and keying per attempt would
fragment the cache. Failed describes are never cached. Settings:
| Key | Default | Range |
| ------------------------------- | ------- | ------- |
| `modalityBridgeCacheEnabled` | `true` | — |
| `modalityBridgeCacheTtlMinutes` | `60` | 11440 |
| `modalityBridgeCacheMaxEntries` | `200` | 105000 |
#### Settings schema + migration
The new `modalityBridge*` keys are Zod-validated in `updateSettingsSchema`
(`src/shared/validation/settingsSchemas.ts`): `modalityBridgeVisionEnabled`,
`modalityBridgeVisionMode`, `modalityBridgeVisionModel`,
`modalityBridgeVisionTaskAware`, `modalityBridgeVisionPrompt`,
`modalityBridgeVisionTimeout`, `modalityBridgeVisionMaxImages`, the
`modalityBridgeCache*` trio, and the PR-3-reserved `modalityBridgeAudio*`
group. Migration `141_modality_bridge_settings.sql` copies existing legacy
`visionBridge*` values to the matching new keys (idempotent, never overwrites
an operator-set `modalityBridge*` value); the legacy keys stay accepted as a
read fallback for one release cycle.
#### Transparency header + stats
Describe-transformed responses carry
`x-omniroute-modality-bridge: image->text;model=<visionModel>;parts=<n>`
(built by `buildModalityBridgeHeader()` in `modalityBridge/bridgeStats.ts`,
stamped by `withModalityBridgeHeader()` in `src/sse/handlers/chatHelpers.ts`).
Rerouted requests get **no** header — the payload was untouched and the model
swap is already visible in the response body's `model` field.
`GET /api/modality-bridge/stats` (management auth, same tier as
`GET /api/settings`) returns the in-memory per-modality counters
`{ bridged, cacheHits, failures, lastUsedAt }` for `vision` (and the
PR-3-reserved `audio`). Counters reset on process restart by design
(telemetry, not accounting).
**Self-loop admission bypass:** when the describe call routes through OmniRoute's
own `/v1` self-loop (non-standard provider model), the sub-request sends
@@ -67,8 +143,10 @@ operator-configured `OMNIROUTE_API_KEY` / `ROUTER_API_KEY` env key (#1350) so
is only honored for those exact credentials, so external clients cannot use the
header to skip admission.
Defaults live in `src/shared/constants/visionBridgeDefaults.ts`. The guardrail
exposes a `deps` constructor option so tests can inject fake `getSettings` and
Legacy defaults live in `src/shared/constants/visionBridgeDefaults.ts`; the
new mode/task-aware/cache defaults and the settings resolver live in
`src/shared/constants/modalityBridgeDefaults.ts`. The guardrail exposes a
`deps` constructor option so tests can inject fake `getSettings` and
`callVisionModel` implementations.
### PII Masker (`piiMasker.ts`)

View File

@@ -18,6 +18,7 @@ import { getHiddenModelsByProvider } from "../../../src/lib/db/models";
import { getComboModelString, normalizeComboStep } from "../../../src/lib/combos/steps.ts";
import { getProviderByAlias, getProviderById } from "../../../src/shared/constants/providers.ts";
import { estimateTokens } from "../contextManager.ts";
import { containsMediaKind } from "../../utils/mediaParts.ts";
import { getResolvedModelCapabilities } from "../modelCapabilities.ts";
import { parseModel, stripContextWindowSuffix } from "../model.ts";
import { dedupeTargetsByExecutionKey, isRecord } from "./comboData.ts";
@@ -483,21 +484,15 @@ function estimateRequestInputTokens(body: Record<string, unknown>): number {
return Object.keys(estimatePayload).length > 0 ? estimateTokens(estimatePayload) : 0;
}
function valueContainsImagePart(value: unknown, depth = 0): boolean {
if (depth > 8 || value === null || value === undefined) return false;
if (typeof value === "string") return value.startsWith("data:image/");
if (Array.isArray(value)) return value.some((entry) => valueContainsImagePart(entry, depth + 1));
if (!isRecord(value)) return false;
const type = typeof value.type === "string" ? value.type.toLowerCase() : null;
if (type === "image" || type === "image_url" || type === "input_image") return true;
if ("image_url" in value || "input_image" in value) return true;
const source = isRecord(value.source) ? value.source : null;
const mediaType = typeof source?.media_type === "string" ? source.media_type.toLowerCase() : "";
if (mediaType.startsWith("image/")) return true;
return Object.values(value).some((entry) => valueContainsImagePart(entry, depth + 1));
function valueContainsImagePart(value: unknown): boolean {
// Delegates to the unified media detector (open-sse/utils/mediaParts.ts) —
// single source of truth shared with the vision-bridge guardrail. The
// detector keeps this filter's legacy permissive matches (image-ish `type`
// in any casing, bare `image_url`/`input_image` keys, source.media_type
// image/*, bare data:image strings, recursion capped at depth 8) via
// "image_indicator" parts. containsMediaKind short-circuits on the first
// hit — this runs on every request, so no full-part collection here.
return containsMediaKind([{ content: [value] }], "image");
}
export function deriveRequestCompatibilityRequirements(

View File

@@ -0,0 +1,250 @@
/**
* Unified media-part detection for request messages.
* Single source of truth shared by the vision/audio bridge guardrails (src/)
* and the combo compatibility filter (open-sse/) — the two previously kept
* divergent copies (guardrail missed input_image; combo saw it).
*/
export type MediaKind = "image" | "audio";
export interface MediaPart {
kind: MediaKind;
/** URL, data URI, or base64 payload reference for the media content. */
ref: string;
/**
* Location of the top-level content part this hit belongs to. For nested
* hits (`nested: true`) these indexes point at the CONTAINER part — the
* entry of `message.content` under which the media was found — not at the
* media object itself.
*/
messageIndex: number;
partIndex: number;
/**
* True when the media was found below the top level of the content part
* (inside another object/array, e.g. an image nested in an audio payload
* or a data URI inside a text field). Splice-style consumers can only
* replace top-level parts, so they must skip nested hits.
*/
nested: boolean;
/** Original wire shape, for callers that need format-specific handling. */
shape:
| "image_url"
| "image_base64"
| "image_source_url"
| "input_image"
| "data_uri_string"
| "input_audio"
| "audio_url"
/** Audio detected via `source.media_type: audio/*` (no explicit type). */
| "audio_source"
/**
* Combo-parity indicator: the value looks like an image part (image-ish
* `type` in any casing, a bare `image_url`/`input_image` key, or a
* `source.media_type` of image/*) but carries no extractable ref — `ref`
* may be "". Boolean callers (combo compatibility filter) count it;
* ref-consuming callers (vision bridge) must skip empty refs.
*/
| "image_indicator";
}
const MAX_DEPTH = 8;
interface DetectCtx {
out: MediaPart[];
messageIndex: number;
partIndex: number;
/** When set, `found` flips true on the first part of this kind (early exit). */
stopAtKind?: MediaKind;
found?: boolean;
}
/** Extract a URL from either a bare string or a `{ url }` object. */
function urlFrom(raw: unknown): string | undefined {
if (typeof raw === "string") return raw;
const url = (raw as Record<string, unknown> | undefined)?.url;
return typeof url === "string" ? url : undefined;
}
function pushPart(
ctx: DetectCtx,
kind: MediaKind,
ref: string,
shape: MediaPart["shape"],
depth: number
): void {
ctx.out.push({
kind,
ref,
messageIndex: ctx.messageIndex,
partIndex: ctx.partIndex,
nested: depth > 0,
shape,
});
if (ctx.stopAtKind === kind) ctx.found = true;
}
/** Strict image shapes with an extractable ref. Returns true when one was pushed. */
function inspectImageShapes(
obj: Record<string, unknown>,
type: string | undefined,
ctx: DetectCtx,
depth: number
): boolean {
if (type === "image_url" || type === "input_image") {
const url = urlFrom(obj.image_url);
if (url) {
pushPart(ctx, "image", url, type === "input_image" ? "input_image" : "image_url", depth);
return true;
}
}
if (type === "image") {
const source = obj.source as Record<string, unknown> | undefined;
if (source?.type === "base64" && typeof source.data === "string") {
const media = typeof source.media_type === "string" ? source.media_type : "image/png";
pushPart(ctx, "image", `data:${media};base64,${source.data}`, "image_base64", depth);
return true;
}
// Non-empty url required: an empty `source.url` is not an extractable image
// (mirrors the guardrail's historical `if (url)` guard).
if (source?.type === "url" && typeof source.url === "string" && source.url) {
pushPart(ctx, "image", source.url, "image_source_url", depth);
return true;
}
}
return false;
}
/**
* Audio shapes. Returns true when a part was pushed (at most one per object).
* Callers must NOT early-return on audio: the same object can also carry
* image indicators or nest image parts inside its payload.
*/
function inspectAudioShapes(
obj: Record<string, unknown>,
type: string | undefined,
mediaType: unknown,
ctx: DetectCtx,
depth: number
): boolean {
if (type === "input_audio") {
const audio = obj.input_audio as Record<string, unknown> | undefined;
if (typeof audio?.data === "string") {
pushPart(ctx, "audio", audio.data, "input_audio", depth);
return true;
}
}
if (type === "audio_url") {
const url = urlFrom(obj.audio_url);
if (url) {
pushPart(ctx, "audio", url, "audio_url", depth);
return true;
}
}
if (typeof mediaType === "string" && mediaType.startsWith("audio/")) {
const data = (obj.source as Record<string, unknown>).data;
if (typeof data === "string") {
pushPart(ctx, "audio", data, "audio_source", depth);
return true;
}
}
return false;
}
/**
* Combo-parity image indicators: the legacy valueContainsImagePart
* (comboStructure) matched image-ish `type` names case-insensitively, bare
* `image_url`/`input_image` keys, and `source.media_type` image/* — all
* without needing an extractable ref. Emit an indicator part (ref
* best-effort, possibly "") so boolean callers keep seeing those requests as
* vision requests. Returns true when one was pushed.
*/
function inspectImageIndicators(
obj: Record<string, unknown>,
type: string | undefined,
mediaType: unknown,
ctx: DetectCtx,
depth: number
): boolean {
const lowerType = type?.toLowerCase();
const looksLikeImage =
lowerType === "image" ||
lowerType === "image_url" ||
lowerType === "input_image" ||
"image_url" in obj ||
"input_image" in obj;
const imageMediaType =
typeof mediaType === "string" && mediaType.toLowerCase().startsWith("image/");
if (!looksLikeImage && !imageMediaType) return false;
pushPart(ctx, "image", urlFrom(obj.image_url ?? obj.input_image) ?? "", "image_indicator", depth);
return true;
}
function inspect(value: unknown, ctx: DetectCtx, depth: number): void {
if (ctx.found || depth > MAX_DEPTH || value == null) return;
if (typeof value === "string") {
if (value.startsWith("data:image/")) pushPart(ctx, "image", value, "data_uri_string", depth);
return;
}
if (Array.isArray(value)) {
for (const entry of value) {
inspect(entry, ctx, depth + 1);
if (ctx.found) return;
}
return;
}
if (typeof value !== "object") return;
const obj = value as Record<string, unknown>;
const type = typeof obj.type === "string" ? obj.type : undefined;
if (inspectImageShapes(obj, type, ctx, depth)) return;
const mediaType = (obj.source as Record<string, unknown> | undefined)?.media_type;
// Audio does not early-return: the same object can also carry image
// indicators (bare `image_url`/`input_image` keys the legacy combo filter
// matched) or nest image parts inside its payload.
inspectAudioShapes(obj, type, mediaType, ctx, depth);
if (ctx.found) return;
if (inspectImageIndicators(obj, type, mediaType, ctx, depth)) return;
for (const nested of Object.values(obj)) {
inspect(nested, ctx, depth + 1);
if (ctx.found) return;
}
}
export function detectMediaParts(
messages: ReadonlyArray<{ role?: string; content?: unknown }> | undefined | null
): MediaPart[] {
const out: MediaPart[] = [];
if (!Array.isArray(messages)) return out;
for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
const content = messages[messageIndex]?.content;
if (!Array.isArray(content)) continue;
for (let partIndex = 0; partIndex < content.length; partIndex++) {
inspect(content[partIndex], { out, messageIndex, partIndex }, 0);
}
}
return out;
}
/**
* Early-exit presence check: returns true as soon as the FIRST part of the
* requested kind is found, without collecting the full part list or finishing
* the traversal. Prefer this on hot paths (e.g. the combo compatibility
* filter runs on every request) over `detectMediaParts(...).some(...)`.
*/
export function containsMediaKind(
messages: ReadonlyArray<{ role?: string; content?: unknown }> | undefined | null,
kind: MediaKind
): boolean {
if (!Array.isArray(messages)) return false;
const out: MediaPart[] = [];
for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
const content = messages[messageIndex]?.content;
if (!Array.isArray(content)) continue;
for (let partIndex = 0; partIndex < content.length; partIndex++) {
const ctx: DetectCtx = { out, messageIndex, partIndex, stopAtKind: kind };
inspect(content[partIndex], ctx, 0);
if (ctx.found) return true;
}
}
return false;
}

View File

@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { getBridgeStats } from "@/lib/guardrails/modalityBridge/bridgeStats";
/**
* GET /api/modality-bridge/stats — read-only, in-memory Modality Bridge
* telemetry (per-modality bridged/cacheHits/failures/lastUsedAt counters).
* Same MANAGEMENT auth tier as GET /api/settings (routeGuard default —
* intentionally NOT local-only: harmless read-only telemetry, no side effects).
* Counters reset on process restart; force-dynamic + no-store so the dashboard
* always sees live values.
*/
export const dynamic = "force-dynamic";
export const revalidate = 0;
export async function GET(request: Request) {
const authError = await requireManagementAuth(request);
if (authError) return authError;
return NextResponse.json(getBridgeStats(), { headers: { "Cache-Control": "no-store" } });
}

View File

@@ -0,0 +1,30 @@
-- 141_modality_bridge_settings.sql
-- Modality Bridge (PR-1): copy legacy visionBridge* settings to the new modalityBridge* keys.
-- Legacy keys are left in place for one release cycle (rollback window).
-- Idempotent: each INSERT only fires when the new key does not exist yet, so an
-- operator-set modalityBridge* value is never overwritten and re-runs are no-ops.
INSERT INTO key_value (namespace, key, value)
SELECT 'settings', 'modalityBridgeVisionEnabled', value FROM key_value
WHERE namespace = 'settings' AND key = 'visionBridgeEnabled'
AND NOT EXISTS (SELECT 1 FROM key_value WHERE namespace = 'settings' AND key = 'modalityBridgeVisionEnabled');
INSERT INTO key_value (namespace, key, value)
SELECT 'settings', 'modalityBridgeVisionModel', value FROM key_value
WHERE namespace = 'settings' AND key = 'visionBridgeModel'
AND NOT EXISTS (SELECT 1 FROM key_value WHERE namespace = 'settings' AND key = 'modalityBridgeVisionModel');
INSERT INTO key_value (namespace, key, value)
SELECT 'settings', 'modalityBridgeVisionPrompt', value FROM key_value
WHERE namespace = 'settings' AND key = 'visionBridgePrompt'
AND NOT EXISTS (SELECT 1 FROM key_value WHERE namespace = 'settings' AND key = 'modalityBridgeVisionPrompt');
INSERT INTO key_value (namespace, key, value)
SELECT 'settings', 'modalityBridgeVisionTimeout', value FROM key_value
WHERE namespace = 'settings' AND key = 'visionBridgeTimeout'
AND NOT EXISTS (SELECT 1 FROM key_value WHERE namespace = 'settings' AND key = 'modalityBridgeVisionTimeout');
INSERT INTO key_value (namespace, key, value)
SELECT 'settings', 'modalityBridgeVisionMaxImages', value FROM key_value
WHERE namespace = 'settings' AND key = 'visionBridgeMaxImages'
AND NOT EXISTS (SELECT 1 FROM key_value WHERE namespace = 'settings' AND key = 'modalityBridgeVisionMaxImages');

View File

@@ -0,0 +1,82 @@
/**
* Modality Bridge description cache (PR-1).
*
* In-memory LRU + TTL cache for bridge outputs (image/audio descriptions),
* keyed by sha256(contentRef + prompt + model). Avoids re-describing the
* same media with the same prompt/model within the configured TTL.
*/
import { createHash } from "node:crypto";
import type { VisionBridgeRuntimeSettings } from "@/shared/constants/modalityBridgeDefaults";
export function bridgeCacheKey(contentRef: string, prompt: string, model: string): string {
// Length-prefix framing: hashing the byte lengths first makes the field
// boundaries unambiguous, so ("ab","c") can never collide with ("a","bc").
return createHash("sha256")
.update(`${Buffer.byteLength(contentRef)}:${Buffer.byteLength(prompt)}:`)
.update(contentRef)
.update(prompt)
.update(model)
.digest("hex");
}
export interface BridgeCacheOptions {
maxEntries: number;
ttlMs: number;
/** Injectable clock for tests. */
now?: () => number;
}
export class BridgeCache {
private readonly entries = new Map<string, { value: string; expiresAt: number }>();
constructor(private readonly opts: BridgeCacheOptions) {}
get(key: string): string | undefined {
const hit = this.entries.get(key);
if (!hit) return undefined;
const now = (this.opts.now ?? Date.now)();
if (hit.expiresAt <= now) {
this.entries.delete(key);
return undefined;
}
// Map preserves insertion order — re-insert to mark as most-recently-used.
this.entries.delete(key);
this.entries.set(key, hit);
return hit.value;
}
set(key: string, value: string): void {
const now = (this.opts.now ?? Date.now)();
this.entries.delete(key);
this.entries.set(key, { value, expiresAt: now + this.opts.ttlMs });
while (this.entries.size > this.opts.maxEntries) {
const oldest = this.entries.keys().next().value;
if (oldest === undefined) break;
this.entries.delete(oldest);
}
}
get size(): number {
return this.entries.size;
}
}
/** Process-wide singleton used by the bridges; recreated when config changes. */
let shared: { cache: BridgeCache; ttlMs: number; maxEntries: number } | null = null;
export function getSharedBridgeCache(ttlMs: number, maxEntries: number): BridgeCache {
if (!shared || shared.ttlMs !== ttlMs || shared.maxEntries !== maxEntries) {
shared = { cache: new BridgeCache({ maxEntries, ttlMs }), ttlMs, maxEntries };
}
return shared.cache;
}
/**
* Single conversion point from runtime settings to the shared cache: every
* bridge (vision, audio) goes through here so the minutes→ms conversion can
* never diverge between callers and thrash the singleton on each request.
*/
export function getSharedBridgeCacheFor(settings: VisionBridgeRuntimeSettings): BridgeCache {
return getSharedBridgeCache(settings.cacheTtlMinutes * 60_000, settings.cacheMaxEntries);
}

View File

@@ -0,0 +1,72 @@
/**
* Modality Bridge stats + response transparency header (PR-1 Task 9).
*
* In-memory, process-global counters for bridge activity ("vision" today,
* "audio" reserved for PR-3) plus the builder for the
* `x-omniroute-modality-bridge` response header, which tells clients that
* their request payload was transparently transformed (image→text describe).
* Reroutes do NOT get a header — the payload was untouched, only the model
* changed, and that is already visible in the response body's `model` field.
*
* Counters reset on process restart by design (telemetry, not accounting).
*/
export interface BridgeModalityStats {
bridged: number;
cacheHits: number;
failures: number;
lastUsedAt: string | null;
}
const stats: Record<"vision" | "audio", BridgeModalityStats> = {
vision: { bridged: 0, cacheHits: 0, failures: 0, lastUsedAt: null },
audio: { bridged: 0, cacheHits: 0, failures: 0, lastUsedAt: null },
};
export function recordBridgeUse(
kind: "vision" | "audio",
opts: { cacheHit?: boolean; failure?: boolean } = {}
): void {
const s = stats[kind];
s.bridged += 1;
if (opts.cacheHit) s.cacheHits += 1;
if (opts.failure) s.failures += 1;
s.lastUsedAt = new Date().toISOString();
}
export function getBridgeStats(): Record<"vision" | "audio", BridgeModalityStats> {
return structuredClone(stats);
}
/**
* Structural subset of GuardrailExecutionResult (src/lib/guardrails/base.ts) —
* only the fields the header builder reads, so callers can pass the registry's
* `results` array directly without a type dependency on the guardrail core.
*/
interface GuardrailMetaEntry {
guardrail: string;
meta?: Record<string, unknown> | null;
}
/** Response header value for a describe-bridged request; null when untouched. */
export function buildModalityBridgeHeader(results: GuardrailMetaEntry[]): string | null {
const segments: string[] = [];
for (const r of results) {
const meta = r.meta ?? {};
if (
r.guardrail === "vision-bridge" &&
typeof meta.imagesProcessed === "number" &&
!meta.rerouted
) {
segments.push(
`image->text;model=${String(meta.visionModel ?? "unknown")};parts=${meta.imagesProcessed}`
);
}
if (r.guardrail === "audio-bridge" && typeof meta.clipsProcessed === "number") {
segments.push(
`audio->text;model=${String(meta.sttModel ?? "unknown")};parts=${meta.clipsProcessed}`
);
}
}
return segments.length ? segments.join(", ") : null;
}

View File

@@ -11,14 +11,17 @@ import { getResolvedModelCapabilities } from "@/lib/modelCapabilities";
import {
extractImageParts,
callVisionModel as defaultCallVisionModel,
composeVisionPrompt,
replaceImageParts,
} from "./visionBridgeHelpers";
import {
VISION_BRIDGE_DEFAULTS,
getVisionBridgeConfig,
isVisionBridgeForcedModel,
} from "@/shared/constants/visionBridgeDefaults";
import { resolveVisionBridgeRuntimeSettings } from "@/shared/constants/modalityBridgeDefaults";
import { getBestVisionModel } from "./visionBridgeRouter";
import { bridgeCacheKey, getSharedBridgeCacheFor } from "./modalityBridge/bridgeCache";
import { recordBridgeUse } from "./modalityBridge/bridgeStats";
import {
isProviderConnectionUsable,
hasUsableCredentialsForModel,
@@ -94,6 +97,30 @@ async function getComboVisionBridgeDecision(model: string): Promise<ComboVisionB
}
}
/**
* Extract the text of the LAST user message that carries any (string content,
* or the first `type: "text"` part of an array content). Used as the
* task-aware focus hint for the describe path.
*/
function extractLastUserText(messages: unknown[]): string | undefined {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i] as { role?: unknown; content?: unknown } | null | undefined;
if (message?.role !== "user") continue;
if (typeof message.content === "string" && message.content.trim()) {
return message.content;
}
if (Array.isArray(message.content)) {
for (const part of message.content) {
const p = part as { type?: unknown; text?: unknown } | null | undefined;
if (p?.type === "text" && typeof p.text === "string" && p.text.trim()) {
return p.text;
}
}
}
}
return undefined;
}
export interface VisionBridgeDependencies {
getSettings?: () => Promise<Record<string, unknown>>;
callVisionModel?: (
@@ -189,13 +216,7 @@ export class VisionBridgeGuardrail extends BaseGuardrail {
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)
// 6. Get settings (injectable for testing)
const getSettings = this.deps.getSettings ?? defaultGetSettings;
let settings: Record<string, unknown> = {};
try {
@@ -204,9 +225,18 @@ export class VisionBridgeGuardrail extends BaseGuardrail {
// If getSettings fails, use defaults
}
// 8. Check if Vision Bridge is enabled in settings
const enabled = settings.visionBridgeEnabled ?? VISION_BRIDGE_DEFAULTS.enabled;
if (!enabled) {
// 7. Resolve runtime settings (new modalityBridge* keys win; legacy
// visionBridge* keys stay a one-cycle fallback) and check enabled
// BEFORE any media traversal, so a disabled bridge never pays the
// per-request deep scan of every message content part.
const runtime = resolveVisionBridgeRuntimeSettings(settings);
if (!runtime.enabled) {
return { block: false };
}
// 8. 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 };
}
@@ -226,9 +256,16 @@ export class VisionBridgeGuardrail extends BaseGuardrail {
// request with model=auto would land on a text-only model (#7871). Keeping
// "auto" is never the answer there, so the keep-credentialed-model skip
// below does not apply to auto — only the reroute-target credential guard.
if ((comboVisionBridgeDecision === "not-combo" || isAuto) && !forceVisionBridge) {
const rerouteEligible =
(comboVisionBridgeDecision === "not-combo" || isAuto) && !forceVisionBridge;
// Forced modes short-circuit BEFORE the auto heuristic (#6640/#7204 untouched):
// - "describe" skips the whole reroute block → straight to the describe path.
// - "reroute" skips only the keep-credentialed-model guard; the reroute-target
// credential guard still applies, and with no usable target it falls through
// to describe (raw images must never reach a text-only backend — #8430).
if (rerouteEligible && runtime.mode !== "describe") {
const checkCreds = this.deps.hasUsableCredentials ?? hasUsableCredentialsForModel;
const originalUsable = await checkCreds(model);
const originalUsable = runtime.mode === "reroute" ? false : await checkCreds(model);
if (originalUsable === true && !isAuto) {
// Keep the credentialed model; describe images below if needed.
@@ -238,14 +275,17 @@ export class VisionBridgeGuardrail extends BaseGuardrail {
);
} else {
// Honor an explicit operator override from the Vision Bridge settings tab
// (settings.visionBridgeModel) as the fixed reroute target, for consistency
// with the combo/describe path below (step 10) which always honors it via
// getVisionBridgeConfig. When unset, auto-select the fastest available
// vision-capable model from available providers.
const configuredModel =
typeof settings.visionBridgeModel === "string" && settings.visionBridgeModel.trim()
? settings.visionBridgeModel.trim()
: undefined;
// as the fixed reroute target, for consistency with the combo/describe
// path below (step 10) which always honors it via getVisionBridgeConfig.
// New modalityBridgeVisionModel wins over legacy visionBridgeModel (same
// precedence as resolveVisionBridgeRuntimeSettings — runtime.model can't
// be used here because it backfills the default and this path must
// auto-select the fastest available vision-capable model when unset).
const rawConfiguredModel = [
settings.modalityBridgeVisionModel,
settings.visionBridgeModel,
].find((value): value is string => typeof value === "string" && value.trim().length > 0);
const configuredModel = rawConfiguredModel?.trim();
// Propagate the same resolved credential check used by the adjacent
// checkCreds() calls above/below (#8430) — without this, the router
// falls back to the real DB-backed hasUsableCredentialsForModel and
@@ -284,13 +324,15 @@ export class VisionBridgeGuardrail extends BaseGuardrail {
// Fall through: describe images as text (or no-op if describe path can't run)
}
// 10. Get configuration
// 10. Get configuration — fed from the resolved runtime values so the new
// modalityBridge* keys are honored; getVisionBridgeConfig keeps producing
// the same VisionModelConfig shape callVisionModel expects.
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,
visionBridgeEnabled: runtime.enabled,
visionBridgeModel: runtime.model,
visionBridgePrompt: runtime.prompt,
visionBridgeTimeout: runtime.timeoutMs,
visionBridgeMaxImages: runtime.maxImages,
});
// 11. Limit images
@@ -301,10 +343,30 @@ export class VisionBridgeGuardrail extends BaseGuardrail {
const logger = context.log;
const startTime = Date.now();
// Task-aware focus hint: append the LAST user question so the description
// targets what the user actually asked instead of a generic caption.
const lastUserText = extractLastUserText(messages);
const composedPrompt = composeVisionPrompt(config.prompt, lastUserText, runtime.taskAware);
const describeConfig = { ...config, prompt: composedPrompt };
// Shared describe cache (sha256 of contentRef+prompt+model): the same image
// with the same prompt/model is described once per TTL. Failures are never
// cached — a throw inside the map happens before the cache write. The model
// component is the CONFIGURED bridge model (config.model — the bridge-config
// identity), not the model that actually produced the description:
// callVisionModel may fall back internally to another vision model, and
// keying by attempt would fragment the cache and leak router state into the
// key. Intentional and stable — do not "fix" this to key per attempt.
const cache = runtime.cacheEnabled ? getSharedBridgeCacheFor(runtime) : null;
// 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);
const key = cache ? bridgeCacheKey(imagePart.imageUrl, composedPrompt, config.model) : null;
const cached = key && cache ? cache.get(key) : undefined;
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}`;
})
);
@@ -320,6 +382,7 @@ export class VisionBridgeGuardrail extends BaseGuardrail {
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}`);
recordBridgeUse("vision", { failure: true });
return null;
});

View File

@@ -1,6 +1,7 @@
/**
* Vision Bridge helper functions for image processing.
*/
import { detectMediaParts, type MediaPart } from "@omniroute/open-sse/utils/mediaParts";
import { fetchRemoteImage } from "@/shared/network/remoteImageFetch";
import { getRuntimePorts } from "@/lib/runtime/ports";
import { resolveSelfLoopBearer } from "@/shared/middleware/chatBodyAdmission";
@@ -118,53 +119,37 @@ export type RequestContentPart =
* vision-bridge guardrail, so the image was silently dropped by a text-only
* executor instead of being described.
*/
/**
* Shapes `replaceImageParts` knows how to splice: top-level content parts
* whose `type` is `image_url`, `image`, or `input_image`. Everything else the
* detector reports (nested hits, `data_uri_string`, `image_indicator`) is
* combo-filter material only — extracting it would desync the positional
* description consumption in visionBridge (descriptions would shift onto the
* wrong images).
*/
const REPLACEABLE_IMAGE_SHAPES: ReadonlySet<MediaPart["shape"]> = new Set([
"image_url",
"image_base64",
"image_source_url",
"input_image",
]);
export function extractImageParts(messages: RequestMessage[]): ImagePart[] {
const results: ImagePart[] = [];
if (!Array.isArray(messages)) {
return results;
}
for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) {
const message = messages[msgIdx];
if (!message || !Array.isArray(message.content)) {
continue;
}
for (let partIdx = 0; partIdx < message.content.length; partIdx++) {
const part = message.content[partIdx];
if (part?.type === "image_url" && part.image_url?.url) {
results.push({
messageIndex: msgIdx,
partIndex: partIdx,
imageUrl: part.image_url.url,
imageType: "image_url",
});
} else if (part?.type === "image" && part.source?.type === "base64") {
const { media_type, data } = part.source;
const dataUri = `data:${media_type};base64,${data}`;
results.push({
messageIndex: msgIdx,
partIndex: partIdx,
imageUrl: dataUri,
imageType: "image",
});
} else if (part?.type === "image" && part.source?.type === "url") {
const url = part.source.url;
if (url) {
results.push({
messageIndex: msgIdx,
partIndex: partIdx,
imageUrl: url,
imageType: "url",
});
}
}
}
}
return results;
// Delegates to the unified detector (open-sse/utils/mediaParts.ts) so the
// guardrail and the combo compatibility filter share one source of truth.
// Extraction is ALLOWLISTED to top-level (non-nested) parts whose shape
// replaceImageParts can splice back — the extract↔replace contract: every
// extracted part MUST be replaceable, in the same order, or the positional
// descriptions shift onto the wrong images.
return detectMediaParts(messages)
.filter((p) => p.kind === "image" && !p.nested && REPLACEABLE_IMAGE_SHAPES.has(p.shape))
.map((p) => ({
messageIndex: p.messageIndex,
partIndex: p.partIndex,
imageUrl: p.ref,
imageType:
p.shape === "image_base64" ? "image" : p.shape === "image_source_url" ? "url" : "image_url",
}));
}
/**
@@ -223,6 +208,19 @@ export interface VisionModelConfig {
maxImages: number;
}
/** Task-aware focus hint (codex-vision-proxy pattern): steer the description
* toward what the user actually asked, instead of a generic caption. */
export function composeVisionPrompt(
basePrompt: string,
lastUserText: string | undefined,
taskAware: boolean
): string {
const text = (lastUserText ?? "").trim();
if (!taskAware || !text) return basePrompt;
const hint = text.length > 500 ? `${text.slice(0, 500)}` : text;
return `${basePrompt}\n\nThe user asked: "${hint}". Focus your description on what is relevant to answering this, and transcribe any text visible in the image.`;
}
/**
* Call the vision model to get an image description.
* Supports both OpenAI-compatible and Anthropic API formats.
@@ -703,7 +701,12 @@ export function replaceImageParts(
const newContent: RequestContentPart[] = [];
for (const part of message.content) {
if (part?.type === "image_url" || part?.type === "image") {
// `input_image` (Responses API) is read through a widened type: it is
// not part of the historical RequestContentPart union but MUST be
// replaceable — extractImageParts allowlists it, and every extracted
// part needs a matching splice here (extract↔replace contract).
const partType = (part as { type?: string } | null | undefined)?.type;
if (partType === "image_url" || partType === "image" || partType === "input_image") {
if (descriptionIndex < descriptions.length) {
const description = descriptions[descriptionIndex];
descriptionIndex++;

View File

@@ -0,0 +1,85 @@
/**
* Modality Bridge default configuration values (PR-1).
*
* New `modalityBridge*` settings keys supersede the legacy `visionBridge*`
* keys; the legacy keys stay accepted as a fallback for one release cycle
* (rollback window) and are resolved here in a single place.
*/
import { VISION_BRIDGE_DEFAULTS } from "./visionBridgeDefaults";
export type VisionBridgeMode = "auto" | "describe" | "reroute";
export const MODALITY_BRIDGE_DEFAULTS = {
visionMode: "auto" as VisionBridgeMode,
visionTaskAware: true,
cacheEnabled: true,
cacheTtlMinutes: 60,
cacheMaxEntries: 200,
audioEnabled: true,
audioModel: "",
audioTimeoutMs: 60000,
audioMaxClips: 3,
} as const;
export interface VisionBridgeRuntimeSettings {
enabled: boolean;
mode: VisionBridgeMode;
model: string;
taskAware: boolean;
prompt: string;
timeoutMs: number;
maxImages: number;
cacheEnabled: boolean;
cacheTtlMinutes: number;
cacheMaxEntries: number;
}
// Typed candidate pickers: a stored value of the wrong type (e.g. the string
// "off" in a boolean field) is skipped so the next candidate/default wins.
function pickBoolean(...values: unknown[]): boolean | undefined {
for (const v of values) if (typeof v === "boolean") return v;
return undefined;
}
function pickNumber(...values: unknown[]): number | undefined {
for (const v of values) if (typeof v === "number" && Number.isFinite(v)) return v;
return undefined;
}
function pickString(...values: unknown[]): string | undefined {
for (const v of values) if (typeof v === "string") return v;
return undefined;
}
/** New modalityBridge* keys win; legacy visionBridge* keys are a one-cycle fallback. */
export function resolveVisionBridgeRuntimeSettings(
settings: Record<string, unknown> | null | undefined
): VisionBridgeRuntimeSettings {
const s = settings ?? {};
const mode = pickString(s.modalityBridgeVisionMode);
return {
enabled:
pickBoolean(s.modalityBridgeVisionEnabled, s.visionBridgeEnabled) ??
VISION_BRIDGE_DEFAULTS.enabled,
mode: mode === "describe" || mode === "reroute" ? mode : MODALITY_BRIDGE_DEFAULTS.visionMode,
model:
pickString(s.modalityBridgeVisionModel, s.visionBridgeModel) ?? VISION_BRIDGE_DEFAULTS.model,
taskAware:
pickBoolean(s.modalityBridgeVisionTaskAware) ?? MODALITY_BRIDGE_DEFAULTS.visionTaskAware,
prompt:
pickString(s.modalityBridgeVisionPrompt, s.visionBridgePrompt) ??
VISION_BRIDGE_DEFAULTS.prompt,
timeoutMs:
pickNumber(s.modalityBridgeVisionTimeout, s.visionBridgeTimeout) ??
VISION_BRIDGE_DEFAULTS.timeoutMs,
maxImages:
pickNumber(s.modalityBridgeVisionMaxImages, s.visionBridgeMaxImages) ??
VISION_BRIDGE_DEFAULTS.maxImagesPerRequest,
cacheEnabled:
pickBoolean(s.modalityBridgeCacheEnabled) ?? MODALITY_BRIDGE_DEFAULTS.cacheEnabled,
cacheTtlMinutes:
pickNumber(s.modalityBridgeCacheTtlMinutes) ?? MODALITY_BRIDGE_DEFAULTS.cacheTtlMinutes,
cacheMaxEntries:
pickNumber(s.modalityBridgeCacheMaxEntries) ?? MODALITY_BRIDGE_DEFAULTS.cacheMaxEntries,
};
}

View File

@@ -330,6 +330,22 @@ export const updateSettingsSchema = z.object({
visionBridgePrompt: z.string().max(5000).optional(),
visionBridgeTimeout: z.number().int().min(1000).max(300000).optional(),
visionBridgeMaxImages: z.number().int().min(1).max(20).optional(),
// Modality Bridge settings (new schema — visionBridge* keys above are the
// deprecated legacy aliases, kept accepted for one release cycle)
modalityBridgeVisionEnabled: z.boolean().optional(),
modalityBridgeVisionMode: z.enum(["auto", "describe", "reroute"]).optional(),
modalityBridgeVisionModel: z.string().max(200).optional(),
modalityBridgeVisionTaskAware: z.boolean().optional(),
modalityBridgeVisionPrompt: z.string().max(5000).optional(),
modalityBridgeVisionTimeout: z.number().int().min(1000).max(300000).optional(),
modalityBridgeVisionMaxImages: z.number().int().min(1).max(20).optional(),
modalityBridgeAudioEnabled: z.boolean().optional(),
modalityBridgeAudioModel: z.string().max(200).optional(),
modalityBridgeAudioTimeout: z.number().int().min(1000).max(300000).optional(),
modalityBridgeAudioMaxClips: z.number().int().min(1).max(10).optional(),
modalityBridgeCacheEnabled: z.boolean().optional(),
modalityBridgeCacheTtlMinutes: z.number().int().min(1).max(1440).optional(),
modalityBridgeCacheMaxEntries: z.number().int().min(10).max(5000).optional(),
// Missing settings
lkgpEnabled: z.boolean().optional(),
// #1311: echo the requested alias/combo name in the response model field (opt-in)

View File

@@ -77,7 +77,9 @@ import {
withSessionHeader,
withSelectedConnectionHeader,
withCorrelationId,
withModalityBridgeHeader,
} from "./chatHelpers";
import { buildModalityBridgeHeader } from "@/lib/guardrails/modalityBridge/bridgeStats";
import {
isAntigravityMissingProjectError,
PROVIDER_BREAKER_FAILURE_STATUSES,
@@ -544,6 +546,10 @@ async function handleChatImplementation(
isModelAllowedForKey,
log,
}));
// Modality Bridge transparency (Task 9): non-null only when a pre-call bridge
// guardrail transformed the payload (describe path) — stamped on the main
// success exits below via withModalityBridgeHeader().
const modalityBridgeHeader = buildModalityBridgeHeader(preCallGuardrails.results);
telemetry.endPhase();
// T08: per-key active session limit (0 = unlimited).
@@ -907,7 +913,10 @@ async function handleChatImplementation(
if (fallbackResponse.ok) {
log.info("GLOBAL_FALLBACK", `Global fallback ${fallbackModel} succeeded`);
recordTelemetry(telemetry);
return withSessionHeader(fallbackResponse, sessionId);
return withModalityBridgeHeader(
withSessionHeader(fallbackResponse, sessionId),
modalityBridgeHeader
);
}
log.warn(
"GLOBAL_FALLBACK",
@@ -944,7 +953,10 @@ async function handleChatImplementation(
});
} catch {}
}
return withCorrelationId(withSessionHeader(response, sessionId), reqId);
return withModalityBridgeHeader(
withCorrelationId(withSessionHeader(response, sessionId), reqId),
modalityBridgeHeader
);
}
telemetry.endPhase();
@@ -986,7 +998,10 @@ async function handleChatImplementation(
false
);
recordTelemetry(telemetry);
return withCorrelationId(withSessionHeader(response, sessionId), reqId);
return withModalityBridgeHeader(
withCorrelationId(withSessionHeader(response, sessionId), reqId),
modalityBridgeHeader
);
}
export const handleChat = chatAdmission.withChatAdmission(handleChatImplementation);

View File

@@ -908,6 +908,31 @@ export function withCorrelationId(response: Response, correlationId: string | nu
}
}
/**
* Modality Bridge transparency (PR-1 Task 9): stamp the
* `x-omniroute-modality-bridge` header on responses whose request payload was
* transparently transformed (e.g. image→text describe). `value` comes from
* buildModalityBridgeHeader(); null (untouched/rerouted request) is a no-op.
* Same try-set/clone-fallback shape as withSessionHeader — the clone reuses
* `response.body`, so SSE streams pass through untouched.
*/
export function withModalityBridgeHeader(response: Response, value: string | null): Response {
if (!response || !value) return response;
try {
response.headers.set("x-omniroute-modality-bridge", value);
return response;
} catch {
const cloned = new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
cloned.headers.set("x-omniroute-modality-bridge", value);
return cloned;
}
}
export function withSelectedConnectionHeader(
response: Response,
connectionId: string | null | undefined

View File

@@ -0,0 +1,79 @@
/**
* Test-only workaround for the inherited base-red "Migration version collision
* detected" on release/v3.8.50 (originally the `134_ccr_blocks.sql` +
* `134_proxy_logs_egress_ip.sql` pair, fixed by #9688; the surviving pair is
* `135_connection_runtime_state.sql` + `135_migrate_model_capability_max_token.sql`,
* fix #9676 in flight). Any test that exercises a code path opening
* the DB (e.g. `VisionBridgeGuardrail.preCall` → `getResolvedModelCapabilities`
* → `getDbInstance`) dies at migration-file scan time, BEFORE the code under
* test runs — making TDD on those paths impossible until the base is fixed.
*
* Base-red tracking: issue #9679; remaining fix PR in flight: #9676 (#9688
* already landed). Once the base has no duplicate prefixes the copy and
* this degrades to a plain pass-through copy — at that point this helper (and
* its callsites) can be removed. Grep trigger: 9679 / 9676 / 9688.
*
* This helper copies the real migrations into a temp dir, renumbering any file
* whose numeric prefix duplicates an earlier one to a fresh (max+1) version,
* and points `OMNIROUTE_MIGRATIONS_DIR` (supported operator env var — see
* `src/lib/db/migrationRunner.ts::resolveMigrationsDir`) at the copy. On the
* FRESH per-process test DATA_DIR (tests/_setup/isolateDataDir.ts) the schema
* CONTENT applied is byte-identical, but note the renumbering does shift the
* displaced duplicate to the END of the migration ORDER (it runs after every
* lower-numbered file instead of at its original slot). Harmless for the
* current colliding pair — and strictly better than the crash — but not
* literally "the same run" as production.
*
* MUST be called before the first `getDbInstance()` in the process (i.e. at
* test-file top level, before any `preCall`). An explicitly configured
* `OMNIROUTE_MIGRATIONS_DIR` always wins.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
export function useDecollidedMigrationsDir(): void {
if (process.env.OMNIROUTE_MIGRATIONS_DIR) return;
const realDir = path.resolve("src/lib/db/migrations");
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-test-migrations-"));
const files = fs
.readdirSync(realDir, { withFileTypes: true })
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.sort();
let maxVersion = 0;
for (const file of files) {
const match = file.match(/^(\d+)_/);
if (match) maxVersion = Math.max(maxVersion, Number.parseInt(match[1], 10));
}
const seenVersions = new Set<number>();
for (const file of files) {
const match = file.match(/^(\d+)_(.*)$/);
let target = file;
if (match) {
const version = Number.parseInt(match[1], 10);
if (seenVersions.has(version)) {
// Collision: move the later duplicate to a fresh version slot.
maxVersion += 1;
target = `${maxVersion}_${match[2]}`;
} else {
seenVersions.add(version);
}
}
fs.copyFileSync(path.join(realDir, file), path.join(tmp, target));
}
process.env.OMNIROUTE_MIGRATIONS_DIR = tmp;
process.on("exit", () => {
try {
fs.rmSync(tmp, { recursive: true, force: true });
} catch {
// Best-effort cleanup — the OS reaps its temp dir eventually.
}
});
}

View File

@@ -0,0 +1,220 @@
import { test } from "node:test";
import assert from "node:assert";
import { containsMediaKind, detectMediaParts } from "../../open-sse/utils/mediaParts";
import { extractImageParts, replaceImageParts } from "../../src/lib/guardrails/visionBridgeHelpers";
const msg = (content: unknown) => [{ role: "user", content }];
test("detects OpenAI image_url part", () => {
const parts = detectMediaParts(
msg([{ type: "image_url", image_url: { url: "https://x/i.png" } }])
);
assert.equal(parts.length, 1);
assert.equal(parts[0].kind, "image");
assert.equal(parts[0].ref, "https://x/i.png");
});
test("detects Anthropic base64 image source", () => {
const parts = detectMediaParts(
msg([{ type: "image", source: { type: "base64", media_type: "image/png", data: "AAA" } }])
);
assert.equal(parts[0].ref, "data:image/png;base64,AAA");
});
test("detects Responses-API input_image", () => {
const parts = detectMediaParts(msg([{ type: "input_image", image_url: "https://x/i.png" }]));
assert.equal(parts.length, 1);
assert.equal(parts[0].kind, "image");
});
test("detects bare data:image string in content array", () => {
const parts = detectMediaParts(msg([{ type: "text", text: "data:image/jpeg;base64,QUJD" }]));
assert.equal(parts.length, 1);
});
test("detects input_audio and audio_url as audio kind", () => {
const parts = detectMediaParts(
msg([
{ type: "input_audio", input_audio: { data: "QUJD", format: "wav" } },
{ type: "audio_url", audio_url: { url: "https://x/a.mp3" } },
])
);
assert.deepEqual(
parts.map((p) => p.kind),
["audio", "audio"]
);
});
test("recursion depth capped at 8", () => {
let nested: Record<string, unknown> = { type: "image_url", image_url: { url: "https://x" } };
for (let i = 0; i < 10; i++) nested = { wrap: nested };
assert.equal(detectMediaParts(msg([nested])).length, 0);
});
test("string content and empty messages yield []", () => {
assert.deepEqual(detectMediaParts([{ role: "user", content: "oi" }]), []);
assert.deepEqual(detectMediaParts([]), []);
});
test("combo-parity: image indicators without extractable refs are still detected", () => {
// Bare image_url key without a `type` (legacy combo matched by key presence).
const bareKey = detectMediaParts(msg([{ image_url: { url: "https://x/i.png" } }]));
assert.equal(bareKey.length, 1);
assert.equal(bareKey[0].kind, "image");
assert.equal(bareKey[0].ref, "https://x/i.png");
// `type: "image"` with no usable source (legacy combo matched by type name).
const bareType = detectMediaParts(msg([{ type: "image" }]));
assert.equal(bareType.length, 1);
assert.equal(bareType[0].shape, "image_indicator");
assert.equal(bareType[0].ref, "");
// source.media_type image/* on an untyped part.
const bySourceMedia = detectMediaParts(
msg([{ source: { media_type: "image/JPEG", data: "AAA" } }])
);
assert.equal(bySourceMedia.length, 1);
assert.equal(bySourceMedia[0].kind, "image");
// Case-insensitive type match (legacy combo lowercased `type`).
const upperType = detectMediaParts(msg([{ type: "IMAGE_URL", image_url: { url: "https://x" } }]));
assert.equal(upperType.length, 1);
assert.equal(upperType[0].ref, "https://x");
});
test("audio part does not shadow a bare image_url key on the same object", () => {
const parts = detectMediaParts(
msg([{ type: "input_audio", input_audio: { data: "QUJD" }, image_url: "https://x/i.png" }])
);
assert.deepEqual(parts.map((p) => p.kind).sort(), ["audio", "image"]);
assert.equal(parts.find((p) => p.kind === "image")?.ref, "https://x/i.png");
});
test("audio_url part does not shadow a bare input_image key on the same object", () => {
const parts = detectMediaParts(
msg([{ type: "audio_url", audio_url: "https://x/a.mp3", input_image: "https://x/i.png" }])
);
assert.deepEqual(parts.map((p) => p.kind).sort(), ["audio", "image"]);
});
test("audio media_type part still recurses into sibling values (combo parity)", () => {
const parts = detectMediaParts(
msg([{ source: { media_type: "audio/mp3", data: "QUJD" }, sibling: { type: "image" } }])
);
assert.equal(parts.filter((p) => p.kind === "audio").length, 1);
assert.equal(parts.filter((p) => p.kind === "image").length, 1);
});
test("image nested inside input_audio payload is still detected", () => {
const parts = detectMediaParts(
msg([
{
type: "input_audio",
input_audio: {
data: "QUJD",
cover: { type: "image_url", image_url: { url: "https://x/c.png" } },
},
},
])
);
assert.equal(parts.filter((p) => p.kind === "audio").length, 1);
assert.ok(parts.some((p) => p.kind === "image" && p.ref === "https://x/c.png"));
});
test("bare source.media_type audio/* yields a single audio_source part", () => {
const parts = detectMediaParts(msg([{ source: { media_type: "audio/wav", data: "QUJD" } }]));
assert.equal(parts.length, 1);
assert.equal(parts[0].kind, "audio");
assert.equal(parts[0].shape, "audio_source");
assert.equal(parts[0].ref, "QUJD");
});
test("containsMediaKind early-exit agrees with detectMediaParts presence", () => {
const withImage = msg([
{ type: "text", text: "hi" },
{ type: "image_url", image_url: { url: "https://x/i.png" } },
]);
assert.equal(containsMediaKind(withImage, "image"), true);
assert.equal(containsMediaKind(withImage, "audio"), false);
const withAudio = msg([{ type: "input_audio", input_audio: { data: "QUJD", format: "wav" } }]);
assert.equal(containsMediaKind(withAudio, "audio"), true);
assert.equal(containsMediaKind(withAudio, "image"), false);
// Nested/indicator hits count for presence (combo parity).
assert.equal(containsMediaKind(msg([{ image_url: "https://x" }]), "image"), true);
assert.equal(containsMediaKind([{ role: "user", content: "oi" }], "image"), false);
assert.equal(containsMediaKind(undefined, "image"), false);
});
test("extractImageParts skips indicator parts without extractable refs", () => {
assert.deepEqual(
extractImageParts([{ role: "user", content: [{ type: "image" }] } as never]),
[]
);
});
test("extractImageParts now sees input_image (Responses API)", () => {
const parts = extractImageParts([
{ role: "user", content: [{ type: "input_image", image_url: "https://x/i.png" }] } as never,
]);
assert.equal(parts.length, 1);
assert.equal(parts[0].imageUrl, "https://x/i.png");
});
test("extract→replace round-trip: input_image does not shift sibling descriptions", () => {
const body = {
messages: [
{
role: "user",
content: [
{ type: "input_image", image_url: "https://x/a.png" },
{ type: "image_url", image_url: { url: "https://x/b.png" } },
],
},
],
};
const extracted = extractImageParts(body.messages as never);
assert.equal(extracted.length, 2);
assert.equal(extracted[0].imageUrl, "https://x/a.png");
assert.equal(extracted[1].imageUrl, "https://x/b.png");
const replaced = replaceImageParts(body as never, ["DA", "DB"]);
const content = (replaced.messages as Array<{ content: unknown }>)[0].content;
assert.deepEqual(content, [
{ type: "text", text: "DA" },
{ type: "text", text: "DB" },
]);
});
test("extractImageParts does not extract a data URI embedded in a text part", () => {
const parts = extractImageParts([
{
role: "user",
content: [{ type: "text", text: "data:image/png;base64,QUJD" }],
} as never,
]);
assert.deepEqual(parts, []);
});
test("extractImageParts skips nested and indicator-only detections", () => {
const parts = extractImageParts([
{
role: "user",
content: [
// Image nested inside an audio payload: detector reports it (combo needs
// it) but the replacer cannot splice it — must not be extracted.
{
type: "input_audio",
input_audio: {
data: "QUJD",
cover: { type: "image_url", image_url: { url: "https://x/c.png" } },
},
},
// Bare image_url key without type: indicator shape, not replaceable.
{ image_url: "https://x/bare.png" },
],
} as never,
]);
assert.deepEqual(parts, []);
});

View File

@@ -0,0 +1,63 @@
import { test } from "node:test";
import assert from "node:assert";
import {
BridgeCache,
bridgeCacheKey,
getSharedBridgeCacheFor,
} from "../../src/lib/guardrails/modalityBridge/bridgeCache.ts";
import { resolveVisionBridgeRuntimeSettings } from "../../src/shared/constants/modalityBridgeDefaults.ts";
test("key is stable sha256 of content+prompt+model", () => {
const a = bridgeCacheKey("data:image/png;base64,AAA", "describe", "gpt-4o-mini");
const b = bridgeCacheKey("data:image/png;base64,AAA", "describe", "gpt-4o-mini");
assert.equal(a, b);
assert.match(a, /^[a-f0-9]{64}$/);
assert.notEqual(a, bridgeCacheKey("data:image/png;base64,AAA", "other", "gpt-4o-mini"));
});
test("key framing prevents boundary-shift collisions between fields", () => {
// Without length-prefix framing ("ab","c") and ("a","bc") would hash the
// same concatenated bytes.
assert.notEqual(bridgeCacheKey("ab", "c", "m"), bridgeCacheKey("a", "bc", "m"));
assert.notEqual(bridgeCacheKey("x", "yz", "m"), bridgeCacheKey("x", "y", "zm"));
});
test("get/set roundtrip and TTL expiry", () => {
let now = 1000;
const cache = new BridgeCache({ maxEntries: 10, ttlMs: 500, now: () => now });
cache.set("k1", "desc");
assert.equal(cache.get("k1"), "desc");
now = 1600;
assert.equal(cache.get("k1"), undefined);
});
test("LRU evicts oldest when full", () => {
const cache = new BridgeCache({ maxEntries: 2, ttlMs: 60000, now: () => 0 });
cache.set("a", "1");
cache.set("b", "2");
cache.get("a");
cache.set("c", "3");
assert.equal(cache.get("b"), undefined);
assert.equal(cache.get("a"), "1");
});
test("getSharedBridgeCacheFor reuses the instance for the same config and recreates on change", () => {
const base = resolveVisionBridgeRuntimeSettings({});
const a = getSharedBridgeCacheFor(base);
const b = getSharedBridgeCacheFor({ ...base });
assert.equal(a, b);
const c = getSharedBridgeCacheFor({ ...base, cacheTtlMinutes: base.cacheTtlMinutes + 5 });
assert.notEqual(c, a);
const d = getSharedBridgeCacheFor({ ...base, cacheTtlMinutes: base.cacheTtlMinutes + 5 });
assert.equal(d, c);
const e = getSharedBridgeCacheFor({
...base,
cacheTtlMinutes: base.cacheTtlMinutes + 5,
cacheMaxEntries: base.cacheMaxEntries + 1,
});
assert.notEqual(e, d);
});

View File

@@ -0,0 +1,116 @@
/**
* Modality Bridge stats + transparency header (PR-1 Task 9):
* - buildModalityBridgeHeader() derives the `x-omniroute-modality-bridge`
* response header value from pre-call guardrail results (describe path only —
* reroute and untouched requests get no header).
* - recordBridgeUse()/getBridgeStats() keep in-memory per-modality counters.
*
* Stats and the describe cache are PROCESS-GLOBAL: assertions use >= against
* captured before-values and every guardrail case uses a unique image payload
* (`auto/` model + mode "describe" keeps the flow DB-free — same recipe as
* tests/unit/vision-bridge-describe-cache.test.ts).
*/
import { test } from "node:test";
import assert from "node:assert";
import {
buildModalityBridgeHeader,
recordBridgeUse,
getBridgeStats,
} from "../../src/lib/guardrails/modalityBridge/bridgeStats.ts";
import { VisionBridgeGuardrail } from "../../src/lib/guardrails/visionBridge.ts";
test("header built from vision-bridge describe meta", () => {
const h = buildModalityBridgeHeader([
{ guardrail: "vision-bridge", meta: { imagesProcessed: 2, visionModel: "openai/gpt-4o-mini" } },
]);
assert.equal(h, "image->text;model=openai/gpt-4o-mini;parts=2");
});
test("no header for reroute or untouched requests", () => {
assert.equal(
buildModalityBridgeHeader([{ guardrail: "vision-bridge", meta: { rerouted: true } }]),
null
);
assert.equal(buildModalityBridgeHeader([]), null);
});
test("audio-bridge meta produces the audio segment (PR-3 forward-compat)", () => {
const h = buildModalityBridgeHeader([
{ guardrail: "vision-bridge", meta: { imagesProcessed: 1, visionModel: "m1" } },
{ guardrail: "audio-bridge", meta: { clipsProcessed: 3, sttModel: "m2" } },
]);
assert.equal(h, "image->text;model=m1;parts=1, audio->text;model=m2;parts=3");
});
test("stats counters accumulate", () => {
recordBridgeUse("vision", { cacheHit: true });
const s = getBridgeStats();
assert.ok(s.vision.bridged >= 1);
assert.ok(s.vision.cacheHits >= 1);
});
// ── Guardrail-level wiring: describe path bumps the vision counters ─────────
function statsGuardrail(counter: { calls: number }, behavior?: { failAlways?: boolean }) {
return new VisionBridgeGuardrail({
deps: {
getSettings: async () => ({ modalityBridgeVisionMode: "describe" }),
callVisionModel: async () => {
counter.calls++;
if (behavior?.failAlways) throw new Error("describe indisponível");
return "uma descrição da imagem";
},
hasUsableCredentials: async () => null,
},
});
}
/** Unique per-test payload — the test name lands inside the base64 content. */
function bodyWithImage(uniqueRef: string): Record<string, unknown> {
return {
model: "auto/bridge-stats",
messages: [
{
role: "user",
content: [
{ type: "text", text: "o que há na imagem?" },
{
type: "image_url",
image_url: {
url: `data:image/png;base64,${Buffer.from(uniqueRef).toString("base64")}`,
},
},
],
},
],
};
}
const context = { model: "auto/bridge-stats", log: console };
test("describe path records bridged use; identical repeat counts a cache hit", async () => {
const before = getBridgeStats().vision;
const counter = { calls: 0 };
const guardrail = statsGuardrail(counter);
await guardrail.preCall(bodyWithImage("bridge-stats-hit-test"), context);
const afterFirst = getBridgeStats().vision;
assert.ok(afterFirst.bridged >= before.bridged + 1, "successful describe must count as bridged");
assert.ok(typeof afterFirst.lastUsedAt === "string", "lastUsedAt must be stamped");
await guardrail.preCall(bodyWithImage("bridge-stats-hit-test"), context);
const afterSecond = getBridgeStats().vision;
assert.equal(counter.calls, 1, "second identical request must be served from the cache");
assert.ok(afterSecond.bridged >= afterFirst.bridged + 1, "cache-served describe still bridged");
assert.ok(afterSecond.cacheHits >= afterFirst.cacheHits + 1, "cache hit must be counted");
});
test("failed describe records a failure", async () => {
const before = getBridgeStats().vision;
const guardrail = statsGuardrail({ calls: 0 }, { failAlways: true });
await guardrail.preCall(bodyWithImage("bridge-stats-failure-test"), context);
const after = getBridgeStats().vision;
assert.ok(after.failures >= before.failures + 1, "failed describe must count as failure");
});

View File

@@ -0,0 +1,92 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
const MIGRATION_PATH = path.resolve("src/lib/db/migrations/141_modality_bridge_settings.sql");
const INITIAL_SCHEMA_PATH = path.resolve("src/lib/db/migrations/001_initial_schema.sql");
/** Extract the REAL key_value DDL from the initial schema so the seed can never drift. */
function keyValueTableDdl(): string {
const schema = fs.readFileSync(INITIAL_SCHEMA_PATH, "utf8");
const match = schema.match(/CREATE TABLE IF NOT EXISTS key_value \([\s\S]*?\);/);
assert.ok(match, "key_value CREATE TABLE block not found in 001_initial_schema.sql");
return match[0];
}
function createSeededDb(): InstanceType<typeof Database> {
const db = new Database(":memory:");
db.exec(keyValueTableDdl());
return db;
}
function getSetting(db: InstanceType<typeof Database>, key: string): string | undefined {
const row = db
.prepare("SELECT value FROM key_value WHERE namespace = 'settings' AND key = ?")
.get(key) as { value: string } | undefined;
return row?.value;
}
test("migration 141 copies legacy visionBridge* settings to modalityBridge* keys", () => {
const sql = fs.readFileSync(MIGRATION_PATH, "utf8");
const db = createSeededDb();
try {
const seed = db.prepare(
"INSERT INTO key_value (namespace, key, value) VALUES ('settings', ?, ?)"
);
seed.run("visionBridgeEnabled", "true");
seed.run("visionBridgeModel", "openai/gpt-4o-mini");
seed.run("visionBridgePrompt", "legacy prompt");
seed.run("visionBridgeTimeout", "45000");
seed.run("visionBridgeMaxImages", "6");
db.exec(sql);
assert.equal(getSetting(db, "modalityBridgeVisionEnabled"), "true");
assert.equal(getSetting(db, "modalityBridgeVisionModel"), "openai/gpt-4o-mini");
assert.equal(getSetting(db, "modalityBridgeVisionPrompt"), "legacy prompt");
assert.equal(getSetting(db, "modalityBridgeVisionTimeout"), "45000");
assert.equal(getSetting(db, "modalityBridgeVisionMaxImages"), "6");
// Legacy keys stay untouched (one-cycle rollback window).
assert.equal(getSetting(db, "visionBridgeEnabled"), "true");
} finally {
db.close();
}
});
test("migration 141 is idempotent and never overwrites an existing new key", () => {
const sql = fs.readFileSync(MIGRATION_PATH, "utf8");
const db = createSeededDb();
try {
const seed = db.prepare(
"INSERT INTO key_value (namespace, key, value) VALUES ('settings', ?, ?)"
);
seed.run("visionBridgeModel", "openai/gpt-4o-mini");
// Operator already set the new key — the migration must not clobber it.
seed.run("modalityBridgeVisionModel", "gemini/gemini-2.5-flash");
db.exec(sql);
db.exec(sql); // re-run: idempotent
assert.equal(getSetting(db, "modalityBridgeVisionModel"), "gemini/gemini-2.5-flash");
const count = db
.prepare("SELECT COUNT(*) AS n FROM key_value WHERE namespace = 'settings'")
.get() as { n: number };
assert.equal(count.n, 2);
} finally {
db.close();
}
});
test("migration 141 is a no-op on a database without legacy settings", () => {
const sql = fs.readFileSync(MIGRATION_PATH, "utf8");
const db = createSeededDb();
try {
db.exec(sql);
const count = db.prepare("SELECT COUNT(*) AS n FROM key_value").get() as { n: number };
assert.equal(count.n, 0);
} finally {
db.close();
}
});

View File

@@ -0,0 +1,96 @@
import { test } from "node:test";
import assert from "node:assert";
import {
resolveVisionBridgeRuntimeSettings,
MODALITY_BRIDGE_DEFAULTS,
} from "../../src/shared/constants/modalityBridgeDefaults.ts";
import { updateSettingsSchema } from "../../src/shared/validation/settingsSchemas.ts";
test("new keys win over legacy keys", () => {
const s = resolveVisionBridgeRuntimeSettings({
modalityBridgeVisionEnabled: false,
visionBridgeEnabled: true,
modalityBridgeVisionModel: "gemini/gemini-2.5-flash",
visionBridgeModel: "openai/gpt-4o-mini",
});
assert.equal(s.enabled, false);
assert.equal(s.model, "gemini/gemini-2.5-flash");
});
test("legacy keys used as fallback when new keys absent (rollback 1 ciclo)", () => {
const s = resolveVisionBridgeRuntimeSettings({
visionBridgeEnabled: false,
visionBridgePrompt: "legacy prompt",
});
assert.equal(s.enabled, false);
assert.equal(s.prompt, "legacy prompt");
});
test("defaults: mode auto, taskAware true, cache on", () => {
const s = resolveVisionBridgeRuntimeSettings({});
assert.equal(s.mode, "auto");
assert.equal(s.taskAware, true);
assert.equal(s.cacheEnabled, true);
assert.equal(s.cacheTtlMinutes, MODALITY_BRIDGE_DEFAULTS.cacheTtlMinutes);
});
test("wrong-typed stored values are skipped in favor of the next candidate/default", () => {
const s = resolveVisionBridgeRuntimeSettings({
modalityBridgeVisionEnabled: "off", // string in a boolean field
modalityBridgeVisionTimeout: "9000", // string in a number field
modalityBridgeVisionModel: 42, // number in a string field
});
assert.equal(s.enabled, true); // falls through to VISION_BRIDGE_DEFAULTS.enabled
assert.equal(s.timeoutMs, 30000);
assert.equal(s.model, "openai/gpt-4o-mini");
// A wrong-typed NEW key must still fall through to a well-typed LEGACY key.
const fallback = resolveVisionBridgeRuntimeSettings({
modalityBridgeVisionEnabled: "off",
visionBridgeEnabled: false,
});
assert.equal(fallback.enabled, false);
});
test("Modality Bridge settings are accepted by the settings PATCH schema", () => {
const validation = updateSettingsSchema.safeParse({
modalityBridgeVisionEnabled: true,
modalityBridgeVisionMode: "describe",
modalityBridgeVisionModel: "gemini/gemini-2.5-flash",
modalityBridgeVisionTaskAware: false,
modalityBridgeVisionPrompt: "Describe the image contents briefly.",
modalityBridgeVisionTimeout: 45000,
modalityBridgeVisionMaxImages: 6,
modalityBridgeAudioEnabled: true,
modalityBridgeAudioModel: "openai/gpt-4o-mini-transcribe",
modalityBridgeAudioTimeout: 60000,
modalityBridgeAudioMaxClips: 3,
modalityBridgeCacheEnabled: true,
modalityBridgeCacheTtlMinutes: 60,
modalityBridgeCacheMaxEntries: 200,
});
assert.equal(validation.success, true);
});
test("Modality Bridge settings keep numeric bounds enforced (each field individually)", () => {
const invalidByField: Record<string, number> = {
modalityBridgeVisionTimeout: 999999,
modalityBridgeVisionMaxImages: 0,
modalityBridgeAudioMaxClips: 11,
modalityBridgeCacheTtlMinutes: 0,
modalityBridgeCacheMaxEntries: 9,
};
for (const [field, value] of Object.entries(invalidByField)) {
const validation = updateSettingsSchema.safeParse({ [field]: value });
assert.equal(validation.success, false, `${field}=${value} should be rejected`);
}
});
test("Modality Bridge vision mode rejects values outside the enum", () => {
const validation = updateSettingsSchema.safeParse({
modalityBridgeVisionMode: "invalid-mode",
});
assert.equal(validation.success, false);
});

View File

@@ -0,0 +1,102 @@
/**
* Describe-path cache integration (Modality Bridge PR-1): the describe loop
* consults the shared BridgeCache (sha256 of contentRef+prompt+model) so the
* same image with the same prompt/model is described once per TTL. Failures
* are never cached. Opt-out via `modalityBridgeCacheEnabled: false`.
*
* The shared cache is PROCESS-WIDE — every test uses a unique image payload so
* tests cannot cross-contaminate each other's keys. Guardrail cases use
* `model: "auto/..."` + `mode: "describe"` so the flow is DB-free.
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { VisionBridgeGuardrail } from "../../src/lib/guardrails/visionBridge.ts";
function cacheGuardrail(
settings: Record<string, unknown>,
counter: { calls: number },
behavior?: { failFirstCall?: boolean }
): InstanceType<typeof VisionBridgeGuardrail> {
return new VisionBridgeGuardrail({
deps: {
getSettings: async () => ({ modalityBridgeVisionMode: "describe", ...settings }),
callVisionModel: async () => {
counter.calls++;
if (behavior?.failFirstCall && counter.calls === 1) {
throw new Error("primeiro describe falhou");
}
return "uma descrição da imagem";
},
hasUsableCredentials: async () => null,
},
});
}
/** Unique per-test payload — the test name lands inside the base64 content. */
function bodyWithImage(uniqueRef: string): Record<string, unknown> {
return {
model: "auto/describe-cache",
messages: [
{
role: "user",
content: [
{ type: "text", text: "o que há na imagem?" },
{
type: "image_url",
image_url: {
url: `data:image/png;base64,${Buffer.from(uniqueRef).toString("base64")}`,
},
},
],
},
],
};
}
const context = { model: "auto/describe-cache", log: console };
test("same image+prompt+model described twice → single upstream call (cache hit)", async () => {
const counter = { calls: 0 };
const guardrail = cacheGuardrail({}, counter);
const first = await guardrail.preCall(bodyWithImage("cache-hit-test"), context);
assert.equal((first.meta ?? {}).imagesProcessed, 1);
assert.equal(counter.calls, 1);
const second = await guardrail.preCall(bodyWithImage("cache-hit-test"), context);
assert.equal((second.meta ?? {}).imagesProcessed, 1, "cached describe still replaces the image");
assert.equal(counter.calls, 1, "second identical request must be served from the cache");
const descriptions = (second.meta ?? {}).descriptions as string[];
assert.ok(
descriptions?.[0]?.includes("uma descrição da imagem"),
"cached description must be spliced into the payload"
);
});
test("modalityBridgeCacheEnabled=false → every request hits the vision model", async () => {
const counter = { calls: 0 };
const guardrail = cacheGuardrail({ modalityBridgeCacheEnabled: false }, counter);
await guardrail.preCall(bodyWithImage("cache-disabled-test"), context);
await guardrail.preCall(bodyWithImage("cache-disabled-test"), context);
assert.equal(counter.calls, 2, "disabled cache must not dedupe describe calls");
});
test("failed describe is NOT cached — the next request retries upstream", async () => {
const counter = { calls: 0 };
const guardrail = cacheGuardrail({}, counter, { failFirstCall: true });
await guardrail.preCall(bodyWithImage("failure-not-cached-test"), context);
assert.equal(counter.calls, 1);
const second = await guardrail.preCall(bodyWithImage("failure-not-cached-test"), context);
assert.equal(counter.calls, 2, "failure must not be cached; retry must reach upstream");
const descriptions = (second.meta ?? {}).descriptions as string[];
assert.ok(
descriptions?.[0]?.includes("uma descrição da imagem"),
"successful retry description must be used"
);
});

View File

@@ -0,0 +1,138 @@
/**
* Vision Bridge mode selector (auto | describe | reroute) — Modality Bridge PR-1.
*
* The forced modes short-circuit BEFORE the auto reroute×describe heuristic, so
* the #6640/#7204/#7871/#8430 contracts stay untouched in "auto" (the default):
* - "describe": never whole-request-reroutes — straight to the describe path.
* - "reroute": skips only the keep-credentialed-model guard; the reroute-target
* credential guard still applies, and with no usable target it falls back to
* describe (raw images must never reach a text-only backend — #8430).
*
* Uses dependency injection for settings/vision calls/credentials. The model
* capability lookup inside preCall still opens the real (isolated) SQLite DB,
* which on the current base dies on the inherited 134 migration collision —
* hence the decollided-migrations helper below.
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { useDecollidedMigrationsDir } from "./helpers/decollidedMigrationsDir.ts";
useDecollidedMigrationsDir();
const { VisionBridgeGuardrail } = await import("../../src/lib/guardrails/visionBridge.ts");
const TEXT_ONLY_MODEL = "some/text-only-model";
/**
* Unique per-test payload: the describe path caches by image+prompt+model
* (Task 8), so reusing the same data URI across tests would turn a later
* describe into a cache hit and hide the upstream call being asserted.
*/
function imageBody(uniqueRef: string): Record<string, unknown> {
return {
model: TEXT_ONLY_MODEL,
messages: [
{
role: "user",
content: [
{ type: "text", text: "o que há na imagem?" },
{
type: "image_url",
image_url: {
url: `data:image/png;base64,${Buffer.from(uniqueRef).toString("base64")}`,
},
},
],
},
],
};
}
function metaOf(result: { meta?: Record<string, unknown> | null }): Record<string, unknown> {
return result.meta ?? {};
}
test("mode=describe: never reroutes even when a reroute target exists", async () => {
const guardrail = new VisionBridgeGuardrail({
deps: {
getSettings: async () => ({
modalityBridgeVisionMode: "describe",
// A configured vision model — in auto/reroute this would be a valid
// fixed reroute target (credentials indeterminate → fail-open #8430).
modalityBridgeVisionModel: "openai/gpt-4o-mini",
}),
callVisionModel: async () => "uma foto de um gato",
hasUsableCredentials: async () => null,
},
});
const body = imageBody("mode-describe-test");
const result = await guardrail.preCall(body, { model: TEXT_ONLY_MODEL, log: console });
const meta = metaOf(result);
assert.notEqual(meta.rerouted, true, "describe mode must never whole-request-reroute");
assert.equal(meta.imagesProcessed, 1, "the image must be described instead");
});
test("mode=reroute: falls back to describe when no reroute target has credentials", async () => {
const describeCalls: string[] = [];
const guardrail = new VisionBridgeGuardrail({
deps: {
getSettings: async () => ({ modalityBridgeVisionMode: "reroute" }),
callVisionModel: async () => {
describeCalls.push("describe");
return "desc";
},
// Every model confirmed unusable — no reroute target can win (#8430).
hasUsableCredentials: async () => false,
},
});
const body = imageBody("mode-reroute-fallback-test");
const result = await guardrail.preCall(body, { model: TEXT_ONLY_MODEL, log: console });
const meta = metaOf(result);
assert.notEqual(meta.rerouted, true, "must not reroute to a target without credentials");
assert.ok(describeCalls.length >= 1, "deveria ter caído para o caminho de descrição");
});
test("mode=reroute: forces reroute where auto mode would keep the credentialed model", async () => {
const guardrail = new VisionBridgeGuardrail({
deps: {
getSettings: async () => ({
modalityBridgeVisionMode: "reroute",
modalityBridgeVisionModel: "openai/gpt-4o-mini",
}),
callVisionModel: async () => "desc",
// Original model IS credentialed (auto mode would keep it, #7204);
// reroute target indeterminate → fail-open proceeds (#8430).
hasUsableCredentials: async (model: string) => (model === TEXT_ONLY_MODEL ? true : null),
},
});
const body = imageBody("mode-reroute-forces-test");
const result = await guardrail.preCall(body, { model: TEXT_ONLY_MODEL, log: console });
const meta = metaOf(result);
assert.equal(meta.rerouted, true, "reroute mode must skip the keep-credentialed-model guard");
assert.equal(meta.toModel, "openai/gpt-4o-mini");
assert.equal(meta.fromModel, TEXT_ONLY_MODEL);
});
test("mode=auto (default): credentialed model is described, not hijacked", async () => {
const guardrail = new VisionBridgeGuardrail({
deps: {
getSettings: async () => ({}),
callVisionModel: async () => "desc",
hasUsableCredentials: async () => true,
},
});
const body = imageBody("mode-auto-default-test");
const result = await guardrail.preCall(body, { model: TEXT_ONLY_MODEL, log: console });
const meta = metaOf(result);
assert.notEqual(meta.rerouted, true, "auto mode keeps the credentialed model (#7204)");
assert.equal(meta.imagesProcessed, 1, "images are described for the kept model");
});

View File

@@ -0,0 +1,115 @@
/**
* Task-aware vision description prompt (codex-vision-proxy pattern) —
* Modality Bridge PR-1. The describe path appends the user's last question as
* a focus hint so the vision model describes what is relevant to answering it
* instead of producing a generic caption. Default ON; disabled via
* `modalityBridgeVisionTaskAware: false`.
*
* Guardrail-level cases use `model: "auto/..."` + `mode: "describe"` so the
* whole flow is DB-free (the auto prefix skips the capability/combo lookups
* that open SQLite, and the forced describe mode skips the reroute block).
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { composeVisionPrompt } from "../../src/lib/guardrails/visionBridgeHelpers.ts";
import { VisionBridgeGuardrail } from "../../src/lib/guardrails/visionBridge.ts";
import type { VisionModelConfig } from "../../src/lib/guardrails/visionBridgeHelpers.ts";
// ── composeVisionPrompt (pure) ──────────────────────────────────────────────
test("appends user focus hint when taskAware", () => {
const p = composeVisionPrompt("Describe the image.", "qual o erro no screenshot?", true);
assert.ok(p.startsWith("Describe the image."));
assert.ok(p.includes("qual o erro no screenshot?"));
});
test("no hint when disabled or no user text", () => {
assert.equal(composeVisionPrompt("Base.", "pergunta", false), "Base.");
assert.equal(composeVisionPrompt("Base.", undefined, true), "Base.");
assert.equal(composeVisionPrompt("Base.", " ", true), "Base.");
});
test("hint truncated to 500 chars", () => {
const p = composeVisionPrompt("Base.", "x".repeat(2000), true);
assert.ok(p.length < 700, `expected truncated prompt, got length ${p.length}`);
assert.ok(p.includes("x".repeat(500)));
assert.ok(!p.includes("x".repeat(501)));
});
// ── Guardrail describe path wiring ──────────────────────────────────────────
function describeGuardrail(
settings: Record<string, unknown>,
capturedPrompts: string[]
): InstanceType<typeof VisionBridgeGuardrail> {
return new VisionBridgeGuardrail({
deps: {
getSettings: async () => ({ modalityBridgeVisionMode: "describe", ...settings }),
callVisionModel: async (_imageDataUri: string, config: VisionModelConfig) => {
capturedPrompts.push(config.prompt);
return "descrição";
},
hasUsableCredentials: async () => null,
},
});
}
/**
* Unique per-test payload: the describe path caches by image+prompt+model
* (Task 8), so reusing the same data URI across tests would make a later
* describe a cache hit and hide the upstream call whose prompt is asserted.
*/
function autoImageBody(uniqueRef: string, userText: string): Record<string, unknown> {
return {
model: "auto/task-aware",
messages: [
{
role: "user",
content: [
{ type: "text", text: userText },
{
type: "image_url",
image_url: {
url: `data:image/png;base64,${Buffer.from(uniqueRef).toString("base64")}`,
},
},
],
},
],
};
}
test("describe call prompt contains the last user question (taskAware default on)", async () => {
const prompts: string[] = [];
const guardrail = describeGuardrail({}, prompts);
const result = await guardrail.preCall(
autoImageBody("task-aware-default-on-test", "qual o erro no screenshot?"),
{ model: "auto/task-aware", log: console }
);
assert.equal((result.meta ?? {}).imagesProcessed, 1);
assert.equal(prompts.length, 1);
assert.ok(
prompts[0].includes("qual o erro no screenshot?"),
`prompt should carry the user question, got: ${prompts[0]}`
);
});
test("modalityBridgeVisionTaskAware=false keeps the base prompt untouched", async () => {
const prompts: string[] = [];
const guardrail = describeGuardrail(
{ modalityBridgeVisionTaskAware: false, modalityBridgeVisionPrompt: "Base prompt." },
prompts
);
const result = await guardrail.preCall(
autoImageBody("task-aware-disabled-test", "pergunta que não deve vazar"),
{ model: "auto/task-aware", log: console }
);
assert.equal((result.meta ?? {}).imagesProcessed, 1);
assert.equal(prompts.length, 1);
assert.equal(prompts[0], "Base prompt.");
});