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::retry;
22use crate::stream_part::StreamPart;
23use crate::types::{Usage, Warning};
24
25/// Reference-failure policy.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum MoaFailMode {
29    /// Drop a failed reference + emit a `Warning::Other`; keep going (default).
30    #[default]
31    BestEffort,
32    /// Fail the whole call as soon as any reference errors.
33    FailFast,
34}
35
36#[derive(Debug, Clone, Deserialize)]
37pub struct MoaConfig {
38    /// `provider()` value (default `"moa"`).
39    #[serde(default = "default_moa_provider")]
40    pub provider_name: String,
41    /// `model_id()` value (default `"moa"`).
42    #[serde(default = "default_moa_provider")]
43    pub model_id: String,
44    /// Optional aggregator system instruction prepended to the reference user
45    /// message. `None` uses a built-in default.
46    #[serde(default)]
47    pub aggregator_instructions: Option<String>,
48    /// Strip `tools` / `tool_choice` from reference calls so references stay
49    /// cheap (Hermes: references don't carry tool schemas). Default `true`.
50    #[serde(default = "default_strip_reference_tools")]
51    pub strip_reference_tools: bool,
52    /// Reference-failure policy.
53    #[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
65// Manual `Default`: `strip_reference_tools` must default to `true` (a derived
66// Default would give `false`, contradicting the documented behavior).
67impl 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
79/// Mixture-of-Agents single-fanout aggregation model.
80///
81/// References fan out in parallel (non-streaming) → outputs are spliced into
82/// the aggregator prompt → the aggregator produces the final result. One
83/// `generate_text` / `stream_text` call, no agent loop.
84pub 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    /// Build reference call options from the user's options. When
100    /// `strip_reference_tools` is set, `tools` is cleared and `tool_choice` is
101    /// reset to `Auto` so references don't carry tool schemas.
102    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    /// Fan out references in parallel (non-streaming), accumulate usage, and
112    /// collect `(model_id, text)` for the successful ones. Failures are handled
113    /// per `fail_mode`. Returns the reference texts, accumulated usage, and any
114    /// drop warnings. Errors if all references fail (and references were
115    /// configured).
116    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        // Retrying the composite would rerun the entire reference fanout;
188        // references and aggregator are retried independently instead.
189        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        // 1. Fan out references (non-streaming).
197        let (texts, ref_usage, warnings) = self.run_references_nonstream(options).await?;
198
199        // 2. Build the aggregator prompt + options.
200        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        // 3. Run the aggregator.
209        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        // 4. Fold reference usage + drop warnings into the aggregator result.
229        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        // 1. Fan out references (non-streaming, blocking until done — MoA
236        //    inherent latency). Errors surface as `Err` from `do_stream`.
237        let (texts, ref_usage, drop_warnings) = self.run_references_nonstream(options).await?;
238
239        // 2. Aggregator prompt + options.
240        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        // 3. Aggregator streams; we emit our own StreamStart and add reference
249        //    usage onto its Finish. We swallow the aggregator's StreamStart
250        //    (we've already emitted ours).
251        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 { .. }) => { /* swallow */ }
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                            // A transport/Core failure is terminal. Retrying or
289                            // replaying the synthesized aggregator request after
290                            // output has escaped would duplicate visible data.
291                            break;
292                        }
293                    }
294                }
295            }
296        };
297
298        // RFC-0022 §3.4: return None for request_body/response_headers. The
299        // aggregator's request body is a synthesized prompt (references
300        // spliced in), not the user's original — exposing it would mislead
301        // cache probing (RFC-0015 fingerprinting).
302        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    /// A mock child that returns fixed text with fixed usage. `fail` forces an
325    /// error. Used as both reference and aggregator.
326    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        // Aggregator text wins as the content.
481        assert_eq!(extract_text(&r.content), "aggregated answer");
482        // usage = references(10 + 20) + aggregator(5) = 35 on each side.
483        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        // Failed reference still contributed its (zero) usage; only ref-a (10) + agg (1).
496        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        // With 0 references, aggregator runs the original prompt directly.
531        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        // references(7 + 8) + aggregator(2) = 17 on each side.
560        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        // First item is our StreamStart, which should carry the drop warning.
618        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        // Verify the helper clears tools/tool_choice when configured (default).
631        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        // The original options are untouched (clone, not mutate).
643        assert_eq!(opts.tool_choice, ToolChoice::Required);
644    }
645
646    #[tokio::test]
647    async fn no_references_does_not_inject_empty_heading() {
648        // S4 regression guard: with 0 references the aggregator prompt is the
649        // original prompt verbatim — no "# Reference model responses" heading.
650        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        // Still just the single original message; nothing appended.
657        assert_eq!(built.len(), 1);
658        // Heading must NOT appear.
659        assert!(!built[0].content.iter().any(|p| matches!(
660            p,
661            ContentPart::Text { text, .. } if text.contains("Reference model responses")
662        )));
663    }
664
665    /// A mock aggregator whose stream yields TextDelta then a transport Err,
666    /// then (misbehaving) more parts. Guards that MoA relays the Err and stops.
667    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                // A transport error mid-stream.
689                Err(AiMuxError::Other("aggregator transport blew up".into())),
690                // A misbehaving aggregator that keeps going after the Err.
691                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        // N3 regression guard: a mid-stream transport Err from the aggregator
708        // is relayed and the stream terminates — no subsequent parts forwarded.
709        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    /// Like `mk` but with full per-field usage (for G4 accumulation testing).
805    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        // G4: add_usage must sum cache_read/cache_write/text/reasoning, not
841        // just total. ref-a: cache_read=5,cache_write=2,text=3,reasoning=1;
842        // ref-b: cache_read=7,cache_write=4,text=6,reasoning=2; agg totals=1.
843        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        // input: cache_read 5+7+0=12, cache_write 2+4+0=6
851        assert_eq!(r.usage.input_tokens.cache_read, Some(12));
852        assert_eq!(r.usage.input_tokens.cache_write, Some(6));
853        // output: text 3+6+1=10, reasoning 1+2+0=3
854        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        // G5: strip_reference_tools=false keeps tools/tool_choice on references.
861        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        // G11: if the aggregator's do_generate fails, the error surfaces
884        // (references already ran successfully).
885        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    /// A mock aggregator whose stream emits non-text parts (ToolCall, Source,
893    /// Reasoning) so we can verify they're forwarded by MoA's do_stream.
894    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        // G10: ToolCall/Source deltas from the aggregator must pass through
953        // (not just TextDelta).
954        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        // G8: composability — a RouterModel can be used as a MoaModel reference
981        // (both are Arc<dyn LanguageModel>). RFC-0022 §6.2 explicitly says this
982        // is supported and "needs testing". The router's fallback should run
983        // inside the MoA fanout, and its output feeds the aggregator.
984        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        // Aggregator output wins as content.
1000        assert_eq!(extract_text(&r.content), "aggregated");
1001        // Usage: router(fell back to backup=4) + plain_ref(3) + aggregator(1) = 8.
1002        assert_eq!(r.usage.input_tokens.total, Some(8));
1003    }
1004}