mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
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.
This commit is contained in:
parent
d2321581ec
commit
bd546f1dac
26 changed files with 2262 additions and 44 deletions
|
|
@ -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<string | null>(null);
|
||||
import PublicAiHub from "@/components/publicModelHub/PublicAiHub";
|
||||
|
||||
useEffect(() => {
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
setAccessToken(key);
|
||||
}, [key]);
|
||||
|
||||
return <PublicModelHubPage accessToken={accessToken} />;
|
||||
function PublicAiHubPage() {
|
||||
const accessToken = useSearchParams()?.get("key") ?? null;
|
||||
return <PublicAiHub accessToken={accessToken} />;
|
||||
}
|
||||
|
||||
export default function PublicModelHub() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex items-center justify-center min-h-screen">Loading...</div>}>
|
||||
<PublicModelHubContent />
|
||||
<Suspense fallback={<div className="flex min-h-screen items-center justify-center">Loading...</div>}>
|
||||
<PublicAiHubPage />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string | null>(null);
|
||||
import PublicAiHub from "@/components/publicModelHub/PublicAiHub";
|
||||
|
||||
useEffect(() => {
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
setAccessToken(key);
|
||||
}, [key]);
|
||||
|
||||
return (
|
||||
<ModelHubTable
|
||||
accessToken={accessToken}
|
||||
publicPage={true}
|
||||
premiumUser={false}
|
||||
userRole={null}
|
||||
canOpenPlayground={false}
|
||||
canAddModels={false}
|
||||
/>
|
||||
);
|
||||
function PublicAiHubPage() {
|
||||
const accessToken = useSearchParams()?.get("key") ?? null;
|
||||
return <PublicAiHub accessToken={accessToken} />;
|
||||
}
|
||||
|
||||
export default function PublicModelHubTable() {
|
||||
return (
|
||||
<Suspense fallback={<div className="flex items-center justify-center min-h-screen">Loading...</div>}>
|
||||
<PublicModelHubTableContent />
|
||||
<Suspense fallback={<div className="flex min-h-screen items-center justify-center">Loading...</div>}>
|
||||
<PublicAiHubPage />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
return (
|
||||
<nav className="sticky top-0 z-chrome border-b border-border bg-card">
|
||||
<div className="w-full">
|
||||
<div className="flex h-14 items-center px-4">
|
||||
<div className={cn("flex h-14 items-center px-4", isPublicPage && "gap-4 sm:px-6")}>
|
||||
<div className="flex shrink-0 items-center">
|
||||
{onToggleSidebar && (
|
||||
<button
|
||||
|
|
@ -82,7 +82,12 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
<div className="flex items-center gap-2">
|
||||
<Link href={uiHref("")} className="flex items-center">
|
||||
<div className="relative">
|
||||
<div className="flex h-10 max-w-48 items-center justify-center overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 max-w-48 items-center justify-center overflow-hidden",
|
||||
isPublicPage && "h-8 max-w-36 sm:h-10 sm:max-w-48",
|
||||
)}
|
||||
>
|
||||
<img src={imageUrl} alt="LiteLLM Brand" className={cn(NAV_LOGO_CLASS_NAME, "dark:hidden")} />
|
||||
<img
|
||||
src={darkImageUrl}
|
||||
|
|
@ -141,7 +146,7 @@ const Navbar: React.FC<NavbarProps> = ({
|
|||
</nav>
|
||||
|
||||
{!hideCommunityLinks && (
|
||||
<div className="flex shrink-0 items-center border-l border-border pl-4">
|
||||
<div className="hidden shrink-0 items-center border-l border-border pl-4 sm:flex">
|
||||
<CommunityEngagementButtons />
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<InspectorHeader title={agent.name} copyLabel="Copy agent name" description={agent.description} />
|
||||
<Facts
|
||||
groups={[
|
||||
[
|
||||
{ label: "Version", value: agent.version },
|
||||
{ label: "Owner", value: agent.provider?.organization },
|
||||
{ label: "Protocol", value: agent.protocolVersion && `A2A ${agent.protocolVersion}` },
|
||||
],
|
||||
[
|
||||
{ label: "Input / output", wide: true, value: modes(agent) },
|
||||
{ label: "Capabilities", wide: true, value: capabilities.length > 0 && <Badges items={capabilities} /> },
|
||||
],
|
||||
]}
|
||||
/>
|
||||
<EndpointField id={`agent-endpoint-${agent.name}`} value={agent.url} />
|
||||
{agent.documentationUrl && (
|
||||
<Button
|
||||
variant="link"
|
||||
nativeButton={false}
|
||||
className="h-auto self-start px-0"
|
||||
render={<a href={agent.documentationUrl} target="_blank" rel="noopener noreferrer" />}
|
||||
>
|
||||
Read the documentation
|
||||
<ExternalLink />
|
||||
</Button>
|
||||
)}
|
||||
{(agent.skills ?? []).length > 0 && (
|
||||
<PaneSection title="Skills">
|
||||
<ItemGroup className="gap-2">
|
||||
{agent.skills.map((skill) => (
|
||||
<Item key={skill.id} variant="outline" size="sm" role="listitem">
|
||||
<ItemContent>
|
||||
<ItemTitle>{skill.name}</ItemTitle>
|
||||
<ItemDescription>{skill.description}</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Badges items={skill.tags ?? []} />
|
||||
</ItemActions>
|
||||
</Item>
|
||||
))}
|
||||
</ItemGroup>
|
||||
</PaneSection>
|
||||
)}
|
||||
<PaneSection title="Call this agent">
|
||||
{agentSnippets(agent).map((snippet, index) => (
|
||||
<Field key={snippet.title}>
|
||||
<FieldDescription>
|
||||
Step {index + 1}: {snippet.title}
|
||||
</FieldDescription>
|
||||
<CodeBlock code={snippet.code} />
|
||||
</Field>
|
||||
))}
|
||||
</PaneSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<ButtonGroup>
|
||||
<Combobox<FilterOption, true>
|
||||
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}
|
||||
>
|
||||
<ComboboxTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn("[&>svg:last-child]:hidden", !active && "border-dashed text-muted-foreground")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{!active && <CirclePlus />}
|
||||
{label}
|
||||
{active && <span className="font-normal text-muted-foreground">{firstPlus(selected)}</span>}
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent className="w-auto min-w-60">
|
||||
<ComboboxInput showTrigger={false} placeholder={`Search ${noun}`} />
|
||||
<ComboboxEmpty>No {noun} found</ComboboxEmpty>
|
||||
<ComboboxList>
|
||||
{(option: FilterOption) => (
|
||||
<ComboboxItem key={option.value} value={option}>
|
||||
{option.provider && <ProviderAvatar provider={option.provider} />}
|
||||
<span className="whitespace-nowrap">{option.label}</span>
|
||||
</ComboboxItem>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
{active && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={`Clear ${label.toLowerCase()} filter`}
|
||||
onClick={() => onChange([])}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">{icon}</EmptyMedia>
|
||||
<EmptyTitle className={SECTION_TITLE}>{noResultsTitle(noun, query, Boolean(onClear))}</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
{onClear ? "Try a shorter search or fewer filters" : "Your admin publishes them from the AI Hub page"}
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
{onClear && (
|
||||
<EmptyContent>
|
||||
<Button variant="outline" size="sm" onClick={onClear}>
|
||||
Clear filters
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
)}
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
|
||||
export function HubNotEnabled() {
|
||||
return (
|
||||
<Empty className="rounded-xl border border-solid border-border">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<LayoutGrid />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle className={SECTION_TITLE}>This AI Hub isn't available</EmptyTitle>
|
||||
<EmptyDescription>Ask your proxy admin to make it public from their Admin UI</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
107
ui/litellm-dashboard/src/components/publicModelHub/HubHeader.tsx
Normal file
107
ui/litellm-dashboard/src/components/publicModelHub/HubHeader.tsx
Normal file
|
|
@ -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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="outline" size="sm" />}>
|
||||
Links
|
||||
<ChevronDown className="text-muted-foreground" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-auto min-w-(--anchor-width)">
|
||||
{links.map((link) => (
|
||||
<DropdownMenuItem key={link.title} render={<a href={link.url} target="_blank" rel="noopener noreferrer" />}>
|
||||
<ExternalLink />
|
||||
{link.title}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{info?.litellm_version && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="text-xs font-normal">LiteLLM v{info.litellm_version}</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function BaseUrlField() {
|
||||
const url = gatewayUrl();
|
||||
return (
|
||||
<Field className="w-full gap-1.5 sm:w-[420px]">
|
||||
<InputGroup className="h-8">
|
||||
<InputGroupAddon>
|
||||
<InputGroupText className="text-xs font-medium">Base URL</InputGroupText>
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
readOnly
|
||||
value={url}
|
||||
aria-label="Base URL"
|
||||
aria-describedby="hub-base-url-hint"
|
||||
className="font-mono text-xs md:text-xs"
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<CopyIconButton value={url} label="Copy base URL" className="size-6" />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
<FieldDescription id="hub-base-url-hint" className="text-xs text-balance">
|
||||
Use your LiteLLM key as the API key in any OpenAI-compatible SDK
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export function HubHeader({
|
||||
info,
|
||||
status,
|
||||
}: {
|
||||
info: HubInfo | undefined;
|
||||
status: "loading" | "operational" | "unavailable";
|
||||
}) {
|
||||
return (
|
||||
<header className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="flex items-center gap-2 text-xl leading-7 font-semibold">
|
||||
<LayoutGrid className="size-5" />
|
||||
AI Hub
|
||||
</h1>
|
||||
{status === "operational" && <StatusBadge tone="success" label="Operational" />}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{info?.custom_docs_description || "Models, agents, MCP servers and skills you can call through this gateway"}
|
||||
</p>
|
||||
</div>
|
||||
{status !== "unavailable" && (
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<BaseUrlField />
|
||||
<LinksMenu info={info} />
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<ItemGroup className="gap-0.5 p-3" aria-busy="true">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} role="listitem">
|
||||
<Item size="sm">
|
||||
<ItemMedia variant="icon" className="size-5">
|
||||
<Skeleton className="size-5 rounded-full" />
|
||||
</ItemMedia>
|
||||
<ItemContent className="gap-1.5">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-48" />
|
||||
</ItemContent>
|
||||
</Item>
|
||||
</div>
|
||||
))}
|
||||
</ItemGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSkeleton() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6" aria-busy="true">
|
||||
<Skeleton className="h-5 w-48" />
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-4">
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex flex-col gap-1.5">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
<Skeleton className="h-4 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-40 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isPlainClick = (event: React.MouseEvent) => {
|
||||
const opensElsewhere = event.metaKey || event.ctrlKey || event.shiftKey;
|
||||
return event.button === 0 && !opensElsewhere;
|
||||
};
|
||||
|
||||
function Rows<T>({
|
||||
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 (
|
||||
<ItemGroup className="gap-0.5 p-3">
|
||||
{rows.map((row) => {
|
||||
const key = getKey(row);
|
||||
const active = key === activeKey;
|
||||
return (
|
||||
<div key={key} role="listitem">
|
||||
<Item
|
||||
size="sm"
|
||||
render={
|
||||
<a href={hrefFor(key)} aria-current={active && explicit ? "true" : undefined} onClick={follow(key)} />
|
||||
}
|
||||
className={cn(active && (explicit ? "bg-muted" : "lg:bg-muted"))}
|
||||
>
|
||||
{renderItem(row)}
|
||||
</Item>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</ItemGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export function HubMasterDetail<T>({
|
||||
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<T | undefined>(chosen);
|
||||
if (chosen !== undefined && chosen !== sheetRow) setSheetRow(chosen);
|
||||
const isEmpty = !loading && rows.length === 0;
|
||||
const list = isEmpty ? (
|
||||
empty
|
||||
) : (
|
||||
<>
|
||||
<Rows
|
||||
rows={rows}
|
||||
activeKey={selected && getKey(selected)}
|
||||
explicit={chosen !== undefined}
|
||||
getKey={getKey}
|
||||
onSelect={onSelect}
|
||||
renderItem={renderItem}
|
||||
/>
|
||||
{footer}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid min-h-0 grid-cols-1 overflow-hidden rounded-xl border border-border lg:h-[calc(100dvh-18.875rem)] lg:min-h-[560px]",
|
||||
!isEmpty && "lg:grid-cols-[minmax(0,5fr)_minmax(0,6fr)]",
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex min-h-0 flex-col", !isEmpty && "lg:border-r lg:border-border")}>
|
||||
<div className="flex flex-col gap-3 border-b border-border p-3">
|
||||
{switcher}
|
||||
<div className="flex flex-col gap-2">{toolbar}</div>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">{loading ? <ListSkeleton /> : list}</ScrollArea>
|
||||
</div>
|
||||
{!isEmpty && (
|
||||
<ScrollArea className="hidden min-h-0 lg:block">
|
||||
{loading ? <DetailSkeleton /> : selected && renderDetail(selected)}
|
||||
</ScrollArea>
|
||||
)}
|
||||
<DetailSheet
|
||||
open={narrow && chosen !== undefined}
|
||||
title={sheetRow ? getKey(sheetRow) : "Details"}
|
||||
onClose={() => onSelect(null)}
|
||||
>
|
||||
{sheetRow && renderDetail(sheetRow)}
|
||||
</DetailSheet>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSheet({
|
||||
open,
|
||||
title,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const body = React.useRef<HTMLDivElement>(null);
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(next) => !next && onClose()}>
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
showCloseButton={false}
|
||||
initialFocus={body}
|
||||
className="max-h-[85dvh] gap-0 rounded-t-xl p-0 lg:hidden"
|
||||
>
|
||||
<SheetTitle className="sr-only">{title}</SheetTitle>
|
||||
<div ref={body} tabIndex={-1} className="min-h-0 overflow-y-auto outline-none">
|
||||
<InSheet.Provider value>{children}</InSheet.Provider>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
187
ui/litellm-dashboard/src/components/publicModelHub/HubParts.tsx
Normal file
187
ui/litellm-dashboard/src/components/publicModelHub/HubParts.tsx
Normal file
|
|
@ -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 (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={label}
|
||||
className={cn("text-muted-foreground", className)}
|
||||
onClick={() => void copyToClipboard(value, "Copied")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Copy />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function CodeBlock({ code, label = "Copy code" }: { code: string; label?: string }) {
|
||||
return (
|
||||
<div className="relative min-w-0 rounded-lg border border-border bg-muted">
|
||||
<CopyIconButton value={code} label={label} className="absolute top-1.5 right-1.5 bg-background" />
|
||||
<pre className="max-h-96 overflow-auto p-3 pr-12 font-mono text-xs leading-5 whitespace-pre text-foreground">
|
||||
{code}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderAvatar({ provider, className }: { provider: string; className?: string }) {
|
||||
const logo = providerLogo(provider);
|
||||
return (
|
||||
<Avatar className={cn("size-4 bg-white ring-1 ring-border", className)}>
|
||||
{logo && <AvatarImage src={logo} alt="" className="object-contain p-0.5" />}
|
||||
<AvatarFallback className="bg-muted text-xs">{providerName(provider).charAt(0)}</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderName({ provider }: { provider: string }) {
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<ProviderAvatar provider={provider} />
|
||||
<span className="truncate">{providerName(provider)}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function Badges({ items, mono }: { items: string[]; mono?: boolean }) {
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{items.map((item) => (
|
||||
<Badge key={item} variant="secondary" className={cn("font-medium", mono && "font-mono font-normal")}>
|
||||
{item}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaneSection({
|
||||
title,
|
||||
aside,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
aside?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className={SECTION_TITLE}>{title}</h3>
|
||||
{aside}
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
{shown.map((group) => (
|
||||
<dl key={group[0].label} className="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-4">
|
||||
{group.map((fact) => (
|
||||
<div key={fact.label} className={cn("flex min-w-0 flex-col gap-1", fact.wide && "col-span-2")}>
|
||||
<dt className="text-xs font-medium whitespace-nowrap text-muted-foreground">{fact.label}</dt>
|
||||
<dd className="min-w-0 truncate text-sm tabular-nums">{fact.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EndpointField({ id, value }: { id: string; value: string }) {
|
||||
return (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={id}>Endpoint</FieldLabel>
|
||||
<InputGroup>
|
||||
<InputGroupInput id={id} readOnly value={value} className="font-mono text-xs md:text-xs" />
|
||||
<InputGroupAddon align="inline-end">
|
||||
<CopyIconButton value={value} label="Copy endpoint" className="size-6" />
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
}) {
|
||||
return (
|
||||
<InputGroup className="h-8 min-w-0 flex-1">
|
||||
<InputGroupAddon>
|
||||
<Search />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
placeholder={placeholder}
|
||||
aria-label={placeholder}
|
||||
/>
|
||||
{value && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton size="icon-xs" aria-label="Clear search" onClick={() => onChange("")}>
|
||||
<X />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export function RowTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ItemTitle className="w-full min-w-0">
|
||||
<span className="line-clamp-2 wrap-anywhere">{children}</span>
|
||||
</ItemTitle>
|
||||
);
|
||||
}
|
||||
|
||||
export function Joined({ parts }: { parts: (string | null | undefined)[] }) {
|
||||
return parts.filter(Boolean).map((part, index) => (
|
||||
<React.Fragment key={part}>
|
||||
{index > 0 && " · "}
|
||||
<span className="whitespace-nowrap">{part}</span>
|
||||
</React.Fragment>
|
||||
));
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Badge variant="outline" className={cn("font-medium", TONE_CLASS[tone])}>
|
||||
<Icon data-icon="inline-start" />
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function HealthBadge({ model }: { model: ModelGroupInfo }) {
|
||||
const badge = <StatusBadge {...healthOf(model)} />;
|
||||
if (!model.health_status) return badge;
|
||||
return (
|
||||
<HoverCard>
|
||||
<HoverCardTrigger delay={150} render={<span tabIndex={0} className="inline-flex" />}>
|
||||
{badge}
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent align="end" className="w-56 p-3 text-xs">
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
|
||||
<dt className="text-muted-foreground">Response time</dt>
|
||||
<dd className="tabular-nums">
|
||||
{model.health_response_time ? `${Math.round(model.health_response_time)} ms` : "-"}
|
||||
</dd>
|
||||
<dt className="text-muted-foreground">Last checked</dt>
|
||||
<dd>{relativeTime(model.health_checked_at)}</dd>
|
||||
</dl>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{media}
|
||||
<h2 className={cn("min-w-0 wrap-break-word", SECTION_TITLE)}>{title}</h2>
|
||||
<CopyIconButton value={title} label={copyLabel} className="size-7" />
|
||||
<span className="flex-1" />
|
||||
{status && <span className="hidden sm:flex">{status}</span>}
|
||||
{inSheet && (
|
||||
<SheetClose
|
||||
render={<Button variant="ghost" size="icon-sm" aria-label="Close" className="text-muted-foreground" />}
|
||||
>
|
||||
<X />
|
||||
</SheetClose>
|
||||
)}
|
||||
</div>
|
||||
{status && <span className="flex sm:hidden">{status}</span>}
|
||||
{description && <p className="text-sm text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<InspectorHeader
|
||||
title={server.server_name}
|
||||
copyLabel="Copy server name"
|
||||
description={server.mcp_info?.description}
|
||||
/>
|
||||
<Facts
|
||||
groups={[
|
||||
[
|
||||
{ label: "Transport", value: server.transport },
|
||||
{ label: "Sign-in", value: mcpAuthLabel(server.auth_type) },
|
||||
{ label: "Alias", value: server.alias },
|
||||
],
|
||||
]}
|
||||
/>
|
||||
<EndpointField id={`mcp-endpoint-${server.server_id}`} value={mcpEndpoint(server)} />
|
||||
<PaneSection title="Connect a client">
|
||||
<CodeBlock code={mcpSnippet(server)} />
|
||||
</PaneSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
<span className="flex flex-wrap gap-x-4 gap-y-1">
|
||||
{providers.map((provider) => (
|
||||
<ProviderName key={provider} provider={provider} />
|
||||
))}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ label: "Tags", wide: true, value: tags.length > 0 && <Badges items={tags} /> },
|
||||
],
|
||||
];
|
||||
};
|
||||
|
||||
function CallThisModel({ model }: { model: ModelGroupInfo }) {
|
||||
const curl = curlSnippet(model);
|
||||
if (!curl) {
|
||||
return (
|
||||
<PaneSection title="Call this model">
|
||||
<CodeBlock code={modelSnippet(model)} />
|
||||
</PaneSection>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<PaneSection title="Call this model">
|
||||
<Tabs defaultValue="python" className="gap-3">
|
||||
<TabsList>
|
||||
<TabsTrigger value="python">Python</TabsTrigger>
|
||||
<TabsTrigger value="curl">cURL</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="python">
|
||||
<CodeBlock code={modelSnippet(model)} />
|
||||
</TabsContent>
|
||||
<TabsContent value="curl">
|
||||
<CodeBlock code={curl} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PaneSection>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomMetadata({ model }: { model: ModelGroupInfo }) {
|
||||
const metadata = Object.entries(model.custom_metadata ?? {});
|
||||
if (metadata.length === 0) return null;
|
||||
return (
|
||||
<PaneSection title="Custom metadata">
|
||||
<dl className="grid grid-cols-[160px_minmax(0,1fr)] gap-x-4 gap-y-2 text-sm">
|
||||
{metadata.map(([key, value]) => (
|
||||
<React.Fragment key={key}>
|
||||
<dt className="text-xs font-medium text-muted-foreground">{key}</dt>
|
||||
<dd>{String(value)}</dd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</dl>
|
||||
</PaneSection>
|
||||
);
|
||||
}
|
||||
|
||||
function SupportedParams({ params }: { params: string[] }) {
|
||||
if (params.length === 0) return null;
|
||||
return (
|
||||
<Collapsible>
|
||||
<PaneSection
|
||||
title="Supported OpenAI parameters"
|
||||
aside={
|
||||
<CollapsibleTrigger render={<Button variant="ghost" size="sm" />}>
|
||||
Show {params.length}
|
||||
<ChevronDown className="text-muted-foreground" />
|
||||
</CollapsibleTrigger>
|
||||
}
|
||||
>
|
||||
<CollapsibleContent>
|
||||
<Badges items={params} mono />
|
||||
</CollapsibleContent>
|
||||
</PaneSection>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelInspector({ model }: { model: ModelGroupInfo }) {
|
||||
const providers = model.providers ?? [];
|
||||
const capabilities = modelCapabilities(model);
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<InspectorHeader
|
||||
media={<ProviderAvatar provider={providers[0] ?? ""} className="size-6" />}
|
||||
title={model.model_group}
|
||||
copyLabel="Copy model name"
|
||||
status={<HealthBadge model={model} />}
|
||||
description={
|
||||
model.description || `${modeLabel(model.mode)} model from ${providers.map(providerName).join(", ")}`
|
||||
}
|
||||
/>
|
||||
{model.model_group.includes("*") && (
|
||||
<Alert>
|
||||
<AlertTitle>Wildcard model</AlertTitle>
|
||||
<AlertDescription>
|
||||
Call it as <code className="font-mono">{wildcardExample(model.model_group)}</code>, with any model name this
|
||||
provider offers
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<Facts groups={modelFacts(model)} />
|
||||
{capabilities.length > 0 && (
|
||||
<PaneSection title="Capabilities">
|
||||
<Badges items={capabilities} />
|
||||
</PaneSection>
|
||||
)}
|
||||
<CallThisModel model={model} />
|
||||
<CustomMetadata model={model} />
|
||||
<SupportedParams params={model.supported_openai_params ?? []} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="flex items-center justify-between gap-2 px-6 pb-3 text-xs text-muted-foreground tabular-nums">
|
||||
<span>
|
||||
{start}–{end} of {rowCount}
|
||||
</span>
|
||||
<ButtonGroup>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label="Previous page"
|
||||
disabled={pagination.pageIndex === 0}
|
||||
onClick={() => go(pagination.pageIndex - 1)}
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label="Next page"
|
||||
disabled={start + pagination.pageSize > rowCount}
|
||||
onClick={() => go(pagination.pageIndex + 1)}
|
||||
>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<ButtonGroup>
|
||||
<Select items={SORTS} value={field} onValueChange={pick}>
|
||||
<SelectTrigger size="sm" className="w-auto gap-1.5" aria-label="Sort by">
|
||||
<span className="hidden text-muted-foreground sm:inline">Sort</span>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end" className="w-auto min-w-(--anchor-width)">
|
||||
{SORTS.map((sort) => (
|
||||
<SelectItem key={sort.value} value={sort.value}>
|
||||
{sort.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Toggle
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Sort descending"
|
||||
pressed={desc}
|
||||
onPressedChange={(pressed) => models.onSortingChange([{ id: field, desc: pressed }])}
|
||||
className="dark:bg-input/30 dark:hover:bg-input/50"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{desc ? <ArrowDownWideNarrow /> : <ArrowUpNarrowWide />}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Sort descending</TooltipContent>
|
||||
</Tooltip>
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelsToolbar({ hub }: { hub: HubData }) {
|
||||
const { models, facets } = hub;
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<SearchInput value={models.searchValue} onChange={models.onSearchChange} placeholder="Search models" />
|
||||
<SortControl models={models} />
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FilterChip
|
||||
label="Provider"
|
||||
noun="providers"
|
||||
options={facets.providers.map((p) => ({ value: p, label: providerName(p), provider: p }))}
|
||||
value={models.providerValues}
|
||||
onChange={models.onProvidersChange}
|
||||
/>
|
||||
<FilterChip
|
||||
label="Mode"
|
||||
noun="modes"
|
||||
options={facets.modes.map((m) => ({ value: m, label: modeLabel(m) }))}
|
||||
value={models.modeValues}
|
||||
onChange={models.onModesChange}
|
||||
/>
|
||||
<FilterChip
|
||||
label="Features"
|
||||
noun="features"
|
||||
options={facets.features.map((f) => ({ value: f, label: featureFacetLabel(f) }))}
|
||||
value={models.featureValues}
|
||||
onChange={models.onFeaturesChange}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<ItemMedia variant="icon" className="size-5">
|
||||
<ProviderAvatar provider={model.providers?.[0] ?? ""} className="size-5" />
|
||||
</ItemMedia>
|
||||
<ItemContent className="min-w-0 gap-0.5">
|
||||
<RowTitle>{model.model_group}</RowTitle>
|
||||
<ItemDescription>
|
||||
<Joined
|
||||
parts={[
|
||||
firstPlus((model.providers ?? []).map(providerName)),
|
||||
model.mode ? modeLabel(model.mode) : null,
|
||||
model.max_input_tokens ? `${formatTokens(model.max_input_tokens)} context` : null,
|
||||
]}
|
||||
/>
|
||||
</ItemDescription>
|
||||
{priced && (
|
||||
<ItemDescription className="tabular-nums sm:hidden">
|
||||
<Joined parts={[input, output]} />
|
||||
</ItemDescription>
|
||||
)}
|
||||
</ItemContent>
|
||||
{priced && (
|
||||
<ItemActions className="hidden flex-col items-end gap-0.5 text-xs whitespace-nowrap tabular-nums sm:flex">
|
||||
{input && <span>{input}</span>}
|
||||
{output && <span className="text-muted-foreground">{output}</span>}
|
||||
</ItemActions>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<HubMasterDetail
|
||||
rows={models.rows}
|
||||
loading={models.isLoading}
|
||||
selectedKey={selected}
|
||||
getKey={(model) => model.model_group}
|
||||
onSelect={onSelect}
|
||||
empty={
|
||||
<NoResults
|
||||
icon={<Boxes />}
|
||||
noun="models"
|
||||
query={models.searchValue}
|
||||
onClear={models.hasActiveQuery ? clear : undefined}
|
||||
/>
|
||||
}
|
||||
toolbar={<ModelsToolbar hub={hub} />}
|
||||
renderItem={(model) => <ModelRow model={model} />}
|
||||
renderDetail={(model) => <ModelInspector model={model} />}
|
||||
footer={<ModelsPager models={models} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<string, unknown> },
|
||||
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: () => <nav aria-label="Top bar" /> }));
|
||||
|
||||
vi.mock("@/lib/telemetry/uiEvents", () => ({ recordUiEvent: vi.fn() }));
|
||||
|
||||
vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
|
||||
useUISettings: () => ({ data: uiSettings, isLoading: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/networking", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/components/networking")>();
|
||||
return {
|
||||
...actual,
|
||||
apiClient: { ...actual.apiClient, get: apiGetMock },
|
||||
getUiConfig: vi.fn().mockResolvedValue({}),
|
||||
getProxyBaseUrl: vi.fn(() => "https://gateway.example.com"),
|
||||
getPublicModelHubInfo: vi.fn().mockResolvedValue(hubInfo),
|
||||
agentHubPublicModelsCall: vi.fn().mockResolvedValue([
|
||||
{
|
||||
name: "research-assistant",
|
||||
description: "Answers research questions",
|
||||
version: "1.4.0",
|
||||
url: "https://gateway.example.com/a2a/research",
|
||||
protocolVersion: "0.3.0",
|
||||
defaultInputModes: ["text"],
|
||||
defaultOutputModes: ["text"],
|
||||
skills: [],
|
||||
},
|
||||
]),
|
||||
mcpHubPublicServersCall: vi.fn().mockResolvedValue([]),
|
||||
skillHubPublicCall: vi.fn().mockResolvedValue({ plugins: [] }),
|
||||
};
|
||||
});
|
||||
|
||||
const MODEL_HUB_PATH = "/public/v1/model_hub";
|
||||
|
||||
const MODELS = [
|
||||
{
|
||||
model_group: "claude-sonnet-4-5",
|
||||
providers: ["anthropic"],
|
||||
mode: "chat",
|
||||
input_cost_per_token: 0.000003,
|
||||
description: "Best for agentic coding",
|
||||
},
|
||||
{ model_group: "gpt-5", providers: ["openai"], mode: "chat", description: "General purpose" },
|
||||
];
|
||||
|
||||
const page = (data: unknown[]) => ({
|
||||
data,
|
||||
meta: { total_count: data.length, page: 1, page_size: 50, total_pages: 1 },
|
||||
links: { self: MODEL_HUB_PATH, first: MODEL_HUB_PATH, prev: null, next: null, last: MODEL_HUB_PATH },
|
||||
});
|
||||
|
||||
const respondWithModels = () =>
|
||||
apiGetMock.mockImplementation((path: string) =>
|
||||
Promise.resolve(path === MODEL_HUB_PATH ? page(MODELS) : { ...page([]), meta: { page: 1, page_size: 100 } }),
|
||||
);
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testQueryClient.clear();
|
||||
uiSettings.values = {};
|
||||
respondWithModels();
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("{}", { status: 404 }));
|
||||
Object.defineProperty(window, "location", {
|
||||
writable: true,
|
||||
value: { ...window.location, replace: replaceMock },
|
||||
});
|
||||
});
|
||||
|
||||
describe("PublicAiHub", () => {
|
||||
it("counts every hub and shows the first model's details", async () => {
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />);
|
||||
|
||||
expect(await screen.findByRole("heading", { level: 2, name: "claude-sonnet-4-5" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /^Models\s*2$/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: /^Agents\s*1$/ })).toBeInTheDocument();
|
||||
expect(screen.getByText("Best for agentic coding")).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "Base URL" })).toHaveValue("https://gateway.example.com");
|
||||
});
|
||||
|
||||
it("opens a model from the list and records it in the URL", async () => {
|
||||
const onUrlUpdate = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />, { onUrlUpdate });
|
||||
|
||||
await user.click(await screen.findByRole("link", { name: /gpt-5/ }));
|
||||
|
||||
expect(await screen.findByRole("heading", { level: 2, name: "gpt-5" })).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(onUrlUpdate).toHaveBeenCalledWith(expect.objectContaining({ queryString: "?open=gpt-5" })),
|
||||
);
|
||||
});
|
||||
|
||||
it("explains an empty search and clears it", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { tab: "agents" } });
|
||||
|
||||
fireEvent.change(await screen.findByRole("textbox", { name: "Search agents" }), { target: { value: "billing" } });
|
||||
|
||||
expect(await screen.findByText('No agents match "billing"')).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Clear filters" }));
|
||||
expect(await screen.findByRole("link", { name: /research-assistant/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists the admin's links and the gateway version", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />);
|
||||
|
||||
await user.click(await screen.findByRole("button", { name: "Links" }));
|
||||
|
||||
expect(await screen.findByRole("menuitem", { name: "Getting started" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://docs.example.com",
|
||||
);
|
||||
expect(screen.getByText("LiteLLM v1.90.0")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says the hub is unavailable when the public models call fails", async () => {
|
||||
apiGetMock.mockRejectedValue(new Error("Public model hub is not enabled"));
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />);
|
||||
|
||||
expect(await screen.findByText("This AI Hub isn't available")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("sends visitors without a session to login when the admin requires it", async () => {
|
||||
uiSettings.values = { require_auth_for_public_ai_hub: true };
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />);
|
||||
|
||||
await waitFor(() => expect(replaceMock).toHaveBeenCalledWith(expect.stringContaining("login")));
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import Navbar from "@/components/navbar";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||
|
||||
import { HubHeader } from "./HubHeader";
|
||||
import { HubNotEnabled } from "./HubEmpty";
|
||||
import { ModelsPane } from "./ModelsPane";
|
||||
import { AgentsPane, McpPane, SkillsPane } from "./ResourcePanes";
|
||||
import { type HubData, useHubData } from "./useHubData";
|
||||
import { type HubTab, useHubUrlState } from "./useHubUrlState";
|
||||
import { useRequirePublicHubAuth } from "./useRequirePublicHubAuth";
|
||||
|
||||
function TabLabel({ children, count }: { children: ReactNode; count: number | undefined }) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<span className="text-xs text-muted-foreground tabular-nums">{count ?? "-"}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const hubStatus = (hub: HubData) => {
|
||||
if (hub.models.isLoading) return "loading";
|
||||
return hub.models.error ? "unavailable" : "operational";
|
||||
};
|
||||
|
||||
function HubTabs({ hub }: { hub: HubData }) {
|
||||
const { tab, setTab, open, setOpen } = useHubUrlState();
|
||||
const paneProps = { selected: open, onSelect: setOpen };
|
||||
return (
|
||||
<Tabs value={tab} onValueChange={(next) => setTab(next as HubTab)} className="gap-4">
|
||||
<TabsList className="w-full sm:w-fit">
|
||||
<TabsTrigger value="models" className="px-1.5 sm:px-2">
|
||||
<TabLabel count={hub.modelTotal}>Models</TabLabel>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="agents" className="px-1.5 sm:px-2">
|
||||
<TabLabel count={hub.agents.data?.length}>Agents</TabLabel>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="mcp" className="px-1.5 sm:px-2">
|
||||
<TabLabel count={hub.mcp.data?.length}>
|
||||
<span className="sm:hidden">MCP</span>
|
||||
<span className="hidden sm:inline">MCP servers</span>
|
||||
</TabLabel>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="skills" className="px-1.5 sm:px-2">
|
||||
<TabLabel count={hub.skills.data?.length}>Skills</TabLabel>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="models">
|
||||
<ModelsPane hub={hub} {...paneProps} />
|
||||
</TabsContent>
|
||||
<TabsContent value="agents">
|
||||
<AgentsPane rows={hub.agents.data} loading={hub.agents.isPending} {...paneProps} />
|
||||
</TabsContent>
|
||||
<TabsContent value="mcp">
|
||||
<McpPane rows={hub.mcp.data} loading={hub.mcp.isPending} {...paneProps} />
|
||||
</TabsContent>
|
||||
<TabsContent value="skills">
|
||||
<SkillsPane rows={hub.skills.data} loading={hub.skills.isPending} {...paneProps} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
function PublicAiHubContent() {
|
||||
useRequirePublicHubAuth();
|
||||
const hub = useHubData();
|
||||
const status = hubStatus(hub);
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-6 p-4 sm:p-6">
|
||||
<HubHeader info={hub.info.data} status={status} />
|
||||
{status === "unavailable" ? <HubNotEnabled /> : <HubTabs hub={hub} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PublicAiHub({ accessToken }: { accessToken: string | null }) {
|
||||
return (
|
||||
<ThemeProvider accessToken={accessToken}>
|
||||
<TooltipProvider>
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navbar accessToken={accessToken} isPublicPage />
|
||||
<PublicAiHubContent />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,172 @@
|
|||
"use client";
|
||||
|
||||
import { Bot, Puzzle, Server } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import type { Plugin } from "@/components/claude_code_plugins/types";
|
||||
import type { AgentCard, MCPServerData } from "@/components/PublicModelHubTableColumns";
|
||||
import { ItemActions, ItemContent, ItemDescription, ItemMedia } from "@/components/ui/item";
|
||||
|
||||
import { AgentInspector } from "./AgentInspector";
|
||||
import { FilterChip } from "./FilterChip";
|
||||
import { mcpAuthLabel } from "./hubFormat";
|
||||
import { HubMasterDetail } from "./HubMasterDetail";
|
||||
import { NoResults } from "./HubEmpty";
|
||||
import { RowTitle, SearchInput } from "./HubParts";
|
||||
import { McpInspector } from "./McpInspector";
|
||||
import { SkillInspector } from "./SkillInspector";
|
||||
|
||||
const matches = (fields: (string | null | undefined)[], query: string) => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
return !needle || fields.some((field) => field?.toLowerCase().includes(needle));
|
||||
};
|
||||
|
||||
interface ResourcePaneProps<T> {
|
||||
rows: T[];
|
||||
loading: boolean;
|
||||
noun: string;
|
||||
icon: React.ReactNode;
|
||||
search: (row: T) => (string | null | undefined)[];
|
||||
filter: { label: string; noun: string; values: (row: T) => string[] };
|
||||
selected: string | null;
|
||||
onSelect: (key: string | null) => void;
|
||||
getKey: (row: T) => string;
|
||||
renderDetail: (row: T) => React.ReactNode;
|
||||
title: (row: T) => string;
|
||||
description: (row: T) => string | null | undefined;
|
||||
meta: (row: T) => string | null | undefined;
|
||||
}
|
||||
|
||||
function ResourcePane<T>({
|
||||
rows,
|
||||
loading,
|
||||
noun,
|
||||
icon,
|
||||
search,
|
||||
filter,
|
||||
selected,
|
||||
onSelect,
|
||||
getKey,
|
||||
renderDetail,
|
||||
title,
|
||||
description,
|
||||
meta,
|
||||
}: ResourcePaneProps<T>) {
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [chosen, setChosen] = React.useState<string[]>([]);
|
||||
const options = [...new Set(rows.flatMap(filter.values))].sort();
|
||||
const shown = rows.filter(
|
||||
(row) => matches(search(row), query) && (chosen.length === 0 || filter.values(row).some((v) => chosen.includes(v))),
|
||||
);
|
||||
const filtered = query.trim() !== "" || chosen.length > 0;
|
||||
const clear = () => {
|
||||
setQuery("");
|
||||
setChosen([]);
|
||||
};
|
||||
return (
|
||||
<HubMasterDetail
|
||||
rows={shown}
|
||||
loading={loading}
|
||||
selectedKey={selected}
|
||||
getKey={getKey}
|
||||
onSelect={onSelect}
|
||||
empty={<NoResults icon={icon} noun={noun} query={query} onClear={filtered ? clear : undefined} />}
|
||||
toolbar={
|
||||
<div className="flex items-center gap-2">
|
||||
<SearchInput value={query} onChange={setQuery} placeholder={`Search ${noun}`} />
|
||||
{options.length > 0 && (
|
||||
<FilterChip
|
||||
label={filter.label}
|
||||
noun={filter.noun}
|
||||
options={options.map((option) => ({ value: option, label: option }))}
|
||||
value={chosen}
|
||||
onChange={setChosen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
renderItem={(row) => (
|
||||
<>
|
||||
<ItemMedia variant="icon" className="size-5 text-muted-foreground">
|
||||
{icon}
|
||||
</ItemMedia>
|
||||
<ItemContent className="min-w-0 gap-0.5">
|
||||
<RowTitle>{title(row)}</RowTitle>
|
||||
{description(row) && <ItemDescription>{description(row)}</ItemDescription>}
|
||||
</ItemContent>
|
||||
{meta(row) && <ItemActions className="text-xs text-muted-foreground">{meta(row)}</ItemActions>}
|
||||
</>
|
||||
)}
|
||||
renderDetail={renderDetail}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface PaneProps<T> {
|
||||
rows: T[] | undefined;
|
||||
loading: boolean;
|
||||
selected: string | null;
|
||||
onSelect: (key: string | null) => void;
|
||||
}
|
||||
|
||||
export function AgentsPane({ rows, ...props }: PaneProps<AgentCard>) {
|
||||
return (
|
||||
<ResourcePane<AgentCard>
|
||||
{...props}
|
||||
rows={rows ?? []}
|
||||
noun="agents"
|
||||
icon={<Bot />}
|
||||
search={(agent) => [agent.name, agent.description]}
|
||||
filter={{
|
||||
label: "Skill tag",
|
||||
noun: "skill tags",
|
||||
values: (agent) => (agent.skills ?? []).flatMap((s) => s.tags ?? []),
|
||||
}}
|
||||
getKey={(agent) => agent.name}
|
||||
title={(agent) => agent.name}
|
||||
description={(agent) => agent.description}
|
||||
meta={(agent) => (agent.version ? `v${agent.version}` : undefined)}
|
||||
renderDetail={(agent) => <AgentInspector agent={agent} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function McpPane({ rows, ...props }: PaneProps<MCPServerData>) {
|
||||
return (
|
||||
<ResourcePane<MCPServerData>
|
||||
{...props}
|
||||
rows={rows ?? []}
|
||||
noun="MCP servers"
|
||||
icon={<Server />}
|
||||
search={(server) => [server.server_name, server.mcp_info?.description]}
|
||||
filter={{
|
||||
label: "Transport",
|
||||
noun: "transports",
|
||||
values: (server) => (server.transport ? [server.transport] : []),
|
||||
}}
|
||||
getKey={(server) => server.server_name}
|
||||
title={(server) => server.server_name}
|
||||
description={(server) => server.mcp_info?.description}
|
||||
meta={(server) => mcpAuthLabel(server.auth_type)}
|
||||
renderDetail={(server) => <McpInspector server={server} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkillsPane({ rows, ...props }: PaneProps<Plugin>) {
|
||||
return (
|
||||
<ResourcePane<Plugin>
|
||||
{...props}
|
||||
rows={rows ?? []}
|
||||
noun="skills"
|
||||
icon={<Puzzle />}
|
||||
search={(skill) => [skill.name, skill.description, skill.namespace, skill.domain, ...(skill.keywords ?? [])]}
|
||||
filter={{ label: "Domain", noun: "domains", values: (skill) => (skill.domain ? [skill.domain] : []) }}
|
||||
getKey={(skill) => skill.name}
|
||||
title={(skill) => skill.name}
|
||||
description={(skill) => skill.description}
|
||||
meta={(skill) => skill.domain}
|
||||
renderDetail={(skill) => <SkillInspector skill={skill} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
"use client";
|
||||
|
||||
import type { Plugin } from "@/components/claude_code_plugins/types";
|
||||
import { Field, FieldDescription } from "@/components/ui/field";
|
||||
|
||||
import { Badges, CodeBlock, Facts, PaneSection } from "./HubParts";
|
||||
import { marketplaceCommand, skillInstallCommand } from "./hubSnippets";
|
||||
import { InspectorHeader } from "./InspectorHeader";
|
||||
|
||||
export function SkillInspector({ skill }: { skill: Plugin }) {
|
||||
const keywords = skill.keywords ?? [];
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<InspectorHeader title={skill.name} copyLabel="Copy skill name" description={skill.description} />
|
||||
<Facts
|
||||
groups={[
|
||||
[
|
||||
{ label: "Domain", value: skill.domain },
|
||||
{ label: "Namespace", value: skill.namespace },
|
||||
{ label: "Version", value: skill.version },
|
||||
{ label: "Category", value: skill.category },
|
||||
],
|
||||
[{ label: "Keywords", wide: true, value: keywords.length > 0 && <Badges items={keywords} /> }],
|
||||
]}
|
||||
/>
|
||||
<PaneSection title="Install in Claude Code">
|
||||
<Field>
|
||||
<FieldDescription>First time only: add this marketplace</FieldDescription>
|
||||
<CodeBlock code={marketplaceCommand()} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldDescription>Then install the skill</FieldDescription>
|
||||
<CodeBlock code={skillInstallCommand(skill)} />
|
||||
</Field>
|
||||
</PaneSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AgentCard, ModelGroupInfo } from "@/components/PublicModelHubTableColumns";
|
||||
|
||||
import {
|
||||
agentCapabilities,
|
||||
capabilityLabel,
|
||||
featureFacetLabel,
|
||||
firstPlus,
|
||||
formatPerMillion,
|
||||
formatTokens,
|
||||
healthOf,
|
||||
mcpAuthLabel,
|
||||
modeLabel,
|
||||
modelCapabilities,
|
||||
relativeTime,
|
||||
usefulLinkList,
|
||||
wildcardExample,
|
||||
} from "./hubFormat";
|
||||
|
||||
vi.mock("@/components/networking", () => ({ getProxyBaseUrl: () => "https://gateway.example.com" }));
|
||||
|
||||
const model = (overrides: Partial<ModelGroupInfo>): ModelGroupInfo => ({
|
||||
model_group: "gpt-5",
|
||||
providers: ["openai"],
|
||||
supports_parallel_function_calling: false,
|
||||
supports_vision: false,
|
||||
supports_function_calling: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("modeLabel", () => {
|
||||
it("names known modes in sentence case and falls back for unknown ones", () => {
|
||||
expect(modeLabel("image_generation")).toBe("Image generation");
|
||||
expect(modeLabel("audio_speech")).toBe("Text to speech");
|
||||
expect(modeLabel("custom_thing")).toBe("Custom thing");
|
||||
expect(modeLabel(undefined)).toBe("-");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTokens", () => {
|
||||
it("abbreviates thousands and millions", () => {
|
||||
expect(formatTokens(512)).toBe("512");
|
||||
expect(formatTokens(200_000)).toBe("200K");
|
||||
expect(formatTokens(1_000_000)).toBe("1M");
|
||||
expect(formatTokens(1_048_576)).toBe("1M");
|
||||
expect(formatTokens(2_500_000)).toBe("2.5M");
|
||||
expect(formatTokens(undefined)).toBe("-");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPerMillion", () => {
|
||||
it("prices per million tokens with two decimals", () => {
|
||||
expect(formatPerMillion(0.000003)).toBe("$3.00");
|
||||
expect(formatPerMillion(0.0000165)).toBe("$16.50");
|
||||
expect(formatPerMillion(0)).toBe("-");
|
||||
});
|
||||
});
|
||||
|
||||
describe("capabilities", () => {
|
||||
it("labels known flags and derives a label for new ones", () => {
|
||||
expect(capabilityLabel("supports_function_calling")).toBe("Function calling");
|
||||
expect(capabilityLabel("supports_pdf_input")).toBe("Pdf input");
|
||||
expect(featureFacetLabel("web_search")).toBe("Web search");
|
||||
});
|
||||
|
||||
it("lists only enabled flags, known ones first in a fixed order", () => {
|
||||
const flags = model({ supports_pdf_input: true, supports_web_search: true, supports_vision: true });
|
||||
expect(modelCapabilities(flags)).toEqual(["Vision", "Web search", "Pdf input"]);
|
||||
});
|
||||
|
||||
it("lists an agent's enabled capabilities", () => {
|
||||
const agent = { capabilities: { streaming: true, pushNotifications: true, stateTransitionHistory: false } };
|
||||
expect(agentCapabilities(agent as AgentCard)).toEqual(["Streaming", "Push notifications"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("healthOf", () => {
|
||||
it("maps the health check to a tone and label", () => {
|
||||
expect(healthOf(model({ health_status: "healthy" }))).toEqual({ tone: "success", label: "Healthy" });
|
||||
expect(healthOf(model({ health_status: "unhealthy" }))).toEqual({ tone: "error", label: "Unhealthy" });
|
||||
expect(healthOf(model({}))).toEqual({ tone: "neutral", label: "Not checked" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("relativeTime", () => {
|
||||
const now = Date.parse("2026-10-31T12:00:00Z");
|
||||
|
||||
it("rounds to minutes, hours or days", () => {
|
||||
expect(relativeTime("2026-10-31T11:59:45Z", now)).toBe("just now");
|
||||
expect(relativeTime("2026-10-31T11:45:00Z", now)).toBe("15 min ago");
|
||||
expect(relativeTime("2026-10-31T09:00:00Z", now)).toBe("3 h ago");
|
||||
expect(relativeTime("2026-10-29T12:00:00Z", now)).toBe("2 days ago");
|
||||
expect(relativeTime(undefined, now)).toBe("-");
|
||||
});
|
||||
});
|
||||
|
||||
describe("small formatters", () => {
|
||||
it("shows a wildcard model as a placeholder name", () => {
|
||||
expect(wildcardExample("anthropic/*")).toBe("anthropic/<model-name>");
|
||||
});
|
||||
|
||||
it("collapses a list to its first item and a count", () => {
|
||||
expect(firstPlus(["Anthropic"])).toBe("Anthropic");
|
||||
expect(firstPlus(["Anthropic", "Amazon Bedrock", "Vertex AI"])).toBe("Anthropic +2");
|
||||
expect(firstPlus([])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("names MCP sign-in types", () => {
|
||||
expect(mcpAuthLabel("oauth2")).toBe("OAuth");
|
||||
expect(mcpAuthLabel("custom_header")).toBe("custom_header");
|
||||
expect(mcpAuthLabel(null)).toBe("-");
|
||||
});
|
||||
});
|
||||
|
||||
describe("usefulLinkList", () => {
|
||||
it("orders the admin's links by index and accepts the legacy string form", () => {
|
||||
const links = {
|
||||
"Status page": { url: "https://status.example.com", index: 2 },
|
||||
"Getting started": { url: "https://docs.example.com", index: 0 },
|
||||
Legacy: "https://legacy.example.com",
|
||||
};
|
||||
expect(usefulLinkList(links).map((link) => link.title)).toEqual(["Getting started", "Legacy", "Status page"]);
|
||||
expect(usefulLinkList(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
125
ui/litellm-dashboard/src/components/publicModelHub/hubFormat.ts
Normal file
125
ui/litellm-dashboard/src/components/publicModelHub/hubFormat.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { getProxyBaseUrl } from "@/components/networking";
|
||||
import type { AgentCard, ModelGroupInfo } from "@/components/PublicModelHubTableColumns";
|
||||
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
|
||||
|
||||
export const SECTION_TITLE = "text-[15px] leading-[22px] font-semibold";
|
||||
|
||||
const MODE_LABELS: Record<string, string> = {
|
||||
chat: "Chat",
|
||||
completion: "Completion",
|
||||
embedding: "Embedding",
|
||||
rerank: "Rerank",
|
||||
image_generation: "Image generation",
|
||||
image_edit: "Image edit",
|
||||
audio_speech: "Text to speech",
|
||||
audio_transcription: "Transcription",
|
||||
video_generation: "Video generation",
|
||||
realtime: "Realtime",
|
||||
responses: "Responses",
|
||||
batch: "Batch",
|
||||
ocr: "OCR",
|
||||
decisions: "Decisions",
|
||||
};
|
||||
|
||||
const sentenceCase = (text: string): string => text.replaceAll("_", " ").replace(/^./, (c) => c.toUpperCase());
|
||||
|
||||
export const modeLabel = (mode: string | null | undefined): string =>
|
||||
mode ? MODE_LABELS[mode] ?? sentenceCase(mode) : "-";
|
||||
|
||||
export const formatTokens = (tokens: number | null | undefined): string => {
|
||||
if (!tokens) return "-";
|
||||
if (tokens >= 1_000_000) return `${+(tokens / 1_000_000).toFixed(1)}M`;
|
||||
if (tokens >= 1_000) return `${Math.round(tokens / 1_000)}K`;
|
||||
return String(tokens);
|
||||
};
|
||||
|
||||
export const formatPerMillion = (cost: number | null | undefined): string =>
|
||||
cost ? `$${(cost * 1_000_000).toFixed(2)}` : "-";
|
||||
|
||||
export const providerName = (provider: string): string =>
|
||||
getProviderLogoAndName(provider).displayName.replace(/\s*\(.*\)$/, "");
|
||||
|
||||
export const providerLogo = (provider: string): string => getProviderLogoAndName(provider).logo;
|
||||
|
||||
const CAPABILITY_LABELS: Record<string, string> = {
|
||||
supports_vision: "Vision",
|
||||
supports_function_calling: "Function calling",
|
||||
supports_reasoning: "Reasoning",
|
||||
supports_web_search: "Web search",
|
||||
supports_parallel_function_calling: "Parallel function calling",
|
||||
supports_url_context: "URL context",
|
||||
supports_fast_mode: "Fast mode",
|
||||
};
|
||||
|
||||
const CAPABILITY_ORDER = Object.keys(CAPABILITY_LABELS);
|
||||
|
||||
const capabilityRank = (key: string): number => {
|
||||
const index = CAPABILITY_ORDER.indexOf(key);
|
||||
return index === -1 ? CAPABILITY_ORDER.length : index;
|
||||
};
|
||||
|
||||
export const capabilityLabel = (key: string): string =>
|
||||
CAPABILITY_LABELS[key] ?? sentenceCase(key.replace(/^supports_/, ""));
|
||||
|
||||
export const featureFacetLabel = (feature: string): string => capabilityLabel(`supports_${feature}`);
|
||||
|
||||
export const modelCapabilities = (model: ModelGroupInfo): string[] =>
|
||||
Object.entries(model)
|
||||
.filter(([key, value]) => key.startsWith("supports_") && value === true)
|
||||
.map(([key]) => key)
|
||||
.sort((a, b) => capabilityRank(a) - capabilityRank(b))
|
||||
.map(capabilityLabel);
|
||||
|
||||
export const agentCapabilities = (agent: AgentCard): string[] =>
|
||||
Object.entries(agent.capabilities ?? {})
|
||||
.filter(([, on]) => on === true)
|
||||
.map(([key]) => (key === "pushNotifications" ? "Push notifications" : sentenceCase(key)));
|
||||
|
||||
export type HealthTone = "success" | "error" | "neutral";
|
||||
|
||||
const HEALTH: Record<string, { tone: HealthTone; label: string }> = {
|
||||
healthy: { tone: "success", label: "Healthy" },
|
||||
unhealthy: { tone: "error", label: "Unhealthy" },
|
||||
};
|
||||
|
||||
export const healthOf = (model: ModelGroupInfo): { tone: HealthTone; label: string } =>
|
||||
HEALTH[model.health_status ?? ""] ?? { tone: "neutral", label: "Not checked" };
|
||||
|
||||
export const relativeTime = (iso: string | null | undefined, now: number = Date.now()): string => {
|
||||
if (!iso) return "-";
|
||||
const minutes = Math.max(0, Math.round((now - new Date(iso).getTime()) / 60_000));
|
||||
if (minutes < 1) return "just now";
|
||||
if (minutes < 60) return `${minutes} min ago`;
|
||||
const hours = Math.round(minutes / 60);
|
||||
if (hours < 24) return `${hours} h ago`;
|
||||
return `${Math.round(hours / 24)} days ago`;
|
||||
};
|
||||
|
||||
export const gatewayUrl = (): string => getProxyBaseUrl();
|
||||
|
||||
export const wildcardExample = (modelGroup: string): string => modelGroup.replaceAll("*", "<model-name>");
|
||||
|
||||
export const firstPlus = (items: string[]): string | undefined =>
|
||||
items.length > 1 ? `${items[0]} +${items.length - 1}` : items[0];
|
||||
|
||||
const MCP_AUTH: Record<string, string> = {
|
||||
none: "None",
|
||||
oauth2: "OAuth",
|
||||
bearer_token: "Bearer token",
|
||||
api_key: "API key",
|
||||
};
|
||||
|
||||
export const mcpAuthLabel = (authType: string | null | undefined): string =>
|
||||
authType ? MCP_AUTH[authType] ?? authType : "-";
|
||||
|
||||
export const usefulLinkList = (
|
||||
links: Record<string, string | { url: string; index?: number }> | undefined,
|
||||
): { title: string; url: string }[] =>
|
||||
Object.entries(links ?? {})
|
||||
.map(([title, value]) => ({
|
||||
title,
|
||||
url: typeof value === "string" ? value : value.url,
|
||||
index: typeof value === "string" ? 0 : value.index ?? 0,
|
||||
}))
|
||||
.sort((a, b) => a.index - b.index)
|
||||
.map(({ title, url }) => ({ title, url }));
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { ModelGroupInfo, MCPServerData } from "@/components/PublicModelHubTableColumns";
|
||||
|
||||
import { curlSnippet, marketplaceCommand, mcpEndpoint, mcpSnippet, modelSnippet } from "./hubSnippets";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
getProxyBaseUrl: () => "https://gateway.example.com",
|
||||
getGlobalLitellmHeaderName: () => "Authorization",
|
||||
}));
|
||||
|
||||
const model = (overrides: Partial<ModelGroupInfo>): ModelGroupInfo => ({
|
||||
model_group: "anthropic/*",
|
||||
providers: ["anthropic"],
|
||||
supports_parallel_function_calling: false,
|
||||
supports_vision: false,
|
||||
supports_function_calling: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const server = { server_id: "s1", server_name: "github" } as MCPServerData;
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("model snippets", () => {
|
||||
it("points the SDK snippet at the gateway and a concrete model name", () => {
|
||||
vi.stubGlobal("window", { location: { origin: "http://ui.example.com" } });
|
||||
const snippet = modelSnippet(model({ mode: "chat" }));
|
||||
expect(snippet).toContain('base_url="https://gateway.example.com"');
|
||||
expect(snippet).toContain("anthropic/<model-name>");
|
||||
expect(snippet).not.toContain("ui.example.com");
|
||||
});
|
||||
|
||||
it("offers cURL only for chat models", () => {
|
||||
expect(curlSnippet(model({ mode: "chat" }))).toContain("curl https://gateway.example.com/v1/chat/completions");
|
||||
expect(curlSnippet(model({ mode: undefined }))).not.toBeNull();
|
||||
expect(curlSnippet(model({ mode: "embedding" }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MCP and skill snippets", () => {
|
||||
it("builds the MCP endpoint from the gateway and the server name", () => {
|
||||
expect(mcpEndpoint(server)).toBe("https://gateway.example.com/github/mcp");
|
||||
expect(mcpSnippet(server)).toContain('"url": "https://gateway.example.com/github/mcp"');
|
||||
});
|
||||
|
||||
it("adds the gateway's marketplace", () => {
|
||||
expect(marketplaceCommand()).toBe(
|
||||
"/plugin marketplace add https://gateway.example.com/claude-code/marketplace.json",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import { modelUsageExample } from "@/components/AIHub/modelUsageExample";
|
||||
import type { Plugin } from "@/components/claude_code_plugins/types";
|
||||
import { formatInstallCommand } from "@/components/claude_code_plugins/helpers";
|
||||
import { generateCodeSnippet } from "@/components/chat_ui/CodeSnippets";
|
||||
import { getEndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
|
||||
import { getGlobalLitellmHeaderName } from "@/components/networking";
|
||||
import type { AgentCard, MCPServerData, ModelGroupInfo } from "@/components/PublicModelHubTableColumns";
|
||||
import { isDecisionMode } from "@/lib/decisionModels";
|
||||
|
||||
import { gatewayUrl, wildcardExample } from "./hubFormat";
|
||||
|
||||
const GREETING = "Hello, how are you?";
|
||||
|
||||
export const modelSnippet = (model: ModelGroupInfo): string => {
|
||||
const name = wildcardExample(model.model_group);
|
||||
if (isDecisionMode(model.mode)) {
|
||||
return modelUsageExample(model.mode, gatewayUrl(), name, getGlobalLitellmHeaderName());
|
||||
}
|
||||
const params: Parameters<typeof generateCodeSnippet>[0] = {
|
||||
apiKeySource: "custom",
|
||||
accessToken: null,
|
||||
apiKey: "your_api_key",
|
||||
inputMessage: GREETING,
|
||||
chatHistory: [{ role: "user", content: GREETING, isImage: false }],
|
||||
selectedTags: [],
|
||||
selectedVectorStores: [],
|
||||
selectedGuardrails: [],
|
||||
selectedPolicies: [],
|
||||
selectedMCPServers: [],
|
||||
endpointType: getEndpointType(model.mode || "chat"),
|
||||
selectedModel: name,
|
||||
selectedSdk: "openai",
|
||||
proxySettings: { PROXY_BASE_URL: gatewayUrl() },
|
||||
};
|
||||
return generateCodeSnippet(params);
|
||||
};
|
||||
|
||||
export const curlSnippet = (model: ModelGroupInfo): string | null => {
|
||||
if (model.mode && model.mode !== "chat") return null;
|
||||
return `curl ${gatewayUrl()}/v1/chat/completions \\
|
||||
-H "Authorization: Bearer $LITELLM_API_KEY" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"model": "${wildcardExample(model.model_group)}",
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
}'`;
|
||||
};
|
||||
|
||||
export const agentSnippets = (agent: AgentCard): { title: string; code: string }[] => [
|
||||
{
|
||||
title: "Retrieve the agent card",
|
||||
code: `from a2a.client import A2ACardResolver
|
||||
|
||||
resolver = A2ACardResolver(httpx_client=httpx_client, base_url="${agent.url}")
|
||||
card = await resolver.get_agent_card()`,
|
||||
},
|
||||
{
|
||||
title: "Call the agent",
|
||||
code: `from a2a.client import A2AClient
|
||||
from a2a.types import MessageSendParams, SendMessageRequest
|
||||
|
||||
client = A2AClient(httpx_client=httpx_client, agent_card=card)
|
||||
request = SendMessageRequest(
|
||||
id=str(uuid4()),
|
||||
params=MessageSendParams(message={
|
||||
"role": "user",
|
||||
"parts": [{"kind": "text", "text": "how much is 10 USD in INR?"}],
|
||||
"messageId": uuid4().hex,
|
||||
}),
|
||||
)
|
||||
response = await client.send_message(request)`,
|
||||
},
|
||||
];
|
||||
|
||||
export const mcpEndpoint = (server: MCPServerData): string => `${gatewayUrl()}/${server.server_name}/mcp`;
|
||||
|
||||
export const mcpSnippet = (server: MCPServerData): string => `from fastmcp import Client
|
||||
|
||||
client = Client({
|
||||
"mcpServers": {
|
||||
"${server.server_name}": {
|
||||
"url": "${mcpEndpoint(server)}",
|
||||
"headers": {"x-litellm-api-key": "Bearer $LITELLM_API_KEY"},
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async with client:
|
||||
tools = await client.list_tools()`;
|
||||
|
||||
export const marketplaceCommand = (): string => `/plugin marketplace add ${gatewayUrl()}/claude-code/marketplace.json`;
|
||||
|
||||
export const skillInstallCommand = (skill: Plugin): string => formatInstallCommand(skill);
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { Plugin } from "@/components/claude_code_plugins/types";
|
||||
import {
|
||||
agentHubPublicModelsCall,
|
||||
getPublicModelHubInfo,
|
||||
getUiConfig,
|
||||
mcpHubPublicServersCall,
|
||||
skillHubPublicCall,
|
||||
} from "@/components/networking";
|
||||
import type { AgentCard, MCPServerData } from "@/components/PublicModelHubTableColumns";
|
||||
|
||||
import { usePublicModelHubFacets } from "./usePublicModelHubFacets";
|
||||
import { usePublicModelHubList } from "./usePublicModelHubList";
|
||||
|
||||
export interface HubInfo {
|
||||
docs_title: string;
|
||||
custom_docs_description: string | null;
|
||||
litellm_version: string;
|
||||
useful_links: Record<string, string | { url: string; index?: number }>;
|
||||
}
|
||||
|
||||
const asArray = <T>(data: unknown): T[] => (Array.isArray(data) ? (data as T[]) : []);
|
||||
|
||||
export function useHubData() {
|
||||
const config = useQuery({ queryKey: ["publicAiHub", "uiConfig"], queryFn: () => getUiConfig(), staleTime: Infinity });
|
||||
const ready = config.isSuccess || config.isError;
|
||||
|
||||
const info = useQuery<HubInfo>({
|
||||
queryKey: ["publicAiHub", "info"],
|
||||
queryFn: () => getPublicModelHubInfo(),
|
||||
enabled: ready,
|
||||
});
|
||||
const agents = useQuery<AgentCard[]>({
|
||||
queryKey: ["publicAiHub", "agents"],
|
||||
queryFn: async () => asArray<AgentCard>(await agentHubPublicModelsCall()),
|
||||
enabled: ready,
|
||||
});
|
||||
const mcp = useQuery<MCPServerData[]>({
|
||||
queryKey: ["publicAiHub", "mcp"],
|
||||
queryFn: async () => asArray<MCPServerData>(await mcpHubPublicServersCall()),
|
||||
enabled: ready,
|
||||
});
|
||||
const skills = useQuery<Plugin[]>({
|
||||
queryKey: ["publicAiHub", "skills"],
|
||||
queryFn: async () => (await skillHubPublicCall()).plugins ?? [],
|
||||
enabled: ready,
|
||||
});
|
||||
|
||||
const list = usePublicModelHubList(ready);
|
||||
const models = { ...list, isLoading: !ready || list.isLoading };
|
||||
const facets = usePublicModelHubFacets(ready);
|
||||
const [modelTotal, setModelTotal] = useState<number | undefined>(undefined);
|
||||
const settledTotal = !models.isLoading && !models.hasActiveQuery && !models.error ? models.rowCount : modelTotal;
|
||||
if (settledTotal !== modelTotal) setModelTotal(settledTotal);
|
||||
|
||||
return { info, agents, mcp, skills, models, facets, modelTotal: settledTotal };
|
||||
}
|
||||
|
||||
export type HubData = ReturnType<typeof useHubData>;
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
"use client";
|
||||
|
||||
import { createSerializer, parseAsString, useQueryState } from "nuqs";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import { useUrlTab } from "@/hooks/useUrlTab";
|
||||
|
||||
export const HUB_TABS = ["models", "agents", "mcp", "skills"] as const;
|
||||
export type HubTab = (typeof HUB_TABS)[number];
|
||||
|
||||
const OPEN_PARAM = { open: parseAsString };
|
||||
const serializeOpen = createSerializer(OPEN_PARAM);
|
||||
|
||||
export function useHubUrlState() {
|
||||
const [tab, setUrlTab] = useUrlTab(HUB_TABS, "models");
|
||||
const [open, setOpenParam] = useQueryState("open", OPEN_PARAM.open);
|
||||
const setOpen = useCallback((key: string | null) => void setOpenParam(key), [setOpenParam]);
|
||||
const setTab = useCallback(
|
||||
(next: HubTab) => {
|
||||
setUrlTab(next);
|
||||
setOpen(null);
|
||||
},
|
||||
[setUrlTab, setOpen],
|
||||
);
|
||||
return { tab, setTab, open, setOpen };
|
||||
}
|
||||
|
||||
export function useOpenHref() {
|
||||
const params = useSearchParams();
|
||||
return useCallback((key: string) => serializeOpen(params?.toString() ?? "", { open: key }), [params]);
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { useSyncExternalStore } from "react";
|
||||
|
||||
const NARROW = "(max-width: 1023px)";
|
||||
|
||||
const subscribe = (onChange: () => void) => {
|
||||
const query = window.matchMedia(NARROW);
|
||||
query.addEventListener("change", onChange);
|
||||
return () => query.removeEventListener("change", onChange);
|
||||
};
|
||||
|
||||
export const useIsNarrow = (): boolean =>
|
||||
useSyncExternalStore(
|
||||
subscribe,
|
||||
() => window.matchMedia(NARROW).matches,
|
||||
() => false,
|
||||
);
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import { getProxyBaseUrl, getUiPageUrl } from "@/components/networking";
|
||||
import { getCookie } from "@/utils/cookieUtils";
|
||||
import { checkTokenValidity } from "@/utils/jwtUtils";
|
||||
import { getLoginUrl } from "@/utils/returnUrlUtils";
|
||||
|
||||
export function useRequirePublicHubAuth() {
|
||||
const { data: uiSettings, isLoading } = useUISettings();
|
||||
const requireAuth = !isLoading && uiSettings?.values?.require_auth_for_public_ai_hub === true;
|
||||
useEffect(() => {
|
||||
if (requireAuth && !checkTokenValidity(getCookie("token"))) {
|
||||
window.location.replace(getUiPageUrl(getLoginUrl(getProxyBaseUrl())));
|
||||
}
|
||||
}, [requireAuth]);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue