Skip to main content

Custom Code Guardrail

Write custom guardrail logic using Python-like code that runs in a sandboxed environment.

Quick Start​

1. Define the guardrail in config​

model_list:
- model_name: gpt-5.6-terra
litellm_params:
model: gpt-5.6-terra
api_key: os.environ/OPENAI_API_KEY

guardrails:
- guardrail_name: block-ssn
litellm_params:
guardrail: custom_code
mode: pre_call
custom_code: |
def apply_guardrail(inputs, request_data, input_type):
for text in inputs["texts"]:
if regex_match(text, r"\d{3}-\d{2}-\d{4}"):
return block("SSN detected")
return allow()

2. Start proxy​

litellm --config config.yaml

3. Test​

curl -X POST http://localhost:4000/chat/completions \
-H "Authorization: Bearer $LITELLM_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-5.6-terra",
"messages": [{"role": "user", "content": "My SSN is 123-45-6789"}],
"guardrails": ["block-ssn"]
}'

Configuration​

ParameterTypeRequiredDescription
guardrailstring✅Must be custom_code
modestring✅When to run: pre_call, post_call, during_call
custom_codestring✅Python-like code with apply_guardrail function
default_onbool❌Run on all requests (default: false)
timeoutfloat❌Wall-clock limit in seconds for one run of apply_guardrail, module-level code included (default: 30). A run that exceeds it fails the request instead of stalling the proxy. See Execution timeout

Writing Custom Code​

Function Signature​

Your code must define an apply_guardrail function. It can be either sync or async:

# 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(), flag() 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​

FieldTypeDescription
textsList[str]Extracted text from the request/response
imagesList[str]Extracted images (for image guardrails)
toolsList[dict]Tools sent to the LLM
tool_callsList[dict]Tool calls returned from the LLM
structured_messagesList[dict]Full messages with role info (system/user/assistant)
modelstrThe model being used

request_data Parameter​

FieldTypeDescription
modelstrModel name
user_idstrUser ID from API key
team_idstrTeam ID from API key
end_user_idstrEnd user ID
metadatadictRequest metadata

Return Values​

FunctionDescription
allow()Let request/response through
block(reason)Reject with message
flag(reason, metadata={})Let request/response through unchanged, but record a non-blocking violation
modify(texts=[], images=[], tool_calls=[])Transform content

Flagging without blocking​

flag(reason, metadata={...}) is for audit-only or monitor-mode guardrails. The request or response continues exactly as it would have with allow(), and the guardrail records a guardrail_flagged entry in the request's guardrail information with the guardrail name, the configured mode, the input_type it evaluated (request or response), the reason, and your structured metadata. It works the same way for streaming and non-streaming requests.

In the Guardrails Monitor a flagged request counts as flagged rather than passed or blocked, and in Request Logs the guardrail row shows flagged (when a guardrail runs on both pre_call and post_call, the row shows the most severe of the two outcomes). The request-level guardrail_status becomes guardrail_flagged unless another guardrail on the same request blocked or failed, in which case that status wins.

guardrails:
- guardrail_name: audit-competitor-mentions
litellm_params:
guardrail: custom_code
mode: [pre_call, post_call]
default_on: true
custom_code: |
def apply_guardrail(inputs, request_data, input_type):
for text in inputs["texts"]:
if contains_any(lower(text), ["acme", "globex"]):
return flag(
"competitor mentioned",
metadata={"category": "competitor", "phase": input_type},
)
return allow()

The recorded entry looks like this in standard_logging_guardrail_information:

{
"guardrail_name": "audit-competitor-mentions",
"guardrail_mode": ["pre_call", "post_call"],
"guardrail_status": "guardrail_flagged",
"guardrail_response": {
"action": "flag",
"reason": "competitor mentioned",
"input_type": "request",
"metadata": {"category": "competitor", "phase": "request"}
}
}

Built-in Primitives​

Regex​

FunctionDescription
regex_match(text, pattern)Returns True if pattern found
regex_replace(text, pattern, replacement)Replace all matches
regex_find_all(text, pattern)Return list of matches

JSON​

FunctionDescription
json_parse(text)Parse JSON string, returns None on error
json_stringify(obj)Convert to JSON string
json_schema_valid(obj, schema)Validate against JSON schema

URL​

FunctionDescription
extract_urls(text)Extract all URLs from text
is_valid_url(url)Check if URL is valid
all_urls_valid(text)Check all URLs in text are valid

Code Detection​

FunctionDescription
detect_code(text)Returns True if code detected
detect_code_languages(text)Returns list of detected languages
contains_code_language(text, ["sql", "python"])Check for specific languages

Text Utilities​

FunctionDescription
contains(text, substring)Check if substring exists
contains_any(text, [substr1, substr2])Check if any substring exists
word_count(text)Count words
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.

FunctionDescription
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:

