Skip to main content

lc_core/
router_llm.rs

1// src/core/router_llm.rs
2//! Model routing, fallback and load balancing.
3//!
4//! `RouterLLM` implements `BaseChatModel` over a pool of heterogeneous chat
5//! models (OpenAI / Anthropic / Gemini / Ollama / ...), picking one per call
6//! according to a [`RoutingStrategy`] and falling back to the next model when
7//! the chosen one fails.
8//!
9//! # Example
10//! ```no_run
11//! use lc_core::router_llm::{RouterLLM, RoutingStrategy};
12//!
13//! // Empty primary-first fallback router. Register real models (OpenAIChat,
14//! // AnthropicChat, ...) via the `with_model` / `with_fallbacks` builders;
15//! // the router then tries them in order, falling back on error.
16//! let _router = RouterLLM::new(RoutingStrategy::Fallback);
17//! ```
18//!
19//! Each provider declares its own error type (`OpenAIError`, `AnthropicError`,
20//! ...), so `RouterLLM` cannot hold them behind a single `dyn BaseChatModel`.
21//! Instead it wraps every model in a `ModelAdapter` that converts the
22//! model's native error into the unified [`RouterError`].
23
24use crate::language_models::{BaseChatModel, BaseLanguageModel, LLMResult, StreamChunk};
25use crate::runnables::Runnable;
26use crate::RunnableConfig;
27use async_trait::async_trait;
28use futures_util::{Stream, StreamExt};
29use lc_schema::Message;
30use std::fmt::{self, Display, Formatter};
31use std::pin::Pin;
32use std::sync::atomic::{AtomicUsize, Ordering};
33use std::sync::{Arc, Mutex};
34use std::time::Instant;
35
36/// Unified error for [`RouterLLM`].
37///
38/// Aggregates heterogeneous provider errors behind `Box<dyn Error>` so a
39/// single router can mix providers whose native error types differ.
40#[derive(Debug)]
41#[non_exhaustive]
42pub enum RouterError {
43    /// No models were configured on the router.
44    Empty,
45    /// Every candidate model was tried and all failed.
46    /// `tried` is the number of models attempted; `last` is the final error.
47    AllFailed {
48        /// The number of models attempted.
49        tried: usize,
50        /// The final error from the last attempted model.
51        last: Box<dyn std::error::Error + Send + Sync>,
52    },
53    /// A single model failed (wrapped when propagating from an adapter).
54    Model {
55        /// The name of the model that failed.
56        model: String,
57        /// The underlying provider error.
58        source: Box<dyn std::error::Error + Send + Sync>,
59    },
60}
61
62impl Display for RouterError {
63    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
64        match self {
65            RouterError::Empty => write!(f, "no models configured in router"),
66            RouterError::AllFailed { tried, last } => {
67                write!(f, "all {} models failed; last error: {}", tried, last)
68            }
69            RouterError::Model { model, source } => {
70                write!(f, "model '{}' error: {}", model, source)
71            }
72        }
73    }
74}
75
76impl std::error::Error for RouterError {
77    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
78        match self {
79            RouterError::AllFailed { last, .. } => Some(last.as_ref()),
80            RouterError::Model { source, .. } => Some(source.as_ref()),
81            RouterError::Empty => None,
82        }
83    }
84}
85
86/// Strategy for selecting which model a [`RouterLLM`] tries first.
87///
88/// Regardless of strategy, a failed model triggers fallback to the next
89/// candidate in the derived order until one succeeds or all fail.
90pub enum RoutingStrategy {
91    /// Always try models in registration order (primary-first). Use with
92    /// [`RouterLLM::with_fallbacks`] for classic primary + backups semantics.
93    Fallback,
94    /// Rotate the starting index across calls so traffic spreads evenly.
95    RoundRobin,
96    /// Prefer the model with the lowest recent latency (exponential moving
97    /// average updated after each call).
98    LeastLatency,
99    /// Prefer the model with the lowest configured cost.
100    LowestCost,
101    /// Pick the primary index from a user-supplied closure over the input
102    /// text; remaining models are tried in registration order as fallback.
103    InputDirected(Arc<dyn Fn(&str) -> usize + Send + Sync>),
104}
105
106/// A chat model pool with routing and fallback.
107///
108/// See the [module docs](self) for design rationale.
109pub struct RouterLLM {
110    name: String,
111    slots: Vec<ModelSlot>,
112    strategy: RoutingStrategy,
113    /// Round-robin cursor.
114    counter: AtomicUsize,
115}
116
117impl RouterLLM {
118    /// Create an empty router with the given strategy. Add models via the
119    /// `with_*` builder methods.
120    pub fn new(strategy: RoutingStrategy) -> Self {
121        Self {
122            name: "router".to_string(),
123            slots: Vec::new(),
124            strategy,
125            counter: AtomicUsize::new(0),
126        }
127    }
128
129    /// Set a human-readable name returned by `model_name`.
130    pub fn with_name(mut self, name: impl Into<String>) -> Self {
131        self.name = name.into();
132        self
133    }
134
135    /// Register a model.
136    pub fn with_model<M>(mut self, model: M) -> Self
137    where
138        M: BaseChatModel + 'static,
139        M::Error: std::error::Error + Send + Sync + 'static,
140    {
141        self.slots
142            .push(ModelSlot::new(Box::new(ModelAdapter(model)), None));
143        self
144    }
145
146    /// Register a model with a relative cost used by [`RoutingStrategy::LowestCost`].
147    pub fn with_cost<M>(mut self, model: M, cost: f64) -> Self
148    where
149        M: BaseChatModel + 'static,
150        M::Error: std::error::Error + Send + Sync + 'static,
151    {
152        self.slots
153            .push(ModelSlot::new(Box::new(ModelAdapter(model)), Some(cost)));
154        self
155    }
156
157    /// Convenience constructor for primary-first fallback over models of the
158    /// same type. The primary is tried first; each fallback is tried in order
159    /// until one succeeds.
160    pub fn with_fallbacks<M>(primary: M, fallbacks: Vec<M>) -> Self
161    where
162        M: BaseChatModel + 'static,
163        M::Error: std::error::Error + Send + Sync + 'static,
164    {
165        let mut router = RouterLLM::new(RoutingStrategy::Fallback);
166        router = router.with_model(primary);
167        for fb in fallbacks {
168            router = router.with_model(fb);
169        }
170        router
171    }
172
173    /// Number of registered models.
174    pub fn len(&self) -> usize {
175        self.slots.len()
176    }
177
178    /// Whether no models are registered.
179    pub fn is_empty(&self) -> bool {
180        self.slots.is_empty()
181    }
182
183    /// Derive the candidate ordering for one call.
184    fn candidate_order(&self, input: &str) -> Vec<usize> {
185        let n = self.slots.len();
186        match &self.strategy {
187            RoutingStrategy::Fallback => (0..n).collect(),
188            RoutingStrategy::RoundRobin => {
189                if n == 0 {
190                    (0..n).collect()
191                } else {
192                    let start = self.counter.fetch_add(1, Ordering::SeqCst) % n;
193                    (0..n).map(|i| (start + i) % n).collect()
194                }
195            }
196            RoutingStrategy::LeastLatency => {
197                let mut idx: Vec<usize> = (0..n).collect();
198                idx.sort_by(|a, b| {
199                    // H4 fix: untried models (latency 0.0) sort last by using f64::MAX
200                    let la = {
201                        let v = self.slots[*a].latency();
202                        if v == 0.0 {
203                            f64::MAX
204                        } else {
205                            v
206                        }
207                    };
208                    let lb = {
209                        let v = self.slots[*b].latency();
210                        if v == 0.0 {
211                            f64::MAX
212                        } else {
213                            v
214                        }
215                    };
216                    la.partial_cmp(&lb).unwrap_or(std::cmp::Ordering::Equal)
217                });
218                idx
219            }
220            RoutingStrategy::LowestCost => {
221                let mut idx: Vec<usize> = (0..n).collect();
222                idx.sort_by(|a, b| {
223                    let ca = self.slots[*a].cost.unwrap_or(f64::MAX);
224                    let cb = self.slots[*b].cost.unwrap_or(f64::MAX);
225                    ca.partial_cmp(&cb).unwrap_or(std::cmp::Ordering::Equal)
226                });
227                idx
228            }
229            RoutingStrategy::InputDirected(f) => {
230                let primary = f(input);
231                if primary < n {
232                    let mut order = vec![primary];
233                    order.extend((0..n).filter(|&i| i != primary));
234                    order
235                } else {
236                    // Out-of-range index: fall back to registration order.
237                    (0..n).collect()
238                }
239            }
240        }
241    }
242
243    fn first_text(messages: &[Message]) -> &str {
244        messages.first().map(|m| m.content.as_str()).unwrap_or("")
245    }
246
247    async fn chat_routed(
248        &self,
249        messages: Vec<Message>,
250        config: Option<RunnableConfig>,
251    ) -> Result<LLMResult, RouterError> {
252        if self.slots.is_empty() {
253            return Err(RouterError::Empty);
254        }
255        let order = self.candidate_order(Self::first_text(&messages));
256        // H3 fix: remove ineffective Arc — just clone messages directly for each attempt
257        let mut last_err: Option<Box<dyn std::error::Error + Send + Sync>> = None;
258        for &idx in &order {
259            let slot = &self.slots[idx];
260            let start = Instant::now();
261            let res = slot.model.chat(messages.clone(), config.clone()).await;
262            let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
263            slot.update_latency(elapsed_ms);
264            match res {
265                Ok(result) => return Ok(result),
266                Err(e) => last_err = Some(Box::new(e)),
267            }
268        }
269        Err(RouterError::AllFailed {
270            tried: order.len(),
271            last: last_err.unwrap_or_else(|| {
272                Box::new(std::io::Error::other(
273                    "candidate order produced no attempts",
274                ))
275            }),
276        })
277    }
278
279    async fn stream_chat_routed(
280        &self,
281        messages: Vec<Message>,
282        config: Option<RunnableConfig>,
283    ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, RouterError>> + Send>>, RouterError>
284    {
285        if self.slots.is_empty() {
286            return Err(RouterError::Empty);
287        }
288        let order = self.candidate_order(Self::first_text(&messages));
289        // H3 fix: remove ineffective Arc — just clone messages directly for each attempt
290        let mut last_err: Option<Box<dyn std::error::Error + Send + Sync>> = None;
291        for &idx in &order {
292            let slot = &self.slots[idx];
293            let start = Instant::now();
294            match slot
295                .model
296                .stream_chat(messages.clone(), config.clone())
297                .await
298            {
299                Ok(s) => {
300                    let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
301                    slot.update_latency(elapsed_ms);
302                    return Ok(s);
303                }
304                Err(e) => {
305                    let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
306                    slot.update_latency(elapsed_ms);
307                    last_err = Some(Box::new(e));
308                }
309            }
310        }
311        Err(RouterError::AllFailed {
312            tried: order.len(),
313            last: last_err.unwrap_or_else(|| {
314                Box::new(std::io::Error::other(
315                    "candidate order produced no attempts",
316                ))
317            }),
318        })
319    }
320}
321
322#[async_trait]
323impl Runnable<Vec<Message>, LLMResult> for RouterLLM {
324    type Error = RouterError;
325
326    async fn invoke(
327        &self,
328        input: Vec<Message>,
329        config: Option<RunnableConfig>,
330    ) -> Result<LLMResult, Self::Error> {
331        self.chat_routed(input, config).await
332    }
333}
334
335#[async_trait]
336impl BaseLanguageModel<Vec<Message>, LLMResult> for RouterLLM {
337    fn model_name(&self) -> &str {
338        &self.name
339    }
340
341    fn get_num_tokens(&self, text: &str) -> usize {
342        crate::token_counter::count_tokens(text).unwrap_or_else(|e| {
343            // 编码器加载失败时按字节数高估(宁可略高,不静默按 0 算导致路由/截断误判)
344            log::warn!("token counting failed, falling back to byte-length estimate: {e}");
345            text.len()
346        })
347    }
348
349    fn with_temperature(self, _temp: f32) -> Self {
350        // The router deliberately does not override temperature: each slot
351        // owns its model's sampling parameters, and they cannot be mutated
352        // behind a `Box<dyn RoutedModel>` trait object. Configure temperature
353        // on the individual models before registering them (Q5: no silent
354        // change — this is an explicit no-op, not an attempt to apply it).
355        self
356    }
357
358    fn with_max_tokens(self, _max: usize) -> Self {
359        // Same rationale as `with_temperature`: per-slot max_tokens is owned
360        // by each registered model and cannot be changed after boxing.
361        self
362    }
363}
364
365#[async_trait]
366impl BaseChatModel for RouterLLM {
367    async fn chat(
368        &self,
369        messages: Vec<Message>,
370        config: Option<RunnableConfig>,
371    ) -> Result<LLMResult, Self::Error> {
372        self.chat_routed(messages, config).await
373    }
374
375    async fn stream_chat(
376        &self,
377        messages: Vec<Message>,
378        config: Option<RunnableConfig>,
379    ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
380    {
381        self.stream_chat_routed(messages, config).await
382    }
383}
384
385// ---------------------------------------------------------------------------
386// Internal: heterogeneous model adapter
387// ---------------------------------------------------------------------------
388
389/// Internal trait unifying the error type across providers.
390#[async_trait]
391trait RoutedModel: Send + Sync {
392    async fn chat(
393        &self,
394        messages: Vec<Message>,
395        config: Option<RunnableConfig>,
396    ) -> Result<LLMResult, RouterError>;
397    async fn stream_chat(
398        &self,
399        messages: Vec<Message>,
400        config: Option<RunnableConfig>,
401    ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, RouterError>> + Send>>, RouterError>;
402}
403
404/// Wraps any `BaseChatModel` whose error is `std::error::Error + Send + Sync`,
405/// converting its native error into [`RouterError`].
406struct ModelAdapter<M: BaseChatModel>(M);
407
408#[async_trait]
409impl<M: BaseChatModel> RoutedModel for ModelAdapter<M>
410where
411    M::Error: std::error::Error + Send + Sync + 'static,
412{
413    async fn chat(
414        &self,
415        messages: Vec<Message>,
416        config: Option<RunnableConfig>,
417    ) -> Result<LLMResult, RouterError> {
418        let name = self.0.model_name().to_string();
419        self.0
420            .chat(messages, config)
421            .await
422            .map_err(|e| RouterError::Model {
423                model: name,
424                source: Box::new(e),
425            })
426    }
427
428    async fn stream_chat(
429        &self,
430        messages: Vec<Message>,
431        config: Option<RunnableConfig>,
432    ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, RouterError>> + Send>>, RouterError>
433    {
434        let name = self.0.model_name().to_string();
435        let inner = self
436            .0
437            .stream_chat(messages, config)
438            .await
439            .map_err(|e| RouterError::Model {
440                model: name.clone(),
441                source: Box::new(e),
442            })?;
443        let mapped = inner.map(move |item| {
444            item.map_err(|e| RouterError::Model {
445                model: name.clone(),
446                source: Box::new(e),
447            })
448        });
449        Ok(Box::pin(mapped))
450    }
451}
452
453/// One registered model plus routing metadata.
454struct ModelSlot {
455    model: Box<dyn RoutedModel>,
456    cost: Option<f64>,
457    /// Exponential moving average latency in milliseconds.
458    latency_ms: Mutex<f64>,
459}
460
461impl ModelSlot {
462    fn new(model: Box<dyn RoutedModel>, cost: Option<f64>) -> Self {
463        Self {
464            model,
465            cost,
466            latency_ms: Mutex::new(0.0),
467        }
468    }
469
470    fn latency(&self) -> f64 {
471        *self.latency_ms.lock().unwrap_or_else(|e| e.into_inner())
472    }
473
474    fn update_latency(&self, ms: f64) {
475        let mut cur = self.latency_ms.lock().unwrap_or_else(|e| e.into_inner());
476        if *cur == 0.0 {
477            *cur = ms;
478        } else {
479            // EMA: weight history 0.7, new sample 0.3.
480            *cur = *cur * 0.7 + ms * 0.3;
481        }
482    }
483}
484
485#[cfg(test)]
486mod tests {
487    //! Internal unit tests for routing order derivation.
488    //! Heterogeneous-model behavior is covered in `tests/unit/router_llm.rs`.
489
490    use super::*;
491
492    fn slot_at(cost: Option<f64>, latency: f64) -> ModelSlot {
493        // A minimal stand-in model is not needed to test order derivation;
494        // we only exercise `candidate_order` / `latency` logic. Build slots
495        // with a no-op model via a tiny helper trait impl below.
496        struct Noop;
497        #[async_trait]
498        impl RoutedModel for Noop {
499            async fn chat(
500                &self,
501                _m: Vec<Message>,
502                _c: Option<RunnableConfig>,
503            ) -> Result<LLMResult, RouterError> {
504                Err(RouterError::Empty)
505            }
506            async fn stream_chat(
507                &self,
508                _m: Vec<Message>,
509                _c: Option<RunnableConfig>,
510            ) -> Result<
511                Pin<Box<dyn Stream<Item = Result<StreamChunk, RouterError>> + Send>>,
512                RouterError,
513            > {
514                Err(RouterError::Empty)
515            }
516        }
517        let s = ModelSlot::new(Box::new(Noop), cost);
518        *s.latency_ms.lock().unwrap_or_else(|e| e.into_inner()) = latency;
519        s
520    }
521
522    fn router_with(slots: Vec<ModelSlot>, strategy: RoutingStrategy) -> RouterLLM {
523        RouterLLM {
524            name: "router".to_string(),
525            slots,
526            strategy,
527            counter: AtomicUsize::new(0),
528        }
529    }
530
531    #[test]
532    fn candidate_order_fallback_is_registration_order() {
533        let r = router_with(
534            vec![slot_at(None, 0.0), slot_at(None, 0.0), slot_at(None, 0.0)],
535            RoutingStrategy::Fallback,
536        );
537        assert_eq!(r.candidate_order(""), vec![0, 1, 2]);
538    }
539
540    #[test]
541    fn candidate_order_lowest_cost_sorts_by_cost() {
542        let r = router_with(
543            vec![
544                slot_at(Some(10.0), 0.0), // idx 0
545                slot_at(Some(1.0), 0.0),  // idx 1
546            ],
547            RoutingStrategy::LowestCost,
548        );
549        assert_eq!(r.candidate_order(""), vec![1, 0]);
550    }
551
552    #[test]
553    fn candidate_order_least_latency_sorts_by_latency() {
554        let r = router_with(
555            vec![
556                slot_at(None, 80.0), // idx 0
557                slot_at(None, 5.0),  // idx 1
558            ],
559            RoutingStrategy::LeastLatency,
560        );
561        assert_eq!(r.candidate_order(""), vec![1, 0]);
562    }
563
564    #[test]
565    fn candidate_order_input_directed_puts_primary_first() {
566        let r = router_with(
567            vec![slot_at(None, 0.0), slot_at(None, 0.0)],
568            RoutingStrategy::InputDirected(Arc::new(|s| if s.contains("x") { 1 } else { 0 })),
569        );
570        assert_eq!(r.candidate_order("hello"), vec![0, 1]);
571        assert_eq!(r.candidate_order("x marks"), vec![1, 0]);
572    }
573
574    #[test]
575    fn candidate_order_input_directed_invalid_index_falls_back() {
576        let r = router_with(
577            vec![slot_at(None, 0.0), slot_at(None, 0.0)],
578            RoutingStrategy::InputDirected(Arc::new(|_| 99)),
579        );
580        assert_eq!(r.candidate_order("hi"), vec![0, 1]);
581    }
582
583    #[test]
584    fn update_latency_ema_blends_samples() {
585        let s = slot_at(None, 0.0);
586        s.update_latency(100.0);
587        assert_eq!(s.latency(), 100.0); // first sample replaces 0
588        s.update_latency(100.0);
589        // 100 * 0.7 + 100 * 0.3 = 100
590        assert_eq!(s.latency(), 100.0);
591        s.update_latency(40.0);
592        // 100 * 0.7 + 40 * 0.3 = 82
593        assert_eq!(s.latency(), 82.0);
594    }
595}