1use std::{
8 collections::HashMap, future::Future, panic::AssertUnwindSafe, pin::Pin, sync::Arc,
9 time::Instant,
10};
11
12use async_trait::async_trait;
13use futures::{FutureExt, Stream, StreamExt};
14use parking_lot::Mutex;
15use serde_json::Value;
16use tokio::sync::{mpsc, oneshot};
17use tokio_stream::wrappers::ReceiverStream;
18use tracing::Instrument;
19
20use switchyard_protocol::{
28 AggLlmResponse, Category, ContentBlock, DecisionRequest, DecisionResponse, ModelId, Request,
29 Response, StopReason,
30};
31
32use crate::{DriverError, LibsyError, Result, State, observability};
33
34pub type StepStream = Pin<Box<dyn Stream<Item = Result<Step>> + Send>>;
38
39#[derive(Clone, Debug, Default)]
50pub struct RuntimeModels {
51 by_category: HashMap<Category, Vec<ModelId>>,
53 subagent: Option<HashMap<Category, Vec<ModelId>>>,
54 has_target_restriction: bool,
55 provider_models: HashMap<ModelId, ModelId>,
56}
57
58impl RuntimeModels {
59 pub fn new(by_category: HashMap<Category, Vec<ModelId>>) -> Self {
61 Self {
62 by_category,
63 subagent: None,
64 has_target_restriction: false,
65 provider_models: HashMap::new(),
66 }
67 }
68
69 pub fn with_provider_models(mut self, models: HashMap<ModelId, ModelId>) -> Self {
71 self.provider_models = models;
72 self
73 }
74
75 pub fn provider_model<'a>(&'a self, target: &'a ModelId) -> &'a ModelId {
77 self.provider_models.get(target).unwrap_or(target)
78 }
79
80 pub fn with_subagent(mut self, models: HashMap<Category, Vec<ModelId>>) -> Self {
82 self.subagent = Some(models);
83 self
84 }
85
86 pub fn with_target_restriction(mut self) -> Self {
89 self.has_target_restriction = true;
90 self
91 }
92
93 fn check_targets(&self, targets: &[ModelId]) -> Result<()> {
94 if !self.has_target_restriction {
95 return Ok(());
96 }
97 for target in targets {
99 if !self
100 .by_category
101 .values()
102 .chain(self.subagent.iter().flat_map(HashMap::values))
103 .any(|models| models.contains(target))
104 {
105 return Err(LibsyError::TargetNotFound {
106 target: target.clone(),
107 });
108 }
109 }
110 Ok(())
111 }
112
113 fn check_request_targets(&self, request: &Request, targets: &[ModelId]) -> Result<()> {
114 self.check_targets(targets)?;
115 if self.has_target_restriction {
116 let selected = targets.first().ok_or(LibsyError::NoTargets)?;
117 if request.llm_request.model.as_deref() != Some(selected.as_str()) {
118 return Err(LibsyError::AlgorithmError {
119 message: "request model does not match selected target".into(),
120 });
121 }
122 }
123 Ok(())
124 }
125
126 pub fn models_for(&self, category: &Category) -> &[ModelId] {
128 self.by_category.get(category).map_or(&[], Vec::as_slice)
129 }
130
131 pub fn subagent_models_for(&self, category: &Category) -> &[ModelId] {
133 self.subagent
134 .as_ref()
135 .and_then(|models| models.get(category))
136 .map_or(&[], Vec::as_slice)
137 }
138}
139
140impl From<HashMap<Category, Vec<ModelId>>> for RuntimeModels {
141 fn from(by_category: HashMap<Category, Vec<ModelId>>) -> Self {
142 Self::new(by_category)
143 }
144}
145
146#[derive(Clone, Copy)]
148enum Scope {
149 Parent,
150 Subagent,
151}
152
153pub struct CallModel {
163 pub algorithm: String,
166 pub request: Request,
168 pub models: Vec<ModelId>,
170 pub recover_errors: bool,
172 telemetry_model: ModelId,
173 reply: Option<oneshot::Sender<Result<Response>>>,
175 started: Instant,
176 span: tracing::Span,
178}
179
180impl CallModel {
181 pub async fn respond(mut self, work: impl Future<Output = Result<Response>>) -> Result<()> {
187 let reply = self.reply.as_mut().ok_or(DriverError::ResponseDropped)?;
188 let result = tokio::select! {
189 biased;
190 _ = reply.closed() => return Ok(()),
191 result = work => result,
192 };
193 if self.reply.as_ref().is_some_and(oneshot::Sender::is_closed) {
194 return Ok(());
195 }
196 if !self.recover_errors
197 && let Err(error) = result
198 {
199 return self.fail(error);
200 }
201 self.record(result.is_ok());
202 if let Ok(response) = &result {
203 observability::record_llm_response(response, &self.span);
204 }
205 self.reply
206 .take()
207 .ok_or(DriverError::ResponseDropped)?
208 .send(result)
209 .ok();
210 Ok(())
211 }
212
213 pub fn fail(mut self, error: LibsyError) -> Result<()> {
216 self.reply = None;
217 self.record(false);
218 Err(error)
219 }
220
221 fn record(&self, is_ok: bool) {
222 observability::record_llm_call(
223 &self.algorithm,
224 self.telemetry_model.as_str(),
225 self.started.elapsed(),
226 is_ok,
227 );
228 self.span
229 .record("outcome", if is_ok { "ok" } else { "error" });
230 }
231}
232
233impl Drop for CallModel {
234 fn drop(&mut self) {
235 if self.reply.is_some() {
236 self.record(false);
237 }
238 }
239}
240
241pub struct CallDecision {
244 pub algorithm: String,
246 pub request: DecisionRequest,
248 pub model: ModelId,
250 reply: Option<oneshot::Sender<Result<DecisionResponse>>>,
251 started: Instant,
252 span: tracing::Span,
254}
255
256impl CallDecision {
257 pub async fn respond(
261 mut self,
262 work: impl Future<Output = Result<DecisionResponse>>,
263 ) -> Result<()> {
264 let reply = self.reply.as_mut().ok_or(DriverError::ResponseDropped)?;
265 let result = tokio::select! {
266 biased;
267 _ = reply.closed() => return Ok(()),
268 result = work => result,
269 };
270 self.record(result.is_ok());
271 if let Ok(response) = &result {
272 observability::record_decision_response(response, &self.span);
273 }
274 self.reply
275 .take()
276 .ok_or(DriverError::ResponseDropped)?
277 .send(result)
278 .ok();
279 Ok(())
280 }
281
282 pub fn fail(mut self, error: LibsyError) -> Result<()> {
284 self.reply = None;
285 self.record(false);
286 Err(error)
287 }
288
289 fn record(&self, is_ok: bool) {
290 observability::record_decision_call(
291 &self.algorithm,
292 &self.model,
293 self.started.elapsed(),
294 is_ok,
295 );
296 self.span
297 .record("outcome", if is_ok { "ok" } else { "error" });
298 }
299}
300
301impl Drop for CallDecision {
302 fn drop(&mut self) {
303 if self.reply.is_some() {
304 self.record(false);
305 }
306 }
307}
308
309pub struct RoutingOutcome {
311 pub selected_model_ids: Vec<ModelId>,
313 pub request: Request,
315 pub fallback_request: Option<Request>,
319 pub candidate_requests: HashMap<ModelId, Request>,
321 pub response: Option<Response>,
323 pub metadata: Option<crate::OutcomeMetadata>,
328 pub state: State,
330}
331
332impl RoutingOutcome {
333 pub fn selected_model_id(&self) -> Result<&ModelId> {
336 self.selected_model_ids.first().ok_or(LibsyError::NoTargets)
337 }
338
339 pub fn route_to(
343 selected_model_id: ModelId,
344 fallback_models: Vec<ModelId>,
345 mut request: Request,
346 ) -> Self {
347 request.llm_request.model = Some(selected_model_id.to_string());
348 let mut selected_model_ids = Vec::with_capacity(1 + fallback_models.len());
349 selected_model_ids.push(selected_model_id);
350 selected_model_ids.extend(fallback_models);
351 Self {
352 selected_model_ids,
353 request,
354 fallback_request: None,
355 candidate_requests: HashMap::new(),
356 response: None,
357 metadata: None,
358 state: State::default(),
359 }
360 }
361
362 pub fn answered(selected_model_id: ModelId, mut request: Request, response: Response) -> Self {
365 request.llm_request.model = Some(selected_model_id.to_string());
366 Self {
367 selected_model_ids: vec![selected_model_id],
368 request,
369 fallback_request: None,
370 candidate_requests: HashMap::new(),
371 response: Some(response),
372 metadata: None,
373 state: State::default(),
374 }
375 }
376}
377
378#[derive(Clone, Copy, Debug, Eq, PartialEq)]
380pub struct AnswerSummary {
381 pub final_reply: bool,
383}
384
385impl AnswerSummary {
386 pub fn of(response: &AggLlmResponse) -> Self {
388 let final_reply = response.outputs.iter().all(|output| {
389 matches!(output.stop_reason, None | Some(StopReason::EndTurn))
390 && !output
391 .content
392 .iter()
393 .any(|block| matches!(block, ContentBlock::ToolCall(_)))
394 }) && response.outputs.iter().any(|output| {
395 output.role == switchyard_protocol::Role::Assistant
396 && output.content.iter().any(
397 |block| matches!(block, ContentBlock::Text { text, .. } if !text.trim().is_empty()),
398 )
399 });
400 Self { final_reply }
401 }
402}
403
404#[derive(Default)]
405struct RunEvidence {
406 outcome: Option<Value>,
407 judgments: Vec<crate::JudgeDecision>,
408}
409
410#[derive(Clone)]
412pub struct Driver {
413 step_tx: mpsc::Sender<Result<Step>>,
414
415 algorithm: String,
417
418 evidence: Arc<Mutex<RunEvidence>>,
420
421 state: Arc<Mutex<State>>,
423
424 models: Arc<RuntimeModels>,
426
427 scope: Scope,
429 judge: Option<ModelId>,
430}
431
432impl Driver {
433 pub(crate) fn new(
436 algorithm: &str,
437 models: Arc<RuntimeModels>,
438 ) -> (Self, mpsc::Receiver<Result<Step>>) {
439 let (step_tx, step_rx) = mpsc::channel(1);
444 (
445 Self {
446 step_tx,
447 algorithm: algorithm.to_string(),
448 evidence: Arc::new(Mutex::new(RunEvidence::default())),
449 state: Arc::new(Mutex::new(State::default())),
450 models,
451 scope: Scope::Parent,
452 judge: None,
453 },
454 step_rx,
455 )
456 }
457
458 pub(crate) fn take_state(&self) -> State {
459 std::mem::take(&mut *self.state.lock())
460 }
461
462 pub(crate) fn set_state(&self, state: State) {
463 *self.state.lock() = state;
464 }
465
466 pub(crate) fn set_evidence(&self, evidence: Value) {
468 self.evidence.lock().outcome = Some(evidence);
469 }
470
471 pub(crate) fn set_evidence_if_empty(&self, evidence: Value) {
473 let mut current = self.evidence.lock();
474 if current.outcome.is_none() {
475 current.outcome = Some(evidence);
476 }
477 }
478
479 pub(crate) fn record_judgment(&self, selected: Option<crate::Score>, evidence: Option<Value>) {
480 let (target, category) = selected
481 .map(|score| (Some(score.target), score.category))
482 .unwrap_or_default();
483 self.evidence.lock().judgments.push(crate::JudgeDecision {
484 target,
485 category: category.map(|category| category.as_str().to_string()),
486 evidence,
487 });
488 }
489
490 pub async fn call_model(&self, request: Request, models: Vec<ModelId>) -> Result<Response> {
500 self.call_model_with_error_recovery(request, models, false)
501 .await
502 }
503
504 #[tracing::instrument(
506 target = "libsy",
507 name = "libsy.llm_call",
508 skip_all,
509 fields(
510 algorithm = self.algorithm,
511 selected_model = %models.first().map(|model| self.provider_model(model).as_str()).unwrap_or("NoTargets"),
512 openinference.span.kind = "CHAIN",
513 outcome = tracing::field::Empty,
514 input_tokens = tracing::field::Empty,
515 output_tokens = tracing::field::Empty,
516 total_tokens = tracing::field::Empty,
517 reasoning_tokens = tracing::field::Empty,
518 )
519 )]
520 pub(crate) async fn call_model_with_error_recovery(
521 &self,
522 mut request: Request,
523 models: Vec<ModelId>,
524 recover_errors: bool,
525 ) -> Result<Response> {
526 let Some(selected_model_id) = models.first() else {
527 return Err(LibsyError::NoTargets);
528 };
529 self.models.check_targets(&models)?;
530 request.llm_request.model = Some(selected_model_id.to_string());
531 let started = Instant::now();
532 let (reply, response) = oneshot::channel::<Result<Response>>();
533 let call = CallModel {
534 algorithm: self.algorithm.clone(),
535 request,
536 telemetry_model: self.provider_model(selected_model_id).clone(),
537 models,
538 recover_errors,
539 reply: Some(reply),
540 started,
541 span: tracing::Span::current(),
542 };
543 self.step_tx
544 .send(Ok(Step::CallModel(Box::new(call))))
545 .await
546 .map_err(|_| DriverError::StreamClosed)?;
547 response
548 .await
549 .map_err(|_| LibsyError::from(DriverError::ResponseDropped))?
550 }
551
552 #[tracing::instrument(
554 target = "libsy",
555 name = "libsy.decision_call",
556 skip_all,
557 fields(
558 algorithm = self.algorithm,
559 selected_model = %model,
560 openinference.span.kind = "CHAIN",
561 outcome = tracing::field::Empty,
562 input_tokens = tracing::field::Empty,
563 output_tokens = tracing::field::Empty,
564 total_tokens = tracing::field::Empty,
565 reasoning_tokens = tracing::field::Empty,
566 gen_ai.response.id = tracing::field::Empty,
567 gen_ai.response.model = tracing::field::Empty,
568 ),
569 )]
570 pub async fn call_decision(
571 &self,
572 mut request: DecisionRequest,
573 model: ModelId,
574 ) -> Result<DecisionResponse> {
575 self.models.check_targets(std::slice::from_ref(&model))?;
576 request.model = Some(model.clone());
577 let started = Instant::now();
578 let (reply, response) = oneshot::channel();
579 let call = CallDecision {
580 algorithm: self.algorithm.clone(),
581 request,
582 model,
583 reply: Some(reply),
584 started,
585 span: tracing::Span::current(),
586 };
587 self.step_tx
588 .send(Ok(Step::CallDecision(Box::new(call))))
589 .await
590 .map_err(|_| DriverError::StreamClosed)?;
591 response
592 .await
593 .map_err(|_| LibsyError::from(DriverError::ResponseDropped))?
594 }
595
596 pub fn provider_model<'a>(&'a self, target: &'a ModelId) -> &'a ModelId {
598 self.models.provider_model(target)
599 }
600
601 pub fn models_for(&self, category: &Category) -> &[ModelId] {
603 if *category == Category::Judge
604 && let Some(judge) = &self.judge
605 {
606 return std::slice::from_ref(judge);
607 }
608 match self.scope {
609 Scope::Parent => self.models.models_for(category),
610 Scope::Subagent => self.models.subagent_models_for(category),
611 }
612 }
613
614 pub(crate) fn for_judge(&self, judge: ModelId) -> Self {
615 Self {
616 judge: Some(judge),
617 ..self.clone()
618 }
619 }
620
621 pub fn first_model_for(&self, category: &Category) -> Result<&ModelId> {
623 self.models_for(category)
624 .first()
625 .ok_or_else(|| LibsyError::AlgorithmError {
626 message: format!("no models available for category {}", category.as_str()),
627 })
628 }
629
630 pub fn for_subagent(&self) -> Result<Self> {
633 if self.models.subagent.is_none() {
634 return Err(LibsyError::AlgorithmError {
635 message: "delegated work has no sub-agent models".to_string(),
636 });
637 }
638 Ok(Self {
639 scope: Scope::Subagent,
640 ..self.clone()
641 })
642 }
643
644 pub(crate) async fn finish(&self, result: Result<RoutingOutcome>) -> Result<()> {
648 let result = result.map(|mut outcome| {
649 outcome.state = self.take_state();
650 let evidence = std::mem::take(&mut *self.evidence.lock());
651 let metadata = outcome.metadata.get_or_insert_with(|| {
652 crate::OutcomeMetadata::new(self.algorithm.clone(), evidence.outcome)
653 });
654 metadata.judgments.extend(evidence.judgments);
655 let provider_models = outcome
656 .selected_model_ids
657 .iter()
658 .map(|target| self.provider_model(target).clone())
659 .collect::<Vec<_>>();
660 observability::record_outcome(metadata, &provider_models);
661 outcome
662 });
663 let selected_model = result
664 .as_ref()
665 .ok()
666 .and_then(|outcome| outcome.selected_model_id().ok().cloned());
667 let step = result.map(|outcome| Step::Done(Box::new(outcome)));
668 self.step_tx
669 .send(step)
670 .await
671 .map_err(|_| DriverError::StreamClosed)?;
672 if let Some(selected_model) = selected_model {
673 observability::record_decision(&self.algorithm, self.provider_model(&selected_model));
674 }
675 Ok(())
676 }
677}
678
679pub enum Step {
682 CallModel(Box<CallModel>),
685 CallDecision(Box<CallDecision>),
687 Done(Box<RoutingOutcome>),
689}
690
691pub enum Call {
694 Model(Box<CallModel>),
696 Decision(Box<CallDecision>),
698}
699
700pub async fn drive<F, Fut>(
710 algorithm: Arc<dyn Algorithm>,
711 request: Request,
712 models: Arc<RuntimeModels>,
713 serve: F,
714) -> Result<RoutingOutcome>
715where
716 F: Fn(Call) -> Fut,
717 Fut: Future<Output = Result<()>>,
718{
719 drive_with_state(algorithm, request, models, State::default(), serve).await
720}
721
722pub(crate) async fn drive_with_state<F, Fut>(
724 algorithm: Arc<dyn Algorithm>,
725 request: Request,
726 models: Arc<RuntimeModels>,
727 state: State,
728 serve: F,
729) -> Result<RoutingOutcome>
730where
731 F: Fn(Call) -> Fut,
732 Fut: Future<Output = Result<()>>,
733{
734 let stream = algorithm.run_stream(request, Arc::clone(&models), state);
735 tokio::pin!(stream);
736
737 let mut in_flight = futures::stream::FuturesUnordered::new();
738 let mut final_outcome: Option<RoutingOutcome> = None;
739
740 loop {
741 tokio::select! {
742 Some(result) = in_flight.next() => match result {
743 Ok(()) => {}, Err(err) => return Err(err), },
746 step = stream.next() => {
747 match step {
748 None => break, Some(item) => match item? {
750 Step::CallModel(call) => {
751 models.check_request_targets(&call.request, &call.models)?;
752 in_flight.push(serve(Call::Model(call)));
753 }
754 Step::CallDecision(call) => {
755 models.check_targets(std::slice::from_ref(&call.model))?;
756 if models.has_target_restriction
757 && call.request.model.as_ref() != Some(&call.model)
758 {
759 return Err(LibsyError::AlgorithmError {
760 message: "decision payload model does not match selected target".into(),
761 });
762 }
763 in_flight.push(serve(Call::Decision(call)));
764 }
765 Step::Done(outcome) => {
766 if outcome.response.is_none() {
767 models.check_request_targets(&outcome.request, &outcome.selected_model_ids)?;
768 }
769 final_outcome = Some(*outcome);
770 break;
771 }
772 }
773 }
774 },
775 }
776 }
777 final_outcome.ok_or(LibsyError::MissingFinalResponse)
778}
779
780fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
782 payload
783 .downcast_ref::<&'static str>()
784 .map(|message| (*message).to_string())
785 .or_else(|| payload.downcast_ref::<String>().cloned())
786 .unwrap_or_else(|| "unknown panic payload".to_string())
787}
788
789struct AbortOnDrop(tokio::task::AbortHandle);
791
792impl Drop for AbortOnDrop {
793 fn drop(&mut self) {
794 self.0.abort();
795 }
796}
797
798pub(crate) fn ensure_model_is_target(targets: &[ModelId], name: &ModelId) -> Result<()> {
803 targets
804 .iter()
805 .any(|target| target == name)
806 .then_some(())
807 .ok_or_else(|| LibsyError::TargetNotFound {
808 target: name.clone(),
809 })
810}
811
812#[derive(Clone, Hash, PartialEq, Eq)]
815pub(crate) enum RoutingIdentity {
816 Session(String),
818 Subagent { session: String, agent: String },
820}
821
822impl RoutingIdentity {
823 pub(crate) fn from_request(request: &Request) -> Option<Self> {
828 let metadata = request.metadata.as_ref()?;
829 let session = metadata.session_id.as_deref().filter(|id| !id.is_empty())?;
830 if metadata.is_subagent {
831 let agent = metadata.agent_id.as_deref().filter(|id| !id.is_empty())?;
832 Some(Self::Subagent {
833 session: session.to_string(),
834 agent: agent.to_string(),
835 })
836 } else {
837 Some(Self::Session(session.to_string()))
838 }
839 }
840}
841
842#[async_trait]
878pub trait Algorithm: Send + Sync + 'static {
879 fn name(&self) -> &str;
883
884 async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<RoutingOutcome>;
887
888 fn tracks_answers(&self) -> bool {
890 false
891 }
892
893 async fn observe_answer(&self, _session_id: &str, _answer: AnswerSummary) {}
898
899 fn run_stream(
907 self: Arc<Self>,
908 request: Request,
909 models: Arc<RuntimeModels>,
910 state: State,
911 ) -> StepStream {
912 let (driver, step_rx) = Driver::new(self.name(), models);
913 driver.set_state(state);
914 let span = observability::run_span(self.name(), &request);
915 let handle = tokio::spawn(
916 async move {
917 let algorithm = self.name().to_string();
918 let route = AssertUnwindSafe(self.route(driver.clone(), request)).catch_unwind();
920 let result = observability::observe_run(&algorithm, async {
921 let outcome = route.await.unwrap_or_else(|payload| {
922 Err(LibsyError::AlgorithmError {
923 message: format!(
924 "algorithm task panicked: {}",
925 panic_message(payload.as_ref())
926 ),
927 })
928 })?;
929 if outcome.response.is_none() {
930 driver
931 .models
932 .check_request_targets(&outcome.request, &outcome.selected_model_ids)?;
933 }
934 Ok(outcome)
935 })
936 .await;
937
938 let _ = driver.finish(result).await;
939 }
940 .instrument(span),
941 );
942 let abort_guard = AbortOnDrop(handle.abort_handle());
944 Box::pin(ReceiverStream::new(step_rx).map(move |step| {
945 let _keep_alive = &abort_guard;
947 step
948 }))
949 }
950}
951
952#[cfg(test)]
953mod tests {
954 use std::collections::HashMap;
955
956 use super::*;
957 use crate::core::testing::{Serve, ServeResult, echo, reply, serve_decision, test_drive};
958 use futures::StreamExt;
959 use switchyard_protocol::{
960 LlmResponse, LlmResponseChunk, completion_text, text_request, text_response,
961 };
962
963 #[test]
964 fn a_final_answer_has_text_and_no_unfinished_or_tool_output() {
965 let mut answer = text_response(None, "done");
966 assert!(AnswerSummary::of(&answer).final_reply);
967 answer.outputs[0].stop_reason = Some(StopReason::MaxTokens);
968 assert!(!AnswerSummary::of(&answer).final_reply);
969 answer.outputs[0].stop_reason = Some(StopReason::EndTurn);
970 let mut tool = answer.outputs[0].clone();
971 tool.content = vec![ContentBlock::ToolCall(switchyard_protocol::ToolCall {
972 id: "call".into(),
973 name: "bash".into(),
974 arguments: serde_json::json!({}),
975 })];
976 answer.outputs.push(tool);
977 assert!(!AnswerSummary::of(&answer).final_reply);
978 answer.outputs.pop();
979 answer.outputs[0].content = vec![ContentBlock::Text {
980 responses: None,
981 text: " ".into(),
982 }];
983 assert!(!AnswerSummary::of(&answer).final_reply);
984 assert!(!AnswerSummary::of(&AggLlmResponse::default()).final_reply);
985 }
986
987 #[derive(Debug, thiserror::Error)]
988 #[error("{0}")]
989 struct TestError(&'static str);
990
991 fn test_error(message: &'static str) -> LibsyError {
992 LibsyError::external("test", TestError(message))
993 }
994
995 struct TestAlgo {
998 target_set: Vec<ModelId>,
999 }
1000
1001 #[async_trait]
1002 impl Algorithm for TestAlgo {
1003 fn name(&self) -> &str {
1004 "test"
1005 }
1006
1007 async fn route(
1008 self: Arc<Self>,
1009 driver: Driver,
1010 request: Request,
1011 ) -> Result<RoutingOutcome> {
1012 let target = self
1013 .target_set
1014 .first()
1015 .ok_or(LibsyError::NoTargets)?
1016 .clone();
1017 let response = driver
1018 .call_model(request.clone(), vec![target.clone()])
1019 .await?;
1020 driver.record_judgment(
1021 Some(crate::Score {
1022 target: target.clone(),
1023 category: Some(Category::Capable),
1024 confidence: 1.0,
1025 }),
1026 Some(serde_json::json!({"source": "judge"})),
1027 );
1028 driver.set_evidence(serde_json::json!({"source": "test"}));
1029 driver.set_evidence_if_empty(serde_json::json!({"source": "ignored"}));
1030 Ok(RoutingOutcome::answered(target, request, response))
1031 }
1032 }
1033
1034 fn orch(target_set: Vec<ModelId>) -> Arc<dyn Algorithm> {
1036 Arc::new(TestAlgo { target_set })
1037 }
1038
1039 fn request() -> Request {
1040 Request {
1041 llm_request: text_request(Some("auto".to_string()), "hi".to_string()),
1042 raw_request: None,
1043 metadata: None,
1044 }
1045 }
1046
1047 #[test]
1048 fn routing_outcome_constructors_stamp_selection_and_preserve_payloads() {
1049 let outcome = RoutingOutcome::route_to(
1050 "selected".into(),
1051 target_set(&["fallback-one", "fallback-two"]),
1052 request(),
1053 );
1054
1055 assert_eq!(
1056 outcome.selected_model_ids,
1057 target_set(&["selected", "fallback-one", "fallback-two"])
1058 );
1059 assert_eq!(outcome.request.model_id().as_deref(), Some("selected"));
1060 assert!(outcome.response.is_none());
1061 assert!(outcome.metadata.is_none());
1062
1063 let outcome = RoutingOutcome::route_to("only".into(), Vec::new(), request());
1064 assert_eq!(outcome.selected_model_ids, target_set(&["only"]));
1065
1066 let outcome = RoutingOutcome::answered(
1067 "answered".into(),
1068 request(),
1069 Response {
1070 llm_response: LlmResponse::Agg(text_response(None, "existing")),
1071 metadata: None,
1072 upstream_headers: http::HeaderMap::new(),
1073 },
1074 );
1075
1076 assert_eq!(outcome.selected_model_ids, target_set(&["answered"]));
1077 assert_eq!(outcome.request.model_id().as_deref(), Some("answered"));
1078 assert_eq!(
1079 outcome
1080 .response
1081 .as_ref()
1082 .and_then(|response| response.llm_response.as_agg())
1083 .map(completion_text),
1084 Some("existing".to_string())
1085 );
1086 }
1087
1088 fn target_set(names: &[&str]) -> Vec<ModelId> {
1089 names.iter().map(|name| ModelId::from(*name)).collect()
1090 }
1091
1092 enum TargetProbe {
1093 Model(&'static [&'static str]),
1094 Decision(&'static str),
1095 Completion(&'static [&'static str]),
1096 MismatchedCompletion,
1097 }
1098
1099 #[async_trait]
1100 impl Algorithm for TargetProbe {
1101 fn name(&self) -> &str {
1102 "target_probe"
1103 }
1104
1105 async fn route(
1106 self: Arc<Self>,
1107 driver: Driver,
1108 request: Request,
1109 ) -> Result<RoutingOutcome> {
1110 match &*self {
1111 Self::Model(models) => {
1112 let response = driver
1113 .call_model(request.clone(), target_set(models))
1114 .await?;
1115 Ok(RoutingOutcome::answered(
1116 models[0].into(),
1117 request,
1118 response,
1119 ))
1120 }
1121 Self::Decision(model) => {
1122 driver
1123 .call_decision(
1124 DecisionRequest {
1125 model: None,
1126 input: Value::Null,
1127 questions: Default::default(),
1128 },
1129 (*model).into(),
1130 )
1131 .await?;
1132 Ok(RoutingOutcome::route_to("allowed".into(), vec![], request))
1133 }
1134 Self::Completion(models) => Ok(RoutingOutcome::route_to(
1135 models[0].into(),
1136 target_set(&models[1..]),
1137 request,
1138 )),
1139 Self::MismatchedCompletion => {
1140 let mut outcome = RoutingOutcome::route_to("allowed".into(), vec![], request);
1141 outcome.request.llm_request.model = Some("outside".into());
1142 Ok(outcome)
1143 }
1144 }
1145 }
1146 }
1147
1148 struct RewrittenSteps(Arc<TargetProbe>, fn(&mut Step));
1149
1150 #[async_trait]
1151 impl Algorithm for RewrittenSteps {
1152 fn name(&self) -> &str {
1153 "rewritten_steps"
1154 }
1155
1156 async fn route(
1157 self: Arc<Self>,
1158 driver: Driver,
1159 request: Request,
1160 ) -> Result<RoutingOutcome> {
1161 Arc::clone(&self.0).route(driver, request).await
1162 }
1163
1164 fn run_stream(
1165 self: Arc<Self>,
1166 request: Request,
1167 models: Arc<RuntimeModels>,
1168 state: State,
1169 ) -> StepStream {
1170 Box::pin(
1171 Arc::clone(&self.0)
1172 .run_stream(request, models, state)
1173 .map(move |step| {
1174 step.map(|mut step| {
1175 (self.1)(&mut step);
1176 step
1177 })
1178 }),
1179 )
1180 }
1181 }
1182
1183 fn restricted_models() -> RuntimeModels {
1184 RuntimeModels::new(HashMap::from([
1185 (Category::Any, target_set(&["allowed", "fallback"])),
1186 (Category::Judge, target_set(&["judge"])),
1187 ]))
1188 .with_target_restriction()
1189 .with_subagent(HashMap::from([(Category::Any, target_set(&["subagent"]))]))
1190 }
1191
1192 #[tokio::test]
1193 async fn target_restriction_checks_default_streams() {
1194 for probe in [
1195 TargetProbe::Model(&["outside"]),
1196 TargetProbe::Model(&["allowed", "outside"]),
1197 TargetProbe::Decision("outside"),
1198 TargetProbe::Completion(&["outside"]),
1199 TargetProbe::Completion(&["allowed", "outside"]),
1200 ] {
1201 let mut stream = Arc::new(probe).run_stream(
1202 request(),
1203 Arc::new(restricted_models()),
1204 State::default(),
1205 );
1206 assert!(matches!(
1207 stream.next().await,
1208 Some(Err(LibsyError::TargetNotFound { target })) if target == "outside"
1209 ));
1210 assert!(stream.next().await.is_none());
1211 }
1212 let mut stream = Arc::new(TargetProbe::MismatchedCompletion).run_stream(
1213 request(),
1214 Arc::new(restricted_models()),
1215 State::default(),
1216 );
1217 assert!(matches!(
1218 stream.next().await,
1219 Some(Err(LibsyError::AlgorithmError { .. }))
1220 ));
1221
1222 let mut stream = Arc::new(TargetProbe::Model(&["outside"])).run_stream(
1223 request(),
1224 Arc::new(RuntimeModels::default()),
1225 State::default(),
1226 );
1227 assert!(matches!(stream.next().await, Some(Ok(Step::CallModel(_)))));
1228 }
1229
1230 #[tokio::test]
1231 async fn target_restriction_rechecks_rewritten_steps() {
1232 for probe in [
1233 TargetProbe::Model(&["allowed"]),
1234 TargetProbe::Decision("judge"),
1235 TargetProbe::Completion(&["allowed"]),
1236 ] {
1237 let probe = Arc::new(probe);
1238 let run = |rewrite| {
1239 drive(
1240 Arc::new(RewrittenSteps(Arc::clone(&probe), rewrite)),
1241 request(),
1242 Arc::new(restricted_models()),
1243 |_| async { panic!("invalid work must not reach the host") },
1244 )
1245 };
1246 let result = run(|step| match step {
1247 Step::CallModel(call) => call.models.push("outside".into()),
1248 Step::CallDecision(call) => call.model = "outside".into(),
1249 Step::Done(outcome) => outcome.selected_model_ids.push("outside".into()),
1250 })
1251 .await;
1252 assert!(
1253 matches!(result, Err(LibsyError::TargetNotFound { target }) if target == "outside")
1254 );
1255
1256 let result = run(|step| match step {
1257 Step::CallModel(call) => call.request.llm_request.model = Some("outside".into()),
1258 Step::CallDecision(call) => call.request.model = Some("allowed".into()),
1259 Step::Done(outcome) => outcome.request.llm_request.model = Some("outside".into()),
1260 })
1261 .await;
1262 assert!(matches!(result, Err(LibsyError::AlgorithmError { .. })));
1263 }
1264 }
1265
1266 #[tokio::test]
1267 async fn target_restriction_preserves_allowed_work() -> Result<()> {
1268 for (probe, expected) in [
1269 (
1270 TargetProbe::Model(&["allowed", "fallback"]),
1271 &["allowed"][..],
1272 ),
1273 (TargetProbe::Model(&["judge"]), &["judge"][..]),
1274 (TargetProbe::Decision("judge"), &["allowed"][..]),
1275 (
1276 TargetProbe::Completion(&["allowed", "fallback"]),
1277 &["allowed", "fallback"][..],
1278 ),
1279 (TargetProbe::Completion(&["subagent"]), &["subagent"][..]),
1280 ] {
1281 let outcome = drive(
1282 Arc::new(probe),
1283 request(),
1284 Arc::new(restricted_models()),
1285 |call| async move {
1286 match call {
1287 Call::Model(call) => {
1288 call.respond(std::future::ready(Ok(reply("ok")))).await
1289 }
1290 Call::Decision(call) => serve_decision(*call).await,
1291 }
1292 },
1293 )
1294 .await?;
1295 assert_eq!(outcome.selected_model_ids, target_set(expected));
1296 }
1297 let outcome = drive(
1298 Arc::new(crate::Noop {}),
1299 request(),
1300 Arc::new(RuntimeModels::default().with_target_restriction()),
1301 |_| async { panic!("Noop must not request model calls") },
1302 )
1303 .await?;
1304 assert!(outcome.response.is_some());
1305 Ok(())
1306 }
1307
1308 #[tokio::test]
1309 async fn typed_driver_preserves_call_and_stream_boundaries() -> Result<()> {
1310 tokio::time::timeout(std::time::Duration::from_secs(1), async {
1311 let (driver, mut step_rx) = Driver::new("test", Arc::new(RuntimeModels::default()));
1314 let first_driver = driver.clone();
1315 let mut first = tokio::spawn(async move {
1316 first_driver
1317 .call_model(request(), vec![ModelId::from("first")])
1318 .await
1319 });
1320 let second = tokio::spawn(async move {
1321 driver
1322 .call_model(request(), vec![ModelId::from("second")])
1323 .await
1324 });
1325
1326 let mut calls = HashMap::new();
1327 for _ in 0..2 {
1328 let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
1329 let Step::CallModel(call) = step else {
1330 return Err(test_error("expected a CallModel step"));
1331 };
1332 let selected_model = call
1333 .models
1334 .first()
1335 .ok_or_else(|| test_error("model call has no candidates"))?
1336 .to_string();
1337 calls.insert(selected_model, call);
1338 }
1339 assert!(
1340 tokio::time::timeout(std::time::Duration::from_millis(20), &mut first)
1341 .await
1342 .is_err(),
1343 "call completed before the host responded"
1344 );
1345 calls
1346 .remove("second")
1347 .ok_or_else(|| test_error("missing second call"))?
1348 .respond(std::future::ready(Ok(reply("second response")))).await?;
1349 calls
1350 .remove("first")
1351 .ok_or_else(|| test_error("missing first call"))?
1352 .respond(std::future::ready(Ok(reply("first response")))).await?;
1353
1354 let first_response = first
1355 .await
1356 .map_err(|source| LibsyError::external("joining a test task", source))??;
1357 let second_response = second
1358 .await
1359 .map_err(|source| LibsyError::external("joining a test task", source))??;
1360 assert_eq!(
1361 first_response.llm_response.as_agg().map(completion_text),
1362 Some("first response".to_string())
1363 );
1364 assert_eq!(
1365 second_response.llm_response.as_agg().map(completion_text),
1366 Some("second response".to_string())
1367 );
1368
1369 let (driver, mut step_rx) = Driver::new("test", Arc::new(RuntimeModels::default()));
1371 let producer = tokio::spawn(async move {
1372 driver
1373 .call_model(request(), vec![ModelId::from("dropped")])
1374 .await
1375 });
1376 let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
1377 let Step::CallModel(call) = step else {
1378 return Err(test_error("expected a CallModel step"));
1379 };
1380 drop(call);
1381 let result = producer
1382 .await
1383 .map_err(|source| LibsyError::external("joining a test task", source))?;
1384 assert!(matches!(
1385 result,
1386 Err(LibsyError::Driver(DriverError::ResponseDropped))
1387 ));
1388
1389 let (driver, step_rx) = Driver::new("test", Arc::new(RuntimeModels::default()));
1391 drop(step_rx);
1392 let result = driver
1393 .call_model(request(), vec![ModelId::from("closed")])
1394 .await;
1395 assert!(matches!(
1396 result,
1397 Err(LibsyError::Driver(DriverError::StreamClosed))
1398 ));
1399
1400 fn decision_request() -> DecisionRequest {
1401 DecisionRequest {
1402 model: Some("overwritten".into()),
1403 input: serde_json::json!({"task": "choose a route"}),
1404 questions: Default::default(),
1405 }
1406 }
1407
1408 fn decision_response() -> DecisionResponse {
1409 DecisionResponse {
1410 id: Some("decision-1".to_string()),
1411 model: Some("provider-model".into()),
1412 answers: [(
1413 "p_solve".to_string(),
1414 switchyard_protocol::DecisionAnswer {
1415 value: switchyard_protocol::DecisionValue::Boolean(
1416 switchyard_protocol::BooleanEstimate::ProbabilityTrue(
1417 switchyard_protocol::Probability(0.8),
1418 ),
1419 ),
1420 provider_confidence: None,
1421 },
1422 )]
1423 .into(),
1424 usage: Default::default(),
1425 }
1426 }
1427
1428 struct MixedCalls(&'static str);
1429
1430 #[async_trait]
1431 impl Algorithm for MixedCalls {
1432 fn name(&self) -> &str {
1433 "mixed"
1434 }
1435
1436 async fn route(
1437 self: Arc<Self>,
1438 driver: Driver,
1439 request: Request,
1440 ) -> Result<RoutingOutcome> {
1441 let (llm, decision) = tokio::join!(
1442 driver.call_model(request.clone(), vec!["llm".into()]),
1443 driver.call_decision(decision_request(), "decision".into()),
1444 );
1445 assert_eq!(
1446 llm?.llm_response.as_agg().map(completion_text),
1447 Some("llm reply".into())
1448 );
1449 match self.0 {
1450 "mock" => assert_eq!(decision?, DecisionResponse {
1451 id: None,
1452 model: Some("decision".into()),
1453 answers: Default::default(),
1454 usage: Default::default(),
1455 }),
1456 "reply" => assert_eq!(decision?, decision_response()),
1457 "error" => assert!(matches!(
1458 decision,
1459 Err(LibsyError::AlgorithmError { message }) if message == "provider failed"
1460 )),
1461 "drop" => assert!(matches!(
1462 decision,
1463 Err(LibsyError::Driver(DriverError::ResponseDropped))
1464 )),
1465 "abort" => return std::future::pending().await,
1466 _ => unreachable!(),
1467 }
1468 Ok(RoutingOutcome::route_to("answer".into(), vec![], request))
1469 }
1470 }
1471
1472 for mode in ["mock", "reply", "error", "drop", "abort"] {
1473 let barrier = Arc::new(tokio::sync::Barrier::new(2));
1475 let outcome = drive(
1476 Arc::new(MixedCalls(mode)),
1477 request(),
1478 Arc::new(RuntimeModels::default()),
1479 move |call| {
1480 let barrier = barrier.clone();
1481 async move {
1482 barrier.wait().await;
1483 let call = match call {
1484 Call::Model(call) => return call.respond(std::future::ready(Ok(reply("llm reply")))).await,
1485 Call::Decision(call) => *call,
1486 };
1487 assert_eq!(call.algorithm, "mixed");
1488 assert_eq!(call.model, "decision");
1489 assert_eq!(call.request.model, Some("decision".into()));
1490 assert_eq!(call.request.input, decision_request().input);
1491 match mode {
1492 "mock" => serve_decision(call).await,
1493 "reply" => call.respond(std::future::ready(Ok(decision_response()))).await,
1494 "error" => call.respond(std::future::ready(Err(LibsyError::AlgorithmError {
1495 message: "provider failed".into(),
1496 }))).await,
1497 "drop" => {
1498 drop(call);
1499 Ok(())
1500 }
1501 "abort" => call.fail(test_error("host aborted")),
1502 _ => unreachable!(),
1503 }
1504 }
1505 },
1506 )
1507 .await;
1508 if mode == "abort" {
1509 assert!(matches!(outcome, Err(LibsyError::External { .. })));
1510 } else {
1511 assert_eq!(outcome?.selected_model_id()?, "answer");
1512 }
1513 }
1514
1515 let (driver, mut steps) = Driver::new("test", Arc::new(RuntimeModels::default()));
1516 let mut pending = Box::pin(driver.call_decision(decision_request(), "decision".into()));
1517 assert!(futures::poll!(&mut pending).is_pending());
1518 let Some(Ok(Step::CallDecision(call))) = steps.recv().await else {
1519 return Err(test_error("expected a decision call"));
1520 };
1521 drop(pending);
1522 call.respond(async { panic!("cancelled work must not be polled") }).await?;
1523 drop(steps);
1524 assert!(matches!(
1525 driver.call_decision(decision_request(), "decision".into()).await,
1526 Err(LibsyError::Driver(DriverError::StreamClosed))
1527 ));
1528 Ok(())
1529 })
1530 .await
1531 .map_err(|error| LibsyError::external("waiting for typed driver boundaries", error))?
1532 }
1533
1534 #[test]
1535 fn target_lookup_returns_the_missing_target() {
1536 let error = ensure_model_is_target(&target_set(&[]), &ModelId::from("missing")).err();
1537 assert!(matches!(
1538 error,
1539 Some(LibsyError::TargetNotFound { target }) if target == "missing"
1540 ));
1541 }
1542
1543 fn streaming_orch(chunks: Vec<LlmResponseChunk>) -> (Arc<dyn Algorithm>, impl Serve) {
1546 let algo = orch(target_set(&["stream/model"]));
1547 let serve = move |_target: ModelId, _request: Request| {
1548 let chunks = chunks.clone();
1549 async move {
1550 let stream =
1551 futures::stream::iter(chunks.into_iter().map(|chunk| Ok(chunk.into()))).boxed();
1552 Ok(Response {
1553 llm_response: LlmResponse::Stream(stream),
1554 metadata: None,
1555 upstream_headers: http::HeaderMap::new(),
1556 })
1557 }
1558 };
1559 (algo, serve)
1560 }
1561
1562 #[tokio::test]
1563 async fn run_returns_a_streamed_response_the_caller_aggregates() -> Result<()> {
1564 let (orch, serve) = streaming_orch(vec![
1567 LlmResponseChunk::MessageStart {
1568 id: Some("m1".to_string()),
1569 model: Some("stream/model".to_string()),
1570 },
1571 LlmResponseChunk::TextDelta {
1572 index: 0,
1573 text: "hel".to_string(),
1574 },
1575 LlmResponseChunk::TextDelta {
1576 index: 0,
1577 text: "lo".to_string(),
1578 },
1579 LlmResponseChunk::MessageStop {
1580 reason: Some("stop".to_string()),
1581 },
1582 ]);
1583 let (selected_model, response) = test_drive(orch, request(), serve).await?;
1584 let agg = response
1586 .llm_response
1587 .into_agg()
1588 .await
1589 .map_err(|error| LibsyError::external("aggregating response stream", error))?;
1590 assert_eq!(completion_text(&agg), "hello");
1591 assert_eq!(agg.model.as_deref(), Some("stream/model"));
1592 assert_eq!(selected_model, "stream/model");
1593 Ok(())
1594 }
1595
1596 #[tokio::test]
1597 async fn aggregating_a_streamed_response_propagates_a_mid_stream_error() -> Result<()> {
1598 let (orch, serve) = streaming_orch(vec![
1601 LlmResponseChunk::TextDelta {
1602 index: 0,
1603 text: "partial".to_string(),
1604 },
1605 LlmResponseChunk::StreamError {
1606 message: "upstream exploded".to_string(),
1607 },
1608 ]);
1609 let (_, response) = test_drive(orch, request(), serve).await?;
1610 match response.llm_response.into_agg().await {
1611 Ok(_) => panic!("expected a mid-stream error, got an aggregate"),
1612 Err(err) => {
1613 assert!(err.to_string().contains("upstream exploded"));
1614 Ok(())
1615 }
1616 }
1617 }
1618
1619 #[tokio::test]
1620 async fn run_offloads_via_promise_then_finishes() -> Result<()> {
1621 let stream = orch(target_set(&["offload/model"])).run_stream(
1624 request(),
1625 Arc::new(RuntimeModels::default()),
1626 State::default(),
1627 );
1628 tokio::pin!(stream);
1629
1630 let mut saw_call = false;
1631 let mut final_completion = None;
1632 while let Some(step) = stream.next().await {
1633 match step? {
1634 Step::CallDecision(_) => return Err(test_error("unexpected decision call")),
1635 Step::CallModel(call) => {
1636 saw_call = true;
1637 assert_eq!(call.models, vec![ModelId::from("offload/model")]);
1638 call.respond(std::future::ready(Ok(Response {
1640 llm_response: LlmResponse::Agg(text_response(
1641 None,
1642 "fulfilled".to_string(),
1643 )),
1644 metadata: None,
1645 upstream_headers: http::HeaderMap::new(),
1646 })))
1647 .await?;
1648 }
1649 Step::Done(outcome) => {
1650 let metadata = outcome
1651 .metadata
1652 .as_ref()
1653 .expect("run_stream should attach outcome metadata");
1654 assert_eq!(metadata.algorithm, "test");
1655 assert_eq!(
1656 uuid::Uuid::parse_str(metadata.outcome_id())
1657 .expect("outcome id should be a UUID")
1658 .get_version_num(),
1659 7
1660 );
1661 assert_eq!(
1662 metadata.evidence,
1663 Some(serde_json::json!({"source": "test"}))
1664 );
1665 assert_eq!(
1666 metadata.judgments[0].evidence,
1667 Some(serde_json::json!({"source": "judge"}))
1668 );
1669 let response = outcome
1670 .response
1671 .ok_or_else(|| test_error("expected an answered outcome"))?;
1672 final_completion = Some(
1673 response
1674 .llm_response
1675 .as_agg()
1676 .map(completion_text)
1677 .unwrap_or_default(),
1678 );
1679 }
1680 }
1681 }
1682
1683 assert!(saw_call, "expected a CallModel step before Done");
1684 assert_eq!(
1685 final_completion.ok_or_else(|| test_error("no Done step"))?,
1686 "fulfilled"
1687 );
1688 Ok(())
1689 }
1690
1691 #[tokio::test(flavor = "multi_thread", worker_threads = 12)]
1692 async fn requests_are_processed_in_parallel() -> Result<()> {
1693 use std::time::Duration;
1694 use tokio::sync::Barrier;
1695
1696 const N: usize = 12;
1697
1698 let barrier = Arc::new(Barrier::new(N));
1703 let algo = orch(target_set(&["m"]));
1705
1706 let mut handles = Vec::new();
1707 for _ in 0..N {
1708 let algo = algo.clone();
1709 let barrier = barrier.clone();
1710 let serve = move |target: ModelId, _request: Request| {
1711 let barrier = barrier.clone();
1712 async move {
1713 barrier.wait().await;
1714 Ok(reply(target))
1715 }
1716 };
1717 handles.push(tokio::spawn(async move {
1718 test_drive(algo, request(), serve)
1719 .await
1720 .map(|(_, response)| {
1721 response
1722 .llm_response
1723 .as_agg()
1724 .map(completion_text)
1725 .unwrap_or_default()
1726 })
1727 }));
1728 }
1729
1730 for handle in handles {
1731 let completion = tokio::time::timeout(Duration::from_secs(5), handle)
1733 .await
1734 .map_err(|error| LibsyError::external("waiting for test task", error))?
1735 .map_err(|source| LibsyError::external("joining a test task", source))??;
1736 assert_eq!(completion, "m");
1737 }
1738 Ok(())
1739 }
1740
1741 #[tokio::test]
1742 async fn offload_error_propagates_back_to_the_algorithm() -> Result<()> {
1743 let stream = orch(target_set(&["offload/model"])).run_stream(
1747 request(),
1748 Arc::new(RuntimeModels::default()),
1749 State::default(),
1750 );
1751 tokio::pin!(stream);
1752
1753 let mut saw_error = false;
1754 while let Some(step) = stream.next().await {
1755 match step {
1756 Ok(Step::CallDecision(_)) => return Err(test_error("unexpected decision call")),
1757 Ok(Step::CallModel(mut call)) => {
1758 call.recover_errors = true;
1759 call.respond(std::future::ready(Err(test_error(
1760 "upstream model call failed",
1761 ))))
1762 .await?;
1763 }
1764 Ok(Step::Done(..)) => {
1765 return Err(test_error(
1766 "expected the offload error to propagate, got a response",
1767 ));
1768 }
1769 Err(err) => {
1770 assert!(err.to_string().contains("upstream model call failed"));
1772 saw_error = true;
1773 }
1774 }
1775 }
1776
1777 assert!(saw_error, "expected an error step");
1778 Ok(())
1779 }
1780
1781 #[tokio::test]
1782 async fn dropping_the_stream_cancels_the_algorithm_task() -> Result<()> {
1783 use std::sync::atomic::{AtomicBool, Ordering};
1784 use std::time::Duration;
1785 use tokio::sync::mpsc;
1786
1787 struct DropGuard(Arc<AtomicBool>);
1790 impl Drop for DropGuard {
1791 fn drop(&mut self) {
1792 self.0.store(true, Ordering::SeqCst);
1793 }
1794 }
1795
1796 struct StuckAlgo {
1797 started: mpsc::UnboundedSender<()>,
1798 dropped: Arc<AtomicBool>,
1799 }
1800
1801 #[async_trait]
1802 impl Algorithm for StuckAlgo {
1803 fn name(&self) -> &str {
1804 "stuck"
1805 }
1806
1807 async fn route(
1808 self: Arc<Self>,
1809 _driver: Driver,
1810 _request: Request,
1811 ) -> Result<RoutingOutcome> {
1812 let _guard = DropGuard(self.dropped.clone());
1813 let _ = self.started.send(());
1814 std::future::pending::<()>().await;
1816 unreachable!()
1817 }
1818 }
1819
1820 let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1821 let dropped = Arc::new(AtomicBool::new(false));
1822 let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1823 started: started_tx,
1824 dropped: dropped.clone(),
1825 });
1826
1827 let stream = algo.run_stream(
1828 request(),
1829 Arc::new(RuntimeModels::default()),
1830 State::default(),
1831 );
1832 started_rx
1833 .recv()
1834 .await
1835 .ok_or_else(|| test_error("task never started"))?;
1836 drop(stream);
1837 tokio::time::sleep(Duration::from_millis(100)).await;
1838
1839 assert!(
1840 dropped.load(Ordering::SeqCst),
1841 "algorithm task was NOT cancelled after dropping the stream"
1842 );
1843 Ok(())
1844 }
1845
1846 #[tokio::test]
1847 async fn route_panic_surfaces_as_a_stream_error() -> Result<()> {
1848 struct Panicky;
1851
1852 #[async_trait]
1853 impl Algorithm for Panicky {
1854 fn name(&self) -> &str {
1855 "panicky"
1856 }
1857
1858 async fn route(
1859 self: Arc<Self>,
1860 _driver: Driver,
1861 _request: Request,
1862 ) -> Result<RoutingOutcome> {
1863 panic!("boom");
1864 }
1865 }
1866
1867 let algo: Arc<dyn Algorithm> = Arc::new(Panicky);
1868 let stream = algo.run_stream(
1869 request(),
1870 Arc::new(RuntimeModels::default()),
1871 State::default(),
1872 );
1873 tokio::pin!(stream);
1874
1875 let mut saw_error = false;
1876 while let Some(step) = stream.next().await {
1877 match step {
1878 Err(err) => {
1879 assert!(err.to_string().contains("algorithm task panicked: boom"));
1881 saw_error = true;
1882 }
1883 Ok(_) => return Err(test_error("expected the panic to surface as an error step")),
1884 }
1885 }
1886
1887 assert!(saw_error, "expected an error step from the panicked task");
1888 Ok(())
1889 }
1890
1891 #[tokio::test]
1895 async fn a_panic_with_a_leaked_driver_clone_still_terminates_the_run() -> Result<()> {
1896 struct LeakyPanic;
1897
1898 #[async_trait]
1899 impl Algorithm for LeakyPanic {
1900 fn name(&self) -> &str {
1901 "leaky_panic"
1902 }
1903
1904 async fn route(
1905 self: Arc<Self>,
1906 driver: Driver,
1907 _request: Request,
1908 ) -> Result<RoutingOutcome> {
1909 tokio::spawn(async move {
1910 let _keep_alive = driver;
1912 std::future::pending::<()>().await;
1913 });
1914 tokio::task::yield_now().await;
1915 panic!("boom");
1916 }
1917 }
1918
1919 let algo: Arc<dyn Algorithm> = Arc::new(LeakyPanic);
1920 let result = tokio::time::timeout(
1922 std::time::Duration::from_secs(1),
1923 test_drive(algo, request(), echo()),
1924 )
1925 .await
1926 .map_err(|error| LibsyError::external("waiting for the panicked run to end", error))?;
1927
1928 match result {
1929 Ok(_) => Err(test_error(
1930 "expected the panic to end the run with an error",
1931 )),
1932 Err(err) => {
1933 assert!(err.to_string().contains("algorithm task panicked: boom"));
1934 Ok(())
1935 }
1936 }
1937 }
1938
1939 #[tokio::test]
1940 async fn cancelling_run_cancels_the_algorithm_task() -> Result<()> {
1941 use std::sync::atomic::{AtomicBool, Ordering};
1942 use std::time::Duration;
1943 use tokio::sync::mpsc;
1944
1945 struct DropGuard(Arc<AtomicBool>);
1948 impl Drop for DropGuard {
1949 fn drop(&mut self) {
1950 self.0.store(true, Ordering::SeqCst);
1951 }
1952 }
1953
1954 struct StuckAlgo {
1955 started: mpsc::UnboundedSender<()>,
1956 dropped: Arc<AtomicBool>,
1957 }
1958
1959 #[async_trait]
1960 impl Algorithm for StuckAlgo {
1961 fn name(&self) -> &str {
1962 "stuck"
1963 }
1964
1965 async fn route(
1966 self: Arc<Self>,
1967 _driver: Driver,
1968 _request: Request,
1969 ) -> Result<RoutingOutcome> {
1970 let _guard = DropGuard(self.dropped.clone());
1971 let _ = self.started.send(());
1972 std::future::pending::<()>().await;
1975 unreachable!()
1976 }
1977 }
1978
1979 let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1980 let dropped = Arc::new(AtomicBool::new(false));
1981 let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1982 started: started_tx,
1983 dropped: dropped.clone(),
1984 });
1985
1986 let run_task = tokio::spawn(async move { test_drive(algo, request(), echo()).await });
1989 started_rx
1990 .recv()
1991 .await
1992 .ok_or_else(|| test_error("task never started"))?;
1993 run_task.abort();
1994 tokio::time::sleep(Duration::from_millis(100)).await;
1995
1996 assert!(
1997 dropped.load(Ordering::SeqCst),
1998 "algorithm task was NOT cancelled after cancelling run"
1999 );
2000 Ok(())
2001 }
2002
2003 struct Hedge {
2008 winner: String,
2009 loser: String,
2010 }
2011
2012 #[async_trait]
2013 impl Algorithm for Hedge {
2014 fn name(&self) -> &str {
2015 "hedge"
2016 }
2017
2018 async fn route(
2019 self: Arc<Self>,
2020 driver: Driver,
2021 request: Request,
2022 ) -> Result<RoutingOutcome> {
2023 let outcome_request = request.clone();
2024 let win = driver.call_model(request.clone(), vec![self.winner.clone().into()]);
2025 let lose = driver.call_model(request, vec![self.loser.clone().into()]);
2026 tokio::select! {
2028 res = win => Ok(RoutingOutcome::answered(
2029 self.winner.clone().into(),
2030 outcome_request,
2031 res?,
2032 )),
2033 res = lose => Ok(RoutingOutcome::answered(
2034 self.loser.clone().into(),
2035 outcome_request,
2036 res?,
2037 )),
2038 }
2039 }
2040 }
2041
2042 fn hedge(loser_delay: Option<std::time::Duration>) -> (Arc<dyn Algorithm>, impl Serve) {
2046 let started = Arc::new(tokio::sync::Notify::new());
2047 let algo = Arc::new(Hedge {
2048 winner: "winner".to_string(),
2049 loser: "loser".to_string(),
2050 });
2051 let serve = move |target: ModelId, _request: Request| {
2052 let started = started.clone();
2053 async move {
2054 if target == "loser" {
2055 started.notify_one();
2056 match loser_delay {
2057 Some(delay) => tokio::time::sleep(delay).await,
2058 None => std::future::pending::<()>().await,
2059 }
2060 } else {
2061 started.notified().await;
2062 }
2063 Ok(reply(target))
2064 }
2065 };
2066 (algo, serve)
2067 }
2068
2069 #[tokio::test]
2070 async fn run_returns_the_winner_without_a_late_loser_overwriting_it() -> Result<()> {
2071 let (algo, serve) = hedge(Some(std::time::Duration::from_millis(50)));
2074 let (_, response) = test_drive(algo, request(), serve).await?;
2075 assert_eq!(
2076 response
2077 .llm_response
2078 .as_agg()
2079 .map(completion_text)
2080 .unwrap_or_default(),
2081 "winner"
2082 );
2083 Ok(())
2084 }
2085
2086 #[tokio::test]
2087 async fn run_returns_the_winner_without_hanging_on_a_pending_loser() -> Result<()> {
2088 let (algo, serve) = hedge(None);
2091 let run = test_drive(algo, request(), serve);
2092 let (_, response) = tokio::time::timeout(std::time::Duration::from_secs(1), run)
2093 .await
2094 .map_err(|error| LibsyError::external("waiting for pending loser", error))??;
2095 assert_eq!(
2096 response
2097 .llm_response
2098 .as_agg()
2099 .map(completion_text)
2100 .unwrap_or_default(),
2101 "winner"
2102 );
2103 Ok(())
2104 }
2105
2106 #[tokio::test]
2107 async fn run_surfaces_a_terminal_error_with_many_calls_in_flight() -> Result<()> {
2108 use std::sync::atomic::{AtomicUsize, Ordering};
2109
2110 const N: usize = 10;
2113
2114 struct FanOutThenError {
2117 all_started: Arc<tokio::sync::Notify>,
2118 n: usize,
2119 }
2120
2121 #[async_trait]
2122 impl Algorithm for FanOutThenError {
2123 fn name(&self) -> &str {
2124 "fan_out_then_error"
2125 }
2126
2127 async fn route(
2128 self: Arc<Self>,
2129 driver: Driver,
2130 request: Request,
2131 ) -> Result<RoutingOutcome> {
2132 let offloads = futures::future::join_all(
2133 (0..self.n)
2134 .map(|i| driver.call_model(request.clone(), vec![format!("m{i}").into()])),
2135 );
2136 tokio::select! {
2137 _ = offloads => Err(test_error("offloads unexpectedly completed")),
2138 _ = self.all_started.notified() => {
2139 Err(test_error("terminal error while calls pending"))
2140 }
2141 }
2142 }
2143 }
2144
2145 let all_started = Arc::new(tokio::sync::Notify::new());
2146 let algo: Arc<dyn Algorithm> = Arc::new(FanOutThenError {
2147 all_started: all_started.clone(),
2148 n: N,
2149 });
2150
2151 let started = Arc::new(AtomicUsize::new(0));
2153 let serve = move |_target: ModelId, _request: Request| {
2154 let started = started.clone();
2155 let all_started = all_started.clone();
2156 async move {
2157 if started.fetch_add(1, Ordering::SeqCst) + 1 == N {
2158 all_started.notify_one();
2159 }
2160 std::future::pending::<ServeResult>().await
2161 }
2162 };
2163
2164 let run = test_drive(algo, request(), serve);
2167 let result = tokio::time::timeout(std::time::Duration::from_millis(500), run)
2168 .await
2169 .map_err(|error| {
2170 LibsyError::external("waiting for terminal error with full call cap", error)
2171 })?;
2172 match result {
2173 Ok(_) => Err(test_error("expected the terminal error, got a response")),
2174 Err(err) => {
2175 assert!(
2176 err.to_string()
2177 .contains("terminal error while calls pending")
2178 );
2179 Ok(())
2180 }
2181 }
2182 }
2183}