mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
fix(proxy): enforce user max_budget for team-associated API keys
The user budget check in common_checks() was gated behind (team_object is None or team_object.team_id is None), which skipped the check entirely for any key with a team_id. This allowed users to spend without limit as long as their key belonged to a team without its own max_budget. Remove the team_object condition so user budgets are always enforced independently of team association. Fixes #27394
This commit is contained in:
parent
0af33fbe70
commit
fe2b2a5c55
2 changed files with 182 additions and 6 deletions
|
|
@ -632,12 +632,8 @@ async def common_checks( # noqa: PLR0915
|
|||
)
|
||||
|
||||
# 4. If user is in budget
|
||||
## 4.1 check personal budget, if personal key
|
||||
if (
|
||||
(team_object is None or team_object.team_id is None)
|
||||
and user_object is not None
|
||||
and user_object.max_budget is not None
|
||||
):
|
||||
## 4.1 check user budget
|
||||
if user_object is not None and user_object.max_budget is not None:
|
||||
user_budget = user_object.max_budget
|
||||
from litellm.proxy.proxy_server import get_current_spend
|
||||
|
||||
|
|
|
|||
180
tests/test_litellm/proxy/auth/test_user_budget_team_key.py
Normal file
180
tests/test_litellm/proxy/auth/test_user_budget_team_key.py
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
"""
|
||||
Test that user max_budget is enforced for team-associated API keys.
|
||||
|
||||
Regression test for https://github.com/BerriAI/litellm/issues/27394
|
||||
The user budget check in common_checks() was gated behind a condition that
|
||||
skipped it when the key belonged to a team.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.abspath("../../.."))
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import litellm
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_TeamTable,
|
||||
LiteLLM_UserTable,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.auth.auth_checks import common_checks
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def set_salt_key(monkeypatch):
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "sk-1234")
|
||||
|
||||
|
||||
def _make_user(user_id: str, max_budget: float, spend: float) -> LiteLLM_UserTable:
|
||||
return LiteLLM_UserTable(
|
||||
user_id=user_id,
|
||||
max_budget=max_budget,
|
||||
spend=spend,
|
||||
)
|
||||
|
||||
|
||||
def _make_team(team_id: str) -> LiteLLM_TeamTable:
|
||||
return LiteLLM_TeamTable(
|
||||
team_id=team_id,
|
||||
models=[],
|
||||
max_budget=None,
|
||||
)
|
||||
|
||||
|
||||
def _make_request():
|
||||
mock_request = MagicMock()
|
||||
mock_request.url = MagicMock()
|
||||
mock_request.url.path = "/chat/completions"
|
||||
return mock_request
|
||||
|
||||
|
||||
def _common_patches():
|
||||
"""Shared mocks for common_checks dependencies."""
|
||||
return [
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.get_current_spend",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.proxy_server.prisma_client",
|
||||
MagicMock(),
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.auth.auth_checks.get_team_membership",
|
||||
new_callable=AsyncMock,
|
||||
return_value=None,
|
||||
),
|
||||
patch(
|
||||
"litellm.proxy.auth.auth_checks._tag_max_budget_check",
|
||||
new_callable=AsyncMock,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_budget_enforced_with_team_key():
|
||||
"""User over budget should be rejected even when the key has a team_id."""
|
||||
user = _make_user(user_id="user-1", max_budget=30.0, spend=35.0)
|
||||
team = _make_team(team_id="team-1")
|
||||
token = UserAPIKeyAuth(token="sk-test", user_id="user-1", team_id="team-1")
|
||||
|
||||
patches = _common_patches()
|
||||
for p in patches:
|
||||
p.start()
|
||||
|
||||
# Set the spend return value
|
||||
from litellm.proxy.proxy_server import get_current_spend
|
||||
|
||||
get_current_spend.return_value = 35.0
|
||||
|
||||
try:
|
||||
with pytest.raises(litellm.BudgetExceededError):
|
||||
await common_checks(
|
||||
request_body={"model": "gpt-4o"},
|
||||
team_object=team,
|
||||
user_object=user,
|
||||
end_user_object=None,
|
||||
global_proxy_spend=None,
|
||||
general_settings={},
|
||||
route="/chat/completions",
|
||||
llm_router=None,
|
||||
proxy_logging_obj=MagicMock(),
|
||||
valid_token=token,
|
||||
request=_make_request(),
|
||||
)
|
||||
finally:
|
||||
for p in patches:
|
||||
p.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_under_budget_with_team_key_passes():
|
||||
"""User under budget with a team key should pass the check."""
|
||||
user = _make_user(user_id="user-2", max_budget=100.0, spend=50.0)
|
||||
team = _make_team(team_id="team-2")
|
||||
token = UserAPIKeyAuth(token="sk-test", user_id="user-2", team_id="team-2")
|
||||
|
||||
patches = _common_patches()
|
||||
for p in patches:
|
||||
p.start()
|
||||
|
||||
from litellm.proxy.proxy_server import get_current_spend
|
||||
|
||||
get_current_spend.return_value = 50.0
|
||||
|
||||
try:
|
||||
result = await common_checks(
|
||||
request_body={"model": "gpt-4o"},
|
||||
team_object=team,
|
||||
user_object=user,
|
||||
end_user_object=None,
|
||||
global_proxy_spend=None,
|
||||
general_settings={},
|
||||
route="/chat/completions",
|
||||
llm_router=None,
|
||||
proxy_logging_obj=MagicMock(),
|
||||
valid_token=token,
|
||||
request=_make_request(),
|
||||
)
|
||||
assert result is True
|
||||
finally:
|
||||
for p in patches:
|
||||
p.stop()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_budget_still_enforced_without_team():
|
||||
"""User over budget without a team should still be rejected (no regression)."""
|
||||
user = _make_user(user_id="user-3", max_budget=10.0, spend=15.0)
|
||||
token = UserAPIKeyAuth(token="sk-test", user_id="user-3")
|
||||
|
||||
patches = _common_patches()
|
||||
for p in patches:
|
||||
p.start()
|
||||
|
||||
from litellm.proxy.proxy_server import get_current_spend
|
||||
|
||||
get_current_spend.return_value = 15.0
|
||||
|
||||
try:
|
||||
with pytest.raises(litellm.BudgetExceededError):
|
||||
await common_checks(
|
||||
request_body={"model": "gpt-4o"},
|
||||
team_object=None,
|
||||
user_object=user,
|
||||
end_user_object=None,
|
||||
global_proxy_spend=None,
|
||||
general_settings={},
|
||||
route="/chat/completions",
|
||||
llm_router=None,
|
||||
proxy_logging_obj=MagicMock(),
|
||||
valid_token=token,
|
||||
request=_make_request(),
|
||||
)
|
||||
finally:
|
||||
for p in patches:
|
||||
p.stop()
|
||||
Loading…
Add table
Reference in a new issue