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