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