Skip to main content

mesh_llm_moa_plugin/
lib.rs

1use mesh_llm_plugin::{
2    HostInferenceRequest, MeshVisibility, PluginContext, PluginMetadata, PluginRuntime,
3    SimplePlugin, VirtualModelInvocation, VirtualModelResponse, VirtualModelRouter,
4    operation_with_schema, plugin_server_info, structured_tool_result, virtual_model,
5};
6use mesh_mixture_of_agents as moa;
7use serde_json::{Value, json};
8use std::sync::Arc;
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::time::Duration;
11use tokio::time::Instant;
12
13pub const PLUGIN_ID: &str = "mesh-moa";
14pub const HANDLER: &str = "chat";
15
16/// Above this verified parameter count a worker is in the capable tier used
17/// by the MoA engine. Unknown sizes are deliberately treated as small for
18/// destructive pool decisions: a label is not evidence that a model is big.
19const SMALL_TIER_MAX_B: f64 = moa::SMALL_TIER_MAX_B;
20
21/// Measured quality is flat beyond four capable workers while fan-out cost is
22/// roughly `2N+1` calls. Keep the shared mesh from being consumed by one turn.
23const COMMITTEE_CAP: usize = 4;
24
25/// Lines the host drips onto a streaming caller while the committee works.
26/// Played once in order, then the last line repeats. See
27/// `VirtualModelBuilder::progress_lines`.
28pub const PROGRESS_LINES: [&str; 6] = [
29    "Routing through mesh…",
30    "Querying peer models…",
31    "Comparing responses…",
32    "Waiting on a slow peer…",
33    "Still gathering responses…",
34    "Hold on, this one's taking a moment…",
35];
36
37pub async fn run(stream: mesh_llm_plugin::LocalStream) -> anyhow::Result<()> {
38    PluginRuntime::run_with_stream(plugin(), stream).await
39}
40
41/// How long a turn waits for better answers before shipping what it has.
42struct PatienceProfile {
43    first_answer_grace: Duration,
44    strong_patience: Duration,
45}
46
47/// Timing profile for the turn, tightened on a public mesh.
48///
49/// A public mesh is a pathological *availability* case (unknown peers, wider
50/// latency spread, more churn), not a trust case. Both knobs here are "how long
51/// do we hold a usable answer hoping for a better one" — exactly the wait that
52/// hurts most when the tail is long. The hard bounds are unchanged; only the
53/// optional waiting shrinks, so quality paths still run when peers are prompt.
54///
55/// The host used to choose this per turn from `node.public_mesh`; it now hands
56/// the plugin the same signal as `mesh_visibility` at init, so the policy stays
57/// identical.
58fn patience_profile(public_mesh: bool) -> PatienceProfile {
59    if public_mesh {
60        PatienceProfile {
61            // Ship a good answer sooner rather than wait out a long tail.
62            first_answer_grace: Duration::from_millis(1500),
63            // Still give a strong peer a real chance, but don't hold a usable
64            // small-tier answer for 20s against an unknown remote worker.
65            strong_patience: Duration::from_secs(8),
66        }
67    } else {
68        PatienceProfile {
69            first_answer_grace: Duration::from_secs(10),
70            strong_patience: Duration::from_secs(20),
71        }
72    }
73}
74
75pub fn plugin() -> SimplePlugin {
76    let public_mesh = Arc::new(AtomicBool::new(false));
77    let manifest = mesh_llm_plugin::plugin_manifest![
78        virtual_model(moa::VIRTUAL_MODEL_NAME, HANDLER)
79            .supports_tools(true)
80            .supports_streaming(true)
81            .requires_candidates(true)
82            .input_modalities(["text", "image", "audio"])
83            // The host drips these onto a streaming caller while the committee
84            // works, so the client's thinking pane shows live activity instead
85            // of a stalled spinner. Played once in order, then the last line
86            // repeats. Short, factual, and grounded in what the mesh is
87            // actually doing — not invented model "thoughts".
88            .progress_lines(PROGRESS_LINES)
89    ];
90    let mut router = VirtualModelRouter::new();
91    let turn_public_mesh = Arc::clone(&public_mesh);
92    router.add_raw(
93        operation_with_schema(HANDLER, "Run a Mesh MoA turn", serde_json::Map::new()),
94        move |request, context| {
95            let context = context.owned();
96            let public_mesh = Arc::clone(&turn_public_mesh);
97            Box::pin(async move {
98                let invocation: VirtualModelInvocation = request.arguments()?;
99                let response =
100                    handle(invocation, context, public_mesh.load(Ordering::Relaxed)).await;
101                structured_tool_result(response)
102            })
103        },
104    );
105    SimplePlugin::new(PluginMetadata::new(
106        PLUGIN_ID,
107        env!("CARGO_PKG_VERSION"),
108        plugin_server_info(
109            PLUGIN_ID,
110            env!("CARGO_PKG_VERSION"),
111            "Mesh MoA",
112            "Built-in mixture-of-agents virtual model",
113            None::<String>,
114        ),
115    ))
116    .with_manifest(manifest)
117    .with_virtual_model_router(router)
118    .on_initialize(move |request, _context| {
119        public_mesh.store(
120            request.mesh_visibility == MeshVisibility::Public,
121            Ordering::Relaxed,
122        );
123        Box::pin(async { Ok(()) })
124    })
125}
126
127async fn handle(
128    mut invocation: VirtualModelInvocation,
129    context: PluginContext<'static>,
130    public_mesh: bool,
131) -> VirtualModelResponse {
132    // The MoA admission contract applied to every chat request before the
133    // plugin owned `mesh`: `messages` must be a present, non-empty array.
134    // `handle_turn` builds an empty session from those shapes and still
135    // returns a worker result or a 502, so a malformed request must be
136    // rejected here — ahead of both the committee and the single-candidate
137    // passthrough.
138    if let Some(message) = chat_request_rejection(&invocation.request) {
139        return error_response(400, message);
140    }
141    if invocation.candidates.is_empty() {
142        return error_response(503, "no concrete models available in the mesh");
143    }
144    let requested_stream = invocation
145        .request
146        .get("stream")
147        .and_then(Value::as_bool)
148        .unwrap_or(false);
149    if let Some(object) = invocation.request.as_object_mut() {
150        object.remove("stream");
151    }
152
153    let (needs_vision, needs_audio) = match select_request_candidates(&mut invocation) {
154        Ok(media) => media,
155        Err(response) => return response,
156    };
157    // Media always takes the direct path, so its eligible standbys should not
158    // be discarded by a cap that only exists for committee fan-out.
159    let all_small = if needs_vision || needs_audio {
160        invocation.candidates.sort_by(candidate_rank);
161        false
162    } else {
163        apply_pool_policy(&mut invocation.candidates)
164    };
165    // A one-model pool has nothing to aggregate. Passing it through the MoA
166    // engine would replace the caller's output budget with the Generalist
167    // worker budget (1,024 tokens), which can make an otherwise valid request
168    // ineligible for a small-context target before inference even starts.
169    // Keep placement and admission host-governed, but preserve the original
170    // request shape when there is no actual committee to convene.
171    if all_small || invocation.candidates.len() == 1 || needs_vision || needs_audio {
172        return direct_capability_response(invocation, context, requested_stream).await;
173    }
174
175    let mut backends: Vec<Arc<dyn moa::ModelBackend>> = Vec::new();
176    let mut models = Vec::new();
177    for candidate in invocation.candidates {
178        let index = backends.len();
179        backends.push(Arc::new(HostBackend {
180            context: context.clone(),
181            target_node_id: candidate.target_node_id.clone(),
182        }));
183        models.push(
184            moa::ModelEntry::new(candidate.model_id, index)
185                .with_parameter_count_b(candidate.parameter_count_b),
186        );
187    }
188    let actor_candidates = actor_candidates(&models);
189    let patience = patience_profile(public_mesh);
190    let config = moa::GatewayConfig {
191        backends,
192        models,
193        worker_timeout: Duration::from_secs(60),
194        reducer_timeout: Duration::from_secs(60),
195        hedge_delay: Duration::from_secs(5),
196        first_answer_grace: patience.first_answer_grace,
197        strong_patience: patience.strong_patience,
198        enable_thinking: Some(false),
199        actor_candidates,
200        reference_policy: moa::ReferencePolicy::Auto,
201        refinement_policy: moa::RefinementPolicy::Auto,
202    };
203    let mut result = moa::handle_turn(&config, &invocation.request).await;
204    strip_response_thinking(&mut result.response_body);
205    let workers_ok = result
206        .worker_summaries
207        .iter()
208        .filter(|w| w.succeeded)
209        .count();
210    let headers = vec![
211        ("x-moa-elapsed-ms".into(), result.elapsed_ms.to_string()),
212        ("x-moa-turn".into(), result.turn_kind.label().to_string()),
213        (
214            "x-moa-workers".into(),
215            result.worker_summaries.len().to_string(),
216        ),
217        ("x-moa-workers-ok".into(), workers_ok.to_string()),
218        ("x-moa-reducer".into(), result.reducer_used.to_string()),
219        (
220            "x-moa-reducer-attempts".into(),
221            result.reducer_attempts.to_string(),
222        ),
223    ];
224    VirtualModelResponse {
225        status_code: if is_failure_body(&result.response_body) {
226            502
227        } else {
228            200
229        },
230        body: result.response_body,
231        headers,
232        event_stream: requested_stream,
233    }
234}
235
236/// Media capabilities are runtime requirements; missing tool evidence is not
237/// proof that a model cannot call tools. Prefer advertised tool support within
238/// the media-eligible pool, but keep that pool when no member advertises it.
239fn select_request_candidates(
240    invocation: &mut VirtualModelInvocation,
241) -> Result<(bool, bool), VirtualModelResponse> {
242    let needs_vision = contains_any_key(&invocation.request, &["image_url", "input_image"]);
243    let needs_audio = contains_any_key(
244        &invocation.request,
245        &["audio_url", "input_audio", "input_audio_buffer"],
246    );
247    let needs_tools = invocation.request.get("tools").is_some_and(|tools| {
248        !tools.is_null() && tools.as_array().is_none_or(|items| !items.is_empty())
249    });
250    invocation.candidates.retain(|candidate| {
251        (!needs_vision || candidate.supports_vision) && (!needs_audio || candidate.supports_audio)
252    });
253    if invocation.candidates.is_empty() {
254        return Err(error_response(
255            422,
256            "no concrete model satisfies the request capabilities",
257        ));
258    }
259    if needs_tools
260        && invocation
261            .candidates
262            .iter()
263            .any(|candidate| candidate.supports_tools)
264    {
265        invocation
266            .candidates
267            .retain(|candidate| candidate.supports_tools);
268    }
269    Ok((needs_vision, needs_audio))
270}
271
272async fn direct_capability_response(
273    invocation: VirtualModelInvocation,
274    context: PluginContext<'static>,
275    requested_stream: bool,
276) -> VirtualModelResponse {
277    direct_with_fallback(
278        invocation,
279        requested_stream,
280        Duration::from_secs(60),
281        |request| context.infer(request),
282    )
283    .await
284}
285
286async fn direct_with_fallback<F, Fut>(
287    mut invocation: VirtualModelInvocation,
288    requested_stream: bool,
289    budget: Duration,
290    mut infer: F,
291) -> VirtualModelResponse
292where
293    F: FnMut(HostInferenceRequest) -> Fut,
294    Fut: std::future::Future<Output = anyhow::Result<mesh_llm_plugin::HostInferenceResponse>>,
295{
296    invocation.candidates.sort_by(candidate_rank);
297    let deadline = Instant::now() + budget;
298    let mut last_failure = None;
299    for candidate in &invocation.candidates {
300        let remaining = deadline.saturating_duration_since(Instant::now());
301        if remaining.is_zero() {
302            break;
303        }
304        let request = HostInferenceRequest {
305            model_id: candidate.model_id.clone(),
306            target_node_id: candidate.target_node_id.clone(),
307            request: invocation.request.clone(),
308            timeout_ms: Some(remaining.as_millis().min(u64::MAX as u128) as u64),
309        };
310        let result = tokio::time::timeout(remaining, infer(request)).await;
311        let response = match result {
312            Ok(Ok(response)) => response,
313            Ok(Err(error)) => {
314                last_failure = Some(error_response(
315                    502,
316                    &format!("capability route failed: {error}"),
317                ));
318                continue;
319            }
320            Err(_) => {
321                last_failure = Some(error_response(504, "capability route timed out"));
322                break;
323            }
324        };
325        if matches!(response.status_code, 502..=504) {
326            last_failure = Some(direct_response(response, requested_stream));
327            continue;
328        }
329        return direct_response(response, requested_stream);
330    }
331    last_failure.unwrap_or_else(|| error_response(504, "capability route timed out"))
332}
333
334fn direct_response(
335    mut response: mesh_llm_plugin::HostInferenceResponse,
336    requested_stream: bool,
337) -> VirtualModelResponse {
338    normalize_direct_response_body(&mut response.body);
339    VirtualModelResponse {
340        status_code: response.status_code,
341        body: response.body,
342        headers: response
343            .served_by
344            .map(|served_by| vec![("x-mesh-served-by".into(), served_by)])
345            .unwrap_or_default(),
346        event_stream: requested_stream,
347    }
348}
349
350/// The chat contract MoA shares with host ingress: `messages` must be a
351/// present, non-empty array. Returns the client-facing message when it is not.
352fn chat_request_rejection(request: &Value) -> Option<&'static str> {
353    match request.get("messages") {
354        Some(Value::Array(messages)) if !messages.is_empty() => None,
355        _ => Some("MoA requires a non-empty `messages` array"),
356    }
357}
358
359fn contains_any_key(value: &Value, keys: &[&str]) -> bool {
360    match value {
361        Value::Object(object) => object
362            .iter()
363            .any(|(key, value)| keys.contains(&key.as_str()) || contains_any_key(value, keys)),
364        Value::Array(values) => values.iter().any(|value| contains_any_key(value, keys)),
365        _ => false,
366    }
367}
368
369fn is_failure_body(body: &Value) -> bool {
370    body.get("error").is_some()
371        || body
372            .pointer("/choices/0/finish_reason")
373            .and_then(Value::as_str)
374            == Some("error")
375}
376
377fn strip_response_thinking(body: &mut Value) {
378    let Some(content) = body
379        .pointer_mut("/choices/0/message/content")
380        .and_then(|value| value.as_str())
381        .map(moa::strip_thinking)
382    else {
383        return;
384    };
385    body["choices"][0]["message"]["content"] = Value::String(content);
386}
387
388fn normalize_direct_response_body(body: &mut Value) {
389    strip_response_thinking(body);
390    if body.is_object() {
391        body["model"] = Value::String(moa::VIRTUAL_MODEL_NAME.into());
392    }
393}
394
395fn actor_candidates(models: &[moa::ModelEntry]) -> Vec<usize> {
396    let mut indices = (0..models.len()).collect::<Vec<_>>();
397    indices.sort_by(|left, right| {
398        models[*right]
399            .parameter_count_b
400            .partial_cmp(&models[*left].parameter_count_b)
401            .unwrap_or(std::cmp::Ordering::Equal)
402    });
403    indices
404}
405
406/// Restore the measured gateway admission rules before the engine assigns
407/// roles. An all-small pool regressed against its best member, so it becomes a
408/// direct route; any real committee is capped to bound shared-mesh cost.
409fn apply_pool_policy(candidates: &mut Vec<mesh_llm_plugin::VirtualModelCandidate>) -> bool {
410    candidates.sort_by(candidate_rank);
411    let all_small = candidates.iter().all(|candidate| {
412        candidate
413            .parameter_count_b
414            .is_none_or(|size| size < SMALL_TIER_MAX_B)
415    });
416    if all_small {
417        // One answer, but keep eligible physical standbys for retryable
418        // placement failures after the candidate snapshot.
419    } else {
420        candidates.truncate(COMMITTEE_CAP);
421    }
422    all_small
423}
424
425fn candidate_rank(
426    left: &mesh_llm_plugin::VirtualModelCandidate,
427    right: &mesh_llm_plugin::VirtualModelCandidate,
428) -> std::cmp::Ordering {
429    left.deprioritized
430        .cmp(&right.deprioritized)
431        .then_with(|| {
432            right
433                .parameter_count_b
434                .partial_cmp(&left.parameter_count_b)
435                .unwrap_or(std::cmp::Ordering::Equal)
436        })
437        .then_with(|| left.model_id.cmp(&right.model_id))
438        .then_with(|| left.target_node_id.cmp(&right.target_node_id))
439}
440
441fn error_response(status_code: u16, message: &str) -> VirtualModelResponse {
442    VirtualModelResponse {
443        status_code,
444        body: json!({"error": {"message": message, "type": "virtual_model_error"}}),
445        headers: Vec::new(),
446        event_stream: false,
447    }
448}
449
450struct HostBackend {
451    context: PluginContext<'static>,
452    target_node_id: Option<String>,
453}
454
455#[async_trait::async_trait]
456impl moa::ModelBackend for HostBackend {
457    async fn chat_completion(
458        &self,
459        model: &str,
460        messages: &[Value],
461        tools: Option<&Value>,
462        max_tokens: u32,
463        timeout: Duration,
464        sampling: moa::SamplingParams,
465    ) -> Result<Value, String> {
466        let mut request = json!({
467            "model": model,
468            "messages": messages,
469            "max_tokens": max_tokens,
470            "temperature": sampling.temperature,
471            "top_p": sampling.top_p,
472            "stream": false,
473        });
474        if let Some(tools) = tools {
475            request["tools"] = tools.clone();
476        }
477        moa::apply_enable_thinking(&mut request, sampling.enable_thinking);
478        let response = self
479            .context
480            .infer(HostInferenceRequest {
481                model_id: model.to_string(),
482                target_node_id: self.target_node_id.clone(),
483                request,
484                timeout_ms: Some(timeout.as_millis().min(u64::MAX as u128) as u64),
485            })
486            .await
487            .map_err(|error| error.to_string())?;
488        if !(200..300).contains(&response.status_code) {
489            return Err(format!("HTTP {}: {}", response.status_code, response.body));
490        }
491        Ok(response.body)
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use mesh_llm_plugin::Plugin;
499
500    fn candidate(
501        model_id: &str,
502        parameter_count_b: Option<f64>,
503        target_node_id: &str,
504    ) -> mesh_llm_plugin::VirtualModelCandidate {
505        mesh_llm_plugin::VirtualModelCandidate {
506            model_id: model_id.into(),
507            target_node_id: Some(target_node_id.into()),
508            parameter_count_b,
509            context_length: Some(32_768),
510            deprioritized: false,
511            supports_tools: true,
512            supports_vision: false,
513            supports_audio: false,
514        }
515    }
516
517    #[test]
518    fn plugin_declares_mesh_virtual_model() {
519        let manifest = plugin().manifest().expect("manifest");
520        assert_eq!(manifest.virtual_models.len(), 1);
521        assert_eq!(manifest.virtual_models[0].model_id, "mesh");
522        assert_eq!(manifest.virtual_models[0].handler, HANDLER);
523        assert_eq!(
524            manifest.virtual_models[0].input_modalities,
525            ["text", "image", "audio"]
526        );
527        assert!(manifest.virtual_models[0].requires_candidates);
528    }
529
530    #[test]
531    fn rejects_malformed_messages_before_dispatch() {
532        for request in [
533            json!({}),
534            json!({"messages": []}),
535            json!({"messages": "hi"}),
536        ] {
537            assert_eq!(
538                chat_request_rejection(&request),
539                Some("MoA requires a non-empty `messages` array"),
540                "unexpected contract decision for {request}"
541            );
542        }
543        assert_eq!(
544            chat_request_rejection(&json!({
545                "messages": [{"role": "user", "content": "hi"}]
546            })),
547            None
548        );
549    }
550
551    #[test]
552    fn public_mesh_waits_less_for_a_better_answer() {
553        let public = patience_profile(true);
554        let private = patience_profile(false);
555        assert!(
556            public.first_answer_grace < private.first_answer_grace,
557            "a public mesh must not hold a usable answer for the trusted-mesh grace"
558        );
559        assert!(
560            public.strong_patience < private.strong_patience,
561            "a public mesh must not wait out a long tail for a strong peer"
562        );
563        assert_eq!(public.first_answer_grace, Duration::from_millis(1500));
564        assert_eq!(public.strong_patience, Duration::from_secs(8));
565        assert_eq!(private.first_answer_grace, Duration::from_secs(10));
566        assert_eq!(private.strong_patience, Duration::from_secs(20));
567    }
568
569    #[test]
570    fn detects_nested_media_inputs() {
571        let request = json!({
572            "messages": [{
573                "role": "user",
574                "content": [{"type": "image_url", "image_url": {"url": "data:image/png;base64,x"}}]
575            }]
576        });
577        assert!(contains_any_key(&request, &["image_url", "input_image"]));
578        assert!(!contains_any_key(&request, &["audio_url", "input_audio"]));
579    }
580
581    #[test]
582    fn strips_thinking_and_detects_error_shapes() {
583        let mut response = json!({
584            "choices": [{
585                "finish_reason": "stop",
586                "message": {"content": "<think>private</think>answer"}
587            }]
588        });
589        strip_response_thinking(&mut response);
590        assert_eq!(response["choices"][0]["message"]["content"], "answer");
591        assert!(!is_failure_body(&response));
592
593        response["choices"][0]["finish_reason"] = Value::String("error".into());
594        assert!(is_failure_body(&response));
595    }
596
597    #[test]
598    fn direct_response_uses_virtual_identity_after_thinking_is_removed() {
599        let mut response = json!({
600            "model": "worker-a",
601            "choices": [{
602                "finish_reason": "stop",
603                "message": {"content": "<think>private</think>answer"}
604            }]
605        });
606
607        normalize_direct_response_body(&mut response);
608
609        assert_eq!(response["model"], moa::VIRTUAL_MODEL_NAME);
610        assert_eq!(response["choices"][0]["message"]["content"], "answer");
611    }
612
613    #[test]
614    fn all_small_pool_routes_to_its_best_member() {
615        let mut candidates = vec![
616            candidate("small-a", Some(8.0), "a"),
617            candidate("small-b", Some(9.0), "b"),
618            candidate("unknown", None, "c"),
619        ];
620
621        assert!(apply_pool_policy(&mut candidates));
622
623        assert_eq!(
624            candidates.len(),
625            3,
626            "physical standbys must survive selection"
627        );
628        assert_eq!(candidates[0].model_id, "small-b");
629    }
630
631    fn invocation(
632        candidates: Vec<mesh_llm_plugin::VirtualModelCandidate>,
633    ) -> VirtualModelInvocation {
634        VirtualModelInvocation {
635            request: json!({"messages": [{"role": "user", "content": "hi"}]}),
636            candidates,
637            response_adapter: String::new(),
638        }
639    }
640
641    fn tools_invocation(
642        candidates: Vec<mesh_llm_plugin::VirtualModelCandidate>,
643    ) -> VirtualModelInvocation {
644        let mut invocation = invocation(candidates);
645        invocation.request["tools"] = json!([{
646            "type": "function",
647            "function": {"name": "report_status", "parameters": {"type": "object"}}
648        }]);
649        invocation
650    }
651
652    #[tokio::test]
653    async fn tools_without_advertised_support_reach_inference() {
654        let mut worker = candidate("qwen", Some(27.0), "local");
655        worker.supports_tools = false;
656        let mut invocation = tools_invocation(vec![worker]);
657        assert_eq!(
658            select_request_candidates(&mut invocation).unwrap(),
659            (false, false)
660        );
661        let original_request = invocation.request.clone();
662        let response =
663            direct_with_fallback(invocation, false, Duration::from_secs(1), move |request| {
664                assert_eq!(request.model_id, "qwen");
665                assert_eq!(request.request, original_request);
666                async {
667                    Ok(mesh_llm_plugin::HostInferenceResponse {
668                        status_code: 200,
669                        body: json!({"choices": [{"message": {"tool_calls": [{
670                            "type": "function",
671                            "function": {"name": "report_status", "arguments": "{}"}
672                        }]}}]}),
673                        served_by: Some("local".into()),
674                    })
675                }
676            })
677            .await;
678        assert_eq!(response.status_code, 200);
679        assert_eq!(
680            response.body["choices"][0]["message"]["tool_calls"][0]["function"]["name"],
681            "report_status"
682        );
683    }
684
685    #[test]
686    fn tools_prefer_advertised_support_over_larger_unknown_models() {
687        let mut unknown = candidate("unknown", Some(70.0), "a");
688        unknown.supports_tools = false;
689        let mut invocation = tools_invocation(vec![unknown, candidate("tools", Some(8.0), "b")]);
690        select_request_candidates(&mut invocation).unwrap();
691        apply_pool_policy(&mut invocation.candidates);
692        assert_eq!(invocation.candidates.len(), 1);
693        assert_eq!(invocation.candidates[0].model_id, "tools");
694    }
695
696    #[test]
697    fn absent_null_or_empty_tools_do_not_filter_candidates() {
698        for tools in [None, Some(Value::Null), Some(json!([]))] {
699            let mut unknown = candidate("unknown", Some(70.0), "a");
700            unknown.supports_tools = false;
701            let mut invocation = invocation(vec![unknown, candidate("tools", Some(8.0), "b")]);
702            if let Some(tools) = tools {
703                invocation.request["tools"] = tools;
704            }
705            select_request_candidates(&mut invocation).unwrap();
706            assert_eq!(invocation.candidates.len(), 2);
707        }
708    }
709
710    #[test]
711    fn tools_fallback_never_relaxes_media_requirements() {
712        for key in ["image_url", "input_audio"] {
713            let mut worker = candidate("text", Some(27.0), "a");
714            worker.supports_tools = false;
715            let mut invocation = tools_invocation(vec![worker]);
716            invocation.request["messages"][0]["content"] = json!([{key: {"url": "fixture"}}]);
717            let response = select_request_candidates(&mut invocation).unwrap_err();
718            assert_eq!(response.status_code, 422);
719        }
720    }
721
722    #[test]
723    fn tools_preference_is_computed_after_media_filtering() {
724        for key in ["image_url", "input_audio"] {
725            let text = candidate("text-tools", Some(70.0), "a");
726            let mut media = candidate("media", Some(27.0), "b");
727            media.supports_tools = false;
728            media.supports_vision = true;
729            media.supports_audio = true;
730            let mut invocation = tools_invocation(vec![text, media]);
731            invocation.request["messages"][0]["content"] = json!([{key: {"url": "fixture"}}]);
732            select_request_candidates(&mut invocation).unwrap();
733            assert_eq!(invocation.candidates.len(), 1);
734            assert_eq!(invocation.candidates[0].model_id, "media");
735        }
736    }
737
738    #[tokio::test]
739    async fn direct_route_retries_a_failed_or_disappeared_replica() {
740        for first_fails_with_response in [true, false] {
741            let attempts = Arc::new(std::sync::Mutex::new(Vec::new()));
742            let seen = Arc::clone(&attempts);
743            let response = direct_with_fallback(
744                invocation(vec![
745                    candidate("small", Some(8.0), "a"),
746                    candidate("small", Some(8.0), "b"),
747                ]),
748                false,
749                Duration::from_secs(1),
750                move |request| {
751                    let seen = Arc::clone(&seen);
752                    async move {
753                        seen.lock().unwrap().push(request.target_node_id.clone());
754                        assert!(request.timeout_ms.is_some_and(|ms| ms <= 1000));
755                        if request.target_node_id.as_deref() == Some("a") {
756                            if first_fails_with_response {
757                                return Ok(mesh_llm_plugin::HostInferenceResponse {
758                                    status_code: 503,
759                                    body: json!({"error": "unavailable"}),
760                                    served_by: None,
761                                });
762                            }
763                            anyhow::bail!("target disappeared after discovery");
764                        }
765                        Ok(mesh_llm_plugin::HostInferenceResponse {
766                            status_code: 200,
767                            body: json!({"model": "small", "choices": [{"message": {"content": "healthy"}}]}),
768                            served_by: Some("b".into()),
769                        })
770                    }
771                },
772            )
773            .await;
774            assert_eq!(response.status_code, 200);
775            assert_eq!(response.body["choices"][0]["message"]["content"], "healthy");
776            assert_eq!(response.headers, [("x-mesh-served-by".into(), "b".into())]);
777            assert_eq!(
778                *attempts.lock().unwrap(),
779                [Some("a".into()), Some("b".into())]
780            );
781        }
782    }
783
784    #[tokio::test]
785    async fn direct_route_does_not_retry_a_non_retryable_response() {
786        let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
787        let seen = Arc::clone(&attempts);
788        let response = direct_with_fallback(
789            invocation(vec![
790                candidate("small", Some(8.0), "a"),
791                candidate("small", Some(8.0), "b"),
792            ]),
793            false,
794            Duration::from_secs(1),
795            move |_| {
796                let seen = Arc::clone(&seen);
797                async move {
798                    seen.fetch_add(1, Ordering::Relaxed);
799                    Ok(mesh_llm_plugin::HostInferenceResponse {
800                        status_code: 402,
801                        body: json!({"error": "payment required"}),
802                        served_by: None,
803                    })
804                }
805            },
806        )
807        .await;
808        assert_eq!(response.status_code, 402);
809        assert_eq!(attempts.load(Ordering::Relaxed), 1);
810    }
811
812    #[tokio::test]
813    async fn direct_route_has_one_shared_deadline() {
814        let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
815        let seen = Arc::clone(&attempts);
816        let response = direct_with_fallback(
817            invocation(vec![
818                candidate("small", Some(8.0), "a"),
819                candidate("small", Some(8.0), "b"),
820            ]),
821            false,
822            Duration::from_millis(20),
823            move |_| {
824                let seen = Arc::clone(&seen);
825                async move {
826                    seen.fetch_add(1, Ordering::Relaxed);
827                    tokio::time::sleep(Duration::from_millis(100)).await;
828                    anyhow::bail!("late")
829                }
830            },
831        )
832        .await;
833        assert_eq!(response.status_code, 504);
834        assert_eq!(attempts.load(Ordering::Relaxed), 1);
835    }
836
837    #[test]
838    fn capable_committee_is_capped_and_keeps_same_model_replicas() {
839        let mut candidates = vec![
840            candidate("big", Some(70.0), "node-a"),
841            candidate("big", Some(70.0), "node-b"),
842            candidate("medium-a", Some(32.0), "node-c"),
843            candidate("medium-b", Some(24.0), "node-d"),
844            candidate("small", Some(8.0), "node-e"),
845        ];
846
847        apply_pool_policy(&mut candidates);
848
849        assert_eq!(candidates.len(), COMMITTEE_CAP);
850        assert_eq!(candidates[0].model_id, "big");
851        assert_eq!(candidates[1].model_id, "big");
852        assert_ne!(
853            candidates[0].target_node_id, candidates[1].target_node_id,
854            "physical replicas must remain distinct committee members"
855        );
856        assert!(
857            candidates
858                .iter()
859                .all(|candidate| candidate.model_id != "small")
860        );
861    }
862
863    #[test]
864    fn committee_cap_keeps_ready_replica_ahead_of_deprioritized_large_ones() {
865        let mut candidates = (0..COMMITTEE_CAP)
866            .map(|index| candidate("big", Some(70.0), &format!("deprioritized-{index}")))
867            .collect::<Vec<_>>();
868        for candidate in &mut candidates {
869            candidate.deprioritized = true;
870        }
871        candidates.push(candidate("ready", Some(32.0), "ready"));
872        assert!(!apply_pool_policy(&mut candidates));
873        assert_eq!(candidates.len(), COMMITTEE_CAP);
874        assert_eq!(candidates[0].model_id, "ready");
875    }
876}