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}