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