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
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    use std::sync::Mutex;
243    use std::sync::atomic::{AtomicUsize, Ordering};
244
245    use agent_sdk_foundation::llm::{ChatResponse, ContentBlock, StopReason, Usage};
246    use anyhow::{Context as _, anyhow};
247
248    use crate::streaming::StreamErrorKind;
249
250    /// A provider that replays a queue of `chat` results and counts its calls.
251    struct ScriptedProvider {
252        name: &'static str,
253        results: Mutex<std::collections::VecDeque<Result<ChatOutcome>>>,
254        stream_deltas: Mutex<Vec<Result<StreamDelta>>>,
255        calls: AtomicUsize,
256    }
257
258    impl ScriptedProvider {
259        fn chat_only(name: &'static str, results: Vec<Result<ChatOutcome>>) -> Arc<Self> {
260            Arc::new(Self {
261                name,
262                results: Mutex::new(results.into()),
263                stream_deltas: Mutex::new(Vec::new()),
264                calls: AtomicUsize::new(0),
265            })
266        }
267
268        fn streaming(name: &'static str, deltas: Vec<Result<StreamDelta>>) -> Arc<Self> {
269            Arc::new(Self {
270                name,
271                results: Mutex::new(std::collections::VecDeque::new()),
272                stream_deltas: Mutex::new(deltas),
273                calls: AtomicUsize::new(0),
274            })
275        }
276
277        fn calls(&self) -> usize {
278            self.calls.load(Ordering::SeqCst)
279        }
280    }
281
282    #[async_trait]
283    impl LlmProvider for ScriptedProvider {
284        async fn chat(&self, _request: ChatRequest) -> Result<ChatOutcome> {
285            self.calls.fetch_add(1, Ordering::SeqCst);
286            self.results
287                .lock()
288                .map_err(|_| anyhow!("results lock poisoned"))?
289                .pop_front()
290                .unwrap_or_else(|| Ok(ChatOutcome::ServerError("exhausted".to_owned())))
291        }
292
293        async fn list_models(&self) -> Result<Vec<crate::provider::ModelInfo>> {
294            Ok(vec![crate::provider::ModelInfo {
295                id: format!("{}-model", self.name),
296                display_name: None,
297                context_window: None,
298                max_output_tokens: None,
299            }])
300        }
301
302        fn chat_stream(&self, _request: ChatRequest) -> StreamBox<'_> {
303            self.calls.fetch_add(1, Ordering::SeqCst);
304            let deltas: Vec<Result<StreamDelta>> = self
305                .stream_deltas
306                .lock()
307                .map(|d| {
308                    d.iter()
309                        .map(|r| match r {
310                            Ok(delta) => Ok(delta.clone()),
311                            Err(e) => Err(anyhow!("{e}")),
312                        })
313                        .collect()
314                })
315                .unwrap_or_default();
316            Box::pin(async_stream::stream! {
317                for delta in deltas {
318                    yield delta;
319                }
320            })
321        }
322
323        fn model(&self) -> &str {
324            self.name
325        }
326
327        fn provider(&self) -> &'static str {
328            self.name
329        }
330    }
331
332    fn success(text: &str) -> ChatOutcome {
333        ChatOutcome::Success(ChatResponse {
334            id: "r".to_owned(),
335            content: vec![ContentBlock::Text {
336                text: text.to_owned(),
337            }],
338            model: "m".to_owned(),
339            stop_reason: Some(StopReason::EndTurn),
340            usage: Usage {
341                input_tokens: 1,
342                output_tokens: 1,
343                cached_input_tokens: 0,
344                cache_creation_input_tokens: 0,
345            },
346        })
347    }
348
349    fn request() -> ChatRequest {
350        ChatRequest::new("sys", vec![agent_sdk_foundation::llm::Message::user("hi")])
351    }
352
353    #[tokio::test]
354    async fn server_error_fails_over_to_secondary() -> Result<()> {
355        let primary = ScriptedProvider::chat_only(
356            "primary",
357            vec![Ok(ChatOutcome::ServerError("boom".to_owned()))],
358        );
359        let secondary = ScriptedProvider::chat_only("secondary", vec![Ok(success("ok"))]);
360        let fb = FallbackProvider::new(primary.clone()).with_fallback(secondary.clone());
361
362        let outcome = fb.chat(request()).await?;
363        assert!(matches!(outcome, ChatOutcome::Success(r) if r.first_text() == Some("ok")));
364        assert_eq!(primary.calls(), 1);
365        assert_eq!(secondary.calls(), 1);
366        Ok(())
367    }
368
369    #[tokio::test]
370    async fn rate_limit_fails_over() -> Result<()> {
371        let primary =
372            ScriptedProvider::chat_only("primary", vec![Ok(ChatOutcome::RateLimited(None))]);
373        let secondary = ScriptedProvider::chat_only("secondary", vec![Ok(success("ok"))]);
374        let fb = FallbackProvider::from_providers(primary.clone(), [secondary.clone() as Arc<_>]);
375
376        let outcome = fb.chat(request()).await?;
377        assert!(matches!(outcome, ChatOutcome::Success(_)));
378        assert_eq!(secondary.calls(), 1);
379        Ok(())
380    }
381
382    #[tokio::test]
383    async fn transport_error_fails_over() -> Result<()> {
384        let primary = ScriptedProvider::chat_only("primary", vec![Err(anyhow!("timeout"))]);
385        let secondary = ScriptedProvider::chat_only("secondary", vec![Ok(success("ok"))]);
386        let fb = FallbackProvider::new(primary.clone()).with_fallback(secondary.clone());
387
388        let outcome = fb.chat(request()).await?;
389        assert!(matches!(outcome, ChatOutcome::Success(_)));
390        Ok(())
391    }
392
393    #[tokio::test]
394    async fn invalid_request_does_not_fail_over() -> Result<()> {
395        let primary = ScriptedProvider::chat_only(
396            "primary",
397            vec![Ok(ChatOutcome::InvalidRequest("bad".to_owned()))],
398        );
399        let secondary = ScriptedProvider::chat_only("secondary", vec![Ok(success("ok"))]);
400        let fb = FallbackProvider::new(primary.clone()).with_fallback(secondary.clone());
401
402        let outcome = fb.chat(request()).await?;
403        assert!(matches!(outcome, ChatOutcome::InvalidRequest(_)));
404        // The non-retryable outcome short-circuits: the secondary is untouched.
405        assert_eq!(secondary.calls(), 0);
406        Ok(())
407    }
408
409    #[tokio::test]
410    async fn last_provider_outcome_is_returned_when_all_fail() -> Result<()> {
411        let primary = ScriptedProvider::chat_only(
412            "primary",
413            vec![Ok(ChatOutcome::ServerError("a".to_owned()))],
414        );
415        let secondary = ScriptedProvider::chat_only(
416            "secondary",
417            vec![Ok(ChatOutcome::ServerError("b".to_owned()))],
418        );
419        let fb = FallbackProvider::new(primary).with_fallback(secondary);
420
421        let outcome = fb.chat(request()).await?;
422        assert!(matches!(outcome, ChatOutcome::ServerError(msg) if msg == "b"));
423        Ok(())
424    }
425
426    #[tokio::test]
427    async fn list_models_delegates_to_primary() -> Result<()> {
428        let primary = ScriptedProvider::chat_only("primary", vec![]);
429        let secondary = ScriptedProvider::chat_only("secondary", vec![]);
430        let fb = FallbackProvider::new(primary).with_fallback(secondary);
431
432        let models = fb.list_models().await?;
433        // Discovery is served by the primary, not the default "unsupported".
434        assert_eq!(models.len(), 1);
435        assert_eq!(models[0].id, "primary-model");
436        Ok(())
437    }
438
439    #[test]
440    fn only_metadata_deltas_leave_the_chain_uncommitted() {
441        // Content — anything the consumer can see or replay — commits: failing
442        // over after it would make the secondary emit it a second time.
443        for delta in [
444            StreamDelta::TextDelta {
445                delta: "hi".to_owned(),
446                block_index: 0,
447            },
448            StreamDelta::ThinkingDelta {
449                delta: "hmm".to_owned(),
450                block_index: 0,
451            },
452            StreamDelta::ToolUseStart {
453                id: "t1".to_owned(),
454                name: "echo".to_owned(),
455                block_index: 0,
456                thought_signature: None,
457            },
458            StreamDelta::ToolInputDelta {
459                id: "t1".to_owned(),
460                delta: "{}".to_owned(),
461                block_index: 0,
462            },
463            StreamDelta::Done {
464                stop_reason: Some(StopReason::EndTurn),
465            },
466        ] {
467            assert!(
468                commits_stream(&delta),
469                "content/terminal deltas must commit the chain: {delta:?}"
470            );
471        }
472
473        // Usage is metadata the user never sees, so it must not strand the chain
474        // on a provider that reported its billing and then died.
475        assert!(!commits_stream(&StreamDelta::Usage(Usage {
476            input_tokens: 1,
477            output_tokens: 1,
478            cached_input_tokens: 0,
479            cache_creation_input_tokens: 0,
480        })));
481    }
482
483    #[tokio::test]
484    async fn stream_usage_before_an_error_does_not_suppress_failover() -> Result<()> {
485        // Providers now report the tokens a failing turn burned *before* the
486        // terminal error. That usage delta must not commit the chain to a dead
487        // primary — it is metadata the user never sees — and the tokens it
488        // reports must survive into the surviving provider's usage, because the
489        // accumulator keeps only the last usage delta.
490        let primary = ScriptedProvider::streaming(
491            "primary",
492            vec![
493                Ok(StreamDelta::Usage(Usage {
494                    input_tokens: 100,
495                    output_tokens: 50,
496                    cached_input_tokens: 0,
497                    cache_creation_input_tokens: 0,
498                })),
499                Ok(StreamDelta::Error {
500                    message: "rate limited".to_owned(),
501                    kind: StreamErrorKind::RateLimited(None),
502                }),
503            ],
504        );
505        let secondary = ScriptedProvider::streaming(
506            "secondary",
507            vec![
508                Ok(StreamDelta::TextDelta {
509                    delta: "hello".to_owned(),
510                    block_index: 0,
511                }),
512                Ok(StreamDelta::Usage(Usage {
513                    input_tokens: 10,
514                    output_tokens: 5,
515                    cached_input_tokens: 0,
516                    cache_creation_input_tokens: 0,
517                })),
518                Ok(StreamDelta::Done {
519                    stop_reason: Some(StopReason::EndTurn),
520                }),
521            ],
522        );
523        let fb = FallbackProvider::new(primary.clone()).with_fallback(secondary.clone());
524
525        let mut accumulator = crate::streaming::StreamAccumulator::new();
526        let mut stream = fb.chat_stream(request());
527        let mut saw_error = false;
528        let mut text = String::new();
529        while let Some(item) = stream.next().await {
530            let delta = item?;
531            if let StreamDelta::Error { .. } = &delta {
532                saw_error = true;
533            }
534            if let StreamDelta::TextDelta { delta, .. } = &delta {
535                text.push_str(delta);
536            }
537            accumulator.apply(&delta);
538        }
539        drop(stream);
540
541        assert!(
542            !saw_error,
543            "the usage delta must not commit us to the primary"
544        );
545        assert_eq!(text, "hello", "the secondary must serve the turn");
546        assert_eq!(primary.calls(), 1);
547        assert_eq!(secondary.calls(), 1);
548
549        // The primary genuinely billed 150 tokens before dying; the secondary
550        // billed 15. The consumer must end up accounting for both.
551        let usage = accumulator
552            .usage()
553            .context("the failover must still report usage")?;
554        assert_eq!(usage.input_tokens, 110);
555        assert_eq!(usage.output_tokens, 55);
556        Ok(())
557    }
558
559    #[tokio::test]
560    async fn stream_fails_over_on_recoverable_first_error() -> Result<()> {
561        let primary = ScriptedProvider::streaming(
562            "primary",
563            vec![Ok(StreamDelta::Error {
564                message: "rate limited".to_owned(),
565                kind: StreamErrorKind::RateLimited(None),
566            })],
567        );
568        let secondary = ScriptedProvider::streaming(
569            "secondary",
570            vec![
571                Ok(StreamDelta::TextDelta {
572                    delta: "hello".to_owned(),
573                    block_index: 0,
574                }),
575                Ok(StreamDelta::Done {
576                    stop_reason: Some(StopReason::EndTurn),
577                }),
578            ],
579        );
580        let fb = FallbackProvider::new(primary.clone()).with_fallback(secondary.clone());
581
582        let mut stream = fb.chat_stream(request());
583        let mut text = String::new();
584        while let Some(item) = stream.next().await {
585            if let StreamDelta::TextDelta { delta, .. } = item? {
586                text.push_str(&delta);
587            }
588        }
589        assert_eq!(text, "hello");
590        assert_eq!(primary.calls(), 1);
591        assert_eq!(secondary.calls(), 1);
592        Ok(())
593    }
594
595    #[test]
596    fn reports_primary_identity() {
597        let primary = ScriptedProvider::chat_only("primary", vec![]);
598        let fb = FallbackProvider::new(primary);
599        assert_eq!(fb.provider(), "primary");
600        assert_eq!(fb.model(), "primary");
601        assert_eq!(fb.len(), 1);
602        assert!(!fb.is_empty());
603    }
604}