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