Skip to main content

nemo_relay/plugins/nemo_guardrails/
component.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! NeMo Guardrails plugin component contract.
5
6use 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
26/// The plugin kind reserved for the planned first-party component.
27pub 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/// Top-level NeMo Guardrails component wrapper.
40#[derive(Debug, Clone)]
41pub struct ComponentSpec {
42    /// Whether the component should be activated.
43    pub enabled: bool,
44    /// Component-local NeMo Guardrails config.
45    pub config: NeMoGuardrailsConfig,
46}
47
48impl ComponentSpec {
49    /// Creates an enabled NeMo Guardrails component spec.
50    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/// Canonical config document for the planned NeMo Guardrails component.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
77pub struct NeMoGuardrailsConfig {
78    /// NeMo Guardrails config schema version.
79    #[serde(default = "default_nemo_guardrails_config_version")]
80    pub version: u32,
81    /// Backend mode: `remote` or `local`.
82    #[serde(default = "default_mode")]
83    #[cfg_attr(feature = "schema", schemars(schema_with = "mode_schema"))]
84    pub mode: String,
85    /// Path to a native NeMo Guardrails config directory.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub config_path: Option<String>,
88    /// Inline native NeMo Guardrails YAML config.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub config_yaml: Option<String>,
91    /// Optional inline Colang content. Valid only with `config_yaml`.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub colang_content: Option<String>,
94    /// Provider request/response codec for LLM-managed surfaces.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    #[cfg_attr(feature = "schema", schemars(schema_with = "codec_schema"))]
97    pub codec: Option<String>,
98    /// Whether to run input rails around managed LLM execution.
99    #[serde(default = "default_true")]
100    pub input: bool,
101    /// Whether to run output rails around managed LLM execution.
102    #[serde(default = "default_true")]
103    pub output: bool,
104    /// Whether to run tool-input rails around managed tool execution.
105    #[serde(default)]
106    pub tool_input: bool,
107    /// Whether to run tool-output rails around managed tool execution.
108    #[serde(default)]
109    pub tool_output: bool,
110    /// Intercept priority. Lower values run earlier.
111    #[serde(default = "default_priority")]
112    pub priority: i32,
113    /// Remote-backend settings used when `mode = "remote"`.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub remote: Option<RemoteBackendConfig>,
116    /// Local-backend settings used when `mode = "local"`.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub local: Option<LocalBackendConfig>,
119    /// Default request semantics passed through to the selected Guardrails backend.
120    ///
121    /// This models request-time concepts such as rail selection and generation
122    /// options without claiming backend parity for every Guardrails feature.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub request_defaults: Option<RequestDefaultsConfig>,
125    /// Component-local unsupported-config policy.
126    #[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/// Remote-backend settings for a hosted NeMo Guardrails service.
153#[derive(Debug, Clone, Serialize, Deserialize)]
154#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
155pub struct RemoteBackendConfig {
156    /// Base URL for the remote Guardrails service.
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub endpoint: Option<String>,
159    /// One remote Guardrails config identifier.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub config_id: Option<String>,
162    /// Multiple remote Guardrails config identifiers to combine.
163    #[serde(default, skip_serializing_if = "Vec::is_empty")]
164    pub config_ids: Vec<String>,
165    /// Static request headers sent to the remote service.
166    #[serde(default)]
167    pub headers: HashMap<String, String>,
168    /// Request timeout in milliseconds.
169    #[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/// Local-backend settings for the Python `nemoguardrails` runtime.
186#[derive(Debug, Clone, Default, Serialize, Deserialize)]
187#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
188pub struct LocalBackendConfig {
189    /// Optional import path for the Python runtime module.
190    #[serde(default, skip_serializing_if = "Option::is_none")]
191    pub python_module: Option<String>,
192}
193
194/// Default request semantics applied by the selected Guardrails backend.
195#[derive(Debug, Clone, Default, Serialize, Deserialize)]
196#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
197pub struct RequestDefaultsConfig {
198    /// Default context object passed into Guardrails requests.
199    #[serde(default, skip_serializing_if = "Option::is_none")]
200    pub context: Option<Json>,
201    /// Default remote thread identifier for continuation-aware requests.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub thread_id: Option<String>,
204    /// Default remote Guardrails state payload for continuation-aware requests.
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub state: Option<Json>,
207    /// Default request-time rail selection.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub rails: Option<RequestRailsConfig>,
210    /// Default model parameters applied to Guardrails-backed LLM calls.
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub llm_params: Option<Json>,
213    /// Whether to include raw LLM output in Guardrails responses.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub llm_output: Option<bool>,
216    /// Default output variables selection.
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub output_vars: Option<Json>,
219    /// Default generation-log selection.
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub log: Option<Json>,
222}
223
224/// Request-time rail selection for Guardrails generation.
225///
226/// These are backend request options, not top-level NeMo Relay interception
227/// surfaces.
228#[derive(Debug, Clone, Default, Serialize, Deserialize)]
229#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
230pub struct RequestRailsConfig {
231    /// Input rails selection.
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub input: Option<RailSelector>,
234    /// Output rails selection.
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub output: Option<RailSelector>,
237    /// Retrieval rails selection.
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub retrieval: Option<RailSelector>,
240    /// Dialog rails selection.
241    #[serde(default, skip_serializing_if = "Option::is_none")]
242    pub dialog: Option<bool>,
243    /// Tool-output rails selection.
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub tool_output: Option<RailSelector>,
246    /// Tool-input rails selection.
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub tool_input: Option<RailSelector>,
249}
250
251/// Rail-selection shape used by Guardrails generation options.
252#[derive(Debug, Clone, Serialize, Deserialize)]
253#[serde(untagged)]
254#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
255pub enum RailSelector {
256    /// Enable or disable the whole rail family.
257    Enabled(bool),
258    /// Enable only named rails within a family.
259    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
387/// Registers the `nemo_guardrails` component kind in the plugin registry.
388pub 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
398/// Deregisters the `nemo_guardrails` component kind from the plugin registry.
399pub fn deregister_nemo_guardrails_component() -> bool {
400    deregister_plugin(NEMO_GUARDRAILS_PLUGIN_KIND)
401}
402
403/// Returns the JSON Schema for the NeMo Guardrails component configuration.
404#[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;