Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
253c845
feat: add local backend for built-in nemo guardrails
afourniernv May 31, 2026
ec49259
docs: refine local guardrails mode docs
afourniernv Jun 1, 2026
98d4915
test: factor local guardrails coverage fixtures
afourniernv Jun 1, 2026
244f29f
style: apply rustfmt for local guardrails tests
afourniernv Jun 1, 2026
ffa88dc
refactor: name local guardrails imports
afourniernv Jun 1, 2026
f8dead5
fix: address local guardrails review nits
afourniernv Jun 1, 2026
67fd1b9
test: extend local guardrails cli coverage
afourniernv Jun 1, 2026
8823aef
Merge remote-tracking branch 'github/main' into guardrails-cli-local-…
afourniernv Jun 3, 2026
e86ae58
refactor: embed local guardrails helper snapshot
afourniernv Jun 4, 2026
763ad75
Merge remote-tracking branch 'github/main' into guardrails-cli-local-…
afourniernv Jun 4, 2026
3c7eecd
refactor: move local guardrails backend into core
afourniernv Jun 4, 2026
7e8f4e1
Merge remote-tracking branch 'github/main' into guardrails-cli-local-…
afourniernv Jun 4, 2026
e249238
refactor: own local NeMo Guardrails runtime in Rust
willkill07 Jun 4, 2026
7e0b3af
refactor: gate fn impl on cfg rather than branch
willkill07 Jun 5, 2026
31584ef
Merge branch 'main' into afournier/relay-149-implement-local-python-b…
willkill07 Jun 5, 2026
9ad985e
Centralize Python Rust dependency versions
willkill07 Jun 5, 2026
5dba153
fix: stabilize local guardrails branch checks
afourniernv Jun 5, 2026
0260c7e
Merge remote-tracking branch 'github-fork/afournier/relay-149-impleme…
afourniernv Jun 5, 2026
4aee554
fix: restore python guardrails coverage split
afourniernv Jun 5, 2026
430dc50
Merge branch 'main' into afournier/relay-149-implement-local-python-b…
willkill07 Jun 5, 2026
aba629c
fix: run local guardrails through python subprocess
willkill07 Jun 5, 2026
cdbd1a0
test: adapt guardrails coverage to subprocess backend
willkill07 Jun 5, 2026
dfee75b
chore: restore cargo manifests
willkill07 Jun 5, 2026
527b1f8
fix: address local guardrails review feedback
willkill07 Jun 5, 2026
6d68272
fix: harden local guardrails worker flow
willkill07 Jun 6, 2026
33df99f
fix: track local guardrails worker tasks
willkill07 Jun 6, 2026
8527102
Merge branch 'main' into afournier/relay-149-implement-local-python-b…
willkill07 Jun 6, 2026
55bc4b9
fix: use real python executable in guardrails tests
willkill07 Jun 6, 2026
47ca8cb
fix: scope guardrails worker python path
willkill07 Jun 6, 2026
1dc1328
Merge branch 'main' into afournier/relay-149-implement-local-python-b…
willkill07 Jun 6, 2026
1f3d406
fix: preserve guardrails worker python environment
willkill07 Jun 6, 2026
827c939
fix: select managed python for guardrails worker
willkill07 Jun 6, 2026
e7b28b9
fix: restore pass-through local guardrails streaming
afourniernv Jun 6, 2026
dafb996
test: relax streamed guardrails ordering assertion
afourniernv Jun 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions crates/core/src/plugins/nemo_guardrails/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,13 @@ use crate::plugin::{
register_plugin,
};

#[path = "local.rs"]
mod local;
#[cfg(all(feature = "guardrails-remote", not(target_arch = "wasm32")))]
#[path = "remote.rs"]
mod remote;
use local::register_local_backend;
pub use local::{clear_local_backend_provider, register_local_backend_provider};
#[cfg(all(feature = "guardrails-remote", not(target_arch = "wasm32")))]
use remote::register_remote_backend;

