From 0da546e11ad285bef810728ea262324c98d712d6 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Sat, 31 Oct 2026 15:23:30 -0700 Subject: [PATCH] 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 --- .../publicModelHub/HubMasterDetail.tsx | 50 +++-- .../components/publicModelHub/ModelsPane.tsx | 13 +- .../PublicAiHub.integration.test.tsx | 180 ++++++++++++++---- .../publicModelHub/SkillInspector.tsx | 3 +- .../components/publicModelHub/useHubData.ts | 48 +++-- .../publicModelHub/useHubUrlState.ts | 2 +- 6 files changed, 212 insertions(+), 84 deletions(-) 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() {