From 4e12852aae15dd126cbcd6263e6b64189d0f7aee Mon Sep 17 00:00:00 2001 From: AkhileshNegi Date: Sat, 10 Oct 2026 12:42:13 +0530 Subject: [PATCH 1/3] 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 --- backend/app/models/llm/request.py | 46 ++++- backend/app/services/llm/guardrails.py | 94 +++++++--- backend/app/tests/models/llm/test_request.py | 83 ++++++++- .../app/tests/services/llm/test_guardrails.py | 164 ++++++++++++++++++ backend/app/tests/services/llm/test_jobs.py | 128 ++++++++++++-- docs/wiki/modules/llm-call.md | 2 +- 6 files changed, 474 insertions(+), 43 deletions(-) diff --git a/backend/app/models/llm/request.py b/backend/app/models/llm/request.py index 870d1ef1c..b98825540 100644 --- a/backend/app/models/llm/request.py +++ b/backend/app/models/llm/request.py @@ -5,7 +5,10 @@ import sqlalchemy as sa from pydantic import ( + ConfigDict, + Discriminator, HttpUrl, + Tag, SerializerFunctionWrapHandler, model_serializer, model_validator, @@ -464,7 +467,46 @@ class ProxyCompletionConfig(SQLModel): class Validator(SQLModel): + """Reference to a validator config saved in the guardrails service.""" + validator_config_id: UUID + is_enabled: bool = Field( + default=True, + description="When false, the reference is neither fetched nor run.", + ) + + +class InlineValidator(SQLModel): + """Validator spec stored directly in the config blob. + + Per-type params (languages, ban_list_id, ...) are extras validated by the + guardrails service, so they are allowed through untouched here. + """ + + model_config = ConfigDict(extra="allow") # pyright: ignore[reportAssignmentType] + + type: str = Field(..., description="Guardrails validator type, e.g. ban_list") + on_fail: str | None = Field(default=None, description="Action on failure") + is_enabled: bool = Field( + default=True, + description="When false, the spec is kept in the blob but not run.", + ) + + +def _guardrail_entry_tag(value: Any) -> str: + if isinstance(value, dict): + has_ref = "validator_config_id" in value + else: + has_ref = hasattr(value, "validator_config_id") + return "ref" if has_ref else "inline" + + +# Explicit tag: InlineValidator allows extras, so a smart union could also +# swallow a reference dict that happens to carry a `type`. +GuardrailEntry = Annotated[ + Annotated[Validator, Tag("ref")] | Annotated[InlineValidator, Tag("inline")], + Discriminator(_guardrail_entry_tag), +] class PromptTemplate(SQLModel): @@ -500,12 +542,12 @@ def _default_proxy_provider(cls, data: Any) -> Any: description="Prompt template with {{input}} placeholder to wrap around the user input", ) - input_guardrails: list[Validator] | None = Field( + input_guardrails: list[GuardrailEntry] | None = Field( default=None, description="Guardrails applied to validate/sanitize the input before the LLM call", ) - output_guardrails: list[Validator] | None = Field( + output_guardrails: list[GuardrailEntry] | None = Field( default=None, description="Guardrails applied to validate/sanitize the output after the LLM call", ) diff --git a/backend/app/services/llm/guardrails.py b/backend/app/services/llm/guardrails.py index a57fcb9d6..62774a95b 100644 --- a/backend/app/services/llm/guardrails.py +++ b/backend/app/services/llm/guardrails.py @@ -11,7 +11,7 @@ from opentelemetry import trace from app.core.config import settings -from app.models.llm.request import Validator +from app.models.llm.request import InlineValidator, Validator logger = logging.getLogger(__name__) tracer = trace.get_tracer(__name__) @@ -19,6 +19,10 @@ GUARDRAILS_PROXY_TIMEOUT_SECONDS = 30.0 +# The service logs validator_log.stage from the spec and defaults to input. +GUARDRAIL_STAGE_INPUT = "input" +GUARDRAIL_STAGE_OUTPUT = "output" + def _guardrails_headers( organization_id: int | None, project_id: int | None @@ -148,7 +152,7 @@ def applied(self) -> bool: def apply_guardrails( *, text: str, - validators: list[Validator] | None, + validators: Sequence[Validator | InlineValidator] | None, job_id: UUID, project_id: int | None, organization_id: int | None, @@ -164,30 +168,45 @@ def apply_guardrails( Args: text: Primary text to validate. Sent as `input` to the service. - validators: Validator references (config IDs). When None/empty the - function short-circuits with a no-op outcome. + validators: Config-ID references and/or inline specs, in run order. + Entries with is_enabled=False are skipped; when nothing enabled + remains the function short-circuits with a no-op outcome. output_text: When provided, sent as `output` to the service for output-guardrail validators that compare input/output pairs. Also routes the IDs through the output-config fetch path. """ - if not validators: + enabled: list[Validator | InlineValidator] = [] + for entry in validators or []: + if entry.is_enabled: + enabled.append(entry) + if not enabled: return GuardrailsOutcome( safe_text=text, error=None, bypassed=False, rephrase_needed=False, raw={} ) is_output = output_text is not None - input_cfgs, output_cfgs = list_validators_config( - organization_id=organization_id, - project_id=project_id, - input_validator_configs=None if is_output else validators, - output_validator_configs=validators if is_output else None, - ) - resolved = output_cfgs if is_output else input_cfgs + references: list[Validator] = [] + for entry in enabled: + if isinstance(entry, Validator): + references.append(entry) + + fetched: list[dict[str, Any]] = [] + if references: + input_cfgs, output_cfgs = list_validators_config( + organization_id=organization_id, + project_id=project_id, + input_validator_configs=None if is_output else references, + output_validator_configs=references if is_output else None, + ) + fetched = output_cfgs if is_output else input_cfgs + + stage = GUARDRAIL_STAGE_OUTPUT if is_output else GUARDRAIL_STAGE_INPUT + resolved = _prepare_validators(enabled, fetched, stage, job_id) if not resolved: logger.info( f"[apply_guardrails] No validator configs resolved upstream; skipping " f"POST /guardrails. job_id={job_id}, requested_ids=" - f"{[str(v.validator_config_id) for v in validators]}, " + f"{[str(v.validator_config_id) for v in references]}, " f"is_output={is_output}" ) return GuardrailsOutcome( @@ -237,6 +256,40 @@ def apply_guardrails( ) +def _prepare_validators( + entries: Sequence[Validator | InlineValidator], + fetched_rows: list[dict[str, Any]], + stage: str, + job_id: UUID, +) -> list[dict[str, Any]]: + """Build the POST `validators` list in blob order, since the service runs + them in the order received while the fetch returns rows by created_at.""" + rows_by_id = {str(row.get("id")): row for row in fetched_rows} + + prepared: list[dict[str, Any]] = [] + for entry in entries: + if isinstance(entry, InlineValidator): + # The service's validator configs are extra="forbid", so is_enabled must go. + spec = entry.model_dump(mode="json", exclude={"is_enabled"}) + # Service's on_fail is non-nullable; omitting it applies its default. + if spec.get("on_fail") is None: + spec.pop("on_fail", None) + spec["stage"] = stage + prepared.append(spec) + continue + + config_id = str(entry.validator_config_id) + row = rows_by_id.get(config_id) + if row is None: + logger.warning( + f"[_prepare_validators] Validator config not returned upstream; " + f"dropping it | job_id={job_id}, validator_config_id={config_id}" + ) + continue + prepared.append(row) + return prepared + + def summarize_validator_results(outcome: GuardrailsOutcome) -> list[dict[str, Any]]: """Per-validator name/outcome/text summary, extracted from the raw guardrails service response, for use in llm_call metadata.""" @@ -259,7 +312,7 @@ def summarize_validator_results(outcome: GuardrailsOutcome) -> list[dict[str, An def run_guardrails_validation( input_text: str, - guardrail_config: Sequence[Validator | dict[str, Any]], + guardrail_config: Sequence[Validator | InlineValidator | dict[str, Any]], job_id: UUID, project_id: int | None, organization_id: int | None, @@ -282,12 +335,13 @@ def run_guardrails_validation( Returns: JSON response from the guardrails service with validation results. """ - validators = [ - validator.model_dump(mode="json") - if isinstance(validator, Validator) - else validator - for validator in guardrail_config - ] + validators: list[dict[str, Any]] = [] + for validator in guardrail_config: + if isinstance(validator, dict): + validators.append(validator) + else: + # Unknown to the service's extra="forbid" validator schemas. + validators.append(validator.model_dump(mode="json", exclude={"is_enabled"})) payload = { "request_id": str(job_id), diff --git a/backend/app/tests/models/llm/test_request.py b/backend/app/tests/models/llm/test_request.py index fdc1e5cd5..682f1a816 100644 --- a/backend/app/tests/models/llm/test_request.py +++ b/backend/app/tests/models/llm/test_request.py @@ -1,4 +1,12 @@ -from app.models.llm.request import build_kaapi_completion_config +from uuid import uuid4 + +from app.models.llm.request import ( + ConfigBlob, + InlineValidator, + NativeCompletionConfig, + Validator, + build_kaapi_completion_config, +) from app.services.llm.mappers import kaapi_params_as_dict @@ -67,3 +75,76 @@ def test_unset_temperature_survives_json_round_trip(self) -> None: assert "temperature" not in kaapi_params_as_dict( round_tripped.completion.params ) + + +def _blob(input_guardrails: list) -> ConfigBlob: + return ConfigBlob( + completion=NativeCompletionConfig( + provider="openai-native", type="text", params={"model": "gpt-4o"} + ), + input_guardrails=input_guardrails, + ) + + +class TestGuardrailEntryParsing: + def test_dict_with_config_id_parses_to_validator(self) -> None: + config_id = uuid4() + blob = _blob([{"validator_config_id": str(config_id)}]) + + (entry,) = blob.input_guardrails or [] + assert isinstance(entry, Validator) + assert entry.validator_config_id == config_id + assert entry.is_enabled is True + + def test_dict_with_type_parses_to_inline_and_keeps_extras(self) -> None: + blob = _blob( + [{"type": "ban_list", "on_fail": "fix", "languages": ["en", "hi"]}] + ) + + (entry,) = blob.input_guardrails or [] + assert isinstance(entry, InlineValidator) + assert entry.model_dump() == { + "type": "ban_list", + "on_fail": "fix", + "is_enabled": True, + "languages": ["en", "hi"], + } + + def test_ref_dict_carrying_type_still_parses_to_validator(self) -> None: + # InlineValidator allows extras, so without the discriminator a smart + # union could claim this ref. + config_id = uuid4() + blob = _blob([{"validator_config_id": str(config_id), "type": "ban_list"}]) + + (entry,) = blob.input_guardrails or [] + assert isinstance(entry, Validator) + assert entry.model_dump() == { + "validator_config_id": config_id, + "is_enabled": True, + } + + def test_revalidating_parsed_entries_keeps_types(self) -> None: + parsed = _blob( + [{"validator_config_id": str(uuid4())}, {"type": "pii_remover"}] + ).input_guardrails + + rebuilt = _blob(list(parsed or [])).input_guardrails or [] + + assert [type(e) for e in rebuilt] == [Validator, InlineValidator] + + def test_is_enabled_false_survives_json_round_trip(self) -> None: + config_id = uuid4() + dumped = _blob( + [ + {"validator_config_id": str(config_id), "is_enabled": False}, + {"type": "pii_remover", "is_enabled": False}, + ] + ).model_dump(mode="json") + + ref, inline = ConfigBlob.model_validate(dumped).input_guardrails or [] + + assert isinstance(ref, Validator) + assert ref.validator_config_id == config_id + assert ref.is_enabled is False + assert isinstance(inline, InlineValidator) + assert inline.is_enabled is False diff --git a/backend/app/tests/services/llm/test_guardrails.py b/backend/app/tests/services/llm/test_guardrails.py index b1ccabcdb..7caa81a4c 100644 --- a/backend/app/tests/services/llm/test_guardrails.py +++ b/backend/app/tests/services/llm/test_guardrails.py @@ -13,6 +13,7 @@ from app.models import Job, JobType from app.models.llm.request import ( ConfigBlob, + InlineValidator, LLMCallConfig, LlmCall, NativeCompletionConfig, @@ -21,6 +22,7 @@ ) from app.services.llm.guardrails import ( GuardrailsOutcome, + apply_guardrails, list_validators_config, run_guardrails_validation, summarize_validator_results, @@ -427,6 +429,168 @@ def test_summarize_validator_results_empty_when_raw_has_no_validator_results() - assert summarize_validator_results(outcome) == [] +_PASS_RESPONSE = {"success": True, "data": {"safe_text": "clean"}} + + +def _ref() -> tuple[Validator, dict[str, Any]]: + config_id = uuid4() + return Validator(validator_config_id=config_id), { + "id": str(config_id), + "type": f"fetched_{config_id.hex[:8]}", + } + + +def _apply(validators: list, output_text: str | None = None) -> GuardrailsOutcome: + return apply_guardrails( + text=TEST_TEXT, + validators=validators, + job_id=TEST_JOB_ID, + project_id=TEST_PROJECT_ID, + organization_id=TEST_ORGANIZATION_ID, + output_text=output_text, + ) + + +def _assert_noop(outcome: GuardrailsOutcome) -> None: + assert outcome.safe_text == TEST_TEXT + assert outcome.error is None + assert outcome.bypassed is False + assert outcome.raw == {} + assert outcome.applied is False + + +@patch("app.services.llm.guardrails.run_guardrails_validation") +@patch("app.services.llm.guardrails.list_validators_config") +class TestApplyGuardrails: + def test_disabled_entries_are_neither_fetched_nor_posted( + self, mock_fetch, mock_run + ) -> None: + enabled_ref, enabled_row = _ref() + disabled_ref, _ = _ref() + disabled_ref.is_enabled = False + mock_fetch.return_value = ([enabled_row], []) + mock_run.return_value = _PASS_RESPONSE + + _apply( + [ + disabled_ref, + enabled_ref, + InlineValidator(type="off_inline", is_enabled=False), + ] + ) + + fetched_ids = [ + v.validator_config_id + for v in mock_fetch.call_args.kwargs["input_validator_configs"] + ] + assert fetched_ids == [enabled_ref.validator_config_id] + assert mock_run.call_args.args[1] == [enabled_row] + + @pytest.mark.parametrize( + "validators", + [ + [], + None, + [ + Validator(validator_config_id=uuid4(), is_enabled=False), + InlineValidator(type="pii_remover", is_enabled=False), + ], + ], + ids=["empty", "none", "all_disabled"], + ) + def test_nothing_enabled_is_noop_without_http( + self, mock_fetch, mock_run, validators + ) -> None: + outcome = _apply(validators) + + _assert_noop(outcome) + mock_fetch.assert_not_called() + mock_run.assert_not_called() + + def test_inline_only_skips_fetch_and_posts(self, mock_fetch, mock_run) -> None: + mock_run.return_value = _PASS_RESPONSE + + outcome = _apply([InlineValidator(type="pii_remover")]) + + mock_fetch.assert_not_called() + assert mock_run.call_args.args[1] == [{"type": "pii_remover", "stage": "input"}] + assert outcome.safe_text == "clean" + + def test_posts_in_blob_order_not_fetch_order(self, mock_fetch, mock_run) -> None: + ref_x, row_x = _ref() + ref_y, row_y = _ref() + mock_fetch.return_value = ([row_y, row_x], []) + mock_run.return_value = _PASS_RESPONSE + + _apply([InlineValidator(type="a"), ref_x, InlineValidator(type="b"), ref_y]) + + assert mock_run.call_args.args[1] == [ + {"type": "a", "stage": "input"}, + row_x, + {"type": "b", "stage": "input"}, + row_y, + ] + + def test_inline_spec_posted_shape(self, mock_fetch, mock_run) -> None: + mock_run.return_value = _PASS_RESPONSE + + _apply( + [ + InlineValidator(type="ban_list", stage="output", banned_words=["x"]), + InlineValidator(type="pii_remover", on_fail="exception"), + ] + ) + + assert mock_run.call_args.args[1] == [ + {"type": "ban_list", "stage": "input", "banned_words": ["x"]}, + {"type": "pii_remover", "on_fail": "exception", "stage": "input"}, + ] + + def test_output_path_sets_output_stage(self, mock_fetch, mock_run) -> None: + ref, row = _ref() + mock_fetch.return_value = ([], [row]) + mock_run.return_value = _PASS_RESPONSE + + _apply([ref, InlineValidator(type="pii_remover")], output_text="llm out") + + assert mock_fetch.call_args.kwargs["input_validator_configs"] is None + assert mock_run.call_args.args[1] == [ + row, + {"type": "pii_remover", "stage": "output"}, + ] + assert mock_run.call_args.kwargs["output_text"] == "llm out" + + def test_ref_missing_upstream_is_dropped(self, mock_fetch, mock_run) -> None: + found_ref, found_row = _ref() + missing_ref, _ = _ref() + mock_fetch.return_value = ([found_row], []) + mock_run.return_value = _PASS_RESPONSE + + _apply([missing_ref, found_ref]) + + assert mock_run.call_args.args[1] == [found_row] + + def test_only_ref_missing_upstream_is_noop(self, mock_fetch, mock_run) -> None: + ref, _ = _ref() + mock_fetch.return_value = ([], []) + + outcome = _apply([ref]) + + _assert_noop(outcome) + mock_run.assert_not_called() + + def test_fetch_failing_open_still_posts_inline_specs( + self, mock_fetch, mock_run + ) -> None: + ref, _ = _ref() + mock_fetch.return_value = ([], []) # list_validators_config's fail-open shape + mock_run.return_value = _PASS_RESPONSE + + _apply([ref, InlineValidator(type="pii_remover")]) + + assert mock_run.call_args.args[1] == [{"type": "pii_remover", "stage": "input"}] + + _SAFE_TEXT = "Please rephrase: content not allowed." _CONFIG_BLOB = ConfigBlob( completion=NativeCompletionConfig( diff --git a/backend/app/tests/services/llm/test_jobs.py b/backend/app/tests/services/llm/test_jobs.py index 81bad7eb7..7ecce7e0b 100644 --- a/backend/app/tests/services/llm/test_jobs.py +++ b/backend/app/tests/services/llm/test_jobs.py @@ -907,7 +907,13 @@ def test_proxy_output_guardrails_failure_returns_error( ): mock_fetch_configs.return_value = ( [], - [{"type": "pii_remover", "stage": "output"}], + [ + { + "id": VALIDATOR_CONFIG_ID_2, + "type": "pii_remover", + "stage": "output", + } + ], ) mock_guardrails.return_value = { "success": False, @@ -981,7 +987,13 @@ def test_proxy_output_guardrails_success_persists_sanitized_content( ): mock_fetch_configs.return_value = ( [], - [{"type": "pii_remover", "stage": "output"}], + [ + { + "id": VALIDATOR_CONFIG_ID_2, + "type": "pii_remover", + "stage": "output", + } + ], ) mock_guardrails.return_value = { "success": True, @@ -1407,7 +1419,13 @@ def test_guardrails_sanitize_input_before_provider( }, } mock_fetch_configs.return_value = ( - [{"type": "pii_remover", "stage": "input"}], + [ + { + "id": VALIDATOR_CONFIG_ID_1, + "type": "pii_remover", + "stage": "input", + } + ], [], ) @@ -1479,7 +1497,13 @@ def test_guardrails_metadata_reports_per_validator_text_for_input_guardrail( }, } mock_fetch_configs.return_value = ( - [{"type": "pii_remover", "stage": "input"}], + [ + { + "id": VALIDATOR_CONFIG_ID_1, + "type": "pii_remover", + "stage": "input", + } + ], [], ) @@ -1557,7 +1581,13 @@ def test_guardrails_metadata_omitted_by_default( }, } mock_fetch_configs.return_value = ( - [{"type": "pii_remover", "stage": "input"}], + [ + { + "id": VALIDATOR_CONFIG_ID_1, + "type": "pii_remover", + "stage": "input", + } + ], [], ) @@ -1599,7 +1629,13 @@ def test_guardrails_skip_input_validation_for_audio_input( ) as mock_fetch_configs, ): mock_fetch_configs.return_value = ( - [{"type": "pii_remover", "stage": "input"}], + [ + { + "id": VALIDATOR_CONFIG_ID_1, + "type": "pii_remover", + "stage": "input", + } + ], [], ) @@ -1660,7 +1696,13 @@ def test_guardrails_sanitize_output_after_provider( } mock_fetch_configs.return_value = ( [], - [{"type": "pii_remover", "stage": "output"}], + [ + { + "id": VALIDATOR_CONFIG_ID_2, + "type": "pii_remover", + "stage": "output", + } + ], ) request_data = { @@ -1728,7 +1770,13 @@ def test_guardrails_metadata_reports_per_validator_text_for_output_guardrail( } mock_fetch_configs.return_value = ( [], - [{"type": "pii_remover", "stage": "output"}], + [ + { + "id": VALIDATOR_CONFIG_ID_2, + "type": "pii_remover", + "stage": "output", + } + ], ) request_data = { @@ -1791,7 +1839,13 @@ def test_guardrails_output_validation_sends_input_output_pair( } mock_fetch_configs.return_value = ( [], - [{"type": "pii_remover", "stage": "output"}], + [ + { + "id": VALIDATOR_CONFIG_ID_2, + "type": "pii_remover", + "stage": "output", + } + ], ) request_data = { @@ -1845,7 +1899,13 @@ def test_guardrails_output_persists_sanitized_content_via_update_llm_call_respon } mock_fetch_configs.return_value = ( [], - [{"type": "pii_remover", "stage": "output"}], + [ + { + "id": VALIDATOR_CONFIG_ID_2, + "type": "pii_remover", + "stage": "output", + } + ], ) request_data = { @@ -1900,7 +1960,13 @@ def test_guardrails_bypass_does_not_modify_output( } mock_fetch_configs.return_value = ( [], - [{"type": "pii_remover", "stage": "output"}], + [ + { + "id": VALIDATOR_CONFIG_ID_2, + "type": "pii_remover", + "stage": "output", + } + ], ) request_data = { @@ -1950,7 +2016,13 @@ def test_guardrails_skip_output_validation_for_audio_output( ): mock_fetch_configs.return_value = ( [], - [{"type": "safety_filter", "stage": "output"}], + [ + { + "id": VALIDATOR_CONFIG_ID_2, + "type": "safety_filter", + "stage": "output", + } + ], ) request_data = { @@ -2002,7 +2074,13 @@ def test_guardrails_bypass_does_not_modify_input( }, } mock_fetch_configs.return_value = ( - [{"type": "pii_remover", "stage": "input"}], + [ + { + "id": VALIDATOR_CONFIG_ID_1, + "type": "pii_remover", + "stage": "input", + } + ], [], ) @@ -2045,7 +2123,13 @@ def test_guardrails_validation_failure_blocks_job( "error": "Unsafe content detected", } mock_fetch_configs.return_value = ( - [{"type": "uli_slur_match", "stage": "input"}], + [ + { + "id": VALIDATOR_CONFIG_ID_1, + "type": "uli_slur_match", + "stage": "input", + } + ], [], ) @@ -2094,7 +2178,13 @@ def test_guardrails_stripping_input_to_empty_blocks_job( }, } mock_fetch_configs.return_value = ( - [{"type": "topic_relevance", "stage": "input"}], + [ + { + "id": VALIDATOR_CONFIG_ID_1, + "type": "topic_relevance", + "stage": "input", + } + ], [], ) @@ -2148,7 +2238,7 @@ def test_guardrails_rephrase_needed_allows_job_with_sanitized_input( }, } mock_fetch_configs.return_value = ( - [{"type": "policy", "stage": "input"}], + [{"id": VALIDATOR_CONFIG_ID_1, "type": "policy", "stage": "input"}], [], ) @@ -2200,7 +2290,7 @@ def test_guardrails_rephrase_saves_original_input_and_safe_text_in_db( }, } mock_fetch_configs.return_value = ( - [{"type": "policy", "stage": "input"}], + [{"id": VALIDATOR_CONFIG_ID_1, "type": "policy", "stage": "input"}], [], ) @@ -2780,10 +2870,10 @@ def test_resolve_config_blob_keeps_validator_refs(self, db: Session): assert error is None assert resolved_blob is not None assert [v.model_dump() for v in (resolved_blob.input_guardrails or [])] == [ - {"validator_config_id": UUID(VALIDATOR_CONFIG_ID_1)} + {"validator_config_id": UUID(VALIDATOR_CONFIG_ID_1), "is_enabled": True} ] assert [v.model_dump() for v in (resolved_blob.output_guardrails or [])] == [ - {"validator_config_id": UUID(VALIDATOR_CONFIG_ID_2)} + {"validator_config_id": UUID(VALIDATOR_CONFIG_ID_2), "is_enabled": True} ] def test_resolve_config_blob_version_not_found(self, db: Session): diff --git a/docs/wiki/modules/llm-call.md b/docs/wiki/modules/llm-call.md index de5749b90..e72efd03b 100644 --- a/docs/wiki/modules/llm-call.md +++ b/docs/wiki/modules/llm-call.md @@ -26,7 +26,7 @@ All paths relative to `backend/app/`. ## Key pydantic/SQLModel schemas (`models/llm/request.py`) - `LLMCallConfig` — one-of: saved reference (`id` + `version`) XOR ad-hoc `blob` (validator-enforced) -- `ConfigBlob` — `completion` + optional `prompt_template` (`PromptTemplate.template`, plain string; `{{input}}` interpolation is llm-chain-only) + `input_guardrails`/`output_guardrails` +- `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. - `CompletionConfig` — discriminated union on `provider`: `KaapiCompletionConfig` (standardized params: `TextLLMParams`/`STTLLMParams`/`TTSLLMParams`), `NativeCompletionConfig` (pass-through), `ProxyCompletionConfig` (client's own endpoint) - `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. - `QueryParams` — per-call input + `ConversationConfig` From 7b004e003daa91ce18a50f00a6247439738063c9 Mon Sep 17 00:00:00 2001 From: AkhileshNegi Date: Tue, 13 Oct 2026 20:41:34 +0530 Subject: [PATCH 2/3] refactor(guardrails): Restrict inline validator types, clarify names Co-Authored-By: Claude Opus 5.5 --- backend/app/models/llm/request.py | 18 ++++++---- backend/app/services/llm/guardrails.py | 35 ++++++++----------- backend/app/tests/models/llm/test_request.py | 17 +++++++-- .../app/tests/services/llm/test_guardrails.py | 27 ++++++++++---- docs/wiki/modules/llm-call.md | 2 +- 5 files changed, 62 insertions(+), 37 deletions(-) diff --git a/backend/app/models/llm/request.py b/backend/app/models/llm/request.py index b98825540..fdc078d17 100644 --- a/backend/app/models/llm/request.py +++ b/backend/app/models/llm/request.py @@ -476,17 +476,23 @@ class Validator(SQLModel): ) +class InlineValidatorTypeEnum(str, Enum): + GENDER_ASSUMPTION_BIAS = "gender_assumption_bias" + PII_REMOVER = "pii_remover" + PROFANITY_FREE = "profanity_free" + TOPIC_RELEVANCE_LLM = "topic_relevance_llm" + + class InlineValidator(SQLModel): """Validator spec stored directly in the config blob. - Per-type params (languages, ban_list_id, ...) are extras validated by the + Per-type params (on_fail, languages, ban_list_id, ...) are extras validated by the guardrails service, so they are allowed through untouched here. """ model_config = ConfigDict(extra="allow") # pyright: ignore[reportAssignmentType] - type: str = Field(..., description="Guardrails validator type, e.g. ban_list") - on_fail: str | None = Field(default=None, description="Action on failure") + type: InlineValidatorTypeEnum = Field(..., description="Guardrails validator type") is_enabled: bool = Field( default=True, description="When false, the spec is kept in the blob but not run.", @@ -495,10 +501,8 @@ class InlineValidator(SQLModel): def _guardrail_entry_tag(value: Any) -> str: if isinstance(value, dict): - has_ref = "validator_config_id" in value - else: - has_ref = hasattr(value, "validator_config_id") - return "ref" if has_ref else "inline" + return "ref" if "validator_config_id" in value else "inline" + return "ref" if hasattr(value, "validator_config_id") else "inline" # Explicit tag: InlineValidator allows extras, so a smart union could also diff --git a/backend/app/services/llm/guardrails.py b/backend/app/services/llm/guardrails.py index 62774a95b..5a3e9d9ba 100644 --- a/backend/app/services/llm/guardrails.py +++ b/backend/app/services/llm/guardrails.py @@ -58,7 +58,7 @@ def proxy_guardrails_request( url = f"{settings.KAAPI_GUARDRAILS_URL}{path}" headers = _guardrails_headers(organization_id, project_id) # Unset query params must be omitted, not sent as empty values. - query = {k: v for k, v in (params or {}).items() if v is not None} + query = {key: value for key, value in (params or {}).items() if value is not None} try: with ( @@ -175,20 +175,16 @@ def apply_guardrails( output-guardrail validators that compare input/output pairs. Also routes the IDs through the output-config fetch path. """ - enabled: list[Validator | InlineValidator] = [] - for entry in validators or []: - if entry.is_enabled: - enabled.append(entry) + enabled = [validator for validator in validators or [] if validator.is_enabled] if not enabled: return GuardrailsOutcome( safe_text=text, error=None, bypassed=False, rephrase_needed=False, raw={} ) is_output = output_text is not None - references: list[Validator] = [] - for entry in enabled: - if isinstance(entry, Validator): - references.append(entry) + references = [ + validator for validator in enabled if isinstance(validator, Validator) + ] fetched: list[dict[str, Any]] = [] if references: @@ -206,7 +202,7 @@ def apply_guardrails( logger.info( f"[apply_guardrails] No validator configs resolved upstream; skipping " f"POST /guardrails. job_id={job_id}, requested_ids=" - f"{[str(v.validator_config_id) for v in references]}, " + f"{[str(reference.validator_config_id) for reference in references]}, " f"is_output={is_output}" ) return GuardrailsOutcome( @@ -271,9 +267,6 @@ def _prepare_validators( if isinstance(entry, InlineValidator): # The service's validator configs are extra="forbid", so is_enabled must go. spec = entry.model_dump(mode="json", exclude={"is_enabled"}) - # Service's on_fail is non-nullable; omitting it applies its default. - if spec.get("on_fail") is None: - spec.pop("on_fail", None) spec["stage"] = stage prepared.append(spec) continue @@ -312,7 +305,7 @@ def summarize_validator_results(outcome: GuardrailsOutcome) -> list[dict[str, An def run_guardrails_validation( input_text: str, - guardrail_config: Sequence[Validator | InlineValidator | dict[str, Any]], + guardrail_config: Sequence[Validator | dict[str, Any]], job_id: UUID, project_id: int | None, organization_id: int | None, @@ -335,13 +328,13 @@ def run_guardrails_validation( Returns: JSON response from the guardrails service with validation results. """ - validators: list[dict[str, Any]] = [] - for validator in guardrail_config: - if isinstance(validator, dict): - validators.append(validator) - else: - # Unknown to the service's extra="forbid" validator schemas. - validators.append(validator.model_dump(mode="json", exclude={"is_enabled"})) + # is_enabled is unknown to the service's extra="forbid" validator schemas. + validators = [ + validator + if isinstance(validator, dict) + else validator.model_dump(mode="json", exclude={"is_enabled"}) + for validator in guardrail_config + ] payload = { "request_id": str(job_id), diff --git a/backend/app/tests/models/llm/test_request.py b/backend/app/tests/models/llm/test_request.py index 682f1a816..18b5a974c 100644 --- a/backend/app/tests/models/llm/test_request.py +++ b/backend/app/tests/models/llm/test_request.py @@ -1,5 +1,8 @@ from uuid import uuid4 +import pytest +from pydantic import ValidationError + from app.models.llm.request import ( ConfigBlob, InlineValidator, @@ -98,13 +101,19 @@ def test_dict_with_config_id_parses_to_validator(self) -> None: def test_dict_with_type_parses_to_inline_and_keeps_extras(self) -> None: blob = _blob( - [{"type": "ban_list", "on_fail": "fix", "languages": ["en", "hi"]}] + [ + { + "type": "topic_relevance_llm", + "on_fail": "fix", + "languages": ["en", "hi"], + } + ] ) (entry,) = blob.input_guardrails or [] assert isinstance(entry, InlineValidator) assert entry.model_dump() == { - "type": "ban_list", + "type": "topic_relevance_llm", "on_fail": "fix", "is_enabled": True, "languages": ["en", "hi"], @@ -123,6 +132,10 @@ def test_ref_dict_carrying_type_still_parses_to_validator(self) -> None: "is_enabled": True, } + def test_unsupported_inline_type_is_rejected(self) -> None: + with pytest.raises(ValidationError): + _blob([{"type": "ban_list"}]) + def test_revalidating_parsed_entries_keeps_types(self) -> None: parsed = _blob( [{"validator_config_id": str(uuid4())}, {"type": "pii_remover"}] diff --git a/backend/app/tests/services/llm/test_guardrails.py b/backend/app/tests/services/llm/test_guardrails.py index 7caa81a4c..4e842a2fc 100644 --- a/backend/app/tests/services/llm/test_guardrails.py +++ b/backend/app/tests/services/llm/test_guardrails.py @@ -475,7 +475,7 @@ def test_disabled_entries_are_neither_fetched_nor_posted( [ disabled_ref, enabled_ref, - InlineValidator(type="off_inline", is_enabled=False), + InlineValidator(type="profanity_free", is_enabled=False), ] ) @@ -522,12 +522,19 @@ def test_posts_in_blob_order_not_fetch_order(self, mock_fetch, mock_run) -> None mock_fetch.return_value = ([row_y, row_x], []) mock_run.return_value = _PASS_RESPONSE - _apply([InlineValidator(type="a"), ref_x, InlineValidator(type="b"), ref_y]) + _apply( + [ + InlineValidator(type="profanity_free"), + ref_x, + InlineValidator(type="pii_remover"), + ref_y, + ] + ) assert mock_run.call_args.args[1] == [ - {"type": "a", "stage": "input"}, + {"type": "profanity_free", "stage": "input"}, row_x, - {"type": "b", "stage": "input"}, + {"type": "pii_remover", "stage": "input"}, row_y, ] @@ -536,13 +543,21 @@ def test_inline_spec_posted_shape(self, mock_fetch, mock_run) -> None: _apply( [ - InlineValidator(type="ban_list", stage="output", banned_words=["x"]), + InlineValidator( + type="gender_assumption_bias", + stage="output", + categories=["healthcare"], + ), InlineValidator(type="pii_remover", on_fail="exception"), ] ) assert mock_run.call_args.args[1] == [ - {"type": "ban_list", "stage": "input", "banned_words": ["x"]}, + { + "type": "gender_assumption_bias", + "stage": "input", + "categories": ["healthcare"], + }, {"type": "pii_remover", "on_fail": "exception", "stage": "input"}, ] diff --git a/docs/wiki/modules/llm-call.md b/docs/wiki/modules/llm-call.md index e72efd03b..662b20ffd 100644 --- a/docs/wiki/modules/llm-call.md +++ b/docs/wiki/modules/llm-call.md @@ -26,7 +26,7 @@ All paths relative to `backend/app/`. ## Key pydantic/SQLModel schemas (`models/llm/request.py`) - `LLMCallConfig` — one-of: saved reference (`id` + `version`) XOR ad-hoc `blob` (validator-enforced) -- `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. +- `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. - `CompletionConfig` — discriminated union on `provider`: `KaapiCompletionConfig` (standardized params: `TextLLMParams`/`STTLLMParams`/`TTSLLMParams`), `NativeCompletionConfig` (pass-through), `ProxyCompletionConfig` (client's own endpoint) - `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. - `QueryParams` — per-call input + `ConversationConfig` From b391849c7586a482c229a8ab926b1212e6cac718 Mon Sep 17 00:00:00 2001 From: AkhileshNegi Date: Wed, 14 Oct 2026 11:21:51 +0530 Subject: [PATCH 3/3] refactor(guardrails): Drop log on unresolved validator configs Co-Authored-By: Claude Opus 5.5 --- backend/app/services/llm/guardrails.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/backend/app/services/llm/guardrails.py b/backend/app/services/llm/guardrails.py index c92926dc6..5a087c4db 100644 --- a/backend/app/services/llm/guardrails.py +++ b/backend/app/services/llm/guardrails.py @@ -204,12 +204,6 @@ def apply_guardrails( stage = GUARDRAIL_STAGE_OUTPUT if is_output else GUARDRAIL_STAGE_INPUT resolved = _prepare_validators(enabled, fetched, stage, job_id) if not resolved: - logger.info( - f"[apply_guardrails] No validator configs resolved upstream; skipping " - f"POST /guardrails. job_id={job_id}, requested_ids=" - f"{[str(reference.validator_config_id) for reference in references]}, " - f"is_output={is_output}" - ) return GuardrailsOutcome( safe_text=text, error=None, bypassed=False, rephrase_needed=False, raw={} )