Skip to main content

drep/llm/
chain.rs

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