Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion crates/switchyard-py/src/libsy_bindings.rs
Original file line number Diff line number Diff line change
@@ -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::*;
Expand All @@ -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(())
}
116 changes: 116 additions & 0 deletions crates/switchyard-py/src/libsy_bindings/target.rs
Original file line number Diff line number Diff line change
@@ -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<Py<PyAny>>,
}

#[pymethods]
impl PyLlmTarget {
#[new]
#[pyo3(signature = (semantic_name, *, llm_client=None))]
fn new(py: Python<'_>, semantic_name: String, llm_client: Option<Py<PyAny>>) -> PyResult<Self> {
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<Py<PyAny>> {
self.llm_client.as_ref().map(|client| client.clone_ref(py))
}

#[setter]
fn set_llm_client(&mut self, py: Python<'_>, llm_client: Option<Py<PyAny>>) -> 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() {
"<configured>"
} else {
"None"
}
)
}
}

/// Reusable routing targets. Algorithm factories snapshot their current clients.
#[pyclass(name = "LlmTargetSet", module = "switchyard.libsy", frozen)]
pub(crate) struct PyLlmTargetSet {
targets: Vec<Py<PyLlmTarget>>,
}

#[pymethods]
impl PyLlmTargetSet {
#[new]
fn new(targets: Vec<Py<PyLlmTarget>>) -> Self {
Self { targets }
}

#[getter]
fn targets(&self, py: Python<'_>) -> Vec<Py<PyLlmTarget>> {
self.targets
.iter()
.map(|target| target.clone_ref(py))
.collect()
}

fn get_target(&self, py: Python<'_>, name: &str) -> PyResult<Py<PyLlmTarget>> {
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<PyAny>>) -> 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::<PyLlmTarget>()?;
module.add_class::<PyLlmTargetSet>()?;
Ok(())
}
6 changes: 4 additions & 2 deletions switchyard/libsy/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
30 changes: 30 additions & 0 deletions switchyard_rust/libsy.py
Original file line number Diff line number Diff line change
@@ -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)
26 changes: 26 additions & 0 deletions switchyard_rust/libsy.pyi
Original file line number Diff line number Diff line change
@@ -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: ...
1 change: 0 additions & 1 deletion tests/test_libsy_protocol_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")


Expand Down
39 changes: 39 additions & 0 deletions tests/test_libsy_target_bindings.py
Original file line number Diff line number Diff line change
@@ -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())
Loading