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