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 ( + <> +
+ + + + + + + + + + + + + + + {rows.map((model) => { + const capabilities = capabilityLabels(model); + const additionalCapabilities = capabilities.slice(3); + return ( + + + + + + + + + + ); + })} + +
Model catalog across all providers
+ {labels.capabilities} + + {labels.flags} +
+ {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 ? ( +
+ +

+ {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"),