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, RoutingFallbackReason,
29 Signals, Usage,
30};
31
32use super::driver::{DriverRequest, DriverStep, TypeErasedDriver};
33use crate::{DriverError, LibsyError, Result, observability};
34
35pub type StepStream = Pin<Box<dyn Stream<Item = Result<Step>> + Send>>;
39
40#[derive(Clone, Debug)]
42pub struct LlmCallObservation {
43 pub selected_model: String,
45 pub tier: Option<String>,
47 pub is_routed: bool,
49 pub is_success: bool,
51 pub duration: Duration,
53 pub usage: Option<Usage>,
55}
56
57#[derive(Clone, Debug)]
59pub enum RunObservation {
60 LlmCall(LlmCallObservation),
62 RoutingOverhead(Duration),
64}
65
66pub type RunObserver = Arc<dyn Fn(RunObservation) + Send + Sync>;
72
73#[derive(Clone)]
82pub struct RoutedRequest {
83 pub request: Request,
85 pub decision: Arc<dyn Decision>,
87 pub default_client: Option<Arc<dyn RoutedLlmClient>>,
91 pub ctx: Context,
95}
96
97pub struct CallLlmRequest {
105 inner: DriverRequest,
106 routed: RoutedRequest,
107}
108
109impl CallLlmRequest {
110 fn new(inner: DriverRequest) -> Self {
113 let routed = match inner.request::<RoutedRequest>() {
116 Ok(routed) => routed.clone(),
117 Err(_) => unreachable!("CallLlmRequest payload is always a RoutedRequest"),
118 };
119 Self { inner, routed }
120 }
121
122 pub fn get_routed(&self) -> &RoutedRequest {
126 &self.routed
127 }
128
129 pub fn get_request(&self) -> &Request {
131 &self.get_routed().request
132 }
133
134 pub fn get_decision(&self) -> &dyn Decision {
136 self.get_routed().decision.as_ref()
137 }
138
139 pub fn respond(self, result: Result<Response>) -> Result<()> {
143 self.inner.respond::<Response>(result)
144 }
145}
146
147#[derive(Clone)]
154pub struct Driver {
155 driver: TypeErasedDriver,
156 routed_call: Arc<Mutex<Option<Duration>>>,
158 observer: Option<RunObserver>,
159}
160
161impl Driver {
162 pub(crate) fn new() -> Self {
165 Self::with_observer(None)
166 }
167
168 fn with_observer(observer: Option<RunObserver>) -> Self {
169 Self {
170 driver: TypeErasedDriver::new(),
171 routed_call: Arc::new(Mutex::new(None)),
172 observer,
173 }
174 }
175
176 pub(crate) fn routed_call_duration(&self) -> Option<Duration> {
178 *self.routed_call.lock()
179 }
180
181 pub(crate) fn observe_routing_overhead(&self, duration: Duration) {
184 if let Some(observer) = &self.observer {
185 observer(RunObservation::RoutingOverhead(duration));
186 }
187 }
188
189 #[tracing::instrument(
198 target = "libsy",
199 name = "libsy.llm_call",
200 skip_all,
201 fields(
202 algorithm = observability::algorithm_label(&routed.ctx),
203 selected_model = routed.decision.selected_model(),
204 openinference.span.kind = "CHAIN",
205 outcome = tracing::field::Empty,
206 error = tracing::field::Empty,
207 input_tokens = tracing::field::Empty,
208 output_tokens = tracing::field::Empty,
209 total_tokens = tracing::field::Empty,
210 reasoning_tokens = tracing::field::Empty,
211 )
212 )]
213 pub async fn call_llm(&self, routed: RoutedRequest) -> Result<Response> {
214 let algorithm = observability::algorithm_label(&routed.ctx).to_string();
215 let selected_model = routed.decision.selected_model().to_string();
216 let tier = routed.decision.routing_tier().map(str::to_string);
217 let is_routed = routed.decision.is_routed_call();
218 let started = Instant::now();
219 let result = self
220 .driver
221 .fulfill_request::<RoutedRequest, Response>(routed.ctx.clone(), routed)
222 .await;
223 let elapsed = started.elapsed();
224 observability::record_llm_call(
225 &algorithm,
226 &selected_model,
227 tier.as_deref(),
228 is_routed,
229 elapsed,
230 &result,
231 &tracing::Span::current(),
232 );
233 if let Some(observer) = &self.observer {
234 observer(RunObservation::LlmCall(LlmCallObservation {
235 selected_model,
236 tier,
237 is_routed,
238 is_success: result.is_ok(),
239 duration: elapsed,
240 usage: result
241 .as_ref()
242 .ok()
243 .and_then(|response| response.llm_response.as_agg())
244 .map(|response| response.usage.clone()),
245 }));
246 }
247 if is_routed && result.is_ok() {
250 *self.routed_call.lock() = Some(elapsed);
251 }
252 result
253 }
254
255 pub async fn call_llm_target(
261 &self,
262 ctx: Context,
263 target: &LlmTarget,
264 request: Request,
265 decision: Arc<dyn Decision>,
266 ) -> Result<Response> {
267 self.call_llm(RoutedRequest {
268 request,
269 decision,
270 default_client: target.llm_client.clone(),
271 ctx,
272 })
273 .await
274 }
275
276 pub async fn info(&self, ctx: Context, decision: Arc<dyn Decision>) -> Result<()> {
280 self.driver.info(ctx.clone(), decision.clone()).await?;
281 observability::record_decision(&ctx, decision.as_ref());
282 Ok(())
283 }
284
285 pub(crate) async fn finish(&self, ctx: Context, result: Result<Response>) -> Result<()> {
289 match result {
290 Ok(response) => self.driver.done(ctx, response).await,
291 Err(err) => self.driver.fail(ctx, err).await,
292 }
293 }
294
295 pub(crate) fn stream(&self) -> impl Stream<Item = Result<Step>> + use<> {
299 self.driver.stream().map(|item| match item? {
300 DriverStep::Request(req) => Ok(Step::CallLlm(Box::new(CallLlmRequest::new(req)))),
301 DriverStep::Info(payload) => payload
302 .downcast::<Arc<dyn Decision>>()
303 .map(|decision| Step::Decision(*decision))
304 .map_err(|_| {
305 DriverError::TypeMismatch {
306 expected: "Arc<dyn Decision>",
307 }
308 .into()
309 }),
310 DriverStep::Done(payload) => payload
311 .downcast::<Response>()
312 .map(Step::ReturnToAgent)
313 .map_err(|_| {
314 DriverError::TypeMismatch {
315 expected: "Response",
316 }
317 .into()
318 }),
319 })
320 }
321}
322
323impl Default for Driver {
324 fn default() -> Self {
325 Self::new()
326 }
327}
328
329pub enum Step {
331 CallLlm(Box<CallLlmRequest>),
335 Decision(Arc<dyn Decision>),
338 ReturnToAgent(Box<Response>),
340}
341
342struct AbortOnDrop(tokio::task::AbortHandle);
344
345impl Drop for AbortOnDrop {
346 fn drop(&mut self) {
347 self.0.abort();
348 }
349}
350
351#[derive(Clone)]
356pub struct LlmTarget {
357 pub semantic_name: String,
361 pub llm_client: Option<Arc<dyn RoutedLlmClient>>,
364}
365
366#[derive(Clone)]
370pub struct LlmTargetSet {
371 targets: Vec<LlmTarget>,
372}
373
374impl LlmTargetSet {
375 pub fn new(targets: Vec<LlmTarget>) -> Self {
377 Self { targets }
378 }
379
380 pub fn targets(&self) -> &[LlmTarget] {
382 &self.targets
383 }
384
385 pub fn get_target(&self, name: &str) -> Result<LlmTarget> {
387 self.targets
388 .iter()
389 .find(|t| t.semantic_name == name)
390 .cloned()
391 .ok_or_else(|| LibsyError::TargetNotFound {
392 target: name.to_string(),
393 })
394 }
395
396 pub fn resolve_target(&self, name: &str, ctx: &Context) -> Result<LlmTarget> {
399 let target = self.get_target(name)?;
400 if !ctx.is_excluded(&target.semantic_name) {
401 return Ok(target);
402 }
403 self.targets
404 .iter()
405 .find(|t| !ctx.is_excluded(&t.semantic_name))
406 .cloned()
407 .ok_or(LibsyError::AllTargetsExcluded)
408 }
409}
410
411#[derive(Clone, Hash, PartialEq, Eq)]
415pub(crate) enum RoutingIdentity {
416 Session(String),
418 Subagent { session: String, agent: String },
420}
421
422impl RoutingIdentity {
423 pub(crate) fn from_request(request: &Request) -> Option<Self> {
428 let metadata = request.metadata.as_ref()?;
429 let session = metadata.session_id.as_deref().filter(|id| !id.is_empty())?;
430 if metadata.is_subagent {
431 let agent = metadata.agent_id.as_deref().filter(|id| !id.is_empty())?;
432 Some(Self::Subagent {
433 session: session.to_string(),
434 agent: agent.to_string(),
435 })
436 } else {
437 Some(Self::Session(session.to_string()))
438 }
439 }
440
441 fn session(&self) -> &str {
443 match self {
444 Self::Session(session) | Self::Subagent { session, .. } => session,
445 }
446 }
447}
448
449const MAX_EVICTION_IDENTITIES: usize = 1_024;
452
453#[derive(Default)]
459pub(crate) struct SessionEvictions {
460 by_identity: Mutex<HashMap<RoutingIdentity, HashSet<String>>>,
461}
462
463impl SessionEvictions {
464 pub(crate) fn remove_session(&self, session: &str) {
466 self.by_identity
467 .lock()
468 .retain(|identity, _| identity.session() != session);
469 }
470
471 fn evicted_for(&self, identity: Option<&RoutingIdentity>) -> Vec<String> {
473 let Some(identity) = identity else {
474 return Vec::new();
475 };
476 self.by_identity
477 .lock()
478 .get(identity)
479 .map(|targets| targets.iter().cloned().collect())
480 .unwrap_or_default()
481 }
482
483 fn record(&self, identity: Option<&RoutingIdentity>, target: &str) {
486 let Some(identity) = identity else { return };
487 let mut histories = self.by_identity.lock();
488 if histories.len() >= MAX_EVICTION_IDENTITIES
489 && !histories.contains_key(identity)
490 && let Some(oldest) = histories.keys().next().cloned()
491 {
492 histories.remove(&oldest);
493 }
494 histories
495 .entry(identity.clone())
496 .or_default()
497 .insert(target.to_string());
498 }
499}
500
501fn eligible_targets(targets: &LlmTargetSet, ctx: &Context) -> usize {
503 targets
504 .targets()
505 .iter()
506 .filter(|t| !ctx.is_excluded(&t.semantic_name))
507 .count()
508}
509
510pub(crate) fn exclude_evicted(
513 ctx: &mut Context,
514 targets: &LlmTargetSet,
515 evictions: &SessionEvictions,
516 identity: Option<&RoutingIdentity>,
517) {
518 for target in evictions.evicted_for(identity) {
519 if eligible_targets(targets, ctx) <= 1 {
522 break;
523 }
524 ctx.exclude_target(target);
525 }
526}
527
528fn classify_fallback(error: &LibsyError) -> Option<(&str, RoutingFallbackReason)> {
530 let LibsyError::ClientCall { target, source } = error else {
531 return None;
532 };
533 let reason = match source {
534 LlmClientError::ContextWindowExceeded { .. } => RoutingFallbackReason::ContextWindow,
535 LlmClientError::Transport { .. } | LlmClientError::Timeout { .. } => {
536 RoutingFallbackReason::Unavailable
537 }
538 LlmClientError::UpstreamHttp { status, .. }
539 if matches!(*status, 403 | 408 | 429) || (500..=599).contains(status) =>
540 {
541 RoutingFallbackReason::Unavailable
542 }
543 _ => return None,
544 };
545 Some((target, reason))
546}
547
548#[allow(clippy::too_many_arguments)]
556pub(crate) async fn call_llm_with_fallback(
557 mut ctx: Context,
558 driver: &Driver,
559 targets: &LlmTargetSet,
560 mut target: LlmTarget,
561 mut decision: Arc<dyn Decision>,
562 request: Request,
563 identity: Option<&RoutingIdentity>,
564 evictions: &SessionEvictions,
565 target_unavailable: impl Fn(&Request, &str),
566 fallback_decision: impl Fn(&LlmTarget, &LlmTarget, RoutingFallbackReason) -> Arc<dyn Decision>,
567) -> Result<Response> {
568 loop {
569 let result = driver
570 .call_llm_target(ctx.clone(), &target, request.clone(), decision.clone())
571 .await;
572 let Err(error) = result else { return result };
573 let Some((failed, reason)) = classify_fallback(&error) else {
574 return Err(error);
575 };
576 if !ctx.exclude_target(failed) {
579 return Err(error);
580 }
581 match reason {
582 RoutingFallbackReason::ContextWindow => evictions.record(identity, failed),
583 RoutingFallbackReason::Unavailable => target_unavailable(&request, failed),
584 }
585 let Ok(next) = targets.resolve_target(&target.semantic_name, &ctx) else {
586 return Err(error);
587 };
588 decision = fallback_decision(&target, &next, reason);
589 target = next;
590 driver.info(ctx.clone(), decision.clone()).await?;
591 }
592}
593
594#[async_trait]
615pub trait Algorithm: Send + Sync + 'static {
616 fn name(&self) -> &str;
620
621 async fn create_run_task(
627 self: Arc<Self>,
628 ctx: Context,
629 driver: Driver,
630 request: Request,
631 ) -> Result<Response>;
632
633 #[allow(unused_variables)]
637 async fn process_signals(self: Arc<Self>, signals: Signals) -> Result<()> {
638 Ok(())
639 }
640
641 fn run_stream(
651 self: Arc<Self>,
652 ctx: Context,
653 request: Request,
654 observer: Option<RunObserver>,
655 ) -> StepStream {
656 let mut ctx = ctx;
659 ctx.values.insert(
660 observability::ALGORITHM_KEY.to_string(),
661 self.name().to_string(),
662 );
663 let driver = Driver::with_observer(observer);
664 let task_driver = driver.clone();
665 let task_ctx = ctx.clone();
666 let stream = task_driver.stream();
667 let span = observability::run_span(self.name(), &request);
671 let observed_driver = task_driver.clone();
672 let handle = tokio::spawn(
673 async move {
674 observability::observe_run(
675 task_ctx.clone(),
676 observed_driver,
677 self.create_run_task(task_ctx, task_driver, request),
678 )
679 .await
680 }
681 .instrument(span),
682 );
683 let abort_guard = AbortOnDrop(handle.abort_handle());
685
686 let finish_driver = driver.clone();
687 let finish_ctx = ctx;
688 let tail: StepStream = Box::pin(
689 futures::stream::once(async move {
690 let result = match handle.await {
691 Ok(response) => response,
692 Err(source) => Err(LibsyError::AlgorithmTask { source }),
693 };
694 finish_driver.finish(finish_ctx, result).await
695 })
696 .filter_map(|finish_result| async move { finish_result.err().map(Err) }),
697 );
698
699 let stream: StepStream = Box::pin(stream);
700 Box::pin(futures::stream::select(stream, tail).map(move |step| {
701 let _keep_alive = &abort_guard;
703 step
704 }))
705 }
706
707 async fn run(
711 self: Arc<Self>,
712 ctx: Context,
713 request: Request,
714 ) -> Result<(Vec<Arc<dyn Decision>>, Response)> {
715 self.run_observed(ctx, request, None).await
716 }
717
718 async fn run_observed(
720 self: Arc<Self>,
721 ctx: Context,
722 request: Request,
723 observer: Option<RunObserver>,
724 ) -> Result<(Vec<Arc<dyn Decision>>, Response)> {
725 #[tracing::instrument(
731 target = "libsy",
732 name = "libsy.client_call",
733 skip_all,
734 fields(
735 algorithm = observability::algorithm_label(&call.get_routed().ctx),
736 switchyard.algorithm = observability::algorithm_label(&call.get_routed().ctx),
737 switchyard.routing.tier = tracing::field::Empty,
738 selected_model = call.get_decision().selected_model(),
739 otel.kind = "client",
740 otel.name = %format_args!("chat {}", call.get_decision().selected_model()),
741 openinference.span.kind = "LLM",
742 gen_ai.operation.name = "chat",
743 gen_ai.request.model = call.get_decision().selected_model(),
744 gen_ai.request.stream = tracing::field::Empty,
745 gen_ai.request.temperature = tracing::field::Empty,
746 gen_ai.request.top_p = tracing::field::Empty,
747 gen_ai.request.top_k = tracing::field::Empty,
748 gen_ai.request.max_tokens = tracing::field::Empty,
749 gen_ai.request.reasoning.level = tracing::field::Empty,
750 gen_ai.output.type = tracing::field::Empty,
751 gen_ai.conversation.id = tracing::field::Empty,
752 server.address = tracing::field::Empty,
753 server.port = tracing::field::Empty,
754 gen_ai.response.id = tracing::field::Empty,
755 gen_ai.response.model = tracing::field::Empty,
756 gen_ai.usage.input_tokens = tracing::field::Empty,
757 gen_ai.usage.output_tokens = tracing::field::Empty,
758 gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
759 gen_ai.usage.cache_creation.input_tokens = tracing::field::Empty,
760 gen_ai.usage.reasoning.output_tokens = tracing::field::Empty,
761 outcome = tracing::field::Empty,
762 otel.status_code = tracing::field::Empty,
763 error.type = tracing::field::Empty,
764 error = tracing::field::Empty,
765 )
766 )]
767 async fn serve(call: CallLlmRequest) -> Result<()> {
768 let span = tracing::Span::current();
769 observability::record_gen_ai_request(&span, &call.get_routed().request.llm_request);
770 if let Some(tier) = call.get_decision().routing_tier() {
771 span.record("switchyard.routing.tier", tier);
772 }
773 if let Some(session_id) = call
774 .get_routed()
775 .request
776 .metadata
777 .as_ref()
778 .and_then(|metadata| metadata.session_id.as_deref())
779 {
780 span.record("gen_ai.conversation.id", session_id);
781 }
782 let routed = call.get_routed().clone();
783 let target = routed.decision.selected_model().to_string();
784 let client =
785 routed
786 .default_client
787 .clone()
788 .ok_or_else(|| LibsyError::MissingClient {
789 target: target.clone(),
790 })?;
791 let result = client
792 .call(routed.ctx, routed.request, routed.decision)
793 .await
794 .map_err(|source| LibsyError::client_call(target, source));
795 let result = observability::observe_client_call(result);
796 call.respond(result)
797 }
798
799 let stream = self.run_stream(ctx, request, observer);
800 tokio::pin!(stream);
801
802 let mut trace: Vec<Arc<dyn Decision>> = Vec::new();
803 let mut in_flight = futures::stream::FuturesUnordered::new();
804 let mut final_response: Option<Response> = None;
805
806 loop {
807 tokio::select! {
808 Some(result) = in_flight.next() => match result {
809 Ok(()) => {}, Err(err) => return Err(err), },
812 step = stream.next() => {
813 match step {
814 None => break, Some(item) => match item? {
816 Step::CallLlm(call) => in_flight.push(serve(*call)),
817 Step::Decision(decision) => trace.push(decision),
818 Step::ReturnToAgent(response) => {
819 final_response = Some(*response);
820 break;
821 }
822 }
823 }
824 },
825 }
826 }
827 final_response
828 .map(|response| (trace, response))
829 .ok_or(LibsyError::MissingFinalResponse)
830 }
831}
832
833#[cfg(test)]
834mod tests {
835 use super::*;
836 use futures::StreamExt;
837 use switchyard_protocol::{
838 LlmResponse, LlmResponseChunk, completion_text, text_request, text_response,
839 };
840
841 #[derive(Debug, thiserror::Error)]
842 #[error("{0}")]
843 struct TestError(&'static str);
844
845 fn test_error(message: &'static str) -> LibsyError {
846 LibsyError::external("test", TestError(message))
847 }
848
849 fn classified_client_error(source: LlmClientError) -> Option<RoutingFallbackReason> {
850 classify_fallback(&LibsyError::client_call("target", source)).map(|(_, reason)| reason)
851 }
852
853 #[test]
854 fn route_fallback_only_accepts_context_and_unavailable_failures() {
855 assert_eq!(
856 classified_client_error(LlmClientError::ContextWindowExceeded {
857 model: "target".to_string(),
858 message: "too long".to_string(),
859 }),
860 Some(RoutingFallbackReason::ContextWindow)
861 );
862 for source in [
863 LlmClientError::Transport {
864 source: Box::new(std::io::Error::other("connection failed")),
865 },
866 LlmClientError::Timeout {
867 source: Box::new(std::io::Error::other("request timed out")),
868 },
869 ] {
870 assert_eq!(
871 classified_client_error(source),
872 Some(RoutingFallbackReason::Unavailable)
873 );
874 }
875 for (status, expected) in [
876 (400, None),
877 (401, None),
878 (403, Some(RoutingFallbackReason::Unavailable)),
879 (404, None),
880 (408, Some(RoutingFallbackReason::Unavailable)),
881 (409, None),
882 (429, Some(RoutingFallbackReason::Unavailable)),
883 (499, None),
884 (500, Some(RoutingFallbackReason::Unavailable)),
885 (599, Some(RoutingFallbackReason::Unavailable)),
886 (600, None),
887 ] {
888 assert_eq!(
889 classified_client_error(LlmClientError::UpstreamHttp {
890 status,
891 body: "failed".to_string(),
892 }),
893 expected
894 );
895 }
896 assert_eq!(
897 classified_client_error(LlmClientError::InvalidResponse {
898 source: Box::new(std::io::Error::other("invalid response")),
899 }),
900 None
901 );
902 }
903
904 struct EchoClient;
906
907 #[async_trait]
908 impl RoutedLlmClient for EchoClient {
909 async fn call(
910 &self,
911 _ctx: Context,
912 _request: Request,
913 decision: Arc<dyn Decision>,
914 ) -> std::result::Result<Response, LlmClientError> {
915 Ok(Response {
917 llm_response: LlmResponse::Agg(text_response(
918 None,
919 decision.selected_model().to_string(),
920 )),
921 metadata: None,
922 })
923 }
924 }
925
926 struct TestDecision {
929 model: String,
930 }
931
932 impl Decision for TestDecision {
933 fn selected_model(&self) -> &str {
934 &self.model
935 }
936 fn reasoning(&self) -> Option<&str> {
937 None
938 }
939 fn as_any(&self) -> &dyn std::any::Any {
940 self
941 }
942 }
943
944 struct TestAlgo {
945 target_set: LlmTargetSet,
946 }
947
948 #[async_trait]
949 impl Algorithm for TestAlgo {
950 fn name(&self) -> &str {
951 "test"
952 }
953
954 async fn create_run_task(
955 self: Arc<Self>,
956 ctx: Context,
957 driver: Driver,
958 request: Request,
959 ) -> Result<Response> {
960 let target = self
961 .target_set
962 .targets()
963 .first()
964 .ok_or(LibsyError::NoTargets)?
965 .clone();
966 let decision: Arc<dyn Decision> = Arc::new(TestDecision {
967 model: target.semantic_name.clone(),
968 });
969 driver.info(ctx.clone(), decision.clone()).await?;
970 driver
971 .call_llm_target(ctx, &target, request, decision)
972 .await
973 }
974 }
975
976 fn orch(target_set: LlmTargetSet) -> Arc<dyn Algorithm> {
978 Arc::new(TestAlgo { target_set })
979 }
980
981 fn request() -> Request {
982 Request {
983 llm_request: text_request(Some("auto".to_string()), "hi".to_string()),
984 raw_request: None,
985 metadata: None,
986 }
987 }
988
989 fn target_set(names: &[(&str, bool)]) -> LlmTargetSet {
991 let targets = names
992 .iter()
993 .map(|(name, has_client)| LlmTarget {
994 semantic_name: name.to_string(),
995 llm_client: has_client.then(|| Arc::new(EchoClient) as Arc<dyn RoutedLlmClient>),
996 })
997 .collect();
998 LlmTargetSet::new(targets)
999 }
1000
1001 #[tokio::test]
1002 async fn observed_run_reports_one_successful_routed_call() -> Result<()> {
1003 let observations = Arc::new(Mutex::new(Vec::new()));
1004 let observed = observations.clone();
1005 let observer: RunObserver = Arc::new(move |observation| observed.lock().push(observation));
1006 let (_, response) = orch(target_set(&[("direct/model", true)]))
1007 .run_observed(Context::default(), request(), Some(observer))
1008 .await?;
1009 assert_eq!(
1010 response.llm_response.as_agg().map(completion_text),
1011 Some("direct/model".to_string())
1012 );
1013 let observations = observations.lock();
1014 assert_eq!(observations.len(), 2);
1015 let RunObservation::LlmCall(observation) = &observations[0] else {
1016 return Err(test_error("expected an LLM call observation"));
1017 };
1018 assert_eq!(observation.selected_model, "direct/model");
1019 assert!(observation.is_routed);
1020 assert!(observation.is_success);
1021 assert!(observation.usage.is_some());
1022 assert!(matches!(
1023 observations[1],
1024 RunObservation::RoutingOverhead(_)
1025 ));
1026 Ok(())
1027 }
1028
1029 #[test]
1030 fn target_lookup_returns_the_missing_target() {
1031 let error = target_set(&[]).get_target("missing").err();
1032 assert!(matches!(
1033 error,
1034 Some(LibsyError::TargetNotFound { target }) if target == "missing"
1035 ));
1036 }
1037
1038 struct StreamingClient {
1041 chunks: Vec<LlmResponseChunk>,
1042 }
1043
1044 #[async_trait]
1045 impl RoutedLlmClient for StreamingClient {
1046 async fn call(
1047 &self,
1048 _ctx: Context,
1049 _request: Request,
1050 _decision: Arc<dyn Decision>,
1051 ) -> std::result::Result<Response, LlmClientError> {
1052 let stream = futures::stream::iter(
1053 self.chunks
1054 .clone()
1055 .into_iter()
1056 .map(|chunk| Ok(chunk.into())),
1057 )
1058 .boxed();
1059 Ok(Response {
1060 llm_response: LlmResponse::Stream(stream),
1061 metadata: None,
1062 })
1063 }
1064 }
1065
1066 fn streaming_orch(chunks: Vec<LlmResponseChunk>) -> Arc<dyn Algorithm> {
1068 let target = LlmTarget {
1069 semantic_name: "stream/model".to_string(),
1070 llm_client: Some(Arc::new(StreamingClient { chunks }) as Arc<dyn RoutedLlmClient>),
1071 };
1072 orch(LlmTargetSet::new(vec![target]))
1073 }
1074
1075 #[tokio::test]
1076 async fn run_returns_a_streamed_response_the_caller_aggregates() -> Result<()> {
1077 let orch = streaming_orch(vec![
1080 LlmResponseChunk::MessageStart {
1081 id: Some("m1".to_string()),
1082 model: Some("stream/model".to_string()),
1083 },
1084 LlmResponseChunk::TextDelta {
1085 index: 0,
1086 text: "hel".to_string(),
1087 },
1088 LlmResponseChunk::TextDelta {
1089 index: 0,
1090 text: "lo".to_string(),
1091 },
1092 LlmResponseChunk::MessageStop {
1093 reason: Some("stop".to_string()),
1094 },
1095 ]);
1096 let (trace, response) = orch.run(Context::default(), request()).await?;
1097 let agg = response
1099 .llm_response
1100 .into_agg()
1101 .await
1102 .map_err(|error| LibsyError::external("aggregating response stream", error))?;
1103 assert_eq!(completion_text(&agg), "hello");
1104 assert_eq!(agg.model.as_deref(), Some("stream/model"));
1105 assert_eq!(trace.len(), 1);
1106 Ok(())
1107 }
1108
1109 #[tokio::test]
1110 async fn aggregating_a_streamed_response_propagates_a_mid_stream_error() -> Result<()> {
1111 let orch = streaming_orch(vec![
1114 LlmResponseChunk::TextDelta {
1115 index: 0,
1116 text: "partial".to_string(),
1117 },
1118 LlmResponseChunk::StreamError {
1119 message: "upstream exploded".to_string(),
1120 },
1121 ]);
1122 let (_, response) = orch.run(Context::default(), request()).await?;
1123 match response.llm_response.into_agg().await {
1124 Ok(_) => panic!("expected a mid-stream error, got an aggregate"),
1125 Err(err) => {
1126 assert!(err.to_string().contains("upstream exploded"));
1127 Ok(())
1128 }
1129 }
1130 }
1131
1132 #[tokio::test]
1133 async fn run_offloads_via_promise_then_returns_to_agent() -> Result<()> {
1134 let stream = orch(target_set(&[("offload/model", false)])).run_stream(
1137 Context::default(),
1138 request(),
1139 None,
1140 );
1141 tokio::pin!(stream);
1142
1143 let mut saw_call = false;
1144 let mut final_completion = None;
1145 while let Some(step) = stream.next().await {
1146 match step? {
1147 Step::CallLlm(call) => {
1148 saw_call = true;
1149 assert_eq!(call.get_decision().selected_model(), "offload/model");
1151 call.respond(Ok(Response {
1153 llm_response: LlmResponse::Agg(text_response(
1154 None,
1155 "fulfilled".to_string(),
1156 )),
1157 metadata: None,
1158 }))?;
1159 }
1160 Step::Decision(decision) => {
1161 assert_eq!(decision.selected_model(), "offload/model");
1162 }
1163 Step::ReturnToAgent(response) => {
1164 final_completion = Some(
1165 response
1166 .llm_response
1167 .as_agg()
1168 .map(completion_text)
1169 .unwrap_or_default(),
1170 );
1171 }
1172 }
1173 }
1174
1175 assert!(saw_call, "expected a CallLlm step before ReturnToAgent");
1176 assert_eq!(
1177 final_completion.ok_or_else(|| test_error("no ReturnToAgent step"))?,
1178 "fulfilled"
1179 );
1180 Ok(())
1181 }
1182
1183 #[tokio::test]
1184 async fn client_backed_target_offloads_with_a_default_client() -> Result<()> {
1185 let stream = orch(target_set(&[("direct/model", true)])).run_stream(
1188 Context::default(),
1189 request(),
1190 None,
1191 );
1192 tokio::pin!(stream);
1193
1194 let mut final_completion = None;
1195 while let Some(step) = stream.next().await {
1196 match step? {
1197 Step::CallLlm(call) => {
1198 let routed = call.get_routed().clone();
1199 let client = routed
1200 .default_client
1201 .clone()
1202 .ok_or_else(|| test_error("expected a default client"))?;
1203 let target = routed.decision.selected_model().to_string();
1204 let result = client
1205 .call(routed.ctx, routed.request, routed.decision)
1206 .await
1207 .map_err(|error| LibsyError::client_call(target, error));
1208 call.respond(result)?;
1209 }
1210 Step::Decision(_) => {}
1211 Step::ReturnToAgent(response) => {
1212 final_completion = Some(
1213 response
1214 .llm_response
1215 .as_agg()
1216 .map(completion_text)
1217 .unwrap_or_default(),
1218 );
1219 }
1220 }
1221 }
1222
1223 assert_eq!(
1225 final_completion.ok_or_else(|| test_error("no ReturnToAgent"))?,
1226 "direct/model"
1227 );
1228 Ok(())
1229 }
1230
1231 #[tokio::test]
1232 async fn run_returns_the_response_when_all_targets_have_clients() -> Result<()> {
1233 let (trace, response) = orch(target_set(&[("direct/model", true)]))
1236 .run(Context::default(), request())
1237 .await?;
1238 assert_eq!(
1240 response
1241 .llm_response
1242 .as_agg()
1243 .map(completion_text)
1244 .unwrap_or_default(),
1245 "direct/model"
1246 );
1247 assert_eq!(trace[0].selected_model(), "direct/model");
1248 Ok(())
1249 }
1250
1251 #[tokio::test]
1252 async fn run_errors_when_a_target_lacks_a_client() -> Result<()> {
1253 let error = orch(target_set(&[("offload/model", false)]))
1256 .run(Context::default(), request())
1257 .await
1258 .err()
1259 .ok_or_else(|| test_error("expected a missing-client error"))?;
1260 assert!(matches!(
1261 error,
1262 LibsyError::MissingClient { target } if target == "offload/model"
1263 ));
1264 Ok(())
1265 }
1266
1267 #[tokio::test(flavor = "multi_thread", worker_threads = 12)]
1268 async fn requests_are_processed_in_parallel() -> Result<()> {
1269 use std::time::Duration;
1270 use tokio::sync::Barrier;
1271
1272 const N: usize = 12;
1273
1274 struct BarrierClient {
1280 barrier: Arc<Barrier>,
1281 }
1282
1283 #[async_trait]
1284 impl RoutedLlmClient for BarrierClient {
1285 async fn call(
1286 &self,
1287 _ctx: Context,
1288 _request: Request,
1289 decision: Arc<dyn Decision>,
1290 ) -> std::result::Result<Response, LlmClientError> {
1291 self.barrier.wait().await;
1292 Ok(Response {
1293 llm_response: LlmResponse::Agg(text_response(
1294 None,
1295 decision.selected_model().to_string(),
1296 )),
1297 metadata: None,
1298 })
1299 }
1300 }
1301
1302 let barrier = Arc::new(Barrier::new(N));
1303 let targets = LlmTargetSet::new(vec![LlmTarget {
1304 semantic_name: "m".to_string(),
1305 llm_client: Some(Arc::new(BarrierClient {
1306 barrier: barrier.clone(),
1307 })),
1308 }]);
1309 let algo = orch(targets);
1311
1312 let mut handles = Vec::new();
1313 for _ in 0..N {
1314 let algo = algo.clone();
1315 handles.push(tokio::spawn(async move {
1316 algo.run(Context::default(), request())
1317 .await
1318 .map(|(_, response)| {
1319 response
1320 .llm_response
1321 .as_agg()
1322 .map(completion_text)
1323 .unwrap_or_default()
1324 })
1325 }));
1326 }
1327
1328 for handle in handles {
1329 let completion = tokio::time::timeout(Duration::from_secs(5), handle)
1331 .await
1332 .map_err(|error| LibsyError::external("waiting for test task", error))?
1333 .map_err(|source| LibsyError::AlgorithmTask { source })??;
1334 assert_eq!(completion, "m");
1335 }
1336 Ok(())
1337 }
1338
1339 #[tokio::test]
1340 async fn offload_error_propagates_back_to_the_algorithm() -> Result<()> {
1341 let stream = orch(target_set(&[("offload/model", false)])).run_stream(
1345 Context::default(),
1346 request(),
1347 None,
1348 );
1349 tokio::pin!(stream);
1350
1351 let mut saw_error = false;
1352 while let Some(step) = stream.next().await {
1353 match step {
1354 Ok(Step::CallLlm(call)) => {
1355 call.respond(Err(test_error("upstream model call failed")))?;
1356 }
1357 Ok(Step::Decision(_)) => {}
1358 Ok(Step::ReturnToAgent(..)) => {
1359 return Err(test_error(
1360 "expected the offload error to propagate, got a response",
1361 ));
1362 }
1363 Err(err) => {
1364 assert!(err.to_string().contains("upstream model call failed"));
1366 saw_error = true;
1367 }
1368 }
1369 }
1370
1371 assert!(saw_error, "expected an error step");
1372 Ok(())
1373 }
1374
1375 #[tokio::test]
1376 async fn dropping_the_stream_cancels_the_algorithm_task() -> Result<()> {
1377 use std::sync::atomic::{AtomicBool, Ordering};
1378 use std::time::Duration;
1379 use tokio::sync::mpsc;
1380
1381 struct DropGuard(Arc<AtomicBool>);
1384 impl Drop for DropGuard {
1385 fn drop(&mut self) {
1386 self.0.store(true, Ordering::SeqCst);
1387 }
1388 }
1389
1390 struct StuckAlgo {
1391 started: mpsc::UnboundedSender<()>,
1392 dropped: Arc<AtomicBool>,
1393 }
1394
1395 #[async_trait]
1396 impl Algorithm for StuckAlgo {
1397 fn name(&self) -> &str {
1398 "stuck"
1399 }
1400
1401 async fn create_run_task(
1402 self: Arc<Self>,
1403 _ctx: Context,
1404 _driver: Driver,
1405 _request: Request,
1406 ) -> Result<Response> {
1407 let _guard = DropGuard(self.dropped.clone());
1408 let _ = self.started.send(());
1409 std::future::pending::<()>().await;
1411 unreachable!()
1412 }
1413 }
1414
1415 let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1416 let dropped = Arc::new(AtomicBool::new(false));
1417 let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1418 started: started_tx,
1419 dropped: dropped.clone(),
1420 });
1421
1422 let stream = algo.run_stream(Context::default(), request(), None);
1423 started_rx
1424 .recv()
1425 .await
1426 .ok_or_else(|| test_error("task never started"))?;
1427 drop(stream);
1428 tokio::time::sleep(Duration::from_millis(100)).await;
1429
1430 assert!(
1431 dropped.load(Ordering::SeqCst),
1432 "algorithm task was NOT cancelled after dropping the stream"
1433 );
1434 Ok(())
1435 }
1436
1437 #[tokio::test]
1438 async fn create_run_task_panic_surfaces_as_a_stream_error() -> Result<()> {
1439 struct Panicky;
1442
1443 #[async_trait]
1444 impl Algorithm for Panicky {
1445 fn name(&self) -> &str {
1446 "panicky"
1447 }
1448
1449 async fn create_run_task(
1450 self: Arc<Self>,
1451 _ctx: Context,
1452 _driver: Driver,
1453 _request: Request,
1454 ) -> Result<Response> {
1455 panic!("boom");
1456 }
1457 }
1458
1459 let algo: Arc<dyn Algorithm> = Arc::new(Panicky);
1460 let stream = algo.run_stream(Context::default(), request(), None);
1461 tokio::pin!(stream);
1462
1463 let mut saw_error = false;
1464 while let Some(step) = stream.next().await {
1465 match step {
1466 Err(err) => {
1467 assert!(matches!(err, LibsyError::AlgorithmTask { .. }));
1468 saw_error = true;
1469 }
1470 Ok(_) => return Err(test_error("expected the panic to surface as an error step")),
1471 }
1472 }
1473
1474 assert!(saw_error, "expected an error step from the panicked task");
1475 Ok(())
1476 }
1477
1478 #[tokio::test]
1479 async fn run_returns_an_error_when_the_algorithm_task_panics() -> Result<()> {
1480 struct Panicky;
1483
1484 #[async_trait]
1485 impl Algorithm for Panicky {
1486 fn name(&self) -> &str {
1487 "panicky"
1488 }
1489
1490 async fn create_run_task(
1491 self: Arc<Self>,
1492 _ctx: Context,
1493 _driver: Driver,
1494 _request: Request,
1495 ) -> Result<Response> {
1496 panic!("boom");
1497 }
1498 }
1499
1500 let algo: Arc<dyn Algorithm> = Arc::new(Panicky);
1501 match algo.run(Context::default(), request()).await {
1502 Ok(_) => Err(test_error(
1503 "expected run to surface the algorithm panic as an error",
1504 )),
1505 Err(err) => {
1506 assert!(matches!(err, LibsyError::AlgorithmTask { .. }));
1507 Ok(())
1508 }
1509 }
1510 }
1511
1512 #[tokio::test]
1513 async fn cancelling_run_cancels_the_algorithm_task() -> Result<()> {
1514 use std::sync::atomic::{AtomicBool, Ordering};
1515 use std::time::Duration;
1516 use tokio::sync::mpsc;
1517
1518 struct DropGuard(Arc<AtomicBool>);
1521 impl Drop for DropGuard {
1522 fn drop(&mut self) {
1523 self.0.store(true, Ordering::SeqCst);
1524 }
1525 }
1526
1527 struct StuckAlgo {
1528 started: mpsc::UnboundedSender<()>,
1529 dropped: Arc<AtomicBool>,
1530 }
1531
1532 #[async_trait]
1533 impl Algorithm for StuckAlgo {
1534 fn name(&self) -> &str {
1535 "stuck"
1536 }
1537
1538 async fn create_run_task(
1539 self: Arc<Self>,
1540 _ctx: Context,
1541 _driver: Driver,
1542 _request: Request,
1543 ) -> Result<Response> {
1544 let _guard = DropGuard(self.dropped.clone());
1545 let _ = self.started.send(());
1546 std::future::pending::<()>().await;
1549 unreachable!()
1550 }
1551 }
1552
1553 let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1554 let dropped = Arc::new(AtomicBool::new(false));
1555 let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1556 started: started_tx,
1557 dropped: dropped.clone(),
1558 });
1559
1560 let run_task = tokio::spawn(async move { algo.run(Context::default(), request()).await });
1563 started_rx
1564 .recv()
1565 .await
1566 .ok_or_else(|| test_error("task never started"))?;
1567 run_task.abort();
1568 tokio::time::sleep(Duration::from_millis(100)).await;
1569
1570 assert!(
1571 dropped.load(Ordering::SeqCst),
1572 "algorithm task was NOT cancelled after cancelling run"
1573 );
1574 Ok(())
1575 }
1576
1577 struct LoserClient {
1583 started: Arc<tokio::sync::Notify>,
1584 delay: Option<std::time::Duration>,
1585 }
1586
1587 #[async_trait]
1588 impl RoutedLlmClient for LoserClient {
1589 async fn call(
1590 &self,
1591 _ctx: Context,
1592 _request: Request,
1593 decision: Arc<dyn Decision>,
1594 ) -> std::result::Result<Response, LlmClientError> {
1595 self.started.notify_one();
1596 match self.delay {
1597 Some(delay) => tokio::time::sleep(delay).await,
1598 None => std::future::pending::<()>().await,
1599 }
1600 Ok(Response {
1601 llm_response: LlmResponse::Agg(text_response(
1602 None,
1603 decision.selected_model().to_string(),
1604 )),
1605 metadata: None,
1606 })
1607 }
1608 }
1609
1610 struct GatedEchoClient {
1613 gate: Arc<tokio::sync::Notify>,
1614 }
1615
1616 #[async_trait]
1617 impl RoutedLlmClient for GatedEchoClient {
1618 async fn call(
1619 &self,
1620 _ctx: Context,
1621 _request: Request,
1622 decision: Arc<dyn Decision>,
1623 ) -> std::result::Result<Response, LlmClientError> {
1624 self.gate.notified().await;
1625 Ok(Response {
1626 llm_response: LlmResponse::Agg(text_response(
1627 None,
1628 decision.selected_model().to_string(),
1629 )),
1630 metadata: None,
1631 })
1632 }
1633 }
1634
1635 struct Hedge {
1638 winner: LlmTarget,
1639 loser: LlmTarget,
1640 }
1641
1642 #[async_trait]
1643 impl Algorithm for Hedge {
1644 fn name(&self) -> &str {
1645 "hedge"
1646 }
1647
1648 async fn create_run_task(
1649 self: Arc<Self>,
1650 ctx: Context,
1651 driver: Driver,
1652 request: Request,
1653 ) -> Result<Response> {
1654 let dec_w: Arc<dyn Decision> = Arc::new(TestDecision {
1655 model: self.winner.semantic_name.clone(),
1656 });
1657 let dec_l: Arc<dyn Decision> = Arc::new(TestDecision {
1658 model: self.loser.semantic_name.clone(),
1659 });
1660 let win = driver.call_llm_target(ctx.clone(), &self.winner, request.clone(), dec_w);
1661 let lose = driver.call_llm_target(ctx, &self.loser, request, dec_l);
1662 tokio::select! {
1664 res = win => res,
1665 res = lose => res,
1666 }
1667 }
1668 }
1669
1670 fn hedge(loser_delay: Option<std::time::Duration>) -> Arc<dyn Algorithm> {
1673 let started = Arc::new(tokio::sync::Notify::new());
1674 let winner = LlmTarget {
1675 semantic_name: "winner".to_string(),
1676 llm_client: Some(Arc::new(GatedEchoClient {
1677 gate: started.clone(),
1678 })),
1679 };
1680 let loser = LlmTarget {
1681 semantic_name: "loser".to_string(),
1682 llm_client: Some(Arc::new(LoserClient {
1683 started,
1684 delay: loser_delay,
1685 })),
1686 };
1687 Arc::new(Hedge { winner, loser })
1688 }
1689
1690 #[tokio::test]
1691 async fn run_returns_the_winner_without_a_late_loser_overwriting_it() -> Result<()> {
1692 let (_trace, response) = hedge(Some(std::time::Duration::from_millis(50)))
1695 .run(Context::default(), request())
1696 .await?;
1697 assert_eq!(
1698 response
1699 .llm_response
1700 .as_agg()
1701 .map(completion_text)
1702 .unwrap_or_default(),
1703 "winner"
1704 );
1705 Ok(())
1706 }
1707
1708 #[tokio::test]
1709 async fn run_returns_the_winner_without_hanging_on_a_pending_loser() -> Result<()> {
1710 let run = hedge(None).run(Context::default(), request());
1713 let (_trace, response) = tokio::time::timeout(std::time::Duration::from_secs(1), run)
1714 .await
1715 .map_err(|error| LibsyError::external("waiting for pending loser", error))??;
1716 assert_eq!(
1717 response
1718 .llm_response
1719 .as_agg()
1720 .map(completion_text)
1721 .unwrap_or_default(),
1722 "winner"
1723 );
1724 Ok(())
1725 }
1726
1727 #[tokio::test]
1728 async fn run_surfaces_a_terminal_error_with_many_calls_in_flight() -> Result<()> {
1729 use std::sync::atomic::{AtomicUsize, Ordering};
1730
1731 const N: usize = 10;
1734
1735 struct EnterThenPend {
1737 started: Arc<AtomicUsize>,
1738 all_started: Arc<tokio::sync::Notify>,
1739 n: usize,
1740 }
1741
1742 #[async_trait]
1743 impl RoutedLlmClient for EnterThenPend {
1744 async fn call(
1745 &self,
1746 _ctx: Context,
1747 _request: Request,
1748 _decision: Arc<dyn Decision>,
1749 ) -> std::result::Result<Response, LlmClientError> {
1750 if self.started.fetch_add(1, Ordering::SeqCst) + 1 == self.n {
1751 self.all_started.notify_one();
1752 }
1753 std::future::pending::<()>().await;
1754 unreachable!()
1755 }
1756 }
1757
1758 struct FanOutThenError {
1761 target: LlmTarget,
1762 all_started: Arc<tokio::sync::Notify>,
1763 n: usize,
1764 }
1765
1766 #[async_trait]
1767 impl Algorithm for FanOutThenError {
1768 fn name(&self) -> &str {
1769 "fan_out_then_error"
1770 }
1771
1772 async fn create_run_task(
1773 self: Arc<Self>,
1774 ctx: Context,
1775 driver: Driver,
1776 request: Request,
1777 ) -> Result<Response> {
1778 let offloads = futures::future::join_all((0..self.n).map(|i| {
1779 let decision: Arc<dyn Decision> = Arc::new(TestDecision {
1780 model: format!("m{i}"),
1781 });
1782 driver.call_llm_target(ctx.clone(), &self.target, request.clone(), decision)
1783 }));
1784 tokio::select! {
1785 _ = offloads => Err(test_error("offloads unexpectedly completed")),
1786 _ = self.all_started.notified() => {
1787 Err(test_error("terminal error while calls pending"))
1788 }
1789 }
1790 }
1791 }
1792
1793 let all_started = Arc::new(tokio::sync::Notify::new());
1794 let target = LlmTarget {
1795 semantic_name: "pending".to_string(),
1796 llm_client: Some(Arc::new(EnterThenPend {
1797 started: Arc::new(AtomicUsize::new(0)),
1798 all_started: all_started.clone(),
1799 n: N,
1800 })),
1801 };
1802 let algo: Arc<dyn Algorithm> = Arc::new(FanOutThenError {
1803 target,
1804 all_started,
1805 n: N,
1806 });
1807
1808 let run = algo.run(Context::default(), request());
1811 let result = tokio::time::timeout(std::time::Duration::from_millis(500), run)
1812 .await
1813 .map_err(|error| {
1814 LibsyError::external("waiting for terminal error with full call cap", error)
1815 })?;
1816 match result {
1817 Ok(_) => Err(test_error("expected the terminal error, got a response")),
1818 Err(err) => {
1819 assert!(
1820 err.to_string()
1821 .contains("terminal error while calls pending")
1822 );
1823 Ok(())
1824 }
1825 }
1826 }
1827}