lc-core 0.22.4

Core abstractions for langchainrust — Runnable, BaseTool, BaseChatModel, etc.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
//! Per-model admission control for [`super::RouterLLM`] (B13, 0.22.4).
//!
//! Two independent limits are applied to every routed call:
//!
//! - **Request rate**: at most `requests_per_window` calls may start in any
//!   sliding `window`. Admitted requests borrow a permit that is returned
//!   exactly one window later (each permit is scheduled back with a timer
//!   task), so a burst of `N` is accepted immediately and the next caller
//!   queues until the oldest permit comes home.
//! - **Concurrency**: at most `max_concurrent` calls may be in flight at once
//!   (including the time the caller spends streaming the response, since the
//!   router keeps the [`GatePermit`] alive inside the returned stream).
//!
//! When a limit is saturated, callers **queue** instead of being rejected:
//! `tokio::sync::Semaphore` wakes waiters FIFO, which is exactly the queue
//! fairness the router needs (the oldest blocked caller is admitted first).
//! The queue is optionally bounded (`max_queue`) and each waiter waits at
//! most `wait_timeout`; both produce a
//! [`RouterError::RateLimited`](super::RouterError::RateLimited) that the
//! router treats like a model failure and uses to fall through to the next
//! candidate model.

use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;

use tokio::sync::{OwnedSemaphorePermit, Semaphore};

use super::RouterError;

/// Default admission queue timeout when callers configure nothing else.
const DEFAULT_WAIT_TIMEOUT: Duration = Duration::from_secs(60);

/// Per-model rate / concurrency limits attached to one router slot.
///
/// Construct with [`ModelRateLimit::per_minute`] (or
/// [`ModelRateLimit::per_second`]) and tighten the optional dimensions with
/// the `with_*` builders. Pass to
/// [`RouterLLM::with_model_rate_limited`](super::RouterLLM::with_model_rate_limited)
/// or [`RouterLLM::with_last_rate_limit`](super::RouterLLM::with_last_rate_limit).
#[derive(Debug, Clone)]
pub struct ModelRateLimit {
    /// Max calls whose windows overlap (0 disables the rate dimension).
    pub(crate) requests_per_window: usize,
    /// Length of the rate window.
    pub(crate) window: Duration,
    /// Max simultaneously in-flight calls (0 disables the dimension).
    pub(crate) max_concurrent: usize,
    /// Max callers allowed to wait (0 = unbounded).
    pub(crate) max_queue: usize,
    /// Max time one caller waits for admission.
    pub(crate) wait_timeout: Duration,
}

impl ModelRateLimit {
    /// At most `requests_per_minute` admitted per 60-second window, unlimited
    /// concurrency, unbounded queue, 60-second wait timeout.
    pub fn per_minute(requests_per_minute: usize) -> Self {
        Self {
            requests_per_window: requests_per_minute,
            window: Duration::from_secs(60),
            max_concurrent: 0,
            max_queue: 0,
            wait_timeout: DEFAULT_WAIT_TIMEOUT,
        }
    }

    /// At most `requests_per_second` admitted per one-second window.
    pub fn per_second(requests_per_second: usize) -> Self {
        Self {
            requests_per_window: requests_per_second,
            window: Duration::from_secs(1),
            max_concurrent: 0,
            max_queue: 0,
            wait_timeout: Duration::from_secs(1),
        }
    }

    /// Overrides the rate window length (e.g. a provider quota stated per day).
    /// The wait timeout is intentionally left untouched: a queued caller may
    /// legitimately wait several windows deep, so configure
    /// [`ModelRateLimit::with_wait_timeout`] separately when shortening.
    pub fn with_window(mut self, window: Duration) -> Self {
        self.window = window;
        self
    }

    /// Bounds in-flight calls. The slot stays occupied until the whole
    /// response (including a streamed body) has finished. `0` (the default)
    /// disables the concurrency dimension.
    pub fn with_max_concurrent(mut self, max_concurrent: usize) -> Self {
        self.max_concurrent = max_concurrent;
        self
    }

    /// Bounds how many callers may wait for admission; `0` (the default)
    /// means an unbounded FIFO queue. A full queue rejects immediately with
    /// [`RouterError::RateLimited`](super::RouterError::RateLimited) so the
    /// router can fall through to a fallback model without delay.
    pub fn with_max_queue(mut self, max_queue: usize) -> Self {
        self.max_queue = max_queue;
        self
    }

    /// Caps how long one caller waits before the router gives up on this
    /// slot and tries the next model. Defaults to one rate window.
    pub fn with_wait_timeout(mut self, wait_timeout: Duration) -> Self {
        self.wait_timeout = wait_timeout;
        self
    }

    /// Configured requests-per-window.
    pub fn requests_per_window(&self) -> usize {
        self.requests_per_window
    }

    /// Configured rate window.
    pub fn window(&self) -> Duration {
        self.window
    }

    /// Configured concurrency cap (0 = unlimited).
    pub fn max_concurrent(&self) -> usize {
        self.max_concurrent
    }

