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