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)/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/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/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 (
, 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/page_metadata.ts b/ui/litellm-dashboard/src/components/page_metadata.ts
index 72bed431421..18f136c3b28 100644
--- a/ui/litellm-dashboard/src/components/page_metadata.ts
+++ b/ui/litellm-dashboard/src/components/page_metadata.ts
@@ -43,6 +43,7 @@ export const pageDescriptions: Record = {
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/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/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 adf020ca96a..3b018db7f9b 100644
--- a/ui/litellm-dashboard/src/lib/http/schema.d.ts
+++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts
@@ -9142,6 +9142,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 +10842,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 +13494,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 +13701,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 +17496,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 +20393,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 +27176,11 @@ export interface components {
date: string;
/** Description */
description: string;
+ /**
+ * Tags
+ * @default []
+ */
+ tags: string[];
/** Title */
title: string;
/** Url */
@@ -37636,6 +37761,8 @@ export interface components {
reasoning_content?: string | null;
/** Reasoning Items */
reasoning_items?: components["schemas"]["ChatCompletionReasoningItem"][] | null;
+ /** Refusal */
+ refusal?: string | null;
/**
* Role
* @enum {string}
@@ -45272,6 +45399,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 +46228,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 +47278,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 */
@@ -49281,30 +49510,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 +63035,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 +65664,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 +68966,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?: {
@@ -73267,6 +73488,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 +78059,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;
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]]