1use std::pin::Pin;
4
5use async_trait::async_trait;
6use futures::{Stream, StreamExt};
7use serde::Deserialize;
8
9use tracing::{Instrument, debug, error, info, info_span};
10
11use crate::chat::ChatProvider;
12use crate::chat::{ChatRequest, ChatResponse};
13use crate::error::{LlmError, Result};
14use crate::message::{Content, Message, Role, ToolCall};
15use crate::stream::{StopReason, StreamChunk};
16use crate::usage::Usage;
17
18use super::client::{Ollama, OllamaToolCall};
19use super::stream::parse_stream_line;
20
21#[derive(Debug, Clone, Deserialize)]
23struct OllamaChatResponse {
24 pub model: String,
25 pub message: OllamaResponseMessage,
26 #[serde(default)]
27 pub done_reason: Option<String>,
28 #[serde(default)]
29 pub prompt_eval_count: Option<u32>,
30 #[serde(default)]
31 pub eval_count: Option<u32>,
32}
33
34#[derive(Debug, Clone, Deserialize)]
36struct OllamaResponseMessage {
37 #[serde(default)]
38 pub content: String,
39 #[serde(default)]
40 pub tool_calls: Option<Vec<OllamaToolCall>>,
41 #[serde(default)]
42 pub thinking: Option<String>,
43}
44
45impl Ollama {
46 fn parse_response(response: OllamaChatResponse) -> ChatResponse {
48 let stop_reason = match response.done_reason.as_deref() {
49 Some("length") => StopReason::Length,
50 _ => StopReason::Stop,
52 };
53
54 let tool_calls = response.message.tool_calls.map(|calls| {
55 calls
56 .into_iter()
57 .map(|tc| {
58 let args = serde_json::to_string(&tc.function.arguments).unwrap_or_default();
59 ToolCall::function(
60 format!("call_{}", uuid::Uuid::new_v4()),
61 tc.function.name,
62 args,
63 )
64 })
65 .collect()
66 });
67
68 let content = if response.message.content.is_empty() {
69 None
70 } else {
71 Some(Content::Text(response.message.content))
72 };
73
74 let reasoning_content = response.message.thinking.filter(|t| !t.is_empty());
76
77 let message = Message {
78 role: Role::Assistant,
79 content,
80 refusal: None,
81 annotations: Vec::new(),
82 tool_calls,
83 tool_call_id: None,
84 name: None,
85 reasoning_content,
86 thinking_blocks: None,
87 };
88
89 let usage = match (response.prompt_eval_count, response.eval_count) {
90 (Some(input), Some(output)) => Some(Usage::new(input, output)),
91 _ => None,
92 };
93
94 ChatResponse {
95 message,
96 stop_reason,
97 usage,
98 model: Some(response.model),
99 id: None,
100 service_tier: None,
101 raw: None,
102 }
103 }
104}
105
106#[async_trait]
107impl ChatProvider for Ollama {
108 async fn chat(&self, request: &ChatRequest) -> Result<ChatResponse> {
109 let span = info_span!(
110 "gen_ai.chat",
111 gen_ai.system = "ollama",
112 gen_ai.request.model = %request.model,
113 gen_ai.request.temperature = request.temperature.unwrap_or(-1.0),
114 gen_ai.request.max_tokens = request.max_completion_tokens.or(request.max_tokens).unwrap_or(0),
115 gen_ai.usage.input_tokens = tracing::field::Empty,
116 gen_ai.usage.output_tokens = tracing::field::Empty,
117 gen_ai.response.model = tracing::field::Empty,
118 gen_ai.response.finish_reason = tracing::field::Empty,
119 error = tracing::field::Empty,
120 );
121
122 async {
123 let url = self.chat_url();
124 let mut body = self.build_body(request).await?;
125 body.stream = false;
126
127 debug!(model = %request.model, messages = request.messages.len(), "Sending Ollama chat request");
128
129 let response = self.client().post(&url).json(&body).send().await?;
130
131 let status = response.status();
132 if !status.is_success() {
133 let error_text = response.text().await.unwrap_or_default();
134 let err = Self::parse_error(status.as_u16(), &error_text);
135 error!(error = %err, status = status.as_u16(), "Ollama API error");
136 tracing::Span::current().record("error", tracing::field::display(&err));
137 return Err(err.into());
138 }
139
140 let response_text = response.text().await?;
141 let parsed: OllamaChatResponse = serde_json::from_str(&response_text).map_err(|e| {
142 let err = LlmError::response_format(
143 "valid Ollama response",
144 format!("parse error: {e}, response: {response_text}"),
145 );
146 error!(error = %err, "Ollama response parse error");
147 tracing::Span::current().record("error", tracing::field::display(&err));
148 err
149 })?;
150
151 let result = Self::parse_response(parsed);
152
153 let current = tracing::Span::current();
155 if let Some(ref usage) = result.usage {
156 current.record("gen_ai.usage.input_tokens", usage.input_tokens);
157 current.record("gen_ai.usage.output_tokens", usage.output_tokens);
158 }
159 if let Some(ref model) = result.model {
160 current.record("gen_ai.response.model", model.as_str());
161 }
162 current.record("gen_ai.response.finish_reason", result.stop_reason.as_str());
163
164 info!(
165 model = result.model.as_deref().unwrap_or(&request.model),
166 finish_reason = result.stop_reason.as_str(),
167 "Ollama chat completed",
168 );
169
170 Ok(result)
171 }
172 .instrument(span)
173 .await
174 }
175
176 async fn chat_stream(
177 &self,
178 request: &ChatRequest,
179 ) -> Result<Pin<Box<dyn Stream<Item = Result<StreamChunk>> + Send>>> {
180 debug!(
181 gen_ai.system = "ollama",
182 model = %request.model,
183 messages = request.messages.len(),
184 "Starting Ollama chat stream",
185 );
186
187 let url = self.chat_url();
188 let mut body = self.build_body(request).await?;
189 body.stream = true;
190
191 let response = self.client().post(&url).json(&body).send().await?;
192
193 let status = response.status();
194 if !status.is_success() {
195 let error_text = response.text().await.unwrap_or_default();
196 return Err(Self::parse_error(status.as_u16(), &error_text).into());
197 }
198
199 let stream = response.bytes_stream();
200 let parsed_stream = stream.flat_map(move |chunk_result| {
201 let chunks: Vec<Result<StreamChunk>> = match chunk_result {
202 Ok(bytes) => {
203 let text = String::from_utf8_lossy(&bytes);
204 text.lines().filter_map(parse_stream_line).collect()
205 }
206 Err(e) => vec![Err(LlmError::stream(e.to_string()).into())],
207 };
208 futures::stream::iter(chunks)
209 });
210
211 Ok(Box::pin(parsed_stream))
212 }
213
214 fn provider_name(&self) -> &'static str {
215 "ollama"
216 }
217
218 fn default_model(&self) -> &str {
219 self.model()
220 }
221
222 fn supports_streaming(&self) -> bool {
223 true
224 }
225
226 fn supports_tools(&self) -> bool {
227 true
228 }
229
230 fn supports_vision(&self) -> bool {
231 true
232 }
233
234 fn supports_json_mode(&self) -> bool {
235 true
236 }
237}
238
239#[cfg(test)]
240#[allow(clippy::unwrap_used, clippy::panic)]
241mod tests {
242 use super::*;
243
244 mod ollama_chat_response {
245 use super::*;
246
247 #[test]
248 fn deserializes_basic_response() {
249 let json = r#"{
250 "model": "llama3",
251 "message": {
252 "content": "Hello!"
253 },
254 "done_reason": "stop"
255 }"#;
256
257 let response: OllamaChatResponse = serde_json::from_str(json).unwrap();
258
259 assert_eq!(response.model, "llama3");
260 assert_eq!(response.message.content, "Hello!");
261 assert_eq!(response.done_reason, Some("stop".to_owned()));
262 }
263
264 #[test]
265 fn deserializes_with_usage_info() {
266 let json = r#"{
267 "model": "llama3",
268 "message": {"content": "Test"},
269 "done_reason": "stop",
270 "prompt_eval_count": 100,
271 "eval_count": 50
272 }"#;
273
274 let response: OllamaChatResponse = serde_json::from_str(json).unwrap();
275
276 assert_eq!(response.prompt_eval_count, Some(100));
277 assert_eq!(response.eval_count, Some(50));
278 }
279
280 #[test]
281 fn deserializes_with_tool_calls() {
282 let json = r#"{
283 "model": "llama3",
284 "message": {
285 "content": "",
286 "tool_calls": [{
287 "function": {
288 "name": "get_weather",
289 "arguments": {"city": "Tokyo"}
290 }
291 }]
292 },
293 "done_reason": "stop"
294 }"#;
295
296 let response: OllamaChatResponse = serde_json::from_str(json).unwrap();
297
298 assert!(response.message.tool_calls.is_some());
299 let tool_calls = response.message.tool_calls.unwrap();
300 assert_eq!(tool_calls.len(), 1);
301 assert_eq!(tool_calls[0].function.name, "get_weather");
302 }
303
304 #[test]
305 fn deserializes_with_thinking() {
306 let json = r#"{
307 "model": "qwen3",
308 "message": {
309 "content": "The answer is 42.",
310 "thinking": "Let me calculate this..."
311 },
312 "done_reason": "stop"
313 }"#;
314
315 let response: OllamaChatResponse = serde_json::from_str(json).unwrap();
316
317 assert_eq!(
318 response.message.thinking,
319 Some("Let me calculate this...".to_owned())
320 );
321 }
322
323 #[test]
324 fn deserializes_with_length_done_reason() {
325 let json = r#"{
326 "model": "llama3",
327 "message": {"content": "Truncated..."},
328 "done_reason": "length"
329 }"#;
330
331 let response: OllamaChatResponse = serde_json::from_str(json).unwrap();
332
333 assert_eq!(response.done_reason, Some("length".to_owned()));
334 }
335
336 #[test]
337 fn deserializes_without_optional_fields() {
338 let json = r#"{
339 "model": "llama3",
340 "message": {"content": "Hello"}
341 }"#;
342
343 let response: OllamaChatResponse = serde_json::from_str(json).unwrap();
344
345 assert!(response.done_reason.is_none());
346 assert!(response.prompt_eval_count.is_none());
347 assert!(response.eval_count.is_none());
348 assert!(response.message.tool_calls.is_none());
349 assert!(response.message.thinking.is_none());
350 }
351
352 #[test]
353 fn deserializes_empty_content() {
354 let json = r#"{
355 "model": "llama3",
356 "message": {"content": ""}
357 }"#;
358
359 let response: OllamaChatResponse = serde_json::from_str(json).unwrap();
360
361 assert!(response.message.content.is_empty());
362 }
363 }
364
365 mod parse_response {
366 use super::*;
367 use crate::llms::ollama::client::OllamaFunctionCall;
368
369 fn make_response(content: &str, done_reason: Option<&str>) -> OllamaChatResponse {
370 OllamaChatResponse {
371 model: "llama3".to_owned(),
372 message: OllamaResponseMessage {
373 content: content.to_owned(),
374 tool_calls: None,
375 thinking: None,
376 },
377 done_reason: done_reason.map(String::from),
378 prompt_eval_count: None,
379 eval_count: None,
380 }
381 }
382
383 #[test]
384 fn parses_basic_text_response() {
385 let response = make_response("Hello, world!", Some("stop"));
386 let parsed = Ollama::parse_response(response);
387
388 assert_eq!(parsed.message.role, Role::Assistant);
389 assert!(parsed.message.content.is_some());
390 if let Some(Content::Text(text)) = &parsed.message.content {
391 assert_eq!(text, "Hello, world!");
392 } else {
393 panic!("Expected text content");
394 }
395 }
396
397 #[test]
398 fn parses_stop_reason_stop() {
399 let response = make_response("Done", Some("stop"));
400 let parsed = Ollama::parse_response(response);
401
402 assert_eq!(parsed.stop_reason, StopReason::Stop);
403 }
404
405 #[test]
406 fn parses_stop_reason_length() {
407 let response = make_response("Truncated", Some("length"));
408 let parsed = Ollama::parse_response(response);
409
410 assert_eq!(parsed.stop_reason, StopReason::Length);
411 }
412
413 #[test]
414 fn parses_stop_reason_none_defaults_to_stop() {
415 let response = make_response("Done", None);
416 let parsed = Ollama::parse_response(response);
417
418 assert_eq!(parsed.stop_reason, StopReason::Stop);
419 }
420
421 #[test]
422 fn parses_stop_reason_unknown_defaults_to_stop() {
423 let response = make_response("Done", Some("unknown_reason"));
424 let parsed = Ollama::parse_response(response);
425
426 assert_eq!(parsed.stop_reason, StopReason::Stop);
427 }
428
429 #[test]
430 fn parses_empty_content_as_none() {
431 let response = make_response("", Some("stop"));
432 let parsed = Ollama::parse_response(response);
433
434 assert!(parsed.message.content.is_none());
435 }
436
437 #[test]
438 fn parses_usage_info() {
439 let response = OllamaChatResponse {
440 model: "llama3".to_owned(),
441 message: OllamaResponseMessage {
442 content: "Test".to_owned(),
443 tool_calls: None,
444 thinking: None,
445 },
446 done_reason: Some("stop".to_owned()),
447 prompt_eval_count: Some(100),
448 eval_count: Some(50),
449 };
450
451 let parsed = Ollama::parse_response(response);
452
453 assert!(parsed.usage.is_some());
454 let usage = parsed.usage.unwrap();
455 assert_eq!(usage.input_tokens, 100);
456 assert_eq!(usage.output_tokens, 50);
457 assert_eq!(usage.total_tokens, 150);
458 }
459
460 #[test]
461 fn parses_partial_usage_as_none() {
462 let response = OllamaChatResponse {
463 model: "llama3".to_owned(),
464 message: OllamaResponseMessage {
465 content: "Test".to_owned(),
466 tool_calls: None,
467 thinking: None,
468 },
469 done_reason: Some("stop".to_owned()),
470 prompt_eval_count: Some(100),
471 eval_count: None, };
473
474 let parsed = Ollama::parse_response(response);
475
476 assert!(parsed.usage.is_none());
477 }
478
479 #[test]
480 fn parses_tool_calls() {
481 let response = OllamaChatResponse {
482 model: "llama3".to_owned(),
483 message: OllamaResponseMessage {
484 content: String::new(),
485 tool_calls: Some(vec![OllamaToolCall {
486 function: OllamaFunctionCall {
487 name: "get_weather".to_owned(),
488 arguments: serde_json::json!({"city": "Tokyo"}),
489 },
490 }]),
491 thinking: None,
492 },
493 done_reason: Some("stop".to_owned()),
494 prompt_eval_count: None,
495 eval_count: None,
496 };
497
498 let parsed = Ollama::parse_response(response);
499
500 assert!(parsed.message.tool_calls.is_some());
501 let tool_calls = parsed.message.tool_calls.unwrap();
502 assert_eq!(tool_calls.len(), 1);
503 assert_eq!(tool_calls[0].function.name, "get_weather");
504 assert!(tool_calls[0].id.starts_with("call_"));
505 }
506
507 #[test]
508 fn parses_multiple_tool_calls() {
509 let response = OllamaChatResponse {
510 model: "llama3".to_owned(),
511 message: OllamaResponseMessage {
512 content: String::new(),
513 tool_calls: Some(vec![
514 OllamaToolCall {
515 function: OllamaFunctionCall {
516 name: "get_weather".to_owned(),
517 arguments: serde_json::json!({"city": "Tokyo"}),
518 },
519 },
520 OllamaToolCall {
521 function: OllamaFunctionCall {
522 name: "get_time".to_owned(),
523 arguments: serde_json::json!({"timezone": "JST"}),
524 },
525 },
526 ]),
527 thinking: None,
528 },
529 done_reason: Some("stop".to_owned()),
530 prompt_eval_count: None,
531 eval_count: None,
532 };
533
534 let parsed = Ollama::parse_response(response);
535
536 let tool_calls = parsed.message.tool_calls.unwrap();
537 assert_eq!(tool_calls.len(), 2);
538 assert_eq!(tool_calls[0].function.name, "get_weather");
539 assert_eq!(tool_calls[1].function.name, "get_time");
540 }
541
542 #[test]
543 fn parses_thinking_content() {
544 let response = OllamaChatResponse {
545 model: "qwen3".to_owned(),
546 message: OllamaResponseMessage {
547 content: "The answer is 42.".to_owned(),
548 tool_calls: None,
549 thinking: Some("Let me think about this...".to_owned()),
550 },
551 done_reason: Some("stop".to_owned()),
552 prompt_eval_count: None,
553 eval_count: None,
554 };
555
556 let parsed = Ollama::parse_response(response);
557
558 assert_eq!(
559 parsed.message.reasoning_content,
560 Some("Let me think about this...".to_owned())
561 );
562 }
563
564 #[test]
565 fn parses_empty_thinking_as_none() {
566 let response = OllamaChatResponse {
567 model: "qwen3".to_owned(),
568 message: OllamaResponseMessage {
569 content: "Answer".to_owned(),
570 tool_calls: None,
571 thinking: Some(String::new()),
572 },
573 done_reason: Some("stop".to_owned()),
574 prompt_eval_count: None,
575 eval_count: None,
576 };
577
578 let parsed = Ollama::parse_response(response);
579
580 assert!(parsed.message.reasoning_content.is_none());
581 }
582
583 #[test]
584 fn includes_model_in_response() {
585 let response = make_response("Test", Some("stop"));
586 let parsed = Ollama::parse_response(response);
587
588 assert_eq!(parsed.model, Some("llama3".to_owned()));
589 }
590
591 #[test]
592 fn id_is_none() {
593 let response = make_response("Test", Some("stop"));
594 let parsed = Ollama::parse_response(response);
595
596 assert!(parsed.id.is_none());
598 }
599
600 #[test]
601 fn service_tier_is_none() {
602 let response = make_response("Test", Some("stop"));
603 let parsed = Ollama::parse_response(response);
604
605 assert!(parsed.service_tier.is_none());
606 }
607
608 #[test]
609 fn raw_is_none() {
610 let response = make_response("Test", Some("stop"));
611 let parsed = Ollama::parse_response(response);
612
613 assert!(parsed.raw.is_none());
614 }
615 }
616
617 mod chat_provider_impl {
618 use super::*;
619
620 #[test]
621 fn provider_name_is_ollama() {
622 let client = Ollama::with_defaults().unwrap();
623 assert_eq!(client.provider_name(), "ollama");
624 }
625
626 #[test]
627 fn default_model_returns_config_model() {
628 let client = Ollama::with_defaults().unwrap();
629 assert_eq!(client.default_model(), client.model());
630 }
631
632 #[test]
633 fn supports_streaming() {
634 let client = Ollama::with_defaults().unwrap();
635 assert!(client.supports_streaming());
636 }
637
638 #[test]
639 fn supports_tools() {
640 let client = Ollama::with_defaults().unwrap();
641 assert!(client.supports_tools());
642 }
643
644 #[test]
645 fn supports_vision() {
646 let client = Ollama::with_defaults().unwrap();
647 assert!(client.supports_vision());
648 }
649
650 #[test]
651 fn supports_json_mode() {
652 let client = Ollama::with_defaults().unwrap();
653 assert!(client.supports_json_mode());
654 }
655 }
656
657 mod tool_call_id_generation {
658 use super::*;
659 use crate::llms::ollama::client::OllamaFunctionCall;
660
661 #[test]
662 fn generates_unique_tool_call_ids() {
663 let response = OllamaChatResponse {
664 model: "llama3".to_owned(),
665 message: OllamaResponseMessage {
666 content: String::new(),
667 tool_calls: Some(vec![
668 OllamaToolCall {
669 function: OllamaFunctionCall {
670 name: "tool1".to_owned(),
671 arguments: serde_json::json!({}),
672 },
673 },
674 OllamaToolCall {
675 function: OllamaFunctionCall {
676 name: "tool2".to_owned(),
677 arguments: serde_json::json!({}),
678 },
679 },
680 ]),
681 thinking: None,
682 },
683 done_reason: Some("stop".to_owned()),
684 prompt_eval_count: None,
685 eval_count: None,
686 };
687
688 let parsed = Ollama::parse_response(response);
689 let tool_calls = parsed.message.tool_calls.unwrap();
690
691 assert_ne!(tool_calls[0].id, tool_calls[1].id);
693 }
694
695 #[test]
696 fn tool_call_ids_have_call_prefix() {
697 let response = OllamaChatResponse {
698 model: "llama3".to_owned(),
699 message: OllamaResponseMessage {
700 content: String::new(),
701 tool_calls: Some(vec![OllamaToolCall {
702 function: OllamaFunctionCall {
703 name: "test".to_owned(),
704 arguments: serde_json::json!({}),
705 },
706 }]),
707 thinking: None,
708 },
709 done_reason: Some("stop".to_owned()),
710 prompt_eval_count: None,
711 eval_count: None,
712 };
713
714 let parsed = Ollama::parse_response(response);
715 let tool_calls = parsed.message.tool_calls.unwrap();
716
717 assert!(tool_calls[0].id.starts_with("call_"));
718 }
719 }
720
721 mod tool_call_arguments {
722 use super::*;
723 use crate::llms::ollama::client::OllamaFunctionCall;
724
725 #[test]
726 fn serializes_object_arguments() {
727 let response = OllamaChatResponse {
728 model: "llama3".to_owned(),
729 message: OllamaResponseMessage {
730 content: String::new(),
731 tool_calls: Some(vec![OllamaToolCall {
732 function: OllamaFunctionCall {
733 name: "get_weather".to_owned(),
734 arguments: serde_json::json!({
735 "city": "Tokyo",
736 "units": "celsius"
737 }),
738 },
739 }]),
740 thinking: None,
741 },
742 done_reason: Some("stop".to_owned()),
743 prompt_eval_count: None,
744 eval_count: None,
745 };
746
747 let parsed = Ollama::parse_response(response);
748 let tool_calls = parsed.message.tool_calls.unwrap();
749 let args = &tool_calls[0].function.arguments;
750
751 assert!(args.contains("Tokyo"));
753 assert!(args.contains("celsius"));
754 }
755
756 #[test]
757 fn handles_empty_arguments() {
758 let response = OllamaChatResponse {
759 model: "llama3".to_owned(),
760 message: OllamaResponseMessage {
761 content: String::new(),
762 tool_calls: Some(vec![OllamaToolCall {
763 function: OllamaFunctionCall {
764 name: "no_params_tool".to_owned(),
765 arguments: serde_json::json!({}),
766 },
767 }]),
768 thinking: None,
769 },
770 done_reason: Some("stop".to_owned()),
771 prompt_eval_count: None,
772 eval_count: None,
773 };
774
775 let parsed = Ollama::parse_response(response);
776 let tool_calls = parsed.message.tool_calls.unwrap();
777 let args = &tool_calls[0].function.arguments;
778
779 assert_eq!(args, "{}");
780 }
781 }
782
783 mod realistic_responses {
784 use super::*;
785
786 #[test]
787 fn parses_typical_chat_response() {
788 let json = r#"{
789 "model": "llama3.2:latest",
790 "created_at": "2024-01-15T10:30:00Z",
791 "message": {
792 "role": "assistant",
793 "content": "The capital of France is Paris. It is known for the Eiffel Tower."
794 },
795 "done": true,
796 "done_reason": "stop",
797 "total_duration": 1234567890,
798 "load_duration": 123456789,
799 "prompt_eval_count": 15,
800 "prompt_eval_duration": 12345678,
801 "eval_count": 25,
802 "eval_duration": 123456789
803 }"#;
804
805 let response: OllamaChatResponse = serde_json::from_str(json).unwrap();
806 let parsed = Ollama::parse_response(response);
807
808 assert!(parsed.message.content.is_some());
809 assert_eq!(parsed.stop_reason, StopReason::Stop);
810 assert!(parsed.usage.is_some());
811 let usage = parsed.usage.unwrap();
812 assert_eq!(usage.input_tokens, 15);
813 assert_eq!(usage.output_tokens, 25);
814 }
815
816 #[test]
817 fn parses_tool_call_response() {
818 let json = r#"{
819 "model": "llama3.2:latest",
820 "message": {
821 "role": "assistant",
822 "content": "",
823 "tool_calls": [
824 {
825 "function": {
826 "name": "get_current_weather",
827 "arguments": {
828 "location": "San Francisco, CA",
829 "format": "fahrenheit"
830 }
831 }
832 }
833 ]
834 },
835 "done": true,
836 "done_reason": "stop",
837 "prompt_eval_count": 100,
838 "eval_count": 20
839 }"#;
840
841 let response: OllamaChatResponse = serde_json::from_str(json).unwrap();
842 let parsed = Ollama::parse_response(response);
843
844 assert!(parsed.message.content.is_none());
845 assert!(parsed.message.tool_calls.is_some());
846 let tool_calls = parsed.message.tool_calls.unwrap();
847 assert_eq!(tool_calls[0].function.name, "get_current_weather");
848 }
849
850 #[test]
851 fn parses_reasoning_model_response() {
852 let json = r#"{
853 "model": "qwen3:thinking",
854 "message": {
855 "role": "assistant",
856 "content": "Based on my analysis, the answer is 42.",
857 "thinking": "Let me break this down step by step:\n1. First, I need to consider...\n2. Then, applying the formula..."
858 },
859 "done": true,
860 "done_reason": "stop",
861 "prompt_eval_count": 50,
862 "eval_count": 150
863 }"#;
864
865 let response: OllamaChatResponse = serde_json::from_str(json).unwrap();
866 let parsed = Ollama::parse_response(response);
867
868 assert!(parsed.message.content.is_some());
869 assert!(parsed.message.reasoning_content.is_some());
870 assert!(
871 parsed
872 .message
873 .reasoning_content
874 .unwrap()
875 .contains("step by step")
876 );
877 }
878
879 #[test]
880 fn parses_truncated_response() {
881 let json = r#"{
882 "model": "llama3.2:latest",
883 "message": {
884 "role": "assistant",
885 "content": "This is a very long response that was truncated because it reached the maximum token limit. The response continues to explain in great detail about the topic but unfortunately the..."
886 },
887 "done": true,
888 "done_reason": "length",
889 "prompt_eval_count": 20,
890 "eval_count": 4096
891 }"#;
892
893 let response: OllamaChatResponse = serde_json::from_str(json).unwrap();
894 let parsed = Ollama::parse_response(response);
895
896 assert_eq!(parsed.stop_reason, StopReason::Length);
897 assert!(parsed.usage.is_some());
898 assert_eq!(parsed.usage.unwrap().output_tokens, 4096);
899 }
900 }
901}