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