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