Skip to content

feat(guardrails): add non-blocking flag() verdict to custom code guardrails - #39728

Merged
yassin-berriai merged 2 commits into
litellm_internal_stagingfrom
litellm_custom_code_flag_verdict
Sep 5, 2026
Merged

feat(guardrails): add non-blocking flag() verdict to custom code guardrails#39728
yassin-berriai merged 2 commits into
litellm_internal_stagingfrom
litellm_custom_code_flag_verdict

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Custom code guardrails can only allow(), block() or modify()
  • No way to let a request through while recording a violation
  • Audit-only or monitor-mode guardrails today have to pick between silence and a 400

How it solves it:

  • New flag(reason, metadata={}) primitive, mirrors block(reason)
  • Content passes through unchanged, a guardrail_flagged entry is logged
  • Entry carries guardrail name, configured mode, input_type (request/response), reason, metadata
  • Monitor counts it as flagged, Request Logs shows flagged, dashboard renders FLAGGED

User Flow

Before: a proxy admin writes an audit-only custom code guardrail and every matching request fails with a 500

  1. They create a custom code guardrail on mode: [pre_call, post_call] whose code returns flag("banana mentioned", metadata={"category": "fruit"}) when the text mentions a banana
  2. A developer sends POST http://localhost:4000/v1/chat/completions with "Say the word banana and nothing else."
  3. They get HTTP 500 with Custom code guardrail execution failed: name 'flag' is not defined
  4. Same with "stream": true, and same when only the model's answer contains the word (post-call)
  5. http://localhost:4000/guardrails/usage/logs never shows the request because it never completed

After: the same request succeeds and the guardrail hit is recorded as flagged, not blocked

  1. They create the same custom code guardrail with the same code
  2. The developer sends the same POST http://localhost:4000/v1/chat/completions with "Say the word banana and nothing else."
  3. They get HTTP 200 and the model's normal answer banana, streaming and non-streaming alike, pre-call and post-call alike
  4. http://localhost:4000/guardrails/usage/logs?guardrail_id=... lists the request with "action": "flagged" and a reason of {'action': 'flag', 'reason': 'banana mentioned', 'metadata': {...}, 'input_type': 'request'} (or 'response' for the post-call hit); ?action=flagged filters to those rows and ?action=blocked returns none
  5. http://localhost:4000/guardrails/usage/overview keeps totalBlocked: 0 and failRate: 0.0; the flagged hits are counted in the guardrail's flagged bucket, not as blocks
  6. In http://localhost:4000/ui/?page=logs the request's guardrail card reads FLAGGED in amber instead of FAILED in red, and the summary shows N Passed plus M Flagged

Relevant issues

Linear ticket

Resolves LIT-6894

Pre-Submission checklist

