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`, hands over the turn's
6//! event sink, and emits the terminal `Done`. Adapter-internals stay
7//! where they are; the architecture boundary is at
8//! `ModelProvider::chat`.
9
10use std::sync::Arc;
11
12use async_trait::async_trait;
13
14use mermaid_domain::{ChatRequest, ToolDefinition};
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;
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        // Race adapter.chat against the cancellation token. When cancelled,
238        // this future is dropped and the adapter's stream loop goes with it —
239        // the adapter never has to know anything about turn IDs.
240        let chat_fut = async {
241            match self
242                .adapter
243                .chat(&request.messages, &config, Some(ctx.sink.clone()))
244                .await
245            {
246                Ok(response) => Ok(response),
247                Err(err) => {
248                    // Learn-from-400: Ollama Cloud rejects a num_predict above
249                    // the model's real output cap and names the cap in the
250                    // body. Learn it (persisted — later turns size below it up
251                    // front), clamp, retry ONCE. A request that 400s streamed
252                    // no events, so the retry starts from a clean stream.
253                    let Some(cap) = output_cap_from_error(&err) else {
254                        return Err(err);
255                    };
256                    let sent = config
257                        .ollama_options()
258                        .num_predict
259                        .map_or(0, |v| v.max(0) as usize);
260                    if retry_cap(sent, cap).is_none() {
261                        return Err(err);
262                    }
263                    let model = Model::name(&self.adapter).to_string();
264                    learn_output_cap("ollama".to_string(), model.clone(), cap).await;
265                    let _ = ctx.sink.send(StreamEvent::Status(format!(
266                        "{model} rejected the output budget; learned its {cap}-token cap and retrying"
267                    ))).await;
268                    let retry_config =
269                        build_model_config(&request, &self.config, sizing.effective, Some(cap));
270                    self.adapter
271                        .chat(&request.messages, &retry_config, Some(ctx.sink.clone()))
272                        .await
273                },
274            }
275        };
276
277        let response = tokio::select! {
278            biased;
279            _ = ctx.token.cancelled() => {
280                // Terminal event for a cancelled turn comes from the
281                // runner's `drop_scope` once the `TurnScope` drains, so
282                // we neither emit `StreamEvent::Done` here nor surface
283                // an `UpstreamError`. `ModelError::Cancelled` is the
284                // sentinel the runner swallows.
285                return Err(ModelError::Cancelled);
286            },
287            r = chat_fut => r?,
288        };
289
290        // F3: the wrapper's `Done` is the sole terminal event — the adapter
291        // never emits one. Carrying `provider_continuation` out of
292        // `ModelResponse` here is what lets multi-turn extended thinking
293        // round-trip. It goes on the same sink the adapter just finished
294        // writing to, so it cannot overtake a still-queued ToolCall.
295        let usage = response.usage.clone();
296        let provider_continuation = response.provider_continuation.clone();
297        let stop_reason = response.stop_reason.clone();
298        let _ = ctx
299            .sink
300            .send(StreamEvent::Done {
301                usage: usage.clone(),
302                provider_continuation: provider_continuation.clone(),
303                stop_reason: stop_reason.clone(),
304            })
305            .await;
306
307        Ok(FinalResponse {
308            usage,
309            provider_continuation,
310            tool_calls: response.tool_calls.unwrap_or_default(),
311            stop_reason,
312        })
313    }
314}
315
316// ─── helpers ────────────────────────────────────────────────────────
317
318/// `num_ctx` is the resolved effective window (auto-fitted, or override/global);
319/// passing it here keeps a single source of truth — the direct `config.ollama.
320/// num_ctx` forward is gone because the resolver already considered it.
321/// `provider_max_output` is a known per-response ceiling (learned from an
322/// Ollama Cloud 400), bounding the derived `num_predict`.
323fn build_model_config(
324    request: &ChatRequest,
325    app_config: &mermaid_domain::Config,
326    num_ctx: Option<usize>,
327    provider_max_output: Option<usize>,
328) -> ModelConfig {
329    let mut mc = ModelConfig {
330        model: request.model_id.clone(),
331        temperature: request.temperature,
332        max_tokens: request.max_tokens,
333        reasoning: request.reasoning,
334        system_prompt: Some(request.system_prompt.clone()),
335        dynamic_system_suffix: request.instructions.clone(),
336        tools: request
337            .tools
338            .iter()
339            .map(ToolDefinition::to_openai_json)
340            .collect(),
341        output_schema: request.output_schema.clone(),
342        ..Default::default()
343    };
344    // Effective context window (auto-fitted to memory / override / global).
345    if let Some(n) = num_ctx {
346        mc.set_backend_option("ollama".into(), "num_ctx".into(), n.to_string());
347    }
348    // Output cap: AUTO (max_tokens == 0) gets the full room num_ctx leaves
349    // after the prompt; an explicit max_tokens is an exact cap bounded by that
350    // room. Without a cap Ollama generates unbounded and only stops when the
351    // window fills — the truncation bug. `None` = AUTO with an unknown window →
352    // omit num_predict so Ollama applies its own default.
353    if let Some(num_predict) = default_ollama_num_predict(
354        request.max_tokens,
355        num_ctx,
356        estimate_prompt_tokens(request),
357        provider_max_output,
358    ) {
359        mc.set_backend_option(
360            "ollama".into(),
361            "num_predict".into(),
362            num_predict.to_string(),
363        );
364    }
365
366    // F11: forward Ollama hardware options from the user's app config (num_ctx is
367    // now handled above via the resolver).
368    if let Some(v) = app_config.ollama.num_gpu {
369        mc.set_backend_option("ollama".into(), "num_gpu".into(), v.to_string());
370    }
371    if let Some(v) = app_config.ollama.num_thread {
372        mc.set_backend_option("ollama".into(), "num_thread".into(), v.to_string());
373    }
374    if let Some(v) = app_config.ollama.numa {
375        mc.set_backend_option("ollama".into(), "numa".into(), v.to_string());
376    }
377    mc
378}
379
380/// Rough prompt-token estimate (≈4 chars/token) for bounding `num_predict`
381/// against the remaining room in `num_ctx`. Approximate by design — it only
382/// gates the output cap, never the prompt itself.
383fn estimate_prompt_tokens(request: &ChatRequest) -> usize {
384    let chars = request.system_prompt.len()
385        + request.instructions.as_deref().map_or(0, str::len)
386        + request
387            .messages
388            .iter()
389            .map(|m| m.content.len())
390            .sum::<usize>();
391    chars / 4
392}
393
394/// Load a cached probe from `provider_probes` (within TTL). Runs the blocking
395/// SQLite read off the async runtime. Best-effort: any failure → `None`.
396async fn load_probe_from_db(model: String) -> Option<OllamaModelInfo> {
397    tokio::task::spawn_blocking(move || {
398        let rec = mermaid_runtime::with_shared_store(|store| {
399            store
400                .provider_probes()
401                .get("ollama", &model, "context_probe")
402        })
403        .ok()??;
404        if probe_is_stale(&rec.probed_at) {
405            return None;
406        }
407        serde_json::from_str::<OllamaModelInfo>(&rec.capability_value).ok()
408    })
409    .await
410    .ok()
411    .flatten()
412}
413
414/// Persist a probe to `provider_probes` for subsequent sessions. Best-effort.
415async fn save_probe_to_db(model: String, info: OllamaModelInfo) {
416    let _ = tokio::task::spawn_blocking(move || -> Option<()> {
417        let value = serde_json::to_string(&info).ok()?;
418        mermaid_runtime::with_shared_store(|store| {
419            store.provider_probes().upsert(NewProviderProbe {
420                provider: "ollama".into(),
421                model_id: model,
422                capability_key: "context_probe".into(),
423                capability_value: value,
424                confidence: "probed".into(),
425                error: None,
426            })
427        })
428        .ok()?;
429        Some(())
430    })
431    .await;
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    #[test]
439    fn build_model_config_maps_request_fields() {
440        let req = ChatRequest {
441            model_id: "ollama/test".to_string(),
442            messages: vec![],
443            system_prompt: "sys".to_string(),
444            instructions: Some("instructions text".to_string()),
445            reasoning: mermaid_model::models::ReasoningLevel::High,
446            temperature: 0.3,
447            max_tokens: 2048,
448            tools: vec![],
449
450            ollama_num_ctx: None,
451            ollama_allow_ram_offload: None,
452            resolved_context_window: None,
453            resolved_max_output: None,
454            output_schema: None,
455            suppress_auto_compact: false,
456            suppressed_builtin_tools: Vec::new(),
457        };
458        let app_cfg = mermaid_domain::Config::default();
459        let cfg = build_model_config(&req, &app_cfg, None, None);
460        assert_eq!(cfg.model, "ollama/test");
461        assert_eq!(cfg.temperature, 0.3);
462        assert_eq!(cfg.max_tokens, 2048);
463        assert_eq!(cfg.reasoning, mermaid_model::models::ReasoningLevel::High);
464        assert_eq!(cfg.system_prompt.as_deref(), Some("sys"));
465        assert_eq!(
466            cfg.dynamic_system_suffix.as_deref(),
467            Some("instructions text")
468        );
469    }
470
471    /// F11 regression guard: Ollama hardware options in the user's app config
472    /// must land in the `ModelConfig`'s `backend_options` so the adapter's
473    /// `build_request_body` emits them under `options`. `num_ctx` now arrives via
474    /// the resolver param (not a direct config forward), and `num_predict` is
475    /// always derived.
476    #[test]
477    fn build_model_config_forwards_ollama_hardware_options() {
478        let req = ChatRequest {
479            model_id: "ollama/test".to_string(),
480            messages: vec![],
481            system_prompt: "sys".to_string(),
482            instructions: None,
483            reasoning: mermaid_model::models::ReasoningLevel::Medium,
484            temperature: 0.7,
485            max_tokens: 4096,
486            tools: vec![],
487
488            ollama_num_ctx: None,
489            ollama_allow_ram_offload: None,
490            resolved_context_window: None,
491            resolved_max_output: None,
492            output_schema: None,
493            suppress_auto_compact: false,
494            suppressed_builtin_tools: Vec::new(),
495        };
496        let mut app_cfg = mermaid_domain::Config::default();
497        app_cfg.ollama.num_gpu = Some(10);
498        app_cfg.ollama.num_thread = Some(8);
499        app_cfg.ollama.numa = Some(true);
500
501        // The effective num_ctx (8192) is passed in by the resolver.
502        let cfg = build_model_config(&req, &app_cfg, Some(8192), None);
503        let opts = cfg.ollama_options();
504        assert_eq!(opts.num_ctx, Some(8192));
505        assert_eq!(opts.num_gpu, Some(10));
506        assert_eq!(opts.num_thread, Some(8));
507        assert_eq!(opts.numa, Some(true));
508        assert!(opts.num_predict.is_some(), "num_predict is always derived");
509    }
510
511    /// An explicit `max_tokens` is an exact cap, bounded by the room left in
512    /// `num_ctx` (no reasoning reserve added — hard means hard).
513    #[test]
514    fn build_model_config_derives_num_predict() {
515        let req = ChatRequest {
516            model_id: "ollama/test".to_string(),
517            messages: vec![],
518            system_prompt: String::new(),
519            instructions: None,
520            reasoning: mermaid_model::models::ReasoningLevel::Max,
521            temperature: 0.7,
522            max_tokens: 4096,
523            tools: vec![],
524
525            ollama_num_ctx: None,
526            ollama_allow_ram_offload: None,
527            resolved_context_window: None,
528            resolved_max_output: None,
529            output_schema: None,
530            suppress_auto_compact: false,
531            suppressed_builtin_tools: Vec::new(),
532        };
533        let cfg = build_model_config(
534            &req,
535            &mermaid_domain::Config::default(),
536            Some(131_072),
537            None,
538        );
539        // Exactly the 4096 cap; plenty of room in a 131072 window.
540        assert_eq!(cfg.ollama_options().num_predict, Some(4_096));
541    }
542
543    /// The minimax incident, recomputed: a learned provider cap bounds the
544    /// AUTO `num_predict` below the full window room, so the retry (and every
545    /// later turn) sends a value Ollama Cloud accepts.
546    #[test]
547    fn build_model_config_caps_num_predict_at_learned_ceiling() {
548        let req = ChatRequest {
549            model_id: "ollama/minimax-m3:cloud".to_string(),
550            messages: vec![],
551            system_prompt: String::new(),
552            instructions: None,
553            reasoning: mermaid_model::models::ReasoningLevel::Medium,
554            temperature: 0.7,
555            max_tokens: 0, // AUTO — the incident's configuration
556            tools: vec![],
557
558            ollama_num_ctx: None,
559            ollama_allow_ram_offload: None,
560            resolved_context_window: None,
561            resolved_max_output: None,
562            output_schema: None,
563            suppress_auto_compact: false,
564            suppressed_builtin_tools: Vec::new(),
565        };
566        let app_cfg = mermaid_domain::Config::default();
567        // Without a learned cap, AUTO hands over the full window room —
568        // 524_288 minus margin — which Ollama Cloud 400s for minimax-m3.
569        let uncapped = build_model_config(&req, &app_cfg, Some(524_288), None);
570        assert!(uncapped.ollama_options().num_predict.unwrap() > 131_072);
571        // With the learned 131_072 cap, sizing stays at the ceiling.
572        let capped = build_model_config(&req, &app_cfg, Some(524_288), Some(131_072));
573        assert_eq!(capped.ollama_options().num_predict, Some(131_072));
574    }
575}