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::LlmError;
60use crate::llm::json_parsing::Extracted;
61
62mod policy;
63use policy::{is_sticky, should_failover};
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 self.complete_json_in_mode(system_prompt, user_content, cache, true)
318 .await
319 }
320
321 /// Send one prompt down the chain without accepting a cached answer.
322 ///
323 /// Used only after a cache-only preflight has identified an exact miss and
324 /// the caller has reserved authority for a fresh provider pass.
325 pub async fn complete_json_fresh(
326 &self,
327 system_prompt: &str,
328 user_content: &str,
329 cache: &Cache,
330 ) -> Result<Served, ChainError> {
331 self.complete_json_in_mode(system_prompt, user_content, cache, false)
332 .await
333 }
334
335 async fn complete_json_in_mode(
336 &self,
337 system_prompt: &str,
338 user_content: &str,
339 cache: &Cache,
340 consult_cache: bool,
341 ) -> Result<Served, ChainError> {
342 let mut attempts: Vec<Attempt> = Vec::new();
343
344 for (index, provider) in self.providers.iter().enumerate() {
345 match try_provider(
346 index,
347 provider,
348 system_prompt,
349 user_content,
350 cache,
351 consult_cache,
352 )
353 .await
354 {
355 ProviderOutcome::Served(served) => return Ok(served),
356 // One place decides what a failure means for the loop, so the
357 // three ways a provider can fail cannot disagree about it.
358 ProviderOutcome::Failed { attempt, advance } => {
359 attempts.push(attempt);
360 if !advance {
361 return Err(self.error(attempts));
362 }
363 }
364 }
365 }
366
367 Err(self.error(attempts))
368 }
369
370 /// Return the first cached answer in provider order without contacting any
371 /// backend. A miss is not a provider failure: nothing was attempted and no
372 /// provider should be demoted merely because this machine has not reviewed
373 /// the payload yet.
374 pub fn cached_json(
375 &self,
376 system_prompt: &str,
377 user_content: &str,
378 cache: &Cache,
379 ) -> Option<Served> {
380 for (index, provider) in self.providers.iter().enumerate() {
381 let key = provider.cache_key(cache, system_prompt, user_content);
382 if let Some(served) = cached_for(index, provider, &key, cache) {
383 return Some(served);
384 }
385 }
386 None
387 }
388
389 fn error(&self, attempts: Vec<Attempt>) -> ChainError {
390 ChainError {
391 attempts,
392 chain_len: self.providers.len(),
393 }
394 }
395}
396
397/// What one provider did with the request.
398enum ProviderOutcome {
399 Served(Served),
400 Failed { attempt: Attempt, advance: bool },
401}
402
403impl ProviderOutcome {
404 /// A provider skipped because it was already demoted.
405 ///
406 /// The recorded reason is replayed through [`should_failover`] exactly as a
407 /// live failure would be, so a remembered 401 stops the chain here and a
408 /// remembered 500 advances it. Deciding otherwise would let a bad key be
409 /// routed around on every file after the first.
410 fn demoted(index: usize, provider: &Provider, err: &LlmError) -> Self {
411 ProviderOutcome::Failed {
412 advance: should_failover(err),
413 attempt: Attempt::new(index, provider, err.clone(), true),
414 }
415 }
416}
417
418/// Try one provider: cache, demotion check, limiter, request.
419///
420/// A free function rather than a method because it reads no chain state — all
421/// of it now lives on the `Provider` it is handed.
422async fn try_provider(
423 index: usize,
424 provider: &Provider,
425 system_prompt: &str,
426 user_content: &str,
427 cache: &Cache,
428 consult_cache: bool,
429) -> ProviderOutcome {
430 // The key is computed here, from *this* provider's model, and the `Served`
431 // carries it back so the caller cannot file the answer under a different
432 // provider's key. A cache hit precedes demotion because it spends no
433 // backend request and remains the preferred provider's own verdict.
434 let key = provider.cache_key(cache, system_prompt, user_content);
435 if consult_cache && let Some(served) = cached_for(index, provider, &key, cache) {
436 return ProviderOutcome::Served(served);
437 }
438
439 // Already down: report what it said the first time rather than paying the
440 // SDK's backoff schedule to be told again.
441 if let Some(err) = provider.down_reason() {
442 return ProviderOutcome::demoted(index, provider, err);
443 }
444
445 // The slot is held for the request and released on every exit path,
446 // failover included. The cache hit above never takes one: the slot
447 // represents in-flight backend work, and a cache read is not in flight.
448 let guard = provider.limiter.acquire().await;
449
450 // Re-check after waiting. Files are analyzed concurrently, so the check
451 // above can only stop files that had not yet started; everything already
452 // queued on this provider's limiter passed it before the first failure
453 // landed. Without this second look, forty-nine files queued against a dead
454 // endpoint each pay the SDK's full retry schedule anyway, and sticky
455 // demotion saves nothing in the one case it exists for. With it, the waste
456 // is bounded by `max_concurrent` rather than by the number of files.
457 if let Some(err) = provider.down_reason() {
458 drop(guard);
459 return ProviderOutcome::demoted(index, provider, err);
460 }
461
462 let outcome = provider
463 .backend
464 .complete_json(system_prompt, user_content)
465 .await;
466 drop(guard);
467
468 match outcome {
469 Ok(extracted) => {
470 provider.record_served();
471 ProviderOutcome::Served(Served {
472 provider: index,
473 key,
474 extracted,
475 from_cache: false,
476 })
477 }
478 Err(err) => {
479 if is_sticky(&err) {
480 provider.mark_down(err.clone());
481 }
482 ProviderOutcome::Failed {
483 advance: should_failover(&err),
484 attempt: Attempt::new(index, provider, err, false),
485 }
486 }
487 }
488}
489
490/// Build the one canonical cache-hit result, including served accounting.
491fn cached_for(index: usize, provider: &Provider, key: &CacheKey, cache: &Cache) -> Option<Served> {
492 let value = cache.get(key)?;
493 provider.record_served();
494 Some(Served {
495 provider: index,
496 key: key.clone(),
497 extracted: Extracted::Complete(value),
498 from_cache: true,
499 })
500}
501
502#[cfg(test)]
503mod tests;