feat(ui): show decision models as decisions and list the supported ones in the AI Hub (#45965)

* feat(ui): show decision models as decisions and list the supported ones in the AI Hub

* fix(ui): gate the decision garden's Add model links and sort the Mode column by its label

The garden shows Add model only for providers the Add Model form can create and only when the session can add models, since a view-only admin's session role reads as Admin. The AI Hub's Mode column sorts by the label it shows, and the Models page parses its tab slug as a string literal

* fix(ui): label the make-public dialog's decision models, clear the prefill after the add, and gate the garden's Add model by the Models page's rule

* fix(ui): drop the dead decisions billing-row filter and the unread team fetch on the AI Hub

The <provider>/decisions/<model> cost map keys never shipped, so the garden filter matched nothing. The AI Hub page fetched /team/list for every visitor and read it for nobody, since only proxy admins reach the add-model check. The org-admin page test now asserts the role string a session carries

* fix(ui): keep self-hosted decision models off the /v1/decisions claim, order the garden one per provider, and show the decisions example on the public hub

* fix(ui): mark decision models this proxy cannot serve yet in the AI Hub garden

* fix(ui): use singular wording in the decision garden header when one model is left

* fix(ui): list gpt-6-luna in the decision garden, send Foundry to its deployment-name docs, and record Models page tab clicks

* fix(ui): say the decisions filter matched no model in decisions mode, and mock usePathname in the Models page integration test

* fix(ui): stop saying deployed decision models still need adding

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-14 09:38:25 +00:00 • committed by GitHub
parent 1aee1a21ed
commit 30b8db325e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 1086 additions and 97 deletions

View file

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

View file

@ -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 <div data-testid="model-hub-table" />;
},
}));
vi.mock("@/components/public_model_hub", () => ({
default: () => <div data-testid="public-model-hub" />,
}));
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(<ModelHubTablePage />);
expect(hubProps).toHaveBeenCalledWith(expect.objectContaining({ canAddModels: true }));
});
it("keeps the garden read-only for a view-only admin", () => {
mockUseAuthorized.mockReturnValue(session("Admin", true));
render(<ModelHubTablePage />);
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(<ModelHubTablePage />);
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(<ModelHubTablePage />);
expect(screen.getByTestId("public-model-hub")).toBeInTheDocument();
expect(hubProps).not.toHaveBeenCalled();
});
});

View file

@ -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 <PublicModelHub accessToken={accessToken} isEmbedded={true} />;
}
const canAddModels = canCreateModels(
{ userRole, userID, isViewOnly },
{ teams: null, disabledForInternalUsers: false },
);
return (
<ModelHubTable
accessToken={accessToken}
@ -17,6 +22,7 @@ export default function ModelHubTablePage() {
premiumUser={premiumUser}
userRole={userRole}
canOpenPlayground={!isViewOnly}
canAddModels={canAddModels}
/>
);
}

View file

@ -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: "",

View file

@ -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: () => <div data-testid="panel-all-models" /> }));
vi.mock("./panels/AddModelPanel", () => ({ default: () => <div data-testid="panel-add" /> }));
vi.mock("./panels/AutoRoutersTabPanel", () => ({ default: () => <div data-testid="panel-auto-routers" /> }));
@ -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(
<QueryClientProvider client={queryClient}>
<ModelsAndEndpointsPage />
</QueryClientProvider>,
);
};
const renderPage = (options?: { searchParams?: string; onUrlUpdate?: OnUrlUpdateFunction }) =>
renderWithProviders(<ModelsAndEndpointsPage />, options);
const lastUrlParams = (onUrlUpdate: Mock<OnUrlUpdateFunction>): 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<OnUrlUpdateFunction>();
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<OnUrlUpdateFunction>();
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();
});
});
});

View file

