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 adapter = OllamaAdapter::new(model_name, backend).await?;
65 let capabilities = Capabilities::from_legacy(adapter.capabilities());
66 Ok(Self {
67 adapter,
68 capabilities,
69 config,
70 ctx_cell: tokio::sync::OnceCell::new(),
71 })
72 }
73
74 async fn probe(&self) -> Option<OllamaModelInfo> {
77 self.ctx_cell
78 .get_or_try_init(|| async { self.load_probe().await.ok_or(()) })
79 .await
80 .ok()
81 .cloned()
82 }
83
84 async fn load_probe(&self) -> Option<OllamaModelInfo> {
85 let model = self.adapter.name().to_string();
86 if let Some(info) = load_probe_from_db(model.clone()).await {
87 return Some(info);
88 }
89 let info = self.adapter.show_model_info().await?;
90 save_probe_to_db(model, info.clone()).await;
91 Some(info)
92 }
93
94 async fn num_ctx_inputs(
99 &self,
100 info: &OllamaModelInfo,
101 override_num_ctx: Option<u32>,
102 override_offload: Option<bool>,
103 ) -> NumCtxInputs {
104 let allow_ram_offload = override_offload.unwrap_or(self.config.ollama.allow_ram_offload);
108 let (vram_bytes, system_ram_bytes) = if allow_ram_offload {
111 (None, crate::utils::system_ram_bytes())
112 } else {
113 (crate::utils::gpu_vram_bytes().await, None)
114 };
115 NumCtxInputs {
116 model_max: info.context_length,
117 dims: info.dims,
118 model_weight_bytes: info.weight_bytes,
119 per_model_override: override_num_ctx,
120 global_num_ctx: self.config.ollama.num_ctx,
121 allow_ram_offload,
122 vram_bytes,
123 system_ram_bytes,
124 max_auto_cap: self.config.ollama.max_auto_num_ctx,
125 is_cloud: crate::ollama::is_cloud_model(self.adapter.name()),
126 }
127 }
128}
129
130#[async_trait]
131impl ModelProvider for OllamaProvider {
132 fn capabilities(&self) -> &Capabilities {
133 &self.capabilities
134 }
135
136 async fn resolve_context_window(&self, request: &ChatRequest) -> ContextSizing {
137 let info = self.probe().await.unwrap_or_default();
138 let inputs = self
139 .num_ctx_inputs(
140 &info,
141 request.ollama_num_ctx,
142 request.ollama_allow_ram_offload,
143 )
144 .await;
145 let model_max = inputs.model_max;
146 let max_output =
151 load_limits_from_db("ollama".to_string(), Model::name(&self.adapter).to_string())
152 .await
153 .and_then(|l| l.max_output_tokens);
154 match resolve_ollama_num_ctx(&inputs) {
155 Some(r) => ContextSizing {
156 model_max,
157 effective: Some(r.value),
158 source: Some(r.source),
159 max_output,
160 },
161 None => ContextSizing {
163 model_max,
164 effective: None,
165 source: None,
166 max_output,
167 },
168 }
169 }
170
171 async fn verify_placement(&self, current_num_ctx: Option<usize>) -> Option<ModelPlacement> {
172 let (vram, total) = self.adapter.model_placement().await?;
173 if total == 0 {
176 return None;
177 }
178 let suggested_num_ctx = if vram < total {
182 let info = self.probe().await.unwrap_or_default();
183 current_num_ctx
184 .zip(info.dims)
185 .and_then(|(current, dims)| {
186 let kv = kv_bytes_per_token(&dims)?;
187 converge_num_ctx(current, vram, total, kv)
188 })
189 .map(|n| n as u32)
190 } else {
191 None
192 };
193 Some(ModelPlacement {
194 size_vram_bytes: vram,
195 total_bytes: total,
196 suggested_num_ctx,
197 })
198 }
199
200 async fn supports_vision(&self) -> Option<bool> {
201 Some(self.adapter.vision_supported().await)
202 }
203
204 async fn chat(&self, request: ChatRequest, ctx: StreamContext) -> Result<FinalResponse> {
205 let sizing = self.resolve_context_window(&request).await;
209 let config =
210 build_model_config(&request, &self.config, sizing.effective, sizing.max_output);
211 let (relay_tx, relay_handle) = super::stream_bridge::ordered_relay(ctx.sink.clone());
216 let callback = super::stream_bridge::forward_callback(relay_tx.clone());
217
218 let chat_fut = async {
226 match self
227 .adapter
228 .chat(&request.messages, &config, Some(callback.clone()))
229 .await
230 {
231 Ok(response) => Ok(response),
232 Err(err) => {
233 let Some(cap) = output_cap_from_error(&err) else {
239 return Err(err);
240 };
241 let sent = config
242 .ollama_options()
243 .num_predict
244 .map_or(0, |v| v.max(0) as usize);
245 if retry_cap(sent, cap).is_none() {
246 return Err(err);
247 }
248 let model = Model::name(&self.adapter).to_string();
249 learn_output_cap("ollama".to_string(), model.clone(), cap).await;
250 let _ = relay_tx.send(StreamEvent::Status(format!(
251 "{model} rejected the output budget; learned its {cap}-token cap and retrying"
252 )));
253 let retry_config =
254 build_model_config(&request, &self.config, sizing.effective, Some(cap));
255 self.adapter
256 .chat(&request.messages, &retry_config, Some(callback.clone()))
257 .await
258 },
259 }
260 };
261
262 let response = tokio::select! {
263 biased;
264 _ = ctx.token.cancelled() => {
265 return Err(ModelError::Cancelled);
271 },
272 r = chat_fut => r?,
273 };
274
275 let usage = response.usage.clone();
280 let provider_continuation = response.provider_continuation.clone();
281 let stop_reason = response.stop_reason.clone();
282 let _ = relay_tx.send(StreamEvent::Done {
284 usage: usage.clone(),
285 provider_continuation: provider_continuation.clone(),
286 stop_reason: stop_reason.clone(),
287 });
288 drop(relay_tx);
289 crate::utils::join_logged(relay_handle.take(), "stream_relay").await;
290
291 Ok(FinalResponse {
292 usage,
293 provider_continuation,
294 tool_calls: response.tool_calls.unwrap_or_default(),
295 stop_reason,
296 })
297 }
298}
299
300fn build_model_config(
308 request: &ChatRequest,
309 app_config: &crate::app::Config,
310 num_ctx: Option<usize>,
311 provider_max_output: Option<usize>,
312) -> ModelConfig {
313 let mut mc = ModelConfig {
314 model: request.model_id.clone(),
315 temperature: request.temperature,
316 max_tokens: request.max_tokens,
317 reasoning: request.reasoning,
318 system_prompt: Some(request.system_prompt.clone()),
319 dynamic_system_suffix: request.instructions.clone(),
320 tools: request.tools.iter().map(|t| t.to_openai_json()).collect(),
321 output_schema: request.output_schema.clone(),
322 ..Default::default()
323 };
324 if let Some(n) = num_ctx {
326 mc.set_backend_option("ollama".into(), "num_ctx".into(), n.to_string());
327 }
328 if let Some(num_predict) = default_ollama_num_predict(
334 request.max_tokens,
335 num_ctx,
336 estimate_prompt_tokens(request),
337 provider_max_output,
338 ) {
339 mc.set_backend_option(
340 "ollama".into(),
341 "num_predict".into(),
342 num_predict.to_string(),
343 );
344 }
345
346 if let Some(v) = app_config.ollama.num_gpu {
349 mc.set_backend_option("ollama".into(), "num_gpu".into(), v.to_string());
350 }
351 if let Some(v) = app_config.ollama.num_thread {
352 mc.set_backend_option("ollama".into(), "num_thread".into(), v.to_string());
353 }
354 if let Some(v) = app_config.ollama.numa {
355 mc.set_backend_option("ollama".into(), "numa".into(), v.to_string());
356 }
357 mc
358}
359
360fn estimate_prompt_tokens(request: &ChatRequest) -> usize {
364 let chars = request.system_prompt.len()
365 + request.instructions.as_deref().map_or(0, str::len)
366 + request
367 .messages
368 .iter()
369 .map(|m| m.content.len())
370 .sum::<usize>();
371 chars / 4
372}
373
374async fn load_probe_from_db(model: String) -> Option<OllamaModelInfo> {
377 tokio::task::spawn_blocking(move || {
378 let store = RuntimeStore::open_default().ok()?;
379 let rec = store
380 .provider_probes()
381 .get("ollama", &model, "context_probe")
382 .ok()??;
383 if probe_is_stale(&rec.probed_at) {
384 return None;
385 }
386 serde_json::from_str::<OllamaModelInfo>(&rec.capability_value).ok()
387 })
388 .await
389 .ok()
390 .flatten()
391}
392
393async fn save_probe_to_db(model: String, info: OllamaModelInfo) {
395 let _ = tokio::task::spawn_blocking(move || -> Option<()> {
396 let value = serde_json::to_string(&info).ok()?;
397 let store = RuntimeStore::open_default().ok()?;
398 store
399 .provider_probes()
400 .upsert(NewProviderProbe {
401 provider: "ollama".into(),
402 model_id: model,
403 capability_key: "context_probe".into(),
404 capability_value: value,
405 confidence: "probed".into(),
406 error: None,
407 })
408 .ok()?;
409 Some(())
410 })
411 .await;
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 #[test]
419 fn build_model_config_maps_request_fields() {
420 let req = ChatRequest {
421 model_id: "ollama/test".to_string(),
422 messages: vec![],
423 system_prompt: "sys".to_string(),
424 instructions: Some("instructions text".to_string()),
425 reasoning: crate::models::ReasoningLevel::High,
426 temperature: 0.3,
427 max_tokens: 2048,
428 tools: vec![],
429
430 ollama_num_ctx: None,
431 ollama_allow_ram_offload: None,
432 resolved_context_window: None,
433 resolved_max_output: None,
434 output_schema: None,
435 suppress_auto_compact: false,
436 suppressed_builtin_tools: Vec::new(),
437 };
438 let app_cfg = crate::app::Config::default();
439 let cfg = build_model_config(&req, &app_cfg, None, None);
440 assert_eq!(cfg.model, "ollama/test");
441 assert_eq!(cfg.temperature, 0.3);
442 assert_eq!(cfg.max_tokens, 2048);
443 assert_eq!(cfg.reasoning, crate::models::ReasoningLevel::High);
444 assert_eq!(cfg.system_prompt.as_deref(), Some("sys"));
445 assert_eq!(
446 cfg.dynamic_system_suffix.as_deref(),
447 Some("instructions text")
448 );
449 }
450
451 #[test]
457 fn build_model_config_forwards_ollama_hardware_options() {
458 let req = ChatRequest {
459 model_id: "ollama/test".to_string(),
460 messages: vec![],
461 system_prompt: "sys".to_string(),
462 instructions: None,
463 reasoning: crate::models::ReasoningLevel::Medium,
464 temperature: 0.7,
465 max_tokens: 4096,
466 tools: vec![],
467
468 ollama_num_ctx: None,
469 ollama_allow_ram_offload: None,
470 resolved_context_window: None,
471 resolved_max_output: None,
472 output_schema: None,
473 suppress_auto_compact: false,
474 suppressed_builtin_tools: Vec::new(),
475 };
476 let mut app_cfg = crate::app::Config::default();
477 app_cfg.ollama.num_gpu = Some(10);
478 app_cfg.ollama.num_thread = Some(8);
479 app_cfg.ollama.numa = Some(true);
480
481 let cfg = build_model_config(&req, &app_cfg, Some(8192), None);
483 let opts = cfg.ollama_options();
484 assert_eq!(opts.num_ctx, Some(8192));
485 assert_eq!(opts.num_gpu, Some(10));
486 assert_eq!(opts.num_thread, Some(8));
487 assert_eq!(opts.numa, Some(true));
488 assert!(opts.num_predict.is_some(), "num_predict is always derived");
489 }
490
491 #[test]
494 fn build_model_config_derives_num_predict() {
495 let req = ChatRequest {
496 model_id: "ollama/test".to_string(),
497 messages: vec![],
498 system_prompt: String::new(),
499 instructions: None,
500 reasoning: crate::models::ReasoningLevel::Max,
501 temperature: 0.7,
502 max_tokens: 4096,
503 tools: vec![],
504
505 ollama_num_ctx: None,
506 ollama_allow_ram_offload: None,
507 resolved_context_window: None,
508 resolved_max_output: None,
509 output_schema: None,
510 suppress_auto_compact: false,
511 suppressed_builtin_tools: Vec::new(),
512 };
513 let cfg = build_model_config(&req, &crate::app::Config::default(), Some(131_072), None);
514 assert_eq!(cfg.ollama_options().num_predict, Some(4_096));
516 }
517
518 #[test]
522 fn build_model_config_caps_num_predict_at_learned_ceiling() {
523 let req = ChatRequest {
524 model_id: "ollama/minimax-m3:cloud".to_string(),
525 messages: vec![],
526 system_prompt: String::new(),
527 instructions: None,
528 reasoning: crate::models::ReasoningLevel::Medium,
529 temperature: 0.7,
530 max_tokens: 0, tools: vec![],
532
533 ollama_num_ctx: None,
534 ollama_allow_ram_offload: None,
535 resolved_context_window: None,
536 resolved_max_output: None,
537 output_schema: None,
538 suppress_auto_compact: false,
539 suppressed_builtin_tools: Vec::new(),
540 };
541 let app_cfg = crate::app::Config::default();
542 let uncapped = build_model_config(&req, &app_cfg, Some(524_288), None);
545 assert!(uncapped.ollama_options().num_predict.unwrap() > 131_072);
546 let capped = build_model_config(&req, &app_cfg, Some(524_288), Some(131_072));
548 assert_eq!(capped.ollama_options().num_predict, Some(131_072));
549 }
550}