use crate::client::LLMClient;
use crate::provider::{
ContentPart, FinishReason, LLMError, LLMProvider, LLMRequest, LLMResponse, LLMStream, LLMStreamEvent, Message,
MessageContent, MessageRole, ToolCall, ToolChoice, ToolDefinition, Usage,
};
use anyhow::Result;
use async_stream::try_stream;
use async_trait::async_trait;
use futures::StreamExt;
use hashbrown::HashMap;
use reqwest::Client as HttpClient;
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use vtcode_config::TimeoutsConfig;
use vtcode_config::constants::{env_vars, models, urls};
use vtcode_config::core::{AnthropicConfig, ModelConfig, PromptCachingConfig};
pub mod client;
pub mod parser;
pub mod pull;
pub mod url;
pub use client::OllamaClient;
pub use parser::pull_events_from_value;
pub use pull::{CliPullProgressReporter, OllamaPullEvent, OllamaPullProgressReporter, TuiPullProgressReporter};
pub use url::{base_url_to_host_root, is_openai_compatible_base_url};
use super::common::{
assistant_interleaved_history_text, collect_history_system_directives, extract_reasoning_text_from_detail_values,
extract_reasoning_text_from_serialized_details, is_minimax_m2_model, merge_system_prompt_with_history_directives,
override_base_url, parse_client_prompt_common, resolve_model, serialize_reasoning_detail_values,
};
use super::error_handling::{format_network_error, format_parse_error};
use super::local_readiness::{invalidate_readiness_cache, resolve_local_model};
use super::local_server::LocalProvider;
pub async fn ensure_oss_ready(model: Option<&str>, base_url: Option<String>) -> std::io::Result<()> {
let target_model = model.unwrap_or(models::ollama::DEFAULT_MODEL);
let resolved_base_url = override_base_url(urls::OLLAMA_API_BASE, base_url, Some(env_vars::OLLAMA_BASE_URL));
let ollama_client = OllamaClient::try_from_base_url(&resolved_base_url).await?;
match ollama_client.fetch_models().await {
Ok(existing_models) => {
if !existing_models.iter().any(|m| m == target_model) {
tracing::info!("Model '{target_model}' not found locally, pulling...");
let mut reporter = CliPullProgressReporter::new();
ollama_client.pull_with_reporter(target_model, &mut reporter).await?;
}
}
Err(e) => {
tracing::warn!("Failed to list Ollama models: {e}");
}
}
Ok(())
}
#[derive(Debug, Deserialize, Serialize)]
struct OllamaTagsResponse {
models: Vec<OllamaTag>,
}
#[derive(Debug, Deserialize, Serialize)]
struct OllamaTag {
name: Option<String>,
model: Option<String>,
modified_at: Option<String>,
size: Option<u64>,
digest: Option<String>,
details: Option<OllamaModelDetails>,
}
#[derive(Debug, Deserialize, Serialize)]
struct OllamaModelDetails {
format: Option<String>,
family: Option<String>,
families: Option<Vec<String>>,
parameter_size: Option<String>,
quantization_level: Option<String>,
}
pub(super) fn ollama_model_name_from_fields<'a>(name: Option<&'a str>, model: Option<&'a str>) -> Option<&'a str> {
name.or(model).map(str::trim).filter(|value| !value.is_empty())
}
pub(super) const OLLAMA_CONNECTION_ERROR: &str = "No running Ollama server detected. Start it with: `ollama serve` (after installing)\n\
Install instructions: https://github.com/ollama/ollama?tab=readme-ov-file";
pub async fn fetch_ollama_models(base_url: Option<String>) -> Result<Vec<String>, anyhow::Error> {
use vtcode_config::constants::{env_vars, urls};
let resolved_base_url = override_base_url(urls::OLLAMA_API_BASE, base_url, Some(env_vars::OLLAMA_BASE_URL));
let tags_url = format!("{resolved_base_url}/api/tags");
let client = vtcode_commons::http::create_client_with_timeout(std::time::Duration::from_secs(5));
let response = client
.get(&tags_url)
.header("Content-Type", "application/json")
.send()
.await
.map_err(|e| {
tracing::warn!("Failed to connect to Ollama server: {e:?}");
anyhow::anyhow!(OLLAMA_CONNECTION_ERROR)
})?;
if !response.status().is_success() {
return Err(anyhow::anyhow!(
"Failed to fetch Ollama models: HTTP {}. {}",
response.status(),
if response.status() == reqwest::StatusCode::NOT_FOUND {
"Ensure Ollama server is running."
} else {
""
}
));
}
let tags_response: OllamaTagsResponse = response
.json()
.await
.map_err(|e| anyhow::anyhow!("Failed to parse Ollama models response: {e}"))?;
let model_names: Vec<String> = tags_response
.models
.into_iter()
.filter_map(|model| {
ollama_model_name_from_fields(model.name.as_deref(), model.model.as_deref()).map(str::to_string)
})
.collect();
Ok(model_names)
}
pub struct OllamaProvider {
http_client: HttpClient,
base_url: String,
model: String,
api_key: Option<String>,
model_behavior: Option<ModelConfig>,
}
impl OllamaProvider {
fn merged_system_prompt(request: &LLMRequest) -> Option<String> {
const HISTORY_DIRECTIVES_SECTION_HEADER: &str = "[History Directives]";
let directives = collect_history_system_directives(request);
merge_system_prompt_with_history_directives(
request.system_prompt.as_ref().map(|prompt| prompt.as_str()),
&directives,
HISTORY_DIRECTIVES_SECTION_HEADER,
)
}
pub fn new(api_key: String) -> Self {
Self::with_model(api_key, models::ollama::DEFAULT_MODEL.to_string())
}
pub fn with_model(api_key: String, model: String) -> Self {
Self::with_model_internal(model, None, Some(api_key), None)
}
pub fn new_with_client(
api_key: String,
model: String,
http_client: reqwest::Client,
base_url: String,
_timeouts: TimeoutsConfig,
) -> Self {
Self {
http_client,
base_url,
model,
api_key: Some(api_key),
model_behavior: None,
}
}
pub fn from_config(
api_key: Option<String>,
model: Option<String>,
base_url: Option<String>,
_prompt_cache: Option<PromptCachingConfig>,
_timeouts: Option<TimeoutsConfig>,
_anthropic: Option<AnthropicConfig>,
model_behavior: Option<ModelConfig>,
) -> Self {
let resolved_model = resolve_model(model, models::ollama::DEFAULT_MODEL);
Self::with_model_internal(resolved_model, base_url, api_key, model_behavior)
}
fn normalize_api_key(api_key: Option<String>) -> Option<String> {
api_key.and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
fn with_model_internal(
model: String,
base_url: Option<String>,
api_key: Option<String>,
model_behavior: Option<ModelConfig>,
) -> Self {
let normalized_api_key = Self::normalize_api_key(api_key);
let is_cloud_model = model.contains(":cloud") || model.contains("-cloud");
let default_base = if is_cloud_model {
urls::OLLAMA_CLOUD_API_BASE
} else {
urls::OLLAMA_API_BASE
};
let resolved_base = override_base_url(default_base, base_url, Some(env_vars::OLLAMA_BASE_URL));
let target_is_local = super::local_server::is_local_base_url(&resolved_base);
let effective_api_key = if target_is_local { None } else { normalized_api_key };
Self {
http_client: vtcode_commons::http::create_default_client(),
base_url: resolved_base,
model,
api_key: effective_api_key,
model_behavior,
}
}
fn chat_url(&self) -> String {
format!("{}/api/chat", self.base_url.trim_end_matches('/'))
}
fn authorized_post(&self, url: String) -> reqwest::RequestBuilder {
let builder = self.http_client.post(url);
if let Some(api_key) = &self.api_key {
builder.bearer_auth(api_key)
} else {
builder
}
}
fn parse_client_prompt(&self, prompt: &str) -> LLMRequest {
parse_client_prompt_common(prompt, &self.model, |value| self.parse_chat_request(value))
}
async fn ensure_ready(&self, requested: &str) -> Result<String, LLMError> {
let autopull = std::env::var("VTCODE_LOCAL_AUTOPULL")
.ok()
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
match resolve_local_model(LocalProvider::Ollama, requested, Some(&self.base_url)).await {
Ok(model) => Ok(model),
Err(err) if autopull => {
let model = match &err {
super::local_readiness::LocalReadinessError::ModelMissing { model, .. } => model.clone(),
_ => return Err(err.to_llm_error("Ollama")),
};
match ensure_oss_ready(Some(&model), Some(self.base_url.clone())).await {
Ok(()) => {
invalidate_readiness_cache();
Ok(model)
}
Err(_) => Err(err.to_llm_error("Ollama")),
}
}
Err(err) => Err(err.to_llm_error("Ollama")),
}
}
fn parse_chat_request(&self, value: &Value) -> Option<LLMRequest> {
let messages_value = value.get("messages")?.as_array()?;
let mut system_prompt = value
.get("system")
.and_then(|entry| entry.as_str())
.filter(|text| !text.trim().is_empty())
.map(|text| text.to_string());
let mut messages = Vec::new();
for entry in messages_value {
let role = entry
.get("role")
.and_then(|r| r.as_str())
.unwrap_or(vtcode_config::constants::message_roles::USER);
let content = entry
.get("content")
.map(|c| match c {
Value::String(text) => text.to_string(),
other => other.to_string(),
})
.unwrap_or_default();
if content.trim().is_empty() {
continue;
}
match role {
"system" => {
if system_prompt.is_none() {
system_prompt = Some(content);
}
}
"assistant" => messages.push(Message::assistant(content)),
"user" => messages.push(Message::user(content)),
_ => {}
}
}
if messages.is_empty() {
return None;
}
let tools = value
.get("tools")
.and_then(|entry| serde_json::from_value::<Vec<ToolDefinition>>(entry.clone()).ok());
Some(LLMRequest {
messages: std::sync::Arc::new(messages),
system_prompt: system_prompt.map(std::sync::Arc::new),
tools: tools.map(std::sync::Arc::new),
model: value
.get("model")
.and_then(|m| m.as_str())
.filter(|m| !m.trim().is_empty())
.map(|m| m.to_string())
.unwrap_or_else(|| self.model.clone()),
max_tokens: value
.get("max_tokens")
.and_then(|entry| entry.as_u64())
.map(|value| value as u32),
temperature: value
.get("temperature")
.and_then(|entry| entry.as_f64())
.map(|value| value as f32),
stream: value.get("stream").and_then(|entry| entry.as_bool()).unwrap_or(false),
..Default::default()
})
}
fn build_payload(&self, request: &LLMRequest, stream: bool) -> Result<OllamaChatRequest, LLMError> {
let mut messages = Vec::new();
let mut tool_names: HashMap<String, String> = HashMap::new();
let minimax_tool_followup_compat = Self::minimax_tool_followup_compat_mode(request);
if let Some(system) = Self::merged_system_prompt(request) {
messages.push(OllamaChatMessage {
role: "system".to_string(),
content: Some(system),
thinking: None,
tool_calls: None,
tool_call_id: None,
tool_name: None,
images: None,
});
}
for message in request.messages.iter() {
let interleaved_content = assistant_interleaved_history_text(message, &request.model);
let used_interleaved_content = interleaved_content.is_some();
let (content_text, images) = if let Some(interleaved_content) = interleaved_content {
(interleaved_content, None)
} else {
Self::extract_content_and_images(&message.content)
};
match message.role {
MessageRole::System => continue,
MessageRole::Tool => {
let tool_name = message.tool_call_id.as_ref().and_then(|id| tool_names.get(id).cloned());
let tool_name = tool_name.or_else(|| message.origin_tool.clone());
let tool_call_id = if minimax_tool_followup_compat && tool_name.is_some() {
None
} else {
message.tool_call_id.clone()
};
messages.push(OllamaChatMessage {
role: "tool".to_string(),
content: Some(content_text),
thinking: None,
tool_calls: None,
tool_call_id,
tool_name,
images: None,
});
}
_ => {
let thinking = if used_interleaved_content {
None
} else {
Self::assistant_thinking_history_text(message)
};
let mut payload_message = OllamaChatMessage {
role: message.role.as_generic_str().to_string(),
content: Some(content_text),
thinking,
tool_calls: None,
tool_call_id: None,
tool_name: None,
images,
};
if let Some(tool_calls) = message.get_tool_calls() {
let mut converted = Vec::new();
for (index, tool_call) in tool_calls.iter().enumerate() {
if let Some(ref func) = tool_call.function {
if !tool_call.id.is_empty() {
tool_names.entry(tool_call.id.clone()).or_insert_with(|| func.name.clone());
}
let arguments =
tool_call.execution_arguments().map_err(|err| LLMError::InvalidRequest {
message: format!("Failed to parse tool arguments for Ollama: {err}"),
metadata: None,
})?;
converted.push(OllamaToolCall {
call_type: tool_call.call_type.clone(),
function: OllamaToolFunctionCall {
name: func.name.clone(),
arguments: Some(arguments),
index: Some(index as u32),
},
});
}
}
if !converted.is_empty() {
payload_message.tool_calls = Some(converted);
if payload_message.content.is_none() {
payload_message.content = Some(String::new());
}
}
}
messages.push(payload_message);
}
}
}
let options = if request.temperature.is_some() || request.max_tokens.is_some() {
Some(OllamaChatOptions {
temperature: request.temperature,
num_predict: request.max_tokens,
})
} else {
None
};
let tools = match request.tool_choice {
Some(ToolChoice::None) => None,
_ => request.tools.as_ref().map(|tools| {
tools
.iter()
.filter_map(|tool| {
tool.function.as_ref().map(|func| {
ToolDefinition::function(
func.name.clone(),
func.description.clone(),
func.parameters.clone(),
)
})
})
.collect()
}),
};
Ok(OllamaChatRequest {
model: request.model.clone(),
messages,
stream,
format: request.output_format.clone(),
options,
tools,
think: Self::think_value(request),
})
}
fn assistant_thinking_history_text(message: &Message) -> Option<String> {
if message.role != MessageRole::Assistant {
return None;
}
message
.reasoning
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_owned)
.or_else(|| {
message
.reasoning_details
.as_deref()
.and_then(extract_reasoning_text_from_detail_values)
})
}
fn extract_content_and_images(content: &MessageContent) -> (String, Option<Vec<String>>) {
let mut images = Vec::new();
if let MessageContent::Parts(parts) = content {
for part in parts {
if let ContentPart::Image { data, .. } = part {
images.push(data.clone());
}
}
}
let text = content.as_text().into_owned();
let images = if images.is_empty() { None } else { Some(images) };
(text, images)
}
fn think_value(request: &LLMRequest) -> Option<Value> {
let model_id = request.model.as_str();
if Self::minimax_tool_followup_compat_mode(request) {
return None;
}
if !models::ollama::REASONING_MODELS.contains(&model_id) {
return None;
}
if models::ollama::REASONING_LEVEL_MODELS.contains(&model_id) {
request.reasoning_effort.map(|effort| Value::String(effort.to_string()))
} else {
Some(Value::Bool(true))
}
}
fn minimax_tool_followup_compat_mode(request: &LLMRequest) -> bool {
is_minimax_m2_model(&request.model)
&& request
.messages
.iter()
.any(|message| message.role == MessageRole::Tool || message.has_tool_calls())
}
fn convert_tool_calls(tool_calls: Option<Vec<OllamaResponseToolCall>>) -> Result<Option<Vec<ToolCall>>, LLMError> {
let Some(tool_calls) = tool_calls else {
return Ok(None);
};
if tool_calls.is_empty() {
return Ok(None);
}
let mut converted = Vec::new();
for call in tool_calls.into_iter() {
let function = call.function.ok_or_else(|| LLMError::Provider {
message: "Ollama response missing function details for tool call".to_string(),
metadata: None,
})?;
let name = function.name.ok_or_else(|| LLMError::Provider {
message: "Ollama response missing tool function name".to_string(),
metadata: None,
})?;
let arguments_value = function.arguments.unwrap_or_else(|| Value::Object(Map::new()));
let arguments = match arguments_value {
Value::String(raw) => raw,
other => serde_json::to_string(&other).map_err(|err| LLMError::Provider {
message: format!("Failed to serialize Ollama tool arguments: {err}"),
metadata: None,
})?,
};
let id = crate::providers::shared::generate_tool_call_id();
converted.push(ToolCall::function(id, name, arguments));
}
Ok(Some(converted))
}
fn usage_from_counts(prompt_tokens: Option<u32>, completion_tokens: Option<u32>) -> Option<Usage> {
if prompt_tokens.is_none() && completion_tokens.is_none() {
return None;
}
let prompt = prompt_tokens.unwrap_or_default();
let completion = completion_tokens.unwrap_or_default();
Some(Usage {
prompt_tokens: prompt,
completion_tokens: completion,
total_tokens: prompt + completion,
cached_prompt_tokens: None,
cache_creation_tokens: None,
cache_read_tokens: None,
iterations: None,
})
}
fn finish_reason_from(reason: Option<&str>) -> FinishReason {
match reason {
Some("stop") | None => FinishReason::Stop,
Some("length") => FinishReason::Length,
Some("tool_calls") => FinishReason::ToolCalls,
Some(other) => FinishReason::Error(other.to_string()),
}
}
fn build_response(
content: Option<String>,
tool_calls: Option<Vec<ToolCall>>,
reasoning: Option<String>,
reasoning_details: Option<Vec<String>>,
model: String,
finish_reason: Option<&str>,
prompt_tokens: Option<u32>,
completion_tokens: Option<u32>,
) -> LLMResponse {
let mut finish = Self::finish_reason_from(finish_reason);
if tool_calls.as_ref().is_some_and(|calls| !calls.is_empty()) {
finish = FinishReason::ToolCalls;
}
LLMResponse {
content,
tool_calls,
model,
usage: Self::usage_from_counts(prompt_tokens, completion_tokens),
finish_reason: finish,
reasoning,
reasoning_details,
tool_references: Vec::new(),
request_id: None,
organization_id: None,
compaction: None,
}
}
fn response_from_chat_payload(model: String, parsed: OllamaChatResponse) -> Result<LLMResponse, LLMError> {
if let Some(error) = parsed.error {
return Err(LLMError::Provider { message: error, metadata: None });
}
let (content, reasoning, tool_calls, native_reasoning_details) = if let Some(message) = parsed.message {
let content = message.content.and_then(|value| (!value.is_empty()).then_some(value));
let reasoning = message.thinking.and_then(|value| (!value.is_empty()).then_some(value));
let tool_calls = Self::convert_tool_calls(message.tool_calls)?;
let native_reasoning_details = message.reasoning_details.filter(|d| !d.is_empty());
(content, reasoning, tool_calls, native_reasoning_details)
} else {
(None, None, None, None)
};
let reasoning = reasoning.or_else(|| {
native_reasoning_details
.as_deref()
.and_then(extract_reasoning_text_from_detail_values)
});
let mut reasoning_details = native_reasoning_details.as_deref().and_then(serialize_reasoning_detail_values);
let (final_reasoning, final_content) = if reasoning.is_none() {
if let Some(ref content_str) = content {
let (reasoning_parts, cleaned_content) = crate::utils::extract_reasoning_content(content_str);
if reasoning_parts.is_empty() {
(None, content)
} else {
super::common::preserve_interleaved_content_in_reasoning_details(
&mut reasoning_details,
content_str,
);
(Some(reasoning_parts.join("\n\n")), cleaned_content.or(content))
}
} else {
(None, content)
}
} else {
(reasoning, content)
};
Ok(Self::build_response(
final_content,
tool_calls,
final_reasoning,
reasoning_details,
model,
parsed.done_reason.as_deref(),
parsed.prompt_eval_count,
parsed.eval_count,
))
}
fn authorized_post_with_key(http_client: &HttpClient, url: &str, api_key: Option<&str>) -> reqwest::RequestBuilder {
let builder = http_client.post(url.to_string());
if let Some(value) = api_key {
builder.bearer_auth(value)
} else {
builder
}
}
async fn request_non_stream_response(
http_client: &HttpClient,
url: &str,
api_key: Option<&str>,
payload: &OllamaChatRequest,
model: String,
) -> Result<LLMResponse, LLMError> {
let response = Self::authorized_post_with_key(http_client, url, api_key)
.json(payload)
.send()
.await
.map_err(|e| format_network_error("Ollama", &e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
let error_message =
Self::extract_error(&body).unwrap_or_else(|| format!("Ollama request failed ({status}): {body}"));
return Err(LLMError::Provider { message: error_message, metadata: None });
}
let parsed = response
.json::<OllamaChatResponse>()
.await
.map_err(|e| format_parse_error("Ollama", &e))?;
Self::response_from_chat_payload(model, parsed)
}
fn extract_error(body: &str) -> Option<String> {
serde_json::from_str::<OllamaErrorResponse>(body)
.ok()
.and_then(|resp| resp.error)
}
}
#[derive(Debug, Serialize)]
struct OllamaChatRequest {
model: String,
messages: Vec<OllamaChatMessage>,
stream: bool,
#[serde(skip_serializing_if = "Option::is_none")]
format: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
options: Option<OllamaChatOptions>,
#[serde(skip_serializing_if = "Option::is_none")]
tools: Option<Vec<ToolDefinition>>,
#[serde(skip_serializing_if = "Option::is_none")]
think: Option<Value>,
}
#[derive(Debug, Serialize)]
struct OllamaChatMessage {
role: String,
#[serde(skip_serializing_if = "Option::is_none")]
content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
thinking: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
images: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_calls: Option<Vec<OllamaToolCall>>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_call_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
tool_name: Option<String>,
}
#[derive(Debug, Serialize)]
struct OllamaChatOptions {
#[serde(skip_serializing_if = "Option::is_none")]
temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
num_predict: Option<u32>,
}
#[derive(Debug, Serialize)]
struct OllamaToolCall {
#[serde(rename = "type")]
call_type: String,
function: OllamaToolFunctionCall,
}
#[derive(Debug, Serialize)]
struct OllamaToolFunctionCall {
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
arguments: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
index: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct OllamaChatResponse {
message: Option<OllamaResponseMessage>,
#[serde(default)]
done: bool,
#[serde(default)]
done_reason: Option<String>,
#[serde(default)]
prompt_eval_count: Option<u32>,
#[serde(default)]
eval_count: Option<u32>,
#[serde(default)]
error: Option<String>,
}
#[derive(Debug, Deserialize)]
struct OllamaResponseMessage {
#[serde(default)]
#[expect(dead_code)]
role: Option<String>,
#[serde(default)]
content: Option<String>,
#[serde(default)]
thinking: Option<String>,
#[serde(default)]
reasoning_details: Option<Vec<Value>>,
#[serde(default)]
tool_calls: Option<Vec<OllamaResponseToolCall>>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
struct OllamaResponseToolCall {
#[serde(default)]
#[serde(rename = "type")]
call_type: Option<String>,
#[serde(default)]
function: Option<OllamaResponseFunctionCall>,
}
#[derive(Debug, Deserialize, Serialize, Clone)]
struct OllamaResponseFunctionCall {
#[serde(default)]
name: Option<String>,
#[serde(default)]
arguments: Option<Value>,
#[serde(default)]
index: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct OllamaErrorResponse {
error: Option<String>,
}
fn parse_stream_chunk(line: &str) -> Result<OllamaChatResponse, LLMError> {
serde_json::from_str::<OllamaChatResponse>(line).map_err(|err| LLMError::Provider {
message: format!("Failed to parse Ollama stream chunk: {err}"),
metadata: None,
})
}
#[async_trait]
impl LLMProvider for OllamaProvider {
fn name(&self) -> &str {
"ollama"
}
fn supports_streaming(&self) -> bool {
true
}
fn supports_tools(&self, _model: &str) -> bool {
true
}
fn supports_reasoning(&self, model: &str) -> bool {
models::ollama::REASONING_MODELS.contains(&model)
|| self
.model_behavior
.as_ref()
.and_then(|b| b.model_supports_reasoning)
.unwrap_or(false)
}
fn supports_reasoning_effort(&self, model: &str) -> bool {
models::ollama::REASONING_LEVEL_MODELS.contains(&model)
|| self
.model_behavior
.as_ref()
.and_then(|b| b.model_supports_reasoning_effort)
.unwrap_or(false)
}
async fn generate(&self, mut request: LLMRequest) -> Result<LLMResponse, LLMError> {
self.validate_request(&request)?;
if request.model.is_empty() {
request.model = self.model.clone();
}
let resolved = self.ensure_ready(&request.model).await?;
request.model = resolved;
let model = request.model.clone();
let payload = self.build_payload(&request, false)?;
let url = self.chat_url();
Self::request_non_stream_response(&self.http_client, &url, self.api_key.as_deref(), &payload, model).await
}
async fn stream(&self, mut request: LLMRequest) -> Result<LLMStream, LLMError> {
self.validate_request(&request)?;
if request.model.is_empty() {
request.model = self.model.clone();
}
let resolved = self.ensure_ready(&request.model).await?;
request.model = resolved;
let model = request.model.clone();
let payload = self.build_payload(&request, true)?;
let fallback_payload = self.build_payload(&request, false)?;
let url = self.chat_url();
let response = self
.authorized_post(url.clone())
.header(reqwest::header::ACCEPT_ENCODING, "identity")
.json(&payload)
.send()
.await
.map_err(|e| format_network_error("Ollama", &e))?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
let error_message = Self::extract_error(&body)
.unwrap_or_else(|| format!("Ollama streaming request failed ({status}): {body}"));
return Err(LLMError::Provider { message: error_message, metadata: None });
}
let byte_stream = response.bytes_stream();
let mut buffer: Vec<u8> = Vec::new();
let mut aggregator = crate::providers::shared::StreamAggregator::new(model.clone());
let fallback_http_client = self.http_client.clone();
let fallback_api_key = self.api_key.clone();
let fallback_model = model.clone();
let fallback_url = url.clone();
let any_interleaved = request
.messages
.iter()
.any(|msg| assistant_interleaved_history_text(msg, &request.model).is_some());
let stream = try_stream! {
let mut prompt_tokens: Option<u32> = None;
let mut completion_tokens: Option<u32> = None;
let mut finish_reason: Option<String> = None;
let mut completed = false;
let mut saw_stream_chunk = false;
futures::pin_mut!(byte_stream);
while let Some(chunk_result) = byte_stream.next().await {
let chunk = match chunk_result {
Ok(chunk) => {
saw_stream_chunk = true;
chunk
}
Err(err) if !saw_stream_chunk => {
tracing::warn!(
model = %fallback_model,
url = %fallback_url,
error = %err,
"Ollama stream failed before first chunk; retrying once as non-stream response"
);
let fallback_response = Self::request_non_stream_response(
&fallback_http_client,
&fallback_url,
fallback_api_key.as_deref(),
&fallback_payload,
fallback_model.clone(),
).await?;
yield LLMStreamEvent::Completed { response: Box::new(fallback_response) };
return;
}
Err(err) => Err(format_network_error("Ollama", &err))?,
};
buffer.extend_from_slice(&chunk);
while let Some(pos) = buffer.iter().position(|b| *b == b'\n') {
let line_bytes: Vec<u8> = buffer.drain(..=pos).collect();
let line = std::str::from_utf8(&line_bytes)
.map_err(|err| LLMError::Provider {
message: format!("Invalid UTF-8 in Ollama stream: {err}"),
metadata: None,
})?;
let line = line.trim();
if line.is_empty() {
continue;
}
let parsed = parse_stream_chunk(line)?;
if let Some(error) = parsed.error {
Err(LLMError::Provider {
message: error,
metadata: None,
})?;
}
if let Some(message) = parsed.message {
if let Some(reasoning_details) = message.reasoning_details.as_deref() {
aggregator.set_reasoning_details(reasoning_details);
}
let has_explicit_thinking = message
.thinking
.as_ref()
.map(|v| !v.is_empty())
.unwrap_or(false);
if let Some(thinking) = message.thinking
&& let Some(delta) = aggregator.handle_reasoning(&thinking) {
yield LLMStreamEvent::Reasoning { delta };
}
if let Some(content) = message.content {
for event in aggregator.handle_content(&content) {
match &event {
LLMStreamEvent::Reasoning { .. }
if has_explicit_thinking || any_interleaved =>
{
}
_ => yield event,
}
}
}
if let Some(tool_calls) = message.tool_calls {
let tool_calls_json: Vec<Value> = tool_calls
.into_iter()
.map(|tc| serde_json::to_value(tc).unwrap_or(Value::Null))
.filter(|v| !v.is_null())
.collect();
aggregator.handle_tool_calls(&tool_calls_json);
}
}
if parsed.done {
prompt_tokens = parsed.prompt_eval_count;
completion_tokens = parsed.eval_count;
finish_reason = parsed.done_reason;
completed = true;
}
}
if completed {
break;
}
}
if !completed {
Err(LLMError::Provider {
message: "Ollama stream ended without completion signal".to_string(),
metadata: None,
})?;
}
let mut response = aggregator.finalize();
if let Some(pt) = prompt_tokens {
let mut usage = response.usage.unwrap_or_default();
usage.prompt_tokens = pt;
if let Some(ct) = completion_tokens {
usage.completion_tokens = ct;
usage.total_tokens = pt + ct;
}
response.usage = Some(usage);
}
if let Some(fr) = finish_reason {
response.finish_reason = crate::providers::common::map_finish_reason_common(&fr);
}
if response.reasoning.is_none()
&& let Some(details) = response.reasoning_details.as_ref()
{
response.reasoning = extract_reasoning_text_from_serialized_details(details);
}
yield LLMStreamEvent::Completed { response: Box::new(response) };
};
Ok(Box::pin(stream))
}
fn supported_models(&self) -> Vec<String> {
models::ollama::SUPPORTED_MODELS.iter().map(|model| model.to_string()).collect()
}
fn validate_request(&self, request: &LLMRequest) -> Result<(), LLMError> {
if let Some(tool_choice) = &request.tool_choice {
match tool_choice {
ToolChoice::Auto | ToolChoice::None | ToolChoice::AllowedTools(_) => {}
_ => {
return Err(LLMError::InvalidRequest {
message: "Ollama does not support explicit tool_choice overrides".to_string(),
metadata: None,
});
}
}
}
if request.parallel_tool_calls.is_some() || request.parallel_tool_config.is_some() {
return Err(LLMError::InvalidRequest {
message: "Ollama does not support parallel tool configuration".to_string(),
metadata: None,
});
}
for message in request.messages.iter() {
if matches!(message.role, MessageRole::Tool) && message.tool_call_id.is_none() {
return Err(LLMError::InvalidRequest {
message: "Ollama tool responses must include tool_call_id".to_string(),
metadata: None,
});
}
}
Ok(())
}
}
#[async_trait]
impl LLMClient for OllamaProvider {
async fn generate(&mut self, prompt: &str) -> Result<LLMResponse, LLMError> {
let mut request = self.parse_client_prompt(prompt);
if request.model.is_empty() {
request.model = self.model.clone();
}
Ok(LLMProvider::generate(self, request).await?)
}
fn model_id(&self) -> &str {
&self.model
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::provider::{ContentPart, Message, MessageContent};
use serde_json::json;
use vtcode_config::types::ReasoningEffortLevel;
fn test_provider() -> OllamaProvider {
OllamaProvider::from_config(
None,
Some("test-model".to_string()),
Some("http://localhost".to_string()),
None,
None,
None,
None,
)
}
#[test]
fn convert_tool_calls_fabricates_unique_ids_when_missing() {
let make_call = |name: &str| OllamaResponseToolCall {
call_type: Some("function".to_string()),
function: Some(OllamaResponseFunctionCall {
name: Some(name.to_string()),
arguments: Some(json!({})),
index: None,
}),
};
let first = OllamaProvider::convert_tool_calls(Some(vec![make_call("foo"), make_call("bar")]))
.expect("conversion should succeed")
.expect("calls expected");
let second = OllamaProvider::convert_tool_calls(Some(vec![make_call("foo")]))
.expect("conversion should succeed")
.expect("calls expected");
let ids: Vec<&str> = first.iter().chain(second.iter()).map(|call| call.id.as_str()).collect();
let unique: std::collections::HashSet<&str> = ids.iter().copied().collect();
assert_eq!(unique.len(), ids.len(), "fabricated ids must be unique within and across responses");
for id in ids {
assert!(id.starts_with("call_"));
}
}
#[test]
fn build_payload_includes_images() {
let provider = test_provider();
let parts = vec![
ContentPart::text("see ".to_string()),
ContentPart::image("BASE64DATA".to_string(), "image/png".to_string()),
];
let request = LLMRequest {
model: "test-model".to_string(),
messages: vec![Message::user_with_parts(parts)].into(),
..Default::default()
};
let payload = provider.build_payload(&request, false).unwrap();
assert_eq!(payload.messages.len(), 1);
let message = &payload.messages[0];
assert_eq!(message.content.as_deref(), Some("see "));
assert_eq!(message.images.as_ref(), Some(&vec!["BASE64DATA".to_string()]));
}
#[test]
fn build_payload_omits_images_when_none_present() {
let provider = test_provider();
let content = MessageContent::text("no images".to_string());
let request = LLMRequest {
model: "test-model".to_string(),
messages: vec![Message::user(content.as_text().into_owned())].into(),
..Default::default()
};
let payload = provider.build_payload(&request, false).unwrap();
assert_eq!(payload.messages.len(), 1);
let message = &payload.messages[0];
assert_eq!(message.content.as_deref(), Some("no images"));
assert!(message.images.is_none());
}
#[test]
fn build_payload_minimax_tool_followup_omits_tool_call_id() {
let provider = test_provider();
let tool_call_id = "direct_exec_command_1".to_string();
let request = LLMRequest {
model: models::ollama::MINIMAX_M27_CLOUD.to_string(),
messages: vec![
Message::assistant_with_tools(
String::new(),
vec![ToolCall::function(
tool_call_id.clone(),
"exec_command".to_string(),
"{\"command\":\"cargo fmt\"}".to_string(),
)],
),
Message::tool_response(tool_call_id, "{\"output\":\"\",\"exit_code\":0}".to_string()),
]
.into(),
reasoning_effort: Some(ReasoningEffortLevel::Low),
..Default::default()
};
let payload = provider.build_payload(&request, false).unwrap();
assert_eq!(payload.messages.len(), 2);
assert_eq!(payload.messages[1].role, "tool");
assert_eq!(payload.messages[1].tool_name.as_deref(), Some("exec_command"));
assert!(payload.messages[1].tool_call_id.is_none());
assert!(payload.think.is_none());
}
#[test]
fn build_payload_non_minimax_tool_followup_keeps_tool_call_id() {
let provider = test_provider();
let tool_call_id = "direct_exec_command_1".to_string();
let request = LLMRequest {
model: models::ollama::GPT_OSS_20B_CLOUD.to_string(),
messages: vec![
Message::assistant_with_tools(
String::new(),
vec![ToolCall::function(
tool_call_id.clone(),
"exec_command".to_string(),
"{\"command\":\"cargo fmt\"}".to_string(),
)],
),
Message::tool_response(tool_call_id.clone(), "{\"output\":\"\",\"exit_code\":0}".to_string()),
]
.into(),
reasoning_effort: Some(ReasoningEffortLevel::Low),
..Default::default()
};
let payload = provider.build_payload(&request, false).unwrap();
assert_eq!(payload.messages.len(), 2);
assert_eq!(payload.messages[1].role, "tool");
assert_eq!(payload.messages[1].tool_name.as_deref(), Some("exec_command"));
assert_eq!(payload.messages[1].tool_call_id.as_deref(), Some(tool_call_id.as_str()));
assert_eq!(payload.think, Some(Value::String("low".to_string())));
}
#[test]
fn build_payload_hoists_history_system_directives_into_system_prompt() {
let provider = test_provider();
let request = LLMRequest {
model: models::ollama::MINIMAX_M27_CLOUD.to_string(),
system_prompt: Some(std::sync::Arc::new(
"stable system instructions".to_string(),
)),
messages: vec![
Message::user("explore architecture".to_string()),
Message::system(
"Previous turn already completed tool execution. Reuse the latest tool outputs in history instead of rerunning the same exploration.".to_string(),
),
].into(),
..Default::default()
};
let payload = provider.build_payload(&request, false).unwrap();
assert_eq!(payload.messages.len(), 2);
assert_eq!(payload.messages[0].role, "system");
assert!(
payload.messages[0]
.content
.as_deref()
.unwrap_or("")
.contains("stable system instructions")
);
assert!(
payload.messages[0]
.content
.as_deref()
.unwrap_or("")
.contains("[History Directives]")
);
assert!(
payload.messages[0]
.content
.as_deref()
.unwrap_or("")
.contains("Previous turn already completed tool execution")
);
assert_eq!(payload.messages[1].role, "user");
assert_eq!(payload.messages[1].content.as_deref(), Some("explore architecture"));
}
#[test]
fn build_payload_promotes_history_system_directive_without_base_system_prompt() {
let provider = test_provider();
let request = LLMRequest {
model: models::ollama::MINIMAX_M27_CLOUD.to_string(),
messages: vec![
Message::system(
"Repeated read-only exploration hit the per-turn family cap. Scheduling a final recovery pass without more tools.".to_string(),
),
Message::user("summarize the architecture".to_string()),
].into(),
..Default::default()
};
let payload = provider.build_payload(&request, false).unwrap();
assert_eq!(payload.messages.len(), 2);
assert_eq!(payload.messages[0].role, "system");
assert!(
payload.messages[0]
.content
.as_deref()
.unwrap_or("")
.contains("[History Directives]")
);
assert!(
payload.messages[0]
.content
.as_deref()
.unwrap_or("")
.contains("Repeated read-only exploration hit the per-turn family cap")
);
assert_eq!(payload.messages[1].role, "user");
}
#[test]
fn build_payload_recovers_balanced_prefix_from_malformed_history_tool_arguments() {
let provider = test_provider();
let request = LLMRequest {
model: "test-model".to_string(),
messages: vec![Message::assistant_with_tools(
String::new(),
vec![ToolCall::function(
"tool_call_0".to_string(),
"apply_patch".to_string(),
"{\"action\":\"read\",\"path\":\"docs/ARCHITECTURE.md\",\"offset\":1,\"limit\":100}{\"action\":\"read\",\"path\":\"README.md\"}"
.to_string(),
)],
)].into(),
..Default::default()
};
let payload = provider
.build_payload(&request, false)
.expect("payload should recover malformed history tool arguments");
let tool_calls = payload.messages[0].tool_calls.as_ref().expect("tool calls should be present");
assert_eq!(tool_calls.len(), 1);
assert_eq!(
tool_calls[0].function.arguments,
Some(json!({
"action": "read",
"path": "docs/ARCHITECTURE.md",
"offset": 1,
"limit": 100
}))
);
}
#[test]
fn build_payload_rehydrates_glm_interleaved_history_into_content() {
let provider = test_provider();
let request = LLMRequest {
model: models::ollama::GLM_5_1_CLOUD.to_string(),
messages: vec![Message::assistant("done".to_string()).with_reasoning(Some("trace".to_string()))].into(),
..Default::default()
};
let payload = provider.build_payload(&request, false).unwrap();
assert_eq!(payload.messages[0].content.as_deref(), Some("<think>trace</think>done"));
assert!(payload.messages[0].thinking.is_none());
}
#[test]
fn build_payload_replays_assistant_reasoning_as_ollama_thinking() {
let provider = test_provider();
let request = LLMRequest {
model: models::ollama::GPT_OSS_20B.to_string(),
messages: vec![
Message::assistant("need a tool".to_string()).with_reasoning(Some("reasoning trace".to_string())),
]
.into(),
..Default::default()
};
let payload = provider.build_payload(&request, false).unwrap();
assert_eq!(payload.messages[0].content.as_deref(), Some("need a tool"));
assert_eq!(payload.messages[0].thinking.as_deref(), Some("reasoning trace"));
}
#[test]
fn build_payload_includes_apply_patch_as_normal_tool() {
let provider = test_provider();
let request = LLMRequest {
model: "test-model".to_string(),
messages: vec![Message::user("patch this file".to_string())].into(),
tools: Some(std::sync::Arc::new(vec![ToolDefinition::apply_patch("Apply VT Code patches".to_string())])),
..Default::default()
};
let payload = provider.build_payload(&request, false).unwrap();
let tools = payload.tools.expect("tools should be present");
assert_eq!(tools.len(), 1);
assert_eq!(tools[0].function_name(), "apply_patch");
}
#[test]
fn response_payload_preserves_reasoning_details() {
let parsed = OllamaChatResponse {
message: Some(OllamaResponseMessage {
role: Some("assistant".to_string()),
content: Some("answer".to_string()),
thinking: None,
reasoning_details: Some(vec![json!({
"type": "reasoning.text",
"text": "step one"
})]),
tool_calls: None,
}),
done: true,
done_reason: Some("stop".to_string()),
prompt_eval_count: Some(1),
eval_count: Some(2),
error: None,
};
let response = OllamaProvider::response_from_chat_payload("test-model".to_string(), parsed)
.expect("response should parse");
assert_eq!(response.reasoning.as_deref(), Some("step one"));
assert!(response.reasoning_details.is_some());
let first_detail = response
.reasoning_details
.as_ref()
.and_then(|details| details.first())
.expect("reasoning detail should exist");
let parsed_detail: Value = serde_json::from_str(first_detail).expect("reasoning detail should be json");
assert_eq!(parsed_detail["type"], "reasoning.text");
}
#[test]
fn tags_response_accepts_partial_model_summaries() {
let parsed: OllamaTagsResponse = serde_json::from_value(json!({
"models": [
{ "model": "qwen3:8b" }
]
}))
.expect("partial model summaries should parse");
let names: Vec<String> = parsed
.models
.into_iter()
.filter_map(|model| model.name.or(model.model))
.collect();
assert_eq!(names, vec!["qwen3:8b".to_string()]);
}
}