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};
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<String, RouterError>> + Send>>, RouterError> {
284        if self.slots.is_empty() {
285            return Err(RouterError::Empty);
286        }
287        let order = self.candidate_order(Self::first_text(&messages));
288        // H3 fix: remove ineffective Arc — just clone messages directly for each attempt
289        let mut last_err: Option<Box<dyn std::error::Error + Send + Sync>> = None;
290        for &idx in &order {
291            let slot = &self.slots[idx];
292            let start = Instant::now();
293            match slot
294                .model
295                .stream_chat(messages.clone(), config.clone())
296                .await
297            {
298                Ok(s) => {
299                    let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
300                    slot.update_latency(elapsed_ms);
301                    return Ok(s);
302                }
303                Err(e) => {
304                    let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
305                    slot.update_latency(elapsed_ms);
306                    last_err = Some(Box::new(e));
307                }
308            }
309        }
310        Err(RouterError::AllFailed {
311            tried: order.len(),
312            last: last_err.unwrap_or_else(|| {
313                Box::new(std::io::Error::other(
314                    "candidate order produced no attempts",
315                ))
316            }),
317        })
318    }
319}
320
321#[async_trait]
322impl Runnable<Vec<Message>, LLMResult> for RouterLLM {
323    type Error = RouterError;
324
325    async fn invoke(
326        &self,
327        input: Vec<Message>,
328        config: Option<RunnableConfig>,
329    ) -> Result<LLMResult, Self::Error> {
330        self.chat_routed(input, config).await
331    }
332}
333
334#[async_trait]
335impl BaseLanguageModel<Vec<Message>, LLMResult> for RouterLLM {
336    fn model_name(&self) -> &str {
337        &self.name
338    }
339
340    fn get_num_tokens(&self, text: &str) -> usize {
341        crate::token_counter::count_tokens(text).unwrap_or_else(|e| {
342            // 编码器加载失败时按字节数高估(宁可略高,不静默按 0 算导致路由/截断误判)
343            log::warn!("token counting failed, falling back to byte-length estimate: {e}");
344            text.len()
345        })
346    }
347
348    fn with_temperature(self, _temp: f32) -> Self {
349        // The router deliberately does not override temperature: each slot
350        // owns its model's sampling parameters, and they cannot be mutated
351        // behind a `Box<dyn RoutedModel>` trait object. Configure temperature
352        // on the individual models before registering them (Q5: no silent
353        // change — this is an explicit no-op, not an attempt to apply it).
354        self
355    }
356
357    fn with_max_tokens(self, _max: usize) -> Self {
358        // Same rationale as `with_temperature`: per-slot max_tokens is owned
359        // by each registered model and cannot be changed after boxing.
360        self
361    }
362}
363
364#[async_trait]
365impl BaseChatModel for RouterLLM {
366    async fn chat(
367        &self,
368        messages: Vec<Message>,
369        config: Option<RunnableConfig>,
370    ) -> Result<LLMResult, Self::Error> {
371        self.chat_routed(messages, config).await
372    }
373
374    async fn stream_chat(
375        &self,
376        messages: Vec<Message>,
377        config: Option<RunnableConfig>,
378    ) -> Result<Pin<Box<dyn Stream<Item = Result<String, Self::Error>> + Send>>, Self::Error> {
379        self.stream_chat_routed(messages, config).await
380    }
381}
382
383// ---------------------------------------------------------------------------
384// Internal: heterogeneous model adapter
385// ---------------------------------------------------------------------------
386
387/// Internal trait unifying the error type across providers.
388#[async_trait]
389trait RoutedModel: Send + Sync {
390    async fn chat(
391        &self,
392        messages: Vec<Message>,
393        config: Option<RunnableConfig>,
394    ) -> Result<LLMResult, RouterError>;
395    async fn stream_chat(
396        &self,
397        messages: Vec<Message>,
398        config: Option<RunnableConfig>,
399    ) -> Result<Pin<Box<dyn Stream<Item = Result<String, RouterError>> + Send>>, RouterError>;
400}
401
402/// Wraps any `BaseChatModel` whose error is `std::error::Error + Send + Sync`,
403/// converting its native error into [`RouterError`].
404struct ModelAdapter<M: BaseChatModel>(M);
405
406#[async_trait]
407impl<M: BaseChatModel> RoutedModel for ModelAdapter<M>
408where
409    M::Error: std::error::Error + Send + Sync + 'static,
410{
411    async fn chat(
412        &self,
413        messages: Vec<Message>,
414        config: Option<RunnableConfig>,
415    ) -> Result<LLMResult, RouterError> {
416        let name = self.0.model_name().to_string();
417        self.0
418            .chat(messages, config)
419            .await
420            .map_err(|e| RouterError::Model {
421                model: name,
422                source: Box::new(e),
423            })
424    }
425
426    async fn stream_chat(
427        &self,
428        messages: Vec<Message>,
429        config: Option<RunnableConfig>,
430    ) -> Result<Pin<Box<dyn Stream<Item = Result<String, RouterError>> + Send>>, RouterError> {
431        let name = self.0.model_name().to_string();
432        let inner = self
433            .0
434            .stream_chat(messages, config)
435            .await
436            .map_err(|e| RouterError::Model {
437                model: name.clone(),
438                source: Box::new(e),
439            })?;
440        let mapped = inner.map(move |item| {
441            item.map_err(|e| RouterError::Model {
442                model: name.clone(),
443                source: Box::new(e),
444            })
445        });
446        Ok(Box::pin(mapped))
447    }
448}
449
450/// One registered model plus routing metadata.
451struct ModelSlot {
452    model: Box<dyn RoutedModel>,
453    cost: Option<f64>,
454    /// Exponential moving average latency in milliseconds.
455    latency_ms: Mutex<f64>,
456}
457
458impl ModelSlot {
459    fn new(model: Box<dyn RoutedModel>, cost: Option<f64>) -> Self {
460        Self {
461            model,
462            cost,
463            latency_ms: Mutex::new(0.0),
464        }
465    }
466
467    fn latency(&self) -> f64 {
468        *self.latency_ms.lock().unwrap_or_else(|e| e.into_inner())
469    }
470
471    fn update_latency(&self, ms: f64) {
472        let mut cur = self.latency_ms.lock().unwrap_or_else(|e| e.into_inner());
473        if *cur == 0.0 {
474            *cur = ms;
475        } else {
476            // EMA: weight history 0.7, new sample 0.3.
477            *cur = *cur * 0.7 + ms * 0.3;
478        }
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    //! Internal unit tests for routing order derivation.
485    //! Heterogeneous-model behavior is covered in `tests/unit/router_llm.rs`.
486
487    use super::*;
488
489    fn slot_at(cost: Option<f64>, latency: f64) -> ModelSlot {
490        // A minimal stand-in model is not needed to test order derivation;
491        // we only exercise `candidate_order` / `latency` logic. Build slots
492        // with a no-op model via a tiny helper trait impl below.
493        struct Noop;
494        #[async_trait]
495        impl RoutedModel for Noop {
496            async fn chat(
497                &self,
498                _m: Vec<Message>,
499                _c: Option<RunnableConfig>,
500            ) -> Result<LLMResult, RouterError> {
501                Err(RouterError::Empty)
502            }
503            async fn stream_chat(
504                &self,
505                _m: Vec<Message>,
506                _c: Option<RunnableConfig>,
507            ) -> Result<Pin<Box<dyn Stream<Item = Result<String, RouterError>> + Send>>, RouterError>
508            {
509                Err(RouterError::Empty)
510            }
511        }
512        let s = ModelSlot::new(Box::new(Noop), cost);
513        *s.latency_ms.lock().unwrap_or_else(|e| e.into_inner()) = latency;
514        s
515    }
516
517    fn router_with(slots: Vec<ModelSlot>, strategy: RoutingStrategy) -> RouterLLM {
518        RouterLLM {
519            name: "router".to_string(),
520            slots,
521            strategy,
522            counter: AtomicUsize::new(0),
523        }
524    }
525
526    #[test]
527    fn candidate_order_fallback_is_registration_order() {
528        let r = router_with(
529            vec![slot_at(None, 0.0), slot_at(None, 0.0), slot_at(None, 0.0)],
530            RoutingStrategy::Fallback,
531        );
532        assert_eq!(r.candidate_order(""), vec![0, 1, 2]);
533    }
534
535    #[test]
536    fn candidate_order_lowest_cost_sorts_by_cost() {
537        let r = router_with(
538            vec![
539                slot_at(Some(10.0), 0.0), // idx 0
540                slot_at(Some(1.0), 0.0),  // idx 1
541            ],
542            RoutingStrategy::LowestCost,
543        );
544        assert_eq!(r.candidate_order(""), vec![1, 0]);
545    }
546
547    #[test]
548    fn candidate_order_least_latency_sorts_by_latency() {
549        let r = router_with(
550            vec![
551                slot_at(None, 80.0), // idx 0
552                slot_at(None, 5.0),  // idx 1
553            ],
554            RoutingStrategy::LeastLatency,
555        );
556        assert_eq!(r.candidate_order(""), vec![1, 0]);
557    }
558
559    #[test]
560    fn candidate_order_input_directed_puts_primary_first() {
561        let r = router_with(
562            vec![slot_at(None, 0.0), slot_at(None, 0.0)],
563            RoutingStrategy::InputDirected(Arc::new(|s| if s.contains("x") { 1 } else { 0 })),
564        );
565        assert_eq!(r.candidate_order("hello"), vec![0, 1]);
566        assert_eq!(r.candidate_order("x marks"), vec![1, 0]);
567    }
568
569    #[test]
570    fn candidate_order_input_directed_invalid_index_falls_back() {
571        let r = router_with(
572            vec![slot_at(None, 0.0), slot_at(None, 0.0)],
573            RoutingStrategy::InputDirected(Arc::new(|_| 99)),
574        );
575        assert_eq!(r.candidate_order("hi"), vec![0, 1]);
576    }
577
578    #[test]
579    fn update_latency_ema_blends_samples() {
580        let s = slot_at(None, 0.0);
581        s.update_latency(100.0);
582        assert_eq!(s.latency(), 100.0); // first sample replaces 0
583        s.update_latency(100.0);
584        // 100 * 0.7 + 100 * 0.3 = 100
585        assert_eq!(s.latency(), 100.0);
586        s.update_latency(40.0);
587        // 100 * 0.7 + 40 * 0.3 = 82
588        assert_eq!(s.latency(), 82.0);
589    }
590}