Add http support to custom code guardrails + Unified guardrails for MCP + Agent guardrail support (#20619)

* fix: fix styling

* fix(custom_code_guardrail.py): add http support for custom code guardrails

allows users to call external guardrails on litellm with minimal code changes (no custom handlers)

Test guardrail integrations more easily

* feat(a2a/): add guardrails for agent interactions

allows the same guardrails for llm's to be applied to agents as well

* fix(a2a/): support passing guardrails to a2a from the UI

* style(code-editor): allow editing custom code guardrails on ui + add examples of pre/post calls for custom code guardrails

* feat(mcp/): support custom code guardrails for mcp calls

allows custom code guardrails to work on mcp input

* feat(chatui.tsx): support guardrails on mcp tool calls on playground
This commit is contained in:
Krish Dholakia
2026-02-06 17:34:32 -08:00
committed by GitHub
parent 0a55571f75
commit ba74e6d9d2
21 changed files with 1862 additions and 240 deletions
@@ -61,15 +61,23 @@ curl -X POST http://localhost:4000/chat/completions \
### Function Signature
Your code must define an `apply_guardrail` function:
Your code must define an `apply_guardrail` function. It can be either sync or async:
```python
# Sync version
def apply_guardrail(inputs, request_data, input_type):
# inputs: see table below
# request_data: {"model": "...", "user_id": "...", "team_id": "...", "metadata": {...}}
# input_type: "request" or "response"
return allow() # or block() or modify()
# Async version (recommended when using HTTP primitives)
async def apply_guardrail(inputs, request_data, input_type):
response = await http_post("https://api.example.com/check", body={"text": inputs["texts"][0]})
if response["success"] and response["body"].get("flagged"):
return block("Content flagged")
return allow()
```
### `inputs` Parameter
@@ -145,6 +153,29 @@ def apply_guardrail(inputs, request_data, input_type):
| `char_count(text)` | Count characters |
| `lower(text)` / `upper(text)` / `trim(text)` | String transforms |
### HTTP Requests (Async)
Make async HTTP requests to external APIs for additional validation or content moderation.
| Function | Description |
|----------|-------------|
| `await http_request(url, method, headers, body, timeout)` | General async HTTP request |
| `await http_get(url, headers, timeout)` | Async GET request |
| `await http_post(url, body, headers, timeout)` | Async POST request |
**Response format:**
```python
{
"status_code": 200, # HTTP status code
"body": {...}, # Response body (parsed JSON or string)
"headers": {...}, # Response headers
"success": True, # True if status code is 2xx
"error": None # Error message if request failed
}
```
**Note:** When using HTTP primitives, define your function as `async def apply_guardrail(...)` for non-blocking execution.
## Examples
### Block PII (SSN)
@@ -213,6 +244,29 @@ def apply_guardrail(inputs, request_data, input_type):
return allow()
```
### Call External Moderation API (Async)
```python
async def apply_guardrail(inputs, request_data, input_type):
# Call an external moderation API
for text in inputs["texts"]:
response = await http_post(
"https://api.example.com/moderate",
body={"text": text, "user_id": request_data["user_id"]},
headers={"Authorization": "Bearer YOUR_API_KEY"},
timeout=10
)
if not response["success"]:
# API call failed - decide whether to allow or block
return allow()
if response["body"].get("flagged"):
return block(response["body"].get("reason", "Content flagged"))
return allow()
```
### Combine Multiple Checks
```python
@@ -241,8 +295,8 @@ Custom code runs in a restricted environment:
- ❌ No `import` statements
- ❌ No file I/O
- ❌ No network access
- ❌ No `exec()` or `eval()`
- ✅ HTTP requests via built-in `http_request`, `http_get`, `http_post` primitives
- ✅ Only LiteLLM-provided primitives available
## Per-Request Usage
+1
View File
@@ -268,6 +268,7 @@ class CustomGuardrail(CustomLogger):
"""
Returns the guardrail(s) to be run from the metadata or root
"""
if "guardrails" in data:
return data["guardrails"]
metadata = data.get("litellm_metadata") or data.get("metadata", {})
@@ -0,0 +1,155 @@
# A2A Protocol Guardrail Translation Handler
Handler for processing A2A (Agent-to-Agent) Protocol messages with guardrails.
## Overview
This handler processes A2A JSON-RPC 2.0 input/output by:
1. Extracting text from message parts (`kind: "text"`)
2. Applying guardrails to text content
3. Mapping guardrailed text back to original structure
## A2A Protocol Format
### Input Format (JSON-RPC 2.0)
```json
{
"jsonrpc": "2.0",
"id": "request-id",
"method": "message/send",
"params": {
"message": {
"kind": "message",
"messageId": "...",
"role": "user",
"parts": [
{"kind": "text", "text": "Hello, my SSN is 123-45-6789"}
]
},
"metadata": {
"guardrails": ["block-ssn"]
}
}
}
```
### Output Formats
The handler supports multiple A2A response formats:
**Direct message:**
```json
{
"result": {
"kind": "message",
"parts": [{"kind": "text", "text": "Response text"}]
}
}
```
**Nested message:**
```json
{
"result": {
"message": {
"parts": [{"kind": "text", "text": "Response text"}]
}
}
}
```
**Task with artifacts:**
```json
{
"result": {
"kind": "task",
"artifacts": [
{"parts": [{"kind": "text", "text": "Artifact text"}]}
]
}
}
```
**Task with status message:**
```json
{
"result": {
"kind": "task",
"status": {
"message": {
"parts": [{"kind": "text", "text": "Status message"}]
}
}
}
}
```
**Streaming artifact-update:**
```json
{
"result": {
"kind": "artifact-update",
"artifact": {
"parts": [{"kind": "text", "text": "Streaming text"}]
}
}
}
```
## Usage
The handler is automatically discovered and applied when guardrails are used with A2A endpoints.
### Via LiteLLM Proxy
```bash
curl -X POST 'http://localhost:4000/a2a/my-agent' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer your-api-key' \
-d '{
"jsonrpc": "2.0",
"id": "1",
"method": "message/send",
"params": {
"message": {
"kind": "message",
"messageId": "msg-1",
"role": "user",
"parts": [{"kind": "text", "text": "Hello, my SSN is 123-45-6789"}]
},
"metadata": {
"guardrails": ["block-ssn"]
}
}
}'
```
### Specifying Guardrails
Guardrails can be specified in the A2A request via the `metadata.guardrails` field:
```json
{
"params": {
"message": {...},
"metadata": {
"guardrails": ["block-ssn", "pii-filter"]
}
}
}
```
## Extension
Override these methods to customize behavior:
- `_extract_texts_from_result()`: Custom text extraction from A2A responses
- `_extract_texts_from_parts()`: Custom text extraction from message parts
- `_apply_text_to_path()`: Custom application of guardrailed text
## Call Types
This handler is registered for:
- `CallTypes.send_message`: Synchronous A2A message sending
- `CallTypes.asend_message`: Asynchronous A2A message sending
@@ -0,0 +1,11 @@
"""A2A Protocol handler for Unified Guardrails."""
from litellm.llms.a2a.chat.guardrail_translation.handler import A2AGuardrailHandler
from litellm.types.utils import CallTypes
guardrail_translation_mappings = {
CallTypes.send_message: A2AGuardrailHandler,
CallTypes.asend_message: A2AGuardrailHandler,
}
__all__ = ["guardrail_translation_mappings"]
@@ -0,0 +1,315 @@
"""
A2A Protocol Handler for Unified Guardrails
This module provides guardrail translation support for A2A (Agent-to-Agent) Protocol.
It handles both JSON-RPC 2.0 input requests and output responses, extracting text
from message parts and applying guardrails.
A2A Protocol Format:
- Input: JSON-RPC 2.0 with params.message.parts containing text parts
- Output: JSON-RPC 2.0 with result containing message/artifact parts
"""
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union
from litellm._logging import verbose_proxy_logger
from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation
from litellm.types.utils import GenericGuardrailAPIInputs
if TYPE_CHECKING:
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
from litellm.proxy._types import UserAPIKeyAuth
class A2AGuardrailHandler(BaseTranslation):
"""
Handler for processing A2A Protocol messages with guardrails.
This class provides methods to:
1. Process input messages (pre-call hook) - extracts text from A2A message parts
2. Process output responses (post-call hook) - extracts text from A2A response parts
A2A Message Format:
- Input: params.message.parts[].text (where kind == "text")
- Output: result.message.parts[].text or result.artifacts[].parts[].text
"""
async def process_input_messages(
self,
data: dict,
guardrail_to_apply: "CustomGuardrail",
litellm_logging_obj: Optional["LiteLLMLoggingObj"] = None,
) -> Any:
"""
Process A2A input messages by applying guardrails to text content.
Extracts text from A2A message parts and applies guardrails.
Args:
data: The A2A JSON-RPC 2.0 request data
guardrail_to_apply: The guardrail instance to apply
litellm_logging_obj: Optional logging object
Returns:
Modified data with guardrails applied to text content
"""
# A2A request format: { "params": { "message": { "parts": [...] } } }
params = data.get("params", {})
message = params.get("message", {})
parts = message.get("parts", [])
if not parts:
verbose_proxy_logger.debug("A2A: No parts in message, skipping guardrail")
return data
texts_to_check: List[str] = []
text_part_indices: List[int] = [] # Track which parts contain text
# Step 1: Extract text from all text parts
for part_idx, part in enumerate(parts):
if part.get("kind") == "text":
text = part.get("text", "")
if text:
texts_to_check.append(text)
text_part_indices.append(part_idx)
# Step 2: Apply guardrail to all texts in batch
if texts_to_check:
inputs = GenericGuardrailAPIInputs(texts=texts_to_check)
# Pass the structured A2A message to guardrails
inputs["structured_messages"] = [message]
# Include agent model info if available
model = data.get("model")
if model:
inputs["model"] = model
guardrailed_inputs = await guardrail_to_apply.apply_guardrail(
inputs=inputs,
request_data=data,
input_type="request",
logging_obj=litellm_logging_obj,
)
guardrailed_texts = guardrailed_inputs.get("texts", [])
# Step 3: Apply guardrailed text back to original parts
if guardrailed_texts and len(guardrailed_texts) == len(text_part_indices):
for task_idx, part_idx in enumerate(text_part_indices):
parts[part_idx]["text"] = guardrailed_texts[task_idx]
verbose_proxy_logger.debug("A2A: Processed input message: %s", message)
return data
async def process_output_response(
self,
response: Any,
guardrail_to_apply: "CustomGuardrail",
litellm_logging_obj: Optional["LiteLLMLoggingObj"] = None,
user_api_key_dict: Optional["UserAPIKeyAuth"] = None,
) -> Any:
"""
Process A2A output response by applying guardrails to text content.
Handles multiple A2A response formats:
- Direct message: {"result": {"kind": "message", "parts": [...]}}
- Nested message: {"result": {"message": {"parts": [...]}}}
- Task with artifacts: {"result": {"kind": "task", "artifacts": [{"parts": [...]}]}}
- Task with status message: {"result": {"kind": "task", "status": {"message": {"parts": [...]}}}}
Args:
response: A2A JSON-RPC 2.0 response dict or object
guardrail_to_apply: The guardrail instance to apply
litellm_logging_obj: Optional logging object
user_api_key_dict: User API key metadata
Returns:
Modified response with guardrails applied to text content
"""
# Handle both dict and Pydantic model responses
if hasattr(response, "model_dump"):
response_dict = response.model_dump()
is_pydantic = True
elif isinstance(response, dict):
response_dict = response
is_pydantic = False
else:
verbose_proxy_logger.warning(
"A2A: Unknown response type %s, skipping guardrail", type(response)
)
return response
result = response_dict.get("result", {})
if not result or not isinstance(result, dict):
verbose_proxy_logger.debug("A2A: No result in response, skipping guardrail")
return response
# Find all text-containing parts in the response
texts_to_check: List[str] = []
# Each mapping is (path_to_parts_list, part_index)
# path_to_parts_list is a tuple of keys to navigate to the parts list
task_mappings: List[Tuple[Tuple[str, ...], int]] = []
# Extract texts from all possible locations
self._extract_texts_from_result(
result=result,
texts_to_check=texts_to_check,
task_mappings=task_mappings,
)
if not texts_to_check:
verbose_proxy_logger.debug("A2A: No text content in response")
return response
# Step 2: Apply guardrail to all texts in batch
# Create a request_data dict with response info and user API key metadata
request_data: dict = {"response": response_dict}
# Add user API key metadata with prefixed keys
user_metadata = self.transform_user_api_key_dict_to_metadata(user_api_key_dict)
if user_metadata:
request_data["litellm_metadata"] = user_metadata
inputs = GenericGuardrailAPIInputs(texts=texts_to_check)
guardrailed_inputs = await guardrail_to_apply.apply_guardrail(
inputs=inputs,
request_data=request_data,
input_type="response",
logging_obj=litellm_logging_obj,
)
guardrailed_texts = guardrailed_inputs.get("texts", [])
# Step 3: Apply guardrailed text back to original response
if guardrailed_texts and len(guardrailed_texts) == len(task_mappings):
for task_idx, (path, part_idx) in enumerate(task_mappings):
self._apply_text_to_path(
result=result,
path=path,
part_idx=part_idx,
text=guardrailed_texts[task_idx],
)
verbose_proxy_logger.debug("A2A: Processed output response")
# Update the original response
if is_pydantic:
# For Pydantic models, we need to update the underlying dict
# and the model will reflect the changes
response_dict["result"] = result
return response
else:
response["result"] = result
return response
def _extract_texts_from_result(
self,
result: Dict[str, Any],
texts_to_check: List[str],
task_mappings: List[Tuple[Tuple[str, ...], int]],
) -> None:
"""
Extract text from all possible locations in an A2A result.
Handles multiple response formats:
1. Direct message with parts: {"parts": [...]}
2. Nested message: {"message": {"parts": [...]}}
3. Task with artifacts: {"artifacts": [{"parts": [...]}]}
4. Task with status message: {"status": {"message": {"parts": [...]}}}
5. Streaming artifact-update: {"artifact": {"parts": [...]}}
"""
# Case 1: Direct parts in result (direct message)
if "parts" in result:
self._extract_texts_from_parts(
parts=result["parts"],
path=("parts",),
texts_to_check=texts_to_check,
task_mappings=task_mappings,
)
# Case 2: Nested message
message = result.get("message")
if message and isinstance(message, dict) and "parts" in message:
self._extract_texts_from_parts(
parts=message["parts"],
path=("message", "parts"),
texts_to_check=texts_to_check,
task_mappings=task_mappings,
)
# Case 3: Streaming artifact-update (singular artifact)
artifact = result.get("artifact")
if artifact and isinstance(artifact, dict) and "parts" in artifact:
self._extract_texts_from_parts(
parts=artifact["parts"],
path=("artifact", "parts"),
texts_to_check=texts_to_check,
task_mappings=task_mappings,
)
# Case 4: Task with status message
status = result.get("status", {})
if isinstance(status, dict):
status_message = status.get("message")
if (
status_message
and isinstance(status_message, dict)
and "parts" in status_message
):
self._extract_texts_from_parts(
parts=status_message["parts"],
path=("status", "message", "parts"),
texts_to_check=texts_to_check,
task_mappings=task_mappings,
)
# Case 5: Task with artifacts (plural, array)
artifacts = result.get("artifacts", [])
if artifacts and isinstance(artifacts, list):
for artifact_idx, art in enumerate(artifacts):
if isinstance(art, dict) and "parts" in art:
self._extract_texts_from_parts(
parts=art["parts"],
path=("artifacts", str(artifact_idx), "parts"),
texts_to_check=texts_to_check,
task_mappings=task_mappings,
)
def _extract_texts_from_parts(
self,
parts: List[Dict[str, Any]],
path: Tuple[str, ...],
texts_to_check: List[str],
task_mappings: List[Tuple[Tuple[str, ...], int]],
) -> None:
"""Extract text from message parts."""
for part_idx, part in enumerate(parts):
if part.get("kind") == "text":
text = part.get("text", "")
if text:
texts_to_check.append(text)
task_mappings.append((path, part_idx))
def _apply_text_to_path(
self,
result: Dict[Union[str, int], Any],
path: Tuple[str, ...],
part_idx: int,
text: str,
) -> None:
"""Apply guardrailed text back to the specified path in the result."""
# Navigate to the parts list
current = result
for key in path:
if key.isdigit():
# Array index
current = current[int(key)]
else:
current = current[key]
# Update the text in the part
current[part_idx]["text"] = text
@@ -1,26 +1,37 @@
"""
MCP Guardrail Handler for Unified Guardrails.
This handler works with the synthetic "messages" payload generated by
`ProxyLogging._convert_mcp_to_llm_format`, which always produces a single user
message whose `content` string encodes the MCP tool name and arguments. The
handler simply feeds that text through the configured guardrail and writes the
result back onto the message.
Converts an MCP call_tool (name + arguments) into a single OpenAI-compatible
tool_call and passes it to apply_guardrail. Works with the synthetic payload
from ProxyLogging._convert_mcp_to_llm_format.
Note: For MCP tool definitions (schema) -> OpenAI tools=[], see
litellm.experimental_mcp_client.tools.transform_mcp_tool_to_openai_tool
when you have a full MCP Tool from list_tools. Here we only have the call
payload (name + arguments) so we just build the tool_call.
"""
from typing import TYPE_CHECKING, Any, Dict, Optional
from mcp.types import Tool as MCPTool
from litellm._logging import verbose_proxy_logger
from litellm.experimental_mcp_client.tools import transform_mcp_tool_to_openai_tool
from litellm.llms.base_llm.guardrail_translation.base_translation import BaseTranslation
from litellm.types.llms.openai import (
ChatCompletionToolParam,
ChatCompletionToolParamFunctionChunk,
)
from litellm.types.utils import GenericGuardrailAPIInputs
if TYPE_CHECKING:
from litellm.integrations.custom_guardrail import CustomGuardrail
from mcp.types import CallToolResult
from litellm.integrations.custom_guardrail import CustomGuardrail
class MCPGuardrailTranslationHandler(BaseTranslation):
"""Guardrail translation handler for MCP tool calls."""
"""Guardrail translation handler for MCP tool calls (passes a single tool_call to guardrail)."""
async def process_input_messages(
self,
@@ -28,56 +39,51 @@ class MCPGuardrailTranslationHandler(BaseTranslation):
guardrail_to_apply: "CustomGuardrail",
litellm_logging_obj: Optional[Any] = None,
) -> Dict[str, Any]:
messages = data.get("messages")
if not isinstance(messages, list) or not messages:
verbose_proxy_logger.debug("MCP Guardrail: No messages to process")
mcp_tool_name = data.get("mcp_tool_name") or data.get("name")
mcp_arguments = data.get("mcp_arguments") or data.get("arguments")
mcp_tool_description = data.get("mcp_tool_description") or data.get(
"description"
)
if mcp_arguments is None or not isinstance(mcp_arguments, dict):
mcp_arguments = {}
if not mcp_tool_name:
verbose_proxy_logger.debug("MCP Guardrail: mcp_tool_name missing")
return data
first_message = messages[0]
content: Optional[str] = None
if isinstance(first_message, dict):
content = first_message.get("content")
else:
content = getattr(first_message, "content", None)
# Convert MCP input via transform_mcp_tool_to_openai_tool, then map to litellm
# ChatCompletionToolParam (openai SDK type has incompatible strict/cache_control).
mcp_tool = MCPTool(
name=mcp_tool_name,
description=mcp_tool_description or "",
inputSchema={}, # Call payload has no schema; guardrail gets args from request_data
)
openai_tool = transform_mcp_tool_to_openai_tool(mcp_tool)
fn = openai_tool["function"]
tool_def: ChatCompletionToolParam = {
"type": "function",
"function": ChatCompletionToolParamFunctionChunk(
name=fn["name"],
description=fn.get("description") or "",
parameters=fn.get("parameters")
or {
"type": "object",
"properties": {},
"additionalProperties": False,
},
strict=fn.get("strict", False) or False, # Default to False if None
),
}
inputs: GenericGuardrailAPIInputs = GenericGuardrailAPIInputs(
tools=[tool_def],
)
if not isinstance(content, str):
verbose_proxy_logger.debug(
"MCP Guardrail: Message content missing or not a string",
)
return data
inputs = GenericGuardrailAPIInputs(texts=[content])
# Include model information if available
model = data.get("model")
if model:
inputs["model"] = model
guardrailed_inputs = await guardrail_to_apply.apply_guardrail(
await guardrail_to_apply.apply_guardrail(
inputs=inputs,
request_data=data,
input_type="request",
logging_obj=litellm_logging_obj,
)
guardrailed_texts = (
guardrailed_inputs.get("texts", []) if guardrailed_inputs else []
)
if guardrailed_texts:
new_content = guardrailed_texts[0]
if isinstance(first_message, dict):
first_message["content"] = new_content
else:
setattr(first_message, "content", new_content)
verbose_proxy_logger.debug(
"MCP Guardrail: Updated content for tool %s",
data.get("mcp_tool_name"),
)
else:
verbose_proxy_logger.debug(
"MCP Guardrail: Guardrail returned no text updates for tool %s",
data.get("mcp_tool_name"),
)
return data
async def process_output_response(
@@ -87,7 +93,6 @@ class MCPGuardrailTranslationHandler(BaseTranslation):
litellm_logging_obj: Optional[Any] = None,
user_api_key_dict: Optional[Any] = None,
) -> Any:
# Not implemented: MCP guardrail translation never calls this path today.
verbose_proxy_logger.debug(
"MCP Guardrail: Output processing not implemented for MCP tools",
)
@@ -65,12 +65,13 @@ from litellm.types.mcp_server.mcp_server_manager import (
from litellm.types.utils import CallTypes
try:
from mcp.shared.tool_name_validation import ( # type: ignore
SEP_986_URL,
validate_tool_name,
from mcp.shared.tool_name_validation import (
validate_tool_name, # type: ignore[reportAssignmentType]
)
from mcp.shared.tool_name_validation import SEP_986_URL
except ImportError:
from pydantic import BaseModel
SEP_986_URL = "https://github.com/modelcontextprotocol/protocol/blob/main/proposals/0001-tool-name-validation.md"
class ToolNameValidationResult(BaseModel):
@@ -469,12 +470,12 @@ class MCPServerManager:
)
# Update tool name to server name mapping (for both prefixed and base names)
self.tool_name_to_mcp_server_name_mapping[
base_tool_name
] = server_prefix
self.tool_name_to_mcp_server_name_mapping[
prefixed_tool_name
] = server_prefix
self.tool_name_to_mcp_server_name_mapping[base_tool_name] = (
server_prefix
)
self.tool_name_to_mcp_server_name_mapping[prefixed_tool_name] = (
server_prefix
)
registered_count += 1
verbose_logger.debug(
@@ -1929,7 +1930,9 @@ class MCPServerManager:
)
async def _call_tool_via_client(client, params):
return await client.call_tool(params, host_progress_callback=host_progress_callback)
return await client.call_tool(
params, host_progress_callback=host_progress_callback
)
tasks.append(
asyncio.create_task(_call_tool_via_client(client, call_tool_params))
@@ -1967,7 +1970,6 @@ class MCPServerManager:
oauth2_headers: Optional[Dict[str, str]] = None,
raw_headers: Optional[Dict[str, str]] = None,
host_progress_callback: Optional[Callable] = None,
) -> CallToolResult:
"""
Call a tool with the given name and arguments
@@ -12,6 +12,7 @@ from litellm.proxy._experimental.mcp_server.utils import merge_mcp_headers
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.types.mcp import MCPAuth
from litellm.types.utils import CallTypes
MCP_AVAILABLE: bool = True
try:
@@ -28,6 +29,7 @@ router = APIRouter(
if MCP_AVAILABLE:
from mcp.types import Tool as MCPTool
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
@@ -96,6 +98,35 @@ if MCP_AVAILABLE:
return _create_tool_response_objects(tools, server.mcp_info)
async def _resolve_allowed_mcp_servers_for_tool_call(
user_api_key_dict: UserAPIKeyAuth,
server_id: str,
) -> List[MCPServer]:
"""Resolve allowed MCP servers for the given user and validate server_id access."""
auth_contexts = await build_effective_auth_contexts(user_api_key_dict)
allowed_server_ids_set = set()
for auth_context in auth_contexts:
servers = await global_mcp_server_manager.get_allowed_mcp_servers(
user_api_key_auth=auth_context
)
allowed_server_ids_set.update(servers)
if server_id not in allowed_server_ids_set:
raise HTTPException(
status_code=403,
detail={
"error": "access_denied",
"message": f"The key is not allowed to access server {server_id}",
},
)
allowed_mcp_servers: List[MCPServer] = []
for allowed_server_id in allowed_server_ids_set:
server = global_mcp_server_manager.get_mcp_server_by_id(
allowed_server_id
)
if server is not None:
allowed_mcp_servers.append(server)
return allowed_mcp_servers
########################################################
@router.get("/tools/list", dependencies=[Depends(user_api_key_auth)])
async def list_tool_rest_api(
@@ -261,7 +292,14 @@ if MCP_AVAILABLE:
from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
MCPRequestHandler,
)
from litellm.proxy.proxy_server import add_litellm_data_to_request, proxy_config
from litellm.proxy.common_request_processing import (
ProxyBaseLLMRequestProcessing,
)
from litellm.proxy.proxy_server import (
general_settings,
proxy_config,
proxy_logging_obj,
)
try:
data = await request.json()
@@ -289,11 +327,16 @@ if MCP_AVAILABLE:
tool_arguments = data.get("arguments")
data = await add_litellm_data_to_request(
data=data,
request=request,
user_api_key_dict=user_api_key_dict,
proxy_config=proxy_config,
proxy_base_llm_response_processor = ProxyBaseLLMRequestProcessing(data=data)
data, logging_obj = (
await proxy_base_llm_response_processor.common_processing_pre_call_logic(
request=request,
user_api_key_dict=user_api_key_dict,
proxy_config=proxy_config,
route_type=CallTypes.call_mcp_tool.value,
proxy_logging_obj=proxy_logging_obj,
general_settings=general_settings,
)
)
# FIX: Extract MCP auth headers from request
@@ -322,35 +365,9 @@ if MCP_AVAILABLE:
if "metadata" in data and "user_api_key_auth" in data["metadata"]:
data["user_api_key_auth"] = data["metadata"]["user_api_key_auth"]
# Get all auth contexts
auth_contexts = await build_effective_auth_contexts(user_api_key_dict)
# Collect allowed server IDs from all contexts
allowed_server_ids_set = set()
for auth_context in auth_contexts:
servers = await global_mcp_server_manager.get_allowed_mcp_servers(
user_api_key_auth=auth_context
)
allowed_server_ids_set.update(servers)
# Check if the specified server_id is allowed
if server_id not in allowed_server_ids_set:
raise HTTPException(
status_code=403,
detail={
"error": "access_denied",
"message": f"The key is not allowed to access server {server_id}",
},
)
# Build allowed_mcp_servers list (only include allowed servers)
allowed_mcp_servers: List[MCPServer] = []
for allowed_server_id in allowed_server_ids_set:
server = global_mcp_server_manager.get_mcp_server_by_id(
allowed_server_id
)
if server is not None:
allowed_mcp_servers.append(server)
allowed_mcp_servers = await _resolve_allowed_mcp_servers_for_tool_call(
user_api_key_dict, server_id
)
# Call execute_mcp_tool directly (permission checks already done)
result = await execute_mcp_tool(
+24 -9
View File
@@ -14,6 +14,7 @@ from fastapi.responses import JSONResponse, StreamingResponse
from litellm._logging import verbose_proxy_logger
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.types.utils import all_litellm_params
router = APIRouter()
@@ -75,10 +76,7 @@ async def _handle_stream_message(
return StreamingResponse(_error_stream(), media_type="application/x-ndjson")
from a2a.types import (
MessageSendParams,
SendStreamingMessageRequest,
)
from a2a.types import MessageSendParams, SendStreamingMessageRequest
async def stream_response():
try:
@@ -208,16 +206,17 @@ async def invoke_agent_a2a(
from litellm.proxy.agent_endpoints.auth.agent_permission_handler import (
AgentRequestHandler,
)
from litellm.proxy.litellm_pre_call_utils import add_litellm_data_to_request
from litellm.proxy.proxy_server import (
general_settings,
proxy_config,
proxy_logging_obj,
version,
)
body = {}
try:
body = await request.json()
verbose_proxy_logger.debug(f"A2A request for agent '{agent_id}': {body}")
# Validate JSON-RPC format
@@ -230,6 +229,16 @@ async def invoke_agent_a2a(
method = body.get("method")
params = body.get("params", {})
if params:
# extract any litellm params from the params - eg. 'guardrails'
params_to_remove = []
for key, value in params.items():
if key in all_litellm_params:
params_to_remove.append(key)
body[key] = value
for key in params_to_remove:
params.pop(key)
if not A2A_SDK_AVAILABLE:
return _jsonrpc_error(
request_id,
@@ -283,12 +292,18 @@ async def invoke_agent_a2a(
)
# Add litellm data (user_api_key, user_id, team_id, etc.)
data = await add_litellm_data_to_request(
data=body,
from litellm.proxy.common_request_processing import (
ProxyBaseLLMRequestProcessing,
)
processor = ProxyBaseLLMRequestProcessing(data=body)
data, logging_obj = await processor.common_processing_pre_call_logic(
request=request,
user_api_key_dict=user_api_key_dict,
proxy_config=proxy_config,
general_settings=general_settings,
user_api_key_dict=user_api_key_dict,
proxy_logging_obj=proxy_logging_obj,
proxy_config=proxy_config,
route_type="asend_message",
version=version,
)
+11 -5
View File
@@ -267,7 +267,9 @@ def _override_openai_response_model(
hidden_params = getattr(response_obj, "_hidden_params", {}) or {}
if isinstance(hidden_params, dict):
fallback_headers = hidden_params.get("additional_headers", {}) or {}
attempted_fallbacks = fallback_headers.get("x-litellm-attempted-fallbacks", None)
attempted_fallbacks = fallback_headers.get(
"x-litellm-attempted-fallbacks", None
)
if attempted_fallbacks is not None and attempted_fallbacks > 0:
# A fallback occurred - preserve the actual model that was used
verbose_proxy_logger.debug(
@@ -517,6 +519,8 @@ class ProxyBaseLLMRequestProcessing:
"aget_interaction",
"adelete_interaction",
"acancel_interaction",
"asend_message",
"call_mcp_tool",
],
version: Optional[str] = None,
user_model: Optional[str] = None,
@@ -837,7 +841,9 @@ class ProxyBaseLLMRequestProcessing:
# aliasing/routing, but the OpenAI-compatible response `model` field should reflect
# what the client sent.
if requested_model_from_client:
self.data["_litellm_client_requested_model"] = requested_model_from_client
self.data["_litellm_client_requested_model"] = (
requested_model_from_client
)
if route_type == "allm_passthrough_route":
# Check if response is an async generator
if self._is_streaming_response(response):
@@ -1409,9 +1415,9 @@ class ProxyBaseLLMRequestProcessing:
# Add cache-related fields to **params (handled by Usage.__init__)
if cache_creation_input_tokens is not None:
usage_kwargs[
"cache_creation_input_tokens"
] = cache_creation_input_tokens
usage_kwargs["cache_creation_input_tokens"] = (
cache_creation_input_tokens
)
if cache_read_input_tokens is not None:
usage_kwargs["cache_read_input_tokens"] = cache_read_input_tokens
@@ -5,7 +5,7 @@ This module provides a guardrail that executes user-defined Python-like code
to implement custom guardrail logic. The code runs in a sandboxed environment
with access to LiteLLM-provided primitives for common guardrail operations.
Example custom code:
Example custom code (sync):
def apply_guardrail(inputs, request_data, input_type):
'''Block messages containing SSNs'''
@@ -13,8 +13,22 @@ Example custom code:
if regex_match(text, r"\\d{3}-\\d{2}-\\d{4}"):
return block("Social Security Number detected")
return allow()
Example custom code (async with HTTP):
async def apply_guardrail(inputs, request_data, input_type):
'''Call external moderation API'''
for text in inputs["texts"]:
response = await http_post(
"https://api.example.com/moderate",
body={"text": text}
)
if response["success"] and response["body"].get("flagged"):
return block("Content flagged by moderation API")
return allow()
"""
import asyncio
import threading
from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Type, cast
@@ -101,6 +115,9 @@ class CustomCodeGuardrail(CustomGuardrail):
GuardrailEventHooks.pre_call,
GuardrailEventHooks.during_call,
GuardrailEventHooks.post_call,
GuardrailEventHooks.pre_mcp_call,
GuardrailEventHooks.during_mcp_call,
GuardrailEventHooks.logging_only,
]
super().__init__(
@@ -175,6 +192,13 @@ class CustomCodeGuardrail(CustomGuardrail):
This method calls the user-defined apply_guardrail function and
processes its result to determine the appropriate action.
The user-defined function can be either sync or async:
- Sync: def apply_guardrail(inputs, request_data, input_type): ...
- Async: async def apply_guardrail(inputs, request_data, input_type): ...
Async functions are recommended when using http_request, http_get, or
http_post primitives to avoid blocking the event loop.
Args:
inputs: Dictionary containing texts, images, tool_calls
request_data: The original request data with metadata
@@ -188,6 +212,7 @@ class CustomCodeGuardrail(CustomGuardrail):
HTTPException: If content is blocked
CustomCodeExecutionError: If execution fails
"""
if self._compiled_function is None:
if self._compile_error:
raise CustomCodeExecutionError(
@@ -201,9 +226,13 @@ class CustomCodeGuardrail(CustomGuardrail):
# Prepare request_data with safe subset of information
safe_request_data = self._prepare_safe_request_data(request_data)
# Execute the custom function
# Execute the custom function - handle both sync and async functions
result = self._compiled_function(inputs, safe_request_data, input_type)
# If the function is async (returns a coroutine), await it
if asyncio.iscoroutine(result):
result = await result
# Process the result
return self._process_result(
result=result,
@@ -10,7 +10,11 @@ import re
from typing import Any, Dict, List, Optional, Tuple, Type, Union
from urllib.parse import urlparse
import httpx
from litellm._logging import verbose_proxy_logger
from litellm.llms.custom_httpx.http_handler import get_async_httpx_client
from litellm.types.llms.custom_http import httpxSpecialProvider
# =============================================================================
# Result Types - Used by Starlark code to return guardrail decisions
@@ -349,6 +353,228 @@ def get_url_domain(url: str) -> Optional[str]:
return None
# =============================================================================
# HTTP Request Primitives (Async)
# =============================================================================
# Default timeout for HTTP requests (in seconds)
_HTTP_DEFAULT_TIMEOUT = 30.0
# Maximum allowed timeout (in seconds)
_HTTP_MAX_TIMEOUT = 60.0
def _http_error_response(error: str) -> Dict[str, Any]:
"""Create a standardized error response for HTTP requests."""
return {
"status_code": 0,
"body": None,
"headers": {},
"success": False,
"error": error,
}
def _http_success_response(response: httpx.Response) -> Dict[str, Any]:
"""Create a standardized success response from an httpx Response."""
parsed_body: Any
try:
parsed_body = response.json()
except (json.JSONDecodeError, ValueError):
parsed_body = response.text
return {
"status_code": response.status_code,
"body": parsed_body,
"headers": dict(response.headers),
"success": 200 <= response.status_code < 300,
"error": None,
}
def _prepare_http_body(
body: Optional[Any],
) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
"""Prepare body arguments for HTTP request - returns (json_body, data_body)."""
if body is None:
return None, None
if isinstance(body, dict):
return body, None
if isinstance(body, list):
return None, json.dumps(body)
if isinstance(body, str):
return None, body
return None, str(body)
async def http_request(
url: str,
method: str = "GET",
headers: Optional[Dict[str, str]] = None,
body: Optional[Any] = None,
timeout: Optional[float] = None,
) -> Dict[str, Any]:
"""
Make an async HTTP request to an external service.
This function allows custom guardrails to call external APIs for
additional validation, content moderation, or data enrichment.
Uses LiteLLM's global cached AsyncHTTPHandler for connection pooling
and better performance.
Args:
url: The URL to request
method: HTTP method (GET, POST, PUT, DELETE, PATCH). Defaults to GET.
headers: Optional dict of HTTP headers
body: Optional request body (will be JSON-encoded if dict/list)
timeout: Optional timeout in seconds (default: 30, max: 60)
Returns:
Dict containing:
- status_code: HTTP status code
- body: Response body (parsed as JSON if possible, otherwise string)
- headers: Response headers as dict
- success: True if status code is 2xx
- error: Error message if request failed, None otherwise
Example:
# Simple GET request
response = await http_request("https://api.example.com/check")
if response["success"]:
data = response["body"]
# POST request with JSON body
response = await http_request(
"https://api.example.com/moderate",
method="POST",
headers={"Authorization": "Bearer token"},
body={"text": "content to check"}
)
"""
# Validate URL
if not is_valid_url(url):
return _http_error_response(f"Invalid URL: {url}")
# Validate and normalize method
method = method.upper()
allowed_methods = {"GET", "POST", "PUT", "DELETE", "PATCH"}
if method not in allowed_methods:
return _http_error_response(
f"Invalid HTTP method: {method}. Allowed: {', '.join(allowed_methods)}"
)
# Apply timeout limits
if timeout is None:
timeout = _HTTP_DEFAULT_TIMEOUT
else:
timeout = min(max(0.1, timeout), _HTTP_MAX_TIMEOUT)
# Get the global cached async HTTP client
client = get_async_httpx_client(
llm_provider=httpxSpecialProvider.GuardrailCallback,
params={"timeout": httpx.Timeout(timeout=timeout, connect=5.0)},
)
try:
response = await _execute_http_request(
client, method, url, headers, body, timeout
)
return _http_success_response(response)
except httpx.TimeoutException as e:
verbose_proxy_logger.warning(f"Custom code http_request timeout: {e}")
return _http_error_response(f"Request timeout after {timeout}s")
except httpx.HTTPStatusError as e:
# Return the response even for non-2xx status codes
return _http_success_response(e.response)
except httpx.RequestError as e:
verbose_proxy_logger.warning(f"Custom code http_request error: {e}")
return _http_error_response(f"Request failed: {str(e)}")
except Exception as e:
verbose_proxy_logger.warning(f"Custom code http_request unexpected error: {e}")
return _http_error_response(f"Unexpected error: {str(e)}")
async def _execute_http_request(
client: Any,
method: str,
url: str,
headers: Optional[Dict[str, str]],
body: Optional[Any],
timeout: float,
) -> httpx.Response:
"""Execute the HTTP request using the appropriate client method."""
json_body, data_body = _prepare_http_body(body)
if method == "GET":
return await client.get(url=url, headers=headers)
elif method == "POST":
return await client.post(
url=url, headers=headers, json=json_body, data=data_body, timeout=timeout
)
elif method == "PUT":
return await client.put(
url=url, headers=headers, json=json_body, data=data_body, timeout=timeout
)
elif method == "DELETE":
return await client.delete(
url=url, headers=headers, json=json_body, data=data_body, timeout=timeout
)
elif method == "PATCH":
return await client.patch(
url=url, headers=headers, json=json_body, data=data_body, timeout=timeout
)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
async def http_get(
url: str,
headers: Optional[Dict[str, str]] = None,
timeout: Optional[float] = None,
) -> Dict[str, Any]:
"""
Make an async HTTP GET request.
Convenience wrapper around http_request for GET requests.
Args:
url: The URL to request
headers: Optional dict of HTTP headers
timeout: Optional timeout in seconds
Returns:
Same as http_request
"""
return await http_request(url=url, method="GET", headers=headers, timeout=timeout)
async def http_post(
url: str,
body: Optional[Any] = None,
headers: Optional[Dict[str, str]] = None,
timeout: Optional[float] = None,
) -> Dict[str, Any]:
"""
Make an async HTTP POST request.
Convenience wrapper around http_request for POST requests.
Args:
url: The URL to request
body: Optional request body (will be JSON-encoded if dict/list)
headers: Optional dict of HTTP headers
timeout: Optional timeout in seconds
Returns:
Same as http_request
"""
return await http_request(
url=url, method="POST", headers=headers, body=body, timeout=timeout
)
# =============================================================================
# Code Detection Primitives
# =============================================================================
@@ -575,6 +801,10 @@ def get_custom_code_primitives() -> Dict[str, Any]:
"is_valid_url": is_valid_url,
"all_urls_valid": all_urls_valid,
"get_url_domain": get_url_domain,
# HTTP (async)
"http_request": http_request,
"http_get": http_get,
"http_post": http_post,
# Code detection
"detect_code": detect_code,
"detect_code_languages": detect_code_languages,
@@ -51,6 +51,7 @@ class UnifiedLLMGuardrails(CustomLogger):
Runs on only Input
Use this if you want to MODIFY the input
"""
global endpoint_guardrail_translation_mappings
from litellm.proxy.common_utils.callback_utils import (
add_guardrail_to_applied_guardrails_header,
@@ -66,6 +67,7 @@ class UnifiedLLMGuardrails(CustomLogger):
if call_type == CallTypes.call_mcp_tool.value:
event_type = GuardrailEventHooks.pre_mcp_call
if (
guardrail_to_apply.should_run_guardrail(data=data, event_type=event_type)
is not True
@@ -9,7 +9,7 @@ import {
CaretRightOutlined,
SaveOutlined,
} from "@ant-design/icons";
import { createGuardrailCall, testCustomCodeGuardrail } from "../../networking";
import { createGuardrailCall, updateGuardrailCall, testCustomCodeGuardrail } from "../../networking";
import NotificationsManager from "../../molecules/notifications_manager";
const { Panel } = Collapse;
@@ -19,7 +19,7 @@ const { TextArea } = Input;
const CODE_TEMPLATES = {
empty: {
name: "Empty Template",
code: `def apply_guardrail(inputs, request_data, input_type):
code: `async def apply_guardrail(inputs, request_data, input_type):
# inputs: {texts, images, tools, tool_calls, structured_messages, model}
# request_data: {model, user_id, team_id, end_user_id, metadata}
# input_type: "request" or "response"
@@ -68,6 +68,27 @@ const CODE_TEMPLATES = {
return block("Response missing required fields")
return allow()`,
},
externalAPI: {
name: "External API Check (async)",
code: `async def apply_guardrail(inputs, request_data, input_type):
# Call an external moderation API (async for non-blocking)
for text in inputs["texts"]:
response = await http_post(
"https://api.example.com/moderate",
body={"text": text, "user_id": request_data["user_id"]},
headers={"Authorization": "Bearer YOUR_API_KEY"},
timeout=10
)
if not response["success"]:
# API call failed, allow by default or block
return allow()
if response["body"].get("flagged"):
return block(response["body"].get("reason", "Content flagged"))
return allow()`,
},
};
// Available primitives organized by category
@@ -77,6 +98,11 @@ const PRIMITIVES = {
{ name: "block(reason)", desc: "Reject with message" },
{ name: "modify(texts=[], images=[], tool_calls=[])", desc: "Transform content" },
],
"HTTP Requests (async)": [
{ name: "await http_request(url, method, headers, body)", desc: "Make async HTTP request" },
{ name: "await http_get(url, headers)", desc: "Async GET request" },
{ name: "await http_post(url, body, headers)", desc: "Async POST request" },
],
"Regex Functions": [
{ name: "regex_match(text, pattern)", desc: "Returns True if pattern found" },
{ name: "regex_replace(text, pattern, replacement)", desc: "Replace all matches" },
@@ -111,13 +137,30 @@ const MODE_OPTIONS = [
{ value: "post_call", label: "post_call (Response)" },
{ value: "during_call", label: "during_call (Parallel)" },
{ value: "logging_only", label: "logging_only" },
{ value: "pre_mcp_call", label: "pre_mcp_call (Before MCP Tool Call)" },
{ value: "post_mcp_call", label: "post_mcp_call (After MCP Tool Call)" },
{ value: "during_mcp_call", label: "during_mcp_call (During MCP Tool Call)" },
];
// Data for editing an existing guardrail
export interface EditGuardrailData {
guardrail_id: string;
guardrail_name: string;
litellm_params: {
mode?: string | string[];
default_on?: boolean;
custom_code?: string;
[key: string]: any;
};
}
interface CustomCodeModalProps {
visible: boolean;
onClose: () => void;
onSuccess: () => void;
accessToken: string | null;
/** If provided, the modal will be in edit mode */
editData?: EditGuardrailData | null;
}
const CustomCodeModal: React.FC<CustomCodeModalProps> = ({
@@ -125,16 +168,72 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({
onClose,
onSuccess,
accessToken,
editData,
}) => {
const isEditMode = !!editData;
const [guardrailName, setGuardrailName] = useState("");
const [mode, setMode] = useState<string>("pre_call");
const [mode, setMode] = useState<string[]>(["pre_call"]);
const [defaultOn, setDefaultOn] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<string>("empty");
const [code, setCode] = useState(CODE_TEMPLATES.empty.code);
const [isSaving, setIsSaving] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [testExpanded, setTestExpanded] = useState(false);
const [testInput, setTestInput] = useState('{"texts": ["Hello, my SSN is 123-45-6789"], "images": [], "tools": [], "tool_calls": [], "structured_messages": [], "model": "gpt-4"}');
// Test input examples for pre_call and post_call
const TEST_INPUT_EXAMPLES = {
pre_call: {
name: "Pre-call (Request)",
data: {
texts: ["Hello, my SSN is 123-45-6789"],
images: [],
tools: [
{
type: "function",
function: {
name: "get_weather",
description: "Get the current weather in a location",
parameters: {
type: "object",
properties: {
location: { type: "string", description: "City name" }
},
required: ["location"]
}
}
}
],
tool_calls: [],
structured_messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Hello, my SSN is 123-45-6789" }
],
model: "gpt-4"
}
},
post_call: {
name: "Post-call (Response)",
data: {
texts: ["The weather in San Francisco is 72°F and sunny."],
images: [],
tools: [],
tool_calls: [
{
id: "call_abc123",
type: "function",
function: {
name: "get_weather",
arguments: "{\"location\": \"San Francisco\"}"
}
}
],
structured_messages: [],
model: "gpt-4"
}
}
};
const [testInput, setTestInput] = useState(JSON.stringify(TEST_INPUT_EXAMPLES.pre_call.data, null, 2));
const [testResult, setTestResult] = useState<any>(null);
const [copiedPrimitive, setCopiedPrimitive] = useState<string | null>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
@@ -145,18 +244,35 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({
setCode(CODE_TEMPLATES[templateKey as keyof typeof CODE_TEMPLATES].code);
};
// Reset form when modal opens
// Normalize mode from API (string or string[]) to string[]
const normalizeMode = (m: string | string[] | undefined): string[] => {
if (m === undefined || m === null) return ["pre_call"];
if (Array.isArray(m)) return m.length ? m : ["pre_call"];
return [m];
};
// Reset form when modal opens or editData changes
useEffect(() => {
if (visible) {
setGuardrailName("");
setMode("pre_call");
setDefaultOn(false);
setSelectedTemplate("empty");
setCode(CODE_TEMPLATES.empty.code);
if (editData) {
// Edit mode: populate with existing data
setGuardrailName(editData.guardrail_name || "");
setMode(normalizeMode(editData.litellm_params?.mode));
setDefaultOn(editData.litellm_params?.default_on || false);
setCode(editData.litellm_params?.custom_code || CODE_TEMPLATES.empty.code);
setSelectedTemplate(""); // No template selected in edit mode
} else {
// Create mode: reset to defaults
setGuardrailName("");
setMode(["pre_call"]);
setDefaultOn(false);
setSelectedTemplate("empty");
setCode(CODE_TEMPLATES.empty.code);
}
setTestResult(null);
setTestExpanded(false);
}
}, [visible]);
}, [visible, editData]);
// Copy primitive to clipboard
const copyPrimitive = async (primitive: string) => {
@@ -184,7 +300,7 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({
}
};
// Save guardrail
// Save guardrail (create or update)
const handleSave = async () => {
if (!guardrailName.trim()) {
NotificationsManager.fromBackend("Please enter a guardrail name");
@@ -201,25 +317,53 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({
setIsSaving(true);
try {
const guardrailData = {
guardrail_name: guardrailName,
litellm_params: {
guardrail: "custom_code",
mode: mode,
default_on: defaultOn,
custom_code: code,
},
guardrail_info: {},
};
if (isEditMode && editData) {
// Update existing guardrail
const updateData: any = {
litellm_params: {
custom_code: code,
},
};
await createGuardrailCall(accessToken, guardrailData);
NotificationsManager.success("Custom code guardrail created successfully");
// Only include changed fields
if (guardrailName !== editData.guardrail_name) {
updateData.guardrail_name = guardrailName;
}
const existingMode = normalizeMode(editData.litellm_params?.mode);
const modeChanged =
mode.length !== existingMode.length ||
mode.some((m, i) => m !== existingMode[i]);
if (modeChanged) {
updateData.litellm_params.mode = mode;
}
if (defaultOn !== editData.litellm_params?.default_on) {
updateData.litellm_params.default_on = defaultOn;
}
await updateGuardrailCall(accessToken, editData.guardrail_id, updateData);
NotificationsManager.success("Custom code guardrail updated successfully");
} else {
// Create new guardrail
const guardrailData = {
guardrail_name: guardrailName,
litellm_params: {
guardrail: "custom_code",
mode: mode,
default_on: defaultOn,
custom_code: code,
},
guardrail_info: {},
};
await createGuardrailCall(accessToken, guardrailData);
NotificationsManager.success("Custom code guardrail created successfully");
}
onSuccess();
onClose();
} catch (error) {
console.error("Failed to create guardrail:", error);
console.error("Failed to save guardrail:", error);
NotificationsManager.fromBackend(
"Failed to create guardrail: " + (error instanceof Error ? error.message : String(error))
`Failed to ${isEditMode ? "update" : "create"} guardrail: ` + (error instanceof Error ? error.message : String(error))
);
} finally {
setIsSaving(false);
@@ -252,10 +396,20 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({
parsedInput.texts = [];
}
// Use first request-like or response-like mode for test input_type
const requestModes = ["pre_call", "pre_mcp_call"];
const responseModes = ["post_call", "post_mcp_call"];
const testInputType: "request" | "response" =
mode.some((m) => requestModes.includes(m))
? "request"
: mode.some((m) => responseModes.includes(m))
? "response"
: "request";
const response = await testCustomCodeGuardrail(accessToken, {
custom_code: code,
test_input: parsedInput,
input_type: mode as "request" | "response",
input_type: testInputType,
request_data: {
model: "test-model",
metadata: {},
@@ -289,7 +443,7 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({
open={visible}
onCancel={onClose}
footer={null}
width={1200}
width={1400}
className="custom-code-modal"
closable={true}
destroyOnClose
@@ -297,7 +451,9 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({
<div className="flex flex-col h-[80vh]">
{/* Header */}
<div className="pb-4 border-b border-gray-200">
<h2 className="text-xl font-semibold text-gray-900">Create Custom Guardrail</h2>
<h2 className="text-xl font-semibold text-gray-900">
{isEditMode ? "Edit Custom Guardrail" : "Create Custom Guardrail"}
</h2>
<p className="text-sm text-gray-500 mt-1">Define custom logic using Python-like syntax</p>
</div>
@@ -311,14 +467,16 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({
placeholder="e.g., block-pii-custom"
/>
</div>
<div className="w-[180px]">
<label className="block text-xs font-medium text-gray-600 mb-1">Mode</label>
<div className="w-[280px]">
<label className="block text-xs font-medium text-gray-600 mb-1">Mode (can select multiple)</label>
<Select
mode="multiple"
value={mode}
onChange={setMode}
options={MODE_OPTIONS}
className="w-full"
size="middle"
placeholder="Select modes"
/>
</div>
<div className="w-[180px]">
@@ -343,21 +501,21 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({
</div>
{/* Main Content */}
<div className="flex flex-1 overflow-hidden mt-4 gap-4">
<div className="flex flex-1 overflow-hidden mt-4 gap-6">
{/* Code Editor */}
<div className="flex-1 flex flex-col min-w-0">
<div className="flex items-center justify-between mb-2">
<div className="flex-[2] flex flex-col min-w-0 overflow-y-auto">
<div className="flex items-center justify-between mb-2 flex-shrink-0">
<span className="text-xs font-semibold text-gray-500 uppercase tracking-wide">Python Logic</span>
<span className="text-xs text-gray-400">Restricted environment (no imports)</span>
</div>
<div className="flex-1 relative rounded-lg overflow-hidden border border-gray-700 bg-[#1e1e1e]">
<div className="relative rounded-lg overflow-hidden border border-gray-700 bg-[#1e1e1e] flex-shrink-0" style={{ minHeight: "300px", maxHeight: "400px" }}>
{/* Line numbers */}
<div
className="absolute left-0 top-0 bottom-0 w-10 bg-[#1e1e1e] border-r border-gray-700 text-right pr-2 pt-3 select-none overflow-hidden"
style={{ fontFamily: "monospace", fontSize: "13px", lineHeight: "1.5" }}
className="absolute left-0 top-0 bottom-0 w-12 bg-[#1e1e1e] border-r border-gray-700 text-right pr-3 pt-3 select-none overflow-hidden"
style={{ fontFamily: "'Fira Code', 'Monaco', 'Consolas', monospace", fontSize: "14px", lineHeight: "1.6" }}
>
{Array.from({ length: Math.max(lineCount, 20) }, (_, i) => (
<div key={i + 1} className="text-gray-500 h-[19.5px]">{i + 1}</div>
<div key={i + 1} className="text-gray-500 h-[22.4px]">{i + 1}</div>
))}
</div>
{/* Code textarea */}
@@ -367,8 +525,8 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({
onChange={(e) => setCode(e.target.value)}
onKeyDown={handleKeyDown}
spellCheck={false}
className="w-full h-full pl-12 pr-4 pt-3 pb-3 font-mono text-sm resize-none focus:outline-none bg-transparent text-gray-200"
style={{ lineHeight: "1.5", tabSize: 4 }}
className="w-full h-full pl-14 pr-4 pt-3 pb-3 resize-none focus:outline-none bg-transparent text-gray-200"
style={{ fontFamily: "'Fira Code', 'Monaco', 'Consolas', monospace", fontSize: "14px", lineHeight: "1.6", tabSize: 4 }}
/>
</div>
@@ -376,7 +534,7 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({
<Collapse
activeKey={testExpanded ? ["test"] : []}
onChange={(keys) => setTestExpanded(keys.includes("test"))}
className="mt-3 bg-white border border-gray-200 rounded-lg"
className="mt-3 bg-white border border-gray-200 rounded-lg flex-shrink-0"
expandIcon={({ isActive }) => <CaretRightOutlined rotate={isActive ? 90 : 0} />}
>
<Panel
@@ -390,11 +548,40 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({
>
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-gray-600 mb-1">Test Input (JSON)</label>
<div className="flex items-center justify-between mb-2">
<label className="block text-xs font-medium text-gray-600">Test Input (JSON)</label>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500">Load example:</span>
<button
type="button"
onClick={() => setTestInput(JSON.stringify(TEST_INPUT_EXAMPLES.pre_call.data, null, 2))}
className="px-2 py-1 text-xs rounded border border-orange-200 bg-orange-50 text-orange-700 hover:bg-orange-100 transition-colors"
>
Pre-call
</button>
<button
type="button"
onClick={() => setTestInput(JSON.stringify(TEST_INPUT_EXAMPLES.post_call.data, null, 2))}
className="px-2 py-1 text-xs rounded border border-green-200 bg-green-50 text-green-700 hover:bg-green-100 transition-colors"
>
Post-call
</button>
</div>
</div>
<div className="mb-2 p-2 bg-gray-50 rounded text-xs text-gray-600 border border-gray-200">
<div className="grid grid-cols-2 gap-x-4 gap-y-1">
<div><strong>texts</strong>: Message content (always)</div>
<div><strong>images</strong>: Base64 images (vision)</div>
<div><strong>tools</strong>: Tool definitions <span className="text-orange-600">(pre_call)</span></div>
<div><strong>tool_calls</strong>: LLM tool calls <span className="text-green-600">(post_call)</span></div>
<div><strong>structured_messages</strong>: Full messages <span className="text-orange-600">(pre_call)</span></div>
<div><strong>model</strong>: Model name (always)</div>
</div>
</div>
<TextArea
value={testInput}
onChange={(e) => setTestInput(e.target.value)}
rows={4}
rows={8}
className="font-mono text-xs"
placeholder='{"texts": ["test message"], ...}'
/>
@@ -448,7 +635,7 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({
</div>
{/* Primitives Panel */}
<div className="w-[280px] flex-shrink-0 overflow-auto">
<div className="w-[300px] flex-shrink-0 overflow-auto border-l border-gray-200 pl-6">
<div className="flex items-center gap-2 mb-3">
<CodeOutlined className="text-blue-500" />
<span className="font-semibold text-gray-700">Available Primitives</span>
@@ -509,7 +696,7 @@ const CustomCodeModal: React.FC<CustomCodeModalProps> = ({
disabled={isSaving || !guardrailName.trim()}
icon={SaveOutlined}
>
Save Guardrail
{isEditMode ? "Update Guardrail" : "Save Guardrail"}
</Button>
</div>
</div>
@@ -14,7 +14,7 @@ import {
TextInput,
} from "@tremor/react";
import { Button, Form, Input, Select, Divider, Tooltip } from "antd";
import { InfoCircleOutlined, EyeInvisibleOutlined, StopOutlined } from "@ant-design/icons";
import { InfoCircleOutlined, EyeInvisibleOutlined, StopOutlined, CodeOutlined } from "@ant-design/icons";
import {
getGuardrailInfo,
updateGuardrailCall,
@@ -29,6 +29,7 @@ import ContentFilterManager, { formatContentFilterDataForAPI } from "./content_f
import ToolPermissionRulesEditor, {
ToolPermissionConfig,
} from "./tool_permission/ToolPermissionRulesEditor";
import CustomCodeModal, { EditGuardrailData } from "./custom_code/CustomCodeModal";
import { ArrowLeftIcon } from "@heroicons/react/outline";
import { copyToClipboard as utilCopyToClipboard } from "@/utils/dataUtils";
import { CheckIcon, CopyIcon } from "lucide-react";
@@ -94,6 +95,7 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
};
const [toolPermissionConfig, setToolPermissionConfig] = useState<ToolPermissionConfig>(emptyToolPermissionConfig);
const [toolPermissionDirty, setToolPermissionDirty] = useState(false);
const [customCodeModalVisible, setCustomCodeModalVisible] = useState(false);
// Content Filter data ref (managed by ContentFilterManager)
const contentFilterDataRef = React.useRef<{ patterns: any[]; blockedWords: any[] }>({
@@ -552,6 +554,33 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
</Card>
)}
{/* Custom Code Display */}
{guardrailData.litellm_params?.guardrail === "custom_code" && guardrailData.litellm_params?.custom_code && (
<Card className="mt-6">
<div className="flex justify-between items-center mb-4">
<div className="flex items-center gap-2">
<CodeOutlined className="text-blue-500" />
<Text className="font-medium text-lg">Custom Code</Text>
</div>
{isAdmin && !isConfigGuardrail && (
<TremorButton
size="xs"
variant="secondary"
icon={CodeOutlined}
onClick={() => setCustomCodeModalVisible(true)}
>
Edit Code
</TremorButton>
)}
</div>
<div className="relative rounded-lg overflow-hidden border border-gray-700 bg-[#1e1e1e]">
<pre className="p-4 text-sm text-gray-200 overflow-x-auto" style={{ fontFamily: "'Fira Code', 'Monaco', 'Consolas', monospace" }}>
<code>{guardrailData.litellm_params.custom_code}</code>
</pre>
</div>
</Card>
)}
{/* Content Filter Configuration Display */}
<ContentFilterManager
guardrailData={guardrailData}
@@ -573,7 +602,16 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
</Tooltip>
)}
{!isEditing && !isConfigGuardrail && (
<TremorButton onClick={() => setIsEditing(true)}>Edit Settings</TremorButton>
guardrailData.litellm_params?.guardrail === "custom_code" ? (
<TremorButton
icon={CodeOutlined}
onClick={() => setCustomCodeModalVisible(true)}
>
Edit Code
</TremorButton>
) : (
<TremorButton onClick={() => setIsEditing(true)}>Edit Settings</TremorButton>
)
)}
</div>
@@ -757,6 +795,22 @@ const GuardrailInfoView: React.FC<GuardrailInfoProps> = ({ guardrailId, onClose,
)}
</TabPanels>
</TabGroup>
{/* Custom Code Editor Modal */}
<CustomCodeModal
visible={customCodeModalVisible}
onClose={() => setCustomCodeModalVisible(false)}
onSuccess={() => {
setCustomCodeModalVisible(false);
fetchGuardrailInfo();
}}
accessToken={accessToken}
editData={guardrailData ? {
guardrail_id: guardrailData.guardrail_id,
guardrail_name: guardrailData.guardrail_name,
litellm_params: guardrailData.litellm_params,
} as EditGuardrailData : null}
/>
</div>
);
};
@@ -0,0 +1,345 @@
import React, { forwardRef, useImperativeHandle, useMemo } from "react";
import { Form, Input, InputNumber, Select, Tooltip } from "antd";
import { InfoCircleOutlined } from "@ant-design/icons";
import { MCPTool, InputSchema, InputSchemaProperty } from "./types";
const isPlainObject = (value: unknown): value is Record<string, any> =>
typeof value === "object" && value !== null && !Array.isArray(value);
function buildArrayItems(items?: InputSchemaProperty | InputSchemaProperty[]): any[] {
if (!items) return [];
if (Array.isArray(items)) {
return items
.map((item) => buildDefaultValue(item))
.filter((value) => value !== undefined);
}
const itemDefault = buildDefaultValue(items);
return itemDefault !== undefined ? [itemDefault] : [];
}
function buildDefaultValue(prop?: InputSchemaProperty, overrideDefault?: any): any {
if (!prop) return undefined;
const effectiveDefault = overrideDefault !== undefined ? overrideDefault : prop.default;
if (prop.type === "object") {
const base = isPlainObject(effectiveDefault) ? { ...effectiveDefault } : {};
if (prop.properties) {
Object.entries(prop.properties).forEach(([childKey, childProp]) => {
base[childKey] = buildDefaultValue(childProp, base[childKey]);
});
}
return base;
}
if (prop.type === "array") {
if (Array.isArray(effectiveDefault)) {
const itemSchema = prop.items;
if (!itemSchema) return effectiveDefault;
if (effectiveDefault.length === 0) {
const sample = buildArrayItems(itemSchema);
return sample.length ? sample : effectiveDefault;
}
if (Array.isArray(itemSchema)) {
return effectiveDefault.map((value, index) => {
const schema = itemSchema[index] ?? itemSchema[itemSchema.length - 1];
return buildDefaultValue(schema, value);
});
}
return effectiveDefault.map((value) => buildDefaultValue(itemSchema, value));
}
if (effectiveDefault !== undefined) return effectiveDefault;
return buildArrayItems(prop.items);
}
if (effectiveDefault !== undefined) return effectiveDefault;
switch (prop.type) {
case "integer":
case "number":
return 0;
case "boolean":
return false;
case "string":
default:
return "";
}
}
const getInitialValueForField = (prop: InputSchemaProperty): any => {
const defaultValue = buildDefaultValue(prop);
if (prop.type === "object" || prop.type === "array") {
const fallback = prop.type === "array" ? [] : {};
return JSON.stringify(defaultValue ?? fallback, null, 2);
}
return defaultValue;
};
function convertFormValues(
values: Record<string, any>,
actualSchema: InputSchema,
schema: InputSchema,
): Record<string, any> {
const convertedValues: Record<string, any> = {};
const schemaToUse = actualSchema;
Object.entries(values).forEach(([key, value]) => {
const prop = schemaToUse.properties?.[key];
if (prop && value !== null && value !== undefined && value !== "") {
switch (prop.type) {
case "boolean":
convertedValues[key] = value === "true" || value === true;
break;
case "number":
case "integer": {
const numericValue = Number(value);
convertedValues[key] = Number.isNaN(numericValue)
? value
: prop.type === "integer"
? Math.trunc(numericValue)
: numericValue;
break;
}
case "object":
case "array": {
try {
const parsed = typeof value === "string" ? JSON.parse(value) : value;
const isValidObject =
prop.type === "object" &&
parsed !== null &&
typeof parsed === "object" &&
!Array.isArray(parsed);
const isValidArray = prop.type === "array" && Array.isArray(parsed);
if ((prop.type === "object" && isValidObject) || (prop.type === "array" && isValidArray)) {
convertedValues[key] = parsed;
} else {
convertedValues[key] = value;
}
} catch {
convertedValues[key] = value;
}
break;
}
case "string":
convertedValues[key] = String(value);
break;
default:
convertedValues[key] = value;
}
} else if (value !== null && value !== undefined && value !== "") {
convertedValues[key] = value;
}
});
const isNestedParams =
schema.properties?.params?.type === "object" && schema.properties.params.properties;
return isNestedParams ? { params: convertedValues } : convertedValues;
}
export interface MCPToolArgumentsFormRef {
getSubmitValues: () => Promise<Record<string, any>>;
}
interface MCPToolArgumentsFormProps {
tool: MCPTool;
className?: string;
}
const MCPToolArgumentsForm = forwardRef<MCPToolArgumentsFormRef, MCPToolArgumentsFormProps>(
({ tool, className }, ref) => {
const [form] = Form.useForm();
const schema: InputSchema = useMemo(() => {
if (typeof tool.inputSchema === "string") {
return {
type: "object",
properties: {
input: {
type: "string",
description: "Input for this tool",
},
},
required: ["input"],
};
}
return tool.inputSchema as InputSchema;
}, [tool.inputSchema]);
const actualSchema: InputSchema = useMemo(() => {
if (
schema.properties?.params?.type === "object" &&
schema.properties.params.properties
) {
return {
type: "object",
properties: schema.properties.params.properties,
required: schema.properties.params.required || [],
};
}
return schema;
}, [schema]);
useImperativeHandle(ref, () => ({
getSubmitValues: async () => {
const values = await form.validateFields();
return convertFormValues(values, actualSchema, schema);
},
}));
React.useEffect(() => {
form.resetFields();
if (!actualSchema.properties) return;
const initialValues: Record<string, any> = {};
Object.entries(actualSchema.properties).forEach(([key, prop]) => {
initialValues[key] = getInitialValueForField(prop);
});
form.setFieldsValue(initialValues);
}, [form, actualSchema, tool]);
if (typeof tool.inputSchema === "string") {
return (
<Form form={form} layout="vertical" className={className}>
<Form.Item
label={
<span className="text-sm font-medium text-gray-700">
Input <span className="text-red-500">*</span>
</span>
}
name="input"
rules={[{ required: true, message: "Please enter input for this tool" }]}
>
<Input placeholder="Enter input for this tool" />
</Form.Item>
</Form>
);
}
if (!actualSchema.properties) {
return (
<Form form={form} layout="vertical" className={className}>
<div className="py-4 text-center text-sm text-gray-500">
No parameters required for this tool.
</div>
</Form>
);
}
return (
<Form form={form} layout="vertical" className={className}>
{Object.entries(actualSchema.properties).map(([key, prop]) => {
const initialValue = getInitialValueForField(prop);
const fieldKey = `${tool.name}-${key}`;
return (
<Form.Item
key={fieldKey}
label={
<span className="text-sm font-medium text-gray-700 flex items-center">
{key} {actualSchema.required?.includes(key) && <span className="text-red-500">*</span>}
{prop.description && (
<Tooltip title={prop.description}>
<InfoCircleOutlined className="ml-2 text-gray-400 hover:text-gray-600" />
</Tooltip>
)}
</span>
}
name={key}
initialValue={initialValue}
rules={[
{
required: actualSchema.required?.includes(key),
message: `Please enter ${key}`,
},
...(prop.type === "object" || prop.type === "array"
? [
{
validator: (_rule: any, value: any) => {
if (
(value === undefined || value === null || value === "") &&
!actualSchema.required?.includes(key)
) {
return Promise.resolve();
}
try {
const parsed = typeof value === "string" ? JSON.parse(value) : value;
const isValidObject =
prop.type === "object" &&
parsed !== null &&
typeof parsed === "object" &&
!Array.isArray(parsed);
const isValidArray = prop.type === "array" && Array.isArray(parsed);
if (
(prop.type === "object" && isValidObject) ||
(prop.type === "array" && isValidArray)
) {
return Promise.resolve();
}
return Promise.reject(
new Error(
prop.type === "object" ? "Please enter a JSON object" : "Please enter a JSON array",
),
);
} catch {
return Promise.reject(new Error("Invalid JSON"));
}
},
},
]
: []),
]}
>
{prop.type === "string" && prop.enum ? (
<Select
placeholder={`Select ${key}`}
allowClear={!actualSchema.required?.includes(key)}
options={prop.enum.map((v) => ({ value: v, label: v }))}
/>
) : prop.type === "string" && !prop.enum ? (
<Input
placeholder={prop.description || `Enter ${key}`}
allowClear
/>
) : prop.type === "number" || prop.type === "integer" ? (
<InputNumber
step={prop.type === "integer" ? 1 : undefined}
placeholder={prop.description || `Enter ${key}`}
className="w-full"
style={{ width: "100%" }}
/>
) : prop.type === "boolean" ? (
<Select
placeholder={`Select ${key}`}
allowClear={!actualSchema.required?.includes(key)}
options={[
{ value: true, label: "True" },
{ value: false, label: "False" },
]}
/>
) : (prop.type === "object" || prop.type === "array") ? (
<Input.TextArea
rows={prop.type === "object" ? 4 : 3}
placeholder={
prop.description ||
(prop.type === "object"
? `Enter JSON object for ${key}`
: `Enter JSON array for ${key}`)
}
spellCheck={false}
className="font-mono"
/>
) : (
<Input
placeholder={prop.description || `Enter ${key}`}
allowClear
/>
)}
</Form.Item>
);
})}
</Form>
);
},
);
MCPToolArgumentsForm.displayName = "MCPToolArgumentsForm";
export default MCPToolArgumentsForm;
@@ -6685,11 +6685,16 @@ export const listMCPTools = async (accessToken: string, serverId: string) => {
}
};
export interface CallMCPToolOptions {
guardrails?: string[];
}
export const callMCPTool = async (
accessToken: string,
serverId: string,
toolName: string,
toolArguments: Record<string, any>,
options?: CallMCPToolOptions,
) => {
try {
// Construct base URL
@@ -6702,14 +6707,19 @@ export const callMCPTool = async (
"Content-Type": "application/json",
};
const body: Record<string, any> = {
server_id: serverId,
name: toolName,
arguments: toolArguments,
};
if (options?.guardrails && options.guardrails.length > 0) {
body.litellm_metadata = { guardrails: options.guardrails };
}
const response = await fetch(url, {
method: "POST",
headers,
body: JSON.stringify({
server_id: serverId,
name: toolName,
arguments: toolArguments,
}),
body: JSON.stringify(body),
});
if (!response.ok) {
@@ -31,9 +31,10 @@ import { v4 as uuidv4 } from "uuid";
import { truncateString } from "../../../utils/textUtils";
import GuardrailSelector from "../../guardrails/GuardrailSelector";
import PolicySelector from "../../policies/PolicySelector";
import MCPToolArgumentsForm, { MCPToolArgumentsFormRef } from "../../mcp_tools/MCPToolArgumentsForm";
import { MCPServer } from "../../mcp_tools/types";
import NotificationsManager from "../../molecules/notifications_manager";
import { fetchMCPServers, listMCPTools } from "../../networking";
import { callMCPTool, fetchMCPServers, listMCPTools } from "../../networking";
import TagSelector from "../../tag_management/TagSelector";
import VectorStoreSelector from "../../vector_store_management/VectorStoreSelector";
import { makeA2ASendMessageRequest } from "../llm_calls/a2a_send_message";
@@ -85,7 +86,11 @@ interface ChatUIProps {
};
}
const MCP_SUPPORTED_ENDPOINTS = new Set<EndpointType>([EndpointType.CHAT, EndpointType.RESPONSES]);
const MCP_SUPPORTED_ENDPOINTS = new Set<EndpointType>([
EndpointType.CHAT,
EndpointType.RESPONSES,
EndpointType.MCP,
]);
const ChatUI: React.FC<ChatUIProps> = ({
accessToken,
@@ -107,6 +112,8 @@ const ChatUI: React.FC<ChatUIProps> = ({
});
const [isLoadingMCPServers, setIsLoadingMCPServers] = useState(false);
const [serverToolsMap, setServerToolsMap] = useState<Record<string, any[]>>({});
const [selectedMCPDirectTool, setSelectedMCPDirectTool] = useState<string | undefined>(undefined);
const mcpToolArgsFormRef = useRef<MCPToolArgumentsFormRef>(null);
const [mcpServerToolRestrictions, setMCPServerToolRestrictions] = useState<Record<string, string[]>>(() => {
const saved = sessionStorage.getItem("mcpServerToolRestrictions");
try {
@@ -396,6 +403,18 @@ const ChatUI: React.FC<ChatUIProps> = ({
loadMCPServers();
}, [accessToken, userID, userRole, apiKeySource, apiKey, token]);
// Load tools when MCP direct mode has a server selected
useEffect(() => {
if (
endpointType === EndpointType.MCP &&
selectedMCPServers.length === 1 &&
selectedMCPServers[0] !== "__all__" &&
!serverToolsMap[selectedMCPServers[0]]
) {
loadServerTools(selectedMCPServers[0]);
}
}, [endpointType, selectedMCPServers, serverToolsMap]);
// Fetch agents when A2A endpoint is selected
useEffect(() => {
const userApiKey = apiKeySource === "session" ? accessToken : apiKey;
@@ -769,8 +788,13 @@ const ChatUI: React.FC<ChatUIProps> = ({
setUploadedAudio(null);
};
const handleSendMessage = async () => {
if (inputMessage.trim() === "" && endpointType !== EndpointType.TRANSCRIPTION) return;
const handleSendMessage = async () => {
if (
inputMessage.trim() === "" &&
endpointType !== EndpointType.TRANSCRIPTION &&
endpointType !== EndpointType.MCP
)
return;
// For image edits, require both image and prompt
if (endpointType === EndpointType.IMAGE_EDITS && uploadedImages.length === 0) {
@@ -790,7 +814,39 @@ const ChatUI: React.FC<ChatUIProps> = ({
return;
}
// Require model selection for all model-based endpoints
// For MCP direct mode, require server and tool selection, and get form values early
let mcpToolArguments: Record<string, any> = {};
if (endpointType === EndpointType.MCP) {
const mcpServerId =
selectedMCPServers.length === 1 && selectedMCPServers[0] !== "__all__"
? selectedMCPServers[0]
: null;
if (!mcpServerId) {
NotificationsManager.fromBackend("Please select an MCP server to test");
return;
}
if (!selectedMCPDirectTool) {
NotificationsManager.fromBackend("Please select an MCP tool to call");
return;
}
const mcpTool = (serverToolsMap[selectedMCPServers[0]] || []).find(
(t: any) => t.name === selectedMCPDirectTool,
);
if (!mcpTool) {
NotificationsManager.fromBackend("Please wait for tool schema to load");
return;
}
try {
mcpToolArguments = (await mcpToolArgsFormRef.current?.getSubmitValues()) ?? {};
} catch (err) {
NotificationsManager.fromBackend(
err instanceof Error ? err.message : "Please fill in all required parameters",
);
return;
}
}
// Require model selection for all model-based endpoints (MCP direct mode does not need a model)
const modelRequiredEndpoints = [
EndpointType.CHAT,
EndpointType.IMAGE,
@@ -874,6 +930,10 @@ const ChatUI: React.FC<ChatUIProps> = ({
? `🎵 Audio file: ${uploadedAudio.name}\nPrompt: ${inputMessage}`
: `🎵 Audio file: ${uploadedAudio.name}`;
displayMessage = createDisplayMessage(audioMessage, false);
} else if (endpointType === EndpointType.MCP && selectedMCPDirectTool) {
// For MCP direct mode, show tool name and arguments from form
const mcpMessage = `🔧 MCP Tool: ${selectedMCPDirectTool}\nArguments: ${JSON.stringify(mcpToolArguments, null, 2)}`;
displayMessage = createDisplayMessage(mcpMessage, false);
} else {
displayMessage = createDisplayMessage(inputMessage, false);
}
@@ -1057,6 +1117,32 @@ const ChatUI: React.FC<ChatUIProps> = ({
}
}
// Handle MCP direct tool calls (no chat completions)
if (endpointType === EndpointType.MCP) {
const mcpServerId =
selectedMCPServers.length === 1 && selectedMCPServers[0] !== "__all__"
? selectedMCPServers[0]
: null;
if (mcpServerId && selectedMCPDirectTool) {
const result = await callMCPTool(
effectiveApiKey,
mcpServerId,
selectedMCPDirectTool,
mcpToolArguments,
selectedGuardrails.length > 0 ? { guardrails: selectedGuardrails } : undefined,
);
const resultText =
result?.content?.length > 0
? JSON.stringify(
result.content.map((c: any) => (c.type === "text" ? c.text : c)).filter(Boolean),
null,
2,
)
: JSON.stringify(result, null, 2);
updateTextUI("assistant", resultText || "Tool executed successfully.");
}
}
// Handle A2A agent calls (separate from model-based calls) - use streaming
if (endpointType === EndpointType.A2A_AGENTS && selectedAgent) {
await makeA2ASendMessageRequest(
@@ -1069,6 +1155,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
updateTotalLatency,
updateA2AMetadata,
customProxyBaseUrl || undefined,
selectedGuardrails.length > 0 ? selectedGuardrails : undefined,
);
}
} catch (error) {
@@ -1253,10 +1340,17 @@ const ChatUI: React.FC<ChatUIProps> = ({
setSelectedModel(undefined);
setSelectedAgent(undefined);
setShowCustomModelInput(false);
setSelectedMCPDirectTool(undefined);
// For MCP direct mode, require single server (clear __all__ or multiple)
if (value === EndpointType.MCP) {
setSelectedMCPServers((prev) =>
prev.length === 1 && prev[0] !== "__all__" ? prev : [],
);
}
try {
sessionStorage.removeItem("selectedModel");
sessionStorage.removeItem("selectedAgent");
} catch { }
} catch {}
}}
className="mb-4"
/>
@@ -1290,8 +1384,8 @@ const ChatUI: React.FC<ChatUIProps> = ({
/>
</div>
{/* Model Selector - shown when NOT using A2A Agents */}
{endpointType !== EndpointType.A2A_AGENTS && (
{/* Model Selector - shown when NOT using A2A Agents or MCP direct mode */}
{endpointType !== EndpointType.A2A_AGENTS && endpointType !== EndpointType.MCP && (
<div>
<Text className="font-medium block mb-2 text-gray-700 flex items-center justify-between">
<span className="flex items-center">
@@ -1449,36 +1543,59 @@ const ChatUI: React.FC<ChatUIProps> = ({
{/* MCP Server Selection */}
<div>
<Text className="font-medium block mb-2 text-gray-700 flex items-center">
<ToolOutlined className="mr-2" /> MCP Servers
<Tooltip className="ml-1" title="Select MCP servers to use in your conversation.">
<ToolOutlined className="mr-2" />
{endpointType === EndpointType.MCP ? "MCP Server" : "MCP Servers"}
<Tooltip
className="ml-1"
title={
endpointType === EndpointType.MCP
? "Select an MCP server to test tools directly."
: "Select MCP servers to use in your conversation."
}
>
<InfoCircleOutlined />
</Tooltip>
</Text>
<Select
mode="multiple"
mode={endpointType === EndpointType.MCP ? undefined : "multiple"}
style={{ width: "100%" }}
placeholder="Select MCP servers"
value={selectedMCPServers}
placeholder={
endpointType === EndpointType.MCP ? "Select MCP server" : "Select MCP servers"
}
value={
endpointType === EndpointType.MCP
? selectedMCPServers[0] !== "__all__" && selectedMCPServers.length === 1
? selectedMCPServers[0]
: undefined
: selectedMCPServers
}
onChange={(value) => {
if (value.includes("__all__")) {
setSelectedMCPServers(["__all__"]);
setMCPServerToolRestrictions({});
if (endpointType === EndpointType.MCP) {
const serverId = value as string | undefined;
setSelectedMCPServers(serverId ? [serverId] : []);
setSelectedMCPDirectTool(undefined);
if (serverId && !serverToolsMap[serverId]) {
loadServerTools(serverId);
}
} else {
setSelectedMCPServers(value);
// Clean up tool restrictions for removed servers
setMCPServerToolRestrictions((prev) => {
const updated = { ...prev };
Object.keys(updated).forEach((serverId) => {
if (!value.includes(serverId)) delete updated[serverId];
if ((value as string[]).includes("__all__")) {
setSelectedMCPServers(["__all__"]);
setMCPServerToolRestrictions({});
} else {
setSelectedMCPServers(value as string[]);
setMCPServerToolRestrictions((prev) => {
const updated = { ...prev };
Object.keys(updated).forEach((serverId) => {
if (!(value as string[]).includes(serverId)) delete updated[serverId];
});
return updated;
});
return updated;
});
// Load tools for newly selected servers
value.forEach((serverId) => {
if (!serverToolsMap[serverId]) {
loadServerTools(serverId);
}
});
(value as string[]).forEach((serverId) => {
if (!serverToolsMap[serverId]) {
loadServerTools(serverId);
}
});
}
}
}}
loading={isLoadingMCPServers}
@@ -1486,15 +1603,17 @@ const ChatUI: React.FC<ChatUIProps> = ({
allowClear
optionLabelProp="label"
disabled={!MCP_SUPPORTED_ENDPOINTS.has(endpointType as EndpointType)}
maxTagCount="responsive"
maxTagCount={endpointType === EndpointType.MCP ? 1 : "responsive"}
>
{/* All MCP Servers option */}
<Select.Option key="__all__" value="__all__" label="All MCP Servers">
<div className="flex flex-col py-1">
<span className="font-medium">All MCP Servers</span>
<span className="text-xs text-gray-500 mt-1">Use all available MCP servers</span>
</div>
</Select.Option>
{/* All MCP Servers option - hidden for MCP direct mode */}
{endpointType !== EndpointType.MCP && (
<Select.Option key="__all__" value="__all__" label="All MCP Servers">
<div className="flex flex-col py-1">
<span className="font-medium">All MCP Servers</span>
<span className="text-xs text-gray-500 mt-1">Use all available MCP servers</span>
</div>
</Select.Option>
)}
{/* Individual servers */}
{mcpServers.map((server) => (
@@ -1502,7 +1621,9 @@ const ChatUI: React.FC<ChatUIProps> = ({
key={server.server_id}
value={server.server_id}
label={server.alias || server.server_name || server.server_id}
disabled={selectedMCPServers.includes("__all__")}
disabled={
endpointType === EndpointType.MCP ? false : selectedMCPServers.includes("__all__")
}
>
<div className="flex flex-col py-1">
<span className="font-medium">{server.alias || server.server_name || server.server_id}</span>
@@ -1512,9 +1633,31 @@ const ChatUI: React.FC<ChatUIProps> = ({
))}
</Select>
{/* Tool restrictions UI (optional) */}
{/* MCP Tool selector - only for MCP direct mode */}
{endpointType === EndpointType.MCP &&
selectedMCPServers.length === 1 &&
selectedMCPServers[0] !== "__all__" && (
<div className="mt-3">
<Text className="text-xs text-gray-600 mb-1 block">Select Tool</Text>
<Select
style={{ width: "100%" }}
placeholder="Select a tool to call"
value={selectedMCPDirectTool}
onChange={(value) => setSelectedMCPDirectTool(value)}
options={(serverToolsMap[selectedMCPServers[0]] || []).map((tool: any) => ({
value: tool.name,
label: tool.name,
}))}
allowClear
className="rounded-md"
/>
</div>
)}
{/* Tool restrictions UI (optional) - hidden for MCP direct mode */}
{selectedMCPServers.length > 0 &&
!selectedMCPServers.includes("__all__") &&
endpointType !== EndpointType.MCP &&
MCP_SUPPORTED_ENDPOINTS.has(endpointType as EndpointType) && (
<div className="mt-3 space-y-2">
{selectedMCPServers.map((serverId) => {
@@ -2085,8 +2228,8 @@ const ChatUI: React.FC<ChatUIProps> = ({
</div>
)}
{/* Suggested prompts - show when chat is empty and not loading */}
{chatHistory.length === 0 && !isLoading && (
{/* Suggested prompts - show when chat is empty and not loading (skip for MCP - uses structured form) */}
{chatHistory.length === 0 && !isLoading && endpointType !== EndpointType.MCP && (
<div className="flex items-center gap-2 mb-3 overflow-x-auto">
{(endpointType === EndpointType.A2A_AGENTS
? ["What can you help me with?", "Tell me about yourself", "What tasks can you perform?"]
@@ -2151,46 +2294,79 @@ const ChatUI: React.FC<ChatUIProps> = ({
)}
</div>
{/* Middle: input field */}
<TextArea
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
endpointType === EndpointType.CHAT ||
{/* Middle: input field or MCP structured form */}
{endpointType === EndpointType.MCP &&
selectedMCPServers.length === 1 &&
selectedMCPServers[0] !== "__all__" &&
selectedMCPDirectTool ? (
<div className="flex-1 overflow-y-auto max-h-48 min-h-[44px] p-2 border border-gray-200 rounded-lg bg-gray-50/50">
{(() => {
const mcpTool = (serverToolsMap[selectedMCPServers[0]] || []).find(
(t: any) => t.name === selectedMCPDirectTool,
);
return mcpTool ? (
<MCPToolArgumentsForm
ref={mcpToolArgsFormRef}
tool={mcpTool}
className="space-y-2"
/>
) : (
<div className="flex items-center justify-center h-10 text-sm text-gray-500">
Loading tool schema...
</div>
);
})()}
</div>
) : (
<TextArea
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
endpointType === EndpointType.CHAT ||
endpointType === EndpointType.EMBEDDINGS ||
endpointType === EndpointType.RESPONSES ||
endpointType === EndpointType.ANTHROPIC_MESSAGES
? "Type your message... (Shift+Enter for new line)"
: endpointType === EndpointType.A2A_AGENTS
? "Send a message to the A2A agent..."
: endpointType === EndpointType.IMAGE_EDITS
? "Describe how you want to edit the image..."
: endpointType === EndpointType.SPEECH
? "Enter text to convert to speech..."
: endpointType === EndpointType.TRANSCRIPTION
? "Optional: Add context or prompt for transcription..."
: "Describe the image you want to generate..."
}
disabled={isLoading}
className="flex-1"
autoSize={{ minRows: 1, maxRows: 4 }}
style={{
resize: "none",
border: "none",
boxShadow: "none",
background: "transparent",
padding: "4px 0",
fontSize: "14px",
lineHeight: "20px",
}}
/>
? "Type your message... (Shift+Enter for new line)"
: endpointType === EndpointType.A2A_AGENTS
? "Send a message to the A2A agent..."
: endpointType === EndpointType.IMAGE_EDITS
? "Describe how you want to edit the image..."
: endpointType === EndpointType.SPEECH
? "Enter text to convert to speech..."
: endpointType === EndpointType.TRANSCRIPTION
? "Optional: Add context or prompt for transcription..."
: "Describe the image you want to generate..."
}
disabled={isLoading}
className="flex-1"
autoSize={{ minRows: 1, maxRows: 4 }}
style={{
resize: "none",
border: "none",
boxShadow: "none",
background: "transparent",
padding: "4px 0",
fontSize: "14px",
lineHeight: "20px",
}}
/>
)}
{/* Right: send button - matching blue theme */}
<TremorButton
onClick={handleSendMessage}
disabled={
isLoading || (endpointType === EndpointType.TRANSCRIPTION ? !uploadedAudio : !inputMessage.trim())
isLoading ||
(endpointType === EndpointType.MCP
? !(
selectedMCPServers.length === 1 &&
selectedMCPServers[0] !== "__all__" &&
selectedMCPDirectTool
)
: endpointType === EndpointType.TRANSCRIPTION
? !uploadedAudio
: !inputMessage.trim())
}
className="flex-shrink-0 ml-2 !w-8 !h-8 !min-w-8 !p-0 !rounded-full !bg-blue-600 hover:!bg-blue-700 disabled:!bg-gray-300 !border-none !text-white disabled:!text-gray-500 !flex !items-center !justify-center"
>
@@ -43,4 +43,5 @@ export const ENDPOINT_OPTIONS = [
{ value: EndpointType.SPEECH, label: "/v1/audio/speech" },
{ value: EndpointType.TRANSCRIPTION, label: "/v1/audio/transcriptions" },
{ value: EndpointType.A2A_AGENTS, label: "/v1/a2a/message/send" },
{ value: EndpointType.MCP, label: "/mcp-rest/tools/call" },
];
@@ -26,6 +26,7 @@ export enum EndpointType {
SPEECH = "speech",
TRANSCRIPTION = "transcription",
A2A_AGENTS = "a2a_agents",
MCP = "mcp",
// add additional endpoint types if required
}
@@ -23,6 +23,7 @@ interface A2AJsonRpcRequest {
method: string;
params: {
message: A2AMessage;
metadata?: { guardrails?: string[] };
};
}
@@ -114,6 +115,7 @@ export const makeA2ASendMessageRequest = async (
onTotalLatency?: (totalLatency: number) => void,
onA2AMetadata?: (metadata: A2ATaskMetadata) => void,
customBaseUrl?: string,
guardrails?: string[],
): Promise<void> => {
const proxyBaseUrl = customBaseUrl || getProxyBaseUrl();
const url = proxyBaseUrl
@@ -137,6 +139,10 @@ export const makeA2ASendMessageRequest = async (
},
};
if (guardrails && guardrails.length > 0) {
jsonRpcRequest.params.metadata = { guardrails };
}
const startTime = performance.now();
try {