Skip to content

feat(libsy): expose typed Python protocol bindings#88

Open
nachiketb-nvidia wants to merge 1 commit into
mainfrom
nachiketb/libsy-python-protocol-bindings
Open

feat(libsy): expose typed Python protocol bindings#88
nachiketb-nvidia wants to merge 1 commit into
mainfrom
nachiketb/libsy-python-protocol-bindings

Conversation

@nachiketb-nvidia

@nachiketb-nvidia nachiketb-nvidia commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Stack

  1. feat(libsy): expose typed Python protocol bindings #88 Protocol bindings (this PR)
  2. feat(libsy): expose Python target bindings #89 Target bindings
  3. feat(libsy): expose Python algorithm execution #87 Algorithm execution
  4. feat(libsy): expose Python run streams #82 Host-driven run streams

Review and merge this PR first.

Why

Python hosts need typed access to the neutral request and response values already owned by switchyard-protocol. Untyped dictionaries would duplicate the Rust model and lose structural matching at the language boundary.

What

  • Bind protocol request, response, content, tool, media, metadata, usage, and streaming chunk values.
  • Expose the types under switchyard.libsy.protocol.
  • Add lazy native-module facades and precise Python stubs.
  • Bridge Python async response sources into Rust LlmResponseStream values.
  • Add focused protocol and response-stream lifecycle tests.

How

PyO3 wrappers own or clone the Rust values and convert only raw JSON extension fields. Complex enums use nested Python variants for structural pattern matching.

What to review

  1. Fidelity of the protocol fields and enum variants.
  2. Ownership of aggregate versus streaming responses.
  3. Python async source cleanup and single-consumer behavior.
  4. Stub precision and namespace placement.

Validation

  • cargo fmt --all -- --check
  • cargo clippy -p switchyard-py --all-targets -- -D warnings
  • uv run pytest tests/test_libsy_protocol_bindings.py -v -o addopts= (8 passed)
  • uv run ruff check on the changed Python files

Summary by CodeRabbit

  • New Features

    • Added Python access to a typed, neutral LLM protocol.
    • Added support for structured requests, responses, content blocks, tools, metadata, and serialization.
    • Added aggregate and asynchronous streaming response handling.
    • Added a dedicated LibsyError exception for orchestration failures.
    • Added comprehensive type definitions and public exports for protocol values.
  • Tests

    • Added coverage for protocol typing, round-tripping, response variants, streaming, and stream cleanup.

Signed-off-by: nachiketb <nachiketb@nvidia.com>
@nachiketb-nvidia
nachiketb-nvidia force-pushed the nachiketb/libsy-python-protocol-bindings branch from b383288 to b7c5507 Compare July 17, 2026 18:18
@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Changes

Adds Rust/PyO3 bindings for the libsy protocol, typed Python exports, aggregate and streaming response wrappers, error registration, and tests covering serialization, variants, namespace behavior, and stream cleanup.

Libsy Python Protocol

