From f8ef5a80e7482185abc1060874a650125d4b8960 Mon Sep 17 00:00:00 2001 From: Krish Dholakia Date: Wed, 18 Feb 2026 19:53:10 -0800 Subject: [PATCH] Feat/customer dashboard (#21532) * feat: add customer dashboard to UI Add a comprehensive customer management dashboard following the Teams page pattern. Features: - Customer list view with filtering and search - Create new customers with budget, model, and region settings - Edit customer details including blocking/unblocking - Delete customers with confirmation - View customer spend and budget information - Filter by status (active/blocked) and region (US/EU) Components: - CustomersView: Main view component with state management - CustomersTable: Table component displaying customer data - CustomersFilters: Search and filter controls - CustomersHeaderTabs: Tab navigation with refresh - Modals: Create, Edit, and Delete customer modals - useFetchCustomers: Custom hook for data fetching API Integration: - Added customerCreateCall, customerUpdateCall, customerDeleteCall - Added Customer interface type definition - Integrated with existing allEndUsersCall Co-Authored-By: Claude Opus 4.6 * feat(ui/): add new customer dashboard to the ui allows seeing all the end users/customers that have been created on litellm visibility --------- Co-authored-by: Claude Opus 4.6 --- .../(dashboard)/customers/CustomersView.tsx | 226 ++++++++++++ .../components/CustomerFormFields.tsx | 91 +++++ .../customers/components/CustomerInfo.tsx | 326 ++++++++++++++++++ .../customers/components/CustomersFilters.tsx | 88 +++++ .../components/CustomersHeaderTabs.tsx | 43 +++ .../customers/components/CustomersTable.tsx | 142 ++++++++ .../components/modals/CreateCustomerModal.tsx | 198 +++++++++++ .../components/modals/CustomerInfoModal.tsx | 167 +++++++++ .../components/modals/DeleteCustomerModal.tsx | 52 +++ .../customers/hooks/useFetchCustomers.ts | 36 ++ .../src/app/(dashboard)/customers/page.tsx | 45 +++ .../src/app/(dashboard)/customers/types.ts | 36 ++ ui/litellm-dashboard/src/app/page.tsx | 3 + .../src/components/leftnav.tsx | 12 + .../src/components/networking.tsx | 105 ++++++ 15 files changed, 1570 insertions(+) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/customers/CustomersView.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/customers/components/CustomerFormFields.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/customers/components/CustomerInfo.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/customers/components/CustomersFilters.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/customers/components/CustomersHeaderTabs.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/customers/components/CustomersTable.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/customers/components/modals/CreateCustomerModal.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/customers/components/modals/CustomerInfoModal.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/customers/components/modals/DeleteCustomerModal.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/customers/hooks/useFetchCustomers.ts create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/customers/page.tsx create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/customers/types.ts diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/CustomersView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/customers/CustomersView.tsx new file mode 100644 index 00000000000..ccd4130ff49 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/CustomersView.tsx @@ -0,0 +1,226 @@ +import React, { useState } from "react"; +import { Button, Card, TabPanel, Text } from "@tremor/react"; +import { RefreshCw } from "lucide-react"; +import CustomersTable from "@/app/(dashboard)/customers/components/CustomersTable"; +import CustomerInfo from "@/app/(dashboard)/customers/components/CustomerInfo"; +import CreateCustomerModal from "@/app/(dashboard)/customers/components/modals/CreateCustomerModal"; +import DeleteCustomerModal from "@/app/(dashboard)/customers/components/modals/DeleteCustomerModal"; +import CustomersHeaderTabs from "@/app/(dashboard)/customers/components/CustomersHeaderTabs"; +import CustomersFilters from "@/app/(dashboard)/customers/components/CustomersFilters"; +import type { Customer, NewCustomerData } from "@/app/(dashboard)/customers/types"; +import { customerDeleteCall } from "@/components/networking"; + +interface CustomersViewProps { + customers: Customer[]; + setCustomers: React.Dispatch>; + accessToken: string | null; + userID: string | null; + userRole: string | null; + isLoading: boolean; +} + +interface FilterState { + user_id: string; + alias: string; + blocked: string; + region: string; +} + +const CustomersView: React.FC = ({ + customers, + setCustomers, + accessToken, + userID, + userRole, + isLoading, +}) => { + const [showFilters, setShowFilters] = useState(false); + const [filters, setFilters] = useState({ + user_id: "", + alias: "", + blocked: "", + region: "", + }); + + const [showCreateModal, setShowCreateModal] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [selectedCustomer, setSelectedCustomer] = useState(null); + const [selectedCustomerId, setSelectedCustomerId] = useState(null); + const [customerDetailDefaultTab, setCustomerDetailDefaultTab] = useState<"overview" | "settings">("overview"); + const [lastRefreshed, setLastRefreshed] = useState(new Date().toLocaleString("en-US")); + + const handleCreateCustomer = async () => { + if (!accessToken) return; + try { + const { allEndUsersCall } = await import("@/components/networking"); + const listData = await allEndUsersCall(accessToken); + if (listData) { + setCustomers(Array.isArray(listData) ? listData : []); + } + setShowCreateModal(false); + } catch (error) { + console.error("Error refreshing customers:", error); + } + }; + + const handleEditCustomer = (customer: Customer) => { + setSelectedCustomerId(customer.user_id); + setSelectedCustomer(customer); + setCustomerDetailDefaultTab("settings"); + }; + + const handleViewInfo = (customer: Customer) => { + setSelectedCustomerId(customer.user_id); + setSelectedCustomer(customer); + setCustomerDetailDefaultTab("overview"); + }; + + const handleCloseCustomerInfo = () => { + setSelectedCustomerId(null); + setSelectedCustomer(null); + }; + + const handleUpdateCustomer = (updated: Customer) => { + setCustomers((prev) => + prev.map((c) => (c.user_id === updated.user_id ? updated : c)) + ); + setSelectedCustomer(updated); + }; + + const handleDeleteClick = (customer: Customer) => { + setSelectedCustomer(customer); + setShowDeleteModal(true); + }; + + const handleConfirmDelete = async () => { + if (!selectedCustomer || !accessToken) return; + + try { + await customerDeleteCall(accessToken, selectedCustomer.user_id); + setCustomers( + customers.filter((c) => c.user_id !== selectedCustomer.user_id) + ); + setShowDeleteModal(false); + setSelectedCustomer(null); + } catch (error) { + console.error("Error deleting customer:", error); + } + }; + + const handleRefresh = () => { + setLastRefreshed(new Date().toLocaleString("en-US")); + // Trigger a re-fetch if needed + }; + + const handleFilterChange = (key: keyof FilterState, value: string) => { + setFilters({ ...filters, [key]: value }); + }; + + const handleFilterReset = () => { + setFilters({ + user_id: "", + alias: "", + blocked: "", + region: "", + }); + }; + + const filteredCustomers = customers.filter((c) => { + const matchesUserId = !filters.user_id || c.user_id.toLowerCase().includes(filters.user_id.toLowerCase()); + const matchesAlias = !filters.alias || (c.alias && c.alias.toLowerCase().includes(filters.alias.toLowerCase())); + const matchesBlocked = + !filters.blocked || + (filters.blocked === "active" && !c.blocked) || + (filters.blocked === "blocked" && c.blocked); + const matchesRegion = !filters.region || c.allowed_model_region === filters.region; + + return matchesUserId && matchesAlias && matchesBlocked && matchesRegion; + }); + + const initialCustomerForDetail = + selectedCustomerId && selectedCustomer?.user_id === selectedCustomerId + ? selectedCustomer + : customers.find((c) => c.user_id === selectedCustomerId) ?? null; + + if (selectedCustomerId) { + return ( +
+ +
+ ); + } + + return ( +
+
+ {(userRole === "Admin" || userRole === "Org Admin") && ( + + )} + + Customers are end-users of an AI application (e.g. users of your internal chat UI). + + + + + + Click on “Customer ID” to view customer details and manage settings. + + +
+ handleFilterChange(key as keyof FilterState, value)} + onReset={handleFilterReset} + /> +
+
+ +
+
+
+
+ + {(userRole === "Admin" || userRole === "Org Admin") && ( + <> + setShowCreateModal(false)} + onCreate={handleCreateCustomer} + /> + { + setShowDeleteModal(false); + setSelectedCustomer(null); + }} + onConfirm={handleConfirmDelete} + customerName={selectedCustomer?.alias || ""} + customerId={selectedCustomer?.user_id || ""} + /> + + )} +
+
+ ); +}; + +export default CustomersView; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/components/CustomerFormFields.tsx b/ui/litellm-dashboard/src/app/(dashboard)/customers/components/CustomerFormFields.tsx new file mode 100644 index 00000000000..718891578eb --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/components/CustomerFormFields.tsx @@ -0,0 +1,91 @@ +import React from "react"; +import { Form, Input, InputNumber, Select as AntSelect, Switch } from "antd"; +import type { FormInstance } from "antd"; + +interface CustomerFormFieldsProps { + form: FormInstance; + mode: "create" | "edit"; + disabledFields?: string[]; +} + +const defaultModelOptions = [ + { value: "", label: "None" }, + { value: "gpt-4o", label: "gpt-4o" }, + { value: "gpt-4o-mini", label: "gpt-4o-mini" }, + { value: "gpt-4-turbo", label: "gpt-4-turbo" }, + { value: "claude-3-sonnet", label: "claude-3-sonnet" }, + { value: "claude-3-opus", label: "claude-3-opus" }, + { value: "claude-3-haiku", label: "claude-3-haiku" }, +]; + +const regionOptions = [ + { value: "", label: "Any region" }, + { value: "us", label: "US" }, + { value: "eu", label: "EU" }, +]; + +const CustomerFormFields: React.FC = ({ + form, + mode, + disabledFields = [], +}) => { + const isDisabled = (field: string) => disabledFields.includes(field); + + return ( + <> + + + + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + {mode === "edit" && ( + +
+ + + {form.getFieldValue("blocked") + ? "This customer is currently blocked from making requests" + : "This customer can make requests normally"} + +
+
+ )} + + ); +}; + +export default CustomerFormFields; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/components/CustomerInfo.tsx b/ui/litellm-dashboard/src/app/(dashboard)/customers/components/CustomerInfo.tsx new file mode 100644 index 00000000000..e66624aa5ef --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/components/CustomerInfo.tsx @@ -0,0 +1,326 @@ +import React, { useEffect, useState } from "react"; +import { Button, Form, Input, Tabs } from "antd"; +import { ArrowLeftIcon } from "@heroicons/react/outline"; +import { Badge, Card, Grid, Text, Title } from "@tremor/react"; +import { CheckIcon, CopyIcon } from "lucide-react"; +import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { customerUpdateCall } from "@/components/networking"; +import type { Customer } from "@/app/(dashboard)/customers/types"; +import CustomerFormFields from "@/app/(dashboard)/customers/components/CustomerFormFields"; +import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector"; +import AgentSelector from "@/components/agent_management/AgentSelector"; +import MCPToolPermissions from "@/components/mcp_server_management/MCPToolPermissions"; +import ObjectPermissionsView from "@/components/object_permissions_view"; + +export interface CustomerInfoProps { + customerId: string; + initialCustomer: Customer | null; + onClose: () => void; + onUpdate: (customer: Customer) => void; + accessToken: string | null; + userRole: string | null; + defaultTab?: "overview" | "settings"; +} + +const CustomerInfo: React.FC = ({ + customerId, + initialCustomer, + onClose, + onUpdate, + accessToken, + userRole, + defaultTab = "overview", +}) => { + const [customer, setCustomer] = useState(initialCustomer); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [copied, setCopied] = useState(false); + const [activeTab, setActiveTab] = useState(defaultTab); + + useEffect(() => { + setCustomer(initialCustomer); + }, [initialCustomer]); + + useEffect(() => { + if (customer) { + const op = customer.object_permission; + form.setFieldsValue({ + ...customer, + max_budget: customer.litellm_budget_table?.max_budget, + budget_duration: customer.litellm_budget_table?.budget_duration, + allowed_mcp_servers_and_groups: { + servers: op?.mcp_servers ?? [], + accessGroups: op?.mcp_access_groups ?? [], + }, + allowed_agents_and_groups: { + agents: op?.agents ?? [], + accessGroups: op?.agent_access_groups ?? [], + }, + mcp_tool_permissions: op?.mcp_tool_permissions ?? {}, + }); + } + }, [customer, form]); + + const copyToClipboard = async () => { + if (!customerId) return; + const success = await utilCopyToClipboard(customerId); + if (success) { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + const buildObjectPermission = (values: Record) => { + const objPerm: { + mcp_servers?: string[]; + mcp_access_groups?: string[]; + mcp_tool_permissions?: Record; + agents?: string[]; + agent_access_groups?: string[]; + } = {}; + const mcp = values.allowed_mcp_servers_and_groups; + if (mcp && (mcp.servers?.length > 0 || mcp.accessGroups?.length > 0)) { + if (mcp.servers?.length) objPerm.mcp_servers = mcp.servers; + if (mcp.accessGroups?.length) objPerm.mcp_access_groups = mcp.accessGroups; + } + if (values.mcp_tool_permissions && Object.keys(values.mcp_tool_permissions).length > 0) { + objPerm.mcp_tool_permissions = values.mcp_tool_permissions; + } + const agents = values.allowed_agents_and_groups; + if (agents && (agents.agents?.length > 0 || agents.accessGroups?.length > 0)) { + if (agents.agents?.length) objPerm.agents = agents.agents; + if (agents.accessGroups?.length) objPerm.agent_access_groups = agents.accessGroups; + } + return Object.keys(objPerm).length > 0 ? objPerm : undefined; + }; + + const handleSave = async () => { + if (!customer || !accessToken) return; + try { + const values = await form.validateFields(); + setLoading(true); + const { + allowed_mcp_servers_and_groups, + allowed_agents_and_groups, + mcp_tool_permissions, + ...rest + } = values; + const object_permission = buildObjectPermission(values); + const updated: Customer = { + ...customer, + ...rest, + ...(object_permission ? { object_permission } : {}), + }; + await customerUpdateCall(accessToken, updated); + setCustomer(updated); + onUpdate(updated); + } catch (error) { + console.error("Validation failed:", error); + } finally { + setLoading(false); + } + }; + + const canEdit = userRole === "Admin" || userRole === "Org Admin"; + + if (!customer && !initialCustomer) { + return ( +
+ + Customer not found. +
+ ); + } + + const c = customer ?? initialCustomer!; + const displayName = c.alias || c.user_id; + + return ( +
+
+
+ + {displayName} +
+ {c.user_id} +
+
+
+ + setActiveTab(key as "overview" | "settings")} + className="mb-4" + items={[ + { + key: "overview", + label: "Overview", + children: ( + + + Spend (USD) +
+ ${formatNumberWithCommas(c.spend, 4)} +
+
+ + Budget (USD) +
+ + {c.litellm_budget_table?.max_budget != null + ? `$${formatNumberWithCommas(c.litellm_budget_table.max_budget, 4)}` + : "No limit"} + + {c.litellm_budget_table?.budget_duration && ( + Duration: {c.litellm_budget_table.budget_duration} + )} +
+
+ + Default Model +
+ {c.default_model ? ( + {c.default_model} + ) : ( + + )} +
+
+ + Region +
+ {c.allowed_model_region ? c.allowed_model_region.toUpperCase() : "Any"} +
+
+ + Status +
+ {c.blocked ? ( + Blocked + ) : ( + Active + )} +
+
+ + +
+ ), + }, + ...(canEdit + ? [ + { + key: "settings", + label: "Settings", + children: ( + +
+ Customer Settings +
+
+ + + + + + + + +
+ MCP Servers / Access Groups + + form.setFieldValue("allowed_mcp_servers_and_groups", val)} + value={form.getFieldValue("allowed_mcp_servers_and_groups")} + accessToken={accessToken || ""} + placeholder="Select MCP servers or access groups (optional)" + /> + + + + prevValues.allowed_mcp_servers_and_groups !== currentValues.allowed_mcp_servers_and_groups || + prevValues.mcp_tool_permissions !== currentValues.mcp_tool_permissions + } + > + {() => ( +
+ form.setFieldsValue({ mcp_tool_permissions: toolPerms })} + /> +
+ )} +
+
+ +
+ Agents / Access Groups + + form.setFieldValue("allowed_agents_and_groups", val)} + value={form.getFieldValue("allowed_agents_and_groups")} + accessToken={accessToken || ""} + placeholder="Select agents or access groups (optional)" + /> + +
+ + + + + +
+ ), + }, + ] + : []), + ]} + /> +
+ ); +}; + +export default CustomerInfo; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/components/CustomersFilters.tsx b/ui/litellm-dashboard/src/app/(dashboard)/customers/components/CustomersFilters.tsx new file mode 100644 index 00000000000..27fc22adb11 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/components/CustomersFilters.tsx @@ -0,0 +1,88 @@ +import React from "react"; +import { Button, TextInput } from "@tremor/react"; +import { Filter, RotateCcw, Search } from "lucide-react"; +import { Select } from "antd"; + +interface CustomersFiltersProps { + filters: { + user_id: string; + alias: string; + blocked: string; + region: string; + }; + showFilters: boolean; + onToggleFilters: (show: boolean) => void; + onChange: (key: string, value: string) => void; + onReset: () => void; +} + +const CustomersFilters: React.FC = ({ + filters, + showFilters, + onToggleFilters, + onChange, + onReset, +}) => { + return ( +
+
+
+ + onChange("user_id", e.target.value)} + className="pl-10" + /> +
+ + +
+ + {showFilters && ( +
+
+ + +
+
+ + +
+
+ )} +
+ ); +}; + +export default CustomersFilters; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/components/CustomersHeaderTabs.tsx b/ui/litellm-dashboard/src/app/(dashboard)/customers/components/CustomersHeaderTabs.tsx new file mode 100644 index 00000000000..28bf7245dbe --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/components/CustomersHeaderTabs.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { Tab, TabGroup, TabList, TabPanels, Text } from "@tremor/react"; +import { RefreshCw } from "lucide-react"; + +interface CustomersHeaderTabsProps { + lastRefreshed: string; + onRefresh: () => void; + userRole: string | null; + children: React.ReactNode; +} + +const CustomersHeaderTabs: React.FC = ({ + lastRefreshed, + onRefresh, + userRole, + children, +}) => { + return ( + +
+ + Your Customers + {(userRole === "Admin" || userRole === "Org Admin") && ( + Customer Settings + )} + +
+ Last Refreshed: {lastRefreshed} + +
+
+ {children} +
+ ); +}; + +export default CustomersHeaderTabs; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/components/CustomersTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/customers/components/CustomersTable.tsx new file mode 100644 index 00000000000..165f927ba63 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/components/CustomersTable.tsx @@ -0,0 +1,142 @@ +import React from "react"; +import { + Button, + Icon, + Table, + TableBody, + TableCell, + TableHead, + TableHeaderCell, + TableRow, + Badge, + Text, +} from "@tremor/react"; +import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline"; +import { Tooltip } from "antd"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; +import type { Customer } from "@/app/(dashboard)/customers/types"; + +interface CustomersTableProps { + customers: Customer[]; + userRole: string | null; + onEdit: (customer: Customer) => void; + onDelete: (customer: Customer) => void; + onViewInfo: (customer: Customer) => void; + isLoading: boolean; +} + +const CustomersTable: React.FC = ({ + customers, + userRole, + onEdit, + onDelete, + onViewInfo, + isLoading, +}) => { + const truncateId = (id: string) => { + if (id.length <= 10) return id; + return id.substring(0, 7) + "..."; + }; + + if (isLoading) { + return ( +
+ Loading customers... +
+ ); + } + + return ( + + + + Customer Name + Customer ID + Spend (USD) + Budget (USD) + Default Model + Region + Status + {(userRole === "Admin" || userRole === "Org Admin") && ( + Actions + )} + + + + {customers.length === 0 ? ( + + + No customers found. + + + ) : ( + customers.map((customer) => ( + + {customer.alias || "—"} + + + + + + {formatNumberWithCommas(customer.spend, 4)} + + {customer.litellm_budget_table?.max_budget !== null && + customer.litellm_budget_table?.max_budget !== undefined + ? customer.litellm_budget_table.max_budget.toString() + : "No limit"} + + + {customer.default_model ? ( + {customer.default_model} + ) : ( + + )} + + + {customer.allowed_model_region ? ( + {customer.allowed_model_region.toUpperCase()} + ) : ( + + )} + + + {customer.blocked ? ( + Blocked + ) : ( + Active + )} + + {(userRole === "Admin" || userRole === "Org Admin") && ( + +
+ onEdit(customer)} + className="cursor-pointer" + /> + onDelete(customer)} + className="cursor-pointer" + /> +
+
+ )} +
+ )) + )} +
+
+ ); +}; + +export default CustomersTable; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/components/modals/CreateCustomerModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/customers/components/modals/CreateCustomerModal.tsx new file mode 100644 index 00000000000..1d944753bbc --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/components/modals/CreateCustomerModal.tsx @@ -0,0 +1,198 @@ +import React, { useEffect, useState } from "react"; +import { Modal, Form, Input } from "antd"; +import { Accordion, AccordionBody, AccordionHeader } from "@tremor/react"; +import { InfoCircleOutlined } from "@ant-design/icons"; +import { Tooltip } from "antd"; +import type { NewCustomerData } from "@/app/(dashboard)/customers/types"; +import CustomerFormFields from "@/app/(dashboard)/customers/components/CustomerFormFields"; +import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector"; +import AgentSelector from "@/components/agent_management/AgentSelector"; +import MCPToolPermissions from "@/components/mcp_server_management/MCPToolPermissions"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { customerCreateCall, fetchMCPAccessGroups } from "@/components/networking"; + +interface CreateCustomerModalProps { + isOpen: boolean; + onClose: () => void; + onCreate: (customer?: NewCustomerData) => void; +} + +const CreateCustomerModal: React.FC = ({ + isOpen, + onClose, + onCreate, +}) => { + const { accessToken } = useAuthorized(); + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + + useEffect(() => { + const loadMCPAccessGroups = async () => { + if (!accessToken) return; + try { + await fetchMCPAccessGroups(accessToken); + } catch (error) { + console.error("Failed to fetch MCP access groups:", error); + } + }; + if (isOpen) loadMCPAccessGroups(); + }, [accessToken, isOpen]); + + const buildObjectPermission = (values: Record) => { + const objPerm: { + mcp_servers?: string[]; + mcp_access_groups?: string[]; + mcp_tool_permissions?: Record; + agents?: string[]; + agent_access_groups?: string[]; + } = {}; + const mcp = values.allowed_mcp_servers_and_groups; + if (mcp && (mcp.servers?.length > 0 || mcp.accessGroups?.length > 0)) { + if (mcp.servers?.length) objPerm.mcp_servers = mcp.servers; + if (mcp.accessGroups?.length) objPerm.mcp_access_groups = mcp.accessGroups; + } + if (values.mcp_tool_permissions && Object.keys(values.mcp_tool_permissions).length > 0) { + objPerm.mcp_tool_permissions = values.mcp_tool_permissions; + } + const agents = values.allowed_agents_and_groups; + if (agents && (agents.agents?.length > 0 || agents.accessGroups?.length > 0)) { + if (agents.agents?.length) objPerm.agents = agents.agents; + if (agents.accessGroups?.length) objPerm.agent_access_groups = agents.accessGroups; + } + return Object.keys(objPerm).length > 0 ? objPerm : undefined; + }; + + const handleOk = async () => { + if (!accessToken) return; + try { + const values = await form.validateFields(); + setLoading(true); + const { allowed_mcp_servers_and_groups, allowed_agents_and_groups, mcp_tool_permissions, ...rest } = values; + const payload: any = { ...rest }; + const object_permission = buildObjectPermission(values); + if (object_permission) payload.object_permission = object_permission; + await customerCreateCall(accessToken, payload); + form.resetFields(); + onCreate(); + onClose(); + } catch (error) { + console.error("Validation failed:", error); + } finally { + setLoading(false); + } + }; + + const handleCancel = () => { + form.resetFields(); + onClose(); + }; + + return ( + +
+ + + + + + + + + + + + + MCP Settings + + + + Allowed MCP Servers{" "} + + + + + } + name="allowed_mcp_servers_and_groups" + initialValue={{ servers: [], accessGroups: [] }} + className="mt-4" + help="Select MCP servers or access groups this customer can access" + > + form.setFieldValue("allowed_mcp_servers_and_groups", val)} + value={form.getFieldValue("allowed_mcp_servers_and_groups")} + accessToken={accessToken || ""} + placeholder="Select MCP servers or access groups (optional)" + /> + + + + prevValues.allowed_mcp_servers_and_groups !== currentValues.allowed_mcp_servers_and_groups || + prevValues.mcp_tool_permissions !== currentValues.mcp_tool_permissions + } + > + {() => ( +
+ form.setFieldsValue({ mcp_tool_permissions: toolPerms })} + /> +
+ )} +
+
+
+ + + + Agent Settings + + + + Allowed Agents{" "} + + + + + } + name="allowed_agents_and_groups" + initialValue={{ agents: [], accessGroups: [] }} + className="mt-4" + help="Select agents or access groups this customer can access" + > + form.setFieldValue("allowed_agents_and_groups", val)} + value={form.getFieldValue("allowed_agents_and_groups")} + accessToken={accessToken || ""} + placeholder="Select agents or access groups (optional)" + /> + + + + +
+ ); +}; + +export default CreateCustomerModal; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/components/modals/CustomerInfoModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/customers/components/modals/CustomerInfoModal.tsx new file mode 100644 index 00000000000..9c1f40933cc --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/components/modals/CustomerInfoModal.tsx @@ -0,0 +1,167 @@ +import React, { useEffect, useState } from "react"; +import { Modal, Form, Input, InputNumber, Select as AntSelect, Switch } from "antd"; +import type { Customer } from "@/app/(dashboard)/customers/types"; + +interface CustomerInfoModalProps { + isOpen: boolean; + onClose: () => void; + customer: Customer | null; + onSave: (customer: Customer) => void; +} + +const CustomerInfoModal: React.FC = ({ + isOpen, + onClose, + customer, + onSave, +}) => { + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (customer) { + form.setFieldsValue({ + ...customer, + max_budget: customer.litellm_budget_table?.max_budget, + budget_duration: customer.litellm_budget_table?.budget_duration, + }); + } + }, [customer, form]); + + const handleOk = async () => { + if (!customer) return; + + try { + const values = await form.validateFields(); + setLoading(true); + onSave({ + ...customer, + ...values, + }); + } catch (error) { + console.error("Validation failed:", error); + } finally { + setLoading(false); + } + }; + + const handleCancel = () => { + form.resetFields(); + onClose(); + }; + + if (!customer) return null; + + return ( + +
+ + + + + + + + +
+ + + + + + + +
+ + + + + +
+ + + None + gpt-4o + gpt-4o-mini + gpt-4-turbo + claude-3-sonnet + claude-3-opus + claude-3-haiku + + + + + + Any region + US + EU + + +
+ + + + + + +
+ + + {form.getFieldValue("blocked") + ? "This customer is currently blocked from making requests" + : "This customer can make requests normally"} + +
+
+
+
+ ); +}; + +export default CustomerInfoModal; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/components/modals/DeleteCustomerModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/customers/components/modals/DeleteCustomerModal.tsx new file mode 100644 index 00000000000..6852e2b42e0 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/components/modals/DeleteCustomerModal.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { Modal } from "antd"; +import { AlertTriangle } from "lucide-react"; + +interface DeleteCustomerModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + customerName: string; + customerId: string; +} + +const DeleteCustomerModal: React.FC = ({ + isOpen, + onClose, + onConfirm, + customerName, + customerId, +}) => { + return ( + + + Delete Customer + + } + open={isOpen} + onOk={onConfirm} + onCancel={onClose} + okText="Delete" + okButtonProps={{ danger: true }} + cancelText="Cancel" + > +
+

+ Are you sure you want to delete this customer? +

+

+ This will permanently delete{" "} + + {customerName || customerId} + {" "} + ({customerId}). This action + cannot be undone. +

+
+
+ ); +}; + +export default DeleteCustomerModal; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/hooks/useFetchCustomers.ts b/ui/litellm-dashboard/src/app/(dashboard)/customers/hooks/useFetchCustomers.ts new file mode 100644 index 00000000000..42bab34dfc5 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/hooks/useFetchCustomers.ts @@ -0,0 +1,36 @@ +import { useCallback, useState } from "react"; +import { allEndUsersCall } from "@/components/networking"; + +interface UseFetchCustomersProps { + setCustomers: (customers: any[]) => void; +} + +const useFetchCustomers = ({ setCustomers }: UseFetchCustomersProps) => { + const [lastRefreshed, setLastRefreshed] = useState( + new Date().toLocaleString("en-US") + ); + + const onRefreshClick = useCallback( + async (accessToken: string | null) => { + if (!accessToken) return; + + try { + const data = await allEndUsersCall(accessToken); + if (data) { + setCustomers(data); + } + setLastRefreshed(new Date().toLocaleString("en-US")); + } catch (error) { + console.error("Error fetching customers:", error); + } + }, + [setCustomers] + ); + + return { + lastRefreshed, + onRefreshClick, + }; +}; + +export default useFetchCustomers; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/customers/page.tsx new file mode 100644 index 00000000000..782763bd615 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/page.tsx @@ -0,0 +1,45 @@ +"use client"; + +import CustomersView from "@/app/(dashboard)/customers/CustomersView"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import { useEffect, useState } from "react"; +import { allEndUsersCall, type Customer } from "@/components/networking"; + +const CustomersPage = () => { + const { accessToken, userId, userRole } = useAuthorized(); + const [customers, setCustomers] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const fetchCustomers = async () => { + if (!accessToken) return; + + setIsLoading(true); + try { + const response = await allEndUsersCall(accessToken); + if (response) { + setCustomers(Array.isArray(response) ? response : []); + } + } catch (error) { + console.error("Error fetching customers:", error); + } finally { + setIsLoading(false); + } + }; + + fetchCustomers(); + }, [accessToken]); + + return ( + + ); +}; + +export default CustomersPage; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/types.ts b/ui/litellm-dashboard/src/app/(dashboard)/customers/types.ts new file mode 100644 index 00000000000..704172aca41 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/types.ts @@ -0,0 +1,36 @@ +export interface CustomerObjectPermission { + mcp_servers?: string[]; + mcp_access_groups?: string[]; + mcp_tool_permissions?: Record; + agents?: string[]; + agent_access_groups?: string[]; +} + +export interface Customer { + user_id: string; + alias: string | null; + spend: number; + allowed_model_region: string | null; + default_model: string | null; + budget_id: string | null; + blocked: boolean; + max_budget?: number | null; + budget_duration?: string | null; + litellm_budget_table?: { + max_budget: number | null; + budget_duration: string | null; + } | null; + object_permission?: CustomerObjectPermission | null; +} + +export interface NewCustomerData { + user_id: string; + alias?: string; + max_budget?: string; + budget_id?: string; + default_model?: string; + allowed_model_region?: string; + budget_duration?: string; + metadata?: string; + object_permission?: CustomerObjectPermission; +} diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index ae3bd76e3cf..d4522646724 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -4,6 +4,7 @@ import APIReferenceView from "@/app/(dashboard)/api-reference/APIReferenceView"; import SidebarProvider from "@/app/(dashboard)/components/SidebarProvider"; import OldModelDashboard from "@/app/(dashboard)/models-and-endpoints/ModelsAndEndpointsView"; import PlaygroundPage from "@/app/(dashboard)/playground/page"; +import CustomersPage from "@/app/(dashboard)/customers/page"; import AdminPanel from "@/components/AdminPanel"; import AgentsPanel from "@/components/agents"; import BudgetPanel from "@/components/budgets/budget_panel"; @@ -459,6 +460,8 @@ function CreateKeyPageContent() { premiumUser={premiumUser} searchParams={searchParams} /> + ) : page == "customers" ? ( + ) : page == "organizations" ? ( , }, + { + key: "customers", + page: "customers", + label: ( + + Customers + + ), + icon: , + roles: all_admin_roles, + }, { key: "organizations", page: "organizations", diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 9d9ff575c8c..01dad184c59 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -194,6 +194,20 @@ export interface Organization { }; } +export interface Customer { + user_id: string; + alias: string | null; + spend: number; + allowed_model_region: string | null; + default_model: string | null; + budget_id: string | null; + blocked: boolean; + litellm_budget_table?: { + max_budget: number | null; + budget_duration: string | null; + } | null; +} + export interface CredentialItem { credential_name: string; credential_values: any; @@ -2511,6 +2525,97 @@ export const allEndUsersCall = async (accessToken: string) => { } }; +export const customerListCall = async (accessToken: string) => { + return allEndUsersCall(accessToken); +}; + +export const customerCreateCall = async (accessToken: string, data: any) => { + try { + let url = proxyBaseUrl ? `${proxyBaseUrl}/customer/new` : `/customer/new`; + + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + const responseData = await response.json(); + console.log("Customer created:", responseData); + return responseData; + } catch (error) { + console.error("Failed to create customer:", error); + throw error; + } +}; + +export const customerUpdateCall = async (accessToken: string, data: any) => { + try { + let url = proxyBaseUrl ? `${proxyBaseUrl}/customer/update` : `/customer/update`; + + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + const responseData = await response.json(); + console.log("Customer updated:", responseData); + return responseData; + } catch (error) { + console.error("Failed to update customer:", error); + throw error; + } +}; + +export const customerDeleteCall = async (accessToken: string, userId: string) => { + try { + let url = proxyBaseUrl ? `${proxyBaseUrl}/customer/delete` : `/customer/delete`; + + const response = await fetch(url, { + method: "POST", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ user_ids: [userId] }), + }); + + if (!response.ok) { + const errorData = await response.json(); + const errorMessage = deriveErrorMessage(errorData); + handleError(errorMessage); + throw new Error(errorMessage); + } + + const responseData = await response.json(); + console.log("Customer deleted:", responseData); + return responseData; + } catch (error) { + console.error("Failed to delete customer:", error); + throw error; + } +}; + export const userFilterUICall = async (accessToken: string, params: URLSearchParams) => { try { let url = proxyBaseUrl ? `${proxyBaseUrl}/user/filter/ui` : `/user/filter/ui`;