1use crate::client::LLMClient;
10use crate::provider::{
11 ContentPart, FinishReason, LLMError, LLMProvider, LLMRequest, LLMResponse, LLMStream, LLMStreamEvent, Message,
12 MessageContent, MessageRole, ToolCall, ToolChoice, ToolDefinition, Usage,
13};
14use anyhow::Result;
15use async_stream::try_stream;
16use async_trait::async_trait;
17use futures::StreamExt;
18use hashbrown::HashMap;
19use reqwest::Client as HttpClient;
20use serde::{Deserialize, Serialize};
21use serde_json::{Map, Value};
22use vtcode_config::TimeoutsConfig;
23use vtcode_config::constants::{env_vars, models, urls};
24use vtcode_config::core::{AnthropicConfig, ModelConfig, PromptCachingConfig};
25
26pub(crate) mod client;
27pub(crate) mod parser;
28pub mod pull;
29pub(crate) mod url;
30
31pub(crate) use client::OllamaClient;
32pub(crate) use parser::pull_events_from_value;
33pub(crate) use pull::{CliPullProgressReporter, OllamaPullEvent, OllamaPullProgressReporter, TuiPullProgressReporter};
34pub(crate) use url::{base_url_to_host_root, is_openai_compatible_base_url};
35
36use super::common::{
37 assistant_interleaved_history_text, collect_history_system_directives, extract_reasoning_text_from_detail_values,
38 extract_reasoning_text_from_serialized_details, is_minimax_m2_model, merge_system_prompt_with_history_directives,
39 override_base_url, parse_client_prompt_common, resolve_model, serialize_reasoning_detail_values,
40};
41use super::error_handling::{format_network_error, format_parse_error};
42use super::local_readiness::{invalidate_readiness_cache, resolve_local_model};
43use super::local_server::LocalProvider;
44
45async fn ensure_oss_ready(model: Option<&str>, base_url: Option<String>) -> std::io::Result<()> {
52 let target_model = model.unwrap_or(models::ollama::DEFAULT_MODEL);
53
54 let resolved_base_url = override_base_url(urls::OLLAMA_API_BASE, base_url, Some(env_vars::OLLAMA_BASE_URL));
55
56 let ollama_client = OllamaClient::try_from_base_url(&resolved_base_url).await?;
58
59 match ollama_client.fetch_models().await {
61 Ok(existing_models) => {
62 if !existing_models.iter().any(|m| m == target_model) {
63 tracing::info!("Model '{target_model}' not found locally, pulling...");
64 let mut reporter = CliPullProgressReporter::new();
65 ollama_client.pull_with_reporter(target_model, &mut reporter).await?;
66 }
67 }
68 Err(e) => {
69 tracing::warn!("Failed to list Ollama models: {e}");
70 }
72 }
73
74 Ok(())
75}
76
77#[derive(Debug, Deserialize, Serialize)]
78struct OllamaTagsResponse {
79 models: Vec<OllamaTag>,
80}
81
82#[derive(Debug, Deserialize, Serialize)]
83struct OllamaTag {
84 name: Option<String>,
85 model: Option<String>,
86 modified_at: Option<String>,
87 size: Option<u64>,
88 digest: Option<String>,
89 details: Option<OllamaModelDetails>,
90}
91
92#[derive(Debug, Deserialize, Serialize)]
93struct OllamaModelDetails {
94 format: Option<String>,
95 family: Option<String>,
96 families: Option<Vec<String>>,
97 parameter_size: Option<String>,
98 quantization_level: Option<String>,
99}
100
101pub(super) fn ollama_model_name_from_fields<'a>(name: Option<&'a str>, model: Option<&'a str>) -> Option<&'a str> {
102 name.or(model).map(str::trim).filter(|value| !value.is_empty())
103}
104
105pub(super) const OLLAMA_CONNECTION_ERROR: &str = "No running Ollama server detected. Start it with: `ollama serve` (after installing)\n\
106 Install instructions: https://github.com/ollama/ollama?tab=readme-ov-file";
107
108pub async fn fetch_ollama_models(base_url: Option<String>) -> Result<Vec<String>, anyhow::Error> {
110 use vtcode_config::constants::{env_vars, urls};
111
112 let resolved_base_url = override_base_url(urls::OLLAMA_API_BASE, base_url, Some(env_vars::OLLAMA_BASE_URL));
113
114 let tags_url = format!("{resolved_base_url}/api/tags");
116
117 let client = vtcode_commons::http::create_client_with_timeout(std::time::Duration::from_secs(5));
119
120 let response = client
122 .get(&tags_url)
123 .header("Content-Type", "application/json")
124 .send()
125 .await
126 .map_err(|e| {
127 tracing::debug!("Failed to connect to Ollama server: {e:?}");
131 anyhow::anyhow!(OLLAMA_CONNECTION_ERROR)
132 })?;
133
134 if !response.status().is_success() {
135 return Err(anyhow::anyhow!(
136 "Failed to fetch Ollama models: HTTP {}. {}",
137 response.status(),
138 if response.status() == reqwest::StatusCode::NOT_FOUND {
139 "Ensure Ollama server is running."
140 } else {
141 ""
142 }
143 ));
144 }
145
146 let tags_response: OllamaTagsResponse = response
148 .json()
149 .await
150 .map_err(|e| anyhow::anyhow!("Failed to parse Ollama models response: {e}"))?;
151
152 let model_names: Vec<String> = tags_response
154 .models
155 .into_iter()
156 .filter_map(|model| {
157 ollama_model_name_from_fields(model.name.as_deref(), model.model.as_deref()).map(str::to_string)
158 })
159 .collect();
160
161 Ok(model_names)
162}
163
164pub struct OllamaProvider {
165 http_client: HttpClient,
166 base_url: String,
167 model: String,
168 api_key: Option<String>,
169 model_behavior: Option<ModelConfig>,
170}
171
172impl OllamaProvider {
173 fn merged_system_prompt(request: &LLMRequest) -> Option<String> {
174 const HISTORY_DIRECTIVES_SECTION_HEADER: &str = "[History Directives]";
175 let directives = collect_history_system_directives(request);
176 merge_system_prompt_with_history_directives(
177 request.system_prompt.as_ref().map(|prompt| prompt.as_ref()),
178 &directives,
179 HISTORY_DIRECTIVES_SECTION_HEADER,
180 )
181 }
182
183 pub fn new(api_key: String) -> Self {
184 Self::with_model(api_key, models::ollama::DEFAULT_MODEL.to_string())
185 }
186
187 fn with_model(api_key: String, model: String) -> Self {
188 Self::with_model_internal(model, None, Some(api_key), None)
189 }
190
191 pub fn new_with_client(
192 api_key: String,
193 model: String,
194 http_client: reqwest::Client,
195 base_url: String,
196 _timeouts: TimeoutsConfig,
197 ) -> Self {
198 Self {
199 http_client,
200 base_url,
201 model,
202 api_key: Some(api_key),
203 model_behavior: None,
204 }
205 }
206
207 pub fn from_config(
208 api_key: Option<String>,
209 model: Option<String>,
210 base_url: Option<String>,
211 _prompt_cache: Option<PromptCachingConfig>,
212 _timeouts: Option<TimeoutsConfig>,
213 _anthropic: Option<AnthropicConfig>,
214 model_behavior: Option<ModelConfig>,
215 ) -> Self {
216 let resolved_model = resolve_model(model, models::ollama::DEFAULT_MODEL);
217 Self::with_model_internal(resolved_model, base_url, api_key, model_behavior)
218 }
219
220 fn normalize_api_key(api_key: Option<String>) -> Option<String> {
221 api_key.and_then(|value| {
222 let trimmed = value.trim();
223 if trimmed.is_empty() {
224 None
225 } else {
226 Some(trimmed.to_string())
227 }
228 })
229 }
230
231 fn with_model_internal(
232 model: String,
233 base_url: Option<String>,
234 api_key: Option<String>,
235 model_behavior: Option<ModelConfig>,
236 ) -> Self {
237 let normalized_api_key = Self::normalize_api_key(api_key);
238 let is_cloud_model = model.contains(":cloud") || model.contains("-cloud");
239
240 let default_base = if is_cloud_model {
241 urls::OLLAMA_CLOUD_API_BASE
242 } else {
243 urls::OLLAMA_API_BASE
244 };
245
246 let resolved_base = override_base_url(default_base, base_url, Some(env_vars::OLLAMA_BASE_URL));
247 let target_is_local = super::local_server::is_local_base_url(&resolved_base);
248
249 let effective_api_key = if target_is_local { None } else { normalized_api_key };
251
252 Self {
253 http_client: vtcode_commons::http::create_default_client(),
254 base_url: resolved_base,
255 model,
256 api_key: effective_api_key,
257 model_behavior,
258 }
259 }
260
261 fn chat_url(&self) -> String {
262 format!("{}/api/chat", self.base_url.trim_end_matches('/'))
263 }
264
265 fn authorized_post(&self, url: String) -> reqwest::RequestBuilder {
266 let builder = self.http_client.post(url);
267 if let Some(api_key) = &self.api_key {
268 builder.bearer_auth(api_key)
269 } else {
270 builder
271 }
272 }
273
274 fn parse_client_prompt(&self, prompt: &str) -> LLMRequest {
275 parse_client_prompt_common(prompt, &self.model, |value| self.parse_chat_request(value))
276 }
277
278 async fn ensure_ready(&self, requested: &str) -> Result<String, LLMError> {
290 let autopull = std::env::var("VTCODE_LOCAL_AUTOPULL")
291 .ok()
292 .is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
293
294 match resolve_local_model(LocalProvider::Ollama, requested, Some(&self.base_url)).await {
295 Ok(model) => Ok(model),
296 Err(err) if autopull => {
297 let model = match &err {
298 super::local_readiness::LocalReadinessError::ModelMissing { model, .. } => model.clone(),
299 _ => return Err(err.to_llm_error("Ollama")),
300 };
301 match ensure_oss_ready(Some(&model), Some(self.base_url.clone())).await {
302 Ok(()) => {
303 invalidate_readiness_cache();
304 Ok(model)
305 }
306 Err(_) => Err(err.to_llm_error("Ollama")),
307 }
308 }
309 Err(err) => Err(err.to_llm_error("Ollama")),
310 }
311 }
312
313 fn parse_chat_request(&self, value: &Value) -> Option<LLMRequest> {
314 let messages_value = value.get("messages")?.as_array()?;
315 let mut system_prompt = value
316 .get("system")
317 .and_then(|entry| entry.as_str())
318 .filter(|text| !text.trim().is_empty())
319 .map(|text| text.to_string());
320 let mut messages = Vec::new();
321
322 for entry in messages_value {
323 let role = entry
324 .get("role")
325 .and_then(|r| r.as_str())
326 .unwrap_or(vtcode_config::constants::message_roles::USER);
327 let content = entry
328 .get("content")
329 .map(|c| match c {
330 Value::String(text) => text.to_string(),
331 other => other.to_string(),
332 })
333 .unwrap_or_default();
334
335 if content.trim().is_empty() {
336 continue;
337 }
338
339 match role {
340 "system" => {
341 if system_prompt.is_none() {
342 system_prompt = Some(content);
343 }
344 }
345 "assistant" => messages.push(Message::assistant(content)),
346 "user" => messages.push(Message::user(content)),
347 _ => {}
348 }
349 }
350
351 if messages.is_empty() {
352 return None;
353 }
354
355 let tools = value
356 .get("tools")
357 .and_then(|entry| serde_json::from_value::<Vec<ToolDefinition>>(entry.clone()).ok());
358
359 Some(LLMRequest {
360 messages: std::sync::Arc::new(messages),
361 system_prompt: system_prompt.map(std::sync::Arc::from),
362 tools: tools.map(std::sync::Arc::new),
363 model: value
364 .get("model")
365 .and_then(|m| m.as_str())
366 .filter(|m| !m.trim().is_empty())
367 .map(|m| m.to_string())
368 .unwrap_or_else(|| self.model.clone()),
369 max_tokens: value
370 .get("max_tokens")
371 .and_then(|entry| entry.as_u64())
372 .map(|value| value as u32),
373 temperature: value
374 .get("temperature")
375 .and_then(|entry| entry.as_f64())
376 .map(|value| value as f32),
377 stream: value.get("stream").and_then(|entry| entry.as_bool()).unwrap_or(false),
378 ..Default::default()
379 })
380 }
381
382 fn build_payload(&self, request: &LLMRequest, stream: bool) -> Result<OllamaChatRequest, LLMError> {
383 let mut messages = Vec::new();
384 let mut tool_names: HashMap<String, String> = HashMap::new();
385 let minimax_tool_followup_compat = Self::minimax_tool_followup_compat_mode(request);
386
387 if let Some(system) = Self::merged_system_prompt(request) {
388 messages.push(OllamaChatMessage {
389 role: "system".to_string(),
390 content: Some(system),
391 thinking: None,
392 tool_calls: None,
393 tool_call_id: None,
394 tool_name: None,
395 images: None,
396 });
397 }
398
399 for message in request.messages.iter() {
400 let interleaved_content = assistant_interleaved_history_text(message, &request.model);
401 let used_interleaved_content = interleaved_content.is_some();
402 let (content_text, images) = if let Some(interleaved_content) = interleaved_content {
403 (interleaved_content, None)
404 } else {
405 Self::extract_content_and_images(&message.content)
406 };
407 match message.role {
408 MessageRole::System => continue,
409 MessageRole::Tool => {
410 let tool_name = message.tool_call_id.as_ref().and_then(|id| tool_names.get(id).cloned());
411 let tool_name = tool_name.or_else(|| message.origin_tool.clone());
412 let tool_call_id = if minimax_tool_followup_compat && tool_name.is_some() {
413 None
414 } else {
415 message.tool_call_id.clone()
416 };
417 messages.push(OllamaChatMessage {
418 role: "tool".to_string(),
419 content: Some(content_text),
420 thinking: None,
421 tool_calls: None,
422 tool_call_id,
423 tool_name,
424 images: None,
425 });
426 }
427 _ => {
428 let thinking = if used_interleaved_content {
429 None
430 } else {
431 Self::assistant_thinking_history_text(message)
432 };
433 let mut payload_message = OllamaChatMessage {
434 role: message.role.as_generic_str().to_string(),
435 content: Some(content_text),
436 thinking,
437 tool_calls: None,
438 tool_call_id: None,
439 tool_name: None,
440 images,
441 };
442
443 if let Some(tool_calls) = message.get_tool_calls() {
444 let mut converted = Vec::new();
445 for (index, tool_call) in tool_calls.iter().enumerate() {
446 if let Some(ref func) = tool_call.function {
447 if !tool_call.id.is_empty() {
448 tool_names.entry(tool_call.id.clone()).or_insert_with(|| func.name.clone());
449 }
450
451 let arguments =
452 tool_call.execution_arguments().map_err(|err| LLMError::InvalidRequest {
453 message: format!("Failed to parse tool arguments for Ollama: {err}"),
454 metadata: None,
455 })?;
456 converted.push(OllamaToolCall {
457 call_type: tool_call.call_type.clone(),
458 function: OllamaToolFunctionCall {
459 name: func.name.clone(),
460 arguments: Some(arguments),
461 index: Some(index as u32),
462 },
463 });
464 }
465 }
466
467 if !converted.is_empty() {
468 payload_message.tool_calls = Some(converted);
469 if payload_message.content.is_none() {
470 payload_message.content = Some(String::new());
471 }
472 }
473 }
474
475 messages.push(payload_message);
476 }
477 }
478 }
479
480 let options = if request.temperature.is_some() || request.max_tokens.is_some() {
481 Some(OllamaChatOptions {
482 temperature: request.temperature,
483 num_predict: request.max_tokens,
484 })
485 } else {
486 None
487 };
488
489 let tools = request.tools.as_ref().map(|tools| {
494 tools
495 .iter()
496 .filter_map(|tool| {
497 tool.function.as_ref().map(|func| {
499 ToolDefinition::function(func.name.clone(), func.description.clone(), func.parameters.clone())
500 })
501 })
502 .collect()
503 });
504
505 Ok(OllamaChatRequest {
506 model: request.model.clone(),
507 messages,
508 stream,
509 format: request.output_format.clone(),
510 options,
511 tools,
512 think: Self::think_value(request),
513 })
514 }
515
516 fn assistant_thinking_history_text(message: &Message) -> Option<String> {
517 if message.role != MessageRole::Assistant {
518 return None;
519 }
520
521 message
522 .reasoning
523 .as_deref()
524 .map(str::trim)
525 .filter(|value| !value.is_empty())
526 .map(str::to_owned)
527 .or_else(|| {
528 message
529 .reasoning_details
530 .as_deref()
531 .and_then(extract_reasoning_text_from_detail_values)
532 })
533 }
534
535 fn extract_content_and_images(content: &MessageContent) -> (String, Option<Vec<String>>) {
536 let mut images = Vec::new();
537 if let MessageContent::Parts(parts) = content {
538 for part in parts {
539 if let ContentPart::Image { data, .. } = part {
540 images.push(data.clone());
541 }
542 }
543 }
544
545 let text = content.as_text().into_owned();
546 let images = if images.is_empty() { None } else { Some(images) };
547 (text, images)
548 }
549
550 fn think_value(request: &LLMRequest) -> Option<Value> {
551 let model_id = request.model.as_str();
552 if Self::minimax_tool_followup_compat_mode(request) {
553 return None;
554 }
555 if !models::ollama::REASONING_MODELS.contains(&model_id) {
556 return None;
557 }
558
559 if models::ollama::REASONING_LEVEL_MODELS.contains(&model_id) {
560 request.reasoning_effort.map(|effort| Value::String(effort.to_string()))
561 } else {
562 Some(Value::Bool(true))
563 }
564 }
565
566 fn minimax_tool_followup_compat_mode(request: &LLMRequest) -> bool {
567 is_minimax_m2_model(&request.model)
568 && request
569 .messages
570 .iter()
571 .any(|message| message.role == MessageRole::Tool || message.has_tool_calls())
572 }
573
574 fn convert_tool_calls(tool_calls: Option<Vec<OllamaResponseToolCall>>) -> Result<Option<Vec<ToolCall>>, LLMError> {
575 let Some(tool_calls) = tool_calls else {
576 return Ok(None);
577 };
578
579 if tool_calls.is_empty() {
580 return Ok(None);
581 }
582
583 let mut converted = Vec::new();
584 for call in tool_calls.into_iter() {
585 let function = call.function.ok_or_else(|| LLMError::Provider {
586 message: "Ollama response missing function details for tool call".to_string(),
587 metadata: None,
588 })?;
589
590 let name = function.name.ok_or_else(|| LLMError::Provider {
591 message: "Ollama response missing tool function name".to_string(),
592 metadata: None,
593 })?;
594
595 let arguments_value = function.arguments.unwrap_or_else(|| Value::Object(Map::new()));
596 let arguments = match arguments_value {
597 Value::String(raw) => raw,
598 other => serde_json::to_string(&other).map_err(|err| LLMError::Provider {
599 message: format!("Failed to serialize Ollama tool arguments: {err}"),
600 metadata: None,
601 })?,
602 };
603
604 let id = crate::providers::shared::generate_tool_call_id();
607
608 converted.push(ToolCall::function(id, name, arguments));
609 }
610
611 Ok(Some(converted))
612 }
613
614 fn usage_from_counts(prompt_tokens: Option<u32>, completion_tokens: Option<u32>) -> Option<Usage> {
615 if prompt_tokens.is_none() && completion_tokens.is_none() {
616 return None;
617 }
618
619 let prompt = prompt_tokens.unwrap_or_default();
620 let completion = completion_tokens.unwrap_or_default();
621 Some(Usage {
622 prompt_tokens: prompt,
623 completion_tokens: completion,
624 total_tokens: prompt + completion,
625 cached_prompt_tokens: None,
626 cache_creation_tokens: None,
627 cache_read_tokens: None,
628 iterations: None,
629 })
630 }
631
632 fn finish_reason_from(reason: Option<&str>) -> FinishReason {
633 match reason {
634 Some("stop") | None => FinishReason::Stop,
635 Some("length") => FinishReason::Length,
636 Some("tool_calls") => FinishReason::ToolCalls,
637 Some(other) => FinishReason::Error(other.to_string()),
638 }
639 }
640
641 fn build_response(
642 content: Option<String>,
643 tool_calls: Option<Vec<ToolCall>>,
644 reasoning: Option<String>,
645 reasoning_details: Option<Vec<String>>,
646 model: String,
647 finish_reason: Option<&str>,
648 prompt_tokens: Option<u32>,
649 completion_tokens: Option<u32>,
650 ) -> LLMResponse {
651 let mut finish = Self::finish_reason_from(finish_reason);
652 if tool_calls.as_ref().is_some_and(|calls| !calls.is_empty()) {
653 finish = FinishReason::ToolCalls;
654 }
655
656 LLMResponse {
657 content,
658 tool_calls,
659 model,
660 usage: Self::usage_from_counts(prompt_tokens, completion_tokens),
661 finish_reason: finish,
662 reasoning,
663 reasoning_details,
664 tool_references: Vec::new(),
665 request_id: None,
666 organization_id: None,
667 compaction: None,
668 }
669 }
670
671 fn response_from_chat_payload(model: String, parsed: OllamaChatResponse) -> Result<LLMResponse, LLMError> {
672 if let Some(error) = parsed.error {
673 return Err(LLMError::Provider { message: error, metadata: None });
674 }
675
676 let (content, reasoning, tool_calls, native_reasoning_details) = if let Some(message) = parsed.message {
677 let content = message.content.and_then(|value| (!value.is_empty()).then_some(value));
678 let reasoning = message.thinking.and_then(|value| (!value.is_empty()).then_some(value));
679 let tool_calls = Self::convert_tool_calls(message.tool_calls)?;
680 let native_reasoning_details = message.reasoning_details.filter(|d| !d.is_empty());
681 (content, reasoning, tool_calls, native_reasoning_details)
682 } else {
683 (None, None, None, None)
684 };
685
686 let reasoning = reasoning.or_else(|| {
687 native_reasoning_details
688 .as_deref()
689 .and_then(extract_reasoning_text_from_detail_values)
690 });
691 let mut reasoning_details = native_reasoning_details.as_deref().and_then(serialize_reasoning_detail_values);
692
693 let (final_reasoning, final_content) = if reasoning.is_none() {
696 if let Some(ref content_str) = content {
697 let (reasoning_parts, cleaned_content) = crate::utils::extract_reasoning_content(content_str);
698 if reasoning_parts.is_empty() {
699 (None, content)
700 } else {
701 super::common::preserve_interleaved_content_in_reasoning_details(
702 &mut reasoning_details,
703 content_str,
704 );
705 (Some(reasoning_parts.join("\n\n")), cleaned_content.or(content))
706 }
707 } else {
708 (None, content)
709 }
710 } else {
711 (reasoning, content)
712 };
713
714 Ok(Self::build_response(
715 final_content,
716 tool_calls,
717 final_reasoning,
718 reasoning_details,
719 model,
720 parsed.done_reason.as_deref(),
721 parsed.prompt_eval_count,
722 parsed.eval_count,
723 ))
724 }
725
726 fn authorized_post_with_key(http_client: &HttpClient, url: &str, api_key: Option<&str>) -> reqwest::RequestBuilder {
727 let builder = http_client.post(url.to_string());
728 if let Some(value) = api_key {
729 builder.bearer_auth(value)
730 } else {
731 builder
732 }
733 }
734
735 async fn request_non_stream_response(
736 http_client: &HttpClient,
737 url: &str,
738 api_key: Option<&str>,
739 payload: &OllamaChatRequest,
740 model: String,
741 ) -> Result<LLMResponse, LLMError> {
742 let response = Self::authorized_post_with_key(http_client, url, api_key)
743 .json(payload)
744 .send()
745 .await
746 .map_err(|e| format_network_error("Ollama", &e))?;
747
748 if !response.status().is_success() {
749 let status = response.status();
750 let body = crate::providers::common::read_provider_error_body(response).await;
751 let error_message =
752 Self::extract_error(&body).unwrap_or_else(|| format!("Ollama request failed ({status}): {body}"));
753 return Err(LLMError::Provider { message: error_message, metadata: None });
754 }
755
756 let parsed = response
757 .json::<OllamaChatResponse>()
758 .await
759 .map_err(|e| format_parse_error("Ollama", &e))?;
760 Self::response_from_chat_payload(model, parsed)
761 }
762
763 fn extract_error(body: &str) -> Option<String> {
764 serde_json::from_str::<OllamaErrorResponse>(body)
765 .ok()
766 .and_then(|resp| resp.error)
767 }
768}
769
770#[derive(Debug, Serialize)]
771struct OllamaChatRequest {
772 model: String,
773 messages: Vec<OllamaChatMessage>,
774 stream: bool,
775 #[serde(skip_serializing_if = "Option::is_none")]
776 format: Option<Value>,
777 #[serde(skip_serializing_if = "Option::is_none")]
778 options: Option<OllamaChatOptions>,
779 #[serde(skip_serializing_if = "Option::is_none")]
780 tools: Option<Vec<ToolDefinition>>,
781 #[serde(skip_serializing_if = "Option::is_none")]
782 think: Option<Value>,
783}
784
785#[derive(Debug, Serialize)]
786struct OllamaChatMessage {
787 role: String,
788 #[serde(skip_serializing_if = "Option::is_none")]
789 content: Option<String>,
790 #[serde(skip_serializing_if = "Option::is_none")]
791 thinking: Option<String>,
792 #[serde(skip_serializing_if = "Option::is_none")]
793 images: Option<Vec<String>>,
794 #[serde(skip_serializing_if = "Option::is_none")]
795 tool_calls: Option<Vec<OllamaToolCall>>,
796 #[serde(skip_serializing_if = "Option::is_none")]
797 tool_call_id: Option<String>,
798 #[serde(skip_serializing_if = "Option::is_none")]
799 tool_name: Option<String>,
800}
801
802#[derive(Debug, Serialize)]
803struct OllamaChatOptions {
804 #[serde(skip_serializing_if = "Option::is_none")]
805 temperature: Option<f32>,
806 #[serde(skip_serializing_if = "Option::is_none")]
807 num_predict: Option<u32>,
808}
809
810#[derive(Debug, Serialize)]
811struct OllamaToolCall {
812 #[serde(rename = "type")]
813 call_type: String,
814 function: OllamaToolFunctionCall,
815}
816
817#[derive(Debug, Serialize)]
818struct OllamaToolFunctionCall {
819 name: String,
820 #[serde(skip_serializing_if = "Option::is_none")]
821 arguments: Option<Value>,
822 #[serde(skip_serializing_if = "Option::is_none")]
823 index: Option<u32>,
824}
825
826#[derive(Debug, Deserialize)]
827struct OllamaChatResponse {
828 message: Option<OllamaResponseMessage>,
829 #[serde(default)]
830 done: bool,
831 #[serde(default)]
832 done_reason: Option<String>,
833 #[serde(default)]
834 prompt_eval_count: Option<u32>,
835 #[serde(default)]
836 eval_count: Option<u32>,
837 #[serde(default)]
838 error: Option<String>,
839}
840
841#[derive(Debug, Deserialize)]
842struct OllamaResponseMessage {
843 #[serde(default)]
844 #[expect(
845 dead_code,
846 reason = "Intentional compatibility, platform, test, or API-shape suppression."
847 )]
848 role: Option<String>,
849 #[serde(default)]
850 content: Option<String>,
851 #[serde(default)]
852 thinking: Option<String>,
853 #[serde(default)]
854 reasoning_details: Option<Vec<Value>>,
855 #[serde(default)]
856 tool_calls: Option<Vec<OllamaResponseToolCall>>,
857}
858
859#[derive(Debug, Deserialize, Serialize, Clone)]
860struct OllamaResponseToolCall {
861 #[serde(default)]
862 #[serde(rename = "type")]
863 call_type: Option<String>,
864 #[serde(default)]
865 function: Option<OllamaResponseFunctionCall>,
866}
867
868#[derive(Debug, Deserialize, Serialize, Clone)]
869struct OllamaResponseFunctionCall {
870 #[serde(default)]
871 name: Option<String>,
872 #[serde(default)]
873 arguments: Option<Value>,
874 #[serde(default)]
875 index: Option<u32>,
876}
877
878#[derive(Debug, Deserialize)]
879struct OllamaErrorResponse {
880 error: Option<String>,
881}
882
883fn parse_stream_chunk(line: &str) -> Result<OllamaChatResponse, LLMError> {
884 serde_json::from_str::<OllamaChatResponse>(line).map_err(|err| LLMError::Provider {
885 message: format!("Failed to parse Ollama stream chunk: {err}"),
886 metadata: None,
887 })
888}
889
890#[async_trait]
891impl LLMProvider for OllamaProvider {
892 fn name(&self) -> &str {
893 "ollama"
894 }
895
896 fn supports_streaming(&self) -> bool {
897 true
898 }
899
900 fn supports_non_streaming(&self, _model: &str) -> bool {
901 true
903 }
904
905 fn supports_tools(&self, _model: &str) -> bool {
906 true
907 }
908
909 fn supports_reasoning(&self, model: &str) -> bool {
910 models::ollama::REASONING_MODELS.contains(&model)
913 || self
914 .model_behavior
915 .as_ref()
916 .and_then(|b| b.model_supports_reasoning)
917 .unwrap_or(false)
918 }
919
920 fn supports_reasoning_effort(&self, model: &str) -> bool {
921 models::ollama::REASONING_LEVEL_MODELS.contains(&model)
923 || self
924 .model_behavior
925 .as_ref()
926 .and_then(|b| b.model_supports_reasoning_effort)
927 .unwrap_or(false)
928 }
929
930 async fn generate(&self, mut request: LLMRequest) -> Result<LLMResponse, LLMError> {
931 self.validate_request(&request)?;
932 if request.model.is_empty() {
933 request.model = self.model.clone();
934 }
935 let resolved = self.ensure_ready(&request.model).await?;
936 request.model = resolved;
937 let model = request.model.clone();
938 let payload = self.build_payload(&request, false)?;
939 let url = self.chat_url();
940 Self::request_non_stream_response(&self.http_client, &url, self.api_key.as_deref(), &payload, model).await
941 }
942
943 async fn stream(&self, mut request: LLMRequest) -> Result<LLMStream, LLMError> {
944 self.validate_request(&request)?;
945 if request.model.is_empty() {
946 request.model = self.model.clone();
947 }
948 let resolved = self.ensure_ready(&request.model).await?;
949 request.model = resolved;
950 let model = request.model.clone();
951 let payload = self.build_payload(&request, true)?;
952 let fallback_payload = self.build_payload(&request, false)?;
953 let url = self.chat_url();
954
955 let response = self
956 .authorized_post(url.clone())
957 .header(reqwest::header::ACCEPT_ENCODING, "identity")
958 .json(&payload)
959 .send()
960 .await
961 .map_err(|e| format_network_error("Ollama", &e))?;
962
963 if !response.status().is_success() {
964 let status = response.status();
965 let body = crate::providers::common::read_provider_error_body(response).await;
966 let error_message = Self::extract_error(&body)
967 .unwrap_or_else(|| format!("Ollama streaming request failed ({status}): {body}"));
968 return Err(LLMError::Provider { message: error_message, metadata: None });
969 }
970
971 let byte_stream = response.bytes_stream();
972 let mut buffer: Vec<u8> = Vec::new();
973 let mut aggregator = crate::providers::shared::StreamAggregator::new(model.clone());
974 let fallback_http_client = self.http_client.clone();
975 let fallback_api_key = self.api_key.clone();
976 let fallback_model = model.clone();
977 let fallback_url = url.clone();
978 let any_interleaved = request
979 .messages
980 .iter()
981 .any(|msg| assistant_interleaved_history_text(msg, &request.model).is_some());
982 let stream = try_stream! {
983 let mut prompt_tokens: Option<u32> = None;
984 let mut completion_tokens: Option<u32> = None;
985 let mut finish_reason: Option<String> = None;
986 let mut completed = false;
987 let mut saw_stream_chunk = false;
988
989 futures::pin_mut!(byte_stream);
990 while let Some(chunk_result) = byte_stream.next().await {
991 let chunk = match chunk_result {
992 Ok(chunk) => {
993 saw_stream_chunk = true;
994 chunk
995 }
996 Err(err) if !saw_stream_chunk => {
997 tracing::warn!(
998 model = %fallback_model,
999 url = %fallback_url,
1000 error = %err,
1001 "Ollama stream failed before first chunk; retrying once as non-stream response"
1002 );
1003 let fallback_response = Self::request_non_stream_response(
1004 &fallback_http_client,
1005 &fallback_url,
1006 fallback_api_key.as_deref(),
1007 &fallback_payload,
1008 fallback_model.clone(),
1009 ).await?;
1010 yield LLMStreamEvent::Completed { response: Box::new(fallback_response) };
1011 return;
1012 }
1013 Err(err) => Err(format_network_error("Ollama", &err))?,
1014 };
1015 buffer.extend_from_slice(&chunk);
1016
1017 while let Some(pos) = buffer.iter().position(|b| *b == b'\n') {
1018 let line = std::str::from_utf8(&buffer[..pos])
1023 .map_err(|err| LLMError::Provider {
1024 message: format!("Invalid UTF-8 in Ollama stream: {err}"),
1025 metadata: None,
1026 })?;
1027 let line = line.trim();
1028
1029 if line.is_empty() {
1030 buffer.drain(..=pos);
1031 continue;
1032 }
1033
1034 let parsed = parse_stream_chunk(line)?;
1035 buffer.drain(..=pos);
1037
1038 if let Some(error) = parsed.error {
1039 Err(LLMError::Provider {
1040 message: error,
1041 metadata: None,
1042 })?;
1043 }
1044
1045 if let Some(message) = parsed.message {
1046 if let Some(reasoning_details) = message.reasoning_details.as_deref() {
1047 aggregator.set_reasoning_details(reasoning_details);
1048 }
1049
1050 let has_explicit_thinking = message
1051 .thinking
1052 .as_ref()
1053 .map(|v| !v.is_empty())
1054 .unwrap_or(false);
1055
1056 if let Some(thinking) = message.thinking
1057 && let Some(delta) = aggregator.handle_reasoning(&thinking) {
1058 yield LLMStreamEvent::Reasoning { delta };
1059 }
1060
1061 if let Some(content) = message.content {
1062 for event in aggregator.handle_content(&content) {
1063 match &event {
1064 LLMStreamEvent::Reasoning { .. }
1065 if has_explicit_thinking || any_interleaved =>
1066 {
1067 }
1068 _ => yield event,
1069 }
1070 }
1071 }
1072
1073 if let Some(tool_calls) = message.tool_calls {
1074 let tool_calls_json: Vec<Value> = tool_calls
1075 .into_iter()
1076 .map(|tc| serde_json::to_value(tc).unwrap_or(Value::Null))
1077 .filter(|v| !v.is_null())
1078 .collect();
1079 aggregator.handle_tool_calls(&tool_calls_json);
1080 }
1081 }
1082
1083 if parsed.done {
1084 prompt_tokens = parsed.prompt_eval_count;
1085 completion_tokens = parsed.eval_count;
1086 finish_reason = parsed.done_reason;
1087 completed = true;
1088 }
1089 }
1090
1091 if completed {
1092 break;
1093 }
1094 }
1095
1096 if !completed {
1097 Err(LLMError::Provider {
1098 message: "Ollama stream ended without completion signal".to_string(),
1099 metadata: None,
1100 })?;
1101 }
1102
1103 let mut response = aggregator.finalize();
1104 if let Some(pt) = prompt_tokens {
1105 let mut usage = response.usage.unwrap_or_default();
1106 usage.prompt_tokens = pt;
1107 if let Some(ct) = completion_tokens {
1108 usage.completion_tokens = ct;
1109 usage.total_tokens = pt + ct;
1110 }
1111 response.usage = Some(usage);
1112 }
1113 if let Some(fr) = finish_reason {
1114 response.finish_reason = crate::providers::common::map_finish_reason_common(&fr);
1115 }
1116 if response.reasoning.is_none()
1117 && let Some(details) = response.reasoning_details.as_ref()
1118 {
1119 response.reasoning = extract_reasoning_text_from_serialized_details(details);
1120 }
1121
1122 yield LLMStreamEvent::Completed { response: Box::new(response) };
1123 };
1124
1125 Ok(Box::pin(stream))
1126 }
1127
1128 fn supported_models(&self) -> Vec<String> {
1129 models::ollama::SUPPORTED_MODELS.iter().map(|model| model.to_string()).collect()
1130 }
1131
1132 fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
1133 if let Some(tool_choice) = &request.tool_choice {
1134 match tool_choice {
1135 ToolChoice::Auto | ToolChoice::None | ToolChoice::AllowedTools(_) => {}
1136 _ => {
1137 return Err(LLMError::InvalidRequest {
1138 message: "Ollama does not support explicit tool_choice overrides".to_string(),
1139 metadata: None,
1140 });
1141 }
1142 }
1143 }
1144
1145 if request.parallel_tool_calls.is_some() || request.parallel_tool_config.is_some() {
1146 return Err(LLMError::InvalidRequest {
1147 message: "Ollama does not support parallel tool configuration".to_string(),
1148 metadata: None,
1149 });
1150 }
1151
1152 for message in request.messages.iter() {
1153 if matches!(message.role, MessageRole::Tool) && message.tool_call_id.is_none() {
1154 return Err(LLMError::InvalidRequest {
1155 message: "Ollama tool responses must include tool_call_id".to_string(),
1156 metadata: None,
1157 });
1158 }
1159 }
1160
1161 Ok(())
1162 }
1163}
1164
1165#[async_trait]
1166impl LLMClient for OllamaProvider {
1167 async fn generate(&mut self, prompt: &str) -> Result<LLMResponse, LLMError> {
1168 let mut request = self.parse_client_prompt(prompt);
1169 if request.model.is_empty() {
1170 request.model = self.model.clone();
1171 }
1172 Ok(LLMProvider::generate(self, request).await?)
1173 }
1174
1175 fn model_id(&self) -> &str {
1176 &self.model
1177 }
1178}
1179
1180#[cfg(test)]
1181mod tests {
1182 use super::*;
1183 use crate::provider::{ContentPart, Message, MessageContent};
1184 use serde_json::json;
1185 use vtcode_config::types::ReasoningEffortLevel;
1186
1187 fn test_provider() -> OllamaProvider {
1188 OllamaProvider::from_config(
1189 None,
1190 Some("test-model".to_string()),
1191 Some("http://localhost".to_string()),
1192 None,
1193 None,
1194 None,
1195 None,
1196 )
1197 }
1198
1199 #[test]
1200 fn convert_tool_calls_fabricates_unique_ids_when_missing() {
1201 let make_call = |name: &str| OllamaResponseToolCall {
1202 call_type: Some("function".to_string()),
1203 function: Some(OllamaResponseFunctionCall {
1204 name: Some(name.to_string()),
1205 arguments: Some(json!({})),
1206 index: None,
1207 }),
1208 };
1209
1210 let first = OllamaProvider::convert_tool_calls(Some(vec![make_call("foo"), make_call("bar")]))
1211 .expect("conversion should succeed")
1212 .expect("calls expected");
1213 let second = OllamaProvider::convert_tool_calls(Some(vec![make_call("foo")]))
1214 .expect("conversion should succeed")
1215 .expect("calls expected");
1216
1217 let ids: Vec<&str> = first.iter().chain(second.iter()).map(|call| call.id.as_str()).collect();
1218 let unique: std::collections::HashSet<&str> = ids.iter().copied().collect();
1219 assert_eq!(unique.len(), ids.len(), "fabricated ids must be unique within and across responses");
1220 for id in ids {
1221 assert!(id.starts_with("call_"));
1222 }
1223 }
1224
1225 #[test]
1226 fn build_payload_includes_images() {
1227 let provider = test_provider();
1228 let parts = vec![
1229 ContentPart::text("see ".to_string()),
1230 ContentPart::image("BASE64DATA".to_string(), "image/png".to_string()),
1231 ];
1232 let request = LLMRequest {
1233 model: "test-model".to_string(),
1234 messages: vec![Message::user_with_parts(parts)].into(),
1235 ..Default::default()
1236 };
1237
1238 let payload = provider.build_payload(&request, false).unwrap();
1239 assert_eq!(payload.messages.len(), 1);
1240 let message = &payload.messages[0];
1241 assert_eq!(message.content.as_deref(), Some("see "));
1242 assert_eq!(message.images.as_ref(), Some(&vec!["BASE64DATA".to_string()]));
1243 }
1244
1245 #[test]
1246 fn build_payload_omits_images_when_none_present() {
1247 let provider = test_provider();
1248 let content = MessageContent::text("no images".to_string());
1249 let request = LLMRequest {
1250 model: "test-model".to_string(),
1251 messages: vec![Message::user(content.as_text().into_owned())].into(),
1252 ..Default::default()
1253 };
1254
1255 let payload = provider.build_payload(&request, false).unwrap();
1256 assert_eq!(payload.messages.len(), 1);
1257 let message = &payload.messages[0];
1258 assert_eq!(message.content.as_deref(), Some("no images"));
1259 assert!(message.images.is_none());
1260 }
1261
1262 #[test]
1263 fn build_payload_keeps_tools_for_tool_choice_none() {
1264 let provider = test_provider();
1265 let request = LLMRequest {
1266 model: "test-model".to_string(),
1267 messages: vec![Message::user("hello".to_string())].into(),
1268 tools: Some(std::sync::Arc::new(vec![ToolDefinition::function(
1269 "get_weather".to_string(),
1270 "Get the weather".to_string(),
1271 json!({"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}),
1272 )])),
1273 tool_choice: Some(ToolChoice::None),
1274 ..Default::default()
1275 };
1276
1277 let payload = provider.build_payload(&request, false).unwrap();
1278 assert!(
1279 payload.tools.as_ref().is_some_and(|tools| !tools.is_empty()),
1280 "tool definitions stay on the wire for prompt-cache stability"
1281 );
1282 }
1283
1284 #[test]
1285 fn build_payload_minimax_tool_followup_omits_tool_call_id() {
1286 let provider = test_provider();
1287 let tool_call_id = "direct_exec_command_1".to_string();
1288 let request = LLMRequest {
1289 model: models::ollama::MINIMAX_M3_CLOUD.to_string(),
1290 messages: vec![
1291 Message::assistant_with_tools(
1292 String::new(),
1293 vec![ToolCall::function(
1294 tool_call_id.clone(),
1295 "exec_command".to_string(),
1296 "{\"command\":\"cargo fmt\"}".to_string(),
1297 )],
1298 ),
1299 Message::tool_response(tool_call_id, "{\"output\":\"\",\"exit_code\":0}".to_string()),
1300 ]
1301 .into(),
1302 reasoning_effort: Some(ReasoningEffortLevel::Low),
1303 ..Default::default()
1304 };
1305
1306 let payload = provider.build_payload(&request, false).unwrap();
1307 assert_eq!(payload.messages.len(), 2);
1308 assert_eq!(payload.messages[1].role, "tool");
1309 assert_eq!(payload.messages[1].tool_name.as_deref(), Some("exec_command"));
1310 assert!(payload.messages[1].tool_call_id.is_none());
1311 assert!(payload.think.is_none());
1312 }
1313
1314 #[test]
1315 fn build_payload_non_minimax_tool_followup_keeps_tool_call_id() {
1316 let provider = test_provider();
1317 let tool_call_id = "direct_exec_command_1".to_string();
1318 let request = LLMRequest {
1319 model: models::ollama::GPT_OSS_20B_CLOUD.to_string(),
1320 messages: vec![
1321 Message::assistant_with_tools(
1322 String::new(),
1323 vec![ToolCall::function(
1324 tool_call_id.clone(),
1325 "exec_command".to_string(),
1326 "{\"command\":\"cargo fmt\"}".to_string(),
1327 )],
1328 ),
1329 Message::tool_response(tool_call_id.clone(), "{\"output\":\"\",\"exit_code\":0}".to_string()),
1330 ]
1331 .into(),
1332 reasoning_effort: Some(ReasoningEffortLevel::Low),
1333 ..Default::default()
1334 };
1335
1336 let payload = provider.build_payload(&request, false).unwrap();
1337 assert_eq!(payload.messages.len(), 2);
1338 assert_eq!(payload.messages[1].role, "tool");
1339 assert_eq!(payload.messages[1].tool_name.as_deref(), Some("exec_command"));
1340 assert_eq!(payload.messages[1].tool_call_id.as_deref(), Some(tool_call_id.as_str()));
1341 assert_eq!(payload.think, Some(Value::String("low".to_string())));
1342 }
1343
1344 #[test]
1345 fn build_payload_hoists_history_system_directives_into_system_prompt() {
1346 let provider = test_provider();
1347 let request = LLMRequest {
1348 model: models::ollama::MINIMAX_M3_CLOUD.to_string(),
1349 system_prompt: Some(std::sync::Arc::from(
1350 "stable system instructions",
1351 )),
1352 messages: vec![
1353 Message::user("explore architecture".to_string()),
1354 Message::system(
1355 "Previous turn already completed tool execution. Reuse the latest tool outputs in history instead of rerunning the same exploration.".to_string(),
1356 ),
1357 ].into(),
1358 ..Default::default()
1359 };
1360
1361 let payload = provider.build_payload(&request, false).unwrap();
1362 assert_eq!(payload.messages.len(), 2);
1363 assert_eq!(payload.messages[0].role, "system");
1364 assert!(
1365 payload.messages[0]
1366 .content
1367 .as_deref()
1368 .unwrap_or("")
1369 .contains("stable system instructions")
1370 );
1371 assert!(
1372 payload.messages[0]
1373 .content
1374 .as_deref()
1375 .unwrap_or("")
1376 .contains("[History Directives]")
1377 );
1378 assert!(
1379 payload.messages[0]
1380 .content
1381 .as_deref()
1382 .unwrap_or("")
1383 .contains("Previous turn already completed tool execution")
1384 );
1385 assert_eq!(payload.messages[1].role, "user");
1386 assert_eq!(payload.messages[1].content.as_deref(), Some("explore architecture"));
1387 }
1388
1389 #[test]
1390 fn build_payload_promotes_history_system_directive_without_base_system_prompt() {
1391 let provider = test_provider();
1392 let request = LLMRequest {
1393 model: models::ollama::MINIMAX_M3_CLOUD.to_string(),
1394 messages: vec![
1395 Message::system(
1396 "Repeated read-only exploration hit the per-turn family cap. Scheduling a final recovery pass without more tools.".to_string(),
1397 ),
1398 Message::user("summarize the architecture".to_string()),
1399 ].into(),
1400 ..Default::default()
1401 };
1402
1403 let payload = provider.build_payload(&request, false).unwrap();
1404 assert_eq!(payload.messages.len(), 2);
1405 assert_eq!(payload.messages[0].role, "system");
1406 assert!(
1407 payload.messages[0]
1408 .content
1409 .as_deref()
1410 .unwrap_or("")
1411 .contains("[History Directives]")
1412 );
1413 assert!(
1414 payload.messages[0]
1415 .content
1416 .as_deref()
1417 .unwrap_or("")
1418 .contains("Repeated read-only exploration hit the per-turn family cap")
1419 );
1420 assert_eq!(payload.messages[1].role, "user");
1421 }
1422
1423 #[test]
1424 fn build_payload_recovers_balanced_prefix_from_malformed_history_tool_arguments() {
1425 let provider = test_provider();
1426 let request = LLMRequest {
1427 model: "test-model".to_string(),
1428 messages: vec![Message::assistant_with_tools(
1429 String::new(),
1430 vec![ToolCall::function(
1431 "tool_call_0".to_string(),
1432 "apply_patch".to_string(),
1433 "{\"action\":\"read\",\"path\":\"docs/ARCHITECTURE.md\",\"offset\":1,\"limit\":100}{\"action\":\"read\",\"path\":\"README.md\"}"
1434 .to_string(),
1435 )],
1436 )].into(),
1437 ..Default::default()
1438 };
1439
1440 let payload = provider
1441 .build_payload(&request, false)
1442 .expect("payload should recover malformed history tool arguments");
1443
1444 let tool_calls = payload.messages[0].tool_calls.as_ref().expect("tool calls should be present");
1445 assert_eq!(tool_calls.len(), 1);
1446 assert_eq!(
1447 tool_calls[0].function.arguments,
1448 Some(json!({
1449 "action": "read",
1450 "path": "docs/ARCHITECTURE.md",
1451 "offset": 1,
1452 "limit": 100
1453 }))
1454 );
1455 }
1456
1457 #[test]
1458 fn build_payload_rehydrates_glm_interleaved_history_into_content() {
1459 let provider = test_provider();
1460 let request = LLMRequest {
1461 model: models::ollama::GLM_5_3_CLOUD.to_string(),
1462 messages: vec![Message::assistant("done".to_string()).with_reasoning(Some("trace".to_string()))].into(),
1463 ..Default::default()
1464 };
1465
1466 let payload = provider.build_payload(&request, false).unwrap();
1467
1468 assert_eq!(payload.messages[0].content.as_deref(), Some("<think>trace</think>done"));
1469 assert!(payload.messages[0].thinking.is_none());
1470 }
1471
1472 #[test]
1473 fn build_payload_replays_assistant_reasoning_as_ollama_thinking() {
1474 let provider = test_provider();
1475 let request = LLMRequest {
1476 model: models::ollama::GPT_OSS_20B.to_string(),
1477 messages: vec![
1478 Message::assistant("need a tool".to_string()).with_reasoning(Some("reasoning trace".to_string())),
1479 ]
1480 .into(),
1481 ..Default::default()
1482 };
1483
1484 let payload = provider.build_payload(&request, false).unwrap();
1485
1486 assert_eq!(payload.messages[0].content.as_deref(), Some("need a tool"));
1487 assert_eq!(payload.messages[0].thinking.as_deref(), Some("reasoning trace"));
1488 }
1489
1490 #[test]
1491 fn build_payload_includes_apply_patch_as_normal_tool() {
1492 let provider = test_provider();
1493 let request = LLMRequest {
1494 model: "test-model".to_string(),
1495 messages: vec![Message::user("patch this file".to_string())].into(),
1496 tools: Some(std::sync::Arc::new(vec![ToolDefinition::apply_patch("Apply VT Code patches".to_string())])),
1497 ..Default::default()
1498 };
1499
1500 let payload = provider.build_payload(&request, false).unwrap();
1501 let tools = payload.tools.expect("tools should be present");
1502 assert_eq!(tools.len(), 1);
1503 assert_eq!(tools[0].function_name(), "apply_patch");
1504 }
1505
1506 #[test]
1507 fn response_payload_preserves_reasoning_details() {
1508 let parsed = OllamaChatResponse {
1509 message: Some(OllamaResponseMessage {
1510 role: Some("assistant".to_string()),
1511 content: Some("answer".to_string()),
1512 thinking: None,
1513 reasoning_details: Some(vec![json!({
1514 "type": "reasoning.text",
1515 "text": "step one"
1516 })]),
1517 tool_calls: None,
1518 }),
1519 done: true,
1520 done_reason: Some("stop".to_string()),
1521 prompt_eval_count: Some(1),
1522 eval_count: Some(2),
1523 error: None,
1524 };
1525
1526 let response = OllamaProvider::response_from_chat_payload("test-model".to_string(), parsed)
1527 .expect("response should parse");
1528 assert_eq!(response.reasoning.as_deref(), Some("step one"));
1529 assert!(response.reasoning_details.is_some());
1530
1531 let first_detail = response
1532 .reasoning_details
1533 .as_ref()
1534 .and_then(|details| details.first())
1535 .expect("reasoning detail should exist");
1536 let parsed_detail: Value = serde_json::from_str(first_detail).expect("reasoning detail should be json");
1537 assert_eq!(parsed_detail["type"], "reasoning.text");
1538 }
1539
1540 #[test]
1541 fn tags_response_accepts_partial_model_summaries() {
1542 let parsed: OllamaTagsResponse = serde_json::from_value(json!({
1543 "models": [
1544 { "model": "qwen3:8b" }
1545 ]
1546 }))
1547 .expect("partial model summaries should parse");
1548
1549 let names: Vec<String> = parsed
1550 .models
1551 .into_iter()
1552 .filter_map(|model| model.name.or(model.model))
1553 .collect();
1554 assert_eq!(names, vec!["qwen3:8b".to_string()]);
1555 }
1556}