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::{
28 Category, DecisionRequest, DecisionResponse, ModelId, Request, Response,
29};
30
31use crate::{DriverError, LibsyError, Result, observability};
32
33pub type StepStream = Pin<Box<dyn Stream<Item = Result<Step>> + Send>>;
37
38#[derive(Clone, Debug, Default)]
49pub struct RuntimeModels {
50 by_category: HashMap<Category, Vec<ModelId>>,
52 subagent: Option<HashMap<Category, Vec<ModelId>>>,
53}
54
55impl RuntimeModels {
56 pub fn new(by_category: HashMap<Category, Vec<ModelId>>) -> Self {
58 Self {
59 by_category,
60 subagent: None,
61 }
62 }
63
64 pub fn with_subagent(mut self, models: HashMap<Category, Vec<ModelId>>) -> Self {
66 self.subagent = Some(models);
67 self
68 }
69
70 pub fn models_for(&self, category: &Category) -> &[ModelId] {
72 self.by_category.get(category).map_or(&[], Vec::as_slice)
73 }
74
75 pub fn subagent_models_for(&self, category: &Category) -> &[ModelId] {
77 self.subagent
78 .as_ref()
79 .and_then(|models| models.get(category))
80 .map_or(&[], Vec::as_slice)
81 }
82}
83
84impl From<HashMap<Category, Vec<ModelId>>> for RuntimeModels {
85 fn from(by_category: HashMap<Category, Vec<ModelId>>) -> Self {
86 Self::new(by_category)
87 }
88}
89
90#[derive(Clone, Copy)]
92enum Scope {
93 Parent,
94 Subagent,
95}
96
97pub struct CallModel {
107 pub algorithm: String,
110 pub request: Request,
112 pub models: Vec<ModelId>,
114 pub recover_errors: bool,
116 reply: Option<oneshot::Sender<Result<Response>>>,
118 started: Instant,
119 span: tracing::Span,
121}
122
123impl CallModel {
124 pub fn respond(mut self, result: Result<Response>) -> Result<()> {
128 self.record(result.is_ok());
129 if let Ok(response) = &result {
130 observability::record_llm_response(response, &self.span);
131 }
132 self.reply
133 .take()
134 .ok_or(DriverError::ResponseDropped)?
135 .send(result)
136 .map_err(|_| DriverError::ResponseDropped.into())
137 }
138
139 pub fn fail(mut self, error: LibsyError) -> Result<()> {
142 self.reply = None;
143 self.record(false);
144 Err(error)
145 }
146
147 fn record(&self, is_ok: bool) {
148 observability::record_llm_call(
149 &self.algorithm,
150 self.models
151 .first()
152 .map(ModelId::as_str)
153 .unwrap_or("NoTargets"),
154 self.started.elapsed(),
155 is_ok,
156 );
157 self.span
158 .record("outcome", if is_ok { "ok" } else { "error" });
159 }
160}
161
162impl Drop for CallModel {
163 fn drop(&mut self) {
164 if self.reply.is_some() {
165 self.record(false);
166 }
167 }
168}
169
170pub struct CallDecision {
173 pub algorithm: String,
175 pub request: DecisionRequest,
177 pub model: ModelId,
179 reply: Option<oneshot::Sender<Result<DecisionResponse>>>,
180 started: Instant,
181 span: tracing::Span,
183}
184
185impl CallDecision {
186 pub fn respond(mut self, result: Result<DecisionResponse>) -> Result<()> {
188 self.record(result.is_ok());
189 if let Ok(response) = &result {
190 observability::record_decision_response(response, &self.span);
191 }
192 self.reply
193 .take()
194 .ok_or(DriverError::ResponseDropped)?
195 .send(result)
196 .map_err(|_| DriverError::ResponseDropped.into())
197 }
198
199 pub fn fail(mut self, error: LibsyError) -> Result<()> {
201 self.reply = None;
202 self.record(false);
203 Err(error)
204 }
205
206 fn record(&self, is_ok: bool) {
207 observability::record_decision_call(
208 &self.algorithm,
209 &self.model,
210 self.started.elapsed(),
211 is_ok,
212 );
213 self.span
214 .record("outcome", if is_ok { "ok" } else { "error" });
215 }
216}
217
218impl Drop for CallDecision {
219 fn drop(&mut self) {
220 if self.reply.is_some() {
221 self.record(false);
222 }
223 }
224}
225
226pub struct RoutingOutcome {
228 pub selected_model_ids: Vec<ModelId>,
230 pub request: Request,
232 pub response: Option<Response>,
234 pub metadata: Option<crate::OutcomeMetadata>,
239}
240
241impl RoutingOutcome {
242 pub fn selected_model_id(&self) -> Result<&ModelId> {
245 self.selected_model_ids.first().ok_or(LibsyError::NoTargets)
246 }
247
248 pub fn route_to(
252 selected_model_id: ModelId,
253 fallback_models: Vec<ModelId>,
254 mut request: Request,
255 ) -> Self {
256 request.llm_request.model = Some(selected_model_id.to_string());
257 let mut selected_model_ids = Vec::with_capacity(1 + fallback_models.len());
258 selected_model_ids.push(selected_model_id);
259 selected_model_ids.extend(fallback_models);
260 Self {
261 selected_model_ids,
262 request,
263 response: None,
264 metadata: None,
265 }
266 }
267
268 pub fn answered(selected_model_id: ModelId, mut request: Request, response: Response) -> Self {
271 request.llm_request.model = Some(selected_model_id.to_string());
272 Self {
273 selected_model_ids: vec![selected_model_id],
274 request,
275 response: Some(response),
276 metadata: None,
277 }
278 }
279}
280
281#[derive(Clone)]
283pub struct Driver {
284 step_tx: mpsc::Sender<Result<Step>>,
285
286 algorithm: String,
288
289 evidence: Arc<Mutex<Option<Value>>>,
291
292 models: Arc<RuntimeModels>,
294
295 scope: Scope,
297}
298
299impl Driver {
300 pub(crate) fn new(
303 algorithm: &str,
304 models: Arc<RuntimeModels>,
305 ) -> (Self, mpsc::Receiver<Result<Step>>) {
306 let (step_tx, step_rx) = mpsc::channel(1);
311 (
312 Self {
313 step_tx,
314 algorithm: algorithm.to_string(),
315 evidence: Arc::new(Mutex::new(None)),
316 models,
317 scope: Scope::Parent,
318 },
319 step_rx,
320 )
321 }
322
323 pub(crate) fn set_evidence(&self, evidence: Value) {
325 *self.evidence.lock() = Some(evidence);
326 }
327
328 pub(crate) fn set_evidence_if_empty(&self, evidence: Value) {
330 let mut current = self.evidence.lock();
331 if current.is_none() {
332 *current = Some(evidence);
333 }
334 }
335
336 pub async fn call_model(&self, request: Request, models: Vec<ModelId>) -> Result<Response> {
346 self.call_model_with_error_recovery(request, models, false)
347 .await
348 }
349
350 #[tracing::instrument(
352 target = "libsy",
353 name = "libsy.llm_call",
354 skip_all,
355 fields(
356 algorithm = self.algorithm,
357 selected_model = %models.first().map(ModelId::as_str).unwrap_or("NoTargets"),
358 openinference.span.kind = "CHAIN",
359 outcome = tracing::field::Empty,
360 input_tokens = tracing::field::Empty,
361 output_tokens = tracing::field::Empty,
362 total_tokens = tracing::field::Empty,
363 reasoning_tokens = tracing::field::Empty,
364 )
365 )]
366 pub(crate) async fn call_model_with_error_recovery(
367 &self,
368 mut request: Request,
369 models: Vec<ModelId>,
370 recover_errors: bool,
371 ) -> Result<Response> {
372 let Some(selected_model_id) = models.first() else {
373 return Err(LibsyError::NoTargets);
374 };
375 request.llm_request.model = Some(selected_model_id.to_string());
376 let started = Instant::now();
377 let (reply, response) = oneshot::channel::<Result<Response>>();
378 let call = CallModel {
379 algorithm: self.algorithm.clone(),
380 request,
381 models,
382 recover_errors,
383 reply: Some(reply),
384 started,
385 span: tracing::Span::current(),
386 };
387 self.step_tx
388 .send(Ok(Step::CallModel(Box::new(call))))
389 .await
390 .map_err(|_| DriverError::StreamClosed)?;
391 response
392 .await
393 .map_err(|_| LibsyError::from(DriverError::ResponseDropped))?
394 }
395
396 #[tracing::instrument(
398 target = "libsy",
399 name = "libsy.decision_call",
400 skip_all,
401 fields(
402 algorithm = self.algorithm,
403 selected_model = %model,
404 openinference.span.kind = "CHAIN",
405 outcome = tracing::field::Empty,
406 input_tokens = tracing::field::Empty,
407 output_tokens = tracing::field::Empty,
408 total_tokens = tracing::field::Empty,
409 reasoning_tokens = tracing::field::Empty,
410 gen_ai.response.id = tracing::field::Empty,
411 gen_ai.response.model = tracing::field::Empty,
412 ),
413 )]
414 pub async fn call_decision(
415 &self,
416 mut request: DecisionRequest,
417 model: ModelId,
418 ) -> Result<DecisionResponse> {
419 request.model = Some(model.clone());
420 let started = Instant::now();
421 let (reply, response) = oneshot::channel();
422 let call = CallDecision {
423 algorithm: self.algorithm.clone(),
424 request,
425 model,
426 reply: Some(reply),
427 started,
428 span: tracing::Span::current(),
429 };
430 self.step_tx
431 .send(Ok(Step::CallDecision(Box::new(call))))
432 .await
433 .map_err(|_| DriverError::StreamClosed)?;
434 response
435 .await
436 .map_err(|_| LibsyError::from(DriverError::ResponseDropped))?
437 }
438
439 pub fn models_for(&self, category: &Category) -> &[ModelId] {
441 match self.scope {
442 Scope::Parent => self.models.models_for(category),
443 Scope::Subagent => self.models.subagent_models_for(category),
444 }
445 }
446
447 pub fn first_model_for(&self, category: &Category) -> Result<&ModelId> {
449 self.models_for(category)
450 .first()
451 .ok_or_else(|| LibsyError::AlgorithmError {
452 message: format!("no models available for category {}", category.as_str()),
453 })
454 }
455
456 pub fn for_subagent(&self) -> Result<Self> {
459 if self.models.subagent.is_none() {
460 return Err(LibsyError::AlgorithmError {
461 message: "delegated work has no sub-agent models".to_string(),
462 });
463 }
464 Ok(Self {
465 scope: Scope::Subagent,
466 ..self.clone()
467 })
468 }
469
470 pub(crate) async fn finish(&self, result: Result<RoutingOutcome>) -> Result<()> {
474 let result = result.map(|mut outcome| {
475 let metadata = outcome.metadata.get_or_insert_with(|| {
476 crate::OutcomeMetadata::new(self.algorithm.clone(), self.evidence.lock().take())
477 });
478 observability::record_outcome(metadata, &outcome.selected_model_ids);
479 outcome
480 });
481 let selected_model = result
482 .as_ref()
483 .ok()
484 .and_then(|outcome| outcome.selected_model_id().ok().cloned());
485 let step = result.map(|outcome| Step::Done(Box::new(outcome)));
486 self.step_tx
487 .send(step)
488 .await
489 .map_err(|_| DriverError::StreamClosed)?;
490 if let Some(selected_model) = selected_model {
491 observability::record_decision(&self.algorithm, &selected_model);
492 }
493 Ok(())
494 }
495}
496
497pub enum Step {
500 CallModel(Box<CallModel>),
503 CallDecision(Box<CallDecision>),
505 Done(Box<RoutingOutcome>),
507}
508
509pub enum Call {
512 Model(Box<CallModel>),
514 Decision(Box<CallDecision>),
516}
517
518pub async fn drive<F, Fut>(
528 algorithm: Arc<dyn Algorithm>,
529 request: Request,
530 models: Arc<RuntimeModels>,
531 serve: F,
532) -> Result<RoutingOutcome>
533where
534 F: Fn(Call) -> Fut,
535 Fut: Future<Output = Result<()>>,
536{
537 let stream = algorithm.run_stream(request, models);
538 tokio::pin!(stream);
539
540 let mut in_flight = futures::stream::FuturesUnordered::new();
541 let mut final_outcome: Option<RoutingOutcome> = None;
542
543 loop {
544 tokio::select! {
545 Some(result) = in_flight.next() => match result {
546 Ok(()) => {}, Err(err) => return Err(err), },
549 step = stream.next() => {
550 match step {
551 None => break, Some(item) => match item? {
553 Step::CallModel(call) => in_flight.push(serve(Call::Model(call))),
554 Step::CallDecision(call) => {
555 in_flight.push(serve(Call::Decision(call)));
556 }
557 Step::Done(outcome) => {
558 final_outcome = Some(*outcome);
559 break;
560 }
561 }
562 }
563 },
564 }
565 }
566 final_outcome.ok_or(LibsyError::MissingFinalResponse)
567}
568
569fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
571 payload
572 .downcast_ref::<&'static str>()
573 .map(|message| (*message).to_string())
574 .or_else(|| payload.downcast_ref::<String>().cloned())
575 .unwrap_or_else(|| "unknown panic payload".to_string())
576}
577
578struct AbortOnDrop(tokio::task::AbortHandle);
580
581impl Drop for AbortOnDrop {
582 fn drop(&mut self) {
583 self.0.abort();
584 }
585}
586
587pub(crate) fn ensure_model_is_target(targets: &[ModelId], name: &ModelId) -> Result<()> {
592 targets
593 .iter()
594 .any(|target| target == name)
595 .then_some(())
596 .ok_or_else(|| LibsyError::TargetNotFound {
597 target: name.clone(),
598 })
599}
600
601#[derive(Clone, Hash, PartialEq, Eq)]
604pub(crate) enum RoutingIdentity {
605 Session(String),
607 Subagent { session: String, agent: String },
609}
610
611impl RoutingIdentity {
612 pub(crate) fn from_request(request: &Request) -> Option<Self> {
617 let metadata = request.metadata.as_ref()?;
618 let session = metadata.session_id.as_deref().filter(|id| !id.is_empty())?;
619 if metadata.is_subagent {
620 let agent = metadata.agent_id.as_deref().filter(|id| !id.is_empty())?;
621 Some(Self::Subagent {
622 session: session.to_string(),
623 agent: agent.to_string(),
624 })
625 } else {
626 Some(Self::Session(session.to_string()))
627 }
628 }
629}
630
631#[async_trait]
666pub trait Algorithm: Send + Sync + 'static {
667 fn name(&self) -> &str;
671
672 async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<RoutingOutcome>;
675
676 fn run_stream(self: Arc<Self>, request: Request, models: Arc<RuntimeModels>) -> StepStream {
684 let (driver, step_rx) = Driver::new(self.name(), models);
685 let span = observability::run_span(self.name(), &request);
686 let handle = tokio::spawn(
687 async move {
688 let algorithm = self.name().to_string();
689 let route = AssertUnwindSafe(self.route(driver.clone(), request)).catch_unwind();
691 let result = observability::observe_run(&algorithm, async move {
692 route.await.unwrap_or_else(|payload| {
693 Err(LibsyError::AlgorithmError {
694 message: format!(
695 "algorithm task panicked: {}",
696 panic_message(payload.as_ref())
697 ),
698 })
699 })
700 })
701 .await;
702
703 let _ = driver.finish(result).await;
704 }
705 .instrument(span),
706 );
707 let abort_guard = AbortOnDrop(handle.abort_handle());
709 Box::pin(ReceiverStream::new(step_rx).map(move |step| {
710 let _keep_alive = &abort_guard;
712 step
713 }))
714 }
715}
716
717#[cfg(test)]
718mod tests {
719 use std::collections::HashMap;
720
721 use super::*;
722 use crate::core::testing::{Serve, ServeResult, echo, reply, serve_decision, test_drive};
723 use futures::StreamExt;
724 use switchyard_protocol::{
725 LlmResponse, LlmResponseChunk, completion_text, text_request, text_response,
726 };
727
728 #[derive(Debug, thiserror::Error)]
729 #[error("{0}")]
730 struct TestError(&'static str);
731
732 fn test_error(message: &'static str) -> LibsyError {
733 LibsyError::external("test", TestError(message))
734 }
735
736 struct TestAlgo {
739 target_set: Vec<ModelId>,
740 }
741
742 #[async_trait]
743 impl Algorithm for TestAlgo {
744 fn name(&self) -> &str {
745 "test"
746 }
747
748 async fn route(
749 self: Arc<Self>,
750 driver: Driver,
751 request: Request,
752 ) -> Result<RoutingOutcome> {
753 let target = self
754 .target_set
755 .first()
756 .ok_or(LibsyError::NoTargets)?
757 .clone();
758 let response = driver
759 .call_model(request.clone(), vec![target.clone()])
760 .await?;
761 driver.set_evidence(serde_json::json!({"source": "test"}));
762 driver.set_evidence_if_empty(serde_json::json!({"source": "ignored"}));
763 Ok(RoutingOutcome::answered(target, request, response))
764 }
765 }
766
767 fn orch(target_set: Vec<ModelId>) -> Arc<dyn Algorithm> {
769 Arc::new(TestAlgo { target_set })
770 }
771
772 fn request() -> Request {
773 Request {
774 llm_request: text_request(Some("auto".to_string()), "hi".to_string()),
775 raw_request: None,
776 metadata: None,
777 }
778 }
779
780 #[test]
781 fn routing_outcome_constructors_stamp_selection_and_preserve_payloads() {
782 let outcome = RoutingOutcome::route_to(
783 "selected".into(),
784 target_set(&["fallback-one", "fallback-two"]),
785 request(),
786 );
787
788 assert_eq!(
789 outcome.selected_model_ids,
790 target_set(&["selected", "fallback-one", "fallback-two"])
791 );
792 assert_eq!(outcome.request.model_id().as_deref(), Some("selected"));
793 assert!(outcome.response.is_none());
794 assert!(outcome.metadata.is_none());
795
796 let outcome = RoutingOutcome::route_to("only".into(), Vec::new(), request());
797 assert_eq!(outcome.selected_model_ids, target_set(&["only"]));
798
799 let outcome = RoutingOutcome::answered(
800 "answered".into(),
801 request(),
802 Response {
803 llm_response: LlmResponse::Agg(text_response(None, "existing")),
804 metadata: None,
805 upstream_headers: http::HeaderMap::new(),
806 },
807 );
808
809 assert_eq!(outcome.selected_model_ids, target_set(&["answered"]));
810 assert_eq!(outcome.request.model_id().as_deref(), Some("answered"));
811 assert_eq!(
812 outcome
813 .response
814 .as_ref()
815 .and_then(|response| response.llm_response.as_agg())
816 .map(completion_text),
817 Some("existing".to_string())
818 );
819 }
820
821 fn target_set(names: &[&str]) -> Vec<ModelId> {
822 names.iter().map(|name| ModelId::from(*name)).collect()
823 }
824
825 #[tokio::test]
826 async fn typed_driver_preserves_call_and_stream_boundaries() -> Result<()> {
827 tokio::time::timeout(std::time::Duration::from_secs(1), async {
828 let (driver, mut step_rx) = Driver::new("test", Arc::new(RuntimeModels::default()));
831 let first_driver = driver.clone();
832 let mut first = tokio::spawn(async move {
833 first_driver
834 .call_model(request(), vec![ModelId::from("first")])
835 .await
836 });
837 let second = tokio::spawn(async move {
838 driver
839 .call_model(request(), vec![ModelId::from("second")])
840 .await
841 });
842
843 let mut calls = HashMap::new();
844 for _ in 0..2 {
845 let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
846 let Step::CallModel(call) = step else {
847 return Err(test_error("expected a CallModel step"));
848 };
849 let selected_model = call
850 .models
851 .first()
852 .ok_or_else(|| test_error("model call has no candidates"))?
853 .to_string();
854 calls.insert(selected_model, call);
855 }
856 assert!(
857 tokio::time::timeout(std::time::Duration::from_millis(20), &mut first)
858 .await
859 .is_err(),
860 "call completed before the host responded"
861 );
862 calls
863 .remove("second")
864 .ok_or_else(|| test_error("missing second call"))?
865 .respond(Ok(reply("second response")))?;
866 calls
867 .remove("first")
868 .ok_or_else(|| test_error("missing first call"))?
869 .respond(Ok(reply("first response")))?;
870
871 let first_response = first
872 .await
873 .map_err(|source| LibsyError::external("joining a test task", source))??;
874 let second_response = second
875 .await
876 .map_err(|source| LibsyError::external("joining a test task", source))??;
877 assert_eq!(
878 first_response.llm_response.as_agg().map(completion_text),
879 Some("first response".to_string())
880 );
881 assert_eq!(
882 second_response.llm_response.as_agg().map(completion_text),
883 Some("second response".to_string())
884 );
885
886 let (driver, mut step_rx) = Driver::new("test", Arc::new(RuntimeModels::default()));
888 let producer = tokio::spawn(async move {
889 driver
890 .call_model(request(), vec![ModelId::from("dropped")])
891 .await
892 });
893 let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
894 let Step::CallModel(call) = step else {
895 return Err(test_error("expected a CallModel step"));
896 };
897 drop(call);
898 let result = producer
899 .await
900 .map_err(|source| LibsyError::external("joining a test task", source))?;
901 assert!(matches!(
902 result,
903 Err(LibsyError::Driver(DriverError::ResponseDropped))
904 ));
905
906 let (driver, step_rx) = Driver::new("test", Arc::new(RuntimeModels::default()));
908 drop(step_rx);
909 let result = driver
910 .call_model(request(), vec![ModelId::from("closed")])
911 .await;
912 assert!(matches!(
913 result,
914 Err(LibsyError::Driver(DriverError::StreamClosed))
915 ));
916
917 fn decision_request() -> DecisionRequest {
918 DecisionRequest {
919 model: Some("overwritten".into()),
920 context: serde_json::json!({"task": "choose a route"}),
921 images: Vec::new(),
922 questions: Default::default(),
923 }
924 }
925
926 fn decision_response() -> DecisionResponse {
927 DecisionResponse {
928 id: Some("decision-1".to_string()),
929 model: Some("provider-model".into()),
930 answers: [(
931 "p_solve".to_string(),
932 switchyard_protocol::DecisionAnswer {
933 value: switchyard_protocol::DecisionValue::Boolean(
934 switchyard_protocol::BooleanEstimate::ProbabilityTrue(
935 switchyard_protocol::Probability(0.8),
936 ),
937 ),
938 provider_confidence: None,
939 },
940 )]
941 .into(),
942 usage: Default::default(),
943 }
944 }
945
946 struct MixedCalls(&'static str);
947
948 #[async_trait]
949 impl Algorithm for MixedCalls {
950 fn name(&self) -> &str {
951 "mixed"
952 }
953
954 async fn route(
955 self: Arc<Self>,
956 driver: Driver,
957 request: Request,
958 ) -> Result<RoutingOutcome> {
959 let (llm, decision) = tokio::join!(
960 driver.call_model(request.clone(), vec!["llm".into()]),
961 driver.call_decision(decision_request(), "decision".into()),
962 );
963 assert_eq!(
964 llm?.llm_response.as_agg().map(completion_text),
965 Some("llm reply".into())
966 );
967 match self.0 {
968 "mock" => assert_eq!(decision?, DecisionResponse {
969 id: None,
970 model: Some("decision".into()),
971 answers: Default::default(),
972 usage: Default::default(),
973 }),
974 "reply" => assert_eq!(decision?, decision_response()),
975 "error" => assert!(matches!(
976 decision,
977 Err(LibsyError::AlgorithmError { message }) if message == "provider failed"
978 )),
979 "drop" => assert!(matches!(
980 decision,
981 Err(LibsyError::Driver(DriverError::ResponseDropped))
982 )),
983 "abort" => return std::future::pending().await,
984 _ => unreachable!(),
985 }
986 Ok(RoutingOutcome::route_to("answer".into(), vec![], request))
987 }
988 }
989
990 for mode in ["mock", "reply", "error", "drop", "abort"] {
991 let barrier = Arc::new(tokio::sync::Barrier::new(2));
993 let outcome = drive(
994 Arc::new(MixedCalls(mode)),
995 request(),
996 Arc::new(RuntimeModels::default()),
997 move |call| {
998 let barrier = barrier.clone();
999 async move {
1000 barrier.wait().await;
1001 let call = match call {
1002 Call::Model(call) => return call.respond(Ok(reply("llm reply"))),
1003 Call::Decision(call) => *call,
1004 };
1005 assert_eq!(call.algorithm, "mixed");
1006 assert_eq!(call.model, "decision");
1007 assert_eq!(call.request.model, Some("decision".into()));
1008 assert_eq!(call.request.context, decision_request().context);
1009 match mode {
1010 "mock" => serve_decision(call).await,
1011 "reply" => call.respond(Ok(decision_response())),
1012 "error" => call.respond(Err(LibsyError::AlgorithmError {
1013 message: "provider failed".into(),
1014 })),
1015 "drop" => {
1016 drop(call);
1017 Ok(())
1018 }
1019 "abort" => call.fail(test_error("host aborted")),
1020 _ => unreachable!(),
1021 }
1022 }
1023 },
1024 )
1025 .await;
1026 if mode == "abort" {
1027 assert!(matches!(outcome, Err(LibsyError::External { .. })));
1028 } else {
1029 assert_eq!(outcome?.selected_model_id()?, "answer");
1030 }
1031 }
1032
1033 let (driver, mut steps) = Driver::new("test", Arc::new(RuntimeModels::default()));
1034 let mut pending = Box::pin(driver.call_decision(decision_request(), "decision".into()));
1035 assert!(futures::poll!(&mut pending).is_pending());
1036 let Some(Ok(Step::CallDecision(call))) = steps.recv().await else {
1037 return Err(test_error("expected a decision call"));
1038 };
1039 drop(pending);
1040 assert!(matches!(
1041 call.respond(Ok(decision_response())),
1042 Err(LibsyError::Driver(DriverError::ResponseDropped))
1043 ));
1044 drop(steps);
1045 assert!(matches!(
1046 driver.call_decision(decision_request(), "decision".into()).await,
1047 Err(LibsyError::Driver(DriverError::StreamClosed))
1048 ));
1049 Ok(())
1050 })
1051 .await
1052 .map_err(|error| LibsyError::external("waiting for typed driver boundaries", error))?
1053 }
1054
1055 #[test]
1056 fn target_lookup_returns_the_missing_target() {
1057 let error = ensure_model_is_target(&target_set(&[]), &ModelId::from("missing")).err();
1058 assert!(matches!(
1059 error,
1060 Some(LibsyError::TargetNotFound { target }) if target == "missing"
1061 ));
1062 }
1063
1064 fn streaming_orch(chunks: Vec<LlmResponseChunk>) -> (Arc<dyn Algorithm>, impl Serve) {
1067 let algo = orch(target_set(&["stream/model"]));
1068 let serve = move |_target: ModelId, _request: Request| {
1069 let chunks = chunks.clone();
1070 async move {
1071 let stream =
1072 futures::stream::iter(chunks.into_iter().map(|chunk| Ok(chunk.into()))).boxed();
1073 Ok(Response {
1074 llm_response: LlmResponse::Stream(stream),
1075 metadata: None,
1076 upstream_headers: http::HeaderMap::new(),
1077 })
1078 }
1079 };
1080 (algo, serve)
1081 }
1082
1083 #[tokio::test]
1084 async fn run_returns_a_streamed_response_the_caller_aggregates() -> Result<()> {
1085 let (orch, serve) = streaming_orch(vec![
1088 LlmResponseChunk::MessageStart {
1089 id: Some("m1".to_string()),
1090 model: Some("stream/model".to_string()),
1091 },
1092 LlmResponseChunk::TextDelta {
1093 index: 0,
1094 text: "hel".to_string(),
1095 },
1096 LlmResponseChunk::TextDelta {
1097 index: 0,
1098 text: "lo".to_string(),
1099 },
1100 LlmResponseChunk::MessageStop {
1101 reason: Some("stop".to_string()),
1102 },
1103 ]);
1104 let (selected_model, response) = test_drive(orch, request(), serve).await?;
1105 let agg = response
1107 .llm_response
1108 .into_agg()
1109 .await
1110 .map_err(|error| LibsyError::external("aggregating response stream", error))?;
1111 assert_eq!(completion_text(&agg), "hello");
1112 assert_eq!(agg.model.as_deref(), Some("stream/model"));
1113 assert_eq!(selected_model, "stream/model");
1114 Ok(())
1115 }
1116
1117 #[tokio::test]
1118 async fn aggregating_a_streamed_response_propagates_a_mid_stream_error() -> Result<()> {
1119 let (orch, serve) = streaming_orch(vec![
1122 LlmResponseChunk::TextDelta {
1123 index: 0,
1124 text: "partial".to_string(),
1125 },
1126 LlmResponseChunk::StreamError {
1127 message: "upstream exploded".to_string(),
1128 },
1129 ]);
1130 let (_, response) = test_drive(orch, request(), serve).await?;
1131 match response.llm_response.into_agg().await {
1132 Ok(_) => panic!("expected a mid-stream error, got an aggregate"),
1133 Err(err) => {
1134 assert!(err.to_string().contains("upstream exploded"));
1135 Ok(())
1136 }
1137 }
1138 }
1139
1140 #[tokio::test]
1141 async fn run_offloads_via_promise_then_finishes() -> Result<()> {
1142 let stream = orch(target_set(&["offload/model"]))
1145 .run_stream(request(), Arc::new(RuntimeModels::default()));
1146 tokio::pin!(stream);
1147
1148 let mut saw_call = false;
1149 let mut final_completion = None;
1150 while let Some(step) = stream.next().await {
1151 match step? {
1152 Step::CallDecision(_) => return Err(test_error("unexpected decision call")),
1153 Step::CallModel(call) => {
1154 saw_call = true;
1155 assert_eq!(call.models, vec![ModelId::from("offload/model")]);
1156 call.respond(Ok(Response {
1158 llm_response: LlmResponse::Agg(text_response(
1159 None,
1160 "fulfilled".to_string(),
1161 )),
1162 metadata: None,
1163 upstream_headers: http::HeaderMap::new(),
1164 }))?;
1165 }
1166 Step::Done(outcome) => {
1167 let metadata = outcome
1168 .metadata
1169 .as_ref()
1170 .expect("run_stream should attach outcome metadata");
1171 assert_eq!(metadata.algorithm, "test");
1172 assert_eq!(
1173 uuid::Uuid::parse_str(metadata.outcome_id())
1174 .expect("outcome id should be a UUID")
1175 .get_version_num(),
1176 7
1177 );
1178 assert_eq!(
1179 metadata.evidence,
1180 Some(serde_json::json!({"source": "test"}))
1181 );
1182 let response = outcome
1183 .response
1184 .ok_or_else(|| test_error("expected an answered outcome"))?;
1185 final_completion = Some(
1186 response
1187 .llm_response
1188 .as_agg()
1189 .map(completion_text)
1190 .unwrap_or_default(),
1191 );
1192 }
1193 }
1194 }
1195
1196 assert!(saw_call, "expected a CallModel step before Done");
1197 assert_eq!(
1198 final_completion.ok_or_else(|| test_error("no Done step"))?,
1199 "fulfilled"
1200 );
1201 Ok(())
1202 }
1203
1204 #[tokio::test(flavor = "multi_thread", worker_threads = 12)]
1205 async fn requests_are_processed_in_parallel() -> Result<()> {
1206 use std::time::Duration;
1207 use tokio::sync::Barrier;
1208
1209 const N: usize = 12;
1210
1211 let barrier = Arc::new(Barrier::new(N));
1216 let algo = orch(target_set(&["m"]));
1218
1219 let mut handles = Vec::new();
1220 for _ in 0..N {
1221 let algo = algo.clone();
1222 let barrier = barrier.clone();
1223 let serve = move |target: ModelId, _request: Request| {
1224 let barrier = barrier.clone();
1225 async move {
1226 barrier.wait().await;
1227 Ok(reply(target))
1228 }
1229 };
1230 handles.push(tokio::spawn(async move {
1231 test_drive(algo, request(), serve)
1232 .await
1233 .map(|(_, response)| {
1234 response
1235 .llm_response
1236 .as_agg()
1237 .map(completion_text)
1238 .unwrap_or_default()
1239 })
1240 }));
1241 }
1242
1243 for handle in handles {
1244 let completion = tokio::time::timeout(Duration::from_secs(5), handle)
1246 .await
1247 .map_err(|error| LibsyError::external("waiting for test task", error))?
1248 .map_err(|source| LibsyError::external("joining a test task", source))??;
1249 assert_eq!(completion, "m");
1250 }
1251 Ok(())
1252 }
1253
1254 #[tokio::test]
1255 async fn offload_error_propagates_back_to_the_algorithm() -> Result<()> {
1256 let stream = orch(target_set(&["offload/model"]))
1260 .run_stream(request(), Arc::new(RuntimeModels::default()));
1261 tokio::pin!(stream);
1262
1263 let mut saw_error = false;
1264 while let Some(step) = stream.next().await {
1265 match step {
1266 Ok(Step::CallDecision(_)) => return Err(test_error("unexpected decision call")),
1267 Ok(Step::CallModel(call)) => {
1268 call.respond(Err(test_error("upstream model call failed")))?;
1269 }
1270 Ok(Step::Done(..)) => {
1271 return Err(test_error(
1272 "expected the offload error to propagate, got a response",
1273 ));
1274 }
1275 Err(err) => {
1276 assert!(err.to_string().contains("upstream model call failed"));
1278 saw_error = true;
1279 }
1280 }
1281 }
1282
1283 assert!(saw_error, "expected an error step");
1284 Ok(())
1285 }
1286
1287 #[tokio::test]
1288 async fn dropping_the_stream_cancels_the_algorithm_task() -> Result<()> {
1289 use std::sync::atomic::{AtomicBool, Ordering};
1290 use std::time::Duration;
1291 use tokio::sync::mpsc;
1292
1293 struct DropGuard(Arc<AtomicBool>);
1296 impl Drop for DropGuard {
1297 fn drop(&mut self) {
1298 self.0.store(true, Ordering::SeqCst);
1299 }
1300 }
1301
1302 struct StuckAlgo {
1303 started: mpsc::UnboundedSender<()>,
1304 dropped: Arc<AtomicBool>,
1305 }
1306
1307 #[async_trait]
1308 impl Algorithm for StuckAlgo {
1309 fn name(&self) -> &str {
1310 "stuck"
1311 }
1312
1313 async fn route(
1314 self: Arc<Self>,
1315 _driver: Driver,
1316 _request: Request,
1317 ) -> Result<RoutingOutcome> {
1318 let _guard = DropGuard(self.dropped.clone());
1319 let _ = self.started.send(());
1320 std::future::pending::<()>().await;
1322 unreachable!()
1323 }
1324 }
1325
1326 let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1327 let dropped = Arc::new(AtomicBool::new(false));
1328 let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1329 started: started_tx,
1330 dropped: dropped.clone(),
1331 });
1332
1333 let stream = algo.run_stream(request(), Arc::new(RuntimeModels::default()));
1334 started_rx
1335 .recv()
1336 .await
1337 .ok_or_else(|| test_error("task never started"))?;
1338 drop(stream);
1339 tokio::time::sleep(Duration::from_millis(100)).await;
1340
1341 assert!(
1342 dropped.load(Ordering::SeqCst),
1343 "algorithm task was NOT cancelled after dropping the stream"
1344 );
1345 Ok(())
1346 }
1347
1348 #[tokio::test]
1349 async fn route_panic_surfaces_as_a_stream_error() -> Result<()> {
1350 struct Panicky;
1353
1354 #[async_trait]
1355 impl Algorithm for Panicky {
1356 fn name(&self) -> &str {
1357 "panicky"
1358 }
1359
1360 async fn route(
1361 self: Arc<Self>,
1362 _driver: Driver,
1363 _request: Request,
1364 ) -> Result<RoutingOutcome> {
1365 panic!("boom");
1366 }
1367 }
1368
1369 let algo: Arc<dyn Algorithm> = Arc::new(Panicky);
1370 let stream = algo.run_stream(request(), Arc::new(RuntimeModels::default()));
1371 tokio::pin!(stream);
1372
1373 let mut saw_error = false;
1374 while let Some(step) = stream.next().await {
1375 match step {
1376 Err(err) => {
1377 assert!(err.to_string().contains("algorithm task panicked: boom"));
1379 saw_error = true;
1380 }
1381 Ok(_) => return Err(test_error("expected the panic to surface as an error step")),
1382 }
1383 }
1384
1385 assert!(saw_error, "expected an error step from the panicked task");
1386 Ok(())
1387 }
1388
1389 #[tokio::test]
1393 async fn a_panic_with_a_leaked_driver_clone_still_terminates_the_run() -> Result<()> {
1394 struct LeakyPanic;
1395
1396 #[async_trait]
1397 impl Algorithm for LeakyPanic {
1398 fn name(&self) -> &str {
1399 "leaky_panic"
1400 }
1401
1402 async fn route(
1403 self: Arc<Self>,
1404 driver: Driver,
1405 _request: Request,
1406 ) -> Result<RoutingOutcome> {
1407 tokio::spawn(async move {
1408 let _keep_alive = driver;
1410 std::future::pending::<()>().await;
1411 });
1412 tokio::task::yield_now().await;
1413 panic!("boom");
1414 }
1415 }
1416
1417 let algo: Arc<dyn Algorithm> = Arc::new(LeakyPanic);
1418 let result = tokio::time::timeout(
1420 std::time::Duration::from_secs(1),
1421 test_drive(algo, request(), echo()),
1422 )
1423 .await
1424 .map_err(|error| LibsyError::external("waiting for the panicked run to end", error))?;
1425
1426 match result {
1427 Ok(_) => Err(test_error(
1428 "expected the panic to end the run with an error",
1429 )),
1430 Err(err) => {
1431 assert!(err.to_string().contains("algorithm task panicked: boom"));
1432 Ok(())
1433 }
1434 }
1435 }
1436
1437 #[tokio::test]
1438 async fn cancelling_run_cancels_the_algorithm_task() -> Result<()> {
1439 use std::sync::atomic::{AtomicBool, Ordering};
1440 use std::time::Duration;
1441 use tokio::sync::mpsc;
1442
1443 struct DropGuard(Arc<AtomicBool>);
1446 impl Drop for DropGuard {
1447 fn drop(&mut self) {
1448 self.0.store(true, Ordering::SeqCst);
1449 }
1450 }
1451
1452 struct StuckAlgo {
1453 started: mpsc::UnboundedSender<()>,
1454 dropped: Arc<AtomicBool>,
1455 }
1456
1457 #[async_trait]
1458 impl Algorithm for StuckAlgo {
1459 fn name(&self) -> &str {
1460 "stuck"
1461 }
1462
1463 async fn route(
1464 self: Arc<Self>,
1465 _driver: Driver,
1466 _request: Request,
1467 ) -> Result<RoutingOutcome> {
1468 let _guard = DropGuard(self.dropped.clone());
1469 let _ = self.started.send(());
1470 std::future::pending::<()>().await;
1473 unreachable!()
1474 }
1475 }
1476
1477 let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1478 let dropped = Arc::new(AtomicBool::new(false));
1479 let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1480 started: started_tx,
1481 dropped: dropped.clone(),
1482 });
1483
1484 let run_task = tokio::spawn(async move { test_drive(algo, request(), echo()).await });
1487 started_rx
1488 .recv()
1489 .await
1490 .ok_or_else(|| test_error("task never started"))?;
1491 run_task.abort();
1492 tokio::time::sleep(Duration::from_millis(100)).await;
1493
1494 assert!(
1495 dropped.load(Ordering::SeqCst),
1496 "algorithm task was NOT cancelled after cancelling run"
1497 );
1498 Ok(())
1499 }
1500
1501 struct Hedge {
1506 winner: String,
1507 loser: String,
1508 }
1509
1510 #[async_trait]
1511 impl Algorithm for Hedge {
1512 fn name(&self) -> &str {
1513 "hedge"
1514 }
1515
1516 async fn route(
1517 self: Arc<Self>,
1518 driver: Driver,
1519 request: Request,
1520 ) -> Result<RoutingOutcome> {
1521 let outcome_request = request.clone();
1522 let win = driver.call_model(request.clone(), vec![self.winner.clone().into()]);
1523 let lose = driver.call_model(request, vec![self.loser.clone().into()]);
1524 tokio::select! {
1526 res = win => Ok(RoutingOutcome::answered(
1527 self.winner.clone().into(),
1528 outcome_request,
1529 res?,
1530 )),
1531 res = lose => Ok(RoutingOutcome::answered(
1532 self.loser.clone().into(),
1533 outcome_request,
1534 res?,
1535 )),
1536 }
1537 }
1538 }
1539
1540 fn hedge(loser_delay: Option<std::time::Duration>) -> (Arc<dyn Algorithm>, impl Serve) {
1544 let started = Arc::new(tokio::sync::Notify::new());
1545 let algo = Arc::new(Hedge {
1546 winner: "winner".to_string(),
1547 loser: "loser".to_string(),
1548 });
1549 let serve = move |target: ModelId, _request: Request| {
1550 let started = started.clone();
1551 async move {
1552 if target == "loser" {
1553 started.notify_one();
1554 match loser_delay {
1555 Some(delay) => tokio::time::sleep(delay).await,
1556 None => std::future::pending::<()>().await,
1557 }
1558 } else {
1559 started.notified().await;
1560 }
1561 Ok(reply(target))
1562 }
1563 };
1564 (algo, serve)
1565 }
1566
1567 #[tokio::test]
1568 async fn run_returns_the_winner_without_a_late_loser_overwriting_it() -> Result<()> {
1569 let (algo, serve) = hedge(Some(std::time::Duration::from_millis(50)));
1572 let (_, response) = test_drive(algo, request(), serve).await?;
1573 assert_eq!(
1574 response
1575 .llm_response
1576 .as_agg()
1577 .map(completion_text)
1578 .unwrap_or_default(),
1579 "winner"
1580 );
1581 Ok(())
1582 }
1583
1584 #[tokio::test]
1585 async fn run_returns_the_winner_without_hanging_on_a_pending_loser() -> Result<()> {
1586 let (algo, serve) = hedge(None);
1589 let run = test_drive(algo, request(), serve);
1590 let (_, response) = tokio::time::timeout(std::time::Duration::from_secs(1), run)
1591 .await
1592 .map_err(|error| LibsyError::external("waiting for pending loser", error))??;
1593 assert_eq!(
1594 response
1595 .llm_response
1596 .as_agg()
1597 .map(completion_text)
1598 .unwrap_or_default(),
1599 "winner"
1600 );
1601 Ok(())
1602 }
1603
1604 #[tokio::test]
1605 async fn run_surfaces_a_terminal_error_with_many_calls_in_flight() -> Result<()> {
1606 use std::sync::atomic::{AtomicUsize, Ordering};
1607
1608 const N: usize = 10;
1611
1612 struct FanOutThenError {
1615 all_started: Arc<tokio::sync::Notify>,
1616 n: usize,
1617 }
1618
1619 #[async_trait]
1620 impl Algorithm for FanOutThenError {
1621 fn name(&self) -> &str {
1622 "fan_out_then_error"
1623 }
1624
1625 async fn route(
1626 self: Arc<Self>,
1627 driver: Driver,
1628 request: Request,
1629 ) -> Result<RoutingOutcome> {
1630 let offloads = futures::future::join_all(
1631 (0..self.n)
1632 .map(|i| driver.call_model(request.clone(), vec![format!("m{i}").into()])),
1633 );
1634 tokio::select! {
1635 _ = offloads => Err(test_error("offloads unexpectedly completed")),
1636 _ = self.all_started.notified() => {
1637 Err(test_error("terminal error while calls pending"))
1638 }
1639 }
1640 }
1641 }
1642
1643 let all_started = Arc::new(tokio::sync::Notify::new());
1644 let algo: Arc<dyn Algorithm> = Arc::new(FanOutThenError {
1645 all_started: all_started.clone(),
1646 n: N,
1647 });
1648
1649 let started = Arc::new(AtomicUsize::new(0));
1651 let serve = move |_target: ModelId, _request: Request| {
1652 let started = started.clone();
1653 let all_started = all_started.clone();
1654 async move {
1655 if started.fetch_add(1, Ordering::SeqCst) + 1 == N {
1656 all_started.notify_one();
1657 }
1658 std::future::pending::<ServeResult>().await
1659 }
1660 };
1661
1662 let run = test_drive(algo, request(), serve);
1665 let result = tokio::time::timeout(std::time::Duration::from_millis(500), run)
1666 .await
1667 .map_err(|error| {
1668 LibsyError::external("waiting for terminal error with full call cap", error)
1669 })?;
1670 match result {
1671 Ok(_) => Err(test_error("expected the terminal error, got a response")),
1672 Err(err) => {
1673 assert!(
1674 err.to_string()
1675 .contains("terminal error while calls pending")
1676 );
1677 Ok(())
1678 }
1679 }
1680 }
1681}