diff --git a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py index b2eda76f9ae..f40ced302ce 100644 --- a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py +++ b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py @@ -780,7 +780,10 @@ async def update_project( # Handle budget updates budget_fields = LiteLLM_BudgetTable.model_fields.keys() - budget_updates = {k: v for k, v in update_data.items() if k in budget_fields} + budget_updates = { + **{k: v for k, v in update_data.items() if k in budget_fields}, + **({"max_budget": None} if "max_budget" in data.model_fields_set and data.max_budget is None else {}), + } if budget_updates and existing_project.budget_id: # Update existing budget diff --git a/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py b/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py index c23b203feba..36878fa698c 100644 --- a/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py +++ b/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py @@ -1292,6 +1292,21 @@ async def test_update_project_leaves_metadata_untouched_when_no_limit_is_sent(mo assert "metadata" not in _written_project_data(mock_prisma) +@pytest.mark.asyncio +async def test_update_project_clears_only_the_explicit_budget_cap(monkeypatch): + mock_prisma = _project_update_mocks(monkeypatch, {}) + mock_prisma.db.litellm_projecttable.find_unique.return_value.budget_id = "budget-clear-test" + mock_prisma.db.litellm_budgettable.update = mock.AsyncMock() + + await _run_project_update("project-clear-test", max_budget=None) + + mock_prisma.db.litellm_budgettable.update.assert_awaited_once_with( + where={"budget_id": "budget-clear-test"}, + data={"max_budget": None, "updated_by": "1234"}, + ) + assert "max_budget" not in _written_project_data(mock_prisma) + + @pytest.mark.parametrize("entry", ["all-proxy-models", "*", "azure/*"]) def test_enforce_project_model_quota_rejects_entries_that_expand_at_request_time(entry): """A quota keyed on a wildcard entry is never applied by the limiter, so it fails loudly.""" diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.ts index 8e6bad04a28..e6dec85128c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/projects/useUpdateProject.ts @@ -10,7 +10,7 @@ export interface ProjectUpdateParams { description?: string; team_id?: string; models?: string[]; - max_budget?: number; + max_budget?: number | null; blocked?: boolean; guardrails?: string[]; metadata?: Record; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.integration.test.tsx index 5b84aa15dd1..2e39d45e660 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.integration.test.tsx @@ -102,6 +102,20 @@ describe("EditProjectModal submit payload", () => { }); }); + it("should send an explicit clear after blanking a saved budget", async () => { + const user = setup(); + renderModal(); + + const budgetInput = screen.getByRole("spinbutton", { name: "Max Budget (USD)" }); + await user.clear(budgetInput); + await user.tab(); + expect(budgetInput).toHaveValue(null); + await save(user); + + await waitFor(() => expect(mutate).toHaveBeenCalled()); + expect(JSON.parse(JSON.stringify(variables().params))).toMatchObject({ max_budget: null }); + }); + it("includes the advanced fields once Advanced Settings has been opened, even after collapsing it again", async () => { const user = setup(); renderModal(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.tsx b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.tsx index 77f28b05ea5..31582da5b14 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/EditProjectModal.tsx @@ -86,7 +86,7 @@ function EditProjectForm({ project, onClose, onSuccess }: Omit onChange(toOptionalNumber(event.target.value))} + value={Number.isNaN(value) ? "" : value ?? ""} + onInput={(event) => { + if (event.currentTarget.validity.badInput || Number.isNaN(value)) { + onChange( + event.currentTarget.validity.badInput + ? Number.NaN + : toOptionalNumber(event.currentTarget.value) ?? null, + ); + } + }} + onChange={(event) => + onChange(event.target.validity.badInput ? Number.NaN : toOptionalNumber(event.target.value) ?? null) + } /> )} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/projectFormSchema.ts b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/projectFormSchema.ts index d4c85d89616..6e6318a9609 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/projectFormSchema.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/projectFormSchema.ts @@ -22,7 +22,7 @@ export const projectFormSchema = z .pipe(z.string({ error: "Please select a team" }).min(1, "Please select a team")), description: z.string().optional(), models: z.array(z.string()), - max_budget: z.number().optional(), + max_budget: z.number().nullish(), isBlocked: z.boolean(), guardrails: z.array(z.string()).optional(), modelLimits: z.array(modelLimitSchema).optional(), diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/projectFormUtils.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/projectFormUtils.test.ts index 28d791ddf54..e76555b3ca3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/projectFormUtils.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/projectFormUtils.test.ts @@ -27,9 +27,9 @@ describe("buildProjectCreateParams", () => { expect(result.description).toBe("A description"); }); - it("should pass through max_budget when provided", () => { - const result = buildProjectCreateParams({ ...baseValues, max_budget: 50.0 }); - expect(result.max_budget).toBe(50.0); + it.each([50.0, 1e308])("should preserve a finite max_budget of %s", (maxBudget) => { + const result = buildProjectCreateParams({ ...baseValues, max_budget: maxBudget }); + expect(JSON.parse(JSON.stringify(result)).max_budget).toBe(maxBudget); }); it("should build model_rpm_limit from modelLimits entries", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/projectFormUtils.ts b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/projectFormUtils.ts index 71c1f5c79af..60e97939e69 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/projectFormUtils.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/projects/_components/ProjectModals/projectFormUtils.ts @@ -16,6 +16,11 @@ const buildModelLimitMap = ( const buildMetadata = (entries: ProjectFormValues["metadata"]): Record | undefined => entries && Object.fromEntries(entries.flatMap((entry) => (entry.key ? [[entry.key, entry.value] as const] : []))); +const roundBudget = (value: number): number => { + const rounded = Math.round(value * 100) / 100; + return Number.isFinite(rounded) ? rounded : value; +}; + const buildProjectApiParams = (values: ProjectFormValues, sendEmpty: boolean) => { const limitEntries = values.modelLimits ?? []; const modelRpmLimit = buildModelLimitMap(limitEntries, (entry) => entry.rpm); @@ -35,7 +40,7 @@ const buildProjectApiParams = (values: ProjectFormValues, sendEmpty: boolean) => project_alias: values.project_alias, description: values.description, models: values.models ?? [], - max_budget: values.max_budget === undefined ? undefined : Math.round(values.max_budget * 100) / 100, + max_budget: values.max_budget == null ? undefined : roundBudget(values.max_budget), blocked: values.isBlocked ?? false, ...guardrailsParam, ...(keep(modelRpmLimit) && { model_rpm_limit: modelRpmLimit }), @@ -53,4 +58,7 @@ export const buildProjectCreateParams = (values: ProjectFormValues) => buildProj * /project/update leaves an omitted key untouched, so a limit the operator cleared has to go out as * an explicitly empty map. Omitting it is what silently kept a removed quota enforced. */ -export const buildProjectUpdateParams = (values: ProjectFormValues) => buildProjectApiParams(values, true); +export const buildProjectUpdateParams = (values: ProjectFormValues, savedMaxBudget?: number | null) => ({ + ...buildProjectApiParams(values, true), + ...(values.max_budget == null && savedMaxBudget != null ? { max_budget: null } : {}), +});