diff --git a/crates/core/tests/integration/worker_plugin_tests.rs b/crates/core/tests/integration/worker_plugin_tests.rs index ed2b298f7..984c268ca 100644 --- a/crates/core/tests/integration/worker_plugin_tests.rs +++ b/crates/core/tests/integration/worker_plugin_tests.rs @@ -1209,10 +1209,6 @@ async fn python_worker_host_runtime_mark_and_mutated_request_round_trip() { .await .expect("Python tool execution intercept should call ToolNext and return its outcome"); assert_eq!(tool_result.result["provider_result"], true); - assert_eq!( - tool_result.result["_nemo_relay_plugin"]["tag"], - "managed-environment" - ); assert_eq!( tool_result.result["args"]["_nemo_relay_plugin"]["tag"], "managed-environment" @@ -1230,14 +1226,10 @@ async fn python_worker_host_runtime_mark_and_mutated_request_round_trip() { flush_subscribers().expect("Python callback mark should flush"); let captured_events = events.lock().unwrap(); - find_event( - &captured_events, - "examples.python_grpc_worker.tool_request", - None, - ); + find_event(&captured_events, "example.python_worker.tool_request", None); let tool_mark = find_event( &captured_events, - "examples.python_grpc_worker.tool_execution", + "example.python_worker.tool_execution", None, ); assert_eq!( diff --git a/crates/plugin/README.md b/crates/plugin/README.md index cf2ff1480..741175865 100644 --- a/crates/plugin/README.md +++ b/crates/plugin/README.md @@ -24,35 +24,16 @@ Native plugins run in the Relay process and are not sandboxed. They should depend on this crate rather than the host `nemo-relay` runtime crate, keeping the dynamic-library boundary on the stable C-compatible ABI. -## Why Use It? - -- **Author native plugins safely**: Implement `NativePlugin` with typed Rust - callbacks instead of constructing ABI tables directly. -- **Register real runtime behavior**: Use `PluginContext` for subscribers, - guardrails, and intercepts. -- **Keep a stable boundary**: Export one versioned native entry point through - the `nemo_relay_plugin!` macro. -- **Use host runtime helpers**: Emit events and manage scope state through the - high-level `PluginRuntime` wrapper. - -## What You Get - -- **`NativePlugin`**: Plugin kind, configuration validation, and registration - lifecycle contract. -- **`PluginContext`**: Component-scoped registration APIs for middleware and - subscribers. -- **`PluginRuntime`**: Typed helpers for Relay-owned scopes and marks. -- **Stable native ABI v4**: C-compatible host and plugin tables behind the - safe Rust authoring interface. Relay negotiates frozen v3 and v2 tables for - Relay 0.8-built plugins that target those layouts. -- **Typed async middleware**: Every typed guardrail, sanitizer, and intercept - returns a future driven by a per-plugin SDK-owned Tokio runtime. Subscribers - and raw synchronous ABI registrations remain synchronous. -- **Async continuations and streams**: Cloneable `ToolNext`, `LlmNext`, and - `LlmStreamNext` handles support repeated or concurrent calls. Streaming LLM - continuations use a pull-based host handle. -- **Canonical tool results**: `ToolNext` returns `ToolExecutionResult`, keeping - application results and opaque annotations adjacent across native API 1. +## Authoring Surface + +| Surface | Role | +|---|---| +| `NativePlugin` | Defines plugin identity, configuration validation, registration, and multiple-component behavior without requiring an author to construct ABI tables. | +| `PluginContext` | Installs component-owned subscribers, guardrails, intercepts, continuations, and streams. | +| `PluginRuntime` | Emits marks and manages Relay-owned scopes and scope stacks through typed host helpers. | +| `nemo_relay_plugin!` | Exports the one versioned native entry point used by the loader. | +| Native ABI v4 | Keeps C-compatible host and plugin tables behind the safe Rust interface while the host retains frozen v3 and v2 tables for previously compiled plugins. | +| Typed async middleware | Drives guardrails, sanitizers, and intercepts on a per-component SDK-owned Tokio executor. Subscribers and raw ABI registrations remain synchronous. | ## Installation @@ -96,20 +77,10 @@ nemo_relay_plugin::nemo_relay_plugin!(nemo_relay_register_plugin, || ExamplePlug ``` Build the `cdylib`, describe its entry symbol and compatibility in a -`relay-plugin.toml` manifest, then register it through the Relay CLI. See the +`relay-plugin.toml` manifest, then register it through the Relay CLI. Refer to the complete example for platform-specific artifact and manifest setup. -Use `compat.native_api = "1"`. Relay 0.8 establishes the canonical -`ToolExecutionResult` JSON contract as the native API 1 baseline. Every native -plugin must be rebuilt for Relay 0.8 and declare a `compat.relay` range that -excludes earlier versions. The recommended range is `>=0.8.0,<1.0`; an -open-ended range such as `>=0.8.0` is also valid. The manifest is the plugin -author's compatibility assertion, not proof that the artifact was rebuilt. - -The JSON contract is independent of the native host-table layout. This SDK -continues to export ABI v4, whose C-compatible layouts and callback signatures -are unchanged by the tool-result cutover. Future incompatible native JSON -contract changes must increment `compat.native_api`. Relay creates one +Typed async plugins require `compat.relay = ">=0.8.0,<1.0"`. Relay creates one SDK-owned Tokio executor for each configured plugin component. It defaults to two workers: enough for modest concurrent async I/O without broadly oversubscribing the host. Increase the count only when measured I/O concurrency @@ -117,6 +88,13 @@ leaves callbacks queued; lower it when the host runs many components or has a tight CPU budget. Do not block these workers; use async I/O or `tokio::task::spawn_blocking`. +Relay 0.8 establishes canonical tool results as the native API 1 baseline. Tool +callbacks and `ToolNext` return `ToolExecutionResult`, preserving an application result +and optional opaque annotation. Tool execution intercepts return the same pair plus +Relay-owned pending marks. The manifest contract remains `compat.native_api = "1"` and +the C host-table ABI remains v4, but plugins must rebuild and exclude pre-0.8 Relay +versions because the JSON result boundary changed. + Set a plugin-wide default in Rust, then let the component's TOML configuration override it: @@ -154,9 +132,3 @@ already accepted typed middleware before the plugin library unloads. Relay scope context is restored around every poll of a registered middleware future. Child tasks created with `tokio::spawn` do not automatically inherit that scope context. - -## Documentation - -- [NeMo Relay documentation](https://docs.nvidia.com/nemo/relay) -- [Build Plugins guide](https://docs.nvidia.com/nemo/relay/build-plugins/about) -- [Rust native plugin example](https://github.com/NVIDIA/NeMo-Relay/blob/main/examples/rust-native-plugin/README.md) diff --git a/crates/worker-proto/README.md b/crates/worker-proto/README.md index a1f2525ef..283ea51c1 100644 --- a/crates/worker-proto/README.md +++ b/crates/worker-proto/README.md @@ -25,52 +25,23 @@ Use `nemo-relay-worker` to author Rust workers. Depend on this crate directly only when implementing another worker SDK, a custom host, or protocol-level tooling. -Relay 0.8 establishes the canonical tool-result contract as the `grpc-v1` -baseline. Workers built for an earlier Relay release must be rebuilt, and their -manifests must declare a `compat.relay` range beginning at `0.8.0` or later. -The protocol identifier and protobuf package remain `grpc-v1` and -`nemo.relay.worker.v1`, respectively. However, the generated protobuf API -changes at the tool-result boundary: `ToolNext` returns -`ToolExecutionResultResponse`, and `ToolExecutionInterceptResult.outcome` is a -typed `ToolExecutionInterceptOutcome`. Rebuild every worker against the Relay -0.8 protocol definitions. - -## Why Use It? - -- **Share the stable transport contract**: Use the `grpc-v1` service and - message definitions accepted by Relay worker manifests. -- **Use generated Tonic bindings**: Access versioned client and server types - from `v1` without generating protobuf code in a consumer project. -- **Keep data ownership clear**: Use structural protobuf wrappers for tool - results while preserving open application payloads as lossless JSON bytes. - Other Relay DTOs continue to use JSON envelopes backed by - `nemo-relay-types`. - -## What You Get - -- **`WORKER_PROTOCOL_GRPC_V1`**: The stable `grpc-v1` protocol identifier. -- **`v1` module**: Generated `PluginWorker` and `RelayHostRuntime` gRPC - clients, servers, services, and messages. -- **JSON envelope helpers**: `json_envelope` and `decode_json_envelope` for - serializing Relay DTOs into protocol payloads. -- **JSON value helpers**: `json_value` and `decode_json_value` for the opaque - application values inside structural tool-result messages. - -## Structural Tool Result Contract - -The `grpc-v1` tool-result boundary uses these generated message types: - -| Protocol Location | Protobuf Type | -| --- | --- | -| Successful `RelayHostRuntime.ToolNext` response | `ToolExecutionResultResponse.value` containing `ToolExecutionResult` | -| `ToolExecutionInterceptResult.outcome` | `ToolExecutionInterceptOutcome` | - -Both messages define `result` and optional `annotation` fields. Intercept -outcomes also carry their ordered `pending_marks` as one JSON array. Arbitrary -JSON values use `JsonValue`, whose bytes contain exactly one JSON value; this -preserves JSON integers and other application data without the numeric coercion -of `google.protobuf.Value`. Hosts and SDKs reject a missing required `result` -or invalid JSON bytes. JSON null annotations normalize to absence. +Relay 0.8 establishes canonical tool results as the `grpc-v1` baseline. Workers built +for earlier releases must regenerate their bindings, rebuild, and declare +`compat.relay` beginning at `0.8.0`. `ToolNext` returns `ToolExecutionResultResponse`, +and tool execution intercepts use structural `ToolExecutionInterceptOutcome` messages. + +## Protocol Surface + +| Surface | Role | +|---|---| +| `WORKER_PROTOCOL_GRPC_V1` | Identifies the stable protocol accepted by Relay worker manifests. | +| `v1` module | Exposes generated `PluginWorker` and `RelayHostRuntime` Tonic clients, servers, services, and messages without regenerating protobuf in a consumer. | +| JSON envelope helpers | Serialize Relay DTOs through `json_envelope` and `decode_json_envelope`, keeping protobuf responsible for transport flow rather than runtime data modeling. | +| JSON value helpers | Serialize application-owned fields inside structural tool-result messages through `json_value` and `decode_json_value`. | + +`ToolExecutionResult` contains required `result` and optional `annotation` values. +`ToolExecutionInterceptOutcome` adds Relay-owned `pending_marks`. These fields use +lossless protobuf `JsonValue` wrappers rather than `google.protobuf.Value`. ## Installation @@ -97,9 +68,3 @@ fn main() -> Result<(), serde_json::Error> { Ok(()) } ``` - -## Documentation - -- [NeMo Relay documentation](https://docs.nvidia.com/nemo/relay) -- [Build Plugins guide](https://docs.nvidia.com/nemo/relay/build-plugins/about) -- [Rust worker SDK](https://github.com/NVIDIA/NeMo-Relay/blob/main/crates/worker/README.md) diff --git a/crates/worker/README.md b/crates/worker/README.md index ed4f57530..8c75ed953 100644 --- a/crates/worker/README.md +++ b/crates/worker/README.md @@ -20,34 +20,25 @@ SPDX-License-Identifier: Apache-2.0 dynamic worker plugins. Use it when plugin code needs process isolation and communicates with Relay through the versioned `grpc-v1` worker protocol. -Relay 0.8 establishes canonical tool results as the `grpc-v1` baseline. -Workers built for an earlier Relay release must be rebuilt with this SDK and -declare a `compat.relay` range beginning at `0.8.0` or later. The protocol name -remains `grpc-v1`, but its generated `ToolNext` response and tool-execution -outcome fields now use structural protobuf messages. - -## Why Use It? - -- **Isolate plugin code**: Run custom runtime behavior outside the Relay host - process. -- **Use typed registration APIs**: Implement `WorkerPlugin` and register - subscribers, guardrails, or intercepts with `PluginContext`. -- **Call the host runtime**: Emit marks, manage scopes, and invoke middleware - continuations through `PluginRuntime`. -- **Keep lifecycle managed**: Let Relay provide authenticated endpoints and - start the worker with `serve_plugin`. - -## What You Get - -- **`WorkerPlugin`**: The plugin identity, validation, and registration - contract. -- **`PluginContext`**: Typed registrations for all supported worker surfaces. -- **`PluginRuntime` and continuations**: Host-runtime callbacks and tool/LLM - execution-chain helpers. -- **Canonical tool results**: `ToolNext` returns `ToolExecutionResult`, so - workers can preserve an opaque annotation independently of the tool result. -- **`serve_plugin`**: Tokio gRPC server startup using the Relay-provided worker - environment. +Relay 0.8 establishes canonical tool results as the `grpc-v1` baseline. Workers built +for an earlier release must rebuild with this SDK and declare `compat.relay` beginning at +`0.8.0`. The protocol identifier remains `grpc-v1`, but `ToolNext` now returns +`ToolExecutionResult`, which keeps an optional opaque annotation beside the application +result. + +## Authoring Surface + +| Surface | Role | +|---|---| +| `WorkerPlugin` | Defines plugin identity, validation, registration, and multiple-component behavior in the worker process. | +| `PluginContext` | Installs typed handlers for all 15 supported registration surfaces. | +| `PluginRuntime` and continuations | Emit marks, manage scopes, and call the remaining tool or LLM execution chain through the authenticated host service. | +| Canonical tool results | Preserve application results and opaque annotations across tool callbacks and continuations. | +| `serve_plugin` | Starts the Tokio gRPC server from the activation identity, local endpoints, and token supplied by Relay. | + +This model keeps plugin dependencies and crashes outside the Relay process, while the +SDK retains the shared runtime contract and manages authentication, cancellation, and +shutdown. ## Installation @@ -100,9 +91,3 @@ Relay sends `CancelInvocation` when a managed caller is cancelled, times out, or stops consuming a stream, and the SDK aborts the matching async callback task. An accepted cancellation confirms the task was found; it cannot prove that arbitrary blocking work started by the callback has stopped. - -## Documentation - -- [NeMo Relay documentation](https://docs.nvidia.com/nemo/relay) -- [Build Plugins guide](https://docs.nvidia.com/nemo/relay/build-plugins/about) -- [Python gRPC worker plugin example](https://github.com/NVIDIA/NeMo-Relay/blob/main/examples/python-grpc-worker-plugin/README.md) diff --git a/docs/about-nemo-relay/concepts/plugins.mdx b/docs/about-nemo-relay/concepts/plugins.mdx index 78dec894b..4ef7c0925 100644 --- a/docs/about-nemo-relay/concepts/plugins.mdx +++ b/docs/about-nemo-relay/concepts/plugins.mdx @@ -20,11 +20,8 @@ Plugins package reusable runtime components. ## Plugin Configuration Model -A plugin configuration document has three main areas: - -- `version` -- `components` -- `policy` +A plugin configuration document combines a schema `version`, an ordered set of +`components`, and a `policy` for unsupported configuration. ### Version @@ -49,7 +46,7 @@ Two similarly named files serve different purposes: Refer to [Plugin Configuration Files](/configure-plugins/plugin-configuration-files) for runtime configuration and -[Discoverable Plugins](/build-plugins/dynamic-plugins/about) for the package +[Package Discoverable Plugins](/build-plugins/package-discoverable-plugins) for the package manifest. ## Component Lifecycle @@ -147,11 +144,9 @@ It connects plugin configuration to the active runtime. ## What Plugins Can Register -Depending on the component, a plugin can register: - -- Middleware -- Subscribers -- Related runtime helpers +Depending on the component, a plugin can register middleware, subscribers, and +runtime helpers. These are the same execution surfaces available elsewhere in +Relay, but the component context gives them shared configuration and ownership. This is what makes plugins a packaging mechanism rather than a separate runtime model. Plugins do not replace scopes, middleware, or subscribers. They install @@ -176,7 +171,7 @@ ordering, and events still own the canonical runtime record. All plugin components use the same validation and activation model. They differ in how their component kind becomes available to the host: -| Delivery model | How it becomes available | Use when | +| Delivery Model | How It Becomes Available | Use When | | --- | --- | --- | | Built-in | The Relay host registers a linked first-party component automatically. | The component ships as part of that host. | | Host-registered | An embedding application links a component crate and registers its kind before validation. | An embedding application decides which component crates to link. | @@ -230,11 +225,9 @@ built-in integration is deprecated and scheduled for removal in NeMo Relay 0.9. There is no replacement in NeMo Relay 0.8; any replacement will target 0.9 or later. Do not use the built-in component for new deployments. -The current shipped user-facing paths are: - -- The remote backend for Guardrails-service integration -- The Python-backed local backend for `nemoguardrails` integration through a - subprocess worker +The current user-facing paths are the remote backend for Guardrails-service +integration and the Python-backed local backend that runs `nemoguardrails` +through a subprocess worker. Detailed Guardrails plugin configuration belongs in [NeMo Guardrails Configuration](/configure-plugins/nemo-guardrails/configuration). @@ -283,7 +276,7 @@ manifest, optional static schema, host policy, compatibility, and trust evidence before enabling or running a dynamic plugin. During startup, Relay loads the enabled adapter and then validates the synthesized component. Refer to [Configure Discoverable Plugins](/configure-plugins/discoverable-plugins) -for the operator workflow and [Discoverable Plugins](/build-plugins/dynamic-plugins/about) +for the operator workflow and [Package Discoverable Plugins](/build-plugins/package-discoverable-plugins) for the authoring workflow. The operator lifecycle is explicit: @@ -304,13 +297,8 @@ The operator lifecycle is explicit: These commands manage future host activation. Teardown of an already running host remains part of that host's owned plugin cleanup. -## Practical Guidance - -Use these practices when applying the concept in application or integration code. - -- Use plugins when behavior should be reusable across applications or - integrations. -- Validate plugin config before initialization. -- Treat plugins as the configuration-driven installation path for runtime - behavior. -- Keep detailed field-by-field config questions in the relevant guide for that plugin component. +Plugins are most useful when runtime behavior should be reusable across +applications or integrations. Validate configuration before initialization, +and keep field-by-field settings in the guide for the component that owns +them. The [plugin authoring overview](/build-plugins/about) explains when a +language-binding, native, or worker plugin is the right delivery model. diff --git a/docs/build-plugins/about.mdx b/docs/build-plugins/about.mdx index d4d2a3523..95cb9c8b6 100644 --- a/docs/build-plugins/about.mdx +++ b/docs/build-plugins/about.mdx @@ -1,78 +1,91 @@ --- -title: "Build Plugins" -description: "Build code-driven, native dynamic, and gRPC worker plugins for NeMo Relay." +title: "About Build Plugins" +description: "Choose a NeMo Relay plugin model and follow a complete authoring path." position: 1 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} - -Use this section when you want to create and package reusable NeMo Relay -behavior as a plugin. - -Plugins are the configuration-driven packaging layer for shared runtime -behavior. A plugin can validate component-local config, register middleware and -subscribers through a component-scoped context, and rely on the plugin system to -report diagnostics and roll back partial setup when activation fails. - -Plugins prevent repeated registration code for policies, request transforms, -exporters, and related runtime components. They give shared behavior a stable -kind name, a structured config document, and a clear activation lifecycle. - -## When to Use This Guide - -Use this guide when you need to package reusable NeMo Relay behavior. - -- Ship policy bundles across applications -- Package framework-agnostic request transforms -- Validate operator-supplied config before runtime behavior changes -- Package a manifest-backed native library or worker for discovery by the CLI - -Keep behavior scope-local when it applies to only one request or tenant. Use a -process-level plugin only for reusable behavior. - -## Plugin Types - -Choose the group that matches the process boundary and language of the plugin. - -### Language Binding Plugins (Rust, Python, Node.js) - -Application code registers these in-process plugins directly. Start with -[Language Binding Plugins](/build-plugins/language-binding/about) for the -common contract and binding-specific registration examples. - -### Native Dynamic Plugins (Rust) - -These plugins run in-process from a separately packaged Rust shared library. -Start with [Native Dynamic Plugins (Rust)](/build-plugins/dynamic-plugins/native-dynamic/about). - -### gRPC Worker Plugins (Rust) - -These plugins run out of process as a Relay-managed Rust worker. Use [gRPC -Worker Plugins (Rust)](/build-plugins/dynamic-plugins/grpc-worker/rust/about). - -### gRPC Worker Plugins (Python) - -These plugins run out of process as a Relay-managed Python worker. Use [gRPC -Worker Plugins (Python)](/build-plugins/dynamic-plugins/grpc-worker/python/about). - -## Next Steps - -- Build a language binding plugin in this order: - 1. [Validate Plugin Configuration](/build-plugins/language-binding/validate-configuration) - 2. [Register Plugin Behavior](/build-plugins/language-binding/register-behavior) - 3. [Design Plugin Configuration](/build-plugins/language-binding/advanced-configuration) - 4. [Code Examples](/build-plugins/language-binding/code-examples) -- Build a shared-library example with [Build a Rust Native Plugin](/build-plugins/dynamic-plugins/native-dynamic/rust-native-plugin-example). -- Compare manifest-backed packages in [Discoverable Plugins](/build-plugins/dynamic-plugins/about). -- Choose a worker runtime in [gRPC Worker Plugin Concepts](/build-plugins/dynamic-plugins/grpc-worker/about) - and implement the boundary with [gRPC Worker Protocol Overview](/build-plugins/dynamic-plugins/grpc-worker/grpc-worker-protocol). -- Configure a built-in or packaged plugin in [Configure Plugins](/configure-plugins/about). - -Start by deciding which runtime surfaces the plugin owns: middleware, -subscribers, or a combination of related runtime behavior. Define the smallest -JSON-compatible config that can drive that behavior, validate it before -registration, and keep external objects or callables out of the config document. - -Use plugins for reusable process-level behavior. Keep request-specific behavior -scope-local so Relay removes it when the owning scope closes. +A plugin turns reusable runtime behavior into a named, configurable component. It can +observe Relay events, remove sensitive fields from observability records, reject or +rewrite tool and model requests, wrap real execution, or combine those responsibilities +when one configuration contract genuinely controls them. + +The smallest useful plugin has four visible stages: + +1. Define the implementation. +2. Register its stable kind. +3. Activate one configured component. +4. Clear the behavior owned by that component. + +The complete [language-binding quickstart](/build-plugins/language-binding/about) shows +this application-owned example in Python, Node.js, and Rust. In every version, the tool +request intercept adds `plugin_tag` to the real tool arguments, so the final assertion +proves that activation changed execution rather than only producing a successful report. + +The implementation object is not middleware by itself. Relay calls `register` only for +an enabled, valid component and owns every callback installed through the supplied +context. That ownership lets cleanup remove the intercept before the kind is +deregistered. + +The first design decision is where the plugin should run. That choice determines how +quickly callbacks execute, what must be packaged, which failures can affect the host, +and how much control the application keeps over dependencies. + +## Choose the Execution Model + +| Consideration | Language-Binding Plugin | Native Rust Plugin | gRPC Worker Plugin | +|---|---|---|---| +| Process boundary | Runs in the application process through the loaded Rust, Python, or Node.js binding. | Runs in the Relay process as a dynamically loaded Rust shared library. | Runs in a separate process and communicates with Relay through `grpc-v1`. | +| Callback overhead | Lowest setup and dispatch complexity for application-owned code. Python and Node.js callbacks still cross their binding boundary. | Avoids a process hop and JSON envelopes, making it the strongest fit for latency- or throughput-sensitive reusable middleware. | Adds gRPC dispatch, JSON-envelope conversion, and worker scheduling to callback paths. | +| Expected latency and throughput | Usually the practical default when behavior already lives with the application. Measure language-runtime costs on hot paths. | Best qualitative fit when per-call overhead matters enough to justify native packaging. This guide does not claim benchmark numbers. | Best chosen for isolation or runtime flexibility, not for minimum callback latency. Streaming callbacks retain the same boundary for every chunk. | +| Dependency isolation | Shares the application environment and its dependency constraints. | Shares the host address space and native dependency environment. | Keeps Python, Rust, or custom command dependencies in the worker environment. | +| Failure impact | A fatal callback or runtime failure can affect the application process. | A memory-safety defect, panic across an unsupported boundary, or native crash can terminate or corrupt the host process. | A worker crash terminates that plugin process. Relay still treats the worker as trusted code, and process separation is not a security sandbox. | +| Distribution | Ships with application source or packages and needs no dynamic manifest. | Ships a platform-specific shared library, `relay-plugin.toml`, JSON Schema, and integrity metadata. | Ships an executable or managed Python environment, `relay-plugin.toml`, JSON Schema, and integrity metadata. | +| Platform specificity | Follows the application binding and its existing deployment target. | Requires a compatible binary for each operating system and architecture. | The command and environment must run on the target, but the protocol is language-neutral. | +| Trust | Has the application's in-process authority. | Requires full in-process trust and is subject to native-loading policy. | Uses an authenticated local endpoint, but the worker remains trusted and can request host runtime operations. | +| Cancellation | Follows the binding callback and managed-call lifecycle. | Typed async middleware receives cooperative cancellation through the host-owned call; a synchronous subscriber must return promptly. | Relay propagates cooperative cancellation. The worker must stop expensive work and downstream continuation calls when cancellation is observed. | +| Development speed | Fastest path for application teams because code, tests, and dependencies stay in one project. | Requires Rust, native packaging, and compatibility testing. | The Python SDK is convenient for isolated Python dependencies; the Rust SDK offers a compiled worker; a custom command requires implementing the protocol. | + +Language-binding plugins are appropriate for application-owned behavior such as adding a +tenant header, enforcing a local model policy, or installing an event subscriber beside +the code that consumes it. They have the simplest build and distribution path, so start +with [Language Binding Plugins](/build-plugins/language-binding/about) unless a separate +artifact solves a concrete operational problem. + +Native Rust plugins suit reusable middleware whose callback latency or throughput is +important enough to justify platform-specific binaries and full in-process trust. A +native plugin uses manifest compatibility `compat.native_api = "1"`; the current SDK +negotiates C host-table ABI v4 and retains frozen v3 and v2 host-table compatibility. +Those are different version axes, as the [Native ABI Reference](/build-plugins/native/native-abi-reference) +explains. + +Worker plugins suit dependencies that should live outside the application environment, +teams that need a different runtime, or deployments that want a separate crash boundary. +That separation comes with gRPC, JSON-envelope, and scheduling overhead. The Python and +Rust SDKs implement the same `grpc-v1` contract. A custom command worker is an advanced +protocol implementation path rather than a fourth plugin model. + +Relay 0.8 makes the tool result explicit at every dynamic boundary. A managed tool +callback and its continuation return a `ToolExecutionResult`: the application payload in +`result` and an optional opaque `annotation`. A tool execution intercept returns that +same pair plus Relay-owned pending marks. Native and worker plugins built for an earlier +release must rebuild for this contract. Workers retain the `grpc-v1` name and protobuf +package; their tool-result fields, not their protocol identity, changed. + +## Match Behavior to a Plugin + +| Desired Behavior | Good Starting Model | Why | +|---|---|---| +| Add deployment metadata to every model request from one service | Language-binding plugin | The behavior is application-owned, can be tested beside the call site, and needs no extra artifact. | +| Distribute a high-volume request policy across several Relay applications | Native Rust plugin | In-process typed callbacks avoid the worker boundary, while a manifest provides reusable packaging. | +| Run a Python detector with dependencies that conflict with the host environment | Python gRPC worker | The managed worker environment isolates those packages while preserving the full plugin registration contract. | +| Ship a compiled policy service without loading code into the host address space | Rust gRPC worker | The worker remains separately deployable and crash-isolated while using the supported SDK. | +| Implement `grpc-v1` from another language or existing service executable | Custom command worker | The protocol is language-neutral, but you own handshake, authentication, cancellation, envelopes, and shutdown behavior. | + +Whichever model you choose, the shared contract comes first. [Plugin Shape](/build-plugins/fundamentals/plugin-shape) +explains lifecycle and ownership, [Configuration and Validation](/build-plugins/fundamentals/configuration-and-validation) +defines the operator-facing boundary, and [PluginContext](/build-plugins/fundamentals/plugin-context) +describes every registration surface. Each model-specific section then follows a +complete path from invalid configuration through activation, representative execution, +observable verification, and teardown. diff --git a/docs/build-plugins/configuration-and-validation.mdx b/docs/build-plugins/configuration-and-validation.mdx new file mode 100644 index 000000000..c2d62ca0d --- /dev/null +++ b/docs/build-plugins/configuration-and-validation.mdx @@ -0,0 +1,228 @@ +--- +title: "Configuration and Validation" +description: "Design portable plugin configuration and actionable validation diagnostics." +position: 3 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Plugin configuration is an operator contract. Keep it portable JSON, make each setting's +effect clear, and reject ambiguous input before Relay changes the runtime. Provider +clients, callbacks, file handles, and resolved secret values belong in implementation +state rather than in the configuration document. + +The `runtime` group is independent of the request-policy group. When either runtime +setting is enabled, the examples install a small tool-execution wrapper that emits marks +or manages an isolated stack. Disabling request rewriting, including with +`requests.break_chain`, does not suppress those runtime operations. + +## The Two Configuration Files Have Different Jobs + +| File | Purpose | +|---|---| +| `plugins.toml` | Runtime configuration. It contains component kinds, `enabled` state, component-local `config`, validation policy, and references to discoverable manifests. Relay can layer discovered files with binding-provided configuration. | +| `relay-plugin.toml` | Package manifest for one discoverable native library or `grpc-v1` worker. It declares identity, compatibility, entrypoint, optional JSON Schema, and integrity metadata. It does not replace the component configuration in `plugins.toml`. | + +The binding APIs use the same canonical document shape as `plugins.toml`: a document +version, `components`, and `policy`. Each component has a `kind`, an `enabled` flag, and a +component-local JSON object. Keys stay `snake_case` in Rust, Python, Node.js, JSON, and +TOML even though Node.js API method names use `camelCase`. + +The following configuration activates every shared feature group and the native-only +executor control: + +```toml +version = 1 + +[[components]] +kind = "documentation-plugin" +enabled = true + +[components.config] +tag = "documentation" + +[components.config.observe] +enabled = true +redact_keys = ["secret"] + +[components.config.requests] +enabled = true +mode = "enforce" +blocked_tools = ["dangerous_tool"] +blocked_models = ["restricted-model"] +header_name = "x-nemo-relay-plugin" +header_value = "documentation" +priority = 20 +break_chain = false + +[components.config.execution] +enabled = true +priority = 30 +emit_pending_marks = true + +[components.config.runtime] +emit_marks = true +emit_isolated_scope = true + +# Include this group only for a native typed plugin. +[components.config.executor] +worker_threads = 2 + +[policy] +unknown_component = "error" +unknown_field = "warn" +unsupported_value = "error" +``` + +The top-level policy controls document validation that Relay owns. A custom plugin's +`validate` hook is responsible for unknown fields, types, ranges, enums, and cross-field +rules inside its own `config`. Built-in plugins can additionally receive host policy +overrides. Do not assume the top-level `unknown_field` choice automatically inspects an +arbitrary third-party JSON object; implement and test that behavior in the plugin. + +## Publish the Component Schema with the Package + +The following strict schema is used by the Python worker and native examples. The native +example adds an `executor` object because only the typed native SDK owns a Tokio executor. +`additionalProperties: false` is repeated inside each object so a misspelled control fails +at the exact nesting level where it appears. + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Documentation Plugin Configuration", + "type": "object", + "additionalProperties": false, + "properties": { + "tag": { "type": "string", "minLength": 1, "default": "documentation" }, + "observe": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean", "default": true }, + "redact_keys": { + "type": "array", + "items": { "type": "string" }, + "default": ["secret"] + } + } + }, + "requests": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean", "default": true }, + "mode": { + "type": "string", + "enum": ["observe", "enforce"], + "default": "enforce" + }, + "blocked_tools": { + "type": "array", + "items": { "type": "string" }, + "default": ["dangerous_tool"] + }, + "blocked_models": { + "type": "array", + "items": { "type": "string" }, + "default": ["restricted-model"] + }, + "header_name": { + "type": "string", + "minLength": 1, + "default": "x-nemo-relay-plugin" + }, + "header_value": { + "type": "string", + "minLength": 1, + "default": "documentation" + }, + "priority": { "type": "integer", "default": 20 }, + "break_chain": { "type": "boolean", "default": false } + } + }, + "execution": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { "type": "boolean", "default": true }, + "priority": { "type": "integer", "default": 30 }, + "emit_pending_marks": { "type": "boolean", "default": true } + } + }, + "runtime": { + "type": "object", + "additionalProperties": false, + "properties": { + "emit_marks": { "type": "boolean", "default": true }, + "emit_isolated_scope": { "type": "boolean", "default": true } + } + } + } +} +``` + +Defaults in a schema describe the intended value to tools and readers; JSON Schema does +not insert them into the component object. The implementation must merge the same +defaults before it validates and registers behavior. Keeping one checked default value +beside each typed configuration structure prevents the schema, validator, and runtime +from silently interpreting omitted fields differently. + +The Rust worker intentionally uses a permissive schema with +`additionalProperties: true`, then reports unknown keys from its validation hook as +warnings. This demonstrates a warning-based migration policy. Do not copy the strict +schema into that worker unless unknown keys should become activation errors; the schema +and validator must express the same operator contract. + +## Write Diagnostics for the Operator + +Validation should be deterministic and free of registration or lasting I/O. A diagnostic +contains a level, stable code, component identity when known, field path when known, and +a sentence explaining how to correct the value. Use a warning when activation remains +safe and the operator should review the choice. Use an error when the plugin cannot +install the promised behavior. + +The following diagnostic identifies an unsupported request-policy value and tells the +operator which field to correct: + +```json +{ + "level": "error", + "code": "documentation-plugin.unsupported_mode", + "component": "documentation-plugin", + "field": "requests.mode", + "message": "requests.mode must be either observe or enforce" +} +``` + +A checked-in JSON Schema gives editors and package validation the same first line of +defense, but it does not replace the validation hook. The hook still owns semantic rules +such as requiring at least one blocked target in `enforce` mode, checking relationships +between feature groups, or applying a deliberate unknown-field policy. + +Store secret references rather than secret values. A plugin can define environment +variable names, credential-provider references, or another deployment-specific lookup +in its schema, then resolve the secret during registration. Validation can confirm that +the reference is well-formed without printing or persisting the resolved value in a +diagnostic or runtime report. + +## Validate Before Activation + +Use the following sequence to prevent invalid configuration from changing runtime state: + +1. Construct the effective plugin document, including any discovered `plugins.toml` + layers that production startup uses. +2. Call the binding or CLI validation path before initialization. Treat the returned + report as data and fail deployment when it contains error diagnostics. +3. Test missing required fields, wrong types, unsupported enum values, unknown fields, + and invalid cross-field combinations. Repeat one invalid case with `enabled = false`; + disabled components are still validated. +4. Initialize only after the effective report is acceptable, then inspect the activation + report separately. An unknown enabled kind can still prevent initialization when a + permissive policy reported it as a warning. +5. Exercise one call for each enabled feature group so configuration controls are tied to + observable behavior. + +Success means invalid or disabled-invalid input produces stable diagnostics without any +registration, while a valid document activates only the requested feature groups. The +complete runtime discovery and layering rules remain in [Plugin Configuration Files](/configure-plugins/plugin-configuration-files). diff --git a/docs/build-plugins/dynamic-plugins/about.mdx b/docs/build-plugins/dynamic-plugins/about.mdx deleted file mode 100644 index 5a2847f5a..000000000 --- a/docs/build-plugins/dynamic-plugins/about.mdx +++ /dev/null @@ -1,119 +0,0 @@ ---- -title: "Discoverable Plugins" -description: "Package Rust native libraries and local gRPC workers as discoverable NeMo Relay plugins." -position: 9 ---- -{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -SPDX-License-Identifier: Apache-2.0 */} - - -Use discoverable plugins when you need to package reusable behavior outside the -Relay host binary. A package includes a `relay-plugin.toml` manifest and one of -two execution lanes: - -| Lane | Use when | Stable boundary | -| --- | --- | --- | -| `rust_dynamic` | Behavior must run in the Relay process. | Versioned native ABI (v2–v4) | -| `worker` | Behavior should run in a separate local process. | `grpc-v1` | - -The manifest describes compatibility, capabilities, artifact integrity, and the -load contract. Operators register the manifest reference and component -configuration in `plugins.toml`, then enable the plugin through the CLI -lifecycle. Keep the package contract separate from the operator workflow: refer -operators to [Configure Discoverable -Plugins](/configure-plugins/discoverable-plugins). - -## Manifest Contract - -Every manifest uses the common fields shown below, followed by lane-specific -compatibility, capability, source, and load fields. This complete example -defines a Python worker: - -```toml -manifest_version = 1 - -[plugin] -id = "acme.example" -kind = "worker" - -[compat] -relay = ">=0.8.0,<1.0" -worker_protocol = "grpc-v1" - -[defaults] -enabled = false - -[capabilities] -items = ["plugin_worker"] - -[source] -manifest_root = "." -artifact = "acme_example/worker.py" - -[integrity] -sha256 = "sha256:" - -[load] -runtime = "python" -entrypoint = "acme_example.worker:main" -``` - -`compat.relay` is a normal SemVer requirement. Dynamic plugins on the Relay -0.8 canonical result baseline must exclude earlier Relay versions; use -`>=0.8.0,<1.0` unless you deliberately need a narrower or open-ended -0.8-or-newer range. The manifest is the plugin author's compatibility -assertion; it does not attest how an artifact was built. The [0.8 migration -guide](/reference/migration-guides#return-canonical-tool-execution-results) -describes the required rebuild. Keep -`defaults.enabled = false`: -operators must enable a registered dynamic plugin explicitly. Declare only the -capabilities the plugin needs. Add `config_schema.path` only with the -`config_schema` capability. - -The following requirements vary by execution lane: - -| Manifest area | Native dynamic plugin | Worker plugin | -| --- | --- | --- | -| `plugin.kind` | `rust_dynamic` | `worker` | -| `compat` | `native_api = "1"` | `worker_protocol = "grpc-v1"` | -| `capabilities.items` | Includes `plugin_native` | Includes `plugin_worker` | -| `load` | `library` and `symbol` | `runtime` and `entrypoint` | -| `source.manifest_root` | Optional | Required for `runtime = "python"`; `nemo-relay plugins add` uses it to create and retain the managed worker environment. | - -Relay 0.8 retains the native API 1 and `grpc-v1` identifiers while establishing -the canonical `ToolExecutionResult` contract. Native application JSON semantics -and worker protobuf tool-result types change; see the [0.8 migration -guide](/reference/migration-guides#return-canonical-tool-execution-results). -Future incompatible contract changes must increment the corresponding native -API or worker protocol version. - -Use `runtime = "python"` for a `module:function` entrypoint, `runtime = -"rust"` for a Rust executable, or `runtime = "command"` for another local -executable that implements `grpc-v1`. - -The CLI verifies `source.artifact` against `integrity.sha256` during `plugins -add` and `plugins validate`. After a native artifact or non-Python worker -changes, update its digest and rerun validation. After Python worker source or -dependency changes, remove and add the worker again so Relay rebuilds its -managed environment. Native loading also verifies `load.library` against -`integrity.sha256`. Add `integrity.signature` when an operator’s policy can -require Ed25519 signature verification. Treat these fields as release -artifacts: update the digest and signature whenever the declared artifact -changes. - -## Choose a Discoverable Plugin Type - -Discoverable plugins always use a manifest. Choose the guide that matches the -manifest-backed package you distribute: - -- **Native dynamic plugin (Rust):** Refer to [Native Dynamic Plugins (Rust)](/build-plugins/dynamic-plugins/native-dynamic/about) - for shared-library packages and the native ABI, then [Build a Rust Native Plugin](/build-plugins/dynamic-plugins/native-dynamic/rust-native-plugin-example) - for the SDK example. -- **gRPC worker plugin:** Refer to [gRPC Worker Plugin Concepts](/build-plugins/dynamic-plugins/grpc-worker/about) - for runtime choices, lifecycle, and trust; [gRPC Worker Plugins (Rust)](/build-plugins/dynamic-plugins/grpc-worker/rust/about) - or [gRPC Worker Plugins (Python)](/build-plugins/dynamic-plugins/grpc-worker/python/about) - for language-specific authoring; and [gRPC Worker Protocol Overview](/build-plugins/dynamic-plugins/grpc-worker/grpc-worker-protocol) - for the stable `grpc-v1` boundary. - -For the complete comparison of manifest-backed packages and in-process, -code-driven language binding plugins, refer to [Build Plugins](/build-plugins). diff --git a/docs/build-plugins/dynamic-plugins/grpc-worker/about.mdx b/docs/build-plugins/dynamic-plugins/grpc-worker/about.mdx deleted file mode 100644 index 46db27982..000000000 --- a/docs/build-plugins/dynamic-plugins/grpc-worker/about.mdx +++ /dev/null @@ -1,94 +0,0 @@ ---- -title: "gRPC Worker Plugin Concepts" -description: "Choose and package local gRPC worker plugins for NeMo Relay." -position: 12 ---- -{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -SPDX-License-Identifier: Apache-2.0 */} - - -Worker dynamic plugins run outside the Relay process and install subscribers, -guardrails, and intercepts through the stable `grpc-v1` protocol. The host -starts the local worker, validates its component configuration, receives a -declarative registration plan, and installs proxy callbacks. - -## Choose a Worker Runtime - -Choose the runtime that matches the artifact you distribute: - -- Use `python` for a `module:function` entrypoint in a Relay-managed Python - environment. Include `source.manifest_root`, then run `nemo-relay plugins - add` so Relay can create and retain the required environment. -- Use `rust` for a manifest-relative or absolute Rust executable. -- Use `command` for another manifest-relative or absolute local executable that - implements `grpc-v1`. - -All worker runtimes receive the same local endpoint and activation credentials. -The process boundary isolates crashes and dependencies, but it does not create a -security sandbox. - -## Manifest - -Use the following worker manifest: - -```toml -manifest_version = 1 - -[plugin] -id = "acme.policy" -kind = "worker" - -[compat] -relay = ">=0.8.0,<1.0" -worker_protocol = "grpc-v1" - -[defaults] -enabled = false - -[capabilities] -items = ["plugin_worker"] - -[source] -manifest_root = "." -artifact = "acme_policy/worker.py" - -[integrity] -sha256 = "sha256:" - -[load] -runtime = "python" -entrypoint = "acme_policy.worker:main" -``` - -Set `compat.worker_protocol` to `grpc-v1` and include `plugin_worker` in the -capability list. Refer to [Choose a Worker Runtime](#choose-a-worker-runtime) -for the runtime-specific `load` and `source` requirements. - -Relay 0.8 establishes canonical annotated tool results as the `grpc-v1` -baseline. Follow the [0.8 migration -guide](/reference/migration-guides#return-canonical-tool-execution-results) to -rebuild the worker and set its `compat.relay` range. `compat.relay` is the -plugin author's compatibility assertion, not proof that an artifact was rebuilt. - -The worker receives its activation ID, plugin ID, worker and host endpoints, -and a local activation token through environment variables. Do not start the -worker directly during normal operation. The host supplies these values. - -## Registration and Trust - -Workers return declarative registrations. Relay owns registry mutation, -namespacing, rollback, and deregistration. Relay DTOs use `JsonEnvelope` -values, while protobuf handles control flow. - -Run `nemo-relay plugins add` and `nemo-relay plugins validate` to evaluate the -configured host policy and artifact trust evidence. Refer to [Configure -Discoverable Plugins](/configure-plugins/discoverable-plugins) for the -operator-side lifecycle and validation flow. - -## Next Steps - -- Refer to [gRPC Worker Plugins (Rust)](/build-plugins/dynamic-plugins/grpc-worker/rust/about) - or [gRPC Worker Plugins (Python)](/build-plugins/dynamic-plugins/grpc-worker/python/about) - for complete language-specific authoring workflows. -- Refer to [gRPC Worker Protocol Overview](/build-plugins/dynamic-plugins/grpc-worker/grpc-worker-protocol) - when you implement `grpc-v1` without an SDK. diff --git a/docs/build-plugins/dynamic-plugins/grpc-worker/grpc-worker-protocol.mdx b/docs/build-plugins/dynamic-plugins/grpc-worker/grpc-worker-protocol.mdx deleted file mode 100644 index 249174d13..000000000 --- a/docs/build-plugins/dynamic-plugins/grpc-worker/grpc-worker-protocol.mdx +++ /dev/null @@ -1,237 +0,0 @@ ---- -title: "gRPC Worker Protocol Overview" -description: "Understand the local `grpc-v1` contract and transport schema for NeMo Relay worker plugins." -position: 15 ---- -{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -SPDX-License-Identifier: Apache-2.0 */} - - -`grpc-v1` is the stable out-of-process plugin protocol for -`plugin.kind = "worker"`. Python, Rust, and other local executables can -implement the `nemo.relay.worker.v1` API. Relay starts every worker and connects -to it through local endpoints. Remote worker endpoints are not supported. - -Relay 0.8 resets the `grpc-v1` tool-result contract to use structured protobuf -`ToolExecutionResult` and `ToolExecutionInterceptOutcome` messages. Rebuild -every worker and regenerate custom protobuf bindings for Relay 0.8. Declare a -`compat.relay` range that excludes earlier releases. The recommended range is -`>=0.8.0,<1.0`; open-ended or narrower 0.8-or-newer ranges are valid. The -manifest is an author compatibility assertion, not artifact attestation. - -Use the Rust or Python SDK unless you need another runtime. Refer to [gRPC -Worker Plugin Concepts](/build-plugins/dynamic-plugins/grpc-worker/about) for runtime -choices and lifecycle guidance. - -## Service Contract - -Workers implement the `PluginWorker` service: - -- `Handshake` and `Health` identify a ready worker. -- `Validate` returns configuration diagnostics. -- `Register` returns declarative subscriber, guardrail, and intercept - registrations. -- `Invoke` and `InvokeStream` run registered behavior. -- `CancelInvocation` requests cancellation, and `Shutdown` requests process - termination. - -Relay implements `RelayHostRuntime` for worker-initiated operations. It lets a -worker emit marks, manage scopes and isolated scope stacks, and call tool, LLM, -or LLM-stream continuations during execution intercepts. - -This page defines the application contract. Refer to the [canonical protobuf -transport schema](https://github.com/NVIDIA/NeMo-Relay/blob/main/crates/worker-proto/proto/nemo/relay/worker/v1/plugin_worker.proto) -for every message and RPC field required to implement another runtime. The -protobuf package, service names, and RPC method names remain at v1. The -`ToolNext` response type and the `ToolExecutionInterceptResult.outcome` field -type change for the Relay 0.8 baseline. Workers built against an earlier -`grpc-v1` schema are incompatible and must regenerate their bindings. - -## PluginWorker RPCs - -The worker implements the following RPCs: - -| RPC | Request and response | Purpose | -| --- | --- | --- | -| `Handshake` | `HandshakeRequest` → `HandshakeResponse` | Confirms plugin identity, `grpc-v1`, SDK/runtime details, and supported registration surfaces. | -| `Health` | `HealthRequest` → `HealthResponse` | Reports whether the worker is ready. | -| `Validate` | `ValidateRequest` → `ValidateResponse` | Validates the component `config` envelope and returns diagnostics or `WorkerError`. | -| `Register` | `RegisterRequest` → `RegisterResponse` | Returns declarative registrations or `WorkerError`. | -| `Invoke` | `InvokeRequest` → `InvokeResponse` | Runs a subscriber, guardrail, or non-streaming intercept. | -| `InvokeStream` | `InvokeRequest` → `stream StreamChunk` | Runs a streaming LLM intercept. | -| `CancelInvocation` | `CancelInvocationRequest` → `WorkerAck` | Requests cooperative cancellation of an invocation. | -| `Shutdown` | `ShutdownRequest` → `WorkerAck` | Requests worker shutdown after Relay removes its proxy callbacks. | - -Every request carries the activation ID and authentication token. `Validate` -and `Register` also carry the plugin ID and component configuration. - -## Registration and Invocation - -On success, `RegisterResponse` contains `Registration` records with a local -name, surface, priority, and `break_chain` value. A failed registration can -return `WorkerError` without registrations. The supported surfaces are: - -- `SUBSCRIBER` -- `TOOL_SANITIZE_REQUEST_GUARDRAIL`, `TOOL_SANITIZE_RESPONSE_GUARDRAIL`, - `TOOL_CONDITIONAL_EXECUTION_GUARDRAIL`, `TOOL_REQUEST_INTERCEPT`, and - `TOOL_EXECUTION_INTERCEPT` -- `LLM_SANITIZE_REQUEST_GUARDRAIL`, `LLM_SANITIZE_RESPONSE_GUARDRAIL`, - `LLM_CONDITIONAL_EXECUTION_GUARDRAIL`, `LLM_REQUEST_INTERCEPT`, - `LLM_EXECUTION_INTERCEPT`, and `LLM_STREAM_EXECUTION_INTERCEPT` - -`InvokeRequest` identifies the registration, surface, invocation, optional -continuation, and scope context. Its payload is one of an event, tool -invocation, or LLM invocation. `InvokeResponse` returns an empty result, JSON -result, guardrail result, LLM request-intercept result, tool-execution result, -or `WorkerError`. `InvokeStream` emits JSON chunks or `WorkerError` chunks. - -Every LLM sanitizer invocation includes a directional context with tagged codec -identity: `none`, `builtin(id)`, `runtime(id)`, or `opaque`. Worker SDKs expose -`context.resolve_codec()` for active codecs. The resulting invocation-scoped -proxy supports request decode/encode or response decode and calls Relay through -the host runtime; the capability identifier is protocol-internal and expires -when the callback completes. `resolve_codec()` returns no proxy only when no -codec is active. Runtime and opaque codecs remain resolvable. - -Request and response sanitizer handlers always receive `(payload, context)`. -Return the sanitized payload to continue the chain, or return no payload to -omit the observability payload and annotation without changing the -client-visible value. Rust worker sanitizer callbacks are async for every -sanitizer surface: mark, scope, tool, and LLM. Python worker sanitizers can -return either an immediate value or an awaitable. - - - - -```rust -ctx.register_llm_sanitize_request_guardrail( - "normalize-request", - 10, - |request, context| async move { - let Some(codec) = context.resolve_codec() else { - return Ok(Some(request)); - }; - let mut annotated = codec.decode(&request).await?; - annotated.messages.clear(); - Ok(Some(codec.encode(&annotated, &request).await?)) - }, -); -``` - - - - -```python -async def normalize_request(request, context): - codec = context.resolve_codec() - if codec is None: - return request - annotated = await codec.decode(request) - annotated["messages"] = [] - return await codec.encode(annotated, request) - -ctx.register_llm_sanitize_request_guardrail( - "normalize-request", - normalize_request, - priority=10, -) -``` - - - - -## RelayHostRuntime RPCs - -Relay implements these RPCs for worker callbacks: - -| RPC group | RPCs | -| --- | --- | -| Marks and scopes | `EmitMark`, `PushScope`, `PopScope` | -| Isolated scope stacks | `CreateScopeStack`, `DropScopeStack` | -| Execution continuations | `ToolNext`, `LlmNext`, `LlmStreamNext` | -| Codec capabilities | `DecodeLlmCodecRequest`, `EncodeLlmCodecRequest`, `DecodeLlmCodecResponse` | - -Every host-runtime request also carries the activation ID and authentication -token. Scope operations include a `ScopeContext`; continuation calls include -the continuation ID that Relay supplied for the active intercept. Codec -operations additionally require the unforgeable capability ID supplied for the -current sanitizer invocation. Relay rejects missing, forged, expired, -wrong-direction, or activation-mismatched capabilities. Codec transformation -failures are non-retryable worker errors. - -The following protobuf types are normative for canonical tool results: - -| Protocol Location | Required Protobuf Type | -| --- | --- | -| `RelayHostRuntime.ToolNext` response | `ToolExecutionResultResponse` | -| Successful `ToolExecutionResultResponse.value` | `ToolExecutionResult` | -| `InvokeResponse.tool_execution.outcome` | `ToolExecutionInterceptOutcome` | - -`ToolExecutionResult` contains a required `result` and optional `annotation`. -`ToolExecutionInterceptOutcome` adds Relay-owned `pending_marks` as one JSON -array. Each opaque JSON value uses `JsonValue`, whose bytes contain exactly one -JSON value. This preserves arbitrary JSON and integer precision while generated -protobuf clients enforce the surrounding result and annotation structure. - -These are deliberate protobuf type changes under the Relay 0.8 `grpc-v1` -baseline. The package and RPC method names remain v1, but workers must -regenerate their protobuf bindings and rebuild. Future incompatible worker -protobuf or payload semantic changes must increment `worker_protocol`. - -A worker may call an execution continuation repeatedly or concurrently while -the corresponding `Invoke` or `InvokeStream` callback is active. Each call gets -an isolated scope-stack branch containing the scopes visible at invocation. -The worker must finish continuation calls before returning its middleware -result; Relay removes the continuation ID and cancels unfinished calls when the -callback settles. For `InvokeStream`, the returned worker stream extends the -active callback lifetime until it closes, so it can call `LlmStreamNext` lazily. -A stream successfully returned by `LlmStreamNext` keeps its normal streaming -lifetime. - -## Authentication and Endpoints - -Relay supplies an activation ID, an activation token, the worker endpoint, and -the host endpoint when it starts the process. SDKs attach the activation ID and -token to protocol calls. The host rejects requests with an invalid activation ID -or token. - -On Unix platforms, Relay uses local Unix sockets. On other platforms, Relay -uses loopback TCP endpoints. Workers must not accept arbitrary remote endpoint -configuration. - -## Payloads - -Relay data values use this envelope: - -```proto -message JsonEnvelope { - string schema = 1; - bytes json = 2; -} -``` - -Use `JsonEnvelope` for open Relay DTOs that the protocol does not model -directly. Canonical tool results are the exception: protobuf defines their -wrapper, and `JsonValue` carries the application result, annotation, and -pending-mark array losslessly. - -## Activation and Shutdown - -1. Run `nemo-relay plugins validate ` before enabling or running a - plugin to check its manifest, trust evidence, and optional static - configuration schema. -2. Relay creates local endpoints, starts the worker, and completes health and - handshake checks. -3. Relay sends component configuration to `Validate`. Error diagnostics stop - initialization after the worker process starts. -4. Relay obtains registrations from `Register` and installs proxy callbacks. - Relay rolls back installed callbacks if initialization fails. -5. Relay removes proxy callbacks before it sends `Shutdown` to the worker. - -## Errors - -gRPC status communicates transport, authentication, malformed protocol -requests, and some stream failures. Registration and unary callback failures -can return structured `WorkerError` values. A stream callback failure can -terminate the gRPC stream with a status error. Worker implementations must -handle both error forms. diff --git a/docs/build-plugins/dynamic-plugins/grpc-worker/python/about.mdx b/docs/build-plugins/dynamic-plugins/grpc-worker/python/about.mdx deleted file mode 100644 index 4cc8a1bf9..000000000 --- a/docs/build-plugins/dynamic-plugins/grpc-worker/python/about.mdx +++ /dev/null @@ -1,319 +0,0 @@ ---- -title: "gRPC Worker Plugins (Python)" -description: "Build, package, configure, and run Python gRPC worker plugins for NeMo Relay." -position: 14 ---- -{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -SPDX-License-Identifier: Apache-2.0 */} - - -Use the `nemo-relay-plugin` package to build an out-of-process plugin worker. -The SDK owns gRPC server setup, generated protocol stubs, JSON-envelope -conversion, continuations, cancellation hooks, and scope-stack helpers. -Refer to [gRPC Worker Plugin Concepts](/build-plugins/dynamic-plugins/grpc-worker/about) -to compare the Python, Rust, and command worker runtimes. - -This guide builds a complete plugin that adds a tag to every tool request and -emits a mark through the Relay host runtime. - -## Create the Project - -Create the following project layout: - -```text -python-grpc-worker/ -├── pyproject.toml -├── relay-plugin.toml -└── nemo_relay_python_grpc_worker/ - ├── __init__.py - └── worker.py -``` - -Add the following `pyproject.toml` file: - -```toml -[build-system] -requires = ["setuptools>=68"] -build-backend = "setuptools.build_meta" - -[project] -name = "nemo-relay-python-grpc-worker-example" -version = "0.1.0" -description = "Example Python gRPC worker plugin for NeMo Relay" -requires-python = ">=3.11" -dependencies = [ - "nemo-relay-plugin>=0.8.0", -] - -[tool.setuptools.packages.find] -where = ["."] -include = ["nemo_relay_python_grpc_worker"] -``` - -Create `nemo_relay_python_grpc_worker/__init__.py` with the following content: - -```python -# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Example Python gRPC worker plugin package.""" -``` - -## Implement the Worker - -Add the following implementation to -`nemo_relay_python_grpc_worker/worker.py`: - -```python -# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Example Python worker plugin using the nemo-relay-plugin SDK.""" - -from __future__ import annotations - -from typing import Any - -from nemo_relay_plugin import ConfigDiagnostic, DiagnosticLevel, Json, PluginContext, WorkerPlugin, serve_plugin - - -class ExamplePythonWorker(WorkerPlugin): - """Small worker plugin that tags tool request JSON and emits a host mark.""" - - plugin_id = "examples.python_grpc_worker" - - def validate(self, config: Json) -> list[ConfigDiagnostic | dict[str, Any]]: - if not isinstance(config, dict): - return [ - ConfigDiagnostic( - level=DiagnosticLevel.ERROR, - code="examples.python_grpc_worker.invalid_config", - component=self.plugin_id, - message="plugin config must be a JSON object", - ) - ] - if config.get("reject") is True: - return [ - ConfigDiagnostic( - level=DiagnosticLevel.ERROR, - code="examples.python_grpc_worker.rejected", - component=self.plugin_id, - field="reject", - message="Python gRPC worker rejection requested", - ) - ] - if "tag" in config and not isinstance(config["tag"], str): - return [ - ConfigDiagnostic( - level=DiagnosticLevel.ERROR, - code="examples.python_grpc_worker.invalid_tag", - component=self.plugin_id, - field="tag", - message="tag must be a string", - ) - ] - return [] - - def register(self, ctx: PluginContext, config: Json) -> None: - if not isinstance(config, dict): - raise TypeError("plugin config must be a JSON object") - if config.get("reject") is True: - raise ValueError("Python gRPC worker rejection requested") - tag = config.get("tag", "python_grpc_worker") - if not isinstance(tag, str): - raise TypeError("tag must be a string") - - async def tag_tool_request(tool_name: str, args: Json) -> Json: - tagged_args = _tag_json(args, tag) - await ctx.runtime.emit_mark( - "examples.python_grpc_worker.tool_request", - {"tool_name": tool_name, "source": "python-grpc-worker", "tag": tag}, - ) - return tagged_args - - ctx.register_tool_request_intercept("tag_tool_request", tag_tool_request) - - -def _tag_json(value: Json, tag: str) -> Json: - if not isinstance(value, dict): - return value - metadata = value.get("_nemo_relay_plugin") - if metadata is None: - metadata = {} - elif not isinstance(metadata, dict): - return value - return { - **value, - "_nemo_relay_plugin": {**metadata, "tag": tag}, - } - - -async def main() -> None: - """Entrypoint referenced by relay-plugin.toml.""" - await serve_plugin(ExamplePythonWorker()) - - -if __name__ == "__main__": - import asyncio - - asyncio.run(main()) -``` - -`WorkerPlugin.validate` returns structured diagnostics after Relay starts the -worker process and before Relay installs registrations. `WorkerPlugin.register` -registers a tool request intercept that updates the request JSON and emits a -mark through `PluginContext.runtime`. The `main` entrypoint blocks until Relay -requests shutdown. - -## Create the Manifest - -Create `relay-plugin.toml` with the following content: - -```toml -manifest_version = 1 - -[plugin] -id = "examples.python_grpc_worker" -kind = "worker" - -[compat] -relay = ">=0.8.0,<1.0" -worker_protocol = "grpc-v1" - -[defaults] -enabled = false - -[capabilities] -items = ["plugin_worker"] - -[source] -manifest_root = "." -artifact = "nemo_relay_python_grpc_worker/worker.py" - -[integrity] -sha256 = "sha256:966849be254cc6299a17a4bb65500363e9a48f98cc1e0091192e42b23821486f" - -[load] -runtime = "python" -entrypoint = "nemo_relay_python_grpc_worker.worker:main" -``` - -The digest matches the `worker.py` file in this guide. Use Python 3.11 or later -to calculate a new digest before you register the worker: - -```bash -python3 - <<'PY' -from hashlib import sha256 -from pathlib import Path - -artifact = Path("nemo_relay_python_grpc_worker/worker.py") -print(f"sha256:{sha256(artifact.read_bytes()).hexdigest()}") -PY -``` - -The module in `load.entrypoint` must resolve directly under -`source.manifest_root` to exactly one Python source file: either -`module/path.py` or `module/path/__init__.py`. That file must also be the -integrity-checked `source.artifact`. Relay rejects ambiguous modules and custom -build-backend mappings, including `src/` layouts that it cannot derive from the -manifest. This check prevents an unsigned sibling module from becoming the -executed entrypoint. - -## Register the Plugin - -Run the following commands from the project directory: - -```bash -relay_tmp="$(mktemp -d)" -relay_config="$relay_tmp/gateway.toml" - -nemo-relay --config "$relay_config" plugins add ./relay-plugin.toml -``` - -`plugins add` creates an isolated Relay-managed Python environment and installs -the `source.manifest_root` project into it. Relay uses that recorded environment -when it starts the worker. Do not start the worker directly: Relay supplies its -worker socket, host socket, activation ID, and activation token. - -Relay records a digest of the installed environment in a locally authenticated -marker that is bound to the integrity-checked entrypoint artifact. The marker -detects changes between provisioning and activation. It is not a security -boundary against another process running as the same operating-system user, -because that process can access the same user-owned keys and files. Worker -process isolation is not a security sandbox. - -## Configure the Plugin - -After `plugins add` registers the worker, edit -`$relay_tmp/plugins.toml`. Add the configuration table to the -`[[plugins.dynamic]]` entry that `plugins add` created. The configuration sets -the tag that the worker adds to tool request JSON: - -```toml -# `plugins add` writes the canonical absolute manifest path. -[[plugins.dynamic]] -manifest = "/absolute/path/to/python-grpc-worker/relay-plugin.toml" - -[plugins.dynamic.config] -tag = "documentation" -``` - -Do not use this TOML block instead of `plugins add` for a Python worker. The -CLI also creates the lifecycle record that points to the Relay-managed Python -environment. The `tag` field is optional and defaults to `python_grpc_worker`. - -## Test Worker-Side Validation - -`nemo-relay plugins validate` checks the manifest, trust evidence, and optional -static schema. It does not call `WorkerPlugin.validate`. - -To test runtime validation: - -1. Set `reject = true`, then start the gateway. Relay starts the worker and - reports the worker diagnostic before it installs registrations. -2. Set `tag` to a non-string value, then start the gateway. Relay reports the - structured diagnostic from `WorkerPlugin.validate`. - -`WorkerPlugin.validate` runs after Relay starts the worker and before Relay -installs registrations. - -## Run the Plugin - -Enable, validate, and start the gateway with the following commands: - -```bash -nemo-relay --config "$relay_config" plugins enable examples.python_grpc_worker -nemo-relay --config "$relay_config" plugins validate examples.python_grpc_worker -nemo-relay --config "$relay_config" --bind 127.0.0.1:4040 -``` - -## Update the Worker - -After you change worker source code or dependencies, rebuild the managed -environment instead of only updating the manifest digest: - -1. Recalculate `integrity.sha256` with Python 3.11 or later and update - `relay-plugin.toml`. -2. Remove the registered worker, then add it again: - - ```bash - nemo-relay --config "$relay_config" plugins remove examples.python_grpc_worker - nemo-relay --config "$relay_config" plugins add ./relay-plugin.toml - ``` - -3. Restore any `[[plugins.dynamic]].config` values, then enable the worker. - -`plugins add` installs a new copy of `source.manifest_root` into a fresh -Relay-managed environment. It cannot refresh an already registered worker. - -After you stop Relay, remove the plugin and its managed environment: - -```bash -nemo-relay --config "$relay_config" plugins remove examples.python_grpc_worker -rm -rf "$relay_tmp" -``` - -Refer to [gRPC Worker Protocol Overview](/build-plugins/dynamic-plugins/grpc-worker/grpc-worker-protocol) for the -shared `grpc-v1` contract and -[Configure Discoverable Plugins](/configure-plugins/discoverable-plugins) for -host trust and lifecycle policies. diff --git a/docs/build-plugins/dynamic-plugins/grpc-worker/rust/about.mdx b/docs/build-plugins/dynamic-plugins/grpc-worker/rust/about.mdx deleted file mode 100644 index c82000460..000000000 --- a/docs/build-plugins/dynamic-plugins/grpc-worker/rust/about.mdx +++ /dev/null @@ -1,142 +0,0 @@ ---- -title: "gRPC Worker Plugins (Rust)" -description: "Build, package, and register out-of-process Rust gRPC worker plugins for NeMo Relay." -position: 13 ---- -{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -SPDX-License-Identifier: Apache-2.0 */} - - -Use the `nemo-relay-worker` crate to build an out-of-process Rust plugin. The -SDK implements the `grpc-v1` service, exchanges structured tool results and -open JSON payloads with Relay, handles cancellation, and provides typed -registration and host-runtime helpers. -Refer to [gRPC Worker Plugin Concepts](/build-plugins/dynamic-plugins/grpc-worker/about) -to compare the Rust, Python, and command worker runtimes. - -## Create the Project - -Create a binary Rust project with the following `Cargo.toml` file: - -```toml -[package] -name = "examples-rust-worker" -version = "0.1.0" -edition = "2024" - -[dependencies] -nemo-relay-worker = "0.8.0" -tokio = { version = "1", features = ["macros", "rt-multi-thread"] } -``` - -## Implement the Worker - -Add the following implementation to `src/main.rs`: - -```rust -use nemo_relay_worker::{Json, PluginContext, Result, WorkerPlugin, serve_plugin}; - -struct ExampleWorker; - -impl WorkerPlugin for ExampleWorker { - fn plugin_id(&self) -> &str { - "examples.rust_worker" - } - - fn validate(&self, config: &Json) -> Vec { - let _ = config; - Vec::new() - } - - fn register(&self, context: &mut PluginContext, _config: &Json) -> Result<()> { - context.register_subscriber("example", |event| { - let _ = event.name(); - }); - Ok(()) - } -} - -#[tokio::main] -async fn main() -> Result<()> { - serve_plugin(ExampleWorker).await -} -``` - -Relay provides the worker and host endpoints, activation ID, plugin ID, and -activation token through environment variables. The Rust SDK derives plugin -identity from `WorkerPlugin::plugin_id()`; custom launchers can use -`NEMO_RELAY_PLUGIN_ID`. `serve_plugin` consumes the endpoints and activation -credentials and runs until Relay requests shutdown. Do not start the worker -directly for normal operation. - -## Package the Worker - -Create `relay-plugin.toml` with the following content. The artifact digest must -match the executable that operators receive: - -```toml -manifest_version = 1 - -[plugin] -id = "examples.rust_worker" -kind = "worker" - -[compat] -relay = ">=0.8.0,<1.0" -worker_protocol = "grpc-v1" - -[defaults] -enabled = false - -[capabilities] -items = ["plugin_worker"] - -[source] -artifact = "target/release/examples-rust-worker" - -[integrity] -sha256 = "sha256:" - -[load] -runtime = "rust" -entrypoint = "target/release/examples-rust-worker" -``` - -Build the executable with the following command: - -```bash -cargo build --release -``` - -Calculate the SHA-256 digest of the executable with the following command: -This command requires Python 3. - - -Use the following PowerShell command on Windows: - -```powershell -$artifact = ".\\target\\release\\examples-rust-worker.exe" -$hash = (Get-FileHash -Algorithm SHA256 $artifact).Hash.ToLower() -"sha256:$hash" -``` - -Replace `sha256:` with the output before you register the -plugin. On Windows, use the `.exe` artifact name in `source.artifact`, -`load.entrypoint`, and the digest command. - -## Register the Worker - -Register, enable, and validate the worker with the following commands: - -```bash -nemo-relay plugins add ./relay-plugin.toml -nemo-relay plugins enable examples.rust_worker -nemo-relay plugins validate examples.rust_worker -``` - -## Related Topics - -- Refer to [Configure Discoverable Plugins](/configure-plugins/discoverable-plugins) - for lifecycle and trust-policy configuration. -- Refer to [gRPC Worker Protocol Overview](/build-plugins/dynamic-plugins/grpc-worker/grpc-worker-protocol) - for the lower-level protocol contract. diff --git a/docs/build-plugins/dynamic-plugins/native-dynamic/about.mdx b/docs/build-plugins/dynamic-plugins/native-dynamic/about.mdx deleted file mode 100644 index 4500caeed..000000000 --- a/docs/build-plugins/dynamic-plugins/native-dynamic/about.mdx +++ /dev/null @@ -1,322 +0,0 @@ ---- -title: "Native Dynamic Plugins (Rust)" -description: "Build in-process Rust shared-library plugins against the NeMo Relay Native ABI v4." -position: 10 ---- -{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -SPDX-License-Identifier: Apache-2.0 */} - - -Native dynamic plugins are trusted in-process shared libraries. Use them when -plugin behavior needs host-process access while still following the Relay plugin -contract: a stable kind, JSON component configuration, validation diagnostics, -and registration through a component-scoped context. - - -Native plugins are not sandboxed. They run in the gateway process, must not -unwind across ABI callbacks, and remain loaded until Relay removes their -registered callbacks. - - -## Manifest - -Use the following manifest: - -```toml -manifest_version = 1 - -[plugin] -id = "acme.native_policy" -kind = "rust_dynamic" - -[compat] -relay = ">=0.8.0,<1.0" -native_api = "1" - -[defaults] -enabled = false - -[capabilities] -items = ["plugin_native"] - -[source] -artifact = "target/release/libacme_native_policy.dylib" - -[integrity] -sha256 = "sha256:" - -[load] -library = "target/release/libacme_native_policy.dylib" -symbol = "nemo_relay_register_plugin" -``` - -Set `plugin.kind` to `rust_dynamic`, `compat.native_api` to `"1"`, and -`capabilities.items` to include `plugin_native`. Relay resolves relative paths -from the manifest. The exported symbol must return a descriptor whose -`plugin_kind` matches `plugin.id` exactly. - -Relay 0.8 establishes the canonical `ToolExecutionResult` JSON contract as the -native API 1 baseline. It preserves opaque result annotations through -synchronous and asynchronous tool intercepts. Every native plugin must be -rebuilt and declare a `compat.relay` range that excludes Relay versions before -0.8. The recommended range is `>=0.8.0,<1.0`; open-ended ranges such as -`>=0.8.0` are also valid. `compat.relay` is the plugin author's compatibility -assertion, not proof that an artifact was rebuilt. - -The application JSON contract is independent of the negotiated native host -table. Relay retains the native ABI v4 C-compatible layouts and callback -signatures. Future incompatible native JSON semantic changes must increment -`compat.native_api`. - -## Create a Native Plugin - -Create a Rust library project with the following `Cargo.toml` file: - -```toml -[package] -name = "acme-native-policy" -version = "0.1.0" -edition = "2024" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -nemo-relay-plugin = "0.8.0" -serde_json = "1" -``` - -Add the following implementation to `src/lib.rs`: - -```rust -use nemo_relay_plugin::{Json, NativePlugin, PluginContext, Result}; -use serde_json::Map; - -struct NativePolicy; - -impl NativePlugin for NativePolicy { - fn plugin_kind(&self) -> &str { - "acme.native_policy" - } - - fn register( - &mut self, - _config: &Map, - context: &mut PluginContext<'_>, - ) -> Result<()> { - context.register_subscriber("audit", |_| {})?; - Ok(()) - } -} - -nemo_relay_plugin::nemo_relay_plugin!(nemo_relay_register_plugin, || NativePolicy); -``` - -Build the library with the following command: - -```bash -cargo build --release -``` - -Update `source.artifact` and `load.library` with the resulting platform library -path, then replace `` with that library's SHA-256 digest. Use -`.dylib` on macOS, `.so` on Linux, or `.dll` on Windows. Refer to [Build a Rust -Native Plugin](/build-plugins/dynamic-plugins/native-dynamic/rust-native-plugin-example) for a complete example -with validation, middleware, scopes, and configuration schema support. - -## Native ABI v4 - -The entry symbol receives a `*const NemoRelayNativeHostApiV1` pointer. It -points at the v1 prefix of a v4 `NemoRelayNativeHostApiV4` table; check -`abi_version` and `struct_size` before casting. The plugin returns a -`NemoRelayNativePluginV1` descriptor: - -```rust -extern "C" fn nemo_relay_register_plugin( - host: *const NemoRelayNativeHostApiV1, - out: *mut NemoRelayNativePluginV1, -) -> NemoRelayStatus -``` - -The v4 host table contains the complete frozen v3 table as its prefix and adds -completion-scoped codecs plus pull-based downstream LLM streams. Relay -negotiates v4, then a separately frozen v3 table, then the legacy v2 table. -Raw ABI plugins can use -`PluginContext::register_async_middleware_raw` when a callback must complete -later. The callback receives a JSON invocation, an optional continuation for -execution intercepts, and a one-shot completion handle. - -Return `Complete` after resolving or rejecting the completion before the -callback returns. Return `Pending` only when retaining the completion; settle -it exactly once, call `async_completion_release`, and release an async `next` -handle after use. The host marks a completion cancelled when the awaiting -runtime work is dropped; late and duplicate settlement is rejected safely. -Invoke and finish every `next` operation before resolving or rejecting the -owning completion. Relay rejects new calls and cancels unfinished calls after -the completion settles. - -For unary execution intercepts, use `async_next_invoke_result` to call `next` -repeatedly or concurrently with an independent result callback for each call. -The older completion-coupled `async_next_invoke` is one-shot because its result -settles the middleware completion. For incremental stream intercepts, the -output stream owns the callback lifetime: `async_next_invoke_stream` may run -repeatedly or concurrently until the output stream finishes, rejects, or is -cancelled. Each call requires independent callback state. Relay rejects later -calls and cancels unfinished calls when the output stream settles. - -Relay invokes an asynchronous middleware callback synchronously on the Tokio -runtime worker that is polling that middleware invocation. There is no stable -OS-thread affinity, and separate invocations can run concurrently. After -returning `Pending`, a plugin can use its retained completion, stream, or -`next` handle from another plugin-owned thread. The host synchronizes those -opaque handles, schedules `next` on the captured Relay runtime, and can invoke -an incremental downstream-stream callback on a Relay runtime worker. The -plugin must synchronize shared `user_data` and callback state, and must keep -them alive until the corresponding host-owned registration and all -callback-owned handle references are released. Do not race a handle's release -operation against settlement, cancellation inspection, stream operations, or -`next` invocation using that same reference; serialize the final release after -the last such call returns. - -The Rust SDK registers typed middleware through these completion APIs and runs -the returned futures on one SDK-owned multi-thread Tokio runtime per configured -component. The default is two workers: enough for modest concurrent async I/O -without broadly oversubscribing the host. Increase the count only when measured -I/O concurrency leaves callbacks queued; lower it when the host runs many -components or has a tight CPU budget. Do not block executor workers. - -Set a plugin-wide default in Rust: - -```rust -use nemo_relay_plugin::{NativeExecutorConfig, NativePlugin}; - -impl NativePlugin for NativePolicy { - fn plugin_kind(&self) -> &str { - "acme.native_policy" - } - - fn executor_config(&self) -> NativeExecutorConfig { - NativeExecutorConfig { worker_threads: 4 } - } - - // ... register and other trait methods ... -} -``` - -Then override that default for one configured component in `plugins.toml`: - -```toml -[[plugins.dynamic]] -manifest = "./acme-plugin/relay-plugin.toml" - -[plugins.dynamic.config.executor] -worker_threads = 4 -``` - -The default `NativePlugin::executor_config_for_component` reads this positive -integer override. Override that method only when the plugin needs custom -configuration rules. If the plugin manifest declares `config_schema`, include -the SDK-owned `executor` object and its positive-integer `worker_threads` -property in that schema, even when the plugin has no other configuration. - -Relay scope context is installed around each root-future poll, but child tasks -created with `tokio::spawn` do not automatically inherit it. Typed subscribers -remain synchronous and continue to run on Relay's subscriber dispatcher. - -Event sanitizers registered through this extension still run on Relay's serial -publication dispatcher. Scope and mark emission remain synchronous and their -sanitized events are delivered later in emission order. - -The generic v3 completion registration settles one JSON value and rejects the -`LlmStreamExecutionIntercept` kind. Register asynchronous stream intercepts -with `plugin_context_register_async_stream_middleware` instead. Its dedicated -`async_next_invoke_stream` continuation forwards downstream chunks -incrementally, so Relay does not buffer the provider stream into an array. - -The incremental output queue is bounded. `async_stream_push_json` and -`async_stream_reject` never block a native callback thread. If either returns -`Backpressured`, retain that logical chunk or rejection and retry it after the -consumer advances. `InvalidArg` means the stream is already closed or cancelled -and must not be retried. `async_stream_is_backpressured` only reports whether -the queue is full at the instant you call it; do not use it to decide whether a -specific operation needs a retry. Check `async_stream_is_cancelled` during -longer producer work. - -For `async_next_invoke_stream`, Relay reports downstream failure or consumer -cancellation through one terminal callback with a non-null error, and reports -clean completion with `done = true`. Reclaim the callback's `user_data` in that -terminal callback. If a chunk callback returns `false`, reclaim `user_data` -before returning because Relay does not invoke another callback afterward. - -Cancelling the one-shot completion supplied to a non-stream execution -intercept also aborts any pending `async_next_invoke` continuation. Plugins -must still release their callback-owned completion and `next` references -exactly once; cancellation only stops the host-side continuation and prevents -it from retaining the plugin indefinitely. - -Legacy v1/v2 middleware callbacks are synchronous and run on the runtime's -execution path. They must not block on I/O; raw plugins can use the v3 -completion-based API for long-running work, while typed Rust plugins should -return futures through the SDK's v4 adapters. - -Text and JSON data cross this boundary as host-owned -`NemoRelayNativeString` handles. ABI structs also carry scalars, opaque -handles, callback pointers, and plugin-owned `user_data`. Do not pass Rust -runtime types, trait objects, futures, `serde_json::Value`, or allocator-owned -strings across the ABI. - -ABI callbacks can register these runtime surfaces: - -- Subscribers -- Tool and LLM guardrails or intercepts, including stream intercepts -- Marks, scopes, and isolated scope stacks - -Relay keeps the library alive while those registrations exist and deregisters -them before unloading it. - -LLM sanitize callbacks receive their request or response JSON first, followed -by `NemoRelayNativeLlmSanitizeRequestContext` or -`NemoRelayNativeLlmSanitizeResponseContext`. Each context contains structured -codec identity and a borrowed, callback-lifetime codec handle. `codec_kind` is -`None`, `BuiltIn`, `Runtime`, or `Opaque`. `codec_id` is present for `BuiltIn` -(one of `openai_chat`, `openai_responses`, `anthropic_messages`, `oci_genai`, or `gemini_generate_content`) and -`Runtime`, and null for `None` and `Opaque`. - -The request handle supports host operations to decode an `LlmRequest` into an -`AnnotatedLlmRequest` and encode normalized changes onto the original request. -The response handle supports decoding response JSON into an -`AnnotatedLlmResponse`. A null handle means no codec is active. Runtime and -opaque codecs still have non-null handles and support the same operations as a -built-in codec. Do not retain a handle or resolved SDK facade after the -sanitizer callback returns. - -The Rust SDK converts the raw structures into -`LlmSanitizeRequestContext` and `LlmSanitizeResponseContext`. Call -`resolve_codec()` to obtain the safe directional facade. Raw ABI consumers use -the `llm_request_codec_decode`, `llm_request_codec_encode`, and -`llm_response_codec_decode` host-table functions. The host owns all returned -JSON string handles, which callers release with the ordinary host string -release operation. - -A successful null sanitizer output omits the LLM observability payload and its -annotation. Returning an error also omits the payload and annotation; Relay -records the callback error. Neither case changes the client-visible request or -response. - -Use the `nemo-relay-plugin` crate rather than the host `nemo-relay` runtime -crate. Refer to [Build a Rust Native Plugin](/build-plugins/dynamic-plugins/native-dynamic/rust-native-plugin-example) -for the SDK-backed example. - -## Register the Plugin - -After you package the manifest and library, register and validate the plugin: - -```bash -nemo-relay plugins add ./relay-plugin.toml -nemo-relay plugins enable acme.native_policy -nemo-relay plugins validate acme.native_policy -``` - -Refer to [Configure Discoverable -Plugins](/configure-plugins/discoverable-plugins) for lifecycle and trust-policy -configuration. diff --git a/docs/build-plugins/dynamic-plugins/native-dynamic/rust-native-plugin-example.mdx b/docs/build-plugins/dynamic-plugins/native-dynamic/rust-native-plugin-example.mdx deleted file mode 100644 index 912b65cb6..000000000 --- a/docs/build-plugins/dynamic-plugins/native-dynamic/rust-native-plugin-example.mdx +++ /dev/null @@ -1,133 +0,0 @@ ---- -title: "Build a Rust Native Plugin" -description: "Build and package the NeMo Relay Rust native dynamic-plugin example." -position: 11 ---- -{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -SPDX-License-Identifier: Apache-2.0 */} - - -Use the `nemo-relay-plugin` crate to create an in-process native plugin without -writing the C ABI by hand. The SDK exports the stable ABI entry point and -provides typed helpers for the supported registration surfaces. - -This example builds a native plugin that registers an event subscriber. The -repository also includes an extended example in `examples/rust-native-plugin` -with validation, middleware, marks, scopes, and a configuration schema. - -## Create the Project - -Create a Rust library project with this `Cargo.toml` file: - -```toml -[package] -name = "acme-native-policy" -version = "0.1.0" -edition = "2024" - -[lib] -crate-type = ["cdylib"] - -[dependencies] -nemo-relay-plugin = "0.8.0" -serde_json = "1" -``` - -Add the following implementation to `src/lib.rs`: - -```rust -use nemo_relay_plugin::{Json, NativePlugin, PluginContext, Result}; -use serde_json::Map; - -struct NativePolicy; - -impl NativePlugin for NativePolicy { - fn plugin_kind(&self) -> &str { - "acme.native_policy" - } - - fn register( - &mut self, - _config: &Map, - context: &mut PluginContext<'_>, - ) -> Result<()> { - context.register_subscriber("audit", |_| {}) - } -} - -nemo_relay_plugin::nemo_relay_plugin!(nemo_relay_register_plugin, || NativePolicy); -``` - -## Create the Manifest - -Create `relay-plugin.toml` with the following content: - -```toml -manifest_version = 1 - -[plugin] -id = "acme.native_policy" -kind = "rust_dynamic" - -[compat] -relay = ">=0.8.0,<1.0" -native_api = "1" - -[defaults] -enabled = false - -[capabilities] -items = ["plugin_native"] - -[source] -artifact = "target/release/" - -[integrity] -sha256 = "sha256:" - -[load] -library = "target/release/" -symbol = "nemo_relay_register_plugin" -``` - -Update the library filename for your platform: use `.dylib` on macOS, `.so` -on Linux, or `.dll` on Windows. Replace `` in both -manifest fields with that filename. - -## Build, Register, and Validate - -Build the library, calculate its digest, and register the plugin with the -following commands. The digest command requires Python 3. - -```bash -cargo build --release -python - <<'PY' -from hashlib import sha256 -from pathlib import Path - -artifact = Path("target/release/") -print(f"sha256:{sha256(artifact.read_bytes()).hexdigest()}") -PY -nemo-relay plugins add ./relay-plugin.toml -nemo-relay plugins enable acme.native_policy -nemo-relay plugins validate acme.native_policy -``` - -Use the following PowerShell command on Windows: - -```powershell -$artifact = ".\\target\\release\\" -$hash = (Get-FileHash -Algorithm SHA256 $artifact).Hash.ToLower() -"sha256:$hash" -``` - -Replace `` in the digest command, then replace -`sha256:` with its output before you run `plugins add`. The -command writes a `[[plugins.dynamic]]` manifest reference. Use -`nemo-relay plugins edit` or edit that record to add component fields when the -plugin accepts configuration. - -Keep tests outside `src`, use the shared DTOs re-exported by -`nemo-relay-plugin`, and do not retain host-owned ABI handles after a callback -returns. Refer to [Configure Discoverable -Plugins](/configure-plugins/discoverable-plugins) for trust-policy configuration. diff --git a/docs/build-plugins/language-binding/about.mdx b/docs/build-plugins/language-binding/about.mdx index ec63654b3..674c7f531 100644 --- a/docs/build-plugins/language-binding/about.mdx +++ b/docs/build-plugins/language-binding/about.mdx @@ -1,180 +1,263 @@ --- -title: "Language Binding Plugins" -description: "Build in-process, code-driven NeMo Relay plugins in Rust, Python, or Node.js." -position: 2 +title: "About Language Binding Plugins" +description: "Build application-owned plugins in Rust, Python, or Node.js." +position: 5 --- -import { MermaidStyles } from "@/components/MermaidStyles"; - {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} - -Use this guide to build an in-process, code-driven plugin in Rust, Python, or -Node.js. The application registers the plugin kind with the loaded language -binding, then initializes it with the same component configuration model used -by built-in plugins. - -This model does not load a shared library or start a worker process. For those -models, use [Native Dynamic Plugins (Rust)](/build-plugins/dynamic-plugins/native-dynamic/about), -[gRPC Worker Plugins (Rust)](/build-plugins/dynamic-plugins/grpc-worker/rust/about), or [gRPC -Worker Plugins (Python)](/build-plugins/dynamic-plugins/grpc-worker/python/about). - -## What You Build - -Define the plugin's purpose, stable kind name, configuration boundary, runtime -surfaces, and activation lifecycle. Then use the focused guides to validate and -register the resulting plugin contract. - - -NeMo Relay plugin configuration keys use `snake_case` in every language and file -format. Node.js helper function names are `camelCase`, but the objects passed to -`plugin.initialize(...)` use the same canonical `snake_case` keys as Python, -Rust, JSON, and TOML plugin configuration. - - - -## Before You Start - -You need: - -- A reusable behavior that belongs outside one application call site. -- A stable plugin kind name. -- A JSON-compatible config shape. -- A decision about which runtime surfaces the plugin installs. -- A teardown plan for tests and applications that need to clear active configuration. - -## Plugin Shape and Requirements - -A plugin needs a stable shape before operators can activate it from config: - -| Requirement | Why It Matters | -|---|---| -| Stable `kind` | The plugin registry uses this string to match config to implementation. | -| JSON-compatible config | Config must move across Python, Node.js, Rust, files, tests, and deployment systems. | -| Validation hook | Operators need diagnostics before runtime behavior changes. | -| Registration hook | Register runtime behavior through `PluginContext` so Relay can qualify names and roll back failed setup. | -| Runtime ownership | The plugin should clearly own subscribers, middleware, or a small bundle of related surfaces. | - -Keep runtime objects out of config. Create provider clients, callbacks, file -handles, caches, and credentials in plugin code, or resolve them from safe -references during registration. - -## What a Plugin Can Install - -A plugin can install one or more of these runtime surfaces: - -- **Subscribers:** Event subscribers. -- **Event sanitizers:** Mark, scope-start, and scope-end observability-field - sanitizers. -- **Tool middleware:** Sanitize-request and sanitize-response guardrails, - conditional-execution guardrails, request intercepts, and execution - intercepts. -- **LLM middleware:** Sanitize-request and sanitize-response guardrails, - conditional-execution guardrails, request intercepts, execution intercepts, - and stream execution intercepts. - -LLM sanitize guardrails always receive the request or response first, followed -by a structured context whose `codec` is `none`, `builtin(id)`, `runtime(id)`, -or `opaque`. Every callback declares `(payload, context)` and may omit the LLM -event payload by returning no value. In-process sanitizer contexts can resolve -the active codec for normalized processing. Worker sanitizer contexts resolve -an invocation-scoped asynchronous codec proxy, so workers can perform the same -directional normalization without receiving a host-process object. - -Start with one surface. Add a bundle only when one configuration document clearly controls related behavior, such as a subscriber plus the request intercepts needed to add correlation metadata. - -## Registration Lifecycle - -The diagram below shows how plugin configuration turns into registered runtime behavior. - - - -```mermaid -flowchart TB - Kind[Plugin kind
registered once] - Config[Plugin config
version + components + policy] - Validate{{Validate component config}} - Diagnostics[/Structured diagnostics/] - Initialize[Initialize enabled components] - Context[PluginContext
component-scoped registrar] - Runtime[Runtime registrations
subscribers + middleware] - Rollback[Rollback partial setup
if initialization fails] - - Kind --> Validate - Config --> Validate - Validate --> Diagnostics - Validate -->|valid or warning-only| Initialize - Initialize --> Context - Context --> Runtime - Initialize -->|error| Rollback - Context -->|registration error| Rollback +A language-binding plugin is application code that registers a stable plugin kind with +the Relay runtime already loaded by Rust, Python, or Node.js. It has no manifest, shared +library, worker process, integrity digest, or separately managed environment. That makes +it the most direct choice for behavior owned and deployed by one application. + +The checked `examples/language-binding-plugin` project implements one +`documentation-plugin` in all three bindings. Every version follows the same sequence: + +1. Validate the same JSON-compatible component settings. +2. Install equivalent event and request behavior. +3. Print the activation report and exercise managed tool, model, stream, and event paths. +4. Clear registrations and deregister the kind. + +## The Smallest Useful Language-Binding Plugin + +These complete programs contain the same boundary in each binding: the plugin validates +component-local JSON, installs component-owned middleware, the host activates a +`PluginConfig`, and cleanup tears the behavior down. + + + +```python +import asyncio +import nemo_relay +from nemo_relay import plugin, tools + +class AddTagPlugin: + def validate(self, config): + tag = config.get("tag") + if not isinstance(tag, str) or not tag: + return [{ + "level": "error", + "code": "example.invalid_tag", + "component": "example.add-tag", + "field": "tag", + "message": "tag must be a non-empty string", + }] + return [] + + def register(self, config, context): + tag = config["tag"] + context.register_tool_request_intercept( + "add-tag", + 20, + False, + lambda _name, request: {**request, "plugin_tag": tag}, + ) + +async def main(): + plugin.register("example.add-tag", AddTagPlugin()) + config = plugin.PluginConfig(components=[ + plugin.ComponentSpec( + kind="example.add-tag", + enabled=True, + config={"tag": "documentation"}, + ) + ]) + try: + report = await plugin.initialize(config) + print("activation:", report) + result = await tools.execute( + "lookup", + {"id": 7}, + lambda request: nemo_relay.ToolExecutionResult(request), + ) + assert result.result == {"id": 7, "plugin_tag": "documentation"} + finally: + await plugin.clear_async() + plugin.deregister("example.add-tag") + +asyncio.run(main()) ``` - -The lifecycle works in stages. Register the plugin kind, validate component -config, and initialize enabled components. `PluginContext` installs runtime -behavior. If registration fails partway through, Relay rolls back the partial -setup. - -## Keep the First Plugin Small - -The easiest first plugin is one of these: - -- A subscriber-oriented plugin that exports events. -- A request-intercept plugin that adds one provider header. -- A sanitize guardrail plugin that redacts one field family. -- A policy plugin that registers one conditional-execution guardrail. - -Avoid a first plugin that combines unrelated subscribers, request transforms, and -policy checks. Multi-surface bundles are useful later, but they need stronger -validation and rollout controls. Refer to [Adaptive Configuration](/configure-plugins/adaptive/configuration) -when you need Adaptive behavior. - -## Minimal Config Contract - -The top-level config document has `version`, `components`, and `policy`. Each -component chooses a plugin kind and passes component-local JSON configuration to -that plugin. The following document shows the complete shape: - -```json -{ - "version": 1, - "components": [ - { - "kind": "header-plugin", - "enabled": true, - "config": { - "header_name": "x-tenant", - "value": "tenant-a" - } + + +```js +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const relay = require('nemo-relay-node'); +const plugin = require('nemo-relay-node/plugin'); + +const addTagPlugin = { + validate(config) { + const { tag } = config; + if (typeof tag !== 'string' || tag.length === 0) { + return [{ + level: 'error', + code: 'example.invalid_tag', + component: 'example.add-tag', + field: 'tag', + message: 'tag must be a non-empty string', + }]; } - ], - "policy": { - "unknown_component": "warn", - "unknown_field": "warn", - "unsupported_value": "error" + return []; + }, + register(config, context) { + context.registerToolRequestIntercept('add-tag', 20, false, (_name, request) => ({ + ...request, + plugin_tag: config.tag, + })); + }, +}; + +plugin.register('example.add-tag', addTagPlugin); +const config = { + version: 1, + components: [plugin.ComponentSpec('example.add-tag', { tag: 'documentation' })], +}; + +try { + const report = await plugin.initialize(config); + console.log('activation:', report); + const result = await relay.toolCallExecute('lookup', { id: 7 }, (request) => ({ result: request })); + if (result.result.plugin_tag !== 'documentation') { + throw new Error('plugin did not rewrite the request'); } +} finally { + await relay.flushSubscribers(); + plugin.clear(); + plugin.deregister('example.add-tag'); } ``` + + +```rust +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use nemo_relay::api::tool::{ToolCallExecuteParams, ToolExecutionResult, tool_call_execute}; +use nemo_relay::plugin::{ + ConfigDiagnostic, DiagnosticLevel, Plugin, PluginComponentSpec, PluginConfig, + PluginRegistrationContext, Result as PluginResult, clear_plugin_configuration, + deregister_plugin, initialize_plugins, register_plugin, +}; +use serde_json::{Map, Value as Json, json}; + +struct AddTagPlugin; + +impl Plugin for AddTagPlugin { + fn plugin_kind(&self) -> &str { + "example.add-tag" + } -Use this document as the boundary between operator intent and plugin implementation. Keep business logic in the plugin code, not in the config parser. - -## Design Checklist - -Before you write the plugin implementation, answer these questions: - -- What is the stable plugin `kind`? -- What runtime surface does it install first? -- Which config fields are required? -- Which fields are safe to expose as JSON? -- What diagnostic should appear when each required field is missing? -- What should happen when the component is disabled? -- What should happen when registration fails halfway through? + fn validate(&self, config: &Map) -> Vec { + match config.get("tag") { + Some(Json::String(tag)) if !tag.is_empty() => Vec::new(), + _ => vec![ConfigDiagnostic { + level: DiagnosticLevel::Error, + code: "example.invalid_tag".into(), + component: Some(self.plugin_kind().into()), + field: Some("tag".into()), + message: "tag must be a non-empty string".into(), + }], + } + } -## Next Steps + fn register<'a>( + &'a self, + config: &Map, + context: &'a mut PluginRegistrationContext, + ) -> Pin> + Send + 'a>> { + let tag = config["tag"].as_str().expect("validated tag").to_owned(); + Box::pin(async move { + context.register_tool_request_intercept( + "add-tag", + 20, + false, + Arc::new(move |_name, mut request| { + let tag = tag.clone(); + Box::pin(async move { + request["plugin_tag"] = Json::String(tag); + Ok(request) + }) + }), + )?; + Ok(()) + }) + } +} -Use these links to continue from this workflow into the next related task. +#[tokio::main] +async fn main() -> Result<(), Box> { + register_plugin(Arc::new(AddTagPlugin))?; + let mut component = PluginComponentSpec::new("example.add-tag"); + component.config = json!({ "tag": "documentation" }) + .as_object() + .expect("object config") + .clone(); + let config = PluginConfig { components: vec![component], ..PluginConfig::default() }; + + let result = async { + let report = initialize_plugins(config).await?; + println!("activation: {report:?}"); + let result = tool_call_execute( + ToolCallExecuteParams::builder() + .name("lookup") + .args(json!({ "id": 7 })) + .func(Arc::new(|request| { + Box::pin(async move { Ok(ToolExecutionResult::new(request)) }) + })) + .build(), + ) + .await?; + assert_eq!(result.result["plugin_tag"], "documentation"); + Ok::<_, Box>(()) + } + .await; -- Define validation behavior with [Validate Plugin Configuration](/build-plugins/language-binding/validate-configuration). -- Register runtime behavior with [Register Plugin Behavior](/build-plugins/language-binding/register-behavior). -- Add rollout controls with [Design Plugin Configuration](/build-plugins/language-binding/advanced-configuration). -- Review complete examples in [Code Examples](/build-plugins/language-binding/code-examples). + clear_plugin_configuration()?; + assert!(deregister_plugin("example.add-tag")); + result +} +``` + + + +The host passes only `{"tag": "documentation"}` to both hooks. `register` does not +receive the surrounding document, `enabled`, or another component's settings. Relay +records the registration against this component, which is why clearing configuration +can remove it without the plugin keeping a global deregistration handle. + +## How the Bindings Differ + +| Concern | Python | Node.js | Rust | +|---|---|---|---| +| Plugin identity | `plugin.register(kind, implementation)` supplies the kind. | `plugin.register(kind, implementation)` supplies the kind. | `Plugin::plugin_kind()` supplies the kind passed to `register_plugin`. | +| Validation | `validate` is optional in the protocol, but a published component should implement it. | `validate` is optional in the interface, but a published component should implement it. | `validate` is required. Override `validate_with_policy` when component diagnostics should honor the host's non-default policy. | +| Registration hook | Runs synchronously. Installed middleware callbacks can be synchronous or asynchronous according to their typed API. | Runs synchronously. Installed middleware callbacks can return values or promises according to their declarations. | Returns a `Send` future, so initialization can await resource setup and registration. | +| Clearing | Use `await clear_async()` inside an event loop; synchronous `clear()` deliberately rejects that situation. | Await `relay.flushSubscribers()` when queued publication is still active, then use `clear()` to remove active registrations synchronously. | `clear_plugin_configuration()` removes active registrations and can report teardown failure. | +| Deregistration | `deregister(kind)` returns whether a kind was removed. | `deregister(kind)` returns whether a kind was removed. | `deregister_plugin(kind)` removes the implementation from future lookup. | + +Configuration keys remain `snake_case` in all three examples. Only Node.js API methods +such as `listKinds` or `registerLlmRequestIntercept` use `camelCase`. + +## Follow the Complete Workflow + +Follow these pages in order to build, activate, exercise, and remove the same plugin in +each language binding: + +1. [Validate Configuration](/build-plugins/language-binding/validate-configuration) + turns wrong types, unsupported modes, unknown fields, and disabled-invalid components + into stable diagnostics before runtime state changes. +2. [Register Behavior](/build-plugins/language-binding/register-behavior) connects valid + feature groups to component-owned registrations and verifies rollback. +3. [Advanced Configuration](/build-plugins/language-binding/advanced-configuration) + covers multiple instances, host policy, reports, clearing, deregistration, and async + lifecycle differences. +4. [Runnable Examples](/build-plugins/language-binding/code-examples) gives the clean + commands and expected output for Rust, Python, and Node.js. + +Success means the same operator intent produces the same visible behavior in each +binding: invalid configuration is inert, valid configuration reports activation, +representative calls show the plugin effect, and teardown removes both active behavior +and future kind lookup when requested. diff --git a/docs/build-plugins/language-binding/advanced-configuration.mdx b/docs/build-plugins/language-binding/advanced-configuration.mdx index a9bec92cb..b94d2054a 100644 --- a/docs/build-plugins/language-binding/advanced-configuration.mdx +++ b/docs/build-plugins/language-binding/advanced-configuration.mdx @@ -1,197 +1,155 @@ --- -title: "Design Plugin Configuration" -description: "Design stable configuration, validation, rollout, and lifecycle behavior for NeMo Relay plugins." -position: 6 +title: "Advanced Configuration" +description: "Control plugin multiplicity, reports, replacement, and teardown across bindings." +position: 8 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} -Use this guide to safely configure a plugin that needs more than a single flag -or string. - -## What You Design - -Define the plugin's configuration contract, validation rules, advanced -configuration patterns, and runtime registration plan. Keep activation -predictable for operators while keeping runtime objects and business logic in -the plugin implementation. - -## Plugin Shape and Requirements - -A NeMo Relay plugin has four practical parts: - -| Part | Requirement | -|---|---| -| Plugin kind | A stable `kind` string registered once per process. | -| Component config | A JSON-compatible object under `components[].config`. | -| Validation hook | A function that returns structured diagnostics before initialization. | -| Registration hook | A function that receives `PluginContext` and installs runtime behavior. | - -The top-level plugin document keeps activation consistent across bindings: - -```json -{ - "version": 1, - "components": [ - { - "kind": "header-plugin", - "enabled": true, - "config": { - "header_name": "x-tenant", - "value": "tenant-a" - } - } - ], - "policy": { - "unknown_component": "warn", - "unknown_field": "warn", - "unsupported_value": "error" - } -} -``` - -Keep the component config portable: - -- Use JSON-compatible values only. -- Put clients, callbacks, file handles, and provider SDK objects in plugin code, not config. -- Include a component-local config version when the plugin's own schema needs to evolve independently. -- Prefer references to secrets or endpoints over embedding sensitive values directly. -- Treat a component whose `enabled` field has the value `false` as disabled for - activation, not as a reason to skip validation. - -The top-level `policy` controls validation that Relay handles. For a custom -plugin's component-local `config`, define unknown-field and unsupported-value -behavior in the plugin's `validate()` method. - -## Configuration Validation - -Keep validation deterministic and side-effect free. Inspect configuration and -return diagnostics. Do not register middleware, open network connections, -create clients, or change process state. - -Check these areas: - -- Required fields are present. -- Field types match the supported shape. -- The plugin reports unknown component-local fields with stable diagnostics. -- The plugin reports unsupported component-local values with stable diagnostics. -- Cross-field combinations make sense. -- Environment-specific limitations are warnings unless they would make activation fail. - -Diagnostics should be actionable and stable enough for tests or deployment automation: - -```json -[ - { - "level": "error", - "code": "header-plugin.missing_header_name", - "component": "header-plugin", - "field": "header_name", - "message": "config.header_name is required" - } -] +After one component works, the difficult questions are ownership questions: whether the +kind can appear more than once, how a replacement interacts with the active report, and +which API removes active behavior versus removing the implementation from future lookup. + +## Multiple Component Instances + +Rust language-binding plugins return `true` from `allows_multiple_components` by default. +A singleton implementation should override it to return false. Python and Node.js custom +plugin objects do not expose a separate multiplicity hook in their current public +interfaces, so design and test their component behavior with the binding contract rather +than copying a Rust-only method into them. + +When multiple components are allowed, each receives a separate component config and +registration context. Give an instance an operator-facing identity such as a region or +tenant only when diagnostics and external resources need it. Registration names remain +local; Relay performs qualification and cleanup. + +## Replacement and Reports + +Initialization replaces active configuration as a transaction. Validation and new +registration run before Relay commits the replacement. If a callback fails, Relay +removes partial new registrations and attempts to retain or restore the last proven +configuration. A cleanup failure is more serious: the process can retain a runtime +diagnostic report and refuse later mutation because it can no longer prove that stale +callbacks are gone. + +The report accessor is a snapshot, not a live inspection of every registry. The kind-listing +accessor answers a different question: which implementations can future component +documents reference? An inactive or disabled kind can appear in that list, and an active +component can keep running after its kind is deregistered until configuration is cleared. + +## Own the Complete Host Lifecycle + +The host, rather than the plugin implementation, owns kind registration and active +configuration. These complete lifecycle skeletons show where validation, report +inspection, clearing, and deregistration belong. Put the clear operation in a guaranteed +cleanup path so an application exception does not leave middleware installed. + + + +```python +implementation = DocumentationPlugin() +plugin.register("documentation-plugin", implementation) +assert "documentation-plugin" in plugin.list_kinds() + +preflight = plugin.validate(plugin_config) +if any(item["level"] == "error" for item in preflight["diagnostics"]): + raise ValueError(preflight["diagnostics"]) + +try: + report = await plugin.initialize(plugin_config) + print("active report:", report) + assert plugin.report() is not None + await run_application_work() +finally: + await plugin.clear_async() + assert plugin.deregister("documentation-plugin") ``` -Use `warning` when the config can still activate but deserves operator attention. Use `error` when initialization should not proceed. - -## Advanced Configuration Patterns - -These patterns help plugin authors keep configuration stable as components evolve. - -### Component-Local Versioning - -Use a field such as `config.version` when the plugin's config schema needs independent compatibility handling. Keep the top-level `version` for the NeMo Relay plugin document itself. - -### Multiple Component Instances +`clear_async` is the correct operation inside this running event loop. Calling the +synchronous `clear()` here raises instead of blocking the loop. + + +```js +plugin.register('documentation-plugin', documentationPlugin); +if (!plugin.listKinds().includes('documentation-plugin')) { + throw new Error('plugin kind was not registered'); +} -If your plugin supports multiple instances, require an explicit instance -identity in configuration: +const preflight = plugin.validate(pluginConfig); +if (preflight.diagnostics.some((item) => item.level === 'error')) { + throw new Error(JSON.stringify(preflight.diagnostics)); +} -```json -{ - "kind": "routing-policy", - "config": { - "instance": "east-region", - "priority": 100, - "region": "us-east-1" +try { + const report = await plugin.initialize(pluginConfig); + console.log('active report:', report); + if (plugin.report() === null) throw new Error('active report is missing'); + await runApplicationWork(); +} finally { + await relay.flushSubscribers(); + plugin.clear(); + if (!plugin.deregister('documentation-plugin')) { + throw new Error('plugin kind was not deregistered'); } } ``` + + +```rust +register_plugin(Arc::new(DocumentationPlugin))?; +assert!(list_plugin_kinds().iter().any(|kind| kind == "documentation-plugin")); + +let preflight = validate_plugin_config(&plugin_config); +if preflight.diagnostics.iter().any(|item| { + matches!(item.level, DiagnosticLevel::Error) +}) { + return Err("plugin configuration failed validation".into()); +} -Use the instance identity in logs, diagnostics, and downstream resource names. -Let the NeMo Relay plugin system qualify runtime registration names. Do not -hand-build global names to avoid collisions. +let report = initialize_plugins(plugin_config).await?; +println!("active report: {report:?}"); +assert!(active_plugin_report().is_some()); -### Presets and Overrides +let work_result = run_application_work().await; +let clear_result = clear_plugin_configuration(); +clear_result?; +assert!(deregister_plugin("documentation-plugin")); +work_result?; +``` -Presets are useful when most deployments use a known shape: +A singleton Rust implementation makes multiplicity explicit: -```json -{ - "kind": "redaction-policy", - "config": { - "preset": "strict", - "overrides": { - "allow_fields": ["request_id", "tenant"] - } - } +```rust +fn allows_multiple_components(&self) -> bool { + false } ``` -Validate the resolved result, not only the literal input. Unknown preset names should be `error` diagnostics because the plugin cannot know what behavior to install. - -### Rollout Controls - -For behavior that can affect execution, include explicit rollout fields: - -- `mode`: for example `observe_only`, `enforce`, or `disabled`. -- `priority`: where middleware should run relative to other registrations. -- `break_chain`: whether a request intercept should stop later intercepts. -- `sample_rate` or `tenants`: when behavior should apply only to part of traffic. - -Prefer observe-only defaults for new policies and execution-affecting intercepts. - -## Plugin Context - -`PluginContext` is the component-scoped surface used during registration. It connects validated config to real runtime behavior. - -Use `PluginContext` to register: - -- Subscribers -- Mark event sanitizers -- Scope-start and scope-end event sanitizers -- Tool guardrails -- Tool request and execution intercepts -- LLM guardrails -- LLM request, execution, and stream execution intercepts - -The context gives the plugin system enough information to qualify runtime names -and roll back partial setup if registration fails. Put all runtime registration -work in the registration hook so rollback can clean up correctly. - -Avoid these patterns: - -- Registering middleware before plugin initialization. -- Creating process-global state that is not owned by the plugin instance. -- Reusing one mutable object across component instances without tenant or request isolation. -- Encoding runtime callbacks inside JSON config. - -## Validation Checklist - -Before publishing a plugin config contract: - -1. Validate the smallest correct config. -2. Validate a config with each required field missing. -3. Validate each unsupported enum or mode. -4. Validate unknown component-local fields and their diagnostic levels. -5. Initialize a valid config and confirm expected middleware or subscribers are active. -6. Force a registration failure and confirm partial setup is rolled back. - -## Next Steps - -Use these links to continue from this workflow into the next related task. - -- Build the first plugin with [Language Binding Plugins](/build-plugins/language-binding/about). -- Validate plugin config with [Validate Plugin Configuration](/build-plugins/language-binding/validate-configuration). -- Register runtime behavior with [Register Plugin Behavior](/build-plugins/language-binding/register-behavior). -- Review reusable patterns in [Code Examples](/build-plugins/language-binding/code-examples). +`initialize_plugins` resolves the supplied document with discovered `plugins.toml` +configuration before it activates the result. `initialize_plugins_exact` is an internal +escape hatch for hosts that have already resolved a `PluginConfig` and need Relay to +apply those values without discovery or layering. + + + +## Teardown in the Correct Order + +Use the following order so active callbacks cannot outlive their implementation: + +1. Stop sending new managed calls and let in-flight plugin callbacks settle. In Node.js, + await `relay.flushSubscribers()` before clearing so queued event and scope-end + sanitizers finish while their component still owns their callbacks. +2. Clear active configuration. In Python, use `await plugin.clear_async()` whenever an + event loop is running; the synchronous `clear()` raises instead of blocking that loop. + Rust checks the returned `Result`, and Node.js calls `plugin.clear()` after the flush. +3. Inspect the report after clear. A clean clear removes the active report; a failed + teardown can retain bounded runtime diagnostics for investigation. +4. Deregister the plugin kind only after active behavior is gone. Deregistration affects + future validation and initialization, not registrations already committed to the + runtime. +5. Confirm the kind no longer appears in `list_plugin_kinds`, `list_kinds`, or `listKinds`, + then repeat a representative call and prove the plugin effect is absent. + +Success means instance multiplicity is intentional, reports are interpreted separately +from registry state, async teardown never blocks its event loop, and the application can +prove both active registrations and future kind lookup have been removed. diff --git a/docs/build-plugins/language-binding/code-examples.mdx b/docs/build-plugins/language-binding/code-examples.mdx index 776483b53..7e65a557c 100644 --- a/docs/build-plugins/language-binding/code-examples.mdx +++ b/docs/build-plugins/language-binding/code-examples.mdx @@ -1,364 +1,105 @@ --- -title: "Code Examples" -description: "Explore reusable NeMo Relay plugin patterns for request interception, event logging, and policy bundles." -position: 8 +title: "Runnable Examples" +description: "Run equivalent Python, Node.js, and Rust language-binding plugin hosts." +position: 9 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} +The checked `examples/language-binding-plugin` directory contains one shared +configuration and three small hosts. Run commands from that directory so relative paths +and package resolution match the tested workflow. -This page shows reusable Python and Node.js plugin patterns for request -interception, event logging, multi-surface policies, and framework-neutral -behavior. The same plugin contract applies to Rust. Refer to the [Header Plugin -Example](/build-plugins/language-binding/register-behavior#header-plugin-example) -for Rust-specific registration guidance. +The executable in each language remains an end-to-end learning path. Its tests are +deliberately narrower: each test creates or directly captures only the plugin state it +needs, asserts one behavior, and cleans up its registrations. You can therefore run any +test by name without relying on test order or on another example having prepared files, +configuration, or process state. -## Dynamic Header Injection +## Python Host -Use an LLM request intercept when a plugin needs to inject tenant or routing metadata into every provider request. +Run and inspect the Python host as follows: -LLM request intercepts receive `name`, `request`, and `annotated`. Python -intercepts receive an immutable request, so they return a new request with the -required changes. Node.js intercepts receive a normal JavaScript request -object; return a complete outcome containing the intended request rather than -relying on in-place mutation. Rust intercepts receive a mutable request. +1. Build the repository Python binding as required by the normal development setup, then + run the example test and host. -The following complete examples define, register, validate, and activate a -plugin that adds a request header. Each example clears the active configuration -during shutdown. + ```bash + cd python + uv run --locked --group test pytest + uv run python main.py + ``` - - -```python -import asyncio -from typing import Any +2. Confirm that the async context clears configuration even when the representative work + raises. The example deliberately uses `clear_async` rather than blocking the running + event loop. -import nemo_relay +Success has the same observable report and call behavior as the Rust host, followed by a +clean report and successful deregistration. -class HeaderPlugin: - def validate(self, plugin_config: dict[str, Any]) -> list[dict[str, str]]: - diagnostics = [] - for field in ("header_name", "value"): - if not isinstance(plugin_config.get(field), str): - diagnostics.append({ - "level": "error", - "code": "header-plugin.invalid_config", - "component": "header-plugin", - "field": field, - "message": f"{field} must be a string", - }) - return diagnostics +## Node.js Host - def register(self, plugin_config: dict[str, Any], context: nemo_relay.plugin.PluginContext): - def add_header( - name: str, - request: nemo_relay.LLMRequest, - annotated: nemo_relay.AnnotatedLLMRequest | None - ) -> nemo_relay.LLMRequestInterceptOutcome: - # Return a new request with updated headers. - headers = request.headers.copy() - headers[plugin_config["header_name"]] = plugin_config["value"] - return nemo_relay.LLMRequestInterceptOutcome( - nemo_relay.LLMRequest(headers=headers, content=request.content), - annotated, - ) +Run and inspect the Node.js host as follows: - context.register_llm_request_intercept("inject-header", 100, False, add_header) +1. Build the repository Node binding through the normal `just build-node` workflow, then + run the example test and host. -async def main() -> None: - nemo_relay.plugin.register("header-plugin", HeaderPlugin()) + ```bash + cd node + npm test + npm start + ``` - config = nemo_relay.plugin.PluginConfig( - components=[ - nemo_relay.plugin.ComponentSpec( - kind="header-plugin", - config={"header_name": "x-tenant", "value": "tenant-a"}, - ) - ] - ) - report = nemo_relay.plugin.validate(config) - if any(diagnostic["level"] == "error" for diagnostic in report["diagnostics"]): - raise RuntimeError(report["diagnostics"]) +2. Confirm that promise-returning middleware is awaited, the LLM request outcome retains + `annotated`, and stream output preserves chunk order. The host uses the public + `nemo-relay-node/typed` stream wrapper, which owns the native stream bridge; plugin + callbacks receive an array of downstream chunks rather than a lazy downstream stream. + Before it clears the component, it awaits `relay.flushSubscribers()` so queued scope-end + sanitizers finish before their callbacks are deregistered. - try: - active_report = await nemo_relay.plugin.initialize(config) - print("Activation report:", active_report) - # Run instrumented application work here. - finally: - await nemo_relay.plugin.clear_async() +## Rust Host +Run and inspect the Rust host as follows: -if __name__ == "__main__": - asyncio.run(main()) -``` - - - -```js -const plugin = require('nemo-relay-node/plugin'); - -const headerPlugin = { - validate(pluginConfig) { - const diagnostics = []; - for (const field of ['header_name', 'value']) { - if (typeof pluginConfig[field] !== 'string') { - diagnostics.push({ - level: 'error', - code: 'header-plugin.invalid_config', - component: 'header-plugin', - field, - message: `${field} must be a string`, - }); - } - } - return diagnostics; - }, - register(pluginConfig, context) { - context.registerLlmRequestIntercept('inject-header', 100, false, ({ request, annotated }) => { - if ( - typeof request !== 'object' || - request === null || - Array.isArray(request) || - typeof request.headers !== 'object' || - request.headers === null || - Array.isArray(request.headers) - ) { - throw new Error('Expected an LLM request object with headers.'); - } - return { - request: { - ...request, - headers: { - ...request.headers, - [String(pluginConfig.header_name)]: String(pluginConfig.value), - }, - }, - annotated, - }; - }); - }, -}; - -void (async () => { - plugin.register('header-plugin', headerPlugin); - - const config = plugin.defaultConfig(); - config.components = [ - plugin.ComponentSpec( - 'header-plugin', - { header_name: 'x-tenant', value: 'tenant-a' }, - { enabled: true }, - ), - ]; - const report = plugin.validate(config); - if (report.diagnostics.some((diagnostic) => diagnostic.level === 'error')) { - throw new Error(JSON.stringify(report.diagnostics)); - } - - try { - const activeReport = await plugin.initialize(config); - console.log('Activation report:', activeReport); - // Run instrumented application work here. - } finally { - plugin.clear(); - } -})().catch((error) => { - console.error(error); - process.exitCode = 1; -}); -``` - - - -This pattern is useful for: - -- Tenant identity -- Trace correlation -- Region or deployment routing - -## Subscriber Logging Pattern - -Use a subscriber-oriented plugin when the component should watch the full -lifecycle rather than rewrite requests. The following examples log each event -in Python and Node.js. They do not transform events into the `openinference` -OpenTelemetry projection. -Refer to -[OpenInference](/configure-plugins/observability/openinference) for the -built-in `openinference` projection of the typed OpenTelemetry exporter. - - - -```python -import asyncio - -import nemo_relay - -class EventLoggingPlugin: - def register(self, plugin_config, context): - def on_event(event): - print("event", event) - - context.register_subscriber("event-logging", on_event) - -async def main() -> None: - nemo_relay.plugin.register("event-logging", EventLoggingPlugin()) - config = nemo_relay.plugin.PluginConfig( - components=[ - nemo_relay.plugin.ComponentSpec(kind="event-logging", config={}) - ] - ) - report = nemo_relay.plugin.validate(config) - if any(diagnostic["level"] == "error" for diagnostic in report["diagnostics"]): - raise RuntimeError(report["diagnostics"]) - - try: - active_report = await nemo_relay.plugin.initialize(config) - print("Activation report:", active_report) - # Run managed tool or LLM work here to log lifecycle events. - finally: - await nemo_relay.plugin.clear_async() - - -if __name__ == "__main__": - asyncio.run(main()) -``` - +1. Run the Rust example and its lifecycle test. - -```js -const plugin = require('nemo-relay-node/plugin'); + ```bash + cd rust + cargo test --locked + cargo run --locked + ``` -const eventLoggingPlugin = { - register(pluginConfig, context) { - context.registerSubscriber('event-logging', (event) => { - console.log('event', event); - }); - }, -}; +2. Read the printed invalid report, active report, canonical allowed-tool result, rewritten LLM + headers, streamed chunks, and teardown confirmation. -void (async () => { - plugin.register('event-logging', eventLoggingPlugin); - const config = plugin.defaultConfig(); - config.components = [plugin.ComponentSpec('event-logging', {})]; +Success means the invalid configuration reports `documentation-plugin.unsupported_mode`, +the valid report is active, representative tool, LLM, stream, and event paths carry the +documentation behavior, and the final kind list no longer contains the example. - const report = plugin.validate(config); - if (report.diagnostics.some((diagnostic) => diagnostic.level === 'error')) { - throw new Error(JSON.stringify(report.diagnostics)); - } +## Compare the Host Output - try { - const activeReport = await plugin.initialize(config); - console.log('Activation report:', activeReport); - // Run managed tool or LLM work here to log lifecycle events. - } finally { - plugin.clear(); - } -})().catch((error) => { - console.error(error); - process.exitCode = 1; -}); -``` - - - -This is the right pattern when the component: - -- Logs events across tools and LLMs -- Adds application-specific logging or filtering -- Should not change execution behavior - -## Multi-Surface Policy Bundle - -A plugin can register more than one runtime surface when one configuration document controls a related behavior bundle. - -The following Python example installs a subscriber and an LLM request intercept -from one component configuration: - -```python -import asyncio -from typing import Any - -import nemo_relay - -class CorrelationPolicy: - def validate(self, plugin_config: dict[str, Any]) -> list[dict[str, str]]: - if isinstance(plugin_config.get("header_name"), str): - return [] - return [{ - "level": "error", - "code": "correlation-policy.invalid_config", - "component": "correlation-policy", - "field": "header_name", - "message": "header_name must be a string", - }] - - def register( - self, - plugin_config: dict[str, Any], - context: nemo_relay.plugin.PluginContext, - ) -> None: - def on_event(event: nemo_relay.Event) -> None: - print("event", event) +The three hosts intentionally share the complete safe plugin surface instead of +showcasing unrelated language tricks. Use them to compare API spelling and async +mechanics while relying on the shared +[PluginContext](/build-plugins/fundamentals/plugin-context) contract for semantics. - def add_correlation_header( - name: str, - request: nemo_relay.LLMRequest, - annotated: nemo_relay.AnnotatedLLMRequest | None, - ) -> nemo_relay.LLMRequestInterceptOutcome: - headers = request.headers.copy() - headers[plugin_config["header_name"]] = "policy-bundle" - return nemo_relay.LLMRequestInterceptOutcome( - nemo_relay.LLMRequest(headers=headers, content=request.content), - annotated, - ) +All three hosts print the same evidence with binding-specific report formatting. The +following normalized transcript omits unrelated trace context and highlights the +diagnostic code, rewritten tool input, rewritten model headers, transformed stream +chunks, and final teardown line: - context.register_subscriber("event-logging", on_event) - context.register_llm_request_intercept( - "add-correlation-header", 100, False, add_correlation_header - ) - -async def main() -> None: - nemo_relay.plugin.register("correlation-policy", CorrelationPolicy()) - config = nemo_relay.plugin.PluginConfig( - components=[ - nemo_relay.plugin.ComponentSpec( - kind="correlation-policy", - config={"header_name": "x-correlation-source"}, - ) - ] - ) - report = nemo_relay.plugin.validate(config) - if any(diagnostic["level"] == "error" for diagnostic in report["diagnostics"]): - raise RuntimeError(report["diagnostics"]) - - try: - await nemo_relay.plugin.initialize(config) - # Run managed tool or LLM work here. - finally: - await nemo_relay.plugin.clear_async() - - -if __name__ == "__main__": - asyncio.run(main()) +```text +registered: documentation-plugin present +invalid: documentation-plugin.unsupported_mode at requests.mode +active: documentation-plugin enabled +tool: {"result":{"value":1,"plugin_tag":"documentation"},"annotation":{"source":"application"}} +llm: {"headers":{"x-nemo-relay-plugin":"documentation"}} +stream: {"chunk":1,"plugin_stream":true} +stream: {"chunk":2,"plugin_stream":true} +teardown: complete ``` -This bundle registers: - -- An event-logging subscriber -- An LLM request intercept that adds correlation metadata - -Use this pattern when one component makes the configured behavior easier to -reason about than several unrelated plugin components. Keep each registered -surface small. Make the component configuration explicit about which surfaces -are enabled. - -## Framework-Neutral Plugin Design - -Plugins can stay framework-agnostic if they operate on the normalized runtime data rather than framework-specific objects. - -Good examples: - -- Rewrite provider headers -- Emit tracing data -- Attach scheduling hints -- Apply cross-framework safety policies +The exact report debug representation is not a compatibility surface, so tests assert +its diagnostic and component fields rather than matching the whole printed line. The +tool callback returns a `ToolExecutionResult`; each host checks its business payload +through `.result` and verifies that the optional `.annotation` survives both execution +wrappers. LLM and stream values remain their existing application-visible JSON values. diff --git a/docs/build-plugins/language-binding/register-behavior.mdx b/docs/build-plugins/language-binding/register-behavior.mdx index 205d03a86..459068f4c 100644 --- a/docs/build-plugins/language-binding/register-behavior.mdx +++ b/docs/build-plugins/language-binding/register-behavior.mdx @@ -1,388 +1,224 @@ --- -title: "Register Plugin Behavior" -description: "Register validated NeMo Relay plugin configuration through PluginContext and manage its lifecycle." -position: 5 +title: "Register Behavior" +description: "Install component-owned event, tool, LLM, and stream behavior in each binding." +position: 7 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} +Registration converts a valid component document into owned runtime behavior. The +example installs only the feature groups whose `enabled` values are true and reads +priority and `break_chain` directly from configuration. It never calls a process-global +middleware registrar from inside the plugin. -Use this guide after you define plugin configuration validation and before the -plugin installs NeMo Relay runtime behavior. +## Register One Equivalent Request Intercept -## What You Build - -Register a plugin kind, initialize validated configuration, install subscribers -or middleware through `PluginContext`, and clear active plugin configuration -during teardown. - -## Use PluginContext - -`PluginContext` is the component-scoped registration surface that Relay passes -to the plugin during initialization. Register subscribers, guardrails, and -intercepts through this context instead of through global registration calls in -application startup. - -The context gives the plugin system three important guarantees: - -- The runtime qualifies names for the component instance. -- Relay rolls back partial setup if one registration fails. -- Plugin diagnostics can identify the affected configured component when the - plugin includes `component` in each diagnostic. - -Use the context only after validation succeeds. Keep validation deterministic and -side-effect free. Inspect configuration and return diagnostics. Create runtime -objects and attach them to the context during registration. - -The context includes mark, scope-start, and scope-end event sanitizer -registrations in addition to the tool and LLM middleware surfaces. Event -sanitizer callbacks receive the immutable event plus `data`, -`category_profile`, and `metadata`, and return only those observability fields. -Use the context methods so component name qualification and rollback also apply -to these registries. Refer to [Event Sanitizers](/reference/event-sanitizers) -for the binding-specific method names. - -## Header Plugin Example - -The same model applies in every binding: validate component-local config, then install middleware through the component-scoped registration context. +The following excerpts show the same model-header rewrite. The full checked examples add +event observation, tool policy, execution wrappers, and streaming verification around +this common center. ```python -from typing import Any - -import nemo_relay - -class HeaderPlugin: - def validate(self, plugin_config: dict[str, Any]) -> list[dict[str, str]]: - diagnostics = [] - for field in ("header_name", "value"): - if not isinstance(plugin_config.get(field), str): - diagnostics.append({ - "level": "error", - "code": "header-plugin.invalid_config", - "component": "header-plugin", - "field": field, - "message": f"{field} must be a string", - }) - return diagnostics - - def register(self, plugin_config: dict[str, Any], context: nemo_relay.plugin.PluginContext): - def add_header( - name: str, - request: nemo_relay.LLMRequest, - annotated: nemo_relay.AnnotatedLLMRequest | None - ) -> nemo_relay.LLMRequestInterceptOutcome: - headers = request.headers.copy() - headers[plugin_config["header_name"]] = plugin_config["value"] - return nemo_relay.LLMRequestInterceptOutcome( - nemo_relay.LLMRequest(headers=headers, content=request.content), - annotated, - ) - - context.register_llm_request_intercept("inject-header", 100, False, add_header) - +settings = normalized_config(config) +tag = settings["tag"] +observe = settings["observe"] +requests = settings["requests"] +execution = settings["execution"] + +if observe["enabled"]: + context.register_subscriber( + "events", lambda event: self.events.append(event.name) + ) + +def tool_policy(name, _args): + if requests["mode"] == "enforce" and name in requests["blocked_tools"]: + return f"tool '{name}' is blocked" + return None + +context.register_tool_conditional_execution_guardrail( + "tool-policy", 10, tool_policy +) +context.register_tool_request_intercept( + "tool-request", + requests["priority"], + requests["break_chain"], + lambda _name, args: {**args, "plugin_tag": tag}, +) + +def add_header(name, request, annotated): + headers = dict(request.headers) + headers[requests["header_name"]] = requests["header_value"] + return LLMRequestInterceptOutcome( + request=LLMRequest(headers=headers, content=request.content), + annotated_request=annotated, + ) + +context.register_llm_request_intercept( + "documentation-header", + requests["priority"], + requests["break_chain"], + add_header, +) + +async def stream_request(request, next_call): + async for chunk in await next_call(request): + yield {**chunk, "plugin_stream": True} + +context.register_llm_stream_execution_intercept( + "documentation-stream", execution["priority"], stream_request +) ``` - - ```js -const plugin = require('nemo-relay-node/plugin'); +const settings = normalizedConfig(config); +const { observe, requests, execution } = settings; -const headerPlugin = { - validate(pluginConfig) { - const diagnostics = []; - for (const field of ['header_name', 'value']) { - if (typeof pluginConfig[field] !== 'string') { - diagnostics.push({ - level: 'error', - code: 'header-plugin.invalid_config', - component: 'header-plugin', - field, - message: `${field} must be a string`, - }); - } - } - return diagnostics; - }, - register(pluginConfig, context) { - context.registerLlmRequestIntercept('inject-header', 100, false, ({ request, annotated }) => { - if ( - typeof request !== 'object' || - request === null || - Array.isArray(request) || - typeof request.headers !== 'object' || - request.headers === null || - Array.isArray(request.headers) - ) { - throw new Error('Expected an LLM request object with headers.'); - } - return { - request: { - ...request, - headers: { - ...request.headers, - [String(pluginConfig.header_name)]: String(pluginConfig.value), - }, - }, - annotated, - }; - }); - }, -}; +if (observe.enabled) { + context.registerSubscriber( + 'events', (event) => documentationPlugin.events.push(event.name), + ); +} +context.registerToolConditionalExecutionGuardrail('tool-policy', 10, (name) => ( + requests.mode === 'enforce' && requests.blocked_tools.includes(name) + ? `tool '${name}' is blocked` + : null +)); +context.registerToolRequestIntercept( + 'tool-request', requests.priority, requests.break_chain, (_name, args) => ({ + ...args, + plugin_tag: settings.tag, + }), +); + +context.registerLlmRequestIntercept( + 'documentation-header', + requests.priority, + requests.break_chain, + ({ request, annotated }) => ({ + request: { + ...request, + headers: { + ...request.headers, + [requests.header_name]: requests.header_value, + }, + }, + annotated, + }), +); + +context.registerLlmStreamExecutionIntercept( + 'documentation-stream', + execution.priority, + async (request, next) => ( + (await next(request)).map((chunk) => ({ ...chunk, plugin_stream: true })) + ), +); ``` - - ```rust -use nemo_relay::api::llm::LlmRequestInterceptOutcome; -use nemo_relay::plugin::{ - ConfigDiagnostic, DiagnosticLevel, Plugin, PluginRegistrationContext, Result as PluginResult, -}; -use serde_json::{Map, Value as Json}; -use std::future::Future; -use std::pin::Pin; -use std::sync::Arc; - -struct HeaderPlugin; - -impl Plugin for HeaderPlugin { - fn plugin_kind(&self) -> &str { - "header-plugin" - } - - fn validate(&self, plugin_config: &Map) -> Vec { - let mut diagnostics = Vec::new(); +let tag = config.tag.clone(); +let mode = config.requests.mode.clone(); +let blocked_tools = config.requests.blocked_tools.clone(); +let header_name = config.requests.header_name.clone(); +let header_value = config.requests.header_value.clone(); + +if config.observe.enabled { + ctx.register_subscriber( + "events", + Arc::new(|event| println!("event: {}", event.name())), + )?; +} - for field in ["header_name", "value"] { - match plugin_config.get(field) { - Some(Json::String(_)) => {} - Some(_) => diagnostics.push(ConfigDiagnostic { - level: DiagnosticLevel::Error, - code: "header-plugin.invalid_config".into(), - component: Some("header-plugin".into()), - field: Some(field.into()), - message: format!("{field} must be a string"), - }), - None => diagnostics.push(ConfigDiagnostic { - level: DiagnosticLevel::Error, - code: "header-plugin.invalid_config".into(), - component: Some("header-plugin".into()), - field: Some(field.into()), - message: format!("{field} is required"), - }), +ctx.register_tool_conditional_execution_guardrail( + "tool-policy", + 10, + Arc::new(move |name, _args| { + let mode = mode.clone(); + let blocked = blocked_tools.clone(); + Box::pin(async move { + Ok((mode == "enforce" && blocked.contains(&name)) + .then(|| format!("tool '{name}' is blocked"))) + }) + }), +)?; + +ctx.register_tool_request_intercept( + "tool-request", + config.requests.priority, + config.requests.break_chain, + Arc::new(move |_name, mut args| { + let tag = tag.clone(); + Box::pin(async move { + if let Some(object) = args.as_object_mut() { + object.insert("plugin_tag".into(), Json::String(tag)); } - } - - diagnostics - } - - fn register<'a>( - &'a self, - plugin_config: &Map, - ctx: &'a mut PluginRegistrationContext, - ) -> Pin> + Send + 'a>> { - let header_name = plugin_config - .get("header_name") - .and_then(Json::as_str) - .unwrap_or("x-plugin") - .to_string(); - let header_value = plugin_config - .get("value") - .and_then(Json::as_str) - .unwrap_or("enabled") - .to_string(); - + Ok(args) + }) + }), +)?; + +ctx.register_llm_request_intercept( + "documentation-header", + config.requests.priority, + config.requests.break_chain, + Arc::new(move |_name, mut request, annotated| { + let header_name = header_name.clone(); + let header_value = header_value.clone(); Box::pin(async move { - ctx.register_llm_request_intercept( - "inject-header", - 100, - false, - Arc::new(move |_name, mut request, annotated| { - request - .headers - .insert(header_name.clone(), header_value.clone().into()); - Ok(LlmRequestInterceptOutcome::new(request, annotated)) - }), - )?; - Ok(()) + request.headers.insert(header_name, header_value.into()); + Ok(LlmRequestInterceptOutcome::new(request, annotated)) }) - } -} - -``` - - - - - -## Activation APIs - -After you register the plugin kind, use the plugin APIs in this order. Refer to -the [Header Plugin Example](#header-plugin-example) for the registration pattern: - -1. Build a `PluginConfig`. -2. Validate the config. -3. Initialize the config. -4. Inspect the activation report. -5. Clear active config during teardown when needed. - -Register the plugin kind before initialization. With the default -`unknown_component="warn"` policy, `validate()` reports an enabled unregistered -kind as a warning. An error-only check therefore passes, but `initialize()` -raises for an enabled unregistered kind. Register every enabled kind before -initialization, or set -`unknown_component="error"` to make validation fail. - -`validate()` checks only the configuration you pass to it. `initialize()` also -layers discovered `plugins.toml` configuration, so startup can activate or -reject components that a preflight report did not include. Refer to [Plugin -Configuration Files](/configure-plugins/plugin-configuration-files) when file -discovery participates in deployment. - -Append the following entry point to the matching Header Plugin Example above. -Each tab relies on that example's plugin definition and imports. Together, the -two blocks register the custom plugin, validate configuration, initialize it, -inspect the activation report and available kinds, and clear active -configuration: - - - -Append this entry point to the Python Header Plugin Example above. - -```python -import asyncio - -import nemo_relay - -async def main() -> None: - nemo_relay.plugin.register("header-plugin", HeaderPlugin()) - - config = nemo_relay.plugin.PluginConfig() - config.components = [ - nemo_relay.plugin.ComponentSpec( - kind="header-plugin", - config={"header_name": "x-tenant", "value": "tenant-a"}, - ) - ] - - report = nemo_relay.plugin.validate(config) - if any(diagnostic["level"] == "error" for diagnostic in report["diagnostics"]): - raise RuntimeError(report["diagnostics"]) - - try: - active_report = await nemo_relay.plugin.initialize(config) - print("Activation report:", active_report) - print("Available kinds:", nemo_relay.plugin.list_kinds()) - finally: - await nemo_relay.plugin.clear_async() - - -if __name__ == "__main__": - asyncio.run(main()) -``` + }), +)?; - - - -Append this entry point to the Node.js Header Plugin Example above. - -```js -void (async () => { - plugin.register('header-plugin', headerPlugin); - - const config = plugin.defaultConfig(); - config.components = [ - plugin.ComponentSpec( - 'header-plugin', - { header_name: 'x-tenant', value: 'tenant-a' }, - { enabled: true }, - ), - ]; - - const report = plugin.validate(config); - if (report.diagnostics.some((diagnostic) => diagnostic.level === 'error')) { - throw new Error(JSON.stringify(report.diagnostics)); - } - - try { - const activeReport = await plugin.initialize(config); - console.log('Activation report:', activeReport); - console.log('Available kinds:', plugin.listKinds()); - } finally { - plugin.clear(); - } -})().catch((error) => { - console.error(error); - process.exitCode = 1; -}); -``` - - - - -Append this entry point to the Rust Header Plugin Example above. - -```rust -use nemo_relay::plugin::{ - clear_plugin_configuration, initialize_plugins, list_plugin_kinds, register_plugin, - validate_plugin_config, PluginComponentSpec, PluginConfig, -}; - -#[tokio::main] -async fn main() -> Result<(), Box> { - register_plugin(Arc::new(HeaderPlugin))?; - - let mut config = PluginConfig::default(); - let mut component = PluginComponentSpec::new("header-plugin"); - component.config.insert("header_name".into(), "x-tenant".into()); - component.config.insert("value".into(), "tenant-a".into()); - config.components.push(component); - - let report = validate_plugin_config(&config); - if report.has_errors() { - return Err(format!("{:?}", report.diagnostics).into()); - } - - let active_report = initialize_plugins(config).await?; - println!("Activation report: {active_report:?}"); - println!("Available kinds: {:?}", list_plugin_kinds()); - clear_plugin_configuration()?; - Ok(()) -} +ctx.register_llm_stream_execution_intercept( + "documentation-stream", + config.execution.priority, + Arc::new(move |_name, request, next| { + Box::pin(async move { + let downstream = next(request).await?; + Ok(LlmJsonStream::new(downstream.map(|chunk| { + chunk.map(|mut value| { + if let Some(object) = value.as_object_mut() { + object.insert("plugin_stream".into(), Json::Bool(true)); + } + value + }) + }))) + }) + }), +)?; ``` - - -## Registration Checklist - -Before publishing or sharing a plugin: - -1. Validate a correct config and confirm no errors are reported. -2. Validate an intentionally invalid config and confirm diagnostics are actionable. -3. Initialize the plugin and verify the expected subscribers or middleware run. -4. Force one registration failure and confirm partial setup is rolled back. -5. Call `clear()` to remove active component registrations during teardown. - Call `deregister()` too only when a test or embedded runtime must register - the same custom kind again. - -## Common Issues - -Check these symptoms first when the workflow does not behave as expected. - -- **Middleware names collide**: Use component-local names and let the plugin runtime qualify them. -- **Partial registrations remain after failure**: Register through `PluginContext` so rollback can clean up. -- **Registration does validation work**: Move deterministic checks into the validation hook. -- **Global state leaks across component instances**: Create instance-local state during registration or key shared state by component identity. - -## Next Steps - -Use these links to continue from this workflow into the next related task. - -- Add advanced validation and rollout controls with [Design Plugin Configuration](/build-plugins/language-binding/advanced-configuration). -- Review concrete authoring patterns in [Code Examples](/build-plugins/language-binding/code-examples). +The LLM intercept returns the complete outcome rather than relying on mutation. In +particular, it preserves `annotated`. The Rust request happens to be mutable inside its +owned callback value; Python creates a new typed request, and Node.js creates a new plain +object. Those language differences do not change Relay semantics. + +## Initialize and Inspect + +Use the following procedure to verify successful activation and transactional rollback: + +1. Register the kind, validate the shared component, and stop if the report contains an + error. Duplicate kind registration is itself an error and should fail the test. +2. Initialize the valid document. Rust awaits `initialize_plugins`, Python awaits + `plugin.initialize`, and Node.js awaits `plugin.initialize`. Inspect the returned + report instead of treating a resolved call as the only signal. +3. Call the report accessor. Rust uses `active_plugin_report`, Python uses `report`, and + Node.js uses `report`. It should describe the last successful activation without + rerunning validation. +4. Execute an LLM request and inspect the real callback headers. Then emit an event and + execute the representative tool and stream paths from the checked example. +5. Force a later registration in the same component to fail. Initialization should + reject, the new partial registrations should disappear, and Relay should restore the + previous configuration when it can prove cleanup succeeded. + +Success means configuration controls the installed surfaces, names are component-owned, +the activation report matches the runtime effect, and failed registration leaves no +half-active middleware. diff --git a/docs/build-plugins/language-binding/validate-configuration.mdx b/docs/build-plugins/language-binding/validate-configuration.mdx index 1f82c5e8b..6b5ad85b4 100644 --- a/docs/build-plugins/language-binding/validate-configuration.mdx +++ b/docs/build-plugins/language-binding/validate-configuration.mdx @@ -1,230 +1,334 @@ --- -title: "Validate Plugin Configuration" -description: "Validate NeMo Relay plugin configuration and return stable diagnostics before activation." -position: 3 +title: "Validate Configuration" +description: "Return equivalent, actionable plugin diagnostics in Rust, Python, and Node.js." +position: 6 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} +The example validator treats component configuration as one coherent contract. It +requires strings and booleans where declared, accepts only `observe` or `enforce` for +`requests.mode`, requires arrays of strings for block and redaction keys, verifies +integer priorities, and reports every unknown component-local field under a stable code. -Use this guide when you have a plugin kind and need predictable diagnostics before the plugin installs runtime behavior. +Validation does not open a client, register middleware, emit an event, or alter active +configuration. Relay validates disabled components too, which lets an operator stage a +future component and discover its mistakes before rollout. -## What You Build +## Return the Same Diagnostic Shape -Define a JSON-compatible component configuration, validate required fields and -supported values, return structured diagnostics, and confirm that disabled -components still report configuration problems before rollout. - -## Configuration Shape - -The canonical plugin configuration is a top-level document with `version`, `components`, and `policy`. - -Each component has: - -- `kind`: the plugin kind to activate. -- `enabled`: whether the component should initialize. -- `config`: the component-local JSON object passed to validation and registration. - -Relay validates disabled components. This lets operators detect configuration -problems before enabling a component in a later rollout. - -The top-level `policy` controls validation that Relay handles, including unknown -plugin kinds and unsupported document versions. For built-in components, -non-default `unknown_field` and `unsupported_value` settings also override the -matching component policy during host validation. When a top-level setting uses -its default value, an explicitly configured component policy remains in effect. - -A custom plugin's `validate()` method controls how its own `config` fields -handle unknown fields and unsupported values. This includes in-process Python -and Node.js plugins, native plugins, and gRPC worker plugins; their validation -callbacks receive only component configuration. - -The following examples create the same configuration in each supported binding: +The following implementations return the same diagnostic fields while using each +binding's native configuration types: ```python -from nemo_relay.plugin import ComponentSpec, ConfigPolicy, PluginConfig - -config = PluginConfig( - version=1, - components=[ - ComponentSpec( - kind="header-plugin", - enabled=True, - config={"header_name": "x-tenant", "value": "tenant-a"}, - ) - ], - policy=ConfigPolicy( - unknown_component="warn", - unknown_field="warn", - unsupported_value="error", - ), -) +def validate_documentation_config(config: dict[str, Any]) -> list[dict[str, str]]: + diagnostics: list[dict[str, str]] = [] + allowed_top_level = {"tag", *GROUP_FIELDS} + for key in config.keys() - allowed_top_level: + diagnostics.append(_diagnostic( + "warning", "unknown_field", key, + f"unknown field '{key}' is not supported", + )) + + for group, allowed in GROUP_FIELDS.items(): + value = config.get(group) + if value is not None and not isinstance(value, dict): + diagnostics.append(_diagnostic( + "error", "invalid_config", group, + f"{group} must be an object", + )) + continue + if isinstance(value, dict): + for key in value.keys() - allowed: + field = f"{group}.{key}" + diagnostics.append(_diagnostic( + "warning", "unknown_field", field, + f"unknown field '{field}' is not supported", + )) + + settings = normalized_config(config) + expected_types = { + "tag": str, + "observe.enabled": bool, + "observe.redact_keys": list, + "requests.enabled": bool, + "requests.mode": str, + "requests.blocked_tools": list, + "requests.blocked_models": list, + "requests.header_name": str, + "requests.header_value": str, + "requests.priority": int, + "requests.break_chain": bool, + "execution.enabled": bool, + "execution.priority": int, + "execution.emit_pending_marks": bool, + "runtime.emit_marks": bool, + "runtime.emit_isolated_scope": bool, + } + for field, expected in expected_types.items(): + group, separator, key = field.partition(".") + value = settings[group][key] if separator else settings[group] + if type(value) is not expected: + diagnostics.append(_diagnostic( + "error", "invalid_config", field, + f"{field} must be a {expected.__name__}", + )) + for field in ("observe.redact_keys", "requests.blocked_tools", "requests.blocked_models"): + group, key = field.split(".") + value = settings[group][key] + if isinstance(value, list) and not all(isinstance(item, str) for item in value): + diagnostics.append(_diagnostic( + "error", "invalid_config", field, + f"{field} must contain only strings", + )) + if isinstance(settings["tag"], str) and not settings["tag"]: + diagnostics.append(_diagnostic( + "error", "invalid_tag", "tag", "tag must be a non-empty string", + )) + for field in ("requests.header_name", "requests.header_value"): + group, key = field.split(".") + if isinstance(settings[group][key], str) and not settings[group][key]: + diagnostics.append(_diagnostic( + "error", "invalid_header", field, f"{field} must be a non-empty string", + )) + if settings["requests"]["mode"] not in {"observe", "enforce"}: + diagnostics.append(_diagnostic( + "error", "unsupported_mode", "requests.mode", + "requests.mode must be either observe or enforce", + )) + return diagnostics + +class DocumentationPlugin: + def validate(self, config: dict[str, Any]) -> list[dict[str, str]]: + return validate_documentation_config(config) ``` - ```js +function validateDocumentationConfig(config) { + const diagnostics = []; + const topLevel = new Set(['tag', ...Object.keys(GROUP_FIELDS)]); + for (const key of Object.keys(config)) { + if (!topLevel.has(key)) { + diagnostics.push(diagnostic( + 'warning', 'unknown_field', key, + `unknown field '${key}' is not supported`, + )); + } + } + for (const [group, allowed] of Object.entries(GROUP_FIELDS)) { + const value = config[group]; + if (value !== undefined + && (value === null || typeof value !== 'object' || Array.isArray(value))) { + diagnostics.push(diagnostic( + 'error', 'invalid_config', group, `${group} must be an object`, + )); + continue; + } + for (const key of Object.keys(value ?? {})) { + if (!allowed.has(key)) { + const field = `${group}.${key}`; + diagnostics.push(diagnostic( + 'warning', 'unknown_field', field, + `unknown field '${field}' is not supported`, + )); + } + } + } + const settings = normalizedConfig(config); + const fields = { + tag: settings.tag, + 'observe.enabled': settings.observe.enabled, + 'observe.redact_keys': settings.observe.redact_keys, + 'requests.enabled': settings.requests.enabled, + 'requests.mode': settings.requests.mode, + 'requests.blocked_tools': settings.requests.blocked_tools, + 'requests.blocked_models': settings.requests.blocked_models, + 'requests.header_name': settings.requests.header_name, + 'requests.header_value': settings.requests.header_value, + 'requests.priority': settings.requests.priority, + 'requests.break_chain': settings.requests.break_chain, + 'execution.enabled': settings.execution.enabled, + 'execution.priority': settings.execution.priority, + 'execution.emit_pending_marks': settings.execution.emit_pending_marks, + 'runtime.emit_marks': settings.runtime.emit_marks, + 'runtime.emit_isolated_scope': settings.runtime.emit_isolated_scope, + }; + const stringFields = new Set([ + 'tag', 'requests.mode', 'requests.header_name', 'requests.header_value', + ]); + const arrayFields = new Set([ + 'observe.redact_keys', 'requests.blocked_tools', 'requests.blocked_models', + ]); + const integerFields = new Set(['requests.priority', 'execution.priority']); + for (const [field, value] of Object.entries(fields)) { + const valid = stringFields.has(field) + ? typeof value === 'string' + : arrayFields.has(field) + ? Array.isArray(value) && value.every((item) => typeof item === 'string') + : integerFields.has(field) + ? Number.isInteger(value) + : typeof value === 'boolean'; + if (!valid) { + diagnostics.push(diagnostic( + 'error', 'invalid_config', field, `${field} has the wrong type`, + )); + } + } + if (typeof settings.tag === 'string' && settings.tag.length === 0) { + diagnostics.push(diagnostic( + 'error', 'invalid_tag', 'tag', 'tag must be a non-empty string', + )); + } + for (const [field, value] of [ + ['requests.header_name', settings.requests.header_name], + ['requests.header_value', settings.requests.header_value], + ]) { + if (typeof value === 'string' && value.length === 0) { + diagnostics.push(diagnostic( + 'error', 'invalid_header', field, `${field} must be a non-empty string`, + )); + } + } + if (!new Set(['observe', 'enforce']).has(settings.requests.mode)) { + diagnostics.push(diagnostic( + 'error', 'unsupported_mode', 'requests.mode', + 'requests.mode must be either observe or enforce', + )); + } + return diagnostics; +} -const config = { - version: 1, - components: [ - { - kind: 'header-plugin', - enabled: true, - config: { header_name: 'x-tenant', value: 'tenant-a' }, - }, - ], - policy: { - unknown_component: 'warn', - unknown_field: 'warn', - unsupported_value: 'error', +const documentationPlugin = { + validate: validateDocumentationConfig, + register(config, context) { + const settings = normalizedConfig(config); + if (settings.observe.enabled) { + context.registerSubscriber('events', (event) => console.log(event.name)); + } }, }; ``` - ```rust -use nemo_relay::plugin::{ConfigPolicy, PluginComponentSpec, PluginConfig}; +#[derive(Clone, Deserialize)] +#[serde(default)] +struct Settings { + tag: String, + observe: Observe, + requests: Requests, + execution: Execution, + runtime: Runtime, +} -let mut component = PluginComponentSpec::new("header-plugin"); -component.enabled = true; -component.config.insert("header_name".into(), "x-tenant".into()); -component.config.insert("value".into(), "tenant-a".into()); +fn parse(config: &Map) -> Result { + serde_json::from_value(Json::Object(config.clone())) + .map_err(|error| error.to_string()) +} -let config = PluginConfig { - version: 1, - components: vec![component], - policy: ConfigPolicy::default(), -}; +fn validate_with_policy( + &self, + config: &Map, + policy: &ConfigPolicy, +) -> Vec { + let mut diagnostics = Vec::new(); + report_unknown_fields(config, policy.unknown_field, &mut diagnostics); + let settings = match parse(config) { + Ok(settings) => settings, + Err(error) => { + diagnostics.push(diagnostic( + DiagnosticLevel::Error, + "invalid_config", + None, + error, + )); + return diagnostics; + } + }; + if !matches!(settings.requests.mode.as_str(), "observe" | "enforce") { + diagnostics.push(diagnostic( + DiagnosticLevel::Error, + "unsupported_mode", + Some("requests.mode"), + "requests.mode must be either observe or enforce", + )); + } + if settings.tag.is_empty() { + diagnostics.push(diagnostic( + DiagnosticLevel::Error, + "invalid_tag", + Some("tag"), + "tag must be a non-empty string", + )); + } + for (field, value) in [ + ("requests.header_name", &settings.requests.header_name), + ("requests.header_value", &settings.requests.header_value), + ] { + if value.is_empty() { + diagnostics.push(diagnostic( + DiagnosticLevel::Error, + "invalid_header", + Some(field), + format!("{field} must be a non-empty string"), + )); + } + } + diagnostics +} ``` - -## Validation Rules - -Keep validation deterministic and side-effect free. Inspect configuration and -return diagnostics. Do not register middleware, open network connections, -create clients, or change process state. - -Validate these areas first: +The checked implementations continue beyond these excerpts by checking every boolean, +integer, string, and string-array field. The important ordering is visible here: report +unknown paths, deserialize or merge defaults, then apply semantic rules to the normalized +value. A wrong object shape therefore produces a type diagnostic instead of an exception +that escapes validation. -- Required fields are present. -- Field types match the supported shape. -- Your plugin reports unknown component-local fields with the diagnostic level - its contract defines. -- Your plugin reports unsupported component-local values with the diagnostic - level its contract defines. -- Cross-field combinations make sense. -- Sensitive values are references or secret names, not raw credentials. +Rust receives the host `ConfigPolicy` only through `validate_with_policy`. The default +implementation calls `validate` and preserves existing custom-plugin behavior, so a Rust +plugin that claims policy-controlled unknown-field diagnostics must override the policy +method. Python and Node.js component hooks receive only component-local config; their +example makes its unknown-field policy an explicit part of the implementation contract. -Use warnings when a config can still activate but deserves operator attention. Use errors when initialization should not proceed. - -## Diagnostic Shape - -Diagnostics should be actionable and stable enough for tests or deployment automation: +One unsupported value returns equivalent data in every binding: ```json -[ - { - "level": "error", - "code": "header-plugin.missing_header_name", - "component": "header-plugin", - "field": "header_name", - "message": "config.header_name is required" - } -] -``` - -Prefer stable diagnostic codes over prose-only messages. The message can improve over time; the code should remain testable. - -## Validate Before Initialization - -Use the validation API before initialization and fail deployment if the report contains errors. - - -This error check does not catch an enabled unknown or unregistered component -kind. Under the default `unknown_component="warn"` policy that case is reported -as a warning, not an error, so the check below passes while `initialize()` still -raises for an enabled kind that is not registered. Register every enabled kind -before initialization, or set -`unknown_component="error"` to make validation fail on unknown kinds. - - -`validate()` checks only the configuration you pass to it. `initialize()` also -layers discovered `plugins.toml` configuration. A preflight report can pass -while the effective startup configuration activates or rejects other -components. Refer to [Plugin Configuration -Files](/configure-plugins/plugin-configuration-files) when file discovery -participates in deployment. - -Append the following validation step to the matching configuration example -above. Each example stops deployment when validation reports an error: - - - -```python -import nemo_relay - -report = nemo_relay.plugin.validate(config) -has_errors = any(diagnostic["level"] == "error" for diagnostic in report["diagnostics"]) -if has_errors: - raise RuntimeError(report["diagnostics"]) -``` - - - -```js -const plugin = require('nemo-relay-node/plugin'); - -const report = plugin.validate(config); -const hasErrors = report.diagnostics.some((diagnostic) => diagnostic.level === 'error'); -if (hasErrors) { - throw new Error(JSON.stringify(report.diagnostics)); +{ + "level": "error", + "code": "documentation-plugin.unsupported_mode", + "component": "documentation-plugin", + "field": "requests.mode", + "message": "requests.mode must be either observe or enforce" } ``` - - - -```rust -use nemo_relay::plugin::validate_plugin_config; - -let report = validate_plugin_config(&config); -if report.has_errors() { - panic!("{:?}", report.diagnostics); -} -``` - - - - -## Validation Checklist - -Before sharing a plugin config contract: - -1. Validate the smallest correct config. -2. Validate a config with each required field missing. -3. Validate unsupported enum or mode values. -4. Validate unknown component-local fields and their diagnostic levels. -5. Validate disabled components with invalid config. -6. Confirm diagnostics identify the component and field that needs action. - -## Common Issues - -Check these symptoms first when the workflow does not behave as expected. - -- **Config contains callables or client objects**: Keep config JSON-compatible and instantiate objects inside plugin code. -- **Disabled components skip validation**: Disabled components should still report config problems. -- **Diagnostics are hard to automate**: Add stable codes and field names. -- **Validation opens network connections**: Move runtime setup into plugin registration. - -## Next Steps - -Use these links to continue from this workflow into the next related task. -- Register runtime behavior with [Register Plugin Behavior](/build-plugins/language-binding/register-behavior). -- Add rollout controls with [Design Plugin Configuration](/build-plugins/language-binding/advanced-configuration). -- Review concrete validation patterns in [Code Examples](/build-plugins/language-binding/code-examples). +## Preflight the Effective Document + +Use the following procedure to prove that validation remains separate from activation: + +1. Register `documentation-plugin`, then call the binding's list function. Rust uses + `list_plugin_kinds`, Python uses `list_kinds`, and Node.js uses `listKinds`. The kind + should appear even though no component is active. +2. Construct a component with `enabled = false` and `requests.mode = "invalid"`. Call + `validate_plugin_config`, `plugin.validate`, or `plugin.validate`. Confirm the report + contains `documentation-plugin.unsupported_mode` and that the active report remains + unchanged. +3. Correct the mode, give `requests.priority` a string, and confirm the error identifies + that field. Restore the integer, add an unknown key, and confirm that the Python and + Node.js examples return their documented warning while the Rust example follows the + supplied `ConfigPolicy`. +4. Validate the shared correct configuration. The report should have no error-level + diagnostics. +5. Remember that `initialize` also layers discovered `plugins.toml` configuration. In a + deployment that uses file discovery, validate the effective startup source rather + than assuming an isolated in-memory report describes the final activation. + +Success means every binding catches the same invalid values before registration, a +disabled component remains visible to validation, diagnostics are stable enough for +automation, and the valid configuration proceeds to registration without a second +interpretation of its fields. diff --git a/docs/build-plugins/native/about.mdx b/docs/build-plugins/native/about.mdx new file mode 100644 index 000000000..46956dcdb --- /dev/null +++ b/docs/build-plugins/native/about.mdx @@ -0,0 +1,156 @@ +--- +title: "About Native Dynamic Plugins" +description: "Decide when to run reusable Rust plugin behavior inside the Relay process." +position: 20 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +A native dynamic plugin is a trusted Rust shared library that Relay loads into its own +process. It follows the same validation and `PluginContext` contract as other plugins, +but typed middleware crosses a stable C boundary without a worker process or gRPC JSON +envelope. This model is appropriate when reusable callback behavior is sensitive to +latency or throughput and the deployment can carry a platform-specific binary. + + +Native plugins are not sandboxed. They share the host address space, allocator boundary, +and process fate. Load only reviewed artifacts, keep ABI ownership rules intact, and +assume a native crash can terminate the Relay host. + + +## Version Contracts + +Three version values answer different questions: + +| Contract | Current Value | Meaning | +|---|---|---| +| Package manifest | `manifest_version = 1` | Shape of the authored `relay-plugin.toml` file. | +| Manifest native API | `compat.native_api = "1"` | Native plugin package contract accepted by discovery and trust validation. | +| C host-table ABI | v4 | Function table negotiated by the current `nemo-relay-plugin` SDK. The host also exposes frozen v3 and v2 tables for compatible older binaries. | + +Typed async middleware and SDK executor configuration require the 0.8 contract, so the +checked example declares `compat.relay = ">=0.8.0,<1.0"` and depends on +`nemo-relay-plugin` 0.8.0. A manifest that admits an older host cannot promise those +surfaces. + +Relay 0.8 changes the native API 1 tool-result JSON contract without changing the v4 +host-table layout. A tool callback and `ToolNext` continuation now return +`ToolExecutionResult`, which carries `result` and optional opaque `annotation`; an +execution intercept returns that pair plus pending marks. Rebuild native plugins for +this contract even though the negotiated host-table ABI remains v4. + +## What the SDK Owns + +The Rust SDK exports the stable entry symbol, converts host-owned JSON handles into +typed DTOs, registers all 15 plugin surfaces, and drives async middleware on one +SDK-owned multi-thread Tokio runtime per configured component. A plugin can set a +default executor size and accept a positive `executor.worker_threads` component +override. The default is two workers; change it only after measuring queued async work +and account for the number of native components in the host. + +Subscribers remain synchronous and run on Relay's subscriber dispatcher. Typed +middleware returns futures and runs on the SDK executor. A callback has no stable OS +thread affinity, separate invocations can overlap, and blocking an executor thread can +delay unrelated calls from the same component. + +The checked-in `examples/rust-native-plugin` project is the end-to-end implementation. +Its configuration, observation, request policy, execution wrappers, and runtime helpers +are separated into modules so each following page can explain one responsibility +without presenting a monolithic sample. + +## Implement the Native Plugin Entrypoint + +The root module is intentionally small. `validate` checks both the example settings and +the SDK-owned executor override. `register` parses the same settings once, obtains the +component runtime handle, and delegates each feature group. The export macro produces +the `nemo_relay_register_plugin` symbol named in the manifest. + +```rust +mod config; +mod execution; +mod observe; +mod requests; +mod runtime; + +use nemo_relay_plugin::{ + ConfigDiagnostic, DiagnosticLevel, Json, NativeExecutorConfig, + NativePlugin, PluginContext, +}; +use serde_json::Map; + +struct ExampleNativePlugin; + +impl NativePlugin for ExampleNativePlugin { + fn plugin_kind(&self) -> &str { + "examples.rust_native_policy" + } + + fn executor_config(&self) -> NativeExecutorConfig { + NativeExecutorConfig { worker_threads: 2 } + } + + fn allows_multiple_components(&self) -> bool { + false + } + + fn validate(&self, plugin_config: &Map) -> Vec { + let mut diagnostics = config::validate(plugin_config); + if let Err(message) = self.executor_config_for_component(plugin_config) { + diagnostics.push(ConfigDiagnostic { + level: DiagnosticLevel::Error, + code: "examples.rust_native_policy.invalid_executor".into(), + component: Some("examples.rust_native_policy".into()), + field: Some("executor.worker_threads".into()), + message, + }); + } + diagnostics + } + + fn register( + &mut self, + plugin_config: &Map, + context: &mut PluginContext<'_>, + ) -> nemo_relay_plugin::Result<()> { + let config = config::ExampleConfig::parse(plugin_config)?; + let runtime = context.runtime(); + observe::register(context, &config, &runtime)?; + requests::register(context, &config)?; + execution::register(context, &config, &runtime)?; + Ok(()) + } +} + +nemo_relay_plugin::nemo_relay_plugin!( + nemo_relay_register_plugin, + || ExampleNativePlugin +); +``` + +The kind in `plugin_kind()` must match `[plugin].id` in `relay-plugin.toml`. Returning +`false` from `allows_multiple_components` tells Relay to reject a document that tries to +activate two configurations of this native implementation. The SDK owns registrations +created through `context`; the plugin does not keep raw registry handles or unload them +itself. + +## Complete Path + +Follow these pages in order to build, activate, exercise, and remove the native plugin: + +1. Follow [Build and Package](/build-plugins/native/build-and-package) to build the + `cdylib`, validate its schema and manifest, calculate integrity, and register it. +2. Add observability behavior with [Observe and Sanitize](/build-plugins/native/observe-and-sanitize), + including subscribers and all three event sanitizer surfaces. +3. Add policy and request rewriting with [Control Requests](/build-plugins/native/control-requests), + preserving annotations and making priority and `break_chain` explicit. +4. Add tool, unary model, and lazy stream wrappers with [Wrap Execution](/build-plugins/native/wrap-execution). +5. Verify marks, scopes, isolated stacks, cleanup, and executor control with + [Runtime Events and Scopes](/build-plugins/native/runtime-events-and-scopes). +6. Consult [Native ABI Reference](/build-plugins/native/native-abi-reference) only when + implementing or auditing the raw boundary. + +Success is not merely a library that loads. The atomic lifecycle test builds the `cdylib` +in an isolated target directory, materializes and integrity-checks its manifest, activates +a valid component, executes a managed tool call, observes the runtime mark, and clears +registrations before the library unloads. The focused configuration tests cover rejected +input and schema shape; the scenario pages show the remaining callback contracts. diff --git a/docs/build-plugins/native/build-and-package.mdx b/docs/build-plugins/native/build-and-package.mdx new file mode 100644 index 000000000..d7037ad08 --- /dev/null +++ b/docs/build-plugins/native/build-and-package.mdx @@ -0,0 +1,166 @@ +--- +title: "Build and Package" +description: "Build, validate, register, exercise, and unload the Rust native example." +position: 21 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +The repository example builds a `cdylib` against the current 0.8.0 SDK and packages it +with a strict schema. Run every command in this procedure from +`examples/rust-native-plugin` unless a step says otherwise. + +## Build the Library + +The example crate exposes both `cdylib` and `rlib`. Relay loads the `cdylib`; the `rlib` +lets the example integration tests call validation helpers without loading an unsafe +dynamic boundary. The dependencies use the current 0.8.0 public SDK rather than a Git +revision or an older package line. The `path = "../../crates/plugin"` override exists +only because this checked example builds inside the NeMo Relay repository. A standalone +plugin depends on the published `nemo-relay-plugin = "0.8.0"` package without `path`. + +The checked example uses the following package and dependency configuration: + +```toml +[package] +name = "nemo-relay-rust-native-plugin-example" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +futures = "0.3" +nemo-relay-plugin = { version = "0.8.0", path = "../../crates/plugin" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["io-util", "macros", "time"] } +``` + +Build and materialize the platform-specific library as follows: + +1. Run the example tests and build the debug library. + + ```bash + cargo test + cargo build + ``` + +2. Copy `relay-plugin.toml` to `relay-plugin.local.toml`. Replace + `` in `source.artifact` and `load.library` with the file Cargo + produced: `libnemo_relay_rust_native_plugin_example.dylib` on macOS, + `libnemo_relay_rust_native_plugin_example.so` on Linux, or + `nemo_relay_rust_native_plugin_example.dll` on Windows. + +3. Calculate the artifact SHA-256 from the example directory. Use + `shasum -a 256 target/debug/` on macOS, + `sha256sum target/debug/` on Linux, or + `Get-FileHash -Algorithm SHA256 target/debug/` in PowerShell. + Put the lowercase result after the existing `sha256:` prefix. + +At this point, success means the tests pass, the library exists at both manifest paths, +and the digest describes those exact bytes. + +## Understand the Manifest + +The checked manifest template declares the plugin identity, version contracts, schema, +library path, and exported symbol: + +```toml +manifest_version = 1 + +[plugin] +id = "examples.rust_native_policy" +kind = "rust_dynamic" + +[compat] +relay = ">=0.8.0,<1.0" +native_api = "1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_native", "config_schema"] + +[config_schema] +path = "config.schema.json" + +[source] +artifact = "target/debug/" + +[integrity] +sha256 = "sha256:" + +[load] +library = "target/debug/" +symbol = "nemo_relay_register_plugin" +``` + +The `source.artifact` and `load.library` paths must identify the same library. Replace +`` with the lowercase digest of those bytes. The exported descriptor's +plugin kind must exactly match `plugin.id`. The schema includes +the SDK-owned `executor.worker_threads` property because this example registers typed +async middleware and allows a component override. `additionalProperties: false` prevents +an operator typo from silently becoming inactive configuration. + +The native-only schema fragment below gives the SDK executor override a strict positive +integer. `executor_config_for_component` performs the corresponding runtime validation, +so direct activation and package validation agree. + +```json +{ + "executor": { + "description": "Overrides the SDK-owned Tokio executor for this component.", + "type": "object", + "additionalProperties": false, + "properties": { + "worker_threads": { + "type": "integer", + "minimum": 1, + "default": 2 + } + } + } +} +``` + +## Validate and Activate + +Use the following procedure to validate the package, activate it, and remove it safely: + +1. From the repository root, validate the local manifest before adding it. + + ```bash + nemo-relay plugins validate ./examples/rust-native-plugin/relay-plugin.local.toml + ``` + +2. Register and enable the package in the user configuration. + + ```bash + nemo-relay plugins add --user ./examples/rust-native-plugin/relay-plugin.local.toml + nemo-relay plugins enable examples.rust_native_policy + ``` + +3. Add the shared scenario configuration under the dynamic record, including + `executor.worker_threads = 2`. Start Relay with the package enabled, inspect the + activation report, and execute the documented tool and LLM calls. + +4. Change `requests.mode` to an unsupported value and validate again. Then restore the + valid value, add an unknown property, and verify that static package validation + rejects the schema violation. During component validation, confirm that the plugin's + diagnostic also reports the field rather than ignoring it. + +5. Disable and remove the package after clearing the active component. + + ```bash + nemo-relay plugins disable examples.rust_native_policy + nemo-relay plugins remove examples.rust_native_policy + ``` + +Success means manifest and schema validation fail before loading altered or invalid +artifacts, a valid component produces an active runtime report, representative calls +show the configured behavior, and removal occurs only after the host reports that the +component registrations have been cleared. diff --git a/docs/build-plugins/native/control-requests.mdx b/docs/build-plugins/native/control-requests.mdx new file mode 100644 index 000000000..e14815181 --- /dev/null +++ b/docs/build-plugins/native/control-requests.mdx @@ -0,0 +1,174 @@ +--- +title: "Control Requests" +description: "Block and rewrite tool and LLM requests with explicit native configuration." +position: 23 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +The example's `requests` group separates blocking policy from request rewriting. Its +conditional guardrails read `blocked_tools`, `blocked_models`, and `mode`; its request +intercepts read the configured header, priority, and `break_chain` values. Disabling the +group installs none of those registrations. + +## Register Policy Only When Configured + +The tool guardrail receives the managed tool name and real JSON request. Returning +`Some(reason)` blocks execution; returning `None` continues the pipeline. `observe` mode +uses the same configuration without blocking, which lets an operator stage a policy. + +```rust +if config.requests.enabled { + context.register_tool_conditional_execution_guardrail( + "documentation_tool_policy", + 10, + { + let mode = config.requests.mode.clone(); + let blocked = config.requests.blocked_tools.clone(); + move |name, _args| { + let mode = mode.clone(); + let blocked = blocked.clone(); + async move { + Ok((mode == "enforce" && blocked.contains(&name)) + .then(|| format!( + "tool '{name}' is blocked by documentation policy" + ))) + } + } + }, + )?; + + context.register_tool_request_intercept( + "documentation_tool_request", + config.requests.priority, + config.requests.break_chain, + { + let tag = config.tag.clone(); + move |name, request| { + let tag = tag.clone(); + async move { Ok(tag_tool_request(request, &name, &tag)) } + } + }, + )?; +} +``` + +`tag_tool_request` returns a new object containing `plugin_tag` and `plugin_tool`; it +does not mutate host-owned memory. A non-object request is returned unchanged. The +priority and `break_chain` arguments come directly from the component configuration. + +## Conditional Guardrails Block Real Execution + +In `observe` mode, the example allows configured names to execute without blocking. In +`enforce` mode, its tool conditional guardrail rejects a matching +tool and its LLM conditional guardrail rejects a matching model before the real callback +runs. Validation rejects any other mode and requires string arrays for both block lists. + +This is execution policy, unlike redaction. A rejected call produces the managed error +path and never invokes the application callback. Tests therefore count callback +invocations as well as checking the returned error. + +## Request Intercepts Rewrite the Real Request + +The tool request intercept adds configured policy metadata to the JSON request and +returns it. The LLM request intercept adds the configured header and returns the full +request-intercept outcome, including the original annotated request. Preserving the +annotation matters because a codec can already have normalized the request for later +middleware. + +```rust +context.register_llm_conditional_execution_guardrail( + "documentation_llm_policy", + 10, + { + let mode = config.requests.mode.clone(); + let blocked = config.requests.blocked_models.clone(); + move |request| { + let mode = mode.clone(); + let blocked = blocked.clone(); + async move { + let model = request.content + .get("model") + .and_then(Json::as_str) + .unwrap_or_default(); + Ok((mode == "enforce" + && blocked.iter().any(|candidate| candidate == model)) + .then(|| format!( + "model '{model}' is blocked by documentation policy" + ))) + } + } + }, +)?; + +context.register_llm_request_intercept( + "documentation_llm_request", + config.requests.priority, + config.requests.break_chain, + { + let header_name = config.requests.header_name.clone(); + let header_value = config.requests.header_value.clone(); + let emit_pending_marks = config.execution.emit_pending_marks; + move |_name, mut request, annotated| { + let header_name = header_name.clone(); + let header_value = header_value.clone(); + async move { + request.headers.insert( + header_name.clone(), + Json::String(header_value), + ); + let mut outcome = LlmRequestInterceptOutcome::new(request, annotated) + .with_optimization_contribution( + LlmOptimizationContribution::new( + "examples.rust_native_policy", + "request_rewrite", + ), + ); + if emit_pending_marks { + outcome = outcome.with_pending_mark( + PendingMarkSpec::builder() + .name("example.native.llm_request") + .category(EventCategory::custom()) + .data(json!({ "header": header_name })) + .build(), + ); + } + Ok(outcome) + } + } + }, +)?; +``` + +The returned outcome keeps `annotated` alongside the rewritten provider request. Relay +emits the optional mark and records the optimization contribution; neither appears in +the provider request or provider response seen by the application. + +Both request-intercept registrations take `requests.priority` and +`requests.break_chain`. Priority decides +where each intercept appears after global and visible scope-local entries are merged. If +`break_chain` is true, later request intercepts do not run after this one. The example +does not hide these choices in code constants: they are schema-checked configuration and +appear in the validation messages. + +## Verify Request Policy + +Use the following procedure to verify policy, rewriting, ordering, and annotation +preservation independently: + +1. Activate `requests.mode = "enforce"` with one blocked tool, one blocked model, + priority 20, and `break_chain = false`. +2. Call each blocked name and confirm that its real callback count remains zero. Call an + allowed name and confirm normal execution. +3. Inspect the allowed tool request and LLM headers in the real callbacks. They must + contain the configured values, proving that request intercepts affect execution. +4. Register a later test intercept, set `break_chain = true`, and repeat the call. Confirm + that the example rewrite runs and the later intercept does not. +5. Repeat an LLM call with an annotated request and confirm that later request intercepts + and the managed start-event path receive the annotation unchanged. +6. Set `requests.enabled = false`, reactivate, and confirm that blocked names execute and + no request is rewritten. + +Success means configuration independently controls policy and rewriting, guardrails +block before side effects, intercept ordering is reproducible, and annotations survive +the complete request path. diff --git a/docs/build-plugins/native/native-abi-reference.mdx b/docs/build-plugins/native/native-abi-reference.mdx new file mode 100644 index 000000000..aad1ffd11 --- /dev/null +++ b/docs/build-plugins/native/native-abi-reference.mdx @@ -0,0 +1,192 @@ +--- +title: "Native ABI Reference" +description: "Reference native host-table versions, ownership, callbacks, codecs, streams, and cancellation." +position: 26 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Use the typed `nemo-relay-plugin` SDK for normal authoring. This page records the raw +contract needed to audit compatibility or implement an escape hatch; unsafe `*_raw` +functions are intentionally not tutorial examples. + +## Entry and Version Negotiation + +The exported symbol receives a pointer to the v1 prefix of the host table and fills a +v1 plugin descriptor. The implementation must inspect `abi_version` and `struct_size` +before casting a longer table. + +```rust +extern "C" fn nemo_relay_register_plugin( + host: *const NemoRelayNativeHostApiV1, + out: *mut NemoRelayNativePluginV1, +) -> NemoRelayStatus +``` + +The current host negotiates ABI v4, then a separately frozen v3 table, then the frozen +legacy v2 table. ABI v4 extends the complete v3 prefix with completion-scoped codecs and +pull-based downstream LLM streams. This table version is independent of authored +`compat.native_api = "1"`. + +The following table enumerates the operations introduced at each table level. The exact +function signatures and field order are defined by the public +[`nemo-relay-plugin` declarations](https://github.com/NVIDIA/NeMo-Relay/blob/main/crates/plugin/src/lib.rs). + +| Table Level | Operations | +|---|---| +| Frozen v1/v2 prefix | Version and struct-size negotiation; host version; string allocation, access, and release; thread-local error reporting; callback-scoped LLM request decode and encode plus response decode; subscriber, five tool, six LLM, and three event-sanitizer registrations; current scope, scope push and pop, mark emission, isolated stack creation and release, thread-stack set, capture, and restore, captured-binding release, active-stack inspection, and scoped binding. | +| Frozen v3 extension | Completion resolve, reject, cancellation inspection, and release; one-shot completion-coupled continuation invocation; continuation release; generic async middleware registration; bounded output-stream push, finish, reject, cancellation inspection, and release; downstream stream invocation; stream-middleware registration; and repeated or concurrent unary continuation invocation with independent result callbacks. | +| Current v4 extension | Completion-scoped LLM request decode and encode plus response decode; pull-based downstream LLM stream open, pull, cancel, and release; completion retain for typed codec facades; and output-stream backpressure inspection. | + +The prefix and descriptor layout are explicit. A plugin fills the descriptor with its +stable kind, component multiplicity, opaque state, callbacks, and destructor. The host +table is immutable after negotiation. + +```rust +#[repr(C)] +pub struct NemoRelayNativeHostApiV1 { + pub abi_version: u32, + pub struct_size: usize, + pub relay_version: *const c_char, + pub string_new: unsafe extern "C" fn( + data: *const u8, + len: usize, + out: *mut *mut NemoRelayNativeString, + ) -> NemoRelayStatus, + pub string_data: unsafe extern "C" fn( + value: *const NemoRelayNativeString, + ) -> *const u8, + pub string_len: unsafe extern "C" fn( + value: *const NemoRelayNativeString, + ) -> usize, + pub string_free: unsafe extern "C" fn( + value: *mut NemoRelayNativeString, + ), + // Registration, runtime, codec, and error operations follow in this table. +} + +#[repr(C)] +pub struct NemoRelayNativePluginV1 { + pub struct_size: usize, + pub plugin_kind: *mut NemoRelayNativeString, + pub allows_multiple_components: bool, + pub user_data: *mut c_void, + pub validate: Option, + pub register: Option, + pub drop: NemoRelayNativePluginDropFn, +} +``` + +The shortened host-table declaration shows the mandatory prefix, not a replacement +definition that a raw plugin can copy. A raw implementation must compile against the +complete public declarations in `nemo-relay-plugin` so field offsets match exactly. + +## Values and Ownership + +Text and JSON cross the boundary as host-owned `NemoRelayNativeString` handles. ABI +structs otherwise contain scalars, opaque handles, callback pointers, and plugin-owned +`user_data`. Rust trait objects, futures, `serde_json::Value`, allocator-owned strings, +and unwinding must never cross the boundary. Release host strings with the matching host +operation and retain plugin state until the host releases the registration and every +callback-owned reference derived from it. + +```rust +#[repr(i32)] +pub enum NemoRelayStatus { + Ok = 0, + AlreadyExists = 1, + NotFound = 2, + ScopeStackEmpty = 3, + GuardrailRejected = 4, + Internal = 5, + NullPointer = 6, + InvalidJson = 7, + InvalidUtf8 = 8, + InvalidArg = 9, + StreamEnd = 10, + Backpressured = 11, +} + +#[repr(C)] +pub struct NemoRelayNativeString { + _private: [u8; 0], + _marker: PhantomData<(*mut u8, PhantomPinned)>, +} +``` + +`Ok` means the output pointers required by that operation were populated according to +its contract. `Backpressured` is retryable only for the bounded stream operation that +returned it. Other errors should be propagated with the host's thread-local error +message set when additional context is available. + +The table exposes registrations for subscriber; mark, scope-start, and scope-end +sanitizers; five tool surfaces; and six LLM surfaces. Runtime operations cover current +scope, mark emission, scope push and pop, isolated stack creation and drop, stack capture +or binding, and restoration. + +## Async Completions and Continuations + +`PluginContext::register_async_middleware_raw` registers non-stream middleware that can +settle later. Return `Complete` only after resolving or rejecting the completion inside +the callback. Return `Pending` only after retaining it. A retained completion must settle +exactly once and then be released. Release every async `next` reference after its last +use. + +`async_next_invoke_result` supports repeated or concurrent unary continuation calls with +independent result callbacks. The older completion-coupled `async_next_invoke` is +one-shot because the continuation result settles the middleware completion. Settle the +owner only after every started continuation call has finished. When the owner settles or +is cancelled, the host rejects new continuation calls and cancels unfinished ones. + +For a tool continuation, the result callback receives canonical +`nemo.relay.ToolExecutionResult@1` JSON with required `result` and optional opaque +`annotation`. A raw tool-execution middleware completion returns +`nemo.relay.ToolExecutionInterceptOutcome@2`: the same result and annotation fields plus +optional `pending_marks`. Pending marks belong to the intercept outcome only. Release +each host-owned result string with the matching host-table operation after decoding it. + +The host can poll a callback on different Tokio workers, and separate invocations can +run concurrently. Plugin code must synchronize `user_data` and opaque handles. Do not +race final release with settlement, cancellation inspection, stream operations, codec +operations, or `next` invocation. + +## Streaming + +The generic v3 completion API rejects the LLM stream intercept kind. Register it through +`plugin_context_register_async_stream_middleware` and invoke downstream streams through +`async_next_invoke_stream`. Each repeated or concurrent invocation needs independent +callback state. + +The output queue is bounded. `async_stream_push_json` and `async_stream_reject` never +block a callback thread. `Backpressured` means the same logical chunk or rejection must +be retried after the consumer advances. `InvalidArg` means the stream is closed or +cancelled and the operation must not be retried. `async_stream_is_backpressured` is only +a point-in-time observation; it does not replace checking the result of a push. + +A downstream terminal callback reports failure or consumer cancellation with a non-null +error and reports clean completion with `done = true`. Reclaim its `user_data` in that +terminal callback. If a chunk callback returns false, reclaim state before returning +because the host does not call it again. + +## Codec Handles + +LLM sanitizer contexts report codec kind `None`, `BuiltIn`, `Runtime`, or `Opaque`, an +optional codec ID, and a borrowed callback-lifetime handle. Built-in IDs are +`openai_chat`, `openai_responses`, `anthropic_messages`, `oci_genai`, and +`gemini_generate_content`. Runtime and opaque codecs can still have a usable handle. + +Request handles support decode to an annotated request and encode of normalized changes +onto the original envelope. Response handles support decode to an annotated response. +A successful null sanitizer result omits the observability payload and annotation; an +error also omits them and records the callback failure. Neither outcome changes the real +request or application response. Never retain a raw handle or resolved typed facade +after the sanitizer callback ends. + +## Unload Ordering + +Relay keeps the library loaded while any owned registration or callback can reference +plugin code. Shutdown stops new invocations, cooperatively cancels unfinished async work, +waits for completion and stream references to be released, deregisters component-owned +surfaces, runs component cleanup, and only then unloads the shared library. A plugin that +retains a completion, continuation, stream, codec, or runtime handle indefinitely can +therefore delay safe unload. diff --git a/docs/build-plugins/native/observe-and-sanitize.mdx b/docs/build-plugins/native/observe-and-sanitize.mdx new file mode 100644 index 000000000..edfb66fef --- /dev/null +++ b/docs/build-plugins/native/observe-and-sanitize.mdx @@ -0,0 +1,230 @@ +--- +title: "Observe and Sanitize" +description: "Register native subscribers and observability-only sanitizers." +position: 22 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +The example's `observe` feature group installs a subscriber, mark sanitizer, +scope-start sanitizer, scope-end sanitizer, tool request and response sanitizers, and LLM +request and response sanitizers. The group is enabled independently and receives the +configured `tag` and `redact_keys`, so operators can see exactly which behavior one +setting controls. + +## Register the Observation Surfaces + +The function returns immediately when observation is disabled, so a report for that +configuration cannot claim registrations that do not exist. One helper implements the +shared event-field transformation, but each event surface is registered separately. + +```rust +pub(crate) fn register( + context: &mut PluginContext<'_>, + config: &ExampleConfig, + runtime: &PluginRuntime, +) -> nemo_relay_plugin::Result<()> { + if !config.observe.enabled { + return Ok(()); + } + + context.register_subscriber("documentation_subscriber", { + let runtime = runtime.clone(); + let tag = config.tag.clone(); + move |event| subscriber_mark(&runtime, &tag, event) + })?; + + let sanitize = + |fields: EventSanitizeFields, tag: String, keys: Vec| async move { + sanitize_event_fields(fields, &tag, &keys) + }; + context.register_mark_sanitize_guardrail("documentation_mark_sanitizer", 10, { + let tag = config.tag.clone(); + let keys = config.observe.redact_keys.clone(); + move |_event, fields| sanitize(fields, tag.clone(), keys.clone()) + })?; + context.register_scope_sanitize_start_guardrail( + "documentation_scope_start_sanitizer", + 10, + { + let tag = config.tag.clone(); + let keys = config.observe.redact_keys.clone(); + move |_event, fields| sanitize(fields, tag.clone(), keys.clone()) + }, + )?; + context.register_scope_sanitize_end_guardrail( + "documentation_scope_end_sanitizer", + 10, + { + let tag = config.tag.clone(); + let keys = config.observe.redact_keys.clone(); + move |_event, fields| sanitize(fields, tag.clone(), keys.clone()) + }, + )?; + context.register_tool_sanitize_request_guardrail( + "documentation_tool_request_sanitizer", + 10, + { + let keys = config.observe.redact_keys.clone(); + move |_name, value| { + let keys = keys.clone(); + async move { Ok(redact_json(value, &keys)) } + } + }, + )?; + context.register_tool_sanitize_response_guardrail( + "documentation_tool_response_sanitizer", + 10, + { + let keys = config.observe.redact_keys.clone(); + move |_name, value| { + let keys = keys.clone(); + async move { Ok(redact_json(value, &keys)) } + } + }, + )?; + // The LLM sanitizer registrations are shown under "Use codecs for model payloads." + Ok(()) +} +``` + +The local name is unique only within this component. The closure clones owned strings +and arrays into each async callback; it never borrows the component configuration after +`register` returns. + +## Keep Real Values Separate from Event Values + +A sanitizer returns the fields Relay should publish. It never changes the request that +the real callback receives or the response that the application receives. To prove that +distinction, place a configured `secret` field in a managed tool request, observe the +redacted start event, and confirm that the tool callback still receives the original +value. + +Event sanitizers receive an immutable event together with mutable copies of `data`, +`category_profile`, and `metadata`. The example walks those JSON values recursively, +replaces keys listed by `observe.redact_keys`, and adds the documentation tag to the +returned metadata. It registers all three event-specific callbacks because mark, +scope-start, and scope-end are separate public surfaces. + +```rust +fn sanitize_event_fields( + mut fields: EventSanitizeFields, + tag: &str, + redact_keys: &[String], +) -> nemo_relay_plugin::Result { + fields.data = fields.data.map(|value| redact_json(value, redact_keys)); + fields.metadata = Some(tagged_metadata(fields.metadata, tag, redact_keys)); + if let Some(profile) = fields.category_profile.take() { + let value = serde_json::to_value(profile) + .map_err(|error| error.to_string())?; + fields.category_profile = Some( + serde_json::from_value(redact_json(value, redact_keys)) + .map_err(|error| error.to_string())?, + ); + } + Ok(fields) +} + +fn redact_json(value: Json, redact_keys: &[String]) -> Json { + match value { + Json::Object(mut object) => { + for (key, value) in &mut object { + if redact_keys.iter().any(|candidate| candidate == key) { + *value = Json::String("[REDACTED]".into()); + } else { + *value = redact_json(value.take(), redact_keys); + } + } + Json::Object(object) + } + Json::Array(values) => Json::Array( + values.into_iter() + .map(|value| redact_json(value, redact_keys)) + .collect(), + ), + other => other, + } +} +``` + +The subscriber observes the sanitized event stream and emits no recursive event for an +event already created by the example. Because native subscribers are synchronous, it +does only bounded local work. Network export or other asynchronous I/O belongs in typed +middleware or a purpose-built exporter with its own queue and shutdown behavior. + +## Use Codecs for Model Payloads + +LLM sanitizers receive a request or response plus a structured context. For requests, +the example resolves the directional codec when one is available, redacts the normalized +annotation, and encodes it back onto the original envelope before applying its raw JSON +fallback. Response codecs expose decode but not a symmetric response encoder, so the +response sanitizer resolves and decodes the active codec for normalized inspection, +then returns a redacted provider envelope. When the codec is absent or opaque, both +callbacks redact the original JSON safely. Returning no payload omits that request or +response from observability; it does not block the model call. + +The codec facade is valid only for the callback lifetime. It can remain in the typed +future across an `await`, because the SDK owns that lifetime, but it must not be cached in +plugin-global state or used by later invocations. + +```rust +context.register_llm_sanitize_request_guardrail( + "documentation_llm_request_sanitizer", + 10, + { + let redact_keys = config.observe.redact_keys.clone(); + move |mut request, codec_context| { + let redact_keys = redact_keys.clone(); + async move { + if let Some(codec) = codec_context.resolve_codec() { + let annotated = codec.decode(&request)?; + let annotated = serde_json::to_value(annotated) + .map(|value| redact_json(value, &redact_keys)) + .and_then(serde_json::from_value) + .map_err(|error| error.to_string())?; + request = codec.encode(&annotated, &request)?; + } + request.content = redact_json(request.content, &redact_keys); + Ok(Some(request)) + } + } + }, +)?; + +context.register_llm_sanitize_response_guardrail( + "documentation_llm_response_sanitizer", + 10, + { + let redact_keys = config.observe.redact_keys.clone(); + move |response, codec_context| { + let redact_keys = redact_keys.clone(); + async move { + if let Some(codec) = codec_context.resolve_codec() { + let _annotated = codec.decode(&response)?; + } + Ok(Some(redact_json(response, &redact_keys))) + } + } + }, +)?; +``` + +## Verify Observation Behavior + +Use the following procedure to verify all eight observation registrations without +confusing observability changes with execution changes: + +1. Activate the example with `observe.enabled = true`, `redact_keys = ["secret"]`, and + the remaining feature groups disabled. +2. Emit a mark and open and close a scope whose data, category profile, and metadata each + contain a `secret` key. Capture the subscriber output. +3. Execute a tool request and an LLM request and response containing the same key. Use a + built-in codec for the model call and repeat once without a codec. +4. Assert that every emitted observability field is redacted and tagged while the tool + callback, model callback, and application result still contain their original real + values. +5. Clear the component and emit another mark. Confirm that neither sanitization nor the + example subscriber runs. + +Success means all eight observation registrations produce observable evidence, codec +and fallback paths both redact safely, and no sanitizer accidentally changes execution. diff --git a/docs/build-plugins/native/runtime-events-and-scopes.mdx b/docs/build-plugins/native/runtime-events-and-scopes.mdx new file mode 100644 index 000000000..e5c82ec4c --- /dev/null +++ b/docs/build-plugins/native/runtime-events-and-scopes.mdx @@ -0,0 +1,140 @@ +--- +title: "Runtime Events and Scopes" +description: "Emit native marks and manage scope stacks with reliable cleanup." +position: 25 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +The native runtime handle lets plugin middleware participate in Relay's event hierarchy +without linking the host runtime crate. The example's `runtime` group emits a configured +mark, opens and closes a child scope, and optionally creates an isolated scope stack. + +## Preserve Stack Ownership + +A scope stack determines visible scope-local middleware, event parentage, and cleanup. +Typed async middleware captures the callback's stack and restores it around every future +and stream poll. The example inspects the current scope, then uses the isolated stack's +`with_current` helper to bind it for a synchronous closure and restore the previous +thread binding afterward. Child tasks created with `tokio::spawn` do not automatically +inherit Relay scope context, so a plugin that deliberately moves work into a new task or +thread must use the explicit capture and binding helpers. + +An isolated stack begins with its own root and is appropriate only when the emitted work +should not be a child of the managed application call. The example creates the stack, +binds it, pushes and pops its child scope, restores the previous binding, and drops the +isolated stack through the typed guards. Those guards perform the same cleanup if the +closure returns an error. + +```rust +pub(crate) fn emit_configured_runtime_events( + runtime: &PluginRuntime, + tag: &str, + config: &RuntimeConfig, +) -> nemo_relay_plugin::Result<()> { + let _current_scope = runtime.current_scope()?; + if config.emit_marks { + runtime.emit_mark( + "example.native.request.seen", + Some(&json!({ "tag": tag })), + None, + )?; + } + + let mut scope = runtime.scope( + "example.native.request", + ScopeType::Custom, + Some(&json!({ "tag": tag })), + None, + None, + )?; + scope.close(Some(&json!({ "done": true })), None)?; + + if config.emit_isolated_scope { + let isolated = runtime.create_scope_stack()?; + isolated.with_current(|| { + if config.emit_marks { + runtime.emit_mark( + "example.native.isolated.mark", + Some(&json!({ "tag": tag })), + None, + )?; + } + let mut child = runtime.scope( + "example.native.isolated.scope", + ScopeType::Custom, + None, + Some(&json!({ "visibility": "isolated" })), + None, + )?; + child.close(Some(&json!({ "done": true })), None) + })?; + } + Ok(()) +} +``` + +`runtime.scope` returns an owning guard. Calling `close` supplies the end-event output; +dropping an unclosed guard still follows the SDK cleanup path. `with_current` restores +the prior stack after either `Ok` or `Err`, and dropping `isolated` releases the stack. + +## Configure the SDK Executor + +`NativePlugin::executor_config` supplies the plugin-wide default. The SDK default and the +example default are two worker threads. `executor_config_for_component` can derive a +component override; the standard implementation recognizes a positive +`executor.worker_threads` integer. Because the example schema rejects unknown values, it +declares the executor object even though the object is owned by the SDK contract. + +More workers help only when measured async I/O leaves callbacks queued. They do not make +CPU-bound or blocking callbacks safe. A host with many active native components has one +executor per component, so an unnecessarily large value multiplies thread use. + +```rust +impl NativePlugin for ExampleNativePlugin { + fn executor_config(&self) -> NativeExecutorConfig { + NativeExecutorConfig { worker_threads: 2 } + } + + fn validate(&self, config: &Map) -> Vec { + let mut diagnostics = config::validate(config); + if let Err(message) = self.executor_config_for_component(config) { + diagnostics.push(ConfigDiagnostic { + level: DiagnosticLevel::Error, + code: "examples.rust_native_policy.invalid_executor".into(), + component: Some("examples.rust_native_policy".into()), + field: Some("executor.worker_threads".into()), + message, + }); + } + diagnostics + } +} +``` + +With no component override, this implementation creates two executor workers. With +`executor.worker_threads = 4`, the standard component resolver creates four. Zero, +negative, fractional, and nonnumeric values become validation errors before the SDK +creates an executor. + +## Verify Runtime Cleanup + +Use the following procedure to verify parentage, restoration, executor settings, and +failure cleanup: + +1. Activate `runtime.emit_marks = true` and `emit_isolated_scope = true` with two executor + workers. +2. Run a managed call inside a named scope. Confirm the plugin mark and ordinary child + scope use that call as their parent. +3. Confirm the isolated scope uses its new root and has no accidental parent from the + caller. +4. In a host integration test, make one runtime operation fail after a push. Confirm the + scope guard closes the scope, `with_current` restores the former stack, and dropping + the isolated stack releases its host handle. +5. Run unrelated work on the same runtime thread and confirm its current stack is + unchanged. Then clear the plugin and wait for active callbacks to finish before + unloading the library. + +Success means event parentage is intentional, no scope or isolated stack leaks on +failure, later work regains its original context, and unload begins only after callback +ownership has ended. diff --git a/docs/build-plugins/native/wrap-execution.mdx b/docs/build-plugins/native/wrap-execution.mdx new file mode 100644 index 000000000..e7801dd42 --- /dev/null +++ b/docs/build-plugins/native/wrap-execution.mdx @@ -0,0 +1,154 @@ +--- +title: "Wrap Execution" +description: "Use native tool, unary LLM, and streaming continuations correctly." +position: 24 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Execution intercepts receive the real request and a continuation representing the rest +of the call path. The example's `execution` group registers a tool wrapper, a unary LLM +wrapper, and an LLM stream wrapper at the configured priority. + +## Tool and Unary Results + +The tool wrapper calls `next` once, awaits the downstream `ToolExecutionResult`, and +converts it into a `ToolExecutionInterceptOutcome` with the example's pending mark. The +conversion preserves both the application `result` and any opaque `annotation`. Relay +owns the pending mark: it is emitted in the managed lifecycle and does not appear in the +application-visible result. +The unary LLM wrapper has a deliberately smaller contract and returns provider-response +JSON. LLM pending marks, annotations, and optimization contributions belong to the +request-intercept outcome shown in [Control Requests](/build-plugins/native/control-requests), +not the execution result. + +```rust +context.register_tool_execution_intercept( + "documentation_tool_execution", + config.execution.priority, + { + let emit_pending_marks = config.execution.emit_pending_marks; + move |_name, request, next| async move { + let result = next.call(request).await?; + let mut outcome = ToolExecutionInterceptOutcome::from(result); + if emit_pending_marks { + outcome = outcome.with_pending_mark( + PendingMarkSpec::builder() + .name("example.native.tool_execution") + .category(EventCategory::custom()) + .data(json!({ "source": "documentation" })) + .build(), + ); + } + Ok(outcome) + } + }, +)?; +``` + +If downstream returns `ToolExecutionResult::annotated(json!({"answer": 42}), +json!({"source": "provider"}))`, the application receives that same result object and +reads `tool_result.result["answer"]`. Relay separately emits +`example.native.tool_execution` under the managed tool call. That separation is why the +callback returns an outcome instead of returning a plain JSON value. + +The continuation is reusable. A deliberate configuration or request flag in the example +can invoke unary `next` twice concurrently, await both responses, and select one. This +demonstrates the API while preserving the cost of repetition. A repeated tool can perform +its side effect twice, and a repeated model call can incur provider cost twice. Production +plugins need an idempotency +or charging policy before they use this pattern. + +```rust +context.register_llm_execution_intercept( + "documentation_llm_execution", + config.execution.priority, + move |_name, request, next| async move { + let repeat = request.content + .get("repeat_downstream") + .and_then(Json::as_bool) + .unwrap_or(false); + if repeat { + let repeated = next.clone(); + let (first, second) = tokio::join!( + repeated.call(request.clone()), + next.call(request), + ); + let response = first?; + second?; + Ok(response) + } else { + next.call(request).await + } + }, +)?; +``` + +Cloning `next` creates another handle to the same downstream continuation. `tokio::join!` +polls both calls concurrently; it does not make either provider operation free or +idempotent. The wrapper propagates a failure from either call and returns the first +response only after both calls succeed. The request flag makes repeated execution +observable and opt-in for this example. + +Calling `next` zero times is also valid and replaces downstream execution. Conditional +guardrails are clearer for ordinary allow-or-block policy; zero-call execution wrappers +are useful when the plugin intentionally synthesizes a complete result. + +## Transform Streams Lazily + +The stream intercept asks the continuation for a downstream stream and maps chunks as +the consumer polls them. It does not collect the stream into an array. Each transformed +chunk preserves the downstream JSON fields, and terminal error or cancellation ends the +output promptly. + +```rust +context.register_llm_stream_execution_intercept( + "documentation_llm_stream_execution", + config.execution.priority, + move |_name, request, next| async move { + let stream = next.call(request).await?; + let mapped: LlmJsonAsyncStream = Box::pin(stream.map(|chunk| { + chunk.map(|chunk| match chunk { + Json::Object(mut object) => { + object.insert("plugin_stream".into(), Json::Bool(true)); + Json::Object(object) + } + other => other, + }) + })); + Ok(mapped) + }, +)?; +``` + +The outer `await` obtains the downstream stream. The `map` closure runs later, once per +polled chunk, and preserves downstream errors through `chunk.map`. There is no collection +step and therefore no requirement to hold the entire response in memory. + +The native raw queue is bounded, but typed SDK users see an asynchronous stream facade. +The SDK handles the host push protocol; the plugin still must avoid producing unbounded +work ahead of demand and must release per-invocation state after clean completion, error, +or cancellation. + +## Verify Execution Behavior + +Use the following procedure to verify unary, repeated, and streaming continuation +behavior: + +1. Activate the example with `execution.enabled = true`, priority 30, and + `emit_pending_marks = true`. +2. Execute a tool and a unary model call. Confirm each downstream callback runs once, + the application receives only its expected result, and an additional pending mark is + emitted under the managed call scope. +3. Enable the example's repeated-continuation input and confirm two downstream unary + invocations can overlap. Verify the selected result and accounting explicitly. +4. Consume a three-chunk LLM stream one item at a time. Confirm the first transformed + chunk arrives before the downstream stream completes. +5. Drop a second stream after its first chunk. Confirm that downstream production and + plugin work stop cooperatively. +6. Clear the component and repeat the calls to prove that no wrapper or pending mark + remains registered. + +Success means unary and stream continuations preserve scope, errors, and cancellation, +while Relay-owned tool marks and LLM request accounting remain separate from +application results. diff --git a/docs/build-plugins/package-discoverable-plugins.mdx b/docs/build-plugins/package-discoverable-plugins.mdx new file mode 100644 index 000000000..88163a7b3 --- /dev/null +++ b/docs/build-plugins/package-discoverable-plugins.mdx @@ -0,0 +1,191 @@ +--- +title: "Package Discoverable Plugins" +description: "Package a native library or grpc-v1 worker for validated Relay discovery." +position: 10 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +A discoverable plugin is a package containing one `relay-plugin.toml` manifest, its +native library or worker entrypoint, and an optional component JSON Schema. The manifest +lets Relay validate compatibility and integrity before code is loaded or a process is +started. Runtime activation still comes from a component in `plugins.toml` or an +equivalent binding configuration. + +## Manifest Responsibilities + +| Block | What It Controls | +|---|---| +| `[plugin]` | Stable package identity and plugin kind. | +| `[compat]` | Supported Relay range plus the native manifest API or worker protocol contract. A typed native plugin that uses the 0.8 SDK should declare a Relay range beginning at 0.8.0. | +| `[defaults]` and `[capabilities]` | Initial enabled state and the features the package declares, such as `plugin_native`, `plugin_worker`, and `config_schema`. | +| `[config_schema]` | An optional JSON Schema path resolved relative to the manifest. Packages that use it also declare the `config_schema` capability. | +| `[source]` | The artifact covered by integrity verification and, for managed Python workers, the package root used to create the environment. | +| `[integrity]` | The SHA-256 digest of `source.artifact`, plus optional signature evidence. | +| `[load]` | The native library and symbol, Python module entrypoint, or Rust or custom-command executable entrypoint that Relay starts. | + +For native packages, `compat.native_api = "1"` is the authored manifest contract. It is +not the C host-table ABI number. The current SDK requests ABI v4 and the host retains +frozen v3 and v2 compatibility for older compiled plugins. For workers, declare +`compat.worker_protocol = "grpc-v1"`; the handshake still negotiates the exact protocol, +surfaces, authentication token, and lifecycle at startup. + +## Start from a Complete Manifest + +These are the complete manifest shapes used by the checked examples. The path in +`source.artifact` is the file whose bytes the digest covers. The native loader opens that +same library; a worker loader either starts the compiled program or imports the Python +entrypoint from the managed environment. + + + +```toml +manifest_version = 1 + +[plugin] +id = "examples.python_grpc_worker" +kind = "worker" + +[compat] +relay = ">=0.8.0,<1.0" +worker_protocol = "grpc-v1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_worker", "config_schema"] + +[config_schema] +path = "config.schema.json" + +[source] +manifest_root = "." +artifact = "nemo_relay_python_grpc_worker_example/worker.py" + +[integrity] +sha256 = "sha256:" + +[load] +runtime = "python" +entrypoint = "nemo_relay_python_grpc_worker_example.worker:main" +``` + + +```toml +manifest_version = 1 + +[plugin] +id = "examples.rust_native_policy" +kind = "rust_dynamic" + +[compat] +relay = ">=0.8.0,<1.0" +native_api = "1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_native", "config_schema"] + +[config_schema] +path = "config.schema.json" + +[source] +artifact = "target/debug/" + +[integrity] +sha256 = "sha256:" + +[load] +library = "target/debug/" +symbol = "nemo_relay_register_plugin" +``` + + +```toml +manifest_version = 1 + +[plugin] +id = "examples.rust_grpc_worker" +kind = "worker" + +[compat] +relay = ">=0.8.0,<1.0" +worker_protocol = "grpc-v1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_worker", "config_schema"] + +[config_schema] +path = "config.schema.json" + +[source] +artifact = "target/debug/" + +[integrity] +sha256 = "sha256:" + +[load] +runtime = "rust" +entrypoint = "target/debug/" +``` + + + +The Python package has one extra responsibility: `source.manifest_root` identifies the +directory Relay installs into its managed environment. The digest still covers only the +declared artifact, so regenerate it whenever `worker.py` changes. A compiled worker has +no managed Python environment and starts the executable named by `load.entrypoint`. + +Relay 0.8 keeps `grpc-v1` as the worker protocol identifier, but changes its tool-result +messages. Both worker manifests therefore begin their Relay compatibility range at +`0.8.0`. Rebuild SDK workers and regenerate bindings in a custom worker before packaging; +the unchanged protocol name does not make an earlier worker wire-compatible. + +## Package and Register the Artifact + +Use the following procedure to turn the checked manifest template into an artifact that +Relay can validate and activate: + +1. Build the shared library or worker from a clean checkout and place the entrypoint at + the relative path declared by `relay-plugin.toml`. +2. Validate the component schema against valid, invalid, and disabled example + configurations. Keep unknown-field behavior aligned between the schema and the + plugin's validation callback. +3. Generate the artifact digest with `shasum -a 256 ` on macOS, + `sha256sum ` on Linux, or `Get-FileHash -Algorithm SHA256 ` in + PowerShell. Do this after the final `source.artifact` bytes are in place; changing + that artifact invalidates the digest. + + For example, this Linux command materializes a usable Rust worker manifest without + changing the checked template: + + ```bash + cp relay-plugin.toml relay-plugin.local.toml + sed -i 's##nemo-relay-rust-grpc-worker-plugin-example#g' \ + relay-plugin.local.toml + digest="$(sha256sum target/debug/nemo-relay-rust-grpc-worker-plugin-example | cut -d' ' -f1)" + sed -i "s#sha256:#sha256:$digest#" relay-plugin.local.toml + ``` + + The local manifest is deliberately untracked. Rebuilding the executable changes its + bytes, so recalculate the digest before every validation attempt. +4. Validate the materialized manifest. For the compiled example above, run + `nemo-relay plugins validate ./relay-plugin.local.toml`. The Python example keeps its + checked digest and uses `./relay-plugin.toml` directly. A successful command confirms + manifest structure, compatibility syntax, entrypoint resolution, schema loading, and + integrity metadata without activating the component. +5. Register the same path with `nemo-relay plugins add --user `, or + add an explicit `[[plugins.dynamic]]` reference to the intended `plugins.toml`. +6. Activate a valid component, inspect the runtime report, execute a representative + managed call, then clear and unload or stop the plugin. + +Success means package validation passes before activation, tampering causes integrity +validation to fail, the runtime report identifies the dynamic component, and teardown +removes its registrations before the library unloads or worker exits. The operator-side +trust policy and file layout are documented in [Configure Discoverable Plugins](/configure-plugins/discoverable-plugins). diff --git a/docs/build-plugins/plugin-context.mdx b/docs/build-plugins/plugin-context.mdx new file mode 100644 index 000000000..a1b541c17 --- /dev/null +++ b/docs/build-plugins/plugin-context.mdx @@ -0,0 +1,197 @@ +--- +title: "PluginContext" +description: "Use every safe plugin registration surface and preserve Relay execution semantics." +position: 4 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +`PluginContext` is the component-scoped bridge between validated configuration and the +Relay runtime. It qualifies each registration name, applies priority and chain controls, +tracks ownership for rollback, and exposes the same 15 registration surfaces to +language-binding, native typed, and worker plugins. + +## Registration Surface + +| Family | Registration | Callback Responsibility | +|---|---|---| +| Events | Subscriber | Observe each emitted event. Native subscribers are synchronous; other binding and worker callback forms follow their SDK contract. A subscriber must not mutate the event. | +| Events | Mark sanitizer | Return replacement `data`, `category_profile`, and `metadata` fields for the emitted mark event. | +| Events | Scope-start sanitizer | Return replacement observability fields for the emitted scope-start event. | +| Events | Scope-end sanitizer | Return replacement observability fields for the emitted scope-end event. | +| Tool | Request sanitizer | Sanitize the request recorded in start events without changing the request passed to real execution. | +| Tool | Response sanitizer | Sanitize the result recorded in end events without changing the application result. | +| Tool | Conditional guardrail | Allow or block real execution from the tool name and request. | +| Tool | Request intercept | Rewrite the real request and optionally affect later request intercepts through registration-time `break_chain`. | +| Tool | Execution intercept | Receive a continuation, call it zero, one, or multiple times, and return an execution outcome. | +| LLM | Request sanitizer | Sanitize request observability with the structured LLM context and its codec handle. | +| LLM | Response sanitizer | Sanitize response observability with the structured LLM context and its codec handle. | +| LLM | Conditional guardrail | Allow or block real model execution from the provider request. The current callback contract does not receive a separate model name or annotation argument. | +| LLM | Request intercept | Return the complete request-intercept outcome, preserving or deliberately replacing annotations. | +| LLM | Execution intercept | Wrap unary execution through a continuation and return provider-response JSON. | +| LLM | Stream execution intercept | Transform response chunks while preserving order, cancellation, and error behavior. Native and worker SDKs expose a lazy stream; the Node.js language binding currently supplies the downstream chunks as an array. | + +Sanitize guardrails change emitted observability payloads only. They do not rewrite the +arguments given to a tool or model and do not change the value returned to the +application. Request and execution intercepts are different: they participate in the +real call path. Use a sanitizer for redaction and an intercept for policy or routing that +must affect execution. + +## Names, Priority, and `break_chain` + +Registration names need only be unique inside the component. Relay qualifies them with +component ownership so multiple components do not need to invent global prefixes. +Middleware from global and visible scope-local registries is merged in priority order. +Choose priorities as part of the configuration contract when ordering changes behavior. + +`break_chain` belongs to request-intercept registration. When an intercept runs and its +flag is true, later request intercepts do not run for that call. It is not a general +guardrail short circuit and it does not prevent execution of the rewritten request. +Document this setting whenever operators can control it, because a seemingly harmless +priority change can decide which transformations are visible downstream. + +## Outcomes, Annotations, and Accounting + +Managed tool callbacks and tool continuations return `ToolExecutionResult`: an +application-owned `result` and optional opaque `annotation`. Tool execution intercepts +return `ToolExecutionInterceptOutcome`, which preserves that pair and adds Relay-owned +pending marks. Tool response sanitizers receive only `result`; they cannot read or +rewrite the annotation. LLM request intercepts use a different outcome containing the +provider request, its optional normalized annotation, pending marks, and optimization +contributions. None of this accounting belongs in the tool result, LLM response, or +stream chunk. Unary LLM execution intercepts return response JSON, and stream intercepts +return response chunks. + +An LLM request intercept must return both the request and its annotated form. Preserve +the annotation unchanged unless the plugin intentionally creates an equivalent updated +annotation. Dropping it can remove normalized payloads or downstream metadata even when +the visible request still looks correct. + +The distinction is easiest to see in code. Each wrapper forwards both fields from the +downstream result, then adds the pending mark without exposing it to the application. + + + +```python +async def tool_execution(_name, args, next_call): + downstream = await next_call(args) + return ToolExecutionInterceptOutcome( + downstream.result, + [PendingMarkSpec("plugin.tool.complete")], + annotation=downstream.annotation, + ) + +context.register_tool_execution_intercept( + "tool_execution", execution_priority, tool_execution +) +``` + + +```js +context.registerToolExecutionIntercept( + 'tool_execution', + executionPriority, + async (args, next) => { + const downstream = await next(args); + return { + ...downstream, + pendingMarks: [{ name: 'plugin.tool.complete' }], + }; + }, +); +``` + + +```rust +context.register_tool_execution_intercept( + "tool_execution", + execution_priority, + move |_name, request, next| async move { + Ok(ToolExecutionInterceptOutcome::from(next.call(request).await?) + .with_pending_mark( + PendingMarkSpec::builder() + .name("plugin.tool.complete") + .category(EventCategory::custom()) + .data(json!({ "source": "documentation" })) + .build(), + )) + }, +)?; +``` + + + +The application receives the `ToolExecutionResult`, including its optional annotation, +from the tool path and provider JSON from the LLM paths. Relay consumes pending marks and +optimization contributions when it emits events. They are not properties to splice into +an application result. + +LLM sanitizers receive a structured context whose codec state can be absent, built-in, +runtime-resolved, or opaque. In-process callbacks can resolve the active codec directly. +Worker callbacks receive an invocation-scoped asynchronous proxy that calls the host for +directional encode or decode operations. A codec can be unavailable or opaque, so a +sanitizer needs a safe fallback that redacts the original JSON envelope. + +The following native SDK request sanitizer resolves the active codec when one exists, +redacts the normalized annotation, encodes it back into the provider envelope, and still +redacts the envelope's ordinary `content`. If the codec is absent or opaque, the content +fallback remains safe. The worker equivalents appear in +[Middleware and Continuations](/build-plugins/workers/middleware-and-continuations). + +```rust +context.register_llm_sanitize_request_guardrail( + "llm_request_sanitizer", + 10, + move |mut request, codec_context| { + let redact_keys = redact_keys.clone(); + async move { + if let Some(codec) = codec_context.resolve_codec() { + let annotated = codec.decode(&request)?; + let redacted = serde_json::to_value(annotated) + .map(|value| redact_json(value, &redact_keys)) + .and_then(serde_json::from_value) + .map_err(|error| error.to_string())?; + request = codec.encode(&redacted, &request)?; + } + request.content = redact_json(request.content, &redact_keys); + Ok(Some(request)) + } + }, +)?; +``` + +Returning `None` from an LLM sanitizer means no sanitized replacement is available. It +does not block the call. A conditional execution guardrail is the surface that returns +an optional block reason and prevents the real callback when that reason is present. + +## Continuations and Streams + +An execution continuation represents the rest of the real call path. Calling it zero +times replaces or blocks downstream execution. Calling it once is the normal wrapper +pattern. Calling it multiple times can implement retry, comparison, or speculative work, +but every call can repeat provider charges, tool side effects, events, and downstream +middleware. SDK continuations support concurrent use where their type permits it; each +invocation retains the captured scope snapshot so parentage remains correct. + +Cancellation is cooperative. When the owner callback completes or Relay cancels the +managed call, outstanding worker continuation invocations are cancelled and late work +must be abandoned. Native and worker stream intercepts should request downstream +execution only when needed, transform chunks as they arrive, and stop promptly when the +consumer drops or cancellation arrives. The Node.js language-binding callback receives +all downstream chunks after `next(request)` resolves, so it can preserve ordering and +transform chunks but does not provide the same lazy downstream boundary. + +## Runtime Helpers + +Native and worker SDK contexts also expose a runtime handle. It can emit marks, inspect +the current scope, push and pop scopes, create and drop isolated scope stacks, bind a +captured stack while work runs, and restore the previous stack afterward. The exact +helper names reflect synchronous Rust, asynchronous worker, and Python context-manager +idioms, but the ownership rule is shared: every push has a cleanup path, isolated stacks +are dropped, and prior thread or task context is restored even when the callback fails. + +Use scoped guards or `try`/`finally` around manual stack changes. Emitting a mark or +creating an isolated scope is observable runtime behavior, so configuration should make +those features explicit. Successful verification shows the mark under the expected +scope, confirms that an isolated stack has no accidental parent from the caller, and +confirms that later application work resumes on its original stack. diff --git a/docs/build-plugins/plugin-shape.mdx b/docs/build-plugins/plugin-shape.mdx new file mode 100644 index 000000000..985655891 --- /dev/null +++ b/docs/build-plugins/plugin-shape.mdx @@ -0,0 +1,177 @@ +--- +title: "Plugin Shape" +description: "Understand plugin identity, lifecycle, ownership, rollback, and teardown." +position: 2 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +A plugin is a configuration-driven installer for Relay behavior. It is not the +subscriber, guardrail, or intercept itself. The plugin gives a related set of runtime +registrations a stable identity, validates one component's JSON configuration, and +installs those registrations through a component-scoped context. + +## The Shared Contract + +| Part | Responsibility | +|---|---| +| Stable identity | A language-binding plugin is registered under a kind; native and worker implementations report their plugin identity during loading or handshake. Configuration uses that identity in `components[].kind`. | +| `validate` | Examines component-local JSON and returns structured diagnostics without installing behavior or performing lasting side effects. | +| `register` | Receives the validated component configuration and a `PluginContext`, then installs the component's subscribers and middleware. The Rust language-binding hook returns a future. Python, Node.js, native, and worker hooks register synchronously, although many callbacks they install are asynchronous. | +| `allows_multiple_components` | Declares whether one plugin implementation can back more than one configured component. The default differs by SDK, so an implementation should set or test the intended behavior instead of relying on assumption. | +| Component ownership | Every registration made through the context belongs to the component being activated. Relay qualifies registration names and tracks cleanup on that component's behalf. | + +The lifecycle hooks use the same logical shape in every binding. These implementations +all validate `tag` and install one component-owned subscriber. The callback spelling is +different, but the identity, validation, and ownership rules are the same. + + + +```python +class AuditPlugin: + def validate(self, config): + tag = config.get("tag", "documentation") + if isinstance(tag, str): + return [] + return [{ + "level": "error", + "code": "audit.invalid_tag", + "component": "audit", + "field": "tag", + "message": "tag must be a string", + }] + + def register(self, _config, context): + context.register_subscriber("events", lambda event: print(event.name)) +``` + + +```js +const auditPlugin = { + validate(config) { + if (config.tag === undefined || typeof config.tag === 'string') return []; + return [{ + level: 'error', + code: 'audit.invalid_tag', + component: 'audit', + field: 'tag', + message: 'tag must be a string', + }]; + }, + + register(_config, context) { + context.registerSubscriber('events', (event) => console.log(event.name)); + }, +}; +``` + + +```rust +use std::{future::Future, pin::Pin, sync::Arc}; + +use nemo_relay::plugin::{ + ConfigDiagnostic, DiagnosticLevel, Plugin, PluginRegistrationContext, + Result as PluginResult, +}; +use serde_json::{Map, Value as Json}; + +struct AuditPlugin; + +impl Plugin for AuditPlugin { + fn plugin_kind(&self) -> &str { + "audit" + } + + fn validate(&self, config: &Map) -> Vec { + if config.get("tag").is_none_or(Json::is_string) { + return Vec::new(); + } + vec![ConfigDiagnostic { + level: DiagnosticLevel::Error, + code: "audit.invalid_tag".into(), + component: Some("audit".into()), + field: Some("tag".into()), + message: "tag must be a string".into(), + }] + } + + fn register<'a>( + &'a self, + _config: &Map, + context: &'a mut PluginRegistrationContext, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + context.register_subscriber( + "events", + Arc::new(|event| println!("{}", event.name())), + )?; + Ok(()) + }) + } +} +``` + + + +Rust carries the kind on the `Plugin` implementation. Python and Node.js supply it when +the application calls `plugin.register("audit", implementation)`. In all three cases, +`events` is a local registration name. Relay qualifies that name with the component +owner, records it during activation, and removes it during clear or rollback. + +One component can install several kinds of middleware when the configuration tells a +coherent story. For example, a policy component can observe calls, block configured +tools, and add an audit mark. A plugin that combines an unrelated exporter, routing +policy, and provider client is harder to validate, roll out, and remove safely; those +behaviors should normally become separate components. + +## Activation Is Transactional + +Activation begins with a complete plugin document, not an isolated callback. Relay +validates the document and every component, including disabled components, so a staged +configuration can be checked before it is enabled. An enabled component with error +diagnostics cannot activate. + +For each valid, enabled component, Relay creates a registration context and calls the +plugin's registration hook. A successful hook commits the registrations as active +component state. If the hook fails after installing some behavior, Relay rolls back the +partial registrations rather than leaving a half-active plugin. Dynamic loading adds a +loader instance around the same component lifecycle; unloading does not begin until the +component registrations have been cleared. + +The runtime report is the observable record of that process. It identifies loaded +components and diagnostics, and it lets an application or deployment test distinguish +"configuration parsed" from "runtime behavior is active." + +## Teardown Has an Owner + +Registrations should be created only through the supplied context. Direct process-global +registration escapes component ownership and prevents reliable rollback. The same rule +applies to external resources: create clients, tasks, and file handles during +registration only when their lifetime is tied to the component and they can be stopped +when activation fails or configuration is cleared. + +Language-binding applications remove active plugin configuration with the binding's +clear API and can deregister a plugin kind when the implementation itself is no longer +available. Dynamic hosts clear component registrations before unloading a native +library or stopping a worker. Worker shutdown also ends in-flight callback service, +closes the authenticated endpoint, and terminates the managed process. + +## Verify the Lifecycle + +Use the following sequence to verify validation, activation, ownership, and teardown: + +1. Register or load the plugin implementation under its stable identity. +2. Validate one invalid component and confirm the report names the component, field, + stable diagnostic code, and error level. +3. Validate a disabled invalid component and confirm the same error is still visible. +4. Validate and initialize a valid enabled component, then inspect the runtime report + before sending application traffic. +5. Execute a representative managed call and observe the registration's effect rather + than treating successful initialization as sufficient proof. +6. Clear configuration and verify that the same call no longer observes the plugin. +7. For a dynamic plugin, unload or stop the implementation only after registrations are + gone. + +Success means invalid configuration never changes runtime behavior, valid configuration +produces an active report and an observable call-path effect, and teardown removes that +effect without leaving a loaded dynamic instance or worker process behind. diff --git a/docs/build-plugins/workers/about.mdx b/docs/build-plugins/workers/about.mdx new file mode 100644 index 000000000..f0cfbfacf --- /dev/null +++ b/docs/build-plugins/workers/about.mdx @@ -0,0 +1,153 @@ +--- +title: "About gRPC Worker Plugins" +description: "Choose a Python, Rust, or custom grpc-v1 worker implementation." +position: 30 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +A worker plugin runs outside the Relay process and implements the stable `grpc-v1` +service contract. It can register the same 15 subscriber and middleware surfaces as +an in-process plugin, call continuations back in the host, use invocation-scoped codecs, +and emit marks or manage scope stacks through host-runtime RPCs. + +Relay 0.8 establishes canonical tool results as the `grpc-v1` baseline. Tool callbacks +and `ToolNext` continuations preserve an application `result` and optional opaque +`annotation`; an execution intercept can add Relay-owned pending marks. Rebuild every +SDK worker, regenerate custom protobuf bindings, and declare `compat.relay` beginning at +`0.8.0`. The protocol remains named `grpc-v1`; this is a changed tool-result contract, +not a new protocol family. + +Choose a worker when process or dependency isolation is worth the gRPC dispatch, +JSON-envelope conversion, and scheduling cost on callback paths. The boundary limits the +blast radius of a worker crash and keeps dependencies out of the application environment, +but it is not a security sandbox. Relay authenticates a local endpoint with a per-activation +token, and a worker is trusted to request host operations within that activation. + +## Choose the Implementation + +| Implementation | Development and Distribution Characteristics | +|---|---| +| Python SDK | Best fit for Python-only dependencies and rapid iteration. `plugins add` builds a Relay-managed environment from the package and records an attestation used at activation. Python workers cannot be activated by merely adding a manifest path to `plugins.toml`. | +| Rust SDK | Best fit for a compiled, self-contained worker and Rust callback code. The executable is packaged directly and can be registered through a manifest reference or the CLI lifecycle. | +| Custom command | Advanced path for another language or an existing service executable. It must implement every required `grpc-v1` lifecycle, authentication, envelope, cancellation, continuation, codec, and shutdown rule itself. The SDK tutorials do not apply to it. | + +Async Python and Rust callback work is cancelled cooperatively when the host caller times +out, a stream consumer goes away, or shutdown begins. Dropping a future or cancelling an +`asyncio` task cannot preempt arbitrary blocking work or a separately spawned process. +Keep blocking work off the SDK event loop, propagate cancellation, and put external +cleanup in a guaranteed finalizer. + +## Let the SDK Own the Transport + +Worker authors implement `WorkerPlugin`; they do not implement the protobuf server in +application code. `serve_plugin` reads the activation environment created by Relay, +binds the authenticated local endpoint, performs handshake and health handling, and +dispatches validation, registration, invocation, cancellation, and shutdown. + + + +```python +import asyncio +from nemo_relay_plugin import WorkerPlugin, serve_plugin + +class ExamplePythonWorker(WorkerPlugin): + plugin_id = "examples.python_grpc_worker" + allows_multiple_components = False + + def validate(self, config): + return validate_config(config) + + def register(self, context, config): + settings = normalized_config(config) + if settings["observe"]["enabled"]: + self._register_observation( + context, settings["tag"], settings["observe"] + ) + if settings["requests"]["enabled"]: + self._register_requests( + context, + settings["tag"], + settings["requests"], + settings["execution"], + ) + self._register_runtime(context, settings["tag"], settings["runtime"]) + if settings["execution"]["enabled"]: + self._register_execution(context, settings["tag"], settings["execution"]) + +async def main(): + await serve_plugin(ExamplePythonWorker()) + +if __name__ == "__main__": + asyncio.run(main()) +``` + + +```rust +use nemo_relay_worker::{ + ConfigDiagnostic, Json, PluginContext, Result, + WorkerPlugin, WorkerSdkError, serve_plugin, +}; + +struct DocumentationWorker; + +impl WorkerPlugin for DocumentationWorker { + fn plugin_id(&self) -> &str { + "examples.rust_grpc_worker" + } + + fn allows_multiple_components(&self) -> bool { + false + } + + fn validate(&self, config: &Json) -> Vec { + validate_config(config) + } + + fn register(&self, context: &mut PluginContext, config: &Json) -> Result<()> { + let settings = ExampleConfig::parse(config) + .map_err(WorkerSdkError::InvalidInput)?; + if settings.observe.enabled { + register_observation(context, &settings); + } + if settings.requests.enabled { + register_requests(context, &settings); + } + register_execution(context, &settings); + Ok(()) + } +} + +#[tokio::main] +async fn main() -> Result<()> { + serve_plugin(DocumentationWorker).await +} +``` + + + +Both `register` methods are synchronous because they describe and return the component's +registration set during activation. The callbacks they install can be asynchronous. +The stable `plugin_id` must match the manifest ID and handshake identity; a mismatch +causes activation to fail before any callback is routed. + +## Complete Path + +Follow these pages in order to build, activate, exercise, and stop a worker plugin: + +1. Use [Python Worker](/build-plugins/workers/python) when Relay should provision and own + the Python environment, or [Rust Worker](/build-plugins/workers/rust) for the checked + compiled counterpart. +2. Implement all callback families with [Middleware and Continuations](/build-plugins/workers/middleware-and-continuations), + paying particular attention to repeated downstream calls and lazy streams. +3. Use [Runtime Events and Scopes](/build-plugins/workers/runtime-events-and-scopes) for + marks, scope stacks, binding, restoration, and failure cleanup. +4. Use the [grpc-v1 Protocol Reference](/build-plugins/workers/grpc-v1-protocol) to audit + an SDK or implement a custom command. + +The atomic Rust lifecycle test builds the executable in an isolated target directory, +materializes and integrity-checks its manifest, completes worker activation, executes a +managed tool call across `grpc-v1`, observes a host-runtime mark, and verifies orderly +shutdown. The Rust and Python callback-contract tests then isolate the sanitizer, +continuation, streaming, codec, cancellation, and scope-cleanup behavior. Merely +starting a process and registering one intercept is not enough evidence. diff --git a/docs/build-plugins/workers/grpc-v1-protocol.mdx b/docs/build-plugins/workers/grpc-v1-protocol.mdx new file mode 100644 index 000000000..9fa7529cc --- /dev/null +++ b/docs/build-plugins/workers/grpc-v1-protocol.mdx @@ -0,0 +1,354 @@ +--- +title: "grpc-v1 Protocol Reference" +description: "Reference every worker RPC, registration surface, envelope, capability, and shutdown stage." +position: 35 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +`grpc-v1` is the stable worker protocol implemented by the Rust and Python SDKs. The +current source of truth is +[`crates/worker-proto/proto/nemo/relay/worker/v1/plugin_worker.proto`](https://github.com/NVIDIA/NeMo-Relay/blob/main/crates/worker-proto/proto/nemo/relay/worker/v1/plugin_worker.proto). +Generated protobuf types are wire material, not the recommended authoring API. + +Relay 0.8 retains the `grpc-v1` identifier and `nemo.relay.worker.v1` package, but it +changes the tool-result boundary to structural protobuf messages. Rebuild workers, +regenerate custom bindings, and declare `compat.relay` beginning at `0.8.0`; an earlier +worker cannot decode the current `ToolNext` response or tool-execution outcome. + +The protocol consists of the worker-facing service implemented by the plugin process +and the host-runtime service implemented by Relay. These are the current service +definitions, including cancellation, codecs, and streaming continuations: + +```proto +service PluginWorker { + rpc Handshake(HandshakeRequest) returns (HandshakeResponse); + rpc Health(HealthRequest) returns (HealthResponse); + rpc Validate(ValidateRequest) returns (ValidateResponse); + rpc Register(RegisterRequest) returns (RegisterResponse); + rpc Invoke(InvokeRequest) returns (InvokeResponse); + rpc InvokeStream(InvokeRequest) returns (stream StreamChunk); + rpc CancelInvocation(CancelInvocationRequest) returns (WorkerAck); + rpc Shutdown(ShutdownRequest) returns (WorkerAck); +} + +service RelayHostRuntime { + rpc EmitMark(EmitMarkRequest) returns (HostAck); + rpc PushScope(PushScopeRequest) returns (PushScopeResponse); + rpc PopScope(PopScopeRequest) returns (HostAck); + rpc CreateScopeStack(CreateScopeStackRequest) + returns (CreateScopeStackResponse); + rpc DropScopeStack(DropScopeStackRequest) returns (HostAck); + rpc ToolNext(ToolNextRequest) returns (ToolExecutionResultResponse); + rpc LlmNext(LlmNextRequest) returns (JsonResult); + rpc LlmStreamNext(LlmStreamNextRequest) returns (stream StreamChunk); + rpc DecodeLlmCodecRequest(LlmCodecDecodeRequest) returns (JsonResult); + rpc EncodeLlmCodecRequest(LlmCodecEncodeRequest) returns (JsonResult); + rpc DecodeLlmCodecResponse(LlmCodecDecodeResponse) returns (JsonResult); +} +``` + +## Worker Service + +| RPC | Contract | +|---|---| +| `Handshake` | Relay supplies activation and plugin identity, Relay version, protocol, token, and host endpoint. The worker returns plugin identity and kind, multiple-component support, protocol, SDK and runtime metadata, and supported surfaces. | +| `Health` | Confirms the authenticated activation, protocol, plugin identity, and current worker readiness. | +| `Validate` | Receives component config in a JSON envelope and returns encoded diagnostics or a structured worker error. It must not register behavior. | +| `Register` | Receives valid component config and returns owned registrations with local name, surface, priority, and `break_chain`. | +| `Invoke` | Dispatches one subscriber, sanitizer, guardrail, request intercept, or unary execution callback and returns the surface-appropriate result. | +| `InvokeStream` | Dispatches an LLM stream execution intercept and emits incremental value or error chunks. | +| `CancelInvocation` | Cooperatively cancels one active invocation ID and reports whether cancellation was accepted. Unknown, completed, and already-cancelled IDs receive a negative acknowledgment. | +| `Shutdown` | Stops new work for an activation and begins orderly worker termination with a reason. | + +Each lifecycle message carries the following fields. Fields described as envelopes use +the schema identifiers in the JSON envelope table later on this page. + +| RPC | Request Fields | Response Fields | +|---|---|---| +| `Handshake` | `activation_id`, manifest `plugin_id`, `relay_version`, `worker_protocol`, `auth_token`, and `host_endpoint` | `plugin_id`, `plugin_kind`, `allows_multiple_components`, `worker_protocol`, `sdk_name`, `sdk_version`, `runtime_name`, `runtime_version`, and `supported_surfaces` | +| `Health` | `activation_id` and `auth_token` | `ok`, `message`, `plugin_id`, `worker_protocol`, SDK name and version, and runtime name and version | +| `Validate` | `activation_id`, `plugin_id`, `auth_token`, and component `config` | A `diagnostics` envelope or `error` | +| `Register` | `activation_id`, `plugin_id`, `auth_token`, and validated component `config` | Repeated `registrations` or `error`; each registration contains `local_name`, `surface`, `priority`, and `break_chain` | +| `Invoke` and `InvokeStream` | `activation_id`, `invocation_id`, `registration_name`, `surface`, optional `continuation_id`, captured `scope`, `auth_token`, and exactly one event, tool, or LLM payload | `Invoke` returns exactly one empty, JSON, guardrail, LLM request outcome, tool execution outcome, or error result. `InvokeStream` returns value chunks followed by clean stream closure or one terminal error chunk. | +| `CancelInvocation` | `activation_id`, `invocation_id`, `auth_token`, and `reason` | `accepted` and a human-readable `message` | +| `Shutdown` | `activation_id`, `auth_token`, and `reason` | `accepted` and a human-readable `message` | + +## Registration Surfaces + +| Value | Surface | Invocation Result | +|---:|---|---| +| 1 | Subscriber | Empty result. | +| 10 | Tool sanitize request guardrail | Sanitized JSON. | +| 11 | Tool sanitize response guardrail | Sanitized JSON. | +| 12 | Tool conditional execution guardrail | Optional block reason. | +| 13 | Tool request intercept | Rewritten JSON. | +| 14 | Tool execution intercept | Tool execution outcome envelope. | +| 20 | LLM sanitize request guardrail | Optional sanitized request. | +| 21 | LLM sanitize response guardrail | Optional sanitized response. | +| 22 | LLM conditional execution guardrail | Optional block reason. | +| 23 | LLM request intercept | Complete request-intercept outcome envelope. | +| 24 | LLM execution intercept | Provider-response JSON. | +| 25 | LLM stream execution intercept | Server stream of incremental chunks. | +| 30 | Mark sanitize guardrail | Replacement event sanitization fields. | +| 31 | Scope-start sanitize guardrail | Replacement event sanitization fields. | +| 32 | Scope-end sanitize guardrail | Replacement event sanitization fields. | + +The numeric values are part of the wire contract. Unknown value zero never represents +a valid registration. + +```proto +enum RegistrationSurface { + REGISTRATION_SURFACE_UNSPECIFIED = 0; + SUBSCRIBER = 1; + TOOL_SANITIZE_REQUEST_GUARDRAIL = 10; + TOOL_SANITIZE_RESPONSE_GUARDRAIL = 11; + TOOL_CONDITIONAL_EXECUTION_GUARDRAIL = 12; + TOOL_REQUEST_INTERCEPT = 13; + TOOL_EXECUTION_INTERCEPT = 14; + LLM_SANITIZE_REQUEST_GUARDRAIL = 20; + LLM_SANITIZE_RESPONSE_GUARDRAIL = 21; + LLM_CONDITIONAL_EXECUTION_GUARDRAIL = 22; + LLM_REQUEST_INTERCEPT = 23; + LLM_EXECUTION_INTERCEPT = 24; + LLM_STREAM_EXECUTION_INTERCEPT = 25; + MARK_SANITIZE_GUARDRAIL = 30; + SCOPE_SANITIZE_START_GUARDRAIL = 31; + SCOPE_SANITIZE_END_GUARDRAIL = 32; +} +``` + +An invocation names the activation, invocation, registration, surface, optional +continuation, captured scope, and token. Its payload is exactly one event, tool +invocation, or LLM invocation. LLM sanitizer invocations additionally carry codec +identity and an opaque invocation-scoped codec capability. + +```proto +message InvokeRequest { + string activation_id = 1; + string invocation_id = 2; + string registration_name = 3; + RegistrationSurface surface = 4; + string continuation_id = 5; + ScopeContext scope = 6; + string auth_token = 7; + + oneof payload { + JsonEnvelope event = 10; + ToolInvocation tool = 11; + LlmInvocation llm = 12; + } +} + +message LlmInvocation { + string model_name = 1; + JsonEnvelope request = 2; + JsonEnvelope annotated_request = 3; + JsonEnvelope response = 4; + reserved 5, 6, 7, 8; + oneof sanitize_context { + LlmSanitizeRequestContext request_sanitize_context = 9; + LlmSanitizeResponseContext response_sanitize_context = 10; + } +} + +message ToolInvocation { + string tool_name = 1; + JsonEnvelope value = 2; +} + +message LlmCodecIdentity { + LlmCodecKind kind = 1; + optional string id = 2; +} + +message LlmSanitizeRequestContext { + LlmCodecIdentity codec = 1; + optional string codec_capability_id = 2; +} + +message LlmSanitizeResponseContext { + LlmCodecIdentity codec = 1; + optional string codec_capability_id = 2; +} + +message InvokeResponse { + oneof result { + EmptyResult empty = 1; + JsonResult json = 2; + GuardrailResult guardrail = 3; + LlmRequestInterceptResult llm_request = 4; + WorkerError error = 5; + ToolExecutionInterceptResult tool_execution = 6; + } +} +``` + +Tool values remain JSON, but the result boundary is structural so Relay can keep an +opaque annotation beside the application value without treating either field as a +schema-tagged envelope. + +```proto +message JsonValue { + bytes json = 1; +} + +message ToolExecutionResultResponse { + ToolExecutionResult value = 1; + WorkerError error = 2; +} + +message ToolExecutionResult { + JsonValue result = 1; + JsonValue annotation = 2; +} + +message ToolExecutionInterceptOutcome { + JsonValue result = 1; + JsonValue annotation = 2; + JsonValue pending_marks = 3; +} +``` + +`continuation_id` is present only for execution intercepts. `scope` captures the host +context used for continuation and runtime calls. `registration_name` is the +component-local name the worker returned in `RegisterResponse`; Relay separately owns +its qualification in the host registries. + +`LlmCodecKind` has four wire values: unspecified, built-in, runtime, and opaque. A built-in +identity carries `openai_chat`, `openai_responses`, `anthropic_messages`, `oci_genai`, +or `gemini_generate_content`; a runtime identity carries its registered ID. An opaque +identity deliberately withholds an ID. The capability ID is optional and +invocation-scoped. A worker must treat it as a secret and must not use it after the owner +invocation ends. + +## Host-Runtime Service + +| RPC | Contract | +|---|---| +| `EmitMark` | Emits mark data and metadata under the supplied scope context. | +| `PushScope` | Opens a typed scope with name, data, metadata, and input, returning the handle required for pop. | +| `PopScope` | Closes the owned scope handle with output and metadata. | +| `CreateScopeStack` | Allocates an isolated stack and returns its opaque ID. | +| `DropScopeStack` | Releases an isolated stack owned by the activation. | +| `ToolNext` | Executes a tool continuation with JSON arguments and captured scope, returning a structural tool result or worker error. | +| `LlmNext` | Executes a unary LLM continuation with a request and captured scope. | +| `LlmStreamNext` | Executes a streaming LLM continuation and returns incremental chunks. | +| `DecodeLlmCodecRequest` | Uses the invocation-scoped capability to decode a request into its annotated representation. | +| `EncodeLlmCodecRequest` | Applies an annotated request to the original provider envelope. | +| `DecodeLlmCodecResponse` | Decodes a provider response into its annotated representation. | + +The host-runtime request and response fields are complete in the following table: + +| RPC | Request Fields | Response Fields | +|---|---|---| +| `EmitMark` | `activation_id`, `auth_token`, captured `scope`, `name`, optional `data`, and optional `metadata` | `HostAck.ok` or `HostAck.error` | +| `PushScope` | `activation_id`, `auth_token`, captured `scope`, `name`, `scope_type`, and optional `data`, `metadata`, and `input` | `scope_handle_id` or `error` | +| `PopScope` | `activation_id`, `auth_token`, owned `scope_handle_id`, and optional `output` and `metadata` | `HostAck.ok` or `HostAck.error` | +| `CreateScopeStack` | `activation_id` and `auth_token` | `scope_stack_id` or `error` | +| `DropScopeStack` | `activation_id`, `auth_token`, and owned `scope_stack_id` | `HostAck.ok` or `HostAck.error` | +| `ToolNext` | `activation_id`, `auth_token`, `continuation_id`, JSON `value`, and captured `scope` | `ToolExecutionResultResponse.value` or `error` | +| `LlmNext` | `activation_id`, `auth_token`, `continuation_id`, typed `request`, and captured `scope` | JSON `value` or `error` | +| `LlmStreamNext` | `activation_id`, `auth_token`, `continuation_id`, typed `request`, and captured `scope` | Incremental JSON value chunks, clean stream closure, or one terminal error chunk | +| `DecodeLlmCodecRequest` | `activation_id`, `auth_token`, `codec_capability_id`, typed `request`, and owner `invocation_id` | Annotated LLM request or `error` | +| `EncodeLlmCodecRequest` | `activation_id`, `auth_token`, `codec_capability_id`, `annotated_request`, `original_request`, and owner `invocation_id` | Typed LLM request or `error` | +| `DecodeLlmCodecResponse` | `activation_id`, `auth_token`, `codec_capability_id`, provider `response`, and owner `invocation_id` | Annotated LLM response or `error` | + +`ScopeContext` contains `scope_stack_id` and `parent_scope_id`. `ScopeType` supports +agent, function, tool, LLM, retriever, embedder, reranker, guardrail, evaluator, custom, +and unknown scopes. The unspecified wire value is invalid for a pushed scope. + +## Authentication and Endpoints + +Relay creates a fresh activation ID and high-entropy token, passes the worker and host +endpoints through the activation environment, and sends the same values in handshake. +Every later worker and host-runtime request includes the activation ID and token. SDKs +bind the endpoint locally, prefer Unix domain sockets where supported, constrain TCP +fallback to loopback, and reject mismatched credentials. Local authentication prevents +accidental cross-activation calls; it does not make untrusted worker code safe. + +Relay supplies the following process environment to Rust, Python, and custom-command +workers: + +| Variable | Contract | +|---|---| +| `NEMO_RELAY_WORKER_ID` | Opaque activation ID used in every authenticated request. It is not the manifest plugin ID. | +| `NEMO_RELAY_PLUGIN_ID` | Manifest plugin ID that the handshake response must match. | +| `NEMO_RELAY_WORKER_TOKEN` | High-entropy activation token used in every worker and host-runtime request. Do not log or persist it. | +| `NEMO_RELAY_WORKER_SOCKET` | Worker listen endpoint. SDKs accept a Unix socket URI or a loopback TCP or HTTP endpoint; port zero requests an ephemeral TCP port. | +| `NEMO_RELAY_HOST_SOCKET` | Relay host-runtime endpoint used for continuations, codecs, marks, and scopes. | +| `NEMO_RELAY_WORKER_ENDPOINT_FILE` | Optional path where a worker that binds an ephemeral port writes its resolved endpoint after it begins accepting requests. | + +## JSON Envelopes and Errors + +`JsonEnvelope` contains a schema identifier and UTF-8 JSON bytes. General values use +`nemo.relay.Json@1`; typed request, annotation, outcome, and event schemas identify their +own expected shape. The envelope owns its bytes for the message lifetime, so neither side +borrows language-runtime objects across RPCs. + +The current envelope schemas are as follows: + +| Schema Identifier | Payload | +|---|---| +| `nemo.relay.Json@1` | General configuration, tool values, provider responses, stream chunks, mark fields, and scope fields. | +| `nemo.relay.Event@1` | ATOF event supplied to subscribers and event sanitizers. | +| `nemo.relay.LlmRequest@1` | Provider request envelope used by LLM middleware, continuations, and request codec operations. | +| `nemo.relay.AnnotatedLlmRequest@2` | Normalized LLM request annotation carried through request intercepts and codec operations. | +| `nemo.relay.LlmRequestInterceptOutcome@2` | Rewritten request, optional annotation, pending marks, and optimization contributions. | +| `nemo.relay.ToolExecutionInterceptOutcome@2` | Application tool result, optional annotation, and Relay-owned pending marks. | +| `nemo.relay.PluginDiagnostics@1` | Diagnostics returned from worker validation. | + +`JsonValue` contains exactly one JSON value and carries arbitrary tool results without +numeric coercion. `ToolExecutionResult` requires that value in `result` and permits an +optional `annotation`; JSON `null` annotations normalize to absence. A tool execution +intercept returns `ToolExecutionInterceptOutcome`, which adds an optional JSON array of +Relay-owned `pending_marks`. Surface results otherwise use empty, JSON, guardrail, LLM +request outcome, or structured error variants. Stream chunks contain one JSON value or +one terminal error. `WorkerError` carries a stable code, human-readable message, and +retryable flag; transport errors remain distinct from plugin callback errors. + +```proto +message JsonEnvelope { + string schema = 1; + bytes json = 2; +} + +message StreamChunk { + oneof item { + JsonEnvelope value = 1; + WorkerError error = 2; + } +} + +message WorkerError { + string code = 1; + string message = 2; + bool retryable = 3; +} +``` + +A clean stream ends when the server stream closes after its last value. A callback +failure travels as the terminal `error` item. Transport cancellation or an unavailable +process remains a gRPC status and must not be rewritten into a plugin `WorkerError` by a +custom implementation. + +## Cancellation and Shutdown Sequence + +The host and worker use the following sequence to end active work and release the worker +process: + +1. Relay stops routing new calls to the component and sends `CancelInvocation` for + in-flight work that cannot drain normally. +2. The worker acknowledges known active invocations and cooperatively cancels their async + tasks. Continuation and codec capabilities become unusable when their owner invocation + ends. +3. Relay sends `Shutdown` with the activation ID, token, and reason. The worker stops its + service and closes the local endpoint. +4. Relay waits for the managed process within its shutdown policy, terminates it if + necessary, removes component registrations, and deletes a managed Python environment + only during explicit package removal. + +Successful protocol verification covers authentication failures, envelope schema +failures, every registration and result variant, repeated unary and incremental stream +continuations, cancellation before and during callbacks, codec capability expiry, host +runtime ownership errors, health, and orderly plus forced shutdown. diff --git a/docs/build-plugins/workers/middleware-and-continuations.mdx b/docs/build-plugins/workers/middleware-and-continuations.mdx new file mode 100644 index 000000000..28412dac1 --- /dev/null +++ b/docs/build-plugins/workers/middleware-and-continuations.mdx @@ -0,0 +1,387 @@ +--- +title: "Middleware and Continuations" +description: "Implement every worker middleware closure and downstream continuation path." +position: 33 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Python and Rust workers expose the same registration model even though their closure +syntax differs. The checked examples share configuration and observable behavior so a +reader can compare runtime mechanics instead of reverse-engineering two unrelated demos. + +## Callback Families + +| Family | Python Callback Form | Rust Callback Form | Required Result | +|---|---|---|---| +| Subscriber | Sync or async event callback. An async callback can await the worker runtime. | Synchronous `Fn(&Event)` callback. Keep its work bounded. | No result; observe only. | +| Event sanitizers | Sync or async callback over event and sanitizable fields. | Closure returning a future of `EventSanitizeFields`. | Replacement data, category profile, and metadata. | +| Tool sanitizers and policy | Sync or async callback, normalized by the SDK. | Closure returning a boxed or inferred future. | Sanitized JSON or an optional block reason. | +| Tool request intercept | Sync or async callback. | Async closure. | Complete rewritten JSON request. | +| Tool execution intercept | Async callback with `ToolNext`. | Async closure with cloneable `ToolNext`. | `ToolExecutionInterceptOutcome`, which preserves the downstream `result` and optional `annotation` plus Relay-owned accounting. | +| LLM sanitizers | Sync or async callback with an invocation-scoped codec context. | Async closure with typed request or response context. | Optional sanitized payload. | +| LLM policy and request intercept | Sync or async callback. | Async closure. | Optional block reason or full request-intercept outcome with annotations. | +| Unary LLM execution | Async callback with `LlmNext`. | Async closure with cloneable `LlmNext`. | Provider-response JSON. | +| Stream LLM execution | Async generator or async callback returning an async iterator. | Async closure returning a `JsonStream`. | Chunks produced lazily until completion, error, or cancellation. | + +Subscribers plus three event sanitizers, five tool registrations, and six LLM +registrations make 15 surfaces. Registration names are component-local; priority +and `break_chain` are sent in registration metadata and enforced by the host after it +merges visible middleware. + +## Register Synchronous and Asynchronous Callbacks + +Python callbacks can be ordinary functions or coroutines on surfaces that the SDK +normalizes. This excerpt uses a synchronous event sanitizer and an asynchronous LLM +sanitizer because codec operations call back to the host. All three event sanitizers +return the same complete field object. + +```python +def sanitize_event(_event, fields): + metadata = _redact(fields.get("metadata") or {}, redact_keys) + if not isinstance(metadata, dict): + metadata = {"original": metadata} + return { + "data": _redact(fields.get("data"), redact_keys), + "category_profile": _redact( + fields.get("category_profile"), redact_keys + ), + "metadata": {**metadata, "plugin_tag": tag}, + } + +async def sanitize_llm_request(request, codec_context): + request = deepcopy(request) + codec = codec_context.resolve_codec() + if codec is not None: + annotated = await codec.decode(request) + annotated = _redact(annotated, redact_keys) + request = await codec.encode(annotated, request) + request["content"] = _redact(request.get("content"), redact_keys) + return request + +ctx.register_mark_sanitize_guardrail( + "documentation_mark_sanitizer", sanitize_event, priority=10 +) +ctx.register_scope_sanitize_start_guardrail( + "documentation_scope_start_sanitizer", sanitize_event, priority=10 +) +ctx.register_scope_sanitize_end_guardrail( + "documentation_scope_end_sanitizer", sanitize_event, priority=10 +) +ctx.register_llm_sanitize_request_guardrail( + "documentation_llm_request_sanitizer", + sanitize_llm_request, + priority=10, +) +``` + +The equivalent Rust closure always returns a future. The codec proxy is asynchronous in +a worker because decode and encode are authenticated host-runtime RPCs. + +```rust +context.register_llm_sanitize_request_guardrail( + "documentation_llm_request_sanitizer", + 10, + { + let keys = config.observe.redact_keys.clone(); + move |mut request, codec_context| { + let keys = keys.clone(); + async move { + if let Some(codec) = codec_context.resolve_codec() { + let annotated = codec.decode(&request).await?; + let annotated = serde_json::to_value(annotated) + .map(|value| redact(value, &keys)) + .and_then(serde_json::from_value) + .map_err(|error| { + WorkerSdkError::InvalidInput(error.to_string()) + })?; + request = codec.encode(&annotated, &request).await?; + } + request.content = redact(request.content, &keys); + Ok(Some(request)) + } + } + }, +); +``` + +The codec object is scoped to this invocation. Its opaque capability expires when the +callback finishes, even if the worker retained a language-level object that previously +referenced it. + +## Return Complete Request Outcomes + +Request intercepts affect real execution. The LLM form returns more than a provider +request because Relay must carry its normalized annotation and accounting separately. + + + +```python +priority = requests["priority"] +break_chain = requests["break_chain"] +header_name = requests["header_name"] +header_value = requests["header_value"] + +def llm_request(_name, request, annotated): + rewritten = deepcopy(request) + rewritten["headers"] = { + **(rewritten.get("headers") or {}), + header_name: header_value, + } + marks = ( + [PendingMarkSpec( + name="example.python_worker.llm_request", + data={"tag": tag}, + )] + if execution["emit_pending_marks"] + else [] + ) + return LlmRequestInterceptOutcome( + request=rewritten, + annotated_request=annotated, + pending_marks=marks, + optimization_contributions=[ + LlmOptimizationContribution( + producer="examples.python_grpc_worker", + kind="request_rewrite", + applied=True, + ) + ], + ) + +ctx.register_llm_request_intercept( + "documentation_llm_request", + llm_request, + priority=priority, + break_chain=break_chain, +) +``` + + +```rust +context.register_llm_request_intercept( + "documentation_llm_request", + config.requests.priority, + config.requests.break_chain, + { + let header_name = config.requests.header_name.clone(); + let header_value = config.requests.header_value.clone(); + let tag = config.tag.clone(); + let emit_marks = config.execution.emit_pending_marks; + move |_model, mut request, annotated| { + let header_name = header_name.clone(); + let header_value = header_value.clone(); + let tag = tag.clone(); + async move { + request.headers.insert( + header_name, + Json::String(header_value), + ); + let mut outcome = LlmRequestInterceptOutcome::new(request, annotated) + .with_optimization_contribution( + LlmOptimizationContribution::new( + "examples.rust_grpc_worker", + "request_rewrite", + ), + ); + if emit_marks { + outcome = outcome.with_pending_mark( + PendingMarkSpec::builder() + .name("example.rust_worker.llm_request") + .data(json!({ "tag": tag })) + .build(), + ); + } + Ok(outcome) + } + } + }, +); +``` + + + +Returning only `rewritten` would be the wrong callback result for this surface. The tool +request intercept does return JSON directly because it has no annotated request or LLM +optimization accounting. + +## Continuation Behavior + +When the example enables `repeat_downstream`, it starts two concurrent downstream calls +and returns the first response after both calls settle. The first call alone determines +whether the wrapper succeeds. The second call is intentional demonstration code: it can +still consume provider capacity, incur cost, and cause provider-side effects even though +Relay does not expose its result to the application. Its failure is deliberately ignored. + +`ToolNext`, `LlmNext`, and `LlmStreamNext` are host proxies identified by an opaque +continuation ID. Calling one issues a host-runtime RPC under the scope snapshot captured +for that worker invocation. A callback can call a unary proxy zero, one, or multiple +times, including concurrently when the SDK type permits cloning. Each call can repeat +side effects, provider charges, events, and downstream middleware. + +`ToolNext` returns `ToolExecutionResult`, not a raw JSON value. Forwarding middleware +must preserve both `downstream.result` and `downstream.annotation` in its outcome. The +Python example keeps the downstream annotation under `upstream` while adding its own +worker metadata; the Rust example forwards it unchanged. A repeated tool continuation +returns another independent structured result; it still does not expose downstream +pending marks. + +The example uses one ordinary wrapper and one explicitly requested concurrent path. It +does not retry implicitly. Tool pending marks remain in the tool execution outcome; +LLM annotations, pending marks, and optimization contributions remain in the request +intercept outcome. The unary execution callback returns only provider-response JSON. + +`LlmStreamNext` returns a remote stream. The worker transforms each chunk as it arrives +and yields immediately. If the host cancels the invocation or the consumer abandons the +stream, the Python task receives `asyncio.CancelledError` and the Rust callback future is +aborted. Cleanup belongs in `finally` or a drop-safe guard. Acknowledged cancellation +does not prove that external blocking work has stopped. + + + +```python +priority = execution["priority"] +emit_pending_marks = execution["emit_pending_marks"] +tag = settings["tag"] + +async def tool_execution(name, args, next_call): + downstream = await next_call.call(args) + marks = ( + [PendingMarkSpec( + name="example.python_worker.tool_execution", + data={"tool_name": name, "tag": tag}, + )] + if emit_pending_marks + else [] + ) + return ToolExecutionInterceptOutcome( + result=downstream.result, + annotation={ + "upstream": downstream.annotation, + "worker": {"tool_name": name, "tag": tag}, + }, + pending_marks=marks, + ) + +async def llm_execution(_name, request, next_call): + content = request.get("content") + repeat = isinstance(content, dict) and content.get("repeat_downstream") is True + if repeat: + first, _second = await asyncio.gather( + next_call.call(request), + next_call.call(request), + return_exceptions=True, + ) + if isinstance(first, BaseException): + raise first + return first + return await next_call.call(request) + +async def llm_stream_execution(_name, request, next_call): + async for chunk in next_call.call(request): + if isinstance(chunk, dict): + yield {**chunk, "plugin_stream": True} + else: + yield chunk + +ctx.register_tool_execution_intercept( + "documentation_tool_execution", tool_execution, priority=priority +) +ctx.register_llm_execution_intercept( + "documentation_llm_execution", llm_execution, priority=priority +) +ctx.register_llm_stream_execution_intercept( + "documentation_llm_stream_execution", + llm_stream_execution, + priority=priority, +) +``` + + +```rust +context.register_tool_execution_intercept( + "documentation_tool_execution", + config.execution.priority, + { + let emit_marks = config.execution.emit_pending_marks; + move |_name, request, next| async move { + let result = next.call(request).await?; + let mut outcome = ToolExecutionInterceptOutcome::from(result); + if emit_marks { + outcome = outcome.with_pending_mark( + PendingMarkSpec::builder() + .name("example.rust_worker.tool_execution") + .build(), + ); + } + Ok(outcome) + } + }, +); + +context.register_llm_execution_intercept( + "documentation_llm_execution", + config.execution.priority, + move |_model, request, next| async move { + if request.content + .get("repeat_downstream") + .and_then(Json::as_bool) + .unwrap_or(false) + { + let repeated = next.clone(); + let (first, _second) = tokio::join!( + repeated.call(request.clone()), + next.call(request), + ); + first + } else { + next.call(request).await + } + }, +); + +context.register_llm_stream_execution_intercept( + "documentation_llm_stream_execution", + config.execution.priority, + move |_model, request, next| async move { + let downstream = next.call(request).await?; + let mapped: JsonStream = Box::pin(downstream.map(|chunk| { + chunk.map(|mut value| { + if let Some(object) = value.as_object_mut() { + object.insert("plugin_stream".into(), Json::Bool(true)); + } + value + }) + })); + Ok(mapped) + }, +); +``` + + + +The second unary result is awaited even though the first response is selected. That +prevents an unobserved continuation from outliving the worker callback. In the stream +case, each error remains an error item and no chunks are requested before the consumer +polls the mapped stream. + +## Verify the Shared Contract + +Use the following procedure to verify equivalent behavior across the two worker SDKs: + +1. Assert that registration returns exactly 15 unique surface and local-name pairs. +2. Exercise all three event sanitizers and both tool and LLM sanitizer directions; prove + that only observability values change. +3. Block configured tool and model names, rewrite allowed requests, and preserve an + annotated LLM request through later request intercepts and the managed start event. +4. Call each unary continuation once, then use the explicit repeated path to call it + twice concurrently and account for both downstream invocations. +5. Consume a transformed stream incrementally, then cancel a second stream and confirm + worker cleanup. +6. Inspect emitted outcomes to ensure pending marks and optimization contributions are + not present in application results. + +Success means Python and Rust exhibit the same Relay semantics despite their different +callback syntax. The code examples show each callback contract, while the atomic Python +example tests and worker SDK integration suites verify callback behavior, authenticated +transport, and host invocation. diff --git a/docs/build-plugins/workers/python.mdx b/docs/build-plugins/workers/python.mdx new file mode 100644 index 000000000..6b38886fd --- /dev/null +++ b/docs/build-plugins/workers/python.mdx @@ -0,0 +1,196 @@ +--- +title: "Python Worker" +description: "Build, package, activate, verify, and remove the checked Python worker." +position: 31 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +The `examples/python-grpc-worker-plugin` package uses the 0.8.0 +`nemo-relay-plugin` SDK and the shared documentation configuration. Its worker registers +all 15 surfaces, accepts synchronous and asynchronous callback forms where the +Python SDK permits them, and relies on the SDK for protobuf stubs, the authenticated +server, and cooperative task cancellation. + +## Test the Package + +Run the package's atomic tests before creating a managed environment: + +1. Enter the example directory and run its self-contained test project. + + ```bash + cd examples/python-grpc-worker-plugin + uv run --locked --group test pytest + ``` + + Every test creates its own worker instance and mock host context, and any one test can + be selected by node ID without running the rest of the suite. The tests separate + configuration, JSON Schema, source digest, wheel packaging, registration metadata, + sanitizers, policies, request outcomes, continuations, streams, and runtime cleanup. + A digest mismatch is a packaging failure, not an activation warning. + +## Define the Installable Python Package + +Relay creates the managed environment from the package root named by the manifest. A +minimal `pyproject.toml` therefore needs a standard build backend, a package, and the +0.8 worker SDK dependency: + +```toml +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[project] +name = "nemo-relay-python-grpc-worker-example" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = ["nemo-relay-plugin>=0.8.0"] + +[dependency-groups] +test = ["pytest>=8", "pytest-asyncio>=0.26"] + +[tool.setuptools.packages.find] +where = ["."] +include = ["nemo_relay_python_grpc_worker_example"] +``` + +The module entrypoint is an async function, not a server factory or a prebound port. +Relay supplies the authenticated endpoint and activation credentials when it starts the +managed command. + +```python +from nemo_relay_plugin import DiagnosticLevel, WorkerPlugin, serve_plugin + +class ExamplePythonWorker(WorkerPlugin): + plugin_id = "examples.python_grpc_worker" + allows_multiple_components = False + + def validate(self, config): + return validate_config(config) + + def register(self, ctx, config): + diagnostics = validate_config(config) + errors = [ + item for item in diagnostics + if item.level == DiagnosticLevel.ERROR + ] + if errors: + raise ValueError(errors[0].message) + settings = normalized_config(config) + if settings["observe"]["enabled"]: + self._register_observation( + ctx, settings["tag"], settings["observe"] + ) + if settings["requests"]["enabled"]: + self._register_requests( + ctx, + settings["tag"], + settings["requests"], + settings["execution"], + ) + self._register_runtime(ctx, settings["tag"], settings["runtime"]) + if settings["execution"]["enabled"]: + self._register_execution(ctx, settings["tag"], settings["execution"]) + +async def main(): + await serve_plugin(ExamplePythonWorker()) +``` + +Relay calls validation before registration, but `register` still rejects invalid direct +use instead of assuming every possible host followed the expected sequence. + +The corresponding manifest tells Relay to install this directory and import `main` from +the installed module. Calculate the `` placeholder from the current +`worker.py`; changing that file requires a new digest. + +```toml +manifest_version = 1 + +[plugin] +id = "examples.python_grpc_worker" +kind = "worker" + +[compat] +relay = ">=0.8.0,<1.0" +worker_protocol = "grpc-v1" + +[capabilities] +items = ["plugin_worker", "config_schema"] + +[config_schema] +path = "config.schema.json" + +[source] +manifest_root = "." +artifact = "nemo_relay_python_grpc_worker_example/worker.py" + +[integrity] +sha256 = "sha256:" + +[load] +runtime = "python" +entrypoint = "nemo_relay_python_grpc_worker_example.worker:main" +``` + +The manifest uses `relay = ">=0.8.0,<1.0"` because Relay 0.8 changes the `grpc-v1` +tool-result boundary. `ToolNext.call()` returns `ToolExecutionResult`, whose `result` +contains the application payload and whose optional `annotation` remains adjacent opaque +metadata. The protocol identifier stays `grpc-v1`, but workers built against earlier +generated bindings cannot decode the current structural result messages. + +## Install and Activate the Package + +Use the following procedure to install the package into Relay's managed environment and +start the worker: + +1. From `examples/python-grpc-worker-plugin`, create a clean temporary Relay state and + add the manifest. + + ```bash + relay_tmp="$(mktemp -d)" + relay_config="$relay_tmp/gateway.toml" + : > "$relay_config" + nemo-relay --config "$relay_config" plugins add ./relay-plugin.toml + ``` + + `plugins add` creates an isolated managed environment and installs + `source.manifest_root` with pip. Set `NEMO_RELAY_PYTHON` only for this add operation + when Relay must use a non-default base interpreter. Standard pip index, proxy, + certificate, and wheelhouse variables control dependency resolution. + +2. Enable the component and start Relay. + + ```bash + nemo-relay --config "$relay_config" plugins enable examples.python_grpc_worker + nemo-relay --config "$relay_config" --bind 127.0.0.1:4040 + ``` + + The activation report should identify `examples.python_grpc_worker`, and the worker + handshake should advertise all 15 supported surfaces. + +## Verify Behavior and Cleanup + +Use the following procedure to verify each callback family and clean up the managed +environment: + +1. Exercise one allowed and one blocked tool, one allowed and one blocked model, a unary + LLM continuation, and a multi-chunk stream. Confirm configured headers, sanitized + event fields, preserved annotations, pending marks, and lazy chunk transformation. +2. Cancel a long-running async callback and abandon a worker stream. Confirm the SDK + task receives cancellation and the worker's `finally` cleanup runs. A synchronous + callback cannot be preempted, so the example keeps synchronous work bounded. +3. Emit a mark, use a nested scope, create and bind an isolated stack, then force a + failure. Confirm the prior scope context is restored and the stack is dropped. +4. Stop Relay with `Ctrl+C`, then remove the plugin and delete the temporary state from + the shell where `relay_tmp` and `relay_config` remain defined. + + ```bash + nemo-relay --config "$relay_config" plugins remove examples.python_grpc_worker + rm -rf -- "$relay_tmp" + ``` + +`plugins remove` deletes the Relay-managed environment. Copying a Python worker manifest +into `plugins.toml` is not an equivalent installation path because no attested environment +would exist. Success means the managed environment is created and later removed, every +feature group has an observable call-path result, cancellation cleanup runs, and no +worker process remains after shutdown. diff --git a/docs/build-plugins/workers/runtime-events-and-scopes.mdx b/docs/build-plugins/workers/runtime-events-and-scopes.mdx new file mode 100644 index 000000000..713da5d19 --- /dev/null +++ b/docs/build-plugins/workers/runtime-events-and-scopes.mdx @@ -0,0 +1,161 @@ +--- +title: "Runtime Events and Scopes" +description: "Use worker host-runtime marks, scopes, stack binding, and cleanup." +position: 34 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +Worker code cannot manipulate host scope objects directly. The SDK exposes an +authenticated `PluginRuntime` proxy whose operations preserve the invocation's captured +scope context while the `RelayHostRuntime` service performs the real work. + +## Runtime Operation Map + +| Intent | Rust Worker SDK | Python Worker SDK | +|---|---|---| +| Emit a mark | Await `emit_mark` with name, data, and metadata. | Await `emit_mark` with the same JSON fields. | +| Create or drop an isolated stack | Await `create_scope_stack` and `drop_scope_stack`. | Await the same operations and retain the returned stack ID only for its owned lifetime. | +| Bind and restore a stack | Run a future through `with_scope_stack`. | Enter `bind_scope_stack`; use `clear_scope_stack` when code must deliberately run without a bound stack. | +| Push and pop a scope | Await `push_scope`, retain its handle, and await `pop_scope`. | Await the same pair; the handle, not a guessed scope ID, owns the pop. | +| Inspect inherited context | The runtime proxy carries the task scope snapshot. | `current_scope_stack_id` and `current_parent_scope_id` expose the current binding. | + +Every host request carries the activation ID, token, and relevant scope context. A worker +must not expose, log, or persist the activation token or codec capability IDs. Those +values identify a live local capability and expire with the activation or invocation. + +## Execute the Complete Cleanup Sequence + +The Python helper pushes a child scope, emits the ordinary mark inside it, and closes the +scope by its returned handle. It then binds an isolated stack only for work that should +have independent parentage. The `finally` block drops the stack even if isolated mark +emission fails or the callback is cancelled. + +```python +async def emit_runtime_events(ctx, tag, settings): + handle = await ctx.runtime.push_scope( + "example.python_worker.request", + scope_type=ScopeType.CUSTOM, + data={"tag": tag}, + ) + try: + if settings["emit_marks"]: + await ctx.runtime.emit_mark( + "example.python_worker.tool_request", + {"tag": tag}, + ) + except BaseException: + try: + await ctx.runtime.pop_scope(handle, metadata={"failed": True}) + except BaseException: + pass + raise + else: + await ctx.runtime.pop_scope(handle, output={"done": True}) + + if settings["emit_isolated_scope"]: + stack_id = await ctx.runtime.create_scope_stack() + try: + with ctx.runtime.bind_scope_stack(stack_id): + if settings["emit_marks"]: + await ctx.runtime.emit_mark( + "example.python_worker.isolated.mark", + {"tag": tag}, + ) + finally: + await ctx.runtime.drop_scope_stack(stack_id) +``` + +The checked Rust worker performs the same operations. `with_scope_stack` accepts a +closure that creates the bound future, restores the previous task binding afterward, +and returns the future's result. Stack drop is attempted even when that result is an +error, and both results are checked before the middleware returns. + +```rust +async fn emit_runtime_events( + runtime: &PluginRuntime, + tag: &str, + config: &RuntimeConfig, +) -> Result<()> { + let handle = runtime.push_scope( + None, + "example.rust_worker.request", + ScopeType::Custom, + Some(json!({ "tag": tag })), + None, + None, + ).await?; + let work = if config.emit_marks { + runtime.emit_mark( + "example.rust_worker.request.seen", + Some(json!({ "tag": tag })), + None, + ).await + } else { + Ok(()) + }; + match work { + Ok(()) => runtime.pop_scope( + &handle, + Some(json!({ "done": true })), + None, + ).await?, + Err(error) => { + let _ = runtime.pop_scope( + &handle, + None, + Some(json!({ "failed": true })), + ).await; + return Err(error); + } + } + + if config.emit_isolated_scope { + let stack = runtime.create_scope_stack().await?; + let emitted = runtime.with_scope_stack(&stack, || async { + runtime.emit_mark( + "example.rust_worker.isolated.mark", + Some(json!({ "tag": tag })), + None, + ).await + }).await; + let dropped = runtime.drop_scope_stack(&stack).await; + emitted?; + dropped?; + } + Ok(()) +} +``` + +Both implementations pop the handle exactly once. Successful work supplies scope output; +failed work supplies failure metadata and then propagates the original callback error. + +## Clean Up Under Failure + +The examples use a structured cleanup sequence: capture the previous binding, create an +isolated stack only when configured, bind it, push a scope, run plugin work, pop the scope +with success or error metadata, restore the previous binding, and drop the isolated +stack. Python uses `try`/`finally`; Rust uses explicit result handling and awaits cleanup +before returning. + +Shutdown can race with these operations. The worker stops accepting new invocations, +cancels active callbacks, and continues only the bounded cleanup that the SDK can still +authenticate. An unreachable host can reject final cleanup, so the worker also releases +its local handles and terminates rather than retrying forever. + +## Verify Parentage and Restoration + +Use the following procedure to verify scope parentage, restoration, and failure cleanup: + +1. Invoke middleware inside a managed LLM scope and emit a mark through the runtime + proxy. Confirm the mark's parent is the invocation scope. +2. Push and pop a child custom scope and verify its start and end event ordering. +3. Create and bind an isolated stack, emit another mark, and confirm it belongs to the + isolated root rather than the application call. +4. Raise an error after the push and confirm the pop, prior binding restoration, and + stack drop still occur. +5. Cancel the callback during runtime work and repeat the same cleanup assertions. + +Success means marks and scopes have intentional parentage, invocation tokens stay +private, no stack remains owned after failure or cancellation, and subsequent worker +callbacks receive their own unmodified scope snapshots. diff --git a/docs/build-plugins/workers/rust.mdx b/docs/build-plugins/workers/rust.mdx new file mode 100644 index 000000000..d5ba46030 --- /dev/null +++ b/docs/build-plugins/workers/rust.mdx @@ -0,0 +1,197 @@ +--- +title: "Rust Worker" +description: "Build, digest, activate, verify, and stop the checked Rust grpc-v1 worker." +position: 32 +--- +{/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +SPDX-License-Identifier: Apache-2.0 */} + +The `examples/rust-grpc-worker-plugin` project is the compiled counterpart to the Python +worker. It depends on `nemo-relay-worker` 0.8.0, implements `WorkerPlugin`, registers all +15 surfaces, and lets the SDK create the authenticated `grpc-v1` server from the +activation environment Relay provides. + +## Build and Calculate Integrity + +Build the executable and materialize its platform-specific manifest as follows: + +1. Run the example from its own directory. + + ```bash + cargo test + cargo build + ``` + +2. Copy `relay-plugin.toml` to `relay-plugin.local.toml` and replace + `` with `nemo-relay-rust-grpc-worker-plugin-example` on macOS or + Linux, or the same name with `.exe` on Windows. + +3. Calculate the promised digest before registration. On macOS run: + + ```bash + shasum -a 256 target/debug/ + ``` + + On Linux run `sha256sum target/debug/`. In PowerShell run + `Get-FileHash -Algorithm SHA256 target/debug/`. Put the lowercase + hexadecimal value after `sha256:` in `[integrity].sha256`. + +Success at this stage means tests pass, the manifest entrypoint resolves to the built +executable, and integrity describes the exact executable that will start. + +The local manifest retains the checked package identity and replaces only the executable +name and digest placeholders: + +```toml +manifest_version = 1 + +[plugin] +id = "examples.rust_grpc_worker" +kind = "worker" + +[compat] +relay = ">=0.8.0,<1.0" +worker_protocol = "grpc-v1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_worker", "config_schema"] + +[config_schema] +path = "config.schema.json" + +[source] +artifact = "target/debug/" + +[integrity] +sha256 = "sha256:" + +[load] +runtime = "rust" +entrypoint = "target/debug/" +``` + +The worker speaks `grpc-v1`, whose Relay 0.8 tool-result baseline requires +`relay = ">=0.8.0,<1.0"`. `ToolNext::call` returns `ToolExecutionResult`, preserving an +application payload and optional opaque annotation separately from Relay-owned pending +marks. Rebuild the worker when adopting this SDK release; the protocol name remains +`grpc-v1`, but an earlier generated binding cannot read the structural result messages. + +The artifact and entrypoint paths must identify the same executable. Relay verifies the +artifact digest before it starts the command, and the worker handshake must return the +same plugin identity and `grpc-v1` protocol. + +## Register and Start the Worker + +Use the following procedure to register the manifest and inspect worker activation: + +1. From the repository root, validate, add, and enable the local manifest. + + ```bash + nemo-relay plugins validate ./examples/rust-grpc-worker-plugin/relay-plugin.local.toml + nemo-relay plugins add --user ./examples/rust-grpc-worker-plugin/relay-plugin.local.toml + nemo-relay plugins enable examples.rust_grpc_worker + ``` + +2. Start Relay from the repository root. Inspect the activation report + and confirm the handshake reports plugin identity, SDK and runtime metadata, + `grpc-v1`, multiple-component behavior, and all 15 surfaces. + + ```bash + nemo-relay --bind 127.0.0.1:4040 + ``` + +## Build the Worker Implementation + +The example uses the current public worker crate and creates both a library for focused +tests and the executable named in the manifest. + +```toml +[package] +name = "nemo-relay-rust-grpc-worker-plugin-example" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +futures-util = "0.3" +nemo-relay-worker = { version = "0.8.0", path = "../../crates/worker" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } + +[lib] +name = "nemo_relay_rust_grpc_worker_plugin_example" + +[[bin]] +name = "nemo-relay-rust-grpc-worker-plugin-example" +path = "src/main.rs" +``` + +`WorkerPlugin::register` is a synchronous description step. It parses the validated JSON +and fills the `PluginContext` with callbacks; the callbacks themselves return futures. + +```rust +pub struct DocumentationWorker; + +impl WorkerPlugin for DocumentationWorker { + fn plugin_id(&self) -> &str { + "examples.rust_grpc_worker" + } + + fn allows_multiple_components(&self) -> bool { + false + } + + fn validate(&self, config: &Json) -> Vec { + config::validate(config) + } + + fn register(&self, context: &mut PluginContext, config: &Json) -> Result<()> { + let settings = ExampleConfig::parse(config) + .map_err(WorkerSdkError::InvalidInput)?; + if settings.observe.enabled { + register_observation(context, &settings); + } + if settings.requests.enabled { + register_requests(context, &settings); + } + register_execution(context, &settings); + Ok(()) + } +} +``` + +The binary contains no transport configuration. `serve_plugin` reads the activation +environment, starts the authenticated SDK service, and stays alive until Relay sends the +shutdown stage. + +```rust +use nemo_relay_rust_grpc_worker_plugin_example::DocumentationWorker; +use nemo_relay_worker::{Result, serve_plugin}; + +#[tokio::main] +async fn main() -> Result<()> { + serve_plugin(DocumentationWorker).await +} +``` + +## Exercise and Remove the Worker + +Use the following procedure to verify the registered callbacks and stop the worker +cleanly: + +1. Exercise allowed and blocked calls, unary and streaming continuations, codec decode and + encode proxies, pending marks, optimization contributions, nested and isolated scopes, + and cancellation. +2. Disable and remove the component only after in-flight invocations have settled. + + ```bash + nemo-relay plugins disable examples.rust_grpc_worker + nemo-relay plugins remove examples.rust_grpc_worker + ``` + +Success means shutdown rejects new invocations, cancels or drains active work, closes the +authenticated endpoint, stops the process, and leaves no owned runtime registrations. diff --git a/docs/configure-plugins/discoverable-plugins.mdx b/docs/configure-plugins/discoverable-plugins.mdx index 8860f9d2b..43a050dcc 100644 --- a/docs/configure-plugins/discoverable-plugins.mdx +++ b/docs/configure-plugins/discoverable-plugins.mdx @@ -5,8 +5,6 @@ position: 3 --- {/* SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0 */} - - Use this guide to add a manifest-backed plugin that another author has packaged. Discoverable plugins are separate from built-in `[[components]]`: their `relay-plugin.toml` manifest describes a native shared library or a @@ -76,6 +74,8 @@ signature and trusted public key. Use `nemo-relay plugins validate ` to evaluate the resolved host policy and artifact trust evidence before you run the gateway. +The following policy requires a valid signature from one trusted Ed25519 public key: + ```toml [plugins.policy.defaults] startup = "required" @@ -101,18 +101,10 @@ Use `[[plugins.policy.rules]]` to apply an effect by `match_kind` or specific plugin. Rules and overrides can set `allowed`, `startup`, `attestation`, and `trusted_public_keys`. -## Select the Authoring Guide - -Ask the plugin author for the correct manifest and artifact. Refer to these -guides when you need to understand the package: - -- [Native Dynamic Plugins (Rust)](/build-plugins/dynamic-plugins/native-dynamic/about) covers - in-process Rust shared libraries and the native ABI. -- [Build a Rust Native Plugin](/build-plugins/dynamic-plugins/native-dynamic/rust-native-plugin-example) - provides the Rust SDK example for a native shared library. -- [gRPC Worker Plugins (Rust)](/build-plugins/dynamic-plugins/grpc-worker/rust/about) covers - Relay-managed Rust workers. -- [gRPC Worker Plugins (Python)](/build-plugins/dynamic-plugins/grpc-worker/python/about) - covers Relay-managed Python workers. -- [gRPC Worker Protocol Overview](/build-plugins/dynamic-plugins/grpc-worker/grpc-worker-protocol) describes the - shared `grpc-v1` protocol. +Ask the plugin author for the correct manifest and artifact. If you also need +to inspect or rebuild the package, the [native plugin guide](/build-plugins/native/about) +explains in-process Rust shared libraries, while the [worker plugin +guide](/build-plugins/workers/about) covers Relay-managed Rust, Python, and +custom-command workers. Protocol implementers can use the [grpc-v1 protocol +reference](/build-plugins/workers/grpc-v1-protocol) to inspect the complete +transport contract. diff --git a/docs/index.yml b/docs/index.yml index 26ebe49cc..42646bb6f 100644 --- a/docs/index.yml +++ b/docs/index.yml @@ -62,21 +62,84 @@ navigation: - page: "About" path: ./build-plugins/about.mdx slug: about - - folder: ./build-plugins/language-binding - title: "Language Binding" - title-source: frontmatter - - section: "Dynamic Plugins" - slug: dynamic-plugins + - section: "Plugin Fundamentals" + slug: fundamentals + contents: + - page: "Plugin Shape" + path: ./build-plugins/plugin-shape.mdx + slug: plugin-shape + - page: "Configuration and Validation" + path: ./build-plugins/configuration-and-validation.mdx + slug: configuration-and-validation + - page: "PluginContext" + path: ./build-plugins/plugin-context.mdx + slug: plugin-context + - section: "Language Binding Plugins" + slug: language-binding + contents: + - page: "About" + path: ./build-plugins/language-binding/about.mdx + slug: about + - page: "Validate Configuration" + path: ./build-plugins/language-binding/validate-configuration.mdx + slug: validate-configuration + - page: "Register Behavior" + path: ./build-plugins/language-binding/register-behavior.mdx + slug: register-behavior + - page: "Advanced Configuration" + path: ./build-plugins/language-binding/advanced-configuration.mdx + slug: advanced-configuration + - page: "Runnable Examples" + path: ./build-plugins/language-binding/code-examples.mdx + slug: code-examples + - page: "Package Discoverable Plugins" + path: ./build-plugins/package-discoverable-plugins.mdx + slug: package-discoverable-plugins + - section: "Native Dynamic Plugins" + slug: native + contents: + - page: "About" + path: ./build-plugins/native/about.mdx + slug: about + - page: "Build and Package" + path: ./build-plugins/native/build-and-package.mdx + slug: build-and-package + - page: "Observe and Sanitize" + path: ./build-plugins/native/observe-and-sanitize.mdx + slug: observe-and-sanitize + - page: "Control Requests" + path: ./build-plugins/native/control-requests.mdx + slug: control-requests + - page: "Wrap Execution" + path: ./build-plugins/native/wrap-execution.mdx + slug: wrap-execution + - page: "Runtime Events and Scopes" + path: ./build-plugins/native/runtime-events-and-scopes.mdx + slug: runtime-events-and-scopes + - page: "Native ABI Reference" + path: ./build-plugins/native/native-abi-reference.mdx + slug: native-abi-reference + - section: "gRPC Worker Plugins" + slug: workers contents: - - page: "Discoverable Plugins" - path: ./build-plugins/dynamic-plugins/about.mdx + - page: "About" + path: ./build-plugins/workers/about.mdx slug: about - - folder: ./build-plugins/dynamic-plugins/native-dynamic - title: "Native Dynamic" - title-source: frontmatter - - folder: ./build-plugins/dynamic-plugins/grpc-worker - title: "gRPC Worker" - title-source: frontmatter + - page: "Python Worker" + path: ./build-plugins/workers/python.mdx + slug: python + - page: "Rust Worker" + path: ./build-plugins/workers/rust.mdx + slug: rust + - page: "Middleware and Continuations" + path: ./build-plugins/workers/middleware-and-continuations.mdx + slug: middleware-and-continuations + - page: "Runtime Events and Scopes" + path: ./build-plugins/workers/runtime-events-and-scopes.mdx + slug: runtime-events-and-scopes + - page: "grpc-v1 Protocol Reference" + path: ./build-plugins/workers/grpc-v1-protocol.mdx + slug: grpc-v1-protocol - folder: ./contribute title: "Contribute" slug: contribute diff --git a/docs/reference/llm-request-intercept-outcomes.mdx b/docs/reference/llm-request-intercept-outcomes.mdx index 6e87b1eef..2f63b664f 100644 --- a/docs/reference/llm-request-intercept-outcomes.mdx +++ b/docs/reference/llm-request-intercept-outcomes.mdx @@ -89,15 +89,13 @@ flowchart TD The following callbacks return the same logical outcome in their native type or object shape: -- Python callbacks return `LLMRequestInterceptOutcome`. -- Rust callbacks return `LlmRequestInterceptOutcome`. -- Node.js callbacks return `{ request, annotated?, pendingMarks? }`. - JavaScript pending-mark DTOs use `categoryProfile`; canonical JSON retains - `pending_marks` and `category_profile`. -- Public C callbacks return one owned canonical outcome JSON string, and native - ABI v2 callbacks return one host-owned outcome JSON string. -- Rust and Python `grpc-v1` worker SDKs return their canonical outcome in a - `JsonEnvelope` with schema `nemo.relay.LlmRequestInterceptOutcome@2`. +| Surface | LLM request-intercept outcome | +| --- | --- | +| Python | `LLMRequestInterceptOutcome` | +| Rust | `LlmRequestInterceptOutcome` | +| Node.js | `{ request, annotated?, pendingMarks? }`; JavaScript pending-mark DTOs use `categoryProfile`, while canonical JSON retains `pending_marks` and `category_profile` | +| Public C and native ABI v2 | One owned or host-owned canonical outcome JSON string, respectively | +| Rust and Python `grpc-v1` workers | A `JsonEnvelope` with schema `nemo.relay.LlmRequestInterceptOutcome@2` | The standalone request-intercept helper returns the complete outcome but does not emit its pending marks because it does not own an LLM lifecycle. @@ -150,7 +148,7 @@ edited annotation. Header edits continue to use `request.headers`. An attempt to change the raw body while its request codec is active returns an explicit error before the provider callback. -## Related Topics - -- [Tool Execution Intercept Outcomes](/reference/tool-execution-intercept-outcomes) -- [gRPC Worker Protocol Overview](/build-plugins/dynamic-plugins/grpc-worker/grpc-worker-protocol) +Tool execution intercepts have a smaller result-and-marks contract, documented +in [Tool Execution Intercept Outcomes](/reference/tool-execution-intercept-outcomes). +Worker authors can trace both envelopes through the [grpc-v1 protocol +reference](/build-plugins/workers/grpc-v1-protocol). diff --git a/docs/reference/support-matrix.mdx b/docs/reference/support-matrix.mdx index e567b5c11..ed5f5ba42 100644 --- a/docs/reference/support-matrix.mdx +++ b/docs/reference/support-matrix.mdx @@ -53,7 +53,7 @@ emulated or cross-architecture worker execution. The process boundary isolates crashes and dependencies, but it is not a security sandbox. Refer to [gRPC Worker Plugin -Concepts](/build-plugins/dynamic-plugins/grpc-worker/about) for manifests, +Overview](/build-plugins/workers/about) for manifests, lifecycle, and trust requirements. ## Coding Agents Supported by the CLI diff --git a/docs/reference/tool-execution-intercept-outcomes.mdx b/docs/reference/tool-execution-intercept-outcomes.mdx index faa499121..e934788b0 100644 --- a/docs/reference/tool-execution-intercept-outcomes.mdx +++ b/docs/reference/tool-execution-intercept-outcomes.mdx @@ -93,26 +93,24 @@ when materialized. Managed callbacks, continuations, and execute helpers use the same logical result in their native type or object shape: -- Python uses `ToolExecutionResult(result, annotation=None)`. -- Rust uses `ToolExecutionResult { result, annotation }` and its constructors. -- Go uses `ToolExecutionResult { Result, Annotation }`. -- Node.js uses `{ result, annotation? }`. -- Public C callbacks use canonical JSON with required `result` and optional - `annotation`. +| Surface | Managed callback or continuation result | +| --- | --- | +| Python | `ToolExecutionResult(result, annotation=None)` | +| Rust | `ToolExecutionResult { result, annotation }` and its constructors | +| Go | `ToolExecutionResult { Result, Annotation }` | +| Node.js | `{ result, annotation? }` | +| Public C | Canonical JSON with required `result` and optional `annotation` | Execution intercepts add lifecycle-owned pending marks to that shape: -- Python callbacks return `ToolExecutionInterceptOutcome`. -- Rust callbacks and native API 1 plugins return - `ToolExecutionInterceptOutcome`. -- Go callbacks return `ToolExecutionInterceptOutcome`. -- Node.js callbacks return `{ result, annotation?, pendingMarks? }`, where - JavaScript pending-mark DTOs use `categoryProfile`. -- Public C intercept callbacks return canonical JSON with `result`, optional - `annotation`, and optional `pending_marks`. -- `grpc-v1` worker SDKs exchange protobuf `ToolExecutionResult` and - `ToolExecutionInterceptOutcome` messages. Their arbitrary JSON fields use - lossless protobuf `JsonValue` wrappers. +| Surface | Execution-intercept outcome | +| --- | --- | +| Python | `ToolExecutionInterceptOutcome` | +| Rust and native API 1 | `ToolExecutionInterceptOutcome` | +| Go | `ToolExecutionInterceptOutcome` | +| Node.js | `{ result, annotation?, pendingMarks? }`; JavaScript pending-mark DTOs use `categoryProfile` | +| Public C | Canonical JSON with `result`, optional `annotation`, and optional `pending_marks` | +| `grpc-v1` worker SDKs | Protobuf `ToolExecutionResult` and `ToolExecutionInterceptOutcome`; arbitrary JSON fields use lossless `JsonValue` wrappers | Canonical JSON uses `annotation`, `pending_marks`, and `category_profile` across bindings. @@ -146,8 +144,8 @@ Rebuild native plugins and workers against the same NeMo Relay release that hosts them. For binding-specific examples, refer to the [Migration Guides](/reference/migration-guides#return-canonical-tool-execution-results). -## Related Topics - -- [LLM Request Intercept Outcomes](/reference/llm-request-intercept-outcomes) -- [Add Middleware](/instrument-applications/advanced-guide) -- [gRPC Worker Protocol Overview](/build-plugins/dynamic-plugins/grpc-worker/grpc-worker-protocol) +LLM request intercepts use a different outcome because they must preserve an +annotated request and codec authority. That contract is described in [LLM +Request Intercept Outcomes](/reference/llm-request-intercept-outcomes). Worker +authors can trace both outcome envelopes through the [grpc-v1 protocol +reference](/build-plugins/workers/grpc-v1-protocol). diff --git a/docs/resources/glossary.mdx b/docs/resources/glossary.mdx index eb7e01d13..50343dd40 100644 --- a/docs/resources/glossary.mdx +++ b/docs/resources/glossary.mdx @@ -138,7 +138,7 @@ the rest of the documentation can use them consistently. : A dynamic plugin is a discoverable plugin package that the operator registers from a `relay-plugin.toml` manifest. Dynamic plugins are either native shared libraries or local gRPC workers. Refer to [Discoverable - Plugins](/build-plugins/dynamic-plugins/about). + Plugins](/build-plugins/package-discoverable-plugins). ## E @@ -207,7 +207,7 @@ the rest of the documentation can use them consistently. and installs proxy callbacks through the stable `grpc-v1` protocol. It can use a Python, Rust, or command runtime. The process boundary isolates crashes and dependencies, but it is not a security sandbox. Refer to [gRPC Worker - Plugin Concepts](/build-plugins/dynamic-plugins/grpc-worker/about). + Plugins](/build-plugins/workers/about). **Guardrail** : A guardrail is middleware that either blocks execution or rewrites the data @@ -318,7 +318,7 @@ the rest of the documentation can use them consistently. : A native dynamic plugin is a Rust shared library loaded into the Relay process through the native plugin ABI. It must be rebuilt for the host ABI and compatible Relay version, and it is not sandboxed. Refer to [Native - Dynamic Plugins](/build-plugins/dynamic-plugins/native-dynamic/about). + Dynamic Plugins](/build-plugins/native/about). **NeMo Guardrails** : NeMo Guardrails is the deprecated built-in `nemo_guardrails` plugin component diff --git a/examples/language-binding-plugin/README.md b/examples/language-binding-plugin/README.md new file mode 100644 index 000000000..befa1caa3 --- /dev/null +++ b/examples/language-binding-plugin/README.md @@ -0,0 +1,23 @@ + + +# Language Binding Plugin + +These Rust, Python, and Node.js hosts implement the same application-owned +`documentation-plugin`. Every test owns one behavior and can run by itself; +setup and teardown do not depend on another test having run first. + +Run each project from its own directory: + +```bash +(cd rust && cargo test) +(cd python && uv run --locked --group test pytest) +(cd node && npm test) +``` + +The test names separate validation, activation, tool and model policies, request +rewrites, streaming, subscription, and teardown. The `main` program in each +directory is the end-to-end demonstration, while its atomic tests identify the +exact contract that failed. diff --git a/examples/language-binding-plugin/node/main.mjs b/examples/language-binding-plugin/node/main.mjs new file mode 100644 index 000000000..21874db49 --- /dev/null +++ b/examples/language-binding-plugin/node/main.mjs @@ -0,0 +1,351 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createRequire } from 'node:module'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const invokedDirectly = path.resolve(process.argv[1] ?? '') === fileURLToPath(import.meta.url); + +const require = createRequire(import.meta.url); +const relay = require('nemo-relay-node'); +const plugin = require('nemo-relay-node/plugin'); +const typed = require('nemo-relay-node/typed'); +const JSON_CODEC = new typed.JsonPassthrough(); + +export { plugin, relay }; + +export const DEFAULT_CONFIG = { + tag: 'documentation', + observe: { enabled: true, redact_keys: ['secret'] }, + requests: { + enabled: true, + mode: 'enforce', + blocked_tools: ['dangerous_tool'], + blocked_models: ['restricted-model'], + header_name: 'x-nemo-relay-plugin', + header_value: 'documentation', + priority: 20, + break_chain: false, + }, + execution: { enabled: true, priority: 30, emit_pending_marks: true }, + runtime: { emit_marks: true, emit_isolated_scope: true }, +}; + +const GROUP_FIELDS = { + observe: new Set(['enabled', 'redact_keys']), + requests: new Set([ + 'enabled', + 'mode', + 'blocked_tools', + 'blocked_models', + 'header_name', + 'header_value', + 'priority', + 'break_chain', + ]), + execution: new Set(['enabled', 'priority', 'emit_pending_marks']), + runtime: new Set(['emit_marks', 'emit_isolated_scope']), +}; + +function diagnostic(level, code, field, message) { + return { + level, + code: `documentation-plugin.${code}`, + component: 'documentation-plugin', + ...(field === null ? {} : { field }), + message, + }; +} + +function normalizedConfig(config) { + const settings = structuredClone(DEFAULT_CONFIG); + if ('tag' in config) settings.tag = config.tag; + for (const group of Object.keys(GROUP_FIELDS)) { + const value = config[group]; + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + Object.assign(settings[group], value); + } + } + return settings; +} + +function redactJson(value, redactKeys) { + if (Array.isArray(value)) return value.map((item) => redactJson(item, redactKeys)); + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + redactKeys.includes(key) ? '[REDACTED]' : redactJson(item, redactKeys), + ]), + ); + } + return value; +} + +function emitRuntimeEvents(runtime, tag) { + if (runtime.emit_marks) { + relay.event('documentation-plugin.request', null, { tag, secret: 'application-value' }); + } + if (runtime.emit_isolated_scope) { + const isolatedStack = relay.createScopeStack(); + relay.withScopeStack(isolatedStack, () => { + const scope = relay.pushScope('documentation-plugin.isolated', relay.ScopeType.Custom); + relay.popScope(scope); + }); + } +} + +function validateDocumentationConfig(config) { + const diagnostics = []; + if (config === null || typeof config !== 'object' || Array.isArray(config)) { + return [diagnostic('error', 'invalid_config', null, 'plugin config must be a JSON object')]; + } + const topLevel = new Set(['tag', ...Object.keys(GROUP_FIELDS)]); + for (const key of Object.keys(config)) { + if (!topLevel.has(key)) { + diagnostics.push(diagnostic('warning', 'unknown_field', key, `unknown field '${key}' is not supported`)); + } + } + for (const [group, allowed] of Object.entries(GROUP_FIELDS)) { + const value = config[group]; + if (value !== undefined && (value === null || typeof value !== 'object' || Array.isArray(value))) { + diagnostics.push(diagnostic('error', 'invalid_config', group, `${group} must be an object`)); + continue; + } + if (value !== undefined) { + for (const key of Object.keys(value)) { + if (!allowed.has(key)) { + const field = `${group}.${key}`; + diagnostics.push(diagnostic('warning', 'unknown_field', field, `unknown field '${field}' is not supported`)); + } + } + } + } + + const settings = normalizedConfig(config); + const fields = { + tag: settings.tag, + 'observe.enabled': settings.observe.enabled, + 'observe.redact_keys': settings.observe.redact_keys, + 'requests.enabled': settings.requests.enabled, + 'requests.mode': settings.requests.mode, + 'requests.blocked_tools': settings.requests.blocked_tools, + 'requests.blocked_models': settings.requests.blocked_models, + 'requests.header_name': settings.requests.header_name, + 'requests.header_value': settings.requests.header_value, + 'requests.priority': settings.requests.priority, + 'requests.break_chain': settings.requests.break_chain, + 'execution.enabled': settings.execution.enabled, + 'execution.priority': settings.execution.priority, + 'execution.emit_pending_marks': settings.execution.emit_pending_marks, + 'runtime.emit_marks': settings.runtime.emit_marks, + 'runtime.emit_isolated_scope': settings.runtime.emit_isolated_scope, + }; + const stringFields = new Set(['tag', 'requests.mode', 'requests.header_name', 'requests.header_value']); + const arrayFields = new Set(['observe.redact_keys', 'requests.blocked_tools', 'requests.blocked_models']); + const integerFields = new Set(['requests.priority', 'execution.priority']); + for (const [field, value] of Object.entries(fields)) { + const valid = stringFields.has(field) + ? typeof value === 'string' + : arrayFields.has(field) + ? Array.isArray(value) && value.every((item) => typeof item === 'string') + : integerFields.has(field) + ? Number.isInteger(value) + : typeof value === 'boolean'; + if (!valid) { + diagnostics.push(diagnostic('error', 'invalid_config', field, `${field} has the wrong type`)); + } + } + if (typeof settings.tag === 'string' && settings.tag.length === 0) { + diagnostics.push(diagnostic('error', 'invalid_tag', 'tag', 'tag must be a non-empty string')); + } + for (const field of ['requests.header_name', 'requests.header_value']) { + const value = fields[field]; + if (typeof value === 'string' && value.length === 0) { + diagnostics.push(diagnostic('error', 'invalid_header', field, `${field} must be a non-empty string`)); + } + } + if (typeof settings.requests.mode === 'string' && !new Set(['observe', 'enforce']).has(settings.requests.mode)) { + diagnostics.push( + diagnostic('error', 'unsupported_mode', 'requests.mode', 'requests.mode must be either observe or enforce'), + ); + } + return diagnostics; +} + +export const documentationPlugin = { + events: [], + validate(config) { + return validateDocumentationConfig(config); + }, + register(config, context) { + const settings = normalizedConfig(config); + const { observe, requests, execution, runtime } = settings; + if (observe.enabled) { + context.registerSubscriber('events', (event) => documentationPlugin.events.push(event.name)); + const sanitizeEvent = (_event, fields) => ({ + data: redactJson(fields.data, observe.redact_keys), + categoryProfile: redactJson(fields.categoryProfile, observe.redact_keys), + metadata: { ...(redactJson(fields.metadata, observe.redact_keys) ?? {}), plugin_tag: settings.tag }, + }); + context.registerMarkSanitizeGuardrail('mark-sanitizer', 10, sanitizeEvent); + context.registerScopeSanitizeStartGuardrail('scope-start-sanitizer', 10, sanitizeEvent); + context.registerScopeSanitizeEndGuardrail('scope-end-sanitizer', 10, sanitizeEvent); + context.registerToolSanitizeRequestGuardrail('tool-request-sanitizer', 10, (_name, value) => + redactJson(value, observe.redact_keys), + ); + context.registerToolSanitizeResponseGuardrail('tool-response-sanitizer', 10, (_name, value) => + redactJson(value, observe.redact_keys), + ); + context.registerLlmSanitizeRequestGuardrail('llm-request-sanitizer', 10, (request) => ({ + ...request, + content: redactJson(request.content, observe.redact_keys), + })); + context.registerLlmSanitizeResponseGuardrail('llm-response-sanitizer', 10, (response) => + redactJson(response, observe.redact_keys), + ); + } + if (requests.enabled) { + context.registerToolConditionalExecutionGuardrail('tool-policy', 10, (name) => + requests.mode === 'enforce' && requests.blocked_tools.includes(name) ? `tool '${name}' is blocked` : null, + ); + context.registerToolRequestIntercept('tool-request', requests.priority, requests.break_chain, (_name, args) => { + return { ...args, plugin_tag: settings.tag }; + }); + context.registerLlmConditionalExecutionGuardrail('llm-policy', 10, (request) => { + const model = request?.content?.model; + return requests.mode === 'enforce' && requests.blocked_models.includes(model) + ? `model '${model}' is blocked` + : null; + }); + context.registerLlmRequestIntercept( + 'llm-request', + requests.priority, + requests.break_chain, + ({ request, annotated }) => ({ + request: { + ...request, + headers: { + ...request.headers, + [requests.header_name]: requests.header_value, + }, + }, + annotated, + }), + ); + } + if (runtime.emit_marks || runtime.emit_isolated_scope) { + context.registerToolExecutionIntercept('runtime-events', 0, async (args, next) => { + emitRuntimeEvents(runtime, settings.tag); + return await next(args); + }); + } + if (execution.enabled) { + context.registerToolExecutionIntercept('tool-execution', execution.priority, async (args, next) => { + const downstream = await next(args); + return { + ...downstream, + pendingMarks: execution.emit_pending_marks ? [{ name: 'documentation-plugin.tool-complete' }] : [], + }; + }); + context.registerLlmExecutionIntercept('llm-execution', execution.priority, async (request, next) => next(request)); + context.registerLlmStreamExecutionIntercept('llm-stream', execution.priority, async (request, next) => + (await next(request)).map((chunk) => ({ ...chunk, plugin_stream: true })), + ); + } + }, +}; + +export function isolateExampleEnvironment() { + const previousDirectory = process.cwd(); + const previousConfigHome = process.env.XDG_CONFIG_HOME; + const isolationDirectory = mkdtempSync(path.join(tmpdir(), 'nemo-relay-language-plugin-')); + process.chdir(isolationDirectory); + process.env.XDG_CONFIG_HOME = isolationDirectory; + return () => { + process.chdir(previousDirectory); + if (previousConfigHome === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = previousConfigHome; + rmSync(isolationDirectory, { recursive: true, force: true }); + }; +} + +export function config(mode, enabled = true) { + const settings = structuredClone(DEFAULT_CONFIG); + settings.requests.mode = mode; + return { version: 1, components: [plugin.ComponentSpec('documentation-plugin', settings, { enabled })] }; +} + +export async function main() { + const restoreEnvironment = isolateExampleEnvironment(); + documentationPlugin.events.length = 0; + plugin.register('documentation-plugin', documentationPlugin); + console.log('registered:', plugin.listKinds()); + const invalid = plugin.validate(config('invalid')).diagnostics; + const disabledInvalid = plugin.validate(config('invalid', false)).diagnostics; + if (disabledInvalid[0]?.code !== 'documentation-plugin.unsupported_mode') { + throw new Error('disabled invalid configuration must still be validated'); + } + console.log('invalid:', invalid); + let summary; + try { + const report = await plugin.initialize(config('enforce')); + console.log('active:', report); + const toolResult = await relay.toolCallExecute('safe_tool', { value: 1 }, (args) => ({ + result: args, + annotation: { source: 'application' }, + })); + console.log('tool:', toolResult); + const request = { headers: {}, content: { model: 'allowed-model' } }; + const llmResult = await relay.llmCallExecute('allowed-model', request, (rewritten) => ({ + headers: rewritten.headers, + })); + console.log('llm:', llmResult); + const stream = await typed.typedLlmStreamExecute( + 'allowed-model', + request, + async function* streamChunks() { + yield { chunk: 1 }; + yield { chunk: 2 }; + }, + () => {}, + () => ({ done: true }), + JSON_CODEC, + JSON_CODEC, + ); + const streamResults = []; + for (;;) { + const chunk = await stream.next(); + if (chunk === null) break; + streamResults.push(chunk); + console.log('stream:', chunk); + } + relay.event('documentation-event', null, { emitted: true }); + await relay.flushSubscribers(); + console.log('event: documentation-event emitted through plugin sanitizer'); + summary = { + invalid, + report, + tool: toolResult, + llm: llmResult, + stream: streamResults, + events: [...documentationPlugin.events], + }; + } finally { + plugin.clear(); + plugin.deregister('documentation-plugin'); + restoreEnvironment(); + } + console.log('teardown: complete'); + return summary; +} + +if (invokedDirectly) { + main().catch((error) => { + console.error(error); + process.exitCode = 1; + }); +} diff --git a/examples/language-binding-plugin/node/package.json b/examples/language-binding-plugin/node/package.json new file mode 100644 index 000000000..af67532e4 --- /dev/null +++ b/examples/language-binding-plugin/node/package.json @@ -0,0 +1,14 @@ +{ + "name": "nemo-relay-node-language-binding-plugin-example", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "pretest": "npm run build-debug --workspace=nemo-relay-node", + "start": "node main.mjs", + "test": "node --test test-plugin.mjs" + }, + "dependencies": { + "nemo-relay-node": "0.8.0" + } +} diff --git a/examples/language-binding-plugin/node/test-plugin.mjs b/examples/language-binding-plugin/node/test-plugin.mjs new file mode 100644 index 000000000..b935087d1 --- /dev/null +++ b/examples/language-binding-plugin/node/test-plugin.mjs @@ -0,0 +1,243 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { DEFAULT_CONFIG, config, documentationPlugin, isolateExampleEnvironment, plugin, relay } from './main.mjs'; + +function registeredCallbacks() { + const callbacks = new Map(); + const context = new Proxy( + {}, + { + get(_target, method) { + return (...args) => callbacks.set(method, args.at(-1)); + }, + }, + ); + documentationPlugin.register(structuredClone(DEFAULT_CONFIG), context); + return callbacks; +} + +async function withActivePlugin(run, pluginConfig = config('enforce')) { + const restoreEnvironment = isolateExampleEnvironment(); + documentationPlugin.events.length = 0; + try { + plugin.register('documentation-plugin', documentationPlugin); + const preflight = plugin.validate(pluginConfig); + assert.deepEqual(preflight.diagnostics, []); + const report = await plugin.initialize(pluginConfig); + return await run(report); + } finally { + // Scope-end sanitizers run in Relay's queued publication path. Drain that + // work before removing the callbacks that the active component owns. + await relay.flushSubscribers(); + plugin.clear(); + plugin.deregister('documentation-plugin'); + restoreEnvironment(); + } +} + +test('validation accepts a supported mode', () => { + assert.deepEqual(documentationPlugin.validate({ requests: { mode: 'enforce' } }), []); +}); + +test('validation rejects an unsupported mode', () => { + const diagnostics = documentationPlugin.validate({ requests: { mode: 'invalid' } }); + + assert.equal(diagnostics[0].code, 'documentation-plugin.unsupported_mode'); +}); + +test('validation rejects a wrong type', () => { + const diagnostics = documentationPlugin.validate({ requests: { priority: 'high' } }); + + assert.equal(diagnostics[0].code, 'documentation-plugin.invalid_config'); +}); + +test('validation reports a non-object configuration', () => { + const diagnostics = documentationPlugin.validate(null); + + assert.equal(diagnostics[0].code, 'documentation-plugin.invalid_config'); + assert.equal(diagnostics[0].field, undefined); +}); + +for (const [config, field, code] of [ + [{ tag: '' }, 'tag', 'documentation-plugin.invalid_tag'], + [{ requests: { header_name: '' } }, 'requests.header_name', 'documentation-plugin.invalid_header'], + [{ requests: { header_value: '' } }, 'requests.header_value', 'documentation-plugin.invalid_header'], +]) { + test(`validation rejects an empty ${field}`, () => { + const diagnostics = documentationPlugin.validate(config); + + assert.ok(diagnostics.some((diagnostic) => diagnostic.code === code && diagnostic.field === field)); + }); +} + +test('validation warns about an unknown field', () => { + const diagnostics = documentationPlugin.validate({ unexpected: true }); + + assert.equal(diagnostics[0].level, 'warning'); + assert.equal(diagnostics[0].field, 'unexpected'); +}); + +test('disabled invalid configuration is still validated', () => { + const restoreEnvironment = isolateExampleEnvironment(); + plugin.register('documentation-plugin', documentationPlugin); + try { + const report = plugin.validate(config('invalid', false)); + + assert.equal(report.diagnostics[0].code, 'documentation-plugin.unsupported_mode'); + } finally { + plugin.deregister('documentation-plugin'); + restoreEnvironment(); + } +}); + +test('registers every safe plugin surface', () => { + const registrations = registeredCallbacks(); + + assert.deepEqual([...registrations.keys()].sort(), [ + 'registerLlmConditionalExecutionGuardrail', + 'registerLlmExecutionIntercept', + 'registerLlmRequestIntercept', + 'registerLlmSanitizeRequestGuardrail', + 'registerLlmSanitizeResponseGuardrail', + 'registerLlmStreamExecutionIntercept', + 'registerMarkSanitizeGuardrail', + 'registerScopeSanitizeEndGuardrail', + 'registerScopeSanitizeStartGuardrail', + 'registerSubscriber', + 'registerToolConditionalExecutionGuardrail', + 'registerToolExecutionIntercept', + 'registerToolRequestIntercept', + 'registerToolSanitizeRequestGuardrail', + 'registerToolSanitizeResponseGuardrail', + ]); +}); + +test('activation reports no diagnostics', async () => { + await withActivePlugin((report) => { + assert.deepEqual(report.diagnostics, []); + }); +}); + +test('tool requests are rewritten', async () => { + await withActivePlugin(async () => { + const result = await relay.toolCallExecute('safe_tool', { value: 1 }, (args) => ({ + result: args, + annotation: { source: 'application' }, + })); + + assert.deepEqual(result, { + result: { value: 1, plugin_tag: 'documentation' }, + annotation: { source: 'application' }, + }); + }); +}); + +test('tool policy blocks the configured tool', () => { + const policy = registeredCallbacks().get('registerToolConditionalExecutionGuardrail'); + + assert.equal(policy('dangerous_tool', { value: 1 }), "tool 'dangerous_tool' is blocked"); +}); + +test('LLM requests are rewritten', async () => { + await withActivePlugin(async () => { + const result = await relay.llmCallExecute( + 'allowed-model', + { headers: {}, content: { model: 'allowed-model' } }, + (request) => ({ headers: request.headers }), + ); + + assert.equal(result.headers['x-nemo-relay-plugin'], 'documentation'); + }); +}); + +test('LLM policy blocks the configured model', () => { + const policy = registeredCallbacks().get('registerLlmConditionalExecutionGuardrail'); + + assert.equal(policy({ headers: {}, content: { model: 'restricted-model' } }), "model 'restricted-model' is blocked"); +}); + +test('LLM stream chunks are transformed', async () => { + const intercept = registeredCallbacks().get('registerLlmStreamExecutionIntercept'); + + const chunks = await intercept({ headers: {}, content: { model: 'allowed-model' } }, async () => [ + { chunk: 1 }, + { chunk: 2 }, + ]); + + assert.deepEqual(chunks, [ + { chunk: 1, plugin_stream: true }, + { chunk: 2, plugin_stream: true }, + ]); +}); + +test('subscriber observes an emitted event', async () => { + await withActivePlugin(async () => { + relay.event('documentation-event', null, { emitted: true }); + await relay.flushSubscribers(); + + assert.ok(documentationPlugin.events.includes('documentation-event')); + }); +}); + +test('runtime controls emit a mark and an isolated scope only when enabled', async () => { + await withActivePlugin(async () => { + await relay.toolCallExecute('safe_tool', { value: 1 }, (args) => ({ result: args })); + await relay.flushSubscribers(); + + assert.ok(documentationPlugin.events.includes('documentation-plugin.request')); + assert.ok(documentationPlugin.events.includes('documentation-plugin.isolated')); + }); + + const runtimeDisabled = config('enforce'); + runtimeDisabled.components[0].config.runtime = { emit_marks: false, emit_isolated_scope: false }; + await withActivePlugin(async () => { + await relay.toolCallExecute('safe_tool', { value: 1 }, (args) => ({ result: args })); + await relay.flushSubscribers(); + + assert.ok(!documentationPlugin.events.includes('documentation-plugin.request')); + assert.ok(!documentationPlugin.events.includes('documentation-plugin.isolated')); + }, runtimeDisabled); +}); + +test('runtime controls do not depend on request rewriting', async () => { + const requestsDisabled = config('enforce'); + requestsDisabled.components[0].config.requests.enabled = false; + await withActivePlugin(async () => { + await relay.toolCallExecute('safe_tool', { value: 1 }, (args) => ({ result: args })); + await relay.flushSubscribers(); + + assert.ok(documentationPlugin.events.includes('documentation-plugin.request')); + }, requestsDisabled); +}); + +test('teardown removes the plugin kind', async () => { + const restoreEnvironment = isolateExampleEnvironment(); + plugin.register('documentation-plugin', documentationPlugin); + try { + await plugin.initialize(config('enforce')); + plugin.clear(); + assert.equal(plugin.deregister('documentation-plugin'), true); + assert.equal(plugin.listKinds().includes('documentation-plugin'), false); + } finally { + plugin.clear(); + plugin.deregister('documentation-plugin'); + restoreEnvironment(); + } +}); + +test('registration rejects a duplicate kind and missing deregistration is false', () => { + const restoreEnvironment = isolateExampleEnvironment(); + plugin.register('documentation-plugin', documentationPlugin); + try { + assert.throws(() => plugin.register('documentation-plugin', documentationPlugin)); + assert.equal(plugin.deregister('missing-documentation-plugin'), false); + } finally { + plugin.clear(); + plugin.deregister('documentation-plugin'); + restoreEnvironment(); + } +}); diff --git a/examples/language-binding-plugin/python/main.py b/examples/language-binding-plugin/python/main.py new file mode 100644 index 000000000..4003917a5 --- /dev/null +++ b/examples/language-binding-plugin/python/main.py @@ -0,0 +1,406 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Runnable Python host for the application-owned documentation plugin.""" + +from __future__ import annotations + +import asyncio +import os +import tempfile +from copy import deepcopy +from typing import Any + +import nemo_relay +from nemo_relay import llm, plugin, scope, subscribers, tools + +DEFAULT_CONFIG: dict[str, Any] = { + "tag": "documentation", + "observe": {"enabled": True, "redact_keys": ["secret"]}, + "requests": { + "enabled": True, + "mode": "enforce", + "blocked_tools": ["dangerous_tool"], + "blocked_models": ["restricted-model"], + "header_name": "x-nemo-relay-plugin", + "header_value": "documentation", + "priority": 20, + "break_chain": False, + }, + "execution": {"enabled": True, "priority": 30, "emit_pending_marks": True}, + "runtime": {"emit_marks": True, "emit_isolated_scope": True}, +} + +GROUP_FIELDS = { + "observe": {"enabled", "redact_keys"}, + "requests": { + "enabled", + "mode", + "blocked_tools", + "blocked_models", + "header_name", + "header_value", + "priority", + "break_chain", + }, + "execution": {"enabled", "priority", "emit_pending_marks"}, + "runtime": {"emit_marks", "emit_isolated_scope"}, +} + + +def _diagnostic( + level: str, + code: str, + field: str | None, + message: str, +) -> dict[str, str]: + diagnostic = { + "level": level, + "code": f"documentation-plugin.{code}", + "component": "documentation-plugin", + "message": message, + } + if field is not None: + diagnostic["field"] = field + return diagnostic + + +def normalized_config(config: dict[str, Any]) -> dict[str, Any]: + settings = deepcopy(DEFAULT_CONFIG) + if "tag" in config: + settings["tag"] = config["tag"] + for group in GROUP_FIELDS: + if isinstance(config.get(group), dict): + settings[group].update(config[group]) + return settings + + +def redact_json(value: Any, redact_keys: list[str]) -> Any: + if isinstance(value, dict): + return { + key: "[REDACTED]" if key in redact_keys else redact_json(item, redact_keys) for key, item in value.items() + } + if isinstance(value, list): + return [redact_json(item, redact_keys) for item in value] + return value + + +def validate_documentation_config(config: dict[str, Any]) -> list[dict[str, str]]: + diagnostics: list[dict[str, str]] = [] + allowed_top_level = {"tag", *GROUP_FIELDS} + for key in config.keys() - allowed_top_level: + diagnostics.append( + _diagnostic( + "warning", + "unknown_field", + key, + f"unknown field '{key}' is not supported", + ) + ) + for group, allowed in GROUP_FIELDS.items(): + value = config.get(group) + if value is not None and not isinstance(value, dict): + diagnostics.append( + _diagnostic( + "error", + "invalid_config", + group, + f"{group} must be an object", + ) + ) + continue + if isinstance(value, dict): + for key in value.keys() - allowed: + field = f"{group}.{key}" + diagnostics.append( + _diagnostic( + "warning", + "unknown_field", + field, + f"unknown field '{field}' is not supported", + ) + ) + + settings = normalized_config(config) + expected_types = { + "tag": str, + "observe.enabled": bool, + "observe.redact_keys": list, + "requests.enabled": bool, + "requests.mode": str, + "requests.blocked_tools": list, + "requests.blocked_models": list, + "requests.header_name": str, + "requests.header_value": str, + "requests.priority": int, + "requests.break_chain": bool, + "execution.enabled": bool, + "execution.priority": int, + "execution.emit_pending_marks": bool, + "runtime.emit_marks": bool, + "runtime.emit_isolated_scope": bool, + } + for field, expected in expected_types.items(): + group, separator, key = field.partition(".") + value = settings[group][key] if separator else settings[group] + valid = type(value) is expected + if not valid: + diagnostics.append( + _diagnostic( + "error", + "invalid_config", + field, + f"{field} must be a {expected.__name__}", + ) + ) + for field in ("observe.redact_keys", "requests.blocked_tools", "requests.blocked_models"): + group, key = field.split(".") + value = settings[group][key] + if isinstance(value, list) and not all(isinstance(item, str) for item in value): + diagnostics.append( + _diagnostic( + "error", + "invalid_config", + field, + f"{field} must contain only strings", + ) + ) + if isinstance(settings["tag"], str) and not settings["tag"]: + diagnostics.append(_diagnostic("error", "invalid_tag", "tag", "tag must be a non-empty string")) + for field in ("requests.header_name", "requests.header_value"): + group, key = field.split(".") + value = settings[group][key] + if isinstance(value, str) and not value: + diagnostics.append(_diagnostic("error", "invalid_header", field, f"{field} must be a non-empty string")) + requests = settings["requests"] + if isinstance(requests["mode"], str) and requests["mode"] not in {"observe", "enforce"}: + diagnostics.append( + _diagnostic( + "error", + "unsupported_mode", + "requests.mode", + "requests.mode must be either observe or enforce", + ) + ) + return diagnostics + + +class DocumentationPlugin: + def __init__(self) -> None: + self.events: list[str] = [] + + def validate(self, config: dict[str, Any]) -> list[dict[str, str]]: + return validate_documentation_config(config) + + def register(self, config: dict[str, Any], context: plugin.PluginContext) -> None: + settings = normalized_config(config) + tag = settings["tag"] + observe = settings["observe"] + requests = settings["requests"] + execution = settings["execution"] + if observe["enabled"]: + context.register_subscriber("events", lambda event: self.events.append(event.name)) + + def sanitize_event(_event, fields): + return { + "data": redact_json(fields["data"], observe["redact_keys"]), + "category_profile": redact_json(fields["category_profile"], observe["redact_keys"]), + "metadata": { + **(redact_json(fields["metadata"], observe["redact_keys"]) or {}), + "plugin_tag": tag, + }, + } + + context.register_mark_sanitize_guardrail("mark-sanitizer", 10, sanitize_event) + context.register_scope_sanitize_start_guardrail("scope-start-sanitizer", 10, sanitize_event) + context.register_scope_sanitize_end_guardrail("scope-end-sanitizer", 10, sanitize_event) + context.register_tool_sanitize_request_guardrail( + "tool-request-sanitizer", 10, lambda _name, value: redact_json(value, observe["redact_keys"]) + ) + context.register_tool_sanitize_response_guardrail( + "tool-response-sanitizer", 10, lambda _name, value: redact_json(value, observe["redact_keys"]) + ) + + def sanitize_llm_request(request, _codec_context): + return nemo_relay.LLMRequest(request.headers, redact_json(request.content, observe["redact_keys"])) + + context.register_llm_sanitize_request_guardrail("llm-request-sanitizer", 10, sanitize_llm_request) + context.register_llm_sanitize_response_guardrail( + "llm-response-sanitizer", 10, lambda value, _codec_context: redact_json(value, observe["redact_keys"]) + ) + if requests["enabled"]: + context.register_tool_conditional_execution_guardrail( + "tool-policy", + 10, + lambda name, _args: ( + f"tool '{name}' is blocked" + if requests["mode"] == "enforce" and name in requests["blocked_tools"] + else None + ), + ) + context.register_tool_request_intercept( + "tool-request", + requests["priority"], + requests["break_chain"], + lambda _name, args: {**args, "plugin_tag": tag}, + ) + + def llm_policy(request): + model = request.content.get("model") if isinstance(request.content, dict) else None + if requests["mode"] == "enforce" and model in requests["blocked_models"]: + return f"model '{model}' is blocked" + return None + + context.register_llm_conditional_execution_guardrail("llm-policy", 10, llm_policy) + + def llm_request(_name, request, annotated): + return nemo_relay.LLMRequestInterceptOutcome( + nemo_relay.LLMRequest( + {**request.headers, requests["header_name"]: requests["header_value"]}, + request.content, + ), + annotated, + ) + + context.register_llm_request_intercept( + "llm-request", + requests["priority"], + requests["break_chain"], + llm_request, + ) + + if settings["runtime"]["emit_marks"] or settings["runtime"]["emit_isolated_scope"]: + + async def runtime_events(_name, args, next_call): + if settings["runtime"]["emit_marks"]: + scope.event( + "documentation-plugin.request", + data={"tag": tag, "secret": "application-value"}, + ) + if settings["runtime"]["emit_isolated_scope"]: + with nemo_relay.use_scope_stack(nemo_relay.create_scope_stack()): + with scope.scope("documentation-plugin.isolated", nemo_relay.ScopeType.Custom): + pass + downstream = await next_call(args) + return nemo_relay.ToolExecutionInterceptOutcome( + downstream.result, + annotation=downstream.annotation, + ) + + context.register_tool_execution_intercept("runtime-events", 0, runtime_events) + + async def stream_request(_request, next_call): + async for chunk in await next_call(_request): + yield {**chunk, "plugin_stream": True} + + if execution["enabled"]: + + async def tool_execution(_name, args, next_call): + result = await next_call(args) + marks = ( + [nemo_relay.PendingMarkSpec("documentation-plugin.tool-complete")] + if execution["emit_pending_marks"] + else [] + ) + return nemo_relay.ToolExecutionInterceptOutcome( + result.result, + marks, + annotation=result.annotation, + ) + + context.register_tool_execution_intercept("tool-execution", execution["priority"], tool_execution) + + async def llm_execution(_name, request, next_call): + return await next_call(request) + + context.register_llm_execution_intercept("llm-execution", execution["priority"], llm_execution) + context.register_llm_stream_execution_intercept( + "llm-stream", + execution["priority"], + stream_request, + ) + + +def component(mode: str, *, enabled: bool = True) -> plugin.PluginConfig: + settings = deepcopy(DEFAULT_CONFIG) + settings["requests"]["mode"] = mode + return plugin.PluginConfig( + components=[ + plugin.ComponentSpec( + kind="documentation-plugin", + enabled=enabled, + config=settings, + ) + ] + ) + + +async def main() -> dict[str, Any]: + implementation = DocumentationPlugin() + plugin.register("documentation-plugin", implementation) + print("registered:", plugin.list_kinds()) + invalid = plugin.validate(component("invalid"))["diagnostics"] + assert invalid[0]["code"] == "documentation-plugin.unsupported_mode" + disabled_invalid = plugin.validate(component("invalid", enabled=False))["diagnostics"] + assert disabled_invalid[0]["code"] == "documentation-plugin.unsupported_mode" + print("invalid:", invalid) + try: + with tempfile.TemporaryDirectory() as directory: + previous_directory = os.getcwd() + previous_config_home = os.environ.get("XDG_CONFIG_HOME") + os.chdir(directory) + os.environ["XDG_CONFIG_HOME"] = directory + try: + report = await plugin.initialize(component("enforce")) + finally: + os.chdir(previous_directory) + if previous_config_home is None: + os.environ.pop("XDG_CONFIG_HOME", None) + else: + os.environ["XDG_CONFIG_HOME"] = previous_config_home + print("active:", report) + tool_result = await tools.execute( + "safe_tool", + {"value": 1}, + lambda args: nemo_relay.ToolExecutionResult(args, {"source": "application"}), + ) + assert tool_result.result == {"value": 1, "plugin_tag": "documentation"} + assert tool_result.annotation == {"source": "application"} + print("tool:", tool_result) + request = nemo_relay.LLMRequest({}, {"model": "allowed-model"}) + llm_result = await llm.execute("allowed-model", request, lambda req: {"headers": req.headers}) + assert llm_result["headers"]["x-nemo-relay-plugin"] == "documentation" + print("llm:", llm_result) + + async def provider(_request): + yield {"chunk": 1} + yield {"chunk": 2} + + chunks: list[dict[str, Any]] = [] + stream = await llm.stream_execute("allowed-model", request, provider, chunks.append, lambda: {"done": True}) + streamed: list[dict[str, Any]] = [] + async for chunk in stream: + streamed.append(chunk) + print("stream:", chunk) + assert len(streamed) == 2 + assert all(chunk["plugin_stream"] is True for chunk in streamed) + await subscribers.flush_async() + assert implementation.events + print("events:", implementation.events) + finally: + await plugin.clear_async() + plugin.deregister("documentation-plugin") + print("teardown: complete") + assert "documentation-plugin" not in plugin.list_kinds() + return { + "invalid": invalid, + "report": report, + "tool": tool_result, + "llm": llm_result, + "stream": streamed, + "events": implementation.events, + } + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/language-binding-plugin/python/pyproject.toml b/examples/language-binding-plugin/python/pyproject.toml new file mode 100644 index 000000000..3e6f41907 --- /dev/null +++ b/examples/language-binding-plugin/python/pyproject.toml @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[project] +name = "nemo-relay-python-language-binding-plugin-example" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "nemo-relay==0.8.0", +] + +[dependency-groups] +test = [ + "pytest>=8", + "pytest-asyncio>=0.26", +] + +[tool.uv] +package = false + +[tool.uv.sources] +nemo-relay = { path = "../../.." } + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["test_plugin.py"] diff --git a/examples/language-binding-plugin/python/test_plugin.py b/examples/language-binding-plugin/python/test_plugin.py new file mode 100644 index 000000000..cbd7e659a --- /dev/null +++ b/examples/language-binding-plugin/python/test_plugin.py @@ -0,0 +1,227 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Atomic tests for the Python language-binding plugin example.""" + +from __future__ import annotations + +from collections.abc import AsyncIterator +from dataclasses import dataclass +from typing import Any + +import pytest +import pytest_asyncio +from main import DocumentationPlugin, component + +import nemo_relay +from nemo_relay import llm, plugin, subscribers, tools + + +@dataclass +class ActivatedExample: + implementation: DocumentationPlugin + report: dict[str, Any] + + +class RecordingContext: + """Records one registration per context method without touching global state.""" + + def __init__(self) -> None: + self.registrations: list[str] = [] + + def __getattr__(self, name: str): + if not name.startswith("register_"): + raise AttributeError(name) + + def register(_registration_name: str, *_args: Any) -> None: + self.registrations.append(name) + + return register + + +@pytest_asyncio.fixture +async def active_plugin(tmp_path: Any, monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[ActivatedExample]: + """Activate a fresh component and remove every owned registration afterward.""" + + implementation = DocumentationPlugin() + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + plugin.register("documentation-plugin", implementation) + try: + report = await plugin.initialize(component("enforce")) + yield ActivatedExample(implementation, report) + finally: + await plugin.clear_async() + plugin.deregister("documentation-plugin") + + +def test_validation_accepts_supported_mode() -> None: + assert DocumentationPlugin().validate({"requests": {"mode": "enforce"}}) == [] + + +def test_validation_rejects_unsupported_mode() -> None: + diagnostics = DocumentationPlugin().validate({"requests": {"mode": "invalid"}}) + + assert diagnostics[0]["code"] == "documentation-plugin.unsupported_mode" + + +def test_validation_rejects_wrong_type() -> None: + diagnostics = DocumentationPlugin().validate({"requests": {"priority": "high"}}) + + assert diagnostics[0]["code"] == "documentation-plugin.invalid_config" + + +def test_registration_rejects_a_duplicate_kind_and_missing_deregistration_is_false() -> None: + plugin.register("documentation-plugin", DocumentationPlugin()) + try: + with pytest.raises(RuntimeError): + plugin.register("documentation-plugin", DocumentationPlugin()) + assert plugin.deregister("missing-documentation-plugin") is False + finally: + plugin.deregister("documentation-plugin") + + +@pytest.mark.parametrize( + ("config", "field", "code"), + [ + ({"tag": ""}, "tag", "documentation-plugin.invalid_tag"), + ({"requests": {"header_name": ""}}, "requests.header_name", "documentation-plugin.invalid_header"), + ({"requests": {"header_value": ""}}, "requests.header_value", "documentation-plugin.invalid_header"), + ], +) +def test_validation_rejects_empty_required_strings(config: dict[str, Any], field: str, code: str) -> None: + diagnostics = DocumentationPlugin().validate(config) + + assert any(item["code"] == code and item["field"] == field for item in diagnostics) + + +def test_validation_warns_about_unknown_field() -> None: + diagnostics = DocumentationPlugin().validate({"unexpected": True}) + + assert diagnostics[0]["level"] == "warning" + assert diagnostics[0]["field"] == "unexpected" + + +def test_disabled_invalid_component_is_still_validated() -> None: + plugin.register("documentation-plugin", DocumentationPlugin()) + try: + report = plugin.validate(component("invalid", enabled=False)) + assert report["diagnostics"][0]["code"] == "documentation-plugin.unsupported_mode" + finally: + plugin.deregister("documentation-plugin") + + +def test_registers_each_safe_plugin_surface() -> None: + context = RecordingContext() + + DocumentationPlugin().register(component("enforce").components[0].config, context) # type: ignore[arg-type] + + assert set(context.registrations) == { + "register_subscriber", + "register_mark_sanitize_guardrail", + "register_scope_sanitize_start_guardrail", + "register_scope_sanitize_end_guardrail", + "register_tool_sanitize_request_guardrail", + "register_tool_sanitize_response_guardrail", + "register_tool_conditional_execution_guardrail", + "register_tool_request_intercept", + "register_tool_execution_intercept", + "register_llm_sanitize_request_guardrail", + "register_llm_sanitize_response_guardrail", + "register_llm_conditional_execution_guardrail", + "register_llm_request_intercept", + "register_llm_execution_intercept", + "register_llm_stream_execution_intercept", + } + + +async def test_activation_reports_no_diagnostics(active_plugin: ActivatedExample) -> None: + assert active_plugin.report["diagnostics"] == [] + + +async def test_tool_request_is_rewritten(active_plugin: ActivatedExample) -> None: + result = await tools.execute( + "safe_tool", + {"value": 1}, + lambda args: nemo_relay.ToolExecutionResult(args, {"source": "application"}), + ) + + assert result.result == {"value": 1, "plugin_tag": "documentation"} + assert result.annotation == {"source": "application"} + + +async def test_tool_policy_blocks_configured_tool(active_plugin: ActivatedExample) -> None: + with pytest.raises(RuntimeError, match="guardrail rejected"): + await tools.execute("dangerous_tool", {"value": 1}, lambda _args: pytest.fail("provider must not run")) + + +async def test_llm_request_is_rewritten(active_plugin: ActivatedExample) -> None: + request = nemo_relay.LLMRequest({}, {"model": "allowed-model"}) + + result = await llm.execute("allowed-model", request, lambda rewritten: {"headers": rewritten.headers}) + + assert result["headers"]["x-nemo-relay-plugin"] == "documentation" + + +async def test_llm_policy_blocks_configured_model(active_plugin: ActivatedExample) -> None: + request = nemo_relay.LLMRequest({}, {"model": "restricted-model"}) + + with pytest.raises(RuntimeError, match="guardrail rejected"): + await llm.execute("restricted-model", request, lambda _request: pytest.fail("provider must not run")) + + +async def test_llm_stream_is_transformed(active_plugin: ActivatedExample) -> None: + request = nemo_relay.LLMRequest({}, {"model": "allowed-model"}) + + async def provider(_request: Any) -> AsyncIterator[dict[str, int]]: + yield {"chunk": 1} + yield {"chunk": 2} + + stream = await llm.stream_execute("allowed-model", request, provider, lambda _chunk: None, lambda: {"done": True}) + chunks = [chunk async for chunk in stream] + + assert chunks == [ + {"chunk": 1, "plugin_stream": True}, + {"chunk": 2, "plugin_stream": True}, + ] + + +async def test_subscriber_observes_managed_call(active_plugin: ActivatedExample) -> None: + await tools.execute("safe_tool", {"value": 1}, nemo_relay.ToolExecutionResult) + await subscribers.flush_async() + + assert active_plugin.implementation.events + + +async def test_runtime_events_do_not_depend_on_request_rewriting( + tmp_path: Any, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + implementation = DocumentationPlugin() + configuration = component("enforce") + configuration.components[0].config["requests"]["enabled"] = False + plugin.register("documentation-plugin", implementation) + try: + await plugin.initialize(configuration) + await tools.execute("safe_tool", {"value": 1}, nemo_relay.ToolExecutionResult) + await subscribers.flush_async() + + assert "documentation-plugin.request" in implementation.events + finally: + await plugin.clear_async() + plugin.deregister("documentation-plugin") + + +async def test_teardown_removes_plugin_kind(tmp_path: Any, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) + plugin.register("documentation-plugin", DocumentationPlugin()) + try: + await plugin.initialize(component("enforce")) + await plugin.clear_async() + assert plugin.deregister("documentation-plugin") is True + assert "documentation-plugin" not in plugin.list_kinds() + finally: + await plugin.clear_async() + plugin.deregister("documentation-plugin") diff --git a/examples/language-binding-plugin/python/uv.lock b/examples/language-binding-plugin/python/uv.lock new file mode 100644 index 000000000..c8335628a --- /dev/null +++ b/examples/language-binding-plugin/python/uv.lock @@ -0,0 +1,154 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "nemo-relay" +source = { directory = "../../../" } + +[package.metadata] +requires-dist = [ + { name = "aiohttp", marker = "extra == 'langchain-nvidia'", specifier = ">=3.14.1" }, + { name = "deepagents", marker = "extra == 'deepagents'", specifier = ">=0.7.4,<0.8.0" }, + { name = "langchain", marker = "extra == 'langchain'", specifier = ">=1.3.14,<2.0.0" }, + { name = "langchain-anthropic", marker = "extra == 'deepagents'", specifier = ">=1.4.8,<2.0.0" }, + { name = "langchain-core", marker = "extra == 'langchain'" }, + { name = "langchain-nvidia-ai-endpoints", marker = "extra == 'langchain-nvidia'", specifier = ">=1.4.1,<2.0.0" }, + { name = "langgraph", marker = "extra == 'langchain'", specifier = ">=1.2.9,<2.0.0" }, + { name = "langgraph", marker = "extra == 'langgraph'", specifier = ">=1.2.9,<2.0.0" }, + { name = "langgraph-checkpoint", marker = "extra == 'langchain'", specifier = ">=4.1.1" }, + { name = "langsmith", marker = "extra == 'langchain'", specifier = ">=0.9.4" }, + { name = "nemo-relay", extras = ["langchain"], marker = "extra == 'langchain-nvidia'" }, + { name = "nemo-relay", extras = ["langchain"], marker = "extra == 'langgraph'" }, + { name = "nemo-relay", extras = ["langgraph"], marker = "extra == 'deepagents'" }, + { name = "nemo-relay-cli-bin", marker = "extra == 'cli'", directory = "../../../python/cli-bin" }, +] +provides-extras = ["cli", "deepagents", "langchain", "langchain-nvidia", "langgraph"] + +[package.metadata.requires-dev] +dev = [ + { name = "beautifulsoup4", specifier = ">=4.14,<5" }, + { name = "ipython", specifier = "~=8.20" }, + { name = "maturin", marker = "sys_platform != 'linux'", specifier = ">=1.0,<2.0" }, + { name = "maturin", extras = ["zig"], marker = "sys_platform == 'linux'", specifier = ">=1.0,<2.0" }, + { name = "pip-licenses", specifier = ">=5,<6" }, + { name = "pre-commit", specifier = "~=4.0" }, + { name = "ruff", specifier = "~=0.11" }, + { name = "ty", specifier = ">=0.0.1a7" }, + { name = "uv", specifier = "~=0.11.0" }, +] +test = [ + { name = "grpcio", marker = "(platform_machine != 'ARM64' and platform_machine != 'aarch64' and platform_machine != 'arm64') or sys_platform != 'win32'", specifier = ">=1.81.1,<2" }, + { name = "nemo-relay-plugin", marker = "(platform_machine != 'ARM64' and platform_machine != 'aarch64' and platform_machine != 'arm64') or sys_platform != 'win32'", editable = "../../../python/plugin" }, + { name = "opentelemetry-proto", specifier = ">=1.39,<2" }, + { name = "pydantic", specifier = ">=2" }, + { name = "pytest", specifier = ">=8" }, + { name = "pytest-asyncio", specifier = ">=0.26" }, + { name = "pytest-cov", specifier = "~=7.0" }, +] + +[[package]] +name = "nemo-relay-python-language-binding-plugin-example" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "nemo-relay" }, +] + +[package.dev-dependencies] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [{ name = "nemo-relay", directory = "../../../" }] + +[package.metadata.requires-dev] +test = [ + { name = "pytest", specifier = ">=8" }, + { name = "pytest-asyncio", specifier = ">=0.26" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] diff --git a/examples/language-binding-plugin/rust/.gitignore b/examples/language-binding-plugin/rust/.gitignore new file mode 100644 index 000000000..989a6d9c2 --- /dev/null +++ b/examples/language-binding-plugin/rust/.gitignore @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +/target/ diff --git a/examples/language-binding-plugin/rust/Cargo.lock b/examples/language-binding-plugin/rust/Cargo.lock new file mode 100644 index 000000000..27bdb866a --- /dev/null +++ b/examples/language-binding-plugin/rust/Cargo.lock @@ -0,0 +1,3037 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc-fast" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" +dependencies = [ + "digest 0.10.7", + "spin", +] + +[[package]] +name = "crossbeam" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8" +dependencies = [ + "crossbeam-channel", + "crossbeam-deque", + "crossbeam-epoch", + "crossbeam-queue", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common 0.2.2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "humantime" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nemo-relay" +version = "0.8.0" +dependencies = [ + "bitflags", + "chrono", + "futures-util", + "libloading", + "log", + "nemo-relay-plugin", + "nemo-relay-types", + "object_store", + "opentelemetry", + "opentelemetry-otlp", + "opentelemetry-semantic-conventions", + "opentelemetry_sdk", + "reqwest 0.12.28", + "semver", + "serde", + "serde_json", + "sha2", + "shell-words", + "spdlog-rs", + "strum", + "thiserror", + "tokio", + "tokio-stream", + "toml", + "tonic", + "tracing", + "typed-builder", + "unicode-general-category", + "uuid", +] + +[[package]] +name = "nemo-relay-language-binding-plugin-example" +version = "0.1.0" +dependencies = [ + "futures", + "nemo-relay", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "nemo-relay-plugin" +version = "0.8.0" +dependencies = [ + "futures", + "nemo-relay-types", + "serde", + "serde_json", + "tokio", + "tokio-util", +] + +[[package]] +name = "nemo-relay-types" +version = "0.8.0" +dependencies = [ + "bitflags", + "chrono", + "serde", + "serde_json", + "typed-builder", + "uuid", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object_store" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354792e39fa5f0009e47623cf8b15b099bf9a652fa55c6f817fe28ac84fea50" +dependencies = [ + "async-trait", + "aws-lc-rs", + "base64", + "bytes", + "chrono", + "crc-fast", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body-util", + "humantime", + "hyper", + "itertools 0.15.0", + "md-5", + "parking_lot", + "percent-encoding", + "quick-xml", + "rand 0.10.2", + "reqwest 0.13.4", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror", + "tokio", + "tracing", + "url", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "opentelemetry" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0142c63252a9e054e68a4c61a5778f7b14f576274d593f8ce883d191a099682" +dependencies = [ + "futures-core", + "futures-sink", + "js-sys", + "pin-project-lite", + "thiserror", + "tracing", +] + +[[package]] +name = "opentelemetry-http" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5683015d09e2df236ef005b17f6f196f0d5f6313c4fa43a7b6a53b52776e4331" +dependencies = [ + "async-trait", + "bytes", + "http", + "opentelemetry", + "reqwest 0.13.4", +] + +[[package]] +name = "opentelemetry-otlp" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9966929966d17620d7c316c643ba62631826e10021409357772d5eea84f62c35" +dependencies = [ + "http", + "opentelemetry", + "opentelemetry-http", + "opentelemetry-proto", + "opentelemetry_sdk", + "prost", + "reqwest 0.13.4", + "thiserror", + "tokio", + "tonic", + "tonic-types", +] + +[[package]] +name = "opentelemetry-proto" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d658ba1faf63f7b9c492cfbe6e0ec365440a16132d3270c1065f7b33f1b638" +dependencies = [ + "opentelemetry", + "opentelemetry_sdk", + "prost", + "tonic", + "tonic-prost", +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c913ac17a6c451661ee255f4625d143e51647ae78ebd969b75e41c4442f4fe47" + +[[package]] +name = "opentelemetry_sdk" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b59f80e1ac4d5ff7a2db8fb6c80badb7f0f3f858211fba08dd9aaec750894f9" +dependencies = [ + "futures-channel", + "futures-executor", + "futures-util", + "opentelemetry", + "percent-encoding", + "portable-atomic", + "rand 0.9.5", + "thiserror", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_buf" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58b57892601430298e96ce14d68f61dff758b648da87f3d491fbe5ec523e749e" +dependencies = [ + "serde_core", + "zerocopy", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_fmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e497af288b3b95d067a23a4f749f2861121ffcb2f6d8379310dcda040c345ed" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.11.3", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spdlog-internal" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eb988bafd6393bd840d78ba07826c857060bb1ba473bd57e8ae4d10a77eb276" +dependencies = [ + "nom", +] + +[[package]] +name = "spdlog-macros" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9a23693b67ee8c1515d2043ddd01ea3561872cf1f19bc00947857c0184a62aa" +dependencies = [ + "proc-macro2", + "quote", + "spdlog-internal", + "syn 2.0.119", +] + +[[package]] +name = "spdlog-rs" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7415ec666ebcaa1d92c5d9fba5d0b44e1a9fdddc8df6c0b2c45e89af8b830b6d" +dependencies = [ + "arc-swap", + "atomic", + "bytemuck", + "chrono", + "crossbeam", + "dyn-clone", + "env_filter", + "libc", + "log", + "once_cell", + "parking_lot", + "rustc_version", + "spdlog-macros", + "value-bag", + "winapi", +] + +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "sval" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4a2a7d92fa86fcc6222e4c3845f8486cff899d9db32480b26c91a5dbf2e22d" + +[[package]] +name = "sval_buffer" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4324db9ac500c609d659b752edf9c8abbf2233f8afd61a503fd6f88ed625032" +dependencies = [ + "sval", + "sval_ref", + "zerocopy", +] + +[[package]] +name = "sval_dynamic" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4046add0eecf55e680b9e207edf5fc7737b18a1d950db363d97e7f1b2d7c629c" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_fmt" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "911a3486b5984a0a4f25edefcf2c2dba23654c29f63e75493b671d338bf24243" +dependencies = [ + "itoa", + "ryu", + "sval", +] + +[[package]] +name = "sval_json" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da53aae7c737b5b5f1be4bcb0ff20e057bf6b2ee4e9d025560075c5830d09f95" +dependencies = [ + "itoa", + "ryu", + "sval", +] + +[[package]] +name = "sval_nested" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df24df43cbdc4bb8c9f5ed19d0d57dc8f60a1a4259cdce52d597fe774ad3a71f" +dependencies = [ + "sval", + "sval_buffer", + "sval_ref", +] + +[[package]] +name = "sval_ref" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bebc17f0f1fad060e57b778728d41ef87627e9111a6365d7463472cb58fc1b3" +dependencies = [ + "sval", +] + +[[package]] +name = "sval_serde" +version = "2.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f26fe3f6a68b40e6c8d654ea48c00e4316272fddf68c80493714c1b034ae70b" +dependencies = [ + "serde_core", + "sval", + "sval_nested", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "base64", + "bytes", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "rustls-native-certs", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-types" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8" +dependencies = [ + "prost", + "prost-types", + "tonic", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typed-builder" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda" +dependencies = [ + "typed-builder-macro", +] + +[[package]] +name = "typed-builder-macro" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "value-bag" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "068e763e8279de7ab94b6afebded2cb701678af094feb1c12ccb061b4783c1be" +dependencies = [ + "value-bag-serde1", + "value-bag-sval2", +] + +[[package]] +name = "value-bag-serde1" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417d6197dd0ee696783d6be4276ac6ea74b985e00024c85ccfb37aff4f2bed82" +dependencies = [ + "erased-serde", + "serde_buf", + "serde_core", + "serde_fmt", +] + +[[package]] +name = "value-bag-sval2" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61f7251ecde2c9ed431bbe0659853e7991753447447bbf1ae59d8b31c578d4e" +dependencies = [ + "sval", + "sval_buffer", + "sval_dynamic", + "sval_fmt", + "sval_json", + "sval_ref", + "sval_serde", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/examples/language-binding-plugin/rust/Cargo.toml b/examples/language-binding-plugin/rust/Cargo.toml new file mode 100644 index 000000000..8eee68fe8 --- /dev/null +++ b/examples/language-binding-plugin/rust/Cargo.toml @@ -0,0 +1,17 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "nemo-relay-language-binding-plugin-example" +version = "0.1.0" +edition = "2024" +publish = false + +[workspace] + +[dependencies] +futures = "0.3" +nemo-relay = { version = "0.8.0", path = "../../../crates/core" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync"] } diff --git a/examples/language-binding-plugin/rust/src/config.rs b/examples/language-binding-plugin/rust/src/config.rs new file mode 100644 index 000000000..b35a607d5 --- /dev/null +++ b/examples/language-binding-plugin/rust/src/config.rs @@ -0,0 +1,237 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashSet; + +use nemo_relay::plugin::{ConfigDiagnostic, ConfigPolicy, DiagnosticLevel, UnsupportedBehavior}; +use serde::Deserialize; +use serde_json::{Map, Value as Json}; + +#[derive(Clone, Deserialize)] +#[serde(default)] +pub(crate) struct Settings { + pub tag: String, + pub observe: Observe, + pub requests: Requests, + pub execution: Execution, + pub runtime: Runtime, +} + +#[derive(Clone, Deserialize)] +#[serde(default)] +pub(crate) struct Observe { + pub enabled: bool, + pub redact_keys: Vec, +} + +#[derive(Clone, Deserialize)] +#[serde(default)] +pub(crate) struct Requests { + pub enabled: bool, + pub mode: String, + pub blocked_tools: Vec, + pub blocked_models: Vec, + pub header_name: String, + pub header_value: String, + pub priority: i32, + pub break_chain: bool, +} + +#[derive(Clone, Deserialize)] +#[serde(default)] +pub(crate) struct Execution { + pub enabled: bool, + pub priority: i32, + pub emit_pending_marks: bool, +} + +#[derive(Clone, Deserialize)] +#[serde(default)] +pub(crate) struct Runtime { + pub emit_marks: bool, + pub emit_isolated_scope: bool, +} + +impl Default for Settings { + fn default() -> Self { + Self { + tag: "documentation".into(), + observe: Observe::default(), + requests: Requests::default(), + execution: Execution::default(), + runtime: Runtime::default(), + } + } +} + +impl Default for Observe { + fn default() -> Self { + Self { + enabled: true, + redact_keys: vec!["secret".into()], + } + } +} + +impl Default for Requests { + fn default() -> Self { + Self { + enabled: true, + mode: "enforce".into(), + blocked_tools: vec!["dangerous_tool".into()], + blocked_models: vec!["restricted-model".into()], + header_name: "x-nemo-relay-plugin".into(), + header_value: "documentation".into(), + priority: 20, + break_chain: false, + } + } +} + +impl Default for Execution { + fn default() -> Self { + Self { + enabled: true, + priority: 30, + emit_pending_marks: true, + } + } +} + +impl Default for Runtime { + fn default() -> Self { + Self { + emit_marks: true, + emit_isolated_scope: true, + } + } +} + +pub(crate) fn parse(config: &Map) -> Result { + serde_json::from_value(Json::Object(config.clone())).map_err(|error| error.to_string()) +} + +pub(crate) fn validate(config: &Map, policy: &ConfigPolicy) -> Vec { + let mut diagnostics = Vec::new(); + report_unknown_fields(config, policy.unknown_field, &mut diagnostics); + let settings = match parse(config) { + Ok(settings) => settings, + Err(error) => { + diagnostics.push(diagnostic( + DiagnosticLevel::Error, + "invalid_config", + None, + error, + )); + return diagnostics; + } + }; + if settings.tag.is_empty() { + diagnostics.push(diagnostic( + DiagnosticLevel::Error, + "invalid_tag", + Some("tag"), + "tag must be a non-empty string", + )); + } + if settings.requests.mode != "observe" && settings.requests.mode != "enforce" { + diagnostics.push(diagnostic( + DiagnosticLevel::Error, + "unsupported_mode", + Some("requests.mode"), + "requests.mode must be either observe or enforce", + )); + } + if settings.requests.header_name.is_empty() { + diagnostics.push(diagnostic( + DiagnosticLevel::Error, + "invalid_header", + Some("requests.header_name"), + "requests.header_name must be a non-empty string", + )); + } + if settings.requests.header_value.is_empty() { + diagnostics.push(diagnostic( + DiagnosticLevel::Error, + "invalid_header", + Some("requests.header_value"), + "requests.header_value must be a non-empty string", + )); + } + diagnostics +} + +fn report_unknown_fields( + config: &Map, + behavior: UnsupportedBehavior, + diagnostics: &mut Vec, +) { + let level = match behavior { + UnsupportedBehavior::Ignore => return, + UnsupportedBehavior::Warn => DiagnosticLevel::Warning, + UnsupportedBehavior::Error => DiagnosticLevel::Error, + }; + const TOP_LEVEL: &[&str] = &["tag", "observe", "requests", "execution", "runtime"]; + const OBSERVE: &[&str] = &["enabled", "redact_keys"]; + const REQUESTS: &[&str] = &[ + "enabled", + "mode", + "blocked_tools", + "blocked_models", + "header_name", + "header_value", + "priority", + "break_chain", + ]; + const EXECUTION: &[&str] = &["enabled", "priority", "emit_pending_marks"]; + const RUNTIME: &[&str] = &["emit_marks", "emit_isolated_scope"]; + report_unknown(config, "", TOP_LEVEL, level, diagnostics); + for (group, allowed) in [ + ("observe", OBSERVE), + ("requests", REQUESTS), + ("execution", EXECUTION), + ("runtime", RUNTIME), + ] { + if let Some(object) = config.get(group).and_then(Json::as_object) { + report_unknown(object, group, allowed, level, diagnostics); + } + } +} + +fn report_unknown( + object: &Map, + prefix: &str, + allowed: &[&str], + level: DiagnosticLevel, + diagnostics: &mut Vec, +) { + let allowed = allowed.iter().copied().collect::>(); + for key in object.keys().filter(|key| !allowed.contains(key.as_str())) { + let field = if prefix.is_empty() { + key.clone() + } else { + format!("{prefix}.{key}") + }; + diagnostics.push(diagnostic( + level, + "unknown_field", + Some(&field), + format!("unknown field '{field}' is not supported"), + )); + } +} + +fn diagnostic( + level: DiagnosticLevel, + suffix: &str, + field: Option<&str>, + message: impl Into, +) -> ConfigDiagnostic { + ConfigDiagnostic { + level, + code: format!("documentation-plugin.{suffix}"), + component: Some("documentation-plugin".into()), + field: field.map(str::to_owned), + message: message.into(), + } +} diff --git a/examples/language-binding-plugin/rust/src/lib.rs b/examples/language-binding-plugin/rust/src/lib.rs new file mode 100644 index 000000000..50ac4d095 --- /dev/null +++ b/examples/language-binding-plugin/rust/src/lib.rs @@ -0,0 +1,503 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +mod config; + +use std::future::Future; +use std::pin::Pin; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use futures::{StreamExt, stream}; +use nemo_relay::api::event::{Event, EventSanitizeFields, PendingMarkSpec}; +use nemo_relay::api::llm::{ + LlmCallExecuteParams, LlmRequest, LlmRequestInterceptOutcome, LlmStreamCallExecuteParams, + llm_call_execute, llm_stream_call_execute, +}; +use nemo_relay::api::runtime::callbacks::LlmJsonStream; +use nemo_relay::api::runtime::{create_scope_stack, with_scope_stack}; +use nemo_relay::api::scope::{ + EmitMarkEventParams, PopScopeParams, PushScopeParams, ScopeType, event, pop_scope, push_scope, +}; +use nemo_relay::api::subscriber::flush_subscribers; +use nemo_relay::api::tool::{ + ToolCallExecuteParams, ToolExecutionInterceptOutcome, ToolExecutionResult, tool_call_execute, +}; +use nemo_relay::plugin::{ + ConfigDiagnostic, ConfigPolicy, Plugin, PluginComponentSpec, PluginConfig, + PluginRegistrationContext, Result as PluginResult, clear_plugin_configuration, + deregister_plugin, initialize_plugins_exact, list_plugin_kinds, register_plugin, + validate_plugin_config, +}; +use serde_json::{Map, Value as Json, json}; + +pub struct DocumentationPlugin; + +static OBSERVED_EVENTS: AtomicUsize = AtomicUsize::new(0); +static OBSERVED_EVENT_DETAILS: Mutex> = Mutex::new(Vec::new()); + +impl Plugin for DocumentationPlugin { + fn plugin_kind(&self) -> &str { + "documentation-plugin" + } + + fn validate(&self, config: &Map) -> Vec { + config::validate(config, &ConfigPolicy::default()) + } + + fn validate_with_policy( + &self, + config: &Map, + policy: &ConfigPolicy, + ) -> Vec { + config::validate(config, policy) + } + + fn register<'a>( + &'a self, + config: &Map, + context: &'a mut PluginRegistrationContext, + ) -> Pin> + Send + 'a>> { + let config = config.clone(); + Box::pin(async move { + let settings = + config::parse(&config).map_err(nemo_relay::plugin::PluginError::InvalidConfig)?; + if settings.observe.enabled { + context.register_subscriber( + "events", + Arc::new(|event| { + OBSERVED_EVENTS.fetch_add(1, Ordering::Relaxed); + OBSERVED_EVENT_DETAILS + .lock() + .expect("event details lock should not be poisoned") + .push(event.clone()); + println!("event: {}", event.name()); + }), + )?; + context.register_mark_sanitize_guardrail( + "mark-redaction", + 10, + Arc::new({ + let redact_keys = settings.observe.redact_keys.clone(); + move |_event, fields| { + let redact_keys = redact_keys.clone(); + Box::pin(async move { Ok(redact_event_fields(fields, &redact_keys)) }) + } + }), + )?; + context.register_scope_sanitize_start_guardrail( + "scope-start-redaction", + 10, + Arc::new({ + let redact_keys = settings.observe.redact_keys.clone(); + move |_event, fields| { + let redact_keys = redact_keys.clone(); + Box::pin(async move { Ok(redact_event_fields(fields, &redact_keys)) }) + } + }), + )?; + context.register_scope_sanitize_end_guardrail( + "scope-end-redaction", + 10, + Arc::new({ + let redact_keys = settings.observe.redact_keys.clone(); + move |_event, fields| { + let redact_keys = redact_keys.clone(); + Box::pin(async move { Ok(redact_event_fields(fields, &redact_keys)) }) + } + }), + )?; + context.register_tool_sanitize_request_guardrail( + "tool-request-redaction", + 10, + Arc::new({ + let redact_keys = settings.observe.redact_keys.clone(); + move |_name, value| { + let redact_keys = redact_keys.clone(); + Box::pin(async move { Ok(redact_json(value, &redact_keys)) }) + } + }), + )?; + context.register_tool_sanitize_response_guardrail( + "tool-response-redaction", + 10, + Arc::new({ + let redact_keys = settings.observe.redact_keys.clone(); + move |_name, value| { + let redact_keys = redact_keys.clone(); + Box::pin(async move { Ok(redact_json(value, &redact_keys)) }) + } + }), + )?; + context.register_llm_sanitize_request_guardrail( + "llm-request-redaction", + 10, + Arc::new({ + let redact_keys = settings.observe.redact_keys.clone(); + move |mut request, _context| { + let redact_keys = redact_keys.clone(); + Box::pin(async move { + request.content = redact_json(request.content, &redact_keys); + Ok(Some(request)) + }) + } + }), + )?; + context.register_llm_sanitize_response_guardrail( + "llm-response-redaction", + 10, + Arc::new({ + let redact_keys = settings.observe.redact_keys.clone(); + move |response, _context| { + let redact_keys = redact_keys.clone(); + Box::pin(async move { Ok(Some(redact_json(response, &redact_keys))) }) + } + }), + )?; + } + if settings.requests.enabled { + context.register_tool_conditional_execution_guardrail( + "tool-policy", + 10, + Arc::new({ + let mode = settings.requests.mode.clone(); + let blocked = settings.requests.blocked_tools.clone(); + move |name, _args| { + let mode = mode.clone(); + let blocked = blocked.clone(); + Box::pin(async move { + Ok((mode == "enforce" && blocked.contains(&name)) + .then(|| format!("tool '{name}' is blocked"))) + }) + } + }), + )?; + context.register_tool_request_intercept( + "tool-request", + settings.requests.priority, + settings.requests.break_chain, + Arc::new({ + let tag = settings.tag.clone(); + move |_name, mut args| { + let tag = tag.clone(); + Box::pin(async move { + if let Some(object) = args.as_object_mut() { + object.insert("plugin_tag".into(), Json::String(tag)); + } + Ok(args) + }) + } + }), + )?; + context.register_llm_conditional_execution_guardrail( + "llm-policy", + 10, + Arc::new({ + let mode = settings.requests.mode.clone(); + let blocked = settings.requests.blocked_models.clone(); + move |request| { + let mode = mode.clone(); + let blocked = blocked.clone(); + Box::pin(async move { + let model = request + .content + .get("model") + .and_then(Json::as_str) + .unwrap_or_default(); + Ok((mode == "enforce" + && blocked.iter().any(|candidate| candidate == model)) + .then(|| format!("model '{model}' is blocked"))) + }) + } + }), + )?; + context.register_llm_request_intercept( + "llm-request", + settings.requests.priority, + settings.requests.break_chain, + Arc::new({ + let header_name = settings.requests.header_name.clone(); + let header_value = settings.requests.header_value.clone(); + move |_name, mut request, annotated| { + let header_name = header_name.clone(); + let header_value = header_value.clone(); + Box::pin(async move { + request + .headers + .insert(header_name, Json::String(header_value)); + Ok(LlmRequestInterceptOutcome::new(request, annotated)) + }) + } + }), + )?; + } + if settings.runtime.emit_marks || settings.runtime.emit_isolated_scope { + context.register_tool_execution_intercept( + "runtime-events", + 0, + Arc::new({ + let tag = settings.tag.clone(); + let runtime = settings.runtime.clone(); + move |_name, args, next| { + let tag = tag.clone(); + let runtime = runtime.clone(); + Box::pin(async move { + emit_runtime_events(&tag, &runtime)?; + Ok(ToolExecutionInterceptOutcome::from(next(args).await?)) + }) + } + }), + )?; + } + if settings.execution.enabled { + context.register_tool_execution_intercept( + "tool-pending-mark", + settings.execution.priority, + Arc::new({ + let emit_pending_marks = settings.execution.emit_pending_marks; + move |_name, args, next| { + Box::pin(async move { + let result = next(args).await?; + let outcome = ToolExecutionInterceptOutcome::from(result); + Ok(if emit_pending_marks { + outcome.with_pending_mark( + PendingMarkSpec::builder() + .name("documentation-plugin.tool-complete") + .data(json!({ "source": "documentation" })) + .build(), + ) + } else { + outcome + }) + }) + } + }), + )?; + context.register_llm_execution_intercept( + "llm-execution", + settings.execution.priority, + Arc::new(move |_name, request, next| { + Box::pin(async move { next(request).await }) + }), + )?; + context.register_llm_stream_execution_intercept( + "llm-stream", + settings.execution.priority, + Arc::new(move |_name, request, next| { + Box::pin(async move { + let stream = next(request).await?; + Ok(LlmJsonStream::new(stream.map(|chunk| { + chunk.map(|mut chunk| { + if let Some(object) = chunk.as_object_mut() { + object.insert("plugin_stream".into(), Json::Bool(true)); + } + chunk + }) + }))) + }) + }), + )?; + } + Ok(()) + }) + } +} + +pub fn config_with_enabled(mode: &str, enabled: bool) -> PluginConfig { + let mut component = PluginComponentSpec::new("documentation-plugin"); + component.enabled = enabled; + component.config = json!({ + "tag": "documentation", + "observe": { "enabled": true, "redact_keys": ["secret"] }, + "requests": { + "enabled": true, + "mode": mode, + "blocked_tools": ["dangerous_tool"], + "blocked_models": ["restricted-model"], + "header_name": "x-nemo-relay-plugin", + "header_value": "documentation", + "priority": 20, + "break_chain": false + }, + "execution": { "enabled": true, "priority": 30, "emit_pending_marks": true }, + "runtime": { "emit_marks": true, "emit_isolated_scope": true } + }) + .as_object() + .expect("config object") + .clone(); + PluginConfig { + components: vec![component], + ..PluginConfig::default() + } +} + +pub fn config(mode: &str) -> PluginConfig { + config_with_enabled(mode, true) +} + +pub fn reset_observed_events() { + OBSERVED_EVENTS.store(0, Ordering::Relaxed); + OBSERVED_EVENT_DETAILS + .lock() + .expect("event details lock should not be poisoned") + .clear(); +} + +pub fn observed_event_count() -> usize { + OBSERVED_EVENTS.load(Ordering::Relaxed) +} + +pub fn observed_events() -> Vec { + OBSERVED_EVENT_DETAILS + .lock() + .expect("event details lock should not be poisoned") + .clone() +} + +fn redact_event_fields( + mut fields: EventSanitizeFields, + redact_keys: &[String], +) -> EventSanitizeFields { + fields.data = fields.data.map(|value| redact_json(value, redact_keys)); + fields.metadata = fields.metadata.map(|value| redact_json(value, redact_keys)); + fields +} + +fn redact_json(value: Json, redact_keys: &[String]) -> Json { + match value { + Json::Object(mut object) => { + for (key, value) in &mut object { + if redact_keys.iter().any(|candidate| candidate == key) { + *value = Json::String("[REDACTED]".into()); + } else { + *value = redact_json(value.take(), redact_keys); + } + } + Json::Object(object) + } + Json::Array(values) => Json::Array( + values + .into_iter() + .map(|value| redact_json(value, redact_keys)) + .collect(), + ), + other => other, + } +} + +fn emit_runtime_events(tag: &str, runtime: &config::Runtime) -> nemo_relay::error::Result<()> { + if runtime.emit_marks { + event( + EmitMarkEventParams::builder() + .name("documentation-plugin.request") + .data(json!({ "tag": tag, "secret": "application-value" })) + .build(), + )?; + } + if runtime.emit_isolated_scope { + let isolated_stack = create_scope_stack(); + with_scope_stack(isolated_stack, || { + let scope = push_scope( + PushScopeParams::builder() + .name("documentation-plugin.isolated") + .scope_type(ScopeType::Custom) + .build(), + )?; + pop_scope(PopScopeParams::builder().handle_uuid(&scope.uuid).build()) + })?; + } + Ok(()) +} + +pub async fn run_workflow() -> Result<(), Box> { + reset_observed_events(); + register_plugin(Arc::new(DocumentationPlugin))?; + println!("registered: {:?}", list_plugin_kinds()); + let invalid = validate_plugin_config(&config("invalid")); + assert_eq!( + invalid.diagnostics[0].code, + "documentation-plugin.unsupported_mode" + ); + let disabled_invalid = validate_plugin_config(&config_with_enabled("invalid", false)); + assert_eq!( + disabled_invalid.diagnostics[0].code, + "documentation-plugin.unsupported_mode" + ); + println!("invalid: {:?}", invalid.diagnostics); + let report = initialize_plugins_exact(config("enforce")).await?; + println!("active: {report:?}"); + + let tool = tool_call_execute( + ToolCallExecuteParams::builder() + .name("safe_tool") + .args(json!({"value": 1})) + .func(Arc::new(|args| { + Box::pin(async move { + Ok(ToolExecutionResult::annotated( + args, + json!({"source": "application"}), + )) + }) + })) + .build(), + ) + .await?; + assert_eq!(tool.result, json!({"value": 1, "plugin_tag": "documentation"})); + assert_eq!(tool.annotation, Some(json!({"source": "application"}))); + println!("tool: {tool:?}"); + + let request = LlmRequest { + headers: Map::new(), + content: json!({"model": "allowed-model"}), + }; + let response = llm_call_execute( + LlmCallExecuteParams::builder() + .name("allowed-model") + .request(request.clone()) + .func(Arc::new(|request| { + Box::pin(async move { Ok(json!({"headers": request.headers})) }) + })) + .build(), + ) + .await?; + assert_eq!(response["headers"]["x-nemo-relay-plugin"], "documentation"); + println!("llm: {response}"); + + let mut output = llm_stream_call_execute( + LlmStreamCallExecuteParams::builder() + .name("allowed-model") + .request(request) + .func(Arc::new(|_request| { + Box::pin(async { + Ok(LlmJsonStream::new(stream::iter(vec![ + Ok(json!({"chunk": 1})), + Ok(json!({"chunk": 2})), + ]))) + }) + })) + .collector(Box::new(|_| Ok(()))) + .finalizer(Box::new(|| json!({"done": true}))) + .build(), + ) + .await?; + let mut chunks = Vec::new(); + while let Some(chunk) = output.next().await { + let chunk = chunk?; + println!("stream: {chunk}"); + chunks.push(chunk); + } + assert_eq!(chunks.len(), 2); + assert!(chunks.iter().all(|chunk| chunk["plugin_stream"] == true)); + flush_subscribers()?; + assert!(OBSERVED_EVENTS.load(Ordering::Relaxed) > 0); + + clear_plugin_configuration()?; + assert!(deregister_plugin("documentation-plugin")); + assert!( + !list_plugin_kinds() + .iter() + .any(|kind| kind == "documentation-plugin") + ); + println!("teardown: complete"); + Ok(()) +} diff --git a/examples/language-binding-plugin/rust/src/main.rs b/examples/language-binding-plugin/rust/src/main.rs new file mode 100644 index 000000000..254c3ed3a --- /dev/null +++ b/examples/language-binding-plugin/rust/src/main.rs @@ -0,0 +1,7 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +#[tokio::main] +async fn main() -> Result<(), Box> { + nemo_relay_language_binding_plugin_example::run_workflow().await +} diff --git a/examples/language-binding-plugin/rust/tests/plugin.rs b/examples/language-binding-plugin/rust/tests/plugin.rs new file mode 100644 index 000000000..4cb6e8c04 --- /dev/null +++ b/examples/language-binding-plugin/rust/tests/plugin.rs @@ -0,0 +1,430 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::sync::Arc; + +use futures::{StreamExt, stream}; +use nemo_relay::api::llm::{ + LlmCallExecuteParams, LlmRequest, LlmStreamCallExecuteParams, llm_call_execute, + llm_stream_call_execute, +}; +use nemo_relay::api::runtime::callbacks::LlmJsonStream; +use nemo_relay::api::subscriber::flush_subscribers; +use nemo_relay::api::tool::{ToolCallExecuteParams, ToolExecutionResult, tool_call_execute}; +use nemo_relay::plugin::{ + ConfigReport, DiagnosticLevel, Plugin, clear_plugin_configuration, deregister_plugin, + initialize_plugins_exact, list_plugin_kinds, register_plugin, validate_plugin_config, +}; +use nemo_relay_language_binding_plugin_example::{ + DocumentationPlugin, config, config_with_enabled, observed_event_count, observed_events, + reset_observed_events, +}; +use serde_json::{Map, json}; +use tokio::sync::{Mutex, MutexGuard}; + +static PLUGIN_TEST_LOCK: Mutex<()> = Mutex::const_new(()); + +struct RegisteredPlugin { + _lock: MutexGuard<'static, ()>, +} + +impl Drop for RegisteredPlugin { + fn drop(&mut self) { + let _ = clear_plugin_configuration(); + deregister_plugin("documentation-plugin"); + } +} + +struct ActivePlugin { + report: ConfigReport, + _registration: RegisteredPlugin, +} + +async fn register_only() -> RegisteredPlugin { + let lock = PLUGIN_TEST_LOCK.lock().await; + reset_observed_events(); + register_plugin(Arc::new(DocumentationPlugin)).expect("plugin registration should succeed"); + RegisteredPlugin { _lock: lock } +} + +async fn activate_with(plugin_config: nemo_relay::plugin::PluginConfig) -> ActivePlugin { + let registration = register_only().await; + let report = initialize_plugins_exact(plugin_config) + .await + .unwrap_or_else(|error| panic!("plugin activation should succeed: {error}")); + ActivePlugin { + report, + _registration: registration, + } +} + +async fn activate() -> ActivePlugin { + activate_with(config("enforce")).await +} + +#[test] +fn validation_accepts_supported_mode() { + let configuration = config("enforce"); + let diagnostics = DocumentationPlugin.validate(&configuration.components[0].config); + + assert!(diagnostics.is_empty()); +} + +#[test] +fn validation_rejects_unsupported_mode() { + let configuration = config("invalid"); + let diagnostics = DocumentationPlugin.validate(&configuration.components[0].config); + + assert_eq!(diagnostics[0].code, "documentation-plugin.unsupported_mode"); +} + +#[test] +fn validation_rejects_wrong_type() { + let mut configuration = config("enforce"); + configuration.components[0] + .config + .insert("requests".into(), json!({"priority": "high"})); + + let diagnostics = DocumentationPlugin.validate(&configuration.components[0].config); + + assert_eq!(diagnostics[0].code, "documentation-plugin.invalid_config"); +} + +#[test] +fn validation_reports_each_empty_required_string_at_its_field() { + for (configuration, code, field) in [ + ( + json!({ "tag": "" }), + "documentation-plugin.invalid_tag", + "tag", + ), + ( + json!({ "requests": { "header_name": "" } }), + "documentation-plugin.invalid_header", + "requests.header_name", + ), + ( + json!({ "requests": { "header_value": "" } }), + "documentation-plugin.invalid_header", + "requests.header_value", + ), + ] { + let diagnostics = DocumentationPlugin.validate(&configuration.as_object().unwrap().clone()); + assert!(diagnostics.iter().any(|diagnostic| { + diagnostic.code == code && diagnostic.field.as_deref() == Some(field) + })); + } +} + +#[test] +fn validation_warns_about_unknown_field() { + let mut configuration = config("enforce"); + configuration.components[0] + .config + .insert("unexpected".into(), json!(true)); + + let diagnostics = DocumentationPlugin.validate(&configuration.components[0].config); + + assert_eq!(diagnostics[0].level, DiagnosticLevel::Warning); + assert_eq!(diagnostics[0].field.as_deref(), Some("unexpected")); +} + +#[test] +fn implementation_registers_each_safe_plugin_surface() { + let source = include_str!("../src/lib.rs"); + assert_eq!( + [ + "register_subscriber", + "register_mark_sanitize_guardrail", + "register_scope_sanitize_start_guardrail", + "register_scope_sanitize_end_guardrail", + "register_tool_sanitize_request_guardrail", + "register_tool_sanitize_response_guardrail", + "register_tool_conditional_execution_guardrail", + "register_tool_request_intercept", + "register_tool_execution_intercept", + "register_llm_sanitize_request_guardrail", + "register_llm_sanitize_response_guardrail", + "register_llm_conditional_execution_guardrail", + "register_llm_request_intercept", + "register_llm_execution_intercept", + "register_llm_stream_execution_intercept", + ] + .iter() + .filter(|method| source.contains(**method)) + .count(), + 15 + ); +} + +#[tokio::test] +async fn registration_rejects_a_duplicate_kind_and_missing_deregistration_is_false() { + let _registered = register_only().await; + assert!(register_plugin(Arc::new(DocumentationPlugin)).is_err()); + assert!(!deregister_plugin("missing-documentation-plugin")); + assert!(deregister_plugin("documentation-plugin")); +} + +#[tokio::test] +async fn disabled_component_is_still_validated() { + let _registered = register_only().await; + let report = validate_plugin_config(&config_with_enabled("invalid", false)); + assert!(deregister_plugin("documentation-plugin")); + + assert_eq!( + report.diagnostics[0].code, + "documentation-plugin.unsupported_mode" + ); +} + +#[tokio::test] +async fn activation_reports_no_diagnostics() { + let active = activate().await; + + assert!(active.report.diagnostics.is_empty()); +} + +#[tokio::test] +async fn tool_request_is_rewritten() { + let _active = activate().await; + + let result = tool_call_execute( + ToolCallExecuteParams::builder() + .name("safe_tool") + .args(json!({"value": 1})) + .func(Arc::new(|args| { + Box::pin(async move { + Ok(ToolExecutionResult::annotated(args, json!({"source": "application"}))) + }) + })) + .build(), + ) + .await + .expect("tool call should succeed"); + + assert_eq!(result.result, json!({"value": 1, "plugin_tag": "documentation"})); + assert_eq!(result.annotation, Some(json!({"source": "application"}))); +} + +#[tokio::test] +async fn tool_policy_blocks_configured_tool() { + let _active = activate().await; + + let error = tool_call_execute( + ToolCallExecuteParams::builder() + .name("dangerous_tool") + .args(json!({"value": 1})) + .func(Arc::new(|_args| { + Box::pin(async move { panic!("provider must not run") }) + })) + .build(), + ) + .await + .expect_err("configured tool should be blocked"); + + assert!(error.to_string().contains("guardrail rejected")); +} + +#[tokio::test] +async fn llm_request_is_rewritten() { + let _active = activate().await; + let request = LlmRequest { + headers: Map::new(), + content: json!({"model": "allowed-model"}), + }; + + let result = llm_call_execute( + LlmCallExecuteParams::builder() + .name("allowed-model") + .request(request) + .func(Arc::new(|request| { + Box::pin(async move { Ok(json!({"headers": request.headers})) }) + })) + .build(), + ) + .await + .expect("LLM call should succeed"); + + assert_eq!(result["headers"]["x-nemo-relay-plugin"], "documentation"); +} + +#[tokio::test] +async fn llm_policy_blocks_configured_model() { + let _active = activate().await; + let request = LlmRequest { + headers: Map::new(), + content: json!({"model": "restricted-model"}), + }; + + let error = llm_call_execute( + LlmCallExecuteParams::builder() + .name("restricted-model") + .request(request) + .func(Arc::new(|_request| { + Box::pin(async move { panic!("provider must not run") }) + })) + .build(), + ) + .await + .expect_err("configured model should be blocked"); + + assert!(error.to_string().contains("guardrail rejected")); +} + +#[tokio::test] +async fn llm_stream_chunks_are_transformed() { + let _active = activate().await; + let request = LlmRequest { + headers: Map::new(), + content: json!({"model": "allowed-model"}), + }; + + let mut output = llm_stream_call_execute( + LlmStreamCallExecuteParams::builder() + .name("allowed-model") + .request(request) + .func(Arc::new(|_request| { + Box::pin(async { + Ok(LlmJsonStream::new(stream::iter(vec![ + Ok(json!({"chunk": 1})), + Ok(json!({"chunk": 2})), + ]))) + }) + })) + .collector(Box::new(|_| Ok(()))) + .finalizer(Box::new(|| json!({"done": true}))) + .build(), + ) + .await + .expect("stream setup should succeed"); + let mut chunks = Vec::new(); + while let Some(chunk) = output.next().await { + chunks.push(chunk.expect("stream chunk should succeed")); + } + + assert_eq!( + chunks, + vec![ + json!({"chunk": 1, "plugin_stream": true}), + json!({"chunk": 2, "plugin_stream": true}), + ] + ); +} + +#[tokio::test] +async fn subscriber_observes_managed_call() { + let _active = activate().await; + + tool_call_execute( + ToolCallExecuteParams::builder() + .name("safe_tool") + .args(json!({"value": 1})) + .func(Arc::new(|args| Box::pin(async move { Ok(ToolExecutionResult::new(args)) }))) + .build(), + ) + .await + .expect("tool call should succeed"); + flush_subscribers().expect("subscriber flush should succeed"); + + assert!(observed_event_count() > 0); +} + +#[tokio::test] +async fn configuration_controls_redaction_pending_marks_and_isolated_scope_events() { + let _active = activate().await; + + tool_call_execute( + ToolCallExecuteParams::builder() + .name("safe_tool") + .args(json!({"value": 1})) + .func(Arc::new(|args| Box::pin(async move { Ok(ToolExecutionResult::new(args)) }))) + .build(), + ) + .await + .expect("tool call should succeed"); + flush_subscribers().expect("subscriber flush should succeed"); + + let events = observed_events(); + let runtime_mark = events + .iter() + .find(|event| event.name() == "documentation-plugin.request") + .expect("configured runtime mark should be delivered"); + assert_eq!( + runtime_mark.data().expect("mark data")["secret"], + "[REDACTED]" + ); + assert!( + events + .iter() + .any(|event| event.name() == "documentation-plugin.tool-complete") + ); + assert!( + events + .iter() + .any(|event| event.name() == "documentation-plugin.isolated") + ); +} + +#[tokio::test] +async fn runtime_events_do_not_depend_on_request_rewriting() { + let mut plugin_config = config("enforce"); + plugin_config.components[0].config["requests"]["enabled"] = json!(false); + let _active = activate_with(plugin_config).await; + + tool_call_execute( + ToolCallExecuteParams::builder() + .name("safe_tool") + .args(json!({"value": 1})) + .func(Arc::new(|args| Box::pin(async move { Ok(ToolExecutionResult::new(args)) }))) + .build(), + ) + .await + .expect("tool call should succeed"); + flush_subscribers().expect("subscriber flush should succeed"); + + assert!( + observed_events() + .iter() + .any(|event| event.name() == "documentation-plugin.request") + ); +} + +#[tokio::test] +async fn runtime_events_are_not_stopped_by_request_break_chain() { + let mut plugin_config = config("enforce"); + plugin_config.components[0].config["requests"]["break_chain"] = json!(true); + let _active = activate_with(plugin_config).await; + + tool_call_execute( + ToolCallExecuteParams::builder() + .name("safe_tool") + .args(json!({"value": 1})) + .func(Arc::new(|args| Box::pin(async move { Ok(ToolExecutionResult::new(args)) }))) + .build(), + ) + .await + .expect("tool call should succeed"); + flush_subscribers().expect("subscriber flush should succeed"); + + assert!( + observed_events() + .iter() + .any(|event| event.name() == "documentation-plugin.request") + ); +} + +#[tokio::test] +async fn teardown_removes_plugin_kind() { + let _registered = register_only().await; + initialize_plugins_exact(config("enforce")) + .await + .expect("plugin activation should succeed"); + + clear_plugin_configuration().expect("plugin cleanup should succeed"); + assert!(deregister_plugin("documentation-plugin")); + assert!( + !list_plugin_kinds() + .iter() + .any(|kind| kind == "documentation-plugin") + ); +} diff --git a/examples/python-grpc-worker-plugin/README.md b/examples/python-grpc-worker-plugin/README.md index b24a3cb6c..35fd064f0 100644 --- a/examples/python-grpc-worker-plugin/README.md +++ b/examples/python-grpc-worker-plugin/README.md @@ -5,52 +5,45 @@ SPDX-License-Identifier: Apache-2.0 # Python gRPC Worker Plugin -This example shows a Python worker plugin using the `nemo-relay-plugin` SDK. It -registers tool request and execution intercepts, calls the host continuation, -preserves the upstream tool-result annotation, and emits marks through both the -host runtime and the execution outcome. +This package is the complete Python worker used by the plugin authoring guide. +It validates the shared documentation configuration, registers every safe +`grpc-v1` surface, preserves annotations and Relay-owned accounting, uses +invocation-scoped codec proxies, transforms streams lazily, and cleans up marks, +scopes, isolated stacks, and cancelled tasks. -The example targets Relay 0.8 or later and the canonical `grpc-v1` tool-result -contract declared in `relay-plugin.toml`. +The worker targets the Relay 0.8 `grpc-v1` result contract. Its tool continuation returns +`ToolExecutionResult`. Its execution intercept preserves the application result, carries +the upstream annotation under worker metadata, and adds Relay-owned pending marks. -## Register With Relay +Run the example's own test project from this directory: -Run the following commands from this directory: +```bash +uv run --locked --group test pytest +``` + +Each test owns one contract and can be selected independently. The suite builds +this directory as a wheel in a clean temporary project, checks the mandatory +source digest and JSON Schema, validates configuration, asserts all 15 +registrations, and then exercises each policy, sanitizer, request rewrite, +continuation, stream, mark, and scope behavior separately. + +To run the managed-environment lifecycle from this directory, create temporary +Relay state, add the manifest, and enable the plugin: ```bash relay_tmp="$(mktemp -d)" relay_config="$relay_tmp/gateway.toml" +: > "$relay_config" nemo-relay --config "$relay_config" plugins add ./relay-plugin.toml nemo-relay --config "$relay_config" plugins enable examples.python_grpc_worker nemo-relay --config "$relay_config" --bind 127.0.0.1:4040 ``` -Press Ctrl+C to stop Relay. Then remove the plugin and its managed environment, -and delete the temporary state: +After stopping Relay, run the cleanup commands in the same shell session so +`relay_config` and `relay_tmp` still identify the temporary Relay state. +Removal also deletes the Relay-managed Python environment. ```bash nemo-relay --config "$relay_config" plugins remove examples.python_grpc_worker -rm -rf "$relay_tmp" +rm -rf -- "$relay_tmp" ``` - -`plugins add` creates an isolated Relay-managed virtual environment and installs -`source.manifest_root` into it with `python -m pip install`. Standard pip index, -proxy, certificate, and wheelhouse environment variables control dependency -resolution. Set `NEMO_RELAY_PYTHON` only when adding the plugin to select a base -Python interpreter; Relay records and reuses the resulting environment during -activation. - -Python workers cannot be loaded directly or by adding a manifest reference to -`plugins.toml`. They must be registered through `plugins add`, which provisions -the required environment. `plugins remove` deletes that Relay-managed -environment. - -The SDK package owns the generated protobuf stubs and gRPC server setup. Relay -starts the worker through the manifest entrypoint and supplies the worker -socket, host socket, activation ID, and activation token environment variables. - -Async callbacks are cancelled cooperatively when the host caller times out or -stops consuming a worker stream. Let `asyncio.CancelledError` propagate and put -resource cleanup in `finally` blocks. Synchronous or blocking callback code -cannot be preempted by the SDK; move that work off the event-loop thread and -define its cancellation behavior explicitly. diff --git a/examples/python-grpc-worker-plugin/config.schema.json b/examples/python-grpc-worker-plugin/config.schema.json new file mode 100644 index 000000000..fbb0c6725 --- /dev/null +++ b/examples/python-grpc-worker-plugin/config.schema.json @@ -0,0 +1,125 @@ +{ + "$comment": "SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Python gRPC Worker Documentation Plugin", + "description": "Configures every safe grpc-v1 registration family in the Python worker example.", + "type": "object", + "additionalProperties": false, + "properties": { + "tag": { + "description": "Non-empty label attached to plugin-created metadata and runtime events.", + "type": "string", + "minLength": 1, + "default": "documentation" + }, + "observe": { + "description": "Controls the subscriber and all observability sanitizers.", + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "description": "Registers the subscriber and event, tool, and LLM observability sanitizers.", + "type": "boolean", + "default": true + }, + "redact_keys": { + "description": "Object keys whose observability values are replaced recursively.", + "type": "array", + "items": { "type": "string" }, + "default": ["secret"] + } + } + }, + "requests": { + "description": "Controls real tool and LLM policy and request rewriting.", + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "description": "Registers tool and model policy plus request-rewriting middleware.", + "type": "boolean", + "default": true + }, + "mode": { + "description": "Blocks configured tool and model names in enforce mode; permits them in observe mode.", + "type": "string", + "enum": ["observe", "enforce"], + "default": "enforce" + }, + "blocked_tools": { + "description": "Exact tool names rejected in enforce mode.", + "type": "array", + "items": { "type": "string" }, + "default": ["dangerous_tool"] + }, + "blocked_models": { + "description": "Exact model names rejected in enforce mode.", + "type": "array", + "items": { "type": "string" }, + "default": ["restricted-model"] + }, + "header_name": { + "description": "Header name added to the real LLM request.", + "type": "string", + "minLength": 1, + "default": "x-nemo-relay-plugin" + }, + "header_value": { + "description": "Header value added to the real LLM request.", + "type": "string", + "minLength": 1, + "default": "documentation" + }, + "priority": { + "description": "Priority for the tool and LLM request intercepts.", + "type": "integer", + "default": 20 + }, + "break_chain": { + "description": "Stops later request intercepts after this component rewrites a request.", + "type": "boolean", + "default": false + } + } + }, + "execution": { + "description": "Controls tool, unary LLM, and stream continuation wrappers.", + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "description": "Registers tool, unary LLM, and streaming LLM execution intercepts.", + "type": "boolean", + "default": true + }, + "priority": { + "description": "Priority for all three execution intercepts.", + "type": "integer", + "default": 30 + }, + "emit_pending_marks": { + "description": "Adds Relay-owned pending marks to tool execution and LLM request outcomes.", + "type": "boolean", + "default": true + } + } + }, + "runtime": { + "description": "Controls host marks, scopes, and isolated stack operations.", + "type": "object", + "additionalProperties": false, + "properties": { + "emit_marks": { + "description": "Emits a host-runtime mark during the tool request intercept.", + "type": "boolean", + "default": true + }, + "emit_isolated_scope": { + "description": "Creates, binds, and drops an isolated scope stack during the tool request intercept.", + "type": "boolean", + "default": true + } + } + } + } +} diff --git a/examples/python-grpc-worker-plugin/nemo_relay_python_grpc_worker_example/worker.py b/examples/python-grpc-worker-plugin/nemo_relay_python_grpc_worker_example/worker.py index a81c52ef2..0a01fa424 100644 --- a/examples/python-grpc-worker-plugin/nemo_relay_python_grpc_worker_example/worker.py +++ b/examples/python-grpc-worker-plugin/nemo_relay_python_grpc_worker_example/worker.py @@ -1,123 +1,437 @@ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Example Python worker plugin using the nemo-relay-plugin SDK.""" +"""Complete Python grpc-v1 worker used by the plugin authoring documentation.""" from __future__ import annotations -from typing import Any +import asyncio +from collections.abc import AsyncIterator +from copy import deepcopy +from typing import Any, cast from nemo_relay_plugin import ( ConfigDiagnostic, DiagnosticLevel, + EventSanitizeFields, Json, + LlmOptimizationContribution, + LlmRequestInterceptOutcome, PendingMarkSpec, PluginContext, + ScopeType, ToolExecutionInterceptOutcome, - ToolNext, + ToolExecutionResult, WorkerPlugin, serve_plugin, ) +DEFAULT_CONFIG: dict[str, Json] = { + "tag": "documentation", + "observe": {"enabled": True, "redact_keys": ["secret"]}, + "requests": { + "enabled": True, + "mode": "enforce", + "blocked_tools": ["dangerous_tool"], + "blocked_models": ["restricted-model"], + "header_name": "x-nemo-relay-plugin", + "header_value": "documentation", + "priority": 20, + "break_chain": False, + }, + "execution": {"enabled": True, "priority": 30, "emit_pending_marks": True}, + "runtime": {"emit_marks": True, "emit_isolated_scope": True}, +} + +GROUP_FIELDS = { + "observe": {"enabled", "redact_keys"}, + "requests": { + "enabled", + "mode", + "blocked_tools", + "blocked_models", + "header_name", + "header_value", + "priority", + "break_chain", + }, + "execution": {"enabled", "priority", "emit_pending_marks"}, + "runtime": {"emit_marks", "emit_isolated_scope"}, +} + class ExamplePythonWorker(WorkerPlugin): - """Small worker plugin that tags tool requests and execution results.""" + """Install every safe worker registration surface from one explicit config.""" plugin_id = "examples.python_grpc_worker" + allows_multiple_components = False def validate(self, config: Json) -> list[ConfigDiagnostic | dict[str, Any]]: - if not isinstance(config, dict): - return [ - ConfigDiagnostic( - level=DiagnosticLevel.ERROR, - code="examples.python_grpc_worker.invalid_config", - component=self.plugin_id, - message="plugin config must be a JSON object", - ) - ] - if config.get("reject") is True: - return [ - ConfigDiagnostic( - level=DiagnosticLevel.ERROR, - code="examples.python_grpc_worker.rejected", - component=self.plugin_id, - field="reject", - message="Python gRPC worker rejection requested", - ) - ] - if "tag" in config and not isinstance(config["tag"], str): - return [ - ConfigDiagnostic( - level=DiagnosticLevel.ERROR, - code="examples.python_grpc_worker.invalid_tag", - component=self.plugin_id, - field="tag", - message="tag must be a string", - ) - ] - return [] + diagnostics: list[ConfigDiagnostic | dict[str, Any]] = [] + diagnostics.extend(validate_config(config)) + return diagnostics def register(self, ctx: PluginContext, config: Json) -> None: - if not isinstance(config, dict): - raise TypeError("plugin config must be a JSON object") - if config.get("reject") is True: - raise ValueError("Python gRPC worker rejection requested") - tag = config.get("tag", "python_grpc_worker") - if not isinstance(tag, str): - raise TypeError("tag must be a string") - - async def tag_tool_request(tool_name: str, args: Json) -> Json: - tagged_args = _tag_json(args, tag) - await ctx.runtime.emit_mark( - "examples.python_grpc_worker.tool_request", - {"tool_name": tool_name, "source": "python-grpc-worker", "tag": tag}, + diagnostics = validate_config(config) + errors = [diagnostic for diagnostic in diagnostics if diagnostic.level == DiagnosticLevel.ERROR] + if errors: + raise ValueError(errors[0].message) + settings = normalized_config(config) + tag = cast(str, settings["tag"]) + observe = cast(dict[str, Json], settings["observe"]) + requests = cast(dict[str, Json], settings["requests"]) + execution = cast(dict[str, Json], settings["execution"]) + runtime_settings = cast(dict[str, Json], settings["runtime"]) + + if observe["enabled"]: + self._register_observation(ctx, tag, observe) + if requests["enabled"]: + self._register_requests(ctx, tag, requests, execution) + self._register_runtime(ctx, tag, runtime_settings) + if execution["enabled"]: + self._register_execution(ctx, tag, execution) + + @staticmethod + def _register_observation(ctx: PluginContext, tag: str, observe: dict[str, Json]) -> None: + redact_keys = cast(list[str], observe["redact_keys"]) + + async def subscriber(event: dict[str, Any]) -> None: + if not str(event.get("name", "")).startswith("example.python_worker"): + await ctx.runtime.emit_mark( + "example.python_worker.subscriber.seen", + {"event": event.get("name"), "tag": tag}, + ) + + def sanitize_event(_event: dict[str, Any], fields: EventSanitizeFields) -> EventSanitizeFields: + metadata = _redact(fields.get("metadata") or {}, redact_keys) + if not isinstance(metadata, dict): + metadata = {"original": metadata} + return { + "data": _redact(fields.get("data"), redact_keys), + "category_profile": cast( + dict[str, Any] | None, + _redact(fields.get("category_profile"), redact_keys), + ), + "metadata": {**metadata, "plugin_tag": tag}, + } + + def sanitize_tool(_name: str, value: Json) -> Json: + return _redact(value, redact_keys) + + async def sanitize_llm_request(request: dict[str, Any], context: Any) -> dict[str, Any]: + request = deepcopy(request) + codec = context.resolve_codec() + if codec is not None: + annotated = await codec.decode(request) + annotated = _redact(annotated, redact_keys) + request = await codec.encode(annotated, request) + request["content"] = _redact(request.get("content"), redact_keys) + return request + + async def sanitize_llm_response(response: Json, context: Any) -> Json: + codec = context.resolve_codec() + if codec is not None: + await codec.decode(response) + return _redact(response, redact_keys) + + ctx.register_subscriber("documentation_subscriber", subscriber) + ctx.register_mark_sanitize_guardrail("documentation_mark_sanitizer", sanitize_event, priority=10) + ctx.register_scope_sanitize_start_guardrail("documentation_scope_start_sanitizer", sanitize_event, priority=10) + ctx.register_scope_sanitize_end_guardrail("documentation_scope_end_sanitizer", sanitize_event, priority=10) + ctx.register_tool_sanitize_request_guardrail("documentation_tool_request_sanitizer", sanitize_tool, priority=10) + ctx.register_tool_sanitize_response_guardrail( + "documentation_tool_response_sanitizer", sanitize_tool, priority=10 + ) + ctx.register_llm_sanitize_request_guardrail( + "documentation_llm_request_sanitizer", sanitize_llm_request, priority=10 + ) + ctx.register_llm_sanitize_response_guardrail( + "documentation_llm_response_sanitizer", sanitize_llm_response, priority=10 + ) + + @staticmethod + def _register_requests( + ctx: PluginContext, + tag: str, + requests: dict[str, Json], + execution: dict[str, Json], + ) -> None: + priority = cast(int, requests["priority"]) + break_chain = cast(bool, requests["break_chain"]) + blocked_tools = cast(list[str], requests["blocked_tools"]) + blocked_models = cast(list[str], requests["blocked_models"]) + mode = cast(str, requests["mode"]) + + def tool_policy(name: str, _args: Json) -> str | None: + if mode == "enforce" and name in blocked_tools: + return f"tool '{name}' is blocked by documentation policy" + return None + + async def tool_request(name: str, args: Json) -> Json: + if not isinstance(args, dict): + return args + return { + **args, + "_nemo_relay_plugin": {"tag": tag, "tool": name}, + "plugin_tag": tag, + "plugin_tool": name, + } + + def llm_policy(request: dict[str, Any]) -> str | None: + content = request.get("content") + model = content.get("model") if isinstance(content, dict) else None + if mode == "enforce" and model in blocked_models: + return f"model '{model}' is blocked by documentation policy" + return None + + def llm_request( + _name: str, + request: dict[str, Any], + annotated: dict[str, Any] | None, + ) -> LlmRequestInterceptOutcome: + rewritten = deepcopy(request) + headers = rewritten.get("headers") + if not isinstance(headers, dict): + headers = {} + rewritten["headers"] = { + **headers, + cast(str, requests["header_name"]): cast(str, requests["header_value"]), + } + marks = ( + [PendingMarkSpec(name="example.python_worker.llm_request", data={"tag": tag})] + if execution["emit_pending_marks"] + else [] + ) + return LlmRequestInterceptOutcome( + request=rewritten, + annotated_request=annotated, + pending_marks=marks, + optimization_contributions=[ + LlmOptimizationContribution( + producer="examples.python_grpc_worker", + kind="request_rewrite", + applied=True, + ) + ], + ) + + ctx.register_tool_conditional_execution_guardrail("documentation_tool_policy", tool_policy, priority=10) + ctx.register_tool_request_intercept( + "documentation_tool_request", tool_request, priority=priority, break_chain=break_chain + ) + ctx.register_llm_conditional_execution_guardrail("documentation_llm_policy", llm_policy, priority=10) + ctx.register_llm_request_intercept( + "documentation_llm_request", llm_request, priority=priority, break_chain=break_chain + ) + + @staticmethod + def _register_runtime(ctx: PluginContext, tag: str, settings: dict[str, Json]) -> None: + if not settings["emit_marks"] and not settings["emit_isolated_scope"]: + return + + async def runtime_events(_name: str, args: Json, next_call: Any) -> ToolExecutionInterceptOutcome: + await _emit_runtime_events(ctx, tag, settings) + downstream = await next_call.call(args) + return ToolExecutionInterceptOutcome( + result=downstream.result, + annotation=downstream.annotation, + ) + + ctx.register_tool_execution_intercept("documentation_runtime_events", runtime_events, priority=0) + + @staticmethod + def _register_execution(ctx: PluginContext, tag: str, execution: dict[str, Json]) -> None: + priority = cast(int, execution["priority"]) + emit_pending_marks = cast(bool, execution["emit_pending_marks"]) + + async def tool_execution(name: str, args: Json, next_call: Any) -> ToolExecutionInterceptOutcome: + result: ToolExecutionResult = await next_call.call(args) + marks = ( + [ + PendingMarkSpec( + name="example.python_worker.tool_execution", + data={"tool_name": name, "tag": tag}, + ) + ] + if emit_pending_marks + else [] ) - return tagged_args - - async def tag_tool_execution( - tool_name: str, - args: Json, - next_call: ToolNext, - ) -> ToolExecutionInterceptOutcome: - result = await next_call.call(args) return ToolExecutionInterceptOutcome( - result=_tag_json(result.result, tag), + result=result.result, annotation={ "upstream": result.annotation, - "worker": {"tool_name": tool_name, "tag": tag}, + "worker": {"tool_name": name, "tag": tag}, }, - pending_marks=[ - PendingMarkSpec( - "examples.python_grpc_worker.tool_execution", - data={"tool_name": tool_name, "tag": tag}, - ) - ], + pending_marks=marks, + ) + + async def llm_execution(_name: str, request: dict[str, Any], next_call: Any) -> Json: + content = request.get("content") + repeat = isinstance(content, dict) and content.get("repeat_downstream") is True + if repeat: + first, _second = await asyncio.gather( + next_call.call(request), next_call.call(request), return_exceptions=True + ) + if isinstance(first, BaseException): + raise first + return first + return await next_call.call(request) + + async def llm_stream_execution( + _name: str, + request: dict[str, Any], + next_call: Any, + ) -> AsyncIterator[Json]: + async for chunk in next_call.call(request): + if isinstance(chunk, dict): + yield {**chunk, "plugin_stream": True} + else: + yield chunk + + ctx.register_tool_execution_intercept("documentation_tool_execution", tool_execution, priority=priority) + ctx.register_llm_execution_intercept("documentation_llm_execution", llm_execution, priority=priority) + ctx.register_llm_stream_execution_intercept( + "documentation_llm_stream_execution", llm_stream_execution, priority=priority + ) + + +def validate_config(config: Json) -> list[ConfigDiagnostic]: + if not isinstance(config, dict): + return [_diagnostic(DiagnosticLevel.ERROR, "invalid_config", None, "plugin config must be a JSON object")] + + diagnostics: list[ConfigDiagnostic] = [] + allowed_top = {"tag", *GROUP_FIELDS} + for key in config.keys() - allowed_top: + diagnostics.append(_diagnostic(DiagnosticLevel.ERROR, "unknown_field", key, f"unknown field '{key}'")) + if "tag" in config and (not isinstance(config["tag"], str) or not config["tag"]): + diagnostics.append(_diagnostic(DiagnosticLevel.ERROR, "invalid_tag", "tag", "tag must be a non-empty string")) + + for group, fields in GROUP_FIELDS.items(): + value = config.get(group) + if value is not None and not isinstance(value, dict): + diagnostics.append(_diagnostic(DiagnosticLevel.ERROR, "invalid_group", group, f"{group} must be an object")) + continue + if isinstance(value, dict): + for key in value.keys() - fields: + path = f"{group}.{key}" + diagnostics.append(_diagnostic(DiagnosticLevel.ERROR, "unknown_field", path, f"unknown field '{path}'")) + + settings = normalized_config(config) + for path in ( + "observe.enabled", + "requests.enabled", + "requests.break_chain", + "execution.enabled", + "execution.emit_pending_marks", + "runtime.emit_marks", + "runtime.emit_isolated_scope", + ): + if not isinstance(_path(settings, path), bool): + diagnostics.append(_diagnostic(DiagnosticLevel.ERROR, "invalid_type", path, f"{path} must be a boolean")) + for path in ("requests.priority", "execution.priority"): + value = _path(settings, path) + if isinstance(value, bool) or not isinstance(value, int): + diagnostics.append(_diagnostic(DiagnosticLevel.ERROR, "invalid_type", path, f"{path} must be an integer")) + for path in ("requests.blocked_tools", "requests.blocked_models", "observe.redact_keys"): + value = _path(settings, path) + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + diagnostics.append( + _diagnostic(DiagnosticLevel.ERROR, "invalid_type", path, f"{path} must be an array of strings") ) + for path in ("requests.header_name", "requests.header_value"): + value = _path(settings, path) + if not isinstance(value, str) or not value: + diagnostics.append( + _diagnostic(DiagnosticLevel.ERROR, "invalid_type", path, f"{path} must be a non-empty string") + ) + mode = _path(settings, "requests.mode") + if mode not in {"observe", "enforce"}: + diagnostics.append( + _diagnostic( + DiagnosticLevel.ERROR, + "unsupported_mode", + "requests.mode", + "requests.mode must be either observe or enforce", + ) + ) + return diagnostics + - ctx.register_tool_request_intercept("tag_tool_request", tag_tool_request) - ctx.register_tool_execution_intercept("tag_tool_execution", tag_tool_execution) +def normalized_config(config: Json) -> dict[str, Json]: + settings = deepcopy(DEFAULT_CONFIG) + if not isinstance(config, dict): + return settings + if "tag" in config: + settings["tag"] = config["tag"] + for group in GROUP_FIELDS: + supplied = config.get(group) + if isinstance(supplied, dict): + cast(dict[str, Json], settings[group]).update(supplied) + elif supplied is not None: + settings[group] = supplied + return settings -def _tag_json(value: Json, tag: str) -> Json: - if not isinstance(value, dict): - return value - metadata = value.get("_nemo_relay_plugin") - if metadata is None: - metadata = {} - elif not isinstance(metadata, dict): - return value - return { - **value, - "_nemo_relay_plugin": {**metadata, "tag": tag}, - } +async def _emit_runtime_events(ctx: PluginContext, tag: str, settings: dict[str, Json]) -> None: + handle = await ctx.runtime.push_scope( + "example.python_worker.request", + scope_type=ScopeType.CUSTOM, + data={"tag": tag}, + ) + try: + if settings["emit_marks"]: + await ctx.runtime.emit_mark("example.python_worker.tool_request", {"tag": tag}) + except BaseException: + try: + await ctx.runtime.pop_scope(handle, metadata={"failed": True}) + except BaseException: + pass + raise + else: + await ctx.runtime.pop_scope(handle, output={"done": True}) + if settings["emit_isolated_scope"]: + stack_id = await ctx.runtime.create_scope_stack() + try: + with ctx.runtime.bind_scope_stack(stack_id): + if settings["emit_marks"]: + await ctx.runtime.emit_mark("example.python_worker.isolated.mark", {"tag": tag}) + finally: + await ctx.runtime.drop_scope_stack(stack_id) + + +def _redact(value: Json, redact_keys: list[str]) -> Json: + if isinstance(value, dict): + return {key: "[REDACTED]" if key in redact_keys else _redact(item, redact_keys) for key, item in value.items()} + if isinstance(value, list): + return [_redact(item, redact_keys) for item in value] + return value + + +def _path(config: dict[str, Json], path: str) -> Json: + group, field = path.split(".", maxsplit=1) + value = config[group] + return value.get(field) if isinstance(value, dict) else None + + +def _diagnostic( + level: DiagnosticLevel, + suffix: str, + field: str | None, + message: str, +) -> ConfigDiagnostic: + return ConfigDiagnostic( + level=level, + code=f"examples.python_grpc_worker.{suffix}", + component=ExamplePythonWorker.plugin_id, + field=field, + message=message, + ) async def main() -> None: - """Entrypoint referenced by relay-plugin.toml.""" + """Serve the worker entrypoint referenced by relay-plugin.toml.""" await serve_plugin(ExamplePythonWorker()) if __name__ == "__main__": - import asyncio - asyncio.run(main()) diff --git a/examples/python-grpc-worker-plugin/pyproject.toml b/examples/python-grpc-worker-plugin/pyproject.toml index 58fc4e700..a83df9b3b 100644 --- a/examples/python-grpc-worker-plugin/pyproject.toml +++ b/examples/python-grpc-worker-plugin/pyproject.toml @@ -14,6 +14,19 @@ dependencies = [ "nemo-relay-plugin>=0.8.0", ] +[dependency-groups] +test = [ + "pytest>=8", + "pytest-asyncio>=0.26", +] + +[tool.uv.sources] +nemo-relay-plugin = { path = "../../python/plugin" } + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + [tool.setuptools.packages.find] where = ["."] include = ["nemo_relay_python_grpc_worker_example"] diff --git a/examples/python-grpc-worker-plugin/relay-plugin.toml b/examples/python-grpc-worker-plugin/relay-plugin.toml index a8ea61b65..e41bb58c6 100644 --- a/examples/python-grpc-worker-plugin/relay-plugin.toml +++ b/examples/python-grpc-worker-plugin/relay-plugin.toml @@ -15,14 +15,17 @@ worker_protocol = "grpc-v1" enabled = false [capabilities] -items = ["plugin_worker"] +items = ["plugin_worker", "config_schema"] + +[config_schema] +path = "config.schema.json" [source] manifest_root = "." artifact = "nemo_relay_python_grpc_worker_example/worker.py" [integrity] -sha256 = "sha256:ce9004bd804fe4e37d99b07158db034fee28e8dddd9d134e01d870d076bf07f8" +sha256 = "sha256:415279b534d8d2080adb8e3209baa5122cf7a5e20d945d71303d107307d75b3b" [load] runtime = "python" diff --git a/examples/python-grpc-worker-plugin/tests/test_worker.py b/examples/python-grpc-worker-plugin/tests/test_worker.py new file mode 100644 index 000000000..ea73d5e50 --- /dev/null +++ b/examples/python-grpc-worker-plugin/tests/test_worker.py @@ -0,0 +1,439 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Atomic contract tests for the standalone Python grpc-v1 worker example.""" + +from __future__ import annotations + +import contextlib +import hashlib +import importlib +import json +import os +import shutil +import subprocess +import sys +import tomllib +from collections.abc import Iterator +from copy import deepcopy +from pathlib import Path +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +if os.environ.get("NEMO_RELAY_SKIP_PYTHON_PLUGIN_TESTS") == "1": + pytest.skip("grpcio is unavailable for Python plugin SDK tests on this runner", allow_module_level=True) + +pytest.importorskip("grpc") + +from nemo_relay_plugin import PluginContext, PluginRuntime, ToolExecutionResult # noqa: E402 + +EXAMPLE_ROOT = Path(__file__).parents[1] +MODULE_NAME = "nemo_relay_python_grpc_worker_example.worker" +PACKAGE_NAME = MODULE_NAME.partition(".")[0] + + +def purge_example_modules() -> None: + for loaded_name in tuple(sys.modules): + if loaded_name == PACKAGE_NAME or loaded_name.startswith(f"{PACKAGE_NAME}."): + sys.modules.pop(loaded_name, None) + + +@pytest.fixture(name="example") +def example_fixture() -> Iterator[Any]: + """Import a fresh copy of the example directly from its own project root.""" + + sys.path.insert(0, str(EXAMPLE_ROOT)) + importlib.invalidate_caches() + purge_example_modules() + try: + yield importlib.import_module(MODULE_NAME) + finally: + purge_example_modules() + sys.path.remove(str(EXAMPLE_ROOT)) + + +def read_manifest() -> dict[str, Any]: + return tomllib.loads((EXAMPLE_ROOT / "relay-plugin.toml").read_text(encoding="utf-8")) + + +def configured_context() -> tuple[MagicMock, MagicMock]: + runtime = MagicMock(spec=PluginRuntime) + runtime.emit_mark = AsyncMock() + runtime.push_scope = AsyncMock(return_value="scope-handle") + runtime.pop_scope = AsyncMock() + runtime.create_scope_stack = AsyncMock(return_value="isolated-stack") + runtime.drop_scope_stack = AsyncMock() + runtime.bind_scope_stack.side_effect = lambda _stack: contextlib.nullcontext() + context = MagicMock(spec=PluginContext) + context.runtime = runtime + return context, runtime + + +def register_example(example: Any) -> tuple[MagicMock, MagicMock]: + context, runtime = configured_context() + example.ExamplePythonWorker().register(context, example.DEFAULT_CONFIG) + return context, runtime + + +def callback(context: MagicMock, method: str, name: str | None = None) -> Any: + calls = getattr(context, method).call_args_list + if name is not None: + for call in calls: + if call.args[0] == name: + return call.args[1] + raise AssertionError(f"{method} did not register {name!r}") + assert len(calls) == 1, f"{method} has multiple callbacks; select one by name" + return calls[0].args[1] + + +def test_manifest_digest_matches_worker_source() -> None: + manifest = read_manifest() + artifact = EXAMPLE_ROOT / manifest["source"]["artifact"] + actual = f"sha256:{hashlib.sha256(artifact.read_bytes()).hexdigest()}" + + assert actual == manifest["integrity"]["sha256"] + + +def test_manifest_declares_current_worker_protocol() -> None: + manifest = read_manifest() + + assert manifest["compat"] == {"relay": ">=0.8.0,<1.0", "worker_protocol": "grpc-v1"} + + +def test_schema_declares_only_supported_groups() -> None: + manifest = read_manifest() + schema = json.loads((EXAMPLE_ROOT / manifest["config_schema"]["path"]).read_text(encoding="utf-8")) + + assert schema["additionalProperties"] is False + assert set(schema["properties"]) == {"tag", "observe", "requests", "execution", "runtime"} + + +def test_project_builds_an_importable_wheel(tmp_path: Path) -> None: + project_root = tmp_path / "project" + wheel_dir = tmp_path / "wheel" + shutil.copytree( + EXAMPLE_ROOT, + project_root, + ignore=shutil.ignore_patterns("build", "dist", "*.egg-info", ".venv", "__pycache__", "*.py[cod]"), + ) + subprocess.run( + ["uv", "build", "--wheel", "--out-dir", str(wheel_dir), str(project_root)], + check=True, + capture_output=True, + text=True, + ) + wheel = next(wheel_dir.glob("*.whl")) + sys.path.insert(0, str(wheel)) + importlib.invalidate_caches() + purge_example_modules() + try: + module = importlib.import_module(MODULE_NAME) + assert module.__file__ is not None + assert Path(module.__file__).is_relative_to(wheel) + finally: + purge_example_modules() + sys.path.remove(str(wheel)) + + +def test_default_configuration_is_valid(example: Any) -> None: + assert example.ExamplePythonWorker().validate(example.DEFAULT_CONFIG) == [] + + +def test_non_object_configuration_is_rejected(example: Any) -> None: + diagnostic = example.ExamplePythonWorker().validate(None)[0] + + assert diagnostic.code == "examples.python_grpc_worker.invalid_config" + + +def test_unsupported_mode_is_rejected(example: Any) -> None: + diagnostics = example.ExamplePythonWorker().validate({"requests": {"mode": "sometimes"}}) + + assert any(item.code == "examples.python_grpc_worker.unsupported_mode" for item in diagnostics) + + +def test_wrong_type_is_rejected(example: Any) -> None: + diagnostics = example.ExamplePythonWorker().validate({"requests": {"priority": "high"}}) + + assert any(item.code == "examples.python_grpc_worker.invalid_type" for item in diagnostics) + + +def test_unknown_field_is_rejected(example: Any) -> None: + diagnostics = example.ExamplePythonWorker().validate({"requests": {"unknown": True}}) + + assert any( + item.code == "examples.python_grpc_worker.unknown_field" and item.level.name == "ERROR" for item in diagnostics + ) + + +def test_register_rejects_invalid_configuration(example: Any) -> None: + with pytest.raises(ValueError, match="requests.priority must be an integer"): + example.ExamplePythonWorker().register( + MagicMock(spec=PluginContext), + {"requests": {"priority": "high"}}, + ) + + +async def test_manifest_entrypoint_serves_worker(example: Any, monkeypatch: pytest.MonkeyPatch) -> None: + served: list[Any] = [] + + async def capture(plugin: Any) -> None: + served.append(plugin) + + monkeypatch.setattr(example, "serve_plugin", capture) + await example.main() + + assert len(served) == 1 + assert isinstance(served[0], example.ExamplePythonWorker) + + +def test_register_installs_all_protocol_surfaces(example: Any) -> None: + context, _runtime = register_example(example) + registration_methods = { + "register_subscriber", + "register_mark_sanitize_guardrail", + "register_scope_sanitize_start_guardrail", + "register_scope_sanitize_end_guardrail", + "register_tool_sanitize_request_guardrail", + "register_tool_sanitize_response_guardrail", + "register_tool_conditional_execution_guardrail", + "register_tool_request_intercept", + "register_tool_execution_intercept", + "register_llm_sanitize_request_guardrail", + "register_llm_sanitize_response_guardrail", + "register_llm_conditional_execution_guardrail", + "register_llm_request_intercept", + "register_llm_execution_intercept", + "register_llm_stream_execution_intercept", + } + + assert all(getattr(context, method).call_count >= 1 for method in registration_methods) + + +def test_runtime_registration_does_not_depend_on_request_configuration(example: Any) -> None: + context, _runtime = configured_context() + config = deepcopy(example.DEFAULT_CONFIG) + config["requests"]["enabled"] = False + + example.ExamplePythonWorker().register(context, config) + + assert context.register_tool_request_intercept.call_count == 0 + callback(context, "register_tool_execution_intercept", "documentation_runtime_events") + + +async def test_subscriber_emits_observation_mark(example: Any) -> None: + context, runtime = register_example(example) + subscriber = callback(context, "register_subscriber") + + await subscriber({"name": "tool.start"}) + + runtime.emit_mark.assert_awaited_once() + + +def test_event_sanitizer_redacts_and_tags_fields(example: Any) -> None: + context, _runtime = register_example(example) + sanitize = callback(context, "register_mark_sanitize_guardrail") + + fields = sanitize( + {"name": "mark"}, + {"data": {"secret": "value"}, "category_profile": {}, "metadata": {}}, + ) + + assert fields["data"] == {"secret": "[REDACTED]"} + assert fields["metadata"]["plugin_tag"] == "documentation" + + +def test_tool_request_sanitizer_redacts_observability_value(example: Any) -> None: + context, _runtime = register_example(example) + sanitize = callback(context, "register_tool_sanitize_request_guardrail") + + assert sanitize("safe_tool", {"secret": "value"}) == {"secret": "[REDACTED]"} + + +def test_tool_response_sanitizer_redacts_observability_value(example: Any) -> None: + context, _runtime = register_example(example) + sanitize = callback(context, "register_tool_sanitize_response_guardrail") + + assert sanitize("safe_tool", {"secret": "value"}) == {"secret": "[REDACTED]"} + + +async def test_llm_request_sanitizer_uses_codec(example: Any) -> None: + context, _runtime = register_example(example) + sanitize = callback(context, "register_llm_sanitize_request_guardrail") + codec = MagicMock() + codec.decode = AsyncMock(return_value={"secret": "value"}) + codec.encode = AsyncMock(return_value={"headers": {}, "content": {"secret": "encoded"}}) + codec_context = MagicMock() + codec_context.resolve_codec.return_value = codec + + result = await sanitize({"headers": {}, "content": {"secret": "raw"}}, codec_context) + + codec.decode.assert_awaited_once() + codec.encode.assert_awaited_once_with({"secret": "[REDACTED]"}, {"headers": {}, "content": {"secret": "raw"}}) + assert result["content"] == {"secret": "[REDACTED]"} + + +async def test_llm_response_sanitizer_uses_codec(example: Any) -> None: + context, _runtime = register_example(example) + sanitize = callback(context, "register_llm_sanitize_response_guardrail") + codec = MagicMock() + codec.decode = AsyncMock(return_value={"message": "decoded"}) + codec_context = MagicMock() + codec_context.resolve_codec.return_value = codec + + result = await sanitize({"secret": "value"}, codec_context) + + codec.decode.assert_awaited_once_with({"secret": "value"}) + assert result == {"secret": "[REDACTED]"} + + +def test_tool_policy_blocks_configured_tool(example: Any) -> None: + context, _runtime = register_example(example) + policy = callback(context, "register_tool_conditional_execution_guardrail") + + assert policy("dangerous_tool", {}) == "tool 'dangerous_tool' is blocked by documentation policy" + + +def test_llm_policy_blocks_configured_model(example: Any) -> None: + context, _runtime = register_example(example) + policy = callback(context, "register_llm_conditional_execution_guardrail") + + assert policy({"headers": {}, "content": {"model": "restricted-model"}}) == ( + "model 'restricted-model' is blocked by documentation policy" + ) + + +async def test_tool_request_intercept_tags_real_request(example: Any) -> None: + context, _runtime = register_example(example) + intercept = callback(context, "register_tool_request_intercept") + + result = await intercept("safe_tool", {"value": 1}) + + assert result == { + "value": 1, + "_nemo_relay_plugin": {"tag": "documentation", "tool": "safe_tool"}, + "plugin_tag": "documentation", + "plugin_tool": "safe_tool", + } + + +async def test_runtime_helpers_clean_up_successful_request(example: Any) -> None: + context, runtime = register_example(example) + intercept = callback(context, "register_tool_execution_intercept", "documentation_runtime_events") + next_call = MagicMock() + next_call.call = AsyncMock(return_value=ToolExecutionResult({"ok": True})) + + await intercept("safe_tool", {"value": 1}, next_call) + + runtime.push_scope.assert_awaited_once() + runtime.pop_scope.assert_awaited_once_with("scope-handle", output={"done": True}) + runtime.drop_scope_stack.assert_awaited_once_with("isolated-stack") + + +async def test_runtime_helpers_close_failed_request(example: Any) -> None: + context, runtime = register_example(example) + runtime.emit_mark.side_effect = RuntimeError("mark failed") + intercept = callback(context, "register_tool_execution_intercept", "documentation_runtime_events") + next_call = MagicMock() + next_call.call = AsyncMock(return_value=ToolExecutionResult({"ok": True})) + + with pytest.raises(RuntimeError, match="mark failed"): + await intercept("safe_tool", {"value": 1}, next_call) + + runtime.pop_scope.assert_awaited_once_with("scope-handle", metadata={"failed": True}) + runtime.create_scope_stack.assert_not_awaited() + + +async def test_runtime_cleanup_preserves_the_callback_error(example: Any) -> None: + context, runtime = register_example(example) + runtime.emit_mark.side_effect = RuntimeError("mark failed") + runtime.pop_scope.side_effect = RuntimeError("cleanup failed") + intercept = callback(context, "register_tool_execution_intercept", "documentation_runtime_events") + next_call = MagicMock() + next_call.call = AsyncMock(return_value=ToolExecutionResult({"ok": True})) + + with pytest.raises(RuntimeError, match="mark failed"): + await intercept("safe_tool", {"value": 1}, next_call) + + runtime.pop_scope.assert_awaited_once_with("scope-handle", metadata={"failed": True}) + + +def test_llm_request_intercept_preserves_outcome_fields(example: Any) -> None: + context, _runtime = register_example(example) + intercept = callback(context, "register_llm_request_intercept") + annotated = {"messages": [{"role": "user", "content": "hello"}]} + + outcome = intercept("allowed-model", {"headers": {}, "content": {}}, annotated) + + assert outcome.request["headers"]["x-nemo-relay-plugin"] == "documentation" + assert outcome.annotated_request is annotated + assert len(outcome.pending_marks) == 1 + assert len(outcome.optimization_contributions) == 1 + + +async def test_tool_execution_returns_pending_mark(example: Any) -> None: + context, _runtime = register_example(example) + intercept = callback(context, "register_tool_execution_intercept", "documentation_tool_execution") + next_call = MagicMock() + next_call.call = AsyncMock(return_value=ToolExecutionResult({"ok": True}, annotation={"source": "application"})) + + outcome = await intercept("safe_tool", {"value": 1}, next_call) + + assert outcome.result == {"ok": True} + assert outcome.annotation == { + "upstream": {"source": "application"}, + "worker": {"tool_name": "safe_tool", "tag": "documentation"}, + } + assert len(outcome.pending_marks) == 1 + + +async def test_llm_execution_can_repeat_continuation(example: Any) -> None: + context, _runtime = register_example(example) + intercept = callback(context, "register_llm_execution_intercept") + next_call = MagicMock() + next_call.call = AsyncMock(side_effect=[{"choice": 1}, {"choice": 2}]) + + result = await intercept( + "allowed-model", + {"headers": {}, "content": {"repeat_downstream": True}}, + next_call, + ) + + assert result == {"choice": 1} + assert next_call.call.await_count == 2 + + +async def test_repeated_llm_continuation_ignores_the_second_failure(example: Any) -> None: + context, _runtime = register_example(example) + intercept = callback(context, "register_llm_execution_intercept") + next_call = MagicMock() + next_call.call = AsyncMock(side_effect=[{"choice": 1}, RuntimeError("second call failed")]) + + result = await intercept( + "allowed-model", + {"headers": {}, "content": {"repeat_downstream": True}}, + next_call, + ) + + assert result == {"choice": 1} + assert next_call.call.await_count == 2 + + +async def test_stream_execution_transforms_chunks_lazily(example: Any) -> None: + context, _runtime = register_example(example) + intercept = callback(context, "register_llm_stream_execution_intercept") + + async def downstream() -> Any: + yield {"chunk": 1} + yield {"chunk": 2} + + next_call = MagicMock() + next_call.call.return_value = downstream() + + stream = intercept("allowed-model", {"headers": {}, "content": {}}, next_call) + + assert [item async for item in stream] == [ + {"chunk": 1, "plugin_stream": True}, + {"chunk": 2, "plugin_stream": True}, + ] diff --git a/examples/python-grpc-worker-plugin/uv.lock b/examples/python-grpc-worker-plugin/uv.lock new file mode 100644 index 000000000..54f011d2a --- /dev/null +++ b/examples/python-grpc-worker-plugin/uv.lock @@ -0,0 +1,190 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "grpcio" +version = "1.83.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0c/98/304898ac4e04e2d5e4e4c2eadc178b1f2a16d5f4bc2f91306c87d64680b9/grpcio-1.83.0.tar.gz", hash = "sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24", size = 13428824, upload-time = "2026-07-23T15:20:37.759Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/f6/3b781cd07a715ea5f5125ae264226e7fc4d87603d6d3955022cabfdc5da2/grpcio-1.83.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:8ff0b8767ddd62704e0d9571c1890af08d84a3a689ebba1807e62519d0b3277f", size = 6338720, upload-time = "2026-07-23T15:19:13.177Z" }, + { url = "https://files.pythonhosted.org/packages/21/cc/d14833d15d5984e366f1b027fa78bd038c9b028c66880bffb0f5a4d25ee2/grpcio-1.83.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:4772402f43517b4824980be4b3b2274a81eec0004a70009473c31b340d43e223", size = 12178773, upload-time = "2026-07-23T15:19:15.401Z" }, + { url = "https://files.pythonhosted.org/packages/6b/98/8acbb416544e7871132d8e42a07ed70c802d70e6a16c6009e505a34d32a4/grpcio-1.83.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f4cee5fc86e84a0cf7ad1574b454c3320e087c07f55b7df5dc0ac6a873fb90c0", size = 6921203, upload-time = "2026-07-23T15:19:17.824Z" }, + { url = "https://files.pythonhosted.org/packages/45/9c/0fdbfaf4fc54e5c88f6bce4008a065092fe7fbc4460eb5617ae8b20fd505/grpcio-1.83.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f5e822a7e7d03282f6ad225e710493c48b9057a353358344a5f7c42b2b37618d", size = 7648508, upload-time = "2026-07-23T15:19:19.685Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ea/107b9dbb2ed3ad14dd774fd3dde7d29ff9938a6c198654becb2c3a0e9a6a/grpcio-1.83.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5f410d7c2903eabb34789dfd6342eef04af1ad459943936b7e09a9f5bd417b9", size = 7079466, upload-time = "2026-07-23T15:19:21.478Z" }, + { url = "https://files.pythonhosted.org/packages/3b/06/9fa9941089e6fae83b060b6ce61c1e81053e52decae43197245f45e07d36/grpcio-1.83.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee94a4016fdf8699fb1fd8a38652475ff677f1c72074cee44deeeb9a7e95e745", size = 7605583, upload-time = "2026-07-23T15:19:23.74Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/f10fb56062dc2771c630827a82d9ad0ecd05cad572ea3b08d49f6631680a/grpcio-1.83.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c6444666317338e903093c7c756e6cc88eee59f798cb8dd41e87725bf54e1617", size = 8637810, upload-time = "2026-07-23T15:19:25.536Z" }, + { url = "https://files.pythonhosted.org/packages/99/55/f84927258f6a1b6ea6dea661fdc6de859b35e560c96f3012d15ccd39f85e/grpcio-1.83.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aa074041231f03959cb097dd5517b0677b8ea49215bae01d5710a7b69dd59969", size = 8008021, upload-time = "2026-07-23T15:19:27.863Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6b/cdf72161397ccd29d4ca2192f641524536c9cf54ad948c9dd0e0e01138fa/grpcio-1.83.0-cp311-cp311-win32.whl", hash = "sha256:cb056f6e171c42639a50460b2929c82241fda51f71cf3dcdd68090fe45095a45", size = 4404376, upload-time = "2026-07-23T15:19:30.137Z" }, + { url = "https://files.pythonhosted.org/packages/df/ed/e0ffeb4c848699c194dc9fb6a29ab29bcb2b6aac8c416bf18c51bfe8242c/grpcio-1.83.0-cp311-cp311-win_amd64.whl", hash = "sha256:7416952ca770477990257206276999056f8316d79196f2f25942393e58a20b49", size = 5164469, upload-time = "2026-07-23T15:19:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/15/2b/51e32514a4e9b715375c99721aadff0f24164cc2049b8269eda4de82a814/grpcio-1.83.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:28f6c35ac8fcf10e4594f138e468f194360089dde40d126a7033e863fc479930", size = 6303167, upload-time = "2026-07-23T15:19:33.78Z" }, + { url = "https://files.pythonhosted.org/packages/39/33/b5b50fc2c6fbe350e04814047bb2d409feec7b36ef8b170254c050e06bc0/grpcio-1.83.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:33898e6a28e4ae598f1577cb1c4fec2a15c033d0ec52b9b45a09610dd045b9da", size = 12160538, upload-time = "2026-07-23T15:19:35.958Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5f/734e72e7b9f79bcf0b2c270b8d3bca0e4ebb97a27a50d06240b145f6d41e/grpcio-1.83.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6fb8a1dd0c6f0f931e69e9d0dc6d1c406ed2a44fa963414eafba07b7fb685d16", size = 6869310, upload-time = "2026-07-23T15:19:38.607Z" }, + { url = "https://files.pythonhosted.org/packages/a4/17/a1735f215b2a5cd43c38b79eac072ad197e61be9829905b6b29550abd0db/grpcio-1.83.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2b5e75c34842cd9c1b95285ca395c6a569664b81e3ffa6b714125922942abaaf", size = 7613472, upload-time = "2026-07-23T15:19:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/b2/78/c9e81f806ac704b6b145cb01628db398985b1f8dfdc10e23b55fb0902b3d/grpcio-1.83.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeb339838db07600481ef869507279b75326c75eac6d10f7afa62a0da1d2bcdd", size = 7040616, upload-time = "2026-07-23T15:19:42.349Z" }, + { url = "https://files.pythonhosted.org/packages/9a/ba/94cd5af859876049d340480acbb61a959096c84b567f215534faa78d0424/grpcio-1.83.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f47d62808b4c0a97b78bff88a6d4ca283a2a492b9a04a87d814af95ca3b9c19c", size = 7570491, upload-time = "2026-07-23T15:19:44.357Z" }, + { url = "https://files.pythonhosted.org/packages/3e/15/108d30d5a5c964312ae8b9cb0e8cc5b3c1cc68d8f757cca52b3565534d26/grpcio-1.83.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62003babc444a606dcd1f009cd16391ce23669ae4ad6ec267a873da7937a69f5", size = 8605036, upload-time = "2026-07-23T15:19:46.454Z" }, + { url = "https://files.pythonhosted.org/packages/ea/23/3828ae13c3db8233d123ad612747665817b952d8a954f32390230b582336/grpcio-1.83.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1aa567f8c3f19850ffd5d2858c9a8ea7c80f0db6c01186b71eb31e923ec984f5", size = 7981587, upload-time = "2026-07-23T15:19:48.913Z" }, + { url = "https://files.pythonhosted.org/packages/17/5b/77af31228f55f55a2a5112bb0077ad0a1c4d23dbb0c2853a62475bbdcc14/grpcio-1.83.0-cp312-cp312-win32.whl", hash = "sha256:cb2906c61db4f9c64cc360054b5df70eeb81846228e9e56a4944bd415a63dadc", size = 4394004, upload-time = "2026-07-23T15:19:50.618Z" }, + { url = "https://files.pythonhosted.org/packages/c0/da/f706e39550e7a3732ce2b9c5926107a93d74a802775b19b642a6df27dc96/grpcio-1.83.0-cp312-cp312-win_amd64.whl", hash = "sha256:1c699bbb20f143c8f2bff219de578aa2dc1f919399d67dc702b038b986ee62df", size = 5158525, upload-time = "2026-07-23T15:19:52.246Z" }, + { url = "https://files.pythonhosted.org/packages/56/eb/135daaa713f32d33b8f99b4153b3f8dc3b2a124996ac15581bf9ebdad3c3/grpcio-1.83.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:6662f3b1e07cc7493d437351860dc867bddc6a93c83ecf33bbfdaf0c217ab2d0", size = 6304480, upload-time = "2026-07-23T15:19:53.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/a1/121806ce69f23138dabe06aa595b0e5f1ae051a37e4c1954eed7d692c800/grpcio-1.83.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:74fe6f9e8a35c7dbf32255ee154d15e3e5338a81ed39173d079d594d2e544cd1", size = 12154419, upload-time = "2026-07-23T15:19:56.3Z" }, + { url = "https://files.pythonhosted.org/packages/b0/e8/d0389e09cd6b4c4d3089b92967ae4e3ffd64795bd349bf2f85cd6656d3da/grpcio-1.83.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10b3fa0475eb572c9a81a6fe37fa16a9c500c0c91cfc148cac15692b7e3c2867", size = 6873200, upload-time = "2026-07-23T15:19:58.701Z" }, + { url = "https://files.pythonhosted.org/packages/f8/51/f464c1d211fa50d5adbabe1b2e519948d99c13757052bfc9ea7afa28e284/grpcio-1.83.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5f20a988480b0f28207f057f7f7ae1313393c3cef0adcfeae8248f9947eaf881", size = 7618811, upload-time = "2026-07-23T15:20:00.733Z" }, + { url = "https://files.pythonhosted.org/packages/e8/c0/539fe0832f2dd6500a28f5263071623fb34e8d4867aec632ccf81bd21156/grpcio-1.83.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bd82671b39065ba18cd536e9cd45b27ff649053f81ddd2c6a966d595067080f", size = 7042310, upload-time = "2026-07-23T15:20:02.675Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ca/ccf617d37ffa72567fa8e005ec7090c99da922799be2fb9847c8b21ca18c/grpcio-1.83.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc60215b5cb9fc8ca72942c498b551ac2305bd08f6ef8d4e3f0d21b64fbecd61", size = 7575412, upload-time = "2026-07-23T15:20:04.712Z" }, + { url = "https://files.pythonhosted.org/packages/eb/b9/fd8d5245f823a8e0fd35d90e20ea3aa4acd47f8d5318fa8df307df52dec6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f1c3e5689d4b90987b1d72022bcfe866a9a3dc66197484cf856d96b6150e7f45", size = 8604248, upload-time = "2026-07-23T15:20:06.77Z" }, + { url = "https://files.pythonhosted.org/packages/14/1e/f37632fc11db72dfa4bba86c3a43e54358e53030df111ecae5e91a733ad6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a21cb4eeeba124443f399be2e8b624943cde864dcbe588cb42e5c483a52a906c", size = 7977458, upload-time = "2026-07-23T15:20:09.109Z" }, + { url = "https://files.pythonhosted.org/packages/93/b6/d70b69ae5c0cfc341b9ba474980e4ed99cbf05c0e4a14e9eee8cb73db0a5/grpcio-1.83.0-cp313-cp313-win32.whl", hash = "sha256:8fe04f1050a59f875601eb55d42b4f66946fe89817f967e34db1462ccd07dadf", size = 4393993, upload-time = "2026-07-23T15:20:11.017Z" }, + { url = "https://files.pythonhosted.org/packages/0f/13/45d4cccb555cf4c476226979bf3d2fd0b0254216f7564c3a053e35117efc/grpcio-1.83.0-cp313-cp313-win_amd64.whl", hash = "sha256:6e01ecd9d8ef280abe1365138a4dc318f9a5287f4cb1b41d07816f796653f735", size = 5159650, upload-time = "2026-07-23T15:20:12.979Z" }, + { url = "https://files.pythonhosted.org/packages/9c/60/f2cca8147ea213d3e43ae9158d03ad04e020fdf32ff027253e1fe93f921d/grpcio-1.83.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3f351629f6ae16ecc0ec3553e586a6763ffd9f6114044286d0cbec3e09241bfa", size = 6305607, upload-time = "2026-07-23T15:20:15.353Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ab/d3874931d123a95e83a3ebf8aa04537988fb62425cedb8bf3cefc5ad41b2/grpcio-1.83.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d05ff664100d429335b93c91b8b34ddf9e94a112205e7fa06dede309e44a4e4c", size = 12166617, upload-time = "2026-07-23T15:20:17.435Z" }, + { url = "https://files.pythonhosted.org/packages/92/ff/6f18f9426b69306f4e00a9add3b0ee2748da8aad53836ef80cab0d62d04f/grpcio-1.83.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7936f2a56cf04f6514705c0fedf400971de01b6aa1719327e4718f410a765e2b", size = 6880213, upload-time = "2026-07-23T15:20:19.98Z" }, + { url = "https://files.pythonhosted.org/packages/70/21/706d1147c6b93b98f179240c13991fbcc56880eba0c868abb1ad40d8a0a6/grpcio-1.83.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b0a0be840e51b6b7ee9df9269770faf77bdf4b771053c257c21d12bad607714c", size = 7618335, upload-time = "2026-07-23T15:20:22.161Z" }, + { url = "https://files.pythonhosted.org/packages/74/04/1a8443c889115ec9e213a213e86bc93a71ee9088027e5befa09aaa0edd9d/grpcio-1.83.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:009667eaf3dcd5224c713589cdc98e7ca4ed0ff0b61132c6b276e930eb83a2df", size = 7043416, upload-time = "2026-07-23T15:20:24.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/c6/94e0fee5b12bc1da1370185b680988db6f739d19b42d9959db01a7ea50bf/grpcio-1.83.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb669918fd88936b15599caff4160a77ab74bdeb25f2231f6e45b61282d6107b", size = 7583253, upload-time = "2026-07-23T15:20:26.313Z" }, + { url = "https://files.pythonhosted.org/packages/a0/97/de1ccb671fb85575bc5192faedf9ecdbdf5b390d2e6584dcf552bcbd370e/grpcio-1.83.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c19b454d3d3f28db81f2c7c4dbaee96e7f6fd149721733ffe79d6bc530f17404", size = 8605102, upload-time = "2026-07-23T15:20:28.437Z" }, + { url = "https://files.pythonhosted.org/packages/17/0f/0e0ec749a7034ffcbaa050e39779872950ead90c22e7e0116be3f28b2b46/grpcio-1.83.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:61007cd08640abc5c54547ee32505474c482cd733a53cb87551ea81faa6350af", size = 7979826, upload-time = "2026-07-23T15:20:31.182Z" }, + { url = "https://files.pythonhosted.org/packages/83/fa/c3fda157287f64bc65acee6c5aa90c41acf9e0d3a8e69a265eecff6d00a1/grpcio-1.83.0-cp314-cp314-win32.whl", hash = "sha256:32e11c37f5285b0c6fa3042c05fe06903696689749833fc64e67dec71b9bbe33", size = 4471765, upload-time = "2026-07-23T15:20:33.195Z" }, + { url = "https://files.pythonhosted.org/packages/a1/00/b1b26431c9d54eee11724fd6e5585473a2ed47fbc1fb95e5204906a642ce/grpcio-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:2bb48cb5e6dd005ca12b89ce4b6ac0b48ff3112c747542ee7986ef611a8ca6d9", size = 5298932, upload-time = "2026-07-23T15:20:35.48Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "nemo-relay-plugin" +version = "0.8.0" +source = { directory = "../../python/plugin" } +dependencies = [ + { name = "grpcio" }, + { name = "protobuf" }, +] + +[package.metadata] +requires-dist = [ + { name = "grpcio", specifier = ">=1.81.1,<2" }, + { name = "protobuf", specifier = ">=6.33.5" }, +] + +[[package]] +name = "nemo-relay-python-grpc-worker-example" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "nemo-relay-plugin" }, +] + +[package.dev-dependencies] +test = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, +] + +[package.metadata] +requires-dist = [{ name = "nemo-relay-plugin", directory = "../../python/plugin" }] + +[package.metadata.requires-dev] +test = [ + { name = "pytest", specifier = ">=8" }, + { name = "pytest-asyncio", specifier = ">=0.26" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a", size = 458717, upload-time = "2026-06-11T21:55:40.257Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6", size = 433226, upload-time = "2026-06-11T21:55:31.719Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799", size = 328847, upload-time = "2026-06-11T21:55:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4", size = 344030, upload-time = "2026-06-11T21:55:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4", size = 327130, upload-time = "2026-06-11T21:55:35.921Z" }, + { url = "https://files.pythonhosted.org/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30", size = 428945, upload-time = "2026-06-11T21:55:37.034Z" }, + { url = "https://files.pythonhosted.org/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87", size = 439996, upload-time = "2026-06-11T21:55:38.123Z" }, + { url = "https://files.pythonhosted.org/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9", size = 171659, upload-time = "2026-06-11T21:55:39.155Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] diff --git a/examples/rust-grpc-worker-plugin/.gitignore b/examples/rust-grpc-worker-plugin/.gitignore new file mode 100644 index 000000000..d2577c43c --- /dev/null +++ b/examples/rust-grpc-worker-plugin/.gitignore @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +/target/ +/Cargo.lock diff --git a/examples/rust-grpc-worker-plugin/Cargo.toml b/examples/rust-grpc-worker-plugin/Cargo.toml new file mode 100644 index 000000000..add9a1ecd --- /dev/null +++ b/examples/rust-grpc-worker-plugin/Cargo.toml @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "nemo-relay-rust-grpc-worker-plugin-example" +version = "0.1.0" +edition = "2024" +publish = false +description = "Complete Rust grpc-v1 worker example for NeMo Relay" + +[workspace] + +[dependencies] +futures-util = "0.3" +nemo-relay-worker = { version = "0.8.0", path = "../../crates/worker" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } + +[dev-dependencies] +nemo-relay = { version = "0.8.0", path = "../../crates/core", features = ["worker-grpc"] } +sha2 = "0.11" +tempfile = "3" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync"] } + +[lib] +name = "nemo_relay_rust_grpc_worker_plugin_example" + +[[bin]] +name = "nemo-relay-rust-grpc-worker-plugin-example" +path = "src/main.rs" diff --git a/examples/rust-grpc-worker-plugin/README.md b/examples/rust-grpc-worker-plugin/README.md new file mode 100644 index 000000000..3a8a80360 --- /dev/null +++ b/examples/rust-grpc-worker-plugin/README.md @@ -0,0 +1,21 @@ + + +# Rust gRPC Worker Plugin + +This project is the checked Rust worker used by the NeMo Relay plugin authoring +guide. It validates the shared documentation configuration, registers every +safe `grpc-v1` surface, exercises continuations and lazy streams, uses +invocation-scoped codecs, and demonstrates marks and scope-stack cleanup. + +Run `cargo test` and `cargo build` from this directory. The configuration and +schema tests are order-independent. The lifecycle test builds a fresh worker, +materializes a digest-checked manifest, activates it through `grpc-v1`, runs +managed middleware, observes a host-runtime mark, and verifies shutdown. Copy +`relay-plugin.toml` to `relay-plugin.local.toml`, replace the platform worker +placeholder with the built executable name, and replace only `` +with the lowercase hexadecimal digest of the built executable. The manifest value +keeps its `sha256:` prefix; omit the filename column printed by `shasum -a 256`, +`sha256sum`, or `Get-FileHash`. diff --git a/examples/rust-grpc-worker-plugin/config.schema.json b/examples/rust-grpc-worker-plugin/config.schema.json new file mode 100644 index 000000000..1b203dfcd --- /dev/null +++ b/examples/rust-grpc-worker-plugin/config.schema.json @@ -0,0 +1,124 @@ +{ + "$comment": "SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Rust gRPC Worker Documentation Plugin", + "type": "object", + "additionalProperties": true, + "properties": { + "tag": { + "description": "Non-empty label attached to plugin-created metadata and runtime events.", + "type": "string", + "minLength": 1, + "default": "documentation" + }, + "observe": { + "description": "Controls the subscriber and all observability sanitizers.", + "type": "object", + "additionalProperties": true, + "properties": { + "enabled": { + "description": "Registers the subscriber and event, tool, and LLM observability sanitizers.", + "type": "boolean", + "default": true + }, + "redact_keys": { + "description": "Object keys whose observability values are replaced recursively.", + "type": "array", + "items": { "type": "string" }, + "default": ["secret"] + } + } + }, + "requests": { + "description": "Controls blocking and real request rewriting.", + "type": "object", + "additionalProperties": true, + "properties": { + "enabled": { + "description": "Registers tool and model policy plus request-rewriting middleware.", + "type": "boolean", + "default": true + }, + "mode": { + "description": "Blocks configured tool and model names in enforce mode; permits them in observe mode.", + "type": "string", + "enum": ["observe", "enforce"], + "default": "enforce" + }, + "blocked_tools": { + "description": "Exact tool names rejected in enforce mode.", + "type": "array", + "items": { "type": "string" }, + "default": ["dangerous_tool"] + }, + "blocked_models": { + "description": "Exact model names rejected in enforce mode.", + "type": "array", + "items": { "type": "string" }, + "default": ["restricted-model"] + }, + "header_name": { + "description": "Header name added to the real LLM request.", + "type": "string", + "minLength": 1, + "default": "x-nemo-relay-plugin" + }, + "header_value": { + "description": "Header value added to the real LLM request.", + "type": "string", + "minLength": 1, + "default": "documentation" + }, + "priority": { + "description": "Priority for the tool and LLM request intercepts.", + "type": "integer", + "default": 20 + }, + "break_chain": { + "description": "Stops later request intercepts after this component rewrites a request.", + "type": "boolean", + "default": false + } + } + }, + "execution": { + "description": "Controls tool, unary LLM, and streaming continuation wrappers.", + "type": "object", + "additionalProperties": true, + "properties": { + "enabled": { + "description": "Registers tool, unary LLM, and streaming LLM execution intercepts.", + "type": "boolean", + "default": true + }, + "priority": { + "description": "Priority for all three execution intercepts.", + "type": "integer", + "default": 30 + }, + "emit_pending_marks": { + "description": "Adds Relay-owned pending marks to tool execution and LLM request outcomes.", + "type": "boolean", + "default": true + } + } + }, + "runtime": { + "description": "Controls host marks, scopes, and isolated scope stacks.", + "type": "object", + "additionalProperties": true, + "properties": { + "emit_marks": { + "description": "Emits a host-runtime mark during the tool request intercept.", + "type": "boolean", + "default": true + }, + "emit_isolated_scope": { + "description": "Creates, binds, and drops an isolated scope stack during the tool request intercept.", + "type": "boolean", + "default": true + } + } + } + } +} diff --git a/examples/rust-grpc-worker-plugin/relay-plugin.toml b/examples/rust-grpc-worker-plugin/relay-plugin.toml new file mode 100644 index 000000000..f2d8982f0 --- /dev/null +++ b/examples/rust-grpc-worker-plugin/relay-plugin.toml @@ -0,0 +1,31 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +manifest_version = 1 + +[plugin] +id = "examples.rust_grpc_worker" +kind = "worker" + +[compat] +relay = ">=0.8.0,<1.0" +worker_protocol = "grpc-v1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_worker", "config_schema"] + +[config_schema] +path = "config.schema.json" + +[source] +artifact = "target/debug/" + +[integrity] +sha256 = "sha256:" + +[load] +runtime = "rust" +entrypoint = "target/debug/" diff --git a/examples/rust-grpc-worker-plugin/src/config.rs b/examples/rust-grpc-worker-plugin/src/config.rs new file mode 100644 index 000000000..d296ee174 --- /dev/null +++ b/examples/rust-grpc-worker-plugin/src/config.rs @@ -0,0 +1,233 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashSet; + +use nemo_relay_worker::{ConfigDiagnostic, DiagnosticLevel, Json}; +use serde::{Deserialize, Serialize}; +use serde_json::Map; + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(default)] +pub(crate) struct ExampleConfig { + pub tag: String, + pub observe: ObserveConfig, + pub requests: RequestsConfig, + pub execution: ExecutionConfig, + pub runtime: RuntimeConfig, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(default)] +pub(crate) struct ObserveConfig { + pub enabled: bool, + pub redact_keys: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(default)] +pub(crate) struct RequestsConfig { + pub enabled: bool, + pub mode: String, + pub blocked_tools: Vec, + pub blocked_models: Vec, + pub header_name: String, + pub header_value: String, + pub priority: i32, + pub break_chain: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(default)] +pub(crate) struct ExecutionConfig { + pub enabled: bool, + pub priority: i32, + pub emit_pending_marks: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(default)] +pub(crate) struct RuntimeConfig { + pub emit_marks: bool, + pub emit_isolated_scope: bool, +} + +impl Default for ExampleConfig { + fn default() -> Self { + Self { + tag: "documentation".into(), + observe: ObserveConfig::default(), + requests: RequestsConfig::default(), + execution: ExecutionConfig::default(), + runtime: RuntimeConfig::default(), + } + } +} + +impl Default for ObserveConfig { + fn default() -> Self { + Self { + enabled: true, + redact_keys: vec!["secret".into()], + } + } +} + +impl Default for RequestsConfig { + fn default() -> Self { + Self { + enabled: true, + mode: "enforce".into(), + blocked_tools: vec!["dangerous_tool".into()], + blocked_models: vec!["restricted-model".into()], + header_name: "x-nemo-relay-plugin".into(), + header_value: "documentation".into(), + priority: 20, + break_chain: false, + } + } +} + +impl Default for ExecutionConfig { + fn default() -> Self { + Self { + enabled: true, + priority: 30, + emit_pending_marks: true, + } + } +} + +impl Default for RuntimeConfig { + fn default() -> Self { + Self { + emit_marks: true, + emit_isolated_scope: true, + } + } +} + +impl ExampleConfig { + pub(crate) fn parse(config: &Json) -> Result { + serde_json::from_value(config.clone()).map_err(|error| error.to_string()) + } +} + +pub(crate) fn validate(config: &Json) -> Vec { + let mut diagnostics = Vec::new(); + validate_unknown_fields(config, &mut diagnostics); + let parsed = match ExampleConfig::parse(config) { + Ok(parsed) => parsed, + Err(error) => { + diagnostics.push(diagnostic( + DiagnosticLevel::Error, + "invalid_config", + None, + error, + )); + return diagnostics; + } + }; + if parsed.tag.is_empty() { + diagnostics.push(diagnostic( + DiagnosticLevel::Error, + "empty_tag", + Some("tag"), + "tag must not be empty", + )); + } + if parsed.requests.mode != "observe" && parsed.requests.mode != "enforce" { + diagnostics.push(diagnostic( + DiagnosticLevel::Error, + "unsupported_mode", + Some("requests.mode"), + "requests.mode must be either observe or enforce", + )); + } + if parsed.requests.header_name.is_empty() { + diagnostics.push(diagnostic( + DiagnosticLevel::Error, + "invalid_header", + Some("requests.header_name"), + "requests.header_name must not be empty", + )); + } + if parsed.requests.header_value.is_empty() { + diagnostics.push(diagnostic( + DiagnosticLevel::Error, + "invalid_header", + Some("requests.header_value"), + "requests.header_value must not be empty", + )); + } + diagnostics +} + +fn validate_unknown_fields(config: &Json, diagnostics: &mut Vec) { + let Some(config) = config.as_object() else { + return; + }; + const TOP_LEVEL: &[&str] = &["tag", "observe", "requests", "execution", "runtime"]; + const OBSERVE: &[&str] = &["enabled", "redact_keys"]; + const REQUESTS: &[&str] = &[ + "enabled", + "mode", + "blocked_tools", + "blocked_models", + "header_name", + "header_value", + "priority", + "break_chain", + ]; + const EXECUTION: &[&str] = &["enabled", "priority", "emit_pending_marks"]; + const RUNTIME: &[&str] = &["emit_marks", "emit_isolated_scope"]; + + report_unknown(config, "", TOP_LEVEL, diagnostics); + for (field, allowed) in [ + ("observe", OBSERVE), + ("requests", REQUESTS), + ("execution", EXECUTION), + ("runtime", RUNTIME), + ] { + if let Some(object) = config.get(field).and_then(Json::as_object) { + report_unknown(object, field, allowed, diagnostics); + } + } +} + +fn report_unknown( + object: &Map, + prefix: &str, + allowed: &[&str], + diagnostics: &mut Vec, +) { + let allowed = allowed.iter().copied().collect::>(); + for key in object.keys().filter(|key| !allowed.contains(key.as_str())) { + let field = if prefix.is_empty() { + key.clone() + } else { + format!("{prefix}.{key}") + }; + diagnostics.push(diagnostic( + DiagnosticLevel::Warning, + "unknown_field", + Some(&field), + format!("unknown config field '{field}' is not supported"), + )); + } +} + +fn diagnostic( + level: DiagnosticLevel, + suffix: &str, + field: Option<&str>, + message: impl Into, +) -> ConfigDiagnostic { + ConfigDiagnostic { + level, + code: format!("examples.rust_grpc_worker.{suffix}"), + component: Some("examples.rust_grpc_worker".into()), + field: field.map(str::to_owned), + message: message.into(), + } +} diff --git a/examples/rust-grpc-worker-plugin/src/lib.rs b/examples/rust-grpc-worker-plugin/src/lib.rs new file mode 100644 index 000000000..f6ac9b99f --- /dev/null +++ b/examples/rust-grpc-worker-plugin/src/lib.rs @@ -0,0 +1,408 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +mod config; + +use futures_util::StreamExt; +use nemo_relay_worker::{ + ConfigDiagnostic, EventSanitizeFields, Json, JsonStream, LlmOptimizationContribution, + LlmRequestInterceptOutcome, PendingMarkSpec, PluginContext, PluginRuntime, Result, ScopeType, + ToolExecutionInterceptOutcome, WorkerPlugin, WorkerSdkError, +}; +use serde_json::json; + +use config::ExampleConfig; + +/// Complete worker implementation used by the plugin authoring documentation. +pub struct DocumentationWorker; + +impl WorkerPlugin for DocumentationWorker { + fn plugin_id(&self) -> &str { + "examples.rust_grpc_worker" + } + + fn allows_multiple_components(&self) -> bool { + false + } + + fn validate(&self, config: &Json) -> Vec { + config::validate(config) + } + + fn register(&self, context: &mut PluginContext, config: &Json) -> Result<()> { + let config = ExampleConfig::parse(config).map_err(WorkerSdkError::InvalidInput)?; + if config.observe.enabled { + register_observation(context, &config); + } + if config.requests.enabled { + register_requests(context, &config); + } + register_execution(context, &config); + Ok(()) + } +} + +/// Validate the shared example configuration without starting the worker server. +pub fn validate_example_config(config: &Json) -> Vec { + config::validate(config) +} + +/// Return the configuration defaults as the JSON document accepted by this worker. +/// +/// The integration tests compare this document with the checked JSON Schema so the +/// documented defaults cannot drift from the runtime defaults. +pub fn default_example_config() -> Json { + serde_json::to_value(ExampleConfig::default()).expect("example configuration must serialize") +} + +fn register_observation(context: &mut PluginContext, config: &ExampleConfig) { + context.register_subscriber("documentation_subscriber", |_event| {}); + + let sanitize_fields = |mut fields: EventSanitizeFields, keys: &[String], tag: &str| { + fields.data = fields.data.map(|value| redact(value, keys)); + fields.metadata = Some(tag_metadata(fields.metadata, tag, keys)); + if let Some(profile) = fields.category_profile.take() { + fields.category_profile = serde_json::to_value(&profile) + .map(|value| redact(value, keys)) + .and_then(serde_json::from_value) + .ok() + .or(Some(profile)); + } + fields + }; + context.register_mark_sanitize_guardrail("documentation_mark_sanitizer", 10, { + let keys = config.observe.redact_keys.clone(); + let tag = config.tag.clone(); + move |_event, fields| { + let fields = sanitize_fields(fields, &keys, &tag); + async move { Ok(fields) } + } + }); + context.register_scope_sanitize_start_guardrail("documentation_scope_start_sanitizer", 10, { + let keys = config.observe.redact_keys.clone(); + let tag = config.tag.clone(); + move |_event, fields| { + let fields = sanitize_fields(fields, &keys, &tag); + async move { Ok(fields) } + } + }); + context.register_scope_sanitize_end_guardrail("documentation_scope_end_sanitizer", 10, { + let keys = config.observe.redact_keys.clone(); + let tag = config.tag.clone(); + move |_event, fields| { + let fields = sanitize_fields(fields, &keys, &tag); + async move { Ok(fields) } + } + }); + context.register_tool_sanitize_request_guardrail("documentation_tool_request_sanitizer", 10, { + let keys = config.observe.redact_keys.clone(); + move |_name, value| { + let keys = keys.clone(); + async move { Ok(redact(value, &keys)) } + } + }); + context.register_tool_sanitize_response_guardrail( + "documentation_tool_response_sanitizer", + 10, + { + let keys = config.observe.redact_keys.clone(); + move |_name, value| { + let keys = keys.clone(); + async move { Ok(redact(value, &keys)) } + } + }, + ); + context.register_llm_sanitize_request_guardrail("documentation_llm_request_sanitizer", 10, { + let keys = config.observe.redact_keys.clone(); + move |mut request, codec_context| { + let keys = keys.clone(); + async move { + if let Some(codec) = codec_context.resolve_codec() { + let annotated = codec.decode(&request).await?; + let annotated = serde_json::to_value(annotated) + .map(|value| redact(value, &keys)) + .and_then(serde_json::from_value) + .map_err(|error| WorkerSdkError::InvalidInput(error.to_string()))?; + request = codec.encode(&annotated, &request).await?; + } + request.content = redact(request.content, &keys); + Ok(Some(request)) + } + } + }); + context.register_llm_sanitize_response_guardrail("documentation_llm_response_sanitizer", 10, { + let keys = config.observe.redact_keys.clone(); + move |response, codec_context| { + let keys = keys.clone(); + async move { + if let Some(codec) = codec_context.resolve_codec() { + let _annotated = codec.decode(&response).await?; + } + Ok(Some(redact(response, &keys))) + } + } + }); +} + +fn register_requests(context: &mut PluginContext, config: &ExampleConfig) { + context.register_tool_conditional_execution_guardrail("documentation_tool_policy", 10, { + let mode = config.requests.mode.clone(); + let blocked = config.requests.blocked_tools.clone(); + move |name, _value| { + let mode = mode.clone(); + let blocked = blocked.clone(); + async move { + Ok((mode == "enforce" && blocked.contains(&name)) + .then(|| format!("tool '{name}' is blocked by documentation policy"))) + } + } + }); + context.register_tool_request_intercept( + "documentation_tool_request", + config.requests.priority, + config.requests.break_chain, + { + let tag = config.tag.clone(); + move |name, value| { + let tag = tag.clone(); + async move { Ok(tag_tool_request(value, &name, &tag)) } + } + }, + ); + context.register_llm_conditional_execution_guardrail("documentation_llm_policy", 10, { + let mode = config.requests.mode.clone(); + let blocked = config.requests.blocked_models.clone(); + move |request| { + let mode = mode.clone(); + let blocked = blocked.clone(); + async move { + let model = request + .content + .get("model") + .and_then(Json::as_str) + .unwrap_or_default(); + Ok( + (mode == "enforce" && blocked.iter().any(|candidate| candidate == model)) + .then(|| format!("model '{model}' is blocked by documentation policy")), + ) + } + } + }); + context.register_llm_request_intercept( + "documentation_llm_request", + config.requests.priority, + config.requests.break_chain, + { + let header_name = config.requests.header_name.clone(); + let header_value = config.requests.header_value.clone(); + let tag = config.tag.clone(); + let emit_marks = config.execution.emit_pending_marks; + move |_model, mut request, annotated| { + let header_name = header_name.clone(); + let header_value = header_value.clone(); + let tag = tag.clone(); + async move { + request + .headers + .insert(header_name.clone(), Json::String(header_value)); + let mut outcome = LlmRequestInterceptOutcome::new(request, annotated) + .with_optimization_contribution(LlmOptimizationContribution::new( + "examples.rust_grpc_worker", + "request_rewrite", + )); + if emit_marks { + outcome = outcome.with_pending_mark( + PendingMarkSpec::builder() + .name("example.rust_worker.llm_request") + .data(json!({ "tag": tag })) + .build(), + ); + } + Ok(outcome) + } + } + }, + ); +} + +fn register_execution(context: &mut PluginContext, config: &ExampleConfig) { + if (config.runtime.emit_marks || config.runtime.emit_isolated_scope) + && let Some(runtime) = context.runtime() + { + context.register_tool_execution_intercept("documentation_runtime_events", 0, { + let tag = config.tag.clone(); + let runtime_config = config.runtime.clone(); + move |_name, value, next| { + let runtime = runtime.clone(); + let tag = tag.clone(); + let runtime_config = runtime_config.clone(); + async move { + emit_runtime_events(&runtime, &tag, &runtime_config).await?; + Ok(ToolExecutionInterceptOutcome::from(next.call(value).await?)) + } + } + }); + } + + if !config.execution.enabled { + return; + } + + context.register_tool_execution_intercept( + "documentation_tool_execution", + config.execution.priority, + { + let emit_marks = config.execution.emit_pending_marks; + move |_name, value, next| async move { + let result = next.call(value).await?; + let mut outcome = ToolExecutionInterceptOutcome::from(result); + if emit_marks { + outcome = outcome.with_pending_mark( + PendingMarkSpec::builder() + .name("example.rust_worker.tool_execution") + .build(), + ); + } + Ok(outcome) + } + }, + ); + context.register_llm_execution_intercept( + "documentation_llm_execution", + config.execution.priority, + move |_model, request, next| async move { + if request + .content + .get("repeat_downstream") + .and_then(Json::as_bool) + .unwrap_or(false) + { + let repeated = next.clone(); + let (first, _second) = + tokio::join!(repeated.call(request.clone()), next.call(request)); + first + } else { + next.call(request).await + } + }, + ); + context.register_llm_stream_execution_intercept( + "documentation_llm_stream_execution", + config.execution.priority, + move |_model, request, next| async move { + let stream = next.call(request).await?; + let mapped: JsonStream = Box::pin(stream.map(|chunk| { + chunk.map(|chunk| match chunk { + Json::Object(mut object) => { + object.insert("plugin_stream".into(), Json::Bool(true)); + Json::Object(object) + } + other => other, + }) + })); + Ok(mapped) + }, + ); +} + +async fn emit_runtime_events( + runtime: &PluginRuntime, + tag: &str, + config: &config::RuntimeConfig, +) -> Result<()> { + let handle = runtime + .push_scope( + None, + "example.rust_worker.request", + ScopeType::Custom, + Some(json!({ "tag": tag })), + None, + None, + ) + .await?; + let work = if config.emit_marks { + runtime + .emit_mark( + "example.rust_worker.request.seen", + Some(json!({ "tag": tag })), + None, + ) + .await + } else { + Ok(()) + }; + match work { + Ok(()) => { + runtime + .pop_scope(&handle, Some(json!({ "done": true })), None) + .await?; + } + Err(error) => { + let _ = runtime + .pop_scope(&handle, None, Some(json!({ "failed": true }))) + .await; + return Err(error); + } + } + + if config.emit_isolated_scope { + let stack = runtime.create_scope_stack().await?; + let emitted = runtime + .with_scope_stack(&stack, || async { + runtime + .emit_mark( + "example.rust_worker.isolated.mark", + Some(json!({ "tag": tag })), + None, + ) + .await + }) + .await; + let dropped = runtime.drop_scope_stack(&stack).await; + emitted?; + dropped?; + } + Ok(()) +} + +fn tag_tool_request(value: Json, name: &str, tag: &str) -> Json { + match value { + Json::Object(mut object) => { + object.insert("plugin_tag".into(), Json::String(tag.into())); + object.insert("plugin_tool".into(), Json::String(name.into())); + Json::Object(object) + } + other => other, + } +} + +fn redact(value: Json, keys: &[String]) -> Json { + match value { + Json::Object(mut object) => { + for (key, value) in &mut object { + if keys.iter().any(|candidate| candidate == key) { + *value = Json::String("[REDACTED]".into()); + } else { + *value = redact(value.take(), keys); + } + } + Json::Object(object) + } + Json::Array(values) => Json::Array( + values + .into_iter() + .map(|value| redact(value, keys)) + .collect(), + ), + other => other, + } +} + +fn tag_metadata(metadata: Option, tag: &str, keys: &[String]) -> Json { + let mut object = match redact(metadata.unwrap_or_else(|| json!({})), keys) { + Json::Object(object) => object, + other => serde_json::Map::from_iter([("original".into(), other)]), + }; + object.insert("plugin_tag".into(), Json::String(tag.into())); + Json::Object(object) +} diff --git a/examples/rust-grpc-worker-plugin/src/main.rs b/examples/rust-grpc-worker-plugin/src/main.rs new file mode 100644 index 000000000..463f28246 --- /dev/null +++ b/examples/rust-grpc-worker-plugin/src/main.rs @@ -0,0 +1,10 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use nemo_relay_rust_grpc_worker_plugin_example::DocumentationWorker; +use nemo_relay_worker::{Result, serve_plugin}; + +#[tokio::main] +async fn main() -> Result<()> { + serve_plugin(DocumentationWorker).await +} diff --git a/examples/rust-grpc-worker-plugin/tests/config.rs b/examples/rust-grpc-worker-plugin/tests/config.rs new file mode 100644 index 000000000..8796a4133 --- /dev/null +++ b/examples/rust-grpc-worker-plugin/tests/config.rs @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use nemo_relay_rust_grpc_worker_plugin_example::{default_example_config, validate_example_config}; +use serde_json::{Value as Json, json}; + +#[test] +fn shared_configuration_is_valid() { + let diagnostics = validate_example_config(&json!({ + "tag": "documentation", + "observe": { "enabled": true, "redact_keys": ["secret"] }, + "requests": { + "enabled": true, + "mode": "enforce", + "blocked_tools": ["dangerous_tool"], + "blocked_models": ["restricted-model"], + "header_name": "x-nemo-relay-plugin", + "header_value": "documentation", + "priority": 20, + "break_chain": false + }, + "execution": { "enabled": true, "priority": 30, "emit_pending_marks": true }, + "runtime": { "emit_marks": true, "emit_isolated_scope": true } + })); + assert!(diagnostics.is_empty(), "{diagnostics:?}"); +} + +#[test] +fn unsupported_mode_is_rejected() { + let diagnostics = validate_example_config(&json!({ + "requests": { "mode": "maybe" } + })); + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "examples.rust_grpc_worker.unsupported_mode") + ); +} + +#[test] +fn unknown_field_produces_diagnostic() { + let diagnostics = validate_example_config(&json!({ + "requests": { "mystery": true } + })); + + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "examples.rust_grpc_worker.unknown_field") + ); +} + +#[test] +fn wrong_types_are_rejected() { + let diagnostics = validate_example_config(&json!({ + "requests": { "priority": "high" } + })); + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "examples.rust_grpc_worker.invalid_config") + ); +} + +#[test] +fn empty_headers_are_reported_at_their_individual_fields() { + for (config, field) in [ + ( + json!({ "requests": { "header_name": "" } }), + "requests.header_name", + ), + ( + json!({ "requests": { "header_value": "" } }), + "requests.header_value", + ), + ] { + let diagnostics = validate_example_config(&config); + assert!(diagnostics.iter().any(|diagnostic| { + diagnostic.code == "examples.rust_grpc_worker.invalid_header" + && diagnostic.field.as_deref() == Some(field) + })); + } +} + +#[test] +fn schema_contains_every_feature_group() { + let schema: Json = serde_json::from_str(include_str!("../config.schema.json")) + .expect("schema should be valid JSON"); + let fields = schema["properties"].as_object().expect("properties object"); + assert_eq!(schema["additionalProperties"], Json::Bool(true)); + assert_eq!(fields.len(), 5); + for field in ["tag", "observe", "requests", "execution", "runtime"] { + assert!(fields.contains_key(field)); + } +} + +#[test] +fn schema_defaults_match_the_runtime_defaults() { + let schema: Json = serde_json::from_str(include_str!("../config.schema.json")) + .expect("schema should be valid JSON"); + assert_schema_defaults(&schema, &default_example_config(), ""); +} + +fn assert_schema_defaults(schema: &Json, value: &Json, path: &str) { + if let Some(expected) = schema.get("default") { + assert_eq!(value, expected, "default mismatch at {path}"); + } + let Some(properties) = schema.get("properties").and_then(Json::as_object) else { + return; + }; + let object = value + .as_object() + .unwrap_or_else(|| panic!("runtime default at {path} must be an object")); + for (name, child_schema) in properties { + let child_value = object + .get(name) + .unwrap_or_else(|| panic!("runtime default is missing {path}{name}")); + assert_schema_defaults(child_schema, child_value, &format!("{path}{name}.")); + } +} + +#[test] +fn manifest_uses_the_rust_worker_load_contract() { + let manifest = include_str!("../relay-plugin.toml"); + assert!(manifest.contains("relay = \">=0.8.0,<1.0\"")); + assert!(manifest.contains("worker_protocol = \"grpc-v1\"")); + assert!(manifest.contains("runtime = \"rust\"")); + assert!(manifest.contains("entrypoint = \"target/debug/\"")); + assert!(!manifest.contains("command =")); +} diff --git a/examples/rust-grpc-worker-plugin/tests/lifecycle.rs b/examples/rust-grpc-worker-plugin/tests/lifecycle.rs new file mode 100644 index 000000000..15e1e11c2 --- /dev/null +++ b/examples/rust-grpc-worker-plugin/tests/lifecycle.rs @@ -0,0 +1,198 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Atomic transport coverage for the documented Rust grpc-v1 worker. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::{Arc, Mutex}; + +use nemo_relay::api::event::Event; +use nemo_relay::api::subscriber::{deregister_subscriber, flush_subscribers, register_subscriber}; +use nemo_relay::api::tool::{ToolCallExecuteParams, ToolExecutionResult, tool_call_execute}; +use nemo_relay::plugin::PluginConfig; +use nemo_relay::plugin::dynamic::{ + DynamicPluginActivationSpec, DynamicPluginKind, PluginHostActivation, +}; +use serde_json::{Map, json}; +use sha2::{Digest, Sha256}; +use tempfile::TempDir; +use tokio::sync::Mutex as AsyncMutex; + +static TEST_LOCK: AsyncMutex<()> = AsyncMutex::const_new(()); +const PLUGIN_ID: &str = "examples.rust_grpc_worker"; +const SUBSCRIBER: &str = "rust_grpc_worker_example_lifecycle_events"; + +#[tokio::test(flavor = "multi_thread")] +async fn built_worker_validates_registers_executes_and_shuts_down() { + let _guard = TEST_LOCK.lock().await; + let (_build_dir, worker) = build_worker(); + let manifest_dir = TempDir::new().expect("manifest directory should be created"); + let manifest = write_manifest(manifest_dir.path(), &worker); + let events = Arc::new(Mutex::new(Vec::::new())); + let captured = Arc::clone(&events); + register_subscriber( + SUBSCRIBER, + Arc::new(move |event| { + captured + .lock() + .expect("event lock should not be poisoned") + .push(event.clone()); + }), + ) + .expect("test subscriber should register"); + + let (activation, report) = PluginHostActivation::activate( + PluginConfig::default(), + [DynamicPluginActivationSpec { + plugin_id: PLUGIN_ID.into(), + kind: DynamicPluginKind::Worker, + manifest_ref: manifest.to_string_lossy().into_owned(), + environment_ref: None, + config: documented_config(), + }], + ) + .await + .expect("the materialized worker manifest should activate"); + assert!(report.diagnostics.is_empty(), "{report:?}"); + + let result = tool_call_execute( + ToolCallExecuteParams::builder() + .name("safe_tool") + .args(json!({"value": 1})) + .func(Arc::new(|args| { + Box::pin(async move { + Ok(ToolExecutionResult::annotated( + args, + json!({"source": "application"}), + )) + }) + })) + .build(), + ) + .await + .expect("worker middleware should execute through grpc-v1"); + assert_eq!(result.result["plugin_tag"], "documentation"); + assert_eq!(result.result["plugin_tool"], "safe_tool"); + assert_eq!(result.annotation, Some(json!({"source": "application"}))); + + flush_subscribers().expect("worker events should flush"); + assert!( + events + .lock() + .expect("event lock should not be poisoned") + .iter() + .any(|event| event.name() == "example.rust_worker.request.seen") + ); + + activation + .clear() + .expect("worker shutdown should follow callback cleanup"); + deregister_subscriber(SUBSCRIBER).expect("test subscriber should deregister"); +} + +fn documented_config() -> Map { + json!({ + "tag": "documentation", + "observe": { "enabled": true, "redact_keys": ["secret"] }, + "requests": { + "enabled": true, + "mode": "enforce", + "blocked_tools": ["dangerous_tool"], + "blocked_models": ["restricted-model"], + "header_name": "x-nemo-relay-plugin", + "header_value": "documentation", + "priority": 20, + "break_chain": false + }, + "execution": { "enabled": true, "priority": 30, "emit_pending_marks": true }, + "runtime": { "emit_marks": true, "emit_isolated_scope": true } + }) + .as_object() + .expect("documented configuration is an object") + .clone() +} + +fn build_worker() -> (TempDir, PathBuf) { + let target = TempDir::new().expect("build target directory should be created"); + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"); + let status = Command::new(env!("CARGO")) + .args(["build", "--manifest-path"]) + .arg(manifest) + .arg("--target-dir") + .arg(target.path()) + .status() + .expect("cargo build should start"); + assert!( + status.success(), + "cargo build should produce the worker executable" + ); + let worker = target.path().join("debug").join(format!( + "nemo-relay-rust-grpc-worker-plugin-example{}", + std::env::consts::EXE_SUFFIX + )); + assert!( + worker.exists(), + "cargo build should produce the expected worker executable" + ); + (target, worker) +} + +fn write_manifest(directory: &Path, worker: &Path) -> PathBuf { + let digest = digest(worker); + let worker = toml_basic_string(&worker.to_string_lossy()); + let manifest = directory.join("relay-plugin.toml"); + std::fs::write( + &manifest, + format!( + r#"manifest_version = 1 + +[plugin] +id = "{PLUGIN_ID}" +kind = "worker" + +[compat] +relay = ">=0.8.0,<1.0" +worker_protocol = "grpc-v1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_worker"] + +[integrity] +sha256 = "{digest}" + +[load] +runtime = "rust" +entrypoint = {worker} +"#, + ), + ) + .expect("materialized manifest should write"); + manifest +} + +fn toml_basic_string(value: &str) -> String { + format!("{value:?}") +} + +#[test] +fn toml_basic_string_escapes_windows_worker_paths() { + assert_eq!( + toml_basic_string(r"C:\Users\relay\worker.exe"), + r#""C:\\Users\\relay\\worker.exe""# + ); +} + +fn digest(path: &Path) -> String { + let digest = Sha256::digest(std::fs::read(path).expect("read artifact")); + format!( + "sha256:{}", + digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ) +} diff --git a/examples/rust-native-plugin/Cargo.toml b/examples/rust-native-plugin/Cargo.toml index f2a37e888..f4ecb616e 100644 --- a/examples/rust-native-plugin/Cargo.toml +++ b/examples/rust-native-plugin/Cargo.toml @@ -12,10 +12,17 @@ description = "Example Rust native dynamic plugin for NeMo Relay" [lib] name = "nemo_relay_rust_native_plugin_example" -crate-type = ["cdylib"] +crate-type = ["cdylib", "rlib"] [dependencies] futures = "0.3" -nemo-relay-plugin = { path = "../../crates/plugin" } +nemo-relay-plugin = { version = "0.8.0", path = "../../crates/plugin" } +serde = { version = "1", features = ["derive"] } serde_json = "1" -tokio = { version = "1", features = ["io-util", "time"] } +tokio = { version = "1", features = ["io-util", "macros", "time"] } + +[dev-dependencies] +nemo-relay = { version = "0.8.0", path = "../../crates/core" } +sha2 = "0.11" +tempfile = "3" +tokio = { version = "1", features = ["macros", "rt-multi-thread", "sync"] } diff --git a/examples/rust-native-plugin/README.md b/examples/rust-native-plugin/README.md index 790605ffa..0b3f3d4a4 100644 --- a/examples/rust-native-plugin/README.md +++ b/examples/rust-native-plugin/README.md @@ -5,117 +5,36 @@ SPDX-License-Identifier: Apache-2.0 # Rust Native Dynamic Plugin -This example shows a trusted in-process Rust dynamic plugin using the -high-level `nemo-relay-plugin` SDK. It builds as a `cdylib`, exports a stable -native ABI entry symbol, validates JSON config, registers middleware and -subscribers, emits runtime marks/scopes, and creates an isolated scope stack. -Typed middleware returns futures driven by the SDK-owned Tokio executor; the -subscriber remains synchronous. The middleware demonstrates timers, async I/O, -codec use across an await, concurrent opt-in `next` calls, and stream -transformation. Set `native_concurrent_next` to `true` in an LLM request's -content to exercise the concurrent continuation path. - -The example intentionally depends on `nemo-relay-plugin`, not on the host -`nemo-relay` runtime crate. Rust DTOs stay inside the plugin crate; the -dynamic-library boundary remains the stable C ABI. - -## Build - -Run this command from the example directory: +This project is the complete native plugin used by the authoring guide. Its +configuration, observation, request policy, execution wrappers, and runtime +helpers live in separate source modules. Together they register the subscriber, +all three event sanitizers, five tool surfaces, and six LLM surfaces exposed by +the current typed 0.8.0 SDK. + +Run the focused tests and build the shared library from this directory. The +configuration tests isolate validation and schema contracts. The lifecycle test +builds a fresh `cdylib`, materializes a digest-checked manifest, activates the +plugin in a host, executes middleware, observes its runtime mark, and clears +the callbacks before unloading the library: ```bash +cargo test cargo build ``` -Before you register the plugin, copy `relay-plugin.toml` to a local manifest and -replace both occurrences of `` with the file name that -`cargo build` creates for your platform: +Copy `relay-plugin.toml` to `relay-plugin.local.toml` and replace +`` with the debug artifact name: -| Platform | Library path | +| Platform | Library Path | |---|---| | macOS | `target/debug/libnemo_relay_rust_native_plugin_example.dylib` | | Linux | `target/debug/libnemo_relay_rust_native_plugin_example.so` | | Windows | `target/debug/nemo_relay_rust_native_plugin_example.dll` | -The copied manifest must use the same relative path for `source.artifact` and -`load.library`. Calculate the library's SHA-256 digest and replace -`` with the lowercase hexadecimal value: - -| Platform | Digest command | -|---|---| -| macOS | `shasum -a 256 ` | -| Linux | `sha256sum ` | -| Windows PowerShell | `(Get-FileHash -Algorithm SHA256).Hash.ToLower()` | - -Keep the `sha256:` prefix in the manifest. For example, a digest of `abc123` -is written as `sha256:abc123`. - -## Register With Relay - -After materializing the library path and digest, run these commands from the -repository root using the copied manifest path: - -```bash -nemo-relay plugins add ./examples/rust-native-plugin/relay-plugin.local.toml -nemo-relay plugins enable examples.rust_native_policy -``` - -You can also reference the manifest manually from `plugins.toml`: - -```toml -[[plugins.dynamic]] -manifest = "./examples/rust-native-plugin/relay-plugin.local.toml" - -[plugins.dynamic.config] -tag = "demo" -block_tools = false -block_llms = false -emit_isolated_scope = true - -[plugins.dynamic.config.executor] -worker_threads = 4 -``` - -The manifest declares the `config_schema` capability and references -`config.schema.json`. After adding the plugin, use the editor for the same -configuration target (`--user`, `--project`, or `--global`) to configure the -fields without loading the native library. Schemas with -`additionalProperties: false` must include the SDK-owned `executor` object and -its positive `worker_threads` field whenever the plugin uses typed middleware: - -```bash -nemo-relay plugins edit -``` - -The editor reads the schema file relative to `relay-plugin.toml`. It does not -run the plugin during schema discovery. - -Start the gateway normally after the dynamic record is enabled: - -```bash -nemo-relay --bind 127.0.0.1:4040 -``` - -## What the Example Registers - -The example registers the following runtime behavior: - -- A subscriber that emits a mark when it sees non-plugin scope starts. -- Tool sanitize request/response guardrails for observability payload tagging. -- Conditional execution guardrails for tools and LLMs controlled by config. -- Request and execution intercepts for tools that mutate JSON payloads and call - continuations. -- LLM sanitize request/response guardrails. -- An LLM request intercept that rewrites the request and schedules a mark. Relay - emits that mark after the LLM start event with the LLM scope as its parent. -- LLM execution and stream execution intercepts. -- Runtime mark and scope events. -- A plugin-owned isolated scope stack for non-correlated visibility. - -Native plugins are not sandboxed. They run in the Relay process and must not -unwind across ABI callbacks. +Calculate the artifact digest with `shasum -a 256`, `sha256sum`, or +`Get-FileHash -Algorithm SHA256`, then replace `` while keeping +the `sha256:` prefix. The same relative artifact path must appear in +`source.artifact` and `load.library`. -Request intercepts do not own an LLM lifecycle because they run before Relay -creates the LLM scope. `register_llm_request_intercept` returns one -`LlmRequestInterceptOutcome`, whose `pending_marks` Relay emits in interceptor -order after the LLM start event and before provider execution. +The strict schema documents every feature group and the SDK-owned +`executor.worker_threads` override. diff --git a/examples/rust-native-plugin/config.schema.json b/examples/rust-native-plugin/config.schema.json index 51ca3e711..b9b46a26b 100644 --- a/examples/rust-native-plugin/config.schema.json +++ b/examples/rust-native-plugin/config.schema.json @@ -1,50 +1,134 @@ { "$comment": "SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. SPDX-License-Identifier: Apache-2.0", "$schema": "https://json-schema.org/draft/2020-12/schema", - "title": "Rust Native Policy Example", + "title": "Rust Native Documentation Plugin", + "description": "Configures the observation, request policy, execution, runtime, and SDK executor groups demonstrated by the native example.", "type": "object", "additionalProperties": false, - "x-nemo-relay-order": [ - "tag", - "block_tools", - "block_llms", - "emit_isolated_scope", - "executor" - ], + "x-nemo-relay-order": ["tag", "observe", "requests", "execution", "runtime", "executor"], "properties": { "tag": { - "title": "Event tag", - "description": "Tag added to events emitted by the example plugin.", + "description": "Non-empty label attached to plugin-created metadata and runtime events.", "type": "string", - "default": "rust-native-example" + "minLength": 1, + "default": "documentation" }, - "block_tools": { - "title": "Block tool calls", - "description": "Reject tool calls through the example conditional guardrail.", - "type": "boolean", - "default": false + "observe": { + "description": "Controls the subscriber and observability-only event, tool, and LLM sanitizers.", + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "description": "Registers the subscriber and event, tool, and LLM observability sanitizers.", + "type": "boolean", + "default": true + }, + "redact_keys": { + "description": "Object keys whose observability values are replaced recursively.", + "type": "array", + "items": { "type": "string", "minLength": 1 }, + "default": ["secret"] + } + } }, - "block_llms": { - "title": "Block LLM calls", - "description": "Reject LLM calls through the example conditional guardrail.", - "type": "boolean", - "default": false + "requests": { + "description": "Controls tool and model blocking plus real request rewriting.", + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "description": "Registers tool and model policy plus request-rewriting middleware.", + "type": "boolean", + "default": true + }, + "mode": { + "description": "Blocks configured tool and model names in enforce mode; permits them in observe mode.", + "type": "string", + "enum": ["observe", "enforce"], + "default": "enforce" + }, + "blocked_tools": { + "description": "Exact tool names rejected in enforce mode.", + "type": "array", + "items": { "type": "string" }, + "default": ["dangerous_tool"] + }, + "blocked_models": { + "description": "Exact model names rejected in enforce mode.", + "type": "array", + "items": { "type": "string" }, + "default": ["restricted-model"] + }, + "header_name": { + "description": "Header name added to the real LLM request.", + "type": "string", + "minLength": 1, + "default": "x-nemo-relay-plugin" + }, + "header_value": { + "description": "Header value added to the real LLM request.", + "type": "string", + "minLength": 1, + "default": "documentation" + }, + "priority": { + "description": "Priority for the tool and LLM request intercepts.", + "type": "integer", + "default": 20 + }, + "break_chain": { + "description": "Stops later request intercepts after this component rewrites a request.", + "type": "boolean", + "default": false + } + } }, - "emit_isolated_scope": { - "title": "Emit isolated scope", - "description": "Emit the example plugin's isolated scope and mark events.", - "type": "boolean", - "default": true + "execution": { + "description": "Controls tool, unary LLM, and streaming LLM continuation wrappers.", + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "description": "Registers tool, unary LLM, and streaming LLM execution intercepts.", + "type": "boolean", + "default": true + }, + "priority": { + "description": "Priority for all three execution intercepts.", + "type": "integer", + "default": 30 + }, + "emit_pending_marks": { + "description": "Adds Relay-owned pending marks to tool execution and LLM request outcomes.", + "type": "boolean", + "default": true + } + } + }, + "runtime": { + "description": "Controls host runtime marks, child scopes, and isolated scope-stack demonstration.", + "type": "object", + "additionalProperties": false, + "properties": { + "emit_marks": { + "description": "Emits a host-runtime mark during the tool request intercept.", + "type": "boolean", + "default": true + }, + "emit_isolated_scope": { + "description": "Creates, binds, and drops an isolated scope stack during the tool request intercept.", + "type": "boolean", + "default": true + } + } }, "executor": { - "title": "Typed middleware executor", - "description": "SDK-owned Tokio executor settings for this configured component.", + "description": "SDK-owned Tokio executor settings for this configured native component.", "type": "object", "additionalProperties": false, "properties": { "worker_threads": { - "title": "Worker threads", - "description": "Positive number of Tokio worker threads. Defaults to 2.", + "description": "Positive number of executor workers. The example defaults to two.", "type": "integer", "minimum": 1, "default": 2 diff --git a/examples/rust-native-plugin/src/config.rs b/examples/rust-native-plugin/src/config.rs new file mode 100644 index 000000000..e17979e4e --- /dev/null +++ b/examples/rust-native-plugin/src/config.rs @@ -0,0 +1,231 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashSet; + +use nemo_relay_plugin::{ConfigDiagnostic, DiagnosticLevel, Json}; +use serde::Deserialize; +use serde_json::Map; + +use crate::diagnostic; + +#[derive(Clone, Debug, Deserialize)] +#[serde(default)] +pub(crate) struct ExampleConfig { + pub tag: String, + pub observe: ObserveConfig, + pub requests: RequestsConfig, + pub execution: ExecutionConfig, + pub runtime: RuntimeConfig, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(default)] +pub(crate) struct ObserveConfig { + pub enabled: bool, + pub redact_keys: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(default)] +pub(crate) struct RequestsConfig { + pub enabled: bool, + pub mode: String, + pub blocked_tools: Vec, + pub blocked_models: Vec, + pub header_name: String, + pub header_value: String, + pub priority: i32, + pub break_chain: bool, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(default)] +pub(crate) struct ExecutionConfig { + pub enabled: bool, + pub priority: i32, + pub emit_pending_marks: bool, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(default)] +pub(crate) struct RuntimeConfig { + pub emit_marks: bool, + pub emit_isolated_scope: bool, +} + +impl Default for ExampleConfig { + fn default() -> Self { + Self { + tag: "documentation".into(), + observe: ObserveConfig::default(), + requests: RequestsConfig::default(), + execution: ExecutionConfig::default(), + runtime: RuntimeConfig::default(), + } + } +} + +impl Default for ObserveConfig { + fn default() -> Self { + Self { + enabled: true, + redact_keys: vec!["secret".into()], + } + } +} + +impl Default for RequestsConfig { + fn default() -> Self { + Self { + enabled: true, + mode: "enforce".into(), + blocked_tools: vec!["dangerous_tool".into()], + blocked_models: vec!["restricted-model".into()], + header_name: "x-nemo-relay-plugin".into(), + header_value: "documentation".into(), + priority: 20, + break_chain: false, + } + } +} + +impl Default for ExecutionConfig { + fn default() -> Self { + Self { + enabled: true, + priority: 30, + emit_pending_marks: true, + } + } +} + +impl Default for RuntimeConfig { + fn default() -> Self { + Self { + emit_marks: true, + emit_isolated_scope: true, + } + } +} + +impl ExampleConfig { + pub(crate) fn parse(plugin_config: &Map) -> nemo_relay_plugin::Result { + let mut value = Json::Object(plugin_config.clone()); + if let Some(object) = value.as_object_mut() { + object.remove("executor"); + } + serde_json::from_value(value).map_err(|error| error.to_string()) + } +} + +pub(crate) fn validate(plugin_config: &Map) -> Vec { + let mut diagnostics = Vec::new(); + validate_unknown_fields(plugin_config, &mut diagnostics); + + match ExampleConfig::parse(plugin_config) { + Ok(config) => { + if config.requests.mode != "observe" && config.requests.mode != "enforce" { + diagnostics.push(diagnostic( + DiagnosticLevel::Error, + "examples.rust_native_policy.unsupported_mode", + Some("requests.mode"), + "requests.mode must be either observe or enforce", + )); + } + if config.tag.is_empty() { + diagnostics.push(diagnostic( + DiagnosticLevel::Error, + "examples.rust_native_policy.empty_tag", + Some("tag"), + "tag must not be empty", + )); + } + if config.requests.header_name.is_empty() { + diagnostics.push(diagnostic( + DiagnosticLevel::Error, + "examples.rust_native_policy.invalid_header", + Some("requests.header_name"), + "requests.header_name must not be empty", + )); + } + if config.requests.header_value.is_empty() { + diagnostics.push(diagnostic( + DiagnosticLevel::Error, + "examples.rust_native_policy.invalid_header", + Some("requests.header_value"), + "requests.header_value must not be empty", + )); + } + } + Err(error) => diagnostics.push(diagnostic( + DiagnosticLevel::Error, + "examples.rust_native_policy.invalid_config", + None, + error, + )), + } + + diagnostics +} + +fn validate_unknown_fields( + plugin_config: &Map, + diagnostics: &mut Vec, +) { + const TOP_LEVEL: &[&str] = &[ + "tag", + "observe", + "requests", + "execution", + "runtime", + "executor", + ]; + const OBSERVE: &[&str] = &["enabled", "redact_keys"]; + const REQUESTS: &[&str] = &[ + "enabled", + "mode", + "blocked_tools", + "blocked_models", + "header_name", + "header_value", + "priority", + "break_chain", + ]; + const EXECUTION: &[&str] = &["enabled", "priority", "emit_pending_marks"]; + const RUNTIME: &[&str] = &["emit_marks", "emit_isolated_scope"]; + + report_unknown(plugin_config, "", TOP_LEVEL, diagnostics); + for (field, allowed) in [ + ("observe", OBSERVE), + ("requests", REQUESTS), + ("execution", EXECUTION), + ("runtime", RUNTIME), + ] { + if let Some(object) = plugin_config.get(field).and_then(Json::as_object) { + report_unknown(object, field, allowed, diagnostics); + } + } +} + +fn report_unknown( + object: &Map, + prefix: &str, + allowed: &[&str], + diagnostics: &mut Vec, +) { + let allowed = allowed.iter().copied().collect::>(); + for key in object.keys().filter(|key| !allowed.contains(key.as_str())) { + let field = if prefix.is_empty() { + key.clone() + } else { + format!("{prefix}.{key}") + }; + diagnostics.push(diagnostic( + DiagnosticLevel::Warning, + "examples.rust_native_policy.unknown_field", + Some(&field), + format!("unknown config field '{field}' is not supported"), + )); + } +} diff --git a/examples/rust-native-plugin/src/execution.rs b/examples/rust-native-plugin/src/execution.rs new file mode 100644 index 000000000..ae8170cc9 --- /dev/null +++ b/examples/rust-native-plugin/src/execution.rs @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use futures::StreamExt; +use nemo_relay_plugin::{ + EventCategory, Json, LlmJsonAsyncStream, PendingMarkSpec, PluginContext, PluginRuntime, + ToolExecutionInterceptOutcome, +}; +use serde_json::json; + +use crate::config::ExampleConfig; +use crate::runtime::emit_configured_runtime_events; + +pub(crate) fn register( + context: &mut PluginContext<'_>, + config: &ExampleConfig, + runtime: &PluginRuntime, +) -> nemo_relay_plugin::Result<()> { + if config.runtime.emit_marks || config.runtime.emit_isolated_scope { + context.register_tool_execution_intercept("documentation_runtime_events", 0, { + let tag = config.tag.clone(); + let runtime = runtime.clone(); + let runtime_config = config.runtime.clone(); + move |_name, request, next| { + let tag = tag.clone(); + let runtime = runtime.clone(); + let runtime_config = runtime_config.clone(); + async move { + emit_configured_runtime_events(&runtime, &tag, &runtime_config)?; + Ok(ToolExecutionInterceptOutcome::from(next.call(request).await?)) + } + } + })?; + } + + if !config.execution.enabled { + return Ok(()); + } + + context.register_tool_execution_intercept( + "documentation_tool_execution", + config.execution.priority, + { + let emit_pending_marks = config.execution.emit_pending_marks; + move |_name, request, next| async move { + let result = next.call(request).await?; + let mut outcome = ToolExecutionInterceptOutcome::from(result); + if emit_pending_marks { + outcome = outcome.with_pending_mark( + PendingMarkSpec::builder() + .name("example.native.tool_execution") + .category(EventCategory::custom()) + .data(json!({ "source": "documentation" })) + .build(), + ); + } + Ok(outcome) + } + }, + )?; + + context.register_llm_execution_intercept( + "documentation_llm_execution", + config.execution.priority, + move |_name, request, next| async move { + if request + .content + .get("repeat_downstream") + .and_then(Json::as_bool) + .unwrap_or(false) + { + let repeated = next.clone(); + let (first, second) = + tokio::join!(repeated.call(request.clone()), next.call(request)); + let response = first?; + second?; + Ok(response) + } else { + next.call(request).await + } + }, + )?; + + context.register_llm_stream_execution_intercept( + "documentation_llm_stream_execution", + config.execution.priority, + move |_name, request, next| async move { + let stream = next.call(request).await?; + let stream: LlmJsonAsyncStream = Box::pin(stream.map(|chunk| { + chunk.map(|chunk| match chunk { + Json::Object(mut object) => { + object.insert("plugin_stream".into(), Json::Bool(true)); + Json::Object(object) + } + other => other, + }) + })); + Ok(stream) + }, + )?; + + Ok(()) +} diff --git a/examples/rust-native-plugin/src/lib.rs b/examples/rust-native-plugin/src/lib.rs index 307c01729..ee058e1ad 100644 --- a/examples/rust-native-plugin/src/lib.rs +++ b/examples/rust-native-plugin/src/lib.rs @@ -1,125 +1,25 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -use futures::StreamExt; +mod config; +mod execution; +mod observe; +mod requests; +mod runtime; + use nemo_relay_plugin::{ - CategoryProfile, ConfigDiagnostic, DiagnosticLevel, Event, EventCategory, Json, - LlmJsonAsyncStream, LlmRequest, LlmRequestInterceptOutcome, NativeExecutorConfig, NativePlugin, - PendingMarkSpec, PluginContext, PluginRuntime, ScopeCategory, ScopeType, - ToolExecutionInterceptOutcome, + ConfigDiagnostic, DiagnosticLevel, Json, NativeExecutorConfig, NativePlugin, PluginContext, }; -use serde_json::{Map, json}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; - -struct ExampleNativePlugin; - -#[derive(Clone, Debug)] -struct ExampleConfig { - tag: String, - block_tools: bool, - block_llms: bool, - emit_isolated_scope: bool, -} - -#[derive(Clone, Copy)] -enum ConfigField { - Tag, - BlockTools, - BlockLlms, - EmitIsolatedScope, -} - -impl ConfigField { - const ALL: [Self; 4] = [ - Self::Tag, - Self::BlockTools, - Self::BlockLlms, - Self::EmitIsolatedScope, - ]; - - const fn name(self) -> &'static str { - match self { - Self::Tag => "tag", - Self::BlockTools => "block_tools", - Self::BlockLlms => "block_llms", - Self::EmitIsolatedScope => "emit_isolated_scope", - } - } - - const fn expected_type(self) -> &'static str { - match self { - Self::Tag => "string", - Self::BlockTools | Self::BlockLlms | Self::EmitIsolatedScope => "boolean", - } - } +use serde_json::Map; - const fn invalid_code(self) -> &'static str { - match self { - Self::Tag => "examples.rust_native_policy.invalid_tag", - Self::BlockTools | Self::BlockLlms | Self::EmitIsolatedScope => { - "examples.rust_native_policy.invalid_boolean" - } - } - } - - fn accepts(self, value: &Json) -> bool { - match self { - Self::Tag => value.is_string(), - Self::BlockTools | Self::BlockLlms | Self::EmitIsolatedScope => value.is_boolean(), - } - } - - fn parse_into(self, value: &Json, config: &mut ExampleConfig) -> nemo_relay_plugin::Result<()> { - if !self.accepts(value) { - return Err(format!( - "{} must be a {}", - self.name(), - self.expected_type() - )); - } - match self { - Self::Tag => { - config.tag = value - .as_str() - .expect("checked config field type") - .to_owned(); - } - Self::BlockTools => { - config.block_tools = value.as_bool().expect("checked config field type"); - } - Self::BlockLlms => { - config.block_llms = value.as_bool().expect("checked config field type"); - } - Self::EmitIsolatedScope => { - config.emit_isolated_scope = value.as_bool().expect("checked config field type"); - } - } - Ok(()) - } -} - -impl Default for ExampleConfig { - fn default() -> Self { - Self { - tag: "rust-native-example".into(), - block_tools: false, - block_llms: false, - emit_isolated_scope: true, - } - } -} +use config::ExampleConfig; -impl ExampleConfig { - fn parse(plugin_config: &Map) -> nemo_relay_plugin::Result { - let mut config = Self::default(); - for field in ConfigField::ALL { - if let Some(value) = plugin_config.get(field.name()) { - field.parse_into(value, &mut config)?; - } - } +struct ExampleNativePlugin; - Ok(config) - } +/// Validate the example's component-local configuration without loading a native host. +pub fn validate_example_config(plugin_config: &Map) -> Vec { + let plugin = ExampleNativePlugin; + plugin.validate(plugin_config) } impl NativePlugin for ExampleNativePlugin { @@ -136,36 +36,7 @@ impl NativePlugin for ExampleNativePlugin { } fn validate(&self, plugin_config: &Map) -> Vec { - let mut diagnostics = Vec::new(); - - for key in plugin_config.keys() { - if key != "executor" - && !ConfigField::ALL - .iter() - .any(|field| field.name() == key.as_str()) - { - diagnostics.push(diagnostic( - DiagnosticLevel::Warning, - "examples.rust_native_policy.unknown_field", - Some(key), - format!("unknown config field '{key}' will be ignored"), - )); - } - } - - for field in ConfigField::ALL { - if let Some(value) = plugin_config.get(field.name()) { - if !field.accepts(value) { - diagnostics.push(diagnostic( - DiagnosticLevel::Error, - field.invalid_code(), - Some(field.name()), - format!("{} must be a {}", field.name(), field.expected_type()), - )); - } - } - } - + let mut diagnostics = config::validate(plugin_config); if let Err(error) = self.executor_config_for_component(plugin_config) { diagnostics.push(diagnostic( DiagnosticLevel::Error, @@ -174,220 +45,25 @@ impl NativePlugin for ExampleNativePlugin { error, )); } - diagnostics } fn register( &mut self, plugin_config: &Map, - ctx: &mut PluginContext<'_>, + context: &mut PluginContext<'_>, ) -> nemo_relay_plugin::Result<()> { let config = ExampleConfig::parse(plugin_config)?; - let runtime = ctx.runtime(); - - ctx.register_subscriber("example_native_subscriber", { - let runtime = runtime.clone(); - let tag = config.tag.clone(); - move |event| subscriber_mark(&runtime, &tag, event) - })?; - - ctx.register_tool_sanitize_request_guardrail("example_tool_sanitize_request", 10, { - let tag = config.tag.clone(); - move |_name, args| { - let tag = tag.clone(); - async move { Ok(tag_json(args, "native_tool_sanitize_request", &tag)) } - } - })?; - ctx.register_tool_sanitize_response_guardrail("example_tool_sanitize_response", 10, { - let tag = config.tag.clone(); - move |_name, result| { - let tag = tag.clone(); - async move { Ok(tag_json(result, "native_tool_sanitize_response", &tag)) } - } - })?; - ctx.register_tool_conditional_execution_guardrail("example_tool_conditional", 10, { - let block_tools = config.block_tools; - move |name, _args| async move { - Ok(block_tools.then(|| format!("tool '{name}' blocked by Rust native plugin"))) - } - })?; - ctx.register_tool_request_intercept("example_tool_request", 20, false, { - let runtime = runtime.clone(); - let tag = config.tag.clone(); - let emit_isolated_scope = config.emit_isolated_scope; - move |name, args| { - let runtime = runtime.clone(); - let tag = tag.clone(); - async move { - // Demonstration only: these operations show that Tokio timers and I/O - // run on the SDK executor; production intercepts do not need them. - tokio::time::sleep(std::time::Duration::from_millis(1)).await; - let (mut writer, mut reader) = tokio::io::duplex(16); - writer - .write_all(b"ready") - .await - .map_err(|error| error.to_string())?; - let mut readiness = [0_u8; 5]; - reader - .read_exact(&mut readiness) - .await - .map_err(|error| error.to_string())?; - emit_runtime_events(&runtime, &tag, emit_isolated_scope)?; - let mut scope = runtime.scope( - "example.native.tool_request", - ScopeType::Tool, - Some(&json!({ "tool": name, "tag": tag })), - None, - Some(&args), - )?; - let tagged = tag_json(args, "native_tool_request_intercept", &tag); - scope.close(Some(&tagged), None)?; - Ok(tagged) - } - } - })?; - ctx.register_tool_execution_intercept("example_tool_execution", 30, { - let tag = config.tag.clone(); - move |_name, args, next| { - let tag = tag.clone(); - async move { - let request = tag_json(args, "native_tool_execution_request", &tag); - let mut result = next.call(request).await?; - result.result = - tag_json(result.result, "native_tool_execution_response", &tag); - Ok( - ToolExecutionInterceptOutcome::from(result).with_pending_mark( - PendingMarkSpec::builder() - .name("example.native.tool_execution") - .category(EventCategory::custom()) - .category_profile(CategoryProfile { - subtype: Some("example.native.tool_result_rewrite".into()), - ..CategoryProfile::default() - }) - .data(json!({ "tag": &tag })) - .build(), - ), - ) - } - } - })?; - - ctx.register_llm_sanitize_request_guardrail("example_llm_sanitize_request", 10, { - let tag = config.tag.clone(); - move |request, context| { - let tag = tag.clone(); - async move { - let request = if let Some(codec) = context.resolve_codec() { - let annotated = codec.decode(&request)?; - tokio::time::sleep(std::time::Duration::from_millis(1)).await; - codec.encode(&annotated, &request)? - } else { - request - }; - Ok(Some(tag_llm_request( - request, - "native_llm_sanitize_request", - &tag, - ))) - } - } - })?; - ctx.register_llm_sanitize_response_guardrail("example_llm_sanitize_response", 10, { - let tag = config.tag.clone(); - move |response, _context| { - let tag = tag.clone(); - async move { - Ok(Some(tag_json( - response, - "native_llm_sanitize_response", - &tag, - ))) - } - } - })?; - ctx.register_llm_conditional_execution_guardrail("example_llm_conditional", 10, { - let block_llms = config.block_llms; - move |_request| async move { - Ok(block_llms.then(|| "LLM call blocked by Rust native plugin".to_string())) - } - })?; - ctx.register_llm_request_intercept("example_llm_request", 20, false, { - let tag = config.tag.clone(); - move |_name, request, annotated| { - let tag = tag.clone(); - async move { - Ok(LlmRequestInterceptOutcome::new( - tag_llm_request(request, "native_llm_request_intercept", &tag), - annotated, - ) - .with_pending_mark( - PendingMarkSpec::builder() - .name("example.native.llm_request_intercept") - .category(EventCategory::custom()) - .category_profile(CategoryProfile { - subtype: Some("example.native.request_rewrite".into()), - ..CategoryProfile::default() - }) - .data(json!({ "tag": &tag })) - .build(), - )) - } - } - })?; - ctx.register_llm_execution_intercept("example_llm_execution", 30, { - let tag = config.tag.clone(); - move |_name, request, next| { - let tag = tag.clone(); - async move { - let request = tag_llm_request(request, "native_llm_execution_request", &tag); - let response = if request - .content - .get("native_concurrent_next") - .and_then(Json::as_bool) - .unwrap_or(false) - { - // Demonstrates concurrent continuations only. Each call reaches the - // downstream provider, so this duplicates its cost and side effects. - let first_next = next.clone(); - let (first, second) = tokio::join!( - first_next.call(request.clone()), - next.call(request), - ); - let response = first?; - second?; - response - } else { - next.call(request).await? - }; - Ok(tag_json(response, "native_llm_execution_response", &tag)) - } - } - })?; - ctx.register_llm_stream_execution_intercept("example_llm_stream_execution", 30, { - let tag = config.tag; - move |_name, request, next| { - let tag = tag.clone(); - async move { - let request = - tag_llm_request(request, "native_llm_stream_execution_request", &tag); - let stream = next.call(request).await?; - let tag = tag.clone(); - let stream: LlmJsonAsyncStream = Box::pin(stream.map(move |chunk| { - chunk.map(|chunk| { - tag_json(chunk, "native_llm_stream_execution_response", &tag) - }) - })); - Ok(stream) - } - } - })?; + let plugin_runtime = context.runtime(); + observe::register(context, &config, &plugin_runtime)?; + requests::register(context, &config)?; + execution::register(context, &config, &plugin_runtime)?; Ok(()) } } -fn diagnostic( +pub(crate) fn diagnostic( level: DiagnosticLevel, code: &str, field: Option<&str>, @@ -402,69 +78,4 @@ fn diagnostic( } } -fn subscriber_mark(runtime: &PluginRuntime, tag: &str, event: &Event) { - if event.scope_category() == Some(ScopeCategory::Start) - && !event.name().starts_with("example.native") - { - let _ = runtime.emit_mark( - "example.native.subscriber.seen", - Some(&json!({ "event": event.name(), "tag": tag })), - None, - ); - } -} - -fn emit_runtime_events( - runtime: &PluginRuntime, - tag: &str, - emit_isolated_scope: bool, -) -> nemo_relay_plugin::Result<()> { - runtime.emit_mark( - "example.native.tool_request.seen", - Some(&json!({ "tag": tag })), - None, - )?; - - if !emit_isolated_scope { - return Ok(()); - } - - let isolated = runtime.create_scope_stack()?; - isolated.with_current(|| { - runtime.emit_mark( - "example.native.isolated.mark", - Some(&json!({ "tag": tag })), - None, - )?; - let mut scope = runtime.scope( - "example.native.isolated.scope", - ScopeType::Custom, - None, - Some(&json!({ "visibility": "isolated" })), - Some(&json!({ "tag": tag })), - )?; - scope.close(Some(&json!({ "done": true })), None) - }) -} - -fn tag_llm_request(mut request: LlmRequest, key: &str, tag: &str) -> LlmRequest { - request.headers.insert( - "x-nemo-relay-native-plugin".into(), - Json::String(tag.into()), - ); - request.content = tag_json(request.content, key, tag); - request -} - -fn tag_json(value: Json, key: &str, tag: &str) -> Json { - match value { - Json::Object(mut object) => { - object.insert(key.into(), Json::Bool(true)); - object.insert("native_plugin_tag".into(), Json::String(tag.into())); - Json::Object(object) - } - other => other, - } -} - nemo_relay_plugin::nemo_relay_plugin!(nemo_relay_register_plugin, || ExampleNativePlugin); diff --git a/examples/rust-native-plugin/src/observe.rs b/examples/rust-native-plugin/src/observe.rs new file mode 100644 index 000000000..ea749c0ca --- /dev/null +++ b/examples/rust-native-plugin/src/observe.rs @@ -0,0 +1,181 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use nemo_relay_plugin::{ + Event, EventSanitizeFields, Json, LlmRequest, PluginContext, PluginRuntime, ScopeCategory, +}; +use serde_json::{Map, json}; + +use crate::config::ExampleConfig; + +pub(crate) fn register( + context: &mut PluginContext<'_>, + config: &ExampleConfig, + runtime: &PluginRuntime, +) -> nemo_relay_plugin::Result<()> { + if !config.observe.enabled { + return Ok(()); + } + + context.register_subscriber("documentation_subscriber", { + let runtime = runtime.clone(); + let tag = config.tag.clone(); + move |event| subscriber_mark(&runtime, &tag, event) + })?; + + let register_event_sanitizer = + |fields: EventSanitizeFields, tag: String, redact_keys: Vec| async move { + sanitize_event_fields(fields, &tag, &redact_keys) + }; + + context.register_mark_sanitize_guardrail("documentation_mark_sanitizer", 10, { + let tag = config.tag.clone(); + let redact_keys = config.observe.redact_keys.clone(); + move |_event, fields| register_event_sanitizer(fields, tag.clone(), redact_keys.clone()) + })?; + context.register_scope_sanitize_start_guardrail( + "documentation_scope_start_sanitizer", + 10, + { + let tag = config.tag.clone(); + let redact_keys = config.observe.redact_keys.clone(); + move |_event, fields| register_event_sanitizer(fields, tag.clone(), redact_keys.clone()) + }, + )?; + context.register_scope_sanitize_end_guardrail("documentation_scope_end_sanitizer", 10, { + let tag = config.tag.clone(); + let redact_keys = config.observe.redact_keys.clone(); + move |_event, fields| register_event_sanitizer(fields, tag.clone(), redact_keys.clone()) + })?; + + context.register_tool_sanitize_request_guardrail( + "documentation_tool_request_sanitizer", + 10, + { + let redact_keys = config.observe.redact_keys.clone(); + move |_name, value| { + let redact_keys = redact_keys.clone(); + async move { Ok(redact_json(value, &redact_keys)) } + } + }, + )?; + context.register_tool_sanitize_response_guardrail( + "documentation_tool_response_sanitizer", + 10, + { + let redact_keys = config.observe.redact_keys.clone(); + move |_name, value| { + let redact_keys = redact_keys.clone(); + async move { Ok(redact_json(value, &redact_keys)) } + } + }, + )?; + + context.register_llm_sanitize_request_guardrail( + "documentation_llm_request_sanitizer", + 10, + { + let redact_keys = config.observe.redact_keys.clone(); + move |mut request, codec_context| { + let redact_keys = redact_keys.clone(); + async move { + if let Some(codec) = codec_context.resolve_codec() { + let annotated = codec.decode(&request)?; + let annotated = serde_json::to_value(annotated) + .map(|value| redact_json(value, &redact_keys)) + .and_then(serde_json::from_value) + .map_err(|error| error.to_string())?; + request = codec.encode(&annotated, &request)?; + } + request.content = redact_json(request.content, &redact_keys); + Ok(Some(request)) + } + } + }, + )?; + context.register_llm_sanitize_response_guardrail( + "documentation_llm_response_sanitizer", + 10, + { + let redact_keys = config.observe.redact_keys.clone(); + move |response, codec_context| { + let redact_keys = redact_keys.clone(); + async move { + if let Some(codec) = codec_context.resolve_codec() { + let _annotated = codec.decode(&response)?; + } + Ok(Some(redact_json(response, &redact_keys))) + } + } + }, + )?; + + Ok(()) +} + +fn sanitize_event_fields( + mut fields: EventSanitizeFields, + tag: &str, + redact_keys: &[String], +) -> nemo_relay_plugin::Result { + fields.data = fields.data.map(|value| redact_json(value, redact_keys)); + fields.metadata = Some(tagged_metadata(fields.metadata, tag, redact_keys)); + if let Some(profile) = fields.category_profile.take() { + let value = serde_json::to_value(profile).map_err(|error| error.to_string())?; + fields.category_profile = Some( + serde_json::from_value(redact_json(value, redact_keys)) + .map_err(|error| error.to_string())?, + ); + } + Ok(fields) +} + +fn tagged_metadata(metadata: Option, tag: &str, redact_keys: &[String]) -> Json { + let mut metadata = match redact_json(metadata.unwrap_or_else(|| json!({})), redact_keys) { + Json::Object(object) => object, + other => Map::from_iter([("original".into(), other)]), + }; + metadata.insert("plugin_tag".into(), Json::String(tag.into())); + Json::Object(metadata) +} + +pub(crate) fn redact_json(value: Json, redact_keys: &[String]) -> Json { + match value { + Json::Object(mut object) => { + for (key, value) in &mut object { + if redact_keys.iter().any(|candidate| candidate == key) { + *value = Json::String("[REDACTED]".into()); + } else { + *value = redact_json(value.take(), redact_keys); + } + } + Json::Object(object) + } + Json::Array(values) => Json::Array( + values + .into_iter() + .map(|value| redact_json(value, redact_keys)) + .collect(), + ), + other => other, + } +} + +fn subscriber_mark(runtime: &PluginRuntime, tag: &str, event: &Event) { + if event.scope_category() == Some(ScopeCategory::Start) + && !event.name().starts_with("example.native") + { + let _ = runtime.emit_mark( + "example.native.subscriber.seen", + Some(&json!({ "event": event.name(), "tag": tag })), + None, + ); + } +} + +pub(crate) fn add_header(mut request: LlmRequest, name: &str, value: &str) -> LlmRequest { + request + .headers + .insert(name.into(), Json::String(value.into())); + request +} diff --git a/examples/rust-native-plugin/src/requests.rs b/examples/rust-native-plugin/src/requests.rs new file mode 100644 index 000000000..a8b00eb69 --- /dev/null +++ b/examples/rust-native-plugin/src/requests.rs @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use nemo_relay_plugin::{ + EventCategory, Json, LlmOptimizationContribution, LlmRequestInterceptOutcome, PendingMarkSpec, + PluginContext, +}; +use serde_json::json; + +use crate::config::ExampleConfig; +use crate::observe::add_header; + +pub(crate) fn register( + context: &mut PluginContext<'_>, + config: &ExampleConfig, +) -> nemo_relay_plugin::Result<()> { + if !config.requests.enabled { + return Ok(()); + } + + context.register_tool_conditional_execution_guardrail("documentation_tool_policy", 10, { + let mode = config.requests.mode.clone(); + let blocked = config.requests.blocked_tools.clone(); + move |name, _args| { + let mode = mode.clone(); + let blocked = blocked.clone(); + async move { + Ok((mode == "enforce" && blocked.contains(&name)) + .then(|| format!("tool '{name}' is blocked by documentation policy"))) + } + } + })?; + context.register_tool_request_intercept( + "documentation_tool_request", + config.requests.priority, + config.requests.break_chain, + { + let tag = config.tag.clone(); + move |name, value| { + let tag = tag.clone(); + async move { Ok(tag_tool_request(value, &name, &tag)) } + } + }, + )?; + + context.register_llm_conditional_execution_guardrail("documentation_llm_policy", 10, { + let mode = config.requests.mode.clone(); + let blocked = config.requests.blocked_models.clone(); + move |request| { + let mode = mode.clone(); + let blocked = blocked.clone(); + async move { + let model = request + .content + .get("model") + .and_then(Json::as_str) + .unwrap_or_default(); + Ok( + (mode == "enforce" && blocked.iter().any(|candidate| candidate == model)) + .then(|| format!("model '{model}' is blocked by documentation policy")), + ) + } + } + })?; + context.register_llm_request_intercept( + "documentation_llm_request", + config.requests.priority, + config.requests.break_chain, + { + let header_name = config.requests.header_name.clone(); + let header_value = config.requests.header_value.clone(); + let emit_pending_marks = config.execution.emit_pending_marks; + move |_name, request, annotated| { + let header_name = header_name.clone(); + let header_value = header_value.clone(); + async move { + let mut outcome = LlmRequestInterceptOutcome::new( + add_header(request, &header_name, &header_value), + annotated, + ) + .with_optimization_contribution( + LlmOptimizationContribution::new( + "examples.rust_native_policy", + "request_rewrite", + ), + ); + if emit_pending_marks { + outcome = outcome.with_pending_mark( + PendingMarkSpec::builder() + .name("example.native.llm_request") + .category(EventCategory::custom()) + .data(json!({ "header": header_name })) + .build(), + ); + } + Ok(outcome) + } + } + }, + )?; + + Ok(()) +} + +fn tag_tool_request(value: Json, name: &str, tag: &str) -> Json { + match value { + Json::Object(mut object) => { + object.insert("plugin_tag".into(), Json::String(tag.into())); + object.insert("plugin_tool".into(), Json::String(name.into())); + Json::Object(object) + } + other => other, + } +} diff --git a/examples/rust-native-plugin/src/runtime.rs b/examples/rust-native-plugin/src/runtime.rs new file mode 100644 index 000000000..fa0af6132 --- /dev/null +++ b/examples/rust-native-plugin/src/runtime.rs @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use nemo_relay_plugin::{PluginRuntime, ScopeType}; +use serde_json::json; + +use crate::config::RuntimeConfig; + +pub(crate) fn emit_configured_runtime_events( + runtime: &PluginRuntime, + tag: &str, + config: &RuntimeConfig, +) -> nemo_relay_plugin::Result<()> { + let _current_scope = runtime.current_scope()?; + if config.emit_marks { + runtime.emit_mark( + "example.native.request.seen", + Some(&json!({ "tag": tag })), + None, + )?; + } + + let mut scope = runtime.scope( + "example.native.request", + ScopeType::Custom, + Some(&json!({ "tag": tag })), + None, + None, + )?; + scope.close(Some(&json!({ "done": true })), None)?; + + if config.emit_isolated_scope { + let isolated = runtime.create_scope_stack()?; + isolated.with_current(|| { + if config.emit_marks { + runtime.emit_mark( + "example.native.isolated.mark", + Some(&json!({ "tag": tag })), + None, + )?; + } + let mut scope = runtime.scope( + "example.native.isolated.scope", + ScopeType::Custom, + None, + Some(&json!({ "visibility": "isolated" })), + None, + )?; + scope.close(Some(&json!({ "done": true })), None) + })?; + } + + Ok(()) +} diff --git a/examples/rust-native-plugin/tests/config.rs b/examples/rust-native-plugin/tests/config.rs new file mode 100644 index 000000000..ce598ce1b --- /dev/null +++ b/examples/rust-native-plugin/tests/config.rs @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use nemo_relay_rust_native_plugin_example::validate_example_config; +use serde_json::{Map, Value as Json, json}; + +fn object(value: Json) -> Map { + value.as_object().expect("test config is an object").clone() +} + +#[test] +fn shared_configuration_is_valid() { + let diagnostics = validate_example_config(&object(json!({ + "tag": "documentation", + "observe": { "enabled": true, "redact_keys": ["secret"] }, + "requests": { + "enabled": true, + "mode": "enforce", + "blocked_tools": ["dangerous_tool"], + "blocked_models": ["restricted-model"], + "header_name": "x-nemo-relay-plugin", + "header_value": "documentation", + "priority": 20, + "break_chain": false + }, + "execution": { "enabled": true, "priority": 30, "emit_pending_marks": true }, + "runtime": { "emit_marks": true, "emit_isolated_scope": true }, + "executor": { "worker_threads": 2 } + }))); + + assert!(diagnostics.is_empty(), "{diagnostics:?}"); +} + +#[test] +fn unsupported_mode_is_rejected() { + let diagnostics = validate_example_config(&object(json!({ + "requests": { "mode": "maybe" } + }))); + + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "examples.rust_native_policy.unsupported_mode") + ); +} + +#[test] +fn unknown_field_produces_diagnostic() { + let diagnostics = validate_example_config(&object(json!({ + "requests": { "mystery": true } + }))); + + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "examples.rust_native_policy.unknown_field") + ); +} + +#[test] +fn wrong_types_are_rejected() { + let diagnostics = validate_example_config(&object(json!({ + "requests": { "priority": "high" } + }))); + assert!( + diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "examples.rust_native_policy.invalid_config") + ); +} + +#[test] +fn invalid_values_are_rejected_at_their_fields() { + for (config, code, field) in [ + ( + json!({ "executor": { "worker_threads": 0 } }), + "examples.rust_native_policy.invalid_executor", + "executor.worker_threads", + ), + ( + json!({ "tag": "" }), + "examples.rust_native_policy.empty_tag", + "tag", + ), + ( + json!({ "requests": { "header_name": "" } }), + "examples.rust_native_policy.invalid_header", + "requests.header_name", + ), + ] { + let diagnostics = validate_example_config(&object(config)); + assert!(diagnostics.iter().any(|diagnostic| { + diagnostic.code == code && diagnostic.field.as_deref() == Some(field) + })); + } +} + +#[test] +fn schema_declares_every_feature_group_and_executor() { + let schema: Json = serde_json::from_str(include_str!("../config.schema.json")) + .expect("example schema should be valid JSON"); + let properties = schema["properties"] + .as_object() + .expect("schema properties should be an object"); + + for field in [ + "tag", + "observe", + "requests", + "execution", + "runtime", + "executor", + ] { + assert!(properties.contains_key(field), "schema is missing {field}"); + } + assert_eq!(schema["additionalProperties"], Json::Bool(false)); +} diff --git a/examples/rust-native-plugin/tests/lifecycle.rs b/examples/rust-native-plugin/tests/lifecycle.rs new file mode 100644 index 000000000..e6fcaae8d --- /dev/null +++ b/examples/rust-native-plugin/tests/lifecycle.rs @@ -0,0 +1,208 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Atomic dynamic-host coverage for the documented native plugin. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::{Arc, Mutex}; + +use nemo_relay::api::event::Event; +use nemo_relay::api::subscriber::{deregister_subscriber, flush_subscribers, register_subscriber}; +use nemo_relay::api::tool::{ToolCallExecuteParams, ToolExecutionResult, tool_call_execute}; +use nemo_relay::plugin::dynamic::{ + DynamicPluginActivationSpec, DynamicPluginKind, PluginHostActivation, +}; +use nemo_relay::plugin::{PluginConfig, list_plugin_kinds}; +use serde_json::{Map, json}; +use sha2::{Digest, Sha256}; +use tempfile::TempDir; +use tokio::sync::Mutex as AsyncMutex; + +static TEST_LOCK: AsyncMutex<()> = AsyncMutex::const_new(()); +const PLUGIN_ID: &str = "examples.rust_native_policy"; +const SUBSCRIBER: &str = "rust_native_example_lifecycle_events"; + +#[tokio::test] +async fn built_cdylib_validates_activates_runs_and_unloads() { + let _guard = TEST_LOCK.lock().await; + let (_build_dir, build) = build_cdylib(); + let manifest_dir = TempDir::new().expect("manifest directory should be created"); + let manifest = write_manifest(manifest_dir.path(), &build); + let events = Arc::new(Mutex::new(Vec::::new())); + let captured = Arc::clone(&events); + register_subscriber( + SUBSCRIBER, + Arc::new(move |event| { + captured + .lock() + .expect("event lock should not be poisoned") + .push(event.clone()); + }), + ) + .expect("test subscriber should register"); + + let config = documented_config(); + let (activation, report) = PluginHostActivation::activate( + PluginConfig::default(), + [DynamicPluginActivationSpec { + plugin_id: PLUGIN_ID.into(), + kind: DynamicPluginKind::RustDynamic, + manifest_ref: manifest.to_string_lossy().into_owned(), + environment_ref: None, + config, + }], + ) + .await + .expect("the materialized native manifest should activate"); + assert!(report.diagnostics.is_empty(), "{report:?}"); + + let result = tool_call_execute( + ToolCallExecuteParams::builder() + .name("safe_tool") + .args(json!({"secret": "application-value"})) + .func(Arc::new(|args| { + Box::pin(async move { + Ok(ToolExecutionResult::annotated( + args, + json!({"source": "application"}), + )) + }) + })) + .build(), + ) + .await + .expect("native tool middleware should execute"); + assert_eq!(result.result["secret"], "application-value"); + assert!(result.result.get("plugin_tag").is_none()); + assert_eq!(result.annotation, Some(json!({"source": "application"}))); + + flush_subscribers().expect("native events should flush"); + assert!( + events + .lock() + .expect("event lock should not be poisoned") + .iter() + .any(|event| event.name() == "example.native.request.seen") + ); + + activation + .clear() + .expect("callbacks should clear before the library unloads"); + deregister_subscriber(SUBSCRIBER).expect("test subscriber should deregister"); + assert!(!list_plugin_kinds().contains(&PLUGIN_ID.to_owned())); +} + +fn documented_config() -> Map { + json!({ + "tag": "documentation", + "observe": { "enabled": true, "redact_keys": ["secret"] }, + "requests": { + "enabled": false, + "mode": "enforce", + "blocked_tools": ["dangerous_tool"], + "blocked_models": ["restricted-model"], + "header_name": "x-nemo-relay-plugin", + "header_value": "documentation", + "priority": 20, + "break_chain": false + }, + "execution": { "enabled": false, "priority": 30, "emit_pending_marks": true }, + "runtime": { "emit_marks": true, "emit_isolated_scope": true }, + "executor": { "worker_threads": 2 } + }) + .as_object() + .expect("documented configuration is an object") + .clone() +} + +fn build_cdylib() -> (TempDir, PathBuf) { + let target = TempDir::new().expect("build target directory should be created"); + let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"); + let status = Command::new(env!("CARGO")) + .args(["build", "--manifest-path"]) + .arg(manifest) + .arg("--target-dir") + .arg(target.path()) + .status() + .expect("cargo build should start"); + assert!( + status.success(), + "cargo build should produce the native library" + ); + let library = target.path().join("debug").join(library_name()); + assert!( + library.exists(), + "cargo build should produce the expected library" + ); + (target, library) +} + +fn write_manifest(directory: &Path, library: &Path) -> PathBuf { + let digest = digest(library); + let library = toml_basic_string(&library.to_string_lossy()); + let manifest = directory.join("relay-plugin.toml"); + std::fs::write( + &manifest, + format!( + r#"manifest_version = 1 + +[plugin] +id = "{PLUGIN_ID}" +kind = "rust_dynamic" + +[compat] +relay = ">=0.8.0,<1.0" +native_api = "1" + +[defaults] +enabled = false + +[capabilities] +items = ["plugin_native"] + +[integrity] +sha256 = "{digest}" + +[load] +library = {library} +symbol = "nemo_relay_register_plugin" +"#, + ), + ) + .expect("materialized manifest should write"); + manifest +} + +fn toml_basic_string(value: &str) -> String { + format!("{value:?}") +} + +#[test] +fn toml_basic_string_escapes_windows_library_paths() { + assert_eq!( + toml_basic_string(r"C:\Users\relay\plugin.dll"), + r#""C:\\Users\\relay\\plugin.dll""# + ); +} + +fn library_name() -> &'static str { + if cfg!(target_os = "windows") { + "nemo_relay_rust_native_plugin_example.dll" + } else if cfg!(target_os = "macos") { + "libnemo_relay_rust_native_plugin_example.dylib" + } else { + "libnemo_relay_rust_native_plugin_example.so" + } +} + +fn digest(path: &Path) -> String { + let digest = Sha256::digest(std::fs::read(path).expect("read artifact")); + format!( + "sha256:{}", + digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ) +} diff --git a/fern/docs.yml b/fern/docs.yml index feb89ebe5..5ab8a79e8 100644 --- a/fern/docs.yml +++ b/fern/docs.yml @@ -115,6 +115,20 @@ redirects: # Build plugin guides - source: /nemo/relay/build-plugins/basic-guide destination: /nemo/relay/build-plugins/language-binding/about +- source: /nemo/relay/build-plugins/dynamic-plugins/about + destination: /nemo/relay/build-plugins/package-discoverable-plugins +- source: /nemo/relay/build-plugins/dynamic-plugins/native-dynamic/about + destination: /nemo/relay/build-plugins/native/about +- source: /nemo/relay/build-plugins/dynamic-plugins/native-dynamic/rust-native-plugin-example + destination: /nemo/relay/build-plugins/native/build-and-package +- source: /nemo/relay/build-plugins/dynamic-plugins/grpc-worker/about + destination: /nemo/relay/build-plugins/workers/about +- source: /nemo/relay/build-plugins/dynamic-plugins/grpc-worker/python/about + destination: /nemo/relay/build-plugins/workers/python +- source: /nemo/relay/build-plugins/dynamic-plugins/grpc-worker/rust/about + destination: /nemo/relay/build-plugins/workers/rust +- source: /nemo/relay/build-plugins/dynamic-plugins/grpc-worker/grpc-worker-protocol + destination: /nemo/relay/build-plugins/workers/grpc-v1-protocol # CLI guides - source: /nemo/relay/nemo-relay-cli/cursor diff --git a/justfile b/justfile index abf785b90..cc51ce39b 100644 --- a/justfile +++ b/justfile @@ -1260,6 +1260,9 @@ test-rust: cargo test --workspace --exclude nemo-relay-ffi cargo test -p nemo-relay-ffi -- --test-threads=1 fi + cargo test --manifest-path examples/rust-native-plugin/Cargo.toml + cargo test --manifest-path examples/rust-grpc-worker-plugin/Cargo.toml + cargo test --manifest-path examples/language-binding-plugin/rust/Cargo.toml # --set [output_dir=] [ci=true|false] test-python: @@ -1307,6 +1310,7 @@ test-python: prepare_test_plugin_fixtures pytest_cmd+=(--durations=25) "$python_executable" -m "${pytest_cmd[@]}" --ignore=python/tests/integrations + (cd examples/language-binding-plugin/python && uv run --locked --group test --reinstall-package nemo-relay pytest) if is_true "{{ ci }}" && [[ -n "$rust_coverage_out" ]]; then cargo llvm-cov report \ -p nemo-relay-python \ @@ -1339,6 +1343,8 @@ test-python-plugin: --cov=nemo_relay_plugin \ --cov-report term-missing \ --cov-fail-under=95 + (cd examples/python-grpc-worker-plugin && uv run --locked --group test --reinstall-package nemo-relay-plugin pytest) + (cd examples/language-binding-plugin/python && uv run --locked --group test --reinstall-package nemo-relay pytest) just test-python-plugin-e2e test-python-plugin-e2e: @@ -1551,6 +1557,16 @@ test-node: else npm test --workspace=nemo-relay-node fi + npm test --workspace=nemo-relay-node-language-binding-plugin-example + +# run each checked plugin authoring example from its own project directory +test-plugin-examples: + (cd examples/rust-native-plugin && cargo test) + (cd examples/rust-grpc-worker-plugin && cargo test) + (cd examples/language-binding-plugin/rust && cargo test) + (cd examples/python-grpc-worker-plugin && uv run --locked --group test --reinstall-package nemo-relay-plugin pytest) + (cd examples/language-binding-plugin/python && uv run --locked --group test --reinstall-package nemo-relay pytest) + npm test --workspace=nemo-relay-node-language-binding-plugin-example # --set [ci=true|false] test-openclaw: diff --git a/package-lock.json b/package-lock.json index cd4f05d49..fb4896ec3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "name": "nemo-relay-workspace", "workspaces": [ "crates/node", + "examples/language-binding-plugin/node", "integrations/openclaw" ], "devDependencies": { @@ -461,6 +462,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "examples/language-binding-plugin/node": { + "name": "nemo-relay-node-language-binding-plugin-example", + "version": "0.1.0", + "dependencies": { + "nemo-relay-node": "0.8.0" + } + }, "integrations/openclaw": { "name": "nemo-relay-openclaw", "version": "0.8.0", @@ -932,6 +940,10 @@ "resolved": "crates/node", "link": true }, + "node_modules/nemo-relay-node-language-binding-plugin-example": { + "resolved": "examples/language-binding-plugin/node", + "link": true + }, "node_modules/nemo-relay-openclaw": { "resolved": "integrations/openclaw", "link": true diff --git a/package.json b/package.json index cf8803a04..843a3d8a0 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ }, "workspaces": [ "crates/node", + "examples/language-binding-plugin/node", "integrations/openclaw" ], "devDependencies": { diff --git a/python/plugin/README.md b/python/plugin/README.md index f0f0934ac..0920fb582 100644 --- a/python/plugin/README.md +++ b/python/plugin/README.md @@ -21,38 +21,26 @@ dynamic worker plugins. Use it when plugin code should run in its own Python process and communicate with Relay through the versioned `grpc-v1` worker protocol. -Relay 0.8 establishes canonical tool results as the `grpc-v1` baseline. The -protocol name remains `grpc-v1`, while generated `ToolNext` responses and tool -execution outcomes use structural protobuf `ToolExecutionResult` and -`ToolExecutionInterceptOutcome` messages instead of schema-tagged JSON -envelopes. Workers and custom bindings built for an earlier Relay release must -regenerate their protobuf bindings, rebuild with this SDK, and declare a -`compat.relay` range beginning at `0.8.0` or later. - -## Why Use It? - -- **Isolate plugin dependencies**: Run custom policy, middleware, or exporter - code outside the Relay host process. -- **Use the shared runtime contract**: Register subscribers, guardrails, and - intercepts through `WorkerPlugin` and `PluginContext`. -- **Call back into Relay safely**: Emit marks, create scopes, and continue - managed execution through the host runtime handle. -- **Keep worker lifecycle managed**: Let Relay provision the worker environment, - start the entrypoint, and supply authenticated local endpoints. - -## What You Get - -- **`WorkerPlugin` and `PluginContext`**: The plugin validation and registration - contract for worker-owned runtime behavior. -- **`serve_plugin`**: An AsyncIO gRPC server wired to the Relay-managed worker - environment. -- **Typed runtime helpers**: JSON, event, scope, middleware, continuation, and - diagnostic types shared with Relay. -- **Canonical tool results**: `ToolNext.call()` returns `ToolExecutionResult`, - preserving opaque annotations separately from application result JSON. -- **Generated transport bindings**: Private protobuf bindings included in built - wheels; published-wheel installation does not require `protoc` or - `grpcio-tools`. +Relay 0.8 establishes canonical tool results as the `grpc-v1` baseline. Workers and +custom bindings built for earlier releases must regenerate protobuf bindings, rebuild, +and declare `compat.relay` beginning at `0.8.0`. `ToolNext.call()` returns +`ToolExecutionResult`, preserving opaque annotation metadata independently of the +application result JSON. + +## Authoring Surface + +The following rows describe the plugin authoring surfaces available through this SDK. + +| Surface | Role | +|---|---| +| `WorkerPlugin` and `PluginContext` | Define validation and install all 15 worker-owned subscriber and middleware registrations. | +| `serve_plugin` | Starts an AsyncIO gRPC server from the Relay-managed environment and authenticated local activation endpoints. | +| Typed runtime helpers | Share JSON, event, scope, middleware, continuation, and diagnostic contracts with the Relay host. | +| Canonical tool results | Preserve application results and opaque annotations across tool callbacks and continuations. | +| Generated transport bindings | Ship private protobuf bindings in the wheel, so installation does not require `protoc` or `grpcio-tools`. | + +The worker process isolates Python dependencies and crashes from Relay while preserving +host-owned execution, event, scope, cancellation, and shutdown semantics. ## Installation @@ -111,9 +99,6 @@ Set `load.entrypoint` to `your_module:main` in `relay-plugin.toml`. Relay imports that function and awaits the returned coroutine when it starts the worker process. -For a complete manifest and runnable plugin, see the -[Python gRPC worker plugin example](https://github.com/NVIDIA/NeMo-Relay/blob/main/examples/python-grpc-worker-plugin/README.md). - ## Request Intercepts LLM request intercepts return one canonical outcome: @@ -171,16 +156,11 @@ cancellation RPC and all other worker RPCs, so offload blocking work and make its own cancellation behavior explicit. `grpc-v1` workers are expected to implement this best-effort cancellation -contract. When a worker returns `accepted = false`, Relay still drops the -transport request, but it cannot guarantee worker-side interruption. +contract. Relay remains compatible with older workers that return +`accepted = false`; in that case it still drops the transport request, but it +cannot guarantee worker-side interruption. Windows ARM64 is not currently supported because `grpcio` does not publish a usable wheel for that platform. The NeMo Relay workspace skips installation and tests for this SDK on Windows ARM64 rather than creating a package without its required gRPC runtime. - -## Documentation - -- [NeMo Relay documentation](https://docs.nvidia.com/nemo/relay) -- [Build Plugins guide](https://docs.nvidia.com/nemo/relay/build-plugins/about) -- [Python gRPC worker plugin example](https://github.com/NVIDIA/NeMo-Relay/blob/main/examples/python-grpc-worker-plugin/README.md) diff --git a/python/tests/plugin/test_python_worker_example.py b/python/tests/plugin/test_python_worker_example.py deleted file mode 100644 index 3e69056cf..000000000 --- a/python/tests/plugin/test_python_worker_example.py +++ /dev/null @@ -1,169 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Tests for the Python gRPC worker plugin example.""" - -from __future__ import annotations - -import hashlib -import importlib -import os -import shutil -import subprocess -import sys -import tomllib -from pathlib import Path -from typing import Any -from unittest.mock import AsyncMock, MagicMock, call - -import pytest - -if os.environ.get("NEMO_RELAY_SKIP_PYTHON_PLUGIN_TESTS") == "1": - pytest.skip("grpcio is unavailable for Python plugin SDK tests on this runner", allow_module_level=True) - -pytest.importorskip("grpc") - -from nemo_relay_plugin import PluginContext, PluginRuntime # noqa: E402 - - -def test_manifest_integrity_matches_artifact_bytes(): - example_root = Path(__file__).parents[3] / "examples/python-grpc-worker-plugin" - manifest = tomllib.loads((example_root / "relay-plugin.toml").read_text(encoding="utf-8")) - artifact = example_root / manifest["source"]["artifact"] - - actual = f"sha256:{hashlib.sha256(artifact.read_bytes()).hexdigest()}" - assert actual == manifest["integrity"]["sha256"] - - -@pytest.fixture(name="example", scope="module") -def example_fixture(tmp_path_factory: pytest.TempPathFactory) -> Any: - example_root = Path(__file__).parents[3] / "examples/python-grpc-worker-plugin" - manifest = tomllib.loads((example_root / "relay-plugin.toml").read_text(encoding="utf-8")) - entrypoint = manifest["load"]["entrypoint"] - module_name, separator, function_name = entrypoint.partition(":") - assert separator == ":" - assert function_name == "main" - - build_root = tmp_path_factory.mktemp("python-worker-example") - project_root = build_root / "project" - wheel_dir = build_root / "wheel" - shutil.copytree( - example_root, - project_root, - ignore=shutil.ignore_patterns("build", "dist", "*.egg-info", ".venv", "__pycache__", "*.py[cod]"), - ) - subprocess.run( - ["uv", "build", "--wheel", "--out-dir", str(wheel_dir), str(project_root)], - check=True, - capture_output=True, - text=True, - ) - wheel = next(wheel_dir.glob("*.whl")) - package_name = module_name.partition(".")[0] - - def purge_example_modules() -> None: - for loaded_name in tuple(sys.modules): - if loaded_name == package_name or loaded_name.startswith(f"{package_name}."): - sys.modules.pop(loaded_name, None) - - sys.path.insert(0, str(wheel)) - importlib.invalidate_caches() - purge_example_modules() - try: - module = importlib.import_module(module_name) - assert getattr(module, function_name) is module.main - module_file = module.__file__ - assert module_file is not None - assert Path(module_file).is_relative_to(wheel) - yield module - finally: - purge_example_modules() - sys.path.remove(str(wheel)) - - -def test_example_validates_tag_configuration(example: Any): - plugin = example.ExamplePythonWorker() - - assert plugin.validate({"tag": "demo"}) == [] - diagnostics = plugin.validate(None) - assert len(diagnostics) == 1 - assert diagnostics[0].code == "examples.python_grpc_worker.invalid_config" - diagnostics = plugin.validate({"reject": True}) - assert len(diagnostics) == 1 - assert diagnostics[0].code == "examples.python_grpc_worker.rejected" - with pytest.raises(ValueError, match="Python gRPC worker rejection requested"): - plugin.register(None, {"reject": True}) - diagnostics = plugin.validate({"tag": 42}) - assert len(diagnostics) == 1 - assert diagnostics[0].code == "examples.python_grpc_worker.invalid_tag" - with pytest.raises(TypeError, match="plugin config must be a JSON object"): - plugin.register(None, None) - with pytest.raises(TypeError, match="tag must be a string"): - plugin.register(None, {"tag": 42}) - - -async def test_manifest_entrypoint_serves_example_plugin( - example: Any, - monkeypatch: pytest.MonkeyPatch, -): - served: list[Any] = [] - - async def capture(plugin: Any) -> None: - served.append(plugin) - - monkeypatch.setattr(example, "serve_plugin", capture) - await example.main() - - assert len(served) == 1 - assert isinstance(served[0], example.ExamplePythonWorker) - - -async def test_example_register_propagates_configured_tag(example: Any): - runtime = MagicMock(spec=PluginRuntime) - runtime.emit_mark = AsyncMock() - context = MagicMock(spec=PluginContext) - context.runtime = runtime - plugin = example.ExamplePythonWorker() - plugin.register(context, {"tag": "demo"}) - - context.register_tool_request_intercept.assert_called_once() - name, callback = context.register_tool_request_intercept.call_args.args - assert name == "tag_tool_request" - - assert await callback("lookup", {"query": "relay"}) == { - "query": "relay", - "_nemo_relay_plugin": {"tag": "demo"}, - } - assert await callback("search", {"query": "plugins"}) == { - "query": "plugins", - "_nemo_relay_plugin": {"tag": "demo"}, - } - assert await callback("collision", {"demo": False}) == { - "demo": False, - "_nemo_relay_plugin": {"tag": "demo"}, - } - assert await callback("existing_metadata", {"_nemo_relay_plugin": {"existing": True}}) == { - "_nemo_relay_plugin": {"existing": True, "tag": "demo"}, - } - assert await callback("scalar", ["not", "an", "object"]) == ["not", "an", "object"] - assert await callback("primitive", "relay") == "relay" - scalar_metadata = {"_nemo_relay_plugin": "owned-by-caller"} - array_metadata = {"_nemo_relay_plugin": ["owned", "by", "caller"]} - assert await callback("scalar_metadata", scalar_metadata) is scalar_metadata - assert await callback("array_metadata", array_metadata) is array_metadata - assert runtime.emit_mark.await_args_list == [ - call( - "examples.python_grpc_worker.tool_request", - {"tool_name": tool_name, "source": "python-grpc-worker", "tag": "demo"}, - ) - for tool_name in ( - "lookup", - "search", - "collision", - "existing_metadata", - "scalar", - "primitive", - "scalar_metadata", - "array_metadata", - ) - ]