Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion src/lib/guardrails/visionBridgeHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
*/
import { fetchRemoteImage } from "@/shared/network/remoteImageFetch";
import { getRuntimePorts } from "@/lib/runtime/ports";

import {
getBestVisionModel,
getFallbackModels,
recordLatency,
} from "./visionBridgeRouter";
/**
* Provider to environment variable mapping for API key resolution.
*/
Expand Down Expand Up @@ -204,8 +208,51 @@ export interface VisionModelConfig {
/**
* Call the vision model to get an image description.
* Supports both OpenAI-compatible and Anthropic API formats.
* Uses auto-routing to select the fastest available model.
*/
export async function callVisionModel(
imageDataUri: string,
config: VisionModelConfig,
apiKey?: string,
routerConfig?: Partial<import("./visionBridgeRouter").VisionBridgeRouterConfig>
): Promise<string> {
// Auto-select the best vision model if not explicitly configured
const modelToUse = getBestVisionModel({
fixedModel: config.model,
...routerConfig,
});
let lastError: Error | null = null;

// Try primary model + fallbacks
const modelsToTry = [modelToUse, ...getFallbackModels(modelToUse, routerConfig)];
const maxAttempts = Math.min(modelsToTry.length, routerConfig?.maxFallbackAttempts ?? 3);

for (let attempt = 0; attempt < maxAttempts; attempt++) {
const currentModel = modelsToTry[attempt];
const attemptStart = Date.now();
try {
const result = await callVisionModelSingle(
imageDataUri,
{ ...config, model: currentModel },
apiKey
);
recordLatency(currentModel, Date.now() - attemptStart, true);
return result;
} catch (error) {
recordLatency(currentModel, Date.now() - attemptStart, false);
lastError = error instanceof Error ? error : new Error(String(error));
// Continue to next model on failure
}
}
Comment on lines +230 to +246

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The latency measurement for fallback models is currently calculated from startTime (which is defined outside the loop). This means that if a previous model attempt takes a long time and fails, that elapsed time will be incorrectly added to the latency of the subsequent fallback model. Measuring latency per attempt ensures accurate routing statistics.

Suggested change
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const currentModel = modelsToTry[attempt];
try {
const result = await callVisionModelSingle(imageDataUri, { ...config, model: currentModel }, apiKey);
const latency = Date.now() - startTime;
recordLatency(currentModel, latency, true);
return result;
} catch (error) {
const latency = Date.now() - startTime;
recordLatency(currentModel, latency, false);
lastError = error instanceof Error ? error : new Error(String(error));
// Continue to next model on failure
}
}
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const currentModel = modelsToTry[attempt];
const attemptStartTime = Date.now();
try {
const result = await callVisionModelSingle(imageDataUri, { ...config, model: currentModel }, apiKey);
const latency = Date.now() - attemptStartTime;
recordLatency(currentModel, latency, true);
return result;
} catch (error) {
const latency = Date.now() - attemptStartTime;
recordLatency(currentModel, latency, false);
lastError = error instanceof Error ? error : new Error(String(error));
// Continue to next model on failure
}
}


// All models failed
throw lastError || new Error("All vision models failed");
}

