Private IP Ranges
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/page.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/page.integration.test.tsx
index 40773381587..2ef684a6ce7 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/memory/page.integration.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/page.integration.test.tsx
@@ -49,10 +49,4 @@ describe("Memory page access by role", () => {
await waitFor(() => expect(requestedUrls().filter((url) => url.includes("/v1/memory"))).toEqual([]));
},
);
-
- it("hides the deprecation banner along with the page body for a denied role", () => {
- renderAs("Internal User");
-
- expect(screen.queryByText(/draft deprecation list/i)).not.toBeInTheDocument();
- });
});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/page.tsx
index 7b1b6223372..1ba2ece9f84 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/memory/page.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/page.tsx
@@ -1,7 +1,6 @@
"use client";
import { MemoryView } from "./_components/MemoryView";
-import { DeprecationBanner } from "@/components/DeprecationBanner";
import { AdminOnlyNotice } from "@/components/shared/AdminOnlyNotice";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useCan from "@/app/(dashboard)/hooks/useCan";
@@ -14,10 +13,5 @@ export default function Memory() {
return ;
}
- return (
- <>
-
-
- >
- );
+ return ;
}
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/(dashboard)/playground/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/page.tsx
index bb5612e7367..fa5f76317de 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/playground/page.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/page.tsx
@@ -7,7 +7,6 @@ import CompareUI from "@/app/(dashboard)/playground/components/compareUI/Compare
import ComplianceUI from "@/app/(dashboard)/playground/components/complianceUI/ComplianceUI";
import SystemOneUI from "@/app/(dashboard)/playground/components/systemOneUI/SystemOneUI";
import BetaBadge from "@/components/BetaBadge";
-import { DeprecationBanner } from "@/components/DeprecationBanner";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { fetchProxySettings } from "@/utils/proxyUtils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@@ -100,7 +99,6 @@ export default function PlaygroundPage() {
-
-
-
- >
- );
+ return ;
}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/TelemetryEnterpriseDialog.tsx b/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/TelemetryEnterpriseDialog.tsx
new file mode 100644
index 00000000000..11b9fc0b302
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/TelemetryEnterpriseDialog.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+
+export function TelemetryEnterpriseDialog({
+ open,
+ onOpenChange,
+ premiumUser,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ premiumUser: boolean;
+}) {
+ return (
+ onOpenChange(next)}>
+
+
+
+ {premiumUser ? "Contact your enterprise representative" : "Telemetry is an enterprise-only feature"}
+
+
+ {premiumUser
+ ? "Telemetry can't be turned on from this page. Reach out to your LiteLLM enterprise representative to turn it on for this proxy."
+ : "Telemetry is only available on LiteLLM Enterprise, and this proxy isn't on an enterprise license."}
+
+
+
+
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/TelemetryEnvBanner.tsx b/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/TelemetryEnvBanner.tsx
new file mode 100644
index 00000000000..c94a1e30e5c
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/TelemetryEnvBanner.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import type { TelemetrySettings } from "@/app/(dashboard)/hooks/telemetry/useTelemetrySettings";
+import { useTelemetrySettings } from "@/app/(dashboard)/hooks/telemetry/useTelemetrySettings";
+
+export function TelemetryEnvNotice({ settings }: { settings: TelemetrySettings }) {
+ if (settings.environment_variables.length === 0) return null;
+ return (
+
+
+ {settings.vetoed
+ ? "Telemetry is turned off by an environment variable"
+ : "Telemetry environment variables are set"}
+
+
+ Set on this proxy: {settings.environment_variables.join(", ")}.{" "}
+ {settings.set_by_environment
+ ? "These override the settings on this page, which are read-only until they are removed."
+ : "The groups below can still be changed here."}
+
+
+ );
+}
+
+export default function TelemetryEnvBanner() {
+ const { data } = useTelemetrySettings();
+ if (data === undefined) return null;
+ return (
+
+
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/TelemetrySettingsPage.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/TelemetrySettingsPage.integration.test.tsx
new file mode 100644
index 00000000000..2a42cd4b1fd
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/TelemetrySettingsPage.integration.test.tsx
@@ -0,0 +1,151 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { NuqsTestingAdapter } from "nuqs/adapters/testing";
+import { backgroundFetchClient, fetchClient } from "@/lib/http/api";
+import type { TelemetrySettings } from "@/app/(dashboard)/hooks/telemetry/useTelemetrySettings";
+import TelemetrySettingsPage from "./TelemetrySettingsPage";
+
+vi.mock("@/lib/telemetry/uiEvents", async (original) => ({
+ ...(await original()),
+ recordUiEvent: vi.fn(() => Promise.resolve()),
+}));
+vi.mock("next/navigation", () => ({ usePathname: () => "/ui/telemetry" }));
+const auth = vi.hoisted(() => ({ premiumUser: false }));
+vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
+ default: () => ({ accessToken: "sk-test", userRole: "proxy_admin", premiumUser: auth.premiumUser }),
+}));
+
+const SETTINGS = {
+ destination: "local_table",
+ stored_destination: "local_table",
+ destination_set_by_environment: false,
+ available_destinations: ["local_table"],
+ editable: true,
+ environment_variables: [],
+ flush_interval_seconds: 300,
+ report: null,
+ report_is_sample: true,
+ retention_days: 30,
+ groups: [
+ { group: "heartbeat", enabled: true, requires: null },
+ { group: "request_success", enabled: true, requires: "heartbeat" },
+ { group: "page_navigation", enabled: false, requires: "heartbeat" },
+ ],
+} as unknown as TelemetrySettings;
+
+const OFF = {
+ ...SETTINGS,
+ groups: SETTINGS.groups.map((info) => ({ ...info, enabled: false })),
+} as TelemetrySettings;
+
+const renderPage = () =>
+ render(
+
+
+
+
+ ,
+ );
+
+describe("TelemetrySettingsPage", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ auth.premiumUser = false;
+ });
+
+ it("says nothing is recorded without a destination and gates picking one behind the enterprise modal", async () => {
+ const unchosen = { ...SETTINGS, destination: null, stored_destination: null };
+ vi.spyOn(backgroundFetchClient, "GET").mockResolvedValue({ data: unchosen, response: new Response() } as never);
+ const put = vi.spyOn(fetchClient, "PUT").mockReturnValue(new Promise(() => undefined) as never);
+ const user = userEvent.setup();
+ renderPage();
+
+ expect(
+ await screen.findByText("Telemetry is on but no destination is chosen, so nothing is recorded"),
+ ).toBeVisible();
+ expect(screen.getByRole("radio", { name: /^HTTP/ })).toHaveAttribute("aria-disabled", "true");
+ await user.click(screen.getByRole("radio", { name: /^Local table/ }));
+
+ expect(await screen.findByRole("dialog", { name: "Telemetry is an enterprise-only feature" })).toBeVisible();
+ expect(put).not.toHaveBeenCalled();
+ });
+
+ it.each([
+ [false, "Telemetry is an enterprise-only feature"],
+ [true, "Contact your enterprise representative"],
+ ])("never turns telemetry on from the top switch (enterprise: %s)", async (premiumUser, title) => {
+ auth.premiumUser = premiumUser;
+ vi.spyOn(backgroundFetchClient, "GET").mockResolvedValue({ data: OFF, response: new Response() } as never);
+ const put = vi.spyOn(fetchClient, "PUT").mockReturnValue(new Promise(() => undefined) as never);
+ const user = userEvent.setup();
+ renderPage();
+
+ const telemetry = await screen.findByRole("switch", { name: "Telemetry" });
+ expect(telemetry).not.toBeChecked();
+ expect(screen.getByRole("switch", { name: "Heartbeat" })).toHaveAttribute("aria-disabled", "true");
+ expect(screen.getByRole("radio", { name: /^Local table/ })).toHaveAttribute("aria-disabled", "true");
+ await user.click(telemetry);
+
+ expect(await screen.findByRole("dialog", { name: title })).toBeVisible();
+ expect(put).not.toHaveBeenCalled();
+ expect(telemetry).not.toBeChecked();
+ });
+
+ it("turns every group off from the top switch and keeps the saved destination", async () => {
+ vi.spyOn(backgroundFetchClient, "GET").mockResolvedValue({ data: SETTINGS, response: new Response() } as never);
+ const put = vi.spyOn(fetchClient, "PUT").mockReturnValue(new Promise(() => undefined) as never);
+ const user = userEvent.setup();
+ renderPage();
+
+ await user.click(await screen.findByRole("switch", { name: "Telemetry" }));
+
+ expect(put).toHaveBeenCalledWith("/telemetry/settings", { body: { groups: [], destination: "local_table" } });
+ });
+
+ it("gates turning another group on behind the enterprise modal while telemetry is on", async () => {
+ vi.spyOn(backgroundFetchClient, "GET").mockResolvedValue({ data: SETTINGS, response: new Response() } as never);
+ const put = vi.spyOn(fetchClient, "PUT").mockReturnValue(new Promise(() => undefined) as never);
+ const user = userEvent.setup();
+ renderPage();
+
+ await user.click(await screen.findByRole("tab", { name: "Admin UI" }));
+ await user.click(await screen.findByRole("switch", { name: "Page navigation" }));
+
+ expect(await screen.findByRole("dialog", { name: "Telemetry is an enterprise-only feature" })).toBeVisible();
+ expect(put).not.toHaveBeenCalled();
+ });
+
+ it("keeps the saved destination when a group is switched", async () => {
+ vi.spyOn(backgroundFetchClient, "GET").mockResolvedValue({ data: SETTINGS, response: new Response() } as never);
+ const put = vi.spyOn(fetchClient, "PUT").mockReturnValue(new Promise(() => undefined) as never);
+ const user = userEvent.setup();
+ renderPage();
+
+ await user.click(await screen.findByRole("switch", { name: "Request success" }));
+
+ expect(put).toHaveBeenCalledWith("/telemetry/settings", {
+ body: { groups: ["heartbeat"], destination: "local_table" },
+ });
+ });
+
+ it("locks the other tab's switches while a save from one tab is still in flight", async () => {
+ vi.spyOn(backgroundFetchClient, "GET").mockResolvedValue({ data: SETTINGS, response: new Response() } as never);
+ const put = vi.spyOn(fetchClient, "PUT").mockReturnValue(new Promise(() => undefined) as never);
+ const user = userEvent.setup();
+ render(
+
+
+
+
+ ,
+ );
+
+ await user.click(await screen.findByRole("switch", { name: "Heartbeat" }));
+ await user.click(screen.getByRole("tab", { name: "Admin UI" }));
+
+ expect(await screen.findByRole("switch", { name: "Page navigation" })).toHaveAttribute("aria-disabled", "true");
+ expect(put).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/TelemetrySettingsPage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/TelemetrySettingsPage.tsx
new file mode 100644
index 00000000000..b99bce385f3
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/TelemetrySettingsPage.tsx
@@ -0,0 +1,253 @@
+"use client";
+
+import { useState } from "react";
+import { Switch } from "@/components/ui/switch";
+import { FieldLabel } from "@/components/ui/field";
+import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
+import { Activity } from "lucide-react";
+import { Page, PageTabs, PageTabsContent, PageTabsList, PageTabsTrigger } from "@/components/shared/Page";
+import { PageHeader, PageHeaderControls, PageHeaderDescription, PageHeaderTitle } from "@/components/shared/PageHeader";
+import { useUrlTab } from "@/hooks/useUrlTab";
+import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
+import {
+ type TelemetryDestination,
+ type TelemetryGroup,
+ type TelemetrySettings,
+ useTelemetrySettings,
+ useUpdateTelemetrySettings,
+} from "@/app/(dashboard)/hooks/telemetry/useTelemetrySettings";
+import { GROUP_COPY, PROXY_GROUPS, UI_GROUPS, relativeDepths, toggleGroup, type Requires } from "./telemetryGroups";
+import { TelemetryEnterpriseDialog } from "./TelemetryEnterpriseDialog";
+
+const TABS = ["proxy", "ui"] as const;
+
+const destinationText = (settings: TelemetrySettings): string => {
+ const every = `every ${settings.flush_interval_seconds} s`;
+ switch (settings.destination) {
+ case "http":
+ return `One JSON report per worker ${every} and on shutdown, by HTTP POST to LITELLM_TELEMETRY_ENDPOINT. A failed send is retried next window`;
+ case "local_table":
+ return `One report per worker ${every} and on shutdown, stored in this proxy's LiteLLM_TelemetryReport table for ${settings.retention_days} days. Nothing leaves the proxy. Admins can export from GET /telemetry/reports`;
+ case null:
+ return "No destination is chosen, so nothing is recorded";
+ }
+};
+
+const DESTINATION_LABELS: Record = {
+ local_table: "Local table (LiteLLM_TelemetryReport in this proxy's database)",
+ http: "HTTP (POST to LITELLM_TELEMETRY_ENDPOINT)",
+};
+
+type SettingsUpdate = ReturnType;
+
+const enabledGroups = (settings: TelemetrySettings): Set =>
+ new Set(settings.groups.filter((info) => info.enabled).map((info) => info.group));
+
+const isOn = (settings: TelemetrySettings): boolean => enabledGroups(settings).size > 0;
+
+const savedDestination = (settings: TelemetrySettings): TelemetryDestination | null =>
+ settings.destination_set_by_environment ? null : settings.stored_destination ?? null;
+
+function DestinationPicker({
+ settings,
+ update,
+ requestEnable,
+}: {
+ settings: TelemetrySettings;
+ update: SettingsUpdate;
+ requestEnable: () => void;
+}) {
+ const unchangeable = !settings.editable || settings.destination_set_by_environment;
+ const locked = unchangeable || update.isPending || !isOn(settings);
+ const missing = settings.destination === null && enabledGroups(settings).size > 0;
+ return (
+
+ Destination
+ {missing && (
+ Telemetry is on but no destination is chosen, so nothing is recorded
+ )}
+
+ settings.destination === null
+ ? requestEnable()
+ : update.mutate({ groups: [...enabledGroups(settings)], destination: value as TelemetryDestination })
+ }
+ >
+ {(Object.keys(DESTINATION_LABELS) as TelemetryDestination[]).map((destination) => (
+
+
+ {DESTINATION_LABELS[destination]}
+
+ ))}
+
+ {settings.destination_set_by_environment && (
+ Set by LITELLM_TELEMETRY_DESTINATION
+ )}
+
+ );
+}
+
+function GroupList({
+ groups,
+ settings,
+ update,
+ requestEnable,
+}: {
+ groups: readonly TelemetryGroup[];
+ settings: TelemetrySettings;
+ update: SettingsUpdate;
+ requestEnable: () => void;
+}) {
+ const requires: Requires = new Map(settings.groups.map((info) => [info.group, info.requires ?? null]));
+ const enabled = enabledGroups(settings);
+ const depths = relativeDepths(groups, requires);
+ const onToggle = (group: TelemetryGroup, on: boolean) => {
+ if (on) {
+ requestEnable();
+ return;
+ }
+ update.mutate({ groups: [...toggleGroup(group, on, enabled, requires)], destination: savedDestination(settings) });
+ };
+ return (
+
+ {groups.map((group) => {
+ const copy = GROUP_COPY[group];
+ const locked = !settings.editable || update.isPending || !isOn(settings);
+ const depth = depths.get(group) ?? 0;
+ return (
+
+ onToggle(group, on)}
+ />
+
+
{copy.title}
+ {copy.slices !== undefined &&
Slices by: {copy.slices}
}
+
+ {copy.slices !== undefined ? "Counts" : "Sends"}: {copy.counts}
+
+
Helps catch: {copy.helps}
+
+
+ );
+ })}
+ {update.error !== null && {update.error.message} }
+
+ );
+}
+
+function TelemetrySwitch({
+ settings,
+ update,
+ requestEnable,
+}: {
+ settings: TelemetrySettings;
+ update: SettingsUpdate;
+ requestEnable: () => void;
+}) {
+ const on = isOn(settings);
+ return (
+
+
+ next ? requestEnable() : update.mutate({ groups: [], destination: savedDestination(settings) })
+ }
+ />
+ {on ? "On" : "Off"}
+
+ );
+}
+
+function ReportPreview({ settings }: { settings: TelemetrySettings }) {
+ return (
+
+
+ {settings.report_is_sample ? "Sample report" : "Last report sent from this worker"}
+
+
+ {settings.report_is_sample
+ ? "Made-up traffic through the real filter, with the groups that are on (all groups while telemetry is off)"
+ : "Exactly what this worker last sent or stored"}
+
+
+ {JSON.stringify(settings.report, null, 2)}
+
+
+ );
+}
+
+export default function TelemetrySettingsPage() {
+ const [tab, setTab] = useUrlTab(TABS, "proxy");
+ const { data: settings, isLoading, error } = useTelemetrySettings();
+ const update = useUpdateTelemetrySettings();
+ const { premiumUser } = useAuthorized();
+ const [enableDialogOpen, setEnableDialogOpen] = useState(false);
+ const requestEnable = () => setEnableDialogOpen(true);
+ if (isLoading) return Loading telemetry settings... ;
+ if (settings === undefined)
+ return Could not load telemetry settings: {error?.message} ;
+ return (
+
+ setTab(value as (typeof TABS)[number])}>
+
+
+
+ Telemetry
+
+
+ Choose what privacy-safe, aggregated usage data this proxy shares
+
+
+
+
+ Proxy requests
+ Admin UI
+
+
+
+
+
+
+
+
+
+ Sent only while Page navigation is on, to this proxy at POST /telemetry/ui_events, and counted into the same
+ report as proxy traffic
+
+
+
+
+
+ How and when it is sent
+ {destinationText(settings)}. Changes apply from the next window
+
+ Off by default. Each switch adds aggregated counts, never prompts, responses, keys, ids or header values.
+ LITELLM_TELEMETRY_DISABLED=true turns everything off
+
+
+
+
+
+ );
+}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/telemetryGroups.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/telemetryGroups.test.ts
new file mode 100644
index 00000000000..063e13b92e0
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/telemetryGroups.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it } from "vitest";
+import type { TelemetryGroup } from "@/app/(dashboard)/hooks/telemetry/useTelemetrySettings";
+import { depthOf, relativeDepths, toggleGroup, type Requires } from "./telemetryGroups";
+
+const REQUIRES: Requires = new Map([
+ ["heartbeat", null],
+ ["request_success", "heartbeat"],
+ ["token_info", "request_success"],
+ ["request_taxonomy", "request_success"],
+ ["event_details", "request_taxonomy"],
+ ["instance_configuration", "heartbeat"],
+ ["page_navigation", "heartbeat"],
+]);
+
+const ALL = new Set(REQUIRES.keys());
+
+describe("toggleGroup", () => {
+ it("turning heartbeat off turns every other group off", () => {
+ expect(toggleGroup("heartbeat", false, ALL, REQUIRES)).toEqual(new Set());
+ });
+
+ it("turning a middle group off keeps its parents and siblings and drops its descendants", () => {
+ expect(toggleGroup("request_taxonomy", false, ALL, REQUIRES)).toEqual(
+ new Set(["heartbeat", "request_success", "token_info", "instance_configuration", "page_navigation"]),
+ );
+ });
+
+ it("token info and request taxonomy are set independently of each other", () => {
+ const success = new Set(["heartbeat", "request_success"]);
+ expect(toggleGroup("request_taxonomy", true, success, REQUIRES)).toEqual(
+ new Set(["heartbeat", "request_success", "request_taxonomy"]),
+ );
+ expect(toggleGroup("token_info", false, ALL, REQUIRES)).toEqual(
+ new Set([...ALL].filter((g) => g !== "token_info")),
+ );
+ });
+
+ it("turning a nested group on also turns on every group it builds on", () => {
+ expect(toggleGroup("event_details", true, new Set(), REQUIRES)).toEqual(
+ new Set(["heartbeat", "request_success", "request_taxonomy", "event_details"]),
+ );
+ expect(toggleGroup("token_info", true, new Set(["heartbeat", "instance_configuration"]), REQUIRES)).toEqual(
+ new Set(["heartbeat", "instance_configuration", "request_success", "token_info"]),
+ );
+ });
+});
+
+describe("depthOf", () => {
+ it("counts the groups above each one so siblings share an indent", () => {
+ expect(
+ (["heartbeat", "request_success", "token_info", "request_taxonomy", "event_details"] as const).map((g) =>
+ depthOf(g, REQUIRES),
+ ),
+ ).toEqual([0, 1, 2, 2, 3]);
+ });
+});
+
+describe("relativeDepths", () => {
+ it("indents from the shallowest group shown, whatever its absolute depth", () => {
+ expect(relativeDepths(["page_navigation"], REQUIRES)).toEqual(new Map([["page_navigation", 0]]));
+ expect(relativeDepths(["request_taxonomy", "event_details", "token_info"], REQUIRES)).toEqual(
+ new Map([
+ ["request_taxonomy", 0],
+ ["event_details", 1],
+ ["token_info", 0],
+ ]),
+ );
+ });
+});
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/telemetryGroups.ts b/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/telemetryGroups.ts
new file mode 100644
index 00000000000..3ed450d8aaf
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/telemetry/_components/telemetryGroups.ts
@@ -0,0 +1,97 @@
+import type { TelemetryGroup } from "@/app/(dashboard)/hooks/telemetry/useTelemetrySettings";
+
+export interface GroupCopy {
+ readonly title: string;
+ readonly slices?: string;
+ readonly counts: string;
+ readonly helps: string;
+}
+
+export const PROXY_GROUPS: readonly TelemetryGroup[] = [
+ "heartbeat",
+ "request_success",
+ "token_info",
+ "request_taxonomy",
+ "event_details",
+ "instance_configuration",
+];
+
+export const UI_GROUPS: readonly TelemetryGroup[] = ["page_navigation"];
+
+export const GROUP_COPY: Readonly> = {
+ heartbeat: {
+ title: "Heartbeat",
+ counts: "Random instance id, LiteLLM version, report window, which groups are on",
+ helps: "Which versions are running",
+ },
+ request_success: {
+ title: "Request success",
+ slices: "endpoint, LiteLLM status, provider status, stream, handled by Rust, LiteLLM cache hit",
+ counts: "requests, provider attempts, latency to headers and to first byte",
+ helps: "Error-rate and latency regressions, Rust vs Python",
+ },
+ token_info: {
+ title: "Token info",
+ slices: "provider prompt-cache hit",
+ counts: "input, output and cache-read token sums",
+ helps: "Broken prompt caching or token counting",
+ },
+ request_taxonomy: {
+ title: "Request taxonomy",
+ slices: "provider, salted deployment hash",
+ counts: "one row per provider attempt with its status",
+ helps: "Which provider is failing, retries and fallbacks",
+ },
+ event_details: {
+ title: "Event details",
+ counts: "message block counts and types, allowlisted header names (no values), provider time to first token",
+ helps: "Which request shapes and clients fail",
+ },
+ instance_configuration: {
+ title: "Instance configuration",
+ counts: "names of allowlisted config keys that are set (no values)",
+ helps: "Which features an upgrade must keep working",
+ },
+ page_navigation: {
+ title: "Page navigation",
+ counts: "Admin UI page views and tab switches by route name (no ids or typed text)",
+ helps: "Which pages and tabs get used",
+ },
+};
+
+export type Requires = ReadonlyMap;
+
+const dependents = (group: TelemetryGroup, requires: Requires): readonly TelemetryGroup[] =>
+ [...requires.entries()]
+ .filter(([, parent]) => parent === group)
+ .flatMap(([child]) => [child, ...dependents(child, requires)]);
+
+export const depthOf = (group: TelemetryGroup, requires: Requires): number => {
+ const parent = requires.get(group) ?? null;
+ return parent === null ? 0 : 1 + depthOf(parent, requires);
+};
+
+export const relativeDepths = (
+ groups: readonly TelemetryGroup[],
+ requires: Requires,
+): ReadonlyMap => {
+ const depths = groups.map((group) => [group, depthOf(group, requires)] as const);
+ const shallowest = Math.min(...depths.map(([, depth]) => depth));
+ return new Map(depths.map(([group, depth]) => [group, depth - shallowest]));
+};
+
+const withAncestors = (group: TelemetryGroup, requires: Requires): readonly TelemetryGroup[] => {
+ const parent = requires.get(group) ?? null;
+ return parent === null ? [group] : [group, ...withAncestors(parent, requires)];
+};
+
+export const toggleGroup = (
+ group: TelemetryGroup,
+ on: boolean,
+ enabled: ReadonlySet,
+ requires: Requires,
+): ReadonlySet => {
+ if (on) return new Set([...enabled, ...withAncestors(group, requires)]);
+ const removed = new Set([group, ...dependents(group, requires)]);
+ return new Set([...enabled].filter((g) => !removed.has(g)));
+};
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/telemetry/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/telemetry/page.tsx
new file mode 100644
index 00000000000..0ca7bee9388
--- /dev/null
+++ b/ui/litellm-dashboard/src/app/(dashboard)/telemetry/page.tsx
@@ -0,0 +1,7 @@
+"use client";
+
+import TelemetrySettingsPage from "./_components/TelemetrySettingsPage";
+
+export default function Telemetry() {
+ return ;
+}
diff --git a/ui/litellm-dashboard/src/app/(dashboard)/workflows/page.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/workflows/page.integration.test.tsx
index 5281ba626e1..6aa7119b034 100644
--- a/ui/litellm-dashboard/src/app/(dashboard)/workflows/page.integration.test.tsx
+++ b/ui/litellm-dashboard/src/app/(dashboard)/workflows/page.integration.test.tsx
@@ -50,9 +50,9 @@ describe("Workflows page access by role", () => {
},
);
- it("hides the deprecation banner along with the page body for a denied role", () => {
- renderAs("Internal User");
+ it("shows an admin the deprecation notice", () => {
+ renderAs("Admin");
- expect(screen.queryByText(/draft deprecation list/i)).not.toBeInTheDocument();
+ expect(screen.getByRole("alert")).toHaveTextContent("Workflows");
});
});
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}
+
+
+
+ );
+}
+
+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.`}
+
+
+
}
+ >
+
+ Decisions docs
+
+
+
+ {visibleRows.map((row) => (
+
+ ))}
+
+ {rows.length > PREVIEW_COUNT && (
+
+ setShowAll((value) => !value)}>
+ {showAll ? "Show fewer" : `Show all ${rows.length}`}
+
+
+ )}
+
+ );
+};
+
+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/DashboardHeader.integration.test.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx
index cb58670c20a..d899d032395 100644
--- a/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/DashboardHeader.integration.test.tsx
@@ -59,7 +59,7 @@ async function openTools() {
-
+
,
diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx
index 6c64d09009c..41d35bd40b7 100644
--- a/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx
+++ b/ui/litellm-dashboard/src/components/DashboardHeader.test.tsx
@@ -35,7 +35,7 @@ vi.mock("@/components/liteadmin/LiteAdmin", () => ({ default: () =>
Lite
const renderDashboardHeader = () =>
render(
-
+
,
);
@@ -133,7 +133,7 @@ describe("DashboardHeader breadcrumb", () => {
state.isDesktop = true;
rerender(
-
+
,
);
expect(screen.queryByRole("dialog", { name: "Gateway tools" })).not.toBeInTheDocument();
@@ -141,7 +141,7 @@ describe("DashboardHeader breadcrumb", () => {
state.isDesktop = false;
rerender(
-
+
,
);
expect(screen.queryByRole("dialog", { name: "Gateway tools" })).not.toBeInTheDocument();
diff --git a/ui/litellm-dashboard/src/components/DashboardHeader.tsx b/ui/litellm-dashboard/src/components/DashboardHeader.tsx
index 7580f8b69c0..77e44a2ff9b 100644
--- a/ui/litellm-dashboard/src/components/DashboardHeader.tsx
+++ b/ui/litellm-dashboard/src/components/DashboardHeader.tsx
@@ -31,7 +31,13 @@ import { CommandPaletteTrigger } from "@/components/CommandPalette/CommandPalett
// Top bar for the dashboard shell. Sits only over the content column (the brand
// lives in the sidebar header); mirrors the design's breadcrumb-left / tools-right layout.
-export function DashboardHeader({ navigationTrigger }: { navigationTrigger?: ReactNode }) {
+export function DashboardHeader({
+ accessToken,
+ navigationTrigger,
+}: {
+ accessToken: string | null;
+ navigationTrigger?: ReactNode;
+}) {
const { title } = getBreadcrumb(usePathname());
const isDesktop = useMediaQuery("(min-width: 768px)", { initializeWithValue: false });
const [mobileToolsOpen, setMobileToolsOpen] = useState(false);
@@ -78,11 +84,11 @@ export function DashboardHeader({ navigationTrigger }: { navigationTrigger?: Rea
{!hideCommunityLinks && }
-
+
-
+
}>
diff --git a/ui/litellm-dashboard/src/components/DeprecationBanner.test.tsx b/ui/litellm-dashboard/src/components/DeprecationBanner.test.tsx
index 596ad2a1626..ead6785bfd4 100644
--- a/ui/litellm-dashboard/src/components/DeprecationBanner.test.tsx
+++ b/ui/litellm-dashboard/src/components/DeprecationBanner.test.tsx
@@ -1,44 +1,20 @@
import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { DeprecationBanner } from "./DeprecationBanner";
describe("DeprecationBanner", () => {
- it("names the deprecated feature in the heading and the body", () => {
- render( );
+ it("names the deprecated feature", () => {
+ render( );
- expect(screen.getByText("Memory is on a draft deprecation list")).toBeInTheDocument();
- expect(screen.getByText(/Memory is one of several experimental features/)).toBeInTheDocument();
- });
-
- it("states the target removal date and that the list is not final", () => {
- render( );
-
- expect(screen.getByText(/as early as September 1, 2026/)).toBeInTheDocument();
- expect(screen.getByText(/This list is a draft and is not final/)).toBeInTheDocument();
+ expect(screen.getByRole("alert")).toHaveTextContent("Workflows");
});
it("links to the deprecation discussion in a new tab without leaking the opener", () => {
- render( );
+ render( );
const link = screen.getByRole("link", { name: "deprecation discussion" });
expect(link).toHaveAttribute("href", "https://github.com/BerriAI/litellm/discussions/32090");
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
-
- it("exposes a named close control", () => {
- render( );
-
- expect(screen.getByRole("button", { name: /close/i })).toBeInTheDocument();
- });
-
- it("hides the banner once the close control is used", async () => {
- const user = userEvent.setup();
- render( );
-
- await user.click(screen.getByRole("button", { name: /close/i }));
-
- expect(screen.queryByText("Memory is on a draft deprecation list")).not.toBeInTheDocument();
- });
});
diff --git a/ui/litellm-dashboard/src/components/DeprecationBanner.tsx b/ui/litellm-dashboard/src/components/DeprecationBanner.tsx
index 9df34636f82..75541e4087c 100644
--- a/ui/litellm-dashboard/src/components/DeprecationBanner.tsx
+++ b/ui/litellm-dashboard/src/components/DeprecationBanner.tsx
@@ -1,52 +1,26 @@
"use client";
-import React, { useState } from "react";
+import React from "react";
import Link from "next/link";
-import { Info, X } from "lucide-react";
+import { Info } from "lucide-react";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
const DEPRECATION_DISCUSSION_URL = "https://github.com/BerriAI/litellm/discussions/32090";
-const DEPRECATION_TARGET_DATE = "September 1, 2026";
interface DeprecationBannerProps {
featureName: string;
}
-export const DeprecationBanner: React.FC = ({ featureName }) => {
- const [isClosed, setIsClosed] = useState(false);
-
- if (isClosed) {
- return null;
- }
-
- return (
-
-
-
-
{`${featureName} is on a draft deprecation list`}
-
- {`${featureName} is one of several experimental features we're considering removing, potentially as early as ${DEPRECATION_TARGET_DATE}. This list is a draft and is not final. If you rely on this feature, please share feedback on the `}
-
- deprecation discussion
-
- .
-
-
-
setIsClosed(true)}
- className="shrink-0 rounded-md p-0.5 text-muted-foreground transition-colors hover:text-foreground"
- >
-
-
-
- );
-};
+export const DeprecationBanner: React.FC = ({ featureName }) => (
+
+
+ {`${featureName} is deprecated`}
+
+ {`${featureName} will be removed in the near future. See the `}
+
+ deprecation discussion
+
+ {" for details."}
+
+
+);
diff --git a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.test.tsx b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.test.tsx
index 8894f2a714b..e0c428a94a0 100644
--- a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.test.tsx
+++ b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.test.tsx
@@ -1,69 +1,175 @@
import { renderWithProviders, screen } from "../../../../tests/test-utils";
-import { NotificationsBell, AUTO_ROUTER_DOCS_URL } from "./NotificationsBell";
+import { AUTO_ROUTER_DOCS_URL, NotificationsBell, NotificationsBellView } from "./NotificationsBell";
+import type { LatestReleaseInfo } from "@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo";
import React from "react";
import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+vi.mock("@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails", () => ({
+ useHealthReadinessDetails: vi.fn(),
+}));
+vi.mock("@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo", () => ({
+ useLatestReleaseInfo: vi.fn(),
+}));
+
+import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
+import { useLatestReleaseInfo } from "@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo";
+
+const RELEASE: LatestReleaseInfo = {
+ version: "1.103.0",
+ new_features: 12,
+ bug_fixes: 30,
+ other_updates: 8,
+ release_url: "https://github.com/BerriAI/litellm/releases/tag/v1.103.0",
+};
+
+const bell = (unread: number) =>
+ screen.getByRole("button", { name: unread === 0 ? /^notifications$/i : `Notifications, ${unread} unread` });
+
+const announcement = (title: string) => screen.getByRole("article", { name: title });
+
+describe("NotificationsBellView", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ localStorage.clear();
+ });
+
+ it("lists the new version notice above Auto Router when the proxy is behind the latest release", async () => {
+ const user = userEvent.setup();
+ renderWithProviders( );
+ await user.click(bell(2));
+
+ const titles = screen.getAllByRole("article").map((item) => item.getAttribute("aria-label"));
+ expect(titles).toEqual(["LiteLLM v1.103.0 is available", "LiteLLM Auto Router"]);
+ const upgrade = announcement("LiteLLM v1.103.0 is available");
+ expect(upgrade).toHaveTextContent("12 new features, 30 fixes, and 8 other updates");
+ expect(upgrade).toHaveTextContent("Your current version is v1.102.0");
+ const notes = screen.getByRole("link", { name: /^view release notes$/i });
+ expect(notes).toHaveAttribute("href", RELEASE.release_url);
+ expect(notes).toHaveAttribute("rel", "noopener noreferrer");
+ const docs = screen.getByRole("link", { name: /^read the docs$/i });
+ expect(docs).toHaveAttribute("href", AUTO_ROUTER_DOCS_URL);
+ expect(docs).toHaveAttribute("target", "_blank");
+ });
+
+ it("uses singular wording for counts of one", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(
+ ,
+ );
+ await user.click(bell(2));
+ expect(announcement("LiteLLM v1.103.0 is available")).toHaveTextContent("1 new feature, 1 fix, and 1 other update");
+ });
+
+ it.each([
+ ["the running version is unknown", undefined, RELEASE],
+ ["the latest release is unavailable", "1.102.0", null],
+ ["the proxy is up to date", "1.103.0", RELEASE],
+ ["the proxy is ahead of the latest release", "1.104.0-dev.1", RELEASE],
+ ])("lists only Auto Router when %s", async (_case, currentVersion, latestRelease) => {
+ const user = userEvent.setup();
+ renderWithProviders( );
+ await user.click(bell(1));
+ expect(screen.getAllByRole("article").map((item) => item.getAttribute("aria-label"))).toEqual([
+ "LiteLLM Auto Router",
+ ]);
+ });
+
+ it("marks each announcement read on its own and clears the unread dot once all are read", async () => {
+ const user = userEvent.setup();
+ renderWithProviders( );
+ await user.click(bell(2));
+ await user.click(screen.getAllByRole("button", { name: /^mark as read$/i })[0]);
+
+ expect(bell(1).querySelector("[data-slot=badge]")).not.toBeNull();
+ expect(screen.getAllByRole("button", { name: /^mark as read$/i })).toHaveLength(1);
+ expect(announcement("LiteLLM Auto Router")).toContainElement(
+ screen.getByRole("button", { name: /^mark as read$/i }),
+ );
+ await user.click(screen.getByRole("button", { name: /^mark as read$/i }));
+
+ expect(bell(0).querySelector("[data-slot=badge]")).toBeNull();
+ expect(screen.getAllByRole("article")).toHaveLength(2);
+ expect(screen.queryByRole("button", { name: /^mark as read$/i })).not.toBeInTheDocument();
+ });
+
+ it("keeps the new version notice read across remounts for the same release", async () => {
+ const user = userEvent.setup();
+ const first = renderWithProviders( );
+ await user.click(bell(2));
+ await user.click(screen.getAllByRole("button", { name: /^mark as read$/i })[0]);
+ first.unmount();
+
+ renderWithProviders( );
+ expect(bell(1)).toBeInTheDocument();
+ });
+
+ it("shows a newer release as unread after an older release was read", async () => {
+ const user = userEvent.setup();
+ const { rerender } = renderWithProviders(
+ ,
+ );
+ await user.click(bell(2));
+ await user.click(screen.getAllByRole("button", { name: /^mark as read$/i })[0]);
+ expect(bell(1)).toBeInTheDocument();
+
+ rerender( );
+ expect(bell(2)).toBeInTheDocument();
+ expect(announcement("LiteLLM v1.104.0 is available")).toContainElement(
+ screen.getAllByRole("button", { name: /^mark as read$/i })[0],
+ );
+ });
+
+ it("treats a release whose upgrade banner was dismissed as already read", () => {
+ localStorage.setItem("litellm:upgradeBannerDismissed:1.103.0", "true");
+ renderWithProviders( );
+ expect(bell(1)).toBeInTheDocument();
+ });
+
+ it("keeps Auto Router read when it was read before", async () => {
+ localStorage.setItem("litellmHideAutoRouterAnnouncement", "true");
+ const user = userEvent.setup();
+ renderWithProviders( );
+ await user.click(bell(0));
+ expect(announcement("LiteLLM Auto Router")).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /^mark as read$/i })).not.toBeInTheDocument();
+ });
+
+ it("syncs sibling bells when one marks an announcement read", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(
+ <>
+
+
+ >,
+ );
+ const [bellA] = screen.getAllByRole("button", { name: "Notifications, 2 unread" });
+ await user.click(bellA);
+ await user.click(screen.getAllByRole("button", { name: /^mark as read$/i })[0]);
+
+ expect(screen.getAllByRole("button", { name: "Notifications, 1 unread" })).toHaveLength(2);
+ });
+});
describe("NotificationsBell", () => {
beforeEach(() => {
localStorage.clear();
});
- it("should open notifications with Auto Router details and docs link", async () => {
+ it("builds the new version notice from the proxy version and the latest release", async () => {
+ vi.mocked(useHealthReadinessDetails).mockReturnValue({
+ data: { status: "healthy", litellm_version: "1.102.0" },
+ } as ReturnType);
+ vi.mocked(useLatestReleaseInfo).mockReturnValue({ data: RELEASE } as ReturnType);
const user = userEvent.setup();
- renderWithProviders( );
- await user.click(screen.getByRole("button", { name: /^notifications$/i }));
- expect(screen.getByText(/^LiteLLM Auto Router$/i)).toBeInTheDocument();
- const docsBtn = screen.getByRole("link", { name: /^read the docs$/i });
- expect(docsBtn).toHaveAttribute("href", AUTO_ROUTER_DOCS_URL);
- expect(docsBtn).toHaveAttribute("target", "_blank");
- expect(docsBtn).toHaveAttribute("rel", "noopener noreferrer");
- });
-
- it("should offer mark as read when announcement is unread", async () => {
- const user = userEvent.setup();
- renderWithProviders( );
- await user.click(screen.getByRole("button", { name: /^notifications$/i }));
- expect(screen.getByRole("button", { name: /^mark as read$/i })).toBeInTheDocument();
- });
-
- it("should hide mark as read and persist after marking read", async () => {
- const user = userEvent.setup();
- renderWithProviders( );
- await user.click(screen.getByRole("button", { name: /^notifications$/i }));
- await user.click(screen.getByRole("button", { name: /^mark as read$/i }));
- expect(localStorage.getItem("litellmHideAutoRouterAnnouncement")).toBe("true");
- await user.click(screen.getByRole("button", { name: /^notifications$/i }));
- expect(screen.queryByRole("button", { name: /^mark as read$/i })).not.toBeInTheDocument();
- });
-
- it("should not show mark as read when previously dismissed", async () => {
- localStorage.setItem("litellmHideAutoRouterAnnouncement", "true");
- const user = userEvent.setup();
- renderWithProviders( );
- await user.click(screen.getByRole("button", { name: /^notifications$/i }));
- expect(screen.queryByRole("button", { name: /^mark as read$/i })).not.toBeInTheDocument();
- });
-
- it("should sync sibling instances when one is dismissed", async () => {
- const user = userEvent.setup();
- renderWithProviders(
- <>
-
-
-
-
-
-
- >,
- );
-
- // Both bells start unread → both render the "Mark as read" affordance once opened.
- const [bellA, bellB] = screen.getAllByRole("button", { name: /^notifications$/i });
- await user.click(bellA);
- await user.click(screen.getByRole("button", { name: /^mark as read$/i }));
-
- // Dismissing in bell A must also clear bell B without a remount.
- await user.click(bellB);
- expect(screen.queryByRole("button", { name: /^mark as read$/i })).not.toBeInTheDocument();
+ renderWithProviders( );
+ await user.click(bell(2));
+ expect(announcement("LiteLLM v1.103.0 is available")).toHaveTextContent("Your current version is v1.102.0");
});
});
diff --git a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx
index 8b9b7d1d0d2..4cb9f851153 100644
--- a/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx
+++ b/ui/litellm-dashboard/src/components/Navbar/NotificationsBell/NotificationsBell.tsx
@@ -1,66 +1,128 @@
"use client";
+import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
import {
- HIDE_AUTO_ROUTER_ANNOUNCEMENT_KEY,
- useHideAutoRouterAnnouncement,
-} from "@/app/(dashboard)/hooks/useHideAutoRouterAnnouncement";
-import { emitLocalStorageChange, setLocalStorageItem } from "@/utils/localStorageUtils";
+ type LatestReleaseInfo,
+ useLatestReleaseInfo,
+} from "@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo";
+import {
+ markAnnouncementRead,
+ useAnnouncementRead,
+ useUnreadAnnouncementCount,
+} from "@/app/(dashboard)/hooks/useAnnouncementRead";
import { Badge } from "@/components/ui/badge";
import { Button, buttonVariants } from "@/components/ui/button";
-import { Popover, PopoverContent, PopoverDescription, PopoverTitle, PopoverTrigger } from "@/components/ui/popover";
+import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
+import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/cva.config";
+import { isNewerVersion } from "@/utils/versionUtils";
import { Bell } from "lucide-react";
-import React, { useState } from "react";
+import React, { Fragment } from "react";
export const AUTO_ROUTER_DOCS_URL = "https://docs.litellm.ai/docs/proxy/auto_routing";
-export const NotificationsBell: React.FC = () => {
- const hidden = useHideAutoRouterAnnouncement();
- const hasUnread = !hidden;
- const [open, setOpen] = useState(false);
+interface Announcement {
+ readKey: string;
+ title: string;
+ description: string;
+ linkLabel: string;
+ href: string;
+}
- const markDismissed = () => {
- setLocalStorageItem(HIDE_AUTO_ROUTER_ANNOUNCEMENT_KEY, "true");
- emitLocalStorageChange(HIDE_AUTO_ROUTER_ANNOUNCEMENT_KEY);
- setOpen(false);
+const AUTO_ROUTER_ANNOUNCEMENT: Announcement = {
+ readKey: "litellmHideAutoRouterAnnouncement",
+ title: "LiteLLM Auto Router",
+ description: "Route every request to the cheapest model that can handle it, no prompt changes needed.",
+ linkLabel: "Read the docs",
+ href: AUTO_ROUTER_DOCS_URL,
+};
+
+const plural = (count: number, singular: string, pluralForm: string): string =>
+ `${count} ${count === 1 ? singular : pluralForm}`;
+
+const describeRelease = ({ new_features, bug_fixes, other_updates }: LatestReleaseInfo): string =>
+ [
+ plural(new_features, "new feature", "new features"),
+ plural(bug_fixes, "fix", "fixes"),
+ `and ${plural(other_updates, "other update", "other updates")}`,
+ ].join(", ");
+
+const upgradeAnnouncement = (
+ currentVersion: string | null | undefined,
+ latestRelease: LatestReleaseInfo | null | undefined,
+): Announcement | null => {
+ if (!currentVersion || !latestRelease || !isNewerVersion(currentVersion, latestRelease.version)) {
+ return null;
+ }
+ return {
+ readKey: `litellm:upgradeBannerDismissed:${latestRelease.version}`,
+ title: `LiteLLM v${latestRelease.version} is available`,
+ description: `${describeRelease(latestRelease)}. Your current version is v${currentVersion}`,
+ linkLabel: "View release notes",
+ href: latestRelease.release_url,
};
+};
- const content = (
-
-
LiteLLM Auto Router
-
- Route every request to the cheapest model that can handle it, no prompt changes needed.
-
+const AnnouncementItem: React.FC<{ announcement: Announcement }> = ({ announcement }) => {
+ const read = useAnnouncementRead(announcement.readKey);
+ return (
+
+ {announcement.title}
+ {announcement.description}
-
+
);
+};
+
+interface NotificationsBellViewProps {
+ currentVersion: string | null | undefined;
+ latestRelease: LatestReleaseInfo | null | undefined;
+}
+
+export const NotificationsBellView: React.FC = ({ currentVersion, latestRelease }) => {
+ const upgrade = upgradeAnnouncement(currentVersion, latestRelease);
+ const announcements = upgrade ? [upgrade, AUTO_ROUTER_ANNOUNCEMENT] : [AUTO_ROUTER_ANNOUNCEMENT];
+ const unreadCount = useUnreadAnnouncementCount(announcements.map((announcement) => announcement.readKey));
return (
-
+
0 ? `Notifications, ${unreadCount} unread` : "Notifications"}
>
- {hasUnread ? : null}
+ {unreadCount > 0 ? : null}
- {content}
+
+ {announcements.map((announcement, index) => (
+
+ {index > 0 ? : null}
+
+
+ ))}
+
);
};
+
+export const NotificationsBell: React.FC<{ accessToken: string | null }> = ({ accessToken }) => {
+ const { data: healthData } = useHealthReadinessDetails(accessToken);
+ const { data: latestRelease } = useLatestReleaseInfo(accessToken);
+ return ;
+};
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/ThemeToggle/ThemeToggle.test.tsx b/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.test.tsx
index c5dda3d3f7c..8cc98e986db 100644
--- a/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.test.tsx
+++ b/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.test.tsx
@@ -45,7 +45,7 @@ describe("ThemeToggle", () => {
it("names the mode the click will switch to, so the button says what it does", async () => {
renderToggle();
- expect(screen.getByRole("button", { name: "Switch to dark mode (beta)" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Switch to dark mode" })).toBeInTheDocument();
await userEvent.click(toggle());
@@ -59,13 +59,4 @@ describe("ThemeToggle", () => {
expect(localStorage.getItem("theme")).toBe("system");
});
-
- it("keeps the beta marker out of the toolbar label once dark is on", async () => {
- renderToggle();
-
- await userEvent.click(toggle());
-
- expect(toggle()).not.toHaveTextContent("Beta");
- expect(toggle()).toHaveAccessibleName("Switch to light mode");
- });
});
diff --git a/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.tsx b/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.tsx
index 2f941dbc2d2..eec764c4cd8 100644
--- a/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.tsx
+++ b/ui/litellm-dashboard/src/components/ThemeToggle/ThemeToggle.tsx
@@ -9,7 +9,7 @@ import { Button } from "@/components/ui/button";
const ThemeToggle: React.FC = () => {
const { setTheme, resolvedTheme } = useTheme();
const isDark = resolvedTheme === "dark";
- const label = isDark ? "Switch to light mode" : "Switch to dark mode (beta)";
+ const label = isDark ? "Switch to light mode" : "Switch to dark mode";
return (
({
- useHealthReadinessDetails: vi.fn(),
-}));
-vi.mock("@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo", () => ({
- useLatestReleaseInfo: vi.fn(),
-}));
-
-import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
-import { useLatestReleaseInfo } from "@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo";
-
-const RELEASE: LatestReleaseInfo = {
- version: "1.103.0",
- new_features: 12,
- bug_fixes: 30,
- other_updates: 8,
- release_url: "https://github.com/BerriAI/litellm/releases/tag/v1.103.0",
-};
-
-describe("describeRelease", () => {
- it("lists features, fixes, and other updates in the agreed order", () => {
- expect(describeRelease(RELEASE)).toBe("12 new features, 30 fixes, and 8 other updates");
- });
-
- it("singularises counts of one", () => {
- const singularCounts = { ...RELEASE, new_features: 1, bug_fixes: 1, other_updates: 1 };
- expect(describeRelease(singularCounts)).toBe("1 new feature, 1 fix, and 1 other update");
- });
-});
-
-describe("UpgradeBannerView", () => {
- beforeEach(() => {
- localStorage.clear();
- });
-
- afterEach(() => {
- localStorage.clear();
- });
-
- it("renders nothing while either version is unknown", () => {
- const { container } = render( );
- expect(container).toBeEmptyDOMElement();
- const { container: noRelease } = render( );
- expect(noRelease).toBeEmptyDOMElement();
- });
-
- it("renders nothing when the running version is up to date or ahead", () => {
- const { container } = render( );
- expect(container).toBeEmptyDOMElement();
- const { container: ahead } = render( );
- expect(ahead).toBeEmptyDOMElement();
- });
-
- it("shows the latest version, the stat line, and the current version when behind", () => {
- render( );
- const alert = screen.getByRole("status");
- expect(alert).toHaveTextContent("The latest version is v1.103.0: 12 new features, 30 fixes, and 8 other updates");
- expect(alert).toHaveTextContent("Your current version is v1.102.0");
- expect(screen.getByRole("link", { name: "v1.103.0" })).toHaveAttribute("href", RELEASE.release_url);
- });
-
- it("dismissing hides the banner and keeps it hidden on remount for the same release", () => {
- const { unmount } = render( );
- fireEvent.click(screen.getByRole("button", { name: "Close" }));
- expect(screen.queryByRole("status")).not.toBeInTheDocument();
- unmount();
-
- const { container } = render( );
- expect(container).toBeEmptyDOMElement();
- });
-
- it("reappears once a newer release ships after a dismissal", () => {
- const { unmount } = render( );
- fireEvent.click(screen.getByRole("button", { name: "Close" }));
- unmount();
-
- render( );
- expect(screen.getByRole("status")).toHaveTextContent("The latest version is v1.104.0");
- });
-
- it("shows a newer release after the current one was dismissed without remounting", () => {
- const { rerender } = render( );
- fireEvent.click(screen.getByRole("button", { name: "Close" }));
- expect(screen.queryByRole("status")).not.toBeInTheDocument();
- rerender( );
- expect(screen.getByRole("status")).toHaveTextContent("The latest version is v1.104.0");
- });
-});
-
-describe("UpgradeBanner", () => {
- beforeEach(() => {
- localStorage.clear();
- });
-
- afterEach(() => {
- localStorage.clear();
- });
-
- it("feeds both hooks the access token and renders from their data", () => {
- const healthReadinessResult = {
- data: { litellm_version: "1.102.0" },
- } as Partial> as ReturnType;
- const latestReleaseResult = { data: RELEASE } as Partial> as ReturnType<
- typeof useLatestReleaseInfo
- >;
- vi.mocked(useHealthReadinessDetails).mockReturnValue(healthReadinessResult);
- vi.mocked(useLatestReleaseInfo).mockReturnValue(latestReleaseResult);
- render( );
- expect(useHealthReadinessDetails).toHaveBeenCalledWith("token");
- expect(useLatestReleaseInfo).toHaveBeenCalledWith("token");
- expect(screen.getByRole("status")).toHaveTextContent("The latest version is v1.103.0");
- });
-
- it("renders nothing when the release endpoint returns null", () => {
- const healthReadinessResult = {
- data: { litellm_version: "1.102.0" },
- } as Partial> as ReturnType;
- const latestReleaseResult = { data: null } as Partial> as ReturnType<
- typeof useLatestReleaseInfo
- >;
- vi.mocked(useHealthReadinessDetails).mockReturnValue(healthReadinessResult);
- vi.mocked(useLatestReleaseInfo).mockReturnValue(latestReleaseResult);
- const { container } = render( );
- expect(container).toBeEmptyDOMElement();
- });
-});
diff --git a/ui/litellm-dashboard/src/components/UpgradeBanner.tsx b/ui/litellm-dashboard/src/components/UpgradeBanner.tsx
deleted file mode 100644
index 101a6e3410d..00000000000
--- a/ui/litellm-dashboard/src/components/UpgradeBanner.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-"use client";
-
-import React, { useState } from "react";
-import { ArrowUpCircle, X } from "lucide-react";
-import { Alert, AlertAction, AlertDescription, AlertTitle } from "@/components/shared/Alert";
-import { Button } from "@/components/ui/button";
-import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails";
-import {
- type LatestReleaseInfo,
- useLatestReleaseInfo,
-} from "@/app/(dashboard)/hooks/latestRelease/useLatestReleaseInfo";
-import { getLocalStorageItem, setLocalStorageItem } from "@/utils/localStorageUtils";
-import { isNewerVersion } from "@/utils/versionUtils";
-
-const DISMISS_KEY_PREFIX = "litellm:upgradeBannerDismissed:";
-
-interface UpgradeBannerProps {
- accessToken: string | null;
-}
-
-interface UpgradeBannerViewProps {
- currentVersion: string | null | undefined;
- latestRelease: LatestReleaseInfo | null | undefined;
-}
-
-const plural = (count: number, singular: string, pluralForm: string): string =>
- `${count} ${count === 1 ? singular : pluralForm}`;
-
-export const describeRelease = ({ new_features, bug_fixes, other_updates }: LatestReleaseInfo): string =>
- [
- plural(new_features, "new feature", "new features"),
- plural(bug_fixes, "fix", "fixes"),
- `and ${plural(other_updates, "other update", "other updates")}`,
- ].join(", ");
-
-export const UpgradeBannerView: React.FC = ({ currentVersion, latestRelease }) => {
- const [dismissedVersion, setDismissedVersion] = useState(null);
-
- if (!currentVersion || !latestRelease || !isNewerVersion(currentVersion, latestRelease.version)) {
- return null;
- }
-
- const dismissKey = `${DISMISS_KEY_PREFIX}${latestRelease.version}`;
- if (dismissedVersion === latestRelease.version || getLocalStorageItem(dismissKey) === "true") {
- return null;
- }
-
- const handleClose = () => {
- setLocalStorageItem(dismissKey, "true");
- setDismissedVersion(latestRelease.version);
- };
-
- return (
-
-
-
- The latest version is{" "}
-
- v{latestRelease.version}
-
- : {describeRelease(latestRelease)}
-
- Your current version is v{currentVersion}
-
-
-
-
-
-
- );
-};
-
-export const UpgradeBanner: React.FC = ({ accessToken }) => {
- const { data: healthData } = useHealthReadinessDetails(accessToken);
- const { data: latestRelease } = useLatestReleaseInfo(accessToken);
- return ;
-};
diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx
index 9a6ec334b56..b811ab8903c 100644
--- a/ui/litellm-dashboard/src/components/leftnav.tsx
+++ b/ui/litellm-dashboard/src/components/leftnav.tsx
@@ -77,6 +77,7 @@ import {
isUserTeamAdminForAnyTeam,
rolesAllowedToViewWriteScopedPages,
rolesWithWriteAccess,
+ proxyAdminTierRoles,
} from "../utils/roles";
import BetaBadge from "./BetaBadge";
import SidebarAccountMenu from "./SidebarAccountMenu/SidebarAccountMenu";
@@ -464,6 +465,13 @@ const menuGroups: MenuGroup[] = [
roles: all_admin_roles,
},
{ key: "ui-theme", page: "ui-theme", label: "UI Theme", icon: , roles: all_admin_roles },
+ {
+ key: "telemetry",
+ page: "telemetry",
+ label: "Telemetry",
+ icon: ,
+ roles: proxyAdminTierRoles,
+ },
],
},
],
diff --git a/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.integration.test.tsx b/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.integration.test.tsx
index 2bc586cd1bb..690721dbbd9 100644
--- a/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.integration.test.tsx
@@ -184,7 +184,7 @@ async function openWidget() {
}
function send(text: string) {
- fireEvent.change(screen.getByPlaceholderText("Ask LiteAdmin…"), { target: { value: text } });
+ fireEvent.change(screen.getByRole("textbox"), { target: { value: text } });
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
}
@@ -209,7 +209,7 @@ describe("LiteAdmin in the gateway", () => {
renderWidget(undefined, true);
const home = await screen.findByRole("region", { name: "Ask LiteAdmin" });
await selectModel();
- const input = within(home).getByPlaceholderText("Ask LiteAdmin…");
+ const input = within(home).getByPlaceholderText("What would you like to do?");
fireEvent.change(input, { target: { value: "First question" } });
if (method === "keyboard") fireEvent.keyDown(input, { key: "Enter" });
else fireEvent.click(within(home).getByRole("button", { name: "Send message" }));
@@ -235,6 +235,31 @@ describe("LiteAdmin in the gateway", () => {
]);
});
+ it("prefills each Home suggestion without sending until the edited draft is submitted", async () => {
+ const requests = gateway([answer("Budget details received.")]);
+ renderWidget(undefined, true);
+ await selectModel();
+ const input = screen.getByRole("textbox", { name: "Message LiteAdmin" });
+ for (const label of ["Create a key", "Add a model", "Set a budget"]) {
+ fireEvent.click(screen.getByRole("button", { name: label, exact: true }));
+ expect(input).toHaveValue(label);
+ expect(input).toHaveFocus();
+ expect(screen.queryByRole("complementary", { name: "LiteAdmin" })).not.toBeInTheDocument();
+ expect(requests.filter((request) => request.url.endsWith("/chat/completions"))).toHaveLength(0);
+ }
+ fireEvent.change(input, { target: { value: "Set a budget of $10 for my team" } });
+ fireEvent.keyDown(input, { key: "Enter", shiftKey: true });
+ fireEvent.keyDown(input, { key: "Enter", isComposing: true });
+ expect(requests.filter((request) => request.url.endsWith("/chat/completions"))).toHaveLength(0);
+ fireEvent.keyDown(input, { key: "Enter" });
+ expect(await screen.findByText("Budget details received.")).toBeInTheDocument();
+ const completions = requests.filter((request) => request.url.endsWith("/chat/completions"));
+ expect(completions).toHaveLength(1);
+ expect(completions[0].body.model).toBe("chat-model");
+ expect(completions[0].body.messages).toContainEqual({ role: "user", content: "Set a budget of $10 for my team" });
+ expect(requests.filter((request) => request.url.endsWith("/key/generate"))).toHaveLength(0);
+ });
+
it("preserves the draft and chosen model when opening, closing and leaving Home", async () => {
const requests = gateway([answer("Draft received.")], {
models: [
@@ -244,12 +269,14 @@ describe("LiteAdmin in the gateway", () => {
});
const view = renderWidget(undefined, true);
await selectModel();
- fireEvent.change(screen.getByPlaceholderText("Ask LiteAdmin…"), { target: { value: "Keep this draft" } });
- fireEvent.click(screen.getByRole("button", { name: "LiteAdmin" }));
+ fireEvent.change(screen.getByPlaceholderText("What would you like to do?"), {
+ target: { value: "Keep this draft" },
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Recent chats" }));
expect(screen.getByPlaceholderText("Ask LiteAdmin…")).toHaveValue("Keep this draft");
fireEvent.click(screen.getByRole("button", { name: "Close LiteAdmin" }));
const home = screen.getByRole("region", { name: "Ask LiteAdmin" });
- expect(within(home).getByPlaceholderText("Ask LiteAdmin…")).toHaveValue("Keep this draft");
+ expect(within(home).getByPlaceholderText("What would you like to do?")).toHaveValue("Keep this draft");
view.refresh(false);
expect(home).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "LiteAdmin" }));
@@ -293,16 +320,16 @@ describe("LiteAdmin in the gateway", () => {
fireEvent.change(screen.getByPlaceholderText("Ask LiteAdmin…"), { target: { value: "First draft" } });
fireEvent.click(screen.getByRole("button", { name: "Close LiteAdmin" }));
const home = screen.getByRole("region", { name: "Ask LiteAdmin" });
- expect(within(home).getByPlaceholderText("Ask LiteAdmin…")).toHaveValue("First draft");
- expect(screen.getAllByPlaceholderText("Ask LiteAdmin…")).toHaveLength(1);
+ expect(within(home).getByPlaceholderText("What would you like to do?")).toHaveValue("First draft");
+ expect(screen.getAllByPlaceholderText("What would you like to do?")).toHaveLength(1);
- fireEvent.click(screen.getByRole("button", { name: "LiteAdmin" }));
+ fireEvent.click(screen.getByRole("button", { name: "Recent chats" }));
fireEvent.click(screen.getByRole("tab", { name: "Chat 2" }));
expect(screen.getByPlaceholderText("Ask LiteAdmin…")).toHaveValue("Second draft");
expect(screen.getByRole("combobox", { name: "LiteAdmin model" })).toHaveValue("other-chat-model");
expect(screen.getAllByLabelText("LiteAdmin conversation")).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: "Close LiteAdmin" }));
- expect(within(home).getByPlaceholderText("Ask LiteAdmin…")).toHaveValue("Second draft");
+ expect(within(home).getByPlaceholderText("What would you like to do?")).toHaveValue("Second draft");
fireEvent.click(within(home).getByRole("button", { name: "Send message" }));
await screen.findByText("Second follow-up.");
expect(screen.getByRole("tab", { name: "Chat 2" })).toHaveAttribute("aria-selected", "true");
@@ -531,13 +558,13 @@ describe("LiteAdmin in the gateway", () => {
const { client } = renderWidget(undefined, true);
const home = await screen.findByRole("region", { name: "Ask LiteAdmin" });
expect(await within(home).findByText("Add a chat model to your gateway to use LiteAdmin.")).toBeInTheDocument();
- expect(within(home).getByPlaceholderText("Ask LiteAdmin…")).toBeDisabled();
+ expect(within(home).getByPlaceholderText("What would you like to do?")).toBeDisabled();
expect(within(home).getByRole("button", { name: "Send message" })).toBeDisabled();
options.models = [{ model_group: "chat-model", mode: "chat" }];
await act(async () => client.invalidateQueries({ queryKey: ["liteadmin-models"] }));
await selectModel();
for (const text of [" ", "x".repeat(MAX_INPUT_LENGTH + 1)]) {
- const input = within(home).getByPlaceholderText("Ask LiteAdmin…");
+ const input = within(home).getByPlaceholderText("What would you like to do?");
fireEvent.change(input, { target: { value: text } });
fireEvent.keyDown(input, { key: "Enter" });
const submit = within(home).getByRole("button", { name: "Send message" });
@@ -566,15 +593,17 @@ describe("LiteAdmin in the gateway", () => {
await act(async () => settingsReady.resolve());
const consent = await within(home).findByRole("button", { name: "Use configured gateway" });
expect(within(home).getByText(EXTERNAL_INFERENCE)).toBeInTheDocument();
- expect(within(home).queryByPlaceholderText("Ask LiteAdmin…")).not.toBeInTheDocument();
+ expect(within(home).queryByPlaceholderText("What would you like to do?")).not.toBeInTheDocument();
fireEvent.click(consent);
await selectModel();
- fireEvent.change(within(home).getByPlaceholderText("Ask LiteAdmin…"), { target: { value: "Old gateway draft" } });
+ fireEvent.change(within(home).getByPlaceholderText("What would you like to do?"), {
+ target: { value: "Old gateway draft" },
+ });
settings.target = `${EXTERNAL_INFERENCE}/changed`;
await act(async () => client.invalidateQueries({ queryKey: ["proxySettings"] }));
fireEvent.click(await within(home).findByRole("button", { name: "Use configured gateway" }));
await selectModel();
- expect(within(home).getByPlaceholderText("Ask LiteAdmin…")).toHaveValue("");
+ expect(within(home).getByPlaceholderText("What would you like to do?")).toHaveValue("");
expect(home).toBeVisible();
expect(requests.some((request) => request.url.endsWith("/chat/completions"))).toBe(false);
});
diff --git a/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.tsx b/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.tsx
index bf296d47746..350379588c1 100644
--- a/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.tsx
+++ b/ui/litellm-dashboard/src/components/liteadmin/LiteAdmin.tsx
@@ -8,12 +8,13 @@ import {
useLayoutEffect,
useRef,
useState,
+ type ComponentProps,
type ReactNode,
type RefObject,
} from "react";
import { createPortal } from "react-dom";
import { useQuery, type UseQueryResult } from "@tanstack/react-query";
-import { Plus, Sparkles, X } from "lucide-react";
+import { History, Plus, Sparkles, X } from "lucide-react";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useDisableLiteAdmin } from "@/app/(dashboard)/hooks/useDisableLiteAdmin";
import { useProxySettingsQuery } from "@/app/(dashboard)/hooks/proxySettings/useProxySettings";
@@ -32,6 +33,7 @@ import { cn } from "@/lib/cva.config";
import { isProxyAdminRole } from "@/utils/roles";
import { getPreferredLiteAdminModel, MAX_INPUT_LENGTH, resolveInferenceTarget } from "./agent";
import { LiteAdminConversation } from "./LiteAdminConversation";
+import { LiteAdminHomeComposer } from "./LiteAdminHomeComposer";
import { useLiteAdmin, type ConversationEntry, type LiteAdminSession } from "./useLiteAdmin";
type ManagementSession = Omit;
@@ -100,17 +102,18 @@ export function LiteAdminHome() {
if (!state) return null;
const { open, show, setHomeContainer } = state;
return (
-
-
-
-
+
+
+
+
LiteAdmin
-
Ask about your gateway. Review changes in chat.
+
Control your entire gateway.
-
- Open chat
+
+
+ Recent chats
@@ -417,22 +420,24 @@ function LiteAdminChat({
};
if (!active) return null;
const homeContainer = !open ? state?.homeContainer : null;
+ const composerProps = {
+ value: input,
+ onChange: setInput,
+ onSubmit: send,
+ onCancel: chat.phase === "applying" ? undefined : chat.stop,
+ disabled: busy || !selectedModel,
+ isLoading: busy,
+ submitDisabled,
+ };
const composer = (
-
-
- {tooLong &&
Keep your message within {MAX_INPUT_LENGTH.toLocaleString()} characters. }
-
-
Use a model you trust with your gateway data.
-
+
);
return (
@@ -448,32 +453,73 @@ function LiteAdminChat({
);
}
+function LiteAdminComposer({
+ models,
+ model,
+ onModelChange,
+ home,
+ tooLong,
+ ...props
+}: Omit, "modelPicker"> & {
+ models: UseQueryResult;
+ model: string | null;
+ onModelChange: (value: string | null) => void;
+ home: boolean;
+ tooLong: boolean;
+}) {
+ return (
+
+ {!home &&
}
+
+ {tooLong &&
Keep your message within {MAX_INPUT_LENGTH.toLocaleString()} characters. }
+ {home ? (
+
+ }
+ />
+ ) : (
+ <>
+
+
Use a model you trust with your gateway data.
+ >
+ )}
+
+ );
+}
+
function ModelPicker({
models,
value,
onChange,
disabled,
+ compact = false,
}: {
models: UseQueryResult;
value: string | null;
onChange: (value: string | null) => void;
disabled: boolean;
+ compact?: boolean;
}) {
+ if (models.isPending) return ;
+ return (
+ ({ value: item.model_group, label: item.model_group }))}
+ value={value}
+ onValueChange={onChange}
+ placeholder="Choose a chat model"
+ disabled={disabled}
+ allowClear={false}
+ className={compact ? "border-transparent shadow-none dark:bg-transparent" : undefined}
+ />
+ );
+}
+
+function ModelStatus({ models }: { models: UseQueryResult }) {
return (
<>
- {models.isPending ? (
-
- ) : (
- ({ value: item.model_group, label: item.model_group }))}
- value={value}
- onValueChange={onChange}
- placeholder="Choose a chat model"
- disabled={disabled}
- allowClear={false}
- />
- )}
{models.isError && (
diff --git a/ui/litellm-dashboard/src/components/liteadmin/LiteAdminHomeComposer.tsx b/ui/litellm-dashboard/src/components/liteadmin/LiteAdminHomeComposer.tsx
new file mode 100644
index 00000000000..6b666aa238f
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/liteadmin/LiteAdminHomeComposer.tsx
@@ -0,0 +1,92 @@
+import { useRef, type ReactNode } from "react";
+import { ArrowUp, Box, CreditCard, KeyRound, Square } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { InputGroup, InputGroupTextarea } from "@/components/ui/input-group";
+
+const suggestions = [
+ { label: "Create a key", icon: KeyRound },
+ { label: "Add a model", icon: Box },
+ { label: "Set a budget", icon: CreditCard },
+];
+
+export function LiteAdminHomeComposer({
+ value,
+ onChange,
+ onSubmit,
+ onCancel,
+ disabled,
+ isLoading,
+ submitDisabled,
+ modelPicker,
+}: {
+ value: string;
+ onChange: (value: string) => void;
+ onSubmit: () => void;
+ onCancel?: () => void;
+ disabled: boolean;
+ isLoading: boolean;
+ submitDisabled: boolean;
+ modelPicker: ReactNode;
+}) {
+ const inputRef = useRef(null);
+ return (
+
+ onChange(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
+ event.preventDefault();
+ if (!submitDisabled && !isLoading) onSubmit();
+ }
+ }}
+ placeholder="What would you like to do?"
+ aria-label="Message LiteAdmin"
+ disabled={disabled}
+ rows={2}
+ className="min-h-24 max-h-48 w-full rounded-t-xl px-5 pt-5 pb-2 text-sm placeholder:text-muted-foreground [field-sizing:content]"
+ />
+
+
+ {suggestions.map(({ label, icon: Icon }) => (
+ {
+ onChange(label);
+ inputRef.current?.focus();
+ }}
+ >
+
+ {label}
+
+ ))}
+
+
+
{modelPicker}
+ {isLoading && onCancel ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+ );
+}
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:
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"
>
diff --git a/ui/litellm-dashboard/src/components/navbar.test.tsx b/ui/litellm-dashboard/src/components/navbar.test.tsx
index 9d80a89f83e..b8c87a3145e 100644
--- a/ui/litellm-dashboard/src/components/navbar.test.tsx
+++ b/ui/litellm-dashboard/src/components/navbar.test.tsx
@@ -143,7 +143,7 @@ describe("Navbar", () => {
it("should render without crashing", () => {
renderWithProviders( );
- expect(screen.getByRole("button", { name: /^notifications$/i })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /^notifications/i })).toBeInTheDocument();
expect(screen.getByText("Docs")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /open account menu/i })).toBeInTheDocument();
});
@@ -266,7 +266,7 @@ describe("Navbar", () => {
renderWithProviders( );
expect(screen.queryByRole("button", { name: /open account menu/i })).not.toBeInTheDocument();
- expect(screen.queryByRole("button", { name: /^notifications$/i })).not.toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /^notifications/i })).not.toBeInTheDocument();
});
it("should handle hide new feature indicators toggle", async () => {
diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx
index 8bc9b06969e..4e99d8e016f 100644
--- a/ui/litellm-dashboard/src/components/navbar.tsx
+++ b/ui/litellm-dashboard/src/components/navbar.tsx
@@ -149,7 +149,7 @@ const Navbar: React.FC = ({
-
+
diff --git a/ui/litellm-dashboard/src/components/organisms/createKeyOwnerAccess.test.ts b/ui/litellm-dashboard/src/components/organisms/createKeyOwnerAccess.test.ts
new file mode 100644
index 00000000000..204a2a8fa86
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/organisms/createKeyOwnerAccess.test.ts
@@ -0,0 +1,164 @@
+import { describe, expect, it } from "vitest";
+
+import type { Member } from "../networking";
+import {
+ administersTeam,
+ anotherUserScope,
+ effectiveKeyOwner,
+ isTeamMemberOwner,
+ teamMemberOwnerOptions,
+ teamMemberOwnerPlaceholder,
+ type TeamMembers,
+} from "./createKeyOwnerAccess";
+
+const VIEWER = "viewer";
+
+const member = (user_id: string | null, role: string, user_email: string | null = null): Member => ({
+ user_id,
+ role,
+ user_email,
+});
+
+const administered: TeamMembers & { team_id: string } = {
+ team_id: "administered",
+ members_with_roles: [member(VIEWER, "admin"), member("alice", "user", "alice@example.com"), member("bob", "user")],
+};
+
+const joined: TeamMembers & { team_id: string } = {
+ team_id: "joined",
+ members_with_roles: [member(VIEWER, "user"), member("carol", "admin", "carol@example.com")],
+};
+
+const foreign: TeamMembers & { team_id: string } = {
+ team_id: "foreign",
+ members_with_roles: [member("dave", "admin")],
+};
+
+const viewer = {
+ userRole: "Internal User",
+ userId: VIEWER,
+ teamAdminEditableFields: ["create_member_keys"],
+ teams: [joined, administered],
+};
+
+describe("anotherUserScope", () => {
+ it("lets a team admin pick among team members once the proxy enables create_member_keys", () => {
+ expect(anotherUserScope(viewer)).toBe("team_members");
+ });
+
+ it.each([
+ ["nothing is enabled", []],
+ ["only other team admin permissions are enabled", ["max_budget", "member_key_budgets", "projects"]],
+ ])("hides Another User from a team admin when %s", (_label, teamAdminEditableFields) => {
+ expect(anotherUserScope({ ...viewer, teamAdminEditableFields })).toBe("none");
+ });
+
+ it.each([
+ ["only a plain member of their teams", [joined]],
+ ["an admin of no listed team", [foreign]],
+ ["in no team", []],
+ ["still waiting on the team list", null],
+ ])("hides Another User with the permission on from a user who is %s", (_label, teams) => {
+ expect(anotherUserScope({ ...viewer, teams })).toBe("none");
+ });
+
+ it("hides Another User when the session carries no user id", () => {
+ expect(anotherUserScope({ ...viewer, userId: null })).toBe("none");
+ });
+
+ it.each([
+ ["with the permission off and no teams", { teamAdminEditableFields: [], teams: [] }],
+ ["with the permission on and an administered team", {}],
+ ["before the team list has loaded", { teams: null }],
+ ])("lets a proxy admin pick any user %s", (_label, overrides) => {
+ expect(anotherUserScope({ ...viewer, userRole: "Admin", ...overrides })).toBe("any_user");
+ });
+});
+
+describe("effectiveKeyOwner", () => {
+ it.each(["any_user", "team_members"] as const)("keeps another_user for a viewer scoped to %s", (scope) => {
+ expect(effectiveKeyOwner("another_user", scope)).toBe("another_user");
+ });
+
+ it("falls back to you when another_user is requested by a viewer who may not pick one", () => {
+ expect(effectiveKeyOwner("another_user", "none")).toBe("you");
+ });
+
+ it.each(["you", "service_account", "agent"])(
+ "leaves %s alone for a viewer who may not pick another user",
+ (owner) => {
+ expect(effectiveKeyOwner(owner, "none")).toBe(owner);
+ },
+ );
+});
+
+describe("administersTeam", () => {
+ it("narrows a team list to the teams the viewer administers", () => {
+ const teams = [joined, administered, foreign];
+
+ expect(teams.filter((team) => administersTeam(team, VIEWER)).map((team) => team.team_id)).toStrictEqual([
+ "administered",
+ ]);
+ });
+
+ it.each([
+ ["no team", null],
+ ["a team with no member list", {}],
+ ["a team whose member list is null", { members_with_roles: null }],
+ ])("is false for %s", (_label, team) => {
+ expect(administersTeam(team, VIEWER)).toBe(false);
+ });
+});
+
+describe("teamMemberOwnerOptions", () => {
+ it("lists the selected team's other members, labelled by email when the team has one", () => {
+ expect(teamMemberOwnerOptions(administered, VIEWER)).toStrictEqual([
+ { value: "alice", label: "alice@example.com (alice)" },
+ { value: "bob", label: "bob" },
+ ]);
+ });
+
+ it("skips a member row that has no user id", () => {
+ const team = {
+ members_with_roles: [member(VIEWER, "admin"), member(null, "user", "pending@example.com"), member("bob", "user")],
+ };
+
+ expect(teamMemberOwnerOptions(team, VIEWER)).toStrictEqual([{ value: "bob", label: "bob" }]);
+ });
+
+ it.each([
+ ["no team is selected", null],
+ ["the selected team is one the viewer only belongs to", joined],
+ ["the selected team is one the viewer is not in", foreign],
+ ])("offers nobody when %s", (_label, team) => {
+ expect(teamMemberOwnerOptions(team, VIEWER)).toStrictEqual([]);
+ });
+});
+
+describe("teamMemberOwnerPlaceholder", () => {
+ const soloTeam = { members_with_roles: [member(VIEWER, "admin")] };
+
+ it.each([
+ ["asks for a team before one is selected", null, "Select a team first"],
+ ["says so when the viewer is the team's only member", soloTeam, "This team has no other members"],
+ ["asks for a member once the team has someone to pick", administered, "Select a member of the team"],
+ ])("%s", (_label, team, expected) => {
+ expect(teamMemberOwnerPlaceholder(teamMemberOwnerOptions(team, VIEWER), team)).toBe(expected);
+ });
+});
+
+describe("isTeamMemberOwner", () => {
+ it("keeps an owner who belongs to the selected team", () => {
+ expect(isTeamMemberOwner("alice", administered, VIEWER)).toBe(true);
+ });
+
+ it.each([
+ ["is in another team only", "carol", administered],
+ ["was picked before the team was cleared", "alice", null],
+ ["belongs to a team the viewer does not administer", "carol", joined],
+ ["is the viewer, who already has the You option", VIEWER, administered],
+ ["is not set", null, administered],
+ ])("rejects an owner who %s", (_label, ownerId, team) => {
+ expect(isTeamMemberOwner(ownerId, team, VIEWER)).toBe(false);
+ });
+});
diff --git a/ui/litellm-dashboard/src/components/organisms/createKeyOwnerAccess.ts b/ui/litellm-dashboard/src/components/organisms/createKeyOwnerAccess.ts
new file mode 100644
index 00000000000..dcbebab7c25
--- /dev/null
+++ b/ui/litellm-dashboard/src/components/organisms/createKeyOwnerAccess.ts
@@ -0,0 +1,59 @@
+import type { SearchSelectOption } from "@/components/shared/SearchSelect";
+import { TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION } from "@/components/team/teamAdminEditAccess";
+import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "@/utils/roles";
+import type { Member } from "../networking";
+
+export type AnotherUserScope = "any_user" | "team_members" | "none";
+
+export interface TeamMembers {
+ readonly members_with_roles?: Member[] | null;
+}
+
+export interface KeyOwnerViewer {
+ readonly userRole: string | null;
+ readonly userId: string | null;
+ readonly teamAdminEditableFields: readonly string[];
+ readonly teams: readonly TeamMembers[] | null;
+}
+
+export const administersTeam = (team: TeamMembers | null, userId: string | null): boolean =>
+ userId !== null && isUserTeamAdminForSingleTeam(team?.members_with_roles ?? null, userId);
+
+export const anotherUserScope = ({
+ userRole,
+ userId,
+ teamAdminEditableFields,
+ teams,
+}: KeyOwnerViewer): AnotherUserScope => {
+ if (isProxyAdminRole(userRole ?? "")) return "any_user";
+ if (!teamAdminEditableFields.includes(TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION)) return "none";
+ return (teams ?? []).some((team) => administersTeam(team, userId)) ? "team_members" : "none";
+};
+
+export const effectiveKeyOwner = (requestedOwner: string, scope: AnotherUserScope): string =>
+ requestedOwner === "another_user" && scope === "none" ? "you" : requestedOwner;
+
+export const teamMemberOwnerOptions = (team: TeamMembers | null, viewerId: string | null): SearchSelectOption[] => {
+ if (!administersTeam(team, viewerId)) return [];
+ return (team?.members_with_roles ?? []).flatMap((member) =>
+ member.user_id && member.user_id !== viewerId
+ ? [
+ {
+ value: member.user_id,
+ label: member.user_email ? `${member.user_email} (${member.user_id})` : member.user_id,
+ },
+ ]
+ : [],
+ );
+};
+
+export const teamMemberOwnerPlaceholder = (
+ options: readonly SearchSelectOption[],
+ team: TeamMembers | null,
+): string => {
+ if (options.length > 0) return "Select a member of the team";
+ return team ? "This team has no other members" : "Select a team first";
+};
+
+export const isTeamMemberOwner = (ownerId: unknown, team: TeamMembers | null, viewerId: string | null): boolean =>
+ teamMemberOwnerOptions(team, viewerId).some((option) => option.value === ownerId);
diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx
index 63ed7e14b67..0fbe043c0d1 100644
--- a/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx
+++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.integration.test.tsx
@@ -649,6 +649,59 @@ describe("CreateKey", () => {
expect(screen.queryByRole("radio", { name: "Another User" })).not.toBeInTheDocument();
});
+ describe("for a team admin", () => {
+ const administeredTeam = {
+ team_id: "team-admin-of",
+ team_alias: "Administered Team",
+ models: [],
+ members_with_roles: [
+ { user_id: "test-user-id", role: "admin" },
+ { user_id: "member-1", role: "user", user_email: "member@example.com" },
+ ],
+ };
+ const joinedTeam = {
+ team_id: "team-member-of",
+ team_alias: "Joined Team",
+ models: [],
+ members_with_roles: [
+ { user_id: "test-user-id", role: "user" },
+ { user_id: "outsider-1", role: "admin", user_email: "outsider@example.com" },
+ ],
+ };
+
+ beforeEach(() => {
+ state.authorized = { ...state.authorized, userRole: "Internal User" };
+ state.teams = [joinedTeam, administeredTeam];
+ });
+
+ it("hides the Another User option until the proxy enables create_member_keys", async () => {
+ state.uiSettings = { team_admin_editable_team_fields: ["max_budget"] };
+ await openModal({ teams: state.teams as unknown as Team[] });
+
+ expect(screen.queryByRole("radio", { name: "Another User" })).not.toBeInTheDocument();
+ });
+
+ it("creates a key for a member of an administered team without the proxy-wide user search", async () => {
+ state.uiSettings = { team_admin_editable_team_fields: ["create_member_keys"] };
+ await openModal({ teams: state.teams as unknown as Team[] });
+ await nameTheKey();
+ await userEvent.click(screen.getByRole("radio", { name: "Another User" }));
+
+ await userEvent.click(await screen.findByLabelText(/^Team/));
+ expect(await screen.findByRole("option", { name: /Administered Team/ })).toBeInTheDocument();
+ expect(screen.queryByRole("option", { name: /Joined Team/ })).not.toBeInTheDocument();
+ await userEvent.click(screen.getByRole("option", { name: /Administered Team/ }));
+
+ await userEvent.click(await screen.findByLabelText(/^User ID/));
+ expect(await screen.findAllByRole("option")).toHaveLength(1);
+ await userEvent.click(screen.getByRole("option", { name: "member@example.com (member-1)" }));
+ await submit();
+
+ expect(await createdPayload()).toMatchObject({ user_id: "member-1", team_id: "team-admin-of" });
+ expect(userFilterUICall).not.toHaveBeenCalled();
+ });
+ });
+
it("routes a service account through the service account endpoint and stamps the alias into metadata", async () => {
state.teams = [{ team_id: "team-1", team_alias: "Team One", models: [] }];
await openModal({ teams: state.teams as unknown as Team[] });
diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx
index 45245ff95b3..eb8c96a675a 100644
--- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx
+++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx
@@ -6,6 +6,7 @@ import { useTags } from "@/app/(dashboard)/hooks/tags/useTags";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useCan from "@/app/(dashboard)/hooks/useCan";
+import { parseTeamAdminEditableFields } from "@/components/team/teamAdminEditAccess";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { useQueryClient } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
@@ -80,6 +81,14 @@ import {
import CreatedKeyDisplay from "../shared/CreatedKeyDisplay";
import NumericalInput from "../shared/numerical_input";
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
+import {
+ administersTeam,
+ anotherUserScope,
+ effectiveKeyOwner,
+ isTeamMemberOwner,
+ teamMemberOwnerOptions,
+ teamMemberOwnerPlaceholder,
+} from "./createKeyOwnerAccess";
import { buildKeyCreatePayload, type KeyCreateInput } from "./createKeyPayload";
import { simplifyKeyGenerateError } from "./utils";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@@ -248,7 +257,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp
const [apiKey, setApiKey] = useState(null);
const [userModels, setUserModels] = useState([]);
const [modelsToPick, setModelsToPick] = useState([]);
- const [keyOwner, setKeyOwner] = useState("you");
+ const [requestedKeyOwner, setKeyOwner] = useState("you");
const [hasPrefilled, setHasPrefilled] = useState(false);
const [pendingPrefillModels, setPendingPrefillModels] = useState(null);
const [guardrailsList, setGuardrailsList] = useState([]);
@@ -279,6 +288,13 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp
const [agentsList, setAgentsList] = useState<{ agent_id: string; agent_name: string }[]>([]);
const [selectedAgentId, setSelectedAgentId] = useState(null);
const selectedModels: string[] = (useWatch({ control: form.control, name: "models" }) as string[] | undefined) ?? [];
+ const teamAdminEditableFields = parseTeamAdminEditableFields(uiSettingsData?.values);
+ const keyOwnerViewer = { userRole, userId: userID, teamAdminEditableFields, teams };
+ const ownerScope = anotherUserScope(keyOwnerViewer);
+ const keyOwner = effectiveKeyOwner(requestedKeyOwner, ownerScope);
+ const ownerLimitedToTeamMembers = keyOwner === "another_user" && ownerScope === "team_members";
+ const memberOwnerOptions = teamMemberOwnerOptions(selectedCreateKeyTeam, userID);
+ const ownerRequiresTeam = keyOwner === "service_account" || ownerLimitedToTeamMembers;
const handleCancel = () => {
setIsModalVisible(false);
setApiKey(null);
@@ -383,14 +399,8 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp
// Apply prefill data if provided
if (prefillData) {
- // Set key owner (owned_by) - validate that "another_user" is only allowed for Admin
if (prefillData.owned_by) {
- if (prefillData.owned_by === "another_user" && userRole !== "Admin") {
- // Ignore invalid owned_by for non-admin users, fall back to default
- setKeyOwner("you");
- } else {
- setKeyOwner(prefillData.owned_by);
- }
+ setKeyOwner(prefillData.owned_by);
}
// Set team - find the team by ID and set it (only if team exists in user's teams)
@@ -616,6 +626,23 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp
}
};
+ const changeKeyOwner = (owner: string) => {
+ setKeyOwner(owner);
+ const limitsOwnerToTeamMembers = owner === "another_user" && ownerScope === "team_members";
+ if (limitsOwnerToTeamMembers && selectedCreateKeyTeam && !administersTeam(selectedCreateKeyTeam, userID)) {
+ selectTeam(null);
+ form.setValue("team_id", null);
+ }
+ };
+
+ useEffect(() => {
+ if (!ownerLimitedToTeamMembers) return;
+ const owner = form.getValues("user_id");
+ if (owner != null && !isTeamMemberOwner(owner, selectedCreateKeyTeam, userID)) {
+ form.setValue("user_id", null);
+ }
+ }, [ownerLimitedToTeamMembers, selectedCreateKeyTeam, userID, form]);
+
const changeProject = (write: FieldWrite) => (projectId: string | null) => {
write(projectId);
if (!projectId) {
@@ -650,6 +677,68 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp
}
};
+ const memberOwnerPlaceholder = teamMemberOwnerPlaceholder(memberOwnerOptions, selectedCreateKeyTeam);
+ const requiredTeamHelp = ownerLimitedToTeamMembers
+ ? "required, the key's owner must be a member of this team"
+ : "required";
+ const teamFieldHelp = ownerRequiresTeam ? requiredTeamHelp : "";
+ const ownerField = keyOwner === "another_user" && (
+
+ User ID{" "}
+
+
+
+
+ }
+ name="user_id"
+ className="mt-4"
+ required
+ rules={requiredRule(
+ keyOwner === "another_user",
+ `Please input the user ID of the user you are assigning the key to`,
+ )}
+ >
+ {(control) =>
+ ownerLimitedToTeamMembers ? (
+
+ ) : (
+
+
+
+
setIsCreateUserModalVisible(true)}>
+ Create User
+
+
+
Search by email or user ID to find users
+
+ )
+ }
+
+ );
+
return (
{userRole && rolesWithWriteAccess.includes(userRole) && (
@@ -679,7 +768,7 @@ const CreateKey: React.FC
= ({ team, teams, data, addKey, autoOp
setKeyOwner(String(value))}
+ onValueChange={(value: unknown) => changeKeyOwner(String(value))}
>
@@ -689,7 +778,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp
Service Account
- {userRole === "Admin" && (
+ {ownerScope !== "none" && (
Another User
@@ -702,50 +791,7 @@ const CreateKey: React.FC = ({ team, teams, data, addKey, autoOp
- {keyOwner === "another_user" && (
-
- User ID{" "}
-
-
-
-
- }
- name="user_id"
- className="mt-4"
- required
- rules={requiredRule(
- keyOwner === "another_user",
- `Please input the user ID of the user you are assigning the key to`,
- )}
- >
- {(control) => (
-
-
-
-
setIsCreateUserModalVisible(true)}>
- Create User
-
-
-
Search by email or user ID to find users
-
- )}
-
- )}
+ {!ownerLimitedToTeamMembers && ownerField}
{keyOwner === "agent" && (
@@ -803,9 +849,14 @@ const CreateKey: React.FC
= ({ team, teams, data, addKey, autoOp
}
name="team_id"
className="mt-4"
- required={keyOwner === "service_account"}
- rules={requiredRule(keyOwner === "service_account", "Please select a team for the service account")}
- help={keyOwner === "service_account" ? "required" : ""}
+ required={ownerRequiresTeam}
+ rules={requiredRule(
+ ownerRequiresTeam,
+ ownerLimitedToTeamMembers
+ ? "Please select the team the key's owner belongs to"
+ : "Please select a team for the service account",
+ )}
+ help={teamFieldHelp}
>
{(control) => (
= ({ team, teams, data, addKey, autoOp
disabled={selectedProjectId !== null}
organizationId={selectedOrganizationId}
onTeamSelect={selectTeam}
+ filterTeam={ownerLimitedToTeamMembers ? (team) => administersTeam(team, userID) : undefined}
/>
)}
+ {ownerLimitedToTeamMembers && ownerField}
{enableProjectsUI && (
= {
skills: "Browse and manage Claude Code skills",
usage: "View legacy usage dashboard",
"router-settings": "Configure routing and load balancing settings",
+ telemetry: "Choose which anonymous usage telemetry this proxy collects and see the last report",
"logging-and-alerts": "Set up logging and alert configurations",
"admin-panel": "Access admin panel and settings",
};
diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx
index b27be2ba966..4d0d590521f 100644
--- a/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx
+++ b/ui/litellm-dashboard/src/components/provider_info_helpers.test.tsx
@@ -197,6 +197,7 @@ describe("provider_info_helpers", () => {
it("should map every provider to a bundled logo except the known logoless set, never a raw /ui/assets path", () => {
const knownLogolessProviders = [
Providers.AUTO_ROUTER,
+ Providers.Bespoke,
Providers.BYTEZ,
Providers.CLARIFAI,
Providers.Cognition,
@@ -209,6 +210,7 @@ describe("provider_info_helpers", () => {
Providers.GALADRIEL,
Providers.GradientAI,
Providers.HEROKU,
+ Providers.Laya,
Providers.LEMONADE,
Providers.LLAMAFILE,
Providers.MARITALK,
@@ -371,6 +373,13 @@ describe("provider_info_helpers", () => {
expect(getPlaceholder("StrandsDecider")).toBe("strands_decider/strands-decider-2B-hobson-v19");
});
+ it.each([
+ [Providers.Laya, "laya/english"],
+ [Providers.Bespoke, "bespoke/nimble"],
+ ])("should return the %s decisions model placeholder", (provider, placeholder) => {
+ expect(getPlaceholder(provider)).toBe(placeholder);
+ });
+
it("should return default gpt-3.5-turbo placeholder for unknown provider", () => {
expect(getPlaceholder("UnknownProvider" as any)).toBe("gpt-3.5-turbo");
});
diff --git a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx
index 7b6c7d24706..3925118a881 100644
--- a/ui/litellm-dashboard/src/components/provider_info_helpers.tsx
+++ b/ui/litellm-dashboard/src/components/provider_info_helpers.tsx
@@ -88,6 +88,7 @@ export enum Providers {
Azure_Speech = "Azure AI Speech",
AZURE_TEXT = "Azure Text",
BASETEN = "Baseten",
+ Bespoke = "Bespoke Nimble",
BYTEZ = "Bytez",
Cerebras = "Cerebras",
CHATGPT = "ChatGPT Subscription",
@@ -129,6 +130,7 @@ export enum Providers {
Infinity = "Infinity",
JinaAI = "Jina AI",
LAMBDA_AI = "Lambda Ai",
+ Laya = "Laya",
LEMONADE = "Lemonade",
LLAMAFILE = "Llamafile",
LM_STUDIO = "Lm Studio",
@@ -211,6 +213,7 @@ export const provider_map: Record = {
BASETEN: "baseten",
Bedrock: "bedrock",
BedrockMantle: "bedrock_mantle",
+ Bespoke: "bespoke",
BYTEZ: "bytez",
Cerebras: "cerebras",
CHATGPT: "chatgpt",
@@ -252,6 +255,7 @@ export const provider_map: Record = {
Infinity: "infinity",
JinaAI: "jina_ai",
LAMBDA_AI: "lambda_ai",
+ Laya: "laya",
LEMONADE: "lemonade",
LLAMAFILE: "llamafile",
LLAMA: "meta_llama",
@@ -475,6 +479,8 @@ const providerPlaceholderMap: Partial> = {
[Providers.SageMaker]: "sagemaker/jumpstart-dft-meta-textgeneration-llama-2-7b",
[Providers.Sail]: "sail/openai/gpt-oss-120b",
[Providers.SCX_AI]: "scx-ai/GLM-5.2",
+ [Providers.Bespoke]: "bespoke/nimble",
+ [Providers.Laya]: "laya/english",
[Providers.Snowflake]: "snowflake/mistral-7b",
[Providers.StrandsDecider]: "strands_decider/strands-decider-2B-hobson-v19",
[Providers.Tencent]: "tencent/deepseek-v4-pro",
diff --git a/ui/litellm-dashboard/src/components/publicModelHub/publicModelHubFilters.test.ts b/ui/litellm-dashboard/src/components/publicModelHub/publicModelHubFilters.test.ts
index d17f1ccae10..abaa21f43bb 100644
--- a/ui/litellm-dashboard/src/components/publicModelHub/publicModelHubFilters.test.ts
+++ b/ui/litellm-dashboard/src/components/publicModelHub/publicModelHubFilters.test.ts
@@ -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: [] },
diff --git a/ui/litellm-dashboard/src/components/publicModelHub/publicModelHubFilters.ts b/ui/litellm-dashboard/src/components/publicModelHub/publicModelHubFilters.ts
index b217fdae955..4aa6b467264 100644
--- a/ui/litellm-dashboard/src/components/publicModelHub/publicModelHubFilters.ts
+++ b/ui/litellm-dashboard/src/components/publicModelHub/publicModelHubFilters.ts
@@ -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);
diff --git a/ui/litellm-dashboard/src/components/public_model_hub.test.tsx b/ui/litellm-dashboard/src/components/public_model_hub.test.tsx
index cb23dfd9bb6..9d7835a8ee3 100644
--- a/ui/litellm-dashboard/src/components/public_model_hub.test.tsx
+++ b/ui/litellm-dashboard/src/components/public_model_hub.test.tsx
@@ -62,9 +62,14 @@ const model = (overrides: Partial & { 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 = 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);
diff --git a/ui/litellm-dashboard/src/components/public_model_hub.tsx b/ui/litellm-dashboard/src/components/public_model_hub.tsx
index 20daee4d006..78adb7e4d40 100644
--- a/ui/litellm-dashboard/src/components/public_model_hub.tsx
+++ b/ui/litellm-dashboard/src/components/public_model_hub.tsx
@@ -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 (
@@ -267,7 +289,14 @@ const PublicModelHub: React.FC
= ({ 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 = ({ accessToken, isEmbedded
Mode:
-
{selectedModel.mode || "Not specified"}
+
{displayMode(selectedModel.mode) ?? "Not specified"}
Providers:
@@ -840,51 +869,11 @@ const PublicModelHub: React.FC
= ({ accessToken, isEmbedded
Usage Example
-
- {(() => {
- 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;
- })()}
-
+
{usageExample(selectedModel)}
{
- 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
diff --git a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.test.ts b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.test.ts
index f7aa3dc1e99..dc88f51293b 100644
--- a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.test.ts
+++ b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.test.ts
@@ -15,6 +15,7 @@ describe("teamAdminFieldLabel", () => {
["max_budget", "Max Budget (USD)"],
["raise_max_budget", "Raise the team's max budget"],
["projects", "Create and update projects"],
+ ["create_member_keys", "Create keys for team members"],
])("names %s the way the team settings form does", (field, label) => {
expect(teamAdminFieldLabel(field)).toBe(label);
});
diff --git a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts
index cd24d6bdff0..1fed045292b 100644
--- a/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts
+++ b/ui/litellm-dashboard/src/components/team/teamAdminEditAccess.ts
@@ -5,6 +5,8 @@ export const TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING = "team_admin_editable_team
export const TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION = "raise_max_budget";
+export const TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION = "create_member_keys";
+
export const TEAM_ADMIN_EDITING_DISABLED_TITLE = "Team admins cannot edit team settings on this proxy";
export const TEAM_ADMIN_EDITING_DISABLED_DESCRIPTION =
"Ask a proxy admin to enable fields under Settings > UI > Team admin editable fields.";
@@ -57,6 +59,7 @@ const TEAM_ADMIN_FIELD_LABELS: ReadonlyMap = new Map([
[TEAM_ADMIN_RAISE_MAX_BUDGET_PERMISSION, "Raise the team's max budget"],
["projects", "Create and update projects"],
["member_key_budgets", "Update budgets on team members' keys"],
+ [TEAM_ADMIN_CREATE_MEMBER_KEYS_PERMISSION, "Create keys for team members"],
]);
export const teamAdminFieldLabel = (field: string): string => TEAM_ADMIN_FIELD_LABELS.get(field) ?? field;
diff --git a/ui/litellm-dashboard/src/contexts/ReactQueryProvider.tsx b/ui/litellm-dashboard/src/contexts/ReactQueryProvider.tsx
index ddcdbecc9c5..b2fada0e8bf 100644
--- a/ui/litellm-dashboard/src/contexts/ReactQueryProvider.tsx
+++ b/ui/litellm-dashboard/src/contexts/ReactQueryProvider.tsx
@@ -10,7 +10,7 @@ const isRetryable = (error: unknown): boolean => !(error instanceof ApiError) ||
export const shouldRetry = (failureCount: number, error: unknown): boolean =>
isRetryable(error) && failureCount < MAX_RETRIES;
-const queryClient = new QueryClient({ defaultOptions: { queries: { retry: shouldRetry } } });
+export const queryClient = new QueryClient({ defaultOptions: { queries: { retry: shouldRetry } } });
export default function ReactQueryProvider({ children }: { children: React.ReactNode }) {
return {children} ;
diff --git a/ui/litellm-dashboard/src/hooks/useUrlTab.test.tsx b/ui/litellm-dashboard/src/hooks/useUrlTab.test.tsx
index 427d454ee2d..cd4951d76dd 100644
--- a/ui/litellm-dashboard/src/hooks/useUrlTab.test.tsx
+++ b/ui/litellm-dashboard/src/hooks/useUrlTab.test.tsx
@@ -2,8 +2,12 @@ import { act, renderHook, waitFor } from "@testing-library/react";
import { NuqsTestingAdapter, type OnUrlUpdateFunction } from "nuqs/adapters/testing";
import type { ReactNode } from "react";
import { describe, expect, it, vi } from "vitest";
+import { recordUiEvent } from "@/lib/telemetry/uiEvents";
import { useUrlTab } from "./useUrlTab";
+vi.mock("@/lib/telemetry/uiEvents", () => ({ recordUiEvent: vi.fn(() => Promise.resolve()) }));
+vi.mock("next/navigation", () => ({ usePathname: () => "/ui/playground" }));
+
const TABS = ["chat", "compare", "compliance"] as const;
type Tab = (typeof TABS)[number];
@@ -97,4 +101,18 @@ describe("useUrlTab", () => {
await waitFor(() => expect(onUrlUpdate).toHaveBeenCalled());
expect(lastUrlUpdate(onUrlUpdate)?.searchParams.has("tab")).toBe(false);
});
+
+ it("records a tab click with the caller's key and the selected tab, and nothing for a tab read from the URL", () => {
+ vi.mocked(recordUiEvent).mockClear();
+ const { result } = renderUrlTab({ searchParams: "?view=compare", key: "view" });
+ expect(recordUiEvent).not.toHaveBeenCalled();
+
+ act(() => result.current[1]("compliance"));
+
+ expect(recordUiEvent).toHaveBeenCalledExactlyOnceWith({
+ page: "playground",
+ action: "click",
+ target: "view=compliance",
+ });
+ });
});
diff --git a/ui/litellm-dashboard/src/hooks/useUrlTab.ts b/ui/litellm-dashboard/src/hooks/useUrlTab.ts
index 2f3d705c610..e5e27974cd7 100644
--- a/ui/litellm-dashboard/src/hooks/useUrlTab.ts
+++ b/ui/litellm-dashboard/src/hooks/useUrlTab.ts
@@ -1,12 +1,22 @@
+import { usePathname } from "next/navigation";
import { parseAsString, useQueryState } from "nuqs";
import { useCallback, useEffect } from "react";
+import { recordUiEvent } from "@/lib/telemetry/uiEvents";
+import { routeSegmentForPathname } from "@/utils/uiHref";
export function useUrlTab(values: readonly T[], fallback: T, key = "tab"): [T, (tab: T) => void] {
const [urlTab, setUrlTab] = useQueryState(key, parseAsString.withDefault(fallback));
+ const page = routeSegmentForPathname(usePathname() ?? "") || "home";
const tab = values.find((value) => value === urlTab) ?? fallback;
useEffect(() => {
if (urlTab !== tab) void setUrlTab(null);
}, [urlTab, tab, setUrlTab]);
- const setTab = useCallback((next: T) => void setUrlTab(next), [setUrlTab]);
+ const setTab = useCallback(
+ (next: T) => {
+ void recordUiEvent({ page, action: "click", target: `${key}=${next}` });
+ void setUrlTab(next);
+ },
+ [page, key, setUrlTab],
+ );
return [tab, setTab];
}
diff --git a/ui/litellm-dashboard/src/lib/decisionModels.test.ts b/ui/litellm-dashboard/src/lib/decisionModels.test.ts
index d5f3aff3fe1..8e131c6b3eb 100644
--- a/ui/litellm-dashboard/src/lib/decisionModels.test.ts
+++ b/ui/litellm-dashboard/src/lib/decisionModels.test.ts
@@ -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",
diff --git a/ui/litellm-dashboard/src/lib/decisionModels.ts b/ui/litellm-dashboard/src/lib/decisionModels.ts
index 4f0ccda5f1a..1808b13c62b 100644
--- a/ui/litellm-dashboard/src/lib/decisionModels.ts
+++ b/ui/litellm-dashboard/src/lib/decisionModels.ts
@@ -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 = 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> = { 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 | 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 | 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 | 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,
diff --git a/ui/litellm-dashboard/src/lib/http/api.ts b/ui/litellm-dashboard/src/lib/http/api.ts
index 560682ec312..968624ae75c 100644
--- a/ui/litellm-dashboard/src/lib/http/api.ts
+++ b/ui/litellm-dashboard/src/lib/http/api.ts
@@ -28,7 +28,7 @@ const carriesJson = async (response: Response): Promise => {
}
};
-const middleware: Middleware = {
+const jsonMiddleware = (report: (message: string) => void): Middleware => ({
onRequest({ request }) {
if (!request.headers.has("Accept")) {
request.headers.set("Accept", "application/json");
@@ -43,7 +43,7 @@ const middleware: Middleware = {
if (await carriesJson(response)) return response;
const contentType = response.headers.get("content-type") ?? "an unknown content type";
const message = `Expected JSON from ${new URL(request.url).pathname} but the server returned ${contentType}`;
- reportError(message);
+ report(message);
throw new ApiError(message, response.status, await response.clone().text());
}
const raw = await response.clone().text();
@@ -55,10 +55,10 @@ const middleware: Middleware = {
} catch {
message = raw || `HTTP ${response.status}`;
}
- reportError(message);
+ report(message);
throw new ApiError(message, response.status, body, retryAfterMs(response.headers));
},
-};
+});
/**
* The typed, schema-bound HTTP client. Use it inside TanStack Query hooks
@@ -76,7 +76,13 @@ export const fetchClient = createFetchClient({
Request: BaseAwareRequest,
fetch: (request) => globalThis.fetch(request),
});
-fetchClient.use(middleware);
+fetchClient.use(jsonMiddleware(reportError));
+
+export const backgroundFetchClient = createFetchClient({
+ Request: BaseAwareRequest,
+ fetch: (request) => globalThis.fetch(request),
+});
+backgroundFetchClient.use(jsonMiddleware(() => {}));
/**
* TanStack Query bound to the typed client. Callers write
diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts
index 7343c13b409..6da601efeec 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -2449,6 +2449,7 @@ export interface paths {
* - homepage: Plugin homepage URL (optional)
* - keywords: Search keywords (optional)
* - category: Plugin category (optional)
+ * - installation_preference: 'available', 'auto_install', or 'required' (optional)
*
* Returns:
* Registration status (action is always "created") and plugin information.
@@ -2515,6 +2516,7 @@ export interface paths {
* - homepage: Plugin homepage URL (optional)
* - keywords: Search keywords (optional)
* - category: Plugin category (optional)
+ * - installation_preference: 'available', 'auto_install', or 'required' (optional)
*
* Returns:
* Update status (action is always "updated") and plugin information.
@@ -6145,6 +6147,9 @@ export interface paths {
* @description Cache activity for the Admin UI cache dashboard, aggregated per call_type:
* cache hits vs successful LLM API requests vs failed requests, plus totals
* for the stat cards and the available key-alias/model filter options.
+ *
+ * Admin callers see proxy-wide activity; all other callers see only their own
+ * spend logs and those of teams they may view, exactly like ``/spend/logs/ui``.
*/
get: operations["get_global_activity_global_activity_cache_hits_get"];
put?: never;
@@ -8347,7 +8352,7 @@ export interface paths {
* - key_alias: Optional[str] - User defined key alias
* - key: Optional[str] - User defined key value. Must start with 'sk-' and be at least 16 characters long. If not set, a 16-digit unique sk-key is created for you.
* - team_id: Optional[str] - The team id of the key
- * - user_id: Optional[str] - The user id of the key
+ * - user_id: Optional[str] - The user id of the key. Non-admin callers may only pass their own id, except a team admin creating a key in a team they administer for another member of that team, when a proxy admin has added `create_member_keys` to `team_admin_editable_team_fields`. That member must have the `internal_user` or `internal_user_viewer` role, and the key is always an `llm_api` key.
* - agent_id: Optional[str] - The agent id associated with the key.
* - organization_id: Optional[str] - The organization id of the key. If not set, and team_id is set, the organization id will be the same as the team id. If conflict, an error will be raised.
* - project_id: Optional[str] - The project id of the key. When set, models and max_budget are validated against the project's limits.
@@ -9142,6 +9147,26 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/live/sessions": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * WebSocket: live_sessions_websocket_endpoint
+ * @description WebSocket connection endpoint
+ */
+ get: operations["websocket_live_sessions_websocket_endpoint_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/login": {
parameters: {
query?: never;
@@ -10822,6 +10847,26 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/openai/v1/live/sessions": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * WebSocket: live_sessions_websocket_endpoint
+ * @description WebSocket connection endpoint
+ */
+ get: operations["websocket_live_sessions_websocket_endpoint_get_3"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/openai/v1/realtime": {
parameters: {
query?: never;
@@ -13454,9 +13499,9 @@ export interface paths {
};
/**
* Get Litellm Blog Posts
- * @description Public endpoint to get the latest LiteLLM blog posts.
+ * @description Public endpoint to get the latest LiteLLM blog posts, each with its RSS categories as ``tags``.
*
- * Fetches from GitHub with a 1-hour in-process cache.
+ * Fetches the docs RSS feed with a 1-hour in-process cache.
* Falls back to the bundled local backup on any failure.
*/
get: operations["get_litellm_blog_posts_public_litellm_blog_posts_get"];
@@ -13661,31 +13706,6 @@ export interface paths {
patch?: never;
trace?: never;
};
- "/public/whats_new": {
- parameters: {
- query?: never;
- header?: never;
- path?: never;
- cookie?: never;
- };
- /**
- * Get Public Whats New
- * @description Return the launches the dashboard Home page shows under What's new.
- *
- * Serves the list bundled with this release. Set ``LITELLM_WHATS_NEW_URL`` to fetch a remote list instead,
- * resolved once per process on the first request and falling back to the bundled list on any failure, so an
- * airgapped proxy serves the bundled list without retrying. ``LITELLM_LOCAL_WHATS_NEW=True`` forces the
- * bundled list even when a URL is set. A restart picks up a newly published list.
- */
- get: operations["get_public_whats_new_public_whats_new_get"];
- put?: never;
- post?: never;
- delete?: never;
- options?: never;
- head?: never;
- patch?: never;
- trace?: never;
- };
"/queue/chat/completions": {
parameters: {
query?: never;
@@ -17481,6 +17501,91 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/telemetry/reports": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Export Telemetry Reports
+ * @description Stored telemetry reports, oldest first, for installs that keep telemetry local instead of sending it.
+ * Page with ``after=next_after&after_id=next_after_id`` until ``next_after`` is null. Another worker can commit a
+ * report just after a newer one is exported, so resume a later export a few windows before the last cursor and skip
+ * ids already seen
+ */
+ get: operations["export_telemetry_reports_telemetry_reports_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/telemetry/settings": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get Telemetry Settings
+ * @description Which telemetry groups are on, whether env vars control them, where reports go and a last or sample report.
+ * Nothing is recorded until a destination is chosen by ``LITELLM_TELEMETRY_DESTINATION`` or a saved setting
+ */
+ get: operations["get_telemetry_settings_telemetry_settings_get"];
+ /**
+ * Update Telemetry Settings
+ * @description Store the telemetry groups and destination for every worker, applied from the next report window
+ */
+ put: operations["update_telemetry_settings_telemetry_settings_put"];
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/telemetry/ui_events": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Record Ui Event
+ * @description Count one Admin UI page view or tab click. Events are dropped unless Page navigation is on
+ */
+ post: operations["record_ui_event_telemetry_ui_events_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/telemetry/ui_events/enabled": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /** Ui Events Enabled */
+ get: operations["ui_events_enabled_telemetry_ui_events_enabled_get"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/test": {
parameters: {
query?: never;
@@ -20293,6 +20398,26 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/v1/live/sessions": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * WebSocket: live_sessions_websocket_endpoint
+ * @description WebSocket connection endpoint
+ */
+ get: operations["websocket_live_sessions_websocket_endpoint_get_2"];
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/v1/logs": {
parameters: {
query?: never;
@@ -27056,6 +27181,11 @@ export interface components {
date: string;
/** Description */
description: string;
+ /**
+ * Tags
+ * @default []
+ */
+ tags: string[];
/** Title */
title: string;
/** Url */
@@ -29601,7 +29731,7 @@ export interface components {
enable_public_model_hub: boolean;
/**
* Enforce Fallback Model Access
- * @description If True, router fallbacks configured in router_settings are only attempted when the calling key (and its team and project) is allowed to call the fallback model; unauthorized fallback targets are skipped and the primary model's error is returned. Default is False.
+ * @description If True, router fallbacks configured in router_settings are only attempted when the calling key (and its team and project) is allowed to call the fallback model; unauthorized fallback targets are skipped and the primary model's error is returned. Default is True. Set False to disable fallback model access enforcement.
*/
enforce_fallback_model_access?: boolean | null;
/**
@@ -36938,6 +37068,10 @@ export interface components {
dcr_server_url?: string | null;
/** Id Jag Resource */
id_jag_resource?: string | null;
+ /** Id Jag Resource Client Id */
+ id_jag_resource_client_id?: string | null;
+ /** Id Jag Resource Client Secret */
+ id_jag_resource_client_secret?: string | null;
/** Id Jag Resource Token Endpoint */
id_jag_resource_token_endpoint?: string | null;
/** Redirect Uris */
@@ -37636,6 +37770,8 @@ export interface components {
reasoning_content?: string | null;
/** Reasoning Items */
reasoning_items?: components["schemas"]["ChatCompletionReasoningItem"][] | null;
+ /** Refusal */
+ refusal?: string | null;
/**
* Role
* @enum {string}
@@ -40111,6 +40247,8 @@ export interface components {
homepage?: string | null;
/** Id */
id: string;
+ /** Installation Preference */
+ installation_preference?: ("available" | "auto_install" | "required") | null;
/** Keywords */
keywords?: string[] | null;
/** Name */
@@ -40146,6 +40284,11 @@ export interface components {
* @description Plugin unique ID
*/
id: string;
+ /**
+ * Installation Preference
+ * @description Plugin installation preference
+ */
+ installation_preference?: ("available" | "auto_install" | "required") | null;
/**
* Name
* @description Plugin name
@@ -41564,6 +41707,11 @@ export interface components {
* @description Plugin homepage URL
*/
homepage?: string | null;
+ /**
+ * Installation Preference
+ * @description Emitted as installationPreference on this plugin's marketplace.json entry. Needs an archive source, the only plugins Claude Desktop lists from a marketplace.json URL, and 'auto_install' or 'required' also need its sha256. Claude Desktop honors the mark only when the archive and marketplace.json share its inference gateway origin
+ */
+ installation_preference?: ("available" | "auto_install" | "required") | null;
/**
* Keywords
* @description Search keywords
@@ -45272,6 +45420,16 @@ export interface components {
*/
user_ids: string[];
};
+ /** StoredReport */
+ StoredReport: {
+ /** Id */
+ id: string;
+ report: components["schemas"]["JsonValue"];
+ /** Window End */
+ window_end: number;
+ /** Window Start */
+ window_start: number;
+ };
/**
* SuccessfulKeyUpdate
* @description Successfully updated key with its updated information
@@ -46091,6 +46249,71 @@ export interface components {
/** User Id */
user_id: string;
};
+ /**
+ * TelemetryDestination
+ * @enum {string}
+ */
+ TelemetryDestination: "local_table" | "http";
+ /**
+ * TelemetryGroup
+ * @description A telemetry group describes a group of metrics that can be toggled on/off separately. One
+ * group may have dependencies on another.
+ * @enum {string}
+ */
+ TelemetryGroup: "heartbeat" | "request_success" | "token_info" | "request_taxonomy" | "event_details" | "instance_configuration" | "page_navigation";
+ /** TelemetryGroupInfo */
+ TelemetryGroupInfo: {
+ /** Enabled */
+ enabled: boolean;
+ group: components["schemas"]["TelemetryGroup"];
+ requires: components["schemas"]["TelemetryGroup"] | null;
+ };
+ /** TelemetryReportsResponse */
+ TelemetryReportsResponse: {
+ /** Next After */
+ next_after: number | null;
+ /** Next After Id */
+ next_after_id: string | null;
+ /** Reports */
+ reports: components["schemas"]["StoredReport"][];
+ };
+ /** TelemetrySettingsResponse */
+ TelemetrySettingsResponse: {
+ /** Available Destinations */
+ available_destinations: components["schemas"]["TelemetryDestination"][];
+ destination: components["schemas"]["TelemetryDestination"] | null;
+ /** Destination Set By Environment */
+ destination_set_by_environment: boolean;
+ /** Editable */
+ editable: boolean;
+ /** Environment Variables */
+ environment_variables: string[];
+ /** Flush Interval Seconds */
+ flush_interval_seconds: number;
+ /** Groups */
+ groups: components["schemas"]["TelemetryGroupInfo"][];
+ /** Report */
+ report: {
+ [key: string]: components["schemas"]["JsonValue"];
+ } | null;
+ /** Report Is Sample */
+ report_is_sample: boolean;
+ /** Retention Days */
+ retention_days: number;
+ /** Set By Environment */
+ set_by_environment: boolean;
+ stored_destination: components["schemas"]["TelemetryDestination"] | null;
+ /** Stored Groups */
+ stored_groups: components["schemas"]["TelemetryGroup"][] | null;
+ /** Vetoed */
+ vetoed: boolean;
+ };
+ /** TelemetrySettingsUpdate */
+ TelemetrySettingsUpdate: {
+ destination?: components["schemas"]["TelemetryDestination"] | null;
+ /** Groups */
+ groups: string[];
+ };
/**
* TestCustomCodeGuardrailRequest
* @description Request model for testing custom code guardrails.
@@ -47076,6 +47299,33 @@ export interface components {
} & {
[key: string]: unknown;
};
+ /**
+ * UIAction
+ * @enum {string}
+ */
+ UIAction: "view" | "click" | "submit";
+ /** UIEventAccepted */
+ UIEventAccepted: {
+ /**
+ * Accepted
+ * @default true
+ * @constant
+ */
+ accepted: true;
+ };
+ /** UIEventBody */
+ UIEventBody: {
+ action: components["schemas"]["UIAction"];
+ /** Page */
+ page: string;
+ /** Target */
+ target?: string | null;
+ };
+ /** UIEventsEnabledResponse */
+ UIEventsEnabledResponse: {
+ /** Enabled */
+ enabled: boolean;
+ };
/** UIField */
UIField: {
/** Key */
@@ -47619,6 +47869,11 @@ export interface components {
* @description Plugin homepage URL
*/
homepage?: string | null;
+ /**
+ * Installation Preference
+ * @description Emitted as installationPreference on this plugin's marketplace.json entry. Needs an archive source, the only plugins Claude Desktop lists from a marketplace.json URL, and 'auto_install' or 'required' also need its sha256. Claude Desktop honors the mark only when the archive and marketplace.json share its inference gateway origin
+ */
+ installation_preference?: ("available" | "auto_install" | "required") | null;
/**
* Keywords
* @description Search keywords
@@ -49281,30 +49536,6 @@ export interface components {
type: "web_search" | "web_search_2025_08_26";
user_location?: components["schemas"]["openai__types__responses__web_search_tool_param__UserLocation"] | null;
};
- /**
- * WhatsNewLaunch
- * @description One launch card in the dashboard Home page's What's new section.
- */
- WhatsNewLaunch: {
- /** Description */
- description: string;
- /** Href */
- href: string;
- /** Icon */
- icon: string;
- /**
- * Published On
- * Format: date
- */
- published_on: string;
- /** Title */
- title: string;
- };
- /** WhatsNewResponse */
- WhatsNewResponse: {
- /** Launches */
- launches: components["schemas"]["WhatsNewLaunch"][];
- };
/** WorkerRegistryEntry */
WorkerRegistryEntry: {
/** Name */
@@ -62830,6 +63061,24 @@ export interface operations {
};
};
};
+ websocket_live_sessions_websocket_endpoint_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description WebSocket Protocol Switched */
+ 101: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
login_login_post: {
parameters: {
query?: never;
@@ -65441,6 +65690,24 @@ export interface operations {
};
};
};
+ websocket_live_sessions_websocket_endpoint_get_3: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description WebSocket Protocol Switched */
+ 101: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
websocket_realtime_websocket_endpoint_get_3: {
parameters: {
query?: never;
@@ -68725,26 +68992,6 @@ export interface operations {
};
};
};
- get_public_whats_new_public_whats_new_get: {
- parameters: {
- query?: never;
- header?: never;
- path?: never;
- cookie?: never;
- };
- requestBody?: never;
- responses: {
- /** @description Successful Response */
- 200: {
- headers: {
- [name: string]: unknown;
- };
- content: {
- "application/json": components["schemas"]["WhatsNewResponse"];
- };
- };
- };
- };
async_queue_request_queue_chat_completions_post: {
parameters: {
query?: {
@@ -71310,6 +71557,22 @@ export interface operations {
*/
content_type: string;
})[];
+ /**
+ * Images
+ * @description Images as data URLs or content_type plus base64, for providers that look at images
+ */
+ images?: (string | {
+ /**
+ * Base64
+ * @description Base64-encoded image bytes
+ */
+ base64: string;
+ /**
+ * Content Type
+ * @description An image/* media type, such as image/png
+ */
+ content_type: string;
+ })[] | null;
/**
* Model
* @description A decision model from the proxy model_list
@@ -73267,6 +73530,147 @@ export interface operations {
};
};
};
+ export_telemetry_reports_telemetry_reports_get: {
+ parameters: {
+ query?: {
+ /** @description window_end of the last report already exported */
+ after?: number;
+ /** @description id of the last report already exported */
+ after_id?: string;
+ limit?: number;
+ };
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["TelemetryReportsResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ get_telemetry_settings_telemetry_settings_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["TelemetrySettingsResponse"];
+ };
+ };
+ };
+ };
+ update_telemetry_settings_telemetry_settings_put: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["TelemetrySettingsUpdate"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["TelemetrySettingsResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ record_ui_event_telemetry_ui_events_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody: {
+ content: {
+ "application/json": components["schemas"]["UIEventBody"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UIEventAccepted"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
+ ui_events_enabled_telemetry_ui_events_enabled_get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Successful Response */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["UIEventsEnabledResponse"];
+ };
+ };
+ };
+ };
test_endpoint_test_get: {
parameters: {
query?: never;
@@ -77697,6 +78101,24 @@ export interface operations {
};
};
};
+ websocket_live_sessions_websocket_endpoint_get_2: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description WebSocket Protocol Switched */
+ 101: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content?: never;
+ };
+ };
+ };
ingest_otlp_traces_v1_logs_post: {
parameters: {
query?: never;
@@ -80015,6 +80437,22 @@ export interface operations {
*/
content_type: string;
})[];
+ /**
+ * Images
+ * @description Images as data URLs or content_type plus base64, for providers that look at images
+ */
+ images?: (string | {
+ /**
+ * Base64
+ * @description Base64-encoded image bytes
+ */
+ base64: string;
+ /**
+ * Content Type
+ * @description An image/* media type, such as image/png
+ */
+ content_type: string;
+ })[] | null;
/**
* Model
* @description A decision model from the proxy model_list
diff --git a/ui/litellm-dashboard/src/lib/telemetry/uiEvents.test.ts b/ui/litellm-dashboard/src/lib/telemetry/uiEvents.test.ts
new file mode 100644
index 00000000000..bab06af70f7
--- /dev/null
+++ b/ui/litellm-dashboard/src/lib/telemetry/uiEvents.test.ts
@@ -0,0 +1,58 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { queryClient } from "@/contexts/ReactQueryProvider";
+import { backgroundFetchClient } from "@/lib/http/api";
+import { pageNavigationQuery, recordUiEvent } from "./uiEvents";
+
+const enabledResponse = (enabled: boolean) => ({ data: { enabled }, response: new Response() });
+
+describe("recordUiEvent", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ queryClient.clear();
+ });
+
+ it("sends nothing while the proxy says page navigation is off", async () => {
+ vi.spyOn(backgroundFetchClient, "GET").mockResolvedValue(enabledResponse(false) as never);
+ const post = vi
+ .spyOn(backgroundFetchClient, "POST")
+ .mockResolvedValue({ data: undefined, response: new Response() });
+
+ await recordUiEvent({ page: "teams", action: "view" });
+
+ expect(post).not.toHaveBeenCalled();
+ });
+
+ it("asks the proxy once per window and posts every event while page navigation is on", async () => {
+ const get = vi.spyOn(backgroundFetchClient, "GET").mockResolvedValue(enabledResponse(true) as never);
+ const post = vi
+ .spyOn(backgroundFetchClient, "POST")
+ .mockResolvedValue({ data: undefined, response: new Response() });
+
+ await recordUiEvent({ page: "teams", action: "view" });
+ await recordUiEvent({ page: "teams", action: "click", target: "tab=members" });
+
+ expect(get).toHaveBeenCalledOnce();
+ expect(post).toHaveBeenLastCalledWith("/telemetry/ui_events", {
+ body: { page: "teams", action: "click", target: "tab=members" },
+ });
+ expect(post).toHaveBeenCalledTimes(2);
+ });
+
+ it("asks the proxy again once the cached answer is older than the report window", async () => {
+ vi.useFakeTimers();
+ const get = vi.spyOn(backgroundFetchClient, "GET").mockResolvedValue(enabledResponse(false) as never);
+
+ await recordUiEvent({ page: "teams", action: "view" });
+ vi.advanceTimersByTime(pageNavigationQuery.staleTime + 1);
+ await recordUiEvent({ page: "teams", action: "view" });
+
+ vi.useRealTimers();
+ expect(get).toHaveBeenCalledTimes(2);
+ });
+
+ it("swallows a failed check or post so telemetry never breaks the page", async () => {
+ vi.spyOn(backgroundFetchClient, "GET").mockRejectedValue(new Error("offline"));
+
+ await expect(recordUiEvent({ page: "teams", action: "view" })).resolves.toBeUndefined();
+ });
+});
diff --git a/ui/litellm-dashboard/src/lib/telemetry/uiEvents.ts b/ui/litellm-dashboard/src/lib/telemetry/uiEvents.ts
new file mode 100644
index 00000000000..8560852ebd7
--- /dev/null
+++ b/ui/litellm-dashboard/src/lib/telemetry/uiEvents.ts
@@ -0,0 +1,24 @@
+import { queryClient } from "@/contexts/ReactQueryProvider";
+import { backgroundFetchClient } from "@/lib/http/api";
+import type { components } from "@/lib/http/schema";
+
+export type UiEvent = components["schemas"]["UIEventBody"];
+
+export const TELEMETRY_WINDOW_MS = 300_000;
+
+export const pageNavigationQuery = {
+ queryKey: ["telemetry", "pageNavigationEnabled"],
+ queryFn: async (): Promise => {
+ const { data } = await backgroundFetchClient.GET("/telemetry/ui_events/enabled");
+ return data?.enabled ?? false;
+ },
+ staleTime: TELEMETRY_WINDOW_MS,
+ retry: false,
+} as const;
+
+export const recordUiEvent = (event: UiEvent): Promise =>
+ queryClient
+ .fetchQuery(pageNavigationQuery)
+ .then((enabled) => (enabled ? backgroundFetchClient.POST("/telemetry/ui_events", { body: event }) : undefined))
+ .then(() => undefined)
+ .catch(() => undefined);
diff --git a/uv.lock b/uv.lock
index 471460a32b5..922f00b9a1d 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,5 +1,5 @@
version = 1
-revision = 3
+revision = 5
requires-python = ">=3.10, <3.15"
resolution-markers = [
"python_full_version >= '3.14'",
@@ -4500,7 +4500,7 @@ wheels = [
[[package]]
name = "litellm"
-version = "1.106.0"
+version = "1.107.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -4996,12 +4996,12 @@ provides-extras = ["test"]
[[package]]
name = "litellm-enterprise"
-version = "0.1.76"
+version = "0.1.77"
source = { editable = "enterprise" }
[[package]]
name = "litellm-proxy-extras"
-version = "0.4.108"
+version = "0.4.109"
source = { editable = "litellm-proxy-extras" }
dependencies = [
{ name = "psycopg" },
@@ -5532,7 +5532,7 @@ wheels = [
[[package]]
name = "mlflow"
-version = "3.15.0"
+version = "3.17.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
@@ -5559,16 +5559,17 @@ dependencies = [
{ name = "sqlalchemy" },
{ name = "waitress", marker = "sys_platform == 'win32'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/af/9f/249b80849aae0919aee3f590003419c394f0efe92ae98546ea626be86e1e/mlflow-3.15.0.tar.gz", hash = "sha256:c53d7933391fefda2ac0f6b942d1744a02b5b65da74a93cc125247f4c4da57c9", size = 10395873, upload-time = "2026-07-31T07:06:00.211Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c7/28/ea097011a17f49ad1b2f6371d9acd2d609f1d2ccd363c0f002cb26171b74/mlflow-3.17.0.tar.gz", hash = "sha256:7c1566b1aca61aec58520516df085948874c7956798dd6ae8ca17560ce06b089", size = 11146015, upload-time = "2026-10-07T06:07:42.286Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/0e/7a/3da40be70e7ca0c4d8c2f98edfcb275ef2898d8f4da565f55a9fff95fc78/mlflow-3.15.0-py3-none-any.whl", hash = "sha256:3ae54c7f91a6b98ae9360aaeda9b31eb7930571c2690491a7b550c84f795a709", size = 11188317, upload-time = "2026-07-31T07:05:57.492Z" },
+ { url = "https://files.pythonhosted.org/packages/74/4b/64533cd38fa188104011c028d04e056c58bc0a9f5059dbcce61079dd765b/mlflow-3.17.0-py3-none-any.whl", hash = "sha256:3954eaa998d4dd4ac8772894293a285bb5e3a428f6f9b37cac298e787423eafd", size = 11989296, upload-time = "2026-10-07T06:07:39.737Z" },
]
[[package]]
name = "mlflow-skinny"
-version = "3.15.0"
+version = "3.17.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
+ { name = "anyio" },
{ name = "cachetools" },
{ name = "click" },
{ name = "cloudpickle" },
@@ -5590,14 +5591,14 @@ dependencies = [
{ name = "typing-extensions" },
{ name = "uvicorn" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/53/a1/04489b8f10ec642cc5d16d8bcf0e3626cbe6b8924940b510de9e3cb37ac7/mlflow_skinny-3.15.0.tar.gz", hash = "sha256:6faad0bb5b0b8adc08550c6cd2ab5a87f9e7d7fe8814278e22b79f9cac99d943", size = 3034359, upload-time = "2026-07-31T07:04:58.359Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/18/5a/dea201b3cec8a3a1d97b042684113df54e79681f4bc051e807a13dd164b2/mlflow_skinny-3.17.0.tar.gz", hash = "sha256:e887675d9f5ab57ec70860247b1cd5682bb719f626c69979ca7c99b5439877df", size = 3472600, upload-time = "2026-10-07T05:46:44.897Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/28/ca/b0194962614876abb8b9a4d38815b4c6bf5c6f626b5dd3bcd321048755fc/mlflow_skinny-3.15.0-py3-none-any.whl", hash = "sha256:fc609ea5f7325a76d6f905489d8be73e85bcba0eef64144a763cf446de83192e", size = 3612199, upload-time = "2026-07-31T07:04:56.564Z" },
+ { url = "https://files.pythonhosted.org/packages/17/36/6c4cb2aa855061705f0295228654cfca564a0a2425d5e5484f187e4aa44d/mlflow_skinny-3.17.0-py3-none-any.whl", hash = "sha256:443c1e8cbf04edc71434754e0dae2846db91814b342c65bbc05b131c2a68b7ba", size = 4090651, upload-time = "2026-10-07T05:46:42.447Z" },
]
[[package]]
name = "mlflow-tracing"
-version = "3.15.0"
+version = "3.17.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cachetools" },
@@ -5609,9 +5610,9 @@ dependencies = [
{ name = "protobuf" },
{ name = "pydantic" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/7b/47/8a3c4f56199aadce644256684060df09f0af6ecaec460860657f2cb13b10/mlflow_tracing-3.15.0.tar.gz", hash = "sha256:e4e73243eed04d0771954b6fa17014dd21dabc67af17d4937fac2b62ec476e95", size = 1500398, upload-time = "2026-07-31T07:05:54.445Z" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/8b/8115b256c748855f1614909dcacac12c2fdb6de7e31759936ca36d92a43e/mlflow_tracing-3.17.0.tar.gz", hash = "sha256:6259dc0149bb681d6738c98f861f02db43c87c18e0a20a7ec9cbce1b7f386512", size = 1686449, upload-time = "2026-10-07T05:47:34.94Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/76/3c/5839690c3fe68f579074080832cba413f988249ddd350dcf7dfc3be1b874/mlflow_tracing-3.15.0-py3-none-any.whl", hash = "sha256:3762f654a858474c7988c0a2a5246765b173fa2b6a2bb8f035c115db1a8e3a6a", size = 1784776, upload-time = "2026-07-31T07:05:52.937Z" },
+ { url = "https://files.pythonhosted.org/packages/04/bf/b5b4d00ec9b48f0a735f5d514d0c5e2ee339a21d251de6f864cfe32dcdee/mlflow_tracing-3.17.0-py3-none-any.whl", hash = "sha256:8e5cb21bd53720f163098fe12a54e2cc5dff19e5b811889205ef10469248ee9e", size = 1978708, upload-time = "2026-10-07T05:47:33.381Z" },
]
[[package]]