mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 13:42:09 +03:00
329 lines
11 KiB
TypeScript
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;
|
|
}
|