Skip to main content

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}