diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx index 724c961bbdc..7f71ee2f08e 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.test.tsx @@ -777,4 +777,92 @@ describe("KeyInfoView", () => { ); }); }); + + describe("allowed_routes payload normalization", () => { + const enterEditMode = async (keyData: KeyResponse) => { + vi.mocked(useAuthorized).mockReturnValue({ + ...baseUseAuthorizedMock, + userId: "proxy-admin-user", + userRole: "proxy_admin", + }); + renderWithProviders( + {}} + keyId="test-key-id" + onKeyDataUpdate={() => {}} + teams={[]} + />, + ); + await userEvent.click(screen.getByRole("tab", { name: /settings/i })); + await userEvent.click(screen.getByRole("button", { name: /edit settings/i })); + await waitFor(() => expect(editViewMocks.onSubmit).toBeDefined()); + }; + + beforeEach(() => { + editViewMocks.onSubmit = undefined; + vi.mocked(keyUpdateCall).mockClear(); + vi.mocked(keyUpdateCall).mockResolvedValue({}); + }); + + it("should drop allowed_routes when the submitted value matches the existing key", async () => { + const keyData: KeyResponse = { + ...MOCK_KEY_DATA, + user_id: "proxy-admin-user", + allowed_routes: ["management_routes"], + } as KeyResponse; + + await enterEditMode(keyData); + await editViewMocks.onSubmit!({ + key: keyData.token, + token: keyData.token, + allowed_routes: ["management_routes"], + }); + + expect(keyUpdateCall).toHaveBeenCalledWith( + expect.anything(), + expect.not.objectContaining({ allowed_routes: expect.anything() }), + ); + }); + + it("should drop empty allowed_routes when the key previously had no route override", async () => { + const keyData: KeyResponse = { + ...MOCK_KEY_DATA, + user_id: "proxy-admin-user", + allowed_routes: [], + } as KeyResponse; + + await enterEditMode(keyData); + await editViewMocks.onSubmit!({ + key: keyData.token, + token: keyData.token, + allowed_routes: [], + }); + + expect(keyUpdateCall).toHaveBeenCalledWith( + expect.anything(), + expect.not.objectContaining({ allowed_routes: expect.anything() }), + ); + }); + + it("should keep allowed_routes when the user clears an existing route override", async () => { + const keyData: KeyResponse = { + ...MOCK_KEY_DATA, + user_id: "proxy-admin-user", + allowed_routes: ["management_routes"], + } as KeyResponse; + + await enterEditMode(keyData); + await editViewMocks.onSubmit!({ + key: keyData.token, + token: keyData.token, + allowed_routes: [], + }); + + expect(keyUpdateCall).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ allowed_routes: [] }), + ); + }); + }); }); diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index 492e43cbc81..1ca71b6e060 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -49,6 +49,30 @@ const isEmptyValue = (v: unknown): boolean => (Array.isArray(v) && v.length === 0) || (typeof v === "string" && v.trim() === ""); +const normalizeStringList = (value: unknown): string[] => { + if (Array.isArray(value)) { + return value + .map((entry) => (typeof entry === "string" ? entry.trim() : "")) + .filter((entry) => entry.length > 0); + } + if (typeof value === "string") { + return value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + } + return []; +}; + +const areStringListsEqual = (left: unknown, right: unknown): boolean => { + const normalizedLeft = normalizeStringList(left); + const normalizedRight = normalizeStringList(right); + return ( + normalizedLeft.length === normalizedRight.length && + normalizedLeft.every((entry, index) => entry === normalizedRight[index]) + ); +}; + /** * ───────────────────────────────────────────────────────────────────────── * @deprecated @@ -174,6 +198,12 @@ export default function KeyInfoView({ } } + // Strip unchanged allowed_routes so non-admin editors don't trip the + // backend "setting allowed_routes" permission check on a no-op save. + if (areStringListsEqual(formValues.allowed_routes, currentKeyData.allowed_routes)) { + delete formValues.allowed_routes; + } + // Handle max budget empty string formValues.max_budget = mapEmptyStringToNull(formValues.max_budget);