From 2e40017533c5f5a7bdf3b8b3aa37a90e9aa88c70 Mon Sep 17 00:00:00 2001 From: rbehal Date: Tue, 11 Aug 2026 19:18:42 +0000 Subject: [PATCH 1/7] gumloop: target py3.10 and rename the dist to gumloop-nemo-switchyard - pyo3 abi3-py312 -> abi3-py310: one cp310-abi3 wheel serves CPython 3.10+ - requires-python >= 3.10; patch the 3.11-only constructs (datetime.UTC, typing.Self) with runtime-equivalent 3.10 forms - defer the translation import in switchyard_rust/__init__.py so the bindings-only surface (switchyard_rust.libsy) never needs provider SDKs - move the upstream core deps (openai/anthropic/httpx/pydantic) into the new 'lib' extra so embedding hosts do not inherit provider-SDK floors; the server/cli/all extras pull it in transitively - version lookups try the renamed dist first, upstream's name second Co-authored-by: Cursor --- crates/switchyard-py/Cargo.toml | 3 +- pyproject.toml | 32 +++++++++++++++---- switchyard/__init__.py | 10 ++++-- .../lib/endpoints/upstream_error_log.py | 5 ++- .../rl_logging_response_processor.py | 5 ++- .../routing_log_response_processor.py | 5 ++- .../deterministic_routing_profile_config.py | 7 +++- .../lib/profiles/escalation_router_config.py | 7 +++- .../escalation_router_profile_config.py | 7 +++- switchyard/lib/profiles/random_routing.py | 7 +++- switchyard/lib/profiles/stage_router.py | 7 +++- switchyard/lib/prometheus_exposition.py | 11 ++++--- switchyard/telemetry.py | 13 +++++--- switchyard_rust/__init__.py | 15 ++++++--- switchyard_rust/core.py | 3 +- 15 files changed, 103 insertions(+), 34 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..ff0d613cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] @@ -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", @@ -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. @@ -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", @@ -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", ] @@ -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 @@ -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 = [ diff --git a/switchyard/__init__.py b/switchyard/__init__.py index fdedd8bad..fb9225a0e 100644 --- a/switchyard/__init__.py +++ b/switchyard/__init__.py @@ -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" diff --git a/switchyard/lib/endpoints/upstream_error_log.py b/switchyard/lib/endpoints/upstream_error_log.py index 70977506f..3d1a002c5 100644 --- a/switchyard/lib/endpoints/upstream_error_log.py +++ b/switchyard/lib/endpoints/upstream_error_log.py @@ -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 diff --git a/switchyard/lib/processors/rl_logging_response_processor.py b/switchyard/lib/processors/rl_logging_response_processor.py index 529a8c1c2..4a534fa30 100644 --- a/switchyard/lib/processors/rl_logging_response_processor.py +++ b/switchyard/lib/processors/rl_logging_response_processor.py @@ -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 diff --git a/switchyard/lib/processors/routing_log_response_processor.py b/switchyard/lib/processors/routing_log_response_processor.py index 7046c851f..0d2ac54b9 100644 --- a/switchyard/lib/processors/routing_log_response_processor.py +++ b/switchyard/lib/processors/routing_log_response_processor.py @@ -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 diff --git a/switchyard/lib/profiles/deterministic_routing_profile_config.py b/switchyard/lib/profiles/deterministic_routing_profile_config.py index 3be3eb9c2..e7281f87b 100644 --- a/switchyard/lib/profiles/deterministic_routing_profile_config.py +++ b/switchyard/lib/profiles/deterministic_routing_profile_config.py @@ -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, diff --git a/switchyard/lib/profiles/escalation_router_config.py b/switchyard/lib/profiles/escalation_router_config.py index ffb208715..e3aeb0297 100644 --- a/switchyard/lib/profiles/escalation_router_config.py +++ b/switchyard/lib/profiles/escalation_router_config.py @@ -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, diff --git a/switchyard/lib/profiles/escalation_router_profile_config.py b/switchyard/lib/profiles/escalation_router_profile_config.py index f6f5b4ce8..f8cd2c684 100644 --- a/switchyard/lib/profiles/escalation_router_profile_config.py +++ b/switchyard/lib/profiles/escalation_router_profile_config.py @@ -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.affinity_pin_store import AffinityPinStore from switchyard.lib.backends.deterministic_routing_llm_backend import ( diff --git a/switchyard/lib/profiles/random_routing.py b/switchyard/lib/profiles/random_routing.py index 2d687782b..01a2cc9f6 100644 --- a/switchyard/lib/profiles/random_routing.py +++ b/switchyard/lib/profiles/random_routing.py @@ -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 pydantic import BaseModel, ConfigDict, ValidationInfo, field_validator diff --git a/switchyard/lib/profiles/stage_router.py b/switchyard/lib/profiles/stage_router.py index 32a0b4e9a..7709ef0f0 100644 --- a/switchyard/lib/profiles/stage_router.py +++ b/switchyard/lib/profiles/stage_router.py @@ -6,7 +6,12 @@ from __future__ import annotations import functools -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.reasoning_hint import model_accepts_reasoning_hint from switchyard.lib.processors.stage_router import StageRouterDecisionLog, TierClassifier diff --git a/switchyard/lib/prometheus_exposition.py b/switchyard/lib/prometheus_exposition.py index 146ad0a26..ea084c713 100644 --- a/switchyard/lib/prometheus_exposition.py +++ b/switchyard/lib/prometheus_exposition.py @@ -32,10 +32,13 @@ from importlib.metadata import version as _pkg_version from typing import Any -try: - _SWITCHYARD_VERSION = _pkg_version("nemo-switchyard") -except Exception: - _SWITCHYARD_VERSION = "unknown" +# Gumloop fork: the renamed dist is tried first; upstream's name keeps source parity. +for _dist in ("gumloop-nemo-switchyard", "nemo-switchyard"): + try: + _SWITCHYARD_VERSION = _pkg_version(_dist) + break + except Exception: + _SWITCHYARD_VERSION = "unknown" def render_prometheus(snapshot: dict[str, Any]) -> str: diff --git a/switchyard/telemetry.py b/switchyard/telemetry.py index fd33635cc..1f0ebb72e 100644 --- a/switchyard/telemetry.py +++ b/switchyard/telemetry.py @@ -46,11 +46,14 @@ def _is_opted_out() -> bool: @lru_cache(maxsize=1) def _get_version() -> str: """Read the installed ``nemo-switchyard`` package version once.""" - try: - return importlib.metadata.version("nemo-switchyard") - except Exception: - log.debug("telemetry: could not read switchyard package version", exc_info=True) - return "unknown" + # Gumloop fork: the renamed dist is tried first; upstream's name keeps source parity. + for dist in ("gumloop-nemo-switchyard", "nemo-switchyard"): + try: + return importlib.metadata.version(dist) + except Exception: + continue + log.debug("telemetry: could not read switchyard package version", exc_info=True) + return "unknown" def get_telemetry_headers() -> dict[str, str]: 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", diff --git a/switchyard_rust/core.py b/switchyard_rust/core.py index f9ed33bfd..56a8783fe 100644 --- a/switchyard_rust/core.py +++ b/switchyard_rust/core.py @@ -361,7 +361,8 @@ class _NativeModule(Protocol): def _ensure_switchyard_version_env() -> None: if os.environ.get("SWITCHYARD_VERSION", "").strip(): return - for distribution in ("switchyard", "nemo-switchyard"): + # Gumloop fork: the renamed dist participates in the existing fallback chain. + for distribution in ("switchyard", "gumloop-nemo-switchyard", "nemo-switchyard"): try: version = importlib.metadata.version(distribution) except importlib.metadata.PackageNotFoundError: From 030ec3904df982a9ae980cbcc5176dfba6f6fa1e Mon Sep 17 00:00:00 2001 From: rbehal Date: Tue, 11 Aug 2026 19:18:42 +0000 Subject: [PATCH 2/7] libsy(py): 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, with the judge's verdict selecting a label through a JSON Pointer and unusable verdicts falling open to default_target. Co-authored-by: Cursor --- crates/switchyard-py/src/libsy_bindings.rs | 111 +++++++++++++- switchyard/libsy/__init__.py | 2 + switchyard/libsy/algorithms.py | 3 +- switchyard_rust/libsy.py | 24 +++ tests/test_libsy_custom_classifier.py | 161 +++++++++++++++++++++ 5 files changed, 297 insertions(+), 4 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/libsy/__init__.py b/switchyard/libsy/__init__.py index 44ca4cea3..d32a3513c 100644 --- a/switchyard/libsy/__init__.py +++ b/switchyard/libsy/__init__.py @@ -5,6 +5,7 @@ from switchyard_rust.libsy import ( Algorithm, + CustomClassifierConfig, LibsyError, LlmClient, LlmFallback, @@ -16,6 +17,7 @@ __all__ = [ "Algorithm", + "CustomClassifierConfig", "LibsyError", "LlmClient", "LlmFallback", diff --git a/switchyard/libsy/algorithms.py b/switchyard/libsy/algorithms.py index 92e9e747e..5f637bebd 100644 --- a/switchyard/libsy/algorithms.py +++ b/switchyard/libsy/algorithms.py @@ -3,9 +3,10 @@ """Factories for Rust-owned libsy algorithms.""" +from switchyard_rust.libsy import custom_classifier as custom_classifier from switchyard_rust.libsy import llm_task_classifier as llm_task_classifier from switchyard_rust.libsy import noop as noop from switchyard_rust.libsy import random as random from switchyard_rust.libsy import stage_router as stage_router -__all__ = ["llm_task_classifier", "noop", "random", "stage_router"] +__all__ = ["custom_classifier", "llm_task_classifier", "noop", "random", "stage_router"] 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 121a479962815a6a3496a7fa0f8f636ecbaeb017 Mon Sep 17 00:00:00 2001 From: rbehal Date: Tue, 11 Aug 2026 19:18:42 +0000 Subject: [PATCH 3/7] gumloop: add the wheel build + Artifact Registry publish workflow Co-authored-by: Cursor --- .github/workflows/gumloop-publish.yml | 53 +++++++++++++++++++++++++++ 1 file changed, 53 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..45734a1de --- /dev/null +++ b/.github/workflows/gumloop-publish.yml @@ -0,0 +1,53 @@ +# 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. +# +# Requires the repository/org secret GCP_ARTIFACT_REGISTRY_SA_KEY: a service +# account key JSON with roles/artifactregistry.writer on +# projects/agenthub-dev/locations/us-west1/repositories/gumloop. + +name: gumloop-publish + +on: + push: + tags: + - "gumloop-v*" + workflow_dispatch: {} + +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: + credentials_json: ${{ secrets.GCP_ARTIFACT_REGISTRY_SA_KEY }} + + - name: Publish to Artifact Registry + run: | + python3 -m pip install --quiet twine + ACCESS_TOKEN="$(gcloud auth print-access-token)" + python3 -m twine upload \ + --repository-url https://us-west1-python.pkg.dev/agenthub-dev/gumloop/ \ + --username oauth2accesstoken \ + --password "${ACCESS_TOKEN}" \ + target/wheels/*.whl + + - name: Upload wheel artifact + uses: actions/upload-artifact@v4 + with: + name: wheels + path: target/wheels/*.whl From c699b36615e50a65c767d7516341e6194c8142de Mon Sep 17 00:00:00 2001 From: rbehal Date: Tue, 11 Aug 2026 19:30:24 +0000 Subject: [PATCH 4/7] gumloop: make tag republishes idempotent with --skip-existing Co-authored-by: Cursor --- .github/workflows/gumloop-publish.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/gumloop-publish.yml b/.github/workflows/gumloop-publish.yml index 45734a1de..d6a21d05f 100644 --- a/.github/workflows/gumloop-publish.yml +++ b/.github/workflows/gumloop-publish.yml @@ -41,6 +41,7 @@ jobs: python3 -m pip install --quiet twine ACCESS_TOKEN="$(gcloud auth print-access-token)" python3 -m twine upload \ + --skip-existing \ --repository-url https://us-west1-python.pkg.dev/agenthub-dev/gumloop/ \ --username oauth2accesstoken \ --password "${ACCESS_TOKEN}" \ From 200b05ef472227eeb70bd4f880980c6d8d2f496b Mon Sep 17 00:00:00 2001 From: rbehal Date: Tue, 11 Aug 2026 19:46:04 +0000 Subject: [PATCH 5/7] gumloop: publish via ADC credentials (SA key or authorized-user JSON) Co-authored-by: Cursor --- .github/workflows/gumloop-publish.yml | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/.github/workflows/gumloop-publish.yml b/.github/workflows/gumloop-publish.yml index d6a21d05f..503e41c84 100644 --- a/.github/workflows/gumloop-publish.yml +++ b/.github/workflows/gumloop-publish.yml @@ -4,8 +4,9 @@ # 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. # -# Requires the repository/org secret GCP_ARTIFACT_REGISTRY_SA_KEY: a service -# account key JSON with roles/artifactregistry.writer on +# Requires the repository secret GCP_ARTIFACT_REGISTRY_CREDENTIALS: a Google +# credentials JSON usable as Application Default Credentials (a service account +# key or an authorized-user file) with artifactregistry.writer on # projects/agenthub-dev/locations/us-west1/repositories/gumloop. name: gumloop-publish @@ -31,21 +32,24 @@ jobs: args: --release --interpreter python3.10 manylinux: 2_28 - - name: Authenticate to Google Cloud - uses: google-github-actions/auth@v2 - with: - credentials_json: ${{ secrets.GCP_ARTIFACT_REGISTRY_SA_KEY }} - + # gcloud mints the token from ADC, so the secret may be a service account + # key or an authorized-user credentials file — same code path for both. - name: Publish to Artifact Registry + env: + GCP_CREDENTIALS_JSON: ${{ secrets.GCP_ARTIFACT_REGISTRY_CREDENTIALS }} run: | + CRED_FILE="$RUNNER_TEMP/gcp-credentials.json" + printf '%s' "$GCP_CREDENTIALS_JSON" > "$CRED_FILE" + export GOOGLE_APPLICATION_CREDENTIALS="$CRED_FILE" python3 -m pip install --quiet twine - ACCESS_TOKEN="$(gcloud auth print-access-token)" + ACCESS_TOKEN="$(gcloud auth application-default print-access-token)" python3 -m twine upload \ --skip-existing \ --repository-url https://us-west1-python.pkg.dev/agenthub-dev/gumloop/ \ --username oauth2accesstoken \ --password "${ACCESS_TOKEN}" \ target/wheels/*.whl + rm -f "$CRED_FILE" - name: Upload wheel artifact uses: actions/upload-artifact@v4 From 387c948f55f737f136efc812d50d793f6869d26b Mon Sep 17 00:00:00 2001 From: rbehal Date: Tue, 11 Aug 2026 19:51:30 +0000 Subject: [PATCH 6/7] gumloop: skip already-published wheels via the simple index Artifact Registry rejects twine's --skip-existing capability probe. Co-authored-by: Cursor --- .github/workflows/gumloop-publish.yml | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/workflows/gumloop-publish.yml b/.github/workflows/gumloop-publish.yml index 503e41c84..259293d34 100644 --- a/.github/workflows/gumloop-publish.yml +++ b/.github/workflows/gumloop-publish.yml @@ -43,12 +43,21 @@ jobs: export GOOGLE_APPLICATION_CREDENTIALS="$CRED_FILE" python3 -m pip install --quiet twine ACCESS_TOKEN="$(gcloud auth application-default print-access-token)" - python3 -m twine upload \ - --skip-existing \ - --repository-url https://us-west1-python.pkg.dev/agenthub-dev/gumloop/ \ - --username oauth2accesstoken \ - --password "${ACCESS_TOKEN}" \ - target/wheels/*.whl + # 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 rm -f "$CRED_FILE" - name: Upload wheel artifact From e95a8885450afda78dc533c2697a6cbba99456b4 Mon Sep 17 00:00:00 2001 From: rbehal Date: Tue, 11 Aug 2026 20:06:53 +0000 Subject: [PATCH 7/7] gumloop: publish keylessly via WIF instead of a stored credential GitHub's OIDC token federates through the switchyard-github pool and impersonates the agenthub-github-ci-cd CI SA. Merge after the backend terraform (module switchyard_wif, environments/staging) has applied; the GCP_ARTIFACT_REGISTRY_CREDENTIALS secret can be deleted once this lands. Co-authored-by: Cursor --- .github/workflows/gumloop-publish.yml | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/.github/workflows/gumloop-publish.yml b/.github/workflows/gumloop-publish.yml index 259293d34..9c25b217b 100644 --- a/.github/workflows/gumloop-publish.yml +++ b/.github/workflows/gumloop-publish.yml @@ -4,10 +4,11 @@ # 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. # -# Requires the repository secret GCP_ARTIFACT_REGISTRY_CREDENTIALS: a Google -# credentials JSON usable as Application Default Credentials (a service account -# key or an authorized-user file) with artifactregistry.writer on -# projects/agenthub-dev/locations/us-west1/repositories/gumloop. +# 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 @@ -17,6 +18,10 @@ on: - "gumloop-v*" workflow_dispatch: {} +permissions: + contents: read + id-token: write + jobs: build-and-publish: runs-on: ubuntu-latest @@ -32,15 +37,14 @@ jobs: args: --release --interpreter python3.10 manylinux: 2_28 - # gcloud mints the token from ADC, so the secret may be a service account - # key or an authorized-user credentials file — same code path for both. + - 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 - env: - GCP_CREDENTIALS_JSON: ${{ secrets.GCP_ARTIFACT_REGISTRY_CREDENTIALS }} run: | - CRED_FILE="$RUNNER_TEMP/gcp-credentials.json" - printf '%s' "$GCP_CREDENTIALS_JSON" > "$CRED_FILE" - export GOOGLE_APPLICATION_CREDENTIALS="$CRED_FILE" 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 @@ -58,7 +62,6 @@ jobs: "$wheel" fi done - rm -f "$CRED_FILE" - name: Upload wheel artifact uses: actions/upload-artifact@v4