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..493578b4f16 --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/AgentInspector.tsx @@ -0,0 +1,77 @@ +"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) => + `${(agent.defaultInputModes ?? []).join(", ")} / ${(agent.defaultOutputModes ?? []).join(", ")}`; + +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..bcf142f1fa6 --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/HubEmpty.tsx @@ -0,0 +1,59 @@ +"use client"; + +import { LayoutGrid } 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 + + + ); +} 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..527953052be --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/HubHeader.tsx @@ -0,0 +1,107 @@ +"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"; + +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: "loading" | "operational" | "unavailable"; +}) { + 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..b21a16c5a86 --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/HubMasterDetail.tsx @@ -0,0 +1,204 @@ +"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 { 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)} + +
+ ); + })} +
+ ); +} + +export function HubMasterDetail({ + switcher, + toolbar, + rows, + loading, + selectedKey, + getKey, + onSelect, + renderItem, + renderDetail, + empty, + footer, +}: { + switcher?: React.ReactNode; + toolbar: React.ReactNode; + rows: T[]; + loading: boolean; + selectedKey: string | null; + 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 = rows.find((row) => getKey(row) === selectedKey); + const selected = chosen ?? rows[0]; + const [sheetRow, setSheetRow] = React.useState(chosen); + if (chosen !== undefined && chosen !== sheetRow) setSheetRow(chosen); + const isEmpty = !loading && rows.length === 0; + const list = isEmpty ? ( + empty + ) : ( + <> + + {footer} + + ); + return ( +
+
+
+ {switcher} +
{toolbar}
+
+ {loading ? : list} +
+ {!isEmpty && ( + + {loading ? : selected && renderDetail(selected)} + + )} + onSelect(null)} + > + {sheetRow && renderDetail(sheetRow)} + +
+ ); +} + +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..b20d6273551 --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/McpInspector.tsx @@ -0,0 +1,33 @@ +"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..1130b3d625e --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/ModelInspector.tsx @@ -0,0 +1,160 @@ +"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 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={ + model.description || `${modeLabel(model.mode)} model from ${providers.map(providerName).join(", ")}` + } + /> + {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..91818b28ad5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/ModelsPane.tsx @@ -0,0 +1,222 @@ +"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 { FilterChip } from "./FilterChip"; +import { featureFacetLabel, firstPlus, formatPerMillion, formatTokens, modeLabel, providerName } from "./hubFormat"; +import { HubMasterDetail } from "./HubMasterDetail"; +import { NoResults } from "./HubEmpty"; +import { Joined, ProviderAvatar, RowTitle, SearchInput } from "./HubParts"; +import type { HubData } 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} + /> + ({ value: m, label: modeLabel(m) }))} + value={models.modeValues} + onChange={models.onModesChange} + /> + ({ value: f, label: featureFacetLabel(f) }))} + value={models.featureValues} + onChange={models.onFeaturesChange} + /> +
+ + ); +} + +const prices = (model: ModelGroupInfo) => [ + model.input_cost_per_token ? `${formatPerMillion(model.input_cost_per_token)} / 1M in` : null, + model.output_cost_per_token ? `${formatPerMillion(model.output_cost_per_token)} / 1M out` : null, +]; + +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 clear = () => { + models.onSearchChange(""); + models.onColumnFiltersChange([]); + }; + return ( + model.model_group} + onSelect={onSelect} + empty={ + } + 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..3f873eb8b59 --- /dev/null +++ b/ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.integration.test.tsx @@ -0,0 +1,169 @@ +import { fireEvent } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +import { renderWithProviders, screen, testQueryClient, waitFor } from "../../../tests/test-utils"; +import PublicAiHub from "./PublicAiHub"; + +const { apiGetMock, uiSettings, replaceMock, hubInfo } = vi.hoisted(() => ({ + apiGetMock: vi.fn(), + 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: () => new URLSearchParams(), + usePathname: () => "/model_hub_table", + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }), +})); + +vi.mock("@/components/navbar", () => ({ default: () =>