fix(proxy): stop 500 on /tag/update when the request carries budget_id

handle_budget_for_entity collects the update fields from
LiteLLM_BudgetTable.model_fields, which includes budget_id, then calls
BudgetNewRequest(budget_id=existing_budget_id, **fields) - so any update
carrying budget_id, even the entity's own unchanged id, passed it twice
and the endpoint 500'd before writing anything.

budget_id is the row identity, already handled separately via
data_budget_id; exclude it from the collected fields.

Fixes #46999
This commit is contained in:
Mrun25 2026-10-17 04:17:48 +05:30
parent b6ca57b36d
commit af63104744
2 changed files with 39 additions and 2 deletions

View file

@ -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(

View file

@ -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