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` is the full JSON serialized as a string for consumers to parse.
390/// Keep it small and free of private data; nothing is filtered or redacted.
391/// These fields are span attributes, never metric labels.
392///
393/// The run/call observability helpers retain `outcome` status and operational metrics,
394/// but omit error details and arbitrary request extra metadata. Algorithms and hosts
395/// may emit their own logs. Errors still reach the caller unchanged.
396/// The host controls the tracing subscriber and global OpenTelemetry
397/// meter provider; libsy installs no exporter and performs no telemetry network I/O.
398#[async_trait]
399pub trait Algorithm: Send + Sync + 'static {
400    /// Stable, low-cardinality name identifying this algorithm — the
401    /// `algorithm` attribute on every span, metric, and log line the crate
402    /// emits for its runs.
403    fn name(&self) -> &str;
404
405    /// Run one request to completion: make routing-time model calls with
406    /// [`Driver::call_model`] and return the terminal [`RoutingOutcome`].
407    /// The method an algorithm implements; [`run_stream`](Self::run_stream) drives it.
408    async fn route(self: Arc<Self>, driver: Driver, request: Request) -> Result<RoutingOutcome>;
409
410    /// Process a request to completion, returning a stream of [`Step`]s.
411    ///
412    /// The consumer must fulfill every [`Step::CallModel`] before the algorithm can
413    /// continue. Every run ends with exactly one terminal item — [`Step::Done`] on
414    /// success, an `Err` item on failure, including when the algorithm panics. Dropping
415    /// the stream aborts the spawned algorithm task.
416    ///
417    /// Every invocation owns a separate [`Driver`].
418    fn run_stream(self: Arc<Self>, request: Request) -> StepStream {
419        let (driver, step_rx) = Driver::new(self.name());
420        let span = observability::run_span(self.name(), &request);
421        let handle = tokio::spawn(
422            async move {
423                let algorithm = self.name().to_string();
424                // Catch a panicking algorithm so the run still publishes a terminal step.
425                let route = AssertUnwindSafe(self.route(driver.clone(), request)).catch_unwind();
426                let result = observability::observe_run(&algorithm, async move {
427                    route.await.unwrap_or_else(|payload| {
428                        Err(LibsyError::AlgorithmError {
429                            message: format!(
430                                "algorithm task panicked: {}",
431                                panic_message(payload.as_ref())
432                            ),
433                        })
434                    })
435                })
436                .await;
437
438                let _ = driver.finish(result).await;
439            }
440            .instrument(span),
441        );
442        // Dropping the stream aborts the algorithm task when its consumer goes away.
443        let abort_guard = AbortOnDrop(handle.abort_handle());
444        Box::pin(ReceiverStream::new(step_rx).map(move |step| {
445            // link abort guard to stream
446            let _keep_alive = &abort_guard;
447            step
448        }))
449    }
450}
451
452#[cfg(test)]
453mod tests {
454    use std::collections::HashMap;
455
456    use super::*;
457    use crate::core::testing::{Serve, ServeResult, echo, reply, test_drive};
458    use futures::StreamExt;
459    use switchyard_protocol::{
460        LlmResponse, LlmResponseChunk, completion_text, text_request, text_response,
461    };
462
463    #[derive(Debug, thiserror::Error)]
464    #[error("{0}")]
465    struct TestError(&'static str);
466
467    fn test_error(message: &'static str) -> LibsyError {
468        LibsyError::external("test", TestError(message))
469    }
470
471    /// Trivial algo used only to exercise the orchestrator: calls the first target
472    /// and returns its response as the routing outcome.
473    struct TestAlgo {
474        target_set: Vec<ModelId>,
475    }
476
477    #[async_trait]
478    impl Algorithm for TestAlgo {
479        fn name(&self) -> &str {
480            "test"
481        }
482
483        async fn route(
484            self: Arc<Self>,
485            driver: Driver,
486            request: Request,
487        ) -> Result<RoutingOutcome> {
488            let target = self
489                .target_set
490                .first()
491                .ok_or(LibsyError::NoTargets)?
492                .clone();
493            let response = driver
494                .call_model(request.clone(), vec![target.clone()])
495                .await?;
496            driver.set_evidence(serde_json::json!({"source": "test"}));
497            driver.set_evidence_if_empty(serde_json::json!({"source": "ignored"}));
498            Ok(RoutingOutcome::answered(target, request, response))
499        }
500    }
501
502    /// Build a shared `TestAlgo` over the given target set.
503    fn orch(target_set: Vec<ModelId>) -> Arc<dyn Algorithm> {
504        Arc::new(TestAlgo { target_set })
505    }
506
507    fn request() -> Request {
508        Request {
509            llm_request: text_request(Some("auto".to_string()), "hi".to_string()),
510            raw_request: None,
511            metadata: None,
512        }
513    }
514
515    #[test]
516    fn routing_outcome_constructors_stamp_selection_and_preserve_payloads() {
517        let outcome = RoutingOutcome::route_to(
518            "selected".into(),
519            target_set(&["fallback-one", "fallback-two"]),
520            request(),
521        );
522
523        assert_eq!(
524            outcome.selected_model_ids,
525            target_set(&["selected", "fallback-one", "fallback-two"])
526        );
527        assert_eq!(outcome.request.model_id().as_deref(), Some("selected"));
528        assert!(outcome.response.is_none());
529        assert!(outcome.metadata.is_none());
530
531        let outcome = RoutingOutcome::route_to("only".into(), Vec::new(), request());
532        assert_eq!(outcome.selected_model_ids, target_set(&["only"]));
533
534        let outcome = RoutingOutcome::answered(
535            "answered".into(),
536            request(),
537            Response {
538                llm_response: LlmResponse::Agg(text_response(None, "existing")),
539                metadata: None,
540            },
541        );
542
543        assert_eq!(outcome.selected_model_ids, target_set(&["answered"]));
544        assert_eq!(outcome.request.model_id().as_deref(), Some("answered"));
545        assert_eq!(
546            outcome
547                .response
548                .as_ref()
549                .and_then(|response| response.llm_response.as_agg())
550                .map(completion_text),
551            Some("existing".to_string())
552        );
553    }
554
555    fn target_set(names: &[&str]) -> Vec<ModelId> {
556        names.iter().map(|name| ModelId::from(*name)).collect()
557    }
558
559    #[tokio::test]
560    async fn typed_driver_preserves_call_and_stream_boundaries() -> Result<()> {
561        tokio::time::timeout(std::time::Duration::from_secs(1), async {
562            // Distinct oneshots keep reverse-order replies paired with their producers, and a
563            // retained call remains pending until the host responds.
564            let (driver, mut step_rx) = Driver::new("test");
565            let first_driver = driver.clone();
566            let mut first = tokio::spawn(async move {
567                first_driver
568                    .call_model(request(), vec![ModelId::from("first")])
569                    .await
570            });
571            let second = tokio::spawn(async move {
572                driver
573                    .call_model(request(), vec![ModelId::from("second")])
574                    .await
575            });
576
577            let mut calls = HashMap::new();
578            for _ in 0..2 {
579                let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
580                let Step::CallModel(call) = step else {
581                    return Err(test_error("expected a CallModel step"));
582                };
583                let selected_model = call
584                    .models
585                    .first()
586                    .ok_or_else(|| test_error("model call has no candidates"))?
587                    .to_string();
588                calls.insert(selected_model, call);
589            }
590            assert!(
591                tokio::time::timeout(std::time::Duration::from_millis(20), &mut first)
592                    .await
593                    .is_err(),
594                "call completed before the host responded"
595            );
596            calls
597                .remove("second")
598                .ok_or_else(|| test_error("missing second call"))?
599                .respond(Ok(reply("second response")))?;
600            calls
601                .remove("first")
602                .ok_or_else(|| test_error("missing first call"))?
603                .respond(Ok(reply("first response")))?;
604
605            let first_response = first
606                .await
607                .map_err(|source| LibsyError::external("joining a test task", source))??;
608            let second_response = second
609                .await
610                .map_err(|source| LibsyError::external("joining a test task", source))??;
611            assert_eq!(
612                first_response.llm_response.as_agg().map(completion_text),
613                Some("first response".to_string())
614            );
615            assert_eq!(
616                second_response.llm_response.as_agg().map(completion_text),
617                Some("second response".to_string())
618            );
619
620            // Dropping the host-facing promise closes only that call's reply channel.
621            let (driver, mut step_rx) = Driver::new("test");
622            let producer = tokio::spawn(async move {
623                driver
624                    .call_model(request(), vec![ModelId::from("dropped")])
625                    .await
626            });
627            let step = step_rx.recv().await.ok_or(DriverError::StreamClosed)??;
628            let Step::CallModel(call) = step else {
629                return Err(test_error("expected a CallModel step"));
630            };
631            drop(call);
632            let result = producer
633                .await
634                .map_err(|source| LibsyError::external("joining a test task", source))?;
635            assert!(matches!(
636                result,
637                Err(LibsyError::Driver(DriverError::ResponseDropped))
638            ));
639
640            // A standalone driver reports the typed step receiver disappearing at its next call.
641            let (driver, step_rx) = Driver::new("test");
642            drop(step_rx);
643            let result = driver
644                .call_model(request(), vec![ModelId::from("closed")])
645                .await;
646            assert!(matches!(
647                result,
648                Err(LibsyError::Driver(DriverError::StreamClosed))
649            ));
650            Ok(())
651        })
652        .await
653        .map_err(|error| LibsyError::external("waiting for typed driver boundaries", error))?
654    }
655
656    #[test]
657    fn target_lookup_returns_the_missing_target() {
658        let error = ensure_model_is_target(&target_set(&[]), &ModelId::from("missing")).err();
659        assert!(matches!(
660            error,
661            Some(LibsyError::TargetNotFound { target }) if target == "missing"
662        ));
663    }
664
665    /// Build a single-target algo, plus a `serve` that answers it as a token stream
666    /// replaying `chunks` in order (as `Ok` items).
667    fn streaming_orch(chunks: Vec<LlmResponseChunk>) -> (Arc<dyn Algorithm>, impl Serve) {
668        let algo = orch(target_set(&["stream/model"]));
669        let serve = move |_target: ModelId, _request: Request| {
670            let chunks = chunks.clone();
671            async move {
672                let stream =
673                    futures::stream::iter(chunks.into_iter().map(|chunk| Ok(chunk.into()))).boxed();
674                Ok(Response {
675                    llm_response: LlmResponse::Stream(stream),
676                    metadata: None,
677                })
678            }
679        };
680        (algo, serve)
681    }
682
683    #[tokio::test]
684    async fn run_returns_a_streamed_response_the_caller_aggregates() -> Result<()> {
685        // A streaming client -> its chunks flow through the promise and `Done`,
686        // and `run` returns the live stream untouched for the caller to fold.
687        let (orch, serve) = streaming_orch(vec![
688            LlmResponseChunk::MessageStart {
689                id: Some("m1".to_string()),
690                model: Some("stream/model".to_string()),
691            },
692            LlmResponseChunk::TextDelta {
693                index: 0,
694                text: "hel".to_string(),
695            },
696            LlmResponseChunk::TextDelta {
697                index: 0,
698                text: "lo".to_string(),
699            },
700            LlmResponseChunk::MessageStop {
701                reason: Some("stop".to_string()),
702            },
703        ]);
704        let (selected_model, response) = test_drive(orch, request(), serve).await?;
705        // The run handed back the live stream; the caller folds it to a buffered aggregate.
706        let agg = response
707            .llm_response
708            .into_agg()
709            .await
710            .map_err(|error| LibsyError::external("aggregating response stream", error))?;
711        assert_eq!(completion_text(&agg), "hello");
712        assert_eq!(agg.model.as_deref(), Some("stream/model"));
713        assert_eq!(selected_model, "stream/model");
714        Ok(())
715    }
716
717    #[tokio::test]
718    async fn aggregating_a_streamed_response_propagates_a_mid_stream_error() -> Result<()> {
719        // The run succeeds and returns the stream; the in-band `Error` chunk surfaces only
720        // when the caller aggregates it.
721        let (orch, serve) = streaming_orch(vec![
722            LlmResponseChunk::TextDelta {
723                index: 0,
724                text: "partial".to_string(),
725            },
726            LlmResponseChunk::StreamError {
727                message: "upstream exploded".to_string(),
728            },
729        ]);
730        let (_, response) = test_drive(orch, request(), serve).await?;
731        match response.llm_response.into_agg().await {
732            Ok(_) => panic!("expected a mid-stream error, got an aggregate"),
733            Err(err) => {
734                assert!(err.to_string().contains("upstream exploded"));
735                Ok(())
736            }
737        }
738    }
739
740    #[tokio::test]
741    async fn run_offloads_via_promise_then_finishes() -> Result<()> {
742        // Every call is offloaded via a promise the orchestrator surfaces as a
743        // `CallModel` step for us to fulfill.
744        let stream = orch(target_set(&["offload/model"])).run_stream(request());
745        tokio::pin!(stream);
746
747        let mut saw_call = false;
748        let mut final_completion = None;
749        while let Some(step) = stream.next().await {
750            match step? {
751                Step::CallModel(call) => {
752                    saw_call = true;
753                    assert_eq!(call.models, vec![ModelId::from("offload/model")]);
754                    // Fulfilling the promise is the "real" model call the caller makes.
755                    call.respond(Ok(Response {
756                        llm_response: LlmResponse::Agg(text_response(
757                            None,
758                            "fulfilled".to_string(),
759                        )),
760                        metadata: None,
761                    }))?;
762                }
763                Step::Done(outcome) => {
764                    let metadata = outcome
765                        .metadata
766                        .as_ref()
767                        .expect("run_stream should attach outcome metadata");
768                    assert_eq!(metadata.algorithm, "test");
769                    assert_eq!(
770                        uuid::Uuid::parse_str(metadata.outcome_id())
771                            .expect("outcome id should be a UUID")
772                            .get_version_num(),
773                        7
774                    );
775                    assert_eq!(
776                        metadata.evidence,
777                        Some(serde_json::json!({"source": "test"}))
778                    );
779                    let response = outcome
780                        .response
781                        .ok_or_else(|| test_error("expected an answered outcome"))?;
782                    final_completion = Some(
783                        response
784                            .llm_response
785                            .as_agg()
786                            .map(completion_text)
787                            .unwrap_or_default(),
788                    );
789                }
790            }
791        }
792
793        assert!(saw_call, "expected a CallModel step before Done");
794        assert_eq!(
795            final_completion.ok_or_else(|| test_error("no Done step"))?,
796            "fulfilled"
797        );
798        Ok(())
799    }
800
801    #[tokio::test(flavor = "multi_thread", worker_threads = 12)]
802    async fn requests_are_processed_in_parallel() -> Result<()> {
803        use std::time::Duration;
804        use tokio::sync::Barrier;
805
806        const N: usize = 12;
807
808        // Serving blocks until all N concurrent calls have arrived. If requests were
809        // serialized (one algorithm behind a `Mutex`), only one call could be in flight,
810        // the barrier would never reach N, and the test would time out. It passes only
811        // because the shared algorithm is driven concurrently across requests.
812        let barrier = Arc::new(Barrier::new(N));
813        // One shared algorithm driven by many concurrent requests.
814        let algo = orch(target_set(&["m"]));
815
816        let mut handles = Vec::new();
817        for _ in 0..N {
818            let algo = algo.clone();
819            let barrier = barrier.clone();
820            let serve = move |target: ModelId, _request: Request| {
821                let barrier = barrier.clone();
822                async move {
823                    barrier.wait().await;
824                    Ok(reply(target))
825                }
826            };
827            handles.push(tokio::spawn(async move {
828                test_drive(algo, request(), serve)
829                    .await
830                    .map(|(_, response)| {
831                        response
832                            .llm_response
833                            .as_agg()
834                            .map(completion_text)
835                            .unwrap_or_default()
836                    })
837            }));
838        }
839
840        for handle in handles {
841            // The timeout turns a serialization deadlock into a failure, not a hang.
842            let completion = tokio::time::timeout(Duration::from_secs(5), handle)
843                .await
844                .map_err(|error| LibsyError::external("waiting for test task", error))?
845                .map_err(|source| LibsyError::external("joining a test task", source))??;
846            assert_eq!(completion, "m");
847        }
848        Ok(())
849    }
850
851    #[tokio::test]
852    async fn offload_error_propagates_back_to_the_algorithm() -> Result<()> {
853        // A client-less target offloads its call; we fulfill the promise with an
854        // Err, which must flow back through `call_model_target` into the algorithm and
855        // out as an error step — not a response.
856        let stream = orch(target_set(&["offload/model"])).run_stream(request());
857        tokio::pin!(stream);
858
859        let mut saw_error = false;
860        while let Some(step) = stream.next().await {
861            match step {
862                Ok(Step::CallModel(call)) => {
863                    call.respond(Err(test_error("upstream model call failed")))?;
864                }
865                Ok(Step::Done(..)) => {
866                    return Err(test_error(
867                        "expected the offload error to propagate, got a response",
868                    ));
869                }
870                Err(err) => {
871                    // The algorithm's `call_model_target` saw the error via the promise.
872                    assert!(err.to_string().contains("upstream model call failed"));
873                    saw_error = true;
874                }
875            }
876        }
877
878        assert!(saw_error, "expected an error step");
879        Ok(())
880    }
881
882    #[tokio::test]
883    async fn dropping_the_stream_cancels_the_algorithm_task() -> Result<()> {
884        use std::sync::atomic::{AtomicBool, Ordering};
885        use std::time::Duration;
886        use tokio::sync::mpsc;
887
888        // Sets a flag when dropped, so we can observe whether the algorithm task was
889        // cancelled/dropped.
890        struct DropGuard(Arc<AtomicBool>);
891        impl Drop for DropGuard {
892            fn drop(&mut self) {
893                self.0.store(true, Ordering::SeqCst);
894            }
895        }
896
897        struct StuckAlgo {
898            started: mpsc::UnboundedSender<()>,
899            dropped: Arc<AtomicBool>,
900        }
901
902        #[async_trait]
903        impl Algorithm for StuckAlgo {
904            fn name(&self) -> &str {
905                "stuck"
906            }
907
908            async fn route(
909                self: Arc<Self>,
910                _driver: Driver,
911                _request: Request,
912            ) -> Result<RoutingOutcome> {
913                let _guard = DropGuard(self.dropped.clone());
914                let _ = self.started.send(());
915                // Await forever without ever touching the driver.
916                std::future::pending::<()>().await;
917                unreachable!()
918            }
919        }
920
921        let (started_tx, mut started_rx) = mpsc::unbounded_channel();
922        let dropped = Arc::new(AtomicBool::new(false));
923        let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
924            started: started_tx,
925            dropped: dropped.clone(),
926        });
927
928        let stream = algo.run_stream(request());
929        started_rx
930            .recv()
931            .await
932            .ok_or_else(|| test_error("task never started"))?;
933        drop(stream);
934        tokio::time::sleep(Duration::from_millis(100)).await;
935
936        assert!(
937            dropped.load(Ordering::SeqCst),
938            "algorithm task was NOT cancelled after dropping the stream"
939        );
940        Ok(())
941    }
942
943    #[tokio::test]
944    async fn route_panic_surfaces_as_a_stream_error() -> Result<()> {
945        // An algorithm whose task panics must surface an `Err` step carrying the panic
946        // message, not abort the process from an unobserved detached task.
947        struct Panicky;
948
949        #[async_trait]
950        impl Algorithm for Panicky {
951            fn name(&self) -> &str {
952                "panicky"
953            }
954
955            async fn route(
956                self: Arc<Self>,
957                _driver: Driver,
958                _request: Request,
959            ) -> Result<RoutingOutcome> {
960                panic!("boom");
961            }
962        }
963
964        let algo: Arc<dyn Algorithm> = Arc::new(Panicky);
965        let stream = algo.run_stream(request());
966        tokio::pin!(stream);
967
968        let mut saw_error = false;
969        while let Some(step) = stream.next().await {
970            match step {
971                Err(err) => {
972                    // The panic message is preserved, not flattened into an opaque failure.
973                    assert!(err.to_string().contains("algorithm task panicked: boom"));
974                    saw_error = true;
975                }
976                Ok(_) => return Err(test_error("expected the panic to surface as an error step")),
977            }
978        }
979
980        assert!(saw_error, "expected an error step from the panicked task");
981        Ok(())
982    }
983
984    /// A panicking algorithm must publish its terminal step even when it left a `Driver`
985    /// clone alive in another task. That clone holds the step channel open, so a run that
986    /// merely unwound would never terminate and the consumer would wait forever.
987    #[tokio::test]
988    async fn a_panic_with_a_leaked_driver_clone_still_terminates_the_run() -> Result<()> {
989        struct LeakyPanic;
990
991        #[async_trait]
992        impl Algorithm for LeakyPanic {
993            fn name(&self) -> &str {
994                "leaky_panic"
995            }
996
997            async fn route(
998                self: Arc<Self>,
999                driver: Driver,
1000                _request: Request,
1001            ) -> Result<RoutingOutcome> {
1002                tokio::spawn(async move {
1003                    // Outlives the panic below, keeping a sender clone alive.
1004                    let _keep_alive = driver;
1005                    std::future::pending::<()>().await;
1006                });
1007                tokio::task::yield_now().await;
1008                panic!("boom");
1009            }
1010        }
1011
1012        let algo: Arc<dyn Algorithm> = Arc::new(LeakyPanic);
1013        // The timeout turns the hang this guards against into a failure rather than a hang.
1014        let result = tokio::time::timeout(
1015            std::time::Duration::from_secs(1),
1016            test_drive(algo, request(), echo()),
1017        )
1018        .await
1019        .map_err(|error| LibsyError::external("waiting for the panicked run to end", error))?;
1020
1021        match result {
1022            Ok(_) => Err(test_error(
1023                "expected the panic to end the run with an error",
1024            )),
1025            Err(err) => {
1026                assert!(err.to_string().contains("algorithm task panicked: boom"));
1027                Ok(())
1028            }
1029        }
1030    }
1031
1032    #[tokio::test]
1033    async fn cancelling_run_cancels_the_algorithm_task() -> Result<()> {
1034        use std::sync::atomic::{AtomicBool, Ordering};
1035        use std::time::Duration;
1036        use tokio::sync::mpsc;
1037
1038        // Sets a flag when dropped, so we can observe whether the algorithm task was
1039        // cancelled once the `run` future driving it is dropped.
1040        struct DropGuard(Arc<AtomicBool>);
1041        impl Drop for DropGuard {
1042            fn drop(&mut self) {
1043                self.0.store(true, Ordering::SeqCst);
1044            }
1045        }
1046
1047        struct StuckAlgo {
1048            started: mpsc::UnboundedSender<()>,
1049            dropped: Arc<AtomicBool>,
1050        }
1051
1052        #[async_trait]
1053        impl Algorithm for StuckAlgo {
1054            fn name(&self) -> &str {
1055                "stuck"
1056            }
1057
1058            async fn route(
1059                self: Arc<Self>,
1060                _driver: Driver,
1061                _request: Request,
1062            ) -> Result<RoutingOutcome> {
1063                let _guard = DropGuard(self.dropped.clone());
1064                let _ = self.started.send(());
1065                // Hang forever without ever touching the driver, so only cancellation
1066                // (not a dropped step channel) can stop this task.
1067                std::future::pending::<()>().await;
1068                unreachable!()
1069            }
1070        }
1071
1072        let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1073        let dropped = Arc::new(AtomicBool::new(false));
1074        let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1075            started: started_tx,
1076            dropped: dropped.clone(),
1077        });
1078
1079        // Drive the run on its own task, wait until the algorithm task is up, then cancel
1080        // it — dropping its future (and the `run_stream` stream it holds).
1081        let run_task = tokio::spawn(async move { test_drive(algo, request(), echo()).await });
1082        started_rx
1083            .recv()
1084            .await
1085            .ok_or_else(|| test_error("task never started"))?;
1086        run_task.abort();
1087        tokio::time::sleep(Duration::from_millis(100)).await;
1088
1089        assert!(
1090            dropped.load(Ordering::SeqCst),
1091            "algorithm task was NOT cancelled after cancelling run"
1092        );
1093        Ok(())
1094    }
1095
1096    // --- first-wins hedging: `run` must not wait on losing speculative calls -------------
1097
1098    /// Offloads two targets concurrently and returns the first to resolve, dropping the
1099    /// loser's call (first-wins hedging).
1100    struct Hedge {
1101        winner: String,
1102        loser: String,
1103    }
1104
1105    #[async_trait]
1106    impl Algorithm for Hedge {
1107        fn name(&self) -> &str {
1108            "hedge"
1109        }
1110
1111        async fn route(
1112            self: Arc<Self>,
1113            driver: Driver,
1114            request: Request,
1115        ) -> Result<RoutingOutcome> {
1116            let outcome_request = request.clone();
1117            let win = driver.call_model(request.clone(), vec![self.winner.clone().into()]);
1118            let lose = driver.call_model(request, vec![self.loser.clone().into()]);
1119            // First to resolve wins; `select!` drops the losing future (and its promise).
1120            tokio::select! {
1121                res = win => Ok(RoutingOutcome::answered(
1122                    self.winner.clone().into(),
1123                    outcome_request,
1124                    res?,
1125                )),
1126                res = lose => Ok(RoutingOutcome::answered(
1127                    self.loser.clone().into(),
1128                    outcome_request,
1129                    res?,
1130                )),
1131            }
1132        }
1133    }
1134
1135    /// Builds a hedging algo and the `serve` that drives it: the winner is gated behind the
1136    /// loser starting (so the loser's serve is guaranteed in flight when the winner wins),
1137    /// and the loser finishes after `loser_delay` — or never, when `None`.
1138    fn hedge(loser_delay: Option<std::time::Duration>) -> (Arc<dyn Algorithm>, impl Serve) {
1139        let started = Arc::new(tokio::sync::Notify::new());
1140        let algo = Arc::new(Hedge {
1141            winner: "winner".to_string(),
1142            loser: "loser".to_string(),
1143        });
1144        let serve = move |target: ModelId, _request: Request| {
1145            let started = started.clone();
1146            async move {
1147                if target == "loser" {
1148                    started.notify_one();
1149                    match loser_delay {
1150                        Some(delay) => tokio::time::sleep(delay).await,
1151                        None => std::future::pending::<()>().await,
1152                    }
1153                } else {
1154                    started.notified().await;
1155                }
1156                Ok(reply(target))
1157            }
1158        };
1159        (algo, serve)
1160    }
1161
1162    #[tokio::test]
1163    async fn run_returns_the_winner_without_a_late_loser_overwriting_it() -> Result<()> {
1164        // The loser responds 50ms after the winner has already won. `run` must return the
1165        // winner, not the loser's `respond`-to-a-dropped-receiver error.
1166        let (algo, serve) = hedge(Some(std::time::Duration::from_millis(50)));
1167        let (_, response) = test_drive(algo, request(), serve).await?;
1168        assert_eq!(
1169            response
1170                .llm_response
1171                .as_agg()
1172                .map(completion_text)
1173                .unwrap_or_default(),
1174            "winner"
1175        );
1176        Ok(())
1177    }
1178
1179    #[tokio::test]
1180    async fn run_returns_the_winner_without_hanging_on_a_pending_loser() -> Result<()> {
1181        // The loser never resolves. `run` must return the winner promptly, not hang
1182        // waiting for the in-flight loser.
1183        let (algo, serve) = hedge(None);
1184        let run = test_drive(algo, request(), serve);
1185        let (_, response) = tokio::time::timeout(std::time::Duration::from_secs(1), run)
1186            .await
1187            .map_err(|error| LibsyError::external("waiting for pending loser", error))??;
1188        assert_eq!(
1189            response
1190                .llm_response
1191                .as_agg()
1192                .map(completion_text)
1193                .unwrap_or_default(),
1194            "winner"
1195        );
1196        Ok(())
1197    }
1198
1199    #[tokio::test]
1200    async fn run_surfaces_a_terminal_error_with_many_calls_in_flight() -> Result<()> {
1201        use std::sync::atomic::{AtomicUsize, Ordering};
1202
1203        // A large fan-out (10 matched the old, now-removed concurrency cap). The terminal
1204        // error must still reach the caller with all of these calls pending.
1205        const N: usize = 10;
1206
1207        // Fans out N calls, then errors as soon as all N are in flight — exercising a
1208        // terminal failure emitted while the offloaded calls are still pending.
1209        struct FanOutThenError {
1210            all_started: Arc<tokio::sync::Notify>,
1211            n: usize,
1212        }
1213
1214        #[async_trait]
1215        impl Algorithm for FanOutThenError {
1216            fn name(&self) -> &str {
1217                "fan_out_then_error"
1218            }
1219
1220            async fn route(
1221                self: Arc<Self>,
1222                driver: Driver,
1223                request: Request,
1224            ) -> Result<RoutingOutcome> {
1225                let offloads = futures::future::join_all(
1226                    (0..self.n)
1227                        .map(|i| driver.call_model(request.clone(), vec![format!("m{i}").into()])),
1228                );
1229                tokio::select! {
1230                    _ = offloads => Err(test_error("offloads unexpectedly completed")),
1231                    _ = self.all_started.notified() => {
1232                        Err(test_error("terminal error while calls pending"))
1233                    }
1234                }
1235            }
1236        }
1237
1238        let all_started = Arc::new(tokio::sync::Notify::new());
1239        let algo: Arc<dyn Algorithm> = Arc::new(FanOutThenError {
1240            all_started: all_started.clone(),
1241            n: N,
1242        });
1243
1244        // Serving enters each call; once all N are in flight it signals, then pends forever.
1245        let started = Arc::new(AtomicUsize::new(0));
1246        let serve = move |_target: ModelId, _request: Request| {
1247            let started = started.clone();
1248            let all_started = all_started.clone();
1249            async move {
1250                if started.fetch_add(1, Ordering::SeqCst) + 1 == N {
1251                    all_started.notify_one();
1252                }
1253                std::future::pending::<ServeResult>().await
1254            }
1255        };
1256
1257        // With the cap gone, the driver keeps polling the stream even with N calls in
1258        // flight, so the terminal error surfaces promptly instead of hanging.
1259        let run = test_drive(algo, request(), serve);
1260        let result = tokio::time::timeout(std::time::Duration::from_millis(500), run)
1261            .await
1262            .map_err(|error| {
1263                LibsyError::external("waiting for terminal error with full call cap", error)
1264            })?;
1265        match result {
1266            Ok(_) => Err(test_error("expected the terminal error, got a response")),
1267            Err(err) => {
1268                assert!(
1269                    err.to_string()
1270                        .contains("terminal error while calls pending")
1271                );
1272                Ok(())
1273            }
1274        }
1275    }
1276}