Skip to content

Commit 7b004e0

Browse files
AkhileshNegiclaude
andcommitted
refactor(guardrails): Restrict inline validator types, clarify names
Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 21580b6 commit 7b004e0

5 files changed

Lines changed: 62 additions & 37 deletions

File tree

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

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -476,17 +476,23 @@ class Validator(SQLModel):
476476
)
477477

478478

479+
class InlineValidatorTypeEnum(str, Enum):
480+
GENDER_ASSUMPTION_BIAS = "gender_assumption_bias"
481+
PII_REMOVER = "pii_remover"
482+
PROFANITY_FREE = "profanity_free"
483+
TOPIC_RELEVANCE_LLM = "topic_relevance_llm"
484+
485+
479486
class InlineValidator(SQLModel):
480487
"""Validator spec stored directly in the config blob.
481488
482-
Per-type params (languages, ban_list_id, ...) are extras validated by the
489+
Per-type params (on_fail, languages, ban_list_id, ...) are extras validated by the
483490
guardrails service, so they are allowed through untouched here.
484491
"""
485492

486493
model_config = ConfigDict(extra="allow") # pyright: ignore[reportAssignmentType]
487494

488-
type: str = Field(..., description="Guardrails validator type, e.g. ban_list")
489-
on_fail: str | None = Field(default=None, description="Action on failure")
495+
type: InlineValidatorTypeEnum = Field(..., description="Guardrails validator type")
490496
is_enabled: bool = Field(
491497
default=True,
492498
description="When false, the spec is kept in the blob but not run.",
@@ -495,10 +501,8 @@ class InlineValidator(SQLModel):
495501

496502
def _guardrail_entry_tag(value: Any) -> str:
497503
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"
504+
return "ref" if "validator_config_id" in value else "inline"
505+
return "ref" if hasattr(value, "validator_config_id") else "inline"
502506

503507

504508
# Explicit tag: InlineValidator allows extras, so a smart union could also

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

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ def proxy_guardrails_request(
5858
url = f"{settings.KAAPI_GUARDRAILS_URL}{path}"
5959
headers = _guardrails_headers(organization_id, project_id)
6060
# Unset query params must be omitted, not sent as empty values.
61-
query = {k: v for k, v in (params or {}).items() if v is not None}
61+
query = {key: value for key, value in (params or {}).items() if value is not None}
6262

6363
try:
6464
with (
@@ -175,20 +175,16 @@ def apply_guardrails(
175175
output-guardrail validators that compare input/output pairs.
176176
Also routes the IDs through the output-config fetch path.
177177
"""
178-
enabled: list[Validator | InlineValidator] = []
179-
for entry in validators or []:
180-
if entry.is_enabled:
181-
enabled.append(entry)
178+
enabled = [validator for validator in validators or [] if validator.is_enabled]
182179
if not enabled:
183180
return GuardrailsOutcome(
184181
safe_text=text, error=None, bypassed=False, rephrase_needed=False, raw={}
185182
)
186183

187184
is_output = output_text is not None
188-
references: list[Validator] = []
189-
for entry in enabled:
190-
if isinstance(entry, Validator):
191-
references.append(entry)
185+
references = [
186+
validator for validator in enabled if isinstance(validator, Validator)
187+
]
192188

193189
fetched: list[dict[str, Any]] = []
194190
if references:
@@ -206,7 +202,7 @@ def apply_guardrails(
206202
logger.info(
207203
f"[apply_guardrails] No validator configs resolved upstream; skipping "
208204
f"POST /guardrails. job_id={job_id}, requested_ids="
209-
f"{[str(v.validator_config_id) for v in references]}, "
205+
f"{[str(reference.validator_config_id) for reference in references]}, "
210206
f"is_output={is_output}"
211207
)
212208
return GuardrailsOutcome(
@@ -271,9 +267,6 @@ def _prepare_validators(
271267
if isinstance(entry, InlineValidator):
272268
# The service's validator configs are extra="forbid", so is_enabled must go.
273269
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)
277270
spec["stage"] = stage
278271
prepared.append(spec)
279272
continue
@@ -312,7 +305,7 @@ def summarize_validator_results(outcome: GuardrailsOutcome) -> list[dict[str, An
312305

313306
def run_guardrails_validation(
314307
input_text: str,
315-
guardrail_config: Sequence[Validator | InlineValidator | dict[str, Any]],
308+
guardrail_config: Sequence[Validator | dict[str, Any]],
316309
job_id: UUID,
317310
project_id: int | None,
318311
organization_id: int | None,
@@ -335,13 +328,13 @@ def run_guardrails_validation(
335328
Returns:
336329
JSON response from the guardrails service with validation results.
337330
"""
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"}))
331+
# is_enabled is unknown to the service's extra="forbid" validator schemas.
332+
validators = [
333+
validator
334+
if isinstance(validator, dict)
335+
else validator.model_dump(mode="json", exclude={"is_enabled"})
336+
for validator in guardrail_config
337+
]
345338

346339
payload = {
347340
"request_id": str(job_id),

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
from uuid import uuid4
22

3+
import pytest
4+
from pydantic import ValidationError
5+
36
from app.models.llm.request import (
47
ConfigBlob,
58
InlineValidator,
@@ -98,13 +101,19 @@ def test_dict_with_config_id_parses_to_validator(self) -> None:
98101

99102
def test_dict_with_type_parses_to_inline_and_keeps_extras(self) -> None:
100103
blob = _blob(
101-
[{"type": "ban_list", "on_fail": "fix", "languages": ["en", "hi"]}]
104+
[
105+
{
106+
"type": "topic_relevance_llm",
107+
"on_fail": "fix",
108+
"languages": ["en", "hi"],
109+
}
110+
]
102111
)
103112

104113
(entry,) = blob.input_guardrails or []
105114
assert isinstance(entry, InlineValidator)
106115
assert entry.model_dump() == {
107-
"type": "ban_list",
116+
"type": "topic_relevance_llm",
108117
"on_fail": "fix",
109118
"is_enabled": True,
110119
"languages": ["en", "hi"],
@@ -123,6 +132,10 @@ def test_ref_dict_carrying_type_still_parses_to_validator(self) -> None:
123132
"is_enabled": True,
124133
}
125134

135+
def test_unsupported_inline_type_is_rejected(self) -> None:
136+
with pytest.raises(ValidationError):
137+
_blob([{"type": "ban_list"}])
138+
126139
def test_revalidating_parsed_entries_keeps_types(self) -> None:
127140
parsed = _blob(
128141
[{"validator_config_id": str(uuid4())}, {"type": "pii_remover"}]

‎backend/app/tests/services/llm/test_guardrails.py‎

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -475,7 +475,7 @@ def test_disabled_entries_are_neither_fetched_nor_posted(
475475
[
476476
disabled_ref,
477477
enabled_ref,
478-
InlineValidator(type="off_inline", is_enabled=False),
478+
InlineValidator(type="profanity_free", is_enabled=False),
479479
]
480480
)
481481

@@ -522,12 +522,19 @@ def test_posts_in_blob_order_not_fetch_order(self, mock_fetch, mock_run) -> None
522522
mock_fetch.return_value = ([row_y, row_x], [])
523523
mock_run.return_value = _PASS_RESPONSE
524524

525-
_apply([InlineValidator(type="a"), ref_x, InlineValidator(type="b"), ref_y])
525+
_apply(
526+
[
527+
InlineValidator(type="profanity_free"),
528+
ref_x,
529+
InlineValidator(type="pii_remover"),
530+
ref_y,
531+
]
532+
)
526533

527534
assert mock_run.call_args.args[1] == [
528-
{"type": "a", "stage": "input"},
535+
{"type": "profanity_free", "stage": "input"},
529536
row_x,
530-
{"type": "b", "stage": "input"},
537+
{"type": "pii_remover", "stage": "input"},
531538
row_y,
532539
]
533540

@@ -536,13 +543,21 @@ def test_inline_spec_posted_shape(self, mock_fetch, mock_run) -> None:
536543

537544
_apply(
538545
[
539-
InlineValidator(type="ban_list", stage="output", banned_words=["x"]),
546+
InlineValidator(
547+
type="gender_assumption_bias",
548+
stage="output",
549+
categories=["healthcare"],
550+
),
540551
InlineValidator(type="pii_remover", on_fail="exception"),
541552
]
542553
)
543554

544555
assert mock_run.call_args.args[1] == [
545-
{"type": "ban_list", "stage": "input", "banned_words": ["x"]},
556+
{
557+
"type": "gender_assumption_bias",
558+
"stage": "input",
559+
"categories": ["healthcare"],
560+
},
546561
{"type": "pii_remover", "on_fail": "exception", "stage": "input"},
547562
]
548563

‎docs/wiki/modules/llm-call.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ All paths relative to `backend/app/`.
2626

2727
## Key pydantic/SQLModel schemas (`models/llm/request.py`)
2828
- `LLMCallConfig` — one-of: saved reference (`id` + `version`) XOR ad-hoc `blob` (validator-enforced)
29-
- `ConfigBlob` — `completion` + optional `prompt_template` (`PromptTemplate.template`, plain string; `{{input}}` interpolation is llm-chain-only) + `input_guardrails`/`output_guardrails`. Each guardrail entry is either a `Validator` reference (`{validator_config_id}`) or an `InlineValidator` spec (`{type, on_fail, ...per-type params}`); tagged union `GuardrailEntry`. Both carry `is_enabled` (default true). Disabled entries are never fetched or sent. `services/llm/guardrails._prepare_validators` POSTs the rest in blob order, with `is_enabled` stripped and `stage` stamped on inline specs.
29+
- `ConfigBlob` — `completion` + optional `prompt_template` (`PromptTemplate.template`, plain string; `{{input}}` interpolation is llm-chain-only) + `input_guardrails`/`output_guardrails`. Each guardrail entry is either a `Validator` reference (`{validator_config_id}`) or an `InlineValidator` spec (`{type, on_fail, ...per-type params}`, `type` limited to `InlineValidatorTypeEnum`: gender_assumption_bias, pii_remover, profanity_free, topic_relevance_llm); tagged union `GuardrailEntry`. Both carry `is_enabled` (default true). Disabled entries are never fetched or sent. `services/llm/guardrails._prepare_validators` POSTs the rest in blob order, with `is_enabled` stripped and `stage` stamped on inline specs.
3030
- `CompletionConfig` — discriminated union on `provider`: `KaapiCompletionConfig` (standardized params: `TextLLMParams`/`STTLLMParams`/`TTSLLMParams`), `NativeCompletionConfig` (pass-through), `ProxyCompletionConfig` (client's own endpoint)
3131
- `TextLLMParams` reasoning knobs: `reasoning` + `effort` (OpenAI-style), `thinking` (Anthropic adaptive-thinking container, forwarded as-is) and `thinking_level` (Gemini). A knob must be **declared here** to survive config save — pydantic's default extra policy is ignore, so an undeclared key is dropped silently at validation with no error.
3232
- `QueryParams` — per-call input + `ConversationConfig`

0 commit comments

Comments
 (0)