mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
* test(proxy): move auth, hooks, policy_engine and client tests into tests/unit/proxy Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): stub HIBP through respx by disabling the aiohttp transport Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): share the httpx transport fixture across proxy unit tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): restore proxy globals without a missing-value sentinel Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): package moved dirs and stub the login breach check at the HTTP boundary Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): isolate the mcp server manager per test Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): move management_endpoints, management_helpers and guardrails tests into tests/unit/proxy Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): reuse the shared httpx transport fixture in moved proxy tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): stub outbound HTTP and package moved test dirs Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): restore the config server hostname in the mcp resolution test Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(proxy): pin the completion tokenizer model in the straiker screening test Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yuneng <yuneng@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
372 lines
13 KiB
Python
372 lines
13 KiB
Python
"""
|
|
Unit tests for customer budget operations.
|
|
|
|
Tests customer update functionality related to budget management:
|
|
- Linking customers to existing budgets via budget_id
|
|
- Creating new budgets for customers with proper field validation
|
|
- Budget creation with required metadata fields
|
|
- Proper database relationship handling
|
|
- Budget initialization on customer creation
|
|
"""
|
|
|
|
from datetime import datetime, timedelta
|
|
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from litellm.proxy._types import (
|
|
LiteLLM_BudgetTable,
|
|
LiteLLM_EndUserTable,
|
|
NewCustomerRequest,
|
|
UpdateCustomerRequest,
|
|
)
|
|
from litellm.proxy.auth.user_api_key_auth import UserAPIKeyAuth
|
|
from litellm.proxy.management_endpoints.customer_endpoints import (
|
|
new_budget_request,
|
|
update_end_user,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_user_api_key_dict():
|
|
"""Mock user API key auth object."""
|
|
mock_auth = MagicMock(spec=UserAPIKeyAuth)
|
|
mock_auth.user_id = "test-admin-user"
|
|
return mock_auth
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_existing_customer():
|
|
"""Mock existing customer data."""
|
|
return MagicMock(spec=LiteLLM_EndUserTable)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_budget_table():
|
|
"""Mock budget table data."""
|
|
return MagicMock(spec=LiteLLM_BudgetTable)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("litellm.proxy.proxy_server.prisma_client")
|
|
@patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin")
|
|
async def test_update_customer_with_budget_id(
|
|
mock_prisma_client, mock_user_api_key_dict, mock_existing_customer
|
|
):
|
|
"""
|
|
Test updating a customer to link them to an existing budget using budget_id.
|
|
|
|
When only budget_id is provided (no budget creation fields), the customer
|
|
should be linked to the existing budget without creating a new one.
|
|
"""
|
|
# Arrange
|
|
mock_existing_customer.model_dump.return_value = {
|
|
"user_id": "test-user",
|
|
"blocked": False,
|
|
"litellm_budget_table": None,
|
|
}
|
|
|
|
mock_prisma_client.db.litellm_endusertable.find_first = AsyncMock(
|
|
return_value=mock_existing_customer
|
|
)
|
|
|
|
mock_updated_user = MagicMock()
|
|
mock_updated_user.model_dump.return_value = {
|
|
"user_id": "test-user",
|
|
"budget_id": "existing-budget-123",
|
|
"blocked": False,
|
|
}
|
|
|
|
mock_prisma_client.db.litellm_endusertable.update = AsyncMock(
|
|
return_value=mock_updated_user
|
|
)
|
|
|
|
# Create update request with only budget_id (no other budget fields)
|
|
update_request = UpdateCustomerRequest(
|
|
user_id="test-user", budget_id="existing-budget-123"
|
|
)
|
|
|
|
# Act
|
|
await update_end_user(update_request, mock_user_api_key_dict)
|
|
|
|
# Assert
|
|
# Verify that update was called on end user table with budget_id
|
|
mock_prisma_client.db.litellm_endusertable.update.assert_called_once()
|
|
call_args = mock_prisma_client.db.litellm_endusertable.update.call_args
|
|
|
|
# Check that budget_id is in the update data for end user table
|
|
update_data = call_args[1]["data"] # kwargs['data']
|
|
assert "budget_id" in update_data
|
|
assert update_data["budget_id"] == "existing-budget-123"
|
|
|
|
# Verify that NO budget creation was attempted
|
|
assert not mock_prisma_client.db.litellm_budgettable.create.called
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("litellm.proxy.proxy_server.prisma_client")
|
|
@patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin")
|
|
async def test_update_customer_creates_budget_with_proper_relations(
|
|
mock_prisma_client, mock_user_api_key_dict, mock_existing_customer
|
|
):
|
|
"""
|
|
Test that creating a new budget for a customer uses proper database relations.
|
|
|
|
When budget creation fields are provided, the system should create a budget
|
|
with correct database relationship includes.
|
|
"""
|
|
# Arrange
|
|
mock_existing_customer.model_dump.return_value = {
|
|
"user_id": "test-user",
|
|
"blocked": False,
|
|
"litellm_budget_table": None,
|
|
}
|
|
|
|
mock_prisma_client.db.litellm_endusertable.find_first = AsyncMock(
|
|
return_value=mock_existing_customer
|
|
)
|
|
|
|
# Mock budget creation
|
|
mock_created_budget = MagicMock()
|
|
mock_created_budget.budget_id = "new-budget-456"
|
|
mock_prisma_client.db.litellm_budgettable.create = AsyncMock(
|
|
return_value=mock_created_budget
|
|
)
|
|
|
|
# Mock end user update
|
|
mock_updated_user = MagicMock()
|
|
mock_updated_user.model_dump.return_value = {"user_id": "test-user", "blocked": False}
|
|
mock_prisma_client.db.litellm_endusertable.update = AsyncMock(
|
|
return_value=mock_updated_user
|
|
)
|
|
|
|
# Create update request with budget creation fields (not just budget_id)
|
|
update_request = UpdateCustomerRequest(
|
|
user_id="test-user",
|
|
max_budget=100.0, # This triggers budget creation
|
|
rpm_limit=200, # Use valid budget field
|
|
)
|
|
|
|
# Act
|
|
await update_end_user(update_request, mock_user_api_key_dict)
|
|
|
|
# Assert
|
|
# Verify budget creation was called with correct include field
|
|
mock_prisma_client.db.litellm_budgettable.create.assert_called_once()
|
|
call_args = mock_prisma_client.db.litellm_budgettable.create.call_args
|
|
|
|
# Check that include uses correct relation name "end_users"
|
|
include_param = call_args[1]["include"] # kwargs['include']
|
|
assert "end_users" in include_param
|
|
assert include_param["end_users"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("litellm.proxy.proxy_server.prisma_client")
|
|
@patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin")
|
|
async def test_update_customer_creates_budget_with_required_fields(
|
|
mock_prisma_client, mock_user_api_key_dict, mock_existing_customer
|
|
):
|
|
"""
|
|
Test that creating a budget for a customer includes all required metadata fields.
|
|
|
|
Budget creation should include created_by and updated_by fields for proper
|
|
audit trail and data integrity.
|
|
"""
|
|
# Arrange
|
|
mock_existing_customer.model_dump.return_value = {
|
|
"user_id": "test-user",
|
|
"blocked": False,
|
|
"litellm_budget_table": None,
|
|
}
|
|
|
|
mock_prisma_client.db.litellm_endusertable.find_first = AsyncMock(
|
|
return_value=mock_existing_customer
|
|
)
|
|
|
|
# Mock budget creation
|
|
mock_created_budget = MagicMock()
|
|
mock_created_budget.budget_id = "new-budget-789"
|
|
mock_prisma_client.db.litellm_budgettable.create = AsyncMock(
|
|
return_value=mock_created_budget
|
|
)
|
|
|
|
# Mock end user update
|
|
mock_updated_user = MagicMock()
|
|
mock_updated_user.model_dump.return_value = {"user_id": "test-user", "blocked": False}
|
|
mock_prisma_client.db.litellm_endusertable.update = AsyncMock(
|
|
return_value=mock_updated_user
|
|
)
|
|
|
|
# Create update request with budget creation fields
|
|
update_request = UpdateCustomerRequest(user_id="test-user", max_budget=200.0)
|
|
|
|
# Act
|
|
await update_end_user(update_request, mock_user_api_key_dict)
|
|
|
|
# Assert
|
|
# Verify budget creation was called with required fields
|
|
mock_prisma_client.db.litellm_budgettable.create.assert_called_once()
|
|
call_args = mock_prisma_client.db.litellm_budgettable.create.call_args
|
|
|
|
# Check that created_by and updated_by are present in creation data
|
|
creation_data = call_args[1]["data"] # kwargs['data']
|
|
assert "created_by" in creation_data
|
|
assert "updated_by" in creation_data
|
|
|
|
# Verify the values are set correctly
|
|
assert creation_data["created_by"] == "test-admin-user"
|
|
assert creation_data["updated_by"] == "test-admin-user"
|
|
|
|
# Verify budget fields are also included
|
|
assert "max_budget" in creation_data
|
|
assert creation_data["max_budget"] == 200.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("litellm.proxy.proxy_server.prisma_client")
|
|
@patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin")
|
|
async def test_update_customer_budget_creation_with_fallback_admin(
|
|
mock_prisma_client, mock_existing_customer
|
|
):
|
|
"""
|
|
Test budget creation falls back to admin name when user_id is not available.
|
|
|
|
When the requesting user's ID is None, the system should use the configured
|
|
proxy admin name for created_by and updated_by fields.
|
|
"""
|
|
# Arrange - user with None user_id
|
|
mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth)
|
|
mock_user_api_key_dict.user_id = None
|
|
|
|
mock_existing_customer.model_dump.return_value = {
|
|
"user_id": "test-user",
|
|
"blocked": False,
|
|
"litellm_budget_table": None,
|
|
}
|
|
|
|
mock_prisma_client.db.litellm_endusertable.find_first = AsyncMock(
|
|
return_value=mock_existing_customer
|
|
)
|
|
|
|
# Mock budget creation
|
|
mock_created_budget = MagicMock()
|
|
mock_created_budget.budget_id = "new-budget-fallback"
|
|
mock_prisma_client.db.litellm_budgettable.create = AsyncMock(
|
|
return_value=mock_created_budget
|
|
)
|
|
|
|
# Mock end user update
|
|
mock_updated_user = MagicMock()
|
|
mock_updated_user.model_dump.return_value = {"user_id": "test-user", "blocked": False}
|
|
mock_prisma_client.db.litellm_endusertable.update = AsyncMock(
|
|
return_value=mock_updated_user
|
|
)
|
|
|
|
# Create update request with budget creation fields
|
|
update_request = UpdateCustomerRequest(
|
|
user_id="test-user",
|
|
max_budget=150.0,
|
|
tpm_limit=1000, # Add another budget field
|
|
)
|
|
|
|
# Act
|
|
await update_end_user(update_request, mock_user_api_key_dict)
|
|
|
|
# Assert
|
|
# Verify budget creation was called with fallback admin name
|
|
mock_prisma_client.db.litellm_budgettable.create.assert_called_once()
|
|
call_args = mock_prisma_client.db.litellm_budgettable.create.call_args
|
|
|
|
creation_data = call_args[1]["data"] # kwargs['data']
|
|
assert creation_data["created_by"] == "admin" # litellm_proxy_admin_name
|
|
assert creation_data["updated_by"] == "admin"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@patch("litellm.proxy.proxy_server.prisma_client")
|
|
@patch("litellm.proxy.proxy_server.litellm_proxy_admin_name", "admin")
|
|
async def test_update_customer_with_budget_id_and_creation_fields(
|
|
mock_prisma_client, mock_user_api_key_dict, mock_existing_customer
|
|
):
|
|
"""
|
|
Test customer update when both budget_id and budget creation fields are provided.
|
|
|
|
When both linking (budget_id) and creation fields are provided, the system
|
|
should prioritize creating a new budget and assign its ID to the customer.
|
|
"""
|
|
# Arrange
|
|
mock_existing_customer.model_dump.return_value = {
|
|
"user_id": "test-user",
|
|
"blocked": False,
|
|
"litellm_budget_table": None,
|
|
}
|
|
|
|
mock_prisma_client.db.litellm_endusertable.find_first = AsyncMock(
|
|
return_value=mock_existing_customer
|
|
)
|
|
|
|
# Mock budget creation
|
|
mock_created_budget = MagicMock()
|
|
mock_created_budget.budget_id = "new-budget-combo"
|
|
mock_prisma_client.db.litellm_budgettable.create = AsyncMock(
|
|
return_value=mock_created_budget
|
|
)
|
|
|
|
# Mock end user update
|
|
mock_updated_user = MagicMock()
|
|
mock_updated_user.model_dump.return_value = {"user_id": "test-user", "blocked": False}
|
|
mock_prisma_client.db.litellm_endusertable.update = AsyncMock(
|
|
return_value=mock_updated_user
|
|
)
|
|
|
|
# Create update request with both budget_id and budget creation fields
|
|
update_request = UpdateCustomerRequest(
|
|
user_id="test-user",
|
|
budget_id="existing-budget-link", # For linking to existing budget
|
|
max_budget=300.0, # This should trigger new budget creation
|
|
rpm_limit=500, # Use valid budget field
|
|
)
|
|
|
|
# Act
|
|
await update_end_user(update_request, mock_user_api_key_dict)
|
|
|
|
# Assert
|
|
# Verify budget creation occurred (because max_budget was provided)
|
|
mock_prisma_client.db.litellm_budgettable.create.assert_called_once()
|
|
|
|
# Verify end user update was called
|
|
mock_prisma_client.db.litellm_endusertable.update.assert_called_once()
|
|
call_args = mock_prisma_client.db.litellm_endusertable.update.call_args
|
|
|
|
# The update data should contain budget_id from the created budget, not the original budget_id
|
|
update_data = call_args[1]["data"]
|
|
assert update_data["budget_id"] == "new-budget-combo" # From created budget
|
|
|
|
|
|
def test_new_budget_request_sets_budget_reset_at_when_duration_provided():
|
|
"""
|
|
Test that new_budget_request auto-populates budget_reset_at when
|
|
budget_duration is provided but budget_reset_at is not.
|
|
|
|
Without this fix, budgets created via /customer/new with a budget_duration
|
|
but no budget_reset_at would have budget_reset_at=NULL in the DB, causing
|
|
the ResetBudgetJob to immediately pick them up and zero out enduser spend.
|
|
"""
|
|
data = NewCustomerRequest(
|
|
user_id="test-user",
|
|
max_budget=10.0,
|
|
budget_duration="30d",
|
|
)
|
|
|
|
before = datetime.utcnow()
|
|
result = new_budget_request(data)
|
|
after = datetime.utcnow()
|
|
|
|
assert result is not None
|
|
assert result.budget_reset_at is not None
|
|
assert result.budget_duration == "30d"
|
|
|
|
expected_min = before + timedelta(days=30)
|
|
expected_max = after + timedelta(days=30)
|
|
assert expected_min <= result.budget_reset_at <= expected_max
|