mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-16 23:41:43 +00:00
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:
commit
a7b894f91b
4 changed files with 118 additions and 5 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
"""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue