1use std::sync::Arc;
11
12use async_trait::async_trait;
13
14use crate::domain::ChatRequest;
15use crate::models::adapters::ollama::{OllamaAdapter, OllamaModelInfo};
16use crate::models::adapters::ollama_sizing::{
17 NumCtxInputs, converge_num_ctx, default_ollama_num_predict, kv_bytes_per_token,
18 resolve_ollama_num_ctx,
19};
20use crate::models::{BackendConfig, Model, ModelConfig, ModelError, Result};
21use crate::runtime::{NewProviderProbe, RuntimeStore};
22
23use super::super::capabilities::Capabilities;
24use super::super::ctx::{FinalResponse, StreamContext, StreamEvent};
25use super::{
26 ContextSizing, ModelPlacement, ModelProvider, learn_output_cap, load_limits_from_db,
27 output_cap_from_error, probe_is_stale, retry_cap,
28};
29
30pub struct OllamaProvider {
32 adapter: OllamaAdapter,
33 capabilities: Capabilities,
34 config: Arc<crate::app::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> {
52 Self::with_app_config(model_name, backend, Arc::new(crate::app::Config::default())).await
53 }
54
55 pub async fn with_app_config(
60 model_name: &str,
61 backend: Arc<BackendConfig>,
62 config: Arc<crate::app::Config>,
63 ) -> Result<Self> {
64 let autostart = backend.ollama_autostart;
65 let adapter = OllamaAdapter::new(model_name, backend).await?;
66 let adapter = if autostart {
71 adapter.with_recovery(Arc::new(crate::ollama::OllamaAutostart))
72 } else {
73 adapter
74 };
75 let capabilities = Capabilities::from_legacy(adapter.capabilities());
76 Ok(Self {
77 adapter,
78 capabilities,
79 config,
80 ctx_cell: tokio::sync::OnceCell::new(),
81 })
82 }
83
84 async fn probe(&self) -> Option<OllamaModelInfo> {
87 self.ctx_cell
88 .get_or_try_init(|| async { self.load_probe().await.ok_or(()) })
89 .await
90 .ok()
91 .cloned()
92 }
93
94 async fn load_probe(&self) -> Option<OllamaModelInfo> {
95 let model = self.adapter.name().to_string();
96 if let Some(info) = load_probe_from_db(model.clone()).await {
97 return Some(info);
98 }
99 let info = self.adapter.show_model_info().await?;
100 save_probe_to_db(model, info.clone()).await;
101 Some(info)
102 }
103
104 async fn num_ctx_inputs(
109 &self,
110 info: &OllamaModelInfo,
111 override_num_ctx: Option<u32>,
112 override_offload: Option<bool>,
113 ) -> NumCtxInputs {
114 let allow_ram_offload = override_offload.unwrap_or(self.config.ollama.allow_ram_offload);
118 let (vram_bytes, system_ram_bytes) = if allow_ram_offload {
121 (None, crate::utils::system_ram_bytes())
122 } else {
123 (crate::utils::gpu_vram_bytes().await, None)
124 };
125 NumCtxInputs {
126 model_max: info.context_length,
127 dims: info.dims,
128 model_weight_bytes: info.weight_bytes,
129 per_model_override: override_num_ctx,
130 global_num_ctx: self.config.ollama.num_ctx,
131 allow_ram_offload,
132 vram_bytes,
133 system_ram_bytes,
134 max_auto_cap: self.config.ollama.max_auto_num_ctx,
135 is_cloud: crate::ollama::is_cloud_model(self.adapter.name()),
136 }
137 }
138}
139
140#[async_trait]
141impl ModelProvider for OllamaProvider {
142 fn capabilities(&self) -> &Capabilities {
143 &self.capabilities
144 }
145
146 async fn resolve_context_window(&self, request: &ChatRequest) -> ContextSizing {
147 let info = self.probe().await.unwrap_or_default();
148 let inputs = self
149 .num_ctx_inputs(
150 &info,
151 request.ollama_num_ctx,
152 request.ollama_allow_ram_offload,
153 )
154 .await;
155 let model_max = inputs.model_max;
156 let max_output =
161 load_limits_from_db("ollama".to_string(), Model::name(&self.adapter).to_string())
162 .await
163 .and_then(|l| l.max_output_tokens);
164 match resolve_ollama_num_ctx(&inputs) {
165 Some(r) => ContextSizing {
166 model_max,
167 effective: Some(r.value),
168 source: Some(r.source),
169 max_output,
170 },
171 None => ContextSizing {
173 model_max,
174 effective: None,
175 source: None,
176 max_output,
177 },
178 }
179 }
180
181 async fn verify_placement(&self, current_num_ctx: Option<usize>) -> Option<ModelPlacement> {
182 let (vram, total) = self.adapter.model_placement().await?;
183 if total == 0 {
186 return None;
187 }
188 let suggested_num_ctx = if vram < total {
192 let info = self.probe().await.unwrap_or_default();
193 current_num_ctx
194 .zip(info.dims)
195 .and_then(|(current, dims)| {
196 let kv = kv_bytes_per_token(&dims)?;
197 converge_num_ctx(current, vram, total, kv)
198 })
199 .map(|n| n as u32)
200 } else {
201 None
202 };
203 Some(ModelPlacement {
204 size_vram_bytes: vram,
205 total_bytes: total,
206 suggested_num_ctx,
207 })
208 }
209
210 async fn supports_vision(&self) -> Option<bool> {
211 Some(self.adapter.vision_supported().await)
212 }
213
214 async fn chat(&self, request: ChatRequest, ctx: StreamContext) -> Result<FinalResponse> {
215 let sizing = self.resolve_context_window(&request).await;
219 let config =
220 build_model_config(&request, &self.config, sizing.effective, sizing.max_output);
221 let (relay_tx, relay_handle) = super::stream_bridge::ordered_relay(ctx.sink.clone());
226 let callback = super::stream_bridge::forward_callback(relay_tx.clone());
227
228 let chat_fut = async {
236 match self
237 .adapter
238 .chat(&request.messages, &config, Some(callback.clone()))
239 .await
240 {
241 Ok(response) => Ok(response),
242 Err(err) => {
243 let Some(cap) = output_cap_from_error(&err) else {
249 return Err(err);
250 };
251 let sent = config
252 .ollama_options()
253 .num_predict
254 .map_or(0, |v| v.max(0) as usize);
255 if retry_cap(sent, cap).is_none() {
256 return Err(err);
257 }
258 let model = Model::name(&self.adapter).to_string();
259 learn_output_cap("ollama".to_string(), model.clone(), cap).await;
260 let _ = relay_tx.send(StreamEvent::Status(format!(
261 "{model} rejected the output budget; learned its {cap}-token cap and retrying"
262 )));
263 let retry_config =
264 build_model_config(&request, &self.config, sizing.effective, Some(cap));
265 self.adapter
266 .chat(&request.messages, &retry_config, Some(callback.clone()))
267 .await
268 },
269 }
270 };
271
272 let response = tokio::select! {
273 biased;
274 _ = ctx.token.cancelled() => {
275 return Err(ModelError::Cancelled);
281 },
282 r = chat_fut => r?,
283 };
284
285 let usage = response.usage.clone();
290 let provider_continuation = response.provider_continuation.clone();
291 let stop_reason = response.stop_reason.clone();
292 let _ = relay_tx.send(StreamEvent::Done {
294 usage: usage.clone(),
295 provider_continuation: provider_continuation.clone(),
296 stop_reason: stop_reason.clone(),
297 });
298 drop(relay_tx);
299 crate::utils::join_logged(relay_handle.take(), "stream_relay").await;
300
301 Ok(FinalResponse {
302 usage,
303 provider_continuation,
304 tool_calls: response.tool_calls.unwrap_or_default(),
305 stop_reason,
306 })
307 }
308}
309
310fn build_model_config(
318 request: &ChatRequest,
319 app_config: &crate::app::Config,
320 num_ctx: Option<usize>,
321 provider_max_output: Option<usize>,
322) -> ModelConfig {
323 let mut mc = ModelConfig {
324 model: request.model_id.clone(),
325 temperature: request.temperature,
326 max_tokens: request.max_tokens,
327 reasoning: request.reasoning,
328 system_prompt: Some(request.system_prompt.clone()),
329 dynamic_system_suffix: request.instructions.clone(),
330 tools: request.tools.iter().map(|t| t.to_openai_json()).collect(),
331 output_schema: request.output_schema.clone(),
332 ..Default::default()
333 };
334 if let Some(n) = num_ctx {
336 mc.set_backend_option("ollama".into(), "num_ctx".into(), n.to_string());
337 }
338 if let Some(num_predict) = default_ollama_num_predict(
344 request.max_tokens,
345 num_ctx,
346 estimate_prompt_tokens(request),
347 provider_max_output,
348 ) {
349 mc.set_backend_option(
350 "ollama".into(),
351 "num_predict".into(),
352 num_predict.to_string(),
353 );
354 }
355
356 if let Some(v) = app_config.ollama.num_gpu {
359 mc.set_backend_option("ollama".into(), "num_gpu".into(), v.to_string());
360 }
361 if let Some(v) = app_config.ollama.num_thread {
362 mc.set_backend_option("ollama".into(), "num_thread".into(), v.to_string());
363 }
364 if let Some(v) = app_config.ollama.numa {
365 mc.set_backend_option("ollama".into(), "numa".into(), v.to_string());
366 }
367 mc
368}
369
370fn estimate_prompt_tokens(request: &ChatRequest) -> usize {
374 let chars = request.system_prompt.len()
375 + request.instructions.as_deref().map_or(0, str::len)
376 + request
377 .messages
378 .iter()
379 .map(|m| m.content.len())
380 .sum::<usize>();
381 chars / 4
382}
383
384async fn load_probe_from_db(model: String) -> Option<OllamaModelInfo> {
387 tokio::task::spawn_blocking(move || {
388 let store = RuntimeStore::open_default().ok()?;
389 let rec = store
390 .provider_probes()
391 .get("ollama", &model, "context_probe")
392 .ok()??;
393 if probe_is_stale(&rec.probed_at) {
394 return None;
395 }
396 serde_json::from_str::<OllamaModelInfo>(&rec.capability_value).ok()
397 })
398 .await
399 .ok()
400 .flatten()
401}
402
403async fn save_probe_to_db(model: String, info: OllamaModelInfo) {
405 let _ = tokio::task::spawn_blocking(move || -> Option<()> {
406 let value = serde_json::to_string(&info).ok()?;
407 let store = RuntimeStore::open_default().ok()?;
408 store
409 .provider_probes()
410 .upsert(NewProviderProbe {
411 provider: "ollama".into(),
412 model_id: model,
413 capability_key: "context_probe".into(),
414 capability_value: value,
415 confidence: "probed".into(),
416 error: None,
417 })
418 .ok()?;
419 Some(())
420 })
421 .await;
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 #[test]
429 fn build_model_config_maps_request_fields() {
430 let req = ChatRequest {
431 model_id: "ollama/test".to_string(),
432 messages: vec![],
433 system_prompt: "sys".to_string(),
434 instructions: Some("instructions text".to_string()),
435 reasoning: crate::models::ReasoningLevel::High,
436 temperature: 0.3,
437 max_tokens: 2048,
438 tools: vec![],
439
440 ollama_num_ctx: None,
441 ollama_allow_ram_offload: None,
442 resolved_context_window: None,
443 resolved_max_output: None,
444 output_schema: None,
445 suppress_auto_compact: false,
446 suppressed_builtin_tools: Vec::new(),
447 };
448 let app_cfg = crate::app::Config::default();
449 let cfg = build_model_config(&req, &app_cfg, None, None);
450 assert_eq!(cfg.model, "ollama/test");
451 assert_eq!(cfg.temperature, 0.3);
452 assert_eq!(cfg.max_tokens, 2048);
453 assert_eq!(cfg.reasoning, crate::models::ReasoningLevel::High);
454 assert_eq!(cfg.system_prompt.as_deref(), Some("sys"));
455 assert_eq!(
456 cfg.dynamic_system_suffix.as_deref(),
457 Some("instructions text")
458 );
459 }
460
461 #[test]
467 fn build_model_config_forwards_ollama_hardware_options() {
468 let req = ChatRequest {
469 model_id: "ollama/test".to_string(),
470 messages: vec![],
471 system_prompt: "sys".to_string(),
472 instructions: None,
473 reasoning: crate::models::ReasoningLevel::Medium,
474 temperature: 0.7,
475 max_tokens: 4096,
476 tools: vec![],
477
478 ollama_num_ctx: None,
479 ollama_allow_ram_offload: None,
480 resolved_context_window: None,
481 resolved_max_output: None,
482 output_schema: None,
483 suppress_auto_compact: false,
484 suppressed_builtin_tools: Vec::new(),
485 };
486 let mut app_cfg = crate::app::Config::default();
487 app_cfg.ollama.num_gpu = Some(10);
488 app_cfg.ollama.num_thread = Some(8);
489 app_cfg.ollama.numa = Some(true);
490
491 let cfg = build_model_config(&req, &app_cfg, Some(8192), None);
493 let opts = cfg.ollama_options();
494 assert_eq!(opts.num_ctx, Some(8192));
495 assert_eq!(opts.num_gpu, Some(10));
496 assert_eq!(opts.num_thread, Some(8));
497 assert_eq!(opts.numa, Some(true));
498 assert!(opts.num_predict.is_some(), "num_predict is always derived");
499 }
500
501 #[test]
504 fn build_model_config_derives_num_predict() {
505 let req = ChatRequest {
506 model_id: "ollama/test".to_string(),
507 messages: vec![],
508 system_prompt: String::new(),
509 instructions: None,
510 reasoning: crate::models::ReasoningLevel::Max,
511 temperature: 0.7,
512 max_tokens: 4096,
513 tools: vec![],
514
515 ollama_num_ctx: None,
516 ollama_allow_ram_offload: None,
517 resolved_context_window: None,
518 resolved_max_output: None,
519 output_schema: None,
520 suppress_auto_compact: false,
521 suppressed_builtin_tools: Vec::new(),
522 };
523 let cfg = build_model_config(&req, &crate::app::Config::default(), Some(131_072), None);
524 assert_eq!(cfg.ollama_options().num_predict, Some(4_096));
526 }
527
528 #[test]
532 fn build_model_config_caps_num_predict_at_learned_ceiling() {
533 let req = ChatRequest {
534 model_id: "ollama/minimax-m3:cloud".to_string(),
535 messages: vec![],
536 system_prompt: String::new(),
537 instructions: None,
538 reasoning: crate::models::ReasoningLevel::Medium,
539 temperature: 0.7,
540 max_tokens: 0, tools: vec![],
542
543 ollama_num_ctx: None,
544 ollama_allow_ram_offload: None,
545 resolved_context_window: None,
546 resolved_max_output: None,
547 output_schema: None,
548 suppress_auto_compact: false,
549 suppressed_builtin_tools: Vec::new(),
550 };
551 let app_cfg = crate::app::Config::default();
552 let uncapped = build_model_config(&req, &app_cfg, Some(524_288), None);
555 assert!(uncapped.ollama_options().num_predict.unwrap() > 131_072);
556 let capped = build_model_config(&req, &app_cfg, Some(524_288), Some(131_072));
558 assert_eq!(capped.ollama_options().num_predict, Some(131_072));
559 }
560}