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