Skip to main content

mermaid_cli/providers/model/
ollama.rs

1//! Ollama provider wrapping `OllamaAdapter`.
2//!
3//! The adapter owns the wire format (NDJSON framing, gpt-oss
4//! reasoning dispatch, truncation marker, retry). The wrapper
5//! translates `ChatRequest` ↔ `ModelConfig` and bridges the
6//! adapter's legacy `StreamCallback` to the typed `StreamEvent`
7//! sink. Adapter-internals stay where they are; the architecture
8//! boundary is at `ModelProvider::chat`.
9
10use std::sync::Arc;
11
12use async_trait::async_trait;
13
14use mermaid_domain::ChatRequest;
15use mermaid_model::models::adapters::ollama::{OllamaAdapter, OllamaModelInfo};
16use mermaid_model::models::adapters::ollama_sizing::{
17    NumCtxInputs, converge_num_ctx, default_ollama_num_predict, kv_bytes_per_token,
18    resolve_ollama_num_ctx,
19};
20use mermaid_model::models::{BackendConfig, Model, ModelConfig, ModelError, Result};
21use mermaid_runtime::{NewProviderProbe, RuntimeStore};
22
23use super::super::ctx::{FinalResponse, StreamContext, StreamEvent};
24use super::{
25    ContextSizing, ModelPlacement, ModelProvider, learn_output_cap, load_limits_from_db,
26    output_cap_from_error, probe_is_stale, retry_cap,
27};
28use mermaid_model::models::ModelCapabilities;
29
30/// Ollama adapter fronted by `ModelProvider`.
31pub struct OllamaProvider {
32    adapter: OllamaAdapter,
33    capabilities: ModelCapabilities,
34    /// Shared app `Config` so `build_model_config` can read Ollama
35    /// hardware options (`num_ctx`, `num_gpu`, `num_thread`, `numa`) at
36    /// call time. Before F11 these were silently dropped because the
37    /// wrapper built `ModelConfig` only from `ChatRequest` fields.
38    config: Arc<mermaid_domain::Config>,
39    /// Cached `/api/show` probe (context window + dims + weight). Filled once per
40    /// process per model — the provider itself is cached by `ProviderFactory`, so
41    /// this fires a single network probe per model. Backed by the cross-session
42    /// `provider_probes` table. Probe *failures* are not cached (left empty for a
43    /// cheap retry next turn).
44    ctx_cell: tokio::sync::OnceCell<OllamaModelInfo>,
45}
46
47impl OllamaProvider {
48    /// Backward-compatible constructor that uses a default app config.
49    /// Call `with_app_config` instead when you have one available so
50    /// Ollama hardware options actually reach the adapter.
51    ///
52    /// # Errors
53    ///
54    /// Only [`Self::with_app_config`]'s.
55    pub async fn new(model_name: &str, backend: Arc<BackendConfig>) -> Result<Self> {
56        Self::with_app_config(
57            model_name,
58            backend,
59            Arc::new(mermaid_domain::Config::default()),
60        )
61        .await
62    }
63
64    /// Construct with an explicit `domain::Config` reference. Used by
65    /// `ProviderFactory::build_provider` so `config.ollama.{num_gpu,
66    /// num_ctx, num_thread, numa}` make it into the Ollama request's
67    /// `options` block.
68    ///
69    /// # Errors
70    ///
71    /// Only [`OllamaAdapter::new`]'s — the HTTP client build. The server is
72    /// not contacted here: the model probe is lazy, so a stopped Ollama still
73    /// constructs, and with `ollama_autostart` set the recovery hook revives
74    /// it on the first request instead.
75    pub async fn with_app_config(
76        model_name: &str,
77        backend: Arc<BackendConfig>,
78        config: Arc<mermaid_domain::Config>,
79    ) -> Result<Self> {
80        let autostart = backend.ollama_autostart;
81        let adapter = OllamaAdapter::new(model_name, backend).await?;
82        // The chat path is an *intent* path: a dead local server is mermaid's
83        // problem, not the user's, so hand the adapter the means to revive one.
84        // Enumeration verbs deliberately construct the adapter without this —
85        // see `LocalServerRecovery`.
86        let adapter = if autostart {
87            adapter.with_recovery(Arc::new(crate::ollama::OllamaAutostart))
88        } else {
89            adapter
90        };
91        let capabilities = adapter.capabilities().clone();
92        Ok(Self {
93            adapter,
94            capabilities,
95            config,
96            ctx_cell: tokio::sync::OnceCell::new(),
97        })
98    }
99
100    /// Probe the model's capabilities, cache-first. In-process `OnceCell` backed
101    /// by the cross-session `provider_probes` table; failures aren't cached.
102    async fn probe(&self) -> Option<OllamaModelInfo> {
103        self.ctx_cell
104            .get_or_try_init(|| async { self.load_probe().await.ok_or(()) })
105            .await
106            .ok()
107            .cloned()
108    }
109
110    async fn load_probe(&self) -> Option<OllamaModelInfo> {
111        let model = self.adapter.name().to_string();
112        if let Some(info) = load_probe_from_db(model.clone()).await {
113            return Some(info);
114        }
115        let info = self.adapter.show_model_info().await?;
116        save_probe_to_db(model, info.clone()).await;
117        Some(info)
118    }
119
120    /// Assemble the sizing inputs from the probe, host memory, config, and the
121    /// request's per-model override. Built here and in the effect layer from the
122    /// same (cached) sources so the effective window can't diverge between the
123    /// request and compaction.
124    async fn num_ctx_inputs(
125        &self,
126        info: &OllamaModelInfo,
127        override_num_ctx: Option<u32>,
128        override_offload: Option<bool>,
129    ) -> NumCtxInputs {
130        // Live `/context offload` toggle wins; otherwise the persisted default.
131        // The provider's `config` is frozen at startup (the factory never
132        // rebuilds), so the toggle rides on the request instead of `config`.
133        let allow_ram_offload = override_offload.unwrap_or(self.config.ollama.allow_ram_offload);
134        // Only fetch the budget we'll actually use: VRAM when keeping the model
135        // on the GPU (default), system RAM when offload is allowed.
136        let (vram_bytes, system_ram_bytes) = if allow_ram_offload {
137            (None, mermaid_model::utils::system_ram_bytes())
138        } else {
139            (mermaid_model::utils::gpu_vram_bytes().await, None)
140        };
141        NumCtxInputs {
142            model_max: info.context_length,
143            dims: info.dims,
144            model_weight_bytes: info.weight_bytes,
145            per_model_override: override_num_ctx,
146            global_num_ctx: self.config.ollama.num_ctx,
147            allow_ram_offload,
148            vram_bytes,
149            system_ram_bytes,
150            max_auto_cap: self.config.ollama.max_auto_num_ctx,
151            is_cloud: crate::ollama::is_cloud_model(self.adapter.name()),
152        }
153    }
154}
155
156#[async_trait]
157impl ModelProvider for OllamaProvider {
158    fn capabilities(&self) -> &ModelCapabilities {
159        &self.capabilities
160    }
161
162    async fn resolve_context_window(&self, request: &ChatRequest) -> ContextSizing {
163        let info = self.probe().await.unwrap_or_default();
164        let inputs = self
165            .num_ctx_inputs(
166                &info,
167                request.ollama_num_ctx,
168                request.ollama_allow_ram_offload,
169            )
170            .await;
171        let model_max = inputs.model_max;
172        // Local Ollama imposes no per-response ceiling (num_predict is ours),
173        // but Ollama Cloud maps num_predict → max_tokens and 400s above the
174        // model's real cap. A cap learned from such a 400 lives in the limits
175        // cache; feed it forward so sizing computes min(window room, cap).
176        let max_output =
177            load_limits_from_db("ollama".to_string(), Model::name(&self.adapter).to_string())
178                .await
179                .and_then(|l| l.max_output_tokens);
180        match resolve_ollama_num_ctx(&inputs) {
181            Some(r) => ContextSizing {
182                model_max,
183                effective: Some(r.value),
184                source: Some(r.source),
185                max_output,
186            },
187            // No model_max and nothing configured → omit num_ctx (Ollama default).
188            None => ContextSizing {
189                model_max,
190                effective: None,
191                source: None,
192                max_output,
193            },
194        }
195    }
196
197    async fn verify_placement(&self, current_num_ctx: Option<usize>) -> Option<ModelPlacement> {
198        let (vram, total) = self.adapter.model_placement().await?;
199        // A zero total means Ollama reported the model but not its footprint —
200        // can't judge placement, so leave it unknown rather than guess.
201        if total == 0 {
202            return None;
203        }
204        // On a spill, compute the largest num_ctx that would fit, from the model's
205        // KV cost (probe dims) and the *measured* overflow — the auto-converge
206        // target. `None` if it already fits or shrinking can't help.
207        let suggested_num_ctx = if vram < total {
208            let info = self.probe().await.unwrap_or_default();
209            current_num_ctx
210                .zip(info.dims)
211                .and_then(|(current, dims)| {
212                    let kv = kv_bytes_per_token(&dims)?;
213                    converge_num_ctx(current, vram, total, kv)
214                })
215                .map(|n| n as u32)
216        } else {
217            None
218        };
219        Some(ModelPlacement {
220            size_vram_bytes: vram,
221            total_bytes: total,
222            suggested_num_ctx,
223        })
224    }
225
226    async fn supports_vision(&self) -> Option<bool> {
227        Some(self.adapter.vision_supported().await)
228    }
229
230    async fn chat(&self, request: ChatRequest, ctx: StreamContext) -> Result<FinalResponse> {
231        // Resolve the effective window first (cache-first probe). Idempotent with
232        // the effect layer's call — both read the same cached probe + memory, so
233        // what we send as num_ctx matches what compaction assumes.
234        let sizing = self.resolve_context_window(&request).await;
235        let config =
236            build_model_config(&request, &self.config, sizing.effective, sizing.max_output);
237        // Ordered relay (F2): the adapter's sync callback pushes into an
238        // `UnboundedSender` (synchronous, FIFO). A single relay task drains
239        // into the bounded sink in order, avoiding the per-event `tokio::
240        // spawn` race that could deliver `Done` before prior tool calls.
241        let (relay_tx, relay_handle) = super::stream_bridge::ordered_relay(ctx.sink.clone());
242        let callback = super::stream_bridge::forward_callback(relay_tx.clone());
243
244        // Race adapter.chat against the cancellation token. When
245        // cancelled, the adapter's stream loop observes the sink
246        // closing (we drop `callback`) and exits at its next await.
247        // This is the crucial structural win vs. the old
248        // `check_interrupt` polling: the adapter doesn't need to
249        // know anything about turn IDs — the sink either drains or
250        // doesn't, and the tokens handle everything else.
251        let chat_fut = async {
252            match self
253                .adapter
254                .chat(&request.messages, &config, Some(callback.clone()))
255                .await
256            {
257                Ok(response) => Ok(response),
258                Err(err) => {
259                    // Learn-from-400: Ollama Cloud rejects a num_predict above
260                    // the model's real output cap and names the cap in the
261                    // body. Learn it (persisted — later turns size below it up
262                    // front), clamp, retry ONCE. A request that 400s streamed
263                    // no events, so the relay is untouched and reusable.
264                    let Some(cap) = output_cap_from_error(&err) else {
265                        return Err(err);
266                    };
267                    let sent = config
268                        .ollama_options()
269                        .num_predict
270                        .map_or(0, |v| v.max(0) as usize);
271                    if retry_cap(sent, cap).is_none() {
272                        return Err(err);
273                    }
274                    let model = Model::name(&self.adapter).to_string();
275                    learn_output_cap("ollama".to_string(), model.clone(), cap).await;
276                    let _ = relay_tx.send(StreamEvent::Status(format!(
277                        "{model} rejected the output budget; learned its {cap}-token cap and retrying"
278                    )));
279                    let retry_config =
280                        build_model_config(&request, &self.config, sizing.effective, Some(cap));
281                    self.adapter
282                        .chat(&request.messages, &retry_config, Some(callback.clone()))
283                        .await
284                },
285            }
286        };
287
288        let response = tokio::select! {
289            biased;
290            _ = ctx.token.cancelled() => {
291                // Terminal event for a cancelled turn comes from the
292                // runner's `drop_scope` once the `TurnScope` drains, so
293                // we neither emit `StreamEvent::Done` here nor surface
294                // an `UpstreamError`. `ModelError::Cancelled` is the
295                // sentinel the runner swallows.
296                return Err(ModelError::Cancelled);
297            },
298            r = chat_fut => r?,
299        };
300
301        // F3: the wrapper's `Done` is now the sole terminal event —
302        // the adapter no longer emits one from the callback. Carrying
303        // `provider_continuation` out of `ModelResponse` here is what
304        // lets multi-turn extended thinking round-trip.
305        let usage = response.usage.clone();
306        let provider_continuation = response.provider_continuation.clone();
307        let stop_reason = response.stop_reason.clone();
308        // Terminal Done through the ordered relay, then drain (see stream_bridge).
309        let _ = relay_tx.send(StreamEvent::Done {
310            usage: usage.clone(),
311            provider_continuation: provider_continuation.clone(),
312            stop_reason: stop_reason.clone(),
313        });
314        drop(relay_tx);
315        mermaid_model::utils::join_logged(relay_handle.take(), "stream_relay").await;
316
317        Ok(FinalResponse {
318            usage,
319            provider_continuation,
320            tool_calls: response.tool_calls.unwrap_or_default(),
321            stop_reason,
322        })
323    }
324}
325
326// ─── helpers ────────────────────────────────────────────────────────
327
328/// `num_ctx` is the resolved effective window (auto-fitted, or override/global);
329/// passing it here keeps a single source of truth — the direct `config.ollama.
330/// num_ctx` forward is gone because the resolver already considered it.
331/// `provider_max_output` is a known per-response ceiling (learned from an
332/// Ollama Cloud 400), bounding the derived `num_predict`.
333fn build_model_config(
334    request: &ChatRequest,
335    app_config: &mermaid_domain::Config,
336    num_ctx: Option<usize>,
337    provider_max_output: Option<usize>,
338) -> ModelConfig {
339    let mut mc = ModelConfig {
340        model: request.model_id.clone(),
341        temperature: request.temperature,
342        max_tokens: request.max_tokens,
343        reasoning: request.reasoning,
344        system_prompt: Some(request.system_prompt.clone()),
345        dynamic_system_suffix: request.instructions.clone(),
346        tools: request.tools.iter().map(|t| t.to_openai_json()).collect(),
347        output_schema: request.output_schema.clone(),
348        ..Default::default()
349    };
350    // Effective context window (auto-fitted to memory / override / global).
351    if let Some(n) = num_ctx {
352        mc.set_backend_option("ollama".into(), "num_ctx".into(), n.to_string());
353    }
354    // Output cap: AUTO (max_tokens == 0) gets the full room num_ctx leaves
355    // after the prompt; an explicit max_tokens is an exact cap bounded by that
356    // room. Without a cap Ollama generates unbounded and only stops when the
357    // window fills — the truncation bug. `None` = AUTO with an unknown window →
358    // omit num_predict so Ollama applies its own default.
359    if let Some(num_predict) = default_ollama_num_predict(
360        request.max_tokens,
361        num_ctx,
362        estimate_prompt_tokens(request),
363        provider_max_output,
364    ) {
365        mc.set_backend_option(
366            "ollama".into(),
367            "num_predict".into(),
368            num_predict.to_string(),
369        );
370    }
371
372    // F11: forward Ollama hardware options from the user's app config (num_ctx is
373    // now handled above via the resolver).
374    if let Some(v) = app_config.ollama.num_gpu {
375        mc.set_backend_option("ollama".into(), "num_gpu".into(), v.to_string());
376    }
377    if let Some(v) = app_config.ollama.num_thread {
378        mc.set_backend_option("ollama".into(), "num_thread".into(), v.to_string());
379    }
380    if let Some(v) = app_config.ollama.numa {
381        mc.set_backend_option("ollama".into(), "numa".into(), v.to_string());
382    }
383    mc
384}
385
386/// Rough prompt-token estimate (≈4 chars/token) for bounding `num_predict`
387/// against the remaining room in `num_ctx`. Approximate by design — it only
388/// gates the output cap, never the prompt itself.
389fn estimate_prompt_tokens(request: &ChatRequest) -> usize {
390    let chars = request.system_prompt.len()
391        + request.instructions.as_deref().map_or(0, str::len)
392        + request
393            .messages
394            .iter()
395            .map(|m| m.content.len())
396            .sum::<usize>();
397    chars / 4
398}
399
400/// Load a cached probe from `provider_probes` (within TTL). Runs the blocking
401/// SQLite read off the async runtime. Best-effort: any failure → `None`.
402async fn load_probe_from_db(model: String) -> Option<OllamaModelInfo> {
403    tokio::task::spawn_blocking(move || {
404        let store = RuntimeStore::open_default().ok()?;
405        let rec = store
406            .provider_probes()
407            .get("ollama", &model, "context_probe")
408            .ok()??;
409        if probe_is_stale(&rec.probed_at) {
410            return None;
411        }
412        serde_json::from_str::<OllamaModelInfo>(&rec.capability_value).ok()
413    })
414    .await
415    .ok()
416    .flatten()
417}
418
419/// Persist a probe to `provider_probes` for subsequent sessions. Best-effort.
420async fn save_probe_to_db(model: String, info: OllamaModelInfo) {
421    let _ = tokio::task::spawn_blocking(move || -> Option<()> {
422        let value = serde_json::to_string(&info).ok()?;
423        let store = RuntimeStore::open_default().ok()?;
424        store
425            .provider_probes()
426            .upsert(NewProviderProbe {
427                provider: "ollama".into(),
428                model_id: model,
429                capability_key: "context_probe".into(),
430                capability_value: value,
431                confidence: "probed".into(),
432                error: None,
433            })
434            .ok()?;
435        Some(())
436    })
437    .await;
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn build_model_config_maps_request_fields() {
446        let req = ChatRequest {
447            model_id: "ollama/test".to_string(),
448            messages: vec![],
449            system_prompt: "sys".to_string(),
450            instructions: Some("instructions text".to_string()),
451            reasoning: mermaid_model::models::ReasoningLevel::High,
452            temperature: 0.3,
453            max_tokens: 2048,
454            tools: vec![],
455
456            ollama_num_ctx: None,
457            ollama_allow_ram_offload: None,
458            resolved_context_window: None,
459            resolved_max_output: None,
460            output_schema: None,
461            suppress_auto_compact: false,
462            suppressed_builtin_tools: Vec::new(),
463        };
464        let app_cfg = mermaid_domain::Config::default();
465        let cfg = build_model_config(&req, &app_cfg, None, None);
466        assert_eq!(cfg.model, "ollama/test");
467        assert_eq!(cfg.temperature, 0.3);
468        assert_eq!(cfg.max_tokens, 2048);
469        assert_eq!(cfg.reasoning, mermaid_model::models::ReasoningLevel::High);
470        assert_eq!(cfg.system_prompt.as_deref(), Some("sys"));
471        assert_eq!(
472            cfg.dynamic_system_suffix.as_deref(),
473            Some("instructions text")
474        );
475    }
476
477    /// F11 regression guard: Ollama hardware options in the user's app config
478    /// must land in the `ModelConfig`'s `backend_options` so the adapter's
479    /// `build_request_body` emits them under `options`. `num_ctx` now arrives via
480    /// the resolver param (not a direct config forward), and `num_predict` is
481    /// always derived.
482    #[test]
483    fn build_model_config_forwards_ollama_hardware_options() {
484        let req = ChatRequest {
485            model_id: "ollama/test".to_string(),
486            messages: vec![],
487            system_prompt: "sys".to_string(),
488            instructions: None,
489            reasoning: mermaid_model::models::ReasoningLevel::Medium,
490            temperature: 0.7,
491            max_tokens: 4096,
492            tools: vec![],
493
494            ollama_num_ctx: None,
495            ollama_allow_ram_offload: None,
496            resolved_context_window: None,
497            resolved_max_output: None,
498            output_schema: None,
499            suppress_auto_compact: false,
500            suppressed_builtin_tools: Vec::new(),
501        };
502        let mut app_cfg = mermaid_domain::Config::default();
503        app_cfg.ollama.num_gpu = Some(10);
504        app_cfg.ollama.num_thread = Some(8);
505        app_cfg.ollama.numa = Some(true);
506
507        // The effective num_ctx (8192) is passed in by the resolver.
508        let cfg = build_model_config(&req, &app_cfg, Some(8192), None);
509        let opts = cfg.ollama_options();
510        assert_eq!(opts.num_ctx, Some(8192));
511        assert_eq!(opts.num_gpu, Some(10));
512        assert_eq!(opts.num_thread, Some(8));
513        assert_eq!(opts.numa, Some(true));
514        assert!(opts.num_predict.is_some(), "num_predict is always derived");
515    }
516
517    /// An explicit `max_tokens` is an exact cap, bounded by the room left in
518    /// `num_ctx` (no reasoning reserve added — hard means hard).
519    #[test]
520    fn build_model_config_derives_num_predict() {
521        let req = ChatRequest {
522            model_id: "ollama/test".to_string(),
523            messages: vec![],
524            system_prompt: String::new(),
525            instructions: None,
526            reasoning: mermaid_model::models::ReasoningLevel::Max,
527            temperature: 0.7,
528            max_tokens: 4096,
529            tools: vec![],
530
531            ollama_num_ctx: None,
532            ollama_allow_ram_offload: None,
533            resolved_context_window: None,
534            resolved_max_output: None,
535            output_schema: None,
536            suppress_auto_compact: false,
537            suppressed_builtin_tools: Vec::new(),
538        };
539        let cfg = build_model_config(
540            &req,
541            &mermaid_domain::Config::default(),
542            Some(131_072),
543            None,
544        );
545        // Exactly the 4096 cap; plenty of room in a 131072 window.
546        assert_eq!(cfg.ollama_options().num_predict, Some(4_096));
547    }
548
549    /// The minimax incident, recomputed: a learned provider cap bounds the
550    /// AUTO `num_predict` below the full window room, so the retry (and every
551    /// later turn) sends a value Ollama Cloud accepts.
552    #[test]
553    fn build_model_config_caps_num_predict_at_learned_ceiling() {
554        let req = ChatRequest {
555            model_id: "ollama/minimax-m3:cloud".to_string(),
556            messages: vec![],
557            system_prompt: String::new(),
558            instructions: None,
559            reasoning: mermaid_model::models::ReasoningLevel::Medium,
560            temperature: 0.7,
561            max_tokens: 0, // AUTO — the incident's configuration
562            tools: vec![],
563
564            ollama_num_ctx: None,
565            ollama_allow_ram_offload: None,
566            resolved_context_window: None,
567            resolved_max_output: None,
568            output_schema: None,
569            suppress_auto_compact: false,
570            suppressed_builtin_tools: Vec::new(),
571        };
572        let app_cfg = mermaid_domain::Config::default();
573        // Without a learned cap, AUTO hands over the full window room —
574        // 524_288 minus margin — which Ollama Cloud 400s for minimax-m3.
575        let uncapped = build_model_config(&req, &app_cfg, Some(524_288), None);
576        assert!(uncapped.ollama_options().num_predict.unwrap() > 131_072);
577        // With the learned 131_072 cap, sizing stays at the ceiling.
578        let capped = build_model_config(&req, &app_cfg, Some(524_288), Some(131_072));
579        assert_eq!(capped.ollama_options().num_predict, Some(131_072));
580    }
581}