Files
OmniRoute/src/lib/guardrails/visionBridgeRouter.ts

329 lines
11 KiB
TypeScript

/**
* 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";
import { hasUsableCredentialsForModel } from "./visionBridgeCredentials";
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;
}
/**
* Injectable dependencies for the router's credential-usability check.
* Defaults to the real `hasUsableCredentialsForModel` (DB-backed). Tests can
* inject a pure stub here instead of mocking the `@/lib/db/providers` module
* boundary — this project's Node native test runner (`node:test`) has no
* supported ESM module-mocking mechanism, so DI is the only way to exercise
* the credential-exclusion branch under `npm run test:unit`.
*/
export interface VisionBridgeRouterDeps {
hasUsableCredentials?: (model: string) => Promise<boolean | null>;
}
/**
* Get all vision-capable models from the registry that also have a usable
* active connection on this instance.
*
* Without this credential check, a model with no working connection (e.g. the
* hardcoded default `openai/gpt-4o-mini` on an instance with no `openai`
* provider connected) could win selection, fail the describe call, and leave
* the guardrail's describe-failure fallback to forward the raw image to a
* non-vision backend, which rejects it with an opaque upstream error.
*/
async function getVisionCapableModels(
deps: VisionBridgeRouterDeps = {}
): Promise<VisionModelCandidate[]> {
const checkCreds = deps.hasUsableCredentials ?? hasUsableCredentialsForModel;
const candidates: VisionModelCandidate[] = [];
const checks: Array<Promise<void>> = [];
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) {
checks.push(
checkCreds(fullModelId).then((usable) => {
// Only a confirmed `false` excludes a candidate — `null` (indeterminate,
// e.g. unit tests / early boot) fails open so existing behavior is preserved
// when the credential store can't be checked.
if (usable === false) return;
// Determine priority based on provider type (lower = better).
// Do NOT prefer opencode-* first: those catalog entries often resolve to a
// noauth connection and 401 "Missing API key", hijacking working providers
// (e.g. zai/glm-5.2 combo targets) when Vision Bridge auto-reroutes.
let priority = 100;
if (providerAlias === "openai" || providerAlias === "anthropic") {
priority = 50; // Major providers with real API keys
} else if (providerAlias === "vertex" || providerAlias === "gemini") {
priority = 55;
} else if (providerAlias.startsWith("opencode-")) {
priority = 95; // Free/catalog — only if nothing credentialed is available
} else {
priority = 75; // Other providers
}
candidates.push({
modelId: model.id,
fullName: fullModelId,
priority,
averageLatencyMs: calculateAverageLatency(fullModelId),
lastUsedAt: 0,
successRate: calculateSuccessRate(fullModelId),
});
})
);
}
}
}
await Promise.all(checks);
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, but validates it has usable
* credentials before short-circuiting — a fixedModel that is confirmed
* unreachable on this instance falls through to auto-selection.
* Returns `null` when no vision-capable candidate has usable credentials.
*/
export async function getBestVisionModel(
config: Partial<VisionBridgeRouterConfig> = {},
deps: VisionBridgeRouterDeps = {}
): Promise<string | null> {
const fullConfig = { ...DEFAULT_ROUTER_CONFIG, ...config };
// If fixed model is configured, validate it has usable credentials first.
// (#8430) An unreachable fixedModel (e.g. the default "openai/gpt-4o-mini"
// on an instance with no OpenAI connection/key) must not short-circuit the
// credential check — fall through to auto-selection instead.
if (fullConfig.fixedModel) {
const checkCreds = deps.hasUsableCredentials ?? hasUsableCredentialsForModel;
const usable = await checkCreds(fullConfig.fixedModel);
// Only skip credential validation when the check is indeterminate (null).
// A confirmed `false` means fall through to auto-selection.
if (usable !== false) {
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 = await getVisionCapableModels(deps);
// Select best model
const best = selectBestModel(candidates, fullConfig);
if (!best) {
// No vision-capable candidate has usable credentials on this instance
return null;
}
// Cache the selection
selectionCache.set(cacheKey, {
modelId: best.fullName,
expiresAt: Date.now() + fullConfig.selectionCacheTtlMs,
});
return best.fullName;
}
/**
* Get fallback models for retry logic.
*/
export async function getFallbackModels(
excludeModel: string,
config: Partial<VisionBridgeRouterConfig> = {},
deps: VisionBridgeRouterDeps = {}
): Promise<string[]> {
const fullConfig = { ...DEFAULT_ROUTER_CONFIG, ...config };
const candidates = await getVisionCapableModels(deps);
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);
}
/**
* 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;
}