Skip to content

fix(guardrails): serve config guardrails from list and info endpoints without a DB and make their ids stable - #35259

Merged
mateo-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_config_guardrail_info_lookup
Jul 31, 2026
Merged

fix(guardrails): serve config guardrails from list and info endpoints without a DB and make their ids stable#35259
mateo-berri merged 1 commit into
litellm_internal_stagingfrom
litellm_config_guardrail_info_lookup

Conversation

@mateo-berri

@mateo-berri mateo-berri commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

TLDR

Problem this solves:

  • Config guardrails show "Guardrail not found" in the Admin UI
  • No-DB proxies: v2 list and info raise 500 before the config fallback
  • v1 list (the UI fallback) never returns guardrail_id
  • Config guardrail ids are random per boot, so restarts and replicas 404

How it solves it:

  • List and info consult the in-memory registry even without prisma
  • v1 list response now carries guardrail_id
  • Config guardrail ids are uuid5(name), stable across boots and replicas

Relevant issues

Fixes #35256

Linear ticket

Pre-Submission checklist

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

  • I have added meaningful tests
  • My PR passes all CI/CD checks (e.g., lint, format, unit tests)
  • 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

All legs use this config (guardrail defined only in config.yaml, no explicit guardrail_id, which is the normal case):

model_list:
  - model_name: gpt-4o-mini
    litellm_params:
      model: openai/gpt-4o-mini
      api_key: os.environ/OPENAI_API_KEY
guardrails:
  - guardrail_name: "tooling"
    litellm_params:
      guardrail: litellm_content_filter
      mode: pre_call
      default_on: false
      blocked_words:
        - keyword: "FORBIDDENWORD"
          action: BLOCK
general_settings:
  master_key: sk-1234

The no-DB legs boot the proxy through a wrapper that strips DATABASE_URL after dotenv loads (the repo .env would otherwise attach a DB):

import os, sys, dotenv
_orig = dotenv.load_dotenv
def _no_db(*a, **k):
    r = _orig(*a, **k)
    os.environ.pop("DATABASE_URL", None)
    return r
dotenv.load_dotenv = _no_db
os.environ.pop("DATABASE_URL", None)
from litellm.proxy.proxy_cli import run_server
run_server(["--config", sys.argv[1], "--port", sys.argv[2], "--detailed_debug"], standalone_mode=True)

Before (base ae242fd, no DB, port 51377)

$ curl -s -w "\nHTTP %{http_code}\n" http://localhost:51377/v2/guardrails/list -H "Authorization: Bearer sk-1234"
{"detail":"Prisma client not initialized"}
HTTP 500

$ curl -s http://localhost:51377/guardrails/list -H "Authorization: Bearer sk-1234" | jq '[.guardrails[] | {guardrail_name, guardrail_id}]'
[{"guardrail_name": "tooling", "guardrail_id": null}]

$ curl -s -w "\nHTTP %{http_code}\n" http://localhost:51377/guardrails/undefined/info -H "Authorization: Bearer sk-1234"
{"detail":"Prisma client not initialized"}
HTTP 500

$ curl -s -w "\nHTTP %{http_code}\n" http://localhost:51377/guardrails/25b2bc3f-de3c-4d08-bdef-f840b1c8a518/info -H "Authorization: Bearer sk-1234"
{"detail":"Prisma client not initialized"}
HTTP 500

/guardrails/undefined/info is the exact request the UI ends up making: v2 500s, the UI falls back to the v1 list, its rows have no id, and the row click fetches id undefined. The live in-memory id is unobtainable by any API consumer pre-fix, and the 500 fires before any lookup, so it is identical for every id

