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
This commit is contained in:
Yuneng Jiang 2026-10-31 14:54:12 -07:00
parent bd546f1dac
commit e980378056
No known key found for this signature in database
20 changed files with 338 additions and 109 deletions

View file

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

View file

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

View file

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

View file

@ -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() {
</Empty>
);
}
export function LoadFailed({ noun, onRetry }: { noun: string; onRetry: () => void }) {
return (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<TriangleAlert />
</EmptyMedia>
<EmptyTitle className={SECTION_TITLE}>Couldn&apos;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}>&ldquo;{name}&rdquo; isn&apos;t listed here</EmptyTitle>
<EmptyDescription>It may have been renamed or unpublished</EmptyDescription>
</EmptyHeader>
</Empty>
);
}

View file

@ -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 (
<header className="flex flex-col gap-4">
<div className="flex flex-col gap-1">

View file

@ -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<T>({
rows,
loading,
selectedKey,
opened,
openPending = false,
getKey,
onSelect,
renderItem,
@ -116,6 +119,8 @@ export function HubMasterDetail<T>({
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<T>({
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<T | undefined>(chosen);
if (chosen !== undefined && chosen !== sheetRow) setSheetRow(chosen);
const isEmpty = !loading && rows.length === 0;
const detail = () => {
if (loading || (selected === undefined && openPending)) return <DetailSkeleton />;
if (selected === undefined) return selectedKey && <NotListed name={selectedKey} />;
return renderDetail(selected);
};
const list = isEmpty ? (
empty
) : (
@ -158,11 +168,7 @@ export function HubMasterDetail<T>({
</div>
<ScrollArea className="min-h-0 flex-1">{loading ? <ListSkeleton /> : list}</ScrollArea>
</div>
{!isEmpty && (
<ScrollArea className="hidden min-h-0 lg:block">
{loading ? <DetailSkeleton /> : selected && renderDetail(selected)}
</ScrollArea>
)}
{!isEmpty && <ScrollArea className="hidden min-h-0 lg:block">{detail()}</ScrollArea>}
<DetailSheet
open={narrow && chosen !== undefined}
title={sheetRow ? getKey(sheetRow) : "Details"}

View file

@ -10,11 +10,7 @@ import { InspectorHeader } from "./InspectorHeader";
export function McpInspector({ server }: { server: MCPServerData }) {
return (
<div className="flex flex-col gap-6 p-6">
<InspectorHeader
title={server.server_name}
copyLabel="Copy server name"
description={server.mcp_info?.description}
/>
<InspectorHeader title={server.name} copyLabel="Copy server name" description={server.mcp_info?.description} />
<Facts
groups={[
[

View file

@ -22,6 +22,12 @@ 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[][] => {
@ -133,9 +139,7 @@ export function ModelInspector({ model }: { model: ModelGroupInfo }) {
title={model.model_group}
copyLabel="Copy model name"
status={<HealthBadge model={model} />}
description={
model.description || `${modeLabel(model.mode)} model from ${providers.map(providerName).join(", ")}`
}
description={summary(model)}
/>
{model.model_group.includes("*") && (
<Alert>

View file

@ -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 }) {
<FilterChip
label="Mode"
noun="modes"
options={facets.modes.map((m) => ({ 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={
<NoResults
icon={<Boxes />}
noun="models"
query={models.searchValue}
onClear={models.hasActiveQuery ? clear : undefined}
/>
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} />}

View file

@ -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<string, unknown> },
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<string, unknown> }) => {
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(<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"));
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(<PublicAiHub accessToken={null} />);
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(<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();
});
});

View file

@ -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 }) {
<ModelsPane hub={hub} {...paneProps} />
</TabsContent>
<TabsContent value="agents">
<AgentsPane rows={hub.agents.data} loading={hub.agents.isPending} {...paneProps} />
<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} {...paneProps} />
<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} {...paneProps} />
<SkillsPane
rows={hub.skills.data}
loading={hub.skills.isPending}
error={hub.skills.isError}
onRetry={() => void hub.skills.refetch()}
{...paneProps}
/>
</TabsContent>
</Tabs>
);

View file

@ -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<T> {
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<T> {
function ResourcePane<T>({
rows,
loading,
error,
onRetry,
noun,
icon,
search,
@ -55,8 +55,11 @@ function ResourcePane<T>({
const [query, setQuery] = React.useState("");
const [chosen, setChosen] = React.useState<string[]>([]);
const options = [...new Set(rows.flatMap(filter.values))].sort();
const shown = rows.filter(
(row) => matches(search(row), query) && (chosen.length === 0 || filter.values(row).some((v) => chosen.includes(v))),
const 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<T>({
rows={shown}
loading={loading}
selectedKey={selected}
opened={rows.find((row) => getKey(row) === selected)}
getKey={getKey}
onSelect={onSelect}
empty={<NoResults icon={icon} noun={noun} query={query} onClear={filtered ? clear : undefined} />}
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}`} />
@ -105,6 +115,8 @@ function ResourcePane<T>({
interface PaneProps<T> {
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<MCPServerData>) {
rows={rows ?? []}
noun="MCP servers"
icon={<Server />}
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) => <McpInspector server={server} />}
/>
);

View file

@ -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 (
<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 added = skill.created_at ? new Date(skill.created_at).toLocaleDateString() : null;
return (
<div className="flex flex-col gap-6 p-6">
<InspectorHeader title={skill.name} copyLabel="Copy skill name" description={skill.description} />
@ -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 && <Badges items={keywords} /> }],
]}
/>

View file

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

View file

@ -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<string, string> = {
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();

View file

@ -18,7 +18,7 @@ const model = (overrides: Partial<ModelGroupInfo>): 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",

View file

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

View file

@ -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 = <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({ queryKey: ["publicAiHub", "uiConfig"], queryFn: () => getUiConfig(), staleTime: Infinity });
const config = useQuery(UI_CONFIG_QUERY);
const ready = config.isSuccess || config.isError;
const info = useQuery<HubInfo>({
@ -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<number | undefined>(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<typeof useHubData>;
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<ResourceListPage<ModelGroupInfo>>(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 };
}

View file

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

View file

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