litellm/tests/unit/proxy/auth/test_team_member_budget.py
jesus-berri 5367855644
feat(teams): show caller member spend and budget on Your Teams (#45674)
* feat(teams): show caller member spend and budget on Your Teams

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(teams): use member-linked reset dates

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(teams): keep type-discipline suppressions on cast lines

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(teams): preserve empty page list response

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(teams): keep the member reset date inside the spend column

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(teams): match email-only roster members for caller membership

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(ui): show team member budget reset date in UTC

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(teams): address review on caller membership enrichment

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(ui): label the caller's team spend line My Spend

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-24 17:09:55 -07:00

489 lines
16 KiB
Python

"""
Unit tests for team member budget checks in common_checks.
These tests verify the team member budget enforcement without requiring a proxy server.
"""
from datetime import datetime, timezone
from typing import Final
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import Request
import litellm
from litellm.proxy._types import (
LiteLLM_BudgetTable,
LiteLLM_TeamMembership,
LiteLLM_TeamTable,
LiteLLM_UserTable,
UserAPIKeyAuth,
)
from litellm.proxy.auth.auth_checks import (
common_checks,
resolve_effective_team_member_budget,
)
@pytest.mark.asyncio
async def test_team_member_budget_check_exceeds_budget():
"""Test that common_checks raises BudgetExceededError when team member spend exceeds budget."""
request_body = {
"model": "gpt-3.5-turbo",
"messages": [{"role": "user", "content": "test"}],
}
# Create team object
team_object = LiteLLM_TeamTable(
team_id="test-team-1",
team_alias="Test Team",
spend=0.0,
max_budget=None,
)
# Create user object
user_object = LiteLLM_UserTable(
user_id="test-user-1",
spend=0.0,
max_budget=None,
)
# Create valid token
valid_token = UserAPIKeyAuth(
token="test-token",
user_id="test-user-1",
team_id="test-team-1",
models=["gpt-3.5-turbo"],
)
# Create team membership with budget exceeded
team_membership = LiteLLM_TeamMembership(
user_id="test-user-1",
team_id="test-team-1",
spend=0.0000002, # Exceeds budget
litellm_budget_table=LiteLLM_BudgetTable(
max_budget=0.0000001, # Very small budget
),
)
mock_request = MagicMock(spec=Request)
mock_prisma_client = MagicMock()
mock_user_api_key_cache = MagicMock()
mock_proxy_logging_obj = MagicMock()
# Mock get_team_membership to return our team membership
with (
patch(
"litellm.proxy.auth.auth_checks.get_team_membership",
new_callable=AsyncMock,
return_value=team_membership,
),
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client),
patch("litellm.proxy.proxy_server.user_api_key_cache", mock_user_api_key_cache),
):
# Should raise BudgetExceededError
with pytest.raises(litellm.BudgetExceededError) as exc_info:
await common_checks(
request_body=request_body,
team_object=team_object,
user_object=user_object,
end_user_object=None,
global_proxy_spend=None,
general_settings={},
route="/chat/completions",
llm_router=None,
proxy_logging_obj=mock_proxy_logging_obj,
valid_token=valid_token,
request=mock_request,
)
# Verify error message contains expected text
assert "Budget has been exceeded" in str(exc_info.value)
assert "test-user-1" in str(exc_info.value)
assert "test-team-1" in str(exc_info.value)
@pytest.mark.asyncio
async def test_team_member_budget_check_within_budget():
"""Test that common_checks passes when team member spend is within budget."""
request_body = {
"model": "gpt-3.5-turbo",
"messages": [{"role": "user", "content": "test"}],
}
# Create team object
team_object = LiteLLM_TeamTable(
team_id="test-team-1",
team_alias="Test Team",
spend=0.0,
max_budget=None,
)
# Create user object
user_object = LiteLLM_UserTable(
user_id="test-user-1",
spend=0.0,
max_budget=None,
)
# Create valid token
valid_token = UserAPIKeyAuth(
token="test-token",
user_id="test-user-1",
team_id="test-team-1",
models=["gpt-3.5-turbo"],
)
# Create team membership within budget
team_membership = LiteLLM_TeamMembership(
user_id="test-user-1",
team_id="test-team-1",
spend=0.00000005, # Within budget
litellm_budget_table=LiteLLM_BudgetTable(
max_budget=0.0000001,
),
)
mock_request = MagicMock(spec=Request)
mock_prisma_client = MagicMock()
mock_user_api_key_cache = MagicMock()
mock_proxy_logging_obj = MagicMock()
# Mock get_team_membership to return our team membership
with (
patch(
"litellm.proxy.auth.auth_checks.get_team_membership",
new_callable=AsyncMock,
return_value=team_membership,
),
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client),
patch("litellm.proxy.proxy_server.user_api_key_cache", mock_user_api_key_cache),
):
# Should not raise an exception
result = await common_checks(
request_body=request_body,
team_object=team_object,
user_object=user_object,
end_user_object=None,
global_proxy_spend=None,
general_settings={},
route="/chat/completions",
llm_router=None,
proxy_logging_obj=mock_proxy_logging_obj,
valid_token=valid_token,
request=mock_request,
)
assert result is True
@pytest.mark.asyncio
async def test_team_member_budget_check_no_budget_set():
"""Test that common_checks passes when team member has no budget set."""
request_body = {
"model": "gpt-3.5-turbo",
"messages": [{"role": "user", "content": "test"}],
}
# Create team object
team_object = LiteLLM_TeamTable(
team_id="test-team-1",
team_alias="Test Team",
spend=0.0,
max_budget=None,
)
# Create user object
user_object = LiteLLM_UserTable(
user_id="test-user-1",
spend=0.0,
max_budget=None,
)
# Create valid token
valid_token = UserAPIKeyAuth(
token="test-token",
user_id="test-user-1",
team_id="test-team-1",
models=["gpt-3.5-turbo"],
)
# Create team membership without budget
team_membership = LiteLLM_TeamMembership(
user_id="test-user-1",
team_id="test-team-1",
spend=0.0,
litellm_budget_table=None, # No budget set
)
mock_request = MagicMock(spec=Request)
mock_prisma_client = MagicMock()
mock_user_api_key_cache = MagicMock()
mock_proxy_logging_obj = MagicMock()
# Mock get_team_membership to return our team membership
with (
patch(
"litellm.proxy.auth.auth_checks.get_team_membership",
new_callable=AsyncMock,
return_value=team_membership,
),
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client),
patch("litellm.proxy.proxy_server.user_api_key_cache", mock_user_api_key_cache),
):
# Should not raise an exception (no budget means no limit)
result = await common_checks(
request_body=request_body,
team_object=team_object,
user_object=user_object,
end_user_object=None,
global_proxy_spend=None,
general_settings={},
route="/chat/completions",
llm_router=None,
proxy_logging_obj=mock_proxy_logging_obj,
valid_token=valid_token,
request=mock_request,
)
assert result is True
@pytest.mark.asyncio
async def test_team_member_budget_check_no_team_membership():
"""Test that common_checks passes when team membership doesn't exist."""
request_body = {
"model": "gpt-3.5-turbo",
"messages": [{"role": "user", "content": "test"}],
}
# Create team object
team_object = LiteLLM_TeamTable(
team_id="test-team-1",
team_alias="Test Team",
spend=0.0,
max_budget=None,
)
# Create user object
user_object = LiteLLM_UserTable(
user_id="test-user-1",
spend=0.0,
max_budget=None,
)
# Create valid token
valid_token = UserAPIKeyAuth(
token="test-token",
user_id="test-user-1",
team_id="test-team-1",
models=["gpt-3.5-turbo"],
)
mock_request = MagicMock(spec=Request)
mock_prisma_client = MagicMock()
mock_user_api_key_cache = MagicMock()
mock_proxy_logging_obj = MagicMock()
# Mock get_team_membership to return None (no membership)
with (
patch(
"litellm.proxy.auth.auth_checks.get_team_membership",
new_callable=AsyncMock,
return_value=None,
),
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client),
patch("litellm.proxy.proxy_server.user_api_key_cache", mock_user_api_key_cache),
):
# Should not raise an exception (no membership means no budget check)
result = await common_checks(
request_body=request_body,
team_object=team_object,
user_object=user_object,
end_user_object=None,
global_proxy_spend=None,
general_settings={},
route="/chat/completions",
llm_router=None,
proxy_logging_obj=mock_proxy_logging_obj,
valid_token=valid_token,
request=mock_request,
)
assert result is True
@pytest.mark.asyncio
async def test_team_member_budget_check_blocks_regenerated_key_after_old_key_exhausts_budget():
"""Deleting an exhausted key and creating a new key must not reset a user's team budget."""
request_body = {
"model": "gpt-3.5-turbo",
"messages": [{"role": "user", "content": "test"}],
}
team_object = LiteLLM_TeamTable(
team_id="test-team-1",
team_alias="Test Team",
spend=0.0,
max_budget=None,
)
# The spend below represents usage accumulated by an earlier key that was
# later deleted. The new key must still be checked against the same
# user/team membership spend instead of receiving a fresh per-key budget.
regenerated_token = UserAPIKeyAuth(
token="new-regenerated-token",
user_id="test-user-1",
team_id="test-team-1",
models=["gpt-3.5-turbo"],
)
team_membership = LiteLLM_TeamMembership(
user_id="test-user-1",
team_id="test-team-1",
spend=0.0000002,
litellm_budget_table=LiteLLM_BudgetTable(
max_budget=0.0000001,
),
)
mock_request = MagicMock(spec=Request)
mock_prisma_client = MagicMock()
mock_user_api_key_cache = MagicMock()
mock_proxy_logging_obj = MagicMock()
with (
patch(
"litellm.proxy.auth.auth_checks.get_team_membership",
new_callable=AsyncMock,
return_value=team_membership,
) as mock_get_team_membership,
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client),
patch("litellm.proxy.proxy_server.user_api_key_cache", mock_user_api_key_cache),
):
with pytest.raises(litellm.BudgetExceededError) as exc_info:
await common_checks(
request_body=request_body,
team_object=team_object,
user_object=None,
end_user_object=None,
global_proxy_spend=None,
general_settings={},
route="/chat/completions",
llm_router=None,
proxy_logging_obj=mock_proxy_logging_obj,
valid_token=regenerated_token,
request=mock_request,
)
mock_get_team_membership.assert_any_await(
user_id="test-user-1",
team_id="test-team-1",
prisma_client=mock_prisma_client,
user_api_key_cache=mock_user_api_key_cache,
proxy_logging_obj=mock_proxy_logging_obj,
)
assert "Budget has been exceeded" in str(exc_info.value)
assert "test-user-1" in str(exc_info.value)
assert "test-team-1" in str(exc_info.value)
@pytest.mark.asyncio
async def test_team_member_budget_check_personal_key_not_team():
"""Test that team member budget check is skipped for personal keys (no team)."""
request_body = {
"model": "gpt-3.5-turbo",
"messages": [{"role": "user", "content": "test"}],
}
# No team object (personal key)
team_object = None
# Create user object
user_object = LiteLLM_UserTable(
user_id="test-user-1",
spend=0.0,
max_budget=None,
)
# Create valid token without team
valid_token = UserAPIKeyAuth(
token="test-token",
user_id="test-user-1",
team_id=None, # Personal key
models=["gpt-3.5-turbo"],
)
mock_request = MagicMock(spec=Request)
mock_prisma_client = MagicMock()
mock_user_api_key_cache = MagicMock()
mock_proxy_logging_obj = MagicMock()
# get_team_membership should not be called for personal keys
with (
patch(
"litellm.proxy.auth.auth_checks.get_team_membership",
new_callable=AsyncMock,
) as mock_get_team_membership,
patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client),
patch("litellm.proxy.proxy_server.user_api_key_cache", mock_user_api_key_cache),
):
result = await common_checks(
request_body=request_body,
team_object=team_object,
user_object=user_object,
end_user_object=None,
global_proxy_spend=None,
general_settings={},
route="/chat/completions",
llm_router=None,
proxy_logging_obj=mock_proxy_logging_obj,
valid_token=valid_token,
request=mock_request,
)
# Should pass and get_team_membership should not be called
assert result is True
mock_get_team_membership.assert_not_called()
def test_resolve_effective_team_member_budget_preserves_member_and_default_precedence() -> None:
now: Final = datetime(2030, 1, 1, tzinfo=timezone.utc)
member_budget: Final = LiteLLM_BudgetTable(
max_budget=100,
temp_budget_increase=10,
temp_budget_expiry=datetime(2030, 1, 2, tzinfo=timezone.utc),
)
expired_member_budget: Final = LiteLLM_BudgetTable(
max_budget=100,
temp_budget_increase=10,
temp_budget_expiry=datetime(2029, 12, 31, tzinfo=timezone.utc),
)
active_inherited_increase: Final = LiteLLM_BudgetTable(
max_budget=None,
temp_budget_increase=10,
temp_budget_expiry=datetime(2030, 1, 2, tzinfo=timezone.utc),
)
expired_inherited_increase: Final = LiteLLM_BudgetTable(
max_budget=None,
temp_budget_increase=10,
temp_budget_expiry=datetime(2029, 12, 31, tzinfo=timezone.utc),
)
assert resolve_effective_team_member_budget(member_budget, None, now) == 110
assert resolve_effective_team_member_budget(expired_member_budget, None, now) == 100
assert resolve_effective_team_member_budget(
active_inherited_increase,
LiteLLM_BudgetTable(max_budget=50),
now,
) == 60
assert resolve_effective_team_member_budget(
expired_inherited_increase,
LiteLLM_BudgetTable(max_budget=50),
now,
) == 50
assert resolve_effective_team_member_budget(
LiteLLM_BudgetTable(max_budget=0),
LiteLLM_BudgetTable(max_budget=50),
now,
) == 0
assert resolve_effective_team_member_budget(None, LiteLLM_BudgetTable(max_budget=50), now) == 50
assert resolve_effective_team_member_budget(None, LiteLLM_BudgetTable(max_budget=0), now) is None
assert resolve_effective_team_member_budget(None, None, now) is None