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