Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
5d66e0d
feat(switchyard): add Decision API routing plugin
bbednarski9 Jul 6, 2026
a3a0da4
test(switchyard): add real-service and Hermes E2E
bbednarski9 Jul 6, 2026
3986de8
add deterministic Switchyard trajectory E2E
bbednarski9 Jul 7, 2026
6ffc45a
document generated trajectory bundles
bbednarski9 Jul 7, 2026
f3361a6
fix: attribute routed model in translated trajectories
bbednarski9 Jul 7, 2026
602e055
feat(switchyard): record routed optimization evidence
bbednarski9 Jul 8, 2026
c0c981a
fix(switchyard): retain trailing stream usage
bbednarski9 Jul 8, 2026
85610b6
fix(switchyard): expose StageRouter decision context
bbednarski9 Jul 8, 2026
6535769
test(switchyard): add InferenceHub StageRouter trajectory
bbednarski9 Jul 8, 2026
210dc94
test(switchyard): normalize Claude feature flags
bbednarski9 Jul 8, 2026
a6dcf13
test(switchyard): omit Claude effort extension
bbednarski9 Jul 8, 2026
30fea74
test(switchyard): constrain the Claude tool surface
bbednarski9 Jul 8, 2026
92972df
test(switchyard): bound the Claude smoke context
bbednarski9 Jul 8, 2026
cc6ebf6
test(switchyard): address the fixture explicitly
bbednarski9 Jul 8, 2026
d2c669b
fix(switchyard): preserve streamed tool arguments
bbednarski9 Jul 8, 2026
477717f
fix(runtime): mirror identity onto tool events
bbednarski9 Jul 8, 2026
1eb3f7c
test(switchyard): make route intent explicit
bbednarski9 Jul 8, 2026
0b52ece
test(switchyard): use a deterministic classifier policy
bbednarski9 Jul 8, 2026
ba6ca8d
test(switchyard): route from real tool outcomes
bbednarski9 Jul 8, 2026
cb94f5d
test(switchyard): distinguish decisions from committed routes
bbednarski9 Jul 8, 2026
ad397e3
test(switchyard): isolate native ATIF upload
bbednarski9 Jul 8, 2026
5760227
test(switchyard): require projected optimization marks
bbednarski9 Jul 8, 2026
3b60629
chore(switchyard): satisfy repository hooks
bbednarski9 Jul 8, 2026
33dc0cf
test(switchyard): use native Claude protocol targets
bbednarski9 Jul 8, 2026
820ea38
test(switchyard): cover plugin lifecycle and fail-open paths
bbednarski9 Jul 8, 2026
6a19df4
test(switchyard): cover terminal fallback errors
bbednarski9 Jul 8, 2026
9ad5660
test(switchyard): cover streaming error translation
bbednarski9 Jul 8, 2026
a441cab
test(switchyard): cover optimization accounting boundaries
bbednarski9 Jul 9, 2026
cb3d118
fix(switchyard): harden routing runtime boundaries
bbednarski9 Jul 9, 2026
5f78f71
test(switchyard): assert dispatch headers stay internal
bbednarski9 Jul 9, 2026
693148b
test(switchyard): tolerate loaded loopback decisions
bbednarski9 Jul 9, 2026
66dc15b
docs(switchyard): align plugin README with repository style
bbednarski9 Jul 9, 2026
5fd9c3d
docs: pin experimental Switchyard topic setup
bbednarski9 Jul 9, 2026
6daea3d
docs: document Switchyard integration examples
bbednarski9 Jul 9, 2026
9e8102a
feat(runtime): add routing identity and ATOF primitives
bbednarski9 Jul 6, 2026
0d73b57
feat(gateway): add retry-aware routed dispatch
bbednarski9 Jul 6, 2026
3e8ea12
docs: remove internal InferenceHub examples
bbednarski9 Jul 9, 2026
2c5a31b
docs: clarify manual Switchyard compatibility smoke
bbednarski9 Jul 9, 2026
7addced
fix(gateway): redact sensitive upstream failure headers
bbednarski9 Jul 9, 2026
7fda7bc
fix(switchyard): preserve streamed tool creation order
bbednarski9 Jul 9, 2026
501a188
test(switchyard): cover interleaved streamed tool calls
bbednarski9 Jul 9, 2026
8a0dbad
fix(switchyard): translate Anthropic base64 images
bbednarski9 Jul 9, 2026
7b7012b
refactor(plugin): use Switchyard translation library
bbednarski9 Jul 9, 2026
6bdb2a1
refactor(plugin): split ATIF attribution fix
bbednarski9 Jul 9, 2026
3c4cb2f
docs(switchyard): mark crate source-only
bbednarski9 Jul 10, 2026
e2da598
test(switchyard): move contract tests to integration suite
bbednarski9 Jul 10, 2026
262953c
test(switchyard): relocate component tests
bbednarski9 Jul 10, 2026
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,4 @@ CHANGELOG.md

