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