Skip to content

Commit 6c04ed8

Browse files
authored
feat(guardrails): Support inline validator specs and drop type, tag keys from API request body (#1338)
1 parent ac13826 commit 6c04ed8

7 files changed

Lines changed: 167 additions & 56 deletions

File tree

‎backend/app/api/docs/guardrails/apply_guardrails.md‎

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,25 @@ Apply guardrails to a piece of text and deliver the sanitised result via a webho
33
This endpoint exists for callers who manage their own LLM workflow but want to
44
reuse Kaapi's guardrails service. It is symmetric for input and output
55
guardrails: send the text that needs sanitisation in `text` along with one or
6-
more `validator_config_id`s, and receive the sanitised text on your
6+
more validators in `config`, and receive the sanitised text on your
77
`callback_url`.
88

9+
Each `config` entry is either a stored validator config or an inline spec,
10+
picked by the presence of `validator_config_id`:
11+
12+
```json
13+
{
14+
"text": "...",
15+
"config": [
16+
{ "validator_config_id": "<uuid>" },
17+
{ "type": "pii_remover", "on_fail": "fix" }
18+
]
19+
}
20+
```
21+
22+
Inline `type` is one of the types listed by `GET /api/v1/guardrails`; its
23+
tuning params sit alongside it. Stored configs run before inline specs.
24+
925
### Flow
1026

1127
1. Caller POSTs `{text, config, callback_url}` to `/api/v1/guardrails`.
@@ -50,15 +66,12 @@ carries the upstream message, and `data` is `null`. If the guardrails service
5066
is unreachable the job still succeeds but the webhook carries the original
5167
text unchanged and `metadata.warnings` carries a human-readable note (e.g.
5268
`"Guardrails service was unavailable; original text was returned unchanged."`).
53-
Other warnings may surface for duplicate validator IDs, an empty validator
69+
Other warnings may surface for duplicate validators, an empty validator
5470
list, or a missing sanitised text in the upstream response.
5571

5672
### Notes
5773

58-
- `config[].type` and `config[].tag` are caller-side
59-
bookkeeping. They are not interpreted by the server but are useful for your
60-
own correlation (echoed back via `request_metadata` if you include them
61-
there).
74+
- To correlate callbacks with requests, put your keys in `request_metadata`.
6275
- For output-guardrail flows that need the original prompt paired with the
6376
LLM output, this endpoint v1 sends only `text`; pairing is not exposed.
6477
- The same webhook signing scheme as `/llm/call` is used when a webhook secret

‎backend/app/models/guardrails/__init__.py‎

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
from app.models.guardrails.request import (
2-
GuardrailValidator,
3-
GuardrailsRequest,
4-
)
1+
from app.models.guardrails.request import GuardrailsRequest
52
from app.models.guardrails.response import (
63
GuardrailsCallbackData,
74
GuardrailsCallbackResponse,
@@ -13,7 +10,6 @@
1310
)
1411

1512
__all__ = [
16-
"GuardrailValidator",
1713
"GuardrailsCallbackData",
1814
"GuardrailsCallbackResponse",
1915
"GuardrailsCallbackUsage",

‎backend/app/models/guardrails/request.py‎

Lines changed: 7 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,43 +1,16 @@
11
from typing import Any
2-
from uuid import UUID
32

43
from pydantic import HttpUrl
54
from sqlmodel import Field, SQLModel
65

7-
8-
class GuardrailValidator(SQLModel):
9-
"""Single validator reference inside a /guardrails request.
10-
11-
Only ``validator_config_id`` is meaningful to the server. ``type`` and
12-
``tag`` are caller-side bookkeeping fields: they are accepted and
13-
persisted on ``job.meta.request`` for traceability, but they are not
14-
echoed back on the webhook payload. To correlate webhooks with
15-
requests, put your correlation keys in ``request_metadata`` (which is
16-
echoed under the callback's ``metadata`` field).
17-
"""
18-
19-
validator_config_id: UUID = Field(
20-
...,
21-
description="ID of a validator configuration registered with the guardrails service.",
22-
)
23-
type: str | None = Field(
24-
default=None,
25-
description=(
26-
"Optional bookkeeping label for the caller (e.g. 'input_guardrail' "
27-
"or 'output_guardrail'). Server does not interpret this field."
28-
),
29-
)
30-
tag: str | None = Field(
31-
default=None,
32-
description="Optional caller-side tag. Server does not interpret this field.",
33-
)
6+
from app.models.llm.request import GuardrailEntry
347

358

369
class GuardrailsRequest(SQLModel):
3710
"""Request body for ``POST /api/v1/guardrails``.
3811
3912
The endpoint is symmetric for input and output guardrails: send the text
40-
that needs sanitisation in ``text`` along with the validator IDs to apply.
13+
that needs sanitisation in ``text`` along with the validators to apply.
4114
The sanitised text is delivered via the configured ``callback_url``.
4215
"""
4316

@@ -46,10 +19,13 @@ class GuardrailsRequest(SQLModel):
4619
min_length=1,
4720
description="Text to validate/sanitise. May be a user prompt or an LLM response.",
4821
)
49-
config: list[GuardrailValidator] = Field(
22+
config: list[GuardrailEntry] = Field(
5023
...,
5124
min_length=1,
52-
description="Validators to apply, identified by validator_config_id.",
25+
description=(
26+
"Validators to apply: `{validator_config_id}` for a stored config, "
27+
"or an inline spec `{type, ...params}`."
28+
),
5329
)
5430
callback_url: HttpUrl | None = Field(
5531
default=None,

‎backend/app/services/guardrails/jobs.py‎

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import logging
23
from typing import Any
34
from uuid import UUID, uuid4
@@ -19,9 +20,8 @@
1920
GuardrailsOutput,
2021
GuardrailsOutputContent,
2122
GuardrailsRequest,
22-
GuardrailValidator,
2323
)
24-
from app.models.llm.request import Validator
24+
from app.models.llm.request import InlineValidator, Validator
2525
from app.services.llm.guardrails import GuardrailsOutcome, apply_guardrails
2626
from app.utils import APIResponse, get_webhook_secret, send_callback
2727

@@ -203,26 +203,35 @@ def _update_job(job_uuid: UUID, **fields: Any) -> None:
203203

204204

205205
def _dedupe_validators(
206-
config: list[GuardrailValidator],
207-
) -> tuple[list[Validator], list[str]]:
208-
"""Drop duplicate validator IDs (preserve order) to avoid double-billing upstream.
206+
config: list[Validator | InlineValidator],
207+
) -> tuple[list[Validator | InlineValidator], list[str]]:
208+
"""Drop duplicate validators (preserve order) to avoid double-billing upstream.
209209
210+
Refs are keyed by ID, inline specs by their full canonical JSON.
210211
Returns the unique validators plus any caller-facing warnings.
211212
"""
212-
seen_ids: set[UUID] = set()
213-
validators: list[Validator] = []
213+
seen_keys: set[str] = set()
214+
validators: list[Validator | InlineValidator] = []
214215
duplicates = 0
215-
for g in config:
216-
if g.validator_config_id in seen_ids:
216+
for validator in config:
217+
# A disabled entry must not claim the key and shadow an enabled duplicate.
218+
if not validator.is_enabled:
219+
continue
220+
key = (
221+
str(validator.validator_config_id)
222+
if isinstance(validator, Validator)
223+
else json.dumps(validator.model_dump(mode="json"), sort_keys=True)
224+
)
225+
if key in seen_keys:
217226
duplicates += 1
218227
continue
219-
seen_ids.add(g.validator_config_id)
220-
validators.append(Validator(validator_config_id=g.validator_config_id))
228+
seen_keys.add(key)
229+
validators.append(validator)
221230

222231
warnings: list[str] = []
223232
if duplicates:
224233
warnings.append(
225-
f"Request contained {duplicates} duplicate validator_config_id "
234+
f"Request contained {duplicates} duplicate validator "
226235
"entries; duplicates were ignored before calling the guardrails service."
227236
)
228237
if not validators:

‎backend/app/tests/api/routes/test_guardrails.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,42 @@ def test_apply_guardrails_malformed_validator_id_422(
187187
assert resp.status_code == 422
188188

189189

190+
@pytest.mark.parametrize(
191+
"config",
192+
[
193+
[{"type": "pii_remover", "on_fail": "fix"}],
194+
[{"validator_config_id": VALIDATOR_ID}, {"type": "uli_slur_match"}],
195+
# Legacy bookkeeping fields are ignored, not rejected.
196+
[{"validator_config_id": VALIDATOR_ID, "type": "input_guardrail", "tag": "x"}],
197+
],
198+
ids=["inline", "mixed", "legacy_type_tag"],
199+
)
200+
def test_apply_guardrails_accepts_ref_and_inline_entries(
201+
client: TestClient, user_api_key_header: dict[str, str], config: list[dict]
202+
) -> None:
203+
with patch("app.api.routes.guardrails.start_job") as mock_start:
204+
mock_start.return_value = _stub_job()
205+
resp = client.post(
206+
"api/v1/guardrails",
207+
json=_payload(config=config),
208+
headers=user_api_key_header,
209+
)
210+
211+
assert resp.status_code == 200
212+
mock_start.assert_called_once()
213+
214+
215+
def test_apply_guardrails_unknown_inline_type_422(
216+
client: TestClient, user_api_key_header: dict[str, str]
217+
) -> None:
218+
resp = client.post(
219+
"api/v1/guardrails",
220+
json=_payload(config=[{"type": "not_a_validator"}]),
221+
headers=user_api_key_header,
222+
)
223+
assert resp.status_code == 422
224+
225+
190226
def test_apply_guardrails_requires_auth(client: TestClient) -> None:
191227
resp = client.post("api/v1/guardrails", json=_payload())
192228
assert resp.status_code in (401, 403)

‎backend/app/tests/services/guardrails/test_jobs.py‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from app.models import Job, JobStatus, JobType
1010
from app.models.guardrails import GuardrailsRequest
11+
from app.models.llm.request import InlineValidator, Validator
1112
from app.services.guardrails.jobs import (
1213
_build_callback_payload,
1314
_coerce_int,
@@ -301,6 +302,85 @@ def test_execute_job_dedupes_duplicate_validators_and_warns(
301302
assert any("duplicate" in w for w in warnings)
302303

303304

305+
def test_execute_job_passes_inline_specs_and_dedupes_them(
306+
user_api_key: TestAuthContext,
307+
) -> None:
308+
pii = {"type": "pii_remover", "on_fail": "fix"}
309+
req = GuardrailsRequest.model_validate(
310+
{
311+
"text": "My email is alice@example.com",
312+
"config": [
313+
{"validator_config_id": str(VALIDATOR_A)},
314+
pii,
315+
{"on_fail": "fix", "type": "pii_remover"},
316+
],
317+
}
318+
)
319+
320+
with _patched_worker_db() as mock_crud, patch(
321+
"app.services.guardrails.jobs.apply_guardrails"
322+
) as mock_apply:
323+
mock_apply.return_value = GuardrailsOutcome(
324+
safe_text="ok",
325+
error=None,
326+
bypassed=False,
327+
rephrase_needed=False,
328+
raw={"data": {}},
329+
)
330+
execute_job(
331+
project_id=user_api_key.project_id,
332+
job_id=str(uuid4()),
333+
task_id="t",
334+
task_instance=None,
335+
request_data=req.model_dump(mode="json"),
336+
organization_id=user_api_key.organization_id,
337+
)
338+
339+
ref, inline = mock_apply.call_args.kwargs["validators"]
340+
assert isinstance(ref, Validator) and ref.validator_config_id == VALIDATOR_A
341+
assert isinstance(inline, InlineValidator)
342+
assert inline.model_dump(mode="json", exclude={"is_enabled"}) == pii
343+
344+
warnings = _update_calls(mock_crud)[-1].meta["callback"]["warnings"]
345+
assert any("duplicate" in w for w in warnings)
346+
347+
348+
def test_execute_job_disabled_entry_does_not_shadow_enabled_duplicate(
349+
user_api_key: TestAuthContext,
350+
) -> None:
351+
req = GuardrailsRequest.model_validate(
352+
{
353+
"text": "hi",
354+
"config": [
355+
{"validator_config_id": str(VALIDATOR_A), "is_enabled": False},
356+
{"validator_config_id": str(VALIDATOR_A)},
357+
],
358+
}
359+
)
360+
361+
with _patched_worker_db(), patch(
362+
"app.services.guardrails.jobs.apply_guardrails"
363+
) as mock_apply:
364+
mock_apply.return_value = GuardrailsOutcome(
365+
safe_text="hi",
366+
error=None,
367+
bypassed=False,
368+
rephrase_needed=False,
369+
raw={"data": {}},
370+
)
371+
execute_job(
372+
project_id=user_api_key.project_id,
373+
job_id=str(uuid4()),
374+
task_id="t",
375+
task_instance=None,
376+
request_data=req.model_dump(mode="json"),
377+
organization_id=user_api_key.organization_id,
378+
)
379+
380+
(validator,) = mock_apply.call_args.kwargs["validators"]
381+
assert validator.is_enabled
382+
383+
304384
def test_execute_job_bypassed_adds_warning(user_api_key: TestAuthContext) -> None:
305385
req = _make_request()
306386

‎docs/wiki/modules/guardrails.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ All paths relative to `backend/app/`. Related: [llm-call.md](llm-call.md) (guard
88

99
| Entry point | Shape | Where |
1010
|---|---|---|
11-
| `POST /guardrails` | Standalone async job. Caller supplies text + validator IDs, gets `job_id`, result via webhook or poll. For callers running their own LLM workflow. | `api/routes/guardrails.py`, `services/guardrails/jobs.py` |
11+
| `POST /guardrails` | Standalone async job. Caller supplies text + validators (stored IDs or inline specs), gets `job_id`, result via webhook or poll. For callers running their own LLM workflow. | `api/routes/guardrails.py`, `services/guardrails/jobs.py` |
1212
| `/llm/call` + `/llm/chain` + **fast evaluation** | Inline hooks around the provider call, driven by `config_blob.input_guardrails` / `output_guardrails`. | `services/llm/jobs.py` (`apply_input_guardrails`, `apply_output_guardrails`) |
1313

1414
Both funnel into `apply_guardrails()` in `services/llm/guardrails.py`. That module is the **only** place that talks HTTP to the guardrails service.
@@ -71,7 +71,8 @@ Worker (`execute_job`): PROCESSING → `_dedupe_validators` → `apply_guardrail
7171

7272
Endpoint-specific behaviour, none of which exists on the `/llm/call` path:
7373

74-
- **Dedupe.** `_dedupe_validators` drops repeated `validator_config_id`s (order-preserving) so callers are not double-billed upstream.
74+
- **Validator shape.** `GuardrailsRequest.config` is `list[GuardrailEntry]`, the same ref-or-inline union as `/llm/call` blobs. Refs run before inline specs (`apply_guardrails` concatenates `fetched + inline_specs`).
75+
- **Dedupe.** `_dedupe_validators` drops repeated entries (order-preserving) so callers are not double-billed upstream — refs keyed by `validator_config_id`, inline specs by canonical JSON.
7576
- **Warnings channel.** Bypass, dedupe, and "no validators resolved" become human-readable strings. Delivered as `metadata.warnings` in the callback and `warnings[]` on the poll response. A caller-supplied `warnings` key in `request_metadata` is overwritten (documented in the model docstring).
7677
- **`job.meta`** holds `{request, response, callback}`. Raw unsafe text is persisted on purpose — this endpoint exists to inspect unsafe content.
7778
- **`response_id` is server-minted** (`uuid4`), so callers always have a stable correlation handle even when upstream returns none.

0 commit comments

Comments
 (0)