Skip to main content

switchyard_libsy/core/
algorithm.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! The [`Algorithm`] trait and its [`Driver`] — the orchestration contract every
5//! algorithm implements, and the offload channel it uses to make model calls and
6//! publish [`Decision`]s.
7
8use std::{
9    collections::{HashMap, HashSet},
10    pin::Pin,
11    sync::Arc,
12    time::{Duration, Instant},
13};
14
15use async_trait::async_trait;
16use futures::{Stream, StreamExt};
17use parking_lot::Mutex;
18use tracing::Instrument;
19
20/// The request/response protocol types come from [`switchyard_protocol`].
21/// [`switchyard_protocol::LlmRequest`] is the normalized request;
22/// [`switchyard_protocol::AggLlmResponse`] is the buffered response;
23/// [`switchyard_protocol::LlmResponseChunk`] is normalized streaming content;
24/// [`switchyard_protocol::LlmResponseStreamEvent`] is its host/algorithm envelope; and
25/// [`switchyard_protocol::LlmResponse`] carries either a live
26/// [`switchyard_protocol::LlmResponseStream`] or the terminal aggregate.
27use switchyard_protocol::{
28    Context, Decision, LlmClientError, Request, Response, RoutedLlmClient, Signals, Usage,
29};
30
31use super::driver::{DriverRequest, DriverStep, TypeErasedDriver};
32use crate::{DriverError, LibsyError, Result, observability};
33
34/// A boxed, `Send` stream of [`Step`]s — the output of
35/// [`Algorithm::run_stream`]. Boxed so the trait method that produces it keeps
36/// `Arc<dyn Algorithm>` object-safe.
37pub type StepStream = Pin<Box<dyn Stream<Item = Result<Step>> + Send>>;
38
39/// One completed model call observed at the algorithm offload boundary.
40#[derive(Clone, Debug)]
41pub struct LlmCallObservation {
42    /// Model selected for the completed call.
43    pub selected_model: String,
44    /// Routing tier attached to the selected model, when present.
45    pub tier: Option<String>,
46    /// Whether this was the routed backend call rather than classifier or judge overhead.
47    pub is_routed: bool,
48    /// Whether the call completed successfully.
49    pub is_success: bool,
50    /// Time spent waiting for the model call to resolve.
51    pub duration: Duration,
52    /// Normalized usage for a buffered successful response.
53    pub usage: Option<Usage>,
54}
55
56/// One request-scoped observation emitted by the algorithm runner.
57#[derive(Clone, Debug)]
58pub enum RunObservation {
59    /// A completed model call.
60    LlmCall(LlmCallObservation),
61    /// Routing time recorded by the `switchyard.routing_overhead_ms` metric.
62    RoutingOverhead(Duration),
63}
64
65/// Request-scoped callback for algorithm-run observations.
66///
67/// The runner invokes this callback inline while resolving observations. Several
68/// runs may invoke the same observer concurrently, so implementations must be
69/// thread-safe, fast, and non-blocking.
70pub type RunObserver = Arc<dyn Fn(RunObservation) + Send + Sync>;
71
72/// A request paired with the routing [`Decision`] that produced it — the offload
73/// payload a host reads (via [`CallLlmRequest::get_routed`]) to serve the call.
74///
75/// The two model identifiers live in separate, unambiguous places: the model to
76/// call is [`decision.selected_model()`](Decision::selected_model), while
77/// `request.llm_request.model` is the *inbound* name the agent asked for (libsy
78/// never overwrites it). A client maps `selected_model()` to the provider model
79/// id it hits.
80#[derive(Clone)]
81pub struct RoutedRequest {
82    /// The request to serve; its `model` is the agent's original name.
83    pub request: Request,
84    /// The routing decision behind this call; `selected_model()` is the model to hit.
85    pub decision: Arc<dyn Decision>,
86    /// The client that serves this call by default, or `None` when the routed target
87    /// had no client. Rides along on the offloaded call so a host driving the stream
88    /// can serve it by default or override it with its own transport.
89    pub default_client: Option<Arc<dyn RoutedLlmClient>>,
90    /// The request's cross-cutting context, carried through the offload so whoever
91    /// serves the call (libsy's own `run`, or a host driving the stream) hands it to
92    /// [`RoutedLlmClient::call`].
93    pub ctx: Context,
94}
95
96/// The host-facing half of an offloaded model call, surfaced inside [`Step::CallLlm`].
97///
98/// Wraps a `DriverRequest` whose payload is a [`RoutedRequest`]. The host reads the
99/// routed request ([`get_routed`](Self::get_routed)) and the decision behind it
100/// ([`get_decision`](Self::get_decision)), performs (or delegates) the model call, and
101/// fulfills it with [`respond`](Self::respond) — unblocking the algorithm's
102/// [`Driver::call_llm`] on the other side.
103pub struct CallLlmRequest {
104    inner: DriverRequest,
105    routed: RoutedRequest,
106}
107
108impl CallLlmRequest {
109    /// Wrap a driver request whose payload is a [`RoutedRequest`]. Caches an owned copy
110    /// so the accessors are plain field reads.
111    fn new(inner: DriverRequest) -> Self {
112        // The payload is always a `RoutedRequest` (set by `Driver::call_llm`); a
113        // mismatch would be a libsy bug, not a runtime condition.
114        let routed = match inner.request::<RoutedRequest>() {
115            Ok(routed) => routed.clone(),
116            Err(_) => unreachable!("CallLlmRequest payload is always a RoutedRequest"),
117        };
118        Self { inner, routed }
119    }
120
121    /// The routed request the host should serve. Its
122    /// [`default_client`](RoutedRequest::default_client) serves the call by default,
123    /// and its `decision.selected_model()` names the model to hit.
124    pub fn get_routed(&self) -> &RoutedRequest {
125        &self.routed
126    }
127
128    /// The model request to perform (the [`Request`] inside the routed request).
129    pub fn get_request(&self) -> &Request {
130        &self.get_routed().request
131    }
132
133    /// The decision that led to this call — its `selected_model()` is the model to hit.
134    pub fn get_decision(&self) -> &dyn Decision {
135        self.get_routed().decision.as_ref()
136    }
137
138    /// Fulfill the promise with the caller's model-call result. Pass `Err(..)` to
139    /// propagate a failed model call back to the algorithm. Consumes the promise: it
140    /// can only be fulfilled once.
141    pub fn respond(self, result: Result<Response>) -> Result<()> {
142        self.inner.respond::<Response>(result)
143    }
144}
145
146/// The offload channel handed to an algorithm's
147/// [`create_run_task`](Algorithm::create_run_task). The algorithm makes model calls
148/// with [`call_llm_target`](Self::call_llm_target) (or [`call_llm`](Self::call_llm)) and
149/// publishes its [`Decision`]s with [`info`](Self::info); each call is offloaded to the
150/// request's [`Step`] stream and awaits the consumer's response. The step channel is
151/// bounded, so the consumer paces the algorithm one step at a time.
152#[derive(Clone)]
153pub struct Driver {
154    driver: TypeErasedDriver,
155    // How long the call that served this run took. We need this to calculate routing overhead.
156    routed_call: Arc<Mutex<Option<Duration>>>,
157    observer: Option<RunObserver>,
158}
159
160impl Driver {
161    /// Build an empty driver with its step channel ready. Created per call by
162    /// [`run_stream`](Algorithm::run_stream).
163    pub(crate) fn new() -> Self {
164        Self::with_observer(None)
165    }
166
167    fn with_observer(observer: Option<RunObserver>) -> Self {
168        Self {
169            driver: TypeErasedDriver::new(),
170            routed_call: Arc::new(Mutex::new(None)),
171            observer,
172        }
173    }
174
175    /// How long the call that served this run took, if one has succeeded.
176    pub(crate) fn routed_call_duration(&self) -> Option<Duration> {
177        *self.routed_call.lock()
178    }
179
180    /// Records routing overhead (how long Switchyard added to the call) once
181    /// per successful run. Called by `observe_run`.
182    pub(crate) fn observe_routing_overhead(&self, duration: Duration) {
183        if let Some(observer) = &self.observer {
184            observer(RunObservation::RoutingOverhead(duration));
185        }
186    }
187
188    /// Offload a model call: publish `routed` as a [`Step::CallLlm`] and await the
189    /// consumer's [`Response`]. The call's context travels inside
190    /// [`routed.ctx`](RoutedRequest::ctx). Errors if the stream is closed or the call failed.
191    /// The await is wrapped in a `libsy.llm_call` span measuring *fulfillment* as
192    /// the algorithm observes it (host queueing/serving included; a streamed
193    /// response resolves when its stream handle arrives); latency, outcome, and
194    /// token usage are recorded when it resolves. The provider call itself gets a
195    /// `libsy.client_call` span when [`Algorithm::run`] serves it.
196    #[tracing::instrument(
197        target = "libsy",
198        name = "libsy.llm_call",
199        skip_all,
200        fields(
201            algorithm = observability::algorithm_label(&routed.ctx),
202            selected_model = routed.decision.selected_model(),
203            openinference.span.kind = "CHAIN",
204            outcome = tracing::field::Empty,
205            error = tracing::field::Empty,
206            input_tokens = tracing::field::Empty,
207            output_tokens = tracing::field::Empty,
208            total_tokens = tracing::field::Empty,
209            reasoning_tokens = tracing::field::Empty,
210        )
211    )]
212    pub async fn call_llm(&self, routed: RoutedRequest) -> Result<Response> {
213        let algorithm = observability::algorithm_label(&routed.ctx).to_string();
214        let selected_model = routed.decision.selected_model().to_string();
215        let tier = routed.decision.routing_tier().map(str::to_string);
216        let is_routed = routed.decision.is_routed_call();
217        let started = Instant::now();
218        let result = self
219            .driver
220            .fulfill_request::<RoutedRequest, Response>(routed.ctx.clone(), routed)
221            .await;
222        let elapsed = started.elapsed();
223        observability::record_llm_call(
224            &algorithm,
225            &selected_model,
226            tier.as_deref(),
227            is_routed,
228            elapsed,
229            &result,
230            &tracing::Span::current(),
231        );
232        if let Some(observer) = &self.observer {
233            observer(RunObservation::LlmCall(LlmCallObservation {
234                selected_model,
235                tier,
236                is_routed,
237                is_success: result.is_ok(),
238                duration: elapsed,
239                usage: result
240                    .as_ref()
241                    .ok()
242                    .and_then(|response| response.llm_response.as_agg())
243                    .map(|response| response.usage.clone()),
244            }));
245        }
246        // Classifier and judge calls are routing overhead.
247        // And don't record time for failed calls.
248        if is_routed && result.is_ok() {
249            *self.routed_call.lock() = Some(elapsed);
250        }
251        result
252    }
253
254    /// Offload a call to `target`: pair `request` with `decision` and the target's
255    /// default client into a [`RoutedRequest`], then publish it (see
256    /// [`call_llm`](Self::call_llm)). The convenience most algorithms use;
257    /// `decision.selected_model()` names the model to hit, and `request`'s
258    /// `model` is left untouched.
259    pub async fn call_llm_target(
260        &self,
261        ctx: Context,
262        target: &LlmTarget,
263        request: Request,
264        decision: Arc<dyn Decision>,
265    ) -> Result<Response> {
266        self.call_llm(RoutedRequest {
267            request,
268            decision,
269            default_client: target.llm_client.clone(),
270            ctx,
271        })
272        .await
273    }
274
275    /// Publish a routing [`Decision`] as a [`Step::Decision`] on the stream.
276    /// Each successfully published decision is counted and logged with its
277    /// reasoning; a decision the stream never accepted is not recorded.
278    pub async fn info(&self, ctx: Context, decision: Arc<dyn Decision>) -> Result<()> {
279        self.driver.info(ctx.clone(), decision.clone()).await?;
280        observability::record_decision(&ctx, decision.as_ref());
281        Ok(())
282    }
283
284    /// Emit the terminal step: [`Step::ReturnToAgent`] on `Ok`, or an `Err` stream
285    /// item on failure. Internal: called once by [`run_stream`](Algorithm::run_stream)
286    /// when the algorithm finishes.
287    pub(crate) async fn finish(&self, ctx: Context, result: Result<Response>) -> Result<()> {
288        match result {
289            Ok(response) => self.driver.done(ctx, response).await,
290            Err(err) => self.driver.fail(ctx, err).await,
291        }
292    }
293
294    /// Transform the raw driver stream into a stream of [`Step`]s. Internal: the
295    /// consumer stream is taken (once) by [`run_stream`](Algorithm::run_stream). A
296    /// payload that does not match the expected type for its step becomes an `Err` item.
297    pub(crate) fn stream(&self) -> impl Stream<Item = Result<Step>> + use<> {
298        self.driver.stream().map(|item| match item? {
299            DriverStep::Request(req) => Ok(Step::CallLlm(Box::new(CallLlmRequest::new(req)))),
300            DriverStep::Info(payload) => payload
301                .downcast::<Arc<dyn Decision>>()
302                .map(|decision| Step::Decision(*decision))
303                .map_err(|_| {
304                    DriverError::TypeMismatch {
305                        expected: "Arc<dyn Decision>",
306                    }
307                    .into()
308                }),
309            DriverStep::Done(payload) => payload
310                .downcast::<Response>()
311                .map(Step::ReturnToAgent)
312                .map_err(|_| {
313                    DriverError::TypeMismatch {
314                        expected: "Response",
315                    }
316                    .into()
317                }),
318        })
319    }
320}
321
322impl Default for Driver {
323    fn default() -> Self {
324        Self::new()
325    }
326}
327
328/// One item in the stream returned by `Driver::stream` / [`Algorithm::run_stream`].
329pub enum Step {
330    /// The algorithm needs this model call performed. The host serves it (optionally
331    /// via [`RoutedRequest::default_client`]) and fulfills it with
332    /// [`CallLlmRequest::respond`]. Boxed: it is by far the largest variant.
333    CallLlm(Box<CallLlmRequest>),
334    /// A routing decision the algorithm made, published via [`Driver::info`] as it
335    /// happens (rather than collected into a trace returned at the end).
336    Decision(Arc<dyn Decision>),
337    /// The algorithm finished with its final response — the last step of a run.
338    ReturnToAgent(Box<Response>),
339}
340
341/// Abort guard
342struct AbortOnDrop(tokio::task::AbortHandle);
343
344impl Drop for AbortOnDrop {
345    fn drop(&mut self) {
346        self.0.abort();
347    }
348}
349
350/// A named routing target: a `semantic_name` an algorithm routes by, and an optional
351/// [`RoutedLlmClient`] to serve its calls. An algorithm hands a target to
352/// [`Driver::call_llm_target`]; the client rides along as
353/// [`RoutedRequest::default_client`] for the stream consumer to serve or override.
354#[derive(Clone)]
355pub struct LlmTarget {
356    /// The routing label an algorithm selects this target by — a logical tier like
357    /// `"strong"`, or the model id when they coincide. Mapping it to a provider model
358    /// id is the client's concern, never the algorithm's.
359    pub semantic_name: String,
360    /// The client that serves this target's calls by default, or `None` (then the
361    /// stream consumer must serve them).
362    pub llm_client: Option<Arc<dyn RoutedLlmClient>>,
363}
364
365/// The set of targets an algorithm may route among. An algorithm is constructed
366/// with one and picks targets by position ([`targets`](Self::targets)) or by name
367/// ([`get_target`](Self::get_target)).
368#[derive(Clone)]
369pub struct LlmTargetSet {
370    targets: Vec<LlmTarget>,
371}
372
373impl LlmTargetSet {
374    /// Build a target set from a list of targets.
375    pub fn new(targets: Vec<LlmTarget>) -> Self {
376        Self { targets }
377    }
378
379    /// All targets in the set — e.g. for an algorithm to select among.
380    pub fn targets(&self) -> &[LlmTarget] {
381        &self.targets
382    }
383
384    /// Look up a target by name; errors if no target has that name.
385    pub fn get_target(&self, name: &str) -> Result<LlmTarget> {
386        self.targets
387            .iter()
388            .find(|t| t.semantic_name == name)
389            .cloned()
390            .ok_or_else(|| LibsyError::TargetNotFound {
391                target: name.to_string(),
392            })
393    }
394
395    /// The named target, or the first one this request is not barred from when it has been
396    /// excluded (see [`Context::exclude_target`]). Errors if every target is excluded.
397    pub fn resolve_target(&self, name: &str, ctx: &Context) -> Result<LlmTarget> {
398        let target = self.get_target(name)?;
399        if !ctx.is_excluded(&target.semantic_name) {
400            return Ok(target);
401        }
402        self.targets
403            .iter()
404            .find(|t| !ctx.is_excluded(&t.semantic_name))
405            .cloned()
406            .ok_or(LibsyError::AllTargetsExcluded)
407    }
408
409    /// The first target's client that can serve `count_tokens` (an Anthropic
410    /// upstream), or `None` when no target has one. Used by an algorithm's
411    /// [`count_tokens_client`](crate::Algorithm::count_tokens_client).
412    pub fn count_tokens_client(&self) -> Option<Arc<dyn RoutedLlmClient>> {
413        self.targets.iter().find_map(|target| {
414            target
415                .llm_client
416                .as_ref()
417                .filter(|client| client.supports_count_tokens())
418                .cloned()
419        })
420    }
421}
422
423/// Bounds process-local overflow history. Dropping a live session's entry costs one
424/// rediscovered overflow, so the victim choice does not need to be exact.
425const MAX_EVICTION_SESSIONS: usize = 1_024;
426
427/// Per-session record of the targets that overflowed their context window.
428///
429/// A conversation only grows, so a target that could not fit one turn will not fit a
430/// later one; remembering it lets the next turn skip a call certain to fail. Requests
431/// without a session id are not tracked — there is nothing to remember them by.
432#[derive(Default)]
433pub(crate) struct SessionEvictions {
434    sessions: Mutex<HashMap<String, HashSet<String>>>,
435}
436
437impl SessionEvictions {
438    /// Forgets overflow history for a completed session.
439    pub(crate) fn remove(&self, session: &str) {
440        self.sessions.lock().remove(session);
441    }
442
443    /// The targets `session` has already overflowed; empty for an untracked request.
444    fn evicted_in(&self, session: Option<&str>) -> Vec<String> {
445        let Some(session) = session else {
446            return Vec::new();
447        };
448        self.sessions
449            .lock()
450            .get(session)
451            .map(|targets| targets.iter().cloned().collect())
452            .unwrap_or_default()
453    }
454
455    /// Remembers that `target` overflowed in `session`, tracking at most
456    /// [`MAX_EVICTION_SESSIONS`] sessions.
457    fn record(&self, session: Option<&str>, target: &str) {
458        let Some(session) = session else { return };
459        let mut sessions = self.sessions.lock();
460        if sessions.len() >= MAX_EVICTION_SESSIONS
461            && !sessions.contains_key(session)
462            && let Some(oldest) = sessions.keys().next().cloned()
463        {
464            sessions.remove(&oldest);
465        }
466        sessions
467            .entry(session.to_string())
468            .or_default()
469            .insert(target.to_string());
470    }
471}
472
473/// How many of `targets` this request is still allowed to reach.
474fn eligible_targets(targets: &LlmTargetSet, ctx: &Context) -> usize {
475    targets
476        .targets()
477        .iter()
478        .filter(|t| !ctx.is_excluded(&t.semantic_name))
479        .count()
480}
481
482/// Bars the targets `session` has already overflowed from this request, so routing does
483/// not select one that is certain to fail again.
484pub(crate) fn exclude_evicted(
485    ctx: &mut Context,
486    targets: &LlmTargetSet,
487    evictions: &SessionEvictions,
488    session: Option<&str>,
489) {
490    for target in evictions.evicted_in(session) {
491        // Never seed the pool empty: a later turn may be small enough to serve, and the
492        // caller should get the upstream's answer rather than a routing error.
493        if eligible_targets(targets, ctx) <= 1 {
494            break;
495        }
496        ctx.exclude_target(target);
497    }
498}
499
500/// Calls `target`, falling back to the next eligible target in `targets` whenever one
501/// overflows its context window, until a call succeeds or every target has been tried.
502///
503/// Routing is deliberately not re-run: the fallback replaces the target in place, so the
504/// caller's request-side work and retained state still see exactly one turn.
505/// `fallback_decision` builds the [`Decision`] published for a `from -> to` hop, and each
506/// overflow is recorded against `session` so later turns skip that target outright.
507#[allow(clippy::too_many_arguments)]
508pub(crate) async fn call_llm_with_overflow_fallback(
509    mut ctx: Context,
510    driver: &Driver,
511    targets: &LlmTargetSet,
512    mut target: LlmTarget,
513    mut decision: Arc<dyn Decision>,
514    request: Request,
515    session: Option<&str>,
516    evictions: &SessionEvictions,
517    fallback_decision: impl Fn(&LlmTarget, &LlmTarget) -> Arc<dyn Decision>,
518) -> Result<Response> {
519    loop {
520        let result = driver
521            .call_llm_target(ctx.clone(), &target, request.clone(), decision.clone())
522            .await;
523        let Err(error) = result else { return result };
524        let LibsyError::ClientCall {
525            target: failed,
526            source: LlmClientError::ContextWindowExceeded { .. },
527        } = &error
528        else {
529            return Err(error);
530        };
531        // A target already excluded means the pool is spent; surface the client error
532        // so the caller still sees a context overflow rather than an internal failure.
533        if !ctx.exclude_target(failed) {
534            return Err(error);
535        }
536        evictions.record(session, failed);
537        let Ok(next) = targets.resolve_target(&target.semantic_name, &ctx) else {
538            return Err(error);
539        };
540        decision = fallback_decision(&target, &next);
541        target = next;
542        driver.info(ctx.clone(), decision.clone()).await?;
543    }
544}
545
546/// An optimization strategy. Implement [`create_run_task`](Self::create_run_task);
547/// callers drive it with the provided [`run`](Self::run) (serve calls, get the answer)
548/// or [`run_stream`](Self::run_stream) (drive the [`Step`] stream yourself).
549///
550/// Methods take `self: Arc<Self>`: one algorithm (`Arc<dyn Algorithm>`) is shared across
551/// requests and run concurrently, so it owns its thread-safety and any shared state.
552///
553/// # Concurrency
554///
555/// A host may run the same algorithm concurrently for many requests. Implementations
556/// must synchronize their own mutable shared state. Each call to [`run_stream`](Self::run_stream)
557/// creates an independent [`Driver`], so model-call promises and emitted [`Step`]s cannot
558/// cross between runs.
559///
560/// # Observability
561///
562/// [`run_stream`](Self::run_stream) creates a `libsy.run` span, and each offloaded model
563/// call creates a `libsy.llm_call` span. [`run`](Self::run) additionally wraps calls it
564/// serves through a target's default client in `libsy.client_call`. Decisions and failures
565/// are emitted through `tracing`; metrics use the global OpenTelemetry meter provider.
566#[async_trait]
567pub trait Algorithm: Send + Sync + 'static {
568    /// Stable, low-cardinality name identifying this algorithm — the
569    /// `algorithm` attribute on every span, metric, and log line the crate
570    /// emits for its runs.
571    fn name(&self) -> &str;
572
573    /// Run one request to completion: make model calls with [`Driver::call_llm_target`],
574    /// publish [`Decision`]s with [`Driver::info`], and return the final [`Response`].
575    /// The method an algorithm implements; [`run`](Self::run) / [`run_stream`](Self::run_stream)
576    /// drive it. `ctx` carries the request's cross-cutting values (today: the
577    /// algorithm's telemetry label in [`Context::values`]).
578    async fn create_run_task(
579        self: Arc<Self>,
580        ctx: Context,
581        driver: Driver,
582        request: Request,
583    ) -> Result<Response>;
584
585    /// Feed the algorithm agentic-stack events (tool results, budgets, etc.). The
586    /// reference algorithms ignore signals; a stateful algorithm updates its own
587    /// (interior-mutable) state. Takes `self: Arc<Self>` like the other run methods.
588    #[allow(unused_variables)]
589    async fn process_signals(self: Arc<Self>, signals: Signals) -> Result<()> {
590        Ok(())
591    }
592
593    /// The client [`count_tokens`](Self::count_tokens) forwards to: the first of
594    /// this algorithm's targets whose client can count tokens (an Anthropic
595    /// upstream). The default is `None` — an algorithm with no Anthropic target
596    /// does not support token counting.
597    ///
598    /// CAVEAT: this picks the *first* Anthropic target, not a routed one —
599    /// count_tokens is a direct passthrough, so it does not run the routing
600    /// cascade. For a route with several Anthropic tiers "first" is arbitrary;
601    /// choosing which tier count_tokens should reflect is deferred.
602    fn count_tokens_client(&self) -> Option<Arc<dyn RoutedLlmClient>> {
603        None
604    }
605
606    /// Count the tokens `request` would use — a **direct passthrough** to this
607    /// algorithm's Anthropic target (via
608    /// [`count_tokens_client`](Self::count_tokens_client)), **not** a routed
609    /// call. Token counting is a pre-flight estimate with no routing decision,
610    /// so it deliberately bypasses the classifier cascade (which runs only for
611    /// completions via [`run`](Self::run)). Returns the upstream's JSON
612    /// verbatim. Errors when the algorithm has no Anthropic target.
613    async fn count_tokens(&self, request: Request) -> Result<serde_json::Value> {
614        let client = self
615            .count_tokens_client()
616            .ok_or_else(|| LibsyError::AlgorithmError {
617                message: "no target supports count_tokens (needs an Anthropic upstream)"
618                    .to_string(),
619            })?;
620        client
621            .count_tokens(request)
622            .await
623            .map_err(|source| LibsyError::client_call("count_tokens", source))
624    }
625
626    /// Process a request to completion, returning a stream of [`Step`]s.
627    ///
628    /// The consumer must fulfill every [`Step::CallLlm`] before the algorithm can
629    /// continue. The bounded step channel applies backpressure when the consumer is
630    /// not polling. A successful run ends with [`Step::ReturnToAgent`]; a failure is
631    /// emitted as an `Err` item. Dropping the stream aborts the spawned algorithm task.
632    ///
633    /// Every invocation owns a separate [`Driver`]. `observer`, when present, receives
634    /// each completed model call and, after a successful routed run, its routing overhead.
635    fn run_stream(
636        self: Arc<Self>,
637        ctx: Context,
638        request: Request,
639        observer: Option<RunObserver>,
640    ) -> StepStream {
641        // Stamp the algorithm's telemetry label into the request context; the
642        // context rides on every driver call, so its telemetry is attributed.
643        let mut ctx = ctx;
644        ctx.values.insert(
645            observability::ALGORITHM_KEY.to_string(),
646            self.name().to_string(),
647        );
648        let driver = Driver::with_observer(observer);
649        let task_driver = driver.clone();
650        let task_ctx = ctx.clone();
651        let stream = task_driver.stream();
652        // One `libsy.run` span covers the whole algorithm task; the driver's
653        // `libsy.llm_call` spans and decision logs nest inside it via `tracing`'s
654        // contextual parenting.
655        let span = observability::run_span(self.name(), &request);
656        let observed_driver = task_driver.clone();
657        let handle = tokio::spawn(
658            async move {
659                observability::observe_run(
660                    task_ctx.clone(),
661                    observed_driver,
662                    self.create_run_task(task_ctx, task_driver, request),
663                )
664                .await
665            }
666            .instrument(span),
667        );
668        // Dropping the stream aborts the algorithm task when its consumer goes away.
669        let abort_guard = AbortOnDrop(handle.abort_handle());
670
671        let finish_driver = driver.clone();
672        let finish_ctx = ctx;
673        let tail: StepStream = Box::pin(
674            futures::stream::once(async move {
675                let result = match handle.await {
676                    Ok(response) => response,
677                    Err(source) => Err(LibsyError::AlgorithmTask { source }),
678                };
679                finish_driver.finish(finish_ctx, result).await
680            })
681            .filter_map(|finish_result| async move { finish_result.err().map(Err) }),
682        );
683
684        let stream: StepStream = Box::pin(stream);
685        Box::pin(futures::stream::select(stream, tail).map(move |step| {
686            // link abort guard to stream
687            let _keep_alive = &abort_guard;
688            step
689        }))
690    }
691
692    /// Process a request to completion, returning the final [`Response`] and the trace of
693    /// [`Decision`]s the algorithm made along the way.
694    ///
695    async fn run(
696        self: Arc<Self>,
697        ctx: Context,
698        request: Request,
699    ) -> Result<(Vec<Arc<dyn Decision>>, Response)> {
700        self.run_observed(ctx, request, None).await
701    }
702
703    /// Process a request to completion while reporting each model call to `observer`.
704    async fn run_observed(
705        self: Arc<Self>,
706        ctx: Context,
707        request: Request,
708        observer: Option<RunObserver>,
709    ) -> Result<(Vec<Arc<dyn Decision>>, Response)> {
710        // Serve one offloaded call with its target's default client. A failed *model*
711        // call is forwarded to the algorithm via `respond`; this errors only on an
712        // infrastructure failure (no default client, or the promise was dropped).
713        // `serve` makes the one API call libsy itself performs, so it gets its
714        // own `libsy.client_call` span.
715        #[tracing::instrument(
716            target = "libsy",
717            name = "libsy.client_call",
718            skip_all,
719            fields(
720                algorithm = observability::algorithm_label(&call.get_routed().ctx),
721                switchyard.algorithm = observability::algorithm_label(&call.get_routed().ctx),
722                switchyard.routing.tier = tracing::field::Empty,
723                selected_model = call.get_decision().selected_model(),
724                otel.kind = "client",
725                otel.name = %format_args!("chat {}", call.get_decision().selected_model()),
726                openinference.span.kind = "LLM",
727                gen_ai.operation.name = "chat",
728                gen_ai.request.model = call.get_decision().selected_model(),
729                gen_ai.request.stream = tracing::field::Empty,
730                gen_ai.request.temperature = tracing::field::Empty,
731                gen_ai.request.top_p = tracing::field::Empty,
732                gen_ai.request.top_k = tracing::field::Empty,
733                gen_ai.request.max_tokens = tracing::field::Empty,
734                gen_ai.request.reasoning.level = tracing::field::Empty,
735                gen_ai.output.type = tracing::field::Empty,
736                gen_ai.conversation.id = tracing::field::Empty,
737                server.address = tracing::field::Empty,
738                server.port = tracing::field::Empty,
739                gen_ai.response.id = tracing::field::Empty,
740                gen_ai.response.model = tracing::field::Empty,
741                gen_ai.usage.input_tokens = tracing::field::Empty,
742                gen_ai.usage.output_tokens = tracing::field::Empty,
743                gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
744                gen_ai.usage.cache_creation.input_tokens = tracing::field::Empty,
745                gen_ai.usage.reasoning.output_tokens = tracing::field::Empty,
746                outcome = tracing::field::Empty,
747                otel.status_code = tracing::field::Empty,
748                error.type = tracing::field::Empty,
749                error = tracing::field::Empty,
750            )
751        )]
752        async fn serve(call: CallLlmRequest) -> Result<()> {
753            let span = tracing::Span::current();
754            observability::record_gen_ai_request(&span, &call.get_routed().request.llm_request);
755            if let Some(tier) = call.get_decision().routing_tier() {
756                span.record("switchyard.routing.tier", tier);
757            }
758            if let Some(session_id) = call
759                .get_routed()
760                .request
761                .metadata
762                .as_ref()
763                .and_then(|metadata| metadata.session_id.as_deref())
764            {
765                span.record("gen_ai.conversation.id", session_id);
766            }
767            let routed = call.get_routed().clone();
768            let target = routed.decision.selected_model().to_string();
769            let client =
770                routed
771                    .default_client
772                    .clone()
773                    .ok_or_else(|| LibsyError::MissingClient {
774                        target: target.clone(),
775                    })?;
776            let result = client
777                .call(routed.ctx, routed.request, routed.decision)
778                .await
779                .map_err(|source| LibsyError::client_call(target, source));
780            let result = observability::observe_client_call(result);
781            call.respond(result)
782        }
783
784        let stream = self.run_stream(ctx, request, observer);
785        tokio::pin!(stream);
786
787        let mut trace: Vec<Arc<dyn Decision>> = Vec::new();
788        let mut in_flight = futures::stream::FuturesUnordered::new();
789        let mut final_response: Option<Response> = None;
790
791        loop {
792            tokio::select! {
793                Some(result) = in_flight.next() => match result {
794                    Ok(()) => {}, // CallLlm completed successfully
795                    Err(err) => return Err(err), // CallLlm failed, propagate the error
796                },
797                step = stream.next() => {
798                    match step {
799                        None => break, // stream has ended, no more steps
800                        Some(item) => match item? {
801                            Step::CallLlm(call) => in_flight.push(serve(*call)),
802                            Step::Decision(decision) => trace.push(decision),
803                            Step::ReturnToAgent(response) => {
804                                final_response = Some(*response);
805                                break;
806                            }
807                        }
808                    }
809                },
810            }
811        }
812        final_response
813            .map(|response| (trace, response))
814            .ok_or(LibsyError::MissingFinalResponse)
815    }
816}
817
818#[cfg(test)]
819mod tests {
820    use super::*;
821    use futures::StreamExt;
822    use switchyard_protocol::{
823        LlmResponse, LlmResponseChunk, completion_text, text_request, text_response,
824    };
825
826    #[derive(Debug, thiserror::Error)]
827    #[error("{0}")]
828    struct TestError(&'static str);
829
830    fn test_error(message: &'static str) -> LibsyError {
831        LibsyError::external("test", TestError(message))
832    }
833
834    /// Mock client that echoes back the target name it was called with.
835    struct EchoClient;
836
837    #[async_trait]
838    impl RoutedLlmClient for EchoClient {
839        async fn call(
840            &self,
841            _ctx: Context,
842            _request: Request,
843            decision: Arc<dyn Decision>,
844        ) -> std::result::Result<Response, LlmClientError> {
845            // Echo back the model the algorithm routed to (the decision's selection).
846            Ok(Response {
847                llm_response: LlmResponse::Agg(text_response(
848                    None,
849                    decision.selected_model().to_string(),
850                )),
851                metadata: None,
852            })
853        }
854    }
855
856    /// Trivial decision + algo used only to exercise the orchestrator: calls the
857    /// first target and returns its response with a one-item trace.
858    struct TestDecision {
859        model: String,
860    }
861
862    impl Decision for TestDecision {
863        fn selected_model(&self) -> &str {
864            &self.model
865        }
866        fn reasoning(&self) -> Option<&str> {
867            None
868        }
869        fn as_any(&self) -> &dyn std::any::Any {
870            self
871        }
872    }
873
874    struct TestAlgo {
875        target_set: LlmTargetSet,
876    }
877
878    #[async_trait]
879    impl Algorithm for TestAlgo {
880        fn name(&self) -> &str {
881            "test"
882        }
883
884        async fn create_run_task(
885            self: Arc<Self>,
886            ctx: Context,
887            driver: Driver,
888            request: Request,
889        ) -> Result<Response> {
890            let target = self
891                .target_set
892                .targets()
893                .first()
894                .ok_or(LibsyError::NoTargets)?
895                .clone();
896            let decision: Arc<dyn Decision> = Arc::new(TestDecision {
897                model: target.semantic_name.clone(),
898            });
899            driver.info(ctx.clone(), decision.clone()).await?;
900            driver
901                .call_llm_target(ctx, &target, request, decision)
902                .await
903        }
904    }
905
906    /// Build a shared `TestAlgo` over the given target set.
907    fn orch(target_set: LlmTargetSet) -> Arc<dyn Algorithm> {
908        Arc::new(TestAlgo { target_set })
909    }
910
911    fn request() -> Request {
912        Request {
913            llm_request: text_request(Some("auto".to_string()), "hi".to_string()),
914            raw_request: None,
915            metadata: None,
916        }
917    }
918
919    /// `(name, has_client)` — `has_client: false` builds a target with no default client.
920    fn target_set(names: &[(&str, bool)]) -> LlmTargetSet {
921        let targets = names
922            .iter()
923            .map(|(name, has_client)| LlmTarget {
924                semantic_name: name.to_string(),
925                llm_client: has_client.then(|| Arc::new(EchoClient) as Arc<dyn RoutedLlmClient>),
926            })
927            .collect();
928        LlmTargetSet::new(targets)
929    }
930
931    #[tokio::test]
932    async fn observed_run_reports_one_successful_routed_call() -> Result<()> {
933        let observations = Arc::new(Mutex::new(Vec::new()));
934        let observed = observations.clone();
935        let observer: RunObserver = Arc::new(move |observation| observed.lock().push(observation));
936        let (_, response) = orch(target_set(&[("direct/model", true)]))
937            .run_observed(Context::default(), request(), Some(observer))
938            .await?;
939        assert_eq!(
940            response.llm_response.as_agg().map(completion_text),
941            Some("direct/model".to_string())
942        );
943        let observations = observations.lock();
944        assert_eq!(observations.len(), 2);
945        let RunObservation::LlmCall(observation) = &observations[0] else {
946            return Err(test_error("expected an LLM call observation"));
947        };
948        assert_eq!(observation.selected_model, "direct/model");
949        assert!(observation.is_routed);
950        assert!(observation.is_success);
951        assert!(observation.usage.is_some());
952        assert!(matches!(
953            observations[1],
954            RunObservation::RoutingOverhead(_)
955        ));
956        Ok(())
957    }
958
959    #[test]
960    fn target_lookup_returns_the_missing_target() {
961        let error = target_set(&[]).get_target("missing").err();
962        assert!(matches!(
963            error,
964            Some(LibsyError::TargetNotFound { target }) if target == "missing"
965        ));
966    }
967
968    /// Client that serves a call as a token stream — its `call` returns
969    /// [`LlmResponse::Stream`] replaying `chunks` in order (as `Ok` items).
970    struct StreamingClient {
971        chunks: Vec<LlmResponseChunk>,
972    }
973
974    #[async_trait]
975    impl RoutedLlmClient for StreamingClient {
976        async fn call(
977            &self,
978            _ctx: Context,
979            _request: Request,
980            _decision: Arc<dyn Decision>,
981        ) -> std::result::Result<Response, LlmClientError> {
982            let stream = futures::stream::iter(
983                self.chunks
984                    .clone()
985                    .into_iter()
986                    .map(|chunk| Ok(chunk.into())),
987            )
988            .boxed();
989            Ok(Response {
990                llm_response: LlmResponse::Stream(stream),
991                metadata: None,
992            })
993        }
994    }
995
996    /// Build a single-target algo whose one target streams `chunks`.
997    fn streaming_orch(chunks: Vec<LlmResponseChunk>) -> Arc<dyn Algorithm> {
998        let target = LlmTarget {
999            semantic_name: "stream/model".to_string(),
1000            llm_client: Some(Arc::new(StreamingClient { chunks }) as Arc<dyn RoutedLlmClient>),
1001        };
1002        orch(LlmTargetSet::new(vec![target]))
1003    }
1004
1005    #[tokio::test]
1006    async fn run_returns_a_streamed_response_the_caller_aggregates() -> Result<()> {
1007        // A streaming client -> its chunks flow through the promise and `ReturnToAgent`,
1008        // and `run` returns the live stream untouched for the caller to fold.
1009        let orch = streaming_orch(vec![
1010            LlmResponseChunk::MessageStart {
1011                id: Some("m1".to_string()),
1012                model: Some("stream/model".to_string()),
1013            },
1014            LlmResponseChunk::TextDelta {
1015                index: 0,
1016                text: "hel".to_string(),
1017            },
1018            LlmResponseChunk::TextDelta {
1019                index: 0,
1020                text: "lo".to_string(),
1021            },
1022            LlmResponseChunk::MessageStop {
1023                reason: Some("stop".to_string()),
1024            },
1025        ]);
1026        let (trace, response) = orch.run(Context::default(), request()).await?;
1027        // `run` handed back the live stream; the caller folds it to a buffered aggregate.
1028        let agg = response
1029            .llm_response
1030            .into_agg()
1031            .await
1032            .map_err(|error| LibsyError::external("aggregating response stream", error))?;
1033        assert_eq!(completion_text(&agg), "hello");
1034        assert_eq!(agg.model.as_deref(), Some("stream/model"));
1035        assert_eq!(trace.len(), 1);
1036        Ok(())
1037    }
1038
1039    #[tokio::test]
1040    async fn aggregating_a_streamed_response_propagates_a_mid_stream_error() -> Result<()> {
1041        // `run` succeeds and returns the stream; the in-band `Error` chunk surfaces only
1042        // when the caller aggregates it.
1043        let orch = streaming_orch(vec![
1044            LlmResponseChunk::TextDelta {
1045                index: 0,
1046                text: "partial".to_string(),
1047            },
1048            LlmResponseChunk::StreamError {
1049                message: "upstream exploded".to_string(),
1050            },
1051        ]);
1052        let (_, response) = orch.run(Context::default(), request()).await?;
1053        match response.llm_response.into_agg().await {
1054            Ok(_) => panic!("expected a mid-stream error, got an aggregate"),
1055            Err(err) => {
1056                assert!(err.to_string().contains("upstream exploded"));
1057                Ok(())
1058            }
1059        }
1060    }
1061
1062    #[tokio::test]
1063    async fn run_offloads_via_promise_then_returns_to_agent() -> Result<()> {
1064        // A client-less target -> its call is offloaded via a promise the
1065        // orchestrator surfaces as a `CallLlm` step for us to fulfill.
1066        let stream = orch(target_set(&[("offload/model", false)])).run_stream(
1067            Context::default(),
1068            request(),
1069            None,
1070        );
1071        tokio::pin!(stream);
1072
1073        let mut saw_call = false;
1074        let mut final_completion = None;
1075        while let Some(step) = stream.next().await {
1076            match step? {
1077                Step::CallLlm(call) => {
1078                    saw_call = true;
1079                    // The decision rode along with the promise.
1080                    assert_eq!(call.get_decision().selected_model(), "offload/model");
1081                    // Fulfilling the promise is the "real" model call the caller makes.
1082                    call.respond(Ok(Response {
1083                        llm_response: LlmResponse::Agg(text_response(
1084                            None,
1085                            "fulfilled".to_string(),
1086                        )),
1087                        metadata: None,
1088                    }))?;
1089                }
1090                Step::Decision(decision) => {
1091                    assert_eq!(decision.selected_model(), "offload/model");
1092                }
1093                Step::ReturnToAgent(response) => {
1094                    final_completion = Some(
1095                        response
1096                            .llm_response
1097                            .as_agg()
1098                            .map(completion_text)
1099                            .unwrap_or_default(),
1100                    );
1101                }
1102            }
1103        }
1104
1105        assert!(saw_call, "expected a CallLlm step before ReturnToAgent");
1106        assert_eq!(
1107            final_completion.ok_or_else(|| test_error("no ReturnToAgent step"))?,
1108            "fulfilled"
1109        );
1110        Ok(())
1111    }
1112
1113    #[tokio::test]
1114    async fn client_backed_target_offloads_with_a_default_client() -> Result<()> {
1115        // Every call now offloads to the stream; a client-backed target rides its
1116        // client along as `default_client` so the consumer can serve it by default.
1117        let stream = orch(target_set(&[("direct/model", true)])).run_stream(
1118            Context::default(),
1119            request(),
1120            None,
1121        );
1122        tokio::pin!(stream);
1123
1124        let mut final_completion = None;
1125        while let Some(step) = stream.next().await {
1126            match step? {
1127                Step::CallLlm(call) => {
1128                    let routed = call.get_routed().clone();
1129                    let client = routed
1130                        .default_client
1131                        .clone()
1132                        .ok_or_else(|| test_error("expected a default client"))?;
1133                    let target = routed.decision.selected_model().to_string();
1134                    let result = client
1135                        .call(routed.ctx, routed.request, routed.decision)
1136                        .await
1137                        .map_err(|error| LibsyError::client_call(target, error));
1138                    call.respond(result)?;
1139                }
1140                Step::Decision(_) => {}
1141                Step::ReturnToAgent(response) => {
1142                    final_completion = Some(
1143                        response
1144                            .llm_response
1145                            .as_agg()
1146                            .map(completion_text)
1147                            .unwrap_or_default(),
1148                    );
1149                }
1150            }
1151        }
1152
1153        // EchoClient echoes the model name back as the completion.
1154        assert_eq!(
1155            final_completion.ok_or_else(|| test_error("no ReturnToAgent"))?,
1156            "direct/model"
1157        );
1158        Ok(())
1159    }
1160
1161    #[tokio::test]
1162    async fn run_returns_the_response_when_all_targets_have_clients() -> Result<()> {
1163        // Every target has a client, so run serves every call via the
1164        // default client and returns the trace + final response.
1165        let (trace, response) = orch(target_set(&[("direct/model", true)]))
1166            .run(Context::default(), request())
1167            .await?;
1168        // TestAlgo calls the first target; EchoClient echoes its name.
1169        assert_eq!(
1170            response
1171                .llm_response
1172                .as_agg()
1173                .map(completion_text)
1174                .unwrap_or_default(),
1175            "direct/model"
1176        );
1177        assert_eq!(trace[0].selected_model(), "direct/model");
1178        Ok(())
1179    }
1180
1181    #[tokio::test]
1182    async fn run_errors_when_a_target_lacks_a_client() -> Result<()> {
1183        // A client-less target has no default client to serve its offloaded call, so
1184        // driving it to completion errors.
1185        let error = orch(target_set(&[("offload/model", false)]))
1186            .run(Context::default(), request())
1187            .await
1188            .err()
1189            .ok_or_else(|| test_error("expected a missing-client error"))?;
1190        assert!(matches!(
1191            error,
1192            LibsyError::MissingClient { target } if target == "offload/model"
1193        ));
1194        Ok(())
1195    }
1196
1197    #[tokio::test(flavor = "multi_thread", worker_threads = 12)]
1198    async fn requests_are_processed_in_parallel() -> Result<()> {
1199        use std::time::Duration;
1200        use tokio::sync::Barrier;
1201
1202        const N: usize = 12;
1203
1204        // A client that blocks until all N concurrent calls have arrived. If
1205        // requests were serialized (one algorithm behind a `Mutex`), only one
1206        // call could be in flight, the barrier would never reach N, and the test
1207        // would time out. It passes only because the shared algorithm is driven
1208        // concurrently across requests.
1209        struct BarrierClient {
1210            barrier: Arc<Barrier>,
1211        }
1212
1213        #[async_trait]
1214        impl RoutedLlmClient for BarrierClient {
1215            async fn call(
1216                &self,
1217                _ctx: Context,
1218                _request: Request,
1219                decision: Arc<dyn Decision>,
1220            ) -> std::result::Result<Response, LlmClientError> {
1221                self.barrier.wait().await;
1222                Ok(Response {
1223                    llm_response: LlmResponse::Agg(text_response(
1224                        None,
1225                        decision.selected_model().to_string(),
1226                    )),
1227                    metadata: None,
1228                })
1229            }
1230        }
1231
1232        let barrier = Arc::new(Barrier::new(N));
1233        let targets = LlmTargetSet::new(vec![LlmTarget {
1234            semantic_name: "m".to_string(),
1235            llm_client: Some(Arc::new(BarrierClient {
1236                barrier: barrier.clone(),
1237            })),
1238        }]);
1239        // One shared algorithm driven by many concurrent requests.
1240        let algo = orch(targets);
1241
1242        let mut handles = Vec::new();
1243        for _ in 0..N {
1244            let algo = algo.clone();
1245            handles.push(tokio::spawn(async move {
1246                algo.run(Context::default(), request())
1247                    .await
1248                    .map(|(_, response)| {
1249                        response
1250                            .llm_response
1251                            .as_agg()
1252                            .map(completion_text)
1253                            .unwrap_or_default()
1254                    })
1255            }));
1256        }
1257
1258        for handle in handles {
1259            // The timeout turns a serialization deadlock into a failure, not a hang.
1260            let completion = tokio::time::timeout(Duration::from_secs(5), handle)
1261                .await
1262                .map_err(|error| LibsyError::external("waiting for test task", error))?
1263                .map_err(|source| LibsyError::AlgorithmTask { source })??;
1264            assert_eq!(completion, "m");
1265        }
1266        Ok(())
1267    }
1268
1269    #[tokio::test]
1270    async fn offload_error_propagates_back_to_the_algorithm() -> Result<()> {
1271        // A client-less target offloads its call; we fulfill the promise with an
1272        // Err, which must flow back through `call_llm_target` into the algorithm and
1273        // out as an error step — not a response.
1274        let stream = orch(target_set(&[("offload/model", false)])).run_stream(
1275            Context::default(),
1276            request(),
1277            None,
1278        );
1279        tokio::pin!(stream);
1280
1281        let mut saw_error = false;
1282        while let Some(step) = stream.next().await {
1283            match step {
1284                Ok(Step::CallLlm(call)) => {
1285                    call.respond(Err(test_error("upstream model call failed")))?;
1286                }
1287                Ok(Step::Decision(_)) => {}
1288                Ok(Step::ReturnToAgent(..)) => {
1289                    return Err(test_error(
1290                        "expected the offload error to propagate, got a response",
1291                    ));
1292                }
1293                Err(err) => {
1294                    // The algorithm's `call_llm_target` saw the error via the promise.
1295                    assert!(err.to_string().contains("upstream model call failed"));
1296                    saw_error = true;
1297                }
1298            }
1299        }
1300
1301        assert!(saw_error, "expected an error step");
1302        Ok(())
1303    }
1304
1305    #[tokio::test]
1306    async fn dropping_the_stream_cancels_the_algorithm_task() -> Result<()> {
1307        use std::sync::atomic::{AtomicBool, Ordering};
1308        use std::time::Duration;
1309        use tokio::sync::mpsc;
1310
1311        // Sets a flag when dropped, so we can observe whether the algorithm task was
1312        // cancelled/dropped.
1313        struct DropGuard(Arc<AtomicBool>);
1314        impl Drop for DropGuard {
1315            fn drop(&mut self) {
1316                self.0.store(true, Ordering::SeqCst);
1317            }
1318        }
1319
1320        struct StuckAlgo {
1321            started: mpsc::UnboundedSender<()>,
1322            dropped: Arc<AtomicBool>,
1323        }
1324
1325        #[async_trait]
1326        impl Algorithm for StuckAlgo {
1327            fn name(&self) -> &str {
1328                "stuck"
1329            }
1330
1331            async fn create_run_task(
1332                self: Arc<Self>,
1333                _ctx: Context,
1334                _driver: Driver,
1335                _request: Request,
1336            ) -> Result<Response> {
1337                let _guard = DropGuard(self.dropped.clone());
1338                let _ = self.started.send(());
1339                // Await forever without ever touching the driver.
1340                std::future::pending::<()>().await;
1341                unreachable!()
1342            }
1343        }
1344
1345        let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1346        let dropped = Arc::new(AtomicBool::new(false));
1347        let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1348            started: started_tx,
1349            dropped: dropped.clone(),
1350        });
1351
1352        let stream = algo.run_stream(Context::default(), request(), None);
1353        started_rx
1354            .recv()
1355            .await
1356            .ok_or_else(|| test_error("task never started"))?;
1357        drop(stream);
1358        tokio::time::sleep(Duration::from_millis(100)).await;
1359
1360        assert!(
1361            dropped.load(Ordering::SeqCst),
1362            "algorithm task was NOT cancelled after dropping the stream"
1363        );
1364        Ok(())
1365    }
1366
1367    #[tokio::test]
1368    async fn create_run_task_panic_surfaces_as_a_stream_error() -> Result<()> {
1369        // An algorithm whose task panics must surface an `Err` step to the stream
1370        // consumer, not abort the process from an unobserved detached task.
1371        struct Panicky;
1372
1373        #[async_trait]
1374        impl Algorithm for Panicky {
1375            fn name(&self) -> &str {
1376                "panicky"
1377            }
1378
1379            async fn create_run_task(
1380                self: Arc<Self>,
1381                _ctx: Context,
1382                _driver: Driver,
1383                _request: Request,
1384            ) -> Result<Response> {
1385                panic!("boom");
1386            }
1387        }
1388
1389        let algo: Arc<dyn Algorithm> = Arc::new(Panicky);
1390        let stream = algo.run_stream(Context::default(), request(), None);
1391        tokio::pin!(stream);
1392
1393        let mut saw_error = false;
1394        while let Some(step) = stream.next().await {
1395            match step {
1396                Err(err) => {
1397                    assert!(matches!(err, LibsyError::AlgorithmTask { .. }));
1398                    saw_error = true;
1399                }
1400                Ok(_) => return Err(test_error("expected the panic to surface as an error step")),
1401            }
1402        }
1403
1404        assert!(saw_error, "expected an error step from the panicked task");
1405        Ok(())
1406    }
1407
1408    #[tokio::test]
1409    async fn run_returns_an_error_when_the_algorithm_task_panics() -> Result<()> {
1410        // The panic surfaces as an `Err` step inside `run_stream`; `run` propagates it
1411        // via `?`, so the caller gets an `Err` rather than a hang or a silent panic.
1412        struct Panicky;
1413
1414        #[async_trait]
1415        impl Algorithm for Panicky {
1416            fn name(&self) -> &str {
1417                "panicky"
1418            }
1419
1420            async fn create_run_task(
1421                self: Arc<Self>,
1422                _ctx: Context,
1423                _driver: Driver,
1424                _request: Request,
1425            ) -> Result<Response> {
1426                panic!("boom");
1427            }
1428        }
1429
1430        let algo: Arc<dyn Algorithm> = Arc::new(Panicky);
1431        match algo.run(Context::default(), request()).await {
1432            Ok(_) => Err(test_error(
1433                "expected run to surface the algorithm panic as an error",
1434            )),
1435            Err(err) => {
1436                assert!(matches!(err, LibsyError::AlgorithmTask { .. }));
1437                Ok(())
1438            }
1439        }
1440    }
1441
1442    #[tokio::test]
1443    async fn cancelling_run_cancels_the_algorithm_task() -> Result<()> {
1444        use std::sync::atomic::{AtomicBool, Ordering};
1445        use std::time::Duration;
1446        use tokio::sync::mpsc;
1447
1448        // Sets a flag when dropped, so we can observe whether the algorithm task was
1449        // cancelled once the `run` future driving it is dropped.
1450        struct DropGuard(Arc<AtomicBool>);
1451        impl Drop for DropGuard {
1452            fn drop(&mut self) {
1453                self.0.store(true, Ordering::SeqCst);
1454            }
1455        }
1456
1457        struct StuckAlgo {
1458            started: mpsc::UnboundedSender<()>,
1459            dropped: Arc<AtomicBool>,
1460        }
1461
1462        #[async_trait]
1463        impl Algorithm for StuckAlgo {
1464            fn name(&self) -> &str {
1465                "stuck"
1466            }
1467
1468            async fn create_run_task(
1469                self: Arc<Self>,
1470                _ctx: Context,
1471                _driver: Driver,
1472                _request: Request,
1473            ) -> Result<Response> {
1474                let _guard = DropGuard(self.dropped.clone());
1475                let _ = self.started.send(());
1476                // Hang forever without ever touching the driver, so only cancellation
1477                // (not a dropped step channel) can stop this task.
1478                std::future::pending::<()>().await;
1479                unreachable!()
1480            }
1481        }
1482
1483        let (started_tx, mut started_rx) = mpsc::unbounded_channel();
1484        let dropped = Arc::new(AtomicBool::new(false));
1485        let algo: Arc<dyn Algorithm> = Arc::new(StuckAlgo {
1486            started: started_tx,
1487            dropped: dropped.clone(),
1488        });
1489
1490        // Drive `run` on its own task, wait until the algorithm task is up, then cancel
1491        // `run` — dropping its future (and the `run_stream` stream it holds).
1492        let run_task = tokio::spawn(async move { algo.run(Context::default(), request()).await });
1493        started_rx
1494            .recv()
1495            .await
1496            .ok_or_else(|| test_error("task never started"))?;
1497        run_task.abort();
1498        tokio::time::sleep(Duration::from_millis(100)).await;
1499
1500        assert!(
1501            dropped.load(Ordering::SeqCst),
1502            "algorithm task was NOT cancelled after cancelling run"
1503        );
1504        Ok(())
1505    }
1506
1507    // --- first-wins hedging: `run` must not wait on losing speculative calls -------------
1508
1509    /// The loser: signals it has started serving (so the winner can then win with the
1510    /// loser's serve guaranteed in flight), then finishes late (`Some(delay)`) or never
1511    /// (`None`).
1512    struct LoserClient {
1513        started: Arc<tokio::sync::Notify>,
1514        delay: Option<std::time::Duration>,
1515    }
1516
1517    #[async_trait]
1518    impl RoutedLlmClient for LoserClient {
1519        async fn call(
1520            &self,
1521            _ctx: Context,
1522            _request: Request,
1523            decision: Arc<dyn Decision>,
1524        ) -> std::result::Result<Response, LlmClientError> {
1525            self.started.notify_one();
1526            match self.delay {
1527                Some(delay) => tokio::time::sleep(delay).await,
1528                None => std::future::pending::<()>().await,
1529            }
1530            Ok(Response {
1531                llm_response: LlmResponse::Agg(text_response(
1532                    None,
1533                    decision.selected_model().to_string(),
1534                )),
1535                metadata: None,
1536            })
1537        }
1538    }
1539
1540    /// The winner: waits until the loser's serve has started, then echoes immediately, so
1541    /// the loser's serve is guaranteed in flight when the winner wins.
1542    struct GatedEchoClient {
1543        gate: Arc<tokio::sync::Notify>,
1544    }
1545
1546    #[async_trait]
1547    impl RoutedLlmClient for GatedEchoClient {
1548        async fn call(
1549            &self,
1550            _ctx: Context,
1551            _request: Request,
1552            decision: Arc<dyn Decision>,
1553        ) -> std::result::Result<Response, LlmClientError> {
1554            self.gate.notified().await;
1555            Ok(Response {
1556                llm_response: LlmResponse::Agg(text_response(
1557                    None,
1558                    decision.selected_model().to_string(),
1559                )),
1560                metadata: None,
1561            })
1562        }
1563    }
1564
1565    /// Offloads two targets concurrently and returns the first to resolve, dropping the
1566    /// loser's call (first-wins hedging).
1567    struct Hedge {
1568        winner: LlmTarget,
1569        loser: LlmTarget,
1570    }
1571
1572    #[async_trait]
1573    impl Algorithm for Hedge {
1574        fn name(&self) -> &str {
1575            "hedge"
1576        }
1577
1578        async fn create_run_task(
1579            self: Arc<Self>,
1580            ctx: Context,
1581            driver: Driver,
1582            request: Request,
1583        ) -> Result<Response> {
1584            let dec_w: Arc<dyn Decision> = Arc::new(TestDecision {
1585                model: self.winner.semantic_name.clone(),
1586            });
1587            let dec_l: Arc<dyn Decision> = Arc::new(TestDecision {
1588                model: self.loser.semantic_name.clone(),
1589            });
1590            let win = driver.call_llm_target(ctx.clone(), &self.winner, request.clone(), dec_w);
1591            let lose = driver.call_llm_target(ctx, &self.loser, request, dec_l);
1592            // First to resolve wins; `select!` drops the losing future (and its promise).
1593            tokio::select! {
1594                res = win => res,
1595                res = lose => res,
1596            }
1597        }
1598    }
1599
1600    /// Builds a hedging algo whose winner is gated behind the loser starting, and whose
1601    /// loser finishes after `loser_delay` (or never, when `None`).
1602    fn hedge(loser_delay: Option<std::time::Duration>) -> Arc<dyn Algorithm> {
1603        let started = Arc::new(tokio::sync::Notify::new());
1604        let winner = LlmTarget {
1605            semantic_name: "winner".to_string(),
1606            llm_client: Some(Arc::new(GatedEchoClient {
1607                gate: started.clone(),
1608            })),
1609        };
1610        let loser = LlmTarget {
1611            semantic_name: "loser".to_string(),
1612            llm_client: Some(Arc::new(LoserClient {
1613                started,
1614                delay: loser_delay,
1615            })),
1616        };
1617        Arc::new(Hedge { winner, loser })
1618    }
1619
1620    #[tokio::test]
1621    async fn run_returns_the_winner_without_a_late_loser_overwriting_it() -> Result<()> {
1622        // The loser responds 50ms after the winner has already won. `run` must return the
1623        // winner, not the loser's `respond`-to-a-dropped-receiver error.
1624        let (_trace, response) = hedge(Some(std::time::Duration::from_millis(50)))
1625            .run(Context::default(), request())
1626            .await?;
1627        assert_eq!(
1628            response
1629                .llm_response
1630                .as_agg()
1631                .map(completion_text)
1632                .unwrap_or_default(),
1633            "winner"
1634        );
1635        Ok(())
1636    }
1637
1638    #[tokio::test]
1639    async fn run_returns_the_winner_without_hanging_on_a_pending_loser() -> Result<()> {
1640        // The loser never resolves. `run` must return the winner promptly, not hang
1641        // waiting for the in-flight loser.
1642        let run = hedge(None).run(Context::default(), request());
1643        let (_trace, response) = tokio::time::timeout(std::time::Duration::from_secs(1), run)
1644            .await
1645            .map_err(|error| LibsyError::external("waiting for pending loser", error))??;
1646        assert_eq!(
1647            response
1648                .llm_response
1649                .as_agg()
1650                .map(completion_text)
1651                .unwrap_or_default(),
1652            "winner"
1653        );
1654        Ok(())
1655    }
1656
1657    #[tokio::test]
1658    async fn run_surfaces_a_terminal_error_with_many_calls_in_flight() -> Result<()> {
1659        use std::sync::atomic::{AtomicUsize, Ordering};
1660
1661        // A large fan-out (10 matched the old, now-removed concurrency cap). The terminal
1662        // error must still reach the caller with all of these calls pending.
1663        const N: usize = 10;
1664
1665        // Enters each call; once all N are in flight, signals, then pends forever.
1666        struct EnterThenPend {
1667            started: Arc<AtomicUsize>,
1668            all_started: Arc<tokio::sync::Notify>,
1669            n: usize,
1670        }
1671
1672        #[async_trait]
1673        impl RoutedLlmClient for EnterThenPend {
1674            async fn call(
1675                &self,
1676                _ctx: Context,
1677                _request: Request,
1678                _decision: Arc<dyn Decision>,
1679            ) -> std::result::Result<Response, LlmClientError> {
1680                if self.started.fetch_add(1, Ordering::SeqCst) + 1 == self.n {
1681                    self.all_started.notify_one();
1682                }
1683                std::future::pending::<()>().await;
1684                unreachable!()
1685            }
1686        }
1687
1688        // Fans out N calls, then errors as soon as all N are in flight — exercising a
1689        // terminal failure emitted while the offloaded calls are still pending.
1690        struct FanOutThenError {
1691            target: LlmTarget,
1692            all_started: Arc<tokio::sync::Notify>,
1693            n: usize,
1694        }
1695
1696        #[async_trait]
1697        impl Algorithm for FanOutThenError {
1698            fn name(&self) -> &str {
1699                "fan_out_then_error"
1700            }
1701
1702            async fn create_run_task(
1703                self: Arc<Self>,
1704                ctx: Context,
1705                driver: Driver,
1706                request: Request,
1707            ) -> Result<Response> {
1708                let offloads = futures::future::join_all((0..self.n).map(|i| {
1709                    let decision: Arc<dyn Decision> = Arc::new(TestDecision {
1710                        model: format!("m{i}"),
1711                    });
1712                    driver.call_llm_target(ctx.clone(), &self.target, request.clone(), decision)
1713                }));
1714                tokio::select! {
1715                    _ = offloads => Err(test_error("offloads unexpectedly completed")),
1716                    _ = self.all_started.notified() => {
1717                        Err(test_error("terminal error while calls pending"))
1718                    }
1719                }
1720            }
1721        }
1722
1723        let all_started = Arc::new(tokio::sync::Notify::new());
1724        let target = LlmTarget {
1725            semantic_name: "pending".to_string(),
1726            llm_client: Some(Arc::new(EnterThenPend {
1727                started: Arc::new(AtomicUsize::new(0)),
1728                all_started: all_started.clone(),
1729                n: N,
1730            })),
1731        };
1732        let algo: Arc<dyn Algorithm> = Arc::new(FanOutThenError {
1733            target,
1734            all_started,
1735            n: N,
1736        });
1737
1738        // With the cap gone, `run` keeps polling the stream even with N calls in flight, so
1739        // the terminal error surfaces promptly instead of hanging.
1740        let run = algo.run(Context::default(), request());
1741        let result = tokio::time::timeout(std::time::Duration::from_millis(500), run)
1742            .await
1743            .map_err(|error| {
1744                LibsyError::external("waiting for terminal error with full call cap", error)
1745            })?;
1746        match result {
1747            Ok(_) => Err(test_error("expected the terminal error, got a response")),
1748            Err(err) => {
1749                assert!(
1750                    err.to_string()
1751                        .contains("terminal error while calls pending")
1752                );
1753                Ok(())
1754            }
1755        }
1756    }
1757}