Merge branch 'BerriAI:main' into patch-1

This commit is contained in:
Tobias Hermann 2025-12-29 09:46:57 +01:00 committed by GitHub
commit 7b7bc85471
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 608 additions and 754 deletions

View file

@ -243,14 +243,14 @@ class CustomGuardrail(CustomLogger):
def _is_valid_response_type(self, result: Any) -> bool:
"""
Check if result is a valid LLMResponseTypes instance.
Safely handles TypedDict types which don't support isinstance checks.
For non-LiteLLM responses (like passthrough httpx.Response), returns True
to allow them through.
"""
if result is None:
return False
try:
# Try isinstance check on valid types that support it
response_types = get_args(LLMResponseTypes)
@ -506,6 +506,7 @@ class CustomGuardrail(CustomLogger):
duration: Optional[float] = None,
masked_entity_count: Optional[Dict[str, int]] = None,
guardrail_provider: Optional[str] = None,
event_type: Optional[GuardrailEventHooks] = None,
) -> None:
"""
Builds `StandardLoggingGuardrailInformation` and adds it to the request metadata so it can be used for logging to DataDog, Langfuse, etc.
@ -514,14 +515,19 @@ class CustomGuardrail(CustomLogger):
guardrail_json_response = str(guardrail_json_response)
from litellm.types.utils import GuardrailMode
# Use event_type if provided, otherwise fall back to self.event_hook
guardrail_mode: Union[GuardrailEventHooks, GuardrailMode, List[GuardrailEventHooks]]
if event_type is not None:
guardrail_mode = event_type
elif isinstance(self.event_hook, Mode):
guardrail_mode = GuardrailMode(**dict(self.event_hook.model_dump())) # type: ignore[typeddict-item]
else:
guardrail_mode = self.event_hook # type: ignore[assignment]
slg = StandardLoggingGuardrailInformation(
guardrail_name=self.guardrail_name,
guardrail_provider=guardrail_provider,
guardrail_mode=(
GuardrailMode(**self.event_hook.model_dump()) # type: ignore
if isinstance(self.event_hook, Mode)
else self.event_hook
),
guardrail_mode=guardrail_mode,
guardrail_response=guardrail_json_response,
guardrail_status=guardrail_status,
start_time=start_time,
@ -589,6 +595,7 @@ class CustomGuardrail(CustomLogger):
start_time: Optional[float] = None,
end_time: Optional[float] = None,
duration: Optional[float] = None,
event_type: Optional[GuardrailEventHooks] = None,
):
"""
Add StandardLoggingGuardrailInformation to the request data
@ -605,6 +612,7 @@ class CustomGuardrail(CustomLogger):
duration=duration,
start_time=start_time,
end_time=end_time,
event_type=event_type,
)
return response
@ -615,6 +623,7 @@ class CustomGuardrail(CustomLogger):
start_time: Optional[float] = None,
end_time: Optional[float] = None,
duration: Optional[float] = None,
event_type: Optional[GuardrailEventHooks] = None,
):
"""
Add StandardLoggingGuardrailInformation to the request data
@ -628,6 +637,7 @@ class CustomGuardrail(CustomLogger):
duration=duration,
start_time=start_time,
end_time=end_time,
event_type=event_type,
)
raise e
@ -712,16 +722,32 @@ def log_guardrail_information(func):
Logs for:
- pre_call
- during_call
- TODO: log post_call. This is more involved since the logs are sent to DD, s3 before the guardrail is even run
- post_call
"""
import asyncio
import functools
def _infer_event_type_from_function_name(
func_name: str,
) -> Optional[GuardrailEventHooks]:
"""Infer the actual event type from the function name"""
if func_name == "async_pre_call_hook":
return GuardrailEventHooks.pre_call
elif func_name == "async_moderation_hook":
return GuardrailEventHooks.during_call
elif func_name in (
"async_post_call_success_hook",
"async_post_call_streaming_hook",
):
return GuardrailEventHooks.post_call
return None
@functools.wraps(func)
async def async_wrapper(*args, **kwargs):
start_time = datetime.now() # Move start_time inside the wrapper
self: CustomGuardrail = args[0]
request_data: dict = kwargs.get("data") or kwargs.get("request_data") or {}
event_type = _infer_event_type_from_function_name(func.__name__)
try:
response = await func(*args, **kwargs)
return self._process_response(
@ -730,6 +756,7 @@ def log_guardrail_information(func):
start_time=start_time.timestamp(),
end_time=datetime.now().timestamp(),
duration=(datetime.now() - start_time).total_seconds(),
event_type=event_type,
)
except Exception as e:
return self._process_error(
@ -738,6 +765,7 @@ def log_guardrail_information(func):
start_time=start_time.timestamp(),
end_time=datetime.now().timestamp(),
duration=(datetime.now() - start_time).total_seconds(),
event_type=event_type,
)
@functools.wraps(func)
@ -745,18 +773,21 @@ def log_guardrail_information(func):
start_time = datetime.now() # Move start_time inside the wrapper
self: CustomGuardrail = args[0]
request_data: dict = kwargs.get("data") or kwargs.get("request_data") or {}
event_type = _infer_event_type_from_function_name(func.__name__)
try:
response = func(*args, **kwargs)
return self._process_response(
response=response,
request_data=request_data,
duration=(datetime.now() - start_time).total_seconds(),
event_type=event_type,
)
except Exception as e:
return self._process_error(
e=e,
request_data=request_data,
duration=(datetime.now() - start_time).total_seconds(),
event_type=event_type,
)
@functools.wraps(func)

View file

