fix(ui): persist budget window deletion on virtual keys (#31107)

Deleting every budget window from a virtual key looked like it saved but
reverted on reload, while editing a window persisted. The key edit form set
budget_limits to undefined once the window list was emptied, and
JSON.stringify drops undefined keys, so /key/update received no budget_limits
field at all and model_dump(exclude_unset=True) skipped the existing
clear-on-empty branch. Sending [] instead lets the backend store JSON null and
clear the stored windows, matching how it already treats an explicit empty list

Resolves LIT-3742
This commit is contained in:
ryan-crabbe-berri 2026-06-24 09:19:53 -07:00 committed by GitHub
parent 8bca05d311
commit 8f4389246d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 100 additions and 2 deletions

View file

@ -600,6 +600,96 @@ describe("KeyEditView", () => {
});
});
it("should submit budget_limits: [] when the last budget window is deleted", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
const keyDataWithWindow = {
...MOCK_KEY_DATA,
budget_limits: [{ budget_duration: "30d", max_budget: 100 }],
};
renderWithProviders(
<KeyEditView
keyData={keyDataWithWindow}
onCancel={() => {}}
onSubmit={onSubmitMock}
accessToken={"test-token"}
userID={"test-user"}
userRole={"admin"}
premiumUser={false}
/>,
);
const deleteWindowButton = await screen.findByRole("button", { name: "✕" });
await userEvent.click(deleteWindowButton);
const submitButton = screen.getByRole("button", { name: /save changes/i });
await userEvent.click(submitButton);
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
const callArgs = onSubmitMock.mock.calls[0][0];
expect(callArgs.budget_limits).toEqual([]);
});
});
it("should resend existing budget windows on submit when they are left untouched", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
const keyDataWithWindow = {
...MOCK_KEY_DATA,
budget_limits: [{ budget_duration: "30d", max_budget: 100 }],
};
renderWithProviders(
<KeyEditView
keyData={keyDataWithWindow}
onCancel={() => {}}
onSubmit={onSubmitMock}
accessToken={"test-token"}
userID={"test-user"}
userRole={"admin"}
premiumUser={false}
/>,
);
const submitButton = await screen.findByRole("button", { name: /save changes/i });
await userEvent.click(submitButton);
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
const callArgs = onSubmitMock.mock.calls[0][0];
expect(callArgs.budget_limits).toEqual([{ budget_duration: "30d", max_budget: 100 }]);
});
});
it("should omit budget_limits (not clear stored windows) when a window is left incomplete", async () => {
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
const keyDataWithWindow = {
...MOCK_KEY_DATA,
budget_limits: [{ budget_duration: "30d", max_budget: 100 }],
};
renderWithProviders(
<KeyEditView
keyData={keyDataWithWindow}
onCancel={() => {}}
onSubmit={onSubmitMock}
accessToken={"test-token"}
userID={"test-user"}
userRole={"admin"}
premiumUser={false}
/>,
);
const maxBudgetInput = await screen.findByPlaceholderText("Max spend ($)");
await userEvent.clear(maxBudgetInput);
const submitButton = screen.getByRole("button", { name: /save changes/i });
await userEvent.click(submitButton);
await waitFor(() => {
expect(onSubmitMock).toHaveBeenCalled();
const callArgs = onSubmitMock.mock.calls[0][0];
expect(callArgs.budget_limits).toBeUndefined();
});
});
it("should display 'AI APIs' label for the llm_api key type option", async () => {
const keyDataWithLlmApiRoutes = {
...MOCK_KEY_DATA,

View file

@ -290,11 +290,19 @@ export function KeyEditView({
values.duration = null;
}
// Include multi-window budget limits (filter out incomplete entries)
// Reconcile multi-window budget limits from the editor state, dropping
// incomplete entries (no max_budget). Sending [] tells the backend to clear
// all stored windows, so only send it when the user removed every window;
// when entries remain but are still incomplete, omit the field so the saved
// windows are left untouched (JSON.stringify drops the undefined key).
const validWindows = budgetLimits.filter(
(w) => w.budget_duration && w.max_budget !== null && w.max_budget !== undefined,
);
values.budget_limits = validWindows.length > 0 ? validWindows : undefined;
if (validWindows.length > 0) {
values.budget_limits = validWindows;
} else if (budgetLimits.length === 0) {
values.budget_limits = [];
}
await onSubmit(values);
} finally {