Skip to main content

zeph_llm/router/
provider_impl.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! [`LlmProvider`] trait implementation for [`RouterProvider`].
5//!
6//! Forwards `chat`/`chat_stream`/`embed`/`embed_batch` and metadata queries to the
7//! selected backend, applying the fallback loop, retry/backoff for embeddings, the
8//! quality gate, `CoE` escalation, and ASI coherence tracking.
9
10use std::sync::atomic::Ordering;
11
12use parking_lot::Mutex;
13
14use super::coe::{CoeDecision, run_coe};
15use super::embed_cache::TurnEmbedCache;
16use super::{RouterProvider, RouterStrategy, messages_contain_image, strip_image_parts};
17use crate::embed::owned_strs;
18use crate::error::LlmError;
19use crate::provider::{ChatResponse, ChatStream, LlmProvider, Message, StatusTx, ToolDefinition};
20use zeph_common::math::cosine_similarity;
21
22const EMBED_MAX_RETRIES: u32 = 3;
23const EMBED_BASE_DELAY_MS: u64 = 500;
24
25/// Record a provider error during the fallback loop and emit a warning log.
26///
27/// Shared by [`RouterProvider::chat`] and [`RouterProvider::chat_stream`] to avoid
28/// duplicating error-path bookkeeping. Not part of the public API.
29fn record_fallback_error(
30    router: &RouterProvider,
31    provider_name: &str,
32    error: &LlmError,
33    elapsed_ms: u64,
34    status_tx: Option<&StatusTx>,
35    log_msg: &'static str,
36) {
37    router.record_availability(provider_name, false, elapsed_ms);
38    if error.is_rate_limited() {
39        router.record_availability(provider_name, false, 0);
40    }
41    if let Some(tx) = status_tx {
42        let _ = tx.send(format!("router: {provider_name} failed, falling back"));
43    }
44    tracing::warn!(provider = provider_name, error = %error, "{}", log_msg);
45}
46
47impl LlmProvider for RouterProvider {
48    fn context_window(&self) -> Option<usize> {
49        self.state
50            .providers
51            .first()
52            .and_then(LlmProvider::context_window)
53    }
54
55    #[allow(clippy::too_many_lines)] // CoE + quality-gate inline logic; extracting would obscure the control flow
56    fn chat(
57        &self,
58        messages: &[Message],
59    ) -> impl std::future::Future<Output = Result<String, LlmError>> + Send {
60        let status_tx = self.status_tx.clone();
61        let messages = messages.to_vec();
62        let router = self.clone();
63        let model = self.model_identifier().to_owned();
64        // NOTE: `chat` and `chat_stream` share error-path logic via `record_fallback_error`.
65        // Their success paths diverge (quality gate + CoE vs. plain stream-open), so a
66        // shared loop helper would reduce clarity without removing significant duplication.
67        let fut = Box::pin(async move {
68            // Increment turn counter once per top-level chat() call. All concurrent sub-calls
69            // (tool schema fetches, embed probes) that re-enter chat() will see the same
70            // turn_id via the shared Arc<AtomicU64>, enabling ASI debounce.
71            let turn_id = router.state.turn_counter.fetch_add(1, Ordering::Relaxed);
72
73            tracing::info!(
74                strategy = ?router.strategy,
75                turn_id,
76                provider_count = router.state.providers.len(),
77                "llm.router.select"
78            );
79
80            if router.strategy == RouterStrategy::Cascade {
81                // Cascade: pass Arc slice directly — providers are sorted at construction,
82                // so no Vec allocation needed on the hot path.
83                return router
84                    .cascade_chat(&router.state.providers, &messages, status_tx)
85                    .await;
86            }
87            if router.strategy == RouterStrategy::Bandit {
88                return router.bandit_chat(&messages, status_tx).await;
89            }
90            let providers = router.ordered_providers();
91
92            // Per-turn embedding cache: avoids re-embedding the same text across quality
93            // gate and ASI update within a single chat() call.
94            let turn_cache = Mutex::new(TurnEmbedCache::default());
95
96            // Pre-compute query embedding once for quality gate (fail-open on error).
97            let query_text = messages
98                .last()
99                .map(Message::to_llm_content)
100                .unwrap_or_default();
101            let query_embedding = if router.quality_gate.is_some() && !query_text.is_empty() {
102                router.embed_cached(query_text, &turn_cache).await.ok()
103            } else {
104                None
105            };
106
107            // Best response seen so far (for quality gate exhaustion fallback, M2).
108            let mut best_response: Option<(f32, String)> = None;
109            // Preserve the most recent error across the fallback loop so an exhausted
110            // loop surfaces the actionable diagnostic instead of a generic `NoProviders`.
111            let mut last_err: Option<LlmError> = None;
112
113            for p in &providers {
114                let start = std::time::Instant::now();
115                match p.chat_with_extras(&messages).await {
116                    Ok((r, extras)) => {
117                        router.record_availability(
118                            p.name(),
119                            true,
120                            u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
121                        );
122
123                        // Quality gate: check response-query embedding similarity.
124                        if let (Some(threshold), Some(qemb)) =
125                            (router.quality_gate, &query_embedding)
126                        {
127                            let resp_emb = router.embed_cached(&r, &turn_cache).await.ok();
128                            let similarity = resp_emb
129                                .as_ref()
130                                .map_or(threshold, |e| cosine_similarity(qemb, e)); // fail-open: None → treat as passing
131                            if similarity < threshold {
132                                tracing::info!(
133                                    provider = p.name(),
134                                    score = similarity,
135                                    threshold,
136                                    "thompson_quality_fallback"
137                                );
138                                // Track best response seen so far.
139                                let is_better = best_response
140                                    .as_ref()
141                                    .is_none_or(|(best, _)| similarity > *best);
142                                if is_better {
143                                    best_response = Some((similarity, r.clone()));
144                                }
145                                // Spawn ASI update even on quality failure, reusing resp_emb.
146                                router.spawn_asi_update(p.name(), r, turn_id, resp_emb);
147                                continue;
148                            }
149                            // Pass resp_emb to ASI to avoid a redundant embed call.
150                            router.spawn_asi_update(p.name(), r.clone(), turn_id, resp_emb);
151
152                            // CoE: pass already-obtained primary result to avoid double call.
153                            if let Some(ref coe_router) = router.coe
154                                && let Ok((final_r, pname, decision)) = run_coe(
155                                    coe_router,
156                                    p.name().to_owned(),
157                                    r.clone(),
158                                    extras,
159                                    &messages,
160                                )
161                                .await
162                            {
163                                if matches!(
164                                    decision,
165                                    CoeDecision::EscalateIntra | CoeDecision::EscalateInter
166                                ) {
167                                    router.record_quality_outcome(&pname, false);
168                                    router
169                                        .record_quality_outcome(coe_router.secondary.name(), true);
170                                }
171                                return Ok(final_r);
172                            }
173
174                            return Ok(r);
175                        }
176
177                        // Spawn ASI embedding update (fire-and-forget, no precomputed embedding).
178                        router.spawn_asi_update(p.name(), r.clone(), turn_id, None);
179
180                        // CoE: pass already-obtained primary result to avoid double call.
181                        if let Some(ref coe_router) = router.coe
182                            && let Ok((final_r, pname, decision)) = run_coe(
183                                coe_router,
184                                p.name().to_owned(),
185                                r.clone(),
186                                extras,
187                                &messages,
188                            )
189                            .await
190                        {
191                            if matches!(
192                                decision,
193                                CoeDecision::EscalateIntra | CoeDecision::EscalateInter
194                            ) {
195                                router.record_quality_outcome(&pname, false);
196                                router.record_quality_outcome(coe_router.secondary.name(), true);
197                            }
198                            return Ok(final_r);
199                        }
200
201                        return Ok(r);
202                    }
203                    Err(e) => {
204                        record_fallback_error(
205                            &router,
206                            p.name(),
207                            &e,
208                            u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
209                            status_tx.as_ref(),
210                            "router fallback",
211                        );
212                        last_err = Some(e);
213                    }
214                }
215            }
216
217            // All providers exhausted by quality gate: return best response seen (M2).
218            if let Some((_, response)) = best_response {
219                return Ok(response);
220            }
221
222            Err(last_err.unwrap_or(LlmError::NoProviders))
223        });
224        {
225            use tracing::Instrument as _;
226            fut.instrument(tracing::info_span!("llm.router.chat", model = model))
227        }
228    }
229
230    fn chat_stream(
231        &self,
232        messages: &[Message],
233    ) -> impl std::future::Future<Output = Result<ChatStream, LlmError>> + Send {
234        let status_tx = self.status_tx.clone();
235        let messages = messages.to_vec();
236        let router = self.clone();
237        let model = self.model_identifier().to_owned();
238        let fut = Box::pin(async move {
239            // NOTE: see DRY design decision above `chat()` — error path shared via
240            // `record_fallback_error`; success paths diverge intentionally.
241            if router.strategy == RouterStrategy::Cascade {
242                // Cascade: pass Arc slice directly — no Vec allocation on the hot path.
243                return router
244                    .cascade_chat_stream(&router.state.providers, &messages, status_tx)
245                    .await;
246            }
247            if router.strategy == RouterStrategy::Bandit {
248                // Bandit stream: select provider then stream from it.
249                // Reward is not recorded for streams (stream completion is async);
250                // this is a known pre-1.0 limitation — same as Thompson stream mode.
251                let query = messages
252                    .last()
253                    .map(crate::provider::Message::to_llm_content)
254                    .unwrap_or_default();
255                let p = router
256                    .bandit_select_provider(query)
257                    .await
258                    .ok_or(LlmError::NoProviders)?;
259                return p.chat_stream(&messages).await;
260            }
261            let providers = router.ordered_providers();
262            // Preserve the most recent error across the fallback loop so an exhausted
263            // loop surfaces the actionable diagnostic instead of a generic `NoProviders`.
264            let mut last_err: Option<LlmError> = None;
265            for p in &providers {
266                let start = std::time::Instant::now();
267                match p.chat_stream(&messages).await {
268                    Ok(r) => {
269                        // NOTE: success is recorded at stream-open time, not on stream
270                        // completion. A provider that opens the stream but then fails
271                        // mid-delivery still gets alpha += 1. This is a known pre-1.0
272                        // limitation: fixing it requires wrapping ChatStream to intercept
273                        // the completion/error signal, which adds latency on the hot path.
274                        // Tracked in the adaptive-inference epic (CRIT-2).
275                        router.record_availability(
276                            p.name(),
277                            true,
278                            u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
279                        );
280                        return Ok(r);
281                    }
282                    Err(e) => {
283                        record_fallback_error(
284                            &router,
285                            p.name(),
286                            &e,
287                            u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
288                            status_tx.as_ref(),
289                            "router stream fallback",
290                        );
291                        last_err = Some(e);
292                    }
293                }
294            }
295            Err(last_err.unwrap_or(LlmError::NoProviders))
296        });
297        {
298            use tracing::Instrument as _;
299            fut.instrument(tracing::info_span!("llm.router.chat_stream", model = model))
300        }
301    }
302
303    fn supports_streaming(&self) -> bool {
304        self.state
305            .providers
306            .iter()
307            .any(LlmProvider::supports_streaming)
308    }
309
310    /// Aggregate: `true` if any configured provider supports vision. This is a coarse,
311    /// optimistic signal (mirrors `TriageRouter::supports_vision`'s known v1 limitation,
312    /// spec-072 §7) — the concrete tool-call dispatch in [`Self::chat_with_tools`] applies
313    /// the real per-provider safety net (C3) so an image is never sent to a provider that
314    /// individually reports `supports_vision() == false`.
315    fn supports_vision(&self) -> bool {
316        self.state
317            .providers
318            .iter()
319            .any(LlmProvider::supports_vision)
320    }
321
322    #[allow(clippy::too_many_lines)] // retry + timeout + fallback + availability tracking: splitting would break the shared `last_err` accumulator
323    fn embed(
324        &self,
325        text: &str,
326    ) -> impl std::future::Future<Output = Result<Vec<f32>, LlmError>> + Send {
327        let providers = self.embed_candidates();
328        let status_tx = self.status_tx.clone();
329        let text = text.to_owned();
330        let router = self.clone();
331        let embed_timeout_ms = self.embed_timeout_ms;
332        let model = self.model_identifier().to_owned();
333        let fut = Box::pin(async move {
334            // Preserve the most recent error across the fallback loop so an exhausted
335            // loop surfaces the actionable diagnostic instead of a generic `NoProviders`.
336            let mut last_err: Option<LlmError> = None;
337            for p in &providers {
338                if !p.supports_embeddings() {
339                    continue;
340                }
341                for attempt in 0..=EMBED_MAX_RETRIES {
342                    if attempt > 0 {
343                        let delay = EMBED_BASE_DELAY_MS * (1u64 << (attempt - 1));
344                        tracing::warn!(
345                            provider = p.name(),
346                            attempt,
347                            delay_ms = delay,
348                            "embed: rate limited, retrying after backoff"
349                        );
350                        tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
351                    }
352                    let start = std::time::Instant::now();
353                    // Apply per-call timeout when configured (embed_timeout_ms > 0).
354                    let embed_result: Result<Vec<f32>, LlmError> = if embed_timeout_ms > 0 {
355                        let timeout = std::time::Duration::from_millis(embed_timeout_ms);
356                        match tokio::time::timeout(timeout, p.embed(&text)).await {
357                            Ok(inner) => inner,
358                            Err(_elapsed) => {
359                                tracing::warn!(
360                                    provider = p.name(),
361                                    timeout_ms = embed_timeout_ms,
362                                    "embed: provider timed out, falling back"
363                                );
364                                router.record_availability(
365                                    p.name(),
366                                    false,
367                                    u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
368                                );
369                                last_err = Some(LlmError::Timeout);
370                                break;
371                            }
372                        }
373                    } else {
374                        p.embed(&text).await
375                    };
376                    match embed_result {
377                        Ok(r) => {
378                            router.record_availability(
379                                p.name(),
380                                true,
381                                u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
382                            );
383                            return Ok(r);
384                        }
385                        Err(e) if e.is_invalid_input() => {
386                            // The input itself is invalid — retrying on another provider
387                            // would fail identically. Do not penalize provider reputation.
388                            tracing::warn!(
389                                provider = p.name(),
390                                error = %e,
391                                "embed: invalid input, not retrying on other providers"
392                            );
393                            return Err(e);
394                        }
395                        Err(e) if e.is_rate_limited() && attempt < EMBED_MAX_RETRIES => {
396                            last_err = Some(e);
397                        }
398                        Err(e) => {
399                            router.record_availability(
400                                p.name(),
401                                false,
402                                u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
403                            );
404                            if let Some(ref tx) = status_tx {
405                                let _ = tx.send(format!(
406                                    "router: {} embed failed, falling back",
407                                    p.name()
408                                ));
409                            }
410                            tracing::warn!(provider = p.name(), error = %e, "router embed fallback");
411                            last_err = Some(e);
412                            break;
413                        }
414                    }
415                }
416                // All retries exhausted for this provider (rate-limited every time).
417                if matches!(last_err, Some(ref e) if e.is_rate_limited()) {
418                    router.record_availability(p.name(), false, 0);
419                    if let Some(ref tx) = status_tx {
420                        let _ = tx.send(format!(
421                            "router: {} embed rate limited, falling back",
422                            p.name()
423                        ));
424                    }
425                    tracing::warn!(
426                        provider = p.name(),
427                        "embed: rate limit retries exhausted, falling back"
428                    );
429                }
430            }
431            Err(last_err.unwrap_or(LlmError::NoProviders))
432        });
433        {
434            use tracing::Instrument as _;
435            fut.instrument(tracing::info_span!("llm.router.embed", model = model))
436        }
437    }
438
439    #[allow(clippy::too_many_lines)] // retry + timeout + fallback + availability tracking: splitting would break the shared `last_err` accumulator
440    fn embed_batch(
441        &self,
442        texts: &[&str],
443    ) -> impl std::future::Future<Output = Result<Vec<Vec<f32>>, LlmError>> + Send {
444        let providers = self.embed_candidates();
445        let status_tx = self.status_tx.clone();
446        let owned = owned_strs(texts);
447        let router = self.clone();
448        let semaphore = self.state.embed_semaphore.clone();
449        let embed_timeout_ms = self.embed_timeout_ms;
450        let model = self.model_identifier().to_owned();
451        let fut = Box::pin(async move {
452            // Acquire embed semaphore permit before any HTTP work to cap concurrency.
453            let _permit = if let Some(ref sem) = semaphore {
454                Some(sem.acquire().await.map_err(|_| LlmError::NoProviders)?)
455            } else {
456                None
457            };
458            let refs: Vec<&str> = owned.iter().map(String::as_str).collect();
459            // Preserve the most recent error across the fallback loop so an exhausted
460            // loop surfaces the actionable diagnostic instead of a generic `NoProviders`.
461            let mut last_err: Option<LlmError> = None;
462            for p in &providers {
463                if !p.supports_embeddings() {
464                    continue;
465                }
466                for attempt in 0..=EMBED_MAX_RETRIES {
467                    if attempt > 0 {
468                        let delay = EMBED_BASE_DELAY_MS * (1u64 << (attempt - 1));
469                        tracing::warn!(
470                            provider = p.name(),
471                            attempt,
472                            delay_ms = delay,
473                            "embed_batch: rate limited, retrying after backoff"
474                        );
475                        tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
476                    }
477                    let start = std::time::Instant::now();
478                    // Apply per-call timeout when configured (embed_timeout_ms > 0).
479                    let embed_result: Result<Vec<Vec<f32>>, LlmError> = if embed_timeout_ms > 0 {
480                        let timeout = std::time::Duration::from_millis(embed_timeout_ms);
481                        match tokio::time::timeout(timeout, p.embed_batch(&refs)).await {
482                            Ok(inner) => inner,
483                            Err(_elapsed) => {
484                                tracing::warn!(
485                                    provider = p.name(),
486                                    timeout_ms = embed_timeout_ms,
487                                    "embed_batch: provider timed out, falling back"
488                                );
489                                router.record_availability(
490                                    p.name(),
491                                    false,
492                                    u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
493                                );
494                                last_err = Some(LlmError::Timeout);
495                                break;
496                            }
497                        }
498                    } else {
499                        p.embed_batch(&refs).await
500                    };
501                    match embed_result {
502                        Ok(r) => {
503                            router.record_availability(
504                                p.name(),
505                                true,
506                                u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
507                            );
508                            return Ok(r);
509                        }
510                        Err(e) if e.is_invalid_input() => {
511                            tracing::warn!(
512                                provider = p.name(),
513                                error = %e,
514                                "embed_batch: invalid input, not retrying on other providers"
515                            );
516                            return Err(e);
517                        }
518                        Err(e) if e.is_rate_limited() && attempt < EMBED_MAX_RETRIES => {
519                            last_err = Some(e);
520                        }
521                        Err(e) => {
522                            router.record_availability(
523                                p.name(),
524                                false,
525                                u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
526                            );
527                            if let Some(ref tx) = status_tx {
528                                let _ = tx.send(format!(
529                                    "router: {} embed_batch failed, falling back",
530                                    p.name()
531                                ));
532                            }
533                            tracing::warn!(
534                                provider = p.name(),
535                                error = %e,
536                                "router embed_batch fallback"
537                            );
538                            last_err = Some(e);
539                            break;
540                        }
541                    }
542                }
543                // All retries exhausted for this provider (rate-limited every time).
544                if matches!(last_err, Some(ref e) if e.is_rate_limited()) {
545                    router.record_availability(p.name(), false, 0);
546                    if let Some(ref tx) = status_tx {
547                        let _ = tx.send(format!(
548                            "router: {} embed_batch rate limited, falling back",
549                            p.name()
550                        ));
551                    }
552                    tracing::warn!(
553                        provider = p.name(),
554                        "embed_batch: rate limit retries exhausted, falling back"
555                    );
556                }
557            }
558            Err(last_err.unwrap_or(LlmError::NoProviders))
559        });
560        {
561            use tracing::Instrument as _;
562            fut.instrument(tracing::info_span!("llm.router.embed_batch", model = model))
563        }
564    }
565
566    fn supports_embeddings(&self) -> bool {
567        self.state
568            .providers
569            .iter()
570            .any(LlmProvider::supports_embeddings)
571    }
572
573    #[allow(clippy::unnecessary_literal_bound)]
574    fn name(&self) -> &str {
575        "router"
576    }
577
578    #[allow(clippy::unnecessary_literal_bound)]
579    fn model_identifier(&self) -> &str {
580        "router"
581    }
582
583    // Mirrors the `last_active_provider` read pattern already used by reputation
584    // attribution (`last_selected_provider_kind`, `record_quality_outcome`): correct
585    // as long as the tool loop stays sequential per turn (no interleaving dispatch
586    // between the call that sets `last_active_provider` and this read). Concurrent
587    // dispatch on a shared Router `Arc` across subagents can race (last-writer-wins);
588    // pre-existing and inherited from the same attribution state, not a new class.
589    fn effective_model_identifier(&self) -> &str {
590        let name = self.state.last_active_provider.lock().clone();
591        let Some(name) = name else {
592            return "router";
593        };
594        self.state
595            .providers
596            .iter()
597            .find(|p| p.name() == name)
598            .map_or("router", |p| p.model_identifier())
599    }
600
601    fn supports_tool_use(&self) -> bool {
602        self.state
603            .providers
604            .iter()
605            .any(LlmProvider::supports_tool_use)
606    }
607
608    fn list_models(&self) -> Vec<String> {
609        self.state
610            .providers
611            .iter()
612            .flat_map(crate::provider::LlmProvider::list_models)
613            .collect()
614    }
615
616    #[allow(refining_impl_trait_reachable)]
617    #[allow(clippy::too_many_lines)] // fallback loop + bandit branch + spec-072 vision safety net
618    fn chat_with_tools(
619        &self,
620        messages: &[Message],
621        tools: &[ToolDefinition],
622    ) -> impl std::future::Future<Output = Result<ChatResponse, LlmError>> + Send {
623        let messages = messages.to_vec();
624        let tool_count = tools.len();
625        let tools = tools.to_vec();
626        let status_tx = self.status_tx.clone();
627        let router = self.clone();
628        let model = self.model_identifier().to_owned();
629        let fut = Box::pin(async move {
630            // spec-072 C3: an Image part must never reach a provider whose own
631            // `supports_vision()` is `false` — computed once, applied per-provider below
632            // (never per-router-aggregate) since `RouterProvider` can dispatch to any
633            // configured provider regardless of strategy.
634            let has_image = messages_contain_image(&messages);
635            let stripped_messages = if has_image {
636                Some(strip_image_parts(&messages))
637            } else {
638                None
639            };
640            let dispatch_messages_for = |p: &crate::any::AnyProvider| -> &[Message] {
641                if has_image && !p.supports_vision() {
642                    stripped_messages.as_deref().unwrap_or(&messages)
643                } else {
644                    &messages
645                }
646            };
647
648            // Bandit routing for tool calls: select a single provider, no quality escalation.
649            if router.strategy == RouterStrategy::Bandit {
650                let query = messages
651                    .last()
652                    .map(crate::provider::Message::to_llm_content)
653                    .unwrap_or_default();
654                let p = router
655                    .bandit_select_provider(query)
656                    .await
657                    .ok_or(LlmError::NoProviders)?;
658                if !p.supports_tool_use() {
659                    return Err(LlmError::NoProviders);
660                }
661                if has_image && !p.supports_vision() {
662                    tracing::warn!(
663                        provider = p.name(),
664                        "router: bandit-selected provider is not vision-capable, dropping \
665                         image part(s) (text placeholder remains)"
666                    );
667                }
668                let result = p.chat_with_tools(dispatch_messages_for(&p), &tools).await;
669                if result.is_ok() {
670                    *router.state.last_active_provider.lock() = Some(p.name().to_owned());
671                }
672                return result;
673            }
674
675            // Cascade is intentionally skipped for tool calls: evaluating quality of
676            // a tool-call response (structured JSON with tool name + args) requires
677            // different heuristics than text quality. Skipping cascade for tool calls
678            // avoids inappropriate escalation based on text signals (HIGH-04).
679            let providers = router.ordered_providers();
680            // Preserve the most recent error across the fallback loop so an exhausted
681            // loop surfaces the actionable diagnostic (e.g. `ModelCapabilityMismatch`'s
682            // enriched message from #5795) instead of a generic `NoProviders`.
683            let mut last_err: Option<LlmError> = None;
684            for p in &providers {
685                if !p.supports_tool_use() {
686                    continue;
687                }
688                if has_image && !p.supports_vision() {
689                    tracing::warn!(
690                        provider = p.name(),
691                        "router: provider is not vision-capable, dropping image part(s) for \
692                         this dispatch (text placeholder remains)"
693                    );
694                }
695                let start = std::time::Instant::now();
696                match p.chat_with_tools(dispatch_messages_for(p), &tools).await {
697                    Ok(r) => {
698                        router.record_availability(
699                            p.name(),
700                            true,
701                            u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
702                        );
703                        // Track which sub-provider served this tool call for reputation attribution.
704                        *router.state.last_active_provider.lock() = Some(p.name().to_owned());
705                        return Ok(r);
706                    }
707                    Err(e) => {
708                        router.record_availability(
709                            p.name(),
710                            false,
711                            u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
712                        );
713                        if e.is_invalid_input() {
714                            tracing::warn!(
715                                provider = p.name(),
716                                error = %e,
717                                "chat_with_tools: invalid input, not retrying on other providers"
718                            );
719                            return Err(e);
720                        }
721                        // A model capability mismatch (e.g. `reasoning_effort` + `tools` on
722                        // this specific model/provider) is retryable elsewhere — the same
723                        // request may succeed on a different model or provider, unlike
724                        // `InvalidInput` which is malformed regardless of destination.
725                        if e.is_model_capability_mismatch() {
726                            tracing::warn!(
727                                provider = p.name(),
728                                error = %e,
729                                "chat_with_tools: model capability mismatch, falling back to next provider"
730                            );
731                        }
732                        if e.is_rate_limited() {
733                            router.record_availability(p.name(), false, 0);
734                        }
735                        if let Some(ref tx) = status_tx {
736                            let _ = tx.send(format!(
737                                "router: {} tool call failed, falling back",
738                                p.name()
739                            ));
740                        }
741                        tracing::warn!(provider = p.name(), error = %e, "router tool fallback");
742                        last_err = Some(e);
743                    }
744                }
745            }
746            Err(last_err.unwrap_or(LlmError::NoProviders))
747        });
748        {
749            use tracing::Instrument as _;
750            fut.instrument(tracing::info_span!(
751                "llm.router.chat_with_tools",
752                model = model,
753                tool_count = tool_count
754            ))
755        }
756    }
757
758    fn debug_request_json(
759        &self,
760        messages: &[Message],
761        tools: &[ToolDefinition],
762        stream: bool,
763    ) -> serde_json::Value {
764        let candidate = if tools.is_empty() {
765            self.ordered_providers().into_iter().next()
766        } else {
767            self.ordered_providers()
768                .into_iter()
769                .find(crate::provider::LlmProvider::supports_tool_use)
770        };
771        candidate.map_or_else(
772            || crate::provider::default_debug_request_json(messages, tools),
773            |provider| provider.debug_request_json(messages, tools, stream),
774        )
775    }
776
777    fn last_cache_usage(&self) -> Option<(u64, u64)> {
778        None
779    }
780}