Skip to main content

agent_sdk_providers/
fallback.rs

1//! Provider failover: try an ordered list of providers until one succeeds.
2//!
3//! [`FallbackProvider`] wraps a primary [`LlmProvider`] plus an ordered list of
4//! secondaries. On a *retryable* failure — a [`ChatOutcome::RateLimited`] /
5//! [`ChatOutcome::ServerError`], or a transport-level error (timeout, dropped
6//! connection) — it advances to the next provider. Non-retryable outcomes
7//! ([`ChatOutcome::InvalidRequest`] and a successful response) short-circuit and
8//! are returned as-is, since retrying them on a different backend would not
9//! help.
10//!
11//! `FallbackProvider` itself implements [`LlmProvider`], so it composes with the
12//! rest of the stack (`run_structured`, `RefreshingProvider`, `ModelRouter`,
13//! the agent-sdk facade) anywhere a `&dyn LlmProvider` is expected.
14
15use std::sync::Arc;
16
17use agent_sdk_foundation::llm::{ChatOutcome, ChatRequest};
18use anyhow::Result;
19use async_trait::async_trait;
20use futures::StreamExt;
21
22use crate::provider::LlmProvider;
23use crate::streaming::{StreamBox, StreamDelta, UsageCarry};
24
25/// Whether a delta commits the stream to the provider that produced it.
26///
27/// Failing over after a provider has emitted output would make the secondary
28/// re-emit it, so the classification is per-variant:
29///
30/// * **Commits** — anything the consumer can already see or will replay:
31///   `TextDelta`, `ThinkingDelta`, `SignatureDelta`, `RedactedThinking`,
32///   `OpaqueReasoning`, `ToolUseStart`, `ToolInputDelta`, and `Done` (a response
33///   the caller has, by definition, already received in full). Unknown
34///   (`#[non_exhaustive]`) variants commit with them: a delta this SDK version
35///   cannot classify might carry content, and duplicating output is a worse
36///   failure than missing a failover.
37/// * **Does not commit** — `Usage`, which is pure metadata: it never reaches the
38///   user, it is folded into token counters, and a provider that reports usage
39///   and *then* fails is exactly the case failover exists for. A provider that
40///   reports usage before any content and then errors is therefore failed over
41///   now where it previously surfaced the error — deliberate: nothing visible is
42///   duplicated, because usage is invisible.
43/// * **Not classified here** — `Error`, which the caller's own arm handles.
44///
45/// The per-variant behaviour is pinned by
46/// `only_metadata_deltas_leave_the_chain_uncommitted`.
47const fn commits_stream(delta: &StreamDelta) -> bool {
48    !matches!(delta, StreamDelta::Usage(_))
49}
50
51/// An [`LlmProvider`] that fails over across an ordered list of backends.
52///
53/// Construct with a primary, then layer secondaries with
54/// [`with_fallback`](Self::with_fallback) (or build the whole chain at once with
55/// [`from_providers`](Self::from_providers)).
56pub struct FallbackProvider {
57    primary: Arc<dyn LlmProvider>,
58    fallbacks: Vec<Arc<dyn LlmProvider>>,
59}
60
61impl FallbackProvider {
62    /// Create a fallback chain with a single primary provider (no secondaries
63    /// yet).
64    #[must_use]
65    pub fn new(primary: Arc<dyn LlmProvider>) -> Self {
66        Self {
67            primary,
68            fallbacks: Vec::new(),
69        }
70    }
71
72    /// Append a secondary provider to the end of the failover order.
73    #[must_use]
74    pub fn with_fallback(mut self, provider: Arc<dyn LlmProvider>) -> Self {
75        self.fallbacks.push(provider);
76        self
77    }
78
79    /// Build a chain from a primary and an ordered iterator of secondaries.
80    #[must_use]
81    pub fn from_providers(
82        primary: Arc<dyn LlmProvider>,
83        fallbacks: impl IntoIterator<Item = Arc<dyn LlmProvider>>,
84    ) -> Self {
85        Self {
86            primary,
87            fallbacks: fallbacks.into_iter().collect(),
88        }
89    }
90
91    /// Total number of providers in the chain (primary + secondaries).
92    #[must_use]
93    pub fn len(&self) -> usize {
94        1 + self.fallbacks.len()
95    }
96
97    /// Always `false` — a chain always has at least the primary.
98    #[must_use]
99    pub const fn is_empty(&self) -> bool {
100        false
101    }
102
103    /// The providers in failover order (primary first), cloned for ownership.
104    fn ordered(&self) -> Vec<Arc<dyn LlmProvider>> {
105        let mut providers = Vec::with_capacity(self.len());
106        providers.push(Arc::clone(&self.primary));
107        providers.extend(self.fallbacks.iter().map(Arc::clone));
108        providers
109    }
110}
111
112/// A `chat` result is worth retrying on the next provider when the provider was
113/// rate-limited or server-errored, or the call failed at the transport layer.
114const fn is_retryable(result: &Result<ChatOutcome>) -> bool {
115    matches!(
116        result,
117        Err(_) | Ok(ChatOutcome::RateLimited(_) | ChatOutcome::ServerError(_))
118    )
119}
120
121#[async_trait]
122impl LlmProvider for FallbackProvider {
123    async fn chat(&self, request: ChatRequest) -> Result<ChatOutcome> {
124        let providers = self.ordered();
125        let last = providers.len() - 1;
126        for (idx, provider) in providers.iter().enumerate() {
127            let result = provider.chat(request.clone()).await;
128            if idx == last || !is_retryable(&result) {
129                return result;
130            }
131            log::warn!(
132                "FallbackProvider: provider '{}' failed retryably, failing over to next",
133                provider.provider()
134            );
135        }
136        // `ordered()` is never empty (the primary is always present), so the
137        // loop above always returns. This keeps the signature total without an
138        // `unwrap`.
139        Ok(ChatOutcome::ServerError(
140            "FallbackProvider: no providers configured".to_owned(),
141        ))
142    }
143
144    fn chat_stream(&self, request: ChatRequest) -> StreamBox<'_> {
145        let providers = self.ordered();
146        Box::pin(async_stream::stream! {
147            let last = providers.len() - 1;
148            // Preserves the tokens billed by providers this chain abandoned, so
149            // the surviving provider's usage delta reports the running total
150            // rather than erasing the abandoned attempt's usage (last-wins
151            // accumulator). See `UsageCarry`.
152            let mut usage_carry = UsageCarry::new();
153
154            for (idx, provider) in providers.iter().enumerate() {
155                let is_last = idx == last;
156                let mut stream = provider.chat_stream(request.clone());
157                // Whether this provider has emitted a delta that commits the
158                // stream to it (see `commits_stream`). Once it has, failing over
159                // would double-emit output, so a later error is surfaced as-is.
160                let mut committed = false;
161                let mut failed_over = false;
162
163                while let Some(item) = stream.next().await {
164                    match item {
165                        Ok(StreamDelta::Error { message, kind }) => {
166                            if !committed && !is_last && kind.is_recoverable() {
167                                log::warn!(
168                                    "FallbackProvider: provider '{}' recoverable stream error ({kind:?}), failing over",
169                                    provider.provider()
170                                );
171                                failed_over = true;
172                                break;
173                            }
174                            yield Ok(StreamDelta::Error { message, kind });
175                        }
176                        Ok(delta) => {
177                            // `commits_stream` is the single decision point:
178                            // metadata leaves the chain free to fail over, and
179                            // usage is additionally rewritten to the running
180                            // total so the abandoned provider's tokens survive.
181                            committed = committed || commits_stream(&delta);
182                            let delta = match delta {
183                                StreamDelta::Usage(usage) => {
184                                    StreamDelta::Usage(usage_carry.running_total(usage))
185                                }
186                                other => other,
187                            };
188                            yield Ok(delta);
189                        }
190                        Err(error) => {
191                            if !committed && !is_last {
192                                log::warn!(
193                                    "FallbackProvider: provider '{}' stream transport error, failing over: {error}",
194                                    provider.provider()
195                                );
196                                failed_over = true;
197                                break;
198                            }
199                            yield Err(error);
200                        }
201                    }
202                }
203
204                if !failed_over {
205                    return;
206                }
207                usage_carry.abandon();
208            }
209        })
210    }
211
212    /// Delegate live model discovery to the primary provider so wrapping in a
213    /// fallback never silently loses `list_models`.
214    async fn list_models(&self) -> Result<Vec<crate::provider::ModelInfo>> {
215        self.primary.list_models().await
216    }
217
218    /// Reachable when any provider in the chain is reachable — the chain can
219    /// serve a request as long as one backend can be dialled.
220    async fn probe_connectivity(&self) -> bool {
221        for provider in self.ordered() {
222            if provider.probe_connectivity().await {
223                return true;
224            }
225        }
226        false
227    }
228
229    fn model(&self) -> &str {
230        self.primary.model()
231    }
232
233    fn provider(&self) -> &'static str {
234        self.primary.provider()
235    }
236
237    /// Reports the **configured primary's** route, not necessarily the one that
238    /// served the call: [`Self::chat`] walks the chain, so a request the primary
239    /// failed and a backup answered still names the primary here. Attributing
240    /// the tier that actually ran needs the serving route to travel back out of
241    /// the chain walk with the response, which this trait has no channel for.
242    fn route(&self) -> &str {
243        self.primary.route()
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    use std::sync::Mutex;
252    use std::sync::atomic::{AtomicUsize, Ordering};
253
254    use agent_sdk_foundation::llm::{ChatResponse, ContentBlock, StopReason, Usage};
255    use anyhow::{Context as _, anyhow};
256
257    use crate::streaming::StreamErrorKind;
258
259    /// A provider that replays a queue of `chat` results and counts its calls.
260    struct ScriptedProvider {
261        name: &'static str,
262        results: Mutex<std::collections::VecDeque<Result<ChatOutcome>>>,
263        stream_deltas: Mutex<Vec<Result<StreamDelta>>>,
264        calls: AtomicUsize,
265    }
266
267    impl ScriptedProvider {
268        fn chat_only(name: &'static str, results: Vec<Result<ChatOutcome>>) -> Arc<Self> {
269            Arc::new(Self {
270                name,
271                results: Mutex::new(results.into()),
272                stream_deltas: Mutex::new(Vec::new()),
273                calls: AtomicUsize::new(0),
274            })
275        }
276
277        fn streaming(name: &'static str, deltas: Vec<Result<StreamDelta>>) -> Arc<Self> {
278            Arc::new(Self {
279                name,
280                results: Mutex::new(std::collections::VecDeque::new()),
281                stream_deltas: Mutex::new(deltas),
282                calls: AtomicUsize::new(0),
283            })
284        }
285
286        fn calls(&self) -> usize {
287            self.calls.load(Ordering::SeqCst)
288        }
289    }
290
291    #[async_trait]
292    impl LlmProvider for ScriptedProvider {
293        async fn chat(&self, _request: ChatRequest) -> Result<ChatOutcome> {
294            self.calls.fetch_add(1, Ordering::SeqCst);
295            self.results
296                .lock()
297                .map_err(|_| anyhow!("results lock poisoned"))?
298                .pop_front()
299                .unwrap_or_else(|| Ok(ChatOutcome::ServerError("exhausted".to_owned())))
300        }
301
302        async fn list_models(&self) -> Result<Vec<crate::provider::ModelInfo>> {
303            Ok(vec![crate::provider::ModelInfo {
304                id: format!("{}-model", self.name),
305                display_name: None,
306                context_window: None,
307                max_output_tokens: None,
308            }])
309        }
310
311        fn chat_stream(&self, _request: ChatRequest) -> StreamBox<'_> {
312            self.calls.fetch_add(1, Ordering::SeqCst);
313            let deltas: Vec<Result<StreamDelta>> = self
314                .stream_deltas
315                .lock()
316                .map(|d| {
317                    d.iter()
318                        .map(|r| match r {
319                            Ok(delta) => Ok(delta.clone()),
320                            Err(e) => Err(anyhow!("{e}")),
321                        })
322                        .collect()
323                })
324                .unwrap_or_default();
325            Box::pin(async_stream::stream! {
326                for delta in deltas {
327                    yield delta;
328                }
329            })
330        }
331
332        fn model(&self) -> &str {
333            self.name
334        }
335
336        fn provider(&self) -> &'static str {
337            self.name
338        }
339    }
340
341    fn success(text: &str) -> ChatOutcome {
342        ChatOutcome::Success(ChatResponse {
343            id: "r".to_owned(),
344            content: vec![ContentBlock::Text {
345                text: text.to_owned(),
346            }],
347            model: "m".to_owned(),
348            stop_reason: Some(StopReason::EndTurn),
349            usage: Usage {
350                input_tokens: 1,
351                output_tokens: 1,
352                cached_input_tokens: 0,
353                cache_creation_input_tokens: 0,
354            },
355        })
356    }
357
358    fn request() -> ChatRequest {
359        ChatRequest::new("sys", vec![agent_sdk_foundation::llm::Message::user("hi")])
360    }
361
362    #[tokio::test]
363    async fn server_error_fails_over_to_secondary() -> Result<()> {
364        let primary = ScriptedProvider::chat_only(
365            "primary",
366            vec![Ok(ChatOutcome::ServerError("boom".to_owned()))],
367        );
368        let secondary = ScriptedProvider::chat_only("secondary", vec![Ok(success("ok"))]);
369        let fb = FallbackProvider::new(primary.clone()).with_fallback(secondary.clone());
370
371        let outcome = fb.chat(request()).await?;
372        assert!(matches!(outcome, ChatOutcome::Success(r) if r.first_text() == Some("ok")));
373        assert_eq!(primary.calls(), 1);
374        assert_eq!(secondary.calls(), 1);
375        Ok(())
376    }
377
378    #[tokio::test]
379    async fn rate_limit_fails_over() -> Result<()> {
380        let primary =
381            ScriptedProvider::chat_only("primary", vec![Ok(ChatOutcome::RateLimited(None))]);
382        let secondary = ScriptedProvider::chat_only("secondary", vec![Ok(success("ok"))]);
383        let fb = FallbackProvider::from_providers(primary.clone(), [secondary.clone() as Arc<_>]);
384
385        let outcome = fb.chat(request()).await?;
386        assert!(matches!(outcome, ChatOutcome::Success(_)));
387        assert_eq!(secondary.calls(), 1);
388        Ok(())
389    }
390
391    #[tokio::test]
392    async fn transport_error_fails_over() -> Result<()> {
393        let primary = ScriptedProvider::chat_only("primary", vec![Err(anyhow!("timeout"))]);
394        let secondary = ScriptedProvider::chat_only("secondary", vec![Ok(success("ok"))]);
395        let fb = FallbackProvider::new(primary.clone()).with_fallback(secondary.clone());
396
397        let outcome = fb.chat(request()).await?;
398        assert!(matches!(outcome, ChatOutcome::Success(_)));
399        Ok(())
400    }
401
402    #[tokio::test]
403    async fn invalid_request_does_not_fail_over() -> Result<()> {
404        let primary = ScriptedProvider::chat_only(
405            "primary",
406            vec![Ok(ChatOutcome::InvalidRequest("bad".to_owned()))],
407        );
408        let secondary = ScriptedProvider::chat_only("secondary", vec![Ok(success("ok"))]);
409        let fb = FallbackProvider::new(primary.clone()).with_fallback(secondary.clone());
410
411        let outcome = fb.chat(request()).await?;
412        assert!(matches!(outcome, ChatOutcome::InvalidRequest(_)));
413        // The non-retryable outcome short-circuits: the secondary is untouched.
414        assert_eq!(secondary.calls(), 0);
415        Ok(())
416    }
417
418    #[tokio::test]
419    async fn last_provider_outcome_is_returned_when_all_fail() -> Result<()> {
420        let primary = ScriptedProvider::chat_only(
421            "primary",
422            vec![Ok(ChatOutcome::ServerError("a".to_owned()))],
423        );
424        let secondary = ScriptedProvider::chat_only(
425            "secondary",
426            vec![Ok(ChatOutcome::ServerError("b".to_owned()))],
427        );
428        let fb = FallbackProvider::new(primary).with_fallback(secondary);
429
430        let outcome = fb.chat(request()).await?;
431        assert!(matches!(outcome, ChatOutcome::ServerError(msg) if msg == "b"));
432        Ok(())
433    }
434
435    #[tokio::test]
436    async fn list_models_delegates_to_primary() -> Result<()> {
437        let primary = ScriptedProvider::chat_only("primary", vec![]);
438        let secondary = ScriptedProvider::chat_only("secondary", vec![]);
439        let fb = FallbackProvider::new(primary).with_fallback(secondary);
440
441        let models = fb.list_models().await?;
442        // Discovery is served by the primary, not the default "unsupported".
443        assert_eq!(models.len(), 1);
444        assert_eq!(models[0].id, "primary-model");
445        Ok(())
446    }
447
448    #[test]
449    fn only_metadata_deltas_leave_the_chain_uncommitted() {
450        // Content — anything the consumer can see or replay — commits: failing
451        // over after it would make the secondary emit it a second time.
452        for delta in [
453            StreamDelta::TextDelta {
454                delta: "hi".to_owned(),
455                block_index: 0,
456            },
457            StreamDelta::ThinkingDelta {
458                delta: "hmm".to_owned(),
459                block_index: 0,
460            },
461            StreamDelta::ToolUseStart {
462                id: "t1".to_owned(),
463                name: "echo".to_owned(),
464                block_index: 0,
465                thought_signature: None,
466            },
467            StreamDelta::ToolInputDelta {
468                id: "t1".to_owned(),
469                delta: "{}".to_owned(),
470                block_index: 0,
471            },
472            StreamDelta::Done {
473                stop_reason: Some(StopReason::EndTurn),
474            },
475        ] {
476            assert!(
477                commits_stream(&delta),
478                "content/terminal deltas must commit the chain: {delta:?}"
479            );
480        }
481
482        // Usage is metadata the user never sees, so it must not strand the chain
483        // on a provider that reported its billing and then died.
484        assert!(!commits_stream(&StreamDelta::Usage(Usage {
485            input_tokens: 1,
486            output_tokens: 1,
487            cached_input_tokens: 0,
488            cache_creation_input_tokens: 0,
489        })));
490    }
491
492    #[tokio::test]
493    async fn stream_usage_before_an_error_does_not_suppress_failover() -> Result<()> {
494        // Providers now report the tokens a failing turn burned *before* the
495        // terminal error. That usage delta must not commit the chain to a dead
496        // primary — it is metadata the user never sees — and the tokens it
497        // reports must survive into the surviving provider's usage, because the
498        // accumulator keeps only the last usage delta.
499        let primary = ScriptedProvider::streaming(
500            "primary",
501            vec![
502                Ok(StreamDelta::Usage(Usage {
503                    input_tokens: 100,
504                    output_tokens: 50,
505                    cached_input_tokens: 0,
506                    cache_creation_input_tokens: 0,
507                })),
508                Ok(StreamDelta::Error {
509                    message: "rate limited".to_owned(),
510                    kind: StreamErrorKind::RateLimited(None),
511                }),
512            ],
513        );
514        let secondary = ScriptedProvider::streaming(
515            "secondary",
516            vec![
517                Ok(StreamDelta::TextDelta {
518                    delta: "hello".to_owned(),
519                    block_index: 0,
520                }),
521                Ok(StreamDelta::Usage(Usage {
522                    input_tokens: 10,
523                    output_tokens: 5,
524                    cached_input_tokens: 0,
525                    cache_creation_input_tokens: 0,
526                })),
527                Ok(StreamDelta::Done {
528                    stop_reason: Some(StopReason::EndTurn),
529                }),
530            ],
531        );
532        let fb = FallbackProvider::new(primary.clone()).with_fallback(secondary.clone());
533
534        let mut accumulator = crate::streaming::StreamAccumulator::new();
535        let mut stream = fb.chat_stream(request());
536        let mut saw_error = false;
537        let mut text = String::new();
538        while let Some(item) = stream.next().await {
539            let delta = item?;
540            if let StreamDelta::Error { .. } = &delta {
541                saw_error = true;
542            }
543            if let StreamDelta::TextDelta { delta, .. } = &delta {
544                text.push_str(delta);
545            }
546            accumulator.apply(&delta);
547        }
548        drop(stream);
549
550        assert!(
551            !saw_error,
552            "the usage delta must not commit us to the primary"
553        );
554        assert_eq!(text, "hello", "the secondary must serve the turn");
555        assert_eq!(primary.calls(), 1);
556        assert_eq!(secondary.calls(), 1);
557
558        // The primary genuinely billed 150 tokens before dying; the secondary
559        // billed 15. The consumer must end up accounting for both.
560        let usage = accumulator
561            .usage()
562            .context("the failover must still report usage")?;
563        assert_eq!(usage.input_tokens, 110);
564        assert_eq!(usage.output_tokens, 55);
565        Ok(())
566    }
567
568    #[tokio::test]
569    async fn stream_fails_over_on_recoverable_first_error() -> Result<()> {
570        let primary = ScriptedProvider::streaming(
571            "primary",
572            vec![Ok(StreamDelta::Error {
573                message: "rate limited".to_owned(),
574                kind: StreamErrorKind::RateLimited(None),
575            })],
576        );
577        let secondary = ScriptedProvider::streaming(
578            "secondary",
579            vec![
580                Ok(StreamDelta::TextDelta {
581                    delta: "hello".to_owned(),
582                    block_index: 0,
583                }),
584                Ok(StreamDelta::Done {
585                    stop_reason: Some(StopReason::EndTurn),
586                }),
587            ],
588        );
589        let fb = FallbackProvider::new(primary.clone()).with_fallback(secondary.clone());
590
591        let mut stream = fb.chat_stream(request());
592        let mut text = String::new();
593        while let Some(item) = stream.next().await {
594            if let StreamDelta::TextDelta { delta, .. } = item? {
595                text.push_str(&delta);
596            }
597        }
598        assert_eq!(text, "hello");
599        assert_eq!(primary.calls(), 1);
600        assert_eq!(secondary.calls(), 1);
601        Ok(())
602    }
603
604    #[test]
605    fn reports_primary_identity() {
606        let primary = ScriptedProvider::chat_only("primary", vec![]);
607        let fb = FallbackProvider::new(primary);
608        assert_eq!(fb.provider(), "primary");
609        assert_eq!(fb.model(), "primary");
610        assert_eq!(fb.len(), 1);
611        assert!(!fb.is_empty());
612    }
613}