From cfc2bc707b43387af63748bd3ddfbfc7657a16a7 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 18 Jun 2026 20:19:00 -0300 Subject: [PATCH] feat(dashboard): derive media serviceKinds from registries (surface MiniMax + media catalog) (#4212) Integrated into release/v3.8.29 (round 7). Media serviceKinds derived from backend registries (single source of truth) unioned with declared kinds; listing + detail pages use the same resolver (no 'listed but 404'). Surfaces MiniMax (tts/video/music) + ~48 previously-invisible media providers. Validated locally: 6/6 tests + typecheck:core clean. --- open-sse/config/mediaServiceKinds.ts | 71 ++++++++++++++ .../media-providers/[kind]/[id]/page.tsx | 12 ++- .../dashboard/media-providers/[kind]/page.tsx | 11 ++- tests/unit/minimax-media-servicekinds.test.ts | 93 +++++++++++++++++++ 4 files changed, 181 insertions(+), 6 deletions(-) create mode 100644 open-sse/config/mediaServiceKinds.ts create mode 100644 tests/unit/minimax-media-servicekinds.test.ts diff --git a/open-sse/config/mediaServiceKinds.ts b/open-sse/config/mediaServiceKinds.ts new file mode 100644 index 0000000000..95e831ce40 --- /dev/null +++ b/open-sse/config/mediaServiceKinds.ts @@ -0,0 +1,71 @@ +/** + * Media serviceKinds — derived from the backend registries. + * + * The dashboard's `/dashboard/media-providers/[kind]` pages list a provider + * under a media kind when the provider supports it. Historically this relied on + * a hand-maintained `serviceKinds` array on every provider in providers.ts, + * which had drifted badly: ~48 providers were wired into the audio/video/music/ + * image/embedding registries (backend works) but declared no `serviceKinds`, so + * they never appeared in the UI. + * + * This module makes the registries the single source of truth: a provider that + * appears as a key in a media registry supports that kind, full stop. The UI + * derives membership from here instead of duplicating it by hand, so adding a + * provider to a registry automatically surfaces it — no second edit, no drift. + * + * Kinds without a backing registry (imageToText, webSearch, webFetch, llm) are + * still declared explicitly via `serviceKinds` on the provider entry; callers + * union the two sources. + */ +import { + AUDIO_TRANSCRIPTION_PROVIDERS, + AUDIO_SPEECH_PROVIDERS, +} from "./audioRegistry.ts"; +import { VIDEO_PROVIDERS } from "./videoRegistry.ts"; +import { MUSIC_PROVIDERS } from "./musicRegistry.ts"; +import { IMAGE_PROVIDERS } from "./imageRegistry.ts"; +import { EMBEDDING_PROVIDERS } from "./embeddingRegistry.ts"; + +/** Media kinds whose provider membership is defined by a backend registry. */ +export const MEDIA_KIND_REGISTRIES = { + stt: AUDIO_TRANSCRIPTION_PROVIDERS, + tts: AUDIO_SPEECH_PROVIDERS, + video: VIDEO_PROVIDERS, + music: MUSIC_PROVIDERS, + image: IMAGE_PROVIDERS, + embedding: EMBEDDING_PROVIDERS, +} as const satisfies Record>; + +export type RegistryMediaKind = keyof typeof MEDIA_KIND_REGISTRIES; + +export const REGISTRY_MEDIA_KINDS: readonly RegistryMediaKind[] = Object.freeze( + Object.keys(MEDIA_KIND_REGISTRIES) as RegistryMediaKind[] +); + +/** + * Media serviceKinds a provider supports, derived from the backend registries. + * Returns the kinds (e.g. `["tts","video","music"]`) for which `providerId` + * appears as a registry key. Empty array if the provider serves no media. + */ +export function getRegistryMediaKinds(providerId: string): RegistryMediaKind[] { + const kinds: RegistryMediaKind[] = []; + for (const kind of REGISTRY_MEDIA_KINDS) { + if (Object.prototype.hasOwnProperty.call(MEDIA_KIND_REGISTRIES[kind], providerId)) { + kinds.push(kind); + } + } + return kinds; +} + +/** + * Full set of serviceKinds for a provider: the explicitly declared ones (llm, + * web*, imageToText) unioned with the media kinds derived from the registries. + */ +export function resolveProviderServiceKinds( + providerId: string, + declared: readonly string[] | undefined +): string[] { + const set = new Set(declared ?? []); + for (const kind of getRegistryMediaKinds(providerId)) set.add(kind); + return [...set]; +} diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.tsx b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.tsx index 3d254a2f4a..ed1a53af9f 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/[id]/page.tsx @@ -1,6 +1,7 @@ import { notFound } from "next/navigation"; import { getTranslations } from "next-intl/server"; import { AI_PROVIDERS } from "@/shared/constants/providers"; +import { resolveProviderServiceKinds } from "@omniroute/open-sse/config/mediaServiceKinds.ts"; import type { MediaKind } from "../../components/mediaKinds"; import { MEDIA_KINDS } from "../../components/mediaKinds"; import MediaProviderPageClient from "./MediaProviderPageClient"; @@ -42,9 +43,14 @@ export default async function MediaProviderDetailPage({ params }: PageProps) { notFound(); } - // Validate that the provider declares this kind - const serviceKinds = provider.serviceKinds as string[] | undefined; - if (!Array.isArray(serviceKinds) || !serviceKinds.includes(validKind)) { + // Validate that the provider supports this kind — declared serviceKinds unioned + // with the media kinds derived from the backend registries (must mirror the + // listing filter in ../page.tsx, otherwise a listed provider would 404 on click). + const serviceKinds = resolveProviderServiceKinds( + provider.id, + provider.serviceKinds as string[] | undefined + ); + if (!serviceKinds.includes(validKind)) { notFound(); } diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/page.tsx b/src/app/(dashboard)/dashboard/media-providers/[kind]/page.tsx index 447d54a5f6..96a6fed124 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/page.tsx +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/page.tsx @@ -2,6 +2,7 @@ import { notFound } from "next/navigation"; import { getTranslations } from "next-intl/server"; import Link from "next/link"; import { AI_PROVIDERS } from "@/shared/constants/providers"; +import { resolveProviderServiceKinds } from "@omniroute/open-sse/config/mediaServiceKinds.ts"; import type { MediaKind } from "../components/mediaKinds"; import { MEDIA_KINDS } from "../components/mediaKinds"; import MediaProviderKindNav from "../components/MediaProviderKindNav"; @@ -28,10 +29,14 @@ export default async function MediaProviderKindPage({ params }: PageProps) { const validKind = kind as MediaKind; const t = await getTranslations("media"); - // Filter providers that support this kind + // Filter providers that support this kind. Membership is the union of the + // explicitly declared serviceKinds and the media kinds derived from the + // backend registries (audio/video/music/image/embedding) — see + // resolveProviderServiceKinds. This keeps the UI in lockstep with the backend + // without hand-maintaining serviceKinds on every media provider. const matchingProviders = Object.values(AI_PROVIDERS).filter((p) => { - const serviceKinds = (p as Record).serviceKinds as string[] | undefined; - return Array.isArray(serviceKinds) && serviceKinds.includes(validKind); + const entry = p as Record & { id: string; serviceKinds?: string[] }; + return resolveProviderServiceKinds(entry.id, entry.serviceKinds).includes(validKind); }); const kindLabel = t(`kinds.${validKind}`); diff --git a/tests/unit/minimax-media-servicekinds.test.ts b/tests/unit/minimax-media-servicekinds.test.ts new file mode 100644 index 0000000000..5cb64cb638 --- /dev/null +++ b/tests/unit/minimax-media-servicekinds.test.ts @@ -0,0 +1,93 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +// Regression guard: media providers surface in the dashboard via registry-derived +// serviceKinds. +// +// Root cause this fixes: `/dashboard/media-providers/[kind]` listed a provider only +// if it hand-declared `serviceKinds` in providers.ts. ~48 providers were wired into +// the audio/video/music/image/embedding registries (backend works) but declared no +// serviceKinds, so every media page was empty. The fix derives media membership from +// the registries (single source of truth) and unions it with declared serviceKinds. +// +// MiniMax was the flagged case: its international endpoint serves TTS/video/music, the +// China variant (minimax-cn) has no media registry entries. + +const { getRegistryMediaKinds, resolveProviderServiceKinds, REGISTRY_MEDIA_KINDS } = + await import("../../open-sse/config/mediaServiceKinds.ts"); +const { AI_PROVIDERS } = await import("../../src/shared/constants/providers.ts"); + +test("minimax (international) derives tts/video/music from the registries", () => { + const kinds = getRegistryMediaKinds("minimax").sort(); + assert.deepEqual(kinds, ["music", "tts", "video"]); +}); + +test("minimax-cn derives no media kinds (China endpoint has no media registry entries)", () => { + assert.deepEqual(getRegistryMediaKinds("minimax-cn"), []); +}); + +test("representative media providers derive the expected kinds", () => { + // Each pair: provider id -> at least these kinds must be derived. + const cases: Record = { + elevenlabs: ["tts"], + deepgram: ["stt", "tts"], + suno: ["music"], + udio: ["music"], + runwayml: ["video"], + openai: ["embedding", "image", "stt", "tts"], + cohere: ["embedding", "stt"], + }; + for (const [id, expected] of Object.entries(cases)) { + const derived = getRegistryMediaKinds(id); + for (const kind of expected) { + assert.ok(derived.includes(kind as never), `${id} should derive ${kind}; got ${derived.join(",")}`); + } + } +}); + +test("resolveProviderServiceKinds unions declared kinds with derived media kinds", () => { + // A provider with a declared non-media kind keeps it AND gains derived media kinds. + const merged = resolveProviderServiceKinds("minimax", ["llm"]); + for (const expected of ["llm", "tts", "video", "music"]) { + assert.ok(merged.includes(expected), `expected ${expected} in ${merged.join(",")}`); + } + // De-dupes when declared overlaps derived. + const veo = resolveProviderServiceKinds("veoaifree-web", ["video"]); + assert.equal(veo.filter((k) => k === "video").length, 1); +}); + +test("media listing filter surfaces minimax where the old declared-only filter missed it", () => { + const cfg = AI_PROVIDERS as Record; + + // OLD behavior (the bug): filter by declared serviceKinds only. + const oldListFor = (kind: string) => + Object.values(cfg) + .filter((p) => (p.serviceKinds ?? []).includes(kind)) + .map((p) => p.id); + + // NEW behavior (the fix): union declared with registry-derived kinds — mirrors page.tsx. + const newListFor = (kind: string) => + Object.values(cfg) + .filter((p) => resolveProviderServiceKinds(p.id, p.serviceKinds).includes(kind)) + .map((p) => p.id); + + for (const kind of ["tts", "video", "music"]) { + assert.ok(!oldListFor(kind).includes("minimax"), `precondition (bug): old filter missed minimax under ${kind}`); + assert.ok(newListFor(kind).includes("minimax"), `fix: minimax now listed under ${kind}`); + assert.ok(!newListFor(kind).includes("minimax-cn"), `minimax-cn must not appear under ${kind}`); + } + + // The fix is systemic, not minimax-only: many providers were invisible before. + assert.ok(oldListFor("tts").length < newListFor("tts").length, "fix surfaces additional tts providers"); +}); + +test("derived kinds are always within the known media-kind set", () => { + for (const id of Object.keys(AI_PROVIDERS)) { + for (const kind of getRegistryMediaKinds(id)) { + assert.ok( + (REGISTRY_MEDIA_KINDS as readonly string[]).includes(kind), + `${id} derived unknown kind ${kind}` + ); + } + } +});