mermaid_cli/providers/model/
ollama.rs1use 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
25pub struct OllamaProvider {
27 adapter: OllamaAdapter,
28 capabilities: Capabilities,
29 config: Arc<crate::app::Config>,
34}
35
36impl OllamaProvider {
37 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 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 let (relay_tx, relay_handle) = super::stream_bridge::ordered_relay(ctx.sink.clone());
76 let callback = stream_callback_for(relay_tx.clone());
77
78 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 return Err(ModelError::Cancelled);
98 },
99 r = chat_fut => r?,
100 };
101
102 let usage = response.usage.clone();
107 let thinking_signature = response.thinking_signature.clone();
108 let stop_reason = response.stop_reason.clone();
109 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
127fn 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 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
158fn 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 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 #[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}