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: () =>