1use async_trait::async_trait;
7use futures::StreamExt;
8use reqwest::Client;
9use serde::{Deserialize, Serialize};
10use serde_json::json;
11use std::sync::Arc;
12use std::time::Duration;
13
14use crate::constants::MAX_RESPONSE_CHARS;
15use crate::models::ModelCapabilities;
16use crate::models::adapters::ollama_sizing::ModelDims;
17use crate::models::config::{BackendConfig, ModelConfig};
18use crate::models::error::{BackendError, ModelError, Result};
19use crate::models::reasoning::{ReasoningChunk, ReasoningLevel};
20use crate::models::stream::{StreamCallback, StreamEvent};
21use crate::models::traits::Model;
22use crate::models::types::{ChatMessage, FinishReason, MessageRole, ModelResponse, TokenUsage};
23use crate::utils::drain_complete_lines;
24
25const TRUNCATION_MARKER: &str = "\n\n[TRUNCATED: response exceeded size limit]";
31
32struct StreamAccumulator {
34 content: String,
35 thinking: String,
36 tool_calls: Vec<crate::models::ToolCall>,
37 hide_reasoning_trace: bool,
42 prompt_tokens: usize,
43 completion_tokens: usize,
44 saw_usage: bool,
49 done_reason: Option<String>,
52 saw_done: bool,
60 truncated: bool,
65}
66
67impl StreamAccumulator {
68 fn usage(&self) -> Option<TokenUsage> {
73 self.saw_usage
74 .then(|| TokenUsage::provider(self.prompt_tokens, self.completion_tokens))
75 }
76
77 fn closed_abnormally(&self) -> bool {
85 !self.saw_done
86 }
87}
88
89fn push_capped(buf: &mut String, chunk: &str, truncated: &mut bool, cap: usize) {
92 if *truncated {
93 return;
94 }
95 buf.push_str(chunk);
96 if buf.len() > cap {
97 let end = buf.floor_char_boundary(cap);
98 buf.truncate(end);
99 buf.push_str(TRUNCATION_MARKER);
100 *truncated = true;
101 }
102}
103
104#[async_trait]
117pub trait LocalServerRecovery: Send + Sync {
118 async fn ensure_running(
122 &self,
123 base_url: &str,
124 notify: Option<&(dyn for<'a> Fn(&'a str) + Sync)>,
125 ) -> std::result::Result<(), Option<String>>;
126}
127
128pub struct OllamaAdapter {
130 client: Client,
131 base_url: String,
132 model_name: String,
133 capabilities: ModelCapabilities,
134 thinking_cap: tokio::sync::OnceCell<bool>,
139 vision_cap: tokio::sync::OnceCell<bool>,
144 recovery: Option<Arc<dyn LocalServerRecovery>>,
150 status_notify: Option<StreamCallback>,
158}
159
160fn uses_effort_string_think(model_name: &str) -> bool {
165 matches!(
166 crate::models::catalog::lookup(model_name).thinking,
167 crate::models::catalog::ThinkingShape::OllamaEffortString
168 )
169}
170
171fn think_for_ollama(
185 model_name: &str,
186 level: ReasoningLevel,
187 supports_thinking: bool,
188) -> Option<serde_json::Value> {
189 if uses_effort_string_think(model_name) {
190 let effort = match level {
191 ReasoningLevel::None | ReasoningLevel::Minimal | ReasoningLevel::Low => "low",
195 ReasoningLevel::Medium => "medium",
196 ReasoningLevel::High | ReasoningLevel::Max | ReasoningLevel::XHigh => "high",
197 };
198 return Some(serde_json::Value::String(effort.to_string()));
199 }
200 if !supports_thinking {
201 return None;
203 }
204 Some(serde_json::Value::Bool(level != ReasoningLevel::None))
205}
206
207impl OllamaAdapter {
208 pub async fn new(model_name: &str, config: Arc<BackendConfig>) -> Result<Self> {
210 let base_url = normalize_url(&config.ollama_url);
211
212 let client = Client::builder()
216 .pool_max_idle_per_host(config.max_idle_per_host)
217 .pool_idle_timeout(Duration::from_secs(90))
218 .tcp_keepalive(Duration::from_secs(60))
219 .connect_timeout(Duration::from_secs(config.timeout_secs))
220 .build()
221 .map_err(|e| {
222 ModelError::Backend(BackendError::ConnectionFailed {
223 backend: "ollama".to_string(),
224 url: base_url.clone(),
225 reason: e.to_string(),
226 })
227 })?;
228
229 let capabilities = if uses_effort_string_think(model_name) {
234 ModelCapabilities {
235 supports_tools: true,
236 supports_vision: false,
237 supports_reasoning: crate::models::ReasoningCapability::Levels(vec![
238 ReasoningLevel::None,
239 ReasoningLevel::Low,
240 ReasoningLevel::Medium,
241 ReasoningLevel::High,
242 ]),
243 max_context_tokens: None,
244 max_output_tokens: None,
245 }
246 } else {
247 ModelCapabilities::ollama_default()
248 };
249
250 Ok(Self {
251 client,
252 base_url,
253 model_name: model_name.to_string(),
254 capabilities,
255 thinking_cap: tokio::sync::OnceCell::new(),
256 vision_cap: tokio::sync::OnceCell::new(),
257 recovery: None,
258 status_notify: None,
259 })
260 }
261
262 pub fn with_recovery(mut self, recovery: Arc<dyn LocalServerRecovery>) -> Self {
266 self.recovery = Some(recovery);
267 self
268 }
269
270 pub fn with_status_notify(mut self, notify: StreamCallback) -> Self {
275 self.status_notify = Some(notify);
276 self
277 }
278
279 async fn thinking_supported(&self) -> bool {
286 *self
287 .thinking_cap
288 .get_or_try_init(|| async {
289 match self.probe_capabilities().await {
290 Some(caps) if !caps.is_empty() => Ok(caps.iter().any(|c| c == "thinking")),
291 _ => Err(()),
292 }
293 })
294 .await
295 .unwrap_or(&true)
296 }
297
298 pub async fn vision_supported(&self) -> bool {
305 *self
306 .vision_cap
307 .get_or_try_init(|| async {
308 match self.probe_capabilities().await {
309 Some(caps) if !caps.is_empty() => Ok(caps.iter().any(|c| c == "vision")),
310 _ => Err(()),
311 }
312 })
313 .await
314 .unwrap_or(&true)
315 }
316
317 async fn probe_capabilities(&self) -> Option<Vec<String>> {
320 let url = format!("{}/api/show", self.base_url);
321 let resp = self
322 .client
323 .post(&url)
324 .json(&json!({ "model": self.model_name }))
325 .timeout(std::time::Duration::from_secs(
326 crate::constants::OLLAMA_PROBE_TIMEOUT_SECS,
327 ))
328 .send()
329 .await
330 .ok()?;
331 if !resp.status().is_success() {
332 return None;
333 }
334 let show: OllamaShowResponse = resp.json().await.ok()?;
335 Some(show.capabilities)
336 }
337
338 pub async fn show_model_info(&self) -> Option<OllamaModelInfo> {
345 let url = format!("{}/api/show", self.base_url);
346 let resp = self
347 .client
348 .post(&url)
349 .json(&json!({ "model": self.model_name }))
350 .timeout(std::time::Duration::from_secs(
351 crate::constants::OLLAMA_PROBE_TIMEOUT_SECS,
352 ))
353 .send()
354 .await
355 .ok()?;
356 if !resp.status().is_success() {
357 return None;
358 }
359 let show: OllamaShowResponse = resp.json().await.ok()?;
360 let context_length = context_length_from_model_info(&show.model_info);
361 let dims = dims_from_model_info(&show.model_info);
362 let weight_bytes = self.model_size_bytes().await;
363
364 if context_length.is_none() && dims.is_none() && weight_bytes.is_none() {
367 return None;
368 }
369 Some(OllamaModelInfo {
370 context_length,
371 dims,
372 weight_bytes,
373 })
374 }
375
376 async fn model_size_bytes(&self) -> Option<u64> {
379 let url = format!("{}/api/tags", self.base_url);
380 let resp = self
381 .client
382 .get(&url)
383 .timeout(std::time::Duration::from_secs(
384 crate::constants::OLLAMA_PROBE_TIMEOUT_SECS,
385 ))
386 .send()
387 .await
388 .ok()?;
389 if !resp.status().is_success() {
390 return None;
391 }
392 let tags: OllamaTagsResponse = resp.json().await.ok()?;
393 tags.models
394 .into_iter()
395 .find(|m| m.name == self.model_name)
396 .and_then(|m| m.size)
397 }
398
399 pub async fn model_placement(&self) -> Option<(u64, u64)> {
405 let url = format!("{}/api/ps", self.base_url);
406 let resp = self
407 .client
408 .get(&url)
409 .timeout(std::time::Duration::from_secs(
410 crate::constants::OLLAMA_PROBE_TIMEOUT_SECS,
411 ))
412 .send()
413 .await
414 .ok()?;
415 if !resp.status().is_success() {
416 return None;
417 }
418 let ps: OllamaPsResponse = resp.json().await.ok()?;
419 ps.models
420 .into_iter()
421 .find(|m| m.name == self.model_name)
422 .and_then(|m| Some((m.size_vram?, m.size?)))
423 }
424
425 async fn handle_stream(
430 &self,
431 response: reqwest::Response,
432 callback: Option<StreamCallback>,
433 hide_reasoning_trace: bool,
434 ) -> Result<ModelResponse> {
435 if !response.status().is_success() {
436 let status = response.status().as_u16();
437 let debug =
438 crate::models::error::ResponseDebugContext::from_headers(response.headers());
439 let error_text = response
440 .text()
441 .await
442 .unwrap_or_else(|_| "Unknown error".to_string());
443 return Err(ModelError::Backend(BackendError::HttpError {
444 status,
445 message: error_text,
446 debug,
447 }));
448 }
449
450 let mut stream = response.bytes_stream();
451 let mut acc = StreamAccumulator {
452 content: String::new(),
453 thinking: String::new(),
454 tool_calls: Vec::new(),
455 hide_reasoning_trace,
456 prompt_tokens: 0,
457 completion_tokens: 0,
458 saw_usage: false,
459 done_reason: None,
460 saw_done: false,
461 truncated: false,
462 };
463
464 let mut line_buffer: Vec<u8> = Vec::new();
472
473 while let Some(chunk_result) = stream.next().await {
474 let chunk = chunk_result.map_err(|e| ModelError::StreamError(e.to_string()))?;
475 if line_buffer.len() > crate::constants::MAX_SSE_BUFFER_BYTES {
480 return Err(ModelError::StreamError(format!(
481 "NDJSON stream exceeded {} byte reassembly cap without a complete line",
482 crate::constants::MAX_SSE_BUFFER_BYTES
483 )));
484 }
485 line_buffer.extend_from_slice(&chunk);
486
487 for line in drain_complete_lines(&mut line_buffer) {
488 if line.trim().is_empty() {
489 continue;
490 }
491
492 let json_chunk = parse_ollama_stream_frame(&line)?;
493
494 Self::process_stream_chunk(&json_chunk, callback.as_ref(), &mut acc);
495 }
496 }
497
498 if !line_buffer.is_empty() {
501 let trailing = String::from_utf8_lossy(&line_buffer).into_owned();
502 if !trailing.trim().is_empty() {
503 let json_chunk = parse_ollama_stream_frame(trailing.trim())?;
504
505 Self::process_stream_chunk(&json_chunk, callback.as_ref(), &mut acc);
506 }
507 }
508
509 if acc.closed_abnormally() {
516 return Err(ModelError::StreamError(
517 "Ollama stream closed before the terminal `done` chunk; the \
518 connection was likely dropped mid-response"
519 .to_string(),
520 ));
521 }
522
523 let usage = acc.usage();
527 let stop_reason = acc.done_reason.as_deref().map(map_ollama_done_reason);
528 let thinking = if acc.thinking.is_empty() {
529 None
530 } else {
531 Some(acc.thinking)
532 };
533 let tool_calls = if acc.tool_calls.is_empty() {
534 None
535 } else {
536 Some(acc.tool_calls)
537 };
538
539 Ok(ModelResponse {
547 content: acc.content,
548 usage,
549 model_name: self.model_name.clone(),
550 stop_reason,
551 thinking,
552 tool_calls,
553 provider_continuation: None,
554 })
555 }
556
557 fn process_stream_chunk(
569 json_chunk: &OllamaStreamChunk,
570 callback: Option<&StreamCallback>,
571 acc: &mut StreamAccumulator,
572 ) {
573 if let Some(ref thinking_chunk) = json_chunk.message.thinking
577 && !acc.truncated
578 && !thinking_chunk.is_empty()
579 {
580 if let Some(cb) = callback
581 && !acc.hide_reasoning_trace
582 {
583 cb(StreamEvent::Reasoning(ReasoningChunk {
584 text: thinking_chunk.clone(),
585 signature: None,
586 }));
587 }
588 push_capped(
589 &mut acc.thinking,
590 thinking_chunk,
591 &mut acc.truncated,
592 MAX_RESPONSE_CHARS,
593 );
594 }
595
596 if let Some(ref tool_calls) = json_chunk.message.tool_calls {
599 acc.tool_calls.extend(tool_calls.clone());
600 if let Some(cb) = callback {
601 for tc in tool_calls {
602 cb(StreamEvent::ToolCall(tc.clone()));
603 }
604 }
605 }
606
607 if !json_chunk.message.content.is_empty() && !acc.truncated {
609 if let Some(cb) = callback {
610 cb(StreamEvent::Text(json_chunk.message.content.clone()));
611 }
612 push_capped(
613 &mut acc.content,
614 &json_chunk.message.content,
615 &mut acc.truncated,
616 MAX_RESPONSE_CHARS,
617 );
618 }
619
620 if json_chunk.done {
624 acc.saw_done = true;
627 if let Some(count) = json_chunk.prompt_eval_count {
628 acc.prompt_tokens = count;
629 acc.saw_usage = true;
630 }
631 if let Some(count) = json_chunk.eval_count {
632 acc.completion_tokens = count;
633 acc.saw_usage = true;
634 }
635 if json_chunk.done_reason.is_some() {
636 acc.done_reason = json_chunk.done_reason.clone();
637 }
638 }
639 }
640
641 fn build_request_body(
646 &self,
647 messages: &[ChatMessage],
648 config: &ModelConfig,
649 stream: bool,
650 supports_thinking: bool,
651 ) -> serde_json::Value {
652 let ollama_opts = config.ollama_options();
653
654 let mut json_messages = Vec::new();
655
656 if let Some(combined) = config.combined_system_prompt() {
659 json_messages.push(json!({
660 "role": "system",
661 "content": combined
662 }));
663 }
664
665 for msg in messages {
666 let role = match msg.role {
667 MessageRole::User => "user",
668 MessageRole::Assistant => "assistant",
669 MessageRole::System => "system",
670 MessageRole::Tool => "tool",
671 };
672 let mut json_msg = json!({
673 "role": role,
674 "content": msg.content
675 });
676 if msg.role == MessageRole::Assistant
677 && let Some(ref tool_calls) = msg.tool_calls
678 {
679 json_msg["tool_calls"] = json!(tool_calls);
680 }
681 if msg.role == MessageRole::Tool
682 && let Some(ref tool_name) = msg.tool_name
683 {
684 json_msg["tool_name"] = json!(tool_name);
685 }
686 if let Some(ref images) = msg.images
687 && !images.is_empty()
688 {
689 json_msg["images"] = json!(images);
690 }
691 json_messages.push(json_msg);
692 }
693
694 let tools: Vec<&serde_json::Value> = config.tools.iter().collect();
700
701 let mut request_body = json!({
702 "model": self.model_name,
703 "messages": json_messages,
704 "stream": stream,
705 "tools": &tools,
706 });
707
708 if let Some(schema) = &config.output_schema {
710 request_body["format"] = schema.clone();
711 }
712
713 if let Some(think) = think_for_ollama(&self.model_name, config.reasoning, supports_thinking)
718 {
719 request_body["think"] = think;
720 }
721 tracing::debug!(
722 "think reasoning={:?} supports_thinking={} shape={}",
723 config.reasoning,
724 supports_thinking,
725 if uses_effort_string_think(&self.model_name) {
726 "string"
727 } else {
728 "bool"
729 }
730 );
731
732 tracing::debug!("Sending {} tools to Ollama", tools.len());
733 tracing::debug!(
734 "Request body tools: {}",
735 serde_json::to_string_pretty(&tools).unwrap_or_default()
736 );
737
738 let mut options = json!({});
739 options["temperature"] = json!(config.temperature.clamp(0.0, 2.0));
741 if let Some(num_ctx) = ollama_opts.num_ctx {
742 options["num_ctx"] = json!(num_ctx);
743 }
744 if let Some(num_predict) = ollama_opts.num_predict {
748 options["num_predict"] = json!(num_predict);
749 }
750 if let Some(num_gpu) = ollama_opts.num_gpu {
751 options["num_gpu"] = json!(num_gpu);
752 }
753 if let Some(num_thread) = ollama_opts.num_thread {
754 options["num_thread"] = json!(num_thread);
755 }
756 if let Some(numa) = ollama_opts.numa {
757 options["numa"] = json!(numa);
758 }
759 tracing::debug!(
760 "Ollama sizing: num_ctx={:?} num_predict={:?}",
761 ollama_opts.num_ctx,
762 ollama_opts.num_predict
763 );
764 request_body["options"] = options;
765
766 request_body
767 }
768
769 async fn send_chat(
775 &self,
776 body: &serde_json::Value,
777 notify: Option<&StreamCallback>,
778 ) -> Result<reqwest::Response> {
779 let url = format!("{}/api/chat", self.base_url);
780 self.with_local_recovery(notify, || async {
781 self.client.post(&url).json(body).send().await.map_err(|e| {
782 ModelError::Backend(BackendError::ConnectionFailed {
783 backend: "ollama".to_string(),
784 url: self.base_url.clone(),
785 reason: e.to_string(),
786 })
787 })
788 })
789 .await
790 }
791
792 async fn with_local_recovery<F, Fut>(
818 &self,
819 notify: Option<&StreamCallback>,
820 mut op: F,
821 ) -> Result<reqwest::Response>
822 where
823 F: FnMut() -> Fut,
824 Fut: std::future::Future<Output = Result<reqwest::Response>>,
825 {
826 let first = crate::models::retry::retry_transient_http_no_connect_retry(&mut op).await;
827 let Some(recovery) = self.recovery.as_ref() else {
828 return first;
829 };
830 if !matches!(
831 first,
832 Err(ModelError::Backend(BackendError::ConnectionFailed { .. }))
833 ) {
834 return first;
835 }
836 let ensured = match notify.or(self.status_notify.as_ref()) {
839 Some(cb) => {
840 let forward = |text: &str| cb(StreamEvent::Status(text.to_string()));
841 recovery
842 .ensure_running(&self.base_url, Some(&forward))
843 .await
844 },
845 None => recovery.ensure_running(&self.base_url, None).await,
846 };
847 match ensured {
848 Ok(()) => crate::models::retry::retry_transient_http(&mut op).await,
849 Err(Some(hint)) => first.map_err(|e| append_reason_hint(e, &hint)),
850 Err(None) => first,
851 }
852 }
853
854 async fn decode_non_streaming(&self, response: reqwest::Response) -> Result<ModelResponse> {
857 if !response.status().is_success() {
858 let status = response.status().as_u16();
859 let debug =
860 crate::models::error::ResponseDebugContext::from_headers(response.headers());
861 let error_text = response
862 .text()
863 .await
864 .unwrap_or_else(|_| "Unknown error".to_string());
865 return Err(ModelError::Backend(BackendError::HttpError {
866 status,
867 message: error_text,
868 debug,
869 }));
870 }
871
872 let json: OllamaStreamChunk =
873 response.json().await.map_err(|e| ModelError::ParseError {
874 message: format!("Failed to parse response: {}", e),
875 raw: None,
876 })?;
877
878 let thinking = json.message.thinking.filter(|t| !t.is_empty());
879 let tool_calls = json.message.tool_calls.filter(|tc| !tc.is_empty());
880
881 let prompt_tokens = json.prompt_eval_count.unwrap_or(0);
882 let completion_tokens = json.eval_count.unwrap_or(0);
883
884 Ok(ModelResponse {
885 content: json.message.content,
886 usage: Some(TokenUsage::provider(prompt_tokens, completion_tokens)),
887 model_name: self.model_name.clone(),
888 stop_reason: json.done_reason.as_deref().map(map_ollama_done_reason),
889 thinking,
890 tool_calls,
891 provider_continuation: None,
892 })
893 }
894}
895
896#[async_trait]
897impl Model for OllamaAdapter {
898 fn name(&self) -> &str {
899 &self.model_name
900 }
901
902 fn capabilities(&self) -> &ModelCapabilities {
903 &self.capabilities
904 }
905
906 async fn list_models(&self) -> Result<Vec<String>> {
907 let url = format!("{}/api/tags", self.base_url);
908
909 let response = self
915 .with_local_recovery(None, || async {
916 self.client.get(&url).send().await.map_err(|e| {
917 ModelError::Backend(BackendError::ConnectionFailed {
918 backend: "ollama".to_string(),
919 url: self.base_url.clone(),
920 reason: e.to_string(),
921 })
922 })
923 })
924 .await?;
925
926 if !response.status().is_success() {
927 return Err(ModelError::Backend(BackendError::HttpError {
928 status: response.status().as_u16(),
929 message: "Failed to list models".to_string(),
930 debug: crate::models::error::ResponseDebugContext::from_headers(response.headers()),
931 }));
932 }
933
934 let tags: OllamaTagsResponse =
935 response.json().await.map_err(|e| ModelError::ParseError {
936 message: format!("Failed to parse tags response: {}", e),
937 raw: None,
938 })?;
939
940 Ok(tags.models.into_iter().map(|m| m.name).collect())
941 }
942
943 async fn chat(
944 &self,
945 messages: &[ChatMessage],
946 config: &ModelConfig,
947 callback: Option<StreamCallback>,
948 ) -> Result<ModelResponse> {
949 let stream = callback.is_some();
950 let supports_thinking = self.thinking_supported().await;
951 let request_body = self.build_request_body(messages, config, stream, supports_thinking);
952 let response = self.send_chat(&request_body, callback.as_ref()).await?;
956
957 if stream {
958 self.handle_stream(response, callback, config.hide_reasoning_trace)
959 .await
960 } else {
961 self.decode_non_streaming(response).await
962 }
963 }
964}
965
966#[derive(Debug, Serialize, Deserialize)]
969struct OllamaStreamChunk {
970 message: OllamaMessage,
971 done: bool,
972 #[serde(default)]
973 prompt_eval_count: Option<usize>,
974 #[serde(default)]
975 eval_count: Option<usize>,
976 #[serde(default)]
977 done_reason: Option<String>,
978}
979
980#[derive(Debug, Serialize, Deserialize)]
981struct OllamaMessage {
982 role: String,
983 #[serde(default)]
988 content: String,
989 #[serde(default)]
990 thinking: Option<String>,
991 #[serde(default)]
992 tool_calls: Option<Vec<crate::models::ToolCall>>,
993}
994
995#[derive(Debug, Serialize, Deserialize)]
996pub(crate) struct OllamaTagsResponse {
997 pub(crate) models: Vec<OllamaModel>,
998}
999
1000#[derive(Debug, Serialize, Deserialize)]
1001pub(crate) struct OllamaModel {
1002 pub(crate) name: String,
1003 #[serde(default)]
1006 pub(crate) size: Option<u64>,
1007}
1008
1009#[derive(Debug, Deserialize)]
1013struct OllamaShowResponse {
1014 #[serde(default)]
1015 model_info: serde_json::Value,
1016 #[serde(default)]
1019 capabilities: Vec<String>,
1020}
1021
1022#[derive(Debug, Serialize, Deserialize)]
1025pub(crate) struct OllamaPsResponse {
1026 #[serde(default)]
1027 pub(crate) models: Vec<OllamaPsModel>,
1028}
1029
1030#[derive(Debug, Serialize, Deserialize)]
1031pub(crate) struct OllamaPsModel {
1032 pub(crate) name: String,
1033 #[serde(default)]
1035 pub(crate) size: Option<u64>,
1036 #[serde(default)]
1038 pub(crate) size_vram: Option<u64>,
1039}
1040
1041#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1046pub struct OllamaModelInfo {
1047 pub context_length: Option<usize>,
1049 pub dims: Option<ModelDims>,
1051 pub weight_bytes: Option<u64>,
1053}
1054
1055fn append_reason_hint(error: ModelError, hint: &str) -> ModelError {
1061 match error {
1062 ModelError::Backend(BackendError::ConnectionFailed {
1063 backend,
1064 url,
1065 reason,
1066 }) => ModelError::Backend(BackendError::ConnectionFailed {
1067 backend,
1068 url,
1069 reason: format!("{reason}. {hint}"),
1070 }),
1071 other => other,
1072 }
1073}
1074
1075fn parse_ollama_stream_frame(line: &str) -> Result<OllamaStreamChunk> {
1084 if let Ok(value) = serde_json::from_str::<serde_json::Value>(line)
1085 && let Some(message) = value.get("error").and_then(|v| v.as_str())
1086 {
1087 return Err(ModelError::Backend(BackendError::ProviderError {
1088 provider: "ollama".to_string(),
1089 code: None,
1090 message: message.to_string(),
1091 debug: crate::models::error::ResponseDebugContext::default(),
1092 }));
1093 }
1094 serde_json::from_str(line).map_err(|e| ModelError::ParseError {
1095 message: format!("Failed to parse Ollama response: {}", e),
1096 raw: Some(line.to_string()),
1097 })
1098}
1099
1100fn map_ollama_done_reason(s: &str) -> FinishReason {
1105 match s {
1106 "stop" => FinishReason::Stop,
1107 "length" => FinishReason::Length,
1108 other => FinishReason::Other(other.to_string()),
1109 }
1110}
1111
1112fn json_to_usize(v: &serde_json::Value) -> Option<usize> {
1114 v.as_u64().map(|n| n as usize)
1115}
1116
1117fn context_length_from_model_info(model_info: &serde_json::Value) -> Option<usize> {
1123 let obj = model_info.as_object()?;
1124 if let Some(arch) = obj.get("general.architecture").and_then(|v| v.as_str())
1125 && let Some(v) = obj
1126 .get(&format!("{arch}.context_length"))
1127 .and_then(json_to_usize)
1128 {
1129 return Some(v);
1130 }
1131 obj.iter()
1132 .find(|(k, _)| k.ends_with(".context_length"))
1133 .and_then(|(_, v)| json_to_usize(v))
1134}
1135
1136fn dims_from_model_info(model_info: &serde_json::Value) -> Option<ModelDims> {
1140 let obj = model_info.as_object()?;
1141 let by_suffix = |suffix: &str| -> Option<usize> {
1142 obj.iter()
1143 .find(|(k, _)| k.ends_with(suffix))
1144 .and_then(|(_, v)| json_to_usize(v))
1145 };
1146 let head_count = by_suffix(".attention.head_count")?;
1147 Some(ModelDims {
1148 block_count: by_suffix(".block_count")?,
1149 head_count,
1150 head_count_kv: by_suffix(".attention.head_count_kv").unwrap_or(head_count),
1151 embedding_length: by_suffix(".embedding_length")?,
1152 })
1153}
1154
1155fn normalize_url(url: &str) -> String {
1156 let mut normalized = url.trim().to_string();
1157
1158 if normalized.contains("0.0.0.0") {
1160 normalized = normalized.replace("0.0.0.0", "127.0.0.1");
1161 }
1162
1163 if !normalized.starts_with("http://") && !normalized.starts_with("https://") {
1169 let host = normalized.split(['/', ':']).next().unwrap_or("");
1170 let scheme = if crate::utils::classify_host(host).is_internal() {
1171 "http"
1172 } else {
1173 "https"
1174 };
1175 normalized = format!("{}://{}", scheme, normalized);
1176 }
1177
1178 if let Some(after_scheme) = normalized.strip_prefix("http://") {
1182 let (authority, path) = match after_scheme.find('/') {
1183 Some(i) => (&after_scheme[..i], &after_scheme[i..]),
1184 None => (after_scheme, ""),
1185 };
1186 if !authority.contains(':') {
1187 normalized = format!("http://{}:11434{}", authority, path);
1188 }
1189 }
1190 normalized
1193}
1194
1195#[cfg(test)]
1196mod tests {
1197 use super::{TRUNCATION_MARKER, normalize_url, push_capped, uses_effort_string_think};
1198
1199 #[test]
1202 fn push_capped_under_cap_appends_normally() {
1203 let mut buf = String::new();
1204 let mut truncated = false;
1205 push_capped(&mut buf, "hello", &mut truncated, 100);
1206 push_capped(&mut buf, " world", &mut truncated, 100);
1207 assert_eq!(buf, "hello world");
1208 assert!(!truncated);
1209 }
1210
1211 #[test]
1212 fn push_capped_truncates_once_then_drops_chunks() {
1213 let mut buf = String::new();
1214 let mut truncated = false;
1215 let cap = 32;
1216 push_capped(&mut buf, &"a".repeat(200), &mut truncated, cap);
1218 assert!(truncated);
1219 assert!(buf.ends_with(TRUNCATION_MARKER));
1220 let len_after_first = buf.len();
1221 push_capped(&mut buf, &"b".repeat(200), &mut truncated, cap);
1223 push_capped(&mut buf, "tail", &mut truncated, cap);
1224 assert_eq!(buf.len(), len_after_first);
1225 assert_eq!(buf.matches(TRUNCATION_MARKER).count(), 1);
1226 }
1227
1228 #[test]
1231 fn ps_response_selects_model_and_handles_missing_fields() {
1232 let body = serde_json::json!({
1235 "models": [
1236 { "name": "other:7b", "size": 8_000_000_000u64, "size_vram": 4_000_000_000u64,
1237 "digest": "abc", "expires_at": "2026-01-01T00:00:00Z" },
1238 { "name": "ornith:9b", "size": 6_000_000_000u64, "size_vram": 6_000_000_000u64 },
1239 { "name": "nogpu:1b", "size": 1_000_000_000u64 },
1240 ]
1241 });
1242 let ps: super::OllamaPsResponse = serde_json::from_value(body).unwrap();
1243 let pick = |name: &str| {
1245 ps.models
1246 .iter()
1247 .find(|m| m.name == name)
1248 .and_then(|m| Some((m.size_vram?, m.size?)))
1249 };
1250 assert_eq!(pick("ornith:9b"), Some((6_000_000_000, 6_000_000_000)));
1251 assert_eq!(pick("other:7b"), Some((4_000_000_000, 8_000_000_000)));
1252 assert_eq!(pick("nogpu:1b"), None); assert_eq!(pick("absent:1b"), None); }
1255
1256 #[test]
1257 fn push_capped_respects_char_boundary_for_cjk() {
1258 let mut buf = String::new();
1259 let mut truncated = false;
1260 push_capped(&mut buf, "你你你你", &mut truncated, 4);
1262 let body = &buf[..buf.find('\n').unwrap()];
1263 assert_eq!(body, "你");
1264 assert!(buf.ends_with(TRUNCATION_MARKER));
1265 }
1266
1267 #[test]
1268 fn test_normalize_url_bare_host() {
1269 assert_eq!(normalize_url("localhost"), "http://localhost:11434");
1270 }
1271
1272 #[test]
1273 fn test_normalize_url_http_no_port() {
1274 assert_eq!(normalize_url("http://localhost"), "http://localhost:11434");
1275 }
1276
1277 #[test]
1278 fn test_normalize_url_http_with_port() {
1279 assert_eq!(
1280 normalize_url("http://localhost:11434"),
1281 "http://localhost:11434"
1282 );
1283 }
1284
1285 #[test]
1286 fn test_normalize_url_custom_port() {
1287 assert_eq!(normalize_url("http://host:8080"), "http://host:8080");
1288 }
1289
1290 #[test]
1291 fn test_normalize_url_with_path_no_port() {
1292 assert_eq!(
1293 normalize_url("http://ollama.example.com/v1"),
1294 "http://ollama.example.com:11434/v1"
1295 );
1296 }
1297
1298 #[test]
1299 fn test_normalize_url_with_path_and_port() {
1300 assert_eq!(
1301 normalize_url("http://ollama.example.com:8080/v1"),
1302 "http://ollama.example.com:8080/v1"
1303 );
1304 }
1305
1306 #[test]
1307 fn test_normalize_url_https_no_port_added() {
1308 assert_eq!(
1309 normalize_url("https://ollama.example.com"),
1310 "https://ollama.example.com"
1311 );
1312 }
1313
1314 #[test]
1315 fn test_normalize_url_replaces_0000() {
1316 assert_eq!(
1317 normalize_url("http://0.0.0.0:11434"),
1318 "http://127.0.0.1:11434"
1319 );
1320 }
1321
1322 #[test]
1323 fn normalize_url_public_host_defaults_to_https() {
1324 assert_eq!(
1326 normalize_url("my-remote-ollama.com:11434"),
1327 "https://my-remote-ollama.com:11434"
1328 );
1329 }
1330
1331 #[test]
1332 fn normalize_url_private_host_stays_http() {
1333 assert_eq!(normalize_url("192.168.1.50"), "http://192.168.1.50:11434");
1335 assert_eq!(normalize_url("127.0.0.1:11434"), "http://127.0.0.1:11434");
1336 }
1337
1338 use super::OllamaAdapter;
1341 use crate::models::config::{BackendConfig, ModelConfig};
1342 use crate::models::reasoning::ReasoningLevel;
1343 use crate::models::types::ChatMessage;
1344 use std::sync::Arc;
1345
1346 async fn make_adapter() -> OllamaAdapter {
1347 OllamaAdapter::new("test-model", Arc::new(BackendConfig::default()))
1350 .await
1351 .expect("adapter")
1352 }
1353
1354 #[tokio::test]
1355 async fn connection_failure_passes_through_when_autostart_disabled() {
1356 let port = {
1362 let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind");
1363 listener.local_addr().expect("addr").port()
1364 };
1365 let backend = BackendConfig {
1366 ollama_url: format!("http://127.0.0.1:{port}"),
1367 timeout_secs: 1,
1368 max_idle_per_host: 1,
1369 ollama_autostart: false,
1370 };
1371 use crate::models::traits::Model;
1372 let adapter = OllamaAdapter::new("test-model", Arc::new(backend))
1373 .await
1374 .expect("adapter");
1375 let err = adapter
1376 .list_models()
1377 .await
1378 .expect_err("dead port must fail");
1379 let msg = err.to_string();
1380 assert!(msg.contains("Failed to connect to ollama"), "got: {msg}");
1381 assert!(
1382 !msg.contains("auto-start") && !msg.contains("ollama.com/download"),
1383 "no hint expected with autostart disabled, got: {msg}"
1384 );
1385 }
1386
1387 #[test]
1388 fn append_reason_hint_enriches_connection_failed_only() {
1389 use crate::models::error::{BackendError, ModelError};
1390 let base = ModelError::Backend(BackendError::ConnectionFailed {
1391 backend: "ollama".into(),
1392 url: "http://localhost:11434".into(),
1393 reason: "connection refused".into(),
1394 });
1395 let enriched = super::append_reason_hint(base, "install it from https://ollama.com");
1396 assert!(
1397 enriched
1398 .to_string()
1399 .contains("connection refused. install it from https://ollama.com"),
1400 "got: {enriched}"
1401 );
1402 let other = ModelError::ParseError {
1404 message: "bad json".into(),
1405 raw: None,
1406 };
1407 let untouched = super::append_reason_hint(other, "should not appear");
1408 assert!(!untouched.to_string().contains("should not appear"));
1409 }
1410
1411 #[tokio::test]
1416 async fn model_directed_system_messages_reach_the_wire_in_place() {
1417 use crate::models::ChatMessageKind;
1418 let adapter = make_adapter().await;
1419 let mut nudge = ChatMessage::system("Reminder: plan mode is active.");
1420 nudge.kind = ChatMessageKind::RecoveryNudge;
1421 let messages = vec![ChatMessage::user("ok"), nudge];
1422 let body = adapter.build_request_body(&messages, &ModelConfig::default(), false, false);
1423
1424 let msgs = body["messages"].as_array().expect("messages array");
1425 let last = msgs.last().expect("non-empty");
1426 assert_eq!(last["role"], "system");
1427 assert!(
1428 last["content"]
1429 .as_str()
1430 .unwrap()
1431 .contains("plan mode is active"),
1432 );
1433 }
1434
1435 #[tokio::test]
1436 async fn ollama_request_body_omits_think_when_reasoning_none() {
1437 let adapter = make_adapter().await;
1438 let config = ModelConfig {
1439 reasoning: ReasoningLevel::None,
1440 ..Default::default()
1441 };
1442 let messages = vec![ChatMessage::user("hi")];
1443
1444 let body = adapter.build_request_body(&messages, &config, false, true);
1445 assert_eq!(body["think"], serde_json::json!(false));
1446 }
1447
1448 #[tokio::test]
1449 async fn ollama_request_body_preserves_registry_selected_web_tools() {
1450 let adapter = make_adapter().await;
1451 let config = ModelConfig {
1452 tools: ["web_fetch", "web_search"]
1453 .into_iter()
1454 .map(|name| {
1455 serde_json::json!({
1456 "type": "function",
1457 "function": {
1458 "name": name,
1459 "description": "registered web tool",
1460 "parameters": {"type": "object"}
1461 }
1462 })
1463 })
1464 .collect(),
1465 ..Default::default()
1466 };
1467
1468 let body = adapter.build_request_body(&[ChatMessage::user("hi")], &config, false, false);
1469 let names: Vec<&str> = body["tools"]
1470 .as_array()
1471 .expect("tools array")
1472 .iter()
1473 .filter_map(|tool| {
1474 tool.pointer("/function/name")
1475 .and_then(serde_json::Value::as_str)
1476 })
1477 .collect();
1478 assert_eq!(names, ["web_fetch", "web_search"]);
1479 }
1480
1481 #[tokio::test]
1482 async fn ollama_request_body_sets_think_true_for_low_reasoning() {
1483 let adapter = make_adapter().await;
1484 let config = ModelConfig {
1485 reasoning: ReasoningLevel::Low,
1486 ..Default::default()
1487 };
1488 let messages = vec![ChatMessage::user("hi")];
1489
1490 let body = adapter.build_request_body(&messages, &config, false, true);
1491 assert_eq!(body["think"], serde_json::json!(true));
1492 }
1493
1494 #[tokio::test]
1495 async fn ollama_request_body_sets_think_true_for_max_reasoning() {
1496 let adapter = make_adapter().await;
1497 let config = ModelConfig {
1498 reasoning: ReasoningLevel::Max,
1499 ..Default::default()
1500 };
1501 let messages = vec![ChatMessage::user("hi")];
1502
1503 let body = adapter.build_request_body(&messages, &config, false, true);
1504 assert_eq!(body["think"], serde_json::json!(true));
1505 }
1506
1507 #[tokio::test]
1508 async fn ollama_request_body_omits_think_when_unsupported() {
1509 let adapter = make_adapter().await;
1512 let config = ModelConfig {
1513 reasoning: ReasoningLevel::High,
1514 ..Default::default()
1515 };
1516 let messages = vec![ChatMessage::user("hi")];
1517
1518 let body = adapter.build_request_body(&messages, &config, false, false);
1519 assert!(
1520 body.get("think").is_none(),
1521 "think must be omitted for a non-thinking model, got {:?}",
1522 body.get("think")
1523 );
1524 }
1525
1526 #[tokio::test]
1527 async fn ollama_request_body_emits_num_ctx_and_num_predict() {
1528 let adapter = make_adapter().await;
1529 let mut config = ModelConfig::default();
1530 config.set_backend_option("ollama".into(), "num_ctx".into(), "32768".into());
1531 config.set_backend_option("ollama".into(), "num_predict".into(), "8192".into());
1532
1533 let body = adapter.build_request_body(&[ChatMessage::user("hi")], &config, false, true);
1534 assert_eq!(body["options"]["num_ctx"], serde_json::json!(32768));
1535 assert_eq!(body["options"]["num_predict"], serde_json::json!(8192));
1536 }
1537
1538 #[tokio::test]
1539 async fn ollama_request_body_omits_sizing_when_unset() {
1540 let adapter = make_adapter().await;
1541 let config = ModelConfig::default();
1542 let body = adapter.build_request_body(&[ChatMessage::user("hi")], &config, false, true);
1543 assert!(body["options"].get("num_ctx").is_none());
1545 assert!(body["options"].get("num_predict").is_none());
1546 }
1547
1548 #[test]
1551 fn context_length_prefers_architecture_prefix() {
1552 let mi = serde_json::json!({
1553 "general.architecture": "qwen2",
1554 "qwen2.context_length": 262_144,
1555 "qwen2.block_count": 28,
1556 });
1557 assert_eq!(super::context_length_from_model_info(&mi), Some(262_144));
1558 }
1559
1560 #[test]
1561 fn context_length_falls_back_to_any_suffix() {
1562 let mi = serde_json::json!({ "llama.context_length": 131_072 });
1564 assert_eq!(super::context_length_from_model_info(&mi), Some(131_072));
1565 }
1566
1567 #[test]
1568 fn context_length_missing_is_none() {
1569 let mi = serde_json::json!({ "general.architecture": "qwen2" });
1570 assert_eq!(super::context_length_from_model_info(&mi), None);
1571 }
1572
1573 #[test]
1574 fn dims_parsed_for_gqa_model() {
1575 let mi = serde_json::json!({
1576 "general.architecture": "qwen2",
1577 "qwen2.block_count": 28,
1578 "qwen2.attention.head_count": 28,
1579 "qwen2.attention.head_count_kv": 4,
1580 "qwen2.embedding_length": 3584,
1581 });
1582 let dims = super::dims_from_model_info(&mi).unwrap();
1583 assert_eq!(dims.block_count, 28);
1584 assert_eq!(dims.head_count, 28);
1585 assert_eq!(dims.head_count_kv, 4);
1586 assert_eq!(dims.embedding_length, 3584);
1587 }
1588
1589 #[test]
1590 fn dims_head_count_kv_defaults_to_head_count() {
1591 let mi = serde_json::json!({
1593 "llama.block_count": 32,
1594 "llama.attention.head_count": 32,
1595 "llama.embedding_length": 4096,
1596 });
1597 let dims = super::dims_from_model_info(&mi).unwrap();
1598 assert_eq!(dims.head_count_kv, 32);
1599 }
1600
1601 #[test]
1602 fn dims_missing_required_is_none() {
1603 let mi = serde_json::json!({ "gptoss.block_count": 24 }); assert!(super::dims_from_model_info(&mi).is_none());
1605 }
1606
1607 #[test]
1608 fn gptoss_architecture_prefix_parsed() {
1609 let mi = serde_json::json!({
1610 "general.architecture": "gptoss",
1611 "gptoss.context_length": 131_072,
1612 "gptoss.block_count": 24,
1613 "gptoss.attention.head_count": 64,
1614 "gptoss.attention.head_count_kv": 8,
1615 "gptoss.embedding_length": 2880,
1616 });
1617 assert_eq!(super::context_length_from_model_info(&mi), Some(131_072));
1618 assert!(super::dims_from_model_info(&mi).is_some());
1619 }
1620
1621 async fn make_gpt_oss_adapter() -> OllamaAdapter {
1626 OllamaAdapter::new("gpt-oss:20b", Arc::new(BackendConfig::default()))
1627 .await
1628 .expect("adapter")
1629 }
1630
1631 #[tokio::test]
1632 async fn ollama_request_body_maps_output_schema_to_format() {
1633 let adapter = make_adapter().await;
1634 let config = ModelConfig {
1635 output_schema: Some(serde_json::json!({"type": "object"})),
1636 ..Default::default()
1637 };
1638 let body = adapter.build_request_body(&[ChatMessage::user("hi")], &config, false, true);
1639 assert_eq!(body["format"]["type"], "object");
1640 let body = adapter.build_request_body(
1642 &[ChatMessage::user("hi")],
1643 &ModelConfig::default(),
1644 false,
1645 true,
1646 );
1647 assert!(body.get("format").is_none());
1648 }
1649
1650 #[tokio::test]
1651 async fn ollama_request_body_sets_think_low_for_gpt_oss_none() {
1652 let adapter = make_gpt_oss_adapter().await;
1653 let config = ModelConfig {
1654 reasoning: ReasoningLevel::None,
1655 ..Default::default()
1656 };
1657 let body = adapter.build_request_body(&[ChatMessage::user("hi")], &config, false, true);
1658 assert_eq!(body["think"], serde_json::json!("low"));
1660 }
1661
1662 #[tokio::test]
1663 async fn ollama_request_body_sets_think_medium_for_gpt_oss_medium() {
1664 let adapter = make_gpt_oss_adapter().await;
1665 let config = ModelConfig {
1666 reasoning: ReasoningLevel::Medium,
1667 ..Default::default()
1668 };
1669 let body = adapter.build_request_body(&[ChatMessage::user("hi")], &config, false, true);
1670 assert_eq!(body["think"], serde_json::json!("medium"));
1671 }
1672
1673 #[tokio::test]
1674 async fn ollama_request_body_sets_think_high_for_gpt_oss_max() {
1675 let adapter = make_gpt_oss_adapter().await;
1676 let config = ModelConfig {
1677 reasoning: ReasoningLevel::Max,
1678 ..Default::default()
1679 };
1680 let body = adapter.build_request_body(&[ChatMessage::user("hi")], &config, false, true);
1681 assert_eq!(body["think"], serde_json::json!("high"));
1683 }
1684
1685 #[tokio::test]
1686 async fn ollama_request_body_sets_think_high_for_gpt_oss_xhigh() {
1687 let adapter = make_gpt_oss_adapter().await;
1688 let config = ModelConfig {
1689 reasoning: ReasoningLevel::XHigh,
1690 ..Default::default()
1691 };
1692 let body = adapter.build_request_body(&[ChatMessage::user("hi")], &config, false, true);
1693 assert_eq!(body["think"], serde_json::json!("high"));
1694 }
1695
1696 #[test]
1697 fn gpt_oss_effort_string_matches_prefix_case_insensitive() {
1698 assert!(uses_effort_string_think("gpt-oss:20b"));
1699 assert!(uses_effort_string_think("gpt-oss:120b-cloud"));
1700 assert!(uses_effort_string_think("GPT-OSS:20b"));
1701 assert!(!uses_effort_string_think("qwen3-coder:30b"));
1702 assert!(!uses_effort_string_think("gpt-4o"));
1703 }
1704
1705 #[test]
1706 fn map_ollama_done_reason_maps_known_and_preserves_unknown() {
1707 use super::{FinishReason, map_ollama_done_reason};
1708 assert_eq!(map_ollama_done_reason("stop"), FinishReason::Stop);
1709 assert_eq!(map_ollama_done_reason("length"), FinishReason::Length);
1710 assert_eq!(
1711 map_ollama_done_reason("load"),
1712 FinishReason::Other("load".to_string())
1713 );
1714 }
1715
1716 #[test]
1717 fn process_stream_chunk_captures_done_reason_and_saturates_tokens() {
1718 use super::{OllamaMessage, OllamaStreamChunk, StreamAccumulator};
1721 let mut acc = StreamAccumulator {
1722 content: String::new(),
1723 thinking: String::new(),
1724 tool_calls: Vec::new(),
1725 hide_reasoning_trace: false,
1726 prompt_tokens: 0,
1727 completion_tokens: 0,
1728 saw_usage: false,
1729 done_reason: None,
1730 saw_done: false,
1731 truncated: false,
1732 };
1733 let chunk = OllamaStreamChunk {
1734 message: OllamaMessage {
1735 role: "assistant".to_string(),
1736 content: String::new(),
1737 thinking: None,
1738 tool_calls: None,
1739 },
1740 done: true,
1741 prompt_eval_count: Some(usize::MAX),
1742 eval_count: Some(10),
1743 done_reason: Some("length".to_string()),
1744 };
1745 OllamaAdapter::process_stream_chunk(&chunk, None, &mut acc);
1746 assert_eq!(acc.done_reason.as_deref(), Some("length"));
1747 assert_eq!(acc.prompt_tokens, usize::MAX);
1748 assert_eq!(acc.completion_tokens, 10);
1749 assert!(acc.saw_usage);
1751 assert!(acc.usage().is_some());
1752 assert_eq!(
1754 acc.prompt_tokens.saturating_add(acc.completion_tokens),
1755 usize::MAX
1756 );
1757 }
1758
1759 fn empty_accumulator() -> super::StreamAccumulator {
1760 super::StreamAccumulator {
1761 content: String::new(),
1762 thinking: String::new(),
1763 tool_calls: Vec::new(),
1764 hide_reasoning_trace: false,
1765 prompt_tokens: 0,
1766 completion_tokens: 0,
1767 saw_usage: false,
1768 done_reason: None,
1769 saw_done: false,
1770 truncated: false,
1771 }
1772 }
1773
1774 #[test]
1775 fn stream_usage_is_none_when_counts_absent_then_some_after_done() {
1776 use super::{OllamaMessage, OllamaStreamChunk};
1780 let mut acc = empty_accumulator();
1781
1782 let content_chunk = OllamaStreamChunk {
1783 message: OllamaMessage {
1784 role: "assistant".to_string(),
1785 content: "hi".to_string(),
1786 thinking: None,
1787 tool_calls: None,
1788 },
1789 done: false,
1790 prompt_eval_count: None,
1791 eval_count: None,
1792 done_reason: None,
1793 };
1794 OllamaAdapter::process_stream_chunk(&content_chunk, None, &mut acc);
1795 assert!(
1796 acc.usage().is_none(),
1797 "a cut stream must not reset the gauge to a zero usage"
1798 );
1799
1800 let done_chunk = OllamaStreamChunk {
1801 message: OllamaMessage {
1802 role: "assistant".to_string(),
1803 content: String::new(),
1804 thinking: None,
1805 tool_calls: None,
1806 },
1807 done: true,
1808 prompt_eval_count: Some(120),
1809 eval_count: Some(8),
1810 done_reason: Some("stop".to_string()),
1811 };
1812 OllamaAdapter::process_stream_chunk(&done_chunk, None, &mut acc);
1813 let usage = acc
1814 .usage()
1815 .expect("usage present after a done chunk with counts");
1816 assert_eq!(usage.prompt_tokens, 120);
1817 assert_eq!(usage.completion_tokens, 8);
1818 assert_eq!(usage.total_tokens(), 128);
1819 }
1820
1821 #[test]
1822 fn closed_abnormally_until_terminal_done_chunk_seen() {
1823 use super::{OllamaMessage, OllamaStreamChunk};
1825 let mut acc = empty_accumulator();
1826 assert!(acc.closed_abnormally());
1828
1829 let content_chunk = OllamaStreamChunk {
1831 message: OllamaMessage {
1832 role: "assistant".to_string(),
1833 content: "partial".to_string(),
1834 thinking: None,
1835 tool_calls: None,
1836 },
1837 done: false,
1838 prompt_eval_count: None,
1839 eval_count: None,
1840 done_reason: None,
1841 };
1842 OllamaAdapter::process_stream_chunk(&content_chunk, None, &mut acc);
1843 assert!(
1844 acc.closed_abnormally(),
1845 "a stream cut before `done` must be flagged abnormal"
1846 );
1847
1848 let done_chunk = OllamaStreamChunk {
1850 message: OllamaMessage {
1851 role: "assistant".to_string(),
1852 content: String::new(),
1853 thinking: None,
1854 tool_calls: None,
1855 },
1856 done: true,
1857 prompt_eval_count: Some(10),
1858 eval_count: Some(2),
1859 done_reason: Some("stop".to_string()),
1860 };
1861 OllamaAdapter::process_stream_chunk(&done_chunk, None, &mut acc);
1862 assert!(
1863 !acc.closed_abnormally(),
1864 "a `done` chunk completes the stream"
1865 );
1866 }
1867
1868 #[test]
1869 fn context_full_length_truncation_is_not_abnormal() {
1870 use super::{FinishReason, OllamaMessage, OllamaStreamChunk, map_ollama_done_reason};
1875 let mut acc = empty_accumulator();
1876 let length_done = OllamaStreamChunk {
1877 message: OllamaMessage {
1878 role: "assistant".to_string(),
1879 content: "...".to_string(),
1880 thinking: None,
1881 tool_calls: None,
1882 },
1883 done: true,
1884 prompt_eval_count: Some(4096),
1885 eval_count: Some(512),
1886 done_reason: Some("length".to_string()),
1887 };
1888 OllamaAdapter::process_stream_chunk(&length_done, None, &mut acc);
1889 assert!(
1890 !acc.closed_abnormally(),
1891 "context-full Length truncation has a real `done` frame — not abnormal"
1892 );
1893 assert_eq!(
1894 acc.done_reason.as_deref().map(map_ollama_done_reason),
1895 Some(FinishReason::Length)
1896 );
1897 }
1898
1899 #[test]
1900 fn stream_frame_error_becomes_typed_provider_error() {
1901 use super::{BackendError, ModelError, parse_ollama_stream_frame};
1905 let err = parse_ollama_stream_frame(r#"{"error":"model requires more system memory"}"#)
1906 .expect_err("error frame must not parse as a chunk");
1907 match err {
1908 ModelError::Backend(BackendError::ProviderError {
1909 provider, message, ..
1910 }) => {
1911 assert_eq!(provider, "ollama");
1912 assert_eq!(message, "model requires more system memory");
1913 },
1914 other => panic!("expected ProviderError, got {other:?}"),
1915 }
1916 }
1917
1918 #[test]
1919 fn stream_frame_normal_chunk_still_parses() {
1920 use super::parse_ollama_stream_frame;
1922 let chunk = parse_ollama_stream_frame(
1923 r#"{"message":{"role":"assistant","content":"hello"},"done":false}"#,
1924 )
1925 .expect("normal frame parses");
1926 assert_eq!(chunk.message.content, "hello");
1927 assert!(!chunk.done);
1928 }
1929
1930 #[test]
1931 fn ollama_message_defaults_missing_content() {
1932 let chunk: super::OllamaStreamChunk = serde_json::from_str(
1935 r#"{"message":{"role":"assistant","thinking":"hmm"},"done":false}"#,
1936 )
1937 .expect("frame without content parses");
1938 assert_eq!(chunk.message.content, "");
1939 assert_eq!(chunk.message.thinking.as_deref(), Some("hmm"));
1940 }
1941
1942 #[tokio::test]
1946 async fn ollama_request_body_concats_dynamic_suffix_to_system_message() {
1947 let adapter = make_adapter().await;
1948 let config = ModelConfig {
1949 system_prompt: Some("You are Mermaid.".to_string()),
1950 dynamic_system_suffix: Some("Project rule: always snake_case.".to_string()),
1951 ..Default::default()
1952 };
1953 let messages = vec![ChatMessage::user("hi")];
1954
1955 let body = adapter.build_request_body(&messages, &config, false, true);
1956 let messages_arr = body["messages"].as_array().expect("messages array");
1957 assert_eq!(messages_arr[0]["role"], "system");
1958 let content = messages_arr[0]["content"].as_str().unwrap();
1959 assert!(content.contains("You are Mermaid."));
1960 assert!(content.contains("Project rule: always snake_case."));
1961 assert!(content.contains("---"));
1962 }
1963}