@ -11,7 +11,7 @@ import datetime
import hashlib
import json
import re
from typing import Any, Dict, List, Optional, Set, Tuple, Union, cast
from typing import Any, Dict, List, Literal, Optional, Set, Tuple, Union, cast
from urllib.parse import urlparse
from fastapi import HTTPException
@ -2127,7 +2127,7 @@ class MCPServerManager:
async def health_check_server(
self, server_id: str, mcp_auth_header: Optional[str] = None
) -> Dict[str, Any]:
) -> LiteLLM_MCPServerTable:
"""
Perform a health check on a specific MCP server.
@ -2138,96 +2138,85 @@ class MCPServerManager:
Returns:
Dict containing health check results
"""
import time
from datetime import datetime
server = self.get_mcp_server_by_id(server_id)
if not server:
return {
"server_id": server_id,
"server_name": None,
"status": "unknown",
"error": "Server not found",
"last_health_check": datetime.now().isoformat(),
"response_time_ms": None,
}
start_time = time.time()
try:
# Try to get tools from the server as a health check
tools = await self._get_tools_from_server(server, mcp_auth_header)
response_time = (time.time() - start_time) * 1000
return {
"server_id": server_id,
"server_name": server.name,
"status": "healthy",
"tools_count": len(tools),
"last_health_check": datetime.now().isoformat(),
"response_time_ms": round(response_time, 2),
"error": None,
}
except Exception as e:
response_time = (time.time() - start_time) * 1000
error_message = str(e)
return {
"server_id": server_id,
"server_name": server.name,
"status": "unhealthy",
"last_health_check": datetime.now().isoformat(),
"response_time_ms": round(response_time, 2),
"error": error_message,
}
async def health_check_all_servers(
self, mcp_auth_header: Optional[str] = None
) -> Dict[str, Any]:
"""
Perform health checks on all MCP servers.
Args:
mcp_auth_header: Optional authentication header for the MCP servers
Returns:
Dict containing health check results for all servers
"""
all_servers = self.get_registry()
results = {}
for server_id, server in all_servers.items():
results[server_id] = await self.health_check_server(
server_id, mcp_auth_header
verbose_logger.warning(f"MCP Server {server_id} not found")
return LiteLLM_MCPServerTable(
server_id=server_id,
server_name=None,
transport=MCPTransport.http, # Default transport for not found servers
status="unknown",
health_check_error="Server not found",
last_health_check=datetime.now(),
)
return results
status: Literal["healthy", "unhealthy", "unknown"] = "unknown"
health_check_error = None
async def health_check_allowed_servers(
self,
user_api_key_auth: Optional[UserAPIKeyAuth] = None,
mcp_auth_header: Optional[str] = None,
) -> Dict[str, Any]:
"""
Perform health checks on all MCP servers that the user has access to.
# Check if we should skip health check based on auth configuration
should_skip_health_check = False
Args:
user_api_key_auth: User authentication info for access control
mcp_auth_header: Optional authentication header for the MCP servers
# Skip if auth_type is oauth2
if server.auth_type == MCPAuth.oauth2:
should_skip_health_check = True
# Skip if auth_type is not none and authentication_token is missing
elif (
server.auth_type
and server.auth_type != MCPAuth.none
and not server.authentication_token
):
should_skip_health_check = True
Returns:
Dict containing health check results for accessible servers
"""
# Get allowed servers for the user
allowed_server_ids = await self.get_allowed_mcp_servers(user_api_key_auth)
if not should_skip_health_check:
extra_headers = {}
if server.static_headers:
extra_headers.update(server.static_headers)
# Perform health checks on allowed servers
results = {}
for server_id in allowed_server_ids:
results[server_id] = await self.health_check_server(
server_id, mcp_auth_header
client = self._create_mcp_client(
server=server,
mcp_auth_header=None,
extra_headers=extra_headers,
stdio_env=None,
)
return results
try:
async def _noop(session):
return "ok"
await client.run_with_session(_noop)
status = "healthy"
except Exception as e:
health_check_error = str(e)
status = "unhealthy"
return LiteLLM_MCPServerTable(
server_id=server.server_id,
server_name=server.server_name,
alias=server.alias,
description=(
server.mcp_info.get("description") if server.mcp_info else None
),
url=server.url,
transport=server.transport,
auth_type=server.auth_type,
created_at=datetime.now(),
updated_at=datetime.now(),
teams=[],
mcp_access_groups=server.access_groups or [],
allowed_tools=server.allowed_tools or [],
extra_headers=server.extra_headers or [],
mcp_info=server.mcp_info,
static_headers=server.static_headers,
status=status,
last_health_check=datetime.now(),
health_check_error=health_check_error,
command=getattr(server, "command", None),
args=getattr(server, "args", None) or [],
env=getattr(server, "env", None) or {},
)
async def get_all_mcp_servers_with_health_and_teams(
self,
@ -2244,100 +2233,18 @@ class MCPServerManager:
Returns:
List of MCP server objects with health and team data
"""
from litellm.proxy._experimental.mcp_server.db import (
get_all_mcp_servers,
get_mcp_servers,
)
from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view
from litellm.proxy.proxy_server import prisma_client
# Get allowed server IDs
allowed_server_ids = await self.get_allowed_mcp_servers(user_api_key_auth)
# Get servers from database
list_mcp_servers: List[LiteLLM_MCPServerTable] = []
if prisma_client is not None:
list_mcp_servers = await get_mcp_servers(prisma_client, allowed_server_ids)
# Run health checks concurrently
tasks = [
self.health_check_server(server_id) for server_id in allowed_server_ids
]
results = await asyncio.gather(*tasks)
# If admin, also get all servers from database
if user_api_key_auth and _user_has_admin_view(user_api_key_auth):
all_mcp_servers = await get_all_mcp_servers(prisma_client)
for server in all_mcp_servers:
if server.server_id not in allowed_server_ids:
list_mcp_servers.append(server)
# Add config.yaml servers
for _server_id, _server_config in self.config_mcp_servers.items():
if _server_id in allowed_server_ids:
list_mcp_servers.append(
LiteLLM_MCPServerTable(
**{
**_server_config.model_dump(),
"created_at": datetime.datetime.now(),
"updated_at": datetime.datetime.now(),
"description": (
_server_config.mcp_info.get("description")
if _server_config.mcp_info
else None
),
"allowed_tools": _server_config.allowed_tools or [],
"mcp_info": _server_config.mcp_info,
"mcp_access_groups": _server_config.access_groups or [],
"extra_headers": _server_config.extra_headers or [],
"command": getattr(_server_config, "command", None),
"args": getattr(_server_config, "args", None) or [],
"env": getattr(_server_config, "env", None) or {},
}
)
)
# Get team information for non-admin users
server_to_teams_map: Dict[str, List[Dict[str, str]]] = {}
if (
user_api_key_auth
and not _user_has_admin_view(user_api_key_auth)
and prisma_client is not None
):
teams = await prisma_client.db.litellm_teamtable.find_many(
include={"object_permission": True}
)
user_teams = []
for team in teams:
if team.members_with_roles:
for member in team.members_with_roles:
if (
"user_id" in member
and member["user_id"] is not None
and member["user_id"] == user_api_key_auth.user_id
):
user_teams.append(team)
# Create a mapping of server_id to teams that have access to it
for team in user_teams:
if team.object_permission and team.object_permission.mcp_servers:
for server_id in team.object_permission.mcp_servers:
if server_id not in server_to_teams_map:
server_to_teams_map[server_id] = []
server_to_teams_map[server_id].append(
{
"team_id": team.team_id,
"team_alias": team.team_alias,
"organization_id": team.organization_id,
}
)
## mark invalid servers w/ reason for being invalid
valid_server_ids = self.get_all_mcp_server_ids()
for server in list_mcp_servers:
if server.server_id not in valid_server_ids:
server.status = "unhealthy"
## try adding server to registry to get error
try:
await self.add_update_server(server)
except Exception as e:
server.health_check_error = str(e)
server.health_check_error = "Server is not in in memory registry yet. This could be a temporary sync issue."
# Filter out None results (servers that were not found)
list_mcp_servers = [server for server in results if server is not None]
return list_mcp_servers

View file

@ -449,6 +449,12 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
prepared_request.headers,
)
event_type = (
GuardrailEventHooks.pre_call
if source == "INPUT"
else GuardrailEventHooks.post_call
)
try:
httpx_response = await self.async_handler.post(
url=prepared_request.url,
@ -469,6 +475,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
start_time=start_time.timestamp(),
end_time=datetime.now().timestamp(),
duration=(datetime.now() - start_time).total_seconds(),
event_type=event_type,
)
# Re-raise the exception to maintain existing behavior
raise
@ -486,6 +493,7 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
start_time=start_time.timestamp(),
end_time=datetime.now().timestamp(),
duration=(datetime.now() - start_time).total_seconds(),
event_type=event_type,
)
#########################################################
if httpx_response.status_code == 200:
@ -605,10 +613,10 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
"""
Only raise exception for "BLOCKED" actions, not for "ANONYMIZED" actions.
If `self.mask_request_content` or `self.mask_response_content` is set to `True`,
If `self.mask_request_content` or `self.mask_response_content` is set to `True`,
then use the output from the guardrail to mask the request or response content.
However, even with masking enabled, content with action="BLOCKED" should still
However, even with masking enabled, content with action="BLOCKED" should still
raise an exception, only content with action="ANONYMIZED" should be masked.
"""
@ -731,9 +739,9 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
#########################################################
########## 1. Make the Bedrock API request ##########
#########################################################
bedrock_guardrail_response: Optional[Union[BedrockGuardrailResponse, str]] = (
None
)
bedrock_guardrail_response: Optional[
Union[BedrockGuardrailResponse, str]
] = None
try:
bedrock_guardrail_response = await self.make_bedrock_api_request(
source="INPUT", messages=filtered_messages, request_data=data
@ -803,9 +811,9 @@ class BedrockGuardrail(CustomGuardrail, BaseAWSLLM):
#########################################################
########## 1. Make the Bedrock API request ##########
#########################################################
bedrock_guardrail_response: Optional[Union[BedrockGuardrailResponse, str]] = (
None
)
bedrock_guardrail_response: Optional[
Union[BedrockGuardrailResponse, str]
] = None
try:
bedrock_guardrail_response = await self.make_bedrock_api_request(
source="INPUT", messages=filtered_messages, request_data=data

View file

@ -97,6 +97,7 @@ class DynamoAIGuardrails(CustomGuardrail):
async def _call_dynamoai_guardrails(
self,
messages: List[Dict[str, Any]],
event_type: GuardrailEventHooks,
text_type: str = "input",
request_data: Optional[dict] = None,
) -> DynamoAIResponse:
@ -157,6 +158,7 @@ class DynamoAIGuardrails(CustomGuardrail):
start_time=start_time.timestamp(),
end_time=end_time.timestamp(),
duration=duration,
event_type=event_type,
)
return response_json
@ -177,6 +179,7 @@ class DynamoAIGuardrails(CustomGuardrail):
start_time=start_time.timestamp(),
end_time=end_time.timestamp(),
duration=duration,
event_type=event_type,
)
raise
@ -332,6 +335,7 @@ class DynamoAIGuardrails(CustomGuardrail):
messages=_messages,
text_type="input",
request_data=data,
event_type=GuardrailEventHooks.pre_call,
)
verbose_proxy_logger.debug(
@ -380,6 +384,7 @@ class DynamoAIGuardrails(CustomGuardrail):
messages=_messages,
text_type="input",
request_data=data,
event_type=GuardrailEventHooks.during_call,
)
verbose_proxy_logger.debug(
@ -460,6 +465,7 @@ class DynamoAIGuardrails(CustomGuardrail):
messages=dynamoai_messages,
text_type="output",
request_data=data,
event_type=GuardrailEventHooks.post_call,
)
verbose_proxy_logger.debug(

View file

@ -108,6 +108,7 @@ class IBMGuardrailDetector(CustomGuardrail):
async def _call_detector_server(
self,
contents: List[str],
event_type: GuardrailEventHooks,
request_data: Optional[dict] = None,
) -> List[List[IBMDetectorDetection]]:
"""
@ -142,7 +143,6 @@ class IBMGuardrailDetector(CustomGuardrail):
)
try:
response = await self.async_handler.post(
url=self.api_url,
json=payload,
@ -172,6 +172,7 @@ class IBMGuardrailDetector(CustomGuardrail):
start_time=start_time.timestamp(),
end_time=end_time.timestamp(),
duration=duration,
event_type=event_type,
)
return response_json
@ -192,6 +193,7 @@ class IBMGuardrailDetector(CustomGuardrail):
start_time=start_time.timestamp(),
end_time=end_time.timestamp(),
duration=duration,
event_type=event_type,
)
raise
@ -199,6 +201,7 @@ class IBMGuardrailDetector(CustomGuardrail):
async def _call_orchestrator(
self,
content: str,
event_type: GuardrailEventHooks,
request_data: Optional[dict] = None,
) -> List[IBMDetectorDetection]:
"""
@ -258,6 +261,7 @@ class IBMGuardrailDetector(CustomGuardrail):
start_time=start_time.timestamp(),
end_time=end_time.timestamp(),
duration=duration,
event_type=event_type,
)
return response_json.get("detections", [])
@ -278,6 +282,7 @@ class IBMGuardrailDetector(CustomGuardrail):
start_time=start_time.timestamp(),
end_time=end_time.timestamp(),
duration=duration,
event_type=event_type,
)
raise
@ -472,6 +477,7 @@ class IBMGuardrailDetector(CustomGuardrail):
result = await self._call_detector_server(
contents=contents_to_check,
request_data=data,
event_type=GuardrailEventHooks.pre_call,
)
verbose_proxy_logger.debug(
@ -500,6 +506,7 @@ class IBMGuardrailDetector(CustomGuardrail):
orchestrator_result = await self._call_orchestrator(
content=content,
request_data=data,
event_type=GuardrailEventHooks.pre_call,
)
verbose_proxy_logger.debug(
@ -557,6 +564,7 @@ class IBMGuardrailDetector(CustomGuardrail):
result = await self._call_detector_server(
contents=contents_to_check,
request_data=data,
event_type=GuardrailEventHooks.during_call,
)
verbose_proxy_logger.debug(
@ -585,6 +593,7 @@ class IBMGuardrailDetector(CustomGuardrail):
orchestrator_result = await self._call_orchestrator(
content=content,
request_data=data,
event_type=GuardrailEventHooks.during_call,
)
verbose_proxy_logger.debug(
@ -673,6 +682,7 @@ class IBMGuardrailDetector(CustomGuardrail):
result = await self._call_detector_server(
contents=contents_to_check,
request_data=data,
event_type=GuardrailEventHooks.post_call,
)
verbose_proxy_logger.debug(
@ -702,6 +712,7 @@ class IBMGuardrailDetector(CustomGuardrail):
orchestrator_result = await self._call_orchestrator(
content=content,
request_data=data,
event_type=GuardrailEventHooks.post_call,
)
verbose_proxy_logger.debug(

View file

@ -83,6 +83,7 @@ class JavelinGuardrail(CustomGuardrail):
async def call_javelin_guard(
self,
request: JavelinGuardRequest,
event_type: GuardrailEventHooks,
) -> JavelinGuardResponse:
"""
Call the Javelin guard API.
@ -158,6 +159,7 @@ class JavelinGuardrail(CustomGuardrail):
start_time=start_time.timestamp(),
end_time=datetime.now().timestamp(),
duration=(datetime.now() - start_time).total_seconds(),
event_type=event_type,
)
async def async_pre_call_hook(
@ -208,7 +210,9 @@ class JavelinGuardrail(CustomGuardrail):
config=self.config if self.config else {},
)
javelin_response = await self.call_javelin_guard(request=javelin_guard_request)
javelin_response = await self.call_javelin_guard(
request=javelin_guard_request, event_type=GuardrailEventHooks.pre_call
)
assessments = javelin_response.get("assessments", [])
reject_prompt = ""

