Skip to main content

vtcode_llm/providers/
ollama.rs

1//! Ollama provider implementation
2//!
3//! Adapted from [openai/codex] `codex-ollama` (Apache-2.0).
4//! Copyright 2025 OpenAI. See the repository `THIRD-PARTY-NOTICES` file for
5//! full attribution.
6//!
7//! [openai/codex]: https://github.com/openai/codex
8
9use 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
45/// Prepare the local OSS environment when using Ollama.
46///
47/// - Ensures a local Ollama server is reachable.
48/// - Checks if the model exists locally and pulls it if missing.
49///
50/// Adapted from OpenAI Codex's codex-ollama/src/lib.rs
51async 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    // Verify local Ollama is reachable
57    let ollama_client = OllamaClient::try_from_base_url(&resolved_base_url).await?;
58
59    // If the model is not present locally, pull it
60    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            // Continue anyway; model might exist but listing failed
71        }
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
108/// Fetches available local Ollama models from the Ollama API endpoint
109pub 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    // Construct the tags endpoint URL
115    let tags_url = format!("{resolved_base_url}/api/tags");
116
117    // Create HTTP client with connection timeout
118    let client = vtcode_commons::http::create_client_with_timeout(std::time::Duration::from_secs(5));
119
120    // Make GET request to fetch models
121    let response = client
122        .get(&tags_url)
123        .header("Content-Type", "application/json")
124        .send()
125        .await
126        .map_err(|e| {
127            // Connection refused is the expected state when Ollama isn't
128            // running — log at debug so startup stays quiet for users without
129            // local servers. The error is still propagated to the caller.
130            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    // Parse the response
147    let tags_response: OllamaTagsResponse = response
148        .json()
149        .await
150        .map_err(|e| anyhow::anyhow!("Failed to parse Ollama models response: {e}"))?;
151
152    // Extract model names
153    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        // Never send API keys to local endpoints; keep keys for cloud/remote targets
250        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    /// Verify the server is up and the model is available before generating.
279    /// Returns the (possibly substituted) model id or a structured error with a
280    /// recovery command (`ollama pull <model>` / `/local start ollama`).
281    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        // Keep tool definitions on the wire even when tools are disabled so the
483        // rendered prefix stays cache-stable across recovery turns (OpenAI
484        // guidance: disable tool use with `tool_choice: "none"` rather than
485        // removing definitions).
486        let tools = request.tools.as_ref().map(|tools| {
487            tools
488                .iter()
489                .filter_map(|tool| {
490                    // Normalize all tools to function type for Ollama compatibility
491                    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            // Ollama omits tool-call ids; index-based fallbacks reset every
598            // response and collide across assistant messages downstream.
599            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        // Fallback: Extract reasoning from content if not provided natively
687        // This handles MiniMax-M2.5 cloud models that use <think></think> tags
688        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        // Pinned so the stream-timeout fallback cannot silently regress.
895        true
896    }
897
898    fn supports_tools(&self, _model: &str) -> bool {
899        true
900    }
901
902    fn supports_reasoning(&self, model: &str) -> bool {
903        // Codex-inspired robustness: Setting model_supports_reasoning to false
904        // does NOT disable it for known reasoning models.
905        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        // Same robustness logic for reasoning effort
915        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                    // Borrow the line directly from `buffer` instead of
1012                    // draining into a temporary `Vec<u8>` per SSE line.
1013                    // `parse_stream_chunk` returns an owned deserialized
1014                    // value, so the borrow ends before we drain.
1015                    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                    // `parsed` is owned — safe to discard the borrowed line.
1029                    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}