diff --git a/ui/litellm-dashboard/src/components/settings.test.tsx b/ui/litellm-dashboard/src/components/settings.test.tsx index 9b67657dbca..280bffd8fe9 100644 --- a/ui/litellm-dashboard/src/components/settings.test.tsx +++ b/ui/litellm-dashboard/src/components/settings.test.tsx @@ -439,6 +439,40 @@ describe("Settings", () => { }); }); + it("should post the saved s3_v2 folder partitioning when unchanged", async () => { + mockS3Callback({ S3_LOG_PROMPTS_ONLY: null, S3_PARTITION_GRANULARITY: "hour" }, "s3_v2"); + const user = await openS3EditModal("s3_v2"); + + const dialog = screen.getByRole("dialog"); + const partitioning = await within(dialog).findByRole("combobox", { name: "Folder Partitioning" }); + expect(partitioning).toHaveTextContent("hour"); + + await user.click(await within(dialog).findByRole("button", { name: "Save Changes" })); + await waitFor(() => { + expect(vi.mocked(setCallbacksCall)).toHaveBeenCalledTimes(1); + }); + const [, payload] = vi.mocked(setCallbacksCall).mock.calls[0]; + expect(payload.environment_variables.s3_partition_granularity).toBe("hour"); + }); + + it("should leave an unset s3_v2 folder partitioning out of the save payload", async () => { + mockS3Callback({ S3_LOG_PROMPTS_ONLY: null, S3_PARTITION_GRANULARITY: null }, "s3_v2"); + const user = await openS3EditModal("s3_v2"); + + const dialog = screen.getByRole("dialog"); + const partitioning = await within(dialog).findByRole("combobox", { name: "Folder Partitioning" }); + expect(partitioning).toHaveTextContent("Select folder partitioning"); + expect(partitioning).not.toHaveTextContent(/day|hour/i); + + await user.click(await within(dialog).findByRole("button", { name: "Save Changes" })); + await waitFor(() => { + expect(vi.mocked(setCallbacksCall)).toHaveBeenCalledTimes(1); + }); + const [, payload] = vi.mocked(setCallbacksCall).mock.calls[0]; + expect(Object.keys(payload.environment_variables)).not.toContain("s3_partition_granularity"); + expect(payload.environment_variables.callback).toBe("s3_v2"); + }); + it("should not offer folder partitioning for the legacy s3 callback, which cannot honour it", async () => { mockS3Callback({ S3_LOG_PROMPTS_ONLY: null, S3_PARTITION_GRANULARITY: null }); const user = await openS3EditModal(); diff --git a/ui/litellm-dashboard/src/components/settings.tsx b/ui/litellm-dashboard/src/components/settings.tsx index e376d858df8..7b08c53df87 100644 --- a/ui/litellm-dashboard/src/components/settings.tsx +++ b/ui/litellm-dashboard/src/components/settings.tsx @@ -285,7 +285,7 @@ const getDynamicParamsForCallback = ( // Shared helper function to build callback payload const buildCallbackPayload = (formValues: Record, callbackName: string) => { return { - environment_variables: formValues, + environment_variables: Object.fromEntries(Object.entries(formValues).filter(([, value]) => value !== undefined)), litellm_settings: { success_callback: [callbackName], }, @@ -348,8 +348,15 @@ const Settings: React.FC = ({ accessToken, userRole, userID, ); const fieldNameFor = (variable: string) => params.find((param) => param.toUpperCase() === variable.toUpperCase()) ?? variable; + const callbackConfig = findCallbackConfig(callbackConfigs, selectedEditCallback.name); const normalized = Object.fromEntries( - Object.entries(selectedEditCallback.variables || {}).map(([k, v]) => [fieldNameFor(k), v ?? ""]), + Object.entries(selectedEditCallback.variables || {}).flatMap(([key, value]) => { + const fieldName = fieldNameFor(key); + if (value == null && callbackConfig?.dynamic_params?.[fieldName]?.type === "select") { + return []; + } + return [[fieldName, value ?? ""]]; + }), ); editForm.reset({ ...normalized,