Merge pull request #41271 from BerriAI/litellm_org_zero_max_budget

fix(proxy): enforce organization budgets when max_budget is 0
This commit is contained in:
Yassin Kortam 2026-09-15 12:47:18 -07:00 committed by GitHub
commit a7b894f91b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 118 additions and 5 deletions

View file

@ -5773,8 +5773,7 @@ async def _organization_max_budget_check(
if org_table.litellm_budget_table is not None:
org_max_budget = org_table.litellm_budget_table.max_budget
# Only check if organization has a valid max_budget set
if org_max_budget is None or org_max_budget <= 0:
if org_max_budget is None:
return
# Read spend from cross-pod counter (Redis-first) or cached object (fallback)

View file

@ -1856,9 +1856,9 @@ def validate_team_org_change(
# Check if the team's budget is less than the org's max_budget
if (
team.max_budget
and organization.litellm_budget_table
and organization.litellm_budget_table.max_budget
team.max_budget is not None
and organization.litellm_budget_table is not None
and organization.litellm_budget_table.max_budget is not None
and team.max_budget > organization.litellm_budget_table.max_budget
):
raise HTTPException(

View file

@ -5855,6 +5855,71 @@ async def test_organization_budget_check_carries_org_state_on_the_token():
assert token.org_budget_snapshot == OrgBudgetSnapshot(spend=12.5, max_budget=100.0)
@pytest.mark.parametrize(
"max_budget, spend, expect_blocked",
[
(0.0, 0.0, True), # explicit zero budget blocks even a fresh org with no spend
(0.0, 7.4e-06, True), # any spend at all against a zero budget blocks
(None, 999.0, False), # unlimited (None) never blocks, regardless of spend
(5.0, 4.99, False), # a positive budget under its cap still passes
],
)
@pytest.mark.asyncio
async def test_organization_zero_max_budget_is_enforced(max_budget, spend, expect_blocked):
"""An explicit organization max_budget of 0 must mean zero allowance, matching
key/team/user semantics, not unlimited.
Regression for LIT-7797: `_organization_max_budget_check` returned early
whenever `org_max_budget <= 0`, so an org configured with max_budget=0 could
spend without limit.
"""
from litellm.proxy._types import LiteLLM_OrganizationTable
from litellm.proxy.auth.auth_checks import _organization_max_budget_check
org_table = LiteLLM_OrganizationTable(
organization_id="o1",
organization_alias="zero-budget-org",
budget_id="b1",
created_by="admin",
updated_by="admin",
spend=spend,
litellm_budget_table=LiteLLM_BudgetTable(max_budget=max_budget) if max_budget is not None else None,
)
token = UserAPIKeyAuth(token="k1", org_id="o1")
user_api_key_cache = UserApiKeyCache()
await user_api_key_cache.async_set_cache(
key="org_id:o1:with_budget", value=org_table, model_type=LiteLLM_OrganizationTable
)
async def _spend(counter_key, fallback_spend, max_budget=None, **kwargs):
return spend
proxy_logging_obj = MagicMock()
proxy_logging_obj.budget_alerts = AsyncMock()
with patch( # test-quality-ok: _organization_max_budget_check imports get_current_spend locally
"litellm.proxy.proxy_server.get_current_spend", _spend
):
if expect_blocked:
with pytest.raises(litellm.BudgetExceededError) as exc_info:
await _organization_max_budget_check(
valid_token=token,
team_object=None,
prisma_client=MagicMock(),
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
assert exc_info.value.max_budget == max_budget
else:
await _organization_max_budget_check(
valid_token=token,
team_object=None,
prisma_client=MagicMock(),
user_api_key_cache=user_api_key_cache,
proxy_logging_obj=proxy_logging_obj,
)
@pytest.mark.parametrize("route", ["/health", "/health/services", "/health/test_connection"])
@pytest.mark.asyncio
async def test_spend_capable_non_llm_routes_still_enforce_budget(route):

View file

@ -245,6 +245,55 @@ async def test_validate_team_org_change_same_org_id():
mock_access_check.assert_not_called() # Ensure access check wasn't called
@pytest.mark.parametrize(
"org_max_budget, team_max_budget, expect_blocked",
[
(0.0, 100.0, True), # explicit zero org budget must still cap the team's budget
(0.0, None, False), # team has no budget of its own, nothing to compare
(None, 100.0, False), # unlimited (None) org budget never blocks
(50.0, 100.0, True), # a positive org budget is still enforced normally
],
)
@pytest.mark.asyncio
async def test_validate_team_org_change_zero_org_budget_is_enforced(
org_max_budget, team_max_budget, expect_blocked
):
"""An organization with an explicit max_budget of 0 must still block moving in a
team with a larger budget, matching key/team/user zero-budget semantics.
Regression for LIT-7797: the truthy check `organization.litellm_budget_table.max_budget`
treated an explicit 0 the same as no budget table at all, silently skipping this guard.
"""
org_id = "team-org-123"
new_org_id = "new-org-456"
team = MagicMock(spec=LiteLLM_TeamTable)
team.organization_id = org_id
team.models = []
team.max_budget = team_max_budget
team.tpm_limit = None
team.rpm_limit = None
team.members_with_roles = []
organization = MagicMock(spec=LiteLLM_OrganizationTableWithMembers)
organization.organization_id = new_org_id
organization.models = []
organization.litellm_budget_table = (
LiteLLM_BudgetTable(max_budget=org_max_budget) if org_max_budget is not None else None
)
organization.members = []
mock_router = MagicMock(spec=Router)
if expect_blocked:
with pytest.raises(HTTPException) as exc_info:
validate_team_org_change(team=team, organization=organization, llm_router=mock_router)
assert exc_info.value.status_code == 403
else:
result = validate_team_org_change(team=team, organization=organization, llm_router=mock_router)
assert result is None or result is True
@pytest.mark.asyncio
async def test_validate_team_org_change_members_in_org():
"""