diff --git a/ui/litellm-dashboard/src/app/(console)/components/Sidebar.tsx b/ui/litellm-dashboard/src/app/(console)/components/Sidebar.tsx new file mode 100644 index 00000000000..ad60ba626aa --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/components/Sidebar.tsx @@ -0,0 +1,291 @@ +import React from "react"; +import Link from "next/link"; +import { usePathname, useSearchParams } from "next/navigation"; +import { Layout, Menu, ConfigProvider } from "antd"; +import { + KeyOutlined, + PlayCircleOutlined, + BlockOutlined, + BarChartOutlined, + TeamOutlined, + BankOutlined, + UserOutlined, + SettingOutlined, + ApiOutlined, + AppstoreOutlined, + DatabaseOutlined, + FileTextOutlined, + LineChartOutlined, + SafetyOutlined, + ExperimentOutlined, + ToolOutlined, + TagsOutlined, + BgColorsOutlined, +} from "@ant-design/icons"; +import { all_admin_roles, internalUserRoles, isAdminRole, rolesWithWriteAccess } from "@/utils/roles"; +import UsageIndicator from "@/components/usage_indicator"; + +const { Sider } = Layout; + +interface SidebarProps { + accessToken: string | null; + userRole: string; + /** Used to highlight a menu item when pathname doesn't match (e.g., on non-routed pages) */ + defaultSelectedKey?: string; + collapsed?: boolean; +} + +interface MenuItem { + key: string; + page: string; + label: string; + roles?: string[]; + children?: MenuItem[]; + icon?: React.ReactNode; +} + +const Sidebar: React.FC = ({ accessToken, userRole, defaultSelectedKey, collapsed = false }) => { + const menuItems: MenuItem[] = [ + { key: "1", page: "api-keys", label: "Virtual Keys", icon: }, + { + key: "3", + page: "llm-playground", + label: "Test Key", + icon: , + roles: rolesWithWriteAccess, + }, + { + key: "2", + page: "models", + label: "Models + Endpoints", + icon: , + roles: rolesWithWriteAccess, + }, + { + key: "12", + page: "new_usage", + label: "Usage", + icon: , + roles: [...all_admin_roles, ...internalUserRoles], + }, + { key: "6", page: "teams", label: "Teams", icon: }, + { + key: "17", + page: "organizations", + label: "Organizations", + icon: , + roles: all_admin_roles, + }, + { + key: "5", + page: "users", + label: "Internal Users", + icon: , + roles: all_admin_roles, + }, + { key: "14", page: "api_ref", label: "API Reference", icon: }, + { key: "16", page: "model-hub-table", label: "Model Hub", icon: }, + { key: "15", page: "logs", label: "Logs", icon: }, + { + key: "11", + page: "guardrails", + label: "Guardrails", + icon: , + roles: all_admin_roles, + }, + { + key: "26", + page: "tools", + label: "Tools", + icon: , + children: [ + { key: "18", page: "mcp-servers", label: "MCP Servers", icon: }, + { + key: "21", + page: "vector-stores", + label: "Vector Stores", + icon: , + roles: all_admin_roles, + }, + ], + }, + { + key: "experimental", + page: "experimental", + label: "Experimental", + icon: , + children: [ + { + key: "9", + page: "caching", + label: "Caching", + icon: , + roles: all_admin_roles, + }, + { + key: "25", + page: "prompts", + label: "Prompts", + icon: , + roles: all_admin_roles, + }, + { + key: "10", + page: "budgets", + label: "Budgets", + icon: , + roles: all_admin_roles, + }, + { + key: "20", + page: "transform-request", + label: "API Playground", + icon: , + roles: [...all_admin_roles, ...internalUserRoles], + }, + { + key: "19", + page: "tag-management", + label: "Tag Management", + icon: , + roles: all_admin_roles, + }, + { key: "4", page: "usage", label: "Old Usage", icon: }, + ], + }, + { + key: "settings", + page: "settings", + label: "Settings", + icon: , + roles: all_admin_roles, + children: [ + { + key: "11", + page: "general-settings", + label: "Router Settings", + icon: , + roles: all_admin_roles, + }, + { + key: "8", + page: "settings", + label: "Logging & Alerts", + icon: , + roles: all_admin_roles, + }, + { + key: "13", + page: "admin-panel", + label: "Admin Settings", + icon: , + roles: all_admin_roles, + }, + { + key: "14", + page: "ui-theme", + label: "UI Theme", + icon: , + roles: all_admin_roles, + }, + ], + }, + ]; + + // Role filtering (preserves original visibility behavior) + const filteredMenuItems: MenuItem[] = menuItems + .filter((item) => !item.roles || item.roles.includes(userRole)) + .map((item) => ({ + ...item, + children: item.children?.filter((child) => !child.roles || child.roles.includes(userRole)), + })); + + // Highlight selection based on pathname or ?page= + const pathname = usePathname(); + const searchParams = useSearchParams(); + const pageParam = searchParams.get("page") || undefined; + + const findMenuItemKey = (page: string): string => { + const top = filteredMenuItems.find((i) => i.page === page); + if (top) return top.key; + for (const i of filteredMenuItems) { + const child = i.children?.find((c) => c.page === page); + if (child) return child.key; + } + return "1"; + }; + + const selectedMenuKey = + pathname === "/virtual-keys" + ? "1" + : pageParam + ? findMenuItemKey(pageParam) + : defaultSelectedKey + ? findMenuItemKey(defaultSelectedKey) + : "1"; + + // Root-only routing helper: always replace everything after the domain + const rootWithPage = (p: string) => ({ pathname: "/", query: { page: p } }); + + // Convert to AntD Menu items: + // - "Virtual Keys" still routes to /virtual-keys + // - All other items (and children) route to "/?page=" + const antdItems = filteredMenuItems.map((item) => { + const isVirtualKeys = item.key === "1"; + const label = isVirtualKeys ? ( + Virtual Keys + ) : ( + {item.label} + ); + + return { + key: item.key, + icon: item.icon, + label, + children: item.children?.map((child) => ({ + key: child.key, + icon: child.icon, + label: {child.label}, + })), + }; + }); + + return ( + + + + + + + {isAdminRole(userRole) && !collapsed && } + + + ); +}; + +export default Sidebar; diff --git a/ui/litellm-dashboard/src/app/(console)/components/modals/CreateUserModal.tsx b/ui/litellm-dashboard/src/app/(console)/components/modals/CreateUserModal.tsx new file mode 100644 index 00000000000..4caaceb662f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/components/modals/CreateUserModal.tsx @@ -0,0 +1,345 @@ +// TODO: refactor +import React, { useState, useEffect } from "react"; +import { Button, Modal, Form, Input, message, Select, InputNumber, Select as Select2 } from "antd"; +import { + Button as Button2, + Text, + TextInput, + SelectItem, + Accordion, + AccordionHeader, + AccordionBody, + Title, +} from "@tremor/react"; +import NotificationsManager from "@/components/molecules/notifications_manager"; +const { Option } = Select; +import { Tooltip } from "antd"; +import { InfoCircleOutlined } from "@ant-design/icons"; +import { useQueryClient } from "@tanstack/react-query"; +import OnboardingModal, { InvitationLink } from "@/components/onboarding_link"; +import { + getProxyBaseUrl, + getProxyUISettings, + invitationCreateCall, + modelAvailableCall, + Team, + userCreateCall, +} from "@/components/networking"; +import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key"; +import BulkCreateUsersButton from "@/components/bulk_create_users_button"; +import { fetchTeams } from "@/app/(console)/virtual-keys/networking"; +import useTeams from "@/app/(console)/virtual-keys/hooks/useTeams"; +import useAuthorized from "@/app/(console)/hooks/useAuthorized"; + +// Helper function to generate UUID compatible across all environments +const generateUUID = (): string => { + if (typeof crypto !== "undefined" && crypto.randomUUID) { + return crypto.randomUUID(); + } + // Fallback UUID generation for environments without crypto.randomUUID + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { + const r = (Math.random() * 16) | 0; + const v = c == "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +}; + +interface CreateUserModalProps { + possibleUIRoles: null | Record>; + onUserCreated?: (userId: string) => void; + isEmbedded?: boolean; +} + +interface UISettings { + PROXY_BASE_URL: string | null; + PROXY_LOGOUT_URL: string | null; + DEFAULT_TEAM_DISABLED: boolean; + SSO_ENABLED: boolean; +} + +const CreateUserModal: React.FC = ({ possibleUIRoles, onUserCreated, isEmbedded = false }) => { + const { userId: userID, accessToken } = useAuthorized(); + const queryClient = useQueryClient(); + const [uiSettings, setUISettings] = useState(null); + const [form] = Form.useForm(); + const [isModalVisible, setIsModalVisible] = useState(false); + const [apiuser, setApiuser] = useState(false); + const [userModels, setUserModels] = useState([]); + const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = useState(false); + const [invitationLinkData, setInvitationLinkData] = useState(null); + const [baseUrl, setBaseUrl] = useState(null); + const teams = useTeams(); + + // get all models + useEffect(() => { + const fetchData = async () => { + try { + const userRole = "any"; // You may need to get the user role dynamically + const modelDataResponse = await modelAvailableCall(accessToken, userID, userRole); + // Assuming modelDataResponse.data contains an array of model objects with a 'model_name' property + const availableModels = []; + for (let i = 0; i < modelDataResponse.data.length; i++) { + const model = modelDataResponse.data[i]; + availableModels.push(model.id); + } + console.log("Model data response:", modelDataResponse.data); + console.log("Available models:", availableModels); + + // Assuming modelDataResponse.data contains an array of model names + setUserModels(availableModels); + + // get ui settings + const uiSettingsResponse = await getProxyUISettings(accessToken); + console.log("uiSettingsResponse:", uiSettingsResponse); + + setUISettings(uiSettingsResponse); + } catch (error) { + console.error("Error fetching model data:", error); + } + }; + + setBaseUrl(getProxyBaseUrl()); + + fetchData(); // Call the function to fetch model data when the component mounts + }, []); // Empty dependency array to run only once + + const handleOk = () => { + setIsModalVisible(false); + form.resetFields(); + }; + + const handleCancel = () => { + setIsModalVisible(false); + setApiuser(false); + form.resetFields(); + }; + + const handleCreate = async (formValues: { user_id: string; models?: string[]; user_role: string }) => { + try { + NotificationsManager.info("Making API Call"); + if (!isEmbedded) { + setIsModalVisible(true); + } + if ((!formValues.models || formValues.models.length === 0) && formValues.user_role !== "proxy_admin") { + console.log("formValues.user_role", formValues.user_role); + // If models is empty or undefined, set it to "no-default-models" + formValues.models = ["no-default-models"]; + } + console.log("formValues in create user:", formValues); + const response = await userCreateCall(accessToken, null, formValues); + await queryClient.invalidateQueries({ queryKey: ["userList"] }); + console.log("user create Response:", response); + setApiuser(true); + const user_id = response.data?.user_id || response.user_id; + + // Call the callback if provided (for embedded mode) + if (onUserCreated && isEmbedded) { + onUserCreated(user_id); + form.resetFields(); + return; // Skip the invitation flow when embedded + } + + // only do invite link flow if sso is not enabled + if (!uiSettings?.SSO_ENABLED) { + invitationCreateCall(accessToken, user_id).then((data) => { + data.has_user_setup_sso = false; + setInvitationLinkData(data); + setIsInvitationLinkModalVisible(true); + }); + } else { + // create an InvitationLink Object for this user for the SSO flow + // for SSO the invite link is the proxy base url since the User just needs to login + const invitationLink: InvitationLink = { + id: generateUUID(), // Generate a unique ID + user_id: user_id, + is_accepted: false, + accepted_at: null, + expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // Set expiry to 7 days from now + created_at: new Date(), + created_by: userID, // Assuming userID is the current user creating the invitation + updated_at: new Date(), + updated_by: userID, + has_user_setup_sso: true, + }; + setInvitationLinkData(invitationLink); + setIsInvitationLinkModalVisible(true); + } + + NotificationsManager.success("API user Created"); + form.resetFields(); + localStorage.removeItem("userData" + userID); + } catch (error: any) { + const errorMessage = error.response?.data?.detail || error?.message || "Error creating the user"; + NotificationsManager.fromBackend(errorMessage); + console.error("Error creating the user:", error); + } + }; + + // Modify the return statement to handle embedded mode + if (isEmbedded) { + return ( +
+ + + + + + {possibleUIRoles && + Object.entries(possibleUIRoles).map(([role, { ui_label, description }]) => ( + +
+ {ui_label}{" "} +

+ {description} +

+
+
+ ))} +
+
+ + + + + + + + +
+ +
+
+ ); + } + + // Original return for standalone mode + return ( +
+ setIsModalVisible(true)}> + + Invite User + + + + Create a User who can own keys +
+ + + + + Global Proxy Role{" "} + + + + + } + name="user_role" + > + + {possibleUIRoles && + Object.entries(possibleUIRoles).map(([role, { ui_label, description }]) => ( + +
+ {ui_label}{" "} +

+ {description} +

+
+
+ ))} +
+
+ + + + + + + + + + + Personal Key Creation + + + + Models{" "} + + + + + } + name="models" + help="Models user has access to, outside of team scope." + > + + + All Proxy Models + + {userModels.map((model) => ( + + {getModelDisplayName(model)} + + ))} + + + + +
+ +
+
+
+ {apiuser && ( + + )} +
+ ); +}; + +export default CreateUserModal; diff --git a/ui/litellm-dashboard/src/app/(console)/hooks/useAuthorized.ts b/ui/litellm-dashboard/src/app/(console)/hooks/useAuthorized.ts new file mode 100644 index 00000000000..45f5ee92ea6 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/hooks/useAuthorized.ts @@ -0,0 +1,23 @@ +import { Router } from "next/router"; +import { getCookie } from "@/utils/cookieUtils"; +import { useRouter } from "next/navigation"; +import { jwtDecode } from "jwt-decode"; + +const useAuthorized = () => { + const token = getCookie("token"); + const router = useRouter(); + + if (!token) { + router.replace("/sso/key/generate"); + } + + const decoded = jwtDecode(token as string) as { [key: string]: any }; + const accessToken = decoded.key; + const userId = decoded.user_id; + const userRole = decoded.user_role; + const premiumUser = decoded.premium_user; + + return { accessToken, userId, userRole, premiumUser }; +}; + +export default useAuthorized; diff --git a/ui/litellm-dashboard/src/app/(console)/layout.tsx b/ui/litellm-dashboard/src/app/(console)/layout.tsx new file mode 100644 index 00000000000..cbfb5bdcb53 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/layout.tsx @@ -0,0 +1,46 @@ +"use client"; + +import React from "react"; +import Navbar from "@/components/navbar"; +import { ThemeProvider } from "@/contexts/ThemeContext"; +import Sidebar from "@/app/(console)/components/Sidebar"; +import useAuthorized from "@/app/(console)/hooks/useAuthorized"; + +export default function Layout({ children }: { children: React.ReactNode }) { + const { accessToken, userRole } = useAuthorized(); + const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false); + + const toggleSidebar = () => setSidebarCollapsed((v) => !v); + + return ( + +
+ +
+
+ +
+
{children}
+
+
+
+ ); +} diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKey.tsx b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKey.tsx new file mode 100644 index 00000000000..c8e0b1d8ce5 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKey.tsx @@ -0,0 +1,108 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Button } from "@tremor/react"; +import { Modal, Form } from "antd"; +import { getPossibleUserRoles, Organization } from "@/components/networking"; +import { rolesWithWriteAccess } from "@/utils/roles"; +import { Team } from "@/components/key_team_helpers/key_list"; +import CreateKeyModal from "@/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyModal"; +import CreateUserModal from "@/app/(console)/components/modals/CreateUserModal"; +import useAuthorized from "@/app/(console)/hooks/useAuthorized"; + +interface CreateKeyProps { + team: Team | null; + userRole: string | null; + data: any[] | null; + addKey: (data: any) => void; +} + +interface User { + user_id: string; + user_email: string; + role?: string; +} + +interface UserOption { + label: string; + value: string; + user: User; +} +const CreateKey: React.FC = ({ team, userRole, data, addKey }) => { + const { accessToken } = useAuthorized(); + const [form] = Form.useForm(); + const [isModalVisible, setIsModalVisible] = useState(false); + const [isCreateUserModalVisible, setIsCreateUserModalVisible] = useState(false); + const [newlyCreatedUserId, setNewlyCreatedUserId] = useState(null); + const [possibleUIRoles, setPossibleUIRoles] = useState>>({}); + + useEffect(() => { + const fetchPossibleRoles = async () => { + try { + if (accessToken) { + // Check if roles are cached in session storage + const cachedRoles = sessionStorage.getItem("possibleUserRoles"); + if (cachedRoles) { + setPossibleUIRoles(JSON.parse(cachedRoles)); + } else { + const availableUserRoles = await getPossibleUserRoles(accessToken); + sessionStorage.setItem("possibleUserRoles", JSON.stringify(availableUserRoles)); + setPossibleUIRoles(availableUserRoles); + } + } + } catch (error) { + console.error("Error fetching possible user roles:", error); + } + }; + + fetchPossibleRoles(); + }, [accessToken]); + + // Add a callback function to handle user creation + const handleUserCreated = (userId: string) => { + setNewlyCreatedUserId(userId); + form.setFieldsValue({ user_id: userId }); + setIsCreateUserModalVisible(false); + }; + + const handleUserSelect = (_value: string, option: UserOption): void => { + const selectedUser = option.user; + form.setFieldsValue({ + user_id: selectedUser.user_id, + }); + }; + + return ( +
+ {userRole && rolesWithWriteAccess.includes(userRole) && ( + + )} + + + {isCreateUserModalVisible && ( + setIsCreateUserModalVisible(false)} + footer={null} + width={800} + > + + + )} +
+ ); +}; + +export default CreateKey; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection.tsx b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection.tsx new file mode 100644 index 00000000000..54e9aaefed7 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection.tsx @@ -0,0 +1,148 @@ +import { TextInput, Title } from "@tremor/react"; +import { Form, FormInstance, Select, Tooltip } from "antd"; +import { InfoCircleOutlined } from "@ant-design/icons"; +import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key"; +import React from "react"; + +export interface KeyDetailsSectionProps { + form: FormInstance; + keyOwner: string; + modelsToPick: string[]; + keyType: string; + setKeyType: (keyType: string) => void; +} + +const { Option } = Select; + +const KeyDetailsSection = ({ form, keyOwner, keyType, modelsToPick, setKeyType }: KeyDetailsSectionProps) => { + return ( +
+ Key Details + + {keyOwner === "you" || keyOwner === "another_user" ? "Key Name" : "Service Account ID"}{" "} + + + + + } + name="key_alias" + rules={[ + { + required: true, + message: `Please input a ${keyOwner === "you" ? "key name" : "service account ID"}`, + }, + ]} + help="required" + > + + + + + Models{" "} + + + + + } + name="models" + rules={ + keyType === "management" || keyType === "read_only" + ? [] + : [{ required: true, message: "Please select a model" }] + } + help={ + keyType === "management" || keyType === "read_only" + ? "Models field is disabled for this key type" + : "required" + } + className="mt-4" + > + + + + + Key Type{" "} + + + + + } + name="key_type" + initialValue="default" + className="mt-4" + > + + +
+ ); +}; + +export default KeyDetailsSection; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection.tsx b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection.tsx new file mode 100644 index 00000000000..c879cdf5955 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection.tsx @@ -0,0 +1,458 @@ +import { Accordion, AccordionBody, AccordionHeader, Text, Title } from "@tremor/react"; +import { Form, FormInstance, Input, Select, Tooltip } from "antd"; +import { InfoCircleOutlined } from "@ant-design/icons"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; +import NumericalInput from "@/components/shared/numerical_input"; +import BudgetDurationDropdown from "@/components/common_components/budget_duration_dropdown"; +import RateLimitTypeFormItem from "@/components/common_components/RateLimitTypeFormItem"; +import VectorStoreSelector from "@/components/vector_store_management/VectorStoreSelector"; +import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector"; +import PremiumLoggingSettings from "@/components/common_components/PremiumLoggingSettings"; +import ModelAliasManager from "@/components/common_components/ModelAliasManager"; +import KeyLifecycleSettings from "@/components/common_components/KeyLifecycleSettings"; +import { proxyBaseUrl } from "@/components/networking"; +import SchemaFormFields from "@/components/common_components/check_openapi_schema"; +import { Team } from "@/components/key_team_helpers/key_list"; +import React from "react"; +import { DefaultOptionType } from "rc-select/lib/Select"; +import { ModelAliases } from "@/app/(console)/virtual-keys/components/CreateKeyModal/types"; + +export interface OptionalSettingsSectionProps { + form: FormInstance; + team: Team | null; + premiumUser: boolean; + guardrails: string[]; + prompts: string[]; + accessToken: string; + predefinedTags: DefaultOptionType[]; + loggingSettings: any; + setLoggingSettings: (settings: any) => void; + disabledCallbacks: string[]; + setDisabledCallbacks: (disabledCallbacks: string[]) => void; + modelAliases: ModelAliases; + setModelAliases: (aliases: ModelAliases) => void; + autoRotationEnabled: boolean; + setAutoRotationEnabled: (enabled: boolean) => void; + rotationInterval: string; + setRotationInterval: (interval: string) => void; +} + +// TODO: break apart this component into smaller sections +const OptionalSettingsSection = ({ + form, + team, + premiumUser, + guardrails, + prompts, + accessToken, + predefinedTags, + loggingSettings, + setLoggingSettings, + disabledCallbacks, + setDisabledCallbacks, + modelAliases, + setModelAliases, + autoRotationEnabled, + setAutoRotationEnabled, + rotationInterval, + setRotationInterval, +}: OptionalSettingsSectionProps) => { + return ( +
+ + + Optional Settings + + + + Max Budget (USD){" "} + + + + + } + name="max_budget" + help={`Budget cannot exceed team max budget: $${team?.max_budget !== null && team?.max_budget !== undefined ? team?.max_budget : "unlimited"}`} + rules={[ + { + validator: async (_, value) => { + if (value && team && team.max_budget !== null && value > team.max_budget) { + throw new Error( + `Budget cannot exceed team max budget: $${formatNumberWithCommas(team.max_budget, 4)}`, + ); + } + }, + }, + ]} + > + + + + Reset Budget{" "} + + + + + } + name="budget_duration" + help={`Team Reset Budget: ${team?.budget_duration !== null && team?.budget_duration !== undefined ? team?.budget_duration : "None"}`} + > + form.setFieldValue("budget_duration", value)} /> + + + Tokens per minute Limit (TPM){" "} + + + + + } + name="tpm_limit" + help={`TPM cannot exceed team TPM limit: ${team?.tpm_limit !== null && team?.tpm_limit !== undefined ? team?.tpm_limit : "unlimited"}`} + rules={[ + { + validator: async (_, value) => { + if (value && team && team.tpm_limit !== null && value > team.tpm_limit) { + throw new Error(`TPM limit cannot exceed team TPM limit: ${team.tpm_limit}`); + } + }, + }, + ]} + > + + + + + Requests per minute Limit (RPM){" "} + + + + + } + name="rpm_limit" + help={`RPM cannot exceed team RPM limit: ${team?.rpm_limit !== null && team?.rpm_limit !== undefined ? team?.rpm_limit : "unlimited"}`} + rules={[ + { + validator: async (_, value) => { + if (value && team && team.rpm_limit !== null && value > team.rpm_limit) { + throw new Error(`RPM limit cannot exceed team RPM limit: ${team.rpm_limit}`); + } + }, + }, + ]} + > + + + + + Guardrails{" "} + + e.stopPropagation()} // Prevent accordion from collapsing when clicking link + > + + + + + } + name="guardrails" + className="mt-4" + help={ + premiumUser + ? "Select existing guardrails or enter new ones" + : "Premium feature - Upgrade to set guardrails by key" + } + > + ({ value: name, label: name }))} + /> + + + Allowed Vector Stores{" "} + + + + + } + name="allowed_vector_store_ids" + className="mt-4" + help="Select vector stores this key can access. Leave empty for access to all vector stores" + > + form.setFieldValue("allowed_vector_store_ids", values)} + value={form.getFieldValue("allowed_vector_store_ids")} + accessToken={accessToken} + placeholder="Select vector stores (optional)" + /> + + + + Allowed MCP Servers{" "} + + + + + } + name="allowed_mcp_servers_and_groups" + className="mt-4" + help="Select MCP servers or access groups this key 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)" + /> + + + + Metadata{" "} + + + + + } + name="metadata" + className="mt-4" + > + + + + Tags{" "} + + + + + } + name="tags" + className="mt-4" + help={`Tags for tracking spend and/or doing tag-based routing.`} + > + handleUserSelect(value, option as UserOption)} + options={userOptions} + loading={userSearchLoading} + allowClear + style={{ width: "100%" }} + notFoundContent={userSearchLoading ? "Searching..." : "No users found"} + /> + setIsCreateUserModalVisible(true)} style={{ marginLeft: "8px" }}> + Create User + +
+
Search by email to find users
+ + + )} + + Team{" "} + + + + + } + name="team_id" + initialValue={team ? team.team_id : null} + className="mt-4" + rules={[ + { + required: keyOwner === "service_account", + message: "Please select a team for the service account", + }, + ]} + help={keyOwner === "service_account" ? "required" : ""} + > + { + const selectedTeam = teams?.find((t) => t.team_id === teamId) || null; + setSelectedCreateKeyTeam(selectedTeam); + }} + /> + + + ); +}; + +export default OwnershipSection; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyModal.tsx b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyModal.tsx new file mode 100644 index 00000000000..9f4d7528a8f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyModal.tsx @@ -0,0 +1,233 @@ +import { Button as Button2, Form, FormInstance, Modal } from "antd"; +import { Text } from "@tremor/react"; +import { keyCreateCall, keyCreateServiceAccountCall } from "@/components/networking"; +import React, { useEffect, useState } from "react"; +import { Team } from "@/components/key_team_helpers/key_list"; +import SaveKeyModal from "@/app/(console)/virtual-keys/components/SaveKeyModal"; +import NotificationsManager from "@/components/molecules/notifications_manager"; +import { + useGuardrailsAndPrompts, + useMcpAccessGroups, + useTeamModels, + useUserModels, + useUserSearch, +} from "@/app/(console)/virtual-keys/components/CreateKeyModal/hooks"; +import OwnershipSection from "@/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OwnershipSection"; +import KeyDetailsSection from "@/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection"; +import OptionalSettingsSection from "@/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection"; +import { ModelAliases } from "@/app/(console)/virtual-keys/components/CreateKeyModal/types"; +import { getPredefinedTags, prepareFormValues } from "@/app/(console)/virtual-keys/components/CreateKeyModal/utils"; +import { fetchTeams } from "@/app/(console)/virtual-keys/networking"; +import useTeams from "@/app/(console)/virtual-keys/hooks/useTeams"; +import useAuthorized from "@/app/(console)/hooks/useAuthorized"; + +export interface CreateKeyModalProps { + isModalVisible: boolean; + setIsModalVisible: (isModalVisible: boolean) => void; + form: FormInstance; + handleUserSelect: (_value: string, option: UserOption) => void; + setIsCreateUserModalVisible: (visible: boolean) => void; + team: Team | null; + data: any[] | null; + addKey: (data: any) => void; +} + +interface User { + user_id: string; + user_email: string; + role?: string; +} + +interface UserOption { + label: string; + value: string; + user: User; +} + +const CreateKeyModal = ({ + isModalVisible, + setIsModalVisible, + form, + handleUserSelect, + setIsCreateUserModalVisible, + team, + data, + addKey, +}: CreateKeyModalProps) => { + const { userId: userID, userRole, accessToken, premiumUser } = useAuthorized(); + const [apiKey, setApiKey] = useState(null); + const [keyOwner, setKeyOwner] = useState("you"); + const [keyType, setKeyType] = useState("default"); + + const [selectedCreateKeyTeam, setSelectedCreateKeyTeam] = useState(team); + + const [softBudget, setSoftBudget] = useState(null); + const [loggingSettings, setLoggingSettings] = useState([]); + const [disabledCallbacks, setDisabledCallbacks] = useState([]); + const [autoRotationEnabled, setAutoRotationEnabled] = useState(false); + const [rotationInterval, setRotationInterval] = useState("30d"); + const [modelAliases, setModelAliases] = useState({}); + const predefinedTags = getPredefinedTags(data); + + const { options: userOptions, loading: userSearchLoading, onSearch: handleUserSearch } = useUserSearch(); + const { guardrails, prompts } = useGuardrailsAndPrompts(); + const teams = useTeams(); + const mcpAccessGroups = useMcpAccessGroups(); + const userModels = useUserModels(); + const modelsToPick = useTeamModels(selectedCreateKeyTeam); + + const isTeamSelectionRequired = modelsToPick.includes("no-default-models"); + const isFormDisabled = isTeamSelectionRequired && !selectedCreateKeyTeam; + + const handleCancel = () => { + setIsModalVisible(false); + setApiKey(null); + setSelectedCreateKeyTeam(null); + form.resetFields(); + setLoggingSettings([]); + setDisabledCallbacks([]); + setKeyType("default"); + setModelAliases({}); + setAutoRotationEnabled(false); + setRotationInterval("30d"); + }; + + const handleOk = () => { + setIsModalVisible(false); + form.resetFields(); + setLoggingSettings([]); + setDisabledCallbacks([]); + setKeyType("default"); + setModelAliases({}); + setAutoRotationEnabled(false); + setRotationInterval("30d"); + }; + + const handleCreate = async (formValues: Record) => { + try { + const newKeyAlias = formValues?.key_alias ?? ""; + const newKeyTeamId = formValues?.team_id ?? null; + + const existingKeyAliases = data?.filter((k) => k.team_id === newKeyTeamId).map((k) => k.key_alias) ?? []; + + if (existingKeyAliases.includes(newKeyAlias)) { + throw new Error( + `Key alias ${newKeyAlias} already exists for team with ID ${newKeyTeamId}, please provide another key alias`, + ); + } + + NotificationsManager.info("Making API Call"); + setIsModalVisible(true); + + const prepared = prepareFormValues(formValues, { + keyOwner, + userID, + loggingSettings, + disabledCallbacks, + autoRotationEnabled, + rotationInterval, + modelAliases, + }); + + let response; + if (keyOwner === "service_account") { + response = await keyCreateServiceAccountCall(accessToken, prepared); + } else { + response = await keyCreateCall(accessToken, userID, prepared); + } + + // TODO: change logic to trigger API call in parent component + addKey(response); + + setApiKey(response["key"]); + setSoftBudget(response["soft_budget"]); + NotificationsManager.success("API Key Created"); + form.resetFields(); + localStorage.removeItem("userData" + userID); + } catch (error) { + console.log("error in create key:", error); + NotificationsManager.fromBackend(`Error creating the key: ${error}`); + } + }; + + useEffect(() => { + form.setFieldValue("models", []); + }, [selectedCreateKeyTeam, accessToken, userID, userRole, form]); + + return ( +
+ +
+ + + {/* Show message when team selection is required */} + {isFormDisabled && ( +
+ + Please select a team to continue configuring your API key. If you do not see any teams, please contact + your Proxy Admin to either provide you with access to models or to add you to a team. + +
+ )} + + {/* Section 2: Key Details */} + {!isFormDisabled && ( + + )} + + {/* Section 3: Optional Settings */} + {!isFormDisabled && ( + + )} + +
+ + Create Key + +
+ +
+ {apiKey && ( + + )} +
+ ); +}; + +export default CreateKeyModal; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/index.ts b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/index.ts new file mode 100644 index 00000000000..ca3c4a663b9 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/index.ts @@ -0,0 +1,5 @@ +export * from "./useGuardrailsAndPrompts"; +export * from "./useMcpAccessGroups"; +export * from "./useUserModels"; +export * from "./useTeamModels"; +export * from "./useUserSearch"; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useGuardrailsAndPrompts.ts b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useGuardrailsAndPrompts.ts new file mode 100644 index 00000000000..d092219f70b --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useGuardrailsAndPrompts.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from "react"; +import { fetchGuardrails, fetchPrompts } from "@/app/(console)/virtual-keys/components/CreateKeyModal/networking"; +import useAuthorized from "@/app/(console)/hooks/useAuthorized"; + +export const useGuardrailsAndPrompts = () => { + const [guardrails, setGuardrails] = useState([]); + const [prompts, setPrompts] = useState([]); + const { accessToken } = useAuthorized(); + + useEffect(() => { + if (!accessToken) { + setGuardrails([]); + setPrompts([]); + return; + } + (async () => { + const [g, p] = await Promise.all([fetchGuardrails(accessToken), fetchPrompts(accessToken)]); + setGuardrails(g || []); + setPrompts(p || []); + })(); + }, [accessToken]); + + return { guardrails, prompts }; +}; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useMcpAccessGroups.ts b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useMcpAccessGroups.ts new file mode 100644 index 00000000000..8313f6e8d6d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useMcpAccessGroups.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from "react"; +import { getMCPAccessGroups } from "@/app/(console)/virtual-keys/components/CreateKeyModal/networking"; +import useAuthorized from "@/app/(console)/hooks/useAuthorized"; + +export const useMcpAccessGroups = () => { + const [mcpAccessGroups, setMcpAccessGroups] = useState([]); + const { accessToken } = useAuthorized(); + + useEffect(() => { + if (!accessToken) { + setMcpAccessGroups([]); + return; + } + (async () => { + const groups = await getMCPAccessGroups(accessToken); + setMcpAccessGroups(groups || []); + })(); + }, [accessToken]); + + return mcpAccessGroups; +}; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useTeamModels.ts b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useTeamModels.ts new file mode 100644 index 00000000000..c28ef0ecdb2 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useTeamModels.ts @@ -0,0 +1,26 @@ +import { useEffect, useMemo, useState } from "react"; +import type { Team } from "@/components/key_team_helpers/key_list"; +import { fetchTeamModels } from "@/app/(console)/virtual-keys/components/CreateKeyModal/networking"; +import useAuthorized from "@/app/(console)/hooks/useAuthorized"; + +export const useTeamModels = (selectedTeam: Team | null) => { + const { userId: userID, userRole, accessToken } = useAuthorized(); + const [modelsToPick, setModelsToPick] = useState([]); + + // turn array into a stable dep so it only re-runs when contents change + const selectedTeamModelsKey = useMemo(() => (selectedTeam?.models ?? []).join("|"), [selectedTeam?.models]); + + useEffect(() => { + if (!userID || !userRole || !accessToken) { + setModelsToPick([]); + return; + } + (async () => { + const fetched = await fetchTeamModels(userID, userRole, accessToken, selectedTeam?.team_id ?? null); + const union = Array.from(new Set([...(selectedTeam?.models ?? []), ...(fetched || [])])); + setModelsToPick(union); + })(); + }, [userID, userRole, accessToken, selectedTeam?.team_id, selectedTeamModelsKey, selectedTeam?.models]); + + return modelsToPick; +}; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useUserModels.ts b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useUserModels.ts new file mode 100644 index 00000000000..ddba6879cc9 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useUserModels.ts @@ -0,0 +1,21 @@ +import { useEffect, useState } from "react"; +import { getUserModelNames } from "@/app/(console)/virtual-keys/components/CreateKeyModal/networking"; +import useAuthorized from "@/app/(console)/hooks/useAuthorized"; + +export const useUserModels = () => { + const { userId: userID, userRole, accessToken } = useAuthorized(); + const [userModels, setUserModels] = useState([]); + + useEffect(() => { + if (!userID || !userRole || !accessToken) { + setUserModels([]); + return; + } + (async () => { + const modelNames = await getUserModelNames(userID, userRole, accessToken); + setUserModels(modelNames || []); + })(); + }, [userID, userRole, accessToken]); + + return userModels; +}; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useUserSearch.ts b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useUserSearch.ts new file mode 100644 index 00000000000..782beccf48f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useUserSearch.ts @@ -0,0 +1,41 @@ +import { useEffect, useMemo, useState } from "react"; +import { debounce } from "lodash"; +import { searchUserOptionsByEmail } from "@/app/(console)/virtual-keys/components/CreateKeyModal/networking"; +import useAuthorized from "@/app/(console)/hooks/useAuthorized"; + +type User = { user_id: string; user_email: string; role?: string }; +export interface UserOption { + label: string; + value: string; + user: User; +} + +export const useUserSearch = (delay = 300) => { + const { accessToken } = useAuthorized(); + const [options, setOptions] = useState([]); + const [loading, setLoading] = useState(false); + + const doSearch = async (raw: string) => { + const q = raw?.trim(); + if (!q || !accessToken) { + setOptions([]); + setLoading(false); + return; + } + setLoading(true); + try { + const res = await searchUserOptionsByEmail(accessToken, q); + setOptions(res || []); + } finally { + setLoading(false); + } + }; + + const onSearch = useMemo(() => debounce(doSearch, delay), [accessToken, delay]); + + useEffect(() => { + return () => onSearch.cancel(); + }, [onSearch]); + + return { options, loading, onSearch }; +}; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/networking.ts b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/networking.ts new file mode 100644 index 00000000000..6cb4e2f7f07 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/networking.ts @@ -0,0 +1,84 @@ +import { + fetchMCPAccessGroups, + getGuardrailsList, + getPromptsList, + modelAvailableCall, + User, + userFilterUICall, +} from "@/components/networking"; +import { ModelAvailableResponse, UserOption } from "@/app/(console)/virtual-keys/components/CreateKeyModal/types"; + +export const fetchGuardrails = async (accessToken: string) => { + try { + const response = await getGuardrailsList(accessToken); + return response.guardrails.map((g: { guardrail_name: string }) => g.guardrail_name); + } catch (error) { + console.error("Failed to fetch guardrails:", error); + return []; + } +}; + +export const fetchPrompts = async (accessToken: string) => { + try { + const response = await getPromptsList(accessToken); + return response.prompts.map((prompt) => prompt.prompt_id); + } catch (error) { + console.error("Failed to fetch prompts:", error); + return []; + } +}; + +export const searchUserOptionsByEmail = async (accessToken: string, emailQuery: string): Promise => { + const params = new URLSearchParams(); + params.append("user_email", emailQuery); + + const response = await userFilterUICall(accessToken, params); + const users: User[] = response; + + return users.map((user) => ({ + label: `${user.user_email} (${user.user_id})`, + value: user.user_id, + user, + })); +}; + +export const getUserModelNames = async (userID: string, userRole: string, accessToken: string): Promise => { + const res: ModelAvailableResponse = await modelAvailableCall(accessToken, userID, userRole); + return (res?.data ?? []).map((m) => m.id); +}; + +export const fetchTeamModels = async ( + userID: string, + userRole: string, + accessToken: string, + teamID: string | null, +): Promise => { + try { + if (userID === null || userRole === null) { + return []; + } + + if (accessToken !== null) { + const model_available = await modelAvailableCall(accessToken, userID, userRole, true, teamID, true); + let available_model_names = model_available["data"].map((element: { id: string }) => element.id); + console.log("available_model_names:", available_model_names); + return available_model_names; + } + return []; + } catch (error) { + console.error("Error fetching user models:", error); + return []; + } +}; + +export const getMCPAccessGroups = async (accessToken: string): Promise => { + try { + if (accessToken == null) { + return []; + } + return await fetchMCPAccessGroups(accessToken); + } catch (error) { + console.error("Failed to fetch MCP access groups:", error); + return []; + } +}; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/types.ts b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/types.ts new file mode 100644 index 00000000000..4d48df82d44 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/types.ts @@ -0,0 +1,17 @@ +export interface User { + user_id: string; + user_email: string; + role?: string; +} + +export interface UserOption { + label: string; + value: string; + user: User; +} + +export type Model = { id: string }; + +export type ModelAvailableResponse = { data: Model[] }; + +export type ModelAliases = { [key: string]: string }; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/utils.ts b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/utils.ts new file mode 100644 index 00000000000..066770cf468 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/utils.ts @@ -0,0 +1,187 @@ +import { mapDisplayToInternalNames } from "@/components/callback_info_helpers"; +import { ModelAliases } from "@/app/(console)/virtual-keys/components/CreateKeyModal/types"; + +export const getPredefinedTags = (data: any[] | null) => { + let allTags = []; + + console.log("data:", JSON.stringify(data)); + + if (data) { + for (let key of data) { + if (key["metadata"] && key["metadata"]["tags"]) { + allTags.push(...key["metadata"]["tags"]); + } + } + } + + const uniqueTags = Array.from(new Set(allTags)).map((tag) => ({ + value: tag, + label: tag, + })); + + console.log("uniqueTags:", uniqueTags); + return uniqueTags; +}; + +type AnyObj = Record; + +type PrepareOptions = { + keyOwner: string; + userID: string; + loggingSettings: any[]; + disabledCallbacks: string[]; + autoRotationEnabled: boolean; + rotationInterval: string; + modelAliases: ModelAliases; +}; + +export function safeParseMetadata(metadataStr: string | undefined): AnyObj { + try { + return JSON.parse(metadataStr || "{}"); + } catch (error) { + console.error("Error parsing metadata:", error); + return {}; + } +} + +export function withOwnerAssignments( + values: AnyObj, + { keyOwner, userID }: Pick, +): AnyObj { + // If owned by "you", set user_id + const base = keyOwner === "you" ? { ...values, user_id: userID } : { ...values }; + return base; +} + +export function enrichMetadata( + metadata: AnyObj, + values: AnyObj, + { + keyOwner, + loggingSettings, + disabledCallbacks, + }: Pick, +): AnyObj { + let next = { ...metadata }; + + // If it's a service account, add the service_account_id to the metadata + if (keyOwner === "service_account") { + next = { ...next, service_account_id: values.key_alias }; + } + + // Add logging settings to the metadata + if (Array.isArray(loggingSettings) && loggingSettings.length > 0) { + next = { ...next, logging: loggingSettings.filter((config: any) => config?.callback_name) }; + } + + // Add disabled callbacks to the metadata + if (Array.isArray(disabledCallbacks) && disabledCallbacks.length > 0) { + const mappedDisabledCallbacks = mapDisplayToInternalNames(disabledCallbacks); + next = { ...next, litellm_disabled_callbacks: mappedDisabledCallbacks }; + } + + return next; +} + +export function withAutoRotation( + values: AnyObj, + { autoRotationEnabled, rotationInterval }: Pick, +): AnyObj { + if (!autoRotationEnabled) return { ...values }; + return { ...values, auto_rotate: true, rotation_interval: rotationInterval }; +} + +export function withDurationNoop(values: AnyObj): AnyObj { + // Preserve the original no-op logic: if duration exists, set it to itself + if (values.duration) { + return { ...values, duration: values.duration }; + } + return { ...values }; +} + +export function mergeObjectPermission(values: AnyObj, patch: AnyObj): AnyObj { + const object_permission = { ...(values.object_permission || {}), ...patch }; + return { ...values, object_permission }; +} + +export function withVectorStores(values: AnyObj): AnyObj { + const { allowed_vector_store_ids, ...rest } = values; + if (Array.isArray(allowed_vector_store_ids) && allowed_vector_store_ids.length > 0) { + const patched = mergeObjectPermission(rest, { vector_stores: allowed_vector_store_ids }); + return patched; // original field removed by destructuring + } + return { ...values }; +} + +export function withMcpServersAndGroups(values: AnyObj): AnyObj { + const { allowed_mcp_servers_and_groups, ...rest } = values; + const servers = allowed_mcp_servers_and_groups?.servers; + const accessGroups = allowed_mcp_servers_and_groups?.accessGroups; + + const hasServers = Array.isArray(servers) && servers.length > 0; + const hasAccessGroups = Array.isArray(accessGroups) && accessGroups.length > 0; + + if (!hasServers && !hasAccessGroups) return { ...values }; + + let patched = { ...rest }; + if (hasServers) patched = mergeObjectPermission(patched, { mcp_servers: servers }); + if (hasAccessGroups) patched = mergeObjectPermission(patched, { mcp_access_groups: accessGroups }); + + // original field removed by destructuring + return patched; +} + +export function withMcpAccessGroups(values: AnyObj): AnyObj { + const { allowed_mcp_access_groups, ...rest } = values; + if (Array.isArray(allowed_mcp_access_groups) && allowed_mcp_access_groups.length > 0) { + const patched = mergeObjectPermission(rest, { mcp_access_groups: allowed_mcp_access_groups }); + return patched; // original field removed by destructuring + } + return { ...values }; +} + +function withAliases(values: AnyObj, modelAliases: ModelAliases): AnyObj { + if (modelAliases && Object.keys(modelAliases).length > 0) { + return { ...values, aliases: JSON.stringify(modelAliases) }; + } + return { ...values }; +} + +function withMetadataString(values: AnyObj, metadata: AnyObj): AnyObj { + return { ...values, metadata: JSON.stringify(metadata) }; +} + +export function prepareFormValues(initialValues: AnyObj, opts: PrepareOptions): AnyObj { + // Step 1: assign user id if needed + let values = withOwnerAssignments(initialValues, { keyOwner: opts.keyOwner, userID: opts.userID }); + + // Step 2: metadata parse & enrichment + const parsedMetadata = safeParseMetadata(values.metadata); + const enrichedMetadata = enrichMetadata(parsedMetadata, values, { + keyOwner: opts.keyOwner, + loggingSettings: opts.loggingSettings, + disabledCallbacks: opts.disabledCallbacks, + }); + + // Step 3: auto-rotation + values = withAutoRotation(values, { + autoRotationEnabled: opts.autoRotationEnabled, + rotationInterval: opts.rotationInterval, + }); + + // Step 4: duration (no-op preservation) + values = withDurationNoop(values); + + // Step 5: commit metadata string + values = withMetadataString(values, enrichedMetadata); + + // Step 6: object_permission transformations + values = withVectorStores(values); + values = withMcpServersAndGroups(values); + values = withMcpAccessGroups(values); + + // Step 7: aliases + values = withAliases(values, opts.modelAliases); + + return values; +} diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/KeyInfoView.tsx b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/KeyInfoView.tsx new file mode 100644 index 00000000000..1c7185f1dc4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/KeyInfoView.tsx @@ -0,0 +1,697 @@ +import React, { useEffect, useState } from "react"; +import { + Card, + Text, + Button, + Grid, + Tab, + TabList, + TabGroup, + TabPanel, + TabPanels, + Title, + Badge, +} from "@tremor/react"; +import { ArrowLeftIcon, TrashIcon, RefreshIcon } from "@heroicons/react/outline"; +import { Form, Tooltip, Button as AntdButton } from "antd"; +import { copyToClipboard as utilCopyToClipboard, formatNumberWithCommas } from "@/utils/dataUtils"; +import { CopyIcon, CheckIcon } from "lucide-react"; +import { KeyResponse } from "@/components/key_team_helpers/key_list" +import { mapDisplayToInternalNames, mapInternalToDisplayNames } from "@/components/callback_info_helpers" +import NotificationManager from "@/components/molecules/notifications_manager" +import { keyDeleteCall, keyUpdateCall } from "@/components/networking" +import { parseErrorMessage } from "@/components/shared/errorUtils" +import { rolesWithWriteAccess } from "@/utils/roles" +import { RegenerateKeyModal } from "@/components/organisms/regenerate_key_modal" +import ObjectPermissionsView from "@/components/object_permissions_view" +import LoggingSettingsView from "@/components/logging_settings_view" +import { extractLoggingSettings, formatMetadataForDisplay } from "@/components/key_info_utils" +import AutoRotationView from "@/components/common_components/AutoRotationView" +import { KeyEditView } from "@/components/templates/key_edit_view" + + +interface KeyInfoViewProps { + keyId: string; + onClose: () => void; + keyData: KeyResponse | undefined; + onKeyDataUpdate?: (data: Partial) => void; + onDelete?: () => void; + accessToken: string | null; + userID: string | null; + userRole: string | null; + teams: any[] | null; + premiumUser: boolean; + setAccessToken?: (token: string) => void; + backButtonText?: string; +} + +const KeyInfoView = ({ + keyId, + onClose, + keyData, + accessToken, + userID, + userRole, + teams, + onKeyDataUpdate, + onDelete, + premiumUser, + setAccessToken, + backButtonText = "Back to Keys", + }: KeyInfoViewProps) => { + const [isEditing, setIsEditing] = useState(false); + const [form] = Form.useForm(); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [deleteConfirmInput, setDeleteConfirmInput] = useState(""); + const [isRegenerateModalOpen, setIsRegenerateModalOpen] = useState(false); + const [copiedStates, setCopiedStates] = useState>({}); + + // Add local state to maintain key data and track regeneration + const [currentKeyData, setCurrentKeyData] = useState(keyData); + const [lastRegeneratedAt, setLastRegeneratedAt] = useState(null); + const [isRecentlyRegenerated, setIsRecentlyRegenerated] = useState(false); + + // Update local state when keyData prop changes (but don't reset to undefined) + useEffect(() => { + if (keyData) { + setCurrentKeyData(keyData); + } + }, [keyData]); + + // Reset recent regeneration indicator after 5 seconds + useEffect(() => { + if (isRecentlyRegenerated) { + const timer = setTimeout(() => { + setIsRecentlyRegenerated(false); + }, 5000); + return () => clearTimeout(timer); + } + }, [isRecentlyRegenerated]); + + // Use currentKeyData instead of keyData throughout the component + if (!currentKeyData) { + return ( +
+ + Key not found +
+ ); + } + + const handleKeyUpdate = async (formValues: Record) => { + try { + if (!accessToken) return; + + const currentKey = formValues.token; + formValues.key = currentKey; + + // Guard premium features + if (!premiumUser) { + delete formValues.guardrails; + delete formValues.prompts; + } + + // Handle object_permission updates + if (formValues.vector_stores !== undefined) { + formValues.object_permission = { + ...currentKeyData.object_permission, + vector_stores: formValues.vector_stores || [], + }; + // Remove vector_stores from the top level as it should be in object_permission + delete formValues.vector_stores; + } + + if (formValues.mcp_servers_and_groups !== undefined) { + const { servers, accessGroups } = formValues.mcp_servers_and_groups || { servers: [], accessGroups: [] }; + formValues.object_permission = { + ...currentKeyData.object_permission, + mcp_servers: servers || [], + mcp_access_groups: accessGroups || [], + }; + // Remove mcp_servers_and_groups from the top level as it should be in object_permission + delete formValues.mcp_servers_and_groups; + } + + // Convert metadata back to an object if it exists and is a string + if (formValues.metadata && typeof formValues.metadata === "string") { + try { + const parsedMetadata = JSON.parse(formValues.metadata); + formValues.metadata = { + ...parsedMetadata, + ...(formValues.guardrails?.length > 0 ? { guardrails: formValues.guardrails } : {}), + ...(formValues.logging_settings ? { logging: formValues.logging_settings } : {}), + ...(formValues.disabled_callbacks?.length > 0 + ? { + litellm_disabled_callbacks: mapDisplayToInternalNames(formValues.disabled_callbacks), + } + : {}), + }; + } catch (error) { + console.error("Error parsing metadata JSON:", error); + NotificationManager.error("Invalid metadata JSON"); + return; + } + } else { + formValues.metadata = { + ...(formValues.metadata || {}), + ...(formValues.guardrails?.length > 0 ? { guardrails: formValues.guardrails } : {}), + ...(formValues.logging_settings ? { logging: formValues.logging_settings } : {}), + ...(formValues.disabled_callbacks?.length > 0 + ? { + litellm_disabled_callbacks: mapDisplayToInternalNames(formValues.disabled_callbacks), + } + : {}), + }; + } + + delete formValues.logging_settings; + + // Convert budget_duration to API format + if (formValues.budget_duration) { + const durationMap: Record = { + daily: "24h", + weekly: "7d", + monthly: "30d", + }; + formValues.budget_duration = durationMap[formValues.budget_duration]; + } + + const newKeyValues = await keyUpdateCall(accessToken, formValues); + + // Update local state + setCurrentKeyData((prevData) => (prevData ? { ...prevData, ...newKeyValues } : undefined)); + + if (onKeyDataUpdate) { + onKeyDataUpdate(newKeyValues); + } + NotificationManager.success("Key updated successfully"); + setIsEditing(false); + // Refresh key data here if needed + } catch (error) { + NotificationManager.fromBackend(parseErrorMessage(error)); + console.error("Error updating key:", error); + } + }; + + const handleDelete = async () => { + try { + if (!accessToken) return; + await keyDeleteCall(accessToken as string, currentKeyData.token || currentKeyData.token_id); + NotificationManager.success("Key deleted successfully"); + if (onDelete) { + onDelete(); + } + onClose(); + } catch (error) { + console.error("Error deleting the key:", error); + NotificationManager.fromBackend(error); + } + // Reset the confirmation input + setDeleteConfirmInput(""); + }; + + const copyToClipboard = async (text: string, key: string) => { + const success = await utilCopyToClipboard(text); + if (success) { + setCopiedStates((prev) => ({ ...prev, [key]: true })); + setTimeout(() => { + setCopiedStates((prev) => ({ ...prev, [key]: false })); + }, 2000); + } + }; + + const handleRegenerateKeyUpdate = (updatedKeyData: Partial) => { + // Update local state immediately with ALL the new data + setCurrentKeyData((prevData) => { + if (!prevData) return undefined; + const newData = { + ...prevData, + ...updatedKeyData, // This should include the new token (key-id) + // Update the created_at to show when it was regenerated + created_at: new Date().toLocaleString(), + }; + return newData; + }); + + // Track regeneration timestamp + setLastRegeneratedAt(new Date()); + setIsRecentlyRegenerated(true); + + if (onKeyDataUpdate) { + onKeyDataUpdate({ + ...updatedKeyData, + created_at: new Date().toLocaleString(), + }); + } + }; + + // Update the formatTimestamp function to use the desired date format + const formatTimestamp = (timestamp: string | Date) => { + const date = new Date(timestamp); + const dateStr = date.toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); + const timeStr = date.toLocaleTimeString("en-US", { + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + return `${dateStr} at ${timeStr}`; + }; + + return ( +
+
+
+ + {currentKeyData.key_alias || "API Key"} + +
+
+ Key ID + {currentKeyData.token_id || currentKeyData.token} +
+ : } + onClick={() => copyToClipboard(currentKeyData.token_id || currentKeyData.token, "key-id")} + className={`ml-2 transition-all duration-200${ + copiedStates["key-id"] + ? "text-green-600 bg-green-50 border-green-200" + : "text-gray-500 hover:text-gray-700 hover:bg-gray-100" + }`} + /> +
+ + {/* Add timestamp and regeneration indicator */} +
+ + {currentKeyData.updated_at && currentKeyData.updated_at !== currentKeyData.created_at + ? `Updated: ${formatTimestamp(currentKeyData.updated_at)}` + : `Created: ${formatTimestamp(currentKeyData.created_at)}`} + + + {isRecentlyRegenerated && ( + + Recently Regenerated + + )} + + {lastRegeneratedAt && ( + + Regenerated + + )} +
+
+ {userRole && rolesWithWriteAccess.includes(userRole) && ( +
+ + + + + + +
+ )} +
+ + {/* Add RegenerateKeyModal */} + setIsRegenerateModalOpen(false)} + accessToken={accessToken} + premiumUser={premiumUser} + setAccessToken={setAccessToken} + onKeyUpdate={handleRegenerateKeyUpdate} + /> + + {/* Delete Confirmation Modal */} + {isDeleteModalOpen && + (() => { + const keyName = currentKeyData?.key_alias || currentKeyData?.token_id || "API Key"; + const isValid = deleteConfirmInput === keyName; + return ( +
+
+
+
+

Delete Key

+ +
+
+
+
+ + + +
+
+

+ Warning: You are about to delete this API key. +

+

+ This action is irreversible and will immediately revoke access for any applications using this + key. +

+
+
+

Are you sure you want to delete this API key?

+
+ + setDeleteConfirmInput(e.target.value)} + placeholder="Enter key name exactly" + className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-base" + autoFocus + /> +
+
+
+
+ + +
+
+
+ ); + })()} + + + + Overview + Settings + + + + {/* Overview Panel */} + + + + Spend +
+ ${formatNumberWithCommas(currentKeyData.spend, 4)} + + of{" "} + {currentKeyData.max_budget !== null + ? `$${formatNumberWithCommas(currentKeyData.max_budget)}` + : "Unlimited"} + +
+
+ + + Rate Limits +
+ TPM: {currentKeyData.tpm_limit !== null ? currentKeyData.tpm_limit : "Unlimited"} + RPM: {currentKeyData.rpm_limit !== null ? currentKeyData.rpm_limit : "Unlimited"} +
+
+ + + Models +
+ {currentKeyData.models && currentKeyData.models.length > 0 ? ( + currentKeyData.models.map((model, index) => ( + + {model} + + )) + ) : ( + No models specified + )} +
+
+ + + + + + + + +
+
+ + {/* Settings Panel */} + + +
+ Key Settings + {!isEditing && userRole && rolesWithWriteAccess.includes(userRole) && ( + + )} +
+ + {isEditing ? ( + setIsEditing(false)} + onSubmit={handleKeyUpdate} + teams={teams} + accessToken={accessToken} + userID={userID} + userRole={userRole} + premiumUser={premiumUser} + /> + ) : ( +
+
+ Key ID + {currentKeyData.token_id || currentKeyData.token} +
+ +
+ Key Alias + {currentKeyData.key_alias || "Not Set"} +
+ +
+ Secret Key + {currentKeyData.key_name} +
+ +
+ Team ID + {currentKeyData.team_id || "Not Set"} +
+ +
+ Organization + {currentKeyData.organization_id || "Not Set"} +
+ +
+ Created + {formatTimestamp(currentKeyData.created_at)} +
+ + {lastRegeneratedAt && ( +
+ Last Regenerated +
+ {formatTimestamp(lastRegeneratedAt)} + + Recent + +
+
+ )} + +
+ Expires + {currentKeyData.expires ? formatTimestamp(currentKeyData.expires) : "Never"} +
+ + + +
+ Spend + ${formatNumberWithCommas(currentKeyData.spend, 4)} USD +
+ +
+ Budget + + {currentKeyData.max_budget !== null + ? `$${formatNumberWithCommas(currentKeyData.max_budget, 2)}` + : "Unlimited"} + +
+ +
+ Prompts + + {Array.isArray(currentKeyData.metadata?.prompts) && currentKeyData.metadata.prompts.length > 0 + ? currentKeyData.metadata.prompts.map((prompt, index) => ( + + {prompt} + + )) + : "No prompts specified"} + +
+ +
+ Models +
+ {currentKeyData.models && currentKeyData.models.length > 0 ? ( + currentKeyData.models.map((model, index) => ( + + {model} + + )) + ) : ( + No models specified + )} +
+
+ +
+ Rate Limits + TPM: {currentKeyData.tpm_limit !== null ? currentKeyData.tpm_limit : "Unlimited"} + RPM: {currentKeyData.rpm_limit !== null ? currentKeyData.rpm_limit : "Unlimited"} + + Max Parallel Requests:{" "} + {currentKeyData.max_parallel_requests !== null + ? currentKeyData.max_parallel_requests + : "Unlimited"} + + + Model TPM Limits:{" "} + {currentKeyData.metadata?.model_tpm_limit + ? JSON.stringify(currentKeyData.metadata.model_tpm_limit) + : "Unlimited"} + + + Model RPM Limits:{" "} + {currentKeyData.metadata?.model_rpm_limit + ? JSON.stringify(currentKeyData.metadata.model_rpm_limit) + : "Unlimited"} + +
+ +
+ Metadata +
+                      {formatMetadataForDisplay(currentKeyData.metadata)}
+                    
+
+ + + + +
+ )} +
+
+
+
+
+ ); +} + +export default KeyInfoView; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/SaveKeyModal.tsx b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/SaveKeyModal.tsx new file mode 100644 index 00000000000..bd0d425c465 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/SaveKeyModal.tsx @@ -0,0 +1,58 @@ +import { Button, Col, Grid, Text, Title } from "@tremor/react"; +import { CopyToClipboard } from "react-copy-to-clipboard"; +import { Modal } from "antd"; +import React from "react"; +import NotificationsManager from "@/components/molecules/notifications_manager"; + +export interface SaveKeyModalProps { + apiKey: string; + isModalVisible: boolean; + handleOk: () => void; + handleCancel: () => void; +} + +const SaveKeyModal = ({ apiKey, isModalVisible, handleOk, handleCancel }: SaveKeyModalProps) => { + const handleCopy = () => { + NotificationsManager.success("API Key copied to clipboard"); + }; + + return ( + + + Save your Key + +

+ Please save this secret key somewhere safe and accessible. For security reasons,{" "} + you will not be able to view it again through your LiteLLM account. If you lose this secret key, you + will need to generate a new one. +

+ + + {apiKey != null ? ( +
+ API Key: +
+
{apiKey}
+
+ + + + +
+ ) : ( + Key being created, this might take 30s + )} + +
+
+ ); +}; + +export default SaveKeyModal; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx new file mode 100644 index 00000000000..5c32a736ae3 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx @@ -0,0 +1,666 @@ +// TODO: refactor +"use client"; + +import React, { useEffect, useState } from "react"; +import { ColumnDef } from "@tanstack/react-table"; +import { Button } from "@tremor/react"; +import { Tooltip } from "antd"; +import { updateExistingKeys } from "@/utils/dataUtils"; +import { flexRender, getCoreRowModel, getSortedRowModel, SortingState, useReactTable } from "@tanstack/react-table"; +import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell, Icon } from "@tremor/react"; +import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline"; +import { Badge, Text } from "@tremor/react"; +import { formatNumberWithCommas } from "@/utils/dataUtils"; +import { KeyResponse, Team } from "@/components/key_team_helpers/key_list"; +import { Organization, userListCall } from "@/components/networking"; +import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key"; +import FilterComponent, { FilterOption } from "@/components/molecules/filter"; +import KeyInfoView from "@/components/templates/key_info_view"; +import { useFilterLogic } from "@/components/key_team_helpers/filter_logic"; +import useTeams from "@/app/(console)/virtual-keys/hooks/useTeams"; +import useAuthorized from "@/app/(console)/hooks/useAuthorized"; + +interface AllKeysTableProps { + keys: KeyResponse[]; + setKeys: (keys: KeyResponse[] | ((prev: KeyResponse[]) => KeyResponse[])) => void; + isLoading?: boolean; + pagination: { + currentPage: number; + totalPages: number; + totalCount: number; + }; + onPageChange: (page: number) => void; + pageSize?: number; + organizations: Organization[] | null; + refresh?: () => void; + onSortChange?: (sortBy: string, sortOrder: "asc" | "desc") => void; + currentSort?: { + sortBy: string; + sortOrder: "asc" | "desc"; + }; + setAccessToken?: (token: string) => void; +} + +interface UserResponse { + user_id: string; + user_email: string; + user_role: string; +} + +const VirtualKeysTable = ({ + keys, + setKeys, + isLoading = false, + pagination, + onPageChange, + pageSize = 50, + organizations, + refresh, + onSortChange, + currentSort, + setAccessToken, +}: AllKeysTableProps) => { + const { userId: userID, userRole, accessToken, premiumUser } = useAuthorized(); + const teams = useTeams(); + const [selectedKeyId, setSelectedKeyId] = useState(null); + const [userList, setUserList] = useState([]); + const [sorting, setSorting] = React.useState(() => { + if (currentSort) { + return [ + { + id: currentSort.sortBy, + desc: currentSort.sortOrder === "desc", + }, + ]; + } + return [ + { + id: "created_at", + desc: true, + }, + ]; + }); + const [expandedAccordions, setExpandedAccordions] = useState>({}); + + // Use the filter logic hook + + const { filters, filteredKeys, allKeyAliases, allTeams, allOrganizations, handleFilterChange, handleFilterReset } = + useFilterLogic({ + keys, + teams, + organizations, + accessToken, + }); + + useEffect(() => { + if (accessToken) { + const user_IDs = keys.map((key) => key.user_id).filter((id) => id !== null); + const fetchUserList = async () => { + const userListData = await userListCall(accessToken, user_IDs, 1, 100); + setUserList(userListData.users); + }; + fetchUserList(); + } + }, [accessToken, keys]); + + // Add a useEffect to call refresh when a key is created + useEffect(() => { + if (refresh) { + const handleStorageChange = () => { + refresh(); + }; + + // Listen for storage events that might indicate a key was created + window.addEventListener("storage", handleStorageChange); + + return () => { + window.removeEventListener("storage", handleStorageChange); + }; + } + }, [refresh]); + + const columns: ColumnDef[] = [ + { + id: "expander", + header: () => null, + cell: ({ row }) => + row.getCanExpand() ? ( + + ) : null, + }, + { + id: "token", + accessorKey: "token", + header: "Key ID", + cell: (info) => ( +
+ + + +
+ ), + }, + { + id: "key_alias", + accessorKey: "key_alias", + header: "Key Alias", + cell: (info) => { + const value = info.getValue() as string; + return ( + {value ? (value.length > 20 ? `${value.slice(0, 20)}...` : value) : "-"} + ); + }, + }, + { + id: "key_name", + accessorKey: "key_name", + header: "Secret Key", + cell: (info) => {info.getValue() as string}, + }, + { + id: "team_alias", + accessorKey: "team_id", + header: "Team Alias", + cell: ({ row, getValue }) => { + const teamId = getValue() as string; + const team = teams?.find((t) => t.team_id === teamId); + return team?.team_alias || "Unknown"; + }, + }, + { + id: "team_id", + accessorKey: "team_id", + header: "Team ID", + cell: (info) => ( + + {info.getValue() ? `${(info.getValue() as string).slice(0, 7)}...` : "-"} + + ), + }, + { + id: "organization_id", + accessorKey: "organization_id", + header: "Organization ID", + cell: (info) => (info.getValue() ? info.renderValue() : "-"), + }, + { + id: "user_email", + accessorKey: "user_id", + header: "User Email", + cell: (info) => { + const userId = info.getValue() as string; + const user = userList.find((u) => u.user_id === userId); + return user?.user_email ? ( + + {user?.user_email.slice(0, 20)}... + + ) : ( + "-" + ); + }, + }, + { + id: "user_id", + accessorKey: "user_id", + header: "User ID", + cell: (info) => { + const userId = info.getValue() as string | null; + if (userId && userId.length > 15) { + return ( + + {userId.slice(0, 7)}... + + ); + } + return userId ? userId : "-"; + }, + }, + { + id: "created_at", + accessorKey: "created_at", + header: "Created At", + cell: (info) => { + const value = info.getValue(); + return value ? new Date(value as string).toLocaleDateString() : "-"; + }, + }, + { + id: "created_by", + accessorKey: "created_by", + header: "Created By", + cell: (info) => { + const value = info.getValue() as string | null; + if (value && value.length > 15) { + return ( + + {value.slice(0, 7)}... + + ); + } + return value; + }, + }, + { + id: "updated_at", + accessorKey: "updated_at", + header: "Updated At", + cell: (info) => { + const value = info.getValue(); + return value ? new Date(value as string).toLocaleDateString() : "Never"; + }, + }, + { + id: "expires", + accessorKey: "expires", + header: "Expires", + cell: (info) => { + const value = info.getValue(); + return value ? new Date(value as string).toLocaleDateString() : "Never"; + }, + }, + { + id: "spend", + accessorKey: "spend", + header: "Spend (USD)", + cell: (info) => formatNumberWithCommas(info.getValue() as number, 4), + }, + { + id: "max_budget", + accessorKey: "max_budget", + header: "Budget (USD)", + cell: (info) => { + const maxBudget = info.getValue() as number | null; + if (maxBudget === null) { + return "Unlimited"; + } + return `$${formatNumberWithCommas(maxBudget)}`; + }, + }, + { + id: "budget_reset_at", + accessorKey: "budget_reset_at", + header: "Budget Reset", + cell: (info) => { + const value = info.getValue(); + return value ? new Date(value as string).toLocaleString() : "Never"; + }, + }, + { + id: "models", + accessorKey: "models", + header: "Models", + cell: (info) => { + const models = info.getValue() as string[]; + return ( +
+ {Array.isArray(models) ? ( +
+ {models.length === 0 ? ( + + All Proxy Models + + ) : ( + <> +
+ {models.length > 3 && ( +
+ { + setExpandedAccordions((prev) => ({ + ...prev, + [info.row.id]: !prev[info.row.id], + })); + }} + /> +
+ )} +
+ {models.slice(0, 3).map((model, index) => + model === "all-proxy-models" ? ( + + All Proxy Models + + ) : ( + + + {model.length > 30 + ? `${getModelDisplayName(model).slice(0, 30)}...` + : getModelDisplayName(model)} + + + ), + )} + {models.length > 3 && !expandedAccordions[info.row.id] && ( + + + +{models.length - 3} {models.length - 3 === 1 ? "more model" : "more models"} + + + )} + {expandedAccordions[info.row.id] && ( +
+ {models.slice(3).map((model, index) => + model === "all-proxy-models" ? ( + + All Proxy Models + + ) : ( + + + {model.length > 30 + ? `${getModelDisplayName(model).slice(0, 30)}...` + : getModelDisplayName(model)} + + + ), + )} +
+ )} +
+
+ + )} +
+ ) : null} +
+ ); + }, + }, + { + id: "rate_limits", + header: "Rate Limits", + cell: ({ row }) => { + const key = row.original; + return ( +
+
TPM: {key.tpm_limit !== null ? key.tpm_limit : "Unlimited"}
+
RPM: {key.rpm_limit !== null ? key.rpm_limit : "Unlimited"}
+
+ ); + }, + }, + ]; + + const filterOptions: FilterOption[] = [ + { + name: "Team ID", + label: "Team ID", + isSearchable: true, + searchFn: async (searchText: string) => { + if (!allTeams || allTeams.length === 0) return []; + + const filteredTeams = allTeams.filter( + (team) => + team.team_id.toLowerCase().includes(searchText.toLowerCase()) || + (team.team_alias && team.team_alias.toLowerCase().includes(searchText.toLowerCase())), + ); + + return filteredTeams.map((team) => ({ + label: `${team.team_alias || team.team_id} (${team.team_id})`, + value: team.team_id, + })); + }, + }, + { + name: "Organization ID", + label: "Organization ID", + isSearchable: true, + searchFn: async (searchText: string) => { + if (!allOrganizations || allOrganizations.length === 0) return []; + + const filteredOrgs = allOrganizations.filter( + (org) => org.organization_id?.toLowerCase().includes(searchText.toLowerCase()) ?? false, + ); + + return filteredOrgs + .filter((org) => org.organization_id !== null && org.organization_id !== undefined) + .map((org) => ({ + label: `${org.organization_id || "Unknown"} (${org.organization_id})`, + value: org.organization_id as string, + })); + }, + }, + { + name: "Key Alias", + label: "Key Alias", + isSearchable: true, + searchFn: async (searchText) => { + const filteredKeyAliases = allKeyAliases.filter((key) => { + return key.toLowerCase().includes(searchText.toLowerCase()); + }); + + return filteredKeyAliases.map((key) => { + return { + label: key, + value: key, + }; + }); + }, + }, + { + name: "User ID", + label: "User ID", + isSearchable: false, + }, + { + name: "Key Hash", + label: "Key Hash", + isSearchable: false, + }, + ]; + + console.log(`keys: ${JSON.stringify(keys)}`); + + const table = useReactTable({ + data: filteredKeys, + columns: columns.filter((col) => col.id !== "expander"), + state: { + sorting, + }, + onSortingChange: (updaterOrValue) => { + const newSorting = typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue; + console.log(`newSorting: ${JSON.stringify(newSorting)}`); + setSorting(newSorting); + if (newSorting && newSorting.length > 0) { + const sortState = newSorting[0]; + const sortBy = sortState.id; + const sortOrder = sortState.desc ? "desc" : "asc"; + console.log(`sortBy: ${sortBy}, sortOrder: ${sortOrder}`); + handleFilterChange({ + ...filters, + "Sort By": sortBy, + "Sort Order": sortOrder, + }); + onSortChange?.(sortBy, sortOrder); + } + }, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + enableSorting: true, + manualSorting: false, + }); + + // Update local sorting state when currentSort prop changes + React.useEffect(() => { + if (currentSort) { + setSorting([ + { + id: currentSort.sortBy, + desc: currentSort.sortOrder === "desc", + }, + ]); + } + }, [currentSort]); + + return ( +
+ {selectedKeyId ? ( + setSelectedKeyId(null)} + keyData={filteredKeys.find((k) => k.token === selectedKeyId)} + onKeyDataUpdate={(updatedKeyData) => { + setKeys((keys) => + keys.map((key) => { + if (key.token === updatedKeyData.token) { + return updateExistingKeys(key, updatedKeyData); + } + return key; + }), + ); + if (refresh) refresh(); // Minimal fix: refresh the full key list after an update + }} + onDelete={() => { + setKeys((keys) => keys.filter((key) => key.token !== selectedKeyId)); + if (refresh) refresh(); // Minimal fix: refresh the full key list after a delete + }} + accessToken={accessToken} + userID={userID} + userRole={userRole} + teams={allTeams} + premiumUser={premiumUser} + setAccessToken={setAccessToken} + /> + ) : ( +
+
+ +
+ +
+ + Showing{" "} + {isLoading + ? "..." + : `${(pagination.currentPage - 1) * pageSize + 1} - ${Math.min(pagination.currentPage * pageSize, pagination.totalCount)}`}{" "} + of {isLoading ? "..." : pagination.totalCount} results + + +
+ + Page {isLoading ? "..." : pagination.currentPage} of {isLoading ? "..." : pagination.totalPages} + + + + + +
+
+
+
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + +
+
+ {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} +
+ {header.id !== "actions" && ( +
+ {header.column.getIsSorted() ? ( + { + asc: , + desc: , + }[header.column.getIsSorted() as string] + ) : ( + + )} +
+ )} +
+
+ ))} +
+ ))} +
+ + {isLoading ? ( + + +
+

🚅 Loading keys...

+
+
+
+ ) : filteredKeys.length > 0 ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + 3 ? "px-0" : ""}`} + > + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + +
+

No keys found

+
+
+
+ )} +
+
+
+
+
+
+ )} +
+ ); +}; + +export default VirtualKeysTable; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/VirtualKeysTable/hooks/useFilterLogic.ts b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/VirtualKeysTable/hooks/useFilterLogic.ts new file mode 100644 index 00000000000..ac174a554e8 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/components/VirtualKeysTable/hooks/useFilterLogic.ts @@ -0,0 +1,189 @@ +import { useCallback, useEffect, useState, useRef } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { debounce } from "lodash"; +import { KeyResponse, Team } from "@/components/key_team_helpers/key_list"; +import { keyListCall, Organization } from "@/components/networking"; +import { defaultPageSize } from "@/components/constants"; +import { fetchAllKeyAliases, fetchAllOrganizations, fetchAllTeams } from "@/components/key_team_helpers/filter_helpers"; + +export interface FilterState { + "Team ID": string; + "Organization ID": string; + "Key Alias": string; + [key: string]: string; + "User ID": string; + "Sort By": string; + "Sort Order": string; +} + +export function useFilterLogic({ + keys, + teams, + organizations, + accessToken, +}: { + keys: KeyResponse[]; + teams: Team[] | null; + organizations: Organization[] | null; + accessToken: string | null; +}) { + const defaultFilters: FilterState = { + "Team ID": "", + "Organization ID": "", + "Key Alias": "", + "User ID": "", + "Sort By": "created_at", + "Sort Order": "desc", + }; + const [filters, setFilters] = useState(defaultFilters); + const [allTeams, setAllTeams] = useState(teams || []); + const [allOrganizations, setAllOrganizations] = useState(organizations || []); + const [filteredKeys, setFilteredKeys] = useState(keys); + const lastSearchTimestamp = useRef(0); + const debouncedSearch = useCallback( + debounce(async (filters: FilterState) => { + if (!accessToken) { + return; + } + + const currentTimestamp = Date.now(); + lastSearchTimestamp.current = currentTimestamp; + + try { + // Make the API call using userListCall with all filter parameters + const data = await keyListCall( + accessToken, + filters["Organization ID"] || null, + filters["Team ID"] || null, + filters["Key Alias"] || null, + filters["User ID"] || null, + filters["Key Hash"] || null, + 1, // Reset to first page when searching + defaultPageSize, + filters["Sort By"] || null, + filters["Sort Order"] || null, + ); + + // Only update state if this is the most recent search + if (currentTimestamp === lastSearchTimestamp.current) { + if (data) { + setFilteredKeys(data.keys); + console.log("called from debouncedSearch filters:", JSON.stringify(filters)); + console.log("called from debouncedSearch data:", JSON.stringify(data)); + } + } + } catch (error) { + console.error("Error searching users:", error); + } + }, 300), + [accessToken], + ); + // Apply filters to keys whenever keys or filters change + useEffect(() => { + if (!keys) { + setFilteredKeys([]); + return; + } + + let result = [...keys]; + + // Apply Team ID filter + if (filters["Team ID"]) { + result = result.filter((key) => key.team_id === filters["Team ID"]); + } + + // Apply Organization ID filter + if (filters["Organization ID"]) { + result = result.filter((key) => key.organization_id === filters["Organization ID"]); + } + + setFilteredKeys(result); + }, [keys, filters]); + + // Fetch all data for filters when component mounts + useEffect(() => { + const loadAllFilterData = async () => { + // Load all teams - no organization filter needed here + const teamsData = await fetchAllTeams(accessToken); + if (teamsData.length > 0) { + setAllTeams(teamsData); + } + + // Load all organizations + const orgsData = await fetchAllOrganizations(accessToken); + if (orgsData.length > 0) { + setAllOrganizations(orgsData); + } + }; + + if (accessToken) { + loadAllFilterData(); + } + }, [accessToken]); + + const queryAllKeysQuery = useQuery({ + queryKey: ["allKeys"], + queryFn: async () => { + if (!accessToken) throw new Error("Access token required"); + return await fetchAllKeyAliases(accessToken); + }, + enabled: !!accessToken, + }); + const allKeyAliases = queryAllKeysQuery.data || []; + + // Update teams and organizations when props change + useEffect(() => { + if (teams && teams.length > 0) { + setAllTeams((prevTeams) => { + // Only update if we don't already have a larger set of teams + return prevTeams.length < teams.length ? teams : prevTeams; + }); + } + }, [teams]); + + useEffect(() => { + if (organizations && organizations.length > 0) { + setAllOrganizations((prevOrgs) => { + // Only update if we don't already have a larger set of organizations + return prevOrgs.length < organizations.length ? organizations : prevOrgs; + }); + } + }, [organizations]); + + const handleFilterChange = (newFilters: Record) => { + // Update filters state + setFilters({ + "Team ID": newFilters["Team ID"] || "", + "Organization ID": newFilters["Organization ID"] || "", + "Key Alias": newFilters["Key Alias"] || "", + "User ID": newFilters["User ID"] || "", + "Sort By": newFilters["Sort By"] || "created_at", + "Sort Order": newFilters["Sort Order"] || "desc", + }); + + // Fetch keys based on new filters + const updatedFilters = { + ...filters, + ...newFilters, + }; + debouncedSearch(updatedFilters); + }; + + const handleFilterReset = () => { + // Reset filters state + setFilters(defaultFilters); + + // Reset selections + debouncedSearch(defaultFilters); + }; + + return { + filters, + filteredKeys, + allKeyAliases, + allTeams, + allOrganizations, + handleFilterChange, + handleFilterReset, + }; +} diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/hooks/useTeams.tsx b/ui/litellm-dashboard/src/app/(console)/virtual-keys/hooks/useTeams.tsx new file mode 100644 index 00000000000..d1208737ec2 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/hooks/useTeams.tsx @@ -0,0 +1,20 @@ +import { useEffect, useState } from "react"; +import { fetchTeams } from "@/app/(console)/virtual-keys/networking"; +import { Team } from "@/components/key_team_helpers/key_list"; +import useAuthorized from "@/app/(console)/hooks/useAuthorized"; + +const useTeams = () => { + const [teams, setTeams] = useState([]); + const { accessToken, userId: userID, userRole } = useAuthorized(); + + useEffect(() => { + (async () => { + const fetched = await fetchTeams(accessToken, userID, userRole, null); + setTeams(fetched); + })(); + }, [accessToken, userID, userRole]); + + return teams; +}; + +export default useTeams; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/networking.ts b/ui/litellm-dashboard/src/app/(console)/virtual-keys/networking.ts new file mode 100644 index 00000000000..7fb09d61a5d --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/networking.ts @@ -0,0 +1,17 @@ +import { Organization, teamListCall } from "@/components/networking"; + +export const fetchTeams = async ( + accessToken: string, + userID: string | null, + userRole: string | null, + currentOrg: Organization | null, +) => { + let givenTeams; + if (userRole != "Admin" && userRole != "Admin Viewer") { + givenTeams = await teamListCall(accessToken, currentOrg?.organization_id || null, userID); + } else { + givenTeams = await teamListCall(accessToken, currentOrg?.organization_id || null); + } + + return givenTeams; +}; diff --git a/ui/litellm-dashboard/src/app/(console)/virtual-keys/page.tsx b/ui/litellm-dashboard/src/app/(console)/virtual-keys/page.tsx new file mode 100644 index 00000000000..9554905088b --- /dev/null +++ b/ui/litellm-dashboard/src/app/(console)/virtual-keys/page.tsx @@ -0,0 +1,52 @@ +"use client"; + +import VirtualKeysTable from "@/app/(console)/virtual-keys/components/VirtualKeysTable/VirtualKeysTable"; +import { getCookie } from "@/utils/cookieUtils"; +import { jwtDecode } from "jwt-decode"; +import { useState } from "react"; +import useKeyList from "@/components/key_team_helpers/key_list"; +import { useRouter } from "next/navigation"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Col, Grid } from "@tremor/react"; +import CreateKey from "@/app/(console)/virtual-keys/components/CreateKey"; +import useAuthorized from "@/app/(console)/hooks/useAuthorized" + +const VirtualKeysPage = () => { + const {accessToken, userRole} = useAuthorized(); + const [createClicked, setCreateClicked] = useState(false); + + const queryClient = new QueryClient(); + + const { keys, isLoading, error, pagination, refresh, setKeys } = useKeyList({ + selectedKeyAlias: null, + currentOrg: null, + accessToken: accessToken || "", + createClicked, + }); + + const addKey = (data: any) => { + setKeys((prevData) => (prevData ? [...prevData, data] : [data])); + setCreateClicked(() => !createClicked); + }; + + return ( + +
+ + + + {}} + organizations={null} + /> + + +
+
+ ); +}; + +export default VirtualKeysPage; diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 579e90e530c..472b3f9a37a 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -234,7 +234,6 @@ export default function CreateKeyPage() { teams={teams} keys={keys} setUserRole={setUserRole} - userEmail={userEmail} setUserEmail={setUserEmail} setTeams={setTeams} setKeys={setKeys} @@ -275,7 +274,6 @@ export default function CreateKeyPage() { teams={teams} keys={keys} setUserRole={setUserRole} - userEmail={userEmail} setUserEmail={setUserEmail} setTeams={setTeams} setKeys={setKeys} diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index e47691d3bb4..d9326cb8878 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -1,8 +1,4 @@ import { Layout, Menu } from "antd"; -import Link from "next/link"; -import { List } from "postcss/lib/list"; -import { Text, Button } from "@tremor/react"; -import { useState } from "react"; import { KeyOutlined, PlayCircleOutlined, @@ -16,29 +12,17 @@ import { AppstoreOutlined, DatabaseOutlined, FileTextOutlined, - LineOutlined, LineChartOutlined, SafetyOutlined, ExperimentOutlined, - ThunderboltOutlined, - LockOutlined, ToolOutlined, TagsOutlined, BgColorsOutlined, - MenuFoldOutlined, - MenuUnfoldOutlined, } from "@ant-design/icons"; -import { - old_admin_roles, - v2_admin_role_names, - all_admin_roles, - rolesAllowedToSeeUsage, - rolesWithWriteAccess, - internalUserRoles, - isAdminRole, -} from "../utils/roles"; +import { all_admin_roles, rolesWithWriteAccess, internalUserRoles, isAdminRole } from "../utils/roles"; import UsageIndicator from "./usage_indicator"; import { ConfigProvider } from "antd"; +import { useRouter } from "next/navigation"; const { Sider } = Layout; // Define the props type @@ -61,6 +45,7 @@ interface MenuItem { } const Sidebar: React.FC = ({ accessToken, setPage, userRole, defaultSelectedKey, collapsed = false }) => { + const router = useRouter(); // Note: If a menu item does not have a role, it is visible to all roles. const menuItems: MenuItem[] = [ { @@ -252,6 +237,19 @@ const Sidebar: React.FC = ({ accessToken, setPage, userRole, defau return true; }); + const navigateToPage = (page: string) => { + if (page === "api-keys") { + // Go to clean route and DO NOT call setPage to avoid any parent sync that re-adds ?page=api-keys + router.replace("/virtual-keys"); + return; + } + const newSearchParams = new URLSearchParams(window.location.search); + newSearchParams.set("page", page); + // Use absolute root to replace everything after the domain + router.replace(`/?${newSearchParams.toString()}`); + setPage(page); + }; + return ( = ({ accessToken, setPage, userRole, defau key: child.key, icon: child.icon, label: child.label, - onClick: () => { - const newSearchParams = new URLSearchParams(window.location.search); - newSearchParams.set("page", child.page); - window.history.pushState(null, "", `?${newSearchParams.toString()}`); - setPage(child.page); - }, + onClick: () => navigateToPage(child.page), })), - onClick: !item.children - ? () => { - const newSearchParams = new URLSearchParams(window.location.search); - newSearchParams.set("page", item.page); - window.history.pushState(null, "", `?${newSearchParams.toString()}`); - setPage(item.page); - } - : undefined, + onClick: !item.children ? () => navigateToPage(item.page) : undefined, }))} /> diff --git a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx index c725466b982..20c1f5b3a36 100644 --- a/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/organisms/create_key_button.tsx @@ -131,6 +131,14 @@ export const fetchUserModels = async ( } }; + +/** + * ───────────────────────────────────────────────────────────────────────── + * @deprecated + * This component is being DEPRECATED in favor of src/app/(console)/virtual-keys/components/CreateKey.tsx + * Please contribute to the new refactor. + * ───────────────────────────────────────────────────────────────────────── + */ const CreateKey: React.FC = ({ userID, team, diff --git a/ui/litellm-dashboard/src/components/templates/view_key_table.tsx b/ui/litellm-dashboard/src/components/templates/view_key_table.tsx index bad8bdb9833..f3ba12d56d5 100644 --- a/ui/litellm-dashboard/src/components/templates/view_key_table.tsx +++ b/ui/litellm-dashboard/src/components/templates/view_key_table.tsx @@ -135,6 +135,13 @@ interface CombinedLimits { [key: string]: CombinedLimit; // Index signature allowing string keys } +/** + * ───────────────────────────────────────────────────────────────────────── + * @deprecated + * This component is being DEPRECATED in favor of src/app/(console)/virtual-keys/components/VirtualKeysTable/ + * Please contribute to the new refactor. + * ───────────────────────────────────────────────────────────────────────── + */ const ViewKeyTable: React.FC = ({ userID, userRole, diff --git a/ui/litellm-dashboard/src/utils/roles.ts b/ui/litellm-dashboard/src/utils/roles.ts index aafeb010beb..f542da105f5 100644 --- a/ui/litellm-dashboard/src/utils/roles.ts +++ b/ui/litellm-dashboard/src/utils/roles.ts @@ -5,7 +5,7 @@ export const all_admin_roles = [...old_admin_roles, ...v2_admin_role_names]; export const internalUserRoles = ["Internal User", "Internal Viewer"]; export const rolesAllowedToSeeUsage = ["Admin", "Admin Viewer", "Internal User", "Internal Viewer"]; -export const rolesWithWriteAccess = ["Internal User", "Admin"]; +export const rolesWithWriteAccess = ["Internal User", "Admin", "proxy_admin"]; // Helper function to check if a role is in all_admin_roles export const isAdminRole = (role: string): boolean => {