Skip to main content

aimux_core/
moa.rs

1//! `MoaModel` — Mixture-of-Agents single-fanout aggregation (RFC-0022).
2//!
3//! Reference models run in parallel (non-streaming) → their text outputs are
4//! spliced into the aggregator prompt → the aggregator runs and its output is
5//! returned. This all happens inside a single `do_generate` / `do_stream`,
6//! with **no agent loop**. `MoaModel` implements [`LanguageModel`], so it drops
7//! into `generate_text` / `stream_text` and every binding unchanged.
8//!
9//! See [`crate::composite`] for the shared skeleton (`ChildModel`, `add_usage`,
10//! `build_aggregator_prompt`).
11
12use 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/// Reference-failure policy.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum MoaFailMode {
28    /// Drop a failed reference + emit a `Warning::Other`; keep going (default).
29    #[default]
30    BestEffort,
31    /// Fail the whole call as soon as any reference errors.
32    FailFast,
33}
34
35#[derive(Debug, Clone, Deserialize)]
36pub struct MoaConfig {
37    /// `provider()` value (default `"moa"`).
38    #[serde(default = "default_moa_provider")]
39    pub provider_name: String,
40    /// `model_id()` value (default `"moa"`).
41    #[serde(default = "default_moa_provider")]
42    pub model_id: String,
43    /// Optional aggregator system instruction prepended to the reference user
44    /// message. `None` uses a built-in default.
45    #[serde(default)]
46    pub aggregator_instructions: Option<String>,
47    /// Strip `tools` / `tool_choice` from reference calls so references stay
48    /// cheap (Hermes: references don't carry tool schemas). Default `true`.
49    #[serde(default = "default_strip_reference_tools")]
50    pub strip_reference_tools: bool,
51    /// Reference-failure policy.
52    #[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
64// Manual `Default`: `strip_reference_tools` must default to `true` (a derived
65// Default would give `false`, contradicting the documented behavior).
66impl 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
78/// Mixture-of-Agents single-fanout aggregation model.
79///
80/// References fan out in parallel (non-streaming) → outputs are spliced into
81/// the aggregator prompt → the aggregator produces the final result. One
82/// `generate_text` / `stream_text` call, no agent loop.
83pub 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    /// Build reference call options from the user's options. When
99    /// `strip_reference_tools` is set, `tools` is cleared and `tool_choice` is
100    /// reset to `Auto` so references don't carry tool schemas.
101    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    /// Fan out references in parallel (non-streaming), accumulate usage, and
111    /// collect `(model_id, text)` for the successful ones. Failures are handled
112    /// per `fail_mode`. Returns the reference texts, accumulated usage, and any
113    /// drop warnings. Errors if all references fail (and references were
114    /// configured).
115    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        // 1. Fan out references (non-streaming).
173        let (texts, ref_usage, warnings) = self.run_references_nonstream(options).await?;
174
175        // 2. Build the aggregator prompt + options.
176        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        // 3. Run the aggregator.
185        let mut agg = self.aggregator.do_generate(&agg_opts).await?;
186
187        // 4. Fold reference usage + drop warnings into the aggregator result.
188        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        // 1. Fan out references (non-streaming, blocking until done — MoA
195        //    inherent latency). Errors surface as `Err` from `do_stream`.
196        let (texts, ref_usage, drop_warnings) = self.run_references_nonstream(options).await?;
197
198        // 2. Aggregator prompt + options.
199        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        // 3. Aggregator streams; we emit our own StreamStart and add reference
208        //    usage onto its Finish. We swallow the aggregator's StreamStart
209        //    (we've already emitted ours).
210        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 { .. }) => { /* swallow */ }
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                    // Stream errors are terminal — relay and stop (the user's
227                    // original request was NOT the aggregator body MoA
228                    // synthesized, so relaying more would be noise).
229                    Err(e) => { yield Err(e); break; }
230                }
231            }
232        };
233
234        // RFC-0022 §3.4: return None for request_body/response_headers. The
235        // aggregator's request body is a synthesized prompt (references
236        // spliced in), not the user's original — exposing it would mislead
237        // cache probing (RFC-0015 fingerprinting).
238        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    /// A mock child that returns fixed text with fixed usage. `fail` forces an
260    /// error. Used as both reference and aggregator.
261    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        // Aggregator text wins as the content.
369        assert_eq!(extract_text(&r.content), "aggregated answer");
370        // usage = references(10 + 20) + aggregator(5) = 35 on each side.
371        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        // Failed reference still contributed its (zero) usage; only ref-a (10) + agg (1).
384        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        // With 0 references, aggregator runs the original prompt directly.
419        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        // references(7 + 8) + aggregator(2) = 17 on each side.
448        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        // First item is our StreamStart, which should carry the drop warning.
460        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        // Verify the helper clears tools/tool_choice when configured (default).
473        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        // The original options are untouched (clone, not mutate).
485        assert_eq!(opts.tool_choice, ToolChoice::Required);
486    }
487
488    #[tokio::test]
489    async fn no_references_does_not_inject_empty_heading() {
490        // S4 regression guard: with 0 references the aggregator prompt is the
491        // original prompt verbatim — no "# Reference model responses" heading.
492        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        // Still just the single original message; nothing appended.
499        assert_eq!(built.len(), 1);
500        // Heading must NOT appear.
501        assert!(!built[0].content.iter().any(|p| matches!(
502            p,
503            ContentPart::Text { text, .. } if text.contains("Reference model responses")
504        )));
505    }
506
507    /// A mock aggregator whose stream yields TextDelta then a transport Err,
508    /// then (misbehaving) more parts. Guards that MoA relays the Err and stops.
509    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                // A transport error mid-stream.
531                Err(AiMuxError::Other("aggregator transport blew up".into())),
532                // A misbehaving aggregator that keeps going after the Err.
533                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        // N3 regression guard: a mid-stream transport Err from the aggregator
550        // is relayed and the stream terminates — no subsequent parts forwarded.
551        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    /// Like `mk` but with full per-field usage (for G4 accumulation testing).
580    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        // G4: add_usage must sum cache_read/cache_write/text/reasoning, not
614        // just total. ref-a: cache_read=5,cache_write=2,text=3,reasoning=1;
615        // ref-b: cache_read=7,cache_write=4,text=6,reasoning=2; agg totals=1.
616        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        // input: cache_read 5+7+0=12, cache_write 2+4+0=6
624        assert_eq!(r.usage.input_tokens.cache_read, Some(12));
625        assert_eq!(r.usage.input_tokens.cache_write, Some(6));
626        // output: text 3+6+1=10, reasoning 1+2+0=3
627        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        // G5: strip_reference_tools=false keeps tools/tool_choice on references.
634        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        // G11: if the aggregator's do_generate fails, the error surfaces
657        // (references already ran successfully).
658        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    /// A mock aggregator whose stream emits non-text parts (ToolCall, Source,
666    /// Reasoning) so we can verify they're forwarded by MoA's do_stream.
667    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        // G10: ToolCall/Source deltas from the aggregator must pass through
724        // (not just TextDelta).
725        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        // G8: composability — a RouterModel can be used as a MoaModel reference
752        // (both are Arc<dyn LanguageModel>). RFC-0022 §6.2 explicitly says this
753        // is supported and "needs testing". The router's fallback should run
754        // inside the MoA fanout, and its output feeds the aggregator.
755        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        // Aggregator output wins as content.
771        assert_eq!(extract_text(&r.content), "aggregated");
772        // Usage: router(fell back to backup=4) + plain_ref(3) + aggregator(1) = 8.
773        assert_eq!(r.usage.input_tokens.total, Some(8));
774    }
775}