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