From a4ca2e1f2ac3df89341316edbb6933283edbb217 Mon Sep 17 00:00:00 2001 From: Thomas Scholtz Date: Wed, 7 Oct 2026 11:51:24 +0200 Subject: [PATCH] fix(ui): omit blank expiry from key payloads The key edit form and the Regenerate Key dialog sent `duration: ""` when the expiry field was left blank. With `upperbound_key_generate_params.duration` set, the proxy's upperbound check parses it and rejects the request with "Invalid duration format". Drop `duration` from both payload builders when it is blank, so the request follows the update API contract: omitted leaves the expiry unchanged, null never expires, a value sets it --- .../RegenerateKeyModal.integration.test.tsx | 7 ++----- .../organisms/regenerateKeyPayload.test.ts | 11 +++++++++++ .../components/organisms/regenerateKeyPayload.ts | 10 ++++++++-- .../templates/keyEditFormValues.test.ts | 16 ++++++++++++++++ .../components/templates/keyEditFormValues.ts | 2 +- .../templates/key_edit_view.integration.test.tsx | 14 ++++++++++++++ 6 files changed, 52 insertions(+), 8 deletions(-) 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..1b0629ad69f 100644 --- a/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/organisms/RegenerateKeyModal.integration.test.tsx @@ -138,7 +138,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("sends null for cleared numbers and omits a cleared duration", async () => { const user = userEvent.setup(); renderModal(); @@ -155,7 +155,6 @@ describe("RegenerateKeyModal submit payload", () => { max_budget: null, tpm_limit: null, rpm_limit: null, - duration: "", grace_period: "", }); }); @@ -180,10 +179,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.stringify(submittedPayload())).toBe('{"grace_period":""}'); }); it("regenerates a key whose fields the API returned as null", async () => { @@ -206,7 +204,6 @@ describe("RegenerateKeyModal submit payload", () => { max_budget: null, tpm_limit: null, rpm_limit: null, - duration: "", grace_period: "", }); }); diff --git a/ui/litellm-dashboard/src/components/organisms/regenerateKeyPayload.test.ts b/ui/litellm-dashboard/src/components/organisms/regenerateKeyPayload.test.ts index 2411c0d0733..ac278c1eae2 100644 --- a/ui/litellm-dashboard/src/components/organisms/regenerateKeyPayload.test.ts +++ b/ui/litellm-dashboard/src/components/organisms/regenerateKeyPayload.test.ts @@ -96,6 +96,17 @@ describe("buildRegenerateKeyPayload", () => { ]); }); + it("omits a blank duration so the proxy leaves the expiry unchanged", () => { + const payload = buildRegenerateKeyPayload(values({ duration: "" })); + + expect(payload).not.toHaveProperty("duration"); + expect(JSON.stringify(payload)).not.toContain("duration"); + }); + + it("keeps a typed duration", () => { + expect(buildRegenerateKeyPayload(values({ duration: "30d" })).duration).toBe("30d"); + }); + it("does not mutate the values it is handed", () => { const original = values({ max_budget: 42.567 }); diff --git a/ui/litellm-dashboard/src/components/organisms/regenerateKeyPayload.ts b/ui/litellm-dashboard/src/components/organisms/regenerateKeyPayload.ts index b20f099d930..1a912288b64 100644 --- a/ui/litellm-dashboard/src/components/organisms/regenerateKeyPayload.ts +++ b/ui/litellm-dashboard/src/components/organisms/regenerateKeyPayload.ts @@ -21,10 +21,16 @@ export const roundToPrecision = (value: number, precision: number): number => { return value < 0 ? -rounded : rounded; }; -export const buildRegenerateKeyPayload = (values: RegenerateKeyFormValues): RegenerateKeyFormValues => ({ - ...values, +export type RegenerateKeyPayload = Omit & { duration?: string }; + +export const buildRegenerateKeyPayload = (values: RegenerateKeyFormValues): RegenerateKeyPayload => ({ + key_alias: values.key_alias, max_budget: typeof values.max_budget === "number" ? roundToPrecision(values.max_budget, MAX_BUDGET_PRECISION) : values.max_budget, + tpm_limit: values.tpm_limit, + rpm_limit: values.rpm_limit, + ...(values.duration === "" ? {} : { duration: values.duration }), + grace_period: values.grace_period, }); diff --git a/ui/litellm-dashboard/src/components/templates/keyEditFormValues.test.ts b/ui/litellm-dashboard/src/components/templates/keyEditFormValues.test.ts index f12088bea19..a58ffe04830 100644 --- a/ui/litellm-dashboard/src/components/templates/keyEditFormValues.test.ts +++ b/ui/litellm-dashboard/src/components/templates/keyEditFormValues.test.ts @@ -25,6 +25,22 @@ describe("tpd_limit round trip", () => { }); }); +describe("duration in the submitted payload", () => { + const submit = (duration: string | null) => + toSubmittedValues( + { ...toKeyEditFormValues({ token: "tok", models: [] } as unknown as KeyResponse), duration }, + { canViewPolicies: true, canViewPrompts: true }, + ); + + it("omits a blank duration so the proxy leaves the expiry unchanged", () => { + expect(submit("")).not.toHaveProperty("duration"); + }); + + it.each([["30d"], [null]])("keeps duration %s", (duration) => { + expect(submit(duration)).toHaveProperty("duration", duration); + }); +}); + describe("keyEditFormSchema", () => { it("accepts an empty form", () => { expect(parse({}).success).toBe(true); diff --git a/ui/litellm-dashboard/src/components/templates/keyEditFormValues.ts b/ui/litellm-dashboard/src/components/templates/keyEditFormValues.ts index 0ae60bbcc81..06a051c829d 100644 --- a/ui/litellm-dashboard/src/components/templates/keyEditFormValues.ts +++ b/ui/litellm-dashboard/src/components/templates/keyEditFormValues.ts @@ -213,7 +213,7 @@ export const toSubmittedValues = ( team_id: values.team_id, logging_settings: values.logging_settings, metadata: values.metadata, - duration: values.duration, + ...(values.duration === "" ? {} : { 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 9e3e19f1e1b..7e685f60ab7 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 @@ -2218,6 +2218,20 @@ describe("KeyEditView", () => { expect(onSubmitMock.mock.calls[0][0]).toHaveProperty("duration", null); }); + it("untouched expiry sends no duration key", async () => { + const { duration: _storedDuration, ...keyWithoutDuration } = MOCK_KEY_DATA; + const onSubmitMock = vi.fn().mockResolvedValue(undefined); + renderForPayload(onSubmitMock, { ...keyWithoutDuration, expires: "2030-01-01T00:00:00Z" } as KeyResponse); + await screen.findByRole("button", { name: /save changes/i }); + + await userEvent.click(screen.getByRole("button", { name: /save changes/i })); + + await waitFor(() => { + expect(onSubmitMock).toHaveBeenCalled(); + }); + expect(onSubmitMock.mock.calls[0][0]).not.toHaveProperty("duration"); + }); + it("carries a typed value from every free-text and numeric control into the payload", async () => { const onSubmitMock = vi.fn().mockResolvedValue(undefined); renderForPayload(onSubmitMock);