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