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