diff --git a/litellm/proxy/management_helpers/utils.py b/litellm/proxy/management_helpers/utils.py index 30aaaacad04..099b9edc613 100644 --- a/litellm/proxy/management_helpers/utils.py +++ b/litellm/proxy/management_helpers/utils.py @@ -215,8 +215,10 @@ async def handle_budget_for_entity( update_budget, ) - # Get all budget field names - budget_params: Final = LiteLLM_BudgetTable.model_fields.keys() + # Get all budget field names, except budget_id: that is the row identity, + # handled separately via data_budget_id. Collecting it here passes it twice + # to BudgetNewRequest on the update path and 500s the request. + budget_params: Final = LiteLLM_BudgetTable.model_fields.keys() - {"budget_id"} # Extract budget fields from data _json_data: Final = _BUDGET_DATA_MAPPING.validate_python( diff --git a/tests/unit/proxy/management_helpers/test_management_helpers_utils.py b/tests/unit/proxy/management_helpers/test_management_helpers_utils.py index 644f0656c7a..b6df71a2b70 100644 --- a/tests/unit/proxy/management_helpers/test_management_helpers_utils.py +++ b/tests/unit/proxy/management_helpers/test_management_helpers_utils.py @@ -309,6 +309,41 @@ async def test_handle_budget_for_entity_updates_only_cleared_or_supplied_fields( } +@pytest.mark.asyncio +async def test_handle_budget_for_entity_update_carrying_own_budget_id() -> None: + """Regression for #46999: a /tag/update that carries the tag's own budget_id + 500'd with "multiple values for keyword argument 'budget_id'" because the id + was collected into the update fields and also passed explicitly. The id is + the row identity, not an updatable field, and the update must go through.""" + from litellm.proxy.management_helpers.utils import handle_budget_for_entity + from litellm.types.tag_management import TagUpdateRequest + + tag: Final = TagUpdateRequest.model_validate({"name": "budget-tag", "budget_id": "budget-1", "max_budget": 10}) + budget_update: Final = AsyncMock() + prisma_client: Final = cast( + PrismaClient, + SimpleNamespace(db=SimpleNamespace(litellm_budgettable=SimpleNamespace(update=budget_update))), + ) + + with ( + patch("litellm.proxy.proxy_server.prisma_client", prisma_client), + patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin"), + ): + returned: Final = await handle_budget_for_entity( + data=tag, + existing_budget_id="budget-1", + user_api_key_dict=UserAPIKeyAuth(user_id="admin"), + prisma_client=prisma_client, + litellm_proxy_admin_name="admin", + cleared_budget_fields=frozenset(), + ) + + assert returned == "budget-1" + budget_update.assert_awaited_once() + update_data: Final = cast(Mapping[str, object], budget_update.await_args.kwargs["data"]) + assert update_data.get("max_budget") == 10 + + @pytest.mark.asyncio async def test_add_new_member_no_budget_when_default_budget_row_is_missing(): from litellm.proxy._types import LitellmUserRoles