Skip to content

feat(python): expose libsy run stream - #392

Draft
nachiketb-nvidia wants to merge 1 commit into
mainfrom
feat/python-libsy-run-stream
Draft

feat(python): expose libsy run stream#392
nachiketb-nvidia wants to merge 1 commit into
mainfrom
feat/python-libsy-run-stream

Conversation

@nachiketb-nvidia

@nachiketb-nvidia nachiketb-nvidia commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

What

Expose the redesigned libsy Algorithm::run_stream contract directly to Python. Python now consumes PyO3 complex-enum Step.CallModel, Step.Decision, and Step.Done values, with a typed Decision object and dictionary-based LLM requests and responses.

Why

The previous binding retained the deleted managed run abstraction and hid model calls behind a Python client stored on each target. The new API matches libsy: targets are semantic names, and the stream consumer decides how each model call is served.

How

  • replace Algorithm.run with an async Algorithm.run_stream iterator
  • expose Step as a PyO3 complex enum and ModelCall as a one-shot reply handle
  • expose protocol decisions through a frozen Decision PyO3 object
  • make LlmTarget name-only and remove the Python LlmClient adapter
  • remove switchyard-llm-client and async-trait from the PyO3 crate
  • keep normalized request and aggregate response payloads dictionary-based
  • update examples/libsy.py to handle the stream directly with one shared client
  • leave the experimental LiteLLM example for a follow-up

Python usage

client = EchoClient()
algorithm = algorithms.random([
    LlmTarget("fast"),
    LlmTarget("quality"),
])

async for step in algorithm.run_stream(request):
    match step:
        case Step.Decision(decision):
            print("Decision:", decision.selected_model_id, decision.reasoning)
        case Step.CallModel(call):
            call.respond(await client.call(call.request))
        case Step.Done(response):
            print("Response:", response)

Decision-only integrations answer intermediate judge or classifier calls, then take the final
answer call without serving it:

async for step in algorithm.run_stream(request):
    match step:
        case Step.CallModel(call) if call.decision.is_answer_call:
            rewritten_request, decision = call.into_parts()
            print("Selected:", decision.selected_model_id)
            break
        case Step.CallModel(call):
            call.respond(await client.call(call.request))

into_parts() returns the final decision and request after algorithm prompt rewriting. It marks
the run as deliberately abandoned rather than failed, matching the decision-only contract in
issue #310.

Intentional Python differences

The stream control flow mirrors Rust, while the boundary remains Python-native:

  • normalized LLM requests and aggregate responses are dictionaries rather than Rust protocol types
  • Step.Done aggregates a streaming Rust response before it crosses into Python
  • Rust CallModel::respond(Result<Response>) is split into respond(mapping) and fail(exception)
  • Algorithm is an opaque handle created by the provided factories; Python cannot implement the Rust trait
  • request headers are accepted separately by run_stream; raw request metadata is not exposed

What to review

  • async iteration and stream cancellation behavior across the PyO3 boundary
  • one-shot ModelCall.respond / ModelCall.fail ownership
  • typed Decision access across stream steps, model calls, and into_parts()
  • decision-only ModelCall.into_parts() behavior
  • generated complex-enum pattern matching and Python type declarations
  • removal of obsolete managed-execution PyO3 code

Validation

  • uv run pytest tests/ -v (136 passed)
  • uv run python examples/libsy.py
  • uv run ruff check .
  • uv run mypy examples/libsy.py switchyard switchyard_rust
  • cargo test -p switchyard-py
  • cargo clippy -p switchyard-py --all-targets -- -D warnings
  • cargo fmt --all --check

Summary by CodeRabbit

  • New Features

    • Added streaming algorithm execution with model-call, routing-decision, completion, and failure events.
    • Added ModelCall and Step types for handling model interactions.
    • Randomized routing now supports optional weights and deterministic seeds.
    • Simplified model target configuration by accepting model names directly.
  • Changes

    • Replaced the previous completion-based execution API with run_stream.
    • Updated examples and public exports to demonstrate the streaming workflow.

@nachiketb-nvidia
nachiketb-nvidia requested a review from a team as a code owner August 12, 2026 21:01
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1

🚀 View preview at
https://NVIDIA-NeMo.github.io/Switchyard/pr-preview/pr-392/

Built to branch gh-pages at 2026-08-12 21:10 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

The libsy Python bindings now use Rust-owned algorithms with asynchronous streaming. Python receives model calls, routing decisions, and terminal responses, then completes model calls through ModelCall. Targets no longer store Python clients.

Changes

libsy streaming API

