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
7 changes: 4 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,10 @@ traffic that sits between client applications and LLM backends.
- **Observability** — Prometheus `/metrics`, a JSON `/v1/stats`
(`/v1/routing/stats` alias), and per-request cost/token/latency stats. See
[Metrics Reference](docs/METRICS_REFERENCE.md).
- **Python library** — `SwitchyardRecipes` (`passthrough_recipe`,
`random_routing_recipe`, `stage_router_recipe`, `deterministic_routing_recipe`,
…) and typed `ChatRequest` / `ChatResponse` containers for in-process use.
- **Python library** — `ProfileSwitchyard` driven by typed profile configs
(`PassthroughProfileConfig`, `RandomRoutingProfileConfig`,
`StageRouterProfileConfig`, …) and typed `ChatRequest` / `ChatResponse`
containers for in-process use.
- **Rust core** (PyO3) — chain execution, the latency-aware router, and the
tool-result signal collector are implemented in Rust and re-exported to
Python.
Expand Down
2 changes: 1 addition & 1 deletion DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ switchyard/
│ ├── lib/ # Core library
│ │ ├── roles.py # RequestProcessor, LLMBackend, ResponseProcessor ABCs
│ │ ├── switchyard.py # Switchyard chain executor
│ │ ├── recipes.py # SwitchyardRecipes (passthrough, random_routing, …)
│ │ ├── profiles/ # Profile configs/runtimes (passthrough, random_routing, …)
│ │ ├── proxy_context.py # ProxyContext — per-request state
│ │ ├── chat_request/ # Typed request hierarchy
│ │ ├── chat_response/ # Typed response hierarchy
Expand Down
8 changes: 4 additions & 4 deletions INSTALLATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ All optional dependencies for complete feature set:
pip install nemo-switchyard[all]
```

Equivalent to: `switchyard[server,cli]`
Equivalent to: `nemo-switchyard[server,cli,tracing,intake,affinity-redis]`

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 | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Locate relevant files and inspect the exact snippets.
git ls-files | rg '^(pyproject\.toml|INSTALLATION\.md|README\.md)$'

printf '\n--- pyproject.toml ---\n'
cat -n pyproject.toml | sed -n '1,260p'

printf '\n--- INSTALLATION.md (around line 70) ---\n'
cat -n INSTALLATION.md | sed -n '55,85p'

printf '\n--- README.md (around line 219) ---\n'
cat -n README.md | sed -n '205,230p'

Repository: NVIDIA-NeMo/Switchyard

Length of output: 10838


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search for all extra definitions/usages to confirm canonical naming.
rg -n --hidden --glob '!**/.git/**' --glob 'pyproject.toml' --glob 'README.md' --glob 'INSTALLATION.md' \
  '(affinity-redis|redis-affinity|\[all\]|\[server,cli,tracing,intake,affinity-redis\])' .

Repository: NVIDIA-NeMo/Switchyard

Length of output: 729


Use affinity-redis consistently in the docs.

pyproject.toml defines the extra as affinity-redis, but the README describes it as redis-affinity. Update the README wording to match the manifest.

📍 Affects 2 files
  • INSTALLATION.md#L70-L70 (this comment)
  • README.md#L219-L219
🤖 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 `@INSTALLATION.md` at line 70, The documentation uses inconsistent extra
naming. In INSTALLATION.md:70-70 and README.md:219-219, update the documented
extra name to affinity-redis to match pyproject.toml, replacing any
redis-affinity wording.


### Common Combinations

Expand Down Expand Up @@ -114,13 +114,13 @@ pip install nemo-switchyard[all]
Embed Switchyard with minimal overhead:

```python
from switchyard import SwitchyardRecipes
from switchyard import PassthroughProfileConfig, ProfileSwitchyard

