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:
Yuneng Jiang 2026-10-31 14:19:17 -07:00
parent d2321581ec
commit bd546f1dac
No known key found for this signature in database
26 changed files with 2262 additions and 44 deletions

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
)}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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&apos;t available</EmptyTitle>
<EmptyDescription>Ask your proxy admin to make it public from their Admin UI</EmptyDescription>
</EmptyHeader>
</Empty>
);
}

View 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>
);
}

View file

@ -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>
);
}

View 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>
));
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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} />}
/>
);
}

View file

@ -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")));
});
});

View file

@ -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>
);
}

View file

@ -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} />}
/>
);
}

View file

@ -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>
);
}

View file

@ -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([]);
});
});

View 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 }));

View file

@ -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",
);
});
});

View file

@ -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);

View file

@ -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>;

View file

@ -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]);
}

View file

@ -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,
);

View file

@ -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]);
}