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
2 changes: 2 additions & 0 deletions Cargo.lock

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

2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Evaluate the complete agent, model pool, and routing configuration against your
| --- | --- | --- |
| **OpenRouter** | [Use Switchyard through OpenRouter](https://openrouter.ai/docs/guides/routing/routers/switchyard-router) | Hosted by OpenRouter. Set `model` to `nvidia/switchyard` in API requests. |
| **LiteLLM** | [Run the Switchyard routing-plugin example](examples/litellm/README.md#quick-start-with-the-local-proxy) | Experimental and checkout-only. The example pins LiteLLM 1.102.0 and supports Stage routing based on request history, plus Random routing. It cannot service intermediate model calls required by classifier or escalation algorithms. |
| **NeMo Relay** | [Build and configure the native plugin](crates/switchyard-nemo-relay-plugin/README.md#build-from-source) | Requires Relay `>=0.8.0, <1.0.0`. The source-build path requires a Rust toolchain and Python 3. |
| **NeMo Relay** | [Build and configure the native plugin](crates/switchyard-nemo-relay-plugin/README.md#build-from-source) | Requires Relay `>=0.10.0, <1.0.0`. The source-build path requires a Rust toolchain and Python 3. |

Relay 0.8.x and 0.9.0 can lose upstream error status and details when the plugin
is enabled, even for models outside its routes. Review the
Expand Down
42 changes: 35 additions & 7 deletions crates/libsy-llm-client/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,14 @@ async fn serve(
let algorithm = call.algorithm.clone();
let candidates = call.models.len();
call.respond(call_one(
&clients, &target, request, &algorithm, &observe, 0, candidates, true,
&clients,
&target,
request,
&algorithm,
&observe,
0,
candidates,
CallPhase::Routing,
))
.await
}
Expand All @@ -255,7 +262,7 @@ async fn call_first_available(
observe,
index,
models.len(),
false,
CallPhase::Completion,
)
.await
{
Expand All @@ -275,6 +282,25 @@ async fn call_first_available(
Err(LibsyError::NoTargets)
}

/// The stage of the Switchyard run in which a model call was issued.
///
/// A routing-phase call may still supply the final answer. This is deliberately
/// distinct from the `routing`/`answer` role assigned after the outcome is known.
#[derive(Clone, Copy)]
enum CallPhase {
Routing,
Completion,
}

impl CallPhase {
const fn as_str(self) -> &'static str {
match self {
Self::Routing => "routing",
Self::Completion => "completion",
}
}
}

/// Call one candidate model and record its observation and span.
#[allow(clippy::too_many_arguments)]
#[tracing::instrument(
Expand All @@ -286,6 +312,7 @@ async fn call_first_available(
switchyard.algorithm = algorithm,
switchyard.candidate = index + 1,
switchyard.candidate_count = count,
switchyard.call_phase = phase.as_str(),
selected_model = %model_id,
otel.kind = "client",
otel.name = %format_args!("chat {model_id}"),
Expand Down Expand Up @@ -325,8 +352,9 @@ async fn call_one(
index: usize,
// count is for span log
count: usize,
buffer: bool,
phase: CallPhase,
) -> Result<Response> {
let is_routing = matches!(phase, CallPhase::Routing);
let span = tracing::Span::current();
observability::record_gen_ai_request(&span, &request.llm_request);
if let Some(session_id) = request
Expand All @@ -342,7 +370,7 @@ async fn call_one(
let started = Instant::now();
let result = async {
let mut response = client?.call(request).await?;
if buffer && let LlmResponse::Stream(chunks) = response.llm_response {
if is_routing && let LlmResponse::Stream(chunks) = response.llm_response {
response.llm_response = LlmResponse::Stream(buffer_routing_stream(chunks).await?);
}
Ok(response)
Expand All @@ -356,10 +384,10 @@ async fn call_one(
response
});
let result = observability::observe_client_call(result);
let result = if !buffer {
metrics::observe_routed_request(algorithm, model_id, Some(duration), result)
} else {
let result = if is_routing {
result
} else {
metrics::observe_routed_request(algorithm, model_id, Some(duration), result)
};
observe(ModelCallObservation {
selected_model: model_id.clone(),
Expand Down
38 changes: 37 additions & 1 deletion crates/libsy-llm-client/tests/observability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1387,6 +1387,35 @@ async fn observed_run_reports_one_successful_routed_call() -> switchyard_libsy::
Ok(())
}

#[tokio::test]
async fn answered_routing_call_keeps_its_execution_phase() -> switchyard_libsy::Result<()> {
let _guard = serialize_test().lock().await;
let (store, _, _, _, _) = telemetry();
const ALGO: &str = "answered-routing-call";
const MODEL: &str = "answer-from-routing";

switchyard_llm_client::run(
Arc::new(RoutingCallAlgo {
name: ALGO.to_string(),
target: MODEL.into(),
}),
ClientRouter::single(Arc::new(UsageClient {
usage: Usage::default(),
})),
request_with_metadata("answered-routing-session", "answered-routing-correlation"),
Arc::new(RuntimeModels::default()),
None,
)
.await?;

let span = find_span(&store.spans(), "libsy.client_call", "selected_model", MODEL);
assert_eq!(
span.fields.get("switchyard.call_phase").map(String::as_str),
Some("routing")
);
Ok(())
}

/// A streamed response keeps the client span available until terminal usage arrives.
struct StreamingUsageClient;

Expand Down Expand Up @@ -1839,7 +1868,7 @@ async fn failed_call_records_metrics_without_error_details() -> switchyard_libsy
#[tokio::test]
async fn classifier_metrics_count_routing_and_answer_calls_once() -> switchyard_libsy::Result<()> {
let _guard = serialize_test().lock().await;
let (_store, exporter, provider, _, _) = telemetry();
let (store, exporter, provider, _, _) = telemetry();
let before = flushed_metrics(exporter, provider);
let total_requests_before =
u64_gauge_value(&before, "switchyard.total_requests").unwrap_or_default();
Expand All @@ -1854,6 +1883,13 @@ async fn classifier_metrics_count_routing_and_answer_calls_once() -> switchyard_

assert_eq!(selected_model, "weak");

let spans = store.spans();
let span = find_span(&spans, "libsy.client_call", "selected_model", "weak");
assert_eq!(
span.fields.get("switchyard.call_phase").map(String::as_str),
Some("completion")
);

let snapshots = flushed_metrics(exporter, provider);
assert_eq!(
u64_counter_value(
Expand Down
2 changes: 2 additions & 0 deletions crates/switchyard-nemo-relay-plugin/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ switchyard-runner.workspace = true
switchyard-translation.workspace = true
tokio.workspace = true
toml = "1.1"
tracing.workspace = true
tracing-subscriber.workspace = true

[dev-dependencies]
tempfile = "3"
Expand Down
6 changes: 6 additions & 0 deletions crates/switchyard-nemo-relay-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,12 @@ marks. Call marks distinguish routing from answer calls; decisions distinguish
selected from served models. Token metrics cover both call roles, while Relay
retains ownership of the outer LLM lifecycle.

The plugin also captures the existing `libsy.client_call` and
`libsy.upstream_attempt` spans for each routed request. It replays completed
spans beneath Relay's outer LLM scope with their original timestamps. The
projection includes allowlisted, payload-free routing metadata only; it excludes
prompts, responses, provider error bodies, and credentials.

## Observability

When Relay is configured with OTLP logs and metrics exporters, the plugin emits
Expand Down
63 changes: 40 additions & 23 deletions crates/switchyard-nemo-relay-plugin/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,20 @@

mod config;
mod runtime;
mod span_capture;
mod translation;

use std::sync::Arc;

use nemo_relay_plugin::{
ConfigDiagnostic, DiagnosticLevel, Json, NativePlugin, PluginContext, PluginRuntime,
};
use nemo_relay_plugin::{ConfigDiagnostic, DiagnosticLevel, Json, NativePlugin, PluginContext};
use serde_json::Map;

use crate::config::{SwitchyardConfig, protocol_from_call};
use crate::runtime::{SwitchyardRuntime, emit_event, emit_events};
use crate::runtime::{
RoutingEvent, RoutingEventEmitter, SwitchyardRuntime, emit_event as emit_routing_event,
emit_events,
};
use crate::span_capture::SpanCapture;

#[derive(Default)]
struct SwitchyardPlugin;
Expand Down Expand Up @@ -50,8 +53,24 @@ impl NativePlugin for SwitchyardPlugin {
let priority = config.priority;
let runtime = Arc::new(SwitchyardRuntime::new(config)?);
let plugin_runtime = ctx.runtime();
register_buffered(ctx, priority, Arc::clone(&runtime), plugin_runtime.clone())?;
register_stream(ctx, priority, runtime, plugin_runtime)?;
let event_runtime = Arc::clone(&runtime);
let emit_event: RoutingEventEmitter = Arc::new(move |event| {
emit_routing_event(&plugin_runtime, event_runtime.redactor(), event)
});
let span_runtime = Arc::clone(&runtime);
let span_emit_event = Arc::clone(&emit_event);
let span_capture = SpanCapture::new(move |mut span| {
span.sanitize(span_runtime.redactor());
span_emit_event(RoutingEvent::Span(span));
});
register_buffered(
ctx,
priority,
Arc::clone(&runtime),
Arc::clone(&emit_event),
span_capture.clone(),
)?;
register_stream(ctx, priority, runtime, emit_event, span_capture)?;
Ok(())
}
}
Expand All @@ -60,14 +79,16 @@ fn register_buffered(
ctx: &mut PluginContext<'_>,
priority: i32,
runtime: Arc<SwitchyardRuntime>,
plugin_runtime: PluginRuntime,
emit_event: RoutingEventEmitter,
span_capture: SpanCapture,
) -> Result<(), String> {
ctx.register_llm_execution_intercept(
"switchyard.runner.buffered",
priority,
move |name, request, _context, next| {
let runtime = Arc::clone(&runtime);
let plugin_runtime = plugin_runtime.clone();
let emit_event = Arc::clone(&emit_event);
let span_capture = span_capture.clone();
async move {
let Some(inbound) = protocol_from_call(&name) else {
return next.call(request).await;
Expand All @@ -76,8 +97,10 @@ fn register_buffered(
return next.call(request).await;
}
let decoded = runtime.decode_request(inbound, request, false)?;
let execution = runtime.execute_buffered(inbound, decoded).await;
emit_events(&plugin_runtime, runtime.redactor(), execution.events);
let execution = span_capture
.capture(runtime.execute_buffered(inbound, decoded))
.await;
emit_events(&emit_event, execution.events);
execution.result
}
},
Expand All @@ -88,14 +111,16 @@ fn register_stream(
ctx: &mut PluginContext<'_>,
priority: i32,
runtime: Arc<SwitchyardRuntime>,
plugin_runtime: PluginRuntime,
emit_event: RoutingEventEmitter,
span_capture: SpanCapture,
) -> Result<(), String> {
ctx.register_llm_stream_execution_intercept(
"switchyard.runner.streaming",
priority,
move |name, request, _context, next| {
let runtime = Arc::clone(&runtime);
let plugin_runtime = plugin_runtime.clone();
let emit_event = Arc::clone(&emit_event);
let span_capture = span_capture.clone();
async move {
let Some(inbound) = protocol_from_call(&name) else {
return next.call(request).await;
Expand All @@ -104,18 +129,10 @@ fn register_stream(
return next.call(request).await;
}
let decoded = runtime.decode_request(inbound, request, true)?;
let stream_plugin_runtime = plugin_runtime.clone();
let stream_runtime = Arc::clone(&runtime);
let execution = runtime
.execute_stream(
inbound,
decoded,
Arc::new(move |event| {
emit_event(&stream_plugin_runtime, stream_runtime.redactor(), event)
}),
)
let execution = span_capture
.capture(runtime.execute_stream(inbound, decoded, Arc::clone(&emit_event)))
.await;
emit_events(&plugin_runtime, runtime.redactor(), execution.events);
emit_events(&emit_event, execution.events);
execution.result
}
},
Expand Down
27 changes: 22 additions & 5 deletions crates/switchyard-nemo-relay-plugin/src/redaction_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ use std::process::Command;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};

use crate::span_capture::SpanCapture;

use super::*;

const KEY_ENV: &str = "SWITCHYARD_RELAY_REDACTION_TEST_KEY";
Expand Down Expand Up @@ -122,16 +124,22 @@ async fn reflected_response(format: WireFormat, streaming: bool, fail: bool) {
}),
};
let captured = Arc::new(Mutex::new(Vec::new()));
let span_observed = Arc::clone(&captured);
let redactor = Arc::clone(&runtime.redactor);
let span_capture = SpanCapture::new(move |mut span| {
span.sanitize(&redactor);
span_observed.lock().unwrap().push(RoutingEvent::Span(span));
});
let output = if streaming {
let observed = Arc::clone(&captured);
let execution = runtime
.execute_stream(
let execution = span_capture
.capture(runtime.execute_stream(
format,
request,
Arc::new(move |event| {
observed.lock().unwrap().push(event);
}),
)
))
.await;
assert!(
captured.lock().unwrap().is_empty(),
Expand All @@ -142,7 +150,9 @@ async fn reflected_response(format: WireFormat, streaming: bool, fail: bool) {
assert_eq!(events.iter().any(Result::is_err), fail);
format!("{events:?}")
} else {
let execution = runtime.execute_buffered(format, request).await;
let execution = span_capture
.capture(runtime.execute_buffered(format, request))
.await;
captured.lock().unwrap().extend(execution.events);
assert_eq!(execution.result.is_err(), fail);
format!("{:?}", execution.result)
Expand All @@ -155,7 +165,14 @@ async fn reflected_response(format: WireFormat, streaming: bool, fail: bool) {
assert!(output.contains("ordinary diagnostics"), "{output}");
assert!(output.contains("[REDACTED]"), "{output}");
}
let telemetry = format!("{:?}", captured.lock().unwrap());
let captured = captured.lock().unwrap();
assert!(
captured
.iter()
.any(|event| matches!(event, RoutingEvent::Span(_))),
"client span was not emitted"
);
let telemetry = format!("{captured:?}");
assert!(!telemetry.contains(KEY), "{telemetry}");
assert!(telemetry.contains("[REDACTED]"), "{telemetry}");
}
Expand Down
Loading
Loading