# Core library only — no server/CLI dependencies
switchyard = SwitchyardRecipes.passthrough_recipe(
switchyard = ProfileSwitchyard(PassthroughProfileConfig(
api_key="sk-...",
base_url="https://api.openai.com/v1",
)
).build())
```

## Troubleshooting
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ Optional extras:
```bash
pip install "nemo-switchyard[server]" # FastAPI / Uvicorn HTTP endpoints
pip install "nemo-switchyard[cli]" # Interactive CLI launchers (Claude / Codex)
pip install "nemo-switchyard[all]" # Server, CLI, and tracing extras
pip install "nemo-switchyard[all]" # Server, CLI, tracing, intake, and redis-affinity extras
```

See [Installation](INSTALLATION.md) for a full breakdown of what each extra adds.
Expand Down
2 changes: 1 addition & 1 deletion crates/libsy/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ async-trait = "0.1"
serde_json = "1"
futures = "0.3"
rand = "0.8"
switchyard-protocol = { path = "../protocol" }
switchyard-protocol = { path = "../protocol", version = "0.1.0" }
tokio = { version = "1", features = ["full"] }
tokio-stream = "0.1"

Expand Down
98 changes: 88 additions & 10 deletions crates/libsy/src/algorithms/llm_class.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,16 +179,19 @@ impl Algorithm for LlmClassifier {
classify_decision,
)
.await?;
let score = classify_response
.llm_response
.as_agg()
.map(completion_text)
.unwrap_or_default()
// Drain a streamed classifier response to its aggregate so a streamed
// score is read instead of silently dropped. Keep only a valid
// probability in [0.0, 1.0]: NaN, ±inf, and out-of-range values parse
// as f64 but are not usable scores, so they collapse to None and fail
// open to strong below rather than being treated as a real verdict.
let score = completion_text(&classify_response.llm_response.into_agg().await?)
.trim()
.parse::<f64>()
.ok();
.ok()
.filter(|s| (0.0..=1.0).contains(s));

// 2. Route: pick strong/weak. Fail open — an unparseable score routes strong.
// 2. Route: pick strong/weak. Fail open — an unparseable or out-of-range
// score routes strong.
let (tier, model) = match score {
Some(s) if s >= self.threshold => (ClassifierTier::Strong, self.strong_model.clone()),
Some(_) => (ClassifierTier::Weak, self.weak_model.clone()),
Expand All @@ -215,7 +218,8 @@ impl Algorithm for LlmClassifier {
#[cfg(test)]
mod tests {
use super::*;
use crate::{LlmResponse, LlmTarget, Response, RoutedLlmClient};
use crate::{LlmResponse, LlmResponseChunk, LlmTarget, Response, RoutedLlmClient};
use futures::StreamExt;
use std::sync::Mutex;
use switchyard_protocol::text_response;

Expand All @@ -225,6 +229,9 @@ mod tests {
struct ScoringClient {
classifier_model: String,
score: String,
/// When true, the classifier target returns its score as a live stream
/// rather than a buffered aggregate, exercising the drain-the-stream path.
stream: bool,
seen: Arc<Mutex<Vec<Request>>>,
}

Expand All @@ -237,25 +244,51 @@ mod tests {
decision: Arc<dyn Decision>,
) -> Result<Response, Box<dyn Error + Send + Sync>> {
let name = decision.selected_model().to_string();
let completion = if name == self.classifier_model {
let is_classifier = name == self.classifier_model;
let completion = if is_classifier {
self.score.clone()
} else {
format!("answer from {name}")
};
self.seen.lock().map_err(|_| "lock poisoned")?.push(request);
// A streaming classifier target emits its score as a live TextDelta
// stream; the algorithm must drain it (into_agg) to read the score.
let llm_response = if is_classifier && self.stream {
let chunks = vec![Ok(LlmResponseChunk::TextDelta {
index: 0,
text: completion,
})];
LlmResponse::Stream(futures::stream::iter(chunks).boxed())
} else {
LlmResponse::Agg(text_response(None, completion))
};
Ok(Response {
llm_response: LlmResponse::Agg(text_response(None, completion)),
llm_response,
metadata: None,
})
}
}

/// Build a classifier algo whose three targets share a scoring client.
fn algo(threshold: f64, score: &str) -> (LlmClassifier, Arc<Mutex<Vec<Request>>>) {
build_algo(threshold, score, false)
}

/// Like [`algo`], but the classifier target returns its score as a live stream.
fn algo_streaming(threshold: f64, score: &str) -> (LlmClassifier, Arc<Mutex<Vec<Request>>>) {
build_algo(threshold, score, true)
}

fn build_algo(
threshold: f64,
score: &str,
stream: bool,
) -> (LlmClassifier, Arc<Mutex<Vec<Request>>>) {
let seen = Arc::new(Mutex::new(Vec::new()));
let client = Arc::new(ScoringClient {
classifier_model: "router/classifier".to_string(),
score: score.to_string(),
stream,
seen: Arc::clone(&seen),
}) as Arc<dyn RoutedLlmClient>;
let target = |name: &str| LlmTarget {
Expand Down Expand Up @@ -378,4 +411,49 @@ mod tests {
assert_eq!(routed.score, None);
Ok(())
}

#[tokio::test]
async fn out_of_range_score_defaults_to_strong() -> Result<(), Box<dyn Error + Send + Sync>> {
// "NaN" parses as an f64 but is not a usable probability, so it must fail
// open to strong rather than be treated as a below-threshold verdict and
// silently downgraded to weak (NvBug 6485976).
let (algo, _) = algo(0.5, "NaN");
let (trace, response) = orch(algo).run(Context::default(), request("hi")).await?;
assert_eq!(
response
.llm_response
.as_agg()
.map(completion_text)
.unwrap_or_default(),
"answer from frontier/model"
);
let routed = as_classifier(&trace[1])?;
assert_eq!(routed.tier, Some(ClassifierTier::Strong));
assert_eq!(routed.score, None);
Ok(())
}

#[tokio::test]
async fn streamed_score_below_threshold_routes_weak() -> Result<(), Box<dyn Error + Send + Sync>>
{
// A streaming classifier target must have its score drained and read, not
// dropped — otherwise every streamed verdict falls open to strong
// regardless of value (NvBug 6485975).
let (algo, _) = algo_streaming(0.5, "0.2");
let (trace, response) = orch(algo)
.run(Context::default(), request("say hello"))
.await?;
assert_eq!(
response
.llm_response
.as_agg()
.map(completion_text)
.unwrap_or_default(),
"answer from cheap/model"
);
let routed = as_classifier(&trace[1])?;
assert_eq!(routed.tier, Some(ClassifierTier::Weak));
assert_eq!(routed.score, Some(0.2));
Ok(())
}
Comment on lines +414 to +458

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 | 🟡 Minor | ⚡ Quick win

Drop the bug-tracker IDs from test comments.

Both new test doc-comments end with a parenthetical tracker reference — "(NvBug 6485976)" (line ~419) and "(NvBug 6485975)" (line ~441). Per the coding guideline for **/*.{py,rs}, source comments should explain behavior/invariants, not carry project-management references like issue IDs.

As per coding guidelines, "Source comments must explain code behavior, invariants, or non-obvious edge cases; do not include project-management references such as tracker steps, issue IDs, or plan links."

📝 Proposed fix
-        // "NaN" parses as an f64 but is not a usable probability, so it must fail
-        // open to strong rather than be treated as a below-threshold verdict and
-        // silently downgraded to weak (NvBug 6485976).
+        // "NaN" parses as an f64 but is not a usable probability, so it must fail
+        // open to strong rather than be treated as a below-threshold verdict and
+        // silently downgraded to weak.
-        // A streaming classifier target must have its score drained and read, not
-        // dropped — otherwise every streamed verdict falls open to strong
-        // regardless of value (NvBug 6485975).
+        // A streaming classifier target must have its score drained and read, not
+        // dropped — otherwise every streamed verdict falls open to strong
+        // regardless of value.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#[tokio::test]
async fn out_of_range_score_defaults_to_strong() -> Result<(), Box<dyn Error + Send + Sync>> {
// "NaN" parses as an f64 but is not a usable probability, so it must fail
// open to strong rather than be treated as a below-threshold verdict and
// silently downgraded to weak (NvBug 6485976).
let (algo, _) = algo(0.5, "NaN");
let (trace, response) = orch(algo).run(Context::default(), request("hi")).await?;
assert_eq!(
response
.llm_response
.as_agg()
.map(completion_text)
.unwrap_or_default(),
"answer from frontier/model"
);
let routed = as_classifier(&trace[1])?;
assert_eq!(routed.tier, Some(ClassifierTier::Strong));
assert_eq!(routed.score, None);
Ok(())
}
#[tokio::test]
async fn streamed_score_below_threshold_routes_weak() -> Result<(), Box<dyn Error + Send + Sync>>
{
// A streaming classifier target must have its score drained and read, not
// dropped — otherwise every streamed verdict falls open to strong
// regardless of value (NvBug 6485975).
let (algo, _) = algo_streaming(0.5, "0.2");
let (trace, response) = orch(algo)
.run(Context::default(), request("say hello"))
.await?;
assert_eq!(
response
.llm_response
.as_agg()
.map(completion_text)
.unwrap_or_default(),
"answer from cheap/model"
);
let routed = as_classifier(&trace[1])?;
assert_eq!(routed.tier, Some(ClassifierTier::Weak));
assert_eq!(routed.score, Some(0.2));
Ok(())
}
#[tokio::test]
async fn out_of_range_score_defaults_to_strong() -> Result<(), Box<dyn Error + Send + Sync>> {
// "NaN" parses as an f64 but is not a usable probability, so it must fail
// open to strong rather than be treated as a below-threshold verdict and
// silently downgraded to weak.
let (algo, _) = algo(0.5, "NaN");
let (trace, response) = orch(algo).run(Context::default(), request("hi")).await?;
assert_eq!(
response
.llm_response
.as_agg()
.map(completion_text)
.unwrap_or_default(),
"answer from frontier/model"
);
let routed = as_classifier(&trace[1])?;
assert_eq!(routed.tier, Some(ClassifierTier::Strong));
assert_eq!(routed.score, None);
Ok(())
}
#[tokio::test]
async fn streamed_score_below_threshold_routes_weak() -> Result<(), Box<dyn Error + Send + Sync>>
{
// A streaming classifier target must have its score drained and read, not
// dropped — otherwise every streamed verdict falls open to strong
// regardless of value.
let (algo, _) = algo_streaming(0.5, "0.2");
let (trace, response) = orch(algo)
.run(Context::default(), request("say hello"))
.await?;
assert_eq!(
response
.llm_response
.as_agg()
.map(completion_text)
.unwrap_or_default(),
"answer from cheap/model"
);
let routed = as_classifier(&trace[1])?;
assert_eq!(routed.tier, Some(ClassifierTier::Weak));
assert_eq!(routed.score, Some(0.2));
Ok(())
}
🤖 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/libsy/src/algorithms/llm_class.rs` around lines 414 - 458, Remove the
parenthetical NvBug tracker references from the comments in
out_of_range_score_defaults_to_strong and
streamed_score_below_threshold_routes_weak, while preserving the behavioral and
edge-case explanations in both comments.

