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//! ```
11//! use langchainrust::{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)
349    }
350
351    fn with_temperature(self, _temp: f32) -> Self {
352        // Per-model temperature is owned by each slot; the router itself does
353        // not override it. No-op to satisfy the trait contract.
354        self
355    }
356
357    fn with_max_tokens(self, _max: usize) -> Self {
358        self
359    }
360}
361
362#[async_trait]
363impl BaseChatModel for RouterLLM {
364    async fn chat(
365        &self,
366        messages: Vec<Message>,
367        config: Option<RunnableConfig>,
368    ) -> Result<LLMResult, Self::Error> {
369        self.chat_routed(messages, config).await
370    }
371
372    async fn stream_chat(
373        &self,
374        messages: Vec<Message>,
375        config: Option<RunnableConfig>,
376    ) -> Result<Pin<Box<dyn Stream<Item = Result<String, Self::Error>> + Send>>, Self::Error> {
377        self.stream_chat_routed(messages, config).await
378    }
379}
380
381// ---------------------------------------------------------------------------
382// Internal: heterogeneous model adapter
383// ---------------------------------------------------------------------------
384
385/// Internal trait unifying the error type across providers.
386#[async_trait]
387trait RoutedModel: Send + Sync {
388    async fn chat(
389        &self,
390        messages: Vec<Message>,
391        config: Option<RunnableConfig>,
392    ) -> Result<LLMResult, RouterError>;
393    async fn stream_chat(
394        &self,
395        messages: Vec<Message>,
396        config: Option<RunnableConfig>,
397    ) -> Result<Pin<Box<dyn Stream<Item = Result<String, RouterError>> + Send>>, RouterError>;
398}
399
400/// Wraps any `BaseChatModel` whose error is `std::error::Error + Send + Sync`,
401/// converting its native error into [`RouterError`].
402struct ModelAdapter<M: BaseChatModel>(M);
403
404#[async_trait]
405impl<M: BaseChatModel> RoutedModel for ModelAdapter<M>
406where
407    M::Error: std::error::Error + Send + Sync + 'static,
408{
409    async fn chat(
410        &self,
411        messages: Vec<Message>,
412        config: Option<RunnableConfig>,
413    ) -> Result<LLMResult, RouterError> {
414        let name = self.0.model_name().to_string();
415        self.0
416            .chat(messages, config)
417            .await
418            .map_err(|e| RouterError::Model {
419                model: name,
420                source: Box::new(e),
421            })
422    }
423
424    async fn stream_chat(
425        &self,
426        messages: Vec<Message>,
427        config: Option<RunnableConfig>,
428    ) -> Result<Pin<Box<dyn Stream<Item = Result<String, RouterError>> + Send>>, RouterError> {
429        let name = self.0.model_name().to_string();
430        let inner = self
431            .0
432            .stream_chat(messages, config)
433            .await
434            .map_err(|e| RouterError::Model {
435                model: name.clone(),
436                source: Box::new(e),
437            })?;
438        let mapped = inner.map(move |item| {
439            item.map_err(|e| RouterError::Model {
440                model: name.clone(),
441                source: Box::new(e),
442            })
443        });
444        Ok(Box::pin(mapped))
445    }
446}
447
448/// One registered model plus routing metadata.
449struct ModelSlot {
450    #[allow(dead_code)]
451    name: String,
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(name: String, model: Box<dyn RoutedModel>, cost: Option<f64>) -> Self {
460        Self {
461            name,
462            model,
463            cost,
464            latency_ms: Mutex::new(0.0),
465        }
466    }
467
468    fn latency(&self) -> f64 {
469        *self.latency_ms.lock().unwrap_or_else(|e| e.into_inner())
470    }
471
472    fn update_latency(&self, ms: f64) {
473        let mut cur = self.latency_ms.lock().unwrap_or_else(|e| e.into_inner());
474        if *cur == 0.0 {
475            *cur = ms;
476        } else {
477            // EMA: weight history 0.7, new sample 0.3.
478            *cur = *cur * 0.7 + ms * 0.3;
479        }
480    }
481}
482
483#[cfg(test)]
484mod tests {
485    //! Internal unit tests for routing order derivation.
486    //! Heterogeneous-model behavior is covered in `tests/unit/router_llm.rs`.
487
488    use super::*;
489
490    fn slot_at(name: &str, cost: Option<f64>, latency: f64) -> ModelSlot {
491        // A minimal stand-in model is not needed to test order derivation;
492        // we only exercise `candidate_order` / `latency` logic. Build slots
493        // with a no-op model via a tiny helper trait impl below.
494        struct Noop;
495        #[async_trait]
496        impl RoutedModel for Noop {
497            async fn chat(
498                &self,
499                _m: Vec<Message>,
500                _c: Option<RunnableConfig>,
501            ) -> Result<LLMResult, RouterError> {
502                Err(RouterError::Empty)
503            }
504            async fn stream_chat(
505                &self,
506                _m: Vec<Message>,
507                _c: Option<RunnableConfig>,
508            ) -> Result<Pin<Box<dyn Stream<Item = Result<String, RouterError>> + Send>>, RouterError>
509            {
510                Err(RouterError::Empty)
511            }
512        }
513        let s = ModelSlot::new(name.to_string(), Box::new(Noop), cost);
514        *s.latency_ms.lock().unwrap() = latency;
515        s
516    }
517
518    fn router_with(slots: Vec<ModelSlot>, strategy: RoutingStrategy) -> RouterLLM {
519        RouterLLM {
520            name: "router".to_string(),
521            slots,
522            strategy,
523            counter: AtomicUsize::new(0),
524        }
525    }
526
527    #[test]
528    fn candidate_order_fallback_is_registration_order() {
529        let r = router_with(
530            vec![
531                slot_at("a", None, 0.0),
532                slot_at("b", None, 0.0),
533                slot_at("c", None, 0.0),
534            ],
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("pricey", Some(10.0), 0.0), // idx 0
545                slot_at("cheap", 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("slow", None, 80.0), // idx 0
557                slot_at("fast", 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("a", None, 0.0), slot_at("b", 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("a", None, 0.0), slot_at("b", 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("m", 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}