/**
* Internal function to call a single vision model.
*/
async function callVisionModelSingle(
imageDataUri: string,
config: VisionModelConfig,
apiKey?: string
Expand Down
271 changes: 271 additions & 0 deletions src/lib/guardrails/visionBridgeRouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
/**
* Vision Bridge Auto-Router
* Automatically selects the fastest vision-capable model from available models.
*/

import { getResolvedModelCapabilities } from "@/lib/modelCapabilities";
import { PROVIDER_MODELS, PROVIDER_ID_TO_ALIAS } from "@omniroute/open-sse/config/providerModels";

export interface VisionModelCandidate {
modelId: string;
fullName: string; // provider/model format
priority: number; // lower = better (local models first)
averageLatencyMs: number;
lastUsedAt: number;
successRate: number;
}

export interface LatencyRecord {
modelId: string;
latencyMs: number;
timestamp: number;
success: boolean;
}

export interface VisionBridgeRouterConfig {
/** Fixed model to use (overrides auto-routing) */
fixedModel?: string;
/** Maximum number of fallback attempts */
maxFallbackAttempts: number;
/** Cache TTL for selection decisions (ms) */
selectionCacheTtlMs: number;
/** Minimum number of latency samples before trusting average */
minLatencySamples: number;
/** Models to exclude from auto-routing */
excludedModels: string[];
}

const DEFAULT_ROUTER_CONFIG: VisionBridgeRouterConfig = {
maxFallbackAttempts: 3,
selectionCacheTtlMs: 60_000, // 1 minute
minLatencySamples: 5,
excludedModels: [],
};

// In-memory latency tracker (would be Redis in production)
const latencyStore = new Map<string, LatencyRecord[]>();
const selectionCache = new Map<string, { modelId: string; expiresAt: number }>();

/**
* Record a latency measurement for a model.
*/
export function recordLatency(modelId: string, latencyMs: number, success: boolean): void {
const records = latencyStore.get(modelId) || [];
records.push({
modelId,
latencyMs,
timestamp: Date.now(),
success,
});

// Keep only last 100 records per model
if (records.length > 100) {
records.splice(0, records.length - 100);
}

latencyStore.set(modelId, records);
}

/**
* Calculate average latency for a model, considering only recent records.
*/
function calculateAverageLatency(modelId: string, windowMs: number = 300_000): number {
const records = latencyStore.get(modelId) || [];
const cutoff = Date.now() - windowMs;
const recentRecords = records.filter((r) => r.timestamp > cutoff && r.success);

if (recentRecords.length === 0) {
return Infinity; // No data = assume slow
}

const sum = recentRecords.reduce((acc, r) => acc + r.latencyMs, 0);
return sum / recentRecords.length;
}

/**
* Calculate success rate for a model.
*/
function calculateSuccessRate(modelId: string): number {
const records = latencyStore.get(modelId) || [];
if (records.length === 0) return 1.0; // No data = assume good

const recentRecords = records.slice(-50); // Last 50 attempts
const successes = recentRecords.filter((r) => r.success).length;
return successes / recentRecords.length;
}

/**
* Get all vision-capable models from the registry.
*/
function getVisionCapableModels(): VisionModelCandidate[] {
const candidates: VisionModelCandidate[] = [];

for (const [providerAlias, models] of Object.entries(PROVIDER_MODELS)) {
if (!Array.isArray(models)) continue;

for (const model of models) {
if (!model?.id) continue;

const fullModelId = `${providerAlias}/${model.id}`;
const caps = getResolvedModelCapabilities(fullModelId);

if (caps.supportsVision === true) {
// Determine priority based on provider type
let priority = 100;
if (providerAlias.startsWith("opencode-")) {
priority = 0; // Local/free models first
} else if (providerAlias === "openai" || providerAlias === "anthropic") {
priority = 50; // Major providers
} else {
priority = 75; // Other providers
}

candidates.push({
modelId: model.id,
fullName: fullModelId,
priority,
averageLatencyMs: calculateAverageLatency(fullModelId),
lastUsedAt: 0,
successRate: calculateSuccessRate(fullModelId),
});
}
}
}

return candidates;
}

/**
* Select the best vision model based on latency, priority, and success rate.
*/
function selectBestModel(
candidates: VisionModelCandidate[],
config: VisionBridgeRouterConfig
): VisionModelCandidate | null {
const filtered = candidates.filter((c) => {
// Exclude explicitly excluded models
if (config.excludedModels.includes(c.fullName)) return false;
if (config.excludedModels.includes(c.modelId)) return false;

// Exclude models with poor success rate (< 50%)
if (c.successRate < 0.5) return false;

return true;
});

if (filtered.length === 0) return null;

// Score each candidate: lower is better
// Score = priority * 1000 + averageLatencyMs
// This prioritizes local models, then fastest latency
const scored = filtered.map((c) => ({
...c,
score: c.priority * 1000 + (c.averageLatencyMs === Infinity ? 10000 : c.averageLatencyMs),
}));

scored.sort((a, b) => a.score - b.score);

return scored[0];
}

/**
* Get the best vision model for image description.
* Respects fixed model override if configured.
*/
export function getBestVisionModel(
config: Partial<VisionBridgeRouterConfig> = {}
): string {
const fullConfig = { ...DEFAULT_ROUTER_CONFIG, ...config };

// If fixed model is configured, use it
if (fullConfig.fixedModel) {
return fullConfig.fixedModel;
}

// Check selection cache — key includes excluded models to prevent cache pollution
// across different configurations
const cacheKey = fullConfig.excludedModels.length > 0
? `excl:${[...fullConfig.excludedModels].sort().join(",")}`
: "default";
const cached = selectionCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.modelId;
}

// Get all vision-capable candidates
const candidates = getVisionCapableModels();

// Select best model
const best = selectBestModel(candidates, fullConfig);

if (!best) {
// Fallback to default
return "openai/gpt-4o-mini";
}

// Cache the selection
selectionCache.set(cacheKey, {
modelId: best.fullName,
expiresAt: Date.now() + fullConfig.selectionCacheTtlMs,
});

return best.fullName;
}

/**
* Get fallback models for retry logic.
*/
export function getFallbackModels(
excludeModel: string,
config: Partial<VisionBridgeRouterConfig> = {}
): string[] {
const fullConfig = { ...DEFAULT_ROUTER_CONFIG, ...config };
const candidates = getVisionCapableModels();

const filtered = candidates.filter(
(c) =>
c.fullName !== excludeModel &&
!fullConfig.excludedModels.includes(c.fullName) &&
c.successRate >= 0.5
);

// Sort by score
const scored = filtered.map((c) => ({
...c,
score: c.priority * 1000 + (c.averageLatencyMs === Infinity ? 10000 : c.averageLatencyMs),
}));

scored.sort((a, b) => a.score - b.score);

return scored.slice(0, fullConfig.maxFallbackAttempts - 1).map((c) => c.fullName);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If maxFallbackAttempts is configured to 0, fullConfig.maxFallbackAttempts - 1 becomes -1. Passing -1 to slice will return all but the last candidate, which is the opposite of the intended behavior (it should return 0 fallbacks). Guarding against negative values with Math.max prevents this.

Suggested change
return scored.slice(0, fullConfig.maxFallbackAttempts - 1).map((c) => c.fullName);
return scored.slice(0, Math.max(0, fullConfig.maxFallbackAttempts - 1)).map((c) => c.fullName);

}

/**
* Clear the selection cache (e.g., after config change).
*/
export function clearSelectionCache(): void {
selectionCache.clear();
}

/**
* Get latency statistics for debugging.
*/
export function getLatencyStats(): Record<string, { avg: number; samples: number; successRate: number }> {
const stats: Record<string, { avg: number; samples: number; successRate: number }> = {};

for (const [modelId, records] of latencyStore.entries()) {
const recentRecords = records.filter((r) => r.timestamp > Date.now() - 300_000);
if (recentRecords.length === 0) continue;

const avg = recentRecords.reduce((acc, r) => acc + r.latencyMs, 0) / recentRecords.length;
const successRate = recentRecords.filter((r) => r.success).length / recentRecords.length;

stats[modelId] = {
avg: Math.round(avg),
samples: recentRecords.length,
successRate: Math.round(successRate * 100) / 100,
};
}

return stats;
}
Loading