Source: Coding guidelines

}
4 changes: 2 additions & 2 deletions crates/libsy/src/core/algorithm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ pub struct RoutedRequest {

/// The host-facing half of an offloaded model call, surfaced inside [`Step::CallLlm`].
///
/// Wraps a [`DriverRequest`] whose payload is a [`RoutedRequest`]. The host reads the
/// Wraps a `DriverRequest` whose payload is a [`RoutedRequest`]. The host reads the
/// routed request ([`get_routed`](Self::get_routed)) and the decision behind it
/// ([`get_decision`](Self::get_decision)), performs (or delegates) the model call, and
/// fulfills it with [`respond`](Self::respond) — unblocking the algorithm's
Expand Down Expand Up @@ -196,7 +196,7 @@ impl Default for Driver {
}
}

/// One item in the stream returned by [`Driver::stream`] / [`Algorithm::run_stream`].
/// One item in the stream returned by `Driver::stream` / [`Algorithm::run_stream`].
pub enum Step {
/// The algorithm needs this model call performed. The host serves it (optionally
/// via [`RoutedRequest::default_client`]) and fulfills it with
Expand Down
4 changes: 2 additions & 2 deletions crates/libsy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@
//!
//! Concrete algorithms live in [`algorithms`]:
//!
//! [`algorithms::Random`](crate::algorithms::Random) provides uniform random routing.
//! [`algorithms::Random`] provides uniform random routing.
//!
//! [`algorithms::LlmClassifier`](crate::algorithms::LlmClassifier) classifies
//! [`algorithms::LlmClassifier`] classifies
//! with one model, then routes to a strong/weak model depending on the classifier's choice.

mod core;
Expand Down
Loading
Loading