1use std::{future::Future, panic::AssertUnwindSafe, pin::Pin, sync::Arc, time::Instant};
8
9use async_trait::async_trait;
10use futures::{FutureExt, Stream, StreamExt};
11use tokio::sync::{mpsc, oneshot};
12use tokio_stream::wrappers::ReceiverStream;
13use tracing::Instrument;
14
15use switchyard_protocol::{ModelId, Request, Response};
23
24use crate::{DriverError, LibsyError, Result, observability};
25
26pub type StepStream = Pin<Box<dyn Stream<Item = Result<Step>> + Send>>;
30
31pub struct CallModel {
41 pub algorithm: String,
44 pub request: Request,
46 pub models: Vec<ModelId>,
48 reply: oneshot::Sender<Result<Response>>,
50}
51
52impl CallModel {
53 pub fn respond(self, result: Result<Response>) -> Result<()> {
57 self.reply
58 .send(result)
59 .map_err(|_| DriverError::ResponseDropped.into())
60 }
61}
62
63pub struct RoutingOutcome {
65 pub selected_model_ids: Vec<ModelId>,
67 pub request: Request,
69 pub response: Option<Response>,
71}
72
73impl RoutingOutcome {
74 pub fn selected_model_id(&self) -> Result<&ModelId> {
77 self.selected_model_ids.first().ok_or(LibsyError::NoTargets)
78 }
79
80 pub fn route_to(
84 selected_model_id: ModelId,
85 fallback_models: Vec<ModelId>,
86 mut request: Request,
87 ) -> Self {
88 request.llm_request.model = Some(selected_model_id.to_string());
89 let mut selected_model_ids = Vec::with_capacity(1 + fallback_models.len());
90 selected_model_ids.push(selected_model_id);
91 selected_model_ids.extend(fallback_models);
92 Self {
93 selected_model_ids,
94 request,
95 response: None,
96 }
97 }
98
99 pub fn answered(selected_model_id: ModelId, mut request: Request, response: Response) -> Self {
102 request.llm_request.model = Some(selected_model_id.to_string());
103 Self {
104 selected_model_ids: vec![selected_model_id],
105 request,
106 response: Some(response),
107 }
108 }
109}
110
111#[derive(Clone)]
113pub struct Driver {
114 step_tx: mpsc::Sender<Result<Step>>,
115 algorithm: String,
117}
118
119impl Driver {
120 pub(crate) fn new(algorithm: &str) -> (Self, mpsc::Receiver<Result<Step>>) {
123 let (step_tx, step_rx) = mpsc::channel(1);
128 (
129 Self {
130 step_tx,
131 algorithm: algorithm.to_string(),
132 },
133 step_rx,
134 )
135 }
136
137 #[tracing::instrument(
146 target = "libsy",
147 name = "libsy.llm_call",
148 skip_all,
149 fields(
150 algorithm = self.algorithm,
151 selected_model = %models.first().map(ModelId::as_str).unwrap_or("NoTargets"),
152 openinference.span.kind = "CHAIN",
153 outcome = tracing::field::Empty,
154 error = tracing::field::Empty,
155 input_tokens = tracing::field::Empty,
156 output_tokens = tracing::field::Empty,
157 total_tokens = tracing::field::Empty,
158 reasoning_tokens = tracing::field::Empty,
159 )
160 )]
161 pub async fn call_model(&self, mut request: Request, models: Vec<ModelId>) -> Result<Response> {
162 let Some(selected_model_id) = models.first().cloned() else {
163 return Err(LibsyError::NoTargets);
164 };
165 request.llm_request.model = Some(selected_model_id.to_string());
166 let started = Instant::now();
167 let (reply, response) = oneshot::channel::<Result<Response>>();
168 let call = CallModel {
169 algorithm: self.algorithm.clone(),
170 request,
171 models,
172 reply,
173 };
174 let result = async {
175 self.step_tx
176 .send(Ok(Step::CallModel(Box::new(call))))
177 .await
178 .map_err(|_| DriverError::StreamClosed)?;
179 response
180 .await
181 .map_err(|_| LibsyError::from(DriverError::ResponseDropped))?
182 }
183 .await;
184 let elapsed = started.elapsed();
185 observability::record_llm_call(
186 &self.algorithm,
187 selected_model_id.as_str(),
188 elapsed,
189 &result,
190 &tracing::Span::current(),
191 );
192 result
193 }
194
195 pub(crate) async fn finish(&self, result: Result<RoutingOutcome>) -> Result<()> {
199 let selected_model = result
200 .as_ref()
201 .ok()
202 .and_then(|outcome| outcome.selected_model_id().ok().cloned());
203 let step = result.map(|outcome| Step::Done(Box::new(outcome)));
204 self.step_tx
205 .send(step)
206 .await
207 .map_err(|_| DriverError::StreamClosed)?;
208 if let Some(selected_model) = selected_model {
209 observability::record_decision(&self.algorithm, &selected_model);
210 }
211 Ok(())
212 }
213}
214
215pub enum Step {
217 CallModel(Box<CallModel>),
220 Done(Box<RoutingOutcome>),
222}
223
224pub async fn drive<F, Fut>(
237 algorithm: Arc<dyn Algorithm>,
238 request: Request,
239 serve: F,
240) -> Result<RoutingOutcome>
241where
242 F: Fn(CallModel) -> Fut,
243 Fut: Future<Output = Result<()>>,
244{
245 let stream = algorithm.run_stream(request);
246 tokio::pin!(stream);
247
248 let mut in_flight = futures::stream::FuturesUnordered::new();
249 let mut final_outcome: Option<RoutingOutcome> = None;
250
251 loop {
252 tokio::select! {
253 Some(result) = in_flight.next() => match result {
254 Ok(()) => {}, Err(err) => return Err(err), },
257 step = stream.next() => {
258 match step {
259 None => break, Some(item) => match item? {
261 Step::CallModel(call) => in_flight.push(serve(*call)),
262 Step::Done(outcome) => {
263 final_outcome = Some(*outcome);
264 break;
265 }
266 }
267 }
268 },
269 }
270 }
271 final_outcome.ok_or(LibsyError::MissingFinalResponse)
272}
273
274fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
276 payload
277 .downcast_ref::<&'static str>()
278 .map(|message| (*message).to_string())
279 .or_else(|| payload.downcast_ref::<String>().cloned())
280 .unwrap_or_else(|| "unknown panic payload".to_string())
281}
282
283struct AbortOnDrop(tokio::task::AbortHandle);
285
286impl Drop for AbortOnDrop {
287 fn drop(&mut self) {
288 self.0.abort();
289 }
290}
291
292pub(crate) fn ensure_model_is_target(targets: &[ModelId], name: &ModelId) -> Result<()> {
297 targets
298 .iter()
299 .any(|target| target == name)
300 .then_some(())
301 .ok_or_else(|| LibsyError::TargetNotFound {
302 target: name.clone(),
303 })
304}
305
306#[derive(Clone, Hash, PartialEq, Eq)]
309pub(crate) enum RoutingIdentity {
310 Session(String),
312 Subagent { session: String, agent: String },
314}
315
316impl RoutingIdentity {
317 pub(crate) fn from_request(request: &Request) -> Option<Self> {
322 let metadata = request.metadata.as_ref()?;
323 let session = metadata.session_id.as_deref().filter(|id| !id.is_empty())?;
324 if metadata.is_subagent {
325 let agent = metadata.agent_id.as_deref().filter(|id| !id.is_empty())?;
326 Some(Self::Subagent {
327 session: session.to_string(),
328 agent: agent.to_string(),
329 })
330 } else {
331 Some(Self::Session(session.to_string()))
332 }
333 }
334}
335
336#[async_trait]
358pub trait Algorithm: Send + Sync + 'static {
359 fn name(&self) -> &str;
363
364 async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<RoutingOutcome>;
368
369 fn run_stream(self: Arc<Self>, request: Request) -> StepStream {
378 let (driver, step_rx) = Driver::new(self.name());
379 let span = observability::run_span(self.name(), &request);
380 let handle = tokio::spawn(
381 async move {
382 let algorithm = self.name().to_string();
383 let route = AssertUnwindSafe(self.route(driver.clone(), request)).catch_unwind();
385 let result = observability::observe_run(&algorithm, async move {
386 route.await.unwrap_or_else(|payload| {
387 Err(LibsyError::AlgorithmError {
388 message: format!(
389 "algorithm task panicked: {}",
390 panic_message(payload.as_ref())
391 ),
392 })
393 })
394 })
395 .await;
396
397 let _ = driver.finish(result).await;
398 }
399 .instrument(span),
400 );
401 let abort_guard = AbortOnDrop(handle.abort_handle());
403 Box::pin(ReceiverStream::new(step_rx).map(move |step| {
404 let _keep_alive = &abort_guard;
406 step
407 }))
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use std::collections::HashMap;
414
415 use super::*;
416 use crate::core::testing::{Serve, ServeResult, echo, reply, test_drive};
417 use futures::StreamExt;
418 use switchyard_protocol::{
419 LlmResponse, LlmResponseChunk, completion_text, text_request, text_response,
420 };
421
422 #[derive(Debug, thiserror::Error)]
423 #[error("{0}")]
424 struct TestError(&'static str);
425
426 fn test_error(message: &'static str) -> LibsyError {
427 LibsyError::external("test", TestError(message))
428 }
429
430 struct TestAlgo {
433 target_set: Vec<ModelId>,
434 }
435
436 #[async_trait]
437 impl Algorithm for TestAlgo {
438 fn name(&self) -> &str {
439 "test"
440 }
441
442 async fn route(
443 self: Arc<Self>,
444 driver: Driver,
445 request: Request,
446 ) -> Result<RoutingOutcome> {
447 let target = self
448 .target_set
449 .first()
450 .ok_or(LibsyError::NoTargets)?
451 .clone();
452 let response = driver
453 .call_model(request.clone(), vec![target.clone()])
454 .await?;
455 Ok(RoutingOutcome::answered(target, request, response))
456 }
457 }
458
459 fn orch(target_set: Vec<ModelId>) -> Arc<dyn Algorithm> {
461 Arc::new(TestAlgo { target_set })
462 }
463
464 fn request() -> Request {
465 Request {
466 llm_request: text_request(Some("auto".to_string()), "hi".to_string()),
467 raw_request: None,
468 metadata: None,
469 }
470 }
471
472 #[test]
473 fn routing_outcome_constructors_stamp_selection_and_preserve_payloads() {
474 let outcome = RoutingOutcome::route_to(
475 "selected".into(),
476 target_set(&["fallback-one", "fallback-two"]),
477 request(),
478 );
479
480 assert_eq!(
481 outcome.selected_model_ids,
482 target_set(&["selected", "fallback-one", "fallback-two"])
483 );
484 assert_eq!(outcome.request.model_id().as_deref(), Some("selected"));
485 assert!(outcome.response.is_none());
486
487 let outcome = RoutingOutcome::route_to("only".into(), Vec::new(), request());
488 assert_eq!(outcome.selected_model_ids, target_set(&["only"]));
489
490 let outcome = RoutingOutcome::answered(
491 "answered".into(),
492 request(),
493 Response {
494 llm_response: LlmResponse::Agg(text_response(None, "existing")),
495 metadata: None,
496 },
497 );
498
499 assert_eq!(outcome.selected_model_ids, target_set(&["answered"]));
500 assert_eq!(outcome.request.model_id().as_deref(), Some("answered"));
501 assert_eq!(
502 outcome
503 .response
504 .as_ref()
505 .and_then(|response| response.llm_response.as_agg())
506 .map(completion_text),
507 Some("existing".to_string())
508 );
509 }
510
511 fn target_set(names: &[&str]) -> Vec<ModelId> {
512 names.iter().map(|name| ModelId::from(*name)).collect()
513 }
514
515 #[tokio::test]
516 async fn typed_driver_preserves_call_and_stream_boundaries() -> Result<()> {
517 tokio::time::timeout(std::time::Duration::from_secs(1), async {
518 let (driver, mut step_rx) = Driver::new("test");
521 let first_driver = driver.clone();
522 let mut first = tokio::spawn(async move {
523 first_driver
524 .call_model(request(), vec![ModelId::from("first")])
525 .await
526 });
527 let second = tokio::spawn(async move {
528 driver
529 .call_model(request(), vec![ModelId::from("second")])
530 .await
531 });
532
533 let mut calls = HashMap::new();
534 for _ in 0..2 {
535 let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
536 let Step::CallModel(call) = step else {
537 return Err(test_error("expected a CallModel step"));
538 };
539 let selected_model = call
540 .models
541 .first()
542 .ok_or_else(|| test_error("model call has no candidates"))?
543 .to_string();
544 calls.insert(selected_model, call);
545 }
546 assert!(
547 tokio::time::timeout(std::time::Duration::from_millis(20), &mut first)
548 .await
549 .is_err(),
550 "call completed before the host responded"
551 );
552 calls
553 .remove("second")
554 .ok_or_else(|| test_error("missing second call"))?
555 .respond(Ok(reply("second response")))?;
556 calls
557 .remove("first")
558 .ok_or_else(|| test_error("missing first call"))?
559 .respond(Ok(reply("first response")))?;
560
561 let first_response = first
562 .await
563 .map_err(|source| LibsyError::external("joining a test task", source))??;
564 let second_response = second
565 .await
566 .map_err(|source| LibsyError::external("joining a test task", source))??;
567 assert_eq!(
568 first_response.llm_response.as_agg().map(completion_text),
569 Some("first response".to_string())
570 );
571 assert_eq!(
572 second_response.llm_response.as_agg().map(completion_text),
573 Some("second response".to_string())
574 );
575
576 let (driver, mut step_rx) = Driver::new("test");
578 let producer = tokio::spawn(async move {
579 driver
580 .call_model(request(), vec![ModelId::from("dropped")])
581 .await
582 });
583 let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
584 let Step::CallModel(call) = step else {
585 return Err(test_error("expected a CallModel step"));
586 };
587 drop(call);
588 let result = producer
589 .await
590 .map_err(|source| LibsyError::external("joining a test task", source))?;
591 assert!(matches!(
592 result,
593 Err(LibsyError::Driver(DriverError::ResponseDropped))
594 ));
595
596 let (driver, step_rx) = Driver::new("test");
598 drop(step_rx);
599 let result = driver
600 .call_model(request(), vec![ModelId::from("closed")])
601 .await;
602 assert!(matches!(
603 result,
604 Err(LibsyError::Driver(DriverError::StreamClosed))
605 ));
606 Ok(())
607 })
608 .await
609 .map_err(|error| LibsyError::external("waiting for typed driver boundaries", error))?
610 }
611
612 #[test]
613 fn target_lookup_returns_the_missing_target() {
614 let error = ensure_model_is_target(&target_set(&[]), &ModelId::from("missing")).err();
615 assert!(matches!(
616 error,
617 Some(LibsyError::TargetNotFound { target }) if target == "missing"
618 ));
619 }
620
621 fn streaming_orch(chunks: Vec<LlmResponseChunk>) -> (Arc<dyn Algorithm>, impl Serve) {
624 let algo = orch(target_set(&["stream/model"]));
625 let serve = move |_target: ModelId, _request: Request| {
626 let chunks = chunks.clone();
627 async move {
628 let stream =
629 futures::stream::iter(chunks.into_iter().map(|chunk| Ok(chunk.into()))).boxed();
630 Ok(Response {
631 llm_response: LlmResponse::Stream(stream),
632 metadata: None,
633 })
634 }
635 };
636 (algo, serve)
637 }
638
639 #[tokio::test]
640 async fn run_returns_a_streamed_response_the_caller_aggregates() -> Result<()> {
641 let (orch, serve) = streaming_orch(vec![
644 LlmResponseChunk::MessageStart {
645 id: Some("m1".to_string()),
646 model: Some("stream/model".to_string()),
647 },
648 LlmResponseChunk::TextDelta {
649 index: 0,
650 text: "hel".to_string(),
651 },
652 LlmResponseChunk::TextDelta {
653 index: 0,
654 text: "lo".to_string(),
655 },
656 LlmResponseChunk::MessageStop {
657 reason: Some("stop".to_string()),
658 },
659 ]);
660 let (selected_model, response) = test_drive(orch, request(), serve).await?;
661 let agg = response
663 .llm_response
664 .into_agg()
665 .await
666 .map_err(|error| LibsyError::external("aggregating response stream", error))?;
667 assert_eq!(completion_text(&agg), "hello");
668 assert_eq!(agg.model.as_deref(), Some("stream/model"));
669 assert_eq!(selected_model, "stream/model");
670 Ok(())
671 }
672
673 #[tokio::test]
674 async fn aggregating_a_streamed_response_propagates_a_mid_stream_error() -> Result<()> {
675 let (orch, serve) = streaming_orch(vec![
678 LlmResponseChunk::TextDelta {
679 index: 0,
680 text: "partial".to_string(),
681 },
682 LlmResponseChunk::StreamError {
683 message: "upstream exploded".to_string(),
684 },
685 ]);
686 let (_, response) = test_drive(orch, request(), serve).await?;
687 match response.llm_response.into_agg().await {
688 Ok(_) => panic!("expected a mid-stream error, got an aggregate"),
689 Err(err) => {
690 assert!(err.to_string().contains("upstream exploded"));
691 Ok(())
692 }
693 }
694 }
695
696 #[tokio::test]
697 async fn run_offloads_via_promise_then_finishes() -> Result<()> {
698 let stream = orch(target_set(&["offload/model"])).run_stream(request());
701 tokio::pin!(stream);
702
703 let mut saw_call = false;
704 let mut final_completion = None;
705 while let Some(step) = stream.next().await {
706 match step? {
707 Step::CallModel(call) => {
708 saw_call = true;
709 assert_eq!(call.models, vec![ModelId::from("offload/model")]);
710 call.respond(Ok(Response {
712 llm_response: LlmResponse::Agg(text_response(
713 None,
714 "fulfilled".to_string(),
715 )),
716 metadata: None,
717 }))?;
718 }
719 Step::Done(outcome) => {
720 let response = outcome
721 .response
722 .ok_or_else(|| test_error("expected an answered outcome"))?;
723 final_completion = Some(
724 response
725 .llm_response
726 .as_agg()
727 .map(completion_text)
728 .unwrap_or_default(),
729 );
730 }
731 }
732 }
733
734 assert!(saw_call, "expected a CallModel step before Done");
735 assert_eq!(
736 final_completion.ok_or_else(|| test_error("no Done step"))?,
737 "fulfilled"
738 );
739 Ok(())
740 }
741
742 #[tokio::test(flavor = "multi_thread", worker_threads = 12)]
743 async fn requests_are_processed_in_parallel() -> Result<()> {
744 use std::time::Duration;
745 use tokio::sync::Barrier;
746
747 const N: usize = 12;
748
749 let barrier = Arc::new(Barrier::new(N));
754 let algo = orch(target_set(&["m"]));
756
757 let mut handles = Vec::new();
758 for _ in 0..N {
759 let algo = algo.clone();
760 let barrier = barrier.clone();
761 let serve = move |target: ModelId, _request: Request| {
762 let barrier = barrier.clone();
763 async move {
764 barrier.wait().await;
765 Ok(reply(target))
766 }
767 };
768 handles.push(tokio::spawn(async move {
769 test_drive(algo, request(), serve)
770 .await
771 .map(|(_, response)| {
772 response
773 .llm_response
774 .as_agg()
775 .map(completion_text)
776 .unwrap_or_default()
777 })
778 }));
779 }
780
781 for handle in handles {
782 let completion = tokio::time::timeout(Duration::from_secs(5), handle)
784 .await
785 .map_err(|error| LibsyError::external("waiting for test task", error))?
786 .map_err(|source| LibsyError::external("joining a test task", source))??;
787 assert_eq!(completion, "m");
788 }
789 Ok(())
790 }
791
792 #[tokio::test]
793 async fn offload_error_propagates_back_to_the_algorithm() -> Result<()> {
794 let stream = orch(target_set(&["offload/model"])).run_stream(request());
798 tokio::pin!(stream);
799
800 let mut saw_error = false;
801 while let Some(step) = stream.next().await {
802 match step {
803 Ok(Step::CallModel(call)) => {
804 call.respond(Err(test_error("upstream model call failed")))?;
805 }
806 Ok(Step::Done(..)) => {
807 return Err(test_error(
808 "expected the offload error to propagate, got a response",
809 ));
810 }
811 Err(err) => {
812 assert!(err.to_string().contains("upstream model call failed"));
814 saw_error = true;
815 }
816 }
817 }
818
819 assert!(saw_error, "expected an error step");
820 Ok(())
821 }
822
823 #[tokio::test]
824 async fn dropping_the_stream_cancels_the_algorithm_task() -> Result<()> {
825 use std::sync::atomic::{AtomicBool, Ordering};
826 use std::time::Duration;
827 use tokio::sync::mpsc;
828
829 struct DropGuard(Arc<AtomicBool>);
832 impl Drop for DropGuard {
833 fn drop(&mut self) {
834 self.0.store(true, Ordering::SeqCst);
835 }
836 }
837
838 struct StuckAlgo {
839 started: mpsc::UnboundedSender<()>,
840 dropped: Arc<AtomicBool>,
841 }
842
843 #[async_trait]
844 impl Algorithm for StuckAlgo {
845 fn name(&self) -> &str {
846 "stuck"
847 }
848
849 async fn route(
850 self: Arc<Self>,
851 _driver: Driver,
852 _request: Request,
853 ) -> Result<RoutingOutcome> {
854 let _guard = DropGuard(self.dropped.clone());
855 let _ = self.started.send(());
856 std::future::pending::<()>().await;
858 unreachable!()
859 }
860 }
861
862 let (started_tx, mut started_rx) = mpsc::unbounded_channel();
863 let dropped = Arc::new(AtomicBool::new(false));
864 let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
865 started: started_tx,
866 dropped: dropped.clone(),
867 });
868
869 let stream = algo.run_stream(request());
870 started_rx
871 .recv()
872 .await
873 .ok_or_else(|| test_error("task never started"))?;
874 drop(stream);
875 tokio::time::sleep(Duration::from_millis(100)).await;
876
877 assert!(
878 dropped.load(Ordering::SeqCst),
879 "algorithm task was NOT cancelled after dropping the stream"
880 );
881 Ok(())
882 }
883
884 #[tokio::test]
885 async fn route_panic_surfaces_as_a_stream_error() -> Result<()> {
886 struct Panicky;
889
890 #[async_trait]
891 impl Algorithm for Panicky {
892 fn name(&self) -> &str {
893 "panicky"
894 }
895
896 async fn route(
897 self: Arc<Self>,
898 _driver: Driver,
899 _request: Request,
900 ) -> Result<RoutingOutcome> {
901 panic!("boom");
902 }
903 }
904
905 let algo: Arc<dyn Algorithm> = Arc::new(Panicky);
906 let stream = algo.run_stream(request());
907 tokio::pin!(stream);
908
909 let mut saw_error = false;
910 while let Some(step) = stream.next().await {
911 match step {
912 Err(err) => {
913 assert!(err.to_string().contains("algorithm task panicked: boom"));
915 saw_error = true;
916 }
917 Ok(_) => return Err(test_error("expected the panic to surface as an error step")),
918 }
919 }
920
921 assert!(saw_error, "expected an error step from the panicked task");
922 Ok(())
923 }
924
925 #[tokio::test]
929 async fn a_panic_with_a_leaked_driver_clone_still_terminates_the_run() -> Result<()> {
930 struct LeakyPanic;
931
932 #[async_trait]
933 impl Algorithm for LeakyPanic {
934 fn name(&self) -> &str {
935 "leaky_panic"
936 }
937
938 async fn route(
939 self: Arc<Self>,
940 driver: Driver,
941 _request: Request,
942 ) -> Result<RoutingOutcome> {
943 tokio::spawn(async move {
944 let _keep_alive = driver;
946 std::future::pending::<()>().await;
947 });
948 tokio::task::yield_now().await;
949 panic!("boom");
950 }
951 }
952
953 let algo: Arc<dyn Algorithm> = Arc::new(LeakyPanic);
954 let result = tokio::time::timeout(
956 std::time::Duration::from_secs(1),
957 test_drive(algo, request(), echo()),
958 )
959 .await
960 .map_err(|error| LibsyError::external("waiting for the panicked run to end", error))?;
961
962 match result {
963 Ok(_) => Err(test_error(
964 "expected the panic to end the run with an error",
965 )),
966 Err(err) => {
967 assert!(err.to_string().contains("algorithm task panicked: boom"));
968 Ok(())
969 }
970 }
971 }
972
973 #[tokio::test]
974 async fn cancelling_run_cancels_the_algorithm_task() -> Result<()> {
975 use std::sync::atomic::{AtomicBool, Ordering};
976 use std::time::Duration;
977 use tokio::sync::mpsc;
978
979 struct DropGuard(Arc<AtomicBool>);
982 impl Drop for DropGuard {
983 fn drop(&mut self) {
984 self.0.store(true, Ordering::SeqCst);
985 }
986 }
987
988 struct StuckAlgo {
989 started: mpsc::UnboundedSender<()>,
990 dropped: Arc<AtomicBool>,
991 }
992
993 #[async_trait]
994 impl Algorithm for StuckAlgo {
995 fn name(&self) -> &str {
996 "stuck"
997 }
998
999 async fn route(
1000 self: Arc<Self>,
1001 _driver: Driver,
1002 _request: Request,
1003 ) -> Result<RoutingOutcome> {
1004 let _guard = DropGuard(self.dropped.clone());
1005 let _ = self.started.send(());
1006 std::future::pending::<()>().await;
1009 unreachable!()
1010 }
1011 }
1012
1013 let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1014 let dropped = Arc::new(AtomicBool::new(false));
1015 let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1016 started: started_tx,
1017 dropped: dropped.clone(),
1018 });
1019
1020 let run_task = tokio::spawn(async move { test_drive(algo, request(), echo()).await });
1023 started_rx
1024 .recv()
1025 .await
1026 .ok_or_else(|| test_error("task never started"))?;
1027 run_task.abort();
1028 tokio::time::sleep(Duration::from_millis(100)).await;
1029
1030 assert!(
1031 dropped.load(Ordering::SeqCst),
1032 "algorithm task was NOT cancelled after cancelling run"
1033 );
1034 Ok(())
1035 }
1036
1037 struct Hedge {
1042 winner: String,
1043 loser: String,
1044 }
1045
1046 #[async_trait]
1047 impl Algorithm for Hedge {
1048 fn name(&self) -> &str {
1049 "hedge"
1050 }
1051
1052 async fn route(
1053 self: Arc<Self>,
1054 driver: Driver,
1055 request: Request,
1056 ) -> Result<RoutingOutcome> {
1057 let outcome_request = request.clone();
1058 let win = driver.call_model(request.clone(), vec![self.winner.clone().into()]);
1059 let lose = driver.call_model(request, vec![self.loser.clone().into()]);
1060 tokio::select! {
1062 res = win => Ok(RoutingOutcome::answered(
1063 self.winner.clone().into(),
1064 outcome_request,
1065 res?,
1066 )),
1067 res = lose => Ok(RoutingOutcome::answered(
1068 self.loser.clone().into(),
1069 outcome_request,
1070 res?,
1071 )),
1072 }
1073 }
1074 }
1075
1076 fn hedge(loser_delay: Option<std::time::Duration>) -> (Arc<dyn Algorithm>, impl Serve) {
1080 let started = Arc::new(tokio::sync::Notify::new());
1081 let algo = Arc::new(Hedge {
1082 winner: "winner".to_string(),
1083 loser: "loser".to_string(),
1084 });
1085 let serve = move |target: ModelId, _request: Request| {
1086 let started = started.clone();
1087 async move {
1088 if target == "loser" {
1089 started.notify_one();
1090 match loser_delay {
1091 Some(delay) => tokio::time::sleep(delay).await,
1092 None => std::future::pending::<()>().await,
1093 }
1094 } else {
1095 started.notified().await;
1096 }
1097 Ok(reply(target))
1098 }
1099 };
1100 (algo, serve)
1101 }
1102
1103 #[tokio::test]
1104 async fn run_returns_the_winner_without_a_late_loser_overwriting_it() -> Result<()> {
1105 let (algo, serve) = hedge(Some(std::time::Duration::from_millis(50)));
1108 let (_, response) = test_drive(algo, request(), serve).await?;
1109 assert_eq!(
1110 response
1111 .llm_response
1112 .as_agg()
1113 .map(completion_text)
1114 .unwrap_or_default(),
1115 "winner"
1116 );
1117 Ok(())
1118 }
1119
1120 #[tokio::test]
1121 async fn run_returns_the_winner_without_hanging_on_a_pending_loser() -> Result<()> {
1122 let (algo, serve) = hedge(None);
1125 let run = test_drive(algo, request(), serve);
1126 let (_, response) = tokio::time::timeout(std::time::Duration::from_secs(1), run)
1127 .await
1128 .map_err(|error| LibsyError::external("waiting for pending loser", error))??;
1129 assert_eq!(
1130 response
1131 .llm_response
1132 .as_agg()
1133 .map(completion_text)
1134 .unwrap_or_default(),
1135 "winner"
1136 );
1137 Ok(())
1138 }
1139
1140 #[tokio::test]
1141 async fn run_surfaces_a_terminal_error_with_many_calls_in_flight() -> Result<()> {
1142 use std::sync::atomic::{AtomicUsize, Ordering};
1143
1144 const N: usize = 10;
1147
1148 struct FanOutThenError {
1151 all_started: Arc<tokio::sync::Notify>,
1152 n: usize,
1153 }
1154
1155 #[async_trait]
1156 impl Algorithm for FanOutThenError {
1157 fn name(&self) -> &str {
1158 "fan_out_then_error"
1159 }
1160
1161 async fn route(
1162 self: Arc<Self>,
1163 driver: Driver,
1164 request: Request,
1165 ) -> Result<RoutingOutcome> {
1166 let offloads = futures::future::join_all(
1167 (0..self.n)
1168 .map(|i| driver.call_model(request.clone(), vec![format!("m{i}").into()])),
1169 );
1170 tokio::select! {
1171 _ = offloads => Err(test_error("offloads unexpectedly completed")),
1172 _ = self.all_started.notified() => {
1173 Err(test_error("terminal error while calls pending"))
1174 }
1175 }
1176 }
1177 }
1178
1179 let all_started = Arc::new(tokio::sync::Notify::new());
1180 let algo: Arc<dyn Algorithm> = Arc::new(FanOutThenError {
1181 all_started: all_started.clone(),
1182 n: N,
1183 });
1184
1185 let started = Arc::new(AtomicUsize::new(0));
1187 let serve = move |_target: ModelId, _request: Request| {
1188 let started = started.clone();
1189 let all_started = all_started.clone();
1190 async move {
1191 if started.fetch_add(1, Ordering::SeqCst) + 1 == N {
1192 all_started.notify_one();
1193 }
1194 std::future::pending::<ServeResult>().await
1195 }
1196 };
1197
1198 let run = test_drive(algo, request(), serve);
1201 let result = tokio::time::timeout(std::time::Duration::from_millis(500), run)
1202 .await
1203 .map_err(|error| {
1204 LibsyError::external("waiting for terminal error with full call cap", error)
1205 })?;
1206 match result {
1207 Ok(_) => Err(test_error("expected the terminal error, got a response")),
1208 Err(err) => {
1209 assert!(
1210 err.to_string()
1211 .contains("terminal error while calls pending")
1212 );
1213 Ok(())
1214 }
1215 }
1216 }
1217}