Expand Down Expand Up @@ -447,9 +451,7 @@ fn register_nemo_guardrails_backend(
) -> PluginResult<()> {
match config.mode.as_str() {
"remote" => register_remote_backend(config, ctx),
"local" => Err(PluginError::RegistrationFailed(
"built-in NeMo Guardrails local backend is not implemented yet".to_string(),
)),
"local" => register_local_backend(config, ctx),
other => Err(PluginError::InvalidConfig(format!(
"unsupported NeMo Guardrails mode '{other}'"
))),
Expand Down Expand Up @@ -955,6 +957,18 @@ fn validate_request_defaults(
return;
};

if config.mode == "local" {
push_policy_diag(
diagnostics,
policy.unsupported_value,
"nemo_guardrails.unsupported_value",
Some(NEMO_GUARDRAILS_PLUGIN_KIND.to_string()),
Some("request_defaults".to_string()),
"local mode does not currently support request_defaults".to_string(),
);
return;
}

validate_json_object_field(
diagnostics,
policy,
Expand Down
51 changes: 51 additions & 0 deletions crates/core/src/plugins/nemo_guardrails/local.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

use std::sync::{Arc, LazyLock, Mutex, MutexGuard};

use crate::plugin::{PluginError, PluginRegistrationContext, Result as PluginResult};

use super::NeMoGuardrailsConfig;

type LocalBackendProvider = Arc<
dyn Fn(NeMoGuardrailsConfig, &mut PluginRegistrationContext) -> PluginResult<()> + Send + Sync,
>;

static LOCAL_BACKEND_PROVIDER: LazyLock<Mutex<Option<LocalBackendProvider>>> =
LazyLock::new(|| Mutex::new(None));

fn local_backend_provider_guard() -> PluginResult<MutexGuard<'static, Option<LocalBackendProvider>>> {
LOCAL_BACKEND_PROVIDER.lock().map_err(|e| {
PluginError::Internal(format!(
"NeMo Guardrails local backend provider lock poisoned: {e}"
))
})
}

#[doc(hidden)]
pub fn register_local_backend_provider(provider: LocalBackendProvider) -> PluginResult<()> {
let mut guard = local_backend_provider_guard()?;
*guard = Some(provider);
Ok(())
}

#[doc(hidden)]
pub fn clear_local_backend_provider() -> PluginResult<()> {
let mut guard = local_backend_provider_guard()?;
*guard = None;
Ok(())
}

pub(super) fn register_local_backend(
config: NeMoGuardrailsConfig,
ctx: &mut PluginRegistrationContext,
) -> PluginResult<()> {
let provider = local_backend_provider_guard()?.clone();

match provider {
Some(provider) => provider(config, ctx),
None => Err(PluginError::RegistrationFailed(
"built-in NeMo Guardrails local backend is unavailable in this runtime".to_string(),
)),
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const TEST_TIMEOUT: Duration = Duration::from_secs(5);

fn reset_runtime() {
let _ = clear_plugin_configuration();
crate::plugins::nemo_guardrails::component::clear_local_backend_provider().unwrap();
crate::shared_runtime::reset_runtime_owner_for_tests();
let context = global_context();
*context.write().unwrap() = NemoRelayContextState::new();
Expand Down Expand Up @@ -789,6 +790,22 @@ fn invalid_shapes_and_values_are_reported() {
.any(|diag| diag.field.as_deref() == Some("local.python_module"))
);

let local_request_defaults = validate_plugin_config(&plugin_config(json!({
"mode": "local",
"codec": "openai_chat",
"config_path": "./rails",
"request_defaults": {
"context": {"tenant": "demo"}
}
})));
assert!(local_request_defaults.has_errors());
assert!(local_request_defaults.diagnostics.iter().any(|diag| {
diag.field.as_deref() == Some("request_defaults")
&& diag
.message
.contains("local mode does not currently support request_defaults")
}));

let invalid_request_defaults = validate_plugin_config(&plugin_config(json!({
"mode": "remote",
"codec": "openai_chat",
Expand Down Expand Up @@ -975,7 +992,7 @@ fn enabled_local_initialization_fails_fast_until_backend_exists() {

match error {
crate::plugin::PluginError::RegistrationFailed(message) => {
assert!(message.contains("local backend"));
assert!(message.contains("unavailable in this runtime"));
}
other => panic!("unexpected error: {other}"),
}
Expand Down Expand Up @@ -1007,5 +1024,34 @@ fn enabled_unknown_mode_initialization_fails_fast_when_policy_ignores_validation
}
}

#[test]
fn enabled_local_initialization_dispatches_through_installed_provider() {
let _guard = crate::plugins::nemo_guardrails::test_mutex()
.lock()
.unwrap_or_else(|err| err.into_inner());
reset_runtime();

let provider_called = Arc::new(AtomicBool::new(false));
let provider_called_clone = Arc::clone(&provider_called);
crate::plugins::nemo_guardrails::component::register_local_backend_provider(Arc::new(
move |config, _ctx| {
provider_called_clone.store(true, Ordering::SeqCst);
assert_eq!(config.mode, "local");
assert_eq!(config.config_path.as_deref(), Some("./rails"));
Ok(())
},
))
.unwrap();

futures::executor::block_on(initialize_plugins(plugin_config(json!({
"mode": "local",
"codec": "openai_chat",
"config_path": "./rails"
}))))
.unwrap();

assert!(provider_called.load(Ordering::SeqCst));
}

#[path = "remote_tests.rs"]
mod remote_tests;
83 changes: 83 additions & 0 deletions crates/python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,16 @@
//! - `py_adaptive` — Python-facing adaptive helpers (`set_latency_sensitivity`)
//! - `py_plugin` — Python-facing generic plugin config/registration helpers
//! - `convert` — JSON ↔ Python conversion utilities
use nemo_relay::plugin::{PluginRegistrationContext, Result as PluginResult};
use nemo_relay::plugins::nemo_guardrails::component::{
NeMoGuardrailsConfig, register_local_backend_provider,
};
use nemo_relay::shared_runtime::initialize_shared_runtime_binding;
use nemo_relay_adaptive::plugin_component::register_adaptive_component;
use pyo3::prelude::*;
use serde_json::Value as Json;
use std::path::{Path, PathBuf};
use std::sync::Arc;

mod convert;
#[doc(hidden)]
Expand Down Expand Up @@ -52,13 +59,89 @@ fn _native(m: &Bound<'_, PyModule>) -> PyResult<()> {
"failed to register adaptive plugin component: {e}"
))
})?;
register_local_backend_provider(Arc::new(register_python_local_guardrails_backend)).map_err(
|e| {
pyo3::exceptions::PyRuntimeError::new_err(format!(
"failed to register NeMo Guardrails local backend provider: {e}"
))
},
)?;
py_types::register(m)?;
py_api::register(m)?;
py_plugin::register(m)?;
py_adaptive::register(m)?;
Ok(())
}

fn register_python_local_guardrails_backend(
config: NeMoGuardrailsConfig,
ctx: &mut PluginRegistrationContext,
) -> PluginResult<()> {
let plugin_config = match serde_json::to_value(config) {
Ok(Json::Object(config)) => config,
Ok(_) => {
return Err(nemo_relay::plugin::PluginError::Internal(
"NeMo Guardrails local config did not serialize to a JSON object".to_string(),
));
}
Err(err) => {
return Err(nemo_relay::plugin::PluginError::Internal(format!(
"failed to serialize NeMo Guardrails local config: {err}"
)));
}
};

let registrations = Python::attach(|py| {
let register_fn = load_guardrails_local_register_fn(py)?;
let namespace_prefix = ctx.qualify_name("");
crate::py_plugin::invoke_python_plugin_register(
py,
"nemo_guardrails",
&register_fn,
&plugin_config,
namespace_prefix,
)
})
.map_err(|err| nemo_relay::plugin::PluginError::RegistrationFailed(err.to_string()))?;

ctx.extend_registrations(registrations);
Ok(())
}

fn load_guardrails_local_register_fn(py: Python<'_>) -> PyResult<Bound<'_, PyAny>> {
let module = match py.import("nemo_relay._guardrails_local") {
Ok(module) => module,
Err(err) => {
let source_python_dir = guardrails_local_source_python_dir();
if !source_python_dir.exists() {
return Err(err);
}

prepend_python_path_if_missing(py, &source_python_dir)?;
py.import("nemo_relay._guardrails_local")?
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
};
module.getattr("register_local_backend")
}

fn guardrails_local_source_python_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../python")
}

fn prepend_python_path_if_missing(py: Python<'_>, path: &Path) -> PyResult<()> {
let sys = py.import("sys")?;
let sys_path = sys.getattr("path")?;
let path_str = path.to_string_lossy();

if !sys_path.contains(path_str.as_ref())? {
// Source-tree fallback for local development and in-repo tests where the
// Python package has not been installed into the active environment yet.
sys_path.call_method1("insert", (0, path_str.as_ref()))?;
}

Ok(())
}

#[cfg(test)]
#[path = "../tests/coverage/coverage_tests.rs"]
mod coverage_tests;
39 changes: 26 additions & 13 deletions crates/python/src/py_plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,27 @@ fn new_py_plugin_context(
)
}

pub(crate) fn invoke_python_plugin_register(
py: Python<'_>,
plugin_kind: &str,
register_fn: &Bound<'_, PyAny>,
plugin_config: &Map<String, Json>,
namespace_prefix: String,
) -> PyResult<Vec<PluginRegistration>> {
let py_ctx = new_py_plugin_context(
py,
plugin_kind,
Arc::new(Mutex::new(vec![])),
namespace_prefix,
)?;
let plugin_config_py = plugin_config_to_py(py, plugin_kind, plugin_config)?;
register_fn.call1((plugin_config_py, py_ctx.clone_ref(py)))?;
{
let py_ctx_ref = py_ctx.bind(py).borrow();
py_ctx_ref.drain_registrations()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

#[pyclass(name = "PluginContext")]
pub struct PyPluginContext {
registrations: Arc<Mutex<Vec<PluginRegistration>>>,
Expand Down Expand Up @@ -695,22 +716,14 @@ impl Plugin for PyPlugin {
let plugin_config = plugin_config.clone();
Box::pin(async move {
let registrations = Python::attach(|py| -> PyResult<Vec<PluginRegistration>> {
let py_ctx = new_py_plugin_context(
let register_fn = self.plugin.getattr(py, "register")?.into_bound(py);
invoke_python_plugin_register(
py,
&self.plugin_kind,
Arc::new(Mutex::new(vec![])),
&register_fn,
&plugin_config,
namespace_prefix,
)?;
let plugin_config_py = json_to_py(py, &Json::Object(plugin_config.clone()))?;
self.plugin.call_method1(
py,
"register",
(plugin_config_py, py_ctx.clone_ref(py)),
)?;
{
let py_ctx_ref = py_ctx.bind(py).borrow();
py_ctx_ref.drain_registrations()
}
)
})
.map_err(|err| PluginError::RegistrationFailed(err.to_string()))?;

Expand Down
Loading
Loading