Repository navigation
feat(vision-bridge): add automatic image description fallback for non-vision models - #1476
Conversation
…-vision models Implements VisionBridgeGuardrail (priority 5) that intercepts image-bearing requests to non-vision models, extracts descriptions via a configurable vision model (default: gpt-4o-mini), and replaces images with text before forwarding. Fails open on any error. - Add VisionBridgeGuardrail class extending BaseGuardrail - Add visionBridgeHelpers: extractImageParts, callVisionModel, replaceImageParts, resolveImageAsDataUri - Add visionBridgeDefaults with configurable settings - Register VisionBridgeGuardrail in GuardrailRegistry at priority 5 - Add 51 unit tests covering all spec scenarios (VB-S01 through VB-S10) - Dependency injection for getSettings and callVisionModel (testable without SQLite) Closes cheaper-inference#1424
There was a problem hiding this comment.
Code Review
This pull request introduces the VisionBridgeGuardrail, which enables text-only models to process image inputs by extracting descriptions via a secondary vision model. The implementation includes comprehensive helper functions for image extraction and replacement, configuration defaults, and a robust suite of unit tests. Additionally, the PR updates error handling for token limits and refines combo target resolution. Feedback highlights critical performance concerns regarding sequential API calls and inefficient database lookups in the chat handler. Technical issues were also identified in the Anthropic API implementation, alongside a project structure violation regarding the placement of the PR documentation file.
| const descriptions: string[] = []; | ||
| const startTime = Date.now(); | ||
|
|
||
| for (let i = 0; i < limitedParts.length; i++) { | ||
| const imagePart = limitedParts[i]; | ||
| try { | ||
| const description = await callVision(imagePart.imageUrl, config); | ||
| descriptions.push(`[Image ${i + 1}]: ${description}`); | ||
| } catch (error) { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| logger?.warn?.( | ||
| "VISION-BRIDGE", | ||
| `Failed to get description for image ${i + 1}: ${message}` | ||
| ); | ||
| descriptions.push(`[Image ${i + 1}]: (unavailable)`); | ||
| } | ||
| } |
There was a problem hiding this comment.
The image descriptions are currently extracted sequentially in a loop. If a request contains multiple images (up to the default limit of 10), this will significantly increase latency as each vision model call must wait for the previous one to complete. Processing these calls in parallel using Promise.all would greatly improve performance.
| const baseUrl = config.model.startsWith("anthropic/") | ||
| ? process.env.ANTHROPIC_API_URL || "https://api.anthropic.com" | ||
| : process.env.OPENAI_API_URL || "https://api.openai.com/v1"; | ||
|
|
||
| const response = await fetch(`${baseUrl}/chat/completions`, { |
There was a problem hiding this comment.
The callVisionModel function attempts to support Anthropic models by changing the base URL, but it still appends /chat/completions to the endpoint and uses the OpenAI request body format. Anthropic's native API uses the /v1/messages endpoint and a different payload structure. Additionally, Anthropic requires the x-api-key header instead of Authorization: Bearer. This implementation will fail when calling Anthropic models directly. If the intention is to support only OpenAI-compatible providers, the Anthropic-specific logic should be removed or clarified.
| function deepClone<T>(obj: T): T { | ||
| if (obj === null || typeof obj !== "object") { | ||
| return obj; | ||
| } | ||
|
|
||
| if (Array.isArray(obj)) { | ||
| return obj.map((item) => deepClone(item)) as T; | ||
| } | ||
|
|
||
| const cloned = {} as Record<string, unknown>; | ||
| for (const [key, value] of Object.entries(obj)) { | ||
| cloned[key] = deepClone(value); | ||
| } | ||
| return cloned as T; | ||
| } |
| const { getCombos } = await import("../../src/lib/localDb"); | ||
| const allCombosData = await getCombos(); |
There was a problem hiding this comment.
Performing a dynamic import and fetching all combos from the database (getCombos()) inside the handleChatCore function on every request is inefficient. This function is in the hot path for chat requests. Consider using a cached version of the combos data or optimizing the lookup to fetch only the specific combo needed.
| ## Summary | ||
|
|
||
| - Implements `VisionBridgeGuardrail` (priority 5) that intercepts image-bearing requests to non-vision models | ||
| - Extracts descriptions via a configurable vision model (default: `gpt-4o-mini`), replaces images with text, and forwards the modified request | ||
| - Fails open on any error — never blocks valid requests | ||
|
|
||
| ## Details | ||
|
|
||
| When a user sends an image to a text-only model (e.g., MiniMax, GLM, DeepSeek), OmniRoute currently either returns an error or silently drops the image. Vision Bridge solves this by: | ||
|
|
||
| 1. **Detecting** image content parts (`image_url` and base64 `image` formats) in requests to non-vision models | ||
| 2. **Extracting** descriptions by calling a configurable vision model via OpenAI-compatible API | ||
| 3. **Replacing** image blocks with labeled text descriptions (e.g., `[Image 1]: A sunset over the ocean`) | ||
| 4. **Forwarding** the modified text-only request to the target model | ||
|
|
||
| ### Files Added (9 files, 1655 lines) | ||
|
|
||
| | File | Purpose | | ||
| |------|---------| | ||
| | `src/lib/guardrails/visionBridge.ts` | VisionBridgeGuardrail class (priority 5, DI-based) | | ||
| | `src/lib/guardrails/visionBridgeHelpers.ts` | extractImageParts, callVisionModel, replaceImageParts, resolveImageAsDataUri | | ||
| | `src/shared/constants/visionBridgeDefaults.ts` | Default config + types + merge function | | ||
| | `tests/unit/guardrails/visionBridge.test.ts` | 16 guardrail scenario tests | | ||
| | `tests/unit/guardrails/visionBridgeHelpers.*.test.ts` | 35 helper tests (4 files) | | ||
|
|
||
| ### Files Modified | ||
|
|
||
| | File | Change | | ||
| |------|--------| | ||
| | `src/lib/guardrails/registry.ts` | Registered VisionBridgeGuardrail at priority 5 | | ||
|
|
||
| ### Architecture Decisions | ||
|
|
||
| - **AD1**: Priority 5 (runs BEFORE PIIMasker) — needs original image data before PII stripping | ||
| - **AD2**: Direct `fetch()` call — simpler than full executor pipeline for a single vision call | ||
| - **AD3**: Fail-open — Vision Bridge is a convenience feature, never blocks requests | ||
| - **AD4**: OpenAI-compatible format — portable across vision providers | ||
| - **AD5**: Settings-based config — consistent with OmniRoute's existing settings pattern | ||
|
|
||
| ### Configuration | ||
|
|
||
| | Setting | Default | Description | | ||
| |---------|---------|-------------| | ||
| | `visionBridgeEnabled` | `true` | Master toggle | | ||
| | `visionBridgeModel` | `openai/gpt-4o-mini` | Vision model for description extraction | | ||
| | `visionBridgePrompt` | `"Describe this image concisely..."` | Prompt for vision model | | ||
| | `visionBridgeTimeout` | `30000` | Timeout in ms | | ||
|
|
||
| Disable via header: `X-OmniRoute-Disabled-Guardrails: vision-bridge` | ||
|
|
||
| ### Test Results | ||
|
|
||
| **51/51 tests passing** (16 guardrail + 35 helpers), 0 TypeScript errors in source files | ||
|
|
||
| Closes #1424 No newline at end of file |
There was a problem hiding this comment.
The file .pr-body.md is located in the project root, which violates the repository's cleanliness rules. According to the style guide, the project root must only contain specific configuration and documentation files (README.md, CHANGELOG.md, AGENTS.md).
References
- The Project Root MUST ONLY CONTAIN: Configuration files, Dependency files, Documentation files (README.md, CHANGELOG.md, AGENTS.md), CI/CD files and ignore definitions. (link)
…der keys, structuredClone
c515e34 to
de58252
Compare
Review Response — All Issues Fixed ✅Commit \de582529\ addresses all review feedback. Here's the resolution for each comment: 1. Sequential image processing → Parallel ✅ FixedReplaced the \or\ loop with \Promise.allSettled()\ in \�isionBridge.ts. Each image is processed independently — a failing image produces [Image N]: (unavailable)\ without blocking the others. 2. Anthropic API broken → Provider branching ✅ FixedAdded provider-aware branching in \callVisionModel():
3. API key hardcoded to OpenAI → Provider-specific resolution ✅ FixedAdded
4. Custom deepClone → structuredClone ✅ FixedReplaced custom \deepClone()\ with native \structuredClone(). Project requires Node ≥20.20.2 where it\s available. Removed the \deepClone\ function entirely. Unrelated comments:
|
b709dca
into
cheaper-inference:release/v3.7.0
…-vision models (cheaper-inference#1476) * feat(vision-bridge): add automatic image description fallback for non-vision models Implements VisionBridgeGuardrail (priority 5) that intercepts image-bearing requests to non-vision models, extracts descriptions via a configurable vision model (default: gpt-4o-mini), and replaces images with text before forwarding. Fails open on any error. - Add VisionBridgeGuardrail class extending BaseGuardrail - Add visionBridgeHelpers: extractImageParts, callVisionModel, replaceImageParts, resolveImageAsDataUri - Add visionBridgeDefaults with configurable settings - Register VisionBridgeGuardrail in GuardrailRegistry at priority 5 - Add 51 unit tests covering all spec scenarios (VB-S01 through VB-S10) - Dependency injection for getSettings and callVisionModel (testable without SQLite) Closes cheaper-inference#1424 * fix(vision-bridge): resolve Anthropic API, parallel processing, provider keys, structuredClone
…-vision models (cheaper-inference#1476) * feat(vision-bridge): add automatic image description fallback for non-vision models Implements VisionBridgeGuardrail (priority 5) that intercepts image-bearing requests to non-vision models, extracts descriptions via a configurable vision model (default: gpt-4o-mini), and replaces images with text before forwarding. Fails open on any error. - Add VisionBridgeGuardrail class extending BaseGuardrail - Add visionBridgeHelpers: extractImageParts, callVisionModel, replaceImageParts, resolveImageAsDataUri - Add visionBridgeDefaults with configurable settings - Register VisionBridgeGuardrail in GuardrailRegistry at priority 5 - Add 51 unit tests covering all spec scenarios (VB-S01 through VB-S10) - Dependency injection for getSettings and callVisionModel (testable without SQLite) Closes cheaper-inference#1424 * fix(vision-bridge): resolve Anthropic API, parallel processing, provider keys, structuredClone
Summary
VisionBridgeGuardrail(priority 5) that intercepts image-bearing requests to non-vision modelsgpt-4o-mini), replaces images with text, and forwards the modified requestDetails
When a user sends an image to a text-only model (e.g., MiniMax, GLM, DeepSeek), OmniRoute currently either returns an error or silently drops the image. Vision Bridge solves this by:
image_urland base64imageformats) in requests to non-vision models[Image 1]: A sunset over the ocean)Files Added (9 files, 1655 lines)
src/lib/guardrails/visionBridge.tssrc/lib/guardrails/visionBridgeHelpers.tssrc/shared/constants/visionBridgeDefaults.tstests/unit/guardrails/visionBridge.test.tstests/unit/guardrails/visionBridgeHelpers.*.test.tsFiles Modified
src/lib/guardrails/registry.tsArchitecture Decisions
fetch()call — simpler than full executor pipeline for a single vision callConfiguration
visionBridgeEnabledtruevisionBridgeModelopenai/gpt-4o-minivisionBridgePrompt"Describe this image concisely..."visionBridgeTimeout30000Disable via header:
X-OmniRoute-Disabled-Guardrails: vision-bridgeTest Results
51/51 tests passing (16 guardrail + 35 helpers), 0 TypeScript errors in source files
Closes #1424