Skip to content

Commit 6e2a28a

Browse files
Guardrails: Expose Dedicated Endpoint (#939)
Co-authored-by: Ayush <80516839+Ayush8923@users.noreply.github.com>
1 parent b7cdc29 commit 6e2a28a

20 files changed

Lines changed: 1841 additions & 107 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
"""add job.meta JSONB column and LLM_GUARDRAILS jobtype enum value
2+
3+
Revision ID: 070
4+
Revises: 069
5+
Create Date: 2026-06-15 00:00:00.000000
6+
7+
"""
8+
9+
import sqlalchemy as sa
10+
from alembic import op
11+
from sqlalchemy.dialects import postgresql
12+
13+
revision = "070"
14+
down_revision = "069"
15+
branch_labels = None
16+
depends_on = None
17+
18+
19+
def upgrade() -> None:
20+
with op.get_context().autocommit_block():
21+
op.execute("ALTER TYPE jobtype ADD VALUE IF NOT EXISTS 'LLM_GUARDRAILS'")
22+
23+
op.add_column(
24+
"job",
25+
sa.Column(
26+
"meta",
27+
postgresql.JSONB(astext_type=sa.Text()),
28+
nullable=True,
29+
comment=(
30+
"Per-job-type tracking payload. For LLM_GUARDRAILS this stores "
31+
"{'request': {...}, 'response': {...}} capturing the inbound "
32+
"guardrails request and the upstream guardrails service response."
33+
),
34+
),
35+
)
36+
37+
38+
def downgrade() -> None:
39+
op.drop_column("job", "meta")
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
Apply guardrails to a piece of text and deliver the sanitised result via a webhook callback.
2+
3+
This endpoint exists for callers who manage their own LLM workflow but want to
4+
reuse Kaapi's guardrails service. It is symmetric for input and output
5+
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
7+
`callback_url`.
8+
9+
### Flow
10+
11+
1. Caller POSTs `{text, config, callback_url}` to `/api/v1/guardrails`.
12+
2. Kaapi creates a job (`job_type=LLM_GUARDRAILS`), returns `job_id` with HTTP 200
13+
immediately.
14+
3. A Celery worker resolves the validators, calls the guardrails service, and
15+
POSTs the sanitised text (or a hard-block error) to `callback_url`.
16+
4. The full upstream guardrails response and the original request body are
17+
persisted on `job.meta` for traceability and can be inspected via
18+
`GET /api/v1/guardrails/{job_id}`.
19+
20+
### Webhook payload
21+
22+
The webhook receives a standard `APIResponse` envelope:
23+
24+
```json
25+
{
26+
"success": true,
27+
"data": {
28+
"response": {
29+
"response_id": "<guardrails-response-id-or-null>",
30+
"output": {
31+
"type": "text",
32+
"content": { "format": "text", "value": "<sanitised text>" }
33+
}
34+
},
35+
"usage": {
36+
"input_tokens": 0,
37+
"output_tokens": 0,
38+
"total_tokens": 0,
39+
"reasoning_tokens": 0
40+
},
41+
"provider_raw_response": null
42+
},
43+
"error": null,
44+
"metadata": { "<your request_metadata>": "...", "warnings": [] }
45+
}
46+
```
47+
48+
If the guardrails service hard-blocks the text, `success` is `false`, `error`
49+
carries the upstream message, and `data` is `null`. If the guardrails service
50+
is unreachable the job still succeeds but the webhook carries the original
51+
text unchanged and `metadata.warnings` carries a human-readable note (e.g.
52+
`"Guardrails service was unavailable; original text was returned unchanged."`).
53+
Other warnings may surface for duplicate validator IDs, an empty validator
54+
list, or a missing sanitised text in the upstream response.
55+
56+
### Notes
57+
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).
62+
- For output-guardrail flows that need the original prompt paired with the
63+
LLM output, this endpoint v1 sends only `text`; pairing is not exposed.
64+
- The same webhook signing scheme as `/llm/call` is used when a webhook secret
65+
is configured.

