diff --git a/tests/e2e/ui/tests/modelHub/modelHub.spec.ts b/tests/e2e/ui/tests/modelHub/modelHub.spec.ts index f8ce4faf45e..517ec64c7df 100644 --- a/tests/e2e/ui/tests/modelHub/modelHub.spec.ts +++ b/tests/e2e/ui/tests/modelHub/modelHub.spec.ts @@ -1,8 +1,8 @@ -import { test, expect, type APIRequestContext } from "@playwright/test"; +import { test, expect } from "@playwright/test"; import { ADMIN_STORAGE_PATH } from "../../constants"; import { navigateToPage, dismissFeedbackPopup } from "../../helpers/navigation"; import { Page } from "../../fixtures/pages"; -import { masterKey } from "../../helpers/traffic"; +import { masterKey, uniqueSuffix } from "../../helpers/traffic"; test.describe("AI Hub (internal admin view)", () => { test.use({ storageState: ADMIN_STORAGE_PATH }); @@ -62,103 +62,93 @@ test.describe("AI Hub (internal admin view)", () => { }); test.describe("Public model hub (/ui/model_hub_table)", () => { - // No storageState — the public page is reached anonymously with a `key` query param. - - test("Public model_hub_table loads and renders the Models tab", async ({ page }) => { - // The page expects the proxy key as the `key` query param. Use the master - // key the e2e runner already exports — this matches what the AI Hub copy - // button hands out. - await page.goto(`/ui/model_hub_table?key=${masterKey()}`); - - // Dismiss the feedback popup before asserting on the tab, so a popup - // race can't briefly mask the tab while we're evaluating visibility. + test("shows the Model Hub and Skills Hub catalogs", async ({ page }) => { + await page.goto("/ui/model_hub_table"); await dismissFeedbackPopup(page); - await expect(page.getByRole("tab", { name: /^Models/ })).toBeVisible({ timeout: 10_000 }); + await expect( + page.getByRole("tab", { name: "Model Hub", exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("textbox", { name: "Search models", exact: true }), + ).toBeVisible(); + await expect(page.getByRole("tab")).toHaveCount(2); + await page.getByRole("tab", { name: "Skills Hub", exact: true }).click(); + await expect( + page.getByRole("textbox", { name: "Search skills", exact: true }), + ).toBeVisible(); }); - test("Agents and MCP servers tabs render their public entries", async ({ page, request }) => { - const suffix = `${Date.now()}`; - const agentName = `e2e-public-agent-${suffix}`; - const mcpServerName = `e2e_public_mcp_${suffix}`; + test("finds a published skill and opens its installation instructions", async ({ + page, + request, + }) => { + const skillName = `e2e-catalog-${uniqueSuffix()}`; + const domain = `catalog-${uniqueSuffix()}`; const auth = { Authorization: `Bearer ${masterKey()}` }; - - const publicMcpServerIds = async (api: APIRequestContext): Promise => { - const res = await api.get("/public/mcp_hub"); - expect(res.ok(), `public mcp_hub read failed (${res.status()}): ${await res.text()}`).toBe(true); - const servers: { server_id: string }[] = await res.json(); - return servers.map((server) => server.server_id); - }; - - const seedPublicEntries = async ( - api: APIRequestContext, - priorMcpIds: string[], - ): Promise<{ agentId: string; serverId: string }> => { - const agentRes = await api.post("/v1/agents", { - headers: auth, - data: { - agent_name: agentName, - agent_card_params: { - name: agentName, - description: "E2E public agent", - version: "1.0.0", - url: "http://127.0.0.1:9999/", - capabilities: {}, - skills: [], - defaultInputModes: ["text"], - defaultOutputModes: ["text"], - }, - }, - }); - expect(agentRes.ok(), `agent create failed (${agentRes.status()}): ${await agentRes.text()}`).toBe(true); - const agentId = (await agentRes.json()).agent_id as string; - - const serverRes = await api.post("/v1/mcp/server", { - headers: auth, - data: { - server_name: mcpServerName, - url: "http://127.0.0.1:9999/mcp", - transport: "http", - description: "E2E public MCP server", - }, - }); - expect(serverRes.ok(), `mcp server create failed (${serverRes.status()}): ${await serverRes.text()}`).toBe(true); - const serverId = (await serverRes.json()).server_id as string; - - const agentPublicRes = await api.post(`/v1/agents/${agentId}/make_public`, { headers: auth }); - expect(agentPublicRes.ok(), `agent make_public failed: ${await agentPublicRes.text()}`).toBe(true); - const mcpPublicRes = await api.post("/v1/mcp/make_public", { - headers: auth, - data: { mcp_server_ids: [...priorMcpIds, serverId] }, - }); - expect(mcpPublicRes.ok(), `mcp make_public failed: ${await mcpPublicRes.text()}`).toBe(true); - - return { agentId, serverId }; - }; - - const priorMcpIds = await publicMcpServerIds(request); - const { agentId, serverId } = await seedPublicEntries(request, priorMcpIds); + const created = await request.post("/claude-code/plugins", { + headers: auth, + data: { + name: skillName, + source: { source: "github", repo: "example/catalog-skills" }, + description: "Catalog browser test skill", + domain, + version: "1.0.0", + }, + }); + expect( + created.ok(), + `skill registration failed (${created.status()}): ${await created.text()}`, + ).toBe(true); try { - await page.goto(`/ui/model_hub_table?key=${masterKey()}`); + await page.goto("/ui/model_hub_table?tab=skills"); await dismissFeedbackPopup(page); + await page.getByRole("checkbox", { name: domain, exact: true }).check(); + await page + .getByRole("textbox", { name: "Search skills", exact: true }) + .fill(skillName); + await page.getByRole("link").filter({ hasText: skillName }).click(); - const agentsTab = page.getByRole("tab", { name: /^Agents/ }); - await expect(agentsTab).toBeVisible({ timeout: 15_000 }); - await agentsTab.click(); - await expect(page.getByRole("textbox", { name: "Search agents" })).toBeVisible({ timeout: 10_000 }); - await expect(page.getByRole("link").filter({ hasText: agentName })).toHaveCount(1, { timeout: 10_000 }); - await expect(page.getByText("E2E public agent").first()).toBeVisible(); - - const mcpTab = page.getByRole("tab", { name: /^MCP/ }); - await expect(mcpTab).toBeVisible(); - await mcpTab.click(); - await expect(page.getByRole("textbox", { name: "Search MCP servers" })).toBeVisible({ timeout: 10_000 }); - await expect(page.getByRole("link").filter({ hasText: mcpServerName })).toHaveCount(1, { timeout: 10_000 }); - await expect(page.getByText("E2E public MCP server").first()).toBeVisible(); + const details = page.getByRole("dialog"); + await expect( + details.getByRole("heading", { name: skillName, exact: true }), + ).toBeVisible(); + await expect( + details.getByText(`/plugin install ${skillName}@litellm`, { + exact: true, + }), + ).toBeVisible(); + await expect(page).toHaveURL(new RegExp(`open=${skillName}`)); + await page.reload(); + await expect( + page + .getByRole("dialog") + .getByRole("heading", { name: skillName, exact: true }), + ).toBeVisible(); } finally { - await request.post("/v1/mcp/make_public", { headers: auth, data: { mcp_server_ids: priorMcpIds } }); - await request.delete(`/v1/agents/${agentId}`, { headers: auth }); - await request.delete(`/v1/mcp/server/${serverId}`, { headers: auth }); + const deleted = await request.delete( + `/claude-code/plugins/${skillName}`, + { headers: auth }, + ); + expect(deleted.ok(), `skill cleanup failed (${deleted.status()})`).toBe( + true, + ); + } + }); + + test("retired catalog links return to Models and clear the old selection", async ({ + page, + }) => { + for (const tab of ["agents", "mcp"]) { + await page.goto( + `/ui/model_hub_table?tab=${tab}&open=retired-entry&key=preserved`, + ); + await dismissFeedbackPopup(page); + await expect( + page.getByRole("tab", { name: "Model Hub", exact: true }), + ).toHaveAttribute("aria-selected", "true"); + await expect(page).toHaveURL(/model_hub_table\?key=preserved$/); + await expect(page.getByRole("dialog")).toHaveCount(0); } }); }); diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index 7ddbd77bb7f..331b4512bf1 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -29,6 +29,7 @@ interface NavbarProps { onToggleSidebar?: () => void; deploymentHealth?: React.ReactNode; deploymentHealthLink?: React.ReactNode; + publicNavigation?: React.ReactNode; } const NAV_LOGO_CLASS_NAME = "h-auto max-h-full w-auto max-w-full object-contain"; @@ -40,6 +41,7 @@ const Navbar: React.FC = ({ onToggleSidebar, deploymentHealth, deploymentHealthLink, + publicNavigation, }) => { const { t } = useTranslation(); const baseUrl = getProxyBaseUrl(); @@ -132,6 +134,8 @@ const Navbar: React.FC = ({ + {publicNavigation} + {!isPublicPage && (
diff --git a/ui/litellm-dashboard/src/components/publicModelHub/AgentInspector.tsx b/ui/litellm-dashboard/src/components/publicModelHub/AgentInspector.tsx deleted file mode 100644 index 4d237877f28..00000000000 --- a/ui/litellm-dashboard/src/components/publicModelHub/AgentInspector.tsx +++ /dev/null @@ -1,93 +0,0 @@ -"use client"; - -import { ExternalLink } from "lucide-react"; -import { useTranslation } from "react-i18next"; - -import type { AgentCard } from "@/components/PublicModelHubTableColumns"; -import { Button } from "@/components/ui/button"; -import { Field, FieldDescription } from "@/components/ui/field"; -import { Item, ItemActions, ItemContent, ItemDescription, ItemGroup, ItemTitle } from "@/components/ui/item"; - -import { agentCapabilities } from "./hubFormat"; -import { Badges, CodeBlock, EndpointField, Facts, PaneSection } from "./HubParts"; -import { agentSnippets } from "./hubSnippets"; -import { InspectorHeader } from "./InspectorHeader"; - -const modes = (agent: AgentCard) => { - const input = (agent.defaultInputModes ?? []).join(", "); - const output = (agent.defaultOutputModes ?? []).join(", "); - return input || output ? `${input || "-"} / ${output || "-"}` : null; -}; - -export function AgentInspector({ agent }: { agent: AgentCard }) { - const { t } = useTranslation(); - const capabilities = agentCapabilities(agent); - return ( -
- - 0 && , - }, - ], - ]} - /> - - {agent.documentationUrl && ( - - )} - {(agent.skills ?? []).length > 0 && ( - - - {agent.skills.map((skill) => ( - - - {skill.name} - {skill.description} - - - - - - ))} - - - )} - - {agentSnippets(agent).map((snippet, index) => ( - - - {t("publicModelHub.agentInspector.step", { step: index + 1, title: snippet.title })} - - - - ))} - -
- ); -} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/CatalogDetails.tsx b/ui/litellm-dashboard/src/components/publicModelHub/CatalogDetails.tsx new file mode 100644 index 00000000000..97dd6b4dbd7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/CatalogDetails.tsx @@ -0,0 +1,37 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; + +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { Skeleton } from "@/components/ui/skeleton"; + +export function CatalogDetails({ + name, + loading = false, + onClose, + children, +}: { + name: string | null; + loading?: boolean; + onClose: () => void; + children: ReactNode; +}) { + const { t } = useTranslation(); + return ( + !next && onClose()}> + + {t("publicModelHub.masterDetail.details")} + {loading ? ( +
+ + + +
+ ) : ( + children + )} +
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/CatalogFilters.tsx b/ui/litellm-dashboard/src/components/publicModelHub/CatalogFilters.tsx new file mode 100644 index 00000000000..7eceab1e410 --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/CatalogFilters.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { ChevronDown } from "lucide-react"; +import { useState, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; + +import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; + +import { useIsNarrow } from "./useIsNarrow"; + +export function CatalogFilters({ + children, + count, + onClear, +}: { + children: ReactNode; + count: number; + onClear: () => void; +}) { + const { t } = useTranslation(); + const narrow = useIsNarrow(); + const [expanded, setExpanded] = useState(false); + return ( + + ); +} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/FilterChip.tsx b/ui/litellm-dashboard/src/components/publicModelHub/FilterChip.tsx deleted file mode 100644 index a5aeff6f068..00000000000 --- a/ui/litellm-dashboard/src/components/publicModelHub/FilterChip.tsx +++ /dev/null @@ -1,93 +0,0 @@ -"use client"; - -import { CirclePlus, X } from "lucide-react"; -import { useTranslation } from "react-i18next"; - -import { Button } from "@/components/ui/button"; -import { ButtonGroup } from "@/components/ui/button-group"; -import { - Combobox, - ComboboxContent, - ComboboxEmpty, - ComboboxInput, - ComboboxItem, - ComboboxList, - ComboboxTrigger, -} from "@/components/ui/combobox"; -import { cn } from "@/lib/cva.config"; - -import { firstPlus } from "./hubFormat"; -import { ProviderAvatar } from "./HubParts"; - -interface FilterOption { - value: string; - label: string; - provider?: string; -} - -export function FilterChip({ - label, - noun, - options, - value, - onChange, -}: { - label: string; - noun: string; - options: FilterOption[]; - value: string[]; - onChange: (value: string[]) => void; -}) { - const { t } = useTranslation(); - const byValue = new Map(options.map((option) => [option.value, option])); - const selected = value.map((v) => byValue.get(v)?.label ?? v); - const active = value.length > 0; - return ( - - - multiple - items={options} - value={options.filter((option) => value.includes(option.value))} - onValueChange={(next) => onChange(next.map((option) => option.value))} - itemToStringLabel={(option) => option.label} - isItemEqualToValue={(a, b) => a.value === b.value} - > - svg:last-child]:hidden", !active && "border-dashed text-muted-foreground")} - /> - } - > - {!active && } - {label} - {active && {firstPlus(selected)}} - - - - {t("publicModelHub.filterChip.empty", { noun })} - - {(option: FilterOption) => ( - - {option.provider && } - {option.label} - - )} - - - - {active && ( - - )} - - ); -} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/HubHeader.tsx b/ui/litellm-dashboard/src/components/publicModelHub/HubHeader.tsx index 532df227ea2..f42e7e0ccbd 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/HubHeader.tsx +++ b/ui/litellm-dashboard/src/components/publicModelHub/HubHeader.tsx @@ -1,6 +1,6 @@ "use client"; -import { ChevronDown, ExternalLink, LayoutGrid } from "lucide-react"; +import { ChevronDown, ExternalLink } from "lucide-react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; @@ -18,7 +18,8 @@ import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/ import { gatewayUrl, usefulLinkList } from "./hubFormat"; import { CopyIconButton } from "./HubParts"; -import { StatusBadge } from "./HubStatus"; +import type { HubTab } from "./useHubUrlState"; +import styles from "./catalog.module.css"; import type { HubInfo } from "./useHubData"; export type HubStatus = "loading" | "operational" | "degraded" | "unavailable"; @@ -59,7 +60,7 @@ function BaseUrlField() { const { t } = useTranslation(); const url = gatewayUrl(); return ( - + {t("publicModelHub.baseUrlField.label")} @@ -75,31 +76,33 @@ function BaseUrlField() { - + {t("publicModelHub.baseUrlField.hint")} ); } -export function HubHeader({ info, status }: { info: HubInfo | undefined; status: HubStatus }) { +export function HubHeader({ info, status, tab }: { info: HubInfo | undefined; status: HubStatus; tab: HubTab }) { const { t } = useTranslation(); return ( -
-
-
-

- - {t("publicModelHub.header.title")} -

- {status === "operational" && } -
-

- {info?.custom_docs_description || t("publicModelHub.header.description")} +

+
+

{info?.docs_title || t("publicModelHub.catalog.eyebrow")}

+

+ {t(tab === "skills" ? "publicModelHub.catalog.skillsHub" : "publicModelHub.catalog.modelHub")} +

+

+ {info?.custom_docs_description || + t( + tab === "skills" + ? "publicModelHub.catalog.skillsDescription" + : "publicModelHub.catalog.modelsDescription", + )}

{status !== "unavailable" && ( -
+
diff --git a/ui/litellm-dashboard/src/components/publicModelHub/HubMasterDetail.tsx b/ui/litellm-dashboard/src/components/publicModelHub/HubMasterDetail.tsx deleted file mode 100644 index 715e1cad07f..00000000000 --- a/ui/litellm-dashboard/src/components/publicModelHub/HubMasterDetail.tsx +++ /dev/null @@ -1,235 +0,0 @@ -"use client"; - -import * as React from "react"; - -import { Item, ItemContent, ItemGroup, ItemMedia } from "@/components/ui/item"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet"; -import { Skeleton } from "@/components/ui/skeleton"; -import { cn } from "@/lib/cva.config"; -import i18n from "@/i18n/i18n"; - -import { NotListed } from "./HubEmpty"; -import { InSheet } from "./InspectorHeader"; -import { useOpenHref } from "./useHubUrlState"; -import { useIsNarrow } from "./useIsNarrow"; - -function ListSkeleton() { - return ( - - {[0, 1, 2].map((i) => ( -
- - - - - - - - - -
- ))} -
- ); -} - -function DetailSkeleton() { - return ( -
- -
- {[0, 1, 2, 3].map((i) => ( -
- - -
- ))} -
- -
- ); -} - -const isPlainClick = (event: React.MouseEvent) => { - const opensElsewhere = event.metaKey || event.ctrlKey || event.shiftKey; - return event.button === 0 && !opensElsewhere; -}; - -function Rows({ - rows, - activeKey, - explicit, - getKey, - onSelect, - renderItem, -}: { - rows: T[]; - activeKey: string | undefined; - explicit: boolean; - getKey: (row: T) => string; - onSelect: (key: string) => void; - renderItem: (row: T) => React.ReactNode; -}) { - const hrefFor = useOpenHref(); - const follow = (key: string) => (event: React.MouseEvent) => { - if (!isPlainClick(event)) return; - event.preventDefault(); - onSelect(key); - }; - return ( - - {rows.map((row) => { - const key = getKey(row); - const active = key === activeKey; - return ( -
- - } - className={cn(active && (explicit ? "bg-muted" : "lg:bg-muted"))} - > - {renderItem(row)} - -
- ); - })} -
- ); -} - -const resolveSelection = ( - rows: T[], - selectedKey: string | null, - opened: T | undefined, - getKey: (row: T) => string, -) => { - const chosen = selectedKey === null ? undefined : opened ?? rows.find((row) => getKey(row) === selectedKey); - return { chosen, selected: selectedKey === null ? rows[0] : chosen }; -}; - -function useSheet( - chosen: T | undefined, - missingKey: string | undefined, - getKey: (row: T) => string, - renderDetail: (row: T) => React.ReactNode, -) { - const [content, setContent] = React.useState<{ row?: T; missingKey?: string }>({ row: chosen }); - if (chosen !== undefined && chosen !== content.row) setContent({ row: chosen }); - if (missingKey !== undefined && missingKey !== content.missingKey) setContent({ missingKey }); - const { row } = content; - return { - open: chosen !== undefined || missingKey !== undefined, - title: row ? getKey(row) : content.missingKey ?? i18n.t("publicModelHub.masterDetail.details"), - body: row ? renderDetail(row) : content.missingKey && , - }; -} - -export function HubMasterDetail({ - switcher, - toolbar, - rows, - loading, - selectedKey, - opened, - openPending = false, - getKey, - onSelect, - renderItem, - renderDetail, - empty, - footer, -}: { - switcher?: React.ReactNode; - toolbar: React.ReactNode; - rows: T[]; - loading: boolean; - selectedKey: string | null; - opened?: T; - openPending?: boolean; - getKey: (row: T) => string; - onSelect: (key: string | null) => void; - renderItem: (row: T) => React.ReactNode; - renderDetail: (row: T) => React.ReactNode; - empty: React.ReactNode; - footer?: React.ReactNode; -}) { - const narrow = useIsNarrow(); - const { chosen, selected } = resolveSelection(rows, selectedKey, opened, getKey); - const isEmpty = !loading && rows.length === 0; - const settled = !loading && !openPending && !isEmpty; - const missingKey = selectedKey !== null && chosen === undefined && settled ? selectedKey : undefined; - const sheet = useSheet(chosen, missingKey, getKey, renderDetail); - const listOnly = isEmpty && chosen === undefined && !openPending; - const detail = () => { - if (loading || (selected === undefined && openPending)) return ; - if (selected === undefined) return selectedKey && ; - return renderDetail(selected); - }; - const list = isEmpty ? ( - empty - ) : ( - <> - - {footer} - - ); - return ( -
-
-
- {switcher} -
{toolbar}
-
- {loading ? : list} -
- {!listOnly && {detail()}} - onSelect(null)}> - {sheet.body} - -
- ); -} - -function DetailSheet({ - open, - title, - onClose, - children, -}: { - open: boolean; - title: string; - onClose: () => void; - children: React.ReactNode; -}) { - const body = React.useRef(null); - return ( - !next && onClose()}> - - {title} -
- {children} -
-
-
- ); -} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/InspectorHeader.tsx b/ui/litellm-dashboard/src/components/publicModelHub/InspectorHeader.tsx index bf76bd49239..9a8f5ea6bb9 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/InspectorHeader.tsx +++ b/ui/litellm-dashboard/src/components/publicModelHub/InspectorHeader.tsx @@ -1,18 +1,12 @@ "use client"; -import { X } from "lucide-react"; import * as React from "react"; -import { useTranslation } from "react-i18next"; -import { Button } from "@/components/ui/button"; -import { SheetClose } from "@/components/ui/sheet"; import { cn } from "@/lib/cva.config"; import { SECTION_TITLE } from "./hubFormat"; import { CopyIconButton } from "./HubParts"; -export const InSheet = React.createContext(false); - export function InspectorHeader({ media, title, @@ -26,8 +20,6 @@ export function InspectorHeader({ status?: React.ReactNode; description?: React.ReactNode; }) { - const { t } = useTranslation(); - const inSheet = React.useContext(InSheet); return (
@@ -36,20 +28,6 @@ export function InspectorHeader({ {status && {status}} - {inSheet && ( - - } - > - - - )}
{status && {status}} {description &&

{description}

} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/McpInspector.tsx b/ui/litellm-dashboard/src/components/publicModelHub/McpInspector.tsx deleted file mode 100644 index 7ec6c5cf78b..00000000000 --- a/ui/litellm-dashboard/src/components/publicModelHub/McpInspector.tsx +++ /dev/null @@ -1,36 +0,0 @@ -"use client"; - -import { useTranslation } from "react-i18next"; - -import type { MCPServerData } from "@/components/PublicModelHubTableColumns"; - -import { mcpAuthLabel } from "./hubFormat"; -import { CodeBlock, EndpointField, Facts, PaneSection } from "./HubParts"; -import { mcpEndpoint, mcpSnippet } from "./hubSnippets"; -import { InspectorHeader } from "./InspectorHeader"; - -export function McpInspector({ server }: { server: MCPServerData }) { - const { t } = useTranslation(); - return ( -
- - - - - - -
- ); -} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/ModelsPane.tsx b/ui/litellm-dashboard/src/components/publicModelHub/ModelsPane.tsx index d7fcc865036..837dd3e0b1d 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/ModelsPane.tsx +++ b/ui/litellm-dashboard/src/components/publicModelHub/ModelsPane.tsx @@ -1,24 +1,26 @@ "use client"; -import { ArrowDownWideNarrow, ArrowUpNarrowWide, Boxes, ChevronLeft, ChevronRight } from "lucide-react"; +import type { ColumnDef } from "@tanstack/react-table"; +import { ArrowDownWideNarrow, ArrowUpNarrowWide, Boxes } from "lucide-react"; +import type { MouseEvent } from "react"; import { useTranslation } from "react-i18next"; import type { ModelGroupInfo } from "@/components/PublicModelHubTableColumns"; -import { Button } from "@/components/ui/button"; +import { DataTable, DataTableSortHeader } from "@/components/shared/DataTable"; import { ButtonGroup } from "@/components/ui/button-group"; -import { ItemActions, ItemContent, ItemDescription, ItemMedia } from "@/components/ui/item"; +import { Checkbox } from "@/components/ui/checkbox"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Toggle } from "@/components/ui/toggle"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { displayMode } from "@/lib/decisionModels"; -import i18n from "@/i18n/i18n"; -import { FilterChip } from "./FilterChip"; -import { featureFacetLabel, firstPlus, formatPerMillion, formatTokens, modeLabel, providerName } from "./hubFormat"; -import { HubMasterDetail } from "./HubMasterDetail"; -import { LoadFailed, NoResults } from "./HubEmpty"; -import { Joined, ProviderAvatar, RowTitle, SearchInput } from "./HubParts"; +import { CatalogDetails } from "./CatalogDetails"; +import { CatalogFilters } from "./CatalogFilters"; +import { featureFacetLabel, formatPerMillion, formatTokens, modeLabel, providerName } from "./hubFormat"; +import { LoadFailed, NoResults, NotListed } from "./HubEmpty"; +import { ProviderAvatar, SearchInput } from "./HubParts"; import { type HubData, useOpenedModel } from "./useHubData"; +import { useOpenHref } from "./useHubUrlState"; import { ModelInspector } from "./ModelInspector"; const SORTS = [ @@ -34,42 +36,12 @@ const SORTS = [ type Models = HubData["models"]; -function ModelsPager({ models }: { models: Models }) { - const { t } = useTranslation(); - const { pagination, rowCount, rows, onPaginationChange } = models; - if (!rowCount) return null; - const start = pagination.pageIndex * pagination.pageSize + 1; - const end = start + rows.length - 1; - const go = (pageIndex: number) => onPaginationChange({ ...pagination, pageIndex }); - return ( -
- {t("publicModelHub.modelsPager.range", { start, end, total: rowCount })} - - - - -
- ); -} - function SortControl({ models }: { models: Models }) { const { t } = useTranslation(); + const options = SORTS.map((sort) => ({ + ...sort, + label: t(`publicModelHub.catalog.sort.${sort.value}`, { defaultValue: sort.label }), + })); const field = models.sorting[0]?.id ?? "model_group"; const desc = models.sorting[0]?.desc ?? false; const pick = (value: string | null) => { @@ -78,13 +50,13 @@ function SortControl({ models }: { models: Models }) { }; return ( - {t("publicModelHub.sortControl.sort")} - {SORTS.map((sort) => ( + {options.map((sort) => ( {sort.label} @@ -112,100 +84,106 @@ function SortControl({ models }: { models: Models }) { ); } -function ModelsToolbar({ hub }: { hub: HubData }) { - const { t } = useTranslation(); - const { models, facets } = hub; +interface FilterOption { + value: string; + label: string; + provider?: string; +} + +function CatalogFilter({ + label, + options, + values, + onChange, +}: { + label: string; + options: FilterOption[]; + values: string[]; + onChange: (values: string[]) => void; +}) { return ( - <> -
- - +
+ {label} +
+ {options.map((option) => ( + + ))}
-
- ({ value: p, label: providerName(p), provider: p }))} - value={models.providerValues} - onChange={models.onProvidersChange} - /> - displayMode(m) ?? m))].map((m) => ({ - value: m, - label: modeLabel(m), - }))} - value={models.modeValues} - onChange={models.onModesChange} - /> - ({ value: f, label: featureFacetLabel(f) }))} - value={models.featureValues} - onChange={models.onFeaturesChange} - /> -
- +
); } -const price = (cost: number | null | undefined, direction: "input" | "output") => { - if (cost === 0) return i18n.t("publicModelHub.modelPrices.freeDirection", { direction }); - return cost - ? i18n.t("publicModelHub.modelPrices.perMillion", { - price: formatPerMillion(cost), - direction: - direction === "input" ? i18n.t("publicModelHub.modelPrices.in") : i18n.t("publicModelHub.modelPrices.out"), - }) - : null; -}; - -const prices = (model: ModelGroupInfo) => - model.input_cost_per_token === 0 && model.output_cost_per_token === 0 - ? [i18n.t("publicModelHub.modelPrices.free"), null] - : [price(model.input_cost_per_token, "input"), price(model.output_cost_per_token, "output")]; - -function ModelRow({ model }: { model: ModelGroupInfo }) { +function ModelFilters({ hub }: { hub: HubData }) { const { t } = useTranslation(); - const [input, output] = prices(model); - const priced = Boolean(input || output); + const { models, facets } = hub; return ( - <> - - - - - {model.model_group} - - - - {priced && ( - - - - )} - - {priced && ( - - {input && {input}} - {output && {output}} - - )} - + models.onColumnFiltersChange([])}> + ({ value: provider, label: providerName(provider), provider }))} + values={models.providerValues} + onChange={models.onProvidersChange} + /> + ({ value: feature, label: featureFacetLabel(feature) }))} + values={models.featureValues} + onChange={models.onFeaturesChange} + /> + displayMode(mode) ?? mode))].map((mode) => ({ + value: mode, + label: modeLabel(mode), + }))} + values={models.modeValues} + onChange={models.onModesChange} + /> + + ); +} + +function ModelLink({ + model, + selected, + onSelect, +}: { + model: ModelGroupInfo; + selected: string | null; + onSelect: (key: string | null) => void; +}) { + const hrefFor = useOpenHref(); + const follow = (event: MouseEvent) => { + if (event.button !== 0 || event.defaultPrevented) return; + if (event.metaKey || event.ctrlKey) return; + if (event.shiftKey || event.altKey) return; + event.preventDefault(); + onSelect(model.model_group); + }; + return ( + + + + {model.model_group} + + {(model.providers ?? []).map(providerName).join(", ")} + + + ); } @@ -221,35 +199,122 @@ export function ModelsPane({ const { t } = useTranslation(); const { models } = hub; const opened = useOpenedModel(selected, models.rows, !models.isLoading); + const detailsLoading = opened.model === undefined && (models.isLoading || opened.pending); + const missingName = opened.model === undefined && !detailsLoading ? selected : null; + const countOptions = { + count: models.rowCount, + defaultValue_one: "{{count}} model", + defaultValue_other: "{{count}} models", + defaultValue: "{{count}} models", + }; + const columns: ColumnDef[] = [ + { + accessorKey: "model_group", + meta: { skeleton: "twoLine" }, + sortDescFirst: false, + header: ({ column }) => ( + + ), + cell: ({ row }) => , + }, + { + accessorKey: "max_input_tokens", + meta: { numeric: true, className: "hidden sm:table-cell" }, + size: 140, + sortDescFirst: true, + header: ({ column }) => ( + + ), + cell: ({ row }) => (row.original.max_input_tokens === 0 ? "0" : formatTokens(row.original.max_input_tokens)), + }, + { + accessorKey: "input_cost_per_token", + meta: { numeric: true }, + size: 130, + sortDescFirst: false, + header: ({ column }) => ( + + ), + cell: ({ row }) => formatPerMillion(row.original.input_cost_per_token), + }, + { + accessorKey: "output_cost_per_token", + meta: { numeric: true }, + size: 130, + sortDescFirst: false, + header: ({ column }) => ( + + ), + cell: ({ row }) => formatPerMillion(row.original.output_cost_per_token), + }, + ]; const clear = () => { models.onSearchChange(""); models.onColumnFiltersChange([]); }; return ( - model.model_group} - onSelect={onSelect} - empty={ - models.error ? ( - - ) : ( - } - noun={t("publicModelHub.modelsPane.noun")} - query={models.searchValue} - onClear={models.hasActiveQuery ? clear : undefined} +
+ +
+
+ - ) - } - toolbar={} - renderItem={(model) => } - renderDetail={(model) => } - footer={} - /> + +
+
+ {models.isFetching || models.isLoading + ? t("publicModelHub.catalog.loadingModels", { defaultValue: "Loading models…" }) + : !models.error && t("publicModelHub.catalog.modelCount", countOptions)} +
+ {models.error && models.rows.length > 0 && ( + + )} +
+ model.model_group} + sortingMode="server" + sorting={models.sorting} + onSortingChange={models.onSortingChange} + paginationMode="server" + pagination={models.pagination} + onPaginationChange={models.onPaginationChange} + rowCount={models.rowCount} + filterMode="server" + columnFilters={models.columnFilters} + onColumnFiltersChange={models.onColumnFiltersChange} + isLoading={models.isLoading} + isError={Boolean(models.error)} + errorMessage={t("publicModelHub.loadFailed.title", { noun: t("publicModelHub.modelsPane.noun") })} + onRetry={models.refetch} + noDataMessage={ + } + noun={t("publicModelHub.modelsPane.noun")} + query={models.searchValue} + onClear={models.hasActiveQuery ? clear : undefined} + /> + } + /> +
+
+ onSelect(null)}> + {opened.model && } + {missingName !== null && } + +
); } diff --git a/ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.integration.test.tsx b/ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.integration.test.tsx index bcddc1fe364..3fedeead150 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.integration.test.tsx @@ -1,13 +1,19 @@ import { fireEvent, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import type { ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { renderWithProviders, screen, testQueryClient, waitFor } from "../../../tests/test-utils"; +import type { Plugin } from "@/components/claude_code_plugins/types"; +import type { apiClient } from "@/components/networking"; + +import { chooseSelectOption, renderWithProviders, screen, testQueryClient, waitFor } from "../../../tests/test-utils"; import PublicAiHub from "./PublicAiHub"; const { apiGetMock, realGet, navigation, uiSettings, replaceMock, hubInfo } = vi.hoisted(() => ({ apiGetMock: vi.fn(), - realGet: { current: (..._args: unknown[]): Promise => Promise.reject(new Error("unset")) }, + realGet: { + current: (..._args: Parameters): Promise => Promise.reject(new Error("unset")), + }, navigation: { params: new URLSearchParams() }, uiSettings: { values: {} as Record }, replaceMock: vi.fn(), @@ -25,7 +31,13 @@ vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }), })); -vi.mock("@/components/navbar", () => ({ default: () =>