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}