From 5c9f50d58479348c9d02fed07c8395bf09e85015 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Tue, 11 Nov 2025 08:20:13 -0800 Subject: [PATCH] [AI Gateway] - End User Budgets - Allow pointing max_end_user budget to an id, so the default ID applies to all end users (#16456) * add _apply_budget_limits_to_end_user_params * add _apply_budget_limits_to_end_user_params * add _apply_budget_limits_to_end_user_params * test_default_budget_applied_to_end_user_without_budget * docs fix * fix config --- docs/my-website/docs/proxy/customers.md | 65 ++++- litellm/__init__.py | 1 + litellm/proxy/auth/auth_checks.py | 204 ++++++++++++++-- litellm/proxy/auth/user_api_key_auth.py | 62 ++++- .../test_default_end_user_budget_simple.py | 228 ++++++++++++++++++ 5 files changed, 510 insertions(+), 50 deletions(-) create mode 100644 tests/proxy_unit_tests/test_default_end_user_budget_simple.py diff --git a/docs/my-website/docs/proxy/customers.md b/docs/my-website/docs/proxy/customers.md index ac160d26542..66142ca3d84 100644 --- a/docs/my-website/docs/proxy/customers.md +++ b/docs/my-website/docs/proxy/customers.md @@ -12,7 +12,7 @@ Track spend, set budgets for your customers. Make a /chat/completions call, pass 'user' - First call Works -```bash +```bash showLineNumbers title="Make request with customer ID" curl -X POST 'http://0.0.0.0:4000/chat/completions' \ --header 'Content-Type: application/json' \ --header 'Authorization: Bearer sk-1234' \ # 👈 YOUR PROXY KEY @@ -39,14 +39,14 @@ If the customer_id already exists, spend will be incremented. Call `/customer/info` to get a customer's all up spend -```bash +```bash showLineNumbers title="Get customer spend" curl -X GET 'http://0.0.0.0:4000/customer/info?end_user_id=ishaan3' \ # 👈 CUSTOMER ID -H 'Authorization: Bearer sk-1234' \ # 👈 YOUR PROXY KEY ``` Expected Response: -``` +```json showLineNumbers title="Response" { "user_id": "ishaan3", "blocked": false, @@ -67,20 +67,20 @@ E.g. if your server is `https://webhook.site` and your listening on `6ab090e8-c5 1. Add webhook url to your proxy environment: -```bash +```bash showLineNumbers title="Set webhook URL" export WEBHOOK_URL="https://webhook.site/6ab090e8-c55f-4a23-b075-3209f5c57906" ``` 2. Add 'webhook' to config.yaml -```yaml +```yaml showLineNumbers title="config.yaml" general_settings: alerting: ["webhook"] # 👈 KEY CHANGE ``` 3. Test it! -```bash +```bash showLineNumbers title="Test webhook" curl -X POST 'http://localhost:4000/chat/completions' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer sk-1234' \ @@ -99,7 +99,7 @@ curl -X POST 'http://localhost:4000/chat/completions' \ Expected Response -```json +```json showLineNumbers title="Webhook event payload" { "spend": 0.0011120000000000001, # 👈 SPEND "max_budget": null, @@ -127,12 +127,51 @@ Expected Response Set customer budgets (e.g. monthly budgets, tpm/rpm limits) on LiteLLM Proxy +### Default Budget for All Customers + +Apply budget limits to all customers without explicit budgets. This is useful for rate limiting and spending controls across all end users. + +**Step 1: Create a default budget** + +```bash showLineNumbers title="Create default budget" +curl -X POST 'http://localhost:4000/budget/new' \ +-H 'Content-Type: application/json' \ +-H 'Authorization: Bearer sk-1234' \ +-d '{ + "max_budget": 10, + "rpm_limit": 2, + "tpm_limit": 1000 +}' +``` + +**Step 2: Configure the default budget ID** + +```yaml showLineNumbers title="config.yaml" +litellm_settings: + max_end_user_budget_id: "budget_id_from_step_1" +``` + +**Step 3: Test it** + +```bash showLineNumbers title="Make request with customer ID" +curl -X POST 'http://localhost:4000/chat/completions' \ +-H 'Content-Type: application/json' \ +-H 'Authorization: Bearer sk-1234' \ +-d '{ + "model": "gpt-3.5-turbo", + "messages": [{"role": "user", "content": "Hello"}], + "user": "my-customer-id" +}' +``` + +The customer will be subject to the default budget limits (RPM, TPM, and $ budget). Customers with explicit budgets are unaffected. + ### Quick Start Create / Update a customer with budget **Create New Customer w/ budget** -```bash +```bash showLineNumbers title="Create customer with budget" curl -X POST 'http://0.0.0.0:4000/customer/new' -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' @@ -144,7 +183,7 @@ curl -X POST 'http://0.0.0.0:4000/customer/new' **Test it!** -```bash +```bash showLineNumbers title="Test customer budget" curl -X POST 'http://localhost:4000/chat/completions' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer sk-1234' \ @@ -180,7 +219,7 @@ Create and assign customers to pricing tiers. Use the `/budget/new` endpoint for creating a new budget. [API Reference](https://litellm-api.up.railway.app/#/budget%20management/new_budget_budget_new_post) -```bash +```bash showLineNumbers title="Create budget via API" curl -X POST 'http://localhost:4000/budget/new' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer sk-1234' \ @@ -200,7 +239,7 @@ In your application code, assign budget when creating a new customer. Just use the `budget_id` used when creating the budget. In our example, this is `my-free-tier`. -```bash +```bash showLineNumbers title="Assign budget to customer" curl -X POST 'http://localhost:4000/customer/new' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer sk-1234' \ @@ -215,7 +254,7 @@ curl -X POST 'http://localhost:4000/customer/new' \ -```bash +```bash showLineNumbers title="Test with curl" curl -X POST 'http://localhost:4000/customer/new' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer sk-1234' \ @@ -228,7 +267,7 @@ curl -X POST 'http://localhost:4000/customer/new' \ -```python +```python showLineNumbers title="Test with OpenAI SDK" from openai import OpenAI client = OpenAI( base_url="", diff --git a/litellm/__init__.py b/litellm/__init__.py index 99e41cbfea0..487b94d0f82 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -369,6 +369,7 @@ max_ui_session_budget: Optional[float] = 10 # $10 USD budgets for UI Chat sessi internal_user_budget_duration: Optional[str] = None tag_budget_config: Optional[Dict[str, BudgetConfig]] = None max_end_user_budget: Optional[float] = None +max_end_user_budget_id: Optional[str] = None disable_end_user_cost_tracking: Optional[bool] = None disable_end_user_cost_tracking_prometheus_only: Optional[bool] = None enable_end_user_cost_tracking_prometheus_only: Optional[bool] = None diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 9f2684ff90d..bfcf51a91de 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -29,6 +29,7 @@ from litellm.litellm_core_utils.get_llm_provider_logic import get_llm_provider from litellm.proxy._types import ( RBAC_ROLES, CallInfo, + LiteLLM_BudgetTable, LiteLLM_EndUserTable, Litellm_EntityType, LiteLLM_JWTAuth, @@ -445,6 +446,135 @@ def get_actual_routes(allowed_routes: list) -> list: return actual_routes +async def get_default_end_user_budget( + prisma_client: Optional[PrismaClient], + user_api_key_cache: DualCache, + parent_otel_span: Optional[Span] = None, +) -> Optional[LiteLLM_BudgetTable]: + """ + Fetches the default end user budget from the database if litellm.max_end_user_budget_id is configured. + + This budget is applied to end users who don't have an explicit budget_id set. + Results are cached for performance. + + Args: + prisma_client: Database client instance + user_api_key_cache: Cache for storing/retrieving budget data + parent_otel_span: Optional OpenTelemetry span for tracing + + Returns: + LiteLLM_BudgetTable if configured and found, None otherwise + """ + if prisma_client is None or litellm.max_end_user_budget_id is None: + return None + + cache_key = f"default_end_user_budget:{litellm.max_end_user_budget_id}" + + # Check cache first + cached_budget = await user_api_key_cache.async_get_cache(key=cache_key) + if cached_budget is not None: + return LiteLLM_BudgetTable(**cached_budget) + + # Fetch from database + try: + budget_record = await prisma_client.db.litellm_budgettable.find_unique( + where={"budget_id": litellm.max_end_user_budget_id} + ) + + if budget_record is None: + verbose_proxy_logger.warning( + f"Default end user budget not found in database: {litellm.max_end_user_budget_id}" + ) + return None + + # Cache the budget for 60 seconds + await user_api_key_cache.async_set_cache( + key=cache_key, + value=budget_record.dict(), + ttl=DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL, + ) + + return LiteLLM_BudgetTable(**budget_record.dict()) + + except Exception as e: + verbose_proxy_logger.error( + f"Error fetching default end user budget: {str(e)}" + ) + return None + + +async def _apply_default_budget_to_end_user( + end_user_obj: LiteLLM_EndUserTable, + prisma_client: PrismaClient, + user_api_key_cache: DualCache, + parent_otel_span: Optional[Span] = None, +) -> LiteLLM_EndUserTable: + """ + Helper function to apply default budget to end user if they don't have a budget assigned. + + Args: + end_user_obj: The end user object to potentially apply default budget to + prisma_client: Database client instance + user_api_key_cache: Cache for storing/retrieving data + parent_otel_span: Optional OpenTelemetry span for tracing + + Returns: + Updated end user object with default budget applied if applicable + """ + # If end user already has a budget assigned, no need to apply default + if end_user_obj.litellm_budget_table is not None: + return end_user_obj + + # If no default budget configured, return as-is + if litellm.max_end_user_budget_id is None: + return end_user_obj + + # Fetch and apply default budget + default_budget = await get_default_end_user_budget( + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=parent_otel_span, + ) + + if default_budget is not None: + # Apply default budget to end user object + end_user_obj.litellm_budget_table = default_budget + verbose_proxy_logger.debug( + f"Applied default budget {litellm.max_end_user_budget_id} to end user {end_user_obj.user_id}" + ) + + return end_user_obj + + +def _check_end_user_budget( + end_user_obj: LiteLLM_EndUserTable, + route: str, +) -> None: + """ + Check if end user is within their budget limit. + + Args: + end_user_obj: The end user object to check + route: The request route + + Raises: + litellm.BudgetExceededError: If end user has exceeded their budget + """ + if route in LiteLLMRoutes.info_routes.value: + return + + if end_user_obj.litellm_budget_table is None: + return + + end_user_budget = end_user_obj.litellm_budget_table.max_budget + if end_user_budget is not None and end_user_obj.spend > end_user_budget: + raise litellm.BudgetExceededError( + current_cost=end_user_obj.spend, + max_budget=end_user_budget, + message=f"ExceededBudget: End User={end_user_obj.user_id} over budget. Spend={end_user_obj.spend}, Budget={end_user_budget}", + ) + + @log_db_metrics async def get_end_user_object( end_user_id: Optional[str], @@ -455,36 +585,49 @@ async def get_end_user_object( proxy_logging_obj: Optional[ProxyLogging] = None, ) -> Optional[LiteLLM_EndUserTable]: """ - Returns end user object, if in db. + Returns end user object from database or cache. + + If end user exists but has no budget_id, applies the default budget + (if configured via litellm.max_end_user_budget_id). - Do a isolated check for end user in table vs. doing a combined key + team + user + end-user check, as key might come in frequently for different end-users. Larger call will slowdown query time. This way we get to cache the constant (key/team/user info) and only update based on the changing value (end-user). + Args: + end_user_id: The ID of the end user + prisma_client: Database client instance + user_api_key_cache: Cache for storing/retrieving data + route: The request route + parent_otel_span: Optional OpenTelemetry span for tracing + proxy_logging_obj: Optional proxy logging object + + Returns: + LiteLLM_EndUserTable if found, None otherwise """ if prisma_client is None: raise Exception("No db connected") if end_user_id is None: return None + _key = "end_user_id:{}".format(end_user_id) - def check_in_budget(end_user_obj: LiteLLM_EndUserTable): - if route in LiteLLMRoutes.info_routes.value: # allow calling info routes - return - if end_user_obj.litellm_budget_table is None: - return - end_user_budget = end_user_obj.litellm_budget_table.max_budget - if end_user_budget is not None and end_user_obj.spend > end_user_budget: - raise litellm.BudgetExceededError( - current_cost=end_user_obj.spend, max_budget=end_user_budget - ) - - # check if in cache + # Check cache first cached_user_obj = await user_api_key_cache.async_get_cache(key=_key) if cached_user_obj is not None: return_obj = LiteLLM_EndUserTable(**cached_user_obj) - check_in_budget(end_user_obj=return_obj) + + # Apply default budget if needed + return_obj = await _apply_default_budget_to_end_user( + end_user_obj=return_obj, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=parent_otel_span, + ) + + # Check budget limits + _check_end_user_budget(end_user_obj=return_obj, route=route) + return return_obj - # else, check db + # Fetch from database try: response = await prisma_client.db.litellm_endusertable.find_unique( where={"user_id": end_user_id}, @@ -494,17 +637,29 @@ async def get_end_user_object( if response is None: raise Exception - # save the end-user object to cache (always store as dict for consistency) - await user_api_key_cache.async_set_cache( - key="end_user_id:{}".format(end_user_id), value=response.dict() - ) - + # Convert to LiteLLM_EndUserTable object _response = LiteLLM_EndUserTable(**response.dict()) - - check_in_budget(end_user_obj=_response) + + # Apply default budget if needed + _response = await _apply_default_budget_to_end_user( + end_user_obj=_response, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=parent_otel_span, + ) + + # Save to cache (always store as dict for consistency) + await user_api_key_cache.async_set_cache( + key="end_user_id:{}".format(end_user_id), + value=_response.dict() + ) + + # Check budget limits + _check_end_user_budget(end_user_obj=_response, route=route) return _response - except Exception as e: # if end-user not in db + + except Exception as e: if isinstance(e, litellm.BudgetExceededError): raise e return None @@ -543,6 +698,7 @@ async def get_tag_objects_batch( tag_objects = {} uncached_tags = [] + # Try to get all tags from cache first for tag_name in tag_names: diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index e4456b71779..c9589cd7746 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -127,6 +127,33 @@ def _get_bearer_token( return api_key +def _apply_budget_limits_to_end_user_params( + end_user_params: dict, + budget_info: LiteLLM_BudgetTable, + end_user_id: str, +) -> None: + """ + Helper function to apply budget limits to end user parameters. + + Args: + end_user_params: Dictionary to update with budget parameters + budget_info: Budget table object containing limits + end_user_id: ID of the end user for logging + """ + if budget_info.tpm_limit is not None: + end_user_params["end_user_tpm_limit"] = budget_info.tpm_limit + + if budget_info.rpm_limit is not None: + end_user_params["end_user_rpm_limit"] = budget_info.rpm_limit + + if budget_info.max_budget is not None: + end_user_params["end_user_max_budget"] = budget_info.max_budget + + verbose_proxy_logger.debug( + f"Applied budget limits to end user {end_user_id}" + ) + + async def user_api_key_auth_websocket(websocket: WebSocket): # Accept the WebSocket connection @@ -643,19 +670,28 @@ async def _user_api_key_auth_builder( # noqa: PLR0915 _end_user_object.allowed_model_region ) if _end_user_object.litellm_budget_table is not None: - budget_info = _end_user_object.litellm_budget_table - if budget_info.tpm_limit is not None: - end_user_params["end_user_tpm_limit"] = ( - budget_info.tpm_limit - ) - if budget_info.rpm_limit is not None: - end_user_params["end_user_rpm_limit"] = ( - budget_info.rpm_limit - ) - if budget_info.max_budget is not None: - end_user_params["end_user_max_budget"] = ( - budget_info.max_budget - ) + _apply_budget_limits_to_end_user_params( + end_user_params=end_user_params, + budget_info=_end_user_object.litellm_budget_table, + end_user_id=end_user_id, + ) + elif litellm.max_end_user_budget_id is not None: + # End user doesn't exist yet, but apply default budget limits if configured + from litellm.proxy.auth.auth_checks import ( + get_default_end_user_budget, + ) + + default_budget = await get_default_end_user_budget( + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=parent_otel_span, + ) + if default_budget is not None: + _apply_budget_limits_to_end_user_params( + end_user_params=end_user_params, + budget_info=default_budget, + end_user_id=end_user_id, + ) except Exception as e: if isinstance(e, litellm.BudgetExceededError): raise e diff --git a/tests/proxy_unit_tests/test_default_end_user_budget_simple.py b/tests/proxy_unit_tests/test_default_end_user_budget_simple.py new file mode 100644 index 00000000000..92ca1f71703 --- /dev/null +++ b/tests/proxy_unit_tests/test_default_end_user_budget_simple.py @@ -0,0 +1,228 @@ +""" +Simplified tests for default end user budget feature. + +Tests the core scenarios where litellm.max_end_user_budget_id applies +a default budget to end users without explicit budgets. +""" + +import sys +import os +import uuid +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +sys.path.insert(0, os.path.abspath("../..")) + +import litellm +from litellm.proxy._types import LiteLLM_BudgetTable, LiteLLM_EndUserTable +from litellm.proxy.auth.auth_checks import get_end_user_object +from litellm.caching import DualCache + + +@pytest.mark.asyncio +async def test_default_budget_applied_to_end_user_without_budget(): + """ + Core scenario: End user without explicit budget gets default budget applied. + This is the main use case - applying limits to all unbudgeted end users. + """ + end_user_id = f"test_user_{uuid.uuid4().hex}" + default_budget_id = str(uuid.uuid4()) + litellm.max_end_user_budget_id = default_budget_id + + default_budget = LiteLLM_BudgetTable( + budget_id=default_budget_id, + max_budget=10.0, + rpm_limit=2, + tpm_limit=10, + ) + + # Mock end user in DB without budget + mock_end_user_data = { + "user_id": end_user_id, + "spend": 1.0, + "litellm_budget_table": None, + "alias": None, + "allowed_model_region": None, + "default_model": None, + "blocked": False, + } + + mock_prisma_client = MagicMock() + mock_prisma_client.db.litellm_endusertable.find_unique = AsyncMock( + return_value=MagicMock(dict=lambda: mock_end_user_data) + ) + mock_prisma_client.db.litellm_budgettable.find_unique = AsyncMock( + return_value=MagicMock(dict=lambda: default_budget.dict()) + ) + + mock_cache = AsyncMock(spec=DualCache) + mock_cache.async_get_cache = AsyncMock(return_value=None) + mock_cache.async_set_cache = AsyncMock() + + result = await get_end_user_object( + end_user_id=end_user_id, + prisma_client=mock_prisma_client, + user_api_key_cache=mock_cache, + route="/chat/completions", + ) + + # Verify default budget was applied + assert result is not None + assert result.litellm_budget_table is not None + assert result.litellm_budget_table.budget_id == default_budget_id + assert result.litellm_budget_table.max_budget == 10.0 + assert result.litellm_budget_table.rpm_limit == 2 + assert result.litellm_budget_table.tpm_limit == 10 + + litellm.max_end_user_budget_id = None + + +@pytest.mark.asyncio +async def test_explicit_budget_not_overridden_by_default(): + """ + Core scenario: End users with explicit budgets keep their budgets. + The default should not override user-specific configurations. + """ + end_user_id = f"test_user_{uuid.uuid4().hex}" + explicit_budget_id = str(uuid.uuid4()) + default_budget_id = str(uuid.uuid4()) + litellm.max_end_user_budget_id = default_budget_id + + explicit_budget = LiteLLM_BudgetTable( + budget_id=explicit_budget_id, + max_budget=100.0, + rpm_limit=50, + ) + + # Mock end user with explicit budget + mock_end_user_data = { + "user_id": end_user_id, + "spend": 10.0, + "litellm_budget_table": explicit_budget.dict(), + "alias": None, + "allowed_model_region": None, + "default_model": None, + "blocked": False, + } + + mock_prisma_client = MagicMock() + mock_prisma_client.db.litellm_endusertable.find_unique = AsyncMock( + return_value=MagicMock(dict=lambda: mock_end_user_data) + ) + + mock_cache = AsyncMock(spec=DualCache) + mock_cache.async_get_cache = AsyncMock(return_value=None) + mock_cache.async_set_cache = AsyncMock() + + result = await get_end_user_object( + end_user_id=end_user_id, + prisma_client=mock_prisma_client, + user_api_key_cache=mock_cache, + route="/chat/completions", + ) + + # Verify explicit budget is kept (not replaced with default) + assert result is not None + assert result.litellm_budget_table.budget_id == explicit_budget_id + assert result.litellm_budget_table.max_budget == 100.0 + assert result.litellm_budget_table.rpm_limit == 50 + + litellm.max_end_user_budget_id = None + + +@pytest.mark.asyncio +async def test_budget_enforcement_blocks_over_budget_users(): + """ + Core scenario: Budget limits are actually enforced. + Users who exceed their budget should be blocked. + """ + end_user_id = f"test_user_{uuid.uuid4().hex}" + default_budget_id = str(uuid.uuid4()) + litellm.max_end_user_budget_id = default_budget_id + + default_budget = LiteLLM_BudgetTable( + budget_id=default_budget_id, + max_budget=10.0, + rpm_limit=2, + ) + + # Mock end user who has already spent more than budget + mock_end_user_data = { + "user_id": end_user_id, + "spend": 15.0, # Exceeds budget of 10.0 + "litellm_budget_table": None, + "alias": None, + "allowed_model_region": None, + "default_model": None, + "blocked": False, + } + + mock_prisma_client = MagicMock() + mock_prisma_client.db.litellm_endusertable.find_unique = AsyncMock( + return_value=MagicMock(dict=lambda: mock_end_user_data) + ) + mock_prisma_client.db.litellm_budgettable.find_unique = AsyncMock( + return_value=MagicMock(dict=lambda: default_budget.dict()) + ) + + mock_cache = AsyncMock(spec=DualCache) + mock_cache.async_get_cache = AsyncMock(return_value=None) + mock_cache.async_set_cache = AsyncMock() + + # Should raise BudgetExceededError + with pytest.raises(litellm.BudgetExceededError) as exc_info: + await get_end_user_object( + end_user_id=end_user_id, + prisma_client=mock_prisma_client, + user_api_key_cache=mock_cache, + route="/chat/completions", + ) + + assert "ExceededBudget" in str(exc_info.value) + assert end_user_id in str(exc_info.value) + + litellm.max_end_user_budget_id = None + + +@pytest.mark.asyncio +async def test_system_works_without_default_budget_configured(): + """ + Core scenario: System continues to work when no default budget is configured. + This ensures backward compatibility. + """ + end_user_id = f"test_user_{uuid.uuid4().hex}" + litellm.max_end_user_budget_id = None # Not configured + + # Mock end user without budget + mock_end_user_data = { + "user_id": end_user_id, + "spend": 5.0, + "litellm_budget_table": None, + "alias": None, + "allowed_model_region": None, + "default_model": None, + "blocked": False, + } + + mock_prisma_client = MagicMock() + mock_prisma_client.db.litellm_endusertable.find_unique = AsyncMock( + return_value=MagicMock(dict=lambda: mock_end_user_data) + ) + + mock_cache = AsyncMock(spec=DualCache) + mock_cache.async_get_cache = AsyncMock(return_value=None) + mock_cache.async_set_cache = AsyncMock() + + result = await get_end_user_object( + end_user_id=end_user_id, + prisma_client=mock_prisma_client, + user_api_key_cache=mock_cache, + route="/chat/completions", + ) + + # Should work fine, just without budget limits + assert result is not None + assert result.user_id == end_user_id + assert result.litellm_budget_table is None # No budget applied +