Skip to main content

switchyard_llm_client/
run.rs

1// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Drive a libsy algorithm to completion, making the model calls it offloads.
5//!
6//! [`switchyard_libsy::Algorithm::run_stream`] is the whole libsy API: it yields a stream of
7//! steps and expects its consumer to serve every offloaded model call. [`run()`] is that
8//! consumer — it drives the stream with [`switchyard_libsy::drive`], hands routing-time calls to
9//! a [`RoutedLlmClient`], and serves the terminal routing outcome.
10//!
11//! libsy owns the stream mechanics; what this module adds is per-target request preparation,
12//! ordered candidate fallback, and the `libsy.client_call` span around each candidate. Each
13//! completion candidate exhausts its backend retry budget before fallback advances. Routing
14//! calls stop on the first candidate's failure unless the algorithm enables error recovery.
15//! A timeout stops completion calls.
16//!
17//! A Responses continuation can refer to state through `previous_response_id` or `conversation`.
18//! [`ClientRouter`] sends native Responses state back to its provider; for Chat or Anthropic
19//! targets it retains the canonical history needed to materialize the continuation.
20
21use std::collections::{HashMap, HashSet};
22use std::sync::Arc;
23use std::time::Instant;
24
25use futures::{StreamExt, TryStreamExt, stream};
26use http::StatusCode;
27use parking_lot::Mutex;
28use serde_json::{Value, json};
29use switchyard_libsy::{
30    Algorithm, AnswerSummary, Call, CallDecision, LibsyError, OutcomeMetadata, Result,
31    RoutingOutcome, RuntimeModels, drive,
32};
33use switchyard_protocol::{
34    AggLlmResponse, LlmClientError, LlmResponse, LlmResponseChunk, LlmResponseStream, Message,
35    ModelId, Request, Response, ResponseAccumulator, RoutedDecisionClient, RoutedLlmClient,
36    RoutingFallbackReason, Usage, WireFormat,
37};
38use switchyard_translation::prepare_request_for_target;
39
40use crate::observation::{ModelCallObservation, RunObservation, RunObserver};
41use crate::{metrics, observability};
42
43/// Run one request to completion, serving every offloaded model call with `client`.
44///
45/// Returns the model selected by the algorithm and the final [`Response`]. `observer`, when
46/// present, receives metadata for the routing outcome, each completed routing or answer call,
47/// and the routing overhead.
48///
49/// `clients` resolves each offloaded call to the client for the target the algorithm
50/// selected — an algorithm may route among targets served by different providers, so this is
51/// a per-call lookup, not one client for the whole run. Use
52/// [`ClientRouter::single`](ClientRouter::single) when one client serves every target.
53///
54/// Routing calls are buffered; client failures stop the request unless the call enables recovery.
55/// Once routing completes, non-timeout failures may try the outcome's ordered fallback candidates.
56pub async fn run(
57    algorithm: Arc<dyn Algorithm>,
58    clients: ClientRouter,
59    request: Request,
60    models: Arc<RuntimeModels>,
61    observer: Option<RunObserver>,
62) -> Result<(ModelId, Response)> {
63    let algorithm_name = algorithm.name().to_string();
64    let run_started = Instant::now();
65    let routing_clients = clients.clone();
66    // This says if we have an observer, put Some(..) in routing_observations.
67    // No observer means we don't want any routing_observations.
68    let routing_observations = observer.as_ref().map(|_| Arc::new(Mutex::new(Vec::new())));
69    let outcome = match clients.stored_state_owner(&request) {
70        Some(owner) => Ok(continue_on(owner, &algorithm_name, request)),
71        None => {
72            drive(Arc::clone(&algorithm), request, models, {
73                let routing_observations = routing_observations.clone();
74                move |call| serve(routing_clients.clone(), call, routing_observations.clone())
75            })
76            .await
77        }
78    };
79    let answered_model = outcome
80        .as_ref()
81        .ok()
82        .and_then(|outcome| outcome.response.as_ref())
83        .and_then(Response::served_model);
84    emit_routing_observations(&observer, &routing_observations, answered_model);
85    let outcome = outcome?;
86    let overhead = run_started.elapsed();
87    metrics::record_routing_overhead(&algorithm_name, overhead);
88
89    let selected_model_id = outcome.selected_model_id()?.clone();
90    if let Some(observer) = &observer
91        && let Some(metadata) = outcome.metadata
92    {
93        observer(RunObservation::Outcome(metadata));
94    }
95    let result = if let Some(response) = outcome.response {
96        let result = clients.remember_state_owner(&outcome.request, response);
97        let served_model = result
98            .as_ref()
99            .ok()
100            .and_then(Response::served_model)
101            .unwrap_or(&selected_model_id);
102        let served_model = served_model.clone();
103        metrics::observe_routed_request(&algorithm_name, &served_model, None, result)
104    } else {
105        let observe = |observation| {
106            if let Some(observer) = &observer {
107                observer(RunObservation::AnswerCall(observation));
108            }
109        };
110        call_first_available(
111            &clients,
112            &algorithm_name,
113            &outcome.request,
114            outcome.fallback_request.as_ref(),
115            &outcome.candidate_requests,
116            &outcome.selected_model_ids,
117            &observe,
118        )
119        .await
120        .and_then(|(request, response)| clients.remember_state_owner(request, response))
121    };
122    if let Some(observer) = &observer {
123        observer(RunObservation::RoutingOverhead(overhead));
124    }
125    let mut response = result?;
126    if algorithm.tracks_answers()
127        && let Some(session) = session_id(&outcome.request)
128    {
129        response = report_answer(algorithm, session, response).await;
130    }
131    Ok((selected_model_id, response))
132}
133
134fn session_id(request: &Request) -> Option<String> {
135    let session = request.metadata.as_ref()?.session_id.as_deref()?;
136    (!session.is_empty()).then(|| session.to_string())
137}
138
139/// Reports the served answer to the algorithm; a stream reports it before its final chunk
140/// so the client cannot send its next request first.
141async fn report_answer(
142    algorithm: Arc<dyn Algorithm>,
143    session: String,
144    mut response: Response,
145) -> Response {
146    let stream = match response.llm_response {
147        LlmResponse::Agg(aggregate) => {
148            algorithm
149                .observe_answer(&session, AnswerSummary::of(&aggregate))
150                .await;
151            response.llm_response = LlmResponse::Agg(aggregate);
152            return response;
153        }
154        LlmResponse::Stream(stream) => stream,
155    };
156    algorithm
157        .observe_answer(&session, AnswerSummary { final_reply: false })
158        .await;
159    let report = (algorithm, session, Some(ResponseAccumulator::new()));
160    response.llm_response = LlmResponse::Stream(Box::pin(stream::unfold(
161        (stream, report),
162        |(mut stream, (algorithm, session, mut accumulator))| async move {
163            let item = stream.next().await?;
164            let failed = match &item {
165                Err(_) => true,
166                Ok(event) => event
167                    .normalized()
168                    .iter()
169                    .any(|chunk| matches!(chunk, LlmResponseChunk::StreamError { .. })),
170            };
171            if failed {
172                accumulator = None;
173                algorithm
174                    .observe_answer(&session, AnswerSummary { final_reply: false })
175                    .await;
176            }
177            let mut stopped = false;
178            if let (Ok(event), Some(answer)) = (&item, accumulator.as_mut()) {
179                for chunk in event.normalized() {
180                    stopped |= matches!(chunk, LlmResponseChunk::MessageStop { .. });
181                    answer.push(chunk.clone());
182                }
183            }
184            if stopped && let Some(answer) = accumulator.take() {
185                let answer = AnswerSummary::of(&answer.finish());
186                algorithm.observe_answer(&session, answer).await;
187            }
188            Some((item, (stream, (algorithm, session, accumulator))))
189        },
190    )));
191    response
192}
193
194/// Run an algorithm to a routing decision without serving its terminal completion.
195///
196/// Routing-time calls are served normally. The returned request is prepared only for the selected
197/// target; use [`run`] to execute selected and fallback candidates.
198pub async fn decide(
199    algorithm: Arc<dyn Algorithm>,
200    clients: ClientRouter,
201    request: Request,
202    models: Arc<RuntimeModels>,
203) -> Result<RoutingOutcome> {
204    let routing_clients = clients.clone();
205    let mut outcome = match clients.stored_state_owner(&request) {
206        Some(owner) => continue_on(owner, algorithm.name(), request),
207        None => {
208            drive(algorithm, request, models, move |call| {
209                serve(routing_clients.clone(), call, None)
210            })
211            .await?
212        }
213    };
214    let selected_model_id = outcome.selected_model_id()?.clone();
215    outcome.request = clients.prepare_completion_request(outcome.request, &selected_model_id);
216    outcome.response = outcome
217        .response
218        .map(|response| clients.remember_state_owner(&outcome.request, response))
219        .transpose()?;
220    Ok(outcome)
221}
222
223#[tracing::instrument(
224    name = "libsy.decision_client_call",
225    skip_all,
226    fields(algorithm = %call.algorithm, selected_model = %call.model, outcome = tracing::field::Empty),
227)]
228async fn serve_decision(
229    clients: &ClientRouter,
230    call: CallDecision,
231    observations: &Option<Arc<Mutex<Vec<RunObservation>>>>,
232) -> Result<()> {
233    let client = clients.route_decision(&call.model);
234    let model = call.model.clone();
235    let request = call.request.clone();
236    call.respond(async {
237        let started = Instant::now();
238        let result = async { client?.call(request).await }.await;
239        tracing::Span::current().record("outcome", if result.is_ok() { "ok" } else { "error" });
240        if let Some(observations) = observations {
241            observations
242                .lock()
243                .push(RunObservation::DecisionCall(ModelCallObservation {
244                    selected_model: model.clone(),
245                    is_success: result.is_ok(),
246                    duration: started.elapsed(),
247                    usage: result.as_ref().ok().map(|response| response.usage.clone()),
248                }));
249        }
250        result.map_err(|error| LibsyError::client_call(model, error))
251    })
252    .await
253}
254
255/// Emits completed routing calls after the outcome reveals whether one response became the answer.
256fn emit_routing_observations(
257    observer: &Option<RunObserver>,
258    observations: &Option<Arc<Mutex<Vec<RunObservation>>>>,
259    answered_model: Option<&ModelId>,
260) {
261    let (Some(observer), Some(observations)) = (observer, observations) else {
262        return;
263    };
264    let mut answer_observed = false;
265    for observation in observations.lock().drain(..) {
266        observer(match observation {
267            RunObservation::LlmCall(call)
268                if !answer_observed && answered_model == Some(&call.selected_model) =>
269            {
270                answer_observed = true;
271                RunObservation::AnswerCall(call)
272            }
273            observation => observation,
274        });
275    }
276}
277
278/// Serve one offloaded call and fulfill its promise.
279///
280/// LLM failures stop the run unless the call enables recovery. Decision results,
281/// including client failures, go back to the algorithm for its routing policy.
282async fn serve(
283    clients: ClientRouter,
284    call: Call,
285    observations: Option<Arc<Mutex<Vec<RunObservation>>>>,
286) -> Result<()> {
287    let call = match call {
288        Call::Model(call) => *call,
289        Call::Decision(call) => return serve_decision(&clients, *call, &observations).await,
290    };
291    let observe = |observation| {
292        if let Some(observations) = &observations {
293            observations
294                .lock()
295                .push(RunObservation::LlmCall(observation));
296        }
297    };
298    let target = call.models.first().cloned().ok_or(LibsyError::NoTargets)?;
299    let request = clients.prepare_routing_request(call.request.clone(), &target);
300    let algorithm = call.algorithm.clone();
301    let candidates = call.models.len();
302    call.respond(call_one(
303        &clients,
304        &target,
305        request,
306        &algorithm,
307        &observe,
308        0,
309        candidates,
310        CallPhase::Routing,
311    ))
312    .await
313}
314
315/// Try candidates in order until one succeeds or a failure stops fallback.
316async fn call_first_available<'a>(
317    clients: &ClientRouter,
318    algorithm: &str,
319    request: &'a Request,
320    fallback_request: Option<&'a Request>,
321    candidate_requests: &'a HashMap<ModelId, Request>,
322    models: &[ModelId],
323    observe: &(dyn Fn(ModelCallObservation) + Send + Sync),
324) -> Result<(&'a Request, Response)> {
325    for (index, target) in models.iter().enumerate() {
326        let request = candidate_requests
327            .get(target)
328            .unwrap_or(match fallback_request {
329                Some(fallback) if index > 0 => fallback,
330                _ => request,
331            });
332        let prepared = clients.prepare_completion_request(request.clone(), target);
333        match call_one(
334            clients,
335            target,
336            prepared,
337            algorithm,
338            observe,
339            index,
340            models.len(),
341            CallPhase::Completion,
342        )
343        .await
344        {
345            Ok(response) => return Ok((request, response)),
346            Err(error) if index + 1 == models.len() => return Err(error),
347            Err(error)
348                if clients.rate_limit_fallback_disabled.contains(target)
349                    && matches!(&error, LibsyError::ClientCall {
350                        source: LlmClientError::UpstreamHttp { status, .. }, ..
351                    } if *status == StatusCode::TOO_MANY_REQUESTS) =>
352            {
353                return Err(error);
354            }
355            Err(error) => match fallback_reason(&error, fallback_request.is_none()) {
356                Some(reason) => tracing::info!(
357                    from = %target,
358                    to = %models[index + 1],
359                    reason = reason.as_str(),
360                    "model call failed; trying next candidate"
361                ),
362                None => return Err(error),
363            },
364        }
365    }
366    Err(LibsyError::NoTargets)
367}
368
369/// The stage of the Switchyard run in which a model call was issued.
370///
371/// A routing-phase call may still supply the final answer. This is deliberately
372/// distinct from the `routing`/`answer` role assigned after the outcome is known.
373#[derive(Clone, Copy)]
374enum CallPhase {
375    Routing,
376    Completion,
377}
378
379impl CallPhase {
380    const fn as_str(self) -> &'static str {
381        match self {
382            Self::Routing => "routing",
383            Self::Completion => "completion",
384        }
385    }
386}
387
388/// Call one candidate model and record its observation and span.
389#[allow(clippy::too_many_arguments)]
390#[tracing::instrument(
391    target = "libsy",
392    name = "libsy.client_call",
393    skip_all,
394    fields(
395        algorithm = algorithm,
396        switchyard.algorithm = algorithm,
397        switchyard.candidate = index + 1,
398        switchyard.candidate_count = count,
399        switchyard.call_phase = phase.as_str(),
400        selected_model = %model_id,
401        otel.kind = "client",
402        otel.name = %format_args!("chat {model_id}"),
403        openinference.span.kind = "LLM",
404        gen_ai.operation.name = "chat",
405        gen_ai.request.model = %model_id,
406        gen_ai.request.stream = tracing::field::Empty,
407        gen_ai.request.temperature = tracing::field::Empty,
408        gen_ai.request.top_p = tracing::field::Empty,
409        gen_ai.request.top_k = tracing::field::Empty,
410        gen_ai.request.max_tokens = tracing::field::Empty,
411        gen_ai.request.reasoning.level = tracing::field::Empty,
412        gen_ai.output.type = tracing::field::Empty,
413        gen_ai.conversation.id = tracing::field::Empty,
414        server.address = tracing::field::Empty,
415        server.port = tracing::field::Empty,
416        gen_ai.response.id = tracing::field::Empty,
417        gen_ai.response.model = tracing::field::Empty,
418        gen_ai.usage.input_tokens = tracing::field::Empty,
419        gen_ai.usage.output_tokens = tracing::field::Empty,
420        gen_ai.usage.cache_read.input_tokens = tracing::field::Empty,
421        gen_ai.usage.cache_creation.input_tokens = tracing::field::Empty,
422        gen_ai.usage.reasoning.output_tokens = tracing::field::Empty,
423        outcome = tracing::field::Empty,
424        otel.status_code = tracing::field::Empty,
425        error.type = tracing::field::Empty,
426        error = tracing::field::Empty,
427    )
428)]
429async fn call_one(
430    clients: &ClientRouter,
431    model_id: &ModelId,
432    request: Request,
433    algorithm: &str,
434    observe: &(dyn Fn(ModelCallObservation) + Send + Sync),
435    // index is for span log
436    index: usize,
437    // count is for span log
438    count: usize,
439    phase: CallPhase,
440) -> Result<Response> {
441    let is_routing = matches!(phase, CallPhase::Routing);
442    let span = tracing::Span::current();
443    observability::record_gen_ai_request(&span, &request.llm_request);
444    if let Some(session_id) = request
445        .metadata
446        .as_ref()
447        .and_then(|metadata| metadata.session_id.as_deref())
448    {
449        span.record("gen_ai.conversation.id", session_id);
450    }
451    // Resolved before the clock starts: picking the client is Switchyard's work, not
452    // the provider's, so it belongs in the routing overhead.
453    let client = clients.route(model_id);
454    let started = Instant::now();
455    let mut routing_usage = None;
456    let result = async {
457        let mut response = client?.call(request).await?;
458        if is_routing && let LlmResponse::Stream(chunks) = response.llm_response {
459            let (chunks, usage) = buffer_routing_stream(chunks).await?;
460            routing_usage = usage;
461            response.llm_response = LlmResponse::Stream(chunks);
462        }
463        Ok(response)
464    }
465    .await
466    .map_err(|source| LibsyError::client_call(model_id.clone(), source));
467    let duration = started.elapsed();
468
469    let result = result.map(|mut response| {
470        response.set_served_model(model_id);
471        response
472    });
473    let result = observability::observe_client_call(result);
474    let result = if is_routing {
475        result
476    } else {
477        metrics::observe_routed_request(algorithm, model_id, Some(duration), result)
478    };
479    observe(ModelCallObservation {
480        selected_model: model_id.clone(),
481        is_success: result.is_ok(),
482        duration,
483        usage: result
484            .as_ref()
485            .ok()
486            .and_then(|response| response.llm_response.as_agg())
487            .map(|response| response.usage.clone())
488            .or(routing_usage),
489    });
490    result
491}
492
493// Routing logs need the final usage, but replay must keep signed provider events intact.
494async fn buffer_routing_stream(
495    mut chunks: LlmResponseStream,
496) -> std::result::Result<(LlmResponseStream, Option<Usage>), LlmClientError> {
497    let mut events = Vec::new();
498    let mut usage = None;
499    while let Some(event) = chunks.try_next().await? {
500        for chunk in event.normalized() {
501            match chunk {
502                LlmResponseChunk::Usage(latest) => usage = Some(latest.clone()),
503                LlmResponseChunk::DecodeError { message } => {
504                    return Err(LlmClientError::ResponseTranslation(message.clone()));
505                }
506                LlmResponseChunk::StreamError { message } => {
507                    return Err(LlmClientError::UpstreamHttp {
508                        status: StatusCode::BAD_GATEWAY,
509                        body: message.clone(),
510                    });
511                }
512                _ => {}
513            }
514        }
515        events.push(event);
516    }
517    Ok((stream::iter(events.into_iter().map(Ok)).boxed(), usage))
518}
519
520/// Whether a failed candidate is worth routing around.
521fn fallback_reason(
522    error: &LibsyError,
523    allow_policy_fallback: bool,
524) -> Option<RoutingFallbackReason> {
525    let LibsyError::ClientCall { source, .. } = error else {
526        return None;
527    };
528    match source {
529        LlmClientError::ContextWindowExceeded { .. } => Some(RoutingFallbackReason::ContextWindow),
530        LlmClientError::Transport { .. } | LlmClientError::TemporarilyUnavailable => {
531            Some(RoutingFallbackReason::Unavailable)
532        }
533        LlmClientError::UpstreamHttp { status, body } => {
534            let policy_denial = serde_json::from_str::<Value>(body).is_ok_and(|value| {
535                value["error"]["code"].as_str() == Some("content_policy_violation")
536            });
537            if policy_denial && !allow_policy_fallback {
538                return None;
539            }
540            ((*status == StatusCode::BAD_REQUEST && policy_denial)
541                || matches!(
542                    *status,
543                    StatusCode::FORBIDDEN
544                        | StatusCode::REQUEST_TIMEOUT
545                        | StatusCode::TOO_MANY_REQUESTS
546                )
547                || status.is_server_error())
548            .then_some(RoutingFallbackReason::Unavailable)
549        }
550        _ => None,
551    }
552}
553
554/// Route to `owner` without fallbacks and record why the algorithm did not run.
555fn continue_on(owner: StateOwner, algorithm: &str, mut request: Request) -> RoutingOutcome {
556    tracing::debug!(
557        target = %owner.model,
558        trigger = owner.trigger,
559        "routing Responses continuation to the model that holds its state"
560    );
561    if let Some(history) = owner.history {
562        let mut messages = Vec::with_capacity(history.len + request.llm_request.messages.len());
563        history.extend(&mut messages);
564        messages.append(&mut request.llm_request.messages);
565        request.llm_request.messages = messages;
566    }
567    let mut outcome = RoutingOutcome::route_to(owner.model, Vec::new(), request);
568    outcome.metadata = Some(OutcomeMetadata::new(
569        algorithm.to_string(),
570        Some(json!({"source": "stored_state", "trigger": owner.trigger})),
571    ));
572    outcome
573}
574
575/// The model that served the stored state and the request field that refers to it.
576struct StateOwner {
577    model: ModelId,
578    trigger: &'static str,
579    history: Option<Arc<MessageHistory>>,
580}
581
582struct MessageHistory {
583    parent: Option<Arc<Self>>,
584    segment: Arc<[Message]>,
585    len: usize,
586}
587
588impl MessageHistory {
589    fn new(parent: Option<Arc<Self>>, segment: Arc<[Message]>) -> Self {
590        let len = parent.as_ref().map_or(0, |history| history.len) + segment.len();
591        Self {
592            parent,
593            segment,
594            len,
595        }
596    }
597
598    fn extend(&self, messages: &mut Vec<Message>) {
599        let mut segments = Vec::new();
600        let mut history = Some(self);
601        while let Some(node) = history {
602            segments.push(node.segment.as_ref());
603            history = node.parent.as_deref();
604        }
605        for segment in segments.into_iter().rev() {
606            messages.extend_from_slice(segment);
607        }
608    }
609}
610
611#[derive(Clone)]
612struct StoredState {
613    model: ModelId,
614    /// `None` means the provider owns the state and only routing must be pinned.
615    history: Option<Arc<MessageHistory>>,
616}
617
618#[derive(Clone)]
619struct CanonicalInput {
620    parent: Option<Arc<MessageHistory>>,
621    messages: Arc<[Message]>,
622}
623
624/// Stored Responses IDs and conversation IDs, with their provider or canonical state.
625///
626/// Keep earlier response IDs because a client can continue from any stored response.
627/// Reject new IDs at capacity rather than forgetting where an earlier response is stored.
628#[derive(Default)]
629struct StateOwners {
630    by_id: HashMap<String, StoredState>,
631}
632
633const MAX_STATE_OWNERS: usize = 65_536;
634
635impl StateOwners {
636    fn owner(&self, id: &str) -> Option<&StoredState> {
637        self.by_id.get(id)
638    }
639
640    fn remember(
641        &mut self,
642        response_id: Option<&str>,
643        conversation_id: Option<&str>,
644        model: &ModelId,
645        history: Option<Arc<MessageHistory>>,
646    ) -> std::result::Result<(), LlmClientError> {
647        let materialized = history.is_some();
648        let ids = [
649            response_id,
650            conversation_id.filter(|id| Some(*id) != response_id),
651        ];
652        let mut new_ids = 0;
653        for id in ids.into_iter().flatten() {
654            match self.by_id.get(id) {
655                Some(owner) if owner.model != *model && !materialized => {
656                    return Err(LlmClientError::ResponseStateConflict);
657                }
658                None => new_ids += 1,
659                _ => {}
660            }
661        }
662        if self.by_id.len() + new_ids > MAX_STATE_OWNERS {
663            return Err(LlmClientError::ResponseStateLimitExceeded {
664                limit: MAX_STATE_OWNERS,
665            });
666        }
667        let state = StoredState {
668            model: model.clone(),
669            history,
670        };
671        if let Some(id) = response_id {
672            if materialized {
673                self.by_id.insert(id.to_owned(), state.clone());
674            } else {
675                self.by_id
676                    .entry(id.to_owned())
677                    .or_insert_with(|| state.clone());
678            }
679        }
680        if let Some(id) = conversation_id {
681            self.by_id.entry(id.to_owned()).or_insert(state);
682        }
683        Ok(())
684    }
685}
686
687/// Read the Responses `conversation` ID from a string or an object.
688fn conversation_id(fields: &serde_json::Map<String, Value>) -> Option<&str> {
689    let conversation = fields.get("conversation")?;
690    conversation
691        .as_str()
692        .or_else(|| conversation.get("id").and_then(Value::as_str))
693}
694
695/// Resolves a routed call's selected model to the client that serves it.
696///
697/// An algorithm routes among named targets; which provider each target lives on is the
698/// host's concern, and two targets in one run may sit on different providers. A router owns
699/// that mapping and resolves the client for each kind of call.
700///
701/// Cloning is cheap — the mapping is shared, so one router can serve every request.
702#[derive(Clone)]
703pub struct ClientRouter {
704    inner: Arc<ClientRouting>,
705    rate_limit_fallback_disabled: Arc<HashSet<ModelId>>,
706}
707
708struct ClientRouting {
709    routing: Routing,
710    decision_clients: HashMap<ModelId, Arc<dyn RoutedDecisionClient>>,
711    target_prompts: HashMap<ModelId, String>,
712    routing_answer_target: Option<ModelId>,
713    /// Provider-owned routing pins and materialized cross-format history.
714    state_owners: Mutex<StateOwners>,
715    /// Native Responses state only needs a routing pin when answer targets span clients.
716    track_provider_state: bool,
717}
718
719enum Routing {
720    /// One client serves every model.
721    Single(Arc<dyn RoutedLlmClient>),
722    /// Each model is served by the client configured for it.
723    ByModel(HashMap<ModelId, Arc<dyn RoutedLlmClient>>),
724}
725
726impl ClientRouter {
727    /// Build a router over `model name -> client`, for targets spread across providers.
728    pub fn new(by_model: HashMap<ModelId, Arc<dyn RoutedLlmClient>>) -> Self {
729        Self::new_with_target_prompts(by_model, HashMap::new(), None)
730    }
731
732    /// Build a router with target prompts used by [`run`] and [`decide`].
733    ///
734    /// `routing_answer_target` identifies a routing-time call whose response may become the
735    /// answer. Pass `None` when routing only selects a later completion target. Prompt keys and
736    /// `routing_answer_target` use the resolved model IDs stored in `by_model`.
737    /// This constructor treats every model in `by_model` as a possible completion target.
738    /// Use [`Self::new_with_completion_targets`] to exclude classifier-only models from the
739    /// comparison of completion clients.
740    pub fn new_with_target_prompts(
741        by_model: HashMap<ModelId, Arc<dyn RoutedLlmClient>>,
742        target_prompts: HashMap<ModelId, String>,
743        routing_answer_target: Option<ModelId>,
744    ) -> Self {
745        let completion_targets = by_model.keys().cloned().collect::<Vec<_>>();
746        Self::new_with_completion_targets(
747            by_model,
748            target_prompts,
749            routing_answer_target,
750            &completion_targets,
751        )
752    }
753
754    /// Build a router with an explicit list of models that can answer requests.
755    ///
756    /// `by_model` contains callable LLMs, including LLM classifiers. Only the model IDs in
757    /// `completion_targets` determine whether native Responses state needs a routing pin.
758    /// Cross-format state is recorded lazily when a Responses request uses a Chat or Anthropic
759    /// target. `target_prompts` and `routing_answer_target` have the same meaning as in
760    /// [`Self::new_with_target_prompts`].
761    pub fn new_with_completion_targets(
762        by_model: HashMap<ModelId, Arc<dyn RoutedLlmClient>>,
763        target_prompts: HashMap<ModelId, String>,
764        routing_answer_target: Option<ModelId>,
765        completion_targets: &[ModelId],
766    ) -> Self {
767        Self::new_with_decision_clients(
768            by_model,
769            HashMap::new(),
770            target_prompts,
771            routing_answer_target,
772            completion_targets,
773        )
774    }
775
776    /// Build a router with separate LLM and decision mappings, shared across clones.
777    /// LLM prompt and completion settings follow [`Self::new_with_completion_targets`].
778    pub fn new_with_decision_clients(
779        by_model: HashMap<ModelId, Arc<dyn RoutedLlmClient>>,
780        decision_clients: HashMap<ModelId, Arc<dyn RoutedDecisionClient>>,
781        target_prompts: HashMap<ModelId, String>,
782        routing_answer_target: Option<ModelId>,
783        completion_targets: &[ModelId],
784    ) -> Self {
785        let mut clients = completion_targets
786            .iter()
787            .filter_map(|model| by_model.get(model));
788        let spans_providers = clients
789            .next()
790            .is_some_and(|first| clients.any(|client| !Arc::ptr_eq(first, client)));
791        Self {
792            rate_limit_fallback_disabled: Arc::default(),
793            inner: Arc::new(ClientRouting {
794                routing: Routing::ByModel(by_model),
795                decision_clients,
796                target_prompts,
797                routing_answer_target,
798                state_owners: Mutex::default(),
799                track_provider_state: spans_providers,
800            }),
801        }
802    }
803
804    /// A router that serves every model with one client — the single-provider case.
805    ///
806    /// [`TranslatingLlmClient`](crate::TranslatingLlmClient) already maps model names to
807    /// backends internally and rejects ones it does not know, so enumerating them here would
808    /// only duplicate that.
809    pub fn single(client: Arc<dyn RoutedLlmClient>) -> Self {
810        Self::single_with_decision_clients(client, HashMap::new())
811    }
812
813    /// Use one client for all LLM targets and explicit clients for decision targets.
814    pub fn single_with_decision_clients(
815        client: Arc<dyn RoutedLlmClient>,
816        decision_clients: HashMap<ModelId, Arc<dyn RoutedDecisionClient>>,
817    ) -> Self {
818        Self {
819            rate_limit_fallback_disabled: Arc::default(),
820            inner: Arc::new(ClientRouting {
821                routing: Routing::Single(client),
822                decision_clients,
823                target_prompts: HashMap::new(),
824                routing_answer_target: None,
825                state_owners: Mutex::default(),
826                track_provider_state: false,
827            }),
828        }
829    }
830
831    /// Stop completion fallback on HTTP 429 for the given models.
832    ///
833    /// For translating clients, also use
834    /// [`TranslatingLlmClient::without_rate_limit_cooldown`](crate::TranslatingLlmClient::without_rate_limit_cooldown)
835    /// so later calls are not skipped with a generic cooldown error.
836    pub fn without_rate_limit_fallback(mut self, models: HashSet<ModelId>) -> Self {
837        self.rate_limit_fallback_disabled = Arc::new(models);
838        self
839    }
840
841    /// The client that serves `model`.
842    ///
843    /// Errors with [`LlmClientError::Configuration`] when the router maps models and has no
844    /// entry for this one, rather than silently sending the call to another provider.
845    pub fn route(
846        &self,
847        model: &ModelId,
848    ) -> std::result::Result<&Arc<dyn RoutedLlmClient>, LlmClientError> {
849        match &self.inner.routing {
850            Routing::Single(client) => Ok(client),
851            Routing::ByModel(by_model) => {
852                by_model
853                    .get(model)
854                    .ok_or_else(|| LlmClientError::Configuration {
855                        message: format!("no llm client is configured for model {model:?}"),
856                    })
857            }
858        }
859    }
860
861    /// Resolve a decision target without falling back to an LLM client.
862    fn route_decision(
863        &self,
864        model: &ModelId,
865    ) -> std::result::Result<&Arc<dyn RoutedDecisionClient>, LlmClientError> {
866        self.inner
867            .decision_clients
868            .get(model)
869            .ok_or_else(|| LlmClientError::Configuration {
870                message: format!("no decision client is configured for model {model:?}"),
871            })
872    }
873
874    /// Return the recorded model for the requested response or conversation ID.
875    fn stored_state_owner(&self, request: &Request) -> Option<StateOwner> {
876        let fields = &request.llm_request.extensions.fields;
877        let (trigger, id) = match fields.get("previous_response_id").and_then(Value::as_str) {
878            Some(id) => ("previous_response_id", id),
879            None => ("conversation", conversation_id(fields)?),
880        };
881        let state = self.inner.state_owners.lock().owner(id)?.clone();
882        Some(StateOwner {
883            model: state.model,
884            trigger,
885            history: state.history,
886        })
887    }
888
889    fn canonical_input(&self, request: &Request) -> CanonicalInput {
890        let parent = request
891            .llm_request
892            .extensions
893            .fields
894            .get("previous_response_id")
895            .and_then(Value::as_str)
896            .and_then(|id| self.inner.state_owners.lock().owner(id)?.history.clone());
897        let parent_len = parent.as_ref().map_or(0, |history| history.len);
898        let (parent, messages) = match request.llm_request.messages.get(parent_len..) {
899            Some(messages) => (parent, messages.to_vec()),
900            None => (None, request.llm_request.messages.clone()),
901        };
902        CanonicalInput {
903            parent,
904            messages: Arc::from(messages),
905        }
906    }
907
908    /// Record provider-owned Responses state or canonical history for a cross-format response.
909    fn remember_state_owner(&self, request: &Request, mut response: Response) -> Result<Response> {
910        let Some(model) = response.served_model().cloned() else {
911            return Ok(response);
912        };
913        let fields = &request.llm_request.extensions.fields;
914        let store = fields.get("store").and_then(Value::as_bool) != Some(false);
915        let conversation = conversation_id(fields).map(str::to_owned);
916        let responses_format = WireFormat::OpenAiResponses.into();
917        let responses_request = request
918            .llm_request
919            .preservation
920            .requests
921            .contains_key(&responses_format)
922            || fields.contains_key("previous_response_id")
923            || fields.contains_key("conversation");
924        if !responses_request && !self.inner.track_provider_state {
925            return Ok(response);
926        }
927        let canonical_input = responses_request.then(|| self.canonical_input(request));
928        response.llm_response = match response.llm_response {
929            LlmResponse::Agg(agg) => {
930                if let Some(body) = agg.preservation.responses.get(&responses_format) {
931                    if self.inner.track_provider_state {
932                        self.remember_response(body, &model, store, conversation.as_deref())
933                            .map_err(|error| LibsyError::client_call(model.clone(), error))?;
934                    }
935                } else if let Some(input) = &canonical_input {
936                    self.remember_canonical_response(&agg, &model, store, input)
937                        .map_err(|error| LibsyError::client_call(model.clone(), error))?;
938                }
939                LlmResponse::Agg(agg)
940            }
941            LlmResponse::Stream(stream) => {
942                let router = self.clone();
943                LlmResponse::Stream(Box::pin(futures_util::stream::try_unfold(
944                    (
945                        stream,
946                        router,
947                        model,
948                        conversation,
949                        canonical_input,
950                        ResponseAccumulator::new(),
951                        false,
952                        true,
953                    ),
954                    move |(
955                        mut stream,
956                        router,
957                        model,
958                        conversation,
959                        canonical_input,
960                        mut accumulator,
961                        mut native_responses,
962                        mut valid,
963                    )| async move {
964                        let Some(event) = stream.next().await else {
965                            if let Some(input) = &canonical_input
966                                && !native_responses
967                                && valid
968                            {
969                                router.remember_canonical_response(
970                                    &accumulator.finish(),
971                                    &model,
972                                    store,
973                                    input,
974                                )?;
975                            }
976                            return Ok(None);
977                        };
978                        let event = event?;
979                        if let Some(preserved) = event.preservation()
980                            && preserved.source().as_str() == WireFormat::OpenAiResponses.as_str()
981                        {
982                            native_responses = true;
983                            if router.inner.track_provider_state
984                                && let Some(body) = preserved.raw().get("response")
985                            {
986                                router.remember_response(
987                                    body,
988                                    &model,
989                                    store,
990                                    conversation.as_deref(),
991                                )?;
992                            }
993                        }
994                        if responses_request {
995                            for chunk in event.normalized() {
996                                if matches!(
997                                    chunk,
998                                    LlmResponseChunk::DecodeError { .. }
999                                        | LlmResponseChunk::StreamError { .. }
1000                                ) {
1001                                    valid = false;
1002                                } else {
1003                                    accumulator.push(chunk.clone());
1004                                }
1005                            }
1006                        }
1007                        Ok(Some((
1008                            event,
1009                            (
1010                                stream,
1011                                router,
1012                                model,
1013                                conversation,
1014                                canonical_input,
1015                                accumulator,
1016                                native_responses,
1017                                valid,
1018                            ),
1019                        )))
1020                    },
1021                )))
1022            }
1023        };
1024        Ok(response)
1025    }
1026
1027    /// Check both IDs from a response or stream event before recording either. The response's
1028    /// `store` value overrides the request value; `false` skips only the response ID.
1029    fn remember_response(
1030        &self,
1031        body: &Value,
1032        model: &ModelId,
1033        store: bool,
1034        conversation: Option<&str>,
1035    ) -> std::result::Result<(), LlmClientError> {
1036        let mut owners = self.inner.state_owners.lock();
1037        let response_id = body
1038            .get("id")
1039            .and_then(Value::as_str)
1040            .filter(|_| body.get("store").and_then(Value::as_bool).unwrap_or(store));
1041        let conversation = body.as_object().and_then(conversation_id).or(conversation);
1042        if let Err(error) = owners.remember(response_id, conversation, model, None) {
1043            if matches!(error, LlmClientError::ResponseStateLimitExceeded { .. }) {
1044                tracing::warn!(%error, "Responses state owner capacity reached; state was not retained");
1045            } else {
1046                tracing::error!(%error, "could not record Responses state owner");
1047                return Err(error);
1048            }
1049        }
1050        Ok(())
1051    }
1052
1053    fn remember_canonical_response(
1054        &self,
1055        response: &AggLlmResponse,
1056        model: &ModelId,
1057        store: bool,
1058        input: &CanonicalInput,
1059    ) -> std::result::Result<(), LlmClientError> {
1060        let response_id = response.id.as_deref().filter(|_| store);
1061        if response_id.is_none() {
1062            return Ok(());
1063        }
1064        let mut segment = input.messages.to_vec();
1065        segment.extend(response.outputs.iter().map(|output| Message {
1066            role: output.role,
1067            content: output.content.clone(),
1068        }));
1069        let history = Arc::new(MessageHistory::new(
1070            input.parent.clone(),
1071            Arc::from(segment),
1072        ));
1073        let result =
1074            self.inner
1075                .state_owners
1076                .lock()
1077                .remember(response_id, None, model, Some(history));
1078        if let Err(error) = result {
1079            if matches!(error, LlmClientError::ResponseStateLimitExceeded { .. }) {
1080                tracing::warn!(%error, "cross-format Responses state capacity reached; history was not retained");
1081                return Ok(());
1082            }
1083            tracing::error!(%error, "could not record cross-format Responses state");
1084            return Err(error);
1085        }
1086        Ok(())
1087    }
1088
1089    /// Prepare a completion candidate with its configured target prompt.
1090    fn prepare_completion_request(&self, mut request: Request, target: &ModelId) -> Request {
1091        let prompt = self.inner.target_prompts.get(target).map(String::as_str);
1092        prepare_request_for_target(&mut request.llm_request, target, prompt);
1093        request
1094    }
1095
1096    /// Prepare a routing call, adding a target prompt only when it generates a candidate answer.
1097    fn prepare_routing_request(&self, mut request: Request, target: &ModelId) -> Request {
1098        let prompt = if self.inner.routing_answer_target.as_ref() == Some(target) {
1099            self.inner.target_prompts.get(target).map(String::as_str)
1100        } else {
1101            None
1102        };
1103        prepare_request_for_target(&mut request.llm_request, target, prompt);
1104        request
1105    }
1106}
1107
1108impl FromIterator<(ModelId, Arc<dyn RoutedLlmClient>)> for ClientRouter {
1109    fn from_iter<I: IntoIterator<Item = (ModelId, Arc<dyn RoutedLlmClient>)>>(iter: I) -> Self {
1110        Self::new(iter.into_iter().collect())
1111    }
1112}
1113
1114#[cfg(test)]
1115mod tests {
1116    use std::collections::{BTreeMap, BTreeSet};
1117
1118    use super::*;
1119    use async_trait::async_trait;
1120    use futures::StreamExt;
1121    use http::StatusCode;
1122    use switchyard_libsy::{Driver, RoutingOutcome};
1123    use switchyard_protocol::{
1124        ContentBlock, LlmResponse, LlmResponseChunk, LlmResponseStreamEvent, Message, Role,
1125        ToolResult, completion_text, text_request, text_response,
1126    };
1127    use wiremock::matchers::method;
1128    use wiremock::{Mock, MockServer, ResponseTemplate};
1129
1130    use crate::{Backend, HttpBackendConfig, ModelConfig, TranslatingLlmClient};
1131    use switchyard_protocol::Category;
1132
1133    struct CandidateAlgorithm {}
1134
1135    struct AnsweredAlgorithm {
1136        model: ModelId,
1137    }
1138
1139    #[async_trait]
1140    impl Algorithm for CandidateAlgorithm {
1141        fn name(&self) -> &str {
1142            "candidate_test"
1143        }
1144
1145        async fn route(
1146            self: Arc<Self>,
1147            driver: Driver,
1148            request: Request,
1149        ) -> Result<RoutingOutcome> {
1150            let models = driver.models_for(&Category::Any);
1151            let selected_model = models.first().cloned().ok_or(LibsyError::NoTargets)?;
1152            Ok(RoutingOutcome::route_to(
1153                selected_model,
1154                models.iter().skip(1).cloned().collect(),
1155                request,
1156            ))
1157        }
1158    }
1159
1160    #[async_trait]
1161    impl Algorithm for AnsweredAlgorithm {
1162        fn name(&self) -> &str {
1163            "answered_test"
1164        }
1165
1166        async fn route(
1167            self: Arc<Self>,
1168            driver: Driver,
1169            request: Request,
1170        ) -> Result<RoutingOutcome> {
1171            let response = driver
1172                .call_model(request.clone(), vec![self.model.clone()])
1173                .await?;
1174            Ok(RoutingOutcome::answered(
1175                self.model.clone(),
1176                request,
1177                response,
1178            ))
1179        }
1180    }
1181
1182    /// Rewrites the request for its selected model only.
1183    struct RewritingAlgorithm {}
1184
1185    #[async_trait]
1186    impl Algorithm for RewritingAlgorithm {
1187        fn name(&self) -> &str {
1188            "rewriting_test"
1189        }
1190
1191        async fn route(
1192            self: Arc<Self>,
1193            _driver: Driver,
1194            request: Request,
1195        ) -> Result<RoutingOutcome> {
1196            let mut rewritten = request.clone();
1197            rewritten
1198                .llm_request
1199                .messages
1200                .push(Message::text(Role::User, "for weak only"));
1201            let mut outcome =
1202                RoutingOutcome::route_to("weak".into(), vec!["strong".into()], rewritten);
1203            outcome.fallback_request = Some(request);
1204            Ok(outcome)
1205        }
1206    }
1207
1208    #[derive(Clone, Copy)]
1209    enum FirstOutcome {
1210        ContextWindow,
1211        ContentPolicy(StatusCode),
1212        Unauthorized,
1213        StreamSuccess,
1214        MidStreamError,
1215    }
1216
1217    struct CandidateClient {
1218        calls: Mutex<Vec<ModelId>>,
1219        requests: Mutex<Vec<Request>>,
1220        first: FirstOutcome,
1221    }
1222
1223    #[async_trait]
1224    impl RoutedLlmClient for CandidateClient {
1225        async fn call(&self, request: Request) -> std::result::Result<Response, LlmClientError> {
1226            let model = request.model_id().unwrap_or_default();
1227            self.calls.lock().push(model.clone());
1228            self.requests.lock().push(request);
1229            if model == "weak" {
1230                return match self.first {
1231                    FirstOutcome::ContextWindow => Err(LlmClientError::ContextWindowExceeded {
1232                        model,
1233                        message: "too long".to_string(),
1234                    }),
1235                    FirstOutcome::ContentPolicy(status) => Err(LlmClientError::UpstreamHttp {
1236                        status,
1237                        body: r#"{"error":{"code":"content_policy_violation","message":"request blocked by content policy","type":"invalid_request_error"},"metadata":{"documentation_section":"context window"}}"#.to_string(),
1238                    }),
1239                    FirstOutcome::Unauthorized => Err(LlmClientError::UpstreamHttp {
1240                        status: StatusCode::UNAUTHORIZED,
1241                        body: "unauthorized".to_string(),
1242                    }),
1243                    FirstOutcome::StreamSuccess => Ok(stream_response(vec![
1244                        LlmResponseChunk::TextDelta {
1245                            index: 0,
1246                            text: "streamed".to_string(),
1247                        },
1248                        LlmResponseChunk::MessageStop {
1249                            reason: Some("stop".to_string()),
1250                        },
1251                    ])),
1252                    FirstOutcome::MidStreamError => Ok(stream_response(vec![
1253                        LlmResponseChunk::TextDelta {
1254                            index: 0,
1255                            text: "partial".to_string(),
1256                        },
1257                        LlmResponseChunk::StreamError {
1258                            message: "stream failed".to_string(),
1259                        },
1260                        LlmResponseChunk::MessageStop { reason: Some("stop".into()) },
1261                    ])),
1262                };
1263            }
1264            Ok(Response {
1265                llm_response: LlmResponse::Agg(AggLlmResponse {
1266                    id: Some("candidate_response".into()),
1267                    ..text_response(Some(model.to_string()), model)
1268                }),
1269                metadata: None,
1270                upstream_headers: http::HeaderMap::new(),
1271            })
1272        }
1273    }
1274
1275    fn stream_response(chunks: Vec<LlmResponseChunk>) -> Response {
1276        Response {
1277            llm_response: LlmResponse::Stream(
1278                futures::stream::iter(
1279                    chunks
1280                        .into_iter()
1281                        .map(|chunk| Ok(LlmResponseStreamEvent::from(chunk))),
1282                )
1283                .boxed(),
1284            ),
1285            metadata: None,
1286            upstream_headers: http::HeaderMap::new(),
1287        }
1288    }
1289
1290    fn request() -> Request {
1291        Request {
1292            llm_request: text_request(Some("auto".to_string()), "hello".to_string()),
1293            raw_request: None,
1294            metadata: None,
1295        }
1296    }
1297
1298    #[tokio::test]
1299    async fn system_one_serves_decisions_and_returns_failures_to_the_classifier()
1300    -> std::result::Result<(), Box<dyn std::error::Error>> {
1301        use std::time::Duration;
1302        use switchyard_libsy::{
1303            CapabilityJudgeConfig, DecisionJudgeConfig, LlmClassifierConfig, LlmTaskClassifier,
1304            TaskClassifierConfig,
1305        };
1306        use switchyard_protocol::{
1307            BooleanEstimate, DecisionRequest, DecisionValue, Probability, ScoreValue,
1308        };
1309        use wiremock::matchers::{body_partial_json, header, path};
1310
1311        let server = MockServer::start().await;
1312        let client = Arc::new(crate::SystemOneClient::new(
1313            format!("{}/v1/systemone", server.uri()).parse()?,
1314            "test-key".into(),
1315            Duration::from_secs(2),
1316        )?);
1317        assert!(matches!(
1318            crate::SystemOneClient::new(
1319                format!("{}/v1/systemone", server.uri()).parse()?,
1320                "invalid\nkey".into(),
1321                Duration::from_secs(2),
1322            ),
1323            Err(LlmClientError::Configuration { .. })
1324        ));
1325        let mixed: DecisionRequest = serde_json::from_value(json!({
1326            "model": "judge", "context": {"task": "A simple task"},
1327            "questions": {
1328                "boolean": {"instructions": "Is it simple?", "kind": {"type": "boolean", "data": {
1329                    "true_description": ["Simple"], "false_description": null
1330                }}},
1331                "route": {"instructions": {"task": "Choose"}, "kind": {"type": "choice", "data": {
1332                    "options": [{"id": "advantage", "description": "A wins"}, {"id": "no_advantage"}]
1333                }}},
1334                "score": {"instructions": "Rate difficulty", "kind": {"type": "score", "data": {
1335                    "levels": ["Easy", {"description": "Hard"}]
1336                }}}
1337            }
1338        }))?;
1339        let mut body = json!({
1340            "model": "jev-1.13.0", "usage": {"input_tokens": 42, "output_tokens": 3},
1341            "answers": {
1342                "boolean": {"type": "noul", "noul": 0.9},
1343                "route": {"type": "choice", "choice": "no_advantage", "confidence": 0.7,
1344                    "probabilities": {"advantage": 0.2, "no_advantage": 0.8}},
1345                "score": {"type": "score", "score": 0.6, "confidence": 0.3,
1346                    "legend": {"0": "Easy", "1": {"description": "Hard"}},
1347                    "probabilities": {"1": 0.6, "0": 0.4}}
1348            }
1349        });
1350        Mock::given(method("POST"))
1351            .and(path("/v1/systemone"))
1352            .and(header("authorization", "Bearer test-key"))
1353            .and(body_partial_json(json!({
1354                "model": "judge", "state": mixed.context,
1355                "questions": {
1356                    "boolean": {"type": "noul", "criteria": {"true": ["Simple"]}},
1357                    "route": {"type": "choice", "instructions": {"task": "Choose"},
1358                        "criteria": {"advantage": "A wins", "no_advantage": null}},
1359                    "score": {"type": "score", "criteria": ["Easy", {"description": "Hard"}]}
1360                }
1361            })))
1362            .respond_with(ResponseTemplate::new(200).set_body_json(&body))
1363            .expect(1)
1364            .mount(&server)
1365            .await;
1366        let response = client.call(mixed.clone()).await?;
1367        assert_eq!(response.model.as_deref(), Some("jev-1.13.0"));
1368        assert_eq!(response.id, None);
1369        assert_eq!(response.usage.input_tokens, Some(42));
1370        assert_eq!(response.usage.total_tokens, None);
1371        assert_eq!(
1372            response.answers["boolean"].value,
1373            DecisionValue::Boolean(BooleanEstimate::ProbabilityTrue(Probability(0.9)))
1374        );
1375        assert_eq!(
1376            response.answers["score"].value,
1377            DecisionValue::Score {
1378                value: ScoreValue(0.6),
1379                probabilities: Some(vec![Probability(0.4), Probability(0.6)]),
1380            }
1381        );
1382        assert_eq!(
1383            response.answers["route"].provider_confidence.map(|c| c.0),
1384            Some(0.7)
1385        );
1386        server.verify().await;
1387
1388        let mut partial = body.clone();
1389        partial["answers"]
1390            .as_object_mut()
1391            .unwrap()
1392            .remove("boolean");
1393        let mut invalid_responses = vec![json!({"answers": {}}), partial];
1394        for (id, answer) in [
1395            ("unknown-question", json!({"type": "noul", "noul": 0.9})),
1396            ("boolean", json!({"type": "score", "score": 0.0})),
1397            (
1398                "route",
1399                json!({"type": "choice", "choice": "unknown-option",
1400                    "probabilities": {"advantage": 0.2, "no_advantage": 0.8}}),
1401            ),
1402            ("score", json!({"type": "score", "score": -0.1})),
1403            (
1404                "score",
1405                json!({"type": "score", "score": 1.1,
1406                "probabilities": {"0": 0.4, "1": 0.6}}),
1407            ),
1408        ] {
1409            let mut invalid = body.clone();
1410            invalid["answers"][id] = answer;
1411            if id == "unknown-question" {
1412                invalid["answers"]
1413                    .as_object_mut()
1414                    .unwrap()
1415                    .remove("boolean");
1416            }
1417            invalid_responses.push(invalid);
1418        }
1419        for template in invalid_responses
1420            .into_iter()
1421            .map(|body| ResponseTemplate::new(200).set_body_json(body))
1422            .chain([ResponseTemplate::new(200).set_body_string("invalid JSON")])
1423        {
1424            server.reset().await;
1425            Mock::given(method("POST"))
1426                .respond_with(template)
1427                .expect(1)
1428                .mount(&server)
1429                .await;
1430            assert!(matches!(
1431                client.call(mixed.clone()).await,
1432                Err(LlmClientError::ResponseTranslation(_) | LlmClientError::InvalidResponse { .. })
1433            ));
1434            server.verify().await;
1435        }
1436
1437        body["answers"].as_object_mut().unwrap().remove("boolean");
1438        body["answers"].as_object_mut().unwrap().remove("score");
1439        let models = Arc::new(RuntimeModels::new(HashMap::from([
1440            (Category::Judge, vec!["judge".into()]),
1441            (Category::Capable, vec!["capable".into()]),
1442            (Category::Efficient, vec!["efficient".into()]),
1443            (Category::Any, vec!["efficient".into(), "capable".into()]),
1444        ])));
1445        let algorithm = || -> Result<Arc<dyn Algorithm>> {
1446            Ok(Arc::new(LlmTaskClassifier::new(
1447                LlmClassifierConfig::Capability {
1448                    config: TaskClassifierConfig {
1449                        judge: CapabilityJudgeConfig::Decision(DecisionJudgeConfig {
1450                            cutoff: 0.4,
1451                            instructions: None,
1452                            candidates: BTreeMap::from([
1453                                ("a".into(), "capable".into()),
1454                                ("b".into(), "efficient".into()),
1455                            ]),
1456                            evidence: json!({}),
1457                        }),
1458                        ..TaskClassifierConfig::default()
1459                    },
1460                },
1461            )?))
1462        };
1463        for (template, expected, is_success) in [
1464            (
1465                ResponseTemplate::new(200).set_body_json(&body),
1466                "efficient",
1467                true,
1468            ),
1469            (
1470                ResponseTemplate::new(503).set_body_string("unavailable"),
1471                "capable",
1472                false,
1473            ),
1474        ] {
1475            server.reset().await;
1476            Mock::given(method("POST"))
1477                .and(path("/v1/systemone"))
1478                .respond_with(template)
1479                .expect(2)
1480                .mount(&server)
1481                .await;
1482            let llm = Arc::new(CandidateClient {
1483                calls: Mutex::default(),
1484                requests: Mutex::default(),
1485                first: FirstOutcome::Unauthorized,
1486            });
1487            let clients = ClientRouter::single_with_decision_clients(
1488                llm.clone(),
1489                HashMap::from([(
1490                    ModelId::from("judge"),
1491                    client.clone() as Arc<dyn RoutedDecisionClient>,
1492                )]),
1493            );
1494            let outcome = decide(algorithm()?, clients.clone(), request(), models.clone()).await?;
1495            assert_eq!(outcome.selected_model_id()?, expected);
1496            assert!(llm.calls.lock().is_empty());
1497            let events = Arc::new(Mutex::new(Vec::new()));
1498            let captured = events.clone();
1499            let (selected, _) = run(
1500                algorithm()?,
1501                clients,
1502                request(),
1503                models.clone(),
1504                Some(Arc::new(move |event| captured.lock().push(event))),
1505            )
1506            .await?;
1507            assert_eq!(selected, expected);
1508            assert_eq!(&*llm.calls.lock(), &[ModelId::from(expected)]);
1509            {
1510                let events = events.lock();
1511                let RunObservation::DecisionCall(call) = &events[0] else {
1512                    panic!("missing decision observation")
1513                };
1514                assert_eq!(call.selected_model, "judge");
1515                assert_eq!(call.is_success, is_success);
1516                assert_eq!(
1517                    call.usage.as_ref().and_then(|u| u.input_tokens),
1518                    is_success.then_some(42)
1519                );
1520                assert!(
1521                    events
1522                        .iter()
1523                        .any(|event| matches!(event, RunObservation::AnswerCall(_)))
1524                );
1525            }
1526            server.verify().await;
1527        }
1528        Ok(())
1529    }
1530
1531    fn instruction_text(request: &Request) -> Vec<&str> {
1532        request
1533            .llm_request
1534            .instructions
1535            .iter()
1536            .flat_map(|instruction| &instruction.content)
1537            .filter_map(|block| match block {
1538                ContentBlock::Text { text } => Some(text.as_str()),
1539                _ => None,
1540            })
1541            .collect()
1542    }
1543
1544    async fn run_candidates(
1545        first: FirstOutcome,
1546    ) -> (Arc<CandidateClient>, Result<(ModelId, Response)>) {
1547        let client = Arc::new(CandidateClient {
1548            calls: Mutex::new(Vec::new()),
1549            requests: Mutex::new(Vec::new()),
1550            first,
1551        });
1552        let algorithm = Arc::new(CandidateAlgorithm {});
1553        let models = to_category_map(&["weak", "strong"]);
1554        let result = run(
1555            algorithm,
1556            ClientRouter::single(client.clone()),
1557            request(),
1558            models,
1559            None,
1560        )
1561        .await;
1562        (client, result)
1563    }
1564
1565    fn to_category_map(names: &[&str]) -> Arc<RuntimeModels> {
1566        Arc::new(RuntimeModels::new(
1567            [(
1568                Category::Any,
1569                names.iter().map(|name| ModelId::from(*name)).collect(),
1570            )]
1571            .into(),
1572        ))
1573    }
1574
1575    #[tokio::test]
1576    async fn answered_outcome_does_not_make_a_second_model_call() -> Result<()> {
1577        let client = Arc::new(CandidateClient {
1578            calls: Mutex::new(Vec::new()),
1579            requests: Mutex::new(Vec::new()),
1580            first: FirstOutcome::StreamSuccess,
1581        });
1582        let observations = Arc::new(Mutex::new(Vec::new()));
1583        let observed = Arc::clone(&observations);
1584        let observer: RunObserver = Arc::new(move |event| observed.lock().push(event));
1585
1586        let (selected, response) = run(
1587            Arc::new(AnsweredAlgorithm {
1588                model: "weak".into(),
1589            }),
1590            ClientRouter::single(client.clone()),
1591            request(),
1592            Arc::new(RuntimeModels::default()),
1593            Some(observer),
1594        )
1595        .await?;
1596
1597        assert_eq!(selected, "weak");
1598        assert_eq!(response.served_model().map(ModelId::as_str), Some("weak"));
1599        assert_eq!(&*client.calls.lock(), &[ModelId::from("weak")]);
1600        let observations = observations.lock();
1601        assert!(matches!(observations[0], RunObservation::AnswerCall(_)));
1602        assert!(matches!(
1603            observations[1],
1604            RunObservation::Outcome(ref metadata) if metadata.algorithm == "answered_test"
1605        ));
1606        assert!(matches!(
1607            observations[2],
1608            RunObservation::RoutingOverhead(_)
1609        ));
1610        assert_eq!(observations.len(), 3);
1611        Ok(())
1612    }
1613
1614    #[tokio::test]
1615    async fn served_answer_is_reported_before_the_client_sees_it_end() -> Result<()> {
1616        #[derive(Default)]
1617        struct ReportingAlgorithm {
1618            answers: Mutex<Vec<(String, bool)>>,
1619            tracking: bool,
1620        }
1621
1622        #[async_trait]
1623        impl Algorithm for ReportingAlgorithm {
1624            fn name(&self) -> &str {
1625                "reporting_test"
1626            }
1627
1628            async fn route(
1629                self: Arc<Self>,
1630                _driver: Driver,
1631                request: Request,
1632            ) -> Result<RoutingOutcome> {
1633                Ok(RoutingOutcome::route_to("weak".into(), Vec::new(), request))
1634            }
1635
1636            fn tracks_answers(&self) -> bool {
1637                self.tracking
1638            }
1639
1640            async fn observe_answer(&self, session_id: &str, answer: AnswerSummary) {
1641                self.answers
1642                    .lock()
1643                    .push((session_id.to_string(), answer.final_reply));
1644            }
1645        }
1646
1647        assert!(!CandidateAlgorithm {}.tracks_answers());
1648        for (tracking, first, final_reply, cancel) in [
1649            (false, FirstOutcome::StreamSuccess, true, false),
1650            (true, FirstOutcome::StreamSuccess, true, false),
1651            (true, FirstOutcome::MidStreamError, false, false),
1652            (true, FirstOutcome::StreamSuccess, false, true),
1653        ] {
1654            let algorithm = Arc::new(ReportingAlgorithm {
1655                tracking,
1656                ..Default::default()
1657            });
1658            let client = Arc::new(CandidateClient {
1659                calls: Mutex::new(Vec::new()),
1660                requests: Mutex::new(Vec::new()),
1661                first,
1662            });
1663            let request = Request {
1664                metadata: Some(switchyard_protocol::Metadata {
1665                    session_id: Some("session-1".to_string()),
1666                    ..Default::default()
1667                }),
1668                ..request()
1669            };
1670            let (_, response) = run(
1671                algorithm.clone(),
1672                ClientRouter::single(client),
1673                request,
1674                to_category_map(&["weak"]),
1675                None,
1676            )
1677            .await?;
1678            let LlmResponse::Stream(mut stream) = response.llm_response else {
1679                panic!("expected a streamed answer");
1680            };
1681            if !tracking {
1682                assert!(algorithm.answers.lock().is_empty());
1683                while let Some(item) = stream.next().await {
1684                    assert!(item.is_ok());
1685                }
1686                assert!(algorithm.answers.lock().is_empty());
1687                continue;
1688            }
1689            if cancel {
1690                stream.next().await;
1691                drop(stream);
1692                assert_eq!(
1693                    *algorithm.answers.lock(),
1694                    [("session-1".to_string(), false)]
1695                );
1696                continue;
1697            }
1698            while let Some(item) = stream.next().await {
1699                let stops = item.as_ref().is_ok_and(|event| {
1700                    event
1701                        .normalized()
1702                        .iter()
1703                        .any(|chunk| matches!(chunk, LlmResponseChunk::MessageStop { .. }))
1704                });
1705                if stops {
1706                    assert_eq!(algorithm.answers.lock().len(), 2);
1707                }
1708            }
1709            assert_eq!(
1710                *algorithm.answers.lock(),
1711                if final_reply {
1712                    vec![
1713                        ("session-1".to_string(), false),
1714                        ("session-1".to_string(), true),
1715                    ]
1716                } else {
1717                    vec![
1718                        ("session-1".to_string(), false),
1719                        ("session-1".to_string(), false),
1720                    ]
1721                }
1722            );
1723        }
1724        Ok(())
1725    }
1726
1727    #[test]
1728    fn answer_observation_keeps_call_order() {
1729        let pending = Some(Arc::new(Mutex::new(
1730            ["answer", "judge"]
1731                .map(|model| ModelCallObservation {
1732                    selected_model: model.into(),
1733                    is_success: true,
1734                    duration: std::time::Duration::ZERO,
1735                    usage: None,
1736                })
1737                .map(RunObservation::LlmCall)
1738                .into(),
1739        )));
1740        let emitted = Arc::new(Mutex::new(Vec::new()));
1741        let captured = Arc::clone(&emitted);
1742        let observer: RunObserver = Arc::new(move |event| captured.lock().push(event));
1743        let answer = ModelId::from("answer");
1744
1745        emit_routing_observations(&Some(observer), &pending, Some(&answer));
1746
1747        assert!(matches!(
1748            &emitted.lock()[..],
1749            [RunObservation::AnswerCall(answer), RunObservation::LlmCall(judge)]
1750                if answer.selected_model == "answer" && judge.selected_model == "judge"
1751        ));
1752    }
1753
1754    #[tokio::test]
1755    async fn responses_state_tracking_uses_upstream_storage_and_format() -> Result<()> {
1756        for (responses, store, conversation) in [
1757            (true, true, None),
1758            (true, false, None),
1759            (true, false, Some(json!("conv_plain"))),
1760            (true, false, Some(json!({"id": "conv_object"}))),
1761            (false, true, None),
1762        ] {
1763            for stream in [false, true] {
1764                let server = MockServer::start().await;
1765                Mock::given(method("POST"))
1766                    .respond_with(move |request: &wiremock::Request| {
1767                        let body: Value = serde_json::from_slice(&request.body).expect("request JSON");
1768                        let response = if responses {
1769                            json!({
1770                                "id": "resp_seed", "object": "response", "model": "weak",
1771                                "status": "completed", "output": [], "store": body["store"],
1772                                "conversation": body["conversation"],
1773                            })
1774                        } else if stream {
1775                            json!({"id": "resp_seed", "model": "weak", "choices": [{"index": 0, "delta": {"content": "ok"}, "finish_reason": "stop"}]})
1776                        } else {
1777                            json!({"id": "resp_seed", "model": "weak", "choices": [{"index": 0, "message": {"role": "assistant", "content": "ok"}, "finish_reason": "stop"}]})
1778                        };
1779                        if stream {
1780                            let event = if responses { json!({"type": "response.completed", "response": response}) } else { response };
1781                            ResponseTemplate::new(200)
1782                                .insert_header("content-type", "text/event-stream")
1783                                .set_body_string(if responses {
1784                                    format!("event: response.completed\ndata: {event}\n\n")
1785                                } else {
1786                                    format!("data: {event}\n\ndata: [DONE]\n\n")
1787                                })
1788                        } else {
1789                            ResponseTemplate::new(200).set_body_json(response)
1790                        }
1791                    })
1792                    .mount(&server).await;
1793                let mut clients = HashMap::new();
1794                for model in ["weak", "strong"] {
1795                    let config = HttpBackendConfig {
1796                        base_url: server.uri(),
1797                        api_key: None,
1798                        forward_auth: false,
1799                        extra_headers: BTreeMap::new(),
1800                        extra_body: BTreeMap::from([("store".to_string(), json!(store))]),
1801                        omit_body_fields: BTreeSet::new(),
1802                        reasoning_effort: None,
1803                        max_retries: 0,
1804                        failure_cooldown: std::time::Duration::ZERO,
1805                        timeout: None,
1806                    };
1807                    let backend = if responses {
1808                        Backend::OpenAiResponses(config)
1809                    } else {
1810                        Backend::OpenAiChat(config)
1811                    };
1812                    let client: Arc<dyn RoutedLlmClient> = Arc::new(
1813                        TranslatingLlmClient::new(&[ModelConfig::new(model, backend, None)])
1814                            .map_err(|error| LibsyError::external("building test client", error))?,
1815                    );
1816                    clients.insert(ModelId::from(model), client);
1817                }
1818                let clients = ClientRouter::new(clients);
1819                let mut seed = request();
1820                seed.llm_request.stream = stream;
1821                if let Some(conversation) = &conversation {
1822                    seed.llm_request
1823                        .extensions
1824                        .fields
1825                        .insert("conversation".to_string(), conversation.clone());
1826                }
1827                let (_, response) = run(
1828                    Arc::new(switchyard_libsy::Passthrough),
1829                    clients.clone(),
1830                    seed,
1831                    to_category_map(&["weak"]),
1832                    None,
1833                )
1834                .await?;
1835                response
1836                    .llm_response
1837                    .into_agg()
1838                    .await
1839                    .map_err(|error| LibsyError::client_call("weak", error))?;
1840                let mut continuations = vec![(
1841                    "previous_response_id",
1842                    json!("resp_seed"),
1843                    responses && store,
1844                )];
1845                if let Some(conversation) = conversation.clone() {
1846                    continuations.push(("conversation", conversation, true));
1847                }
1848                for (field, id, pinned) in continuations {
1849                    let mut follow = request();
1850                    follow
1851                        .llm_request
1852                        .extensions
1853                        .fields
1854                        .insert(field.to_string(), id);
1855                    let outcome = decide(
1856                        Arc::new(switchyard_libsy::Passthrough),
1857                        clients.clone(),
1858                        follow,
1859                        to_category_map(&["strong"]),
1860                    )
1861                    .await?;
1862                    assert_eq!(
1863                        outcome.selected_model_id()?.as_str(),
1864                        if pinned { "weak" } else { "strong" },
1865                        "responses={responses}, store={store}, stream={stream}, field={field}"
1866                    );
1867                }
1868                let requests = server.received_requests().await.expect("request recording");
1869                assert_eq!(requests.len(), 1);
1870                assert_eq!(
1871                    serde_json::from_slice::<Value>(&requests[0].body).expect("request JSON")["store"],
1872                    store
1873                );
1874            }
1875        }
1876        Ok(())
1877    }
1878
1879    #[tokio::test]
1880    async fn responses_stored_tool_continuation_materializes_for_anthropic() -> Result<()> {
1881        let server = MockServer::start().await;
1882        Mock::given(method("POST"))
1883            .respond_with(|request: &wiremock::Request| {
1884                let body: Value = serde_json::from_slice(&request.body).expect("request JSON");
1885                let blocks = body["messages"]
1886                    .as_array()
1887                    .into_iter()
1888                    .flatten()
1889                    .filter_map(|message| message["content"].as_array())
1890                    .flatten()
1891                    .collect::<Vec<_>>();
1892                let has_result = blocks.iter().any(|block| block["type"] == "tool_result");
1893                let has_call = blocks.iter().any(|block| block["type"] == "tool_use");
1894                let has_thanks = blocks
1895                    .iter()
1896                    .any(|block| block["type"] == "text" && block["text"] == "thanks")
1897                    || body["messages"].as_array().is_some_and(|messages| {
1898                        messages
1899                            .iter()
1900                            .any(|message| message["content"] == "thanks")
1901                    });
1902                if has_result && !has_call {
1903                    return ResponseTemplate::new(400).set_body_json(json!({
1904                        "type": "error",
1905                        "error": {
1906                            "type": "invalid_request_error",
1907                            "message": "tool_result has no corresponding tool_use"
1908                        }
1909                    }));
1910                }
1911                let response = if has_thanks {
1912                    json!({
1913                        "id": "msg_third", "type": "message", "role": "assistant",
1914                        "model": "weak", "content": [{"type": "text", "text": "done"}],
1915                        "stop_reason": "end_turn", "usage": {"input_tokens": 6, "output_tokens": 1}
1916                    })
1917                } else if has_result {
1918                    json!({
1919                        "id": "msg_follow", "type": "message", "role": "assistant",
1920                        "model": "weak", "content": [{"type": "text", "text": "sunny"}],
1921                        "stop_reason": "end_turn", "usage": {"input_tokens": 4, "output_tokens": 1}
1922                    })
1923                } else {
1924                    json!({
1925                        "id": "msg_seed", "type": "message", "role": "assistant",
1926                        "model": "weak",
1927                        "content": [{
1928                            "type": "tool_use", "id": "toolu_weather", "name": "get_weather",
1929                            "input": {"city": "Paris"}
1930                        }],
1931                        "stop_reason": "tool_use", "usage": {"input_tokens": 3, "output_tokens": 1}
1932                    })
1933                };
1934                ResponseTemplate::new(200).set_body_json(response)
1935            })
1936            .mount(&server)
1937            .await;
1938
1939        let client: Arc<dyn RoutedLlmClient> = Arc::new(
1940            TranslatingLlmClient::new(&[ModelConfig::new(
1941                "weak",
1942                Backend::Anthropic(HttpBackendConfig {
1943                    base_url: server.uri(),
1944                    api_key: None,
1945                    forward_auth: false,
1946                    extra_headers: BTreeMap::new(),
1947                    extra_body: BTreeMap::new(),
1948                    omit_body_fields: BTreeSet::new(),
1949                    reasoning_effort: None,
1950                    max_retries: 0,
1951                    failure_cooldown: std::time::Duration::ZERO,
1952                    timeout: None,
1953                }),
1954                None,
1955            )])
1956            .map_err(|error| LibsyError::external("building test client", error))?,
1957        );
1958        let clients = ClientRouter::new(HashMap::from([(ModelId::from("weak"), client)]));
1959        let models = to_category_map(&["weak"]);
1960
1961        let seed = Request {
1962            llm_request: switchyard_translation::decode_request(
1963                WireFormat::OpenAiResponses,
1964                &json!({
1965                    "model": "route",
1966                    "input": "Call get_weather for Paris",
1967                    "tools": [{
1968                        "type": "function", "name": "get_weather",
1969                        "parameters": {"type": "object"}
1970                    }],
1971                    "store": true
1972                }),
1973            )
1974            .map_err(|error| LibsyError::external("decoding seed request", error))?,
1975            raw_request: None,
1976            metadata: None,
1977        };
1978        let (_, response) = run(
1979            Arc::new(switchyard_libsy::Passthrough),
1980            clients.clone(),
1981            seed,
1982            models.clone(),
1983            None,
1984        )
1985        .await?;
1986        assert_eq!(
1987            response
1988                .llm_response
1989                .as_agg()
1990                .and_then(|agg| agg.id.as_deref()),
1991            Some("msg_seed")
1992        );
1993
1994        let follow = Request {
1995            llm_request: switchyard_translation::decode_request(
1996                WireFormat::OpenAiResponses,
1997                &json!({
1998                    "model": "route",
1999                    "previous_response_id": "msg_seed",
2000                    "input": [{
2001                        "type": "function_call_output",
2002                        "call_id": "toolu_weather",
2003                        "output": "Paris is sunny"
2004                    }],
2005                    "tools": [{
2006                        "type": "function", "name": "get_weather",
2007                        "parameters": {"type": "object"}
2008                    }],
2009                    "store": true
2010                }),
2011            )
2012            .map_err(|error| LibsyError::external("decoding continuation request", error))?,
2013            raw_request: None,
2014            metadata: None,
2015        };
2016        let (_, response) = run(
2017            Arc::new(switchyard_libsy::Passthrough),
2018            clients.clone(),
2019            follow,
2020            models.clone(),
2021            None,
2022        )
2023        .await?;
2024        assert_eq!(
2025            completion_text(
2026                response
2027                    .llm_response
2028                    .as_agg()
2029                    .expect("buffered Anthropic response"),
2030            ),
2031            "sunny"
2032        );
2033
2034        let third = Request {
2035            llm_request: switchyard_translation::decode_request(
2036                WireFormat::OpenAiResponses,
2037                &json!({
2038                    "model": "route",
2039                    "previous_response_id": "msg_follow",
2040                    "input": "thanks",
2041                    "store": true
2042                }),
2043            )
2044            .map_err(|error| LibsyError::external("decoding chained request", error))?,
2045            raw_request: None,
2046            metadata: None,
2047        };
2048        let history = clients
2049            .stored_state_owner(&third)
2050            .and_then(|owner| owner.history)
2051            .expect("chained canonical history");
2052        assert_eq!(history.len, 4);
2053        assert_eq!(history.segment.len(), 2);
2054        assert!(history.parent.is_some());
2055        let (_, response) = run(
2056            Arc::new(switchyard_libsy::Passthrough),
2057            clients,
2058            third,
2059            models,
2060            None,
2061        )
2062        .await?;
2063        assert_eq!(
2064            completion_text(
2065                response
2066                    .llm_response
2067                    .as_agg()
2068                    .expect("buffered Anthropic response"),
2069            ),
2070            "done"
2071        );
2072
2073        let requests = server.received_requests().await.expect("request recording");
2074        assert_eq!(requests.len(), 3);
2075        let follow: Value = serde_json::from_slice(&requests[1].body)
2076            .map_err(|error| LibsyError::external("decoding captured request", error))?;
2077        assert_eq!(follow["messages"][1]["content"][0]["type"], "tool_use");
2078        assert_eq!(follow["messages"][2]["content"][0]["type"], "tool_result");
2079        let third: Value = serde_json::from_slice(&requests[2].body)
2080            .map_err(|error| LibsyError::external("decoding captured request", error))?;
2081        assert_eq!(third["messages"][1]["content"][0]["type"], "tool_use");
2082        assert_eq!(third["messages"][2]["content"][0]["type"], "tool_result");
2083        assert_eq!(third["messages"][3]["content"], "sunny");
2084        assert_eq!(third["messages"][4]["content"][0]["text"], "thanks");
2085        Ok(())
2086    }
2087
2088    #[tokio::test]
2089    async fn streamed_cross_format_state_is_recorded_only_after_completion() -> Result<()> {
2090        let client = Arc::new(CandidateClient {
2091            calls: Mutex::new(Vec::new()),
2092            requests: Mutex::new(Vec::new()),
2093            first: FirstOutcome::StreamSuccess,
2094        });
2095        let clients = ClientRouter::new(HashMap::from([(
2096            ModelId::from("weak"),
2097            client as Arc<dyn RoutedLlmClient>,
2098        )]));
2099        let mut seed = request();
2100        seed.llm_request.messages = vec![Message::text(Role::User, "inspect")];
2101        seed.llm_request
2102            .preservation
2103            .requests
2104            .insert(WireFormat::OpenAiResponses.into(), json!({}));
2105        let mut response = stream_response(vec![
2106            LlmResponseChunk::MessageStart {
2107                id: Some("msg_stream".to_string()),
2108                model: Some("weak".to_string()),
2109            },
2110            LlmResponseChunk::ToolCallDelta {
2111                index: 0,
2112                id: Some("toolu_stream".to_string()),
2113                name: Some("lookup".to_string()),
2114                arguments_delta: Some("{}".to_string()),
2115            },
2116            LlmResponseChunk::MessageStop {
2117                reason: Some("tool_use".to_string()),
2118            },
2119        ]);
2120        response.set_served_model(&ModelId::from("weak"));
2121        let response = clients.remember_state_owner(&seed, response)?;
2122
2123        let mut follow = request();
2124        follow
2125            .llm_request
2126            .extensions
2127            .fields
2128            .insert("previous_response_id".to_string(), json!("msg_stream"));
2129        follow.llm_request.messages = vec![Message {
2130            role: Role::Tool,
2131            content: vec![ContentBlock::ToolResult(ToolResult {
2132                tool_call_id: "toolu_stream".to_string(),
2133                content: vec![ContentBlock::Text {
2134                    text: "ready".to_string(),
2135                }],
2136                is_error: None,
2137            })],
2138        }];
2139        assert!(clients.stored_state_owner(&follow).is_none());
2140        response
2141            .llm_response
2142            .into_agg()
2143            .await
2144            .map_err(|error| LibsyError::client_call("weak", error))?;
2145
2146        let outcome = continue_on(
2147            clients
2148                .stored_state_owner(&follow)
2149                .expect("completed stream state"),
2150            "passthrough",
2151            follow,
2152        );
2153        assert_eq!(outcome.request.llm_request.messages.len(), 3);
2154        assert!(matches!(
2155            outcome.request.llm_request.messages[1].content.as_slice(),
2156            [ContentBlock::ToolCall(call)] if call.id == "toolu_stream"
2157        ));
2158        assert!(matches!(
2159            outcome.request.llm_request.messages[2].content.as_slice(),
2160            [ContentBlock::ToolResult(result)] if result.tool_call_id == "toolu_stream"
2161        ));
2162        Ok(())
2163    }
2164
2165    #[test]
2166    fn cross_format_response_with_store_false_is_not_retained() -> Result<()> {
2167        let client = Arc::new(CandidateClient {
2168            calls: Mutex::new(Vec::new()),
2169            requests: Mutex::new(Vec::new()),
2170            first: FirstOutcome::StreamSuccess,
2171        });
2172        let clients = ClientRouter::new(HashMap::from([(
2173            ModelId::from("weak"),
2174            client as Arc<dyn RoutedLlmClient>,
2175        )]));
2176        let mut seed = request();
2177        seed.llm_request
2178            .extensions
2179            .fields
2180            .insert("store".to_string(), json!(false));
2181        seed.llm_request
2182            .preservation
2183            .requests
2184            .insert(WireFormat::OpenAiResponses.into(), json!({}));
2185        let mut agg = text_response(Some("weak".to_string()), "done".to_string());
2186        agg.id = Some("msg_not_stored".to_string());
2187        let mut response = Response {
2188            llm_response: LlmResponse::Agg(agg),
2189            metadata: None,
2190            upstream_headers: http::HeaderMap::new(),
2191        };
2192        response.set_served_model(&ModelId::from("weak"));
2193        clients.remember_state_owner(&seed, response)?;
2194
2195        let mut follow = request();
2196        follow
2197            .llm_request
2198            .extensions
2199            .fields
2200            .insert("previous_response_id".to_string(), json!("msg_not_stored"));
2201        assert!(clients.stored_state_owner(&follow).is_none());
2202        Ok(())
2203    }
2204
2205    #[tokio::test]
2206    async fn state_capacity_does_not_fail_completed_responses() -> Result<()> {
2207        let client = Arc::new(CandidateClient {
2208            calls: Mutex::new(Vec::new()),
2209            requests: Mutex::new(Vec::new()),
2210            first: FirstOutcome::StreamSuccess,
2211        });
2212        let clients = ClientRouter::new(HashMap::from([(
2213            ModelId::from("weak"),
2214            client as Arc<dyn RoutedLlmClient>,
2215        )]));
2216        let model = ModelId::from("weak");
2217        clients.inner.state_owners.lock().by_id = (0..MAX_STATE_OWNERS)
2218            .map(|id| {
2219                (
2220                    format!("existing_{id}"),
2221                    StoredState {
2222                        model: model.clone(),
2223                        history: None,
2224                    },
2225                )
2226            })
2227            .collect();
2228
2229        let mut request = request();
2230        request
2231            .llm_request
2232            .preservation
2233            .requests
2234            .insert(WireFormat::OpenAiResponses.into(), json!({}));
2235        let mut agg = text_response(Some("weak".to_string()), "done".to_string());
2236        agg.id = Some("overflow_buffered".to_string());
2237        let mut buffered = Response {
2238            llm_response: LlmResponse::Agg(agg),
2239            metadata: None,
2240            upstream_headers: http::HeaderMap::new(),
2241        };
2242        buffered.set_served_model(&model);
2243        clients.remember_state_owner(&request, buffered)?;
2244
2245        let mut streamed = stream_response(vec![
2246            LlmResponseChunk::MessageStart {
2247                id: Some("overflow_streamed".to_string()),
2248                model: Some("weak".to_string()),
2249            },
2250            LlmResponseChunk::TextDelta {
2251                index: 0,
2252                text: "done".to_string(),
2253            },
2254            LlmResponseChunk::MessageStop {
2255                reason: Some("stop".to_string()),
2256            },
2257        ]);
2258        streamed.set_served_model(&model);
2259        clients
2260            .remember_state_owner(&request, streamed)?
2261            .llm_response
2262            .into_agg()
2263            .await
2264            .map_err(|error| LibsyError::client_call("weak", error))?;
2265
2266        assert_eq!(
2267            clients.inner.state_owners.lock().by_id.len(),
2268            MAX_STATE_OWNERS
2269        );
2270        Ok(())
2271    }
2272
2273    #[test]
2274    fn state_owners_preserve_existing_records_at_capacity()
2275    -> std::result::Result<(), LlmClientError> {
2276        let mut owners = StateOwners::default();
2277        let model = ModelId::from("model/a");
2278        for id in 0..MAX_STATE_OWNERS - 1 {
2279            owners.remember(Some(&format!("resp_{id}")), None, &model, None)?;
2280        }
2281        assert!(matches!(
2282            owners.remember(Some("new_response"), Some("new_conversation"), &model, None,),
2283            Err(LlmClientError::ResponseStateLimitExceeded {
2284                limit: MAX_STATE_OWNERS
2285            })
2286        ));
2287        assert!(owners.owner("new_response").is_none());
2288        assert!(owners.owner("new_conversation").is_none());
2289        owners.remember(Some("last_id"), Some("last_id"), &model, None)?;
2290        owners.remember(Some("last_id"), Some("resp_0"), &model, None)?;
2291        assert!(matches!(
2292            owners.remember(Some("over_limit"), None, &model, None),
2293            Err(LlmClientError::ResponseStateLimitExceeded { .. })
2294        ));
2295        assert!(matches!(
2296            owners.remember(Some("resp_0"), None, &ModelId::from("model/b"), None,),
2297            Err(LlmClientError::ResponseStateConflict)
2298        ));
2299        assert_eq!(
2300            owners.owner("resp_0").map(|state| &state.model),
2301            Some(&model)
2302        );
2303        assert_eq!(owners.by_id.len(), MAX_STATE_OWNERS);
2304        Ok(())
2305    }
2306
2307    #[test]
2308    fn concurrent_state_owners_do_not_exceed_capacity() {
2309        let model = ModelId::from("model/a");
2310        let owners = Arc::new(Mutex::new(StateOwners {
2311            by_id: (0..MAX_STATE_OWNERS - 16)
2312                .map(|id| {
2313                    (
2314                        format!("existing_{id}"),
2315                        StoredState {
2316                            model: model.clone(),
2317                            history: None,
2318                        },
2319                    )
2320                })
2321                .collect(),
2322        }));
2323        let barrier = Arc::new(std::sync::Barrier::new(32));
2324        let accepted = std::thread::scope(|scope| {
2325            let handles = (0..32)
2326                .map(|id| {
2327                    let owners = owners.clone();
2328                    let barrier = barrier.clone();
2329                    let model = model.clone();
2330                    scope.spawn(move || {
2331                        barrier.wait();
2332                        let result = owners.lock().remember(
2333                            Some(&format!("resp_{id}")),
2334                            Some(&format!("conv_{id}")),
2335                            &model,
2336                            None,
2337                        );
2338                        match result {
2339                            Ok(()) => Some(id),
2340                            Err(LlmClientError::ResponseStateLimitExceeded { .. }) => None,
2341                            other => panic!("unexpected state tracking result: {other:?}"),
2342                        }
2343                    })
2344                })
2345                .collect::<Vec<_>>();
2346            handles
2347                .into_iter()
2348                .filter_map(|handle| handle.join().expect("request thread panicked"))
2349                .collect::<Vec<_>>()
2350        });
2351        let owners = owners.lock();
2352        assert_eq!(accepted.len(), 8);
2353        assert_eq!(owners.by_id.len(), MAX_STATE_OWNERS);
2354        assert_eq!(
2355            owners.owner("existing_0").map(|state| &state.model),
2356            Some(&model)
2357        );
2358        for id in accepted {
2359            assert_eq!(
2360                owners
2361                    .owner(&format!("resp_{id}"))
2362                    .map(|state| &state.model),
2363                Some(&model)
2364            );
2365            assert_eq!(
2366                owners
2367                    .owner(&format!("conv_{id}"))
2368                    .map(|state| &state.model),
2369                Some(&model)
2370            );
2371        }
2372    }
2373
2374    #[tokio::test]
2375    async fn each_fallback_candidate_receives_only_its_own_prompt() -> Result<()> {
2376        let client = Arc::new(CandidateClient {
2377            calls: Mutex::new(Vec::new()),
2378            requests: Mutex::new(Vec::new()),
2379            first: FirstOutcome::ContextWindow,
2380        });
2381        let routed_client: Arc<dyn RoutedLlmClient> = client.clone();
2382        let clients = ClientRouter::new_with_target_prompts(
2383            HashMap::from([
2384                (ModelId::from("weak"), Arc::clone(&routed_client)),
2385                (ModelId::from("strong"), routed_client),
2386            ]),
2387            HashMap::from([
2388                ("weak".into(), "weak prompt".to_string()),
2389                ("strong".into(), "strong prompt".to_string()),
2390            ]),
2391            None,
2392        );
2393
2394        run(
2395            Arc::new(CandidateAlgorithm {}),
2396            clients,
2397            request(),
2398            to_category_map(&["weak", "strong"]),
2399            None,
2400        )
2401        .await?;
2402
2403        let calls = client.requests.lock();
2404        assert_eq!(calls.len(), 2);
2405        assert_eq!(instruction_text(&calls[0]), ["weak prompt"]);
2406        assert_eq!(instruction_text(&calls[1]), ["strong prompt"]);
2407        Ok(())
2408    }
2409
2410    #[tokio::test]
2411    async fn decision_prompts_a_routing_response_target() -> Result<()> {
2412        let client = Arc::new(CandidateClient {
2413            calls: Mutex::new(Vec::new()),
2414            requests: Mutex::new(Vec::new()),
2415            first: FirstOutcome::StreamSuccess,
2416        });
2417        let routed_client: Arc<dyn RoutedLlmClient> = client.clone();
2418        let clients = ClientRouter::new_with_target_prompts(
2419            HashMap::from([(ModelId::from("answer"), routed_client)]),
2420            HashMap::from([("answer".into(), "answer prompt".to_string())]),
2421            Some("answer".into()),
2422        );
2423
2424        decide(
2425            Arc::new(AnsweredAlgorithm {
2426                model: "answer".into(),
2427            }),
2428            clients,
2429            request(),
2430            Arc::new(RuntimeModels::default()),
2431        )
2432        .await?;
2433
2434        let calls = client.requests.lock();
2435        assert_eq!(calls.len(), 1);
2436        assert_eq!(instruction_text(&calls[0]), ["answer prompt"]);
2437        Ok(())
2438    }
2439
2440    #[tokio::test]
2441    async fn decision_prepares_only_the_selected_request() -> Result<()> {
2442        let client = Arc::new(CandidateClient {
2443            calls: Mutex::new(Vec::new()),
2444            requests: Mutex::new(Vec::new()),
2445            first: FirstOutcome::StreamSuccess,
2446        });
2447        let routed_client: Arc<dyn RoutedLlmClient> = client;
2448        let clients = ClientRouter::new_with_target_prompts(
2449            HashMap::from([
2450                (ModelId::from("weak"), Arc::clone(&routed_client)),
2451                (ModelId::from("strong"), routed_client),
2452            ]),
2453            HashMap::from([
2454                (ModelId::from("weak"), "weak prompt".to_string()),
2455                (ModelId::from("strong"), "strong prompt".to_string()),
2456            ]),
2457            None,
2458        );
2459
2460        let outcome = decide(
2461            Arc::new(CandidateAlgorithm {}),
2462            clients,
2463            request(),
2464            to_category_map(&["weak", "strong"]),
2465        )
2466        .await?;
2467
2468        assert_eq!(
2469            outcome.selected_model_ids,
2470            [ModelId::from("weak"), ModelId::from("strong")]
2471        );
2472        assert_eq!(instruction_text(&outcome.request), ["weak prompt"]);
2473        Ok(())
2474    }
2475
2476    #[test]
2477    fn fallback_only_accepts_context_policy_and_unavailable_failures() {
2478        let error = |source| LibsyError::client_call("target", source);
2479        assert_eq!(
2480            fallback_reason(
2481                &error(LlmClientError::ContextWindowExceeded {
2482                    model: "target".into(),
2483                    message: "too long".to_string(),
2484                }),
2485                true
2486            ),
2487            Some(RoutingFallbackReason::ContextWindow)
2488        );
2489        for status in [
2490            StatusCode::FORBIDDEN,
2491            StatusCode::REQUEST_TIMEOUT,
2492            StatusCode::TOO_MANY_REQUESTS,
2493            StatusCode::INTERNAL_SERVER_ERROR,
2494            StatusCode::from_u16(599).expect("599 is a valid status"),
2495        ] {
2496            assert_eq!(
2497                fallback_reason(
2498                    &error(LlmClientError::UpstreamHttp {
2499                        status,
2500                        body: "failed".to_string(),
2501                    }),
2502                    true
2503                ),
2504                Some(RoutingFallbackReason::Unavailable)
2505            );
2506        }
2507        for (body, expected) in [
2508            (
2509                r#"{"error":{"code":"content_policy_violation"}}"#,
2510                Some(RoutingFallbackReason::Unavailable),
2511            ),
2512            (
2513                r#"{"error":{"code":"invalid_request_error"},"metadata":"content_policy_violation"}"#,
2514                None,
2515            ),
2516            (r#"{"error":{"message":"content_policy_violation"}}"#, None),
2517            ("content_policy_violation", None),
2518        ] {
2519            assert_eq!(
2520                fallback_reason(
2521                    &error(LlmClientError::UpstreamHttp {
2522                        status: StatusCode::BAD_REQUEST,
2523                        body: body.to_string(),
2524                    }),
2525                    true
2526                ),
2527                expected,
2528                "{body}"
2529            );
2530        }
2531        for status in [
2532            StatusCode::BAD_REQUEST,
2533            StatusCode::UNAUTHORIZED,
2534            StatusCode::NOT_FOUND,
2535            StatusCode::CONFLICT,
2536            StatusCode::from_u16(499).expect("499 is a valid status"),
2537            StatusCode::from_u16(600).expect("600 is a valid status"),
2538        ] {
2539            assert_eq!(
2540                fallback_reason(
2541                    &error(LlmClientError::UpstreamHttp {
2542                        status,
2543                        body: "failed".to_string(),
2544                    }),
2545                    true
2546                ),
2547                None
2548            );
2549        }
2550    }
2551
2552    #[tokio::test]
2553    async fn candidate_failures_follow_the_fallback_policy() -> Result<()> {
2554        // Context overflow is retryable across candidates.
2555        let (client, result) = run_candidates(FirstOutcome::ContextWindow).await;
2556        let (_, response) = result?;
2557        assert_eq!(
2558            &*client.calls.lock(),
2559            &[ModelId::from("weak"), "strong".into()]
2560        );
2561        assert_eq!(
2562            response
2563                .llm_response
2564                .as_agg()
2565                .map(|response| response.model.as_deref()),
2566            Some(Some("strong"))
2567        );
2568        assert_eq!(response.served_model().map(ModelId::as_str), Some("strong"));
2569
2570        // A provider-specific policy denial also permits another candidate.
2571        let (client, result) =
2572            run_candidates(FirstOutcome::ContentPolicy(StatusCode::BAD_REQUEST)).await;
2573        let (_, response) = result?;
2574        assert_eq!(
2575            &*client.calls.lock(),
2576            &[ModelId::from("weak"), "strong".into()]
2577        );
2578        assert_eq!(response.served_model().map(ModelId::as_str), Some("strong"));
2579
2580        // Authentication failure is not retryable, so the second candidate is untouched.
2581        let (client, result) = run_candidates(FirstOutcome::Unauthorized).await;
2582        assert!(matches!(
2583            result,
2584            Err(LibsyError::ClientCall {
2585                source: LlmClientError::UpstreamHttp {
2586                    status: StatusCode::UNAUTHORIZED,
2587                    ..
2588                },
2589                ..
2590            })
2591        ));
2592        assert_eq!(&*client.calls.lock(), &[ModelId::from("weak")]);
2593        Ok(())
2594    }
2595
2596    #[tokio::test]
2597    async fn fallback_candidates_receive_the_fallback_request() -> Result<()> {
2598        let client = Arc::new(CandidateClient {
2599            calls: Mutex::new(Vec::new()),
2600            requests: Mutex::new(Vec::new()),
2601            first: FirstOutcome::ContextWindow,
2602        });
2603        let clients = ClientRouter::single(client.clone());
2604        let mut seed = request();
2605        seed.llm_request
2606            .preservation
2607            .requests
2608            .insert(WireFormat::OpenAiResponses.into(), json!({}));
2609        run(
2610            Arc::new(RewritingAlgorithm {}),
2611            clients.clone(),
2612            seed,
2613            to_category_map(&["weak", "strong"]),
2614            None,
2615        )
2616        .await?;
2617        let sent: Vec<_> = client
2618            .requests
2619            .lock()
2620            .iter()
2621            .map(|request| request.llm_request.messages.clone())
2622            .collect();
2623        assert_eq!(sent.len(), 2);
2624        assert_eq!(sent[0].len(), request().llm_request.messages.len() + 1);
2625        assert_eq!(sent[1], request().llm_request.messages);
2626        let mut follow = request();
2627        follow
2628            .llm_request
2629            .extensions
2630            .fields
2631            .insert("previous_response_id".into(), json!("candidate_response"));
2632        follow.llm_request.messages = vec![Message::text(Role::User, "continue")];
2633        let owner = clients
2634            .stored_state_owner(&follow)
2635            .expect("fallback Responses state");
2636        let continued = continue_on(owner, "test", follow);
2637        let mut expected = sent[1].clone();
2638        expected.push(Message::text(Role::Assistant, "strong"));
2639        expected.push(Message::text(Role::User, "continue"));
2640        assert_eq!(continued.request.llm_request.messages, expected);
2641        Ok(())
2642    }
2643
2644    #[tokio::test]
2645    async fn fallback_request_does_not_bypass_content_policy() -> Result<()> {
2646        for status in [StatusCode::BAD_REQUEST, StatusCode::FORBIDDEN] {
2647            let client = Arc::new(CandidateClient {
2648                calls: Mutex::new(Vec::new()),
2649                requests: Mutex::new(Vec::new()),
2650                first: FirstOutcome::ContentPolicy(status),
2651            });
2652            let result = run(
2653                Arc::new(RewritingAlgorithm {}),
2654                ClientRouter::single(client.clone()),
2655                request(),
2656                to_category_map(&["weak", "strong"]),
2657                None,
2658            )
2659            .await;
2660            assert!(
2661                matches!(result, Err(LibsyError::ClientCall { source: LlmClientError::UpstreamHttp { status: actual, .. }, .. }) if actual == status)
2662            );
2663            assert_eq!(&*client.calls.lock(), &[ModelId::from("weak")]);
2664        }
2665        Ok(())
2666    }
2667
2668    #[tokio::test]
2669    async fn retry_exhaustion_falls_back_and_optional_cooldown_skips_failed_backend() -> Result<()>
2670    {
2671        let server = MockServer::start().await;
2672        let calls = Arc::new(Mutex::new(Vec::new()));
2673        let observed_calls = Arc::clone(&calls);
2674        Mock::given(method("POST"))
2675            .respond_with(move |request: &wiremock::Request| {
2676                let body: serde_json::Value =
2677                    serde_json::from_slice(&request.body).unwrap_or(serde_json::Value::Null);
2678                let model = body["model"].as_str().unwrap_or_default().to_string();
2679                observed_calls.lock().push(model.clone());
2680                if model == "weak" {
2681                    ResponseTemplate::new(503)
2682                        .insert_header("retry-after", "0")
2683                        .set_body_string("unavailable")
2684                } else {
2685                    ResponseTemplate::new(200).set_body_json(serde_json::json!({
2686                        "id": "answer",
2687                        "model": "strong",
2688                        "choices": [{
2689                            "index": 0,
2690                            "message": {"role": "assistant", "content": "ok"},
2691                            "finish_reason": "stop"
2692                        }],
2693                        "usage": {}
2694                    }))
2695                }
2696            })
2697            .mount(&server)
2698            .await;
2699
2700        let client_with_cooldown = |cooldown| {
2701            let backend = || {
2702                Backend::OpenAiChat(HttpBackendConfig {
2703                    base_url: format!("{}/v1", server.uri()),
2704                    api_key: None,
2705                    forward_auth: false,
2706                    extra_headers: BTreeMap::new(),
2707                    extra_body: BTreeMap::new(),
2708                    omit_body_fields: BTreeSet::new(),
2709                    reasoning_effort: None,
2710                    max_retries: 2,
2711                    failure_cooldown: cooldown,
2712                    timeout: None,
2713                })
2714            };
2715            TranslatingLlmClient::new(&[
2716                ModelConfig::new("weak", backend(), None),
2717                ModelConfig::new("strong", backend(), None),
2718            ])
2719            .map(Arc::new)
2720            .map_err(|error| LibsyError::external("building test client", error))
2721        };
2722        let call = async |client: Arc<TranslatingLlmClient>| {
2723            run(
2724                Arc::new(CandidateAlgorithm {}),
2725                ClientRouter::single(client),
2726                request(),
2727                to_category_map(&["weak", "strong"]),
2728                None,
2729            )
2730            .await?;
2731            Ok::<_, LibsyError>(std::mem::take(&mut *calls.lock()))
2732        };
2733
2734        let client = client_with_cooldown(std::time::Duration::ZERO)?;
2735        assert_eq!(
2736            call(client.clone()).await?,
2737            ["weak", "weak", "weak", "strong"]
2738        );
2739        assert_eq!(call(client).await?, ["weak", "weak", "weak", "strong"]);
2740
2741        let cooldown = std::time::Duration::from_secs(60);
2742        let client = client_with_cooldown(cooldown)?;
2743        assert_eq!(
2744            call(client.clone()).await?,
2745            ["weak", "weak", "weak", "strong"]
2746        );
2747        assert_eq!(call(client.clone()).await?, ["strong"]);
2748
2749        tokio::time::pause();
2750        tokio::time::advance(cooldown).await;
2751        tokio::time::resume();
2752
2753        assert_eq!(call(client).await?, ["weak", "weak", "weak", "strong"]);
2754        Ok(())
2755    }
2756
2757    #[tokio::test]
2758    async fn refused_connection_falls_back_to_the_next_candidate() -> Result<()> {
2759        let server = MockServer::start().await;
2760        Mock::given(method("POST"))
2761            .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
2762                "id": "answer",
2763                "model": "strong",
2764                "choices": [{
2765                    "index": 0,
2766                    "message": {"role": "assistant", "content": "ok"},
2767                    "finish_reason": "stop"
2768                }],
2769                "usage": {}
2770            })))
2771            .mount(&server)
2772            .await;
2773
2774        let backend = |base_url: String| {
2775            Backend::OpenAiChat(HttpBackendConfig {
2776                base_url,
2777                api_key: None,
2778                forward_auth: false,
2779                extra_headers: BTreeMap::new(),
2780                extra_body: BTreeMap::new(),
2781                omit_body_fields: BTreeSet::new(),
2782                reasoning_effort: None,
2783                max_retries: 0,
2784                failure_cooldown: std::time::Duration::ZERO,
2785                timeout: None,
2786            })
2787        };
2788        let client = Arc::new(
2789            TranslatingLlmClient::new(&[
2790                // Nothing listens on port 1, so the connection is refused.
2791                ModelConfig::new("weak", backend("http://127.0.0.1:1/v1".to_string()), None),
2792                ModelConfig::new("strong", backend(format!("{}/v1", server.uri())), None),
2793            ])
2794            .map_err(|error| LibsyError::external("building test client", error))?,
2795        );
2796        let (_, response) = run(
2797            Arc::new(CandidateAlgorithm {}),
2798            ClientRouter::single(client),
2799            request(),
2800            to_category_map(&["weak", "strong"]),
2801            None,
2802        )
2803        .await?;
2804
2805        assert_eq!(response.served_model().map(ModelId::as_str), Some("strong"));
2806        Ok(())
2807    }
2808
2809    #[tokio::test]
2810    async fn streams_are_outside_the_candidate_fallback_boundary() -> Result<()> {
2811        // Receiving a stream handle is a successful call and ends candidate selection.
2812        let (client, result) = run_candidates(FirstOutcome::StreamSuccess).await;
2813        let (_, response) = result?;
2814        assert_eq!(response.served_model().map(ModelId::as_str), Some("weak"));
2815        let aggregate = response
2816            .llm_response
2817            .into_agg()
2818            .await
2819            .map_err(|source| LibsyError::client_call("weak", source))?;
2820        assert_eq!(&*client.calls.lock(), &[ModelId::from("weak")]);
2821        assert_eq!(completion_text(&aggregate), "streamed");
2822
2823        // A later in-stream failure surfaces during aggregation without trying another model.
2824        let (client, result) = run_candidates(FirstOutcome::MidStreamError).await;
2825        let (_, response) = result?;
2826        let error = response
2827            .llm_response
2828            .into_agg()
2829            .await
2830            .expect_err("the stream should fail during aggregation");
2831        assert!(error.to_string().contains("stream failed"));
2832        assert_eq!(&*client.calls.lock(), &[ModelId::from("weak")]);
2833        Ok(())
2834    }
2835
2836    // Verbatim shape of an SGLang admission rejection on a streaming session:
2837    // HTTP 200, and the overflow arrives as the first SSE event.
2838    const OVERFLOW_SSE: &str = "data: {\"error\":{\"code\":400,\"message\":\"Input length (600013 tokens) exceeds the maximum allowed length (536826 tokens). Use a shorter input or enable --allow-auto-truncate.\",\"object\":\"error\",\"param\":null,\"type\":\"BAD_REQUEST\"},\"model\":\"weak\"}\n\ndata: [DONE]\n\n";
2839
2840    // A normal one-turn streamed answer, as the second candidate serves it.
2841    const STRONG_SSE: &str = "data: {\"id\":\"answer\",\"model\":\"strong\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"ok\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"answer\",\"model\":\"strong\",\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\ndata: [DONE]\n\n";
2842
2843    /// Runs a streaming request through the weak→strong candidate loop, serving
2844    /// `weak_sse` from the first candidate and [`STRONG_SSE`] from the second.
2845    /// The server is returned so response streams stay readable after the call.
2846    async fn run_streaming_candidates(
2847        weak_sse: &'static str,
2848    ) -> (
2849        MockServer,
2850        Arc<Mutex<Vec<String>>>,
2851        Result<(ModelId, Response)>,
2852    ) {
2853        let server = MockServer::start().await;
2854        let calls = Arc::new(Mutex::new(Vec::new()));
2855        let observed_calls = Arc::clone(&calls);
2856        Mock::given(method("POST"))
2857            .respond_with(move |request: &wiremock::Request| {
2858                let body: serde_json::Value =
2859                    serde_json::from_slice(&request.body).unwrap_or(serde_json::Value::Null);
2860                let model = body["model"].as_str().unwrap_or_default().to_string();
2861                observed_calls.lock().push(model.clone());
2862                let sse = if model == "weak" {
2863                    weak_sse
2864                } else {
2865                    STRONG_SSE
2866                };
2867                ResponseTemplate::new(200)
2868                    .insert_header("content-type", "text/event-stream")
2869                    .set_body_string(sse)
2870            })
2871            .mount(&server)
2872            .await;
2873
2874        let backend = || {
2875            Backend::OpenAiChat(HttpBackendConfig {
2876                base_url: format!("{}/v1", server.uri()),
2877                api_key: None,
2878                forward_auth: false,
2879                extra_headers: BTreeMap::new(),
2880                extra_body: BTreeMap::new(),
2881                omit_body_fields: BTreeSet::new(),
2882                reasoning_effort: None,
2883                max_retries: 0,
2884                failure_cooldown: std::time::Duration::ZERO,
2885                timeout: None,
2886            })
2887        };
2888        let client = Arc::new(
2889            TranslatingLlmClient::new(&[
2890                ModelConfig::new("weak", backend(), None),
2891                ModelConfig::new("strong", backend(), None),
2892            ])
2893            .expect("building test client"),
2894        );
2895        let algorithm = Arc::new(CandidateAlgorithm {});
2896        let mut llm_request = text_request(Some("auto".to_string()), "hello".to_string());
2897        llm_request.stream = true;
2898        let request = Request {
2899            llm_request,
2900            raw_request: None,
2901            metadata: None,
2902        };
2903        let result = run(
2904            algorithm,
2905            ClientRouter::single(client),
2906            request,
2907            to_category_map(&["weak", "strong"]),
2908            None,
2909        )
2910        .await;
2911        (server, calls, result)
2912    }
2913
2914    #[tokio::test]
2915    async fn first_stream_event_overflow_falls_back_to_the_next_candidate() -> Result<()> {
2916        // Nothing has reached the caller when the first event arrives, so an overflow
2917        // there falls through to the next candidate exactly as its buffered twin does.
2918        let (_server, calls, result) = run_streaming_candidates(OVERFLOW_SSE).await;
2919        let (_, response) = result?;
2920        assert_eq!(&*calls.lock(), &["weak", "strong"]);
2921        assert_eq!(response.served_model().map(ModelId::as_str), Some("strong"));
2922        let aggregate = response
2923            .llm_response
2924            .into_agg()
2925            .await
2926            .map_err(|source| LibsyError::client_call("strong", source))?;
2927        assert_eq!(completion_text(&aggregate), "ok");
2928
2929        // Only a classified overflow re-routes; any other in-band error event still
2930        // streams through to the consumer without trying another candidate.
2931        let (_server, calls, result) = run_streaming_candidates(
2932            "data: {\"error\":{\"code\":500,\"message\":\"engine exploded\",\"object\":\"error\"},\"model\":\"weak\"}\n\ndata: [DONE]\n\n",
2933        )
2934        .await;
2935        let (_, response) = result?;
2936        assert_eq!(&*calls.lock(), &["weak"]);
2937        let error = response
2938            .llm_response
2939            .into_agg()
2940            .await
2941            .expect_err("the in-band error should surface to the consumer");
2942        assert!(error.to_string().contains("engine exploded"));
2943
2944        // Once content has streamed, a retry would repeat delivered output, so a later
2945        // overflow event keeps today's semantics and surfaces during aggregation.
2946        let (_server, calls, result) = run_streaming_candidates(
2947            "data: {\"id\":\"turn\",\"model\":\"weak\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"partial\"},\"finish_reason\":null}]}\n\ndata: {\"error\":{\"code\":400,\"message\":\"Input length (600013 tokens) exceeds the maximum allowed length (536826 tokens).\",\"object\":\"error\",\"type\":\"BAD_REQUEST\"},\"model\":\"weak\"}\n\ndata: [DONE]\n\n",
2948        )
2949        .await;
2950        let (_, response) = result?;
2951        assert_eq!(&*calls.lock(), &["weak"]);
2952        let error = response
2953            .llm_response
2954            .into_agg()
2955            .await
2956            .expect_err("the mid-stream overflow should surface to the consumer");
2957        assert!(
2958            error
2959                .to_string()
2960                .contains("exceeds the maximum allowed length")
2961        );
2962
2963        // A stream that ends before any event is not a classified failure; it still
2964        // aggregates to an empty response.
2965        let (_server, calls, result) = run_streaming_candidates("data: [DONE]\n\n").await;
2966        let (_, response) = result?;
2967        assert_eq!(&*calls.lock(), &["weak"]);
2968        let aggregate = response
2969            .llm_response
2970            .into_agg()
2971            .await
2972            .map_err(|source| LibsyError::client_call("weak", source))?;
2973        assert_eq!(completion_text(&aggregate), "");
2974        Ok(())
2975    }
2976}