mirror of
https://github.com/tiennm99/litellm.git
synced 2026-08-20 06:23:46 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user