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