1use std::cmp::Ordering;
57
58use async_trait::async_trait;
59
60use crate::error::{KernelError, Result};
61use crate::llm::client::LLMClient;
62use crate::llm::types::{LLMRequest, LLMResponse, LLMStream};
63
64pub struct Backend<C: LLMClient> {
71 pub client: C,
73 pub cost_per_1m: Option<(f64, f64)>,
75 pub priority: usize,
77 label: Option<String>,
78}
79
80impl<C: LLMClient> Backend<C> {
81 pub fn new(client: C, cost_per_1m: Option<(f64, f64)>) -> Self {
83 Self {
84 client,
85 cost_per_1m,
86 priority: 0,
87 label: None,
88 }
89 }
90
91 pub fn with_priority(mut self, priority: usize) -> Self {
93 self.priority = priority;
94 self
95 }
96
97 pub fn with_label(mut self, label: impl Into<String>) -> Self {
100 self.label = Some(label.into());
101 self
102 }
103
104 fn name(&self) -> &str {
105 self.label
106 .as_deref()
107 .unwrap_or_else(|| self.client.model_name())
108 }
109
110 fn cost_rank(&self) -> f64 {
113 let raw = self
114 .cost_per_1m
115 .map(|(input, output)| (input + output) / 2.0)
116 .unwrap_or(f64::MAX);
117 if raw.is_nan() { f64::MAX } else { raw }
118 }
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
123pub enum RoutingStrategy {
124 #[default]
127 Fallback,
128 LowestCost,
131}
132
133fn compute_order<C: LLMClient>(backends: &[Backend<C>], strategy: RoutingStrategy) -> Vec<usize> {
136 let mut idx: Vec<usize> = (0..backends.len()).collect();
137 match strategy {
138 RoutingStrategy::Fallback => idx.sort_by_key(|&i| backends[i].priority),
139 RoutingStrategy::LowestCost => idx.sort_by(|&a, &b| {
140 backends[a]
141 .cost_rank()
142 .partial_cmp(&backends[b].cost_rank())
143 .unwrap_or(Ordering::Equal)
144 }),
145 }
146 idx
147}
148
149fn is_permanent_client_error(err: &KernelError) -> bool {
156 matches!(
157 err,
158 KernelError::Http { status, .. }
159 if (400..500).contains(status) && *status != 408 && *status != 429
160 )
161}
162
163pub struct RouterClient<C: LLMClient> {
178 backends: Vec<Backend<C>>,
179 strategy: RoutingStrategy,
180 order: Vec<usize>,
182}
183
184impl<C: LLMClient> RouterClient<C> {
185 pub fn new(backends: Vec<Backend<C>>, strategy: RoutingStrategy) -> Result<Self> {
189 if backends.is_empty() {
190 return Err(KernelError::Config(
191 "RouterClient requires at least one backend".into(),
192 ));
193 }
194 let order = compute_order(&backends, strategy);
195 Ok(Self {
196 backends,
197 strategy,
198 order,
199 })
200 }
201
202 pub fn backends(&self) -> &[Backend<C>] {
204 &self.backends
205 }
206
207 pub fn strategy(&self) -> RoutingStrategy {
209 self.strategy
210 }
211}
212
213#[async_trait]
214impl<C: LLMClient> LLMClient for RouterClient<C> {
215 async fn complete(&self, request: LLMRequest) -> Result<LLMResponse> {
216 let mut last_err: Option<KernelError> = None;
217 for &i in &self.order {
218 match self.backends[i].client.complete(request.clone()).await {
219 Ok(response) => return Ok(response),
220 Err(err) => {
221 if is_permanent_client_error(&err) {
222 return Err(err);
223 }
224 last_err = Some(err);
225 }
226 }
227 }
228 Err(last_err
229 .unwrap_or_else(|| KernelError::Config("RouterClient: no backends tried".into())))
230 }
231
232 fn model_name(&self) -> &str {
233 let first = self.order.first().copied().unwrap_or(0);
235 self.backends[first].name()
236 }
237
238 async fn stream_complete(&self, request: LLMRequest) -> Result<LLMStream> {
239 let mut last_err: Option<KernelError> = None;
243 for &i in &self.order {
244 match self.backends[i]
245 .client
246 .stream_complete(request.clone())
247 .await
248 {
249 Ok(stream) => return Ok(stream),
250 Err(err) => {
251 if is_permanent_client_error(&err) {
252 return Err(err);
253 }
254 last_err = Some(err);
255 }
256 }
257 }
258 Err(last_err
259 .unwrap_or_else(|| KernelError::Config("RouterClient: no stream established".into())))
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use std::sync::{
267 Arc, Mutex,
268 atomic::{AtomicU32, Ordering},
269 };
270
271 use crate::llm::types::StreamEvent;
272 use tokio::sync::mpsc;
273 use tokio_stream::wrappers::ReceiverStream;
274
275 struct MockClient {
278 name: &'static str,
279 responses: Mutex<Vec<Result<LLMResponse>>>,
280 stream_calls: Arc<AtomicU32>,
281 stream_fail_status: Option<u16>,
285 }
286
287 impl MockClient {
288 fn new(name: &'static str, responses: Vec<Result<LLMResponse>>) -> Self {
289 Self {
290 name,
291 responses: Mutex::new(responses),
292 stream_calls: Arc::new(AtomicU32::new(0)),
293 stream_fail_status: None,
294 }
295 }
296
297 fn stream_call_count(&self) -> Arc<AtomicU32> {
298 Arc::clone(&self.stream_calls)
299 }
300
301 fn with_failing_stream(mut self, status: u16) -> Self {
303 self.stream_fail_status = Some(status);
304 self
305 }
306 }
307
308 #[async_trait]
309 impl LLMClient for MockClient {
310 async fn complete(&self, _request: LLMRequest) -> Result<LLMResponse> {
311 let mut responses = self.responses.lock().unwrap();
312 if responses.is_empty() {
313 panic!("MockClient({}): no more responses queued", self.name);
314 }
315 responses.remove(0)
316 }
317
318 fn model_name(&self) -> &str {
319 self.name
320 }
321
322 async fn stream_complete(&self, _request: LLMRequest) -> Result<LLMStream> {
323 self.stream_calls.fetch_add(1, Ordering::Relaxed);
324 if let Some(status) = self.stream_fail_status {
325 return Err(KernelError::Http {
326 status,
327 message: "stream unavailable".into(),
328 });
329 }
330 let (tx, rx) = mpsc::channel::<Result<StreamEvent>>(16);
332 let _ = tx.send(Ok(StreamEvent::Done)).await;
333 Ok(Box::pin(ReceiverStream::new(rx)))
334 }
335 }
336
337 fn ok_from(name: &str) -> Result<LLMResponse> {
338 Ok(LLMResponse {
339 content: format!("from-{name}"),
340 model: name.into(),
341 ..Default::default()
342 })
343 }
344
345 fn server_error() -> Result<LLMResponse> {
346 Err(KernelError::Http {
347 status: 500,
348 message: "boom".into(),
349 })
350 }
351
352 fn client_error(status: u16) -> Result<LLMResponse> {
353 Err(KernelError::Http {
354 status,
355 message: "bad request".into(),
356 })
357 }
358
359 #[tokio::test]
362 async fn fallback_falls_through_to_secondary_on_error() {
363 let primary = Backend::new(MockClient::new("primary", vec![server_error()]), None);
364 let secondary = Backend::new(
365 MockClient::new("secondary", vec![ok_from("secondary")]),
366 None,
367 );
368 let router = RouterClient::new(vec![primary, secondary], RoutingStrategy::Fallback)
369 .expect("non-empty");
370
371 let resp = router
372 .complete(LLMRequest::builder().build())
373 .await
374 .expect("secondary succeeds");
375 assert_eq!(resp.content, "from-secondary");
376 }
377
378 #[tokio::test]
379 async fn permanent_4xx_short_circuits_no_fallback() {
380 let primary = Backend::new(MockClient::new("primary", vec![client_error(400)]), None);
383 let secondary = Backend::new(MockClient::new("secondary", vec![]), None);
384 let router = RouterClient::new(vec![primary, secondary], RoutingStrategy::Fallback)
385 .expect("non-empty");
386
387 let err = router
388 .complete(LLMRequest::builder().build())
389 .await
390 .expect_err("400 short-circuits");
391 assert!(matches!(err, KernelError::Http { status: 400, .. }));
392 }
393
394 #[tokio::test]
395 async fn transient_429_falls_through() {
396 let primary = Backend::new(MockClient::new("primary", vec![client_error(429)]), None);
398 let secondary = Backend::new(
399 MockClient::new("secondary", vec![ok_from("secondary")]),
400 None,
401 );
402 let router = RouterClient::new(vec![primary, secondary], RoutingStrategy::Fallback)
403 .expect("non-empty");
404
405 let resp = router
406 .complete(LLMRequest::builder().build())
407 .await
408 .expect("429 falls through");
409 assert_eq!(resp.content, "from-secondary");
410 }
411
412 #[tokio::test]
413 async fn fallback_respects_priority_order() {
414 let secondary = Backend::new(MockClient::new("secondary", vec![server_error()]), None)
417 .with_priority(10);
418 let primary = Backend::new(MockClient::new("primary", vec![ok_from("primary")]), None)
419 .with_priority(1);
420
421 let router = RouterClient::new(vec![secondary, primary], RoutingStrategy::Fallback)
422 .expect("non-empty");
423 let resp = router
424 .complete(LLMRequest::builder().build())
425 .await
426 .expect("primary succeeds");
427 assert_eq!(resp.content, "from-primary");
428 }
429
430 #[tokio::test]
431 async fn lowest_cost_orders_by_cost() {
432 let expensive = Backend::new(
434 MockClient::new("expensive", vec![server_error(), ok_from("expensive")]),
435 Some((10.0, 30.0)),
436 );
437 let cheap = Backend::new(
438 MockClient::new("cheap", vec![ok_from("cheap")]),
439 Some((0.15, 0.60)),
440 );
441 let router = RouterClient::new(vec![expensive, cheap], RoutingStrategy::LowestCost)
442 .expect("non-empty");
443
444 let resp = router
445 .complete(LLMRequest::builder().build())
446 .await
447 .expect("cheap succeeds first");
448 assert_eq!(resp.content, "from-cheap");
449 }
450
451 #[tokio::test]
452 async fn lowest_cost_falls_through_to_next_cheapest() {
453 let cheap = Backend::new(
455 MockClient::new("cheap", vec![server_error()]),
456 Some((0.15, 0.60)),
457 );
458 let mid = Backend::new(
459 MockClient::new("mid", vec![ok_from("mid")]),
460 Some((1.0, 2.0)),
461 );
462 let router =
463 RouterClient::new(vec![mid, cheap], RoutingStrategy::LowestCost).expect("non-empty");
464
465 let resp = router
466 .complete(LLMRequest::builder().build())
467 .await
468 .expect("mid succeeds after cheap fails");
469 assert_eq!(resp.content, "from-mid");
470 }
471
472 #[tokio::test]
473 async fn unknown_cost_sorts_last() {
474 let unknown = Backend::new(MockClient::new("unknown", vec![]), None);
478 let priced = Backend::new(
479 MockClient::new("priced", vec![ok_from("priced")]),
480 Some((1.0, 2.0)),
481 );
482 let router = RouterClient::new(vec![unknown, priced], RoutingStrategy::LowestCost)
483 .expect("non-empty");
484 let resp = router
485 .complete(LLMRequest::builder().build())
486 .await
487 .expect("priced tried first");
488 assert_eq!(resp.content, "from-priced");
489 }
490
491 #[tokio::test]
492 async fn nan_cost_is_treated_as_unknown() {
493 let nan_cost = Backend::new(MockClient::new("nan", vec![]), Some((f64::NAN, 1.0)));
495 let priced = Backend::new(
496 MockClient::new("priced", vec![ok_from("priced")]),
497 Some((1.0, 2.0)),
498 );
499 let router = RouterClient::new(vec![nan_cost, priced], RoutingStrategy::LowestCost)
500 .expect("non-empty");
501 let resp = router
502 .complete(LLMRequest::builder().build())
503 .await
504 .expect("priced precedes NaN-cost backend");
505 assert_eq!(resp.content, "from-priced");
506 }
507
508 #[tokio::test]
509 async fn all_backends_fail_returns_last_error() {
510 let primary = Backend::new(MockClient::new("primary", vec![server_error()]), None);
511 let secondary = Backend::new(
512 MockClient::new("secondary", vec![Err(KernelError::LlmApi("down".into()))]),
513 None,
514 );
515 let router = RouterClient::new(vec![primary, secondary], RoutingStrategy::Fallback)
516 .expect("non-empty");
517
518 let err = router
519 .complete(LLMRequest::builder().build())
520 .await
521 .expect_err("both fail");
522 assert!(matches!(err, KernelError::LlmApi(_)));
524 }
525
526 #[tokio::test]
527 async fn first_success_short_circuits_remaining_backends() {
528 let primary = Backend::new(MockClient::new("primary", vec![ok_from("primary")]), None);
531 let secondary = Backend::new(MockClient::new("secondary", vec![]), None);
532 let router = RouterClient::new(vec![primary, secondary], RoutingStrategy::Fallback)
533 .expect("non-empty");
534
535 let resp = router
536 .complete(LLMRequest::builder().build())
537 .await
538 .expect("primary succeeds");
539 assert_eq!(resp.content, "from-primary");
540 }
541
542 #[tokio::test]
545 async fn empty_backends_is_rejected() {
546 let result = RouterClient::<MockClient>::new(vec![], RoutingStrategy::Fallback);
547 assert!(result.is_err());
548 }
549
550 #[tokio::test]
551 async fn ties_keep_insertion_order() {
552 let primary = Backend::new(MockClient::new("primary", vec![ok_from("primary")]), None)
555 .with_priority(5);
556 let secondary = Backend::new(MockClient::new("secondary", vec![]), None).with_priority(5);
557 let router = RouterClient::new(vec![primary, secondary], RoutingStrategy::Fallback)
558 .expect("non-empty");
559 let resp = router
560 .complete(LLMRequest::builder().build())
561 .await
562 .expect("insertion-order primary first");
563 assert_eq!(resp.content, "from-primary");
564 }
565
566 #[tokio::test]
567 async fn equal_cost_keeps_insertion_order() {
568 let first = Backend::new(
569 MockClient::new("first", vec![ok_from("first")]),
570 Some((1.0, 1.0)),
571 );
572 let second = Backend::new(MockClient::new("second", vec![]), Some((1.0, 1.0)));
573 let router =
574 RouterClient::new(vec![first, second], RoutingStrategy::LowestCost).expect("non-empty");
575 let resp = router
576 .complete(LLMRequest::builder().build())
577 .await
578 .expect("insertion-order first");
579 assert_eq!(resp.content, "from-first");
580 }
581
582 #[test]
583 fn compute_order_lowest_cost_ranks_unknown_and_nan_last() {
584 let backends = [
586 Backend::new(MockClient::new("nan", vec![]), Some((f64::NAN, 1.0))),
587 Backend::new(MockClient::new("unknown", vec![]), None),
588 Backend::new(MockClient::new("mid", vec![]), Some((1.0, 2.0))),
589 Backend::new(MockClient::new("cheap", vec![]), Some((0.1, 0.2))),
590 ];
591 let order = compute_order(&backends, RoutingStrategy::LowestCost);
592 assert_eq!(order, vec![3, 2, 0, 1]);
596 }
597
598 #[test]
599 fn compute_order_fallback_uses_priority_with_ties() {
600 let backends = [
602 Backend::new(MockClient::new("a", vec![]), None).with_priority(5),
603 Backend::new(MockClient::new("b", vec![]), None).with_priority(1),
604 Backend::new(MockClient::new("c", vec![]), None).with_priority(5),
605 ];
606 let order = compute_order(&backends, RoutingStrategy::Fallback);
607 assert_eq!(order, vec![1, 0, 2]);
608 }
609
610 #[tokio::test]
613 async fn model_name_reflects_preferred_backend() {
614 let primary = Backend::new(MockClient::new("primary", vec![ok_from("primary")]), None)
615 .with_priority(1);
616 let secondary = Backend::new(
617 MockClient::new("secondary", vec![ok_from("secondary")]),
618 None,
619 )
620 .with_priority(5);
621 let router = RouterClient::new(vec![secondary, primary], RoutingStrategy::Fallback)
622 .expect("non-empty");
623 assert_eq!(router.model_name(), "primary");
624 }
625
626 #[tokio::test]
627 async fn with_label_overrides_model_name() {
628 let backend = Backend::new(MockClient::new("gpt-4o", vec![ok_from("gpt-4o")]), None)
629 .with_label("fallback-alias");
630 let router =
631 RouterClient::new(vec![backend], RoutingStrategy::Fallback).expect("non-empty");
632 assert_eq!(router.model_name(), "fallback-alias");
633 }
634
635 #[tokio::test]
638 async fn stream_returns_preferred_and_skips_rest() {
639 let primary_mock = MockClient::new("primary", vec![]);
642 let primary_calls = primary_mock.stream_call_count();
643 let secondary_mock = MockClient::new("secondary", vec![]);
644 let secondary_calls = secondary_mock.stream_call_count();
645
646 let primary = Backend::new(primary_mock, None).with_priority(1);
647 let secondary = Backend::new(secondary_mock, None).with_priority(5);
648 let router = RouterClient::new(vec![secondary, primary], RoutingStrategy::Fallback)
649 .expect("non-empty");
650
651 let stream = router
652 .stream_complete(LLMRequest::builder().build())
653 .await
654 .expect("preferred backend streams");
655 drop(stream); assert_eq!(
658 primary_calls.load(Ordering::Relaxed),
659 1,
660 "preferred streamed once"
661 );
662 assert_eq!(
663 secondary_calls.load(Ordering::Relaxed),
664 0,
665 "secondary not contacted"
666 );
667 }
668
669 #[tokio::test]
670 async fn stream_falls_through_on_pre_stream_error() {
671 let primary_mock = MockClient::new("primary", vec![]).with_failing_stream(503);
674 let primary_calls = primary_mock.stream_call_count();
675 let secondary_mock = MockClient::new("secondary", vec![]);
676 let secondary_calls = secondary_mock.stream_call_count();
677
678 let primary = Backend::new(primary_mock, None);
679 let secondary = Backend::new(secondary_mock, None);
680 let router = RouterClient::new(vec![primary, secondary], RoutingStrategy::Fallback)
681 .expect("non-empty");
682
683 let stream = router
684 .stream_complete(LLMRequest::builder().build())
685 .await
686 .expect("secondary establishes stream");
687 drop(stream);
688
689 assert_eq!(primary_calls.load(Ordering::Relaxed), 1);
690 assert_eq!(secondary_calls.load(Ordering::Relaxed), 1);
691 }
692
693 #[tokio::test]
694 async fn stream_permanent_4xx_short_circuits() {
695 let primary_mock = MockClient::new("primary", vec![]).with_failing_stream(403);
698 let primary_calls = primary_mock.stream_call_count();
699 let secondary_mock = MockClient::new("secondary", vec![]);
700 let secondary_calls = secondary_mock.stream_call_count();
701
702 let primary = Backend::new(primary_mock, None);
703 let secondary = Backend::new(secondary_mock, None);
704 let router = RouterClient::new(vec![primary, secondary], RoutingStrategy::Fallback)
705 .expect("non-empty");
706
707 let err = router
708 .stream_complete(LLMRequest::builder().build())
709 .await
710 .err()
711 .expect("403 short-circuits");
712 assert!(matches!(err, KernelError::Http { status: 403, .. }));
713 assert_eq!(primary_calls.load(Ordering::Relaxed), 1);
714 assert_eq!(secondary_calls.load(Ordering::Relaxed), 0);
715 }
716}