Skip to main content

mecha_core/provider/
mod.rs

1//! Model providers.
2//!
3//! A provider translates [`CompletionRequest`] onto some wire protocol and
4//! translates the reply back into [`CompletionResponse`]. Everything above this
5//! layer — the agent loop, tools, sessions — is provider-agnostic.
6
7pub 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/// Incremental output, emitted only when a sink is supplied.
18#[derive(Debug, Clone)]
19pub enum StreamEvent {
20    TextDelta(String),
21    ThinkingDelta(String),
22    /// A tool call has begun; arguments are still streaming.
23    ToolUseStart {
24        name: String,
25    },
26    /// Everything known about this turn's token usage *so far*, cumulative.
27    ///
28    /// Emitted as it arrives rather than only at the end, because cancelling a
29    /// run drops the provider future and with it the final frame that carries
30    /// the totals. Without this, a run interrupted on its first turn reports
31    /// zero tokens — and the tokens were spent. Input is usually known from the
32    /// very first frame, which is the expensive half when a cached prefix is in
33    /// play.
34    Usage(Usage),
35}
36
37pub type StreamSink = UnboundedSender<StreamEvent>;
38
39#[async_trait]
40pub trait Provider: Send + Sync {
41    /// Stable identifier used in config and `--provider`.
42    fn id(&self) -> &str;
43
44    /// Model used when the caller doesn't name one.
45    fn default_model(&self) -> &str;
46
47    /// Run one turn. With `sink`, stream and emit deltas as they arrive; the
48    /// accumulated response is still returned.
49    async fn complete(
50        &self,
51        req: &CompletionRequest,
52        sink: Option<&StreamSink>,
53    ) -> Result<CompletionResponse>;
54}
55
56/// Build a provider from a config entry.
57pub 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
69/// Tries the primary, and on a *transient* exhaustion tries each fallback in
70/// order — turn-local, so the next call starts from the primary again.
71///
72/// Two rules carry the design:
73///
74/// - **Only errors carrying a [`retry::ProviderError`] in their chain are
75///   eligible, and only transient ones.** The providers attach that marker
76///   exactly when nothing of the attempt was consumed — a mid-stream failure
77///   has already shown the user deltas, and re-issuing it would replay half
78///   an answer. `Invalid`/`ContextOverflow` fail identically everywhere, and
79///   `Auth`/`Billing` are the primary's problem, not a routing decision.
80/// - **Each fallback answers as itself.** The request's model name is
81///   rewritten to the fallback's own default — sending one server's model
82///   name to another server was a real recorded bug (`mecha replay -p`).
83pub 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    /// Fails every call with the given error; counts how often it was asked.
152    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    /// Answers, and records the model name it was asked for.
176    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        // The recorded bug this guards: sending one server's model name to
256        // another server. The fallback must be asked for its own model.
257        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        // An invalid request fails identically everywhere; auth is the
266        // primary's problem, not a routing decision. The fallback must not
267        // even be consulted.
268        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        // Errors without a ProviderError in the chain are mid-stream failures:
288        // deltas may already be on the user's screen, and a fallback would
289        // replay half an answer as a whole one.
290        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}