1pub mod anthropic;
8pub mod openai;
9pub mod retry;
10pub(crate) mod sse;
11
12use crate::message::{CompletionRequest, CompletionResponse, Usage};
13use anyhow::Result;
14use async_trait::async_trait;
15use tokio::sync::mpsc::UnboundedSender;
16
17#[derive(Debug, Clone)]
19pub enum StreamEvent {
20 TextDelta(String),
21 ThinkingDelta(String),
22 ToolUseStart {
24 name: String,
25 },
26 Usage(Usage),
35}
36
37pub type StreamSink = UnboundedSender<StreamEvent>;
38
39#[async_trait]
40pub trait Provider: Send + Sync {
41 fn id(&self) -> &str;
43
44 fn default_model(&self) -> &str;
46
47 async fn complete(
50 &self,
51 req: &CompletionRequest,
52 sink: Option<&StreamSink>,
53 ) -> Result<CompletionResponse>;
54}
55
56pub fn build(cfg: &crate::config::ProviderConfig) -> Result<Box<dyn Provider>> {
58 match cfg.kind.as_str() {
59 "anthropic" => Ok(Box::new(anthropic::Anthropic::from_config(cfg)?)),
60 "openai" | "openai-compatible" | "local" => {
61 Ok(Box::new(openai::OpenAiCompatible::from_config(cfg)?))
62 }
63 other => {
64 anyhow::bail!("unknown provider kind {other:?} (expected: anthropic, openai, local)")
65 }
66 }
67}
68
69pub struct Failover {
84 primary: Box<dyn Provider>,
85 fallbacks: Vec<(String, Box<dyn Provider>)>,
86}
87
88impl Failover {
89 pub fn new(primary: Box<dyn Provider>, fallbacks: Vec<(String, Box<dyn Provider>)>) -> Self {
90 Failover { primary, fallbacks }
91 }
92}
93
94fn failover_worthy(e: &anyhow::Error) -> bool {
95 e.downcast_ref::<retry::ProviderError>()
96 .is_some_and(retry::ProviderError::transient)
97}
98
99#[async_trait]
100impl Provider for Failover {
101 fn id(&self) -> &str {
102 self.primary.id()
103 }
104
105 fn default_model(&self) -> &str {
106 self.primary.default_model()
107 }
108
109 async fn complete(
110 &self,
111 req: &CompletionRequest,
112 sink: Option<&StreamSink>,
113 ) -> Result<CompletionResponse> {
114 let mut last = match self.primary.complete(req, sink).await {
115 Ok(response) => return Ok(response),
116 Err(e) if failover_worthy(&e) => e,
117 Err(e) => return Err(e),
118 };
119
120 for (name, provider) in &self.fallbacks {
121 tracing::warn!(
122 error = %last,
123 fallback = %name,
124 "provider failed transiently after retries; falling back"
125 );
126 let fb_req = CompletionRequest {
127 model: provider.default_model().to_string(),
128 ..req.clone()
129 };
130 match provider.complete(&fb_req, sink).await {
131 Ok(response) => return Ok(response),
132 Err(e) if failover_worthy(&e) => last = e,
133 Err(e) => return Err(e),
134 }
135 }
136 Err(last.context(format!(
137 "the primary and {} fallback(s) all failed transiently",
138 self.fallbacks.len()
139 )))
140 }
141}
142
143#[cfg(test)]
144mod failover_tests {
145 use super::*;
146 use crate::message::{Block, Message, StopReason};
147 use retry::ProviderError;
148 use std::sync::atomic::{AtomicUsize, Ordering};
149 use std::sync::{Arc, Mutex};
150
151 struct Failing {
153 error: fn() -> anyhow::Error,
154 calls: Arc<AtomicUsize>,
155 }
156
157 #[async_trait]
158 impl Provider for Failing {
159 fn id(&self) -> &str {
160 "failing"
161 }
162 fn default_model(&self) -> &str {
163 "primary-model"
164 }
165 async fn complete(
166 &self,
167 _req: &CompletionRequest,
168 _sink: Option<&StreamSink>,
169 ) -> Result<CompletionResponse> {
170 self.calls.fetch_add(1, Ordering::SeqCst);
171 Err((self.error)())
172 }
173 }
174
175 struct Recording {
177 model_seen: Arc<Mutex<Option<String>>>,
178 calls: Arc<AtomicUsize>,
179 }
180
181 #[async_trait]
182 impl Provider for Recording {
183 fn id(&self) -> &str {
184 "recording"
185 }
186 fn default_model(&self) -> &str {
187 "fallback-model"
188 }
189 async fn complete(
190 &self,
191 req: &CompletionRequest,
192 _sink: Option<&StreamSink>,
193 ) -> Result<CompletionResponse> {
194 self.calls.fetch_add(1, Ordering::SeqCst);
195 *self.model_seen.lock().unwrap() = Some(req.model.clone());
196 Ok(CompletionResponse {
197 message: Message::assistant(vec![Block::text("from the fallback")]),
198 stop_reason: StopReason::EndTurn,
199 usage: Usage::default(),
200 refusal: None,
201 model: "fallback-model".into(),
202 malformed_tool_args: 0,
203 })
204 }
205 }
206
207 fn req() -> CompletionRequest {
208 CompletionRequest {
209 model: "primary-model".into(),
210 system: None,
211 messages: vec![Message::user("hi")],
212 tools: Vec::new(),
213 max_tokens: 64,
214 effort: None,
215 thinking: false,
216 cache_prompt: false,
217 }
218 }
219
220 type Rig = (
221 Failover,
222 Arc<AtomicUsize>,
223 Arc<Mutex<Option<String>>>,
224 Arc<AtomicUsize>,
225 );
226
227 fn rig(error: fn() -> anyhow::Error) -> Rig {
228 let primary_calls = Arc::new(AtomicUsize::new(0));
229 let fallback_calls = Arc::new(AtomicUsize::new(0));
230 let model_seen = Arc::new(Mutex::new(None));
231 let failover = Failover::new(
232 Box::new(Failing {
233 error,
234 calls: Arc::clone(&primary_calls),
235 }),
236 vec![(
237 "small".into(),
238 Box::new(Recording {
239 model_seen: Arc::clone(&model_seen),
240 calls: Arc::clone(&fallback_calls),
241 }) as Box<dyn Provider>,
242 )],
243 );
244 (failover, primary_calls, model_seen, fallback_calls)
245 }
246
247 #[tokio::test]
248 async fn a_transient_exhaustion_falls_back_and_the_fallback_answers_as_itself() {
249 let (failover, _, model_seen, _) =
250 rig(|| anyhow::Error::new(ProviderError::Overloaded).context("anthropic 529: busy"));
251
252 let response = failover.complete(&req(), None).await.unwrap();
253
254 assert_eq!(response.message.text(), "from the fallback");
255 assert_eq!(
258 model_seen.lock().unwrap().as_deref(),
259 Some("fallback-model")
260 );
261 }
262
263 #[tokio::test]
264 async fn terminal_classes_never_fall_back() {
265 for error in [
269 (|| anyhow::Error::new(ProviderError::Invalid("bad".into()))) as fn() -> anyhow::Error,
270 || anyhow::Error::new(ProviderError::Auth),
271 || anyhow::Error::new(ProviderError::ContextOverflow),
272 ] {
273 let (failover, primary_calls, _, fallback_calls) = rig(error);
274 let err = failover.complete(&req(), None).await.unwrap_err();
275 assert!(err.downcast_ref::<ProviderError>().is_some());
276 assert_eq!(primary_calls.load(Ordering::SeqCst), 1);
277 assert_eq!(
278 fallback_calls.load(Ordering::SeqCst),
279 0,
280 "the fallback was consulted"
281 );
282 }
283 }
284
285 #[tokio::test]
286 async fn an_unclassified_error_never_falls_back_because_it_may_be_mid_stream() {
287 let (failover, _, _, fallback_calls) = rig(|| anyhow::anyhow!("stream aborted mid-body"));
291
292 let err = failover.complete(&req(), None).await.unwrap_err();
293 assert!(err.to_string().contains("stream aborted"));
294 assert_eq!(fallback_calls.load(Ordering::SeqCst), 0);
295 }
296}