diff --git a/crates/core/src/api/runtime/callbacks.rs b/crates/core/src/api/runtime/callbacks.rs index 30040f017..8b1767c5a 100644 --- a/crates/core/src/api/runtime/callbacks.rs +++ b/crates/core/src/api/runtime/callbacks.rs @@ -22,6 +22,7 @@ use crate::codec::request::AnnotatedLlmRequest; use crate::codec::traits::{LlmCodec, LlmResponseCodec}; use crate::error::Result; use crate::json::Json; +pub use nemo_relay_types::codec::identity::{BuiltinLlmCodec, LlmCodecIdentity}; /// Sanitize mutable observability fields on a fully constructed event. /// @@ -151,49 +152,6 @@ pub(crate) type ToolExecutionOutcomeNextFn = Arc< + Sync, >; -/// Relay's built-in LLM codec identities. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BuiltinLlmCodec { - /// OpenAI Chat Completions request and response payloads. - OpenAiChat, - /// OpenAI Responses request and response payloads. - OpenAiResponses, - /// Anthropic Messages request and response payloads. - AnthropicMessages, - /// OCI Generative AI chat request and response payloads. - OCIGenAI, - /// Gemini generateContent request and response payloads. - GeminiGenerateContent, -} - -impl BuiltinLlmCodec { - /// Stable identifier used in configuration and language bindings. - #[must_use] - pub const fn id(self) -> &'static str { - match self { - Self::OpenAiChat => "openai_chat", - Self::OpenAiResponses => "openai_responses", - Self::AnthropicMessages => "anthropic_messages", - Self::OCIGenAI => "oci_genai", - Self::GeminiGenerateContent => "gemini_generate_content", - } - } -} - -/// Per-call LLM codec identity supplied to sanitize guardrails. -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub enum LlmCodecIdentity { - /// No codec was active for this payload direction. - #[default] - None, - /// A Relay built-in codec was active. - BuiltIn(BuiltinLlmCodec), - /// A runtime-registered codec was active, identified by its stable ID. - Runtime(String), - /// A codec was active but does not expose a registered identity. - Opaque, -} - /// Per-call codec context for LLM request sanitize guardrails. /// /// The context distinguishes no codec, Relay built-ins, runtime-registered diff --git a/crates/plugin/src/async_sdk.rs b/crates/plugin/src/async_sdk.rs index c8a2b3684..d759db5d3 100644 --- a/crates/plugin/src/async_sdk.rs +++ b/crates/plugin/src/async_sdk.rs @@ -1039,20 +1039,9 @@ impl CodecIdentityInvocation { match (self.codec_kind.as_str(), self.codec_id) { ("none", _) => Ok(LlmCodecIdentity::None), ("opaque", _) => Ok(LlmCodecIdentity::Opaque), - ("builtin", Some(id)) => match id.as_str() { - "openai_chat" => Ok(LlmCodecIdentity::BuiltIn(BuiltinLlmCodec::OpenAiChat)), - "openai_responses" => { - Ok(LlmCodecIdentity::BuiltIn(BuiltinLlmCodec::OpenAiResponses)) - } - "anthropic_messages" => Ok(LlmCodecIdentity::BuiltIn( - BuiltinLlmCodec::AnthropicMessages, - )), - "oci_genai" => Ok(LlmCodecIdentity::BuiltIn(BuiltinLlmCodec::OCIGenAI)), - "gemini_generate_content" => Ok(LlmCodecIdentity::BuiltIn( - BuiltinLlmCodec::GeminiGenerateContent, - )), - _ => Err(format!("unknown built-in LLM codec: {id}")), - }, + ("builtin", Some(id)) => BuiltinLlmCodec::from_id(&id) + .map(LlmCodecIdentity::BuiltIn) + .ok_or_else(|| format!("unknown built-in LLM codec: {id}")), ("runtime", Some(id)) => Ok(LlmCodecIdentity::Runtime(id)), (kind, _) => Err(format!("invalid LLM codec context: {kind}")), } diff --git a/crates/plugin/src/lib.rs b/crates/plugin/src/lib.rs index 97ff278ae..43cb9cc1e 100644 --- a/crates/plugin/src/lib.rs +++ b/crates/plugin/src/lib.rs @@ -27,6 +27,7 @@ pub use nemo_relay_types::api::event::{ pub use nemo_relay_types::api::llm::{LlmAttributes, LlmRequest, LlmRequestInterceptOutcome}; pub use nemo_relay_types::api::scope::{HandleAttributes, ScopeAttributes, ScopeType}; pub use nemo_relay_types::api::tool::{ToolAttributes, ToolExecutionInterceptOutcome}; +pub use nemo_relay_types::codec::identity::{BuiltinLlmCodec, LlmCodecIdentity}; pub use nemo_relay_types::codec::optimization::{ LlmOptimizationContribution, LlmOptimizationEvidenceQuality, LlmOptimizationKind, LlmOptimizationModel, LlmOptimizationModelTransition, LlmOptimizationPayload, @@ -52,43 +53,6 @@ pub const NEMO_RELAY_NATIVE_ABI_VERSION_TYPED_ASYNC: u32 = 4; /// Legacy native plugin ABI accepted by Relay hosts for compatibility. pub const NEMO_RELAY_NATIVE_ABI_VERSION_LEGACY: u32 = 2; -/// Built-in LLM codec identities available to native plugins. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, serde::Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum BuiltinLlmCodec { - /// OpenAI Chat Completions. - #[serde(rename = "openai_chat")] - OpenAiChat, - /// OpenAI Responses. - #[serde(rename = "openai_responses")] - OpenAiResponses, - /// Anthropic Messages. - #[serde(rename = "anthropic_messages")] - AnthropicMessages, - /// OCI Generative AI chat. - #[serde(rename = "oci_genai")] - OCIGenAI, - /// Gemini generateContent. - #[serde(rename = "gemini_generate_content")] - GeminiGenerateContent, -} - -/// Per-call LLM codec identity delivered to native plugins. -#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, serde::Deserialize)] -#[serde(tag = "kind", content = "id", rename_all = "snake_case")] -pub enum LlmCodecIdentity { - /// No codec was active. - #[default] - None, - /// A Relay built-in codec was active. - #[serde(rename = "builtin")] - BuiltIn(BuiltinLlmCodec), - /// A runtime-registered codec was active, identified by its stable ID. - Runtime(String), - /// A codec was active but has no registered identity. - Opaque, -} - /// Per-call request codec context delivered to an LLM sanitizer. pub struct LlmSanitizeRequestContext<'a> { /// Identity of the active codec. diff --git a/crates/plugin/tests/typed_callbacks.rs b/crates/plugin/tests/typed_callbacks.rs index 09aa73c91..969810c90 100644 --- a/crates/plugin/tests/typed_callbacks.rs +++ b/crates/plugin/tests/typed_callbacks.rs @@ -3368,6 +3368,85 @@ fn typed_async_llm_sanitize_context_decodes_oci_genai_builtin_identity() { } } +#[test] +fn typed_async_llm_sanitize_context_decodes_all_builtin_identities() { + let _guard = begin_test(); + + for expected in [ + BuiltinLlmCodec::OpenAiChat, + BuiltinLlmCodec::OpenAiResponses, + BuiltinLlmCodec::AnthropicMessages, + BuiltinLlmCodec::OCIGenAI, + BuiltinLlmCodec::GeminiGenerateContent, + ] { + let host = test_host_v4(); + let mut ctx = test_context(&host.v3.v1); + ctx.register_llm_sanitize_request_guardrail( + "llm-request-builtins", + 0, + move |request, context| async move { + assert_eq!(context.codec, LlmCodecIdentity::BuiltIn(expected)); + Ok(Some(request)) + }, + ) + .unwrap(); + + let registration = + take_async_registration(NemoRelayNativeAsyncMiddlewareKind::LlmSanitizeRequest); + assert_eq!( + invoke_async_registration( + &host, + ®istration, + json!({ + "request": test_llm_request(), + "context": { "codec_kind": "builtin", "codec_id": expected.id() } + }), + None, + ) + .unwrap()["content"], + json!({ "prompt": "hello" }) + ); + unsafe { registration.free() }; + + let deadline = Instant::now() + Duration::from_secs(5); + while live_host_strings() != 0 { + assert!( + Instant::now() < deadline, + "host strings were not released after the sanitize invocation" + ); + std::thread::yield_now(); + } + } +} + +#[test] +fn typed_async_llm_sanitize_context_rejects_unknown_builtin_identity() { + let _guard = begin_test(); + let host = test_host_v4(); + let mut ctx = test_context(&host.v3.v1); + ctx.register_llm_sanitize_request_guardrail( + "llm-request-unknown", + 0, + |request, _context| async move { Ok(Some(request)) }, + ) + .unwrap(); + + let registration = + take_async_registration(NemoRelayNativeAsyncMiddlewareKind::LlmSanitizeRequest); + let error = invoke_async_registration( + &host, + ®istration, + json!({ + "request": test_llm_request(), + "context": { "codec_kind": "builtin", "codec_id": "future_provider" } + }), + None, + ) + .expect_err("unknown built-in codec identities must be rejected"); + assert!(error.contains("unknown built-in LLM codec: future_provider")); + unsafe { registration.free() }; +} + #[test] fn typed_async_registration_failure_rolls_back_callback_state() { let _guard = begin_test(); diff --git a/crates/types/src/codec/identity.rs b/crates/types/src/codec/identity.rs new file mode 100644 index 000000000..d888477ad --- /dev/null +++ b/crates/types/src/codec/identity.rs @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Stable LLM codec identities shared across Relay runtime and SDK boundaries. +//! +//! These types identify a codec selected by the Relay runtime. They do not +//! provide codec implementations, provider detection, or runtime capabilities. + +use serde::{Deserialize, Serialize}; + +macro_rules! builtin_llm_codecs { + ($( $(#[$meta:meta])* $variant:ident => $id:literal, )+) => { + /// Relay's built-in LLM codec identities. + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] + pub enum BuiltinLlmCodec { + $( + $(#[$meta])* + #[serde(rename = $id)] + $variant, + )+ + } + + impl BuiltinLlmCodec { + /// Every built-in codec identity in stable ID order. + pub const ALL: &'static [Self] = &[$(Self::$variant),+]; + + /// Stable identifier used in configuration and SDK transport boundaries. + #[must_use] + pub const fn id(self) -> &'static str { + match self { + $(Self::$variant => $id),+ + } + } + + /// Resolve a stable built-in codec identifier. + #[must_use] + pub fn from_id(id: &str) -> Option { + match id { + $($id => Some(Self::$variant),)+ + _ => None, + } + } + } + }; +} + +builtin_llm_codecs! { + /// OpenAI Chat Completions request and response payloads. + OpenAiChat => "openai_chat", + /// OpenAI Responses request and response payloads. + OpenAiResponses => "openai_responses", + /// Anthropic Messages request and response payloads. + AnthropicMessages => "anthropic_messages", + /// OCI Generative AI chat request and response payloads. + OCIGenAI => "oci_genai", + /// Gemini generateContent request and response payloads. + GeminiGenerateContent => "gemini_generate_content", +} + +/// Per-call LLM codec identity supplied to sanitizer and SDK callbacks. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(tag = "kind", content = "id", rename_all = "snake_case")] +pub enum LlmCodecIdentity { + /// No codec was active for this payload direction. + #[default] + None, + /// A Relay built-in codec was active. + #[serde(rename = "builtin")] + BuiltIn(BuiltinLlmCodec), + /// A runtime-registered codec was active, identified by its stable ID. + Runtime(String), + /// A codec was active but does not expose a registered identity. + Opaque, +} diff --git a/crates/types/src/codec/mod.rs b/crates/types/src/codec/mod.rs index 79d61e9ba..e4960fcc9 100644 --- a/crates/types/src/codec/mod.rs +++ b/crates/types/src/codec/mod.rs @@ -3,6 +3,8 @@ //! Shared normalized LLM request and response data types. +/// Stable LLM codec identities shared across SDK boundaries. +pub mod identity; /// Plugin-neutral LLM optimization evidence and summaries. pub mod optimization; /// Normalized LLM request data types. diff --git a/crates/types/tests/codec_identity_tests.rs b/crates/types/tests/codec_identity_tests.rs new file mode 100644 index 000000000..53ed2aec4 --- /dev/null +++ b/crates/types/tests/codec_identity_tests.rs @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Compatibility tests for shared LLM codec identities. + +use nemo_relay_types::codec::identity::{BuiltinLlmCodec, LlmCodecIdentity}; +use serde_json::json; + +#[test] +fn builtin_codec_ids_round_trip() { + for &codec in BuiltinLlmCodec::ALL { + assert_eq!(BuiltinLlmCodec::from_id(codec.id()), Some(codec)); + assert_eq!(serde_json::to_value(codec).unwrap(), json!(codec.id())); + } +} + +#[test] +fn builtin_codec_ids_reject_unknown_values() { + for id in ["", "openai-chat", "OpenAI_chat", "unknown"] { + assert_eq!(BuiltinLlmCodec::from_id(id), None); + } +} + +#[test] +fn codec_identity_preserves_the_native_plugin_json_contract() { + let cases = [ + (LlmCodecIdentity::None, json!({"kind": "none"})), + ( + LlmCodecIdentity::BuiltIn(BuiltinLlmCodec::OpenAiChat), + json!({"kind": "builtin", "id": "openai_chat"}), + ), + ( + LlmCodecIdentity::Runtime("com.example.chat.v1".to_owned()), + json!({"kind": "runtime", "id": "com.example.chat.v1"}), + ), + (LlmCodecIdentity::Opaque, json!({"kind": "opaque"})), + ]; + + for (identity, expected) in cases { + assert_eq!(serde_json::to_value(&identity).unwrap(), expected); + assert_eq!( + serde_json::from_value::(expected).unwrap(), + identity + ); + } +} diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index d80937fce..bf50cf6dc 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -38,6 +38,7 @@ pub use nemo_relay_types::api::event::{DataSchema, Event, EventSanitizeFields, P pub use nemo_relay_types::api::llm::{LlmRequest, LlmRequestInterceptOutcome}; pub use nemo_relay_types::api::scope::ScopeType; pub use nemo_relay_types::api::tool::ToolExecutionInterceptOutcome; +pub use nemo_relay_types::codec::identity::{BuiltinLlmCodec, LlmCodecIdentity}; pub use nemo_relay_types::codec::optimization::{ LlmOptimizationContribution, LlmOptimizationEvidenceQuality, LlmOptimizationKind, LlmOptimizationModel, LlmOptimizationModelTransition, LlmOptimizationPayload, @@ -145,34 +146,6 @@ type LlmSanitizeRequestFn = Arc< type LlmSanitizeResponseFn = Arc BoxFutureResult> + Send + Sync>; -/// Relay built-in codec identities supplied to worker sanitizers. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BuiltinLlmCodec { - /// OpenAI Chat Completions. - OpenAiChat, - /// OpenAI Responses. - OpenAiResponses, - /// Anthropic Messages. - AnthropicMessages, - /// OCI Generative AI chat request and response payloads. - OCIGenAI, - /// Gemini generateContent request and response payloads. - GeminiGenerateContent, -} - -/// Per-call LLM codec identity supplied to worker sanitizers. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum LlmCodecIdentity { - /// No codec was active. - None, - /// A Relay built-in codec was active. - BuiltIn(BuiltinLlmCodec), - /// A runtime-registered codec was active, identified by its stable ID. - Runtime(String), - /// A codec was active but has no registered identity. - Opaque, -} - /// Active codec context supplied to an LLM request sanitizer. #[derive(Clone)] pub struct LlmSanitizeRequestContext { @@ -2155,18 +2128,10 @@ fn codec_identity_from_proto( let codec_id = codec.and_then(|codec| codec.id.clone()); match LlmCodecKind::try_from(codec_kind).ok() { Some(LlmCodecKind::Unspecified) => LlmCodecIdentity::None, - Some(LlmCodecKind::Builtin) => match codec_id.as_deref() { - Some("openai_chat") => LlmCodecIdentity::BuiltIn(BuiltinLlmCodec::OpenAiChat), - Some("openai_responses") => LlmCodecIdentity::BuiltIn(BuiltinLlmCodec::OpenAiResponses), - Some("anthropic_messages") => { - LlmCodecIdentity::BuiltIn(BuiltinLlmCodec::AnthropicMessages) - } - Some("oci_genai") => LlmCodecIdentity::BuiltIn(BuiltinLlmCodec::OCIGenAI), - Some("gemini_generate_content") => { - LlmCodecIdentity::BuiltIn(BuiltinLlmCodec::GeminiGenerateContent) - } - _ => LlmCodecIdentity::Opaque, - }, + Some(LlmCodecKind::Builtin) => codec_id + .as_deref() + .and_then(BuiltinLlmCodec::from_id) + .map_or(LlmCodecIdentity::Opaque, LlmCodecIdentity::BuiltIn), Some(LlmCodecKind::Runtime) => codec_id .filter(|id| !id.is_empty()) .map_or(LlmCodecIdentity::Opaque, LlmCodecIdentity::Runtime), diff --git a/crates/worker/tests/unit/codec_identity_tests.rs b/crates/worker/tests/unit/codec_identity_tests.rs index 8dc1d7420..a64df87aa 100644 --- a/crates/worker/tests/unit/codec_identity_tests.rs +++ b/crates/worker/tests/unit/codec_identity_tests.rs @@ -4,35 +4,39 @@ use super::*; #[test] -fn test_gemini_codec_identity_decoded_as_builtin_not_opaque() { +fn known_builtin_codec_identities_decode_as_builtins() { use nemo_relay_worker_proto::v1::LlmCodecIdentity as ProtoIdentity; use nemo_relay_worker_proto::v1::LlmCodecKind; - let proto = ProtoIdentity { - kind: LlmCodecKind::Builtin as i32, - id: Some("gemini_generate_content".to_string()), - }; - let identity = codec_identity_from_proto(Some(&proto)); - assert_eq!( - identity, - LlmCodecIdentity::BuiltIn(BuiltinLlmCodec::GeminiGenerateContent), - "Gemini generateContent codec id must decode to BuiltIn(GeminiGenerateContent), not Opaque" - ); + for codec in [ + BuiltinLlmCodec::OpenAiChat, + BuiltinLlmCodec::OpenAiResponses, + BuiltinLlmCodec::AnthropicMessages, + BuiltinLlmCodec::OCIGenAI, + BuiltinLlmCodec::GeminiGenerateContent, + ] { + let proto = ProtoIdentity { + kind: LlmCodecKind::Builtin as i32, + id: Some(codec.id().to_owned()), + }; + assert_eq!( + codec_identity_from_proto(Some(&proto)), + LlmCodecIdentity::BuiltIn(codec), + ); + } } #[test] -fn test_oci_genai_codec_identity_decoded_as_builtin_not_opaque() { +fn unknown_builtin_codec_identity_decodes_as_opaque() { use nemo_relay_worker_proto::v1::LlmCodecIdentity as ProtoIdentity; use nemo_relay_worker_proto::v1::LlmCodecKind; let proto = ProtoIdentity { kind: LlmCodecKind::Builtin as i32, - id: Some("oci_genai".to_string()), + id: Some("future_provider".to_owned()), }; - let identity = codec_identity_from_proto(Some(&proto)); assert_eq!( - identity, - LlmCodecIdentity::BuiltIn(BuiltinLlmCodec::OCIGenAI), - "OCI GenAI codec id must decode to BuiltIn(OCIGenAI), not Opaque" + codec_identity_from_proto(Some(&proto)), + LlmCodecIdentity::Opaque ); }