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
492 .tools
493 .as_ref()
494 .filter(|_| !matches!(request.tool_choice, Some(ToolChoice::None)))
495 .map(|tools| {
496 tools
497 .iter()
498 .filter_map(|tool| {
499 tool.function.as_ref().map(|func| {
500 ToolDefinition::function(
501 func.name.clone(),
502 func.description.clone(),
503 func.parameters.clone(),
504 )
505 })
506 })
507 .collect()
508 });
509
510 Ok(OllamaChatRequest {
511 model: request.model.clone(),
512 messages,
513 stream,
514 format: request.output_format.clone(),
515 options,
516 tools,
517 think: Self::think_value(request),
518 })
519 }
520
521 fn assistant_thinking_history_text(message: &Message) -> Option<String> {
522 if message.role != MessageRole::Assistant {
523 return None;
524 }
525
526 message
527 .reasoning
528 .as_deref()
529 .map(str::trim)
530 .filter(|value| !value.is_empty())
531 .map(str::to_owned)
532 .or_else(|| {
533 message
534 .reasoning_details
535 .as_deref()
536 .and_then(extract_reasoning_text_from_detail_values)
537 })
538 }
539
540 fn extract_content_and_images(content: &MessageContent) -> (String, Option<Vec<String>>) {
541 let mut images = Vec::new();
542 if let MessageContent::Parts(parts) = content {
543 for part in parts {
544 if let ContentPart::Image { data, .. } = part {
545 images.push(data.clone());
546 }
547 }
548 }
549
550 let text = content.as_text().into_owned();
551 let images = if images.is_empty() { None } else { Some(images) };
552 (text, images)
553 }
554
555 fn think_value(request: &LLMRequest) -> Option<Value> {
556 let model_id = request.model.as_str();
557 if Self::minimax_tool_followup_compat_mode(request) {
558 return None;
559 }
560 if !models::ollama::REASONING_MODELS.contains(&model_id) {
561 return None;
562 }
563
564 if models::ollama::REASONING_LEVEL_MODELS.contains(&model_id) {
565 request.reasoning_effort.map(|effort| Value::String(effort.to_string()))
566 } else {
567 Some(Value::Bool(true))
568 }
569 }
570
571 fn minimax_tool_followup_compat_mode(request: &LLMRequest) -> bool {
572 is_minimax_m2_model(&request.model)
573 && request
574 .messages
575 .iter()
576 .any(|message| message.role == MessageRole::Tool || message.has_tool_calls())
577 }
578
579 fn convert_tool_calls(tool_calls: Option<Vec<OllamaResponseToolCall>>) -> Result<Option<Vec<ToolCall>>, LLMError> {
580 let Some(tool_calls) = tool_calls else {
581 return Ok(None);
582 };
583
584 if tool_calls.is_empty() {
585 return Ok(None);
586 }
587
588 let mut converted = Vec::new();
589 for call in tool_calls.into_iter() {
590 let function = call.function.ok_or_else(|| LLMError::Provider {
591 message: "Ollama response missing function details for tool call".to_string(),
592 metadata: None,
593 })?;
594
595 let name = function.name.ok_or_else(|| LLMError::Provider {
596 message: "Ollama response missing tool function name".to_string(),
597 metadata: None,
598 })?;
599
600 let arguments_value = function.arguments.unwrap_or_else(|| Value::Object(Map::new()));
601 let arguments = match arguments_value {
602 Value::String(raw) => raw,
603 other => serde_json::to_string(&other).map_err(|err| LLMError::Provider {
604 message: format!("Failed to serialize Ollama tool arguments: {err}"),
605 metadata: None,
606 })?,
607 };
608
609 let id = crate::providers::shared::generate_tool_call_id();
612
613 converted.push(ToolCall::function(id, name, arguments));
614 }
615
616 Ok(Some(converted))
617 }
618
619 fn usage_from_counts(prompt_tokens: Option<u32>, completion_tokens: Option<u32>) -> Option<Usage> {
620 if prompt_tokens.is_none() && completion_tokens.is_none() {
621 return None;
622 }
623
624 let prompt = prompt_tokens.unwrap_or_default();
625 let completion = completion_tokens.unwrap_or_default();
626 Some(Usage {
627 prompt_tokens: prompt,
628 completion_tokens: completion,
629 total_tokens: prompt + completion,
630 cached_prompt_tokens: None,
631 cache_creation_tokens: None,
632 cache_read_tokens: None,
633 iterations: None,
634 })
635 }
636
637 fn finish_reason_from(reason: Option<&str>) -> FinishReason {
638 match reason {
639 Some("stop") | None => FinishReason::Stop,
640 Some("length") => FinishReason::Length,
641 Some("tool_calls") => FinishReason::ToolCalls,
642 Some(other) => FinishReason::Error(other.to_string()),
643 }
644 }
645
646 fn build_response(
647 content: Option<String>,
648 tool_calls: Option<Vec<ToolCall>>,
649 reasoning: Option<String>,
650 reasoning_details: Option<Vec<String>>,
651 model: String,
652 finish_reason: Option<&str>,
653 prompt_tokens: Option<u32>,
654 completion_tokens: Option<u32>,
655 ) -> LLMResponse {
656 let mut finish = Self::finish_reason_from(finish_reason);
657 if tool_calls.as_ref().is_some_and(|calls| !calls.is_empty()) {
658 finish = FinishReason::ToolCalls;
659 }
660
661 LLMResponse {
662 content,
663 tool_calls,
664 model,
665 usage: Self::usage_from_counts(prompt_tokens, completion_tokens),
666 finish_reason: finish,
667 reasoning,
668 reasoning_details,
669 tool_references: Vec::new(),
670 request_id: None,
671 organization_id: None,
672 compaction: None,
673 }
674 }
675
676 fn normalize_response_reasoning(response: &mut LLMResponse) {
679 let Some(content) = response.content.take() else {
680 return;
681 };
682 let (reasoning_parts, cleaned_content) = crate::utils::extract_reasoning_content(&content);
683 if reasoning_parts.is_empty() {
684 response.content = Some(content);
685 return;
686 }
687 super::common::preserve_interleaved_content_in_reasoning_details(&mut response.reasoning_details, &content);
688 if response.reasoning.is_none() {
689 response.reasoning = Some(reasoning_parts.join("\n\n"));
690 }
691 response.content = cleaned_content;
692 }
693
694 fn response_from_chat_payload(model: String, parsed: OllamaChatResponse) -> Result<LLMResponse, LLMError> {
695 if let Some(error) = parsed.error {
696 return Err(LLMError::Provider { message: error, metadata: None });
697 }
698
699 let (content, reasoning, tool_calls, native_reasoning_details) = if let Some(message) = parsed.message {
700 let content = message.content.and_then(|value| (!value.is_empty()).then_some(value));
701 let reasoning = message.thinking.and_then(|value| (!value.is_empty()).then_some(value));
702 let tool_calls = Self::convert_tool_calls(message.tool_calls)?;
703 let native_reasoning_details = message.reasoning_details.filter(|d| !d.is_empty());
704 (content, reasoning, tool_calls, native_reasoning_details)
705 } else {
706 (None, None, None, None)
707 };
708
709 let reasoning = reasoning.or_else(|| {
710 native_reasoning_details
711 .as_deref()
712 .and_then(extract_reasoning_text_from_detail_values)
713 });
714 let reasoning_details = native_reasoning_details.as_deref().and_then(serialize_reasoning_detail_values);
715 let mut response = Self::build_response(
716 content,
717 tool_calls,
718 reasoning,
719 reasoning_details,
720 model,
721 parsed.done_reason.as_deref(),
722 parsed.prompt_eval_count,
723 parsed.eval_count,
724 );
725 Self::normalize_response_reasoning(&mut response);
726 Ok(response)
727 }
728
729 fn authorized_post_with_key(http_client: &HttpClient, url: &str, api_key: Option<&str>) -> reqwest::RequestBuilder {
730 let builder = http_client.post(url.to_string());
731 if let Some(value) = api_key {
732 builder.bearer_auth(value)
733 } else {
734 builder
735 }
736 }
737
738 async fn request_non_stream_response(
739 http_client: &HttpClient,
740 url: &str,
741 api_key: Option<&str>,
742 payload: &OllamaChatRequest,
743 model: String,
744 ) -> Result<LLMResponse, LLMError> {
745 let response = Self::authorized_post_with_key(http_client, url, api_key)
746 .json(payload)
747 .send()
748 .await
749 .map_err(|e| format_network_error("Ollama", &e))?;
750
751 if !response.status().is_success() {
752 let status = response.status();
753 let body = crate::providers::common::read_provider_error_body(response).await;
754 let error_message =
755 Self::extract_error(&body).unwrap_or_else(|| format!("Ollama request failed ({status}): {body}"));
756 return Err(LLMError::Provider { message: error_message, metadata: None });
757 }
758
759 let parsed = response
760 .json::<OllamaChatResponse>()
761 .await
762 .map_err(|e| format_parse_error("Ollama", &e))?;
763 Self::response_from_chat_payload(model, parsed)
764 }
765
766 fn extract_error(body: &str) -> Option<String> {
767 serde_json::from_str::<OllamaErrorResponse>(body)
768 .ok()
769 .and_then(|resp| resp.error)
770 }
771}
772
773#[derive(Debug, Serialize)]
774struct OllamaChatRequest {
775 model: String,
776 messages: Vec<OllamaChatMessage>,
777 stream: bool,
778 #[serde(skip_serializing_if = "Option::is_none")]
779 format: Option<Value>,
780 #[serde(skip_serializing_if = "Option::is_none")]
781 options: Option<OllamaChatOptions>,
782 #[serde(skip_serializing_if = "Option::is_none")]
783 tools: Option<Vec<ToolDefinition>>,
784 #[serde(skip_serializing_if = "Option::is_none")]
785 think: Option<Value>,
786}
787
788#[derive(Debug, Serialize)]
789struct OllamaChatMessage {
790 role: String,
791 #[serde(skip_serializing_if = "Option::is_none")]
792 content: Option<String>,
793 #[serde(skip_serializing_if = "Option::is_none")]
794 thinking: Option<String>,
795 #[serde(skip_serializing_if = "Option::is_none")]
796 images: Option<Vec<String>>,
797 #[serde(skip_serializing_if = "Option::is_none")]
798 tool_calls: Option<Vec<OllamaToolCall>>,
799 #[serde(skip_serializing_if = "Option::is_none")]
800 tool_call_id: Option<String>,
801 #[serde(skip_serializing_if = "Option::is_none")]
802 tool_name: Option<String>,
803}
804
805#[derive(Debug, Serialize)]
806struct OllamaChatOptions {
807 #[serde(skip_serializing_if = "Option::is_none")]
808 temperature: Option<f32>,
809 #[serde(skip_serializing_if = "Option::is_none")]
810 num_predict: Option<u32>,
811}
812
813#[derive(Debug, Serialize)]
814struct OllamaToolCall {
815 #[serde(rename = "type")]
816 call_type: String,
817 function: OllamaToolFunctionCall,
818}
819
820#[derive(Debug, Serialize)]
821struct OllamaToolFunctionCall {
822 name: String,
823 #[serde(skip_serializing_if = "Option::is_none")]
824 arguments: Option<Value>,
825 #[serde(skip_serializing_if = "Option::is_none")]
826 index: Option<u32>,
827}
828
829#[derive(Debug, Deserialize)]
830struct OllamaChatResponse {
831 message: Option<OllamaResponseMessage>,
832 #[serde(default)]
833 done: bool,
834 #[serde(default)]
835 done_reason: Option<String>,
836 #[serde(default)]
837 prompt_eval_count: Option<u32>,
838 #[serde(default)]
839 eval_count: Option<u32>,
840 #[serde(default)]
841 error: Option<String>,
842}
843
844#[derive(Debug, Deserialize)]
845struct OllamaResponseMessage {
846 #[serde(default)]
847 #[expect(
848 dead_code,
849 reason = "Intentional compatibility, platform, test, or API-shape suppression."
850 )]
851 role: Option<String>,
852 #[serde(default)]
853 content: Option<String>,
854 #[serde(default)]
855 thinking: Option<String>,
856 #[serde(default)]
857 reasoning_details: Option<Vec<Value>>,
858 #[serde(default)]
859 tool_calls: Option<Vec<OllamaResponseToolCall>>,
860}
861
862#[derive(Debug, Deserialize, Serialize, Clone)]
863struct OllamaResponseToolCall {
864 #[serde(default)]
865 #[serde(rename = "type")]
866 call_type: Option<String>,
867 #[serde(default)]
868 function: Option<OllamaResponseFunctionCall>,
869}
870
871#[derive(Debug, Deserialize, Serialize, Clone)]
872struct OllamaResponseFunctionCall {
873 #[serde(default)]
874 name: Option<String>,
875 #[serde(default)]
876 arguments: Option<Value>,
877 #[serde(default)]
878 index: Option<u32>,
879}
880
881#[derive(Debug, Deserialize)]
882struct OllamaErrorResponse {
883 error: Option<String>,
884}
885
886fn parse_stream_chunk(line: &str) -> Result<OllamaChatResponse, LLMError> {
887 serde_json::from_str::<OllamaChatResponse>(line).map_err(|err| LLMError::Provider {
888 message: format!("Failed to parse Ollama stream chunk: {err}"),
889 metadata: None,
890 })
891}
892
893#[async_trait]
894impl LLMProvider for OllamaProvider {
895 fn name(&self) -> &str {
896 "ollama"
897 }
898
899 fn supports_streaming(&self) -> bool {
900 true
901 }
902
903 fn supports_non_streaming(&self, _model: &str) -> bool {
904 true
906 }
907
908 fn supports_tools(&self, _model: &str) -> bool {
909 true
910 }
911
912 fn supports_reasoning(&self, model: &str) -> bool {
913 models::ollama::REASONING_MODELS.contains(&model)
916 || self
917 .model_behavior
918 .as_ref()
919 .and_then(|b| b.model_supports_reasoning)
920 .unwrap_or(false)
921 }
922
923 fn supports_reasoning_effort(&self, model: &str) -> bool {
924 models::ollama::REASONING_LEVEL_MODELS.contains(&model)
926 || self
927 .model_behavior
928 .as_ref()
929 .and_then(|b| b.model_supports_reasoning_effort)
930 .unwrap_or(false)
931 }
932
933 async fn generate(&self, mut request: LLMRequest) -> Result<LLMResponse, LLMError> {
934 self.validate_request(&request)?;
935 if request.model.is_empty() {
936 request.model = self.model.clone();
937 }
938 let resolved = self.ensure_ready(&request.model).await?;
939 request.model = resolved;
940 let model = request.model.clone();
941 let payload = self.build_payload(&request, false)?;
942 let url = self.chat_url();
943 Self::request_non_stream_response(&self.http_client, &url, self.api_key.as_deref(), &payload, model).await
944 }
945
946 async fn stream(&self, mut request: LLMRequest) -> Result<LLMStream, LLMError> {
947 self.validate_request(&request)?;
948 if request.model.is_empty() {
949 request.model = self.model.clone();
950 }
951 let resolved = self.ensure_ready(&request.model).await?;
952 request.model = resolved;
953 let model = request.model.clone();
954 let payload = self.build_payload(&request, true)?;
955 let fallback_payload = self.build_payload(&request, false)?;
956 let allow_transport_fallback = !matches!(request.tool_choice, Some(ToolChoice::None));
959 let url = self.chat_url();
960
961 let response = self
962 .authorized_post(url.clone())
963 .header(reqwest::header::ACCEPT_ENCODING, "identity")
964 .json(&payload)
965 .send()
966 .await
967 .map_err(|e| format_network_error("Ollama", &e))?;
968
969 if !response.status().is_success() {
970 let status = response.status();
971 let body = crate::providers::common::read_provider_error_body(response).await;
972 let error_message = Self::extract_error(&body)
973 .unwrap_or_else(|| format!("Ollama streaming request failed ({status}): {body}"));
974 return Err(LLMError::Provider { message: error_message, metadata: None });
975 }
976
977 let byte_stream = response.bytes_stream();
978 let mut buffer: Vec<u8> = Vec::new();
979 let mut aggregator = crate::providers::shared::StreamAggregator::new(model.clone());
980 let fallback_http_client = self.http_client.clone();
981 let fallback_api_key = self.api_key.clone();
982 let fallback_model = model.clone();
983 let fallback_url = url.clone();
984 let any_interleaved = request
985 .messages
986 .iter()
987 .any(|msg| assistant_interleaved_history_text(msg, &request.model).is_some());
988 let stream = try_stream! {
989 let mut prompt_tokens: Option<u32> = None;
990 let mut completion_tokens: Option<u32> = None;
991 let mut finish_reason: Option<String> = None;
992 let mut completed = false;
993 let mut saw_stream_chunk = false;
994
995 futures::pin_mut!(byte_stream);
996 while let Some(chunk_result) = byte_stream.next().await {
997 let chunk = match chunk_result {
998 Ok(chunk) => {
999 saw_stream_chunk = true;
1000 chunk
1001 }
1002 Err(err) if !saw_stream_chunk && allow_transport_fallback => {
1003 tracing::warn!(
1004 model = %fallback_model,
1005 url = %fallback_url,
1006 error = %err,
1007 "Ollama stream failed before first chunk; retrying once as non-stream response"
1008 );
1009 let fallback_response = Self::request_non_stream_response(
1010 &fallback_http_client,
1011 &fallback_url,
1012 fallback_api_key.as_deref(),
1013 &fallback_payload,
1014 fallback_model.clone(),
1015 ).await?;
1016 yield LLMStreamEvent::Completed { response: Box::new(fallback_response) };
1017 return;
1018 }
1019 Err(err) => Err(format_network_error("Ollama", &err))?,
1020 };
1021 buffer.extend_from_slice(&chunk);
1022
1023 while let Some(pos) = buffer.iter().position(|b| *b == b'\n') {
1024 let line = std::str::from_utf8(&buffer[..pos])
1029 .map_err(|err| LLMError::Provider {
1030 message: format!("Invalid UTF-8 in Ollama stream: {err}"),
1031 metadata: None,
1032 })?;
1033 let line = line.trim();
1034
1035 if line.is_empty() {
1036 buffer.drain(..=pos);
1037 continue;
1038 }
1039
1040 let parsed = parse_stream_chunk(line)?;
1041 buffer.drain(..=pos);
1043
1044 if let Some(error) = parsed.error {
1045 Err(LLMError::Provider {
1046 message: error,
1047 metadata: None,
1048 })?;
1049 }
1050
1051 if let Some(message) = parsed.message {
1052 if let Some(reasoning_details) = message.reasoning_details.as_deref() {
1053 aggregator.set_reasoning_details(reasoning_details);
1054 }
1055
1056 let has_explicit_thinking = message
1057 .thinking
1058 .as_ref()
1059 .map(|v| !v.is_empty())
1060 .unwrap_or(false);
1061
1062 if let Some(thinking) = message.thinking
1063 && let Some(delta) = aggregator.handle_reasoning(&thinking) {
1064 yield LLMStreamEvent::Reasoning { delta };
1065 }
1066
1067 if let Some(content) = message.content {
1068 for event in aggregator.handle_content(&content) {
1069 match &event {
1070 LLMStreamEvent::Reasoning { .. }
1071 if has_explicit_thinking || any_interleaved =>
1072 {
1073 }
1074 _ => yield event,
1075 }
1076 }
1077 }
1078
1079 if let Some(tool_calls) = message.tool_calls {
1080 let tool_calls_json: Vec<Value> = tool_calls
1081 .into_iter()
1082 .map(|tc| serde_json::to_value(tc).unwrap_or(Value::Null))
1083 .filter(|v| !v.is_null())
1084 .collect();
1085 aggregator.handle_tool_calls(&tool_calls_json);
1086 }
1087 }
1088
1089 if parsed.done {
1090 prompt_tokens = parsed.prompt_eval_count;
1091 completion_tokens = parsed.eval_count;
1092 finish_reason = parsed.done_reason;
1093 completed = true;
1094 }
1095 }
1096
1097 if completed {
1098 break;
1099 }
1100 }
1101
1102 if !completed {
1103 Err(LLMError::Provider {
1104 message: "Ollama stream ended without completion signal".to_string(),
1105 metadata: None,
1106 })?;
1107 }
1108
1109 let mut response = aggregator.finalize();
1110 if let Some(pt) = prompt_tokens {
1111 let mut usage = response.usage.unwrap_or_default();
1112 usage.prompt_tokens = pt;
1113 if let Some(ct) = completion_tokens {
1114 usage.completion_tokens = ct;
1115 usage.total_tokens = pt + ct;
1116 }
1117 response.usage = Some(usage);
1118 }
1119 if let Some(fr) = finish_reason {
1120 response.finish_reason = crate::providers::common::map_finish_reason_common(&fr);
1121 }
1122 if response.reasoning.is_none()
1123 && let Some(details) = response.reasoning_details.as_ref()
1124 {
1125 response.reasoning = extract_reasoning_text_from_serialized_details(details);
1126 }
1127
1128 Self::normalize_response_reasoning(&mut response);
1129 yield LLMStreamEvent::Completed { response: Box::new(response) };
1130 };
1131
1132 Ok(Box::pin(stream))
1133 }
1134
1135 fn supported_models(&self) -> Vec<String> {
1136 models::ollama::SUPPORTED_MODELS.iter().map(|model| model.to_string()).collect()
1137 }
1138
1139 fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
1140 if let Some(tool_choice) = &request.tool_choice {
1141 match tool_choice {
1142 ToolChoice::Auto | ToolChoice::None | ToolChoice::AllowedTools(_) => {}
1143 _ => {
1144 return Err(LLMError::InvalidRequest {
1145 message: "Ollama does not support explicit tool_choice overrides".to_string(),
1146 metadata: None,
1147 });
1148 }
1149 }
1150 }
1151
1152 if request.parallel_tool_calls.is_some() || request.parallel_tool_config.is_some() {
1153 return Err(LLMError::InvalidRequest {
1154 message: "Ollama does not support parallel tool configuration".to_string(),
1155 metadata: None,
1156 });
1157 }
1158
1159 for message in request.messages.iter() {
1160 if matches!(message.role, MessageRole::Tool) && message.tool_call_id.is_none() {
1161 return Err(LLMError::InvalidRequest {
1162 message: "Ollama tool responses must include tool_call_id".to_string(),
1163 metadata: None,
1164 });
1165 }
1166 }
1167
1168 Ok(())
1169 }
1170}
1171
1172#[async_trait]
1173impl LLMClient for OllamaProvider {
1174 async fn generate(&mut self, prompt: &str) -> Result<LLMResponse, LLMError> {
1175 let mut request = self.parse_client_prompt(prompt);
1176 if request.model.is_empty() {
1177 request.model = self.model.clone();
1178 }
1179 Ok(LLMProvider::generate(self, request).await?)
1180 }
1181
1182 fn model_id(&self) -> &str {
1183 &self.model
1184 }
1185}
1186
1187#[cfg(test)]
1188mod tests {
1189 use super::*;
1190 use crate::provider::{ContentPart, Message, MessageContent};
1191 use serde_json::json;
1192 use vtcode_config::types::ReasoningEffortLevel;
1193
1194 fn test_provider() -> OllamaProvider {
1195 OllamaProvider::from_config(
1196 None,
1197 Some("test-model".to_string()),
1198 Some("http://localhost".to_string()),
1199 None,
1200 None,
1201 None,
1202 None,
1203 )
1204 }
1205
1206 #[test]
1207 fn convert_tool_calls_fabricates_unique_ids_when_missing() {
1208 let make_call = |name: &str| OllamaResponseToolCall {
1209 call_type: Some("function".to_string()),
1210 function: Some(OllamaResponseFunctionCall {
1211 name: Some(name.to_string()),
1212 arguments: Some(json!({})),
1213 index: None,
1214 }),
1215 };
1216
1217 let first = OllamaProvider::convert_tool_calls(Some(vec![make_call("foo"), make_call("bar")]))
1218 .expect("conversion should succeed")
1219 .expect("calls expected");
1220 let second = OllamaProvider::convert_tool_calls(Some(vec![make_call("foo")]))
1221 .expect("conversion should succeed")
1222 .expect("calls expected");
1223
1224 let ids: Vec<&str> = first.iter().chain(second.iter()).map(|call| call.id.as_str()).collect();
1225 let unique: std::collections::HashSet<&str> = ids.iter().copied().collect();
1226 assert_eq!(unique.len(), ids.len(), "fabricated ids must be unique within and across responses");
1227 for id in ids {
1228 assert!(id.starts_with("call_"));
1229 }
1230 }
1231
1232 #[test]
1233 fn build_payload_includes_images() {
1234 let provider = test_provider();
1235 let parts = vec![
1236 ContentPart::text("see ".to_string()),
1237 ContentPart::image("BASE64DATA".to_string(), "image/png".to_string()),
1238 ];
1239 let request = LLMRequest {
1240 model: "test-model".to_string(),
1241 messages: vec![Message::user_with_parts(parts)].into(),
1242 ..Default::default()
1243 };
1244
1245 let payload = provider.build_payload(&request, false).unwrap();
1246 assert_eq!(payload.messages.len(), 1);
1247 let message = &payload.messages[0];
1248 assert_eq!(message.content.as_deref(), Some("see "));
1249 assert_eq!(message.images.as_ref(), Some(&vec!["BASE64DATA".to_string()]));
1250 }
1251
1252 #[test]
1253 fn build_payload_omits_images_when_none_present() {
1254 let provider = test_provider();
1255 let content = MessageContent::text("no images".to_string());
1256 let request = LLMRequest {
1257 model: "test-model".to_string(),
1258 messages: vec![Message::user(content.as_text().into_owned())].into(),
1259 ..Default::default()
1260 };
1261
1262 let payload = provider.build_payload(&request, false).unwrap();
1263 assert_eq!(payload.messages.len(), 1);
1264 let message = &payload.messages[0];
1265 assert_eq!(message.content.as_deref(), Some("no images"));
1266 assert!(message.images.is_none());
1267 }
1268
1269 #[test]
1270 fn build_payload_omits_callable_tools_for_tool_choice_none() {
1271 let provider = test_provider();
1272 let request = LLMRequest {
1273 model: "test-model".to_string(),
1274 messages: vec![
1275 Message::assistant_with_tools(
1276 String::new(),
1277 vec![ToolCall::function(
1278 "call_history".to_owned(),
1279 "get_weather".to_owned(),
1280 "{}".to_owned(),
1281 )],
1282 ),
1283 Message::tool_response("call_history".to_owned(), "sunny".to_owned()),
1284 Message::user("Synthesize the result".to_owned()),
1285 ]
1286 .into(),
1287 tools: Some(std::sync::Arc::new(vec![ToolDefinition::function(
1288 "get_weather".to_string(),
1289 "Get the weather".to_string(),
1290 json!({"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}),
1291 )])),
1292 tool_choice: Some(ToolChoice::None),
1293 ..Default::default()
1294 };
1295
1296 for stream in [false, true] {
1297 let payload = serde_json::to_value(provider.build_payload(&request, stream).unwrap()).unwrap();
1298 assert!(payload.get("tools").is_none(), "tool-free synthesis must not advertise callable tools");
1299 assert_eq!(payload["stream"], stream);
1300 assert_eq!(payload["messages"][0]["tool_calls"][0]["function"]["name"], "get_weather");
1301 assert_eq!(payload["messages"][1]["tool_call_id"], "call_history");
1302 assert_eq!(payload["messages"][1]["content"], "sunny");
1303 let mut enabled = request.clone();
1304 enabled.tool_choice = Some(ToolChoice::Auto);
1305 let payload = serde_json::to_value(provider.build_payload(&enabled, stream).unwrap()).unwrap();
1306 assert_eq!(payload["tools"][0]["function"]["name"], "get_weather");
1307 }
1308 }
1309
1310 #[test]
1311 fn build_payload_minimax_tool_followup_omits_tool_call_id() {
1312 let provider = test_provider();
1313 let tool_call_id = "direct_exec_command_1".to_string();
1314 let request = LLMRequest {
1315 model: models::ollama::MINIMAX_M3_CLOUD.to_string(),
1316 messages: vec![
1317 Message::assistant_with_tools(
1318 String::new(),
1319 vec![ToolCall::function(
1320 tool_call_id.clone(),
1321 "exec_command".to_string(),
1322 "{\"command\":\"cargo fmt\"}".to_string(),
1323 )],
1324 ),
1325 Message::tool_response(tool_call_id, "{\"output\":\"\",\"exit_code\":0}".to_string()),
1326 ]
1327 .into(),
1328 reasoning_effort: Some(ReasoningEffortLevel::Low),
1329 ..Default::default()
1330 };
1331
1332 let payload = provider.build_payload(&request, false).unwrap();
1333 assert_eq!(payload.messages.len(), 2);
1334 assert_eq!(payload.messages[1].role, "tool");
1335 assert_eq!(payload.messages[1].tool_name.as_deref(), Some("exec_command"));
1336 assert!(payload.messages[1].tool_call_id.is_none());
1337 assert!(payload.think.is_none());
1338 }
1339
1340 #[test]
1341 fn build_payload_non_minimax_tool_followup_keeps_tool_call_id() {
1342 let provider = test_provider();
1343 let tool_call_id = "direct_exec_command_1".to_string();
1344 let request = LLMRequest {
1345 model: models::ollama::GPT_OSS_20B_CLOUD.to_string(),
1346 messages: vec![
1347 Message::assistant_with_tools(
1348 String::new(),
1349 vec![ToolCall::function(
1350 tool_call_id.clone(),
1351 "exec_command".to_string(),
1352 "{\"command\":\"cargo fmt\"}".to_string(),
1353 )],
1354 ),
1355 Message::tool_response(tool_call_id.clone(), "{\"output\":\"\",\"exit_code\":0}".to_string()),
1356 ]
1357 .into(),
1358 reasoning_effort: Some(ReasoningEffortLevel::Low),
1359 ..Default::default()
1360 };
1361
1362 let payload = provider.build_payload(&request, false).unwrap();
1363 assert_eq!(payload.messages.len(), 2);
1364 assert_eq!(payload.messages[1].role, "tool");
1365 assert_eq!(payload.messages[1].tool_name.as_deref(), Some("exec_command"));
1366 assert_eq!(payload.messages[1].tool_call_id.as_deref(), Some(tool_call_id.as_str()));
1367 assert_eq!(payload.think, Some(Value::String("low".to_string())));
1368 }
1369
1370 #[test]
1371 fn build_payload_hoists_history_system_directives_into_system_prompt() {
1372 let provider = test_provider();
1373 let request = LLMRequest {
1374 model: models::ollama::MINIMAX_M3_CLOUD.to_string(),
1375 system_prompt: Some(std::sync::Arc::from(
1376 "stable system instructions",
1377 )),
1378 messages: vec![
1379 Message::user("explore architecture".to_string()),
1380 Message::system(
1381 "Previous turn already completed tool execution. Reuse the latest tool outputs in history instead of rerunning the same exploration.".to_string(),
1382 ),
1383 ].into(),
1384 ..Default::default()
1385 };
1386
1387 let payload = provider.build_payload(&request, false).unwrap();
1388 assert_eq!(payload.messages.len(), 2);
1389 assert_eq!(payload.messages[0].role, "system");
1390 assert!(
1391 payload.messages[0]
1392 .content
1393 .as_deref()
1394 .unwrap_or("")
1395 .contains("stable system instructions")
1396 );
1397 assert!(
1398 payload.messages[0]
1399 .content
1400 .as_deref()
1401 .unwrap_or("")
1402 .contains("[History Directives]")
1403 );
1404 assert!(
1405 payload.messages[0]
1406 .content
1407 .as_deref()
1408 .unwrap_or("")
1409 .contains("Previous turn already completed tool execution")
1410 );
1411 assert_eq!(payload.messages[1].role, "user");
1412 assert_eq!(payload.messages[1].content.as_deref(), Some("explore architecture"));
1413 }
1414
1415 #[test]
1416 fn build_payload_promotes_history_system_directive_without_base_system_prompt() {
1417 let provider = test_provider();
1418 let request = LLMRequest {
1419 model: models::ollama::MINIMAX_M3_CLOUD.to_string(),
1420 messages: vec![
1421 Message::system(
1422 "Repeated read-only exploration hit the per-turn family cap. Scheduling a final recovery pass without more tools.".to_string(),
1423 ),
1424 Message::user("summarize the architecture".to_string()),
1425 ].into(),
1426 ..Default::default()
1427 };
1428
1429 let payload = provider.build_payload(&request, false).unwrap();
1430 assert_eq!(payload.messages.len(), 2);
1431 assert_eq!(payload.messages[0].role, "system");
1432 assert!(
1433 payload.messages[0]
1434 .content
1435 .as_deref()
1436 .unwrap_or("")
1437 .contains("[History Directives]")
1438 );
1439 assert!(
1440 payload.messages[0]
1441 .content
1442 .as_deref()
1443 .unwrap_or("")
1444 .contains("Repeated read-only exploration hit the per-turn family cap")
1445 );
1446 assert_eq!(payload.messages[1].role, "user");
1447 }
1448
1449 #[test]
1450 fn build_payload_recovers_balanced_prefix_from_malformed_history_tool_arguments() {
1451 let provider = test_provider();
1452 let request = LLMRequest {
1453 model: "test-model".to_string(),
1454 messages: vec![Message::assistant_with_tools(
1455 String::new(),
1456 vec![ToolCall::function(
1457 "tool_call_0".to_string(),
1458 "apply_patch".to_string(),
1459 "{\"action\":\"read\",\"path\":\"docs/ARCHITECTURE.md\",\"offset\":1,\"limit\":100}{\"action\":\"read\",\"path\":\"README.md\"}"
1460 .to_string(),
1461 )],
1462 )].into(),
1463 ..Default::default()
1464 };
1465
1466 let payload = provider
1467 .build_payload(&request, false)
1468 .expect("payload should recover malformed history tool arguments");
1469
1470 let tool_calls = payload.messages[0].tool_calls.as_ref().expect("tool calls should be present");
1471 assert_eq!(tool_calls.len(), 1);
1472 assert_eq!(
1473 tool_calls[0].function.arguments,
1474 Some(json!({
1475 "action": "read",
1476 "path": "docs/ARCHITECTURE.md",
1477 "offset": 1,
1478 "limit": 100
1479 }))
1480 );
1481 }
1482
1483 #[test]
1484 fn build_payload_rehydrates_glm_interleaved_history_into_content() {
1485 let provider = test_provider();
1486 let request = LLMRequest {
1487 model: models::ollama::GLM_5_3_CLOUD.to_string(),
1488 messages: vec![Message::assistant("done".to_string()).with_reasoning(Some("trace".to_string()))].into(),
1489 ..Default::default()
1490 };
1491
1492 let payload = provider.build_payload(&request, false).unwrap();
1493
1494 assert_eq!(payload.messages[0].content.as_deref(), Some("<think>trace</think>done"));
1495 assert!(payload.messages[0].thinking.is_none());
1496 }
1497
1498 #[test]
1499 fn build_payload_replays_assistant_reasoning_as_ollama_thinking() {
1500 let provider = test_provider();
1501 let request = LLMRequest {
1502 model: models::ollama::GPT_OSS_20B.to_string(),
1503 messages: vec![
1504 Message::assistant("need a tool".to_string()).with_reasoning(Some("reasoning trace".to_string())),
1505 ]
1506 .into(),
1507 ..Default::default()
1508 };
1509
1510 let payload = provider.build_payload(&request, false).unwrap();
1511
1512 assert_eq!(payload.messages[0].content.as_deref(), Some("need a tool"));
1513 assert_eq!(payload.messages[0].thinking.as_deref(), Some("reasoning trace"));
1514 }
1515
1516 #[test]
1517 fn build_payload_includes_apply_patch_as_normal_tool() {
1518 let provider = test_provider();
1519 let request = LLMRequest {
1520 model: "test-model".to_string(),
1521 messages: vec![Message::user("patch this file".to_string())].into(),
1522 tools: Some(std::sync::Arc::new(vec![ToolDefinition::apply_patch("Apply VT Code patches".to_string())])),
1523 ..Default::default()
1524 };
1525
1526 let payload = provider.build_payload(&request, false).unwrap();
1527 let tools = payload.tools.expect("tools should be present");
1528 assert_eq!(tools.len(), 1);
1529 assert_eq!(tools[0].function_name(), "apply_patch");
1530 }
1531
1532 #[test]
1533 fn reasoning_only_responses_never_promote_private_content_in_either_wire_mode() {
1534 for (content, native_thinking, expected_public, expected_reasoning) in [
1535 (None, Some("native private reasoning"), None, "native private reasoning"),
1536 (Some("<think>private reasoning</think>"), None, None, "private reasoning"),
1537 (
1538 Some("<think>private reasoning</think>Public final answer"),
1539 None,
1540 Some("Public final answer"),
1541 "private reasoning",
1542 ),
1543 (
1544 Some("<think>tagged private reasoning</think>"),
1545 Some("native private reasoning"),
1546 None,
1547 "native private reasoning",
1548 ),
1549 ] {
1550 let parsed: OllamaChatResponse = serde_json::from_value(json!({
1551 "message": {"role": "assistant", "content": content, "thinking": native_thinking},
1552 "done": true, "done_reason": "stop",
1553 }))
1554 .expect("Ollama response fixture");
1555 let response =
1556 OllamaProvider::response_from_chat_payload("test-model".into(), parsed).expect("nonstream response");
1557 assert_eq!(response.content.as_deref(), expected_public);
1558 assert_eq!(response.reasoning.as_deref(), Some(expected_reasoning));
1559
1560 let mut aggregator = crate::providers::shared::StreamAggregator::new("test-model".into());
1561 if let Some(thinking) = native_thinking {
1562 aggregator.handle_reasoning(thinking);
1563 }
1564 let mut rendered_public = String::new();
1565 for chunk in content.unwrap_or_default().as_bytes().chunks(7) {
1566 for event in aggregator.handle_content(std::str::from_utf8(chunk).expect("ASCII fixture")) {
1567 if let LLMStreamEvent::Token { delta } = event {
1568 rendered_public.push_str(&delta);
1569 }
1570 }
1571 }
1572 let mut streamed = aggregator.finalize();
1573 OllamaProvider::normalize_response_reasoning(&mut streamed);
1574 assert_eq!(streamed.content.as_deref(), expected_public);
1575 assert_eq!(streamed.reasoning.as_deref(), Some(expected_reasoning));
1576 assert_eq!(rendered_public.trim(), expected_public.unwrap_or_default());
1577 assert!(!rendered_public.contains("private reasoning"));
1578 }
1579 }
1580
1581 #[tokio::test]
1582 async fn streaming_completion_keeps_tagged_reasoning_out_of_public_content() {
1583 use wiremock::matchers::{method, path};
1584 use wiremock::{Mock, ResponseTemplate};
1585 let Some(server) = crate::providers::test_support::start_mock_server_or_skip().await else {
1586 return;
1587 };
1588 let provider = OllamaProvider::from_config(
1589 None,
1590 Some("test-model:cloud".into()),
1591 Some(server.uri()),
1592 None,
1593 None,
1594 None,
1595 None,
1596 );
1597 for public_tail in ["", "Public final answer"] {
1598 let frames = [
1599 json!({"message": {"role": "assistant", "content": "<thi"}, "done": false}),
1600 json!({"message": {"role": "assistant", "content": "nk>private reasoning</think>"}, "done": false}),
1601 json!({"message": {"role": "assistant", "content": public_tail}, "done": true, "done_reason": "stop"}),
1602 ];
1603 let body = frames.iter().map(ToString::to_string).collect::<Vec<_>>().join("\n") + "\n";
1604 Mock::given(method("POST"))
1605 .and(path("/api/chat"))
1606 .respond_with(ResponseTemplate::new(200).set_body_raw(body, "application/x-ndjson"))
1607 .up_to_n_times(1)
1608 .expect(1)
1609 .mount(&server)
1610 .await;
1611 let mut stream = LLMProvider::stream(
1612 &provider,
1613 LLMRequest {
1614 model: "test-model:cloud".into(),
1615 messages: vec![Message::user("Synthesize saved evidence".into())].into(),
1616 tool_choice: Some(ToolChoice::None),
1617 ..Default::default()
1618 },
1619 )
1620 .await
1621 .expect("mock Ollama stream");
1622 let mut public_tokens = String::new();
1623 let mut completed = None;
1624 while let Some(event) = stream.next().await {
1625 match event.expect("Ollama streaming event") {
1626 LLMStreamEvent::Token { delta } => public_tokens.push_str(&delta),
1627 LLMStreamEvent::Completed { response } => completed = Some(response),
1628 _ => {}
1629 }
1630 }
1631 assert_eq!(public_tokens, public_tail);
1632 let response = completed.expect("terminal completed response");
1633 assert_eq!(response.content.as_deref(), (!public_tail.is_empty()).then_some(public_tail));
1634 assert_eq!(response.reasoning.as_deref(), Some("private reasoning"));
1635 }
1636 }
1637
1638 #[tokio::test]
1639 async fn tool_free_stream_failure_does_not_dispatch_an_uncharged_nonstream_fallback() {
1640 use std::sync::atomic::{AtomicUsize, Ordering};
1641 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1642
1643 async fn drain_request(socket: &mut tokio::net::TcpStream) {
1644 let mut request = Vec::new();
1645 let mut chunk = [0_u8; 1024];
1646 loop {
1647 let read = socket.read(&mut chunk).await.expect("read mock HTTP request");
1648 assert!(read > 0, "request must contain complete headers and body");
1649 request.extend_from_slice(&chunk[..read]);
1650 if let Some(end) = request.windows(4).position(|window| window == b"\r\n\r\n") {
1651 let headers = std::str::from_utf8(&request[..end]).expect("HTTP headers");
1652 let content_length = headers
1653 .lines()
1654 .find_map(|line| {
1655 let (name, value) = line.split_once(':')?;
1656 name.eq_ignore_ascii_case("content-length")
1657 .then(|| value.trim().parse::<usize>().expect("content length"))
1658 })
1659 .unwrap_or(0);
1660 if request.len() >= end + 4 + content_length {
1661 return;
1662 }
1663 }
1664 }
1665 }
1666
1667 for choice in [ToolChoice::None, ToolChoice::Auto] {
1668 let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0))
1669 .await
1670 .expect("mock transport listener");
1671 let address = listener.local_addr().expect("listener address");
1672 let requests = std::sync::Arc::new(AtomicUsize::new(0));
1673 let captured = requests.clone();
1674 let server = tokio::spawn(async move {
1675 for index in 0..2 {
1676 let (mut socket, _) = listener.accept().await.expect("accept mock request");
1677 drain_request(&mut socket).await;
1678 captured.fetch_add(1, Ordering::SeqCst);
1679 if index == 0 {
1680 socket.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 64\r\nContent-Type: application/x-ndjson\r\nConnection: close\r\n\r\n").await.expect("write truncated response");
1683 } else {
1684 let body = r#"{"message":{"role":"assistant","content":"Fallback final"},"done":true,"done_reason":"stop"}"#;
1685 let header = format!(
1686 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n",
1687 body.len()
1688 );
1689 socket.write_all(header.as_bytes()).await.expect("write fallback headers");
1690 socket.write_all(body.as_bytes()).await.expect("write fallback body");
1691 }
1692 socket.shutdown().await.expect("finish mock response");
1693 }
1694 });
1695 let provider = OllamaProvider::from_config(
1696 None,
1697 Some("test-model:cloud".into()),
1698 Some(format!("http://{address}")),
1699 None,
1700 None,
1701 None,
1702 None,
1703 );
1704 let mut stream = LLMProvider::stream(
1705 &provider,
1706 LLMRequest {
1707 model: "test-model:cloud".into(),
1708 messages: vec![Message::user("Synthesize saved evidence".into())].into(),
1709 tool_choice: Some(choice.clone()),
1710 ..Default::default()
1711 },
1712 )
1713 .await
1714 .expect("successful mock response headers");
1715 let mut failed = false;
1716 let mut completed = None;
1717 while let Some(event) = stream.next().await {
1718 match event {
1719 Err(_) => failed = true,
1720 Ok(LLMStreamEvent::Completed { response }) => completed = Some(response),
1721 _ => {}
1722 }
1723 }
1724 server.abort();
1725 let _ = server.await;
1726 if matches!(choice, ToolChoice::None) {
1727 assert!(failed && completed.is_none());
1728 assert_eq!(
1729 requests.load(Ordering::SeqCst),
1730 1,
1731 "recovery must return failure to charged runloop admission"
1732 );
1733 } else {
1734 assert!(!failed);
1735 assert_eq!(completed.expect("enabled fallback response").content.as_deref(), Some("Fallback final"));
1736 assert_eq!(
1737 requests.load(Ordering::SeqCst),
1738 2,
1739 "ordinary requests retain the existing transport fallback"
1740 );
1741 }
1742 }
1743 }
1744
1745 #[test]
1746 fn response_payload_preserves_reasoning_details() {
1747 let parsed = OllamaChatResponse {
1748 message: Some(OllamaResponseMessage {
1749 role: Some("assistant".to_string()),
1750 content: Some("answer".to_string()),
1751 thinking: None,
1752 reasoning_details: Some(vec![json!({
1753 "type": "reasoning.text",
1754 "text": "step one"
1755 })]),
1756 tool_calls: None,
1757 }),
1758 done: true,
1759 done_reason: Some("stop".to_string()),
1760 prompt_eval_count: Some(1),
1761 eval_count: Some(2),
1762 error: None,
1763 };
1764
1765 let response = OllamaProvider::response_from_chat_payload("test-model".to_string(), parsed)
1766 .expect("response should parse");
1767 assert_eq!(response.reasoning.as_deref(), Some("step one"));
1768 assert!(response.reasoning_details.is_some());
1769
1770 let first_detail = response
1771 .reasoning_details
1772 .as_ref()
1773 .and_then(|details| details.first())
1774 .expect("reasoning detail should exist");
1775 let parsed_detail: Value = serde_json::from_str(first_detail).expect("reasoning detail should be json");
1776 assert_eq!(parsed_detail["type"], "reasoning.text");
1777 }
1778
1779 #[test]
1780 fn tags_response_accepts_partial_model_summaries() {
1781 let parsed: OllamaTagsResponse = serde_json::from_value(json!({
1782 "models": [
1783 { "model": "qwen3:8b" }
1784 ]
1785 }))
1786 .expect("partial model summaries should parse");
1787
1788 let names: Vec<String> = parsed
1789 .models
1790 .into_iter()
1791 .filter_map(|model| model.name.or(model.model))
1792 .collect();
1793 assert_eq!(names, vec!["qwen3:8b".to_string()]);
1794 }
1795}