1use std::{
9 collections::{HashMap, HashSet},
10 pin::Pin,
11 sync::Arc,
12 time::{Duration, Instant},
13};
14
15use async_trait::async_trait;
16use futures::{Stream, StreamExt};
17use parking_lot::Mutex;
18use tracing::Instrument;
19
20use switchyard_protocol::{
28 Context, Decision, LlmClientError, Request, Response, RoutedLlmClient, Signals, Usage,
29};
30
31use super::driver::{DriverRequest, DriverStep, TypeErasedDriver};
32use crate::{DriverError, LibsyError, Result, observability};
33
34pub type StepStream = Pin<Box<dyn Stream<Item = Result<Step>> + Send>>;
38
39#[derive(Clone, Debug)]
41pub struct LlmCallObservation {
42 pub selected_model: String,
44 pub tier: Option<String>,
46 pub is_routed: bool,
48 pub is_success: bool,
50 pub duration: Duration,
52 pub usage: Option<Usage>,
54}
55
56#[derive(Clone, Debug)]
58pub enum RunObservation {
59 LlmCall(LlmCallObservation),
61 RoutingOverhead(Duration),
63}
64
65pub type RunObserver = Arc<dyn Fn(RunObservation) + Send + Sync>;
71
72#[derive(Clone)]
81pub struct RoutedRequest {
82 pub request: Request,
84 pub decision: Arc<dyn Decision>,
86 pub default_client: Option<Arc<dyn RoutedLlmClient>>,
90 pub ctx: Context,
94}
95
96pub struct CallLlmRequest {
104 inner: DriverRequest,
105 routed: RoutedRequest,
106}
107
108impl CallLlmRequest {
109 fn new(inner: DriverRequest) -> Self {
112 let routed = match inner.request::<RoutedRequest>() {
115 Ok(routed) => routed.clone(),
116 Err(_) => unreachable!("CallLlmRequest payload is always a RoutedRequest"),
117 };
118 Self { inner, routed }
119 }
120
121 pub fn get_routed(&self) -> &RoutedRequest {
125 &self.routed
126 }
127
128 pub fn get_request(&self) -> &Request {
130 &self.get_routed().request
131 }
132
133 pub fn get_decision(&self) -> &dyn Decision {
135 self.get_routed().decision.as_ref()
136 }
137
138 pub fn respond(self, result: Result<Response>) -> Result<()> {
142 self.inner.respond::<Response>(result)
143 }
144}
145
146#[derive(Clone)]
153pub struct Driver {
154 driver: TypeErasedDriver,
155 routed_call: Arc<Mutex<Option<Duration>>>,
157 observer: Option<RunObserver>,
158}
159
160impl Driver {
161 pub(crate) fn new() -> Self {
164 Self::with_observer(None)
165 }
166
167 fn with_observer(observer: Option<RunObserver>) -> Self {
168 Self {
169 driver: TypeErasedDriver::new(),
170 routed_call: Arc::new(Mutex::new(None)),
171 observer,
172 }
173 }
174
175 pub(crate) fn routed_call_duration(&self) -> Option<Duration> {
177 *self.routed_call.lock()
178 }
179
180 pub(crate) fn observe_routing_overhead(&self, duration: Duration) {
183 if let Some(observer) = &self.observer {
184 observer(RunObservation::RoutingOverhead(duration));
185 }
186 }
187
188 #[tracing::instrument(
197 target = "libsy",
198 name = "libsy.llm_call",
199 skip_all,
200 fields(
201 algorithm = observability::algorithm_label(&routed.ctx),
202 selected_model = routed.decision.selected_model(),
203 openinference.span.kind = "CHAIN",
204 outcome = tracing::field::Empty,
205 error = tracing::field::Empty,
206 input_tokens = tracing::field::Empty,
207 output_tokens = tracing::field::Empty,
208 total_tokens = tracing::field::Empty,
209 reasoning_tokens = tracing::field::Empty,
210 )
211 )]
212 pub async fn call_llm(&self, routed: RoutedRequest) -> Result<Response> {
213 let algorithm = observability::algorithm_label(&routed.ctx).to_string();
214 let selected_model = routed.decision.selected_model().to_string();
215 let tier = routed.decision.routing_tier().map(str::to_string);
216 let is_routed = routed.decision.is_routed_call();
217 let started = Instant::now();
218 let result = self
219 .driver
220 .fulfill_request::<RoutedRequest, Response>(routed.ctx.clone(), routed)
221 .await;
222 let elapsed = started.elapsed();
223 observability::record_llm_call(
224 &algorithm,
225 &selected_model,
226 tier.as_deref(),
227 is_routed,
228 elapsed,
229 &result,
230 &tracing::Span::current(),
231 );
232 if let Some(observer) = &self.observer {
233 observer(RunObservation::LlmCall(LlmCallObservation {
234 selected_model,
235 tier,
236 is_routed,
237 is_success: result.is_ok(),
238 duration: elapsed,
239 usage: result
240 .as_ref()
241 .ok()
242 .and_then(|response| response.llm_response.as_agg())
243 .map(|response| response.usage.clone()),
244 }));
245 }
246 if is_routed && result.is_ok() {
249 *self.routed_call.lock() = Some(elapsed);
250 }
251 result
252 }
253
254 pub async fn call_llm_target(
260 &self,
261 ctx: Context,
262 target: &LlmTarget,
263 request: Request,
264 decision: Arc<dyn Decision>,
265 ) -> Result<Response> {
266 self.call_llm(RoutedRequest {
267 request,
268 decision,
269 default_client: target.llm_client.clone(),
270 ctx,
271 })
272 .await
273 }
274
275 pub async fn info(&self, ctx: Context, decision: Arc<dyn Decision>) -> Result<()> {
279 self.driver.info(ctx.clone(), decision.clone()).await?;
280 observability::record_decision(&ctx, decision.as_ref());
281 Ok(())
282 }
283
284 pub(crate) async fn finish(&self, ctx: Context, result: Result<Response>) -> Result<()> {
288 match result {
289 Ok(response) => self.driver.done(ctx, response).await,
290 Err(err) => self.driver.fail(ctx, err).await,
291 }
292 }
293
294 pub(crate) fn stream(&self) -> impl Stream<Item = Result<Step>> + use<> {
298 self.driver.stream().map(|item| match item? {
299 DriverStep::Request(req) => Ok(Step::CallLlm(Box::new(CallLlmRequest::new(req)))),
300 DriverStep::Info(payload) => payload
301 .downcast::<Arc<dyn Decision>>()
302 .map(|decision| Step::Decision(*decision))
303 .map_err(|_| {
304 DriverError::TypeMismatch {
305 expected: "Arc<dyn Decision>",
306 }
307 .into()
308 }),
309 DriverStep::Done(payload) => payload
310 .downcast::<Response>()
311 .map(Step::ReturnToAgent)
312 .map_err(|_| {
313 DriverError::TypeMismatch {
314 expected: "Response",
315 }
316 .into()
317 }),
318 })
319 }
320}
321
322impl Default for Driver {
323 fn default() -> Self {
324 Self::new()
325 }
326}
327
328pub enum Step {
330 CallLlm(Box<CallLlmRequest>),
334 Decision(Arc<dyn Decision>),
337 ReturnToAgent(Box<Response>),
339}
340
341struct AbortOnDrop(tokio::task::AbortHandle);
343
344impl Drop for AbortOnDrop {
345 fn drop(&mut self) {
346 self.0.abort();
347 }
348}
349
350#[derive(Clone)]
355pub struct LlmTarget {
356 pub semantic_name: String,
360 pub llm_client: Option<Arc<dyn RoutedLlmClient>>,
363}
364
365#[derive(Clone)]
369pub struct LlmTargetSet {
370 targets: Vec<LlmTarget>,
371}
372
373impl LlmTargetSet {
374 pub fn new(targets: Vec<LlmTarget>) -> Self {
376 Self { targets }
377 }
378
379 pub fn targets(&self) -> &[LlmTarget] {
381 &self.targets
382 }
383
384 pub fn get_target(&self, name: &str) -> Result<LlmTarget> {
386 self.targets
387 .iter()
388 .find(|t| t.semantic_name == name)
389 .cloned()
390 .ok_or_else(|| LibsyError::TargetNotFound {
391 target: name.to_string(),
392 })
393 }
394
395 pub fn resolve_target(&self, name: &str, ctx: &Context) -> Result<LlmTarget> {
398 let target = self.get_target(name)?;
399 if !ctx.is_excluded(&target.semantic_name) {
400 return Ok(target);
401 }
402 self.targets
403 .iter()
404 .find(|t| !ctx.is_excluded(&t.semantic_name))
405 .cloned()
406 .ok_or(LibsyError::AllTargetsExcluded)
407 }
408
409 pub fn count_tokens_client(&self) -> Option<Arc<dyn RoutedLlmClient>> {
413 self.targets.iter().find_map(|target| {
414 target
415 .llm_client
416 .as_ref()
417 .filter(|client| client.supports_count_tokens())
418 .cloned()
419 })
420 }
421}
422
423const MAX_EVICTION_SESSIONS: usize = 1_024;
426
427#[derive(Default)]
433pub(crate) struct SessionEvictions {
434 sessions: Mutex<HashMap<String, HashSet<String>>>,
435}
436
437impl SessionEvictions {
438 pub(crate) fn remove(&self, session: &str) {
440 self.sessions.lock().remove(session);
441 }
442
443 fn evicted_in(&self, session: Option<&str>) -> Vec<String> {
445 let Some(session) = session else {
446 return Vec::new();
447 };
448 self.sessions
449 .lock()
450 .get(session)
451 .map(|targets| targets.iter().cloned().collect())
452 .unwrap_or_default()
453 }
454
455 fn record(&self, session: Option<&str>, target: &str) {
458 let Some(session) = session else { return };
459 let mut sessions = self.sessions.lock();
460 if sessions.len() >= MAX_EVICTION_SESSIONS
461 && !sessions.contains_key(session)
462 && let Some(oldest) = sessions.keys().next().cloned()
463 {
464 sessions.remove(&oldest);
465 }
466 sessions
467 .entry(session.to_string())
468 .or_default()
469 .insert(target.to_string());
470 }
471}
472
473fn eligible_targets(targets: &LlmTargetSet, ctx: &Context) -> usize {
475 targets
476 .targets()
477 .iter()
478 .filter(|t| !ctx.is_excluded(&t.semantic_name))
479 .count()
480}
481
482pub(crate) fn exclude_evicted(
485 ctx: &mut Context,
486 targets: &LlmTargetSet,
487 evictions: &SessionEvictions,
488 session: Option<&str>,
489) {
490 for target in evictions.evicted_in(session) {
491 if eligible_targets(targets, ctx) <= 1 {
494 break;
495 }
496 ctx.exclude_target(target);
497 }
498}
499
500#[allow(clippy::too_many_arguments)]
508pub(crate) async fn call_llm_with_overflow_fallback(
509 mut ctx: Context,
510 driver: &Driver,
511 targets: &LlmTargetSet,
512 mut target: LlmTarget,
513 mut decision: Arc<dyn Decision>,
514 request: Request,
515 session: Option<&str>,
516 evictions: &SessionEvictions,
517 fallback_decision: impl Fn(&LlmTarget, &LlmTarget) -> Arc<dyn Decision>,
518) -> Result<Response> {
519 loop {
520 let result = driver
521 .call_llm_target(ctx.clone(), &target, request.clone(), decision.clone())
522 .await;
523 let Err(error) = result else { return result };
524 let LibsyError::ClientCall {
525 target: failed,
526 source: LlmClientError::ContextWindowExceeded { .. },
527 } = &error
528 else {
529 return Err(error);
530 };
531 if !ctx.exclude_target(failed) {
534 return Err(error);
535 }
536 evictions.record(session, failed);
537 let Ok(next) = targets.resolve_target(&target.semantic_name, &ctx) else {
538 return Err(error);
539 };
540 decision = fallback_decision(&target, &next);
541 target = next;
542 driver.info(ctx.clone(), decision.clone()).await?;
543 }
544}
545
546#[async_trait]
567pub trait Algorithm: Send + Sync + 'static {
568 fn name(&self) -> &str;
572
573 async fn create_run_task(
579 self: Arc<Self>,
580 ctx: Context,
581 driver: Driver,
582 request: Request,
583 ) -> Result<Response>;
584
585 #[allow(unused_variables)]
589 async fn process_signals(self: Arc<Self>, signals: Signals) -> Result<()> {
590 Ok(())
591 }
592
593 fn count_tokens_client(&self) -> Option<Arc<dyn RoutedLlmClient>> {
603 None
604 }
605
606 async fn count_tokens(&self, request: Request) -> Result<serde_json::Value> {
614 let client = self
615 .count_tokens_client()
616 .ok_or_else(|| LibsyError::AlgorithmError {
617 message: "no target supports count_tokens (needs an Anthropic upstream)"
618 .to_string(),
619 })?;
620 client
621 .count_tokens(request)
622 .await
623 .map_err(|source| LibsyError::client_call("count_tokens", source))
624 }
625
626 fn run_stream(
636 self: Arc<Self>,
637 ctx: Context,
638 request: Request,
639 observer: Option<RunObserver>,
640 ) -> StepStream {
641 let mut ctx = ctx;
644 ctx.values.insert(
645 observability::ALGORITHM_KEY.to_string(),
646 self.name().to_string(),
647 );
648 let driver = Driver::with_observer(observer);
649 let task_driver = driver.clone();
650 let task_ctx = ctx.clone();
651 let stream = task_driver.stream();
652 let span = observability::run_span(self.name(), &request);
656 let observed_driver = task_driver.clone();
657 let handle = tokio::spawn(
658 async move {
659 observability::observe_run(
660 task_ctx.clone(),
661 observed_driver,
662 self.create_run_task(task_ctx, task_driver, request),
663 )
664 .await
665 }
666 .instrument(span),
667 );
668 let abort_guard = AbortOnDrop(handle.abort_handle());
670
671 let finish_driver = driver.clone();
672 let finish_ctx = ctx;
673 let tail: StepStream = Box::pin(
674 futures::stream::once(async move {
675 let result = match handle.await {
676 Ok(response) => response,
677 Err(source) => Err(LibsyError::AlgorithmTask { source }),
678 };
679 finish_driver.finish(finish_ctx, result).await
680 })
681 .filter_map(|finish_result| async move { finish_result.err().map(Err) }),
682 );
683
684 let stream: StepStream = Box::pin(stream);
685 Box::pin(futures::stream::select(stream, tail).map(move |step| {
686 let _keep_alive = &abort_guard;
688 step
689 }))
690 }
691
692 async fn run(
696 self: Arc<Self>,
697 ctx: Context,
698 request: Request,
699 ) -> Result<(Vec<Arc<dyn Decision>>, Response)> {
700 self.run_observed(ctx, request, None).await
701 }
702
703 async fn run_observed(
705 self: Arc<Self>,
706 ctx: Context,
707 request: Request,
708 observer: Option<RunObserver>,
709 ) -> Result<(Vec<Arc<dyn Decision>>, Response)> {
710 #[tracing::instrument(
716 target = "libsy",
717 name = "libsy.client_call",
718 skip_all,
719 fields(
720 algorithm = observability::algorithm_label(&call.get_routed().ctx),
721 switchyard.algorithm = observability::algorithm_label(&call.get_routed().ctx),
722 switchyard.routing.tier = tracing::field::Empty,
723 selected_model = call.get_decision().selected_model(),
724 otel.kind = "client",
725 otel.name = %format_args!("chat {}", call.get_decision().selected_model()),
726 openinference.span.kind = "LLM",
727 gen_ai.operation.name = "chat",
728 gen_ai.request.model = call.get_decision().selected_model(),
729 gen_ai.request.stream = tracing::field::Empty,
730 gen_ai.request.temperature = tracing::field::Empty,
731 gen_ai.request.top_p = tracing::field::Empty,
732 gen_ai.request.top_k = tracing::field::Empty,
733 gen_ai.request.max_tokens = tracing::field::Empty,
734 gen_ai.request.reasoning.level = tracing::field::Empty,
735 gen_ai.output.type = tracing::field::Empty,
736 gen_ai.conversation.id = tracing::field::Empty,
737 server.address = tracing::field::Empty,
738 server.port = tracing::field::Empty,
739 gen_ai.response.id = tracing::field::Empty,
740 gen_ai.response.model = tracing::field::Empty,
741 gen_ai.usage.input_tokens = tracing::field::Empty,
742 gen_ai.usage.output_tokens = tracing::field::Empty,
743 gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
744 gen_ai.usage.cache_creation.input_tokens = tracing::field::Empty,
745 gen_ai.usage.reasoning.output_tokens = tracing::field::Empty,
746 outcome = tracing::field::Empty,
747 otel.status_code = tracing::field::Empty,
748 error.type = tracing::field::Empty,
749 error = tracing::field::Empty,
750 )
751 )]
752 async fn serve(call: CallLlmRequest) -> Result<()> {
753 let span = tracing::Span::current();
754 observability::record_gen_ai_request(&span, &call.get_routed().request.llm_request);
755 if let Some(tier) = call.get_decision().routing_tier() {
756 span.record("switchyard.routing.tier", tier);
757 }
758 if let Some(session_id) = call
759 .get_routed()
760 .request
761 .metadata
762 .as_ref()
763 .and_then(|metadata| metadata.session_id.as_deref())
764 {
765 span.record("gen_ai.conversation.id", session_id);
766 }
767 let routed = call.get_routed().clone();
768 let target = routed.decision.selected_model().to_string();
769 let client =
770 routed
771 .default_client
772 .clone()
773 .ok_or_else(|| LibsyError::MissingClient {
774 target: target.clone(),
775 })?;
776 let result = client
777 .call(routed.ctx, routed.request, routed.decision)
778 .await
779 .map_err(|source| LibsyError::client_call(target, source));
780 let result = observability::observe_client_call(result);
781 call.respond(result)
782 }
783
784 let stream = self.run_stream(ctx, request, observer);
785 tokio::pin!(stream);
786
787 let mut trace: Vec<Arc<dyn Decision>> = Vec::new();
788 let mut in_flight = futures::stream::FuturesUnordered::new();
789 let mut final_response: Option<Response> = None;
790
791 loop {
792 tokio::select! {
793 Some(result) = in_flight.next() => match result {
794 Ok(()) => {}, Err(err) => return Err(err), },
797 step = stream.next() => {
798 match step {
799 None => break, Some(item) => match item? {
801 Step::CallLlm(call) => in_flight.push(serve(*call)),
802 Step::Decision(decision) => trace.push(decision),
803 Step::ReturnToAgent(response) => {
804 final_response = Some(*response);
805 break;
806 }
807 }
808 }
809 },
810 }
811 }
812 final_response
813 .map(|response| (trace, response))
814 .ok_or(LibsyError::MissingFinalResponse)
815 }
816}
817
818#[cfg(test)]
819mod tests {
820 use super::*;
821 use futures::StreamExt;
822 use switchyard_protocol::{
823 LlmResponse, LlmResponseChunk, completion_text, text_request, text_response,
824 };
825
826 #[derive(Debug, thiserror::Error)]
827 #[error("{0}")]
828 struct TestError(&'static str);
829
830 fn test_error(message: &'static str) -> LibsyError {
831 LibsyError::external("test", TestError(message))
832 }
833
834 struct EchoClient;
836
837 #[async_trait]
838 impl RoutedLlmClient for EchoClient {
839 async fn call(
840 &self,
841 _ctx: Context,
842 _request: Request,
843 decision: Arc<dyn Decision>,
844 ) -> std::result::Result<Response, LlmClientError> {
845 Ok(Response {
847 llm_response: LlmResponse::Agg(text_response(
848 None,
849 decision.selected_model().to_string(),
850 )),
851 metadata: None,
852 })
853 }
854 }
855
856 struct TestDecision {
859 model: String,
860 }
861
862 impl Decision for TestDecision {
863 fn selected_model(&self) -> &str {
864 &self.model
865 }
866 fn reasoning(&self) -> Option<&str> {
867 None
868 }
869 fn as_any(&self) -> &dyn std::any::Any {
870 self
871 }
872 }
873
874 struct TestAlgo {
875 target_set: LlmTargetSet,
876 }
877
878 #[async_trait]
879 impl Algorithm for TestAlgo {
880 fn name(&self) -> &str {
881 "test"
882 }
883
884 async fn create_run_task(
885 self: Arc<Self>,
886 ctx: Context,
887 driver: Driver,
888 request: Request,
889 ) -> Result<Response> {
890 let target = self
891 .target_set
892 .targets()
893 .first()
894 .ok_or(LibsyError::NoTargets)?
895 .clone();
896 let decision: Arc<dyn Decision> = Arc::new(TestDecision {
897 model: target.semantic_name.clone(),
898 });
899 driver.info(ctx.clone(), decision.clone()).await?;
900 driver
901 .call_llm_target(ctx, &target, request, decision)
902 .await
903 }
904 }
905
906 fn orch(target_set: LlmTargetSet) -> Arc<dyn Algorithm> {
908 Arc::new(TestAlgo { target_set })
909 }
910
911 fn request() -> Request {
912 Request {
913 llm_request: text_request(Some("auto".to_string()), "hi".to_string()),
914 raw_request: None,
915 metadata: None,
916 }
917 }
918
919 fn target_set(names: &[(&str, bool)]) -> LlmTargetSet {
921 let targets = names
922 .iter()
923 .map(|(name, has_client)| LlmTarget {
924 semantic_name: name.to_string(),
925 llm_client: has_client.then(|| Arc::new(EchoClient) as Arc<dyn RoutedLlmClient>),
926 })
927 .collect();
928 LlmTargetSet::new(targets)
929 }
930
931 #[tokio::test]
932 async fn observed_run_reports_one_successful_routed_call() -> Result<()> {
933 let observations = Arc::new(Mutex::new(Vec::new()));
934 let observed = observations.clone();
935 let observer: RunObserver = Arc::new(move |observation| observed.lock().push(observation));
936 let (_, response) = orch(target_set(&[("direct/model", true)]))
937 .run_observed(Context::default(), request(), Some(observer))
938 .await?;
939 assert_eq!(
940 response.llm_response.as_agg().map(completion_text),
941 Some("direct/model".to_string())
942 );
943 let observations = observations.lock();
944 assert_eq!(observations.len(), 2);
945 let RunObservation::LlmCall(observation) = &observations[0] else {
946 return Err(test_error("expected an LLM call observation"));
947 };
948 assert_eq!(observation.selected_model, "direct/model");
949 assert!(observation.is_routed);
950 assert!(observation.is_success);
951 assert!(observation.usage.is_some());
952 assert!(matches!(
953 observations[1],
954 RunObservation::RoutingOverhead(_)
955 ));
956 Ok(())
957 }
958
959 #[test]
960 fn target_lookup_returns_the_missing_target() {
961 let error = target_set(&[]).get_target("missing").err();
962 assert!(matches!(
963 error,
964 Some(LibsyError::TargetNotFound { target }) if target == "missing"
965 ));
966 }
967
968 struct StreamingClient {
971 chunks: Vec<LlmResponseChunk>,
972 }
973
974 #[async_trait]
975 impl RoutedLlmClient for StreamingClient {
976 async fn call(
977 &self,
978 _ctx: Context,
979 _request: Request,
980 _decision: Arc<dyn Decision>,
981 ) -> std::result::Result<Response, LlmClientError> {
982 let stream = futures::stream::iter(
983 self.chunks
984 .clone()
985 .into_iter()
986 .map(|chunk| Ok(chunk.into())),
987 )
988 .boxed();
989 Ok(Response {
990 llm_response: LlmResponse::Stream(stream),
991 metadata: None,
992 })
993 }
994 }
995
996 fn streaming_orch(chunks: Vec<LlmResponseChunk>) -> Arc<dyn Algorithm> {
998 let target = LlmTarget {
999 semantic_name: "stream/model".to_string(),
1000 llm_client: Some(Arc::new(StreamingClient { chunks }) as Arc<dyn RoutedLlmClient>),
1001 };
1002 orch(LlmTargetSet::new(vec![target]))
1003 }
1004
1005 #[tokio::test]
1006 async fn run_returns_a_streamed_response_the_caller_aggregates() -> Result<()> {
1007 let orch = streaming_orch(vec![
1010 LlmResponseChunk::MessageStart {
1011 id: Some("m1".to_string()),
1012 model: Some("stream/model".to_string()),
1013 },
1014 LlmResponseChunk::TextDelta {
1015 index: 0,
1016 text: "hel".to_string(),
1017 },
1018 LlmResponseChunk::TextDelta {
1019 index: 0,
1020 text: "lo".to_string(),
1021 },
1022 LlmResponseChunk::MessageStop {
1023 reason: Some("stop".to_string()),
1024 },
1025 ]);
1026 let (trace, response) = orch.run(Context::default(), request()).await?;
1027 let agg = response
1029 .llm_response
1030 .into_agg()
1031 .await
1032 .map_err(|error| LibsyError::external("aggregating response stream", error))?;
1033 assert_eq!(completion_text(&agg), "hello");
1034 assert_eq!(agg.model.as_deref(), Some("stream/model"));
1035 assert_eq!(trace.len(), 1);
1036 Ok(())
1037 }
1038
1039 #[tokio::test]
1040 async fn aggregating_a_streamed_response_propagates_a_mid_stream_error() -> Result<()> {
1041 let orch = streaming_orch(vec![
1044 LlmResponseChunk::TextDelta {
1045 index: 0,
1046 text: "partial".to_string(),
1047 },
1048 LlmResponseChunk::StreamError {
1049 message: "upstream exploded".to_string(),
1050 },
1051 ]);
1052 let (_, response) = orch.run(Context::default(), request()).await?;
1053 match response.llm_response.into_agg().await {
1054 Ok(_) => panic!("expected a mid-stream error, got an aggregate"),
1055 Err(err) => {
1056 assert!(err.to_string().contains("upstream exploded"));
1057 Ok(())
1058 }
1059 }
1060 }
1061
1062 #[tokio::test]
1063 async fn run_offloads_via_promise_then_returns_to_agent() -> Result<()> {
1064 let stream = orch(target_set(&[("offload/model", false)])).run_stream(
1067 Context::default(),
1068 request(),
1069 None,
1070 );
1071 tokio::pin!(stream);
1072
1073 let mut saw_call = false;
1074 let mut final_completion = None;
1075 while let Some(step) = stream.next().await {
1076 match step? {
1077 Step::CallLlm(call) => {
1078 saw_call = true;
1079 assert_eq!(call.get_decision().selected_model(), "offload/model");
1081 call.respond(Ok(Response {
1083 llm_response: LlmResponse::Agg(text_response(
1084 None,
1085 "fulfilled".to_string(),
1086 )),
1087 metadata: None,
1088 }))?;
1089 }
1090 Step::Decision(decision) => {
1091 assert_eq!(decision.selected_model(), "offload/model");
1092 }
1093 Step::ReturnToAgent(response) => {
1094 final_completion = Some(
1095 response
1096 .llm_response
1097 .as_agg()
1098 .map(completion_text)
1099 .unwrap_or_default(),
1100 );
1101 }
1102 }
1103 }
1104
1105 assert!(saw_call, "expected a CallLlm step before ReturnToAgent");
1106 assert_eq!(
1107 final_completion.ok_or_else(|| test_error("no ReturnToAgent step"))?,
1108 "fulfilled"
1109 );
1110 Ok(())
1111 }
1112
1113 #[tokio::test]
1114 async fn client_backed_target_offloads_with_a_default_client() -> Result<()> {
1115 let stream = orch(target_set(&[("direct/model", true)])).run_stream(
1118 Context::default(),
1119 request(),
1120 None,
1121 );
1122 tokio::pin!(stream);
1123
1124 let mut final_completion = None;
1125 while let Some(step) = stream.next().await {
1126 match step? {
1127 Step::CallLlm(call) => {
1128 let routed = call.get_routed().clone();
1129 let client = routed
1130 .default_client
1131 .clone()
1132 .ok_or_else(|| test_error("expected a default client"))?;
1133 let target = routed.decision.selected_model().to_string();
1134 let result = client
1135 .call(routed.ctx, routed.request, routed.decision)
1136 .await
1137 .map_err(|error| LibsyError::client_call(target, error));
1138 call.respond(result)?;
1139 }
1140 Step::Decision(_) => {}
1141 Step::ReturnToAgent(response) => {
1142 final_completion = Some(
1143 response
1144 .llm_response
1145 .as_agg()
1146 .map(completion_text)
1147 .unwrap_or_default(),
1148 );
1149 }
1150 }
1151 }
1152
1153 assert_eq!(
1155 final_completion.ok_or_else(|| test_error("no ReturnToAgent"))?,
1156 "direct/model"
1157 );
1158 Ok(())
1159 }
1160
1161 #[tokio::test]
1162 async fn run_returns_the_response_when_all_targets_have_clients() -> Result<()> {
1163 let (trace, response) = orch(target_set(&[("direct/model", true)]))
1166 .run(Context::default(), request())
1167 .await?;
1168 assert_eq!(
1170 response
1171 .llm_response
1172 .as_agg()
1173 .map(completion_text)
1174 .unwrap_or_default(),
1175 "direct/model"
1176 );
1177 assert_eq!(trace[0].selected_model(), "direct/model");
1178 Ok(())
1179 }
1180
1181 #[tokio::test]
1182 async fn run_errors_when_a_target_lacks_a_client() -> Result<()> {
1183 let error = orch(target_set(&[("offload/model", false)]))
1186 .run(Context::default(), request())
1187 .await
1188 .err()
1189 .ok_or_else(|| test_error("expected a missing-client error"))?;
1190 assert!(matches!(
1191 error,
1192 LibsyError::MissingClient { target } if target == "offload/model"
1193 ));
1194 Ok(())
1195 }
1196
1197 #[tokio::test(flavor = "multi_thread", worker_threads = 12)]
1198 async fn requests_are_processed_in_parallel() -> Result<()> {
1199 use std::time::Duration;
1200 use tokio::sync::Barrier;
1201
1202 const N: usize = 12;
1203
1204 struct BarrierClient {
1210 barrier: Arc<Barrier>,
1211 }
1212
1213 #[async_trait]
1214 impl RoutedLlmClient for BarrierClient {
1215 async fn call(
1216 &self,
1217 _ctx: Context,
1218 _request: Request,
1219 decision: Arc<dyn Decision>,
1220 ) -> std::result::Result<Response, LlmClientError> {
1221 self.barrier.wait().await;
1222 Ok(Response {
1223 llm_response: LlmResponse::Agg(text_response(
1224 None,
1225 decision.selected_model().to_string(),
1226 )),
1227 metadata: None,
1228 })
1229 }
1230 }
1231
1232 let barrier = Arc::new(Barrier::new(N));
1233 let targets = LlmTargetSet::new(vec![LlmTarget {
1234 semantic_name: "m".to_string(),
1235 llm_client: Some(Arc::new(BarrierClient {
1236 barrier: barrier.clone(),
1237 })),
1238 }]);
1239 let algo = orch(targets);
1241
1242 let mut handles = Vec::new();
1243 for _ in 0..N {
1244 let algo = algo.clone();
1245 handles.push(tokio::spawn(async move {
1246 algo.run(Context::default(), request())
1247 .await
1248 .map(|(_, response)| {
1249 response
1250 .llm_response
1251 .as_agg()
1252 .map(completion_text)
1253 .unwrap_or_default()
1254 })
1255 }));
1256 }
1257
1258 for handle in handles {
1259 let completion = tokio::time::timeout(Duration::from_secs(5), handle)
1261 .await
1262 .map_err(|error| LibsyError::external("waiting for test task", error))?
1263 .map_err(|source| LibsyError::AlgorithmTask { source })??;
1264 assert_eq!(completion, "m");
1265 }
1266 Ok(())
1267 }
1268
1269 #[tokio::test]
1270 async fn offload_error_propagates_back_to_the_algorithm() -> Result<()> {
1271 let stream = orch(target_set(&[("offload/model", false)])).run_stream(
1275 Context::default(),
1276 request(),
1277 None,
1278 );
1279 tokio::pin!(stream);
1280
1281 let mut saw_error = false;
1282 while let Some(step) = stream.next().await {
1283 match step {
1284 Ok(Step::CallLlm(call)) => {
1285 call.respond(Err(test_error("upstream model call failed")))?;
1286 }
1287 Ok(Step::Decision(_)) => {}
1288 Ok(Step::ReturnToAgent(..)) => {
1289 return Err(test_error(
1290 "expected the offload error to propagate, got a response",
1291 ));
1292 }
1293 Err(err) => {
1294 assert!(err.to_string().contains("upstream model call failed"));
1296 saw_error = true;
1297 }
1298 }
1299 }
1300
1301 assert!(saw_error, "expected an error step");
1302 Ok(())
1303 }
1304
1305 #[tokio::test]
1306 async fn dropping_the_stream_cancels_the_algorithm_task() -> Result<()> {
1307 use std::sync::atomic::{AtomicBool, Ordering};
1308 use std::time::Duration;
1309 use tokio::sync::mpsc;
1310
1311 struct DropGuard(Arc<AtomicBool>);
1314 impl Drop for DropGuard {
1315 fn drop(&mut self) {
1316 self.0.store(true, Ordering::SeqCst);
1317 }
1318 }
1319
1320 struct StuckAlgo {
1321 started: mpsc::UnboundedSender<()>,
1322 dropped: Arc<AtomicBool>,
1323 }
1324
1325 #[async_trait]
1326 impl Algorithm for StuckAlgo {
1327 fn name(&self) -> &str {
1328 "stuck"
1329 }
1330
1331 async fn create_run_task(
1332 self: Arc<Self>,
1333 _ctx: Context,
1334 _driver: Driver,
1335 _request: Request,
1336 ) -> Result<Response> {
1337 let _guard = DropGuard(self.dropped.clone());
1338 let _ = self.started.send(());
1339 std::future::pending::<()>().await;
1341 unreachable!()
1342 }
1343 }
1344
1345 let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1346 let dropped = Arc::new(AtomicBool::new(false));
1347 let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1348 started: started_tx,
1349 dropped: dropped.clone(),
1350 });
1351
1352 let stream = algo.run_stream(Context::default(), request(), None);
1353 started_rx
1354 .recv()
1355 .await
1356 .ok_or_else(|| test_error("task never started"))?;
1357 drop(stream);
1358 tokio::time::sleep(Duration::from_millis(100)).await;
1359
1360 assert!(
1361 dropped.load(Ordering::SeqCst),
1362 "algorithm task was NOT cancelled after dropping the stream"
1363 );
1364 Ok(())
1365 }
1366
1367 #[tokio::test]
1368 async fn create_run_task_panic_surfaces_as_a_stream_error() -> Result<()> {
1369 struct Panicky;
1372
1373 #[async_trait]
1374 impl Algorithm for Panicky {
1375 fn name(&self) -> &str {
1376 "panicky"
1377 }
1378
1379 async fn create_run_task(
1380 self: Arc<Self>,
1381 _ctx: Context,
1382 _driver: Driver,
1383 _request: Request,
1384 ) -> Result<Response> {
1385 panic!("boom");
1386 }
1387 }
1388
1389 let algo: Arc<dyn Algorithm> = Arc::new(Panicky);
1390 let stream = algo.run_stream(Context::default(), request(), None);
1391 tokio::pin!(stream);
1392
1393 let mut saw_error = false;
1394 while let Some(step) = stream.next().await {
1395 match step {
1396 Err(err) => {
1397 assert!(matches!(err, LibsyError::AlgorithmTask { .. }));
1398 saw_error = true;
1399 }
1400 Ok(_) => return Err(test_error("expected the panic to surface as an error step")),
1401 }
1402 }
1403
1404 assert!(saw_error, "expected an error step from the panicked task");
1405 Ok(())
1406 }
1407
1408 #[tokio::test]
1409 async fn run_returns_an_error_when_the_algorithm_task_panics() -> Result<()> {
1410 struct Panicky;
1413
1414 #[async_trait]
1415 impl Algorithm for Panicky {
1416 fn name(&self) -> &str {
1417 "panicky"
1418 }
1419
1420 async fn create_run_task(
1421 self: Arc<Self>,
1422 _ctx: Context,
1423 _driver: Driver,
1424 _request: Request,
1425 ) -> Result<Response> {
1426 panic!("boom");
1427 }
1428 }
1429
1430 let algo: Arc<dyn Algorithm> = Arc::new(Panicky);
1431 match algo.run(Context::default(), request()).await {
1432 Ok(_) => Err(test_error(
1433 "expected run to surface the algorithm panic as an error",
1434 )),
1435 Err(err) => {
1436 assert!(matches!(err, LibsyError::AlgorithmTask { .. }));
1437 Ok(())
1438 }
1439 }
1440 }
1441
1442 #[tokio::test]
1443 async fn cancelling_run_cancels_the_algorithm_task() -> Result<()> {
1444 use std::sync::atomic::{AtomicBool, Ordering};
1445 use std::time::Duration;
1446 use tokio::sync::mpsc;
1447
1448 struct DropGuard(Arc<AtomicBool>);
1451 impl Drop for DropGuard {
1452 fn drop(&mut self) {
1453 self.0.store(true, Ordering::SeqCst);
1454 }
1455 }
1456
1457 struct StuckAlgo {
1458 started: mpsc::UnboundedSender<()>,
1459 dropped: Arc<AtomicBool>,
1460 }
1461
1462 #[async_trait]
1463 impl Algorithm for StuckAlgo {
1464 fn name(&self) -> &str {
1465 "stuck"
1466 }
1467
1468 async fn create_run_task(
1469 self: Arc<Self>,
1470 _ctx: Context,
1471 _driver: Driver,
1472 _request: Request,
1473 ) -> Result<Response> {
1474 let _guard = DropGuard(self.dropped.clone());
1475 let _ = self.started.send(());
1476 std::future::pending::<()>().await;
1479 unreachable!()
1480 }
1481 }
1482
1483 let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1484 let dropped = Arc::new(AtomicBool::new(false));
1485 let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1486 started: started_tx,
1487 dropped: dropped.clone(),
1488 });
1489
1490 let run_task = tokio::spawn(async move { algo.run(Context::default(), request()).await });
1493 started_rx
1494 .recv()
1495 .await
1496 .ok_or_else(|| test_error("task never started"))?;
1497 run_task.abort();
1498 tokio::time::sleep(Duration::from_millis(100)).await;
1499
1500 assert!(
1501 dropped.load(Ordering::SeqCst),
1502 "algorithm task was NOT cancelled after cancelling run"
1503 );
1504 Ok(())
1505 }
1506
1507 struct LoserClient {
1513 started: Arc<tokio::sync::Notify>,
1514 delay: Option<std::time::Duration>,
1515 }
1516
1517 #[async_trait]
1518 impl RoutedLlmClient for LoserClient {
1519 async fn call(
1520 &self,
1521 _ctx: Context,
1522 _request: Request,
1523 decision: Arc<dyn Decision>,
1524 ) -> std::result::Result<Response, LlmClientError> {
1525 self.started.notify_one();
1526 match self.delay {
1527 Some(delay) => tokio::time::sleep(delay).await,
1528 None => std::future::pending::<()>().await,
1529 }
1530 Ok(Response {
1531 llm_response: LlmResponse::Agg(text_response(
1532 None,
1533 decision.selected_model().to_string(),
1534 )),
1535 metadata: None,
1536 })
1537 }
1538 }
1539
1540 struct GatedEchoClient {
1543 gate: Arc<tokio::sync::Notify>,
1544 }
1545
1546 #[async_trait]
1547 impl RoutedLlmClient for GatedEchoClient {
1548 async fn call(
1549 &self,
1550 _ctx: Context,
1551 _request: Request,
1552 decision: Arc<dyn Decision>,
1553 ) -> std::result::Result<Response, LlmClientError> {
1554 self.gate.notified().await;
1555 Ok(Response {
1556 llm_response: LlmResponse::Agg(text_response(
1557 None,
1558 decision.selected_model().to_string(),
1559 )),
1560 metadata: None,
1561 })
1562 }
1563 }
1564
1565 struct Hedge {
1568 winner: LlmTarget,
1569 loser: LlmTarget,
1570 }
1571
1572 #[async_trait]
1573 impl Algorithm for Hedge {
1574 fn name(&self) -> &str {
1575 "hedge"
1576 }
1577
1578 async fn create_run_task(
1579 self: Arc<Self>,
1580 ctx: Context,
1581 driver: Driver,
1582 request: Request,
1583 ) -> Result<Response> {
1584 let dec_w: Arc<dyn Decision> = Arc::new(TestDecision {
1585 model: self.winner.semantic_name.clone(),
1586 });
1587 let dec_l: Arc<dyn Decision> = Arc::new(TestDecision {
1588 model: self.loser.semantic_name.clone(),
1589 });
1590 let win = driver.call_llm_target(ctx.clone(), &self.winner, request.clone(), dec_w);
1591 let lose = driver.call_llm_target(ctx, &self.loser, request, dec_l);
1592 tokio::select! {
1594 res = win => res,
1595 res = lose => res,
1596 }
1597 }
1598 }
1599
1600 fn hedge(loser_delay: Option<std::time::Duration>) -> Arc<dyn Algorithm> {
1603 let started = Arc::new(tokio::sync::Notify::new());
1604 let winner = LlmTarget {
1605 semantic_name: "winner".to_string(),
1606 llm_client: Some(Arc::new(GatedEchoClient {
1607 gate: started.clone(),
1608 })),
1609 };
1610 let loser = LlmTarget {
1611 semantic_name: "loser".to_string(),
1612 llm_client: Some(Arc::new(LoserClient {
1613 started,
1614 delay: loser_delay,
1615 })),
1616 };
1617 Arc::new(Hedge { winner, loser })
1618 }
1619
1620 #[tokio::test]
1621 async fn run_returns_the_winner_without_a_late_loser_overwriting_it() -> Result<()> {
1622 let (_trace, response) = hedge(Some(std::time::Duration::from_millis(50)))
1625 .run(Context::default(), request())
1626 .await?;
1627 assert_eq!(
1628 response
1629 .llm_response
1630 .as_agg()
1631 .map(completion_text)
1632 .unwrap_or_default(),
1633 "winner"
1634 );
1635 Ok(())
1636 }
1637
1638 #[tokio::test]
1639 async fn run_returns_the_winner_without_hanging_on_a_pending_loser() -> Result<()> {
1640 let run = hedge(None).run(Context::default(), request());
1643 let (_trace, response) = tokio::time::timeout(std::time::Duration::from_secs(1), run)
1644 .await
1645 .map_err(|error| LibsyError::external("waiting for pending loser", error))??;
1646 assert_eq!(
1647 response
1648 .llm_response
1649 .as_agg()
1650 .map(completion_text)
1651 .unwrap_or_default(),
1652 "winner"
1653 );
1654 Ok(())
1655 }
1656
1657 #[tokio::test]
1658 async fn run_surfaces_a_terminal_error_with_many_calls_in_flight() -> Result<()> {
1659 use std::sync::atomic::{AtomicUsize, Ordering};
1660
1661 const N: usize = 10;
1664
1665 struct EnterThenPend {
1667 started: Arc<AtomicUsize>,
1668 all_started: Arc<tokio::sync::Notify>,
1669 n: usize,
1670 }
1671
1672 #[async_trait]
1673 impl RoutedLlmClient for EnterThenPend {
1674 async fn call(
1675 &self,
1676 _ctx: Context,
1677 _request: Request,
1678 _decision: Arc<dyn Decision>,
1679 ) -> std::result::Result<Response, LlmClientError> {
1680 if self.started.fetch_add(1, Ordering::SeqCst) + 1 == self.n {
1681 self.all_started.notify_one();
1682 }
1683 std::future::pending::<()>().await;
1684 unreachable!()
1685 }
1686 }
1687
1688 struct FanOutThenError {
1691 target: LlmTarget,
1692 all_started: Arc<tokio::sync::Notify>,
1693 n: usize,
1694 }
1695
1696 #[async_trait]
1697 impl Algorithm for FanOutThenError {
1698 fn name(&self) -> &str {
1699 "fan_out_then_error"
1700 }
1701
1702 async fn create_run_task(
1703 self: Arc<Self>,
1704 ctx: Context,
1705 driver: Driver,
1706 request: Request,
1707 ) -> Result<Response> {
1708 let offloads = futures::future::join_all((0..self.n).map(|i| {
1709 let decision: Arc<dyn Decision> = Arc::new(TestDecision {
1710 model: format!("m{i}"),
1711 });
1712 driver.call_llm_target(ctx.clone(), &self.target, request.clone(), decision)
1713 }));
1714 tokio::select! {
1715 _ = offloads => Err(test_error("offloads unexpectedly completed")),
1716 _ = self.all_started.notified() => {
1717 Err(test_error("terminal error while calls pending"))
1718 }
1719 }
1720 }
1721 }
1722
1723 let all_started = Arc::new(tokio::sync::Notify::new());
1724 let target = LlmTarget {
1725 semantic_name: "pending".to_string(),
1726 llm_client: Some(Arc::new(EnterThenPend {
1727 started: Arc::new(AtomicUsize::new(0)),
1728 all_started: all_started.clone(),
1729 n: N,
1730 })),
1731 };
1732 let algo: Arc<dyn Algorithm> = Arc::new(FanOutThenError {
1733 target,
1734 all_started,
1735 n: N,
1736 });
1737
1738 let run = algo.run(Context::default(), request());
1741 let result = tokio::time::timeout(std::time::Duration::from_millis(500), run)
1742 .await
1743 .map_err(|error| {
1744 LibsyError::external("waiting for terminal error with full call cap", error)
1745 })?;
1746 match result {
1747 Ok(_) => Err(test_error("expected the terminal error, got a response")),
1748 Err(err) => {
1749 assert!(
1750 err.to_string()
1751 .contains("terminal error while calls pending")
1752 );
1753 Ok(())
1754 }
1755 }
1756 }
1757}