# Relay
/.nemo-relay
/artifacts/
28 changes: 28 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ members = [
"crates/worker",
"crates/adaptive",
"crates/pii-redaction",
"crates/switchyard",
"crates/cli",
# Language Bindings
"crates/python",
Expand All @@ -33,6 +34,8 @@ nemo-relay-worker-proto = { version = "0.6.0", path = "crates/worker-proto" }
nemo-relay-worker = { version = "0.6.0", path = "crates/worker" }
nemo-relay-adaptive = { version = "0.6.0", path = "crates/adaptive" }
nemo-relay-pii-redaction = { version = "0.6.0", path = "crates/pii-redaction" }
nemo-relay-switchyard = { version = "0.6.0", path = "crates/switchyard" }
switchyard-translation = { git = "https://github.com/NVIDIA-NeMo/Switchyard.git", rev = "8f9db9a6a47f848cdff1d262276ba25a8ae9cbc8" }
nemo-relay-ffi = { version = "0.6.0", path = "crates/ffi" }
nemo-relay-cli = { version = "0.6.0", path = "crates/cli" }
opentelemetry = { version = "0.31", default-features = false }
Expand Down
7 changes: 7 additions & 0 deletions crates/cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,13 @@ workspace = true
[features]
default = ["atof-streaming"]
atof-streaming = ["nemo-relay/atof-streaming"]
switchyard = ["dep:nemo-relay-switchyard"]

[dependencies]
nemo-relay = { workspace = true, features = ["guardrails-remote", "object-store", "otel", "openinference", "worker-grpc"] }
nemo-relay-adaptive = { workspace = true, features = ["redis-backend"] }
nemo-relay-pii-redaction.workspace = true
nemo-relay-switchyard = { workspace = true, optional = true, features = ["schema"] }
async-stream = "0.3"
axum = "0.8"
base64 = "0.22"
Expand Down Expand Up @@ -61,3 +63,8 @@ opentelemetry = { workspace = true, features = ["trace"] }
opentelemetry_sdk = { workspace = true, features = ["trace", "testing"] }
tempfile = "3"
tower = { version = "0.5", features = ["util"] }

[[test]]
name = "switchyard_process_e2e"
path = "tests/switchyard_process_e2e.rs"
required-features = ["switchyard"]
64 changes: 53 additions & 11 deletions crates/cli/src/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ use nemo_relay::observability::plugin_component::OBSERVABILITY_PLUGIN_KIND;
use nemo_relay::plugin::{DiagnosticLevel, PluginConfig, validate_plugin_config};
use nemo_relay_adaptive::plugin_component::register_adaptive_component;
use nemo_relay_pii_redaction::component::register_pii_redaction_component;
#[cfg(feature = "switchyard")]
use nemo_relay_switchyard::{
register_switchyard_component, validate_switchyard_atof_configuration,
};
use serde::Serialize;
use serde_json::{Value, json};
use tokio::time::timeout;
Expand Down Expand Up @@ -642,6 +646,24 @@ async fn collect_observability(gateway: &GatewayConfig) -> Vec<Check> {
});
return checks;
}
#[cfg(feature = "switchyard")]
if let Err(error) = register_switchyard_component() {
checks.push(Check {
name: "Switchyard plugin",
status: Status::Fail,
details: format!("registration failed: {error}"),
});
return checks;
}
#[cfg(feature = "switchyard")]
if let Err(error) = validate_switchyard_atof_configuration(&plugin_config) {
checks.push(Check {
name: "Switchyard ATOF",
status: Status::Fail,
details: error,
});
return checks;
}
let report = validate_plugin_config(&plugin_config);
if report.diagnostics.is_empty() {
checks.push(Check {
Expand Down Expand Up @@ -946,18 +968,38 @@ async fn probe_atof_endpoint(index: usize, endpoint: &Value) -> Check {
}

fn endpoint_headers(endpoint: &Value) -> Result<Vec<(String, String)>, String> {
let Some(headers) = endpoint.get("headers") else {
return Ok(Vec::new());
};
let Some(object) = headers.as_object() else {
return Err("headers must be an object of string values".into());
};
let mut out = Vec::with_capacity(object.len());
for (key, value) in object {
let Some(value) = value.as_str() else {
return Err(format!("headers.{key} must be a string"));
let mut out = Vec::new();
if let Some(headers) = endpoint.get("headers") {
let Some(object) = headers.as_object() else {
return Err("headers must be an object of string values".into());
};
for (key, value) in object {
let Some(value) = value.as_str() else {
return Err(format!("headers.{key} must be a string"));
};
out.push((key.clone(), value.to_string()));
}
}
if let Some(header_env) = endpoint.get("header_env") {
let Some(object) = header_env.as_object() else {
return Err("header_env must be an object of string values".into());
};
out.push((key.clone(), value.to_string()));
for (key, variable) in object {
if out.iter().any(|(existing, _)| existing == key) {
return Err(format!(
"header {key:?} cannot appear in both headers and header_env"
));
}
let Some(variable) = variable.as_str() else {
return Err(format!("header_env.{key} must be a string"));
};
let value = std::env::var(variable)
.map_err(|_| format!("environment variable {variable:?} is not set"))?;
if value.trim().is_empty() {
return Err(format!("environment variable {variable:?} is blank"));
}
out.push((key.clone(), value));
}
}
Ok(out)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down
15 changes: 11 additions & 4 deletions crates/cli/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use nemo_relay::error::FlowError;
use nemo_relay::error::{FlowError, UpstreamFailure};
use serde::Serialize;
use serde_json::{Map, Value, json};
use strum::Display;
Expand Down Expand Up @@ -36,6 +36,8 @@ pub(crate) enum CliError {
PayloadTooLarge(String),
#[error("gateway upstream error: {0}")]
Upstream(#[from] reqwest::Error),
#[error("{0}")]
ProviderFailure(UpstreamFailure),
#[error("http error: {0}")]
Http(#[from] http::Error),
#[error("io error: {0}")]
Expand Down Expand Up @@ -87,16 +89,21 @@ impl CliError {

impl IntoResponse for CliError {
// Maps gateway errors into a compact JSON HTTP response. Bad hook payloads are client errors,
// upstream gateway failures are bad gateway responses, and local install/config/runtime faults
// remain internal errors so callers do not mistake them for agent policy decisions.
// network-level upstream failures are bad gateway responses, provider failures mirror the
// upstream status when available, and local install/config/runtime faults remain internal
// errors so callers do not mistake them for agent policy decisions.
fn into_response(self) -> Response {
let message = self.to_string();
let guardrail_reason = self.guardrail_rejection_reason().map(ToOwned::to_owned);
let status = match (guardrail_reason.is_some(), self) {
let status = match (guardrail_reason.is_some(), &self) {
(true, _) => StatusCode::FORBIDDEN,
(false, Self::PayloadTooLarge(_)) => StatusCode::PAYLOAD_TOO_LARGE,
(false, Self::InvalidPayload(_)) => StatusCode::BAD_REQUEST,
(false, Self::Upstream(_)) => StatusCode::BAD_GATEWAY,
(false, Self::ProviderFailure(failure)) => failure
.status
.and_then(|status| StatusCode::from_u16(status).ok())
.unwrap_or(StatusCode::BAD_GATEWAY),
(
false,
Self::Http(_)
Expand Down
Loading