diff --git a/backend/app/api/docs/guardrails_schemas.py b/backend/app/api/docs/guardrails_schemas.py index 230ca6948..5cde9677e 100644 --- a/backend/app/api/docs/guardrails_schemas.py +++ b/backend/app/api/docs/guardrails_schemas.py @@ -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" @@ -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." @@ -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", @@ -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", @@ -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]: @@ -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 += ["", "
", "", 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" @@ -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": ( diff --git a/backend/app/api/routes/guardrails.py b/backend/app/api/routes/guardrails.py index 9d0ee55ce..4c3968e99 100644 --- a/backend/app/api/routes/guardrails.py +++ b/backend/app/api/routes/guardrails.py @@ -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__) @@ -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( diff --git a/backend/app/services/llm/guardrails.py b/backend/app/services/llm/guardrails.py index 3adcf1970..f3d8caae0 100644 --- a/backend/app/services/llm/guardrails.py +++ b/backend/app/services/llm/guardrails.py @@ -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 diff --git a/backend/app/tests/api/routes/test_guardrails.py b/backend/app/tests/api/routes/test_guardrails.py index b04a7582a..81670c1a7 100644 --- a/backend/app/tests/api/routes/test_guardrails.py +++ b/backend/app/tests/api/routes/test_guardrails.py @@ -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 @@ -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: diff --git a/docs/wiki/modules/guardrails.md b/docs/wiki/modules/guardrails.md index ca284d544..a02672288 100644 --- a/docs/wiki/modules/guardrails.md +++ b/docs/wiki/modules/guardrails.md @@ -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 @@ -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