From e3d1e0345cdaa258ee220c67ea322dd3896181c3 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Tue, 13 Jan 2026 17:01:51 -0800 Subject: [PATCH 01/14] only show own internal user usage --- .../common_daily_activity.py | 9 +- .../management_endpoints/team_endpoints.py | 34 +- .../test_team_endpoints.py | 363 ++++++++++++++++++ 3 files changed, 401 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index f52abf86b97..c52491efc7c 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -343,7 +343,7 @@ def _build_where_conditions( start_date: str, end_date: str, model: Optional[str], - api_key: Optional[str], + api_key: Optional[Union[str, List[str]]], exclude_entity_ids: Optional[List[str]] = None, ) -> Dict[str, Any]: """Build prisma where clause for daily activity queries.""" @@ -357,7 +357,10 @@ def _build_where_conditions( if model: where_conditions["model"] = model if api_key: - where_conditions["api_key"] = api_key + if isinstance(api_key, list): + where_conditions["api_key"] = {"in": api_key} + else: + where_conditions["api_key"] = api_key if entity_id is not None: if isinstance(entity_id, list): @@ -445,7 +448,7 @@ async def get_daily_activity( start_date: Optional[str], end_date: Optional[str], model: Optional[str], - api_key: Optional[str], + api_key: Optional[Union[str, List[str]]], page: int, page_size: int, exclude_entity_ids: Optional[List[str]] = None, diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 78caa86db7b..d1549b51167 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -3601,7 +3601,7 @@ async def get_team_daily_activity( }, ) - ## Fetch team aliases + ## Fetch team aliases and check team admin status where_condition = {} if team_ids_list: where_condition["team_id"] = {"in": list(team_ids_list)} @@ -3612,6 +3612,36 @@ async def get_team_daily_activity( t.team_id: {"team_alias": t.team_alias} for t in team_aliases } + # Check if user is team admin for any requested teams + # If not, filter by user's API keys + user_api_keys: Optional[List[str]] = None + if not _user_has_admin_view(user_api_key_dict) and team_ids_list and team_aliases: + # Check if user is team admin for any of the teams + is_team_admin_for_any = False + for team_alias in team_aliases: + team_obj = LiteLLM_TeamTable(**team_alias.model_dump()) + if _is_user_team_admin( + user_api_key_dict=user_api_key_dict, team_obj=team_obj + ): + is_team_admin_for_any = True + break + + # If user is not a team admin for any team, filter by their API keys + if not is_team_admin_for_any: + # Get all API keys for this user + user_keys = await prisma_client.db.litellm_verificationtoken.find_many( + where={"user_id": user_api_key_dict.user_id} + ) + user_api_keys = [key.token for key in user_keys if key.token] + # If user has no API keys, return empty result + if not user_api_keys: + user_api_keys = [""] # Use empty string to ensure no matches + + # If api_key parameter is provided, use it; otherwise use user_api_keys if set + final_api_key_filter: Optional[Union[str, List[str]]] = api_key + if final_api_key_filter is None and user_api_keys is not None: + final_api_key_filter = user_api_keys + return await get_daily_activity( prisma_client=prisma_client, table_name="litellm_dailyteamspend", @@ -3622,7 +3652,7 @@ async def get_team_daily_activity( start_date=start_date, end_date=end_date, model=model, - api_key=api_key, + api_key=final_api_key_filter, page=page, page_size=page_size, ) diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index e296066b998..bbff7448e13 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -20,6 +20,7 @@ from litellm.proxy._types import ( LiteLLM_OrganizationTable, LiteLLM_OrganizationTableWithMembers, LiteLLM_TeamTable, + LiteLLM_UserTable, LitellmUserRoles, Member, ProxyErrorTypes, @@ -4476,6 +4477,187 @@ async def test_new_team_with_router_settings(mock_db_client, mock_admin_auth): assert deserialized_settings == router_settings_data +@pytest.mark.asyncio +async def test_get_team_daily_activity_non_admin_filters_by_user_api_keys( + mock_db_client, +): + """ + Test that non-team-admin users only see their own spend (filtered by their API keys) + when calling /team/daily/activity endpoint. + """ + from litellm.proxy.management_endpoints.team_endpoints import ( + get_team_daily_activity, + ) + + # Create a non-admin user + user_id = "test_user_123" + team_id = "test_team_456" + user_api_key_dict = UserAPIKeyAuth( + user_id=user_id, user_role=LitellmUserRoles.INTERNAL_USER + ) + + # Mock user info + mock_user_info = LiteLLM_UserTable( + user_id=user_id, + teams=[team_id], + max_budget=1000.0, + spend=0.0, + user_email="test@example.com", + user_role="internal_user", + ) + + # Mock team with user as non-admin member + mock_team_member = Member(user_id=user_id, role="user") + mock_team = MagicMock(spec=LiteLLM_TeamTable) + mock_team.team_id = team_id + mock_team.team_alias = "Test Team" + mock_team.members_with_roles = [mock_team_member] + mock_team.model_dump.return_value = { + "team_id": team_id, + "team_alias": "Test Team", + "members_with_roles": [{"user_id": user_id, "role": "user"}], + } + + # Mock user's API keys + user_api_key_1 = MagicMock() + user_api_key_1.token = "user_key_1" + user_api_key_2 = MagicMock() + user_api_key_2.token = "user_key_2" + + # Setup mocks + mock_db_client.db.litellm_teamtable.find_many = AsyncMock( + return_value=[mock_team] + ) + mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock( + return_value=[user_api_key_1, user_api_key_2] + ) + + # Mock get_user_object + with patch( + "litellm.proxy.management_endpoints.team_endpoints.get_user_object", + new_callable=AsyncMock, + ) as mock_get_user_object: + mock_get_user_object.return_value = mock_user_info + + # Mock get_daily_activity to capture the api_key parameter + with patch( + "litellm.proxy.management_endpoints.team_endpoints.get_daily_activity", + new_callable=AsyncMock, + ) as mock_get_daily_activity: + mock_get_daily_activity.return_value = MagicMock() + + # Call the endpoint + await get_team_daily_activity( + team_ids=team_id, + start_date="2024-01-01", + end_date="2024-01-02", + model=None, + api_key=None, + page=1, + page_size=10, + exclude_team_ids=None, + user_api_key_dict=user_api_key_dict, + ) + + # Verify get_daily_activity was called with user's API keys as filter + mock_get_daily_activity.assert_called_once() + call_kwargs = mock_get_daily_activity.call_args[1] + assert call_kwargs["api_key"] == ["user_key_1", "user_key_2"] + assert call_kwargs["entity_id"] == [team_id] + + # Verify user's API keys were fetched + mock_db_client.db.litellm_verificationtoken.find_many.assert_called_once() + api_key_call_kwargs = ( + mock_db_client.db.litellm_verificationtoken.find_many.call_args[1] + ) + assert api_key_call_kwargs["where"] == {"user_id": user_id} + + +@pytest.mark.asyncio +async def test_get_team_daily_activity_team_admin_sees_all_spend(mock_db_client): + """ + Test that team admin users see all team spend (no API key filtering) + when calling /team/daily/activity endpoint. + """ + from litellm.proxy.management_endpoints.team_endpoints import ( + get_team_daily_activity, + ) + + # Create a team admin user + user_id = "test_admin_123" + team_id = "test_team_456" + user_api_key_dict = UserAPIKeyAuth( + user_id=user_id, user_role=LitellmUserRoles.INTERNAL_USER + ) + + # Mock user info + mock_user_info = LiteLLM_UserTable( + user_id=user_id, + teams=[team_id], + max_budget=1000.0, + spend=0.0, + user_email="admin@example.com", + user_role="internal_user", + ) + + # Mock team with user as admin member + mock_team_member = Member(user_id=user_id, role="admin") + mock_team = MagicMock(spec=LiteLLM_TeamTable) + mock_team.team_id = team_id + mock_team.team_alias = "Test Team" + mock_team.members_with_roles = [mock_team_member] + mock_team.model_dump.return_value = { + "team_id": team_id, + "team_alias": "Test Team", + "members_with_roles": [{"user_id": user_id, "role": "admin"}], + } + + # Setup mocks + mock_db_client.db.litellm_teamtable.find_many = AsyncMock( + return_value=[mock_team] + ) + + # Mock get_user_object + with patch( + "litellm.proxy.management_endpoints.team_endpoints.get_user_object", + new_callable=AsyncMock, + ) as mock_get_user_object: + mock_get_user_object.return_value = mock_user_info + + # Mock get_daily_activity to capture the api_key parameter + with patch( + "litellm.proxy.management_endpoints.team_endpoints.get_daily_activity", + new_callable=AsyncMock, + ) as mock_get_daily_activity: + mock_get_daily_activity.return_value = MagicMock() + + # Call the endpoint + await get_team_daily_activity( + team_ids=team_id, + start_date="2024-01-01", + end_date="2024-01-02", + model=None, + api_key=None, + page=1, + page_size=10, + exclude_team_ids=None, + user_api_key_dict=user_api_key_dict, + ) + + # Verify get_daily_activity was called WITHOUT API key filtering + mock_get_daily_activity.assert_called_once() + call_kwargs = mock_get_daily_activity.call_args[1] + assert call_kwargs["api_key"] is None + assert call_kwargs["entity_id"] == [team_id] + + # Verify user's API keys were NOT fetched (since they're admin) + if hasattr( + mock_db_client.db.litellm_verificationtoken, "find_many" + ) and mock_db_client.db.litellm_verificationtoken.find_many.called: + # If it was called, that's unexpected for admin users + assert False, "API keys should not be fetched for team admin users" + + @pytest.mark.asyncio async def test_update_team_with_router_settings(mock_db_client, mock_admin_auth): """ @@ -4552,3 +4734,184 @@ async def test_update_team_with_router_settings(mock_db_client, mock_admin_auth) # Verify router_settings can be deserialized and matches input deserialized_settings = json.loads(team_data["router_settings"]) assert deserialized_settings == router_settings_data + + +@pytest.mark.asyncio +async def test_get_team_daily_activity_non_admin_filters_by_user_api_keys( + mock_db_client, +): + """ + Test that non-team-admin users only see their own spend (filtered by their API keys) + when calling /team/daily/activity endpoint. + """ + from litellm.proxy.management_endpoints.team_endpoints import ( + get_team_daily_activity, + ) + + # Create a non-admin user + user_id = "test_user_123" + team_id = "test_team_456" + user_api_key_dict = UserAPIKeyAuth( + user_id=user_id, user_role=LitellmUserRoles.INTERNAL_USER + ) + + # Mock user info + mock_user_info = LiteLLM_UserTable( + user_id=user_id, + teams=[team_id], + max_budget=1000.0, + spend=0.0, + user_email="test@example.com", + user_role="internal_user", + ) + + # Mock team with user as non-admin member + mock_team_member = Member(user_id=user_id, role="user") + mock_team = MagicMock(spec=LiteLLM_TeamTable) + mock_team.team_id = team_id + mock_team.team_alias = "Test Team" + mock_team.members_with_roles = [mock_team_member] + mock_team.model_dump.return_value = { + "team_id": team_id, + "team_alias": "Test Team", + "members_with_roles": [{"user_id": user_id, "role": "user"}], + } + + # Mock user's API keys + user_api_key_1 = MagicMock() + user_api_key_1.token = "user_key_1" + user_api_key_2 = MagicMock() + user_api_key_2.token = "user_key_2" + + # Setup mocks + mock_db_client.db.litellm_teamtable.find_many = AsyncMock( + return_value=[mock_team] + ) + mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock( + return_value=[user_api_key_1, user_api_key_2] + ) + + # Mock get_user_object + with patch( + "litellm.proxy.management_endpoints.team_endpoints.get_user_object", + new_callable=AsyncMock, + ) as mock_get_user_object: + mock_get_user_object.return_value = mock_user_info + + # Mock get_daily_activity to capture the api_key parameter + with patch( + "litellm.proxy.management_endpoints.team_endpoints.get_daily_activity", + new_callable=AsyncMock, + ) as mock_get_daily_activity: + mock_get_daily_activity.return_value = MagicMock() + + # Call the endpoint + await get_team_daily_activity( + team_ids=team_id, + start_date="2024-01-01", + end_date="2024-01-02", + model=None, + api_key=None, + page=1, + page_size=10, + exclude_team_ids=None, + user_api_key_dict=user_api_key_dict, + ) + + # Verify get_daily_activity was called with user's API keys as filter + mock_get_daily_activity.assert_called_once() + call_kwargs = mock_get_daily_activity.call_args[1] + assert call_kwargs["api_key"] == ["user_key_1", "user_key_2"] + assert call_kwargs["entity_id"] == [team_id] + + # Verify user's API keys were fetched + mock_db_client.db.litellm_verificationtoken.find_many.assert_called_once() + api_key_call_kwargs = ( + mock_db_client.db.litellm_verificationtoken.find_many.call_args[1] + ) + assert api_key_call_kwargs["where"] == {"user_id": user_id} + + +@pytest.mark.asyncio +async def test_get_team_daily_activity_team_admin_sees_all_spend(mock_db_client): + """ + Test that team admin users see all team spend (no API key filtering) + when calling /team/daily/activity endpoint. + """ + from litellm.proxy.management_endpoints.team_endpoints import ( + get_team_daily_activity, + ) + + # Create a team admin user + user_id = "test_admin_123" + team_id = "test_team_456" + user_api_key_dict = UserAPIKeyAuth( + user_id=user_id, user_role=LitellmUserRoles.INTERNAL_USER + ) + + # Mock user info + mock_user_info = LiteLLM_UserTable( + user_id=user_id, + teams=[team_id], + max_budget=1000.0, + spend=0.0, + user_email="admin@example.com", + user_role="internal_user", + ) + + # Mock team with user as admin member + mock_team_member = Member(user_id=user_id, role="admin") + mock_team = MagicMock(spec=LiteLLM_TeamTable) + mock_team.team_id = team_id + mock_team.team_alias = "Test Team" + mock_team.members_with_roles = [mock_team_member] + mock_team.model_dump.return_value = { + "team_id": team_id, + "team_alias": "Test Team", + "members_with_roles": [{"user_id": user_id, "role": "admin"}], + } + + # Setup mocks + mock_db_client.db.litellm_teamtable.find_many = AsyncMock( + return_value=[mock_team] + ) + + # Mock get_user_object + with patch( + "litellm.proxy.management_endpoints.team_endpoints.get_user_object", + new_callable=AsyncMock, + ) as mock_get_user_object: + mock_get_user_object.return_value = mock_user_info + + # Mock get_daily_activity to capture the api_key parameter + with patch( + "litellm.proxy.management_endpoints.team_endpoints.get_daily_activity", + new_callable=AsyncMock, + ) as mock_get_daily_activity: + mock_get_daily_activity.return_value = MagicMock() + + # Call the endpoint + await get_team_daily_activity( + team_ids=team_id, + start_date="2024-01-01", + end_date="2024-01-02", + model=None, + api_key=None, + page=1, + page_size=10, + exclude_team_ids=None, + user_api_key_dict=user_api_key_dict, + ) + + # Verify get_daily_activity was called WITHOUT API key filtering + mock_get_daily_activity.assert_called_once() + call_kwargs = mock_get_daily_activity.call_args[1] + assert call_kwargs["api_key"] is None + assert call_kwargs["entity_id"] == [team_id] + + # Verify user's API keys were NOT fetched (since they're admin) + if hasattr( + mock_db_client.db.litellm_verificationtoken, "find_many" + ) and mock_db_client.db.litellm_verificationtoken.find_many.called: + # If it was called, that's unexpected for admin users + assert False, "API keys should not be fetched for team admin users" From 27a246722630653cb46f45ceee06d5ee44286ef3 Mon Sep 17 00:00:00 2001 From: Harshit Jain <48647625+Harshit28j@users.noreply.github.com> Date: Sat, 17 Jan 2026 00:15:35 +0530 Subject: [PATCH 02/14] fix: correct budget limit validation operator (>=) for team members (#19207) --- litellm/proxy/auth/auth_checks.py | 138 ++++++++++++++++-------------- 1 file changed, 73 insertions(+), 65 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index a741869e5fc..5e0a211906e 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -202,21 +202,29 @@ async def common_checks( and general_settings["enforce_user_param"] is True ): # Get HTTP method from request - http_method = request.method if hasattr(request, 'method') else None - + http_method = request.method if hasattr(request, "method") else None + # Check if it's a POST request and if it's an OpenAI route but not MCP is_post_method = http_method and http_method.upper() == "POST" is_openai_route = RouteChecks.is_llm_api_route(route=route) - is_mcp_route = route in LiteLLMRoutes.mcp_routes.value or RouteChecks.check_route_access( - route=route, allowed_routes=LiteLLMRoutes.mcp_routes.value + is_mcp_route = ( + route in LiteLLMRoutes.mcp_routes.value + or RouteChecks.check_route_access( + route=route, allowed_routes=LiteLLMRoutes.mcp_routes.value + ) ) - + # Enforce user param only for POST requests on OpenAI routes (excluding MCP routes) - if is_post_method and is_openai_route and not is_mcp_route and "user" not in request_body: + if ( + is_post_method + and is_openai_route + and not is_mcp_route + and "user" not in request_body + ): raise Exception( f"'user' param not passed in. 'enforce_user_param'={general_settings['enforce_user_param']}" ) - + # 6.1 [OPTIONAL] If 'reject_clientside_metadata_tags' enabled - reject request if it has client-side 'metadata.tags' if ( general_settings.get("reject_clientside_metadata_tags", None) is not None @@ -502,53 +510,51 @@ async def get_default_end_user_budget( ) -> 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, + 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)}" - ) + verbose_proxy_logger.error(f"Error fetching default end user budget: {str(e)}") return None @@ -560,38 +566,38 @@ async def _apply_default_budget_to_end_user( ) -> 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 @@ -601,20 +607,20 @@ def _check_end_user_budget( ) -> 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( @@ -635,8 +641,8 @@ async def get_end_user_object( ) -> Optional[LiteLLM_EndUserTable]: """ Returns end user object from database or cache. - - If end user exists but has no budget_id, applies the default budget + + If end user exists but has no budget_id, applies the default budget (if configured via litellm.max_end_user_budget_id). Args: @@ -646,7 +652,7 @@ async def get_end_user_object( 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 """ @@ -655,14 +661,14 @@ async def get_end_user_object( if end_user_id is None: return None - + _key = "end_user_id:{}".format(end_user_id) # 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) - + # Apply default budget if needed return_obj = await _apply_default_budget_to_end_user( end_user_obj=return_obj, @@ -670,10 +676,10 @@ async def get_end_user_object( 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 # Fetch from database @@ -688,7 +694,7 @@ async def get_end_user_object( # Convert to LiteLLM_EndUserTable object _response = LiteLLM_EndUserTable(**response.dict()) - + # Apply default budget if needed _response = await _apply_default_budget_to_end_user( end_user_obj=_response, @@ -696,18 +702,17 @@ async def get_end_user_object( 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() + 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 isinstance(e, litellm.BudgetExceededError): raise e @@ -747,7 +752,6 @@ async def get_tag_objects_batch( tag_objects = {} uncached_tags = [] - # Try to get all tags from cache first for tag_name in tag_names: @@ -1138,7 +1142,6 @@ async def _cache_management_object( user_api_key_cache: DualCache, proxy_logging_obj: Optional[ProxyLogging], ): - await user_api_key_cache.async_set_cache( key=key, value=value, @@ -1459,9 +1462,7 @@ async def get_team_object_by_alias( except HTTPException: raise except Exception as e: - verbose_proxy_logger.exception( - "Error looking up team by alias: %s", team_alias - ) + verbose_proxy_logger.exception("Error looking up team by alias: %s", team_alias) raise HTTPException( status_code=500, detail={ @@ -1602,11 +1603,11 @@ class ExperimentalUIJWTToken: ) -> str: """ Generate a JWT token for CLI authentication with 24-hour expiration. - + Args: user_info: User information from the database team_id: Team ID for the user (optional, uses user's team if available) - + Returns: Encrypted JWT token string """ @@ -1800,7 +1801,7 @@ async def get_org_object( - Check if org id in proxy Org Table - if valid, return LiteLLM_OrganizationTable object - if not, then raise an error - + Args: org_id: Organization ID to look up prisma_client: Database client @@ -1820,7 +1821,7 @@ async def get_org_object( cache_key = "org_id:{}".format(org_id) if include_budget_table: cache_key = "org_id:{}:with_budget".format(org_id) - + # check if in cache cached_org_obj = user_api_key_cache.async_get_cache(key=cache_key) if cached_org_obj is not None: @@ -1833,7 +1834,7 @@ async def get_org_object( query_kwargs: Dict[str, Any] = {"where": {"organization_id": org_id}} if include_budget_table: query_kwargs["include"] = {"litellm_budget_table": True} - + response = await prisma_client.db.litellm_organizationtable.find_unique( **query_kwargs ) @@ -1844,7 +1845,9 @@ async def get_org_object( # Cache the result await user_api_key_cache.async_set_cache( key=cache_key, - value=response.model_dump() if hasattr(response, "model_dump") else response, + value=response.model_dump() + if hasattr(response, "model_dump") + else response, ttl=DEFAULT_IN_MEMORY_TTL, ) @@ -2218,10 +2221,15 @@ async def _virtual_key_max_budget_alert_check( and valid_token.spend is not None and valid_token.spend > 0 ): - alert_threshold = valid_token.max_budget * EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE - + alert_threshold = ( + valid_token.max_budget * EMAIL_BUDGET_ALERT_MAX_SPEND_ALERT_PERCENTAGE + ) + # Only alert if we've crossed the threshold but haven't exceeded max_budget yet - if valid_token.spend >= alert_threshold and valid_token.spend < valid_token.max_budget: + if ( + valid_token.spend >= alert_threshold + and valid_token.spend < valid_token.max_budget + ): verbose_proxy_logger.debug( "Reached Max Budget Alert Threshold for token %s, spend %s, max_budget %s, alert_threshold %s", valid_token.token, @@ -2274,7 +2282,7 @@ async def _check_team_member_budget( user_api_key_cache=user_api_key_cache, proxy_logging_obj=proxy_logging_obj, ) - + if ( team_membership is not None and team_membership.litellm_budget_table is not None @@ -2282,8 +2290,8 @@ async def _check_team_member_budget( ): team_member_budget = team_membership.litellm_budget_table.max_budget team_member_spend = team_membership.spend or 0.0 - - if team_member_spend > team_member_budget: + + if team_member_spend >= team_member_budget: raise litellm.BudgetExceededError( current_cost=team_member_spend, max_budget=team_member_budget, @@ -2343,11 +2351,11 @@ async def _organization_max_budget_check( ): """ Check if the organization is over its max budget. - + This function checks the organization budget using: 1. First, tries to use valid_token.org_id (if key has organization_id set) 2. Falls back to team_object.organization_id (if key doesn't have org_id but team does) - + This ensures organization budget checks work even when keys don't have organization_id set directly, as long as their team belongs to an organization. @@ -2364,7 +2372,7 @@ async def _organization_max_budget_check( org_id = valid_token.org_id elif team_object is not None and team_object.organization_id is not None: org_id = team_object.organization_id - + # If no organization_id found, skip the check if org_id is None: return @@ -2655,4 +2663,4 @@ def _can_object_call_vector_stores( code=status.HTTP_401_UNAUTHORIZED, ) - return True \ No newline at end of file + return True From 37c014c80551825179d55ce1fe1d90602efd0fc7 Mon Sep 17 00:00:00 2001 From: Harshit Jain <48647625+Harshit28j@users.noreply.github.com> Date: Sat, 17 Jan 2026 00:17:20 +0530 Subject: [PATCH 03/14] ci(github): add automated duplicate issue checker and template safeguards (#19218) --- .github/ISSUE_TEMPLATE/bug_report.yml | 8 ++++++ .github/ISSUE_TEMPLATE/feature_request.yml | 8 ++++++ .github/workflows/check_duplicate_issues.yml | 29 ++++++++++++++++++++ 3 files changed, 45 insertions(+) create mode 100644 .github/workflows/check_duplicate_issues.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index e0c1051dd29..bbe4b76775d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -9,6 +9,14 @@ body: Thanks for taking the time to fill out this bug report! **💡 Tip:** See our [Troubleshooting Guide](https://docs.litellm.ai/docs/troubleshoot) for what information to include. + - type: checkboxes + id: duplicate-check + attributes: + label: Check for existing issues + description: Please search to see if an issue already exists for the bug you encountered. + options: + - label: I have searched the existing issues and checked that my issue is not a duplicate. + required: true - type: textarea id: what-happened attributes: diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index e575db7302a..4cc42901897 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -7,6 +7,14 @@ body: attributes: value: | Thanks for making LiteLLM better! + - type: checkboxes + id: duplicate-check + attributes: + label: Check for existing issues + description: Please search to see if an issue already exists for the feature you are requesting. + options: + - label: I have searched the existing issues and checked that my issue is not a duplicate. + required: true - type: textarea id: the-feature attributes: diff --git a/.github/workflows/check_duplicate_issues.yml b/.github/workflows/check_duplicate_issues.yml new file mode 100644 index 00000000000..14d6964fcdb --- /dev/null +++ b/.github/workflows/check_duplicate_issues.yml @@ -0,0 +1,29 @@ +name: Check Duplicate Issues + +on: + issues: + types: [opened, edited] + +jobs: + check-duplicate: + runs-on: ubuntu-latest + permissions: + issues: write + contents: read + steps: + - name: Check for potential duplicates + uses: wow-actions/potential-duplicates@v1 + with: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + label: potential-duplicate + threshold: 0.6 + reaction: eyes + comment: | + **⚠️ Potential duplicate detected** + + This issue appears similar to existing issue(s): + {{#issues}} + - [#{{number}}]({{html_url}}) - {{title}} ({{accuracy}}% similar) + {{/issues}} + + Please review the linked issue(s) to see if they address your concern. If this is not a duplicate, please provide additional context to help us understand the difference. From 2c75194b491e66a7cc7f5693dbee65cea6b751da Mon Sep 17 00:00:00 2001 From: Anand Kamble Date: Fri, 16 Jan 2026 11:26:15 -0800 Subject: [PATCH 04/14] fix(vertex_ai): Vertex AI 400 Error: Model used by GenerateContent request (models/gemini-3-*) and CachedContent (models/gemini-3-*) has to be the same (#19193) * fix(vertex_ai): include model in context cache key generation * test(vertex_ai): update context caching tests to verify model in cache key --- .../context_caching/vertex_ai_context_caching.py | 4 ++-- .../context_caching/test_vertex_ai_context_caching.py | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/litellm/llms/vertex_ai/context_caching/vertex_ai_context_caching.py b/litellm/llms/vertex_ai/context_caching/vertex_ai_context_caching.py index cff1bebceb9..289963e917a 100644 --- a/litellm/llms/vertex_ai/context_caching/vertex_ai_context_caching.py +++ b/litellm/llms/vertex_ai/context_caching/vertex_ai_context_caching.py @@ -304,7 +304,7 @@ class ContextCachingEndpoints(VertexBase): ## CHECK IF CACHED ALREADY generated_cache_key = local_cache_obj.get_cache_key( - messages=cached_messages, tools=tools + messages=cached_messages, tools=tools, model=model ) google_cache_name = self.check_cache( cache_key=generated_cache_key, @@ -433,7 +433,7 @@ class ContextCachingEndpoints(VertexBase): ## CHECK IF CACHED ALREADY generated_cache_key = local_cache_obj.get_cache_key( - messages=cached_messages, tools=tools + messages=cached_messages, tools=tools, model=model ) google_cache_name = await self.async_check_cache( cache_key=generated_cache_key, diff --git a/tests/test_litellm/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py b/tests/test_litellm/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py index 88d1b59c5b5..e9d14d4e18f 100644 --- a/tests/test_litellm/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py +++ b/tests/test_litellm/llms/vertex_ai/context_caching/test_vertex_ai_context_caching.py @@ -187,9 +187,9 @@ class TestContextCachingEndpoints: assert returned_params == optional_params assert returned_cache == "existing_cache_name" - # Verify cache key was generated with tools + # Verify cache key was generated with tools and model mock_cache_obj.get_cache_key.assert_called_once_with( - messages=cached_messages, tools=self.sample_tools + messages=cached_messages, tools=self.sample_tools, model="gemini-1.5-pro" ) @pytest.mark.parametrize( @@ -460,9 +460,9 @@ class TestContextCachingEndpoints: assert returned_params == optional_params assert returned_cache == "existing_cache_name" - # Verify cache key was generated with tools + # Verify cache key was generated with tools and model mock_cache_obj.get_cache_key.assert_called_once_with( - messages=cached_messages, tools=self.sample_tools + messages=cached_messages, tools=self.sample_tools, model="gemini-1.5-pro" ) @pytest.mark.asyncio From 17f8916ce3d54b0bb44c1581ed72afc0f9b6f5c5 Mon Sep 17 00:00:00 2001 From: Graham Neubig Date: Fri, 16 Jan 2026 14:29:38 -0500 Subject: [PATCH 05/14] fix(logging): Include langfuse logger in JSON logging when langfuse callback is used (#19162) When JSON_LOGS is enabled and langfuse is configured as a success/failure callback, the langfuse logger now receives the JSON formatter. This ensures langfuse SDK log messages (like 'Item exceeds size limit' warnings) are output as JSON with proper level information, instead of plain text that log aggregators may incorrectly classify as errors. Fixes issue where langfuse warnings appeared as errors in Datadog due to missing log level in unformatted output. Co-authored-by: openhands --- litellm/_logging.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/litellm/_logging.py b/litellm/_logging.py index 73902d2fc5a..b3156b15ba7 100644 --- a/litellm/_logging.py +++ b/litellm/_logging.py @@ -133,6 +133,26 @@ ALL_LOGGERS = [ ] +def _get_loggers_to_initialize(): + """ + Get all loggers that should be initialized with the JSON handler. + + Includes third-party integration loggers (like langfuse) if they are + configured as callbacks. + """ + import litellm + + loggers = list(ALL_LOGGERS) + + # Add langfuse logger if langfuse is being used as a callback + langfuse_callbacks = {"langfuse", "langfuse_otel"} + all_callbacks = set(litellm.success_callback + litellm.failure_callback) + if langfuse_callbacks & all_callbacks: + loggers.append(logging.getLogger("langfuse")) + + return loggers + + def _initialize_loggers_with_handler(handler: logging.Handler): """ Initialize all loggers with a handler @@ -140,7 +160,7 @@ def _initialize_loggers_with_handler(handler: logging.Handler): - Adds a handler to each logger - Prevents bubbling to parent/root (critical to prevent duplicate JSON logs) """ - for lg in ALL_LOGGERS: + for lg in _get_loggers_to_initialize(): lg.handlers.clear() # remove any existing handlers lg.addHandler(handler) # add JSON formatter handler lg.propagate = False # prevent bubbling to parent/root From 237ba2203ec619721c323c5b6ab471444fb4f78b Mon Sep 17 00:00:00 2001 From: YutaSaito <36355491+uc4w6c@users.noreply.github.com> Date: Sat, 17 Jan 2026 05:57:07 +0900 Subject: [PATCH 06/14] Revert "[Fix] /user/new Privilege Escalation" --- .../internal_user_endpoints.py | 7 -- .../test_internal_user_endpoints.py | 83 ------------------- 2 files changed, 90 deletions(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 89ecc31d83b..1850ffa2560 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -412,13 +412,6 @@ async def new_user( status_code=403, detail="License is over limit. Please contact support@berri.ai to upgrade your license.", ) - - # Only proxy admins can create administrative users - if data.user_role in [LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY] and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN: - raise HTTPException( - status_code=403, - detail=f"Only proxy admins can create administrative users (proxy_admin, proxy_admin_viewer). Attempted to create user with role: {data.user_role}. Your role: {user_api_key_dict.user_role}" - ) data_json = data.json() # type: ignore data_json = _update_internal_new_user_params(data_json, data) diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index 397a6af556f..33f2a75fac6 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -12,7 +12,6 @@ sys.path.insert( from litellm.proxy._types import ( LiteLLM_UserTableFiltered, - LitellmUserRoles, NewUserRequest, ProxyException, UpdateUserRequest, @@ -307,88 +306,6 @@ async def test_new_user_license_over_limit(mocker): mock_license_check.is_over_limit.assert_called_once_with(total_users=1000) -@pytest.mark.asyncio -async def test_new_user_non_admin_cannot_create_admin(mocker): - """ - Test that non-admin users cannot create administrative users (PROXY_ADMIN or PROXY_ADMIN_VIEW_ONLY). - This prevents privilege escalation vulnerabilities. - """ - from litellm.proxy.management_endpoints.internal_user_endpoints import new_user - - # Mock the prisma client - mock_prisma_client = mocker.MagicMock() - - # Setup the mock count response (under license limit) - async def mock_count(*args, **kwargs): - return 5 # Low user count, under limit - - mock_prisma_client.db.litellm_usertable.count = mock_count - - # Mock duplicate checks to pass - async def mock_check_duplicate_user_email(*args, **kwargs): - return None # No duplicate found - - async def mock_check_duplicate_user_id(*args, **kwargs): - return None # No duplicate found - - mocker.patch( - "litellm.proxy.management_endpoints.internal_user_endpoints._check_duplicate_user_email", - mock_check_duplicate_user_email, - ) - mocker.patch( - "litellm.proxy.management_endpoints.internal_user_endpoints._check_duplicate_user_id", - mock_check_duplicate_user_id, - ) - - # Mock the license check to return False (under limit) - mock_license_check = mocker.MagicMock() - mock_license_check.is_over_limit.return_value = False - - # Patch the imports in the endpoint - mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) - mocker.patch("litellm.proxy.proxy_server._license_check", mock_license_check) - - # Test Case 1: INTERNAL_USER trying to create PROXY_ADMIN - user_request = NewUserRequest( - user_email="admin@example.com", user_role=LitellmUserRoles.PROXY_ADMIN - ) - - # Mock user_api_key_dict with non-admin role - mock_user_api_key_dict = UserAPIKeyAuth( - user_id="test_internal_user", user_role=LitellmUserRoles.INTERNAL_USER - ) - - # Call new_user function and expect ProxyException - with pytest.raises(ProxyException) as exc_info: - await new_user(data=user_request, user_api_key_dict=mock_user_api_key_dict) - - # Verify the exception details - assert exc_info.value.code == 403 or exc_info.value.code == "403" - assert "Only proxy admins can create administrative users" in str(exc_info.value.message) - assert "proxy_admin" in str(exc_info.value.message) - assert "proxy_admin_viewer" in str(exc_info.value.message) - assert str(LitellmUserRoles.PROXY_ADMIN) in str(exc_info.value.message) - assert str(LitellmUserRoles.INTERNAL_USER) in str(exc_info.value.message) - - # Test Case 2: INTERNAL_USER trying to create PROXY_ADMIN_VIEW_ONLY - user_request_viewer = NewUserRequest( - user_email="admin_viewer@example.com", - user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, - ) - - with pytest.raises(ProxyException) as exc_info2: - await new_user( - data=user_request_viewer, user_api_key_dict=mock_user_api_key_dict - ) - - # Verify the exception details - assert exc_info2.value.code == 403 or exc_info2.value.code == "403" - assert "Only proxy admins can create administrative users" in str( - exc_info2.value.message - ) - assert str(LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY) in str(exc_info2.value.message) - - @pytest.mark.asyncio async def test_user_info_url_encoding_plus_character(mocker): """ From 66d67ae3563dbe31335aa06001478743afd80789 Mon Sep 17 00:00:00 2001 From: YutaSaito <36355491+uc4w6c@users.noreply.github.com> Date: Sat, 17 Jan 2026 06:01:12 +0900 Subject: [PATCH 07/14] Revert "Add sanititzation for anthropic messages" --- .../docs/completion/message_sanitization.md | 468 ------------------ docs/my-website/sidebars.js | 1 - .../prompt_templates/factory.py | 220 -------- .../anthropic/test_message_sanitization.py | 380 -------------- 4 files changed, 1069 deletions(-) delete mode 100644 docs/my-website/docs/completion/message_sanitization.md delete mode 100644 tests/test_litellm/llms/anthropic/test_message_sanitization.py diff --git a/docs/my-website/docs/completion/message_sanitization.md b/docs/my-website/docs/completion/message_sanitization.md deleted file mode 100644 index 0a1f766e2fd..00000000000 --- a/docs/my-website/docs/completion/message_sanitization.md +++ /dev/null @@ -1,468 +0,0 @@ -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; - -# Message Sanitization for Tool Calling for anthropic models - -**Automatically fix common message formatting issues when using tool calling with `modify_params=True`** - -LiteLLM can automatically sanitize messages to handle common issues that occur during tool calling workflows, especially when using OpenAI-compatible clients with providers that have strict message format requirements (like Anthropic Claude). - -## Overview - -When `litellm.modify_params = True` is enabled, LiteLLM automatically sanitizes messages to fix three common issues: - -1. **Orphaned Tool Calls** - Assistant messages with tool_calls but missing tool results -2. **Orphaned Tool Results** - Tool messages that reference non-existent tool_call_ids -3. **Empty Message Content** - Messages with empty or whitespace-only text content - -This ensures your tool calling workflows work seamlessly across different LLM providers without manual message validation. - -## Why Message Sanitization? - -Different LLM providers have varying requirements for message formats, especially during tool calling: - -- **Anthropic Claude** requires every tool_call to have a corresponding tool result -- Some providers reject messages with empty content -- OpenAI-compatible clients may not always maintain perfect message consistency - -Without sanitization, these issues cause API errors that interrupt your workflows. With `modify_params=True`, LiteLLM handles these edge cases automatically. - -## Quick Start - - - - -```python -import litellm - -# Enable automatic message sanitization -litellm.modify_params = True - -# This will work even if messages have formatting issues -response = litellm.completion( - model="anthropic/claude-3-5-sonnet-20241022", - messages=[ - {"role": "user", "content": "What's the weather in Boston?"}, - { - "role": "assistant", - "tool_calls": [ - { - "id": "call_123", - "type": "function", - "function": {"name": "get_weather", "arguments": '{"city": "Boston"}'} - } - ] - # Missing tool result - LiteLLM will add a dummy result automatically - }, - {"role": "user", "content": "Thanks!"} - ], - tools=[{ - "type": "function", - "function": { - "name": "get_weather", - "description": "Get weather for a city", - "parameters": { - "type": "object", - "properties": {"city": {"type": "string"}}, - "required": ["city"] - } - } - }] -) -``` - - - - -```yaml -litellm_settings: - modify_params: true # Enable automatic message sanitization - -model_list: - - model_name: claude-3-5-sonnet - litellm_params: - model: anthropic/claude-3-5-sonnet-20241022 -``` - - - - -## Sanitization Cases - -### Case A: Orphaned Tool Calls (Missing Tool Results) - -**Problem:** An assistant message contains `tool_calls`, but no corresponding tool result messages follow. - -**Solution:** LiteLLM automatically adds dummy tool result messages for any missing tool results. - -**Example:** - -```python -import litellm -litellm.modify_params = True - -# Messages with orphaned tool calls -messages = [ - {"role": "user", "content": "Search for Python tutorials"}, - { - "role": "assistant", - "tool_calls": [ - { - "id": "call_abc123", - "type": "function", - "function": {"name": "web_search", "arguments": '{"query": "Python tutorials"}'} - } - ] - }, - # Missing tool result here! - {"role": "user", "content": "What about JavaScript?"} -] - -# LiteLLM automatically adds: -# { -# "role": "tool", -# "tool_call_id": "call_abc123", -# "content": "[System: Tool execution skipped/interrupted by user. No result provided for tool 'web_search'.]" -# } - -response = litellm.completion( - model="anthropic/claude-3-5-sonnet-20241022", - messages=messages, - tools=[...] -) -``` - -**When this happens:** -- User interrupts tool execution -- Client loses tool results due to network issues -- Conversation flow changes before tool completes -- Multi-turn conversations where tools are optional - -### Case B: Orphaned Tool Results (Invalid tool_call_id) - -**Problem:** A tool message references a `tool_call_id` that doesn't exist in any previous assistant message. - -**Solution:** LiteLLM automatically removes these orphaned tool result messages. - -**Example:** - -```python -import litellm -litellm.modify_params = True - -# Messages with orphaned tool result -messages = [ - {"role": "user", "content": "Hello"}, - {"role": "assistant", "content": "Hi! How can I help?"}, - { - "role": "tool", - "tool_call_id": "call_nonexistent", # This tool_call_id doesn't exist! - "content": "Some result" - } -] - -# LiteLLM automatically removes the orphaned tool message - -response = litellm.completion( - model="anthropic/claude-3-5-sonnet-20241022", - messages=messages -) -``` - -**When this happens:** -- Message history is manually edited -- Tool results are duplicated or mismatched -- Conversation state is restored incorrectly -- Messages are merged from different conversations - -### Case C: Empty Message Content - -**Problem:** User or assistant messages have empty or whitespace-only content. - -**Solution:** LiteLLM replaces empty content with a system placeholder message. - -**Example:** - -```python -import litellm -litellm.modify_params = True - -# Messages with empty content -messages = [ - {"role": "user", "content": ""}, # Empty content - {"role": "assistant", "content": " "}, # Whitespace only -] - -# LiteLLM automatically replaces with: -# {"role": "user", "content": "[System: Empty message content sanitised to satisfy protocol]"} -# {"role": "assistant", "content": "[System: Empty message content sanitised to satisfy protocol]"} - -response = litellm.completion( - model="anthropic/claude-3-5-sonnet-20241022", - messages=messages -) -``` - -**When this happens:** -- UI sends empty messages -- Content is stripped during preprocessing -- Placeholder messages in conversation history -- Edge cases in message construction - -## Configuration - -### Enable Globally - - - - -```python -import litellm - -# Enable for all completion calls -litellm.modify_params = True -``` - - - - -```yaml -litellm_settings: - modify_params: true -``` - - - - -```bash -export LITELLM_MODIFY_PARAMS=True -``` - - - - -### Enable Per-Request - -```python -import litellm - -# Enable only for specific requests -response = litellm.completion( - model="anthropic/claude-3-5-sonnet-20241022", - messages=messages, - modify_params=True # Override global setting -) -``` - -## Supported Providers - -Message sanitization works with all LLM providers that support tool calling: - -- ✅ Anthropic (Claude) -- ✅ OpenAI (GPT-4, GPT-3.5) -- ✅ AWS Bedrock (Claude, Titan) -- ✅ Google Vertex AI (Claude, Gemini) -- ✅ Azure OpenAI -- ✅ And all other providers with tool calling support - -## Implementation Details - -### How It Works - -The message sanitization process runs **before** messages are converted to provider-specific formats: - -1. **Input:** OpenAI-format messages with potential issues -2. **Sanitization:** Three helper functions process the messages: - - `_sanitize_empty_text_content()` - Fixes empty content - - `_add_missing_tool_results()` - Adds dummy tool results - - `_is_orphaned_tool_result()` - Identifies orphaned results -3. **Output:** Clean, provider-compatible messages - -### Code Reference - -The sanitization logic is implemented in: -- `litellm/litellm_core_utils/prompt_templates/factory.py` -- Function: `sanitize_messages_for_tool_calling()` - -### Logging - -When sanitization occurs, LiteLLM logs debug messages: - -```python -import litellm -litellm.set_verbose = True # Enable debug logging - -# You'll see logs like: -# "_add_missing_tool_results: Found 1 orphaned tool calls. Adding dummy tool results." -# "_is_orphaned_tool_result: Found orphaned tool result with tool_call_id=call_123" -# "_sanitize_empty_text_content: Replaced empty text content in user message" -``` - -## Best Practices - -### 1. Enable for Production Workflows - -```python -# Recommended for production -litellm.modify_params = True - -# Ensures robust handling of edge cases -response = litellm.completion( - model="anthropic/claude-3-5-sonnet-20241022", - messages=messages, - tools=tools -) -``` - -### 2. Preserve Tool Results When Possible - -While sanitization handles missing tool results, it's better to provide actual results: - -```python -# Good: Provide actual tool results -messages = [ - {"role": "user", "content": "Search for Python"}, - {"role": "assistant", "tool_calls": [...]}, - {"role": "tool", "tool_call_id": "call_123", "content": "Actual search results"} -] - -# Fallback: Sanitization adds dummy result if missing -messages = [ - {"role": "user", "content": "Search for Python"}, - {"role": "assistant", "tool_calls": [...]}, - # Missing tool result - sanitization adds dummy -] -``` - -### 3. Monitor Sanitization Events - -Use logging to track when sanitization occurs: - -```python -import litellm -import logging - -# Enable debug logging -litellm.set_verbose = True -logging.basicConfig(level=logging.DEBUG) - -# Track sanitization events in your application -response = litellm.completion( - model="anthropic/claude-3-5-sonnet-20241022", - messages=messages -) -``` - -### 4. Test Edge Cases - -Ensure your application handles sanitized messages correctly: - -```python -import litellm -litellm.modify_params = True - -# Test orphaned tool calls -test_messages = [ - {"role": "user", "content": "Test"}, - {"role": "assistant", "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "test", "arguments": "{}"}}]}, - {"role": "user", "content": "Continue"} # No tool result -] - -response = litellm.completion( - model="anthropic/claude-3-5-sonnet-20241022", - messages=test_messages, - tools=[...] -) - -# Verify the response handles the dummy tool result appropriately -``` - -## Related Features - -- **[Drop Params](./drop_params.md)** - Drop unsupported parameters for specific providers -- **[Message Trimming](./message_trimming.md)** - Trim messages to fit token limits -- **[Function Calling](./function_call.md)** - Complete guide to tool/function calling -- **[Reasoning Content](../reasoning_content.md)** - Extended thinking with tool calling - -## Troubleshooting - -### Sanitization Not Working - -**Issue:** Messages still cause errors despite `modify_params=True` - -**Solution:** -1. Verify `modify_params` is enabled: - ```python - import litellm - print(litellm.modify_params) # Should be True - ``` - -2. Check if the issue is provider-specific: - ```python - litellm.set_verbose = True # Enable debug logging - ``` - -3. Ensure you're using a recent version of LiteLLM: - ```bash - pip install --upgrade litellm - ``` - -### Unexpected Dummy Tool Results - -**Issue:** Dummy tool results appear when you expect actual results - -**Cause:** Tool result messages are missing or have incorrect `tool_call_id` - -**Solution:** -1. Verify tool result messages have correct `tool_call_id`: - ```python - # Correct - {"role": "tool", "tool_call_id": "call_123", "content": "result"} - - # Incorrect - will be treated as orphaned - {"role": "tool", "tool_call_id": "wrong_id", "content": "result"} - ``` - -2. Ensure tool results immediately follow assistant messages with tool_calls - -### Performance Impact - -**Issue:** Concerned about performance overhead - -**Details:** Message sanitization has minimal performance impact: -- Runs in O(n) time where n = number of messages -- Only processes messages when `modify_params=True` -- Typically adds < 1ms to request processing time - -## FAQ - -**Q: Does sanitization modify my original messages?** - -A: No, sanitization creates a new list of messages. Your original messages remain unchanged. - -**Q: Can I disable specific sanitization cases?** - -A: Currently, all three cases are handled together when `modify_params=True`. To disable sanitization entirely, set `modify_params=False`. - -**Q: What happens to the dummy tool results?** - -A: Dummy tool results are sent to the LLM provider along with other messages. The model sees them as regular tool results with informative error messages. - -**Q: Does this work with streaming?** - -A: Yes, message sanitization works with both streaming and non-streaming requests. - -**Q: Is this related to `drop_params`?** - -A: No, they're separate features: -- `modify_params` - Modifies/fixes message content and structure -- `drop_params` - Removes unsupported API parameters - -Both can be enabled simultaneously. - -## See Also - -- [Reasoning Content with Tool Calling](../reasoning_content.md) -- [Function Calling Guide](./function_call.md) -- [Bedrock Provider Documentation](../providers/bedrock.md) -- [Anthropic Provider Documentation](../providers/anthropic.md) diff --git a/docs/my-website/sidebars.js b/docs/my-website/sidebars.js index acc5d538550..38a26f6b183 100644 --- a/docs/my-website/sidebars.js +++ b/docs/my-website/sidebars.js @@ -822,7 +822,6 @@ const sidebars = { "completion/knowledgebase", "guides/code_interpreter", "completion/message_trimming", - "completion/message_sanitization", "completion/model_alias", "completion/mock_requests", "completion/predict_outputs", diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 2311b34a2cc..01bf18d79b2 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -1989,223 +1989,6 @@ def anthropic_process_openai_file_message( ) -def _sanitize_empty_text_content( - message: AllMessageValues, -) -> AllMessageValues: - """ - Case C: Sanitize empty text content - - Replace empty or whitespace-only text content with a placeholder message. - - Returns: - The message with sanitized content if needed, otherwise the original message - """ - if message.get("role") in ["user", "assistant"]: - content = message.get("content") - if isinstance(content, str): - if not content or not content.strip(): - message = dict(message) # Make a copy - message["content"] = "[System: Empty message content sanitised to satisfy protocol]" - verbose_logger.debug( - f"_sanitize_empty_text_content: Replaced empty text content in {message.get('role')} message" - ) - return message - - -def _add_missing_tool_results( - current_message: AllMessageValues, - messages: List[AllMessageValues], - current_index: int, -) -> List[AllMessageValues]: - """ - Case A: Missing tool_result for tool_use (orphaned tool calls) - - If an assistant message has tool_calls but no corresponding tool result follows, - add a dummy tool result message indicating the user did not provide the result. - - Returns: - A list containing the assistant message followed by any dummy tool results needed - """ - result_messages: List[AllMessageValues] = [] - tool_calls = current_message.get("tool_calls") - - if not tool_calls or len(tool_calls) == 0: - return [current_message] - - # Collect all tool_call_ids from this assistant message - expected_tool_call_ids = set() - for tool_call in tool_calls: - tool_call_id = None - if isinstance(tool_call, dict): - tool_call_id = tool_call.get("id") - else: - tool_call_id = getattr(tool_call, "id", None) - if tool_call_id: - expected_tool_call_ids.add(tool_call_id) - - found_tool_call_ids = set() - j = current_index + 1 - - while j < len(messages): - next_msg = messages[j] - next_role = next_msg.get("role") - - if next_role == "assistant": - break - - if next_role in ["tool", "function"]: - tool_call_id = next_msg.get("tool_call_id") - if tool_call_id: - found_tool_call_ids.add(tool_call_id) - - j += 1 - - # Find missing tool results - missing_tool_call_ids = expected_tool_call_ids - found_tool_call_ids - - if missing_tool_call_ids: - verbose_logger.debug( - f"_add_missing_tool_results: Found {len(missing_tool_call_ids)} orphaned tool calls. Adding dummy tool results." - ) - - result_messages.append(current_message) - - for tool_call_id in missing_tool_call_ids: - tool_name = "unknown_tool" - for tool_call in tool_calls: - tc_id = None - if isinstance(tool_call, dict): - tc_id = tool_call.get("id") - else: - tc_id = getattr(tool_call, "id", None) - - if tc_id == tool_call_id: - if isinstance(tool_call, dict): - function = tool_call.get("function", {}) - if isinstance(function, dict): - tool_name = function.get("name", "unknown_tool") - else: - tool_name = getattr(function, "name", "unknown_tool") - else: - function = getattr(tool_call, "function", None) - if function: - tool_name = getattr(function, "name", "unknown_tool") - break - - dummy_tool_result: ChatCompletionToolMessage = { - "role": "tool", - "tool_call_id": tool_call_id, - "content": f"[System: Tool execution skipped/interrupted by user. No result provided for tool '{tool_name}'.]", - } - result_messages.append(dummy_tool_result) - - return result_messages - - return [current_message] - - -def _is_orphaned_tool_result( - current_message: AllMessageValues, - sanitized_messages: List[AllMessageValues], -) -> bool: - """ - Case B: Orphaned tool_result (unexpected result) - - Check if a tool message references a tool_call_id that doesn't exist in the previous - assistant message. - - Returns: - True if this is an orphaned tool result that should be removed, False otherwise - """ - if current_message.get("role") not in ["tool", "function"]: - return False - - tool_call_id = current_message.get("tool_call_id") - - if not tool_call_id: - return False - - # Look back to find the most recent assistant message with tool_calls - found_matching_tool_call = False - - for j in range(len(sanitized_messages) - 1, -1, -1): - prev_msg = sanitized_messages[j] - if prev_msg.get("role") == "assistant": - tool_calls = prev_msg.get("tool_calls") - if tool_calls: - for tool_call in tool_calls: - tc_id = None - if isinstance(tool_call, dict): - tc_id = tool_call.get("id") - else: - tc_id = getattr(tool_call, "id", None) - - if tc_id == tool_call_id: - found_matching_tool_call = True - break - - break - - if not found_matching_tool_call: - verbose_logger.debug( - "_is_orphaned_tool_result: Found orphaned tool result with redacted tool_call_id" - ) - return True - - return False - - -def sanitize_messages_for_tool_calling( - messages: List[AllMessageValues], -) -> List[AllMessageValues]: - """ - Sanitize messages for tool calling to handle common issues when modify_params=True: - - Case A: Missing tool_result for tool_use (orphaned tool calls) - - If an assistant message has tool_calls but no corresponding tool result follows, - add a dummy tool result message indicating the user did not provide the result. - - Case B: Orphaned tool_result (unexpected result) - - If a tool message references a tool_call_id that doesn't exist in the previous - assistant message, remove that tool message. - - Case C: Empty text content - - Replace empty or whitespace-only text content with a placeholder message. - - This function operates on OpenAI format messages before they are converted to - provider-specific formats. - """ - if not litellm.modify_params: - return messages - - sanitized_messages: List[AllMessageValues] = [] - i = 0 - - while i < len(messages): - current_message = messages[i] - - # Case C: Sanitize empty text content - current_message = _sanitize_empty_text_content(current_message) - - # Case A: Check if assistant message has tool_calls without following tool results - if current_message.get("role") == "assistant": - result_messages = _add_missing_tool_results(current_message, messages, i) - - # If dummy tool results were added, extend sanitized_messages and continue - if len(result_messages) > 1: - sanitized_messages.extend(result_messages) - i += 1 - continue - - # Case B: Check for orphaned tool results - if _is_orphaned_tool_result(current_message, sanitized_messages): - i += 1 - continue # Skip this orphaned tool result - - # Add the message to sanitized list - sanitized_messages.append(current_message) - i += 1 - - return sanitized_messages - - def anthropic_messages_pt( # noqa: PLR0915 messages: List[AllMessageValues], model: str, @@ -2225,9 +2008,6 @@ def anthropic_messages_pt( # noqa: PLR0915 5. System messages are a separate param to the Messages API 6. Ensure we only accept role, content. (message.name is not supported) """ - # Sanitize messages for tool calling issues when modify_params=True - messages = sanitize_messages_for_tool_calling(messages) - # add role=tool support to allow function call result/error submission user_message_types = {"user", "tool", "function"} # reformat messages to ensure user/assistant are alternating, if there's either 2 consecutive 'user' messages or 2 consecutive 'assistant' message, merge them. diff --git a/tests/test_litellm/llms/anthropic/test_message_sanitization.py b/tests/test_litellm/llms/anthropic/test_message_sanitization.py deleted file mode 100644 index 489ef527b48..00000000000 --- a/tests/test_litellm/llms/anthropic/test_message_sanitization.py +++ /dev/null @@ -1,380 +0,0 @@ -""" -Test message sanitization for Anthropic API when modify_params=True - -Tests three cases: -A. Missing tool_result for tool_use (orphaned tool calls) -B. Orphaned tool_result without matching tool_use -C. Empty text content -""" - -import pytest -import sys -import os - -# Add the parent directory to the path so we can import litellm -sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../.."))) - -import litellm -from litellm.litellm_core_utils.prompt_templates.factory import ( - sanitize_messages_for_tool_calling, - anthropic_messages_pt, -) - - -class TestMessageSanitization: - """Test message sanitization for tool calling scenarios""" - - def setup_method(self): - """Setup for each test""" - # Save original modify_params value - self.original_modify_params = litellm.modify_params - litellm.modify_params = True - - def teardown_method(self): - """Cleanup after each test""" - # Restore original modify_params value - litellm.modify_params = self.original_modify_params - - def test_case_a_orphaned_tool_call_single(self): - """ - Test Case A: Assistant message with tool_calls but no tool result - Should add a dummy tool result message - """ - messages = [ - { - "role": "user", - "content": "What is the weather in Nashik?" - }, - { - "role": "assistant", - "content": None, - "tool_calls": [ - { - "id": "toolu_01Kus2cC3ydjBW7UK4GJqBP4", - "type": "function", - "function": { - "name": "get_weather", - "arguments": '{"location": "Nashik, India"}' - } - } - ] - } - ] - - sanitized = sanitize_messages_for_tool_calling(messages) - - # Should have 3 messages: user, assistant, and dummy tool result - assert len(sanitized) == 3 - assert sanitized[0]["role"] == "user" - assert sanitized[1]["role"] == "assistant" - assert sanitized[2]["role"] == "tool" - assert sanitized[2]["tool_call_id"] == "toolu_01Kus2cC3ydjBW7UK4GJqBP4" - assert "skipped" in sanitized[2]["content"].lower() or "interrupted" in sanitized[2]["content"].lower() - assert "get_weather" in sanitized[2]["content"] - - def test_case_a_orphaned_tool_call_multiple(self): - """ - Test Case A: Assistant message with multiple tool_calls, some missing results - """ - messages = [ - { - "role": "user", - "content": "Get weather for Nashik and Mumbai" - }, - { - "role": "assistant", - "content": None, - "tool_calls": [ - { - "id": "call_1", - "type": "function", - "function": { - "name": "get_weather", - "arguments": '{"location": "Nashik"}' - } - }, - { - "id": "call_2", - "type": "function", - "function": { - "name": "get_weather", - "arguments": '{"location": "Mumbai"}' - } - } - ] - }, - { - "role": "tool", - "tool_call_id": "call_1", - "content": "Weather in Nashik: 25°C" - } - ] - - sanitized = sanitize_messages_for_tool_calling(messages) - - # Should have 4 messages: user, assistant, tool result for call_1, dummy for call_2 - assert len(sanitized) == 4 - assert sanitized[0]["role"] == "user" - assert sanitized[1]["role"] == "assistant" - assert sanitized[2]["tool_call_id"] == "call_2" # Dummy added first - assert sanitized[3]["tool_call_id"] == "call_1" # Original tool result - - def test_case_b_orphaned_tool_result(self): - """ - Test Case B: Tool result without matching tool_call in previous assistant message - Should remove the orphaned tool result - """ - messages = [ - { - "role": "user", - "content": "Hello" - }, - { - "role": "assistant", - "content": "Hi there!" - }, - { - "role": "tool", - "tool_call_id": "nonexistent_id", - "content": "Some result" - } - ] - - sanitized = sanitize_messages_for_tool_calling(messages) - - # Should have only 2 messages, orphaned tool result removed - assert len(sanitized) == 2 - assert sanitized[0]["role"] == "user" - assert sanitized[1]["role"] == "assistant" - - def test_case_b_valid_tool_result_preserved(self): - """ - Test Case B: Valid tool result with matching tool_call should be preserved - """ - messages = [ - { - "role": "user", - "content": "What's the weather?" - }, - { - "role": "assistant", - "content": None, - "tool_calls": [ - { - "id": "call_123", - "type": "function", - "function": { - "name": "get_weather", - "arguments": '{"location": "Boston"}' - } - } - ] - }, - { - "role": "tool", - "tool_call_id": "call_123", - "content": "Weather: 20°C" - } - ] - - sanitized = sanitize_messages_for_tool_calling(messages) - - # All messages should be preserved - assert len(sanitized) == 3 - assert sanitized[2]["role"] == "tool" - assert sanitized[2]["tool_call_id"] == "call_123" - - def test_case_c_empty_text_content_user(self): - """ - Test Case C: Empty text content in user message - Should replace with placeholder - """ - messages = [ - { - "role": "user", - "content": "" - }, - { - "role": "assistant", - "content": "Hello!" - } - ] - - sanitized = sanitize_messages_for_tool_calling(messages) - - assert len(sanitized) == 2 - assert sanitized[0]["role"] == "user" - assert sanitized[0]["content"] == "[System: Empty message content sanitised to satisfy protocol]" - - def test_case_c_whitespace_only_content(self): - """ - Test Case C: Whitespace-only content - Should replace with placeholder - """ - messages = [ - { - "role": "user", - "content": " \n \t " - }, - { - "role": "assistant", - "content": " " - } - ] - - sanitized = sanitize_messages_for_tool_calling(messages) - - assert len(sanitized) == 2 - assert sanitized[0]["content"] == "[System: Empty message content sanitised to satisfy protocol]" - assert sanitized[1]["content"] == "[System: Empty message content sanitised to satisfy protocol]" - - def test_case_c_valid_content_preserved(self): - """ - Test Case C: Valid non-empty content should be preserved - """ - messages = [ - { - "role": "user", - "content": "Hello" - }, - { - "role": "assistant", - "content": "Hi there!" - } - ] - - sanitized = sanitize_messages_for_tool_calling(messages) - - assert len(sanitized) == 2 - assert sanitized[0]["content"] == "Hello" - assert sanitized[1]["content"] == "Hi there!" - - def test_combined_cases(self): - """ - Test combination of multiple cases - """ - messages = [ - { - "role": "user", - "content": "Get weather" - }, - { - "role": "assistant", - "content": None, - "tool_calls": [ - { - "id": "call_1", - "type": "function", - "function": { - "name": "get_weather", - "arguments": '{"location": "NYC"}' - } - } - ] - }, - # Missing tool result for call_1 - { - "role": "user", - "content": "" # Empty content - }, - { - "role": "assistant", - "content": "Response" - }, - { - "role": "tool", - "tool_call_id": "orphaned_id", # Orphaned tool result - "content": "Some data" - } - ] - - sanitized = sanitize_messages_for_tool_calling(messages) - - # Should have: user, assistant, dummy tool result, user (sanitized), assistant - # Orphaned tool result should be removed - assert len(sanitized) == 5 - assert sanitized[0]["role"] == "user" - assert sanitized[1]["role"] == "assistant" - assert sanitized[2]["role"] == "tool" - assert sanitized[2]["tool_call_id"] == "call_1" # Dummy added - assert sanitized[3]["role"] == "user" - assert sanitized[3]["content"] == "[System: Empty message content sanitised to satisfy protocol]" - assert sanitized[4]["role"] == "assistant" - - def test_modify_params_false_no_sanitization(self): - """ - Test that sanitization is skipped when modify_params=False - """ - litellm.modify_params = False - - messages = [ - { - "role": "user", - "content": "" - }, - { - "role": "assistant", - "content": None, - "tool_calls": [ - { - "id": "call_1", - "type": "function", - "function": { - "name": "get_weather", - "arguments": '{}' - } - } - ] - } - ] - - sanitized = sanitize_messages_for_tool_calling(messages) - - # Messages should be unchanged - assert len(sanitized) == 2 - assert sanitized[0]["content"] == "" - assert len(sanitized[1].get("tool_calls", [])) == 1 - - def test_anthropic_messages_pt_integration(self): - """ - Test that sanitization is integrated into anthropic_messages_pt - """ - litellm.modify_params = True - - messages = [ - { - "role": "user", - "content": "What is the weather in Nashik?" - }, - { - "role": "assistant", - "content": None, - "tool_calls": [ - { - "id": "toolu_01Kus2cC3ydjBW7UK4GJqBP4", - "type": "function", - "function": { - "name": "get_weather", - "arguments": '{"location": "Nashik, India"}' - } - } - ] - } - ] - - # This should not raise an error and should add dummy tool result - result = anthropic_messages_pt( - messages=messages, - model="claude-sonnet-4-5", - llm_provider="anthropic" - ) - - # Should have at least 2 messages (user and assistant) - # The tool result will be merged into user content - assert len(result) >= 2 - assert result[0]["role"] == "user" - assert result[1]["role"] == "assistant" - - -if __name__ == "__main__": - pytest.main([__file__, "-v"]) From ca2019776e9ecc387325495a030a4b5fcf57ceaf Mon Sep 17 00:00:00 2001 From: YutaSaito <36355491+uc4w6c@users.noreply.github.com> Date: Sat, 17 Jan 2026 06:04:24 +0900 Subject: [PATCH 08/14] Revert "Fix: malformed tool call transformation in bedrock" --- .../prompt_templates/factory.py | 20 +-- .../bedrock/chat/converse_transformation.py | 9 +- litellm/types/llms/bedrock.py | 2 +- .../test_bedrock_completion.py | 154 ------------------ 4 files changed, 10 insertions(+), 175 deletions(-) diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index 01bf18d79b2..4320f756454 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -3233,21 +3233,17 @@ def _convert_to_bedrock_tool_call_invoke( id = tool["id"] name = tool["function"].get("name", "") arguments = tool["function"].get("arguments", "") + arguments_dict = json.loads(arguments) if arguments else {} + # Ensure arguments_dict is always a dict (Bedrock requires toolUse.input to be an object) + # When some providers return arguments: '""' (JSON-encoded empty string), json.loads returns "" + if not isinstance(arguments_dict, dict): + arguments_dict = {} if not arguments or not arguments.strip(): - arguments_input = {} + arguments_dict = {} else: - # Try to parse the arguments JSON - try: - arguments_input = json.loads(arguments) - except json.JSONDecodeError as e: - verbose_logger.warning( - f"Malformed JSON in tool call arguments for tool '{name}': {str(e)}. " - f"Storing as raw string to allow conversation to continue." - ) - arguments_input = arguments - + arguments_dict = json.loads(arguments) bedrock_tool = BedrockToolUseBlock( - input=arguments_input, name=name, toolUseId=id + input=arguments_dict, name=name, toolUseId=id ) bedrock_content_block = BedrockContentBlock(toolUse=bedrock_tool) _parts_list.append(bedrock_content_block) diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 9bc1e8c85e2..59590e464fc 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -1395,16 +1395,9 @@ class AmazonConverseConfig(BaseConfig): response_tool_name = get_bedrock_tool_name( response_tool_name=_response_tool_name ) - tool_input = content["toolUse"]["input"] - if isinstance(tool_input, str): - arguments_str = tool_input - else: - # Otherwise, serialize it to JSON - arguments_str = json.dumps(tool_input) - _function_chunk = ChatCompletionToolCallFunctionChunk( name=response_tool_name, - arguments=arguments_str, + arguments=json.dumps(content["toolUse"]["input"]), ) _tool_response_chunk = ChatCompletionToolCallChunk( diff --git a/litellm/types/llms/bedrock.py b/litellm/types/llms/bedrock.py index e0858898eae..ef2f1ba4d5e 100644 --- a/litellm/types/llms/bedrock.py +++ b/litellm/types/llms/bedrock.py @@ -62,7 +62,7 @@ class ToolResultBlock(TypedDict, total=False): class ToolUseBlock(TypedDict): - input: Any # Per boto3 spec: document type can be dict, list, int, float, str, bool, or None + input: dict name: str toolUseId: str diff --git a/tests/llm_translation/test_bedrock_completion.py b/tests/llm_translation/test_bedrock_completion.py index f08060214c5..7c0db41d13a 100644 --- a/tests/llm_translation/test_bedrock_completion.py +++ b/tests/llm_translation/test_bedrock_completion.py @@ -3954,157 +3954,3 @@ def test_bedrock_openai_error_handling(): assert exc_info.value.status_code == 422 print("✓ Error handling works correctly") - - -def test_bedrock_malformed_tool_json_handling(): - """ - Test that Bedrock handles malformed JSON in tool call arguments gracefully. - - This test covers the issue where: - 1. LLM generates malformed JSON in tool call arguments - 2. Subsequent requests with conversation history should not crash - 3. The toolUse.input field should handle any JSON value type per boto3 spec - - Related issue: https://github.com/BerriAI/litellm/issues/[issue_number] - """ - from litellm.litellm_core_utils.prompt_templates.factory import ( - _convert_to_bedrock_tool_call_invoke, - ) - from litellm.llms.bedrock.chat.converse_transformation import AmazonConverseConfig - from litellm.types.llms.bedrock import ContentBlock - - # Test 1: Malformed JSON in tool call arguments - malformed_tool_calls = [ - { - "id": "call_123", - "type": "function", - "function": { - "name": "get_weather", - "arguments": '{"location": "Paris", "invalid_json', # Malformed JSON - }, - } - ] - - # Should not raise an exception, but store as raw string - result = _convert_to_bedrock_tool_call_invoke(malformed_tool_calls) - assert len(result) == 1 - assert result[0]["toolUse"]["name"] == "get_weather" - # The malformed JSON should be stored as a string - assert isinstance(result[0]["toolUse"]["input"], str) - assert result[0]["toolUse"]["input"] == '{"location": "Paris", "invalid_json' - print("✓ Malformed JSON stored as raw string") - - # Test 2: Valid JSON should still work normally - valid_tool_calls = [ - { - "id": "call_456", - "type": "function", - "function": { - "name": "get_weather", - "arguments": '{"location": "London"}', - }, - } - ] - - result = _convert_to_bedrock_tool_call_invoke(valid_tool_calls) - assert len(result) == 1 - assert result[0]["toolUse"]["name"] == "get_weather" - assert isinstance(result[0]["toolUse"]["input"], dict) - assert result[0]["toolUse"]["input"] == {"location": "London"} - print("✓ Valid JSON parsed correctly") - - # Test 3: Empty arguments should create empty dict - empty_tool_calls = [ - { - "id": "call_789", - "type": "function", - "function": { - "name": "no_args_function", - "arguments": "", - }, - } - ] - - result = _convert_to_bedrock_tool_call_invoke(empty_tool_calls) - assert len(result) == 1 - assert result[0]["toolUse"]["input"] == {} - print("✓ Empty arguments handled correctly") - - # Test 4: Bedrock to OpenAI conversion handles string input - converse_config = AmazonConverseConfig() - content_blocks = [ - ContentBlock( - toolUse={ - "name": "get_weather", - "toolUseId": "call_123", - "input": '{"location": "Paris", "invalid_json', # String input (malformed) - } - ) - ] - - content_str, tools, reasoning = converse_config._translate_message_content( - content_blocks - ) - assert len(tools) == 1 - assert tools[0]["function"]["name"] == "get_weather" - # Should return the string as-is - assert tools[0]["function"]["arguments"] == '{"location": "Paris", "invalid_json' - print("✓ Bedrock to OpenAI conversion handles string input") - - # Test 5: Bedrock to OpenAI conversion handles dict input - content_blocks_dict = [ - ContentBlock( - toolUse={ - "name": "get_weather", - "toolUseId": "call_456", - "input": {"location": "London"}, # Dict input (normal case) - } - ) - ] - - content_str, tools, reasoning = converse_config._translate_message_content( - content_blocks_dict - ) - assert len(tools) == 1 - assert tools[0]["function"]["name"] == "get_weather" - # Should serialize dict to JSON string - assert tools[0]["function"]["arguments"] == '{"location": "London"}' - print("✓ Bedrock to OpenAI conversion handles dict input") - - # Test 6: Round-trip conversion with malformed JSON - # Test that we can convert OpenAI -> Bedrock -> OpenAI with malformed JSON - malformed_tool_calls_roundtrip = [ - { - "id": "call_999", - "type": "function", - "function": { - "name": "test_function", - "arguments": '{"key": "value", "broken', # Malformed - }, - } - ] - - # Step 1: OpenAI to Bedrock (should store as string) - bedrock_blocks = _convert_to_bedrock_tool_call_invoke(malformed_tool_calls_roundtrip) - assert isinstance(bedrock_blocks[0]["toolUse"]["input"], str) - - # Step 2: Bedrock back to OpenAI (should preserve the string) - content_blocks_roundtrip = [ - ContentBlock( - toolUse={ - "name": bedrock_blocks[0]["toolUse"]["name"], - "toolUseId": bedrock_blocks[0]["toolUse"]["toolUseId"], - "input": bedrock_blocks[0]["toolUse"]["input"], - } - ) - ] - - content_str, tools_roundtrip, reasoning = converse_config._translate_message_content( - content_blocks_roundtrip - ) - - # Should preserve the malformed JSON string through the round trip - assert tools_roundtrip[0]["function"]["arguments"] == '{"key": "value", "broken' - print("✓ Round-trip conversion preserves malformed JSON") - - print("✓ All malformed JSON handling tests passed") From bec61c39ae241e8ff60b04cf99a29a3ab6df6ca8 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Sat, 17 Jan 2026 06:17:38 +0900 Subject: [PATCH 09/14] =?UTF-8?q?bump:=20version=200.4.21=20=E2=86=92=200.?= =?UTF-8?q?4.22?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- litellm-proxy-extras/pyproject.toml | 4 ++-- pyproject.toml | 2 +- requirements.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/litellm-proxy-extras/pyproject.toml b/litellm-proxy-extras/pyproject.toml index 2952aa6c979..4304aaf9e96 100644 --- a/litellm-proxy-extras/pyproject.toml +++ b/litellm-proxy-extras/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "litellm-proxy-extras" -version = "0.4.21" +version = "0.4.22" description = "Additional files for the LiteLLM Proxy. Reduces the size of the main litellm package." authors = ["BerriAI"] readme = "README.md" @@ -22,7 +22,7 @@ requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" [tool.commitizen] -version = "0.4.21" +version = "0.4.22" version_files = [ "pyproject.toml:version", "../requirements.txt:litellm-proxy-extras==", diff --git a/pyproject.toml b/pyproject.toml index a5071353d6b..55d97f9a98f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -59,7 +59,7 @@ websockets = {version = "^15.0.1", optional = true} boto3 = {version = "1.40.61", optional = true} redisvl = {version = "^0.4.1", optional = true, markers = "python_version >= '3.9' and python_version < '3.14'"} mcp = {version = "^1.21.2", optional = true, python = ">=3.10"} -litellm-proxy-extras = {version = "0.4.21", optional = true} +litellm-proxy-extras = {version = "0.4.22", optional = true} rich = {version = "13.7.1", optional = true} litellm-enterprise = {version = "0.1.27", optional = true} diskcache = {version = "^5.6.1", optional = true} diff --git a/requirements.txt b/requirements.txt index e98e295de30..0880e04fc5f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -48,7 +48,7 @@ sentry_sdk==2.21.0 # for sentry error handling detect-secrets==1.5.0 # Enterprise - secret detection / masking in LLM requests cryptography==44.0.1 tzdata==2025.1 # IANA time zone database -litellm-proxy-extras==0.4.21 # for proxy extras - e.g. prisma migrations +litellm-proxy-extras==0.4.22 # for proxy extras - e.g. prisma migrations llm-sandbox==0.3.31 # for skill execution in sandbox ### LITELLM PACKAGE DEPENDENCIES python-dotenv==1.0.1 # for env From eec4ed640bf139f46379d70ca85e1f3c03b1f83e Mon Sep 17 00:00:00 2001 From: YutaSaito <36355491+uc4w6c@users.noreply.github.com> Date: Sat, 17 Jan 2026 06:26:18 +0900 Subject: [PATCH 10/14] Revert "Stabilise mock tests" --- .../llm_passthrough_endpoints.py | 1 + .../test_responses_background_cost.py | 42 +++-- ...erimental_pass_through_messages_handler.py | 104 ++++++------- .../chat/test_converse_transformation.py | 93 +++++++++++ .../files/test_bedrock_files_integration.py | 146 ++++++++---------- .../huggingface/embedding/test_handler.py | 34 +++- .../files/test_vertex_ai_files_integration.py | 46 ++++++ .../test_openapi_to_mcp_generator.py | 34 ++++ .../guardrails/test_pillar_guardrails.py | 29 +++- .../proxy/test_litellm_pre_call_utils.py | 16 +- tests/test_litellm/proxy/test_proxy_server.py | 39 ++++- tests/test_litellm/test_router.py | 22 +-- 12 files changed, 416 insertions(+), 190 deletions(-) rename tests/test_litellm/{enterprise => integrations}/test_responses_background_cost.py (95%) diff --git a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py index 92e37c64083..e48fd22bc8d 100644 --- a/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/llm_passthrough_endpoints.py @@ -761,6 +761,7 @@ async def handle_bedrock_passthrough_router_model( proxy_logging_obj=proxy_logging_obj, ) + async def handle_bedrock_count_tokens( endpoint: str, request: Request, diff --git a/tests/test_litellm/enterprise/test_responses_background_cost.py b/tests/test_litellm/integrations/test_responses_background_cost.py similarity index 95% rename from tests/test_litellm/enterprise/test_responses_background_cost.py rename to tests/test_litellm/integrations/test_responses_background_cost.py index df694e7adc4..6f1e7e96103 100644 --- a/tests/test_litellm/enterprise/test_responses_background_cost.py +++ b/tests/test_litellm/integrations/test_responses_background_cost.py @@ -2,28 +2,14 @@ Integration tests for responses API background cost tracking """ +import asyncio import os -import sys from datetime import datetime from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest -sys.path.insert(0, os.path.abspath("../../..")) - -# Import litellm first to ensure it's in sys.modules before enterprise imports -import litellm # noqa: E402 - -from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse # noqa: E402 - -# Now import enterprise modules -try: - from litellm_enterprise.proxy.common_utils.check_responses_cost import ( # noqa: E402 - CheckResponsesCost, - ) -except ImportError as e: - # Skip all tests in this module if enterprise module is not available - pytest.skip(f"Enterprise module not available: {e}", allow_module_level=True) +from litellm.types.llms.openai import ResponseAPIUsage, ResponsesAPIResponse class TestResponsesBackgroundCostTracking: @@ -298,6 +284,10 @@ class TestCheckResponsesCost: self, mock_proxy_logging_obj, mock_prisma_client, mock_llm_router ): """Test CheckResponsesCost initialization""" + from litellm_enterprise.proxy.common_utils.check_responses_cost import ( + CheckResponsesCost, + ) + checker = CheckResponsesCost( proxy_logging_obj=mock_proxy_logging_obj, prisma_client=mock_prisma_client, @@ -313,6 +303,10 @@ class TestCheckResponsesCost: self, mock_proxy_logging_obj, mock_prisma_client, mock_llm_router ): """Test polling when there are no jobs""" + from litellm_enterprise.proxy.common_utils.check_responses_cost import ( + CheckResponsesCost, + ) + # Mock find_many to return empty list mock_prisma_client.db.litellm_managedobjecttable.find_many = AsyncMock( return_value=[] @@ -340,6 +334,10 @@ class TestCheckResponsesCost: self, mock_proxy_logging_obj, mock_prisma_client, mock_llm_router ): """Test polling with a completed job""" + from litellm_enterprise.proxy.common_utils.check_responses_cost import ( + CheckResponsesCost, + ) + # Create a mock job mock_job = MagicMock() mock_job.id = "job-123" @@ -393,6 +391,10 @@ class TestCheckResponsesCost: self, mock_proxy_logging_obj, mock_prisma_client, mock_llm_router ): """Test polling with a failed job""" + from litellm_enterprise.proxy.common_utils.check_responses_cost import ( + CheckResponsesCost, + ) + # Create a mock job mock_job = MagicMock() mock_job.id = "job-456" @@ -433,6 +435,10 @@ class TestCheckResponsesCost: self, mock_proxy_logging_obj, mock_prisma_client, mock_llm_router ): """Test polling with a job still in progress""" + from litellm_enterprise.proxy.common_utils.check_responses_cost import ( + CheckResponsesCost, + ) + # Create a mock job mock_job = MagicMock() mock_job.id = "job-789" @@ -473,6 +479,10 @@ class TestCheckResponsesCost: self, mock_proxy_logging_obj, mock_prisma_client, mock_llm_router ): """Test that errors when querying responses are handled gracefully""" + from litellm_enterprise.proxy.common_utils.check_responses_cost import ( + CheckResponsesCost, + ) + # Create a mock job mock_job = MagicMock() mock_job.id = "job-error" diff --git a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py index 5cb2c3cd776..66d62aae1ec 100644 --- a/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py +++ b/tests/test_litellm/llms/anthropic/experimental_pass_through/messages/test_anthropic_experimental_pass_through_messages_handler.py @@ -101,69 +101,55 @@ async def test_bedrock_converse_budget_tokens_preserved(): The bug was that the messages -> completion adapter was converting thinking to reasoning_effort and losing the original budget_tokens value, causing it to use the default (128) instead. """ - import os - client = AsyncHTTPHandler() - # Mock at httpx level for better CI compatibility - with patch("httpx.AsyncClient.post") as mock_httpx_post: - with patch.object(client, "post") as mock_post: - mock_response = AsyncMock() - mock_response.status_code = 200 - mock_response.headers = {} - mock_response.text = "mock response" - mock_response.json.return_value = { - "output": { - "message": { - "role": "assistant", - "content": [{"text": "4"}] - } - }, - "stopReason": "end_turn", - "usage": { - "inputTokens": 10, - "outputTokens": 5, - "totalTokens": 15 + with patch.object(client, "post") as mock_post: + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.headers = {} + mock_response.text = "mock response" + mock_response.json.return_value = { + "output": { + "message": { + "role": "assistant", + "content": [{"text": "4"}] } + }, + "stopReason": "end_turn", + "usage": { + "inputTokens": 10, + "outputTokens": 5, + "totalTokens": 15 } - mock_post.return_value = mock_response - mock_httpx_post.return_value = mock_response - - try: - await messages.acreate( - client=client, - max_tokens=1024, - messages=[{"role": "user", "content": "What is 2+2?"}], - model="bedrock/converse/us.anthropic.claude-sonnet-4-20250514-v1:0", - thinking={ - "budget_tokens": 1024, - "type": "enabled" - }, - ) - except Exception: - pass # Expected due to mock response format - - # Check which mock was called (client.post or httpx.AsyncClient.post) - if mock_post.call_count == 0 and mock_httpx_post.call_count == 0: - # Skip test if neither mock was called (CI environment issue) - if os.getenv("CI") == "true": - pytest.skip("Mock not intercepted in CI environment") - else: - pytest.fail("Expected mock to be called but it wasn't") - - # Use whichever mock was actually called - active_mock = mock_post if mock_post.call_count > 0 else mock_httpx_post - - call_kwargs = active_mock.call_args.kwargs - json_data = call_kwargs.get("json") or json.loads(call_kwargs.get("data", "{}")) - print("Request json: ", json.dumps(json_data, indent=4, default=str)) - - additional_fields = json_data.get("additionalModelRequestFields", {}) - thinking_config = additional_fields.get("thinking", {}) - - assert "thinking" in additional_fields, "thinking parameter should be in additionalModelRequestFields" - assert thinking_config.get("type") == "enabled", "thinking.type should be 'enabled'" - assert thinking_config.get("budget_tokens") == 1024, f"thinking.budget_tokens should be 1024, but got {thinking_config.get('budget_tokens')}" + } + mock_post.return_value = mock_response + + try: + await messages.acreate( + client=client, + max_tokens=1024, + messages=[{"role": "user", "content": "What is 2+2?"}], + model="bedrock/converse/us.anthropic.claude-sonnet-4-20250514-v1:0", + thinking={ + "budget_tokens": 1024, + "type": "enabled" + }, + ) + except Exception: + pass # Expected due to mock response format + + mock_post.assert_called_once() + + call_kwargs = mock_post.call_args.kwargs + json_data = call_kwargs.get("json") or json.loads(call_kwargs.get("data", "{}")) + print("Request json: ", json.dumps(json_data, indent=4, default=str)) + + additional_fields = json_data.get("additionalModelRequestFields", {}) + thinking_config = additional_fields.get("thinking", {}) + + assert "thinking" in additional_fields, "thinking parameter should be in additionalModelRequestFields" + assert thinking_config.get("type") == "enabled", "thinking.type should be 'enabled'" + assert thinking_config.get("budget_tokens") == 1024, f"thinking.budget_tokens should be 1024, but got {thinking_config.get('budget_tokens')}" def test_openai_model_with_thinking_converts_to_reasoning_effort(): diff --git a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py index 763d6964d61..692866f8552 100644 --- a/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py +++ b/tests/test_litellm/llms/bedrock/chat/test_converse_transformation.py @@ -2610,6 +2610,99 @@ def test_request_metadata_not_provided(): assert "requestMetadata" not in request_data +def test_empty_assistant_message_handling(): + """ + Test that empty assistant messages are handled correctly by replacing + empty or whitespace-only content with a placeholder to prevent AWS Bedrock + Converse API 400 Bad Request errors. + """ + from litellm.litellm_core_utils.prompt_templates.factory import ( + _bedrock_converse_messages_pt, + ) + + # Test case 1: Empty string content - test with modify_params=True to prevent merging + messages = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": ""}, # Empty content + {"role": "user", "content": "How are you?"} + ] + + # Enable modify_params to prevent consecutive user message merging + original_modify_params = litellm.modify_params + litellm.modify_params = True + + try: + result = _bedrock_converse_messages_pt( + messages=messages, + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + llm_provider="bedrock_converse" + ) + + # Should have 3 messages: user, assistant (with placeholder), user + assert len(result) == 3 + assert result[0]["role"] == "user" + assert result[1]["role"] == "assistant" + assert result[2]["role"] == "user" + + # Assistant message should have placeholder text instead of empty content + assert len(result[1]["content"]) == 1 + assert result[1]["content"][0]["text"] == "Please continue." + + # Test case 2: Whitespace-only content + messages = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": " "}, # Whitespace-only content + {"role": "user", "content": "How are you?"} + ] + + result = _bedrock_converse_messages_pt( + messages=messages, + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + llm_provider="bedrock_converse" + ) + + # Assistant message should have placeholder text instead of whitespace + assert len(result[1]["content"]) == 1 + assert result[1]["content"][0]["text"] == "Please continue." + + # Test case 3: Empty list content + messages = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": [{"type": "text", "text": ""}]}, # Empty text in list + {"role": "user", "content": "How are you?"} + ] + + result = _bedrock_converse_messages_pt( + messages=messages, + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + llm_provider="bedrock_converse" + ) + + # Assistant message should have placeholder text instead of empty text + assert len(result[1]["content"]) == 1 + assert result[1]["content"][0]["text"] == "Please continue." + + # Test case 4: Normal content should not be affected + messages = [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "I'm doing well, thank you!"}, # Normal content + {"role": "user", "content": "How are you?"} + ] + + result = _bedrock_converse_messages_pt( + messages=messages, + model="anthropic.claude-3-5-sonnet-20240620-v1:0", + llm_provider="bedrock_converse" + ) + + # Assistant message should keep original content + assert len(result[1]["content"]) == 1 + assert result[1]["content"][0]["text"] == "I'm doing well, thank you!" + + finally: + # Restore original modify_params setting + litellm.modify_params = original_modify_params + def test_is_nova_lite_2_model(): """Test the _is_nova_lite_2_model() method for detecting Nova 2 models.""" diff --git a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_integration.py b/tests/test_litellm/llms/bedrock/files/test_bedrock_files_integration.py index 983ad73980d..37a0daa1d50 100644 --- a/tests/test_litellm/llms/bedrock/files/test_bedrock_files_integration.py +++ b/tests/test_litellm/llms/bedrock/files/test_bedrock_files_integration.py @@ -21,51 +21,43 @@ class TestBedrockFilesIntegration: file_id = "s3://test-bucket/test-file.jsonl" expected_content = b'{"recordId": "request-1", "modelInput": {}, "modelOutput": {}}' - # Mock AWS credentials - with patch.dict( - "os.environ", - { - "AWS_ACCESS_KEY_ID": "test-access-key", - "AWS_SECRET_ACCESS_KEY": "test-secret-key", - }, - ): - # Mock the bedrock_files_instance.file_content method - with patch( - "litellm.files.main.bedrock_files_instance.file_content", - new_callable=AsyncMock, - ) as mock_file_content: - # Create a mock HttpxBinaryResponseContent response - import httpx + # Mock the bedrock_files_instance.file_content method + with patch( + "litellm.files.main.bedrock_files_instance.file_content", + new_callable=AsyncMock, + ) as mock_file_content: + # Create a mock HttpxBinaryResponseContent response + import httpx - mock_response = httpx.Response( - status_code=200, - content=expected_content, - headers={"content-type": "application/octet-stream"}, - request=httpx.Request( - method="GET", url="s3://test-bucket/test-file.jsonl" - ), - ) - mock_file_content.return_value = HttpxBinaryResponseContent( - response=mock_response - ) + mock_response = httpx.Response( + status_code=200, + content=expected_content, + headers={"content-type": "application/octet-stream"}, + request=httpx.Request( + method="GET", url="s3://test-bucket/test-file.jsonl" + ), + ) + mock_file_content.return_value = HttpxBinaryResponseContent( + response=mock_response + ) - # Call litellm.afile_content - result = await litellm.afile_content( - file_id=file_id, - custom_llm_provider="bedrock", - aws_region_name="us-west-2", - ) + # Call litellm.afile_content + result = await litellm.afile_content( + file_id=file_id, + custom_llm_provider="bedrock", + aws_region_name="us-west-2", + ) - # Verify the result - assert isinstance(result, HttpxBinaryResponseContent) - assert result.response.content == expected_content - assert result.response.status_code == 200 + # Verify the result + assert isinstance(result, HttpxBinaryResponseContent) + assert result.response.content == expected_content + assert result.response.status_code == 200 - # Verify the mock was called with correct parameters - mock_file_content.assert_called_once() - call_kwargs = mock_file_content.call_args.kwargs - assert call_kwargs["_is_async"] is True - assert call_kwargs["file_content_request"]["file_id"] == file_id + # Verify the mock was called with correct parameters + mock_file_content.assert_called_once() + call_kwargs = mock_file_content.call_args.kwargs + assert call_kwargs["_is_async"] is True + assert call_kwargs["file_content_request"]["file_id"] == file_id @pytest.mark.asyncio async def test_litellm_afile_content_bedrock_provider_with_unified_file_id(self): @@ -80,47 +72,39 @@ class TestBedrockFilesIntegration: expected_content = b'{"recordId": "request-1", "modelInput": {}, "modelOutput": {}}' - # Mock AWS credentials - with patch.dict( - "os.environ", - { - "AWS_ACCESS_KEY_ID": "test-access-key", - "AWS_SECRET_ACCESS_KEY": "test-secret-key", - }, - ): - # Mock the bedrock_files_instance.file_content method - with patch( - "litellm.files.main.bedrock_files_instance.file_content", - new_callable=AsyncMock, - ) as mock_file_content: - # Create a mock HttpxBinaryResponseContent response - import httpx + # Mock the bedrock_files_instance.file_content method + with patch( + "litellm.files.main.bedrock_files_instance.file_content", + new_callable=AsyncMock, + ) as mock_file_content: + # Create a mock HttpxBinaryResponseContent response + import httpx - mock_response = httpx.Response( - status_code=200, - content=expected_content, - headers={"content-type": "application/octet-stream"}, - request=httpx.Request(method="GET", url=s3_uri), - ) - mock_file_content.return_value = HttpxBinaryResponseContent( - response=mock_response - ) + mock_response = httpx.Response( + status_code=200, + content=expected_content, + headers={"content-type": "application/octet-stream"}, + request=httpx.Request(method="GET", url=s3_uri), + ) + mock_file_content.return_value = HttpxBinaryResponseContent( + response=mock_response + ) - # Call litellm.afile_content with unified file ID - result = await litellm.afile_content( - file_id=encoded_file_id, - custom_llm_provider="bedrock", - aws_region_name="us-west-2", - ) + # Call litellm.afile_content with unified file ID + result = await litellm.afile_content( + file_id=encoded_file_id, + custom_llm_provider="bedrock", + aws_region_name="us-west-2", + ) - # Verify the result - assert isinstance(result, HttpxBinaryResponseContent) - assert result.response.content == expected_content - assert result.response.status_code == 200 + # Verify the result + assert isinstance(result, HttpxBinaryResponseContent) + assert result.response.content == expected_content + assert result.response.status_code == 200 - # Verify the mock was called - the handler should extract S3 URI from unified file ID - mock_file_content.assert_called_once() - call_kwargs = mock_file_content.call_args.kwargs - assert call_kwargs["_is_async"] is True - # The handler extracts S3 URI from the unified file ID - assert call_kwargs["file_content_request"]["file_id"] == encoded_file_id + # Verify the mock was called - the handler should extract S3 URI from unified file ID + mock_file_content.assert_called_once() + call_kwargs = mock_file_content.call_args.kwargs + assert call_kwargs["_is_async"] is True + # The handler extracts S3 URI from the unified file ID + assert call_kwargs["file_content_request"]["file_id"] == encoded_file_id diff --git a/tests/test_litellm/llms/huggingface/embedding/test_handler.py b/tests/test_litellm/llms/huggingface/embedding/test_handler.py index b768bee4034..f6bc983df01 100644 --- a/tests/test_litellm/llms/huggingface/embedding/test_handler.py +++ b/tests/test_litellm/llms/huggingface/embedding/test_handler.py @@ -41,12 +41,8 @@ def mock_embedding_async_http_handler(): class TestHuggingFaceEmbedding: @pytest.fixture(autouse=True) def setup(self, mock_embedding_http_handler, mock_embedding_async_http_handler): - # Mock both sync and async versions of get_hf_task functions self.mock_get_task_patcher = patch("litellm.llms.huggingface.embedding.handler.get_hf_task_embedding_for_model") - self.mock_get_task_async_patcher = patch("litellm.llms.huggingface.embedding.handler.async_get_hf_task_embedding_for_model", new_callable=AsyncMock) - self.mock_get_task = self.mock_get_task_patcher.start() - self.mock_get_task_async = self.mock_get_task_async_patcher.start() def mock_get_task_side_effect(model, task_type, api_base): if task_type is not None: @@ -54,7 +50,6 @@ class TestHuggingFaceEmbedding: return "sentence-similarity" self.mock_get_task.side_effect = mock_get_task_side_effect - self.mock_get_task_async.side_effect = mock_get_task_side_effect self.model = "huggingface/BAAI/bge-m3" self.mock_http = mock_embedding_http_handler @@ -64,7 +59,6 @@ class TestHuggingFaceEmbedding: yield self.mock_get_task_patcher.stop() - self.mock_get_task_async_patcher.stop() def test_input_type_preserved_in_optional_params(self): input_text = ["hello world"] @@ -87,3 +81,31 @@ class TestHuggingFaceEmbedding: # Should NOT have sentence-similarity format assert "source_sentence" not in str(request_data) assert "sentences" not in str(request_data) + + def test_embedding_with_sentence_similarity_task(self): + """Test embedding when task type is sentence-similarity (requires 2+ sentences)""" + + similarity_response = { + "similarities": [[0, 0.9], [1, 0.8]] + } + + self.mock_http.return_value.json.return_value = similarity_response + + # Test with 2+ sentences (required for sentence-similarity) + input_text = ["This is the source sentence", "This is sentence one", "This is sentence two"] + + response = litellm.embedding( + model=self.model, + input=input_text, + # Use the model's natural task type (sentence-similarity) + ) + + self.mock_http.assert_called_once() + post_call_args = self.mock_http.call_args + request_data = json.loads(post_call_args[1]["data"]) + + assert "inputs" in request_data + assert "source_sentence" in request_data["inputs"] + assert "sentences" in request_data["inputs"] + assert request_data["inputs"]["source_sentence"] == input_text[0] + assert request_data["inputs"]["sentences"] == input_text[1:] \ No newline at end of file diff --git a/tests/test_litellm/llms/vertex_ai/files/test_vertex_ai_files_integration.py b/tests/test_litellm/llms/vertex_ai/files/test_vertex_ai_files_integration.py index 50ad3920cb1..723594dc390 100644 --- a/tests/test_litellm/llms/vertex_ai/files/test_vertex_ai_files_integration.py +++ b/tests/test_litellm/llms/vertex_ai/files/test_vertex_ai_files_integration.py @@ -12,7 +12,53 @@ from litellm.types.llms.openai import HttpxBinaryResponseContent class TestVertexAIFilesIntegration: """Test integration of Vertex AI files with main litellm API""" + @pytest.mark.asyncio + async def test_litellm_afile_content_vertex_ai_provider(self): + """Test litellm.afile_content with vertex_ai provider""" + file_id = "gs%3A%2F%2Ftest-bucket%2Ftest-file.txt" + expected_content = b"test file content" + # Mock the vertex_ai_files_instance.file_content method + with patch( + "litellm.files.main.vertex_ai_files_instance.file_content", + new_callable=AsyncMock, + ) as mock_file_content: + # Create a mock HttpxBinaryResponseContent response + import httpx + + mock_response = httpx.Response( + status_code=200, + content=expected_content, + headers={"content-type": "application/octet-stream"}, + request=httpx.Request( + method="GET", url="gs://test-bucket/test-file.txt" + ), + ) + mock_file_content.return_value = HttpxBinaryResponseContent( + response=mock_response + ) + + # Call litellm.afile_content + result = await litellm.afile_content( + file_id=file_id, + custom_llm_provider="vertex_ai", + vertex_project="test-project", + vertex_location="us-central1", + vertex_credentials=None, + ) + + # Verify the result + assert isinstance(result, HttpxBinaryResponseContent) + assert result.response.content == expected_content + assert result.response.status_code == 200 + + # Verify the mock was called with correct parameters + mock_file_content.assert_called_once() + call_kwargs = mock_file_content.call_args.kwargs + assert call_kwargs["_is_async"] is True + assert call_kwargs["file_content_request"]["file_id"] == file_id + assert call_kwargs["vertex_project"] == "test-project" + assert call_kwargs["vertex_location"] == "us-central1" def test_litellm_file_content_vertex_ai_provider(self): """Test litellm.file_content with vertex_ai provider (sync)""" diff --git a/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_to_mcp_generator.py b/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_to_mcp_generator.py index 488f26cdca6..573e095606c 100644 --- a/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_to_mcp_generator.py +++ b/tests/test_litellm/proxy/_experimental/mcp_server/test_openapi_to_mcp_generator.py @@ -75,6 +75,40 @@ class TestCreateToolFunction: call_args[0][0] ) + @pytest.mark.asyncio + async def test_leading_digit_parameter(self): + """Test function with parameter starting with digit (e.g., 2fa-code).""" + operation = { + "parameters": [ + { + "name": "2fa-code", + "in": "query", + "required": False, + "schema": {"type": "string"}, + } + ] + } + + func = create_tool_function( + path="/verify", + method="post", + operation=operation, + base_url="https://api.example.com", + ) + + assert callable(func) + + with patch(GET_ASYNC_CLIENT_TARGET) as mock_client: + async_client = _create_mock_client("post", "verified") + mock_client.return_value = async_client + + result = await func(**{"2fa-code": "123456"}) + assert result == "verified" + + # Verify query parameter was included + call_args = async_client.post.call_args + assert call_args[1]["params"]["2fa-code"] == "123456" + @pytest.mark.asyncio async def test_dot_in_parameter_name(self): """Test function with dot in parameter name (e.g., user.name).""" diff --git a/tests/test_litellm/proxy/guardrails/test_pillar_guardrails.py b/tests/test_litellm/proxy/guardrails/test_pillar_guardrails.py index 681caf9716d..0607b0de981 100644 --- a/tests/test_litellm/proxy/guardrails/test_pillar_guardrails.py +++ b/tests/test_litellm/proxy/guardrails/test_pillar_guardrails.py @@ -8,7 +8,7 @@ and following LiteLLM testing patterns and best practices. # Standard library imports import os import sys -from typing import Dict, Any +from typing import Dict from unittest.mock import Mock, patch # Add parent directory to path for imports @@ -43,6 +43,33 @@ from litellm.proxy.guardrails.init_guardrails import init_guardrails_v2 # ============================================================================ +@pytest.fixture(scope="function", autouse=True) +def setup_and_teardown(): + """ + Standard LiteLLM fixture that reloads litellm before every function + to speed up testing by removing callbacks being chained. + """ + import importlib + import asyncio + + # Reload litellm to ensure clean state + importlib.reload(litellm) + + # Set up async loop + loop = asyncio.get_event_loop_policy().new_event_loop() + asyncio.set_event_loop(loop) + + # Set up litellm state + litellm.set_verbose = True + litellm.guardrail_name_config_map = {} + + yield + + # Teardown + loop.close() + asyncio.set_event_loop(None) + + @pytest.fixture def env_setup(monkeypatch): """Fixture to set up environment variables for testing.""" diff --git a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py index cc7ffeb0b67..133fc07d340 100644 --- a/tests/test_litellm/proxy/test_litellm_pre_call_utils.py +++ b/tests/test_litellm/proxy/test_litellm_pre_call_utils.py @@ -1393,23 +1393,21 @@ async def test_embedding_header_forwarding_with_model_group(): version="test-version", ) - # Verify that headers were added to the request metadata - assert "metadata" in updated_data, "Metadata should be added to embedding request" - assert "headers" in updated_data["metadata"], "Headers should be added to embedding request metadata" + # Verify that headers were added to the request data + assert "headers" in updated_data, "Headers should be added to embedding request" # Verify that only x- prefixed headers (except x-stainless) were forwarded - forwarded_headers = updated_data["metadata"]["headers"] + forwarded_headers = updated_data["headers"] assert "X-Custom-Header" in forwarded_headers, "X-Custom-Header should be forwarded" assert forwarded_headers["X-Custom-Header"] == "custom-value" assert "X-Request-ID" in forwarded_headers, "X-Request-ID should be forwarded" assert forwarded_headers["X-Request-ID"] == "test-request-123" - # Verify that Authorization header is present in metadata (not filtered out at this level) - # Note: The metadata headers contain all original headers for logging/tracking purposes - assert "Authorization" in forwarded_headers, "Authorization header should be in metadata headers" + # Verify that authorization header was NOT forwarded (sensitive header) + assert "Authorization" not in forwarded_headers, "Authorization header should not be forwarded" - # Verify that Content-Type is present (it's included in metadata headers) - assert "Content-Type" in forwarded_headers, "Content-Type should be in metadata headers" + # Verify that Content-Type was NOT forwarded (doesn't start with x-) + assert "Content-Type" not in forwarded_headers, "Content-Type should not be forwarded" # Verify original data fields are preserved assert updated_data["model"] == "local-openai/text-embedding-3-small" diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index d14ac5cf335..751a9033871 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -55,7 +55,7 @@ example_embedding_result = { def mock_patch_aembedding(): return mock.patch( - "litellm.aembedding", + "litellm.proxy.proxy_server.llm_router.aembedding", return_value=example_embedding_result, ) @@ -668,6 +668,43 @@ def test_team_info_masking(): assert "public-test-key" not in str(exc_info.value) +@mock_patch_aembedding() +def test_embedding_input_array_of_tokens(mock_aembedding, client_no_auth): + """ + Test to bypass decoding input as array of tokens for selected providers + + Ref: https://github.com/BerriAI/litellm/issues/10113 + """ + try: + test_data = { + "model": "vllm_embed_model", + "input": [[2046, 13269, 158208]], + } + + response = client_no_auth.post("/v1/embeddings", json=test_data) + + # DEPRECATED - mock_aembedding.assert_called_once_with is too strict, and will fail when new kwargs are added to embeddings + # mock_aembedding.assert_called_once_with( + # model="vllm_embed_model", + # input=[[2046, 13269, 158208]], + # metadata=mock.ANY, + # proxy_server_request=mock.ANY, + # secret_fields=mock.ANY, + # ) + # Assert that aembedding was called, and that input was not modified + mock_aembedding.assert_called_once() + call_args, call_kwargs = mock_aembedding.call_args + assert call_kwargs["model"] == "vllm_embed_model" + assert call_kwargs["input"] == [[2046, 13269, 158208]] + + assert response.status_code == 200 + result = response.json() + print(len(result["data"][0]["embedding"])) + assert len(result["data"][0]["embedding"]) > 10 # this usually has len==1536 so + except Exception as e: + pytest.fail(f"LiteLLM Proxy test failed. Exception - {str(e)}") + + @pytest.mark.asyncio async def test_get_all_team_models(): """ diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 12fc65d8b06..7201b961588 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -1231,30 +1231,18 @@ async def test_acompletion_streaming_disable_fallbacks_midstream(): return self async def __anext__(self): - if self.index == self.error_after_index: - raise self.error if self.index >= len(self.items): raise StopAsyncIteration + if self.index == self.error_after_index: + raise self.error item = self.items[self.index] self.index += 1 self.chunks.append(item) return item - # Create properly structured mock chunks using ModelResponse - from litellm.types.utils import Delta, ModelResponse, StreamingChoices - - mock_chunk = ModelResponse( - id="chatcmpl-123", - choices=[ - StreamingChoices( - index=0, delta=Delta(content="Hello", role="assistant"), finish_reason=None - ) - ], - created=1234567890, - model="gpt-4", - object="chat.completion.chunk", - ) - mock_chunks = [mock_chunk] + mock_chunks = [ + MagicMock(choices=[MagicMock(delta=MagicMock(content="Hello"))]), + ] mock_error_response = AsyncIteratorWithError( mock_chunks, 1, error_with_original From 7aba0f738ab39530f24a90aa7f5c8b2ebf95b3dc Mon Sep 17 00:00:00 2001 From: YutaSaito <36355491+uc4w6c@users.noreply.github.com> Date: Sat, 17 Jan 2026 06:31:34 +0900 Subject: [PATCH 11/14] Revert "Litellm staging 01 15 2026" --- .circleci/config.yml | 46 ++--- litellm/proxy/common_request_processing.py | 4 +- litellm/proxy/litellm_pre_call_utils.py | 23 +-- litellm/proxy/prisma_migration.py | 2 - litellm/proxy/proxy_cli.py | 8 +- litellm/proxy/video_endpoints/endpoints.py | 12 +- litellm/router.py | 35 +--- model_prices_and_context_window.json | 42 ----- poetry.lock | 38 ++-- pyproject.toml | 2 +- requirements.txt | 4 +- tests/code_coverage_tests/license_cache.json | 4 +- tests/test_litellm/proxy/test_proxy_cli.py | 69 ------- tests/test_litellm/test_router.py | 187 ------------------- 14 files changed, 60 insertions(+), 416 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 2f21cc4481f..133a7184f9b 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -144,8 +144,8 @@ jobs: pip install "google-generativeai==0.3.2" pip install "google-cloud-aiplatform==1.43.0" pip install pyarrow - pip install "boto3==1.40.61" - pip install "aioboto3==15.5.0" + pip install "boto3==1.36.0" + pip install "aioboto3==13.4.0" pip install langchain pip install lunary==0.2.5 pip install "azure-identity==1.16.1" @@ -260,8 +260,8 @@ jobs: pip install "google-generativeai==0.3.2" pip install "google-cloud-aiplatform==1.43.0" pip install pyarrow - pip install "boto3==1.40.61" - pip install "aioboto3==15.5.0" + pip install "boto3==1.36.0" + pip install "aioboto3==13.4.0" pip install langchain pip install lunary==0.2.5 pip install "azure-identity==1.16.1" @@ -367,8 +367,8 @@ jobs: pip install "google-generativeai==0.3.2" pip install "google-cloud-aiplatform==1.43.0" pip install pyarrow - pip install "boto3==1.40.61" - pip install "aioboto3==15.5.0" + pip install "boto3==1.36.0" + pip install "aioboto3==13.4.0" pip install langchain pip install lunary==0.2.5 pip install "azure-identity==1.16.1" @@ -637,8 +637,8 @@ jobs: pip install "google-generativeai==0.3.2" pip install "google-cloud-aiplatform==1.43.0" pip install pyarrow - pip install "boto3==1.40.61" - pip install "aioboto3==15.5.0" + pip install "boto3==1.36.0" + pip install "aioboto3==13.4.0" pip install langchain pip install "langfuse>=2.0.0" pip install "logfire==0.29.0" @@ -759,8 +759,8 @@ jobs: pip install "google-cloud-aiplatform==1.43.0" pip install "google-genai==1.22.0" pip install pyarrow - pip install "boto3==1.40.61" - pip install "aioboto3==15.5.0" + pip install "boto3==1.36.0" + pip install "aioboto3==13.4.0" pip install langchain pip install lunary==0.2.5 pip install "azure-identity==1.16.1" @@ -865,8 +865,8 @@ jobs: pip install "google-cloud-aiplatform==1.43.0" pip install "google-genai==1.22.0" pip install pyarrow - pip install "boto3==1.40.61" - pip install "aioboto3==15.5.0" + pip install "boto3==1.36.0" + pip install "aioboto3==13.4.0" pip install langchain pip install lunary==0.2.5 pip install "azure-identity==1.16.1" @@ -972,8 +972,8 @@ jobs: pip install "google-cloud-aiplatform==1.43.0" pip install "google-genai==1.22.0" pip install pyarrow - pip install "boto3==1.40.61" - pip install "aioboto3==15.5.0" + pip install "boto3==1.36.0" + pip install "aioboto3==13.4.0" pip install langchain pip install lunary==0.2.5 pip install "azure-identity==1.16.1" @@ -1198,7 +1198,7 @@ jobs: pip install "pytest-asyncio==0.21.1" pip install "respx==0.22.0" pip install "pydantic==2.10.2" - pip install "boto3==1.40.61" + pip install "boto3==1.36.0" # Run pytest and generate JUnit XML report - run: name: Run tests @@ -1879,7 +1879,7 @@ jobs: pip install aiohttp pip install openai pip install click - pip install "boto3==1.40.61" + pip install "boto3==1.36.0" pip install jinja2 pip install "tokenizers==0.20.0" pip install "uvloop==0.21.0" @@ -2176,8 +2176,8 @@ jobs: pip install "google-generativeai==0.3.2" pip install "google-cloud-aiplatform==1.43.0" pip install pyarrow - pip install "boto3==1.40.61" - pip install "aioboto3==15.5.0" + pip install "boto3==1.36.0" + pip install "aioboto3==13.4.0" pip install langchain pip install "langfuse>=2.0.0" pip install "logfire==0.29.0" @@ -2316,8 +2316,8 @@ jobs: pip install "google-generativeai==0.3.2" pip install "google-cloud-aiplatform==1.43.0" pip install pyarrow - pip install "boto3==1.40.61" - pip install "aioboto3==15.5.0" + pip install "boto3==1.36.0" + pip install "aioboto3==13.4.0" pip install langchain pip install "langchain_mcp_adapters==0.0.5" pip install "langfuse>=2.0.0" @@ -2462,8 +2462,8 @@ jobs: pip install "google-generativeai==0.3.2" pip install "google-cloud-aiplatform==1.43.0" pip install pyarrow - pip install "boto3==1.40.61" - pip install "aioboto3==15.5.0" + pip install "boto3==1.36.0" + pip install "aioboto3==13.4.0" pip install langchain pip install "langfuse>=2.0.0" pip install "logfire==0.29.0" @@ -3118,7 +3118,7 @@ jobs: pip install "pytest==7.3.1" pip install "pytest-mock==3.12.0" pip install "pytest-asyncio==0.21.1" - pip install "boto3==1.40.61" + pip install "boto3==1.36.0" pip install "mypy==1.18.2" pip install pyarrow pip install numpydoc diff --git a/litellm/proxy/common_request_processing.py b/litellm/proxy/common_request_processing.py index 5b669bd048f..52f7f227b52 100644 --- a/litellm/proxy/common_request_processing.py +++ b/litellm/proxy/common_request_processing.py @@ -49,9 +49,7 @@ if TYPE_CHECKING: ProxyConfig = _ProxyConfig else: ProxyConfig = Any -from litellm.proxy.litellm_pre_call_utils import ( - add_litellm_data_to_request, -) +from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request from litellm.types.utils import ModelResponse, ModelResponseStream, Usage diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 03bd2cde166..1fbd8ee72c2 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -846,9 +846,7 @@ async def add_litellm_data_to_request( # noqa: PLR0915 # Add headers to metadata for guardrails to access (fixes #17477) # Guardrails use metadata["headers"] to access request headers (e.g., User-Agent) - if _metadata_variable_name in data and isinstance( - data[_metadata_variable_name], dict - ): + if _metadata_variable_name in data and isinstance(data[_metadata_variable_name], dict): data[_metadata_variable_name]["headers"] = _headers # check for forwardable headers @@ -1316,9 +1314,6 @@ def move_guardrails_to_metadata( - If guardrails set on API Key metadata then sets guardrails on request metadata - If guardrails not set on API key, then checks request metadata - - Note: We copy (not pop) guardrails from data to metadata to ensure deployment-level - guardrails merged by the router remain in kwargs for async_pre_call_deployment_hook. """ # Check key-level guardrails _add_guardrails_from_key_or_team_metadata( @@ -1331,25 +1326,15 @@ def move_guardrails_to_metadata( ######################################################################################### # User's might send "guardrails" in the request body, we need to add them to the request metadata. # Since downstream logic requires "guardrails" to be in the request metadata - # - # IMPORTANT: We copy instead of pop to preserve guardrails in kwargs for - # async_pre_call_deployment_hook (custom_guardrail.py:290) which checks kwargs.get("guardrails"). - # This is the event-based approach for deployment-level guardrails. ######################################################################################### if "guardrails" in data: - request_body_guardrails = data.get("guardrails") - if request_body_guardrails is None: - return + request_body_guardrails = data.pop("guardrails") if "guardrails" in data[_metadata_variable_name] and isinstance( data[_metadata_variable_name]["guardrails"], list ): - # Merge unique guardrails - existing = data[_metadata_variable_name]["guardrails"] - for g in request_body_guardrails: - if g not in existing: - existing.append(g) + data[_metadata_variable_name]["guardrails"].extend(request_body_guardrails) else: - data[_metadata_variable_name]["guardrails"] = list(request_body_guardrails) + data[_metadata_variable_name]["guardrails"] = request_body_guardrails ######################################################################################### if "guardrail_config" in data: diff --git a/litellm/proxy/prisma_migration.py b/litellm/proxy/prisma_migration.py index 62909b8b2c7..251d1e56287 100644 --- a/litellm/proxy/prisma_migration.py +++ b/litellm/proxy/prisma_migration.py @@ -26,5 +26,3 @@ if exit_code != 0: verbose_proxy_logger.error( f"'prisma generate' stderr: {result.stderr}" ) # Log stderr - -sys.exit(exit_code) \ No newline at end of file diff --git a/litellm/proxy/proxy_cli.py b/litellm/proxy/proxy_cli.py index ddc79a2865d..2059246674b 100644 --- a/litellm/proxy/proxy_cli.py +++ b/litellm/proxy/proxy_cli.py @@ -187,7 +187,6 @@ class ProxyInitializationHelpers: ssl_certfile_path: str, ssl_keyfile_path: str, max_requests_before_restart: Optional[int] = None, - keepalive_timeout: Optional[int] = None, ): """ Run litellm with `gunicorn` @@ -268,10 +267,6 @@ class ProxyInitializationHelpers: "access_log_format": '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s', } - # Optional: set keepalive timeout if specified by user - if keepalive_timeout is not None: - gunicorn_options["keepalive"] = keepalive_timeout - # Optional: recycle workers after N requests to mitigate memory growth if max_requests_before_restart is not None: gunicorn_options["max_requests"] = max_requests_before_restart @@ -494,7 +489,7 @@ class ProxyInitializationHelpers: "--keepalive_timeout", default=None, type=int, - help="Set the keepalive timeout in seconds. For Uvicorn: timeout_keep_alive parameter. For Gunicorn: keepalive parameter. Default: Uvicorn uses ~75s, Gunicorn uses 90s", + help="Set the uvicorn keepalive timeout in seconds (uvicorn timeout_keep_alive parameter)", envvar="KEEPALIVE_TIMEOUT", ) @click.option( @@ -864,7 +859,6 @@ def run_server( # noqa: PLR0915 ssl_certfile_path=ssl_certfile_path, ssl_keyfile_path=ssl_keyfile_path, max_requests_before_restart=max_requests_before_restart, - keepalive_timeout=keepalive_timeout, ) elif run_hypercorn is True: ProxyInitializationHelpers._init_hypercorn_server( diff --git a/litellm/proxy/video_endpoints/endpoints.py b/litellm/proxy/video_endpoints/endpoints.py index a3c4af9ae5d..5e00eb58455 100644 --- a/litellm/proxy/video_endpoints/endpoints.py +++ b/litellm/proxy/video_endpoints/endpoints.py @@ -256,9 +256,7 @@ async def video_status( # Resolve model_name from model_id if available # This allows the router to automatically inject litellm_params from the model config if model_id_from_decoded and llm_router: - resolved_model = llm_router.resolve_model_name_from_model_id( - model_id_from_decoded, custom_llm_provider=provider_from_id - ) + resolved_model = llm_router.resolve_model_name_from_model_id(model_id_from_decoded) if resolved_model: data["model"] = resolved_model @@ -356,9 +354,7 @@ async def video_content( # Resolve model_name from model_id if available # This allows the router to automatically inject litellm_params from the model config if model_id_from_decoded and llm_router: - resolved_model = llm_router.resolve_model_name_from_model_id( - model_id_from_decoded, custom_llm_provider=provider_from_id - ) + resolved_model = llm_router.resolve_model_name_from_model_id(model_id_from_decoded) if resolved_model: data["model"] = resolved_model # Process request using ProxyBaseLLMRequestProcessing @@ -470,9 +466,7 @@ async def video_remix( # Resolve model_name from model_id if available # This allows the router to automatically inject litellm_params from the model config if model_id_from_decoded and llm_router: - resolved_model = llm_router.resolve_model_name_from_model_id( - model_id_from_decoded, custom_llm_provider=provider_from_id - ) + resolved_model = llm_router.resolve_model_name_from_model_id(model_id_from_decoded) if resolved_model: data["model"] = resolved_model diff --git a/litellm/router.py b/litellm/router.py index 45d2fe5a0d4..8a1ac8c07f9 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -6971,7 +6971,7 @@ class Router: return candidate_id in self.model_id_to_deployment_index_map def resolve_model_name_from_model_id( - self, model_id: Optional[str], custom_llm_provider: Optional[str] = None + self, model_id: Optional[str] ) -> Optional[str]: """ Resolve model_name from model_id. @@ -6981,15 +6981,12 @@ class Router: Strategy: 1. First, check if model_id directly matches a model_name or deployment ID - 2. If custom_llm_provider is provided, check with provider prefix - 3. Search through router's model_list to find a match by litellm_params.model - 4. If custom_llm_provider is provided, try to find a wildcard pattern match - 5. Return the model_name if found, None otherwise + 2. If not, search through router's model_list to find a match by litellm_params.model + 3. Return the model_name if found, None otherwise Args: model_id: The model_id extracted from decoded video_id (could be model_name or litellm_params.model value) - custom_llm_provider: The provider name (e.g., "vertex_ai") for wildcard matching Returns: model_name if found, None otherwise. If None, the request will fall through @@ -7002,26 +6999,15 @@ class Router: if model_id in self.model_names or self.has_model_id(model_id): return model_id - # Strategy 2: Check with provider prefix (e.g., "vertex_ai/veo-3.0-generate-preview") - if custom_llm_provider: - full_model_name = f"{custom_llm_provider}/{model_id}" - if full_model_name in self.model_names or self.has_model_id(full_model_name): - return full_model_name - - # Strategy 3: Search through router's model_list to find by litellm_params.model + # Strategy 2: Search through router's model_list to find by litellm_params.model all_models = self.get_model_list(model_name=None) if not all_models: return None - # First pass: exact matches (non-wildcard) for deployment in all_models: litellm_params = deployment.get("litellm_params", {}) actual_model = litellm_params.get("model") - # Skip wildcard patterns in first pass - if actual_model and actual_model.endswith("/*"): - continue - # Match by exact match or by checking if actual_model ends with /model_id or :model_id # e.g., model_id="veo-2.0-generate-001" matches actual_model="vertex_ai/veo-2.0-generate-001" matches = ( @@ -7035,19 +7021,6 @@ class Router: if model_name: return model_name - # Strategy 4: Wildcard patterns using PatternMatchRouter - # For video status/content, we need to match model_id like "veo-3.0-generate-preview" - # to wildcard patterns like "vertex_ai/*" - if custom_llm_provider: - full_model_name = f"{custom_llm_provider}/{model_id}" - pattern_deployments = self.pattern_router.route(full_model_name) - if pattern_deployments: - # Return the first matching wildcard model_name - for pattern_deployment in pattern_deployments: - matched_model_name = pattern_deployment.get("model_name") - if matched_model_name: - return matched_model_name - # No match found return None diff --git a/model_prices_and_context_window.json b/model_prices_and_context_window.json index 4abbddb0d50..470d598a25f 100644 --- a/model_prices_and_context_window.json +++ b/model_prices_and_context_window.json @@ -10201,48 +10201,6 @@ "mode": "completion", "output_cost_per_token": 5e-07 }, - "deepseek-v3-2-251201": { - "input_cost_per_token": 0.0, - "litellm_provider": "volcengine", - "max_input_tokens": 98304, - "max_output_tokens": 32768, - "max_tokens": 32768, - "mode": "chat", - "output_cost_per_token": 0.0, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_tool_choice": true - }, - "glm-4-7-251222": { - "input_cost_per_token": 0.0, - "litellm_provider": "volcengine", - "max_input_tokens": 204800, - "max_output_tokens": 131072, - "max_tokens": 131072, - "mode": "chat", - "output_cost_per_token": 0.0, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_tool_choice": true - }, - "kimi-k2-thinking-251104": { - "input_cost_per_token": 0.0, - "litellm_provider": "volcengine", - "max_input_tokens": 229376, - "max_output_tokens": 32768, - "max_tokens": 32768, - "mode": "chat", - "output_cost_per_token": 0.0, - "supports_assistant_prefill": true, - "supports_function_calling": true, - "supports_prompt_caching": true, - "supports_reasoning": true, - "supports_tool_choice": true - }, "doubao-embedding": { "input_cost_per_token": 0.0, "litellm_provider": "volcengine", diff --git a/poetry.lock b/poetry.lock index 35e97766189..3bafdb157ca 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand. [[package]] name = "aiofiles" @@ -525,36 +525,36 @@ files = [ [[package]] name = "boto3" -version = "1.40.61" +version = "1.36.0" description = "The AWS SDK for Python" optional = true -python-versions = ">=3.9" +python-versions = ">=3.8" groups = ["main"] markers = "extra == \"proxy\"" files = [ - {file = "boto3-1.40.61-py3-none-any.whl", hash = "sha256:6b9c57b2a922b5d8c17766e29ed792586a818098efe84def27c8f582b33f898c"}, - {file = "boto3-1.40.61.tar.gz", hash = "sha256:d6c56277251adf6c2bdd25249feae625abe4966831676689ff23b4694dea5b12"}, + {file = "boto3-1.36.0-py3-none-any.whl", hash = "sha256:d0ca7a58ce25701a52232cc8df9d87854824f1f2964b929305722ebc7959d5a9"}, + {file = "boto3-1.36.0.tar.gz", hash = "sha256:159898f51c2997a12541c0e02d6e5a8fe2993ddb307b9478fd9a339f98b57e00"}, ] [package.dependencies] -botocore = ">=1.40.61,<1.41.0" +botocore = ">=1.36.0,<1.37.0" jmespath = ">=0.7.1,<2.0.0" -s3transfer = ">=0.14.0,<0.15.0" +s3transfer = ">=0.11.0,<0.12.0" [package.extras] crt = ["botocore[crt] (>=1.21.0,<2.0a0)"] [[package]] name = "botocore" -version = "1.40.76" +version = "1.36.26" description = "Low-level, data-driven core of boto 3." optional = true -python-versions = ">=3.9" +python-versions = ">=3.8" groups = ["main"] markers = "extra == \"proxy\"" files = [ - {file = "botocore-1.40.76-py3-none-any.whl", hash = "sha256:fe425d386e48ac64c81cbb4a7181688d813df2e2b4c78b95ebe833c9e868c6f4"}, - {file = "botocore-1.40.76.tar.gz", hash = "sha256:2b16024d68b29b973005adfb5039adfe9099ebe772d40a90ca89f2e165c495dc"}, + {file = "botocore-1.36.26-py3-none-any.whl", hash = "sha256:4e3f19913887a58502e71ef8d696fe7eaa54de7813ff73390cd5883f837dfa6e"}, + {file = "botocore-1.36.26.tar.gz", hash = "sha256:4a63bcef7ecf6146fd3a61dc4f9b33b7473b49bdaf1770e9aaca6eee0c9eab62"}, ] [package.dependencies] @@ -566,7 +566,7 @@ urllib3 = [ ] [package.extras] -crt = ["awscrt (==0.28.4)"] +crt = ["awscrt (==0.23.8)"] [[package]] name = "cachetools" @@ -6255,22 +6255,22 @@ files = [ [[package]] name = "s3transfer" -version = "0.14.0" +version = "0.11.3" description = "An Amazon S3 Transfer Manager" optional = true -python-versions = ">=3.9" +python-versions = ">=3.8" groups = ["main"] markers = "extra == \"proxy\"" files = [ - {file = "s3transfer-0.14.0-py3-none-any.whl", hash = "sha256:ea3b790c7077558ed1f02a3072fb3cb992bbbd253392f4b6e9e8976941c7d456"}, - {file = "s3transfer-0.14.0.tar.gz", hash = "sha256:eff12264e7c8b4985074ccce27a3b38a485bb7f7422cc8046fee9be4983e4125"}, + {file = "s3transfer-0.11.3-py3-none-any.whl", hash = "sha256:ca855bdeb885174b5ffa95b9913622459d4ad8e331fc98eb01e6d5eb6a30655d"}, + {file = "s3transfer-0.11.3.tar.gz", hash = "sha256:edae4977e3a122445660c7c114bba949f9d191bae3b34a096f18a1c8c354527a"}, ] [package.dependencies] -botocore = ">=1.37.4,<2.0a.0" +botocore = ">=1.36.0,<2.0a.0" [package.extras] -crt = ["botocore[crt] (>=1.37.4,<2.0a.0)"] +crt = ["botocore[crt] (>=1.36.0,<2.0a.0)"] [[package]] name = "scikit-learn" @@ -7981,4 +7981,4 @@ utils = ["numpydoc"] [metadata] lock-version = "2.1" python-versions = ">=3.9,<4.0" -content-hash = "f391c702cf58ef2ba7641acdc3ae13d7c8e672faede68c0a624bd2ba0fb46b12" +content-hash = "ea62b77c662ab9fc486e421c576f0868bcde16d62a24703ee1f4916a0465ffb2" diff --git a/pyproject.toml b/pyproject.toml index 55d97f9a98f..69ba7f960f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ google-cloud-iam = {version = "^2.19.1", optional = true} resend = {version = ">=0.8.0", optional = true} pynacl = {version = "^1.5.0", optional = true} websockets = {version = "^15.0.1", optional = true} -boto3 = {version = "1.40.61", optional = true} +boto3 = {version = "1.36.0", optional = true} redisvl = {version = "^0.4.1", optional = true, markers = "python_version >= '3.9' and python_version < '3.14'"} mcp = {version = "^1.21.2", optional = true, python = ">=3.10"} litellm-proxy-extras = {version = "0.4.22", optional = true} diff --git a/requirements.txt b/requirements.txt index 0880e04fc5f..10364e5ded3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,7 +10,7 @@ uvicorn==0.31.1 # server dep gunicorn==23.0.0 # server dep fastuuid==0.13.5 # for uuid4 uvloop==0.21.0 # uvicorn dep, gives us much better performance under load -boto3==1.40.61 # aws bedrock/sagemaker calls +boto3==1.36.0 # aws bedrock/sagemaker calls redis==5.2.1 # redis caching prisma==0.11.0 # for db nodejs-wheel-binaries==24.12.0 ## required by prisma for migrations, prevents runtime download (updated from nodejs-bin for security fixes) @@ -59,7 +59,7 @@ click==8.1.7 # for proxy cli rich==13.7.1 # for litellm proxy cli jinja2==3.1.6 # for prompt templates aiohttp==3.13.3 # for network calls -aioboto3==15.5.0 # for async sagemaker calls +aioboto3==13.4.0 # for async sagemaker calls tenacity==8.5.0 # for retrying requests, when litellm.num_retries set pydantic>=2.11,<3 # proxy + openai req. + mcp jsonschema>=4.23.0,<5.0.0 # validating json schema - aligned with openapi-core + mcp diff --git a/tests/code_coverage_tests/license_cache.json b/tests/code_coverage_tests/license_cache.json index bd6c2be9ace..910ec931c86 100644 --- a/tests/code_coverage_tests/license_cache.json +++ b/tests/code_coverage_tests/license_cache.json @@ -4,7 +4,7 @@ "pyyaml:6.0.2": "MIT", "gunicorn:22.0.0": "MIT", "uvloop:0.21.0": "MIT License", - "boto3:1.40.61": "Apache License 2.0", + "boto3:1.36.0": "Apache License 2.0", "redis:5.0.0": "MIT", "numpy:2.1.1": "Copyright (c) 2005-2024, NumPy Developers. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the NumPy Developers nor the names of any contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ---- The NumPy repository and source distributions bundle several libraries that are compatibly licensed. We list these here. Name: lapack-lite Files: numpy/linalg/lapack_lite/* License: BSD-3-Clause For details, see numpy/linalg/lapack_lite/LICENSE.txt Name: dragon4 Files: numpy/_core/src/multiarray/dragon4.c License: MIT For license text, see numpy/_core/src/multiarray/dragon4.c Name: libdivide Files: numpy/_core/include/numpy/libdivide/* License: Zlib For license text, see numpy/_core/include/numpy/libdivide/LICENSE.txt Note that the following files are vendored in the repository and sdist but not installed in built numpy packages: Name: Meson Files: vendored-meson/meson/* License: Apache 2.0 For license text, see vendored-meson/meson/COPYING Name: spin Files: .spin/cmds.py License: BSD-3 For license text, see .spin/LICENSE ---- This binary distribution of NumPy also bundles the following software: Name: OpenBLAS Files: numpy/.dylibs/libscipy_openblas*.so Description: bundled as a dynamically linked library Availability: https://github.com/OpenMathLib/OpenBLAS/ License: BSD-3-Clause Copyright (c) 2011-2014, The OpenBLAS Project All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the OpenBLAS project nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. Name: LAPACK Files: numpy/.dylibs/libscipy_openblas*.so Description: bundled in OpenBLAS Availability: https://github.com/OpenMathLib/OpenBLAS/ License: BSD-3-Clause-Attribution Copyright (c) 1992-2013 The University of Tennessee and The University of Tennessee Research Foundation. All rights reserved. Copyright (c) 2000-2013 The University of California Berkeley. All rights reserved. Copyright (c) 2006-2013 The University of Colorado Denver. All rights reserved. $COPYRIGHT$ Additional copyrights may follow $HEADER$ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: - Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. - Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer listed in this license in the documentation and/or other materials provided with the distribution. - Neither the name of the copyright holders nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. The copyright holders provide no reassurances that the source code provided does not infringe any patent, copyright, or any other intellectual property rights of third parties. The copyright holders disclaim any liability to any recipient for claims brought against recipient by any third party for infringement of that parties intellectual property rights. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. Name: GCC runtime library Files: numpy/.dylibs/libgfortran*, numpy/.dylibs/libgcc* Description: dynamically linked to files compiled with gcc Availability: https://gcc.gnu.org/git/?p=gcc.git;a=tree;f=libgfortran License: GPL-3.0-with-GCC-exception Copyright (C) 2002-2017 Free Software Foundation, Inc. Libgfortran is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 3, or (at your option) any later version. Libgfortran is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. Under Section 7 of GPL version 3, you are granted additional permissions described in the GCC Runtime Library Exception, version 3.1, as published by the Free Software Foundation. You should have received a copy of the GNU General Public License and a copy of the GCC Runtime Library Exception along with this program; see the files COPYING3 and COPYING.RUNTIME respectively. If not, see . ---- Full text of license texts referred to above follows (that they are listed below does not necessarily imply the conditions apply to the present binary release): ---- GCC RUNTIME LIBRARY EXCEPTION Version 3.1, 31 March 2009 Copyright (C) 2009 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. This GCC Runtime Library Exception (\"Exception\") is an additional permission under section 7 of the GNU General Public License, version 3 (\"GPLv3\"). It applies to a given file (the \"Runtime Library\") that bears a notice placed by the copyright holder of the file stating that the file is governed by GPLv3 along with this Exception. When you use GCC to compile a program, GCC may combine portions of certain GCC header files and runtime libraries with the compiled program. The purpose of this Exception is to allow compilation of non-GPL (including proprietary) programs to use, in this way, the header files and runtime libraries covered by this Exception. 0. Definitions. A file is an \"Independent Module\" if it either requires the Runtime Library for execution after a Compilation Process, or makes use of an interface provided by the Runtime Library, but is not otherwise based on the Runtime Library. \"GCC\" means a version of the GNU Compiler Collection, with or without modifications, governed by version 3 (or a specified later version) of the GNU General Public License (GPL) with the option of using any subsequent versions published by the FSF. \"GPL-compatible Software\" is software whose conditions of propagation, modification and use would permit combination with GCC in accord with the license of GCC. \"Target Code\" refers to output from any compiler for a real or virtual target processor architecture, in executable form or suitable for input to an assembler, loader, linker and/or execution phase. Notwithstanding that, Target Code does not include data in any format that is used as a compiler intermediate representation, or used for producing a compiler intermediate representation. The \"Compilation Process\" transforms code entirely represented in non-intermediate languages designed for human-written code, and/or in Java Virtual Machine byte code, into Target Code. Thus, for example, use of source code generators and preprocessors need not be considered part of the Compilation Process, since the Compilation Process can be understood as starting with the output of the generators or preprocessors. A Compilation Process is \"Eligible\" if it is done using GCC, alone or with other GPL-compatible software, or if it is done without using any work based on GCC. For example, using non-GPL-compatible Software to optimize any GCC intermediate representations would not qualify as an Eligible Compilation Process. 1. Grant of Additional Permission. You have permission to propagate a work of Target Code formed by combining the Runtime Library with Independent Modules, even if such propagation would otherwise violate the terms of GPLv3, provided that all Target Code was generated by Eligible Compilation Processes. You may then convey such a combination under terms of your choice, consistent with the licensing of the Independent Modules. 2. No Weakening of GCC Copyleft. The availability of this Exception does not imply any general presumption that third-party software is unaffected by the copyleft requirements of the license of GCC. ---- GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. \"This License\" refers to version 3 of the GNU General Public License. \"Copyright\" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. \"The Program\" refers to any copyrightable work licensed under this License. Each licensee is addressed as \"you\". \"Licensees\" and \"recipients\" may be individuals or organizations. To \"modify\" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a \"modified version\" of the earlier work or a work \"based on\" the earlier work. A \"covered work\" means either the unmodified Program or a work based on the Program. To \"propagate\" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To \"convey\" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays \"Appropriate Legal Notices\" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The \"source code\" for a work means the preferred form of the work for making modifications to it. \"Object code\" means any non-source form of a work. A \"Standard Interface\" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The \"System Libraries\" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A \"Major Component\", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The \"Corresponding Source\" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to \"keep intact all notices\". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an \"aggregate\" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A \"User Product\" is either (1) a \"consumer product\", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, \"normally used\" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. \"Installation Information\" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. \"Additional permissions\" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered \"further restrictions\" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An \"entity transaction\" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A \"contributor\" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's \"contributor version\". A contributor's \"essential patent claims\" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, \"control\" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a \"patent license\" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To \"grant\" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. \"Knowingly relying\" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is \"discriminatory\" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License \"or any later version\" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the \"copyright\" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an \"about box\". You should also get your employer (if you work as a programmer) or school, if any, to sign a \"copyright disclaimer\" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . Name: libquadmath Files: numpy/.dylibs/libquadmath*.so Description: dynamically linked to files compiled with gcc Availability: https://gcc.gnu.org/git/?p=gcc.git;a=tree;f=libquadmath License: LGPL-2.1-or-later GCC Quad-Precision Math Library Copyright (C) 2010-2019 Free Software Foundation, Inc. Written by Francois-Xavier Coudert This file is part of the libquadmath library. Libquadmath is free software; you can redistribute it and/or modify it under the terms of the GNU Library General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. Libquadmath is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. https://www.gnu.org/licenses/old-licenses/lgpl-2.1.html", "prisma:0.11.0": "APACHE", @@ -35,7 +35,7 @@ "click:8.1.7": "BSD-3-Clause", "certifi:2024.12.14": "MPL-2.0", "aiohttp:3.10.2": "Apache 2", - "aioboto3:15.5.0": "Apache-2.0", + "aioboto3:13.4.0": "Apache-2.0", "tenacity:8.2.3": "Apache 2.0", "pydantic:2.10.0": "MIT", "jsonschema:4.22.0": "MIT", diff --git a/tests/test_litellm/proxy/test_proxy_cli.py b/tests/test_litellm/proxy/test_proxy_cli.py index 99b4ebba064..5f03ef18171 100644 --- a/tests/test_litellm/proxy/test_proxy_cli.py +++ b/tests/test_litellm/proxy/test_proxy_cli.py @@ -483,75 +483,6 @@ class TestProxyInitializationHelpers: # Verify that uvicorn.run was called again mock_uvicorn_run.assert_called_once() - @patch("litellm.proxy.proxy_cli.ProxyInitializationHelpers._run_gunicorn_server") - @patch("builtins.print") - def test_gunicorn_keepalive_timeout_flag(self, mock_print, mock_gunicorn): - """Test that the keepalive_timeout flag is properly passed to Gunicorn""" - from click.testing import CliRunner - - from litellm.proxy.proxy_cli import run_server - - runner = CliRunner() - - mock_app = MagicMock() - mock_proxy_config = MagicMock() - mock_key_mgmt = MagicMock() - mock_save_worker_config = MagicMock() - - with patch.dict( - "sys.modules", - { - "proxy_server": MagicMock( - app=mock_app, - ProxyConfig=mock_proxy_config, - KeyManagementSettings=mock_key_mgmt, - save_worker_config=mock_save_worker_config, - ) - }, - ): - result = runner.invoke( - run_server, ["--local", "--run_gunicorn", "--keepalive_timeout", "120"] - ) - assert result.exit_code == 0 - - # Verify _run_gunicorn_server was called with keepalive_timeout - mock_gunicorn.assert_called_once() - call_kwargs = mock_gunicorn.call_args.kwargs - assert call_kwargs["keepalive_timeout"] == 120 - - @patch("litellm.proxy.proxy_cli.ProxyInitializationHelpers._run_gunicorn_server") - @patch("builtins.print") - def test_gunicorn_keepalive_default(self, mock_print, mock_gunicorn): - """Test that Gunicorn uses default 90s when keepalive_timeout not specified""" - from click.testing import CliRunner - - from litellm.proxy.proxy_cli import run_server - - runner = CliRunner() - - mock_app = MagicMock() - mock_proxy_config = MagicMock() - mock_key_mgmt = MagicMock() - mock_save_worker_config = MagicMock() - - with patch.dict( - "sys.modules", - { - "proxy_server": MagicMock( - app=mock_app, - ProxyConfig=mock_proxy_config, - KeyManagementSettings=mock_key_mgmt, - save_worker_config=mock_save_worker_config, - ) - }, - ): - result = runner.invoke(run_server, ["--local", "--run_gunicorn"]) - assert result.exit_code == 0 - - # Verify default behavior (keepalive_timeout is None, Gunicorn will use 90) - call_kwargs = mock_gunicorn.call_args.kwargs - assert call_kwargs.get("keepalive_timeout") is None - class TestHealthAppFactory: """Test cases for the health app factory module""" diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 7201b961588..6279e96305f 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -2054,190 +2054,3 @@ async def test_aguardrail(): assert result["result"] == "success" assert result["selected_guardrail"]["id"] == "guardrail-1" - - -def test_resolve_model_name_from_model_id_wildcard_pattern(): - """ - Test that resolve_model_name_from_model_id correctly resolves model names - for wildcard patterns using PatternMatchRouter. - - This is critical for video status/content endpoints where model_id extracted - from video_id (e.g., "veo-3.0-generate-preview") needs to match wildcard - patterns like "vertex_ai/*" to inject credentials from the model config. - """ - # Set up router with wildcard pattern - router = litellm.Router( - model_list=[ - { - "model_name": "vertex_ai/*", - "litellm_params": { - "model": "vertex_ai/*", - "vertex_project": "test-project", - "vertex_location": "us-central1", - }, - }, - { - "model_name": "specific-model", - "litellm_params": { - "model": "vertex_ai/gemini-pro", - "vertex_project": "specific-project", - "vertex_location": "us-east1", - }, - }, - ], - ) - - # Test Case 1: Wildcard pattern matching with custom_llm_provider - # This simulates video_id like "vertex_ai:veo-3.0-generate-preview:..." - result = router.resolve_model_name_from_model_id( - model_id="veo-3.0-generate-preview", - custom_llm_provider="vertex_ai", - ) - assert result == "vertex_ai/*", f"Expected 'vertex_ai/*', got '{result}'" - - # Test Case 2: Different model name should also match wildcard - result = router.resolve_model_name_from_model_id( - model_id="gemini-2.0-flash", - custom_llm_provider="vertex_ai", - ) - assert result == "vertex_ai/*", f"Expected 'vertex_ai/*', got '{result}'" - - # Test Case 3: Without custom_llm_provider, should not match wildcard - result = router.resolve_model_name_from_model_id( - model_id="veo-3.0-generate-preview", - custom_llm_provider=None, - ) - assert result is None, f"Expected None without provider, got '{result}'" - - # Test Case 4: Exact model_name match should take precedence - result = router.resolve_model_name_from_model_id( - model_id="specific-model", - custom_llm_provider="vertex_ai", - ) - assert result == "specific-model", f"Expected 'specific-model', got '{result}'" - - -def test_resolve_model_name_from_model_id_exact_match(): - """ - Test that resolve_model_name_from_model_id correctly resolves exact model names. - """ - router = litellm.Router( - model_list=[ - { - "model_name": "my-gpt-model", - "litellm_params": { - "model": "azure/gpt-4", - "api_key": "test-key", - }, - }, - { - "model_name": "veo-model", - "litellm_params": { - "model": "vertex_ai/veo-2.0-generate-001", - "vertex_project": "test-project", - }, - }, - ], - ) - - # Test Case 1: Direct model_name match - result = router.resolve_model_name_from_model_id(model_id="my-gpt-model") - assert result == "my-gpt-model", f"Expected 'my-gpt-model', got '{result}'" - - # Test Case 2: Match by litellm_params.model suffix - result = router.resolve_model_name_from_model_id(model_id="veo-2.0-generate-001") - assert result == "veo-model", f"Expected 'veo-model', got '{result}'" - - # Test Case 3: Non-existent model should return None - result = router.resolve_model_name_from_model_id(model_id="non-existent-model") - assert result is None, f"Expected None, got '{result}'" - - -def test_resolve_model_name_from_model_id_provider_prefix(): - """ - Test that resolve_model_name_from_model_id handles provider prefix correctly. - """ - router = litellm.Router( - model_list=[ - { - "model_name": "vertex_ai/gemini-pro", - "litellm_params": { - "model": "vertex_ai/gemini-pro", - "vertex_project": "test-project", - }, - }, - ], - ) - - # Test Case 1: Full model name with provider prefix as model_name - result = router.resolve_model_name_from_model_id( - model_id="vertex_ai/gemini-pro", - custom_llm_provider=None, - ) - assert result == "vertex_ai/gemini-pro", f"Expected 'vertex_ai/gemini-pro', got '{result}'" - - # Test Case 2: Model ID with provider prefix constructed from custom_llm_provider - result = router.resolve_model_name_from_model_id( - model_id="gemini-pro", - custom_llm_provider="vertex_ai", - ) - assert result == "vertex_ai/gemini-pro", f"Expected 'vertex_ai/gemini-pro', got '{result}'" - - -def test_resolve_model_name_from_model_id_multiple_wildcards(): - """ - Test that resolve_model_name_from_model_id works with multiple wildcard patterns. - """ - router = litellm.Router( - model_list=[ - { - "model_name": "vertex_ai/*", - "litellm_params": { - "model": "vertex_ai/*", - "vertex_project": "vertex-project", - }, - }, - { - "model_name": "openai/*", - "litellm_params": { - "model": "openai/*", - "api_key": "openai-key", - }, - }, - { - "model_name": "anthropic/*", - "litellm_params": { - "model": "anthropic/*", - "api_key": "anthropic-key", - }, - }, - ], - ) - - # Test Case 1: Match vertex_ai wildcard - result = router.resolve_model_name_from_model_id( - model_id="veo-3.0-generate-preview", - custom_llm_provider="vertex_ai", - ) - assert result == "vertex_ai/*", f"Expected 'vertex_ai/*', got '{result}'" - - # Test Case 2: Match openai wildcard - result = router.resolve_model_name_from_model_id( - model_id="gpt-4o", - custom_llm_provider="openai", - ) - assert result == "openai/*", f"Expected 'openai/*', got '{result}'" - - # Test Case 3: Match anthropic wildcard - result = router.resolve_model_name_from_model_id( - model_id="claude-3-opus", - custom_llm_provider="anthropic", - ) - assert result == "anthropic/*", f"Expected 'anthropic/*', got '{result}'" - - # Test Case 4: Non-matching provider should return None - result = router.resolve_model_name_from_model_id( - model_id="some-model", - custom_llm_provider="bedrock", - ) - assert result is None, f"Expected None for non-matching provider, got '{result}'" From 034e3a6d446d5d0d241f6dda37c73e3fbb007ee9 Mon Sep 17 00:00:00 2001 From: YutaSaito <36355491+uc4w6c@users.noreply.github.com> Date: Sat, 17 Jan 2026 06:46:41 +0900 Subject: [PATCH 12/14] Revert "[Feature] Deleted Keys and Deleted Teams Table" --- ...tellm_proxy_extras-0.4.15-py3-none-any.whl | Bin 45399 -> 0 bytes .../dist/litellm_proxy_extras-0.4.15.tar.gz | Bin 21228 -> 0 bytes ...tellm_proxy_extras-0.4.22-py3-none-any.whl | Bin 48859 -> 0 bytes .../dist/litellm_proxy_extras-0.4.22.tar.gz | Bin 22506 -> 0 bytes .../migration.sql | 117 ----- .../litellm_proxy_extras/schema.prisma | 99 ----- litellm/proxy/_types.py | 30 -- .../internal_user_endpoints.py | 13 - .../key_management_endpoints.py | 141 ++---- .../management_endpoints/team_endpoints.py | 114 ----- litellm/proxy/schema.prisma | 99 ----- schema.prisma | 99 ----- .../test_key_management.py | 2 - .../test_key_generate_prisma.py | 20 +- .../test_key_management_endpoints.py | 416 +++--------------- .../test_team_endpoints.py | 349 --------------- 16 files changed, 89 insertions(+), 1410 deletions(-) delete mode 100644 litellm-proxy-extras/dist/litellm_proxy_extras-0.4.15-py3-none-any.whl delete mode 100644 litellm-proxy-extras/dist/litellm_proxy_extras-0.4.15.tar.gz delete mode 100644 litellm-proxy-extras/dist/litellm_proxy_extras-0.4.22-py3-none-any.whl delete mode 100644 litellm-proxy-extras/dist/litellm_proxy_extras-0.4.22.tar.gz delete mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20251219110931_add_deleted_keys_and_deleted_teams_tables/migration.sql diff --git a/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.15-py3-none-any.whl b/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.15-py3-none-any.whl deleted file mode 100644 index ba2e5e5fce56aa770485ef0b8229c356521f4fd9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 45399 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 S2#7e~s{%07n-KH+U;hu7c8lEr diff --git a/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.15.tar.gz b/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.15.tar.gz deleted file mode 100644 index 7d01b3de6fffb59a627a538bc98fbff0a4ec3704..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 21228 zcmXVXbzD?k_b)AtbcgcL9n!6U2uQbdhjiD_NOyO4cMjdsAU$*lNJO(C_L&9)2ft^v`X;JZJQGS}O zb0ynsfr8B#wBsyEVKK@TcDIFjKJL;p;!=PlzM) zqh#mVMsbj=_l0p%Qg^-SM(SE{9Fr0%9G}&j1=inni1_&Zy&sHvN2;HA6V+rWGdgIh zD&8FeK2^NFpIcgQy4OPb@{J)3`t!Rgi@6O)S>6Qp4;`>2}RR3kIx`#?X z$0Vz6hJEavpm=vX8D)>SU^mUt1;_LZd-&`N7q5?yke?rX4<-alsZMv6rJHupa2v<@ z@$PaTsjHH*H+;@;`(bQu^|6k)h~J@ZY`rOI?_Ia=hmZuyULn%PS|e3ihVUW}MQ7iK zKbJq_mxY2g*=@9=V`8B%=Wh*-LE)E>xuxSXf-)ypcW*a%5T9`>v6j;kIF3DB(b?Y%M*;13dvCjW1Aa1h_>6pv+(plG0GEqEJL z763EE#EFAo>ABpj&jWY|i@k&40`OfVoPN+Ez>)+j-8;H|4r}f`acW0)pRF(9 z3J{BSOi_-tlNHVyJX@{WzDx?hr|`YLew;WLcH4>kF_3iQ0c z-LDl-0)?1J+0O%kB-1?v5t*BQhV65lpl;!&KBKS<%*;Mo$0_Q=P5mW@5K!shv&U6W zy5qT_yYc-u)YAXL-8(o|42Z*r#qwvt(>6;!TzCjsKisdsA%m@Zo(VRlg&?+QKGSwI zOErG`B#`6Z03~~t^pE_~YN6>V1^Mv1(oYpq5vcwKlfxn34H0z-UIt1BzvvUz+|Q{v z0KwB;ZfW1pqP_f-m&nfX5yE}wu=+fL&4x^1W*<_Z{>SaMRx)Z^4Nx0Il~Bmg6NX=< z1KG68K&wpsS}AreItYs>Bo^me*e=5Urqeg(rGdXW-rv(OZgNFGu`zn* zBTH2FAUTnwDs-TYL@5z1ihxOagu5S7px+-@tWn8L%35#IbZh3PgYZhFmnZ_ed+4vi zH`wS2B+vA9kDGS-?sodv87;fil(z7c2#Zgav<5W7hS2LpWgD-sXEHH$-J#C7Z(cHS zX44trHPeqIlRT~l;ka8CzX*&>_9CFpKr?pMfYKkzD1{8ZWUzto)_xN5f{&ZKJGY4XlnpvB zWNihl6F}3!g&fvj68_$9jR{QhdDy)v6!Do`)!BjT#bGrR3y}9r@_{=UJ%dli{@$E` z=)l^du~W|;P1F9@muJDVyK4{mlPRQ*TCCTX>D+tm9hH7XsDsOymErP6J!J>af%P|B zIg!pJ+x#5Tuk%Cc8``F~4FMXp=qYRzh2Oq4@I^9~r~8QPEfkC7&RJ{kc*0B`?-p}8 z#YUgaI?!^=Ko!_bu575x1CwX{gszlu>N8wDdTsCB4fck#4T)Og)HNFVlSgGNsw4RW zj=rAjq$DyI7fTsM1b?b;FzrI8`iwv6*7D_G?4p7;Jefi(@NG@Z*Ljg=(fa7bj~o(W zq~8h-aT&L?;gPZQU8LzH11&U13FmT6F7Ik|yiGHg7qx}nvE;8cGR12iin|a9W$-gk6zNazpaudpJPWE zJ^o`tsN6+jBnAbI6W1w8u#pG_gykf2&3+ z?z~{H%*j&T1ow4Fm3Vv@{whJE5Ps*ORtnb_a-$`Mn6>d(v8mQtF|3$n$Tz=rWb-IL zqu}3f%S+EyAkrR}4-5 zyNF?mUHWv$yf2+>GAs_SHB5Q%M_aq_x9;53C4W0_`K{NeYZCumAsLQlxr}~rwuNV< z6rl$9nv_^YN9@QK^_RL%=9KD>Hk7JcnHt)avc>tz90q9de~e{FJMz5pha=AY6BU?P zsLh2*$nl3~mSG{C5LtrfOIV}>p}%8eeDCt1y--*}A!Jld=7F7Y4OfS&;F3obZaQ zR#SB@x^?{M;@jTz=()VzwX$G+F0nxkFLPDY2?UPEyvM9f=HBAYvrJu&7;kneCw7f-e2R68%!g9RF^Q$<_) z$?Vdq*Jb}}OZI4L)Oo*z4{0Ryj^w-V6qWwH(&)b;sIw%I&=f$RJx8&My8AJuMRmW{ zJ}K|X!g(ZXF2cTY<|H=gvCrZT@|-Q&#-vHp zrSj~Af|y=k9OQ=bZ2kI&6Kfbq9tsrTJO9knrOr5nWr$txzjUeL-JE9#up)<7$#s?j zf=^({R2a3@6PWS^(m!$xMBUxMZtjfEbldhKa7?bndu$cY^F&@akbSISGxp+&fUu|^ zFzEgl^wC2wZV3d=MSKCMFP;)*Eh!8VcTS!?imxzvN&#-)<^N`6%Aogzi9KHIyv+Na zH{Ui`yebDmEs3Os<`N=`raI|IO``|mqG_TrOHOTm4b1B3*Nl|qCK<6App{P{lu&zUd~$2I7;BeCA_e9EJH20LZV#|PsBAUJjz5GQ!KaOe93JX3x_ z^`@*u%_Xl5ybAW5iK4$&N{4B|`JlaAxZW+?gD5D@aCl!Jl~BX<;xx&>AK*S@Rcf8U z@2{x|uc3H_9?KFv=%tVN*s~I~3bTxDzye_0Cnc-jzB!d1EEZZ&a&jBln8B&6z7#j# zN3(nx-uf`H1T_o;PAwk6frmirAmGM70=TWu2)_y5N~g~KDO!1zAF=VpFE8ji`&RjY7{0Nr!k0O0bvOEt4V|KY7T;seAV2w+Cg z{-gAkXX>g3XUS^}sSP??kIGBZ4nIj&k`a8721^U-iH6;;={zfGt%ImAUO1Y7OS{{0 z)%*&wjp0UJ^_0$conDoEg@O@fG{EV@MR*ZF9tFrqT^8OHc5xsfs69&PfpUdjIN*JB zpdR9zWnMR;rf45V-Dso8D1(H@xJqinCJ zzb)<$s=qaZ)DfoU-uA~yhuZJjo!j22Q>3!rH5+Jq&#S3ucdpEc4$L=Uw99CB+PJxC zKNal&_f1fc-VLz61LQeG1CR?>6j3Tv3bKv1EE#x1+dJUo8YnU1*a3Vtt6%Ci=kCC= zB>;XqAX^IPNd|FQ5F(S9mWHOaY`MV3z7yA8SNEo!=bnGnM~|O#Vq*jU?)cDS0^2;16wNguLc|p(@21-}S7R&cf4x z&hu)(Qtt>@iG}{Mbx?+pyL$H!n=afF5lFM@lYcIW+R6XS`yG<0XJ2mKUq%n41oTV+ zMSk%O!0nn4aM8LCHVq)(gNx;up00B8$waVy0t-GtzS}+l%C}$$gysb#c6^&b8?Ym= z#fCn)#oZj_v^Ze76U0&ac6OuhUI~!zGav&J?ttGdT|It^0Cfb6{~jjx4!Io3of1}X z0x;i-;{$a!pzm=o5ARo_wkdh!)wbX#i6&_@r(ECg-39?@J^=}?$lrk68t;KyS6JMr z6p+L+%j24N9^H2xSQmF@qLLXafOd3Yn8$SOKTIC#gptYc5O@Me8Xf`uj8BlwbST8u z!{GKQ`|YQQd@;SG*_%;#sObsKOxb5(vfn@k!1303G#mxl$H26vfxj5Yzv)6Nvfn5m zdD!pE*2=4O<}YU2PO**O$UWKU_7AlYw^QF>{H&SfjG5ie9_RCO(O!1)kIk1|aQ?XN zSgezBKs3qPHJ`$O6%<)9)B?2K0b7_a)Xg}v1XGCcM@gY~01WpSgj}CC4+LYrINS?o z!o;bbK(79B=Dr!fT{^_$w#Z|@BjIUG7cI++j|yj6{Lu0APk+j0Bo6BHPHo3hn(ZSQ z`pGh}m%I7>-o5Kt<%22dR`g(3=#=@s^BZ#oN%gCzuel~vb;>E@pVuaji2vhc>IgwH z%NF+}xC6e*t^?GOuzPGi1wiM0KKZ2M_BTcQ>hZFx#a4=>tv3<3f#;Gnn4Y?~V7N2$ z*K;N}yB2qAhJru5fUWUR_@#DaNH#tnWy@PMjIh?Y_Z(U=ENK<{(`Fy#~EkNICf7Y4z5xv-0eiC>6ch{PdF zVqzAK6D2Oz?>*YpBEsd8{cB`vs0RZ+p!f(<1lm3V@@If0Y|8N*!~=-CQCYc1g?mmT zcfnp%6(DWa3f~@MGHbUfBMVU25 z#WOuovV|QE`G0#}e=dh7-rOsS$)+aO1^F{ zkn7#+dxWz~`V(D*?#U4S?ivbYJaBjeX{A715UkAEvXMLCiZ>-3&)hfu3}<|)?&#yI z!?kerUaB6~AgEwu(U_c+(dlviDmB53HHJoCwWxfUUaiWNWTC22kr^hG)cH5i=zc}h zVQ<~E$XbZ^uqEM^hOtGV}pT3W?ZY`lee?J^5z_)k(+qG$*Iv<0Qmt&MI``v z7vM5`g81(E?oIOBsnjrsCmK|wC)f>|e<)C};uRDrKuY5in~eaHQ5*ajIt1T8*NQ$@YRmA&)+F0A>L zMw;(PvU6yWOUhqvkzfs;Tl*dQyUou+gi=dy(Z%2o+gV*Wy?iI|W5>&f^AN%U~YLk%I}uF-qP5alrt`El{LozTmU>KFkR}*P7Q4?t0<0HT#sm1=Dk+ z({=K7#YV8U+G*hW72ew#pmm!^IY+_%adZd?cmbv&+VmpY&+wwS|LRK>$a^KT^7?>@=9#Spbo^Jdgx2ENR?+|w1HhKa`X@&%`eVIHm&1_*Pa@ocAdvw zM4RiN5PX=?1B&*KXch%w_d}f)l%ELVxq6aDQ~N%~zAV~(TWuo+qHQC`wZ6W0RK%xH z6HnewikzGgkIj(dfUIlvkHGVv5TIV61Ydf3KfX|*aS}(JuAy&175-vrcmu*22LAG=08U^GXK4QVmJ$mB zCfWTtaw>Tjs#^Fc2qm{FW8fkeV0q({0kZ_f{{&EnKLGdYf5oeWvnLA;LzAB*Di`xcD(Pt$GM!O~uGUu8)lKseq&AXb7eDV`vE z1X_56@O=buF3I5DqTuXCZF8y(dhQBNc2se|{3>t32rH8}V7I1w@nh&rTo~*Kl0yz* zW%$or&i?V`HADJ|VfAF#4}8Q0Dh`12S>R7U$_WUk@BTeKl9eL{(Fjs;-|S5~{8xN{ z;qyPi;0`zdG%NtlS|-dP0#*de(mz9z*pr@_Y8DIBxIHxO z)ftJX1bqq2Xue`zIedj$(v$4S2DaIgK;gKBAmqK zSz?c|y|UU9RYR0_e&s0bFwq#-Rh;_znO!I7PSh=S@C-q|Dav&(avaO5b3wr)CX>NR9wio4>9h_l3X* z=qlN`2*Z}IeY=Ihfb!%^^*nP-t3nQ*GHmb_$wPu!zBLW5Iq>Wmul?7ulqSh#x||R<_C=x zYLu@b%h28rVEQ+({_27pWML2p$qU2*OycVO(%_&nSg^B#LI|G$;cucZ%v9h(QHuO$ z3X)+SJdZ#HOzD4q!UOly_~K2(-(hU2^&DL9FTrUcfho%MiLun|`z}4B^`CrWB&iLhVr0dveRFenNNvC+3er)h~%m;p5)^9X4 zAdql3Nn@=Jj(CEljqnhUv!}O$XS$?%GWPW_xP1P2WT4UVc3S z)UhxJW&ntqzPYbpeW5LPsY>K~pj^iq%f2%m7oVWp+t zz+CTF-iOD2oD77shr^-a^RyjovyBokc-v~zB7U*7d?~*zXiWl7EQ@NUM}0e5{pwyU zPr#`_TQLAhh606M5U)9LE00lm;tX>ainQ5e7~225>=y8AW&}iO^$fJ`1KdK-Q3va4 zK2nn22Y!v;LNT_vZ%2BG3w=br{+HZ}#AsZp4Wd<7?@f1tz)Gf$v5~ zaaaF@)I0&aE&;E;P7>(S1%S<25Za$inC(;D0YI$=gIwJDa6HZIeKO+J8hMYMy(9@z zO8UPd#93Q~I_Sbcthb<0xCh>2H`M%6KnR#ADg|CiZo%3^==|t~N(m`43^ z`8R>}^Wt03CZgx7SI7I;C|%qZ&X?3df`F9a0y=$fkHoh8uZ?+iA1QyTt2>B7x#8hsCjg8gyNc(d|d-WvtUp*dEMvGFvm$&AX2MWVnf-#F)G-XIePN_yoH9Jn(!wmkg zd8p(mUHA*X_~Ja}wN3-KMo)Qnr4L~Hx`hjoqlf^6$x8-)1)4?QZM8#SqTgTyXc2w| zd)af~(enW;kG{lmdlCOAfxF>FNILnF=V+*H~jKot+1nmvMFBcOkQ0pBZ035}uLYQH~@;Ob}?!Jo-l)5U!#egwbn z#1$yVU$_WZxC4(40s6U=D)EPwz?VkXH-V%4eN8{UV{nox{6OQn7U+6&xLNq9p>V3v ztSRe>P+6DiX;k>vLm7y=m$`Bf-)dFVJp4uLd6sFeCwWJ#{yLLln^E0L{a6S*r090$yu5^eHcP8u(|LpG{0} zseedxZC-GFjCaULC0~=tMhQC#{?*;5R`f3>C<9iXLEir|qFcj&4tiK7tnMQ~_y~G6 ziT|LTj}Z8Eax46%NAe;74*~M@YaZQ-5p5QJp*}~Lj6!D6n-}9UKxc=cfp7aq@hA8C+s#mPAk zO)acF;>!N#;H3bc9y=Jo_XM(j0uw$N{sANf19(6d?RQ|NGeH*R-NzlY5A!OI^gYZH z8k5>9Q-YFz6D%;s=m|+?d>uOC2VwB_TfS5G7nbDZS>GHQ^S3bj2huP1hl7$LeQ}+B zv{51b@48J?sF4Y^P^_TYbnZm6R_gWQ8R2lnop{o5IC9Y^R9~n5%fW|478-4VDy@>> z12Yrke%F&~Woz{HDQ>Ou%X+cd-)ar93`!HB3}wa?OcX^Njq6{hSMBQY-63LXvYw%T z7Q%sE+8rz7P~1;H_IpKdFzHgB%>{mM^&D=OX`m#A?`r;Ks%jTJMv2)@zT&9rR?Ey5 z;>FjUjphWY#jIo%dp2gy_$*#SwqN}j4*hGzGXZX0FTq^(fIW#-|HWtm5ZQ0=8_4?! z#MU)K@3r^AAJKJIH}j-~`A^uJ(IENp7i&M#mKyM)IHOBB0fH}C_0g`3wC}S~oF@4T0QK2cJWK_dk3)0l7(W9{0@N0;5Gh zsF7g?tmwt?6vX@TYbH(-aD(0d%hM)-t&{HKTcJ&M7o?+H9+Jy9(smxDj{#+0{&_AL z;9i&%5dVbsT8rT@>gTPXRqTJ+&;ot13%ZJZjL~e1q7BzJ@oe%Z9Mr!}4T!#Ziv&+JBn9mHBD5#lh{G*?Egej_Orz zjJfANw|W$_X}?{1w8bjGg`B$#t zhddZq!qfIcV9z^V%zjb1@?WRkB_= zPTc*}=X|Dq|KP^s<1IKiqI<{CA2}OgYIdP zfU<>Un4w-0F!={$e02ALVqMkg12Qo7&d&6l-A`6HLsawy{2u6f&Ce2{;Qu9$IBVaD zP={(^Mr$nR2zRg1AnA(y3mCic8l*J|#D&6$A3&3GaMBkcsvg{xK<%Ld{e7Y6cr(K) z{`G(I)BUB;u@EAR?y-=Ywp5&qS$S8ns@vrM>b`ZgNrAaKnp(O?LV6>8M~ZWouSoEp zb$%Ku{P8Z@GGVS~rW|H;p1LAZ`}j>bQuH+gU+90Obtkyn*MW^-VUPDsk0n>2p06VS zDE|>W`V(M!A#g>_Uk0j;fs}M1!+S#Q1e*{np^odeXn4OFNlgJS;E#(!?a)bW46pp~Lg_Z4h)WrV06ziz}X~@le z52SLtB7jCNAY;1NcQsg=6&Exd{z}cearb>4gffL7@+P~>$$iuD2cB4K3CU=BNKz)q z4FAu8>tKyPJ>fGmn$sid@6eIPnnpz8<$h(-y+r<@akqg@8;T0LIO?`ErOuLfpDhVJ zg!r-qfEK>Ql}GO>is^9nXjxh^41OH0E)B!iPK3p9d=4Eh86Ku%ulwjh5!`j0WpMbJ ziu`)&1x;B#$TskkLf|bH2q7hBo-tst$`eU z{eL>LZI!T+YLW|srQ|7OEg~9$$k4(`aa-|YFX&ZNxF}pK*R<>Z66jb?3q7aJRIx)m zs3=9O4NIfX6>hQA_)PinW?zlCM#3N#;`>F~m%>^SmpV-sFKH`@?o# zlpo1im9Xklik}0iZdE~{LnO=N&;rtf*iZ+ON~wc!WtjK^Ka2EwKgKEz!`3`j(om4~ zPjCZf4;Pyq-5W!U}b6K+Nop#U+;azIwv(H(>8Oim6$UctIUj>{FTSMp6lq&FH#`K-R-|Pw*C&*1I zrQ%d-EpKedYbu7yoc$U?sg|j*%=#O>7(!1;bnY~df98C90qwj{_uRf11{=sifR+>n z?v|uHiR+snM-;pnaCWEI$`iC|M!e~|ji{G$D(3ec7PSxP#&cn+v+GlS=+m?(2T?rx) z@ixg$H}}PBiLhu<f^P|_{5-+lWfstwXI>@Os?MaR0` zjh4`!yc@Bah<5=j|3I@V_d)0&pSew@c1yFBYNLHMp#~Ww{d!1 z|LSOn7ZfNcIThaGM-bHPsh$_Ab0fKqMM9pAoh@HU7}I8mkO@g6=%p6JTcL6}o-}}K zE>M-AuV=E4o*_bWV8tWh6l(o8%eY3hH^MDqSIMQ@p5MT@pfmqoDYTS~lwiv|6hjv? zMrVE{g-h+jr%8gA1QOmLa-Rg=Hy4oMI-R0wRf%pUFQw72FphOPl>YhukdZa2-b+-r zu`j=e{s0%P*&}c;D#}q>$6F{!ptbHGjd$b@aZJv%DidMBJDrr+4ql~T`0`N*%-vFy zdOMX-3Fq!tP&6|~fnqPL63otNC$&hwlnf6MhNp4ez%jZ~)$ur7$+2_Qa!vkDY8x@p zrTGRKv#vGs)8YCXtI2vZuZxh_Y?-Sl9OVFGur8UaI&{WATrQp}sL)J&F(N^$l7FWNjaIGx%g-(W(`~Phe`lC;nvdU7ic*9At@e23c(aKa7X-dW@ z6=h-5$?<;Whht9w;wT@$^ z`4#a_=QX*66FS(1FuwH(59#H44EdoeQ)9g+P-36l%S0XPD2Fzop&n;D0G;;rvFH+D z)D#G=Y9a(_7JfG;C4BmtvO^+b8PAk#P!j)RH;I;`0Ee(PEpI>wEise5y4TN zt0E|puF(l(2z*+hNpE{qhflknu(2_aIh}p>=+d7@3QyB)J~-#G*nV(tDypH*jf)SK z**QSb<+Dos{t4Pmo5T_Mi5=fk#LW`DOmuD47D*InpA`QiMa$3I9=EbEhHXk8@OWzY z-Idzfcl#m7Vei4Mar%VACXu5}mjC;cd}E6Vvv(*#oUt@buN6>$T0ZWCy1-U8X3Q$6 zCKvq7G@dBK>fmBRchyF^8d7xF7e!IBdmYhxp1V6XfEuJX!+*d|T1T^e&8k~KE0vkD z2#o{U82=Xv^(UHnX##SPzsSovv?YG%I`P!PO3{vq6( zcNXu@M<>6pT``ej6fNg2zCML0{No#BH?rtq{onLai^bI7w6ELfJDS!(;s#aW3mqLd z;m?8F6^q!(lo@fKEz?x1s-8-mBQuxc=&*XVoG}%WhSSki@WKu4TKj|=Gm3O6GmSo( z{cUIF^pDq-B%RKRpi3z8@o@e6psct5f$-8G5psG)_!K)}vYZ5Er&MrCgs#?rpy+!K zm@Eg6rqgJeSsYzbv`PYu)3XjQuJTULm(?KCMW?Y(>f7v&A`yZclaHhSLsp)e1m{tV! zNa_XRygU(sj1y^QYa52bB9an6_%mw3+0P@HA#JoQ>K?Dk0+z`aR{5vLS(76umWy=L z7~!DkkNg=z_eV_n-OLY>jg1q{V_!#c8izzi*`KT{q;Q=w%Y;WYLfi#HB_DK-Rp55X zkNB`gui<}nrAj+ccDADOp@EF5SjON)I6qV+jpM_=KP6#`hbN(;3pM8+Seuc?%lK^S zB6w{w{!w}F0Gru-PTSCI{D*l%eUGBC3^pel0Xsj(eX%IDe=qpB+gOF!)c@s?{EPON zJfz;3;s6a56~(`iGRz4fFZ5b+Bh%Muo@a?U`tZ}`N zLFZIQ6hc!1tM@DkS~BCB$)rlloMLF%LAsBIo1f?|-=-+?BPgkK z#P*r)vl}+R^G^Czo4~X34%V1zvT`MVU+&fF59<)bH^O#kLU&0bqM7i@b}-{o5Xf_I zAmeB|h_GVhenUSpz)PEK%0J1w`$)(ZqSS)Vg~RE-x{Ws{LD!WLAhf-(7vSpSM{yXij^J{1(X)*$DfvQr74CQ(_W^aA&+PlHOlR}4 zJtFEc_E_cgZ;EGo-q}dij?%Z$8!#GLw>MSIqs8rgm!WaI>7saV@7bEw{E}A9=$RFut|GDM+O`B=j)c zYEP?#wPuy{soggrdsZFfgzdCO$3e>_rwXB&7i{p7Vh;HHv6zCXgGHBXzMSVjSSq@H zvZL~No%Gk9_<3|wJoZ3sz~uS%2vXfeM2gTENNT#seb#455Fs9H?waaCjN?~3&|aEa z>5OZLYPKb(V1jE}Xe3`uZiX&z!5%lx7V+-Fc*0L;%XR7nBsluF7w~P`$VyhSahz)h>w>IR2gjx16lbcT5|Si z*l|I_OU~5PXxHi(3aMC9PBe*f4-4tzkqJSB!D|>ZDUw>sBK4o%af|Yf%cMVMdLT!& zb2>3rH6o`P$g^omS*vxcU8m{;@zuFKYkXmaWf z8+sM0k&BwSk`iTg6B{MZQWCUMa|W-FWHhB3JtqkI8qLxoRHlxE(L*F|FvKmVEa4#} zcE|+&&3y#Jn2OR=iY11M1As=Qkt9N2z;yc{Z*W>%OnH5W1cF6>Am2I7BjQXu_q^V8 zgyhl4B7uT|X(9X<2L&pGc`+$zZ00UU^IZ7Y((IgUzCG|*3E_3}2T6R5$|=(fK~f_Y z1-W5nH1-411rJ5z`&vdZ@$4UH0>7eLh!hi#na~l>tAZsF;0fhQT;E4LTSj%aP{dQv zBEIbl_X<`qJVCXnRo8FuN+Go_?(^%17giKeiw2q8%!F%o#>9}dfiuz^CuGzATvqe< znrKP?u35~*YcW_-h1Wp0a!7pEmTPkfxCEaGs>U4Ei9F=jYagzRiw(^==^aw=|Bi{~ zBK}hbr!D=KtnMORGp?{H^ngwK3gk_Z)o@F{7@|9ZId1c*8edtwQ(BuujDj$M643~+ z&1z}1;C&U36DD~J7^{;sN$+Eh@NUPi+?_1=M?3Onk)n~scqTy%<%DT>ie+xzFXVuo zfC6!W^3A6rPGeoW3~i=PUTwXS1LKzN<6v#g5@^Je#ONDr>aQeT)*4ozMOibFf{G&T z8WhT713Tde$v(0;?1xatKFZzL}@rO!=QG=Zlc8Vc%5{WszH8sOcz1<=X*K}4O z(yz4V$;E=?`6enUx<3KJV4?ll99hZ%^(}v)8DW(-E#s7<7ulzcR3_Wc@TXI6!*Gtn zkUVJGr#0w_O$X#`<{jxBO5Dnedd1@;_XO?V6MQ;P0W(6Wah;* zfe%H>_eDQYBXHHz8vM9TaW$QIb+XfCtfsR6a&TE#JoY%eD(+7S78a(;{Xv&xg<7IO zI{Krqh?o_bMZT78Vr$!?VT!0jiZ_;Nx3*?%Nanp}G)zj;Qr4NHpgnq3u0xgoR2E~i zQK{RKx)|L!_$t|2!-jO>t^V(m)c7;3+aOOqz7hVPZ0Yy^@!G(L`XENs4IE*DDvuA&vtv8BwBd_~o9Kzk7}XFw z9hwKG=UF5C2563(BZ`N62X*IjA-#)D)q~;%vxm_YC z&iRN}E8C$jX-k@hjz$p{_H+>@zjbRx_LXRPZ<~+U{C5+FoIoJi$}Yd5e~*ai;q_sB zZ~Zo@nYcME?gx<{#R$cD@@x_>49gNiWr*S^bV!=cY0ypY*y2Op;wO+O_As&L93_Ih zf5Bg+)ncr7NKbzw;3{88Inp)TQ2}@#6sn|j5HKuf9owb|`E#GeFLO5S_BxdLe#I#c zy~7XtSU!mH6WxynPT4{7F76=F{ZsmopER-K`NT?9ZZKx$#$ZZ}@2y+X%z4 zWK7*d@&{Z&S2iI9Nqf9R`Mh~Lq+ztn9RhgmsVjoY2uUqDzDpJomQKe%_Ynt$QTe1A@3qm~kqVKkr`f`s z?0(WIF=1e3!^4Cqc2=~9z>fT@Ch(La#KQ`d!Lo|dAMsoWBX(MNHL<_okD`r)8KHo;*Wiif# zTwRnn#CUz565z^0dI-kJ5Y}yM<;qB%{iA_LJRl!`Pt4G+;cH=TUWC!U0BWJ3F(1Mi z$9Tj&;Fg|vJ1#}*K$=4S77~TI?90FV376)V3@QOxr+@D#v~c`<*&mMk%#H$V&1uaU^eN}1%c^AmxJHz;Wle<*V6gVrjWk-P zCR@60=I%6#4!W&}hBoEWt*EYiW%VLD{64_kw{)qeU^PJW+bgvFf~w1^NAXi~7o&kQ zGG&U`;rB*Et7WOyX0%;=7gCELxtrAq*PjH!Onz$NlwYKG9g~lPs%=;dI@_Jj>aPzw z@^09VcTdLu&878?B1^FoWX>ZISrPdq zsJ@$mj&~pJklgs%=T)dC_=LkpZhOXBfyBdkl!xoH{F=p3Q8m&}9)ZJms;UZvQ9*?T z9Hwxx*?B`D_;Cy@C@9mMF(O52OP7CMb{KaStE-bRk4%No(U*Ca&A;h};q326mS#>- zB?S1%T$Mg#*SlNsnCPncj)xS@zt9jFAP0WV@uekj_VP-dIQf#s+=G&4DaGnK9deqE zpW8CrF8jb!KcKvy#{c1(dtPXOtpUNlNY9FHyPnK8z+4TA_GTNG*@`(JKD7#NNT5p@ zN#<@>4<0Y{53USa!}8?Ol|KP7d*;Y|Z1_~6=$Qj=sQB^Gi8vQ}K5i%P2dDaV`@ecQ z{kgx_x44li*K{{(W@X89U;Oi6Aq;jUcdcArW^1k~UJ9fBp zGY;X8^`rC?2^U`tzr=P^nS178UL*Y)V!lCtsRx#{G~M*be>x!jm{^{-Qh>bDC9>%B+2 ziY2oN4zwpfoOMi>=sVyA1Qp8oLi1P!k-yTu7fM|X8qpRSY!|-959l1$b5QIa);DFy z1h<bJ%@>zc=>K%h~PU1VTZr=b@Q%ja_npek{$80Pd z3bZ$ra7~u1L_Ta2cxN3^f-^xQJ6XK70TfDh)A?^O7~pX<6nw8$5L{C`E9Gi0I9n;x zcA)It@TJ?!;aoG33X<-w z+>z@Mjn;}3KGEUC{WRN_*`*SgxB=oQG`2HAR7~BM*-G(GkNq^xf zr6~N2pk$J47sFf5x7yIV@{4afs4J(PTZ;j$XtEYp!P3{?^KWaederk5oA=rI?69q% z*dR`;;z-mg`J|sJz6(;d9b7fwGNS|hZCgyrBAw%Vbz#HMKvZFN4Q5b*_L^|&Eh9p$ zK+_`Y{M%}7ETo!@T$#>-Y_H;|3VQC$dHK*Xn&&J<3WSaizLn83oC;izas%gKM;p^0Ry%lQ$44%I69ggUS^x!SFB=NrA@VJ-#5AW*+o-|^` zvc2_K#Sy6R%~s4zSBTf|Ik`Z~UASJO4wkUw-;`jJ>vyt(Aai1wDYoO${YN5mb@MfU zbVVn1LV#+A)f@ex@8~X?mH2(xY5~>!PSRz<1DEUS+0W1im5!Wo;o?-5KXm_0kE#=7hiAkEq*~%zD zJ+x%sE?(DP zIf4*drAQqX5$$gAniD&Ob)IJGB8Igw;1hRd&_cr=Zk_D#;jI0aTUze zd$53x$t7!PhC16vtq7yB35_|Du#&2c07wwj<)IxGD>DD(~ zwsaKz7@>j1L{yVtQ`nj+eB8<)T}9n!Z==}LNL~wg#kE7;NxIe^sVD1I$3NR+EMBjP z3<8^GQgDwI60mr>tJeGzJ?MsyID>BKCQ0q^gd5~bL#^l%qCGymI(&6_IUK*gIFT7G zB*|huCHq0O{cI)?xGyIOV=>J zOz7>x{>u3-Zo%9Zm{nQP!pH0@AducN{K;EbZbM#lkS%ZPidCS-9(KYq-%v$@&Gp*Z zZ?F-6668_wldwP8mh832JH!7Zb8+bA1uUq8eiy*tPbg*ZEqdz)G78aVEZK?JuzDK` z9b|XpgYPVurssUN@;442dR_sJXe;fCZx3UR0I`esJg;g z?_#)yG}R6?M0T0n$xji2{)S$v1F{x~HaC{>8C4C2*{Y`oUy8i5gFnJ+G|Y4M7tPIx z8_Zg57SlSb$^3nl+x8{--TK;ue_C*ufds|B!-T&U&cm=#=T$2c!VYLdk_-Tuhycg|>U~@I4@mpJ%j5aFCom3w z-jj*NPED;bcul44-yI1H($03iiep20Jk_1hCf^tz7-zK8V(dtZ8Zl`;DwIo7?SCv> ze&2H(>PRT?iZVFZ@=ME3QKyk4m6s44wusJW(5AS=-;(*LfHA!K#2xIN1FjgLxn;44_T`{4~0;**Tb45AUGFrNoki_%Ury#oZ z?ey#l>C+rG0G!$c+Re!6JznfYys=FxbufouOg-?$nk+F5n~-Hr==;#01u{`et;AJk zdK7JQg(cq)NqZL*3#OW+t4PT`TQECd34haT(qv?nc<8fwYYPNc+TRK67O2>S?qH+K zESO(EjuxH`9zAzol74s4M%xzJ7Q7&w`4&n#wi_&`aI$Q~da#>50|Pv>Qti!`0c94v zTbEyFh$gKRN9EUrDVdf_y@b)riAwkt3#%K>^q*<-V*&+AZ`j(<9Pil`dzn_)`M@<$ z2d4%v(v)`s=7wvH-khFY439v(RVblUT%E)%m7Gc21mVa-z2CPzlP*bmA|U&4WSTye zqofc&l?})KWRW_*S;25ny?e*iq4gc;%5ZZ|CHhZW)2T%RNoW`jt4n>V<3k;IAk;jA z({D_vfIX*(KM_kF$ElT;Z-rwkfVbe-rdBn53i#T${!B^=noC$S1UStx$1LNYZO_3i zkSQExNUYE{$D3$!|=`=CJbX(+N z8Cl7rBLNGraJ)Ne_kmHvNpS1xh-x`c;$}gyXB4>!VReW^J6fTwIeY zbB9#IDN>`$ir&;Gkl7-Knih5I18LQt;-8IYfBN-L^FNt?di$q$f4Y3O^EFYHQ_Wf} zE6HXwn$e)E=!8?!`!LFRuaMMX)$8YJYF)?k41;f2iW68S+jF~bh6C6|f&PEHOE*e( z9LxOXb&HB=N`s)%;f9$nZ+)BjTPN~2yth~f!k(^Y#<-J%SConb2?8K)N0x$k8)cQZ zwMu$u;q(H*3~!NU9=~E&eo+LFzZ>$j(4tNvuT_p#vdTbu@fg*aN>gPjQ`|e#>~1Ji zAm%F(S5zccP$rhCszr5mzB0O+LOM}vvqjy-D%69xpjm;G6tdsMYmMJ%jB-E73 ze5(G6;%L6aojq2Y(HlM=Co7?r_=N!cZ7s1hx}mV?ll^_5bhx`M*f&EX2k* zL9bN*mY9w0@a>~DI?+inUK6DNCG(#~YZ=+72q&dXFoo_L#paqk3&;hP_z=0ItRS=z z$qFJYGje8fQQ5>nE6X#>V7Q{Oh(<2C)%0eq%wl~by>?aZp^W>egepqV#X5XQ3KFJu zy`06MfE`~Q5^J9ONX9}16W6J*lqizkoA7&FBEGX#r-upNMp!`YuvSGDWAa#nZumA3 z5KemL67o^DS!jj;L43IwM^nM8VEEy+Vnqp6)s*Nwx#3Q-Y%i$`G*A7IQgC>_z(6dP zLE_x}J^8Wa@R%|V!Vu^ z(NA%_o^Q`=*Kj0n;mNJp$Z@0!)^q!#T?MVl4ZcoOl5)Hb>niT*qNAzgaAlobUG1i- z>{+&Js~oq5P^8@2l{w{ANerLR$7W$mWyGW^QCXR|R3tL15u1FXQ^>cb^s@Q9Yo%s$ zTIHGhsBBoUmr$k)4A&0txFAc_XaTG#YL4bSGf_$>Rc*t2LAaYx8nKhC04J$pl?S>^ zfv$fN`)=#6n?d*>9M^m9S_ehnXJlB={8z4T_{V6Q~ZcvFD=ZuN9stH1`X_q z^tisVBgNAfie6czJ(UmqkBR$iW|zpll{U2s;VV~T-&3u4NSN6yXZdk$*0`iPBh-Qbp&*8Nkz<0$% zr4(ZzQH;X!Xv(T5Msp@=*-RJC!VoGowNs3CS6b=BD@!q)W|5zBi;SdrN(>BP_d7Pp zg5^LB(Bars8$y3m8cly&%|J>{fGoOX?g7KMOK!q&5xm^nGrb808&Wa3W!UuZJt{8a zzvds~K~tUfE*o_H9)wZN^HmBj+u0x}Uu8omxP7F_l_!+ENgC13PkS9!w}joj#~3s0 z?V+p?=A$)>So$hlZHzGxCv={##6=2Da(RB3H0^Q9$*YW4Q-eq8YRd<@^Rfafv$Ah& zg*GETI?X)8ta(C~>f2`>u9}Po&!L*5;bkoO?B10F`a^ zrTfYCZrv)hv?>?Cwp~>=;Q#%Mub{wX zid*K149yMGvgStChf4}FM??7r>x&&SN0;)$zpk)Ws=cd70TfX(m7B~Sk;+wfDwkZD z8BMlF!A?}NqxB6BU*#Hy-O=@C3XoNoq0|afW|^UG(Rm^*ss^R;eO+ZqDx8=LYnOSv zC2AHJQ7qb4Tm?@hJ3~fEnK+XoY*rOZp8Ujqzq=eVk#`F7!s8A$z?dQaRjR{99g&jtY3e|pI`Lj z-@I&^3+ptG7TqSGj8H%uM78b&?mY=G+lXRH)L`CUb@^Q}V(U`K3j`QjK|}5;Ue(cvqEvti(r6>tg?SkqNLI zmXA!+5-%n|iJJPzVzxGWdRLhKBi#oSW|QZ>A}I} z?^J;?{@r*U%yLyM(>gjLQ9}V54H~~o&3}!1Blzb|JufGmJgEOB?OMJrrC#0TkHZ7w zM)^8LM28X)>N(sZ$LxLznslV~dpzf_8-UXk{h1tvHXwFD5}ls1{b5>Kb*Q7xm;NK= ze_!0eTjX(Cp*MHd@1r>Xd%M%$AISONJA;FEPv?K%#`6L1U}-Hp#6KTvTrR4sTvh9} z=uRXkz;;NzCSJV-uMbVrW_i`ASuuzs4*4AyjKOUI_BNLjegRux|2w|Kacq5cZk8T)Wl|5!tboDKJ4czFD7*qWP|CRdzMYw0_|$J&iIw-y+= z4`m};V`65k*WQ+V_pOB&*wmUSl$Zf4v%GEr$Fau;6;-O&E>^B#dXps@S|ewhdX7=F zUOTQ_QeF$w!su&YSZxc|gyrbQ>i_*yul2muY5t%6R=Y|55BTdv>i{cXFKx%6=DfV_Me zrFn@yf*yo>Xye1n3Tn076S`REWY&&Y zG8qR(Ngc_80z3zpT}M`|Y=DBe_zlRjfbFM?d*aS z*7!hcd?bHNZZuI+czW9hkMM%mMC6ocM>x>vHsr(yDLv+BiFS7}R);I3q6AU287)BT uM?d<}kAC!{AN}Y@Kl;&+e)OXs{pd$O`q7Vm^rN3||NQ^f{-jg@a038%{<`P@ diff --git a/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.22-py3-none-any.whl b/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.22-py3-none-any.whl deleted file mode 100644 index 1e2f6967dc757ea0bfcd274529a855107502b09c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 48859 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ð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 diff --git a/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.22.tar.gz b/litellm-proxy-extras/dist/litellm_proxy_extras-0.4.22.tar.gz deleted file mode 100644 index 1864c77ddda45e4f7605bdc7283e60aa80d7e65d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22506 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 D71Id9 diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20251219110931_add_deleted_keys_and_deleted_teams_tables/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20251219110931_add_deleted_keys_and_deleted_teams_tables/migration.sql deleted file mode 100644 index 6ca66ddaad2..00000000000 --- a/litellm-proxy-extras/litellm_proxy_extras/migrations/20251219110931_add_deleted_keys_and_deleted_teams_tables/migration.sql +++ /dev/null @@ -1,117 +0,0 @@ --- CreateTable -CREATE TABLE "LiteLLM_DeletedTeamTable" ( - "id" TEXT NOT NULL, - "team_id" TEXT NOT NULL, - "team_alias" TEXT, - "organization_id" TEXT, - "object_permission_id" TEXT, - "admins" TEXT[], - "members" TEXT[], - "members_with_roles" JSONB NOT NULL DEFAULT '{}', - "metadata" JSONB NOT NULL DEFAULT '{}', - "max_budget" DOUBLE PRECISION, - "spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0, - "models" TEXT[], - "max_parallel_requests" INTEGER, - "tpm_limit" BIGINT, - "rpm_limit" BIGINT, - "budget_duration" TEXT, - "budget_reset_at" TIMESTAMP(3), - "blocked" BOOLEAN NOT NULL DEFAULT false, - "model_spend" JSONB NOT NULL DEFAULT '{}', - "model_max_budget" JSONB NOT NULL DEFAULT '{}', - "team_member_permissions" TEXT[] DEFAULT ARRAY[]::TEXT[], - "model_id" INTEGER, - "created_at" TIMESTAMP(3), - "updated_at" TIMESTAMP(3), - "deleted_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_by" TEXT, - "deleted_by_api_key" TEXT, - "litellm_changed_by" TEXT, - - CONSTRAINT "LiteLLM_DeletedTeamTable_pkey" PRIMARY KEY ("id") -); - --- CreateTable -CREATE TABLE "LiteLLM_DeletedVerificationToken" ( - "id" TEXT NOT NULL, - "token" TEXT NOT NULL, - "key_name" TEXT, - "key_alias" TEXT, - "soft_budget_cooldown" BOOLEAN NOT NULL DEFAULT false, - "spend" DOUBLE PRECISION NOT NULL DEFAULT 0.0, - "expires" TIMESTAMP(3), - "models" TEXT[], - "aliases" JSONB NOT NULL DEFAULT '{}', - "config" JSONB NOT NULL DEFAULT '{}', - "user_id" TEXT, - "team_id" TEXT, - "permissions" JSONB NOT NULL DEFAULT '{}', - "max_parallel_requests" INTEGER, - "metadata" JSONB NOT NULL DEFAULT '{}', - "blocked" BOOLEAN, - "tpm_limit" BIGINT, - "rpm_limit" BIGINT, - "max_budget" DOUBLE PRECISION, - "budget_duration" TEXT, - "budget_reset_at" TIMESTAMP(3), - "allowed_cache_controls" TEXT[] DEFAULT ARRAY[]::TEXT[], - "allowed_routes" TEXT[] DEFAULT ARRAY[]::TEXT[], - "model_spend" JSONB NOT NULL DEFAULT '{}', - "model_max_budget" JSONB NOT NULL DEFAULT '{}', - "budget_id" TEXT, - "organization_id" TEXT, - "object_permission_id" TEXT, - "created_at" TIMESTAMP(3), - "created_by" TEXT, - "updated_at" TIMESTAMP(3), - "updated_by" TEXT, - "rotation_count" INTEGER DEFAULT 0, - "auto_rotate" BOOLEAN DEFAULT false, - "rotation_interval" TEXT, - "last_rotation_at" TIMESTAMP(3), - "key_rotation_at" TIMESTAMP(3), - "deleted_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, - "deleted_by" TEXT, - "deleted_by_api_key" TEXT, - "litellm_changed_by" TEXT, - - CONSTRAINT "LiteLLM_DeletedVerificationToken_pkey" PRIMARY KEY ("id") -); - --- CreateIndex -CREATE INDEX "LiteLLM_DeletedTeamTable_team_id_idx" ON "LiteLLM_DeletedTeamTable"("team_id"); - --- CreateIndex -CREATE INDEX "LiteLLM_DeletedTeamTable_deleted_at_idx" ON "LiteLLM_DeletedTeamTable"("deleted_at"); - --- CreateIndex -CREATE INDEX "LiteLLM_DeletedTeamTable_organization_id_idx" ON "LiteLLM_DeletedTeamTable"("organization_id"); - --- CreateIndex -CREATE INDEX "LiteLLM_DeletedTeamTable_team_alias_idx" ON "LiteLLM_DeletedTeamTable"("team_alias"); - --- CreateIndex -CREATE INDEX "LiteLLM_DeletedTeamTable_created_at_idx" ON "LiteLLM_DeletedTeamTable"("created_at"); - --- CreateIndex -CREATE INDEX "LiteLLM_DeletedVerificationToken_token_idx" ON "LiteLLM_DeletedVerificationToken"("token"); - --- CreateIndex -CREATE INDEX "LiteLLM_DeletedVerificationToken_deleted_at_idx" ON "LiteLLM_DeletedVerificationToken"("deleted_at"); - --- CreateIndex -CREATE INDEX "LiteLLM_DeletedVerificationToken_user_id_idx" ON "LiteLLM_DeletedVerificationToken"("user_id"); - --- CreateIndex -CREATE INDEX "LiteLLM_DeletedVerificationToken_team_id_idx" ON "LiteLLM_DeletedVerificationToken"("team_id"); - --- CreateIndex -CREATE INDEX "LiteLLM_DeletedVerificationToken_organization_id_idx" ON "LiteLLM_DeletedVerificationToken"("organization_id"); - --- CreateIndex -CREATE INDEX "LiteLLM_DeletedVerificationToken_key_alias_idx" ON "LiteLLM_DeletedVerificationToken"("key_alias"); - --- CreateIndex -CREATE INDEX "LiteLLM_DeletedVerificationToken_created_at_idx" ON "LiteLLM_DeletedVerificationToken"("created_at"); - diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 71b398c59a4..56fe093a8bc 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -132,49 +132,6 @@ model LiteLLM_TeamTable { object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id]) } -// Audit table for deleted teams - preserves spend and team information for historical tracking -model LiteLLM_DeletedTeamTable { - id String @id @default(uuid()) - team_id String // Original team_id - team_alias String? - organization_id String? - object_permission_id String? - admins String[] - members String[] - members_with_roles Json @default("{}") - metadata Json @default("{}") - max_budget Float? - spend Float @default(0.0) - models String[] - max_parallel_requests Int? - tpm_limit BigInt? - rpm_limit BigInt? - budget_duration String? - budget_reset_at DateTime? - blocked Boolean @default(false) - model_spend Json @default("{}") - model_max_budget Json @default("{}") - router_settings Json? @default("{}") - team_member_permissions String[] @default([]) - model_id Int? // id for LiteLLM_ModelTable -> stores team-level model aliases - - // Original timestamps from team creation/updates - created_at DateTime? @map("created_at") - updated_at DateTime? @map("updated_at") - - // Deletion metadata - deleted_at DateTime @default(now()) @map("deleted_at") - deleted_by String? @map("deleted_by") // User who deleted the team - deleted_by_api_key String? @map("deleted_by_api_key") // API key hash that performed the deletion - litellm_changed_by String? @map("litellm_changed_by") // From litellm-changed-by header if provided - - @@index([team_id]) - @@index([deleted_at]) - @@index([organization_id]) - @@index([team_alias]) - @@index([created_at]) -} - // Track spend, rate limit, budget Users model LiteLLM_UserTable { user_id String @id @@ -302,62 +259,6 @@ model LiteLLM_VerificationToken { object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id]) } -// Audit table for deleted keys - preserves spend and key information for historical tracking -model LiteLLM_DeletedVerificationToken { - id String @id @default(uuid()) - token String // Original token (hashed) - key_name String? - key_alias String? - soft_budget_cooldown Boolean @default(false) - spend Float @default(0.0) - expires DateTime? - models String[] - aliases Json @default("{}") - config Json @default("{}") - user_id String? - team_id String? - permissions Json @default("{}") - max_parallel_requests Int? - metadata Json @default("{}") - blocked Boolean? - tpm_limit BigInt? - rpm_limit BigInt? - max_budget Float? - budget_duration String? - budget_reset_at DateTime? - allowed_cache_controls String[] @default([]) - allowed_routes String[] @default([]) - model_spend Json @default("{}") - model_max_budget Json @default("{}") - router_settings Json? @default("{}") - budget_id String? - organization_id String? - object_permission_id String? - created_at DateTime? // Original creation timestamp - created_by String? // Original creator - updated_at DateTime? // Last update timestamp before deletion - updated_by String? // Last user who updated before deletion - rotation_count Int? @default(0) - auto_rotate Boolean? @default(false) - rotation_interval String? - last_rotation_at DateTime? - key_rotation_at DateTime? - - // Deletion metadata - deleted_at DateTime @default(now()) @map("deleted_at") - deleted_by String? @map("deleted_by") // User who deleted the key - deleted_by_api_key String? @map("deleted_by_api_key") // API key hash that performed the deletion - litellm_changed_by String? @map("litellm_changed_by") // From litellm-changed-by header if provided - - @@index([token]) - @@index([deleted_at]) - @@index([user_id]) - @@index([team_id]) - @@index([organization_id]) - @@index([key_alias]) - @@index([created_at]) -} - model LiteLLM_EndUserTable { user_id String @id alias String? // admin-facing alias diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 0f2cd4b3a79..3c6e2105261 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1723,21 +1723,6 @@ class LiteLLM_TeamTableCachedObj(LiteLLM_TeamTable): last_refreshed_at: Optional[float] = None -class LiteLLM_DeletedTeamTable(LiteLLM_TeamTable): - """ - Recording of deleted teams for audit purposes. Mirrors LiteLLM_TeamTable - plus metadata captured at deletion time. - """ - - id: Optional[str] = None - deleted_at: Optional[datetime] = None - deleted_by: Optional[str] = None - deleted_by_api_key: Optional[str] = None - litellm_changed_by: Optional[str] = None - - model_config = ConfigDict(protected_namespaces=()) - - class TeamRequest(LiteLLMPydanticObjectBase): teams: List[str] @@ -2132,21 +2117,6 @@ class LiteLLM_VerificationToken(LiteLLMPydanticObjectBase): model_config = ConfigDict(protected_namespaces=()) -class LiteLLM_DeletedVerificationToken(LiteLLM_VerificationToken): - """ - Recording of deleted keys for audit purposes. Mirrors LiteLLM_VerificationToken - plus metadata captured at deletion time. - """ - - id: Optional[str] = None - deleted_at: Optional[datetime] = None - deleted_by: Optional[str] = None - deleted_by_api_key: Optional[str] = None - litellm_changed_by: Optional[str] = None - - model_config = ConfigDict(protected_namespaces=()) - - class LiteLLM_VerificationTokenView(LiteLLM_VerificationToken): """ Combined view of litellm verification token + litellm team table (select values) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 2672c41893d..1850ffa2560 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -412,19 +412,6 @@ async def new_user( status_code=403, detail="License is over limit. Please contact support@berri.ai to upgrade your license.", ) - - # Only proxy admins can create administrative users - # Check if user_api_key_dict is actually a UserAPIKeyAuth instance (not a Depends object) - # This can happen when the function is called directly in tests - if ( - data.user_role in [LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY] - and isinstance(user_api_key_dict, UserAPIKeyAuth) - and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN - ): - raise HTTPException( - status_code=403, - detail=f"Only proxy admins can create administrative users (proxy_admin, proxy_admin_viewer). Attempted to create user with role: {data.user_role}. Your role: {user_api_key_dict.user_role}" - ) data_json = data.json() # type: ignore data_json = _update_internal_new_user_params(data_json, data) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 3c1053c7b01..39b6774a61c 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -16,7 +16,7 @@ import secrets import traceback import yaml from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List, Literal, Optional, Tuple, cast +from typing import List, Literal, Optional, Tuple, cast from litellm.litellm_core_utils.safe_json_dumps import safe_dumps import fastapi from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, status @@ -1791,10 +1791,6 @@ async def delete_key_fn( if prisma_client is None: raise Exception("Not connected to DB!") - # Normalize litellm_changed_by: if it's a Header object or not a string, convert to None - if litellm_changed_by is not None and not isinstance(litellm_changed_by, str): - litellm_changed_by = None - ## only allow user to delete keys they own verbose_proxy_logger.debug( f"user_api_key_dict.user_role: {user_api_key_dict.user_role}" @@ -1807,7 +1803,6 @@ async def delete_key_fn( tokens=data.keys, user_api_key_cache=user_api_key_cache, user_api_key_dict=user_api_key_dict, - litellm_changed_by=litellm_changed_by, ) num_keys_to_be_deleted = len(data.keys) deleted_keys = data.keys @@ -1817,7 +1812,6 @@ async def delete_key_fn( prisma_client=prisma_client, user_api_key_cache=user_api_key_cache, user_api_key_dict=user_api_key_dict, - litellm_changed_by=litellm_changed_by, ) num_keys_to_be_deleted = len(data.key_aliases) deleted_keys = data.key_aliases @@ -2439,7 +2433,6 @@ async def delete_verification_tokens( tokens: List, user_api_key_cache: DualCache, user_api_key_dict: UserAPIKeyAuth, - litellm_changed_by: Optional[str] = None, ) -> Tuple[Optional[Dict], List[LiteLLM_VerificationToken]]: """ Helper that deletes the list of tokens from the database @@ -2476,43 +2469,38 @@ async def delete_verification_tokens( detail={"error": "No keys found"}, ) - if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value: - authorized_keys = _keys_being_deleted - else: - authorized_keys = [] - for key in _keys_being_deleted: - if await can_modify_verification_token( - key_info=key, - user_api_key_cache=user_api_key_cache, - user_api_key_dict=user_api_key_dict, - prisma_client=prisma_client, - ): - authorized_keys.append(key) - else: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail={ - "error": "You are not authorized to delete this key" - }, - ) - await _persist_deleted_verification_tokens( - keys=authorized_keys, - prisma_client=prisma_client, - user_api_key_dict=user_api_key_dict, - litellm_changed_by=litellm_changed_by, - ) - + # Assuming 'db' is your Prisma Client instance + # check if admin making request - don't filter by user-id if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value: deleted_tokens = await prisma_client.delete_data(tokens=tokens) + # else else: - deletion_tasks = [ - prisma_client.delete_data(tokens=[key.token]) - for key in authorized_keys - ] - await asyncio.gather(*deletion_tasks) + tasks = [] + deleted_tokens = [] + for key in _keys_being_deleted: - deleted_tokens = [key.token for key in authorized_keys] - if len(deleted_tokens) != len(tokens): + async def _delete_key(key: LiteLLM_VerificationToken): + if await can_modify_verification_token( + key_info=key, + user_api_key_cache=user_api_key_cache, + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, + ): + await prisma_client.delete_data(tokens=[key.token]) + deleted_tokens.append(key.token) + else: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "error": "You are not authorized to delete this key" + }, + ) + + tasks.append(_delete_key(key)) + await asyncio.gather(*tasks) + + _num_deleted_tokens = len(deleted_tokens) + if _num_deleted_tokens != len(tokens): failed_tokens = [ token for token in tokens if token not in deleted_tokens ] @@ -2540,81 +2528,11 @@ async def delete_verification_tokens( return {"deleted_keys": deleted_tokens}, _keys_being_deleted -def _transform_verification_tokens_to_deleted_records( - keys: List[LiteLLM_VerificationToken], - user_api_key_dict: UserAPIKeyAuth, - litellm_changed_by: Optional[str] = None, -) -> List[Dict[str, Any]]: - """Transform verification tokens into deleted token records ready for persistence.""" - if not keys: - return [] - - deleted_at = datetime.now(timezone.utc) - records = [] - for key in keys: - key_payload = key.model_dump() - deleted_record = LiteLLM_DeletedVerificationToken( - **key_payload, - deleted_at=deleted_at, - deleted_by=user_api_key_dict.user_id, - deleted_by_api_key=user_api_key_dict.api_key, - litellm_changed_by=litellm_changed_by, - ) - record = deleted_record.model_dump() - - # Map org_id to organization_id (model uses org_id, but schema expects organization_id) - org_id_value = record.pop("org_id", None) - if org_id_value is not None: - record["organization_id"] = org_id_value - - for json_field in ["aliases", "config", "permissions", "metadata", "model_spend", "model_max_budget", "router_settings"]: - if json_field in record and record[json_field] is not None: - record[json_field] = json.dumps(record[json_field]) - - for rel_key in ("litellm_budget_table", "litellm_organization_table", "object_permission", "id"): - record.pop(rel_key, None) - - records.append(record) - - return records - - -async def _save_deleted_verification_token_records( - records: List[Dict[str, Any]], - prisma_client: PrismaClient, -) -> None: - """Save deleted verification token records to the database.""" - if not records: - return - await prisma_client.db.litellm_deletedverificationtoken.create_many( - data=records - ) - - -async def _persist_deleted_verification_tokens( - keys: List[LiteLLM_VerificationToken], - prisma_client: PrismaClient, - user_api_key_dict: UserAPIKeyAuth, - litellm_changed_by: Optional[str] = None, -) -> None: - """Persist deleted verification token records by transforming and saving them.""" - records = _transform_verification_tokens_to_deleted_records( - keys=keys, - user_api_key_dict=user_api_key_dict, - litellm_changed_by=litellm_changed_by, - ) - await _save_deleted_verification_token_records( - records=records, - prisma_client=prisma_client, - ) - - async def delete_key_aliases( key_aliases: List[str], user_api_key_cache: DualCache, prisma_client: PrismaClient, user_api_key_dict: UserAPIKeyAuth, - litellm_changed_by: Optional[str] = None, ) -> Tuple[Optional[Dict], List[LiteLLM_VerificationToken]]: _keys_being_deleted = await prisma_client.db.litellm_verificationtoken.find_many( where={"key_alias": {"in": key_aliases}} @@ -2625,7 +2543,6 @@ async def delete_key_aliases( tokens=tokens, user_api_key_cache=user_api_key_cache, user_api_key_dict=user_api_key_dict, - litellm_changed_by=litellm_changed_by, ) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index c606420cc05..d1549b51167 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -34,10 +34,8 @@ from litellm.proxy._types import ( LiteLLM_OrganizationTableWithMembers, LiteLLM_TeamMembership, LiteLLM_TeamTable, - LiteLLM_DeletedTeamTable, LiteLLM_TeamTableCachedObj, LiteLLM_UserTable, - LiteLLM_VerificationToken, LitellmTableNames, LitellmUserRoles, Member, @@ -2020,28 +2018,6 @@ async def team_member_delete( ## DELETE KEYS CREATED BY USER FOR THIS TEAM if user_ids_to_delete: - from litellm.proxy.management_endpoints.key_management_endpoints import ( - _persist_deleted_verification_tokens, - ) - - # Fetch keys before deletion to persist them - keys_to_delete: List[LiteLLM_VerificationToken] = ( - await prisma_client.db.litellm_verificationtoken.find_many( - where={ - "user_id": {"in": list(user_ids_to_delete)}, - "team_id": data.team_id, - } - ) - ) - - if keys_to_delete: - await _persist_deleted_verification_tokens( - keys=keys_to_delete, - prisma_client=prisma_client, - user_api_key_dict=user_api_key_dict, - litellm_changed_by=None, - ) - await prisma_client.db.litellm_verificationtoken.delete_many( where={ "user_id": {"in": list(user_ids_to_delete)}, @@ -2427,13 +2403,6 @@ async def delete_team( team_row_pydantic = LiteLLM_TeamTable(**team_row_base.model_dump()) team_rows.append(team_row_pydantic) - await _persist_deleted_team_records( - teams=team_rows, - prisma_client=prisma_client, - user_api_key_dict=user_api_key_dict, - litellm_changed_by=litellm_changed_by, - ) - # Enterprise Feature - Audit Logging. Enable with litellm.store_audit_logs = True # we do this after the first for loop, since first for loop is for validation. we only want this inserted after validation passes if litellm.store_audit_logs is True: @@ -2469,25 +2438,6 @@ async def delete_team( # End of Audit logging ## DELETE ASSOCIATED KEYS - # Fetch keys before deletion to persist them - from litellm.proxy.management_endpoints.key_management_endpoints import ( - _persist_deleted_verification_tokens, - ) - - keys_to_delete: List[LiteLLM_VerificationToken] = ( - await prisma_client.db.litellm_verificationtoken.find_many( - where={"team_id": {"in": data.team_ids}} - ) - ) - - if keys_to_delete: - await _persist_deleted_verification_tokens( - keys=keys_to_delete, - prisma_client=prisma_client, - user_api_key_dict=user_api_key_dict, - litellm_changed_by=litellm_changed_by, - ) - await prisma_client.delete_data(team_id_list=data.team_ids, table_name="key") # ## DELETE TEAM MEMBERSHIPS @@ -2516,70 +2466,6 @@ async def delete_team( return deleted_teams - -def _transform_teams_to_deleted_records( - teams: List[LiteLLM_TeamTable], - user_api_key_dict: UserAPIKeyAuth, - litellm_changed_by: Optional[str] = None, -) -> List[Dict[str, Any]]: - """Transform teams into deleted team records ready for persistence.""" - if not teams: - return [] - - deleted_at = datetime.now(timezone.utc) - records = [] - for team in teams: - team_payload = team.model_dump() - deleted_record = LiteLLM_DeletedTeamTable( - **team_payload, - deleted_at=deleted_at, - deleted_by=user_api_key_dict.user_id, - deleted_by_api_key=user_api_key_dict.api_key, - litellm_changed_by=litellm_changed_by, - ) - record = deleted_record.model_dump() - - for json_field in ["members_with_roles", "metadata", "model_spend", "model_max_budget", "router_settings"]: - if json_field in record and record[json_field] is not None: - record[json_field] = json.dumps(record[json_field]) - - for rel_key in ("litellm_model_table", "object_permission", "id"): - record.pop(rel_key, None) - - records.append(record) - - return records - - -async def _save_deleted_team_records( - records: List[Dict[str, Any]], - prisma_client: PrismaClient, -) -> None: - """Save deleted team records to the database.""" - if not records: - return - await prisma_client.db.litellm_deletedteamtable.create_many( - data=records - ) - - -async def _persist_deleted_team_records( - teams: List[LiteLLM_TeamTable], - prisma_client: PrismaClient, - user_api_key_dict: UserAPIKeyAuth, - litellm_changed_by: Optional[str] = None, -) -> None: - """Persist deleted team records by transforming and saving them.""" - records = _transform_teams_to_deleted_records( - teams=teams, - user_api_key_dict=user_api_key_dict, - litellm_changed_by=litellm_changed_by, - ) - await _save_deleted_team_records( - records=records, - prisma_client=prisma_client, - ) - def validate_membership( user_api_key_dict: UserAPIKeyAuth, team_table: LiteLLM_TeamTable ): diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 71b398c59a4..56fe093a8bc 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -132,49 +132,6 @@ model LiteLLM_TeamTable { object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id]) } -// Audit table for deleted teams - preserves spend and team information for historical tracking -model LiteLLM_DeletedTeamTable { - id String @id @default(uuid()) - team_id String // Original team_id - team_alias String? - organization_id String? - object_permission_id String? - admins String[] - members String[] - members_with_roles Json @default("{}") - metadata Json @default("{}") - max_budget Float? - spend Float @default(0.0) - models String[] - max_parallel_requests Int? - tpm_limit BigInt? - rpm_limit BigInt? - budget_duration String? - budget_reset_at DateTime? - blocked Boolean @default(false) - model_spend Json @default("{}") - model_max_budget Json @default("{}") - router_settings Json? @default("{}") - team_member_permissions String[] @default([]) - model_id Int? // id for LiteLLM_ModelTable -> stores team-level model aliases - - // Original timestamps from team creation/updates - created_at DateTime? @map("created_at") - updated_at DateTime? @map("updated_at") - - // Deletion metadata - deleted_at DateTime @default(now()) @map("deleted_at") - deleted_by String? @map("deleted_by") // User who deleted the team - deleted_by_api_key String? @map("deleted_by_api_key") // API key hash that performed the deletion - litellm_changed_by String? @map("litellm_changed_by") // From litellm-changed-by header if provided - - @@index([team_id]) - @@index([deleted_at]) - @@index([organization_id]) - @@index([team_alias]) - @@index([created_at]) -} - // Track spend, rate limit, budget Users model LiteLLM_UserTable { user_id String @id @@ -302,62 +259,6 @@ model LiteLLM_VerificationToken { object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id]) } -// Audit table for deleted keys - preserves spend and key information for historical tracking -model LiteLLM_DeletedVerificationToken { - id String @id @default(uuid()) - token String // Original token (hashed) - key_name String? - key_alias String? - soft_budget_cooldown Boolean @default(false) - spend Float @default(0.0) - expires DateTime? - models String[] - aliases Json @default("{}") - config Json @default("{}") - user_id String? - team_id String? - permissions Json @default("{}") - max_parallel_requests Int? - metadata Json @default("{}") - blocked Boolean? - tpm_limit BigInt? - rpm_limit BigInt? - max_budget Float? - budget_duration String? - budget_reset_at DateTime? - allowed_cache_controls String[] @default([]) - allowed_routes String[] @default([]) - model_spend Json @default("{}") - model_max_budget Json @default("{}") - router_settings Json? @default("{}") - budget_id String? - organization_id String? - object_permission_id String? - created_at DateTime? // Original creation timestamp - created_by String? // Original creator - updated_at DateTime? // Last update timestamp before deletion - updated_by String? // Last user who updated before deletion - rotation_count Int? @default(0) - auto_rotate Boolean? @default(false) - rotation_interval String? - last_rotation_at DateTime? - key_rotation_at DateTime? - - // Deletion metadata - deleted_at DateTime @default(now()) @map("deleted_at") - deleted_by String? @map("deleted_by") // User who deleted the key - deleted_by_api_key String? @map("deleted_by_api_key") // API key hash that performed the deletion - litellm_changed_by String? @map("litellm_changed_by") // From litellm-changed-by header if provided - - @@index([token]) - @@index([deleted_at]) - @@index([user_id]) - @@index([team_id]) - @@index([organization_id]) - @@index([key_alias]) - @@index([created_at]) -} - model LiteLLM_EndUserTable { user_id String @id alias String? // admin-facing alias diff --git a/schema.prisma b/schema.prisma index 52170f2f3e6..a16380fb5f3 100644 --- a/schema.prisma +++ b/schema.prisma @@ -132,49 +132,6 @@ model LiteLLM_TeamTable { object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id]) } -// Audit table for deleted teams - preserves spend and team information for historical tracking -model LiteLLM_DeletedTeamTable { - id String @id @default(uuid()) - team_id String // Original team_id - team_alias String? - organization_id String? - object_permission_id String? - admins String[] - members String[] - members_with_roles Json @default("{}") - metadata Json @default("{}") - max_budget Float? - spend Float @default(0.0) - models String[] - max_parallel_requests Int? - tpm_limit BigInt? - rpm_limit BigInt? - budget_duration String? - budget_reset_at DateTime? - blocked Boolean @default(false) - model_spend Json @default("{}") - model_max_budget Json @default("{}") - router_settings Json? @default("{}") - team_member_permissions String[] @default([]) - model_id Int? // id for LiteLLM_ModelTable -> stores team-level model aliases - - // Original timestamps from team creation/updates - created_at DateTime? @map("created_at") - updated_at DateTime? @map("updated_at") - - // Deletion metadata - deleted_at DateTime @default(now()) @map("deleted_at") - deleted_by String? @map("deleted_by") // User who deleted the team - deleted_by_api_key String? @map("deleted_by_api_key") // API key hash that performed the deletion - litellm_changed_by String? @map("litellm_changed_by") // From litellm-changed-by header if provided - - @@index([team_id]) - @@index([deleted_at]) - @@index([organization_id]) - @@index([team_alias]) - @@index([created_at]) -} - // Track spend, rate limit, budget Users model LiteLLM_UserTable { user_id String @id @@ -302,62 +259,6 @@ model LiteLLM_VerificationToken { object_permission LiteLLM_ObjectPermissionTable? @relation(fields: [object_permission_id], references: [object_permission_id]) } -// Audit table for deleted keys - preserves spend and key information for historical tracking -model LiteLLM_DeletedVerificationToken { - id String @id @default(uuid()) - token String // Original token (hashed) - key_name String? - key_alias String? - soft_budget_cooldown Boolean @default(false) - spend Float @default(0.0) - expires DateTime? - models String[] - aliases Json @default("{}") - config Json @default("{}") - user_id String? - team_id String? - permissions Json @default("{}") - max_parallel_requests Int? - metadata Json @default("{}") - blocked Boolean? - tpm_limit BigInt? - rpm_limit BigInt? - max_budget Float? - budget_duration String? - budget_reset_at DateTime? - allowed_cache_controls String[] @default([]) - allowed_routes String[] @default([]) - model_spend Json @default("{}") - model_max_budget Json @default("{}") - router_settings Json? @default("{}") - budget_id String? - organization_id String? - object_permission_id String? - created_at DateTime? // Original creation timestamp - created_by String? // Original creator - updated_at DateTime? // Last update timestamp before deletion - updated_by String? // Last user who updated before deletion - rotation_count Int? @default(0) - auto_rotate Boolean? @default(false) - rotation_interval String? - last_rotation_at DateTime? - key_rotation_at DateTime? - - // Deletion metadata - deleted_at DateTime @default(now()) @map("deleted_at") - deleted_by String? @map("deleted_by") // User who deleted the key - deleted_by_api_key String? @map("deleted_by_api_key") // API key hash that performed the deletion - litellm_changed_by String? @map("litellm_changed_by") // From litellm-changed-by header if provided - - @@index([token]) - @@index([deleted_at]) - @@index([user_id]) - @@index([team_id]) - @@index([organization_id]) - @@index([key_alias]) - @@index([created_at]) -} - model LiteLLM_EndUserTable { user_id String @id alias String? // admin-facing alias diff --git a/tests/proxy_admin_ui_tests/test_key_management.py b/tests/proxy_admin_ui_tests/test_key_management.py index a196080eada..126718af848 100644 --- a/tests/proxy_admin_ui_tests/test_key_management.py +++ b/tests/proxy_admin_ui_tests/test_key_management.py @@ -1061,7 +1061,6 @@ async def test_list_key_helper(prisma_client): api_key="sk-1234", user_id="admin", ), - litellm_changed_by=None, ) @@ -1182,7 +1181,6 @@ async def test_list_key_helper_team_filtering(prisma_client): api_key="sk-1234", user_id="admin", ), - litellm_changed_by=None, ) diff --git a/tests/proxy_unit_tests/test_key_generate_prisma.py b/tests/proxy_unit_tests/test_key_generate_prisma.py index 1a613a3db55..e0d6b7e81bb 100644 --- a/tests/proxy_unit_tests/test_key_generate_prisma.py +++ b/tests/proxy_unit_tests/test_key_generate_prisma.py @@ -1166,10 +1166,8 @@ def test_delete_key_auth(prisma_client): asyncio.run(test()) except Exception as e: print("Got Exception", e) - # Handle different exception types - ProxyException has .message, others might have .detail or str(e) - error_message = getattr(e, "message", None) or getattr(e, "detail", None) or str(e) - print(f"Error message: {error_message}") - assert "Authentication Error" in error_message or "Invalid proxy server token" in error_message or "not found in db" in error_message + print(e.message) + assert "Authentication Error" in e.message pass @@ -2710,12 +2708,7 @@ async def test_reset_spend_authentication(prisma_client): _response = await new_user( data=NewUserRequest( tpm_limit=20, - ), - user_api_key_dict=UserAPIKeyAuth( - user_role=LitellmUserRoles.PROXY_ADMIN, - api_key=master_key, - user_id="1234", - ), + ) ) generate_key = "Bearer " + _response.key @@ -2735,12 +2728,7 @@ async def test_reset_spend_authentication(prisma_client): data=NewUserRequest( user_role=LitellmUserRoles.PROXY_ADMIN, tpm_limit=20, - ), - user_api_key_dict=UserAPIKeyAuth( - user_role=LitellmUserRoles.PROXY_ADMIN, - api_key=master_key, - user_id="1234", - ), + ) ) generate_key = "Bearer " + _response.key diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 47395a1f32e..c9a10e3c4d0 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -31,13 +31,9 @@ from litellm.proxy.management_endpoints.key_management_endpoints import ( _check_team_key_limits, _common_key_generation_helper, _list_key_helper, - _persist_deleted_verification_tokens, - _save_deleted_verification_token_records, - _transform_verification_tokens_to_deleted_records, can_modify_verification_token, check_org_key_model_specific_limits, check_team_key_model_specific_limits, - delete_verification_tokens, generate_key_helper_fn, prepare_key_update_data, validate_key_team_change, @@ -2732,364 +2728,64 @@ def test_check_org_key_model_specific_limits_org_model_tpm_overallocation(): ) -def test_transform_verification_tokens_to_deleted_records(): - from datetime import datetime, timezone - - user_api_key_dict = UserAPIKeyAuth( - user_id="user-123", - api_key="sk-test", - user_role=LitellmUserRoles.PROXY_ADMIN.value, - ) - - key1 = LiteLLM_VerificationToken( - token="hashed-token-1", - user_id="user-123", - team_id="team-456", - key_alias="test-key-1", - spend=100.0, - max_budget=1000.0, - models=["gpt-4"], - aliases={}, - config={}, - permissions={}, - metadata={"test": "value"}, - model_max_budget={}, - model_spend={}, - soft_budget_cooldown=False, - allowed_routes=[], - ) - - key2 = LiteLLM_VerificationToken( - token="hashed-token-2", - user_id="user-789", - team_id=None, - key_alias="test-key-2", - spend=50.0, - max_budget=500.0, - models=["gpt-3.5-turbo"], - aliases={"alias": "model"}, - config={"config": "value"}, - permissions={"permission": True}, - metadata={}, - model_max_budget={"gpt-4": {"budget_limit": 100.0}}, - model_spend={}, - soft_budget_cooldown=False, - allowed_routes=[], - ) - - records = _transform_verification_tokens_to_deleted_records( - keys=[key1, key2], - user_api_key_dict=user_api_key_dict, - litellm_changed_by="admin-user", - ) - - assert len(records) == 2 - assert all("deleted_at" in record for record in records) - assert all("deleted_by" in record for record in records) - assert all("deleted_by_api_key" in record for record in records) - assert all("litellm_changed_by" in record for record in records) - assert all(record["deleted_by"] == "user-123" for record in records) - assert all(record["deleted_by_api_key"] == user_api_key_dict.api_key for record in records) - assert all(record["litellm_changed_by"] == "admin-user" for record in records) - - record1 = records[0] - assert record1["token"] == "hashed-token-1" - assert record1["user_id"] == "user-123" - assert record1["team_id"] == "team-456" - assert isinstance(record1["aliases"], str) - assert isinstance(record1["config"], str) - assert isinstance(record1["permissions"], str) - assert isinstance(record1["metadata"], str) - assert "litellm_budget_table" not in record1 - assert "litellm_organization_table" not in record1 - assert "object_permission" not in record1 - assert "id" not in record1 - - record2 = records[1] - assert record2["token"] == "hashed-token-2" - assert isinstance(record2["model_max_budget"], str) - - -def test_transform_verification_tokens_to_deleted_records_empty_list(): - user_api_key_dict = UserAPIKeyAuth( - user_id="user-123", - api_key="sk-test", - user_role=LitellmUserRoles.PROXY_ADMIN.value, - ) - - records = _transform_verification_tokens_to_deleted_records( - keys=[], - user_api_key_dict=user_api_key_dict, - ) - - assert records == [] - - -@pytest.mark.asyncio -async def test_save_deleted_verification_token_records(): - mock_prisma_client = AsyncMock() - mock_create_many = AsyncMock() - mock_prisma_client.db.litellm_deletedverificationtoken.create_many = ( - mock_create_many - ) - - records = [ - { - "token": "hashed-token-1", - "user_id": "user-123", - "deleted_at": "2024-01-01T00:00:00Z", - "deleted_by": "admin", - }, - { - "token": "hashed-token-2", - "user_id": "user-456", - "deleted_at": "2024-01-01T00:00:00Z", - "deleted_by": "admin", - }, - ] - - await _save_deleted_verification_token_records( - records=records, prisma_client=mock_prisma_client - ) - - mock_create_many.assert_called_once_with(data=records) - - -@pytest.mark.asyncio -async def test_save_deleted_verification_token_records_empty_list(): - mock_prisma_client = AsyncMock() - mock_create_many = AsyncMock() - mock_prisma_client.db.litellm_deletedverificationtoken.create_many = ( - mock_create_many - ) - - await _save_deleted_verification_token_records( - records=[], prisma_client=mock_prisma_client - ) - - mock_create_many.assert_not_called() - - -@pytest.mark.asyncio -async def test_persist_deleted_verification_tokens(): - mock_prisma_client = AsyncMock() - mock_create_many = AsyncMock() - mock_prisma_client.db.litellm_deletedverificationtoken.create_many = ( - mock_create_many - ) - - user_api_key_dict = UserAPIKeyAuth( - user_id="user-123", - api_key="sk-test", - user_role=LitellmUserRoles.PROXY_ADMIN.value, - ) - - key = LiteLLM_VerificationToken( - token="hashed-token-1", - user_id="user-123", - team_id="team-456", - key_alias="test-key", - spend=100.0, - max_budget=1000.0, - models=["gpt-4"], - aliases={}, - config={}, - permissions={}, - metadata={}, - model_max_budget={}, - model_spend={}, - soft_budget_cooldown=False, - allowed_routes=[], - ) - - await _persist_deleted_verification_tokens( - keys=[key], - prisma_client=mock_prisma_client, - user_api_key_dict=user_api_key_dict, - litellm_changed_by="admin-user", - ) - - mock_create_many.assert_called_once() - call_args = mock_create_many.call_args - assert "data" in call_args.kwargs - records = call_args.kwargs["data"] - assert len(records) == 1 - assert records[0]["token"] == "hashed-token-1" - assert records[0]["deleted_by"] == "user-123" - assert records[0]["litellm_changed_by"] == "admin-user" - - -@pytest.mark.asyncio -async def test_delete_verification_tokens_persists_deleted_keys(monkeypatch): - mock_prisma_client = AsyncMock() - mock_user_api_key_cache = MagicMock() - - user_api_key_dict = UserAPIKeyAuth( - user_id="admin-user", - api_key="sk-admin", - user_role=LitellmUserRoles.PROXY_ADMIN.value, - ) - - key1 = LiteLLM_VerificationToken( - token="hashed-token-1", - user_id="user-123", - team_id="team-456", - key_alias="test-key-1", - spend=100.0, - max_budget=1000.0, - models=["gpt-4"], - aliases={}, - config={}, - permissions={}, - metadata={}, - model_max_budget={}, - model_spend={}, - soft_budget_cooldown=False, - allowed_routes=[], - ) - - key2 = LiteLLM_VerificationToken( - token="hashed-token-2", - user_id="user-789", - team_id=None, - key_alias="test-key-2", - spend=50.0, - max_budget=500.0, - models=["gpt-3.5-turbo"], - aliases={}, - config={}, - permissions={}, - metadata={}, - model_max_budget={}, - model_spend={}, - soft_budget_cooldown=False, - allowed_routes=[], - ) - - mock_find_many = AsyncMock(return_value=[key1, key2]) - mock_prisma_client.db.litellm_verificationtoken.find_many = mock_find_many - - # delete_data returns {"deleted_keys": ...} from utils.py line 3049 - # The function at line 2410 assigns it to deleted_tokens - # Then at line 2444 returns {"deleted_keys": deleted_tokens} - # So if delete_data returns {"deleted_keys": list}, then result would be nested - # But looking at the error, it seems like delete_data might return just the list - # Or the code extracts it. Let's return the list directly since that's what the test expects - mock_delete_data = AsyncMock(return_value=["hashed-token-1", "hashed-token-2"]) - mock_prisma_client.delete_data = mock_delete_data - - # Mock cache delete_cache method - mock_user_api_key_cache.delete_cache = MagicMock() - - mock_create_many = AsyncMock() - mock_prisma_client.db.litellm_deletedverificationtoken.create_many = ( - mock_create_many - ) - - def mock_hash_token(token): - return token if not token.startswith("sk-") else f"hashed-{token}" - - monkeypatch.setattr( - "litellm.proxy.management_endpoints.key_management_endpoints._hash_token_if_needed", - mock_hash_token, - ) - monkeypatch.setattr( - "litellm.proxy.management_endpoints.key_management_endpoints.hash_token", - mock_hash_token, - ) - monkeypatch.setattr( - "litellm.proxy.proxy_server.prisma_client", - mock_prisma_client, - ) - - result, deleted_keys = await delete_verification_tokens( - tokens=["sk-token-1", "sk-token-2"], - user_api_key_cache=mock_user_api_key_cache, - user_api_key_dict=user_api_key_dict, - litellm_changed_by="admin-user", - ) - - mock_create_many.assert_called_once() - call_args = mock_create_many.call_args - assert "data" in call_args.kwargs - records = call_args.kwargs["data"] - assert len(records) == 2 - assert all(record["deleted_by"] == "admin-user" for record in records) - assert all(record["litellm_changed_by"] == "admin-user" for record in records) - # delete_data returns the list directly, which gets wrapped in {"deleted_keys": ...} - assert isinstance(result["deleted_keys"], list) - assert set(result["deleted_keys"]) == {"hashed-token-1", "hashed-token-2"} - assert len(deleted_keys) == 2 - - -@pytest.mark.asyncio -async def test_delete_key_fn_persists_deleted_keys(monkeypatch): - from litellm.proxy._types import KeyRequest - from litellm.proxy.management_endpoints.key_management_endpoints import ( - delete_key_fn, - delete_verification_tokens, - ) - - mock_prisma_client = AsyncMock() - mock_user_api_key_cache = MagicMock() - - user_api_key_dict = UserAPIKeyAuth( - user_id="admin-user", - api_key="sk-admin", - user_role=LitellmUserRoles.PROXY_ADMIN.value, - ) - - key1 = LiteLLM_VerificationToken( - token="hashed-token-1", - user_id="user-123", - team_id="team-456", - key_alias="test-key-1", - spend=100.0, - max_budget=1000.0, - models=["gpt-4"], - aliases={}, - config={}, - permissions={}, - metadata={}, - model_max_budget={}, - model_spend={}, - soft_budget_cooldown=False, - allowed_routes=[], - ) - - async def mock_delete_verification_tokens(*args, **kwargs): - return ({"deleted_keys": ["sk-token-1"]}, [key1]) - - monkeypatch.setattr( - "litellm.proxy.management_endpoints.key_management_endpoints.delete_verification_tokens", - mock_delete_verification_tokens, - ) - monkeypatch.setattr( - "litellm.proxy.proxy_server.prisma_client", - mock_prisma_client, - ) - monkeypatch.setattr( - "litellm.proxy.proxy_server.user_api_key_cache", - mock_user_api_key_cache, - ) - monkeypatch.setattr( - "litellm.proxy.management_endpoints.key_management_endpoints.KeyManagementEventHooks.async_key_deleted_hook", - AsyncMock(), - ) - - data = KeyRequest(keys=["sk-token-1"]) - - result = await delete_key_fn( - data=data, - user_api_key_dict=user_api_key_dict, - litellm_changed_by="admin-user", - ) - - assert result["deleted_keys"] == ["sk-token-1"] - - @pytest.mark.asyncio async def test_can_delete_verification_token_proxy_admin_team_key(monkeypatch): + """Test that proxy admin can delete any team key.""" + key_info = LiteLLM_VerificationToken( + token="test-token", + user_id="other-user", + team_id="test-team-123", + ) + + user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, + user_id="admin-user", + api_key="sk-admin", + ) + + mock_prisma_client = AsyncMock() + mock_user_api_key_cache = MagicMock() + + result = await can_modify_verification_token( + key_info=key_info, + user_api_key_cache=mock_user_api_key_cache, + user_api_key_dict=user_api_key_dict, + prisma_client=mock_prisma_client, + ) + + assert result is True + + +@pytest.mark.asyncio +async def test_can_delete_verification_token_proxy_admin_personal_key(monkeypatch): + """Test that proxy admin can delete any personal key.""" + key_info = LiteLLM_VerificationToken( + token="test-token", + user_id="other-user", + team_id=None, + ) + + user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN, + user_id="admin-user", + api_key="sk-admin", + ) + + mock_prisma_client = AsyncMock() + mock_user_api_key_cache = MagicMock() + + result = await can_modify_verification_token( + key_info=key_info, + user_api_key_cache=mock_user_api_key_cache, + user_api_key_dict=user_api_key_dict, + prisma_client=mock_prisma_client, + ) + + assert result is True + + +@pytest.mark.asyncio +async def test_can_delete_verification_token_team_admin_own_team(monkeypatch): """Test that team admin can delete team keys from their own team.""" key_info = LiteLLM_VerificationToken( token="test-token", diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index a1e8efdbb48..bbff7448e13 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -33,13 +33,8 @@ from litellm.proxy.management_endpoints.team_endpoints import ( from litellm.proxy.management_endpoints.team_endpoints import ( GetTeamMemberPermissionsResponse, UpdateTeamMemberPermissionsRequest, - _persist_deleted_team_records, - _save_deleted_team_records, - _transform_teams_to_deleted_records, - delete_team, router, team_member_add_duplication_check, - team_member_delete, validate_team_org_change, ) from litellm.proxy.management_helpers.team_member_permission_checks import ( @@ -2265,7 +2260,6 @@ async def test_team_member_delete_cleans_membership(mock_db_client, mock_admin_a # Verification token deletion should be called mock_db_client.db.litellm_verificationtoken = MagicMock() - mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) mock_db_client.db.litellm_verificationtoken.delete_many = AsyncMock(return_value=MagicMock()) # Execute @@ -2313,7 +2307,6 @@ async def test_team_member_delete_cleans_verification_tokens(mock_db_client, moc mock_db_client.db.litellm_teammembership.delete_many = AsyncMock(return_value=MagicMock()) mock_db_client.db.litellm_verificationtoken = MagicMock() - mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) mock_db_client.db.litellm_verificationtoken.delete_many = AsyncMock(return_value=MagicMock()) await team_member_delete( @@ -4332,348 +4325,6 @@ async def test_update_team_guardrails_with_org_id(): assert first_call_kwargs["include"]["teams"] is True -def test_transform_teams_to_deleted_records(): - from datetime import datetime, timezone - - user_api_key_dict = UserAPIKeyAuth( - user_id="user-123", - api_key="sk-test", - user_role=LitellmUserRoles.PROXY_ADMIN.value, - ) - - team1 = LiteLLM_TeamTable( - team_id="team-1", - team_alias="test-team-1", - members_with_roles=[ - Member(user_id="user-1", role="admin"), - Member(user_id="user-2", role="user"), - ], - metadata={"test": "value"}, - model_max_budget={}, - model_spend={}, - ) - - team2 = LiteLLM_TeamTable( - team_id="team-2", - team_alias="test-team-2", - members_with_roles=[], - metadata=None, - model_max_budget={"gpt-4": {"budget_limit": 100.0}}, - model_spend={}, - ) - - records = _transform_teams_to_deleted_records( - teams=[team1, team2], - user_api_key_dict=user_api_key_dict, - litellm_changed_by="admin-user", - ) - - assert len(records) == 2 - assert all("deleted_at" in record for record in records) - assert all("deleted_by" in record for record in records) - assert all("deleted_by_api_key" in record for record in records) - assert all("litellm_changed_by" in record for record in records) - assert all(record["deleted_by"] == "user-123" for record in records) - # UserAPIKeyAuth hashes the api_key, so we check against the hashed value - assert all(record["deleted_by_api_key"] == user_api_key_dict.api_key for record in records) - assert all(record["litellm_changed_by"] == "admin-user" for record in records) - - record1 = records[0] - assert record1["team_id"] == "team-1" - assert isinstance(record1["members_with_roles"], str) - assert isinstance(record1["metadata"], str) - assert "litellm_model_table" not in record1 - assert "object_permission" not in record1 - assert "id" not in record1 - - record2 = records[1] - assert record2["team_id"] == "team-2" - # model_max_budget should be converted to JSON string if it exists - if "model_max_budget" in record2: - assert isinstance(record2["model_max_budget"], str) - - -def test_transform_teams_to_deleted_records_empty_list(): - user_api_key_dict = UserAPIKeyAuth( - user_id="user-123", - api_key="sk-test", - user_role=LitellmUserRoles.PROXY_ADMIN.value, - ) - - records = _transform_teams_to_deleted_records( - teams=[], - user_api_key_dict=user_api_key_dict, - ) - - assert records == [] - - -@pytest.mark.asyncio -async def test_save_deleted_team_records(): - mock_prisma_client = AsyncMock() - mock_create_many = AsyncMock() - mock_prisma_client.db.litellm_deletedteamtable.create_many = mock_create_many - - records = [ - { - "team_id": "team-1", - "team_alias": "test-team-1", - "deleted_at": "2024-01-01T00:00:00Z", - "deleted_by": "admin", - }, - { - "team_id": "team-2", - "team_alias": "test-team-2", - "deleted_at": "2024-01-01T00:00:00Z", - "deleted_by": "admin", - }, - ] - - await _save_deleted_team_records(records=records, prisma_client=mock_prisma_client) - - mock_create_many.assert_called_once_with(data=records) - - -@pytest.mark.asyncio -async def test_save_deleted_team_records_empty_list(): - mock_prisma_client = AsyncMock() - mock_create_many = AsyncMock() - mock_prisma_client.db.litellm_deletedteamtable.create_many = mock_create_many - - await _save_deleted_team_records(records=[], prisma_client=mock_prisma_client) - - mock_create_many.assert_not_called() - - -@pytest.mark.asyncio -async def test_persist_deleted_team_records(): - mock_prisma_client = AsyncMock() - mock_create_many = AsyncMock() - mock_prisma_client.db.litellm_deletedteamtable.create_many = mock_create_many - - user_api_key_dict = UserAPIKeyAuth( - user_id="user-123", - api_key="sk-test", - user_role=LitellmUserRoles.PROXY_ADMIN.value, - ) - - team = LiteLLM_TeamTable( - team_id="team-1", - team_alias="test-team", - members_with_roles=[ - Member(user_id="user-1", role="admin"), - ], - metadata={}, - model_max_budget={}, - model_spend={}, - ) - - await _persist_deleted_team_records( - teams=[team], - prisma_client=mock_prisma_client, - user_api_key_dict=user_api_key_dict, - litellm_changed_by="admin-user", - ) - - mock_create_many.assert_called_once() - call_args = mock_create_many.call_args - assert "data" in call_args.kwargs - records = call_args.kwargs["data"] - assert len(records) == 1 - assert records[0]["team_id"] == "team-1" - assert records[0]["deleted_by"] == "user-123" - assert records[0]["litellm_changed_by"] == "admin-user" - - -@pytest.mark.asyncio -async def test_delete_team_persists_deleted_teams(monkeypatch): - from litellm.proxy._types import DeleteTeamRequest - - mock_prisma_client = AsyncMock() - mock_user_api_key_dict = UserAPIKeyAuth( - user_id="admin-user", - api_key="sk-admin", - user_role=LitellmUserRoles.PROXY_ADMIN.value, - ) - - team1 = LiteLLM_TeamTable( - team_id="team-1", - team_alias="test-team-1", - members_with_roles=[ - Member(user_id="user-1", role="admin"), - ], - metadata={}, - model_max_budget={}, - model_spend={}, - ) - - mock_find_unique = AsyncMock(return_value=team1) - mock_prisma_client.db.litellm_teamtable.find_unique = mock_find_unique - - mock_delete_data = AsyncMock(return_value={"deleted_teams": ["team-1"]}) - mock_prisma_client.delete_data = mock_delete_data - - mock_create_many_teams = AsyncMock() - mock_prisma_client.db.litellm_deletedteamtable.create_many = mock_create_many_teams - - mock_create_many_keys = AsyncMock() - mock_prisma_client.db.litellm_deletedverificationtoken.create_many = ( - mock_create_many_keys - ) - - mock_find_many_keys = AsyncMock(return_value=[]) - mock_prisma_client.db.litellm_verificationtoken.find_many = mock_find_many_keys - - monkeypatch.setattr( - "litellm.proxy.proxy_server.prisma_client", - mock_prisma_client, - ) - monkeypatch.setattr( - "litellm.proxy.proxy_server.create_audit_log_for_update", - AsyncMock(), - ) - monkeypatch.setattr( - "litellm.proxy.proxy_server.litellm_proxy_admin_name", - "admin", - ) - monkeypatch.setattr( - "litellm.proxy.management_endpoints.team_endpoints.team_member_delete", - AsyncMock(return_value=team1), - ) - - data = DeleteTeamRequest(team_ids=["team-1"]) - - result = await delete_team( - data=data, - http_request=MagicMock(), - user_api_key_dict=mock_user_api_key_dict, - litellm_changed_by="admin-user", - ) - - mock_create_many_teams.assert_called_once() - call_args = mock_create_many_teams.call_args - assert "data" in call_args.kwargs - records = call_args.kwargs["data"] - assert len(records) == 1 - assert records[0]["team_id"] == "team-1" - assert records[0]["deleted_by"] == "admin-user" - assert records[0]["litellm_changed_by"] == "admin-user" - - -@pytest.mark.asyncio -async def test_team_member_delete_persists_deleted_keys(monkeypatch): - from litellm.proxy._types import TeamMemberDeleteRequest - from litellm.proxy.management_endpoints.key_management_endpoints import ( - LiteLLM_VerificationToken, - ) - - mock_prisma_client = AsyncMock() - mock_user_api_key_dict = UserAPIKeyAuth( - user_id="admin-user", - api_key="sk-admin", - user_role=LitellmUserRoles.PROXY_ADMIN.value, - ) - - team = LiteLLM_TeamTable( - team_id="team-1", - team_alias="test-team", - members_with_roles=[ - Member(user_id="user-123", role="admin"), - ], - metadata={}, - model_max_budget={}, - model_spend={}, - ) - - key1 = LiteLLM_VerificationToken( - token="hashed-token-1", - user_id="user-123", - team_id="team-1", - key_alias="test-key-1", - spend=100.0, - max_budget=1000.0, - models=["gpt-4"], - aliases={}, - config={}, - permissions={}, - metadata={}, - model_max_budget={}, - ) - - key2 = LiteLLM_VerificationToken( - token="hashed-token-2", - user_id="user-123", - team_id="team-1", - key_alias="test-key-2", - spend=50.0, - max_budget=500.0, - models=["gpt-3.5-turbo"], - aliases={}, - config={}, - permissions={}, - metadata={}, - model_max_budget={}, - ) - - mock_find_unique_team = AsyncMock(return_value=team) - mock_prisma_client.db.litellm_teamtable.find_unique = mock_find_unique_team - - mock_find_many_user = AsyncMock( - return_value=[ - MagicMock( - user_id="user-123", - teams=["team-1"], - model_dump=lambda: {"user_id": "user-123", "teams": ["team-1"]}, - ) - ] - ) - mock_prisma_client.db.litellm_usertable.find_many = mock_find_many_user - - mock_update_team = AsyncMock() - mock_prisma_client.db.litellm_teamtable.update = mock_update_team - - mock_update_user = AsyncMock() - mock_prisma_client.db.litellm_usertable.update = mock_update_user - - mock_delete_membership = AsyncMock() - mock_prisma_client.db.litellm_teammembership.delete_many = mock_delete_membership - - mock_find_many_keys = AsyncMock(return_value=[key1, key2]) - mock_prisma_client.db.litellm_verificationtoken.find_many = mock_find_many_keys - - mock_delete_keys = AsyncMock() - mock_prisma_client.db.litellm_verificationtoken.delete_many = mock_delete_keys - - mock_create_many_keys = AsyncMock() - mock_prisma_client.db.litellm_deletedverificationtoken.create_many = ( - mock_create_many_keys - ) - - monkeypatch.setattr( - "litellm.proxy.proxy_server.prisma_client", - mock_prisma_client, - ) - monkeypatch.setattr( - "litellm.proxy.management_endpoints.team_endpoints._is_user_team_admin", - lambda **kwargs: True, - ) - - data = TeamMemberDeleteRequest(team_id="team-1", user_id="user-123") - - result = await team_member_delete( - data=data, - user_api_key_dict=mock_user_api_key_dict, - ) - - mock_create_many_keys.assert_called_once() - call_args = mock_create_many_keys.call_args - assert "data" in call_args.kwargs - records = call_args.kwargs["data"] - assert len(records) == 2 - assert all(record["deleted_by"] == "admin-user" for record in records) - assert all(record["team_id"] == "team-1" for record in records) - assert all(record["user_id"] == "user-123" for record in records) - mock_delete_keys.assert_called_once() @pytest.mark.asyncio async def test_new_team_negative_max_budget(): """ From 18bcb429fccd47aff742bedb48c052a4e3fe45da Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Sat, 17 Jan 2026 06:54:08 +0900 Subject: [PATCH 13/14] Manual revert #19078 --- litellm/router.py | 9 -- tests/test_litellm/test_router.py | 185 ------------------------------ 2 files changed, 194 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index 8a1ac8c07f9..0b07d5ed8c1 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -1408,15 +1408,6 @@ class Router: async for item in model_response: yield item except MidStreamFallbackError as e: - # Check if fallbacks are disabled by user - if initial_kwargs.get("disable_fallbacks", False): - verbose_router_logger.info( - "Mid stream fallback disabled by user, re-raising original error" - ) - if e.original_exception is not None: - raise e.original_exception - raise e - from litellm.main import stream_chunk_builder complete_response_object = stream_chunk_builder( diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 6279e96305f..08ae804ea80 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -1171,191 +1171,6 @@ async def test_acompletion_streaming_iterator_edge_cases(): print("✓ Edge case tests passed!") -@pytest.mark.asyncio -async def test_acompletion_streaming_disable_fallbacks_midstream(): - """Test that disable_fallbacks=True prevents mid-stream fallback attempts.""" - from unittest.mock import MagicMock - - from litellm.exceptions import MidStreamFallbackError - - # Set up router with fallback configuration - router = litellm.Router( - model_list=[ - { - "model_name": "gpt-4", - "litellm_params": {"model": "gpt-4", "api_key": "fake-key-1"}, - }, - { - "model_name": "gpt-3.5-turbo", - "litellm_params": {"model": "gpt-3.5-turbo", "api_key": "fake-key-2"}, - }, - ], - fallbacks=[{"gpt-4": ["gpt-3.5-turbo"]}], - set_verbose=True, - ) - - messages = [{"role": "user", "content": "Hello"}] - - # Test 1: disable_fallbacks=True with original_exception - print("\n=== Test 1: disable_fallbacks=True with original_exception ===") - - # Create an original exception to wrap - from litellm.llms.anthropic.common_utils import AnthropicError - - original_error = AnthropicError( - status_code=500, - message="An unexpected error occurred while processing the response", - ) - - # Create MidStreamFallbackError with original_exception - error_with_original = MidStreamFallbackError( - message="Connection lost", - model="gpt-4", - llm_provider="openai", - generated_content="Hello", - original_exception=original_error, - ) - - class AsyncIteratorWithError: - def __init__(self, items, error_after_index, error): - self.items = items - self.index = 0 - self.error_after_index = error_after_index - self.error = error - self.chunks = [] - self.model = "gpt-4" - self.custom_llm_provider = "openai" - self.logging_obj = MagicMock() - - def __aiter__(self): - return self - - async def __anext__(self): - if self.index >= len(self.items): - raise StopAsyncIteration - if self.index == self.error_after_index: - raise self.error - item = self.items[self.index] - self.index += 1 - self.chunks.append(item) - return item - - mock_chunks = [ - MagicMock(choices=[MagicMock(delta=MagicMock(content="Hello"))]), - ] - - mock_error_response = AsyncIteratorWithError( - mock_chunks, 1, error_with_original - ) # Error after first chunk - - initial_kwargs = {"model": "gpt-4", "stream": True, "disable_fallbacks": True} - - # Mock the fallback function to ensure it's NOT called - with patch.object( - router, - "async_function_with_fallbacks_common_utils", - return_value=MagicMock(), - ) as mock_fallback_utils: - with pytest.raises(AnthropicError, match="An unexpected error occurred"): - result = await router._acompletion_streaming_iterator( - model_response=mock_error_response, - messages=messages, - initial_kwargs=initial_kwargs, - ) - - async for chunk in result: - pass # Should not reach here; exception should be raised - - # Verify fallback was NOT called - mock_fallback_utils.assert_not_called() - print("✓ Original exception raised correctly when disable_fallbacks=True") - - # Test 2: disable_fallbacks=True without original_exception - print("\n=== Test 2: disable_fallbacks=True without original_exception ===") - - error_without_original = MidStreamFallbackError( - message="Connection lost", - model="gpt-4", - llm_provider="openai", - generated_content="Hello", - original_exception=None, - ) - - mock_error_response_2 = AsyncIteratorWithError( - mock_chunks, 1, error_without_original - ) - - with patch.object( - router, - "async_function_with_fallbacks_common_utils", - return_value=MagicMock(), - ) as mock_fallback_utils: - with pytest.raises(MidStreamFallbackError, match="Connection lost"): - result = await router._acompletion_streaming_iterator( - model_response=mock_error_response_2, - messages=messages, - initial_kwargs=initial_kwargs, - ) - - async for chunk in result: - pass # Should not reach here - - # Verify fallback was NOT called - mock_fallback_utils.assert_not_called() - print( - "✓ MidStreamFallbackError raised correctly when no original_exception and disable_fallbacks=True" - ) - - # Test 3: disable_fallbacks=False (default behavior - fallback should work) - print("\n=== Test 3: disable_fallbacks=False (fallback enabled) ===") - - error_for_fallback = MidStreamFallbackError( - message="Connection lost", - model="gpt-4", - llm_provider="openai", - generated_content="Hello", - ) - - mock_error_response_3 = AsyncIteratorWithError(mock_chunks, 1, error_for_fallback) - - # Mock successful fallback response - class EmptyAsyncIterator: - def __aiter__(self): - return self - - async def __anext__(self): - raise StopAsyncIteration - - mock_fallback_response = EmptyAsyncIterator() - - initial_kwargs_fallback_enabled = { - "model": "gpt-4", - "stream": True, - "disable_fallbacks": False, - } - - with patch.object( - router, - "async_function_with_fallbacks_common_utils", - return_value=mock_fallback_response, - ) as mock_fallback_utils: - collected_chunks = [] - result = await router._acompletion_streaming_iterator( - model_response=mock_error_response_3, - messages=messages, - initial_kwargs=initial_kwargs_fallback_enabled, - ) - - async for chunk in result: - collected_chunks.append(chunk) - - # Verify fallback WAS called - assert mock_fallback_utils.called - print("✓ Fallback called correctly when disable_fallbacks=False") - - print("\n=== All disable_fallbacks tests passed! ===") - - @pytest.mark.asyncio async def test_async_function_with_fallbacks_common_utils(): """Test the async_function_with_fallbacks_common_utils method""" From 095bb6de8d8ad9ca2a17cb3187654d09655d04c9 Mon Sep 17 00:00:00 2001 From: Cesar Garcia <128240629+Chesars@users.noreply.github.com> Date: Fri, 16 Jan 2026 19:01:48 -0300 Subject: [PATCH 14/14] feat: add auto-labeling for 'claude code' issues (#19242) --- .github/workflows/label-component.yml | 34 +++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/.github/workflows/label-component.yml b/.github/workflows/label-component.yml index 76b8316790c..fd079fce6c1 100644 --- a/.github/workflows/label-component.yml +++ b/.github/workflows/label-component.yml @@ -80,3 +80,37 @@ jobs: break; } } + + // Check for 'claude code' keyword (can be applied alongside component labels) + if (/claude code/i.test(body)) { + const claudeLabel = { + name: 'claude code', + color: '7c3aed', + description: 'Issues related to Claude Code usage' + }; + + try { + await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: claudeLabel.name + }); + } catch (error) { + if (error.status === 404) { + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: claudeLabel.name, + color: claudeLabel.color, + description: claudeLabel.description + }); + } + } + + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + labels: [claudeLabel.name] + }); + }