‎backend/app/api/main.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
evaluations,
1717
features,
1818
fine_tuning,
19+
guardrails,
1920
languages,
2021
llm,
2122
llm_chain,
@@ -55,6 +56,7 @@
5556
api_router.include_router(languages.router)
5657
api_router.include_router(llm.router)
5758
api_router.include_router(llm_chain.router)
59+
api_router.include_router(guardrails.router)
5860
api_router.include_router(llm_sts.router)
5961
api_router.include_router(login.router)
6062
api_router.include_router(model_config.router)
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
import logging
2+
from uuid import UUID
3+
4+
from fastapi import APIRouter, Depends, HTTPException
5+
from opentelemetry import trace
6+
7+
from app.api.deps import AuthContextDep, SessionDep
8+
from app.api.permissions import Permission, require_permission
9+
from app.core.rate_monitor import monitor_rate
10+
from app.core.telemetry import log_context
11+
from app.crud.jobs import JobCrud
12+
from app.models import JobStatus, JobType
13+
from app.models.guardrails import (
14+
GuardrailsCallbackData,
15+
GuardrailsJobImmediatePublic,
16+
GuardrailsJobPublic,
17+
GuardrailsRequest,
18+
)
19+
from app.services.guardrails.jobs import start_job
20+
from app.utils import APIResponse, load_description, validate_callback_url
21+
22+
logger = logging.getLogger(__name__)
23+
24+
router = APIRouter(tags=["Guardrails"])
25+
guardrails_callback_router = APIRouter()
26+
27+
28+
@router.post(
29+
"/guardrails",
30+
description=load_description("guardrails/apply_guardrails.md"),
31+
response_model=APIResponse[GuardrailsJobImmediatePublic],
32+
callbacks=guardrails_callback_router.routes,
33+
dependencies=[
34+
Depends(require_permission(Permission.REQUIRE_PROJECT)),
35+
Depends(monitor_rate("llm_call")),
36+
],
37+
)
38+
def apply_guardrails_endpoint(
39+
_current_user: AuthContextDep,
40+
session: SessionDep,
41+
request: GuardrailsRequest,
42+
) -> APIResponse[GuardrailsJobImmediatePublic]:
43+
"""Initiate a guardrails-only job. Returns the job_id immediately; the
44+
sanitised text is delivered via callback_url (or polled via GET)."""
45+
project_id = _current_user.project_.id
46+
organization_id = _current_user.organization_.id
47+
48+
with log_context(
49+
tag="guardrails",
50+
system="guardrails",
51+
lifecycle="api.guardrails.apply",
52+
project_id=project_id,
53+
organization_id=organization_id,
54+
callback_enabled=request.callback_url is not None,
55+
):
56+
span = trace.get_current_span()
57+
if span.is_recording():
58+
span.set_attribute("kaapi.project_id", project_id)
59+
span.set_attribute("kaapi.organization_id", organization_id)
60+
span.set_attribute(
61+
"guardrails.callback_enabled", request.callback_url is not None
62+
)
63+
64+
if request.callback_url:
65+
validate_callback_url(str(request.callback_url))
66+
67+
job = start_job(
68+
db=session,
69+
request=request,
70+
project_id=project_id,
71+
organization_id=organization_id,
72+
)
73+
74+
if span.is_recording():
75+
span.set_attribute("guardrails.job_id", str(job.id))
76+
77+
message = (
78+
"Guardrails are being applied; the sanitised text will be delivered via callback."
79+
if request.callback_url
80+
else "Guardrails are being applied; poll GET /guardrails/{job_id} for the result."
81+
)
82+
83+
return APIResponse.success_response(
84+
data=GuardrailsJobImmediatePublic(
85+
job_id=job.id,
86+
status=job.status.value,
87+
message=message,
88+
job_inserted_at=job.inserted_at,
89+
job_updated_at=job.updated_at,
90+
)
91+
)
92+
93+
94+
@router.get(
95+
"/guardrails/{job_id}",
96+
response_model=APIResponse[GuardrailsJobPublic],
97+
dependencies=[Depends(require_permission(Permission.REQUIRE_PROJECT))],
98+
)
99+
def get_guardrails_job_status(
100+
_current_user: AuthContextDep,
101+
session: SessionDep,
102+
job_id: UUID,
103+
) -> APIResponse[GuardrailsJobPublic]:
104+
"""Poll for a /guardrails job's status and result.
105+
106+
On SUCCESS the sanitised text is rehydrated from the persisted upstream
107+
response stored on ``job.meta``.
108+
"""
109+
project_id = _current_user.project_.id
110+
111+
with log_context(
112+
tag="guardrails",
113+
system="guardrails",
114+
lifecycle="api.guardrails.status",
115+
job_id=job_id,
116+
project_id=project_id,
117+
organization_id=_current_user.organization_.id,
118+
):
119+
job = JobCrud(session=session).get(job_id=job_id, project_id=project_id)
120+
if not job or job.job_type != JobType.LLM_GUARDRAILS:
121+
# 404 (not 403) to avoid leaking existence of non-guardrails jobs.
122+
raise HTTPException(status_code=404, detail="Job not found")
123+
124+
meta = job.meta if isinstance(job.meta, dict) else {}
125+
callback_blob = meta.get("callback") if isinstance(meta, dict) else None
126+
127+
warnings: list[str] = []
128+
if isinstance(callback_blob, dict):
129+
raw_warnings = callback_blob.get("warnings")
130+
if isinstance(raw_warnings, list):
131+
warnings = [w for w in raw_warnings if isinstance(w, str)]
132+
133+
guardrails_response: GuardrailsCallbackData | None = None
134+
if job.status == JobStatus.SUCCESS:
135+
response_blob = meta.get("response") or {}
136+
data_blob = (
137+
response_blob.get("data") if isinstance(response_blob, dict) else None
138+
) or {}
139+
safe_text = (
140+
data_blob.get("safe_text") if isinstance(data_blob, dict) else None
141+
)
142+
request_blob = meta.get("request") or {}
143+
original_text = (
144+
request_blob.get("text") if isinstance(request_blob, dict) else None
145+
)
146+
value = safe_text if isinstance(safe_text, str) else (original_text or "")
147+
148+
response_id: str | None = None
149+
if isinstance(callback_blob, dict):
150+
rid = callback_blob.get("response_id")
151+
if isinstance(rid, str):
152+
response_id = rid
153+
154+
guardrails_response = GuardrailsCallbackData.model_validate(
155+
{
156+
"response": {
157+
"response_id": response_id,
158+
"output": {
159+
"type": "text",
160+
"content": {"format": "text", "value": value},
161+
},
162+
},
163+
"usage": (
164+
data_blob.get("usage")
165+
if isinstance(data_blob, dict)
166+
and isinstance(data_blob.get("usage"), dict)
167+
else {}
168+
),
169+
"provider_raw_response": None,
170+
}
171+
)
172+
173+
return APIResponse.success_response(
174+
data=GuardrailsJobPublic(
175+
job_id=job.id,
176+
status=job.status.value,
177+
guardrails_response=guardrails_response,
178+
error_message=job.error_message,
179+
warnings=warnings,
180+
)
181+
)

