Skip to main content

mermaid_cli/providers/model/
mod.rs

1//! Model adapters wrapped as `ModelProvider` implementations.
2//!
3//! Five providers today: Ollama, Anthropic, Gemini, Meta, and OpenAI-
4//! compat (covering OpenAI, OpenRouter, Groq, Cerebras, DeepInfra,
5//! Together, and user-defined endpoints). Each wraps the
6//! corresponding adapter in `mermaid_model::models::adapters`; the adapter
7//! owns the wire format and the wrapper owns the trait shape.
8
9pub mod anthropic;
10pub mod gemini;
11pub mod meta;
12pub mod ollama;
13pub mod openai_compat;
14
15use std::sync::Arc;
16
17use async_trait::async_trait;
18
19use mermaid_domain::{ChatRequest, TurnId};
20use mermaid_model::models::adapters::ModelLimits;
21use mermaid_model::models::adapters::learning::{ParamMemory, Rejections};
22use mermaid_model::models::adapters::ollama_sizing::NumCtxSource;
23use mermaid_model::models::{FinishReason, ModelError, Result, TokenUsage};
24use mermaid_runtime::{NewProviderProbe, RuntimeStore};
25
26use super::ctx::{FinalResponse, StreamContext, StreamEvent};
27use mermaid_model::models::ModelCapabilities;
28
29/// Resolved context sizing for a turn. For most providers `model_max ==
30/// effective` (the static advertised window). For Ollama they differ:
31/// `model_max` is the probed architectural window, while `effective` is what we
32/// actually enforce as `num_ctx` (auto-fitted to memory, capped, or an
33/// override). Compaction and the status bar use `effective`; "model supports up
34/// to X" uses `model_max`.
35#[derive(Debug, Clone, Copy, Default)]
36pub struct ContextSizing {
37    pub model_max: Option<usize>,
38    pub effective: Option<usize>,
39    /// How `effective` was chosen (Ollama only). `None` for static/advertised.
40    pub source: Option<NumCtxSource>,
41    /// The model's per-response output ceiling, when the provider exposes one
42    /// (`/models` metadata, or a documented static table). Rides the same
43    /// resolve→reducer pipeline as the window so `provider_capabilities` can be
44    /// refreshed live.
45    pub max_output: Option<usize>,
46    /// Whether the provider compacts the conversation itself when a turn asks
47    /// (`ChatRequest::native_compaction`): Anthropic, until the model refuses.
48    pub compacts_natively: bool,
49}
50
51/// Where a loaded model actually sits in memory, from a post-turn probe (Ollama
52/// `/api/ps`). `total_bytes` is weights + KV + buffers; `size_vram_bytes` is the
53/// part resident in VRAM. `size_vram_bytes < total_bytes` means the model spilled
54/// to CPU/RAM (partial offload → slow). Only Ollama reports this.
55#[derive(Debug, Clone, Copy)]
56pub struct ModelPlacement {
57    pub size_vram_bytes: u64,
58    pub total_bytes: u64,
59    /// Auto-converge target: when the model spilled, the largest `num_ctx` that
60    /// would fit instead — or `None` if it already fits or shrinking can't help
61    /// (weights-bound). Computed against the *measured* footprint.
62    pub suggested_num_ctx: Option<u32>,
63}
64
65/// Provider-facing interface. A `ModelProvider` impl owns whatever
66/// HTTP client / state it needs and exposes `chat()` — that's the
67/// whole surface.
68#[async_trait]
69pub trait ModelProvider: Send + Sync {
70    /// `ModelCapabilities` the provider advertises. The reducer reads this
71    /// when building the outgoing `ChatRequest` (e.g. whether to
72    /// attach reasoning controls).
73    fn capabilities(&self) -> &ModelCapabilities;
74
75    /// Resolve the *effective* context window for a turn (what the model will
76    /// actually enforce). The default returns the static advertised window;
77    /// Ollama overrides this to probe the model's real window and auto-fit
78    /// `num_ctx` to host memory, honoring the request's per-model
79    /// `ollama_num_ctx` override. `None` means "let the backend decide".
80    ///
81    /// Awaited only on the effect runtime (never the reducer), so a probe never
82    /// blocks the UI.
83    async fn resolve_context_window(&self, request: &ChatRequest) -> ContextSizing {
84        let _ = request;
85        let max = self.capabilities().max_context_tokens;
86        ContextSizing {
87            model_max: max,
88            effective: max,
89            source: None,
90            max_output: self.capabilities().max_output_tokens,
91            compacts_natively: false,
92        }
93    }
94
95    /// Best-effort: where the loaded model currently sits in memory. The default
96    /// returns `None` (unknown / not applicable); Ollama overrides it to probe
97    /// `/api/ps`. Awaited only on the effect runtime, *after* a turn (when the
98    /// model is resident), so it never blocks the UI.
99    async fn verify_placement(&self, current_num_ctx: Option<usize>) -> Option<ModelPlacement> {
100        let _ = current_num_ctx;
101        None
102    }
103
104    /// Best-effort: whether the active model can actually see images. `None`
105    /// means "unknown / not applicable" — the default for providers that don't
106    /// probe, and for cloud providers whose vision support is already known
107    /// good; `Some(false)` is what drives the no-vision-model warning. Awaited
108    /// only on the effect runtime, so a probe never blocks the UI.
109    async fn supports_vision(&self) -> Option<bool> {
110        None
111    }
112
113    /// Stream a chat turn. Typed events flow through
114    /// `ctx.sink`; the returned `FinalResponse` carries token usage
115    /// and the Anthropic thinking-signature (opaque blob required to
116    /// continue extended thinking across turns).
117    ///
118    /// Cancellation: the provider MUST select! on `ctx.token.
119    /// cancelled()` inside any await that could block for more than
120    /// a few hundred ms. This is the contract that replaces the old
121    /// `check_interrupt` polling pattern.
122    async fn chat(&self, request: ChatRequest, ctx: StreamContext) -> Result<FinalResponse>;
123}
124
125/// Output collected from a one-shot, non-interactive streaming model call.
126#[derive(Debug, Clone, Default)]
127pub struct CollectedText {
128    pub text: String,
129    pub usage: Option<TokenUsage>,
130    pub stop_reason: Option<FinishReason>,
131    pub reasoning: Option<String>,
132}
133
134/// Run a one-shot, non-interactive model call and collect its streamed text
135/// into a `CollectedText`. For internal calls whose output is NOT shown to the user
136/// as it streams — context compaction, memory consolidation, and the Auto-mode safety classifier.
137/// Drains a private event channel, capturing plain text, reasoning trace, final token
138/// usage, and stop reasons (e.g. length limits / content filters). The `token` lets the
139/// caller cancel the call (e.g. on Ctrl+C) like any other turn work.
140pub(crate) async fn collect_text(
141    provider: Arc<dyn ModelProvider>,
142    turn: TurnId,
143    request: ChatRequest,
144    token: tokio_util::sync::CancellationToken,
145) -> Result<CollectedText> {
146    let (stream_tx, mut stream_rx) = tokio::sync::mpsc::channel::<StreamEvent>(128);
147    let ctx = StreamContext::new(token, stream_tx, turn);
148    let collector = tokio::task::spawn(async move {
149        let mut text = String::new();
150        let mut reasoning = String::new();
151        let mut usage = None;
152        let mut stop_reason = None;
153        while let Some(event) = stream_rx.recv().await {
154            match event {
155                StreamEvent::Text(chunk) => text.push_str(&chunk),
156                StreamEvent::Reasoning(chunk) => reasoning.push_str(&chunk.text),
157                StreamEvent::Done {
158                    usage: done_usage,
159                    stop_reason: done_stop,
160                    ..
161                } => {
162                    usage = done_usage;
163                    stop_reason = done_stop;
164                },
165                // Status is a user-facing plumbing notice, not content —
166                // a text collector has nowhere to surface it.
167                StreamEvent::ToolCall(_) | StreamEvent::Status(_) => {},
168            }
169        }
170        (
171            text,
172            (!reasoning.is_empty()).then_some(reasoning),
173            usage,
174            stop_reason,
175        )
176    });
177
178    let response = provider.chat(request, ctx).await;
179    let (text, reasoning, stream_usage, stream_stop_reason) = collector
180        .await
181        .map_err(|err| ModelError::StreamError(format!("collect_text collector failed: {err}")))?;
182    match response {
183        Ok(final_response) => Ok(CollectedText {
184            text,
185            usage: final_response.usage.or(stream_usage),
186            stop_reason: final_response.stop_reason.or(stream_stop_reason),
187            reasoning,
188        }),
189        Err(err) => Err(err),
190    }
191}
192
193pub use anthropic::AnthropicProvider;
194pub use gemini::GeminiProvider;
195pub use meta::MetaProvider;
196pub use ollama::OllamaProvider;
197pub use openai_compat::OpenAICompatProvider;
198
199/// True when a `provider_probes` row is older than the probe TTL (shared by
200/// the Ollama context probe and the per-provider limits probes). An
201/// unparseable timestamp is treated as stale so it re-probes.
202pub(crate) fn probe_is_stale(probed_at: &str) -> bool {
203    use chrono::{DateTime, Utc};
204    match DateTime::parse_from_rfc3339(probed_at) {
205        Ok(t) => {
206            Utc::now()
207                .signed_duration_since(t.with_timezone(&Utc))
208                .num_days()
209                >= mermaid_model::constants::PROVIDER_PROBE_TTL_DAYS
210        },
211        // Unparseable timestamp → treat as stale and re-probe.
212        Err(_) => true,
213    }
214}
215
216/// Limits learned from one live probe of a provider's models endpoint, cached
217/// per (provider, model) in `provider_probes`. A successful fetch WITHOUT
218/// limit metadata (or a definitive "model not listed") is cached too — as
219/// `None`s — so providers that don't expose limits aren't re-fetched every
220/// turn. Fetch *failures* are never cached.
221#[derive(serde::Serialize, serde::Deserialize)]
222pub(crate) struct CachedLimits {
223    pub(crate) max_context_tokens: Option<usize>,
224    pub(crate) max_output_tokens: Option<usize>,
225}
226
227pub(crate) const LIMITS_PROBE_KEY: &str = "limits_probe";
228
229/// Load fresh cached limits, off the async runtime. Best-effort → `None`.
230pub(crate) async fn load_limits_from_db(provider: String, model: String) -> Option<CachedLimits> {
231    tokio::task::spawn_blocking(move || {
232        let rec = mermaid_runtime::with_shared_store(|store| {
233            store
234                .provider_probes()
235                .get(&provider, &model, LIMITS_PROBE_KEY)
236        })
237        .ok()??;
238        if probe_is_stale(&rec.probed_at) {
239            return None;
240        }
241        serde_json::from_str::<CachedLimits>(&rec.capability_value).ok()
242    })
243    .await
244    .ok()
245    .flatten()
246}
247
248/// Persist probed limits for subsequent sessions. Best-effort.
249pub(crate) async fn save_limits_to_db(provider: String, model: String, limits: &CachedLimits) {
250    let value = match serde_json::to_string(limits) {
251        Ok(v) => v,
252        Err(_) => return,
253    };
254    let _ = tokio::task::spawn_blocking(move || -> Option<()> {
255        mermaid_runtime::with_shared_store(|store| {
256            store.provider_probes().upsert(NewProviderProbe {
257                provider,
258                model_id: model,
259                capability_key: LIMITS_PROBE_KEY.into(),
260                capability_value: value,
261                confidence: "probed".into(),
262                error: None,
263            })
264        })
265        .ok()?;
266        Some(())
267    })
268    .await;
269}
270
271/// Cache-first limit resolution: return fresh cached limits when present,
272/// otherwise run `fetch` against the provider's models endpoint. A successful
273/// fetch is cached even when all-`None` (definitive "provider exposes
274/// nothing"); a failed fetch is NOT cached and resolves to `None` so the next
275/// turn retries.
276pub(crate) async fn resolve_limits_cached<F, Fut>(
277    provider: &str,
278    model: &str,
279    fetch: F,
280) -> Option<CachedLimits>
281where
282    F: FnOnce() -> Fut,
283    Fut: std::future::Future<Output = Result<ModelLimits>>,
284{
285    if let Some(cached) = load_limits_from_db(provider.to_string(), model.to_string()).await {
286        return Some(cached);
287    }
288    match fetch().await {
289        Ok(limits) => {
290            let cached = CachedLimits {
291                max_context_tokens: limits.max_context_tokens,
292                max_output_tokens: limits.max_output_tokens,
293            };
294            save_limits_to_db(provider.to_string(), model.to_string(), &cached).await;
295            Some(cached)
296        },
297        // Network/parse failure: don't cache; caller falls back to static.
298        Err(_) => None,
299    }
300}
301
302/// `provider_probes` key for the request items a provider rejected for one
303/// model (`mermaid_model::models::adapters::learning`): a JSON array such as
304/// `["effort:xhigh","temperature"]`. TTL-bounded like every probe, so a
305/// provider that later adds support is re-asked after the TTL.
306pub(crate) const REJECTIONS_PROBE_KEY: &str = "rejected_params";
307
308/// The fresh cached rejections for one model, if any.
309fn read_rejections(store: &RuntimeStore, provider: &str, model: &str) -> Option<Rejections> {
310    let rec = store
311        .provider_probes()
312        .get(provider, model, REJECTIONS_PROBE_KEY)
313        .ok()??;
314    if probe_is_stale(&rec.probed_at) {
315        return None;
316    }
317    serde_json::from_str(&rec.capability_value).ok()
318}
319
320/// Store `learned` for one model, merged with whatever a concurrent session
321/// cached meanwhile.
322fn write_rejections(
323    store: &RuntimeStore,
324    provider: &str,
325    model: &str,
326    learned: &Rejections,
327) -> anyhow::Result<()> {
328    let mut merged = read_rejections(store, provider, model).unwrap_or_default();
329    merged.extend(learned);
330    store.provider_probes().upsert(NewProviderProbe {
331        provider: provider.to_string(),
332        model_id: model.to_string(),
333        capability_key: REJECTIONS_PROBE_KEY.into(),
334        capability_value: serde_json::to_string(&merged)?,
335        confidence: "learned".into(),
336        error: None,
337    })?;
338    Ok(())
339}
340
341/// A provider wrapper's link between its adapter's in-memory rejections and
342/// the cross-session cache: seed once before the first turn, persist
343/// whenever a turn learned something new. Best-effort both ways — a store
344/// that can't be opened costs a relearned rejection, never a failed turn.
345#[derive(Default)]
346pub(crate) struct RejectionCache {
347    seeded: tokio::sync::OnceCell<()>,
348    /// What the store holds as far as this instance knows.
349    persisted: std::sync::Mutex<Rejections>,
350}
351
352impl RejectionCache {
353    /// Seed `memory` from the store, once per provider instance.
354    pub(crate) async fn seed(&self, provider: &str, model: &str, memory: &ParamMemory) {
355        self.seeded
356            .get_or_init(|| async {
357                let (provider, model) = (provider.to_string(), model.to_string());
358                let cached = tokio::task::spawn_blocking(move || {
359                    mermaid_runtime::with_shared_store(|store| {
360                        Ok(read_rejections(store, &provider, &model))
361                    })
362                    .ok()
363                    .flatten()
364                })
365                .await
366                .ok()
367                .flatten();
368                if let Some(cached) = cached {
369                    memory.seed(&cached);
370                    *self.lock() = cached;
371                }
372            })
373            .await;
374    }
375
376    /// Persist `memory` if it learned anything the store doesn't have.
377    pub(crate) async fn persist(&self, provider: &str, model: &str, memory: &ParamMemory) {
378        let learned = memory.snapshot();
379        if *self.lock() == learned {
380            return;
381        }
382        *self.lock() = learned.clone();
383        let (provider, model) = (provider.to_string(), model.to_string());
384        let _ = tokio::task::spawn_blocking(move || {
385            mermaid_runtime::with_shared_store(|store| {
386                write_rejections(store, &provider, &model, &learned)
387            })
388        })
389        .await;
390    }
391
392    fn lock(&self) -> std::sync::MutexGuard<'_, Rejections> {
393        self.persisted
394            .lock()
395            .unwrap_or_else(std::sync::PoisonError::into_inner)
396    }
397}
398
399/// Extract a model's real per-response output ceiling from a provider's 400
400/// rejection body. Fires ONLY on unambiguous output-cap wordings:
401///
402/// - Ollama Cloud / MiniMax: `max_tokens (521276) exceeds model's maximum
403///   output tokens (131072) for model …`
404/// - OpenAI-compat: `max_tokens is too large: … This model supports at most
405///   16384 completion tokens …`
406///
407/// Anything else — in particular context-limit wordings ("prompt is too
408/// long", "maximum context length") — returns `None`: learning a window as
409/// an output cap would poison the cache, and a missed match just means
410/// today's behavior (the error surfaces). Values outside a sanity range are
411/// rejected as parser noise.
412pub(crate) fn parse_output_cap_message(body: &str) -> Option<usize> {
413    let cap = if let Some(rest) = text_after(body, "exceeds model's maximum output tokens") {
414        leading_integer(rest)
415    } else if body.contains("max_tokens is too large") {
416        text_after(body, "supports at most").and_then(leading_integer)
417    } else {
418        None
419    }?;
420    (1_024..10_000_000).contains(&cap).then_some(cap)
421}
422
423/// The slice of `haystack` after the first occurrence of `marker`.
424fn text_after<'a>(haystack: &'a str, marker: &str) -> Option<&'a str> {
425    haystack.find(marker).map(|i| &haystack[i + marker.len()..])
426}
427
428/// The first integer in `s`, required to start within a few characters —
429/// both documented wordings put the number right after the marker (`" ("` /
430/// `" "`), and a distant number would belong to something else.
431fn leading_integer(s: &str) -> Option<usize> {
432    let start = s.find(|c: char| c.is_ascii_digit()).filter(|&i| i <= 8)?;
433    s[start..]
434        .chars()
435        .take_while(char::is_ascii_digit)
436        .collect::<String>()
437        .parse()
438        .ok()
439}
440
441/// Decide the output cap for a one-shot retry after learning `learned` from
442/// a 400. AUTO (`requested == 0`, including "field omitted") retries at the
443/// learned cap; an explicit ask above the cap retries clamped to it; an ask
444/// already within the cap returns `None` — the 400 was about something else,
445/// so retrying the same request would loop.
446pub(crate) fn retry_cap(requested: usize, learned: usize) -> Option<usize> {
447    (requested == 0 || requested > learned).then_some(learned)
448}
449
450/// `parse_output_cap_message` gated to actual HTTP 400s — the only status
451/// where the body names a rejected parameter rather than a transient fault.
452pub(crate) fn output_cap_from_error(err: &ModelError) -> Option<usize> {
453    match err {
454        ModelError::Backend(mermaid_model::models::BackendError::HttpError {
455            status: 400,
456            message,
457            ..
458        }) => parse_output_cap_message(message),
459        _ => None,
460    }
461}
462
463/// Persist an output cap learned from a provider's 400 rejection (the error
464/// body names the model's real ceiling). Merges into any existing cached
465/// row — reading it raw, ignoring the TTL, since a stale window is still
466/// better than dropping it — and upserts as "probed". Best-effort.
467pub(crate) async fn learn_output_cap(provider: String, model: String, cap: usize) {
468    let _ = tokio::task::spawn_blocking(move || -> Option<()> {
469        let existing = mermaid_runtime::with_shared_store(|store| {
470            store
471                .provider_probes()
472                .get(&provider, &model, LIMITS_PROBE_KEY)
473        })
474        .ok()
475        .flatten()
476        .and_then(|rec| serde_json::from_str::<CachedLimits>(&rec.capability_value).ok());
477        let merged = CachedLimits {
478            max_context_tokens: existing.and_then(|l| l.max_context_tokens),
479            max_output_tokens: Some(cap),
480        };
481        let value = serde_json::to_string(&merged).ok()?;
482        mermaid_runtime::with_shared_store(|store| {
483            store.provider_probes().upsert(NewProviderProbe {
484                provider,
485                model_id: model,
486                capability_key: LIMITS_PROBE_KEY.into(),
487                capability_value: value,
488                confidence: "probed".into(),
489                error: None,
490            })
491        })
492        .ok()?;
493        Some(())
494    })
495    .await;
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501
502    fn temp_store(name: &str) -> RuntimeStore {
503        let dir =
504            std::env::temp_dir().join(format!("mermaid_rejections_{name}_{}", std::process::id()));
505        let _ = std::fs::remove_dir_all(&dir);
506        std::fs::create_dir_all(&dir).expect("dir");
507        RuntimeStore::open(dir.join("runtime.sqlite3")).expect("store")
508    }
509
510    #[test]
511    fn rejections_round_trip_through_provider_probes() {
512        let store = temp_store("round_trip");
513        assert_eq!(read_rejections(&store, "anthropic", "claude-nova-7"), None);
514        let learned = Rejections::from(["temperature"]);
515        write_rejections(&store, "anthropic", "claude-nova-7", &learned).expect("write");
516        assert_eq!(
517            read_rejections(&store, "anthropic", "claude-nova-7"),
518            Some(learned)
519        );
520        // Keyed per (provider, model): another model learned nothing.
521        assert_eq!(read_rejections(&store, "anthropic", "claude-other"), None);
522        let rec = store
523            .provider_probes()
524            .get("anthropic", "claude-nova-7", REJECTIONS_PROBE_KEY)
525            .expect("get")
526            .expect("row");
527        assert_eq!(rec.confidence, "learned");
528        assert_eq!(rec.capability_value, r#"["temperature"]"#);
529    }
530
531    #[test]
532    fn writing_rejections_merges_with_a_concurrent_sessions() {
533        let store = temp_store("merge");
534        write_rejections(&store, "openai", "m", &Rejections::from(["temperature"])).expect("a");
535        write_rejections(&store, "openai", "m", &Rejections::from(["max_tokens"])).expect("b");
536        assert_eq!(
537            read_rejections(&store, "openai", "m"),
538            Some(Rejections::from(["max_tokens", "temperature"]))
539        );
540    }
541
542    // The incident wording (Ollama Cloud, minimax-m3), raw and JSON-wrapped.
543    const MINIMAX_RAW: &str =
544        "max_tokens (521276) exceeds model's maximum output tokens (131072) for model minimax-m3";
545    const MINIMAX_JSON: &str = r#"{"error":"max_tokens (521276) exceeds model's maximum output tokens (131072) for model minimax-m3 (ref: a05c9ffb-168f)"}"#;
546    const OPENAI_STYLE: &str = r#"{"error":{"message":"max_tokens is too large: 200000. This model supports at most 16384 completion tokens, whereas you provided 200000.","type":"invalid_request_error"}}"#;
547
548    #[test]
549    fn parse_output_cap_matches_documented_wordings() {
550        assert_eq!(parse_output_cap_message(MINIMAX_RAW), Some(131_072));
551        assert_eq!(parse_output_cap_message(MINIMAX_JSON), Some(131_072));
552        assert_eq!(parse_output_cap_message(OPENAI_STYLE), Some(16_384));
553    }
554
555    #[test]
556    fn parse_output_cap_never_matches_context_limit_wordings() {
557        // Learning a context window as an output cap would poison the cache —
558        // these must all be None even though they mention token limits.
559        for body in [
560            "prompt is too long: 210000 tokens > 200000 maximum",
561            "This model's maximum context length is 128000 tokens",
562            "input length and max_tokens exceed context limit: 190000 + 20000 > 200000",
563            "the request exceeds the maximum context window of 131072 tokens",
564            "rate limit exceeded, try again in 20s",
565            "",
566        ] {
567            assert_eq!(parse_output_cap_message(body), None, "matched: {body}");
568        }
569    }
570
571    #[test]
572    fn parse_output_cap_rejects_nonsense_values() {
573        // Sub-1024 and absurd values are parser noise, not real ceilings.
574        assert_eq!(
575            parse_output_cap_message("exceeds model's maximum output tokens (512)"),
576            None
577        );
578        assert_eq!(
579            parse_output_cap_message("exceeds model's maximum output tokens (99999999999)"),
580            None
581        );
582        // A number too far from the marker belongs to something else.
583        assert_eq!(
584            parse_output_cap_message(
585                "exceeds model's maximum output tokens for this deployment tier which is 131072"
586            ),
587            None
588        );
589    }
590
591    #[test]
592    fn retry_cap_triple() {
593        // AUTO (0 / omitted) → retry at the learned cap.
594        assert_eq!(retry_cap(0, 131_072), Some(131_072));
595        // Explicit ask above the cap → clamp.
596        assert_eq!(retry_cap(521_276, 131_072), Some(131_072));
597        // Ask already within the cap → the 400 was about something else.
598        assert_eq!(retry_cap(4_096, 131_072), None);
599    }
600
601    #[test]
602    fn output_cap_from_error_gates_on_http_400() {
603        let err_400 = ModelError::Backend(mermaid_model::models::BackendError::HttpError {
604            status: 400,
605            message: MINIMAX_JSON.to_string(),
606            debug: Default::default(),
607        });
608        assert_eq!(output_cap_from_error(&err_400), Some(131_072));
609        // Same body on a 500 is a transient fault, not a learned limit.
610        let err_500 = ModelError::Backend(mermaid_model::models::BackendError::HttpError {
611            status: 500,
612            message: MINIMAX_JSON.to_string(),
613            debug: Default::default(),
614        });
615        assert_eq!(output_cap_from_error(&err_500), None);
616        assert_eq!(output_cap_from_error(&ModelError::Cancelled), None);
617    }
618}