diff --git a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx index d30f0ff92e..6b79b54ec0 100644 --- a/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx +++ b/ui/litellm-dashboard/src/components/Settings/LoggingAndAlerts/LoggingCallbacks/LoggingCallbacksTable.tsx @@ -1,9 +1,8 @@ import { PencilAltIcon, PlayIcon, TrashIcon } from "@heroicons/react/outline"; import { Button, Icon } from "@tremor/react"; import type { TableProps } from "antd"; -import { Table } from "antd"; +import { Table, Tooltip } from "antd"; import Title from "antd/es/typography/Title"; -import { PlusCircle } from "lucide-react"; import React from "react"; import { AlertingObject } from "./types"; @@ -80,31 +79,31 @@ export const LoggingCallbacksTable: React.FC = ({ align: "right", render: (_: unknown, record: CallbackRow) => (
- + onTest(record)} /> - + - + onEdit(record)} /> - - + + onDelete(record)} /> - +
), width: 240, @@ -124,16 +123,9 @@ export const LoggingCallbacksTable: React.FC = ({

No callbacks configured

-

Add your first callback to start logging data to external services.

- +

Add your first callback to start logging data to external services.

-
/* Callbacks list */ + ) : (
{ expect(mockGetCallbackConfigsCall).toHaveBeenCalledWith(defaultProps.accessToken); }); }); + + it("should display edit modal with fields when edit is clicked", async () => { + const mockCallback = { + name: "langfuse", + variables: { + LANGFUSE_PUBLIC_KEY: "test-public-key", + LANGFUSE_SECRET_KEY: "test-secret-key", + LANGFUSE_HOST: "https://test.langfuse.com", + SLACK_WEBHOOK_URL: null, + OPENMETER_API_KEY: null, + }, + }; + + const mockCallbackConfig = { + id: "langfuse", + displayName: "Langfuse", + dynamic_params: { + LANGFUSE_PUBLIC_KEY: { + type: "text", + ui_name: "Public Key", + required: true, + }, + LANGFUSE_SECRET_KEY: { + type: "password", + ui_name: "Secret Key", + required: true, + }, + LANGFUSE_HOST: { + type: "text", + ui_name: "Host", + required: false, + }, + }, + }; + + mockGetCallbacksCall.mockResolvedValue({ + callbacks: [mockCallback], + available_callbacks: { + langfuse: { + litellm_callback_name: "langfuse", + litellm_callback_params: ["LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY", "LANGFUSE_HOST"], + ui_callback_name: "Langfuse", + }, + }, + alerts: [], + }); + + mockGetCallbackConfigsCall.mockResolvedValue([mockCallbackConfig]); + + const { getByText, container } = render(); + + await waitFor(() => { + expect(getByText("Active Logging Callbacks")).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(getByText("Langfuse")).toBeInTheDocument(); + }); + + const actionsCell = container.querySelector('[class*="flex justify-end gap-2"]'); + expect(actionsCell).toBeTruthy(); + + const icons = actionsCell?.querySelectorAll("svg"); + expect(icons?.length).toBeGreaterThanOrEqual(2); + + const editIconParent = icons?.[1]?.closest('[class*="cursor-pointer"]'); + expect(editIconParent).toBeTruthy(); + + act(() => { + fireEvent.click(editIconParent!); + }); + + await waitFor(() => { + expect(getByText("Edit Callback Settings")).toBeInTheDocument(); + }); + + await waitFor(() => { + expect(getByText("Public Key")).toBeInTheDocument(); + expect(getByText("Secret Key")).toBeInTheDocument(); + expect(getByText("Host")).toBeInTheDocument(); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/settings.tsx b/ui/litellm-dashboard/src/components/settings.tsx index 3f06f08de6..f8a7bafce5 100644 --- a/ui/litellm-dashboard/src/components/settings.tsx +++ b/ui/litellm-dashboard/src/components/settings.tsx @@ -53,6 +53,164 @@ interface genericCallbackParams { const assetsLogoFolder = "../ui/assets/logos/"; +interface DynamicParamsFieldsProps { + params: string[]; + callbackConfigs: any[]; + selectedCallback: string | null; +} + +const DynamicParamsFields: React.FC = ({ params, callbackConfigs, selectedCallback }) => { + if (!params || params.length === 0) { + return null; + } + + return ( +
+ {params.map((param) => { + const callbackConfig = callbackConfigs.find((config) => config.id === selectedCallback); + const paramConfig = callbackConfig?.dynamic_params?.[param] || {}; + const paramType = paramConfig.type || "text"; + const fieldLabel = paramConfig.ui_name || param.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); + const isRequired = paramConfig.required || false; + + return ( + {fieldLabel} } + name={param} + key={param} + className="mb-4" + rules={ + isRequired + ? [ + { + required: true, + message: `Please enter the ${fieldLabel.toLowerCase()}`, + }, + ] + : undefined + } + > + {paramType === "password" ? ( + + ) : paramType === "number" ? ( + + ) : ( + + )} + + ); + })} +
+ ); +}; + +// Shared component for rendering callback selector +interface CallbackSelectorProps { + callbackConfigs: any[]; + selectedCallback: string | null; + onCallbackChange: (value: string) => void; + disabled?: boolean; +} + +const CallbackSelector: React.FC = ({ + callbackConfigs, + selectedCallback, + onCallbackChange, + disabled = false, +}) => { + return ( + + + + ); +}; + +// Shared helper function to get dynamic params for a callback +const getDynamicParamsForCallback = ( + callbackName: string | null, + callbackConfigs: any[], + fallbackVariables?: Record, +): string[] => { + if (!callbackName) { + return fallbackVariables ? Object.keys(fallbackVariables) : []; + } + + const callbackConfig = callbackConfigs.find((config) => config.id === callbackName); + if (callbackConfig?.dynamic_params) { + return Object.keys(callbackConfig.dynamic_params); + } + + return fallbackVariables ? Object.keys(fallbackVariables) : []; +}; + +// Shared helper function to build callback payload +const buildCallbackPayload = (formValues: Record, callbackName: string) => { + return { + environment_variables: formValues, + litellm_settings: { + success_callback: [callbackName], + }, + }; +}; + const Settings: React.FC = ({ accessToken, userRole, userID, premiumUser }) => { const [callbacks, setCallbacks] = useState([]); const [alerts, setAlerts] = useState([]); @@ -83,6 +241,8 @@ const Settings: React.FC = ({ accessToken, userRole, userID, const [selectedEditCallback, setSelectedEditCallback] = useState(null); const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false); const [callbackToDelete, setCallbackToDelete] = useState(null); + const [isUpdatingCallback, setIsUpdatingCallback] = useState(false); + const [isAddingCallback, setIsAddingCallback] = useState(false); useEffect(() => { if (!accessToken) { @@ -102,7 +262,10 @@ const Settings: React.FC = ({ accessToken, userRole, userID, const normalized = Object.fromEntries( Object.entries(selectedEditCallback.variables || {}).map(([k, v]) => [k, v ?? ""]), ); - editForm.setFieldsValue(normalized); + editForm.setFieldsValue({ + ...normalized, + callback: selectedEditCallback.name, + }); } }, [showEditCallback, selectedEditCallback, editForm]); @@ -154,31 +317,36 @@ const Settings: React.FC = ({ accessToken, userRole, userID, return activeAlerts && activeAlerts.includes(alertName); }; - const updateCallbackCall = async (formValues: Record) => { - if (!accessToken || !selectedEditCallback) { + // Shared handler for callback form submission + const handleCallbackSubmit = async (formValues: Record, callbackName: string, isEdit: boolean) => { + if (!accessToken) { return; } - let env_vars: Record = {}; - // add all other variables - Object.entries(formValues).forEach(([key, value]) => { - if (key !== "callback") { - env_vars[key] = value; - } - }); - let payload = { - environment_variables: formValues, - litellm_settings: { - success_callback: [selectedEditCallback.name], - }, - }; + if (isEdit) { + setIsUpdatingCallback(true); + } else { + setIsAddingCallback(true); + } + + const payload = buildCallbackPayload(formValues, callbackName); try { await setCallbacksCall(accessToken, payload); - NotificationsManager.success("Callback updated successfully"); - setShowEditCallback(false); - editForm.resetFields(); - setSelectedEditCallback(null); + NotificationsManager.success( + isEdit ? "Callback updated successfully" : `Callback ${callbackName} added successfully`, + ); + + if (isEdit) { + setShowEditCallback(false); + editForm.resetFields(); + setSelectedEditCallback(null); + } else { + setShowAddCallbacksModal(false); + addForm.resetFields(); + setSelectedCallback(null); + setSelectedCallbackParams([]); + } // Refresh the callbacks list if (userID && userRole) { @@ -187,56 +355,34 @@ const Settings: React.FC = ({ accessToken, userRole, userID, } } catch (error) { NotificationsManager.fromBackend(error); + } finally { + if (isEdit) { + setIsUpdatingCallback(false); + } else { + setIsAddingCallback(false); + } } }; - const addNewCallbackCall = async (formValues: Record) => { - if (!accessToken) { + const updateCallbackCall = async (formValues: Record) => { + if (!selectedEditCallback) { return; } - let new_callback = formValues?.callback; + await handleCallbackSubmit(formValues, selectedEditCallback.name, true); + }; - let env_vars: Record = {}; - // add all other variables - Object.entries(formValues).forEach(([key, value]) => { - if (key !== "callback") { - env_vars[key] = value; - } - }); - - let payload = { - environment_variables: formValues, - litellm_settings: { - success_callback: [new_callback], - }, - }; - - try { - await setCallbacksCall(accessToken, payload); - NotificationsManager.success(`Callback ${new_callback} added successfully`); - setShowAddCallbacksModal(false); - addForm.resetFields(); - setSelectedCallback(null); - setSelectedCallbackParams([]); - - // Refresh the callbacks list - const updatedData = await getCallbacksCall(accessToken, userID || "", userRole || ""); - setCallbacks(updatedData.callbacks); - } catch (error) { - NotificationsManager.fromBackend(error); + const addNewCallbackCall = async (formValues: Record) => { + const new_callback = formValues?.callback; + if (!new_callback) { + return; } + await handleCallbackSubmit(formValues, new_callback, false); }; const handleSelectedCallbackChange = (callbackName: string) => { setSelectedCallback(callbackName); - - const callbackConfig = callbackConfigs.find((config) => config.id === callbackName); - - if (callbackConfig?.dynamic_params) { - setSelectedCallbackParams(Object.keys(callbackConfig.dynamic_params)); - } else { - setSelectedCallbackParams([]); - } + const params = getDynamicParamsForCallback(callbackName, callbackConfigs); + setSelectedCallbackParams(params); }; const handleSaveAlerts = async () => { @@ -564,103 +710,17 @@ const Settings: React.FC = ({ accessToken, userRole, userID, wrapperCol={{ span: 16 }} labelAlign="left" > - - - - - {selectedCallbackParams && selectedCallbackParams.length > 0 && ( -
- {selectedCallbackParams.map((param) => { - const callbackConfig = callbackConfigs.find((config) => config.id === selectedCallback); - const paramConfig = callbackConfig?.dynamic_params?.[param] || {}; - const paramType = paramConfig.type || "text"; - const fieldLabel = - paramConfig.ui_name || param.replace(/_/g, " ").replace(/\b\w/g, (l) => l.toUpperCase()); - const isRequired = paramConfig.required || false; - - return ( - {fieldLabel} } - name={param} - key={param} - className="mb-4" - rules={ - isRequired - ? [ - { - required: true, - message: `Please enter the ${fieldLabel.toLowerCase()}`, - }, - ] - : undefined - } - > - {paramType === "password" ? ( - - ) : paramType === "number" ? ( - - ) : ( - - )} - - ); - })} -
- )} +
= ({ accessToken, userRole, userID, setSelectedCallbackParams([]); addForm.resetFields(); }} + disabled={isAddingCallback} > Cancel - Add Callback + + {isAddingCallback ? "Adding..." : "Add Callback"} +
{ setShowEditCallback(false); setSelectedEditCallback(null); + editForm.resetFields(); }} footer={null} > @@ -695,35 +759,54 @@ const Settings: React.FC = ({ accessToken, userRole, userID, wrapperCol={{ span: 16 }} labelAlign="left" > - <> - {selectedEditCallback && - selectedEditCallback.variables && - Object.entries(selectedEditCallback.variables).map(([param]) => ( - - - - ))} - + {selectedEditCallback && ( + <> + {}} + disabled={true} + /> -
- Save + + + )} + +
+ { + setShowEditCallback(false); + setSelectedEditCallback(null); + editForm.resetFields(); + }} + disabled={isUpdatingCallback} + > + Cancel + + { + editForm.submit(); + }} + loading={isUpdatingCallback} + disabled={isUpdatingCallback} + > + {isUpdatingCallback ? "Saving..." : "Save Changes"} +
{ setShowDeleteConfirmModal(false);