From e9803780565481bc9dc100c67917892a2dc34a4f Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 31 Oct 2026 14:54:12 -0700 Subject: [PATCH] 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 --- tests/e2e/ui/tests/modelHub/modelHub.spec.ts | 29 +++---- ui/litellm-dashboard/eslint-suppressions.json | 10 --- .../publicModelHub/AgentInspector.tsx | 7 +- .../components/publicModelHub/HubEmpty.tsx | 35 +++++++- .../components/publicModelHub/HubHeader.tsx | 10 +-- .../publicModelHub/HubMasterDetail.tsx | 20 +++-- .../publicModelHub/McpInspector.tsx | 6 +- .../publicModelHub/ModelInspector.tsx | 10 ++- .../components/publicModelHub/ModelsPane.tsx | 29 +++++-- .../PublicAiHub.integration.test.tsx | 87 +++++++++++++++++-- .../components/publicModelHub/PublicAiHub.tsx | 32 +++++-- .../publicModelHub/ResourcePanes.tsx | 38 +++++--- .../publicModelHub/SkillInspector.tsx | 27 ++++++ .../publicModelHub/hubFormat.test.ts | 20 ++++- .../components/publicModelHub/hubFormat.ts | 33 +++++-- .../publicModelHub/hubSnippets.test.ts | 7 +- .../components/publicModelHub/hubSnippets.ts | 6 +- .../components/publicModelHub/useHubData.ts | 37 ++++++-- .../publicModelHub/useHubUrlState.ts | 2 +- .../components/publicModelHub/useIsNarrow.ts | 2 +- 20 files changed, 338 insertions(+), 109 deletions(-) diff --git a/tests/e2e/ui/tests/modelHub/modelHub.spec.ts b/tests/e2e/ui/tests/modelHub/modelHub.spec.ts index 04b02d9ccc4..f8ce4faf45e 100644 --- a/tests/e2e/ui/tests/modelHub/modelHub.spec.ts +++ b/tests/e2e/ui/tests/modelHub/modelHub.spec.ts @@ -64,7 +64,7 @@ test.describe("AI Hub (internal admin view)", () => { test.describe("Public model hub (/ui/model_hub_table)", () => { // No storageState — the public page is reached anonymously with a `key` query param. - test("Public model_hub_table loads and renders the Model Hub tab", async ({ page }) => { + test("Public model_hub_table loads and renders the Models tab", async ({ page }) => { // The page expects the proxy key as the `key` query param. Use the master // key the e2e runner already exports — this matches what the AI Hub copy // button hands out. @@ -74,13 +74,10 @@ test.describe("Public model hub (/ui/model_hub_table)", () => { // race can't briefly mask the tab while we're evaluating visibility. await dismissFeedbackPopup(page); - // Page loads (no auth redirect) and the Model Hub tab is always present. - // Agent Hub and MCP Hub tabs are conditionally rendered only when public - // agents/MCP servers exist, so we don't assert on them in a fresh CI run. - await expect(page.getByRole("tab", { name: "Model Hub" })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole("tab", { name: /^Models/ })).toBeVisible({ timeout: 10_000 }); }); - test("Agent Hub and MCP Hub tabs render their public entries", async ({ page, request }) => { + test("Agents and MCP servers tabs render their public entries", async ({ page, request }) => { const suffix = `${Date.now()}`; const agentName = `e2e-public-agent-${suffix}`; const mcpServerName = `e2e_public_mcp_${suffix}`; @@ -145,18 +142,18 @@ test.describe("Public model hub (/ui/model_hub_table)", () => { await page.goto(`/ui/model_hub_table?key=${masterKey()}`); await dismissFeedbackPopup(page); - const agentHubTab = page.getByRole("tab", { name: "Agent Hub" }); - await expect(agentHubTab).toBeVisible({ timeout: 15_000 }); - await agentHubTab.click(); - await expect(page.getByText("Available Agents")).toBeVisible({ timeout: 10_000 }); - await expect(page.getByRole("row").filter({ hasText: agentName })).toHaveCount(1, { timeout: 10_000 }); + const agentsTab = page.getByRole("tab", { name: /^Agents/ }); + await expect(agentsTab).toBeVisible({ timeout: 15_000 }); + await agentsTab.click(); + await expect(page.getByRole("textbox", { name: "Search agents" })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole("link").filter({ hasText: agentName })).toHaveCount(1, { timeout: 10_000 }); await expect(page.getByText("E2E public agent").first()).toBeVisible(); - const mcpHubTab = page.getByRole("tab", { name: "MCP Hub" }); - await expect(mcpHubTab).toBeVisible(); - await mcpHubTab.click(); - await expect(page.getByText("Available MCP Servers")).toBeVisible({ timeout: 10_000 }); - await expect(page.getByRole("row").filter({ hasText: mcpServerName })).toHaveCount(1, { timeout: 10_000 }); + const mcpTab = page.getByRole("tab", { name: /^MCP/ }); + await expect(mcpTab).toBeVisible(); + await mcpTab.click(); + await expect(page.getByRole("textbox", { name: "Search MCP servers" })).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole("link").filter({ hasText: mcpServerName })).toHaveCount(1, { timeout: 10_000 }); await expect(page.getByText("E2E public MCP server").first()).toBeVisible(); } finally { await request.post("/v1/mcp/make_public", { headers: auth, data: { mcp_server_ids: priorMcpIds } }); diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index c14f3e6f036..c6adb27ee1b 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -1155,16 +1155,6 @@ "count": 2 } }, - "src/app/model_hub/page.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, - "src/app/model_hub_table/page.tsx": { - "react-hooks/set-state-in-effect": { - "count": 1 - } - }, "src/components/AIHub/ModelHubTable.test.tsx": { "max-params": { "count": 1 diff --git a/ui/litellm-dashboard/src/components/publicModelHub/AgentInspector.tsx b/ui/litellm-dashboard/src/components/publicModelHub/AgentInspector.tsx index 493578b4f16..1a35dbf0083 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/AgentInspector.tsx +++ b/ui/litellm-dashboard/src/components/publicModelHub/AgentInspector.tsx @@ -12,8 +12,11 @@ import { Badges, CodeBlock, EndpointField, Facts, PaneSection } from "./HubParts import { agentSnippets } from "./hubSnippets"; import { InspectorHeader } from "./InspectorHeader"; -const modes = (agent: AgentCard) => - `${(agent.defaultInputModes ?? []).join(", ")} / ${(agent.defaultOutputModes ?? []).join(", ")}`; +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); diff --git a/ui/litellm-dashboard/src/components/publicModelHub/HubEmpty.tsx b/ui/litellm-dashboard/src/components/publicModelHub/HubEmpty.tsx index bcf142f1fa6..eac852f2ca9 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/HubEmpty.tsx +++ b/ui/litellm-dashboard/src/components/publicModelHub/HubEmpty.tsx @@ -1,6 +1,6 @@ "use client"; -import { LayoutGrid } from "lucide-react"; +import { LayoutGrid, SearchX, TriangleAlert } from "lucide-react"; import * as React from "react"; import { Button } from "@/components/ui/button"; @@ -57,3 +57,36 @@ export function HubNotEnabled() { ); } + +export function LoadFailed({ noun, onRetry }: { noun: string; onRetry: () => void }) { + return ( + + + + + + Couldn't load {noun} + The gateway returned an error. Try again in a moment + + + + + + ); +} + +export function NotListed({ name }: { name: string }) { + return ( + + + + + + “{name}” isn't listed here + It may have been renamed or unpublished + + + ); +} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/HubHeader.tsx b/ui/litellm-dashboard/src/components/publicModelHub/HubHeader.tsx index 527953052be..ca8df489cfa 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/HubHeader.tsx +++ b/ui/litellm-dashboard/src/components/publicModelHub/HubHeader.tsx @@ -20,6 +20,8 @@ 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; @@ -75,13 +77,7 @@ function BaseUrlField() { ); } -export function HubHeader({ - info, - status, -}: { - info: HubInfo | undefined; - status: "loading" | "operational" | "unavailable"; -}) { +export function HubHeader({ info, status }: { info: HubInfo | undefined; status: HubStatus }) { return (
diff --git a/ui/litellm-dashboard/src/components/publicModelHub/HubMasterDetail.tsx b/ui/litellm-dashboard/src/components/publicModelHub/HubMasterDetail.tsx index b21a16c5a86..f317522e217 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/HubMasterDetail.tsx +++ b/ui/litellm-dashboard/src/components/publicModelHub/HubMasterDetail.tsx @@ -8,6 +8,7 @@ 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"; @@ -104,6 +105,8 @@ export function HubMasterDetail({ rows, loading, selectedKey, + opened, + openPending = false, getKey, onSelect, renderItem, @@ -116,6 +119,8 @@ export function HubMasterDetail({ 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; @@ -124,11 +129,16 @@ export function HubMasterDetail({ footer?: React.ReactNode; }) { const narrow = useIsNarrow(); - const chosen = rows.find((row) => getKey(row) === selectedKey); - const selected = chosen ?? rows[0]; + const chosen = selectedKey === null ? undefined : opened ?? rows.find((row) => getKey(row) === selectedKey); + const selected = selectedKey === null ? rows[0] : chosen; const [sheetRow, setSheetRow] = React.useState(chosen); if (chosen !== undefined && chosen !== sheetRow) setSheetRow(chosen); const isEmpty = !loading && rows.length === 0; + const detail = () => { + if (loading || (selected === undefined && openPending)) return ; + if (selected === undefined) return selectedKey && ; + return renderDetail(selected); + }; const list = isEmpty ? ( empty ) : ( @@ -158,11 +168,7 @@ export function HubMasterDetail({
{loading ? : list} - {!isEmpty && ( - - {loading ? : selected && renderDetail(selected)} - - )} + {!isEmpty && {detail()}} - + { + 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[][] => { @@ -133,9 +139,7 @@ export function ModelInspector({ model }: { model: ModelGroupInfo }) { title={model.model_group} copyLabel="Copy model name" status={} - description={ - model.description || `${modeLabel(model.mode)} model from ${providers.map(providerName).join(", ")}` - } + description={summary(model)} /> {model.model_group.includes("*") && ( diff --git a/ui/litellm-dashboard/src/components/publicModelHub/ModelsPane.tsx b/ui/litellm-dashboard/src/components/publicModelHub/ModelsPane.tsx index 91818b28ad5..7120d14feb1 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/ModelsPane.tsx +++ b/ui/litellm-dashboard/src/components/publicModelHub/ModelsPane.tsx @@ -9,13 +9,14 @@ import { ItemActions, ItemContent, ItemDescription, ItemMedia } from "@/componen 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 { NoResults } from "./HubEmpty"; +import { LoadFailed, NoResults } from "./HubEmpty"; import { Joined, ProviderAvatar, RowTitle, SearchInput } from "./HubParts"; -import type { HubData } from "./useHubData"; +import { type HubData, useOpenedModel } from "./useHubData"; import { ModelInspector } from "./ModelInspector"; const SORTS = [ @@ -128,7 +129,10 @@ function ModelsToolbar({ hub }: { hub: HubData }) { ({ value: m, label: modeLabel(m) }))} + options={[...new Set(facets.modes.map((m) => displayMode(m) ?? m))].map((m) => ({ + value: m, + label: modeLabel(m), + }))} value={models.modeValues} onChange={models.onModesChange} /> @@ -194,6 +198,7 @@ export function ModelsPane({ onSelect: (key: string | null) => void; }) { const { models } = hub; + const opened = useOpenedModel(selected, models.rows, !models.isLoading); const clear = () => { models.onSearchChange(""); models.onColumnFiltersChange([]); @@ -203,15 +208,21 @@ export function ModelsPane({ rows={models.rows} loading={models.isLoading} selectedKey={selected} + opened={opened.model} + openPending={opened.pending} getKey={(model) => model.model_group} onSelect={onSelect} empty={ - } - noun="models" - query={models.searchValue} - onClear={models.hasActiveQuery ? clear : undefined} - /> + models.error ? ( + + ) : ( + } + noun="models" + query={models.searchValue} + onClear={models.hasActiveQuery ? clear : undefined} + /> + ) } toolbar={} renderItem={(model) => } diff --git a/ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.integration.test.tsx b/ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.integration.test.tsx index 3f873eb8b59..b51da66ce27 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.integration.test.tsx @@ -2,11 +2,14 @@ import { fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { agentHubPublicModelsCall, mcpHubPublicServersCall, skillHubPublicCall } from "@/components/networking"; + import { renderWithProviders, screen, testQueryClient, waitFor } from "../../../tests/test-utils"; import PublicAiHub from "./PublicAiHub"; -const { apiGetMock, uiSettings, replaceMock, hubInfo } = vi.hoisted(() => ({ +const { apiGetMock, navigation, uiSettings, replaceMock, hubInfo } = vi.hoisted(() => ({ apiGetMock: vi.fn(), + navigation: { params: new URLSearchParams() }, uiSettings: { values: {} as Record }, replaceMock: vi.fn(), hubInfo: { @@ -18,7 +21,7 @@ const { apiGetMock, uiSettings, replaceMock, hubInfo } = vi.hoisted(() => ({ })); vi.mock("next/navigation", () => ({ - useSearchParams: () => new URLSearchParams(), + useSearchParams: () => navigation.params, usePathname: () => "/model_hub_table", useRouter: () => ({ push: vi.fn(), replace: vi.fn(), refresh: vi.fn() }), })); @@ -75,10 +78,18 @@ const page = (data: unknown[]) => ({ 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" }; + const respondWithModels = () => - apiGetMock.mockImplementation((path: string) => - Promise.resolve(path === MODEL_HUB_PATH ? page(MODELS) : { ...page([]), meta: { page: 1, page_size: 100 } }), - ); + apiGetMock.mockImplementation((path: string, options?: { query?: Record }) => { + if (path !== MODEL_HUB_PATH) return Promise.resolve({ ...page([]), meta: { page: 1, page_size: 100 } }); + return Promise.resolve(options?.query?.q === "o1-pro" ? page([OFF_PAGE_MODEL]) : page(MODELS)); + }); + +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`; +}; beforeAll(() => { Object.defineProperty(window, "matchMedia", { @@ -96,6 +107,8 @@ beforeEach(() => { 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", { @@ -152,18 +165,78 @@ describe("PublicAiHub", () => { expect(screen.getByText("LiteLLM v1.90.0")).toBeInTheDocument(); }); - it("says the hub is unavailable when the public models call fails", async () => { - apiGetMock.mockRejectedValue(new Error("Public model hub is not enabled")); + it("keeps agents reachable when only the models call fails", async () => { + const user = userEvent.setup(); + apiGetMock.mockRejectedValue(new Error("No models configured")); + renderWithProviders(); + + 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")); + vi.mocked(agentHubPublicModelsCall).mockRejectedValueOnce(new Error("Not found")); + vi.mocked(mcpHubPublicServersCall).mockRejectedValueOnce(new Error("Not found")); + vi.mocked(skillHubPublicCall).mockRejectedValueOnce(new Error("Not found")); renderWithProviders(); expect(await screen.findByText("This AI Hub isn't available")).toBeInTheDocument(); expect(screen.queryByRole("tablist")).not.toBeInTheDocument(); }); + it("keeps the page's other query params in each row's link", async () => { + navigation.params = new URLSearchParams("tab=agents&key=abc"); + renderWithProviders(, { 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(, { 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(, { 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(, { 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(); 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(); + + expect(await screen.findByRole("heading", { level: 2, name: "claude-sonnet-4-5" })).toBeInTheDocument(); + expect(replaceMock).not.toHaveBeenCalled(); + }); }); diff --git a/ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.tsx b/ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.tsx index e32578cfa2c..07c612e840c 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.tsx +++ b/ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.tsx @@ -7,7 +7,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { TooltipProvider } from "@/components/ui/tooltip"; import { ThemeProvider } from "@/contexts/ThemeContext"; -import { HubHeader } from "./HubHeader"; +import { HubHeader, type HubStatus } from "./HubHeader"; import { HubNotEnabled } from "./HubEmpty"; import { ModelsPane } from "./ModelsPane"; import { AgentsPane, McpPane, SkillsPane } from "./ResourcePanes"; @@ -24,9 +24,11 @@ function TabLabel({ children, count }: { children: ReactNode; count: number | un ); } -const hubStatus = (hub: HubData) => { +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 hub.models.error ? "unavailable" : "operational"; + return failures.length > 0 ? "degraded" : "operational"; }; function HubTabs({ hub }: { hub: HubData }) { @@ -55,13 +57,31 @@ function HubTabs({ hub }: { hub: HubData }) { - + void hub.agents.refetch()} + {...paneProps} + /> - + void hub.mcp.refetch()} + {...paneProps} + /> - + void hub.skills.refetch()} + {...paneProps} + /> ); diff --git a/ui/litellm-dashboard/src/components/publicModelHub/ResourcePanes.tsx b/ui/litellm-dashboard/src/components/publicModelHub/ResourcePanes.tsx index a1623a7495a..d9b1030ce50 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/ResourcePanes.tsx +++ b/ui/litellm-dashboard/src/components/publicModelHub/ResourcePanes.tsx @@ -6,24 +6,22 @@ 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 { NoResults } from "./HubEmpty"; +import { LoadFailed, NoResults } from "./HubEmpty"; import { RowTitle, SearchInput } from "./HubParts"; import { McpInspector } from "./McpInspector"; import { SkillInspector } from "./SkillInspector"; -const matches = (fields: (string | null | undefined)[], query: string) => { - const needle = query.trim().toLowerCase(); - return !needle || fields.some((field) => field?.toLowerCase().includes(needle)); -}; - interface ResourcePaneProps { rows: T[]; loading: boolean; + error: boolean; + onRetry: () => void; noun: string; icon: React.ReactNode; search: (row: T) => (string | null | undefined)[]; @@ -40,6 +38,8 @@ interface ResourcePaneProps { function ResourcePane({ rows, loading, + error, + onRetry, noun, icon, search, @@ -55,8 +55,11 @@ function ResourcePane({ const [query, setQuery] = React.useState(""); const [chosen, setChosen] = React.useState([]); const options = [...new Set(rows.flatMap(filter.values))].sort(); - const shown = rows.filter( - (row) => matches(search(row), query) && (chosen.length === 0 || filter.values(row).some((v) => chosen.includes(v))), + const 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 = () => { @@ -68,9 +71,16 @@ function ResourcePane({ rows={shown} loading={loading} selectedKey={selected} + opened={rows.find((row) => getKey(row) === selected)} getKey={getKey} onSelect={onSelect} - empty={} + empty={ + error ? ( + + ) : ( + + ) + } toolbar={
@@ -105,6 +115,8 @@ function ResourcePane({ interface PaneProps { rows: T[] | undefined; loading: boolean; + error: boolean; + onRetry: () => void; selected: string | null; onSelect: (key: string | null) => void; } @@ -138,16 +150,16 @@ export function McpPane({ rows, ...props }: PaneProps) { rows={rows ?? []} noun="MCP servers" icon={} - search={(server) => [server.server_name, server.mcp_info?.description]} + search={(server) => [server.name, server.mcp_info?.description]} filter={{ label: "Transport", noun: "transports", values: (server) => (server.transport ? [server.transport] : []), }} - getKey={(server) => server.server_name} - title={(server) => server.server_name} + getKey={(server) => server.name} + title={(server) => server.name} description={(server) => server.mcp_info?.description} - meta={(server) => mcpAuthLabel(server.auth_type)} + meta={(server) => (server.auth_type ? mcpAuthLabel(server.auth_type) : undefined)} renderDetail={(server) => } /> ); diff --git a/ui/litellm-dashboard/src/components/publicModelHub/SkillInspector.tsx b/ui/litellm-dashboard/src/components/publicModelHub/SkillInspector.tsx index 2434f0d9bff..773ff62830e 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/SkillInspector.tsx +++ b/ui/litellm-dashboard/src/components/publicModelHub/SkillInspector.tsx @@ -1,14 +1,36 @@ "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 ( + + ); +} + export function SkillInspector({ skill }: { skill: Plugin }) { const keywords = skill.keywords ?? []; + const added = skill.created_at ? new Date(skill.created_at).toLocaleDateString() : null; return (
@@ -20,6 +42,11 @@ export function SkillInspector({ skill }: { skill: Plugin }) { { 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 && }], ]} /> diff --git a/ui/litellm-dashboard/src/components/publicModelHub/hubFormat.test.ts b/ui/litellm-dashboard/src/components/publicModelHub/hubFormat.test.ts index b2db644c199..fa668481145 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/hubFormat.test.ts +++ b/ui/litellm-dashboard/src/components/publicModelHub/hubFormat.test.ts @@ -34,6 +34,7 @@ describe("modeLabel", () => { 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("-"); }); }); @@ -50,10 +51,13 @@ describe("formatTokens", () => { }); describe("formatPerMillion", () => { - it("prices per million tokens with two decimals", () => { + 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(0)).toBe("-"); + expect(formatPerMillion(3.75e-8)).toBe("$0.0375"); + expect(formatPerMillion(2e-9)).toBe("$0.002"); + expect(formatPerMillion(0)).toBe("Free"); + expect(formatPerMillion(undefined)).toBe("-"); }); }); @@ -70,8 +74,14 @@ describe("capabilities", () => { }); it("lists an agent's enabled capabilities", () => { - const agent = { capabilities: { streaming: true, pushNotifications: true, stateTransitionHistory: false } }; - expect(agentCapabilities(agent as AgentCard)).toEqual(["Streaming", "Push notifications"]); + const agent = { + capabilities: { streaming: true, pushNotifications: true, stateTransitionHistory: true, x: false }, + }; + expect(agentCapabilities(agent as AgentCard)).toEqual([ + "Streaming", + "Push notifications", + "State transition history", + ]); }); }); @@ -90,8 +100,10 @@ describe("relativeTime", () => { 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("-"); }); }); diff --git a/ui/litellm-dashboard/src/components/publicModelHub/hubFormat.ts b/ui/litellm-dashboard/src/components/publicModelHub/hubFormat.ts index a8516a15c8b..f64e32c0edd 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/hubFormat.ts +++ b/ui/litellm-dashboard/src/components/publicModelHub/hubFormat.ts @@ -1,6 +1,7 @@ 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"; @@ -23,8 +24,10 @@ const MODE_LABELS: Record = { const sentenceCase = (text: string): string => text.replaceAll("_", " ").replace(/^./, (c) => c.toUpperCase()); -export const modeLabel = (mode: string | null | undefined): string => - mode ? MODE_LABELS[mode] ?? sentenceCase(mode) : "-"; +export const 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 "-"; @@ -33,8 +36,20 @@ export const formatTokens = (tokens: number | null | undefined): string => { return String(tokens); }; -export const formatPerMillion = (cost: number | null | undefined): string => - cost ? `$${(cost * 1_000_000).toFixed(2)}` : "-"; +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*\(.*\)$/, ""); @@ -73,7 +88,7 @@ export const modelCapabilities = (model: ModelGroupInfo): string[] => export const agentCapabilities = (agent: AgentCard): string[] => Object.entries(agent.capabilities ?? {}) .filter(([, on]) => on === true) - .map(([key]) => (key === "pushNotifications" ? "Push notifications" : sentenceCase(key))); + .map(([key]) => sentenceCase(key.replace(/([a-z])([A-Z])/g, "$1_$2").toLowerCase())); export type HealthTone = "success" | "error" | "neutral"; @@ -86,13 +101,15 @@ export const healthOf = (model: ModelGroupInfo): { tone: HealthTone; label: stri HEALTH[model.health_status ?? ""] ?? { tone: "neutral", label: "Not checked" }; export const relativeTime = (iso: string | null | undefined, now: number = Date.now()): string => { - if (!iso) return "-"; - const minutes = Math.max(0, Math.round((now - new Date(iso).getTime()) / 60_000)); + 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`; - return `${Math.round(hours / 24)} days ago`; + const days = Math.round(hours / 24); + return `${days} ${days === 1 ? "day" : "days"} ago`; }; export const gatewayUrl = (): string => getProxyBaseUrl(); diff --git a/ui/litellm-dashboard/src/components/publicModelHub/hubSnippets.test.ts b/ui/litellm-dashboard/src/components/publicModelHub/hubSnippets.test.ts index 91c3cac0ca2..548afeb1755 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/hubSnippets.test.ts +++ b/ui/litellm-dashboard/src/components/publicModelHub/hubSnippets.test.ts @@ -18,7 +18,7 @@ const model = (overrides: Partial): ModelGroupInfo => ({ ...overrides, }); -const server = { server_id: "s1", server_name: "github" } as MCPServerData; +const server = { server_id: "s1", name: "github", server_name: "github" } as MCPServerData; afterEach(() => { vi.unstubAllGlobals(); @@ -46,6 +46,11 @@ describe("MCP and skill snippets", () => { 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", diff --git a/ui/litellm-dashboard/src/components/publicModelHub/hubSnippets.ts b/ui/litellm-dashboard/src/components/publicModelHub/hubSnippets.ts index 8dc81e13ac6..b34db7c1b39 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/hubSnippets.ts +++ b/ui/litellm-dashboard/src/components/publicModelHub/hubSnippets.ts @@ -72,15 +72,15 @@ response = await client.send_message(request)`, }, ]; -export const mcpEndpoint = (server: MCPServerData): string => `${gatewayUrl()}/${server.server_name}/mcp`; +export const mcpEndpoint = (server: MCPServerData): string => `${gatewayUrl()}/${server.name}/mcp`; export const mcpSnippet = (server: MCPServerData): string => `from fastmcp import Client client = Client({ "mcpServers": { - "${server.server_name}": { + "${server.name}": { "url": "${mcpEndpoint(server)}", - "headers": {"x-litellm-api-key": "Bearer $LITELLM_API_KEY"}, + "headers": {"x-litellm-api-key": "Bearer your_api_key"}, } } }) diff --git a/ui/litellm-dashboard/src/components/publicModelHub/useHubData.ts b/ui/litellm-dashboard/src/components/publicModelHub/useHubData.ts index 4bf7d3b91e9..cf53307a072 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/useHubData.ts +++ b/ui/litellm-dashboard/src/components/publicModelHub/useHubData.ts @@ -3,18 +3,20 @@ 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 { agentHubPublicModelsCall, + apiClient, getPublicModelHubInfo, getUiConfig, mcpHubPublicServersCall, skillHubPublicCall, } from "@/components/networking"; -import type { AgentCard, MCPServerData } from "@/components/PublicModelHubTableColumns"; +import type { AgentCard, MCPServerData, ModelGroupInfo } from "@/components/PublicModelHubTableColumns"; import { usePublicModelHubFacets } from "./usePublicModelHubFacets"; -import { usePublicModelHubList } from "./usePublicModelHubList"; +import { PUBLIC_MODEL_HUB_PAGE_SIZE, PUBLIC_MODEL_HUB_PATH, usePublicModelHubList } from "./usePublicModelHubList"; export interface HubInfo { docs_title: string; @@ -25,8 +27,15 @@ export interface HubInfo { const asArray = (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({ queryKey: ["publicAiHub", "uiConfig"], queryFn: () => getUiConfig(), staleTime: Infinity }); + const config = useQuery(UI_CONFIG_QUERY); const ready = config.isSuccess || config.isError; const info = useQuery({ @@ -51,13 +60,31 @@ export function useHubData() { }); const list = usePublicModelHubList(ready); - const models = { ...list, isLoading: !ready || list.isLoading }; + const listLoading = !ready || list.isLoading; + const [listSettled, setListSettled] = useState(false); + if (!listLoading && !listSettled) setListSettled(true); + const models = { ...list, isLoading: listLoading && !listSettled }; const facets = usePublicModelHubFacets(ready); const [modelTotal, setModelTotal] = useState(undefined); - const settledTotal = !models.isLoading && !models.hasActiveQuery && !models.error ? models.rowCount : modelTotal; + 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; + +export function useOpenedModel(open: string | null, rows: ModelGroupInfo[], enabled: boolean) { + const onPage = rows.find((model) => model.model_group === open); + const lookup = useQuery({ + queryKey: ["publicAiHub", "openedModel", open], + queryFn: ({ signal }) => + apiClient.get>(PUBLIC_MODEL_HUB_PATH, { + query: { q: open ?? "", page_size: PUBLIC_MODEL_HUB_PAGE_SIZE }, + signal, + }), + enabled: enabled && open !== null && onPage === undefined, + }); + const found = lookup.data?.data.find((model) => model.model_group === open); + return { model: onPage ?? found, pending: onPage === undefined && lookup.isLoading }; +} diff --git a/ui/litellm-dashboard/src/components/publicModelHub/useHubUrlState.ts b/ui/litellm-dashboard/src/components/publicModelHub/useHubUrlState.ts index 62fcb148c58..9abe57e872f 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/useHubUrlState.ts +++ b/ui/litellm-dashboard/src/components/publicModelHub/useHubUrlState.ts @@ -28,5 +28,5 @@ export function useHubUrlState() { export function useOpenHref() { const params = useSearchParams(); - return useCallback((key: string) => serializeOpen(params?.toString() ?? "", { open: key }), [params]); + return useCallback((key: string) => serializeOpen(params ?? new URLSearchParams(), { open: key }), [params]); } diff --git a/ui/litellm-dashboard/src/components/publicModelHub/useIsNarrow.ts b/ui/litellm-dashboard/src/components/publicModelHub/useIsNarrow.ts index 7d17a23d52e..d9af009eb23 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/useIsNarrow.ts +++ b/ui/litellm-dashboard/src/components/publicModelHub/useIsNarrow.ts @@ -1,6 +1,6 @@ import { useSyncExternalStore } from "react"; -const NARROW = "(max-width: 1023px)"; +const NARROW = "(width < 64rem)"; const subscribe = (onChange: () => void) => { const query = window.matchMedia(NARROW);