View file

@ -70,6 +70,7 @@ class LakeraAIGuardrail(CustomGuardrail):
self,
messages: List[AllMessageValues],
request_data: Dict,
event_type: GuardrailEventHooks,
) -> Tuple[LakeraAIResponse, Dict]:
"""
Call the Lakera AI v2 guard API.
@ -128,6 +129,7 @@ class LakeraAIGuardrail(CustomGuardrail):
end_time=datetime.now().timestamp(),
duration=(datetime.now() - start_time).total_seconds(),
masked_entity_count=masked_entity_count,
event_type=event_type,
)
def _mask_pii_in_messages(
@ -214,6 +216,7 @@ class LakeraAIGuardrail(CustomGuardrail):
lakera_guardrail_response, masked_entity_count = await self.call_v2_guard(
messages=new_messages,
request_data=data,
event_type=GuardrailEventHooks.pre_call,
)
#########################################################
@ -279,6 +282,7 @@ class LakeraAIGuardrail(CustomGuardrail):
lakera_guardrail_response, masked_entity_count = await self.call_v2_guard(
messages=new_messages,
request_data=data,
event_type=GuardrailEventHooks.during_call,
)
#########################################################

View file

@ -295,7 +295,9 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase):
filters = (
list(filter_results.values())
if isinstance(filter_results, dict)
else filter_results if isinstance(filter_results, list) else []
else filter_results
if isinstance(filter_results, list)
else []
)
# Prefer sanitized text from deidentifyResult if present
@ -327,6 +329,7 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase):
start_time: Optional[float] = None,
end_time: Optional[float] = None,
duration: Optional[float] = None,
event_type: Optional[GuardrailEventHooks] = None,
):
"""
Override to store only the Model Armor API response, not the entire data dict.
@ -351,6 +354,7 @@ class ModelArmorGuardrail(CustomGuardrail, VertexBase):
duration=duration,
start_time=start_time,
end_time=end_time,
event_type=event_type,
)
return response

