1use 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
25const fn commits_stream(delta: &StreamDelta) -> bool {
48 !matches!(delta, StreamDelta::Usage(_))
49}
50
51pub struct FallbackProvider {
57 primary: Arc<dyn LlmProvider>,
58 fallbacks: Vec<Arc<dyn LlmProvider>>,
59}
60
61impl FallbackProvider {
62 #[must_use]
65 pub fn new(primary: Arc<dyn LlmProvider>) -> Self {
66 Self {
67 primary,
68 fallbacks: Vec::new(),
69 }
70 }
71
72 #[must_use]
74 pub fn with_fallback(mut self, provider: Arc<dyn LlmProvider>) -> Self {
75 self.fallbacks.push(provider);
76 self
77 }
78
79 #[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 #[must_use]
93 pub fn len(&self) -> usize {
94 1 + self.fallbacks.len()
95 }
96
97 #[must_use]
99 pub const fn is_empty(&self) -> bool {
100 false
101 }
102
103 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
112const 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 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 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 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 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 async fn list_models(&self) -> Result<Vec<crate::provider::ModelInfo>> {
215 self.primary.list_models().await
216 }
217
218 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 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 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 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 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 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 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 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}