From cbe361d97079c73fe5b2800a4f215eb6057c857d Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 3 Nov 2026 18:23:01 -0800 Subject: [PATCH] feat(ui): rebuild the public AI Hub as a searchable list with a details pane (#49496) * feat(ui): rebuild the public AI Hub as a searchable list with a details pane The public AI Hub at /ui/model_hub_table (and the older /ui/model_hub) now renders PublicAiHub, built from shadcn parts only: a header with the gateway's base URL and the admin's links, segmented tabs for models, agents, MCP servers and skills, and a list beside a details pane on desktop that becomes a bottom sheet on phones. The open item and tab live in the URL. It keeps every feature the old page had: server-side search, provider, mode and feature filters, sorting, paging, health, rate limits, prices, custom metadata, supported params, usage snippets, the require-auth redirect and the not-enabled state. cURL shows only for chat models, since the old one called chat completions for every mode. The navbar hides the community buttons below the sm breakpoint on every page, and the public navbar uses a smaller logo on phones, so Docs and Blog no longer collide with them. * fix(ui): harden the public AI Hub after adversarial review - Show "This AI Hub isn't available" only when every hub call fails; a failed models call now shows an inline error with Try again and leaves agents, MCP servers and skills usable (a gateway with no models answers the models list with 400) - Row links keep the page's other query params, so open-in-new-tab and copy link no longer 404 - A shared ?open= link resolves models beyond the first page, keeps the opened item when a search filters it out, and says when it is no longer listed instead of showing a different item - Searching, sorting and paging no longer blank the details pane or drop the Operational badge - Key MCP servers by their listed name, which is never null - Keep sub-cent prices and show Free for zero cost, fold evaluation into Decisions, sort agents, MCP servers and skills by name, restore word-wise search, and show a skill's source, author and date added - Drop the invented description for models without a mode, the empty agent modes row, the stray auth dash on MCP rows and NaN dates - Do not retry the UI config call, and close the 1023-1024px gap between the sheet and the details pane - Update the public hub Playwright spec for the new tabs and list, and prune the lint suppressions the old route pages needed * fix(ui): surface hub load errors and keep the open item steady Agents, MCP servers and skills now load through apiClient so an HTTP error shows the inline error instead of an empty "published yet" state, and an all-failing gateway reaches the unavailable page. Try again shows loading instead of an empty hub, the open model stays in the details pane when a search or page change hides its row, a zero-result search keeps the pane, phones get the not-listed sheet for a stale link, an empty open param means nothing opened, free models read Free in the list, and an invalid skill date is hidden --- tests/e2e/ui/tests/modelHub/modelHub.spec.ts | 29 +- ui/litellm-dashboard/eslint-suppressions.json | 10 - .../src/app/model_hub/page.tsx | 24 +- .../src/app/model_hub_table/page.tsx | 33 +- .../src/components/navbar.tsx | 11 +- .../publicModelHub/AgentInspector.tsx | 80 ++++ .../components/publicModelHub/FilterChip.tsx | 91 +++++ .../components/publicModelHub/HubEmpty.tsx | 92 +++++ .../components/publicModelHub/HubHeader.tsx | 103 ++++++ .../publicModelHub/HubMasterDetail.tsx | 234 ++++++++++++ .../components/publicModelHub/HubParts.tsx | 187 ++++++++++ .../components/publicModelHub/HubStatus.tsx | 49 +++ .../publicModelHub/InspectorHeader.tsx | 49 +++ .../publicModelHub/McpInspector.tsx | 29 ++ .../publicModelHub/ModelInspector.tsx | 164 +++++++++ .../components/publicModelHub/ModelsPane.tsx | 238 ++++++++++++ .../PublicAiHub.integration.test.tsx | 342 ++++++++++++++++++ .../components/publicModelHub/PublicAiHub.tsx | 113 ++++++ .../publicModelHub/ResourcePanes.tsx | 184 ++++++++++ .../publicModelHub/SkillInspector.tsx | 66 ++++ .../publicModelHub/hubFormat.test.ts | 138 +++++++ .../components/publicModelHub/hubFormat.ts | 142 ++++++++ .../publicModelHub/hubSnippets.test.ts | 59 +++ .../components/publicModelHub/hubSnippets.ts | 93 +++++ .../components/publicModelHub/useHubData.ts | 88 +++++ .../publicModelHub/useHubUrlState.ts | 32 ++ .../components/publicModelHub/useIsNarrow.ts | 16 + .../publicModelHub/useRequirePublicHubAuth.ts | 19 + 28 files changed, 2645 insertions(+), 70 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/AgentInspector.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/FilterChip.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/HubEmpty.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/HubHeader.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/HubMasterDetail.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/HubParts.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/HubStatus.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/InspectorHeader.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/McpInspector.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/ModelInspector.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/ModelsPane.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.integration.test.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/ResourcePanes.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/SkillInspector.tsx create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/hubFormat.test.ts create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/hubFormat.ts create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/hubSnippets.test.ts create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/hubSnippets.ts create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/useHubData.ts create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/useHubUrlState.ts create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/useIsNarrow.ts create mode 100644 ui/litellm-dashboard/src/components/publicModelHub/useRequirePublicHubAuth.ts diff --git a/tests/e2e/ui/tests/modelHub/modelHub.spec.ts b/tests/e2e/ui/tests/modelHub/modelHub.spec.ts index 04b02d9ccc4..f8ce4faf45e 100644 --- a/tests/e2e/ui/tests/modelHub/modelHub.spec.ts +++ b/tests/e2e/ui/tests/modelHub/modelHub.spec.ts @@ -64,7 +64,7 @@ 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 Model Hub tab", async ({ page }) => { + 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. @@ -74,13 +74,10 @@ test.describe("Public model hub (/ui/model_hub_table)", () => { // race can't briefly mask the tab while we're evaluating visibility. await dismissFeedbackPopup(page); - // Page loads (no auth redirect) and the Model Hub tab is always present. - // Agent Hub and MCP Hub tabs are conditionally rendered only when public - // agents/MCP servers exist, so we don't assert on them in a fresh CI run. - await expect(page.getByRole("tab", { name: "Model Hub" })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole("tab", { name: /^Models/ })).toBeVisible({ timeout: 10_000 }); }); - test("Agent Hub and MCP Hub tabs render their public entries", async ({ page, request }) => { + 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}`; @@ -145,18 +142,18 @@ test.describe("Public model hub (/ui/model_hub_table)", () => { await page.goto(`/ui/model_hub_table?key=${masterKey()}`); await dismissFeedbackPopup(page); - const agentHubTab = page.getByRole("tab", { name: "Agent Hub" }); - await expect(agentHubTab).toBeVisible({ timeout: 15_000 }); - await agentHubTab.click(); - await expect(page.getByText("Available Agents")).toBeVisible({ timeout: 10_000 }); - await expect(page.getByRole("row").filter({ hasText: agentName })).toHaveCount(1, { timeout: 10_000 }); + 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 mcpHubTab = page.getByRole("tab", { name: "MCP Hub" }); - await expect(mcpHubTab).toBeVisible(); - await mcpHubTab.click(); - await expect(page.getByText("Available MCP Servers")).toBeVisible({ timeout: 10_000 }); - await expect(page.getByRole("row").filter({ hasText: mcpServerName })).toHaveCount(1, { timeout: 10_000 }); + 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(); } finally { await request.post("/v1/mcp/make_public", { headers: auth, data: { mcp_server_ids: priorMcpIds } }); diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index 0cadc13a213..3d30964129d 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1153,16 +1153,6 @@ "count": 2 } }, - "src/app/model_hub/page.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/model_hub_table/page.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, "src/components/AIHub/ModelHubTable.test.tsx": { "max-params": { "count": 1 diff --git a/ui/litellm-dashboard/src/app/model_hub/page.tsx b/ui/litellm-dashboard/src/app/model_hub/page.tsx index df6228f3b36..60a2f054dff 100644 --- a/ui/litellm-dashboard/src/app/model_hub/page.tsx +++ b/ui/litellm-dashboard/src/app/model_hub/page.tsx @@ -1,27 +1,19 @@ "use client"; -import React, { Suspense, useEffect, useState } from "react"; + import { useSearchParams } from "next/navigation"; -import PublicModelHubPage from "@/components/public_model_hub"; +import { Suspense } from "react"; -function PublicModelHubContent() { - const searchParams = useSearchParams()!; - const key = searchParams.get("key"); - const [accessToken, setAccessToken] = useState(null); +import PublicAiHub from "@/components/publicModelHub/PublicAiHub"; - useEffect(() => { - if (!key) { - return; - } - setAccessToken(key); - }, [key]); - - return ; +function PublicAiHubPage() { + const accessToken = useSearchParams()?.get("key") ?? null; + return ; } export default function PublicModelHub() { return ( - Loading...}> - + Loading...}> + ); } diff --git a/ui/litellm-dashboard/src/app/model_hub_table/page.tsx b/ui/litellm-dashboard/src/app/model_hub_table/page.tsx index af6764dcd4c..99df5ed8cfd 100644 --- a/ui/litellm-dashboard/src/app/model_hub_table/page.tsx +++ b/ui/litellm-dashboard/src/app/model_hub_table/page.tsx @@ -1,36 +1,19 @@ "use client"; -import React, { Suspense, useEffect, useState } from "react"; + import { useSearchParams } from "next/navigation"; -import ModelHubTable from "@/components/AIHub/ModelHubTable"; +import { Suspense } from "react"; -function PublicModelHubTableContent() { - const searchParams = useSearchParams()!; - const key = searchParams.get("key"); - const [accessToken, setAccessToken] = useState(null); +import PublicAiHub from "@/components/publicModelHub/PublicAiHub"; - useEffect(() => { - if (!key) { - return; - } - setAccessToken(key); - }, [key]); - - return ( - - ); +function PublicAiHubPage() { + const accessToken = useSearchParams()?.get("key") ?? null; + return ; } export default function PublicModelHubTable() { return ( - Loading...}> - + Loading...}> + ); } diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index cece15663ec..c3f31644c8a 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -61,7 +61,7 @@ const Navbar: React.FC = ({ return ( {!hideCommunityLinks && ( -
+
)} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/AgentInspector.tsx b/ui/litellm-dashboard/src/components/publicModelHub/AgentInspector.tsx new file mode 100644 index 00000000000..1a35dbf0083 --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/AgentInspector.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { ExternalLink } from "lucide-react"; + +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 capabilities = agentCapabilities(agent); + return ( +
+ + 0 && }, + ], + ]} + /> + + {agent.documentationUrl && ( + + )} + {(agent.skills ?? []).length > 0 && ( + + + {agent.skills.map((skill) => ( + + + {skill.name} + {skill.description} + + + + + + ))} + + + )} + + {agentSnippets(agent).map((snippet, index) => ( + + + Step {index + 1}: {snippet.title} + + + + ))} + +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/FilterChip.tsx b/ui/litellm-dashboard/src/components/publicModelHub/FilterChip.tsx new file mode 100644 index 00000000000..c25a1619845 --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/FilterChip.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { CirclePlus, X } from "lucide-react"; + +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 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)}} + + + + No {noun} found + + {(option: FilterOption) => ( + + {option.provider && } + {option.label} + + )} + + + + {active && ( + + )} + + ); +} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/HubEmpty.tsx b/ui/litellm-dashboard/src/components/publicModelHub/HubEmpty.tsx new file mode 100644 index 00000000000..eac852f2ca9 --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/HubEmpty.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { LayoutGrid, SearchX, TriangleAlert } from "lucide-react"; +import * as React from "react"; + +import { Button } from "@/components/ui/button"; +import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"; + +import { SECTION_TITLE } from "./hubFormat"; + +const noResultsTitle = (noun: string, query: string, filtered: boolean) => { + if (!filtered) return `No ${noun} published yet`; + return query ? `No ${noun} match "${query}"` : `No ${noun} match these filters`; +}; + +export function NoResults({ + icon, + noun, + query, + onClear, +}: { + icon: React.ReactNode; + noun: string; + query: string; + onClear?: () => void; +}) { + return ( + + + {icon} + {noResultsTitle(noun, query, Boolean(onClear))} + + {onClear ? "Try a shorter search or fewer filters" : "Your admin publishes them from the AI Hub page"} + + + {onClear && ( + + + + )} + + ); +} + +export function HubNotEnabled() { + return ( + + + + + + This AI Hub isn't available + Ask your proxy admin to make it public from their Admin UI + + + ); +} + +export function LoadFailed({ noun, onRetry }: { noun: string; onRetry: () => void }) { + return ( + + + + + + Couldn't load {noun} + The gateway returned an error. Try again in a moment + + + + + + ); +} + +export function NotListed({ name }: { name: string }) { + return ( + + + + + + “{name}” isn't listed here + It may have been renamed or unpublished + + + ); +} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/HubHeader.tsx b/ui/litellm-dashboard/src/components/publicModelHub/HubHeader.tsx new file mode 100644 index 00000000000..ca8df489cfa --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/HubHeader.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { ChevronDown, ExternalLink, LayoutGrid } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Field, FieldDescription } from "@/components/ui/field"; +import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group"; + +import { gatewayUrl, usefulLinkList } from "./hubFormat"; +import { CopyIconButton } from "./HubParts"; +import { StatusBadge } from "./HubStatus"; +import type { HubInfo } from "./useHubData"; + +export type HubStatus = "loading" | "operational" | "degraded" | "unavailable"; + +function LinksMenu({ info }: { info: HubInfo | undefined }) { + const links = usefulLinkList(info?.useful_links); + if (links.length === 0) return null; + return ( + + }> + Links + + + + {links.map((link) => ( + }> + + {link.title} + + ))} + {info?.litellm_version && ( + <> + + + LiteLLM v{info.litellm_version} + + + )} + + + ); +} + +function BaseUrlField() { + const url = gatewayUrl(); + return ( + + + + Base URL + + + + + + + + Use your LiteLLM key as the API key in any OpenAI-compatible SDK + + + ); +} + +export function HubHeader({ info, status }: { info: HubInfo | undefined; status: HubStatus }) { + return ( +
+
+
+

+ + AI Hub +

+ {status === "operational" && } +
+

+ {info?.custom_docs_description || "Models, agents, MCP servers and skills you can call through this gateway"} +

+
+ {status !== "unavailable" && ( +
+ + +
+ )} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/HubMasterDetail.tsx b/ui/litellm-dashboard/src/components/publicModelHub/HubMasterDetail.tsx new file mode 100644 index 00000000000..63153c63c76 --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/HubMasterDetail.tsx @@ -0,0 +1,234 @@ +"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 { 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 ?? "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/HubParts.tsx b/ui/litellm-dashboard/src/components/publicModelHub/HubParts.tsx new file mode 100644 index 00000000000..e325bd42eed --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/HubParts.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { Copy, Search, X } from "lucide-react"; +import * as React from "react"; + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Field, FieldLabel } from "@/components/ui/field"; +import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/components/ui/input-group"; +import { ItemTitle } from "@/components/ui/item"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/cva.config"; +import { copyToClipboard } from "@/utils/dataUtils"; + +import { providerLogo, providerName, SECTION_TITLE } from "./hubFormat"; + +export function CopyIconButton({ value, label, className }: { value: string; label: string; className?: string }) { + return ( + + void copyToClipboard(value, "Copied")} + /> + } + > + + + {label} + + ); +} + +export function CodeBlock({ code, label = "Copy code" }: { code: string; label?: string }) { + return ( +
+ +
+        {code}
+      
+
+ ); +} + +export function ProviderAvatar({ provider, className }: { provider: string; className?: string }) { + const logo = providerLogo(provider); + return ( + + {logo && } + {providerName(provider).charAt(0)} + + ); +} + +export function ProviderName({ provider }: { provider: string }) { + return ( + + + {providerName(provider)} + + ); +} + +export function Badges({ items, mono }: { items: string[]; mono?: boolean }) { + if (items.length === 0) return null; + return ( +
+ {items.map((item) => ( + + {item} + + ))} +
+ ); +} + +export function PaneSection({ + title, + aside, + children, +}: { + title: string; + aside?: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {aside} +
+ {children} +
+ ); +} + +export type Fact = { label: string; value: React.ReactNode; wide?: boolean }; + +const BLANK: React.ReactNode[] = [null, undefined, false, "", "-"]; + +const present = (value: React.ReactNode) => !BLANK.includes(value); + +export function Facts({ groups }: { groups: Fact[][] }) { + const shown = groups.map((group) => group.filter((fact) => present(fact.value))).filter((group) => group.length); + if (shown.length === 0) return null; + return ( +
+ {shown.map((group) => ( +
+ {group.map((fact) => ( +
+
{fact.label}
+
{fact.value}
+
+ ))} +
+ ))} +
+ ); +} + +export function EndpointField({ id, value }: { id: string; value: string }) { + return ( + + Endpoint + + + + + + + + ); +} + +export function SearchInput({ + value, + onChange, + placeholder, +}: { + value: string; + onChange: (value: string) => void; + placeholder: string; +}) { + return ( + + + + + onChange(event.target.value)} + placeholder={placeholder} + aria-label={placeholder} + /> + {value && ( + + onChange("")}> + + + + )} + + ); +} + +export function RowTitle({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +export function Joined({ parts }: { parts: (string | null | undefined)[] }) { + return parts.filter(Boolean).map((part, index) => ( + + {index > 0 && " · "} + {part} + + )); +} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/HubStatus.tsx b/ui/litellm-dashboard/src/components/publicModelHub/HubStatus.tsx new file mode 100644 index 00000000000..cae5211f3ab --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/HubStatus.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { CircleCheck, CircleDashed, CircleX } from "lucide-react"; + +import type { ModelGroupInfo } from "@/components/PublicModelHubTableColumns"; +import { Badge } from "@/components/ui/badge"; +import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card"; +import { cn } from "@/lib/cva.config"; + +import { healthOf, type HealthTone, relativeTime } from "./hubFormat"; + +const TONE_ICON = { success: CircleCheck, error: CircleX, neutral: CircleDashed } as const; +const TONE_CLASS = { + success: "border-success/25 bg-success/10 text-success", + error: "border-destructive/25 bg-destructive/10 text-destructive", + neutral: "text-muted-foreground", +} as const; + +export function StatusBadge({ tone, label }: { tone: HealthTone; label: string }) { + const Icon = TONE_ICON[tone]; + return ( + + + {label} + + ); +} + +export function HealthBadge({ model }: { model: ModelGroupInfo }) { + const badge = ; + if (!model.health_status) return badge; + return ( + + }> + {badge} + + +
+
Response time
+
+ {model.health_response_time ? `${Math.round(model.health_response_time)} ms` : "-"} +
+
Last checked
+
{relativeTime(model.health_checked_at)}
+
+
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/InspectorHeader.tsx b/ui/litellm-dashboard/src/components/publicModelHub/InspectorHeader.tsx new file mode 100644 index 00000000000..4400b18ce1f --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/InspectorHeader.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { X } from "lucide-react"; +import * as React from "react"; + +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, + copyLabel, + status, + description, +}: { + media?: React.ReactNode; + title: string; + copyLabel: string; + status?: React.ReactNode; + description?: React.ReactNode; +}) { + const inSheet = React.useContext(InSheet); + return ( +
+
+ {media} +

{title}

+ + + {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 new file mode 100644 index 00000000000..73b6de1ed72 --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/McpInspector.tsx @@ -0,0 +1,29 @@ +"use client"; + +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 }) { + return ( +
+ + + + + + +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/ModelInspector.tsx b/ui/litellm-dashboard/src/components/publicModelHub/ModelInspector.tsx new file mode 100644 index 00000000000..87f22d81655 --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/ModelInspector.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { ChevronDown } from "lucide-react"; +import * as React from "react"; + +import type { ModelGroupInfo } from "@/components/PublicModelHubTableColumns"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +import { + formatPerMillion, + formatTokens, + modeLabel, + modelCapabilities, + providerName, + wildcardExample, +} from "./hubFormat"; +import { Badges, CodeBlock, type Fact, Facts, PaneSection, ProviderAvatar, ProviderName } from "./HubParts"; +import { curlSnippet, modelSnippet } from "./hubSnippets"; +import { HealthBadge } from "./HubStatus"; +import { InspectorHeader } from "./InspectorHeader"; + +const summary = (model: ModelGroupInfo): string | undefined => { + if (model.description) return model.description; + const providers = (model.providers ?? []).map(providerName).join(", "); + return model.mode && providers ? `${modeLabel(model.mode)} model from ${providers}` : undefined; +}; + +const tokens = (value: number | undefined) => (value ? `${formatTokens(value)} tokens` : null); + +const modelFacts = (model: ModelGroupInfo): Fact[][] => { + const providers = model.providers ?? []; + const tags = model.custom_tags ?? []; + return [ + [ + { label: "Mode", value: model.mode ? modeLabel(model.mode) : null }, + { label: "Context", value: tokens(model.max_input_tokens) }, + { label: "Max output", value: tokens(model.max_output_tokens) }, + ], + [ + { label: "Input / 1M tokens", value: formatPerMillion(model.input_cost_per_token) }, + { label: "Output / 1M tokens", value: formatPerMillion(model.output_cost_per_token) }, + ], + [ + { label: "Requests per minute", value: model.rpm }, + { label: "Tokens per minute", value: model.tpm ? formatTokens(model.tpm) : null }, + ], + [ + { + label: providers.length > 1 ? "Providers" : "Provider", + wide: true, + value: providers.length > 0 && ( + + {providers.map((provider) => ( + + ))} + + ), + }, + { label: "Tags", wide: true, value: tags.length > 0 && }, + ], + ]; +}; + +function CallThisModel({ model }: { model: ModelGroupInfo }) { + const curl = curlSnippet(model); + if (!curl) { + return ( + + + + ); + } + return ( + + + + Python + cURL + + + + + + + + + + ); +} + +function CustomMetadata({ model }: { model: ModelGroupInfo }) { + const metadata = Object.entries(model.custom_metadata ?? {}); + if (metadata.length === 0) return null; + return ( + +
+ {metadata.map(([key, value]) => ( + +
{key}
+
{String(value)}
+
+ ))} +
+
+ ); +} + +function SupportedParams({ params }: { params: string[] }) { + if (params.length === 0) return null; + return ( + + }> + Show {params.length} + + + } + > + + + + + + ); +} + +export function ModelInspector({ model }: { model: ModelGroupInfo }) { + const providers = model.providers ?? []; + const capabilities = modelCapabilities(model); + return ( +
+ } + title={model.model_group} + copyLabel="Copy model name" + status={} + description={summary(model)} + /> + {model.model_group.includes("*") && ( + + Wildcard model + + Call it as {wildcardExample(model.model_group)}, with any model name this + provider offers + + + )} + + {capabilities.length > 0 && ( + + + + )} + + + +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/ModelsPane.tsx b/ui/litellm-dashboard/src/components/publicModelHub/ModelsPane.tsx new file mode 100644 index 00000000000..1de09e554c1 --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/ModelsPane.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { ArrowDownWideNarrow, ArrowUpNarrowWide, Boxes, ChevronLeft, ChevronRight } from "lucide-react"; + +import type { ModelGroupInfo } from "@/components/PublicModelHubTableColumns"; +import { Button } from "@/components/ui/button"; +import { ButtonGroup } from "@/components/ui/button-group"; +import { ItemActions, ItemContent, ItemDescription, ItemMedia } from "@/components/ui/item"; +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 { 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 { type HubData, useOpenedModel } from "./useHubData"; +import { ModelInspector } from "./ModelInspector"; + +const SORTS = [ + { value: "model_group", label: "Name", desc: false }, + { value: "providers", label: "Provider", desc: false }, + { value: "mode", label: "Mode", desc: false }, + { value: "input_cost_per_token", label: "Input price", desc: false }, + { value: "output_cost_per_token", label: "Output price", desc: false }, + { value: "max_input_tokens", label: "Context", desc: true }, + { value: "max_output_tokens", label: "Max output", desc: true }, + { value: "rpm", label: "Rate limit", desc: true }, +]; + +type Models = HubData["models"]; + +function ModelsPager({ models }: { models: Models }) { + 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 ( +
+ + {start}–{end} of {rowCount} + + + + + +
+ ); +} + +function SortControl({ models }: { models: Models }) { + const field = models.sorting[0]?.id ?? "model_group"; + const desc = models.sorting[0]?.desc ?? false; + const pick = (value: string | null) => { + const sort = SORTS.find((option) => option.value === value) ?? SORTS[0]; + models.onSortingChange([{ id: sort.value, desc: sort.desc }]); + }; + return ( + + + + models.onSortingChange([{ id: field, desc: pressed }])} + className="dark:bg-input/30 dark:hover:bg-input/50" + /> + } + > + {desc ? : } + + Sort descending + + + ); +} + +function ModelsToolbar({ hub }: { hub: HubData }) { + const { models, facets } = hub; + return ( + <> +
+ + +
+
+ ({ 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 `Free ${direction}`; + return cost ? `${formatPerMillion(cost)} / 1M ${direction === "input" ? "in" : "out"}` : null; +}; + +const prices = (model: ModelGroupInfo) => + model.input_cost_per_token === 0 && model.output_cost_per_token === 0 + ? ["Free", null] + : [price(model.input_cost_per_token, "input"), price(model.output_cost_per_token, "output")]; + +function ModelRow({ model }: { model: ModelGroupInfo }) { + const [input, output] = prices(model); + const priced = Boolean(input || output); + return ( + <> + + + + + {model.model_group} + + + + {priced && ( + + + + )} + + {priced && ( + + {input && {input}} + {output && {output}} + + )} + + ); +} + +export function ModelsPane({ + hub, + selected, + onSelect, +}: { + hub: HubData; + selected: string | null; + onSelect: (key: string | null) => void; +}) { + const { models } = hub; + const opened = useOpenedModel(selected, models.rows, !models.isLoading); + const clear = () => { + models.onSearchChange(""); + models.onColumnFiltersChange([]); + }; + return ( + model.model_group} + onSelect={onSelect} + empty={ + models.error ? ( + + ) : ( + } + noun="models" + query={models.searchValue} + onClear={models.hasActiveQuery ? clear : undefined} + /> + ) + } + toolbar={} + renderItem={(model) => } + renderDetail={(model) => } + footer={} + /> + ); +} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.integration.test.tsx b/ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.integration.test.tsx new file mode 100644 index 00000000000..bcddc1fe364 --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.integration.test.tsx @@ -0,0 +1,342 @@ +import { fireEvent, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { 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")) }, + navigation: { params: new URLSearchParams() }, + uiSettings: { values: {} as Record }, + replaceMock: vi.fn(), + hubInfo: { + docs_title: "", + custom_docs_description: null, + litellm_version: "1.90.0", + useful_links: { "Getting started": { url: "https://docs.example.com", index: 0 } }, + }, +})); + +vi.mock("next/navigation", () => ({ + useSearchParams: () => navigation.params, + usePathname: () => "/model_hub_table", + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }), +})); + +vi.mock("@/components/navbar", () => ({ default: () =>