1use std::collections::HashMap;
7use std::future::Future;
8use std::pin::Pin;
9use std::sync::Arc;
10
11use serde::{Deserialize, Serialize};
12use serde_json::{Map, Value as Json};
13
14use crate::codec::resolve::supported_codec_names;
15use crate::plugin::{
16 ConfigDiagnostic, ConfigPolicy, DiagnosticLevel, Plugin, PluginComponentSpec, PluginError,
17 PluginRegistrationContext, Result as PluginResult, UnsupportedBehavior,
18 apply_global_config_policy, deregister_plugin, register_builtin_plugin,
19};
20
21#[path = "local.rs"]
22mod local;
23#[cfg(feature = "guardrails-remote")]
24#[path = "remote.rs"]
25mod remote;
26use local::register_local_backend;
27#[cfg(feature = "guardrails-remote")]
28use remote::register_remote_backend;
29
30pub const NEMO_GUARDRAILS_PLUGIN_KIND: &str = "nemo_guardrails";
32
33#[cfg(not(feature = "guardrails-remote"))]
34fn register_remote_backend(
35 _config: NeMoGuardrailsConfig,
36 _ctx: &mut PluginRegistrationContext,
37) -> PluginResult<()> {
38 Err(PluginError::RegistrationFailed(
39 "built-in NeMo Guardrails remote backend is unavailable in this build".to_string(),
40 ))
41}
42
43#[derive(Debug, Clone)]
45pub struct ComponentSpec {
46 pub enabled: bool,
48 pub config: NeMoGuardrailsConfig,
50}
51
52impl ComponentSpec {
53 pub fn new(config: NeMoGuardrailsConfig) -> Self {
55 Self {
56 enabled: true,
57 config,
58 }
59 }
60}
61
62impl From<ComponentSpec> for PluginComponentSpec {
63 fn from(value: ComponentSpec) -> Self {
64 let Json::Object(config) = serde_json::to_value(value.config)
65 .expect("NeMo Guardrails config should serialize to an object")
66 else {
67 unreachable!("NeMo Guardrails config must serialize to an object");
68 };
69
70 PluginComponentSpec {
71 kind: NEMO_GUARDRAILS_PLUGIN_KIND.to_string(),
72 enabled: value.enabled,
73 config,
74 }
75 }
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
81pub struct NeMoGuardrailsConfig {
82 #[serde(default = "default_nemo_guardrails_config_version")]
84 pub version: u32,
85 #[serde(default = "default_mode")]
87 #[cfg_attr(feature = "schema", schemars(schema_with = "mode_schema"))]
88 pub mode: String,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub config_path: Option<String>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub config_yaml: Option<String>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
97 pub colang_content: Option<String>,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
100 #[cfg_attr(feature = "schema", schemars(schema_with = "codec_schema"))]
101 pub codec: Option<String>,
102 #[serde(default = "default_true")]
104 pub input: bool,
105 #[serde(default = "default_true")]
107 pub output: bool,
108 #[serde(default)]
110 pub tool_input: bool,
111 #[serde(default)]
113 pub tool_output: bool,
114 #[serde(default = "default_priority")]
116 pub priority: i32,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub remote: Option<RemoteBackendConfig>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub local: Option<LocalBackendConfig>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub request_defaults: Option<RequestDefaultsConfig>,
129 #[serde(default)]
131 pub policy: ConfigPolicy,
132}
133
134impl Default for NeMoGuardrailsConfig {
135 fn default() -> Self {
136 Self {
137 version: default_nemo_guardrails_config_version(),
138 mode: default_mode(),
139 config_path: None,
140 config_yaml: None,
141 colang_content: None,
142 codec: None,
143 input: true,
144 output: true,
145 tool_input: false,
146 tool_output: false,
147 priority: default_priority(),
148 remote: None,
149 local: None,
150 request_defaults: None,
151 policy: ConfigPolicy::default(),
152 }
153 }
154}
155
156#[derive(Debug, Clone, Serialize, Deserialize)]
158#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
159pub struct RemoteBackendConfig {
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub endpoint: Option<String>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub config_id: Option<String>,
166 #[serde(default, skip_serializing_if = "Vec::is_empty")]
168 pub config_ids: Vec<String>,
169 #[serde(default)]
171 pub headers: HashMap<String, String>,
172 #[serde(default = "default_timeout_millis")]
174 pub timeout_millis: u64,
175}
176
177impl Default for RemoteBackendConfig {
178 fn default() -> Self {
179 Self {
180 endpoint: None,
181 config_id: None,
182 config_ids: vec![],
183 headers: HashMap::new(),
184 timeout_millis: default_timeout_millis(),
185 }
186 }
187}
188
189#[derive(Debug, Clone, Default, Serialize, Deserialize)]
191#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
192pub struct LocalBackendConfig {
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub python_module: Option<String>,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub python_executable: Option<String>,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub python_path: Option<String>,
202}
203
204#[derive(Debug, Clone, Default, Serialize, Deserialize)]
206#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
207pub struct RequestDefaultsConfig {
208 #[serde(default, skip_serializing_if = "Option::is_none")]
210 pub context: Option<Json>,
211 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub thread_id: Option<String>,
214 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub state: Option<Json>,
217 #[serde(default, skip_serializing_if = "Option::is_none")]
219 pub rails: Option<RequestRailsConfig>,
220 #[serde(default, skip_serializing_if = "Option::is_none")]
222 pub llm_params: Option<Json>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub llm_output: Option<bool>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub output_vars: Option<Json>,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub log: Option<Json>,
232}
233
234#[derive(Debug, Clone, Default, Serialize, Deserialize)]
239#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
240pub struct RequestRailsConfig {
241 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub input: Option<RailSelector>,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
246 pub output: Option<RailSelector>,
247 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub retrieval: Option<RailSelector>,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub dialog: Option<bool>,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub tool_output: Option<RailSelector>,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub tool_input: Option<RailSelector>,
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize)]
263#[serde(untagged)]
264#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
265pub enum RailSelector {
266 Enabled(bool),
268 Named(Vec<String>),
270}
271
272crate::editor_config! {
273 impl NeMoGuardrailsConfig {
274 mode => {
275 label: "mode",
276 kind: Enum,
277 values: ["remote", "local"],
278 },
279 config_path => { label: "config_path", kind: String, optional: true },
280 config_yaml => { label: "config_yaml", kind: String, optional: true },
281 colang_content => { label: "colang_content", kind: String, optional: true },
282 codec => {
283 label: "codec",
284 kind: Enum,
285 values: ["openai_chat", "openai_responses", "anthropic_messages"],
286 optional: true,
287 },
288 input => { label: "input", kind: Boolean },
289 output => { label: "output", kind: Boolean },
290 tool_input => { label: "tool_input", kind: Boolean },
291 tool_output => { label: "tool_output", kind: Boolean },
292 priority => { label: "priority", kind: Integer },
293 remote => {
294 label: "remote",
295 kind: Section,
296 optional: true,
297 nested: RemoteBackendConfig,
298 default: RemoteBackendConfig,
299 },
300 local => {
301 label: "local",
302 kind: Section,
303 optional: true,
304 nested: LocalBackendConfig,
305 default: LocalBackendConfig,
306 },
307 request_defaults => {
308 label: "request_defaults",
309 kind: Section,
310 optional: true,
311 nested: RequestDefaultsConfig,
312 default: RequestDefaultsConfig,
313 },
314 policy => {
315 label: "policy",
316 kind: Section,
317 nested: ConfigPolicy,
318 default: ConfigPolicy,
319 },
320 }
321}
322
323crate::editor_config! {
324 impl RemoteBackendConfig {
325 endpoint => { label: "endpoint", kind: String, optional: true },
326 config_id => { label: "config_id", kind: String, optional: true },
327 config_ids => { label: "config_ids", kind: List, list: &crate::config_editor::STRING_LIST_ITEM },
328 headers => { label: "headers", kind: StringMap },
329 timeout_millis => { label: "timeout_millis", kind: Integer },
330 }
331}
332
333crate::editor_config! {
334 impl LocalBackendConfig {
335 python_module => { label: "python_module", kind: String, optional: true },
336 python_executable => { label: "python_executable", kind: String, optional: true },
337 python_path => { label: "python_path", kind: String, optional: true },
338 }
339}
340
341crate::editor_config! {
342 impl RequestDefaultsConfig {
343 context => { label: "context", kind: Json, optional: true },
344 thread_id => { label: "thread_id", kind: String, optional: true },
345 state => { label: "state", kind: Json, optional: true },
346 rails => {
347 label: "rails",
348 kind: Section,
349 optional: true,
350 nested: RequestRailsConfig,
351 default: RequestRailsConfig,
352 },
353 llm_params => { label: "llm_params", kind: Json, optional: true },
354 llm_output => { label: "llm_output", kind: Boolean, optional: true },
355 output_vars => { label: "output_vars", kind: Json, optional: true },
356 log => { label: "log", kind: Json, optional: true },
357 }
358}
359
360crate::editor_config! {
361 impl RequestRailsConfig {
362 input => { label: "input", kind: Json, optional: true },
363 output => { label: "output", kind: Json, optional: true },
364 retrieval => { label: "retrieval", kind: Json, optional: true },
365 dialog => { label: "dialog", kind: Boolean, optional: true },
366 tool_output => { label: "tool_output", kind: Json, optional: true },
367 tool_input => { label: "tool_input", kind: Json, optional: true },
368 }
369}
370
371struct NeMoGuardrailsPlugin;
372
373impl Plugin for NeMoGuardrailsPlugin {
374 fn plugin_kind(&self) -> &str {
375 NEMO_GUARDRAILS_PLUGIN_KIND
376 }
377
378 fn allows_multiple_components(&self) -> bool {
379 false
380 }
381
382 fn validate(&self, plugin_config: &Map<String, Json>) -> Vec<ConfigDiagnostic> {
383 validate_nemo_guardrails_plugin_config(plugin_config)
384 }
385
386 fn validate_with_policy(
387 &self,
388 plugin_config: &Map<String, Json>,
389 policy: &ConfigPolicy,
390 ) -> Vec<ConfigDiagnostic> {
391 validate_nemo_guardrails_plugin_config_with_policy(plugin_config, Some(policy))
392 }
393
394 fn register<'a>(
395 &'a self,
396 plugin_config: &Map<String, Json>,
397 ctx: &'a mut PluginRegistrationContext,
398 ) -> Pin<Box<dyn Future<Output = PluginResult<()>> + Send + 'a>> {
399 let parsed = parse_nemo_guardrails_config(plugin_config);
400 Box::pin(async move {
401 let config = parsed?;
402 register_nemo_guardrails_backend(config, ctx)
403 })
404 }
405}
406
407pub fn register_nemo_guardrails_component() -> PluginResult<()> {
409 register_builtin_plugin(Arc::new(NeMoGuardrailsPlugin))
410}
411
412pub fn deregister_nemo_guardrails_component() -> bool {
414 deregister_plugin(NEMO_GUARDRAILS_PLUGIN_KIND)
415}
416
417#[cfg(feature = "schema")]
419pub fn nemo_guardrails_config_schema() -> serde_json::Value {
420 serde_json::to_value(schemars::schema_for!(NeMoGuardrailsConfig))
421 .expect("NeMo Guardrails config schema should serialize")
422}
423
424#[cfg(feature = "schema")]
425fn mode_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
426 string_enum_schema(generator, &["remote", "local"], Some("remote"))
427}
428
429#[cfg(feature = "schema")]
430fn codec_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
431 string_enum_schema(
432 generator,
433 &["openai_chat", "openai_responses", "anthropic_messages"],
434 None,
435 )
436}
437
438#[cfg(feature = "schema")]
439fn string_enum_schema(
440 generator: &mut schemars::r#gen::SchemaGenerator,
441 values: &[&str],
442 default: Option<&str>,
443) -> schemars::schema::Schema {
444 let mut schema: schemars::schema::SchemaObject =
445 <String as schemars::JsonSchema>::json_schema(generator).into();
446 schema.enum_values = Some(
447 values
448 .iter()
449 .map(|value| Json::String((*value).into()))
450 .collect(),
451 );
452 if let Some(default) = default {
453 schema.metadata().default = Some(Json::String(default.into()));
454 }
455 schema.into()
456}
457
458fn register_nemo_guardrails_backend(
459 config: NeMoGuardrailsConfig,
460 ctx: &mut PluginRegistrationContext,
461) -> PluginResult<()> {
462 match config.mode.as_str() {
463 "remote" => register_remote_backend(config, ctx),
464 "local" => register_local_backend(config, ctx),
465 other => Err(PluginError::InvalidConfig(format!(
466 "unsupported NeMo Guardrails mode '{other}'"
467 ))),
468 }
469}
470
471fn parse_nemo_guardrails_config(
472 plugin_config: &Map<String, Json>,
473) -> PluginResult<NeMoGuardrailsConfig> {
474 serde_json::from_value(Json::Object(plugin_config.clone())).map_err(|err| {
475 PluginError::InvalidConfig(format!("invalid NeMo Guardrails plugin config: {err}"))
476 })
477}
478
479fn validate_nemo_guardrails_plugin_config(
480 plugin_config: &Map<String, Json>,
481) -> Vec<ConfigDiagnostic> {
482 validate_nemo_guardrails_plugin_config_with_policy(plugin_config, None)
483}
484
485fn validate_nemo_guardrails_plugin_config_with_policy(
486 plugin_config: &Map<String, Json>,
487 policy: Option<&ConfigPolicy>,
488) -> Vec<ConfigDiagnostic> {
489 let mut config = match parse_nemo_guardrails_config(plugin_config) {
490 Ok(config) => config,
491 Err(err) => {
492 return vec![ConfigDiagnostic {
493 level: DiagnosticLevel::Error,
494 code: "nemo_guardrails.invalid_plugin_config".to_string(),
495 component: Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()),
496 field: None,
497 message: err.to_string(),
498 }];
499 }
500 };
501 if let Some(policy) = policy {
502 config.policy = apply_global_config_policy(config.policy, policy);
503 }
504
505 let mut diagnostics = vec![];
506
507 validate_unknown_fields(
508 &mut diagnostics,
509 &config.policy,
510 Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()),
511 plugin_config,
512 &[
513 "version",
514 "mode",
515 "config_path",
516 "config_yaml",
517 "colang_content",
518 "codec",
519 "input",
520 "output",
521 "tool_input",
522 "tool_output",
523 "priority",
524 "remote",
525 "local",
526 "request_defaults",
527 "policy",
528 ],
529 );
530
531 validate_policy_fields(&mut diagnostics, &config.policy, plugin_config);
532 validate_section_fields(
533 &mut diagnostics,
534 &config.policy,
535 plugin_config,
536 "remote",
537 &[
538 "endpoint",
539 "config_id",
540 "config_ids",
541 "headers",
542 "timeout_millis",
543 ],
544 );
545 validate_section_fields(
546 &mut diagnostics,
547 &config.policy,
548 plugin_config,
549 "local",
550 &["python_module", "python_executable", "python_path"],
551 );
552 validate_section_fields(
553 &mut diagnostics,
554 &config.policy,
555 plugin_config,
556 "request_defaults",
557 &[
558 "context",
559 "thread_id",
560 "state",
561 "rails",
562 "llm_params",
563 "llm_output",
564 "output_vars",
565 "log",
566 ],
567 );
568 validate_nested_section_fields(
569 &mut diagnostics,
570 &config.policy,
571 plugin_config,
572 "request_defaults",
573 "rails",
574 &[
575 "input",
576 "output",
577 "retrieval",
578 "dialog",
579 "tool_output",
580 "tool_input",
581 ],
582 );
583
584 validate_version(&mut diagnostics, &config.policy, config.version);
585 validate_mode(&mut diagnostics, &config.policy, &config.mode);
586 validate_non_empty_strings(&mut diagnostics, &config.policy, &config);
587 validate_config_shape(&mut diagnostics, &config.policy, &config);
588 validate_codec_requirements(&mut diagnostics, &config.policy, &config);
589 validate_surface_selection(&mut diagnostics, &config.policy, &config);
590 validate_remote_backend_support(&mut diagnostics, &config.policy, &config);
591 validate_request_defaults(&mut diagnostics, &config.policy, &config);
592
593 diagnostics
594}
595
596fn validate_version(diagnostics: &mut Vec<ConfigDiagnostic>, policy: &ConfigPolicy, version: u32) {
597 if version != 1 {
598 push_policy_diag(
599 diagnostics,
600 policy.unsupported_value,
601 "nemo_guardrails.unsupported_config_version",
602 Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()),
603 Some("version".to_string()),
604 format!("NeMo Guardrails config version {version} is unsupported"),
605 );
606 }
607}
608
609fn validate_mode(diagnostics: &mut Vec<ConfigDiagnostic>, policy: &ConfigPolicy, mode: &str) {
610 if !matches!(mode, "remote" | "local") {
611 push_policy_diag(
612 diagnostics,
613 policy.unsupported_value,
614 "nemo_guardrails.unsupported_value",
615 Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()),
616 Some("mode".to_string()),
617 "mode must be 'remote' or 'local'".to_string(),
618 );
619 }
620}
621
622fn validate_non_empty_strings(
623 diagnostics: &mut Vec<ConfigDiagnostic>,
624 policy: &ConfigPolicy,
625 config: &NeMoGuardrailsConfig,
626) {
627 validate_optional_non_empty_string(
628 diagnostics,
629 policy,
630 "config_path",
631 config.config_path.as_deref(),
632 "config_path must not be empty",
633 );
634 validate_optional_non_empty_string(
635 diagnostics,
636 policy,
637 "config_yaml",
638 config.config_yaml.as_deref(),
639 "config_yaml must not be empty",
640 );
641 validate_optional_non_empty_string(
642 diagnostics,
643 policy,
644 "colang_content",
645 config.colang_content.as_deref(),
646 "colang_content must not be empty",
647 );
648
649 if let Some(remote) = &config.remote {
650 validate_remote_non_empty_strings(diagnostics, policy, remote);
651 }
652
653 if let Some(local) = &config.local {
654 validate_local_non_empty_strings(diagnostics, policy, local);
655 }
656}
657
658fn validate_remote_non_empty_strings(
659 diagnostics: &mut Vec<ConfigDiagnostic>,
660 policy: &ConfigPolicy,
661 remote: &RemoteBackendConfig,
662) {
663 validate_optional_non_empty_string(
664 diagnostics,
665 policy,
666 "remote.endpoint",
667 remote.endpoint.as_deref(),
668 "remote.endpoint must not be empty",
669 );
670 validate_optional_non_empty_string(
671 diagnostics,
672 policy,
673 "remote.config_id",
674 remote.config_id.as_deref(),
675 "remote.config_id must not be empty",
676 );
677 for (index, config_id) in remote.config_ids.iter().enumerate() {
678 validate_optional_non_empty_string(
679 diagnostics,
680 policy,
681 format!("remote.config_ids[{index}]"),
682 Some(config_id.as_str()),
683 "remote.config_ids entries must not be empty",
684 );
685 }
686}
687
688fn validate_local_non_empty_strings(
689 diagnostics: &mut Vec<ConfigDiagnostic>,
690 policy: &ConfigPolicy,
691 local: &LocalBackendConfig,
692) {
693 validate_optional_non_empty_string(
694 diagnostics,
695 policy,
696 "local.python_module",
697 local.python_module.as_deref(),
698 "local.python_module must not be empty",
699 );
700 validate_optional_non_empty_string(
701 diagnostics,
702 policy,
703 "local.python_executable",
704 local.python_executable.as_deref(),
705 "local.python_executable must not be empty",
706 );
707 validate_optional_non_empty_string(
708 diagnostics,
709 policy,
710 "local.python_path",
711 local.python_path.as_deref(),
712 "local.python_path must not be empty",
713 );
714}
715
716fn validate_optional_non_empty_string(
717 diagnostics: &mut Vec<ConfigDiagnostic>,
718 policy: &ConfigPolicy,
719 field: impl Into<String>,
720 value: Option<&str>,
721 message: &str,
722) {
723 if let Some(value) = value
724 && value.trim().is_empty()
725 {
726 push_policy_diag(
727 diagnostics,
728 policy.unsupported_value,
729 "nemo_guardrails.unsupported_value",
730 Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()),
731 Some(field.into()),
732 message.to_string(),
733 );
734 }
735}
736
737fn validate_config_shape(
738 diagnostics: &mut Vec<ConfigDiagnostic>,
739 policy: &ConfigPolicy,
740 config: &NeMoGuardrailsConfig,
741) {
742 let flags = ConfigShapeFlags::from(config);
743
744 match config.mode.as_str() {
745 "local" => validate_local_config_shape(diagnostics, policy, config, &flags),
746 "remote" => validate_remote_config_shape(diagnostics, policy, config, &flags),
747 _ => {}
748 }
749}
750
751struct ConfigShapeFlags {
752 has_config_path: bool,
753 has_config_yaml: bool,
754 has_colang_content: bool,
755 has_remote_config_id: bool,
756 has_remote_config_ids: bool,
757}
758
759impl From<&NeMoGuardrailsConfig> for ConfigShapeFlags {
760 fn from(config: &NeMoGuardrailsConfig) -> Self {
761 Self {
762 has_config_path: config.config_path.is_some(),
763 has_config_yaml: config.config_yaml.is_some(),
764 has_colang_content: config.colang_content.is_some(),
765 has_remote_config_id: config
766 .remote
767 .as_ref()
768 .and_then(|remote| remote.config_id.as_ref())
769 .is_some(),
770 has_remote_config_ids: config
771 .remote
772 .as_ref()
773 .map(|remote| !remote.config_ids.is_empty())
774 .unwrap_or(false),
775 }
776 }
777}
778
779fn validate_local_config_shape(
780 diagnostics: &mut Vec<ConfigDiagnostic>,
781 policy: &ConfigPolicy,
782 config: &NeMoGuardrailsConfig,
783 flags: &ConfigShapeFlags,
784) {
785 if flags.has_config_path == flags.has_config_yaml {
786 push_config_shape_diag(
787 diagnostics,
788 policy.unsupported_value,
789 "nemo_guardrails.invalid_config_source",
790 None,
791 "exactly one of config_path or config_yaml is required in local mode",
792 );
793 }
794
795 if flags.has_colang_content && !flags.has_config_yaml {
796 push_config_shape_diag(
797 diagnostics,
798 policy.unsupported_value,
799 "nemo_guardrails.unsupported_value",
800 Some("colang_content"),
801 "colang_content can only be used with config_yaml",
802 );
803 }
804
805 if config.remote.is_some() {
806 push_config_shape_diag(
807 diagnostics,
808 policy.unsupported_value,
809 "nemo_guardrails.unsupported_value",
810 Some("remote"),
811 "remote backend settings cannot be used when mode is 'local'",
812 );
813 }
814}
815
816fn validate_remote_config_shape(
817 diagnostics: &mut Vec<ConfigDiagnostic>,
818 policy: &ConfigPolicy,
819 config: &NeMoGuardrailsConfig,
820 flags: &ConfigShapeFlags,
821) {
822 if flags.has_config_path || flags.has_config_yaml || flags.has_colang_content {
823 push_config_shape_diag(
824 diagnostics,
825 policy.unsupported_value,
826 "nemo_guardrails.invalid_config_source",
827 None,
828 "remote mode uses remote config identity and cannot include config_path, config_yaml, or colang_content",
829 );
830 }
831
832 if config.local.is_some() {
833 push_config_shape_diag(
834 diagnostics,
835 policy.unsupported_value,
836 "nemo_guardrails.unsupported_value",
837 Some("local"),
838 "local backend settings cannot be used when mode is 'remote'",
839 );
840 }
841
842 match &config.remote {
843 Some(remote)
844 if remote
845 .endpoint
846 .as_ref()
847 .is_some_and(|value| !value.trim().is_empty()) => {}
848 _ => push_config_shape_diag(
849 diagnostics,
850 policy.unsupported_value,
851 "nemo_guardrails.unsupported_value",
852 Some("remote.endpoint"),
853 "remote.endpoint is required when mode is 'remote'",
854 ),
855 }
856
857 if flags.has_remote_config_id && flags.has_remote_config_ids {
858 push_config_shape_diag(
859 diagnostics,
860 policy.unsupported_value,
861 "nemo_guardrails.unsupported_value",
862 Some("remote"),
863 "remote.config_id and remote.config_ids cannot be used together",
864 );
865 }
866
867 if !(flags.has_remote_config_id || flags.has_remote_config_ids) {
868 push_config_shape_diag(
869 diagnostics,
870 policy.unsupported_value,
871 "nemo_guardrails.invalid_config_source",
872 None,
873 "remote mode requires remote.config_id or remote.config_ids",
874 );
875 }
876}
877
878fn push_config_shape_diag(
879 diagnostics: &mut Vec<ConfigDiagnostic>,
880 behavior: UnsupportedBehavior,
881 code: &str,
882 field: Option<&str>,
883 message: &str,
884) {
885 push_policy_diag(
886 diagnostics,
887 behavior,
888 code,
889 Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()),
890 field.map(str::to_string),
891 message.to_string(),
892 );
893}
894
895fn validate_codec_requirements(
896 diagnostics: &mut Vec<ConfigDiagnostic>,
897 policy: &ConfigPolicy,
898 config: &NeMoGuardrailsConfig,
899) {
900 let llm_surface_enabled = config.input || config.output;
901 if !llm_surface_enabled {
902 return;
903 }
904
905 let Some(codec) = config.codec.as_deref() else {
906 push_policy_diag(
907 diagnostics,
908 policy.unsupported_value,
909 "nemo_guardrails.unsupported_value",
910 Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()),
911 Some("codec".to_string()),
912 "codec is required when any LLM surface is enabled".to_string(),
913 );
914 return;
915 };
916
917 if !supported_codec_names().contains(&codec) {
918 push_policy_diag(
919 diagnostics,
920 policy.unsupported_value,
921 "nemo_guardrails.unsupported_value",
922 Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()),
923 Some("codec".to_string()),
924 "codec must be 'openai_chat', 'openai_responses', or 'anthropic_messages'".to_string(),
925 );
926 }
927}
928
929fn validate_surface_selection(
930 diagnostics: &mut Vec<ConfigDiagnostic>,
931 policy: &ConfigPolicy,
932 config: &NeMoGuardrailsConfig,
933) {
934 if config.input || config.output || config.tool_input || config.tool_output {
935 return;
936 }
937
938 push_policy_diag(
939 diagnostics,
940 policy.unsupported_value,
941 "nemo_guardrails.unsupported_value",
942 Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()),
943 None,
944 "at least one Guardrails surface must be enabled".to_string(),
945 );
946}
947
948fn validate_remote_backend_support(
949 diagnostics: &mut Vec<ConfigDiagnostic>,
950 policy: &ConfigPolicy,
951 config: &NeMoGuardrailsConfig,
952) {
953 if config.mode != "remote" {
954 return;
955 }
956
957 if (config.input || config.output)
958 && config
959 .codec
960 .as_deref()
961 .is_some_and(|codec| codec != "openai_chat")
962 {
963 push_policy_diag(
964 diagnostics,
965 policy.unsupported_value,
966 "nemo_guardrails.unsupported_value",
967 Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()),
968 Some("codec".to_string()),
969 "remote mode currently supports only codec = 'openai_chat'".to_string(),
970 );
971 }
972
973 if config.tool_input {
974 push_policy_diag(
975 diagnostics,
976 policy.unsupported_value,
977 "nemo_guardrails.unsupported_value",
978 Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()),
979 Some("tool_input".to_string()),
980 "remote mode does not currently support managed tool_input against the stock Guardrails remote contract".to_string(),
981 );
982 }
983}
984
985fn validate_request_defaults(
986 diagnostics: &mut Vec<ConfigDiagnostic>,
987 policy: &ConfigPolicy,
988 config: &NeMoGuardrailsConfig,
989) {
990 let Some(request_defaults) = &config.request_defaults else {
991 return;
992 };
993
994 if config.mode == "local" {
995 push_policy_diag(
996 diagnostics,
997 policy.unsupported_value,
998 "nemo_guardrails.unsupported_value",
999 Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()),
1000 Some("request_defaults".to_string()),
1001 "local mode does not currently support request_defaults".to_string(),
1002 );
1003 return;
1004 }
1005
1006 validate_json_object_field(
1007 diagnostics,
1008 policy,
1009 request_defaults.context.as_ref(),
1010 "request_defaults.context",
1011 "request_defaults.context must be a JSON object",
1012 );
1013 validate_request_thread_id(diagnostics, policy, request_defaults.thread_id.as_deref());
1014 validate_json_object_field(
1015 diagnostics,
1016 policy,
1017 request_defaults.state.as_ref(),
1018 "request_defaults.state",
1019 "request_defaults.state must be a JSON object",
1020 );
1021 validate_request_state_keys(diagnostics, policy, request_defaults.state.as_ref());
1022 validate_json_object_field(
1023 diagnostics,
1024 policy,
1025 request_defaults.llm_params.as_ref(),
1026 "request_defaults.llm_params",
1027 "request_defaults.llm_params must be a JSON object",
1028 );
1029 validate_json_object_field(
1030 diagnostics,
1031 policy,
1032 request_defaults.log.as_ref(),
1033 "request_defaults.log",
1034 "request_defaults.log must be a JSON object",
1035 );
1036
1037 validate_output_vars(diagnostics, policy, request_defaults.output_vars.as_ref());
1038 validate_request_rails(diagnostics, policy, request_defaults.rails.as_ref());
1039}
1040
1041fn push_request_defaults_diag(
1042 diagnostics: &mut Vec<ConfigDiagnostic>,
1043 policy: &ConfigPolicy,
1044 field: &str,
1045 message: &str,
1046) {
1047 push_policy_diag(
1048 diagnostics,
1049 policy.unsupported_value,
1050 "nemo_guardrails.unsupported_value",
1051 Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()),
1052 Some(field.to_string()),
1053 message.to_string(),
1054 );
1055}
1056
1057fn validate_request_thread_id(
1058 diagnostics: &mut Vec<ConfigDiagnostic>,
1059 policy: &ConfigPolicy,
1060 thread_id: Option<&str>,
1061) {
1062 let Some(thread_id) = thread_id else {
1063 return;
1064 };
1065
1066 let trimmed_thread_id = thread_id.trim();
1067 if trimmed_thread_id.is_empty() {
1068 push_request_defaults_diag(
1069 diagnostics,
1070 policy,
1071 "request_defaults.thread_id",
1072 "request_defaults.thread_id must not be empty",
1073 );
1074 } else if trimmed_thread_id.len() < 16 {
1075 push_request_defaults_diag(
1076 diagnostics,
1077 policy,
1078 "request_defaults.thread_id",
1079 "request_defaults.thread_id must be at least 16 characters long",
1080 );
1081 }
1082}
1083
1084fn validate_request_state_keys(
1085 diagnostics: &mut Vec<ConfigDiagnostic>,
1086 policy: &ConfigPolicy,
1087 state: Option<&Json>,
1088) {
1089 let Some(state) = state.and_then(Json::as_object) else {
1090 return;
1091 };
1092
1093 let contains_supported_key = state.contains_key("events") || state.contains_key("state");
1094 let contains_unsupported_key = state.keys().any(|key| key != "events" && key != "state");
1095 if (!state.is_empty() && !contains_supported_key) || contains_unsupported_key {
1096 push_request_defaults_diag(
1097 diagnostics,
1098 policy,
1099 "request_defaults.state",
1100 "request_defaults.state must be empty or contain only 'events' or 'state'",
1101 );
1102 }
1103}
1104
1105fn validate_output_vars(
1106 diagnostics: &mut Vec<ConfigDiagnostic>,
1107 policy: &ConfigPolicy,
1108 output_vars: Option<&Json>,
1109) {
1110 let Some(output_vars) = output_vars else {
1111 return;
1112 };
1113
1114 match output_vars {
1115 Json::Bool(_) => {}
1116 Json::Array(values) => validate_output_var_entries(diagnostics, policy, values),
1117 _ => push_request_defaults_diag(
1118 diagnostics,
1119 policy,
1120 "request_defaults.output_vars",
1121 "request_defaults.output_vars must be a boolean or an array of strings",
1122 ),
1123 }
1124}
1125
1126fn validate_output_var_entries(
1127 diagnostics: &mut Vec<ConfigDiagnostic>,
1128 policy: &ConfigPolicy,
1129 values: &[Json],
1130) {
1131 for (index, value) in values.iter().enumerate() {
1132 if !value.is_string() || value.as_str().is_some_and(|entry| entry.trim().is_empty()) {
1133 push_request_defaults_diag(
1134 diagnostics,
1135 policy,
1136 &format!("request_defaults.output_vars[{index}]"),
1137 "request_defaults.output_vars array entries must be non-empty strings",
1138 );
1139 }
1140 }
1141}
1142
1143fn validate_request_rails(
1144 diagnostics: &mut Vec<ConfigDiagnostic>,
1145 policy: &ConfigPolicy,
1146 rails: Option<&RequestRailsConfig>,
1147) {
1148 let Some(rails) = rails else {
1149 return;
1150 };
1151
1152 validate_rail_selector(
1153 diagnostics,
1154 policy,
1155 rails.input.as_ref(),
1156 "request_defaults.rails.input",
1157 );
1158 validate_rail_selector(
1159 diagnostics,
1160 policy,
1161 rails.output.as_ref(),
1162 "request_defaults.rails.output",
1163 );
1164 validate_rail_selector(
1165 diagnostics,
1166 policy,
1167 rails.retrieval.as_ref(),
1168 "request_defaults.rails.retrieval",
1169 );
1170 validate_rail_selector(
1171 diagnostics,
1172 policy,
1173 rails.tool_output.as_ref(),
1174 "request_defaults.rails.tool_output",
1175 );
1176 validate_rail_selector(
1177 diagnostics,
1178 policy,
1179 rails.tool_input.as_ref(),
1180 "request_defaults.rails.tool_input",
1181 );
1182}
1183
1184fn validate_json_object_field(
1185 diagnostics: &mut Vec<ConfigDiagnostic>,
1186 policy: &ConfigPolicy,
1187 value: Option<&Json>,
1188 field: &str,
1189 message: &str,
1190) {
1191 let Some(value) = value else {
1192 return;
1193 };
1194
1195 if !value.is_object() {
1196 push_policy_diag(
1197 diagnostics,
1198 policy.unsupported_value,
1199 "nemo_guardrails.unsupported_value",
1200 Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()),
1201 Some(field.to_string()),
1202 message.to_string(),
1203 );
1204 }
1205}
1206
1207fn validate_rail_selector(
1208 diagnostics: &mut Vec<ConfigDiagnostic>,
1209 policy: &ConfigPolicy,
1210 value: Option<&RailSelector>,
1211 field: &str,
1212) {
1213 let Some(value) = value else {
1214 return;
1215 };
1216
1217 if let RailSelector::Named(names) = value {
1218 for (index, name) in names.iter().enumerate() {
1219 if name.trim().is_empty() {
1220 push_policy_diag(
1221 diagnostics,
1222 policy.unsupported_value,
1223 "nemo_guardrails.unsupported_value",
1224 Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()),
1225 Some(format!("{field}[{index}]")),
1226 "named rail selections must not contain empty strings".to_string(),
1227 );
1228 }
1229 }
1230 }
1231}
1232
1233fn validate_policy_fields(
1234 diagnostics: &mut Vec<ConfigDiagnostic>,
1235 policy: &ConfigPolicy,
1236 plugin_config: &Map<String, Json>,
1237) {
1238 if let Some(policy_json) = plugin_config.get("policy").and_then(Json::as_object) {
1239 validate_unknown_fields(
1240 diagnostics,
1241 policy,
1242 Some("policy".to_string()),
1243 policy_json,
1244 &["unknown_component", "unknown_field", "unsupported_value"],
1245 );
1246 }
1247}
1248
1249fn validate_section_fields(
1250 diagnostics: &mut Vec<ConfigDiagnostic>,
1251 policy: &ConfigPolicy,
1252 plugin_config: &Map<String, Json>,
1253 section: &str,
1254 known_fields: &[&str],
1255) {
1256 if let Some(section_json) = plugin_config.get(section).and_then(Json::as_object) {
1257 validate_unknown_fields(
1258 diagnostics,
1259 policy,
1260 Some(section.to_string()),
1261 section_json,
1262 known_fields,
1263 );
1264 }
1265}
1266
1267fn validate_nested_section_fields(
1268 diagnostics: &mut Vec<ConfigDiagnostic>,
1269 policy: &ConfigPolicy,
1270 plugin_config: &Map<String, Json>,
1271 section: &str,
1272 nested_section: &str,
1273 known_fields: &[&str],
1274) {
1275 if let Some(section_json) = plugin_config.get(section).and_then(Json::as_object)
1276 && let Some(nested_json) = section_json.get(nested_section).and_then(Json::as_object)
1277 {
1278 validate_unknown_fields(
1279 diagnostics,
1280 policy,
1281 Some(format!("{section}.{nested_section}")),
1282 nested_json,
1283 known_fields,
1284 );
1285 }
1286}
1287
1288fn validate_unknown_fields(
1289 diagnostics: &mut Vec<ConfigDiagnostic>,
1290 policy: &ConfigPolicy,
1291 component: Option<String>,
1292 config: &Map<String, Json>,
1293 known_fields: &[&str],
1294) {
1295 for field in config.keys() {
1296 if !known_fields.contains(&field.as_str()) {
1297 push_policy_diag(
1298 diagnostics,
1299 policy.unknown_field,
1300 "nemo_guardrails.unknown_field",
1301 component.clone(),
1302 Some(field.clone()),
1303 format!(
1304 "field '{}' is not recognized for '{}'",
1305 field,
1306 component.as_deref().unwrap_or("unknown")
1307 ),
1308 );
1309 }
1310 }
1311}
1312
1313fn push_policy_diag(
1314 diagnostics: &mut Vec<ConfigDiagnostic>,
1315 behavior: UnsupportedBehavior,
1316 code: &str,
1317 component: Option<String>,
1318 field: Option<String>,
1319 message: String,
1320) {
1321 let level = match behavior {
1322 UnsupportedBehavior::Ignore => return,
1323 UnsupportedBehavior::Warn => DiagnosticLevel::Warning,
1324 UnsupportedBehavior::Error => DiagnosticLevel::Error,
1325 };
1326
1327 diagnostics.push(ConfigDiagnostic {
1328 level,
1329 code: code.to_string(),
1330 component,
1331 field,
1332 message,
1333 });
1334}
1335
1336fn default_nemo_guardrails_config_version() -> u32 {
1337 1
1338}
1339
1340fn default_mode() -> String {
1341 "remote".to_string()
1342}
1343
1344fn default_true() -> bool {
1345 true
1346}
1347
1348fn default_priority() -> i32 {
1349 100
1350}
1351
1352fn default_timeout_millis() -> u64 {
1353 3_000
1354}
1355
1356#[cfg(test)]
1357#[path = "../../../tests/unit/plugins/nemo_guardrails/component_tests.rs"]
1358mod tests;