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::{
21 BackendConfig, Model, ModelConfig, ModelError, ReasoningChunk, Result, StreamCallback,
22 StreamEvent as ModelStreamEvent,
23};
24use crate::runtime::{NewProviderProbe, RuntimeStore};
25
26use super::super::capabilities::Capabilities;
27use super::super::ctx::{FinalResponse, StreamContext, StreamEvent};
28use super::{ContextSizing, ModelPlacement, ModelProvider};
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 }
126 }
127}
128
129#[async_trait]
130impl ModelProvider for OllamaProvider {
131 fn capabilities(&self) -> &Capabilities {
132 &self.capabilities
133 }
134
135 async fn resolve_context_window(&self, request: &ChatRequest) -> ContextSizing {
136 let info = self.probe().await.unwrap_or_default();
137 let inputs = self
138 .num_ctx_inputs(
139 &info,
140 request.ollama_num_ctx,
141 request.ollama_allow_ram_offload,
142 )
143 .await;
144 let model_max = inputs.model_max;
145 match resolve_ollama_num_ctx(&inputs) {
146 Some(r) => ContextSizing {
147 model_max,
148 effective: Some(r.value),
149 source: Some(r.source),
150 },
151 None => ContextSizing {
153 model_max,
154 effective: None,
155 source: None,
156 },
157 }
158 }
159
160 async fn verify_placement(&self, current_num_ctx: Option<usize>) -> Option<ModelPlacement> {
161 let (vram, total) = self.adapter.model_placement().await?;
162 if total == 0 {
165 return None;
166 }
167 let suggested_num_ctx = if vram < total {
171 let info = self.probe().await.unwrap_or_default();
172 current_num_ctx
173 .zip(info.dims)
174 .and_then(|(current, dims)| {
175 let kv = kv_bytes_per_token(&dims)?;
176 converge_num_ctx(current, vram, total, kv)
177 })
178 .map(|n| n as u32)
179 } else {
180 None
181 };
182 Some(ModelPlacement {
183 size_vram_bytes: vram,
184 total_bytes: total,
185 suggested_num_ctx,
186 })
187 }
188
189 async fn chat(&self, request: ChatRequest, ctx: StreamContext) -> Result<FinalResponse> {
190 let effective = self.resolve_context_window(&request).await.effective;
194 let config = build_model_config(&request, &self.config, effective);
195 let (relay_tx, relay_handle) = super::stream_bridge::ordered_relay(ctx.sink.clone());
200 let callback = stream_callback_for(relay_tx.clone());
201
202 let chat_fut = self
210 .adapter
211 .chat(&request.messages, &config, Some(callback));
212
213 let response = tokio::select! {
214 biased;
215 _ = ctx.token.cancelled() => {
216 return Err(ModelError::Cancelled);
222 },
223 r = chat_fut => r?,
224 };
225
226 let usage = response.usage.clone();
231 let thinking_signature = response.thinking_signature.clone();
232 let stop_reason = response.stop_reason.clone();
233 let _ = relay_tx.send(StreamEvent::Done {
235 usage: usage.clone(),
236 thinking_signature: thinking_signature.clone(),
237 stop_reason: stop_reason.clone(),
238 });
239 drop(relay_tx);
240 let _ = relay_handle.await;
241
242 Ok(FinalResponse {
243 usage,
244 thinking_signature,
245 tool_calls: response.tool_calls.unwrap_or_default(),
246 stop_reason,
247 })
248 }
249}
250
251fn build_model_config(
257 request: &ChatRequest,
258 app_config: &crate::app::Config,
259 num_ctx: Option<usize>,
260) -> ModelConfig {
261 let mut mc = ModelConfig {
262 model: request.model_id.clone(),
263 temperature: request.temperature,
264 max_tokens: request.max_tokens,
265 reasoning: request.reasoning,
266 system_prompt: Some(request.system_prompt.clone()),
267 dynamic_system_suffix: request.instructions.clone(),
268 tools: request.tools.iter().map(|t| t.to_openai_json()).collect(),
269 ..Default::default()
270 };
271 if let Some(n) = num_ctx {
273 mc.set_backend_option("ollama".into(), "num_ctx".into(), n.to_string());
274 }
275 let num_predict = default_ollama_num_predict(
279 request.max_tokens,
280 request.reasoning,
281 num_ctx,
282 estimate_prompt_tokens(request),
283 );
284 mc.set_backend_option(
285 "ollama".into(),
286 "num_predict".into(),
287 num_predict.to_string(),
288 );
289
290 if let Some(v) = app_config.ollama.num_gpu {
293 mc.set_backend_option("ollama".into(), "num_gpu".into(), v.to_string());
294 }
295 if let Some(v) = app_config.ollama.num_thread {
296 mc.set_backend_option("ollama".into(), "num_thread".into(), v.to_string());
297 }
298 if let Some(v) = app_config.ollama.numa {
299 mc.set_backend_option("ollama".into(), "numa".into(), v.to_string());
300 }
301 mc
302}
303
304fn estimate_prompt_tokens(request: &ChatRequest) -> usize {
308 let chars = request.system_prompt.len()
309 + request.instructions.as_deref().map_or(0, str::len)
310 + request
311 .messages
312 .iter()
313 .map(|m| m.content.len())
314 .sum::<usize>();
315 chars / 4
316}
317
318async fn load_probe_from_db(model: String) -> Option<OllamaModelInfo> {
321 tokio::task::spawn_blocking(move || {
322 let store = RuntimeStore::open_default().ok()?;
323 let rec = store
324 .provider_probes()
325 .get("ollama", &model, "context_probe")
326 .ok()??;
327 if probe_is_stale(&rec.probed_at) {
328 return None;
329 }
330 serde_json::from_str::<OllamaModelInfo>(&rec.capability_value).ok()
331 })
332 .await
333 .ok()
334 .flatten()
335}
336
337async fn save_probe_to_db(model: String, info: OllamaModelInfo) {
339 let _ = tokio::task::spawn_blocking(move || -> Option<()> {
340 let value = serde_json::to_string(&info).ok()?;
341 let store = RuntimeStore::open_default().ok()?;
342 store
343 .provider_probes()
344 .upsert(NewProviderProbe {
345 provider: "ollama".into(),
346 model_id: model,
347 capability_key: "context_probe".into(),
348 capability_value: value,
349 confidence: "probed".into(),
350 error: None,
351 })
352 .ok()?;
353 Some(())
354 })
355 .await;
356}
357
358fn probe_is_stale(probed_at: &str) -> bool {
359 use chrono::{DateTime, Utc};
360 match DateTime::parse_from_rfc3339(probed_at) {
361 Ok(t) => {
362 Utc::now()
363 .signed_duration_since(t.with_timezone(&Utc))
364 .num_days()
365 >= crate::constants::OLLAMA_PROBE_TTL_DAYS
366 },
367 Err(_) => true,
369 }
370}
371
372fn stream_callback_for(sink: tokio::sync::mpsc::UnboundedSender<StreamEvent>) -> StreamCallback {
378 Arc::new(move |event: ModelStreamEvent| {
379 let mapped = match event {
380 ModelStreamEvent::Text(s) => StreamEvent::Text(s),
381 ModelStreamEvent::Reasoning(chunk) => StreamEvent::Reasoning(ReasoningChunk {
382 text: chunk.text,
383 signature: chunk.signature,
384 }),
385 ModelStreamEvent::ToolCall(tc) => StreamEvent::ToolCall(tc),
386 ModelStreamEvent::Done { tokens } => StreamEvent::Done {
387 usage: if tokens > 0 {
388 Some(crate::models::TokenUsage::provider(0, tokens, tokens))
389 } else {
390 None
391 },
392 thinking_signature: None,
393 stop_reason: None,
394 },
395 };
396 let _ = sink.send(mapped);
399 })
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 #[test]
407 fn build_model_config_maps_request_fields() {
408 let req = ChatRequest {
409 model_id: "ollama/test".to_string(),
410 messages: vec![],
411 system_prompt: "sys".to_string(),
412 instructions: Some("instructions text".to_string()),
413 reasoning: crate::models::ReasoningLevel::High,
414 temperature: 0.3,
415 max_tokens: 2048,
416 tools: vec![],
417
418 ollama_num_ctx: None,
419 ollama_allow_ram_offload: None,
420 };
421 let app_cfg = crate::app::Config::default();
422 let cfg = build_model_config(&req, &app_cfg, None);
423 assert_eq!(cfg.model, "ollama/test");
424 assert_eq!(cfg.temperature, 0.3);
425 assert_eq!(cfg.max_tokens, 2048);
426 assert_eq!(cfg.reasoning, crate::models::ReasoningLevel::High);
427 assert_eq!(cfg.system_prompt.as_deref(), Some("sys"));
428 assert_eq!(
429 cfg.dynamic_system_suffix.as_deref(),
430 Some("instructions text")
431 );
432 }
433
434 #[test]
440 fn build_model_config_forwards_ollama_hardware_options() {
441 let req = ChatRequest {
442 model_id: "ollama/test".to_string(),
443 messages: vec![],
444 system_prompt: "sys".to_string(),
445 instructions: None,
446 reasoning: crate::models::ReasoningLevel::Medium,
447 temperature: 0.7,
448 max_tokens: 4096,
449 tools: vec![],
450
451 ollama_num_ctx: None,
452 ollama_allow_ram_offload: None,
453 };
454 let mut app_cfg = crate::app::Config::default();
455 app_cfg.ollama.num_gpu = Some(10);
456 app_cfg.ollama.num_thread = Some(8);
457 app_cfg.ollama.numa = Some(true);
458
459 let cfg = build_model_config(&req, &app_cfg, Some(8192));
461 let opts = cfg.ollama_options();
462 assert_eq!(opts.num_ctx, Some(8192));
463 assert_eq!(opts.num_gpu, Some(10));
464 assert_eq!(opts.num_thread, Some(8));
465 assert_eq!(opts.numa, Some(true));
466 assert!(opts.num_predict.is_some(), "num_predict is always derived");
467 }
468
469 #[test]
472 fn build_model_config_derives_num_predict() {
473 let req = ChatRequest {
474 model_id: "ollama/test".to_string(),
475 messages: vec![],
476 system_prompt: String::new(),
477 instructions: None,
478 reasoning: crate::models::ReasoningLevel::Max,
479 temperature: 0.7,
480 max_tokens: 4096,
481 tools: vec![],
482
483 ollama_num_ctx: None,
484 ollama_allow_ram_offload: None,
485 };
486 let cfg = build_model_config(&req, &crate::app::Config::default(), Some(131_072));
487 assert_eq!(cfg.ollama_options().num_predict, Some(12_288));
489 }
490
491 #[tokio::test]
492 async fn stream_callback_forwards_text_event() {
493 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
494 let cb = stream_callback_for(tx);
495 cb(ModelStreamEvent::Text("hello".to_string()));
496 let recv = tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv())
497 .await
498 .expect("recv")
499 .expect("sender alive");
500 match recv {
501 StreamEvent::Text(s) => assert_eq!(s, "hello"),
502 _ => panic!("wrong variant"),
503 }
504 }
505
506 #[tokio::test]
507 async fn stream_callback_forwards_done_with_tokens() {
508 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
509 let cb = stream_callback_for(tx);
510 cb(ModelStreamEvent::Done { tokens: 42 });
511 let recv = tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv())
512 .await
513 .expect("recv")
514 .expect("sender");
515 match recv {
516 StreamEvent::Done { usage, .. } => {
517 let u = usage.expect("tokens > 0 → Some");
518 assert_eq!(u.total_tokens, 42);
519 },
520 _ => panic!("wrong variant"),
521 }
522 }
523
524 #[tokio::test]
525 async fn stream_callback_done_zero_tokens_is_none_usage() {
526 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
527 let cb = stream_callback_for(tx);
528 cb(ModelStreamEvent::Done { tokens: 0 });
529 let recv = tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv())
530 .await
531 .expect("recv")
532 .expect("sender");
533 match recv {
534 StreamEvent::Done { usage, .. } => assert!(usage.is_none()),
535 _ => panic!("wrong variant"),
536 }
537 }
538}