Skip to main content

zeph_llm/router/
builder.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Construction and configuration of [`RouterProvider`].
5//!
6//! Holds the constructor, the strategy builder methods (`with_*`), state-persistence
7//! helpers (`save_*`), and the diagnostic accessors (`*_stats`, `set_status_tx`,
8//! `list_models_remote`).
9
10use std::path::Path;
11use std::sync::Arc;
12use std::sync::atomic::Ordering;
13
14use parking_lot::Mutex;
15
16use super::asi::AsiState;
17use super::bandit::BanditState;
18use super::cascade::CascadeState;
19use super::coe::{self, CoeRouter};
20use super::config::{AsiRouterConfig, BanditRouterConfig, CascadeRouterConfig};
21use super::embed_cache::BanditEmbedCache;
22use super::reputation::{self, ReputationTracker};
23use super::state::RouterState;
24use super::thompson::{self, ThompsonState};
25use super::{RouterProvider, RouterStrategy, blocking_load};
26use crate::any::AnyProvider;
27use crate::ema::EmaTracker;
28use crate::error::LlmError;
29use crate::provider::{LlmProvider, StatusTx};
30
31impl RouterProvider {
32    /// Create a new router over `providers`.
33    ///
34    /// Use the builder methods (e.g., [`with_thompson`][Self::with_thompson],
35    /// [`with_cascade`][Self::with_cascade]) to configure a routing strategy.
36    /// The default strategy is [`RouterStrategy::Ema`].
37    #[must_use]
38    pub fn new(providers: Vec<AnyProvider>) -> Self {
39        let state = RouterState::new(Arc::from(providers));
40        Self {
41            state,
42            status_tx: None,
43            ema: None,
44            strategy: RouterStrategy::Ema,
45            thompson: None,
46            thompson_state_path: None,
47            cascade_state: None,
48            cascade_config: None,
49            reputation: None,
50            reputation_state_path: None,
51            reputation_weight: 0.3,
52            bandit: None,
53            bandit_state_path: None,
54            bandit_config: None,
55            bandit_embedding_provider: None,
56            bandit_embed_cache: Arc::new(Mutex::new(BanditEmbedCache::default())),
57            asi: None,
58            asi_config: None,
59            quality_gate: None,
60            coe: None,
61            embed_timeout_ms: 5000,
62            asi_tasks: Arc::new(Mutex::new(tokio::task::JoinSet::new())),
63        }
64    }
65
66    /// Set the per-call timeout for [`embed`][Self::embed] across all non-bandit providers.
67    ///
68    /// A stalled provider is skipped and the next candidate is tried. Default is `5000` ms.
69    /// Pass `0` to disable the timeout (not recommended for production).
70    ///
71    /// # Examples
72    ///
73    /// ```no_run
74    /// # use zeph_llm::router::RouterProvider;
75    /// let router = RouterProvider::new(vec![]).with_embed_timeout(3000);
76    /// ```
77    #[must_use]
78    pub fn with_embed_timeout(mut self, timeout_ms: u64) -> Self {
79        self.embed_timeout_ms = timeout_ms;
80        self
81    }
82
83    /// Register the provider explicitly flagged `embed = true` in `[[llm.providers]]`.
84    ///
85    /// `embed()`/`embed_batch()` try this provider first, ahead of the generic scan over
86    /// `providers` for any backend that merely reports `supports_embeddings() == true`.
87    /// Without this, a chat-only provider sharing a backend type with the dedicated
88    /// embedding provider (e.g. two `OllamaProvider` instances) can shadow it and silently
89    /// fall back to an unconfigured default embedding model (#5859).
90    ///
91    /// # Examples
92    ///
93    /// ```no_run
94    /// # use zeph_llm::router::RouterProvider;
95    /// # use zeph_llm::any::AnyProvider;
96    /// # use zeph_llm::ollama::OllamaProvider;
97    /// let embedder = AnyProvider::Ollama(
98    ///     OllamaProvider::new("http://localhost:11434", "chat-model".into(), "nomic-embed-text".into())
99    ///         .with_provider_name("embedder"),
100    /// );
101    /// let router = RouterProvider::new(vec![]).with_embed_provider(embedder);
102    /// ```
103    #[must_use]
104    pub fn with_embed_provider(mut self, provider: AnyProvider) -> Self {
105        self.state.dedicated_embed_provider = Some(Arc::new(provider));
106        self
107    }
108
109    /// Set the maximum number of concurrent `embed_batch` calls.
110    ///
111    /// A value of 0 disables the semaphore (unlimited). Default is no semaphore.
112    #[must_use]
113    pub fn with_embed_concurrency(mut self, limit: usize) -> Self {
114        self.state.embed_semaphore = if limit > 0 {
115            Some(Arc::new(tokio::sync::Semaphore::new(limit)))
116        } else {
117            None
118        };
119        self
120    }
121
122    /// Set the MAR (Memory-Augmented Routing) signal for the current turn.
123    ///
124    /// Must be called before `chat` / `chat_stream` to influence bandit provider selection.
125    /// Pass `None` to disable MAR for this turn.
126    pub fn set_memory_confidence(&self, confidence: Option<f32>) {
127        let raw = confidence.map_or(u32::MAX, f32::to_bits);
128        self.state
129            .last_memory_confidence
130            .store(raw, std::sync::atomic::Ordering::Relaxed);
131    }
132
133    /// Enable EMA-based adaptive provider ordering.
134    #[must_use]
135    pub fn with_ema(mut self, alpha: f64, reorder_interval: u64) -> Self {
136        self.ema = Some(EmaTracker::new(alpha, reorder_interval));
137        self
138    }
139
140    /// Enable Collaborative Entropy (`CoE`) for Ema/Thompson strategies.
141    ///
142    /// `CoE` detects uncertain responses via intra-entropy and inter-divergence signals,
143    /// escalating to `secondary` when either threshold is exceeded.
144    ///
145    /// No-op (with a `warn!`) when the active strategy is `Cascade` or `Bandit`.
146    #[must_use]
147    pub fn with_coe(
148        mut self,
149        config: coe::CoeConfig,
150        secondary: AnyProvider,
151        embed: AnyProvider,
152    ) -> Self {
153        if matches!(
154            self.strategy,
155            RouterStrategy::Cascade | RouterStrategy::Bandit
156        ) {
157            tracing::warn!(
158                strategy = ?self.strategy,
159                "coe disabled for strategy; supported: ema, thompson"
160            );
161            return self;
162        }
163        self.coe = Some(Arc::new(CoeRouter {
164            config,
165            secondary: Arc::new(secondary) as Arc<dyn crate::provider_dyn::LlmProviderDyn>,
166            embed: Arc::new(embed) as Arc<dyn crate::provider_dyn::LlmProviderDyn>,
167            metrics: Arc::new(coe::CoeMetrics::default()),
168        }));
169        self
170    }
171
172    /// Return session-level `CoE` metrics snapshot, or `None` if `CoE` is disabled.
173    #[must_use]
174    pub fn coe_metrics(&self) -> Option<(u64, u64, u64, u64)> {
175        self.coe.as_ref().map(|c| {
176            (
177                c.metrics.kept_primary.load(Ordering::Relaxed),
178                c.metrics.intra_escalations.load(Ordering::Relaxed),
179                c.metrics.inter_escalations.load(Ordering::Relaxed),
180                c.metrics.embed_failures.load(Ordering::Relaxed),
181            )
182        })
183    }
184
185    /// Enable Agent Stability Index (ASI) coherence tracking.
186    ///
187    /// When enabled, each successful response is embedded in a background task and added
188    /// to a per-provider sliding window. The coherence score (cosine similarity of the
189    /// latest embedding vs. window mean) penalizes Thompson/EMA routing priors for
190    /// providers whose responses drift.
191    #[must_use]
192    pub fn with_asi(mut self, config: AsiRouterConfig) -> Self {
193        self.asi = Some(Arc::new(Mutex::new(AsiState::default())));
194        self.asi_config = Some(config);
195        self
196    }
197
198    /// Enable embedding-based quality gate for Thompson/EMA routing.
199    ///
200    /// After provider selection, computes cosine similarity between the query embedding
201    /// and the response embedding. If below `threshold`, tries the next provider in the
202    /// ordered list. On full exhaustion, returns the best response seen (highest similarity).
203    /// Fail-open: embedding errors disable the gate for that request.
204    #[must_use]
205    pub fn with_quality_gate(mut self, threshold: f32) -> Self {
206        self.quality_gate = Some(threshold);
207        self
208    }
209
210    /// Enable Thompson Sampling strategy.
211    ///
212    /// Loads existing state from `state_path` if present; falls back to uniform prior.
213    /// Prunes stale entries for providers not in the current chain.
214    #[must_use]
215    pub fn with_thompson(mut self, state_path: Option<&Path>) -> Self {
216        self.strategy = RouterStrategy::Thompson;
217        let path = state_path.map_or_else(ThompsonState::default_path, Path::to_path_buf);
218        let mut state = blocking_load(|| ThompsonState::load(&path));
219        // CRIT-3: prune orphan entries from previous configs.
220        let known: std::collections::HashSet<String> = self
221            .state
222            .providers
223            .iter()
224            .map(|p| p.name().to_owned())
225            .collect();
226        state.prune(&known);
227        self.thompson = Some(Arc::new(Mutex::new(state)));
228        self.thompson_state_path = Some(path);
229        self
230    }
231
232    /// Enable PILOT bandit routing strategy (`LinUCB` contextual bandit).
233    ///
234    /// Loads existing state from `state_path` (or the default path) using
235    /// [`tokio::task::block_in_place`] to avoid blocking the async executor.
236    /// Applies session-level decay if `config.decay_factor < 1.0`, and prunes arms for
237    /// removed providers.
238    ///
239    /// `embedding_provider` is used to obtain feature vectors for each query.
240    /// When `None`, the bandit falls back to Thompson/uniform selection whenever an
241    /// embedding cannot be obtained within `config.embedding_timeout_ms`.
242    ///
243    /// The `warmup_queries` default of `0` in `BanditRouterConfig` is overridden here to
244    /// `10 * num_providers` to ensure sufficient initial exploration.
245    #[must_use]
246    pub fn with_bandit(
247        mut self,
248        mut config: BanditRouterConfig,
249        state_path: Option<&Path>,
250        embedding_provider: Option<AnyProvider>,
251    ) -> Self {
252        self.strategy = RouterStrategy::Bandit;
253        let n = self.state.providers.len();
254        if config.warmup_queries == 0 {
255            config.warmup_queries = u64::try_from(10 * n.max(1)).unwrap_or(100);
256        }
257        let cache_size = config.cache_size;
258        let path = state_path.map_or_else(BanditState::default_path, Path::to_path_buf);
259        let mut state = blocking_load(|| BanditState::load(&path));
260        if state.dim == 0 {
261            state = BanditState::new(config.dim);
262        } else if state.dim != config.dim {
263            // Config changed dim — reset state rather than use mismatched arms.
264            tracing::warn!(
265                old_dim = state.dim,
266                new_dim = config.dim,
267                "bandit: dim changed, resetting state"
268            );
269            state = BanditState::new(config.dim);
270        }
271        // Validate config bounds before applying. Clamp to safe ranges with a warning.
272        if config.alpha <= 0.0 {
273            tracing::warn!(alpha = config.alpha, "bandit: alpha <= 0, clamping to 0.01");
274            config.alpha = 0.01;
275        }
276        if config.dim == 0 || config.dim > 256 {
277            tracing::warn!(
278                dim = config.dim,
279                "bandit: dim out of range [1, 256], clamping to 32"
280            );
281            config.dim = 32;
282        }
283        if config.decay_factor <= 0.0 || config.decay_factor > 1.0 {
284            tracing::warn!(
285                decay_factor = config.decay_factor,
286                "bandit: decay_factor out of (0.0, 1.0], clamping to 1.0"
287            );
288            config.decay_factor = 1.0;
289        }
290        if config.decay_factor < 1.0 {
291            state.apply_decay(config.decay_factor);
292        }
293        let known: std::collections::HashSet<String> = self
294            .state
295            .providers
296            .iter()
297            .map(|p| p.name().to_owned())
298            .collect();
299        state.prune(&known);
300        self.bandit = Some(Arc::new(Mutex::new(state)));
301        self.bandit_state_path = Some(path);
302        self.bandit_embed_cache = Arc::new(Mutex::new(BanditEmbedCache::new(cache_size)));
303        self.bandit_embedding_provider =
304            embedding_provider.map(|p| Arc::new(p) as Arc<dyn crate::provider_dyn::LlmProviderDyn>);
305        // Initialize Thompson state for cold-start fallback (total_updates < warmup_queries).
306        // Uses default uniform priors; no persistence path needed since it's a fallback only.
307        self.thompson = Some(Arc::new(Mutex::new(ThompsonState::default())));
308        self.bandit_config = Some(config);
309        self
310    }
311
312    /// Persist current bandit state to disk. No-op if bandit strategy is not active.
313    ///
314    /// Uses [`tokio::task::spawn_blocking`] so it is safe to call from any async context.
315    #[tracing::instrument(name = "llm.router.builder.save_bandit_state", skip_all)]
316    pub async fn save_bandit_state(&self) {
317        let (Some(bandit), Some(path)) = (&self.bandit, &self.bandit_state_path) else {
318            return;
319        };
320        let bandit = Arc::clone(bandit);
321        let path = path.clone();
322        tokio::task::spawn_blocking(move || {
323            let state = bandit.lock();
324            if let Err(e) = state.save(&path) {
325                tracing::warn!(error = %e, "failed to save bandit state");
326            }
327        })
328        .await
329        .unwrap_or_else(|e| tracing::warn!(error = %e, "bandit state save task panicked"));
330    }
331
332    /// Return bandit diagnostic stats: `(provider_name, pulls, mean_reward)`.
333    ///
334    /// Returns an empty vec if bandit strategy is not active.
335    #[must_use]
336    pub fn bandit_stats(&self) -> Vec<(String, u64, f32)> {
337        let Some(ref bandit) = self.bandit else {
338            return vec![];
339        };
340        let state = bandit.lock();
341        state.stats()
342    }
343
344    /// Enable Bayesian reputation scoring (RAPS).
345    ///
346    /// Loads existing state from `state_path` (or the default path) using
347    /// [`tokio::task::block_in_place`] to avoid blocking the async executor.
348    /// Applies session-level decay and prunes stale provider entries.
349    ///
350    /// No-op for Cascade routing (reputation is not used for cost-tier ordering).
351    #[must_use]
352    pub fn with_reputation(
353        mut self,
354        decay_factor: f64,
355        weight: f64,
356        min_observations: u64,
357        state_path: Option<&Path>,
358    ) -> Self {
359        let path = state_path.map_or_else(ReputationTracker::default_path, Path::to_path_buf);
360        // Load persisted state, apply decay, and prune orphaned providers.
361        let mut tracker = blocking_load(|| ReputationTracker::load(&path));
362        let known: std::collections::HashSet<String> = self
363            .state
364            .providers
365            .iter()
366            .map(|p| p.name().to_owned())
367            .collect();
368        tracker.apply_decay();
369        tracker.prune(&known);
370        // Overwrite config params (decay/min_obs may differ from the persisted defaults).
371        let tracker = {
372            let stats = tracker.stats();
373            let mut t = ReputationTracker::new(decay_factor, min_observations);
374            for (name, alpha, beta, _, obs) in stats {
375                t.models.insert(
376                    name,
377                    reputation::ReputationEntry {
378                        dist: thompson::BetaDist { alpha, beta },
379                        observations: obs,
380                    },
381                );
382            }
383            t
384        };
385        self.reputation = Some(Arc::new(Mutex::new(tracker)));
386        self.reputation_state_path = Some(path);
387        self.reputation_weight = weight.clamp(0.0, 1.0);
388        self
389    }
390
391    /// Record a quality outcome for the last active sub-provider (tool execution result).
392    ///
393    /// Call only for semantic failures (invalid tool args, parse errors).
394    /// Do NOT call for network errors, rate limits, or transient I/O failures.
395    /// No-op when reputation scoring is disabled, strategy is Cascade, or no tool call
396    /// has been made yet in this session.
397    ///
398    /// The `_provider_name` parameter is ignored — quality is attributed to the sub-provider
399    /// that served the most recent `chat_with_tools` call, tracked via `last_active_provider`.
400    pub fn record_quality_outcome(&self, _provider_name: &str, success: bool) {
401        if matches!(
402            self.strategy,
403            RouterStrategy::Cascade | RouterStrategy::Bandit
404        ) {
405            // Cascade: quality tracked via CascadeState.
406            // Bandit: quality fed via bandit_record_reward() after each response.
407            return;
408        }
409        let Some(ref reputation) = self.reputation else {
410            return;
411        };
412        let active = self.state.last_active_provider.lock().clone();
413        let Some(provider_name) = active else {
414            return;
415        };
416        let mut tracker = reputation.lock();
417        tracker.record_quality(&provider_name, success);
418    }
419
420    /// Returns the `provider_kind_str` of the last provider selected by the router.
421    ///
422    /// Used by [`crate::any::AnyProvider::provider_kind_str`] to attribute cost to the
423    /// actual child provider rather than returning the generic `"local"` sentinel for all
424    /// router-dispatched calls. Falls back to `"local"` when no call has been made yet.
425    #[must_use]
426    pub fn last_selected_provider_kind(&self) -> &'static str {
427        let name = self.state.last_active_provider.lock().clone();
428        let Some(name) = name else {
429            return "local";
430        };
431        self.state
432            .providers
433            .iter()
434            .find(|p| p.name() == name)
435            .map_or("local", |p| p.provider_kind_str())
436    }
437
438    /// Persist current reputation state to disk. No-op if reputation is disabled.
439    /// Uses [`tokio::task::spawn_blocking`] so it is safe to call from any async context.
440    #[tracing::instrument(name = "llm.router.builder.save_reputation_state", skip_all)]
441    pub async fn save_reputation_state(&self) {
442        let (Some(reputation), Some(path)) = (&self.reputation, &self.reputation_state_path) else {
443            return;
444        };
445        let reputation = Arc::clone(reputation);
446        let path = path.clone();
447        tokio::task::spawn_blocking(move || {
448            let state = reputation.lock();
449            if let Err(e) = state.save(&path) {
450                tracing::warn!(error = %e, "failed to save reputation state");
451            }
452        })
453        .await
454        .unwrap_or_else(|e| tracing::warn!(error = %e, "reputation state save task panicked"));
455    }
456
457    /// Return reputation stats for all tracked providers: (name, alpha, beta, mean, observations).
458    #[must_use]
459    pub fn reputation_stats(&self) -> Vec<(String, f64, f64, f64, u64)> {
460        let Some(ref reputation) = self.reputation else {
461            return vec![];
462        };
463        let tracker = reputation.lock();
464        tracker.stats()
465    }
466
467    /// Enable Cascade routing strategy.
468    ///
469    /// Providers are tried in chain order (cheapest first). Each response is evaluated
470    /// by the quality classifier; if it falls below `quality_threshold`, the next
471    /// provider is tried. At most `max_escalations` quality-based escalations occur.
472    ///
473    /// Network/API errors do not count against the escalation budget.
474    /// The best response seen so far is returned if all escalations are exhausted.
475    ///
476    /// When `config.cost_tiers` is set, providers are reordered once at construction
477    /// time (no per-request cost). Providers absent from `cost_tiers` are appended
478    /// after listed ones in original chain order. Unknown names in `cost_tiers` emit
479    /// a warning and are otherwise ignored.
480    #[must_use]
481    pub fn with_cascade(mut self, config: CascadeRouterConfig) -> Self {
482        self.strategy = RouterStrategy::Cascade;
483
484        if let Some(ref tiers) = config.cost_tiers
485            && !tiers.is_empty()
486        {
487            let provider_names: std::collections::HashSet<&str> =
488                self.state.providers.iter().map(AnyProvider::name).collect();
489            for name in tiers {
490                if !provider_names.contains(name.as_str()) {
491                    tracing::warn!(
492                        name = %name,
493                        "cascade: cost_tiers entry does not match any provider name"
494                    );
495                }
496            }
497
498            let tier_pos: std::collections::HashMap<&str, usize> = tiers
499                .iter()
500                .enumerate()
501                .map(|(i, n)| (n.as_str(), i))
502                .collect();
503
504            let before: Vec<_> = self
505                .state
506                .providers
507                .iter()
508                .map(|p| p.name().to_owned())
509                .collect();
510            let mut indexed: Vec<(usize, AnyProvider)> =
511                self.state.providers.iter().cloned().enumerate().collect();
512            indexed.sort_by_key(|(orig_idx, p)| {
513                tier_pos
514                    .get(p.name())
515                    .copied()
516                    .map_or((1usize, *orig_idx), |t| (0, t))
517            });
518            let after: Vec<_> = indexed.iter().map(|(_, p)| p.name().to_owned()).collect();
519            if before != after {
520                tracing::debug!(
521                    before = ?before,
522                    after = ?after,
523                    "cascade: providers reordered by cost_tiers"
524                );
525            }
526            self.state.providers =
527                Arc::from(indexed.into_iter().map(|(_, p)| p).collect::<Vec<_>>());
528        }
529
530        let window = config.window_size;
531        self.cascade_state = Some(Arc::new(Mutex::new(CascadeState::new(window))));
532        self.cascade_config = Some(config);
533        self
534    }
535
536    /// Persist current Thompson state to disk.
537    ///
538    /// No-op if Thompson strategy is not active.
539    ///
540    /// Uses [`tokio::task::spawn_blocking`] so it is safe to call from any async context,
541    /// including mid-request paths.
542    #[tracing::instrument(name = "llm.router.builder.save_thompson_state", skip_all)]
543    pub async fn save_thompson_state(&self) {
544        let (Some(thompson), Some(path)) = (&self.thompson, &self.thompson_state_path) else {
545            return;
546        };
547        let thompson = Arc::clone(thompson);
548        let path = path.clone();
549        tokio::task::spawn_blocking(move || {
550            let state = thompson.lock();
551            if let Err(e) = state.save(&path) {
552                tracing::warn!(error = %e, "failed to save Thompson router state");
553            }
554        })
555        .await
556        .unwrap_or_else(|e| tracing::warn!(error = %e, "Thompson state save task panicked"));
557    }
558    /// Return a snapshot of Thompson distribution parameters for all tracked providers.
559    ///
560    /// Returns an empty vec if Thompson strategy is not active.
561    #[must_use]
562    pub fn thompson_stats(&self) -> Vec<(String, f64, f64)> {
563        let Some(ref thompson) = self.thompson else {
564            return vec![];
565        };
566        let state = thompson.lock();
567        state.provider_stats()
568    }
569
570    pub fn set_status_tx(&mut self, tx: StatusTx) {
571        if let Some(providers) = Arc::get_mut(&mut self.state.providers) {
572            for p in providers {
573                p.set_status_tx(tx.clone());
574            }
575        } else {
576            // Defensive path: should never happen at bootstrap (refcount == 1).
577            let mut v: Vec<_> = self.state.providers.iter().cloned().collect();
578            for p in &mut v {
579                p.set_status_tx(tx.clone());
580            }
581            self.state.providers = Arc::from(v);
582        }
583        self.status_tx = Some(tx);
584    }
585
586    /// Resolve the pool index that runtime capability commands (`/think-tokens`,
587    /// `/reasoning-effort`) target: the inner provider that served the most recent call, or
588    /// the first configured provider as a deterministic fallback (FR-006) when no dispatch has
589    /// happened yet this session, or when `last_active_provider` names a provider no longer in
590    /// the pool (config drift). Returns `None` only for an empty pool.
591    pub(crate) fn capability_target_index(&self) -> Option<usize> {
592        if self.state.providers.is_empty() {
593            return None;
594        }
595        let name = self.state.last_active_provider.lock().clone();
596        match name {
597            Some(name) => Some(
598                self.state
599                    .providers
600                    .iter()
601                    .position(|p| p.name() == name)
602                    .unwrap_or(0),
603            ),
604            None => Some(0),
605        }
606    }
607
608    /// Mutate the pooled provider at `idx` in place.
609    ///
610    /// `RouterState::providers` is `Arc<[AnyProvider]>`; `Arc::get_mut` succeeds at refcount 1
611    /// (the common case for a slash command holding `&mut` to the sole owned
612    /// `AnyProvider::Router`). The rebuild branch below is **not** a defensive fallback: any
613    /// in-flight `spawn_asi_update` background task clones `RouterProvider` (sharing this same
614    /// `providers` Arc) for up to `embed_timeout_ms` (default 5000ms) after every turn, so a
615    /// `/think-tokens`/`/reasoning-effort` issued shortly after a turn routinely takes this
616    /// path. The stale clone keeps its old (transient) slice; the authoritative router's *next*
617    /// dispatch reads the freshly rebuilt Arc, so the mutation still persists (FR-007).
618    ///
619    /// Correctness invariant: this mutates the authoritative `RouterProvider` instance. Dispatch
620    /// (`chat`/`chat_with_tools`) reads `self.state` on the same authoritative instance the
621    /// agent holds, so a setter call landing before the next dispatch is guaranteed to be
622    /// observed by it. A future refactor that caches a pre-cloned dispatch copy ahead of time
623    /// would silently break this.
624    fn with_target_provider_mut<T>(
625        &mut self,
626        idx: usize,
627        f: impl FnOnce(&mut AnyProvider) -> T,
628    ) -> T {
629        if let Some(providers) = Arc::get_mut(&mut self.state.providers) {
630            f(&mut providers[idx])
631        } else {
632            let mut v: Vec<_> = self.state.providers.iter().cloned().collect();
633            let out = f(&mut v[idx]);
634            self.state.providers = Arc::from(v);
635            out
636        }
637    }
638
639    /// Delegated implementation of [`crate::any::AnyProvider::set_thinking_budget`] for
640    /// `Self::Router`. See [`Self::capability_target_index`] for target resolution.
641    ///
642    /// # Errors
643    ///
644    /// Returns [`LlmError::ModelCapabilityMismatch`] naming `"router"` when the pool is empty,
645    /// or the real inner provider's own error when it does not support a thinking-token budget.
646    pub(crate) fn set_thinking_budget_delegated(
647        &mut self,
648        budget: Option<u32>,
649    ) -> Result<(), LlmError> {
650        let idx =
651            self.capability_target_index()
652                .ok_or_else(|| LlmError::ModelCapabilityMismatch {
653                    provider: "router".to_owned(),
654                    message: "router has no configured providers".into(),
655                })?;
656        tracing::debug!(
657            target_idx = idx,
658            strategy = ?self.strategy,
659            "router: delegating set_thinking_budget to applicable inner provider"
660        );
661        self.with_target_provider_mut(idx, |p| p.set_thinking_budget(budget))
662    }
663
664    /// Delegated implementation of [`crate::any::AnyProvider::apply_reasoning_effort`] for
665    /// `Self::Router`. See [`Self::capability_target_index`] for target resolution.
666    ///
667    /// # Errors
668    ///
669    /// Returns [`LlmError::ModelCapabilityMismatch`] naming `"router"` when the pool is empty,
670    /// or the real inner provider's own error when it does not support a reasoning-effort level.
671    pub(crate) fn apply_reasoning_effort_delegated(
672        &mut self,
673        effort: crate::any::ReasoningEffort,
674    ) -> Result<(), LlmError> {
675        let idx =
676            self.capability_target_index()
677                .ok_or_else(|| LlmError::ModelCapabilityMismatch {
678                    provider: "router".to_owned(),
679                    message: "router has no configured providers".into(),
680                })?;
681        tracing::debug!(
682            target_idx = idx,
683            strategy = ?self.strategy,
684            "router: delegating apply_reasoning_effort to applicable inner provider"
685        );
686        self.with_target_provider_mut(idx, |p| p.apply_reasoning_effort(effort))
687    }
688
689    /// Delegated implementation of [`crate::any::AnyProvider::current_thinking_budget`] for
690    /// `Self::Router`.
691    #[must_use]
692    pub(crate) fn current_thinking_budget_delegated(&self) -> Option<u32> {
693        let idx = self.capability_target_index()?;
694        self.state.providers[idx].current_thinking_budget()
695    }
696
697    /// Delegated implementation of [`crate::any::AnyProvider::current_reasoning_effort`] for
698    /// `Self::Router`.
699    #[must_use]
700    pub(crate) fn current_reasoning_effort_delegated(&self) -> Option<String> {
701        let idx = self.capability_target_index()?;
702        self.state.providers[idx].current_reasoning_effort()
703    }
704
705    /// Delegated implementation of [`crate::any::AnyProvider::capability_delegation_advisory`]
706    /// for `Self::Router`.
707    ///
708    /// Returns `None` when the pool has at most one provider, or when `self.strategy` is
709    /// [`RouterStrategy::Cascade`] (deterministic cheapest-first — always reselects the same
710    /// slot barring quality-driven escalation). For re-sampling strategies (`Ema`, `Thompson`,
711    /// `Bandit`) over a multi-provider pool, the next dispatch may pick a different provider
712    /// than the one just configured — see spec `071-router-thinking-budget-delegation` §5.
713    #[must_use]
714    pub(crate) fn capability_delegation_advisory(&self) -> Option<String> {
715        if self.state.providers.len() <= 1 || self.strategy == RouterStrategy::Cascade {
716            return None;
717        }
718        let idx = self.capability_target_index()?;
719        let name = self.state.providers.get(idx)?.name();
720        Some(format!(
721            "applied to {name}; routing={:?} may select a different provider on the next turn",
722            self.strategy
723        ))
724    }
725
726    /// Aggregate model lists from all sub-providers, deduplicating by id.
727    ///
728    /// Individual sub-provider errors are logged as warnings and skipped.
729    ///
730    /// # Errors
731    ///
732    /// Always succeeds (errors per-provider are swallowed).
733    #[tracing::instrument(name = "llm.router.builder.list_models_remote", skip_all)]
734    pub async fn list_models_remote(
735        &self,
736    ) -> Result<Vec<crate::model_cache::RemoteModelInfo>, LlmError> {
737        let mut seen = std::collections::HashSet::new();
738        let mut all = Vec::new();
739        for p in self.state.providers.iter() {
740            match p.list_models_remote().await {
741                Ok(models) => {
742                    for m in models {
743                        if seen.insert(m.id.clone()) {
744                            all.push(m);
745                        }
746                    }
747                }
748                Err(e) => {
749                    tracing::warn!(error = %e, "router: list_models_remote sub-provider failed");
750                }
751            }
752        }
753        Ok(all)
754    }
755}