View file

@ -119,9 +119,7 @@ class NomaGuardrail(CustomGuardrail):
self.api_base = api_base or os.environ.get(
"NOMA_API_BASE", NomaGuardrail._DEFAULT_API_BASE
)
self.application_id = application_id or os.environ.get(
"NOMA_APPLICATION_ID"
)
self.application_id = application_id or os.environ.get("NOMA_APPLICATION_ID")
self.default_application_id = "litellm"
if monitor_mode is None:
@ -163,6 +161,7 @@ class NomaGuardrail(CustomGuardrail):
self,
request_data: dict,
user_auth: UserAPIKeyAuth,
event_type: Optional[GuardrailEventHooks] = None,
) -> Optional[str]:
"""Shared logic for processing user message checks"""
start_time = datetime.now()
@ -213,6 +212,7 @@ class NomaGuardrail(CustomGuardrail):
start_time=start_time.timestamp(),
end_time=end_time.timestamp(),
duration=duration,
event_type=event_type,
)
if self.monitor_mode:
@ -242,6 +242,7 @@ class NomaGuardrail(CustomGuardrail):
request_data: dict,
response: LLMResponse,
user_auth: UserAPIKeyAuth,
event_type: Optional[GuardrailEventHooks] = None,
) -> Optional[str]:
"""Shared logic for processing LLM response checks"""
@ -293,6 +294,7 @@ class NomaGuardrail(CustomGuardrail):
start_time=start_time.timestamp(),
end_time=end_time.timestamp(),
duration=duration,
event_type=event_type,
)
if self.monitor_mode:
@ -578,7 +580,6 @@ class NomaGuardrail(CustomGuardrail):
data: dict,
call_type: CallTypesLiteral,
) -> Optional[Union[Exception, str, dict]]:
verbose_proxy_logger.debug("Running Noma pre-call hook")
if (
@ -602,7 +603,9 @@ class NomaGuardrail(CustomGuardrail):
return data
try:
return await self._check_user_message(data, user_api_key_dict)
return await self._check_user_message(
data, user_api_key_dict, GuardrailEventHooks.pre_call
)
except NomaBlockedMessage:
# Blocked requests were already logged in _process_user_message_check with "blocked" status
raise
@ -619,6 +622,7 @@ class NomaGuardrail(CustomGuardrail):
start_time=start_time.timestamp(),
end_time=start_time.timestamp(),
duration=0.0,
event_type=GuardrailEventHooks.pre_call,
)
verbose_proxy_logger.error(f"Noma pre-call hook failed: {str(e)}")
@ -650,7 +654,9 @@ class NomaGuardrail(CustomGuardrail):
return data
try:
return await self._check_user_message(data, user_api_key_dict)
return await self._check_user_message(
data, user_api_key_dict, GuardrailEventHooks.during_call
)
except NomaBlockedMessage:
# Blocked requests were already logged in _process_user_message_check with "blocked" status
raise
@ -667,6 +673,7 @@ class NomaGuardrail(CustomGuardrail):
start_time=start_time.timestamp(),
end_time=start_time.timestamp(),
duration=0.0,
event_type=GuardrailEventHooks.during_call,
)
verbose_proxy_logger.error(f"Noma moderation hook failed: {str(e)}")
@ -700,7 +707,9 @@ class NomaGuardrail(CustomGuardrail):
return response
try:
return await self._check_llm_response(data, response, user_api_key_dict)
return await self._check_llm_response(
data, response, user_api_key_dict, GuardrailEventHooks.post_call
)
except NomaBlockedMessage:
# Blocked requests were already logged in _process_llm_response_check with "blocked" status
raise
@ -717,6 +726,7 @@ class NomaGuardrail(CustomGuardrail):
start_time=start_time.timestamp(),
end_time=start_time.timestamp(),
duration=0.0,
event_type=GuardrailEventHooks.post_call,
)
verbose_proxy_logger.error(f"Noma post-call hook failed: {str(e)}")
@ -728,9 +738,12 @@ class NomaGuardrail(CustomGuardrail):
self,
request_data: dict,
user_auth: UserAPIKeyAuth,
event_type: Optional[GuardrailEventHooks] = None,
) -> Union[Exception, str, dict, None]:
"""Check user message for policy violations"""
user_message = await self._process_user_message_check(request_data, user_auth)
user_message = await self._process_user_message_check(
request_data, user_auth, event_type
)
if not user_message:
return request_data
@ -741,10 +754,11 @@ class NomaGuardrail(CustomGuardrail):
request_data: dict,
response: LLMResponse,
user_auth: UserAPIKeyAuth,
event_type: Optional[GuardrailEventHooks] = None,
) -> Any:
"""Check LLM response for policy violations"""
content = await self._process_llm_response_check(
request_data, response, user_auth
request_data, response, user_auth, event_type
)
if not content:
return response
@ -858,7 +872,10 @@ class NomaGuardrail(CustomGuardrail):
if isinstance(assembled_model_response, ModelResponse):
try:
processed_response = await self._check_llm_response(
request_data, assembled_model_response, user_api_key_dict
request_data,
assembled_model_response,
user_api_key_dict,
GuardrailEventHooks.post_call,
)
except NomaBlockedMessage:
raise

View file

@ -24,6 +24,7 @@ from litellm.llms.custom_httpx.http_handler import (
httpxSpecialProvider,
)
from litellm.proxy._types import UserAPIKeyAuth
from litellm.types.guardrails import GuardrailEventHooks
from litellm.types.utils import CallTypesLiteral, ModelResponse
if TYPE_CHECKING:
@ -523,6 +524,7 @@ class PanwPrismaAirsHandler(CustomGuardrail):
scan_result: Dict[str, Any],
data: Dict[str, Any],
start_time: datetime,
event_type: GuardrailEventHooks,
is_response: bool = False,
) -> Optional[Dict[str, Any]]:
"""Handle API errors with fail-open/fail-closed logic."""
@ -542,6 +544,7 @@ class PanwPrismaAirsHandler(CustomGuardrail):
start_time=start_time.timestamp(),
end_time=end_time.timestamp(),
duration=duration,
event_type=event_type,
)
if scan_result.get("_always_block"):
@ -735,7 +738,11 @@ class PanwPrismaAirsHandler(CustomGuardrail):
if scan_result.get("_is_transient") or scan_result.get("_always_block"):
return self._handle_api_error_with_logging(
scan_result, data, start_time, is_response=False
scan_result,
data,
start_time,
is_response=False,
event_type=GuardrailEventHooks.pre_call,
)
end_time = datetime.now()
@ -749,6 +756,7 @@ class PanwPrismaAirsHandler(CustomGuardrail):
start_time=start_time.timestamp(),
end_time=end_time.timestamp(),
duration=(end_time - start_time).total_seconds(),
event_type=GuardrailEventHooks.pre_call,
)
action = scan_result.get("action", "block")
@ -872,7 +880,11 @@ class PanwPrismaAirsHandler(CustomGuardrail):
if scan_result.get("_is_transient") or scan_result.get("_always_block"):
self._handle_api_error_with_logging(
scan_result, data, start_time, is_response=True
scan_result,
data,
start_time,
is_response=True,
event_type=GuardrailEventHooks.post_call,
)
return response
@ -887,6 +899,7 @@ class PanwPrismaAirsHandler(CustomGuardrail):
start_time=start_time.timestamp(),
end_time=end_time.timestamp(),
duration=(end_time - start_time).total_seconds(),
event_type=GuardrailEventHooks.post_call,
)
action = scan_result.get("action", "block")
@ -1066,7 +1079,11 @@ class PanwPrismaAirsHandler(CustomGuardrail):
if scan_result.get("_is_transient") or scan_result.get("_always_block"):
self._handle_api_error_with_logging(
scan_result, request_data, start_time, is_response=True
scan_result,
request_data,
start_time,
is_response=True,
event_type=EventHooks.post_call,
)
for chunk in all_chunks:
yield chunk
@ -1083,6 +1100,7 @@ class PanwPrismaAirsHandler(CustomGuardrail):
start_time=start_time.timestamp(),
end_time=end_time.timestamp(),
duration=(end_time - start_time).total_seconds(),
event_type=EventHooks.post_call,
)
# Add guardrail to applied guardrails header for observability

