) {
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 (
+ }
+ >
+ {href.replace(/^https:\/\//, "")}
+
+
+ );
+}
+
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);