From d6a415987134e6cae795ec3c34c84e445d200d17 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sat, 15 Aug 2026 04:27:44 -0300 Subject: [PATCH] fix(modality-bridge): preserve capability and telemetry truth --- .../modalityBridge/ModalityBridgeStatsRow.tsx | 28 ++++++-- src/app/api/models/route.ts | 50 ++++++++------ src/lib/db/models.ts | 68 +++++++++++++++++++ .../guardrails/modalityBridge/bridgeCache.ts | 20 +++++- .../guardrails/modalityBridge/bridgeStats.ts | 5 +- src/lib/guardrails/videoBridge.ts | 34 +++++++--- src/lib/guardrails/visionBridgeHelpers.ts | 7 ++ src/lib/modelCapabilities.ts | 34 ++-------- src/lib/modelCapabilityResolutionSnapshot.ts | 7 +- tests/unit/api-models-hide-paid-6328.test.ts | 36 +++++++++- tests/unit/guardrails/videoBridge.test.ts | 55 +++++++++++++++ .../ui/modality-bridge-audio-tab.test.tsx | 4 ++ .../ui/modality-bridge-vision-tab.test.tsx | 4 ++ tests/unit/video-bridge-header-stats.test.ts | 14 +++- 14 files changed, 295 insertions(+), 71 deletions(-) diff --git a/src/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeStatsRow.tsx b/src/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeStatsRow.tsx index 15a939edd6..dfe792ed07 100644 --- a/src/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeStatsRow.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeStatsRow.tsx @@ -12,6 +12,7 @@ interface BridgeStats { cacheHits: number; failures: number; lastUsedAt: string | null; + latencySamples: number; successes: number; totalLatencyMs: number; } @@ -36,6 +37,19 @@ function parseStats(value: unknown): BridgeStats | null { typeof record.attempts === "number" ? record.attempts : record.bridged + record.failures; const averageLatencyMs = typeof record.averageLatencyMs === "number" ? record.averageLatencyMs : 0; + const totalLatencyMs = + typeof record.totalLatencyMs === "number" ? record.totalLatencyMs : averageLatencyMs * attempts; + // Compatibility with pre-latencySamples servers: positive latency data was + // sampled, while the legacy all-zero shape means timing was never recorded. + const latencySamples = + typeof record.latencySamples === "number" + ? Math.max(0, Math.floor(record.latencySamples)) + : totalLatencyMs > 0 + ? Math.max( + 1, + averageLatencyMs > 0 ? Math.round(totalLatencyMs / averageLatencyMs) : attempts + ) + : 0; return { attempts, averageLatencyMs, @@ -43,11 +57,9 @@ function parseStats(value: unknown): BridgeStats | null { cacheHits: record.cacheHits, failures: record.failures, lastUsedAt: typeof lastUsedAt === "string" ? lastUsedAt : null, + latencySamples, successes: typeof record.successes === "number" ? record.successes : record.bridged, - totalLatencyMs: - typeof record.totalLatencyMs === "number" - ? record.totalLatencyMs - : averageLatencyMs * attempts, + totalLatencyMs, }; } @@ -82,7 +94,7 @@ export default function ModalityBridgeStatsRow({ kind }: ModalityBridgeStatsRowP return (
- {stats.attempts} {tProviderStats("requests").toLowerCase()} + {stats.attempts} {tRoot("requestLogger.attempts").toLowerCase()} {stats.successes} {t("modalityBridgeStatsBridged")} @@ -94,10 +106,12 @@ export default function ModalityBridgeStatsRow({ kind }: ModalityBridgeStatsRowP {stats.failures} {t("modalityBridgeStatsFailures")} - {tRoot("trafficInspector.timingTotalLatency")}: {Math.round(stats.totalLatencyMs)} ms + {tRoot("trafficInspector.timingTotalLatency")}:{" "} + {stats.latencySamples > 0 ? `${Math.round(stats.totalLatencyMs)} ms` : "—"} - {tProviderStats("avgLatency")}: {Math.round(stats.averageLatencyMs)} ms + {tProviderStats("avgLatency")}:{" "} + {stats.latencySamples > 0 ? `${Math.round(stats.averageLatencyMs)} ms` : "—"} {t("modalityBridgeStatsLastUsed")}: {lastUsed} diff --git a/src/app/api/models/route.ts b/src/app/api/models/route.ts index 268d1fa6e8..88d6cf00f4 100644 --- a/src/app/api/models/route.ts +++ b/src/app/api/models/route.ts @@ -5,7 +5,10 @@ import { updateModelAliasSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; import { hasEligibleConnectionForModel } from "@/domain/connectionModelRules"; import { getSettings } from "@/lib/db/settings"; -import { getResolvedModelCapabilities } from "@/lib/modelCapabilities"; +import { + createModelCapabilityResolutionSnapshot, + getResolvedModelCapabilities, +} from "@/lib/modelCapabilities"; import { isFreeModel, providerHasFreeModels } from "@/shared/utils/freeModels"; // GET /api/models - Get models with aliases (only from active providers by default) @@ -77,18 +80,6 @@ export async function GET(request: Request) { } } - const models = AI_MODELS.map((m: any) => { - const fullModel = `${m.provider}/${m.model}`; - const available = !activeProviders || activeProviders.has(m.provider); - return { - ...m, - fullModel, - alias: modelAliases[fullModel] || m.model, - available, - supportsVision: getResolvedModelCapabilities(fullModel).supportsVision === true, - }; - }).filter((m: any) => showAll || m.available); - // #6328 (follow-up to #6495): REMOVE — not just hide — paid models from the // dashboard model picker when the operator opts into hidePaidModels. Mirrors // the `shouldHidePaid` guard in `src/app/api/v1/models/catalog.ts` (public @@ -98,14 +89,33 @@ export async function GET(request: Request) { const settings = await getSettings(); hidePaid = settings?.hidePaidModels === true; } catch {} - const filtered = hidePaid - ? models.filter( - (m: { provider: string; model: string }) => - providerHasFreeModels(m.provider) && isFreeModel(m.provider, { id: m.model }) - ) - : models; - return NextResponse.json({ models: filtered }); + // Filter before capability resolution so unavailable/paid rows cannot trigger + // needless capability work. One request-local snapshot supplies all persisted + // capability and custom-vision rows to the remaining resolutions. + const candidates = AI_MODELS.filter((model: any) => { + if (!showAll && activeProviders && !activeProviders.has(model.provider)) return false; + return ( + !hidePaid || + (providerHasFreeModels(model.provider) && isFreeModel(model.provider, { id: model.model })) + ); + }); + const capabilitySnapshot = createModelCapabilityResolutionSnapshot(); + const models = candidates.map((m: any) => { + const fullModel = `${m.provider}/${m.model}`; + const available = !activeProviders || activeProviders.has(m.provider); + return { + ...m, + fullModel, + alias: modelAliases[fullModel] || m.model, + available, + supportsVision: + getResolvedModelCapabilities(fullModel, undefined, capabilitySnapshot).supportsVision === + true, + }; + }); + + return NextResponse.json({ models }); } catch (error) { console.log("Error fetching models:", error); return NextResponse.json({ error: "Failed to fetch models" }, { status: 500 }); diff --git a/src/lib/db/models.ts b/src/lib/db/models.ts index 97c6f81653..41af595e7b 100644 --- a/src/lib/db/models.ts +++ b/src/lib/db/models.ts @@ -90,6 +90,74 @@ export async function getAllCustomModels() { return result; } +/** Nested provider → model map of explicit custom-model vision overrides. */ +export type CustomModelVisionOverrideMap = ReadonlyMap>; + +function readVisionOverrideFromModels(value: string | null, modelId: string): boolean | null { + if (!value) return null; + try { + const models = JSON.parse(value) as unknown; + if (!Array.isArray(models)) return null; + const entry = models.find( + (candidate): candidate is { id: string; supportsVision?: boolean } => + candidate !== null && + typeof candidate === "object" && + !Array.isArray(candidate) && + (candidate as { id?: unknown }).id === modelId + ); + return entry && typeof entry.supportsVision === "boolean" ? entry.supportsVision : null; + } catch { + return null; + } +} + +/** + * Resolve one explicit custom-model vision override. A supplied bulk map avoids + * SQLite reads for request/build-local capability resolution. + */ +export function getCustomModelVisionOverride( + providerId: string, + modelId: string, + bulk?: CustomModelVisionOverrideMap | null +): boolean | null { + if (bulk) return bulk.get(providerId)?.get(modelId) ?? null; + const row = getDbInstance() + .prepare("SELECT value FROM key_value WHERE namespace = 'customModels' AND key = ?") + .get(providerId); + return readVisionOverrideFromModels(getKeyValue(row).value, modelId); +} + +/** Bulk-load explicit custom-model vision overrides with one SQLite query. */ +export function listCustomModelVisionOverrides(): CustomModelVisionOverrideMap { + const rows = getDbInstance() + .prepare("SELECT key, value FROM key_value WHERE namespace = 'customModels'") + .all(); + const result = new Map>(); + for (const row of rows) { + const { key, value } = getKeyValue(row); + if (!key || !value) continue; + try { + const models = JSON.parse(value) as unknown; + if (!Array.isArray(models)) continue; + const byModel = new Map(); + for (const candidate of models) { + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) continue; + const { id, supportsVision } = candidate as { + id?: unknown; + supportsVision?: unknown; + }; + if (typeof id === "string" && typeof supportsVision === "boolean") { + byModel.set(id, supportsVision); + } + } + if (byModel.size > 0) result.set(key, byModel); + } catch { + // Malformed custom-model rows do not participate in capability resolution. + } + } + return result; +} + export async function addCustomModel( providerId: string, modelId: string, diff --git a/src/lib/guardrails/modalityBridge/bridgeCache.ts b/src/lib/guardrails/modalityBridge/bridgeCache.ts index f4efb797fa..2158638a0f 100644 --- a/src/lib/guardrails/modalityBridge/bridgeCache.ts +++ b/src/lib/guardrails/modalityBridge/bridgeCache.ts @@ -27,12 +27,22 @@ export interface BridgeCacheOptions { now?: () => number; } +export interface BridgeCacheEntry { + value: string; + /** Actual successful producer, which may differ from the routing-plan model after fallback. */ + producerModel?: string; +} + export class BridgeCache { - private readonly entries = new Map(); + private readonly entries = new Map(); constructor(private readonly opts: BridgeCacheOptions) {} get(key: string): string | undefined { + return this.getEntry(key)?.value; + } + + getEntry(key: string): BridgeCacheEntry | undefined { const hit = this.entries.get(key); if (!hit) return undefined; const now = (this.opts.now ?? Date.now)(); @@ -43,13 +53,17 @@ export class BridgeCache { // Map preserves insertion order — re-insert to mark as most-recently-used. this.entries.delete(key); this.entries.set(key, hit); - return hit.value; + return hit.entry; } set(key: string, value: string): void { + this.setEntry(key, { value }); + } + + setEntry(key: string, entry: BridgeCacheEntry): void { const now = (this.opts.now ?? Date.now)(); this.entries.delete(key); - this.entries.set(key, { value, expiresAt: now + this.opts.ttlMs }); + this.entries.set(key, { entry, expiresAt: now + this.opts.ttlMs }); while (this.entries.size > this.opts.maxEntries) { const oldest = this.entries.keys().next().value; if (oldest === undefined) break; diff --git a/src/lib/guardrails/modalityBridge/bridgeStats.ts b/src/lib/guardrails/modalityBridge/bridgeStats.ts index 6f07f1e247..c447043860 100644 --- a/src/lib/guardrails/modalityBridge/bridgeStats.ts +++ b/src/lib/guardrails/modalityBridge/bridgeStats.ts @@ -18,6 +18,7 @@ export interface BridgeModalityStats { cacheHits: number; failures: number; lastUsedAt: string | null; + latencySamples: number; successes: number; totalLatencyMs: number; } @@ -38,6 +39,7 @@ function emptyStats(): BridgeModalityStats { cacheHits: 0, failures: 0, lastUsedAt: null, + latencySamples: 0, successes: 0, totalLatencyMs: 0, }; @@ -64,8 +66,9 @@ export function recordBridgeUse( s.cacheHits += cacheHits; if (typeof opts.latencyMs === "number" && Number.isFinite(opts.latencyMs)) { s.totalLatencyMs += Math.max(0, opts.latencyMs); + s.latencySamples += 1; } - s.averageLatencyMs = s.attempts > 0 ? s.totalLatencyMs / s.attempts : 0; + s.averageLatencyMs = s.latencySamples > 0 ? s.totalLatencyMs / s.latencySamples : 0; s.lastUsedAt = new Date().toISOString(); } diff --git a/src/lib/guardrails/videoBridge.ts b/src/lib/guardrails/videoBridge.ts index 6309df0f3a..ed9ecc6747 100644 --- a/src/lib/guardrails/videoBridge.ts +++ b/src/lib/guardrails/videoBridge.ts @@ -30,6 +30,12 @@ type VideoBridgeBody = { [key: string]: unknown; }; +function combineModelIdentities(models: ReadonlySet, fallback: string): string { + if (models.size === 0) return fallback; + if (models.size === 1) return models.values().next().value ?? fallback; + return "mixed"; +} + export interface VideoBridgeDependencies { getSettings?: () => Promise>; getCapabilities?: (model: string) => { supportsVideo: boolean | null }; @@ -83,7 +89,8 @@ export class VideoBridgeGuardrail extends BaseGuardrail { const visionRuntime = resolveVisionBridgeRuntimeSettings(persisted); const configuredModel = runtime.model.trim() || visionRuntime.model.trim(); - let effectiveVideoModel = configuredModel || "auto"; + const routingPlanModel = configuredModel || "auto"; + const successfulModels = new Set(); let selectedModelPromise: Promise | null = null; const selectVideoModel = (): Promise => { if (!selectedModelPromise) { @@ -119,7 +126,7 @@ export class VideoBridgeGuardrail extends BaseGuardrail { context.signal ); if (context.signal?.aborted) throw new Error("Video Bridge processing was aborted"); - if (described.modelUsed) effectiveVideoModel = described.modelUsed; + if (described.modelUsed) successfulModels.add(described.modelUsed); const videoCacheHits = described.cacheHits ?? 0; descriptions.push(described.description); totalFramesRequested += described.framesRequested; @@ -181,7 +188,7 @@ export class VideoBridgeGuardrail extends BaseGuardrail { framesUsed: totalFramesUsed, processingTimeMs: Date.now() - startedAt, attempts: attemptedParts.length, - videoModel: effectiveVideoModel, + videoModel: combineModelIdentities(successfulModels, routingPlanModel), videosProcessed, videosReplaced, }, @@ -201,6 +208,7 @@ export class VideoBridgeGuardrail extends BaseGuardrail { const cache = runtime.cacheEnabled ? getSharedBridgeCacheFor(runtime) : null; const callVisionModel = this.deps.callVisionModel ?? defaultCallVisionModel; let cacheHits = 0; + const successfulModels = new Set(); const described = await defaultDescribeVideoPart( part, { @@ -213,23 +221,33 @@ export class VideoBridgeGuardrail extends BaseGuardrail { const key = cache ? bridgeCacheKey(frameDataUri, `${prompt}@${timestampSeconds.toFixed(3)}`, selectedModel) : null; - const cached = key && cache ? cache.get(key) : undefined; - if (cached !== undefined) { + const cached = key && cache ? cache.getEntry(key) : undefined; + if (cached) { cacheHits += 1; - return cached; + successfulModels.add(cached.producerModel ?? selectedModel); + return cached.value; } + let producerModel = selectedModel; const caption = await callVisionModel(frameDataUri, { maxImages: 1, model: selectedModel, + onModelUsed: (model) => { + producerModel = model; + }, prompt, signal, timeoutMs: runtime.timeoutMs, }); - if (key && cache) cache.set(key, caption); + successfulModels.add(producerModel); + if (key && cache) cache.setEntry(key, { value: caption, producerModel }); return caption; }, { extractFrames: this.deps.extractFrames } ); - return { ...described, cacheHits, modelUsed: selectedModel }; + return { + ...described, + cacheHits, + modelUsed: combineModelIdentities(successfulModels, selectedModel), + }; } } diff --git a/src/lib/guardrails/visionBridgeHelpers.ts b/src/lib/guardrails/visionBridgeHelpers.ts index fbc8e06847..4d2f6e1804 100644 --- a/src/lib/guardrails/visionBridgeHelpers.ts +++ b/src/lib/guardrails/visionBridgeHelpers.ts @@ -350,6 +350,8 @@ export interface VisionModelConfig { signal?: AbortSignal; /** Injectable fetch (tests). Defaults to undici fetch to bypass the runtime's hooked global fetch. */ fetchImpl?: typeof fetch; + /** Receives the actual successful model while the public return value remains a string. */ + onModelUsed?: (model: string) => void; } /** Task-aware focus hint (codex-vision-proxy pattern): steer the description @@ -416,6 +418,11 @@ export async function callVisionModel( apiKey ); recordLatency(currentModel, Date.now() - attemptStart, true); + try { + config.onModelUsed?.(currentModel); + } catch { + // Observability callbacks must never turn a successful caption into a retry. + } return result; } catch (error) { recordLatency(currentModel, Date.now() - attemptStart, false); diff --git a/src/lib/modelCapabilities.ts b/src/lib/modelCapabilities.ts index 38acf56476..d1f87d7ab5 100644 --- a/src/lib/modelCapabilities.ts +++ b/src/lib/modelCapabilities.ts @@ -14,8 +14,7 @@ import { getSyncedCapability } from "@/lib/modelsDevSync"; import { MODELS_DEV_PROVIDER_MAP } from "@/lib/modelsDevSync/transform"; import { getModelContextOverride } from "@/lib/db/modelContextOverrides"; import { getModelCapabilityOverride } from "@/lib/db/modelCapabilityOverrides"; -import { getDbInstance } from "@/lib/db/core"; -import { getKeyValue } from "@/lib/db/models/shared"; +import { getCustomModelVisionOverride } from "@/lib/db/models"; import type { ModelCapabilityResolutionSnapshot } from "@/lib/modelCapabilityResolutionSnapshot"; import { resolveAudioCapability, resolveVideoCapability } from "@/lib/modelCapabilityModalities"; @@ -462,31 +461,6 @@ function modalitiesDeclareVision(modalities: readonly string[]): boolean { }); } -/** - * #9195: Read the customModels supportsVision override for a given provider/model - * pair from the database. Returns true/false when an explicit override exists, or - * null if no custom model entry or no explicit flag. Sync read (better-sqlite3). - */ -function getCustomModelVisionOverride(provider: string, model: string): boolean | null { - try { - const db = getDbInstance(); - const row = db - .prepare("SELECT value FROM key_value WHERE namespace = 'customModels' AND key = ?") - .get(provider); - if (!row) return null; - const parsed = getKeyValue(row); - if (!parsed.value) return null; - const models: Array<{ id: string; supportsVision?: boolean }> = JSON.parse(parsed.value); - const entry = models.find((m) => m.id === model); - if (entry && typeof entry.supportsVision === "boolean") { - return entry.supportsVision; - } - return null; - } catch { - return null; - } -} - function resolveVisionCapability( spec: ModelSpec | undefined, registryModel: { supportsVision?: boolean } | null, @@ -782,7 +756,11 @@ export function getResolvedModelCapabilities( // dashboard "Vision capable" toggle affects Combo routing. const customVisionOverride = resolved.provider && resolved.model - ? getCustomModelVisionOverride(resolved.provider, resolved.model) + ? getCustomModelVisionOverride( + resolved.provider, + resolved.model, + snapshot?.customVisionOverrides + ) : null; const supportsVision = resolveVisionCapability( diff --git a/src/lib/modelCapabilityResolutionSnapshot.ts b/src/lib/modelCapabilityResolutionSnapshot.ts index 1ef49e680f..b5724a62bb 100644 --- a/src/lib/modelCapabilityResolutionSnapshot.ts +++ b/src/lib/modelCapabilityResolutionSnapshot.ts @@ -1,7 +1,7 @@ /** * Build-local capability/context/override resolution snapshot (#9199). * - * Catalog preparation bulk-loads the three capability tables once into a + * Catalog preparation bulk-loads the capability and custom-model tables once into a * build-local view for pure in-memory resolution. This must not flip models.dev's * module-global all-row cache, and ordinary runtime callers keep on-demand DB reads. * @@ -10,6 +10,7 @@ */ import { listModelCapabilityOverrides } from "@/lib/db/modelCapabilityOverrides"; import { listModelContextOverrides } from "@/lib/db/modelContextOverrides"; +import { listCustomModelVisionOverrides, type CustomModelVisionOverrideMap } from "@/lib/db/models"; import { loadAllSyncedCapabilitiesUncached, type CapabilitiesByProvider, @@ -23,6 +24,7 @@ export interface ModelCapabilityResolutionSnapshot { readonly maxTokenOverrides: NestedOverrideMap; readonly maxInputTokenOverrides: NestedOverrideMap; readonly contextOverrides: NestedOverrideMap; + readonly customVisionOverrides: CustomModelVisionOverrideMap; } function setNestedOverride( @@ -40,7 +42,7 @@ function setNestedOverride( } /** - * Load all three capability tables in one uninterrupted JS turn. + * Load all capability/custom-model tables in one uninterrupted JS turn. * Callers must not yield between the bulk reads if they need a coherent view; * existing catalog generation guards remain authoritative across later yields. */ @@ -67,5 +69,6 @@ export function createModelCapabilityResolutionSnapshot(): ModelCapabilityResolu maxTokenOverrides, maxInputTokenOverrides, contextOverrides, + customVisionOverrides: listCustomModelVisionOverrides(), }; } diff --git a/tests/unit/api-models-hide-paid-6328.test.ts b/tests/unit/api-models-hide-paid-6328.test.ts index 81cf702458..e9b3becdd1 100644 --- a/tests/unit/api-models-hide-paid-6328.test.ts +++ b/tests/unit/api-models-hide-paid-6328.test.ts @@ -38,15 +38,49 @@ test.after(() => { test("/api/models retains genuine resolved vision capability for the Video Bridge picker", async () => { await settingsDb.updateSettings({ hidePaidModels: false }); - const models = await fetchModels(); + const db = core.getDbInstance(); + db.prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) " + "VALUES ('customModels', ?, ?)" + ).run("openai", JSON.stringify([{ id: "gpt-4o", supportsVision: false }])); + const originalPrepare = db.prepare; + const callPrepare = originalPrepare.bind(db); + let customModelReads = 0; + (db as unknown as { prepare: typeof db.prepare }).prepare = ((sql: string) => { + const normalized = String(sql).replace(/\s+/g, " ").trim(); + if (normalized.includes("FROM key_value WHERE namespace = 'customModels'")) { + customModelReads++; + } + return callPrepare(sql); + }) as typeof db.prepare; + + let models: Awaited>; + try { + models = await fetchModels(); + } finally { + (db as unknown as { prepare: typeof db.prepare }).prepare = originalPrepare; + } const vision = models.find( (model) => model.provider === "openai" && model.model === "gpt-4o-mini" ); const textOnly = models.find((model) => model.provider === "deepgram"); + const explicitlyDowngraded = models.find( + (model) => model.provider === "openai" && model.model === "gpt-4o" + ); assert.ok(vision, "known static vision model must be present in the real producer response"); assert.equal(vision.supportsVision, true); if (textOnly) assert.notEqual(textOnly.supportsVision, true); + assert.ok(explicitlyDowngraded, "custom-overridden model must remain in the real producer"); + assert.equal( + explicitlyDowngraded.supportsVision, + false, + "request snapshot must preserve the explicit custom supportsVision override" + ); + assert.equal( + customModelReads, + 1, + "one request-level capability snapshot must bulk-read custom vision overrides once" + ); }); test("#6328 /api/models removes paid models when hidePaidModels is on", async () => { diff --git a/tests/unit/guardrails/videoBridge.test.ts b/tests/unit/guardrails/videoBridge.test.ts index e3329ddab0..4ba5d336f4 100644 --- a/tests/unit/guardrails/videoBridge.test.ts +++ b/tests/unit/guardrails/videoBridge.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { VideoBridgeGuardrail } from "../../../src/lib/guardrails/videoBridge.ts"; +import { callVisionModel } from "../../../src/lib/guardrails/visionBridgeHelpers.ts"; import { buildModalityBridgeHeader, getBridgeStats, @@ -339,6 +340,60 @@ test("real Video Bridge cache hit avoids a second model call and records the hit assert.equal(second.meta?.cacheHits, 1); }); +test("real primary failure reports and caches the successful fallback model identity", async () => { + const primary = "openai/gpt-4o-mini"; + const fallback = "anthropic/claude-fable-5"; + const attemptedModels: string[] = []; + const fetchImpl: typeof fetch = async (_input, init) => { + const body = JSON.parse(String(init?.body)) as { model: string }; + attemptedModels.push(body.model); + if (body.model === "gpt-4o-mini") { + return new Response("primary unavailable", { status: 503 }); + } + return Response.json({ content: [{ type: "text", text: "fallback observation" }] }); + }; + const bridge = new VideoBridgeGuardrail({ + deps: { + getSettings: async () => ({ + modalityBridgeVideoEnabled: true, + modalityBridgeVideoModel: primary, + modalityBridgeVisionPrompt: "fallback identity integration 9760", + modalityBridgeCacheEnabled: true, + modalityBridgeCacheTtlMinutes: 62, + modalityBridgeCacheMaxEntries: 52, + }), + getCapabilities: () => ({ supportsVideo: false }), + selectVisionModel: async () => primary, + extractFrames: async () => ({ + durationSeconds: 1, + frames: [{ timestampSeconds: 0.5, dataUri: "data:image/jpeg;base64,FALLBACK9760" }], + }), + callVisionModel: (image, config) => + callVisionModel( + image, + { ...config, fetchImpl }, + "sk-fallback-test", + { maxFallbackAttempts: 2 }, + { + hasUsableCredentials: async (model) => model === primary || model === fallback, + } + ), + }, + }); + + const first = await bridge.preCall(payload(), {}); + const second = await bridge.preCall(payload(), {}); + + assert.deepEqual(attemptedModels, ["gpt-4o-mini", "claude-fable-5"]); + assert.equal(first.meta?.videoModel, fallback, "meta must name the successful fallback"); + assert.equal(second.meta?.videoModel, fallback, "cache hit must retain the producer identity"); + assert.equal(second.meta?.cacheHits, 1); + assert.equal( + buildModalityBridgeHeader([{ guardrail: "video-bridge", meta: second.meta }]), + `video->text;model=${fallback};parts=1` + ); +}); + test("cache keys miss on timestamp, prompt, and effective model changes; failures are not cached", async () => { let timestamp = 0.25; let prompt = "prompt-a-9760"; diff --git a/tests/unit/ui/modality-bridge-audio-tab.test.tsx b/tests/unit/ui/modality-bridge-audio-tab.test.tsx index 3b6691024b..0cf884f724 100644 --- a/tests/unit/ui/modality-bridge-audio-tab.test.tsx +++ b/tests/unit/ui/modality-bridge-audio-tab.test.tsx @@ -117,6 +117,10 @@ describe("ModalityBridgeAudioTab", () => { expect(optionValues).not.toContain("deepgram/aura"); expect(optionValues).not.toContain("openai/gpt-5.6"); expect(el.textContent).toContain("4 modalityBridgeStatsBridged"); + expect(el.textContent).toContain("5 requestlogger.attempts"); + expect(el.textContent).toContain("trafficInspector.timingTotalLatency: —"); + expect(el.textContent).toContain("avgLatency: —"); + expect(el.textContent).not.toContain("0 ms"); }); it("PATCHes only the new audio setting when toggled", async () => { diff --git a/tests/unit/ui/modality-bridge-vision-tab.test.tsx b/tests/unit/ui/modality-bridge-vision-tab.test.tsx index 3c4c4e920d..3cccb0b14a 100644 --- a/tests/unit/ui/modality-bridge-vision-tab.test.tsx +++ b/tests/unit/ui/modality-bridge-vision-tab.test.tsx @@ -154,6 +154,10 @@ describe("ModalityBridgeVisionTab", () => { await waitFor(() => el.textContent?.includes("3 modalityBridgeStatsBridged") ?? false, "stats"); expect(el.textContent).toContain("1 modalityBridgeStatsCacheHits"); + expect(el.textContent).toContain("3 requestlogger.attempts"); + expect(el.textContent).toContain("trafficInspector.timingTotalLatency: —"); + expect(el.textContent).toContain("avgLatency: —"); + expect(el.textContent).not.toContain("0 ms"); }); it("clamps advanced numeric settings to the schema bounds before PATCHing", async () => { diff --git a/tests/unit/video-bridge-header-stats.test.ts b/tests/unit/video-bridge-header-stats.test.ts index be8507b96a..67c5b902e8 100644 --- a/tests/unit/video-bridge-header-stats.test.ts +++ b/tests/unit/video-bridge-header-stats.test.ts @@ -41,10 +41,22 @@ test("tracks attempts, successes, failures, cache hits, and latency without coun assert.equal(after.cacheHits - before.cacheHits, 2); assert.equal(after.failures - before.failures, 1); assert.equal(after.totalLatencyMs - before.totalLatencyMs, 200); - assert.equal(after.averageLatencyMs, after.totalLatencyMs / after.attempts); + assert.equal(after.latencySamples - before.latencySamples, 2); + assert.equal(after.averageLatencyMs, after.totalLatencyMs / after.latencySamples); assert.match(after.lastUsedAt ?? "", /^\d{4}-\d{2}-\d{2}T/); }); +test("Vision and Audio attempts without timing do not fabricate zero-millisecond samples", () => { + for (const kind of ["vision", "audio"] as const) { + const before = getBridgeStats()[kind]; + recordBridgeUse(kind, { cacheHit: true }); + const after = getBridgeStats()[kind]; + assert.equal(after.attempts - before.attempts, 1); + assert.equal(after.latencySamples, before.latencySamples); + assert.equal(after.totalLatencyMs, before.totalLatencyMs); + } +}); + test("video header is omitted when every attempted video failed", () => { assert.equal( buildModalityBridgeHeader([