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, RejectionCache, learn_output_cap,
26    load_limits_from_db, 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    /// Links the adapter's learned rejections to the cross-session cache.
35    rejections: RejectionCache,
36    /// Shared app `Config` so `build_model_config` can read Ollama
37    /// hardware options (`num_ctx`, `num_gpu`, `num_thread`, `numa`) at
38    /// call time. Before F11 these were silently dropped because the
39    /// wrapper built `ModelConfig` only from `ChatRequest` fields.
40    config: Arc<mermaid_domain::Config>,
41    /// Cached `/api/show` probe (context window + dims + weight). Filled once per
42    /// process per model — the provider itself is cached by `ProviderFactory`, so
43    /// this fires a single network probe per model. Backed by the cross-session
44    /// `provider_probes` table. Probe *failures* are not cached (left empty for a
45    /// cheap retry next turn).
46    ctx_cell: tokio::sync::OnceCell<OllamaModelInfo>,
47}
48
49impl OllamaProvider {
50    /// Backward-compatible constructor that uses a default app config.
51    /// Call `with_app_config` instead when you have one available so
52    /// Ollama hardware options actually reach the adapter.
53    ///
54    /// # Errors
55    ///
56    /// Only [`Self::with_app_config`]'s.
57    pub async fn new(model_name: &str, backend: Arc<BackendConfig>) -> Result<Self> {
58        Self::with_app_config(
59            model_name,
60            backend,
61            Arc::new(mermaid_domain::Config::default()),
62        )
63        .await
64    }
65
66    /// Construct with an explicit `domain::Config` reference. Used by
67    /// `ProviderFactory::build_provider` so `config.ollama.{num_gpu,
68    /// num_ctx, num_thread, numa}` make it into the Ollama request's
69    /// `options` block.
70    ///
71    /// # Errors
72    ///
73    /// Only [`OllamaAdapter::new`]'s — the HTTP client build. The server is
74    /// not contacted here: the model probe is lazy, so a stopped Ollama still
75    /// constructs, and with `ollama_autostart` set the recovery hook revives
76    /// it on the first request instead.
77    pub async fn with_app_config(
78        model_name: &str,
79        backend: Arc<BackendConfig>,
80        config: Arc<mermaid_domain::Config>,
81    ) -> Result<Self> {
82        let autostart = backend.ollama_autostart;
83        let adapter = OllamaAdapter::new(model_name, backend).await?;
84        // The chat path is an *intent* path: a dead local server is mermaid's
85        // problem, not the user's, so hand the adapter the means to revive one.
86        // Enumeration verbs deliberately construct the adapter without this —
87        // see `LocalServerRecovery`.
88        let adapter = if autostart {
89            adapter.with_recovery(Arc::new(crate::ollama::OllamaAutostart))
90        } else {
91            adapter
92        };
93        let capabilities = adapter.capabilities().clone();
94        Ok(Self {
95            adapter,
96            capabilities,
97            rejections: RejectionCache::default(),
98            config,
99            ctx_cell: tokio::sync::OnceCell::new(),
100        })
101    }
102
103    /// Probe the model's capabilities, cache-first. In-process `OnceCell` backed
104    /// by the cross-session `provider_probes` table; failures aren't cached.
105    async fn probe(&self) -> Option<OllamaModelInfo> {
106        self.ctx_cell
107            .get_or_try_init(|| async { self.load_probe().await.ok_or(()) })
108            .await
109            .ok()
110            .cloned()
111    }
112
113    async fn load_probe(&self) -> Option<OllamaModelInfo> {
114        let model = self.adapter.name().to_string();
115        if let Some(info) = load_probe_from_db(model.clone()).await {
116            return Some(info);
117        }
118        let info = self.adapter.show_model_info().await?;
119        save_probe_to_db(model, info.clone()).await;
120        Some(info)
121    }
122
123    /// Assemble the sizing inputs from the probe, host memory, config, and the
124    /// request's per-model override. Built here and in the effect layer from the
125    /// same (cached) sources so the effective window can't diverge between the
126    /// request and compaction.
127    async fn num_ctx_inputs(
128        &self,
129        info: &OllamaModelInfo,
130        override_num_ctx: Option<u32>,
131        override_offload: Option<bool>,
132    ) -> NumCtxInputs {
133        // Live `/context offload` toggle wins; otherwise the persisted default.
134        // The provider's `config` is frozen at startup (the factory never
135        // rebuilds), so the toggle rides on the request instead of `config`.
136        let allow_ram_offload = override_offload.unwrap_or(self.config.ollama.allow_ram_offload);
137        // Only fetch the budget we'll actually use: VRAM when keeping the model
138        // on the GPU (default), system RAM when offload is allowed.
139        let (vram_bytes, system_ram_bytes) = if allow_ram_offload {
140            (None, mermaid_model::utils::system_ram_bytes())
141        } else {
142            (mermaid_model::utils::gpu_vram_bytes().await, None)
143        };
144        NumCtxInputs {
145            model_max: info.context_length,
146            dims: info.dims,
147            model_weight_bytes: info.weight_bytes,
148            per_model_override: override_num_ctx,
149            global_num_ctx: self.config.ollama.num_ctx,
150            allow_ram_offload,
151            vram_bytes,
152            system_ram_bytes,
153            max_auto_cap: self.config.ollama.max_auto_num_ctx,
154            is_cloud: crate::ollama::is_cloud_model(self.adapter.name()),
155        }
156    }
157}
158
159#[async_trait]
160impl ModelProvider for OllamaProvider {
161    fn capabilities(&self) -> &ModelCapabilities {
162        &self.capabilities
163    }
164
165    async fn resolve_context_window(&self, request: &ChatRequest) -> ContextSizing {
166        let info = self.probe().await.unwrap_or_default();
167        let inputs = self
168            .num_ctx_inputs(
169                &info,
170                request.ollama_num_ctx,
171                request.ollama_allow_ram_offload,
172            )
173            .await;
174        let model_max = inputs.model_max;
175        // Local Ollama imposes no per-response ceiling (num_predict is ours),
176        // but Ollama Cloud maps num_predict → max_tokens and 400s above the
177        // model's real cap. A cap learned from such a 400 lives in the limits
178        // cache; feed it forward so sizing computes min(window room, cap).
179        let max_output =
180            load_limits_from_db("ollama".to_string(), Model::name(&self.adapter).to_string())
181                .await
182                .and_then(|l| l.max_output_tokens);
183        match resolve_ollama_num_ctx(&inputs) {
184            Some(r) => ContextSizing {
185                model_max,
186                effective: Some(r.value),
187                source: Some(r.source),
188                max_output,
189                compacts_natively: false,
190            },
191            // No model_max and nothing configured → omit num_ctx (Ollama default).
192            None => ContextSizing {
193                model_max,
194                effective: None,
195                source: None,
196                max_output,
197                compacts_natively: false,
198            },
199        }
200    }
201
202    async fn verify_placement(&self, current_num_ctx: Option<usize>) -> Option<ModelPlacement> {
203        let (vram, total) = self.adapter.model_placement().await?;
204        // A zero total means Ollama reported the model but not its footprint —
205        // can't judge placement, so leave it unknown rather than guess.
206        if total == 0 {
207            return None;
208        }
209        // On a spill, compute the largest num_ctx that would fit, from the model's
210        // KV cost (probe dims) and the *measured* overflow — the auto-converge
211        // target. `None` if it already fits or shrinking can't help.
212        let suggested_num_ctx = if vram < total {
213            let info = self.probe().await.unwrap_or_default();
214            current_num_ctx
215                .zip(info.dims)
216                .and_then(|(current, dims)| {
217                    let kv = kv_bytes_per_token(&dims)?;
218                    converge_num_ctx(current, vram, total, kv)
219                })
220                .map(|n| n as u32)
221        } else {
222            None
223        };
224        Some(ModelPlacement {
225            size_vram_bytes: vram,
226            total_bytes: total,
227            suggested_num_ctx,
228        })
229    }
230
231    async fn supports_vision(&self) -> Option<bool> {
232        Some(self.adapter.vision_supported().await)
233    }
234
235    async fn chat(&self, request: ChatRequest, ctx: StreamContext) -> Result<FinalResponse> {
236        // Resolve the effective window first (cache-first probe). Idempotent with
237        // the effect layer's call — both read the same cached probe + memory, so
238        // what we send as num_ctx matches what compaction assumes.
239        let sizing = self.resolve_context_window(&request).await;
240        let config =
241            build_model_config(&request, &self.config, sizing.effective, sizing.max_output);
242        // Race adapter.chat against the cancellation token. When cancelled,
243        // this future is dropped and the adapter's stream loop goes with it —
244        // the adapter never has to know anything about turn IDs.
245        let chat_fut = async {
246            match self
247                .adapter
248                .chat(&request.messages, &config, Some(ctx.sink.clone()))
249                .await
250            {
251                Ok(response) => Ok(response),
252                Err(err) => {
253                    // Learn-from-400: Ollama Cloud rejects a num_predict above
254                    // the model's real output cap and names the cap in the
255                    // body. Learn it (persisted — later turns size below it up
256                    // front), clamp, retry ONCE. A request that 400s streamed
257                    // no events, so the retry starts from a clean stream.
258                    let Some(cap) = output_cap_from_error(&err) else {
259                        return Err(err);
260                    };
261                    let sent = config
262                        .ollama_options()
263                        .num_predict
264                        .map_or(0, |v| v.max(0) as usize);
265                    if retry_cap(sent, cap).is_none() {
266                        return Err(err);
267                    }
268                    let model = Model::name(&self.adapter).to_string();
269                    learn_output_cap("ollama".to_string(), model.clone(), cap).await;
270                    let _ = ctx.sink.send(StreamEvent::Status(format!(
271                        "{model} rejected the output budget; learned its {cap}-token cap and retrying"
272                    ))).await;
273                    let retry_config =
274                        build_model_config(&request, &self.config, sizing.effective, Some(cap));
275                    self.adapter
276                        .chat(&request.messages, &retry_config, Some(ctx.sink.clone()))
277                        .await
278                },
279            }
280        };
281
282        // Seed what earlier sessions learned this model rejects, and persist
283        // whatever this turn learns (see `learning`).
284        let learned_model = Model::name(&self.adapter).to_string();
285        let memory = self.adapter.param_memory();
286        self.rejections.seed("ollama", &learned_model, memory).await;
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 => {
299                self.rejections.persist("ollama", &learned_model, memory).await;
300                r?
301            },
302        };
303
304        // F3: the wrapper's `Done` is the sole terminal event — the adapter
305        // never emits one. Carrying `provider_continuation` out of
306        // `ModelResponse` here is what lets multi-turn extended thinking
307        // round-trip. It goes on the same sink the adapter just finished
308        // writing to, so it cannot overtake a still-queued ToolCall.
309        let usage = response.usage.clone();
310        let provider_continuation = response.provider_continuation.clone();
311        let stop_reason = response.stop_reason.clone();
312        let _ = ctx
313            .sink
314            .send(StreamEvent::Done {
315                usage: usage.clone(),
316                provider_continuation: provider_continuation.clone(),
317                stop_reason: stop_reason.clone(),
318            })
319            .await;
320
321        Ok(FinalResponse {
322            usage,
323            provider_continuation,
324            tool_calls: response.tool_calls.unwrap_or_default(),
325            stop_reason,
326        })
327    }
328}
329
330// ─── helpers ────────────────────────────────────────────────────────
331
332/// `num_ctx` is the resolved effective window (auto-fitted, or override/global);
333/// passing it here keeps a single source of truth — the direct `config.ollama.
334/// num_ctx` forward is gone because the resolver already considered it.
335/// `provider_max_output` is a known per-response ceiling (learned from an
336/// Ollama Cloud 400), bounding the derived `num_predict`.
337fn build_model_config(
338    request: &ChatRequest,
339    app_config: &mermaid_domain::Config,
340    num_ctx: Option<usize>,
341    provider_max_output: Option<usize>,
342) -> ModelConfig {
343    let mut mc = ModelConfig::from(request);
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            requested_compaction: None,
457            native_compaction: None,
458            native_tools: mermaid_model::models::NativeTools::default(),
459        };
460        let app_cfg = mermaid_domain::Config::default();
461        let cfg = build_model_config(&req, &app_cfg, None, None);
462        assert_eq!(cfg.model, "ollama/test");
463        assert_eq!(cfg.temperature, 0.3);
464        assert_eq!(cfg.max_tokens, 2048);
465        assert_eq!(cfg.reasoning, mermaid_model::models::ReasoningLevel::High);
466        assert_eq!(cfg.system_prompt.as_deref(), Some("sys"));
467        assert_eq!(
468            cfg.dynamic_system_suffix.as_deref(),
469            Some("instructions text")
470        );
471    }
472
473    /// F11 regression guard: Ollama hardware options in the user's app config
474    /// must land in the `ModelConfig`'s `backend_options` so the adapter's
475    /// `build_request_body` emits them under `options`. `num_ctx` now arrives via
476    /// the resolver param (not a direct config forward), and `num_predict` is
477    /// always derived.
478    #[test]
479    fn build_model_config_forwards_ollama_hardware_options() {
480        let req = ChatRequest {
481            model_id: "ollama/test".to_string(),
482            messages: vec![],
483            system_prompt: "sys".to_string(),
484            instructions: None,
485            reasoning: mermaid_model::models::ReasoningLevel::Medium,
486            temperature: 0.7,
487            max_tokens: 4096,
488            tools: vec![],
489
490            ollama_num_ctx: None,
491            ollama_allow_ram_offload: None,
492            resolved_context_window: None,
493            resolved_max_output: None,
494            output_schema: None,
495            suppress_auto_compact: false,
496            requested_compaction: None,
497            native_compaction: None,
498            native_tools: mermaid_model::models::NativeTools::default(),
499        };
500        let mut app_cfg = mermaid_domain::Config::default();
501        app_cfg.ollama.num_gpu = Some(10);
502        app_cfg.ollama.num_thread = Some(8);
503        app_cfg.ollama.numa = Some(true);
504
505        // The effective num_ctx (8192) is passed in by the resolver.
506        let cfg = build_model_config(&req, &app_cfg, Some(8192), None);
507        let opts = cfg.ollama_options();
508        assert_eq!(opts.num_ctx, Some(8192));
509        assert_eq!(opts.num_gpu, Some(10));
510        assert_eq!(opts.num_thread, Some(8));
511        assert_eq!(opts.numa, Some(true));
512        assert!(opts.num_predict.is_some(), "num_predict is always derived");
513    }
514
515    /// An explicit `max_tokens` is an exact cap, bounded by the room left in
516    /// `num_ctx` (no reasoning reserve added — hard means hard).
517    #[test]
518    fn build_model_config_derives_num_predict() {
519        let req = ChatRequest {
520            model_id: "ollama/test".to_string(),
521            messages: vec![],
522            system_prompt: String::new(),
523            instructions: None,
524            reasoning: mermaid_model::models::ReasoningLevel::Max,
525            temperature: 0.7,
526            max_tokens: 4096,
527            tools: vec![],
528
529            ollama_num_ctx: None,
530            ollama_allow_ram_offload: None,
531            resolved_context_window: None,
532            resolved_max_output: None,
533            output_schema: None,
534            suppress_auto_compact: false,
535            requested_compaction: None,
536            native_compaction: None,
537            native_tools: mermaid_model::models::NativeTools::default(),
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            requested_compaction: None,
571            native_compaction: None,
572            native_tools: mermaid_model::models::NativeTools::default(),
573        };
574        let app_cfg = mermaid_domain::Config::default();
575        // Without a learned cap, AUTO hands over the full window room —
576        // 524_288 minus margin — which Ollama Cloud 400s for minimax-m3.
577        let uncapped = build_model_config(&req, &app_cfg, Some(524_288), None);
578        assert!(uncapped.ollama_options().num_predict.unwrap() > 131_072);
579        // With the learned 131_072 cap, sizing stays at the ceiling.
580        let capped = build_model_config(&req, &app_cfg, Some(524_288), Some(131_072));
581        assert_eq!(capped.ollama_options().num_predict, Some(131_072));
582    }
583}