Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 68 additions & 6 deletions litellm/proxy/guardrails/guardrail_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@
from litellm.constants import DEFAULT_MAX_RECURSE_DEPTH
from litellm.integrations.custom_guardrail import CustomGuardrail
from litellm.litellm_core_utils.safe_json_dumps import safe_dumps
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy._types import (
LitellmUserRoles,
ProxyErrorTypes,
ProxyException,
UserAPIKeyAuth,
)
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view
from litellm.proxy.guardrails.guardrail_hooks.custom_code.sandbox import (
Expand Down Expand Up @@ -2187,6 +2192,57 @@ def execute_guardrail():
)


def _collect_guardrail_info_from_data(data: dict) -> List[Dict[str, Any]]:
"""Flatten the StandardLoggingGuardrailInformation entries the guardrail wrote
into request metadata (in its finally block) into a JSON-serializable list."""
entries: list = []
for key in ("metadata", "litellm_metadata"):
container = data.get(key) or {}
found = container.get("standard_logging_guardrail_information")
if found:
entries.extend(found if isinstance(found, list) else [found])
Comment on lines +2199 to +2203

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Possible duplicate entries when both metadata namespaces are populated

The loop iterates ("metadata", "litellm_metadata") and appends entries from each independently. If a guardrail (or a pre-call hook) writes standard_logging_guardrail_information into both data["metadata"] and data["litellm_metadata"], the same guardrail run will appear twice in the collected list. A deduplication step or a clear policy on which namespace takes precedence would prevent callers from seeing doubled results.


collected: List[Dict[str, Any]] = []
for entry in entries:
d = entry if isinstance(entry, dict) else dict(entry)
mode = d.get("guardrail_mode")
collected.append(
{
"guardrail_name": d.get("guardrail_name"),
"guardrail_status": d.get("guardrail_status"),
"guardrail_mode": str(mode) if mode is not None else None,
"guardrail_provider": d.get("guardrail_provider"),
"guardrail_response": d.get("guardrail_response"),
"duration": d.get("duration"),
}
)
return collected


def _enrich_guardrail_block_exception(e: Exception, data: dict) -> Exception:
"""When a guardrail blocked the request, attach the structured guardrail
classification to the raised HTTPException so the failure (e.g. 403) response
carries the same `guardrail_response` detail as the success path."""
info = _collect_guardrail_info_from_data(data)
if not info or not isinstance(e, HTTPException):
return e
detail = e.detail
if isinstance(detail, dict):
message = detail.get("error") or detail.get("message") or str(detail)
else:
message = str(detail)
# Return a ProxyException with provider_specific_fields so the structured
# classification stays a real JSON object on the wire (not stringified into
# `message`). handle_exception_on_proxy passes ProxyException through as-is.
return ProxyException(
message=message if isinstance(message, str) else str(message),
type=ProxyErrorTypes.internal_server_error,
param="None",
code=e.status_code,
provider_specific_fields={"guardrail_response": info},
)
Comment on lines +2237 to +2243

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 ProxyErrorTypes.internal_server_error is semantically wrong for a guardrail block. The HTTP status code (e.status_code, typically 403) is preserved correctly in code, but the type field in the serialized error body will read "internal_server_error", which is misleading for clients that decode it. A more descriptive type (e.g. bad_request_error, or ideally a dedicated guardrail_blocked value) should be used here.

Suggested change
return ProxyException(
message=message if isinstance(message, str) else str(message),
type=ProxyErrorTypes.internal_server_error,
param="None",
code=e.status_code,
provider_specific_fields={"guardrail_response": info},
)
return ProxyException(
message=message if isinstance(message, str) else str(message),
type=ProxyErrorTypes.bad_request_error,
param="None",
code=e.status_code,
provider_specific_fields={"guardrail_response": info},
)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!



