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, RuntimeStore};
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 (relay_tx, relay_handle) = super::stream_bridge::ordered_relay(ctx.sink.clone());
242 let callback = super::stream_bridge::forward_callback(relay_tx.clone());
243
244 let chat_fut = async {
252 match self
253 .adapter
254 .chat(&request.messages, &config, Some(callback.clone()))
255 .await
256 {
257 Ok(response) => Ok(response),
258 Err(err) => {
259 let Some(cap) = output_cap_from_error(&err) else {
265 return Err(err);
266 };
267 let sent = config
268 .ollama_options()
269 .num_predict
270 .map_or(0, |v| v.max(0) as usize);
271 if retry_cap(sent, cap).is_none() {
272 return Err(err);
273 }
274 let model = Model::name(&self.adapter).to_string();
275 learn_output_cap("ollama".to_string(), model.clone(), cap).await;
276 let _ = relay_tx.send(StreamEvent::Status(format!(
277 "{model} rejected the output budget; learned its {cap}-token cap and retrying"
278 )));
279 let retry_config =
280 build_model_config(&request, &self.config, sizing.effective, Some(cap));
281 self.adapter
282 .chat(&request.messages, &retry_config, Some(callback.clone()))
283 .await
284 },
285 }
286 };
287
288 let response = tokio::select! {
289 biased;
290 _ = ctx.token.cancelled() => {
291 return Err(ModelError::Cancelled);
297 },
298 r = chat_fut => r?,
299 };
300
301 let usage = response.usage.clone();
306 let provider_continuation = response.provider_continuation.clone();
307 let stop_reason = response.stop_reason.clone();
308 let _ = relay_tx.send(StreamEvent::Done {
310 usage: usage.clone(),
311 provider_continuation: provider_continuation.clone(),
312 stop_reason: stop_reason.clone(),
313 });
314 drop(relay_tx);
315 mermaid_model::utils::join_logged(relay_handle.take(), "stream_relay").await;
316
317 Ok(FinalResponse {
318 usage,
319 provider_continuation,
320 tool_calls: response.tool_calls.unwrap_or_default(),
321 stop_reason,
322 })
323 }
324}
325
326fn build_model_config(
334 request: &ChatRequest,
335 app_config: &mermaid_domain::Config,
336 num_ctx: Option<usize>,
337 provider_max_output: Option<usize>,
338) -> ModelConfig {
339 let mut mc = ModelConfig {
340 model: request.model_id.clone(),
341 temperature: request.temperature,
342 max_tokens: request.max_tokens,
343 reasoning: request.reasoning,
344 system_prompt: Some(request.system_prompt.clone()),
345 dynamic_system_suffix: request.instructions.clone(),
346 tools: request.tools.iter().map(|t| t.to_openai_json()).collect(),
347 output_schema: request.output_schema.clone(),
348 ..Default::default()
349 };
350 if let Some(n) = num_ctx {
352 mc.set_backend_option("ollama".into(), "num_ctx".into(), n.to_string());
353 }
354 if let Some(num_predict) = default_ollama_num_predict(
360 request.max_tokens,
361 num_ctx,
362 estimate_prompt_tokens(request),
363 provider_max_output,
364 ) {
365 mc.set_backend_option(
366 "ollama".into(),
367 "num_predict".into(),
368 num_predict.to_string(),
369 );
370 }
371
372 if let Some(v) = app_config.ollama.num_gpu {
375 mc.set_backend_option("ollama".into(), "num_gpu".into(), v.to_string());
376 }
377 if let Some(v) = app_config.ollama.num_thread {
378 mc.set_backend_option("ollama".into(), "num_thread".into(), v.to_string());
379 }
380 if let Some(v) = app_config.ollama.numa {
381 mc.set_backend_option("ollama".into(), "numa".into(), v.to_string());
382 }
383 mc
384}
385
386fn estimate_prompt_tokens(request: &ChatRequest) -> usize {
390 let chars = request.system_prompt.len()
391 + request.instructions.as_deref().map_or(0, str::len)
392 + request
393 .messages
394 .iter()
395 .map(|m| m.content.len())
396 .sum::<usize>();
397 chars / 4
398}
399
400async fn load_probe_from_db(model: String) -> Option<OllamaModelInfo> {
403 tokio::task::spawn_blocking(move || {
404 let store = RuntimeStore::open_default().ok()?;
405 let rec = store
406 .provider_probes()
407 .get("ollama", &model, "context_probe")
408 .ok()??;
409 if probe_is_stale(&rec.probed_at) {
410 return None;
411 }
412 serde_json::from_str::<OllamaModelInfo>(&rec.capability_value).ok()
413 })
414 .await
415 .ok()
416 .flatten()
417}
418
419async fn save_probe_to_db(model: String, info: OllamaModelInfo) {
421 let _ = tokio::task::spawn_blocking(move || -> Option<()> {
422 let value = serde_json::to_string(&info).ok()?;
423 let store = RuntimeStore::open_default().ok()?;
424 store
425 .provider_probes()
426 .upsert(NewProviderProbe {
427 provider: "ollama".into(),
428 model_id: model,
429 capability_key: "context_probe".into(),
430 capability_value: value,
431 confidence: "probed".into(),
432 error: None,
433 })
434 .ok()?;
435 Some(())
436 })
437 .await;
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443
444 #[test]
445 fn build_model_config_maps_request_fields() {
446 let req = ChatRequest {
447 model_id: "ollama/test".to_string(),
448 messages: vec![],
449 system_prompt: "sys".to_string(),
450 instructions: Some("instructions text".to_string()),
451 reasoning: mermaid_model::models::ReasoningLevel::High,
452 temperature: 0.3,
453 max_tokens: 2048,
454 tools: vec![],
455
456 ollama_num_ctx: None,
457 ollama_allow_ram_offload: None,
458 resolved_context_window: None,
459 resolved_max_output: None,
460 output_schema: None,
461 suppress_auto_compact: false,
462 suppressed_builtin_tools: Vec::new(),
463 };
464 let app_cfg = mermaid_domain::Config::default();
465 let cfg = build_model_config(&req, &app_cfg, None, None);
466 assert_eq!(cfg.model, "ollama/test");
467 assert_eq!(cfg.temperature, 0.3);
468 assert_eq!(cfg.max_tokens, 2048);
469 assert_eq!(cfg.reasoning, mermaid_model::models::ReasoningLevel::High);
470 assert_eq!(cfg.system_prompt.as_deref(), Some("sys"));
471 assert_eq!(
472 cfg.dynamic_system_suffix.as_deref(),
473 Some("instructions text")
474 );
475 }
476
477 #[test]
483 fn build_model_config_forwards_ollama_hardware_options() {
484 let req = ChatRequest {
485 model_id: "ollama/test".to_string(),
486 messages: vec![],
487 system_prompt: "sys".to_string(),
488 instructions: None,
489 reasoning: mermaid_model::models::ReasoningLevel::Medium,
490 temperature: 0.7,
491 max_tokens: 4096,
492 tools: vec![],
493
494 ollama_num_ctx: None,
495 ollama_allow_ram_offload: None,
496 resolved_context_window: None,
497 resolved_max_output: None,
498 output_schema: None,
499 suppress_auto_compact: false,
500 suppressed_builtin_tools: Vec::new(),
501 };
502 let mut app_cfg = mermaid_domain::Config::default();
503 app_cfg.ollama.num_gpu = Some(10);
504 app_cfg.ollama.num_thread = Some(8);
505 app_cfg.ollama.numa = Some(true);
506
507 let cfg = build_model_config(&req, &app_cfg, Some(8192), None);
509 let opts = cfg.ollama_options();
510 assert_eq!(opts.num_ctx, Some(8192));
511 assert_eq!(opts.num_gpu, Some(10));
512 assert_eq!(opts.num_thread, Some(8));
513 assert_eq!(opts.numa, Some(true));
514 assert!(opts.num_predict.is_some(), "num_predict is always derived");
515 }
516
517 #[test]
520 fn build_model_config_derives_num_predict() {
521 let req = ChatRequest {
522 model_id: "ollama/test".to_string(),
523 messages: vec![],
524 system_prompt: String::new(),
525 instructions: None,
526 reasoning: mermaid_model::models::ReasoningLevel::Max,
527 temperature: 0.7,
528 max_tokens: 4096,
529 tools: vec![],
530
531 ollama_num_ctx: None,
532 ollama_allow_ram_offload: None,
533 resolved_context_window: None,
534 resolved_max_output: None,
535 output_schema: None,
536 suppress_auto_compact: false,
537 suppressed_builtin_tools: Vec::new(),
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 suppressed_builtin_tools: Vec::new(),
571 };
572 let app_cfg = mermaid_domain::Config::default();
573 let uncapped = build_model_config(&req, &app_cfg, Some(524_288), None);
576 assert!(uncapped.ollama_options().num_predict.unwrap() > 131_072);
577 let capped = build_model_config(&req, &app_cfg, Some(524_288), Some(131_072));
579 assert_eq!(capped.ollama_options().num_predict, Some(131_072));
580 }
581}