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;
16use crate::models::{
17    BackendConfig, Model, ModelConfig, ModelError, ReasoningChunk, Result, StreamCallback,
18    StreamEvent as ModelStreamEvent,
19};
20
21use super::super::capabilities::Capabilities;
22use super::super::ctx::{FinalResponse, StreamContext, StreamEvent};
23use super::ModelProvider;
24
25/// Ollama adapter fronted by `ModelProvider`.
26pub struct OllamaProvider {
27    adapter: OllamaAdapter,
28    capabilities: Capabilities,
29    /// Shared app `Config` so `build_model_config` can read Ollama
30    /// hardware options (`num_ctx`, `num_gpu`, `num_thread`, `numa`) at
31    /// call time. Before F11 these were silently dropped because the
32    /// wrapper built `ModelConfig` only from `ChatRequest` fields.
33    config: Arc<crate::app::Config>,
34}
35
36impl OllamaProvider {
37    /// Backward-compatible constructor that uses a default app config.
38    /// Call `with_app_config` instead when you have one available so
39    /// Ollama hardware options actually reach the adapter.
40    pub async fn new(model_name: &str, backend: Arc<BackendConfig>) -> Result<Self> {
41        Self::with_app_config(model_name, backend, Arc::new(crate::app::Config::default())).await
42    }
43
44    /// Construct with an explicit `app::Config` reference. Used by
45    /// `ProviderFactory::build_provider` so `config.ollama.{num_gpu,
46    /// num_ctx, num_thread, numa}` make it into the Ollama request's
47    /// `options` block.
48    pub async fn with_app_config(
49        model_name: &str,
50        backend: Arc<BackendConfig>,
51        config: Arc<crate::app::Config>,
52    ) -> Result<Self> {
53        let adapter = OllamaAdapter::new(model_name, backend).await?;
54        let capabilities = Capabilities::from_legacy(adapter.capabilities());
55        Ok(Self {
56            adapter,
57            capabilities,
58            config,
59        })
60    }
61}
62
63#[async_trait]
64impl ModelProvider for OllamaProvider {
65    fn capabilities(&self) -> &Capabilities {
66        &self.capabilities
67    }
68
69    async fn chat(&self, request: ChatRequest, ctx: StreamContext) -> Result<FinalResponse> {
70        let config = build_model_config(&request, &self.config);
71        // Ordered relay (F2): the adapter's sync callback pushes into an
72        // `UnboundedSender` (synchronous, FIFO). A single relay task drains
73        // into the bounded sink in order, avoiding the per-event `tokio::
74        // spawn` race that could deliver `Done` before prior tool calls.
75        let (relay_tx, relay_handle) = super::stream_bridge::ordered_relay(ctx.sink.clone());
76        let callback = stream_callback_for(relay_tx.clone());
77
78        // Race adapter.chat against the cancellation token. When
79        // cancelled, the adapter's stream loop observes the sink
80        // closing (we drop `callback`) and exits at its next await.
81        // This is the crucial structural win vs. the old
82        // `check_interrupt` polling: the adapter doesn't need to
83        // know anything about turn IDs — the sink either drains or
84        // doesn't, and the tokens handle everything else.
85        let chat_fut = self
86            .adapter
87            .chat(&request.messages, &config, Some(callback));
88
89        let response = tokio::select! {
90            biased;
91            _ = ctx.token.cancelled() => {
92                // Terminal event for a cancelled turn comes from the
93                // runner's `drop_scope` once the `TurnScope` drains, so
94                // we neither emit `StreamEvent::Done` here nor surface
95                // an `UpstreamError`. `ModelError::Cancelled` is the
96                // sentinel the runner swallows.
97                return Err(ModelError::Cancelled);
98            },
99            r = chat_fut => r?,
100        };
101
102        // F3: the wrapper's `Done` is now the sole terminal event —
103        // the adapter no longer emits one from the callback. Carrying
104        // `thinking_signature` out of `ModelResponse` here is what
105        // lets multi-turn extended thinking round-trip.
106        let usage = response.usage.clone();
107        let thinking_signature = response.thinking_signature.clone();
108        let stop_reason = response.stop_reason.clone();
109        // Terminal Done through the ordered relay, then drain (see stream_bridge).
110        let _ = relay_tx.send(StreamEvent::Done {
111            usage: usage.clone(),
112            thinking_signature: thinking_signature.clone(),
113            stop_reason: stop_reason.clone(),
114        });
115        drop(relay_tx);
116        let _ = relay_handle.await;
117
118        Ok(FinalResponse {
119            usage,
120            thinking_signature,
121            tool_calls: response.tool_calls.unwrap_or_default(),
122            stop_reason,
123        })
124    }
125}
126
127// ─── helpers ────────────────────────────────────────────────────────
128
129fn build_model_config(request: &ChatRequest, app_config: &crate::app::Config) -> ModelConfig {
130    let mut mc = ModelConfig {
131        model: request.model_id.clone(),
132        temperature: request.temperature,
133        max_tokens: request.max_tokens,
134        reasoning: request.reasoning,
135        system_prompt: Some(request.system_prompt.clone()),
136        dynamic_system_suffix: request.instructions.clone(),
137        tools: request.tools.iter().map(|t| t.to_openai_json()).collect(),
138        ..Default::default()
139    };
140    // F11: forward Ollama hardware options from the user's app config.
141    // Previously these fields were configured + persisted but silently
142    // ignored because this wrapper built ModelConfig in isolation.
143    if let Some(v) = app_config.ollama.num_gpu {
144        mc.set_backend_option("ollama".into(), "num_gpu".into(), v.to_string());
145    }
146    if let Some(v) = app_config.ollama.num_ctx {
147        mc.set_backend_option("ollama".into(), "num_ctx".into(), v.to_string());
148    }
149    if let Some(v) = app_config.ollama.num_thread {
150        mc.set_backend_option("ollama".into(), "num_thread".into(), v.to_string());
151    }
152    if let Some(v) = app_config.ollama.numa {
153        mc.set_backend_option("ollama".into(), "numa".into(), v.to_string());
154    }
155    mc
156}
157
158/// Build a `StreamCallback` that forwards `ModelStreamEvent`s from the
159/// adapter into an `UnboundedSender<StreamEvent>` (ordered relay). The
160/// caller wires that sender to a bounded sink via
161/// `stream_bridge::ordered_relay`; this keeps event delivery FIFO even
162/// though the adapter calls us from a sync context.
163fn stream_callback_for(sink: tokio::sync::mpsc::UnboundedSender<StreamEvent>) -> StreamCallback {
164    Arc::new(move |event: ModelStreamEvent| {
165        let mapped = match event {
166            ModelStreamEvent::Text(s) => StreamEvent::Text(s),
167            ModelStreamEvent::Reasoning(chunk) => StreamEvent::Reasoning(ReasoningChunk {
168                text: chunk.text,
169                signature: chunk.signature,
170            }),
171            ModelStreamEvent::ToolCall(tc) => StreamEvent::ToolCall(tc),
172            ModelStreamEvent::Done { tokens } => StreamEvent::Done {
173                usage: if tokens > 0 {
174                    Some(crate::models::TokenUsage::provider(0, tokens, tokens))
175                } else {
176                    None
177                },
178                thinking_signature: None,
179                stop_reason: None,
180            },
181        };
182        // Synchronous send preserves ordering. Ignore errors — the
183        // receiver has closed means the turn is already gone.
184        let _ = sink.send(mapped);
185    })
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn build_model_config_maps_request_fields() {
194        let req = ChatRequest {
195            model_id: "ollama/test".to_string(),
196            messages: vec![],
197            system_prompt: "sys".to_string(),
198            instructions: Some("instructions text".to_string()),
199            reasoning: crate::models::ReasoningLevel::High,
200            temperature: 0.3,
201            max_tokens: 2048,
202            tools: vec![],
203        };
204        let app_cfg = crate::app::Config::default();
205        let cfg = build_model_config(&req, &app_cfg);
206        assert_eq!(cfg.model, "ollama/test");
207        assert_eq!(cfg.temperature, 0.3);
208        assert_eq!(cfg.max_tokens, 2048);
209        assert_eq!(cfg.reasoning, crate::models::ReasoningLevel::High);
210        assert_eq!(cfg.system_prompt.as_deref(), Some("sys"));
211        assert_eq!(
212            cfg.dynamic_system_suffix.as_deref(),
213            Some("instructions text")
214        );
215    }
216
217    /// F11 regression guard: Ollama hardware options in the user's
218    /// app config must land in the ModelConfig's backend_options so
219    /// the adapter's `build_request_body` emits them under `options`.
220    /// Before F11 these were configured + persisted but never reached
221    /// the wire — `num_ctx = 8192` in config.toml was a silent no-op.
222    #[test]
223    fn build_model_config_forwards_ollama_hardware_options() {
224        let req = ChatRequest {
225            model_id: "ollama/test".to_string(),
226            messages: vec![],
227            system_prompt: "sys".to_string(),
228            instructions: None,
229            reasoning: crate::models::ReasoningLevel::Medium,
230            temperature: 0.7,
231            max_tokens: 4096,
232            tools: vec![],
233        };
234        let mut app_cfg = crate::app::Config::default();
235        app_cfg.ollama.num_ctx = Some(8192);
236        app_cfg.ollama.num_gpu = Some(10);
237        app_cfg.ollama.num_thread = Some(8);
238        app_cfg.ollama.numa = Some(true);
239
240        let cfg = build_model_config(&req, &app_cfg);
241        let opts = cfg.ollama_options();
242        assert_eq!(opts.num_ctx, Some(8192));
243        assert_eq!(opts.num_gpu, Some(10));
244        assert_eq!(opts.num_thread, Some(8));
245        assert_eq!(opts.numa, Some(true));
246    }
247
248    #[tokio::test]
249    async fn stream_callback_forwards_text_event() {
250        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
251        let cb = stream_callback_for(tx);
252        cb(ModelStreamEvent::Text("hello".to_string()));
253        let recv = tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv())
254            .await
255            .expect("recv")
256            .expect("sender alive");
257        match recv {
258            StreamEvent::Text(s) => assert_eq!(s, "hello"),
259            _ => panic!("wrong variant"),
260        }
261    }
262
263    #[tokio::test]
264    async fn stream_callback_forwards_done_with_tokens() {
265        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
266        let cb = stream_callback_for(tx);
267        cb(ModelStreamEvent::Done { tokens: 42 });
268        let recv = tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv())
269            .await
270            .expect("recv")
271            .expect("sender");
272        match recv {
273            StreamEvent::Done { usage, .. } => {
274                let u = usage.expect("tokens > 0 → Some");
275                assert_eq!(u.total_tokens, 42);
276            },
277            _ => panic!("wrong variant"),
278        }
279    }
280
281    #[tokio::test]
282    async fn stream_callback_done_zero_tokens_is_none_usage() {
283        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
284        let cb = stream_callback_for(tx);
285        cb(ModelStreamEvent::Done { tokens: 0 });
286        let recv = tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv())
287            .await
288            .expect("recv")
289            .expect("sender");
290        match recv {
291            StreamEvent::Done { usage, .. } => assert!(usage.is_none()),
292            _ => panic!("wrong variant"),
293        }
294    }
295}