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