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
62 changes: 26 additions & 36 deletions crates/libsy-llm-client/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ use reqwest::RequestBuilder;
use reqwest::header::{HeaderMap, RETRY_AFTER};
use serde_json::{Map, Value};
use switchyard_protocol::{
Decision, LlmRequest, LlmResponse, Metadata, ModelId, Request, Response, RoutedLlmClient,
LlmRequest, LlmResponse, Metadata, ModelId, Request, Response, RoutedLlmClient,
};
use switchyard_translation::{
WireFormat, decode_aggregated_response, decode_request, decode_stream,
Expand Down Expand Up @@ -144,10 +144,11 @@ impl TranslatingLlmClient {
message: format!("model {model} has no Anthropic backend for count_tokens"),
})?;
let Request {
llm_request,
mut llm_request,
metadata,
..
} = request;
llm_request.model = Some(model.to_string());
let http_response = self
.send_encoded(
backend,
Expand All @@ -171,9 +172,9 @@ impl TranslatingLlmClient {
})
}

/// Encode `llm_request` (its model restamped to `model`) for `wire_format`,
/// POST it to `url` with the request's forwarded headers plus the backend's
/// static headers and auth, and return the successful upstream response. A
/// Encode `llm_request` for `wire_format`, POST it to `url` with the request's
/// forwarded headers plus the backend's static headers and auth, and return the
/// successful upstream response. A
/// buffered response is fully collected within the retry boundary; a streamed
/// response is returned as soon as its successful headers arrive. A non-success
/// status maps to a typed error — a 400 is classified as a context-window
Expand All @@ -186,13 +187,11 @@ impl TranslatingLlmClient {
&self,
backend: &Backend,
wire_format: WireFormat,
mut llm_request: LlmRequest,
llm_request: LlmRequest,
metadata: Option<&Metadata>,
model: &ModelId,
endpoint: UpstreamEndpoint,
) -> Result<EncodedResponse> {
// The resolved name is the upstream model id (per the crate contract).
llm_request.model = Some(model.to_string());
let mut body = encode_request(&llm_request, wire_format)
.map_err(|error| LlmClientError::RequestEncoding(error.to_string()))?;
// `encode_request` round-trips a preserved same-format body verbatim,
Expand Down Expand Up @@ -365,43 +364,42 @@ impl TranslatingLlmClient {
request: Request,
model_name: Option<&ModelId>,
) -> Result<Response> {
// Own the request's parts so the model can be set without a `mut` param
// and without cloning the messages. `raw_request` is unused here.
let Request {
llm_request,
mut llm_request,
metadata,
..
} = request;

let model = model_name
let model_id = model_name
.cloned()
.or_else(|| llm_request.model.clone().map(ModelId::from))
.or_else(|| llm_request.model.map(ModelId::from))
.ok_or_else(|| LlmClientError::InvalidRequest {
message: "no model given".to_string(),
})?;
llm_request.model = Some(model_id.to_string());

let orig_format = metadata.as_ref().and_then(|m| m.wire_format);
let wire_format = orig_format.unwrap_or(
self.model_to_config
.get(&model)
.get(&model_id)
.map(|config| config.default_backend.wire_format())
.ok_or_else(|| LlmClientError::Configuration {
message: format!("no backend configured for model {model:?}"),
message: format!("no backend configured for model {model_id:?}"),
})?,
);
let backend =
self.backend_for(&model, wire_format)
.ok_or_else(|| LlmClientError::Configuration {
message: format!("model {model:?} has no backend for format {wire_format}"),
})?;
let backend = self.backend_for(&model_id, wire_format).ok_or_else(|| {
LlmClientError::Configuration {
message: format!("model {model_id:?} has no backend for format {wire_format}"),
}
})?;

let http_response = self
.send_encoded(
backend,
wire_format,
llm_request,
metadata.as_ref(),
&model,
&model_id,
UpstreamEndpoint::Completion,
)
.await?;
Expand Down Expand Up @@ -505,9 +503,8 @@ impl TranslatingLlmClient {

#[async_trait]
impl RoutedLlmClient for TranslatingLlmClient {
async fn call(&self, request: Request, decision: Decision) -> Result<Response> {
let model_name = Some(decision.selected_model_id());
self.call_rewrite_model(request, model_name).await
async fn call(&self, request: Request) -> Result<Response> {
self.call_rewrite_model(request, None).await
}
}

Expand Down Expand Up @@ -1710,9 +1707,8 @@ mod tests {
client.client = reqwest::Client::builder()
.timeout(std::time::Duration::from_millis(10))
.build()?;
let decision = fixed_decision("gpt");

let Err(error) = client.call(request_for(None, false), decision).await else {
let Err(error) = client.call(request_for(Some("gpt"), false)).await else {
panic!("expected a timeout");
};
let LlmClientError::Timeout { source } = error else {
Expand Down Expand Up @@ -1809,14 +1805,10 @@ mod tests {
Ok(())
}

fn fixed_decision(target: &str) -> Decision {
Decision::new(target, None, true)
}

// Exercises the `RoutedLlmClient` impl: `call` resolves the upstream model from the
// decision (the request carries none) and round-trips a buffered response.
// Exercises the `RoutedLlmClient` impl: `call` uses the model already materialized in the
// request and round-trips a buffered response.
#[tokio::test]
async fn routed_llm_client_serves_the_decision_model()
async fn routed_llm_client_serves_the_request_model()
-> std::result::Result<(), Box<dyn Error + Sync + Send + 'static>> {
let server = MockServer::start().await;
Mock::given(method("POST"))
Expand All @@ -1835,9 +1827,7 @@ mod tests {
.await;

let client = TranslatingLlmClient::new(&chat_map(&format!("{}/v1", server.uri())))?;
let decision = fixed_decision("gpt");
// Called through the trait; the request has no model, so "gpt" comes from the decision.
let response = client.call(request_for(None, false), decision).await?;
let response = client.call(request_for(Some("gpt"), false)).await?;
let agg = response.llm_response.into_agg().await?;
assert_eq!(completion_text(&agg), "routed hi");
Ok(())
Expand Down
17 changes: 8 additions & 9 deletions crates/libsy-llm-client/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,12 @@ impl RoutedCallWindows {
fields(
algorithm = call.algorithm,
switchyard.algorithm = call.algorithm,
selected_model = %call.decision.selected_model_id(),
selected_model = call.selected_model_id(),
otel.kind = "client",
otel.name = %format_args!("chat {}", call.decision.selected_model_id()),
otel.name = %format_args!("chat {}", call.selected_model_id()),
openinference.span.kind = "LLM",
gen_ai.operation.name = "chat",
gen_ai.request.model = %call.decision.selected_model_id(),
gen_ai.request.model = call.selected_model_id(),
gen_ai.request.stream = tracing::field::Empty,
gen_ai.request.temperature = tracing::field::Empty,
gen_ai.request.top_p = tracing::field::Empty,
Expand Down Expand Up @@ -155,26 +155,25 @@ async fn serve(
{
span.record("gen_ai.conversation.id", session_id);
}
let target = ModelId::from(call.selected_model_id());
let request = call.request.clone();
let decision = call.decision.clone();
let target = decision.selected_model_id().clone();
let is_answer_call = decision.is_answer_call();
let is_answer_call = call.decision.is_answer_call();
// Resolved before the clock starts: picking the client is Switchyard's work, not
// the provider's, so it belongs in the routing overhead.
let client = clients.route(&target);
let started = Instant::now();
let result = match client {
Ok(client) => client.call(request, decision).await,
Ok(client) => client.call(request).await,
Err(error) => Err(error),
}
.map_err(|source| LibsyError::client_call(target, source));
.map_err(|source| LibsyError::client_call(target.clone(), source));
let ended = Instant::now();
let duration = ended - started;

let result = observability::observe_client_call(result);
if let Some(observer) = observer {
observer(RunObservation::LlmCall(LlmCallObservation {
selected_model: call.decision.selected_model_id().clone(),
selected_model: target,
is_answer_call,
is_success: result.is_ok(),
duration,
Expand Down
49 changes: 19 additions & 30 deletions crates/libsy-llm-client/tests/observability.rs
Original file line number Diff line number Diff line change
Expand Up @@ -327,27 +327,24 @@ struct UsageClient {
/// Client that returns a weak classifier verdict. The delays let a test tell
/// classifier time apart from routed-call time.
struct ClassifierClient {
classifier_model_id: ModelId,
classifier_delay: Duration,
routed_delay: Duration,
}

#[async_trait]
impl RoutedLlmClient for ClassifierClient {
async fn call(
&self,
_request: Request,
decision: Decision,
) -> Result<Response, LlmClientError> {
let model = decision.selected_model_id().to_string();
let completion = if decision.is_answer_call() {
async fn call(&self, request: Request) -> Result<Response, LlmClientError> {
let model_id = request.model_id().unwrap_or_default();
let completion = if model_id != self.classifier_model_id {
tokio::time::sleep(self.routed_delay).await;
"routed response"
} else {
tokio::time::sleep(self.classifier_delay).await;
r#"{"crux":"bounded task","primary_rule":"SUP-1","capability_boundary":"supported","p_solve":0.9}"#
};
Ok(Response {
llm_response: LlmResponse::Agg(text_response(Some(model), completion)),
llm_response: LlmResponse::Agg(text_response(Some(model_id.to_string()), completion)),
metadata: None,
})
}
Expand All @@ -361,20 +358,17 @@ enum JudgeOutcome {

/// Returns one configured judge outcome and serves the selected target normally.
struct JudgeClient {
judge_model: ModelId,
outcome: JudgeOutcome,
}

#[async_trait]
impl RoutedLlmClient for JudgeClient {
async fn call(
&self,
_request: Request,
decision: Decision,
) -> Result<Response, LlmClientError> {
if decision.is_answer_call() {
async fn call(&self, request: Request) -> Result<Response, LlmClientError> {
if request.model_id().as_deref().unwrap_or_default() != self.judge_model {
return Ok(Response {
llm_response: LlmResponse::Agg(text_response(
Some(decision.selected_model_id().to_string()),
request.model_id().map(|id| id.to_string()),
"routed response",
)),
metadata: None,
Expand Down Expand Up @@ -408,11 +402,10 @@ impl RoutedLlmClient for JudgeClient {
impl RoutedLlmClient for UsageClient {
async fn call(
&self,
_request: Request,
decision: Decision,
request: Request,
) -> Result<Response, switchyard_protocol::LlmClientError> {
let mut response = text_response(
Some(decision.selected_model_id().to_string()),
request.model_id().map(|s| s.to_string()),
"observed response",
);
response.id = Some("obs-response-1".to_string());
Expand Down Expand Up @@ -967,11 +960,7 @@ struct StreamingUsageClient;

#[async_trait]
impl RoutedLlmClient for StreamingUsageClient {
async fn call(
&self,
_request: Request,
decision: Decision,
) -> Result<Response, LlmClientError> {
async fn call(&self, request: Request) -> Result<Response, LlmClientError> {
let usage = Usage {
input_tokens: Some(13),
output_tokens: Some(5),
Expand All @@ -981,7 +970,7 @@ impl RoutedLlmClient for StreamingUsageClient {
let chunks = vec![Ok(LlmResponseStreamEvent::new(vec![
LlmResponseChunk::MessageStart {
id: Some("obs-stream-response".to_string()),
model: Some(decision.selected_model_id().to_string()),
model: request.model_id().map(|s| s.to_string()),
},
LlmResponseChunk::Usage(usage),
LlmResponseChunk::MessageStop {
Expand All @@ -999,11 +988,7 @@ struct TimeoutClient;

#[async_trait]
impl RoutedLlmClient for TimeoutClient {
async fn call(
&self,
_request: Request,
_decision: Decision,
) -> Result<Response, LlmClientError> {
async fn call(&self, _request: Request) -> Result<Response, LlmClientError> {
Err(LlmClientError::Timeout {
source: Box::new(TestError("upstream timed out")),
})
Expand Down Expand Up @@ -1236,6 +1221,7 @@ async fn classifier_metrics_count_only_the_final_routed_call() -> switchyard_lib
u64_gauge_value(&before, "switchyard.total_requests").unwrap_or_default();

let client = Arc::new(ClassifierClient {
classifier_model_id: "classifier".into(),
classifier_delay: Duration::from_millis(60),
routed_delay: Duration::from_millis(200),
}) as Arc<dyn RoutedLlmClient>;
Expand Down Expand Up @@ -1329,7 +1315,10 @@ async fn classifier_fail_open_records_each_failure_stage() -> switchyard_libsy::
];

for (judge_model, outcome, expected_reason) in cases {
let client = Arc::new(JudgeClient { outcome }) as Arc<dyn RoutedLlmClient>;
let client = Arc::new(JudgeClient {
judge_model: judge_model.into(),
outcome,
}) as Arc<dyn RoutedLlmClient>;
run(
classifier_router(judge_model, "fo-weak", "fo-strong")?,
client,
Expand Down
14 changes: 7 additions & 7 deletions crates/libsy/src/algorithms/noop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@
use std::sync::Arc;

use switchyard_protocol::{
AggLlmResponse, ContentBlock, LlmResponse, Request, Response, ResponseOutput, Role, StopReason,
AggLlmResponse, ContentBlock, LlmResponse, ModelId, Request, Response, ResponseOutput, Role,
StopReason,
};

use crate::Result;
Expand All @@ -23,20 +24,19 @@ impl Algorithm for Noop {
}

async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<Response> {
let model = request
.requested_model()
.unwrap_or("switchyard/noop")
.to_string();
let model_id = request
.model_id()
.unwrap_or_else(|| ModelId::from("switchyard/noop"));
let decision: Decision = Decision::new(
model.clone(),
model_id.clone(),
Some("noop returned its synthetic response".to_string()),
true,
);
driver.decide(decision.clone()).await?;

let llm_response = LlmResponse::Agg(AggLlmResponse {
id: Some("switchyard-noop".to_string()),
model: Some(model),
model: Some(model_id.to_string()),
outputs: vec![ResponseOutput {
role: Role::Assistant,
content: vec![ContentBlock::Text {
Expand Down
Loading
Loading