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