After (this PR's head 5ae1f15, no DB, port 51919, boot 1)

$ curl -s -w "\nHTTP %{http_code}\n" http://localhost:51919/v2/guardrails/list -H "Authorization: Bearer sk-1234"
{"guardrails":[{"guardrail_id":"d6ec73d0-a2d4-5280-b172-7659aca68334","guardrail_name":"tooling", ... "guardrail_definition_location":"config"}]}
HTTP 200

$ curl -s http://localhost:51919/guardrails/list -H "Authorization: Bearer sk-1234" | jq '[.guardrails[] | {guardrail_name, guardrail_id}]'
[{"guardrail_name": "tooling", "guardrail_id": "d6ec73d0-a2d4-5280-b172-7659aca68334"}]

$ curl -s -o /dev/null -w "HTTP %{http_code}\n" http://localhost:51919/guardrails/d6ec73d0-a2d4-5280-b172-7659aca68334/info -H "Authorization: Bearer sk-1234"
HTTP 200

$ curl -s -o /dev/null -w "HTTP %{http_code}\n" http://localhost:51919/guardrails/d6ec73d0-a2d4-5280-b172-7659aca68334 -H "Authorization: Bearer sk-1234"
HTTP 200

$ curl -s -w "\nHTTP %{http_code}\n" http://localhost:51919/guardrails/25b2bc3f-de3c-4d08-bdef-f840b1c8a518/info -H "Authorization: Bearer sk-1234"
{"detail":"Guardrail with ID 25b2bc3f-de3c-4d08-bdef-f840b1c8a518 not found"}
HTTP 404

After, restart stability (same head, no DB, port 51919, boot 2)

$ curl -s http://localhost:51919/v2/guardrails/list -H "Authorization: Bearer sk-1234" | jq '[.guardrails[] | {guardrail_name, guardrail_id}]'
[{"guardrail_name": "tooling", "guardrail_id": "d6ec73d0-a2d4-5280-b172-7659aca68334"}]

$ curl -s -o /dev/null -w "HTTP %{http_code}\n" http://localhost:51919/guardrails/d6ec73d0-a2d4-5280-b172-7659aca68334/info -H "Authorization: Bearer sk-1234"
HTTP 200

The id is byte-identical across boots, and the pre-restart id keeps resolving after the restart

After, with-DB sanity (same head, DATABASE_URL set, Postgres on localhost:5432, port 52466)

$ curl -s -X POST http://localhost:52466/guardrails -H "Authorization: Bearer sk-1234" -H "Content-Type: application/json" -d '{"guardrail": {"guardrail_name": "db-guard", "litellm_params": {"guardrail": "litellm_content_filter", "mode": "pre_call", "default_on": false, "blocked_words": [{"keyword": "DBWORD", "action": "BLOCK"}]}}}' | jq '{guardrail_id, guardrail_name}'
{"guardrail_id": "a83a46cd-e409-443c-97d6-697ba53be9b5", "guardrail_name": "db-guard"}

$ curl -s http://localhost:52466/v2/guardrails/list -H "Authorization: Bearer sk-1234" | jq '[.guardrails[] | {guardrail_name, guardrail_id, guardrail_definition_location}]'
[
  {"guardrail_name": "db-guard", "guardrail_id": "a83a46cd-e409-443c-97d6-697ba53be9b5", "guardrail_definition_location": "db"},
  {"guardrail_name": "tooling", "guardrail_id": "d6ec73d0-a2d4-5280-b172-7659aca68334", "guardrail_definition_location": "config"}
]

$ curl -s -o /dev/null -w "HTTP %{http_code}\n" http://localhost:52466/guardrails/a83a46cd-e409-443c-97d6-697ba53be9b5/info -H "Authorization: Bearer sk-1234"
HTTP 200

$ curl -s -o /dev/null -w "HTTP %{http_code}\n" http://localhost:52466/guardrails/d6ec73d0-a2d4-5280-b172-7659aca68334/info -H "Authorization: Bearer sk-1234"
HTTP 200

DB-defined guardrails keep their DB-generated ids and the list shape is unchanged; the config guardrail carries the same stable id with and without a DB

Leg Route Before ae242fd After 5ae1f15
No DB GET /v2/guardrails/list FAIL, 500 PASS, 200
No DB GET /guardrails/list ids FAIL, null PASS, id set
No DB GET /guardrails/{id}/info FAIL, 500 PASS, 200
No DB GET /guardrails/{id} FAIL, 500 PASS, 200
No DB info, unknown id FAIL, 500 PASS, 404
No DB id across restarts FAIL, changes PASS, identical
With DB v2 list, db + config PASS PASS
With DB info, both sources PASS PASS

Type

🐛 Bug Fix

Changes

litellm/proxy/guardrails/guardrail_endpoints.py: /v2/guardrails/list and GET /guardrails/{id} plus /guardrails/{id}/info no longer raise 500 when prisma is not initialized; they treat the DB as empty and serve the in-memory config guardrails that both endpoints already knew how to merge. The info endpoint 404s only when neither source has the id. _get_guardrails_list_response (the v1 list the UI falls back to) now includes guardrail_id; the ids are present on the config dicts because initialize_guardrail writes them back at startup

litellm/proxy/guardrails/guardrail_registry.py: a config guardrail without an explicit guardrail_id now gets uuid5(CONFIG_GUARDRAIL_ID_NAMESPACE, guardrail_name) instead of a fresh uuid4 per process, so the id survives restarts and matches across replicas. An explicit guardrail_id in yaml still wins

Safety analysis for the deterministic id:

Duplicate guardrail_name entries in config are legitimate today (load balancing across guardrail deployments with the same name, see _populate_router_guardrail_list). A plain name hash would make the second occurrence collide with the first and silently skip its initialization, so on collision the derivation walks deterministic seeds (name, name:1, name:2, ...) until a free id is found. Occurrences keep distinct ids that are stable across boots as long as the config keeps its order, and since the entries share a name a reorder only swaps ids between interchangeable deployments

Consumers audited: usage_tracking.py aggregates daily usage by guardrail_id, so stable ids stop the per-restart fragmentation of usage rows for config guardrails (rows written under old random ids stay under those ids, a one-time discontinuity). _init_guardrails_in_db, sync_guardrail_from_db, and reconcile_db_guardrails only handle ids read from DB rows, which are DB-generated uuid4 values, and reconciliation never touches config-sourced entries, so there is no interaction with derived ids beyond a cryptographically negligible uuid collision. The mutation endpoints (PUT, PATCH, DELETE on /guardrails/{id}) check DB existence first and keep 404ing for config ids exactly as before. The source="db" callers of initialize_guardrail always pass ids from DB rows, so the derived-id path is effectively config-only. No blocker found

QA runbook

  1. Save the config from the proof section as config.yaml and the no-DB wrapper as no_db_proxy.py
  2. Pick a random free high port, e.g. lsof -nP -iTCP:51919 -sTCP:LISTEN must print nothing
  3. Boot without a DB: python no_db_proxy.py config.yaml 51919
  4. curl -s -w "\nHTTP %{http_code}\n" http://localhost:51919/v2/guardrails/list -H "Authorization: Bearer sk-1234" and expect HTTP 200 listing tooling with a guardrail_id and guardrail_definition_location config
  5. curl -s http://localhost:51919/guardrails/list -H "Authorization: Bearer sk-1234" | jq '.guardrails[].guardrail_id' and expect the same id, not null
  6. curl -s -w "\nHTTP %{http_code}\n" http://localhost:51919/guardrails/<that id>/info -H "Authorization: Bearer sk-1234" and expect HTTP 200
  7. Restart the proxy, rerun steps 4 and 6, and expect the identical id and HTTP 200 for the pre-restart id
  8. Boot normally with DATABASE_URL set (python litellm/proxy/proxy_cli.py --config config.yaml --port 52466), rerun step 4, and expect DB guardrails listed as db alongside the config guardrail with the same id as the no-DB boots
  9. In the Admin UI guardrails page, click the tooling row and expect its info panel instead of "Guardrail not found"

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

Note

Medium Risk
Deterministic config guardrail IDs change identity across upgrades (one-time usage/analytics discontinuity) and duplicate-name ordering affects which occurrence gets which id; read-path behavior for no-DB proxies is otherwise low risk.

Overview
Fixes Admin UI "Guardrail not found" and 500s on proxies without a database by making list/info endpoints work when Prisma is unset, and by returning stable IDs for config-defined guardrails.

No-DB / read APIs: GET /v2/guardrails/list and GET /guardrails/{id}/info (and GET /guardrails/{id}) no longer fail with Prisma client not initialized. They treat the DB as empty and still merge config guardrails from the in-memory registry. Unknown IDs return 404 instead of 500. The v1 GET /guardrails/list helper now includes guardrail_id so UI fallback rows don’t request /guardrails/undefined/info.

Stable config IDs: Config guardrails without an explicit guardrail_id get a deterministic uuid5 from guardrail_name (with name:1, name:2, … for duplicate names) instead of a new uuid4 per process, so IDs survive restarts and match across replicas. Explicit YAML guardrail_id still wins.

Reviewed by Cursor Bugbot for commit 5ae1f15. Bugbot is set up for automated code reviews on this repo. Configure here.

@greptile-apps

greptile-apps Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR makes config-defined guardrails available through list and info endpoints without Prisma and assigns deterministic IDs to guardrails lacking explicit IDs.

  • Treats the database guardrail set as empty when Prisma is unavailable, allowing endpoints to fall back to the in-memory registry.
  • Includes guardrail IDs in the v1 list response.
  • Derives stable UUIDv5 identifiers from guardrail names while deterministically handling duplicate names.
  • Adds coverage for no-database endpoints, unknown IDs, explicit IDs, restart stability, and duplicate names.

Confidence Score: 5/5

The PR appears safe to merge, with no concrete changed-code failure identified.

The database-optional paths preserve existing filtering, masking, source selection, and response conversion while allowing config guardrails to resolve from memory, and deterministic ID generation retains explicit IDs and assigns distinct repeatable IDs to duplicate names.

Important Files Changed

Filename Overview
litellm/proxy/guardrails/guardrail_endpoints.py Adds safe no-database fallbacks to list and info endpoints and exposes in-memory guardrail IDs in the v1 response.
litellm/proxy/guardrails/guardrail_registry.py Replaces random implicit config guardrail IDs with deterministic UUIDv5 IDs and collision probing for duplicate names.
tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py Adds focused tests for no-Prisma list and info behavior, unknown IDs, and v1 ID propagation.
tests/test_litellm/proxy/guardrails/test_guardrail_registry.py Adds tests for restart-stable IDs, explicit-ID precedence, and deterministic duplicate-name handling.

Reviews (1): Last reviewed commit: "fix(guardrails): serve config guardrails..." | Re-trigger Greptile

@codecov

codecov Bot commented Jul 30, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@codspeed-hq

codspeed-hq Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Merging this PR will not alter performance

✅ 31 untouched benchmarks


Comparing litellm_config_guardrail_info_lookup (5ae1f15) with litellm_internal_staging (ae242fd)1

Open in CodSpeed

Footnotes

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

@yucheng-berri

Copy link
Copy Markdown
Contributor

bugbot run

@cursor cursor Bot left a comment

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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 5ae1f15. Configure here.

@mateo-berri
mateo-berri merged commit 0303700 into litellm_internal_staging Jul 31, 2026
83 checks passed
@mateo-berri
mateo-berri deleted the litellm_config_guardrail_info_lookup branch July 31, 2026 01:59
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.

[Bug]: Config-defined guardrails show "Guardrail not found" in the UI (no-DB deployments and stale per-boot guardrail ids)

2 participants