fix(organization): 422 on negative limits and unparseable budget_duration in v2 update

This commit is contained in:
ryan-crabbe-berri 2026-09-04 12:58:33 -07:00
parent 62087c5d5f
commit 976ff0a785
3 changed files with 54 additions and 2 deletions

View file

@ -22,7 +22,7 @@ def validate_finite_spend(spend: float | None) -> None:
)
def validate_budget_duration(budget_duration: str | None) -> None:
def validate_budget_duration(budget_duration: str | None, status_code: int = 400) -> None:
"""Reject budget durations that can't be parsed, are non-positive, or
overflow date math, so a bad value can't be persisted and later crash the
budget reset job.
@ -44,7 +44,7 @@ def validate_budget_duration(budget_duration: str | None) -> None:
get_budget_reset_time(budget_duration=budget_duration)
except (ValueError, OverflowError):
raise HTTPException(
status_code=400,
status_code=status_code,
detail={
"error": f"Invalid budget_duration '{budget_duration}'. Use a format like '1h', '24h', '7d', or '30d'."
},

View file

@ -41,6 +41,7 @@ from litellm.proxy.management_endpoints.common_daily_activity import get_daily_a
from litellm.proxy.management_endpoints.common_utils import (
_set_object_metadata_field,
_user_has_admin_view,
validate_budget_duration,
)
from litellm.proxy.management_helpers.object_permission_utils import (
handle_update_object_permission_common,
@ -807,6 +808,17 @@ async def update_organization_v2(
status_code=422,
detail={"error": f"soft_budget must be a non-negative finite number. Received: {data.soft_budget}"},
)
for limit_name, limit_value in (
("tpm_limit", data.tpm_limit),
("rpm_limit", data.rpm_limit),
("max_parallel_requests", data.max_parallel_requests),
):
if limit_value is not None and limit_value < 0:
raise HTTPException(
status_code=422,
detail={"error": f"{limit_name} must be non-negative. Received: {limit_value}"},
)
validate_budget_duration(data.budget_duration, status_code=422)
if data.model_max_budget:
from litellm.proxy.management_endpoints.key_management_endpoints import (
validate_model_max_budget,

View file

@ -814,6 +814,46 @@ async def test_v2_rejects_negative_max_budget(monkeypatch):
assert "max_budget" in str(exc.value.detail)
@pytest.mark.asyncio
@pytest.mark.parametrize("field", ["tpm_limit", "rpm_limit", "max_parallel_requests"])
async def test_v2_rejects_negative_integer_limits(monkeypatch: pytest.MonkeyPatch, field: str):
"""v2 rejects negative tpm/rpm/parallel-request limits with a 422 instead of persisting them to the budget row."""
from litellm.proxy._types import LitellmUserRoles, OrganizationUpdateRequestV2, UserAPIKeyAuth
from litellm.proxy.management_endpoints.organization_endpoints import update_organization_v2
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", AsyncMock())
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-1")
with pytest.raises(HTTPException) as exc:
await update_organization_v2(
organization_id="org-1",
data=OrganizationUpdateRequestV2.model_validate({field: -1}),
user_api_key_dict=auth,
)
assert exc.value.status_code == 422
assert field in str(exc.value.detail)
@pytest.mark.asyncio
async def test_v2_rejects_unparseable_budget_duration(monkeypatch: pytest.MonkeyPatch):
"""v2 rejects a budget_duration the parser can't read with a 422 instead of persisting it alongside a silent
next-midnight fallback reset."""
from litellm.proxy._types import LitellmUserRoles, OrganizationUpdateRequestV2, UserAPIKeyAuth
from litellm.proxy.management_endpoints.organization_endpoints import update_organization_v2
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", AsyncMock())
auth = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin-1")
with pytest.raises(HTTPException) as exc:
await update_organization_v2(
organization_id="org-1",
data=OrganizationUpdateRequestV2.model_validate({"budget_duration": "bogus"}),
user_api_key_dict=auth,
)
assert exc.value.status_code == 422
assert "budget_duration" in str(exc.value.detail)
@pytest.mark.asyncio
async def test_v2_rejects_caller_without_org_access(monkeypatch):
"""v2 runs the real _verify_org_access guard: a non-admin without ORG_ADMIN on the org gets 403 and no write."""