‎backend/app/celery/tasks/job_execution.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,24 @@ def run_response_job(self, project_id: int, job_id: str, trace_id: str, **kwargs
130130
)
131131

132132

133+
@celery_app.task(bind=True, queue="default", priority=9)
134+
@gevent_timeout(settings.CELERY_TASK_SOFT_TIME_LIMIT, "run_guardrails_job")
135+
def run_guardrails_job(self, project_id: int, job_id: str, trace_id: str, **kwargs):
136+
from app.services.guardrails.jobs import execute_job
137+
138+
_set_trace(trace_id)
139+
return _run_with_otel_parent(
140+
self,
141+
lambda: execute_job(
142+
project_id=project_id,
143+
job_id=job_id,
144+
task_id=current_task.request.id,
145+
task_instance=self,
146+
**kwargs,
147+
),
148+
)
149+
150+
133151
@celery_app.task(bind=True, queue="default", priority=2)
134152
@gevent_timeout(settings.CELERY_TASK_SOFT_TIME_LIMIT, "run_doctransform_job")
135153
def run_doctransform_job(self, project_id: int, job_id: str, trace_id: str, **kwargs):

‎backend/app/celery/utils.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,24 @@ def start_llm_chain_job(
5656
return task_id
5757

5858

59+
def start_guardrails_job(
60+
project_id: int, job_id: str, trace_id: str = "N/A", **kwargs
61+
) -> str:
62+
from app.celery.tasks.job_execution import run_guardrails_job
63+
64+
task_id = _enqueue_with_trace_context(
65+
run_guardrails_job,
66+
project_id=project_id,
67+
job_id=job_id,
68+
trace_id=trace_id,
69+
**kwargs,
70+
)
71+
logger.info(
72+
f"[start_guardrails_job] Started job {job_id} with Celery task {task_id}"
73+
)
74+
return task_id
75+
76+
5977
def start_response_job(
6078
project_id: int, job_id: str, trace_id: str = "N/A", **kwargs
6179
) -> str:

‎backend/app/core/exception_handlers.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from fastapi.exceptions import RequestValidationError
66
from fastapi.responses import JSONResponse
77
from starlette.status import (
8-
HTTP_422_UNPROCESSABLE_ENTITY,
8+
HTTP_422_UNPROCESSABLE_CONTENT,
99
HTTP_500_INTERNAL_SERVER_ERROR,
1010
)
1111

@@ -91,7 +91,7 @@ async def validation_error_handler(
9191
) -> JSONResponse:
9292
errors = _sanitize_validation_errors(exc.errors())
9393
return JSONResponse(
94-
status_code=HTTP_422_UNPROCESSABLE_ENTITY,
94+
status_code=HTTP_422_UNPROCESSABLE_CONTENT,
9595
content=APIResponse.failure_response(errors).model_dump(),
9696
)
9797

‎backend/app/crud/jobs.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
from typing import Any
23
from sqlmodel import Session
34
from uuid import UUID
45

@@ -17,8 +18,14 @@ def create(
1718
job_type: JobType,
1819
trace_id: str | None = None,
1920
project_id: int | None = None,
21+
meta: dict[str, Any] | None = None,
2022
) -> Job:
21-
new_job = Job(job_type=job_type, trace_id=trace_id, project_id=project_id)
23+
new_job = Job(
24+
job_type=job_type,
25+
trace_id=trace_id,
26+
project_id=project_id,
27+
meta=meta,
28+
)
2229
self.session.add(new_job)
2330
self.session.commit()
2431
self.session.refresh(new_job)

0 commit comments

Comments
 (0)