diff --git a/changelog.d/features/14684-unified-model-catalog.md b/changelog.d/features/14684-unified-model-catalog.md
new file mode 100644
index 00000000000..b7e7f1c8120
--- /dev/null
+++ b/changelog.d/features/14684-unified-model-catalog.md
@@ -0,0 +1 @@
+- **feat(dashboard):** Add a unified, searchable model catalog across all providers ([#14684](https://github.com/diegosouzapw/OmniRoute/pull/14684)) — thanks @Dattuog
diff --git a/src/app/(dashboard)/dashboard/models/ModelCatalogTable.tsx b/src/app/(dashboard)/dashboard/models/ModelCatalogTable.tsx
new file mode 100644
index 00000000000..fa4e480b18e
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/models/ModelCatalogTable.tsx
@@ -0,0 +1,281 @@
+import { Badge, Button } from "@/shared/components";
+import type { CatalogModelRow, CatalogSortDirection, CatalogSortField } from "./modelCatalogUtils";
+
+function formatCount(value: number): string {
+ return new Intl.NumberFormat().format(value);
+}
+
+function formatOptionalCount(value: number | undefined): string {
+ return typeof value === "number" ? formatCount(value) : "—";
+}
+
+function humanize(value: string): string {
+ return value
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
+ .replaceAll(/[_-]+/g, " ")
+ .replace(/^\w/, (first) => first.toUpperCase());
+}
+
+function capabilityLabels(model: CatalogModelRow): string[] {
+ const labels = Object.entries(model.capabilities ?? {}).flatMap(([key, value]) => {
+ if (value === true) return [humanize(key)];
+ if (Array.isArray(value)) {
+ return value.filter((entry): entry is string => typeof entry === "string");
+ }
+ return [];
+ });
+
+ for (const modality of model.input_modalities ?? []) labels.push(`${humanize(modality)} input`);
+ for (const modality of model.output_modalities ?? []) labels.push(`${humanize(modality)} output`);
+ return [...new Set(labels)];
+}
+
+function SortableHeading({
+ field,
+ label,
+ activeField,
+ direction,
+ onSort,
+}: {
+ field: CatalogSortField;
+ label: string;
+ activeField: CatalogSortField;
+ direction: CatalogSortDirection;
+ onSort: (field: CatalogSortField) => void;
+}) {
+ const active = field === activeField;
+ return (
+
+
+ |
+ );
+}
+
+export default function ModelCatalogTable({
+ rows,
+ sortField,
+ sortDirection,
+ onSort,
+ page,
+ pageCount,
+ startIndex,
+ totalCount,
+ loading,
+ error,
+ onPrevious,
+ onNext,
+ labels,
+}: {
+ rows: CatalogModelRow[];
+ sortField: CatalogSortField;
+ sortDirection: CatalogSortDirection;
+ onSort: (field: CatalogSortField) => void;
+ page: number;
+ pageCount: number;
+ startIndex: number;
+ totalCount: number;
+ loading: boolean;
+ error: boolean;
+ onPrevious: () => void;
+ onNext: () => void;
+ labels: {
+ provider: string;
+ model: string;
+ type: string;
+ capabilities: string;
+ context: string;
+ output: string;
+ flags: string;
+ custom: string;
+ free: string;
+ };
+}) {
+ const firstResult = startIndex + 1;
+ const lastResult = startIndex + rows.length;
+
+ return (
+ <>
+
+
+ Model catalog across all providers
+
+
+
+
+
+ |
+ {labels.capabilities}
+ |
+
+
+
+ {labels.flags}
+ |
+
+
+
+ {rows.map((model) => {
+ const capabilities = capabilityLabels(model);
+ const additionalCapabilities = capabilities.slice(3);
+ return (
+
+ |
+ {model.provider}
+
+ {model.providerId}
+
+ |
+
+
+ {model.name}
+
+
+ {model.id}
+
+ |
+
+ {humanize(model.type)}
+ {model.subtype && (
+
+ {humanize(model.subtype)}
+
+ )}
+ |
+
+ {capabilities.length > 0 ? (
+
+ {capabilities.slice(0, 3).map((capability) => (
+
+ {capability}
+
+ ))}
+ {additionalCapabilities.length > 0 && (
+
+
+ +{additionalCapabilities.length}
+
+
+ )}
+
+ ) : (
+
+ —
+
+ )}
+ |
+
+ {formatOptionalCount(model.context_length)}
+ |
+
+ {formatOptionalCount(model.max_output_tokens)}
+ |
+
+
+ {model.custom === true && (
+
+ {labels.custom}
+
+ )}
+ {model.free === true && (
+
+ {labels.free}
+
+ )}
+ {model.custom !== true && model.free !== true && (
+
+ —
+
+ )}
+
+ |
+
+ );
+ })}
+
+
+
+
+
+ >
+ );
+}
diff --git a/src/app/(dashboard)/dashboard/models/modelCatalogUtils.ts b/src/app/(dashboard)/dashboard/models/modelCatalogUtils.ts
new file mode 100644
index 00000000000..88f766f830e
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/models/modelCatalogUtils.ts
@@ -0,0 +1,162 @@
+export interface CatalogModel {
+ id: string;
+ name: string;
+ type: string;
+ subtype?: string;
+ custom?: boolean;
+ free?: boolean;
+ capabilities?: Record;
+ context_length?: number;
+ max_output_tokens?: number;
+ input_modalities?: string[];
+ output_modalities?: string[];
+ supported_endpoints?: string[];
+}
+
+export interface CatalogModelRow extends CatalogModel {
+ providerId: string;
+ provider: string;
+}
+
+export type CatalogSortField = "provider" | "id" | "type" | "context_length" | "max_output_tokens";
+export type CatalogSortDirection = "asc" | "desc";
+
+export interface CatalogFilters {
+ query: string;
+ providerId: string;
+ type: string;
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+/** Convert the provider-grouped API payload into stable rows for the dashboard table. */
+export function flattenCatalog(catalog: unknown): CatalogModelRow[] {
+ if (!isRecord(catalog)) return [];
+
+ const rows: CatalogModelRow[] = [];
+ for (const [providerId, rawBucket] of Object.entries(catalog)) {
+ if (!isRecord(rawBucket) || !Array.isArray(rawBucket.models)) continue;
+ const provider = typeof rawBucket.provider === "string" ? rawBucket.provider : providerId;
+
+ for (const rawModel of rawBucket.models) {
+ if (!isRecord(rawModel) || typeof rawModel.id !== "string") continue;
+ const id = rawModel.id;
+ rows.push({
+ providerId,
+ provider,
+ id,
+ name: typeof rawModel.name === "string" ? rawModel.name : id,
+ type: typeof rawModel.type === "string" ? rawModel.type : "unknown",
+ ...(typeof rawModel.subtype === "string" ? { subtype: rawModel.subtype } : {}),
+ ...(rawModel.custom === true ? { custom: true } : {}),
+ ...(rawModel.free === true ? { free: true } : {}),
+ ...(isRecord(rawModel.capabilities) ? { capabilities: rawModel.capabilities } : {}),
+ ...(typeof rawModel.context_length === "number"
+ ? { context_length: rawModel.context_length }
+ : {}),
+ ...(typeof rawModel.max_output_tokens === "number"
+ ? { max_output_tokens: rawModel.max_output_tokens }
+ : {}),
+ ...(Array.isArray(rawModel.input_modalities)
+ ? { input_modalities: rawModel.input_modalities.filter(isString) }
+ : {}),
+ ...(Array.isArray(rawModel.output_modalities)
+ ? { output_modalities: rawModel.output_modalities.filter(isString) }
+ : {}),
+ ...(Array.isArray(rawModel.supported_endpoints)
+ ? { supported_endpoints: rawModel.supported_endpoints.filter(isString) }
+ : {}),
+ });
+ }
+ }
+
+ return rows;
+}
+
+function isString(value: unknown): value is string {
+ return typeof value === "string";
+}
+
+function searchableText(model: CatalogModelRow): string {
+ return [
+ model.provider,
+ model.providerId,
+ model.id,
+ model.name,
+ model.type,
+ model.subtype,
+ ...Object.entries(model.capabilities ?? {}).flatMap(([key, value]) => [
+ key,
+ ...(Array.isArray(value) ? value.filter(isString) : []),
+ ]),
+ ...(model.input_modalities ?? []),
+ ...(model.output_modalities ?? []),
+ ...(model.supported_endpoints ?? []),
+ ]
+ .filter(isString)
+ .join(" ")
+ .toLocaleLowerCase();
+}
+
+export function filterCatalogModels(
+ models: CatalogModelRow[],
+ filters: CatalogFilters
+): CatalogModelRow[] {
+ const query = filters.query.trim().toLocaleLowerCase();
+ return models.filter((model) => {
+ if (filters.providerId !== "all" && model.providerId !== filters.providerId) return false;
+ if (filters.type !== "all" && model.type !== filters.type) return false;
+ return query === "" || searchableText(model).includes(query);
+ });
+}
+
+function compareText(left: string, right: string): number {
+ const normalizedLeft = left.toLocaleLowerCase();
+ const normalizedRight = right.toLocaleLowerCase();
+ return normalizedLeft < normalizedRight ? -1 : normalizedLeft > normalizedRight ? 1 : 0;
+}
+
+export function sortCatalogModels(
+ models: CatalogModelRow[],
+ field: CatalogSortField,
+ direction: CatalogSortDirection
+): CatalogModelRow[] {
+ const multiplier = direction === "asc" ? 1 : -1;
+ return [...models].sort((left, right) => {
+ const leftValue = left[field];
+ const rightValue = right[field];
+
+ if (typeof leftValue === "number" || typeof rightValue === "number") {
+ // Keep unknown values last regardless of direction; absence is not zero.
+ if (typeof leftValue !== "number") return 1;
+ if (typeof rightValue !== "number") return -1;
+ if (leftValue !== rightValue) return (leftValue - rightValue) * multiplier;
+ } else {
+ const comparison = compareText(String(leftValue ?? ""), String(rightValue ?? ""));
+ if (comparison !== 0) return comparison * multiplier;
+ }
+
+ return compareText(left.providerId, right.providerId) || compareText(left.id, right.id);
+ });
+}
+
+export function getCatalogPage(
+ rows: T[],
+ requestedPage: number,
+ requestedPageSize: number
+): { rows: T[]; page: number; pageCount: number } {
+ const pageSize =
+ Number.isFinite(requestedPageSize) && requestedPageSize > 0 ? requestedPageSize : 50;
+ const pageCount = Math.ceil(rows.length / pageSize);
+ if (pageCount === 0) return { rows: [], page: 0, pageCount: 0 };
+
+ const safePage = Number.isFinite(requestedPage) ? Math.floor(requestedPage) : 0;
+ const page = Math.min(Math.max(0, safePage), pageCount - 1);
+ return {
+ rows: rows.slice(page * pageSize, (page + 1) * pageSize),
+ page,
+ pageCount,
+ };
+}
diff --git a/src/app/(dashboard)/dashboard/models/page.tsx b/src/app/(dashboard)/dashboard/models/page.tsx
new file mode 100644
index 00000000000..cf39486cfa9
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/models/page.tsx
@@ -0,0 +1,261 @@
+"use client";
+
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useTranslations } from "next-intl";
+
+import { Button, Card, Input } from "@/shared/components";
+import {
+ filterCatalogModels,
+ flattenCatalog,
+ getCatalogPage,
+ sortCatalogModels,
+ type CatalogModelRow,
+ type CatalogSortDirection,
+ type CatalogSortField,
+} from "./modelCatalogUtils";
+import ModelCatalogTable from "./ModelCatalogTable";
+
+const PAGE_SIZE = 50;
+
+type Translator = ((key: string) => string) & { has?: (key: string) => boolean };
+
+function commonText(translator: Translator, key: string, fallback: string): string {
+ return typeof translator.has === "function" && translator.has(key) ? translator(key) : fallback;
+}
+
+export default function ModelCatalogPage() {
+ const commonTranslator = useTranslations("common") as unknown as Translator;
+ const providersTranslator = useTranslations("providers") as unknown as Translator;
+ const [models, setModels] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(false);
+ const [query, setQuery] = useState("");
+ const [providerId, setProviderId] = useState("all");
+ const [modelType, setModelType] = useState("all");
+ const [sortField, setSortField] = useState("provider");
+ const [sortDirection, setSortDirection] = useState("asc");
+ const [requestedPage, setRequestedPage] = useState(0);
+ const requestController = useRef(null);
+
+ const loadCatalog = useCallback(async () => {
+ requestController.current?.abort();
+ const controller = new AbortController();
+ requestController.current = controller;
+
+ try {
+ const response = await fetch("/api/models/catalog", { signal: controller.signal });
+ if (!response.ok) throw new Error("catalog request failed");
+ const payload: unknown = await response.json();
+ const catalog =
+ typeof payload === "object" && payload !== null && "catalog" in payload
+ ? payload.catalog
+ : null;
+ setModels(flattenCatalog(catalog));
+ setError(false);
+ } catch {
+ if (!controller.signal.aborted) setError(true);
+ } finally {
+ if (!controller.signal.aborted) setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ const initialFetch = window.setTimeout(() => {
+ void loadCatalog();
+ }, 0);
+ return () => {
+ window.clearTimeout(initialFetch);
+ requestController.current?.abort();
+ };
+ }, [loadCatalog]);
+
+ const refreshCatalog = useCallback(() => {
+ setLoading(true);
+ setError(false);
+ void loadCatalog();
+ }, [loadCatalog]);
+
+ const providerOptions = useMemo(
+ () =>
+ [...new Map(models.map((model) => [model.providerId, model.provider])).entries()].sort(
+ ([left], [right]) => left.localeCompare(right)
+ ),
+ [models]
+ );
+ const typeOptions = useMemo(
+ () =>
+ [...new Set(models.map((model) => model.type))].sort((left, right) =>
+ left.localeCompare(right)
+ ),
+ [models]
+ );
+
+ const visibleModels = useMemo(() => {
+ const filtered = filterCatalogModels(models, { query, providerId, type: modelType });
+ return sortCatalogModels(filtered, sortField, sortDirection);
+ }, [models, modelType, providerId, query, sortDirection, sortField]);
+ const page = getCatalogPage(visibleModels, requestedPage, PAGE_SIZE);
+
+ const updateQuery = (value: string) => {
+ setQuery(value);
+ setRequestedPage(0);
+ };
+
+ const updateProvider = (value: string) => {
+ setProviderId(value);
+ setRequestedPage(0);
+ };
+
+ const updateType = (value: string) => {
+ setModelType(value);
+ setRequestedPage(0);
+ };
+
+ const handleSort = (field: CatalogSortField) => {
+ if (field === sortField) {
+ setSortDirection((current) => (current === "asc" ? "desc" : "asc"));
+ } else {
+ setSortField(field);
+ setSortDirection("asc");
+ }
+ setRequestedPage(0);
+ };
+
+ const text = (key: string, fallback: string) => commonText(commonTranslator, key, fallback);
+ const providerText = (key: string, fallback: string) =>
+ commonText(providersTranslator, key, fallback);
+
+ return (
+
+
+
+
+
Model catalog
+
+ Browse model metadata from every provider in one place.
+
+
+
+
+
+
+
+
+
updateQuery(event.target.value)}
+ className="min-w-0 flex-1"
+ />
+
+
+
+
+
+
+ {loading && models.length === 0 ? (
+
+ {text("loading", "Loading...")}
+
+ ) : error && models.length === 0 ? (
+
+
Unable to load the model catalog.
+
Check the connection and try again.
+
+
+ ) : visibleModels.length === 0 ? (
+
+
+ search_off
+
+
+ {models.length === 0
+ ? text("noModelsFound", "No models are available yet.")
+ : "No models match these filters."}
+
+ {models.length > 0 && (
+
+ )}
+
+ ) : (
+ setRequestedPage((current) => Math.max(0, current - 1))}
+ onNext={() => setRequestedPage((current) => Math.min(page.pageCount - 1, current + 1))}
+ labels={{
+ provider: text("provider", "Provider"),
+ model: text("model", "Model"),
+ type: text("type", "Type"),
+ capabilities: "Capabilities",
+ context: "Context",
+ output: text("output", "Max output"),
+ flags: "Flags",
+ custom: text("custom", "Custom"),
+ free: text("free", "Free"),
+ }}
+ />
+ )}
+
+
+ );
+}
diff --git a/src/shared/constants/sidebarVisibility/sections.ts b/src/shared/constants/sidebarVisibility/sections.ts
index bbf72a01e9a..99c49024c1e 100644
--- a/src/shared/constants/sidebarVisibility/sections.ts
+++ b/src/shared/constants/sidebarVisibility/sections.ts
@@ -40,6 +40,15 @@ const OMNI_PROXY_ITEMS: readonly SidebarItemDefinition[] = [
subtitleKey: "providersSubtitle",
icon: "dns",
},
+ {
+ id: "model-catalog",
+ href: "/dashboard/models",
+ i18nKey: "modelCatalog",
+ labelFallback: "Model catalog",
+ subtitleKey: "modelCatalogSubtitle",
+ subtitleFallback: "Browse models across providers",
+ icon: "view_list",
+ },
{
id: "embedded-services",
href: "/dashboard/providers/services",
diff --git a/src/shared/constants/sidebarVisibility/types.ts b/src/shared/constants/sidebarVisibility/types.ts
index a7d1480c939..451feff4af1 100644
--- a/src/shared/constants/sidebarVisibility/types.ts
+++ b/src/shared/constants/sidebarVisibility/types.ts
@@ -5,6 +5,7 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
"api-manager",
"endpoints",
"providers",
+ "model-catalog",
"embedded-services",
"combos",
"combos-live",
diff --git a/tests/unit/dashboard/modelCatalogPage.test.tsx b/tests/unit/dashboard/modelCatalogPage.test.tsx
new file mode 100644
index 00000000000..61415a1b465
--- /dev/null
+++ b/tests/unit/dashboard/modelCatalogPage.test.tsx
@@ -0,0 +1,121 @@
+// @vitest-environment jsdom
+import React, { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("next-intl", () => ({
+ useTranslations: () => {
+ const translate = (key: string) => key;
+ return Object.assign(translate, { has: () => false });
+ },
+}));
+
+import ModelCatalogPage from "@/app/(dashboard)/dashboard/models/page";
+
+describe("ModelCatalogPage", () => {
+ let container: HTMLDivElement;
+ let root: Root;
+
+ beforeEach(() => {
+ (
+ globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
+ ).IS_REACT_ACT_ENVIRONMENT = true;
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it("loads all provider models and only labels flags the API actually reports", async () => {
+ vi.stubGlobal(
+ "fetch",
+ vi.fn().mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ catalog: {
+ alpha: {
+ provider: "Alpha Labs",
+ models: [
+ {
+ id: "alpha-chat",
+ name: "Alpha Chat",
+ type: "chat",
+ free: true,
+ context_length: 128_000,
+ capabilities: {
+ tools: true,
+ vision: true,
+ reasoning: true,
+ audio: true,
+ structured_output: true,
+ },
+ },
+ ],
+ },
+ beta: {
+ provider: "Beta AI",
+ models: [{ id: "beta-vision", name: "Beta Vision", type: "chat" }],
+ },
+ },
+ }),
+ })
+ );
+
+ act(() => {
+ root.render();
+ });
+ await act(async () => new Promise((resolve) => window.setTimeout(resolve, 0)));
+
+ expect(fetch).toHaveBeenCalledWith("/api/models/catalog", expect.anything());
+ expect(container.textContent).toContain("Alpha Labs");
+ expect(container.textContent).toContain("Beta AI");
+ expect(container.textContent).toContain("Free");
+ expect(container.textContent).not.toContain("Paid");
+ expect(container.querySelector('[role="img"]')?.getAttribute("aria-label")).toBe(
+ "Additional capabilities: Audio, Structured output"
+ );
+ expect(container.querySelectorAll("tbody tr")).toHaveLength(2);
+
+ const providerFilter = container.querySelector("select")!;
+ act(() => {
+ providerFilter.value = "beta";
+ providerFilter.dispatchEvent(new Event("change", { bubbles: true }));
+ });
+ expect(container.querySelectorAll("tbody tr")).toHaveLength(1);
+ expect(container.querySelector("tbody")?.textContent).toContain("Beta Vision");
+
+ act(() => {
+ providerFilter.value = "all";
+ providerFilter.dispatchEvent(new Event("change", { bubbles: true }));
+ });
+ const modelSort = [...container.querySelectorAll("th button")].find(
+ (button) => button.textContent === "Model"
+ )!;
+ act(() => modelSort.click());
+ act(() => modelSort.click());
+ expect(modelSort.closest("th")?.getAttribute("aria-sort")).toBe("descending");
+ expect(container.querySelector("tbody tr")?.textContent).toContain("Beta Vision");
+ });
+
+ it("shows a retry action after the catalog request fails", async () => {
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network unavailable")));
+
+ act(() => {
+ root.render();
+ });
+ await act(async () => new Promise((resolve) => window.setTimeout(resolve, 0)));
+
+ expect(container.querySelector('[role="alert"]')?.textContent).toContain(
+ "Unable to load the model catalog."
+ );
+ expect(
+ [...container.querySelectorAll("button")].some((button) => button.textContent === "Retry")
+ ).toBe(true);
+ });
+});
diff --git a/tests/unit/dashboard/modelCatalogUtils.test.tsx b/tests/unit/dashboard/modelCatalogUtils.test.tsx
new file mode 100644
index 00000000000..a82e223536e
--- /dev/null
+++ b/tests/unit/dashboard/modelCatalogUtils.test.tsx
@@ -0,0 +1,110 @@
+import { describe, expect, it } from "vitest";
+import {
+ filterCatalogModels,
+ flattenCatalog,
+ getCatalogPage,
+ sortCatalogModels,
+ type CatalogModelRow,
+} from "@/app/(dashboard)/dashboard/models/modelCatalogUtils";
+
+const models: CatalogModelRow[] = [
+ {
+ providerId: "zeta",
+ provider: "Zeta AI",
+ id: "zeta-chat",
+ name: "Zeta Chat",
+ type: "chat",
+ context_length: 128_000,
+ input_modalities: ["text", "image"],
+ },
+ {
+ providerId: "alpha",
+ provider: "Alpha Labs",
+ id: "alpha-embed",
+ name: "Alpha Embed",
+ type: "embedding",
+ capabilities: { tools: true },
+ custom: true,
+ },
+ {
+ providerId: "alpha",
+ provider: "Alpha Labs",
+ id: "alpha-chat",
+ name: "Alpha Chat",
+ type: "chat",
+ free: true,
+ },
+];
+
+describe("flattenCatalog", () => {
+ it("creates one row per model while retaining the provider identity", () => {
+ expect(
+ flattenCatalog({
+ zeta: { provider: "Zeta AI", models: [models[0]] },
+ alpha: { provider: "Alpha Labs", models: [models[1], models[2]] },
+ })
+ ).toEqual(models);
+ });
+
+ it("ignores malformed provider buckets and empty model lists", () => {
+ expect(
+ flattenCatalog({
+ broken: null,
+ empty: { provider: "Empty", models: [] },
+ })
+ ).toEqual([]);
+ });
+});
+
+describe("filterCatalogModels", () => {
+ it("searches case-insensitively across provider, model, and capability metadata", () => {
+ expect(filterCatalogModels(models, { query: "IMAGE", providerId: "all", type: "all" })).toEqual(
+ [models[0]]
+ );
+ expect(
+ filterCatalogModels(models, { query: "alpha labs", providerId: "all", type: "all" })
+ ).toEqual([models[1], models[2]]);
+ });
+
+ it("combines provider and type filters", () => {
+ expect(filterCatalogModels(models, { query: "", providerId: "alpha", type: "chat" })).toEqual([
+ models[2],
+ ]);
+ });
+});
+
+describe("sortCatalogModels", () => {
+ it("sorts numeric fields and keeps unknown values at the end in either direction", () => {
+ const values = [models[0], { ...models[1], context_length: 32_000 }, models[2]];
+ expect(sortCatalogModels(values, "context_length", "desc").map((model) => model.id)).toEqual([
+ "zeta-chat",
+ "alpha-embed",
+ "alpha-chat",
+ ]);
+ expect(sortCatalogModels(values, "context_length", "asc").map((model) => model.id)).toEqual([
+ "alpha-embed",
+ "zeta-chat",
+ "alpha-chat",
+ ]);
+ });
+
+ it("sorts text fields without mutating the source list", () => {
+ expect(sortCatalogModels(models, "provider", "asc").map((model) => model.id)).toEqual([
+ "alpha-chat",
+ "alpha-embed",
+ "zeta-chat",
+ ]);
+ expect(models[0].id).toBe("zeta-chat");
+ });
+});
+
+describe("getCatalogPage", () => {
+ it("returns the requested slice and clamps out-of-range pages", () => {
+ expect(getCatalogPage(models, 1, 2)).toEqual({ rows: [models[2]], page: 1, pageCount: 2 });
+ expect(getCatalogPage(models, 99, 2)).toEqual({ rows: [models[2]], page: 1, pageCount: 2 });
+ });
+
+ it("represents an empty result with page zero and no pages", () => {
+ expect(getCatalogPage([], 5, 2)).toEqual({ rows: [], page: 0, pageCount: 0 });
+ });
+});
diff --git a/tests/unit/sidebar-visibility.test.ts b/tests/unit/sidebar-visibility.test.ts
index 66890fdc862..398c1abbf45 100644
--- a/tests/unit/sidebar-visibility.test.ts
+++ b/tests/unit/sidebar-visibility.test.ts
@@ -43,6 +43,7 @@ test("primary sidebar items place limits after cache", () => {
"endpoints",
"api-manager",
"providers",
+ "model-catalog",
"embedded-services",
"combos",
"combos-live",
@@ -163,6 +164,13 @@ test("plugins has a discoverable sidebar entry (#3656 follow-up)", async () => {
assert.match(pluginsPage, /scanForPlugins|installedTab|fetchPlugins/i);
});
+test("model catalog is available from the OmniProxy sidebar and can be hidden", () => {
+ const item = sectionItems("omni-proxy").find((candidate) => candidate.id === "model-catalog");
+ assert.ok(item, "expected the model catalog sidebar item to exist");
+ assert.equal(item.href, "/dashboard/models");
+ assert.equal(sidebarVisibility.HIDEABLE_SIDEBAR_ITEM_IDS.includes("model-catalog"), true);
+});
+
test("legacy dashboard routes redirect to their consolidated surfaces", async () => {
const autoComboPage = await readFile(
join(repoRoot, "src/app/(dashboard)/dashboard/auto-combo/page.tsx"),