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