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}