use super::api_client::{ApiClient, AuthMethod}; use super::base::{ ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata, ProviderUsage, Usage, }; use super::errors::ProviderError; use super::openai_compatible::{ handle_response_openai_compat, handle_status_openai_compat, stream_openai_compat, }; use super::retry::ProviderRetry; use super::utils::{get_model, ImageFormat, RequestLog}; use crate::config::declarative_providers::DeclarativeProviderConfig; use crate::config::GooseMode; use crate::conversation::message::Message; use crate::conversation::Conversation; use crate::model::ModelConfig; use crate::providers::formats::openai::{create_request, get_usage, response_to_message}; use crate::utils::safe_truncate; use anyhow::Result; use async_trait::async_trait; use futures::future::BoxFuture; use regex::Regex; use rmcp::model::Tool; use serde_json::Value; use std::time::Duration; use url::Url; const OLLAMA_PROVIDER_NAME: &str = "ollama"; pub const OLLAMA_HOST: &str = "localhost"; pub const OLLAMA_TIMEOUT: u64 = 600; pub const OLLAMA_DEFAULT_PORT: u16 = 11434; pub const OLLAMA_DEFAULT_MODEL: &str = "qwen3"; pub const OLLAMA_KNOWN_MODELS: &[&str] = &[ OLLAMA_DEFAULT_MODEL, "qwen3-coder:30b", "qwen3-coder:480b-cloud", ]; pub const OLLAMA_DOC_URL: &str = "https://ollama.com/library"; #[derive(serde::Serialize)] pub struct OllamaProvider { #[serde(skip)] api_client: ApiClient, model: ModelConfig, supports_streaming: bool, name: String, } impl OllamaProvider { pub async fn from_env(model: ModelConfig) -> Result { let config = crate::config::Config::global(); let host: String = config .get_param("OLLAMA_HOST") .unwrap_or_else(|_| OLLAMA_HOST.to_string()); let timeout: Duration = Duration::from_secs(config.get_param("OLLAMA_TIMEOUT").unwrap_or(OLLAMA_TIMEOUT)); let base = if host.starts_with("http://") || host.starts_with("https://") { host.clone() } else { format!("http://{}", host) }; let mut base_url = Url::parse(&base).map_err(|e| anyhow::anyhow!("Invalid base URL: {e}"))?; let explicit_port = host.contains(':'); let is_localhost = host == "localhost" || host == "127.0.0.1" || host == "::1"; if base_url.port().is_none() && !explicit_port && !host.starts_with("http") && is_localhost { base_url .set_port(Some(OLLAMA_DEFAULT_PORT)) .map_err(|_| anyhow::anyhow!("Failed to set default port"))?; } let api_client = ApiClient::with_timeout(base_url.to_string(), AuthMethod::NoAuth, timeout)?; Ok(Self { api_client, model, supports_streaming: true, name: OLLAMA_PROVIDER_NAME.to_string(), }) } pub fn from_custom_config( model: ModelConfig, config: DeclarativeProviderConfig, ) -> Result { let timeout = Duration::from_secs(config.timeout_seconds.unwrap_or(OLLAMA_TIMEOUT)); let base = if config.base_url.starts_with("http://") || config.base_url.starts_with("https://") { config.base_url.clone() } else { format!("http://{}", config.base_url) }; let mut base_url = Url::parse(&base) .map_err(|e| anyhow::anyhow!("Invalid base URL '{}': {}", config.base_url, e))?; let explicit_default_port = config.base_url.ends_with(":80") || config.base_url.ends_with(":443"); let is_https = base_url.scheme() == "https"; if base_url.port().is_none() && !explicit_default_port && !is_https { base_url .set_port(Some(OLLAMA_DEFAULT_PORT)) .map_err(|_| anyhow::anyhow!("Failed to set default port"))?; } let mut api_client = ApiClient::with_timeout(base_url.to_string(), AuthMethod::NoAuth, timeout)?; if let Some(headers) = &config.headers { let mut header_map = reqwest::header::HeaderMap::new(); for (key, value) in headers { let header_name = reqwest::header::HeaderName::from_bytes(key.as_bytes())?; let header_value = reqwest::header::HeaderValue::from_str(value)?; header_map.insert(header_name, header_value); } api_client = api_client.with_headers(header_map)?; } Ok(Self { api_client, model, supports_streaming: config.supports_streaming.unwrap_or(true), name: config.name.clone(), }) } async fn post( &self, session_id: Option<&str>, payload: &Value, ) -> Result { let response = self .api_client .response_post(session_id, "v1/chat/completions", payload) .await?; handle_response_openai_compat(response).await } } impl ProviderDef for OllamaProvider { type Provider = Self; fn metadata() -> ProviderMetadata { ProviderMetadata::new( OLLAMA_PROVIDER_NAME, "Ollama", "Local open source models", OLLAMA_DEFAULT_MODEL, OLLAMA_KNOWN_MODELS.to_vec(), OLLAMA_DOC_URL, vec![ ConfigKey::new("OLLAMA_HOST", true, false, Some(OLLAMA_HOST)), ConfigKey::new( "OLLAMA_TIMEOUT", false, false, Some(&(OLLAMA_TIMEOUT.to_string())), ), ], ) } fn from_env(model: ModelConfig) -> BoxFuture<'static, Result> { Box::pin(Self::from_env(model)) } } #[async_trait] impl Provider for OllamaProvider { fn get_name(&self) -> &str { &self.name } fn get_model_config(&self) -> ModelConfig { self.model.clone() } #[tracing::instrument( skip(self, model_config, system, messages, tools), fields(model_config, input, output, input_tokens, output_tokens, total_tokens) )] async fn complete_with_model( &self, session_id: Option<&str>, model_config: &ModelConfig, system: &str, messages: &[Message], tools: &[Tool], ) -> Result<(Message, ProviderUsage), ProviderError> { let config = crate::config::Config::global(); let goose_mode = config.get_goose_mode().unwrap_or(GooseMode::Auto); let filtered_tools = if goose_mode == GooseMode::Chat { &[] } else { tools }; let payload = create_request( model_config, system, messages, filtered_tools, &ImageFormat::OpenAi, false, )?; let mut log = RequestLog::start(model_config, &payload)?; let response = self .with_retry(|| async { let payload_clone = payload.clone(); self.post(session_id, &payload_clone).await }) .await .inspect_err(|e| { let _ = log.error(e); })?; let message = response_to_message(&response)?; let usage = response.get("usage").map(get_usage).unwrap_or_else(|| { tracing::debug!("Failed to get usage data"); Usage::default() }); let response_model = get_model(&response); log.write(&response, Some(&usage))?; Ok((message, ProviderUsage::new(response_model, usage))) } async fn generate_session_name( &self, session_id: &str, messages: &Conversation, ) -> Result { let context = self.get_initial_user_messages(messages); let message = Message::user().with_text(self.create_session_name_prompt(&context)); let result = self .complete( session_id, "You are a title generator. Output only the requested title of 4 words or less, with no additional text, reasoning, or explanations.", &[message], &[], ) .await?; let mut description = result.0.as_concat_text(); description = Self::filter_reasoning_tokens(&description); Ok(safe_truncate(&description, 100)) } fn supports_streaming(&self) -> bool { self.supports_streaming } async fn stream( &self, session_id: &str, system: &str, messages: &[Message], tools: &[Tool], ) -> Result { let config = crate::config::Config::global(); let goose_mode = config.get_goose_mode().unwrap_or(GooseMode::Auto); let filtered_tools = if goose_mode == GooseMode::Chat { &[] } else { tools }; let payload = create_request( &self.model, system, messages, filtered_tools, &ImageFormat::OpenAi, true, )?; let mut log = RequestLog::start(&self.model, &payload)?; let response = self .with_retry(|| async { let resp = self .api_client .response_post(Some(session_id), "v1/chat/completions", &payload) .await?; handle_status_openai_compat(resp).await }) .await .inspect_err(|e| { let _ = log.error(e); })?; stream_openai_compat(response, log) } async fn fetch_supported_models(&self) -> Result>, ProviderError> { let response = self .api_client .request(None, "api/tags") .response_get() .await .map_err(|e| ProviderError::RequestFailed(format!("Failed to fetch models: {}", e)))?; if !response.status().is_success() { return Err(ProviderError::RequestFailed(format!( "Failed to fetch models: HTTP {}", response.status() ))); } let json_response = response.json::().await.map_err(|e| { ProviderError::RequestFailed(format!("Failed to parse response: {}", e)) })?; let models = json_response .get("models") .and_then(|m| m.as_array()) .ok_or_else(|| { ProviderError::RequestFailed("No models array in response".to_string()) })?; let mut model_names: Vec = models .iter() .filter_map(|model| model.get("name").and_then(|n| n.as_str()).map(String::from)) .collect(); model_names.sort(); Ok(Some(model_names)) } } impl OllamaProvider { fn filter_reasoning_tokens(text: &str) -> String { let mut filtered = text.to_string(); let reasoning_patterns = [ r".*?", r".*?", r"Let me think.*?\n", r"I need to.*?\n", r"First, I.*?\n", r"Okay, .*?\n", r"So, .*?\n", r"Well, .*?\n", r"Hmm, .*?\n", r"Actually, .*?\n", r"Based on.*?I think", r"Looking at.*?I would say", ]; for pattern in reasoning_patterns { if let Ok(re) = Regex::new(pattern) { filtered = re.replace_all(&filtered, "").to_string(); } } filtered = filtered .replace("", "") .replace("", "") .replace("", "") .replace("", ""); filtered = filtered .lines() .map(|line| line.trim()) .filter(|line| !line.is_empty()) .collect::>() .join(" "); filtered } }