Skip to main content

switchyard_llm_client/
run.rs

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