1use async_trait::async_trait;
13use futures::{StreamExt, future::join_all};
14use serde::Deserialize;
15
16use crate::composite::{ChildModel, add_usage, build_aggregator_prompt, extract_text};
17use crate::error::AiMuxError;
18use crate::language_model::LanguageModel;
19use crate::options::{CallOptions, ToolChoice};
20use crate::result::{GenerateResult, StreamResult};
21use crate::retry;
22use crate::stream_part::StreamPart;
23use crate::types::{Usage, Warning};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum MoaFailMode {
29 #[default]
31 BestEffort,
32 FailFast,
34}
35
36#[derive(Debug, Clone, Deserialize)]
37pub struct MoaConfig {
38 #[serde(default = "default_moa_provider")]
40 pub provider_name: String,
41 #[serde(default = "default_moa_provider")]
43 pub model_id: String,
44 #[serde(default)]
47 pub aggregator_instructions: Option<String>,
48 #[serde(default = "default_strip_reference_tools")]
51 pub strip_reference_tools: bool,
52 #[serde(default)]
54 pub fail_mode: MoaFailMode,
55}
56
57fn default_moa_provider() -> String {
58 "moa".into()
59}
60
61fn default_strip_reference_tools() -> bool {
62 true
63}
64
65impl Default for MoaConfig {
68 fn default() -> Self {
69 Self {
70 provider_name: "moa".into(),
71 model_id: "moa".into(),
72 aggregator_instructions: None,
73 strip_reference_tools: true,
74 fail_mode: MoaFailMode::BestEffort,
75 }
76 }
77}
78
79pub struct MoaModel {
85 references: Vec<ChildModel>,
86 aggregator: ChildModel,
87 config: MoaConfig,
88}
89
90impl MoaModel {
91 pub fn new(references: Vec<ChildModel>, aggregator: ChildModel, config: MoaConfig) -> Self {
92 Self {
93 references,
94 aggregator,
95 config,
96 }
97 }
98
99 fn reference_options(&self, options: &CallOptions) -> CallOptions {
103 let mut o = options.clone();
104 if self.config.strip_reference_tools {
105 o.tools = None;
106 o.tool_choice = ToolChoice::Auto;
107 }
108 o
109 }
110
111 async fn run_references_nonstream(
117 &self,
118 options: &CallOptions,
119 ) -> Result<(Vec<(String, String)>, Usage, Vec<Warning>), AiMuxError> {
120 if self.references.is_empty() {
121 return Ok((Vec::new(), Usage::default(), Vec::new()));
122 }
123 let ref_opts = self.reference_options(options);
124 let results = join_all(self.references.iter().enumerate().map(|(i, m)| {
125 let child_opts =
126 ref_opts.for_step(format!("moa.ref[{i}]:{}/{}", m.provider(), m.model_id()));
127 async move {
128 let retries = retry::prepare_retries(
129 child_opts.max_retries,
130 m.retry_config(),
131 child_opts.abort_signal.clone(),
132 );
133 retries
134 .retry(|| {
135 if let Some(context) = &child_opts.recording_context {
136 let _ = context.start_attempt();
137 }
138 m.do_generate(&child_opts)
139 })
140 .await
141 }
142 }))
143 .await;
144
145 let mut usage = Usage::default();
146 let mut warnings = Vec::new();
147 let mut texts: Vec<(String, String)> = Vec::new();
148 for (i, r) in results.into_iter().enumerate() {
149 match r {
150 Ok(res) => {
151 usage = add_usage(usage, &res.usage);
152 let mid = res
153 .response
154 .model_id
155 .clone()
156 .unwrap_or_else(|| format!("ref-{i}"));
157 texts.push((mid, extract_text(&res.content)));
158 }
159 Err(e) => {
160 if self.config.fail_mode == MoaFailMode::FailFast {
161 return Err(e);
162 }
163 warnings.push(Warning::Other {
164 message: format!("moa reference {i} failed: {e}"),
165 });
166 }
167 }
168 }
169 if texts.is_empty() {
170 return Err(AiMuxError::Other("moa: all reference models failed".into()));
171 }
172 Ok((texts, usage, warnings))
173 }
174}
175
176#[async_trait]
177impl LanguageModel for MoaModel {
178 fn provider(&self) -> &str {
179 &self.config.provider_name
180 }
181
182 fn model_id(&self) -> &str {
183 &self.config.model_id
184 }
185
186 fn retry_config(&self) -> retry::RetryConfig {
187 retry::RetryConfig {
190 max_retries: 0,
191 ..retry::RetryConfig::default()
192 }
193 }
194
195 async fn do_generate(&self, options: &CallOptions) -> Result<GenerateResult, AiMuxError> {
196 let (texts, ref_usage, warnings) = self.run_references_nonstream(options).await?;
198
199 let agg_prompt = build_aggregator_prompt(
201 &options.prompt,
202 self.config.aggregator_instructions.as_deref(),
203 &texts,
204 );
205 let mut agg_opts = options.clone();
206 agg_opts.prompt = agg_prompt;
207
208 let retries = retry::prepare_retries(
210 agg_opts.max_retries,
211 self.aggregator.retry_config(),
212 agg_opts.abort_signal.clone(),
213 );
214 let agg_opts = agg_opts.for_step(format!(
215 "moa.aggregator:{}/{}",
216 self.aggregator.provider(),
217 self.aggregator.model_id()
218 ));
219 let mut agg = retries
220 .retry(|| {
221 if let Some(context) = &agg_opts.recording_context {
222 let _ = context.start_attempt();
223 }
224 self.aggregator.do_generate(&agg_opts)
225 })
226 .await?;
227
228 agg.usage = add_usage(agg.usage, &ref_usage);
230 agg.warnings.extend(warnings);
231 Ok(agg)
232 }
233
234 async fn do_stream(&self, options: &CallOptions) -> Result<StreamResult, AiMuxError> {
235 let (texts, ref_usage, drop_warnings) = self.run_references_nonstream(options).await?;
238
239 let agg_prompt = build_aggregator_prompt(
241 &options.prompt,
242 self.config.aggregator_instructions.as_deref(),
243 &texts,
244 );
245 let mut agg_opts = options.clone();
246 agg_opts.prompt = agg_prompt;
247
248 let retries = retry::prepare_retries(
252 agg_opts.max_retries,
253 self.aggregator.retry_config(),
254 agg_opts.abort_signal.clone(),
255 );
256 let agg_opts = agg_opts.for_step(format!(
257 "moa.aggregator:{}/{}",
258 self.aggregator.provider(),
259 self.aggregator.model_id()
260 ));
261 let agg = retries
262 .retry(|| {
263 if let Some(context) = &agg_opts.recording_context {
264 let _ = context.start_attempt();
265 }
266 self.aggregator.do_stream(&agg_opts)
267 })
268 .await?;
269 let mut agg_stream = agg.stream;
270
271 let stream = async_stream::stream! {
272 yield Ok(StreamPart::StreamStart { warnings: drop_warnings });
273 while let Some(part) = agg_stream.next().await {
274 match part {
275 Ok(StreamPart::StreamStart { .. }) => { }
276 Ok(StreamPart::Finish { finish_reason, usage, provider_metadata }) => {
277 yield Ok(StreamPart::Finish {
278 finish_reason,
279 usage: add_usage(usage, &ref_usage),
280 provider_metadata,
281 });
282 }
283 Ok(other) => yield Ok(other),
284 Err(e) => {
285 let recoverable = e.is_recoverable_stream_error();
286 yield Err(e);
287 if !recoverable {
288 break;
292 }
293 }
294 }
295 }
296 };
297
298 Ok(StreamResult {
303 stream: Box::pin(stream),
304 request_body: None,
305 response_headers: None,
306 })
307 }
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313 use crate::content::ContentPart;
314 use crate::language_model_message::LanguageModelPromptMessage;
315 use crate::message::Role;
316 use crate::result::GenerateContent;
317 use crate::stream_part::StreamPart;
318 use crate::types::{FinishReason, FinishReasonUnified, TokenUsage};
319 use async_trait::async_trait;
320 use futures::stream;
321 use std::sync::Arc;
322 use std::sync::atomic::{AtomicUsize, Ordering};
323
324 struct MockChild {
327 name: &'static str,
328 text: String,
329 fail: bool,
330 usage: Usage,
331 retry_failures: usize,
332 calls: Arc<AtomicUsize>,
333 }
334
335 #[async_trait]
336 impl LanguageModel for MockChild {
337 fn provider(&self) -> &str {
338 "mock"
339 }
340 fn model_id(&self) -> &str {
341 self.name
342 }
343 async fn do_generate(&self, _options: &CallOptions) -> Result<GenerateResult, AiMuxError> {
344 let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1;
345 if self.fail {
346 return Err(AiMuxError::Other(format!("{} failed", self.name)));
347 }
348 if attempt <= self.retry_failures {
349 return Err(retryable_error(self.name));
350 }
351 Ok(GenerateResult {
352 content: vec![GenerateContent::Text {
353 text: self.text.clone(),
354 provider_metadata: None,
355 }],
356 finish_reason: FinishReason {
357 unified: FinishReasonUnified::Stop,
358 raw: None,
359 },
360 usage: self.usage.clone(),
361 warnings: vec![],
362 provider_metadata: None,
363 response: crate::types::ResponseMetadata {
364 model_id: Some(self.name.into()),
365 ..Default::default()
366 },
367 request_body: None,
368 response_headers: None,
369 })
370 }
371 async fn do_stream(&self, _options: &CallOptions) -> Result<StreamResult, AiMuxError> {
372 let attempt = self.calls.fetch_add(1, Ordering::SeqCst) + 1;
373 if self.fail {
374 return Err(AiMuxError::Other(format!("{} failed", self.name)));
375 }
376 if attempt <= self.retry_failures {
377 return Err(retryable_error(self.name));
378 }
379 let parts: Vec<Result<StreamPart, AiMuxError>> = vec![
380 Ok(StreamPart::StreamStart { warnings: vec![] }),
381 Ok(StreamPart::TextDelta {
382 id: "t1".into(),
383 delta: self.text.clone(),
384 provider_metadata: None,
385 }),
386 Ok(StreamPart::Finish {
387 finish_reason: FinishReason {
388 unified: FinishReasonUnified::Stop,
389 raw: None,
390 },
391 usage: self.usage.clone(),
392 provider_metadata: None,
393 }),
394 ];
395 Ok(StreamResult {
396 stream: Box::pin(stream::iter(parts)),
397 request_body: None,
398 response_headers: None,
399 })
400 }
401 }
402
403 fn retryable_error(name: &str) -> AiMuxError {
404 AiMuxError::ApiCall(Box::new(crate::ApiCallError {
405 status_code: Some(503),
406 response_headers: Some(std::collections::HashMap::from([(
407 "retry-after-ms".into(),
408 "0".into(),
409 )])),
410 is_retryable: true,
411 ..crate::ApiCallError::new(
412 format!("{name} retryable failure"),
413 "https://example.test",
414 serde_json::json!({}),
415 )
416 }))
417 }
418
419 fn retry_child(
420 name: &'static str,
421 text: &str,
422 retry_failures: usize,
423 ) -> (ChildModel, Arc<AtomicUsize>) {
424 let calls = Arc::new(AtomicUsize::new(0));
425 (
426 Arc::new(MockChild {
427 name,
428 text: text.into(),
429 fail: false,
430 usage: Usage::default(),
431 retry_failures,
432 calls: calls.clone(),
433 }),
434 calls,
435 )
436 }
437
438 fn mk(name: &'static str, text: &str, fail: bool, total: u32) -> ChildModel {
439 Arc::new(MockChild {
440 name,
441 text: text.into(),
442 fail,
443 usage: Usage {
444 input_tokens: TokenUsage {
445 total: Some(total),
446 ..Default::default()
447 },
448 output_tokens: TokenUsage {
449 total: Some(total),
450 ..Default::default()
451 },
452 raw: None,
453 },
454 retry_failures: 0,
455 calls: Arc::new(AtomicUsize::new(0)),
456 })
457 }
458
459 fn opts_with_prompt() -> CallOptions {
460 CallOptions {
461 prompt: vec![LanguageModelPromptMessage {
462 role: Role::User,
463 content: vec![ContentPart::text("What is Rust?")],
464 provider_options: None,
465 }],
466 ..Default::default()
467 }
468 }
469
470 #[tokio::test]
471 async fn generate_fans_out_and_aggregates() {
472 let refs = vec![
473 mk("ref-a", "A says hi", false, 10),
474 mk("ref-b", "B says hi", false, 20),
475 ];
476 let agg = mk("aggregator", "aggregated answer", false, 5);
477 let moa = MoaModel::new(refs, agg, MoaConfig::default());
478
479 let r = moa.do_generate(&opts_with_prompt()).await.unwrap();
480 assert_eq!(extract_text(&r.content), "aggregated answer");
482 assert_eq!(r.usage.input_tokens.total, Some(35));
484 assert_eq!(r.usage.output_tokens.total, Some(35));
485 }
486
487 #[tokio::test]
488 async fn best_effort_drops_failed_reference_and_warns() {
489 let refs = vec![mk("ref-a", "A ok", false, 10), mk("ref-b", "B", true, 0)];
490 let agg = mk("aggregator", "final", false, 1);
491 let moa = MoaModel::new(refs, agg, MoaConfig::default());
492
493 let r = moa.do_generate(&opts_with_prompt()).await.unwrap();
494 assert_eq!(extract_text(&r.content), "final");
495 assert_eq!(r.usage.input_tokens.total, Some(11));
497 assert!(r.warnings.iter().any(|w| matches!(
498 w,
499 Warning::Other { message } if message.contains("reference 1 failed")
500 )));
501 }
502
503 #[tokio::test]
504 async fn fail_fast_propagates_first_reference_error() {
505 let refs = vec![mk("ref-a", "A", true, 0), mk("ref-b", "B ok", false, 10)];
506 let agg = mk("aggregator", "final", false, 1);
507 let moa = MoaModel::new(
508 refs,
509 agg,
510 MoaConfig {
511 fail_mode: MoaFailMode::FailFast,
512 ..Default::default()
513 },
514 );
515 let err = moa.do_generate(&opts_with_prompt()).await.unwrap_err();
516 assert!(err.to_string().contains("ref-a failed"));
517 }
518
519 #[tokio::test]
520 async fn all_references_fail_is_error() {
521 let refs = vec![mk("ref-a", "A", true, 0), mk("ref-b", "B", true, 0)];
522 let agg = mk("aggregator", "final", false, 1);
523 let moa = MoaModel::new(refs, agg, MoaConfig::default());
524 let err = moa.do_generate(&opts_with_prompt()).await.unwrap_err();
525 assert!(err.to_string().contains("all reference models failed"));
526 }
527
528 #[tokio::test]
529 async fn no_references_degrades_to_aggregator() {
530 let agg = mk("aggregator", "solo answer", false, 3);
532 let moa = MoaModel::new(vec![], agg, MoaConfig::default());
533 let r = moa.do_generate(&opts_with_prompt()).await.unwrap();
534 assert_eq!(extract_text(&r.content), "solo answer");
535 assert_eq!(r.usage.input_tokens.total, Some(3));
536 }
537
538 #[tokio::test]
539 async fn stream_runs_references_then_streams_aggregator() {
540 let refs = vec![mk("ref-a", "A", false, 7), mk("ref-b", "B", false, 8)];
541 let agg = mk("aggregator", "streamed", false, 2);
542 let moa = MoaModel::new(refs, agg, MoaConfig::default());
543
544 let result = moa.do_stream(&opts_with_prompt()).await.unwrap();
545 let mut s = result.stream;
546 let mut saw_start = false;
547 let mut text = String::new();
548 let mut finish_usage: Option<Usage> = None;
549 while let Some(part) = s.next().await {
550 match part.unwrap() {
551 StreamPart::StreamStart { .. } => saw_start = true,
552 StreamPart::TextDelta { delta, .. } => text.push_str(&delta),
553 StreamPart::Finish { usage, .. } => finish_usage = Some(usage),
554 _ => {}
555 }
556 }
557 assert!(saw_start);
558 assert_eq!(text, "streamed");
559 assert_eq!(finish_usage.unwrap().input_tokens.total, Some(17));
561 }
562
563 #[tokio::test]
564 async fn retries_references_and_aggregator_independently_without_replaying_fanout() {
565 let (ref_a, ref_a_calls) = retry_child("ref-a", "A", 1);
566 let (ref_b, ref_b_calls) = retry_child("ref-b", "B", 0);
567 let (aggregator, aggregator_calls) = retry_child("aggregator", "final", 1);
568 let moa = MoaModel::new(vec![ref_a, ref_b], aggregator, MoaConfig::default());
569 assert_eq!(moa.retry_config().max_retries, 0);
570
571 let result = crate::generate::generate_text(
572 &moa,
573 "hello",
574 crate::generate::GenerateTextOptions {
575 max_retries: Some(2),
576 ..Default::default()
577 },
578 )
579 .await
580 .unwrap();
581
582 assert_eq!(result.text, "final");
583 assert_eq!(ref_a_calls.load(Ordering::SeqCst), 2);
584 assert_eq!(ref_b_calls.load(Ordering::SeqCst), 1);
585 assert_eq!(aggregator_calls.load(Ordering::SeqCst), 2);
586 }
587
588 #[tokio::test]
589 async fn retries_aggregator_stream_setup_without_replaying_references() {
590 let (reference, reference_calls) = retry_child("reference", "A", 0);
591 let (aggregator, aggregator_stream_calls) = retry_child("aggregator", "final", 1);
592 let moa = MoaModel::new(vec![reference], aggregator, MoaConfig::default());
593
594 let _result = crate::generate::stream_text(
595 &moa,
596 "hello",
597 crate::generate::GenerateTextOptions {
598 max_retries: Some(2),
599 ..Default::default()
600 },
601 )
602 .await
603 .unwrap();
604
605 assert_eq!(reference_calls.load(Ordering::SeqCst), 1);
606 assert_eq!(aggregator_stream_calls.load(Ordering::SeqCst), 2);
607 }
608
609 #[tokio::test]
610 async fn stream_best_effort_emits_drop_warning_in_start() {
611 let refs = vec![mk("ref-a", "A", false, 5), mk("ref-b", "B", true, 0)];
612 let agg = mk("aggregator", "out", false, 1);
613 let moa = MoaModel::new(refs, agg, MoaConfig::default());
614
615 let result = moa.do_stream(&opts_with_prompt()).await.unwrap();
616 let mut s = result.stream;
617 match s.next().await.unwrap().unwrap() {
619 StreamPart::StreamStart { warnings } => {
620 assert!(warnings
621 .iter()
622 .any(|w| matches!(w, Warning::Other { message } if message.contains("reference 1 failed"))));
623 }
624 other => panic!("expected StreamStart, got {other:?}"),
625 }
626 }
627
628 #[tokio::test]
629 async fn strip_reference_tools_default_clears_tools() {
630 let agg = mk("agg", "x", false, 0);
632 let moa = MoaModel::new(vec![], agg, MoaConfig::default());
633 let opts = CallOptions {
634 prompt: vec![],
635 tools: Some(vec![]),
636 tool_choice: ToolChoice::Required,
637 ..Default::default()
638 };
639 let ref_opts = moa.reference_options(&opts);
640 assert!(ref_opts.tools.is_none());
641 assert_eq!(ref_opts.tool_choice, ToolChoice::Auto);
642 assert_eq!(opts.tool_choice, ToolChoice::Required);
644 }
645
646 #[tokio::test]
647 async fn no_references_does_not_inject_empty_heading() {
648 let prompt = vec![LanguageModelPromptMessage {
651 role: Role::User,
652 content: vec![ContentPart::text("hello")],
653 provider_options: None,
654 }];
655 let built = build_aggregator_prompt(&prompt, None, &[]);
656 assert_eq!(built.len(), 1);
658 assert!(!built[0].content.iter().any(|p| matches!(
660 p,
661 ContentPart::Text { text, .. } if text.contains("Reference model responses")
662 )));
663 }
664
665 struct MidStreamErrorAggregator;
668
669 #[async_trait]
670 impl LanguageModel for MidStreamErrorAggregator {
671 fn provider(&self) -> &str {
672 "mock"
673 }
674 fn model_id(&self) -> &str {
675 "agg-err"
676 }
677 async fn do_generate(&self, _options: &CallOptions) -> Result<GenerateResult, AiMuxError> {
678 unimplemented!()
679 }
680 async fn do_stream(&self, _options: &CallOptions) -> Result<StreamResult, AiMuxError> {
681 let parts: Vec<Result<StreamPart, AiMuxError>> = vec![
682 Ok(StreamPart::StreamStart { warnings: vec![] }),
683 Ok(StreamPart::TextDelta {
684 id: "t1".into(),
685 delta: "partial".into(),
686 provider_metadata: None,
687 }),
688 Err(AiMuxError::Other("aggregator transport blew up".into())),
690 Ok(StreamPart::TextDelta {
692 id: "t2".into(),
693 delta: "should-not-see".into(),
694 provider_metadata: None,
695 }),
696 ];
697 Ok(StreamResult {
698 stream: Box::pin(stream::iter(parts)),
699 request_body: None,
700 response_headers: None,
701 })
702 }
703 }
704
705 #[tokio::test]
706 async fn stream_relays_aggregator_transport_error_and_stops() {
707 let refs = vec![mk("ref-a", "A", false, 5)];
710 let agg = Arc::new(MidStreamErrorAggregator);
711 let moa = MoaModel::new(refs, agg, MoaConfig::default());
712 let result = moa.do_stream(&opts_with_prompt()).await.unwrap();
713 let parts: Vec<_> = result.stream.collect().await;
714 let mut saw_error = false;
715 let mut text = String::new();
716 for part in parts {
717 match part {
718 Ok(StreamPart::StreamStart { .. }) => {}
719 Ok(StreamPart::TextDelta { delta, .. }) => text.push_str(&delta),
720 Err(e) => {
721 saw_error = true;
722 assert!(e.to_string().contains("blew up"), "got: {e}");
723 }
724 _ => {}
725 }
726 }
727 assert!(
728 saw_error,
729 "expected the aggregator transport error to be relayed"
730 );
731 assert!(
732 !text.contains("should-not-see"),
733 "parts after the terminal Err must not be forwarded; got: {text}"
734 );
735 }
736
737 struct RecoverableFrameErrorAggregator;
738
739 #[async_trait]
740 impl LanguageModel for RecoverableFrameErrorAggregator {
741 fn provider(&self) -> &str {
742 "mock"
743 }
744
745 fn model_id(&self) -> &str {
746 "agg-recoverable-error"
747 }
748
749 async fn do_generate(&self, _options: &CallOptions) -> Result<GenerateResult, AiMuxError> {
750 unimplemented!()
751 }
752
753 async fn do_stream(&self, _options: &CallOptions) -> Result<StreamResult, AiMuxError> {
754 Ok(StreamResult {
755 stream: Box::pin(stream::iter([
756 Ok(StreamPart::StreamStart { warnings: vec![] }),
757 Err(AiMuxError::JsonParse("malformed SSE data".into())),
758 Ok(StreamPart::TextDelta {
759 id: "t1".into(),
760 delta: "after-error".into(),
761 provider_metadata: None,
762 }),
763 Ok(StreamPart::Finish {
764 finish_reason: FinishReason {
765 unified: FinishReasonUnified::Stop,
766 raw: Some("stop".into()),
767 },
768 usage: Usage::default(),
769 provider_metadata: None,
770 }),
771 ])),
772 request_body: None,
773 response_headers: None,
774 })
775 }
776 }
777
778 #[tokio::test]
779 async fn stream_continues_after_recoverable_aggregator_frame_error() {
780 let refs = vec![mk("ref-a", "A", false, 5)];
781 let moa = MoaModel::new(
782 refs,
783 Arc::new(RecoverableFrameErrorAggregator),
784 MoaConfig::default(),
785 );
786 let result = moa.do_stream(&opts_with_prompt()).await.unwrap();
787 let outcomes: Vec<_> = result.stream.collect().await;
788 let error_index = outcomes
789 .iter()
790 .position(|outcome| matches!(outcome, Err(AiMuxError::JsonParse(_))))
791 .expect("parse error should be relayed");
792
793 assert!(outcomes[error_index + 1..].iter().any(|outcome| matches!(
794 outcome,
795 Ok(StreamPart::TextDelta { delta, .. }) if delta == "after-error"
796 )));
797 assert!(outcomes.iter().any(|outcome| matches!(
798 outcome,
799 Ok(StreamPart::Finish { finish_reason, .. })
800 if finish_reason.unified == FinishReasonUnified::Stop
801 )));
802 }
803
804 fn mk_full(
806 name: &'static str,
807 text: &str,
808 total: u32,
809 cache_read: u32,
810 cache_write: u32,
811 text_tokens: u32,
812 reasoning_tokens: u32,
813 ) -> ChildModel {
814 Arc::new(MockChild {
815 name,
816 text: text.into(),
817 fail: false,
818 usage: Usage {
819 input_tokens: TokenUsage {
820 total: Some(total),
821 cache_read: Some(cache_read),
822 cache_write: Some(cache_write),
823 ..Default::default()
824 },
825 output_tokens: TokenUsage {
826 total: Some(total),
827 text: Some(text_tokens),
828 reasoning: Some(reasoning_tokens),
829 ..Default::default()
830 },
831 raw: None,
832 },
833 retry_failures: 0,
834 calls: Arc::new(AtomicUsize::new(0)),
835 })
836 }
837
838 #[tokio::test]
839 async fn generate_accumulates_per_field_usage() {
840 let refs = vec![
844 mk_full("ref-a", "A", 10, 5, 2, 3, 1),
845 mk_full("ref-b", "B", 20, 7, 4, 6, 2),
846 ];
847 let agg = mk_full("aggregator", "out", 1, 0, 0, 1, 0);
848 let moa = MoaModel::new(refs, agg, MoaConfig::default());
849 let r = moa.do_generate(&opts_with_prompt()).await.unwrap();
850 assert_eq!(r.usage.input_tokens.cache_read, Some(12));
852 assert_eq!(r.usage.input_tokens.cache_write, Some(6));
853 assert_eq!(r.usage.output_tokens.text, Some(10));
855 assert_eq!(r.usage.output_tokens.reasoning, Some(3));
856 }
857
858 #[tokio::test]
859 async fn strip_reference_tools_false_preserves_tools() {
860 let agg = mk("agg", "x", false, 0);
862 let moa = MoaModel::new(
863 vec![],
864 agg,
865 MoaConfig {
866 strip_reference_tools: false,
867 ..Default::default()
868 },
869 );
870 let opts = CallOptions {
871 prompt: vec![],
872 tools: Some(vec![]),
873 tool_choice: ToolChoice::Required,
874 ..Default::default()
875 };
876 let ref_opts = moa.reference_options(&opts);
877 assert_eq!(ref_opts.tools.as_ref().map(std::vec::Vec::len), Some(0));
878 assert_eq!(ref_opts.tool_choice, ToolChoice::Required);
879 }
880
881 #[tokio::test]
882 async fn aggregator_failure_on_generate_propagates() {
883 let refs = vec![mk("ref-a", "A", false, 5)];
886 let agg = mk("aggregator", "out", true, 1);
887 let moa = MoaModel::new(refs, agg, MoaConfig::default());
888 let err = moa.do_generate(&opts_with_prompt()).await.unwrap_err();
889 assert!(err.to_string().contains("aggregator failed"));
890 }
891
892 struct RichStreamAggregator;
895
896 #[async_trait]
897 impl LanguageModel for RichStreamAggregator {
898 fn provider(&self) -> &str {
899 "mock"
900 }
901 fn model_id(&self) -> &str {
902 "rich-agg"
903 }
904 async fn do_generate(&self, _: &CallOptions) -> Result<GenerateResult, AiMuxError> {
905 unimplemented!()
906 }
907 async fn do_stream(&self, _: &CallOptions) -> Result<StreamResult, AiMuxError> {
908 let parts: Vec<Result<StreamPart, AiMuxError>> = vec![
909 Ok(StreamPart::StreamStart { warnings: vec![] }),
910 Ok(StreamPart::TextDelta {
911 id: "t1".into(),
912 delta: "answer".into(),
913 provider_metadata: None,
914 }),
915 Ok(StreamPart::ToolCall {
916 tool_call_id: "tc1".into(),
917 tool_name: "search".into(),
918 input: serde_json::json!({"q": "rust"}),
919 provider_executed: None,
920 dynamic: None,
921 thought_signature: None,
922 invalid: None,
923 error: None,
924 provider_metadata: None,
925 }),
926 Ok(StreamPart::Source {
927 id: "s1".into(),
928 source_type: "url".into(),
929 url: Some("https://example.com".into()),
930 title: Some("Ex".into()),
931 provider_metadata: None,
932 }),
933 Ok(StreamPart::Finish {
934 finish_reason: FinishReason {
935 unified: FinishReasonUnified::Stop,
936 raw: None,
937 },
938 usage: Usage::default(),
939 provider_metadata: None,
940 }),
941 ];
942 Ok(StreamResult {
943 stream: Box::pin(stream::iter(parts)),
944 request_body: None,
945 response_headers: None,
946 })
947 }
948 }
949
950 #[tokio::test]
951 async fn stream_forwards_non_text_parts() {
952 let refs = vec![mk("ref-a", "A", false, 5)];
955 let agg = Arc::new(RichStreamAggregator);
956 let moa = MoaModel::new(refs, agg, MoaConfig::default());
957 let result = moa.do_stream(&opts_with_prompt()).await.unwrap();
958 let parts: Vec<_> = result.stream.collect().await;
959 let mut saw_tool_call = false;
960 let mut saw_source = false;
961 for part in parts {
962 match part.unwrap() {
963 StreamPart::ToolCall { tool_name, .. } => {
964 saw_tool_call = true;
965 assert_eq!(tool_name, "search");
966 }
967 StreamPart::Source { id, .. } => {
968 saw_source = true;
969 assert_eq!(id, "s1");
970 }
971 _ => {}
972 }
973 }
974 assert!(saw_tool_call, "aggregator ToolCall must be forwarded");
975 assert!(saw_source, "aggregator Source must be forwarded");
976 }
977
978 #[tokio::test]
979 async fn nested_router_as_moa_reference() {
980 let router = crate::router::RouterModel::new(
985 vec![
986 mk("router-primary", "router picked primary", true, 0),
987 mk("router-backup", "router fell back", false, 4),
988 ],
989 Box::new(crate::router::RuleRouter),
990 crate::router::FallbackPolicy::OnError,
991 crate::router::RouterConfig::default(),
992 );
993 let router_child: ChildModel = Arc::new(router);
994 let plain_ref = mk("plain-ref", "plain reference", false, 3);
995 let agg = mk("aggregator", "aggregated", false, 1);
996 let moa = MoaModel::new(vec![router_child, plain_ref], agg, MoaConfig::default());
997
998 let r = moa.do_generate(&opts_with_prompt()).await.unwrap();
999 assert_eq!(extract_text(&r.content), "aggregated");
1001 assert_eq!(r.usage.input_tokens.total, Some(8));
1003 }
1004}