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