Skip to content
Merged
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
90 changes: 90 additions & 0 deletions .github/workflows/gumloop-publish.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion crates/switchyard-py/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
111 changes: 108 additions & 3 deletions crates/switchyard-py/src/libsy_bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<usize>,
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<usize>,
max_output_tokens: u64,
) -> PyResult<Self> {
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",
Expand Down Expand Up @@ -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<PyLlmTarget>,
targets: Vec<(String, Py<PyLlmTarget>)>,
default_target: String,
config: Py<PyCustomClassifierConfig>,
) -> PyResult<PyAlgorithm> {
let targets = targets
.iter()
.map(|(label, target)| Ok((label.clone(), target.bind(py).try_borrow()?.clone_core(py))))
.collect::<PyResult<Vec<_>>>()?;
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 = (
Expand Down Expand Up @@ -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::<PyAlgorithm>()?;
libsy_module.add_class::<PyCustomClassifierConfig>()?;
libsy_module.add_class::<PyLlmFallback>()?;
libsy_module.add_class::<PyLlmTarget>()?;
libsy_module.add_class::<PyTaskClassifierConfig>()?;
Expand All @@ -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)?;
Expand Down
26 changes: 19 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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.
Expand All @@ -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",
Expand All @@ -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",
]

Expand All @@ -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
Expand Down Expand Up @@ -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 = [
Expand Down
15 changes: 10 additions & 5 deletions switchyard_rust/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
24 changes: 24 additions & 0 deletions switchyard_rust/libsy.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@
_EXPORTS = frozenset(
{
"Algorithm",
"CustomClassifierConfig",
"LibsyError",
"LlmFallback",
"LlmTarget",
"TaskClassifierConfig",
"custom_classifier",
"llm_task_classifier",
"noop",
"random",
Expand Down Expand Up @@ -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__(
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading