mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(ui): allow clearing a key's budget reset from the Edit Key form (#36140)
This commit is contained in:
parent
a79d9bacbf
commit
429a5dc430
6 changed files with 135 additions and 5 deletions
|
|
@ -236,7 +236,7 @@ const TeamSSOSettings: React.FC<TeamSSOSettingsProps> = ({ accessToken }) => {
|
|||
editContent={
|
||||
<BudgetDurationDropdown
|
||||
value={editedValues.budget_duration || null}
|
||||
onChange={(v) => update("budget_duration", v)}
|
||||
onChange={(v) => update("budget_duration", v ?? null)}
|
||||
style={{ maxWidth: 320 }}
|
||||
/>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,10 @@ const { Option } = Select;
|
|||
|
||||
interface BudgetDurationDropdownProps {
|
||||
value?: string | null;
|
||||
onChange?: (value: string) => void;
|
||||
onChange?: (value: string | undefined) => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const BudgetDurationDropdown: React.FC<BudgetDurationDropdownProps> = ({
|
||||
|
|
@ -15,6 +16,7 @@ const BudgetDurationDropdown: React.FC<BudgetDurationDropdownProps> = ({
|
|||
onChange,
|
||||
className = "",
|
||||
style = {},
|
||||
placeholder = "n/a",
|
||||
}) => {
|
||||
return (
|
||||
<Select
|
||||
|
|
@ -22,7 +24,7 @@ const BudgetDurationDropdown: React.FC<BudgetDurationDropdownProps> = ({
|
|||
value={value || undefined}
|
||||
onChange={onChange}
|
||||
className={className}
|
||||
placeholder="n/a"
|
||||
placeholder={placeholder}
|
||||
allowClear
|
||||
>
|
||||
<Option value="1h">hourly</Option>
|
||||
|
|
|
|||
|
|
@ -1060,7 +1060,10 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
|
|||
name="budget_duration"
|
||||
help={`Team Reset Budget: ${team?.budget_duration !== null && team?.budget_duration !== undefined ? team?.budget_duration : "None"}`}
|
||||
>
|
||||
<BudgetDurationDropdown onChange={(value) => form.setFieldValue("budget_duration", value)} />
|
||||
<BudgetDurationDropdown
|
||||
placeholder="Never resets"
|
||||
onChange={(value) => form.setFieldValue("budget_duration", value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="mt-4"
|
||||
|
|
|
|||
|
|
@ -507,6 +507,68 @@ describe("KeyInfoView handleKeyUpdate budget_duration", () => {
|
|||
const [, sentPayload] = keyUpdateCallMock.mock.calls[0];
|
||||
expect(sentPayload.budget_duration).toBe("30d");
|
||||
});
|
||||
|
||||
it("should forward a cleared budget_duration as an explicit null the JSON body keeps", async () => {
|
||||
renderView(true);
|
||||
|
||||
fireEvent.click(screen.getByText("Settings"));
|
||||
fireEvent.click(screen.getByText("Edit Settings"));
|
||||
(globalThis as any).__TEST_FORM_VALUES = {
|
||||
token: "tok_123",
|
||||
budget_duration: null,
|
||||
};
|
||||
|
||||
fireEvent.click(screen.getByText("Mock Submit"));
|
||||
|
||||
await waitFor(() => expect(keyUpdateCallMock).toHaveBeenCalled());
|
||||
|
||||
const [, sentPayload] = keyUpdateCallMock.mock.calls[0];
|
||||
expect(sentPayload.budget_duration).toBeNull();
|
||||
expect(JSON.stringify({ ...sentPayload })).toContain('"budget_duration":null');
|
||||
});
|
||||
|
||||
it("should render the cleared budget as never resetting instead of snapping back to the old interval", async () => {
|
||||
keyUpdateCallMock.mockResolvedValueOnce({
|
||||
...baseKeyData,
|
||||
budget_duration: null,
|
||||
budget_reset_at: null,
|
||||
});
|
||||
mockUseAuthorized.mockReturnValue({
|
||||
accessToken: "access_abc",
|
||||
userId: "user_1",
|
||||
userRole: "Admin",
|
||||
premiumUser: true,
|
||||
token: "token_123",
|
||||
userEmail: "test@example.com",
|
||||
disabledPersonalKeyCreation: false,
|
||||
showSSOBanner: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<KeyInfoView
|
||||
keyId="tok_123"
|
||||
onClose={() => {}}
|
||||
keyData={{ ...baseKeyData, budget_duration: "30d", budget_reset_at: "2026-09-01T00:00:00Z" } as any}
|
||||
onKeyDataUpdate={() => {}}
|
||||
teams={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Settings"));
|
||||
expect(screen.getByText("Budget Reset").parentElement?.textContent).toContain("Every 30d");
|
||||
|
||||
fireEvent.click(screen.getByText("Edit Settings"));
|
||||
(globalThis as any).__TEST_FORM_VALUES = {
|
||||
token: "tok_123",
|
||||
budget_duration: null,
|
||||
};
|
||||
|
||||
fireEvent.click(screen.getByText("Mock Submit"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Budget Reset").parentElement?.textContent).toBe("Budget ResetNever");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("KeyInfoView handleKeyUpdate empty strings", () => {
|
||||
|
|
|
|||
|
|
@ -777,6 +777,65 @@ describe("KeyEditView", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("should send an explicit null budget_duration when a previously-set Reset Budget is cleared", async () => {
|
||||
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
|
||||
renderWithProviders(
|
||||
<KeyEditView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
onCancel={() => {}}
|
||||
onSubmit={onSubmitMock}
|
||||
accessToken={"test-token"}
|
||||
userID={"test-user"}
|
||||
userRole={"admin"}
|
||||
premiumUser={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const resetBudgetItem = (await screen.findByText("Reset Budget")).closest(".ant-form-item") as HTMLElement;
|
||||
const clearIcon = resetBudgetItem.querySelector(".ant-select-clear");
|
||||
expect(clearIcon).not.toBeNull();
|
||||
fireEvent.mouseDown(clearIcon as Element);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(resetBudgetItem).getByText("Never resets")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmitMock).toHaveBeenCalled();
|
||||
});
|
||||
const callArgs = onSubmitMock.mock.calls[0][0];
|
||||
expect(callArgs.budget_duration).toBeNull();
|
||||
expect(JSON.stringify({ ...callArgs })).toContain('"budget_duration":null');
|
||||
});
|
||||
|
||||
it("should send an explicit null budget_duration when a legacy word-form Reset Budget is cleared", async () => {
|
||||
const onSubmitMock = vi.fn().mockResolvedValue(undefined);
|
||||
const legacyKeyData = { ...MOCK_KEY_DATA, budget_duration: "monthly" };
|
||||
renderWithProviders(
|
||||
<KeyEditView
|
||||
keyData={legacyKeyData}
|
||||
onCancel={() => {}}
|
||||
onSubmit={onSubmitMock}
|
||||
accessToken={"test-token"}
|
||||
userID={"test-user"}
|
||||
userRole={"admin"}
|
||||
premiumUser={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const resetBudgetItem = (await screen.findByText("Reset Budget")).closest(".ant-form-item") as HTMLElement;
|
||||
fireEvent.mouseDown(resetBudgetItem.querySelector(".ant-select-clear") as Element);
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmitMock).toHaveBeenCalled();
|
||||
});
|
||||
expect(onSubmitMock.mock.calls[0][0].budget_duration).toBeNull();
|
||||
});
|
||||
|
||||
it("should omit budget_limits when existing windows are left untouched (issue #33246)", async () => {
|
||||
// The backend treats any budget_limits in the payload as an admin-only
|
||||
// budget change, so re-sending untouched windows 403s a non-admin owner.
|
||||
|
|
|
|||
|
|
@ -294,6 +294,10 @@ export function KeyEditView({
|
|||
values.duration = null;
|
||||
}
|
||||
|
||||
if (keyData.budget_duration && !values.budget_duration) {
|
||||
values.budget_duration = null;
|
||||
}
|
||||
|
||||
// Reconcile multi-window budget limits from the editor state, dropping
|
||||
// incomplete entries (no max_budget). The backend treats any budget_limits
|
||||
// in a /key/update request as an admin-only budget change, so re-sending
|
||||
|
|
@ -494,7 +498,7 @@ export function KeyEditView({
|
|||
</Form.Item>
|
||||
|
||||
<Form.Item label="Reset Budget" name="budget_duration">
|
||||
<BudgetDurationDropdown />
|
||||
<BudgetDurationDropdown placeholder="Never resets" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue