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