Layer / File(s) Summary
Public streaming contract
switchyard_rust/libsy.py, switchyard/libsy/__init__.py
The API replaces LlmClient with ModelCall and Step. LlmTarget accepts only a model name. Algorithm.run_stream yields call, decision, and completion steps. random accepts optional weights and a seed.
Rust binding stream execution
crates/switchyard-py/Cargo.toml, crates/switchyard-py/src/libsy_bindings.rs
The bindings expose asynchronous stream iteration, model-call completion and failure, and client-free random, classifier, and stage-router construction.
Streaming consumers and validation
examples/libsy.py, tests/test_libsy_minimal_bindings.py
The example handles streamed steps. Tests use an external client map and validate streaming, headers, invalid requests, capacity limits, and client failures.

Estimated code review effort: 4 (Complex) | ~45 minutes

Poem

I hop through streams where model calls flow,
Decisions sparkle, then answers grow.
Rust keeps the route, Python lends a hand,
Each Step arrives just as planned.
Squeak, the bindings now run grand!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.88% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: exposing the libsy streaming API to Python.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
tests/test_libsy_minimal_bindings.py (2)

259-267: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for the ModelCall.fail guard paths.

PyModelCall::fail has two guards that no test exercises. It raises TypeError with "error must derive from BaseException" for a non-exception argument. It raises LibsyError with "model call has already been completed" for a second completion. The duplicate-completion path is covered for respond at line 90 but not for fail.

Add cases inside a Step.CallModel branch:

with pytest.raises(TypeError, match="must derive from BaseException"):
    call.fail("not an exception")
call.fail(RuntimeError("boom"))
with pytest.raises(LibsyError, match="already been completed"):
    call.fail(RuntimeError("boom"))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_libsy_minimal_bindings.py` around lines 259 - 267, Add coverage
for both PyModelCall.fail guards in the relevant Step.CallModel test branch:
assert that passing a non-BaseException raises TypeError with the expected
message, then complete the call with RuntimeError and assert a second fail
raises LibsyError indicating the model call is already completed.

62-69: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Give a clear failure when the test forgets to register a client.

Line 63 indexes clients outside the try block. If a test omits a client for the selected target, the helper raises a bare KeyError while the CallModel is still pending. The stream then drops and the algorithm task aborts, so the reported failure does not name the missing target.

Raise an explicit error instead.

♻️ Proposed refactor
             case Step.CallModel(call):
                 target = call.decision["selected_model_id"]
-                client = (clients or {})[target]
+                client = (clients or {}).get(target)
+                if client is None:
+                    raise AssertionError(f"no client registered for target {target!r}")
                 try:
                     response = await client.call(call.request)
                 except Exception as error:
                     call.fail(error)
                 else:
                     call.respond(response)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_libsy_minimal_bindings.py` around lines 62 - 69, Move the client
lookup in the call-handling flow into the existing try block so missing
registrations are captured before the CallModel remains pending. Convert a
missing target from the clients mapping into an explicit error that identifies
the selected target, then continue using call.fail(error) while preserving the
existing response path for registered clients.
crates/switchyard-py/src/libsy_bindings.rs (1)

164-181: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider exposing CallModel::algorithm on ModelCall.

PyModelCall::new keeps only call.request.llm_request and the decision. The upstream CallModel also carries algorithm, which exists so a host that instruments the calls it serves can attribute its own spans to the algorithm behind them. A Python host cannot read that field now.

Add an algorithm getter if host-side span attribution is a goal for this API.

♻️ Proposed addition
 struct PyModelCall {
     inner: Option<CallModel>,
+    algorithm: String,
     request: Py<PyAny>,
     decision: Py<PyAny>,
 }
    /// The name of the algorithm that produced this call.
    #[getter]
    fn algorithm(&self) -> &str {
        &self.algorithm
    }

Update switchyard_rust/libsy.py if you add the getter.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/switchyard-py/src/libsy_bindings.rs` around lines 164 - 181, Expose
the upstream CallModel algorithm through PyModelCall so Python hosts can
attribute spans to the producing algorithm. Preserve the value in
PyModelCall::new, add an algorithm getter returning its name, and update
switchyard_rust/libsy.py to expose the corresponding property.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@examples/libsy.py`:
- Around line 13-22: Update EchoClient to accept the selected model during
construction and have call return that stored model instead of the hardcoded
"echo" value. In the example’s routing flow, instantiate EchoClient with
decision.selected_model_id() so the final response matches the printed routing
decision, following the constructor pattern used by the test EchoClient(model).

In `@switchyard_rust/libsy.py`:
- Around line 19-20: Preserve a deprecation path for the removed public APIs: in
switchyard_rust/libsy.py at lines 19-20, document migration for LlmClient and
Algorithm.run or retain a deprecated LlmClient alias that raises
DeprecationWarning, and apply the same decision in switchyard/libsy/__init__.py
at lines 23-24 so both __all__ export surfaces remain consistent.

---

