diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index fe0d672a..ff531ef0 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -61,6 +61,7 @@ pub mod config_editor; mod context; pub mod error; pub mod json; +pub mod nemoguardrails; pub mod observability; pub mod plugin; pub mod registry; diff --git a/crates/core/src/nemoguardrails/mod.rs b/crates/core/src/nemoguardrails/mod.rs new file mode 100644 index 00000000..c44ee141 --- /dev/null +++ b/crates/core/src/nemoguardrails/mod.rs @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Planned NeMo Guardrails integrations for NeMo Flow Core. + +#[cfg(test)] +use std::sync::Mutex; + +#[cfg(test)] +pub(crate) fn test_mutex() -> &'static Mutex<()> { + crate::shared_runtime::runtime_owner_test_mutex() +} + +pub mod plugin_component; diff --git a/crates/core/src/nemoguardrails/plugin_component.rs b/crates/core/src/nemoguardrails/plugin_component.rs new file mode 100644 index 00000000..c20ef7a0 --- /dev/null +++ b/crates/core/src/nemoguardrails/plugin_component.rs @@ -0,0 +1,1105 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! NeMo Guardrails plugin component contract. + +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value as Json}; + +use crate::plugin::{ + ConfigDiagnostic, ConfigPolicy, DiagnosticLevel, Plugin, PluginComponentSpec, PluginError, + PluginRegistrationContext, Result as PluginResult, UnsupportedBehavior, deregister_plugin, + lookup_plugin, register_plugin, +}; + +/// The plugin kind reserved for the planned first-party component. +pub const NEMO_GUARDRAILS_PLUGIN_KIND: &str = "nemoguardrails"; + +/// Top-level NeMo Guardrails component wrapper. +#[derive(Debug, Clone)] +pub struct ComponentSpec { + /// Whether the component should be activated. + pub enabled: bool, + /// Component-local NeMo Guardrails config. + pub config: NeMoGuardrailsConfig, +} + +impl ComponentSpec { + /// Creates an enabled NeMo Guardrails component spec. + pub fn new(config: NeMoGuardrailsConfig) -> Self { + Self { + enabled: true, + config, + } + } +} + +impl From for PluginComponentSpec { + fn from(value: ComponentSpec) -> Self { + let Json::Object(config) = serde_json::to_value(value.config) + .expect("NeMo Guardrails config should serialize to an object") + else { + unreachable!("NeMo Guardrails config must serialize to an object"); + }; + + PluginComponentSpec { + kind: NEMO_GUARDRAILS_PLUGIN_KIND.to_string(), + enabled: value.enabled, + config, + } + } +} + +/// Canonical config document for the planned NeMo Guardrails component. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct NeMoGuardrailsConfig { + /// NeMo Guardrails config schema version. + #[serde(default = "default_nemoguardrails_config_version")] + pub version: u32, + /// Backend mode: `remote` or `local`. + #[serde(default = "default_mode")] + #[cfg_attr(feature = "schema", schemars(schema_with = "mode_schema"))] + pub mode: String, + /// Path to a native NeMo Guardrails config directory. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_path: Option, + /// Inline native NeMo Guardrails YAML config. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_yaml: Option, + /// Optional inline Colang content. Valid only with `config_yaml`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub colang_content: Option, + /// Provider request/response codec for LLM-managed surfaces. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "schema", schemars(schema_with = "codec_schema"))] + pub codec: Option, + /// Whether to run input rails around managed LLM execution. + #[serde(default = "default_true")] + pub input: bool, + /// Whether to run output rails around managed LLM execution. + #[serde(default = "default_true")] + pub output: bool, + /// Whether to run tool-input rails around managed tool execution. + #[serde(default)] + pub tool_input: bool, + /// Whether to run tool-output rails around managed tool execution. + #[serde(default)] + pub tool_output: bool, + /// Intercept priority. Lower values run earlier. + #[serde(default = "default_priority")] + pub priority: i32, + /// Remote-backend settings used when `mode = "remote"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote: Option, + /// Local-backend settings used when `mode = "local"`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub local: Option, + /// Default request semantics passed through to the selected Guardrails backend. + /// + /// This models request-time concepts such as rail selection and generation + /// options without claiming backend parity for every Guardrails feature. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request_defaults: Option, + /// Component-local unsupported-config policy. + #[serde(default)] + pub policy: ConfigPolicy, +} + +impl Default for NeMoGuardrailsConfig { + fn default() -> Self { + Self { + version: default_nemoguardrails_config_version(), + mode: default_mode(), + config_path: None, + config_yaml: None, + colang_content: None, + codec: None, + input: true, + output: true, + tool_input: false, + tool_output: false, + priority: default_priority(), + remote: None, + local: None, + request_defaults: None, + policy: ConfigPolicy::default(), + } + } +} + +/// Remote-backend settings for a hosted NeMo Guardrails service. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct RemoteBackendConfig { + /// Base URL for the remote Guardrails service. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub endpoint: Option, + /// One remote Guardrails config identifier. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_id: Option, + /// Multiple remote Guardrails config identifiers to combine. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub config_ids: Vec, + /// Static request headers sent to the remote service. + #[serde(default)] + pub headers: HashMap, + /// Request timeout in milliseconds. + #[serde(default = "default_timeout_millis")] + pub timeout_millis: u64, +} + +impl Default for RemoteBackendConfig { + fn default() -> Self { + Self { + endpoint: None, + config_id: None, + config_ids: vec![], + headers: HashMap::new(), + timeout_millis: default_timeout_millis(), + } + } +} + +/// Local-backend settings for the Python `nemoguardrails` runtime. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct LocalBackendConfig { + /// Optional import path for the Python runtime module. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub python_module: Option, +} + +/// Default request semantics applied by the selected Guardrails backend. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct RequestDefaultsConfig { + /// Default context object passed into Guardrails requests. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context: Option, + /// Default request-time rail selection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rails: Option, + /// Default model parameters applied to Guardrails-backed LLM calls. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub llm_params: Option, + /// Whether to include raw LLM output in Guardrails responses. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub llm_output: Option, + /// Default output variables selection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output_vars: Option, + /// Default generation-log selection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub log: Option, +} + +/// Request-time rail selection for Guardrails generation. +/// +/// These are backend request options, not top-level NeMo Flow interception +/// surfaces. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub struct RequestRailsConfig { + /// Input rails selection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub input: Option, + /// Output rails selection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub output: Option, + /// Retrieval rails selection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retrieval: Option, + /// Dialog rails selection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dialog: Option, + /// Tool-output rails selection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_output: Option, + /// Tool-input rails selection. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_input: Option, +} + +/// Rail-selection shape used by Guardrails generation options. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +pub enum RailSelector { + /// Enable or disable the whole rail family. + Enabled(bool), + /// Enable only named rails within a family. + Named(Vec), +} + +crate::editor_config! { + impl NeMoGuardrailsConfig { + mode => { + label: "mode", + kind: Enum, + values: ["remote", "local"], + }, + config_path => { label: "config_path", kind: String, optional: true }, + config_yaml => { label: "config_yaml", kind: String, optional: true }, + colang_content => { label: "colang_content", kind: String, optional: true }, + codec => { + label: "codec", + kind: Enum, + values: ["openai_chat", "openai_responses", "anthropic_messages"], + optional: true, + }, + input => { label: "input", kind: Boolean }, + output => { label: "output", kind: Boolean }, + tool_input => { label: "tool_input", kind: Boolean }, + tool_output => { label: "tool_output", kind: Boolean }, + priority => { label: "priority", kind: Integer }, + remote => { + label: "remote", + kind: Section, + optional: true, + nested: RemoteBackendConfig, + default: RemoteBackendConfig, + }, + local => { + label: "local", + kind: Section, + optional: true, + nested: LocalBackendConfig, + default: LocalBackendConfig, + }, + request_defaults => { + label: "request_defaults", + kind: Section, + optional: true, + nested: RequestDefaultsConfig, + default: RequestDefaultsConfig, + }, + policy => { + label: "policy", + kind: Section, + nested: ConfigPolicy, + default: ConfigPolicy, + }, + } +} + +crate::editor_config! { + impl RemoteBackendConfig { + endpoint => { label: "endpoint", kind: String, optional: true }, + config_id => { label: "config_id", kind: String, optional: true }, + config_ids => { label: "config_ids", kind: Json }, + headers => { label: "headers", kind: StringMap }, + timeout_millis => { label: "timeout_millis", kind: Integer }, + } +} + +crate::editor_config! { + impl LocalBackendConfig { + python_module => { label: "python_module", kind: String, optional: true }, + } +} + +crate::editor_config! { + impl RequestDefaultsConfig { + context => { label: "context", kind: Json, optional: true }, + rails => { + label: "rails", + kind: Section, + optional: true, + nested: RequestRailsConfig, + default: RequestRailsConfig, + }, + llm_params => { label: "llm_params", kind: Json, optional: true }, + llm_output => { label: "llm_output", kind: Boolean, optional: true }, + output_vars => { label: "output_vars", kind: Json, optional: true }, + log => { label: "log", kind: Json, optional: true }, + } +} + +crate::editor_config! { + impl RequestRailsConfig { + input => { label: "input", kind: Json, optional: true }, + output => { label: "output", kind: Json, optional: true }, + retrieval => { label: "retrieval", kind: Json, optional: true }, + dialog => { label: "dialog", kind: Boolean, optional: true }, + tool_output => { label: "tool_output", kind: Json, optional: true }, + tool_input => { label: "tool_input", kind: Json, optional: true }, + } +} + +struct NeMoGuardrailsPlugin; + +impl Plugin for NeMoGuardrailsPlugin { + fn plugin_kind(&self) -> &str { + NEMO_GUARDRAILS_PLUGIN_KIND + } + + fn allows_multiple_components(&self) -> bool { + false + } + + fn validate(&self, plugin_config: &Map) -> Vec { + validate_nemoguardrails_plugin_config(plugin_config) + } + + fn register<'a>( + &'a self, + _plugin_config: &Map, + _ctx: &'a mut PluginRegistrationContext, + ) -> Pin> + Send + 'a>> { + Box::pin(async { + Err(PluginError::RegistrationFailed( + "built-in NeMo Guardrails plugin backend is not implemented yet".to_string(), + )) + }) + } +} + +/// Registers the `nemoguardrails` component kind in the plugin registry. +pub fn register_nemoguardrails_component() -> PluginResult<()> { + match register_plugin(Arc::new(NeMoGuardrailsPlugin)) { + Ok(()) => Ok(()), + Err(PluginError::RegistrationFailed(message)) + if message.contains("already registered") + && lookup_plugin(NEMO_GUARDRAILS_PLUGIN_KIND).is_some() => + { + Ok(()) + } + Err(err) => Err(err), + } +} + +/// Deregisters the `nemoguardrails` component kind from the plugin registry. +pub fn deregister_nemoguardrails_component() -> bool { + deregister_plugin(NEMO_GUARDRAILS_PLUGIN_KIND) +} + +/// Returns the JSON Schema for the NeMo Guardrails component configuration. +#[cfg(feature = "schema")] +pub fn nemoguardrails_config_schema() -> serde_json::Value { + serde_json::to_value(schemars::schema_for!(NeMoGuardrailsConfig)) + .expect("NeMo Guardrails config schema should serialize") +} + +#[cfg(feature = "schema")] +fn mode_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { + string_enum_schema(generator, &["remote", "local"], Some("remote")) +} + +#[cfg(feature = "schema")] +fn codec_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema { + string_enum_schema( + generator, + &["openai_chat", "openai_responses", "anthropic_messages"], + None, + ) +} + +#[cfg(feature = "schema")] +fn string_enum_schema( + generator: &mut schemars::r#gen::SchemaGenerator, + values: &[&str], + default: Option<&str>, +) -> schemars::schema::Schema { + let mut schema: schemars::schema::SchemaObject = + ::json_schema(generator).into(); + schema.enum_values = Some( + values + .iter() + .map(|value| Json::String((*value).into())) + .collect(), + ); + if let Some(default) = default { + schema.metadata().default = Some(Json::String(default.into())); + } + schema.into() +} + +fn parse_nemoguardrails_config( + plugin_config: &Map, +) -> PluginResult { + serde_json::from_value(Json::Object(plugin_config.clone())).map_err(|err| { + PluginError::InvalidConfig(format!("invalid NeMo Guardrails plugin config: {err}")) + }) +} + +fn validate_nemoguardrails_plugin_config( + plugin_config: &Map, +) -> Vec { + let config = match parse_nemoguardrails_config(plugin_config) { + Ok(config) => config, + Err(err) => { + return vec![ConfigDiagnostic { + level: DiagnosticLevel::Error, + code: "nemoguardrails.invalid_plugin_config".to_string(), + component: Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()), + field: None, + message: err.to_string(), + }]; + } + }; + + let mut diagnostics = vec![]; + + validate_unknown_fields( + &mut diagnostics, + &config.policy, + Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()), + plugin_config, + &[ + "version", + "mode", + "config_path", + "config_yaml", + "colang_content", + "codec", + "input", + "output", + "tool_input", + "tool_output", + "priority", + "remote", + "local", + "request_defaults", + "policy", + ], + ); + + validate_policy_fields(&mut diagnostics, &config.policy, plugin_config); + validate_section_fields( + &mut diagnostics, + &config.policy, + plugin_config, + "remote", + &[ + "endpoint", + "config_id", + "config_ids", + "headers", + "timeout_millis", + ], + ); + validate_section_fields( + &mut diagnostics, + &config.policy, + plugin_config, + "local", + &["python_module"], + ); + validate_section_fields( + &mut diagnostics, + &config.policy, + plugin_config, + "request_defaults", + &[ + "context", + "rails", + "llm_params", + "llm_output", + "output_vars", + "log", + ], + ); + validate_nested_section_fields( + &mut diagnostics, + &config.policy, + plugin_config, + "request_defaults", + "rails", + &[ + "input", + "output", + "retrieval", + "dialog", + "tool_output", + "tool_input", + ], + ); + + validate_version(&mut diagnostics, &config.policy, config.version); + validate_mode(&mut diagnostics, &config.policy, &config.mode); + validate_non_empty_strings(&mut diagnostics, &config.policy, &config); + validate_config_shape(&mut diagnostics, &config.policy, &config); + validate_codec_requirements(&mut diagnostics, &config.policy, &config); + validate_surface_selection(&mut diagnostics, &config.policy, &config); + validate_request_defaults(&mut diagnostics, &config.policy, &config); + + diagnostics +} + +fn validate_version(diagnostics: &mut Vec, policy: &ConfigPolicy, version: u32) { + if version != 1 { + push_policy_diag( + diagnostics, + policy.unsupported_value, + "nemoguardrails.unsupported_config_version", + Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()), + Some("version".to_string()), + format!("NeMo Guardrails config version {version} is unsupported"), + ); + } +} + +fn validate_mode(diagnostics: &mut Vec, policy: &ConfigPolicy, mode: &str) { + if !matches!(mode, "remote" | "local") { + push_policy_diag( + diagnostics, + policy.unsupported_value, + "nemoguardrails.unsupported_value", + Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()), + Some("mode".to_string()), + "mode must be 'remote' or 'local'".to_string(), + ); + } +} + +fn validate_non_empty_strings( + diagnostics: &mut Vec, + policy: &ConfigPolicy, + config: &NeMoGuardrailsConfig, +) { + if let Some(config_path) = &config.config_path + && config_path.trim().is_empty() + { + push_policy_diag( + diagnostics, + policy.unsupported_value, + "nemoguardrails.unsupported_value", + Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()), + Some("config_path".to_string()), + "config_path must not be empty".to_string(), + ); + } + + if let Some(config_yaml) = &config.config_yaml + && config_yaml.trim().is_empty() + { + push_policy_diag( + diagnostics, + policy.unsupported_value, + "nemoguardrails.unsupported_value", + Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()), + Some("config_yaml".to_string()), + "config_yaml must not be empty".to_string(), + ); + } + + if let Some(colang_content) = &config.colang_content + && colang_content.trim().is_empty() + { + push_policy_diag( + diagnostics, + policy.unsupported_value, + "nemoguardrails.unsupported_value", + Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()), + Some("colang_content".to_string()), + "colang_content must not be empty".to_string(), + ); + } + + if let Some(remote) = &config.remote { + if let Some(endpoint) = &remote.endpoint + && endpoint.trim().is_empty() + { + push_policy_diag( + diagnostics, + policy.unsupported_value, + "nemoguardrails.unsupported_value", + Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()), + Some("remote.endpoint".to_string()), + "remote.endpoint must not be empty".to_string(), + ); + } + if let Some(config_id) = &remote.config_id + && config_id.trim().is_empty() + { + push_policy_diag( + diagnostics, + policy.unsupported_value, + "nemoguardrails.unsupported_value", + Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()), + Some("remote.config_id".to_string()), + "remote.config_id must not be empty".to_string(), + ); + } + for (index, config_id) in remote.config_ids.iter().enumerate() { + if config_id.trim().is_empty() { + push_policy_diag( + diagnostics, + policy.unsupported_value, + "nemoguardrails.unsupported_value", + Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()), + Some(format!("remote.config_ids[{index}]")), + "remote.config_ids entries must not be empty".to_string(), + ); + } + } + } + + if let Some(local) = &config.local + && let Some(python_module) = &local.python_module + && python_module.trim().is_empty() + { + push_policy_diag( + diagnostics, + policy.unsupported_value, + "nemoguardrails.unsupported_value", + Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()), + Some("local.python_module".to_string()), + "local.python_module must not be empty".to_string(), + ); + } +} + +fn validate_config_shape( + diagnostics: &mut Vec, + policy: &ConfigPolicy, + config: &NeMoGuardrailsConfig, +) { + let has_config_path = config.config_path.is_some(); + let has_config_yaml = config.config_yaml.is_some(); + let has_colang_content = config.colang_content.is_some(); + let has_remote_config_id = config + .remote + .as_ref() + .and_then(|remote| remote.config_id.as_ref()) + .is_some(); + let has_remote_config_ids = config + .remote + .as_ref() + .map(|remote| !remote.config_ids.is_empty()) + .unwrap_or(false); + + match config.mode.as_str() { + "local" => { + if has_config_path == has_config_yaml { + push_policy_diag( + diagnostics, + policy.unsupported_value, + "nemoguardrails.invalid_config_source", + Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()), + None, + "exactly one of config_path or config_yaml is required in local mode" + .to_string(), + ); + } + + if has_colang_content && !has_config_yaml { + push_policy_diag( + diagnostics, + policy.unsupported_value, + "nemoguardrails.unsupported_value", + Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()), + Some("colang_content".to_string()), + "colang_content can only be used with config_yaml".to_string(), + ); + } + + if config.remote.is_some() { + push_policy_diag( + diagnostics, + policy.unsupported_value, + "nemoguardrails.unsupported_value", + Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()), + Some("remote".to_string()), + "remote backend settings cannot be used when mode is 'local'".to_string(), + ); + } + } + "remote" => { + if has_config_path || has_config_yaml || has_colang_content { + push_policy_diag( + diagnostics, + policy.unsupported_value, + "nemoguardrails.invalid_config_source", + Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()), + None, + "remote mode uses remote config identity and cannot include config_path, config_yaml, or colang_content".to_string(), + ); + } + + if config.local.is_some() { + push_policy_diag( + diagnostics, + policy.unsupported_value, + "nemoguardrails.unsupported_value", + Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()), + Some("local".to_string()), + "local backend settings cannot be used when mode is 'remote'".to_string(), + ); + } + + match &config.remote { + Some(remote) + if remote + .endpoint + .as_ref() + .is_some_and(|value| !value.trim().is_empty()) => {} + _ => push_policy_diag( + diagnostics, + policy.unsupported_value, + "nemoguardrails.unsupported_value", + Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()), + Some("remote.endpoint".to_string()), + "remote.endpoint is required when mode is 'remote'".to_string(), + ), + } + + if has_remote_config_id && has_remote_config_ids { + push_policy_diag( + diagnostics, + policy.unsupported_value, + "nemoguardrails.unsupported_value", + Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()), + Some("remote".to_string()), + "remote.config_id and remote.config_ids cannot be used together".to_string(), + ); + } + + if !(has_remote_config_id || has_remote_config_ids) { + push_policy_diag( + diagnostics, + policy.unsupported_value, + "nemoguardrails.invalid_config_source", + Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()), + None, + "remote mode requires remote.config_id or remote.config_ids".to_string(), + ); + } + } + _ => {} + } +} + +fn validate_codec_requirements( + diagnostics: &mut Vec, + policy: &ConfigPolicy, + config: &NeMoGuardrailsConfig, +) { + let llm_surface_enabled = config.input || config.output; + if !llm_surface_enabled { + return; + } + + let Some(codec) = config.codec.as_deref() else { + push_policy_diag( + diagnostics, + policy.unsupported_value, + "nemoguardrails.unsupported_value", + Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()), + Some("codec".to_string()), + "codec is required when any LLM surface is enabled".to_string(), + ); + return; + }; + + if !matches!( + codec, + "openai_chat" | "openai_responses" | "anthropic_messages" + ) { + push_policy_diag( + diagnostics, + policy.unsupported_value, + "nemoguardrails.unsupported_value", + Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()), + Some("codec".to_string()), + "codec must be 'openai_chat', 'openai_responses', or 'anthropic_messages'".to_string(), + ); + } +} + +fn validate_surface_selection( + diagnostics: &mut Vec, + policy: &ConfigPolicy, + config: &NeMoGuardrailsConfig, +) { + if config.input || config.output || config.tool_input || config.tool_output { + return; + } + + push_policy_diag( + diagnostics, + policy.unsupported_value, + "nemoguardrails.unsupported_value", + Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()), + None, + "at least one Guardrails surface must be enabled".to_string(), + ); +} + +fn validate_request_defaults( + diagnostics: &mut Vec, + policy: &ConfigPolicy, + config: &NeMoGuardrailsConfig, +) { + let Some(request_defaults) = &config.request_defaults else { + return; + }; + + validate_json_object_field( + diagnostics, + policy, + request_defaults.context.as_ref(), + "request_defaults.context", + "request_defaults.context must be a JSON object", + ); + validate_json_object_field( + diagnostics, + policy, + request_defaults.llm_params.as_ref(), + "request_defaults.llm_params", + "request_defaults.llm_params must be a JSON object", + ); + validate_json_object_field( + diagnostics, + policy, + request_defaults.log.as_ref(), + "request_defaults.log", + "request_defaults.log must be a JSON object", + ); + + if let Some(output_vars) = &request_defaults.output_vars { + match output_vars { + Json::Bool(_) => {} + Json::Array(values) => { + for (index, value) in values.iter().enumerate() { + if !value.is_string() + || value.as_str().is_some_and(|entry| entry.trim().is_empty()) + { + push_policy_diag( + diagnostics, + policy.unsupported_value, + "nemoguardrails.unsupported_value", + Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()), + Some(format!("request_defaults.output_vars[{index}]")), + "request_defaults.output_vars array entries must be non-empty strings" + .to_string(), + ); + } + } + } + _ => push_policy_diag( + diagnostics, + policy.unsupported_value, + "nemoguardrails.unsupported_value", + Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()), + Some("request_defaults.output_vars".to_string()), + "request_defaults.output_vars must be a boolean or an array of strings".to_string(), + ), + } + } + + if let Some(rails) = &request_defaults.rails { + validate_rail_selector( + diagnostics, + policy, + rails.input.as_ref(), + "request_defaults.rails.input", + ); + validate_rail_selector( + diagnostics, + policy, + rails.output.as_ref(), + "request_defaults.rails.output", + ); + validate_rail_selector( + diagnostics, + policy, + rails.retrieval.as_ref(), + "request_defaults.rails.retrieval", + ); + validate_rail_selector( + diagnostics, + policy, + rails.tool_output.as_ref(), + "request_defaults.rails.tool_output", + ); + validate_rail_selector( + diagnostics, + policy, + rails.tool_input.as_ref(), + "request_defaults.rails.tool_input", + ); + } +} + +fn validate_json_object_field( + diagnostics: &mut Vec, + policy: &ConfigPolicy, + value: Option<&Json>, + field: &str, + message: &str, +) { + let Some(value) = value else { + return; + }; + + if !value.is_object() { + push_policy_diag( + diagnostics, + policy.unsupported_value, + "nemoguardrails.unsupported_value", + Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()), + Some(field.to_string()), + message.to_string(), + ); + } +} + +fn validate_rail_selector( + diagnostics: &mut Vec, + policy: &ConfigPolicy, + value: Option<&RailSelector>, + field: &str, +) { + let Some(value) = value else { + return; + }; + + if let RailSelector::Named(names) = value { + for (index, name) in names.iter().enumerate() { + if name.trim().is_empty() { + push_policy_diag( + diagnostics, + policy.unsupported_value, + "nemoguardrails.unsupported_value", + Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()), + Some(format!("{field}[{index}]")), + "named rail selections must not contain empty strings".to_string(), + ); + } + } + } +} + +fn validate_policy_fields( + diagnostics: &mut Vec, + policy: &ConfigPolicy, + plugin_config: &Map, +) { + if let Some(policy_json) = plugin_config.get("policy").and_then(Json::as_object) { + validate_unknown_fields( + diagnostics, + policy, + Some("policy".to_string()), + policy_json, + &["unknown_component", "unknown_field", "unsupported_value"], + ); + } +} + +fn validate_section_fields( + diagnostics: &mut Vec, + policy: &ConfigPolicy, + plugin_config: &Map, + section: &str, + known_fields: &[&str], +) { + if let Some(section_json) = plugin_config.get(section).and_then(Json::as_object) { + validate_unknown_fields( + diagnostics, + policy, + Some(section.to_string()), + section_json, + known_fields, + ); + } +} + +fn validate_nested_section_fields( + diagnostics: &mut Vec, + policy: &ConfigPolicy, + plugin_config: &Map, + section: &str, + nested_section: &str, + known_fields: &[&str], +) { + if let Some(section_json) = plugin_config.get(section).and_then(Json::as_object) + && let Some(nested_json) = section_json.get(nested_section).and_then(Json::as_object) + { + validate_unknown_fields( + diagnostics, + policy, + Some(format!("{section}.{nested_section}")), + nested_json, + known_fields, + ); + } +} + +fn validate_unknown_fields( + diagnostics: &mut Vec, + policy: &ConfigPolicy, + component: Option, + config: &Map, + known_fields: &[&str], +) { + for field in config.keys() { + if !known_fields.contains(&field.as_str()) { + push_policy_diag( + diagnostics, + policy.unknown_field, + "nemoguardrails.unknown_field", + component.clone(), + Some(field.clone()), + format!( + "field '{}' is not recognized for '{}'", + field, + component.as_deref().unwrap_or("unknown") + ), + ); + } + } +} + +fn push_policy_diag( + diagnostics: &mut Vec, + behavior: UnsupportedBehavior, + code: &str, + component: Option, + field: Option, + message: String, +) { + let level = match behavior { + UnsupportedBehavior::Ignore => return, + UnsupportedBehavior::Warn => DiagnosticLevel::Warning, + UnsupportedBehavior::Error => DiagnosticLevel::Error, + }; + + diagnostics.push(ConfigDiagnostic { + level, + code: code.to_string(), + component, + field, + message, + }); +} + +fn default_nemoguardrails_config_version() -> u32 { + 1 +} + +fn default_mode() -> String { + "remote".to_string() +} + +fn default_true() -> bool { + true +} + +fn default_priority() -> i32 { + 100 +} + +fn default_timeout_millis() -> u64 { + 3_000 +} + +#[cfg(test)] +#[path = "../../tests/unit/nemoguardrails/plugin_component_tests.rs"] +mod tests; diff --git a/crates/core/tests/unit/nemoguardrails/plugin_component_tests.rs b/crates/core/tests/unit/nemoguardrails/plugin_component_tests.rs new file mode 100644 index 00000000..0c9a3882 --- /dev/null +++ b/crates/core/tests/unit/nemoguardrails/plugin_component_tests.rs @@ -0,0 +1,638 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Unit tests for the planned NeMo Guardrails plugin component contract. + +use super::*; +use crate::api::runtime::NemoFlowContextState; +use crate::api::runtime::global_context; +use crate::config_editor::{EditorConfig, EditorFieldKind}; +#[cfg(feature = "schema")] +use crate::plugin::plugin_config_schema; +use crate::plugin::{ + PluginComponentSpec, PluginConfig, clear_plugin_configuration, initialize_plugins, + list_plugin_kinds, lookup_plugin, validate_plugin_config, +}; +use serde_json::json; + +fn reset_runtime() { + let _ = clear_plugin_configuration(); + let _ = deregister_nemoguardrails_component(); + crate::shared_runtime::reset_runtime_owner_for_tests(); + let context = global_context(); + *context.write().unwrap() = NemoFlowContextState::new(); +} + +fn ensure_registered() { + register_nemoguardrails_component().unwrap(); +} + +fn component(config: Json) -> PluginComponentSpec { + let Json::Object(config) = config else { + panic!("component config must be an object"); + }; + PluginComponentSpec { + kind: NEMO_GUARDRAILS_PLUGIN_KIND.to_string(), + enabled: true, + config, + } +} + +fn disabled_component(config: Json) -> PluginComponentSpec { + let Json::Object(config) = config else { + panic!("component config must be an object"); + }; + PluginComponentSpec { + kind: NEMO_GUARDRAILS_PLUGIN_KIND.to_string(), + enabled: false, + config, + } +} + +fn plugin_config(config: Json) -> PluginConfig { + PluginConfig { + version: 1, + components: vec![component(config)], + policy: Default::default(), + } +} + +fn remote_valid_config() -> Json { + json!({ + "mode": "remote", + "codec": "openai_chat", + "remote": { + "endpoint": "http://localhost:8000", + "config_id": "safety-default" + } + }) +} + +#[test] +fn editor_schema_tracks_nemoguardrails_config_types() { + let schema = NeMoGuardrailsConfig::editor_schema(); + let mode = schema.field("mode").expect("mode field"); + assert_eq!(mode.kind, EditorFieldKind::Enum); + assert_eq!(mode.enum_values, &["remote", "local"]); + + let remote = schema.field("remote").expect("remote section"); + assert_eq!(remote.kind, EditorFieldKind::Section); + assert!(remote.optional); + + let remote_schema = remote.schema().expect("remote editor schema"); + let headers = remote_schema.field("headers").expect("headers field"); + assert_eq!(headers.kind, EditorFieldKind::StringMap); + + let request_defaults = schema + .field("request_defaults") + .expect("request_defaults section"); + assert_eq!(request_defaults.kind, EditorFieldKind::Section); + assert!(request_defaults.optional); + + let request_defaults_schema = request_defaults + .schema() + .expect("request_defaults editor schema"); + let rails = request_defaults_schema.field("rails").expect("rails field"); + assert_eq!(rails.kind, EditorFieldKind::Section); + + let rails_schema = rails.schema().expect("request rails editor schema"); + let retrieval = rails_schema.field("retrieval").expect("retrieval field"); + assert_eq!(retrieval.kind, EditorFieldKind::Json); +} + +#[test] +fn default_config_and_component_conversion_cover_public_shape() { + let _guard = crate::nemoguardrails::test_mutex() + .lock() + .unwrap_or_else(|err| err.into_inner()); + reset_runtime(); + + let defaults = NeMoGuardrailsConfig::default(); + assert_eq!(defaults.version, 1); + assert_eq!(defaults.mode, "remote"); + assert!(defaults.input); + assert!(defaults.output); + assert!(!defaults.tool_input); + assert!(!defaults.tool_output); + assert_eq!(defaults.priority, 100); + assert!(defaults.remote.is_none()); + assert!(defaults.local.is_none()); + assert!(defaults.request_defaults.is_none()); + + let remote = RemoteBackendConfig::default(); + assert_eq!(remote.timeout_millis, 3_000); + assert!(remote.headers.is_empty()); + assert!(remote.config_ids.is_empty()); + + let generic: PluginComponentSpec = ComponentSpec::new(NeMoGuardrailsConfig { + remote: Some(RemoteBackendConfig { + endpoint: Some("http://localhost:8000".into()), + config_id: Some("default".into()), + ..RemoteBackendConfig::default() + }), + ..NeMoGuardrailsConfig::default() + }) + .into(); + assert_eq!(generic.kind, NEMO_GUARDRAILS_PLUGIN_KIND); + assert!(generic.enabled); + assert_eq!(generic.config["mode"], json!("remote")); + assert_eq!(generic.config["remote"]["config_id"], json!("default")); +} + +#[cfg(feature = "schema")] +fn schema_has_property(schema: &Json, name: &str) -> bool { + schema_property(schema, name).is_some() +} + +#[cfg(feature = "schema")] +fn schema_property_has_enum(schema: &Json, name: &str, expected: &[&str]) -> bool { + schema_property(schema, name) + .and_then(|property| property.get("enum")) + .and_then(Json::as_array) + .is_some_and(|values| { + expected + .iter() + .all(|expected| values.iter().any(|value| value == *expected)) + }) +} + +#[cfg(feature = "schema")] +fn schema_property_has_default(schema: &Json, name: &str, expected: Json) -> bool { + schema_property(schema, name) + .and_then(|property| property.get("default")) + .is_some_and(|default| default == &expected) +} + +#[cfg(feature = "schema")] +fn schema_property<'a>(schema: &'a Json, name: &str) -> Option<&'a Json> { + match schema { + Json::Object(object) => { + if let Some(property) = object + .get("properties") + .and_then(Json::as_object) + .and_then(|properties| properties.get(name)) + { + return Some(property); + } + object + .values() + .find_map(|value| schema_property(value, name)) + } + Json::Array(values) => values.iter().find_map(|value| schema_property(value, name)), + _ => None, + } +} + +#[cfg(feature = "schema")] +#[test] +fn schema_contains_every_supported_nemoguardrails_option() { + let schema = nemoguardrails_config_schema(); + for field in [ + "version", + "mode", + "config_path", + "config_yaml", + "colang_content", + "codec", + "input", + "output", + "tool_input", + "tool_output", + "priority", + "remote", + "local", + "request_defaults", + "policy", + "endpoint", + "config_id", + "config_ids", + "headers", + "timeout_millis", + "python_module", + "context", + "rails", + "llm_params", + "llm_output", + "output_vars", + "log", + "retrieval", + "dialog", + "unknown_component", + "unknown_field", + "unsupported_value", + ] { + assert!( + schema_has_property(&schema, field), + "schema missing property `{field}`:\n{}", + serde_json::to_string_pretty(&schema).unwrap() + ); + } + assert!(schema_property_has_enum( + &schema, + "mode", + &["remote", "local"] + )); + assert!(schema_property_has_enum( + &schema, + "codec", + &["openai_chat", "openai_responses", "anthropic_messages"] + )); + assert!(schema_property_has_default( + &schema, + "mode", + json!("remote") + )); +} + +#[cfg(feature = "schema")] +#[test] +fn plugin_schema_contains_generic_plugin_surface() { + let schema = plugin_config_schema(); + for field in [ + "version", + "components", + "policy", + "kind", + "enabled", + "config", + ] { + assert!( + schema_has_property(&schema, field), + "plugin schema missing property `{field}`" + ); + } +} + +#[test] +fn registration_is_explicit_not_automatic() { + let _guard = crate::nemoguardrails::test_mutex() + .lock() + .unwrap_or_else(|err| err.into_inner()); + reset_runtime(); + + assert!(!list_plugin_kinds().contains(&NEMO_GUARDRAILS_PLUGIN_KIND.to_string())); + assert!(lookup_plugin(NEMO_GUARDRAILS_PLUGIN_KIND).is_none()); + + ensure_registered(); + assert!(list_plugin_kinds().contains(&NEMO_GUARDRAILS_PLUGIN_KIND.to_string())); + assert!(lookup_plugin(NEMO_GUARDRAILS_PLUGIN_KIND).is_some()); + + ensure_registered(); + assert!(lookup_plugin(NEMO_GUARDRAILS_PLUGIN_KIND).is_some()); + assert!(deregister_nemoguardrails_component()); + assert!(!deregister_nemoguardrails_component()); +} + +#[test] +fn disabled_component_validates_and_initializes_without_runtime_work() { + let _guard = crate::nemoguardrails::test_mutex() + .lock() + .unwrap_or_else(|err| err.into_inner()); + reset_runtime(); + ensure_registered(); + + let config = PluginConfig { + version: 1, + components: vec![disabled_component(remote_valid_config())], + policy: Default::default(), + }; + assert!(!validate_plugin_config(&config).has_errors()); + futures::executor::block_on(initialize_plugins(config)).unwrap(); +} + +#[test] +fn duplicate_component_is_rejected_as_singleton() { + let _guard = crate::nemoguardrails::test_mutex() + .lock() + .unwrap_or_else(|err| err.into_inner()); + reset_runtime(); + ensure_registered(); + + let config = PluginConfig { + version: 1, + components: vec![ + component(remote_valid_config()), + component(remote_valid_config()), + ], + policy: Default::default(), + }; + let report = validate_plugin_config(&config); + assert!(report.has_errors()); + assert!( + report + .diagnostics + .iter() + .any(|diag| diag.code == "plugin.duplicate_component") + ); +} + +#[test] +fn invalid_shapes_and_values_are_reported() { + let _guard = crate::nemoguardrails::test_mutex() + .lock() + .unwrap_or_else(|err| err.into_inner()); + reset_runtime(); + ensure_registered(); + + let invalid_shape = validate_plugin_config(&plugin_config(json!({ + "version": "one", + }))); + assert!(invalid_shape.has_errors()); + assert!( + invalid_shape + .diagnostics + .iter() + .any(|diag| diag.code == "nemoguardrails.invalid_plugin_config") + ); + + let local_missing_source = validate_plugin_config(&plugin_config(json!({ + "mode": "local", + "codec": "openai_chat", + }))); + assert!(local_missing_source.has_errors()); + assert!(local_missing_source.diagnostics.iter().any(|diag| { + diag.message + .contains("exactly one of config_path or config_yaml is required in local mode") + })); + + let local_bad_colang = validate_plugin_config(&plugin_config(json!({ + "mode": "local", + "config_path": "./rails", + "colang_content": "define flow x", + "codec": "openai_chat", + }))); + assert!(local_bad_colang.has_errors()); + assert!( + local_bad_colang + .diagnostics + .iter() + .any(|diag| diag.message.contains("colang_content can only be used")) + ); + + let remote_missing_identity = validate_plugin_config(&plugin_config(json!({ + "mode": "remote", + "codec": "openai_chat", + "remote": {"endpoint": "http://localhost:8000"}, + }))); + assert!(remote_missing_identity.has_errors()); + assert!(remote_missing_identity.diagnostics.iter().any(|diag| { + diag.message + .contains("remote mode requires remote.config_id or remote.config_ids") + })); + + let remote_conflicting_ids = validate_plugin_config(&plugin_config(json!({ + "mode": "remote", + "codec": "openai_chat", + "remote": { + "endpoint": "http://localhost:8000", + "config_id": "one", + "config_ids": ["two"] + }, + }))); + assert!(remote_conflicting_ids.has_errors()); + assert!(remote_conflicting_ids.diagnostics.iter().any(|diag| { + diag.message + .contains("remote.config_id and remote.config_ids cannot be used together") + })); + + let missing_codec = validate_plugin_config(&plugin_config(json!({ + "mode": "remote", + "remote": { + "endpoint": "http://localhost:8000", + "config_id": "default" + } + }))); + assert!(missing_codec.has_errors()); + assert!( + missing_codec + .diagnostics + .iter() + .any(|diag| diag.field.as_deref() == Some("codec")) + ); + + let bad_codec = validate_plugin_config(&plugin_config(json!({ + "mode": "remote", + "codec": "openai_agents", + "remote": { + "endpoint": "http://localhost:8000", + "config_id": "default" + } + }))); + assert!(bad_codec.has_errors()); + assert!(bad_codec.diagnostics.iter().any(|diag| { + diag.message + .contains("codec must be 'openai_chat', 'openai_responses', or 'anthropic_messages'") + })); + + let remote_empty_fields = validate_plugin_config(&plugin_config(json!({ + "mode": "remote", + "codec": "openai_chat", + "remote": { + "endpoint": "", + "config_id": "", + "config_ids": ["default", ""] + } + }))); + assert!(remote_empty_fields.has_errors()); + assert!( + remote_empty_fields + .diagnostics + .iter() + .any(|diag| diag.field.as_deref() == Some("remote.endpoint")) + ); + assert!( + remote_empty_fields + .diagnostics + .iter() + .any(|diag| diag.field.as_deref() == Some("remote.config_id")) + ); + assert!( + remote_empty_fields + .diagnostics + .iter() + .any(|diag| diag.field.as_deref() == Some("remote.config_ids[1]")) + ); + + let remote_local_mix = validate_plugin_config(&plugin_config(json!({ + "mode": "remote", + "config_path": "./rails", + "codec": "openai_chat", + "remote": { + "endpoint": "http://localhost:8000", + "config_id": "default" + }, + "local": {"python_module": "nemoguardrails"} + }))); + assert!(remote_local_mix.has_errors()); + assert!( + remote_local_mix + .diagnostics + .iter() + .any(|diag| diag.field.as_deref() == Some("local")) + ); + assert!(remote_local_mix.diagnostics.iter().any(|diag| { + diag.message + .contains("remote mode uses remote config identity") + })); + + let no_surfaces = validate_plugin_config(&plugin_config(json!({ + "mode": "local", + "config_path": "./rails", + "input": false, + "output": false, + "tool_input": false, + "tool_output": false + }))); + assert!(no_surfaces.has_errors()); + assert!( + no_surfaces + .diagnostics + .iter() + .any(|diag| diag.message.contains("at least one Guardrails surface")) + ); + + let local_empty_fields = validate_plugin_config(&plugin_config(json!({ + "mode": "local", + "config_yaml": "", + "colang_content": "", + "codec": "openai_chat", + "local": {"python_module": ""} + }))); + assert!(local_empty_fields.has_errors()); + assert!( + local_empty_fields + .diagnostics + .iter() + .any(|diag| diag.field.as_deref() == Some("config_yaml")) + ); + assert!( + local_empty_fields + .diagnostics + .iter() + .any(|diag| diag.field.as_deref() == Some("colang_content")) + ); + assert!( + local_empty_fields + .diagnostics + .iter() + .any(|diag| diag.field.as_deref() == Some("local.python_module")) + ); + + let invalid_request_defaults = validate_plugin_config(&plugin_config(json!({ + "mode": "remote", + "codec": "openai_chat", + "remote": { + "endpoint": "http://localhost:8000", + "config_id": "default" + }, + "request_defaults": { + "context": true, + "llm_params": [], + "log": "verbose", + "output_vars": 7, + "rails": { + "retrieval": [""] + } + } + }))); + assert!(invalid_request_defaults.has_errors()); + assert!( + invalid_request_defaults + .diagnostics + .iter() + .any(|diag| diag.field.as_deref() == Some("request_defaults.context")) + ); + assert!( + invalid_request_defaults + .diagnostics + .iter() + .any(|diag| diag.field.as_deref() == Some("request_defaults.llm_params")) + ); + assert!( + invalid_request_defaults + .diagnostics + .iter() + .any(|diag| diag.field.as_deref() == Some("request_defaults.log")) + ); + assert!( + invalid_request_defaults + .diagnostics + .iter() + .any(|diag| diag.field.as_deref() == Some("request_defaults.output_vars")) + ); + assert!( + invalid_request_defaults + .diagnostics + .iter() + .any(|diag| diag.field.as_deref() == Some("request_defaults.rails.retrieval[0]")) + ); +} + +#[test] +fn unknown_fields_follow_policy() { + let _guard = crate::nemoguardrails::test_mutex() + .lock() + .unwrap_or_else(|err| err.into_inner()); + reset_runtime(); + ensure_registered(); + + let warn_report = validate_plugin_config(&plugin_config(json!({ + "mode": "remote", + "codec": "openai_chat", + "remote": {"endpoint": "http://localhost:8000", "config_id": "default"}, + "bogus": true + }))); + assert!( + warn_report + .diagnostics + .iter() + .any(|diag| diag.code == "nemoguardrails.unknown_field") + ); + + let nested_warn_report = validate_plugin_config(&plugin_config(json!({ + "mode": "remote", + "codec": "openai_chat", + "remote": {"endpoint": "http://localhost:8000", "config_id": "default"}, + "request_defaults": { + "rails": { + "bogus": true + } + } + }))); + assert!( + nested_warn_report + .diagnostics + .iter() + .any(|diag| diag.component.as_deref() == Some("request_defaults.rails")) + ); + + let ignored = validate_plugin_config(&plugin_config(json!({ + "policy": {"unknown_field": "ignore", "unsupported_value": "ignore"}, + "mode": "remote", + "codec": "openai_chat", + "remote": {"endpoint": "http://localhost:8000", "config_id": "default"}, + "bogus": true + }))); + assert!(!ignored.has_errors()); + assert!(ignored.diagnostics.is_empty()); +} + +#[test] +fn enabled_initialization_fails_fast_until_backend_exists() { + let _guard = crate::nemoguardrails::test_mutex() + .lock() + .unwrap_or_else(|err| err.into_inner()); + reset_runtime(); + ensure_registered(); + + let error = + futures::executor::block_on(initialize_plugins(plugin_config(remote_valid_config()))) + .unwrap_err(); + + match error { + crate::plugin::PluginError::RegistrationFailed(message) => { + assert!(message.contains("not implemented yet")); + } + other => panic!("unexpected error: {other}"), + } +}