Skip to main content

switchyard_libsy/core/
algorithm.rs

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