{
"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.

Blocked destinations​

Every URL, redirect hops included, goes through the proxy's SSRF validation before a connection is opened. Private (RFC 1918), loopback, link-local and cloud metadata addresses are refused, and the primitive returns an error response instead of raising:

{
"status_code": 0,
"body": None,
"headers": {},
"success": False,
"error": "Blocked URL: URL targets a blocked address (169.254.169.254). If this is a legitimate internal service, add the host to `user_url_allowed_hosts` in litellm_settings."
}

To let a guardrail call an internal service, list its host (as written in the URL, with the port when the URL carries one) under litellm_settings:

litellm_settings:
user_url_allowed_hosts:
- moderation.corp.internal
- 10.0.0.12:8080

user_url_validation: false turns the check off for the whole proxy. Both settings are documented in config settings. GET requests follow redirects, validating each hop; POST, PUT, PATCH and DELETE never follow redirects, so a 3xx comes back as the response.

Execution timeout​

Each run of apply_guardrail is bounded by timeout (default 30 seconds), and so is the module-level code that runs when the guardrail loads. A sync function runs on a worker thread, so a busy loop never stalls the proxy's event loop, and every while test, for iteration, and comprehension in guardrail code checks the budget, so a loop that never yields or awaits still stops at the deadline. An async function is checked the same way, with no await point needed. A run that exceeds the budget fails the request with a Custom code guardrail '<name>' exceeded its 30s execution timeout error, and compile-time code that exceeds it fails the guardrail's load.

guardrails:
- guardrail_name: "slow-moderation"
litellm_params:
guardrail: custom_code
mode: "pre_call"
timeout: 10
custom_code: |
async def apply_guardrail(inputs, request_data, input_type):
result = await http_post("https://moderation.example.com/check", body={"texts": inputs["texts"]})
if result["success"] and result["body"].get("flagged"):
return block("Flagged by moderation service")
return allow()

The timeout argument of http_request bounds a single HTTP call (default 30 seconds, at most 60) and counts against the run's budget.

Examples​

Block PII (SSN)​

def apply_guardrail(inputs, request_data, input_type):
for text in inputs["texts"]:
if regex_match(text, r"\d{3}-\d{2}-\d{4}"):
return block("SSN detected")
return allow()

Redact Email Addresses​

def apply_guardrail(inputs, request_data, input_type):
pattern = r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"
modified = []
for text in inputs["texts"]:
modified.append(regex_replace(text, pattern, "[EMAIL REDACTED]"))
return modify(texts=modified)

Block SQL Injection​

def apply_guardrail(inputs, request_data, input_type):
if input_type != "request":
return allow()
for text in inputs["texts"]:
if contains_code_language(text, ["sql"]):
return block("SQL code not allowed")
return allow()

Validate JSON Response​

def apply_guardrail(inputs, request_data, input_type):
if input_type != "response":
return allow()

schema = {
"type": "object",
"required": ["name", "value"]
}

for text in inputs["texts"]:
obj = json_parse(text)
if obj is None:
return block("Invalid JSON response")
if not json_schema_valid(obj, schema):
return block("Response missing required fields")
return allow()

Check URLs in Response​

def apply_guardrail(inputs, request_data, input_type):
if input_type != "response":
return allow()
for text in inputs["texts"]:
if not all_urls_valid(text):
return block("Response contains invalid URLs")
return allow()

Call External Moderation API (Async)​

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​

def apply_guardrail(inputs, request_data, input_type):
modified = []

for text in inputs["texts"]:
# Redact SSN
text = regex_replace(text, r"\d{3}-\d{2}-\d{4}", "[SSN]")
# Redact credit cards
text = regex_replace(text, r"\d{16}", "[CARD]")
modified.append(text)

# Block SQL in requests
if input_type == "request":
for text in inputs["texts"]:
if contains_code_language(text, ["sql"]):
return block("SQL injection blocked")

return modify(texts=modified)

Sandbox Restrictions​

Custom code runs in a restricted environment:

  • ❌ No import statements
  • ❌ No file I/O
  • ❌ No exec() or eval()
  • ✅ HTTP requests via built-in http_request, http_get, http_post primitives, to public addresses or hosts listed in user_url_allowed_hosts (see Blocked destinations)
  • ✅ Only LiteLLM-provided primitives available
  • ⏱ Each run is bounded by the guardrail's timeout (see Execution timeout)

Per-Request Usage​

Enable guardrail per request:

curl -X POST http://localhost:4000/chat/completions \
-H "Authorization: Bearer $LITELLM_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "gpt-5.6-terra",
"messages": [{"role": "user", "content": "Hello"}],
"guardrails": ["block-ssn"]
}'

Default On​

Run guardrail on all requests by setting default_on: true on the top-level guardrails entry:

guardrails:
- guardrail_name: block-ssn
litellm_params:
guardrail: custom_code
mode: pre_call
default_on: true
custom_code: |
def apply_guardrail(inputs, request_data, input_type):
...