Skip to main content

switchyard_libsy/core/
algorithm.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The [`Algorithm`] trait and its [`Driver`] — the orchestration contract every
5//! algorithm implements and the offload channel it uses for routing-time model calls.
6
7use std::{future::Future, panic::AssertUnwindSafe, pin::Pin, sync::Arc, time::Instant};
8
9use async_trait::async_trait;
10use futures::{FutureExt, Stream, StreamExt};
11use tokio::sync::{mpsc, oneshot};
12use tokio_stream::wrappers::ReceiverStream;
13use tracing::Instrument;
14
15/// The request/response protocol types come from [`switchyard_protocol`].
16/// [`switchyard_protocol::LlmRequest`] is the normalized request;
17/// [`switchyard_protocol::AggLlmResponse`] is the buffered response;
18/// [`switchyard_protocol::LlmResponseChunk`] is normalized streaming content;
19/// [`switchyard_protocol::LlmResponseStreamEvent`] is its host/algorithm envelope; and
20/// [`switchyard_protocol::LlmResponse`] carries either a live
21/// [`switchyard_protocol::LlmResponseStream`] or the terminal aggregate.
22use switchyard_protocol::{ModelId, Request, Response};
23
24use crate::{DriverError, LibsyError, Result, observability};
25
26/// A boxed, `Send` stream of [`Step`]s — the output of
27/// [`Algorithm::run_stream`]. Boxed so the trait method that produces it keeps
28/// `Arc<dyn Algorithm>` object-safe.
29pub type StepStream = Pin<Box<dyn Stream<Item = Result<Step>> + Send>>;
30
31/// An offloaded model call, surfaced inside [`Step::CallModel`].
32///
33/// The host reads the public fields, performs (or delegates) the model call, and fulfills it
34/// with [`respond`](Self::respond) — unblocking the algorithm's [`Driver::call_model`] on the
35/// other side. `switchyard-llm-client`'s `run` is the ready-made consumer that does this for
36/// you.
37///
38/// [`Driver::call_model`] stamps the first candidate model onto the request before publishing
39/// the call. A consumer that falls through to a later candidate must re-stamp it.
40pub struct CallModel {
41    /// The name of the algorithm that produced this call, so a host instrumenting the
42    /// calls it serves can attribute its own spans to the algorithm behind them.
43    pub algorithm: String,
44    /// The request to serve; its `model` is stamped with the first candidate.
45    pub request: Request,
46    /// Candidate models, tried in order until one answers. Never empty.
47    pub models: Vec<ModelId>,
48    // How to send the response back to the algorithm
49    reply: oneshot::Sender<Result<Response>>,
50}
51
52impl CallModel {
53    /// Fulfill the promise with the caller's model-call result. Pass `Err(..)` to
54    /// propagate a failed model call back to the algorithm. Consumes the promise: it
55    /// can only be fulfilled once.
56    pub fn respond(self, result: Result<Response>) -> Result<()> {
57        self.reply
58            .send(result)
59            .map_err(|_| DriverError::ResponseDropped.into())
60    }
61}
62
63/// The terminal result of routing.
64pub struct RoutingOutcome {
65    /// The model selected by the algorithm and tried first by the client.
66    pub selected_model_id: ModelId,
67    /// Additional models the client may try in order after an eligible failure.
68    pub fallback_models: Vec<ModelId>,
69    /// The request after all routing-time rewrites, stamped with the selected model.
70    pub request: Request,
71    /// A response produced while routing, or `None` when the client must make the answer call.
72    pub response: Option<Response>,
73}
74
75impl RoutingOutcome {
76    /// The decision is that client should send this `request`. The `selected_model_id`
77    /// will be written into it by this function.
78    /// If that fails client should try the `fallback_models` in order.
79    pub fn route_to(
80        selected_model_id: ModelId,
81        fallback_models: Vec<ModelId>,
82        mut request: Request,
83    ) -> Self {
84        request.llm_request.model = Some(selected_model_id.to_string());
85        Self {
86            selected_model_id,
87            fallback_models,
88            request,
89            response: None,
90        }
91    }
92
93    /// Algorithm generated the response as part of the routing decision. Here it is.
94    /// The `request` will have the `selected_model_id` written into it by this function.
95    pub fn answered(selected_model_id: ModelId, mut request: Request, response: Response) -> Self {
96        request.llm_request.model = Some(selected_model_id.to_string());
97        Self {
98            selected_model_id,
99            fallback_models: Vec::new(),
100            request,
101            response: Some(response),
102        }
103    }
104}
105
106/// How an algorithm's [`route`](Algorithm::route) makes model calls.
107#[derive(Clone)]
108pub struct Driver {
109    step_tx: mpsc::Sender<Result<Step>>,
110    /// The owning algorithm's telemetry label, stamped onto every call this driver publishes.
111    algorithm: String,
112}
113
114impl Driver {
115    /// Build an empty driver with its step channel ready. Created per call by
116    /// [`run_stream`](Algorithm::run_stream). Also returns the Step receiver.
117    pub(crate) fn new(algorithm: &str) -> (Self, mpsc::Receiver<Result<Step>>) {
118        // Capacity one keeps the algorithm paced by the stream consumer. It limits queued steps,
119        // not model calls already pulled from the stream, which can still run at the same time.
120        // A larger buffer would use more memory and let the algorithm run farther ahead with
121        // little benefit because reading a step is cheap compared with serving a model call.
122        let (step_tx, step_rx) = mpsc::channel(1);
123        (
124            Self {
125                step_tx,
126                algorithm: algorithm.to_string(),
127            },
128            step_rx,
129        )
130    }
131
132    /// Publish a model call and await the consumer's response.
133    ///
134    /// Errors if the stream is closed or the call failed.
135    /// The await is wrapped in a `libsy.llm_call` span measuring *fulfillment* as
136    /// the algorithm observes it (host queueing/serving included; a streamed
137    /// response resolves when its stream handle arrives); latency, outcome, and
138    /// token usage are recorded when it resolves. The provider call itself is the
139    /// host's, and is instrumented by whoever makes it.
140    #[tracing::instrument(
141        target = "libsy",
142        name = "libsy.llm_call",
143        skip_all,
144        fields(
145            algorithm = self.algorithm,
146            selected_model = %models.first().map(ModelId::as_str).unwrap_or("NoTargets"),
147            openinference.span.kind = "CHAIN",
148            outcome = tracing::field::Empty,
149            error = tracing::field::Empty,
150            input_tokens = tracing::field::Empty,
151            output_tokens = tracing::field::Empty,
152            total_tokens = tracing::field::Empty,
153            reasoning_tokens = tracing::field::Empty,
154        )
155    )]
156    pub async fn call_model(&self, mut request: Request, models: Vec<ModelId>) -> Result<Response> {
157        let Some(selected_model_id) = models.first().cloned() else {
158            return Err(LibsyError::NoTargets);
159        };
160        request.llm_request.model = Some(selected_model_id.to_string());
161        let started = Instant::now();
162        let (reply, response) = oneshot::channel::<Result<Response>>();
163        let call = CallModel {
164            algorithm: self.algorithm.clone(),
165            request,
166            models,
167            reply,
168        };
169        let result = async {
170            self.step_tx
171                .send(Ok(Step::CallModel(Box::new(call))))
172                .await
173                .map_err(|_| DriverError::StreamClosed)?;
174            response
175                .await
176                .map_err(|_| LibsyError::from(DriverError::ResponseDropped))?
177        }
178        .await;
179        let elapsed = started.elapsed();
180        observability::record_llm_call(
181            &self.algorithm,
182            selected_model_id.as_str(),
183            elapsed,
184            &result,
185            &tracing::Span::current(),
186        );
187        result
188    }
189
190    /// Emit the terminal step: [`Step::Done`] on `Ok`, or an `Err` stream
191    /// item on failure. Internal: called once by [`run_stream`](Algorithm::run_stream)
192    /// when the algorithm finishes.
193    pub(crate) async fn finish(&self, result: Result<RoutingOutcome>) -> Result<()> {
194        let selected_model = result
195            .as_ref()
196            .ok()
197            .map(|outcome| outcome.selected_model_id.clone());
198        let step = result.map(|outcome| Step::Done(Box::new(outcome)));
199        self.step_tx
200            .send(step)
201            .await
202            .map_err(|_| DriverError::StreamClosed)?;
203        if let Some(selected_model) = selected_model {
204            observability::record_decision(&self.algorithm, &selected_model);
205        }
206        Ok(())
207    }
208}
209
210/// One item in the stream returned by [`Algorithm::run_stream`].
211pub enum Step {
212    /// The algorithm needs this model call performed. The host serves it and fulfills
213    /// it with [`CallModel::respond`]. Boxed: it is by far the largest variant.
214    CallModel(Box<CallModel>),
215    /// The algorithm finished with its routing outcome — the last step of a run.
216    Done(Box<RoutingOutcome>),
217}
218
219/// Drive [`Algorithm::run_stream`] to completion, handing each offloaded call to `serve`.
220///
221/// Returns the final [`RoutingOutcome`].
222/// `serve` owns the call: it performs it however the host likes and must fulfill the promise
223/// with [`CallModel::respond`]. A failed *model* call belongs in `respond` — the
224/// algorithm may route around it. Returning `Err` from `serve` aborts the whole run, so
225/// reserve it for infrastructure failures. Calls are served concurrently, so an algorithm
226/// that offloads several at once (hedging, fan-out) gets real parallelism.
227///
228/// libsy performs no I/O; this is only the mechanics of consuming its own step stream, kept
229/// here so every host does not reimplement the same loop. `switchyard-llm-client`'s `run`
230/// is this function plus an HTTP client.
231pub async fn drive<F, Fut>(
232    algorithm: Arc<dyn Algorithm>,
233    request: Request,
234    serve: F,
235) -> Result<RoutingOutcome>
236where
237    F: Fn(CallModel) -> Fut,
238    Fut: Future<Output = Result<()>>,
239{
240    let stream = algorithm.run_stream(request);
241    tokio::pin!(stream);
242
243    let mut in_flight = futures::stream::FuturesUnordered::new();
244    let mut final_outcome: Option<RoutingOutcome> = None;
245
246    loop {
247        tokio::select! {
248            Some(result) = in_flight.next() => match result {
249                Ok(()) => {}, // CallModel completed successfully
250                Err(err) => return Err(err), // CallModel failed, propagate the error
251            },
252            step = stream.next() => {
253                match step {
254                    None => break, // stream has ended, no more steps
255                    Some(item) => match item? {
256                        Step::CallModel(call) => in_flight.push(serve(*call)),
257                        Step::Done(outcome) => {
258                            final_outcome = Some(*outcome);
259                            break;
260                        }
261                    }
262                }
263            },
264        }
265    }
266    final_outcome.ok_or(LibsyError::MissingFinalResponse)
267}
268
269/// Recover the message from an algorithm's panic.
270fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
271    payload
272        .downcast_ref::<&'static str>()
273        .map(|message| (*message).to_string())
274        .or_else(|| payload.downcast_ref::<String>().cloned())
275        .unwrap_or_else(|| "unknown panic payload".to_string())
276}
277
278/// Abort guard
279struct AbortOnDrop(tokio::task::AbortHandle);
280
281impl Drop for AbortOnDrop {
282    fn drop(&mut self) {
283        self.0.abort();
284    }
285}
286
287/// Errors unless `targets` contains `name`.
288///
289/// Config target names must be resolved before an algorithm is built. This list contains
290/// model IDs, not target names.
291pub(crate) fn ensure_model_is_target(targets: &[ModelId], name: &ModelId) -> Result<()> {
292    targets
293        .iter()
294        .any(|target| target == name)
295        .then_some(())
296        .ok_or_else(|| LibsyError::TargetNotFound {
297            target: name.clone(),
298        })
299}
300
301/// Key for routing affinity: a root request by its session, a child request by its session
302/// and agent.
303#[derive(Clone, Hash, PartialEq, Eq)]
304pub(crate) enum RoutingIdentity {
305    /// Root request, keyed by session ID.
306    Session(String),
307    /// Child request, keyed by session and agent IDs.
308    Subagent { session: String, agent: String },
309}
310
311impl RoutingIdentity {
312    /// Builds a root or child identity from non-empty request metadata.
313    ///
314    /// A child request missing either ID returns `None`, so it keeps no routing history
315    /// rather than sharing the parent's.
316    pub(crate) fn from_request(request: &Request) -> Option<Self> {
317        let metadata = request.metadata.as_ref()?;
318        let session = metadata.session_id.as_deref().filter(|id| !id.is_empty())?;
319        if metadata.is_subagent {
320            let agent = metadata.agent_id.as_deref().filter(|id| !id.is_empty())?;
321            Some(Self::Subagent {
322                session: session.to_string(),
323                agent: agent.to_string(),
324            })
325        } else {
326            Some(Self::Session(session.to_string()))
327        }
328    }
329}
330
331/// An optimization strategy. Implement [`route`](Self::route);
332/// callers drive it with [`run_stream`](Self::run_stream), serving each [`Step::CallModel`]
333/// it emits. `switchyard-llm-client`'s `run` is the ready-made consumer that does this
334/// over HTTP.
335///
336/// Methods take `self: Arc<Self>`: one algorithm (`Arc<dyn Algorithm>`) is shared across
337/// requests and run concurrently, so it owns its thread-safety and any shared state.
338///
339/// # Concurrency
340///
341/// A host may run the same algorithm concurrently for many requests. Implementations
342/// must synchronize their own mutable shared state. Each call to [`run_stream`](Self::run_stream)
343/// creates an independent [`Driver`], so model-call promises and emitted [`Step`]s cannot
344/// cross between runs.
345///
346/// # Observability
347///
348/// [`run_stream`](Self::run_stream) creates a `libsy.run` span, and each offloaded model
349/// call creates a `libsy.llm_call` span. Routing decisions and failures are emitted through
350/// `tracing`; metrics use the global OpenTelemetry meter provider. The provider call
351/// itself belongs to the host, and is instrumented by whoever makes it.
352#[async_trait]
353pub trait Algorithm: Send + Sync + 'static {
354    /// Stable, low-cardinality name identifying this algorithm — the
355    /// `algorithm` attribute on every span, metric, and log line the crate
356    /// emits for its runs.
357    fn name(&self) -> &str;
358
359    /// Run one request to completion: make routing-time model calls with
360    /// [`Driver::call_model`] and return the terminal [`RoutingOutcome`].
361    /// The method an algorithm implements; [`run_stream`](Self::run_stream) drives it.
362    async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<RoutingOutcome>;
363
364    /// Process a request to completion, returning a stream of [`Step`]s.
365    ///
366    /// The consumer must fulfill every [`Step::CallModel`] before the algorithm can
367    /// continue. Every run ends with exactly one terminal item — [`Step::Done`] on
368    /// success, an `Err` item on failure, including when the algorithm panics. Dropping
369    /// the stream aborts the spawned algorithm task.
370    ///
371    /// Every invocation owns a separate [`Driver`].
372    fn run_stream(self: Arc<Self>, request: Request) -> StepStream {
373        let (driver, step_rx) = Driver::new(self.name());
374        let span = observability::run_span(self.name(), &request);
375        let handle = tokio::spawn(
376            async move {
377                let algorithm = self.name().to_string();
378                // Catch a panicking algorithm so the run still publishes a terminal step.
379                let route = AssertUnwindSafe(self.route(driver.clone(), request)).catch_unwind();
380                let result = observability::observe_run(&algorithm, async move {
381                    route.await.unwrap_or_else(|payload| {
382                        Err(LibsyError::AlgorithmError {
383                            message: format!(
384                                "algorithm task panicked: {}",
385                                panic_message(payload.as_ref())
386                            ),
387                        })
388                    })
389                })
390                .await;
391
392                let _ = driver.finish(result).await;
393            }
394            .instrument(span),
395        );
396        // Dropping the stream aborts the algorithm task when its consumer goes away.
397        let abort_guard = AbortOnDrop(handle.abort_handle());
398        Box::pin(ReceiverStream::new(step_rx).map(move |step| {
399            // link abort guard to stream
400            let _keep_alive = &abort_guard;
401            step
402        }))
403    }
404}
405
406#[cfg(test)]
407mod tests {
408    use std::collections::HashMap;
409
410    use super::*;
411    use crate::core::testing::{Serve, ServeResult, echo, reply, test_drive};
412    use futures::StreamExt;
413    use switchyard_protocol::{
414        LlmResponse, LlmResponseChunk, completion_text, text_request, text_response,
415    };
416
417    #[derive(Debug, thiserror::Error)]
418    #[error("{0}")]
419    struct TestError(&'static str);
420
421    fn test_error(message: &'static str) -> LibsyError {
422        LibsyError::external("test", TestError(message))
423    }
424
425    /// Trivial algo used only to exercise the orchestrator: calls the first target
426    /// and returns its response as the routing outcome.
427    struct TestAlgo {
428        target_set: Vec<ModelId>,
429    }
430
431    #[async_trait]
432    impl Algorithm for TestAlgo {
433        fn name(&self) -> &str {
434            "test"
435        }
436
437        async fn route(
438            self: Arc<Self>,
439            driver: Driver,
440            request: Request,
441        ) -> Result<RoutingOutcome> {
442            let target = self
443                .target_set
444                .first()
445                .ok_or(LibsyError::NoTargets)?
446                .clone();
447            let response = driver
448                .call_model(request.clone(), vec![target.clone()])
449                .await?;
450            Ok(RoutingOutcome::answered(target, request, response))
451        }
452    }
453
454    /// Build a shared `TestAlgo` over the given target set.
455    fn orch(target_set: Vec<ModelId>) -> Arc<dyn Algorithm> {
456        Arc::new(TestAlgo { target_set })
457    }
458
459    fn request() -> Request {
460        Request {
461            llm_request: text_request(Some("auto".to_string()), "hi".to_string()),
462            raw_request: None,
463            metadata: None,
464        }
465    }
466
467    #[test]
468    fn routing_outcome_constructors_stamp_selection_and_preserve_payloads() {
469        let outcome = RoutingOutcome::route_to(
470            "selected".into(),
471            target_set(&["fallback-one", "fallback-two"]),
472            request(),
473        );
474
475        assert_eq!(outcome.selected_model_id, "selected");
476        assert_eq!(
477            outcome.fallback_models,
478            target_set(&["fallback-one", "fallback-two"])
479        );
480        assert_eq!(outcome.request.model_id().as_deref(), Some("selected"));
481        assert!(outcome.response.is_none());
482
483        let outcome = RoutingOutcome::route_to("only".into(), Vec::new(), request());
484        assert!(outcome.fallback_models.is_empty());
485
486        let outcome = RoutingOutcome::answered(
487            "answered".into(),
488            request(),
489            Response {
490                llm_response: LlmResponse::Agg(text_response(None, "existing")),
491                metadata: None,
492            },
493        );
494
495        assert_eq!(outcome.selected_model_id, "answered");
496        assert_eq!(outcome.request.model_id().as_deref(), Some("answered"));
497        assert!(outcome.fallback_models.is_empty());
498        assert_eq!(
499            outcome
500                .response
501                .as_ref()
502                .and_then(|response| response.llm_response.as_agg())
503                .map(completion_text),
504            Some("existing".to_string())
505        );
506    }
507
508    fn target_set(names: &[&str]) -> Vec<ModelId> {
509        names.iter().map(|name| ModelId::from(*name)).collect()
510    }
511
512    #[tokio::test]
513    async fn typed_driver_preserves_call_and_stream_boundaries() -> Result<()> {
514        tokio::time::timeout(std::time::Duration::from_secs(1), async {
515            // Distinct oneshots keep reverse-order replies paired with their producers, and a
516            // retained call remains pending until the host responds.
517            let (driver, mut step_rx) = Driver::new("test");
518            let first_driver = driver.clone();
519            let mut first = tokio::spawn(async move {
520                first_driver
521                    .call_model(request(), vec![ModelId::from("first")])
522                    .await
523            });
524            let second = tokio::spawn(async move {
525                driver
526                    .call_model(request(), vec![ModelId::from("second")])
527                    .await
528            });
529
530            let mut calls = HashMap::new();
531            for _ in 0..2 {
532                let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
533                let Step::CallModel(call) = step else {
534                    return Err(test_error("expected a CallModel step"));
535                };
536                let selected_model = call
537                    .models
538                    .first()
539                    .ok_or_else(|| test_error("model call has no candidates"))?
540                    .to_string();
541                calls.insert(selected_model, call);
542            }
543            assert!(
544                tokio::time::timeout(std::time::Duration::from_millis(20), &mut first)
545                    .await
546                    .is_err(),
547                "call completed before the host responded"
548            );
549            calls
550                .remove("second")
551                .ok_or_else(|| test_error("missing second call"))?
552                .respond(Ok(reply("second response")))?;
553            calls
554                .remove("first")
555                .ok_or_else(|| test_error("missing first call"))?
556                .respond(Ok(reply("first response")))?;
557
558            let first_response = first
559                .await
560                .map_err(|source| LibsyError::external("joining a test task", source))??;
561            let second_response = second
562                .await
563                .map_err(|source| LibsyError::external("joining a test task", source))??;
564            assert_eq!(
565                first_response.llm_response.as_agg().map(completion_text),
566                Some("first response".to_string())
567            );
568            assert_eq!(
569                second_response.llm_response.as_agg().map(completion_text),
570                Some("second response".to_string())
571            );
572
573            // Dropping the host-facing promise closes only that call's reply channel.
574            let (driver, mut step_rx) = Driver::new("test");
575            let producer = tokio::spawn(async move {
576                driver
577                    .call_model(request(), vec![ModelId::from("dropped")])
578                    .await
579            });
580            let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
581            let Step::CallModel(call) = step else {
582                return Err(test_error("expected a CallModel step"));
583            };
584            drop(call);
585            let result = producer
586                .await
587                .map_err(|source| LibsyError::external("joining a test task", source))?;
588            assert!(matches!(
589                result,
590                Err(LibsyError::Driver(DriverError::ResponseDropped))
591            ));
592
593            // A standalone driver reports the typed step receiver disappearing at its next call.
594            let (driver, step_rx) = Driver::new("test");
595            drop(step_rx);
596            let result = driver
597                .call_model(request(), vec![ModelId::from("closed")])
598                .await;
599            assert!(matches!(
600                result,
601                Err(LibsyError::Driver(DriverError::StreamClosed))
602            ));
603            Ok(())
604        })
605        .await
606        .map_err(|error| LibsyError::external("waiting for typed driver boundaries", error))?
607    }
608
609    #[test]
610    fn target_lookup_returns_the_missing_target() {
611        let error = ensure_model_is_target(&target_set(&[]), &ModelId::from("missing")).err();
612        assert!(matches!(
613            error,
614            Some(LibsyError::TargetNotFound { target }) if target == "missing"
615        ));
616    }
617
618    /// Build a single-target algo, plus a `serve` that answers it as a token stream
619    /// replaying `chunks` in order (as `Ok` items).
620    fn streaming_orch(chunks: Vec<LlmResponseChunk>) -> (Arc<dyn Algorithm>, impl Serve) {
621        let algo = orch(target_set(&["stream/model"]));
622        let serve = move |_target: ModelId, _request: Request| {
623            let chunks = chunks.clone();
624            async move {
625                let stream =
626                    futures::stream::iter(chunks.into_iter().map(|chunk| Ok(chunk.into()))).boxed();
627                Ok(Response {
628                    llm_response: LlmResponse::Stream(stream),
629                    metadata: None,
630                })
631            }
632        };
633        (algo, serve)
634    }
635
636    #[tokio::test]
637    async fn run_returns_a_streamed_response_the_caller_aggregates() -> Result<()> {
638        // A streaming client -> its chunks flow through the promise and `Done`,
639        // and `run` returns the live stream untouched for the caller to fold.
640        let (orch, serve) = streaming_orch(vec![
641            LlmResponseChunk::MessageStart {
642                id: Some("m1".to_string()),
643                model: Some("stream/model".to_string()),
644            },
645            LlmResponseChunk::TextDelta {
646                index: 0,
647                text: "hel".to_string(),
648            },
649            LlmResponseChunk::TextDelta {
650                index: 0,
651                text: "lo".to_string(),
652            },
653            LlmResponseChunk::MessageStop {
654                reason: Some("stop".to_string()),
655            },
656        ]);
657        let (selected_model, response) = test_drive(orch, request(), serve).await?;
658        // The run handed back the live stream; the caller folds it to a buffered aggregate.
659        let agg = response
660            .llm_response
661            .into_agg()
662            .await
663            .map_err(|error| LibsyError::external("aggregating response stream", error))?;
664        assert_eq!(completion_text(&agg), "hello");
665        assert_eq!(agg.model.as_deref(), Some("stream/model"));
666        assert_eq!(selected_model, "stream/model");
667        Ok(())
668    }
669
670    #[tokio::test]
671    async fn aggregating_a_streamed_response_propagates_a_mid_stream_error() -> Result<()> {
672        // The run succeeds and returns the stream; the in-band `Error` chunk surfaces only
673        // when the caller aggregates it.
674        let (orch, serve) = streaming_orch(vec![
675            LlmResponseChunk::TextDelta {
676                index: 0,
677                text: "partial".to_string(),
678            },
679            LlmResponseChunk::StreamError {
680                message: "upstream exploded".to_string(),
681            },
682        ]);
683        let (_, response) = test_drive(orch, request(), serve).await?;
684        match response.llm_response.into_agg().await {
685            Ok(_) => panic!("expected a mid-stream error, got an aggregate"),
686            Err(err) => {
687                assert!(err.to_string().contains("upstream exploded"));
688                Ok(())
689            }
690        }
691    }
692
693    #[tokio::test]
694    async fn run_offloads_via_promise_then_finishes() -> Result<()> {
695        // Every call is offloaded via a promise the orchestrator surfaces as a
696        // `CallModel` step for us to fulfill.
697        let stream = orch(target_set(&["offload/model"])).run_stream(request());
698        tokio::pin!(stream);
699
700        let mut saw_call = false;
701        let mut final_completion = None;
702        while let Some(step) = stream.next().await {
703            match step? {
704                Step::CallModel(call) => {
705                    saw_call = true;
706                    assert_eq!(call.models, vec![ModelId::from("offload/model")]);
707                    // Fulfilling the promise is the "real" model call the caller makes.
708                    call.respond(Ok(Response {
709                        llm_response: LlmResponse::Agg(text_response(
710                            None,
711                            "fulfilled".to_string(),
712                        )),
713                        metadata: None,
714                    }))?;
715                }
716                Step::Done(outcome) => {
717                    let response = outcome
718                        .response
719                        .ok_or_else(|| test_error("expected an answered outcome"))?;
720                    final_completion = Some(
721                        response
722                            .llm_response
723                            .as_agg()
724                            .map(completion_text)
725                            .unwrap_or_default(),
726                    );
727                }
728            }
729        }
730
731        assert!(saw_call, "expected a CallModel step before Done");
732        assert_eq!(
733            final_completion.ok_or_else(|| test_error("no Done step"))?,
734            "fulfilled"
735        );
736        Ok(())
737    }
738
739    #[tokio::test(flavor = "multi_thread", worker_threads = 12)]
740    async fn requests_are_processed_in_parallel() -> Result<()> {
741        use std::time::Duration;
742        use tokio::sync::Barrier;
743
744        const N: usize = 12;
745
746        // Serving blocks until all N concurrent calls have arrived. If requests were
747        // serialized (one algorithm behind a `Mutex`), only one call could be in flight,
748        // the barrier would never reach N, and the test would time out. It passes only
749        // because the shared algorithm is driven concurrently across requests.
750        let barrier = Arc::new(Barrier::new(N));
751        // One shared algorithm driven by many concurrent requests.
752        let algo = orch(target_set(&["m"]));
753
754        let mut handles = Vec::new();
755        for _ in 0..N {
756            let algo = algo.clone();
757            let barrier = barrier.clone();
758            let serve = move |target: ModelId, _request: Request| {
759                let barrier = barrier.clone();
760                async move {
761                    barrier.wait().await;
762                    Ok(reply(target))
763                }
764            };
765            handles.push(tokio::spawn(async move {
766                test_drive(algo, request(), serve)
767                    .await
768                    .map(|(_, response)| {
769                        response
770                            .llm_response
771                            .as_agg()
772                            .map(completion_text)
773                            .unwrap_or_default()
774                    })
775            }));
776        }
777
778        for handle in handles {
779            // The timeout turns a serialization deadlock into a failure, not a hang.
780            let completion = tokio::time::timeout(Duration::from_secs(5), handle)
781                .await
782                .map_err(|error| LibsyError::external("waiting for test task", error))?
783                .map_err(|source| LibsyError::external("joining a test task", source))??;
784            assert_eq!(completion, "m");
785        }
786        Ok(())
787    }
788
789    #[tokio::test]
790    async fn offload_error_propagates_back_to_the_algorithm() -> Result<()> {
791        // A client-less target offloads its call; we fulfill the promise with an
792        // Err, which must flow back through `call_model_target` into the algorithm and
793        // out as an error step — not a response.
794        let stream = orch(target_set(&["offload/model"])).run_stream(request());
795        tokio::pin!(stream);
796
797        let mut saw_error = false;
798        while let Some(step) = stream.next().await {
799            match step {
800                Ok(Step::CallModel(call)) => {
801                    call.respond(Err(test_error("upstream model call failed")))?;
802                }
803                Ok(Step::Done(..)) => {
804                    return Err(test_error(
805                        "expected the offload error to propagate, got a response",
806                    ));
807                }
808                Err(err) => {
809                    // The algorithm's `call_model_target` saw the error via the promise.
810                    assert!(err.to_string().contains("upstream model call failed"));
811                    saw_error = true;
812                }
813            }
814        }
815
816        assert!(saw_error, "expected an error step");
817        Ok(())
818    }
819
820    #[tokio::test]
821    async fn dropping_the_stream_cancels_the_algorithm_task() -> Result<()> {
822        use std::sync::atomic::{AtomicBool, Ordering};
823        use std::time::Duration;
824        use tokio::sync::mpsc;
825
826        // Sets a flag when dropped, so we can observe whether the algorithm task was
827        // cancelled/dropped.
828        struct DropGuard(Arc<AtomicBool>);
829        impl Drop for DropGuard {
830            fn drop(&mut self) {
831                self.0.store(true, Ordering::SeqCst);
832            }
833        }
834
835        struct StuckAlgo {
836            started: mpsc::UnboundedSender<()>,
837            dropped: Arc<AtomicBool>,
838        }
839
840        #[async_trait]
841        impl Algorithm for StuckAlgo {
842            fn name(&self) -> &str {
843                "stuck"
844            }
845
846            async fn route(
847                self: Arc<Self>,
848                _driver: Driver,
849                _request: Request,
850            ) -> Result<RoutingOutcome> {
851                let _guard = DropGuard(self.dropped.clone());
852                let _ = self.started.send(());
853                // Await forever without ever touching the driver.
854                std::future::pending::<()>().await;
855                unreachable!()
856            }
857        }
858
859        let (started_tx, mut started_rx) = mpsc::unbounded_channel();
860        let dropped = Arc::new(AtomicBool::new(false));
861        let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
862            started: started_tx,
863            dropped: dropped.clone(),
864        });
865
866        let stream = algo.run_stream(request());
867        started_rx
868            .recv()
869            .await
870            .ok_or_else(|| test_error("task never started"))?;
871        drop(stream);
872        tokio::time::sleep(Duration::from_millis(100)).await;
873
874        assert!(
875            dropped.load(Ordering::SeqCst),
876            "algorithm task was NOT cancelled after dropping the stream"
877        );
878        Ok(())
879    }
880
881    #[tokio::test]
882    async fn route_panic_surfaces_as_a_stream_error() -> Result<()> {
883        // An algorithm whose task panics must surface an `Err` step carrying the panic
884        // message, not abort the process from an unobserved detached task.
885        struct Panicky;
886
887        #[async_trait]
888        impl Algorithm for Panicky {
889            fn name(&self) -> &str {
890                "panicky"
891            }
892
893            async fn route(
894                self: Arc<Self>,
895                _driver: Driver,
896                _request: Request,
897            ) -> Result<RoutingOutcome> {
898                panic!("boom");
899            }
900        }
901
902        let algo: Arc<dyn Algorithm> = Arc::new(Panicky);
903        let stream = algo.run_stream(request());
904        tokio::pin!(stream);
905
906        let mut saw_error = false;
907        while let Some(step) = stream.next().await {
908            match step {
909                Err(err) => {
910                    // The panic message is preserved, not flattened into an opaque failure.
911                    assert!(err.to_string().contains("algorithm task panicked: boom"));
912                    saw_error = true;
913                }
914                Ok(_) => return Err(test_error("expected the panic to surface as an error step")),
915            }
916        }
917
918        assert!(saw_error, "expected an error step from the panicked task");
919        Ok(())
920    }
921
922    /// A panicking algorithm must publish its terminal step even when it left a `Driver`
923    /// clone alive in another task. That clone holds the step channel open, so a run that
924    /// merely unwound would never terminate and the consumer would wait forever.
925    #[tokio::test]
926    async fn a_panic_with_a_leaked_driver_clone_still_terminates_the_run() -> Result<()> {
927        struct LeakyPanic;
928
929        #[async_trait]
930        impl Algorithm for LeakyPanic {
931            fn name(&self) -> &str {
932                "leaky_panic"
933            }
934
935            async fn route(
936                self: Arc<Self>,
937                driver: Driver,
938                _request: Request,
939            ) -> Result<RoutingOutcome> {
940                tokio::spawn(async move {
941                    // Outlives the panic below, keeping a sender clone alive.
942                    let _keep_alive = driver;
943                    std::future::pending::<()>().await;
944                });
945                tokio::task::yield_now().await;
946                panic!("boom");
947            }
948        }
949
950        let algo: Arc<dyn Algorithm> = Arc::new(LeakyPanic);
951        // The timeout turns the hang this guards against into a failure rather than a hang.
952        let result = tokio::time::timeout(
953            std::time::Duration::from_secs(1),
954            test_drive(algo, request(), echo()),
955        )
956        .await
957        .map_err(|error| LibsyError::external("waiting for the panicked run to end", error))?;
958
959        match result {
960            Ok(_) => Err(test_error(
961                "expected the panic to end the run with an error",
962            )),
963            Err(err) => {
964                assert!(err.to_string().contains("algorithm task panicked: boom"));
965                Ok(())
966            }
967        }
968    }
969
970    #[tokio::test]
971    async fn cancelling_run_cancels_the_algorithm_task() -> Result<()> {
972        use std::sync::atomic::{AtomicBool, Ordering};
973        use std::time::Duration;
974        use tokio::sync::mpsc;
975
976        // Sets a flag when dropped, so we can observe whether the algorithm task was
977        // cancelled once the `run` future driving it is dropped.
978        struct DropGuard(Arc<AtomicBool>);
979        impl Drop for DropGuard {
980            fn drop(&mut self) {
981                self.0.store(true, Ordering::SeqCst);
982            }
983        }
984
985        struct StuckAlgo {
986            started: mpsc::UnboundedSender<()>,
987            dropped: Arc<AtomicBool>,
988        }
989
990        #[async_trait]
991        impl Algorithm for StuckAlgo {
992            fn name(&self) -> &str {
993                "stuck"
994            }
995
996            async fn route(
997                self: Arc<Self>,
998                _driver: Driver,
999                _request: Request,
1000            ) -> Result<RoutingOutcome> {
1001                let _guard = DropGuard(self.dropped.clone());
1002                let _ = self.started.send(());
1003                // Hang forever without ever touching the driver, so only cancellation
1004                // (not a dropped step channel) can stop this task.
1005                std::future::pending::<()>().await;
1006                unreachable!()
1007            }
1008        }
1009
1010        let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1011        let dropped = Arc::new(AtomicBool::new(false));
1012        let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1013            started: started_tx,
1014            dropped: dropped.clone(),
1015        });
1016
1017        // Drive the run on its own task, wait until the algorithm task is up, then cancel
1018        // it — dropping its future (and the `run_stream` stream it holds).
1019        let run_task = tokio::spawn(async move { test_drive(algo, request(), echo()).await });
1020        started_rx
1021            .recv()
1022            .await
1023            .ok_or_else(|| test_error("task never started"))?;
1024        run_task.abort();
1025        tokio::time::sleep(Duration::from_millis(100)).await;
1026
1027        assert!(
1028            dropped.load(Ordering::SeqCst),
1029            "algorithm task was NOT cancelled after cancelling run"
1030        );
1031        Ok(())
1032    }
1033
1034    // --- first-wins hedging: `run` must not wait on losing speculative calls -------------
1035
1036    /// Offloads two targets concurrently and returns the first to resolve, dropping the
1037    /// loser's call (first-wins hedging).
1038    struct Hedge {
1039        winner: String,
1040        loser: String,
1041    }
1042
1043    #[async_trait]
1044    impl Algorithm for Hedge {
1045        fn name(&self) -> &str {
1046            "hedge"
1047        }
1048
1049        async fn route(
1050            self: Arc<Self>,
1051            driver: Driver,
1052            request: Request,
1053        ) -> Result<RoutingOutcome> {
1054            let outcome_request = request.clone();
1055            let win = driver.call_model(request.clone(), vec![self.winner.clone().into()]);
1056            let lose = driver.call_model(request, vec![self.loser.clone().into()]);
1057            // First to resolve wins; `select!` drops the losing future (and its promise).
1058            tokio::select! {
1059                res = win => Ok(RoutingOutcome::answered(
1060                    self.winner.clone().into(),
1061                    outcome_request,
1062                    res?,
1063                )),
1064                res = lose => Ok(RoutingOutcome::answered(
1065                    self.loser.clone().into(),
1066                    outcome_request,
1067                    res?,
1068                )),
1069            }
1070        }
1071    }
1072
1073    /// Builds a hedging algo and the `serve` that drives it: the winner is gated behind the
1074    /// loser starting (so the loser's serve is guaranteed in flight when the winner wins),
1075    /// and the loser finishes after `loser_delay` — or never, when `None`.
1076    fn hedge(loser_delay: Option<std::time::Duration>) -> (Arc<dyn Algorithm>, impl Serve) {
1077        let started = Arc::new(tokio::sync::Notify::new());
1078        let algo = Arc::new(Hedge {
1079            winner: "winner".to_string(),
1080            loser: "loser".to_string(),
1081        });
1082        let serve = move |target: ModelId, _request: Request| {
1083            let started = started.clone();
1084            async move {
1085                if target == "loser" {
1086                    started.notify_one();
1087                    match loser_delay {
1088                        Some(delay) => tokio::time::sleep(delay).await,
1089                        None => std::future::pending::<()>().await,
1090                    }
1091                } else {
1092                    started.notified().await;
1093                }
1094                Ok(reply(target))
1095            }
1096        };
1097        (algo, serve)
1098    }
1099
1100    #[tokio::test]
1101    async fn run_returns_the_winner_without_a_late_loser_overwriting_it() -> Result<()> {
1102        // The loser responds 50ms after the winner has already won. `run` must return the
1103        // winner, not the loser's `respond`-to-a-dropped-receiver error.
1104        let (algo, serve) = hedge(Some(std::time::Duration::from_millis(50)));
1105        let (_, response) = test_drive(algo, request(), serve).await?;
1106        assert_eq!(
1107            response
1108                .llm_response
1109                .as_agg()
1110                .map(completion_text)
1111                .unwrap_or_default(),
1112            "winner"
1113        );
1114        Ok(())
1115    }
1116
1117    #[tokio::test]
1118    async fn run_returns_the_winner_without_hanging_on_a_pending_loser() -> Result<()> {
1119        // The loser never resolves. `run` must return the winner promptly, not hang
1120        // waiting for the in-flight loser.
1121        let (algo, serve) = hedge(None);
1122        let run = test_drive(algo, request(), serve);
1123        let (_, response) = tokio::time::timeout(std::time::Duration::from_secs(1), run)
1124            .await
1125            .map_err(|error| LibsyError::external("waiting for pending loser", error))??;
1126        assert_eq!(
1127            response
1128                .llm_response
1129                .as_agg()
1130                .map(completion_text)
1131                .unwrap_or_default(),
1132            "winner"
1133        );
1134        Ok(())
1135    }
1136
1137    #[tokio::test]
1138    async fn run_surfaces_a_terminal_error_with_many_calls_in_flight() -> Result<()> {
1139        use std::sync::atomic::{AtomicUsize, Ordering};
1140
1141        // A large fan-out (10 matched the old, now-removed concurrency cap). The terminal
1142        // error must still reach the caller with all of these calls pending.
1143        const N: usize = 10;
1144
1145        // Fans out N calls, then errors as soon as all N are in flight — exercising a
1146        // terminal failure emitted while the offloaded calls are still pending.
1147        struct FanOutThenError {
1148            all_started: Arc<tokio::sync::Notify>,
1149            n: usize,
1150        }
1151
1152        #[async_trait]
1153        impl Algorithm for FanOutThenError {
1154            fn name(&self) -> &str {
1155                "fan_out_then_error"
1156            }
1157
1158            async fn route(
1159                self: Arc<Self>,
1160                driver: Driver,
1161                request: Request,
1162            ) -> Result<RoutingOutcome> {
1163                let offloads = futures::future::join_all(
1164                    (0..self.n)
1165                        .map(|i| driver.call_model(request.clone(), vec![format!("m{i}").into()])),
1166                );
1167                tokio::select! {
1168                    _ = offloads => Err(test_error("offloads unexpectedly completed")),
1169                    _ = self.all_started.notified() => {
1170                        Err(test_error("terminal error while calls pending"))
1171                    }
1172                }
1173            }
1174        }
1175
1176        let all_started = Arc::new(tokio::sync::Notify::new());
1177        let algo: Arc<dyn Algorithm> = Arc::new(FanOutThenError {
1178            all_started: all_started.clone(),
1179            n: N,
1180        });
1181
1182        // Serving enters each call; once all N are in flight it signals, then pends forever.
1183        let started = Arc::new(AtomicUsize::new(0));
1184        let serve = move |_target: ModelId, _request: Request| {
1185            let started = started.clone();
1186            let all_started = all_started.clone();
1187            async move {
1188                if started.fetch_add(1, Ordering::SeqCst) + 1 == N {
1189                    all_started.notify_one();
1190                }
1191                std::future::pending::<ServeResult>().await
1192            }
1193        };
1194
1195        // With the cap gone, the driver keeps polling the stream even with N calls in
1196        // flight, so the terminal error surfaces promptly instead of hanging.
1197        let run = test_drive(algo, request(), serve);
1198        let result = tokio::time::timeout(std::time::Duration::from_millis(500), run)
1199            .await
1200            .map_err(|error| {
1201                LibsyError::external("waiting for terminal error with full call cap", error)
1202            })?;
1203        match result {
1204            Ok(_) => Err(test_error("expected the terminal error, got a response")),
1205            Err(err) => {
1206                assert!(
1207                    err.to_string()
1208                        .contains("terminal error while calls pending")
1209                );
1210                Ok(())
1211            }
1212        }
1213    }
1214}