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::{
29 ContextSizing, ModelPlacement, ModelProvider, learn_output_cap, load_limits_from_db,
30 output_cap_from_error, probe_is_stale, retry_cap,
31};
32
33pub struct OllamaProvider {
35 adapter: OllamaAdapter,
36 capabilities: Capabilities,
37 config: Arc<crate::app::Config>,
42 ctx_cell: tokio::sync::OnceCell<OllamaModelInfo>,
48}
49
50impl OllamaProvider {
51 pub async fn new(model_name: &str, backend: Arc<BackendConfig>) -> Result<Self> {
55 Self::with_app_config(model_name, backend, Arc::new(crate::app::Config::default())).await
56 }
57
58 pub async fn with_app_config(
63 model_name: &str,
64 backend: Arc<BackendConfig>,
65 config: Arc<crate::app::Config>,
66 ) -> Result<Self> {
67 let adapter = OllamaAdapter::new(model_name, backend).await?;
68 let capabilities = Capabilities::from_legacy(adapter.capabilities());
69 Ok(Self {
70 adapter,
71 capabilities,
72 config,
73 ctx_cell: tokio::sync::OnceCell::new(),
74 })
75 }
76
77 async fn probe(&self) -> Option<OllamaModelInfo> {
80 self.ctx_cell
81 .get_or_try_init(|| async { self.load_probe().await.ok_or(()) })
82 .await
83 .ok()
84 .cloned()
85 }
86
87 async fn load_probe(&self) -> Option<OllamaModelInfo> {
88 let model = self.adapter.name().to_string();
89 if let Some(info) = load_probe_from_db(model.clone()).await {
90 return Some(info);
91 }
92 let info = self.adapter.show_model_info().await?;
93 save_probe_to_db(model, info.clone()).await;
94 Some(info)
95 }
96
97 async fn num_ctx_inputs(
102 &self,
103 info: &OllamaModelInfo,
104 override_num_ctx: Option<u32>,
105 override_offload: Option<bool>,
106 ) -> NumCtxInputs {
107 let allow_ram_offload = override_offload.unwrap_or(self.config.ollama.allow_ram_offload);
111 let (vram_bytes, system_ram_bytes) = if allow_ram_offload {
114 (None, crate::utils::system_ram_bytes())
115 } else {
116 (crate::utils::gpu_vram_bytes().await, None)
117 };
118 NumCtxInputs {
119 model_max: info.context_length,
120 dims: info.dims,
121 model_weight_bytes: info.weight_bytes,
122 per_model_override: override_num_ctx,
123 global_num_ctx: self.config.ollama.num_ctx,
124 allow_ram_offload,
125 vram_bytes,
126 system_ram_bytes,
127 max_auto_cap: self.config.ollama.max_auto_num_ctx,
128 is_cloud: crate::ollama::is_cloud_model(self.adapter.name()),
129 }
130 }
131}
132
133#[async_trait]
134impl ModelProvider for OllamaProvider {
135 fn capabilities(&self) -> &Capabilities {
136 &self.capabilities
137 }
138
139 async fn resolve_context_window(&self, request: &ChatRequest) -> ContextSizing {
140 let info = self.probe().await.unwrap_or_default();
141 let inputs = self
142 .num_ctx_inputs(
143 &info,
144 request.ollama_num_ctx,
145 request.ollama_allow_ram_offload,
146 )
147 .await;
148 let model_max = inputs.model_max;
149 let max_output =
154 load_limits_from_db("ollama".to_string(), Model::name(&self.adapter).to_string())
155 .await
156 .and_then(|l| l.max_output_tokens);
157 match resolve_ollama_num_ctx(&inputs) {
158 Some(r) => ContextSizing {
159 model_max,
160 effective: Some(r.value),
161 source: Some(r.source),
162 max_output,
163 },
164 None => ContextSizing {
166 model_max,
167 effective: None,
168 source: None,
169 max_output,
170 },
171 }
172 }
173
174 async fn verify_placement(&self, current_num_ctx: Option<usize>) -> Option<ModelPlacement> {
175 let (vram, total) = self.adapter.model_placement().await?;
176 if total == 0 {
179 return None;
180 }
181 let suggested_num_ctx = if vram < total {
185 let info = self.probe().await.unwrap_or_default();
186 current_num_ctx
187 .zip(info.dims)
188 .and_then(|(current, dims)| {
189 let kv = kv_bytes_per_token(&dims)?;
190 converge_num_ctx(current, vram, total, kv)
191 })
192 .map(|n| n as u32)
193 } else {
194 None
195 };
196 Some(ModelPlacement {
197 size_vram_bytes: vram,
198 total_bytes: total,
199 suggested_num_ctx,
200 })
201 }
202
203 async fn supports_vision(&self) -> Option<bool> {
204 Some(self.adapter.vision_supported().await)
205 }
206
207 async fn chat(&self, request: ChatRequest, ctx: StreamContext) -> Result<FinalResponse> {
208 let sizing = self.resolve_context_window(&request).await;
212 let config =
213 build_model_config(&request, &self.config, sizing.effective, sizing.max_output);
214 let (relay_tx, relay_handle) = super::stream_bridge::ordered_relay(ctx.sink.clone());
219 let callback = stream_callback_for(relay_tx.clone());
220
221 let chat_fut = async {
229 match self
230 .adapter
231 .chat(&request.messages, &config, Some(callback.clone()))
232 .await
233 {
234 Ok(response) => Ok(response),
235 Err(err) => {
236 let Some(cap) = output_cap_from_error(&err) else {
242 return Err(err);
243 };
244 let sent = config
245 .ollama_options()
246 .num_predict
247 .map_or(0, |v| v.max(0) as usize);
248 if retry_cap(sent, cap).is_none() {
249 return Err(err);
250 }
251 let model = Model::name(&self.adapter).to_string();
252 learn_output_cap("ollama".to_string(), model.clone(), cap).await;
253 let _ = relay_tx.send(StreamEvent::Status(format!(
254 "{model} rejected the output budget; learned its {cap}-token cap and retrying"
255 )));
256 let retry_config =
257 build_model_config(&request, &self.config, sizing.effective, Some(cap));
258 self.adapter
259 .chat(&request.messages, &retry_config, Some(callback.clone()))
260 .await
261 },
262 }
263 };
264
265 let response = tokio::select! {
266 biased;
267 _ = ctx.token.cancelled() => {
268 return Err(ModelError::Cancelled);
274 },
275 r = chat_fut => r?,
276 };
277
278 let usage = response.usage.clone();
283 let provider_continuation = response.provider_continuation.clone();
284 let stop_reason = response.stop_reason.clone();
285 let _ = relay_tx.send(StreamEvent::Done {
287 usage: usage.clone(),
288 provider_continuation: provider_continuation.clone(),
289 stop_reason: stop_reason.clone(),
290 });
291 drop(relay_tx);
292 crate::utils::join_logged(relay_handle.take(), "stream_relay").await;
293
294 Ok(FinalResponse {
295 usage,
296 provider_continuation,
297 tool_calls: response.tool_calls.unwrap_or_default(),
298 stop_reason,
299 })
300 }
301}
302
303fn build_model_config(
311 request: &ChatRequest,
312 app_config: &crate::app::Config,
313 num_ctx: Option<usize>,
314 provider_max_output: Option<usize>,
315) -> ModelConfig {
316 let mut mc = ModelConfig {
317 model: request.model_id.clone(),
318 temperature: request.temperature,
319 max_tokens: request.max_tokens,
320 reasoning: request.reasoning,
321 system_prompt: Some(request.system_prompt.clone()),
322 dynamic_system_suffix: request.instructions.clone(),
323 tools: request.tools.iter().map(|t| t.to_openai_json()).collect(),
324 output_schema: request.output_schema.clone(),
325 ..Default::default()
326 };
327 if let Some(n) = num_ctx {
329 mc.set_backend_option("ollama".into(), "num_ctx".into(), n.to_string());
330 }
331 if let Some(num_predict) = default_ollama_num_predict(
337 request.max_tokens,
338 num_ctx,
339 estimate_prompt_tokens(request),
340 provider_max_output,
341 ) {
342 mc.set_backend_option(
343 "ollama".into(),
344 "num_predict".into(),
345 num_predict.to_string(),
346 );
347 }
348
349 if let Some(v) = app_config.ollama.num_gpu {
352 mc.set_backend_option("ollama".into(), "num_gpu".into(), v.to_string());
353 }
354 if let Some(v) = app_config.ollama.num_thread {
355 mc.set_backend_option("ollama".into(), "num_thread".into(), v.to_string());
356 }
357 if let Some(v) = app_config.ollama.numa {
358 mc.set_backend_option("ollama".into(), "numa".into(), v.to_string());
359 }
360 mc
361}
362
363fn estimate_prompt_tokens(request: &ChatRequest) -> usize {
367 let chars = request.system_prompt.len()
368 + request.instructions.as_deref().map_or(0, str::len)
369 + request
370 .messages
371 .iter()
372 .map(|m| m.content.len())
373 .sum::<usize>();
374 chars / 4
375}
376
377async fn load_probe_from_db(model: String) -> Option<OllamaModelInfo> {
380 tokio::task::spawn_blocking(move || {
381 let store = RuntimeStore::open_default().ok()?;
382 let rec = store
383 .provider_probes()
384 .get("ollama", &model, "context_probe")
385 .ok()??;
386 if probe_is_stale(&rec.probed_at) {
387 return None;
388 }
389 serde_json::from_str::<OllamaModelInfo>(&rec.capability_value).ok()
390 })
391 .await
392 .ok()
393 .flatten()
394}
395
396async fn save_probe_to_db(model: String, info: OllamaModelInfo) {
398 let _ = tokio::task::spawn_blocking(move || -> Option<()> {
399 let value = serde_json::to_string(&info).ok()?;
400 let store = RuntimeStore::open_default().ok()?;
401 store
402 .provider_probes()
403 .upsert(NewProviderProbe {
404 provider: "ollama".into(),
405 model_id: model,
406 capability_key: "context_probe".into(),
407 capability_value: value,
408 confidence: "probed".into(),
409 error: None,
410 })
411 .ok()?;
412 Some(())
413 })
414 .await;
415}
416
417fn stream_callback_for(sink: tokio::sync::mpsc::UnboundedSender<StreamEvent>) -> StreamCallback {
423 Arc::new(move |event: ModelStreamEvent| {
424 let mapped = match event {
425 ModelStreamEvent::Text(s) => StreamEvent::Text(s),
426 ModelStreamEvent::Reasoning(chunk) => StreamEvent::Reasoning(ReasoningChunk {
427 text: chunk.text,
428 signature: chunk.signature,
429 }),
430 ModelStreamEvent::ToolCall(tc) => StreamEvent::ToolCall(tc),
431 ModelStreamEvent::Status(s) => StreamEvent::Status(s),
432 ModelStreamEvent::Done { .. } => StreamEvent::Done {
438 usage: None,
439 provider_continuation: None,
440 stop_reason: None,
441 },
442 };
443 let _ = sink.send(mapped);
446 })
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452
453 #[test]
454 fn build_model_config_maps_request_fields() {
455 let req = ChatRequest {
456 model_id: "ollama/test".to_string(),
457 messages: vec![],
458 system_prompt: "sys".to_string(),
459 instructions: Some("instructions text".to_string()),
460 reasoning: crate::models::ReasoningLevel::High,
461 temperature: 0.3,
462 max_tokens: 2048,
463 tools: vec![],
464
465 ollama_num_ctx: None,
466 ollama_allow_ram_offload: None,
467 resolved_context_window: None,
468 resolved_max_output: None,
469 output_schema: None,
470 suppress_auto_compact: false,
471 };
472 let app_cfg = crate::app::Config::default();
473 let cfg = build_model_config(&req, &app_cfg, None, None);
474 assert_eq!(cfg.model, "ollama/test");
475 assert_eq!(cfg.temperature, 0.3);
476 assert_eq!(cfg.max_tokens, 2048);
477 assert_eq!(cfg.reasoning, crate::models::ReasoningLevel::High);
478 assert_eq!(cfg.system_prompt.as_deref(), Some("sys"));
479 assert_eq!(
480 cfg.dynamic_system_suffix.as_deref(),
481 Some("instructions text")
482 );
483 }
484
485 #[test]
491 fn build_model_config_forwards_ollama_hardware_options() {
492 let req = ChatRequest {
493 model_id: "ollama/test".to_string(),
494 messages: vec![],
495 system_prompt: "sys".to_string(),
496 instructions: None,
497 reasoning: crate::models::ReasoningLevel::Medium,
498 temperature: 0.7,
499 max_tokens: 4096,
500 tools: vec![],
501
502 ollama_num_ctx: None,
503 ollama_allow_ram_offload: None,
504 resolved_context_window: None,
505 resolved_max_output: None,
506 output_schema: None,
507 suppress_auto_compact: false,
508 };
509 let mut app_cfg = crate::app::Config::default();
510 app_cfg.ollama.num_gpu = Some(10);
511 app_cfg.ollama.num_thread = Some(8);
512 app_cfg.ollama.numa = Some(true);
513
514 let cfg = build_model_config(&req, &app_cfg, Some(8192), None);
516 let opts = cfg.ollama_options();
517 assert_eq!(opts.num_ctx, Some(8192));
518 assert_eq!(opts.num_gpu, Some(10));
519 assert_eq!(opts.num_thread, Some(8));
520 assert_eq!(opts.numa, Some(true));
521 assert!(opts.num_predict.is_some(), "num_predict is always derived");
522 }
523
524 #[test]
527 fn build_model_config_derives_num_predict() {
528 let req = ChatRequest {
529 model_id: "ollama/test".to_string(),
530 messages: vec![],
531 system_prompt: String::new(),
532 instructions: None,
533 reasoning: crate::models::ReasoningLevel::Max,
534 temperature: 0.7,
535 max_tokens: 4096,
536 tools: vec![],
537
538 ollama_num_ctx: None,
539 ollama_allow_ram_offload: None,
540 resolved_context_window: None,
541 resolved_max_output: None,
542 output_schema: None,
543 suppress_auto_compact: false,
544 };
545 let cfg = build_model_config(&req, &crate::app::Config::default(), Some(131_072), None);
546 assert_eq!(cfg.ollama_options().num_predict, Some(4_096));
548 }
549
550 #[test]
554 fn build_model_config_caps_num_predict_at_learned_ceiling() {
555 let req = ChatRequest {
556 model_id: "ollama/minimax-m3:cloud".to_string(),
557 messages: vec![],
558 system_prompt: String::new(),
559 instructions: None,
560 reasoning: crate::models::ReasoningLevel::Medium,
561 temperature: 0.7,
562 max_tokens: 0, tools: vec![],
564
565 ollama_num_ctx: None,
566 ollama_allow_ram_offload: None,
567 resolved_context_window: None,
568 resolved_max_output: None,
569 output_schema: None,
570 suppress_auto_compact: false,
571 };
572 let app_cfg = crate::app::Config::default();
573 let uncapped = build_model_config(&req, &app_cfg, Some(524_288), None);
576 assert!(uncapped.ollama_options().num_predict.unwrap() > 131_072);
577 let capped = build_model_config(&req, &app_cfg, Some(524_288), Some(131_072));
579 assert_eq!(capped.ollama_options().num_predict, Some(131_072));
580 }
581
582 #[tokio::test]
583 async fn stream_callback_forwards_text_event() {
584 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
585 let cb = stream_callback_for(tx);
586 cb(ModelStreamEvent::Text("hello".to_string()));
587 let recv = tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv())
588 .await
589 .expect("recv")
590 .expect("sender alive");
591 match recv {
592 StreamEvent::Text(s) => assert_eq!(s, "hello"),
593 _ => panic!("wrong variant"),
594 }
595 }
596
597 #[tokio::test]
598 async fn stream_callback_forwards_status_notice() {
599 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
603 let cb = stream_callback_for(tx);
604 cb(ModelStreamEvent::Status(
605 "Starting the local Ollama server…".to_string(),
606 ));
607 let recv = tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv())
608 .await
609 .expect("recv")
610 .expect("sender alive");
611 match recv {
612 StreamEvent::Status(s) => assert!(s.contains("Starting")),
613 _ => panic!("wrong variant"),
614 }
615 }
616
617 #[tokio::test]
618 async fn stream_callback_done_never_invents_usage() {
619 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
623 let cb = stream_callback_for(tx);
624 cb(ModelStreamEvent::Done { tokens: 42 });
625 let recv = tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv())
626 .await
627 .expect("recv")
628 .expect("sender");
629 match recv {
630 StreamEvent::Done { usage, .. } => assert!(usage.is_none()),
631 _ => panic!("wrong variant"),
632 }
633 }
634}