1use std::{
9 collections::{HashMap, HashSet},
10 future::Future,
11 panic::AssertUnwindSafe,
12 pin::Pin,
13 sync::Arc,
14 time::Instant,
15};
16
17use async_trait::async_trait;
18use futures::{FutureExt, Stream, StreamExt};
19use parking_lot::Mutex;
20use tokio::sync::{mpsc, oneshot};
21use tokio_stream::wrappers::ReceiverStream;
22use tracing::Instrument;
23
24use switchyard_protocol::{
32 Decision, LlmClientError, ModelId, Request, Response, RoutingFallbackReason,
33};
34
35use crate::{DriverError, LibsyError, Result, observability};
36
37pub type StepStream = Pin<Box<dyn Stream<Item = Result<Step>> + Send>>;
41
42pub struct CallModel {
56 pub algorithm: String,
59 pub request: Request,
63 pub decision: Decision,
65 reply: oneshot::Sender<Result<Response>>,
67}
68
69impl CallModel {
70 pub fn respond(self, result: Result<Response>) -> Result<()> {
74 self.reply
75 .send(result)
76 .map_err(|_| DriverError::ResponseDropped.into())
77 }
78
79 pub fn into_parts(self) -> (Request, Decision) {
90 let Self {
91 request,
92 decision,
93 reply,
94 ..
95 } = self;
96 let _ = reply.send(Err(DriverError::Abandoned.into()));
100 (request, decision)
101 }
102}
103
104#[derive(Clone)]
106pub struct Driver {
107 step_tx: mpsc::Sender<Result<Step>>,
108 algorithm: String,
111}
112
113impl Driver {
114 pub(crate) fn new(algorithm: &str) -> (Self, mpsc::Receiver<Result<Step>>) {
117 let (step_tx, step_rx) = mpsc::channel(1);
122 (
123 Self {
124 step_tx,
125 algorithm: algorithm.to_string(),
126 },
127 step_rx,
128 )
129 }
130
131 #[tracing::instrument(
139 target = "libsy",
140 name = "libsy.llm_call",
141 skip_all,
142 fields(
143 algorithm = self.algorithm,
144 selected_model = %decision.selected_model_id(),
145 openinference.span.kind = "CHAIN",
146 outcome = tracing::field::Empty,
147 error = tracing::field::Empty,
148 input_tokens = tracing::field::Empty,
149 output_tokens = tracing::field::Empty,
150 total_tokens = tracing::field::Empty,
151 reasoning_tokens = tracing::field::Empty,
152 )
153 )]
154 pub async fn call_model(&self, request: Request, decision: Decision) -> Result<Response> {
155 let selected_model_id = decision.selected_model_id().to_string();
156 let is_answer_call = decision.is_answer_call();
157 let started = Instant::now();
158 let (reply, response) = oneshot::channel::<Result<Response>>();
159 let call = CallModel {
160 algorithm: self.algorithm.clone(),
161 request,
162 decision,
163 reply,
164 };
165 let result = async {
166 self.step_tx
167 .send(Ok(Step::CallModel(Box::new(call))))
168 .await
169 .map_err(|_| DriverError::StreamClosed)?;
170 response
171 .await
172 .map_err(|_| LibsyError::from(DriverError::ResponseDropped))?
173 }
174 .await;
175 let elapsed = started.elapsed();
176 observability::record_llm_call(
177 &self.algorithm,
178 &selected_model_id,
179 is_answer_call,
180 elapsed,
181 &result,
182 &tracing::Span::current(),
183 );
184 result
185 }
186
187 pub async fn decide(&self, decision: Decision) -> Result<()> {
191 self.step_tx
192 .send(Ok(Step::Decision(decision.clone())))
193 .await
194 .map_err(|_| DriverError::StreamClosed)?;
195 observability::record_decision(&self.algorithm, &decision);
196 Ok(())
197 }
198
199 pub(crate) async fn finish(&self, result: Result<Response>) -> Result<()> {
203 let step = result.map(|response| Step::Done(Box::new(response)));
204 self.step_tx
205 .send(step)
206 .await
207 .map_err(|_| DriverError::StreamClosed.into())
208 }
209}
210
211pub enum Step {
213 CallModel(Box<CallModel>),
216 Decision(Decision),
219 Done(Box<Response>),
221}
222
223pub async fn drive<F, Fut>(
236 algorithm: Arc<dyn Algorithm>,
237 request: Request,
238 serve: F,
239) -> Result<(Vec<Decision>, Response)>
240where
241 F: Fn(CallModel) -> Fut,
242 Fut: Future<Output = Result<()>>,
243{
244 let stream = algorithm.run_stream(request);
245 tokio::pin!(stream);
246
247 let mut trace: Vec<Decision> = Vec::new();
248 let mut in_flight = futures::stream::FuturesUnordered::new();
249 let mut final_response: Option<Response> = None;
250
251 loop {
252 tokio::select! {
253 Some(result) = in_flight.next() => match result {
254 Ok(()) => {}, Err(err) => return Err(err), },
257 step = stream.next() => {
258 match step {
259 None => break, Some(item) => match item? {
261 Step::CallModel(call) => in_flight.push(serve(*call)),
262 Step::Decision(decision) => trace.push(decision),
263 Step::Done(response) => {
264 final_response = Some(*response);
265 break;
266 }
267 }
268 }
269 },
270 }
271 }
272 final_response
273 .map(|response| (trace, response))
274 .ok_or(LibsyError::MissingFinalResponse)
275}
276
277fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
279 payload
280 .downcast_ref::<&'static str>()
281 .map(|message| (*message).to_string())
282 .or_else(|| payload.downcast_ref::<String>().cloned())
283 .unwrap_or_else(|| "unknown panic payload".to_string())
284}
285
286struct AbortOnDrop(tokio::task::AbortHandle);
288
289impl Drop for AbortOnDrop {
290 fn drop(&mut self) {
291 self.0.abort();
292 }
293}
294
295pub(crate) fn ensure_model_is_target(targets: &[ModelId], name: &ModelId) -> Result<()> {
300 targets
301 .iter()
302 .any(|target| target == name)
303 .then_some(())
304 .ok_or_else(|| LibsyError::TargetNotFound {
305 target: name.clone(),
306 })
307}
308
309pub(crate) fn select_eligible_model(
312 targets: &[ModelId],
313 name: &ModelId,
314 excluded: &HashSet<ModelId>,
315) -> Result<ModelId> {
316 ensure_model_is_target(targets, name)?;
317 if !excluded.contains(name) {
318 return Ok(name.clone());
319 }
320 targets
321 .iter()
322 .find(|target| !excluded.contains(*target))
323 .cloned()
324 .ok_or(LibsyError::AllTargetsExcluded)
325}
326
327#[derive(Clone, Hash, PartialEq, Eq)]
331pub(crate) enum RoutingIdentity {
332 Session(String),
334 Subagent { session: String, agent: String },
336}
337
338impl RoutingIdentity {
339 pub(crate) fn from_request(request: &Request) -> Option<Self> {
344 let metadata = request.metadata.as_ref()?;
345 let session = metadata.session_id.as_deref().filter(|id| !id.is_empty())?;
346 if metadata.is_subagent {
347 let agent = metadata.agent_id.as_deref().filter(|id| !id.is_empty())?;
348 Some(Self::Subagent {
349 session: session.to_string(),
350 agent: agent.to_string(),
351 })
352 } else {
353 Some(Self::Session(session.to_string()))
354 }
355 }
356
357 fn session(&self) -> &str {
359 match self {
360 Self::Session(session) | Self::Subagent { session, .. } => session,
361 }
362 }
363}
364
365const MAX_EVICTION_IDENTITIES: usize = 1_024;
368
369#[derive(Default)]
375pub(crate) struct SessionEvictions {
376 by_identity: Mutex<HashMap<RoutingIdentity, HashSet<ModelId>>>,
377}
378
379impl SessionEvictions {
380 pub(crate) fn remove_session(&self, session: &str) {
382 self.by_identity
383 .lock()
384 .retain(|identity, _| identity.session() != session);
385 }
386
387 fn evicted_for(&self, identity: Option<&RoutingIdentity>) -> Vec<ModelId> {
389 let Some(identity) = identity else {
390 return Vec::new();
391 };
392 self.by_identity
393 .lock()
394 .get(identity)
395 .map(|targets| targets.iter().cloned().collect())
396 .unwrap_or_default()
397 }
398
399 fn record(&self, identity: Option<&RoutingIdentity>, target: &ModelId) {
402 let Some(identity) = identity else { return };
403 let mut histories = self.by_identity.lock();
404 if histories.len() >= MAX_EVICTION_IDENTITIES
405 && !histories.contains_key(identity)
406 && let Some(oldest) = histories.keys().next().cloned()
407 {
408 histories.remove(&oldest);
409 }
410 histories
411 .entry(identity.clone())
412 .or_default()
413 .insert(target.clone());
414 }
415}
416
417fn eligible_targets(targets: &[ModelId], excluded: &HashSet<ModelId>) -> usize {
419 targets
420 .iter()
421 .filter(|target| !excluded.contains(*target))
422 .count()
423}
424
425pub(crate) fn exclude_evicted(
428 excluded: &mut HashSet<ModelId>,
429 targets: &[ModelId],
430 evictions: &SessionEvictions,
431 identity: Option<&RoutingIdentity>,
432) {
433 for target in evictions.evicted_for(identity) {
434 if eligible_targets(targets, excluded) <= 1 {
437 break;
438 }
439 excluded.insert(target);
440 }
441}
442
443fn classify_fallback(error: &LibsyError) -> Option<(&ModelId, RoutingFallbackReason)> {
445 let LibsyError::ClientCall { target, source } = error else {
446 return None;
447 };
448 let reason = match source {
449 LlmClientError::ContextWindowExceeded { .. } => RoutingFallbackReason::ContextWindow,
450 LlmClientError::Transport { .. } | LlmClientError::Timeout { .. } => {
451 RoutingFallbackReason::Unavailable
452 }
453 LlmClientError::UpstreamHttp { status, .. }
454 if matches!(*status, 403 | 408 | 429) || (500..=599).contains(status) =>
455 {
456 RoutingFallbackReason::Unavailable
457 }
458 _ => return None,
459 };
460 Some((target, reason))
461}
462
463#[allow(clippy::too_many_arguments)]
471pub(crate) async fn call_model_with_fallback(
472 excluded: &mut HashSet<ModelId>,
473 driver: &Driver,
474 targets: &[ModelId],
475 mut target: ModelId,
476 mut decision: Decision,
477 request: Request,
478 identity: Option<&RoutingIdentity>,
479 evictions: &SessionEvictions,
480 target_unavailable: impl Fn(&Request, &ModelId),
481 fallback_decision: impl Fn(&ModelId, &ModelId, RoutingFallbackReason) -> Decision,
482) -> Result<Response> {
483 loop {
484 let result = driver.call_model(request.clone(), decision.clone()).await;
485 let Err(error) = result else { return result };
486 let Some((failed, reason)) = classify_fallback(&error) else {
487 return Err(error);
488 };
489 if !excluded.insert(failed.clone()) {
492 return Err(error);
493 }
494 match reason {
495 RoutingFallbackReason::ContextWindow => evictions.record(identity, failed),
496 RoutingFallbackReason::Unavailable => target_unavailable(&request, failed),
497 }
498 let Ok(next) = select_eligible_model(targets, &target, excluded) else {
499 return Err(error);
500 };
501 decision = fallback_decision(&target, &next, reason);
502 target = next;
503 driver.decide(decision.clone()).await?;
504 }
505}
506
507#[async_trait]
529pub trait Algorithm: Send + Sync + 'static {
530 fn name(&self) -> &str;
534
535 async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<Response>;
539
540 fn run_stream(self: Arc<Self>, request: Request) -> StepStream {
549 let (driver, step_rx) = Driver::new(self.name());
550 let span = observability::run_span(self.name(), &request);
551 let handle = tokio::spawn(
552 async move {
553 let algorithm = self.name().to_string();
554 let route = AssertUnwindSafe(self.route(driver.clone(), request)).catch_unwind();
556 let result = observability::observe_run(&algorithm, async move {
557 route.await.unwrap_or_else(|payload| {
558 Err(LibsyError::AlgorithmError {
559 message: format!(
560 "algorithm task panicked: {}",
561 panic_message(payload.as_ref())
562 ),
563 })
564 })
565 })
566 .await;
567
568 let _ = driver.finish(result).await;
569 }
570 .instrument(span),
571 );
572 let abort_guard = AbortOnDrop(handle.abort_handle());
574 Box::pin(ReceiverStream::new(step_rx).map(move |step| {
575 let _keep_alive = &abort_guard;
577 step
578 }))
579 }
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585 use crate::core::testing::{Serve, ServeResult, echo, reply, test_drive};
586 use futures::StreamExt;
587 use switchyard_protocol::{
588 LlmResponse, LlmResponseChunk, completion_text, text_request, text_response,
589 };
590
591 #[derive(Debug, thiserror::Error)]
592 #[error("{0}")]
593 struct TestError(&'static str);
594
595 fn test_error(message: &'static str) -> LibsyError {
596 LibsyError::external("test", TestError(message))
597 }
598
599 fn classified_client_error(source: LlmClientError) -> Option<RoutingFallbackReason> {
600 classify_fallback(&LibsyError::client_call("target", source)).map(|(_, reason)| reason)
601 }
602
603 #[test]
604 fn route_fallback_only_accepts_context_and_unavailable_failures() {
605 assert_eq!(
606 classified_client_error(LlmClientError::ContextWindowExceeded {
607 model: ModelId::from("target"),
608 message: "too long".to_string(),
609 }),
610 Some(RoutingFallbackReason::ContextWindow)
611 );
612 for source in [
613 LlmClientError::Transport {
614 source: Box::new(std::io::Error::other("connection failed")),
615 },
616 LlmClientError::Timeout {
617 source: Box::new(std::io::Error::other("request timed out")),
618 },
619 ] {
620 assert_eq!(
621 classified_client_error(source),
622 Some(RoutingFallbackReason::Unavailable)
623 );
624 }
625 for (status, expected) in [
626 (400, None),
627 (401, None),
628 (403, Some(RoutingFallbackReason::Unavailable)),
629 (404, None),
630 (408, Some(RoutingFallbackReason::Unavailable)),
631 (409, None),
632 (429, Some(RoutingFallbackReason::Unavailable)),
633 (499, None),
634 (500, Some(RoutingFallbackReason::Unavailable)),
635 (599, Some(RoutingFallbackReason::Unavailable)),
636 (600, None),
637 ] {
638 assert_eq!(
639 classified_client_error(LlmClientError::UpstreamHttp {
640 status,
641 body: "failed".to_string(),
642 }),
643 expected
644 );
645 }
646 assert_eq!(
647 classified_client_error(LlmClientError::InvalidResponse {
648 source: Box::new(std::io::Error::other("invalid response")),
649 }),
650 None
651 );
652 }
653
654 fn test_decision(selected_model_id: ModelId) -> Decision {
656 Decision::new(selected_model_id, None, true)
657 }
658
659 struct TestAlgo {
662 target_set: Vec<ModelId>,
663 }
664
665 #[async_trait]
666 impl Algorithm for TestAlgo {
667 fn name(&self) -> &str {
668 "test"
669 }
670
671 async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<Response> {
672 let target = self
673 .target_set
674 .first()
675 .ok_or(LibsyError::NoTargets)?
676 .clone();
677 let decision = test_decision(target.clone());
678 driver.decide(decision.clone()).await?;
679 driver.call_model(request, decision).await
680 }
681 }
682
683 fn orch(target_set: Vec<ModelId>) -> Arc<dyn Algorithm> {
685 Arc::new(TestAlgo { target_set })
686 }
687
688 fn request() -> Request {
689 Request {
690 llm_request: text_request(Some("auto".to_string()), "hi".to_string()),
691 raw_request: None,
692 metadata: None,
693 }
694 }
695
696 fn target_set(names: &[&str]) -> Vec<ModelId> {
697 names.iter().map(|name| ModelId::from(*name)).collect()
698 }
699
700 #[tokio::test]
701 async fn typed_driver_preserves_call_and_stream_boundaries() -> Result<()> {
702 tokio::time::timeout(std::time::Duration::from_secs(1), async {
703 let (driver, mut step_rx) = Driver::new("test");
706 let first_driver = driver.clone();
707 let mut first = tokio::spawn(async move {
708 first_driver
709 .call_model(request(), test_decision(ModelId::from("first")))
710 .await
711 });
712 let second = tokio::spawn(async move {
713 driver
714 .call_model(request(), test_decision(ModelId::from("second")))
715 .await
716 });
717
718 let mut calls = HashMap::new();
719 for _ in 0..2 {
720 let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
721 let Step::CallModel(call) = step else {
722 return Err(test_error("expected a CallModel step"));
723 };
724 calls.insert(call.decision.selected_model_id().to_string(), call);
725 }
726 assert!(
727 tokio::time::timeout(std::time::Duration::from_millis(20), &mut first)
728 .await
729 .is_err(),
730 "call completed before the host responded"
731 );
732 calls
733 .remove("second")
734 .ok_or_else(|| test_error("missing second call"))?
735 .respond(Ok(reply("second response")))?;
736 calls
737 .remove("first")
738 .ok_or_else(|| test_error("missing first call"))?
739 .respond(Ok(reply("first response")))?;
740
741 let first_response = first
742 .await
743 .map_err(|source| LibsyError::external("joining a test task", source))??;
744 let second_response = second
745 .await
746 .map_err(|source| LibsyError::external("joining a test task", source))??;
747 assert_eq!(
748 first_response.llm_response.as_agg().map(completion_text),
749 Some("first response".to_string())
750 );
751 assert_eq!(
752 second_response.llm_response.as_agg().map(completion_text),
753 Some("second response".to_string())
754 );
755
756 let (driver, mut step_rx) = Driver::new("test");
758 let producer = tokio::spawn(async move {
759 driver
760 .call_model(request(), test_decision(ModelId::from("dropped")))
761 .await
762 });
763 let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
764 let Step::CallModel(call) = step else {
765 return Err(test_error("expected a CallModel step"));
766 };
767 drop(call);
768 let result = producer
769 .await
770 .map_err(|source| LibsyError::external("joining a test task", source))?;
771 assert!(matches!(
772 result,
773 Err(LibsyError::Driver(DriverError::ResponseDropped))
774 ));
775
776 let (driver, step_rx) = Driver::new("test");
778 drop(step_rx);
779 let decision = test_decision(ModelId::from("closed"));
780 let result = driver.decide(decision).await;
781 assert!(matches!(
782 result,
783 Err(LibsyError::Driver(DriverError::StreamClosed))
784 ));
785 Ok(())
786 })
787 .await
788 .map_err(|error| LibsyError::external("waiting for typed driver boundaries", error))?
789 }
790
791 #[tokio::test]
796 async fn into_parts_yields_the_call_without_answering_it() -> Result<()> {
797 let (driver, mut step_rx) = Driver::new("test");
798 let decision = test_decision(ModelId::from("answer/model"));
799 let producer = tokio::spawn({
800 let decision = decision.clone();
801 async move { driver.call_model(request(), decision).await }
802 });
803
804 let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
805 let Step::CallModel(call) = step else {
806 return Err(test_error("expected a CallModel step"));
807 };
808 let (taken_request, taken_decision) = call.into_parts();
809 assert_eq!(taken_decision.selected_model_id(), "answer/model");
810 assert!(taken_decision.is_answer_call());
811 assert_eq!(taken_request.llm_request, request().llm_request);
812
813 let result = producer
814 .await
815 .map_err(|source| LibsyError::external("joining a test task", source))?;
816 assert!(matches!(
817 result,
818 Err(LibsyError::Driver(DriverError::Abandoned))
819 ));
820 Ok(())
821 }
822
823 #[test]
826 fn abandoning_and_dropping_a_call_report_different_outcomes() {
827 let abandoned: Result<Response> = Err(DriverError::Abandoned.into());
828 let dropped: Result<Response> = Err(DriverError::ResponseDropped.into());
829 assert_eq!(observability::outcome_value(&abandoned), "abandoned");
830 assert_eq!(observability::outcome_value(&dropped), "error");
831 assert!(observability::is_abandoned(&abandoned));
832 assert!(!observability::is_abandoned(&dropped));
833 }
834
835 #[test]
836 fn target_lookup_returns_the_missing_target() {
837 let error = ensure_model_is_target(&target_set(&[]), &ModelId::from("missing")).err();
838 assert!(matches!(
839 error,
840 Some(LibsyError::TargetNotFound { target }) if target == "missing"
841 ));
842 }
843
844 fn streaming_orch(chunks: Vec<LlmResponseChunk>) -> (Arc<dyn Algorithm>, impl Serve) {
847 let algo = orch(target_set(&["stream/model"]));
848 let serve = move |_decision: Decision, _request: Request| {
849 let chunks = chunks.clone();
850 async move {
851 let stream =
852 futures::stream::iter(chunks.into_iter().map(|chunk| Ok(chunk.into()))).boxed();
853 Ok(Response {
854 llm_response: LlmResponse::Stream(stream),
855 metadata: None,
856 })
857 }
858 };
859 (algo, serve)
860 }
861
862 #[tokio::test]
863 async fn run_returns_a_streamed_response_the_caller_aggregates() -> Result<()> {
864 let (orch, serve) = streaming_orch(vec![
867 LlmResponseChunk::MessageStart {
868 id: Some("m1".to_string()),
869 model: Some("stream/model".to_string()),
870 },
871 LlmResponseChunk::TextDelta {
872 index: 0,
873 text: "hel".to_string(),
874 },
875 LlmResponseChunk::TextDelta {
876 index: 0,
877 text: "lo".to_string(),
878 },
879 LlmResponseChunk::MessageStop {
880 reason: Some("stop".to_string()),
881 },
882 ]);
883 let (trace, response) = test_drive(orch, request(), serve).await?;
884 let agg = response
886 .llm_response
887 .into_agg()
888 .await
889 .map_err(|error| LibsyError::external("aggregating response stream", error))?;
890 assert_eq!(completion_text(&agg), "hello");
891 assert_eq!(agg.model.as_deref(), Some("stream/model"));
892 assert_eq!(trace.len(), 1);
893 Ok(())
894 }
895
896 #[tokio::test]
897 async fn aggregating_a_streamed_response_propagates_a_mid_stream_error() -> Result<()> {
898 let (orch, serve) = streaming_orch(vec![
901 LlmResponseChunk::TextDelta {
902 index: 0,
903 text: "partial".to_string(),
904 },
905 LlmResponseChunk::StreamError {
906 message: "upstream exploded".to_string(),
907 },
908 ]);
909 let (_, response) = test_drive(orch, request(), serve).await?;
910 match response.llm_response.into_agg().await {
911 Ok(_) => panic!("expected a mid-stream error, got an aggregate"),
912 Err(err) => {
913 assert!(err.to_string().contains("upstream exploded"));
914 Ok(())
915 }
916 }
917 }
918
919 #[tokio::test]
920 async fn run_offloads_via_promise_then_finishes() -> Result<()> {
921 let stream = orch(target_set(&["offload/model"])).run_stream(request());
924 tokio::pin!(stream);
925
926 let mut saw_call = false;
927 let mut final_completion = None;
928 while let Some(step) = stream.next().await {
929 match step? {
930 Step::CallModel(call) => {
931 saw_call = true;
932 assert_eq!(call.decision.selected_model_id(), "offload/model");
934 call.respond(Ok(Response {
936 llm_response: LlmResponse::Agg(text_response(
937 None,
938 "fulfilled".to_string(),
939 )),
940 metadata: None,
941 }))?;
942 }
943 Step::Decision(decision) => {
944 assert_eq!(decision.selected_model_id(), "offload/model");
945 }
946 Step::Done(response) => {
947 final_completion = Some(
948 response
949 .llm_response
950 .as_agg()
951 .map(completion_text)
952 .unwrap_or_default(),
953 );
954 }
955 }
956 }
957
958 assert!(saw_call, "expected a CallModel step before Done");
959 assert_eq!(
960 final_completion.ok_or_else(|| test_error("no Done step"))?,
961 "fulfilled"
962 );
963 Ok(())
964 }
965
966 #[tokio::test]
967 async fn a_driven_run_returns_the_trace_and_the_final_response() -> Result<()> {
968 let (trace, response) =
969 test_drive(orch(target_set(&["direct/model"])), request(), echo()).await?;
970 assert_eq!(
972 response
973 .llm_response
974 .as_agg()
975 .map(completion_text)
976 .unwrap_or_default(),
977 "direct/model"
978 );
979 assert_eq!(trace[0].selected_model_id(), "direct/model");
980 Ok(())
981 }
982
983 #[tokio::test(flavor = "multi_thread", worker_threads = 12)]
984 async fn requests_are_processed_in_parallel() -> Result<()> {
985 use std::time::Duration;
986 use tokio::sync::Barrier;
987
988 const N: usize = 12;
989
990 let barrier = Arc::new(Barrier::new(N));
995 let algo = orch(target_set(&["m"]));
997
998 let mut handles = Vec::new();
999 for _ in 0..N {
1000 let algo = algo.clone();
1001 let barrier = barrier.clone();
1002 let serve = move |decision: Decision, _request: Request| {
1003 let barrier = barrier.clone();
1004 async move {
1005 barrier.wait().await;
1006 Ok(reply(decision.selected_model_id()))
1007 }
1008 };
1009 handles.push(tokio::spawn(async move {
1010 test_drive(algo, request(), serve)
1011 .await
1012 .map(|(_, response)| {
1013 response
1014 .llm_response
1015 .as_agg()
1016 .map(completion_text)
1017 .unwrap_or_default()
1018 })
1019 }));
1020 }
1021
1022 for handle in handles {
1023 let completion = tokio::time::timeout(Duration::from_secs(5), handle)
1025 .await
1026 .map_err(|error| LibsyError::external("waiting for test task", error))?
1027 .map_err(|source| LibsyError::external("joining a test task", source))??;
1028 assert_eq!(completion, "m");
1029 }
1030 Ok(())
1031 }
1032
1033 #[tokio::test]
1034 async fn offload_error_propagates_back_to_the_algorithm() -> Result<()> {
1035 let stream = orch(target_set(&["offload/model"])).run_stream(request());
1039 tokio::pin!(stream);
1040
1041 let mut saw_error = false;
1042 while let Some(step) = stream.next().await {
1043 match step {
1044 Ok(Step::CallModel(call)) => {
1045 call.respond(Err(test_error("upstream model call failed")))?;
1046 }
1047 Ok(Step::Decision(_)) => {}
1048 Ok(Step::Done(..)) => {
1049 return Err(test_error(
1050 "expected the offload error to propagate, got a response",
1051 ));
1052 }
1053 Err(err) => {
1054 assert!(err.to_string().contains("upstream model call failed"));
1056 saw_error = true;
1057 }
1058 }
1059 }
1060
1061 assert!(saw_error, "expected an error step");
1062 Ok(())
1063 }
1064
1065 #[tokio::test]
1066 async fn dropping_the_stream_cancels_the_algorithm_task() -> Result<()> {
1067 use std::sync::atomic::{AtomicBool, Ordering};
1068 use std::time::Duration;
1069 use tokio::sync::mpsc;
1070
1071 struct DropGuard(Arc<AtomicBool>);
1074 impl Drop for DropGuard {
1075 fn drop(&mut self) {
1076 self.0.store(true, Ordering::SeqCst);
1077 }
1078 }
1079
1080 struct StuckAlgo {
1081 started: mpsc::UnboundedSender<()>,
1082 dropped: Arc<AtomicBool>,
1083 }
1084
1085 #[async_trait]
1086 impl Algorithm for StuckAlgo {
1087 fn name(&self) -> &str {
1088 "stuck"
1089 }
1090
1091 async fn route(
1092 self: Arc<Self>,
1093 _driver: Driver,
1094 _request: Request,
1095 ) -> Result<Response> {
1096 let _guard = DropGuard(self.dropped.clone());
1097 let _ = self.started.send(());
1098 std::future::pending::<()>().await;
1100 unreachable!()
1101 }
1102 }
1103
1104 let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1105 let dropped = Arc::new(AtomicBool::new(false));
1106 let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1107 started: started_tx,
1108 dropped: dropped.clone(),
1109 });
1110
1111 let stream = algo.run_stream(request());
1112 started_rx
1113 .recv()
1114 .await
1115 .ok_or_else(|| test_error("task never started"))?;
1116 drop(stream);
1117 tokio::time::sleep(Duration::from_millis(100)).await;
1118
1119 assert!(
1120 dropped.load(Ordering::SeqCst),
1121 "algorithm task was NOT cancelled after dropping the stream"
1122 );
1123 Ok(())
1124 }
1125
1126 #[tokio::test]
1127 async fn route_panic_surfaces_as_a_stream_error() -> Result<()> {
1128 struct Panicky;
1131
1132 #[async_trait]
1133 impl Algorithm for Panicky {
1134 fn name(&self) -> &str {
1135 "panicky"
1136 }
1137
1138 async fn route(
1139 self: Arc<Self>,
1140 _driver: Driver,
1141 _request: Request,
1142 ) -> Result<Response> {
1143 panic!("boom");
1144 }
1145 }
1146
1147 let algo: Arc<dyn Algorithm> = Arc::new(Panicky);
1148 let stream = algo.run_stream(request());
1149 tokio::pin!(stream);
1150
1151 let mut saw_error = false;
1152 while let Some(step) = stream.next().await {
1153 match step {
1154 Err(err) => {
1155 assert!(err.to_string().contains("algorithm task panicked: boom"));
1157 saw_error = true;
1158 }
1159 Ok(_) => return Err(test_error("expected the panic to surface as an error step")),
1160 }
1161 }
1162
1163 assert!(saw_error, "expected an error step from the panicked task");
1164 Ok(())
1165 }
1166
1167 #[tokio::test]
1171 async fn a_panic_with_a_leaked_driver_clone_still_terminates_the_run() -> Result<()> {
1172 struct LeakyPanic;
1173
1174 #[async_trait]
1175 impl Algorithm for LeakyPanic {
1176 fn name(&self) -> &str {
1177 "leaky_panic"
1178 }
1179
1180 async fn route(self: Arc<Self>, driver: Driver, _request: Request) -> Result<Response> {
1181 tokio::spawn(async move {
1182 let _keep_alive = driver;
1184 std::future::pending::<()>().await;
1185 });
1186 tokio::task::yield_now().await;
1187 panic!("boom");
1188 }
1189 }
1190
1191 let algo: Arc<dyn Algorithm> = Arc::new(LeakyPanic);
1192 let result = tokio::time::timeout(
1194 std::time::Duration::from_secs(1),
1195 test_drive(algo, request(), echo()),
1196 )
1197 .await
1198 .map_err(|error| LibsyError::external("waiting for the panicked run to end", error))?;
1199
1200 match result {
1201 Ok(_) => Err(test_error(
1202 "expected the panic to end the run with an error",
1203 )),
1204 Err(err) => {
1205 assert!(err.to_string().contains("algorithm task panicked: boom"));
1206 Ok(())
1207 }
1208 }
1209 }
1210
1211 #[tokio::test]
1212 async fn cancelling_run_cancels_the_algorithm_task() -> Result<()> {
1213 use std::sync::atomic::{AtomicBool, Ordering};
1214 use std::time::Duration;
1215 use tokio::sync::mpsc;
1216
1217 struct DropGuard(Arc<AtomicBool>);
1220 impl Drop for DropGuard {
1221 fn drop(&mut self) {
1222 self.0.store(true, Ordering::SeqCst);
1223 }
1224 }
1225
1226 struct StuckAlgo {
1227 started: mpsc::UnboundedSender<()>,
1228 dropped: Arc<AtomicBool>,
1229 }
1230
1231 #[async_trait]
1232 impl Algorithm for StuckAlgo {
1233 fn name(&self) -> &str {
1234 "stuck"
1235 }
1236
1237 async fn route(
1238 self: Arc<Self>,
1239 _driver: Driver,
1240 _request: Request,
1241 ) -> Result<Response> {
1242 let _guard = DropGuard(self.dropped.clone());
1243 let _ = self.started.send(());
1244 std::future::pending::<()>().await;
1247 unreachable!()
1248 }
1249 }
1250
1251 let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1252 let dropped = Arc::new(AtomicBool::new(false));
1253 let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1254 started: started_tx,
1255 dropped: dropped.clone(),
1256 });
1257
1258 let run_task = tokio::spawn(async move { test_drive(algo, request(), echo()).await });
1261 started_rx
1262 .recv()
1263 .await
1264 .ok_or_else(|| test_error("task never started"))?;
1265 run_task.abort();
1266 tokio::time::sleep(Duration::from_millis(100)).await;
1267
1268 assert!(
1269 dropped.load(Ordering::SeqCst),
1270 "algorithm task was NOT cancelled after cancelling run"
1271 );
1272 Ok(())
1273 }
1274
1275 struct Hedge {
1280 winner: String,
1281 loser: String,
1282 }
1283
1284 #[async_trait]
1285 impl Algorithm for Hedge {
1286 fn name(&self) -> &str {
1287 "hedge"
1288 }
1289
1290 async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<Response> {
1291 let dec_w = test_decision(self.winner.clone().into());
1292 let dec_l = test_decision(self.loser.clone().into());
1293 let win = driver.call_model(request.clone(), dec_w);
1294 let lose = driver.call_model(request, dec_l);
1295 tokio::select! {
1297 res = win => res,
1298 res = lose => res,
1299 }
1300 }
1301 }
1302
1303 fn hedge(loser_delay: Option<std::time::Duration>) -> (Arc<dyn Algorithm>, impl Serve) {
1307 let started = Arc::new(tokio::sync::Notify::new());
1308 let algo = Arc::new(Hedge {
1309 winner: "winner".to_string(),
1310 loser: "loser".to_string(),
1311 });
1312 let serve = move |decision: Decision, _request: Request| {
1313 let started = started.clone();
1314 async move {
1315 if decision.selected_model_id() == "loser" {
1316 started.notify_one();
1317 match loser_delay {
1318 Some(delay) => tokio::time::sleep(delay).await,
1319 None => std::future::pending::<()>().await,
1320 }
1321 } else {
1322 started.notified().await;
1323 }
1324 Ok(reply(decision.selected_model_id()))
1325 }
1326 };
1327 (algo, serve)
1328 }
1329
1330 #[tokio::test]
1331 async fn run_returns_the_winner_without_a_late_loser_overwriting_it() -> Result<()> {
1332 let (algo, serve) = hedge(Some(std::time::Duration::from_millis(50)));
1335 let (_trace, response) = test_drive(algo, request(), serve).await?;
1336 assert_eq!(
1337 response
1338 .llm_response
1339 .as_agg()
1340 .map(completion_text)
1341 .unwrap_or_default(),
1342 "winner"
1343 );
1344 Ok(())
1345 }
1346
1347 #[tokio::test]
1348 async fn run_returns_the_winner_without_hanging_on_a_pending_loser() -> Result<()> {
1349 let (algo, serve) = hedge(None);
1352 let run = test_drive(algo, request(), serve);
1353 let (_trace, response) = tokio::time::timeout(std::time::Duration::from_secs(1), run)
1354 .await
1355 .map_err(|error| LibsyError::external("waiting for pending loser", error))??;
1356 assert_eq!(
1357 response
1358 .llm_response
1359 .as_agg()
1360 .map(completion_text)
1361 .unwrap_or_default(),
1362 "winner"
1363 );
1364 Ok(())
1365 }
1366
1367 #[tokio::test]
1368 async fn run_surfaces_a_terminal_error_with_many_calls_in_flight() -> Result<()> {
1369 use std::sync::atomic::{AtomicUsize, Ordering};
1370
1371 const N: usize = 10;
1374
1375 struct FanOutThenError {
1378 all_started: Arc<tokio::sync::Notify>,
1379 n: usize,
1380 }
1381
1382 #[async_trait]
1383 impl Algorithm for FanOutThenError {
1384 fn name(&self) -> &str {
1385 "fan_out_then_error"
1386 }
1387
1388 async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<Response> {
1389 let offloads = futures::future::join_all((0..self.n).map(|i| {
1390 let decision = test_decision(format!("m{i}").into());
1391 driver.call_model(request.clone(), decision)
1392 }));
1393 tokio::select! {
1394 _ = offloads => Err(test_error("offloads unexpectedly completed")),
1395 _ = self.all_started.notified() => {
1396 Err(test_error("terminal error while calls pending"))
1397 }
1398 }
1399 }
1400 }
1401
1402 let all_started = Arc::new(tokio::sync::Notify::new());
1403 let algo: Arc<dyn Algorithm> = Arc::new(FanOutThenError {
1404 all_started: all_started.clone(),
1405 n: N,
1406 });
1407
1408 let started = Arc::new(AtomicUsize::new(0));
1410 let serve = move |_decision: Decision, _request: Request| {
1411 let started = started.clone();
1412 let all_started = all_started.clone();
1413 async move {
1414 if started.fetch_add(1, Ordering::SeqCst) + 1 == N {
1415 all_started.notify_one();
1416 }
1417 std::future::pending::<ServeResult>().await
1418 }
1419 };
1420
1421 let run = test_drive(algo, request(), serve);
1424 let result = tokio::time::timeout(std::time::Duration::from_millis(500), run)
1425 .await
1426 .map_err(|error| {
1427 LibsyError::external("waiting for terminal error with full call cap", error)
1428 })?;
1429 match result {
1430 Ok(_) => Err(test_error("expected the terminal error, got a response")),
1431 Err(err) => {
1432 assert!(
1433 err.to_string()
1434 .contains("terminal error while calls pending")
1435 );
1436 Ok(())
1437 }
1438 }
1439 }
1440}