Skip to content
Merged
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
44 changes: 26 additions & 18 deletions backend/app/api/docs/guardrails_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from typing import Any, NamedTuple

from app.api.openapi_docs import ResponseDoc, ref
from app.services.llm.guardrails import EXPOSED_VALIDATOR_TYPES

_UUID_EXAMPLE = "0b9f1a4c-2d3e-4f50-8a6b-7c8d9e0f1a2b"
_TIMESTAMP_EXAMPLE = "2026-10-14T09:30:00"
Expand Down Expand Up @@ -231,8 +232,8 @@ def _optional(fields: dict[str, Any]) -> dict[str, Any]:
"type": "object",
"description": (
"Extra keys are the validator's tuning config — e.g. "
"`entity_types` for `pii_remover`, `slur_severity` for "
"`content_safety`. Call GET /api/v1/guardrails for each type's "
"`entity_types` for `pii_remover`, `severity` for "
"`uli_slur_match`. Call GET /api/v1/guardrails for each type's "
"accepted keys. `organization_id`/`project_id` must not be sent: "
"the guardrails service derives the tenant from the authenticated "
"context and rejects those keys with a 422."
Expand Down Expand Up @@ -354,12 +355,11 @@ class ValidatorGroup(NamedTuple):
types: tuple[str, ...]


# Three questions a reader actually arrives with, rather than one flat list of
# twelve. Each group leads with the type to reach for first.
# Rendering keeps only EXPOSED_VALIDATOR_TYPES.
GROUPS: tuple[ValidatorGroup, ...] = (
ValidatorGroup(
"Abuse, profanity and unsafe content",
"Start with `content_safety`. The rest are narrower checks to add "
"Start with `uli_slur_match`. The rest are narrower checks to add "
"when it is not enough on its own.",
(
"content_safety",
Expand All @@ -372,13 +372,13 @@ class ValidatorGroup(NamedTuple):
),
ValidatorGroup(
"Personal data and banned words",
"Both redact rather than block, so the conversation carries on with "
"These redact rather than block, so the conversation carries on with "
"the sensitive words removed.",
("pii_remover", "ban_list"),
),
ValidatorGroup(
"Staying on topic, and answer quality",
"All four call an LLM, so each adds a little latency per message. "
"These call an LLM, so each adds a little latency per message. "
"Reach for `topic_relevance_llm` first.",
(
"topic_relevance_llm",
Expand Down Expand Up @@ -859,9 +859,14 @@ def _by_type() -> dict[str, ValidatorType]:


def _ordered_types() -> list[ValidatorType]:
"""Catalogue in reading order: grouped, groups in `GROUPS` order."""
"""Exposed types in `GROUPS` order."""
catalogued = _by_type()
return [catalogued[name] for group in GROUPS for name in group.types]
return [
catalogued[name]
for group in GROUPS
for name in group.types
if name in EXPOSED_VALIDATOR_TYPES
]


def _render_validator(validator: ValidatorType) -> list[str]:
Expand Down Expand Up @@ -900,13 +905,16 @@ def render_validator_reference() -> str:
"",
"## The validator types",
"",
"Twelve of them, grouped by the problem they solve. Every one also "
"takes the shared fields above.",
"Grouped by the problem they solve. Every one also takes the shared "
"fields above.",
]
catalogued = _by_type()
for group in GROUPS:
exposed = [name for name in group.types if name in EXPOSED_VALIDATOR_TYPES]
if not exposed:
continue
lines += ["", "<br>", "", f"### {group.title}", "", group.blurb]
for name in group.types:
for name in exposed:
lines += ["", "---", ""]
lines += _render_validator(catalogued[name])
return "\n".join(lines) + "\n"
Expand Down Expand Up @@ -993,15 +1001,15 @@ def _validator_catalogue_example() -> dict[str, Any]:
"entity_types": ["EMAIL_ADDRESS", "PHONE_NUMBER", "IN_AADHAAR"],
},
),
"content_safety": (
"Slur redaction plus profanity check",
"uli_slur_match": (
"Slur redaction",
{
"name": "Content Safety Input",
"type": "content_safety",
"name": "Slur Match Input",
"type": "uli_slur_match",
"stage": "input",
"on_fail_action": "fix",
"slur_severity": "all",
"profanity_threshold": 0.9,
"languages": ["en", "hi"],
"severity": "all",
},
),
"ban_list": (
Expand Down
9 changes: 6 additions & 3 deletions backend/app/api/routes/guardrails.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@
GuardrailsRequest,
)
from app.services.guardrails.jobs import start_job
from app.services.llm.guardrails import proxy_guardrails_request
from app.services.llm.guardrails import (
filter_exposed_validator_types,
proxy_guardrails_request,
)
from app.utils import APIResponse, load_description, validate_callback_url

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -180,14 +183,14 @@ def _upstream_response(status_code: int, payload: Any) -> Response:
**doc(responses={200: VALIDATOR_TYPE_LIST_OK, **PROXY_READ_RESPONSES}),
)
def list_guardrails_validator_types(_current_user: AuthContextDep) -> Response:
"""List the validator types supported upstream and their JSON schemas."""
"""List the exposed validator types and their JSON schemas."""
status_code, payload = proxy_guardrails_request(
"GET",
"/",
organization_id=_current_user.organization_.id,
project_id=_current_user.project_.id,
)
return _upstream_response(status_code, payload)
return _upstream_response(status_code, filter_exposed_validator_types(payload))


@router.post(
Expand Down
20 changes: 20 additions & 0 deletions backend/app/services/llm/guardrails.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,26 @@

GUARDRAILS_PROXY_TIMEOUT_SECONDS = 30.0

# Upstream supports more; only these are productised for tenants.
EXPOSED_VALIDATOR_TYPES = frozenset(
{"topic_relevance_llm", "uli_slur_match", "gender_assumption_bias", "pii_remover"}
)


def filter_exposed_validator_types(payload: Any) -> Any:
"""Keep only exposed types; non-catalogue payloads (errors, 204) pass through."""
if not isinstance(payload, dict) or not isinstance(payload.get("validators"), list):
return payload
return {
**payload,
"validators": [
validator
for validator in payload["validators"]
if isinstance(validator, dict)
and validator.get("type") in EXPOSED_VALIDATOR_TYPES
],
}


def _guardrails_headers(
organization_id: int | None, project_id: int | None
Expand Down
18 changes: 18 additions & 0 deletions backend/app/tests/api/routes/test_guardrails.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from app.core.config import settings
from app.crud import JobCrud
from app.models import Job, JobStatus, JobType, JobUpdate
from app.services.llm.guardrails import EXPOSED_VALIDATOR_TYPES
from app.tests.utils.auth import TestAuthContext


Expand Down Expand Up @@ -331,6 +332,23 @@ def test_upstream_status_and_body_echoed(
assert resp.status_code == status_code
assert resp.json() == body

def test_list_validator_types_hides_non_exposed_types(
self, client: TestClient, user_api_key_header: dict[str, str]
) -> None:
exposed = sorted(EXPOSED_VALIDATOR_TYPES)
upstream_types = [*exposed, "ban_list"]
body = {
"validators": [
{"type": validator_type, "config": {}}
for validator_type in upstream_types
]
}
with _mock_upstream(status_code=200, json_body=body):
resp = client.get("api/v1/guardrails", headers=user_api_key_header)

assert resp.status_code == 200
assert [validator["type"] for validator in resp.json()["validators"]] == exposed

def test_empty_upstream_body_returns_status_with_no_body(
self, client: TestClient, user_api_key_header: dict[str, str]
) -> None:
Expand Down
4 changes: 2 additions & 2 deletions docs/wiki/modules/guardrails.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ Opt-in flag on `LLMCallRequest`, default `False`. Gated on `outcome.applied`, so

Thin pass-throughs over the upstream management API, all under `require_permission(Permission.REQUIRE_PROJECT)`:

- `GET /guardrails` — validator catalogue
- `GET /guardrails` — validator catalogue, filtered to `EXPOSED_VALIDATOR_TYPES` (`services/llm/guardrails.py`; v1.0: `topic_relevance_llm`, `uli_slur_match`, `gender_assumption_bias`, `pii_remover`). Only the list hides types — validator-config writes still forward any type upstream accepts.
- `/guardrails/ban_lists` — POST, GET (`domain`, `offset`, `limit`); `/{id}` GET/PATCH/DELETE
- `/guardrails/llm_prompt_configs` — POST, GET (`validator_name`, `offset`, `limit`); `/{id}` GET/PATCH/DELETE
- `/guardrails/validators/configs` — POST, GET (`ids`, `stage`, `type`); `/{id}` GET/PATCH/DELETE
Expand All @@ -113,7 +113,7 @@ These do **not** fail open: upstream status codes and bodies (including its 422s

**Bodies are not validated here.** Each write route takes a `ProxyBody` (`Annotated[Any, Body(...)]`) and forwards it verbatim; the guardrails service owns validation. Mirroring its schemas as Pydantic models was tried and abandoned: the mirror rejected payloads upstream accepts as soon as the two drifted — a `content_safety` validator type added upstream was a gateway 422 while the enum here lagged behind.

`GET /guardrails` carries a third thing: `VALIDATOR_TYPES` in `api/docs/guardrails_schemas.py`, a catalogue of all 12 validator types with their tuning keys, defaults and gotchas. It renders *both* the example response and the per-type reference appended to the route description (`render_validator_reference()`), so those two cannot disagree with each other. They can still fall behind the guardrails service — that is the one piece of this module that needs re-checking when upstream adds a validator or a key. Re-verify by diffing against the live schemas:
`GET /guardrails` carries a third thing: `VALIDATOR_TYPES` in `api/docs/guardrails_schemas.py`, a catalogue of all 12 validator types with their tuning keys, defaults and gotchas. It renders *both* the example response and the per-type reference appended to the route description (`render_validator_reference()`), so those two cannot disagree with each other. Both render only `EXPOSED_VALIDATOR_TYPES`; the catalogue itself stays complete so exposing a type is a one-line change. They can still fall behind the guardrails service — that is the one piece of this module that needs re-checking when upstream adds a validator or a key. Re-verify by diffing against the live schemas:

```python
# in kaapi-guardrails
Expand Down
Loading