Skip to content
Closed
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
70 changes: 70 additions & 0 deletions .github/workflows/gumloop-publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# 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.0` (tag must match
# the version in pyproject.toml) or manually via workflow_dispatch.
#
# Auth is keyless: GitHub's OIDC token federates through the switchyard-github
# WIF pool (backend/iac/terraform/environments/staging, module "switchyard_wif")
# and impersonates the agenthub-github-ci-cd CI service account, which carries
# artifactregistry uploadArtifacts. The repo variable GCP_WIF_PROVIDER holds the
# fully qualified provider name; no long-lived secret is stored.

name: gumloop-publish

on:
push:
tags:
- "gumloop-v*"
workflow_dispatch: {}

permissions:
contents: read
id-token: write

jobs:
build-and-publish:
runs-on: ubuntu-latest
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: Authenticate to Google Cloud
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

- name: Publish to Artifact Registry
run: |
python3 -m pip install --quiet twine
ACCESS_TOKEN="$(gcloud auth application-default print-access-token)"
# 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
32 changes: 25 additions & 7 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@ requires = ["maturin>=1.9,<2.0"]
build-backend = "maturin"

[project]
name = "nemo-switchyard"
version = "0.2.0"
description = "Typed, composable LLM routing with request/response translation and multi-backend orchestration"
# Gumloop fork: dist renamed (mirrors gumloop-celery); import names are unchanged.
name = "gumloop-nemo-switchyard"
version = "0.2.0+gumloop.0.1.0"
description = "Typed, composable LLM routing with request/response translation and multi-backend orchestration (Gumloop fork of nemo-switchyard)"
readme = "README.md"
license = "Apache-2.0"
requires-python = ">=3.12"
# Gumloop fork: cp310-abi3 wheel (see crates/switchyard-py/Cargo.toml abi3-py310).
requires-python = ">=3.10"
authors = [{ name = "NVIDIA Corporation" }]
maintainers = [{ name = "NVIDIA Corporation" }]
keywords = ["llm", "switchyard", "routing", "openai", "nemo"]
Expand All @@ -17,6 +19,8 @@ classifiers = [
"Intended Audience :: Developers",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
Expand All @@ -30,7 +34,20 @@ 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
# Gumloop fork: the core install carries only what the libsy bindings surface
# (``switchyard_rust.libsy``) needs, so embedding hosts do not inherit provider-SDK
# floors they may pin differently. The upstream core dependencies moved to the
# ``lib`` extra, which the full ``switchyard`` package (proxy/profiles/clients)
# requires; ``server``/``cli``/``all`` pull it in transitively.
dependencies = [
# py3.10 needs the typing_extensions backport of typing.Self.
"typing-extensions>=4.0; python_version < '3.11'",
]

[project.optional-dependencies]
# Gumloop fork: the upstream core dependencies, needed 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 +57,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 +69,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 +92,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 +121,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
10 changes: 7 additions & 3 deletions switchyard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,11 @@ def __getattr__(name: str) -> Any:
]

try:
__version__ = _metadata.version("nemo-switchyard")
# Gumloop fork: the renamed dist is tried first; upstream's name keeps source parity.
__version__ = _metadata.version("gumloop-nemo-switchyard")
except _metadata.PackageNotFoundError:
# A source checkout may not have installed distribution metadata.
__version__ = "0.0.0+unknown"
try:
__version__ = _metadata.version("nemo-switchyard")
except _metadata.PackageNotFoundError:
# A source checkout may not have installed distribution metadata.
__version__ = "0.0.0+unknown"
5 changes: 4 additions & 1 deletion switchyard/lib/endpoints/upstream_error_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@

import json
import logging
from datetime import UTC, datetime
# Gumloop fork: py3.10 has no datetime.UTC alias.
from datetime import datetime, timezone

UTC = timezone.utc

from switchyard.lib.endpoints.outcome_metrics import classify, code_label

Expand Down
5 changes: 4 additions & 1 deletion switchyard/lib/processors/rl_logging_response_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@
import json
import logging
import uuid as uuid_lib
from datetime import UTC, datetime
# Gumloop fork: py3.10 has no datetime.UTC alias.
from datetime import datetime, timezone

UTC = timezone.utc
from pathlib import Path
from typing import Any

Expand Down
5 changes: 4 additions & 1 deletion switchyard/lib/processors/routing_log_response_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@
import logging
import threading
from collections.abc import Mapping
from datetime import UTC, datetime
# Gumloop fork: py3.10 has no datetime.UTC alias.
from datetime import datetime, timezone

UTC = timezone.utc
from pathlib import Path
from typing import TYPE_CHECKING, Any

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@

from __future__ import annotations

from typing import Any, Self
from typing import Any

try:
from typing import Self
except ImportError: # Gumloop fork: py3.10 — Self landed in typing in 3.11.
from typing_extensions import Self

from switchyard.lib.processors.llm_classifier.presets import (
PROFILE_FACTORIES,
Expand Down
7 changes: 6 additions & 1 deletion switchyard/lib/profiles/escalation_router_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@

from __future__ import annotations

from typing import Literal, Self
from typing import Literal

try:
from typing import Self
except ImportError: # Gumloop fork: py3.10 — Self landed in typing in 3.11.
from typing_extensions import Self

from pydantic import (
BaseModel,
Expand Down
Loading
Loading