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.
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        self.slots
137            .push(ModelSlot::new(Box::new(ModelAdapter(model)), None));
138        self
139    }
140
141    /// Register a model with a relative cost used by [`RoutingStrategy::LowestCost`].
142    pub fn with_cost<M>(mut self, model: M, cost: f64) -> Self
143    where
144        M: BaseChatModel + 'static,
145        M::Error: std::error::Error + Send + Sync + 'static,
146    {
147        self.slots
148            .push(ModelSlot::new(Box::new(ModelAdapter(model)), Some(cost)));
149        self
150    }
151
152    /// Convenience constructor for primary-first fallback over models of the
153    /// same type. The primary is tried first; each fallback is tried in order
154    /// until one succeeds.
155    pub fn with_fallbacks<M>(primary: M, fallbacks: Vec<M>) -> Self
156    where
157        M: BaseChatModel + 'static,
158        M::Error: std::error::Error + Send + Sync + 'static,
159    {
160        let mut router = RouterLLM::new(RoutingStrategy::Fallback);
161        router = router.with_model(primary);
162        for fb in fallbacks {
163            router = router.with_model(fb);
164        }
165        router
166    }
167
168    /// Number of registered models.
169    pub fn len(&self) -> usize {
170        self.slots.len()
171    }
172
173    /// Whether no models are registered.
174    pub fn is_empty(&self) -> bool {
175        self.slots.is_empty()
176    }
177
178    /// Derive the candidate ordering for one call.
179    fn candidate_order(&self, input: &str) -> Vec<usize> {
180        let n = self.slots.len();
181        match &self.strategy {
182            RoutingStrategy::Fallback => (0..n).collect(),
183            RoutingStrategy::RoundRobin => {
184                if n == 0 {
185                    (0..n).collect()
186                } else {
187                    let start = self.counter.fetch_add(1, Ordering::SeqCst) % n;
188                    (0..n).map(|i| (start + i) % n).collect()
189                }
190            }
191            RoutingStrategy::LeastLatency => {
192                let mut idx: Vec<usize> = (0..n).collect();
193                idx.sort_by(|a, b| {
194                    // H4 fix: untried models (latency 0.0) sort last by using f64::MAX
195                    let la = {
196                        let v = self.slots[*a].latency();
197                        if v == 0.0 {
198                            f64::MAX
199                        } else {
200                            v
201                        }
202                    };
203                    let lb = {
204                        let v = self.slots[*b].latency();
205                        if v == 0.0 {
206                            f64::MAX
207                        } else {
208                            v
209                        }
210                    };
211                    la.partial_cmp(&lb).unwrap_or(std::cmp::Ordering::Equal)
212                });
213                idx
214            }
215            RoutingStrategy::LowestCost => {
216                let mut idx: Vec<usize> = (0..n).collect();
217                idx.sort_by(|a, b| {
218                    let ca = self.slots[*a].cost.unwrap_or(f64::MAX);
219                    let cb = self.slots[*b].cost.unwrap_or(f64::MAX);
220                    ca.partial_cmp(&cb).unwrap_or(std::cmp::Ordering::Equal)
221                });
222                idx
223            }
224            RoutingStrategy::InputDirected(f) => {
225                let primary = f(input);
226                if primary < n {
227                    let mut order = vec![primary];
228                    order.extend((0..n).filter(|&i| i != primary));
229                    order
230                } else {
231                    // Out-of-range index: fall back to registration order.
232                    (0..n).collect()
233                }
234            }
235        }
236    }
237
238    fn first_text(messages: &[Message]) -> &str {
239        messages.first().map(|m| m.content.as_str()).unwrap_or("")
240    }
241
242    async fn chat_routed(
243        &self,
244        messages: Vec<Message>,
245        config: Option<RunnableConfig>,
246    ) -> Result<LLMResult, RouterError> {
247        if self.slots.is_empty() {
248            return Err(RouterError::Empty);
249        }
250        let order = self.candidate_order(Self::first_text(&messages));
251        // H3 fix: remove ineffective Arc — just clone messages directly for each attempt
252        let mut last_err: Option<Box<dyn std::error::Error + Send + Sync>> = None;
253        for &idx in &order {
254            let slot = &self.slots[idx];
255            let start = Instant::now();
256            let res = slot.model.chat(messages.clone(), config.clone()).await;
257            let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
258            slot.update_latency(elapsed_ms);
259            match res {
260                Ok(result) => return Ok(result),
261                Err(e) => last_err = Some(Box::new(e)),
262            }
263        }
264        Err(RouterError::AllFailed {
265            tried: order.len(),
266            last: last_err.unwrap_or_else(|| {
267                Box::new(std::io::Error::other(
268                    "candidate order produced no attempts",
269                ))
270            }),
271        })
272    }
273
274    async fn stream_chat_routed(
275        &self,
276        messages: Vec<Message>,
277        config: Option<RunnableConfig>,
278    ) -> Result<Pin<Box<dyn Stream<Item = Result<String, RouterError>> + Send>>, RouterError> {
279        if self.slots.is_empty() {
280            return Err(RouterError::Empty);
281        }
282        let order = self.candidate_order(Self::first_text(&messages));
283        // H3 fix: remove ineffective Arc — just clone messages directly for each attempt
284        let mut last_err: Option<Box<dyn std::error::Error + Send + Sync>> = None;
285        for &idx in &order {
286            let slot = &self.slots[idx];
287            let start = Instant::now();
288            match slot
289                .model
290                .stream_chat(messages.clone(), config.clone())
291                .await
292            {
293                Ok(s) => {
294                    let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
295                    slot.update_latency(elapsed_ms);
296                    return Ok(s);
297                }
298                Err(e) => {
299                    let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
300                    slot.update_latency(elapsed_ms);
301                    last_err = Some(Box::new(e));
302                }
303            }
304        }
305        Err(RouterError::AllFailed {
306            tried: order.len(),
307            last: last_err.unwrap_or_else(|| {
308                Box::new(std::io::Error::other(
309                    "candidate order produced no attempts",
310                ))
311            }),
312        })
313    }
314}
315
316#[async_trait]
317impl Runnable<Vec<Message>, LLMResult> for RouterLLM {
318    type Error = RouterError;
319
320    async fn invoke(
321        &self,
322        input: Vec<Message>,
323        config: Option<RunnableConfig>,
324    ) -> Result<LLMResult, Self::Error> {
325        self.chat_routed(input, config).await
326    }
327}
328
329#[async_trait]
330impl BaseLanguageModel<Vec<Message>, LLMResult> for RouterLLM {
331    fn model_name(&self) -> &str {
332        &self.name
333    }
334
335    fn get_num_tokens(&self, text: &str) -> usize {
336        crate::token_counter::count_tokens(text).unwrap_or_else(|e| {
337            // 编码器加载失败时按字节数高估(宁可略高,不静默按 0 算导致路由/截断误判)
338            log::warn!("token 计数失败,回退为按字节数估算: {e}");
339            text.len()
340        })
341    }
342
343    fn with_temperature(self, _temp: f32) -> Self {
344        // The router deliberately does not override temperature: each slot
345        // owns its model's sampling parameters, and they cannot be mutated
346        // behind a `Box<dyn RoutedModel>` trait object. Configure temperature
347        // on the individual models before registering them (Q5: no silent
348        // change — this is an explicit no-op, not an attempt to apply it).
349        self
350    }
351
352    fn with_max_tokens(self, _max: usize) -> Self {
353        // Same rationale as `with_temperature`: per-slot max_tokens is owned
354        // by each registered model and cannot be changed after boxing.
355        self
356    }
357}
358
359#[async_trait]
360impl BaseChatModel for RouterLLM {
361    async fn chat(
362        &self,
363        messages: Vec<Message>,
364        config: Option<RunnableConfig>,
365    ) -> Result<LLMResult, Self::Error> {
366        self.chat_routed(messages, config).await
367    }
368
369    async fn stream_chat(
370        &self,
371        messages: Vec<Message>,
372        config: Option<RunnableConfig>,
373    ) -> Result<Pin<Box<dyn Stream<Item = Result<String, Self::Error>> + Send>>, Self::Error> {
374        self.stream_chat_routed(messages, config).await
375    }
376}
377
378// ---------------------------------------------------------------------------
379// Internal: heterogeneous model adapter
380// ---------------------------------------------------------------------------
381
382/// Internal trait unifying the error type across providers.
383#[async_trait]
384trait RoutedModel: Send + Sync {
385    async fn chat(
386        &self,
387        messages: Vec<Message>,
388        config: Option<RunnableConfig>,
389    ) -> Result<LLMResult, RouterError>;
390    async fn stream_chat(
391        &self,
392        messages: Vec<Message>,
393        config: Option<RunnableConfig>,
394    ) -> Result<Pin<Box<dyn Stream<Item = Result<String, RouterError>> + Send>>, RouterError>;
395}
396
397/// Wraps any `BaseChatModel` whose error is `std::error::Error + Send + Sync`,
398/// converting its native error into [`RouterError`].
399struct ModelAdapter<M: BaseChatModel>(M);
400
401#[async_trait]
402impl<M: BaseChatModel> RoutedModel for ModelAdapter<M>
403where
404    M::Error: std::error::Error + Send + Sync + 'static,
405{
406    async fn chat(
407        &self,
408        messages: Vec<Message>,
409        config: Option<RunnableConfig>,
410    ) -> Result<LLMResult, RouterError> {
411        let name = self.0.model_name().to_string();
412        self.0
413            .chat(messages, config)
414            .await
415            .map_err(|e| RouterError::Model {
416                model: name,
417                source: Box::new(e),
418            })
419    }
420
421    async fn stream_chat(
422        &self,
423        messages: Vec<Message>,
424        config: Option<RunnableConfig>,
425    ) -> Result<Pin<Box<dyn Stream<Item = Result<String, RouterError>> + Send>>, RouterError> {
426        let name = self.0.model_name().to_string();
427        let inner = self
428            .0
429            .stream_chat(messages, config)
430            .await
431            .map_err(|e| RouterError::Model {
432                model: name.clone(),
433                source: Box::new(e),
434            })?;
435        let mapped = inner.map(move |item| {
436            item.map_err(|e| RouterError::Model {
437                model: name.clone(),
438                source: Box::new(e),
439            })
440        });
441        Ok(Box::pin(mapped))
442    }
443}
444
445/// One registered model plus routing metadata.
446struct ModelSlot {
447    model: Box<dyn RoutedModel>,
448    cost: Option<f64>,
449    /// Exponential moving average latency in milliseconds.
450    latency_ms: Mutex<f64>,
451}
452
453impl ModelSlot {
454    fn new(model: Box<dyn RoutedModel>, cost: Option<f64>) -> Self {
455        Self {
456            model,
457            cost,
458            latency_ms: Mutex::new(0.0),
459        }
460    }
461
462    fn latency(&self) -> f64 {
463        *self.latency_ms.lock().unwrap_or_else(|e| e.into_inner())
464    }
465
466    fn update_latency(&self, ms: f64) {
467        let mut cur = self.latency_ms.lock().unwrap_or_else(|e| e.into_inner());
468        if *cur == 0.0 {
469            *cur = ms;
470        } else {
471            // EMA: weight history 0.7, new sample 0.3.
472            *cur = *cur * 0.7 + ms * 0.3;
473        }
474    }
475}
476
477#[cfg(test)]
478mod tests {
479    //! Internal unit tests for routing order derivation.
480    //! Heterogeneous-model behavior is covered in `tests/unit/router_llm.rs`.
481
482    use super::*;
483
484    fn slot_at(cost: Option<f64>, latency: f64) -> ModelSlot {
485        // A minimal stand-in model is not needed to test order derivation;
486        // we only exercise `candidate_order` / `latency` logic. Build slots
487        // with a no-op model via a tiny helper trait impl below.
488        struct Noop;
489        #[async_trait]
490        impl RoutedModel for Noop {
491            async fn chat(
492                &self,
493                _m: Vec<Message>,
494                _c: Option<RunnableConfig>,
495            ) -> Result<LLMResult, RouterError> {
496                Err(RouterError::Empty)
497            }
498            async fn stream_chat(
499                &self,
500                _m: Vec<Message>,
501                _c: Option<RunnableConfig>,
502            ) -> Result<Pin<Box<dyn Stream<Item = Result<String, RouterError>> + Send>>, RouterError>
503            {
504                Err(RouterError::Empty)
505            }
506        }
507        let s = ModelSlot::new(Box::new(Noop), cost);
508        *s.latency_ms.lock().unwrap_or_else(|e| e.into_inner()) = latency;
509        s
510    }
511
512    fn router_with(slots: Vec<ModelSlot>, strategy: RoutingStrategy) -> RouterLLM {
513        RouterLLM {
514            name: "router".to_string(),
515            slots,
516            strategy,
517            counter: AtomicUsize::new(0),
518        }
519    }
520
521    #[test]
522    fn candidate_order_fallback_is_registration_order() {
523        let r = router_with(
524            vec![slot_at(None, 0.0), slot_at(None, 0.0), slot_at(None, 0.0)],
525            RoutingStrategy::Fallback,
526        );
527        assert_eq!(r.candidate_order(""), vec![0, 1, 2]);
528    }
529
530    #[test]
531    fn candidate_order_lowest_cost_sorts_by_cost() {
532        let r = router_with(
533            vec![
534                slot_at(Some(10.0), 0.0), // idx 0
535                slot_at(Some(1.0), 0.0),  // idx 1
536            ],
537            RoutingStrategy::LowestCost,
538        );
539        assert_eq!(r.candidate_order(""), vec![1, 0]);
540    }
541
542    #[test]
543    fn candidate_order_least_latency_sorts_by_latency() {
544        let r = router_with(
545            vec![
546                slot_at(None, 80.0), // idx 0
547                slot_at(None, 5.0),  // idx 1
548            ],
549            RoutingStrategy::LeastLatency,
550        );
551        assert_eq!(r.candidate_order(""), vec![1, 0]);
552    }
553
554    #[test]
555    fn candidate_order_input_directed_puts_primary_first() {
556        let r = router_with(
557            vec![slot_at(None, 0.0), slot_at(None, 0.0)],
558            RoutingStrategy::InputDirected(Arc::new(|s| if s.contains("x") { 1 } else { 0 })),
559        );
560        assert_eq!(r.candidate_order("hello"), vec![0, 1]);
561        assert_eq!(r.candidate_order("x marks"), vec![1, 0]);
562    }
563
564    #[test]
565    fn candidate_order_input_directed_invalid_index_falls_back() {
566        let r = router_with(
567            vec![slot_at(None, 0.0), slot_at(None, 0.0)],
568            RoutingStrategy::InputDirected(Arc::new(|_| 99)),
569        );
570        assert_eq!(r.candidate_order("hi"), vec![0, 1]);
571    }
572
573    #[test]
574    fn update_latency_ema_blends_samples() {
575        let s = slot_at(None, 0.0);
576        s.update_latency(100.0);
577        assert_eq!(s.latency(), 100.0); // first sample replaces 0
578        s.update_latency(100.0);
579        // 100 * 0.7 + 100 * 0.3 = 100
580        assert_eq!(s.latency(), 100.0);
581        s.update_latency(40.0);
582        // 100 * 0.7 + 40 * 0.3 = 82
583        assert_eq!(s.latency(), 82.0);
584    }
585}