1use 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#[derive(Debug)]
41#[non_exhaustive]
42pub enum RouterError {
43 Empty,
45 AllFailed {
48 tried: usize,
50 last: Box<dyn std::error::Error + Send + Sync>,
52 },
53 Model {
55 model: String,
57 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
86pub enum RoutingStrategy {
91 Fallback,
94 RoundRobin,
96 LeastLatency,
99 LowestCost,
101 InputDirected(Arc<dyn Fn(&str) -> usize + Send + Sync>),
104}
105
106pub struct RouterLLM {
110 name: String,
111 slots: Vec<ModelSlot>,
112 strategy: RoutingStrategy,
113 counter: AtomicUsize,
115}
116
117impl RouterLLM {
118 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 pub fn with_name(mut self, name: impl Into<String>) -> Self {
131 self.name = name.into();
132 self
133 }
134
135 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 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 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 pub fn len(&self) -> usize {
175 self.slots.len()
176 }
177
178 pub fn is_empty(&self) -> bool {
180 self.slots.is_empty()
181 }
182
183 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 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 (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 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 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 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 self
356 }
357
358 fn with_max_tokens(self, _max: usize) -> Self {
359 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#[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
404struct 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
453struct ModelSlot {
455 model: Box<dyn RoutedModel>,
456 cost: Option<f64>,
457 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 *cur = *cur * 0.7 + ms * 0.3;
481 }
482 }
483}
484
485#[cfg(test)]
486mod tests {
487 use super::*;
491
492 fn slot_at(cost: Option<f64>, latency: f64) -> ModelSlot {
493 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), slot_at(Some(1.0), 0.0), ],
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), slot_at(None, 5.0), ],
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); s.update_latency(100.0);
589 assert_eq!(s.latency(), 100.0);
591 s.update_latency(40.0);
592 assert_eq!(s.latency(), 82.0);
594 }
595}