1use std::sync::Arc;
11
12use async_trait::async_trait;
13
14use mermaid_domain::ChatRequest;
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, RejectionCache, learn_output_cap,
26 load_limits_from_db, 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 rejections: RejectionCache,
36 config: Arc<mermaid_domain::Config>,
41 ctx_cell: tokio::sync::OnceCell<OllamaModelInfo>,
47}
48
49impl OllamaProvider {
50 pub async fn new(model_name: &str, backend: Arc<BackendConfig>) -> Result<Self> {
58 Self::with_app_config(
59 model_name,
60 backend,
61 Arc::new(mermaid_domain::Config::default()),
62 )
63 .await
64 }
65
66 pub async fn with_app_config(
78 model_name: &str,
79 backend: Arc<BackendConfig>,
80 config: Arc<mermaid_domain::Config>,
81 ) -> Result<Self> {
82 let autostart = backend.ollama_autostart;
83 let adapter = OllamaAdapter::new(model_name, backend).await?;
84 let adapter = if autostart {
89 adapter.with_recovery(Arc::new(crate::ollama::OllamaAutostart))
90 } else {
91 adapter
92 };
93 let capabilities = adapter.capabilities().clone();
94 Ok(Self {
95 adapter,
96 capabilities,
97 rejections: RejectionCache::default(),
98 config,
99 ctx_cell: tokio::sync::OnceCell::new(),
100 })
101 }
102
103 async fn probe(&self) -> Option<OllamaModelInfo> {
106 self.ctx_cell
107 .get_or_try_init(|| async { self.load_probe().await.ok_or(()) })
108 .await
109 .ok()
110 .cloned()
111 }
112
113 async fn load_probe(&self) -> Option<OllamaModelInfo> {
114 let model = self.adapter.name().to_string();
115 if let Some(info) = load_probe_from_db(model.clone()).await {
116 return Some(info);
117 }
118 let info = self.adapter.show_model_info().await?;
119 save_probe_to_db(model, info.clone()).await;
120 Some(info)
121 }
122
123 async fn num_ctx_inputs(
128 &self,
129 info: &OllamaModelInfo,
130 override_num_ctx: Option<u32>,
131 override_offload: Option<bool>,
132 ) -> NumCtxInputs {
133 let allow_ram_offload = override_offload.unwrap_or(self.config.ollama.allow_ram_offload);
137 let (vram_bytes, system_ram_bytes) = if allow_ram_offload {
140 (None, mermaid_model::utils::system_ram_bytes())
141 } else {
142 (mermaid_model::utils::gpu_vram_bytes().await, None)
143 };
144 NumCtxInputs {
145 model_max: info.context_length,
146 dims: info.dims,
147 model_weight_bytes: info.weight_bytes,
148 per_model_override: override_num_ctx,
149 global_num_ctx: self.config.ollama.num_ctx,
150 allow_ram_offload,
151 vram_bytes,
152 system_ram_bytes,
153 max_auto_cap: self.config.ollama.max_auto_num_ctx,
154 is_cloud: crate::ollama::is_cloud_model(self.adapter.name()),
155 }
156 }
157}
158
159#[async_trait]
160impl ModelProvider for OllamaProvider {
161 fn capabilities(&self) -> &ModelCapabilities {
162 &self.capabilities
163 }
164
165 async fn resolve_context_window(&self, request: &ChatRequest) -> ContextSizing {
166 let info = self.probe().await.unwrap_or_default();
167 let inputs = self
168 .num_ctx_inputs(
169 &info,
170 request.ollama_num_ctx,
171 request.ollama_allow_ram_offload,
172 )
173 .await;
174 let model_max = inputs.model_max;
175 let max_output =
180 load_limits_from_db("ollama".to_string(), Model::name(&self.adapter).to_string())
181 .await
182 .and_then(|l| l.max_output_tokens);
183 match resolve_ollama_num_ctx(&inputs) {
184 Some(r) => ContextSizing {
185 model_max,
186 effective: Some(r.value),
187 source: Some(r.source),
188 max_output,
189 compacts_natively: false,
190 },
191 None => ContextSizing {
193 model_max,
194 effective: None,
195 source: None,
196 max_output,
197 compacts_natively: false,
198 },
199 }
200 }
201
202 async fn verify_placement(&self, current_num_ctx: Option<usize>) -> Option<ModelPlacement> {
203 let (vram, total) = self.adapter.model_placement().await?;
204 if total == 0 {
207 return None;
208 }
209 let suggested_num_ctx = if vram < total {
213 let info = self.probe().await.unwrap_or_default();
214 current_num_ctx
215 .zip(info.dims)
216 .and_then(|(current, dims)| {
217 let kv = kv_bytes_per_token(&dims)?;
218 converge_num_ctx(current, vram, total, kv)
219 })
220 .map(|n| n as u32)
221 } else {
222 None
223 };
224 Some(ModelPlacement {
225 size_vram_bytes: vram,
226 total_bytes: total,
227 suggested_num_ctx,
228 })
229 }
230
231 async fn supports_vision(&self) -> Option<bool> {
232 Some(self.adapter.vision_supported().await)
233 }
234
235 async fn chat(&self, request: ChatRequest, ctx: StreamContext) -> Result<FinalResponse> {
236 let sizing = self.resolve_context_window(&request).await;
240 let config =
241 build_model_config(&request, &self.config, sizing.effective, sizing.max_output);
242 let chat_fut = async {
246 match self
247 .adapter
248 .chat(&request.messages, &config, Some(ctx.sink.clone()))
249 .await
250 {
251 Ok(response) => Ok(response),
252 Err(err) => {
253 let Some(cap) = output_cap_from_error(&err) else {
259 return Err(err);
260 };
261 let sent = config
262 .ollama_options()
263 .num_predict
264 .map_or(0, |v| v.max(0) as usize);
265 if retry_cap(sent, cap).is_none() {
266 return Err(err);
267 }
268 let model = Model::name(&self.adapter).to_string();
269 learn_output_cap("ollama".to_string(), model.clone(), cap).await;
270 let _ = ctx.sink.send(StreamEvent::Status(format!(
271 "{model} rejected the output budget; learned its {cap}-token cap and retrying"
272 ))).await;
273 let retry_config =
274 build_model_config(&request, &self.config, sizing.effective, Some(cap));
275 self.adapter
276 .chat(&request.messages, &retry_config, Some(ctx.sink.clone()))
277 .await
278 },
279 }
280 };
281
282 let learned_model = Model::name(&self.adapter).to_string();
285 let memory = self.adapter.param_memory();
286 self.rejections.seed("ollama", &learned_model, memory).await;
287
288 let response = tokio::select! {
289 biased;
290 _ = ctx.token.cancelled() => {
291 return Err(ModelError::Cancelled);
297 },
298 r = chat_fut => {
299 self.rejections.persist("ollama", &learned_model, memory).await;
300 r?
301 },
302 };
303
304 let usage = response.usage.clone();
310 let provider_continuation = response.provider_continuation.clone();
311 let stop_reason = response.stop_reason.clone();
312 let _ = ctx
313 .sink
314 .send(StreamEvent::Done {
315 usage: usage.clone(),
316 provider_continuation: provider_continuation.clone(),
317 stop_reason: stop_reason.clone(),
318 })
319 .await;
320
321 Ok(FinalResponse {
322 usage,
323 provider_continuation,
324 tool_calls: response.tool_calls.unwrap_or_default(),
325 stop_reason,
326 })
327 }
328}
329
330fn build_model_config(
338 request: &ChatRequest,
339 app_config: &mermaid_domain::Config,
340 num_ctx: Option<usize>,
341 provider_max_output: Option<usize>,
342) -> ModelConfig {
343 let mut mc = ModelConfig::from(request);
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 requested_compaction: None,
457 native_compaction: None,
458 native_tools: mermaid_model::models::NativeTools::default(),
459 };
460 let app_cfg = mermaid_domain::Config::default();
461 let cfg = build_model_config(&req, &app_cfg, None, None);
462 assert_eq!(cfg.model, "ollama/test");
463 assert_eq!(cfg.temperature, 0.3);
464 assert_eq!(cfg.max_tokens, 2048);
465 assert_eq!(cfg.reasoning, mermaid_model::models::ReasoningLevel::High);
466 assert_eq!(cfg.system_prompt.as_deref(), Some("sys"));
467 assert_eq!(
468 cfg.dynamic_system_suffix.as_deref(),
469 Some("instructions text")
470 );
471 }
472
473 #[test]
479 fn build_model_config_forwards_ollama_hardware_options() {
480 let req = ChatRequest {
481 model_id: "ollama/test".to_string(),
482 messages: vec![],
483 system_prompt: "sys".to_string(),
484 instructions: None,
485 reasoning: mermaid_model::models::ReasoningLevel::Medium,
486 temperature: 0.7,
487 max_tokens: 4096,
488 tools: vec![],
489
490 ollama_num_ctx: None,
491 ollama_allow_ram_offload: None,
492 resolved_context_window: None,
493 resolved_max_output: None,
494 output_schema: None,
495 suppress_auto_compact: false,
496 requested_compaction: None,
497 native_compaction: None,
498 native_tools: mermaid_model::models::NativeTools::default(),
499 };
500 let mut app_cfg = mermaid_domain::Config::default();
501 app_cfg.ollama.num_gpu = Some(10);
502 app_cfg.ollama.num_thread = Some(8);
503 app_cfg.ollama.numa = Some(true);
504
505 let cfg = build_model_config(&req, &app_cfg, Some(8192), None);
507 let opts = cfg.ollama_options();
508 assert_eq!(opts.num_ctx, Some(8192));
509 assert_eq!(opts.num_gpu, Some(10));
510 assert_eq!(opts.num_thread, Some(8));
511 assert_eq!(opts.numa, Some(true));
512 assert!(opts.num_predict.is_some(), "num_predict is always derived");
513 }
514
515 #[test]
518 fn build_model_config_derives_num_predict() {
519 let req = ChatRequest {
520 model_id: "ollama/test".to_string(),
521 messages: vec![],
522 system_prompt: String::new(),
523 instructions: None,
524 reasoning: mermaid_model::models::ReasoningLevel::Max,
525 temperature: 0.7,
526 max_tokens: 4096,
527 tools: vec![],
528
529 ollama_num_ctx: None,
530 ollama_allow_ram_offload: None,
531 resolved_context_window: None,
532 resolved_max_output: None,
533 output_schema: None,
534 suppress_auto_compact: false,
535 requested_compaction: None,
536 native_compaction: None,
537 native_tools: mermaid_model::models::NativeTools::default(),
538 };
539 let cfg = build_model_config(
540 &req,
541 &mermaid_domain::Config::default(),
542 Some(131_072),
543 None,
544 );
545 assert_eq!(cfg.ollama_options().num_predict, Some(4_096));
547 }
548
549 #[test]
553 fn build_model_config_caps_num_predict_at_learned_ceiling() {
554 let req = ChatRequest {
555 model_id: "ollama/minimax-m3:cloud".to_string(),
556 messages: vec![],
557 system_prompt: String::new(),
558 instructions: None,
559 reasoning: mermaid_model::models::ReasoningLevel::Medium,
560 temperature: 0.7,
561 max_tokens: 0, tools: vec![],
563
564 ollama_num_ctx: None,
565 ollama_allow_ram_offload: None,
566 resolved_context_window: None,
567 resolved_max_output: None,
568 output_schema: None,
569 suppress_auto_compact: false,
570 requested_compaction: None,
571 native_compaction: None,
572 native_tools: mermaid_model::models::NativeTools::default(),
573 };
574 let app_cfg = mermaid_domain::Config::default();
575 let uncapped = build_model_config(&req, &app_cfg, Some(524_288), None);
578 assert!(uncapped.ollama_options().num_predict.unwrap() > 131_072);
579 let capped = build_model_config(&req, &app_cfg, Some(524_288), Some(131_072));
581 assert_eq!(capped.ollama_options().num_predict, Some(131_072));
582 }
583}