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