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