1use crate::client::LLMClient;
10use crate::provider::{
11 ContentPart, FinishReason, LLMError, LLMProvider, LLMRequest, LLMResponse, LLMStream, LLMStreamEvent, Message,
12 MessageContent, MessageRole, ToolCall, ToolChoice, ToolDefinition, Usage,
13};
14use anyhow::Result;
15use async_stream::try_stream;
16use async_trait::async_trait;
17use futures::StreamExt;
18use hashbrown::HashMap;
19use reqwest::Client as HttpClient;
20use serde::{Deserialize, Serialize};
21use serde_json::{Map, Value};
22use vtcode_config::TimeoutsConfig;
23use vtcode_config::constants::{env_vars, models, urls};
24use vtcode_config::core::{AnthropicConfig, ModelConfig, PromptCachingConfig};
25
26pub(crate) mod client;
27pub(crate) mod parser;
28pub mod pull;
29pub(crate) mod url;
30
31pub(crate) use client::OllamaClient;
32pub(crate) use parser::pull_events_from_value;
33pub(crate) use pull::{CliPullProgressReporter, OllamaPullEvent, OllamaPullProgressReporter, TuiPullProgressReporter};
34pub(crate) use url::{base_url_to_host_root, is_openai_compatible_base_url};
35
36use super::common::{
37 assistant_interleaved_history_text, collect_history_system_directives, extract_reasoning_text_from_detail_values,
38 extract_reasoning_text_from_serialized_details, is_minimax_m2_model, merge_system_prompt_with_history_directives,
39 override_base_url, parse_client_prompt_common, resolve_model, serialize_reasoning_detail_values,
40};
41use super::error_handling::{format_network_error, format_parse_error};
42use super::local_readiness::{invalidate_readiness_cache, resolve_local_model};
43use super::local_server::LocalProvider;
44
45async fn ensure_oss_ready(model: Option<&str>, base_url: Option<String>) -> std::io::Result<()> {
52 let target_model = model.unwrap_or(models::ollama::DEFAULT_MODEL);
53
54 let resolved_base_url = override_base_url(urls::OLLAMA_API_BASE, base_url, Some(env_vars::OLLAMA_BASE_URL));
55
56 let ollama_client = OllamaClient::try_from_base_url(&resolved_base_url).await?;
58
59 match ollama_client.fetch_models().await {
61 Ok(existing_models) => {
62 if !existing_models.iter().any(|m| m == target_model) {
63 tracing::info!("Model '{target_model}' not found locally, pulling...");
64 let mut reporter = CliPullProgressReporter::new();
65 ollama_client.pull_with_reporter(target_model, &mut reporter).await?;
66 }
67 }
68 Err(e) => {
69 tracing::warn!("Failed to list Ollama models: {e}");
70 }
72 }
73
74 Ok(())
75}
76
77#[derive(Debug, Deserialize, Serialize)]
78struct OllamaTagsResponse {
79 models: Vec<OllamaTag>,
80}
81
82#[derive(Debug, Deserialize, Serialize)]
83struct OllamaTag {
84 name: Option<String>,
85 model: Option<String>,
86 modified_at: Option<String>,
87 size: Option<u64>,
88 digest: Option<String>,
89 details: Option<OllamaModelDetails>,
90}
91
92#[derive(Debug, Deserialize, Serialize)]
93struct OllamaModelDetails {
94 format: Option<String>,
95 family: Option<String>,
96 families: Option<Vec<String>>,
97 parameter_size: Option<String>,
98 quantization_level: Option<String>,
99}
100
101pub(super) fn ollama_model_name_from_fields<'a>(name: Option<&'a str>, model: Option<&'a str>) -> Option<&'a str> {
102 name.or(model).map(str::trim).filter(|value| !value.is_empty())
103}
104
105pub(super) const OLLAMA_CONNECTION_ERROR: &str = "No running Ollama server detected. Start it with: `ollama serve` (after installing)\n\
106 Install instructions: https://github.com/ollama/ollama?tab=readme-ov-file";
107
108pub async fn fetch_ollama_models(base_url: Option<String>) -> Result<Vec<String>, anyhow::Error> {
110 use vtcode_config::constants::{env_vars, urls};
111
112 let resolved_base_url = override_base_url(urls::OLLAMA_API_BASE, base_url, Some(env_vars::OLLAMA_BASE_URL));
113
114 let tags_url = format!("{resolved_base_url}/api/tags");
116
117 let client = vtcode_commons::http::create_client_with_timeout(std::time::Duration::from_secs(5));
119
120 let response = client
122 .get(&tags_url)
123 .header("Content-Type", "application/json")
124 .send()
125 .await
126 .map_err(|e| {
127 tracing::debug!("Failed to connect to Ollama server: {e:?}");
131 anyhow::anyhow!(OLLAMA_CONNECTION_ERROR)
132 })?;
133
134 if !response.status().is_success() {
135 return Err(anyhow::anyhow!(
136 "Failed to fetch Ollama models: HTTP {}. {}",
137 response.status(),
138 if response.status() == reqwest::StatusCode::NOT_FOUND {
139 "Ensure Ollama server is running."
140 } else {
141 ""
142 }
143 ));
144 }
145
146 let tags_response: OllamaTagsResponse = response
148 .json()
149 .await
150 .map_err(|e| anyhow::anyhow!("Failed to parse Ollama models response: {e}"))?;
151
152 let model_names: Vec<String> = tags_response
154 .models
155 .into_iter()
156 .filter_map(|model| {
157 ollama_model_name_from_fields(model.name.as_deref(), model.model.as_deref()).map(str::to_string)
158 })
159 .collect();
160
161 Ok(model_names)
162}
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 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 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 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 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 true
710 }
711
712 fn supports_tools(&self, _model: &str) -> bool {
713 true
714 }
715
716 fn supports_reasoning(&self, model: &str) -> bool {
717 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 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 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 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 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;