diff --git a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.integration.test.tsx b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.integration.test.tsx index 0a1ddd73313..358382b21e4 100644 --- a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.integration.test.tsx @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils"; import { RegenerateKeyModal } from "./RegenerateKeyModal"; @@ -58,16 +59,22 @@ describe("RegenerateKeyModal submit payload", () => { }); }); - it("keeps the wire body's key order and drops nothing through JSON serialisation", async () => { + it("submits the expected regenerated key fields", async () => { const user = userEvent.setup(); + const expectedPayload = { + key_alias: "my-test-key", + max_budget: 100, + tpm_limit: 5000, + rpm_limit: 500, + grace_period: "", + duration: "30d", + }; renderModal(); await regenerate(user); await waitFor(() => expect(mockRegenerateKeyCall).toHaveBeenCalledOnce()); - expect(JSON.stringify(submittedPayload())).toBe( - '{"key_alias":"my-test-key","max_budget":100,"tpm_limit":5000,"rpm_limit":500,"duration":"30d","grace_period":""}', - ); + expect(submittedPayload()).toEqual(expectedPayload); }); it("rounds max_budget to two decimals and sends the other numbers unrounded", async () => { @@ -138,7 +145,7 @@ describe("RegenerateKeyModal submit payload", () => { expect(submittedPayload()[key]).toBe(12.7); }); - it("sends null for cleared numbers and an empty string for a cleared duration", async () => { + it("omits a cleared duration while sending null for cleared numbers", async () => { const user = userEvent.setup(); renderModal(); @@ -155,7 +162,6 @@ describe("RegenerateKeyModal submit payload", () => { max_budget: null, tpm_limit: null, rpm_limit: null, - duration: "", grace_period: "", }); }); @@ -180,10 +186,9 @@ describe("RegenerateKeyModal submit payload", () => { max_budget: undefined, tpm_limit: undefined, rpm_limit: undefined, - duration: "", grace_period: "", }); - expect(JSON.stringify(submittedPayload())).toBe('{"duration":"","grace_period":""}'); + expect(JSON.parse(JSON.stringify(submittedPayload()))).toEqual({ grace_period: "" }); }); it("regenerates a key whose fields the API returned as null", async () => { @@ -206,11 +211,21 @@ describe("RegenerateKeyModal submit payload", () => { max_budget: null, tpm_limit: null, rpm_limit: null, - duration: "", grace_period: "", }); }); + it("sends a typed duration when the original key has no stored duration", async () => { + const user = userEvent.setup(); + renderModal(makeToken({ duration: undefined })); + + fireEvent.change(screen.getByPlaceholderText("e.g. 30s, 30h, 30d"), { target: { value: "30d" } }); + await regenerate(user); + + await waitFor(() => expect(mockRegenerateKeyCall).toHaveBeenCalledOnce()); + expect(submittedPayload()).toHaveProperty("duration", "30d"); + }); + it("targets the key by its token hash", async () => { const user = userEvent.setup(); renderModal(); diff --git a/ui/litellm-dashboard/src/components/organisms/regenerateKeyPayload.test.ts b/ui/litellm-dashboard/src/components/organisms/regenerateKeyPayload.test.ts index 2411c0d0733..d48f11d195e 100644 --- a/ui/litellm-dashboard/src/components/organisms/regenerateKeyPayload.test.ts +++ b/ui/litellm-dashboard/src/components/organisms/regenerateKeyPayload.test.ts @@ -62,6 +62,13 @@ describe("buildRegenerateKeyPayload", () => { }); }); + it.each(["", " "])("omits blank duration %j from the payload and JSON body", (duration) => { + const payload = buildRegenerateKeyPayload(values({ duration })); + + expect(payload).not.toHaveProperty("duration"); + expect(JSON.stringify(payload)).not.toContain('"duration"'); + }); + it.each([ ["null", null], ["undefined", undefined], @@ -82,18 +89,10 @@ describe("buildRegenerateKeyPayload", () => { duration: "30d", grace_period: "", }); - expect(JSON.stringify(payload)).toBe('{"duration":"30d","grace_period":""}'); - }); - - it("preserves the field order the proxy request body is serialised in", () => { - expect(Object.keys(buildRegenerateKeyPayload(values({ max_budget: 1.239 })))).toStrictEqual([ - "key_alias", - "max_budget", - "tpm_limit", - "rpm_limit", - "duration", - "grace_period", - ]); + expect(JSON.parse(JSON.stringify(payload))).toEqual({ + grace_period: "", + duration: "30d", + }); }); it("does not mutate the values it is handed", () => { diff --git a/ui/litellm-dashboard/src/components/organisms/regenerateKeyPayload.ts b/ui/litellm-dashboard/src/components/organisms/regenerateKeyPayload.ts index b20f099d930..69e682f260f 100644 --- a/ui/litellm-dashboard/src/components/organisms/regenerateKeyPayload.ts +++ b/ui/litellm-dashboard/src/components/organisms/regenerateKeyPayload.ts @@ -9,6 +9,8 @@ export interface RegenerateKeyFormValues { grace_period: string; } +export type RegenerateKeyPayload = Omit & { duration?: string }; + const shiftExponent = (value: number, places: number): number => { const [mantissa, exponent = "0"] = value.toExponential().split("e"); return Number(`${mantissa}e${Number(exponent) + places}`); @@ -21,10 +23,13 @@ export const roundToPrecision = (value: number, precision: number): number => { return value < 0 ? -rounded : rounded; }; -export const buildRegenerateKeyPayload = (values: RegenerateKeyFormValues): RegenerateKeyFormValues => ({ - ...values, - max_budget: - typeof values.max_budget === "number" - ? roundToPrecision(values.max_budget, MAX_BUDGET_PRECISION) - : values.max_budget, -}); +export const buildRegenerateKeyPayload = (values: RegenerateKeyFormValues): RegenerateKeyPayload => { + const { duration, ...rest } = values; + + return { + ...rest, + max_budget: + typeof rest.max_budget === "number" ? roundToPrecision(rest.max_budget, MAX_BUDGET_PRECISION) : rest.max_budget, + ...(duration.trim() !== "" ? { duration } : {}), + }; +}; diff --git a/ui/litellm-dashboard/src/components/templates/keyEditFormValues.test.ts b/ui/litellm-dashboard/src/components/templates/keyEditFormValues.test.ts index f12088bea19..872f801634c 100644 --- a/ui/litellm-dashboard/src/components/templates/keyEditFormValues.test.ts +++ b/ui/litellm-dashboard/src/components/templates/keyEditFormValues.test.ts @@ -53,3 +53,17 @@ describe("keyEditFormSchema", () => { expect(parse({ default_estimated_output_tokens_per_model: '{"gpt-4": 4096}' }).success).toBe(true); }); }); + +describe("duration submission", () => { + const submit = (duration: string | null) => + toSubmittedValues({ duration }, { canViewPolicies: false, canViewPrompts: false }); + + it.each(["", " "])("omits a blank duration value %j", (duration) => { + expect(submit(duration)).not.toHaveProperty("duration"); + }); + + it("preserves a typed duration and null", () => { + expect(submit("30d")).toHaveProperty("duration", "30d"); + expect(submit(null)).toHaveProperty("duration", null); + }); +}); diff --git a/ui/litellm-dashboard/src/components/templates/keyEditFormValues.ts b/ui/litellm-dashboard/src/components/templates/keyEditFormValues.ts index ffbdd7ae086..9dd3601ef31 100644 --- a/ui/litellm-dashboard/src/components/templates/keyEditFormValues.ts +++ b/ui/litellm-dashboard/src/components/templates/keyEditFormValues.ts @@ -215,7 +215,7 @@ export const toSubmittedValues = ( team_id: values.team_id, logging_settings: values.logging_settings, metadata: values.metadata, - duration: values.duration, + ...(typeof values.duration === "string" && values.duration.trim() === "" ? {} : { duration: values.duration }), token: values.token, disabled_callbacks: values.disabled_callbacks, auto_rotate: values.auto_rotate, diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx index 4dd98b31781..b6319b3713e 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.integration.test.tsx @@ -2542,6 +2542,37 @@ describe("KeyEditView", () => { expect(onSubmitMock.mock.calls[0][0]).toHaveProperty("duration", null); }); + it("omits an untouched blank duration for an expiring key", async () => { + const onSubmitMock = vi.fn().mockResolvedValue(undefined); + renderForPayload(onSubmitMock, { + ...MOCK_KEY_DATA, + expires: "2026-12-31T00:00:00Z", + duration: undefined, + }); + await screen.findByRole("button", { name: /save changes/i }); + + await userEvent.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => expect(onSubmitMock).toHaveBeenCalledTimes(1)); + expect(onSubmitMock.mock.calls[0][0]).not.toHaveProperty("duration"); + }); + + it("submits a typed expiry duration", async () => { + const onSubmitMock = vi.fn().mockResolvedValue(undefined); + renderForPayload(onSubmitMock, { + ...MOCK_KEY_DATA, + expires: "2026-12-31T00:00:00Z", + duration: undefined, + }); + await screen.findByRole("button", { name: /save changes/i }); + + fireEvent.change(screen.getByPlaceholderText("e.g., 30d"), { target: { value: "30d" } }); + await userEvent.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => expect(onSubmitMock).toHaveBeenCalledTimes(1)); + expect(onSubmitMock.mock.calls[0][0]).toHaveProperty("duration", "30d"); + }); + it("carries a typed value from every free-text and numeric control into the payload", async () => { const onSubmitMock = vi.fn().mockResolvedValue(undefined); renderForPayload(onSubmitMock);