mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
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.
This commit is contained in:
committed by
GitHub
parent
12124aaad8
commit
cfc2bc707b
71
open-sse/config/mediaServiceKinds.ts
Normal file
71
open-sse/config/mediaServiceKinds.ts
Normal file
@@ -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<string, Record<string, unknown>>;
|
||||
|
||||
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<string>(declared ?? []);
|
||||
for (const kind of getRegistryMediaKinds(providerId)) set.add(kind);
|
||||
return [...set];
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>).serviceKinds as string[] | undefined;
|
||||
return Array.isArray(serviceKinds) && serviceKinds.includes(validKind);
|
||||
const entry = p as Record<string, unknown> & { id: string; serviceKinds?: string[] };
|
||||
return resolveProviderServiceKinds(entry.id, entry.serviceKinds).includes(validKind);
|
||||
});
|
||||
|
||||
const kindLabel = t(`kinds.${validKind}`);
|
||||
|
||||
93
tests/unit/minimax-media-servicekinds.test.ts
Normal file
93
tests/unit/minimax-media-servicekinds.test.ts
Normal file
@@ -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<string, string[]> = {
|
||||
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<string, { id: string; serviceKinds?: string[] }>;
|
||||
|
||||
// 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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user