View file

@ -296,117 +296,6 @@ if MCP_AVAILABLE:
access_groups_list = sorted(list(access_groups))
return {"access_groups": access_groups_list}
@router.get(
"/server/{server_id}/health",
description="Perform health check on a specific MCP server",
dependencies=[Depends(user_api_key_auth)],
)
async def health_check_mcp_server(
server_id: str,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Perform a health check on the MCP server specified by the `server_id`
Parameters:
- server_id: str - Required. The unique identifier of the mcp server to health check.
```
curl --location 'http://localhost:4000/v1/mcp/server/{server_id}/health' \
--header 'Authorization: Bearer your_api_key_here'
```
"""
# Check if server exists and user has access
prisma_client = get_prisma_client_or_throw(
"Database not connected. Connect a database to your proxy"
)
# check to see if server exists for all users
mcp_server = await get_mcp_server(prisma_client, server_id)
if mcp_server is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={"error": f"MCP Server with id {server_id} not found"},
)
# Implement authz restriction from requested user
if not _user_has_admin_view(user_api_key_dict):
# Perform authz check to filter the mcp servers user has access to
mcp_server_records = await get_all_mcp_servers_for_user(
prisma_client, user_api_key_dict
)
exists = does_mcp_server_exist(mcp_server_records, server_id)
if not exists:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": f"User does not have permission to access mcp server with id {server_id}. You can only access mcp servers that you have access to."
},
)
# Perform health check using server manager
try:
health_result = await global_mcp_server_manager.health_check_server(
server_id
)
return health_result
except Exception as e:
verbose_proxy_logger.exception(
f"Error performing health check on MCP server {server_id}: {str(e)}"
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"error": f"Error performing health check: {str(e)}"},
)
@router.get(
"/server/health",
description="Perform health check on all accessible MCP servers",
dependencies=[Depends(user_api_key_auth)],
)
async def health_check_all_mcp_servers(
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Perform health checks on all MCP servers accessible to the user
```
curl --location 'http://localhost:4000/v1/mcp/server/health' \
--header 'Authorization: Bearer your_api_key_here'
```
"""
# Use server manager to get health checks for allowed servers
try:
all_health_results = (
await global_mcp_server_manager.health_check_allowed_servers(
user_api_key_auth=user_api_key_dict
)
)
return {
"total_servers": len(all_health_results),
"healthy_count": len(
[r for r in all_health_results.values() if r["status"] == "healthy"]
),
"unhealthy_count": len(
[
r
for r in all_health_results.values()
if r["status"] == "unhealthy"
]
),
"unknown_count": len(
[r for r in all_health_results.values() if r["status"] == "unknown"]
),
"servers": all_health_results,
}
except Exception as e:
verbose_proxy_logger.exception(
f"Error performing health checks on MCP servers: {str(e)}"
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"error": f"Error performing health checks: {str(e)}"},
)
## FastAPI Routes
@router.get(
"/server",
@ -484,15 +373,11 @@ if MCP_AVAILABLE:
server_id
)
# Update the server object with health check results
mcp_server.status = health_result.get("status", "unknown")
mcp_server.last_health_check = (
datetime.fromisoformat(
health_result.get("last_health_check", datetime.now().isoformat())
)
if health_result.get("last_health_check")
else None
mcp_server.status = (
health_result.status if health_result.status else "unknown"
)
mcp_server.health_check_error = health_result.get("error")
mcp_server.last_health_check = health_result.last_health_check
mcp_server.health_check_error = health_result.health_check_error
except Exception as e:
verbose_proxy_logger.debug(
f"Error performing health check on server {server_id}: {e}"

View file

@ -498,7 +498,7 @@ class TestPassthroughCallTypeHandling:
def test_get_pre_call_type_with_allm_passthrough_route(self):
"""
Test that _get_pre_call_type correctly maps allm_passthrough_route.
This tests Fix #1: allm_passthrough_route was not being handled, causing call_type to be None.
"""
from litellm.proxy.common_request_processing import (
@ -509,14 +509,14 @@ class TestPassthroughCallTypeHandling:
result = ProxyBaseLLMRequestProcessing._get_pre_call_type(
route_type="allm_passthrough_route"
)
# Should return allm_passthrough_route, not None
assert result == "allm_passthrough_route"
def test_get_pre_call_type_preserves_standard_mappings(self):
"""
Test that _get_pre_call_type still correctly maps standard route types.
Ensures Fix #1 didn't break existing functionality.
"""
from litellm.proxy.common_request_processing import (
@ -536,3 +536,235 @@ class TestPassthroughCallTypeHandling:
ProxyBaseLLMRequestProcessing._get_pre_call_type(route_type="aresponses")
== "responses"
)
class TestEventTypeLogging:
"""Tests for event_type logging in guardrail information."""
@pytest.mark.asyncio
async def test_log_guardrail_information_infers_event_type_from_async_pre_call_hook(
self,
):
"""
Test that log_guardrail_information decorator correctly infers GuardrailEventHooks.pre_call
from async_pre_call_hook function name.
"""
from litellm.integrations.custom_guardrail import log_guardrail_information
from litellm.types.guardrails import GuardrailEventHooks
class TestGuardrail(CustomGuardrail):
def __init__(self):
super().__init__(
guardrail_name="test_event_type_guardrail",
event_hook=[
GuardrailEventHooks.pre_call,
GuardrailEventHooks.post_call,
],
)
@log_guardrail_information
async def async_pre_call_hook(self, data: dict, **kwargs):
return {"result": "pre_call_executed"}
guardrail = TestGuardrail()
request_data = {"metadata": {}}
await guardrail.async_pre_call_hook(data=request_data)
# Check that the guardrail_mode was set to pre_call (not the full list)
logged_info = request_data["metadata"]["standard_logging_guardrail_information"]
assert len(logged_info) == 1
assert logged_info[0]["guardrail_mode"] == GuardrailEventHooks.pre_call
@pytest.mark.asyncio
async def test_log_guardrail_information_infers_event_type_from_async_post_call_success_hook(
self,
):
"""
Test that log_guardrail_information decorator correctly infers GuardrailEventHooks.post_call
from async_post_call_success_hook function name.
"""
from litellm.integrations.custom_guardrail import log_guardrail_information
from litellm.types.guardrails import GuardrailEventHooks
class TestGuardrail(CustomGuardrail):
def __init__(self):
super().__init__(
guardrail_name="test_event_type_guardrail",
event_hook=[
GuardrailEventHooks.pre_call,
GuardrailEventHooks.post_call,
],
)
@log_guardrail_information
async def async_post_call_success_hook(self, data: dict, **kwargs):
return {"result": "post_call_executed"}
guardrail = TestGuardrail()
request_data = {"metadata": {}}
await guardrail.async_post_call_success_hook(data=request_data)
# Check that the guardrail_mode was set to post_call (not the full list)
logged_info = request_data["metadata"]["standard_logging_guardrail_information"]
assert len(logged_info) == 1
assert logged_info[0]["guardrail_mode"] == GuardrailEventHooks.post_call
@pytest.mark.asyncio
async def test_log_guardrail_information_infers_event_type_from_async_moderation_hook(
self,
):
"""
Test that log_guardrail_information decorator correctly infers GuardrailEventHooks.during_call
from async_moderation_hook function name.
"""
from litellm.integrations.custom_guardrail import log_guardrail_information
from litellm.types.guardrails import GuardrailEventHooks
class TestGuardrail(CustomGuardrail):
def __init__(self):
super().__init__(
guardrail_name="test_event_type_guardrail",
event_hook=[
GuardrailEventHooks.during_call,
GuardrailEventHooks.post_call,
],
)
@log_guardrail_information
async def async_moderation_hook(self, data: dict, **kwargs):
return {"result": "moderation_executed"}
guardrail = TestGuardrail()
request_data = {"metadata": {}}
await guardrail.async_moderation_hook(data=request_data)
# Check that the guardrail_mode was set to during_call (not the full list)
logged_info = request_data["metadata"]["standard_logging_guardrail_information"]
assert len(logged_info) == 1
assert logged_info[0]["guardrail_mode"] == GuardrailEventHooks.during_call
@pytest.mark.asyncio
async def test_log_guardrail_information_infers_event_type_from_async_post_call_streaming_hook(
self,
):
"""
Test that log_guardrail_information decorator correctly infers GuardrailEventHooks.post_call
from async_post_call_streaming_hook function name.
"""
from litellm.integrations.custom_guardrail import log_guardrail_information
from litellm.types.guardrails import GuardrailEventHooks
class TestGuardrail(CustomGuardrail):
def __init__(self):
super().__init__(
guardrail_name="test_event_type_guardrail",
event_hook=[
GuardrailEventHooks.pre_call,
GuardrailEventHooks.post_call,
],
)
@log_guardrail_information
async def async_post_call_streaming_hook(self, data: dict, **kwargs):
return {"result": "streaming_executed"}
guardrail = TestGuardrail()
request_data = {"metadata": {}}
await guardrail.async_post_call_streaming_hook(data=request_data)
# Check that the guardrail_mode was set to post_call (not the full list)
logged_info = request_data["metadata"]["standard_logging_guardrail_information"]
assert len(logged_info) == 1
assert logged_info[0]["guardrail_mode"] == GuardrailEventHooks.post_call
@pytest.mark.asyncio
async def test_log_guardrail_information_returns_none_for_unknown_function_name(
self,
):
"""
Test that log_guardrail_information decorator returns None for event_type
when function name doesn't match known patterns, and falls back to self.event_hook.
"""
from litellm.integrations.custom_guardrail import log_guardrail_information
from litellm.types.guardrails import GuardrailEventHooks
class TestGuardrail(CustomGuardrail):
def __init__(self):
super().__init__(
guardrail_name="test_event_type_guardrail",
event_hook=GuardrailEventHooks.pre_call,
)
@log_guardrail_information
async def some_other_hook(self, data: dict, **kwargs):
return {"result": "other_hook_executed"}
guardrail = TestGuardrail()
request_data = {"metadata": {}}
await guardrail.some_other_hook(data=request_data)
# Check that the guardrail_mode falls back to self.event_hook
logged_info = request_data["metadata"]["standard_logging_guardrail_information"]
assert len(logged_info) == 1
assert logged_info[0]["guardrail_mode"] == GuardrailEventHooks.pre_call
def test_add_standard_logging_uses_event_type_over_event_hook(self):
"""
Test that add_standard_logging_guardrail_information_to_request_data
prioritizes event_type parameter over self.event_hook.
"""
from litellm.types.guardrails import GuardrailEventHooks
guardrail = CustomGuardrail(
guardrail_name="test_guardrail",
event_hook=[GuardrailEventHooks.pre_call, GuardrailEventHooks.post_call],
)
request_data = {"metadata": {}}
# Call with explicit event_type
guardrail.add_standard_logging_guardrail_information_to_request_data(
guardrail_json_response={"result": "ok"},
request_data=request_data,
guardrail_status="success",
event_type=GuardrailEventHooks.post_call,
)
# Should use the provided event_type (post_call), not the full event_hook list
logged_info = request_data["metadata"]["standard_logging_guardrail_information"]
assert len(logged_info) == 1
assert logged_info[0]["guardrail_mode"] == GuardrailEventHooks.post_call
def test_add_standard_logging_falls_back_to_event_hook_when_event_type_is_none(
self,
):
"""
Test that add_standard_logging_guardrail_information_to_request_data
falls back to self.event_hook when event_type is None.
"""
from litellm.types.guardrails import GuardrailEventHooks
guardrail = CustomGuardrail(
guardrail_name="test_guardrail",
event_hook=GuardrailEventHooks.pre_call,
)
request_data = {"metadata": {}}
# Call with event_type=None
guardrail.add_standard_logging_guardrail_information_to_request_data(
guardrail_json_response={"result": "ok"},
request_data=request_data,
guardrail_status="success",
event_type=None,
)
# Should fall back to self.event_hook
logged_info = request_data["metadata"]["standard_logging_guardrail_information"]
assert len(logged_info) == 1
assert logged_info[0]["guardrail_mode"] == GuardrailEventHooks.pre_call

View file

@ -641,37 +641,31 @@ class TestMCPServerManager:
manager = MCPServerManager()
# Mock server
server = MagicMock()
server.server_id = "test-server"
server.name = "test-server"
server = MCPServer(
server_id="test-server",
name="test-server",
transport=MCPTransport.http,
auth_type=None,
authentication_token="test-token",
url="http://test-server.com",
)
manager.get_mcp_server_by_id = MagicMock(return_value=server)
# Mock successful _get_tools_from_server
async def mock_get_tools_from_server(
server,
mcp_auth_header=None,
raw_headers=None,
):
tool1 = MagicMock()
tool1.name = "tool1"
tool2 = MagicMock()
tool2.name = "tool2"
return [tool1, tool2]
manager._get_tools_from_server = mock_get_tools_from_server
# Mock successful client.run_with_session
mock_client = AsyncMock()
mock_client.run_with_session = AsyncMock(return_value="ok")
manager._create_mcp_client = MagicMock(return_value=mock_client)
# Perform health check
result = await manager.health_check_server("test-server")
# Verify results
assert result["server_id"] == "test-server"
assert result["status"] == "healthy"
assert result["tools_count"] == 2
assert result["error"] is None
assert "last_health_check" in result
assert "response_time_ms" in result
assert result["response_time_ms"] >= 0 # Allow 0 for very fast mocks
# Verify results - result is now LiteLLM_MCPServerTable
assert isinstance(result, LiteLLM_MCPServerTable)
assert result.server_id == "test-server"
assert result.status == "healthy"
assert result.health_check_error is None
assert result.last_health_check is not None
@pytest.mark.asyncio
async def test_health_check_server_unhealthy(self):
@ -679,32 +673,33 @@ class TestMCPServerManager:
manager = MCPServerManager()
# Mock server
server = MagicMock()
server.server_id = "test-server"
server.name = "test-server"
server = MCPServer(
server_id="test-server",
name="test-server",
transport=MCPTransport.http,
auth_type=None,
authentication_token="test-token",
url="http://test-server.com",
)
manager.get_mcp_server_by_id = MagicMock(return_value=server)
# Mock failed _get_tools_from_server
async def mock_get_tools_from_server(
server,
mcp_auth_header=None,
raw_headers=None,
):
raise Exception("Connection timeout")
manager._get_tools_from_server = mock_get_tools_from_server
# Mock failed client.run_with_session
mock_client = AsyncMock()
mock_client.run_with_session = AsyncMock(
side_effect=Exception("Connection timeout")
)
manager._create_mcp_client = MagicMock(return_value=mock_client)
# Perform health check
result = await manager.health_check_server("test-server")
# Verify results
assert result["server_id"] == "test-server"
assert result["status"] == "unhealthy"
assert result["error"] == "Connection timeout"
assert "last_health_check" in result
assert "response_time_ms" in result
assert result["response_time_ms"] >= 0 # Allow 0 for very fast mocks
assert isinstance(result, LiteLLM_MCPServerTable)
assert result.server_id == "test-server"
assert result.status == "unhealthy"
assert result.health_check_error == "Connection timeout"
assert result.last_health_check is not None
@pytest.mark.asyncio
async def test_health_check_server_not_found(self):
@ -718,104 +713,121 @@ class TestMCPServerManager:
result = await manager.health_check_server("non-existent-server")
# Verify results
assert result["server_id"] == "non-existent-server"
assert result["status"] == "unknown"
assert result["error"] == "Server not found"
assert result["response_time_ms"] is None
assert "last_health_check" in result
assert isinstance(result, LiteLLM_MCPServerTable)
assert result.server_id == "non-existent-server"
assert result.server_name is None
assert result.status == "unknown"
assert result.health_check_error == "Server not found"
assert result.last_health_check is not None
@pytest.mark.asyncio
async def test_health_check_all_servers(self):
"""Test health check for all servers"""
async def test_health_check_server_oauth2_skips_check(self):
"""Test that health check is skipped for OAuth2 servers and returns unknown status"""
manager = MCPServerManager()
# Mock servers
server1 = MagicMock()
server1.server_id = "server1"
server1.name = "server1"
server2 = MagicMock()
server2.server_id = "server2"
server2.name = "server2"
# Mock registry
manager.registry = {"server1": server1, "server2": server2}
# Mock get_mcp_server_by_id
def mock_get_server_by_id(server_id):
if server_id == "server1":
return server1
elif server_id == "server2":
return server2
return None
manager.get_mcp_server_by_id = mock_get_server_by_id
# Mock _get_tools_from_server with different results
async def mock_get_tools_from_server(
server,
mcp_auth_header=None,
raw_headers=None,
):
if server.server_id == "server1":
tool = MagicMock()
tool.name = "tool1"
return [tool]
elif server.server_id == "server2":
raise Exception("Connection failed")
return []
manager._get_tools_from_server = mock_get_tools_from_server
# Perform health check for all servers
result = await manager.health_check_all_servers()
# Verify results
assert len(result) == 2
assert "server1" in result
assert "server2" in result
# Check server1 (healthy)
assert result["server1"]["status"] == "healthy"
assert result["server1"]["tools_count"] == 1
assert result["server1"]["error"] is None
# Check server2 (unhealthy)
assert result["server2"]["status"] == "unhealthy"
assert result["server2"]["error"] == "Connection failed"
@pytest.mark.asyncio
async def test_health_check_server_with_auth_header(self):
"""Test health check with authentication header"""
manager = MCPServerManager()
# Mock server
server = MagicMock()
server.server_id = "test-server"
server.name = "test-server"
# Mock OAuth2 server
server = MCPServer(
server_id="oauth2-server",
name="oauth2-server",
transport=MCPTransport.http,
auth_type=MCPAuth.oauth2,
url="http://oauth2-server.com",
)
manager.get_mcp_server_by_id = MagicMock(return_value=server)
# Mock _get_tools_from_server to verify auth header is passed
async def mock_get_tools_from_server(
server,
mcp_auth_header=None,
raw_headers=None,
):
assert mcp_auth_header == "test-token"
tool = MagicMock()
tool.name = "tool1"
return [tool]
# _create_mcp_client should not be called for OAuth2 servers
manager._create_mcp_client = MagicMock()
manager._get_tools_from_server = mock_get_tools_from_server
# Perform health check
result = await manager.health_check_server("oauth2-server")
# Perform health check with auth header
result = await manager.health_check_server("test-server", "test-token")
# Verify that client was not created (health check was skipped)
manager._create_mcp_client.assert_not_called()
# Verify results
assert result["server_id"] == "test-server"
assert result["status"] == "healthy"
assert result["tools_count"] == 1
assert isinstance(result, LiteLLM_MCPServerTable)
assert result.server_id == "oauth2-server"
assert result.status == "unknown"
assert result.health_check_error is None
assert result.last_health_check is not None
@pytest.mark.asyncio
async def test_health_check_server_no_token_skips_check(self):
"""Test that health check is skipped when auth_type is set but authentication_token is missing"""
manager = MCPServerManager()
# Mock server with auth_type but no authentication_token
server = MCPServer(
server_id="no-token-server",
name="no-token-server",
transport=MCPTransport.http,
auth_type=MCPAuth.bearer_token,
authentication_token=None, # No token
url="http://no-token-server.com",
)
manager.get_mcp_server_by_id = MagicMock(return_value=server)
# _create_mcp_client should not be called
manager._create_mcp_client = MagicMock()
# Perform health check
result = await manager.health_check_server("no-token-server")
# Verify that client was not created (health check was skipped)
manager._create_mcp_client.assert_not_called()
# Verify results
assert isinstance(result, LiteLLM_MCPServerTable)
assert result.server_id == "no-token-server"
assert result.status == "unknown"
assert result.health_check_error is None
assert result.last_health_check is not None
@pytest.mark.asyncio
async def test_health_check_server_with_static_headers(self):
"""Test health check with static headers configured"""
manager = MCPServerManager()
# Mock server with static_headers
server = MCPServer(
server_id="test-server",
name="test-server",
transport=MCPTransport.http,
auth_type=None,
authentication_token="test-token",
url="http://test-server.com",
static_headers={"X-Custom-Header": "custom-value"},
)
manager.get_mcp_server_by_id = MagicMock(return_value=server)
# Mock successful client
mock_client = AsyncMock()
mock_client.run_with_session = AsyncMock(return_value="ok")
# Capture the extra_headers passed to _create_mcp_client
captured_extra_headers = None
def capture_create_mcp_client(server, mcp_auth_header, extra_headers, stdio_env):
nonlocal captured_extra_headers
captured_extra_headers = extra_headers
return mock_client
manager._create_mcp_client = MagicMock(side_effect=capture_create_mcp_client)
# Perform health check
result = await manager.health_check_server("test-server")
# Verify static headers were passed
assert captured_extra_headers == {"X-Custom-Header": "custom-value"}
# Verify results
assert isinstance(result, LiteLLM_MCPServerTable)
assert result.server_id == "test-server"
assert result.status == "healthy"
assert result.health_check_error is None
@pytest.mark.asyncio
async def test_pre_call_tool_check_allowed_tools_list_allows_tool(self):

View file

@ -486,11 +486,14 @@ class TestListMCPServers:
mock_server.credentials = {"auth_value": "top-secret"}
mock_prisma_client = MagicMock()
mock_health_result = {
"status": "healthy",
"last_health_check": datetime.now().isoformat(),
"error": None,
}
# Mock health check result as LiteLLM_MCPServerTable
mock_health_result = generate_mock_mcp_server_db_record(
server_id="server-1", alias="Server 1"
)
mock_health_result.status = "healthy"
mock_health_result.last_health_check = datetime.now()
mock_health_result.health_check_error = None
mock_user_auth = generate_mock_user_api_key_auth(
user_role=LitellmUserRoles.PROXY_ADMIN
@ -531,11 +534,14 @@ class TestListMCPServers:
delattr(mock_server, "credentials")
mock_prisma_client = MagicMock()
mock_health_result = {
"status": "healthy",
"last_health_check": datetime.now().isoformat(),
"error": None,
}
# Mock health check result as LiteLLM_MCPServerTable
mock_health_result = generate_mock_mcp_server_db_record(
server_id="server-2", alias="Server 2"
)
mock_health_result.status = "healthy"
mock_health_result.last_health_check = datetime.now()
mock_health_result.health_check_error = None
mock_user_auth = generate_mock_user_api_key_auth(
user_role=LitellmUserRoles.PROXY_ADMIN
@ -568,296 +574,6 @@ class TestListMCPServers:
assert result.status == "healthy"
class TestMCPHealthCheckEndpoints:
"""Test MCP health check endpoints"""
@pytest.mark.asyncio
async def test_health_check_mcp_server_success(self):
"""Test successful health check for a specific MCP server"""
# Mock server
mock_server = generate_mock_mcp_server_db_record(
server_id="test-server", alias="Test Server"
)
# Mock dependencies
mock_prisma_client = MagicMock()
# Mock global MCP server manager
mock_manager = MagicMock()
mock_manager.health_check_server = AsyncMock(
return_value={
"server_id": "test-server",
"server_name": "Test Server",
"status": "healthy",
"tools_count": 3,
"last_health_check": "2024-01-01T12:00:00",
"response_time_ms": 150.5,
"error": None,
}
)
mock_user_auth = generate_mock_user_api_key_auth(
user_role=LitellmUserRoles.PROXY_ADMIN
)
with patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=mock_prisma_client,
), patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints._user_has_admin_view",
return_value=True,
), patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager",
mock_manager,
), patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_mcp_server",
AsyncMock(return_value=mock_server),
):
# Import and call the function
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
health_check_mcp_server,
)
result = await health_check_mcp_server(
server_id="test-server", user_api_key_dict=mock_user_auth
)
# Verify results
assert result["server_id"] == "test-server"
assert result["server_name"] == "Test Server"
assert result["status"] == "healthy"
assert result["tools_count"] == 3
assert result["response_time_ms"] == 150.5
assert result["error"] is None
@pytest.mark.asyncio
async def test_health_check_mcp_server_not_found(self):
"""Test health check for a server that doesn't exist"""
# Mock dependencies
mock_prisma_client = MagicMock()
mock_user_auth = generate_mock_user_api_key_auth(
user_role=LitellmUserRoles.PROXY_ADMIN
)
with patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=mock_prisma_client,
), patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_mcp_server",
AsyncMock(return_value=None),
):
# Import and call the function
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
health_check_mcp_server,
)
# Should raise HTTPException
with pytest.raises(Exception) as exc_info:
await health_check_mcp_server(
server_id="non-existent-server", user_api_key_dict=mock_user_auth
)
assert "not found" in str(exc_info.value)
@pytest.mark.asyncio
async def test_health_check_mcp_server_unauthorized(self):
"""Test health check for a server user doesn't have access to"""
# Mock server
mock_server = generate_mock_mcp_server_db_record(
server_id="test-server", alias="Test Server"
)
# Mock dependencies
mock_prisma_client = MagicMock()
mock_user_auth = generate_mock_user_api_key_auth(
user_role=LitellmUserRoles.INTERNAL_USER # Non-admin user
)
# Mock user doesn't have access to this server
mock_user_servers = []
with patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=mock_prisma_client,
), patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints._user_has_admin_view",
return_value=False,
), patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_all_mcp_servers_for_user",
return_value=mock_user_servers,
), patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_mcp_server",
AsyncMock(return_value=mock_server),
):
# Import and call the function
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
health_check_mcp_server,
)
# Should raise HTTPException
with pytest.raises(Exception) as exc_info:
await health_check_mcp_server(
server_id="test-server", user_api_key_dict=mock_user_auth
)
assert "permission" in str(exc_info.value)
@pytest.mark.asyncio
async def test_health_check_all_mcp_servers(self):
"""Test health check for all accessible MCP servers"""
# Mock team records
team_records = [
generate_mock_team_record(
team_id="team1",
team_alias="Team 1",
organization_id="org1",
mcp_servers=["server1", "server2"],
)
]
# Mock DB servers
db_servers = [
generate_mock_mcp_server_db_record(server_id="server1"),
generate_mock_mcp_server_db_record(server_id="server2"),
]
# Mock dependencies
mock_prisma_client = MagicMock()
mock_prisma_client = setup_mock_prisma_client(
mock_prisma_client=mock_prisma_client,
team_records=team_records,
mcp_servers=db_servers,
)
# Mock global MCP server manager
mock_manager = MagicMock()
mock_manager.health_check_allowed_servers = AsyncMock(
return_value={
"server1": {
"server_id": "server1",
"server_name": "Test DB Server",
"status": "healthy",
"tools_count": 2,
"last_health_check": "2024-01-01T12:00:00",
"response_time_ms": 100.0,
"error": None,
},
"server2": {
"server_id": "server2",
"server_name": "Test DB Server",
"status": "unhealthy",
"last_health_check": "2024-01-01T12:00:00",
"response_time_ms": 5000.0,
"error": "Connection timeout",
},
}
)
mock_manager.get_allowed_mcp_servers = AsyncMock(
return_value=["server1", "server2"]
)
mock_user_auth = generate_mock_user_api_key_auth(
user_role=LitellmUserRoles.INTERNAL_USER
)
with patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=mock_prisma_client,
), patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints._user_has_admin_view",
return_value=False,
), patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager",
mock_manager,
):
# Import and call the function
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
health_check_all_mcp_servers,
)
result = await health_check_all_mcp_servers(
user_api_key_dict=mock_user_auth
)
# Verify results
assert result["total_servers"] == 2
assert result["healthy_count"] == 1
assert result["unhealthy_count"] == 1
assert result["unknown_count"] == 0
assert "server1" in result["servers"]
assert "server2" in result["servers"]
# Check individual server results
assert result["servers"]["server1"]["status"] == "healthy"
assert result["servers"]["server1"]["tools_count"] == 2
assert result["servers"]["server1"]["server_name"] == "Test DB Server"
assert result["servers"]["server2"]["status"] == "unhealthy"
assert result["servers"]["server2"]["error"] == "Connection timeout"
assert result["servers"]["server2"]["server_name"] == "Test DB Server"
@pytest.mark.asyncio
async def test_fetch_all_mcp_servers_with_health_status(self):
"""Test that fetch_all_mcp_servers includes health check status"""
# Mock server with health status
mock_server = generate_mock_mcp_server_db_record(
server_id="test-server", alias="Test Server"
)
# Add health status to the mock server
mock_server.status = "healthy"
mock_server.last_health_check = datetime.now()
mock_server.health_check_error = None
# Mock dependencies
mock_prisma_client = MagicMock()
mock_prisma_client = setup_mock_prisma_client(
mock_prisma_client=mock_prisma_client,
team_records=[],
mcp_servers=[], # Don't add servers here since we're mocking get_all_mcp_servers
)
# Mock global MCP server manager
mock_manager = MagicMock()
mock_manager.config_mcp_servers = {}
mock_manager.get_allowed_mcp_servers = AsyncMock(return_value=[])
mock_manager.get_all_mcp_servers_with_health_and_teams = AsyncMock(
return_value=[mock_server]
)
mock_server.credentials = {"auth_value": "secret"}
mock_user_auth = generate_mock_user_api_key_auth(
user_role=LitellmUserRoles.PROXY_ADMIN
)
with patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.get_prisma_client_or_throw",
return_value=mock_prisma_client,
), patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints._user_has_admin_view",
return_value=True,
), patch(
"litellm.proxy.management_endpoints.mcp_management_endpoints.global_mcp_server_manager",
mock_manager,
):
# Import and call the function
from litellm.proxy.management_endpoints.mcp_management_endpoints import (
fetch_all_mcp_servers,
)
result = await fetch_all_mcp_servers(user_api_key_dict=mock_user_auth)
# Verify health check status is included
assert len(result) == 1
server = result[0]
assert server.server_id == "test-server"
assert server.status == "healthy"
assert server.last_health_check is not None
assert server.health_check_error is None
assert server.credentials is None
class TestTemporaryMCPSessionEndpoints:
def test_inherit_credentials_from_existing_server(self):
payload = NewMCPServerRequest(
@ -1170,7 +886,6 @@ class TestTemporaryMCPSessionEndpoints:
fallback_client_id="server-1",
)
class TestUpdateMCPServer:
"""Test suite for update MCP server functionality"""