Nitpick comments:
In `@crates/switchyard-py/src/libsy_bindings.rs`:
- Around line 164-181: Expose the upstream CallModel algorithm through
PyModelCall so Python hosts can attribute spans to the producing algorithm.
Preserve the value in PyModelCall::new, add an algorithm getter returning its
name, and update switchyard_rust/libsy.py to expose the corresponding property.

In `@tests/test_libsy_minimal_bindings.py`:
- Around line 259-267: Add coverage for both PyModelCall.fail guards in the
relevant Step.CallModel test branch: assert that passing a non-BaseException
raises TypeError with the expected message, then complete the call with
RuntimeError and assert a second fail raises LibsyError indicating the model
call is already completed.
- Around line 62-69: Move the client lookup in the call-handling flow into the
existing try block so missing registrations are captured before the CallModel
remains pending. Convert a missing target from the clients mapping into an
explicit error that identifies the selected target, then continue using
call.fail(error) while preserving the existing response path for registered
clients.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: d74a9cd8-f85e-4341-9f8e-c171cfd63f94

📥 Commits

Reviewing files that changed from the base of the PR and between 0151621 and 008ae90.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock, !Cargo.lock
📒 Files selected for processing (6)
  • crates/switchyard-py/Cargo.toml
  • crates/switchyard-py/src/libsy_bindings.rs
  • examples/libsy.py
  • switchyard/libsy/__init__.py
  • switchyard_rust/libsy.py
  • tests/test_libsy_minimal_bindings.py

Comment thread examples/libsy.py
Comment on lines 13 to 22
class EchoClient:
"""Return its configured model as the completion."""

def __init__(self, model: str) -> None:
self.model = model
"""Return a fixed completion for any selected target."""

async def call(self, request: Mapping[str, object]) -> Mapping[str, object]:
return {
"model": self.model,
"model": "echo",
"outputs": [
{"role": "assistant", "content": [{"type": "text", "text": self.model}]}
{"role": "assistant", "content": [{"type": "text", "text": "Hello"}]}
],
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make EchoClient honor the routing decision.

EchoClient.call ignores call.decision and always returns "model": "echo". The example prints a decision that selects "fast" or "quality", then prints a final response whose model is "echo". The two outputs contradict each other.

The upstream CallModel documents that the caller must send the request to decision.selected_model_id(), because request["model"] still holds the original agent-supplied name. The example currently teaches the opposite. Pass the selected model into the client, as tests/test_libsy_minimal_bindings.py does with its EchoClient(model).

♻️ Proposed fix
 class EchoClient:
     """Return a fixed completion for any selected target."""
 
-    async def call(self, request: Mapping[str, object]) -> Mapping[str, object]:
+    async def call(
+        self, request: Mapping[str, object], model: str
+    ) -> Mapping[str, object]:
         return {
-            "model": "echo",
+            "model": model,
             "outputs": [
                 {"role": "assistant", "content": [{"type": "text", "text": "Hello"}]}
             ],
         }
             case Step.CallModel(call):
-                call.respond(await client.call(call.request))
+                model = call.decision["selected_model_id"]
+                call.respond(await client.call(call.request, model))

Also applies to: 45-46

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/libsy.py` around lines 13 - 22, Update EchoClient to accept the
selected model during construction and have call return that stored model
instead of the hardcoded "echo" value. In the example’s routing flow,
instantiate EchoClient with decision.selected_model_id() so the final response
matches the printed routing decision, following the constructor pattern used by
the test EchoClient(model).

Comment thread switchyard_rust/libsy.py
Comment on lines +19 to +20
"ModelCall",
"Step",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

LlmClient is removed from the public API without a deprecation plan. Both export declarations dropped the LlmClient symbol, and switchyard_rust/libsy.py also replaced Algorithm.run with run_stream. External code that imports LlmClient or calls Algorithm.run breaks with no deprecation signal.

  • switchyard_rust/libsy.py#L19-L20: record the removal of LlmClient and of Algorithm.run in a migration note, or keep a deprecated LlmClient alias that raises a DeprecationWarning.
  • switchyard/libsy/__init__.py#L23-L24: apply the same decision to the package __all__ so both export surfaces stay consistent.

As per coding guidelines: "Never do - Remove or rename public API exports without an explicit deprecation plan."

📍 Affects 2 files
  • switchyard_rust/libsy.py#L19-L20 (this comment)
  • switchyard/libsy/__init__.py#L23-L24
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@switchyard_rust/libsy.py` around lines 19 - 20, Preserve a deprecation path
for the removed public APIs: in switchyard_rust/libsy.py at lines 19-20,
document migration for LlmClient and Algorithm.run or retain a deprecated
LlmClient alias that raises DeprecationWarning, and apply the same decision in
switchyard/libsy/__init__.py at lines 23-24 so both __all__ export surfaces
remain consistent.

Source: Coding guidelines

Signed-off-by: nachiketb <nachiketb@nvidia.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant