From 467fd38730d2ca1ebaefbfcdf2bf38ea1b84bd58 Mon Sep 17 00:00:00 2001 From: rbehal Date: Tue, 11 Aug 2026 20:10:18 +0000 Subject: [PATCH 1/3] build: target py3.10, rename the dist, split provider-SDK deps into the lib extra - pyo3 abi3-py312 -> abi3-py310: one cp310-abi3 wheel serves CPython 3.10+ - dist renamed nemo-switchyard -> gumloop-nemo-switchyard (mirrors gumloop-celery) - upstream core deps (openai/anthropic/httpx/pydantic) move to the new 'lib' extra so embedding hosts don't inherit provider-SDK floors; the bindings surface (switchyard_rust.libsy) needs none of them, and its import chain defers the translation module that pulls openai types - the supported 3.10 surface is the bindings; the full package (lib extra) keeps upstream's 3.12 expectations Co-authored-by: Cursor --- crates/switchyard-py/Cargo.toml | 3 ++- pyproject.toml | 26 +++++++++++++++++++------- switchyard_rust/__init__.py | 15 ++++++++++----- 3 files changed, 31 insertions(+), 13 deletions(-) diff --git a/crates/switchyard-py/Cargo.toml b/crates/switchyard-py/Cargo.toml index 378e1517a..f5915b75c 100644 --- a/crates/switchyard-py/Cargo.toml +++ b/crates/switchyard-py/Cargo.toml @@ -21,7 +21,8 @@ futures-util.workspace = true http.workspace = true parking_lot.workspace = true switchyard-libsy.workspace = true -pyo3 = { version = "0.28.3", features = ["abi3-py312", "extension-module"] } +# Gumloop fork: target the py3.10 stable ABI so one wheel serves CPython 3.10+. +pyo3 = { version = "0.28.3", features = ["abi3-py310", "extension-module"] } pyo3-async-runtimes = { version = "0.28", features = ["tokio-runtime"] } pythonize = "0.28.0" serde.workspace = true diff --git a/pyproject.toml b/pyproject.toml index f39163c35..362303712 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,12 +3,16 @@ requires = ["maturin>=1.9,<2.0"] build-backend = "maturin" [project] -name = "nemo-switchyard" -version = "0.2.0" +# Gumloop fork: dist renamed (mirrors gumloop-celery); import names are unchanged. +name = "gumloop-nemo-switchyard" +version = "0.2.0+gumloop.0.1.1" description = "Typed, composable LLM routing with request/response translation and multi-backend orchestration" readme = "README.md" license = "Apache-2.0" -requires-python = ">=3.12" +# Gumloop fork: cp310-abi3 wheel (see crates/switchyard-py/Cargo.toml). The supported +# 3.10 surface is the bindings (switchyard_rust.libsy); the full package needs the +# `lib` extra and keeps upstream's 3.12 expectations. +requires-python = ">=3.10" authors = [{ name = "NVIDIA Corporation" }] maintainers = [{ name = "NVIDIA Corporation" }] keywords = ["llm", "switchyard", "routing", "openai", "nemo"] @@ -30,7 +34,14 @@ classifiers = [ # pip install nemo-switchyard[server] # Add FastAPI/Uvicorn for e2e # pip install nemo-switchyard[cli] # Add prompt-toolkit for CLI # pip install nemo-switchyard[all] # Everything -dependencies = [ +# Gumloop fork: the upstream core deps moved to the `lib` extra so embedding hosts +# consuming only the bindings do not inherit provider-SDK floors they pin differently. +dependencies = [] + +[project.optional-dependencies] +# Gumloop fork: upstream's core dependencies; required by the full `switchyard` +# package (proxy, profiles, provider clients) but not by `switchyard_rust.libsy`. +lib = [ # openai: request/response schema types, plus the async client in lib/llm_client.py. # Keep this floor low enough for downstream consumers to co-install us; NeMo Gym pins # openai<=2.7.2. The suite passes unchanged from 2.7.0 through 2.48.0. @@ -40,10 +51,10 @@ dependencies = [ "pydantic>=2.13.3,<3.0", ] -[project.optional-dependencies] # Server dependencies — FastAPI + Uvicorn for e2e users who want to run # switchyard as a proxy. Not needed for library-only usage. server = [ + "gumloop-nemo-switchyard[lib]", "fastapi>=0.136.1,<1.0", "uvicorn[standard]>=0.46.0,<1.0", "sse-starlette>=3.4.1,<4.0", @@ -52,6 +63,7 @@ server = [ # CLI dependencies — prompt-toolkit for Claude Code launcher and ShellTUI. # Only needed for users running the switchyard CLI. cli = [ + "gumloop-nemo-switchyard[lib]", "prompt-toolkit>=3.0.52,<4.0", ] @@ -74,7 +86,7 @@ affinity-redis = [ # Everything — all optional dependencies for full-featured deployment. all = [ - "nemo-switchyard[server,cli,tracing,affinity-redis]", + "gumloop-nemo-switchyard[lib,server,cli,tracing,affinity-redis]", ] # Dev tooling lives in a PEP 735 dependency group rather than an optional @@ -103,7 +115,7 @@ dev = [ # Harbor is needed for local evaluation runs and supports the package floor. # Package users still do not see it in published metadata. "harbor @ git+https://github.com/harbor-framework/harbor.git@v0.6.4 ; python_version >= '3.12'", - "nemo-switchyard[server]", + "gumloop-nemo-switchyard[server]", "pytest-markdown-docs>=0.9.2", ] docs = [ diff --git a/switchyard_rust/__init__.py b/switchyard_rust/__init__.py index 2603ef237..c3da26383 100644 --- a/switchyard_rust/__init__.py +++ b/switchyard_rust/__init__.py @@ -7,12 +7,11 @@ from typing import TYPE_CHECKING -from switchyard_rust.translation import ( - TranslationEngine, - is_native_translation_available, -) - if TYPE_CHECKING: + from switchyard_rust.translation import TranslationEngine as TranslationEngine + from switchyard_rust.translation import ( + is_native_translation_available as is_native_translation_available, + ) from switchyard_rust.components import AnthropicNativeBackend as AnthropicNativeBackend from switchyard_rust.components import BackendFormat as BackendFormat from switchyard_rust.components import EndpointConfig as EndpointConfig @@ -52,6 +51,12 @@ def __getattr__(name: str) -> object: + # Gumloop fork: translation pulls provider SDK types, so it resolves lazily and + # the bindings-only surface (switchyard_rust.libsy) stays importable without them. + if name in {"TranslationEngine", "is_native_translation_available"}: + from switchyard_rust import translation + + return getattr(translation, name) if name in { "AnthropicNativeBackend", "BackendFormat", From a7029d083985e7c4707f696a8e644b4c54b03091 Mon Sep 17 00:00:00 2001 From: rbehal Date: Tue, 11 Aug 2026 20:10:18 +0000 Subject: [PATCH 2/3] feat(python): expose the custom N-target classifier to Python Binds LlmClassifierConfig::Custom (CustomClassifierConfig + TargetSelector) as custom_classifier(judge_target, targets, *, default_target, config): schema-driven routing across two or more labeled targets; unusable verdicts and judge failures fall open to default_target. Filed upstream as NVIDIA-NeMo/Switchyard#365. Co-authored-by: Cursor --- crates/switchyard-py/src/libsy_bindings.rs | 111 +++++++++++++- switchyard_rust/libsy.py | 24 +++ tests/test_libsy_custom_classifier.py | 161 +++++++++++++++++++++ 3 files changed, 293 insertions(+), 3 deletions(-) create mode 100644 tests/test_libsy_custom_classifier.py diff --git a/crates/switchyard-py/src/libsy_bindings.rs b/crates/switchyard-py/src/libsy_bindings.rs index 7f13746ad..0bed73a79 100644 --- a/crates/switchyard-py/src/libsy_bindings.rs +++ b/crates/switchyard-py/src/libsy_bindings.rs @@ -10,9 +10,10 @@ use pyo3::exceptions::{PyTypeError, PyValueError}; use pyo3::prelude::*; use serde_json::{Value, json}; use switchyard_libsy::{ - Algorithm, ClassifierContractConfig, HandoffNoteConfig, LibsyError as RustLibsyError, - LlmClassifierConfig, LlmFallback, LlmTarget, LlmTargetSet, LlmTaskClassifier, Noop, PickerMode, - Random, StageRouter, StageRouterConfig, TaskClassifierConfig, + Algorithm, ClassifierContractConfig, CustomClassifierConfig, CustomClassifierPolicy, + HandoffNoteConfig, LibsyError as RustLibsyError, LlmClassifierConfig, LlmFallback, LlmTarget, + LlmTargetSet, LlmTaskClassifier, Noop, PickerMode, Random, StageRouter, StageRouterConfig, + TaskClassifierConfig, }; use switchyard_protocol::{ AggLlmResponse, Context, Decision, LlmClientError, LlmResponse, Metadata, Request, Response, @@ -157,6 +158,74 @@ impl PyTaskClassifierConfig { } } +/// Settings for a custom-schema classifier: a user-supplied prompt, an inner JSON +/// Schema for the verdict, and a JSON Pointer selecting the target label from it. +#[pyclass( + name = "CustomClassifierConfig", + module = "switchyard.libsy", + frozen, + skip_from_py_object +)] +struct PyCustomClassifierConfig { + prompt: String, + response_schema: Value, + selector: String, + session_affinity: bool, + message_hash_fallback: bool, + recent_turn_window: Option, + max_output_tokens: u64, +} + +impl PyCustomClassifierConfig { + fn clone_core(&self) -> CustomClassifierConfig { + CustomClassifierConfig { + prompt: self.prompt.clone(), + response_schema: self.response_schema.clone(), + policy: CustomClassifierPolicy::target_selector(self.selector.clone()), + session_affinity: self.session_affinity, + message_hash_fallback: self.message_hash_fallback, + recent_turn_window: self.recent_turn_window, + max_output_tokens: self.max_output_tokens, + } + } +} + +#[pymethods] +impl PyCustomClassifierConfig { + #[new] + #[pyo3(signature = ( + prompt, + response_schema, + selector, + *, + session_affinity=false, + message_hash_fallback=false, + recent_turn_window=None, + max_output_tokens=4096 + ))] + #[allow(clippy::too_many_arguments)] + fn new( + prompt: String, + response_schema: &Bound<'_, PyAny>, + selector: String, + session_affinity: bool, + message_hash_fallback: bool, + recent_turn_window: Option, + max_output_tokens: u64, + ) -> PyResult { + let response_schema: Value = from_python(response_schema)?; + Ok(Self { + prompt, + response_schema, + selector, + session_affinity, + message_hash_fallback, + recent_turn_window, + max_output_tokens, + }) + } +} + /// Judge target and policy used when stage-router signals are inconclusive. #[pyclass( name = "LlmFallback", @@ -308,6 +377,40 @@ fn llm_task_classifier_algorithm( Ok(PyAlgorithm::new(Arc::new(algorithm))) } +/// Construct schema-driven classifier routing across two or more named targets. +/// +/// `targets` pairs each user-facing label with its routing target; the judge's +/// schema-validated verdict selects a label through the config's JSON Pointer, +/// and `default_target` is used when the judge does not produce a usable verdict. +#[pyfunction(name = "custom_classifier")] +#[pyo3(signature = ( + judge_target, + targets, + *, + default_target, + config +))] +fn custom_classifier_algorithm( + py: Python<'_>, + judge_target: Py, + targets: Vec<(String, Py)>, + default_target: String, + config: Py, +) -> PyResult { + let targets = targets + .iter() + .map(|(label, target)| Ok((label.clone(), target.bind(py).try_borrow()?.clone_core(py)))) + .collect::>>()?; + let algorithm = LlmTaskClassifier::new(LlmClassifierConfig::Custom { + judge_target: judge_target.bind(py).try_borrow()?.clone_core(py), + targets, + default_target, + config: config.bind(py).try_borrow()?.clone_core(), + }) + .map_err(|error| PyValueError::new_err(error.to_string()))?; + Ok(PyAlgorithm::new(Arc::new(algorithm))) +} + /// Construct signal-driven stage routing with an optional LLM classifier fallback. #[pyfunction(name = "stage_router")] #[pyo3(signature = ( @@ -399,6 +502,7 @@ fn invalid_python_response(error: PyErr) -> LlmClientError { 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_class::()?; libsy_module.add_class::()?; libsy_module.add_class::()?; @@ -408,6 +512,7 @@ pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> { llm_task_classifier_algorithm, &libsy_module )?)?; + libsy_module.add_function(wrap_pyfunction!(custom_classifier_algorithm, &libsy_module)?)?; libsy_module.add_function(wrap_pyfunction!(stage_router_algorithm, &libsy_module)?)?; libsy_module.add("LibsyError", module.getattr("LibsyError")?)?; module.add_submodule(&libsy_module)?; diff --git a/switchyard_rust/libsy.py b/switchyard_rust/libsy.py index dd01ed5d4..baa66bcad 100644 --- a/switchyard_rust/libsy.py +++ b/switchyard_rust/libsy.py @@ -13,10 +13,12 @@ _EXPORTS = frozenset( { "Algorithm", + "CustomClassifierConfig", "LibsyError", "LlmFallback", "LlmTarget", "TaskClassifierConfig", + "custom_classifier", "llm_task_classifier", "noop", "random", @@ -65,6 +67,20 @@ def __init__( prompt: str | None = None, ) -> None: ... + @final + class CustomClassifierConfig: + def __init__( + self, + prompt: str, + response_schema: Mapping[str, object], + selector: str, + *, + session_affinity: bool = False, + message_hash_fallback: bool = False, + recent_turn_window: int | None = None, + max_output_tokens: int = 4096, + ) -> None: ... + @final class LlmFallback: def __init__( @@ -94,6 +110,14 @@ def llm_task_classifier( config: TaskClassifierConfig, ) -> Algorithm: ... + def custom_classifier( + judge_target: LlmTarget, + targets: Sequence[tuple[str, LlmTarget]], + *, + default_target: str, + config: CustomClassifierConfig, + ) -> Algorithm: ... + def stage_router( capable_target: LlmTarget, efficient_target: LlmTarget, diff --git a/tests/test_libsy_custom_classifier.py b/tests/test_libsy_custom_classifier.py new file mode 100644 index 000000000..87d479cab --- /dev/null +++ b/tests/test_libsy_custom_classifier.py @@ -0,0 +1,161 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for the custom N-target classifier Python binding. + +Imports go through ``switchyard_rust.libsy`` — the bindings-only surface that must +stay importable without the ``lib`` extra's provider SDKs. +""" + +from typing import Any + +import pytest + +from switchyard_rust.libsy import CustomClassifierConfig, LlmTarget, custom_classifier + +LANES = ("grok", "luna", "flash", "sol", "opus") + +SCHEMA: dict[str, Any] = { + "type": "object", + "additionalProperties": False, + "required": ["model"], + "properties": {"model": {"type": "string", "enum": list(LANES)}}, +} + + +def request_body() -> dict[str, Any]: + return { + "model": "auto", + "messages": [ + { + "role": "user", + "content": [{"type": "text", "text": "summarize this document"}], + } + ], + } + + +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", + } + ], + } + + +class VerdictClient(EchoClient): + def __init__(self, model: str, verdict: str) -> None: + super().__init__(model) + self.verdict = verdict + + 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.verdict}], + "stop_reason": "end_turn", + } + ], + } + + +class FailingClient: + async def call(self, request: dict[str, Any]) -> dict[str, Any]: + raise RuntimeError("judge unavailable") + + +def build(judge_client: Any, clients: dict[str, EchoClient]): + return custom_classifier( + LlmTarget("judge", judge_client), + [(lane, LlmTarget(lane, clients[lane])) for lane in LANES], + default_target="grok", + config=CustomClassifierConfig( + "Pick the best lane.", + SCHEMA, + "/model", + ), + ) + + +def lane_clients() -> dict[str, EchoClient]: + return {lane: EchoClient(lane) for lane in LANES} + + +@pytest.mark.parametrize("lane", LANES) +async def test_verdict_routes_each_lane(lane: str) -> None: + clients = lane_clients() + judge = VerdictClient("judge", f'{{"model":"{lane}"}}') + algorithm = build(judge, clients) + + decisions, response = await algorithm.run(request_body()) + + assert response["model"] == lane + assert clients[lane].calls, "selected lane's client must serve the answer call" + assert decisions[-1]["selected_model"] == lane + + +async def test_judge_receives_prompt_and_inner_schema() -> None: + clients = lane_clients() + judge = VerdictClient("judge", '{"model":"luna"}') + algorithm = build(judge, clients) + + await algorithm.run(request_body()) + + judge_request = judge.calls[0] + assert judge_request["instructions"][0]["content"][0]["text"] == "Pick the best lane." + schema = judge_request["output"]["response_format"]["json_schema"]["schema"] + assert schema["properties"]["model"]["enum"] == list(LANES) + + +async def test_judge_failure_falls_open_to_the_default_target() -> None: + clients = lane_clients() + algorithm = build(FailingClient(), clients) + + _, response = await algorithm.run(request_body()) + + assert response["model"] == "grok" + + +async def test_unusable_verdict_falls_open_to_the_default_target() -> None: + clients = lane_clients() + judge = VerdictClient("judge", '{"model":"a-lane-that-does-not-exist"}') + algorithm = build(judge, clients) + + _, response = await algorithm.run(request_body()) + + assert response["model"] == "grok" + + +def test_default_target_must_be_a_configured_label() -> None: + clients = lane_clients() + with pytest.raises(ValueError, match="default_target"): + custom_classifier( + LlmTarget("judge", EchoClient("judge")), + [(lane, LlmTarget(lane, clients[lane])) for lane in LANES], + default_target="not-a-lane", + config=CustomClassifierConfig("Pick.", SCHEMA, "/model"), + ) + + +def test_requires_at_least_two_targets() -> None: + with pytest.raises(ValueError, match="at least two targets"): + custom_classifier( + LlmTarget("judge", EchoClient("judge")), + [("grok", LlmTarget("grok", EchoClient("grok")))], + default_target="grok", + config=CustomClassifierConfig("Pick.", SCHEMA, "/model"), + ) From c8c5181ad48d08f72a086daa14c187837e2d2f2b Mon Sep 17 00:00:00 2001 From: rbehal Date: Tue, 11 Aug 2026 20:10:18 +0000 Subject: [PATCH 3/3] ci: build, test, and publish wheels to the Gumloop Artifact Registry maturin build in the manylinux_2_28 container. Pull requests build the wheel and run the binding tests on Python 3.10; gumloop-v* tags and manual dispatches additionally publish, authenticated keylessly via WIF (vars.GCP_WIF_PROVIDER impersonating agenthub-github-ci-cd). Republished tags skip wheels already in the simple index. Co-authored-by: Cursor --- .github/workflows/gumloop-publish.yml | 90 +++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 .github/workflows/gumloop-publish.yml diff --git a/.github/workflows/gumloop-publish.yml b/.github/workflows/gumloop-publish.yml new file mode 100644 index 000000000..d4f8aa686 --- /dev/null +++ b/.github/workflows/gumloop-publish.yml @@ -0,0 +1,90 @@ +# Gumloop fork: build the cp310-abi3 wheel and publish it to the Gumloop +# Python Artifact Registry (the same registry gumloop-celery ships from). +# +# Trigger by pushing a tag like `gumloop-v0.2.0+gumloop.0.1.1` (tag must match +# the version in pyproject.toml) or manually via workflow_dispatch. +# +# Auth is keyless (Workload Identity Federation): the repo variable +# GCP_WIF_PROVIDER holds the provider name emitted by the backend Terraform +# output `switchyard_wif_provider` (module `switchyard_wif`), which trusts +# gumloop-v* tags and gumloop-main and impersonates the agenthub-github-ci-cd +# service account (Artifact Registry writer via AgentHubGitHubCICD). + +name: gumloop-publish + +# Pull requests build the wheel and run the binding tests; publishing runs only +# for gumloop-v* tags and manual dispatches (the WIF provider trusts only those refs). +on: + push: + tags: + - "gumloop-v*" + pull_request: {} + workflow_dispatch: {} + +jobs: + build-and-publish: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - uses: actions/checkout@v4 + + # manylinux_2_28 keeps the wheel installable on the backend's Debian-based + # images; building on the bare runner would tag the runner's glibc instead. + - name: Build cp310-abi3 wheel + uses: PyO3/maturin-action@v1 + with: + command: build + args: --release --interpreter python3.10 + manylinux: 2_28 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + # Runs from a neutral directory so the source tree can't shadow the wheel. + - name: Test the bindings surface on the built wheel + run: | + python3.10 -m pip install --quiet target/wheels/*.whl pytest pytest-asyncio + cp tests/test_libsy_custom_classifier.py "$RUNNER_TEMP/" + cd "$RUNNER_TEMP" + python3.10 -m pytest test_libsy_custom_classifier.py -q -o asyncio_mode=auto + + - name: Authenticate to Google Cloud + if: github.event_name != 'pull_request' + id: auth + uses: google-github-actions/auth@v2 + with: + workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }} + service_account: agenthub-github-ci-cd@agenthub-dev.iam.gserviceaccount.com + token_format: access_token + + - name: Publish to Artifact Registry + if: github.event_name != 'pull_request' + env: + ACCESS_TOKEN: ${{ steps.auth.outputs.access_token }} + run: | + python3 -m pip install --quiet twine + # Artifact Registry rejects twine's --skip-existing capability probe, so + # republished tags stay idempotent by checking the simple index instead. + EXISTING="$(curl -sf -u "oauth2accesstoken:${ACCESS_TOKEN}" \ + https://us-west1-python.pkg.dev/agenthub-dev/gumloop/simple/gumloop-nemo-switchyard/ || true)" + for wheel in target/wheels/*.whl; do + if printf '%s' "$EXISTING" | grep -qF "$(basename "$wheel")"; then + echo "already published, skipping: $(basename "$wheel")" + else + python3 -m twine upload \ + --repository-url https://us-west1-python.pkg.dev/agenthub-dev/gumloop/ \ + --username oauth2accesstoken \ + --password "${ACCESS_TOKEN}" \ + "$wheel" + fi + done + + - name: Upload wheel artifact + uses: actions/upload-artifact@v4 + with: + name: wheels + path: target/wheels/*.whl