    /// Configured queue depth (0 = unbounded).
    pub fn max_queue(&self) -> usize {
        self.max_queue
    }

    /// Configured admission wait timeout.
    pub fn wait_timeout(&self) -> Duration {
        self.wait_timeout
    }
}

/// Admission gate built from a [`ModelRateLimit`] and shared (`Arc`) by every
/// call routed to one slot.
pub(super) struct ModelGate {
    /// `None` when the rate dimension is disabled.
    rate: Option<RateGate>,
    /// Concurrency permits; [`Semaphore::MAX_PERMITS`] when unlimited.
    concurrency: Arc<Semaphore>,
    /// Max wait for a free concurrency slot (same configured timeout as the
    /// rate queue, so a slot saturated by in-flight streams is skipped).
    wait_timeout: Duration,
}

impl ModelGate {
    pub(super) fn new(config: &ModelRateLimit) -> Arc<Self> {
        let concurrency = if config.max_concurrent == 0 {
            Semaphore::MAX_PERMITS
        } else {
            config.max_concurrent
        };
        Arc::new(Self {
            rate: if config.requests_per_window > 0 {
                Some(RateGate::new(config))
            } else {
                None
            },
            concurrency: Arc::new(Semaphore::new(concurrency)),
            wait_timeout: config.wait_timeout,
        })
    }

    /// Acquires admission for one call.
    ///
    /// Rate admission happens **first** so the FIFO rate queue determines
    /// global order; the concurrency slot is taken afterwards and held in the
    /// returned guard until the call (and its streamed body) completes.
    /// Either dimension parks at most `wait_timeout`, then the router skips
    /// the slot.
    pub(super) async fn acquire(&self, model: &str) -> Result<GatePermit, RouterError> {
        if let Some(rate) = &self.rate {
            rate.acquire(model).await?;
        }
        // An unlimited-configured semaphore cannot park or close; a bounded
        // one waits up to the configured timeout so the caller can fall
        // through to the next candidate instead of blocking indefinitely.
        let acquired =
            tokio::time::timeout(self.wait_timeout, self.concurrency.clone().acquire_owned()).await;
        let concurrency = match acquired {
            Ok(Ok(permit)) => permit,
            Ok(Err(_closed)) => panic!("router concurrency semaphore is never closed"),
            Err(_elapsed) => {
                return Err(RouterError::RateLimited {
                    model: model.to_string(),
                    reason: RateLimitReason::Timeout {
                        waited: self.wait_timeout,
                    },
                });
            }
        };
        Ok(GatePermit {
            _concurrency: concurrency,
        })
    }
}

/// Held while one call is in flight; dropping it frees the concurrency slot.
pub(super) struct GatePermit {
    _concurrency: OwnedSemaphorePermit,
}

impl std::fmt::Debug for GatePermit {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("GatePermit").finish_non_exhaustive()
    }
}

/// Sliding-window request-rate gate with a bounded FIFO wait queue.
struct RateGate {
    /// One permit per admissible call; returned `window` after acquisition.
    semaphore: Arc<Semaphore>,
    window: Duration,
    /// Callers currently trying to acquire (waiting or about to be admitted).
    queued: AtomicUsize,
    max_queue: usize,
    wait_timeout: Duration,
}

impl RateGate {
    fn new(config: &ModelRateLimit) -> Self {
        Self {
            semaphore: Arc::new(Semaphore::new(config.requests_per_window)),
            window: config.window,
            queued: AtomicUsize::new(0),
            max_queue: config.max_queue,
            wait_timeout: config.wait_timeout,
        }
    }

    async fn acquire(&self, model: &str) -> Result<(), RouterError> {
        // Queue accounting brackets only the wait: a call that already holds
        // a rate permit must not consume queue capacity.
        let position = self.queued.fetch_add(1, Ordering::AcqRel);
        if self.max_queue > 0 && position >= self.max_queue {
            self.queued.fetch_sub(1, Ordering::AcqRel);
            return Err(RouterError::RateLimited {
                model: model.to_string(),
                reason: RateLimitReason::QueueFull {
                    max_queue: self.max_queue,
                },
            });
        }

        let acquired =
            tokio::time::timeout(self.wait_timeout, self.semaphore.clone().acquire_owned()).await;
        self.queued.fetch_sub(1, Ordering::AcqRel);

        let permit = match acquired {
            Ok(Ok(permit)) => permit,
            Ok(Err(_closed)) => panic!("router rate semaphore is never closed"),
            Err(_elapsed) => {
                return Err(RouterError::RateLimited {
                    model: model.to_string(),
                    reason: RateLimitReason::Timeout {
                        waited: self.wait_timeout,
                    },
                });
            }
        };

        // Return the permit exactly one window after this acquisition: the
        // semaphore itself is the sliding-window log, and its FIFO wake order
        // is the admission queue.
        let window = self.window;
        tokio::spawn(async move {
            tokio::time::sleep(window).await;
            drop(permit);
        });
        Ok(())
    }
}

/// Why a queued call could not be admitted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RateLimitReason {
    /// The bounded waiting queue was already full.
    QueueFull {
        /// Configured queue capacity.
        max_queue: usize,
    },
    /// No permit became available within the configured wait timeout.
    Timeout {
        /// Time waited.
        waited: Duration,
    },
}

impl std::fmt::Display for RateLimitReason {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            RateLimitReason::QueueFull { max_queue } => {
                write!(f, "admission queue full (max_queue={max_queue})")
            }
            RateLimitReason::Timeout { waited } => {
                write!(f, "no permit within {} ms", waited.as_millis())
            }
        }
    }
}

impl std::error::Error for RateLimitReason {}

#[cfg(test)]
mod tests {
    use super::*;

    fn gate(rl: &ModelRateLimit) -> Arc<ModelGate> {
        ModelGate::new(rl)
    }

    #[tokio::test(start_paused = true)]
    async fn admits_burst_then_queues_fifo_across_window() {
        // 1 call / 100 ms, concurrency 1 so admissions serialize and the
        // completion order is the admission order.
        let rl = ModelRateLimit::per_second(1)
            .with_window(Duration::from_millis(100))
            .with_max_concurrent(1);
        let g = gate(&rl);

        let finished = Arc::new(tokio::sync::Mutex::new(Vec::new()));
        let mut handles = Vec::new();
        for id in 0..3u8 {
            let g = g.clone();
            let finished = finished.clone();
            handles.push(tokio::spawn(async move {
                let _permit = g.acquire("m").await.unwrap();
                // Simulate in-call work while holding both permits.
                tokio::time::sleep(Duration::from_millis(10)).await;
                finished.lock().await.push(id);
            }));
        }
        for h in handles {
            h.await.unwrap();
        }
        // FIFO: spawn order 0,1,2 must be completion order even though
        // callers 1 and 2 had to wait for sliding-window replenishment.
        assert_eq!(*finished.lock().await, vec![0, 1, 2]);
    }

    #[tokio::test(start_paused = true)]
    async fn full_queue_rejects_instead_of_waiting() {
        let rl = ModelRateLimit::per_minute(1).with_max_queue(1);
        let g = gate(&rl);

        let _p0 = g.acquire("m").await.unwrap();
        // One caller is allowed to park in the queue ...
        let g1 = g.clone();
        let waiter = tokio::spawn(async move { g1.acquire("m").await });
        // Let the waiter park.
        tokio::task::yield_now().await;
        tokio::task::yield_now().await;
        // ... the next caller hits the bounded queue and fails immediately so
        // the router can fall through to a fallback.
        let err = g.acquire("m").await.unwrap_err();
        assert!(
            matches!(
                err,
                RouterError::RateLimited {
                    reason: RateLimitReason::QueueFull { max_queue: 1 },
                    ..
                }
            ),
            "got {err:?}"
        );
        // The parked waiter is unaffected and gets admitted once a permit
        // returns (one window later).
        tokio::time::sleep(Duration::from_secs(60)).await;
        assert!(waiter.await.unwrap().is_ok());
    }

    #[tokio::test(start_paused = true)]
    async fn wait_timeout_falls_through() {
        let rl = ModelRateLimit::per_minute(1).with_wait_timeout(Duration::from_secs(5));
        let g = gate(&rl);
        let _p0 = g.acquire("m").await.unwrap();
        let started = tokio::time::Instant::now();
        let err = g.acquire("primary").await.unwrap_err();
        assert_eq!(started.elapsed(), Duration::from_secs(5));
        assert!(
            matches!(
                err,
                RouterError::RateLimited {
                    reason: RateLimitReason::Timeout { .. },
                    ..
                }
            ),
            "got {err:?}"
        );
    }

    #[tokio::test(start_paused = true)]
    async fn permit_returns_after_window_and_keeps_rate_stable() {
        // 2 / 100 ms: two bursts of two, separated by a window, must admit.
        let rl = ModelRateLimit::per_second(2).with_window(Duration::from_millis(100));
        let g = gate(&rl);
        let _p1 = g.acquire("m").await.unwrap();
        let _p2 = g.acquire("m").await.unwrap();
        // Third call queues; must be admitted shortly after the window rolls.
        let g2 = g.clone();
        let h = tokio::spawn(async move { g2.acquire("m").await });
        tokio::time::sleep(Duration::from_millis(101)).await;
        assert!(h.await.unwrap().is_ok());
    }

    #[test]
    fn disabled_dimensions_are_represented_as_zero() {
        let rl = ModelRateLimit::per_minute(10);
        assert_eq!(rl.max_concurrent(), 0);
        assert_eq!(rl.max_queue(), 0);
        assert_eq!(rl.requests_per_window(), 10);
        let rl2 = rl
            .with_window(Duration::from_secs(30))
            .with_wait_timeout(Duration::from_secs(30));
        // Window and wait timeout are configured independently (a queued
        // caller may wait more than one window).
        assert_eq!(rl2.window(), Duration::from_secs(30));
        assert_eq!(rl2.wait_timeout(), Duration::from_secs(30));
    }
}