Please complete all items before asking a LiteLLM maintainer to review your PR

  • I have added meaningful tests
  • The handful of test files covering my change pass locally, e.g. 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
  • My PR passes all required CI/CD checks (e.g., lint, schema.d.ts sync check, etc.)
  • My PR's scope is as isolated as possible; it only solves 1 specific problem
  • I have received a Greptile Confidence Score of at least 4/5 before requesting a maintainer review (Greptile reviews automatically once the PR is opened; only comment @greptileai to 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 to gpt-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 (sha256 842a6cc2…, 7aada5ef…, 62dd7b9e…, f69725e6…). The served build is identified by what the code itself emits: the Before build has no flag symbol and says so in its error, the After build accepts it.

model_list:
  - model_name: gpt-5.4-mini
    litellm_params:
      model: openai/gpt-5.4-mini
      api_key: os.environ/OPENAI_API_KEY

guardrails:
  - guardrail_name: audit-flag
    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(lower(text), "banana"):
                    return flag("banana mentioned", metadata={"category": "fruit", "phase": input_type})
            return allow()

general_settings:
  master_key: sk-1234
  database_url: os.environ/DATABASE_URL

Fixtures (fx/*.json):

// control_allow.json
{"model": "gpt-5.4-mini", "messages": [{"role": "user", "content": "Say the word apple and nothing else."}], "max_tokens": 20}
// pre_flag.json
{"model": "gpt-5.4-mini", "messages": [{"role": "user", "content": "Say the word banana and nothing else."}], "max_tokens": 20}
// post_flag.json (the prompt is clean, only the model's answer trips the guardrail)
{"model": "gpt-5.4-mini", "messages": [{"role": "user", "content": "What yellow curved fruit do monkeys famously eat? Answer with one lowercase word."}], "max_tokens": 20}
// stream_flag.json
{"model": "gpt-5.4-mini", "messages": [{"role": "user", "content": "Say the word banana and nothing else."}], "max_tokens": 20, "stream": true}

Before (c8635ec)

Control: allow() path still works

  1. 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.json
  2. Output:
{"id":"chatcmpl-EKKXSNPbUrvMclmLsmBHfWtJV87xE","created":1788514218,"model":"gpt-5.4-mini","object":"chat.completion","choices":[{"finish_reason":"stop","index":0,"message":{"content":"apple","role":"assistant",...}}],"usage":{"completion_tokens":4,"prompt_tokens":14,"total_tokens":18,...}}
HTTP 200

Pre-call flag (non-streaming)

  1. 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.json
  2. Output:
{"error":{"message":"Custom code guardrail execution failed: name 'flag' is not defined","type":"internal_server_error","param":null,"code":"500"}}
HTTP 500

Post-call flag (non-streaming)

  1. 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.json
  2. Output:
{"error":{"message":"Custom code guardrail execution failed: name 'flag' is not defined","type":"internal_server_error","param":null,"code":"500"}}
HTTP 500

Pre-call flag (streaming)

  1. 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.json
  2. Output:
{"error":{"message":"Custom code guardrail execution failed: name 'flag' is not defined","type":"internal_server_error","param":null,"code":"500"}}
HTTP 500

Request Logs and Monitor

  1. curl -s 'http://localhost:4000/guardrails/usage/logs?page_size=10' -H 'Authorization: Bearer sk-1234'
  2. Output: {"logs":[],"total":0,"page":1,"page_size":10} (the flagged requests never completed, so there is nothing to show)
  3. curl -s http://localhost:4000/guardrails/usage/overview -H 'Authorization: Bearer sk-1234'
  4. Output: {"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

  1. 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.json
  2. Output:
{"id":"chatcmpl-EKKgV8Ue20LrSlvGJjNVO5cuPy3bk","created":1788514779,"model":"gpt-5.4-mini","object":"chat.completion","choices":[{"finish_reason":"stop","index":0,"message":{"content":"apple","role":"assistant",...}}],"usage":{"completion_tokens":4,"prompt_tokens":14,"total_tokens":18,...}}
HTTP 200

Pre-call flag (non-streaming)

  1. 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.json
  2. Output (request went through untouched, model answered normally):
{"id":"chatcmpl-EKKgWcZg2wv7MiCRalkc06RFy4yxA","created":1788514780,"model":"gpt-5.4-mini","object":"chat.completion","choices":[{"finish_reason":"stop","index":0,"message":{"content":"banana","role":"assistant",...}}],"usage":{"completion_tokens":4,"prompt_tokens":14,"total_tokens":18,...}}
HTTP 200

Post-call flag (non-streaming)

  1. 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.json
  2. Output (pre-call allowed the clean prompt, post-call flagged the answer, response unchanged):
{"id":"chatcmpl-EKKgXUTfcZIY4VO9Nf0dYpihvKhRb","created":1788514781,"model":"gpt-5.4-mini","object":"chat.completion","choices":[{"finish_reason":"stop","index":0,"message":{"content":"banana","role":"assistant",...}}],"usage":{"completion_tokens":4,"prompt_tokens":21,"total_tokens":25,...}}
HTTP 200

Pre-call flag (streaming)

  1. 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.json
  2. Output:
data: {"id":"chatcmpl-EKKgY3upQ9TqWlHbhdJyydQiiGP76","object":"chat.completion.chunk","created":1788514782,"model":"gpt-5.4-mini","choices":[{"index":0,"delta":{"role":"assistant","content":""}}]}

data: {"id":"chatcmpl-EKKgY3upQ9TqWlHbhdJyydQiiGP76","object":"chat.completion.chunk","created":1788514782,"model":"gpt-5.4-mini","choices":[{"index":0,"delta":{"content":"banana"}}]}

data: {"id":"chatcmpl-EKKgY3upQ9TqWlHbhdJyydQiiGP76","object":"chat.completion.chunk","created":1788514782,"model":"gpt-5.4-mini","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}

data: [DONE]

HTTP 200

Request Logs and Monitor

  1. 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
  2. Output (newest first: streaming pre-call, post-call, pre-call, control):
{
    "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
}
  1. 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":[]
  2. curl -s http://localhost:4000/guardrails/usage/overview -H 'Authorization: Bearer sk-1234'
  3. Output: {"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,...}
  4. psql "$DATABASE_URL" -c 'select guardrail_id, date, requests_evaluated, passed_count, blocked_count, flagged_count from "LiteLLM_DailyGuardrailMetrics";'
 guardrail_id |    date    | requests_evaluated | passed_count | blocked_count | flagged_count
--------------+------------+--------------------+--------------+---------------+---------------
 audit-flag   | 2026-09-04 |                 30 |           12 |             0 |            18

Dashboard (Request Logs detail)

Steps for a reviewer to see the UI change: run npm run dev in ui/litellm-dashboard, open http://localhost:3000/ui/?page=logs, click one of the banana requests above, scroll to the Guardrails section. The audit-flag card shows an amber FLAGGED badge (previously red FAILED, because anything that was not success fell into FAILED), the summary pill reads 1 Passed plus 1 Flagged for 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 in GuardrailViewer.test.tsx and LogDetailContent.test.tsx; the custom code editor at http://localhost:3000/ui/?page=guardrails now lists flag(reason, metadata={}) under Return Values.

Type

🆕 New Feature

Caveats (if any)

Low

  • Monitor overview chart keeps its existing passed/blocked shape; flagged counts live in flagged_count (as they already did for guardrails that failed to respond)
  • When a guardrail runs on both pre_call and post_call, Request Logs shows the most severe of the two phase results
  • API choice: flag(reason, metadata={}) mirrors block(reason, detection_info={}); the alternative was allow(flag=...), rejected as it hides the verdict
  • Docs for the new primitive: docs(guardrails): document the flag() verdict for custom code guardrails litellm-docs#1190

Final Attestation

  • The tests check the right things, including the edge cases, and regressions in the respective real-world customer use-cases are not possible after this PR

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

…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-integration

Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

@greptileai please review

@codspeed-hq

codspeed-hq Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_custom_code_flag_verdict (ecc0b19) with litellm_internal_staging (7672399)1

Open in CodSpeed

Footnotes

  1. No successful run was found on litellm_internal_staging (e670551) during the generation of this report, so 14b95e4 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@greptile-apps

greptile-apps Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a non-blocking flag() verdict for custom-code guardrails and carries that verdict through standard logging, guardrail usage metrics, request-log APIs, and dashboard presentation.

  • Records flagged evaluations without modifying or rejecting content.
  • Aggregates flagged outcomes between successful and intervened outcomes.
  • Exposes flagged actions in usage logs and daily metrics.
  • Adds amber flagged states to guardrail log details and lifecycle displays.
  • Adds focused backend and dashboard tests for the new behavior.

Confidence Score: 5/5

The 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.

Important Files Changed

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

codecov Bot commented Sep 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@yassin-berriai
yassin-berriai enabled auto-merge (squash) September 5, 2026 18:38
…erdict

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@devin-ai-integration
devin-ai-integration Bot requested a review from a team September 5, 2026 18:41
@yassin-berriai
yassin-berriai merged commit 5df0e12 into litellm_internal_staging Sep 5, 2026
188 checks passed
@yassin-berriai
yassin-berriai deleted the litellm_custom_code_flag_verdict branch September 5, 2026 20:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants