feat(guardrails): add non-blocking flag() verdict to custom code guardrails - #39728
Conversation
…drails
Custom code guardrails could only allow(), block(reason) or modify(). This adds flag(reason, metadata={}) which lets the request or response through unchanged and records a guardrail_flagged entry carrying the guardrail name, configured mode, evaluated input_type (request or response), reason and structured metadata. The new status is threaded through the request-level guardrail_status aggregation, the Guardrails Monitor rollup (flagged_count), Request Logs (action=flagged, most severe phase wins when a guardrail runs pre and post call) and the Request Logs detail view in the dashboard, which now renders FLAGGED with warning styling instead of falling into FAILED.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
|
|
|
@greptileai please review |
Greptile SummaryThis PR adds a non-blocking
Confidence Score: 5/5The PR appears safe to merge with no actionable correctness, security, or repository-rule violations identified. The new verdict is propagated consistently through execution, standard logging, usage aggregation, API presentation, and dashboard rendering, with focused tests covering the principal request, response, aggregation, and UI paths.
|
| Filename | Overview |
|---|---|
| litellm/proxy/guardrails/guardrail_hooks/custom_code/primitives.py | Adds the sandbox-exposed flag(reason, metadata) result primitive. |
| litellm/proxy/guardrails/guardrail_hooks/custom_code/custom_code_guardrail.py | Processes flag verdicts as unchanged content while recording one timed guardrail_flagged entry. |
| litellm/litellm_core_utils/litellm_logging.py | Adds flagged status normalization and ranks it between successful and failed/intervened outcomes. |
| litellm/proxy/guardrails/usage_tracking.py | Maps the new status into the existing flagged metrics bucket. |
| litellm/proxy/guardrails/usage_endpoints.py | Selects the most severe phase result and exposes flagged actions in request logs. |
| litellm/types/utils.py | Extends the shared guardrail-status contract with guardrail_flagged. |
| ui/litellm-dashboard/src/components/view_logs/GuardrailViewer/GuardrailViewer.tsx | Adds flagged entry, timeline, and aggregate presentation with warning styling. |
| ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/LogDetailContent.tsx | Makes the guardrail jump link distinguish passed, flagged, and failed aggregate outcomes. |
Reviews (1): Last reviewed commit: "feat(guardrails): add non-blocking flag(..." | Re-trigger Greptile
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
…erdict Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
TLDR
Problem this solves:
allow(),block()ormodify()How it solves it:
flag(reason, metadata={})primitive, mirrorsblock(reason)guardrail_flaggedentry is loggedinput_type(request/response), reason, metadataflagged, dashboard renders FLAGGEDUser Flow
Before: a proxy admin writes an audit-only custom code guardrail and every matching request fails with a 500
mode: [pre_call, post_call]whose code returnsflag("banana mentioned", metadata={"category": "fruit"})when the text mentions a banana"Say the word banana and nothing else."HTTP 500withCustom code guardrail execution failed: name 'flag' is not defined"stream": true, and same when only the model's answer contains the word (post-call)After: the same request succeeds and the guardrail hit is recorded as flagged, not blocked
"Say the word banana and nothing else."HTTP 200and the model's normal answerbanana, streaming and non-streaming alike, pre-call and post-call alike"action": "flagged"and a reason of{'action': 'flag', 'reason': 'banana mentioned', 'metadata': {...}, 'input_type': 'request'}(or'response'for the post-call hit);?action=flaggedfilters to those rows and?action=blockedreturns nonetotalBlocked: 0andfailRate: 0.0; the flagged hits are counted in the guardrail's flagged bucket, not as blocksN PassedplusM FlaggedRelevant issues
Linear ticket
Resolves LIT-6894
Pre-Submission checklist
Please complete all items before asking a LiteLLM maintainer to review your PR
uv run pytest tests/test_litellm/<your_test_file>.py -v. Leave the suites (make test-unit-*,make test-unit) to CI: it finishes in ~15 minutes where a laptop takes an hour or more@greptileaito re-request a review after pushing changes)Delays in PR merge?
If you're seeing a delay in your PR being merged, ping the LiteLLM Team on Slack (#pr-review).
Screenshots / Proof of Fix
Shared setup: Postgres on localhost:5432, proxy started with
PYTHONPATH=<checkout> python litellm/proxy/proxy_cli.py --config config.yaml --port 4000 --use_v2_migration_resolver, real OpenAI calls togpt-5.4-mini. The Before arm ran from a worktree checked out at the merge base, the After arm from this branch. All four fixture files are byte-identical across arms (sha256842a6cc2…,7aada5ef…,62dd7b9e…,f69725e6…). The served build is identified by what the code itself emits: the Before build has noflagsymbol and says so in its error, the After build accepts it.Fixtures (
fx/*.json):Before (c8635ec)
Control: allow() path still works
curl -s -w "\nHTTP %{http_code}\n" http://localhost:4000/v1/chat/completions -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d @fx/control_allow.jsonPre-call flag (non-streaming)
curl -s -w "\nHTTP %{http_code}\n" http://localhost:4000/v1/chat/completions -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d @fx/pre_flag.jsonPost-call flag (non-streaming)
curl -s -w "\nHTTP %{http_code}\n" http://localhost:4000/v1/chat/completions -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d @fx/post_flag.jsonPre-call flag (streaming)
curl -s -w "\nHTTP %{http_code}\n" http://localhost:4000/v1/chat/completions -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d @fx/stream_flag.jsonRequest Logs and Monitor
curl -s 'http://localhost:4000/guardrails/usage/logs?page_size=10' -H 'Authorization: Bearer sk-1234'{"logs":[],"total":0,"page":1,"page_size":10}(the flagged requests never completed, so there is nothing to show)curl -s http://localhost:4000/guardrails/usage/overview -H 'Authorization: Bearer sk-1234'{"rows":[{"id":"42ff3b45-...","name":"audit-flag",...,"requestsEvaluated":6,"failRate":0.0,...}],"chart":[{"date":"2026-09-04","passed":3,"blocked":0}],"totalRequests":6,"totalBlocked":0,"passRate":100.0,...}(only the control requests are counted)After (61ed126)
Control: allow() path still works
curl -s -w "\nHTTP %{http_code}\n" http://localhost:4000/v1/chat/completions -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d @fx/control_allow.jsonPre-call flag (non-streaming)
curl -s -w "\nHTTP %{http_code}\n" http://localhost:4000/v1/chat/completions -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d @fx/pre_flag.jsonPost-call flag (non-streaming)
curl -s -w "\nHTTP %{http_code}\n" http://localhost:4000/v1/chat/completions -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d @fx/post_flag.jsonPre-call flag (streaming)
curl -s -w "\nHTTP %{http_code}\n" http://localhost:4000/v1/chat/completions -H 'Authorization: Bearer sk-1234' -H 'Content-Type: application/json' -d @fx/stream_flag.jsonRequest Logs and Monitor
curl -s 'http://localhost:4000/guardrails/usage/logs?guardrail_id=42ff3b45-2383-5582-b316-dfb10649f43a&page_size=4' -H 'Authorization: Bearer sk-1234' | python3 -m json.tool{ "logs": [ { "id": "chatcmpl-EKKgY3upQ9TqWlHbhdJyydQiiGP76", "timestamp": "2026-09-04T09:39:41.426000+00:00", "action": "flagged", "model": "openai/gpt-5.4-mini", "input_snippet": "Say the word banana and nothing else.", "reason": "{'action': 'flag', 'reason': 'banana mentioned', 'metadata': {'phase': 'request', 'category': 'fruit'}, 'input_type': 'request'}" }, { "id": "chatcmpl-EKKgXUTfcZIY4VO9Nf0dYpihvKhRb", "timestamp": "2026-09-04T09:39:40.961000+00:00", "action": "flagged", "model": "openai/gpt-5.4-mini", "input_snippet": "What yellow curved fruit do monkeys famously eat? Answer with one lowercase word.", "reason": "{'action': 'flag', 'reason': 'banana mentioned', 'metadata': {'phase': 'response', 'category': 'fruit'}, 'input_type': 'response'}" }, { "id": "chatcmpl-EKKgWcZg2wv7MiCRalkc06RFy4yxA", "timestamp": "2026-09-04T09:39:40.017000+00:00", "action": "flagged", "model": "openai/gpt-5.4-mini", "input_snippet": "Say the word banana and nothing else.", "reason": "{'action': 'flag', 'reason': 'banana mentioned', 'metadata': {'phase': 'request', 'category': 'fruit'}, 'input_type': 'request'}" }, { "id": "chatcmpl-EKKgV8Ue20LrSlvGJjNVO5cuPy3bk", "timestamp": "2026-09-04T09:39:39.484000+00:00", "action": "passed", "model": "openai/gpt-5.4-mini", "input_snippet": "Say the word apple and nothing else.", "reason": "allow" } ], "total": 16, "page": 1, "page_size": 4 }curl -s '.../guardrails/usage/logs?guardrail_id=42ff3b45-...&action=flagged&page_size=4' -H 'Authorization: Bearer sk-1234'returns three rows, all"action": "flagged";...&action=blocked...returns"logs":[]curl -s http://localhost:4000/guardrails/usage/overview -H 'Authorization: Bearer sk-1234'{"rows":[{"id":"42ff3b45-...","name":"audit-flag",...,"requestsEvaluated":30,"failRate":0.0,"status":"healthy",...}],"chart":[{"date":"2026-09-04","passed":12,"blocked":0}],"totalRequests":30,"totalBlocked":0,"passRate":100.0,...}psql "$DATABASE_URL" -c 'select guardrail_id, date, requests_evaluated, passed_count, blocked_count, flagged_count from "LiteLLM_DailyGuardrailMetrics";'Dashboard (Request Logs detail)
Steps for a reviewer to see the UI change: run
npm run devinui/litellm-dashboard, open http://localhost:3000/ui/?page=logs, click one of thebananarequests above, scroll to the Guardrails section. Theaudit-flagcard shows an amber FLAGGED badge (previously red FAILED, because anything that was notsuccessfell into FAILED), the summary pill reads1 Passedplus1 Flaggedfor the post-call request (pre-call allow, post-call flag), and the request lifecycle timeline marks the flagged phase with the amber icon. Covered by the unit tests inGuardrailViewer.test.tsxandLogDetailContent.test.tsx; the custom code editor at http://localhost:3000/ui/?page=guardrails now listsflag(reason, metadata={})under Return Values.Type
🆕 New Feature
Caveats (if any)
Low
chartkeeps its existingpassed/blockedshape; flagged counts live inflagged_count(as they already did for guardrails that failed to respond)pre_callandpost_call, Request Logs shows the most severe of the two phase resultsflag(reason, metadata={})mirrorsblock(reason, detection_info={}); the alternative wasallow(flag=...), rejected as it hides the verdictFinal Attestation
Link to Devin session: https://app.devin.ai/sessions/1b006929b7a3436797ef22a7ce2e3ef4
Open in Devin Desktop: https://app.devin.ai/desktop/session/1b006929b7a3436797ef22a7ce2e3ef4?variant=devin
Requested by: @yassin-berriai