Skip to main content

drep/llm/
chain.rs

1//! The provider chain: an ordered list of LLM providers, tried in turn.
2//!
3//! drep 2.0 dropped the circuit breaker and the rate limiter as server-shaped
4//! complexity. Failover is the same category of machinery and is taken anyway,
5//! for one specific failure that happens in practice: a local endpoint that is
6//! off blocks **every** commit, because "could not analyze" is a hard stop by
7//! design. The bar it had to clear is recorded in `docs/rust-migration.md`
8//! rather than left implicit here.
9//!
10//! ## Two independent questions about a failure
11//!
12//! - [`should_failover`] — does the next provider get asked? HTTP transport
13//!   failures advance the chain for 408, 429, 5xx and status-less failures
14//!   (timeout, connection refused, an empty body). A backend may also report a
15//!   typed usage-limit failure. A 401/403 or backend authentication failure
16//!   must **not** advance: that is misconfiguration, and quietly asking a
17//!   second provider masks it. An unparseable non-empty response does not
18//!   advance either.
19//! - [`is_sticky`] — is the failure remembered for the rest of the run? Every
20//!   failure that advances the chain, plus the two that are a property of the
21//!   connection rather than the request: a stale API key returns 401 for every
22//!   file, and re-handshaking forty-nine times to be told so again is pure
23//!   wall-clock on a commit gate that is going to exit 2 anyway. A *request*
24//!   -level 4xx is deliberately excluded - remembering one would let a single
25//!   oversized payload stop the chain for every later file.
26//!
27//! Conflating the two is what makes a chain either mask a bad key or re-ask a
28//! dead endpoint once per file. A remembered failure is then replayed through
29//! `should_failover` exactly as a live one would be, so a demoted 401 still
30//! stops the chain and a demoted 500 still advances it.
31//!
32//! An **empty** response does fail over. It reaches here as
33//! `Transport { status: None }` only after the SDK has already retried it, and
34//! a provider that keeps answering with nothing is as unusable as one that is
35//! down - which is exactly the flakiness that failed 7 of 49 files on drep's
36//! own first gated push. It also produced zero output tokens, so the attempts
37//! it burned cost almost nothing.
38//!
39//! ## The cache key moves with the provider
40//!
41//! The key is computed from the provider's *backend identity and* model, so it is
42//! computed **inside** the loop, once per provider tried. Keying provider 1 and
43//! then letting provider 2 serve the answer would file that answer under a key
44//! it did not come from, and a later run with provider 1 healthy would get a
45//! hit that never came from provider 1. [`Served::key`] is the key of the
46//! provider that actually answered; the caller stores under that key or not at
47//! all.
48//!
49//! The backend is in the key because a model name is not an identity: one model
50//! served locally, through an API and through a ChatGPT subscription can have
51//! the same name while producing three distinct request paths.
52
53use std::sync::OnceLock;
54use std::sync::atomic::{AtomicUsize, Ordering};
55
56use crate::config::LlmConfig;
57use crate::llm::backend::{BackendFactory, ProviderBackend};
58use crate::llm::cache::{Cache, CacheKey};
59use crate::llm::concurrency::Limiter;
60use crate::llm::error::{BackendErrorKind, LlmError};
61use crate::llm::json_parsing::Extracted;
62
63/// One provider: a backend, its concurrency budget, and the
64/// two pieces of run-scoped state that belong to it.
65///
66/// The limiter is per provider rather than per process because the slot
67/// represents in-flight backend work, and `max_concurrent`
68/// is configured per `[[llm]]` entry. A single shared limiter would apply the
69/// local model's generous budget to a rate-limited cloud endpoint.
70///
71/// `down` and `served` live here rather than in vectors parallel to the
72/// provider list. Parallel vectors force a length invariant the type system
73/// cannot check, and make every reader defensive about an index that is
74/// structurally always valid — a `mark_down` that silently did nothing on an
75/// out-of-range index would be invisible.
76///
77/// The cache identity is computed once because it includes stable backend
78/// metadata such as the Codex CLI version. The model and display location stay
79/// on the backend so reporting cannot drift from the client that is used.
80#[derive(Debug)]
81pub struct Provider {
82    /// `pub(crate)` for the same reason `LlmClient`'s fields are: the test
83    /// fixtures build a chain through the production `ProviderChain::new` and
84    /// then shrink only the backoff delays, so a retry test does not spend
85    /// seconds asleep. Not part of the public API.
86    pub(crate) backend: ProviderBackend,
87    identity: String,
88    limiter: Limiter,
89    /// Why this provider was demoted, set once. `OnceLock` rather than a
90    /// `Mutex<Option<_>>` because the value is write-once and read-often, and
91    /// first-writer-wins is the semantics we want anyway — concurrent files
92    /// hitting the same dead endpoint should record the first reason, not race
93    /// to overwrite it.
94    down: OnceLock<LlmError>,
95    /// How many files this provider answered, cache hits included.
96    served: AtomicUsize,
97}
98
99impl Provider {
100    /// The model this provider asks for.
101    pub fn model(&self) -> &str {
102        self.backend.model()
103    }
104
105    /// The backend-neutral location this provider talks to.
106    pub fn location(&self) -> &str {
107        self.backend.location()
108    }
109
110    /// The concurrency budget for this provider's backend.
111    ///
112    /// Exposed because it is the only thing that can demonstrate the limiter
113    /// works: wiremock never overlaps requests, so neither an in-flight
114    /// counter nor wall-clock can tell a working limiter from a deleted one.
115    /// Observing `available()` while the analysis runs can.
116    pub fn limiter(&self) -> &Limiter {
117        &self.limiter
118    }
119
120    /// How many files this provider answered.
121    ///
122    /// Counted here rather than carried out through the analysis result and
123    /// rejoined against the chain later: the chain already knows who answered,
124    /// it is already shared for the whole process, and it already holds
125    /// per-provider interior-mutable state. A cache hit counts — the answer
126    /// still originated with that provider, and the user's code was still
127    /// reviewed by that model.
128    pub fn served(&self) -> usize {
129        self.served.load(Ordering::Relaxed)
130    }
131
132    /// Whether this provider has been demoted for the rest of the run.
133    pub fn is_down(&self) -> bool {
134        self.down.get().is_some()
135    }
136
137    /// Why it was demoted, if it was.
138    fn down_reason(&self) -> Option<&LlmError> {
139        self.down.get()
140    }
141
142    /// Demote for the rest of the run, recording why. First writer wins; a
143    /// concurrent second failure is dropped rather than overwriting the reason
144    /// a user is about to read.
145    fn mark_down(&self, err: LlmError) {
146        let _ = self.down.set(err);
147    }
148
149    fn record_served(&self) {
150        self.served.fetch_add(1, Ordering::Relaxed);
151    }
152
153    /// This provider's cache key for one prompt.
154    ///
155    /// The single definition of "which key belongs to which provider". It lives
156    /// here rather than at the call site so a test cannot compute the key a
157    /// different way than production does - which is exactly how the endpoint
158    /// went missing from it: the tests spelled out `model` and `temperature` by
159    /// hand and agreed with the bug.
160    pub fn cache_key(&self, cache: &Cache, system_prompt: &str, user_content: &str) -> CacheKey {
161        cache.key(
162            system_prompt,
163            user_content,
164            &self.identity,
165            self.model(),
166            self.backend.request_identity(),
167            self.backend.temperature(),
168        )
169    }
170
171    #[cfg(test)]
172    pub(crate) fn for_test(backend: ProviderBackend, max_concurrent: usize) -> Self {
173        let identity = backend.identity();
174        Self {
175            backend,
176            identity,
177            limiter: Limiter::new(max_concurrent),
178            down: OnceLock::new(),
179            served: AtomicUsize::new(0),
180        }
181    }
182}
183
184/// One provider's contribution to a failed file.
185///
186/// `skipped` distinguishes "tried now and failed" from "already down from
187/// earlier in this run". Both are worth reporting: a user reading why a file
188/// went unanalyzed needs to know the local endpoint has been dead since file
189/// three, not just that the cloud fallback then returned a 401.
190#[derive(Debug)]
191pub struct Attempt {
192    /// Zero-based position in the chain. Rendered one-based.
193    pub provider: usize,
194    /// The model that provider asks for, for a message the user can act on.
195    pub model: String,
196    /// Why it failed.
197    pub error: LlmError,
198    /// True when this provider was already marked down and was not contacted
199    /// for this file.
200    pub skipped: bool,
201}
202
203impl Attempt {
204    fn new(index: usize, provider: &Provider, error: LlmError, skipped: bool) -> Self {
205        Self {
206            provider: index,
207            model: provider.model().to_owned(),
208            error,
209            skipped,
210        }
211    }
212}
213
214/// No provider produced an answer, with what each one contributed.
215///
216/// `attempts` is never empty: [`ProviderChain::new`] rejects an empty chain, so
217/// a file that could not be analyzed always names at least one provider. It can
218/// be *shorter* than `chain_len` — a 401 at the head stops the chain, and the
219/// providers below it were never consulted.
220///
221/// `chain_len` is carried so the caller can tell "the only provider failed"
222/// from "a chain stopped early", which are the same `attempts.len() == 1` but
223/// very different questions for the user.
224#[derive(Debug)]
225pub struct ChainError {
226    pub attempts: Vec<Attempt>,
227    pub chain_len: usize,
228}
229
230/// A response, and which provider produced it.
231///
232/// `key` is that provider's cache key - the caller stores under it, so the
233/// entry is filed against the model that actually answered.
234#[derive(Debug)]
235pub struct Served {
236    /// Zero-based position in the chain.
237    pub provider: usize,
238    /// The cache key of the provider that answered.
239    pub key: CacheKey,
240    /// What came back.
241    pub extracted: Extracted,
242    /// True when the cache answered and no request was made.
243    pub from_cache: bool,
244}
245
246/// An ordered chain of providers with sticky demotion.
247///
248/// Built once per process from `Config::providers()` and shared across the
249/// analyzer, so the demotion one file discovers is visible to every other.
250#[derive(Debug)]
251pub struct ProviderChain {
252    /// `pub(crate)` so the test fixtures can shrink the per-provider backoff.
253    /// See [`Provider`].
254    pub(crate) providers: Vec<Provider>,
255}
256
257impl ProviderChain {
258    #[cfg(test)]
259    pub(crate) fn for_test(providers: impl IntoIterator<Item = (ProviderBackend, usize)>) -> Self {
260        Self {
261            providers: providers
262                .into_iter()
263                .map(|(backend, max_concurrent)| Provider::for_test(backend, max_concurrent))
264                .collect(),
265        }
266    }
267
268    /// Build a chain from the enabled providers, in order.
269    ///
270    /// A misconfigured entry is fatal rather than skipped. Skipping it would
271    /// be the same masking that the 401 rule forbids: an endpoint-less
272    /// `[[llm]]` block is a broken install, and a gate that quietly routes
273    /// around it is a gate reporting on a configuration the user did not
274    /// write. The index is carried into the message because with several
275    /// providers "LLM model is not set" does not say which one.
276    pub fn new(cfgs: &[&LlmConfig]) -> Result<Self, LlmError> {
277        if cfgs.is_empty() {
278            return Err(LlmError::NotConfigured(
279                "no enabled `[[llm]]` provider".to_string(),
280            ));
281        }
282        let mut providers = Vec::with_capacity(cfgs.len());
283        let mut factory = BackendFactory::new();
284        for (index, cfg) in cfgs.iter().enumerate() {
285            let backend = factory
286                .build(cfg)
287                .map_err(|err| LlmError::NotConfigured(format!("[[llm]] #{}: {err}", index + 1)))?;
288            let identity = backend.identity();
289            providers.push(Provider {
290                backend,
291                identity,
292                limiter: Limiter::new(cfg.max_concurrent),
293                down: OnceLock::new(),
294                served: AtomicUsize::new(0),
295            });
296        }
297        Ok(Self { providers })
298    }
299
300    /// The providers, in preference order.
301    pub fn providers(&self) -> &[Provider] {
302        &self.providers
303    }
304
305    /// Send one prompt down the chain, consulting the cache per provider.
306    ///
307    /// Returns the first answer anyone gives, or every provider's reason for
308    /// not giving one.
309    pub async fn complete_json(
310        &self,
311        system_prompt: &str,
312        user_content: &str,
313        cache: &Cache,
314    ) -> Result<Served, ChainError> {
315        let mut attempts: Vec<Attempt> = Vec::new();
316
317        for (index, provider) in self.providers.iter().enumerate() {
318            match try_provider(index, provider, system_prompt, user_content, cache).await {
319                ProviderOutcome::Served(served) => return Ok(served),
320                // One place decides what a failure means for the loop, so the
321                // three ways a provider can fail cannot disagree about it.
322                ProviderOutcome::Failed { attempt, advance } => {
323                    attempts.push(attempt);
324                    if !advance {
325                        return Err(self.error(attempts));
326                    }
327                }
328            }
329        }
330
331        Err(self.error(attempts))
332    }
333
334    fn error(&self, attempts: Vec<Attempt>) -> ChainError {
335        ChainError {
336            attempts,
337            chain_len: self.providers.len(),
338        }
339    }
340}
341
342/// What one provider did with the request.
343enum ProviderOutcome {
344    Served(Served),
345    Failed { attempt: Attempt, advance: bool },
346}
347
348impl ProviderOutcome {
349    /// A provider skipped because it was already demoted.
350    ///
351    /// The recorded reason is replayed through [`should_failover`] exactly as a
352    /// live failure would be, so a remembered 401 stops the chain here and a
353    /// remembered 500 advances it. Deciding otherwise would let a bad key be
354    /// routed around on every file after the first.
355    fn demoted(index: usize, provider: &Provider, err: &LlmError) -> Self {
356        ProviderOutcome::Failed {
357            advance: should_failover(err),
358            attempt: Attempt::new(index, provider, err.clone(), true),
359        }
360    }
361}
362
363/// Try one provider: demotion check, cache, limiter, request.
364///
365/// A free function rather than a method because it reads no chain state — all
366/// of it now lives on the `Provider` it is handed.
367async fn try_provider(
368    index: usize,
369    provider: &Provider,
370    system_prompt: &str,
371    user_content: &str,
372    cache: &Cache,
373) -> ProviderOutcome {
374    // Already down: report what it said the first time rather than paying the
375    // SDK's backoff schedule to be told again.
376    if let Some(err) = provider.down_reason() {
377        return ProviderOutcome::demoted(index, provider, err);
378    }
379
380    // The key is computed here, from *this* provider's model, and the `Served`
381    // carries it back so the caller cannot file the answer under a different
382    // provider's key.
383    let key = provider.cache_key(cache, system_prompt, user_content);
384    if let Some(value) = cache.get(&key) {
385        provider.record_served();
386        return ProviderOutcome::Served(Served {
387            provider: index,
388            key,
389            extracted: Extracted::Complete(value),
390            from_cache: true,
391        });
392    }
393
394    // The slot is held for the request and released on every exit path,
395    // failover included. The cache hit above never takes one: the slot
396    // represents in-flight backend work, and a cache read is not in flight.
397    let guard = provider.limiter.acquire().await;
398
399    // Re-check after waiting. Files are analyzed concurrently, so the check
400    // above can only stop files that had not yet started; everything already
401    // queued on this provider's limiter passed it before the first failure
402    // landed. Without this second look, forty-nine files queued against a dead
403    // endpoint each pay the SDK's full retry schedule anyway, and sticky
404    // demotion saves nothing in the one case it exists for. With it, the waste
405    // is bounded by `max_concurrent` rather than by the number of files.
406    if let Some(err) = provider.down_reason() {
407        drop(guard);
408        return ProviderOutcome::demoted(index, provider, err);
409    }
410
411    let outcome = provider
412        .backend
413        .complete_json(system_prompt, user_content)
414        .await;
415    drop(guard);
416
417    match outcome {
418        Ok(extracted) => {
419            provider.record_served();
420            ProviderOutcome::Served(Served {
421                provider: index,
422                key,
423                extracted,
424                from_cache: false,
425            })
426        }
427        Err(err) => {
428            if is_sticky(&err) {
429                provider.mark_down(err.clone());
430            }
431            ProviderOutcome::Failed {
432                advance: should_failover(&err),
433                attempt: Attempt::new(index, provider, err, false),
434            }
435        }
436    }
437}
438
439/// Whether this failure should be handed to the next provider.
440///
441/// The whole failover policy, in one place, so the rule cannot be restated
442/// differently at a second site.
443fn should_failover(err: &LlmError) -> bool {
444    match err {
445        // No status: a timeout, a refused connection, or an empty body. All
446        // provider-level, all worth asking someone else.
447        LlmError::Transport { status: None, .. } => true,
448        LlmError::Transport {
449            status: Some(code), ..
450        } => is_retryable_status(*code),
451        // A non-empty body we could not parse is deterministic - a second
452        // provider is a second full-price call for the same outcome.
453        LlmError::Unparseable(_) => false,
454        // A token cap or a content filter is a property of the request. A
455        // second provider cannot make the file smaller, and asking one to is
456        // the same category error as failing over on a 400. `is_sticky` is
457        // defined in terms of this, so it is not remembered either - which
458        // matters, because remembering a non-failover failure is what let one
459        // bad file stop the chain for every later one.
460        LlmError::ModelStopped { .. } => false,
461        // Misconfiguration. Routing around it is what hides it.
462        LlmError::NotConfigured(_) => false,
463        LlmError::Backend { kind, .. } => matches!(kind, BackendErrorKind::UsageLimit),
464    }
465}
466
467/// Whether this failure is remembered for the rest of the run.
468///
469/// Deliberately a wider set than [`should_failover`]. A 401 does not advance
470/// the chain, but it is still a property of the endpoint rather than of this
471/// file: every file in the run will get the same answer, so ask once.
472fn is_sticky(err: &LlmError) -> bool {
473    // Remember a failure only when remembering it cannot change a later file's
474    // outcome. Two ways that holds:
475    //
476    // - The chain advances past this provider anyway, so skipping it costs the
477    //   later file nothing it was not already going to pay.
478    // - It is a credential the endpoint rejects, which it will reject for every
479    //   request regardless of payload.
480    //
481    // The combination to avoid is "remembered" *and* "does not fail over": a
482    // later file skips the provider, replays the stored reason, and stops the
483    // chain without contacting anyone. `Transport { .. } => true` had exactly
484    // that shape for a request-level 4xx - one oversized payload drawing a 400
485    // demoted the provider, and every file after it stopped there without ever
486    // reaching the configured fallback. One bad file poisoned the run.
487    should_failover(err) || is_auth_failure(err) || is_sticky_backend_failure(err)
488}
489
490fn is_sticky_backend_failure(err: &LlmError) -> bool {
491    matches!(
492        err,
493        LlmError::Backend {
494            kind: BackendErrorKind::Contract | BackendErrorKind::Authentication,
495            ..
496        }
497    )
498}
499
500/// Whether the endpoint rejected the credential rather than the request.
501///
502/// 401 and 403 are the two statuses that are a property of the *connection* and
503/// not of what was sent, so they are the only non-failover failures worth
504/// remembering: a stale key answers the same way for every file, and
505/// re-handshaking once per file is pure wall-clock on a gate that will exit 2
506/// regardless.
507fn is_auth_failure(err: &LlmError) -> bool {
508    matches!(
509        err,
510        LlmError::Transport {
511            status: Some(401 | 403),
512            ..
513        }
514    )
515}
516
517/// The retryable HTTP statuses.
518///
519/// 408 and 429 are the two 4xx codes that mean "ask again"; everything else in
520/// the 4xx range is the client's fault and a second provider cannot fix it.
521/// 5xx is the server's fault and another server might not have it.
522///
523/// Deliberately drep's own list rather than a claim to mirror the SDK's:
524/// open-agent-sdk's retryable set is private, and it excludes some 5xx codes
525/// (501, 505) that a *different provider* may well not return at all. The two
526/// answer different questions - "retry this endpoint" and "try another one".
527fn is_retryable_status(code: u16) -> bool {
528    matches!(code, 408 | 429) || (500..=599).contains(&code)
529}
530
531#[cfg(test)]
532mod tests;