1use 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#[derive(Debug)]
41pub enum RouterError {
42 Empty,
44 AllFailed {
47 tried: usize,
48 last: Box<dyn std::error::Error + Send + Sync>,
49 },
50 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
81pub enum RoutingStrategy {
86 Fallback,
89 RoundRobin,
91 LeastLatency,
94 LowestCost,
96 InputDirected(Arc<dyn Fn(&str) -> usize + Send + Sync>),
99}
100
101pub struct RouterLLM {
105 name: String,
106 slots: Vec<ModelSlot>,
107 strategy: RoutingStrategy,
108 counter: AtomicUsize,
110}
111
112impl RouterLLM {
113 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 pub fn with_name(mut self, name: impl Into<String>) -> Self {
126 self.name = name.into();
127 self
128 }
129
130 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 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 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 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 pub fn len(&self) -> usize {
190 self.slots.len()
191 }
192
193 pub fn is_empty(&self) -> bool {
195 self.slots.is_empty()
196 }
197
198 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 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 (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 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 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 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#[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
400struct 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
448struct ModelSlot {
450 #[allow(dead_code)]
451 name: String,
452 model: Box<dyn RoutedModel>,
453 cost: Option<f64>,
454 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 *cur = *cur * 0.7 + ms * 0.3;
479 }
480 }
481}
482
483#[cfg(test)]
484mod tests {
485 use super::*;
489
490 fn slot_at(name: &str, cost: Option<f64>, latency: f64) -> ModelSlot {
491 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), slot_at("cheap", 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("slow", None, 80.0), slot_at("fast", 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("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); 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}