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