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 category: Option<Category>,
111 pub request: Request,
113 pub models: Vec<ModelId>,
115 reply: oneshot::Sender<Result<Response>>,
117}
118
119impl CallModel {
120 pub fn respond(self, result: Result<Response>) -> Result<()> {
124 self.reply
125 .send(result)
126 .map_err(|_| DriverError::ResponseDropped.into())
127 }
128}
129
130pub struct RoutingOutcome {
132 pub selected_model_ids: Vec<ModelId>,
134 pub request: Request,
136 pub response: Option<Response>,
138 pub metadata: Option<crate::OutcomeMetadata>,
143}
144
145impl RoutingOutcome {
146 pub fn selected_model_id(&self) -> Result<&ModelId> {
149 self.selected_model_ids.first().ok_or(LibsyError::NoTargets)
150 }
151
152 pub fn route_to(
156 selected_model_id: ModelId,
157 fallback_models: Vec<ModelId>,
158 mut request: Request,
159 ) -> Self {
160 request.llm_request.model = Some(selected_model_id.to_string());
161 let mut selected_model_ids = Vec::with_capacity(1 + fallback_models.len());
162 selected_model_ids.push(selected_model_id);
163 selected_model_ids.extend(fallback_models);
164 Self {
165 selected_model_ids,
166 request,
167 response: None,
168 metadata: None,
169 }
170 }
171
172 pub fn answered(selected_model_id: ModelId, mut request: Request, response: Response) -> Self {
175 request.llm_request.model = Some(selected_model_id.to_string());
176 Self {
177 selected_model_ids: vec![selected_model_id],
178 request,
179 response: Some(response),
180 metadata: None,
181 }
182 }
183}
184
185#[derive(Clone)]
187pub struct Driver {
188 step_tx: mpsc::Sender<Result<Step>>,
189
190 algorithm: String,
192
193 evidence: Arc<Mutex<Option<Value>>>,
195
196 models: Arc<RuntimeModels>,
198
199 scope: Scope,
201}
202
203impl Driver {
204 pub(crate) fn new(
207 algorithm: &str,
208 models: Arc<RuntimeModels>,
209 ) -> (Self, mpsc::Receiver<Result<Step>>) {
210 let (step_tx, step_rx) = mpsc::channel(1);
215 (
216 Self {
217 step_tx,
218 algorithm: algorithm.to_string(),
219 evidence: Arc::new(Mutex::new(None)),
220 models,
221 scope: Scope::Parent,
222 },
223 step_rx,
224 )
225 }
226
227 pub(crate) fn set_evidence(&self, evidence: Value) {
229 *self.evidence.lock() = Some(evidence);
230 }
231
232 pub(crate) fn set_evidence_if_empty(&self, evidence: Value) {
234 let mut current = self.evidence.lock();
235 if current.is_none() {
236 *current = Some(evidence);
237 }
238 }
239
240 pub async fn call_model(&self, request: Request, models: Vec<ModelId>) -> Result<Response> {
249 self.publish_call(request, models, None).await
250 }
251
252 pub async fn call_model_for_category(
255 &self,
256 request: Request,
257 category: Category,
258 ) -> Result<Response> {
259 let models = self.models_for(&category).to_vec();
260 self.publish_call(request, models, Some(category)).await
261 }
262
263 #[tracing::instrument(
264 target = "libsy",
265 name = "libsy.llm_call",
266 skip_all,
267 fields(
268 algorithm = self.algorithm,
269 selected_model = %models.first().map(ModelId::as_str).unwrap_or("NoTargets"),
270 openinference.span.kind = "CHAIN",
271 outcome = tracing::field::Empty,
272 input_tokens = tracing::field::Empty,
273 output_tokens = tracing::field::Empty,
274 total_tokens = tracing::field::Empty,
275 reasoning_tokens = tracing::field::Empty,
276 )
277 )]
278 async fn publish_call(
279 &self,
280 mut request: Request,
281 models: Vec<ModelId>,
282 category: Option<Category>,
283 ) -> Result<Response> {
284 let Some(selected_model_id) = models.first().cloned() else {
285 return Err(LibsyError::NoTargets);
286 };
287 request.llm_request.model = Some(selected_model_id.to_string());
288 let started = Instant::now();
289 let (reply, response) = oneshot::channel::<Result<Response>>();
290 let call = CallModel {
291 algorithm: self.algorithm.clone(),
292 category,
293 request,
294 models,
295 reply,
296 };
297 let result = async {
298 self.step_tx
299 .send(Ok(Step::CallModel(Box::new(call))))
300 .await
301 .map_err(|_| DriverError::StreamClosed)?;
302 response
303 .await
304 .map_err(|_| LibsyError::from(DriverError::ResponseDropped))?
305 }
306 .await;
307 let elapsed = started.elapsed();
308 observability::record_llm_call(
309 &self.algorithm,
310 selected_model_id.as_str(),
311 elapsed,
312 &result,
313 &tracing::Span::current(),
314 );
315 result
316 }
317
318 pub fn models_for(&self, category: &Category) -> &[ModelId] {
320 match self.scope {
321 Scope::Parent => self.models.models_for(category),
322 Scope::Subagent => self.models.subagent_models_for(category),
323 }
324 }
325
326 pub fn first_model_for(&self, category: &Category) -> Result<&ModelId> {
328 self.models_for(category)
329 .first()
330 .ok_or_else(|| LibsyError::AlgorithmError {
331 message: format!("no models available for category {}", category.as_str()),
332 })
333 }
334
335 pub fn for_subagent(&self) -> Result<Self> {
338 if self.models.subagent.is_none() {
339 return Err(LibsyError::AlgorithmError {
340 message: "delegated work has no sub-agent models".to_string(),
341 });
342 }
343 Ok(Self {
344 scope: Scope::Subagent,
345 ..self.clone()
346 })
347 }
348
349 pub(crate) async fn finish(&self, result: Result<RoutingOutcome>) -> Result<()> {
353 let result = result.map(|mut outcome| {
354 let metadata = outcome.metadata.get_or_insert_with(|| {
355 crate::OutcomeMetadata::new(self.algorithm.clone(), self.evidence.lock().take())
356 });
357 observability::record_outcome(metadata, &outcome.selected_model_ids);
358 outcome
359 });
360 let selected_model = result
361 .as_ref()
362 .ok()
363 .and_then(|outcome| outcome.selected_model_id().ok().cloned());
364 let step = result.map(|outcome| Step::Done(Box::new(outcome)));
365 self.step_tx
366 .send(step)
367 .await
368 .map_err(|_| DriverError::StreamClosed)?;
369 if let Some(selected_model) = selected_model {
370 observability::record_decision(&self.algorithm, &selected_model);
371 }
372 Ok(())
373 }
374}
375
376pub enum Step {
378 CallModel(Box<CallModel>),
381 Done(Box<RoutingOutcome>),
383}
384
385pub async fn drive<F, Fut>(
398 algorithm: Arc<dyn Algorithm>,
399 request: Request,
400 models: Arc<RuntimeModels>,
401 serve: F,
402) -> Result<RoutingOutcome>
403where
404 F: Fn(CallModel) -> Fut,
405 Fut: Future<Output = Result<()>>,
406{
407 let stream = algorithm.run_stream(request, models);
408 tokio::pin!(stream);
409
410 let mut in_flight = futures::stream::FuturesUnordered::new();
411 let mut final_outcome: Option<RoutingOutcome> = None;
412
413 loop {
414 tokio::select! {
415 Some(result) = in_flight.next() => match result {
416 Ok(()) => {}, Err(err) => return Err(err), },
419 step = stream.next() => {
420 match step {
421 None => break, Some(item) => match item? {
423 Step::CallModel(call) => in_flight.push(serve(*call)),
424 Step::Done(outcome) => {
425 final_outcome = Some(*outcome);
426 break;
427 }
428 }
429 }
430 },
431 }
432 }
433 final_outcome.ok_or(LibsyError::MissingFinalResponse)
434}
435
436fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
438 payload
439 .downcast_ref::<&'static str>()
440 .map(|message| (*message).to_string())
441 .or_else(|| payload.downcast_ref::<String>().cloned())
442 .unwrap_or_else(|| "unknown panic payload".to_string())
443}
444
445struct AbortOnDrop(tokio::task::AbortHandle);
447
448impl Drop for AbortOnDrop {
449 fn drop(&mut self) {
450 self.0.abort();
451 }
452}
453
454pub(crate) fn ensure_model_is_target(targets: &[ModelId], name: &ModelId) -> Result<()> {
459 targets
460 .iter()
461 .any(|target| target == name)
462 .then_some(())
463 .ok_or_else(|| LibsyError::TargetNotFound {
464 target: name.clone(),
465 })
466}
467
468#[derive(Clone, Hash, PartialEq, Eq)]
471pub(crate) enum RoutingIdentity {
472 Session(String),
474 Subagent { session: String, agent: String },
476}
477
478impl RoutingIdentity {
479 pub(crate) fn from_request(request: &Request) -> Option<Self> {
484 let metadata = request.metadata.as_ref()?;
485 let session = metadata.session_id.as_deref().filter(|id| !id.is_empty())?;
486 if metadata.is_subagent {
487 let agent = metadata.agent_id.as_deref().filter(|id| !id.is_empty())?;
488 Some(Self::Subagent {
489 session: session.to_string(),
490 agent: agent.to_string(),
491 })
492 } else {
493 Some(Self::Session(session.to_string()))
494 }
495 }
496}
497
498#[async_trait]
531pub trait Algorithm: Send + Sync + 'static {
532 fn name(&self) -> &str;
536
537 async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<RoutingOutcome>;
541
542 fn run_stream(self: Arc<Self>, request: Request, models: Arc<RuntimeModels>) -> StepStream {
551 let (driver, step_rx) = Driver::new(self.name(), models);
552 let span = observability::run_span(self.name(), &request);
553 let handle = tokio::spawn(
554 async move {
555 let algorithm = self.name().to_string();
556 let route = AssertUnwindSafe(self.route(driver.clone(), request)).catch_unwind();
558 let result = observability::observe_run(&algorithm, async move {
559 route.await.unwrap_or_else(|payload| {
560 Err(LibsyError::AlgorithmError {
561 message: format!(
562 "algorithm task panicked: {}",
563 panic_message(payload.as_ref())
564 ),
565 })
566 })
567 })
568 .await;
569
570 let _ = driver.finish(result).await;
571 }
572 .instrument(span),
573 );
574 let abort_guard = AbortOnDrop(handle.abort_handle());
576 Box::pin(ReceiverStream::new(step_rx).map(move |step| {
577 let _keep_alive = &abort_guard;
579 step
580 }))
581 }
582}
583
584#[cfg(test)]
585mod tests {
586 use std::collections::HashMap;
587
588 use super::*;
589 use crate::core::testing::{Serve, ServeResult, echo, reply, test_drive};
590 use futures::StreamExt;
591 use switchyard_protocol::{
592 LlmResponse, LlmResponseChunk, completion_text, text_request, text_response,
593 };
594
595 #[derive(Debug, thiserror::Error)]
596 #[error("{0}")]
597 struct TestError(&'static str);
598
599 fn test_error(message: &'static str) -> LibsyError {
600 LibsyError::external("test", TestError(message))
601 }
602
603 struct TestAlgo {
606 target_set: Vec<ModelId>,
607 }
608
609 #[async_trait]
610 impl Algorithm for TestAlgo {
611 fn name(&self) -> &str {
612 "test"
613 }
614
615 async fn route(
616 self: Arc<Self>,
617 driver: Driver,
618 request: Request,
619 ) -> Result<RoutingOutcome> {
620 let target = self
621 .target_set
622 .first()
623 .ok_or(LibsyError::NoTargets)?
624 .clone();
625 let response = driver
626 .call_model(request.clone(), vec![target.clone()])
627 .await?;
628 driver.set_evidence(serde_json::json!({"source": "test"}));
629 driver.set_evidence_if_empty(serde_json::json!({"source": "ignored"}));
630 Ok(RoutingOutcome::answered(target, request, response))
631 }
632 }
633
634 fn orch(target_set: Vec<ModelId>) -> Arc<dyn Algorithm> {
636 Arc::new(TestAlgo { target_set })
637 }
638
639 fn request() -> Request {
640 Request {
641 llm_request: text_request(Some("auto".to_string()), "hi".to_string()),
642 raw_request: None,
643 metadata: None,
644 }
645 }
646
647 #[test]
648 fn routing_outcome_constructors_stamp_selection_and_preserve_payloads() {
649 let outcome = RoutingOutcome::route_to(
650 "selected".into(),
651 target_set(&["fallback-one", "fallback-two"]),
652 request(),
653 );
654
655 assert_eq!(
656 outcome.selected_model_ids,
657 target_set(&["selected", "fallback-one", "fallback-two"])
658 );
659 assert_eq!(outcome.request.model_id().as_deref(), Some("selected"));
660 assert!(outcome.response.is_none());
661 assert!(outcome.metadata.is_none());
662
663 let outcome = RoutingOutcome::route_to("only".into(), Vec::new(), request());
664 assert_eq!(outcome.selected_model_ids, target_set(&["only"]));
665
666 let outcome = RoutingOutcome::answered(
667 "answered".into(),
668 request(),
669 Response {
670 llm_response: LlmResponse::Agg(text_response(None, "existing")),
671 metadata: None,
672 upstream_headers: http::HeaderMap::new(),
673 },
674 );
675
676 assert_eq!(outcome.selected_model_ids, target_set(&["answered"]));
677 assert_eq!(outcome.request.model_id().as_deref(), Some("answered"));
678 assert_eq!(
679 outcome
680 .response
681 .as_ref()
682 .and_then(|response| response.llm_response.as_agg())
683 .map(completion_text),
684 Some("existing".to_string())
685 );
686 }
687
688 fn target_set(names: &[&str]) -> Vec<ModelId> {
689 names.iter().map(|name| ModelId::from(*name)).collect()
690 }
691
692 #[tokio::test]
693 async fn typed_driver_preserves_call_and_stream_boundaries() -> Result<()> {
694 tokio::time::timeout(std::time::Duration::from_secs(1), async {
695 let (driver, mut step_rx) = Driver::new("test", Arc::new(RuntimeModels::default()));
698 let first_driver = driver.clone();
699 let mut first = tokio::spawn(async move {
700 first_driver
701 .call_model(request(), vec![ModelId::from("first")])
702 .await
703 });
704 let second = tokio::spawn(async move {
705 driver
706 .call_model(request(), vec![ModelId::from("second")])
707 .await
708 });
709
710 let mut calls = HashMap::new();
711 for _ in 0..2 {
712 let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
713 let Step::CallModel(call) = step else {
714 return Err(test_error("expected a CallModel step"));
715 };
716 let selected_model = call
717 .models
718 .first()
719 .ok_or_else(|| test_error("model call has no candidates"))?
720 .to_string();
721 calls.insert(selected_model, call);
722 }
723 assert!(
724 tokio::time::timeout(std::time::Duration::from_millis(20), &mut first)
725 .await
726 .is_err(),
727 "call completed before the host responded"
728 );
729 calls
730 .remove("second")
731 .ok_or_else(|| test_error("missing second call"))?
732 .respond(Ok(reply("second response")))?;
733 calls
734 .remove("first")
735 .ok_or_else(|| test_error("missing first call"))?
736 .respond(Ok(reply("first response")))?;
737
738 let first_response = first
739 .await
740 .map_err(|source| LibsyError::external("joining a test task", source))??;
741 let second_response = second
742 .await
743 .map_err(|source| LibsyError::external("joining a test task", source))??;
744 assert_eq!(
745 first_response.llm_response.as_agg().map(completion_text),
746 Some("first response".to_string())
747 );
748 assert_eq!(
749 second_response.llm_response.as_agg().map(completion_text),
750 Some("second response".to_string())
751 );
752
753 let (driver, mut step_rx) = Driver::new("test", Arc::new(RuntimeModels::default()));
755 let producer = tokio::spawn(async move {
756 driver
757 .call_model(request(), vec![ModelId::from("dropped")])
758 .await
759 });
760 let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
761 let Step::CallModel(call) = step else {
762 return Err(test_error("expected a CallModel step"));
763 };
764 drop(call);
765 let result = producer
766 .await
767 .map_err(|source| LibsyError::external("joining a test task", source))?;
768 assert!(matches!(
769 result,
770 Err(LibsyError::Driver(DriverError::ResponseDropped))
771 ));
772
773 let (driver, step_rx) = Driver::new("test", Arc::new(RuntimeModels::default()));
775 drop(step_rx);
776 let result = driver
777 .call_model(request(), vec![ModelId::from("closed")])
778 .await;
779 assert!(matches!(
780 result,
781 Err(LibsyError::Driver(DriverError::StreamClosed))
782 ));
783 Ok(())
784 })
785 .await
786 .map_err(|error| LibsyError::external("waiting for typed driver boundaries", error))?
787 }
788
789 #[test]
790 fn target_lookup_returns_the_missing_target() {
791 let error = ensure_model_is_target(&target_set(&[]), &ModelId::from("missing")).err();
792 assert!(matches!(
793 error,
794 Some(LibsyError::TargetNotFound { target }) if target == "missing"
795 ));
796 }
797
798 fn streaming_orch(chunks: Vec<LlmResponseChunk>) -> (Arc<dyn Algorithm>, impl Serve) {
801 let algo = orch(target_set(&["stream/model"]));
802 let serve = move |_target: ModelId, _request: Request| {
803 let chunks = chunks.clone();
804 async move {
805 let stream =
806 futures::stream::iter(chunks.into_iter().map(|chunk| Ok(chunk.into()))).boxed();
807 Ok(Response {
808 llm_response: LlmResponse::Stream(stream),
809 metadata: None,
810 upstream_headers: http::HeaderMap::new(),
811 })
812 }
813 };
814 (algo, serve)
815 }
816
817 #[tokio::test]
818 async fn run_returns_a_streamed_response_the_caller_aggregates() -> Result<()> {
819 let (orch, serve) = streaming_orch(vec![
822 LlmResponseChunk::MessageStart {
823 id: Some("m1".to_string()),
824 model: Some("stream/model".to_string()),
825 },
826 LlmResponseChunk::TextDelta {
827 index: 0,
828 text: "hel".to_string(),
829 },
830 LlmResponseChunk::TextDelta {
831 index: 0,
832 text: "lo".to_string(),
833 },
834 LlmResponseChunk::MessageStop {
835 reason: Some("stop".to_string()),
836 },
837 ]);
838 let (selected_model, response) = test_drive(orch, request(), serve).await?;
839 let agg = response
841 .llm_response
842 .into_agg()
843 .await
844 .map_err(|error| LibsyError::external("aggregating response stream", error))?;
845 assert_eq!(completion_text(&agg), "hello");
846 assert_eq!(agg.model.as_deref(), Some("stream/model"));
847 assert_eq!(selected_model, "stream/model");
848 Ok(())
849 }
850
851 #[tokio::test]
852 async fn aggregating_a_streamed_response_propagates_a_mid_stream_error() -> Result<()> {
853 let (orch, serve) = streaming_orch(vec![
856 LlmResponseChunk::TextDelta {
857 index: 0,
858 text: "partial".to_string(),
859 },
860 LlmResponseChunk::StreamError {
861 message: "upstream exploded".to_string(),
862 },
863 ]);
864 let (_, response) = test_drive(orch, request(), serve).await?;
865 match response.llm_response.into_agg().await {
866 Ok(_) => panic!("expected a mid-stream error, got an aggregate"),
867 Err(err) => {
868 assert!(err.to_string().contains("upstream exploded"));
869 Ok(())
870 }
871 }
872 }
873
874 #[tokio::test]
875 async fn run_offloads_via_promise_then_finishes() -> Result<()> {
876 let stream = orch(target_set(&["offload/model"]))
879 .run_stream(request(), Arc::new(RuntimeModels::default()));
880 tokio::pin!(stream);
881
882 let mut saw_call = false;
883 let mut final_completion = None;
884 while let Some(step) = stream.next().await {
885 match step? {
886 Step::CallModel(call) => {
887 saw_call = true;
888 assert_eq!(call.models, vec![ModelId::from("offload/model")]);
889 call.respond(Ok(Response {
891 llm_response: LlmResponse::Agg(text_response(
892 None,
893 "fulfilled".to_string(),
894 )),
895 metadata: None,
896 upstream_headers: http::HeaderMap::new(),
897 }))?;
898 }
899 Step::Done(outcome) => {
900 let metadata = outcome
901 .metadata
902 .as_ref()
903 .expect("run_stream should attach outcome metadata");
904 assert_eq!(metadata.algorithm, "test");
905 assert_eq!(
906 uuid::Uuid::parse_str(metadata.outcome_id())
907 .expect("outcome id should be a UUID")
908 .get_version_num(),
909 7
910 );
911 assert_eq!(
912 metadata.evidence,
913 Some(serde_json::json!({"source": "test"}))
914 );
915 let response = outcome
916 .response
917 .ok_or_else(|| test_error("expected an answered outcome"))?;
918 final_completion = Some(
919 response
920 .llm_response
921 .as_agg()
922 .map(completion_text)
923 .unwrap_or_default(),
924 );
925 }
926 }
927 }
928
929 assert!(saw_call, "expected a CallModel step before Done");
930 assert_eq!(
931 final_completion.ok_or_else(|| test_error("no Done step"))?,
932 "fulfilled"
933 );
934 Ok(())
935 }
936
937 #[tokio::test(flavor = "multi_thread", worker_threads = 12)]
938 async fn requests_are_processed_in_parallel() -> Result<()> {
939 use std::time::Duration;
940 use tokio::sync::Barrier;
941
942 const N: usize = 12;
943
944 let barrier = Arc::new(Barrier::new(N));
949 let algo = orch(target_set(&["m"]));
951
952 let mut handles = Vec::new();
953 for _ in 0..N {
954 let algo = algo.clone();
955 let barrier = barrier.clone();
956 let serve = move |target: ModelId, _request: Request| {
957 let barrier = barrier.clone();
958 async move {
959 barrier.wait().await;
960 Ok(reply(target))
961 }
962 };
963 handles.push(tokio::spawn(async move {
964 test_drive(algo, request(), serve)
965 .await
966 .map(|(_, response)| {
967 response
968 .llm_response
969 .as_agg()
970 .map(completion_text)
971 .unwrap_or_default()
972 })
973 }));
974 }
975
976 for handle in handles {
977 let completion = tokio::time::timeout(Duration::from_secs(5), handle)
979 .await
980 .map_err(|error| LibsyError::external("waiting for test task", error))?
981 .map_err(|source| LibsyError::external("joining a test task", source))??;
982 assert_eq!(completion, "m");
983 }
984 Ok(())
985 }
986
987 #[tokio::test]
988 async fn offload_error_propagates_back_to_the_algorithm() -> Result<()> {
989 let stream = orch(target_set(&["offload/model"]))
993 .run_stream(request(), Arc::new(RuntimeModels::default()));
994 tokio::pin!(stream);
995
996 let mut saw_error = false;
997 while let Some(step) = stream.next().await {
998 match step {
999 Ok(Step::CallModel(call)) => {
1000 call.respond(Err(test_error("upstream model call failed")))?;
1001 }
1002 Ok(Step::Done(..)) => {
1003 return Err(test_error(
1004 "expected the offload error to propagate, got a response",
1005 ));
1006 }
1007 Err(err) => {
1008 assert!(err.to_string().contains("upstream model call failed"));
1010 saw_error = true;
1011 }
1012 }
1013 }
1014
1015 assert!(saw_error, "expected an error step");
1016 Ok(())
1017 }
1018
1019 #[tokio::test]
1020 async fn dropping_the_stream_cancels_the_algorithm_task() -> Result<()> {
1021 use std::sync::atomic::{AtomicBool, Ordering};
1022 use std::time::Duration;
1023 use tokio::sync::mpsc;
1024
1025 struct DropGuard(Arc<AtomicBool>);
1028 impl Drop for DropGuard {
1029 fn drop(&mut self) {
1030 self.0.store(true, Ordering::SeqCst);
1031 }
1032 }
1033
1034 struct StuckAlgo {
1035 started: mpsc::UnboundedSender<()>,
1036 dropped: Arc<AtomicBool>,
1037 }
1038
1039 #[async_trait]
1040 impl Algorithm for StuckAlgo {
1041 fn name(&self) -> &str {
1042 "stuck"
1043 }
1044
1045 async fn route(
1046 self: Arc<Self>,
1047 _driver: Driver,
1048 _request: Request,
1049 ) -> Result<RoutingOutcome> {
1050 let _guard = DropGuard(self.dropped.clone());
1051 let _ = self.started.send(());
1052 std::future::pending::<()>().await;
1054 unreachable!()
1055 }
1056 }
1057
1058 let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1059 let dropped = Arc::new(AtomicBool::new(false));
1060 let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1061 started: started_tx,
1062 dropped: dropped.clone(),
1063 });
1064
1065 let stream = algo.run_stream(request(), Arc::new(RuntimeModels::default()));
1066 started_rx
1067 .recv()
1068 .await
1069 .ok_or_else(|| test_error("task never started"))?;
1070 drop(stream);
1071 tokio::time::sleep(Duration::from_millis(100)).await;
1072
1073 assert!(
1074 dropped.load(Ordering::SeqCst),
1075 "algorithm task was NOT cancelled after dropping the stream"
1076 );
1077 Ok(())
1078 }
1079
1080 #[tokio::test]
1081 async fn route_panic_surfaces_as_a_stream_error() -> Result<()> {
1082 struct Panicky;
1085
1086 #[async_trait]
1087 impl Algorithm for Panicky {
1088 fn name(&self) -> &str {
1089 "panicky"
1090 }
1091
1092 async fn route(
1093 self: Arc<Self>,
1094 _driver: Driver,
1095 _request: Request,
1096 ) -> Result<RoutingOutcome> {
1097 panic!("boom");
1098 }
1099 }
1100
1101 let algo: Arc<dyn Algorithm> = Arc::new(Panicky);
1102 let stream = algo.run_stream(request(), Arc::new(RuntimeModels::default()));
1103 tokio::pin!(stream);
1104
1105 let mut saw_error = false;
1106 while let Some(step) = stream.next().await {
1107 match step {
1108 Err(err) => {
1109 assert!(err.to_string().contains("algorithm task panicked: boom"));
1111 saw_error = true;
1112 }
1113 Ok(_) => return Err(test_error("expected the panic to surface as an error step")),
1114 }
1115 }
1116
1117 assert!(saw_error, "expected an error step from the panicked task");
1118 Ok(())
1119 }
1120
1121 #[tokio::test]
1125 async fn a_panic_with_a_leaked_driver_clone_still_terminates_the_run() -> Result<()> {
1126 struct LeakyPanic;
1127
1128 #[async_trait]
1129 impl Algorithm for LeakyPanic {
1130 fn name(&self) -> &str {
1131 "leaky_panic"
1132 }
1133
1134 async fn route(
1135 self: Arc<Self>,
1136 driver: Driver,
1137 _request: Request,
1138 ) -> Result<RoutingOutcome> {
1139 tokio::spawn(async move {
1140 let _keep_alive = driver;
1142 std::future::pending::<()>().await;
1143 });
1144 tokio::task::yield_now().await;
1145 panic!("boom");
1146 }
1147 }
1148
1149 let algo: Arc<dyn Algorithm> = Arc::new(LeakyPanic);
1150 let result = tokio::time::timeout(
1152 std::time::Duration::from_secs(1),
1153 test_drive(algo, request(), echo()),
1154 )
1155 .await
1156 .map_err(|error| LibsyError::external("waiting for the panicked run to end", error))?;
1157
1158 match result {
1159 Ok(_) => Err(test_error(
1160 "expected the panic to end the run with an error",
1161 )),
1162 Err(err) => {
1163 assert!(err.to_string().contains("algorithm task panicked: boom"));
1164 Ok(())
1165 }
1166 }
1167 }
1168
1169 #[tokio::test]
1170 async fn cancelling_run_cancels_the_algorithm_task() -> Result<()> {
1171 use std::sync::atomic::{AtomicBool, Ordering};
1172 use std::time::Duration;
1173 use tokio::sync::mpsc;
1174
1175 struct DropGuard(Arc<AtomicBool>);
1178 impl Drop for DropGuard {
1179 fn drop(&mut self) {
1180 self.0.store(true, Ordering::SeqCst);
1181 }
1182 }
1183
1184 struct StuckAlgo {
1185 started: mpsc::UnboundedSender<()>,
1186 dropped: Arc<AtomicBool>,
1187 }
1188
1189 #[async_trait]
1190 impl Algorithm for StuckAlgo {
1191 fn name(&self) -> &str {
1192 "stuck"
1193 }
1194
1195 async fn route(
1196 self: Arc<Self>,
1197 _driver: Driver,
1198 _request: Request,
1199 ) -> Result<RoutingOutcome> {
1200 let _guard = DropGuard(self.dropped.clone());
1201 let _ = self.started.send(());
1202 std::future::pending::<()>().await;
1205 unreachable!()
1206 }
1207 }
1208
1209 let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1210 let dropped = Arc::new(AtomicBool::new(false));
1211 let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1212 started: started_tx,
1213 dropped: dropped.clone(),
1214 });
1215
1216 let run_task = tokio::spawn(async move { test_drive(algo, request(), echo()).await });
1219 started_rx
1220 .recv()
1221 .await
1222 .ok_or_else(|| test_error("task never started"))?;
1223 run_task.abort();
1224 tokio::time::sleep(Duration::from_millis(100)).await;
1225
1226 assert!(
1227 dropped.load(Ordering::SeqCst),
1228 "algorithm task was NOT cancelled after cancelling run"
1229 );
1230 Ok(())
1231 }
1232
1233 struct Hedge {
1238 winner: String,
1239 loser: String,
1240 }
1241
1242 #[async_trait]
1243 impl Algorithm for Hedge {
1244 fn name(&self) -> &str {
1245 "hedge"
1246 }
1247
1248 async fn route(
1249 self: Arc<Self>,
1250 driver: Driver,
1251 request: Request,
1252 ) -> Result<RoutingOutcome> {
1253 let outcome_request = request.clone();
1254 let win = driver.call_model(request.clone(), vec![self.winner.clone().into()]);
1255 let lose = driver.call_model(request, vec![self.loser.clone().into()]);
1256 tokio::select! {
1258 res = win => Ok(RoutingOutcome::answered(
1259 self.winner.clone().into(),
1260 outcome_request,
1261 res?,
1262 )),
1263 res = lose => Ok(RoutingOutcome::answered(
1264 self.loser.clone().into(),
1265 outcome_request,
1266 res?,
1267 )),
1268 }
1269 }
1270 }
1271
1272 fn hedge(loser_delay: Option<std::time::Duration>) -> (Arc<dyn Algorithm>, impl Serve) {
1276 let started = Arc::new(tokio::sync::Notify::new());
1277 let algo = Arc::new(Hedge {
1278 winner: "winner".to_string(),
1279 loser: "loser".to_string(),
1280 });
1281 let serve = move |target: ModelId, _request: Request| {
1282 let started = started.clone();
1283 async move {
1284 if target == "loser" {
1285 started.notify_one();
1286 match loser_delay {
1287 Some(delay) => tokio::time::sleep(delay).await,
1288 None => std::future::pending::<()>().await,
1289 }
1290 } else {
1291 started.notified().await;
1292 }
1293 Ok(reply(target))
1294 }
1295 };
1296 (algo, serve)
1297 }
1298
1299 #[tokio::test]
1300 async fn run_returns_the_winner_without_a_late_loser_overwriting_it() -> Result<()> {
1301 let (algo, serve) = hedge(Some(std::time::Duration::from_millis(50)));
1304 let (_, response) = test_drive(algo, request(), serve).await?;
1305 assert_eq!(
1306 response
1307 .llm_response
1308 .as_agg()
1309 .map(completion_text)
1310 .unwrap_or_default(),
1311 "winner"
1312 );
1313 Ok(())
1314 }
1315
1316 #[tokio::test]
1317 async fn run_returns_the_winner_without_hanging_on_a_pending_loser() -> Result<()> {
1318 let (algo, serve) = hedge(None);
1321 let run = test_drive(algo, request(), serve);
1322 let (_, response) = tokio::time::timeout(std::time::Duration::from_secs(1), run)
1323 .await
1324 .map_err(|error| LibsyError::external("waiting for pending loser", error))??;
1325 assert_eq!(
1326 response
1327 .llm_response
1328 .as_agg()
1329 .map(completion_text)
1330 .unwrap_or_default(),
1331 "winner"
1332 );
1333 Ok(())
1334 }
1335
1336 #[tokio::test]
1337 async fn run_surfaces_a_terminal_error_with_many_calls_in_flight() -> Result<()> {
1338 use std::sync::atomic::{AtomicUsize, Ordering};
1339
1340 const N: usize = 10;
1343
1344 struct FanOutThenError {
1347 all_started: Arc<tokio::sync::Notify>,
1348 n: usize,
1349 }
1350
1351 #[async_trait]
1352 impl Algorithm for FanOutThenError {
1353 fn name(&self) -> &str {
1354 "fan_out_then_error"
1355 }
1356
1357 async fn route(
1358 self: Arc<Self>,
1359 driver: Driver,
1360 request: Request,
1361 ) -> Result<RoutingOutcome> {
1362 let offloads = futures::future::join_all(
1363 (0..self.n)
1364 .map(|i| driver.call_model(request.clone(), vec![format!("m{i}").into()])),
1365 );
1366 tokio::select! {
1367 _ = offloads => Err(test_error("offloads unexpectedly completed")),
1368 _ = self.all_started.notified() => {
1369 Err(test_error("terminal error while calls pending"))
1370 }
1371 }
1372 }
1373 }
1374
1375 let all_started = Arc::new(tokio::sync::Notify::new());
1376 let algo: Arc<dyn Algorithm> = Arc::new(FanOutThenError {
1377 all_started: all_started.clone(),
1378 n: N,
1379 });
1380
1381 let started = Arc::new(AtomicUsize::new(0));
1383 let serve = move |_target: ModelId, _request: Request| {
1384 let started = started.clone();
1385 let all_started = all_started.clone();
1386 async move {
1387 if started.fetch_add(1, Ordering::SeqCst) + 1 == N {
1388 all_started.notify_one();
1389 }
1390 std::future::pending::<ServeResult>().await
1391 }
1392 };
1393
1394 let run = test_drive(algo, request(), serve);
1397 let result = tokio::time::timeout(std::time::Duration::from_millis(500), run)
1398 .await
1399 .map_err(|error| {
1400 LibsyError::external("waiting for terminal error with full call cap", error)
1401 })?;
1402 match result {
1403 Ok(_) => Err(test_error("expected the terminal error, got a response")),
1404 Err(err) => {
1405 assert!(
1406 err.to_string()
1407 .contains("terminal error while calls pending")
1408 );
1409 Ok(())
1410 }
1411 }
1412 }
1413}