1use std::{
8 collections::HashMap, future::Future, panic::AssertUnwindSafe, pin::Pin, sync::Arc,
9 time::Instant,
10};
11
12use async_trait::async_trait;
13use futures::{FutureExt, Stream, StreamExt};
14use parking_lot::Mutex;
15use serde_json::Value;
16use tokio::sync::{mpsc, oneshot};
17use tokio_stream::wrappers::ReceiverStream;
18use tracing::Instrument;
19
20use switchyard_protocol::{Category, ModelId, Request, Response};
28
29use crate::{DriverError, LibsyError, Result, observability};
30
31pub type StepStream = Pin<Box<dyn Stream<Item = Result<Step>> + Send>>;
35
36#[derive(Clone, Debug, Default)]
47pub struct RuntimeModels {
48 by_category: HashMap<Category, Vec<ModelId>>,
50 subagent: Option<HashMap<Category, Vec<ModelId>>>,
51}
52
53impl RuntimeModels {
54 pub fn new(by_category: HashMap<Category, Vec<ModelId>>) -> Self {
56 Self {
57 by_category,
58 subagent: None,
59 }
60 }
61
62 pub fn with_subagent(mut self, models: HashMap<Category, Vec<ModelId>>) -> Self {
64 self.subagent = Some(models);
65 self
66 }
67
68 pub fn models_for(&self, category: &Category) -> &[ModelId] {
70 self.by_category.get(category).map_or(&[], Vec::as_slice)
71 }
72
73 pub fn subagent_models_for(&self, category: &Category) -> &[ModelId] {
75 self.subagent
76 .as_ref()
77 .and_then(|models| models.get(category))
78 .map_or(&[], Vec::as_slice)
79 }
80}
81
82impl From<HashMap<Category, Vec<ModelId>>> for RuntimeModels {
83 fn from(by_category: HashMap<Category, Vec<ModelId>>) -> Self {
84 Self::new(by_category)
85 }
86}
87
88#[derive(Clone, Copy)]
90enum Scope {
91 Parent,
92 Subagent,
93}
94
95pub struct CallModel {
105 pub algorithm: String,
108 pub request: Request,
110 pub models: Vec<ModelId>,
112 reply: oneshot::Sender<Result<Response>>,
114}
115
116impl CallModel {
117 pub fn respond(self, result: Result<Response>) -> Result<()> {
121 self.reply
122 .send(result)
123 .map_err(|_| DriverError::ResponseDropped.into())
124 }
125}
126
127pub struct RoutingOutcome {
129 pub selected_model_ids: Vec<ModelId>,
131 pub request: Request,
133 pub response: Option<Response>,
135 pub metadata: Option<crate::OutcomeMetadata>,
140}
141
142impl RoutingOutcome {
143 pub fn selected_model_id(&self) -> Result<&ModelId> {
146 self.selected_model_ids.first().ok_or(LibsyError::NoTargets)
147 }
148
149 pub fn route_to(
153 selected_model_id: ModelId,
154 fallback_models: Vec<ModelId>,
155 mut request: Request,
156 ) -> Self {
157 request.llm_request.model = Some(selected_model_id.to_string());
158 let mut selected_model_ids = Vec::with_capacity(1 + fallback_models.len());
159 selected_model_ids.push(selected_model_id);
160 selected_model_ids.extend(fallback_models);
161 Self {
162 selected_model_ids,
163 request,
164 response: None,
165 metadata: None,
166 }
167 }
168
169 pub fn answered(selected_model_id: ModelId, mut request: Request, response: Response) -> Self {
172 request.llm_request.model = Some(selected_model_id.to_string());
173 Self {
174 selected_model_ids: vec![selected_model_id],
175 request,
176 response: Some(response),
177 metadata: None,
178 }
179 }
180}
181
182#[derive(Clone)]
184pub struct Driver {
185 step_tx: mpsc::Sender<Result<Step>>,
186
187 algorithm: String,
189
190 evidence: Arc<Mutex<Option<Value>>>,
192
193 models: Arc<RuntimeModels>,
195
196 scope: Scope,
198}
199
200impl Driver {
201 pub(crate) fn new(
204 algorithm: &str,
205 models: Arc<RuntimeModels>,
206 ) -> (Self, mpsc::Receiver<Result<Step>>) {
207 let (step_tx, step_rx) = mpsc::channel(1);
212 (
213 Self {
214 step_tx,
215 algorithm: algorithm.to_string(),
216 evidence: Arc::new(Mutex::new(None)),
217 models,
218 scope: Scope::Parent,
219 },
220 step_rx,
221 )
222 }
223
224 pub(crate) fn set_evidence(&self, evidence: Value) {
226 *self.evidence.lock() = Some(evidence);
227 }
228
229 pub(crate) fn set_evidence_if_empty(&self, evidence: Value) {
231 let mut current = self.evidence.lock();
232 if current.is_none() {
233 *current = Some(evidence);
234 }
235 }
236
237 #[tracing::instrument(
246 target = "libsy",
247 name = "libsy.llm_call",
248 skip_all,
249 fields(
250 algorithm = self.algorithm,
251 selected_model = %models.first().map(ModelId::as_str).unwrap_or("NoTargets"),
252 openinference.span.kind = "CHAIN",
253 outcome = tracing::field::Empty,
254 input_tokens = tracing::field::Empty,
255 output_tokens = tracing::field::Empty,
256 total_tokens = tracing::field::Empty,
257 reasoning_tokens = tracing::field::Empty,
258 )
259 )]
260 pub async fn call_model(&self, mut request: Request, models: Vec<ModelId>) -> Result<Response> {
261 let Some(selected_model_id) = models.first().cloned() else {
262 return Err(LibsyError::NoTargets);
263 };
264 request.llm_request.model = Some(selected_model_id.to_string());
265 let started = Instant::now();
266 let (reply, response) = oneshot::channel::<Result<Response>>();
267 let call = CallModel {
268 algorithm: self.algorithm.clone(),
269 request,
270 models,
271 reply,
272 };
273 let result = async {
274 self.step_tx
275 .send(Ok(Step::CallModel(Box::new(call))))
276 .await
277 .map_err(|_| DriverError::StreamClosed)?;
278 response
279 .await
280 .map_err(|_| LibsyError::from(DriverError::ResponseDropped))?
281 }
282 .await;
283 let elapsed = started.elapsed();
284 observability::record_llm_call(
285 &self.algorithm,
286 selected_model_id.as_str(),
287 elapsed,
288 &result,
289 &tracing::Span::current(),
290 );
291 result
292 }
293
294 pub fn models_for(&self, category: &Category) -> &[ModelId] {
296 match self.scope {
297 Scope::Parent => self.models.models_for(category),
298 Scope::Subagent => self.models.subagent_models_for(category),
299 }
300 }
301
302 pub fn first_model_for(&self, category: &Category) -> Result<&ModelId> {
304 self.models_for(category)
305 .first()
306 .ok_or_else(|| LibsyError::AlgorithmError {
307 message: format!("no models available for category {}", category.as_str()),
308 })
309 }
310
311 pub fn for_subagent(&self) -> Result<Self> {
314 if self.models.subagent.is_none() {
315 return Err(LibsyError::AlgorithmError {
316 message: "delegated work has no sub-agent models".to_string(),
317 });
318 }
319 Ok(Self {
320 scope: Scope::Subagent,
321 ..self.clone()
322 })
323 }
324
325 pub(crate) async fn finish(&self, result: Result<RoutingOutcome>) -> Result<()> {
329 let result = result.map(|mut outcome| {
330 let metadata = outcome.metadata.get_or_insert_with(|| {
331 crate::OutcomeMetadata::new(self.algorithm.clone(), self.evidence.lock().take())
332 });
333 observability::record_outcome(metadata, &outcome.selected_model_ids);
334 outcome
335 });
336 let selected_model = result
337 .as_ref()
338 .ok()
339 .and_then(|outcome| outcome.selected_model_id().ok().cloned());
340 let step = result.map(|outcome| Step::Done(Box::new(outcome)));
341 self.step_tx
342 .send(step)
343 .await
344 .map_err(|_| DriverError::StreamClosed)?;
345 if let Some(selected_model) = selected_model {
346 observability::record_decision(&self.algorithm, &selected_model);
347 }
348 Ok(())
349 }
350}
351
352pub enum Step {
354 CallModel(Box<CallModel>),
357 Done(Box<RoutingOutcome>),
359}
360
361pub async fn drive<F, Fut>(
374 algorithm: Arc<dyn Algorithm>,
375 request: Request,
376 models: Arc<RuntimeModels>,
377 serve: F,
378) -> Result<RoutingOutcome>
379where
380 F: Fn(CallModel) -> Fut,
381 Fut: Future<Output = Result<()>>,
382{
383 let stream = algorithm.run_stream(request, models);
384 tokio::pin!(stream);
385
386 let mut in_flight = futures::stream::FuturesUnordered::new();
387 let mut final_outcome: Option<RoutingOutcome> = None;
388
389 loop {
390 tokio::select! {
391 Some(result) = in_flight.next() => match result {
392 Ok(()) => {}, Err(err) => return Err(err), },
395 step = stream.next() => {
396 match step {
397 None => break, Some(item) => match item? {
399 Step::CallModel(call) => in_flight.push(serve(*call)),
400 Step::Done(outcome) => {
401 final_outcome = Some(*outcome);
402 break;
403 }
404 }
405 }
406 },
407 }
408 }
409 final_outcome.ok_or(LibsyError::MissingFinalResponse)
410}
411
412fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
414 payload
415 .downcast_ref::<&'static str>()
416 .map(|message| (*message).to_string())
417 .or_else(|| payload.downcast_ref::<String>().cloned())
418 .unwrap_or_else(|| "unknown panic payload".to_string())
419}
420
421struct AbortOnDrop(tokio::task::AbortHandle);
423
424impl Drop for AbortOnDrop {
425 fn drop(&mut self) {
426 self.0.abort();
427 }
428}
429
430pub(crate) fn ensure_model_is_target(targets: &[ModelId], name: &ModelId) -> Result<()> {
435 targets
436 .iter()
437 .any(|target| target == name)
438 .then_some(())
439 .ok_or_else(|| LibsyError::TargetNotFound {
440 target: name.clone(),
441 })
442}
443
444#[derive(Clone, Hash, PartialEq, Eq)]
447pub(crate) enum RoutingIdentity {
448 Session(String),
450 Subagent { session: String, agent: String },
452}
453
454impl RoutingIdentity {
455 pub(crate) fn from_request(request: &Request) -> Option<Self> {
460 let metadata = request.metadata.as_ref()?;
461 let session = metadata.session_id.as_deref().filter(|id| !id.is_empty())?;
462 if metadata.is_subagent {
463 let agent = metadata.agent_id.as_deref().filter(|id| !id.is_empty())?;
464 Some(Self::Subagent {
465 session: session.to_string(),
466 agent: agent.to_string(),
467 })
468 } else {
469 Some(Self::Session(session.to_string()))
470 }
471 }
472}
473
474#[async_trait]
507pub trait Algorithm: Send + Sync + 'static {
508 fn name(&self) -> &str;
512
513 async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<RoutingOutcome>;
517
518 fn run_stream(self: Arc<Self>, request: Request, models: Arc<RuntimeModels>) -> StepStream {
527 let (driver, step_rx) = Driver::new(self.name(), models);
528 let span = observability::run_span(self.name(), &request);
529 let handle = tokio::spawn(
530 async move {
531 let algorithm = self.name().to_string();
532 let route = AssertUnwindSafe(self.route(driver.clone(), request)).catch_unwind();
534 let result = observability::observe_run(&algorithm, async move {
535 route.await.unwrap_or_else(|payload| {
536 Err(LibsyError::AlgorithmError {
537 message: format!(
538 "algorithm task panicked: {}",
539 panic_message(payload.as_ref())
540 ),
541 })
542 })
543 })
544 .await;
545
546 let _ = driver.finish(result).await;
547 }
548 .instrument(span),
549 );
550 let abort_guard = AbortOnDrop(handle.abort_handle());
552 Box::pin(ReceiverStream::new(step_rx).map(move |step| {
553 let _keep_alive = &abort_guard;
555 step
556 }))
557 }
558}
559
560#[cfg(test)]
561mod tests {
562 use std::collections::HashMap;
563
564 use super::*;
565 use crate::core::testing::{Serve, ServeResult, echo, reply, test_drive};
566 use futures::StreamExt;
567 use switchyard_protocol::{
568 LlmResponse, LlmResponseChunk, completion_text, text_request, text_response,
569 };
570
571 #[derive(Debug, thiserror::Error)]
572 #[error("{0}")]
573 struct TestError(&'static str);
574
575 fn test_error(message: &'static str) -> LibsyError {
576 LibsyError::external("test", TestError(message))
577 }
578
579 struct TestAlgo {
582 target_set: Vec<ModelId>,
583 }
584
585 #[async_trait]
586 impl Algorithm for TestAlgo {
587 fn name(&self) -> &str {
588 "test"
589 }
590
591 async fn route(
592 self: Arc<Self>,
593 driver: Driver,
594 request: Request,
595 ) -> Result<RoutingOutcome> {
596 let target = self
597 .target_set
598 .first()
599 .ok_or(LibsyError::NoTargets)?
600 .clone();
601 let response = driver
602 .call_model(request.clone(), vec![target.clone()])
603 .await?;
604 driver.set_evidence(serde_json::json!({"source": "test"}));
605 driver.set_evidence_if_empty(serde_json::json!({"source": "ignored"}));
606 Ok(RoutingOutcome::answered(target, request, response))
607 }
608 }
609
610 fn orch(target_set: Vec<ModelId>) -> Arc<dyn Algorithm> {
612 Arc::new(TestAlgo { target_set })
613 }
614
615 fn request() -> Request {
616 Request {
617 llm_request: text_request(Some("auto".to_string()), "hi".to_string()),
618 raw_request: None,
619 metadata: None,
620 }
621 }
622
623 #[test]
624 fn routing_outcome_constructors_stamp_selection_and_preserve_payloads() {
625 let outcome = RoutingOutcome::route_to(
626 "selected".into(),
627 target_set(&["fallback-one", "fallback-two"]),
628 request(),
629 );
630
631 assert_eq!(
632 outcome.selected_model_ids,
633 target_set(&["selected", "fallback-one", "fallback-two"])
634 );
635 assert_eq!(outcome.request.model_id().as_deref(), Some("selected"));
636 assert!(outcome.response.is_none());
637 assert!(outcome.metadata.is_none());
638
639 let outcome = RoutingOutcome::route_to("only".into(), Vec::new(), request());
640 assert_eq!(outcome.selected_model_ids, target_set(&["only"]));
641
642 let outcome = RoutingOutcome::answered(
643 "answered".into(),
644 request(),
645 Response {
646 llm_response: LlmResponse::Agg(text_response(None, "existing")),
647 metadata: None,
648 upstream_headers: http::HeaderMap::new(),
649 },
650 );
651
652 assert_eq!(outcome.selected_model_ids, target_set(&["answered"]));
653 assert_eq!(outcome.request.model_id().as_deref(), Some("answered"));
654 assert_eq!(
655 outcome
656 .response
657 .as_ref()
658 .and_then(|response| response.llm_response.as_agg())
659 .map(completion_text),
660 Some("existing".to_string())
661 );
662 }
663
664 fn target_set(names: &[&str]) -> Vec<ModelId> {
665 names.iter().map(|name| ModelId::from(*name)).collect()
666 }
667
668 #[tokio::test]
669 async fn typed_driver_preserves_call_and_stream_boundaries() -> Result<()> {
670 tokio::time::timeout(std::time::Duration::from_secs(1), async {
671 let (driver, mut step_rx) = Driver::new("test", Arc::new(RuntimeModels::default()));
674 let first_driver = driver.clone();
675 let mut first = tokio::spawn(async move {
676 first_driver
677 .call_model(request(), vec![ModelId::from("first")])
678 .await
679 });
680 let second = tokio::spawn(async move {
681 driver
682 .call_model(request(), vec![ModelId::from("second")])
683 .await
684 });
685
686 let mut calls = HashMap::new();
687 for _ in 0..2 {
688 let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
689 let Step::CallModel(call) = step else {
690 return Err(test_error("expected a CallModel step"));
691 };
692 let selected_model = call
693 .models
694 .first()
695 .ok_or_else(|| test_error("model call has no candidates"))?
696 .to_string();
697 calls.insert(selected_model, call);
698 }
699 assert!(
700 tokio::time::timeout(std::time::Duration::from_millis(20), &mut first)
701 .await
702 .is_err(),
703 "call completed before the host responded"
704 );
705 calls
706 .remove("second")
707 .ok_or_else(|| test_error("missing second call"))?
708 .respond(Ok(reply("second response")))?;
709 calls
710 .remove("first")
711 .ok_or_else(|| test_error("missing first call"))?
712 .respond(Ok(reply("first response")))?;
713
714 let first_response = first
715 .await
716 .map_err(|source| LibsyError::external("joining a test task", source))??;
717 let second_response = second
718 .await
719 .map_err(|source| LibsyError::external("joining a test task", source))??;
720 assert_eq!(
721 first_response.llm_response.as_agg().map(completion_text),
722 Some("first response".to_string())
723 );
724 assert_eq!(
725 second_response.llm_response.as_agg().map(completion_text),
726 Some("second response".to_string())
727 );
728
729 let (driver, mut step_rx) = Driver::new("test", Arc::new(RuntimeModels::default()));
731 let producer = tokio::spawn(async move {
732 driver
733 .call_model(request(), vec![ModelId::from("dropped")])
734 .await
735 });
736 let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
737 let Step::CallModel(call) = step else {
738 return Err(test_error("expected a CallModel step"));
739 };
740 drop(call);
741 let result = producer
742 .await
743 .map_err(|source| LibsyError::external("joining a test task", source))?;
744 assert!(matches!(
745 result,
746 Err(LibsyError::Driver(DriverError::ResponseDropped))
747 ));
748
749 let (driver, step_rx) = Driver::new("test", Arc::new(RuntimeModels::default()));
751 drop(step_rx);
752 let result = driver
753 .call_model(request(), vec![ModelId::from("closed")])
754 .await;
755 assert!(matches!(
756 result,
757 Err(LibsyError::Driver(DriverError::StreamClosed))
758 ));
759 Ok(())
760 })
761 .await
762 .map_err(|error| LibsyError::external("waiting for typed driver boundaries", error))?
763 }
764
765 #[test]
766 fn target_lookup_returns_the_missing_target() {
767 let error = ensure_model_is_target(&target_set(&[]), &ModelId::from("missing")).err();
768 assert!(matches!(
769 error,
770 Some(LibsyError::TargetNotFound { target }) if target == "missing"
771 ));
772 }
773
774 fn streaming_orch(chunks: Vec<LlmResponseChunk>) -> (Arc<dyn Algorithm>, impl Serve) {
777 let algo = orch(target_set(&["stream/model"]));
778 let serve = move |_target: ModelId, _request: Request| {
779 let chunks = chunks.clone();
780 async move {
781 let stream =
782 futures::stream::iter(chunks.into_iter().map(|chunk| Ok(chunk.into()))).boxed();
783 Ok(Response {
784 llm_response: LlmResponse::Stream(stream),
785 metadata: None,
786 upstream_headers: http::HeaderMap::new(),
787 })
788 }
789 };
790 (algo, serve)
791 }
792
793 #[tokio::test]
794 async fn run_returns_a_streamed_response_the_caller_aggregates() -> Result<()> {
795 let (orch, serve) = streaming_orch(vec![
798 LlmResponseChunk::MessageStart {
799 id: Some("m1".to_string()),
800 model: Some("stream/model".to_string()),
801 },
802 LlmResponseChunk::TextDelta {
803 index: 0,
804 text: "hel".to_string(),
805 },
806 LlmResponseChunk::TextDelta {
807 index: 0,
808 text: "lo".to_string(),
809 },
810 LlmResponseChunk::MessageStop {
811 reason: Some("stop".to_string()),
812 },
813 ]);
814 let (selected_model, response) = test_drive(orch, request(), serve).await?;
815 let agg = response
817 .llm_response
818 .into_agg()
819 .await
820 .map_err(|error| LibsyError::external("aggregating response stream", error))?;
821 assert_eq!(completion_text(&agg), "hello");
822 assert_eq!(agg.model.as_deref(), Some("stream/model"));
823 assert_eq!(selected_model, "stream/model");
824 Ok(())
825 }
826
827 #[tokio::test]
828 async fn aggregating_a_streamed_response_propagates_a_mid_stream_error() -> Result<()> {
829 let (orch, serve) = streaming_orch(vec![
832 LlmResponseChunk::TextDelta {
833 index: 0,
834 text: "partial".to_string(),
835 },
836 LlmResponseChunk::StreamError {
837 message: "upstream exploded".to_string(),
838 },
839 ]);
840 let (_, response) = test_drive(orch, request(), serve).await?;
841 match response.llm_response.into_agg().await {
842 Ok(_) => panic!("expected a mid-stream error, got an aggregate"),
843 Err(err) => {
844 assert!(err.to_string().contains("upstream exploded"));
845 Ok(())
846 }
847 }
848 }
849
850 #[tokio::test]
851 async fn run_offloads_via_promise_then_finishes() -> Result<()> {
852 let stream = orch(target_set(&["offload/model"]))
855 .run_stream(request(), Arc::new(RuntimeModels::default()));
856 tokio::pin!(stream);
857
858 let mut saw_call = false;
859 let mut final_completion = None;
860 while let Some(step) = stream.next().await {
861 match step? {
862 Step::CallModel(call) => {
863 saw_call = true;
864 assert_eq!(call.models, vec![ModelId::from("offload/model")]);
865 call.respond(Ok(Response {
867 llm_response: LlmResponse::Agg(text_response(
868 None,
869 "fulfilled".to_string(),
870 )),
871 metadata: None,
872 upstream_headers: http::HeaderMap::new(),
873 }))?;
874 }
875 Step::Done(outcome) => {
876 let metadata = outcome
877 .metadata
878 .as_ref()
879 .expect("run_stream should attach outcome metadata");
880 assert_eq!(metadata.algorithm, "test");
881 assert_eq!(
882 uuid::Uuid::parse_str(metadata.outcome_id())
883 .expect("outcome id should be a UUID")
884 .get_version_num(),
885 7
886 );
887 assert_eq!(
888 metadata.evidence,
889 Some(serde_json::json!({"source": "test"}))
890 );
891 let response = outcome
892 .response
893 .ok_or_else(|| test_error("expected an answered outcome"))?;
894 final_completion = Some(
895 response
896 .llm_response
897 .as_agg()
898 .map(completion_text)
899 .unwrap_or_default(),
900 );
901 }
902 }
903 }
904
905 assert!(saw_call, "expected a CallModel step before Done");
906 assert_eq!(
907 final_completion.ok_or_else(|| test_error("no Done step"))?,
908 "fulfilled"
909 );
910 Ok(())
911 }
912
913 #[tokio::test(flavor = "multi_thread", worker_threads = 12)]
914 async fn requests_are_processed_in_parallel() -> Result<()> {
915 use std::time::Duration;
916 use tokio::sync::Barrier;
917
918 const N: usize = 12;
919
920 let barrier = Arc::new(Barrier::new(N));
925 let algo = orch(target_set(&["m"]));
927
928 let mut handles = Vec::new();
929 for _ in 0..N {
930 let algo = algo.clone();
931 let barrier = barrier.clone();
932 let serve = move |target: ModelId, _request: Request| {
933 let barrier = barrier.clone();
934 async move {
935 barrier.wait().await;
936 Ok(reply(target))
937 }
938 };
939 handles.push(tokio::spawn(async move {
940 test_drive(algo, request(), serve)
941 .await
942 .map(|(_, response)| {
943 response
944 .llm_response
945 .as_agg()
946 .map(completion_text)
947 .unwrap_or_default()
948 })
949 }));
950 }
951
952 for handle in handles {
953 let completion = tokio::time::timeout(Duration::from_secs(5), handle)
955 .await
956 .map_err(|error| LibsyError::external("waiting for test task", error))?
957 .map_err(|source| LibsyError::external("joining a test task", source))??;
958 assert_eq!(completion, "m");
959 }
960 Ok(())
961 }
962
963 #[tokio::test]
964 async fn offload_error_propagates_back_to_the_algorithm() -> Result<()> {
965 let stream = orch(target_set(&["offload/model"]))
969 .run_stream(request(), Arc::new(RuntimeModels::default()));
970 tokio::pin!(stream);
971
972 let mut saw_error = false;
973 while let Some(step) = stream.next().await {
974 match step {
975 Ok(Step::CallModel(call)) => {
976 call.respond(Err(test_error("upstream model call failed")))?;
977 }
978 Ok(Step::Done(..)) => {
979 return Err(test_error(
980 "expected the offload error to propagate, got a response",
981 ));
982 }
983 Err(err) => {
984 assert!(err.to_string().contains("upstream model call failed"));
986 saw_error = true;
987 }
988 }
989 }
990
991 assert!(saw_error, "expected an error step");
992 Ok(())
993 }
994
995 #[tokio::test]
996 async fn dropping_the_stream_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;
1030 unreachable!()
1031 }
1032 }
1033
1034 let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1035 let dropped = Arc::new(AtomicBool::new(false));
1036 let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1037 started: started_tx,
1038 dropped: dropped.clone(),
1039 });
1040
1041 let stream = algo.run_stream(request(), Arc::new(RuntimeModels::default()));
1042 started_rx
1043 .recv()
1044 .await
1045 .ok_or_else(|| test_error("task never started"))?;
1046 drop(stream);
1047 tokio::time::sleep(Duration::from_millis(100)).await;
1048
1049 assert!(
1050 dropped.load(Ordering::SeqCst),
1051 "algorithm task was NOT cancelled after dropping the stream"
1052 );
1053 Ok(())
1054 }
1055
1056 #[tokio::test]
1057 async fn route_panic_surfaces_as_a_stream_error() -> Result<()> {
1058 struct Panicky;
1061
1062 #[async_trait]
1063 impl Algorithm for Panicky {
1064 fn name(&self) -> &str {
1065 "panicky"
1066 }
1067
1068 async fn route(
1069 self: Arc<Self>,
1070 _driver: Driver,
1071 _request: Request,
1072 ) -> Result<RoutingOutcome> {
1073 panic!("boom");
1074 }
1075 }
1076
1077 let algo: Arc<dyn Algorithm> = Arc::new(Panicky);
1078 let stream = algo.run_stream(request(), Arc::new(RuntimeModels::default()));
1079 tokio::pin!(stream);
1080
1081 let mut saw_error = false;
1082 while let Some(step) = stream.next().await {
1083 match step {
1084 Err(err) => {
1085 assert!(err.to_string().contains("algorithm task panicked: boom"));
1087 saw_error = true;
1088 }
1089 Ok(_) => return Err(test_error("expected the panic to surface as an error step")),
1090 }
1091 }
1092
1093 assert!(saw_error, "expected an error step from the panicked task");
1094 Ok(())
1095 }
1096
1097 #[tokio::test]
1101 async fn a_panic_with_a_leaked_driver_clone_still_terminates_the_run() -> Result<()> {
1102 struct LeakyPanic;
1103
1104 #[async_trait]
1105 impl Algorithm for LeakyPanic {
1106 fn name(&self) -> &str {
1107 "leaky_panic"
1108 }
1109
1110 async fn route(
1111 self: Arc<Self>,
1112 driver: Driver,
1113 _request: Request,
1114 ) -> Result<RoutingOutcome> {
1115 tokio::spawn(async move {
1116 let _keep_alive = driver;
1118 std::future::pending::<()>().await;
1119 });
1120 tokio::task::yield_now().await;
1121 panic!("boom");
1122 }
1123 }
1124
1125 let algo: Arc<dyn Algorithm> = Arc::new(LeakyPanic);
1126 let result = tokio::time::timeout(
1128 std::time::Duration::from_secs(1),
1129 test_drive(algo, request(), echo()),
1130 )
1131 .await
1132 .map_err(|error| LibsyError::external("waiting for the panicked run to end", error))?;
1133
1134 match result {
1135 Ok(_) => Err(test_error(
1136 "expected the panic to end the run with an error",
1137 )),
1138 Err(err) => {
1139 assert!(err.to_string().contains("algorithm task panicked: boom"));
1140 Ok(())
1141 }
1142 }
1143 }
1144
1145 #[tokio::test]
1146 async fn cancelling_run_cancels_the_algorithm_task() -> Result<()> {
1147 use std::sync::atomic::{AtomicBool, Ordering};
1148 use std::time::Duration;
1149 use tokio::sync::mpsc;
1150
1151 struct DropGuard(Arc<AtomicBool>);
1154 impl Drop for DropGuard {
1155 fn drop(&mut self) {
1156 self.0.store(true, Ordering::SeqCst);
1157 }
1158 }
1159
1160 struct StuckAlgo {
1161 started: mpsc::UnboundedSender<()>,
1162 dropped: Arc<AtomicBool>,
1163 }
1164
1165 #[async_trait]
1166 impl Algorithm for StuckAlgo {
1167 fn name(&self) -> &str {
1168 "stuck"
1169 }
1170
1171 async fn route(
1172 self: Arc<Self>,
1173 _driver: Driver,
1174 _request: Request,
1175 ) -> Result<RoutingOutcome> {
1176 let _guard = DropGuard(self.dropped.clone());
1177 let _ = self.started.send(());
1178 std::future::pending::<()>().await;
1181 unreachable!()
1182 }
1183 }
1184
1185 let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1186 let dropped = Arc::new(AtomicBool::new(false));
1187 let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1188 started: started_tx,
1189 dropped: dropped.clone(),
1190 });
1191
1192 let run_task = tokio::spawn(async move { test_drive(algo, request(), echo()).await });
1195 started_rx
1196 .recv()
1197 .await
1198 .ok_or_else(|| test_error("task never started"))?;
1199 run_task.abort();
1200 tokio::time::sleep(Duration::from_millis(100)).await;
1201
1202 assert!(
1203 dropped.load(Ordering::SeqCst),
1204 "algorithm task was NOT cancelled after cancelling run"
1205 );
1206 Ok(())
1207 }
1208
1209 struct Hedge {
1214 winner: String,
1215 loser: String,
1216 }
1217
1218 #[async_trait]
1219 impl Algorithm for Hedge {
1220 fn name(&self) -> &str {
1221 "hedge"
1222 }
1223
1224 async fn route(
1225 self: Arc<Self>,
1226 driver: Driver,
1227 request: Request,
1228 ) -> Result<RoutingOutcome> {
1229 let outcome_request = request.clone();
1230 let win = driver.call_model(request.clone(), vec![self.winner.clone().into()]);
1231 let lose = driver.call_model(request, vec![self.loser.clone().into()]);
1232 tokio::select! {
1234 res = win => Ok(RoutingOutcome::answered(
1235 self.winner.clone().into(),
1236 outcome_request,
1237 res?,
1238 )),
1239 res = lose => Ok(RoutingOutcome::answered(
1240 self.loser.clone().into(),
1241 outcome_request,
1242 res?,
1243 )),
1244 }
1245 }
1246 }
1247
1248 fn hedge(loser_delay: Option<std::time::Duration>) -> (Arc<dyn Algorithm>, impl Serve) {
1252 let started = Arc::new(tokio::sync::Notify::new());
1253 let algo = Arc::new(Hedge {
1254 winner: "winner".to_string(),
1255 loser: "loser".to_string(),
1256 });
1257 let serve = move |target: ModelId, _request: Request| {
1258 let started = started.clone();
1259 async move {
1260 if target == "loser" {
1261 started.notify_one();
1262 match loser_delay {
1263 Some(delay) => tokio::time::sleep(delay).await,
1264 None => std::future::pending::<()>().await,
1265 }
1266 } else {
1267 started.notified().await;
1268 }
1269 Ok(reply(target))
1270 }
1271 };
1272 (algo, serve)
1273 }
1274
1275 #[tokio::test]
1276 async fn run_returns_the_winner_without_a_late_loser_overwriting_it() -> Result<()> {
1277 let (algo, serve) = hedge(Some(std::time::Duration::from_millis(50)));
1280 let (_, response) = test_drive(algo, request(), serve).await?;
1281 assert_eq!(
1282 response
1283 .llm_response
1284 .as_agg()
1285 .map(completion_text)
1286 .unwrap_or_default(),
1287 "winner"
1288 );
1289 Ok(())
1290 }
1291
1292 #[tokio::test]
1293 async fn run_returns_the_winner_without_hanging_on_a_pending_loser() -> Result<()> {
1294 let (algo, serve) = hedge(None);
1297 let run = test_drive(algo, request(), serve);
1298 let (_, response) = tokio::time::timeout(std::time::Duration::from_secs(1), run)
1299 .await
1300 .map_err(|error| LibsyError::external("waiting for pending loser", error))??;
1301 assert_eq!(
1302 response
1303 .llm_response
1304 .as_agg()
1305 .map(completion_text)
1306 .unwrap_or_default(),
1307 "winner"
1308 );
1309 Ok(())
1310 }
1311
1312 #[tokio::test]
1313 async fn run_surfaces_a_terminal_error_with_many_calls_in_flight() -> Result<()> {
1314 use std::sync::atomic::{AtomicUsize, Ordering};
1315
1316 const N: usize = 10;
1319
1320 struct FanOutThenError {
1323 all_started: Arc<tokio::sync::Notify>,
1324 n: usize,
1325 }
1326
1327 #[async_trait]
1328 impl Algorithm for FanOutThenError {
1329 fn name(&self) -> &str {
1330 "fan_out_then_error"
1331 }
1332
1333 async fn route(
1334 self: Arc<Self>,
1335 driver: Driver,
1336 request: Request,
1337 ) -> Result<RoutingOutcome> {
1338 let offloads = futures::future::join_all(
1339 (0..self.n)
1340 .map(|i| driver.call_model(request.clone(), vec![format!("m{i}").into()])),
1341 );
1342 tokio::select! {
1343 _ = offloads => Err(test_error("offloads unexpectedly completed")),
1344 _ = self.all_started.notified() => {
1345 Err(test_error("terminal error while calls pending"))
1346 }
1347 }
1348 }
1349 }
1350
1351 let all_started = Arc::new(tokio::sync::Notify::new());
1352 let algo: Arc<dyn Algorithm> = Arc::new(FanOutThenError {
1353 all_started: all_started.clone(),
1354 n: N,
1355 });
1356
1357 let started = Arc::new(AtomicUsize::new(0));
1359 let serve = move |_target: ModelId, _request: Request| {
1360 let started = started.clone();
1361 let all_started = all_started.clone();
1362 async move {
1363 if started.fetch_add(1, Ordering::SeqCst) + 1 == N {
1364 all_started.notify_one();
1365 }
1366 std::future::pending::<ServeResult>().await
1367 }
1368 };
1369
1370 let run = test_drive(algo, request(), serve);
1373 let result = tokio::time::timeout(std::time::Duration::from_millis(500), run)
1374 .await
1375 .map_err(|error| {
1376 LibsyError::external("waiting for terminal error with full call cap", error)
1377 })?;
1378 match result {
1379 Ok(_) => Err(test_error("expected the terminal error, got a response")),
1380 Err(err) => {
1381 assert!(
1382 err.to_string()
1383 .contains("terminal error while calls pending")
1384 );
1385 Ok(())
1386 }
1387 }
1388 }
1389}