diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/CustomersView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/customers/CustomersView.tsx index 2890306abdb..ccd4130ff49 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/customers/CustomersView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/CustomersView.tsx @@ -1,14 +1,14 @@ import React, { useState } from "react"; -import { Button, Card, Col, Grid, TabPanel, Text } from "@tremor/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 CustomerInfoModal from "@/app/(dashboard)/customers/components/modals/CustomerInfoModal"; 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 { customerCreateCall, customerDeleteCall, customerUpdateCall } from "@/components/networking"; +import { customerDeleteCall } from "@/components/networking"; interface CustomersViewProps { customers: Customer[]; @@ -43,53 +43,48 @@ const CustomersView: React.FC = ({ }); const [showCreateModal, setShowCreateModal] = useState(false); - const [showInfoModal, setShowInfoModal] = 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 (data: NewCustomerData) => { + const handleCreateCustomer = async () => { if (!accessToken) return; - try { - const response = await customerCreateCall(accessToken, data); - if (response) { - // Refresh the customer list - const { allEndUsersCall } = await import("@/components/networking"); - const listData = await allEndUsersCall(accessToken); - if (listData) { - setCustomers(Array.isArray(listData) ? listData : []); - } + 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 creating customer:", error); + console.error("Error refreshing customers:", error); } }; const handleEditCustomer = (customer: Customer) => { + setSelectedCustomerId(customer.user_id); setSelectedCustomer(customer); - setShowInfoModal(true); + setCustomerDetailDefaultTab("settings"); }; const handleViewInfo = (customer: Customer) => { + setSelectedCustomerId(customer.user_id); setSelectedCustomer(customer); - setShowInfoModal(true); + setCustomerDetailDefaultTab("overview"); }; - const handleSaveCustomer = async (updated: Customer) => { - if (!accessToken) return; + const handleCloseCustomerInfo = () => { + setSelectedCustomerId(null); + setSelectedCustomer(null); + }; - try { - await customerUpdateCall(accessToken, updated); - setCustomers( - customers.map((c) => (c.user_id === updated.user_id ? updated : c)) - ); - setShowInfoModal(false); - setSelectedCustomer(null); - } catch (error) { - console.error("Error updating customer:", error); - } + const handleUpdateCustomer = (updated: Customer) => { + setCustomers((prev) => + prev.map((c) => (c.user_id === updated.user_id ? updated : c)) + ); + setSelectedCustomer(updated); }; const handleDeleteClick = (customer: Customer) => { @@ -142,46 +137,67 @@ const CustomersView: React.FC = ({ return matchesUserId && matchesAlias && matchesBlocked && matchesRegion; }); - return ( -
- - - {(userRole === "Admin" || userRole === "Org Admin") && ( - - )} + const initialCustomerForDetail = + selectedCustomerId && selectedCustomer?.user_id === selectedCustomerId + ? selectedCustomer + : customers.find((c) => c.user_id === selectedCustomerId) ?? null; - - - - Click on “Customer ID” to view customer details and manage settings. - - - - -
- -
- -
- -
-
-
+ 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") && ( <> @@ -190,15 +206,6 @@ const CustomersView: React.FC = ({ onClose={() => setShowCreateModal(false)} onCreate={handleCreateCustomer} /> - { - setShowInfoModal(false); - setSelectedCustomer(null); - }} - customer={selectedCustomer} - onSave={handleSaveCustomer} - /> { @@ -211,8 +218,7 @@ const CustomersView: React.FC = ({ /> )} - - +
); }; 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/CustomersHeaderTabs.tsx b/ui/litellm-dashboard/src/app/(dashboard)/customers/components/CustomersHeaderTabs.tsx index 951f46b78ae..28bf7245dbe 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/customers/components/CustomersHeaderTabs.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/components/CustomersHeaderTabs.tsx @@ -16,18 +16,15 @@ const CustomersHeaderTabs: React.FC = ({ children, }) => { return ( -
-
- - - Your Customers - {(userRole === "Admin" || userRole === "Org Admin") && ( - Customer Settings - )} - - {children} - -
+ +
+ + Your Customers + {(userRole === "Admin" || userRole === "Org Admin") && ( + Customer Settings + )} + +
Last Refreshed: {lastRefreshed}
-
+ {children} + ); }; 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 index 46d4648c427..1d944753bbc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/customers/components/modals/CreateCustomerModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/components/modals/CreateCustomerModal.tsx @@ -1,11 +1,20 @@ -import React, { useState } from "react"; -import { Modal, Form, Input, InputNumber, Select as AntSelect } from "antd"; +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; + onCreate: (customer?: NewCustomerData) => void; } const CreateCustomerModal: React.FC = ({ @@ -13,15 +22,59 @@ const CreateCustomerModal: React.FC = ({ 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); - onCreate(values); + 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 { @@ -44,11 +97,7 @@ const CreateCustomerModal: React.FC = ({ okText="Create Customer" width={600} > -
+ = ({ - - + + + + -
- - - + + + 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 })} + /> +
+ )} +
+
+
- - - -
- -
- - - gpt-4o - gpt-4o-mini - gpt-4-turbo - claude-3-sonnet - claude-3-opus - claude-3-haiku - - - - - - Any region - US - EU - - -
- - - - - - - - + + + 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)" + /> + + + ); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/customers/types.ts b/ui/litellm-dashboard/src/app/(dashboard)/customers/types.ts index f054a9493a6..704172aca41 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/customers/types.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/customers/types.ts @@ -1,3 +1,11 @@ +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; @@ -12,6 +20,7 @@ export interface Customer { max_budget: number | null; budget_duration: string | null; } | null; + object_permission?: CustomerObjectPermission | null; } export interface NewCustomerData { @@ -23,4 +32,5 @@ export interface NewCustomerData { 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",