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