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