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