@router.post("/guardrails/apply_guardrail", response_model=ApplyGuardrailResponse)
@router.post("/apply_guardrail", response_model=ApplyGuardrailResponse)
async def apply_guardrail(
Expand All @@ -2200,6 +2256,11 @@ async def apply_guardrail(
"""
from litellm.proxy.utils import handle_exception_on_proxy

# Defined before the try so the except block can read the guardrail's logged
# result (written into request_data["metadata"] in the guardrail's finally
# block) for the failure-path classification.
request_data: dict = {"messages": request.messages} if request.messages else {}

try:
active_guardrail: Optional[CustomGuardrail] = (
GUARDRAIL_REGISTRY.get_initialized_guardrail_callback(
Expand All @@ -2212,10 +2273,6 @@ async def apply_guardrail(
detail=f"Guardrail '{request.guardrail_name}' not found. Please ensure the guardrail is configured in your LiteLLM proxy.",
)

request_data: dict = {}
if request.messages:
request_data["messages"] = request.messages

# Auto-detect input_type: if the caller didn't specify "response" but the
# guardrail only runs post_call (e.g. LLM-as-a-judge), use "response" so
# the test actually exercises the guardrail logic.
Expand All @@ -2238,9 +2295,14 @@ async def apply_guardrail(
response_text = guardrailed_inputs.get("texts", [])

return ApplyGuardrailResponse(
response_text=response_text[0] if response_text else request.text
response_text=response_text[0] if response_text else request.text,
# Surface the structured guardrail result on success (HTTP 200).
guardrail_response=_collect_guardrail_info_from_data(request_data) or None,
)
except Exception as e:
# On a guardrail block, surface the structured classification (matching
# the success path) instead of a stringified dict in the error message.
e = _enrich_guardrail_block_exception(e, request_data)
raise handle_exception_on_proxy(e)


Expand Down
5 changes: 5 additions & 0 deletions litellm/types/guardrails.py
Original file line number Diff line number Diff line change
Expand Up @@ -915,6 +915,11 @@ class ApplyGuardrailRequest(BaseModel):

class ApplyGuardrailResponse(BaseModel):
response_text: str
# The structured guardrail result (name, status, provider, and the raw
# guardrail response/classification). Populated on both the success and the
# blocked paths. Optional so the field is omitted when no guardrail info is
# available.
guardrail_response: Optional[List[Dict[str, Any]]] = None


class PatchGuardrailRequest(BaseModel):
Expand Down
120 changes: 120 additions & 0 deletions tests/test_litellm/proxy/guardrails/test_apply_guardrail_response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"""
Unit tests for the structured `guardrail_response` surfaced by the
`/apply_guardrail` endpoint on both the success and the blocked (failure) paths.

Covers the helpers added on top of PR #28970:
- `_collect_guardrail_info_from_data`
- `_enrich_guardrail_block_exception`
and the new `ApplyGuardrailResponse.guardrail_response` field.

These are mocked unit tests (no real LLM / guardrail API calls).
"""

import os
import sys

sys.path.insert(0, os.path.abspath("../../.."))

from fastapi import HTTPException

from litellm.proxy._types import ProxyException
from litellm.proxy.guardrails.guardrail_endpoints import (
_collect_guardrail_info_from_data,
_enrich_guardrail_block_exception,
)
from litellm.types.guardrails import ApplyGuardrailResponse


def _sample_slg(status="success", guardrail_response=None):
return {
"guardrail_name": "content-safety-multi",
"guardrail_status": status,
"guardrail_mode": "pre_call",
"guardrail_provider": "litellm_content_filter",
"guardrail_response": [] if guardrail_response is None else guardrail_response,
"duration": 0.001,
}


def test_collect_guardrail_info_from_metadata():
data = {"metadata": {"standard_logging_guardrail_information": [_sample_slg()]}}
info = _collect_guardrail_info_from_data(data)
assert len(info) == 1
assert info[0]["guardrail_name"] == "content-safety-multi"
assert info[0]["guardrail_status"] == "success"
assert info[0]["guardrail_mode"] == "pre_call"
assert info[0]["guardrail_provider"] == "litellm_content_filter"


def test_collect_guardrail_info_from_litellm_metadata():
data = {
"litellm_metadata": {"standard_logging_guardrail_information": [_sample_slg()]}
}
info = _collect_guardrail_info_from_data(data)
assert len(info) == 1
assert info[0]["guardrail_name"] == "content-safety-multi"


def test_collect_guardrail_info_empty_when_absent():
assert _collect_guardrail_info_from_data({}) == []
assert _collect_guardrail_info_from_data({"metadata": {}}) == []


def test_enrich_block_exception_adds_structured_classification():
classification = [
{
"type": "category_keyword",
"category": "denied_medical_advice",
"keyword": "medicine",
"severity": "high",
"action": "BLOCK",
}
]
data = {
"metadata": {
"standard_logging_guardrail_information": [
_sample_slg(
status="guardrail_intervened", guardrail_response=classification
)
]
}
}
original = HTTPException(
status_code=403,
detail={
"error": "Content blocked: denied_medical_advice category keyword 'medicine' detected (severity: high)",
"category": "denied_medical_advice",
},
)
enriched = _enrich_guardrail_block_exception(original, data)

assert isinstance(enriched, ProxyException)
assert str(enriched.code) == "403"
assert "Content blocked" in enriched.message
assert enriched.provider_specific_fields is not None
surfaced = enriched.provider_specific_fields["guardrail_response"]
assert surfaced[0]["guardrail_status"] == "guardrail_intervened"
assert surfaced[0]["guardrail_response"] == classification


def test_enrich_block_exception_noop_without_guardrail_info():
original = HTTPException(status_code=403, detail="blocked")
# No guardrail info in data -> the original exception is returned unchanged.
assert _enrich_guardrail_block_exception(original, {"metadata": {}}) is original


def test_enrich_block_exception_noop_for_non_http_exception():
err = ValueError("boom")
data = {"metadata": {"standard_logging_guardrail_information": [_sample_slg()]}}
# Non-HTTPException errors are passed through untouched.
assert _enrich_guardrail_block_exception(err, data) is err


def test_apply_guardrail_response_model_carries_guardrail_response():
# defaults to None (field omitted when no hook/endpoint attaches it)
assert ApplyGuardrailResponse(response_text="hi").guardrail_response is None

populated = ApplyGuardrailResponse(
response_text="hi", guardrail_response=[{"guardrail_name": "x"}]
)
assert populated.guardrail_response[0]["guardrail_name"] == "x"
Loading