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;
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::from(request);
330    // Effective context window (auto-fitted to memory / override / global).
331    if let Some(n) = num_ctx {
332        mc.set_backend_option("ollama".into(), "num_ctx".into(), n.to_string());
333    }
334    // Output cap: AUTO (max_tokens == 0) gets the full room num_ctx leaves
335    // after the prompt; an explicit max_tokens is an exact cap bounded by that
336    // room. Without a cap Ollama generates unbounded and only stops when the
337    // window fills — the truncation bug. `None` = AUTO with an unknown window →
338    // omit num_predict so Ollama applies its own default.
339    if let Some(num_predict) = default_ollama_num_predict(
340        request.max_tokens,
341        num_ctx,
342        estimate_prompt_tokens(request),
343        provider_max_output,
344    ) {
345        mc.set_backend_option(
346            "ollama".into(),
347            "num_predict".into(),
348            num_predict.to_string(),
349        );
350    }
351
352    // F11: forward Ollama hardware options from the user's app config (num_ctx is
353    // now handled above via the resolver).
354    if let Some(v) = app_config.ollama.num_gpu {
355        mc.set_backend_option("ollama".into(), "num_gpu".into(), v.to_string());
356    }
357    if let Some(v) = app_config.ollama.num_thread {
358        mc.set_backend_option("ollama".into(), "num_thread".into(), v.to_string());
359    }
360    if let Some(v) = app_config.ollama.numa {
361        mc.set_backend_option("ollama".into(), "numa".into(), v.to_string());
362    }
363    mc
364}
365
366/// Rough prompt-token estimate (≈4 chars/token) for bounding `num_predict`
367/// against the remaining room in `num_ctx`. Approximate by design — it only
368/// gates the output cap, never the prompt itself.
369fn estimate_prompt_tokens(request: &ChatRequest) -> usize {
370    let chars = request.system_prompt.len()
371        + request.instructions.as_deref().map_or(0, str::len)
372        + request
373            .messages
374            .iter()
375            .map(|m| m.content.len())
376            .sum::<usize>();
377    chars / 4
378}
379
380/// Load a cached probe from `provider_probes` (within TTL). Runs the blocking
381/// SQLite read off the async runtime. Best-effort: any failure → `None`.
382async fn load_probe_from_db(model: String) -> Option<OllamaModelInfo> {
383    tokio::task::spawn_blocking(move || {
384        let rec = mermaid_runtime::with_shared_store(|store| {
385            store
386                .provider_probes()
387                .get("ollama", &model, "context_probe")
388        })
389        .ok()??;
390        if probe_is_stale(&rec.probed_at) {
391            return None;
392        }
393        serde_json::from_str::<OllamaModelInfo>(&rec.capability_value).ok()
394    })
395    .await
396    .ok()
397    .flatten()
398}
399
400/// Persist a probe to `provider_probes` for subsequent sessions. Best-effort.
401async fn save_probe_to_db(model: String, info: OllamaModelInfo) {
402    let _ = tokio::task::spawn_blocking(move || -> Option<()> {
403        let value = serde_json::to_string(&info).ok()?;
404        mermaid_runtime::with_shared_store(|store| {
405            store.provider_probes().upsert(NewProviderProbe {
406                provider: "ollama".into(),
407                model_id: model,
408                capability_key: "context_probe".into(),
409                capability_value: value,
410                confidence: "probed".into(),
411                error: None,
412            })
413        })
414        .ok()?;
415        Some(())
416    })
417    .await;
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    #[test]
425    fn build_model_config_maps_request_fields() {
426        let req = ChatRequest {
427            model_id: "ollama/test".to_string(),
428            messages: vec![],
429            system_prompt: "sys".to_string(),
430            instructions: Some("instructions text".to_string()),
431            reasoning: mermaid_model::models::ReasoningLevel::High,
432            temperature: 0.3,
433            max_tokens: 2048,
434            tools: vec![],
435
436            ollama_num_ctx: None,
437            ollama_allow_ram_offload: None,
438            resolved_context_window: None,
439            resolved_max_output: None,
440            output_schema: None,
441            suppress_auto_compact: false,
442            suppressed_builtin_tools: Vec::new(),
443        };
444        let app_cfg = mermaid_domain::Config::default();
445        let cfg = build_model_config(&req, &app_cfg, None, None);
446        assert_eq!(cfg.model, "ollama/test");
447        assert_eq!(cfg.temperature, 0.3);
448        assert_eq!(cfg.max_tokens, 2048);
449        assert_eq!(cfg.reasoning, mermaid_model::models::ReasoningLevel::High);
450        assert_eq!(cfg.system_prompt.as_deref(), Some("sys"));
451        assert_eq!(
452            cfg.dynamic_system_suffix.as_deref(),
453            Some("instructions text")
454        );
455    }
456
457    /// F11 regression guard: Ollama hardware options in the user's app config
458    /// must land in the `ModelConfig`'s `backend_options` so the adapter's
459    /// `build_request_body` emits them under `options`. `num_ctx` now arrives via
460    /// the resolver param (not a direct config forward), and `num_predict` is
461    /// always derived.
462    #[test]
463    fn build_model_config_forwards_ollama_hardware_options() {
464        let req = ChatRequest {
465            model_id: "ollama/test".to_string(),
466            messages: vec![],
467            system_prompt: "sys".to_string(),
468            instructions: None,
469            reasoning: mermaid_model::models::ReasoningLevel::Medium,
470            temperature: 0.7,
471            max_tokens: 4096,
472            tools: vec![],
473
474            ollama_num_ctx: None,
475            ollama_allow_ram_offload: None,
476            resolved_context_window: None,
477            resolved_max_output: None,
478            output_schema: None,
479            suppress_auto_compact: false,
480            suppressed_builtin_tools: Vec::new(),
481        };
482        let mut app_cfg = mermaid_domain::Config::default();
483        app_cfg.ollama.num_gpu = Some(10);
484        app_cfg.ollama.num_thread = Some(8);
485        app_cfg.ollama.numa = Some(true);
486
487        // The effective num_ctx (8192) is passed in by the resolver.
488        let cfg = build_model_config(&req, &app_cfg, Some(8192), None);
489        let opts = cfg.ollama_options();
490        assert_eq!(opts.num_ctx, Some(8192));
491        assert_eq!(opts.num_gpu, Some(10));
492        assert_eq!(opts.num_thread, Some(8));
493        assert_eq!(opts.numa, Some(true));
494        assert!(opts.num_predict.is_some(), "num_predict is always derived");
495    }
496
497    /// An explicit `max_tokens` is an exact cap, bounded by the room left in
498    /// `num_ctx` (no reasoning reserve added — hard means hard).
499    #[test]
500    fn build_model_config_derives_num_predict() {
501        let req = ChatRequest {
502            model_id: "ollama/test".to_string(),
503            messages: vec![],
504            system_prompt: String::new(),
505            instructions: None,
506            reasoning: mermaid_model::models::ReasoningLevel::Max,
507            temperature: 0.7,
508            max_tokens: 4096,
509            tools: vec![],
510
511            ollama_num_ctx: None,
512            ollama_allow_ram_offload: None,
513            resolved_context_window: None,
514            resolved_max_output: None,
515            output_schema: None,
516            suppress_auto_compact: false,
517            suppressed_builtin_tools: Vec::new(),
518        };
519        let cfg = build_model_config(
520            &req,
521            &mermaid_domain::Config::default(),
522            Some(131_072),
523            None,
524        );
525        // Exactly the 4096 cap; plenty of room in a 131072 window.
526        assert_eq!(cfg.ollama_options().num_predict, Some(4_096));
527    }
528
529    /// The minimax incident, recomputed: a learned provider cap bounds the
530    /// AUTO `num_predict` below the full window room, so the retry (and every
531    /// later turn) sends a value Ollama Cloud accepts.
532    #[test]
533    fn build_model_config_caps_num_predict_at_learned_ceiling() {
534        let req = ChatRequest {
535            model_id: "ollama/minimax-m3:cloud".to_string(),
536            messages: vec![],
537            system_prompt: String::new(),
538            instructions: None,
539            reasoning: mermaid_model::models::ReasoningLevel::Medium,
540            temperature: 0.7,
541            max_tokens: 0, // AUTO — the incident's configuration
542            tools: vec![],
543
544            ollama_num_ctx: None,
545            ollama_allow_ram_offload: None,
546            resolved_context_window: None,
547            resolved_max_output: None,
548            output_schema: None,
549            suppress_auto_compact: false,
550            suppressed_builtin_tools: Vec::new(),
551        };
552        let app_cfg = mermaid_domain::Config::default();
553        // Without a learned cap, AUTO hands over the full window room —
554        // 524_288 minus margin — which Ollama Cloud 400s for minimax-m3.
555        let uncapped = build_model_config(&req, &app_cfg, Some(524_288), None);
556        assert!(uncapped.ollama_options().num_predict.unwrap() > 131_072);
557        // With the learned 131_072 cap, sizing stays at the ceiling.
558        let capped = build_model_config(&req, &app_cfg, Some(524_288), Some(131_072));
559        assert_eq!(capped.ollama_options().num_predict, Some(131_072));
560    }
561}