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::{
21    BackendConfig, Model, ModelConfig, ModelError, ReasoningChunk, Result, StreamCallback,
22    StreamEvent as ModelStreamEvent,
23};
24use crate::runtime::{NewProviderProbe, RuntimeStore};
25
26use super::super::capabilities::Capabilities;
27use super::super::ctx::{FinalResponse, StreamContext, StreamEvent};
28use super::{ContextSizing, ModelPlacement, ModelProvider};
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        match resolve_ollama_num_ctx(&inputs) {
147            Some(r) => ContextSizing {
148                model_max,
149                effective: Some(r.value),
150                source: Some(r.source),
151            },
152            // No model_max and nothing configured → omit num_ctx (Ollama default).
153            None => ContextSizing {
154                model_max,
155                effective: None,
156                source: None,
157            },
158        }
159    }
160
161    async fn verify_placement(&self, current_num_ctx: Option<usize>) -> Option<ModelPlacement> {
162        let (vram, total) = self.adapter.model_placement().await?;
163        // A zero total means Ollama reported the model but not its footprint —
164        // can't judge placement, so leave it unknown rather than guess.
165        if total == 0 {
166            return None;
167        }
168        // On a spill, compute the largest num_ctx that would fit, from the model's
169        // KV cost (probe dims) and the *measured* overflow — the auto-converge
170        // target. `None` if it already fits or shrinking can't help.
171        let suggested_num_ctx = if vram < total {
172            let info = self.probe().await.unwrap_or_default();
173            current_num_ctx
174                .zip(info.dims)
175                .and_then(|(current, dims)| {
176                    let kv = kv_bytes_per_token(&dims)?;
177                    converge_num_ctx(current, vram, total, kv)
178                })
179                .map(|n| n as u32)
180        } else {
181            None
182        };
183        Some(ModelPlacement {
184            size_vram_bytes: vram,
185            total_bytes: total,
186            suggested_num_ctx,
187        })
188    }
189
190    async fn chat(&self, request: ChatRequest, ctx: StreamContext) -> Result<FinalResponse> {
191        // Resolve the effective window first (cache-first probe). Idempotent with
192        // the effect layer's call — both read the same cached probe + memory, so
193        // what we send as num_ctx matches what compaction assumes.
194        let effective = self.resolve_context_window(&request).await.effective;
195        let config = build_model_config(&request, &self.config, effective);
196        // Ordered relay (F2): the adapter's sync callback pushes into an
197        // `UnboundedSender` (synchronous, FIFO). A single relay task drains
198        // into the bounded sink in order, avoiding the per-event `tokio::
199        // spawn` race that could deliver `Done` before prior tool calls.
200        let (relay_tx, relay_handle) = super::stream_bridge::ordered_relay(ctx.sink.clone());
201        let callback = stream_callback_for(relay_tx.clone());
202
203        // Race adapter.chat against the cancellation token. When
204        // cancelled, the adapter's stream loop observes the sink
205        // closing (we drop `callback`) and exits at its next await.
206        // This is the crucial structural win vs. the old
207        // `check_interrupt` polling: the adapter doesn't need to
208        // know anything about turn IDs — the sink either drains or
209        // doesn't, and the tokens handle everything else.
210        let chat_fut = self
211            .adapter
212            .chat(&request.messages, &config, Some(callback));
213
214        let response = tokio::select! {
215            biased;
216            _ = ctx.token.cancelled() => {
217                // Terminal event for a cancelled turn comes from the
218                // runner's `drop_scope` once the `TurnScope` drains, so
219                // we neither emit `StreamEvent::Done` here nor surface
220                // an `UpstreamError`. `ModelError::Cancelled` is the
221                // sentinel the runner swallows.
222                return Err(ModelError::Cancelled);
223            },
224            r = chat_fut => r?,
225        };
226
227        // F3: the wrapper's `Done` is now the sole terminal event —
228        // the adapter no longer emits one from the callback. Carrying
229        // `thinking_signature` out of `ModelResponse` here is what
230        // lets multi-turn extended thinking round-trip.
231        let usage = response.usage.clone();
232        let thinking_signature = response.thinking_signature.clone();
233        let stop_reason = response.stop_reason.clone();
234        // Terminal Done through the ordered relay, then drain (see stream_bridge).
235        let _ = relay_tx.send(StreamEvent::Done {
236            usage: usage.clone(),
237            thinking_signature: thinking_signature.clone(),
238            stop_reason: stop_reason.clone(),
239        });
240        drop(relay_tx);
241        let _ = relay_handle.await;
242
243        Ok(FinalResponse {
244            usage,
245            thinking_signature,
246            tool_calls: response.tool_calls.unwrap_or_default(),
247            stop_reason,
248        })
249    }
250}
251
252// ─── helpers ────────────────────────────────────────────────────────
253
254/// `num_ctx` is the resolved effective window (auto-fitted, or override/global);
255/// passing it here keeps a single source of truth — the direct `config.ollama.
256/// num_ctx` forward is gone because the resolver already considered it.
257fn build_model_config(
258    request: &ChatRequest,
259    app_config: &crate::app::Config,
260    num_ctx: Option<usize>,
261) -> ModelConfig {
262    let mut mc = ModelConfig {
263        model: request.model_id.clone(),
264        temperature: request.temperature,
265        max_tokens: request.max_tokens,
266        reasoning: request.reasoning,
267        system_prompt: Some(request.system_prompt.clone()),
268        dynamic_system_suffix: request.instructions.clone(),
269        tools: request.tools.iter().map(|t| t.to_openai_json()).collect(),
270        ..Default::default()
271    };
272    // Effective context window (auto-fitted to memory / override / global).
273    if let Some(n) = num_ctx {
274        mc.set_backend_option("ollama".into(), "num_ctx".into(), n.to_string());
275    }
276    // Output cap: max_tokens + reasoning headroom, bounded by the room left in
277    // num_ctx. Without this Ollama generates unbounded and only stops when the
278    // window fills — the truncation bug.
279    let num_predict = default_ollama_num_predict(
280        request.max_tokens,
281        request.reasoning,
282        num_ctx,
283        estimate_prompt_tokens(request),
284    );
285    mc.set_backend_option(
286        "ollama".into(),
287        "num_predict".into(),
288        num_predict.to_string(),
289    );
290
291    // F11: forward Ollama hardware options from the user's app config (num_ctx is
292    // now handled above via the resolver).
293    if let Some(v) = app_config.ollama.num_gpu {
294        mc.set_backend_option("ollama".into(), "num_gpu".into(), v.to_string());
295    }
296    if let Some(v) = app_config.ollama.num_thread {
297        mc.set_backend_option("ollama".into(), "num_thread".into(), v.to_string());
298    }
299    if let Some(v) = app_config.ollama.numa {
300        mc.set_backend_option("ollama".into(), "numa".into(), v.to_string());
301    }
302    mc
303}
304
305/// Rough prompt-token estimate (≈4 chars/token) for bounding `num_predict`
306/// against the remaining room in `num_ctx`. Approximate by design — it only
307/// gates the output cap, never the prompt itself.
308fn estimate_prompt_tokens(request: &ChatRequest) -> usize {
309    let chars = request.system_prompt.len()
310        + request.instructions.as_deref().map_or(0, str::len)
311        + request
312            .messages
313            .iter()
314            .map(|m| m.content.len())
315            .sum::<usize>();
316    chars / 4
317}
318
319/// Load a cached probe from `provider_probes` (within TTL). Runs the blocking
320/// SQLite read off the async runtime. Best-effort: any failure → `None`.
321async fn load_probe_from_db(model: String) -> Option<OllamaModelInfo> {
322    tokio::task::spawn_blocking(move || {
323        let store = RuntimeStore::open_default().ok()?;
324        let rec = store
325            .provider_probes()
326            .get("ollama", &model, "context_probe")
327            .ok()??;
328        if probe_is_stale(&rec.probed_at) {
329            return None;
330        }
331        serde_json::from_str::<OllamaModelInfo>(&rec.capability_value).ok()
332    })
333    .await
334    .ok()
335    .flatten()
336}
337
338/// Persist a probe to `provider_probes` for subsequent sessions. Best-effort.
339async fn save_probe_to_db(model: String, info: OllamaModelInfo) {
340    let _ = tokio::task::spawn_blocking(move || -> Option<()> {
341        let value = serde_json::to_string(&info).ok()?;
342        let store = RuntimeStore::open_default().ok()?;
343        store
344            .provider_probes()
345            .upsert(NewProviderProbe {
346                provider: "ollama".into(),
347                model_id: model,
348                capability_key: "context_probe".into(),
349                capability_value: value,
350                confidence: "probed".into(),
351                error: None,
352            })
353            .ok()?;
354        Some(())
355    })
356    .await;
357}
358
359fn probe_is_stale(probed_at: &str) -> bool {
360    use chrono::{DateTime, Utc};
361    match DateTime::parse_from_rfc3339(probed_at) {
362        Ok(t) => {
363            Utc::now()
364                .signed_duration_since(t.with_timezone(&Utc))
365                .num_days()
366                >= crate::constants::OLLAMA_PROBE_TTL_DAYS
367        },
368        // Unparseable timestamp → treat as stale and re-probe.
369        Err(_) => true,
370    }
371}
372
373/// Build a `StreamCallback` that forwards `ModelStreamEvent`s from the
374/// adapter into an `UnboundedSender<StreamEvent>` (ordered relay). The
375/// caller wires that sender to a bounded sink via
376/// `stream_bridge::ordered_relay`; this keeps event delivery FIFO even
377/// though the adapter calls us from a sync context.
378fn stream_callback_for(sink: tokio::sync::mpsc::UnboundedSender<StreamEvent>) -> StreamCallback {
379    Arc::new(move |event: ModelStreamEvent| {
380        let mapped = match event {
381            ModelStreamEvent::Text(s) => StreamEvent::Text(s),
382            ModelStreamEvent::Reasoning(chunk) => StreamEvent::Reasoning(ReasoningChunk {
383                text: chunk.text,
384                signature: chunk.signature,
385            }),
386            ModelStreamEvent::ToolCall(tc) => StreamEvent::ToolCall(tc),
387            ModelStreamEvent::Done { tokens } => StreamEvent::Done {
388                usage: if tokens > 0 {
389                    Some(crate::models::TokenUsage::provider(0, tokens, tokens))
390                } else {
391                    None
392                },
393                thinking_signature: None,
394                stop_reason: None,
395            },
396        };
397        // Synchronous send preserves ordering. Ignore errors — the
398        // receiver has closed means the turn is already gone.
399        let _ = sink.send(mapped);
400    })
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    #[test]
408    fn build_model_config_maps_request_fields() {
409        let req = ChatRequest {
410            model_id: "ollama/test".to_string(),
411            messages: vec![],
412            system_prompt: "sys".to_string(),
413            instructions: Some("instructions text".to_string()),
414            reasoning: crate::models::ReasoningLevel::High,
415            temperature: 0.3,
416            max_tokens: 2048,
417            tools: vec![],
418
419            ollama_num_ctx: None,
420            ollama_allow_ram_offload: None,
421        };
422        let app_cfg = crate::app::Config::default();
423        let cfg = build_model_config(&req, &app_cfg, None);
424        assert_eq!(cfg.model, "ollama/test");
425        assert_eq!(cfg.temperature, 0.3);
426        assert_eq!(cfg.max_tokens, 2048);
427        assert_eq!(cfg.reasoning, crate::models::ReasoningLevel::High);
428        assert_eq!(cfg.system_prompt.as_deref(), Some("sys"));
429        assert_eq!(
430            cfg.dynamic_system_suffix.as_deref(),
431            Some("instructions text")
432        );
433    }
434
435    /// F11 regression guard: Ollama hardware options in the user's app config
436    /// must land in the ModelConfig's backend_options so the adapter's
437    /// `build_request_body` emits them under `options`. `num_ctx` now arrives via
438    /// the resolver param (not a direct config forward), and `num_predict` is
439    /// always derived.
440    #[test]
441    fn build_model_config_forwards_ollama_hardware_options() {
442        let req = ChatRequest {
443            model_id: "ollama/test".to_string(),
444            messages: vec![],
445            system_prompt: "sys".to_string(),
446            instructions: None,
447            reasoning: crate::models::ReasoningLevel::Medium,
448            temperature: 0.7,
449            max_tokens: 4096,
450            tools: vec![],
451
452            ollama_num_ctx: None,
453            ollama_allow_ram_offload: None,
454        };
455        let mut app_cfg = crate::app::Config::default();
456        app_cfg.ollama.num_gpu = Some(10);
457        app_cfg.ollama.num_thread = Some(8);
458        app_cfg.ollama.numa = Some(true);
459
460        // The effective num_ctx (8192) is passed in by the resolver.
461        let cfg = build_model_config(&req, &app_cfg, Some(8192));
462        let opts = cfg.ollama_options();
463        assert_eq!(opts.num_ctx, Some(8192));
464        assert_eq!(opts.num_gpu, Some(10));
465        assert_eq!(opts.num_thread, Some(8));
466        assert_eq!(opts.numa, Some(true));
467        assert!(opts.num_predict.is_some(), "num_predict is always derived");
468    }
469
470    /// `num_predict` is derived from max_tokens + the reasoning headroom, bounded
471    /// by the room left in num_ctx.
472    #[test]
473    fn build_model_config_derives_num_predict() {
474        let req = ChatRequest {
475            model_id: "ollama/test".to_string(),
476            messages: vec![],
477            system_prompt: String::new(),
478            instructions: None,
479            reasoning: crate::models::ReasoningLevel::Max,
480            temperature: 0.7,
481            max_tokens: 4096,
482            tools: vec![],
483
484            ollama_num_ctx: None,
485            ollama_allow_ram_offload: None,
486        };
487        let cfg = build_model_config(&req, &crate::app::Config::default(), Some(131_072));
488        // 4096 + 8192 (Max reserve), plenty of room in a 131072 window.
489        assert_eq!(cfg.ollama_options().num_predict, Some(12_288));
490    }
491
492    #[tokio::test]
493    async fn stream_callback_forwards_text_event() {
494        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
495        let cb = stream_callback_for(tx);
496        cb(ModelStreamEvent::Text("hello".to_string()));
497        let recv = tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv())
498            .await
499            .expect("recv")
500            .expect("sender alive");
501        match recv {
502            StreamEvent::Text(s) => assert_eq!(s, "hello"),
503            _ => panic!("wrong variant"),
504        }
505    }
506
507    #[tokio::test]
508    async fn stream_callback_forwards_done_with_tokens() {
509        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
510        let cb = stream_callback_for(tx);
511        cb(ModelStreamEvent::Done { tokens: 42 });
512        let recv = tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv())
513            .await
514            .expect("recv")
515            .expect("sender");
516        match recv {
517            StreamEvent::Done { usage, .. } => {
518                let u = usage.expect("tokens > 0 → Some");
519                assert_eq!(u.total_tokens, 42);
520            },
521            _ => panic!("wrong variant"),
522        }
523    }
524
525    #[tokio::test]
526    async fn stream_callback_done_zero_tokens_is_none_usage() {
527        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
528        let cb = stream_callback_for(tx);
529        cb(ModelStreamEvent::Done { tokens: 0 });
530        let recv = tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv())
531            .await
532            .expect("recv")
533            .expect("sender");
534        match recv {
535            StreamEvent::Done { usage, .. } => assert!(usage.is_none()),
536            _ => panic!("wrong variant"),
537        }
538    }
539}