diff --git a/backend/app/models/llm/request.py b/backend/app/models/llm/request.py index 870d1ef1c..fdc078d17 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,50 @@ 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 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 (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: 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.", + ) + + +def _guardrail_entry_tag(value: Any) -> str: + if isinstance(value, dict): + 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 +# 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 +546,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 3adcf1970..5a087c4db 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 @@ -54,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 ( @@ -153,7 +157,7 @@ def blocked(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, @@ -169,32 +173,37 @@ 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 = [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 - 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 - 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"is_output={is_output}" + references = [ + validator for validator in enabled if isinstance(validator, Validator) + ] + + 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: return GuardrailsOutcome( safe_text=text, error=None, bypassed=False, rephrase_needed=False, raw={} ) @@ -242,6 +251,37 @@ 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"}) + 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.""" @@ -287,10 +327,11 @@ def run_guardrails_validation( Returns: JSON response from the guardrails service with validation results. """ + # is_enabled is unknown to the service's extra="forbid" validator schemas. validators = [ - validator.model_dump(mode="json") - if isinstance(validator, Validator) - else validator + validator + if isinstance(validator, dict) + else validator.model_dump(mode="json", exclude={"is_enabled"}) for validator in guardrail_config ] diff --git a/backend/app/tests/models/llm/test_request.py b/backend/app/tests/models/llm/test_request.py index fdc1e5cd5..18b5a974c 100644 --- a/backend/app/tests/models/llm/test_request.py +++ b/backend/app/tests/models/llm/test_request.py @@ -1,4 +1,15 @@ -from app.models.llm.request import build_kaapi_completion_config +from uuid import uuid4 + +import pytest +from pydantic import ValidationError + +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 +78,86 @@ 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": "topic_relevance_llm", + "on_fail": "fix", + "languages": ["en", "hi"], + } + ] + ) + + (entry,) = blob.input_guardrails or [] + assert isinstance(entry, InlineValidator) + assert entry.model_dump() == { + "type": "topic_relevance_llm", + "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_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"}] + ).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 eaf84e5b1..6c25fe2b1 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, @@ -428,6 +429,183 @@ 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="profanity_free", 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="profanity_free"), + ref_x, + InlineValidator(type="pii_remover"), + ref_y, + ] + ) + + assert mock_run.call_args.args[1] == [ + {"type": "profanity_free", "stage": "input"}, + row_x, + {"type": "pii_remover", "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="gender_assumption_bias", + stage="output", + categories=["healthcare"], + ), + InlineValidator(type="pii_remover", on_fail="exception"), + ] + ) + + assert mock_run.call_args.args[1] == [ + { + "type": "gender_assumption_bias", + "stage": "input", + "categories": ["healthcare"], + }, + {"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 5df13faf0..1f01fcdd3 100644 --- a/docs/wiki/modules/llm-call.md +++ b/docs/wiki/modules/llm-call.md @@ -27,7 +27,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_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}`, `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`