1use 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
43pub 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 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
127pub 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
188fn 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
211async 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
248async 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#[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#[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: usize,
367 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 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
423async 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
450fn 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
484fn 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
505struct 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 history: Option<Arc<MessageHistory>>,
546}
547
548#[derive(Clone)]
549struct CanonicalInput {
550 parent: Option<Arc<MessageHistory>>,
551 messages: Arc<[Message]>,
552}
553
554#[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
617fn 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#[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 state_owners: Mutex<StateOwners>,
645 track_provider_state: bool,
647}
648
649enum Routing {
650 Single(Arc<dyn RoutedLlmClient>),
652 ByModel(HashMap<ModelId, Arc<dyn RoutedLlmClient>>),
654}
655
656impl ClientRouter {
657 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 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 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 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 pub fn single(client: Arc<dyn RoutedLlmClient>) -> Self {
740 Self::single_with_decision_clients(client, HashMap::new())
741 }
742
743 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}