lc_core/router_llm/mod.rs
1// src/core/router_llm/mod.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//! Besides plain error fallback, the router supports three operational
10//! controls (B13, 0.22.4):
11//!
12//! - **Latency-driven routing**: [`RoutingStrategy::LeastLatency`] sorts by
13//! the per-model EMA latency, and [`RoutingStrategy::LatencyWeighted`]
14//! draws the primary model with weights derived from the same observed
15//! latencies. Both warm themselves on real call timings.
16//! - **Per-model rate limiting**: attach a [`ModelRateLimit`] to a slot and
17//! saturated models queue callers FIFO (bounded, with a wait timeout); a
18//! slot that cannot be admitted is skipped and the next candidate tried.
19//! - **Budget circuit breaker**: attach a shared [`RouterBudget`]; calls
20//! whose projected spend would cross the cap skip the paid slot and roll
21//! over to free fallbacks, and measured usage latches the breaker.
22//!
23//! # Example
24//! ```
25//! use lc_core::router_llm::{RouterLLM, RoutingStrategy};
26//!
27//! // Empty primary-first fallback router. Register real models (OpenAIChat,
28//! // AnthropicChat, ...) via the `with_model` / `with_fallbacks` builders;
29//! // the router then tries them in order, falling back on error.
30//! let _router = RouterLLM::new(RoutingStrategy::Fallback);
31//! ```
32//!
33//! Operational controls on a populated router:
34//!
35//! ```ignore
36//! // paid primary limited to 30 starts/min and 4 concurrent, free local
37//! // fallback, whole router trips at a 5 USD cumulative spend:
38//! let router = RouterLLM::new(RoutingStrategy::Fallback)
39//! .with_priced_model(paid_model, ModelPrice::new(2.5, 10.0))
40//! .with_last_rate_limit(ModelRateLimit::per_minute(30).with_max_concurrent(4))
41//! .with_model(local_model)
42//! .with_budget(RouterBudget::with_cost_and_token_limits(5.0, 1_000_000));
43//! ```
44//!
45//! Each provider declares its own error type (`OpenAIError`, `AnthropicError`,
46//! ...), so `RouterLLM` cannot hold them behind a single `dyn BaseChatModel`.
47//! Instead it wraps every model in a `ModelAdapter` that converts the
48//! model's native error into the unified [`RouterError`].
49
50mod budget;
51mod rate;
52
53pub use budget::{BudgetExceeded, BudgetKind, RouterBudget};
54pub use rate::{ModelRateLimit, RateLimitReason};
55
56use crate::cost::ModelPrice;
57use crate::language_models::{
58 BaseChatModel, BaseLanguageModel, LLMResult, StreamChunk, TokenUsage,
59};
60use crate::model_registry::ModelRegistry;
61use crate::runnables::Runnable;
62use crate::RunnableConfig;
63use async_trait::async_trait;
64use futures_util::{Stream, StreamExt};
65use lc_schema::Message;
66use std::fmt::{self, Display, Formatter};
67use std::pin::Pin;
68use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
69use std::sync::{Arc, Mutex};
70use std::time::Instant;
71
72#[cfg(test)]
73mod tests;
74
75/// Unified error for [`RouterLLM`].
76///
77/// Aggregates heterogeneous provider errors behind `Box<dyn Error>` so a
78/// single router can mix providers whose native error types differ.
79#[derive(Debug)]
80#[non_exhaustive]
81pub enum RouterError {
82 /// No models were configured on the router.
83 Empty,
84 /// Every candidate model was tried and all failed.
85 /// `tried` is the number of models attempted; `last` is the final error.
86 AllFailed {
87 /// The number of models attempted.
88 tried: usize,
89 /// The final error from the last attempted model.
90 last: Box<dyn std::error::Error + Send + Sync>,
91 },
92 /// A single model failed (wrapped when propagating from an adapter).
93 Model {
94 /// The name of the model that failed.
95 model: String,
96 /// The underlying provider error.
97 source: Box<dyn std::error::Error + Send + Sync>,
98 },
99 /// The slot's per-model rate limiter could not admit the call in time.
100 /// The router treats this like a model failure and tries the next slot.
101 RateLimited {
102 /// The name of the rate-limited model.
103 model: String,
104 /// Whether the queue was full or the wait timed out.
105 reason: RateLimitReason,
106 },
107 /// The router budget would have been exceeded by the projected call.
108 /// The router skips that slot (free slots remain reachable) and tries
109 /// the next candidate; this surfaces as the final error only when every
110 /// candidate was skipped.
111 BudgetExceeded(
112 /// Snapshot of the exceeded dimension, used amount and limit.
113 BudgetExceeded,
114 ),
115}
116
117impl Display for RouterError {
118 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
119 match self {
120 RouterError::Empty => write!(f, "no models configured in router"),
121 RouterError::AllFailed { tried, last } => {
122 write!(f, "all {} models failed; last error: {}", tried, last)
123 }
124 RouterError::Model { model, source } => {
125 write!(f, "model '{}' error: {}", model, source)
126 }
127 RouterError::RateLimited { model, reason } => {
128 write!(f, "model '{}' rate limited: {}", model, reason)
129 }
130 RouterError::BudgetExceeded(exceeded) => {
131 write!(f, "router budget skip — {exceeded}")
132 }
133 }
134 }
135}
136
137impl std::error::Error for RouterError {
138 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
139 match self {
140 RouterError::AllFailed { last, .. } => Some(last.as_ref()),
141 RouterError::Model { source, .. } => Some(source.as_ref()),
142 RouterError::BudgetExceeded(exceeded) => Some(exceeded),
143 RouterError::Empty | RouterError::RateLimited { .. } => None,
144 }
145 }
146}
147
148/// Strategy for selecting which model a [`RouterLLM`] tries first.
149///
150/// Regardless of strategy, a failed model triggers fallback to the next
151/// candidate in the derived order until one succeeds or all fail.
152pub enum RoutingStrategy {
153 /// Always try models in registration order (primary-first). Use with
154 /// [`RouterLLM::with_fallbacks`] for classic primary + backups semantics.
155 Fallback,
156 /// Rotate the starting index across calls so traffic spreads evenly.
157 RoundRobin,
158 /// Prefer the model with the lowest recent latency (exponential moving
159 /// average updated after each call).
160 LeastLatency,
161 /// Pick the primary model by a weighted draw over observed latency:
162 /// slot weight is `(1 / latency_ms).powf(beta)`, so `beta = 1.0` makes a
163 /// 100 ms model ten times as likely to lead as a 1000 ms one. Models
164 /// never tried are optimistically given the best observed latency so
165 /// they still receive exploration traffic; when nothing has been tried
166 /// all weights are equal. Remaining slots follow the draw as a fallback
167 /// chain sorted by weight. The draw is a deterministic SplitMix64
168 /// sequence seeded per router (no `rand` dependency).
169 LatencyWeighted(f64),
170 /// Prefer the model with the lowest configured cost.
171 LowestCost,
172 /// Pick the primary index from a user-supplied closure over the input
173 /// text; remaining models are tried in registration order as fallback.
174 InputDirected(Arc<dyn Fn(&str) -> usize + Send + Sync>),
175}
176
177/// A chat model pool with routing and fallback.
178///
179/// See the [module docs](self) for design rationale.
180pub struct RouterLLM {
181 name: String,
182 slots: Vec<ModelSlot>,
183 strategy: RoutingStrategy,
184 /// Round-robin cursor.
185 counter: AtomicUsize,
186 /// Deterministic PRNG state driving [`RoutingStrategy::LatencyWeighted`].
187 rng: AtomicU64,
188 /// Optional model catalog (B3): prices `with_model_as`-keyed slots for
189 /// [`RoutingStrategy::LowestCost`] without repeating numbers at the call site.
190 registry: Option<Arc<ModelRegistry>>,
191 /// Optional shared spend/token circuit breaker (B13).
192 budget: Option<Arc<RouterBudget>>,
193}
194
195impl RouterLLM {
196 /// Create an empty router with the given strategy. Add models via the
197 /// `with_*` builder methods.
198 pub fn new(strategy: RoutingStrategy) -> Self {
199 Self {
200 name: "router".to_string(),
201 slots: Vec::new(),
202 strategy,
203 counter: AtomicUsize::new(0),
204 rng: AtomicU64::new(0x9E37_79B9_7F4A_7C15),
205 registry: None,
206 budget: None,
207 }
208 }
209
210 /// Attaches a shared [`RouterBudget`]. Every subsequent call projects its
211 /// prompt spend against the remaining budget, skips paid slots once the
212 /// breaker is tripped (free slots stay reachable as fallback), and
213 /// records the model-reported token usage after a successful call.
214 pub fn with_budget(mut self, budget: Arc<RouterBudget>) -> Self {
215 self.budget = Some(budget);
216 self
217 }
218
219 /// Attaches a model registry. Keyed slots ([`RouterLLM::with_model_as`])
220 /// then take their [`RoutingStrategy::LowestCost`] weight from the
221 /// registry's blended per-1K price. An explicit cost from
222 /// [`RouterLLM::with_cost`] always wins; a key the registry cannot resolve
223 /// sorts last (treated as infinitely expensive).
224 pub fn with_registry(mut self, registry: Arc<ModelRegistry>) -> Self {
225 self.registry = Some(registry);
226 self
227 }
228
229 /// Set a human-readable name returned by `model_name`.
230 pub fn with_name(mut self, name: impl Into<String>) -> Self {
231 self.name = name.into();
232 self
233 }
234
235 /// Register a model.
236 pub fn with_model<M>(mut self, model: M) -> Self
237 where
238 M: BaseChatModel + 'static,
239 M::Error: std::error::Error + Send + Sync + 'static,
240 {
241 self.slots
242 .push(ModelSlot::new(Box::new(ModelAdapter(model)), None));
243 self
244 }
245
246 /// Register a model with a relative cost used by [`RoutingStrategy::LowestCost`].
247 pub fn with_cost<M>(mut self, model: M, cost: f64) -> Self
248 where
249 M: BaseChatModel + 'static,
250 M::Error: std::error::Error + Send + Sync + 'static,
251 {
252 self.slots
253 .push(ModelSlot::new(Box::new(ModelAdapter(model)), Some(cost)));
254 self
255 }
256
257 /// Register a model keyed as `"<provider>/<model-id>"` in the attached
258 /// [`ModelRegistry`] (see [`RouterLLM::with_registry`]). Under
259 /// [`RoutingStrategy::LowestCost`] the slot is weighted by the registry's
260 /// blended per-1K price; other strategies ignore the key.
261 pub fn with_model_as<M, K>(mut self, model: M, registry_key: K) -> Self
262 where
263 M: BaseChatModel + 'static,
264 M::Error: std::error::Error + Send + Sync + 'static,
265 K: Into<String>,
266 {
267 self.slots
268 .push(ModelSlot::new(Box::new(ModelAdapter(model)), None).with_key(registry_key));
269 self
270 }
271
272 /// Register a model with an explicit per-token [`ModelPrice`]. The price
273 /// both participates in [`RoutingStrategy::LowestCost`] (blended) and
274 /// prices projected/actual spend for an attached [`RouterBudget`].
275 pub fn with_priced_model<M>(mut self, model: M, price: ModelPrice) -> Self
276 where
277 M: BaseChatModel + 'static,
278 M::Error: std::error::Error + Send + Sync + 'static,
279 {
280 self.slots
281 .push(ModelSlot::new(Box::new(ModelAdapter(model)), None).with_price(Some(price)));
282 self
283 }
284
285 /// Register a model behind a per-model [`ModelRateLimit`] (B13). Saturated
286 /// slots queue callers FIFO; when the queue is full or the configured
287 /// wait timeout elapses, the call skips to the next candidate model.
288 pub fn with_model_rate_limited<M>(mut self, model: M, limit: ModelRateLimit) -> Self
289 where
290 M: BaseChatModel + 'static,
291 M::Error: std::error::Error + Send + Sync + 'static,
292 {
293 self.slots.push(
294 ModelSlot::new(Box::new(ModelAdapter(model)), None)
295 .with_gate(rate::ModelGate::new(&limit)),
296 );
297 self
298 }
299
300 /// Attaches a [`ModelPrice`] to the most recently registered slot.
301 ///
302 /// Use to combine registration styles, e.g. a keyed slot whose registry
303 /// entry lacks pricing. A no-op with a warning when no slot exists.
304 pub fn with_last_price(mut self, price: ModelPrice) -> Self {
305 match self.slots.last_mut() {
306 Some(slot) => slot.price = Some(price),
307 None => log::warn!("with_last_price called before any model was registered"),
308 }
309 self
310 }
311
312 /// Attaches a [`ModelRateLimit`] to the most recently registered slot.
313 ///
314 /// A no-op with a warning when no slot exists.
315 pub fn with_last_rate_limit(mut self, limit: ModelRateLimit) -> Self {
316 match self.slots.last_mut() {
317 Some(slot) => slot.gate = Some(rate::ModelGate::new(&limit)),
318 None => log::warn!("with_last_rate_limit called before any model was registered"),
319 }
320 self
321 }
322
323 /// Convenience constructor for primary-first fallback over models of the
324 /// same type. The primary is tried first; each fallback is tried in order
325 /// until one succeeds.
326 pub fn with_fallbacks<M>(primary: M, fallbacks: Vec<M>) -> Self
327 where
328 M: BaseChatModel + 'static,
329 M::Error: std::error::Error + Send + Sync + 'static,
330 {
331 let mut router = RouterLLM::new(RoutingStrategy::Fallback);
332 router = router.with_model(primary);
333 for fb in fallbacks {
334 router = router.with_model(fb);
335 }
336 router
337 }
338
339 /// Number of registered models.
340 pub fn len(&self) -> usize {
341 self.slots.len()
342 }
343
344 /// Whether no models are registered.
345 pub fn is_empty(&self) -> bool {
346 self.slots.is_empty()
347 }
348
349 /// Derive the candidate ordering for one call.
350 fn candidate_order(&self, input: &str) -> Vec<usize> {
351 let n = self.slots.len();
352 match &self.strategy {
353 RoutingStrategy::Fallback => (0..n).collect(),
354 RoutingStrategy::RoundRobin => {
355 if n == 0 {
356 (0..n).collect()
357 } else {
358 let start = self.counter.fetch_add(1, Ordering::SeqCst) % n;
359 (0..n).map(|i| (start + i) % n).collect()
360 }
361 }
362 RoutingStrategy::LeastLatency => {
363 let mut idx: Vec<usize> = (0..n).collect();
364 idx.sort_by(|a, b| {
365 // H4 fix: untried models (latency 0.0) sort last by using f64::MAX
366 let la = {
367 let v = self.slots[*a].latency();
368 if v == 0.0 {
369 f64::MAX
370 } else {
371 v
372 }
373 };
374 let lb = {
375 let v = self.slots[*b].latency();
376 if v == 0.0 {
377 f64::MAX
378 } else {
379 v
380 }
381 };
382 la.partial_cmp(&lb).unwrap_or(std::cmp::Ordering::Equal)
383 });
384 idx
385 }
386 RoutingStrategy::LatencyWeighted(beta) => {
387 // Non-finite / negative beta is a configuration mistake; use a
388 // linear exponent rather than poisoning ordering with NaNs.
389 let beta = if beta.is_finite() && *beta >= 0.0 {
390 *beta
391 } else {
392 1.0
393 };
394 let observed: Vec<f64> = self.slots.iter().map(|s| s.latency()).collect();
395 // Untried slots optimistically assume the best seen latency
396 // (equal weights when the whole pool is cold).
397 let best = observed
398 .iter()
399 .copied()
400 .filter(|l| *l > 0.0)
401 .fold(f64::MAX, f64::min);
402 let assumed = if best == f64::MAX { 1.0 } else { best };
403 let weights: Vec<f64> = observed
404 .iter()
405 .map(|l| {
406 let latency = if *l == 0.0 { assumed } else { *l };
407 (1.0 / latency).powf(beta)
408 })
409 .collect();
410 let total: f64 = weights.iter().sum();
411 let target = if total > 0.0 {
412 self.next_pseudo() * total
413 } else {
414 0.0
415 };
416 let mut primary = n.saturating_sub(1);
417 let mut cumulative = 0.0;
418 for (i, weight) in weights.iter().enumerate() {
419 cumulative += *weight;
420 if target < cumulative {
421 primary = i;
422 break;
423 }
424 }
425 // Fallback chain: remaining slots by descending weight,
426 // registration order breaks ties deterministically.
427 let mut rest: Vec<usize> = (0..n).filter(|&i| i != primary).collect();
428 rest.sort_by(|&a, &b| {
429 weights[b]
430 .partial_cmp(&weights[a])
431 .unwrap_or(std::cmp::Ordering::Equal)
432 .then(a.cmp(&b))
433 });
434 let mut order = Vec::with_capacity(n);
435 order.push(primary);
436 order.extend(rest);
437 order
438 }
439 RoutingStrategy::LowestCost => {
440 let mut idx: Vec<usize> = (0..n).collect();
441 idx.sort_by(|a, b| {
442 let ca = self.effective_cost(&self.slots[*a]);
443 let cb = self.effective_cost(&self.slots[*b]);
444 ca.partial_cmp(&cb).unwrap_or(std::cmp::Ordering::Equal)
445 });
446 idx
447 }
448 RoutingStrategy::InputDirected(f) => {
449 let primary = f(input);
450 if primary < n {
451 let mut order = vec![primary];
452 order.extend((0..n).filter(|&i| i != primary));
453 order
454 } else {
455 // Out-of-range index: fall back to registration order.
456 (0..n).collect()
457 }
458 }
459 }
460 }
461
462 fn first_text(messages: &[Message]) -> &str {
463 messages.first().map(|m| m.content.as_str()).unwrap_or("")
464 }
465
466 /// Effective LowestCost weight: explicit `with_cost` wins, then an
467 /// explicit slot [`ModelPrice`], then the registry price of a keyed slot,
468 /// then +∞ (unweighted slots sort last).
469 fn effective_cost(&self, slot: &ModelSlot) -> f64 {
470 if let Some(cost) = slot.cost {
471 return cost;
472 }
473 if let Some(price) = self.slot_price(slot) {
474 return price.blended_per_1k();
475 }
476 f64::MAX
477 }
478
479 /// Pricing used to measure budget spend: explicit slot price first, then
480 /// the registry entry of a keyed slot, then `None` (a free / unpriced
481 /// slot whose calls project zero cost).
482 fn slot_price(&self, slot: &ModelSlot) -> Option<ModelPrice> {
483 if slot.price.is_some() {
484 return slot.price;
485 }
486 if let (Some(registry), Some(key)) = (&self.registry, &slot.registry_key) {
487 if let Some(info) = registry.get_by_key(key) {
488 return Some(info.price);
489 }
490 }
491 None
492 }
493
494 /// tiktoken (or byte-length fallback) estimate of the pending prompt size.
495 fn estimate_prompt_tokens(&self, messages: &[Message]) -> usize {
496 let joined = messages
497 .iter()
498 .map(|m| m.content.as_str())
499 .collect::<Vec<_>>()
500 .join("\n");
501 self.get_num_tokens(&joined)
502 }
503
504 /// Pre-call budget gate for one slot. When the projected call would
505 /// cross a cap, returns the exceeded dimension so the caller can skip
506 /// this slot and try the next candidate.
507 fn precheck_budget(
508 &self,
509 slot: &ModelSlot,
510 estimated_tokens: usize,
511 ) -> Result<(), BudgetExceeded> {
512 let Some(budget) = &self.budget else {
513 return Ok(());
514 };
515 let projected = self
516 .slot_price(slot)
517 .map(|price| price.cost_of(estimated_tokens, 0))
518 .unwrap_or(0.0);
519 budget.precheck(projected, estimated_tokens as u64)
520 }
521
522 /// Post-success accounting: real model-reported usage priced through the
523 /// slot's price feeds the (potentially latching) breaker.
524 fn record_usage(&self, slot: &ModelSlot, usage: &TokenUsage) {
525 if let Some(budget) = &self.budget {
526 let cost = self
527 .slot_price(slot)
528 .map(|price| price.cost_of(usage.prompt_tokens, usage.completion_tokens))
529 .unwrap_or(0.0);
530 budget.record(cost, usage.total_tokens as u64);
531 }
532 }
533
534 /// Deterministic uniform draw in `[0, 1)` (SplitMix64), so
535 /// latency-weighted routing needs neither an `rand` dependency nor
536 /// process-global randomness.
537 fn next_pseudo(&self) -> f64 {
538 let mut z = self
539 .rng
540 .fetch_add(1, Ordering::SeqCst)
541 .wrapping_add(0x9E37_79B9_7F4A_7C15);
542 z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
543 z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
544 z ^= z >> 31;
545 // Top 53 bits → mantissa of a f64 in [0, 1).
546 (z >> 11) as f64 / (1u64 << 53) as f64
547 }
548
549 async fn chat_routed(
550 &self,
551 messages: Vec<Message>,
552 config: Option<RunnableConfig>,
553 ) -> Result<LLMResult, RouterError> {
554 if self.slots.is_empty() {
555 return Err(RouterError::Empty);
556 }
557 let order = self.candidate_order(Self::first_text(&messages));
558 // H3 fix: remove ineffective Arc — just clone messages directly for each attempt
559 let estimated_tokens = self.estimate_prompt_tokens(&messages);
560 let mut last_err: Option<Box<dyn std::error::Error + Send + Sync>> = None;
561 for &idx in &order {
562 let slot = &self.slots[idx];
563
564 // Budget circuit breaker: a projected overrun skips the slot
565 // instead of spending on it; free slots project zero and stay.
566 if let Err(exceeded) = self.precheck_budget(slot, estimated_tokens) {
567 last_err = Some(Box::new(RouterError::BudgetExceeded(exceeded)));
568 continue;
569 }
570
571 // Rate gate: queue FIFO; a saturated slot is skipped like a
572 // failed model so the fallback chain keeps moving.
573 let permit = match &slot.gate {
574 Some(gate) => match gate.acquire(slot.model.name()).await {
575 Ok(permit) => Some(permit),
576 Err(e) => {
577 last_err = Some(Box::new(e));
578 continue;
579 }
580 },
581 None => None,
582 };
583
584 // Timing starts after admission: queued wait is contention, not
585 // model latency, and must not distort latency-driven routing.
586 let start = Instant::now();
587 let res = slot.model.chat(messages.clone(), config.clone()).await;
588 let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
589 slot.update_latency(elapsed_ms);
590 match res {
591 Ok(result) => {
592 if let Some(usage) = result.token_usage.as_ref() {
593 self.record_usage(slot, usage);
594 }
595 drop(permit);
596 return Ok(result);
597 }
598 Err(e) => last_err = Some(Box::new(e)),
599 }
600 }
601 Err(RouterError::AllFailed {
602 tried: order.len(),
603 last: last_err.unwrap_or_else(|| {
604 Box::new(std::io::Error::other(
605 "candidate order produced no attempts",
606 ))
607 }),
608 })
609 }
610
611 async fn stream_chat_routed(
612 &self,
613 messages: Vec<Message>,
614 config: Option<RunnableConfig>,
615 ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, RouterError>> + Send>>, RouterError>
616 {
617 if self.slots.is_empty() {
618 return Err(RouterError::Empty);
619 }
620 let order = self.candidate_order(Self::first_text(&messages));
621 // H3 fix: remove ineffective Arc — just clone messages directly for each attempt
622 let estimated_tokens = self.estimate_prompt_tokens(&messages);
623 let mut last_err: Option<Box<dyn std::error::Error + Send + Sync>> = None;
624 for &idx in &order {
625 let slot = &self.slots[idx];
626
627 if let Err(exceeded) = self.precheck_budget(slot, estimated_tokens) {
628 last_err = Some(Box::new(RouterError::BudgetExceeded(exceeded)));
629 continue;
630 }
631
632 // The permit must stay alive until the *body* finishes streaming,
633 // not just until headers arrive; it is moved into the wrapped
634 // stream below and released when the stream is dropped/done.
635 let permit = match &slot.gate {
636 Some(gate) => match gate.acquire(slot.model.name()).await {
637 Ok(permit) => Some(permit),
638 Err(e) => {
639 last_err = Some(Box::new(e));
640 continue;
641 }
642 },
643 None => None,
644 };
645
646 let start = Instant::now();
647 match slot
648 .model
649 .stream_chat(messages.clone(), config.clone())
650 .await
651 {
652 Ok(stream) => {
653 let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
654 slot.update_latency(elapsed_ms);
655 let budget = self.budget.clone();
656 let price = self.slot_price(slot);
657 let guarded = async_stream::stream! {
658 let _permit = permit;
659 let mut recorded = false;
660 let mut inner = stream;
661 while let Some(item) = inner.next().await {
662 if let Ok(chunk) = &item {
663 if !recorded {
664 if let Some(usage) = chunk.token_usage.as_ref() {
665 if let Some(budget) = &budget {
666 let cost = price
667 .map(|p| {
668 p.cost_of(
669 usage.prompt_tokens,
670 usage.completion_tokens,
671 )
672 })
673 .unwrap_or(0.0);
674 budget.record(cost, usage.total_tokens as u64);
675 }
676 recorded = true;
677 }
678 }
679 }
680 yield item;
681 }
682 };
683 return Ok(Box::pin(guarded));
684 }
685 Err(e) => {
686 let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
687 slot.update_latency(elapsed_ms);
688 last_err = Some(Box::new(e));
689 }
690 }
691 }
692 Err(RouterError::AllFailed {
693 tried: order.len(),
694 last: last_err.unwrap_or_else(|| {
695 Box::new(std::io::Error::other(
696 "candidate order produced no attempts",
697 ))
698 }),
699 })
700 }
701}
702
703#[async_trait]
704impl Runnable<Vec<Message>, LLMResult> for RouterLLM {
705 type Error = RouterError;
706
707 async fn invoke(
708 &self,
709 input: Vec<Message>,
710 config: Option<RunnableConfig>,
711 ) -> Result<LLMResult, Self::Error> {
712 self.chat_routed(input, config).await
713 }
714}
715
716#[async_trait]
717impl BaseLanguageModel<Vec<Message>, LLMResult> for RouterLLM {
718 fn model_name(&self) -> &str {
719 &self.name
720 }
721
722 fn get_num_tokens(&self, text: &str) -> usize {
723 crate::token_counter::count_tokens(text).unwrap_or_else(|e| {
724 // 编码器加载失败时按字节数高估(宁可略高,不静默按 0 算导致路由/截断误判)
725 log::warn!("token counting failed, falling back to byte-length estimate: {e}");
726 text.len()
727 })
728 }
729
730 fn with_temperature(self, _temp: f32) -> Self {
731 // The router deliberately does not override temperature: each slot
732 // owns its model's sampling parameters, and they cannot be mutated
733 // behind a `Box<dyn RoutedModel>` trait object. Configure temperature
734 // on the individual models before registering them (Q5: no silent
735 // change — this is an explicit no-op, not an attempt to apply it).
736 self
737 }
738
739 fn with_max_tokens(self, _max: usize) -> Self {
740 // Same rationale as `with_temperature`: per-slot max_tokens is owned
741 // by each registered model and cannot be changed after boxing.
742 self
743 }
744}
745
746#[async_trait]
747impl BaseChatModel for RouterLLM {
748 async fn chat(
749 &self,
750 messages: Vec<Message>,
751 config: Option<RunnableConfig>,
752 ) -> Result<LLMResult, Self::Error> {
753 self.chat_routed(messages, config).await
754 }
755
756 async fn stream_chat(
757 &self,
758 messages: Vec<Message>,
759 config: Option<RunnableConfig>,
760 ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, Self::Error>> + Send>>, Self::Error>
761 {
762 self.stream_chat_routed(messages, config).await
763 }
764}
765
766// ---------------------------------------------------------------------------
767// Internal: heterogeneous model adapter
768// ---------------------------------------------------------------------------
769
770/// Internal trait unifying the error type across providers.
771#[async_trait]
772trait RoutedModel: Send + Sync {
773 /// The wrapped model's reported name (used in rate-limit errors).
774 fn name(&self) -> &str;
775
776 async fn chat(
777 &self,
778 messages: Vec<Message>,
779 config: Option<RunnableConfig>,
780 ) -> Result<LLMResult, RouterError>;
781 async fn stream_chat(
782 &self,
783 messages: Vec<Message>,
784 config: Option<RunnableConfig>,
785 ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, RouterError>> + Send>>, RouterError>;
786}
787
788/// Wraps any `BaseChatModel` whose error is `std::error::Error + Send + Sync`,
789/// converting its native error into [`RouterError`].
790struct ModelAdapter<M: BaseChatModel>(M);
791
792#[async_trait]
793impl<M: BaseChatModel> RoutedModel for ModelAdapter<M>
794where
795 M::Error: std::error::Error + Send + Sync + 'static,
796{
797 fn name(&self) -> &str {
798 self.0.model_name()
799 }
800
801 async fn chat(
802 &self,
803 messages: Vec<Message>,
804 config: Option<RunnableConfig>,
805 ) -> Result<LLMResult, RouterError> {
806 let name = self.0.model_name().to_string();
807 self.0
808 .chat(messages, config)
809 .await
810 .map_err(|e| RouterError::Model {
811 model: name,
812 source: Box::new(e),
813 })
814 }
815
816 async fn stream_chat(
817 &self,
818 messages: Vec<Message>,
819 config: Option<RunnableConfig>,
820 ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk, RouterError>> + Send>>, RouterError>
821 {
822 let name = self.0.model_name().to_string();
823 let inner = self
824 .0
825 .stream_chat(messages, config)
826 .await
827 .map_err(|e| RouterError::Model {
828 model: name.clone(),
829 source: Box::new(e),
830 })?;
831 let mapped = inner.map(move |item| {
832 item.map_err(|e| RouterError::Model {
833 model: name.clone(),
834 source: Box::new(e),
835 })
836 });
837 Ok(Box::pin(mapped))
838 }
839}
840
841/// One registered model plus routing metadata.
842struct ModelSlot {
843 model: Box<dyn RoutedModel>,
844 cost: Option<f64>,
845 /// `"<provider>/<id>"` key resolved against the router's [`ModelRegistry`].
846 registry_key: Option<String>,
847 /// Explicit per-token price used when no `with_cost` weight or registry
848 /// entry applies; also prices budget spend for this slot.
849 price: Option<ModelPrice>,
850 /// Optional per-model rate / concurrency admission gate.
851 gate: Option<Arc<rate::ModelGate>>,
852 /// Exponential moving average latency in milliseconds.
853 latency_ms: Mutex<f64>,
854}
855
856impl ModelSlot {
857 fn new(model: Box<dyn RoutedModel>, cost: Option<f64>) -> Self {
858 Self {
859 model,
860 cost,
861 registry_key: None,
862 price: None,
863 gate: None,
864 latency_ms: Mutex::new(0.0),
865 }
866 }
867
868 fn with_key<K: Into<String>>(mut self, key: K) -> Self {
869 self.registry_key = Some(key.into());
870 self
871 }
872
873 fn with_price(mut self, price: Option<ModelPrice>) -> Self {
874 self.price = price;
875 self
876 }
877
878 fn with_gate(mut self, gate: Arc<rate::ModelGate>) -> Self {
879 self.gate = Some(gate);
880 self
881 }
882
883 fn latency(&self) -> f64 {
884 *self.latency_ms.lock().unwrap_or_else(|e| e.into_inner())
885 }
886
887 fn update_latency(&self, ms: f64) {
888 let mut cur = self.latency_ms.lock().unwrap_or_else(|e| e.into_inner());
889 if *cur == 0.0 {
890 *cur = ms;
891 } else {
892 // EMA: weight history 0.7, new sample 0.3.
893 *cur = *cur * 0.7 + ms * 0.3;
894 }
895 }
896}