diff --git a/ui/litellm-dashboard/src/components/publicModelHub/HubMasterDetail.tsx b/ui/litellm-dashboard/src/components/publicModelHub/HubMasterDetail.tsx index f317522e217..63153c63c76 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/HubMasterDetail.tsx +++ b/ui/litellm-dashboard/src/components/publicModelHub/HubMasterDetail.tsx @@ -99,6 +99,33 @@ function Rows({ ); } +const resolveSelection = ( + 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( + 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 && , + }; +} + export function HubMasterDetail({ switcher, toolbar, @@ -129,11 +156,12 @@ export function HubMasterDetail({ footer?: React.ReactNode; }) { const narrow = useIsNarrow(); - 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 { 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 ; if (selected === undefined) return selectedKey && ; @@ -158,23 +186,19 @@ export function HubMasterDetail({
-
+
{switcher}
{toolbar}
{loading ? : list}
- {!isEmpty && {detail()}} - onSelect(null)} - > - {sheetRow && renderDetail(sheetRow)} + {!listOnly && {detail()}} + onSelect(null)}> + {sheet.body}
); diff --git a/ui/litellm-dashboard/src/components/publicModelHub/ModelsPane.tsx b/ui/litellm-dashboard/src/components/publicModelHub/ModelsPane.tsx index 7120d14feb1..1de09e554c1 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/ModelsPane.tsx +++ b/ui/litellm-dashboard/src/components/publicModelHub/ModelsPane.tsx @@ -148,10 +148,15 @@ function ModelsToolbar({ hub }: { hub: HubData }) { ); } -const prices = (model: ModelGroupInfo) => [ - model.input_cost_per_token ? `${formatPerMillion(model.input_cost_per_token)} / 1M in` : null, - model.output_cost_per_token ? `${formatPerMillion(model.output_cost_per_token)} / 1M out` : null, -]; +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); 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 b51da66ce27..bcddc1fe364 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/publicModelHub/PublicAiHub.integration.test.tsx @@ -1,14 +1,13 @@ -import { fireEvent } from "@testing-library/react"; +import { fireEvent, within } 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 { beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders, screen, testQueryClient, waitFor } from "../../../tests/test-utils"; import PublicAiHub from "./PublicAiHub"; -const { apiGetMock, navigation, uiSettings, replaceMock, hubInfo } = vi.hoisted(() => ({ +const { apiGetMock, realGet, navigation, uiSettings, replaceMock, hubInfo } = vi.hoisted(() => ({ apiGetMock: vi.fn(), + realGet: { current: (..._args: unknown[]): Promise => Promise.reject(new Error("unset")) }, navigation: { params: new URLSearchParams() }, uiSettings: { values: {} as Record }, replaceMock: vi.fn(), @@ -36,26 +35,13 @@ vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({ vi.mock("@/components/networking", async (importOriginal) => { const actual = await importOriginal(); + 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), - agentHubPublicModelsCall: vi.fn().mockResolvedValue([ - { - name: "research-assistant", - description: "Answers research questions", - version: "1.4.0", - url: "https://gateway.example.com/a2a/research", - protocolVersion: "0.3.0", - defaultInputModes: ["text"], - defaultOutputModes: ["text"], - skills: [], - }, - ]), - mcpHubPublicServersCall: vi.fn().mockResolvedValue([]), - skillHubPublicCall: vi.fn().mockResolvedValue({ plugins: [] }), }; }); @@ -69,9 +55,35 @@ const MODELS = [ input_cost_per_token: 0.000003, description: "Best for agentic coding", }, - { model_group: "gpt-5", providers: ["openai"], mode: "chat", description: "General purpose" }, + { + 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 = { + "/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 }, @@ -80,10 +92,32 @@ const page = (data: unknown[]) => ({ const OFF_PAGE_MODEL = { model_group: "o1-pro", providers: ["openai"], mode: "chat", description: "Deep reasoning" }; -const respondWithModels = () => - 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)); +type Query = { query?: Record }; + +const respond = (path: string, options?: Query): Promise => { + 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) => + 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) => { @@ -91,19 +125,8 @@ const tokenExpiringIn = (seconds: number) => { return `${encode({ alg: "HS256" })}.${encode({ exp: Math.floor(Date.now() / 1000) + seconds })}.signature`; }; -beforeAll(() => { - Object.defineProperty(window, "matchMedia", { - writable: true, - value: (query: string) => ({ - matches: false, - media: query, - addEventListener: () => {}, - removeEventListener: () => {}, - }), - }); -}); - beforeEach(() => { + narrowScreen(false); vi.clearAllMocks(); testQueryClient.clear(); uiSettings.values = {}; @@ -167,7 +190,7 @@ describe("PublicAiHub", () => { it("keeps agents reachable when only the models call fails", async () => { const user = userEvent.setup(); - apiGetMock.mockRejectedValue(new Error("No models configured")); + failModelsWith(() => Promise.reject(new Error("No models configured"))); renderWithProviders(); expect(await screen.findByText("Couldn't load models")).toBeInTheDocument(); @@ -178,15 +201,92 @@ describe("PublicAiHub", () => { 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("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(); + + 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(); + 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(, { 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(, { + 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(); + + 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(, { 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(, { 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(, { searchParams: { tab: "agents", key: "abc" } }); diff --git a/ui/litellm-dashboard/src/components/publicModelHub/SkillInspector.tsx b/ui/litellm-dashboard/src/components/publicModelHub/SkillInspector.tsx index 773ff62830e..fdc2467e5da 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/SkillInspector.tsx +++ b/ui/litellm-dashboard/src/components/publicModelHub/SkillInspector.tsx @@ -30,7 +30,8 @@ function sourceValue(source: Plugin["source"]) { export function SkillInspector({ skill }: { skill: Plugin }) { const keywords = skill.keywords ?? []; - const added = skill.created_at ? new Date(skill.created_at).toLocaleDateString() : null; + const addedAt = skill.created_at ? new Date(skill.created_at) : null; + const added = addedAt && !Number.isNaN(addedAt.getTime()) ? addedAt.toLocaleDateString() : null; return (
diff --git a/ui/litellm-dashboard/src/components/publicModelHub/useHubData.ts b/ui/litellm-dashboard/src/components/publicModelHub/useHubData.ts index cf53307a072..65c2a2c5873 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/useHubData.ts +++ b/ui/litellm-dashboard/src/components/publicModelHub/useHubData.ts @@ -5,14 +5,7 @@ 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 { apiClient, getPublicModelHubInfo, getUiConfig } from "@/components/networking"; import type { AgentCard, MCPServerData, ModelGroupInfo } from "@/components/PublicModelHubTableColumns"; import { usePublicModelHubFacets } from "./usePublicModelHubFacets"; @@ -45,25 +38,24 @@ export function useHubData() { }); const agents = useQuery({ queryKey: ["publicAiHub", "agents"], - queryFn: async () => asArray(await agentHubPublicModelsCall()), + queryFn: async ({ signal }) => asArray(await apiClient.get("/public/agent_hub", { signal })), enabled: ready, }); const mcp = useQuery({ queryKey: ["publicAiHub", "mcp"], - queryFn: async () => asArray(await mcpHubPublicServersCall()), + queryFn: async ({ signal }) => asArray(await apiClient.get("/public/mcp_hub", { signal })), enabled: ready, }); const skills = useQuery({ queryKey: ["publicAiHub", "skills"], - queryFn: async () => (await skillHubPublicCall()).plugins ?? [], + 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 [listSettled, setListSettled] = useState(false); - if (!listLoading && !listSettled) setListSettled(true); - const models = { ...list, isLoading: listLoading && !listSettled }; + const models = { ...list, isLoading: listLoading && list.rows.length === 0 }; const facets = usePublicModelHubFacets(ready); const [modelTotal, setModelTotal] = useState(undefined); const settledTotal = !listLoading && !models.hasActiveQuery && !models.error ? models.rowCount : modelTotal; @@ -74,17 +66,23 @@ export function useHubData() { export type HubData = ReturnType; +const openedModelQuery = (open: string | null, enabled: boolean) => ({ + queryKey: ["publicAiHub", "openedModel", open], + queryFn: ({ signal }: { signal: AbortSignal }) => + apiClient.get>(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 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 }; + const [kept, setKept] = useState(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 }; } diff --git a/ui/litellm-dashboard/src/components/publicModelHub/useHubUrlState.ts b/ui/litellm-dashboard/src/components/publicModelHub/useHubUrlState.ts index 9abe57e872f..632c372d36c 100644 --- a/ui/litellm-dashboard/src/components/publicModelHub/useHubUrlState.ts +++ b/ui/litellm-dashboard/src/components/publicModelHub/useHubUrlState.ts @@ -23,7 +23,7 @@ export function useHubUrlState() { }, [setUrlTab, setOpen], ); - return { tab, setTab, open, setOpen }; + return { tab, setTab, open: open || null, setOpen }; } export function useOpenHref() {