From b7c5507e381727f202e8b07ec951ea6b7f5241c0 Mon Sep 17 00:00:00 2001 From: nachiketb Date: Fri, 17 Jul 2026 11:08:02 -0700 Subject: [PATCH 1/2] feat(libsy): expose typed Python protocol bindings Signed-off-by: nachiketb --- Cargo.lock | 1 + crates/switchyard-py/Cargo.toml | 1 + crates/switchyard-py/src/errors.rs | 7 + crates/switchyard-py/src/lib.rs | 2 + crates/switchyard-py/src/libsy_bindings.rs | 18 + .../src/libsy_bindings/protocol.rs | 1604 +++++++++++++++++ .../src/libsy_bindings/values.rs | 654 +++++++ switchyard/__init__.py | 2 + switchyard/libsy/__init__.py | 8 + switchyard/libsy/protocol.py | 98 + switchyard_rust/libsy_protocol.py | 84 + switchyard_rust/libsy_protocol.pyi | 493 +++++ tests/test_libsy_protocol_bindings.py | 241 +++ 13 files changed, 3213 insertions(+) create mode 100644 crates/switchyard-py/src/libsy_bindings.rs create mode 100644 crates/switchyard-py/src/libsy_bindings/protocol.rs create mode 100644 crates/switchyard-py/src/libsy_bindings/values.rs create mode 100644 switchyard/libsy/__init__.py create mode 100644 switchyard/libsy/protocol.py create mode 100644 switchyard_rust/libsy_protocol.py create mode 100644 switchyard_rust/libsy_protocol.pyi create mode 100644 tests/test_libsy_protocol_bindings.py diff --git a/Cargo.lock b/Cargo.lock index 18749f29..9c1b87b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1653,6 +1653,7 @@ dependencies = [ "switchyard-components", "switchyard-components-v2", "switchyard-core", + "switchyard-protocol", "switchyard-server", "switchyard-translation", "tokio", diff --git a/crates/switchyard-py/Cargo.toml b/crates/switchyard-py/Cargo.toml index 67f59cca..ceb9eb3f 100644 --- a/crates/switchyard-py/Cargo.toml +++ b/crates/switchyard-py/Cargo.toml @@ -26,6 +26,7 @@ serde_json = "1" switchyard-core = { path = "../switchyard-core" } switchyard-components = { path = "../switchyard-components" } switchyard-components-v2 = { path = "../switchyard-components-v2" } +switchyard-protocol = { path = "../libsy-protocol" } switchyard-server = { path = "../switchyard-server" } switchyard-translation = { path = "../switchyard-translation" } tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync"] } diff --git a/crates/switchyard-py/src/errors.rs b/crates/switchyard-py/src/errors.rs index 72b3dba4..39ef2d2e 100644 --- a/crates/switchyard-py/src/errors.rs +++ b/crates/switchyard-py/src/errors.rs @@ -10,6 +10,7 @@ use switchyard_core::SwitchyardError; use switchyard_translation::TranslationError; create_exception!(_switchyard_rust, SwitchyardRuntimeError, PyRuntimeError); +create_exception!(_switchyard_rust, LibsyError, SwitchyardRuntimeError); create_exception!( _switchyard_rust, SwitchyardConfigError, @@ -79,6 +80,11 @@ pub(crate) fn py_translation_error(error: TranslationError) -> PyErr { PyValueError::new_err(format!("{}: {}", error.kind(), error)) } +/// Converts libsy orchestration failures into the Python binding error. +pub(crate) fn py_libsy_error(error: impl std::fmt::Display) -> PyErr { + LibsyError::new_err(error.to_string()) +} + /// Converts core Switchyard errors into typed Python runtime errors. /// /// `ContextWindowExceeded` and `ContextPoolExhausted` carry typed fields @@ -161,6 +167,7 @@ pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { "SwitchyardRuntimeError", py.get_type::(), )?; + module.add("LibsyError", py.get_type::())?; module.add( "SwitchyardConfigError", py.get_type::(), diff --git a/crates/switchyard-py/src/lib.rs b/crates/switchyard-py/src/lib.rs index 00b0eb75..977f66ba 100644 --- a/crates/switchyard-py/src/lib.rs +++ b/crates/switchyard-py/src/lib.rs @@ -6,6 +6,7 @@ use pyo3::prelude::*; mod component_bindings; mod core_bindings; mod errors; +mod libsy_bindings; mod profile_bindings; mod py_serde; mod server_bindings; @@ -14,6 +15,7 @@ mod translation; #[pymodule] fn _switchyard_rust(module: &Bound<'_, PyModule>) -> PyResult<()> { errors::register(module)?; + libsy_bindings::register(module)?; translation::register(module)?; core_bindings::register(module)?; component_bindings::register(module)?; diff --git a/crates/switchyard-py/src/libsy_bindings.rs b/crates/switchyard-py/src/libsy_bindings.rs new file mode 100644 index 00000000..87f8530a --- /dev/null +++ b/crates/switchyard-py/src/libsy_bindings.rs @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Python bindings for Switchyard's neutral LLM protocol values. + +mod protocol; +mod values; + +use pyo3::prelude::*; + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + let protocol_module = PyModule::new(module.py(), "libsy_protocol")?; + protocol::register(&protocol_module)?; + values::register(&protocol_module)?; + module.add_submodule(&protocol_module)?; + + Ok(()) +} diff --git a/crates/switchyard-py/src/libsy_bindings/protocol.rs b/crates/switchyard-py/src/libsy_bindings/protocol.rs new file mode 100644 index 00000000..55b58344 --- /dev/null +++ b/crates/switchyard-py/src/libsy_bindings/protocol.rs @@ -0,0 +1,1604 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Typed Python wrappers for the Rust-owned Switchyard protocol. + +use std::collections::BTreeMap; + +use pyo3::prelude::*; +use pyo3::types::PyType; +use serde::Serialize; +use serde_json::{Map, Value}; +use switchyard_protocol::{ + AggLlmResponse, ContentBlock, FileSource, FormatId, ImageSource, InstructionBlock, LlmRequest, + LlmResponseChunk, MediaSource, Message, OutputParams, PreservationMetadata, ProviderExtensions, + ReasoningParams, ResponseOutput, Role, SamplingParams, StopReason, ToolCall, ToolChoice, + ToolDefinition, ToolResult, Usage, WireFormat, +}; + +use crate::py_serde::{value_from_python, value_to_python}; + +fn serialized_to_python(py: Python<'_>, value: &T) -> PyResult> { + let value = serde_json::to_value(value) + .map_err(|error| pyo3::exceptions::PyValueError::new_err(error.to_string()))?; + value_to_python(py, &value) +} + +fn optional_json(value: Option<&Bound<'_, PyAny>>) -> PyResult> { + value.map(value_from_python).transpose() +} + +fn json_or_null(py: Python<'_>, value: Option<&Value>) -> PyResult> { + match value { + Some(value) => value_to_python(py, value), + None => Ok(py.None()), + } +} + +#[pyclass( + name = "WireFormat", + module = "switchyard.libsy.protocol", + eq, + frozen, + from_py_object +)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum PyWireFormat { + #[pyo3(name = "OPENAI_CHAT")] + OpenAiChat, + #[pyo3(name = "ANTHROPIC_MESSAGES")] + AnthropicMessages, + #[pyo3(name = "OPENAI_RESPONSES")] + OpenAiResponses, +} + +impl From for WireFormat { + fn from(value: PyWireFormat) -> Self { + match value { + PyWireFormat::OpenAiChat => Self::OpenAiChat, + PyWireFormat::AnthropicMessages => Self::AnthropicMessages, + PyWireFormat::OpenAiResponses => Self::OpenAiResponses, + } + } +} + +impl From for PyWireFormat { + fn from(value: WireFormat) -> Self { + match value { + WireFormat::OpenAiChat => Self::OpenAiChat, + WireFormat::AnthropicMessages => Self::AnthropicMessages, + WireFormat::OpenAiResponses => Self::OpenAiResponses, + } + } +} + +#[pymethods] +impl PyWireFormat { + #[getter] + fn value(&self) -> &'static str { + WireFormat::from(*self).as_str() + } + + fn __str__(&self) -> &'static str { + self.value() + } +} + +#[pyclass( + name = "FormatId", + module = "switchyard.libsy.protocol", + frozen, + skip_from_py_object +)] +#[derive(Clone)] +pub(crate) struct PyFormatId { + inner: FormatId, +} + +impl PyFormatId { + fn from_core(inner: FormatId) -> Self { + Self { inner } + } + + fn clone_core(&self) -> FormatId { + self.inner.clone() + } +} + +#[pymethods] +impl PyFormatId { + #[new] + fn new(value: String) -> Self { + Self::from_core(FormatId::new(value)) + } + + #[classmethod] + fn known(_cls: &Bound<'_, PyType>, value: PyWireFormat) -> Self { + Self::from_core(FormatId::known(value.into())) + } + + #[getter] + fn value(&self) -> &str { + self.inner.as_str() + } + + fn __str__(&self) -> &str { + self.inner.as_str() + } + + fn __repr__(&self) -> String { + format!("FormatId({:?})", self.inner.as_str()) + } +} + +#[pyclass( + name = "Role", + module = "switchyard.libsy.protocol", + eq, + frozen, + from_py_object +)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum PyRole { + #[pyo3(name = "SYSTEM")] + System, + #[pyo3(name = "DEVELOPER")] + Developer, + #[pyo3(name = "USER")] + User, + #[pyo3(name = "ASSISTANT")] + Assistant, + #[pyo3(name = "TOOL")] + Tool, +} + +impl From for Role { + fn from(value: PyRole) -> Self { + match value { + PyRole::System => Self::System, + PyRole::Developer => Self::Developer, + PyRole::User => Self::User, + PyRole::Assistant => Self::Assistant, + PyRole::Tool => Self::Tool, + } + } +} + +impl From for PyRole { + fn from(value: Role) -> Self { + match value { + Role::System => Self::System, + Role::Developer => Self::Developer, + Role::User => Self::User, + Role::Assistant => Self::Assistant, + Role::Tool => Self::Tool, + } + } +} + +#[pymethods] +impl PyRole { + #[getter] + fn value(&self) -> &'static str { + match self { + Self::System => "system", + Self::Developer => "developer", + Self::User => "user", + Self::Assistant => "assistant", + Self::Tool => "tool", + } + } +} + +#[pyclass( + name = "StopReason", + module = "switchyard.libsy.protocol", + eq, + frozen, + from_py_object +)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum PyStopReason { + #[pyo3(name = "END_TURN")] + EndTurn, + #[pyo3(name = "MAX_TOKENS")] + MaxTokens, + #[pyo3(name = "TOOL_USE")] + ToolUse, + #[pyo3(name = "CONTENT_FILTER")] + ContentFilter, + #[pyo3(name = "ERROR")] + Error, + #[pyo3(name = "UNKNOWN")] + Unknown, +} + +impl From for StopReason { + fn from(value: PyStopReason) -> Self { + match value { + PyStopReason::EndTurn => Self::EndTurn, + PyStopReason::MaxTokens => Self::MaxTokens, + PyStopReason::ToolUse => Self::ToolUse, + PyStopReason::ContentFilter => Self::ContentFilter, + PyStopReason::Error => Self::Error, + PyStopReason::Unknown => Self::Unknown, + } + } +} + +impl From for PyStopReason { + fn from(value: StopReason) -> Self { + match value { + StopReason::EndTurn => Self::EndTurn, + StopReason::MaxTokens => Self::MaxTokens, + StopReason::ToolUse => Self::ToolUse, + StopReason::ContentFilter => Self::ContentFilter, + StopReason::Error => Self::Error, + StopReason::Unknown => Self::Unknown, + } + } +} + +#[pymethods] +impl PyStopReason { + #[getter] + fn value(&self) -> &'static str { + match self { + Self::EndTurn => "end_turn", + Self::MaxTokens => "max_tokens", + Self::ToolUse => "tool_use", + Self::ContentFilter => "content_filter", + Self::Error => "error", + Self::Unknown => "unknown", + } + } +} + +#[pyclass(name = "ImageSource", module = "switchyard.libsy.protocol", frozen)] +pub(crate) enum PyImageSource { + Url { + url: String, + detail: Option, + }, + Base64 { + media_type: Option, + data: String, + }, + Raw { + value: Py, + }, +} + +impl PyImageSource { + fn from_core(py: Python<'_>, source: ImageSource) -> PyResult { + match source { + ImageSource::Url { url, detail } => Ok(Self::Url { url, detail }), + ImageSource::Base64 { media_type, data } => Ok(Self::Base64 { media_type, data }), + ImageSource::Raw(value) => Ok(Self::Raw { + value: value_to_python(py, &value)?, + }), + } + } + + fn clone_core(&self, py: Python<'_>) -> PyResult { + match self { + Self::Url { url, detail } => Ok(ImageSource::Url { + url: url.clone(), + detail: detail.clone(), + }), + Self::Base64 { media_type, data } => Ok(ImageSource::Base64 { + media_type: media_type.clone(), + data: data.clone(), + }), + Self::Raw { value } => Ok(ImageSource::Raw(value_from_python(value.bind(py))?)), + } + } +} + +#[pyclass(name = "FileSource", module = "switchyard.libsy.protocol", frozen)] +pub(crate) enum PyFileSource { + FileId { + id: String, + }, + FileData { + data: String, + filename: Option, + }, + Raw { + value: Py, + }, +} + +impl PyFileSource { + fn from_core(py: Python<'_>, source: FileSource) -> PyResult { + match source { + FileSource::FileId(id) => Ok(Self::FileId { id }), + FileSource::FileData { data, filename } => Ok(Self::FileData { data, filename }), + FileSource::Raw(value) => Ok(Self::Raw { + value: value_to_python(py, &value)?, + }), + } + } + + fn clone_core(&self, py: Python<'_>) -> PyResult { + match self { + Self::FileId { id } => Ok(FileSource::FileId(id.clone())), + Self::FileData { data, filename } => Ok(FileSource::FileData { + data: data.clone(), + filename: filename.clone(), + }), + Self::Raw { value } => Ok(FileSource::Raw(value_from_python(value.bind(py))?)), + } + } +} + +#[pyclass(name = "MediaSource", module = "switchyard.libsy.protocol", frozen)] +pub(crate) enum PyMediaSource { + Url { + url: String, + media_type: Option, + }, + Base64 { + media_type: Option, + data: String, + }, + Raw { + value: Py, + }, +} + +impl PyMediaSource { + fn from_core(py: Python<'_>, source: MediaSource) -> PyResult { + match source { + MediaSource::Url { url, media_type } => Ok(Self::Url { url, media_type }), + MediaSource::Base64 { media_type, data } => Ok(Self::Base64 { media_type, data }), + MediaSource::Raw(value) => Ok(Self::Raw { + value: value_to_python(py, &value)?, + }), + } + } + + fn clone_core(&self, py: Python<'_>) -> PyResult { + match self { + Self::Url { url, media_type } => Ok(MediaSource::Url { + url: url.clone(), + media_type: media_type.clone(), + }), + Self::Base64 { media_type, data } => Ok(MediaSource::Base64 { + media_type: media_type.clone(), + data: data.clone(), + }), + Self::Raw { value } => Ok(MediaSource::Raw(value_from_python(value.bind(py))?)), + } + } +} + +#[pyclass( + name = "ToolCall", + module = "switchyard.libsy.protocol", + frozen, + skip_from_py_object +)] +#[derive(Clone)] +pub(crate) struct PyToolCall { + inner: ToolCall, +} + +impl PyToolCall { + fn from_core(inner: ToolCall) -> Self { + Self { inner } + } + + fn clone_core(&self) -> ToolCall { + self.inner.clone() + } +} + +#[pymethods] +impl PyToolCall { + #[new] + fn new(id: String, name: String, arguments: &Bound<'_, PyAny>) -> PyResult { + Ok(Self::from_core(ToolCall { + id, + name, + arguments: value_from_python(arguments)?, + })) + } + + #[getter] + fn id(&self) -> &str { + &self.inner.id + } + + #[getter] + fn name(&self) -> &str { + &self.inner.name + } + + #[getter] + fn arguments(&self, py: Python<'_>) -> PyResult> { + value_to_python(py, &self.inner.arguments) + } + + fn to_dict(&self, py: Python<'_>) -> PyResult> { + serialized_to_python(py, &self.inner) + } +} + +#[pyclass( + name = "ToolResult", + module = "switchyard.libsy.protocol", + frozen, + skip_from_py_object +)] +#[derive(Clone)] +pub(crate) struct PyToolResult { + inner: ToolResult, +} + +impl PyToolResult { + fn from_core(inner: ToolResult) -> Self { + Self { inner } + } + + fn clone_core(&self) -> ToolResult { + self.inner.clone() + } +} + +#[pymethods] +impl PyToolResult { + #[new] + #[pyo3(signature = (tool_call_id, content, *, is_error=None))] + fn new( + py: Python<'_>, + tool_call_id: String, + content: Vec>, + is_error: Option, + ) -> PyResult { + Ok(Self::from_core(ToolResult { + tool_call_id, + content: content_blocks_to_core(py, content)?, + is_error, + })) + } + + #[getter] + fn tool_call_id(&self) -> &str { + &self.inner.tool_call_id + } + + #[getter] + fn content(&self, py: Python<'_>) -> PyResult>> { + content_blocks_from_core(py, self.inner.content.clone()) + } + + #[getter] + fn is_error(&self) -> Option { + self.inner.is_error + } + + fn to_dict(&self, py: Python<'_>) -> PyResult> { + serialized_to_python(py, &self.inner) + } +} + +#[pyclass(name = "ContentBlock", module = "switchyard.libsy.protocol", frozen)] +pub(crate) enum PyContentBlock { + Text { + text: String, + }, + Reasoning { + text: String, + signature: Option, + }, + Image { + source: Py, + }, + Audio { + source: Py, + }, + Video { + source: Py, + }, + File { + source: Py, + }, + #[pyo3(name = "ToolCallBlock")] + ToolCall { + tool_call: Py, + }, + #[pyo3(name = "ToolResultBlock")] + ToolResult { + tool_result: Py, + }, + Refusal { + text: String, + }, + Unknown { + provider: Py, + raw: Py, + }, +} + +impl PyContentBlock { + fn from_core(py: Python<'_>, block: ContentBlock) -> PyResult { + match block { + ContentBlock::Text { text } => Ok(Self::Text { text }), + ContentBlock::Reasoning { text, signature } => Ok(Self::Reasoning { text, signature }), + ContentBlock::Image { source } => Ok(Self::Image { + source: PyImageSource::from_core(py, source)? + .into_pyobject(py)? + .unbind(), + }), + ContentBlock::Audio { source } => Ok(Self::Audio { + source: PyMediaSource::from_core(py, source)? + .into_pyobject(py)? + .unbind(), + }), + ContentBlock::Video { source } => Ok(Self::Video { + source: PyMediaSource::from_core(py, source)? + .into_pyobject(py)? + .unbind(), + }), + ContentBlock::File { source } => Ok(Self::File { + source: PyFileSource::from_core(py, source)? + .into_pyobject(py)? + .unbind(), + }), + ContentBlock::ToolCall(tool_call) => Ok(Self::ToolCall { + tool_call: Py::new(py, PyToolCall::from_core(tool_call))?, + }), + ContentBlock::ToolResult(tool_result) => Ok(Self::ToolResult { + tool_result: Py::new(py, PyToolResult::from_core(tool_result))?, + }), + ContentBlock::Refusal { text } => Ok(Self::Refusal { text }), + ContentBlock::Unknown { provider, raw } => Ok(Self::Unknown { + provider: Py::new(py, PyFormatId::from_core(provider))?, + raw: value_to_python(py, &raw)?, + }), + } + } + + fn clone_core(&self, py: Python<'_>) -> PyResult { + match self { + Self::Text { text } => Ok(ContentBlock::Text { text: text.clone() }), + Self::Reasoning { text, signature } => Ok(ContentBlock::Reasoning { + text: text.clone(), + signature: signature.clone(), + }), + Self::Image { source } => Ok(ContentBlock::Image { + source: source.borrow(py).clone_core(py)?, + }), + Self::Audio { source } => Ok(ContentBlock::Audio { + source: source.borrow(py).clone_core(py)?, + }), + Self::Video { source } => Ok(ContentBlock::Video { + source: source.borrow(py).clone_core(py)?, + }), + Self::File { source } => Ok(ContentBlock::File { + source: source.borrow(py).clone_core(py)?, + }), + Self::ToolCall { tool_call } => { + Ok(ContentBlock::ToolCall(tool_call.borrow(py).clone_core())) + } + Self::ToolResult { tool_result } => Ok(ContentBlock::ToolResult( + tool_result.borrow(py).clone_core(), + )), + Self::Refusal { text } => Ok(ContentBlock::Refusal { text: text.clone() }), + Self::Unknown { provider, raw } => Ok(ContentBlock::Unknown { + provider: provider.borrow(py).clone_core(), + raw: value_from_python(raw.bind(py))?, + }), + } + } +} + +fn content_blocks_to_core( + py: Python<'_>, + content: Vec>, +) -> PyResult> { + content + .into_iter() + .map(|block| block.borrow(py).clone_core(py)) + .collect() +} + +fn content_blocks_from_core( + py: Python<'_>, + content: Vec, +) -> PyResult>> { + content + .into_iter() + .map(|block| { + PyContentBlock::from_core(py, block)? + .into_pyobject(py) + .map(|value| value.unbind().into_any()) + }) + .collect() +} + +#[pyclass( + name = "InstructionBlock", + module = "switchyard.libsy.protocol", + frozen, + skip_from_py_object +)] +#[derive(Clone)] +pub(crate) struct PyInstructionBlock { + inner: InstructionBlock, +} + +impl PyInstructionBlock { + fn from_core(inner: InstructionBlock) -> Self { + Self { inner } + } +} + +#[pymethods] +impl PyInstructionBlock { + #[new] + fn new(py: Python<'_>, role: PyRole, content: Vec>) -> PyResult { + Ok(Self::from_core(InstructionBlock { + role: role.into(), + content: content_blocks_to_core(py, content)?, + })) + } + + #[getter] + fn role(&self) -> PyRole { + self.inner.role.into() + } + + #[getter] + fn content(&self, py: Python<'_>) -> PyResult>> { + content_blocks_from_core(py, self.inner.content.clone()) + } + + fn to_dict(&self, py: Python<'_>) -> PyResult> { + serialized_to_python(py, &self.inner) + } +} + +#[pyclass( + name = "Message", + module = "switchyard.libsy.protocol", + frozen, + skip_from_py_object +)] +#[derive(Clone)] +pub(crate) struct PyMessage { + inner: Message, +} + +impl PyMessage { + fn from_core(inner: Message) -> Self { + Self { inner } + } +} + +#[pymethods] +impl PyMessage { + #[new] + fn new(py: Python<'_>, role: PyRole, content: Vec>) -> PyResult { + Ok(Self::from_core(Message { + role: role.into(), + content: content_blocks_to_core(py, content)?, + })) + } + + #[getter] + fn role(&self) -> PyRole { + self.inner.role.into() + } + + #[getter] + fn content(&self, py: Python<'_>) -> PyResult>> { + content_blocks_from_core(py, self.inner.content.clone()) + } + + fn to_dict(&self, py: Python<'_>) -> PyResult> { + serialized_to_python(py, &self.inner) + } +} + +#[pyclass( + name = "ToolDefinition", + module = "switchyard.libsy.protocol", + frozen, + skip_from_py_object +)] +#[derive(Clone)] +pub(crate) struct PyToolDefinition { + inner: ToolDefinition, +} + +impl PyToolDefinition { + fn from_core(inner: ToolDefinition) -> Self { + Self { inner } + } +} + +#[pymethods] +impl PyToolDefinition { + #[new] + #[pyo3(signature = (name, parameters, *, description=None, strict=None))] + fn new( + name: String, + parameters: &Bound<'_, PyAny>, + description: Option, + strict: Option, + ) -> PyResult { + Ok(Self::from_core(ToolDefinition { + name, + description, + parameters: value_from_python(parameters)?, + strict, + })) + } + + #[getter] + fn name(&self) -> &str { + &self.inner.name + } + + #[getter] + fn description(&self) -> Option { + self.inner.description.clone() + } + + #[getter] + fn parameters(&self, py: Python<'_>) -> PyResult> { + value_to_python(py, &self.inner.parameters) + } + + #[getter] + fn strict(&self) -> Option { + self.inner.strict + } + + fn to_dict(&self, py: Python<'_>) -> PyResult> { + serialized_to_python(py, &self.inner) + } +} + +#[pyclass( + name = "ToolChoice", + module = "switchyard.libsy.protocol", + frozen, + skip_from_py_object +)] +#[derive(Clone)] +pub(crate) struct PyToolChoice { + inner: ToolChoice, +} + +impl PyToolChoice { + fn from_core(inner: ToolChoice) -> Self { + Self { inner } + } +} + +#[pymethods] +impl PyToolChoice { + #[classmethod] + fn auto(_cls: &Bound<'_, PyType>) -> Self { + Self::from_core(ToolChoice::Auto) + } + + #[classmethod] + fn required(_cls: &Bound<'_, PyType>) -> Self { + Self::from_core(ToolChoice::Required) + } + + #[classmethod] + fn none(_cls: &Bound<'_, PyType>) -> Self { + Self::from_core(ToolChoice::None) + } + + #[classmethod] + fn tool(_cls: &Bound<'_, PyType>, name: String) -> Self { + Self::from_core(ToolChoice::Tool { name }) + } + + #[classmethod] + fn raw(_cls: &Bound<'_, PyType>, value: &Bound<'_, PyAny>) -> PyResult { + Ok(Self::from_core(ToolChoice::Raw(value_from_python(value)?))) + } + + #[getter] + fn kind(&self) -> &'static str { + match self.inner { + ToolChoice::Auto => "auto", + ToolChoice::Required => "required", + ToolChoice::None => "none", + ToolChoice::Tool { .. } => "tool", + ToolChoice::Raw(_) => "raw", + } + } + + #[getter] + fn name(&self) -> Option { + match &self.inner { + ToolChoice::Tool { name } => Some(name.clone()), + _ => None, + } + } + + #[getter] + fn raw_value(&self, py: Python<'_>) -> PyResult> { + match &self.inner { + ToolChoice::Raw(value) => value_to_python(py, value), + _ => Ok(py.None()), + } + } + + fn to_dict(&self, py: Python<'_>) -> PyResult> { + serialized_to_python(py, &self.inner) + } +} + +#[pyclass( + name = "SamplingParams", + module = "switchyard.libsy.protocol", + frozen, + skip_from_py_object +)] +#[derive(Clone, Default)] +pub(crate) struct PySamplingParams { + inner: SamplingParams, +} + +impl PySamplingParams { + fn from_core(inner: SamplingParams) -> Self { + Self { inner } + } +} + +#[pymethods] +impl PySamplingParams { + #[new] + #[pyo3(signature = (*, temperature=None, top_p=None, top_k=None))] + fn new(temperature: Option, top_p: Option, top_k: Option) -> Self { + Self::from_core(SamplingParams { + temperature, + top_p, + top_k, + }) + } + + #[getter] + fn temperature(&self) -> Option { + self.inner.temperature + } + + #[getter] + fn top_p(&self) -> Option { + self.inner.top_p + } + + #[getter] + fn top_k(&self) -> Option { + self.inner.top_k + } + + fn to_dict(&self, py: Python<'_>) -> PyResult> { + serialized_to_python(py, &self.inner) + } +} + +#[pyclass( + name = "OutputParams", + module = "switchyard.libsy.protocol", + frozen, + skip_from_py_object +)] +#[derive(Clone, Default)] +pub(crate) struct PyOutputParams { + inner: OutputParams, +} + +impl PyOutputParams { + fn from_core(inner: OutputParams) -> Self { + Self { inner } + } +} + +#[pymethods] +impl PyOutputParams { + #[new] + #[pyo3(signature = (*, max_output_tokens=None, response_format=None))] + fn new( + max_output_tokens: Option, + response_format: Option<&Bound<'_, PyAny>>, + ) -> PyResult { + Ok(Self::from_core(OutputParams { + max_output_tokens, + response_format: optional_json(response_format)?, + })) + } + + #[getter] + fn max_output_tokens(&self) -> Option { + self.inner.max_output_tokens + } + + #[getter] + fn response_format(&self, py: Python<'_>) -> PyResult> { + json_or_null(py, self.inner.response_format.as_ref()) + } + + fn to_dict(&self, py: Python<'_>) -> PyResult> { + serialized_to_python(py, &self.inner) + } +} + +#[pyclass( + name = "ReasoningParams", + module = "switchyard.libsy.protocol", + frozen, + skip_from_py_object +)] +#[derive(Clone, Default)] +pub(crate) struct PyReasoningParams { + inner: ReasoningParams, +} + +impl PyReasoningParams { + fn from_core(inner: ReasoningParams) -> Self { + Self { inner } + } +} + +#[pymethods] +impl PyReasoningParams { + #[new] + #[pyo3(signature = (*, effort=None, raw=None))] + fn new(effort: Option, raw: Option<&Bound<'_, PyAny>>) -> PyResult { + Ok(Self::from_core(ReasoningParams { + effort, + raw: optional_json(raw)?, + })) + } + + #[getter] + fn effort(&self) -> Option { + self.inner.effort.clone() + } + + #[getter] + fn raw(&self, py: Python<'_>) -> PyResult> { + json_or_null(py, self.inner.raw.as_ref()) + } + + fn to_dict(&self, py: Python<'_>) -> PyResult> { + serialized_to_python(py, &self.inner) + } +} + +#[pyclass( + name = "ProviderExtensions", + module = "switchyard.libsy.protocol", + frozen, + skip_from_py_object +)] +#[derive(Clone, Default)] +pub(crate) struct PyProviderExtensions { + inner: ProviderExtensions, +} + +impl PyProviderExtensions { + fn from_core(inner: ProviderExtensions) -> Self { + Self { inner } + } +} + +#[pymethods] +impl PyProviderExtensions { + #[new] + #[pyo3(signature = (fields=None))] + fn new(fields: Option<&Bound<'_, PyAny>>) -> PyResult { + let fields = match optional_json(fields)? { + None => Map::new(), + Some(Value::Object(fields)) => fields, + Some(_) => { + return Err(pyo3::exceptions::PyValueError::new_err( + "fields must be a mapping", + )) + } + }; + Ok(Self::from_core(ProviderExtensions { fields })) + } + + #[getter] + fn fields(&self, py: Python<'_>) -> PyResult> { + value_to_python(py, &Value::Object(self.inner.fields.clone())) + } + + fn to_dict(&self, py: Python<'_>) -> PyResult> { + serialized_to_python(py, &self.inner) + } +} + +#[pyclass( + name = "PreservationMetadata", + module = "switchyard.libsy.protocol", + frozen, + skip_from_py_object +)] +#[derive(Clone, Default)] +pub(crate) struct PyPreservationMetadata { + inner: PreservationMetadata, +} + +impl PyPreservationMetadata { + fn from_core(inner: PreservationMetadata) -> Self { + Self { inner } + } +} + +#[pyclass( + name = "LlmRequest", + module = "switchyard.libsy.protocol", + frozen, + skip_from_py_object +)] +#[derive(Clone)] +pub(crate) struct PyLlmRequest { + inner: LlmRequest, +} + +impl PyLlmRequest { + pub(crate) fn from_core(inner: LlmRequest) -> Self { + Self { inner } + } + + pub(crate) fn clone_core(&self) -> LlmRequest { + self.inner.clone() + } +} + +#[pymethods] +impl PyLlmRequest { + #[new] + #[pyo3(signature = (*, model=None, instructions=None, messages=None, tools=None, tool_choice=None, sampling=None, output=None, reasoning=None, stream=false, extensions=None, preservation=None))] + #[allow(clippy::too_many_arguments)] + fn new( + py: Python<'_>, + model: Option, + instructions: Option>>, + messages: Option>>, + tools: Option>>, + tool_choice: Option>, + sampling: Option>, + output: Option>, + reasoning: Option>, + stream: bool, + extensions: Option>, + preservation: Option>, + ) -> Self { + Self::from_core(LlmRequest { + model, + instructions: instructions + .unwrap_or_default() + .into_iter() + .map(|item| item.borrow(py).inner.clone()) + .collect(), + messages: messages + .unwrap_or_default() + .into_iter() + .map(|item| item.borrow(py).inner.clone()) + .collect(), + tools: tools + .unwrap_or_default() + .into_iter() + .map(|item| item.borrow(py).inner.clone()) + .collect(), + tool_choice: tool_choice.map(|value| value.inner.clone()), + sampling: sampling + .map(|value| value.inner.clone()) + .unwrap_or_default(), + output: output.map(|value| value.inner.clone()).unwrap_or_default(), + reasoning: reasoning + .map(|value| value.inner.clone()) + .unwrap_or_default(), + stream, + extensions: extensions + .map(|value| value.inner.clone()) + .unwrap_or_default(), + preservation: preservation + .map(|value| value.inner.clone()) + .unwrap_or_default(), + }) + } + + #[getter] + fn model(&self) -> Option { + self.inner.model.clone() + } + + #[getter] + fn instructions(&self, py: Python<'_>) -> PyResult>> { + self.inner + .instructions + .iter() + .cloned() + .map(|value| Py::new(py, PyInstructionBlock::from_core(value))) + .collect() + } + + #[getter] + fn messages(&self, py: Python<'_>) -> PyResult>> { + self.inner + .messages + .iter() + .cloned() + .map(|value| Py::new(py, PyMessage::from_core(value))) + .collect() + } + + #[getter] + fn tools(&self, py: Python<'_>) -> PyResult>> { + self.inner + .tools + .iter() + .cloned() + .map(|value| Py::new(py, PyToolDefinition::from_core(value))) + .collect() + } + + #[getter] + fn tool_choice(&self, py: Python<'_>) -> PyResult>> { + self.inner + .tool_choice + .clone() + .map(|value| Py::new(py, PyToolChoice::from_core(value))) + .transpose() + } + + #[getter] + fn sampling(&self, py: Python<'_>) -> PyResult> { + Py::new(py, PySamplingParams::from_core(self.inner.sampling.clone())) + } + + #[getter] + fn output(&self, py: Python<'_>) -> PyResult> { + Py::new(py, PyOutputParams::from_core(self.inner.output.clone())) + } + + #[getter] + fn reasoning(&self, py: Python<'_>) -> PyResult> { + Py::new( + py, + PyReasoningParams::from_core(self.inner.reasoning.clone()), + ) + } + + #[getter] + fn stream(&self) -> bool { + self.inner.stream + } + + #[getter] + fn extensions(&self, py: Python<'_>) -> PyResult> { + Py::new( + py, + PyProviderExtensions::from_core(self.inner.extensions.clone()), + ) + } + + #[getter] + fn preservation(&self, py: Python<'_>) -> PyResult> { + Py::new( + py, + PyPreservationMetadata::from_core(self.inner.preservation.clone()), + ) + } + + fn to_dict(&self, py: Python<'_>) -> PyResult> { + serialized_to_python(py, &self.inner) + } + + fn __repr__(&self) -> String { + format!("LlmRequest(model={:?})", self.inner.model) + } +} + +#[pyclass( + name = "Usage", + module = "switchyard.libsy.protocol", + frozen, + skip_from_py_object +)] +#[derive(Clone, Default)] +pub(crate) struct PyUsage { + inner: Usage, +} + +impl PyUsage { + fn from_core(inner: Usage) -> Self { + Self { inner } + } +} + +#[pymethods] +impl PyUsage { + #[new] + #[pyo3(signature = (*, input_tokens=None, output_tokens=None, total_tokens=None, reasoning_tokens=None))] + fn new( + input_tokens: Option, + output_tokens: Option, + total_tokens: Option, + reasoning_tokens: Option, + ) -> Self { + Self::from_core(Usage { + input_tokens, + output_tokens, + total_tokens, + reasoning_tokens, + }) + } + + #[getter] + fn input_tokens(&self) -> Option { + self.inner.input_tokens + } + + #[getter] + fn output_tokens(&self) -> Option { + self.inner.output_tokens + } + + #[getter] + fn total_tokens(&self) -> Option { + self.inner.total_tokens + } + + #[getter] + fn reasoning_tokens(&self) -> Option { + self.inner.reasoning_tokens + } + + fn to_dict(&self, py: Python<'_>) -> PyResult> { + serialized_to_python(py, &self.inner) + } +} + +#[pyclass( + name = "ResponseOutput", + module = "switchyard.libsy.protocol", + frozen, + skip_from_py_object +)] +#[derive(Clone)] +pub(crate) struct PyResponseOutput { + inner: ResponseOutput, +} + +impl PyResponseOutput { + fn from_core(inner: ResponseOutput) -> Self { + Self { inner } + } +} + +#[pymethods] +impl PyResponseOutput { + #[new] + #[pyo3(signature = (role, content, *, stop_reason=None))] + fn new( + py: Python<'_>, + role: PyRole, + content: Vec>, + stop_reason: Option, + ) -> PyResult { + Ok(Self::from_core(ResponseOutput { + role: role.into(), + content: content_blocks_to_core(py, content)?, + stop_reason: stop_reason.map(Into::into), + })) + } + + #[getter] + fn role(&self) -> PyRole { + self.inner.role.into() + } + + #[getter] + fn content(&self, py: Python<'_>) -> PyResult>> { + content_blocks_from_core(py, self.inner.content.clone()) + } + + #[getter] + fn stop_reason(&self) -> Option { + self.inner.stop_reason.map(Into::into) + } + + fn to_dict(&self, py: Python<'_>) -> PyResult> { + serialized_to_python(py, &self.inner) + } +} + +#[pyclass( + name = "AggLlmResponse", + module = "switchyard.libsy.protocol", + frozen, + skip_from_py_object +)] +#[derive(Clone)] +pub(crate) struct PyAggLlmResponse { + inner: AggLlmResponse, +} + +impl PyAggLlmResponse { + pub(crate) fn from_core(inner: AggLlmResponse) -> Self { + Self { inner } + } + + pub(crate) fn clone_core(&self) -> AggLlmResponse { + self.inner.clone() + } +} + +#[pymethods] +impl PyAggLlmResponse { + #[new] + #[pyo3(signature = (*, id=None, model=None, outputs=None, usage=None, extensions=None, preservation=None))] + fn new( + py: Python<'_>, + id: Option, + model: Option, + outputs: Option>>, + usage: Option>, + extensions: Option>, + preservation: Option>, + ) -> Self { + Self::from_core(AggLlmResponse { + id, + model, + outputs: outputs + .unwrap_or_default() + .into_iter() + .map(|value| value.borrow(py).inner.clone()) + .collect(), + usage: usage.map(|value| value.inner.clone()).unwrap_or_default(), + extensions: extensions + .map(|value| value.inner.clone()) + .unwrap_or_default(), + preservation: preservation + .map(|value| value.inner.clone()) + .unwrap_or_default(), + }) + } + + #[getter] + fn id(&self) -> Option { + self.inner.id.clone() + } + + #[getter] + fn model(&self) -> Option { + self.inner.model.clone() + } + + #[getter] + fn outputs(&self, py: Python<'_>) -> PyResult>> { + self.inner + .outputs + .iter() + .cloned() + .map(|value| Py::new(py, PyResponseOutput::from_core(value))) + .collect() + } + + #[getter] + fn usage(&self, py: Python<'_>) -> PyResult> { + Py::new(py, PyUsage::from_core(self.inner.usage.clone())) + } + + #[getter] + fn extensions(&self, py: Python<'_>) -> PyResult> { + Py::new( + py, + PyProviderExtensions::from_core(self.inner.extensions.clone()), + ) + } + + #[getter] + fn preservation(&self, py: Python<'_>) -> PyResult> { + Py::new( + py, + PyPreservationMetadata::from_core(self.inner.preservation.clone()), + ) + } + + fn to_dict(&self, py: Python<'_>) -> PyResult> { + serialized_to_python(py, &self.inner) + } + + fn __repr__(&self) -> String { + format!("AggLlmResponse(model={:?})", self.inner.model) + } +} + +#[pyclass( + name = "LlmResponseChunk", + module = "switchyard.libsy.protocol", + frozen +)] +pub(crate) enum PyLlmResponseChunk { + MessageStart { + id: Option, + model: Option, + }, + TextDelta { + index: usize, + text: String, + }, + ReasoningDelta { + index: usize, + text: String, + }, + ToolCallDelta { + index: usize, + id: Option, + name: Option, + arguments_delta: Option, + }, + #[pyo3(name = "UsageUpdate")] + Usage { + usage: Py, + }, + MessageStop { + reason: Option, + }, + Error { + message: String, + }, +} + +impl PyLlmResponseChunk { + pub(crate) fn from_core(py: Python<'_>, inner: LlmResponseChunk) -> PyResult { + match inner { + LlmResponseChunk::MessageStart { id, model } => Ok(Self::MessageStart { id, model }), + LlmResponseChunk::TextDelta { index, text } => Ok(Self::TextDelta { index, text }), + LlmResponseChunk::ReasoningDelta { index, text } => { + Ok(Self::ReasoningDelta { index, text }) + } + LlmResponseChunk::ToolCallDelta { + index, + id, + name, + arguments_delta, + } => Ok(Self::ToolCallDelta { + index, + id, + name, + arguments_delta, + }), + LlmResponseChunk::Usage(usage) => Ok(Self::Usage { + usage: Py::new(py, PyUsage::from_core(usage))?, + }), + LlmResponseChunk::MessageStop { reason } => Ok(Self::MessageStop { reason }), + LlmResponseChunk::Error { message } => Ok(Self::Error { message }), + } + } + + pub(crate) fn clone_core(&self, py: Python<'_>) -> LlmResponseChunk { + match self { + Self::MessageStart { id, model } => LlmResponseChunk::MessageStart { + id: id.clone(), + model: model.clone(), + }, + Self::TextDelta { index, text } => LlmResponseChunk::TextDelta { + index: *index, + text: text.clone(), + }, + Self::ReasoningDelta { index, text } => LlmResponseChunk::ReasoningDelta { + index: *index, + text: text.clone(), + }, + Self::ToolCallDelta { + index, + id, + name, + arguments_delta, + } => LlmResponseChunk::ToolCallDelta { + index: *index, + id: id.clone(), + name: name.clone(), + arguments_delta: arguments_delta.clone(), + }, + Self::Usage { usage } => LlmResponseChunk::Usage(usage.borrow(py).inner.clone()), + Self::MessageStop { reason } => LlmResponseChunk::MessageStop { + reason: reason.clone(), + }, + Self::Error { message } => LlmResponseChunk::Error { + message: message.clone(), + }, + } + } +} + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + Ok(()) +} + +fn preservation_map( + value: Option<&Bound<'_, PyAny>>, + name: &str, +) -> PyResult> { + let Some(value) = optional_json(value)? else { + return Ok(BTreeMap::new()); + }; + let Value::Object(value) = value else { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "{name} must be a mapping" + ))); + }; + Ok(value + .into_iter() + .map(|(key, value)| (FormatId::new(key), value)) + .collect()) +} + +fn preservation_map_to_value(value: &BTreeMap) -> Value { + Value::Object( + value + .iter() + .map(|(key, value)| (key.as_str().to_string(), value.clone())) + .collect(), + ) +} + +#[pymethods] +impl PyPreservationMetadata { + #[new] + #[pyo3(signature = (*, requests=None, responses=None))] + fn new( + requests: Option<&Bound<'_, PyAny>>, + responses: Option<&Bound<'_, PyAny>>, + ) -> PyResult { + Ok(Self::from_core(PreservationMetadata { + requests: preservation_map(requests, "requests")?, + responses: preservation_map(responses, "responses")?, + })) + } + + #[getter] + fn requests(&self, py: Python<'_>) -> PyResult> { + value_to_python(py, &preservation_map_to_value(&self.inner.requests)) + } + + #[getter] + fn responses(&self, py: Python<'_>) -> PyResult> { + value_to_python(py, &preservation_map_to_value(&self.inner.responses)) + } + + fn to_dict(&self, py: Python<'_>) -> PyResult> { + serialized_to_python(py, &self.inner) + } +} diff --git a/crates/switchyard-py/src/libsy_bindings/values.rs b/crates/switchyard-py/src/libsy_bindings/values.rs new file mode 100644 index 00000000..b671717f --- /dev/null +++ b/crates/switchyard-py/src/libsy_bindings/values.rs @@ -0,0 +1,654 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Neutral libsy values and response-stream adapters. + +use std::collections::{BTreeMap, HashMap}; +use std::error::Error; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::task::{Context as TaskContext, Poll}; + +use futures_util::{stream, Stream, StreamExt}; +use pyo3::exceptions::{PyAttributeError, PyRuntimeError, PyStopAsyncIteration, PyValueError}; +use pyo3::prelude::*; +use pyo3::types::PyType; +use serde::Serialize; +use serde_json::{Map, Value}; +use switchyard_protocol::{ + Context, LlmResponse, LlmResponseChunk, LlmResponseStream, Metadata, Request, +}; + +use crate::errors::py_libsy_error; +use crate::py_serde::{value_from_python, value_to_python}; + +use super::protocol::{PyAggLlmResponse, PyLlmRequest, PyLlmResponseChunk, PyWireFormat}; + +type BoxError = Box; + +#[pyclass( + name = "Context", + module = "switchyard.libsy.protocol", + frozen, + skip_from_py_object +)] +#[derive(Clone, Default)] +pub(crate) struct PyContext { + inner: Context, +} + +#[pymethods] +impl PyContext { + #[new] + #[pyo3(signature = (*, values=None))] + fn new(values: Option>) -> Self { + Self { + inner: Context { + values: values.unwrap_or_default(), + }, + } + } + + #[getter] + fn values(&self) -> HashMap { + self.inner.values.clone() + } + + fn __repr__(&self) -> String { + format!("Context(values={:?})", self.inner.values) + } +} + +#[pyclass( + name = "Metadata", + module = "switchyard.libsy.protocol", + frozen, + skip_from_py_object +)] +#[derive(Clone)] +pub(crate) struct PyMetadata { + inner: Metadata, +} + +impl PyMetadata { + fn from_core(inner: Metadata) -> Self { + Self { inner } + } + + fn clone_core(&self) -> Metadata { + self.inner.clone() + } +} + +#[pymethods] +impl PyMetadata { + #[new] + #[pyo3(signature = (*, session_id=None, agent_id=None, task_id=None, correlation_id=None, extra_metadata=None, http_headers=None, wire_format=None))] + fn new( + session_id: Option, + agent_id: Option, + task_id: Option, + correlation_id: Option, + extra_metadata: Option>, + http_headers: Option>, + wire_format: Option, + ) -> Self { + Self { + inner: Metadata { + session_id, + agent_id, + task_id, + correlation_id, + extra_metadata, + http_headers, + wire_format: wire_format.map(Into::into), + }, + } + } + + #[getter] + fn session_id(&self) -> Option { + self.inner.session_id.clone() + } + + #[getter] + fn agent_id(&self) -> Option { + self.inner.agent_id.clone() + } + + #[getter] + fn task_id(&self) -> Option { + self.inner.task_id.clone() + } + + #[getter] + fn correlation_id(&self) -> Option { + self.inner.correlation_id.clone() + } + + #[getter] + fn extra_metadata(&self) -> Option> { + self.inner.extra_metadata.clone() + } + + #[getter] + fn http_headers(&self) -> Option> { + self.inner.http_headers.clone() + } + + #[getter] + fn wire_format(&self) -> Option { + self.inner.wire_format.map(Into::into) + } + + fn to_dict(&self, py: Python<'_>) -> PyResult> { + value_to_python(py, &metadata_to_value(&self.inner)) + } + + fn __repr__(&self) -> String { + format!( + "Metadata(session_id={:?}, correlation_id={:?})", + self.inner.session_id, self.inner.correlation_id + ) + } +} + +#[pyclass( + name = "Request", + module = "switchyard.libsy.protocol", + frozen, + skip_from_py_object +)] +#[derive(Clone)] +pub(crate) struct PyRequest { + inner: Request, +} + +#[pymethods] +impl PyRequest { + #[new] + #[pyo3(signature = (llm_request, *, raw_request=None, metadata=None))] + fn new( + llm_request: PyRef<'_, PyLlmRequest>, + raw_request: Option<&Bound<'_, PyAny>>, + metadata: Option>, + ) -> PyResult { + let raw_request = raw_request.map(value_from_python).transpose()?; + Ok(Self { + inner: Request { + llm_request: llm_request.clone_core(), + raw_request, + metadata: metadata.map(|value| value.clone_core()), + }, + }) + } + + #[getter] + fn llm_request(&self, py: Python<'_>) -> PyResult> { + Py::new(py, PyLlmRequest::from_core(self.inner.llm_request.clone())) + } + + #[getter] + fn raw_request(&self, py: Python<'_>) -> PyResult> { + match &self.inner.raw_request { + Some(value) => value_to_python(py, value), + None => Ok(py.None()), + } + } + + #[getter] + fn metadata(&self, py: Python<'_>) -> PyResult>> { + self.inner + .metadata + .clone() + .map(|metadata| Py::new(py, PyMetadata::from_core(metadata))) + .transpose() + } + + #[getter] + fn requested_model(&self) -> Option { + self.inner.requested_model().map(str::to_owned) + } + + fn to_dict(&self, py: Python<'_>) -> PyResult> { + let mut object = Map::new(); + object.insert( + "llm_request".to_string(), + serde_json::to_value(&self.inner.llm_request) + .map_err(|error| PyValueError::new_err(error.to_string()))?, + ); + object.insert( + "raw_request".to_string(), + self.inner.raw_request.clone().unwrap_or(Value::Null), + ); + object.insert( + "metadata".to_string(), + self.inner + .metadata + .as_ref() + .map(metadata_to_value) + .unwrap_or(Value::Null), + ); + value_to_python(py, &Value::Object(object)) + } + + fn __repr__(&self) -> String { + format!("Request(model={:?})", self.inner.requested_model()) + } +} + +#[pyclass(name = "Response", module = "switchyard.libsy.protocol")] +pub(crate) struct PyResponse { + llm_response: Mutex>, + metadata: Option, +} + +#[pymethods] +impl PyResponse { + #[new] + #[pyo3(signature = (llm_response, *, metadata=None))] + fn new( + llm_response: PyRef<'_, PyAggLlmResponse>, + metadata: Option>, + ) -> PyResult { + Ok(Self { + llm_response: Mutex::new(Some(LlmResponse::Agg(llm_response.clone_core()))), + metadata: metadata.map(|value| value.clone_core()), + }) + } + + #[classmethod] + #[pyo3(signature = (source, *, metadata=None))] + fn from_stream( + _cls: &Bound<'_, PyType>, + source: &Bound<'_, PyAny>, + metadata: Option>, + ) -> Self { + Self { + llm_response: Mutex::new(Some(LlmResponse::Stream(stream_from_python_source( + source.clone().unbind(), + )))), + metadata: metadata.map(|value| value.clone_core()), + } + } + + #[getter] + fn is_streaming(&self) -> PyResult { + let response = self + .llm_response + .lock() + .map_err(|_| PyRuntimeError::new_err("Response lock is poisoned"))?; + Ok(matches!(response.as_ref(), Some(LlmResponse::Stream(_)))) + } + + #[getter] + fn selected_model(&self) -> PyResult> { + let response = self + .llm_response + .lock() + .map_err(|_| PyRuntimeError::new_err("Response lock is poisoned"))?; + Ok(response + .as_ref() + .and_then(LlmResponse::selected_model) + .map(str::to_owned)) + } + + #[getter] + fn aggregate(&self, py: Python<'_>) -> PyResult> { + let response = self + .llm_response + .lock() + .map_err(|_| PyRuntimeError::new_err("Response lock is poisoned"))?; + match response.as_ref() { + Some(LlmResponse::Agg(aggregate)) => { + Py::new(py, PyAggLlmResponse::from_core(aggregate.clone())) + } + Some(LlmResponse::Stream(_)) => Err(PyAttributeError::new_err( + "streaming Response has no aggregate value", + )), + None => Err(PyRuntimeError::new_err( + "Response has already been consumed", + )), + } + } + + #[getter] + fn stream(&self, py: Python<'_>) -> PyResult> { + let mut response = self + .llm_response + .lock() + .map_err(|_| PyRuntimeError::new_err("Response lock is poisoned"))?; + let Some(current) = response.take() else { + return Err(PyRuntimeError::new_err( + "Response has already been consumed", + )); + }; + match current { + LlmResponse::Stream(stream) => Py::new(py, PyLlmResponseStream::new(stream)), + aggregate @ LlmResponse::Agg(_) => { + *response = Some(aggregate); + Err(PyAttributeError::new_err( + "aggregate Response has no stream", + )) + } + } + } + + #[getter] + fn metadata(&self, py: Python<'_>) -> PyResult>> { + self.metadata + .clone() + .map(|metadata| Py::new(py, PyMetadata::from_core(metadata))) + .transpose() + } + + fn to_dict(&self, py: Python<'_>) -> PyResult> { + let response = self + .llm_response + .lock() + .map_err(|_| PyRuntimeError::new_err("Response lock is poisoned"))?; + match response.as_ref() { + Some(LlmResponse::Agg(aggregate)) => serialized_to_python(py, aggregate), + Some(LlmResponse::Stream(_)) => Err(PyAttributeError::new_err( + "streaming Response has no aggregate value", + )), + None => Err(PyRuntimeError::new_err( + "Response has already been consumed", + )), + } + } + + fn __repr__(&self) -> PyResult { + let response = self + .llm_response + .lock() + .map_err(|_| PyRuntimeError::new_err("Response lock is poisoned"))?; + let kind = match response.as_ref() { + Some(LlmResponse::Agg(_)) => "aggregate", + Some(LlmResponse::Stream(_)) => "stream", + None => "consumed", + }; + Ok(format!("Response(kind='{kind}')")) + } +} + +#[pyclass(name = "LlmResponseStream", module = "switchyard.libsy.protocol")] +pub(crate) struct PyLlmResponseStream { + stream: Arc>>, + consumed: bool, +} + +impl PyLlmResponseStream { + fn new(stream: LlmResponseStream) -> Self { + Self { + stream: Arc::new(tokio::sync::Mutex::new(Some(stream))), + consumed: false, + } + } +} + +#[pymethods] +impl PyLlmResponseStream { + fn __aiter__(mut slf: PyRefMut<'_, Self>) -> PyResult> { + if slf.consumed { + return Err(PyRuntimeError::new_err( + "LlmResponseStream has already been consumed", + )); + } + slf.consumed = true; + let py = slf.py(); + Ok(slf.into_pyobject(py)?.unbind().into_any()) + } + + fn __anext__<'py>(&self, py: Python<'py>) -> PyResult> { + let stream = Arc::clone(&self.stream); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let mut guard = stream.lock().await; + let Some(inner) = guard.as_mut() else { + return Err(PyStopAsyncIteration::new_err(())); + }; + match inner.next().await { + Some(Ok(chunk)) => Python::attach(|py| { + PyLlmResponseChunk::from_core(py, chunk)? + .into_pyobject(py) + .map(|value| value.unbind().into_any()) + }), + Some(Err(error)) => { + guard.take(); + Err(py_libsy_error(error)) + } + None => { + guard.take(); + Err(PyStopAsyncIteration::new_err(())) + } + } + }) + } + + fn aclose<'py>(&mut self, py: Python<'py>) -> PyResult> { + self.consumed = true; + let stream = Arc::clone(&self.stream); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + stream.lock().await.take(); + Ok(()) + }) + } + + fn __repr__(&self) -> &'static str { + "LlmResponseStream()" + } +} + +struct PythonChunkState { + source: Py, + iterator: Option>, + done: bool, +} + +struct SourceClosingLlmStream { + stream: LlmResponseStream, + source: Option>, +} + +impl Stream for SourceClosingLlmStream { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll> { + let this = self.get_mut(); + let result = this.stream.as_mut().poll_next(cx); + if matches!(result, Poll::Ready(None) | Poll::Ready(Some(Err(_)))) { + if let Some(source) = this.source.take() { + schedule_stream_source_close(source); + } + } + result + } +} + +impl Drop for SourceClosingLlmStream { + fn drop(&mut self) { + if let Some(source) = self.source.take() { + schedule_stream_source_close(source); + } + } +} + +fn stream_from_python_source(source: Py) -> LlmResponseStream { + let retained = Python::attach(|py| source.clone_ref(py)); + let stream = stream::unfold( + PythonChunkState { + source, + iterator: None, + done: false, + }, + next_python_chunk, + ); + Box::pin(SourceClosingLlmStream { + stream: Box::pin(stream), + source: Some(retained), + }) +} + +async fn next_python_chunk( + mut state: PythonChunkState, +) -> Option<(Result, PythonChunkState)> { + if state.done { + return None; + } + + if state.iterator.is_none() { + match Python::attach(|py| { + state + .source + .bind(py) + .call_method0("__aiter__") + .map(Bound::unbind) + }) { + Ok(iterator) => state.iterator = Some(iterator), + Err(error) => { + state.done = true; + return Some((Err(python_error(error)), state)); + } + } + } + + let Some(iterator) = state.iterator.as_ref() else { + state.done = true; + return Some((Err("async iterator initialization failed".into()), state)); + }; + let future = match Python::attach(|py| { + let awaitable = iterator.bind(py).call_method0("__anext__")?; + pyo3_async_runtimes::tokio::into_future(awaitable) + }) { + Ok(future) => future, + Err(error) => { + state.done = true; + return Some((Err(python_error(error)), state)); + } + }; + + match future.await { + Ok(value) => { + let result = + Python::attach(|py| chunk_from_python(value.bind(py))).map_err(python_error); + Some((result, state)) + } + Err(error) if is_stop_async_iteration(&error) => None, + Err(error) => { + state.done = true; + Some((Err(python_error(error)), state)) + } + } +} + +fn chunk_from_python(value: &Bound<'_, PyAny>) -> PyResult { + let chunk = value.extract::>()?; + Ok(chunk.clone_core(value.py())) +} + +pub(crate) fn python_error(error: PyErr) -> BoxError { + std::io::Error::other(error.to_string()).into() +} + +fn is_stop_async_iteration(error: &PyErr) -> bool { + Python::attach(|py| error.is_instance_of::(py)) +} + +fn schedule_stream_source_close(source: Py) { + let result = Python::attach(|py| { + let source = source.bind(py); + if source.hasattr("aclose")? { + let awaitable = source.call_method0("aclose")?; + pyo3_async_runtimes::tokio::into_future(awaitable).map(Some) + } else if source.hasattr("close")? { + source.call_method0("close")?; + Ok(None) + } else { + Ok(None) + } + }); + match result { + Ok(Some(future)) => { + if let Ok(runtime) = tokio::runtime::Handle::try_current() { + runtime.spawn(async move { + if let Err(error) = future.await { + tracing::warn!(error = %error, "libsy response stream close failed"); + } + }); + } + } + Ok(None) => {} + Err(error) => { + tracing::warn!(error = %error, "libsy response stream could not be closed"); + } + } +} + +fn serialized_to_python(py: Python<'_>, value: &T) -> PyResult> { + let value = + serde_json::to_value(value).map_err(|error| PyValueError::new_err(error.to_string()))?; + value_to_python(py, &value) +} + +fn metadata_to_value(metadata: &Metadata) -> Value { + let mut value = Map::new(); + value.insert( + "session_id".to_string(), + option_string_value(&metadata.session_id), + ); + value.insert( + "agent_id".to_string(), + option_string_value(&metadata.agent_id), + ); + value.insert( + "task_id".to_string(), + option_string_value(&metadata.task_id), + ); + value.insert( + "correlation_id".to_string(), + option_string_value(&metadata.correlation_id), + ); + value.insert( + "extra_metadata".to_string(), + string_map_value(&metadata.extra_metadata), + ); + value.insert( + "http_headers".to_string(), + string_map_value(&metadata.http_headers), + ); + value.insert( + "wire_format".to_string(), + metadata + .wire_format + .map(|format| Value::String(format.as_str().to_string())) + .unwrap_or(Value::Null), + ); + Value::Object(value) +} + +fn option_string_value(value: &Option) -> Value { + value.clone().map(Value::String).unwrap_or(Value::Null) +} + +fn string_map_value(value: &Option>) -> Value { + value + .as_ref() + .map(|items| { + Value::Object( + items + .iter() + .map(|(key, value)| (key.clone(), Value::String(value.clone()))) + .collect(), + ) + }) + .unwrap_or(Value::Null) +} + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + module.add_class::()?; + Ok(()) +} diff --git a/switchyard/__init__.py b/switchyard/__init__.py index ce9bbb55..c89ddde6 100644 --- a/switchyard/__init__.py +++ b/switchyard/__init__.py @@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any +from switchyard import libsy as libsy from switchyard.lib.backends import ( AnthropicNativeBackend, EndpointHealth, @@ -140,6 +141,7 @@ def __getattr__(name: str) -> Any: __all__ = [ + "libsy", # ChatRequest types "AnthropicChatRequest", "ChatRequest", diff --git a/switchyard/libsy/__init__.py b/switchyard/libsy/__init__.py new file mode 100644 index 00000000..1b394620 --- /dev/null +++ b/switchyard/libsy/__init__.py @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Public Python API for Rust-owned libsy functionality.""" + +from . import protocol as protocol + +__all__ = ["protocol"] diff --git a/switchyard/libsy/protocol.py b/switchyard/libsy/protocol.py new file mode 100644 index 00000000..cbca118e --- /dev/null +++ b/switchyard/libsy/protocol.py @@ -0,0 +1,98 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Typed Python bindings for the Rust-owned Switchyard protocol.""" + +from switchyard_rust.libsy_protocol import ( + AggLlmResponse, + ContentBlock, + Context, + FileSource, + FormatId, + ImageSource, + InstructionBlock, + LlmRequest, + LlmResponseChunk, + LlmResponseStream, + MediaSource, + Message, + Metadata, + OutputParams, + PreservationMetadata, + ProviderExtensions, + ReasoningParams, + Request, + Response, + ResponseOutput, + Role, + SamplingParams, + StopReason, + ToolCall, + ToolChoice, + ToolDefinition, + ToolResult, + Usage, + WireFormat, +) + +ContentBlockValue = ( + ContentBlock.Text + | ContentBlock.Reasoning + | ContentBlock.Image + | ContentBlock.Audio + | ContentBlock.Video + | ContentBlock.File + | ContentBlock.ToolCallBlock + | ContentBlock.ToolResultBlock + | ContentBlock.Refusal + | ContentBlock.Unknown +) +ImageSourceValue = ImageSource.Url | ImageSource.Base64 | ImageSource.Raw +FileSourceValue = FileSource.FileId | FileSource.FileData | FileSource.Raw +MediaSourceValue = MediaSource.Url | MediaSource.Base64 | MediaSource.Raw +LlmResponseChunkValue = ( + LlmResponseChunk.MessageStart + | LlmResponseChunk.TextDelta + | LlmResponseChunk.ReasoningDelta + | LlmResponseChunk.ToolCallDelta + | LlmResponseChunk.UsageUpdate + | LlmResponseChunk.MessageStop + | LlmResponseChunk.Error +) + +__all__ = [ + "AggLlmResponse", + "ContentBlock", + "ContentBlockValue", + "Context", + "FileSource", + "FileSourceValue", + "FormatId", + "ImageSource", + "ImageSourceValue", + "InstructionBlock", + "LlmRequest", + "LlmResponseChunk", + "LlmResponseChunkValue", + "LlmResponseStream", + "MediaSource", + "MediaSourceValue", + "Message", + "Metadata", + "OutputParams", + "PreservationMetadata", + "ProviderExtensions", + "ReasoningParams", + "Request", + "Response", + "ResponseOutput", + "Role", + "SamplingParams", + "StopReason", + "ToolCall", + "ToolChoice", + "ToolDefinition", + "ToolResult", + "Usage", + "WireFormat", +] diff --git a/switchyard_rust/libsy_protocol.py b/switchyard_rust/libsy_protocol.py new file mode 100644 index 00000000..876b2902 --- /dev/null +++ b/switchyard_rust/libsy_protocol.py @@ -0,0 +1,84 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Typed Rust-owned Switchyard protocol bindings.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from switchyard_rust.core import _load_native + +_EXPORTS = frozenset( + { + "AggLlmResponse", + "ContentBlock", + "Context", + "FileSource", + "FormatId", + "ImageSource", + "InstructionBlock", + "LlmRequest", + "LlmResponseChunk", + "LlmResponseStream", + "MediaSource", + "Message", + "Metadata", + "OutputParams", + "PreservationMetadata", + "ProviderExtensions", + "ReasoningParams", + "Request", + "Response", + "ResponseOutput", + "Role", + "SamplingParams", + "StopReason", + "ToolCall", + "ToolChoice", + "ToolDefinition", + "ToolResult", + "Usage", + "WireFormat", + } +) + +if TYPE_CHECKING: + AggLlmResponse: type[Any] + ContentBlock: type[Any] + Context: type[Any] + FileSource: type[Any] + FormatId: type[Any] + ImageSource: type[Any] + InstructionBlock: type[Any] + LlmRequest: type[Any] + LlmResponseChunk: type[Any] + LlmResponseStream: type[Any] + MediaSource: type[Any] + Message: type[Any] + Metadata: type[Any] + OutputParams: type[Any] + PreservationMetadata: type[Any] + ProviderExtensions: type[Any] + ReasoningParams: type[Any] + Request: type[Any] + Response: type[Any] + ResponseOutput: type[Any] + Role: type[Any] + SamplingParams: type[Any] + StopReason: type[Any] + ToolCall: type[Any] + ToolChoice: type[Any] + ToolDefinition: type[Any] + ToolResult: type[Any] + Usage: type[Any] + WireFormat: type[Any] + + +def __getattr__(name: str) -> object: + if name in _EXPORTS: + return getattr(_load_native().libsy_protocol, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +__all__ = sorted(_EXPORTS) diff --git a/switchyard_rust/libsy_protocol.pyi b/switchyard_rust/libsy_protocol.pyi new file mode 100644 index 00000000..94f780ae --- /dev/null +++ b/switchyard_rust/libsy_protocol.pyi @@ -0,0 +1,493 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +from collections.abc import AsyncIterator, Mapping, Sequence +from typing import Any, ClassVar, final + +class WireFormat: + OPENAI_CHAT: ClassVar[WireFormat] + ANTHROPIC_MESSAGES: ClassVar[WireFormat] + OPENAI_RESPONSES: ClassVar[WireFormat] + + value: str + +class FormatId: + value: str + + def __init__(self, value: str) -> None: ... + @classmethod + def known(cls, value: WireFormat) -> FormatId: ... + +class Role: + SYSTEM: ClassVar[Role] + DEVELOPER: ClassVar[Role] + USER: ClassVar[Role] + ASSISTANT: ClassVar[Role] + TOOL: ClassVar[Role] + + value: str + +class StopReason: + END_TURN: ClassVar[StopReason] + MAX_TOKENS: ClassVar[StopReason] + TOOL_USE: ClassVar[StopReason] + CONTENT_FILTER: ClassVar[StopReason] + ERROR: ClassVar[StopReason] + UNKNOWN: ClassVar[StopReason] + + value: str + +class ImageSource: + class Url: + url: str + detail: str | None + + def __init__(self, url: str, detail: str | None) -> None: ... + + class Base64: + media_type: str | None + data: str + + def __init__(self, media_type: str | None, data: str) -> None: ... + + class Raw: + value: Any + + def __init__(self, value: Any) -> None: ... + +ImageSourceValue = ImageSource.Url | ImageSource.Base64 | ImageSource.Raw + +class FileSource: + class FileId: + id: str + + def __init__(self, id: str) -> None: ... + + class FileData: + data: str + filename: str | None + + def __init__(self, data: str, filename: str | None) -> None: ... + + class Raw: + value: Any + + def __init__(self, value: Any) -> None: ... + +FileSourceValue = FileSource.FileId | FileSource.FileData | FileSource.Raw + +class MediaSource: + class Url: + url: str + media_type: str | None + + def __init__(self, url: str, media_type: str | None) -> None: ... + + class Base64: + media_type: str | None + data: str + + def __init__(self, media_type: str | None, data: str) -> None: ... + + class Raw: + value: Any + + def __init__(self, value: Any) -> None: ... + +MediaSourceValue = MediaSource.Url | MediaSource.Base64 | MediaSource.Raw + +class ToolCall: + id: str + name: str + arguments: Any + + def __init__(self, id: str, name: str, arguments: Any) -> None: ... + def to_dict(self) -> dict[str, Any]: ... + +class ContentBlock: + class Text: + text: str + + def __init__(self, text: str) -> None: ... + + class Reasoning: + text: str + signature: str | None + + def __init__(self, text: str, signature: str | None) -> None: ... + + class Image: + source: ImageSourceValue + + def __init__(self, source: ImageSourceValue) -> None: ... + + class Audio: + source: MediaSourceValue + + def __init__(self, source: MediaSourceValue) -> None: ... + + class Video: + source: MediaSourceValue + + def __init__(self, source: MediaSourceValue) -> None: ... + + class File: + source: FileSourceValue + + def __init__(self, source: FileSourceValue) -> None: ... + + class ToolCallBlock: + tool_call: ToolCall + + def __init__(self, tool_call: ToolCall) -> None: ... + + class ToolResultBlock: + tool_result: ToolResult + + def __init__(self, tool_result: ToolResult) -> None: ... + + class Refusal: + text: str + + def __init__(self, text: str) -> None: ... + + class Unknown: + provider: FormatId + raw: Any + + def __init__(self, provider: FormatId, raw: Any) -> None: ... + +ContentBlockValue = ( + ContentBlock.Text + | ContentBlock.Reasoning + | ContentBlock.Image + | ContentBlock.Audio + | ContentBlock.Video + | ContentBlock.File + | ContentBlock.ToolCallBlock + | ContentBlock.ToolResultBlock + | ContentBlock.Refusal + | ContentBlock.Unknown +) + +class ToolResult: + tool_call_id: str + content: list[ContentBlockValue] + is_error: bool | None + + def __init__( + self, + tool_call_id: str, + content: Sequence[ContentBlockValue], + *, + is_error: bool | None = None, + ) -> None: ... + def to_dict(self) -> dict[str, Any]: ... + +class InstructionBlock: + role: Role + content: list[ContentBlockValue] + + def __init__(self, role: Role, content: Sequence[ContentBlockValue]) -> None: ... + def to_dict(self) -> dict[str, Any]: ... + +class Message: + role: Role + content: list[ContentBlockValue] + + def __init__(self, role: Role, content: Sequence[ContentBlockValue]) -> None: ... + def to_dict(self) -> dict[str, Any]: ... + +class ToolDefinition: + name: str + description: str | None + parameters: Any + strict: bool | None + + def __init__( + self, + name: str, + parameters: Any, + *, + description: str | None = None, + strict: bool | None = None, + ) -> None: ... + def to_dict(self) -> dict[str, Any]: ... + +class ToolChoice: + kind: str + name: str | None + raw_value: Any | None + + @classmethod + def auto(cls) -> ToolChoice: ... + @classmethod + def required(cls) -> ToolChoice: ... + @classmethod + def none(cls) -> ToolChoice: ... + @classmethod + def tool(cls, name: str) -> ToolChoice: ... + @classmethod + def raw(cls, value: Any) -> ToolChoice: ... + def to_dict(self) -> Any: ... + +class SamplingParams: + temperature: float | None + top_p: float | None + top_k: int | None + + def __init__( + self, + *, + temperature: float | None = None, + top_p: float | None = None, + top_k: int | None = None, + ) -> None: ... + def to_dict(self) -> dict[str, Any]: ... + +class OutputParams: + max_output_tokens: int | None + response_format: Any | None + + def __init__( + self, + *, + max_output_tokens: int | None = None, + response_format: Any | None = None, + ) -> None: ... + def to_dict(self) -> dict[str, Any]: ... + +class ReasoningParams: + effort: str | None + raw: Any | None + + def __init__(self, *, effort: str | None = None, raw: Any | None = None) -> None: ... + def to_dict(self) -> dict[str, Any]: ... + +class ProviderExtensions: + fields: dict[str, Any] + + def __init__(self, fields: Mapping[str, Any] | None = None) -> None: ... + def to_dict(self) -> dict[str, Any]: ... + +class PreservationMetadata: + requests: dict[str, Any] + responses: dict[str, Any] + + def __init__( + self, + *, + requests: Mapping[str, Any] | None = None, + responses: Mapping[str, Any] | None = None, + ) -> None: ... + def to_dict(self) -> dict[str, Any]: ... + +class LlmRequest: + model: str | None + instructions: list[InstructionBlock] + messages: list[Message] + tools: list[ToolDefinition] + tool_choice: ToolChoice | None + sampling: SamplingParams + output: OutputParams + reasoning: ReasoningParams + stream: bool + extensions: ProviderExtensions + preservation: PreservationMetadata + + def __init__( + self, + *, + model: str | None = None, + instructions: Sequence[InstructionBlock] | None = None, + messages: Sequence[Message] | None = None, + tools: Sequence[ToolDefinition] | None = None, + tool_choice: ToolChoice | None = None, + sampling: SamplingParams | None = None, + output: OutputParams | None = None, + reasoning: ReasoningParams | None = None, + stream: bool = False, + extensions: ProviderExtensions | None = None, + preservation: PreservationMetadata | None = None, + ) -> None: ... + def to_dict(self) -> dict[str, Any]: ... + +class Usage: + input_tokens: int | None + output_tokens: int | None + total_tokens: int | None + reasoning_tokens: int | None + + def __init__( + self, + *, + input_tokens: int | None = None, + output_tokens: int | None = None, + total_tokens: int | None = None, + reasoning_tokens: int | None = None, + ) -> None: ... + def to_dict(self) -> dict[str, Any]: ... + +class ResponseOutput: + role: Role + content: list[ContentBlockValue] + stop_reason: StopReason | None + + def __init__( + self, + role: Role, + content: Sequence[ContentBlockValue], + *, + stop_reason: StopReason | None = None, + ) -> None: ... + def to_dict(self) -> dict[str, Any]: ... + +class AggLlmResponse: + id: str | None + model: str | None + outputs: list[ResponseOutput] + usage: Usage + extensions: ProviderExtensions + preservation: PreservationMetadata + + def __init__( + self, + *, + id: str | None = None, + model: str | None = None, + outputs: Sequence[ResponseOutput] | None = None, + usage: Usage | None = None, + extensions: ProviderExtensions | None = None, + preservation: PreservationMetadata | None = None, + ) -> None: ... + def to_dict(self) -> dict[str, Any]: ... + +class LlmResponseChunk: + class MessageStart: + id: str | None + model: str | None + + def __init__(self, id: str | None, model: str | None) -> None: ... + + class TextDelta: + index: int + text: str + + def __init__(self, index: int, text: str) -> None: ... + + class ReasoningDelta: + index: int + text: str + + def __init__(self, index: int, text: str) -> None: ... + + class ToolCallDelta: + index: int + id: str | None + name: str | None + arguments_delta: str | None + + def __init__( + self, + index: int, + id: str | None, + name: str | None, + arguments_delta: str | None, + ) -> None: ... + + class UsageUpdate: + usage: Usage + + def __init__(self, usage: Usage) -> None: ... + + class MessageStop: + reason: str | None + + def __init__(self, reason: str | None) -> None: ... + + class Error: + message: str + + def __init__(self, message: str) -> None: ... + +LlmResponseChunkValue = ( + LlmResponseChunk.MessageStart + | LlmResponseChunk.TextDelta + | LlmResponseChunk.ReasoningDelta + | LlmResponseChunk.ToolCallDelta + | LlmResponseChunk.UsageUpdate + | LlmResponseChunk.MessageStop + | LlmResponseChunk.Error +) + +class Context: + values: dict[str, str] + + def __init__(self, *, values: Mapping[str, str] | None = None) -> None: ... + +@final +class Metadata: + session_id: str | None + agent_id: str | None + task_id: str | None + correlation_id: str | None + extra_metadata: dict[str, str] | None + http_headers: dict[str, str] | None + wire_format: WireFormat | None + + def __init__( + self, + *, + session_id: str | None = None, + agent_id: str | None = None, + task_id: str | None = None, + correlation_id: str | None = None, + extra_metadata: Mapping[str, str] | None = None, + http_headers: Mapping[str, str] | None = None, + wire_format: WireFormat | None = None, + ) -> None: ... + def to_dict(self) -> dict[str, Any]: ... + +class Request: + llm_request: LlmRequest + raw_request: Any | None + metadata: Metadata | None + requested_model: str | None + + def __init__( + self, + llm_request: LlmRequest, + *, + raw_request: Any | None = None, + metadata: Metadata | None = None, + ) -> None: ... + def to_dict(self) -> dict[str, Any]: ... + +class LlmResponseStream(AsyncIterator[LlmResponseChunkValue]): + def __aiter__(self) -> LlmResponseStream: ... + async def __anext__(self) -> LlmResponseChunkValue: ... + async def aclose(self) -> None: ... + +class Response: + is_streaming: bool + selected_model: str | None + aggregate: AggLlmResponse + stream: LlmResponseStream + metadata: Metadata | None + + def __init__( + self, + llm_response: AggLlmResponse, + *, + metadata: Metadata | None = None, + ) -> None: ... + @classmethod + def from_stream( + cls, + source: AsyncIterator[LlmResponseChunkValue], + *, + metadata: Metadata | None = None, + ) -> Response: ... + def to_dict(self) -> dict[str, Any]: ... diff --git a/tests/test_libsy_protocol_bindings.py b/tests/test_libsy_protocol_bindings.py new file mode 100644 index 00000000..d7bef20c --- /dev/null +++ b/tests/test_libsy_protocol_bindings.py @@ -0,0 +1,241 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator + +import pytest + +import switchyard +from switchyard import libsy + + +def test_libsy_uses_an_explicit_namespace() -> None: + assert switchyard.libsy is libsy + assert libsy.protocol.__name__ == "switchyard.libsy.protocol" + assert not hasattr(libsy, "Algorithm") + assert not hasattr(libsy, "LlmTarget") + assert not hasattr(libsy, "RunStream") + + +def test_protocol_request_and_response_fields_stay_typed() -> None: + image: libsy.protocol.ImageSourceValue = libsy.protocol.ImageSource.Url( + "https://example.test/image.png", "high" + ) + tool_call = libsy.protocol.ToolCall("call-1", "lookup", {"query": "switchyard"}) + request = libsy.protocol.LlmRequest( + model="requested-model", + instructions=[ + libsy.protocol.InstructionBlock( + libsy.protocol.Role.DEVELOPER, + [libsy.protocol.ContentBlock.Text("route carefully")], + ) + ], + messages=[ + libsy.protocol.Message( + libsy.protocol.Role.USER, + [libsy.protocol.ContentBlock.Image(image)], + ), + libsy.protocol.Message( + libsy.protocol.Role.ASSISTANT, + [libsy.protocol.ContentBlock.ToolCallBlock(tool_call)], + ), + ], + tools=[ + libsy.protocol.ToolDefinition( + "lookup", + {"type": "object"}, + description="Look up a value", + strict=True, + ) + ], + tool_choice=libsy.protocol.ToolChoice.tool("lookup"), + sampling=libsy.protocol.SamplingParams(temperature=0.2, top_p=0.9, top_k=20), + output=libsy.protocol.OutputParams( + max_output_tokens=128, + response_format={"type": "json_object"}, + ), + reasoning=libsy.protocol.ReasoningParams(effort="medium", raw={"budget": 64}), + stream=True, + extensions=libsy.protocol.ProviderExtensions({"provider_flag": True}), + preservation=libsy.protocol.PreservationMetadata( + requests={"openai_chat": {"model": "requested-model"}} + ), + ) + + assert request.instructions[0].role == libsy.protocol.Role.DEVELOPER + assert request.tools[0].parameters == {"type": "object"} + assert request.tool_choice is not None + assert request.tool_choice.name == "lookup" + assert request.sampling.temperature == 0.2 + assert request.output.max_output_tokens == 128 + assert request.reasoning.effort == "medium" + assert request.extensions.fields == {"provider_flag": True} + assert request.preservation.requests == {"openai_chat": {"model": "requested-model"}} + match request.messages[0].content[0]: + case libsy.protocol.ContentBlock.Image( + source=libsy.protocol.ImageSource.Url( + url="https://example.test/image.png", detail="high" + ) + ): + pass + case block: + pytest.fail(f"unexpected image block: {block!r}") + match request.messages[1].content[0]: + case libsy.protocol.ContentBlock.ToolCallBlock(tool_call=round_tripped): + assert round_tripped.arguments == {"query": "switchyard"} + case block: + pytest.fail(f"unexpected tool call block: {block!r}") + + response = libsy.protocol.AggLlmResponse( + id="response-1", + model="selected-model", + outputs=[ + libsy.protocol.ResponseOutput( + libsy.protocol.Role.ASSISTANT, + [libsy.protocol.ContentBlock.Text("done")], + stop_reason=libsy.protocol.StopReason.END_TURN, + ) + ], + usage=libsy.protocol.Usage(input_tokens=4, output_tokens=1, total_tokens=5), + ) + + assert response.outputs[0].role == libsy.protocol.Role.ASSISTANT + assert response.outputs[0].stop_reason == libsy.protocol.StopReason.END_TURN + assert response.usage.total_tokens == 5 + + +def test_request_and_metadata_round_trip_as_python_values() -> None: + context = libsy.protocol.Context(values={"tenant": "test"}) + metadata = libsy.protocol.Metadata( + session_id="session-1", + correlation_id="correlation-1", + extra_metadata={"tenant": "test"}, + wire_format=libsy.protocol.WireFormat.OPENAI_CHAT, + ) + llm_request = libsy.protocol.LlmRequest( + model="inbound-model", + messages=[ + libsy.protocol.Message( + libsy.protocol.Role.USER, + [libsy.protocol.ContentBlock.Text("hello")], + ) + ], + ) + request = libsy.protocol.Request( + llm_request, + raw_request={"provider_field": True}, + metadata=metadata, + ) + + assert context.values == {"tenant": "test"} + assert request.requested_model == "inbound-model" + assert request.llm_request.model == "inbound-model" + assert request.llm_request.messages[0].role == libsy.protocol.Role.USER + match request.llm_request.messages[0].content[0]: + case libsy.protocol.ContentBlock.Text(text="hello"): + pass + case block: + pytest.fail(f"unexpected content block: {block!r}") + assert request.raw_request == {"provider_field": True} + assert request.metadata is not None + assert request.metadata.to_dict() == { + "agent_id": None, + "correlation_id": "correlation-1", + "extra_metadata": {"tenant": "test"}, + "http_headers": None, + "session_id": "session-1", + "task_id": None, + "wire_format": "openai_chat", + } + + +def test_response_chunks_are_typed_variants() -> None: + text = libsy.protocol.LlmResponseChunk.TextDelta(0, "hello") + usage = libsy.protocol.LlmResponseChunk.UsageUpdate( + libsy.protocol.Usage(input_tokens=3, output_tokens=2, total_tokens=5) + ) + + match text: + case libsy.protocol.LlmResponseChunk.TextDelta(index=0, text="hello"): + pass + case chunk: + pytest.fail(f"unexpected response chunk: {chunk!r}") + assert usage.usage.input_tokens == 3 + assert usage.usage.output_tokens == 2 + assert usage.usage.total_tokens == 5 + + +def test_aggregate_response_exposes_neutral_values() -> None: + response = libsy.protocol.Response(libsy.protocol.AggLlmResponse(model="selected-model")) + + assert response.is_streaming is False + assert response.selected_model == "selected-model" + assert response.aggregate.model == "selected-model" + with pytest.raises(AttributeError, match="aggregate Response has no stream"): + _ = response.stream + + +class _ChunkSource: + def __init__(self) -> None: + self._chunks: AsyncIterator[libsy.protocol.LlmResponseChunkValue] = self._iterate() + self.closed = False + + async def _iterate(self) -> AsyncIterator[libsy.protocol.LlmResponseChunkValue]: + yield libsy.protocol.LlmResponseChunk.MessageStart("response-1", "selected-model") + yield libsy.protocol.LlmResponseChunk.TextDelta(0, "hello") + yield libsy.protocol.LlmResponseChunk.MessageStop("end_turn") + + def __aiter__(self) -> _ChunkSource: + return self + + async def __anext__(self) -> libsy.protocol.LlmResponseChunkValue: + return await anext(self._chunks) + + async def aclose(self) -> None: + self.closed = True + await self._chunks.aclose() + + +async def test_python_response_stream_round_trips_and_closes_source() -> None: + source = _ChunkSource() + response = libsy.protocol.Response.from_stream(source) + + assert response.is_streaming is True + stream = response.stream + chunks = [chunk async for chunk in stream] + await asyncio.sleep(0) + + match chunks: + case [ + libsy.protocol.LlmResponseChunk.MessageStart(id="response-1", model="selected-model"), + libsy.protocol.LlmResponseChunk.TextDelta(index=0, text="hello"), + libsy.protocol.LlmResponseChunk.MessageStop(reason="end_turn"), + ]: + pass + case _: + pytest.fail(f"unexpected response chunks: {chunks!r}") + assert source.closed is True + with pytest.raises(RuntimeError, match="LlmResponseStream has already been consumed"): + async for _ in stream: + pass + + +async def test_response_stream_aclose_releases_source() -> None: + source = _ChunkSource() + stream = libsy.protocol.Response.from_stream(source).stream + + await stream.aclose() + await asyncio.sleep(0) + + assert source.closed is True + + +async def test_streaming_response_can_only_release_its_stream_once() -> None: + response = libsy.protocol.Response.from_stream(_ChunkSource()) + + _ = response.stream + with pytest.raises(RuntimeError, match="Response has already been consumed"): + _ = response.stream From b4d1641287880e2c9d61caaf96dcef723ef6f127 Mon Sep 17 00:00:00 2001 From: nachiketb Date: Fri, 17 Jul 2026 11:12:17 -0700 Subject: [PATCH 2/2] feat(libsy): expose Python target bindings Signed-off-by: nachiketb --- crates/switchyard-py/src/libsy_bindings.rs | 6 +- .../src/libsy_bindings/target.rs | 116 ++++++++++++++++++ switchyard/libsy/__init__.py | 6 +- switchyard_rust/libsy.py | 30 +++++ switchyard_rust/libsy.pyi | 26 ++++ tests/test_libsy_protocol_bindings.py | 1 - tests/test_libsy_target_bindings.py | 39 ++++++ 7 files changed, 220 insertions(+), 4 deletions(-) create mode 100644 crates/switchyard-py/src/libsy_bindings/target.rs create mode 100644 switchyard_rust/libsy.py create mode 100644 switchyard_rust/libsy.pyi create mode 100644 tests/test_libsy_target_bindings.py diff --git a/crates/switchyard-py/src/libsy_bindings.rs b/crates/switchyard-py/src/libsy_bindings.rs index 87f8530a..bf5d79bd 100644 --- a/crates/switchyard-py/src/libsy_bindings.rs +++ b/crates/switchyard-py/src/libsy_bindings.rs @@ -1,9 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Python bindings for Switchyard's neutral LLM protocol values. +//! Python bindings for Switchyard's neutral protocol and libsy targets. mod protocol; +mod target; mod values; use pyo3::prelude::*; @@ -14,5 +15,8 @@ pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { values::register(&protocol_module)?; module.add_submodule(&protocol_module)?; + let libsy_module = PyModule::new(module.py(), "libsy")?; + target::register(&libsy_module)?; + module.add_submodule(&libsy_module)?; Ok(()) } diff --git a/crates/switchyard-py/src/libsy_bindings/target.rs b/crates/switchyard-py/src/libsy_bindings/target.rs new file mode 100644 index 00000000..b0a5c428 --- /dev/null +++ b/crates/switchyard-py/src/libsy_bindings/target.rs @@ -0,0 +1,116 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Python bindings for libsy routing targets and host-provided clients. + +use pyo3::exceptions::{PyKeyError, PyTypeError}; +use pyo3::prelude::*; + +/// A semantic routing target and its optional Python-hosted routed client. +#[pyclass(name = "LlmTarget", module = "switchyard.libsy")] +pub(crate) struct PyLlmTarget { + semantic_name: String, + llm_client: Option>, +} + +#[pymethods] +impl PyLlmTarget { + #[new] + #[pyo3(signature = (semantic_name, *, llm_client=None))] + fn new(py: Python<'_>, semantic_name: String, llm_client: Option>) -> PyResult { + validate_client(py, llm_client.as_ref())?; + Ok(Self { + semantic_name, + llm_client, + }) + } + + #[getter] + fn semantic_name(&self) -> &str { + &self.semantic_name + } + + #[getter] + fn llm_client(&self, py: Python<'_>) -> Option> { + self.llm_client.as_ref().map(|client| client.clone_ref(py)) + } + + #[setter] + fn set_llm_client(&mut self, py: Python<'_>, llm_client: Option>) -> PyResult<()> { + validate_client(py, llm_client.as_ref())?; + self.llm_client = llm_client; + Ok(()) + } + + fn __repr__(&self) -> String { + format!( + "LlmTarget(semantic_name={:?}, llm_client={})", + self.semantic_name, + if self.llm_client.is_some() { + "" + } else { + "None" + } + ) + } +} + +/// Reusable routing targets. Algorithm factories snapshot their current clients. +#[pyclass(name = "LlmTargetSet", module = "switchyard.libsy", frozen)] +pub(crate) struct PyLlmTargetSet { + targets: Vec>, +} + +#[pymethods] +impl PyLlmTargetSet { + #[new] + fn new(targets: Vec>) -> Self { + Self { targets } + } + + #[getter] + fn targets(&self, py: Python<'_>) -> Vec> { + self.targets + .iter() + .map(|target| target.clone_ref(py)) + .collect() + } + + fn get_target(&self, py: Python<'_>, name: &str) -> PyResult> { + for target in &self.targets { + if target.bind(py).try_borrow()?.semantic_name == name { + return Ok(target.clone_ref(py)); + } + } + Err(PyKeyError::new_err(name.to_string())) + } + + fn __len__(&self) -> usize { + self.targets.len() + } + + fn __repr__(&self) -> String { + format!("LlmTargetSet(len={})", self.targets.len()) + } +} + +fn validate_client(py: Python<'_>, client: Option<&Py>) -> PyResult<()> { + let Some(client) = client else { + return Ok(()); + }; + let call = client.bind(py).getattr("call").map_err(|_| { + PyTypeError::new_err("llm_client must define call(context, request, decision)") + })?; + if !call.is_callable() { + return Err(PyTypeError::new_err( + "llm_client.call must be an async callable", + )); + } + Ok(()) +} + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_class::()?; + Ok(()) +} diff --git a/switchyard/libsy/__init__.py b/switchyard/libsy/__init__.py index 1b394620..e5d2e622 100644 --- a/switchyard/libsy/__init__.py +++ b/switchyard/libsy/__init__.py @@ -1,8 +1,10 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Public Python API for Rust-owned libsy functionality.""" +"""Public Python API for configuring Rust-owned libsy targets.""" + +from switchyard_rust.libsy import LlmTarget, LlmTargetSet from . import protocol as protocol -__all__ = ["protocol"] +__all__ = ["LlmTarget", "LlmTargetSet", "protocol"] diff --git a/switchyard_rust/libsy.py b/switchyard_rust/libsy.py new file mode 100644 index 00000000..946db5a0 --- /dev/null +++ b/switchyard_rust/libsy.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Rust-owned libsy target bindings.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from switchyard_rust.core import _load_native + +_EXPORTS = frozenset( + { + "LlmTarget", + "LlmTargetSet", + } +) + +if TYPE_CHECKING: + LlmTarget: type[Any] + LlmTargetSet: type[Any] + + +def __getattr__(name: str) -> object: + if name in _EXPORTS: + return getattr(_load_native().libsy, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +__all__ = sorted(_EXPORTS) diff --git a/switchyard_rust/libsy.pyi b/switchyard_rust/libsy.pyi new file mode 100644 index 00000000..1664e7fd --- /dev/null +++ b/switchyard_rust/libsy.pyi @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from typing import Any, final + +class LlmTarget: + def __init__( + self, + semantic_name: str, + *, + llm_client: Any | None = None, + ) -> None: ... + @property + def semantic_name(self) -> str: ... + @property + def llm_client(self) -> Any | None: ... + @llm_client.setter + def llm_client(self, value: Any | None) -> None: ... + +@final +class LlmTargetSet: + def __init__(self, targets: list[LlmTarget]) -> None: ... + @property + def targets(self) -> list[LlmTarget]: ... + def get_target(self, name: str) -> LlmTarget: ... + def __len__(self) -> int: ... diff --git a/tests/test_libsy_protocol_bindings.py b/tests/test_libsy_protocol_bindings.py index d7bef20c..06f9f538 100644 --- a/tests/test_libsy_protocol_bindings.py +++ b/tests/test_libsy_protocol_bindings.py @@ -16,7 +16,6 @@ def test_libsy_uses_an_explicit_namespace() -> None: assert switchyard.libsy is libsy assert libsy.protocol.__name__ == "switchyard.libsy.protocol" assert not hasattr(libsy, "Algorithm") - assert not hasattr(libsy, "LlmTarget") assert not hasattr(libsy, "RunStream") diff --git a/tests/test_libsy_target_bindings.py b/tests/test_libsy_target_bindings.py new file mode 100644 index 00000000..28494f90 --- /dev/null +++ b/tests/test_libsy_target_bindings.py @@ -0,0 +1,39 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import pytest + +from switchyard import libsy + + +class _Client: + async def call( + self, + context: libsy.protocol.Context, + request: libsy.protocol.Request, + decision: object, + ) -> libsy.protocol.Response: + return libsy.protocol.Response(libsy.protocol.AggLlmResponse()) + + +def test_libsy_targets_hold_assignable_clients() -> None: + first_client = _Client() + second_client = _Client() + target = libsy.LlmTarget("strong", llm_client=first_client) + targets = libsy.LlmTargetSet([target]) + + assert target.semantic_name == "strong" + assert target.llm_client is first_client + assert targets.targets == [target] + assert targets.get_target("strong") is target + assert len(targets) == 1 + + target.llm_client = second_client + assert target.llm_client is second_client + target.llm_client = None + assert target.llm_client is None + + with pytest.raises(KeyError, match="missing"): + targets.get_target("missing") + with pytest.raises(TypeError, match="must define call"): + libsy.LlmTarget("invalid", llm_client=object())