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
20 changes: 13 additions & 7 deletions crates/libsy-llm-client/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ pub async fn run(
request: Request,
models: Arc<RuntimeModels>,
observer: Option<RunObserver>,
) -> Result<(ModelId, Response)> {
) -> Result<(ModelId, Response, Option<OutcomeMetadata>)> {
let algorithm_name = algorithm.name().to_string();
let run_started = Instant::now();
let routing_clients = clients.clone();
Expand Down Expand Up @@ -85,6 +85,10 @@ pub async fn run(
metrics::record_routing_overhead(&algorithm_name, overhead);

let selected_model_id = outcome.selected_model_id()?.clone();
// Captured before `outcome.metadata` is moved into the observer below. This is the
// algorithm's own record of WHY it chose, and the server needs it on the wire: the
// response body and the selected-model header both describe WHAT was chosen, never why.
let metadata = outcome.metadata.clone();
if let Some(observer) = &observer
&& let Some(metadata) = outcome.metadata
{
Expand Down Expand Up @@ -118,7 +122,7 @@ pub async fn run(
if let Some(observer) = &observer {
observer(RunObservation::RoutingOverhead(overhead));
}
result.map(|response| (selected_model_id, response))
result.map(|response| (selected_model_id, response, metadata))
}

/// Run an algorithm to a routing decision without serving its terminal completion.
Expand Down Expand Up @@ -1085,6 +1089,7 @@ mod tests {
None,
)
.await;
let result = result.map(|(selected, response, _metadata)| (selected, response));
(client, result)
}

Expand All @@ -1109,7 +1114,7 @@ mod tests {
let observed = Arc::clone(&observations);
let observer: RunObserver = Arc::new(move |event| observed.lock().push(event));

let (selected, response) = run(
let (selected, response, _metadata) = run(
Arc::new(AnsweredAlgorithm {
model: "weak".into(),
}),
Expand Down Expand Up @@ -1235,7 +1240,7 @@ mod tests {
.fields
.insert("conversation".to_string(), conversation.clone());
}
let (_, response) = run(
let (_, response, _metadata) = run(
Arc::new(switchyard_libsy::Passthrough),
clients.clone(),
seed,
Expand Down Expand Up @@ -1385,7 +1390,7 @@ mod tests {
raw_request: None,
metadata: None,
};
let (_, response) = run(
let (_, response, _metadata) = run(
Arc::new(switchyard_libsy::Passthrough),
clients.clone(),
seed,
Expand Down Expand Up @@ -1423,7 +1428,7 @@ mod tests {
raw_request: None,
metadata: None,
};
let (_, response) = run(
let (_, response, _metadata) = run(
Arc::new(switchyard_libsy::Passthrough),
clients.clone(),
follow,
Expand Down Expand Up @@ -1462,7 +1467,7 @@ mod tests {
assert_eq!(history.len, 4);
assert_eq!(history.segment.len(), 2);
assert!(history.parent.is_some());
let (_, response) = run(
let (_, response, _metadata) = run(
Arc::new(switchyard_libsy::Passthrough),
clients,
third,
Expand Down Expand Up @@ -2156,6 +2161,7 @@ mod tests {
None,
)
.await;
let result = result.map(|(selected, response, _metadata)| (selected, response));
(server, calls, result)
}

Expand Down
10 changes: 6 additions & 4 deletions crates/libsy-llm-client/tests/observability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,7 @@ async fn run(
None,
)
.await
.map(|(selected, response, _metadata)| (selected, response))
}

fn classifier_router() -> switchyard_libsy::Result<Arc<dyn Algorithm>> {
Expand Down Expand Up @@ -647,6 +648,7 @@ async fn run_classifier(
None,
)
.await
.map(|(selected, response, _metadata)| (selected, response))
}

fn classifier_request() -> Request {
Expand Down Expand Up @@ -875,7 +877,7 @@ async fn affinity_keeps_the_algorithm_selection_after_client_fallback()
})?) as Arc<dyn Algorithm>;
let request = request_with_metadata("affinity-fallback-session", "affinity-fallback-first");

let (selected, first_response) = switchyard_llm_client::run(
let (selected, first_response, _) = switchyard_llm_client::run(
Arc::clone(&router),
ClientRouter::single(client.clone()),
request.clone(),
Expand Down Expand Up @@ -938,7 +940,7 @@ async fn affinity_keeps_the_algorithm_selection_after_client_fallback()
}

client.efficient_available.store(true, Ordering::Relaxed);
let (selected, second_response) = switchyard_llm_client::run(
let (selected, second_response, _) = switchyard_llm_client::run(
router,
ClientRouter::single(client.clone()),
request,
Expand Down Expand Up @@ -1289,7 +1291,7 @@ async fn stage_router_records_algorithm_owned_metrics() -> switchyard_libsy::Res
usage: Usage::default(),
}) as Arc<dyn RoutedLlmClient>;

let (selected_model, _) = switchyard_llm_client::run(
let (selected_model, _, _) = switchyard_llm_client::run(
algorithm,
ClientRouter::single(client),
request,
Expand Down Expand Up @@ -1343,7 +1345,7 @@ async fn observed_run_reports_one_successful_routed_call() -> switchyard_libsy::
usage: Usage::default(),
}) as Arc<dyn RoutedLlmClient>;

let (_, response) = switchyard_llm_client::run(
let (_, response, _) = switchyard_llm_client::run(
algo(ALGO, MODEL),
ClientRouter::single(client),
request_with_metadata("observed-session", "observed-correlation"),
Expand Down
8 changes: 6 additions & 2 deletions crates/switchyard-runner/src/route.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
use std::error::Error;
use std::sync::Arc;

use libsy::{Algorithm, LibsyError, RoutingOutcome, RuntimeModels};
use libsy::{Algorithm, LibsyError, OutcomeMetadata, RoutingOutcome, RuntimeModels};
use serde_json::Value;
use switchyard_llm_client::{AuxiliaryOperation, ClientRouter, RunObserver, TranslatingLlmClient};
use switchyard_protocol::{LlmClientError, ModelId, Request, Response, WireFormat};
Expand Down Expand Up @@ -124,6 +124,9 @@ pub struct Route {
pub struct RunOutput {
pub selected_model: ModelId,
pub response: Response,
/// The algorithm's record of why it chose, carried so the server can expose the
/// decision's provenance on the wire. `None` when the algorithm produced no metadata.
pub metadata: Option<OutcomeMetadata>,
}

impl Route {
Expand Down Expand Up @@ -195,7 +198,7 @@ impl Route {
request: Request,
observer: Option<RunObserver>,
) -> Result<RunOutput, RunnerError> {
let (selected_model, response) = switchyard_llm_client::run(
let (selected_model, response, metadata) = switchyard_llm_client::run(
Arc::clone(&self.algorithm),
self.clients.clone(),
request,
Expand All @@ -206,6 +209,7 @@ impl Route {
Ok(RunOutput {
selected_model,
response,
metadata,
})
}

Expand Down
45 changes: 42 additions & 3 deletions crates/switchyard-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Extension, Json, Router};
use axum_server::tls_rustls::RustlsConfig;
use libsy::{LibsyError, RoutingOutcome};
use libsy::{LibsyError, OutcomeMetadata, RoutingOutcome};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
Expand Down Expand Up @@ -65,6 +65,15 @@ pub const DEFAULT_GRACEFUL_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(30);
pub const DEFAULT_MAX_REQUEST_BODY_BYTES: usize = 32 * 1024 * 1024;

const HEADER_SELECTED_MODEL: &str = "x-model-router-selected-model";

/// Why the router chose this model. Paired with [`HEADER_SELECTED_MODEL`], which reports
/// WHAT was chosen; neither is meaningful without the other.
///
/// `docs/routing_algorithms/stage_router_routing.md` describes "two routing headers" but
/// only ever listed the selected-model one. The deciding component was already recorded
/// internally as `DecisionSource` and carried as `OutcomeMetadata::evidence`, so this
/// header forwards existing state rather than introducing a new mechanism.
const HEADER_DECISION_SOURCE: &str = "x-model-router-decision-source";
const FORWARDED_UPSTREAM_HEADERS: &[&str] = &[
"baggage",
"openai-processing-ms",
Expand Down Expand Up @@ -987,6 +996,18 @@ fn resolve_route(
"invalid_request_error",
)
})?;
// Every wire format normalizes turns into `messages`, so an empty vec means
// the body carried no conversation at all. Reject it here, next to the
// `model` check, so a client error costs no upstream round trip and is not
// reported as an upstream failure.
if llm_request.messages.is_empty() {
return Err(error_response(
StatusCode::BAD_REQUEST,
"request body must include a non-empty `messages` array",
"invalid_request_error",
"invalid_request_error",
));
}
let route = state.route_for_model(&requested_model).ok_or_else(|| {
error_response(
StatusCode::NOT_FOUND,
Expand Down Expand Up @@ -1076,6 +1097,7 @@ async fn handle_llm_request(
let RunOutput {
selected_model,
response,
metadata,
} = output;
// The response carries the candidate that actually served it. Fall back to the routing
// selection for algorithms that return a response without an offloaded model call.
Expand Down Expand Up @@ -1119,7 +1141,7 @@ async fn handle_llm_request(
response_headers.append(name.clone(), value.clone());
}
if let Some(served_model) = served_model.as_ref() {
attach_routing_headers(&mut response, served_model.as_str());
attach_routing_headers(&mut response, served_model.as_str(), metadata.as_ref());
}
response
}
Expand Down Expand Up @@ -1238,8 +1260,25 @@ fn metadata_from_headers(headers: HeaderMap) -> Metadata {
metadata
}

fn attach_routing_headers(response: &mut Response, served_model: &str) {
fn attach_routing_headers(
response: &mut Response,
served_model: &str,
metadata: Option<&OutcomeMetadata>,
) {
insert_routing_header(response, HEADER_SELECTED_MODEL, served_model);
if let Some(source) = metadata.and_then(decision_source_of) {
insert_routing_header(response, HEADER_DECISION_SOURCE, &source);
}
}

/// Extracts the deciding component from an outcome's evidence.
///
/// Built-in algorithms record a stable `source` string (`override`, `dimensions`,
/// `llm-classifier`, `fall_open`, ...) in their evidence. Anything else - a custom
/// algorithm, a route with no evidence at all - yields `None`, which simply omits the
/// header rather than inventing a reason.
fn decision_source_of(metadata: &OutcomeMetadata) -> Option<String> {
metadata.evidence.as_ref()?.get("source")?.as_str().map(str::to_string)
}

fn insert_routing_header(response: &mut Response, name: &'static str, value: &str) {
Expand Down
Loading
Loading