-
-
Notifications
You must be signed in to change notification settings - Fork 11.7k
feat(guardrails): return structured guardrail_response on /apply_guardrail for success and blocked responses #29384
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 ( | ||||||||||||||||||||||||||||||
|
|
@@ -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]) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
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( | ||||||||||||||||||||||||||||||
|
|
@@ -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( | ||||||||||||||||||||||||||||||
|
|
@@ -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. | ||||||||||||||||||||||||||||||
|
|
@@ -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) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| 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" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The loop iterates
("metadata", "litellm_metadata")and appends entries from each independently. If a guardrail (or a pre-call hook) writesstandard_logging_guardrail_informationinto bothdata["metadata"]anddata["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.