mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-04 07:27:51 +00:00
feat(ui): rebuild the public AI Hub as a searchable list with a details pane (#49496)
* feat(ui): rebuild the public AI Hub as a searchable list with a details pane The public AI Hub at /ui/model_hub_table (and the older /ui/model_hub) now renders PublicAiHub, built from shadcn parts only: a header with the gateway's base URL and the admin's links, segmented tabs for models, agents, MCP servers and skills, and a list beside a details pane on desktop that becomes a bottom sheet on phones. The open item and tab live in the URL. It keeps every feature the old page had: server-side search, provider, mode and feature filters, sorting, paging, health, rate limits, prices, custom metadata, supported params, usage snippets, the require-auth redirect and the not-enabled state. cURL shows only for chat models, since the old one called chat completions for every mode. The navbar hides the community buttons below the sm breakpoint on every page, and the public navbar uses a smaller logo on phones, so Docs and Blog no longer collide with them. * fix(ui): harden the public AI Hub after adversarial review - Show "This AI Hub isn't available" only when every hub call fails; a failed models call now shows an inline error with Try again and leaves agents, MCP servers and skills usable (a gateway with no models answers the models list with 400) - Row links keep the page's other query params, so open-in-new-tab and copy link no longer 404 - A shared ?open= link resolves models beyond the first page, keeps the opened item when a search filters it out, and says when it is no longer listed instead of showing a different item - Searching, sorting and paging no longer blank the details pane or drop the Operational badge - Key MCP servers by their listed name, which is never null - Keep sub-cent prices and show Free for zero cost, fold evaluation into Decisions, sort agents, MCP servers and skills by name, restore word-wise search, and show a skill's source, author and date added - Drop the invented description for models without a mode, the empty agent modes row, the stray auth dash on MCP rows and NaN dates - Do not retry the UI config call, and close the 1023-1024px gap between the sheet and the details pane - Update the public hub Playwright spec for the new tabs and list, and prune the lint suppressions the old route pages needed * fix(ui): surface hub load errors and keep the open item steady Agents, MCP servers and skills now load through apiClient so an HTTP error shows the inline error instead of an empty "published yet" state, and an all-failing gateway reaches the unavailable page. Try again shows loading instead of an empty hub, the open model stays in the details pane when a search or page change hides its row, a zero-result search keeps the pane, phones get the not-listed sheet for a stale link, an empty open param means nothing opened, free models read Free in the list, and an invalid skill date is hidden
This commit is contained in:
parent
b9ad61d4f6
commit
cbe361d970
28 changed files with 2645 additions and 70 deletions
|
|
@ -64,7 +64,7 @@ test.describe("AI Hub (internal admin view)", () => {
|
|||
test.describe("Public model hub (/ui/model_hub_table)", () => {
|
||||
// No storageState — the public page is reached anonymously with a `key` query param.
|
||||
|
||||
test("Public model_hub_table loads and renders the Model Hub tab", async ({ page }) => {
|
||||
test("Public model_hub_table loads and renders the Models tab", async ({ page }) => {
|
||||
// The page expects the proxy key as the `key` query param. Use the master
|
||||
// key the e2e runner already exports — this matches what the AI Hub copy
|
||||
// button hands out.
|
||||
|
|
@ -74,13 +74,10 @@ test.describe("Public model hub (/ui/model_hub_table)", () => {
|
|||
// race can't briefly mask the tab while we're evaluating visibility.
|
||||
await dismissFeedbackPopup(page);
|
||||
|
||||
// Page loads (no auth redirect) and the Model Hub tab is always present.
|
||||
// Agent Hub and MCP Hub tabs are conditionally rendered only when public
|
||||
// agents/MCP servers exist, so we don't assert on them in a fresh CI run.
|
||||
await expect(page.getByRole("tab", { name: "Model Hub" })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole("tab", { name: /^Models/ })).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("Agent Hub and MCP Hub tabs render their public entries", async ({ page, request }) => {
|
||||
test("Agents and MCP servers tabs render their public entries", async ({ page, request }) => {
|
||||
const suffix = `${Date.now()}`;
|
||||
const agentName = `e2e-public-agent-${suffix}`;
|
||||
const mcpServerName = `e2e_public_mcp_${suffix}`;
|
||||
|
|
@ -145,18 +142,18 @@ test.describe("Public model hub (/ui/model_hub_table)", () => {
|
|||
await page.goto(`/ui/model_hub_table?key=${masterKey()}`);
|
||||
await dismissFeedbackPopup(page);
|
||||
|
||||
const agentHubTab = page.getByRole("tab", { name: "Agent Hub" });
|
||||
await expect(agentHubTab).toBeVisible({ timeout: 15_000 });
|
||||
await agentHubTab.click();
|
||||
await expect(page.getByText("Available Agents")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole("row").filter({ hasText: agentName })).toHaveCount(1, { timeout: 10_000 });
|
||||
const agentsTab = page.getByRole("tab", { name: /^Agents/ });
|
||||
await expect(agentsTab).toBeVisible({ timeout: 15_000 });
|
||||
await agentsTab.click();
|
||||
await expect(page.getByRole("textbox", { name: "Search agents" })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole("link").filter({ hasText: agentName })).toHaveCount(1, { timeout: 10_000 });
|
||||
await expect(page.getByText("E2E public agent").first()).toBeVisible();
|
||||
|
||||
const mcpHubTab = page.getByRole("tab", { name: "MCP Hub" });
|
||||
await expect(mcpHubTab).toBeVisible();
|
||||
await mcpHubTab.click();
|
||||
await expect(page.getByText("Available MCP Servers")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole("row").filter({ hasText: mcpServerName })).toHaveCount(1, { timeout: 10_000 });
|
||||
const mcpTab = page.getByRole("tab", { name: /^MCP/ });
|
||||
await expect(mcpTab).toBeVisible();
|
||||
await mcpTab.click();
|
||||
await expect(page.getByRole("textbox", { name: "Search MCP servers" })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole("link").filter({ hasText: mcpServerName })).toHaveCount(1, { timeout: 10_000 });
|
||||
await expect(page.getByText("E2E public MCP server").first()).toBeVisible();
|
||||
} finally {
|
||||
await request.post("/v1/mcp/make_public", { headers: auth, data: { mcp_server_ids: priorMcpIds } });
|
||||
|
|
|
|||
|
|
@ -1153,16 +1153,6 @@
|
|||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/app/model_hub/page.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/app/model_hub_table/page.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/components/AIHub/ModelHubTable.test.tsx": {
|
||||
"max-params": {
|
||||
"count": 1
|
||||
|
|
|
|||
|
|
@ -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,80 @@
|
|||
"use client";
|
||||
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
import type { AgentCard } from "@/components/PublicModelHubTableColumns";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Field, FieldDescription } from "@/components/ui/field";
|
||||
import { Item, ItemActions, ItemContent, ItemDescription, ItemGroup, ItemTitle } from "@/components/ui/item";
|
||||
|
||||
import { agentCapabilities } from "./hubFormat";
|
||||
import { Badges, CodeBlock, EndpointField, Facts, PaneSection } from "./HubParts";
|
||||
import { agentSnippets } from "./hubSnippets";
|
||||
import { InspectorHeader } from "./InspectorHeader";
|
||||
|
||||
const modes = (agent: AgentCard) => {
|
||||
const input = (agent.defaultInputModes ?? []).join(", ");
|
||||
const output = (agent.defaultOutputModes ?? []).join(", ");
|
||||
return input || output ? `${input || "-"} / ${output || "-"}` : null;
|
||||
};
|
||||
|
||||
export function AgentInspector({ agent }: { agent: AgentCard }) {
|
||||
const capabilities = agentCapabilities(agent);
|
||||
return (
|
||||
<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,92 @@
|
|||
"use client";
|
||||
|
||||
import { LayoutGrid, SearchX, TriangleAlert } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
|
||||
|
||||
import { SECTION_TITLE } from "./hubFormat";
|
||||
|
||||
const noResultsTitle = (noun: string, query: string, filtered: boolean) => {
|
||||
if (!filtered) return `No ${noun} published yet`;
|
||||
return query ? `No ${noun} match "${query}"` : `No ${noun} match these filters`;
|
||||
};
|
||||
|
||||
export function NoResults({
|
||||
icon,
|
||||
noun,
|
||||
query,
|
||||
onClear,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
noun: string;
|
||||
query: string;
|
||||
onClear?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoadFailed({ noun, onRetry }: { noun: string; onRetry: () => void }) {
|
||||
return (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<TriangleAlert />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle className={SECTION_TITLE}>Couldn't load {noun}</EmptyTitle>
|
||||
<EmptyDescription>The gateway returned an error. Try again in a moment</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<EmptyContent>
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
Try again
|
||||
</Button>
|
||||
</EmptyContent>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotListed({ name }: { name: string }) {
|
||||
return (
|
||||
<Empty className="h-full">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<SearchX />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle className={SECTION_TITLE}>“{name}” isn't listed here</EmptyTitle>
|
||||
<EmptyDescription>It may have been renamed or unpublished</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
103
ui/litellm-dashboard/src/components/publicModelHub/HubHeader.tsx
Normal file
103
ui/litellm-dashboard/src/components/publicModelHub/HubHeader.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown, ExternalLink, LayoutGrid } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Field, FieldDescription } from "@/components/ui/field";
|
||||
import { InputGroup, InputGroupAddon, InputGroupInput, InputGroupText } from "@/components/ui/input-group";
|
||||
|
||||
import { gatewayUrl, usefulLinkList } from "./hubFormat";
|
||||
import { CopyIconButton } from "./HubParts";
|
||||
import { StatusBadge } from "./HubStatus";
|
||||
import type { HubInfo } from "./useHubData";
|
||||
|
||||
export type HubStatus = "loading" | "operational" | "degraded" | "unavailable";
|
||||
|
||||
function LinksMenu({ info }: { info: HubInfo | undefined }) {
|
||||
const links = usefulLinkList(info?.useful_links);
|
||||
if (links.length === 0) return null;
|
||||
return (
|
||||
<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: HubStatus }) {
|
||||
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,234 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { Item, ItemContent, ItemGroup, ItemMedia } from "@/components/ui/item";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
|
||||
import { NotListed } from "./HubEmpty";
|
||||
import { InSheet } from "./InspectorHeader";
|
||||
import { useOpenHref } from "./useHubUrlState";
|
||||
import { useIsNarrow } from "./useIsNarrow";
|
||||
|
||||
function ListSkeleton() {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
const resolveSelection = <T,>(
|
||||
rows: T[],
|
||||
selectedKey: string | null,
|
||||
opened: T | undefined,
|
||||
getKey: (row: T) => string,
|
||||
) => {
|
||||
const chosen = selectedKey === null ? undefined : opened ?? rows.find((row) => getKey(row) === selectedKey);
|
||||
return { chosen, selected: selectedKey === null ? rows[0] : chosen };
|
||||
};
|
||||
|
||||
function useSheet<T>(
|
||||
chosen: T | undefined,
|
||||
missingKey: string | undefined,
|
||||
getKey: (row: T) => string,
|
||||
renderDetail: (row: T) => React.ReactNode,
|
||||
) {
|
||||
const [content, setContent] = React.useState<{ row?: T; missingKey?: string }>({ row: chosen });
|
||||
if (chosen !== undefined && chosen !== content.row) setContent({ row: chosen });
|
||||
if (missingKey !== undefined && missingKey !== content.missingKey) setContent({ missingKey });
|
||||
const { row } = content;
|
||||
return {
|
||||
open: chosen !== undefined || missingKey !== undefined,
|
||||
title: row ? getKey(row) : content.missingKey ?? "Details",
|
||||
body: row ? renderDetail(row) : content.missingKey && <NotListed name={content.missingKey} />,
|
||||
};
|
||||
}
|
||||
|
||||
export function HubMasterDetail<T>({
|
||||
switcher,
|
||||
toolbar,
|
||||
rows,
|
||||
loading,
|
||||
selectedKey,
|
||||
opened,
|
||||
openPending = false,
|
||||
getKey,
|
||||
onSelect,
|
||||
renderItem,
|
||||
renderDetail,
|
||||
empty,
|
||||
footer,
|
||||
}: {
|
||||
switcher?: React.ReactNode;
|
||||
toolbar: React.ReactNode;
|
||||
rows: T[];
|
||||
loading: boolean;
|
||||
selectedKey: string | null;
|
||||
opened?: T;
|
||||
openPending?: boolean;
|
||||
getKey: (row: T) => string;
|
||||
onSelect: (key: string | null) => void;
|
||||
renderItem: (row: T) => React.ReactNode;
|
||||
renderDetail: (row: T) => React.ReactNode;
|
||||
empty: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
}) {
|
||||
const narrow = useIsNarrow();
|
||||
const { chosen, selected } = resolveSelection(rows, selectedKey, opened, getKey);
|
||||
const isEmpty = !loading && rows.length === 0;
|
||||
const settled = !loading && !openPending && !isEmpty;
|
||||
const missingKey = selectedKey !== null && chosen === undefined && settled ? selectedKey : undefined;
|
||||
const sheet = useSheet(chosen, missingKey, getKey, renderDetail);
|
||||
const listOnly = isEmpty && chosen === undefined && !openPending;
|
||||
const detail = () => {
|
||||
if (loading || (selected === undefined && openPending)) return <DetailSkeleton />;
|
||||
if (selected === undefined) return selectedKey && <NotListed name={selectedKey} />;
|
||||
return renderDetail(selected);
|
||||
};
|
||||
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]",
|
||||
!listOnly && "lg:grid-cols-[minmax(0,5fr)_minmax(0,6fr)]",
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex min-h-0 flex-col", !listOnly && "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>
|
||||
{!listOnly && <ScrollArea className="hidden min-h-0 lg:block">{detail()}</ScrollArea>}
|
||||
<DetailSheet open={narrow && sheet.open} title={sheet.title} onClose={() => onSelect(null)}>
|
||||
{sheet.body}
|
||||
</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,29 @@
|
|||
"use client";
|
||||
|
||||
import type { MCPServerData } from "@/components/PublicModelHubTableColumns";
|
||||
|
||||
import { mcpAuthLabel } from "./hubFormat";
|
||||
import { CodeBlock, EndpointField, Facts, PaneSection } from "./HubParts";
|
||||
import { mcpEndpoint, mcpSnippet } from "./hubSnippets";
|
||||
import { InspectorHeader } from "./InspectorHeader";
|
||||
|
||||
export function McpInspector({ server }: { server: MCPServerData }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<InspectorHeader title={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,164 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
|
||||
import type { ModelGroupInfo } from "@/components/PublicModelHubTableColumns";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
import {
|
||||
formatPerMillion,
|
||||
formatTokens,
|
||||
modeLabel,
|
||||
modelCapabilities,
|
||||
providerName,
|
||||
wildcardExample,
|
||||
} from "./hubFormat";
|
||||
import { Badges, CodeBlock, type Fact, Facts, PaneSection, ProviderAvatar, ProviderName } from "./HubParts";
|
||||
import { curlSnippet, modelSnippet } from "./hubSnippets";
|
||||
import { HealthBadge } from "./HubStatus";
|
||||
import { InspectorHeader } from "./InspectorHeader";
|
||||
|
||||
const summary = (model: ModelGroupInfo): string | undefined => {
|
||||
if (model.description) return model.description;
|
||||
const providers = (model.providers ?? []).map(providerName).join(", ");
|
||||
return model.mode && providers ? `${modeLabel(model.mode)} model from ${providers}` : undefined;
|
||||
};
|
||||
|
||||
const tokens = (value: number | undefined) => (value ? `${formatTokens(value)} tokens` : null);
|
||||
|
||||
const modelFacts = (model: ModelGroupInfo): Fact[][] => {
|
||||
const providers = model.providers ?? [];
|
||||
const tags = model.custom_tags ?? [];
|
||||
return [
|
||||
[
|
||||
{ label: "Mode", value: model.mode ? modeLabel(model.mode) : null },
|
||||
{ label: "Context", value: tokens(model.max_input_tokens) },
|
||||
{ label: "Max output", value: tokens(model.max_output_tokens) },
|
||||
],
|
||||
[
|
||||
{ label: "Input / 1M tokens", value: formatPerMillion(model.input_cost_per_token) },
|
||||
{ label: "Output / 1M tokens", value: formatPerMillion(model.output_cost_per_token) },
|
||||
],
|
||||
[
|
||||
{ label: "Requests per minute", value: model.rpm },
|
||||
{ label: "Tokens per minute", value: model.tpm ? formatTokens(model.tpm) : null },
|
||||
],
|
||||
[
|
||||
{
|
||||
label: providers.length > 1 ? "Providers" : "Provider",
|
||||
wide: true,
|
||||
value: providers.length > 0 && (
|
||||
<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={summary(model)}
|
||||
/>
|
||||
{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,238 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowDownWideNarrow, ArrowUpNarrowWide, Boxes, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
import type { ModelGroupInfo } from "@/components/PublicModelHubTableColumns";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ButtonGroup } from "@/components/ui/button-group";
|
||||
import { ItemActions, ItemContent, ItemDescription, ItemMedia } from "@/components/ui/item";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Toggle } from "@/components/ui/toggle";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { displayMode } from "@/lib/decisionModels";
|
||||
|
||||
import { FilterChip } from "./FilterChip";
|
||||
import { featureFacetLabel, firstPlus, formatPerMillion, formatTokens, modeLabel, providerName } from "./hubFormat";
|
||||
import { HubMasterDetail } from "./HubMasterDetail";
|
||||
import { LoadFailed, NoResults } from "./HubEmpty";
|
||||
import { Joined, ProviderAvatar, RowTitle, SearchInput } from "./HubParts";
|
||||
import { type HubData, useOpenedModel } from "./useHubData";
|
||||
import { ModelInspector } from "./ModelInspector";
|
||||
|
||||
const SORTS = [
|
||||
{ value: "model_group", label: "Name", desc: false },
|
||||
{ value: "providers", label: "Provider", desc: false },
|
||||
{ value: "mode", label: "Mode", desc: false },
|
||||
{ value: "input_cost_per_token", label: "Input price", desc: false },
|
||||
{ value: "output_cost_per_token", label: "Output price", desc: false },
|
||||
{ value: "max_input_tokens", label: "Context", desc: true },
|
||||
{ value: "max_output_tokens", label: "Max output", desc: true },
|
||||
{ value: "rpm", label: "Rate limit", desc: true },
|
||||
];
|
||||
|
||||
type Models = HubData["models"];
|
||||
|
||||
function ModelsPager({ models }: { models: Models }) {
|
||||
const { pagination, rowCount, rows, onPaginationChange } = models;
|
||||
if (!rowCount) return null;
|
||||
const start = pagination.pageIndex * pagination.pageSize + 1;
|
||||
const end = start + rows.length - 1;
|
||||
const go = (pageIndex: number) => onPaginationChange({ ...pagination, pageIndex });
|
||||
return (
|
||||
<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={[...new Set(facets.modes.map((m) => displayMode(m) ?? m))].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 price = (cost: number | null | undefined, direction: "input" | "output") => {
|
||||
if (cost === 0) return `Free ${direction}`;
|
||||
return cost ? `${formatPerMillion(cost)} / 1M ${direction === "input" ? "in" : "out"}` : null;
|
||||
};
|
||||
|
||||
const prices = (model: ModelGroupInfo) =>
|
||||
model.input_cost_per_token === 0 && model.output_cost_per_token === 0
|
||||
? ["Free", null]
|
||||
: [price(model.input_cost_per_token, "input"), price(model.output_cost_per_token, "output")];
|
||||
|
||||
function ModelRow({ model }: { model: ModelGroupInfo }) {
|
||||
const [input, output] = prices(model);
|
||||
const priced = Boolean(input || output);
|
||||
return (
|
||||
<>
|
||||
<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 opened = useOpenedModel(selected, models.rows, !models.isLoading);
|
||||
const clear = () => {
|
||||
models.onSearchChange("");
|
||||
models.onColumnFiltersChange([]);
|
||||
};
|
||||
return (
|
||||
<HubMasterDetail
|
||||
rows={models.rows}
|
||||
loading={models.isLoading}
|
||||
selectedKey={selected}
|
||||
opened={opened.model}
|
||||
openPending={opened.pending}
|
||||
getKey={(model) => model.model_group}
|
||||
onSelect={onSelect}
|
||||
empty={
|
||||
models.error ? (
|
||||
<LoadFailed noun="models" onRetry={models.refetch} />
|
||||
) : (
|
||||
<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,342 @@
|
|||
import { fireEvent, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderWithProviders, screen, testQueryClient, waitFor } from "../../../tests/test-utils";
|
||||
import PublicAiHub from "./PublicAiHub";
|
||||
|
||||
const { apiGetMock, realGet, navigation, uiSettings, replaceMock, hubInfo } = vi.hoisted(() => ({
|
||||
apiGetMock: vi.fn(),
|
||||
realGet: { current: (..._args: unknown[]): Promise<unknown> => Promise.reject(new Error("unset")) },
|
||||
navigation: { params: new URLSearchParams() },
|
||||
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: () => navigation.params,
|
||||
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")>();
|
||||
realGet.current = actual.apiClient.get;
|
||||
return {
|
||||
...actual,
|
||||
apiClient: { ...actual.apiClient, get: apiGetMock },
|
||||
getUiConfig: vi.fn().mockResolvedValue({}),
|
||||
getProxyBaseUrl: vi.fn(() => "https://gateway.example.com"),
|
||||
getPublicModelHubInfo: vi.fn().mockResolvedValue(hubInfo),
|
||||
};
|
||||
});
|
||||
|
||||
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",
|
||||
input_cost_per_token: 0,
|
||||
output_cost_per_token: 0,
|
||||
description: "General purpose",
|
||||
},
|
||||
];
|
||||
|
||||
const AGENTS = [
|
||||
{
|
||||
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: [],
|
||||
},
|
||||
];
|
||||
|
||||
const HUBS: Record<string, unknown> = {
|
||||
"/public/agent_hub": AGENTS,
|
||||
"/public/mcp_hub": [],
|
||||
"/public/skill_hub": { plugins: [] },
|
||||
};
|
||||
|
||||
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 OFF_PAGE_MODEL = { model_group: "o1-pro", providers: ["openai"], mode: "chat", description: "Deep reasoning" };
|
||||
|
||||
type Query = { query?: Record<string, unknown> };
|
||||
|
||||
const respond = (path: string, options?: Query): Promise<unknown> => {
|
||||
if (path in HUBS) return Promise.resolve(HUBS[path]);
|
||||
if (path !== MODEL_HUB_PATH) return Promise.resolve({ ...page([]), meta: { page: 1, page_size: 100 } });
|
||||
const q = String(options?.query?.q ?? "");
|
||||
if (q === "o1-pro") return Promise.resolve(page([OFF_PAGE_MODEL]));
|
||||
return Promise.resolve(page(MODELS.filter((model) => model.model_group.includes(q))));
|
||||
};
|
||||
|
||||
const respondWithModels = () => apiGetMock.mockImplementation(respond);
|
||||
|
||||
const failModelsWith = (next: (options?: Query) => Promise<unknown>) =>
|
||||
apiGetMock.mockImplementation((path: string, options?: Query) =>
|
||||
path === MODEL_HUB_PATH ? next(options) : respond(path, options),
|
||||
);
|
||||
|
||||
const narrowScreen = (narrow: boolean) =>
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: narrow,
|
||||
media: query,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
}),
|
||||
});
|
||||
|
||||
const tokenExpiringIn = (seconds: number) => {
|
||||
const encode = (value: object) => btoa(JSON.stringify(value)).replace(/=+$/, "");
|
||||
return `${encode({ alg: "HS256" })}.${encode({ exp: Math.floor(Date.now() / 1000) + seconds })}.signature`;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
narrowScreen(false);
|
||||
vi.clearAllMocks();
|
||||
testQueryClient.clear();
|
||||
uiSettings.values = {};
|
||||
navigation.params = new URLSearchParams();
|
||||
document.cookie = "token=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/";
|
||||
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("keeps agents reachable when only the models call fails", async () => {
|
||||
const user = userEvent.setup();
|
||||
failModelsWith(() => Promise.reject(new Error("No models configured")));
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />);
|
||||
|
||||
expect(await screen.findByText("Couldn't load models")).toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "Search models" })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("tab", { name: /^Agents\s*1$/ }));
|
||||
expect(await screen.findByRole("link", { name: /research-assistant/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says the hub is unavailable when every hub call fails", async () => {
|
||||
apiGetMock.mockRejectedValue(new Error("Not found"));
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />);
|
||||
|
||||
expect(await screen.findByText("This AI Hub isn't available")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a load error when a hub answers with an HTTP error status", async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify({ detail: "Database error" }), { status: 500 }));
|
||||
apiGetMock.mockImplementation((path: string, options?: Query) =>
|
||||
path === "/public/agent_hub" ? realGet.current(path, options) : respond(path, options),
|
||||
);
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />);
|
||||
|
||||
await user.click(await screen.findByRole("tab", { name: /^Agents/ }));
|
||||
|
||||
expect(await screen.findByText("Couldn't load agents")).toBeInTheDocument();
|
||||
expect(screen.queryByText("No agents published yet")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows loading rather than an empty hub while Try again reloads models", async () => {
|
||||
const user = userEvent.setup();
|
||||
let finishRetry: (value: unknown) => void = () => {};
|
||||
failModelsWith(() => Promise.reject(new Error("Gateway timeout")));
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />);
|
||||
await screen.findByText("Couldn't load models");
|
||||
failModelsWith(() => new Promise((resolve) => (finishRetry = resolve)));
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Try again" }));
|
||||
|
||||
expect(screen.queryByText("No models published yet")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Couldn't load models")).not.toBeInTheDocument();
|
||||
finishRetry(page(MODELS));
|
||||
expect(await screen.findByRole("heading", { level: 2, name: "claude-sonnet-4-5" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the open model and its snippet tab while a search hides its row", async () => {
|
||||
const user = userEvent.setup();
|
||||
failModelsWith((options) =>
|
||||
options?.query?.q === "gpt-5" ? new Promise(() => {}) : respond(MODEL_HUB_PATH, options),
|
||||
);
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { open: "gpt-5" } });
|
||||
await screen.findByRole("heading", { level: 2, name: "gpt-5" });
|
||||
await user.click(screen.getByRole("tab", { name: "cURL" }));
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Search models" }), { target: { value: "claude" } });
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("link", { name: /gpt-5/ })).not.toBeInTheDocument());
|
||||
expect(screen.getByRole("heading", { level: 2, name: "gpt-5" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("tab", { name: "cURL" })).toHaveAttribute("aria-selected", "true");
|
||||
});
|
||||
|
||||
it("keeps the open item's details when a search matches nothing", async () => {
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />, {
|
||||
searchParams: { tab: "agents", open: "research-assistant" },
|
||||
});
|
||||
|
||||
fireEvent.change(await screen.findByRole("textbox", { name: "Search agents" }), { target: { value: "billing" } });
|
||||
|
||||
expect(await screen.findByText('No agents match "billing"')).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { level: 2, name: "research-assistant" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks a free model as free in its row", async () => {
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />);
|
||||
|
||||
const row = await screen.findByRole("link", { name: /gpt-5/ });
|
||||
|
||||
expect(within(row).getAllByText("Free").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("treats an empty open param as nothing opened", async () => {
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { open: "" } });
|
||||
|
||||
expect(await screen.findByRole("heading", { level: 2, name: "claude-sonnet-4-5" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("tells phone visitors when a shared link names something no longer listed", async () => {
|
||||
narrowScreen(true);
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { open: "retired-model" } });
|
||||
|
||||
const sheet = await screen.findByRole("dialog");
|
||||
|
||||
expect(within(sheet).getByText("\u201cretired-model\u201d isn't listed here")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the page's other query params in each row's link", async () => {
|
||||
navigation.params = new URLSearchParams("tab=agents&key=abc");
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { tab: "agents", key: "abc" } });
|
||||
|
||||
expect(await screen.findByRole("link", { name: /research-assistant/ })).toHaveAttribute(
|
||||
"href",
|
||||
"?tab=agents&key=abc&open=research-assistant",
|
||||
);
|
||||
});
|
||||
|
||||
it("clears the open item when switching tabs", async () => {
|
||||
const onUrlUpdate = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { open: "gpt-5" }, onUrlUpdate });
|
||||
|
||||
expect(await screen.findByRole("heading", { level: 2, name: "gpt-5" })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("tab", { name: /^Agents/ }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(onUrlUpdate).toHaveBeenLastCalledWith(expect.objectContaining({ queryString: "?tab=agents" })),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens a shared link to a model that is not on the first page", async () => {
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { open: "o1-pro" } });
|
||||
|
||||
expect(await screen.findByRole("heading", { level: 2, name: "o1-pro" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Deep reasoning")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says when a shared link names something that is no longer listed", async () => {
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />, { searchParams: { open: "retired-model" } });
|
||||
|
||||
expect(await screen.findByText("\u201cretired-model\u201d isn't listed here")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("heading", { level: 2, name: "claude-sonnet-4-5" })).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")));
|
||||
});
|
||||
|
||||
it("keeps a signed-in visitor on the hub when the admin requires sign-in", async () => {
|
||||
uiSettings.values = { require_auth_for_public_ai_hub: true };
|
||||
document.cookie = `token=${tokenExpiringIn(3600)}; path=/`;
|
||||
renderWithProviders(<PublicAiHub accessToken={null} />);
|
||||
|
||||
expect(await screen.findByRole("heading", { level: 2, name: "claude-sonnet-4-5" })).toBeInTheDocument();
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
"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, type HubStatus } 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): HubStatus => {
|
||||
const failures = [hub.models.error, hub.agents.error, hub.mcp.error, hub.skills.error].filter(Boolean);
|
||||
if (failures.length === 4) return "unavailable";
|
||||
if (hub.models.isLoading) return "loading";
|
||||
return failures.length > 0 ? "degraded" : "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}
|
||||
error={hub.agents.isError}
|
||||
onRetry={() => void hub.agents.refetch()}
|
||||
{...paneProps}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="mcp">
|
||||
<McpPane
|
||||
rows={hub.mcp.data}
|
||||
loading={hub.mcp.isPending}
|
||||
error={hub.mcp.isError}
|
||||
onRetry={() => void hub.mcp.refetch()}
|
||||
{...paneProps}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="skills">
|
||||
<SkillsPane
|
||||
rows={hub.skills.data}
|
||||
loading={hub.skills.isPending}
|
||||
error={hub.skills.isError}
|
||||
onRetry={() => void hub.skills.refetch()}
|
||||
{...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,184 @@
|
|||
"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 { matchesSearchTerm } from "@/utils/searchUtils";
|
||||
|
||||
import { AgentInspector } from "./AgentInspector";
|
||||
import { FilterChip } from "./FilterChip";
|
||||
import { mcpAuthLabel } from "./hubFormat";
|
||||
import { HubMasterDetail } from "./HubMasterDetail";
|
||||
import { LoadFailed, NoResults } from "./HubEmpty";
|
||||
import { RowTitle, SearchInput } from "./HubParts";
|
||||
import { McpInspector } from "./McpInspector";
|
||||
import { SkillInspector } from "./SkillInspector";
|
||||
|
||||
interface ResourcePaneProps<T> {
|
||||
rows: T[];
|
||||
loading: boolean;
|
||||
error: boolean;
|
||||
onRetry: () => void;
|
||||
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,
|
||||
error,
|
||||
onRetry,
|
||||
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 sorted = [...rows].sort((a, b) => title(a).localeCompare(title(b)));
|
||||
const shown = sorted.filter(
|
||||
(row) =>
|
||||
matchesSearchTerm(query, search(row)) &&
|
||||
(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}
|
||||
opened={rows.find((row) => getKey(row) === selected)}
|
||||
getKey={getKey}
|
||||
onSelect={onSelect}
|
||||
empty={
|
||||
error ? (
|
||||
<LoadFailed noun={noun} onRetry={onRetry} />
|
||||
) : (
|
||||
<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;
|
||||
error: boolean;
|
||||
onRetry: () => void;
|
||||
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.name, server.mcp_info?.description]}
|
||||
filter={{
|
||||
label: "Transport",
|
||||
noun: "transports",
|
||||
values: (server) => (server.transport ? [server.transport] : []),
|
||||
}}
|
||||
getKey={(server) => server.name}
|
||||
title={(server) => server.name}
|
||||
description={(server) => server.mcp_info?.description}
|
||||
meta={(server) => (server.auth_type ? mcpAuthLabel(server.auth_type) : undefined)}
|
||||
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,66 @@
|
|||
"use client";
|
||||
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
import { getSourceDisplayText, getSourceLink } from "@/components/claude_code_plugins/helpers";
|
||||
import type { Plugin } from "@/components/claude_code_plugins/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Field, FieldDescription } from "@/components/ui/field";
|
||||
|
||||
import { Badges, CodeBlock, Facts, PaneSection } from "./HubParts";
|
||||
import { marketplaceCommand, skillInstallCommand } from "./hubSnippets";
|
||||
import { InspectorHeader } from "./InspectorHeader";
|
||||
|
||||
function sourceValue(source: Plugin["source"]) {
|
||||
const link = getSourceLink(source);
|
||||
const href = link && source.source === "git-subdir" && source.path ? `${link}/tree/main/${source.path}` : link;
|
||||
if (!href) return getSourceDisplayText(source) || null;
|
||||
return (
|
||||
<Button
|
||||
variant="link"
|
||||
nativeButton={false}
|
||||
className="h-auto max-w-full justify-start px-0"
|
||||
render={<a href={href} target="_blank" rel="noopener noreferrer" />}
|
||||
>
|
||||
<span className="truncate">{href.replace(/^https:\/\//, "")}</span>
|
||||
<ExternalLink />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkillInspector({ skill }: { skill: Plugin }) {
|
||||
const keywords = skill.keywords ?? [];
|
||||
const addedAt = skill.created_at ? new Date(skill.created_at) : null;
|
||||
const added = addedAt && !Number.isNaN(addedAt.getTime()) ? addedAt.toLocaleDateString() : null;
|
||||
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: "Source", wide: true, value: sourceValue(skill.source) },
|
||||
{ label: "Author", value: skill.author?.name },
|
||||
{ label: "Added", value: added },
|
||||
],
|
||||
[{ 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,138 @@
|
|||
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("evaluation")).toBe("Decisions");
|
||||
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 at least two decimals and keeps sub-cent prices", () => {
|
||||
expect(formatPerMillion(0.000003)).toBe("$3.00");
|
||||
expect(formatPerMillion(0.0000165)).toBe("$16.50");
|
||||
expect(formatPerMillion(3.75e-8)).toBe("$0.0375");
|
||||
expect(formatPerMillion(2e-9)).toBe("$0.002");
|
||||
expect(formatPerMillion(0)).toBe("Free");
|
||||
expect(formatPerMillion(undefined)).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: true, x: false },
|
||||
};
|
||||
expect(agentCapabilities(agent as AgentCard)).toEqual([
|
||||
"Streaming",
|
||||
"Push notifications",
|
||||
"State transition history",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
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-30T12:00:00Z", now)).toBe("1 day ago");
|
||||
expect(relativeTime("2026-10-29T12:00:00Z", now)).toBe("2 days ago");
|
||||
expect(relativeTime(undefined, now)).toBe("-");
|
||||
expect(relativeTime("not-a-date", 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([]);
|
||||
});
|
||||
});
|
||||
142
ui/litellm-dashboard/src/components/publicModelHub/hubFormat.ts
Normal file
142
ui/litellm-dashboard/src/components/publicModelHub/hubFormat.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { getProxyBaseUrl } from "@/components/networking";
|
||||
import type { AgentCard, ModelGroupInfo } from "@/components/PublicModelHubTableColumns";
|
||||
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
|
||||
import { displayMode } from "@/lib/decisionModels";
|
||||
|
||||
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 => {
|
||||
const shown = displayMode(mode);
|
||||
return shown ? MODE_LABELS[shown] ?? sentenceCase(shown) : "-";
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const PER_MILLION_FORMAT: Intl.NumberFormatOptions = {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 4,
|
||||
useGrouping: false,
|
||||
};
|
||||
|
||||
const PER_MILLION = new Intl.NumberFormat("en-US", PER_MILLION_FORMAT);
|
||||
|
||||
export const formatPerMillion = (cost: number | null | undefined): string => {
|
||||
if (cost === 0) return "Free";
|
||||
return cost ? PER_MILLION.format(cost * 1_000_000) : "-";
|
||||
};
|
||||
|
||||
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]) => sentenceCase(key.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase()));
|
||||
|
||||
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 => {
|
||||
const then = iso ? new Date(iso).getTime() : NaN;
|
||||
if (Number.isNaN(then)) return "-";
|
||||
const minutes = Math.max(0, Math.round((now - then) / 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`;
|
||||
const days = Math.round(hours / 24);
|
||||
return `${days} ${days === 1 ? "day" : "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,59 @@
|
|||
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", name: "github", 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("routes a server without a server name through its listed name", () => {
|
||||
const aliased = { server_id: "s2", name: "my_alias", server_name: null } as unknown as MCPServerData;
|
||||
expect(mcpEndpoint(aliased)).toBe("https://gateway.example.com/my_alias/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.name}/mcp`;
|
||||
|
||||
export const mcpSnippet = (server: MCPServerData): string => `from fastmcp import Client
|
||||
|
||||
client = Client({
|
||||
"mcpServers": {
|
||||
"${server.name}": {
|
||||
"url": "${mcpEndpoint(server)}",
|
||||
"headers": {"x-litellm-api-key": "Bearer your_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,88 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { ResourceListPage } from "@/app/(dashboard)/hooks/common/useResourceList";
|
||||
import type { Plugin } from "@/components/claude_code_plugins/types";
|
||||
import { apiClient, getPublicModelHubInfo, getUiConfig } from "@/components/networking";
|
||||
import type { AgentCard, MCPServerData, ModelGroupInfo } from "@/components/PublicModelHubTableColumns";
|
||||
|
||||
import { usePublicModelHubFacets } from "./usePublicModelHubFacets";
|
||||
import { PUBLIC_MODEL_HUB_PAGE_SIZE, PUBLIC_MODEL_HUB_PATH, 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[]) : []);
|
||||
|
||||
const UI_CONFIG_QUERY = {
|
||||
queryKey: ["publicAiHub", "uiConfig"],
|
||||
queryFn: () => getUiConfig(),
|
||||
staleTime: Infinity,
|
||||
retry: false,
|
||||
};
|
||||
|
||||
export function useHubData() {
|
||||
const config = useQuery(UI_CONFIG_QUERY);
|
||||
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 ({ signal }) => asArray<AgentCard>(await apiClient.get("/public/agent_hub", { signal })),
|
||||
enabled: ready,
|
||||
});
|
||||
const mcp = useQuery<MCPServerData[]>({
|
||||
queryKey: ["publicAiHub", "mcp"],
|
||||
queryFn: async ({ signal }) => asArray<MCPServerData>(await apiClient.get("/public/mcp_hub", { signal })),
|
||||
enabled: ready,
|
||||
});
|
||||
const skills = useQuery<Plugin[]>({
|
||||
queryKey: ["publicAiHub", "skills"],
|
||||
queryFn: async ({ signal }) =>
|
||||
(await apiClient.get<{ plugins?: Plugin[] }>("/public/skill_hub", { signal })).plugins ?? [],
|
||||
enabled: ready,
|
||||
});
|
||||
|
||||
const list = usePublicModelHubList(ready);
|
||||
const listLoading = !ready || list.isLoading;
|
||||
const models = { ...list, isLoading: listLoading && list.rows.length === 0 };
|
||||
const facets = usePublicModelHubFacets(ready);
|
||||
const [modelTotal, setModelTotal] = useState<number | undefined>(undefined);
|
||||
const settledTotal = !listLoading && !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>;
|
||||
|
||||
const openedModelQuery = (open: string | null, enabled: boolean) => ({
|
||||
queryKey: ["publicAiHub", "openedModel", open],
|
||||
queryFn: ({ signal }: { signal: AbortSignal }) =>
|
||||
apiClient.get<ResourceListPage<ModelGroupInfo>>(PUBLIC_MODEL_HUB_PATH, {
|
||||
query: { q: open ?? "", page_size: PUBLIC_MODEL_HUB_PAGE_SIZE },
|
||||
signal,
|
||||
}),
|
||||
enabled,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
export function useOpenedModel(open: string | null, rows: ModelGroupInfo[], enabled: boolean) {
|
||||
const onPage = rows.find((model) => model.model_group === open);
|
||||
const [kept, setKept] = useState<ModelGroupInfo | undefined>(undefined);
|
||||
const known = onPage ?? (kept?.model_group === open ? kept : undefined);
|
||||
const lookup = useQuery(openedModelQuery(open, enabled && open !== null && known === undefined));
|
||||
const model = known ?? lookup.data?.data.find((found) => found.model_group === open);
|
||||
if (model !== undefined && model !== kept) setKept(model);
|
||||
return { model, pending: model === undefined && lookup.isLoading };
|
||||
}
|
||||
|
|
@ -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: open || null, setOpen };
|
||||
}
|
||||
|
||||
export function useOpenHref() {
|
||||
const params = useSearchParams();
|
||||
return useCallback((key: string) => serializeOpen(params ?? new URLSearchParams(), { open: key }), [params]);
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { useSyncExternalStore } from "react";
|
||||
|
||||
const NARROW = "(width < 64rem)";
|
||||
|
||||
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