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