1use std::sync::Arc;
11
12use async_trait::async_trait;
13
14use mermaid_domain::{ChatRequest, ToolDefinition};
15use mermaid_model::models::adapters::ollama::{OllamaAdapter, OllamaModelInfo};
16use mermaid_model::models::adapters::ollama_sizing::{
17 NumCtxInputs, converge_num_ctx, default_ollama_num_predict, kv_bytes_per_token,
18 resolve_ollama_num_ctx,
19};
20use mermaid_model::models::{BackendConfig, Model, ModelConfig, ModelError, Result};
21use mermaid_runtime::NewProviderProbe;
22
23use super::super::ctx::{FinalResponse, StreamContext, StreamEvent};
24use super::{
25 ContextSizing, ModelPlacement, ModelProvider, learn_output_cap, load_limits_from_db,
26 output_cap_from_error, probe_is_stale, retry_cap,
27};
28use mermaid_model::models::ModelCapabilities;
29
30pub struct OllamaProvider {
32 adapter: OllamaAdapter,
33 capabilities: ModelCapabilities,
34 config: Arc<mermaid_domain::Config>,
39 ctx_cell: tokio::sync::OnceCell<OllamaModelInfo>,
45}
46
47impl OllamaProvider {
48 pub async fn new(model_name: &str, backend: Arc<BackendConfig>) -> Result<Self> {
56 Self::with_app_config(
57 model_name,
58 backend,
59 Arc::new(mermaid_domain::Config::default()),
60 )
61 .await
62 }
63
64 pub async fn with_app_config(
76 model_name: &str,
77 backend: Arc<BackendConfig>,
78 config: Arc<mermaid_domain::Config>,
79 ) -> Result<Self> {
80 let autostart = backend.ollama_autostart;
81 let adapter = OllamaAdapter::new(model_name, backend).await?;
82 let adapter = if autostart {
87 adapter.with_recovery(Arc::new(crate::ollama::OllamaAutostart))
88 } else {
89 adapter
90 };
91 let capabilities = adapter.capabilities().clone();
92 Ok(Self {
93 adapter,
94 capabilities,
95 config,
96 ctx_cell: tokio::sync::OnceCell::new(),
97 })
98 }
99
100 async fn probe(&self) -> Option<OllamaModelInfo> {
103 self.ctx_cell
104 .get_or_try_init(|| async { self.load_probe().await.ok_or(()) })
105 .await
106 .ok()
107 .cloned()
108 }
109
110 async fn load_probe(&self) -> Option<OllamaModelInfo> {
111 let model = self.adapter.name().to_string();
112 if let Some(info) = load_probe_from_db(model.clone()).await {
113 return Some(info);
114 }
115 let info = self.adapter.show_model_info().await?;
116 save_probe_to_db(model, info.clone()).await;
117 Some(info)
118 }
119
120 async fn num_ctx_inputs(
125 &self,
126 info: &OllamaModelInfo,
127 override_num_ctx: Option<u32>,
128 override_offload: Option<bool>,
129 ) -> NumCtxInputs {
130 let allow_ram_offload = override_offload.unwrap_or(self.config.ollama.allow_ram_offload);
134 let (vram_bytes, system_ram_bytes) = if allow_ram_offload {
137 (None, mermaid_model::utils::system_ram_bytes())
138 } else {
139 (mermaid_model::utils::gpu_vram_bytes().await, None)
140 };
141 NumCtxInputs {
142 model_max: info.context_length,
143 dims: info.dims,
144 model_weight_bytes: info.weight_bytes,
145 per_model_override: override_num_ctx,
146 global_num_ctx: self.config.ollama.num_ctx,
147 allow_ram_offload,
148 vram_bytes,
149 system_ram_bytes,
150 max_auto_cap: self.config.ollama.max_auto_num_ctx,
151 is_cloud: crate::ollama::is_cloud_model(self.adapter.name()),
152 }
153 }
154}
155
156#[async_trait]
157impl ModelProvider for OllamaProvider {
158 fn capabilities(&self) -> &ModelCapabilities {
159 &self.capabilities
160 }
161
162 async fn resolve_context_window(&self, request: &ChatRequest) -> ContextSizing {
163 let info = self.probe().await.unwrap_or_default();
164 let inputs = self
165 .num_ctx_inputs(
166 &info,
167 request.ollama_num_ctx,
168 request.ollama_allow_ram_offload,
169 )
170 .await;
171 let model_max = inputs.model_max;
172 let max_output =
177 load_limits_from_db("ollama".to_string(), Model::name(&self.adapter).to_string())
178 .await
179 .and_then(|l| l.max_output_tokens);
180 match resolve_ollama_num_ctx(&inputs) {
181 Some(r) => ContextSizing {
182 model_max,
183 effective: Some(r.value),
184 source: Some(r.source),
185 max_output,
186 },
187 None => ContextSizing {
189 model_max,
190 effective: None,
191 source: None,
192 max_output,
193 },
194 }
195 }
196
197 async fn verify_placement(&self, current_num_ctx: Option<usize>) -> Option<ModelPlacement> {
198 let (vram, total) = self.adapter.model_placement().await?;
199 if total == 0 {
202 return None;
203 }
204 let suggested_num_ctx = if vram < total {
208 let info = self.probe().await.unwrap_or_default();
209 current_num_ctx
210 .zip(info.dims)
211 .and_then(|(current, dims)| {
212 let kv = kv_bytes_per_token(&dims)?;
213 converge_num_ctx(current, vram, total, kv)
214 })
215 .map(|n| n as u32)
216 } else {
217 None
218 };
219 Some(ModelPlacement {
220 size_vram_bytes: vram,
221 total_bytes: total,
222 suggested_num_ctx,
223 })
224 }
225
226 async fn supports_vision(&self) -> Option<bool> {
227 Some(self.adapter.vision_supported().await)
228 }
229
230 async fn chat(&self, request: ChatRequest, ctx: StreamContext) -> Result<FinalResponse> {
231 let sizing = self.resolve_context_window(&request).await;
235 let config =
236 build_model_config(&request, &self.config, sizing.effective, sizing.max_output);
237 let chat_fut = async {
241 match self
242 .adapter
243 .chat(&request.messages, &config, Some(ctx.sink.clone()))
244 .await
245 {
246 Ok(response) => Ok(response),
247 Err(err) => {
248 let Some(cap) = output_cap_from_error(&err) else {
254 return Err(err);
255 };
256 let sent = config
257 .ollama_options()
258 .num_predict
259 .map_or(0, |v| v.max(0) as usize);
260 if retry_cap(sent, cap).is_none() {
261 return Err(err);
262 }
263 let model = Model::name(&self.adapter).to_string();
264 learn_output_cap("ollama".to_string(), model.clone(), cap).await;
265 let _ = ctx.sink.send(StreamEvent::Status(format!(
266 "{model} rejected the output budget; learned its {cap}-token cap and retrying"
267 ))).await;
268 let retry_config =
269 build_model_config(&request, &self.config, sizing.effective, Some(cap));
270 self.adapter
271 .chat(&request.messages, &retry_config, Some(ctx.sink.clone()))
272 .await
273 },
274 }
275 };
276
277 let response = tokio::select! {
278 biased;
279 _ = ctx.token.cancelled() => {
280 return Err(ModelError::Cancelled);
286 },
287 r = chat_fut => r?,
288 };
289
290 let usage = response.usage.clone();
296 let provider_continuation = response.provider_continuation.clone();
297 let stop_reason = response.stop_reason.clone();
298 let _ = ctx
299 .sink
300 .send(StreamEvent::Done {
301 usage: usage.clone(),
302 provider_continuation: provider_continuation.clone(),
303 stop_reason: stop_reason.clone(),
304 })
305 .await;
306
307 Ok(FinalResponse {
308 usage,
309 provider_continuation,
310 tool_calls: response.tool_calls.unwrap_or_default(),
311 stop_reason,
312 })
313 }
314}
315
316fn build_model_config(
324 request: &ChatRequest,
325 app_config: &mermaid_domain::Config,
326 num_ctx: Option<usize>,
327 provider_max_output: Option<usize>,
328) -> ModelConfig {
329 let mut mc = ModelConfig {
330 model: request.model_id.clone(),
331 temperature: request.temperature,
332 max_tokens: request.max_tokens,
333 reasoning: request.reasoning,
334 system_prompt: Some(request.system_prompt.clone()),
335 dynamic_system_suffix: request.instructions.clone(),
336 tools: request
337 .tools
338 .iter()
339 .map(ToolDefinition::to_openai_json)
340 .collect(),
341 output_schema: request.output_schema.clone(),
342 ..Default::default()
343 };
344 if let Some(n) = num_ctx {
346 mc.set_backend_option("ollama".into(), "num_ctx".into(), n.to_string());
347 }
348 if let Some(num_predict) = default_ollama_num_predict(
354 request.max_tokens,
355 num_ctx,
356 estimate_prompt_tokens(request),
357 provider_max_output,
358 ) {
359 mc.set_backend_option(
360 "ollama".into(),
361 "num_predict".into(),
362 num_predict.to_string(),
363 );
364 }
365
366 if let Some(v) = app_config.ollama.num_gpu {
369 mc.set_backend_option("ollama".into(), "num_gpu".into(), v.to_string());
370 }
371 if let Some(v) = app_config.ollama.num_thread {
372 mc.set_backend_option("ollama".into(), "num_thread".into(), v.to_string());
373 }
374 if let Some(v) = app_config.ollama.numa {
375 mc.set_backend_option("ollama".into(), "numa".into(), v.to_string());
376 }
377 mc
378}
379
380fn estimate_prompt_tokens(request: &ChatRequest) -> usize {
384 let chars = request.system_prompt.len()
385 + request.instructions.as_deref().map_or(0, str::len)
386 + request
387 .messages
388 .iter()
389 .map(|m| m.content.len())
390 .sum::<usize>();
391 chars / 4
392}
393
394async fn load_probe_from_db(model: String) -> Option<OllamaModelInfo> {
397 tokio::task::spawn_blocking(move || {
398 let rec = mermaid_runtime::with_shared_store(|store| {
399 store
400 .provider_probes()
401 .get("ollama", &model, "context_probe")
402 })
403 .ok()??;
404 if probe_is_stale(&rec.probed_at) {
405 return None;
406 }
407 serde_json::from_str::<OllamaModelInfo>(&rec.capability_value).ok()
408 })
409 .await
410 .ok()
411 .flatten()
412}
413
414async fn save_probe_to_db(model: String, info: OllamaModelInfo) {
416 let _ = tokio::task::spawn_blocking(move || -> Option<()> {
417 let value = serde_json::to_string(&info).ok()?;
418 mermaid_runtime::with_shared_store(|store| {
419 store.provider_probes().upsert(NewProviderProbe {
420 provider: "ollama".into(),
421 model_id: model,
422 capability_key: "context_probe".into(),
423 capability_value: value,
424 confidence: "probed".into(),
425 error: None,
426 })
427 })
428 .ok()?;
429 Some(())
430 })
431 .await;
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 #[test]
439 fn build_model_config_maps_request_fields() {
440 let req = ChatRequest {
441 model_id: "ollama/test".to_string(),
442 messages: vec![],
443 system_prompt: "sys".to_string(),
444 instructions: Some("instructions text".to_string()),
445 reasoning: mermaid_model::models::ReasoningLevel::High,
446 temperature: 0.3,
447 max_tokens: 2048,
448 tools: vec![],
449
450 ollama_num_ctx: None,
451 ollama_allow_ram_offload: None,
452 resolved_context_window: None,
453 resolved_max_output: None,
454 output_schema: None,
455 suppress_auto_compact: false,
456 suppressed_builtin_tools: Vec::new(),
457 };
458 let app_cfg = mermaid_domain::Config::default();
459 let cfg = build_model_config(&req, &app_cfg, None, None);
460 assert_eq!(cfg.model, "ollama/test");
461 assert_eq!(cfg.temperature, 0.3);
462 assert_eq!(cfg.max_tokens, 2048);
463 assert_eq!(cfg.reasoning, mermaid_model::models::ReasoningLevel::High);
464 assert_eq!(cfg.system_prompt.as_deref(), Some("sys"));
465 assert_eq!(
466 cfg.dynamic_system_suffix.as_deref(),
467 Some("instructions text")
468 );
469 }
470
471 #[test]
477 fn build_model_config_forwards_ollama_hardware_options() {
478 let req = ChatRequest {
479 model_id: "ollama/test".to_string(),
480 messages: vec![],
481 system_prompt: "sys".to_string(),
482 instructions: None,
483 reasoning: mermaid_model::models::ReasoningLevel::Medium,
484 temperature: 0.7,
485 max_tokens: 4096,
486 tools: vec![],
487
488 ollama_num_ctx: None,
489 ollama_allow_ram_offload: None,
490 resolved_context_window: None,
491 resolved_max_output: None,
492 output_schema: None,
493 suppress_auto_compact: false,
494 suppressed_builtin_tools: Vec::new(),
495 };
496 let mut app_cfg = mermaid_domain::Config::default();
497 app_cfg.ollama.num_gpu = Some(10);
498 app_cfg.ollama.num_thread = Some(8);
499 app_cfg.ollama.numa = Some(true);
500
501 let cfg = build_model_config(&req, &app_cfg, Some(8192), None);
503 let opts = cfg.ollama_options();
504 assert_eq!(opts.num_ctx, Some(8192));
505 assert_eq!(opts.num_gpu, Some(10));
506 assert_eq!(opts.num_thread, Some(8));
507 assert_eq!(opts.numa, Some(true));
508 assert!(opts.num_predict.is_some(), "num_predict is always derived");
509 }
510
511 #[test]
514 fn build_model_config_derives_num_predict() {
515 let req = ChatRequest {
516 model_id: "ollama/test".to_string(),
517 messages: vec![],
518 system_prompt: String::new(),
519 instructions: None,
520 reasoning: mermaid_model::models::ReasoningLevel::Max,
521 temperature: 0.7,
522 max_tokens: 4096,
523 tools: vec![],
524
525 ollama_num_ctx: None,
526 ollama_allow_ram_offload: None,
527 resolved_context_window: None,
528 resolved_max_output: None,
529 output_schema: None,
530 suppress_auto_compact: false,
531 suppressed_builtin_tools: Vec::new(),
532 };
533 let cfg = build_model_config(
534 &req,
535 &mermaid_domain::Config::default(),
536 Some(131_072),
537 None,
538 );
539 assert_eq!(cfg.ollama_options().num_predict, Some(4_096));
541 }
542
543 #[test]
547 fn build_model_config_caps_num_predict_at_learned_ceiling() {
548 let req = ChatRequest {
549 model_id: "ollama/minimax-m3:cloud".to_string(),
550 messages: vec![],
551 system_prompt: String::new(),
552 instructions: None,
553 reasoning: mermaid_model::models::ReasoningLevel::Medium,
554 temperature: 0.7,
555 max_tokens: 0, tools: vec![],
557
558 ollama_num_ctx: None,
559 ollama_allow_ram_offload: None,
560 resolved_context_window: None,
561 resolved_max_output: None,
562 output_schema: None,
563 suppress_auto_compact: false,
564 suppressed_builtin_tools: Vec::new(),
565 };
566 let app_cfg = mermaid_domain::Config::default();
567 let uncapped = build_model_config(&req, &app_cfg, Some(524_288), None);
570 assert!(uncapped.ollama_options().num_predict.unwrap() > 131_072);
571 let capped = build_model_config(&req, &app_cfg, Some(524_288), Some(131_072));
573 assert_eq!(capped.ollama_options().num_predict, Some(131_072));
574 }
575}