Layer / File(s) Summary
Native module registration and errors
crates/switchyard-py/Cargo.toml, crates/switchyard-py/src/{errors.rs,lib.rs,libsy_bindings.rs}
Registers the protocol dependency and PyO3 submodule, and exports LibsyError.
Protocol model wrappers
crates/switchyard-py/src/libsy_bindings/protocol.rs
Adds typed wrappers, conversions, serialization, validation, and registration for protocol identifiers, content, tools, requests, responses, and chunks.
Request, response, and stream values
crates/switchyard-py/src/libsy_bindings/values.rs
Adds context, metadata, request, response, and single-consumption async stream bindings with source cleanup.
Typed Python public surface
switchyard/__init__.py, switchyard/libsy/*, switchyard_rust/libsy_protocol.*
Publishes libsy.protocol, adds lazy native exports, union aliases, and type stubs.
Protocol and stream validation
tests/test_libsy_protocol_bindings.py
Tests typed values, round-tripping, response variants, namespace boundaries, streaming, cleanup, and single-use behavior.

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

Poem

I’m a bunny with bindings, hopping in tune,
Rust sends little chunks beneath the moon.
Typed tools and messages neatly align,
Streams close softly, one hop at a time.
Libsy blooms bright in Python’s view—
Carrot-powered tests say, “All green too!”

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: exposing typed Python protocol bindings under libsy.
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.

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: 5

🧹 Nitpick comments (1)
tests/test_libsy_protocol_bindings.py (1)

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

Consider using AsyncGenerator instead of AsyncIterator for _chunks.

self._chunks.aclose() is called on line 199, but AsyncIterator doesn't declare aclose() — that method belongs to AsyncGenerator. Using the more precise type avoids type-checker errors and correctly documents the capability.

♻️ Proposed type annotation fix
 from collections.abc import AsyncIterator
+from collections.abc import AsyncGenerator

 class _ChunkSource:
     def __init__(self) -> None:
-        self._chunks: AsyncIterator[libsy.protocol.LlmResponseChunkValue] = self._iterate()
+        self._chunks: AsyncGenerator[libsy.protocol.LlmResponseChunkValue, None] = self._iterate()
         self.closed = False

-    async def _iterate(self) -> AsyncIterator[libsy.protocol.LlmResponseChunkValue]:
+    async def _iterate(self) -> AsyncGenerator[libsy.protocol.LlmResponseChunkValue, None]:
         yield libsy.protocol.LlmResponseChunk.MessageStart("response-1", "selected-model")
🤖 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_protocol_bindings.py` around lines 181 - 199, Update the
_ChunkSource._chunks annotation from AsyncIterator to AsyncGenerator, matching
the _iterate() implementation and its use of aclose() in aclose(). Preserve the
existing iteration behavior and generator element type.
🤖 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 `@crates/switchyard-py/src/libsy_bindings/protocol.rs`:
- Around line 38-53: Document the complete Python-visible Rust API: in
crates/switchyard-py/src/libsy_bindings/protocol.rs:38-53, add concise
item-level documentation for every exported protocol PyO3 class, including
PyWireFormat; in crates/switchyard-py/src/libsy_bindings/values.rs:29-38,
document every exported value wrapper; in
crates/switchyard-py/src/libsy_bindings/values.rs:375-440, document stream
single-consumption, closure, and concurrency invariants around the relevant
stream types and methods; and in crates/switchyard-py/src/errors.rs:13-13,
document the exception’s purpose and hierarchy.

In `@crates/switchyard-py/src/libsy_bindings/values.rs`:
- Around line 275-294: Update the is_streaming and selected_model getters to
detect the consumed response state after the stored LlmResponse has been taken
by stream, and return the existing “already consumed” PyRuntimeError instead of
false or None. Reuse the established consumed-state check and preserve the
current lock-poisoning behavior and normal response results.
- Around line 571-579: Update SourceClosingLlmStream to capture and store a
Tokio runtime Handle during construction, then use that stored handle to spawn
the stream-close future instead of calling Handle::try_current() in the
drop/completion path. Mirror core_bindings/response.rs and add the requested
warning fallback when no runtime handle was captured, ensuring the aclose
coroutine is polled whenever a handle is available.

In `@switchyard_rust/libsy_protocol.py`:
- Around line 12-44: Keep the lazy facade and type stub exports synchronized: in
switchyard_rust/libsy_protocol.py (lines 12-44), expose the five aliases
ImageSourceValue, FileSourceValue, MediaSourceValue, ContentBlockValue, and
LlmResponseChunkValue, or explicitly remove them from the public stub; in
switchyard_rust/libsy_protocol.pyi, align the declarations at lines 60, 79, 99,
162-173, and 415-423 with that choice so explicit imports remain type- and
runtime-correct.

In `@switchyard/__init__.py`:
- Line 14: Update switchyard/__init__.py to re-export every newly introduced
public protocol class alongside libsy, and add each class to the module’s
__all__ so they are available from the switchyard package root.

---

Nitpick comments:
In `@tests/test_libsy_protocol_bindings.py`:
- Around line 181-199: Update the _ChunkSource._chunks annotation from
AsyncIterator to AsyncGenerator, matching the _iterate() implementation and its
use of aclose() in aclose(). Preserve the existing iteration behavior and
generator element type.
🪄 Autofix (Beta)

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: 03b97e98-5b92-4b4b-a6cd-82f51ab3c070

📥 Commits

Reviewing files that changed from the base of the PR and between 93f0e3e and b383288.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock, !Cargo.lock
📒 Files selected for processing (12)
  • crates/switchyard-py/Cargo.toml
  • crates/switchyard-py/src/errors.rs
  • crates/switchyard-py/src/lib.rs
  • crates/switchyard-py/src/libsy_bindings.rs
  • crates/switchyard-py/src/libsy_bindings/protocol.rs
  • crates/switchyard-py/src/libsy_bindings/values.rs
  • switchyard/__init__.py
  • switchyard/libsy/__init__.py
  • switchyard/libsy/protocol.py
  • switchyard_rust/libsy_protocol.py
  • switchyard_rust/libsy_protocol.pyi
  • tests/test_libsy_protocol_bindings.py

Comment on lines +38 to +53
#[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,
}

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 | 🏗️ Heavy lift

Document the complete Python-visible Rust API. The new binding layer has module documentation but lacks item-level behavior and invariant documentation.

  • crates/switchyard-py/src/libsy_bindings/protocol.rs#L38-L53: document every exported protocol PyO3 class.
  • crates/switchyard-py/src/libsy_bindings/values.rs#L29-L38: document every exported value wrapper.
  • crates/switchyard-py/src/libsy_bindings/values.rs#L375-L440: document stream single-consumption, closure, and concurrency invariants.
  • crates/switchyard-py/src/errors.rs#L13-L13: document the exception’s purpose and hierarchy.

As per coding guidelines, public Rust items and complex async/lifecycle logic require concise documentation.

📍 Affects 3 files
  • crates/switchyard-py/src/libsy_bindings/protocol.rs#L38-L53 (this comment)
  • crates/switchyard-py/src/libsy_bindings/values.rs#L29-L38
  • crates/switchyard-py/src/libsy_bindings/values.rs#L375-L440
  • crates/switchyard-py/src/errors.rs#L13-L13
🤖 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/protocol.rs` around lines 38 - 53,
Document the complete Python-visible Rust API: in
crates/switchyard-py/src/libsy_bindings/protocol.rs:38-53, add concise
item-level documentation for every exported protocol PyO3 class, including
PyWireFormat; in crates/switchyard-py/src/libsy_bindings/values.rs:29-38,
document every exported value wrapper; in
crates/switchyard-py/src/libsy_bindings/values.rs:375-440, document stream
single-consumption, closure, and concurrency invariants around the relevant
stream types and methods; and in crates/switchyard-py/src/errors.rs:13-13,
document the exception’s purpose and hierarchy.

Source: Coding guidelines

Comment on lines +275 to +294
#[getter]
fn is_streaming(&self) -> PyResult<bool> {
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<Option<String>> {
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))
}

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

Do not silently misclassify a consumed response.

After .stream takes the value, is_streaming returns False and selected_model returns None. Both look valid but represent consumed state; raise the existing “already consumed” error instead.

Proposed fix
-        Ok(matches!(response.as_ref(), Some(LlmResponse::Stream(_))))
+        match response.as_ref() {
+            Some(LlmResponse::Stream(_)) => Ok(true),
+            Some(LlmResponse::Agg(_)) => Ok(false),
+            None => Err(PyRuntimeError::new_err(
+                "Response has already been consumed",
+            )),
+        }

Apply the equivalent consumed-state check before calling selected_model.

🤖 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/values.rs` around lines 275 - 294,
Update the is_streaming and selected_model getters to detect the consumed
response state after the stored LlmResponse has been taken by stream, and return
the existing “already consumed” PyRuntimeError instead of false or None. Reuse
the established consumed-state check and preserve the current lock-poisoning
behavior and normal response results.

Comment on lines +571 to +579
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");
}
});
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm dependency versions and locate established configured-runtime patterns.
rg -n -C3 'pyo3-async-runtimes|tokio' Cargo.lock crates/switchyard-py/Cargo.toml
rg -n -C4 'get_runtime|Handle::try_current|future_into_py|into_future' crates/switchyard-py/src

Repository: NVIDIA-NeMo/Switchyard

Length of output: 45802


🏁 Script executed:

#!/bin/bash
set -euo pipefail

ast-grep outline crates/switchyard-py/src/libsy_bindings/values.rs --view expanded

printf '\n--- relevant range ---\n'
sed -n '520,610p' crates/switchyard-py/src/libsy_bindings/values.rs

printf '\n--- surrounding types ---\n'
sed -n '1,220p' crates/switchyard-py/src/libsy_bindings/values.rs

Repository: NVIDIA-NeMo/Switchyard

Length of output: 12602


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n 'runtime: Option<tokio::runtime::Handle>|Handle::try_current|runtime\.spawn|self\.runtime|try_current\(\)\.ok\(\)' crates/switchyard-py/src/libsy_bindings/values.rs

Repository: NVIDIA-NeMo/Switchyard

Length of output: 284


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '250,290p' crates/switchyard-py/src/libsy_bindings/values.rs

Repository: NVIDIA-NeMo/Switchyard

Length of output: 1497


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '220,290p' crates/switchyard-py/src/libsy_bindings/values.rs

Repository: NVIDIA-NeMo/Switchyard

Length of output: 2315


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '618,710p' crates/switchyard-py/src/core_bindings/response.rs

Repository: NVIDIA-NeMo/Switchyard

Length of output: 4010


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '440,500p' crates/switchyard-py/src/libsy_bindings/values.rs

Repository: NVIDIA-NeMo/Switchyard

Length of output: 1734


Use a captured runtime handle for stream-source close. Handle::try_current() can fail when SourceClosingLlmStream is dropped or completes on a non-Tokio Python thread, so the aclose coroutine is created and then never polled, leaving the source open. Mirror core_bindings/response.rs by storing the runtime handle at construction and spawning there, with a warning fallback when no handle exists.

🤖 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/values.rs` around lines 571 - 579,
Update SourceClosingLlmStream to capture and store a Tokio runtime Handle during
construction, then use that stored handle to spawn the stream-close future
instead of calling Handle::try_current() in the drop/completion path. Mirror
core_bindings/response.rs and add the requested warning fallback when no runtime
handle was captured, ensuring the aclose coroutine is polled whenever a handle
is available.

Comment on lines +12 to +44
_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",
}
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep the lazy facade and stub exports identical. Five aliases exist only in the stub, so type-correct explicit imports fail at runtime.

  • switchyard_rust/libsy_protocol.py#L12-L44: lazily expose the five aliases or intentionally exclude them from the public stub.
  • switchyard_rust/libsy_protocol.pyi#L60-L60: align ImageSourceValue.
  • switchyard_rust/libsy_protocol.pyi#L79-L79: align FileSourceValue.
  • switchyard_rust/libsy_protocol.pyi#L99-L99: align MediaSourceValue.
  • switchyard_rust/libsy_protocol.pyi#L162-L173: align ContentBlockValue.
  • switchyard_rust/libsy_protocol.pyi#L415-L423: align LlmResponseChunkValue.
📍 Affects 2 files
  • switchyard_rust/libsy_protocol.py#L12-L44 (this comment)
  • switchyard_rust/libsy_protocol.pyi#L60-L60
  • switchyard_rust/libsy_protocol.pyi#L79-L79
  • switchyard_rust/libsy_protocol.pyi#L99-L99
  • switchyard_rust/libsy_protocol.pyi#L162-L173
  • switchyard_rust/libsy_protocol.pyi#L415-L423
🤖 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_protocol.py` around lines 12 - 44, Keep the lazy facade
and type stub exports synchronized: in switchyard_rust/libsy_protocol.py (lines
12-44), expose the five aliases ImageSourceValue, FileSourceValue,
MediaSourceValue, ContentBlockValue, and LlmResponseChunkValue, or explicitly
remove them from the public stub; in switchyard_rust/libsy_protocol.pyi, align
the declarations at lines 60, 79, 99, 162-173, and 415-423 with that choice so
explicit imports remain type- and runtime-correct.

Comment thread switchyard/__init__.py

from typing import TYPE_CHECKING, Any

from switchyard import libsy as libsy

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 | 🏗️ Heavy lift

Re-export the new public protocol classes.

Exporting only libsy leaves all newly introduced public classes absent from switchyard and its __all__.

As per coding guidelines, switchyard/__init__.py must export every new public class and include it in __all__.

Also applies to: 143-144

🤖 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/__init__.py` at line 14, Update switchyard/__init__.py to
re-export every newly introduced public protocol class alongside libsy, and add
each class to the module’s __all__ so they are available from the switchyard
package root.

Source: Coding guidelines

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