Skip to content

Commit 4e12852

Browse files
AkhileshNegiclaude
andcommitted
feat(guardrails): Inline validator specs and is_enabled in config blob
Guardrail entries in the LLM config blob can now be either a validator_config_id reference or an inline validator spec. Both accept is_enabled (default true): disabled entries stay saved in the config version but are never fetched or sent to the guardrails service. Validators are sent in blob order, inline specs get their stage stamped and is_enabled stripped (the service's validator schemas forbid extras). Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 3c5333f commit 4e12852

6 files changed

Lines changed: 474 additions & 43 deletions

File tree

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

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55

66
import sqlalchemy as sa
77
from pydantic import (
8+
ConfigDict,
9+
Discriminator,
810
HttpUrl,
11+
Tag,
912
SerializerFunctionWrapHandler,
1013
model_serializer,
1114
model_validator,
@@ -464,7 +467,46 @@ class ProxyCompletionConfig(SQLModel):
464467

465468

466469
class Validator(SQLModel):
470+
"""Reference to a validator config saved in the guardrails service."""
471+
467472
validator_config_id: UUID
473+
is_enabled: bool = Field(
474+
default=True,
475+
description="When false, the reference is neither fetched nor run.",
476+
)
477+
478+
479+
class InlineValidator(SQLModel):
480+
"""Validator spec stored directly in the config blob.
481+
482+
Per-type params (languages, ban_list_id, ...) are extras validated by the
483+
guardrails service, so they are allowed through untouched here.
484+
"""
485+
486+
model_config = ConfigDict(extra="allow") # pyright: ignore[reportAssignmentType]
487+
488+
type: str = Field(..., description="Guardrails validator type, e.g. ban_list")
489+
on_fail: str | None = Field(default=None, description="Action on failure")
490+
is_enabled: bool = Field(
491+
default=True,
492+
description="When false, the spec is kept in the blob but not run.",
493+
)
494+
495+
496+
def _guardrail_entry_tag(value: Any) -> str:
497+
if isinstance(value, dict):
498+
has_ref = "validator_config_id" in value
499+
else:
500+
has_ref = hasattr(value, "validator_config_id")
501+
return "ref" if has_ref else "inline"
502+
503+
504+
# Explicit tag: InlineValidator allows extras, so a smart union could also
505+
# swallow a reference dict that happens to carry a `type`.
506+
GuardrailEntry = Annotated[
507+
Annotated[Validator, Tag("ref")] | Annotated[InlineValidator, Tag("inline")],
508+
Discriminator(_guardrail_entry_tag),
509+
]
468510

469511

470512
class PromptTemplate(SQLModel):
@@ -500,12 +542,12 @@ def _default_proxy_provider(cls, data: Any) -> Any:
500542
description="Prompt template with {{input}} placeholder to wrap around the user input",
501543
)
502544

503-
input_guardrails: list[Validator] | None = Field(
545+
input_guardrails: list[GuardrailEntry] | None = Field(
504546
default=None,
505547
description="Guardrails applied to validate/sanitize the input before the LLM call",
506548
)
507549

508-
output_guardrails: list[Validator] | None = Field(
550+
output_guardrails: list[GuardrailEntry] | None = Field(
509551
default=None,
510552
description="Guardrails applied to validate/sanitize the output after the LLM call",
511553
)

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

Lines changed: 74 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,18 @@
1111
from opentelemetry import trace
1212

1313
from app.core.config import settings
14-
from app.models.llm.request import Validator
14+
from app.models.llm.request import InlineValidator, Validator
1515

1616
logger = logging.getLogger(__name__)
1717
tracer = trace.get_tracer(__name__)
1818

1919

2020
GUARDRAILS_PROXY_TIMEOUT_SECONDS = 30.0
2121

22+
# The service logs validator_log.stage from the spec and defaults to input.
23+
GUARDRAIL_STAGE_INPUT = "input"
24+
GUARDRAIL_STAGE_OUTPUT = "output"
25+
2226

2327
def _guardrails_headers(
2428
organization_id: int | None, project_id: int | None
@@ -148,7 +152,7 @@ def applied(self) -> bool:
148152
def apply_guardrails(
149153
*,
150154
text: str,
151-
validators: list[Validator] | None,
155+
validators: Sequence[Validator | InlineValidator] | None,
152156
job_id: UUID,
153157
project_id: int | None,
154158
organization_id: int | None,
@@ -164,30 +168,45 @@ def apply_guardrails(
164168
165169
Args:
166170
text: Primary text to validate. Sent as `input` to the service.
167-
validators: Validator references (config IDs). When None/empty the
168-
function short-circuits with a no-op outcome.
171+
validators: Config-ID references and/or inline specs, in run order.
172+
Entries with is_enabled=False are skipped; when nothing enabled
173+
remains the function short-circuits with a no-op outcome.
169174
output_text: When provided, sent as `output` to the service for
170175
output-guardrail validators that compare input/output pairs.
171176
Also routes the IDs through the output-config fetch path.
172177
"""
173-
if not validators:
178+
enabled: list[Validator | InlineValidator] = []
179+
for entry in validators or []:
180+
if entry.is_enabled:
181+
enabled.append(entry)
182+
if not enabled:
174183
return GuardrailsOutcome(
175184
safe_text=text, error=None, bypassed=False, rephrase_needed=False, raw={}
176185
)
177186

178187
is_output = output_text is not None
179-
input_cfgs, output_cfgs = list_validators_config(
180-
organization_id=organization_id,
181-
project_id=project_id,
182-
input_validator_configs=None if is_output else validators,
183-
output_validator_configs=validators if is_output else None,
184-
)
185-
resolved = output_cfgs if is_output else input_cfgs
188+
references: list[Validator] = []
189+
for entry in enabled:
190+
if isinstance(entry, Validator):
191+
references.append(entry)
192+
193+
fetched: list[dict[str, Any]] = []
194+
if references:
195+
input_cfgs, output_cfgs = list_validators_config(
196+
organization_id=organization_id,
197+
project_id=project_id,
198+
input_validator_configs=None if is_output else references,
199+
output_validator_configs=references if is_output else None,
200+
)
201+
fetched = output_cfgs if is_output else input_cfgs
202+
203+
stage = GUARDRAIL_STAGE_OUTPUT if is_output else GUARDRAIL_STAGE_INPUT
204+
resolved = _prepare_validators(enabled, fetched, stage, job_id)
186205
if not resolved:
187206
logger.info(
188207
f"[apply_guardrails] No validator configs resolved upstream; skipping "
189208
f"POST /guardrails. job_id={job_id}, requested_ids="
190-
f"{[str(v.validator_config_id) for v in validators]}, "
209+
f"{[str(v.validator_config_id) for v in references]}, "
191210
f"is_output={is_output}"
192211
)
193212
return GuardrailsOutcome(
@@ -237,6 +256,40 @@ def apply_guardrails(
237256
)
238257

239258

259+
def _prepare_validators(
260+
entries: Sequence[Validator | InlineValidator],
261+
fetched_rows: list[dict[str, Any]],
262+
stage: str,
263+
job_id: UUID,
264+
) -> list[dict[str, Any]]:
265+
"""Build the POST `validators` list in blob order, since the service runs
266+
them in the order received while the fetch returns rows by created_at."""
267+
rows_by_id = {str(row.get("id")): row for row in fetched_rows}
268+
269+
prepared: list[dict[str, Any]] = []
270+
for entry in entries:
271+
if isinstance(entry, InlineValidator):
272+
# The service's validator configs are extra="forbid", so is_enabled must go.
273+
spec = entry.model_dump(mode="json", exclude={"is_enabled"})
274+
# Service's on_fail is non-nullable; omitting it applies its default.
275+
if spec.get("on_fail") is None:
276+
spec.pop("on_fail", None)
277+
spec["stage"] = stage
278+
prepared.append(spec)
279+
continue
280+
281+
config_id = str(entry.validator_config_id)
282+
row = rows_by_id.get(config_id)
283+
if row is None:
284+
logger.warning(
285+
f"[_prepare_validators] Validator config not returned upstream; "
286+
f"dropping it | job_id={job_id}, validator_config_id={config_id}"
287+
)
288+
continue
289+
prepared.append(row)
290+
return prepared
291+
292+
240293
def summarize_validator_results(outcome: GuardrailsOutcome) -> list[dict[str, Any]]:
241294
"""Per-validator name/outcome/text summary, extracted from the raw
242295
guardrails service response, for use in llm_call metadata."""
@@ -259,7 +312,7 @@ def summarize_validator_results(outcome: GuardrailsOutcome) -> list[dict[str, An
259312

260313
def run_guardrails_validation(
261314
input_text: str,
262-
guardrail_config: Sequence[Validator | dict[str, Any]],
315+
guardrail_config: Sequence[Validator | InlineValidator | dict[str, Any]],
263316
job_id: UUID,
264317
project_id: int | None,
265318
organization_id: int | None,
@@ -282,12 +335,13 @@ def run_guardrails_validation(
282335
Returns:
283336
JSON response from the guardrails service with validation results.
284337
"""
285-
validators = [
286-
validator.model_dump(mode="json")
287-
if isinstance(validator, Validator)
288-
else validator
289-
for validator in guardrail_config
290-
]
338+
validators: list[dict[str, Any]] = []
339+
for validator in guardrail_config:
340+
if isinstance(validator, dict):
341+
validators.append(validator)
342+
else:
343+
# Unknown to the service's extra="forbid" validator schemas.
344+
validators.append(validator.model_dump(mode="json", exclude={"is_enabled"}))
291345

292346
payload = {
293347
"request_id": str(job_id),

‎backend/app/tests/models/llm/test_request.py‎

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
from app.models.llm.request import build_kaapi_completion_config
1+
from uuid import uuid4
2+
3+
from app.models.llm.request import (
4+
ConfigBlob,
5+
InlineValidator,
6+
NativeCompletionConfig,
7+
Validator,
8+
build_kaapi_completion_config,
9+
)
210
from app.services.llm.mappers import kaapi_params_as_dict
311

412

@@ -67,3 +75,76 @@ def test_unset_temperature_survives_json_round_trip(self) -> None:
6775
assert "temperature" not in kaapi_params_as_dict(
6876
round_tripped.completion.params
6977
)
78+
79+
80+
def _blob(input_guardrails: list) -> ConfigBlob:
81+
return ConfigBlob(
82+
completion=NativeCompletionConfig(
83+
provider="openai-native", type="text", params={"model": "gpt-4o"}
84+
),
85+
input_guardrails=input_guardrails,
86+
)
87+
88+
89+
class TestGuardrailEntryParsing:
90+
def test_dict_with_config_id_parses_to_validator(self) -> None:
91+
config_id = uuid4()
92+
blob = _blob([{"validator_config_id": str(config_id)}])
93+
94+
(entry,) = blob.input_guardrails or []
95+
assert isinstance(entry, Validator)
96+
assert entry.validator_config_id == config_id
97+
assert entry.is_enabled is True
98+
99+
def test_dict_with_type_parses_to_inline_and_keeps_extras(self) -> None:
100+
blob = _blob(
101+
[{"type": "ban_list", "on_fail": "fix", "languages": ["en", "hi"]}]
102+
)
103+
104+
(entry,) = blob.input_guardrails or []
105+
assert isinstance(entry, InlineValidator)
106+
assert entry.model_dump() == {
107+
"type": "ban_list",
108+
"on_fail": "fix",
109+
"is_enabled": True,
110+
"languages": ["en", "hi"],
111+
}
112+
113+
def test_ref_dict_carrying_type_still_parses_to_validator(self) -> None:
114+
# InlineValidator allows extras, so without the discriminator a smart
115+
# union could claim this ref.
116+
config_id = uuid4()
117+
blob = _blob([{"validator_config_id": str(config_id), "type": "ban_list"}])
118+
119+
(entry,) = blob.input_guardrails or []
120+
assert isinstance(entry, Validator)
121+
assert entry.model_dump() == {
122+
"validator_config_id": config_id,
123+
"is_enabled": True,
124+
}
125+
126+
def test_revalidating_parsed_entries_keeps_types(self) -> None:
127+
parsed = _blob(
128+
[{"validator_config_id": str(uuid4())}, {"type": "pii_remover"}]
129+
).input_guardrails
130+
131+
rebuilt = _blob(list(parsed or [])).input_guardrails or []
132+
133+
assert [type(e) for e in rebuilt] == [Validator, InlineValidator]
134+
135+
def test_is_enabled_false_survives_json_round_trip(self) -> None:
136+
config_id = uuid4()
137+
dumped = _blob(
138+
[
139+
{"validator_config_id": str(config_id), "is_enabled": False},
140+
{"type": "pii_remover", "is_enabled": False},
141+
]
142+
).model_dump(mode="json")
143+
144+
ref, inline = ConfigBlob.model_validate(dumped).input_guardrails or []
145+
146+
assert isinstance(ref, Validator)
147+
assert ref.validator_config_id == config_id
148+
assert ref.is_enabled is False
149+
assert isinstance(inline, InlineValidator)
150+
assert inline.is_enabled is False

0 commit comments

Comments
 (0)