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 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 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 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 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 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 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 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 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}