From 04e5eedc8802f7fd749c86dc783115967a043d5a Mon Sep 17 00:00:00 2001 From: nachiketb Date: Fri, 17 Jul 2026 11:14:49 -0700 Subject: [PATCH] feat(libsy): expose Python algorithm execution Signed-off-by: nachiketb --- Cargo.lock | 1 + crates/switchyard-py/Cargo.toml | 3 +- crates/switchyard-py/src/libsy_bindings.rs | 5 +- .../src/libsy_bindings/protocol.rs | 43 +++++++++- .../switchyard-py/src/libsy_bindings/run.rs | 83 ++++++++++++++++++ .../src/libsy_bindings/target.rs | 69 +++++++++++++++ .../src/libsy_bindings/values.rs | 55 ++++++++++-- switchyard/libsy/__init__.py | 14 ++- switchyard/libsy/algorithms.py | 9 ++ switchyard/libsy/protocol.py | 4 + switchyard_rust/libsy.py | 8 +- switchyard_rust/libsy.pyi | 24 +++++- switchyard_rust/libsy_protocol.py | 20 ++++- switchyard_rust/libsy_protocol.pyi | 15 +++- tests/test_libsy_algorithm_bindings.py | 86 +++++++++++++++++++ tests/test_libsy_protocol_bindings.py | 1 - 16 files changed, 416 insertions(+), 24 deletions(-) create mode 100644 crates/switchyard-py/src/libsy_bindings/run.rs create mode 100644 switchyard/libsy/algorithms.py create mode 100644 tests/test_libsy_algorithm_bindings.py diff --git a/Cargo.lock b/Cargo.lock index 9c1b87b2..23128cfc 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/switchyard-py/Cargo.toml b/crates/switchyard-py/Cargo.toml index ceb9eb3f..d250e688 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" @@ -26,8 +27,8 @@ 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-protocol = { path = "../libsy-protocol" } switchyard-translation = { path = "../switchyard-translation" } tokio = { version = "1", features = ["rt", "rt-multi-thread", "sync"] } tracing = { version = "0.1", default-features = false, features = ["std"] } diff --git a/crates/switchyard-py/src/libsy_bindings.rs b/crates/switchyard-py/src/libsy_bindings.rs index bf5d79bd..be9c36e1 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 protocol and libsy targets. +//! Python bindings for Switchyard's neutral protocol and libsy algorithms. mod protocol; +mod run; mod target; mod values; @@ -17,6 +18,8 @@ pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { let libsy_module = PyModule::new(module.py(), "libsy")?; target::register(&libsy_module)?; + run::register(&libsy_module)?; + libsy_module.add("LibsyError", module.getattr("LibsyError")?)?; module.add_submodule(&libsy_module)?; Ok(()) } diff --git a/crates/switchyard-py/src/libsy_bindings/protocol.rs b/crates/switchyard-py/src/libsy_bindings/protocol.rs index 55b58344..3f29632d 100644 --- a/crates/switchyard-py/src/libsy_bindings/protocol.rs +++ b/crates/switchyard-py/src/libsy_bindings/protocol.rs @@ -4,16 +4,17 @@ //! Typed Python wrappers for the Rust-owned Switchyard protocol. use std::collections::BTreeMap; +use std::sync::Arc; 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, + AggLlmResponse, ContentBlock, Decision, 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}; @@ -35,6 +36,39 @@ fn json_or_null(py: Python<'_>, value: Option<&Value>) -> PyResult> { } } +/// A routing decision exposed through the protocol's neutral interface. +#[pyclass(name = "Decision", module = "switchyard.libsy.protocol", frozen)] +pub(crate) struct PyDecision { + inner: Arc, +} + +impl PyDecision { + pub(crate) fn new(inner: Arc) -> Self { + Self { inner } + } +} + +#[pymethods] +impl PyDecision { + #[getter] + fn selected_model(&self) -> String { + self.inner.selected_model().to_string() + } + + #[getter] + fn reasoning(&self) -> Option { + self.inner.reasoning().map(str::to_owned) + } + + fn __repr__(&self) -> String { + format!( + "Decision(selected_model={:?}, reasoning={:?})", + self.inner.selected_model(), + self.inner.reasoning() + ) + } +} + #[pyclass( name = "WireFormat", module = "switchyard.libsy.protocol", @@ -1520,6 +1554,7 @@ impl PyLlmResponseChunk { } pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; module.add_class::()?; module.add_class::()?; module.add_class::()?; diff --git a/crates/switchyard-py/src/libsy_bindings/run.rs b/crates/switchyard-py/src/libsy_bindings/run.rs new file mode 100644 index 00000000..0b227bbd --- /dev/null +++ b/crates/switchyard-py/src/libsy_bindings/run.rs @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Python execution for Rust-owned libsy algorithms. + +use std::sync::Arc; + +use libsy::{Algorithm, NoopAlgo, RandomAlgo}; +use pyo3::prelude::*; + +use super::protocol::PyDecision; +use super::target::PyLlmTargetSet; +use super::values::{PyContext, PyRequest, PyResponse}; +use crate::errors::py_libsy_error; + +/// Opaque Python handle to a Rust-owned routing algorithm. +#[pyclass(name = "Algorithm", module = "switchyard.libsy", frozen)] +pub(crate) struct PyAlgorithm { + inner: Arc, +} + +impl PyAlgorithm { + fn new(inner: Arc) -> Self { + Self { inner } + } +} + +#[pymethods] +impl PyAlgorithm { + /// Run the algorithm using its targets' default clients. + #[pyo3(signature = (request, *, context=None))] + fn run<'py>( + &self, + py: Python<'py>, + request: PyRef<'_, PyRequest>, + context: Option>, + ) -> PyResult> { + let algorithm = Arc::clone(&self.inner); + let context = context + .map(|context| context.clone_core()) + .unwrap_or_default(); + let request = request.clone_core(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let (decisions, response) = algorithm + .run(context, request) + .await + .map_err(py_libsy_error)?; + Python::attach(|py| { + let decisions = decisions + .into_iter() + .map(|decision| Py::new(py, PyDecision::new(decision))) + .collect::>>()?; + let response = Py::new(py, PyResponse::from_core(response))?; + Ok((decisions, 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 the supplied targets. +#[pyfunction(name = "random")] +fn random_algorithm(py: Python<'_>, targets: PyRef<'_, PyLlmTargetSet>) -> PyResult { + Ok(PyAlgorithm::new(Arc::new(RandomAlgo::new( + targets.clone_core(py)?, + )))) +} + +pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { + module.add_class::()?; + module.add_function(wrap_pyfunction!(noop_algorithm, module)?)?; + module.add_function(wrap_pyfunction!(random_algorithm, module)?)?; + Ok(()) +} diff --git a/crates/switchyard-py/src/libsy_bindings/target.rs b/crates/switchyard-py/src/libsy_bindings/target.rs index b0a5c428..e239654d 100644 --- a/crates/switchyard-py/src/libsy_bindings/target.rs +++ b/crates/switchyard-py/src/libsy_bindings/target.rs @@ -3,8 +3,52 @@ //! Python bindings for libsy routing targets and host-provided clients. +use std::error::Error; +use std::sync::Arc; + +use async_trait::async_trait; +use libsy::{LlmTarget, LlmTargetSet}; use pyo3::exceptions::{PyKeyError, PyTypeError}; use pyo3::prelude::*; +use switchyard_protocol::{Context, Decision, Request, Response, RoutedLlmClient}; + +use super::protocol::PyDecision; +use super::values::{python_error, PyContext, PyRequest, PyResponse}; + +type BoxError = Box; + +/// Adapts an object with the protocol's async routed-client method to Rust. +struct PythonRoutedLlmClient { + inner: Py, +} + +#[async_trait] +impl RoutedLlmClient for PythonRoutedLlmClient { + async fn call( + &self, + ctx: Context, + request: Request, + decision: Arc, + ) -> Result { + let future = Python::attach(|py| { + let ctx = Py::new(py, PyContext::from_core(ctx))?; + let request = Py::new(py, PyRequest::from_core(request))?; + let decision = Py::new(py, PyDecision::new(decision))?; + let awaitable = self + .inner + .bind(py) + .call_method1("call", (ctx, request, decision))?; + pyo3_async_runtimes::tokio::into_future(awaitable) + }) + .map_err(python_error)?; + let response = future.await.map_err(python_error)?; + Python::attach(|py| { + let response = response.bind(py).extract::>()?; + response.take_core() + }) + .map_err(python_error) + } +} /// A semantic routing target and its optional Python-hosted routed client. #[pyclass(name = "LlmTarget", module = "switchyard.libsy")] @@ -13,6 +57,20 @@ pub(crate) struct PyLlmTarget { llm_client: Option>, } +impl PyLlmTarget { + fn clone_core(&self, py: Python<'_>) -> LlmTarget { + let llm_client = self.llm_client.as_ref().map(|client| { + Arc::new(PythonRoutedLlmClient { + inner: client.clone_ref(py), + }) as Arc + }); + LlmTarget { + semantic_name: self.semantic_name.clone(), + llm_client, + } + } +} + #[pymethods] impl PyLlmTarget { #[new] @@ -61,6 +119,17 @@ pub(crate) struct PyLlmTargetSet { targets: Vec>, } +impl PyLlmTargetSet { + pub(crate) fn clone_core(&self, py: Python<'_>) -> PyResult { + let targets = self + .targets + .iter() + .map(|target| Ok(target.bind(py).try_borrow()?.clone_core(py))) + .collect::>>()?; + Ok(LlmTargetSet::new(targets)) + } +} + #[pymethods] impl PyLlmTargetSet { #[new] diff --git a/crates/switchyard-py/src/libsy_bindings/values.rs b/crates/switchyard-py/src/libsy_bindings/values.rs index b671717f..c92c01aa 100644 --- a/crates/switchyard-py/src/libsy_bindings/values.rs +++ b/crates/switchyard-py/src/libsy_bindings/values.rs @@ -16,7 +16,7 @@ use pyo3::types::PyType; use serde::Serialize; use serde_json::{Map, Value}; use switchyard_protocol::{ - Context, LlmResponse, LlmResponseChunk, LlmResponseStream, Metadata, Request, + Context, LlmResponse, LlmResponseChunk, LlmResponseStream, Metadata, Request, Response, }; use crate::errors::py_libsy_error; @@ -37,16 +37,24 @@ pub(crate) struct PyContext { inner: Context, } +impl PyContext { + pub(crate) fn from_core(inner: Context) -> Self { + Self { inner } + } + + pub(crate) fn clone_core(&self) -> Context { + self.inner.clone() + } +} + #[pymethods] impl PyContext { #[new] #[pyo3(signature = (*, values=None))] fn new(values: Option>) -> Self { - Self { - inner: Context { - values: values.unwrap_or_default(), - }, - } + Self::from_core(Context { + values: values.unwrap_or_default(), + }) } #[getter] @@ -164,6 +172,16 @@ pub(crate) struct PyRequest { inner: Request, } +impl PyRequest { + pub(crate) fn from_core(inner: Request) -> Self { + Self { inner } + } + + pub(crate) fn clone_core(&self) -> Request { + self.inner.clone() + } +} + #[pymethods] impl PyRequest { #[new] @@ -243,6 +261,31 @@ pub(crate) struct PyResponse { metadata: Option, } +impl PyResponse { + pub(crate) fn from_core(inner: Response) -> Self { + Self { + llm_response: Mutex::new(Some(inner.llm_response)), + metadata: inner.metadata, + } + } + + pub(crate) fn take_core(&self) -> PyResult { + let mut response = self + .llm_response + .lock() + .map_err(|_| PyRuntimeError::new_err("Response lock is poisoned"))?; + let Some(llm_response) = response.take() else { + return Err(PyRuntimeError::new_err( + "Response has already been consumed", + )); + }; + Ok(Response { + llm_response, + metadata: self.metadata.clone(), + }) + } +} + #[pymethods] impl PyResponse { #[new] diff --git a/switchyard/libsy/__init__.py b/switchyard/libsy/__init__.py index e5d2e622..ea8dd62b 100644 --- a/switchyard/libsy/__init__.py +++ b/switchyard/libsy/__init__.py @@ -1,10 +1,18 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Public Python API for configuring Rust-owned libsy targets.""" +"""Public Python API for running Rust-owned libsy algorithms.""" -from switchyard_rust.libsy import LlmTarget, LlmTargetSet +from switchyard_rust.libsy import Algorithm, LibsyError, LlmTarget, LlmTargetSet +from . import algorithms as algorithms from . import protocol as protocol -__all__ = ["LlmTarget", "LlmTargetSet", "protocol"] +__all__ = [ + "Algorithm", + "LibsyError", + "LlmTarget", + "LlmTargetSet", + "algorithms", + "protocol", +] 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/libsy/protocol.py b/switchyard/libsy/protocol.py index cbca118e..d51435dc 100644 --- a/switchyard/libsy/protocol.py +++ b/switchyard/libsy/protocol.py @@ -7,6 +7,7 @@ AggLlmResponse, ContentBlock, Context, + Decision, FileSource, FormatId, ImageSource, @@ -25,6 +26,7 @@ Response, ResponseOutput, Role, + RoutedLlmClient, SamplingParams, StopReason, ToolCall, @@ -65,6 +67,7 @@ "ContentBlock", "ContentBlockValue", "Context", + "Decision", "FileSource", "FileSourceValue", "FormatId", @@ -87,6 +90,7 @@ "Response", "ResponseOutput", "Role", + "RoutedLlmClient", "SamplingParams", "StopReason", "ToolCall", diff --git a/switchyard_rust/libsy.py b/switchyard_rust/libsy.py index 946db5a0..12063955 100644 --- a/switchyard_rust/libsy.py +++ b/switchyard_rust/libsy.py @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Rust-owned libsy target bindings.""" +"""Rust-owned libsy algorithm bindings.""" from __future__ import annotations @@ -11,12 +11,18 @@ _EXPORTS = frozenset( { + "Algorithm", + "LibsyError", "LlmTarget", "LlmTargetSet", + "noop", + "random", } ) if TYPE_CHECKING: + Algorithm: type[Any] + LibsyError: type[RuntimeError] LlmTarget: type[Any] LlmTargetSet: type[Any] diff --git a/switchyard_rust/libsy.pyi b/switchyard_rust/libsy.pyi index 1664e7fd..617213b8 100644 --- a/switchyard_rust/libsy.pyi +++ b/switchyard_rust/libsy.pyi @@ -1,21 +1,25 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -from typing import Any, final +from typing import final + +from switchyard_rust.libsy_protocol import Context, Decision, Request, Response, RoutedLlmClient + +class LibsyError(RuntimeError): ... class LlmTarget: def __init__( self, semantic_name: str, *, - llm_client: Any | None = None, + llm_client: RoutedLlmClient | None = None, ) -> None: ... @property def semantic_name(self) -> str: ... @property - def llm_client(self) -> Any | None: ... + def llm_client(self) -> RoutedLlmClient | None: ... @llm_client.setter - def llm_client(self, value: Any | None) -> None: ... + def llm_client(self, value: RoutedLlmClient | None) -> None: ... @final class LlmTargetSet: @@ -24,3 +28,15 @@ class LlmTargetSet: def targets(self) -> list[LlmTarget]: ... def get_target(self, name: str) -> LlmTarget: ... def __len__(self) -> int: ... + +@final +class Algorithm: + async def run( + self, + request: Request, + *, + context: Context | None = None, + ) -> tuple[list[Decision], Response]: ... + +def noop() -> Algorithm: ... +def random(targets: LlmTargetSet) -> Algorithm: ... diff --git a/switchyard_rust/libsy_protocol.py b/switchyard_rust/libsy_protocol.py index 876b2902..4bfd9cea 100644 --- a/switchyard_rust/libsy_protocol.py +++ b/switchyard_rust/libsy_protocol.py @@ -5,7 +5,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Protocol from switchyard_rust.core import _load_native @@ -14,6 +14,7 @@ "AggLlmResponse", "ContentBlock", "Context", + "Decision", "FileSource", "FormatId", "ImageSource", @@ -47,6 +48,7 @@ AggLlmResponse: type[Any] ContentBlock: type[Any] Context: type[Any] + Decision: type[Any] FileSource: type[Any] FormatId: type[Any] ImageSource: type[Any] @@ -75,10 +77,24 @@ WireFormat: type[Any] +class RoutedLlmClient(Protocol): + """Host implementation that serves a routed model call.""" + + async def call( + self, + context: Any, + request: Any, + decision: Any, + /, + ) -> Any: + """Return a typed protocol response for the selected model.""" + ... + + 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) +__all__ = sorted(_EXPORTS | {"RoutedLlmClient"}) diff --git a/switchyard_rust/libsy_protocol.pyi b/switchyard_rust/libsy_protocol.pyi index 94f780ae..ed4ecbfa 100644 --- a/switchyard_rust/libsy_protocol.pyi +++ b/switchyard_rust/libsy_protocol.pyi @@ -4,7 +4,7 @@ from __future__ import annotations from collections.abc import AsyncIterator, Mapping, Sequence -from typing import Any, ClassVar, final +from typing import Any, ClassVar, Protocol, final class WireFormat: OPENAI_CHAT: ClassVar[WireFormat] @@ -428,6 +428,19 @@ class Context: def __init__(self, *, values: Mapping[str, str] | None = None) -> None: ... @final +class Decision: + selected_model: str + reasoning: str | None + +class RoutedLlmClient(Protocol): + async def call( + self, + context: Context, + request: Request, + decision: Decision, + /, + ) -> Response: ... + class Metadata: session_id: str | None agent_id: str | None diff --git a/tests/test_libsy_algorithm_bindings.py b/tests/test_libsy_algorithm_bindings.py new file mode 100644 index 00000000..f8c4c9c3 --- /dev/null +++ b/tests/test_libsy_algorithm_bindings.py @@ -0,0 +1,86 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import pytest + +from switchyard import libsy + + +def test_algorithm_handle_cannot_be_constructed_or_subclassed_in_python() -> None: + with pytest.raises(TypeError): + libsy.Algorithm() + with pytest.raises(TypeError): + + class PythonAlgorithm(libsy.Algorithm): + pass + + +class _EchoRoutedLlmClient: + def __init__(self) -> None: + self.calls: list[ + tuple[ + libsy.protocol.Context, + libsy.protocol.Request, + libsy.protocol.Decision, + ] + ] = [] + + async def call( + self, + context: libsy.protocol.Context, + request: libsy.protocol.Request, + decision: libsy.protocol.Decision, + ) -> libsy.protocol.Response: + self.calls.append((context, request, decision)) + return libsy.protocol.Response(libsy.protocol.AggLlmResponse(model=decision.selected_model)) + + +class _FailingRoutedLlmClient: + async def call( + self, + context: libsy.protocol.Context, + request: libsy.protocol.Request, + decision: libsy.protocol.Decision, + ) -> libsy.protocol.Response: + raise RuntimeError(f"failed to call {decision.selected_model}") + + +async def test_noop_algorithm_runs_to_completion() -> None: + decisions, response = await libsy.algorithms.noop().run( + libsy.protocol.Request(libsy.protocol.LlmRequest(model="requested-model")) + ) + + assert [decision.selected_model for decision in decisions] == ["requested-model"] + assert response.selected_model == "requested-model" + match response.aggregate.outputs[0].content[0]: + case libsy.protocol.ContentBlock.Text(text="OK"): + pass + case block: + pytest.fail(f"unexpected content block: {block!r}") + + +async def test_random_algorithm_run_uses_the_target_client() -> None: + client = _EchoRoutedLlmClient() + target = libsy.LlmTarget("only/model", llm_client=client) + algorithm = libsy.algorithms.random(libsy.LlmTargetSet([target])) + request = libsy.protocol.Request(libsy.protocol.LlmRequest(model="auto")) + context = libsy.protocol.Context(values={"request_id": "request-1"}) + + assert type(algorithm) is libsy.Algorithm + decisions, response = await algorithm.run(request, context=context) + + assert [decision.selected_model for decision in decisions] == ["only/model"] + assert response.selected_model == "only/model" + assert len(client.calls) == 1 + called_context, called_request, called_decision = client.calls[0] + assert called_context.values == {"request_id": "request-1"} + assert called_request.requested_model == "auto" + assert called_decision.selected_model == "only/model" + + +async def test_python_target_client_errors_reach_the_algorithm_caller() -> None: + target = libsy.LlmTarget("broken/model", llm_client=_FailingRoutedLlmClient()) + algorithm = libsy.algorithms.random(libsy.LlmTargetSet([target])) + + with pytest.raises(libsy.LibsyError, match="failed to call broken/model"): + await algorithm.run(libsy.protocol.Request(libsy.protocol.LlmRequest(model="auto"))) diff --git a/tests/test_libsy_protocol_bindings.py b/tests/test_libsy_protocol_bindings.py index 06f9f538..15ac656e 100644 --- a/tests/test_libsy_protocol_bindings.py +++ b/tests/test_libsy_protocol_bindings.py @@ -15,7 +15,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, "RunStream")