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