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..7f61dcaa809 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 @@ -776,5 +776,65 @@ describe("KeyInfoView", () => { expect.objectContaining({ policies: [] }), ); }); + + 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..175f03b489f 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,33 @@ 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 +201,13 @@ export default function KeyInfoView({ } } + // The edit form always includes allowed_routes. If the user didn't change it, + // strip it from the payload 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);