From 03804cb1d29b1fc930c64089e03f7a08e17156f7 Mon Sep 17 00:00:00 2001 From: Achintya Rajan Date: Sun, 5 Oct 2025 16:01:20 -0700 Subject: [PATCH] preliminary virtual-keys refactor simplifies handleCreate function for CreateKeyModal moved hooks, components, files where I want them moved virtual-key page to (console) for common layout added layout for refactored console file structure, sidebar wired teams to refactored components fixing type issue wired new top level hook useAuthorized Adds routing to unrefactored pages Update leftnav.tsx Update useAuthorized.ts useAuthorized in virtual-keys/page and deprecation notices prelim VirtualKeyViewPage, removed old comment Delete page.tsx --- .../src/app/(console)/components/Sidebar.tsx | 291 ++++++++ .../components/modals/CreateUserModal.tsx | 345 +++++++++ .../src/app/(console)/hooks/useAuthorized.ts | 23 + .../src/app/(console)/layout.tsx | 46 ++ .../virtual-keys/components/CreateKey.tsx | 108 +++ .../CreateKeyForm/KeyDetailsSection.tsx | 148 ++++ .../CreateKeyForm/OptionalSettingsSection.tsx | 458 ++++++++++++ .../CreateKeyForm/OwnershipSection.tsx | 130 ++++ .../CreateKeyModal/CreateKeyModal.tsx | 233 ++++++ .../components/CreateKeyModal/hooks/index.ts | 5 + .../hooks/useGuardrailsAndPrompts.ts | 24 + .../hooks/useMcpAccessGroups.ts | 21 + .../CreateKeyModal/hooks/useTeamModels.ts | 26 + .../CreateKeyModal/hooks/useUserModels.ts | 21 + .../CreateKeyModal/hooks/useUserSearch.ts | 41 ++ .../components/CreateKeyModal/networking.ts | 84 +++ .../components/CreateKeyModal/types.ts | 17 + .../components/CreateKeyModal/utils.ts | 187 +++++ .../virtual-keys/components/KeyInfoView.tsx | 697 ++++++++++++++++++ .../virtual-keys/components/SaveKeyModal.tsx | 58 ++ .../VirtualKeysTable/VirtualKeysTable.tsx | 666 +++++++++++++++++ .../VirtualKeysTable/hooks/useFilterLogic.ts | 189 +++++ .../(console)/virtual-keys/hooks/useTeams.tsx | 20 + .../app/(console)/virtual-keys/networking.ts | 17 + .../src/app/(console)/virtual-keys/page.tsx | 52 ++ ui/litellm-dashboard/src/app/page.tsx | 2 - .../src/components/leftnav.tsx | 50 +- .../organisms/create_key_button.tsx | 8 + .../components/templates/view_key_table.tsx | 7 + ui/litellm-dashboard/src/utils/roles.ts | 2 +- 30 files changed, 3941 insertions(+), 35 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(console)/components/Sidebar.tsx create mode 100644 ui/litellm-dashboard/src/app/(console)/components/modals/CreateUserModal.tsx create mode 100644 ui/litellm-dashboard/src/app/(console)/hooks/useAuthorized.ts create mode 100644 ui/litellm-dashboard/src/app/(console)/layout.tsx create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKey.tsx create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection.tsx create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection.tsx create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OwnershipSection.tsx create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyModal.tsx create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/index.ts create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useGuardrailsAndPrompts.ts create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useMcpAccessGroups.ts create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useTeamModels.ts create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useUserModels.ts create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/hooks/useUserSearch.ts create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/networking.ts create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/types.ts create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/CreateKeyModal/utils.ts create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/KeyInfoView.tsx create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/SaveKeyModal.tsx create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/VirtualKeysTable/VirtualKeysTable.tsx create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/components/VirtualKeysTable/hooks/useFilterLogic.ts create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/hooks/useTeams.tsx create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/networking.ts create mode 100644 ui/litellm-dashboard/src/app/(console)/virtual-keys/page.tsx 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 => {