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::stream_part::StreamPart;
22use crate::types::{Usage, Warning};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum MoaFailMode {
28 #[default]
30 BestEffort,
31 FailFast,
33}
34
35#[derive(Debug, Clone, Deserialize)]
36pub struct MoaConfig {
37 #[serde(default = "default_moa_provider")]
39 pub provider_name: String,
40 #[serde(default = "default_moa_provider")]
42 pub model_id: String,
43 #[serde(default)]
46 pub aggregator_instructions: Option<String>,
47 #[serde(default = "default_strip_reference_tools")]
50 pub strip_reference_tools: bool,
51 #[serde(default)]
53 pub fail_mode: MoaFailMode,
54}
55
56fn default_moa_provider() -> String {
57 "moa".into()
58}
59
60fn default_strip_reference_tools() -> bool {
61 true
62}
63
64impl Default for MoaConfig {
67 fn default() -> Self {
68 Self {
69 provider_name: "moa".into(),
70 model_id: "moa".into(),
71 aggregator_instructions: None,
72 strip_reference_tools: true,
73 fail_mode: MoaFailMode::BestEffort,
74 }
75 }
76}
77
78pub struct MoaModel {
84 references: Vec<ChildModel>,
85 aggregator: ChildModel,
86 config: MoaConfig,
87}
88
89impl MoaModel {
90 pub fn new(references: Vec<ChildModel>, aggregator: ChildModel, config: MoaConfig) -> Self {
91 Self {
92 references,
93 aggregator,
94 config,
95 }
96 }
97
98 fn reference_options(&self, options: &CallOptions) -> CallOptions {
102 let mut o = options.clone();
103 if self.config.strip_reference_tools {
104 o.tools = None;
105 o.tool_choice = ToolChoice::Auto;
106 }
107 o
108 }
109
110 async fn run_references_nonstream(
116 &self,
117 options: &CallOptions,
118 ) -> Result<(Vec<(String, String)>, Usage, Vec<Warning>), AiMuxError> {
119 if self.references.is_empty() {
120 return Ok((Vec::new(), Usage::default(), Vec::new()));
121 }
122 let ref_opts = self.reference_options(options);
123 let results = join_all(
124 self.references
125 .iter()
126 .map(|m| async { m.do_generate(&ref_opts).await }),
127 )
128 .await;
129
130 let mut usage = Usage::default();
131 let mut warnings = Vec::new();
132 let mut texts: Vec<(String, String)> = Vec::new();
133 for (i, r) in results.into_iter().enumerate() {
134 match r {
135 Ok(res) => {
136 usage = add_usage(usage, &res.usage);
137 let mid = res
138 .response
139 .model_id
140 .clone()
141 .unwrap_or_else(|| format!("ref-{i}"));
142 texts.push((mid, extract_text(&res.content)));
143 }
144 Err(e) => {
145 if self.config.fail_mode == MoaFailMode::FailFast {
146 return Err(e);
147 }
148 warnings.push(Warning::Other {
149 message: format!("moa reference {i} failed: {e}"),
150 });
151 }
152 }
153 }
154 if texts.is_empty() {
155 return Err(AiMuxError::Other("moa: all reference models failed".into()));
156 }
157 Ok((texts, usage, warnings))
158 }
159}
160
161#[async_trait]
162impl LanguageModel for MoaModel {
163 fn provider(&self) -> &str {
164 &self.config.provider_name
165 }
166
167 fn model_id(&self) -> &str {
168 &self.config.model_id
169 }
170
171 async fn do_generate(&self, options: &CallOptions) -> Result<GenerateResult, AiMuxError> {
172 let (texts, ref_usage, warnings) = self.run_references_nonstream(options).await?;
174
175 let agg_prompt = build_aggregator_prompt(
177 &options.prompt,
178 self.config.aggregator_instructions.as_deref(),
179 &texts,
180 );
181 let mut agg_opts = options.clone();
182 agg_opts.prompt = agg_prompt;
183
184 let mut agg = self.aggregator.do_generate(&agg_opts).await?;
186
187 agg.usage = add_usage(agg.usage, &ref_usage);
189 agg.warnings.extend(warnings);
190 Ok(agg)
191 }
192
193 async fn do_stream(&self, options: &CallOptions) -> Result<StreamResult, AiMuxError> {
194 let (texts, ref_usage, drop_warnings) = self.run_references_nonstream(options).await?;
197
198 let agg_prompt = build_aggregator_prompt(
200 &options.prompt,
201 self.config.aggregator_instructions.as_deref(),
202 &texts,
203 );
204 let mut agg_opts = options.clone();
205 agg_opts.prompt = agg_prompt;
206
207 let agg = self.aggregator.do_stream(&agg_opts).await?;
211 let mut agg_stream = agg.stream;
212
213 let stream = async_stream::stream! {
214 yield Ok(StreamPart::StreamStart { warnings: drop_warnings });
215 while let Some(part) = agg_stream.next().await {
216 match part {
217 Ok(StreamPart::StreamStart { .. }) => { }
218 Ok(StreamPart::Finish { finish_reason, usage, provider_metadata }) => {
219 yield Ok(StreamPart::Finish {
220 finish_reason,
221 usage: add_usage(usage, &ref_usage),
222 provider_metadata,
223 });
224 }
225 Ok(other) => yield Ok(other),
226 Err(e) => { yield Err(e); break; }
230 }
231 }
232 };
233
234 Ok(StreamResult {
239 stream: Box::pin(stream),
240 request_body: None,
241 response_headers: None,
242 })
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249 use crate::content::ContentPart;
250 use crate::language_model_message::LanguageModelPromptMessage;
251 use crate::message::Role;
252 use crate::result::GenerateContent;
253 use crate::stream_part::StreamPart;
254 use crate::types::{FinishReason, FinishReasonUnified, TokenUsage};
255 use async_trait::async_trait;
256 use futures::stream;
257 use std::sync::Arc;
258
259 struct MockChild {
262 name: &'static str,
263 text: String,
264 fail: bool,
265 usage: Usage,
266 }
267
268 #[async_trait]
269 impl LanguageModel for MockChild {
270 fn provider(&self) -> &str {
271 "mock"
272 }
273 fn model_id(&self) -> &str {
274 self.name
275 }
276 async fn do_generate(&self, _options: &CallOptions) -> Result<GenerateResult, AiMuxError> {
277 if self.fail {
278 return Err(AiMuxError::Other(format!("{} failed", self.name)));
279 }
280 Ok(GenerateResult {
281 content: vec![GenerateContent::Text {
282 text: self.text.clone(),
283 provider_metadata: None,
284 }],
285 finish_reason: FinishReason {
286 unified: FinishReasonUnified::Stop,
287 raw: None,
288 },
289 usage: self.usage.clone(),
290 warnings: vec![],
291 provider_metadata: None,
292 response: crate::types::ResponseMetadata {
293 model_id: Some(self.name.into()),
294 ..Default::default()
295 },
296 request_body: None,
297 response_headers: None,
298 })
299 }
300 async fn do_stream(&self, _options: &CallOptions) -> Result<StreamResult, AiMuxError> {
301 if self.fail {
302 return Err(AiMuxError::Other(format!("{} failed", self.name)));
303 }
304 let parts: Vec<Result<StreamPart, AiMuxError>> = vec![
305 Ok(StreamPart::StreamStart { warnings: vec![] }),
306 Ok(StreamPart::TextDelta {
307 id: "t1".into(),
308 delta: self.text.clone(),
309 provider_metadata: None,
310 }),
311 Ok(StreamPart::Finish {
312 finish_reason: FinishReason {
313 unified: FinishReasonUnified::Stop,
314 raw: None,
315 },
316 usage: self.usage.clone(),
317 provider_metadata: None,
318 }),
319 ];
320 Ok(StreamResult {
321 stream: Box::pin(stream::iter(parts)),
322 request_body: None,
323 response_headers: None,
324 })
325 }
326 }
327
328 fn mk(name: &'static str, text: &str, fail: bool, total: u32) -> ChildModel {
329 Arc::new(MockChild {
330 name,
331 text: text.into(),
332 fail,
333 usage: Usage {
334 input_tokens: TokenUsage {
335 total: Some(total),
336 ..Default::default()
337 },
338 output_tokens: TokenUsage {
339 total: Some(total),
340 ..Default::default()
341 },
342 raw: None,
343 },
344 })
345 }
346
347 fn opts_with_prompt() -> CallOptions {
348 CallOptions {
349 prompt: vec![LanguageModelPromptMessage {
350 role: Role::User,
351 content: vec![ContentPart::text("What is Rust?")],
352 provider_options: None,
353 }],
354 ..Default::default()
355 }
356 }
357
358 #[tokio::test]
359 async fn generate_fans_out_and_aggregates() {
360 let refs = vec![
361 mk("ref-a", "A says hi", false, 10),
362 mk("ref-b", "B says hi", false, 20),
363 ];
364 let agg = mk("aggregator", "aggregated answer", false, 5);
365 let moa = MoaModel::new(refs, agg, MoaConfig::default());
366
367 let r = moa.do_generate(&opts_with_prompt()).await.unwrap();
368 assert_eq!(extract_text(&r.content), "aggregated answer");
370 assert_eq!(r.usage.input_tokens.total, Some(35));
372 assert_eq!(r.usage.output_tokens.total, Some(35));
373 }
374
375 #[tokio::test]
376 async fn best_effort_drops_failed_reference_and_warns() {
377 let refs = vec![mk("ref-a", "A ok", false, 10), mk("ref-b", "B", true, 0)];
378 let agg = mk("aggregator", "final", false, 1);
379 let moa = MoaModel::new(refs, agg, MoaConfig::default());
380
381 let r = moa.do_generate(&opts_with_prompt()).await.unwrap();
382 assert_eq!(extract_text(&r.content), "final");
383 assert_eq!(r.usage.input_tokens.total, Some(11));
385 assert!(r.warnings.iter().any(|w| matches!(
386 w,
387 Warning::Other { message } if message.contains("reference 1 failed")
388 )));
389 }
390
391 #[tokio::test]
392 async fn fail_fast_propagates_first_reference_error() {
393 let refs = vec![mk("ref-a", "A", true, 0), mk("ref-b", "B ok", false, 10)];
394 let agg = mk("aggregator", "final", false, 1);
395 let moa = MoaModel::new(
396 refs,
397 agg,
398 MoaConfig {
399 fail_mode: MoaFailMode::FailFast,
400 ..Default::default()
401 },
402 );
403 let err = moa.do_generate(&opts_with_prompt()).await.unwrap_err();
404 assert!(err.to_string().contains("ref-a failed"));
405 }
406
407 #[tokio::test]
408 async fn all_references_fail_is_error() {
409 let refs = vec![mk("ref-a", "A", true, 0), mk("ref-b", "B", true, 0)];
410 let agg = mk("aggregator", "final", false, 1);
411 let moa = MoaModel::new(refs, agg, MoaConfig::default());
412 let err = moa.do_generate(&opts_with_prompt()).await.unwrap_err();
413 assert!(err.to_string().contains("all reference models failed"));
414 }
415
416 #[tokio::test]
417 async fn no_references_degrades_to_aggregator() {
418 let agg = mk("aggregator", "solo answer", false, 3);
420 let moa = MoaModel::new(vec![], agg, MoaConfig::default());
421 let r = moa.do_generate(&opts_with_prompt()).await.unwrap();
422 assert_eq!(extract_text(&r.content), "solo answer");
423 assert_eq!(r.usage.input_tokens.total, Some(3));
424 }
425
426 #[tokio::test]
427 async fn stream_runs_references_then_streams_aggregator() {
428 let refs = vec![mk("ref-a", "A", false, 7), mk("ref-b", "B", false, 8)];
429 let agg = mk("aggregator", "streamed", false, 2);
430 let moa = MoaModel::new(refs, agg, MoaConfig::default());
431
432 let result = moa.do_stream(&opts_with_prompt()).await.unwrap();
433 let mut s = result.stream;
434 let mut saw_start = false;
435 let mut text = String::new();
436 let mut finish_usage: Option<Usage> = None;
437 while let Some(part) = s.next().await {
438 match part.unwrap() {
439 StreamPart::StreamStart { .. } => saw_start = true,
440 StreamPart::TextDelta { delta, .. } => text.push_str(&delta),
441 StreamPart::Finish { usage, .. } => finish_usage = Some(usage),
442 _ => {}
443 }
444 }
445 assert!(saw_start);
446 assert_eq!(text, "streamed");
447 assert_eq!(finish_usage.unwrap().input_tokens.total, Some(17));
449 }
450
451 #[tokio::test]
452 async fn stream_best_effort_emits_drop_warning_in_start() {
453 let refs = vec![mk("ref-a", "A", false, 5), mk("ref-b", "B", true, 0)];
454 let agg = mk("aggregator", "out", false, 1);
455 let moa = MoaModel::new(refs, agg, MoaConfig::default());
456
457 let result = moa.do_stream(&opts_with_prompt()).await.unwrap();
458 let mut s = result.stream;
459 match s.next().await.unwrap().unwrap() {
461 StreamPart::StreamStart { warnings } => {
462 assert!(warnings
463 .iter()
464 .any(|w| matches!(w, Warning::Other { message } if message.contains("reference 1 failed"))));
465 }
466 other => panic!("expected StreamStart, got {other:?}"),
467 }
468 }
469
470 #[tokio::test]
471 async fn strip_reference_tools_default_clears_tools() {
472 let agg = mk("agg", "x", false, 0);
474 let moa = MoaModel::new(vec![], agg, MoaConfig::default());
475 let opts = CallOptions {
476 prompt: vec![],
477 tools: Some(vec![]),
478 tool_choice: ToolChoice::Required,
479 ..Default::default()
480 };
481 let ref_opts = moa.reference_options(&opts);
482 assert!(ref_opts.tools.is_none());
483 assert_eq!(ref_opts.tool_choice, ToolChoice::Auto);
484 assert_eq!(opts.tool_choice, ToolChoice::Required);
486 }
487
488 #[tokio::test]
489 async fn no_references_does_not_inject_empty_heading() {
490 let prompt = vec![LanguageModelPromptMessage {
493 role: Role::User,
494 content: vec![ContentPart::text("hello")],
495 provider_options: None,
496 }];
497 let built = build_aggregator_prompt(&prompt, None, &[]);
498 assert_eq!(built.len(), 1);
500 assert!(!built[0].content.iter().any(|p| matches!(
502 p,
503 ContentPart::Text { text, .. } if text.contains("Reference model responses")
504 )));
505 }
506
507 struct MidStreamErrorAggregator;
510
511 #[async_trait]
512 impl LanguageModel for MidStreamErrorAggregator {
513 fn provider(&self) -> &str {
514 "mock"
515 }
516 fn model_id(&self) -> &str {
517 "agg-err"
518 }
519 async fn do_generate(&self, _options: &CallOptions) -> Result<GenerateResult, AiMuxError> {
520 unimplemented!()
521 }
522 async fn do_stream(&self, _options: &CallOptions) -> Result<StreamResult, AiMuxError> {
523 let parts: Vec<Result<StreamPart, AiMuxError>> = vec![
524 Ok(StreamPart::StreamStart { warnings: vec![] }),
525 Ok(StreamPart::TextDelta {
526 id: "t1".into(),
527 delta: "partial".into(),
528 provider_metadata: None,
529 }),
530 Err(AiMuxError::Other("aggregator transport blew up".into())),
532 Ok(StreamPart::TextDelta {
534 id: "t2".into(),
535 delta: "should-not-see".into(),
536 provider_metadata: None,
537 }),
538 ];
539 Ok(StreamResult {
540 stream: Box::pin(stream::iter(parts)),
541 request_body: None,
542 response_headers: None,
543 })
544 }
545 }
546
547 #[tokio::test]
548 async fn stream_relays_aggregator_transport_error_and_stops() {
549 let refs = vec![mk("ref-a", "A", false, 5)];
552 let agg = Arc::new(MidStreamErrorAggregator);
553 let moa = MoaModel::new(refs, agg, MoaConfig::default());
554 let result = moa.do_stream(&opts_with_prompt()).await.unwrap();
555 let parts: Vec<_> = result.stream.collect().await;
556 let mut saw_error = false;
557 let mut text = String::new();
558 for part in parts {
559 match part {
560 Ok(StreamPart::StreamStart { .. }) => {}
561 Ok(StreamPart::TextDelta { delta, .. }) => text.push_str(&delta),
562 Err(e) => {
563 saw_error = true;
564 assert!(e.to_string().contains("blew up"), "got: {e}");
565 }
566 _ => {}
567 }
568 }
569 assert!(
570 saw_error,
571 "expected the aggregator transport error to be relayed"
572 );
573 assert!(
574 !text.contains("should-not-see"),
575 "parts after the terminal Err must not be forwarded; got: {text}"
576 );
577 }
578
579 fn mk_full(
581 name: &'static str,
582 text: &str,
583 total: u32,
584 cache_read: u32,
585 cache_write: u32,
586 text_tokens: u32,
587 reasoning_tokens: u32,
588 ) -> ChildModel {
589 Arc::new(MockChild {
590 name,
591 text: text.into(),
592 fail: false,
593 usage: Usage {
594 input_tokens: TokenUsage {
595 total: Some(total),
596 cache_read: Some(cache_read),
597 cache_write: Some(cache_write),
598 ..Default::default()
599 },
600 output_tokens: TokenUsage {
601 total: Some(total),
602 text: Some(text_tokens),
603 reasoning: Some(reasoning_tokens),
604 ..Default::default()
605 },
606 raw: None,
607 },
608 })
609 }
610
611 #[tokio::test]
612 async fn generate_accumulates_per_field_usage() {
613 let refs = vec![
617 mk_full("ref-a", "A", 10, 5, 2, 3, 1),
618 mk_full("ref-b", "B", 20, 7, 4, 6, 2),
619 ];
620 let agg = mk_full("aggregator", "out", 1, 0, 0, 1, 0);
621 let moa = MoaModel::new(refs, agg, MoaConfig::default());
622 let r = moa.do_generate(&opts_with_prompt()).await.unwrap();
623 assert_eq!(r.usage.input_tokens.cache_read, Some(12));
625 assert_eq!(r.usage.input_tokens.cache_write, Some(6));
626 assert_eq!(r.usage.output_tokens.text, Some(10));
628 assert_eq!(r.usage.output_tokens.reasoning, Some(3));
629 }
630
631 #[tokio::test]
632 async fn strip_reference_tools_false_preserves_tools() {
633 let agg = mk("agg", "x", false, 0);
635 let moa = MoaModel::new(
636 vec![],
637 agg,
638 MoaConfig {
639 strip_reference_tools: false,
640 ..Default::default()
641 },
642 );
643 let opts = CallOptions {
644 prompt: vec![],
645 tools: Some(vec![]),
646 tool_choice: ToolChoice::Required,
647 ..Default::default()
648 };
649 let ref_opts = moa.reference_options(&opts);
650 assert_eq!(ref_opts.tools.as_ref().map(std::vec::Vec::len), Some(0));
651 assert_eq!(ref_opts.tool_choice, ToolChoice::Required);
652 }
653
654 #[tokio::test]
655 async fn aggregator_failure_on_generate_propagates() {
656 let refs = vec![mk("ref-a", "A", false, 5)];
659 let agg = mk("aggregator", "out", true, 1);
660 let moa = MoaModel::new(refs, agg, MoaConfig::default());
661 let err = moa.do_generate(&opts_with_prompt()).await.unwrap_err();
662 assert!(err.to_string().contains("aggregator failed"));
663 }
664
665 struct RichStreamAggregator;
668
669 #[async_trait]
670 impl LanguageModel for RichStreamAggregator {
671 fn provider(&self) -> &str {
672 "mock"
673 }
674 fn model_id(&self) -> &str {
675 "rich-agg"
676 }
677 async fn do_generate(&self, _: &CallOptions) -> Result<GenerateResult, AiMuxError> {
678 unimplemented!()
679 }
680 async fn do_stream(&self, _: &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: "answer".into(),
686 provider_metadata: None,
687 }),
688 Ok(StreamPart::ToolCall {
689 tool_call_id: "tc1".into(),
690 tool_name: "search".into(),
691 input: serde_json::json!({"q": "rust"}),
692 provider_executed: None,
693 dynamic: None,
694 thought_signature: None,
695 provider_metadata: None,
696 }),
697 Ok(StreamPart::Source {
698 id: "s1".into(),
699 source_type: "url".into(),
700 url: Some("https://example.com".into()),
701 title: Some("Ex".into()),
702 provider_metadata: None,
703 }),
704 Ok(StreamPart::Finish {
705 finish_reason: FinishReason {
706 unified: FinishReasonUnified::Stop,
707 raw: None,
708 },
709 usage: Usage::default(),
710 provider_metadata: None,
711 }),
712 ];
713 Ok(StreamResult {
714 stream: Box::pin(stream::iter(parts)),
715 request_body: None,
716 response_headers: None,
717 })
718 }
719 }
720
721 #[tokio::test]
722 async fn stream_forwards_non_text_parts() {
723 let refs = vec![mk("ref-a", "A", false, 5)];
726 let agg = Arc::new(RichStreamAggregator);
727 let moa = MoaModel::new(refs, agg, MoaConfig::default());
728 let result = moa.do_stream(&opts_with_prompt()).await.unwrap();
729 let parts: Vec<_> = result.stream.collect().await;
730 let mut saw_tool_call = false;
731 let mut saw_source = false;
732 for part in parts {
733 match part.unwrap() {
734 StreamPart::ToolCall { tool_name, .. } => {
735 saw_tool_call = true;
736 assert_eq!(tool_name, "search");
737 }
738 StreamPart::Source { id, .. } => {
739 saw_source = true;
740 assert_eq!(id, "s1");
741 }
742 _ => {}
743 }
744 }
745 assert!(saw_tool_call, "aggregator ToolCall must be forwarded");
746 assert!(saw_source, "aggregator Source must be forwarded");
747 }
748
749 #[tokio::test]
750 async fn nested_router_as_moa_reference() {
751 let router = crate::router::RouterModel::new(
756 vec![
757 mk("router-primary", "router picked primary", true, 0),
758 mk("router-backup", "router fell back", false, 4),
759 ],
760 Box::new(crate::router::RuleRouter),
761 crate::router::FallbackPolicy::OnError,
762 crate::router::RouterConfig::default(),
763 );
764 let router_child: ChildModel = Arc::new(router);
765 let plain_ref = mk("plain-ref", "plain reference", false, 3);
766 let agg = mk("aggregator", "aggregated", false, 1);
767 let moa = MoaModel::new(vec![router_child, plain_ref], agg, MoaConfig::default());
768
769 let r = moa.do_generate(&opts_with_prompt()).await.unwrap();
770 assert_eq!(extract_text(&r.content), "aggregated");
772 assert_eq!(r.usage.input_tokens.total, Some(8));
774 }
775}