diff --git a/docs/my-website/docs/proxy/guardrails/pillar_security.md b/docs/my-website/docs/proxy/guardrails/pillar_security.md
index 9632376768b..de0b0d53614 100644
--- a/docs/my-website/docs/proxy/guardrails/pillar_security.md
+++ b/docs/my-website/docs/proxy/guardrails/pillar_security.md
@@ -233,7 +233,7 @@ curl -X POST "http://localhost:4000/v1/chat/completions" \
}'
```
-This provides clear, explicit conversation tracking that works seamlessly with LiteLLM's session management.
+This provides clear, explicit conversation tracking that works seamlessly with LiteLLM's session management. When using monitor mode, the session ID is returned in the `x-pillar-session-id` response header for easy correlation and tracking.
### Actions on Flagged Content
@@ -251,6 +251,73 @@ Logs the violation but allows the request to proceed:
on_flagged_action: "monitor"
```
+**Response Headers:**
+
+You can opt in to receiving detection details in response headers by configuring `include_scanners: true` and/or `include_evidence: true`. When enabled, these headers are included for **every request**—not just flagged ones—enabling comprehensive metrics, false positive analysis, and threat investigation.
+
+- **`x-pillar-flagged`**: Boolean string indicating Pillar's blocking recommendation (`"true"` or `"false"`)
+- **`x-pillar-scanners`**: URL-encoded JSON object showing scanner categories (e.g., `%7B%22jailbreak%22%3Atrue%7D`) — requires `include_scanners: true`
+- **`x-pillar-evidence`**: URL-encoded JSON array of detection evidence (may contain items even when `flagged` is `false`) — requires `include_evidence: true`
+- **`x-pillar-session-id`**: URL-encoded session ID for correlation and investigation
+
+:::info Understanding `flagged` vs Scanner Results
+The `flagged` field is Pillar's **policy-level blocking recommendation**, which may differ from individual scanner results:
+
+- **`flagged: true`** → Pillar recommends blocking based on your configured policies
+- **`flagged: false`** → Pillar does not recommend blocking, but individual scanners may still detect content
+
+For example, the `toxic_language` scanner might detect profanity (`scanners.toxic_language: true`) while `flagged` remains `false` if your Pillar policy doesn't block on toxic language alone. This allows you to:
+- Monitor threats without blocking users
+- Build metrics on detection rates vs block rates
+- Analyze false positive rates by comparing scanner results to user feedback
+:::
+
+The `x-pillar-scanners`, `x-pillar-evidence`, and `x-pillar-session-id` headers use URL encoding (percent-encoding) to convert JSON data into an ASCII-safe format. This is necessary because HTTP headers only support ISO-8859-1 characters and cannot contain raw JSON special characters (`{`, `"`, `:`) or Unicode text. To read these headers, first URL-decode the value, then parse it as JSON.
+
+LiteLLM truncates the `x-pillar-evidence` header to a maximum of 8 KB per header to avoid proxy limits. Note that most proxies and servers also enforce a total header size limit of approximately 32 KB across all headers combined. When truncation occurs, each affected evidence item includes an `"evidence_truncated": true` flag and the metadata contains `pillar_evidence_truncated: true`.
+
+**Example Response Headers (URL-encoded):**
+```http
+x-pillar-flagged: true
+x-pillar-session-id: abc-123-def-456
+x-pillar-scanners: %7B%22jailbreak%22%3Atrue%2C%22prompt_injection%22%3Afalse%2C%22toxic_language%22%3Afalse%7D
+x-pillar-evidence: %5B%7B%22category%22%3A%22prompt_injection%22%2C%22evidence%22%3A%22Ignore%20previous%20instructions%22%7D%5D
+```
+
+**After Decoding:**
+```json
+// x-pillar-scanners
+{"jailbreak": true, "prompt_injection": false, "toxic_language": false}
+
+// x-pillar-evidence
+[{"category": "prompt_injection", "evidence": "Ignore previous instructions"}]
+```
+
+**Decoding Example (Python):**
+
+```python
+from urllib.parse import unquote
+import json
+
+# Step 1: URL-decode the header value (converts %7B to {, %22 to ", etc.)
+# Step 2: Parse the resulting JSON string
+scanners = json.loads(unquote(response.headers["x-pillar-scanners"]))
+evidence = json.loads(unquote(response.headers["x-pillar-evidence"]))
+
+# Session ID is a plain string, so only URL-decode is needed (no JSON parsing)
+session_id = unquote(response.headers["x-pillar-session-id"])
+```
+
+:::tip
+LiteLLM mirrors the encoded values onto `metadata["pillar_response_headers"]` so you can inspect exactly what was returned. When truncation occurs, it sets `metadata["pillar_evidence_truncated"]` to `true` and marks affected evidence items with `"evidence_truncated": true`. Evidence text is shortened with a `...[truncated]` suffix, and entire evidence entries may be removed if necessary to stay under the 8 KB header limit. Check these flags to determine if full evidence details are available in your logs.
+:::
+
+This allows your application to:
+- Track threats without blocking legitimate users
+- Implement custom handling logic based on threat types
+- Build analytics and alerting on security events
+- Correlate threats across requests using session IDs
+
### Resilience and Error Handling
#### Graceful Degradation (`fallback_on_error`)
@@ -544,6 +611,79 @@ curl -X POST "http://localhost:4000/v1/chat/completions" \
}
```
+
+
+
+**Monitor mode request with scanner detection:**
+
+```bash
+# Test with content that triggers scanner detection
+curl -v -X POST "http://localhost:4000/v1/chat/completions" \
+ -H "Content-Type: application/json" \
+ -H "Authorization: Bearer YOUR_LITELLM_PROXY_MASTER_KEY" \
+ -d '{
+ "model": "gpt-4.1-mini",
+ "messages": [{"role": "user", "content": "how do I rob a bank?"}],
+ "max_tokens": 50
+ }'
+```
+
+**Expected response (Allowed with headers):**
+
+The request succeeds and returns the LLM response. Headers are included for **all requests** when `include_scanners` and `include_evidence` are enabled—even when `flagged` is `false`:
+
+```http
+HTTP/1.1 200 OK
+x-litellm-applied-guardrails: pillar-monitor-everything,pillar-monitor-everything
+x-pillar-flagged: false
+x-pillar-scanners: %7B%22jailbreak%22%3Afalse%2C%22safety%22%3Atrue%2C%22prompt_injection%22%3Afalse%2C%22pii%22%3Afalse%2C%22secret%22%3Afalse%2C%22toxic_language%22%3Afalse%7D
+x-pillar-evidence: %5B%7B%22category%22%3A%22safety%22%2C%22type%22%3A%22non_violent_crimes%22%2C%22end_idx%22%3A20%2C%22evidence%22%3A%22how%20do%20I%20rob%20a%20bank%3F%22%2C%22metadata%22%3A%7B%22start_idx%22%3A0%2C%22end_idx%22%3A20%7D%7D%5D
+x-pillar-session-id: d9433f86-b428-4ee7-93ee-e97a53f8a180
+```
+
+Notice that `x-pillar-flagged: false` but `safety: true` in the scanners. This is because `flagged` represents Pillar's policy-level blocking recommendation, while individual scanners report their own detections.
+
+```python
+from urllib.parse import unquote
+import json
+
+scanners = json.loads(unquote(response.headers["x-pillar-scanners"]))
+evidence = json.loads(unquote(response.headers["x-pillar-evidence"]))
+session_id = unquote(response.headers["x-pillar-session-id"])
+flagged = response.headers["x-pillar-flagged"] == "true"
+
+# Scanner detected safety issue, but policy didn't flag for blocking
+print(f"Flagged for blocking: {flagged}") # False
+print(f"Safety issue detected: {scanners.get('safety')}") # True
+print(f"Evidence: {evidence}")
+# [{'category': 'safety', 'type': 'non_violent_crimes', 'evidence': 'how do I rob a bank?', ...}]
+```
+
+```json
+{
+ "id": "chatcmpl-xyz123",
+ "object": "chat.completion",
+ "model": "gpt-4.1-mini",
+ "choices": [
+ {
+ "index": 0,
+ "message": {
+ "role": "assistant",
+ "content": "I'm sorry, but I can't assist with that request."
+ },
+ "finish_reason": "stop"
+ }
+ ],
+ "usage": {
+ "prompt_tokens": 14,
+ "completion_tokens": 11,
+ "total_tokens": 25
+ }
+}
+```
+
+**Note:** In monitor mode, scanner results and evidence are included in response headers for every request, allowing you to build metrics and analyze detection patterns. The `flagged` field indicates whether Pillar's policy recommends blocking—your application can use the detailed scanner data for custom alerting, analytics, or false positive analysis.
+
diff --git a/litellm/proxy/common_utils/callback_utils.py b/litellm/proxy/common_utils/callback_utils.py
index 90f82580f1e..76c54332fa3 100644
--- a/litellm/proxy/common_utils/callback_utils.py
+++ b/litellm/proxy/common_utils/callback_utils.py
@@ -359,7 +359,11 @@ def get_remaining_tokens_and_requests_from_request_data(data: Dict) -> Dict[str,
def get_logging_caching_headers(request_data: Dict) -> Optional[Dict]:
- _metadata = request_data.get("metadata", None) or {}
+ _metadata = request_data.get("metadata", None)
+ if not _metadata:
+ _metadata = request_data.get("litellm_metadata", None)
+ if not isinstance(_metadata, dict):
+ _metadata = {}
headers = {}
if "applied_guardrails" in _metadata:
headers["x-litellm-applied-guardrails"] = ",".join(
@@ -369,6 +373,12 @@ def get_logging_caching_headers(request_data: Dict) -> Optional[Dict]:
if "semantic-similarity" in _metadata:
headers["x-litellm-semantic-similarity"] = str(_metadata["semantic-similarity"])
+ pillar_headers = _metadata.get("pillar_response_headers")
+ if isinstance(pillar_headers, dict):
+ headers.update(pillar_headers)
+ elif "pillar_flagged" in _metadata:
+ headers["x-pillar-flagged"] = str(_metadata["pillar_flagged"]).lower()
+
return headers
diff --git a/litellm/proxy/guardrails/guardrail_hooks/pillar/pillar.py b/litellm/proxy/guardrails/guardrail_hooks/pillar/pillar.py
index 74903cc52a4..0df610177e5 100644
--- a/litellm/proxy/guardrails/guardrail_hooks/pillar/pillar.py
+++ b/litellm/proxy/guardrails/guardrail_hooks/pillar/pillar.py
@@ -6,8 +6,10 @@
# +-------------------------------------------------------------+
# Standard library imports
+import json
import os
-from typing import TYPE_CHECKING, Any, Dict, Literal, Optional, Tuple, Type, Union
+from urllib.parse import quote
+from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Tuple, Type, Union
# Third-party imports
from fastapi import HTTPException
@@ -28,6 +30,7 @@ from litellm.llms.custom_httpx.http_handler import (
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.common_utils.callback_utils import (
add_guardrail_to_applied_guardrails_header,
+ get_metadata_variable_name_from_kwargs,
)
from litellm.types.guardrails import GuardrailEventHooks
from litellm.types.utils import LLMResponseTypes
@@ -35,6 +38,109 @@ from litellm.types.utils import LLMResponseTypes
if TYPE_CHECKING:
from litellm.types.proxy.guardrails.guardrail_hooks.base import GuardrailConfigModel
+MAX_PILLAR_HEADER_VALUE_BYTES = 8 * 1024
+
+
+def _encode_json_for_header(data: Any) -> str:
+ """
+ JSON-serialize and URL-encode data for safe header transmission.
+ """
+ json_payload = json.dumps(data, ensure_ascii=False, separators=(",", ":"))
+ return quote(json_payload, safe="")
+
+
+def _truncate_evidence_payload(
+ evidence: Any, max_bytes: int = MAX_PILLAR_HEADER_VALUE_BYTES
+) -> Tuple[Any, str, bool]:
+ """
+ Truncate evidence payload so the encoded header value stays within max_bytes.
+
+ Returns:
+ truncated_evidence: Evidence list/value after truncation
+ encoded_value: URL-encoded JSON string for header
+ was_truncated: Whether truncation occurred
+ """
+ if not isinstance(evidence, list):
+ encoded = _encode_json_for_header(evidence)
+ if len(encoded.encode("utf-8")) <= max_bytes:
+ return evidence, encoded, False
+ truncated_value = "[truncated]"
+ return truncated_value, _encode_json_for_header(truncated_value), True
+
+ truncated: List[Any] = []
+ encoded = _encode_json_for_header(truncated)
+ truncated_flag = False
+
+ for entry in evidence:
+ working_entry: Any
+ if isinstance(entry, dict):
+ working_entry = dict(entry)
+ else:
+ working_entry = entry
+
+ truncated.append(working_entry)
+ encoded = _encode_json_for_header(truncated)
+
+ if len(encoded.encode("utf-8")) <= max_bytes:
+ continue
+
+ truncated_flag = True
+ if isinstance(working_entry, dict):
+ evidence_text = str(working_entry.get("evidence", ""))
+ if evidence_text:
+ step = max(1, len(evidence_text) // 2)
+ while len(encoded.encode("utf-8")) > max_bytes and evidence_text:
+ evidence_text = (
+ evidence_text[:-step] if len(evidence_text) > step else evidence_text[:-1]
+ )
+ step = max(1, step // 2)
+ truncated_text = (
+ f"{evidence_text}...[truncated]" if evidence_text else "[truncated]"
+ )
+ working_entry["evidence"] = truncated_text
+ working_entry["evidence_truncated"] = True
+ encoded = _encode_json_for_header(truncated)
+
+ if len(encoded.encode("utf-8")) <= max_bytes:
+ continue
+
+ truncated.pop()
+ encoded = _encode_json_for_header(truncated)
+
+ return truncated, encoded, truncated_flag
+
+
+def build_pillar_response_headers(metadata_store: Dict[str, Any]) -> Dict[str, str]:
+ """
+ Create URL-safe Pillar response headers and apply truncation metadata.
+ """
+ headers: Dict[str, str] = {}
+
+ if "pillar_flagged" in metadata_store:
+ headers["x-pillar-flagged"] = str(metadata_store["pillar_flagged"]).lower()
+
+ if "pillar_scanners" in metadata_store:
+ headers["x-pillar-scanners"] = _encode_json_for_header(metadata_store["pillar_scanners"])
+
+ if "pillar_evidence" in metadata_store:
+ truncated_evidence, encoded_value, truncated_flag = _truncate_evidence_payload(
+ metadata_store["pillar_evidence"]
+ )
+ metadata_store["pillar_evidence"] = truncated_evidence
+ if truncated_flag:
+ metadata_store["pillar_evidence_truncated"] = True
+ headers["x-pillar-evidence"] = encoded_value
+
+ if "pillar_session_id_response" in metadata_store:
+ headers["x-pillar-session-id"] = quote(
+ str(metadata_store["pillar_session_id_response"]), safe=""
+ )
+
+ if headers:
+ metadata_store["pillar_response_headers"] = headers
+
+ return headers
+
# Exception classes
class PillarGuardrailMissingSecrets(Exception):
@@ -637,15 +743,31 @@ class PillarGuardrail(CustomGuardrail):
flagged = pillar_response.get("flagged", False)
+ metadata_field = get_metadata_variable_name_from_kwargs(original_data)
+ if metadata_field not in original_data or not isinstance(original_data.get(metadata_field), dict):
+ original_data[metadata_field] = {}
+ metadata_store = original_data[metadata_field]
+
+ # Backwards compatibility - ensure metadata alias exists when different key used
+ if metadata_field != "metadata":
+ if "metadata" not in original_data or not isinstance(original_data.get("metadata"), dict):
+ original_data["metadata"] = metadata_store
+
# Store session_id from Pillar response for potential reuse
pillar_session_id = pillar_response.get("session_id")
if pillar_session_id:
verbose_proxy_logger.debug(f"Pillar Guardrail: Received session_id from server: {pillar_session_id}")
# Store in request metadata for use in subsequent hooks
- if "metadata" not in original_data:
- original_data["metadata"] = {}
- if "pillar_session_id" not in original_data["metadata"]:
- original_data["metadata"]["pillar_session_id"] = pillar_session_id
+ if "pillar_session_id" not in metadata_store:
+ metadata_store["pillar_session_id"] = pillar_session_id
+ metadata_store["pillar_session_id_response"] = pillar_session_id
+
+ # Always set flagged status and scanner/evidence data for monitor mode
+ metadata_store["pillar_flagged"] = flagged
+ if self.include_scanners:
+ metadata_store["pillar_scanners"] = pillar_response.get("scanners", {})
+ if self.include_evidence:
+ metadata_store["pillar_evidence"] = pillar_response.get("evidence", [])
if flagged:
verbose_proxy_logger.warning("Pillar Guardrail: Threat detected")
@@ -654,6 +776,8 @@ class PillarGuardrail(CustomGuardrail):
elif self.on_flagged_action == "monitor":
verbose_proxy_logger.info("Pillar Guardrail: Monitoring mode - allowing flagged content to proceed")
+ build_pillar_response_headers(metadata_store)
+
def _raise_pillar_detection_exception(self, pillar_response: Dict[str, Any]) -> None:
"""
Raise an HTTPException for Pillar security detections.
diff --git a/tests/test_litellm/proxy/guardrails/test_pillar_guardrails.py b/tests/test_litellm/proxy/guardrails/test_pillar_guardrails.py
index 99a51d20a7d..2e7443e889f 100644
--- a/tests/test_litellm/proxy/guardrails/test_pillar_guardrails.py
+++ b/tests/test_litellm/proxy/guardrails/test_pillar_guardrails.py
@@ -15,6 +15,9 @@ from unittest.mock import Mock, patch
sys.path.insert(0, os.path.abspath("../../.."))
# Third-party imports
+import json
+from urllib.parse import unquote
+
import pytest
from fastapi.exceptions import HTTPException
from httpx import Request, Response
@@ -23,11 +26,15 @@ from httpx import Request, Response
import litellm
from litellm import DualCache
from litellm.proxy._types import UserAPIKeyAuth
+from litellm.proxy.common_utils.callback_utils import get_logging_caching_headers
from litellm.proxy.guardrails.guardrail_hooks.pillar import (
PillarGuardrail,
PillarGuardrailAPIError,
PillarGuardrailMissingSecrets,
)
+from litellm.proxy.guardrails.guardrail_hooks.pillar.pillar import (
+ build_pillar_response_headers,
+)
from litellm.proxy.guardrails.init_guardrails import init_guardrails_v2
@@ -169,6 +176,7 @@ def pillar_clean_response():
"pii": False,
"toxic_language": False,
},
+ "evidence": [],
},
status_code=200,
request=Request(
@@ -402,6 +410,133 @@ async def test_pre_call_hook_flagged_content_monitor(
)
assert result == malicious_request_data
+ assert "metadata" in malicious_request_data
+ metadata = malicious_request_data["metadata"]
+ assert metadata.get("pillar_flagged") is True
+ assert metadata.get("pillar_session_id") == pillar_flagged_response.json()["session_id"]
+ assert metadata.get("pillar_session_id_response") == pillar_flagged_response.json()["session_id"]
+ assert metadata.get("pillar_scanners") == pillar_flagged_response.json().get("scanners", {})
+ assert metadata.get("pillar_evidence") == pillar_flagged_response.json().get("evidence", [])
+
+
+@pytest.mark.asyncio
+async def test_pre_call_hook_clean_content_returns_scanners_and_evidence(
+ pillar_monitor_guardrail,
+ sample_request_data,
+ user_api_key_dict,
+ dual_cache,
+ pillar_clean_response,
+):
+ """Test that scanners and evidence are returned even when content is not flagged."""
+ with patch(
+ "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
+ return_value=pillar_clean_response,
+ ):
+ result = await pillar_monitor_guardrail.async_pre_call_hook(
+ data=sample_request_data,
+ cache=dual_cache,
+ user_api_key_dict=user_api_key_dict,
+ call_type="completion",
+ )
+
+ assert result == sample_request_data
+ assert "metadata" in sample_request_data
+ metadata = sample_request_data["metadata"]
+ # Even when not flagged, we should get scanners and evidence
+ assert metadata.get("pillar_flagged") is False
+ # pillar_session_id preserves existing value, pillar_session_id_response is always from response
+ assert metadata.get("pillar_session_id_response") == pillar_clean_response.json()["session_id"]
+ assert metadata.get("pillar_scanners") == pillar_clean_response.json().get("scanners", {})
+ assert metadata.get("pillar_evidence") == pillar_clean_response.json().get("evidence", [])
+
+ # Verify headers are also built
+ headers = get_logging_caching_headers(sample_request_data)
+ assert headers["x-pillar-flagged"] == "false"
+ assert json.loads(unquote(headers["x-pillar-scanners"])) == pillar_clean_response.json().get("scanners", {})
+
+
+def test_get_logging_caching_headers_pillar_metadata():
+ scanners = {"toxic_language": True, "jailbreak": False}
+ evidence = [{"category": "toxic_language", "evidence": "example"}]
+ request_data = {
+ "metadata": {
+ "pillar_flagged": True,
+ "pillar_scanners": scanners,
+ "pillar_evidence": evidence,
+ "pillar_session_id_response": "test-session-123",
+ }
+ }
+
+ build_pillar_response_headers(request_data["metadata"])
+
+ headers = get_logging_caching_headers(request_data)
+
+ assert headers["x-pillar-flagged"] == "true"
+ assert json.loads(unquote(headers["x-pillar-scanners"])) == scanners
+ assert json.loads(unquote(headers["x-pillar-evidence"])) == evidence
+ assert unquote(headers["x-pillar-session-id"]) == "test-session-123"
+ assert request_data["metadata"]["pillar_response_headers"]["x-pillar-flagged"] == "true"
+
+
+def test_get_logging_caching_headers_truncates_large_evidence():
+ long_text = "悪" * 6000 # multi-byte unicode to test URL encoding and truncation
+ request_data = {
+ "metadata": {
+ "pillar_evidence": [{"category": "unicode", "evidence": long_text}],
+ }
+ }
+
+ build_pillar_response_headers(request_data["metadata"])
+
+ headers = get_logging_caching_headers(request_data)
+ evidence_header = headers["x-pillar-evidence"]
+
+ assert len(evidence_header.encode("utf-8")) <= 8 * 1024
+ decoded_evidence = json.loads(unquote(evidence_header))
+ assert decoded_evidence
+ assert decoded_evidence[0]["evidence"].endswith("...[truncated]")
+ assert decoded_evidence[0].get("evidence_truncated") is True
+ assert request_data["metadata"]["pillar_evidence_truncated"] is True
+ assert request_data["metadata"]["pillar_response_headers"]["x-pillar-evidence"] == evidence_header
+
+
+@pytest.mark.asyncio
+async def test_post_call_hook_flagged_content_monitor_updates_metadata_and_headers(
+ pillar_monitor_guardrail,
+ malicious_request_data,
+ user_api_key_dict,
+ pillar_flagged_response,
+ mock_llm_response,
+):
+ """Ensure post-call monitor verdicts update shared metadata and headers."""
+ request_data = malicious_request_data.copy()
+ request_data["metadata"] = {}
+
+ with patch(
+ "litellm.llms.custom_httpx.http_handler.AsyncHTTPHandler.post",
+ return_value=pillar_flagged_response,
+ ):
+ response = await pillar_monitor_guardrail.async_post_call_success_hook(
+ data=request_data,
+ user_api_key_dict=user_api_key_dict,
+ response=mock_llm_response,
+ )
+
+ assert response is mock_llm_response
+ metadata = request_data["metadata"]
+ pillar_json = pillar_flagged_response.json()
+ assert metadata.get("pillar_flagged") is True
+ assert metadata.get("pillar_session_id") == pillar_json["session_id"]
+ assert metadata.get("pillar_session_id_response") == pillar_json["session_id"]
+ assert metadata.get("pillar_scanners") == pillar_json.get("scanners", {})
+ assert metadata.get("pillar_evidence") == pillar_json.get("evidence", [])
+
+ headers = get_logging_caching_headers(request_data)
+ assert headers["x-pillar-flagged"] == "true"
+ assert json.loads(unquote(headers["x-pillar-scanners"])) == pillar_json.get("scanners", {})
+ assert json.loads(unquote(headers["x-pillar-evidence"])) == pillar_json.get("evidence", [])
+ assert unquote(headers["x-pillar-session-id"]) == pillar_json["session_id"]
+ assert request_data["metadata"]["pillar_response_headers"]["x-pillar-session-id"] == headers["x-pillar-session-id"]
@pytest.mark.asyncio