diff --git a/Cargo.lock b/Cargo.lock index 642b93ae..0ed7ec26 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1645,6 +1645,7 @@ version = "0.1.0" dependencies = [ "async-trait", "futures-util", + "libsy", "pyo3", "pyo3-async-runtimes", "pythonize", diff --git a/crates/libsy-protocol/src/llm.rs b/crates/libsy-protocol/src/llm.rs index 6d873e79..635860b5 100644 --- a/crates/libsy-protocol/src/llm.rs +++ b/crates/libsy-protocol/src/llm.rs @@ -12,6 +12,7 @@ use crate::format::FormatId; /// Actor role normalized across provider APIs. #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum Role { System, Developer, @@ -64,6 +65,7 @@ impl Message { /// Normalized content block variants carried by messages and tool results. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] pub enum ContentBlock { Text { text: String, @@ -97,6 +99,7 @@ pub enum ContentBlock { /// Image payload forms supported by the conversation model. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] pub enum ImageSource { Url { url: String, @@ -111,6 +114,7 @@ pub enum ImageSource { /// File payload forms supported by the conversation model. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] pub enum FileSource { FileId(String), FileData { @@ -122,6 +126,7 @@ pub enum FileSource { /// Audio and video payload forms supported by the conversation model. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] pub enum MediaSource { Url { url: String, @@ -161,6 +166,7 @@ pub struct ToolDefinition { /// Normalized tool choice policy. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] pub enum ToolChoice { Auto, Required, @@ -193,12 +199,14 @@ pub struct ReasoningParams { /// Provider-specific fields that do not have first-class conversation fields. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] pub struct ProviderExtensions { pub fields: Map, } /// Exact source payloads retained for lossless round trips. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] pub struct PreservationMetadata { pub requests: BTreeMap, pub responses: BTreeMap, @@ -206,6 +214,7 @@ pub struct PreservationMetadata { /// Normalized request representation shared by Switchyard components. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] pub struct LlmRequest { pub model: Option, pub instructions: Vec, @@ -231,6 +240,7 @@ pub struct Usage { /// Normalized reason a model stopped producing output. #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] pub enum StopReason { EndTurn, MaxTokens, @@ -253,6 +263,7 @@ pub struct ResponseOutput { /// [`LlmResponse`](crate::LlmResponse)); `switchyard-translation` re-exports it as /// `ConversationResponse`. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] pub struct AggLlmResponse { pub id: Option, pub model: Option, @@ -268,3 +279,38 @@ impl AggLlmResponse { self.outputs.first() } } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn serde_uses_python_friendly_dictionary_shapes() -> Result<(), serde_json::Error> { + let request: LlmRequest = serde_json::from_value(json!({ + "model": "auto", + "messages": [{ + "role": "user", + "content": [{"type": "text", "text": "hello"}] + }] + }))?; + assert_eq!(request.messages[0], Message::text(Role::User, "hello")); + + let tool_call = ContentBlock::ToolCall(ToolCall { + id: "call-1".to_string(), + name: "lookup".to_string(), + arguments: json!({"query": "rust"}), + }); + assert_eq!( + serde_json::to_value(tool_call)?, + json!({ + "type": "tool_call", + "id": "call-1", + "name": "lookup", + "arguments": {"query": "rust"} + }) + ); + Ok(()) + } +} diff --git a/crates/switchyard-py/Cargo.toml b/crates/switchyard-py/Cargo.toml index 67f59cca..5659430c 100644 --- a/crates/switchyard-py/Cargo.toml +++ b/crates/switchyard-py/Cargo.toml @@ -18,6 +18,7 @@ crate-type = ["cdylib", "rlib"] [dependencies] async-trait = "0.1" futures-util = "0.3" +libsy = { path = "../libsy" } pyo3 = { version = "0.28.3", features = ["abi3-py312", "extension-module"] } pyo3-async-runtimes = { version = "0.28", features = ["tokio-runtime"] } pythonize = "0.28.0" diff --git a/crates/switchyard-py/src/errors.rs b/crates/switchyard-py/src/errors.rs index 72b3dba4..5eccc784 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 execution failures into one stable Python exception. +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..6b3fe73d 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; @@ -17,6 +18,7 @@ fn _switchyard_rust(module: &Bound<'_, PyModule>) -> PyResult<()> { translation::register(module)?; core_bindings::register(module)?; component_bindings::register(module)?; + libsy_bindings::register(module)?; profile_bindings::register(module)?; server_bindings::register(module)?; Ok(()) diff --git a/crates/switchyard-py/src/libsy_bindings.rs b/crates/switchyard-py/src/libsy_bindings.rs new file mode 100644 index 00000000..a7e62ef3 --- /dev/null +++ b/crates/switchyard-py/src/libsy_bindings.rs @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Minimal Python API for running Rust-owned libsy algorithms. + +use std::error::Error; +use std::sync::Arc; + +use async_trait::async_trait; +use libsy::{ + AggLlmResponse, Algorithm, Context, Decision, LlmResponse, LlmTarget, LlmTargetSet, NoopAlgo, + RandomAlgo, Request, Response, RoutedLlmClient, +}; +use pyo3::exceptions::{PyTypeError, PyValueError}; +use pyo3::prelude::*; +use serde_json::{json, Value}; + +use crate::errors::py_libsy_error; +use crate::py_serde::{from_python, to_python}; + +type BoxError = Box; + +/// Adapts a Python object with `async call(request)` to libsy. +struct PythonLlmClient { + inner: Py, +} + +#[async_trait] +impl RoutedLlmClient for PythonLlmClient { + async fn call( + &self, + _ctx: Context, + request: Request, + _decision: Arc, + ) -> Result { + let metadata = request.metadata; + let future = Python::attach(|py| { + let request = to_python(py, &request.llm_request)?; + let awaitable = self.inner.bind(py).call_method1("call", (request,))?; + pyo3_async_runtimes::tokio::into_future(awaitable) + }) + .map_err(boxed_python_error)?; + + let response = future.await.map_err(boxed_python_error)?; + let aggregate = Python::attach(|py| from_python::(response.bind(py))) + .map_err(boxed_python_error)?; + Ok(Response { + llm_response: LlmResponse::Agg(aggregate), + metadata, + }) + } +} + +/// A required-client routing target used by Python-created algorithms. +#[pyclass(name = "LlmTarget", module = "switchyard.libsy", frozen)] +struct PyLlmTarget { + name: String, + client: Py, +} + +impl PyLlmTarget { + fn clone_core(&self, py: Python<'_>) -> LlmTarget { + LlmTarget { + semantic_name: self.name.clone(), + llm_client: Some(Arc::new(PythonLlmClient { + inner: self.client.clone_ref(py), + })), + } + } +} + +#[pymethods] +impl PyLlmTarget { + #[new] + fn new(py: Python<'_>, name: String, client: Py) -> PyResult { + let call = client + .bind(py) + .getattr("call") + .map_err(|_| PyTypeError::new_err("client must define async call(request)"))?; + if !call.is_callable() { + return Err(PyTypeError::new_err( + "client.call must be callable as async call(request)", + )); + } + Ok(Self { name, client }) + } + + #[getter] + fn name(&self) -> &str { + &self.name + } + + fn __repr__(&self) -> String { + format!("LlmTarget(name={:?})", self.name) + } +} + +/// Opaque handle shared by every Rust-owned algorithm exposed to Python. +#[pyclass(name = "Algorithm", module = "switchyard.libsy", frozen)] +struct PyAlgorithm { + inner: Arc, +} + +impl PyAlgorithm { + fn new(inner: Arc) -> Self { + Self { inner } + } +} + +#[pymethods] +impl PyAlgorithm { + /// Run to completion using the clients configured on the algorithm's targets. + fn run<'py>(&self, py: Python<'py>, request: &Bound<'_, PyAny>) -> PyResult> { + let algorithm = Arc::clone(&self.inner); + let request = Request { + llm_request: from_python(request)?, + raw_request: None, + metadata: None, + }; + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let (decisions, response) = algorithm + .run(Context::default(), request) + .await + .map_err(py_libsy_error)?; + let response = response + .llm_response + .into_agg() + .await + .map_err(py_libsy_error)?; + let decisions = decisions + .iter() + .map(|decision| { + json!({ + "selected_model": decision.selected_model(), + "reasoning": decision.reasoning(), + }) + }) + .collect::>(); + Python::attach(|py| Ok((to_python(py, &decisions)?, to_python(py, &response)?))) + }) + } + + fn __repr__(&self) -> &'static str { + "Algorithm()" + } +} + +/// Construct the no-op reference algorithm. +#[pyfunction(name = "noop")] +fn noop_algorithm() -> PyAlgorithm { + PyAlgorithm::new(Arc::new(NoopAlgo {})) +} + +/// Construct uniform random routing over targets with Python clients. +#[pyfunction(name = "random")] +fn random_algorithm(py: Python<'_>, targets: Vec>) -> PyResult { + if targets.is_empty() { + return Err(PyValueError::new_err("random requires at least one target")); + } + let targets = targets + .iter() + .map(|target| Ok(target.bind(py).try_borrow()?.clone_core(py))) + .collect::>>()?; + Ok(PyAlgorithm::new(Arc::new(RandomAlgo::new( + LlmTargetSet::new(targets), + )))) +} + +fn boxed_python_error(error: PyErr) -> BoxError { + std::io::Error::other(error.to_string()).into() +} + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + let libsy_module = PyModule::new(module.py(), "libsy")?; + libsy_module.add_class::()?; + libsy_module.add_class::()?; + libsy_module.add_function(wrap_pyfunction!(noop_algorithm, &libsy_module)?)?; + libsy_module.add_function(wrap_pyfunction!(random_algorithm, &libsy_module)?)?; + libsy_module.add("LibsyError", module.getattr("LibsyError")?)?; + module.add_submodule(&libsy_module)?; + Ok(()) +} diff --git a/crates/switchyard-py/src/py_serde.rs b/crates/switchyard-py/src/py_serde.rs index c1770799..c6110e8e 100644 --- a/crates/switchyard-py/src/py_serde.rs +++ b/crates/switchyard-py/src/py_serde.rs @@ -7,22 +7,33 @@ use pyo3::exceptions::PyValueError; use pyo3::prelude::*; use pyo3::types::PyDict; use pythonize::{depythonize, pythonize}; +use serde::{de::DeserializeOwned, Serialize}; use serde_json::Value; -/// Converts an arbitrary Python object into a JSON value. -pub(crate) fn value_from_python(value: &Bound<'_, PyAny>) -> PyResult { +/// Converts a Python mapping-like object into a Serde-owned Rust value. +pub(crate) fn from_python(value: &Bound<'_, PyAny>) -> PyResult { let normalized = jsonable_python(value)?; depythonize(normalized.bind(value.py())) .map_err(|error| PyValueError::new_err(error.to_string())) } -/// Converts a JSON value into a Python object. -pub(crate) fn value_to_python(py: Python<'_>, value: &Value) -> PyResult> { +/// Converts a serializable Rust value into Python dictionaries and lists. +pub(crate) fn to_python(py: Python<'_>, value: &T) -> PyResult> { pythonize(py, value) .map(|object| object.unbind()) .map_err(|error| PyValueError::new_err(error.to_string())) } +/// Converts an arbitrary Python object into a JSON value. +pub(crate) fn value_from_python(value: &Bound<'_, PyAny>) -> PyResult { + from_python(value) +} + +/// Converts a JSON value into a Python object. +pub(crate) fn value_to_python(py: Python<'_>, value: &Value) -> PyResult> { + to_python(py, value) +} + fn jsonable_python(value: &Bound<'_, PyAny>) -> PyResult> { if let Ok(model_dump) = value.getattr("model_dump") { if model_dump.is_callable() { diff --git a/examples/libsy.py b/examples/libsy.py new file mode 100644 index 00000000..d0e5c195 --- /dev/null +++ b/examples/libsy.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Run libsy's no-op and random-routing algorithms from Python.""" + +import asyncio +from collections.abc import Mapping + +from switchyard.libsy import LlmTarget, algorithms + + +class EchoClient: + """Return its configured model as the completion.""" + + def __init__(self, model: str) -> None: + self.model = model + + async def call(self, request: Mapping[str, object]) -> Mapping[str, object]: + return { + "model": self.model, + "outputs": [ + {"role": "assistant", "content": [{"type": "text", "text": self.model}]} + ], + } + + +async def main() -> None: + """Run both algorithms and print their aggregate results.""" + request = { + "model": "auto", + "messages": [{"role": "user", "content": [{"type": "text", "text": "Hello"}]}], + } + + noop_decisions, noop_response = await algorithms.noop().run(request) + print("No-op:", noop_decisions, noop_response) + + random = algorithms.random( + [ + LlmTarget("fast", EchoClient("fast")), + LlmTarget("quality", EchoClient("quality")), + ] + ) + random_decisions, random_response = await random.run(request) + print("Random:", random_decisions, random_response) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/switchyard/libsy/__init__.py b/switchyard/libsy/__init__.py new file mode 100644 index 00000000..f7f5b51b --- /dev/null +++ b/switchyard/libsy/__init__.py @@ -0,0 +1,10 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Run Rust-owned libsy algorithms with Python-hosted LLM clients.""" + +from switchyard_rust.libsy import Algorithm, LibsyError, LlmClient, LlmTarget + +from . import algorithms as algorithms + +__all__ = ["Algorithm", "LibsyError", "LlmClient", "LlmTarget", "algorithms"] diff --git a/switchyard/libsy/algorithms.py b/switchyard/libsy/algorithms.py new file mode 100644 index 00000000..434526fe --- /dev/null +++ b/switchyard/libsy/algorithms.py @@ -0,0 +1,9 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Factories for Rust-owned libsy algorithms.""" + +from switchyard_rust.libsy import noop as noop +from switchyard_rust.libsy import random as random + +__all__ = ["noop", "random"] diff --git a/switchyard_rust/libsy.py b/switchyard_rust/libsy.py new file mode 100644 index 00000000..b7ef7bfb --- /dev/null +++ b/switchyard_rust/libsy.py @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Minimal bindings for Rust-owned libsy algorithms.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any, Protocol + +from switchyard_rust.core import _load_native + +_EXPORTS = frozenset({"Algorithm", "LibsyError", "LlmTarget", "noop", "random"}) + + +class LlmClient(Protocol): + """Structural interface for a Python-hosted model client.""" + + async def call( + self, + request: Mapping[str, object], + ) -> Mapping[str, object]: + """Call the configured target and return an aggregate neutral response.""" + ... + + +if TYPE_CHECKING: + from collections.abc import Sequence + from typing import final + + from switchyard_rust.core import SwitchyardRuntimeError + + class LibsyError(SwitchyardRuntimeError): ... + + @final + class LlmTarget: + def __init__(self, name: str, client: LlmClient) -> None: ... + + @property + def name(self) -> str: ... + + @final + class Algorithm: + async def run( + self, + request: Mapping[str, object], + ) -> tuple[list[dict[str, object]], dict[str, object]]: ... + + def noop() -> Algorithm: ... + + def random(targets: Sequence[LlmTarget]) -> Algorithm: ... + + +def __getattr__(name: str) -> object: + if name in _EXPORTS: + native: Any = _load_native() + return getattr(native.libsy, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +__all__ = [*sorted(_EXPORTS), "LlmClient"] diff --git a/tests/test_libsy_minimal_bindings.py b/tests/test_libsy_minimal_bindings.py new file mode 100644 index 00000000..b2014a33 --- /dev/null +++ b/tests/test_libsy_minimal_bindings.py @@ -0,0 +1,110 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the dictionary-based libsy Python API.""" + +from typing import Any + +import pytest + +from switchyard.libsy import LibsyError, LlmTarget, algorithms + + +def request_body() -> dict[str, Any]: + return { + "model": "auto", + "messages": [ + { + "role": "user", + "content": [{"type": "text", "text": "hello"}], + } + ], + } + + +class EchoClient: + def __init__(self, model: str) -> None: + self.model = model + self.calls: list[dict[str, Any]] = [] + + async def call(self, request: dict[str, Any]) -> dict[str, Any]: + self.calls.append(request) + return { + "model": self.model, + "outputs": [ + { + "role": "assistant", + "content": [{"type": "text", "text": self.model}], + "stop_reason": "end_turn", + } + ], + } + + +async def test_random_runs_with_a_python_client() -> None: + client = EchoClient("fast") + algorithm = algorithms.random([LlmTarget("fast", client)]) + + decisions, response = await algorithm.run(request_body()) + + assert decisions == [ + { + "selected_model": "fast", + "reasoning": "random routing selected target 'fast'", + } + ] + assert client.calls[0]["messages"][0]["content"] == [ + {"type": "text", "text": "hello"} + ] + assert response["model"] == "fast" + assert response["outputs"][0]["content"] == [{"type": "text", "text": "fast"}] + + +async def test_noop_needs_no_client() -> None: + decisions, response = await algorithms.noop().run(request_body()) + + assert decisions[0]["selected_model"] == "auto" + assert response["outputs"][0]["content"] == [{"type": "text", "text": "OK"}] + + +def test_algorithm_exposes_only_managed_execution() -> None: + algorithm = algorithms.noop() + + assert callable(algorithm.run) + assert not hasattr(algorithm, "run_stream") + + +def test_target_requires_a_callable_client() -> None: + with pytest.raises(TypeError, match="client must define async call"): + LlmTarget("fast", object()) + + with pytest.raises(TypeError, match="client.call must be callable"): + LlmTarget("fast", type("Client", (), {"call": None})()) + + +def test_random_requires_a_target() -> None: + with pytest.raises(ValueError, match="at least one target"): + algorithms.random([]) + + +async def test_invalid_request_is_rejected_at_the_boundary() -> None: + algorithm = algorithms.random([LlmTarget("fast", EchoClient("fast"))]) + + with pytest.raises(ValueError, match="unknown variant"): + await algorithm.run( + { + "model": "auto", + "messages": [{"role": "invalid", "content": []}], + } + ) + + +async def test_client_failure_becomes_libsy_error() -> None: + class FailingClient: + async def call(self, request: dict[str, Any]) -> dict[str, Any]: + raise RuntimeError("client failed") + + algorithm = algorithms.random([LlmTarget("broken", FailingClient())]) + + with pytest.raises(LibsyError, match="client failed"): + await algorithm.run(request_body())