Skip to main content

vtcode_llm/providers/ollama/
mod.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}
163mod payload;
164
165pub struct OllamaProvider {
166    http_client: HttpClient,
167    base_url: String,
168    model: String,
169    api_key: Option<String>,
170    model_behavior: Option<ModelConfig>,
171}
172
173impl OllamaProvider {
174    fn merged_system_prompt(request: &LLMRequest) -> Option<String> {
175        const HISTORY_DIRECTIVES_SECTION_HEADER: &str = "[History Directives]";
176        let directives = collect_history_system_directives(request);
177        merge_system_prompt_with_history_directives(
178            request.system_prompt.as_ref().map(|prompt| prompt.as_ref()),
179            &directives,
180            HISTORY_DIRECTIVES_SECTION_HEADER,
181        )
182    }
183
184    pub fn new(api_key: String) -> Self {
185        Self::with_model(api_key, models::ollama::DEFAULT_MODEL.to_string())
186    }
187
188    fn with_model(api_key: String, model: String) -> Self {
189        Self::with_model_internal(model, None, Some(api_key), None)
190    }
191
192    pub fn new_with_client(
193        api_key: String,
194        model: String,
195        http_client: reqwest::Client,
196        base_url: String,
197        _timeouts: TimeoutsConfig,
198    ) -> Self {
199        Self {
200            http_client,
201            base_url,
202            model,
203            api_key: Some(api_key),
204            model_behavior: None,
205        }
206    }
207
208    pub fn from_config(
209        api_key: Option<String>,
210        model: Option<String>,
211        base_url: Option<String>,
212        _prompt_cache: Option<PromptCachingConfig>,
213        _timeouts: Option<TimeoutsConfig>,
214        _anthropic: Option<AnthropicConfig>,
215        model_behavior: Option<ModelConfig>,
216    ) -> Self {
217        let resolved_model = resolve_model(model, models::ollama::DEFAULT_MODEL);
218        Self::with_model_internal(resolved_model, base_url, api_key, model_behavior)
219    }
220
221    fn normalize_api_key(api_key: Option<String>) -> Option<String> {
222        api_key.and_then(|value| {
223            let trimmed = value.trim();
224            if trimmed.is_empty() {
225                None
226            } else {
227                Some(trimmed.to_string())
228            }
229        })
230    }
231
232    fn with_model_internal(
233        model: String,
234        base_url: Option<String>,
235        api_key: Option<String>,
236        model_behavior: Option<ModelConfig>,
237    ) -> Self {
238        let normalized_api_key = Self::normalize_api_key(api_key);
239        let is_cloud_model = model.contains(":cloud") || model.contains("-cloud");
240
241        let default_base = if is_cloud_model {
242            urls::OLLAMA_CLOUD_API_BASE
243        } else {
244            urls::OLLAMA_API_BASE
245        };
246
247        let resolved_base = override_base_url(default_base, base_url, Some(env_vars::OLLAMA_BASE_URL));
248        let target_is_local = super::local_server::is_local_base_url(&resolved_base);
249
250        // Never send API keys to local endpoints; keep keys for cloud/remote targets
251        let effective_api_key = if target_is_local { None } else { normalized_api_key };
252
253        Self {
254            http_client: vtcode_commons::http::create_default_client(),
255            base_url: resolved_base,
256            model,
257            api_key: effective_api_key,
258            model_behavior,
259        }
260    }
261
262    fn chat_url(&self) -> String {
263        format!("{}/api/chat", self.base_url.trim_end_matches('/'))
264    }
265
266    fn authorized_post(&self, url: String) -> reqwest::RequestBuilder {
267        let builder = self.http_client.post(url);
268        if let Some(api_key) = &self.api_key {
269            builder.bearer_auth(api_key)
270        } else {
271            builder
272        }
273    }
274
275    fn parse_client_prompt(&self, prompt: &str) -> LLMRequest {
276        parse_client_prompt_common(prompt, &self.model, |value| self.parse_chat_request(value))
277    }
278
279    /// Verify the server is up and the model is available before generating.
280    /// Returns the (possibly substituted) model id or a structured error with a
281    /// recovery command (`ollama pull <model>` / `/local start ollama`).
282    ///
283    /// `VTCODE_LOCAL_AUTOPULL` is deliberately strict (`1`/`true`, no
284    /// whitespace/casing slack) because enabling it side-effects: a missing
285    /// model triggers a network `ollama pull` at generate time. Widening to
286    /// the canonical tri-state set (canonical parser, `parse_bool_env_value`)
287    /// would silently enable downloads for casual values (`yes`/`on`). Keep a
288    /// minimal literal comparison; the canonical parser governs the non-wire,
289    /// non-side-effecting flags.
290    async fn ensure_ready(&self, requested: &str) -> Result<String, LLMError> {
291        let autopull = std::env::var("VTCODE_LOCAL_AUTOPULL")
292            .ok()
293            .is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
294
295        match resolve_local_model(LocalProvider::Ollama, requested, Some(&self.base_url)).await {
296            Ok(model) => Ok(model),
297            Err(err) if autopull => {
298                let model = match &err {
299                    super::local_readiness::LocalReadinessError::ModelMissing { model, .. } => model.clone(),
300                    _ => return Err(err.to_llm_error("Ollama")),
301                };
302                match ensure_oss_ready(Some(&model), Some(self.base_url.clone())).await {
303                    Ok(()) => {
304                        invalidate_readiness_cache();
305                        Ok(model)
306                    }
307                    Err(_) => Err(err.to_llm_error("Ollama")),
308                }
309            }
310            Err(err) => Err(err.to_llm_error("Ollama")),
311        }
312    }
313
314    fn parse_chat_request(&self, value: &Value) -> Option<LLMRequest> {
315        let messages_value = value.get("messages")?.as_array()?;
316        let mut system_prompt = value
317            .get("system")
318            .and_then(|entry| entry.as_str())
319            .filter(|text| !text.trim().is_empty())
320            .map(|text| text.to_string());
321        let mut messages = Vec::new();
322
323        for entry in messages_value {
324            let role = entry
325                .get("role")
326                .and_then(|r| r.as_str())
327                .unwrap_or(vtcode_config::constants::message_roles::USER);
328            let content = entry
329                .get("content")
330                .map(|c| match c {
331                    Value::String(text) => text.to_string(),
332                    other => other.to_string(),
333                })
334                .unwrap_or_default();
335
336            if content.trim().is_empty() {
337                continue;
338            }
339
340            match role {
341                "system" => {
342                    if system_prompt.is_none() {
343                        system_prompt = Some(content);
344                    }
345                }
346                "assistant" => messages.push(Message::assistant(content)),
347                "user" => messages.push(Message::user(content)),
348                _ => {}
349            }
350        }
351
352        if messages.is_empty() {
353            return None;
354        }
355
356        let tools = value
357            .get("tools")
358            .and_then(|entry| serde_json::from_value::<Vec<ToolDefinition>>(entry.clone()).ok());
359
360        Some(LLMRequest {
361            messages: std::sync::Arc::new(messages),
362            system_prompt: system_prompt.map(std::sync::Arc::from),
363            tools: tools.map(std::sync::Arc::new),
364            model: value
365                .get("model")
366                .and_then(|m| m.as_str())
367                .filter(|m| !m.trim().is_empty())
368                .map(|m| m.to_string())
369                .unwrap_or_else(|| self.model.clone()),
370            max_tokens: value
371                .get("max_tokens")
372                .and_then(|entry| entry.as_u64())
373                .map(|value| value as u32),
374            temperature: value
375                .get("temperature")
376                .and_then(|entry| entry.as_f64())
377                .map(|value| value as f32),
378            stream: value.get("stream").and_then(|entry| entry.as_bool()).unwrap_or(false),
379            ..Default::default()
380        })
381    }
382
383    fn convert_tool_calls(tool_calls: Option<Vec<OllamaResponseToolCall>>) -> Result<Option<Vec<ToolCall>>, LLMError> {
384        let Some(tool_calls) = tool_calls else {
385            return Ok(None);
386        };
387
388        if tool_calls.is_empty() {
389            return Ok(None);
390        }
391
392        let mut converted = Vec::new();
393        for call in tool_calls.into_iter() {
394            let function = call.function.ok_or_else(|| LLMError::Provider {
395                message: "Ollama response missing function details for tool call".to_string(),
396                metadata: None,
397            })?;
398
399            let name = function.name.ok_or_else(|| LLMError::Provider {
400                message: "Ollama response missing tool function name".to_string(),
401                metadata: None,
402            })?;
403
404            let arguments_value = function.arguments.unwrap_or_else(|| Value::Object(Map::new()));
405            let arguments = match arguments_value {
406                Value::String(raw) => raw,
407                other => serde_json::to_string(&other).map_err(|err| LLMError::Provider {
408                    message: format!("Failed to serialize Ollama tool arguments: {err}"),
409                    metadata: None,
410                })?,
411            };
412
413            // Ollama omits tool-call ids; index-based fallbacks reset every
414            // response and collide across assistant messages downstream.
415            let id = crate::providers::shared::generate_tool_call_id();
416
417            converted.push(ToolCall::function(id, name, arguments));
418        }
419
420        Ok(Some(converted))
421    }
422
423    fn usage_from_counts(prompt_tokens: Option<u32>, completion_tokens: Option<u32>) -> Option<Usage> {
424        if prompt_tokens.is_none() && completion_tokens.is_none() {
425            return None;
426        }
427
428        let prompt = prompt_tokens.unwrap_or_default();
429        let completion = completion_tokens.unwrap_or_default();
430        Some(Usage {
431            prompt_tokens: prompt,
432            completion_tokens: completion,
433            total_tokens: prompt + completion,
434            cached_prompt_tokens: None,
435            cache_creation_tokens: None,
436            cache_read_tokens: None,
437            iterations: None,
438        })
439    }
440
441    fn finish_reason_from(reason: Option<&str>) -> FinishReason {
442        match reason {
443            Some("stop") | None => FinishReason::Stop,
444            Some("length") => FinishReason::Length,
445            Some("tool_calls") => FinishReason::ToolCalls,
446            Some(other) => FinishReason::Error(other.to_string()),
447        }
448    }
449
450    fn build_response(
451        content: Option<String>,
452        tool_calls: Option<Vec<ToolCall>>,
453        reasoning: Option<String>,
454        reasoning_details: Option<Vec<String>>,
455        model: String,
456        finish_reason: Option<&str>,
457        prompt_tokens: Option<u32>,
458        completion_tokens: Option<u32>,
459    ) -> LLMResponse {
460        let mut finish = Self::finish_reason_from(finish_reason);
461        if tool_calls.as_ref().is_some_and(|calls| !calls.is_empty()) {
462            finish = FinishReason::ToolCalls;
463        }
464
465        LLMResponse {
466            content,
467            tool_calls,
468            model,
469            usage: Self::usage_from_counts(prompt_tokens, completion_tokens),
470            finish_reason: finish,
471            reasoning,
472            reasoning_details,
473            tool_references: Vec::new(),
474            request_id: None,
475            organization_id: None,
476            compaction: None,
477        }
478    }
479
480    /// Tagged private reasoning must never become public content when no final
481    /// text remains. Apply the same boundary to both completed wire modes.
482    fn normalize_response_reasoning(response: &mut LLMResponse) {
483        let Some(content) = response.content.take() else {
484            return;
485        };
486        let (reasoning_parts, cleaned_content) = crate::utils::extract_reasoning_content(&content);
487        if reasoning_parts.is_empty() {
488            response.content = Some(content);
489            return;
490        }
491        super::common::preserve_interleaved_content_in_reasoning_details(&mut response.reasoning_details, &content);
492        if response.reasoning.is_none() {
493            response.reasoning = Some(reasoning_parts.join("\n\n"));
494        }
495        response.content = cleaned_content;
496    }
497
498    fn response_from_chat_payload(model: String, parsed: OllamaChatResponse) -> Result<LLMResponse, LLMError> {
499        if let Some(error) = parsed.error {
500            return Err(LLMError::Provider { message: error, metadata: None });
501        }
502
503        let (content, reasoning, tool_calls, native_reasoning_details) = if let Some(message) = parsed.message {
504            let content = message.content.and_then(|value| (!value.is_empty()).then_some(value));
505            let reasoning = message.thinking.and_then(|value| (!value.is_empty()).then_some(value));
506            let tool_calls = Self::convert_tool_calls(message.tool_calls)?;
507            let native_reasoning_details = message.reasoning_details.filter(|d| !d.is_empty());
508            (content, reasoning, tool_calls, native_reasoning_details)
509        } else {
510            (None, None, None, None)
511        };
512
513        let reasoning = reasoning.or_else(|| {
514            native_reasoning_details
515                .as_deref()
516                .and_then(extract_reasoning_text_from_detail_values)
517        });
518        let reasoning_details = native_reasoning_details.as_deref().and_then(serialize_reasoning_detail_values);
519        let mut response = Self::build_response(
520            content,
521            tool_calls,
522            reasoning,
523            reasoning_details,
524            model,
525            parsed.done_reason.as_deref(),
526            parsed.prompt_eval_count,
527            parsed.eval_count,
528        );
529        Self::normalize_response_reasoning(&mut response);
530        Ok(response)
531    }
532
533    fn authorized_post_with_key(http_client: &HttpClient, url: &str, api_key: Option<&str>) -> reqwest::RequestBuilder {
534        let builder = http_client.post(url.to_string());
535        if let Some(value) = api_key {
536            builder.bearer_auth(value)
537        } else {
538            builder
539        }
540    }
541
542    async fn request_non_stream_response(
543        http_client: &HttpClient,
544        url: &str,
545        api_key: Option<&str>,
546        payload: &OllamaChatRequest,
547        model: String,
548    ) -> Result<LLMResponse, LLMError> {
549        let response = Self::authorized_post_with_key(http_client, url, api_key)
550            .json(payload)
551            .send()
552            .await
553            .map_err(|e| format_network_error("Ollama", &e))?;
554
555        if !response.status().is_success() {
556            let status = response.status();
557            let body = crate::providers::common::read_provider_error_body(response).await;
558            let error_message =
559                Self::extract_error(&body).unwrap_or_else(|| format!("Ollama request failed ({status}): {body}"));
560            return Err(LLMError::Provider { message: error_message, metadata: None });
561        }
562
563        let parsed = response
564            .json::<OllamaChatResponse>()
565            .await
566            .map_err(|e| format_parse_error("Ollama", &e))?;
567        Self::response_from_chat_payload(model, parsed)
568    }
569
570    fn extract_error(body: &str) -> Option<String> {
571        serde_json::from_str::<OllamaErrorResponse>(body)
572            .ok()
573            .and_then(|resp| resp.error)
574    }
575}
576
577#[derive(Debug, Serialize)]
578struct OllamaChatRequest {
579    model: String,
580    messages: Vec<OllamaChatMessage>,
581    stream: bool,
582    #[serde(skip_serializing_if = "Option::is_none")]
583    format: Option<Value>,
584    #[serde(skip_serializing_if = "Option::is_none")]
585    options: Option<OllamaChatOptions>,
586    #[serde(skip_serializing_if = "Option::is_none")]
587    tools: Option<Vec<ToolDefinition>>,
588    #[serde(skip_serializing_if = "Option::is_none")]
589    think: Option<Value>,
590}
591
592#[derive(Debug, Serialize)]
593struct OllamaChatMessage {
594    role: String,
595    #[serde(skip_serializing_if = "Option::is_none")]
596    content: Option<String>,
597    #[serde(skip_serializing_if = "Option::is_none")]
598    thinking: Option<String>,
599    #[serde(skip_serializing_if = "Option::is_none")]
600    images: Option<Vec<String>>,
601    #[serde(skip_serializing_if = "Option::is_none")]
602    tool_calls: Option<Vec<OllamaToolCall>>,
603    #[serde(skip_serializing_if = "Option::is_none")]
604    tool_call_id: Option<String>,
605    #[serde(skip_serializing_if = "Option::is_none")]
606    tool_name: Option<String>,
607}
608
609#[derive(Debug, Serialize)]
610struct OllamaChatOptions {
611    #[serde(skip_serializing_if = "Option::is_none")]
612    temperature: Option<f32>,
613    #[serde(skip_serializing_if = "Option::is_none")]
614    num_predict: Option<u32>,
615}
616
617#[derive(Debug, Serialize)]
618struct OllamaToolCall {
619    #[serde(rename = "type")]
620    call_type: String,
621    function: OllamaToolFunctionCall,
622}
623
624#[derive(Debug, Serialize)]
625struct OllamaToolFunctionCall {
626    name: String,
627    #[serde(skip_serializing_if = "Option::is_none")]
628    arguments: Option<Value>,
629    #[serde(skip_serializing_if = "Option::is_none")]
630    index: Option<u32>,
631}
632
633#[derive(Debug, Deserialize)]
634struct OllamaChatResponse {
635    message: Option<OllamaResponseMessage>,
636    #[serde(default)]
637    done: bool,
638    #[serde(default)]
639    done_reason: Option<String>,
640    #[serde(default)]
641    prompt_eval_count: Option<u32>,
642    #[serde(default)]
643    eval_count: Option<u32>,
644    #[serde(default)]
645    error: Option<String>,
646}
647
648#[derive(Debug, Deserialize)]
649struct OllamaResponseMessage {
650    #[serde(default)]
651    #[expect(
652        dead_code,
653        reason = "Intentional compatibility, platform, test, or API-shape suppression."
654    )]
655    role: Option<String>,
656    #[serde(default)]
657    content: Option<String>,
658    #[serde(default)]
659    thinking: Option<String>,
660    #[serde(default)]
661    reasoning_details: Option<Vec<Value>>,
662    #[serde(default)]
663    tool_calls: Option<Vec<OllamaResponseToolCall>>,
664}
665
666#[derive(Debug, Deserialize, Serialize, Clone)]
667struct OllamaResponseToolCall {
668    #[serde(default)]
669    #[serde(rename = "type")]
670    call_type: Option<String>,
671    #[serde(default)]
672    function: Option<OllamaResponseFunctionCall>,
673}
674
675#[derive(Debug, Deserialize, Serialize, Clone)]
676struct OllamaResponseFunctionCall {
677    #[serde(default)]
678    name: Option<String>,
679    #[serde(default)]
680    arguments: Option<Value>,
681    #[serde(default)]
682    index: Option<u32>,
683}
684
685#[derive(Debug, Deserialize)]
686struct OllamaErrorResponse {
687    error: Option<String>,
688}
689
690fn parse_stream_chunk(line: &str) -> Result<OllamaChatResponse, LLMError> {
691    serde_json::from_str::<OllamaChatResponse>(line).map_err(|err| LLMError::Provider {
692        message: format!("Failed to parse Ollama stream chunk: {err}"),
693        metadata: None,
694    })
695}
696
697#[async_trait]
698impl LLMProvider for OllamaProvider {
699    fn name(&self) -> &str {
700        "ollama"
701    }
702
703    fn supports_streaming(&self) -> bool {
704        true
705    }
706
707    fn supports_non_streaming(&self, _model: &str) -> bool {
708        // Pinned so the stream-timeout fallback cannot silently regress.
709        true
710    }
711
712    fn supports_tools(&self, _model: &str) -> bool {
713        true
714    }
715
716    fn supports_reasoning(&self, model: &str) -> bool {
717        // Codex-inspired robustness: Setting model_supports_reasoning to false
718        // does NOT disable it for known reasoning models.
719        models::ollama::REASONING_MODELS.contains(&model)
720            || self
721                .model_behavior
722                .as_ref()
723                .and_then(|b| b.model_supports_reasoning)
724                .unwrap_or(false)
725    }
726
727    fn supports_reasoning_effort(&self, model: &str) -> bool {
728        // Same robustness logic for reasoning effort
729        models::ollama::REASONING_LEVEL_MODELS.contains(&model)
730            || self
731                .model_behavior
732                .as_ref()
733                .and_then(|b| b.model_supports_reasoning_effort)
734                .unwrap_or(false)
735    }
736
737    async fn generate(&self, mut request: LLMRequest) -> Result<LLMResponse, LLMError> {
738        self.validate_request(&request)?;
739        if request.model.is_empty() {
740            request.model = self.model.clone();
741        }
742        let resolved = self.ensure_ready(&request.model).await?;
743        request.model = resolved;
744        let model = request.model.clone();
745        let payload = self.build_payload(&request, false)?;
746        let url = self.chat_url();
747        Self::request_non_stream_response(&self.http_client, &url, self.api_key.as_deref(), &payload, model).await
748    }
749
750    async fn stream(&self, mut request: LLMRequest) -> Result<LLMStream, LLMError> {
751        self.validate_request(&request)?;
752        if request.model.is_empty() {
753            request.model = self.model.clone();
754        }
755        let resolved = self.ensure_ready(&request.model).await?;
756        request.model = resolved;
757        let model = request.model.clone();
758        let payload = self.build_payload(&request, true)?;
759        let fallback_payload = self.build_payload(&request, false)?;
760        // Tool-free recovery owns a shared request allowance in the runloop.
761        // Propagate transport failure so that layer admits any corrective call.
762        let allow_transport_fallback = !matches!(request.tool_choice, Some(ToolChoice::None));
763        let url = self.chat_url();
764
765        let response = self
766            .authorized_post(url.clone())
767            .header(reqwest::header::ACCEPT_ENCODING, "identity")
768            .json(&payload)
769            .send()
770            .await
771            .map_err(|e| format_network_error("Ollama", &e))?;
772
773        if !response.status().is_success() {
774            let status = response.status();
775            let body = crate::providers::common::read_provider_error_body(response).await;
776            let error_message = Self::extract_error(&body)
777                .unwrap_or_else(|| format!("Ollama streaming request failed ({status}): {body}"));
778            return Err(LLMError::Provider { message: error_message, metadata: None });
779        }
780
781        let byte_stream = response.bytes_stream();
782        let mut buffer: Vec<u8> = Vec::new();
783        let mut aggregator = crate::providers::shared::StreamAggregator::new(model.clone());
784        let fallback_http_client = self.http_client.clone();
785        let fallback_api_key = self.api_key.clone();
786        let fallback_model = model.clone();
787        let fallback_url = url.clone();
788        let any_interleaved = request
789            .messages
790            .iter()
791            .any(|msg| assistant_interleaved_history_text(msg, &request.model).is_some());
792        let stream = try_stream! {
793            let mut prompt_tokens: Option<u32> = None;
794            let mut completion_tokens: Option<u32> = None;
795            let mut finish_reason: Option<String> = None;
796            let mut completed = false;
797            let mut saw_stream_chunk = false;
798
799            futures::pin_mut!(byte_stream);
800            while let Some(chunk_result) = byte_stream.next().await {
801                let chunk = match chunk_result {
802                    Ok(chunk) => {
803                        saw_stream_chunk = true;
804                        chunk
805                    }
806                    Err(err) if !saw_stream_chunk && allow_transport_fallback => {
807                        tracing::warn!(
808                            model = %fallback_model,
809                            url = %fallback_url,
810                            error = %err,
811                            "Ollama stream failed before first chunk; retrying once as non-stream response"
812                        );
813                        let fallback_response = Self::request_non_stream_response(
814                            &fallback_http_client,
815                            &fallback_url,
816                            fallback_api_key.as_deref(),
817                            &fallback_payload,
818                            fallback_model.clone(),
819                        ).await?;
820                        yield LLMStreamEvent::Completed { response: Box::new(fallback_response) };
821                        return;
822                    }
823                    Err(err) => Err(format_network_error("Ollama", &err))?,
824                };
825                buffer.extend_from_slice(&chunk);
826
827                while let Some(pos) = buffer.iter().position(|b| *b == b'\n') {
828                    // Borrow the line directly from `buffer` instead of
829                    // draining into a temporary `Vec<u8>` per SSE line.
830                    // `parse_stream_chunk` returns an owned deserialized
831                    // value, so the borrow ends before we drain.
832                    let line = std::str::from_utf8(&buffer[..pos])
833                        .map_err(|err| LLMError::Provider {
834                            message: format!("Invalid UTF-8 in Ollama stream: {err}"),
835                            metadata: None,
836                        })?;
837                    let line = line.trim();
838
839                    if line.is_empty() {
840                        buffer.drain(..=pos);
841                        continue;
842                    }
843
844                    let parsed = parse_stream_chunk(line)?;
845                    // `parsed` is owned — safe to discard the borrowed line.
846                    buffer.drain(..=pos);
847
848                    if let Some(error) = parsed.error {
849                        Err(LLMError::Provider {
850                            message: error,
851                            metadata: None,
852                        })?;
853                    }
854
855                    if let Some(message) = parsed.message {
856                        if let Some(reasoning_details) = message.reasoning_details.as_deref() {
857                            aggregator.set_reasoning_details(reasoning_details);
858                        }
859
860                        let has_explicit_thinking = message
861                            .thinking
862                            .as_ref()
863                            .map(|v| !v.is_empty())
864                            .unwrap_or(false);
865
866                        if let Some(thinking) = message.thinking
867                            && let Some(delta) = aggregator.handle_reasoning(&thinking) {
868                                yield LLMStreamEvent::Reasoning { delta };
869                            }
870
871                        if let Some(content) = message.content {
872                            for event in aggregator.handle_content(&content) {
873                                match &event {
874                                    LLMStreamEvent::Reasoning { .. }
875                                        if has_explicit_thinking || any_interleaved =>
876                                    {
877                                    }
878                                    _ => yield event,
879                                }
880                            }
881                        }
882
883                        if let Some(tool_calls) = message.tool_calls {
884                            let tool_calls_json: Vec<Value> = tool_calls
885                                .into_iter()
886                                .map(|tc| serde_json::to_value(tc).unwrap_or(Value::Null))
887                                .filter(|v| !v.is_null())
888                                .collect();
889                            aggregator.handle_tool_calls(&tool_calls_json);
890                        }
891                    }
892
893                    if parsed.done {
894                        prompt_tokens = parsed.prompt_eval_count;
895                        completion_tokens = parsed.eval_count;
896                        finish_reason = parsed.done_reason;
897                        completed = true;
898                    }
899                }
900
901                if completed {
902                    break;
903                }
904            }
905
906            if !completed {
907                Err(LLMError::Provider {
908                    message: "Ollama stream ended without completion signal".to_string(),
909                    metadata: None,
910                })?;
911            }
912
913            let mut response = aggregator.finalize();
914            if let Some(pt) = prompt_tokens {
915                let mut usage = response.usage.unwrap_or_default();
916                usage.prompt_tokens = pt;
917                if let Some(ct) = completion_tokens {
918                    usage.completion_tokens = ct;
919                    usage.total_tokens = pt + ct;
920                }
921                response.usage = Some(usage);
922            }
923            if let Some(fr) = finish_reason {
924                response.finish_reason = crate::providers::common::map_finish_reason_common(&fr);
925            }
926            if response.reasoning.is_none()
927                && let Some(details) = response.reasoning_details.as_ref()
928            {
929                response.reasoning = extract_reasoning_text_from_serialized_details(details);
930            }
931
932            Self::normalize_response_reasoning(&mut response);
933            yield LLMStreamEvent::Completed { response: Box::new(response) };
934        };
935
936        Ok(Box::pin(stream))
937    }
938
939    fn supported_models(&self) -> Vec<String> {
940        models::ollama::SUPPORTED_MODELS.iter().map(|model| model.to_string()).collect()
941    }
942
943    fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
944        if let Some(tool_choice) = &request.tool_choice {
945            match tool_choice {
946                ToolChoice::Auto | ToolChoice::None | ToolChoice::AllowedTools(_) => {}
947                _ => {
948                    return Err(LLMError::InvalidRequest {
949                        message: "Ollama does not support explicit tool_choice overrides".to_string(),
950                        metadata: None,
951                    });
952                }
953            }
954        }
955
956        if request.parallel_tool_calls.is_some() || request.parallel_tool_config.is_some() {
957            return Err(LLMError::InvalidRequest {
958                message: "Ollama does not support parallel tool configuration".to_string(),
959                metadata: None,
960            });
961        }
962
963        for message in request.messages.iter() {
964            if matches!(message.role, MessageRole::Tool) && message.tool_call_id.is_none() {
965                return Err(LLMError::InvalidRequest {
966                    message: "Ollama tool responses must include tool_call_id".to_string(),
967                    metadata: None,
968                });
969            }
970        }
971
972        Ok(())
973    }
974}
975
976#[async_trait]
977impl LLMClient for OllamaProvider {
978    async fn generate(&mut self, prompt: &str) -> Result<LLMResponse, LLMError> {
979        let mut request = self.parse_client_prompt(prompt);
980        if request.model.is_empty() {
981            request.model = self.model.clone();
982        }
983        Ok(LLMProvider::generate(self, request).await?)
984    }
985
986    fn model_id(&self) -> &str {
987        &self.model
988    }
989}
990
991#[cfg(test)]
992mod tests;