From 49560f4ea35bf1088dfa73a8dc47eae9f51cee9e Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 8 Aug 2026 11:25:22 -0300 Subject: [PATCH] fix(modality-bridge): harden audio catalog and response header --- .../modalityBridge/ModalityBridgeAudioTab.tsx | 1 + src/app/api/models/catalog/route.ts | 1 + .../guardrails/modalityBridge/bridgeStats.ts | 10 ++++- src/shared/components/ModelSelectField.tsx | 44 +++++++++++++++++-- tests/unit/guardrails/audioBridge.test.ts | 12 +++++ tests/unit/model-alias-route.test.ts | 15 +++++++ .../ui/modality-bridge-audio-tab.test.tsx | 25 ++++++----- 7 files changed, 93 insertions(+), 15 deletions(-) diff --git a/src/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeAudioTab.tsx b/src/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeAudioTab.tsx index ae3c9fe7eb..0674e191c7 100644 --- a/src/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeAudioTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/modalityBridge/ModalityBridgeAudioTab.tsx @@ -114,6 +114,7 @@ export default function ModalityBridgeAudioTab() { placeholder={t("modalityBridgeAudioModelAuto")} allowEmpty modelFilter={isSttModel} + modelSource="catalog" onChange={(value) => void update({ modalityBridgeAudioModel: value })} className="text-sm" /> diff --git a/src/app/api/models/catalog/route.ts b/src/app/api/models/catalog/route.ts index 62909b77f1..500b2569b8 100644 --- a/src/app/api/models/catalog/route.ts +++ b/src/app/api/models/catalog/route.ts @@ -38,6 +38,7 @@ export async function GET(request: Request) { id: model.id, name: model.name || model.root || model.id, type: model.type || "chat", + ...(typeof model.subtype === "string" ? { subtype: model.subtype } : {}), custom: model.custom === true, ...(model.free === true ? { free: true } : {}), ...(model.capabilities ? { capabilities: model.capabilities } : {}), diff --git a/src/lib/guardrails/modalityBridge/bridgeStats.ts b/src/lib/guardrails/modalityBridge/bridgeStats.ts index df6c416175..b511ced37d 100644 --- a/src/lib/guardrails/modalityBridge/bridgeStats.ts +++ b/src/lib/guardrails/modalityBridge/bridgeStats.ts @@ -48,6 +48,12 @@ interface GuardrailMetaEntry { meta?: Record | null; } +function headerModelToken(value: unknown): string { + return String(value ?? "unknown") + .slice(0, 200) + .replace(/[^A-Za-z0-9._~/-]/g, "_"); +} + /** Response header value for a describe-bridged request; null when untouched. */ export function buildModalityBridgeHeader(results: GuardrailMetaEntry[]): string | null { const segments: string[] = []; @@ -59,7 +65,7 @@ export function buildModalityBridgeHeader(results: GuardrailMetaEntry[]): string !meta.rerouted ) { segments.push( - `image->text;model=${String(meta.visionModel ?? "unknown")};parts=${meta.imagesProcessed}` + `image->text;model=${headerModelToken(meta.visionModel)};parts=${meta.imagesProcessed}` ); } if ( @@ -68,7 +74,7 @@ export function buildModalityBridgeHeader(results: GuardrailMetaEntry[]): string !meta.rerouted ) { segments.push( - `audio->text;model=${String(meta.sttModel ?? "unknown")};parts=${meta.clipsProcessed}` + `audio->text;model=${headerModelToken(meta.sttModel)};parts=${meta.clipsProcessed}` ); } } diff --git a/src/shared/components/ModelSelectField.tsx b/src/shared/components/ModelSelectField.tsx index ea60553915..50986153a7 100644 --- a/src/shared/components/ModelSelectField.tsx +++ b/src/shared/components/ModelSelectField.tsx @@ -25,6 +25,8 @@ export interface ModelSelectFieldProps { allowEmpty?: boolean; /** Optional catalog predicate, e.g. restrict the picker to STT models. */ modelFilter?: (model: ApiModel) => boolean; + /** Model API to read. The unified catalog includes specialty audio/video surfaces. */ + modelSource?: "available" | "catalog"; className?: string; } @@ -33,6 +35,35 @@ interface FetchState { options: { value: string; label: string }[]; } +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" ? (value as Record) : null; +} + +function readCatalogModels(value: unknown): ApiModel[] { + const catalog = asRecord(asRecord(value)?.catalog); + if (!catalog) return []; + + const models: ApiModel[] = []; + for (const [provider, rawBucket] of Object.entries(catalog)) { + const bucket = asRecord(rawBucket); + if (!Array.isArray(bucket?.models)) continue; + for (const rawModel of bucket.models) { + const model = asRecord(rawModel); + const id = typeof model?.id === "string" ? model.id : ""; + if (!id) continue; + const providerPrefix = `${provider}/`; + models.push({ + provider, + model: id.startsWith(providerPrefix) ? id.slice(providerPrefix.length) : id, + fullModel: id.startsWith(providerPrefix) ? id : `${providerPrefix}${id}`, + type: typeof model?.type === "string" ? model.type : undefined, + subtype: typeof model?.subtype === "string" ? model.subtype : undefined, + }); + } + } + return models; +} + /** * hidePaid-aware model picker (#6540). Loads options from `GET /api/models` * (already filters by `hidePaidModels`) instead of a static catalog. Falls @@ -51,17 +82,24 @@ export default function ModelSelectField({ allowCustom = true, allowEmpty = false, modelFilter, + modelSource = "available", className, }: ModelSelectFieldProps) { const [state, setState] = useState({ status: "loading", options: [] }); useEffect(() => { let cancelled = false; - fetch("/api/models") + const endpoint = modelSource === "catalog" ? "/api/models/catalog" : "/api/models"; + fetch(endpoint) .then((res) => (res.ok ? res.json() : Promise.reject(new Error("fetch failed")))) .then((data) => { if (cancelled) return; - const models: ApiModel[] = Array.isArray(data?.models) ? data.models : []; + const models: ApiModel[] = + modelSource === "catalog" + ? readCatalogModels(data) + : Array.isArray(data?.models) + ? data.models + : []; const filteredModels = modelFilter ? models.filter(modelFilter) : models; const options = filteredModels.map((m) => { const full = m.fullModel || `${m.provider}/${m.model}`; @@ -75,7 +113,7 @@ export default function ModelSelectField({ return () => { cancelled = true; }; - }, [modelFilter]); + }, [modelFilter, modelSource]); if (state.status === "error" && allowCustom) { return ( diff --git a/tests/unit/guardrails/audioBridge.test.ts b/tests/unit/guardrails/audioBridge.test.ts index f6dc52e011..5c0a7a20ea 100644 --- a/tests/unit/guardrails/audioBridge.test.ts +++ b/tests/unit/guardrails/audioBridge.test.ts @@ -264,4 +264,16 @@ test("audio transparency header is emitted only for transformed clips", () => { ]), null ); + assert.equal( + buildModalityBridgeHeader([ + { + guardrail: "audio-bridge", + meta: { + clipsProcessed: 1, + sttModel: "deepgram/nova-3\r\nx-injected: yes", + }, + }, + ]), + "audio->text;model=deepgram/nova-3__x-injected__yes;parts=1" + ); }); diff --git a/tests/unit/model-alias-route.test.ts b/tests/unit/model-alias-route.test.ts index 4682bdd25f..5517defc44 100644 --- a/tests/unit/model-alias-route.test.ts +++ b/tests/unit/model-alias-route.test.ts @@ -11,6 +11,7 @@ process.env.JWT_SECRET = process.env.JWT_SECRET || "model-alias-route-jwt"; const core = await import("../../src/lib/db/core.ts"); const modelsDb = await import("../../src/lib/db/models.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); const localDb = await import("../../src/lib/localDb.ts"); const route = await import("../../src/app/api/models/alias/route.ts"); const catalogRoute = await import("../../src/app/api/models/catalog/route.ts"); @@ -86,6 +87,15 @@ test("model alias route requires a dashboard session when management auth is ena }); test("api models catalog route reuses the unified catalog diagnostics headers", async () => { + await providersDb.createProviderConnection({ + provider: "deepgram", + authType: "apikey", + name: "deepgram-audio-catalog", + apiKey: "dg-test", + isActive: true, + testStatus: "active", + }); + v1Catalog.__resetCatalogBuilderRunsForTest(); const response = await catalogRoute.GET( new Request("http://localhost/api/models/catalog", { headers: { "x-request-id": "req-model-catalog-1" }, @@ -98,6 +108,11 @@ test("api models catalog route reuses the unified catalog diagnostics headers", assert.match(response.headers.get("X-Model-Catalog-Version") || "", /^model-metadata-v1:/); assert.equal(typeof body.catalog, "object"); assert.equal(typeof body.catalogVersion, "string"); + const nova = body.catalog.deepgram.models.find( + (model: { id?: string }) => model.id === "deepgram/nova-3" + ); + assert.equal(nova.type, "audio"); + assert.equal(nova.subtype, "transcription"); }); test("v1 models catalog emits diagnostics headers alongside the OpenAI-compatible list", async () => { diff --git a/tests/unit/ui/modality-bridge-audio-tab.test.tsx b/tests/unit/ui/modality-bridge-audio-tab.test.tsx index 29b7edea44..3b6691024b 100644 --- a/tests/unit/ui/modality-bridge-audio-tab.test.tsx +++ b/tests/unit/ui/modality-bridge-audio-tab.test.tsx @@ -28,20 +28,24 @@ describe("ModalityBridgeAudioTab", () => { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = String(input); - if (url.includes("/api/models")) { + if (url.includes("/api/models/catalog")) { return Response.json({ - models: [ - { - provider: "deepgram", - model: "nova-3", - type: "audio", - subtype: "transcription", + catalog: { + deepgram: { + provider: "Deepgram", + models: [ + { id: "deepgram/nova-3", type: "audio", subtype: "transcription" }, + { id: "deepgram/aura", type: "audio", subtype: "speech" }, + ], }, - { provider: "deepgram", model: "aura", type: "audio", subtype: "speech" }, - { provider: "openai", model: "gpt-5.6" }, - ], + openai: { + provider: "OpenAI", + models: [{ id: "openai/gpt-5.6", type: "chat" }], + }, + }, }); } + if (url.includes("/api/models")) return Response.json({ models: [] }); if (url.includes("/api/modality-bridge/stats")) { return Response.json({ vision: { bridged: 0, cacheHits: 0, failures: 0, lastUsedAt: null }, @@ -99,6 +103,7 @@ describe("ModalityBridgeAudioTab", () => { it("shows only transcription models and exposes selectable Auto", async () => { const el = await render(); + expect(fetchMock).toHaveBeenCalledWith("/api/models/catalog"); await waitFor( () => Array.from(el.querySelectorAll("option")).some(