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