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, 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::from(request);
330 if let Some(n) = num_ctx {
332 mc.set_backend_option("ollama".into(), "num_ctx".into(), n.to_string());
333 }
334 if let Some(num_predict) = default_ollama_num_predict(
340 request.max_tokens,
341 num_ctx,
342 estimate_prompt_tokens(request),
343 provider_max_output,
344 ) {
345 mc.set_backend_option(
346 "ollama".into(),
347 "num_predict".into(),
348 num_predict.to_string(),
349 );
350 }
351
352 if let Some(v) = app_config.ollama.num_gpu {
355 mc.set_backend_option("ollama".into(), "num_gpu".into(), v.to_string());
356 }
357 if let Some(v) = app_config.ollama.num_thread {
358 mc.set_backend_option("ollama".into(), "num_thread".into(), v.to_string());
359 }
360 if let Some(v) = app_config.ollama.numa {
361 mc.set_backend_option("ollama".into(), "numa".into(), v.to_string());
362 }
363 mc
364}
365
366fn estimate_prompt_tokens(request: &ChatRequest) -> usize {
370 let chars = request.system_prompt.len()
371 + request.instructions.as_deref().map_or(0, str::len)
372 + request
373 .messages
374 .iter()
375 .map(|m| m.content.len())
376 .sum::<usize>();
377 chars / 4
378}
379
380async fn load_probe_from_db(model: String) -> Option<OllamaModelInfo> {
383 tokio::task::spawn_blocking(move || {
384 let rec = mermaid_runtime::with_shared_store(|store| {
385 store
386 .provider_probes()
387 .get("ollama", &model, "context_probe")
388 })
389 .ok()??;
390 if probe_is_stale(&rec.probed_at) {
391 return None;
392 }
393 serde_json::from_str::<OllamaModelInfo>(&rec.capability_value).ok()
394 })
395 .await
396 .ok()
397 .flatten()
398}
399
400async fn save_probe_to_db(model: String, info: OllamaModelInfo) {
402 let _ = tokio::task::spawn_blocking(move || -> Option<()> {
403 let value = serde_json::to_string(&info).ok()?;
404 mermaid_runtime::with_shared_store(|store| {
405 store.provider_probes().upsert(NewProviderProbe {
406 provider: "ollama".into(),
407 model_id: model,
408 capability_key: "context_probe".into(),
409 capability_value: value,
410 confidence: "probed".into(),
411 error: None,
412 })
413 })
414 .ok()?;
415 Some(())
416 })
417 .await;
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423
424 #[test]
425 fn build_model_config_maps_request_fields() {
426 let req = ChatRequest {
427 model_id: "ollama/test".to_string(),
428 messages: vec![],
429 system_prompt: "sys".to_string(),
430 instructions: Some("instructions text".to_string()),
431 reasoning: mermaid_model::models::ReasoningLevel::High,
432 temperature: 0.3,
433 max_tokens: 2048,
434 tools: vec![],
435
436 ollama_num_ctx: None,
437 ollama_allow_ram_offload: None,
438 resolved_context_window: None,
439 resolved_max_output: None,
440 output_schema: None,
441 suppress_auto_compact: false,
442 suppressed_builtin_tools: Vec::new(),
443 };
444 let app_cfg = mermaid_domain::Config::default();
445 let cfg = build_model_config(&req, &app_cfg, None, None);
446 assert_eq!(cfg.model, "ollama/test");
447 assert_eq!(cfg.temperature, 0.3);
448 assert_eq!(cfg.max_tokens, 2048);
449 assert_eq!(cfg.reasoning, mermaid_model::models::ReasoningLevel::High);
450 assert_eq!(cfg.system_prompt.as_deref(), Some("sys"));
451 assert_eq!(
452 cfg.dynamic_system_suffix.as_deref(),
453 Some("instructions text")
454 );
455 }
456
457 #[test]
463 fn build_model_config_forwards_ollama_hardware_options() {
464 let req = ChatRequest {
465 model_id: "ollama/test".to_string(),
466 messages: vec![],
467 system_prompt: "sys".to_string(),
468 instructions: None,
469 reasoning: mermaid_model::models::ReasoningLevel::Medium,
470 temperature: 0.7,
471 max_tokens: 4096,
472 tools: vec![],
473
474 ollama_num_ctx: None,
475 ollama_allow_ram_offload: None,
476 resolved_context_window: None,
477 resolved_max_output: None,
478 output_schema: None,
479 suppress_auto_compact: false,
480 suppressed_builtin_tools: Vec::new(),
481 };
482 let mut app_cfg = mermaid_domain::Config::default();
483 app_cfg.ollama.num_gpu = Some(10);
484 app_cfg.ollama.num_thread = Some(8);
485 app_cfg.ollama.numa = Some(true);
486
487 let cfg = build_model_config(&req, &app_cfg, Some(8192), None);
489 let opts = cfg.ollama_options();
490 assert_eq!(opts.num_ctx, Some(8192));
491 assert_eq!(opts.num_gpu, Some(10));
492 assert_eq!(opts.num_thread, Some(8));
493 assert_eq!(opts.numa, Some(true));
494 assert!(opts.num_predict.is_some(), "num_predict is always derived");
495 }
496
497 #[test]
500 fn build_model_config_derives_num_predict() {
501 let req = ChatRequest {
502 model_id: "ollama/test".to_string(),
503 messages: vec![],
504 system_prompt: String::new(),
505 instructions: None,
506 reasoning: mermaid_model::models::ReasoningLevel::Max,
507 temperature: 0.7,
508 max_tokens: 4096,
509 tools: vec![],
510
511 ollama_num_ctx: None,
512 ollama_allow_ram_offload: None,
513 resolved_context_window: None,
514 resolved_max_output: None,
515 output_schema: None,
516 suppress_auto_compact: false,
517 suppressed_builtin_tools: Vec::new(),
518 };
519 let cfg = build_model_config(
520 &req,
521 &mermaid_domain::Config::default(),
522 Some(131_072),
523 None,
524 );
525 assert_eq!(cfg.ollama_options().num_predict, Some(4_096));
527 }
528
529 #[test]
533 fn build_model_config_caps_num_predict_at_learned_ceiling() {
534 let req = ChatRequest {
535 model_id: "ollama/minimax-m3:cloud".to_string(),
536 messages: vec![],
537 system_prompt: String::new(),
538 instructions: None,
539 reasoning: mermaid_model::models::ReasoningLevel::Medium,
540 temperature: 0.7,
541 max_tokens: 0, tools: vec![],
543
544 ollama_num_ctx: None,
545 ollama_allow_ram_offload: None,
546 resolved_context_window: None,
547 resolved_max_output: None,
548 output_schema: None,
549 suppress_auto_compact: false,
550 suppressed_builtin_tools: Vec::new(),
551 };
552 let app_cfg = mermaid_domain::Config::default();
553 let uncapped = build_model_config(&req, &app_cfg, Some(524_288), None);
556 assert!(uncapped.ollama_options().num_predict.unwrap() > 131_072);
557 let capped = build_model_config(&req, &app_cfg, Some(524_288), Some(131_072));
559 assert_eq!(capped.ollama_options().num_predict, Some(131_072));
560 }
561}