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