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