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