mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 01:32:22 +03:00
fix(modality-bridge): harden audio catalog and response header
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -48,6 +48,12 @@ interface GuardrailMetaEntry {
|
||||
meta?: Record<string, unknown> | 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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> | null {
|
||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : 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<FetchState>({ 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 (
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user