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, State, 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 async fn respond(mut self, work: impl Future<Output = Result<Response>>) -> Result<()> {
130 let reply = self.reply.as_mut().ok_or(DriverError::ResponseDropped)?;
131 let result = tokio::select! {
132 biased;
133 _ = reply.closed() => return Ok(()),
134 result = work => result,
135 };
136 if self.reply.as_ref().is_some_and(oneshot::Sender::is_closed) {
137 return Ok(());
138 }
139 if !self.recover_errors
140 && let Err(error) = result
141 {
142 return self.fail(error);
143 }
144 self.record(result.is_ok());
145 if let Ok(response) = &result {
146 observability::record_llm_response(response, &self.span);
147 }
148 self.reply
149 .take()
150 .ok_or(DriverError::ResponseDropped)?
151 .send(result)
152 .ok();
153 Ok(())
154 }
155
156 pub fn fail(mut self, error: LibsyError) -> Result<()> {
159 self.reply = None;
160 self.record(false);
161 Err(error)
162 }
163
164 fn record(&self, is_ok: bool) {
165 observability::record_llm_call(
166 &self.algorithm,
167 self.models
168 .first()
169 .map(ModelId::as_str)
170 .unwrap_or("NoTargets"),
171 self.started.elapsed(),
172 is_ok,
173 );
174 self.span
175 .record("outcome", if is_ok { "ok" } else { "error" });
176 }
177}
178
179impl Drop for CallModel {
180 fn drop(&mut self) {
181 if self.reply.is_some() {
182 self.record(false);
183 }
184 }
185}
186
187pub struct CallDecision {
190 pub algorithm: String,
192 pub request: DecisionRequest,
194 pub model: ModelId,
196 reply: Option<oneshot::Sender<Result<DecisionResponse>>>,
197 started: Instant,
198 span: tracing::Span,
200}
201
202impl CallDecision {
203 pub async fn respond(
207 mut self,
208 work: impl Future<Output = Result<DecisionResponse>>,
209 ) -> Result<()> {
210 let reply = self.reply.as_mut().ok_or(DriverError::ResponseDropped)?;
211 let result = tokio::select! {
212 biased;
213 _ = reply.closed() => return Ok(()),
214 result = work => result,
215 };
216 self.record(result.is_ok());
217 if let Ok(response) = &result {
218 observability::record_decision_response(response, &self.span);
219 }
220 self.reply
221 .take()
222 .ok_or(DriverError::ResponseDropped)?
223 .send(result)
224 .ok();
225 Ok(())
226 }
227
228 pub fn fail(mut self, error: LibsyError) -> Result<()> {
230 self.reply = None;
231 self.record(false);
232 Err(error)
233 }
234
235 fn record(&self, is_ok: bool) {
236 observability::record_decision_call(
237 &self.algorithm,
238 &self.model,
239 self.started.elapsed(),
240 is_ok,
241 );
242 self.span
243 .record("outcome", if is_ok { "ok" } else { "error" });
244 }
245}
246
247impl Drop for CallDecision {
248 fn drop(&mut self) {
249 if self.reply.is_some() {
250 self.record(false);
251 }
252 }
253}
254
255pub struct RoutingOutcome {
257 pub selected_model_ids: Vec<ModelId>,
259 pub request: Request,
261 pub response: Option<Response>,
263 pub metadata: Option<crate::OutcomeMetadata>,
268 pub state: State,
270}
271
272impl RoutingOutcome {
273 pub fn selected_model_id(&self) -> Result<&ModelId> {
276 self.selected_model_ids.first().ok_or(LibsyError::NoTargets)
277 }
278
279 pub fn route_to(
283 selected_model_id: ModelId,
284 fallback_models: Vec<ModelId>,
285 mut request: Request,
286 ) -> Self {
287 request.llm_request.model = Some(selected_model_id.to_string());
288 let mut selected_model_ids = Vec::with_capacity(1 + fallback_models.len());
289 selected_model_ids.push(selected_model_id);
290 selected_model_ids.extend(fallback_models);
291 Self {
292 selected_model_ids,
293 request,
294 response: None,
295 metadata: None,
296 state: State::default(),
297 }
298 }
299
300 pub fn answered(selected_model_id: ModelId, mut request: Request, response: Response) -> Self {
303 request.llm_request.model = Some(selected_model_id.to_string());
304 Self {
305 selected_model_ids: vec![selected_model_id],
306 request,
307 response: Some(response),
308 metadata: None,
309 state: State::default(),
310 }
311 }
312}
313
314#[derive(Clone)]
316pub struct Driver {
317 step_tx: mpsc::Sender<Result<Step>>,
318
319 algorithm: String,
321
322 evidence: Arc<Mutex<Option<Value>>>,
324
325 state: Arc<Mutex<State>>,
327
328 models: Arc<RuntimeModels>,
330
331 scope: Scope,
333}
334
335impl Driver {
336 pub(crate) fn new(
339 algorithm: &str,
340 models: Arc<RuntimeModels>,
341 ) -> (Self, mpsc::Receiver<Result<Step>>) {
342 let (step_tx, step_rx) = mpsc::channel(1);
347 (
348 Self {
349 step_tx,
350 algorithm: algorithm.to_string(),
351 evidence: Arc::new(Mutex::new(None)),
352 state: Arc::new(Mutex::new(State::default())),
353 models,
354 scope: Scope::Parent,
355 },
356 step_rx,
357 )
358 }
359
360 pub(crate) fn take_state(&self) -> State {
361 std::mem::take(&mut *self.state.lock())
362 }
363
364 pub(crate) fn set_state(&self, state: State) {
365 *self.state.lock() = state;
366 }
367
368 pub(crate) fn set_evidence(&self, evidence: Value) {
370 *self.evidence.lock() = Some(evidence);
371 }
372
373 pub(crate) fn set_evidence_if_empty(&self, evidence: Value) {
375 let mut current = self.evidence.lock();
376 if current.is_none() {
377 *current = Some(evidence);
378 }
379 }
380
381 pub async fn call_model(&self, request: Request, models: Vec<ModelId>) -> Result<Response> {
391 self.call_model_with_error_recovery(request, models, false)
392 .await
393 }
394
395 #[tracing::instrument(
397 target = "libsy",
398 name = "libsy.llm_call",
399 skip_all,
400 fields(
401 algorithm = self.algorithm,
402 selected_model = %models.first().map(ModelId::as_str).unwrap_or("NoTargets"),
403 openinference.span.kind = "CHAIN",
404 outcome = tracing::field::Empty,
405 input_tokens = tracing::field::Empty,
406 output_tokens = tracing::field::Empty,
407 total_tokens = tracing::field::Empty,
408 reasoning_tokens = tracing::field::Empty,
409 )
410 )]
411 pub(crate) async fn call_model_with_error_recovery(
412 &self,
413 mut request: Request,
414 models: Vec<ModelId>,
415 recover_errors: bool,
416 ) -> Result<Response> {
417 let Some(selected_model_id) = models.first() else {
418 return Err(LibsyError::NoTargets);
419 };
420 request.llm_request.model = Some(selected_model_id.to_string());
421 let started = Instant::now();
422 let (reply, response) = oneshot::channel::<Result<Response>>();
423 let call = CallModel {
424 algorithm: self.algorithm.clone(),
425 request,
426 models,
427 recover_errors,
428 reply: Some(reply),
429 started,
430 span: tracing::Span::current(),
431 };
432 self.step_tx
433 .send(Ok(Step::CallModel(Box::new(call))))
434 .await
435 .map_err(|_| DriverError::StreamClosed)?;
436 response
437 .await
438 .map_err(|_| LibsyError::from(DriverError::ResponseDropped))?
439 }
440
441 #[tracing::instrument(
443 target = "libsy",
444 name = "libsy.decision_call",
445 skip_all,
446 fields(
447 algorithm = self.algorithm,
448 selected_model = %model,
449 openinference.span.kind = "CHAIN",
450 outcome = tracing::field::Empty,
451 input_tokens = tracing::field::Empty,
452 output_tokens = tracing::field::Empty,
453 total_tokens = tracing::field::Empty,
454 reasoning_tokens = tracing::field::Empty,
455 gen_ai.response.id = tracing::field::Empty,
456 gen_ai.response.model = tracing::field::Empty,
457 ),
458 )]
459 pub async fn call_decision(
460 &self,
461 mut request: DecisionRequest,
462 model: ModelId,
463 ) -> Result<DecisionResponse> {
464 request.model = Some(model.clone());
465 let started = Instant::now();
466 let (reply, response) = oneshot::channel();
467 let call = CallDecision {
468 algorithm: self.algorithm.clone(),
469 request,
470 model,
471 reply: Some(reply),
472 started,
473 span: tracing::Span::current(),
474 };
475 self.step_tx
476 .send(Ok(Step::CallDecision(Box::new(call))))
477 .await
478 .map_err(|_| DriverError::StreamClosed)?;
479 response
480 .await
481 .map_err(|_| LibsyError::from(DriverError::ResponseDropped))?
482 }
483
484 pub fn models_for(&self, category: &Category) -> &[ModelId] {
486 match self.scope {
487 Scope::Parent => self.models.models_for(category),
488 Scope::Subagent => self.models.subagent_models_for(category),
489 }
490 }
491
492 pub fn first_model_for(&self, category: &Category) -> Result<&ModelId> {
494 self.models_for(category)
495 .first()
496 .ok_or_else(|| LibsyError::AlgorithmError {
497 message: format!("no models available for category {}", category.as_str()),
498 })
499 }
500
501 pub fn for_subagent(&self) -> Result<Self> {
504 if self.models.subagent.is_none() {
505 return Err(LibsyError::AlgorithmError {
506 message: "delegated work has no sub-agent models".to_string(),
507 });
508 }
509 Ok(Self {
510 scope: Scope::Subagent,
511 ..self.clone()
512 })
513 }
514
515 pub(crate) async fn finish(&self, result: Result<RoutingOutcome>) -> Result<()> {
519 let result = result.map(|mut outcome| {
520 outcome.state = self.take_state();
521 let metadata = outcome.metadata.get_or_insert_with(|| {
522 crate::OutcomeMetadata::new(self.algorithm.clone(), self.evidence.lock().take())
523 });
524 observability::record_outcome(metadata, &outcome.selected_model_ids);
525 outcome
526 });
527 let selected_model = result
528 .as_ref()
529 .ok()
530 .and_then(|outcome| outcome.selected_model_id().ok().cloned());
531 let step = result.map(|outcome| Step::Done(Box::new(outcome)));
532 self.step_tx
533 .send(step)
534 .await
535 .map_err(|_| DriverError::StreamClosed)?;
536 if let Some(selected_model) = selected_model {
537 observability::record_decision(&self.algorithm, &selected_model);
538 }
539 Ok(())
540 }
541}
542
543pub enum Step {
546 CallModel(Box<CallModel>),
549 CallDecision(Box<CallDecision>),
551 Done(Box<RoutingOutcome>),
553}
554
555pub enum Call {
558 Model(Box<CallModel>),
560 Decision(Box<CallDecision>),
562}
563
564pub async fn drive<F, Fut>(
574 algorithm: Arc<dyn Algorithm>,
575 request: Request,
576 models: Arc<RuntimeModels>,
577 serve: F,
578) -> Result<RoutingOutcome>
579where
580 F: Fn(Call) -> Fut,
581 Fut: Future<Output = Result<()>>,
582{
583 drive_with_state(algorithm, request, models, State::default(), serve).await
584}
585
586pub(crate) async fn drive_with_state<F, Fut>(
588 algorithm: Arc<dyn Algorithm>,
589 request: Request,
590 models: Arc<RuntimeModels>,
591 state: State,
592 serve: F,
593) -> Result<RoutingOutcome>
594where
595 F: Fn(Call) -> Fut,
596 Fut: Future<Output = Result<()>>,
597{
598 let stream = algorithm.run_stream(request, models, state);
599 tokio::pin!(stream);
600
601 let mut in_flight = futures::stream::FuturesUnordered::new();
602 let mut final_outcome: Option<RoutingOutcome> = None;
603
604 loop {
605 tokio::select! {
606 Some(result) = in_flight.next() => match result {
607 Ok(()) => {}, Err(err) => return Err(err), },
610 step = stream.next() => {
611 match step {
612 None => break, Some(item) => match item? {
614 Step::CallModel(call) => in_flight.push(serve(Call::Model(call))),
615 Step::CallDecision(call) => {
616 in_flight.push(serve(Call::Decision(call)));
617 }
618 Step::Done(outcome) => {
619 final_outcome = Some(*outcome);
620 break;
621 }
622 }
623 }
624 },
625 }
626 }
627 final_outcome.ok_or(LibsyError::MissingFinalResponse)
628}
629
630fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
632 payload
633 .downcast_ref::<&'static str>()
634 .map(|message| (*message).to_string())
635 .or_else(|| payload.downcast_ref::<String>().cloned())
636 .unwrap_or_else(|| "unknown panic payload".to_string())
637}
638
639struct AbortOnDrop(tokio::task::AbortHandle);
641
642impl Drop for AbortOnDrop {
643 fn drop(&mut self) {
644 self.0.abort();
645 }
646}
647
648pub(crate) fn ensure_model_is_target(targets: &[ModelId], name: &ModelId) -> Result<()> {
653 targets
654 .iter()
655 .any(|target| target == name)
656 .then_some(())
657 .ok_or_else(|| LibsyError::TargetNotFound {
658 target: name.clone(),
659 })
660}
661
662#[derive(Clone, Hash, PartialEq, Eq)]
665pub(crate) enum RoutingIdentity {
666 Session(String),
668 Subagent { session: String, agent: String },
670}
671
672impl RoutingIdentity {
673 pub(crate) fn from_request(request: &Request) -> Option<Self> {
678 let metadata = request.metadata.as_ref()?;
679 let session = metadata.session_id.as_deref().filter(|id| !id.is_empty())?;
680 if metadata.is_subagent {
681 let agent = metadata.agent_id.as_deref().filter(|id| !id.is_empty())?;
682 Some(Self::Subagent {
683 session: session.to_string(),
684 agent: agent.to_string(),
685 })
686 } else {
687 Some(Self::Session(session.to_string()))
688 }
689 }
690}
691
692#[async_trait]
727pub trait Algorithm: Send + Sync + 'static {
728 fn name(&self) -> &str;
732
733 async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<RoutingOutcome>;
736
737 fn run_stream(
745 self: Arc<Self>,
746 request: Request,
747 models: Arc<RuntimeModels>,
748 state: State,
749 ) -> StepStream {
750 let (driver, step_rx) = Driver::new(self.name(), models);
751 driver.set_state(state);
752 let span = observability::run_span(self.name(), &request);
753 let handle = tokio::spawn(
754 async move {
755 let algorithm = self.name().to_string();
756 let route = AssertUnwindSafe(self.route(driver.clone(), request)).catch_unwind();
758 let result = observability::observe_run(&algorithm, async move {
759 route.await.unwrap_or_else(|payload| {
760 Err(LibsyError::AlgorithmError {
761 message: format!(
762 "algorithm task panicked: {}",
763 panic_message(payload.as_ref())
764 ),
765 })
766 })
767 })
768 .await;
769
770 let _ = driver.finish(result).await;
771 }
772 .instrument(span),
773 );
774 let abort_guard = AbortOnDrop(handle.abort_handle());
776 Box::pin(ReceiverStream::new(step_rx).map(move |step| {
777 let _keep_alive = &abort_guard;
779 step
780 }))
781 }
782}
783
784#[cfg(test)]
785mod tests {
786 use std::collections::HashMap;
787
788 use super::*;
789 use crate::core::testing::{Serve, ServeResult, echo, reply, serve_decision, test_drive};
790 use futures::StreamExt;
791 use switchyard_protocol::{
792 LlmResponse, LlmResponseChunk, completion_text, text_request, text_response,
793 };
794
795 #[derive(Debug, thiserror::Error)]
796 #[error("{0}")]
797 struct TestError(&'static str);
798
799 fn test_error(message: &'static str) -> LibsyError {
800 LibsyError::external("test", TestError(message))
801 }
802
803 struct TestAlgo {
806 target_set: Vec<ModelId>,
807 }
808
809 #[async_trait]
810 impl Algorithm for TestAlgo {
811 fn name(&self) -> &str {
812 "test"
813 }
814
815 async fn route(
816 self: Arc<Self>,
817 driver: Driver,
818 request: Request,
819 ) -> Result<RoutingOutcome> {
820 let target = self
821 .target_set
822 .first()
823 .ok_or(LibsyError::NoTargets)?
824 .clone();
825 let response = driver
826 .call_model(request.clone(), vec![target.clone()])
827 .await?;
828 driver.set_evidence(serde_json::json!({"source": "test"}));
829 driver.set_evidence_if_empty(serde_json::json!({"source": "ignored"}));
830 Ok(RoutingOutcome::answered(target, request, response))
831 }
832 }
833
834 fn orch(target_set: Vec<ModelId>) -> Arc<dyn Algorithm> {
836 Arc::new(TestAlgo { target_set })
837 }
838
839 fn request() -> Request {
840 Request {
841 llm_request: text_request(Some("auto".to_string()), "hi".to_string()),
842 raw_request: None,
843 metadata: None,
844 }
845 }
846
847 #[test]
848 fn routing_outcome_constructors_stamp_selection_and_preserve_payloads() {
849 let outcome = RoutingOutcome::route_to(
850 "selected".into(),
851 target_set(&["fallback-one", "fallback-two"]),
852 request(),
853 );
854
855 assert_eq!(
856 outcome.selected_model_ids,
857 target_set(&["selected", "fallback-one", "fallback-two"])
858 );
859 assert_eq!(outcome.request.model_id().as_deref(), Some("selected"));
860 assert!(outcome.response.is_none());
861 assert!(outcome.metadata.is_none());
862
863 let outcome = RoutingOutcome::route_to("only".into(), Vec::new(), request());
864 assert_eq!(outcome.selected_model_ids, target_set(&["only"]));
865
866 let outcome = RoutingOutcome::answered(
867 "answered".into(),
868 request(),
869 Response {
870 llm_response: LlmResponse::Agg(text_response(None, "existing")),
871 metadata: None,
872 upstream_headers: http::HeaderMap::new(),
873 },
874 );
875
876 assert_eq!(outcome.selected_model_ids, target_set(&["answered"]));
877 assert_eq!(outcome.request.model_id().as_deref(), Some("answered"));
878 assert_eq!(
879 outcome
880 .response
881 .as_ref()
882 .and_then(|response| response.llm_response.as_agg())
883 .map(completion_text),
884 Some("existing".to_string())
885 );
886 }
887
888 fn target_set(names: &[&str]) -> Vec<ModelId> {
889 names.iter().map(|name| ModelId::from(*name)).collect()
890 }
891
892 #[tokio::test]
893 async fn typed_driver_preserves_call_and_stream_boundaries() -> Result<()> {
894 tokio::time::timeout(std::time::Duration::from_secs(1), async {
895 let (driver, mut step_rx) = Driver::new("test", Arc::new(RuntimeModels::default()));
898 let first_driver = driver.clone();
899 let mut first = tokio::spawn(async move {
900 first_driver
901 .call_model(request(), vec![ModelId::from("first")])
902 .await
903 });
904 let second = tokio::spawn(async move {
905 driver
906 .call_model(request(), vec![ModelId::from("second")])
907 .await
908 });
909
910 let mut calls = HashMap::new();
911 for _ in 0..2 {
912 let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
913 let Step::CallModel(call) = step else {
914 return Err(test_error("expected a CallModel step"));
915 };
916 let selected_model = call
917 .models
918 .first()
919 .ok_or_else(|| test_error("model call has no candidates"))?
920 .to_string();
921 calls.insert(selected_model, call);
922 }
923 assert!(
924 tokio::time::timeout(std::time::Duration::from_millis(20), &mut first)
925 .await
926 .is_err(),
927 "call completed before the host responded"
928 );
929 calls
930 .remove("second")
931 .ok_or_else(|| test_error("missing second call"))?
932 .respond(std::future::ready(Ok(reply("second response")))).await?;
933 calls
934 .remove("first")
935 .ok_or_else(|| test_error("missing first call"))?
936 .respond(std::future::ready(Ok(reply("first response")))).await?;
937
938 let first_response = first
939 .await
940 .map_err(|source| LibsyError::external("joining a test task", source))??;
941 let second_response = second
942 .await
943 .map_err(|source| LibsyError::external("joining a test task", source))??;
944 assert_eq!(
945 first_response.llm_response.as_agg().map(completion_text),
946 Some("first response".to_string())
947 );
948 assert_eq!(
949 second_response.llm_response.as_agg().map(completion_text),
950 Some("second response".to_string())
951 );
952
953 let (driver, mut step_rx) = Driver::new("test", Arc::new(RuntimeModels::default()));
955 let producer = tokio::spawn(async move {
956 driver
957 .call_model(request(), vec![ModelId::from("dropped")])
958 .await
959 });
960 let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
961 let Step::CallModel(call) = step else {
962 return Err(test_error("expected a CallModel step"));
963 };
964 drop(call);
965 let result = producer
966 .await
967 .map_err(|source| LibsyError::external("joining a test task", source))?;
968 assert!(matches!(
969 result,
970 Err(LibsyError::Driver(DriverError::ResponseDropped))
971 ));
972
973 let (driver, step_rx) = Driver::new("test", Arc::new(RuntimeModels::default()));
975 drop(step_rx);
976 let result = driver
977 .call_model(request(), vec![ModelId::from("closed")])
978 .await;
979 assert!(matches!(
980 result,
981 Err(LibsyError::Driver(DriverError::StreamClosed))
982 ));
983
984 fn decision_request() -> DecisionRequest {
985 DecisionRequest {
986 model: Some("overwritten".into()),
987 context: serde_json::json!({"task": "choose a route"}),
988 questions: Default::default(),
989 }
990 }
991
992 fn decision_response() -> DecisionResponse {
993 DecisionResponse {
994 id: Some("decision-1".to_string()),
995 model: Some("provider-model".into()),
996 answers: [(
997 "p_solve".to_string(),
998 switchyard_protocol::DecisionAnswer {
999 value: switchyard_protocol::DecisionValue::Boolean(
1000 switchyard_protocol::BooleanEstimate::ProbabilityTrue(
1001 switchyard_protocol::Probability(0.8),
1002 ),
1003 ),
1004 provider_confidence: None,
1005 },
1006 )]
1007 .into(),
1008 usage: Default::default(),
1009 }
1010 }
1011
1012 struct MixedCalls(&'static str);
1013
1014 #[async_trait]
1015 impl Algorithm for MixedCalls {
1016 fn name(&self) -> &str {
1017 "mixed"
1018 }
1019
1020 async fn route(
1021 self: Arc<Self>,
1022 driver: Driver,
1023 request: Request,
1024 ) -> Result<RoutingOutcome> {
1025 let (llm, decision) = tokio::join!(
1026 driver.call_model(request.clone(), vec!["llm".into()]),
1027 driver.call_decision(decision_request(), "decision".into()),
1028 );
1029 assert_eq!(
1030 llm?.llm_response.as_agg().map(completion_text),
1031 Some("llm reply".into())
1032 );
1033 match self.0 {
1034 "mock" => assert_eq!(decision?, DecisionResponse {
1035 id: None,
1036 model: Some("decision".into()),
1037 answers: Default::default(),
1038 usage: Default::default(),
1039 }),
1040 "reply" => assert_eq!(decision?, decision_response()),
1041 "error" => assert!(matches!(
1042 decision,
1043 Err(LibsyError::AlgorithmError { message }) if message == "provider failed"
1044 )),
1045 "drop" => assert!(matches!(
1046 decision,
1047 Err(LibsyError::Driver(DriverError::ResponseDropped))
1048 )),
1049 "abort" => return std::future::pending().await,
1050 _ => unreachable!(),
1051 }
1052 Ok(RoutingOutcome::route_to("answer".into(), vec![], request))
1053 }
1054 }
1055
1056 for mode in ["mock", "reply", "error", "drop", "abort"] {
1057 let barrier = Arc::new(tokio::sync::Barrier::new(2));
1059 let outcome = drive(
1060 Arc::new(MixedCalls(mode)),
1061 request(),
1062 Arc::new(RuntimeModels::default()),
1063 move |call| {
1064 let barrier = barrier.clone();
1065 async move {
1066 barrier.wait().await;
1067 let call = match call {
1068 Call::Model(call) => return call.respond(std::future::ready(Ok(reply("llm reply")))).await,
1069 Call::Decision(call) => *call,
1070 };
1071 assert_eq!(call.algorithm, "mixed");
1072 assert_eq!(call.model, "decision");
1073 assert_eq!(call.request.model, Some("decision".into()));
1074 assert_eq!(call.request.context, decision_request().context);
1075 match mode {
1076 "mock" => serve_decision(call).await,
1077 "reply" => call.respond(std::future::ready(Ok(decision_response()))).await,
1078 "error" => call.respond(std::future::ready(Err(LibsyError::AlgorithmError {
1079 message: "provider failed".into(),
1080 }))).await,
1081 "drop" => {
1082 drop(call);
1083 Ok(())
1084 }
1085 "abort" => call.fail(test_error("host aborted")),
1086 _ => unreachable!(),
1087 }
1088 }
1089 },
1090 )
1091 .await;
1092 if mode == "abort" {
1093 assert!(matches!(outcome, Err(LibsyError::External { .. })));
1094 } else {
1095 assert_eq!(outcome?.selected_model_id()?, "answer");
1096 }
1097 }
1098
1099 let (driver, mut steps) = Driver::new("test", Arc::new(RuntimeModels::default()));
1100 let mut pending = Box::pin(driver.call_decision(decision_request(), "decision".into()));
1101 assert!(futures::poll!(&mut pending).is_pending());
1102 let Some(Ok(Step::CallDecision(call))) = steps.recv().await else {
1103 return Err(test_error("expected a decision call"));
1104 };
1105 drop(pending);
1106 call.respond(async { panic!("cancelled work must not be polled") }).await?;
1107 drop(steps);
1108 assert!(matches!(
1109 driver.call_decision(decision_request(), "decision".into()).await,
1110 Err(LibsyError::Driver(DriverError::StreamClosed))
1111 ));
1112 Ok(())
1113 })
1114 .await
1115 .map_err(|error| LibsyError::external("waiting for typed driver boundaries", error))?
1116 }
1117
1118 #[test]
1119 fn target_lookup_returns_the_missing_target() {
1120 let error = ensure_model_is_target(&target_set(&[]), &ModelId::from("missing")).err();
1121 assert!(matches!(
1122 error,
1123 Some(LibsyError::TargetNotFound { target }) if target == "missing"
1124 ));
1125 }
1126
1127 fn streaming_orch(chunks: Vec<LlmResponseChunk>) -> (Arc<dyn Algorithm>, impl Serve) {
1130 let algo = orch(target_set(&["stream/model"]));
1131 let serve = move |_target: ModelId, _request: Request| {
1132 let chunks = chunks.clone();
1133 async move {
1134 let stream =
1135 futures::stream::iter(chunks.into_iter().map(|chunk| Ok(chunk.into()))).boxed();
1136 Ok(Response {
1137 llm_response: LlmResponse::Stream(stream),
1138 metadata: None,
1139 upstream_headers: http::HeaderMap::new(),
1140 })
1141 }
1142 };
1143 (algo, serve)
1144 }
1145
1146 #[tokio::test]
1147 async fn run_returns_a_streamed_response_the_caller_aggregates() -> Result<()> {
1148 let (orch, serve) = streaming_orch(vec![
1151 LlmResponseChunk::MessageStart {
1152 id: Some("m1".to_string()),
1153 model: Some("stream/model".to_string()),
1154 },
1155 LlmResponseChunk::TextDelta {
1156 index: 0,
1157 text: "hel".to_string(),
1158 },
1159 LlmResponseChunk::TextDelta {
1160 index: 0,
1161 text: "lo".to_string(),
1162 },
1163 LlmResponseChunk::MessageStop {
1164 reason: Some("stop".to_string()),
1165 },
1166 ]);
1167 let (selected_model, response) = test_drive(orch, request(), serve).await?;
1168 let agg = response
1170 .llm_response
1171 .into_agg()
1172 .await
1173 .map_err(|error| LibsyError::external("aggregating response stream", error))?;
1174 assert_eq!(completion_text(&agg), "hello");
1175 assert_eq!(agg.model.as_deref(), Some("stream/model"));
1176 assert_eq!(selected_model, "stream/model");
1177 Ok(())
1178 }
1179
1180 #[tokio::test]
1181 async fn aggregating_a_streamed_response_propagates_a_mid_stream_error() -> Result<()> {
1182 let (orch, serve) = streaming_orch(vec![
1185 LlmResponseChunk::TextDelta {
1186 index: 0,
1187 text: "partial".to_string(),
1188 },
1189 LlmResponseChunk::StreamError {
1190 message: "upstream exploded".to_string(),
1191 },
1192 ]);
1193 let (_, response) = test_drive(orch, request(), serve).await?;
1194 match response.llm_response.into_agg().await {
1195 Ok(_) => panic!("expected a mid-stream error, got an aggregate"),
1196 Err(err) => {
1197 assert!(err.to_string().contains("upstream exploded"));
1198 Ok(())
1199 }
1200 }
1201 }
1202
1203 #[tokio::test]
1204 async fn run_offloads_via_promise_then_finishes() -> Result<()> {
1205 let stream = orch(target_set(&["offload/model"])).run_stream(
1208 request(),
1209 Arc::new(RuntimeModels::default()),
1210 State::default(),
1211 );
1212 tokio::pin!(stream);
1213
1214 let mut saw_call = false;
1215 let mut final_completion = None;
1216 while let Some(step) = stream.next().await {
1217 match step? {
1218 Step::CallDecision(_) => return Err(test_error("unexpected decision call")),
1219 Step::CallModel(call) => {
1220 saw_call = true;
1221 assert_eq!(call.models, vec![ModelId::from("offload/model")]);
1222 call.respond(std::future::ready(Ok(Response {
1224 llm_response: LlmResponse::Agg(text_response(
1225 None,
1226 "fulfilled".to_string(),
1227 )),
1228 metadata: None,
1229 upstream_headers: http::HeaderMap::new(),
1230 })))
1231 .await?;
1232 }
1233 Step::Done(outcome) => {
1234 let metadata = outcome
1235 .metadata
1236 .as_ref()
1237 .expect("run_stream should attach outcome metadata");
1238 assert_eq!(metadata.algorithm, "test");
1239 assert_eq!(
1240 uuid::Uuid::parse_str(metadata.outcome_id())
1241 .expect("outcome id should be a UUID")
1242 .get_version_num(),
1243 7
1244 );
1245 assert_eq!(
1246 metadata.evidence,
1247 Some(serde_json::json!({"source": "test"}))
1248 );
1249 let response = outcome
1250 .response
1251 .ok_or_else(|| test_error("expected an answered outcome"))?;
1252 final_completion = Some(
1253 response
1254 .llm_response
1255 .as_agg()
1256 .map(completion_text)
1257 .unwrap_or_default(),
1258 );
1259 }
1260 }
1261 }
1262
1263 assert!(saw_call, "expected a CallModel step before Done");
1264 assert_eq!(
1265 final_completion.ok_or_else(|| test_error("no Done step"))?,
1266 "fulfilled"
1267 );
1268 Ok(())
1269 }
1270
1271 #[tokio::test(flavor = "multi_thread", worker_threads = 12)]
1272 async fn requests_are_processed_in_parallel() -> Result<()> {
1273 use std::time::Duration;
1274 use tokio::sync::Barrier;
1275
1276 const N: usize = 12;
1277
1278 let barrier = Arc::new(Barrier::new(N));
1283 let algo = orch(target_set(&["m"]));
1285
1286 let mut handles = Vec::new();
1287 for _ in 0..N {
1288 let algo = algo.clone();
1289 let barrier = barrier.clone();
1290 let serve = move |target: ModelId, _request: Request| {
1291 let barrier = barrier.clone();
1292 async move {
1293 barrier.wait().await;
1294 Ok(reply(target))
1295 }
1296 };
1297 handles.push(tokio::spawn(async move {
1298 test_drive(algo, request(), serve)
1299 .await
1300 .map(|(_, response)| {
1301 response
1302 .llm_response
1303 .as_agg()
1304 .map(completion_text)
1305 .unwrap_or_default()
1306 })
1307 }));
1308 }
1309
1310 for handle in handles {
1311 let completion = tokio::time::timeout(Duration::from_secs(5), handle)
1313 .await
1314 .map_err(|error| LibsyError::external("waiting for test task", error))?
1315 .map_err(|source| LibsyError::external("joining a test task", source))??;
1316 assert_eq!(completion, "m");
1317 }
1318 Ok(())
1319 }
1320
1321 #[tokio::test]
1322 async fn offload_error_propagates_back_to_the_algorithm() -> Result<()> {
1323 let stream = orch(target_set(&["offload/model"])).run_stream(
1327 request(),
1328 Arc::new(RuntimeModels::default()),
1329 State::default(),
1330 );
1331 tokio::pin!(stream);
1332
1333 let mut saw_error = false;
1334 while let Some(step) = stream.next().await {
1335 match step {
1336 Ok(Step::CallDecision(_)) => return Err(test_error("unexpected decision call")),
1337 Ok(Step::CallModel(mut call)) => {
1338 call.recover_errors = true;
1339 call.respond(std::future::ready(Err(test_error(
1340 "upstream model call failed",
1341 ))))
1342 .await?;
1343 }
1344 Ok(Step::Done(..)) => {
1345 return Err(test_error(
1346 "expected the offload error to propagate, got a response",
1347 ));
1348 }
1349 Err(err) => {
1350 assert!(err.to_string().contains("upstream model call failed"));
1352 saw_error = true;
1353 }
1354 }
1355 }
1356
1357 assert!(saw_error, "expected an error step");
1358 Ok(())
1359 }
1360
1361 #[tokio::test]
1362 async fn dropping_the_stream_cancels_the_algorithm_task() -> Result<()> {
1363 use std::sync::atomic::{AtomicBool, Ordering};
1364 use std::time::Duration;
1365 use tokio::sync::mpsc;
1366
1367 struct DropGuard(Arc<AtomicBool>);
1370 impl Drop for DropGuard {
1371 fn drop(&mut self) {
1372 self.0.store(true, Ordering::SeqCst);
1373 }
1374 }
1375
1376 struct StuckAlgo {
1377 started: mpsc::UnboundedSender<()>,
1378 dropped: Arc<AtomicBool>,
1379 }
1380
1381 #[async_trait]
1382 impl Algorithm for StuckAlgo {
1383 fn name(&self) -> &str {
1384 "stuck"
1385 }
1386
1387 async fn route(
1388 self: Arc<Self>,
1389 _driver: Driver,
1390 _request: Request,
1391 ) -> Result<RoutingOutcome> {
1392 let _guard = DropGuard(self.dropped.clone());
1393 let _ = self.started.send(());
1394 std::future::pending::<()>().await;
1396 unreachable!()
1397 }
1398 }
1399
1400 let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1401 let dropped = Arc::new(AtomicBool::new(false));
1402 let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1403 started: started_tx,
1404 dropped: dropped.clone(),
1405 });
1406
1407 let stream = algo.run_stream(
1408 request(),
1409 Arc::new(RuntimeModels::default()),
1410 State::default(),
1411 );
1412 started_rx
1413 .recv()
1414 .await
1415 .ok_or_else(|| test_error("task never started"))?;
1416 drop(stream);
1417 tokio::time::sleep(Duration::from_millis(100)).await;
1418
1419 assert!(
1420 dropped.load(Ordering::SeqCst),
1421 "algorithm task was NOT cancelled after dropping the stream"
1422 );
1423 Ok(())
1424 }
1425
1426 #[tokio::test]
1427 async fn route_panic_surfaces_as_a_stream_error() -> Result<()> {
1428 struct Panicky;
1431
1432 #[async_trait]
1433 impl Algorithm for Panicky {
1434 fn name(&self) -> &str {
1435 "panicky"
1436 }
1437
1438 async fn route(
1439 self: Arc<Self>,
1440 _driver: Driver,
1441 _request: Request,
1442 ) -> Result<RoutingOutcome> {
1443 panic!("boom");
1444 }
1445 }
1446
1447 let algo: Arc<dyn Algorithm> = Arc::new(Panicky);
1448 let stream = algo.run_stream(
1449 request(),
1450 Arc::new(RuntimeModels::default()),
1451 State::default(),
1452 );
1453 tokio::pin!(stream);
1454
1455 let mut saw_error = false;
1456 while let Some(step) = stream.next().await {
1457 match step {
1458 Err(err) => {
1459 assert!(err.to_string().contains("algorithm task panicked: boom"));
1461 saw_error = true;
1462 }
1463 Ok(_) => return Err(test_error("expected the panic to surface as an error step")),
1464 }
1465 }
1466
1467 assert!(saw_error, "expected an error step from the panicked task");
1468 Ok(())
1469 }
1470
1471 #[tokio::test]
1475 async fn a_panic_with_a_leaked_driver_clone_still_terminates_the_run() -> Result<()> {
1476 struct LeakyPanic;
1477
1478 #[async_trait]
1479 impl Algorithm for LeakyPanic {
1480 fn name(&self) -> &str {
1481 "leaky_panic"
1482 }
1483
1484 async fn route(
1485 self: Arc<Self>,
1486 driver: Driver,
1487 _request: Request,
1488 ) -> Result<RoutingOutcome> {
1489 tokio::spawn(async move {
1490 let _keep_alive = driver;
1492 std::future::pending::<()>().await;
1493 });
1494 tokio::task::yield_now().await;
1495 panic!("boom");
1496 }
1497 }
1498
1499 let algo: Arc<dyn Algorithm> = Arc::new(LeakyPanic);
1500 let result = tokio::time::timeout(
1502 std::time::Duration::from_secs(1),
1503 test_drive(algo, request(), echo()),
1504 )
1505 .await
1506 .map_err(|error| LibsyError::external("waiting for the panicked run to end", error))?;
1507
1508 match result {
1509 Ok(_) => Err(test_error(
1510 "expected the panic to end the run with an error",
1511 )),
1512 Err(err) => {
1513 assert!(err.to_string().contains("algorithm task panicked: boom"));
1514 Ok(())
1515 }
1516 }
1517 }
1518
1519 #[tokio::test]
1520 async fn cancelling_run_cancels_the_algorithm_task() -> Result<()> {
1521 use std::sync::atomic::{AtomicBool, Ordering};
1522 use std::time::Duration;
1523 use tokio::sync::mpsc;
1524
1525 struct DropGuard(Arc<AtomicBool>);
1528 impl Drop for DropGuard {
1529 fn drop(&mut self) {
1530 self.0.store(true, Ordering::SeqCst);
1531 }
1532 }
1533
1534 struct StuckAlgo {
1535 started: mpsc::UnboundedSender<()>,
1536 dropped: Arc<AtomicBool>,
1537 }
1538
1539 #[async_trait]
1540 impl Algorithm for StuckAlgo {
1541 fn name(&self) -> &str {
1542 "stuck"
1543 }
1544
1545 async fn route(
1546 self: Arc<Self>,
1547 _driver: Driver,
1548 _request: Request,
1549 ) -> Result<RoutingOutcome> {
1550 let _guard = DropGuard(self.dropped.clone());
1551 let _ = self.started.send(());
1552 std::future::pending::<()>().await;
1555 unreachable!()
1556 }
1557 }
1558
1559 let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1560 let dropped = Arc::new(AtomicBool::new(false));
1561 let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1562 started: started_tx,
1563 dropped: dropped.clone(),
1564 });
1565
1566 let run_task = tokio::spawn(async move { test_drive(algo, request(), echo()).await });
1569 started_rx
1570 .recv()
1571 .await
1572 .ok_or_else(|| test_error("task never started"))?;
1573 run_task.abort();
1574 tokio::time::sleep(Duration::from_millis(100)).await;
1575
1576 assert!(
1577 dropped.load(Ordering::SeqCst),
1578 "algorithm task was NOT cancelled after cancelling run"
1579 );
1580 Ok(())
1581 }
1582
1583 struct Hedge {
1588 winner: String,
1589 loser: String,
1590 }
1591
1592 #[async_trait]
1593 impl Algorithm for Hedge {
1594 fn name(&self) -> &str {
1595 "hedge"
1596 }
1597
1598 async fn route(
1599 self: Arc<Self>,
1600 driver: Driver,
1601 request: Request,
1602 ) -> Result<RoutingOutcome> {
1603 let outcome_request = request.clone();
1604 let win = driver.call_model(request.clone(), vec![self.winner.clone().into()]);
1605 let lose = driver.call_model(request, vec![self.loser.clone().into()]);
1606 tokio::select! {
1608 res = win => Ok(RoutingOutcome::answered(
1609 self.winner.clone().into(),
1610 outcome_request,
1611 res?,
1612 )),
1613 res = lose => Ok(RoutingOutcome::answered(
1614 self.loser.clone().into(),
1615 outcome_request,
1616 res?,
1617 )),
1618 }
1619 }
1620 }
1621
1622 fn hedge(loser_delay: Option<std::time::Duration>) -> (Arc<dyn Algorithm>, impl Serve) {
1626 let started = Arc::new(tokio::sync::Notify::new());
1627 let algo = Arc::new(Hedge {
1628 winner: "winner".to_string(),
1629 loser: "loser".to_string(),
1630 });
1631 let serve = move |target: ModelId, _request: Request| {
1632 let started = started.clone();
1633 async move {
1634 if target == "loser" {
1635 started.notify_one();
1636 match loser_delay {
1637 Some(delay) => tokio::time::sleep(delay).await,
1638 None => std::future::pending::<()>().await,
1639 }
1640 } else {
1641 started.notified().await;
1642 }
1643 Ok(reply(target))
1644 }
1645 };
1646 (algo, serve)
1647 }
1648
1649 #[tokio::test]
1650 async fn run_returns_the_winner_without_a_late_loser_overwriting_it() -> Result<()> {
1651 let (algo, serve) = hedge(Some(std::time::Duration::from_millis(50)));
1654 let (_, response) = test_drive(algo, request(), serve).await?;
1655 assert_eq!(
1656 response
1657 .llm_response
1658 .as_agg()
1659 .map(completion_text)
1660 .unwrap_or_default(),
1661 "winner"
1662 );
1663 Ok(())
1664 }
1665
1666 #[tokio::test]
1667 async fn run_returns_the_winner_without_hanging_on_a_pending_loser() -> Result<()> {
1668 let (algo, serve) = hedge(None);
1671 let run = test_drive(algo, request(), serve);
1672 let (_, response) = tokio::time::timeout(std::time::Duration::from_secs(1), run)
1673 .await
1674 .map_err(|error| LibsyError::external("waiting for pending loser", error))??;
1675 assert_eq!(
1676 response
1677 .llm_response
1678 .as_agg()
1679 .map(completion_text)
1680 .unwrap_or_default(),
1681 "winner"
1682 );
1683 Ok(())
1684 }
1685
1686 #[tokio::test]
1687 async fn run_surfaces_a_terminal_error_with_many_calls_in_flight() -> Result<()> {
1688 use std::sync::atomic::{AtomicUsize, Ordering};
1689
1690 const N: usize = 10;
1693
1694 struct FanOutThenError {
1697 all_started: Arc<tokio::sync::Notify>,
1698 n: usize,
1699 }
1700
1701 #[async_trait]
1702 impl Algorithm for FanOutThenError {
1703 fn name(&self) -> &str {
1704 "fan_out_then_error"
1705 }
1706
1707 async fn route(
1708 self: Arc<Self>,
1709 driver: Driver,
1710 request: Request,
1711 ) -> Result<RoutingOutcome> {
1712 let offloads = futures::future::join_all(
1713 (0..self.n)
1714 .map(|i| driver.call_model(request.clone(), vec![format!("m{i}").into()])),
1715 );
1716 tokio::select! {
1717 _ = offloads => Err(test_error("offloads unexpectedly completed")),
1718 _ = self.all_started.notified() => {
1719 Err(test_error("terminal error while calls pending"))
1720 }
1721 }
1722 }
1723 }
1724
1725 let all_started = Arc::new(tokio::sync::Notify::new());
1726 let algo: Arc<dyn Algorithm> = Arc::new(FanOutThenError {
1727 all_started: all_started.clone(),
1728 n: N,
1729 });
1730
1731 let started = Arc::new(AtomicUsize::new(0));
1733 let serve = move |_target: ModelId, _request: Request| {
1734 let started = started.clone();
1735 let all_started = all_started.clone();
1736 async move {
1737 if started.fetch_add(1, Ordering::SeqCst) + 1 == N {
1738 all_started.notify_one();
1739 }
1740 std::future::pending::<ServeResult>().await
1741 }
1742 };
1743
1744 let run = test_drive(algo, request(), serve);
1747 let result = tokio::time::timeout(std::time::Duration::from_millis(500), run)
1748 .await
1749 .map_err(|error| {
1750 LibsyError::external("waiting for terminal error with full call cap", error)
1751 })?;
1752 match result {
1753 Ok(_) => Err(test_error("expected the terminal error, got a response")),
1754 Err(err) => {
1755 assert!(
1756 err.to_string()
1757 .contains("terminal error while calls pending")
1758 );
1759 Ok(())
1760 }
1761 }
1762 }
1763}