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 preflight;
10pub mod retry;
11pub(crate) mod sse;
12
13use crate::message::{CompletionRequest, CompletionResponse, Usage};
14use anyhow::Result;
15use async_trait::async_trait;
16use tokio::sync::mpsc::UnboundedSender;
17
18/// Incremental output, emitted only when a sink is supplied.
19#[derive(Debug, Clone)]
20pub enum StreamEvent {
21    TextDelta(String),
22    ThinkingDelta(String),
23    /// A tool call has begun; arguments are still streaming.
24    ToolUseStart {
25        name: String,
26    },
27    /// Everything known about this turn's token usage *so far*, cumulative.
28    ///
29    /// Emitted as it arrives rather than only at the end, because cancelling a
30    /// run drops the provider future and with it the final frame that carries
31    /// the totals. Without this, a run interrupted on its first turn reports
32    /// zero tokens — and the tokens were spent. Input is usually known from the
33    /// very first frame, which is the expensive half when a cached prefix is in
34    /// play.
35    Usage(Usage),
36}
37
38pub type StreamSink = UnboundedSender<StreamEvent>;
39
40#[async_trait]
41pub trait Provider: Send + Sync {
42    /// Stable identifier used in config and `--provider`.
43    fn id(&self) -> &str;
44
45    /// Model used when the caller doesn't name one.
46    fn default_model(&self) -> &str;
47
48    /// Whether this provider will actually put an image in front of a model.
49    ///
50    /// Asked by whatever is about to *build* an image block — the Slack
51    /// connector, the TUI — so it can name a file instead of reading and
52    /// base64-encoding one that would only be rendered as text anyway. The
53    /// encoders degrade correctly without this; what it saves is a megabyte
54    /// of base64 written into a transcript for nothing.
55    ///
56    /// Defaults to `false`, so a provider added later is text-only until
57    /// somebody says otherwise — the direction that fails safe, on the
58    /// `unrouted_domains` reasoning: forgetting costs a feature that does
59    /// not fire, and the alternative costs a failed request.
60    fn vision(&self) -> bool {
61        false
62    }
63
64    /// Run one turn. With `sink`, stream and emit deltas as they arrive; the
65    /// accumulated response is still returned.
66    async fn complete(
67        &self,
68        req: &CompletionRequest,
69        sink: Option<&StreamSink>,
70    ) -> Result<CompletionResponse>;
71}
72
73/// Build a provider from a config entry.
74pub fn build(cfg: &crate::config::ProviderConfig) -> Result<Box<dyn Provider>> {
75    match cfg.kind.as_str() {
76        "anthropic" => Ok(Box::new(anthropic::Anthropic::from_config(cfg)?)),
77        "openai" | "openai-compatible" | "local" => {
78            Ok(Box::new(openai::OpenAiCompatible::from_config(cfg)?))
79        }
80        other => {
81            anyhow::bail!("unknown provider kind {other:?} (expected: anthropic, openai, local)")
82        }
83    }
84}
85
86/// Tries the primary, and on a *transient* exhaustion tries each fallback in
87/// order — turn-local, so the next call starts from the primary again.
88///
89/// Two rules carry the design:
90///
91/// - **Only errors carrying a [`retry::ProviderError`] in their chain are
92///   eligible, and only transient ones.** The providers attach that marker
93///   exactly when nothing of the attempt was consumed — a mid-stream failure
94///   has already shown the user deltas, and re-issuing it would replay half
95///   an answer. `Invalid`/`ContextOverflow` fail identically everywhere, and
96///   `Auth`/`Billing` are the primary's problem, not a routing decision.
97/// - **Each fallback answers as itself.** The request's model name is
98///   rewritten to the fallback's own default — sending one server's model
99///   name to another server was a real recorded bug (`mecha replay -p`).
100pub struct Failover {
101    primary: Box<dyn Provider>,
102    fallbacks: Vec<(String, Box<dyn Provider>)>,
103}
104
105impl Failover {
106    pub fn new(primary: Box<dyn Provider>, fallbacks: Vec<(String, Box<dyn Provider>)>) -> Self {
107        Failover { primary, fallbacks }
108    }
109}
110
111fn failover_worthy(e: &anyhow::Error) -> bool {
112    e.downcast_ref::<retry::ProviderError>()
113        .is_some_and(retry::ProviderError::transient)
114}
115
116#[async_trait]
117impl Provider for Failover {
118    fn id(&self) -> &str {
119        self.primary.id()
120    }
121
122    fn default_model(&self) -> &str {
123        self.primary.default_model()
124    }
125
126    async fn complete(
127        &self,
128        req: &CompletionRequest,
129        sink: Option<&StreamSink>,
130    ) -> Result<CompletionResponse> {
131        let mut last = match self.primary.complete(req, sink).await {
132            Ok(response) => return Ok(response),
133            Err(e) if failover_worthy(&e) => e,
134            Err(e) => return Err(e),
135        };
136
137        for (name, provider) in &self.fallbacks {
138            tracing::warn!(
139                error = %last,
140                fallback = %name,
141                "provider failed transiently after retries; falling back"
142            );
143            let fb_req = CompletionRequest {
144                model: provider.default_model().to_string(),
145                ..req.clone()
146            };
147            match provider.complete(&fb_req, sink).await {
148                Ok(response) => return Ok(response),
149                Err(e) if failover_worthy(&e) => last = e,
150                Err(e) => return Err(e),
151            }
152        }
153        Err(last.context(format!(
154            "the primary and {} fallback(s) all failed transiently",
155            self.fallbacks.len()
156        )))
157    }
158}
159
160#[cfg(test)]
161mod failover_tests {
162    use super::*;
163    use crate::message::{Block, Message, StopReason};
164    use retry::ProviderError;
165    use std::sync::atomic::{AtomicUsize, Ordering};
166    use std::sync::{Arc, Mutex};
167
168    /// Fails every call with the given error; counts how often it was asked.
169    struct Failing {
170        error: fn() -> anyhow::Error,
171        calls: Arc<AtomicUsize>,
172    }
173
174    #[async_trait]
175    impl Provider for Failing {
176        fn id(&self) -> &str {
177            "failing"
178        }
179        fn default_model(&self) -> &str {
180            "primary-model"
181        }
182        async fn complete(
183            &self,
184            _req: &CompletionRequest,
185            _sink: Option<&StreamSink>,
186        ) -> Result<CompletionResponse> {
187            self.calls.fetch_add(1, Ordering::SeqCst);
188            Err((self.error)())
189        }
190    }
191
192    /// Answers, and records the model name it was asked for.
193    struct Recording {
194        model_seen: Arc<Mutex<Option<String>>>,
195        calls: Arc<AtomicUsize>,
196    }
197
198    #[async_trait]
199    impl Provider for Recording {
200        fn id(&self) -> &str {
201            "recording"
202        }
203        fn default_model(&self) -> &str {
204            "fallback-model"
205        }
206        async fn complete(
207            &self,
208            req: &CompletionRequest,
209            _sink: Option<&StreamSink>,
210        ) -> Result<CompletionResponse> {
211            self.calls.fetch_add(1, Ordering::SeqCst);
212            *self.model_seen.lock().unwrap() = Some(req.model.clone());
213            Ok(CompletionResponse {
214                message: Message::assistant(vec![Block::text("from the fallback")]),
215                stop_reason: StopReason::EndTurn,
216                usage: Usage::default(),
217                refusal: None,
218                model: "fallback-model".into(),
219                malformed_tool_args: 0,
220            })
221        }
222    }
223
224    fn req() -> CompletionRequest {
225        CompletionRequest {
226            model: "primary-model".into(),
227            system: None,
228            messages: vec![Message::user("hi")],
229            tools: Vec::new(),
230            max_tokens: 64,
231            effort: None,
232            thinking: false,
233            cache_prompt: false,
234        }
235    }
236
237    type Rig = (
238        Failover,
239        Arc<AtomicUsize>,
240        Arc<Mutex<Option<String>>>,
241        Arc<AtomicUsize>,
242    );
243
244    fn rig(error: fn() -> anyhow::Error) -> Rig {
245        let primary_calls = Arc::new(AtomicUsize::new(0));
246        let fallback_calls = Arc::new(AtomicUsize::new(0));
247        let model_seen = Arc::new(Mutex::new(None));
248        let failover = Failover::new(
249            Box::new(Failing {
250                error,
251                calls: Arc::clone(&primary_calls),
252            }),
253            vec![(
254                "small".into(),
255                Box::new(Recording {
256                    model_seen: Arc::clone(&model_seen),
257                    calls: Arc::clone(&fallback_calls),
258                }) as Box<dyn Provider>,
259            )],
260        );
261        (failover, primary_calls, model_seen, fallback_calls)
262    }
263
264    #[tokio::test]
265    async fn a_transient_exhaustion_falls_back_and_the_fallback_answers_as_itself() {
266        let (failover, _, model_seen, _) =
267            rig(|| anyhow::Error::new(ProviderError::Overloaded).context("anthropic 529: busy"));
268
269        let response = failover.complete(&req(), None).await.unwrap();
270
271        assert_eq!(response.message.text(), "from the fallback");
272        // The recorded bug this guards: sending one server's model name to
273        // another server. The fallback must be asked for its own model.
274        assert_eq!(
275            model_seen.lock().unwrap().as_deref(),
276            Some("fallback-model")
277        );
278    }
279
280    #[tokio::test]
281    async fn terminal_classes_never_fall_back() {
282        // An invalid request fails identically everywhere; auth is the
283        // primary's problem, not a routing decision. The fallback must not
284        // even be consulted.
285        for error in [
286            (|| anyhow::Error::new(ProviderError::Invalid("bad".into()))) as fn() -> anyhow::Error,
287            || anyhow::Error::new(ProviderError::Auth),
288            || anyhow::Error::new(ProviderError::ContextOverflow),
289        ] {
290            let (failover, primary_calls, _, fallback_calls) = rig(error);
291            let err = failover.complete(&req(), None).await.unwrap_err();
292            assert!(err.downcast_ref::<ProviderError>().is_some());
293            assert_eq!(primary_calls.load(Ordering::SeqCst), 1);
294            assert_eq!(
295                fallback_calls.load(Ordering::SeqCst),
296                0,
297                "the fallback was consulted"
298            );
299        }
300    }
301
302    #[tokio::test]
303    async fn an_unclassified_error_never_falls_back_because_it_may_be_mid_stream() {
304        // Errors without a ProviderError in the chain are mid-stream failures:
305        // deltas may already be on the user's screen, and a fallback would
306        // replay half an answer as a whole one.
307        let (failover, _, _, fallback_calls) = rig(|| anyhow::anyhow!("stream aborted mid-body"));
308
309        let err = failover.complete(&req(), None).await.unwrap_err();
310        assert!(err.to_string().contains("stream aborted"));
311        assert_eq!(fallback_calls.load(Ordering::SeqCst), 0);
312    }
313}