diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/providers/useProxyDecisionProviders.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/providers/useProxyDecisionProviders.ts new file mode 100644 index 00000000000..fd79bb2fbb4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/providers/useProxyDecisionProviders.ts @@ -0,0 +1,17 @@ +import { getGuardrailUISettings } from "@/components/networking"; +import { parseProxyDecisionProviders } from "@/lib/decisionModels"; +import { useQuery } from "@tanstack/react-query"; +import { createQueryKeys } from "../common/queryKeysFactory"; + +const proxyDecisionProvidersKeys = createQueryKeys("proxyDecisionProviders"); + +export const useProxyDecisionProviders = (accessToken: string | null) => { + const query = { + queryKey: proxyDecisionProvidersKeys.list({}), + queryFn: async () => parseProxyDecisionProviders(await getGuardrailUISettings(accessToken ?? "")), + enabled: accessToken !== null, + retry: false, + staleTime: 24 * 60 * 60 * 1000, + }; + return useQuery(query); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/model-hub-table/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/model-hub-table/page.test.tsx new file mode 100644 index 00000000000..c6348cdb052 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/model-hub-table/page.test.tsx @@ -0,0 +1,57 @@ +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import ModelHubTablePage from "./page"; + +const mockUseAuthorized = vi.fn(); +const hubProps = vi.fn(); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => mockUseAuthorized() })); +vi.mock("@/components/AIHub/ModelHubTable", () => ({ + default: (props: { canAddModels: boolean }) => { + hubProps(props); + return
; + }, +})); +vi.mock("@/components/public_model_hub", () => ({ + default: () =>
, +})); + +const session = (userRole: string, isViewOnly = false) => ({ + accessToken: "token", + userRole, + userId: "user-1", + premiumUser: false, + isViewOnly, +}); + +describe("ModelHubTablePage", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("lets a proxy admin add models from the garden", () => { + mockUseAuthorized.mockReturnValue(session("Admin")); + render(); + expect(hubProps).toHaveBeenCalledWith(expect.objectContaining({ canAddModels: true })); + }); + + it("keeps the garden read-only for a view-only admin", () => { + mockUseAuthorized.mockReturnValue(session("Admin", true)); + render(); + expect(hubProps).toHaveBeenCalledWith(expect.objectContaining({ canAddModels: false })); + }); + + it("sends an org admin to the public hub under the role string a session carries", () => { + mockUseAuthorized.mockReturnValue(session("Org Admin")); + render(); + expect(screen.getByTestId("public-model-hub")).toBeInTheDocument(); + expect(hubProps).not.toHaveBeenCalled(); + }); + + it("sends a non-admin to the public hub", () => { + mockUseAuthorized.mockReturnValue(session("internal_user")); + render(); + expect(screen.getByTestId("public-model-hub")).toBeInTheDocument(); + expect(hubProps).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/model-hub-table/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/model-hub-table/page.tsx index c9eb8762ed2..d402af41112 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/model-hub-table/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/model-hub-table/page.tsx @@ -3,13 +3,18 @@ import ModelHubTable from "@/components/AIHub/ModelHubTable"; import PublicModelHub from "@/components/public_model_hub"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { canCreateModels } from "@/utils/modelPermissions"; import { isAdminRole } from "@/utils/roles"; export default function ModelHubTablePage() { - const { accessToken, userRole, premiumUser, isViewOnly } = useAuthorized(); + const { accessToken, userRole, userId: userID, premiumUser, isViewOnly } = useAuthorized(); if (!isAdminRole(userRole)) { return ; } + const canAddModels = canCreateModels( + { userRole, userID, isViewOnly }, + { teams: null, disabledForInternalUsers: false }, + ); return ( ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.integration.test.tsx index fa62f43c2d1..4d18966625e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.integration.test.tsx @@ -39,7 +39,10 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ }), })); -vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }) })); +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn() }), + usePathname: () => "/ui/models-and-endpoints", +})); vi.mock("@/components/networking", () => ({ serverRootPath: "", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx index a9cb68ae8dd..3cfed7297c8 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx @@ -1,10 +1,15 @@ /* @vitest-environment jsdom */ -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { render, screen } from "@testing-library/react"; +import { screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OnUrlUpdateFunction } from "nuqs/adapters/testing"; +import { beforeEach, describe, expect, it, vi, type Mock } from "vitest"; +import { renderWithProviders } from "../../../../tests/test-utils"; +import { recordUiEvent } from "@/lib/telemetry/uiEvents"; import ModelsAndEndpointsPage from "./page"; +vi.mock("@/lib/telemetry/uiEvents", () => ({ recordUiEvent: vi.fn(() => Promise.resolve()) })); +vi.mock("next/navigation", () => ({ usePathname: () => "/ui/models-and-endpoints" })); + vi.mock("./panels/AllModelsPanel", () => ({ default: () =>
})); vi.mock("./panels/AddModelPanel", () => ({ default: () =>
})); vi.mock("./panels/AutoRoutersTabPanel", () => ({ default: () =>
})); @@ -61,14 +66,13 @@ const NON_ADMIN = { // A proxy_admin_viewer session: effectiveSessionRole masquerades the role as "Admin". const VIEW_ONLY_ADMIN = { ...ADMIN, isViewOnly: true }; -const renderPage = () => { - const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); - return render( - - - , - ); -}; +const renderPage = (options?: { searchParams?: string; onUrlUpdate?: OnUrlUpdateFunction }) => + renderWithProviders(, options); + +const lastUrlParams = (onUrlUpdate: Mock): URLSearchParams | undefined => + onUrlUpdate.mock.calls.at(-1)?.[0].searchParams; + +const ADD_MODEL_PREFILL_LINK = "?tab=add&provider=typesafe&litellm_model=typesafe%2Fjev-latest"; describe("ModelsAndEndpointsPage", () => { beforeEach(() => { @@ -224,4 +228,69 @@ describe("ModelsAndEndpointsPage", () => { expect(screen.queryByRole("tab", { name: /Auto-Routers/ })).not.toBeInTheDocument(); }); }); + + describe("tab from the URL", () => { + it("opens the Add Model tab from ?tab=add for an admin", () => { + renderPage({ searchParams: "?tab=add" }); + + expect(screen.getByRole("tab", { name: "Add Model" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByTestId("panel-add")).toBeInTheDocument(); + expect(screen.queryByTestId("panel-all-models")).not.toBeInTheDocument(); + }); + + it("falls back to Deployed Models on ?tab=add for a view-only admin, who has no Add Model tab", () => { + mockUseAuthorized.mockReturnValue(VIEW_ONLY_ADMIN); + renderPage({ searchParams: "?tab=add" }); + + expect(screen.getByRole("tab", { name: "Deployed Models" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByTestId("panel-all-models")).toBeInTheDocument(); + expect(screen.queryByTestId("panel-add")).not.toBeInTheDocument(); + }); + + it("falls back to Deployed Models on an unknown ?tab", () => { + renderPage({ searchParams: "?tab=nope" }); + + expect(screen.getByRole("tab", { name: "Deployed Models" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByTestId("panel-all-models")).toBeInTheDocument(); + }); + + it("records a UI event for each tab the user clicks", async () => { + const user = userEvent.setup(); + renderPage(); + + await user.click(screen.getByRole("tab", { name: "Health Status" })); + + expect(recordUiEvent).toHaveBeenCalledWith({ + page: "models-and-endpoints", + action: "click", + target: "tab=health", + }); + }); + + it("writes the clicked tab to the URL and clears it for Deployed Models", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + renderPage({ onUrlUpdate }); + + await user.click(screen.getByRole("tab", { name: "Health Status" })); + await waitFor(() => expect(lastUrlParams(onUrlUpdate)?.get("tab")).toBe("health")); + expect(screen.getByTestId("panel-health")).toBeInTheDocument(); + + await user.click(screen.getByRole("tab", { name: "Deployed Models" })); + await waitFor(() => expect(lastUrlParams(onUrlUpdate)?.get("tab")).toBeNull()); + expect(screen.getByTestId("panel-all-models")).toBeInTheDocument(); + }); + + it("drops the Add Model prefill params when the user leaves the tab", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + renderPage({ searchParams: ADD_MODEL_PREFILL_LINK, onUrlUpdate }); + + await user.click(screen.getByRole("tab", { name: "Deployed Models" })); + + await waitFor(() => expect(lastUrlParams(onUrlUpdate)?.get("tab")).toBeNull()); + expect(lastUrlParams(onUrlUpdate)?.get("provider")).toBeNull(); + expect(lastUrlParams(onUrlUpdate)?.get("litellm_model")).toBeNull(); + }); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx index a54c4ee7292..9f1ee04c5a2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx @@ -2,10 +2,12 @@ import { useMemo, useState } from "react"; import { RefreshCw } from "lucide-react"; +import { parseAsString, useQueryStates } from "nuqs"; import { useQueryClient } from "@tanstack/react-query"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings"; +import { useUrlTab } from "@/hooks/useUrlTab"; import { all_admin_roles, internalUserRoles } from "@/utils/roles"; import { autoRouterCreationScope, canCreateModels } from "@/utils/modelPermissions"; import BetaBadge from "@/components/BetaBadge"; @@ -27,19 +29,26 @@ import PriceDataPanel from "@/app/(dashboard)/models-and-endpoints/panels/PriceD import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -type ModelTabSlug = - | "add" - | "auto-routers" - | "llm-credentials" - | "pass-through" - | "health" - | "retry-settings" - | "model-group-alias" - | "access-group-budgets" - | "price-data"; +const MODEL_TAB_SLUGS = [ + "add", + "auto-routers", + "llm-credentials", + "pass-through", + "health", + "retry-settings", + "model-group-alias", + "access-group-budgets", + "price-data", +] as const; + +type ModelTabSlug = (typeof MODEL_TAB_SLUGS)[number]; const BASE_TAB_KEY = "all-models"; +const TAB_KEYS = [BASE_TAB_KEY, ...MODEL_TAB_SLUGS] as const; + +const PREFILL_URL_PARAMS = { provider: parseAsString, litellm_model: parseAsString }; + const TAB_LABELS: Record = { add: "Add Model", "auto-routers": "Auto-Routers", @@ -87,7 +96,8 @@ export default function ModelsAndEndpointsPage() { const { modelId, teamId, close } = useModelDetailRouting(); const { availableModelAccessGroups, allModelsOnProxy } = useModelDashboardData(); - const [activeKey, setActiveKey] = useState(BASE_TAB_KEY); + const [requestedTab, setRequestedTab] = useUrlTab(TAB_KEYS, BASE_TAB_KEY); + const [, setPrefillParams] = useQueryStates(PREFILL_URL_PARAMS); const [lastRefreshed, setLastRefreshed] = useState(""); const isInternalUser = userRole && internalUserRoles.includes(userRole); @@ -124,6 +134,18 @@ export default function ModelsAndEndpointsPage() { [canCreate, canViewAutoRouters, isAdmin, isInternalUser, isViewOnly], ); + const visibleKeys: readonly string[] = visibleSlugs.map((slug) => slug || BASE_TAB_KEY); + const activeKey = visibleKeys.includes(requestedTab) ? requestedTab : BASE_TAB_KEY; + + const handleTabChange = (key: string) => { + const next = TAB_KEYS.find((tabKey) => tabKey === key); + if (next === undefined) { + return; + } + setRequestedTab(next); + void setPrefillParams({ provider: null, litellm_model: null }); + }; + const allModelsLabel = isAdmin ? "Deployed Models" : "Your Models"; const tabLabel = (slug: "" | ModelTabSlug): React.ReactNode => { if (!slug) return allModelsLabel; @@ -192,7 +214,7 @@ export default function ModelsAndEndpointsPage() { modelAccessGroups={availableModelAccessGroups} /> ) : ( - +
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.integration.test.tsx index 05560c9e7cd..99656df3b0e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.integration.test.tsx @@ -1,5 +1,6 @@ import { useSyncExternalStore } from "react"; import { act, renderWithProviders, screen, waitFor, within } from "../../../../../tests/test-utils"; +import type { OnUrlUpdateFunction } from "nuqs/adapters/testing"; import userEvent, { PointerEventsCheckLevel } from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import AddModelPanel from "./AddModelPanel"; @@ -140,9 +141,9 @@ const baseModelInfo = { access_groups: undefined, mode: undefined }; const { api_base: _omitted, ...ALWAYS_MOUNTED_WITHOUT_API_BASE } = alwaysMounted; -const setup = async () => { +const setup = async (searchParams?: string, onUrlUpdate?: OnUrlUpdateFunction) => { const user = userEvent.setup({ pointerEventsCheck: PointerEventsCheckLevel.Never }); - renderWithProviders(); + renderWithProviders(, { searchParams, onUrlUpdate }); await screen.findByText("Provider"); const openAdvanced = async () => { @@ -595,3 +596,78 @@ describe("AddModelPanel decision models", () => { expect(note).not.toHaveTextContent("/chat/completions"); }); }); + +describe("AddModelPanel prefill from the URL", () => { + const COST_MAP = { + "typesafe/jev-latest": { litellm_provider: "typesafe", mode: "evaluation" }, + "gpt-4o-2024-08-06": { litellm_provider: "openai", mode: "chat" }, + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockUseModelCostMap.mockReturnValue({ data: COST_MAP }); + mockPtuEnabled.mockReturnValue(false); + mockAuthorized.mockReturnValue(PROXY_ADMIN); + }); + + it("preselects the provider and model named in the URL and submits that model", async () => { + const { submit } = await setup("?provider=typesafe&litellm_model=typesafe%2Fjev-latest"); + + await waitFor(() => expect(screen.getByRole("combobox", { name: /provider/i })).toHaveValue("TypeSafe")); + expect(screen.getByRole("combobox", { name: "Select models" })).toBeInTheDocument(); + expect(screen.getByDisplayValue("typesafe/jev-latest")).toBeInTheDocument(); + expect(await screen.findByRole("note", { name: "Decision model notice" })).toBeInTheDocument(); + + await submit(); + + expect(lastCreatedModel()).toStrictEqual({ + model_name: "typesafe/jev-latest", + litellm_params: { api_key: undefined, custom_llm_provider: "typesafe", model: "typesafe/jev-latest" }, + model_info: { ...baseModelInfo }, + }); + }); + + it("drops the prefill params from the URL once the prefilled model is added", async () => { + const onUrlUpdate = vi.fn(); + const { submit } = await setup("?provider=typesafe&litellm_model=typesafe%2Fjev-latest", onUrlUpdate); + await waitFor(() => expect(screen.getByRole("combobox", { name: /provider/i })).toHaveValue("TypeSafe")); + + await submit(); + + await waitFor(() => { + const params = onUrlUpdate.mock.calls.at(-1)?.[0].searchParams; + expect(params?.has("provider")).toBe(false); + expect(params?.has("litellm_model")).toBe(false); + }); + }); + + it("leaves the form on its defaults when the provider slug matches no provider, model included", async () => { + const { submitExpectingRejection } = await setup("?provider=unknownslug&litellm_model=typesafe%2Fjev-latest"); + + expect(screen.getByRole("combobox", { name: /provider/i })).toHaveValue(""); + expect(screen.queryByDisplayValue("typesafe/jev-latest")).not.toBeInTheDocument(); + + await submitExpectingRejection("Required"); + + expect(modelCreateCall).not.toHaveBeenCalled(); + }); + + it("keeps a provider the user picks after the prefill instead of re-applying the URL", async () => { + const { user, submit } = await setup("?provider=typesafe&litellm_model=typesafe%2Fjev-latest"); + await waitFor(() => expect(screen.getByRole("combobox", { name: /provider/i })).toHaveValue("TypeSafe")); + + const provider = screen.getByRole("combobox", { name: /provider/i }); + await user.clear(provider); + await user.type(provider, "OpenAI"); + await user.click(await screen.findByRole("option", { name: /OpenAI/ })); + await user.click(await screen.findByPlaceholderText("Select models")); + await user.click(await screen.findByText("gpt-4o-2024-08-06")); + await submit(); + + expect(lastCreatedModel()).toStrictEqual({ + model_name: "gpt-4o-2024-08-06", + litellm_params: { ...alwaysMounted, model: "gpt-4o-2024-08-06" }, + model_info: { ...baseModelInfo }, + }); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx index 4703bda8d20..392a3c3940a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx @@ -1,7 +1,8 @@ "use client"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useForm, useWatch } from "react-hook-form"; +import { parseAsString, useQueryStates } from "nuqs"; import { useQueryClient } from "@tanstack/react-query"; import AddModelForm from "@/components/add_model/AddModelForm"; import { handleAddModelSubmit } from "@/components/add_model/handle_add_model_submit"; @@ -13,12 +14,15 @@ import { import { Providers, getPlaceholder, getProviderModels } from "@/components/provider_info_helpers"; import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap"; import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials"; +import { useProviderFields } from "@/app/(dashboard)/hooks/providers/useProviderFields"; import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { buildDecisionCatalog } from "@/lib/decisionModels"; const INITIAL_VALUES: MountedFormValues = { litellm_credential_name: null }; +const PREFILL_URL_PARAMS = { provider: parseAsString, litellm_model: parseAsString }; + export default function AddModelPanel() { const { accessToken } = useAuthorized(); const form = useForm({ mode: "onChange", defaultValues: INITIAL_VALUES }); @@ -28,6 +32,31 @@ export default function AddModelPanel() { const { data: credentialsResponse } = useCredentials(); const { data: teams } = useTeams(); const [selectedProvider, setSelectedProvider] = useState(Providers.Anthropic); + const [{ provider: prefillProviderSlug, litellm_model: prefillModel }, setPrefillParams] = + useQueryStates(PREFILL_URL_PARAMS); + const { data: providerMetadata } = useProviderFields(); + const prefillProvider = useMemo( + () => + prefillProviderSlug === null + ? null + : providerMetadata?.find((info) => info.litellm_provider === prefillProviderSlug)?.provider ?? null, + [prefillProviderSlug, providerMetadata], + ); + const prefillApplied = useRef(false); + + useEffect(() => { + if (prefillApplied.current || prefillProvider === null) { + return; + } + prefillApplied.current = true; + setSelectedProvider(prefillProvider); + form.setValue("custom_llm_provider", prefillProvider); + if (prefillModel === null) { + return; + } + form.setValue("model", [prefillModel]); + }, [form, prefillModel, prefillProvider]); + const pickedProvider = useWatch({ control: form.control, name: "custom_llm_provider" }); const providerModels = useMemo( () => (typeof pickedProvider === "string" ? getProviderModels(pickedProvider, modelCostMapData) : []), @@ -48,7 +77,12 @@ export default function AddModelPanel() { await handleAddModelSubmit( mountedValues(), accessToken, - { resetFields: () => form.reset(INITIAL_VALUES) }, + { + resetFields: () => { + form.reset(INITIAL_VALUES); + void setPrefillParams({ provider: null, litellm_model: null }); + }, + }, refresh, ); return true; diff --git a/ui/litellm-dashboard/src/app/model_hub_table/page.tsx b/ui/litellm-dashboard/src/app/model_hub_table/page.tsx index 46c7394cff7..af6764dcd4c 100644 --- a/ui/litellm-dashboard/src/app/model_hub_table/page.tsx +++ b/ui/litellm-dashboard/src/app/model_hub_table/page.tsx @@ -22,6 +22,7 @@ function PublicModelHubTableContent() { premiumUser={false} userRole={null} canOpenPlayground={false} + canAddModels={false} /> ); } diff --git a/ui/litellm-dashboard/src/components/AIHub/DecisionModelGarden.tsx b/ui/litellm-dashboard/src/components/AIHub/DecisionModelGarden.tsx new file mode 100644 index 00000000000..dcd2528c761 --- /dev/null +++ b/ui/litellm-dashboard/src/components/AIHub/DecisionModelGarden.tsx @@ -0,0 +1,159 @@ +import React, { useMemo, useState } from "react"; +import { BookOpen, Plus, Sparkles } from "lucide-react"; + +import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap"; +import { useProviderFields } from "@/app/(dashboard)/hooks/providers/useProviderFields"; +import { useProxyDecisionProviders } from "@/app/(dashboard)/hooks/providers/useProxyDecisionProviders"; +import { Logo } from "@/components/molecules/logo/Logo"; +import { getProviderLogoAndName } from "@/components/provider_info_helpers"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { + DECISIONS_DOCS_URL, + DECISIONS_PROVIDERS_DOCS_URL, + DECISIONS_SELF_HOSTED_DOCS_URL, + DecisionGardenModel, + DecisionRoute, + buildDecisionGarden, +} from "@/lib/decisionModels"; +import { uiHref } from "@/utils/uiHref"; + +const PREVIEW_COUNT = 8; + +const plural = (count: number, one: string, many: string): string => (count === 1 ? one : many); + +const ROUTE_LINKS: Readonly> = { + decisions: { href: DECISIONS_DOCS_URL, label: "/v1/decisions docs" }, + "self-hosted": { href: DECISIONS_SELF_HOSTED_DOCS_URL, label: "Self-hosted, own route" }, + "needs-upgrade": { href: DECISIONS_PROVIDERS_DOCS_URL, label: "Needs a newer LiteLLM" }, +}; + +const DEPLOYMENT_NAMED_PROVIDERS: ReadonlySet = new Set(["azure", "azure_ai"]); + +const OWN_DEPLOYMENT_LINK = { href: DECISIONS_PROVIDERS_DOCS_URL, label: "Deploy under your own name" } as const; + +const needsOwnDeploymentName = (row: DecisionGardenModel): boolean => + row.route === "decisions" && DEPLOYMENT_NAMED_PROVIDERS.has(row.provider); + +const cardLink = (row: DecisionGardenModel) => + needsOwnDeploymentName(row) ? OWN_DEPLOYMENT_LINK : ROUTE_LINKS[row.route]; + +export const addModelHref = (row: DecisionGardenModel): string => { + const params = new URLSearchParams({ tab: "add", provider: row.provider, litellm_model: row.model }); + return `${uiHref("models-and-endpoints")}?${params.toString()}`; +}; + +interface DecisionModelCardProps { + readonly row: DecisionGardenModel; + readonly canAddModels: boolean; +} + +function DecisionModelCard({ row, canAddModels }: DecisionModelCardProps) { + const { displayName } = getProviderLogoAndName(row.provider); + const link = cardLink(row); + return ( +
+
+ + {displayName} +
+ + {row.model} + +
+ + {link.label} + + {canAddModels && ( + + )} +
+
+ ); +} + +interface DecisionModelGardenProps { + readonly accessToken: string | null; + readonly canAddModels: boolean; +} + +const DecisionModelGarden: React.FC = ({ accessToken, canAddModels }) => { + const { data: costMap } = useModelCostMap(true, true); + const { data: providerFields } = useProviderFields(); + const { data: proxyProviders, isLoading: proxyProvidersLoading } = useProxyDecisionProviders(accessToken); + const rows = useMemo(() => buildDecisionGarden(costMap, proxyProviders ?? null), [costMap, proxyProviders]); + const addableProviders = useMemo( + () => new Set((providerFields ?? []).map((info) => info.litellm_provider)), + [providerFields], + ); + const [showAll, setShowAll] = useState(false); + const isAddable = (row: DecisionGardenModel): boolean => + row.route === "decisions" && !needsOwnDeploymentName(row) && addableProviders.has(row.provider); + + if (proxyProvidersLoading || rows.length === 0) { + return null; + } + + const routedRows = rows.filter((row) => row.route === "decisions"); + const routedProviderCount = new Set(routedRows.map((row) => row.provider)).size; + const selfHostedCount = rows.filter((row) => row.route === "self-hosted").length; + const needsUpgradeCount = rows.filter((row) => row.route === "needs-upgrade").length; + const visibleRows = showAll ? rows : rows.slice(0, PREVIEW_COUNT); + + return ( + +
+
+ +
+
+

Decision models LiteLLM supports

+

+ {routedRows.length} decision {plural(routedRows.length, "model", "models")} across {routedProviderCount}{" "} + {plural(routedProviderCount, "provider", "providers")} {plural(routedRows.length, "answers", "answer")} on{" "} + /v1/decisions. + {selfHostedCount > 0 && + ` ${selfHostedCount} more ${plural(selfHostedCount, "is self-hosted and answers on its own route", "are self-hosted and answer on their own routes")}.`} + {needsUpgradeCount > 0 && + ` ${needsUpgradeCount} more ${plural(needsUpgradeCount, "needs", "need")} a newer LiteLLM version.`} +

+
+ +
+
+ {visibleRows.map((row) => ( + + ))} +
+ {rows.length > PREVIEW_COUNT && ( +
+ +
+ )} +
+ ); +}; + +export default DecisionModelGarden; diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx index cc3ac1d2591..d1a118f238d 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.test.tsx @@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event"; import { act } from "@testing-library/react"; import type { MCPServerData } from "./MCPHubTableColumns"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils"; +import { renderWithProviders, screen, testQueryClient, waitFor, within } from "../../../tests/test-utils"; import ModelHubTable from "./ModelHubTable"; const mockUseUISettings = vi.hoisted(() => vi.fn()); @@ -24,8 +24,31 @@ vi.mock("@/components/networking", () => ({ makeMCPPublicCall: vi.fn(), getUiSettings: vi.fn(), getClaudeCodePluginsList: vi.fn(() => Promise.resolve({ plugins: [] })), + modelCostMap: vi.fn(() => Promise.resolve({})), + getProviderCreateMetadata: vi.fn(() => Promise.resolve([])), + getGuardrailUISettings: vi.fn(() => Promise.resolve({})), })); +const GARDEN_COST_MAP = { + "typesafe/jev-latest": { litellm_provider: "typesafe", mode: "evaluation" }, + "cloudflare/clef": { litellm_provider: "cloudflare", mode: "evaluation" }, + "newvendor/decider": { litellm_provider: "newvendor", mode: "evaluation" }, + "laya/english": { litellm_provider: "laya", mode: "evaluation", supported_endpoints: ["/v1/systemone"] }, + "claude-opus-4-8": { litellm_provider: "anthropic", mode: "chat" }, +}; + +const ADDABLE_PROVIDER_FIELDS = [ + { provider: "TypeSafe", provider_display_name: "TypeSafe", litellm_provider: "typesafe", credential_fields: [] }, + { provider: "Laya", provider_display_name: "Laya", litellm_provider: "laya", credential_fields: [] }, + { provider: "OpenAI", provider_display_name: "OpenAI", litellm_provider: "openai", credential_fields: [] }, + { + provider: "Azure_AI_Studio", + provider_display_name: "Azure AI Foundry (Studio)", + litellm_provider: "azure_ai", + credential_fields: [], + }, +]; + vi.mock("next/navigation", () => ({ useRouter: () => ({ replace: mockRouterReplace, @@ -72,6 +95,7 @@ describe("ModelHubTable", () => { writable: true, }); vi.clearAllMocks(); + testQueryClient.clear(); }); // Reusable helper function to setup mocks for auth redirect tests @@ -123,6 +147,7 @@ describe("ModelHubTable", () => { premiumUser={false} userRole={null} canOpenPlayground={false} + canAddModels={false} />, ); @@ -163,6 +188,7 @@ describe("ModelHubTable", () => { premiumUser={false} userRole={null} canOpenPlayground + canAddModels />, ); @@ -181,7 +207,14 @@ describe("ModelHubTable", () => { }); renderWithProviders( - , + , ); expect(await screen.findByText("No models yet")).toBeInTheDocument(); @@ -215,6 +248,7 @@ describe("ModelHubTable", () => { premiumUser={false} userRole={null} canOpenPlayground={false} + canAddModels={false} />, ); @@ -248,6 +282,7 @@ describe("ModelHubTable", () => { premiumUser={false} userRole="Admin" canOpenPlayground={canOpenPlayground} + canAddModels />, ); await user.click(await screen.findByRole("button", { name: model.model_group })); @@ -289,6 +324,241 @@ describe("ModelHubTable", () => { }); }); + describe("decision model garden", () => { + const renderHub = async ( + models: { model_group: string; providers: string[]; mode: string }[], + { + userRole = "Admin", + canAddModels = true, + loadProxySettings = async () => ({ decision_model_providers: ["typesafe", "cloudflare"] }), + costMap = GARDEN_COST_MAP, + }: { + userRole?: string; + canAddModels?: boolean; + loadProxySettings?: () => Promise; + costMap?: Record; + } = {}, + ) => { + vi.mocked(networking.modelHubCall).mockResolvedValue({ data: models }); + vi.mocked(networking.modelCostMap).mockResolvedValue(costMap); + vi.mocked(networking.getProviderCreateMetadata).mockResolvedValue([ + ...ADDABLE_PROVIDER_FIELDS, + { + provider: "NewVendor", + provider_display_name: "NewVendor", + litellm_provider: "newvendor", + credential_fields: [], + }, + ]); + vi.mocked(networking.getGuardrailUISettings).mockImplementation(loadProxySettings); + vi.mocked(networking.getConfigFieldSetting).mockResolvedValue({ field_value: false }); + vi.mocked(networking.getAgentsList).mockResolvedValue({ agents: [] }); + vi.mocked(networking.fetchMCPServers).mockResolvedValue([]); + vi.mocked(networking.getUiSettings).mockResolvedValue({ values: {} }); + mockUseUISettings.mockReturnValue({ data: { values: {} }, isLoading: false }); + const user = userEvent.setup(); + renderWithProviders( + , + ); + await screen.findByText(/Showing \d+ of \d+ models/); + return { user, modeSelect: () => screen.getByDisplayValue("All Modes") }; + }; + + const chatOnly = [{ model_group: "claude-opus-4-8", providers: ["anthropic"], mode: "chat" }]; + + it("lists the decision models from the cost map when the proxy has none configured", async () => { + await renderHub(chatOnly); + + const garden = await screen.findByTestId("decision-model-garden"); + expect(garden).toHaveTextContent("2 decision models across 2 providers answer on /v1/decisions"); + expect(within(garden).getByTestId("decision-model-card-typesafe/jev-latest")).toHaveTextContent("TypeSafe"); + expect(within(garden).getByTestId("decision-model-card-cloudflare/clef")).toHaveTextContent("Cloudflare"); + expect(within(garden).queryByText("claude-opus-4-8")).not.toBeInTheDocument(); + }); + + it("sends a self-hosted model that does not answer on /v1/decisions to its own docs, with no Add Model link", async () => { + await renderHub(chatOnly); + + const garden = await screen.findByTestId("decision-model-garden"); + expect(garden).toHaveTextContent("1 more is self-hosted and answers on its own route."); + const layaCard = within(garden).getByTestId("decision-model-card-laya/english"); + expect(within(layaCard).queryByRole("link", { name: "/v1/decisions docs" })).not.toBeInTheDocument(); + expect(within(layaCard).getByRole("link", { name: "Self-hosted, own route" })).toHaveAttribute( + "href", + "https://docs.litellm.ai/docs/decisions#self-hosted-laya-and-nimble", + ); + expect(await within(garden).findAllByRole("link", { name: "Add model" })).toHaveLength(1); + expect(within(layaCard).queryByRole("link", { name: "Add model" })).not.toBeInTheDocument(); + }); + + it("links each card to the decisions docs and to Add Model prefilled with that provider and model", async () => { + await renderHub(chatOnly); + + const card = within(await screen.findByTestId("decision-model-garden")).getByTestId( + "decision-model-card-typesafe/jev-latest", + ); + expect(within(card).getByRole("link", { name: "/v1/decisions docs" })).toHaveAttribute( + "href", + "https://docs.litellm.ai/docs/decisions", + ); + expect(await within(card).findByRole("link", { name: "Add model" })).toHaveAttribute( + "href", + expect.stringMatching(/\/models-and-endpoints\?tab=add&provider=typesafe&litellm_model=typesafe%2Fjev-latest$/), + ); + }); + + it("labels a model whose provider this proxy cannot serve on /v1/decisions, with no Add Model and no count", async () => { + await renderHub(chatOnly); + + const garden = await screen.findByTestId("decision-model-garden"); + expect(garden).toHaveTextContent("2 decision models across 2 providers answer on /v1/decisions"); + expect(garden).toHaveTextContent("1 more needs a newer LiteLLM version."); + const newCard = within(garden).getByTestId("decision-model-card-newvendor/decider"); + expect(within(newCard).getByRole("link", { name: "Needs a newer LiteLLM" })).toHaveAttribute( + "href", + "https://docs.litellm.ai/docs/decisions#supported-providers", + ); + expect(await within(garden).findAllByRole("link", { name: "Add model" })).toHaveLength(1); + expect(within(newCard).queryByRole("link", { name: "Add model" })).not.toBeInTheDocument(); + }); + + it("lists OpenAI's chat model that answers on /v1/decisions, with Add Model prefilled", async () => { + await renderHub(chatOnly, { + loadProxySettings: async () => ({ decision_model_providers: ["openai"] }), + costMap: { + "gpt-6-luna": { + litellm_provider: "openai", + mode: "chat", + supported_endpoints: ["/v1/chat/completions", "/v1/decisions"], + }, + "gpt-5.5": { litellm_provider: "openai", mode: "chat", supported_endpoints: ["/v1/chat/completions"] }, + }, + }); + + const garden = await screen.findByTestId("decision-model-garden"); + expect(garden).toHaveTextContent("1 decision model across 1 provider answers on /v1/decisions."); + const card = within(garden).getByTestId("decision-model-card-gpt-6-luna"); + expect(await within(card).findByRole("link", { name: "Add model" })).toHaveAttribute( + "href", + expect.stringMatching(/\?tab=add&provider=openai&litellm_model=gpt-6-luna$/), + ); + expect(within(garden).queryByTestId("decision-model-card-gpt-5.5")).not.toBeInTheDocument(); + }); + + it("sends a Foundry decision model to the docs for its deployment name instead of prefilling one Azure refuses", async () => { + await renderHub(chatOnly, { + loadProxySettings: async () => ({ decision_model_providers: ["typesafe", "azure_ai"] }), + costMap: { + "typesafe/jev-latest": { litellm_provider: "typesafe", mode: "evaluation" }, + "azure_ai/Microsoft-Decision-1": { litellm_provider: "azure_ai", mode: "evaluation" }, + }, + }); + + const garden = await screen.findByTestId("decision-model-garden"); + expect(garden).toHaveTextContent("2 decision models across 2 providers answer on /v1/decisions"); + const foundryCard = within(garden).getByTestId("decision-model-card-azure_ai/Microsoft-Decision-1"); + expect(within(foundryCard).getByRole("link", { name: "Deploy under your own name" })).toHaveAttribute( + "href", + "https://docs.litellm.ai/docs/decisions#supported-providers", + ); + expect(await within(garden).findAllByRole("link", { name: "Add model" })).toHaveLength(1); + expect(within(foundryCard).queryByRole("link", { name: "Add model" })).not.toBeInTheDocument(); + }); + + it("falls back to the cost map alone when this proxy's decision provider list cannot be read", async () => { + await renderHub(chatOnly, { + loadProxySettings: async () => { + throw new Error("Failed to get guardrail UI settings"); + }, + }); + + const garden = await screen.findByTestId("decision-model-garden"); + expect(garden).toHaveTextContent("3 decision models across 3 providers answer on /v1/decisions"); + expect(garden).not.toHaveTextContent("a newer LiteLLM version"); + }); + + it("offers a decisions mode option that keeps the garden, hides the chat rows, and points at the garden", async () => { + const { user, modeSelect } = await renderHub(chatOnly); + await screen.findByTestId("decision-model-garden"); + + expect(within(modeSelect()).queryByRole("option", { name: "evaluation" })).not.toBeInTheDocument(); + await user.selectOptions(modeSelect(), "decisions"); + + expect(screen.getByTestId("decision-model-garden")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "claude-opus-4-8" })).not.toBeInTheDocument(); + expect(screen.getByText("Showing 0 of 1 models")).toBeInTheDocument(); + expect(screen.getByText("No model on this proxy uses the decisions mode")).toBeInTheDocument(); + expect(screen.queryByText("Adjust the filters to see more models.")).not.toBeInTheDocument(); + }); + + it("hides the garden while a non-decision mode is selected", async () => { + const { user, modeSelect } = await renderHub(chatOnly); + await screen.findByTestId("decision-model-garden"); + + await user.selectOptions(modeSelect(), "chat"); + expect(screen.queryByTestId("decision-model-garden")).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "Clear Filters" })); + expect(await screen.findByTestId("decision-model-garden")).toBeInTheDocument(); + }); + + it("labels a configured evaluation model as decisions in the table and under the decisions filter", async () => { + const { user, modeSelect } = await renderHub([ + ...chatOnly, + { model_group: "jev-latest", providers: ["typesafe"], mode: "evaluation" }, + ]); + + const row = screen.getByRole("button", { name: "jev-latest" }).closest("tr"); + expect(row).toHaveTextContent("decisions"); + expect(row).not.toHaveTextContent("evaluation"); + + await user.selectOptions(modeSelect(), "decisions"); + expect(screen.getByText("Showing 1 of 2 models")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "jev-latest" })).toBeInTheDocument(); + }); + + it("drops the Add Model link for an admin session that cannot add models", async () => { + await renderHub(chatOnly, { canAddModels: false }); + + const garden = await screen.findByTestId("decision-model-garden"); + expect(within(garden).queryByRole("link", { name: "Add model" })).not.toBeInTheDocument(); + expect(within(garden).getAllByRole("link", { name: "/v1/decisions docs" })).toHaveLength(2); + }); + + it("drops the Add Model link on a card whose provider the Add Model form cannot create", async () => { + await renderHub(chatOnly); + + const garden = await screen.findByTestId("decision-model-garden"); + const typesafeCard = within(garden).getByTestId("decision-model-card-typesafe/jev-latest"); + const cloudflareCard = within(garden).getByTestId("decision-model-card-cloudflare/clef"); + expect(await within(typesafeCard).findByRole("link", { name: "Add model" })).toBeInTheDocument(); + expect(within(cloudflareCard).queryByRole("link", { name: "Add model" })).not.toBeInTheDocument(); + expect(within(cloudflareCard).getByRole("link", { name: "/v1/decisions docs" })).toBeInTheDocument(); + }); + + it("sorts the Mode column by the label it shows, placing decisions between chat and embedding", async () => { + const { user } = await renderHub([ + { model_group: "text-embedding-4", providers: ["openai"], mode: "embedding" }, + { model_group: "jev-latest", providers: ["typesafe"], mode: "evaluation" }, + ...chatOnly, + ]); + + await user.click(screen.getByTestId("sort-header-mode")); + + const order = screen + .getAllByRole("button", { name: /^(claude-opus-4-8|jev-latest|text-embedding-4)$/ }) + .map((button) => button.textContent); + expect(order).toEqual(["claude-opus-4-8", "jev-latest", "text-embedding-4"]); + }); + }); + describe("hub tabs", () => { const renderHub = async (agents: object[] = [], mcpServers: Promise = Promise.resolve([])) => { vi.mocked(networking.modelHubCall).mockResolvedValue({ @@ -308,6 +578,7 @@ describe("ModelHubTable", () => { premiumUser={false} userRole="Admin" canOpenPlayground + canAddModels />, ); return { user, search: await screen.findByPlaceholderText("Search model names...") }; diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx index 2007020e117..3e23d64d28a 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTable.tsx @@ -44,7 +44,14 @@ import { checkTokenValidity } from "@/utils/jwtUtils"; import { getCookie } from "@/utils/cookieUtils"; import { getLoginUrl } from "@/utils/returnUrlUtils"; import { uiHref } from "@/utils/uiHref"; -import { DECISIONS_DOCS_URL, SYSTEM_ONE_PLAYGROUND_ROUTE, displayMode, isDecisionMode } from "@/lib/decisionModels"; +import { + DECISIONS_DOCS_URL, + DECISIONS_MODE, + SYSTEM_ONE_PLAYGROUND_ROUTE, + displayMode, + isDecisionMode, +} from "@/lib/decisionModels"; +import DecisionModelGarden from "@/components/AIHub/DecisionModelGarden"; interface ModelHubTableProps { accessToken: string | null; @@ -52,12 +59,29 @@ interface ModelHubTableProps { premiumUser: boolean; userRole: string | null; canOpenPlayground: boolean; + canAddModels: boolean; } function isMCPHubVisibilityDisabled(isLoading: boolean, servers: readonly MCPServerData[] | null): boolean { return isLoading || servers === null; } +function modelTableEmptyState( + models: readonly ModelHubData[], + showsDecisionGarden: boolean, + selectedMode: string, +): { title: string; body: string } { + if (showsDecisionGarden && selectedMode === DECISIONS_MODE && !models.some((model) => isDecisionMode(model.mode))) { + return { + title: "No model on this proxy uses the decisions mode", + body: "The decision models LiteLLM supports are listed below.", + }; + } + return models.length > 0 + ? { title: "No matching models", body: "Adjust the filters to see more models." } + : { title: "No models yet", body: "Models added to this proxy will appear here." }; +} + function HubEmptyState({ title, body }: { title: string; body: string }) { return (
@@ -76,6 +100,7 @@ const ModelHubTable: React.FC = ({ premiumUser, userRole, canOpenPlayground, + canAddModels, }) => { const syntaxTheme = useSyntaxTheme(prism); // Admin Viewer follows the read-parity rule: see the AI Hub catalog, but @@ -88,6 +113,8 @@ const ModelHubTable: React.FC = ({ const [isModalVisible, setIsModalVisible] = useState(false); const [selectedModel, setSelectedModel] = useState(null); const [filteredData, setFilteredData] = useState([]); + const [selectedMode, setSelectedMode] = useState(""); + const showDecisionGarden = !publicPage && (selectedMode === "" || selectedMode === DECISIONS_MODE); const [isMakePublicModalVisible, setIsMakePublicModalVisible] = useState(false); // Agent Hub state const [agentHubData, setAgentHubData] = useState(null); @@ -479,7 +506,12 @@ const ModelHubTable: React.FC = ({ )} {/* Filters */} - + {/* Model Table */} = ({ isLoading={loading} loadingMessage="Loading models…" noDataMessage={ - + } size="compact" /> @@ -511,6 +536,8 @@ const ModelHubTable: React.FC = ({ Showing {filteredData.length} of {modelHubData?.length || 0} models

+ + {showDecisionGarden && } {/* Agent Hub Tab */} diff --git a/ui/litellm-dashboard/src/components/AIHub/ModelHubTableColumns.tsx b/ui/litellm-dashboard/src/components/AIHub/ModelHubTableColumns.tsx index 559c18930c7..1a5f8889c93 100644 --- a/ui/litellm-dashboard/src/components/AIHub/ModelHubTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/ModelHubTableColumns.tsx @@ -129,7 +129,7 @@ export const getModelHubTableColumns = ({ onModelClick }: ModelHubTableColumnsDe }, { id: "mode", - accessorKey: "mode", + accessorFn: (row) => (row.mode ? displayMode(row.mode) : ""), meta: { title: "Mode", className: "hidden lg:table-cell" }, header: ({ column }) => , size: 110, diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.test.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.test.tsx index bbeee883b1a..f99facad6fb 100644 --- a/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.test.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.test.tsx @@ -329,6 +329,19 @@ describe("MakeModelPublicForm", () => { expect(screen.getAllByText("openai")).toHaveLength(2); }); + it("labels an evaluation-mode model as decisions, matching the mode filter", () => { + const decisionModel = { + ...mockProps.modelHubData[0], + model_group: "jev-latest", + providers: ["typesafe"], + mode: "evaluation", + }; + render(); + + expect(screen.getByText("decisions")).toBeInTheDocument(); + expect(screen.queryByText("evaluation")).not.toBeInTheDocument(); + }); + it("should handle submit error properly", async () => { const errorMessage = "Network error"; mockMakeModelGroupPublic.mockRejectedValueOnce(new Error(errorMessage)); diff --git a/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.tsx b/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.tsx index 073eedca191..20fa0295fed 100644 --- a/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.tsx +++ b/ui/litellm-dashboard/src/components/AIHub/forms/MakeModelPublicForm.tsx @@ -8,6 +8,7 @@ import { cn } from "@/lib/cva.config"; import { makeModelGroupPublic } from "../../networking"; import ModelFilters from "../../model_filters"; import { toast } from "@/lib/toast"; +import { displayMode } from "@/lib/decisionModels"; const STEP_TITLES = ["Select Models", "Confirm"]; @@ -186,7 +187,7 @@ const MakeModelPublicForm: React.FC = ({

{model.model_group}

- {model.mode && {model.mode}} + {model.mode && {displayMode(model.mode)}}
{model.providers.map((provider) => ( diff --git a/ui/litellm-dashboard/src/components/PublicModelHubTableColumns.tsx b/ui/litellm-dashboard/src/components/PublicModelHubTableColumns.tsx index 2fae8044d6b..0288c58e31e 100644 --- a/ui/litellm-dashboard/src/components/PublicModelHubTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/PublicModelHubTableColumns.tsx @@ -7,6 +7,7 @@ import { CellTooltip, IdentityCell, StatusBadge, type StatusTone } from "@/compo import { Badge } from "@/components/ui/badge"; import { getProviderLogoAndName } from "@/components/provider_info_helpers"; import { PUBLIC_MODEL_HUB_SORTABLE_FIELDS } from "@/components/publicModelHub/publicModelHubFilters"; +import { displayMode } from "@/lib/decisionModels"; export interface ModelGroupInfo { model_group: string; @@ -203,7 +204,7 @@ export const getPublicModelHubColumns = ({ onModelClick }: PublicModelHubColumns cell: ({ row }) => ( {getModeIcon(row.original.mode || "")} - {row.original.mode || "Chat"} + {row.original.mode ? displayMode(row.original.mode) : "Chat"} ), }, diff --git a/ui/litellm-dashboard/src/components/model_filters.tsx b/ui/litellm-dashboard/src/components/model_filters.tsx index 1eef5083f16..165cbf68fe0 100644 --- a/ui/litellm-dashboard/src/components/model_filters.tsx +++ b/ui/litellm-dashboard/src/components/model_filters.tsx @@ -26,6 +26,8 @@ interface ModelFiltersProps { onFilteredDataChange: (filteredData: ModelGroupInfo[]) => void; showFiltersCard?: boolean; className?: string; + extraModes?: readonly string[]; + onModeChange?: (mode: string) => void; } const ModelFilters: React.FC = ({ @@ -33,6 +35,8 @@ const ModelFilters: React.FC = ({ onFilteredDataChange, showFiltersCard = true, className = "", + extraModes = [], + onModeChange, }) => { const [searchTerm, setSearchTerm] = useState(""); const [selectedProvider, setSelectedProvider] = useState(""); @@ -50,7 +54,7 @@ const ModelFilters: React.FC = ({ }; const getUniqueModes = (data: ModelGroupInfo[]) => { - const modes = new Set(); + const modes = new Set(extraModes); data.forEach((model) => { const mode = displayMode(model.mode); if (mode) modes.add(mode); @@ -58,6 +62,11 @@ const ModelFilters: React.FC = ({ return Array.from(modes); }; + const changeMode = (mode: string) => { + setSelectedMode(mode); + onModeChange?.(mode); + }; + const getUniqueFeatures = (data: ModelGroupInfo[]) => { const features = new Set(); data.forEach((model) => { @@ -119,7 +128,7 @@ const ModelFilters: React.FC = ({ const resetFilters = () => { setSearchTerm(""); setSelectedProvider(""); - setSelectedMode(""); + changeMode(""); setSelectedFeature(""); }; @@ -159,7 +168,7 @@ const ModelFilters: React.FC = ({

Mode: