Skip to main content

leviath_runtime/
inference_bridge.rs

1//! The async worker side of the ECS inference stage - the sync-ECS ↔ async-I/O
2//! bridge for inference.
3//!
4//! Systems can't `.await`, and a single inference can take up to an hour, so the
5//! inference-dispatch system never runs the network call itself. Instead it
6//! builds an [`InferenceJob`] (an agent's request plus the per-model pool permit
7//! it acquired) and `tokio::spawn`s [`run_inference_job`]. That short-lived task
8//! performs the call with the permit held, reports an [`InferenceOutcome`] on the
9//! results channel, and wakes the tick loop; the inference-collect system drains
10//! outcomes on a later tick and applies them back to the agents.
11//!
12//! One task exists per *in-flight request* (bounded by the per-model
13//! [`InferencePools`](crate::inference_pool::InferencePools) permits), **never**
14//! one per agent - that is what keeps CPU bounded by work, not by agent count.
15
16use std::sync::Arc;
17use std::time::Duration;
18
19use bevy_ecs::entity::Entity;
20use leviath_providers::{InferenceRequest, InferenceResponse, Provider, ProviderError};
21use tokio::sync::Notify;
22use tokio::sync::mpsc::UnboundedSender;
23
24use crate::inference_pool::InferencePermit;
25
26/// Total attempts, including the first, for a transient inference failure.
27///
28/// Served from `[limits] inference_retry_attempts`, which defaults to this.
29pub const DEFAULT_RETRY_ATTEMPTS: u32 = 4;
30
31/// The first backoff, in milliseconds, after an ordinary transient failure.
32///
33/// Served from `[limits] inference_retry_base_ms`, which defaults to this. Each
34/// further retry doubles it, so the default schedule is 1s, 2s, 4s.
35pub const DEFAULT_RETRY_BASE_DELAY_MS: u64 = 1_000;
36
37/// The first backoff after a *capacity* failure (429, or a 529 "overloaded")
38/// the provider gave no `Retry-After` for, in seconds.
39///
40/// Deliberately much larger than [`DEFAULT_RETRY_BASE_DELAY_MS`]. An overload
41/// window lasts minutes, so a second of waiting only buys another refusal: the
42/// reported run (issue #417) spent all three of its retries inside one 529
43/// window and was failed with 44 iterations of finished work in hand.
44pub const CAPACITY_BASE_DELAY_SECS: u64 = 15;
45
46/// The longest one capacity backoff may last, in seconds, and the ceiling on a
47/// `Retry-After` the provider asks for.
48///
49/// A minute is long enough to leave most overload windows and short enough that
50/// a run still notices when the provider comes back. A server asking for longer
51/// than this is waited out a minute at a time instead, which costs an extra
52/// refusal but keeps one header from parking a run for an hour.
53pub const CAPACITY_MAX_DELAY_SECS: u64 = 60;
54
55/// The ceiling on the *cumulative* backoff of a single inference job, in
56/// seconds, across every retry it makes.
57///
58/// The overall bound on waiting, and the answer to "how long can a retrying job
59/// hold its pool slot": five minutes of sleeping, however the attempts,
60/// backoffs and `Retry-After` hints add up. Network time is on top of it and is
61/// bounded separately by [`RetryPolicy::job_timeout`].
62pub const MAX_TOTAL_BACKOFF_SECS: u64 = 300;
63
64/// How a transient inference failure is retried before the agent is failed.
65///
66/// Transient errors (see [`ProviderError::is_transient`]) are retried with
67/// exponential backoff; a permanent error (auth, invalid request, token limit)
68/// fails immediately. This keeps a passing network blip from marking a stage
69/// `error` and carrying its half-finished work forward.
70///
71/// A *capacity* failure - a 429, or Anthropic's 529 "overloaded" - is retried on
72/// its own, much slower schedule (see [`Self::capacity_base_delay`]), because it
73/// describes a window that lasts minutes rather than a blip that clears in a
74/// second. When the provider said how long to wait, that answer is used instead
75/// of any of these numbers.
76///
77/// Every schedule is bounded twice over: by [`Self::max_attempts`], and by
78/// [`Self::max_total_backoff`] on the sum of the waits. A job can never retry
79/// forever.
80#[derive(Debug, Clone, Copy)]
81pub struct RetryPolicy {
82    /// Total attempts including the first (e.g. `4` = one try + three retries).
83    pub max_attempts: u32,
84    /// Base backoff; the retry after attempt `n` waits `base_delay * 2^(n-1)`
85    /// (so 1s, 2s, 4s, … for a 1s base).
86    pub base_delay: Duration,
87    /// Base backoff for a capacity failure the provider gave no `Retry-After`
88    /// for, doubling per attempt exactly as [`Self::base_delay`] does and capped
89    /// at [`Self::capacity_max_delay`]. Defaults to
90    /// [`CAPACITY_BASE_DELAY_SECS`], so the default schedule is 15s, 30s, 60s.
91    pub capacity_base_delay: Duration,
92    /// Ceiling on one capacity backoff, and on an honored `Retry-After`.
93    /// Defaults to [`CAPACITY_MAX_DELAY_SECS`].
94    pub capacity_max_delay: Duration,
95    /// Ceiling on the sum of every backoff this job sleeps. Once it is spent the
96    /// job stops retrying and reports the last error, whatever
97    /// [`Self::max_attempts`] still allowed. Defaults to
98    /// [`MAX_TOTAL_BACKOFF_SECS`].
99    pub max_total_backoff: Duration,
100    /// Hard ceiling on the total wall-clock time one job (all attempts +
101    /// backoffs) may run before it is aborted and its pool slot freed.
102    ///
103    /// Providers apply this same deadline as their own per-call timeout, so a
104    /// call normally ends there. This outer bound is the backstop: a provider
105    /// timer can be defeated (e.g. a connection that keeps trickling keepalive
106    /// bytes without ever completing resets a read timer forever), which would
107    /// leak the permit until the model's pool fills and new agents never get a
108    /// slot. Wrapping the whole job in a wall-clock timeout guarantees the slot
109    /// is released within a fixed time regardless. Defaults to
110    /// [`leviath_providers::DEFAULT_INFERENCE_TIMEOUT_SECS`]; a stage's
111    /// `request_timeout_secs` overrides it per stage.
112    pub job_timeout: Duration,
113}
114
115impl Default for RetryPolicy {
116    fn default() -> Self {
117        Self {
118            max_attempts: DEFAULT_RETRY_ATTEMPTS,
119            base_delay: Duration::from_millis(DEFAULT_RETRY_BASE_DELAY_MS),
120            capacity_base_delay: Duration::from_secs(CAPACITY_BASE_DELAY_SECS),
121            capacity_max_delay: Duration::from_secs(CAPACITY_MAX_DELAY_SECS),
122            max_total_backoff: Duration::from_secs(MAX_TOTAL_BACKOFF_SECS),
123            // The unified default inference deadline. A stage's
124            // `request_timeout_secs` overrides this per stage (see
125            // `pipeline::retry_policy_for`); the providers apply the same value
126            // as their own per-call timeout, so this is the single outer bound
127            // that also frees the pool slot if a provider's timer is defeated.
128            job_timeout: Duration::from_secs(leviath_providers::DEFAULT_INFERENCE_TIMEOUT_SECS),
129        }
130    }
131}
132
133/// A unit of inference work the dispatch system hands to the worker pool.
134pub struct InferenceJob {
135    /// The agent this inference is for.
136    pub entity: Entity,
137    /// The provider to call (already resolved for the agent's model).
138    pub provider: Arc<dyn Provider>,
139    /// The assembled request.
140    pub request: InferenceRequest,
141    /// The per-model pool permit, held for the whole request and released when
142    /// the job finishes.
143    pub permit: InferencePermit,
144    /// When set, count the assembled request's tokens exactly (via the
145    /// provider's `count_tokens`, which uses a remote endpoint where available)
146    /// before calling `infer`, and fail early if it would exceed the model's
147    /// context window. Off by default - the runtime's cheap `len/4` estimates
148    /// drive normal budgeting; this is the opt-in accurate guard.
149    pub exact_token_counting: bool,
150}
151
152/// Flatten a request into the text whose tokens we count for the budget guard:
153/// system blocks, every message's textual content, and each tool's name +
154/// description + JSON schema. This mirrors what the provider sends closely enough
155/// for a context-window check (exact per-message/role overhead is the provider's
156/// to add; the bulk is this text).
157fn flatten_request_text(request: &InferenceRequest) -> String {
158    let mut parts: Vec<String> = Vec::new();
159    for block in &request.system {
160        parts.push(block.text.clone());
161    }
162    for msg in &request.messages {
163        parts.push(msg.content.as_text());
164    }
165    for tool in &request.tools {
166        parts.push(tool.name.clone());
167        parts.push(tool.description.clone());
168        parts.push(tool.parameters.to_string());
169    }
170    parts.join("\n")
171}
172
173/// `base` doubled once per attempt already made: `base * 2^(attempt - 1)`.
174///
175/// Saturating throughout, and the exponent is clamped, so a policy with a large
176/// base or a job that somehow retried thousands of times yields a very long
177/// duration rather than an overflow panic.
178fn exponential(base: Duration, attempt: u32) -> Duration {
179    base.saturating_mul(2u32.saturating_pow(attempt.saturating_sub(1).min(16)))
180}
181
182/// How long to wait before retrying `error`, or `None` to stop and report it.
183///
184/// `attempt` is the attempt that just failed, counting from 1, and `spent` is
185/// the backoff this job has already slept. Pure, so the whole schedule - the
186/// ordinary one, the capacity one, an honored `Retry-After`, and both ceilings -
187/// is asserted in tests without a second of real waiting.
188///
189/// The order of the checks is the policy: a permanent error is never retried, an
190/// exhausted attempt count stops, an exhausted backoff budget stops, and only
191/// then does the kind of failure decide how long to wait.
192fn backoff_after(
193    policy: &RetryPolicy,
194    error: &ProviderError,
195    attempt: u32,
196    spent: Duration,
197) -> Option<Duration> {
198    if !error.is_transient() || attempt >= policy.max_attempts {
199        return None;
200    }
201    // The overall ceiling: once the job has slept its whole budget it stops,
202    // however many attempts were left. This is what guarantees a retrying run
203    // cannot wait forever, whatever a provider's `Retry-After` asks for.
204    let remaining = policy
205        .max_total_backoff
206        .checked_sub(spent)
207        .filter(|left| !left.is_zero())?;
208    let advice = error.retry_advice();
209    let delay = match (advice.capacity, advice.retry_after_secs) {
210        // The provider said when to come back, so come back then.
211        (true, Some(secs)) => Duration::from_secs(secs).min(policy.capacity_max_delay),
212        // At capacity with no hint: the slow schedule, since the window this
213        // failure describes outlasts a blip-sized wait (issue #417).
214        (true, None) => {
215            exponential(policy.capacity_base_delay, attempt).min(policy.capacity_max_delay)
216        }
217        // An ordinary blip - a reset connection, a 500 - keeps the fast
218        // schedule it has always had.
219        (false, _) => exponential(policy.base_delay, attempt),
220    };
221    Some(delay.min(remaining))
222}
223
224/// The completed result of an [`InferenceJob`], applied on a later tick by the
225/// inference-collect system.
226pub struct InferenceOutcome {
227    /// The agent the result belongs to.
228    pub entity: Entity,
229    /// The provider's response, or the error it failed with.
230    pub result: Result<InferenceResponse, ProviderError>,
231    /// Wall-clock time the job took, retries and backoff included. Measured
232    /// here because the ECS only sees the outcome land on a later tick; this
233    /// is the only place the call's real duration exists.
234    pub latency: std::time::Duration,
235}
236
237/// Run one inference job to completion: perform the (possibly hour-long) network
238/// call with the pool permit held, release the slot, report the outcome, and
239/// wake the tick loop.
240///
241/// Meant to be `tokio::spawn`ed by the dispatch system. If the results receiver
242/// has been dropped (the world is shutting down) the send is a harmless no-op.
243pub async fn run_inference_job(
244    job: InferenceJob,
245    results: UnboundedSender<InferenceOutcome>,
246    wake: Arc<Notify>,
247    retry: RetryPolicy,
248    cancel: crate::cancel::CancelToken,
249) {
250    let InferenceJob {
251        entity,
252        provider,
253        request,
254        permit,
255        exact_token_counting,
256    } = job;
257    let started = std::time::Instant::now();
258    // Opt-in accurate pre-flight budget guard: count the assembled request
259    // exactly (remote endpoint where the provider has one, heuristic otherwise)
260    // and refuse a request that would overflow the model's context window,
261    // rather than sending it and letting the provider reject it after the fact.
262    if exact_token_counting {
263        let text = flatten_request_text(&request);
264        let used = provider.count_tokens(&text, &request.model).await;
265        let max = provider.max_context_tokens(&request.model);
266        if used.saturating_add(request.max_tokens) > max {
267            drop(permit);
268            let _ = results.send(InferenceOutcome {
269                entity,
270                result: Err(ProviderError::TokenLimitExceeded { used, max }),
271                latency: started.elapsed(),
272            });
273            wake.notify_one();
274            return;
275        }
276    }
277    // Retry transient failures (connection reset, timeout, 429, 5xx) with
278    // exponential backoff, holding the permit across the backoff; a permanent
279    // error fails immediately. `backoff_after` decides each wait: a capacity
280    // refusal gets the slow schedule or the provider's own `Retry-After`, an
281    // ordinary blip the fast one. The whole thing is bounded by
282    // `max_total_backoff` on the sleeping and by `job_timeout` on the job, so a
283    // never-completing (stalled-stream) call cannot hold the pool slot forever.
284    //
285    // `infer` borrows the request, so every attempt reuses the one assembled
286    // copy. It used to be cloned per attempt, which doubled the live footprint
287    // of every in-flight request for the whole (possibly minutes-long) call.
288    let attempts = async {
289        let mut attempt = 1u32;
290        let mut spent = Duration::ZERO;
291        loop {
292            match provider.infer(&request).await {
293                Ok(response) => break Ok(response),
294                Err(e) => match backoff_after(&retry, &e, attempt, spent) {
295                    Some(delay) => {
296                        tokio::time::sleep(delay).await;
297                        spent = spent.saturating_add(delay);
298                        attempt += 1;
299                    }
300                    None => break Err(e),
301                },
302            }
303        }
304    };
305    // A cancel drops the whole retry-and-backoff future - aborting the in-flight
306    // HTTP request rather than waiting out the job timeout (up to 15 minutes) -
307    // and reports nothing: the agent is already terminal, so there is no outcome
308    // to apply. Releasing the permit here is the point; a cancelled run used to
309    // hold its model's pool slot for as long as the provider took to answer.
310    //
311    // Note this arm sends no outcome and so never reaches the `wake` below: the
312    // tick loop learns the slot is free from the permit's own `Drop` (see
313    // `InferencePools::with_wake`). Without that, this return frees a slot in
314    // silence and every agent queued on this model stays parked (issue #189).
315    let result = tokio::select! {
316        biased;
317        _ = cancel.cancelled() => {
318            drop(permit);
319            return;
320        }
321        outcome = tokio::time::timeout(retry.job_timeout, attempts) => match outcome {
322            Ok(result) => result,
323            Err(_elapsed) => Err(leviath_providers::ProviderError::Other(format!(
324                "inference exceeded the {}s job timeout and was aborted to free the \
325                 pool slot (a stalled or never-completing response)",
326                retry.job_timeout.as_secs()
327            ))),
328        },
329    };
330    drop(permit); // free the pool slot before the collect system runs
331    let _ = results.send(InferenceOutcome {
332        entity,
333        result,
334        latency: started.elapsed(),
335    });
336    wake.notify_one();
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use crate::inference_pool::{InferencePoolConfig, InferencePools};
343    use tokio::sync::mpsc;
344
345    fn test_request() -> InferenceRequest {
346        InferenceRequest {
347            system: vec![],
348            messages: vec![],
349            model: "m".to_string(),
350            max_tokens: 100,
351            temperature: 0.0,
352            tools: vec![],
353            extra: serde_json::Value::Null,
354            request_timeout_secs: None,
355        }
356    }
357
358    fn response(text: &str) -> InferenceResponse {
359        InferenceResponse {
360            content: text.to_string(),
361            tool_calls: vec![],
362            tokens_used: leviath_providers::TokenUsage {
363                prompt_tokens: 1,
364                completion_tokens: 1,
365                total_tokens: 2,
366                cached_tokens: 0,
367                cache_write_tokens: 0,
368            },
369            finish_reason: leviath_providers::FinishReason::Complete,
370        }
371    }
372
373    /// A provider that returns a fixed success or error for `infer` (avoids
374    /// cloning `ProviderError`, which isn't `Clone`).
375    enum Fixed {
376        Ok(InferenceResponse),
377        Err(String),
378    }
379
380    #[async_trait::async_trait]
381    impl Provider for Fixed {
382        async fn infer(
383            &self,
384            _req: &InferenceRequest,
385        ) -> leviath_providers::Result<InferenceResponse> {
386            match self {
387                Fixed::Ok(r) => Ok(r.clone()),
388                Fixed::Err(m) => Err(ProviderError::Other(m.clone())),
389            }
390        }
391        async fn count_tokens(&self, _text: &str, _model: &str) -> usize {
392            1
393        }
394        fn max_context_tokens(&self, _model: &str) -> usize {
395            100_000
396        }
397        fn name(&self) -> &str {
398            "fixed"
399        }
400        fn capabilities(&self, _model: &str) -> leviath_providers::ModelCapabilities {
401            leviath_providers::ModelCapabilities::default()
402        }
403    }
404
405    fn job(provider: Arc<dyn Provider>) -> InferenceJob {
406        let pools = InferencePools::new(InferencePoolConfig::new());
407        InferenceJob {
408            entity: Entity::from_raw_u32(7)
409                .expect("a small literal index is always a valid entity id"),
410            provider,
411            request: test_request(),
412            permit: pools.try_acquire("m").expect("free pool"),
413            exact_token_counting: false,
414        }
415    }
416
417    /// Cancelling releases the pool slot immediately instead of waiting out the
418    /// job timeout (15 minutes by default), and reports no outcome - the agent is
419    /// already terminal, so there is nothing to apply.
420    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
421    async fn a_cancelled_job_frees_its_pool_slot_without_reporting() {
422        let mut cfg = InferencePoolConfig::new();
423        cfg.set_limit("m", 1);
424        let pools = InferencePools::new(cfg);
425        let permit = pools.try_acquire("m").expect("free pool");
426        assert!(pools.try_acquire("m").is_none(), "pool should be full");
427
428        // A provider that never answers - the stalled-call case a cancel exists
429        // to escape.
430        let provider = Arc::new(Scripted {
431            steps: std::sync::Mutex::new(vec![Step::Hang].into()),
432            calls: std::sync::Mutex::new(0),
433        });
434        let job = InferenceJob {
435            entity: Entity::from_raw_u32(7)
436                .expect("a small literal index is always a valid entity id"),
437            provider,
438            request: test_request(),
439            permit,
440            exact_token_counting: false,
441        };
442        let (tx, mut rx) = mpsc::unbounded_channel();
443        let cancel = crate::cancel::CancelToken::new();
444        let running = tokio::spawn(run_inference_job(
445            job,
446            tx,
447            Arc::new(Notify::new()),
448            // A job timeout far longer than the test: only the cancel can end it.
449            RetryPolicy {
450                max_attempts: 1,
451                job_timeout: Duration::from_secs(3600),
452                ..instant()
453            },
454            cancel.clone(),
455        ));
456        tokio::task::yield_now().await;
457        cancel.cancel();
458
459        tokio::time::timeout(Duration::from_secs(5), running)
460            .await
461            .expect("the cancel ended the job")
462            .unwrap();
463        assert!(
464            pools.try_acquire("m").is_some(),
465            "the pool slot is free for the next agent"
466        );
467        assert!(
468            rx.try_recv().is_err(),
469            "and no outcome is reported for a cancelled run"
470        );
471    }
472
473    #[tokio::test]
474    async fn run_job_aborts_a_hung_call_and_frees_the_pool_slot() {
475        // A model pool of one slot, taken by the (hung) job under test.
476        let mut cfg = InferencePoolConfig::new();
477        cfg.set_limit("m", 1);
478        let pools = InferencePools::new(cfg);
479        let permit = pools.try_acquire("m").expect("free pool");
480        assert!(pools.try_acquire("m").is_none(), "pool should be full");
481
482        let provider = Arc::new(Scripted {
483            steps: std::sync::Mutex::new(vec![Step::Hang].into()),
484            calls: std::sync::Mutex::new(0),
485        });
486        let job = InferenceJob {
487            entity: Entity::from_raw_u32(7)
488                .expect("a small literal index is always a valid entity id"),
489            provider,
490            request: test_request(),
491            permit,
492            exact_token_counting: false,
493        };
494        let (tx, mut rx) = mpsc::unbounded_channel();
495        let policy = RetryPolicy {
496            max_attempts: 1,
497            job_timeout: Duration::from_millis(50),
498            ..instant()
499        };
500        run_inference_job(
501            job,
502            tx,
503            Arc::new(Notify::new()),
504            policy,
505            crate::cancel::CancelToken::new(),
506        )
507        .await;
508
509        // The hung call was aborted with a timeout error…
510        let outcome = rx.try_recv().expect("outcome sent");
511        let err = outcome.result.expect_err("hung call should error");
512        assert!(err.to_string().contains("job timeout"), "got: {err}");
513        // …and its pool slot is free again for the next agent.
514        assert!(
515            pools.try_acquire("m").is_some(),
516            "the slot must be released after the timeout"
517        );
518    }
519
520    #[tokio::test]
521    async fn run_job_reports_ok_and_wakes() {
522        let (tx, mut rx) = mpsc::unbounded_channel();
523        let wake = Arc::new(Notify::new());
524        run_inference_job(
525            job(Arc::new(Fixed::Ok(response("hi")))),
526            tx,
527            wake.clone(),
528            RetryPolicy::default(),
529            crate::cancel::CancelToken::new(),
530        )
531        .await;
532
533        let outcome = rx.try_recv().expect("outcome sent");
534        assert_eq!(
535            outcome.entity,
536            Entity::from_raw_u32(7).expect("a small literal index is always a valid entity id")
537        );
538        assert_eq!(outcome.result.unwrap().content, "hi");
539        // The wake was signalled (a subsequent notified() returns immediately).
540        wake.notified().await;
541    }
542
543    #[tokio::test]
544    async fn run_job_reports_provider_error() {
545        let (tx, mut rx) = mpsc::unbounded_channel();
546        let wake = Arc::new(Notify::new());
547        let err = Arc::new(Fixed::Err("boom".to_string()));
548        run_inference_job(
549            job(err),
550            tx,
551            wake,
552            RetryPolicy::default(),
553            crate::cancel::CancelToken::new(),
554        )
555        .await;
556
557        let outcome = rx.try_recv().expect("outcome sent");
558        assert!(outcome.result.is_err());
559    }
560
561    /// A provider with a fixed `count_tokens` result and context window, used to
562    /// drive the opt-in pre-inference budget guard. `infer` always succeeds.
563    struct Counter {
564        count: usize,
565        max: usize,
566    }
567
568    #[async_trait::async_trait]
569    impl Provider for Counter {
570        async fn infer(
571            &self,
572            _req: &InferenceRequest,
573        ) -> leviath_providers::Result<InferenceResponse> {
574            Ok(response("ok"))
575        }
576        async fn count_tokens(&self, _text: &str, _model: &str) -> usize {
577            self.count
578        }
579        fn max_context_tokens(&self, _model: &str) -> usize {
580            self.max
581        }
582        fn name(&self) -> &str {
583            "counter"
584        }
585        fn capabilities(&self, _model: &str) -> leviath_providers::ModelCapabilities {
586            leviath_providers::ModelCapabilities::default()
587        }
588    }
589
590    fn counting_job(provider: Arc<dyn Provider>, exact: bool) -> InferenceJob {
591        let pools = InferencePools::new(InferencePoolConfig::new());
592        InferenceJob {
593            entity: Entity::from_raw_u32(7)
594                .expect("a small literal index is always a valid entity id"),
595            provider,
596            request: test_request(), // max_tokens: 100
597            permit: pools.try_acquire("m").expect("free pool"),
598            exact_token_counting: exact,
599        }
600    }
601
602    #[test]
603    fn flatten_request_text_includes_system_messages_and_tools() {
604        use leviath_providers::{SystemBlock, Tool};
605        let req = InferenceRequest {
606            system: vec![SystemBlock {
607                text: "sys".to_string(),
608                cache_hint: leviath_core::CacheHint::Never,
609            }],
610            messages: vec![leviath_providers::Message {
611                role: "user".to_string(),
612                content: "hello".into(),
613                cache_breakpoint: false,
614            }],
615            model: "m".to_string(),
616            max_tokens: 10,
617            temperature: 0.0,
618            tools: vec![Tool {
619                name: "search".to_string(),
620                description: "find things".to_string(),
621                parameters: serde_json::json!({"type": "object"}),
622            }],
623            extra: serde_json::Value::Null,
624            request_timeout_secs: None,
625        };
626        let text = flatten_request_text(&req);
627        assert!(text.contains("sys"));
628        assert!(text.contains("hello"));
629        assert!(text.contains("search"));
630        assert!(text.contains("find things"));
631        assert!(text.contains("object"));
632    }
633
634    #[tokio::test]
635    async fn guard_rejects_request_over_context_window() {
636        // count(950) + max_tokens(100) = 1050 > context(1000) ⇒ rejected pre-flight.
637        let (tx, mut rx) = mpsc::unbounded_channel();
638        let provider = Arc::new(Counter {
639            count: 950,
640            max: 1000,
641        });
642        run_inference_job(
643            counting_job(provider, true),
644            tx,
645            Arc::new(Notify::new()),
646            RetryPolicy::default(),
647            crate::cancel::CancelToken::new(),
648        )
649        .await;
650        let outcome = rx.try_recv().expect("outcome sent");
651        let err = outcome.result.expect_err("should be rejected");
652        // Assert on the Display string (branch-free) rather than `matches!`,
653        // whose non-matching arm would be an uncovered region.
654        assert_eq!(err.to_string(), "Token limit exceeded: 950 > 1000");
655    }
656
657    #[tokio::test]
658    async fn guard_allows_request_within_context_window() {
659        // count(800) + 100 = 900 ≤ 1000 ⇒ proceeds to infer.
660        let (tx, mut rx) = mpsc::unbounded_channel();
661        let provider = Arc::new(Counter {
662            count: 800,
663            max: 1000,
664        });
665        run_inference_job(
666            counting_job(provider, true),
667            tx,
668            Arc::new(Notify::new()),
669            RetryPolicy::default(),
670            crate::cancel::CancelToken::new(),
671        )
672        .await;
673        let outcome = rx.try_recv().expect("outcome sent");
674        assert_eq!(outcome.result.expect("should succeed").content, "ok");
675    }
676
677    #[tokio::test]
678    async fn counter_provider_metadata_is_exercised() {
679        // Keep the Counter mock's non-`infer` trait methods measured.
680        let p = Counter { count: 5, max: 10 };
681        assert_eq!(p.name(), "counter");
682        assert_eq!(p.max_context_tokens("m"), 10);
683        assert_eq!(p.count_tokens("t", "m").await, 5);
684        assert!(p.capabilities("m").supports_streaming);
685    }
686
687    #[tokio::test]
688    async fn guard_off_skips_the_count_and_proceeds() {
689        // Even wildly over budget, with the flag off the guard never runs.
690        let (tx, mut rx) = mpsc::unbounded_channel();
691        let provider = Arc::new(Counter {
692            count: 1_000_000,
693            max: 1000,
694        });
695        run_inference_job(
696            counting_job(provider, false),
697            tx,
698            Arc::new(Notify::new()),
699            RetryPolicy::default(),
700            crate::cancel::CancelToken::new(),
701        )
702        .await;
703        let outcome = rx.try_recv().expect("outcome sent");
704        assert_eq!(outcome.result.expect("should succeed").content, "ok");
705    }
706
707    #[tokio::test]
708    async fn fixed_provider_metadata_is_exercised() {
709        // Covers the mock's non-`infer` trait methods (the pipeline resolves
710        // these off the provider elsewhere; here we just keep them measured).
711        let p = Fixed::Ok(response("x"));
712        assert_eq!(p.name(), "fixed");
713        assert_eq!(p.count_tokens("t", "m").await, 1);
714        assert_eq!(p.max_context_tokens("m"), 100_000);
715        let _ = p.capabilities("m");
716    }
717
718    #[tokio::test]
719    async fn run_job_survives_dropped_receiver() {
720        let (tx, rx) = mpsc::unbounded_channel();
721        drop(rx); // world shutting down: nobody to receive
722        let wake = Arc::new(Notify::new());
723        // Must not panic even though the send fails.
724        run_inference_job(
725            job(Arc::new(Fixed::Ok(response("x")))),
726            tx,
727            wake,
728            RetryPolicy::default(),
729            crate::cancel::CancelToken::new(),
730        )
731        .await;
732    }
733
734    // ── retry behavior ──
735
736    enum Step {
737        Ok(String),
738        Transient,
739        /// The reported failure: a 529 the provider reports as a plain API
740        /// error, which is a capacity refusal rather than a blip (issue #417).
741        Overloaded,
742        Permanent,
743        /// Never returns - a stalled/hung call, for the job-timeout test.
744        Hang,
745    }
746
747    /// A provider that plays a scripted sequence of results and counts calls.
748    struct Scripted {
749        steps: std::sync::Mutex<std::collections::VecDeque<Step>>,
750        calls: std::sync::Mutex<u32>,
751    }
752
753    #[async_trait::async_trait]
754    impl Provider for Scripted {
755        async fn infer(
756            &self,
757            _req: &InferenceRequest,
758        ) -> leviath_providers::Result<InferenceResponse> {
759            *self.calls.lock().unwrap() += 1;
760            // Pop before matching so the mutex guard is not held across the
761            // `Hang` arm's `.await` (which would make this future non-`Send`).
762            let step = self.steps.lock().unwrap().pop_front();
763            match step {
764                Some(Step::Ok(t)) => Ok(response(&t)),
765                Some(Step::Transient) => Err(ProviderError::RateLimitExceeded {
766                    retry_after_secs: None,
767                }),
768                Some(Step::Overloaded) => {
769                    Err(ProviderError::ApiError("HTTP 529 Overloaded".to_string()))
770                }
771                Some(Step::Permanent) => Err(ProviderError::Other("permanent".to_string())),
772                Some(Step::Hang) => std::future::pending().await,
773                None => Err(ProviderError::Other("exhausted".to_string())),
774            }
775        }
776        async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
777            1
778        }
779        fn max_context_tokens(&self, _m: &str) -> usize {
780            100_000
781        }
782        fn name(&self) -> &str {
783            "scripted"
784        }
785        fn capabilities(&self, _m: &str) -> leviath_providers::ModelCapabilities {
786            leviath_providers::ModelCapabilities::default()
787        }
788    }
789
790    /// A policy whose every backoff is zero, so a job-level test drives the
791    /// retry *loop* without sleeping. The schedule those durations would have
792    /// been is asserted against `backoff_after` directly, where no sleeping is
793    /// involved at all.
794    fn instant() -> RetryPolicy {
795        RetryPolicy {
796            base_delay: Duration::ZERO,
797            capacity_base_delay: Duration::ZERO,
798            capacity_max_delay: Duration::ZERO,
799            ..RetryPolicy::default()
800        }
801    }
802
803    fn no_delay(max_attempts: u32) -> RetryPolicy {
804        RetryPolicy {
805            max_attempts,
806            job_timeout: Duration::from_secs(30),
807            ..instant()
808        }
809    }
810
811    #[tokio::test]
812    async fn run_job_retries_transient_then_succeeds() {
813        let provider = Arc::new(Scripted {
814            steps: std::sync::Mutex::new(
815                vec![
816                    Step::Transient,
817                    Step::Transient,
818                    Step::Ok("done".to_string()),
819                ]
820                .into(),
821            ),
822            calls: std::sync::Mutex::new(0),
823        });
824        let (tx, mut rx) = mpsc::unbounded_channel();
825        run_inference_job(
826            job(provider.clone()),
827            tx,
828            Arc::new(Notify::new()),
829            no_delay(4),
830            crate::cancel::CancelToken::new(),
831        )
832        .await;
833        let outcome = rx.try_recv().expect("outcome sent");
834        assert_eq!(outcome.result.unwrap().content, "done");
835        assert_eq!(*provider.calls.lock().unwrap(), 3); // two retries then success
836    }
837
838    #[tokio::test]
839    async fn run_job_gives_up_after_max_attempts() {
840        let provider = Arc::new(Scripted {
841            steps: std::sync::Mutex::new(
842                vec![
843                    Step::Transient,
844                    Step::Transient,
845                    Step::Transient,
846                    Step::Transient,
847                ]
848                .into(),
849            ),
850            calls: std::sync::Mutex::new(0),
851        });
852        let (tx, mut rx) = mpsc::unbounded_channel();
853        run_inference_job(
854            job(provider.clone()),
855            tx,
856            Arc::new(Notify::new()),
857            no_delay(3),
858            crate::cancel::CancelToken::new(),
859        )
860        .await;
861        let outcome = rx.try_recv().expect("outcome sent");
862        assert!(outcome.result.is_err());
863        assert_eq!(*provider.calls.lock().unwrap(), 3); // exhausted the 3 attempts
864    }
865
866    #[tokio::test]
867    async fn run_job_does_not_retry_a_permanent_error() {
868        let provider = Arc::new(Scripted {
869            steps: std::sync::Mutex::new(vec![Step::Permanent, Step::Ok("x".to_string())].into()),
870            calls: std::sync::Mutex::new(0),
871        });
872        let (tx, mut rx) = mpsc::unbounded_channel();
873        run_inference_job(
874            job(provider.clone()),
875            tx,
876            Arc::new(Notify::new()),
877            no_delay(4),
878            crate::cancel::CancelToken::new(),
879        )
880        .await;
881        let outcome = rx.try_recv().expect("outcome sent");
882        assert!(outcome.result.is_err());
883        assert_eq!(*provider.calls.lock().unwrap(), 1); // no retry on a permanent error
884    }
885
886    // ── the retry schedule (issue #417) ──
887    //
888    // Asserted against `backoff_after` rather than by running a job and timing
889    // it: the schedule is minutes long, and a test that slept it would be a test
890    // nobody runs. The job-level tests above drive the same loop with every
891    // delay set to zero, which proves the loop and the schedule separately.
892
893    fn blip() -> ProviderError {
894        ProviderError::RequestFailed("connection reset by peer".to_string())
895    }
896
897    fn overloaded() -> ProviderError {
898        ProviderError::ApiError("HTTP 529 Overloaded".to_string())
899    }
900
901    #[test]
902    fn an_ordinary_blip_keeps_the_fast_schedule() {
903        // The common case must not get slower: 1s, 2s, 4s, then give up on the
904        // fourth attempt.
905        let policy = RetryPolicy::default();
906        let spent = Duration::ZERO;
907        assert_eq!(
908            backoff_after(&policy, &blip(), 1, spent),
909            Some(Duration::from_secs(1))
910        );
911        assert_eq!(
912            backoff_after(&policy, &blip(), 2, spent),
913            Some(Duration::from_secs(2))
914        );
915        assert_eq!(
916            backoff_after(&policy, &blip(), 3, spent),
917            Some(Duration::from_secs(4))
918        );
919        assert_eq!(backoff_after(&policy, &blip(), 4, spent), None);
920    }
921
922    #[test]
923    fn an_overload_waits_long_enough_to_leave_the_window() {
924        // The reported bug: three retries of 1s, 2s and 4s all landed inside the
925        // same 529 window, so a run with 44 iterations of finished work was
926        // failed after seven seconds of trying. The capacity schedule is 15s,
927        // 30s, 60s instead - 105 seconds of waiting on the same four attempts.
928        let policy = RetryPolicy::default();
929        let spent = Duration::ZERO;
930        assert_eq!(
931            backoff_after(&policy, &overloaded(), 1, spent),
932            Some(Duration::from_secs(15))
933        );
934        assert_eq!(
935            backoff_after(&policy, &overloaded(), 2, spent),
936            Some(Duration::from_secs(30))
937        );
938        // Capped, rather than the 60s the doubling would reach on its own; the
939        // next attempt would ask for 120s and gets the same minute.
940        assert_eq!(
941            backoff_after(&policy, &overloaded(), 3, spent),
942            Some(Duration::from_secs(60))
943        );
944        // A rate limit with no hint is the same kind of failure and waits the
945        // same way.
946        assert_eq!(
947            backoff_after(
948                &policy,
949                &ProviderError::RateLimitExceeded {
950                    retry_after_secs: None
951                },
952                1,
953                spent
954            ),
955            Some(Duration::from_secs(15))
956        );
957    }
958
959    #[test]
960    fn the_servers_own_answer_wins_and_is_capped() {
961        let policy = RetryPolicy::default();
962        let hint = |secs| ProviderError::RateLimitExceeded {
963            retry_after_secs: Some(secs),
964        };
965        // Told to come back in three seconds, come back in three - not the 15
966        // the schedule would have picked. This is what keeps a provider that
967        // answers precisely fast.
968        assert_eq!(
969            backoff_after(&policy, &hint(3), 1, Duration::ZERO),
970            Some(Duration::from_secs(3))
971        );
972        // An hour is not honored: one header may not park a run indefinitely.
973        assert_eq!(
974            backoff_after(&policy, &hint(3600), 1, Duration::ZERO),
975            Some(Duration::from_secs(60))
976        );
977    }
978
979    #[test]
980    fn a_permanent_error_is_never_retried() {
981        assert_eq!(
982            backoff_after(
983                &RetryPolicy::default(),
984                &ProviderError::TokenLimitExceeded { used: 9, max: 8 },
985                1,
986                Duration::ZERO
987            ),
988            None
989        );
990    }
991
992    #[test]
993    fn the_total_backoff_ceiling_bounds_however_long_a_provider_asks_for() {
994        // The promise the config docs make: whatever the attempts, the schedule
995        // and the provider's hints add up to, one request's retries sleep at
996        // most `max_total_backoff`.
997        let policy = RetryPolicy {
998            max_attempts: 100,
999            ..RetryPolicy::default()
1000        };
1001        // Most of the budget already slept: the next wait is trimmed to what is
1002        // left rather than the full minute it would have been.
1003        assert_eq!(
1004            backoff_after(
1005                &policy,
1006                &overloaded(),
1007                5,
1008                policy.max_total_backoff - Duration::from_secs(2)
1009            ),
1010            Some(Duration::from_secs(2))
1011        );
1012        // Budget spent: stop, with attempts still on the clock.
1013        assert_eq!(
1014            backoff_after(&policy, &overloaded(), 5, policy.max_total_backoff),
1015            None
1016        );
1017        assert_eq!(
1018            backoff_after(
1019                &policy,
1020                &overloaded(),
1021                5,
1022                policy.max_total_backoff + Duration::from_secs(1)
1023            ),
1024            None
1025        );
1026    }
1027
1028    #[test]
1029    fn a_long_schedule_saturates_rather_than_overflowing() {
1030        // A large base and a high attempt count must not panic in a release
1031        // build's arithmetic or wrap in a debug one; the ceiling catches the
1032        // result either way.
1033        let policy = RetryPolicy {
1034            max_attempts: u32::MAX,
1035            base_delay: Duration::from_secs(u64::MAX / 2),
1036            ..RetryPolicy::default()
1037        };
1038        assert_eq!(
1039            backoff_after(&policy, &blip(), u32::MAX - 1, Duration::ZERO),
1040            Some(policy.max_total_backoff)
1041        );
1042    }
1043
1044    #[tokio::test]
1045    async fn run_job_retries_an_overloaded_provider() {
1046        // End to end through the loop: a 529 is retried, not reported. The
1047        // policy's waits are zero here so the test costs nothing; how long they
1048        // would have been is asserted above.
1049        let provider = Arc::new(Scripted {
1050            steps: std::sync::Mutex::new(
1051                vec![
1052                    Step::Overloaded,
1053                    Step::Overloaded,
1054                    Step::Ok("survived the overload".to_string()),
1055                ]
1056                .into(),
1057            ),
1058            calls: std::sync::Mutex::new(0),
1059        });
1060        let (tx, mut rx) = mpsc::unbounded_channel();
1061        run_inference_job(
1062            job(provider.clone()),
1063            tx,
1064            Arc::new(Notify::new()),
1065            no_delay(4),
1066            crate::cancel::CancelToken::new(),
1067        )
1068        .await;
1069        let outcome = rx.try_recv().expect("outcome sent");
1070        assert_eq!(outcome.result.unwrap().content, "survived the overload");
1071        assert_eq!(*provider.calls.lock().unwrap(), 3);
1072    }
1073
1074    #[tokio::test]
1075    async fn scripted_provider_metadata_is_exercised() {
1076        let p = Scripted {
1077            steps: std::sync::Mutex::new(std::collections::VecDeque::new()),
1078            calls: std::sync::Mutex::new(0),
1079        };
1080        assert_eq!(p.name(), "scripted");
1081        assert_eq!(p.count_tokens("t", "m").await, 1);
1082        assert_eq!(p.max_context_tokens("m"), 100_000);
1083        let _ = p.capabilities("m");
1084    }
1085}