@ -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<ModelTabSlug, string> = {
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<string>(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}
/>
) : (
<Tabs value={activeKey} onValueChange={setActiveKey}>
<Tabs value={activeKey} onValueChange={handleTabChange}>
<div className="flex min-w-0 flex-nowrap items-center gap-3 border-b">
<div className="no-scrollbar scroll-fade-e -mb-1.5 min-w-0 flex-1 overflow-x-auto pb-1.5">
<TabsList variant="line" className="w-max justify-start">

View file

@ -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(<AddModelPanel />);
renderWithProviders(<AddModelPanel />, { 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<OnUrlUpdateFunction>();
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 },
});
});
});

View file

@ -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<MountedFormValues>({ 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<string | null>(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;

View file

@ -22,6 +22,7 @@ function PublicModelHubTableContent() {
premiumUser={false}
userRole={null}
canOpenPlayground={false}
canAddModels={false}
/>
);
}

View file

@ -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<Record<DecisionRoute, { readonly href: string; readonly label: string }>> = {
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<string> = 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 (
<div
className="flex flex-col gap-3 rounded-lg border bg-background p-4"
data-testid={`decision-model-card-${row.model}`}
>
<div className="flex items-center gap-2">
<Logo provider={row.provider} className="size-5" />
<span className="truncate text-sm font-medium text-foreground">{displayName}</span>
</div>
<code className="truncate text-xs text-muted-foreground" title={row.model}>
{row.model}
</code>
<div className="mt-auto flex items-center justify-between gap-2">
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground underline"
>
{link.label}
</a>
{canAddModels && (
<Button size="xs" variant="outline" nativeButton={false} role="link" render={<a href={addModelHref(row)} />}>
<Plus />
Add model
</Button>
)}
</div>
</div>
);
}
interface DecisionModelGardenProps {
readonly accessToken: string | null;
readonly canAddModels: boolean;
}
const DecisionModelGarden: React.FC<DecisionModelGardenProps> = ({ 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 (
<Card className="mt-6 px-6" data-testid="decision-model-garden">
<div className="mb-4 flex items-center gap-4">
<div className="flex size-10 shrink-0 items-center justify-center rounded-full border bg-background">
<Sparkles className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<h3 className="m-0 text-base font-semibold text-foreground">Decision models LiteLLM supports</h3>
<p className="m-0 mt-0.5 text-sm text-muted-foreground">
{routedRows.length} decision {plural(routedRows.length, "model", "models")} across {routedProviderCount}{" "}
{plural(routedProviderCount, "provider", "providers")} {plural(routedRows.length, "answers", "answer")} on{" "}
<code>/v1/decisions</code>.
{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.`}
</p>
</div>
<Button
variant="outline"
className="shrink-0"
nativeButton={false}
role="link"
render={<a href={DECISIONS_DOCS_URL} target="_blank" rel="noopener noreferrer" />}
>
<BookOpen />
Decisions docs
</Button>
</div>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{visibleRows.map((row) => (
<DecisionModelCard key={row.model} row={row} canAddModels={canAddModels && isAddable(row)} />
))}
</div>
{rows.length > PREVIEW_COUNT && (
<div className="mt-4 flex justify-center">
<Button variant="ghost" type="button" onClick={() => setShowAll((value) => !value)}>
{showAll ? "Show fewer" : `Show all ${rows.length}`}
</Button>
</div>
)}
</Card>
);
};
export default DecisionModelGarden;

View file

@ -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(
<ModelHubTable accessToken={null} publicPage={false} premiumUser={false} userRole={null} canOpenPlayground />,
<ModelHubTable
accessToken={null}
publicPage={false}
premiumUser={false}
userRole={null}
canOpenPlayground
canAddModels
/>,
);
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<unknown>;
costMap?: Record<string, unknown>;
} = {},
) => {
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(
<ModelHubTable
accessToken="test-token"
publicPage={false}
premiumUser={false}
userRole={userRole}
canOpenPlayground
canAddModels={canAddModels}
/>,
);
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<MCPServerData[]> = 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...") };

View file

@ -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 (
<div className="flex flex-col items-center gap-1 py-6">
@ -76,6 +100,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
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<ModelHubTableProps> = ({
const [isModalVisible, setIsModalVisible] = useState(false);
const [selectedModel, setSelectedModel] = useState<null | ModelHubData>(null);
const [filteredData, setFilteredData] = useState<ModelHubData[]>([]);
const [selectedMode, setSelectedMode] = useState<string>("");
const showDecisionGarden = !publicPage && (selectedMode === "" || selectedMode === DECISIONS_MODE);
const [isMakePublicModalVisible, setIsMakePublicModalVisible] = useState(false);
// Agent Hub state
const [agentHubData, setAgentHubData] = useState<AgentHubData[] | null>(null);
@ -479,7 +506,12 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
)}
{/* Filters */}
<ModelFilters modelHubData={modelHubData || []} onFilteredDataChange={handleFilteredDataChange} />
<ModelFilters
modelHubData={modelHubData || []}
onFilteredDataChange={handleFilteredDataChange}
extraModes={publicPage ? [] : [DECISIONS_MODE]}
onModeChange={setSelectedMode}
/>
{/* Model Table */}
<DataTable
@ -493,14 +525,7 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
isLoading={loading}
loadingMessage="Loading models…"
noDataMessage={
<HubEmptyState
title={modelHubData?.length ? "No matching models" : "No models yet"}
body={
modelHubData?.length
? "Adjust the filters to see more models."
: "Models added to this proxy will appear here."
}
/>
<HubEmptyState {...modelTableEmptyState(modelHubData ?? [], showDecisionGarden, selectedMode)} />
}
size="compact"
/>
@ -511,6 +536,8 @@ const ModelHubTable: React.FC<ModelHubTableProps> = ({
Showing {filteredData.length} of {modelHubData?.length || 0} models
</p>
</div>
{showDecisionGarden && <DecisionModelGarden accessToken={accessToken} canAddModels={canAddModels} />}
</TabsContent>
{/* Agent Hub Tab */}

View file

@ -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 }) => <DataTableSortHeader column={column} title="Mode" />,
size: 110,

View file

@ -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(<MakeModelPublicForm {...mockProps} modelHubData={[decisionModel]} />);
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));

View file

@ -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<MakeModelPublicFormProps> = ({
<div className="flex-1 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="font-medium break-words">{model.model_group}</p>
{model.mode && <Badge>{model.mode}</Badge>}
{model.mode && <Badge>{displayMode(model.mode)}</Badge>}
</div>
<div className="flex flex-wrap gap-1 mt-1">
{model.providers.map((provider) => (

View file

@ -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 }) => (
<span className="flex items-center gap-2 text-sm">
<span>{getModeIcon(row.original.mode || "")}</span>
<span>{row.original.mode || "Chat"}</span>
<span>{row.original.mode ? displayMode(row.original.mode) : "Chat"}</span>
</span>
),
},

View file

@ -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<ModelFiltersProps> = ({
@ -33,6 +35,8 @@ const ModelFilters: React.FC<ModelFiltersProps> = ({
onFilteredDataChange,
showFiltersCard = true,
className = "",
extraModes = [],
onModeChange,
}) => {
const [searchTerm, setSearchTerm] = useState<string>("");
const [selectedProvider, setSelectedProvider] = useState<string>("");
@ -50,7 +54,7 @@ const ModelFilters: React.FC<ModelFiltersProps> = ({
};
const getUniqueModes = (data: ModelGroupInfo[]) => {
const modes = new Set<string>();
const modes = new Set<string>(extraModes);
data.forEach((model) => {
const mode = displayMode(model.mode);
if (mode) modes.add(mode);
@ -58,6 +62,11 @@ const ModelFilters: React.FC<ModelFiltersProps> = ({
return Array.from(modes);
};
const changeMode = (mode: string) => {
setSelectedMode(mode);
onModeChange?.(mode);
};
const getUniqueFeatures = (data: ModelGroupInfo[]) => {
const features = new Set<string>();
data.forEach((model) => {
@ -119,7 +128,7 @@ const ModelFilters: React.FC<ModelFiltersProps> = ({
const resetFilters = () => {
setSearchTerm("");
setSelectedProvider("");
setSelectedMode("");
changeMode("");
setSelectedFeature("");
};
@ -159,7 +168,7 @@ const ModelFilters: React.FC<ModelFiltersProps> = ({
<p className="text-sm font-medium mb-2">Mode:</p>
<select
value={selectedMode}
onChange={(e) => setSelectedMode(e.target.value)}
onChange={(e) => changeMode(e.target.value)}
className="border rounded-sm px-3 py-2 text-sm text-muted-foreground w-32 h-10"
>
<option value="" className="text-sm text-muted-foreground">

View file

@ -26,6 +26,12 @@ describe("serializePublicModelHubFilters", () => {
});
});
it("asks the route for every stored spelling of a decision model when decisions is picked", () => {
const filters: ColumnFiltersState = [{ id: MODE_FILTER_ID, value: ["chat", "decisions"] }];
expect(serializePublicModelHubFilters(filters)).toEqual({ "filter[mode][in]": "chat,evaluation,decisions" });
});
it("omits blank filters rather than sending parameters the route rejects", () => {
const filters: ColumnFiltersState = [
{ id: MODE_FILTER_ID, value: [] },

View file

@ -1,5 +1,7 @@
import type { ColumnFilter, ColumnFiltersState } from "@tanstack/react-table";
import { expandModeFilterValues } from "@/lib/decisionModels";
export const MODE_FILTER_ID = "mode";
export const PROVIDER_FILTER_ID = "providers";
export const FEATURE_FILTER_ID = "features";
@ -31,6 +33,7 @@ const inFilter = (field: string, value: unknown): QueryEntry[] =>
const filterParams = (filter: ColumnFilter): QueryEntry[] => {
switch (filter.id) {
case MODE_FILTER_ID:
return inFilter(filter.id, expandModeFilterValues(asStringArray(filter.value)));
case PROVIDER_FILTER_ID:
case FEATURE_FILTER_ID:
return inFilter(filter.id, filter.value);

View file

@ -62,9 +62,14 @@ const model = (overrides: Partial<ModelGroupInfo> & { model_group: string }): Mo
const DEFAULT_MODELS = [model({ model_group: "gpt-4" }), model({ model_group: "claude-3", providers: ["anthropic"] })];
const respondWith = (rows: ModelGroupInfo[], totalCount: number = rows.length, pageSize: number = 50) =>
const respondWith = (
rows: ModelGroupInfo[],
totalCount: number = rows.length,
pageSize: number = 50,
facetValues: Record<string, string[]> = FACET_VALUES,
) =>
apiGetMock.mockImplementation((path: string) => {
const facet = FACET_VALUES[path];
const facet = facetValues[path];
if (facet) {
return Promise.resolve({
data: facet,
@ -277,6 +282,45 @@ describe("PublicModelHub", () => {
await waitFor(() => expect(lastModelQuery()["filter[mode][in]"]).toBe("embedding"));
});
it("shows decision models as decisions and filters on every spelling the route stores", async () => {
respondWith([model({ model_group: "jev-latest", providers: ["typesafe"], mode: "evaluation" })], 1, 50, {
...FACET_VALUES,
[`${MODEL_HUB_PATH}/modes`]: ["chat", "evaluation", "decisions"],
});
const user = userEvent.setup();
renderHub();
const row = (await screen.findByText("jev-latest")).closest("tr");
expect(row).toHaveTextContent("decisions");
expect(row).not.toHaveTextContent("evaluation");
await user.click(screen.getByPlaceholderText("Select modes"));
expect(screen.getAllByRole("option", { name: "decisions" })).toHaveLength(1);
expect(screen.queryByRole("option", { name: "evaluation" })).not.toBeInTheDocument();
await user.click(screen.getByRole("option", { name: "decisions" }));
await waitFor(() => expect(lastModelQuery()["filter[mode][in]"]).toBe("evaluation,decisions"));
});
it("shows a decision model's usage example on /v1/systemone instead of chat completions", async () => {
respondWith([model({ model_group: "jev-latest", providers: ["typesafe"], mode: "evaluation" })]);
renderHub();
fireEvent.click(await screen.findByRole("button", { name: "jev-latest" }));
const example = (await screen.findByText(/\/v1\/systemone/)).closest("pre");
expect(example).toHaveTextContent('"model": "jev-latest"');
expect(example).not.toHaveTextContent("chat.completions");
});
it("keeps the chat completions usage example for a chat model", async () => {
renderHub();
fireEvent.click(await screen.findByRole("button", { name: "gpt-4" }));
const example = (await screen.findByText(/chat\.completions\.create/)).closest("pre");
expect(example).toHaveTextContent('model="gpt-4"');
});
it("filters by several providers at once, and returns to the first page", async () => {
const user = userEvent.setup();
respondWith(DEFAULT_MODELS, 300);

View file

@ -30,6 +30,7 @@ import Navbar from "./navbar";
import {
agentHubPublicModelsCall,
skillHubPublicCall,
getGlobalLitellmHeaderName,
getProxyBaseUrl,
getPublicModelHubInfo,
getUiConfig,
@ -49,6 +50,8 @@ import { generateCodeSnippet } from "@/components/chat_ui/CodeSnippets";
import { getEndpointType } from "@/components/chat_ui/mode_endpoint_mapping";
import { MessageType } from "@/components/chat_ui/types";
import { getProviderLogoAndName } from "./provider_info_helpers";
import { displayMode, isDecisionMode } from "@/lib/decisionModels";
import { modelUsageExample } from "@/components/AIHub/modelUsageExample";
import { filterBySearchTerm, rankBySearchRelevance } from "@/utils/searchUtils";
interface PublicModelHubProps {
@ -56,6 +59,25 @@ interface PublicModelHubProps {
isEmbedded?: boolean; // When true, hides navbar and adjusts layout for embedding in dashboard
}
const usageExample = (model: ModelGroupInfo): string =>
isDecisionMode(model.mode)
? modelUsageExample(model.mode, getProxyBaseUrl(), model.model_group, getGlobalLitellmHeaderName())
: generateCodeSnippet({
apiKeySource: "custom",
accessToken: null,
apiKey: "your_api_key",
inputMessage: "Hello, how are you?",
chatHistory: [{ role: "user", content: "Hello, how are you?", isImage: false } as MessageType],
selectedTags: [],
selectedVectorStores: [],
selectedGuardrails: [],
selectedPolicies: [],
selectedMCPServers: [],
endpointType: getEndpointType(model.mode || "chat"),
selectedModel: model.model_group,
selectedSdk: "openai",
});
function PublicHubEmptyState({ title, body }: { title: string; body: string }) {
return (
<div className="flex flex-col items-center gap-1 py-6">
@ -267,7 +289,14 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
const models = usePublicModelHubList(proxyConfigured);
const modelFacets = usePublicModelHubFacets(proxyConfigured);
const modeOptions = useMemo(() => modelFacets.modes.map((mode) => ({ label: mode, value: mode })), [modelFacets]);
const modeOptions = useMemo(
() =>
[...new Set(modelFacets.modes.flatMap((mode) => displayMode(mode) ?? []))].map((mode) => ({
label: mode,
value: mode,
})),
[modelFacets],
);
const featureOptions = useMemo(
() => modelFacets.features.map((feature) => ({ label: featureLabel(feature), value: feature })),
[modelFacets],
@ -687,7 +716,7 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
</div>
<div>
<p className="font-medium">Mode:</p>
<p>{selectedModel.mode || "Not specified"}</p>
<p>{displayMode(selectedModel.mode) ?? "Not specified"}</p>
</div>
<div>
<p className="font-medium">Providers:</p>
@ -840,51 +869,11 @@ const PublicModelHub: React.FC<PublicModelHubProps> = ({ accessToken, isEmbedded
<div>
<p className="text-lg font-semibold mb-4">Usage Example</p>
<div className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
<pre className="text-sm">
{(() => {
const codeSnippet = generateCodeSnippet({
apiKeySource: "custom",
accessToken: null,
apiKey: "your_api_key",
inputMessage: "Hello, how are you?",
chatHistory: [
{ role: "user", content: "Hello, how are you?", isImage: false } as MessageType,
],
selectedTags: [],
selectedVectorStores: [],
selectedGuardrails: [],
selectedPolicies: [],
selectedMCPServers: [],
endpointType: getEndpointType(selectedModel.mode || "chat"),
selectedModel: selectedModel.model_group,
selectedSdk: "openai",
});
return codeSnippet;
})()}
</pre>
<pre className="text-sm">{usageExample(selectedModel)}</pre>
</div>
<div className="mt-2 text-right">
<button
onClick={() => {
const codeSnippet = generateCodeSnippet({
apiKeySource: "custom",
accessToken: null,
apiKey: "your_api_key",
inputMessage: "Hello, how are you?",
chatHistory: [
{ role: "user", content: "Hello, how are you?", isImage: false } as MessageType,
],
selectedTags: [],
selectedVectorStores: [],
selectedGuardrails: [],
selectedPolicies: [],
selectedMCPServers: [],
endpointType: getEndpointType(selectedModel.mode || "chat"),
selectedModel: selectedModel.model_group,
selectedSdk: "openai",
});
copyToClipboard(codeSnippet);
}}
onClick={() => copyToClipboard(usageExample(selectedModel))}
className="text-sm text-info hover:text-info/80 cursor-pointer"
>
Copy to clipboard

View file

@ -2,14 +2,18 @@ import { describe, expect, it } from "vitest";
import {
EMPTY_DECISION_CATALOG,
buildDecisionCatalog,
buildDecisionGarden,
displayMode,
expandModeFilterValues,
isDecisionMode,
isDecisionSelection,
parseProxyDecisionProviders,
} from "./decisionModels";
const COST_MAP = {
"typesafe/jev-latest": { litellm_provider: "typesafe", mode: "evaluation" },
"typesafe/jev-preview": { litellm_provider: "typesafe", mode: "evaluation" },
"cloudflare/clef": { litellm_provider: "cloudflare", mode: "decisions" },
"gpt-6-luna": {
litellm_provider: "openai",
mode: "chat",
@ -33,21 +37,136 @@ describe("isDecisionMode", () => {
});
describe("displayMode", () => {
it("shows the legacy evaluation mode as decisions and leaves other modes alone", () => {
it("shows every decision mode spelling as decisions and leaves other modes alone", () => {
expect(displayMode("evaluation")).toBe("decisions");
expect(displayMode("decisions")).toBe("decisions");
expect(displayMode("chat")).toBe("chat");
expect(displayMode("embedding")).toBe("embedding");
expect(displayMode(null)).toBeUndefined();
expect(displayMode(undefined)).toBeUndefined();
});
});
describe("expandModeFilterValues", () => {
it("widens a decisions selection to every mode spelling the route stores", () => {
expect(expandModeFilterValues(["decisions"])).toEqual(["evaluation", "decisions"]);
expect(expandModeFilterValues(["chat", "decisions"])).toEqual(["chat", "evaluation", "decisions"]);
expect(expandModeFilterValues(["chat"])).toEqual(["chat"]);
});
});
describe("buildDecisionGarden", () => {
const garden = buildDecisionGarden(COST_MAP, null);
it("lists each decision model once, one per provider before any provider's second", () => {
expect(garden).toEqual([
{ model: "cloudflare/clef", provider: "cloudflare", route: "decisions" },
{ model: "gpt-6-luna", provider: "openai", route: "decisions" },
{ model: "perplexity/pplx-decider-v1-27b", provider: "perplexity", route: "decisions" },
{ model: "typesafe/jev-latest", provider: "typesafe", route: "decisions" },
{ model: "typesafe/jev-preview", provider: "typesafe", route: "decisions" },
{ model: "systemone-only", provider: "somevendor", route: "self-hosted" },
]);
});
it("puts self-hosted models that only serve their own route after every /v1/decisions model", () => {
const rows = buildDecisionGarden(
{
"aaa/self-hosted": { litellm_provider: "aaa", mode: "evaluation", supported_endpoints: ["/v1/systemone"] },
"typesafe/jev-latest": { litellm_provider: "typesafe", mode: "evaluation" },
"typesafe/jev-preview": { litellm_provider: "typesafe", mode: "evaluation" },
"zzz/decider": { litellm_provider: "zzz", mode: "evaluation", supported_endpoints: ["/v1/decisions"] },
},
null,
);
expect(rows.map((row) => [row.model, row.route])).toEqual([
["typesafe/jev-latest", "decisions"],
["zzz/decider", "decisions"],
["typesafe/jev-preview", "decisions"],
["aaa/self-hosted", "self-hosted"],
]);
});
it("shows a provider's shortest model name first, so an alias outranks its long twin", () => {
const rows = buildDecisionGarden(
{
"cloudflare/@cf/cloudflare/clef": { litellm_provider: "cloudflare", mode: "evaluation" },
"cloudflare/clef": { litellm_provider: "cloudflare", mode: "evaluation" },
"databricks/ai_decide": { litellm_provider: "databricks", mode: "evaluation" },
},
null,
);
expect(rows.map((row) => row.model)).toEqual([
"cloudflare/clef",
"databricks/ai_decide",
"cloudflare/@cf/cloudflare/clef",
]);
});
it("lists a chat model that answers on /v1/decisions and leaves other chat models out", () => {
const models = garden.map((row) => row.model);
expect(models).toContain("gpt-6-luna");
expect(models).not.toContain("gpt-5.5");
expect(models).not.toContain("perplexity/sonar-pro");
});
it("reads a model as answering on /v1/decisions when this proxy serves its provider, whatever endpoints its row lists", () => {
const rows = buildDecisionGarden(
{
"aaa/self-hosted": { litellm_provider: "aaa", mode: "evaluation", supported_endpoints: ["/v1/systemone"] },
"typesafe/jev-systemone": {
litellm_provider: "typesafe",
mode: "evaluation",
supported_endpoints: ["/v1/systemone"],
},
},
new Set(["typesafe"]),
);
expect(rows.map((row) => [row.model, row.route])).toEqual([
["typesafe/jev-systemone", "decisions"],
["aaa/self-hosted", "self-hosted"],
]);
});
it("marks a model from a provider this proxy cannot serve on /v1/decisions as needing a newer LiteLLM, last", () => {
const rows = buildDecisionGarden(
{
"aaa/self-hosted": { litellm_provider: "aaa", mode: "evaluation", supported_endpoints: ["/v1/systemone"] },
"newvendor/decider": { litellm_provider: "newvendor", mode: "evaluation" },
"typesafe/jev-latest": { litellm_provider: "typesafe", mode: "evaluation" },
},
new Set(["typesafe"]),
);
expect(rows.map((row) => [row.model, row.route])).toEqual([
["typesafe/jev-latest", "decisions"],
["aaa/self-hosted", "self-hosted"],
["newvendor/decider", "needs-upgrade"],
]);
});
it("is empty for a missing or malformed cost map", () => {
expect(buildDecisionGarden(undefined, null)).toEqual([]);
expect(buildDecisionGarden("not a map", null)).toEqual([]);
});
});
describe("parseProxyDecisionProviders", () => {
it("reads the decision providers this proxy serves, and nothing from a response without them", () => {
expect(parseProxyDecisionProviders({ decision_model_providers: ["typesafe", "cloudflare"] })).toEqual(
new Set(["typesafe", "cloudflare"]),
);
expect(parseProxyDecisionProviders({ supported_modes: ["pre_call"] })).toBeNull();
expect(parseProxyDecisionProviders(undefined)).toBeNull();
});
});
describe("buildDecisionCatalog", () => {
const catalog = buildDecisionCatalog(COST_MAP);
it("collects evaluation models and models that list a decision endpoint", () => {
expect([...catalog.models].sort()).toEqual(
[
"cloudflare/clef",
"gpt-6-luna",
"perplexity/pplx-decider-v1-27b",
"systemone-only",

View file

@ -2,6 +2,7 @@ import { z } from "zod";
export const DECISIONS_DOCS_URL = "https://docs.litellm.ai/docs/decisions";
export const DECISIONS_PROVIDERS_DOCS_URL = `${DECISIONS_DOCS_URL}#supported-providers`;
export const DECISIONS_SELF_HOSTED_DOCS_URL = `${DECISIONS_DOCS_URL}#self-hosted-laya-and-nimble`;
export const SYSTEM_ONE_PLAYGROUND_ROUTE = "playground?tab=system-one";
const DECISION_ENDPOINTS: ReadonlySet<string> = new Set(["/v1/decisions", "/v1/systemone"]);
@ -25,14 +26,18 @@ export interface DecisionCatalog {
export const EMPTY_DECISION_CATALOG: DecisionCatalog = { models: new Set(), providers: new Map() };
export const isDecisionMode = (mode: string | null | undefined): boolean =>
mode === "decisions" || mode === "evaluation";
export const DECISIONS_MODE = "decisions";
const DECISION_MODE_VALUES: readonly string[] = ["evaluation", DECISIONS_MODE];
export const isDecisionMode = (mode: string | null | undefined): boolean =>
mode !== null && mode !== undefined && DECISION_MODE_VALUES.includes(mode);
export const displayMode = (mode: string | null | undefined): string | undefined =>
isDecisionMode(mode) ? DECISIONS_MODE : mode ?? undefined;
export const expandModeFilterValues = (modes: readonly string[]): readonly string[] =>
modes.flatMap((mode) => (mode === DECISIONS_MODE ? DECISION_MODE_VALUES : [mode]));
const isDecisionEntry = (entry: CostMapEntry): boolean =>
isDecisionMode(entry.mode) || (entry.supported_endpoints ?? []).some((endpoint) => DECISION_ENDPOINTS.has(endpoint));
@ -60,6 +65,63 @@ export function buildDecisionCatalog(costMap: unknown): DecisionCatalog {
return { models: new Set(decisionEntries.map(([key]) => key)), providers };
}
export type DecisionRoute = "decisions" | "self-hosted" | "needs-upgrade";
const ROUTE_ORDER: Readonly<Record<DecisionRoute, number>> = { decisions: 0, "self-hosted": 1, "needs-upgrade": 2 };
const proxyDecisionProvidersSchema = z.object({ decision_model_providers: z.array(z.string()) });
export const parseProxyDecisionProviders = (settings: unknown): ReadonlySet<string> | null => {
const parsed = proxyDecisionProvidersSchema.safeParse(settings);
return parsed.success ? new Set(parsed.data.decision_model_providers) : null;
};
export interface DecisionGardenModel {
readonly model: string;
readonly provider: string;
readonly route: DecisionRoute;
}
const decisionRoute = (entry: CostMapEntry, proxyProviders: ReadonlySet<string> | null): DecisionRoute => {
if (proxyProviders?.has(entry.litellm_provider)) {
return "decisions";
}
if (!(entry.supported_endpoints ?? ["/v1/decisions"]).includes("/v1/decisions")) {
return "self-hosted";
}
return proxyProviders === null ? "decisions" : "needs-upgrade";
};
const shortestNameFirst = (a: DecisionGardenModel, b: DecisionGardenModel): number =>
a.model.length - b.model.length || a.model.localeCompare(b.model);
const rankedRow = (row: DecisionGardenModel, index: number, rows: readonly DecisionGardenModel[]) => ({
row,
routeOrder: ROUTE_ORDER[row.route],
rankInProvider: rows.slice(0, index).filter((earlier) => earlier.provider === row.provider).length,
});
export const buildDecisionGarden = (
costMap: unknown,
proxyProviders: ReadonlySet<string> | null,
): readonly DecisionGardenModel[] =>
parsedEntries(costMap)
.filter(([, entry]) => isDecisionEntry(entry))
.map(([key, entry]) => ({
model: key,
provider: entry.litellm_provider,
route: decisionRoute(entry, proxyProviders),
}))
.toSorted(shortestNameFirst)
.map(rankedRow)
.toSorted(
(a, b) =>
a.routeOrder - b.routeOrder ||
a.rankInProvider - b.rankInProvider ||
a.row.provider.localeCompare(b.row.provider),
)
.map(({ row }) => row);
export const isDecisionSelection = (
catalog: DecisionCatalog,
litellmProvider: string | undefined,