Skip to content

Commit e9a30fb

Browse files
committed
feat(libsy): prepare requests for routed candidates
Signed-off-by: Alex Fournier <afournier@nvidia.com>
1 parent 55d2a22 commit e9a30fb

18 files changed

Lines changed: 527 additions & 154 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/libsy-llm-client/src/run.rs

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -62,17 +62,17 @@ pub async fn run(
6262
.and_then(|outcome| outcome.response.as_ref())
6363
.and_then(Response::served_model);
6464
emit_routing_observations(&observer, &routing_observations, answered_model);
65-
let outcome = outcome?;
65+
let mut outcome = outcome?;
6666
let overhead = run_started.elapsed();
6767
metrics::record_routing_overhead(&algorithm_name, overhead);
6868

69-
let selected_model_id = outcome.selected_model_id;
70-
let (result, answer_duration) = if let Some(response) = outcome.response {
69+
let selected_model_id = outcome.selected_model_id.clone();
70+
let (result, answer_duration) = if let Some(response) = outcome.response.take() {
7171
(Ok(response), None)
7272
} else {
7373
let mut models = Vec::with_capacity(1 + outcome.fallback_models.len());
7474
models.push(selected_model_id.clone());
75-
models.extend(outcome.fallback_models);
75+
models.extend(outcome.fallback_models.iter().cloned());
7676
let answer_started = Instant::now();
7777
let observe = |observation| {
7878
if let Some(observer) = &observer {
@@ -82,8 +82,8 @@ pub async fn run(
8282
let result = call_first_available(
8383
&clients,
8484
&algorithm_name,
85-
&outcome.request,
8685
&models,
86+
move |target| outcome.request_for(target),
8787
&observe,
8888
)
8989
.await;
@@ -142,8 +142,8 @@ async fn serve(
142142
let result = call_first_available(
143143
&clients,
144144
&call.algorithm,
145-
&call.request,
146145
&call.models,
146+
|target| call.request_for(target),
147147
&observe,
148148
)
149149
.await;
@@ -154,12 +154,12 @@ async fn serve(
154154
async fn call_first_available(
155155
clients: &ClientRouter,
156156
algorithm: &str,
157-
request: &Request,
158157
models: &[ModelId],
158+
request_for: impl Fn(&ModelId) -> Result<Request> + Send,
159159
observe: &(dyn Fn(LlmCallObservation) + Send + Sync),
160160
) -> Result<Response> {
161161
for (index, target) in models.iter().enumerate() {
162-
let request = request_for(request, target);
162+
let request = request_for(target)?;
163163
match call_one(
164164
clients,
165165
target,
@@ -298,13 +298,6 @@ fn fallback_reason(error: &LibsyError) -> Option<RoutingFallbackReason> {
298298
}
299299
}
300300

301-
/// Clone a request and stamp the candidate model that should receive it.
302-
fn request_for(request: &Request, target: &ModelId) -> Request {
303-
let mut request = request.clone();
304-
request.llm_request.model = Some(target.to_string());
305-
request
306-
}
307-
308301
/// Resolves a routed call's selected model to the client that serves it.
309302
///
310303
/// An algorithm routes among named targets; which provider each target lives on is the
@@ -379,10 +372,10 @@ mod tests {
379372
use async_trait::async_trait;
380373
use futures::StreamExt;
381374
use http::StatusCode;
382-
use switchyard_libsy::{Driver, RoutingOutcome};
375+
use switchyard_libsy::{Driver, RoutingOutcome, TargetPrompts, with_target_prompts};
383376
use switchyard_protocol::{
384-
LlmResponse, LlmResponseChunk, LlmResponseStreamEvent, completion_text, text_request,
385-
text_response,
377+
ContentBlock, LlmResponse, LlmResponseChunk, LlmResponseStreamEvent, completion_text,
378+
text_request, text_response,
386379
};
387380
use wiremock::matchers::method;
388381
use wiremock::{Mock, MockServer, ResponseTemplate};
@@ -429,7 +422,7 @@ mod tests {
429422
request: Request,
430423
) -> Result<RoutingOutcome> {
431424
let response = driver
432-
.call_model(request.clone(), vec![self.model.clone()])
425+
.call_answer_model(request.clone(), self.model.clone())
433426
.await?;
434427
Ok(RoutingOutcome::answered(
435428
self.model.clone(),
@@ -449,6 +442,7 @@ mod tests {
449442

450443
struct CandidateClient {
451444
calls: Mutex<Vec<ModelId>>,
445+
prompts: Mutex<Vec<Vec<String>>>,
452446
first: FirstOutcome,
453447
}
454448

@@ -457,6 +451,18 @@ mod tests {
457451
async fn call(&self, request: Request) -> std::result::Result<Response, LlmClientError> {
458452
let model = request.model_id().unwrap_or_default();
459453
self.calls.lock().push(model.clone());
454+
self.prompts.lock().push(
455+
request
456+
.llm_request
457+
.instructions
458+
.iter()
459+
.flat_map(|instruction| &instruction.content)
460+
.filter_map(|block| match block {
461+
ContentBlock::Text { text } => Some(text.clone()),
462+
_ => None,
463+
})
464+
.collect(),
465+
);
460466
if model == "weak" {
461467
return match self.first {
462468
FirstOutcome::ContextWindow => Err(LlmClientError::ContextWindowExceeded {
@@ -521,11 +527,16 @@ mod tests {
521527
) -> (Arc<CandidateClient>, Result<(ModelId, Response)>) {
522528
let client = Arc::new(CandidateClient {
523529
calls: Mutex::new(Vec::new()),
530+
prompts: Mutex::new(Vec::new()),
524531
first,
525532
});
526-
let algorithm = Arc::new(CandidateAlgorithm {
533+
let inner: Arc<dyn Algorithm> = Arc::new(CandidateAlgorithm {
527534
models: vec!["weak".into(), "strong".into()],
528535
});
536+
let prompts = TargetPrompts::default()
537+
.with("weak", "weak prompt")
538+
.with("strong", "strong prompt");
539+
let algorithm = with_target_prompts(inner, prompts);
529540
let result = run(
530541
algorithm,
531542
ClientRouter::single(client.clone()),
@@ -540,6 +551,7 @@ mod tests {
540551
async fn answered_outcome_does_not_make_a_second_model_call() -> Result<()> {
541552
let client = Arc::new(CandidateClient {
542553
calls: Mutex::new(Vec::new()),
554+
prompts: Mutex::new(Vec::new()),
543555
first: FirstOutcome::StreamSuccess,
544556
});
545557
let observations = Arc::new(Mutex::new(Vec::new()));
@@ -621,6 +633,13 @@ mod tests {
621633
&*client.calls.lock(),
622634
&[ModelId::from("weak"), "strong".into()]
623635
);
636+
assert_eq!(
637+
&*client.prompts.lock(),
638+
&[
639+
vec!["weak prompt".to_string()],
640+
vec!["strong prompt".to_string()]
641+
]
642+
);
624643
assert_eq!(
625644
response
626645
.llm_response

crates/libsy/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ parking_lot.workspace = true
3232
rand.workspace = true
3333
regex.workspace = true
3434
switchyard-protocol.workspace = true
35+
switchyard-translation.workspace = true
3536
thiserror.workspace = true
3637
tokio.workspace = true
3738
tokio-stream = "0.1"

crates/libsy/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,15 @@ fallbacks, rewritten request, and an optional response already produced while ro
3838
makes no network calls itself — `switchyard-llm-client`'s `run` is a ready-made consumer that
3939
drives the stream and performs the terminal answer call, retries, and fallback over HTTP.
4040

41+
[`RoutingOutcome`]'s `request` field is ready for the selected answer target. A custom host
42+
trying the selected target or a fallback should call [`RoutingOutcome::request_for`]; that
43+
prepares the candidate's model and any prompt configured with [`with_target_prompts`] as one
44+
operation.
45+
46+
Routing-time [`CallModel`] requests are likewise ready for their first candidate. Hosts trying
47+
a later classifier or judge candidate should use [`CallModel::request_for`] so exact provider
48+
bodies receive the candidate model together with the normalized request.
49+
4150
The provider-neutral [`Request`], [`Response`], [`Usage`], and [`LlmResponse`]
4251
contracts come from `switchyard-protocol`.
4352

crates/libsy/src/algorithms/advisor_gate.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,7 @@ impl AdvisorGate {
334334
// Gated phase: generate the turn once, fully buffered, so the gate
335335
// can inspect it before the client sees anything.
336336
let response = driver
337-
.call_model(request.clone(), vec![self.executor.clone()])
337+
.call_answer_model(request.clone(), self.executor.clone())
338338
.await?;
339339
let turn = buffer_turn(self.executor.as_str(), response).await?;
340340

crates/libsy/src/algorithms/advisor_gate/tests.rs

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use switchyard_protocol::{
1616
use super::transcript::{NO_TEXT_PLACEHOLDER, TRUNCATION_MARKER, middle_drop};
1717
use super::*;
1818
use crate::core::testing::{reply, test_drive};
19+
use crate::{TargetPrompts, with_target_prompts};
1920

2021
const EXECUTOR: &str = "executor";
2122
const ADVISOR: &str = "advisor";
@@ -268,7 +269,12 @@ async fn tool_call_turn_replays_without_review() {
268269
#[tokio::test]
269270
async fn approved_terminal_turn_returns_buffered_body() {
270271
let script = Script::new();
271-
let gate = gate(AdvisorGateConfig::default());
272+
let gate = with_target_prompts(
273+
gate(AdvisorGateConfig::default()),
274+
TargetPrompts::default()
275+
.with(EXECUTOR, "executor prompt")
276+
.with(ADVISOR, "answer-only advisor prompt"),
277+
);
272278
let serve = script.serve("APPROVE", |_| reply("all done"));
273279
let (selected_model, response) = test_drive(gate, task_request(), serve)
274280
.await
@@ -279,6 +285,19 @@ async fn approved_terminal_turn_returns_buffered_body() {
279285
);
280286
assert_eq!(completion_text(&agg_of(response).await), "all done");
281287
assert_eq!(selected_model, EXECUTOR);
288+
let executor = script.call(0);
289+
assert_eq!(
290+
executor.llm_request.instructions[0].content,
291+
vec![ContentBlock::Text {
292+
text: "executor prompt".to_string(),
293+
}]
294+
);
295+
let advisor = script.call(1);
296+
assert!(!advisor.llm_request.instructions.iter().any(|instruction| {
297+
instruction.content.iter().any(|block| {
298+
matches!(block, ContentBlock::Text { text } if text == "answer-only advisor prompt")
299+
})
300+
}));
282301
}
283302

284303
#[tokio::test]

crates/libsy/src/algorithms/llm_class.rs

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -522,7 +522,7 @@ impl Classifier<State> for EscalationClassifier {
522522
"escalation classifier selected efficient tier"
523523
);
524524
let efficient_response = match driver
525-
.call_model(request.clone(), vec![self.efficient.clone()])
525+
.call_answer_model(request.clone(), self.efficient.clone())
526526
.await
527527
{
528528
Ok(r) => r,
@@ -1777,6 +1777,16 @@ mod tests {
17771777
}
17781778
}
17791779

1780+
/// Reports whether a request contains `expected` as an instruction text block.
1781+
fn has_instruction(request: &Request, expected: &str) -> bool {
1782+
request
1783+
.llm_request
1784+
.instructions
1785+
.iter()
1786+
.flat_map(|instruction| &instruction.content)
1787+
.any(|block| matches!(block, ContentBlock::Text { text } if text == expected))
1788+
}
1789+
17801790
/// Returns a stream that emits partial content before failing during aggregation.
17811791
fn streamed_then_error(error: LlmClientError) -> Response {
17821792
Response {
@@ -1814,17 +1824,39 @@ mod tests {
18141824
// Judge: no escalation. Expect the efficient response to be returned directly.
18151825
let judge = Queue::new([r#"{"escalate":false,"reason":"progressing"}"#]);
18161826
let model = Queue::new(["efficient answer"]);
1817-
let router = escalation_router()?;
1827+
let replies = queued(model, judge);
1828+
let prompted = Arc::new(Mutex::new(Vec::new()));
1829+
let recorded = Arc::clone(&prompted);
1830+
let serve = move |target: ModelId, request: Request| {
1831+
let expected = if target == "judge" {
1832+
"answer-only judge prompt"
1833+
} else {
1834+
"efficient prompt"
1835+
};
1836+
recorded
1837+
.lock()
1838+
.push((target.clone(), has_instruction(&request, expected)));
1839+
replies.serve(target, request)
1840+
};
1841+
let router = crate::with_target_prompts(
1842+
escalation_router()?,
1843+
crate::TargetPrompts::default()
1844+
.with("efficient", "efficient prompt")
1845+
.with("judge", "answer-only judge prompt"),
1846+
);
18181847

1819-
let (selected_model, response) =
1820-
test_drive(router, classify_request(), queued(model, judge)).await?;
1848+
let (selected_model, response) = test_drive(router, classify_request(), serve).await?;
18211849

18221850
// The efficient model is the serving target, and the response comes from its call.
18231851
assert_eq!(selected_model, "efficient");
18241852
assert_eq!(
18251853
response.llm_response.as_agg().map(completion_text),
18261854
Some("efficient answer".to_string())
18271855
);
1856+
assert_eq!(
1857+
&*prompted.lock(),
1858+
&[(ModelId::from("efficient"), true), ("judge".into(), false)]
1859+
);
18281860
Ok(())
18291861
}
18301862

0 commit comments

Comments
 (0)