move ollama provider into goose-providers (#9986)
Some checks are pending
Canary / Prepare Version (push) Waiting to run
Canary / build-cli (push) Blocked by required conditions
Canary / Upload Install Script (push) Blocked by required conditions
Canary / bundle-desktop (push) Blocked by required conditions
Canary / bundle-desktop-intel (push) Blocked by required conditions
Canary / bundle-desktop-linux (push) Blocked by required conditions
Canary / bundle-desktop-windows (push) Blocked by required conditions
Canary / bundle-desktop-windows-cuda (push) Blocked by required conditions
Canary / Release (push) Blocked by required conditions
Unused Dependencies / machete (push) Waiting to run
CI / changes (push) Waiting to run
CI / Check Rust Code Format (push) Blocked by required conditions
CI / Build and Test Rust Project (push) Blocked by required conditions
CI / Build Rust Project on Windows (push) Waiting to run
CI / Check MSRV (push) Blocked by required conditions
CI / Lint Rust Code (push) Blocked by required conditions
CI / Check Generated Schemas are Up-to-Date (push) Blocked by required conditions
CI / Test and Lint Electron Desktop App (push) Blocked by required conditions
Create Minor Release PR / release (push) Blocked by required conditions
Create Minor Release PR / check-version-bump-pr (push) Waiting to run
Live Provider Tests / check-fork (push) Waiting to run
Live Provider Tests / changes (push) Blocked by required conditions
Live Provider Tests / Build Binary (push) Blocked by required conditions
Live Provider Tests / Smoke Tests (push) Blocked by required conditions
Live Provider Tests / Smoke Tests (Code Execution) (push) Blocked by required conditions
Live Provider Tests / Compaction Tests (push) Blocked by required conditions
Live Provider Tests / goose server HTTP integration tests (push) Blocked by required conditions
Publish Docker Image / docker (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run

This commit is contained in:
Jack Amadeo 2026-06-30 10:28:45 -07:00 committed by GitHub
parent 808b8ca9ad
commit b806204887
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 752 additions and 746 deletions

View file

@ -1,3 +1,4 @@
pub mod anthropic;
pub mod ollama;
pub mod openai;
pub mod openai_responses;

View file

@ -10,20 +10,20 @@
//! - qwen3-coder-32b
use crate::conversation::message::{Message, MessageContent};
use async_stream::try_stream;
use chrono;
use futures::Stream;
use goose_providers::{
use crate::{
conversation::token_usage::ProviderUsage,
formats::openai::{self, is_valid_function_name},
};
use async_stream::try_stream;
use chrono;
use futures::Stream;
use regex::Regex;
use rmcp::model::{object, CallToolRequestParams, ErrorCode, ErrorData, Role};
use serde_json::Value;
use std::borrow::Cow;
use uuid::Uuid;
pub use goose_providers::formats::openai::{
pub use crate::formats::openai::{
create_request, format_messages, format_tools, get_usage, validate_tool_schemas,
};

View file

@ -11,6 +11,7 @@ pub mod images;
pub mod json;
pub(crate) mod mcp_utils;
pub mod model;
pub mod ollama;
pub mod openai;
pub mod openai_compatible;
pub mod permission;

View file

@ -0,0 +1,453 @@
use super::api_client::ApiClient;
use super::base::{ConfigKey, MessageStream, Provider, ProviderMetadata};
use super::openai_compatible::handle_status;
use super::retry::{ProviderRetry, RetryConfig};
use crate::base::ProviderDescriptor;
use crate::conversation::message::Message;
use crate::errors::ProviderError;
use crate::formats::ollama::{create_request, response_to_streaming_message_ollama};
use crate::images::ImageFormat;
use crate::model::ModelConfig;
use crate::request_log::{start_log, LoggerHandleExt, RequestLogHandle};
use anyhow::{Error, Result};
use async_stream::try_stream;
use async_trait::async_trait;
use futures::TryStreamExt;
use reqwest::Response;
use rmcp::model::Tool;
use serde_json::{json, Value};
use std::time::Duration;
use tokio::pin;
use tokio_stream::StreamExt;
use tokio_util::codec::{FramedRead, LinesCodec};
use tokio_util::io::StreamReader;
pub 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-vl",
"qwen3-coder:30b",
"qwen3-coder:480b-cloud",
];
pub const OLLAMA_DOC_URL: &str = "https://ollama.com/library";
// Ollama-specific retry config: large models can take 30-120s to load into memory,
// during which Ollama returns 500 errors. Use more retries with gradual backoff
// to wait for the model to become ready.
const OLLAMA_MAX_RETRIES: usize = 10;
const OLLAMA_INITIAL_RETRY_INTERVAL_MS: u64 = 2000;
const OLLAMA_BACKOFF_MULTIPLIER: f64 = 1.5;
const OLLAMA_MAX_RETRY_INTERVAL_MS: u64 = 15_000;
/// Provider settings resolved from `config::Config` at construction time.
///
/// All values that the Ollama provider reads out of the global config are
/// resolved once, up front, and carried here. This keeps the streaming hot
/// path free of config lookups and makes the provider's config dependencies
/// explicit at the construction boundary.
#[derive(Debug, Clone, serde::Serialize)]
pub struct OllamaOptions {
/// Explicit context window override from `GOOSE_INPUT_LIMIT`.
/// `None` when unset, zero, or invalid; the model's context limit is then
/// used as the fallback.
pub input_limit: Option<usize>,
/// Whether to keep `stream_options` in the request (`OLLAMA_STREAM_USAGE`,
/// default `true`).
pub stream_usage: bool,
/// Per-chunk stream timeout in seconds, resolved from
/// `OLLAMA_STREAM_TIMEOUT` > `GOOSE_STREAM_TIMEOUT` > `OLLAMA_TIMEOUT` >
/// default (120s).
pub chunk_timeout_secs: u64,
}
impl Default for OllamaOptions {
fn default() -> Self {
Self {
input_limit: None,
stream_usage: true,
chunk_timeout_secs: OLLAMA_DEFAULT_CHUNK_TIMEOUT_SECS,
}
}
}
#[derive(serde::Serialize)]
pub struct OllamaProvider {
#[serde(skip)]
api_client: ApiClient,
name: String,
skip_canonical_filtering: bool,
options: OllamaOptions,
}
impl OllamaProvider {
pub fn new(
api_client: ApiClient,
name: String,
skip_canonical_filtering: bool,
options: OllamaOptions,
) -> Self {
Self {
api_client,
name,
skip_canonical_filtering,
options,
}
}
}
fn resolve_ollama_num_ctx(options: &OllamaOptions, model_config: &ModelConfig) -> Option<usize> {
options.input_limit.or(model_config.context_limit)
}
fn apply_ollama_options(payload: &mut Value, options: &OllamaOptions, model_config: &ModelConfig) {
if let Some(obj) = payload.as_object_mut() {
// Gate stream_options behind OLLAMA_STREAM_USAGE (default: true).
// Older Ollama builds that don't support stream_options may stall before
// emitting any SSE data, blocking until the client timeout (600s).
// with_line_timeout() only protects after the first line arrives, so
// users on older builds should set OLLAMA_STREAM_USAGE=false.
if !options.stream_usage {
obj.remove("stream_options");
}
// Convert max_completion_tokens / max_tokens to Ollama's options.num_predict.
// Reasoning models emit max_completion_tokens; non-reasoning models emit max_tokens.
let max_tokens = obj
.remove("max_completion_tokens")
.or_else(|| obj.remove("max_tokens"));
if let Some(max_tokens) = max_tokens {
let options_value = obj.entry("options").or_insert_with(|| json!({}));
if let Some(options_obj) = options_value.as_object_mut() {
options_obj.entry("num_predict").or_insert(max_tokens);
}
}
// Apply num_ctx from context limit settings.
if let Some(limit) = resolve_ollama_num_ctx(options, model_config) {
let options_value = obj.entry("options").or_insert_with(|| json!({}));
if let Some(options_obj) = options_value.as_object_mut() {
options_obj.insert("num_ctx".to_string(), json!(limit));
}
}
}
}
impl ProviderDescriptor for OllamaProvider {
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), true),
ConfigKey::new(
"OLLAMA_TIMEOUT",
false,
false,
Some(&(OLLAMA_TIMEOUT.to_string())),
false,
),
],
)
}
}
#[async_trait]
impl Provider for OllamaProvider {
fn get_name(&self) -> &str {
&self.name
}
fn skip_canonical_filtering(&self) -> bool {
self.skip_canonical_filtering
}
fn retry_config(&self) -> RetryConfig {
RetryConfig::new(
OLLAMA_MAX_RETRIES,
OLLAMA_INITIAL_RETRY_INTERVAL_MS,
OLLAMA_BACKOFF_MULTIPLIER,
OLLAMA_MAX_RETRY_INTERVAL_MS,
)
.transient_only()
}
async fn stream(
&self,
model_config: &ModelConfig,
system: &str,
messages: &[Message],
tools: &[Tool],
) -> Result<MessageStream, ProviderError> {
let mut payload = create_request(
model_config,
system,
messages,
tools,
&ImageFormat::OpenAi,
true,
)?;
apply_ollama_options(&mut payload, &self.options, model_config);
let mut log = start_log(model_config, &payload)?;
let response = self
.with_retry(|| async {
let resp = self
.api_client
.response_post("v1/chat/completions", &payload)
.await?;
handle_status(resp).await
})
.await
.inspect_err(|e| {
let _ = log.error(e);
})?;
stream_ollama(response, self.options.chunk_timeout_secs, log)
}
async fn fetch_supported_models(&self) -> Result<Vec<String>, ProviderError> {
let response = self
.api_client
.request("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::<Value>().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<String> = models
.iter()
.filter_map(|model| model.get("name").and_then(|n| n.as_str()).map(String::from))
.collect();
model_names.sort();
Ok(model_names)
}
}
/// Default per-chunk timeout for Ollama streaming responses (seconds).
/// Configurable via OLLAMA_STREAM_TIMEOUT, GOOSE_STREAM_TIMEOUT, or falls back
/// to OLLAMA_TIMEOUT. Set high to accommodate slower models (CPU inference,
/// large parameter counts, complex reasoning).
pub const OLLAMA_DEFAULT_CHUNK_TIMEOUT_SECS: u64 = 120;
/// Wraps a line stream with a per-item timeout at the raw SSE level.
/// This detects dead connections without false-positive stalls during long
/// tool-call generations where response_to_streaming_message_ollama buffers.
fn with_line_timeout(
stream: impl futures::Stream<Item = anyhow::Result<String>> + Unpin + Send + 'static,
timeout_secs: u64,
) -> std::pin::Pin<Box<dyn futures::Stream<Item = anyhow::Result<String>> + Send>> {
let timeout = Duration::from_secs(timeout_secs);
Box::pin(try_stream! {
let mut stream = stream;
// Allow time-to-first-token to be governed by the request timeout.
// Only enforce per-chunk timeout after first SSE line arrives.
match stream.next().await {
Some(first_item) => yield first_item?,
None => return,
}
loop {
match tokio::time::timeout(timeout, stream.next()).await {
Ok(Some(item)) => yield item?,
Ok(None) => break,
Err(_) => {
Err::<(), anyhow::Error>(anyhow::anyhow!(
"Ollama stream stalled: no data received for {}s. \
This may indicate the model is overwhelmed by the request payload. \
Try a smaller model, reduce the number of tools, or increase the \
timeout via OLLAMA_STREAM_TIMEOUT, GOOSE_STREAM_TIMEOUT, or \
OLLAMA_TIMEOUT in your config.",
timeout_secs
))?;
}
}
}
})
}
/// Ollama-specific streaming handler with XML tool call fallback.
/// Uses the Ollama format module which buffers text when XML tool calls are detected,
/// preventing duplicate content from being emitted to the UI.
/// Timeout is applied at the raw SSE line level via with_line_timeout so that
/// buffering inside response_to_streaming_message_ollama does not cause false stalls.
fn stream_ollama(
response: Response,
chunk_timeout: u64,
mut log: Option<Box<dyn RequestLogHandle>>,
) -> Result<MessageStream, ProviderError> {
let stream = response.bytes_stream().map_err(std::io::Error::other);
Ok(Box::pin(try_stream! {
let stream_reader = StreamReader::new(stream);
let framed = FramedRead::new(stream_reader, LinesCodec::new())
.map_err(Error::from);
let timed_lines = with_line_timeout(framed, chunk_timeout);
let message_stream = response_to_streaming_message_ollama(timed_lines);
pin!(message_stream);
while let Some(message) = message_stream.next().await {
let (message, usage) = message.map_err(ProviderError::from_stream_error)?;
log.write(&message, usage.as_ref().map(|f| f.usage).as_ref())?;
yield (message, usage);
}
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_apply_ollama_options_uses_input_limit() {
let options = OllamaOptions {
input_limit: Some(8192),
..Default::default()
};
let model_config = ModelConfig::new("qwen3").with_context_limit(Some(16_000));
let mut payload = json!({});
apply_ollama_options(&mut payload, &options, &model_config);
assert_eq!(payload["options"]["num_ctx"], 8192);
}
#[test]
fn test_apply_ollama_options_falls_back_to_context_limit() {
let options = OllamaOptions::default();
let model_config = ModelConfig::new("qwen3").with_context_limit(Some(12_000));
let mut payload = json!({});
apply_ollama_options(&mut payload, &options, &model_config);
assert_eq!(payload["options"]["num_ctx"], 12_000);
}
#[test]
fn test_apply_ollama_options_skips_when_no_limit() {
let options = OllamaOptions::default();
let mut model_config = ModelConfig::new("qwen3");
model_config.context_limit = None;
let mut payload = json!({});
apply_ollama_options(&mut payload, &options, &model_config);
assert!(payload.get("options").is_none());
}
#[test]
fn test_raw_create_request_contains_unsupported_ollama_fields() {
use crate::formats::ollama::create_request;
let model_config = ModelConfig::new("llama3.1").with_max_tokens(Some(4096));
let messages = vec![crate::conversation::message::Message::user().with_text("hi")];
let payload = create_request(
&model_config,
"You are a helpful assistant.",
&messages,
&[],
&ImageFormat::OpenAi,
true,
)
.unwrap();
assert!(
payload.get("stream_options").is_some(),
"create_request should produce stream_options for usage tracking"
);
assert!(
payload.get("max_tokens").is_some(),
"create_request should produce max_tokens (unsupported by Ollama)"
);
}
#[test]
fn test_apply_ollama_options_preserves_stream_options_by_default() {
use crate::formats::ollama::create_request;
let options = OllamaOptions::default();
let model_config = ModelConfig::new("llama3.1").with_max_tokens(Some(4096));
let messages = vec![crate::conversation::message::Message::user().with_text("hi")];
let mut payload = create_request(
&model_config,
"You are a helpful assistant.",
&messages,
&[],
&ImageFormat::OpenAi,
true,
)
.unwrap();
apply_ollama_options(&mut payload, &options, &model_config);
assert!(
payload.get("stream_options").is_some(),
"stream_options should be preserved by default for usage tracking"
);
assert!(
payload.get("max_tokens").is_none(),
"max_tokens should be removed for Ollama"
);
assert!(
payload.get("max_completion_tokens").is_none(),
"max_completion_tokens should be removed for Ollama"
);
assert_eq!(
payload["options"]["num_predict"], 4096,
"max_tokens should be moved to options.num_predict"
);
assert_eq!(payload["stream"], true, "stream field should be preserved");
}
#[test]
fn test_apply_ollama_options_strips_stream_options_when_disabled() {
use crate::formats::ollama::create_request;
let options = OllamaOptions {
input_limit: None,
stream_usage: false,
chunk_timeout_secs: 120,
};
let model_config = ModelConfig::new("llama3.1").with_max_tokens(Some(4096));
let messages = vec![crate::conversation::message::Message::user().with_text("hi")];
let mut payload = create_request(
&model_config,
"You are a helpful assistant.",
&messages,
&[],
&ImageFormat::OpenAi,
true,
)
.unwrap();
apply_ollama_options(&mut payload, &options, &model_config);
assert!(
payload.get("stream_options").is_none(),
"stream_options should be removed when OLLAMA_STREAM_USAGE=false"
);
}
}

View file

@ -5,7 +5,7 @@ use crate::providers::base::{ModelInfo, ProviderType};
use crate::providers::huggingface::HuggingFaceProvider;
use crate::providers::huggingface_auth;
use crate::providers::inventory::declarative_inventory_identity;
use crate::providers::ollama::OllamaProvider;
use crate::providers::ollama_def::OllamaProviderDef;
use crate::providers::openai_def::OpenAiProviderDef;
use anyhow::Result;
use include_dir::{include_dir, Dir};
@ -624,14 +624,14 @@ pub fn register_declarative_provider(
ProviderEngine::Ollama => {
let captured = config.clone();
let identity_config = config.clone();
registry.register_with_name::<OllamaProvider, _, _>(
registry.register_with_name::<OllamaProviderDef, _, _>(
&config,
provider_type,
config.dynamic_models.unwrap_or(false),
move |tls_config| {
let mut cfg = captured.clone();
resolve_config(&mut cfg)?;
OllamaProvider::from_custom_config(cfg, tls_config)
crate::providers::ollama_def::from_custom_config(cfg, tls_config)
},
move || {
let mut cfg = identity_config.clone();

View file

@ -6,6 +6,5 @@ pub mod bedrock;
pub mod databricks;
pub mod gcpvertexai;
pub mod google;
pub mod ollama;
pub mod openrouter;
pub mod snowflake;

View file

@ -30,7 +30,6 @@ use super::{
kimicode::KimiCodeProvider,
litellm::LiteLLMProvider,
nanogpt::NanoGptProvider,
ollama::OllamaProvider,
openrouter::OpenRouterProvider,
pi_acp::PiAcpProvider,
provider_registry::ProviderRegistry,
@ -42,6 +41,7 @@ use super::{
use crate::config::ExtensionConfig;
use crate::providers::anthropic_def::AnthropicProviderDef;
use crate::providers::base::ProviderType;
use crate::providers::ollama_def::OllamaProviderDef;
use crate::providers::openai_def::OpenAiProviderDef;
use crate::{
config::declarative_providers::register_declarative_providers,
@ -115,7 +115,7 @@ async fn init_registry() -> RwLock<ProviderRegistry> {
registry.register::<KimiCodeProvider>(true);
registry.register::<LiteLLMProvider>(false);
registry.register::<NanoGptProvider>(true);
registry.register_with_inventory::<OllamaProvider>(
registry.register_with_inventory::<OllamaProviderDef>(
true,
Some(registrations::ollama_inventory()),
);

View file

@ -2,7 +2,7 @@ use super::{
config_secret_value, default_inventory_identity, default_inventory_identity_resolver,
serialize_string_map, InventoryIdentityInput, InventoryRegistration,
};
use crate::config::Config;
use crate::config::{self, Config};
use crate::providers::acp_tooling::{acp_adapter_installed, resolved_acp_command};
use crate::providers::amp_acp::{AMP_ACP_BINARY, AMP_ACP_PROVIDER_NAME};
use crate::providers::base::ProviderDescriptor;
@ -14,7 +14,7 @@ use crate::providers::formats::anthropic::ANTHROPIC_PROVIDER_NAME;
use crate::providers::google::{GOOGLE_API_HOST, GOOGLE_PROVIDER_NAME};
use crate::providers::huggingface::HuggingFaceProvider;
use crate::providers::huggingface_auth;
use crate::providers::ollama::{ollama_host_configured, OLLAMA_PROVIDER_NAME};
use crate::providers::ollama::OLLAMA_PROVIDER_NAME;
use crate::providers::openai::{OPEN_AI_DEFAULT_BASE_PATH, OPEN_AI_PROVIDER_NAME};
use crate::providers::pi_acp::{PI_ACP_BINARY, PI_ACP_PROVIDER_NAME};
use crate::providers::xai_oauth::TokenCache as XaiOAuthTokenCache;
@ -118,7 +118,11 @@ pub fn ollama_inventory() -> InventoryRegistration {
),
)
})
.with_configured(|| ollama_host_configured(Config::global()))
.with_configured(|| {
config::Config::global()
.get_param::<String>("OLLAMA_HOST")
.is_ok()
})
}
pub fn huggingface_inventory() -> InventoryRegistration {

View file

@ -52,7 +52,10 @@ pub mod local_inference;
pub mod nanogpt;
pub mod oauth;
pub mod oauth_device_flow;
pub mod ollama;
pub mod ollama {
pub use goose_providers::ollama::*;
}
pub mod ollama_def;
pub mod openai {
pub use goose_providers::openai::*;
}

View file

@ -1,731 +0,0 @@
use super::api_client::{ApiClient, AuthMethod};
use super::base::{
ConfigKey, MessageStream, Provider, ProviderDef, ProviderMetadata,
DEFAULT_PROVIDER_TIMEOUT_SECS,
};
use super::openai_compatible::handle_status;
use super::retry::{ProviderRetry, RetryConfig};
use crate::config::declarative_providers::DeclarativeProviderConfig;
use crate::conversation::message::Message;
use crate::providers::formats::ollama::{create_request, response_to_streaming_message_ollama};
use anyhow::{Error, Result};
use async_stream::try_stream;
use async_trait::async_trait;
use futures::future::BoxFuture;
use futures::TryStreamExt;
use goose_providers::errors::ProviderError;
use goose_providers::images::ImageFormat;
use goose_providers::model::ModelConfig;
use goose_providers::request_log::{start_log, LoggerHandleExt, RequestLogHandle};
use reqwest::Response;
use rmcp::model::Tool;
use serde_json::{json, Value};
use std::time::Duration;
use tokio::pin;
use tokio_stream::StreamExt;
use tokio_util::codec::{FramedRead, LinesCodec};
use tokio_util::io::StreamReader;
use url::Url;
pub(crate) const OLLAMA_PROVIDER_NAME: &str = "ollama";
pub const OLLAMA_HOST: &str = "localhost";
pub const OLLAMA_TIMEOUT: u64 = DEFAULT_PROVIDER_TIMEOUT_SECS;
pub const OLLAMA_DEFAULT_PORT: u16 = 11434;
pub const OLLAMA_DEFAULT_MODEL: &str = "qwen3";
pub const OLLAMA_KNOWN_MODELS: &[&str] = &[
OLLAMA_DEFAULT_MODEL,
"qwen3-vl",
"qwen3-coder:30b",
"qwen3-coder:480b-cloud",
];
pub const OLLAMA_DOC_URL: &str = "https://ollama.com/library";
// Ollama-specific retry config: large models can take 30-120s to load into memory,
// during which Ollama returns 500 errors. Use more retries with gradual backoff
// to wait for the model to become ready.
const OLLAMA_MAX_RETRIES: usize = 10;
const OLLAMA_INITIAL_RETRY_INTERVAL_MS: u64 = 2000;
const OLLAMA_BACKOFF_MULTIPLIER: f64 = 1.5;
const OLLAMA_MAX_RETRY_INTERVAL_MS: u64 = 15_000;
#[derive(serde::Serialize)]
pub struct OllamaProvider {
#[serde(skip)]
api_client: ApiClient,
supports_streaming: bool,
name: String,
skip_canonical_filtering: bool,
}
fn resolve_ollama_num_ctx(model_config: &ModelConfig) -> Option<usize> {
let config = crate::config::Config::global();
let input_limit = match config.get_param::<usize>("GOOSE_INPUT_LIMIT") {
Ok(limit) if limit > 0 => Some(limit),
Ok(_) => None,
Err(crate::config::ConfigError::NotFound(_)) => None,
Err(e) => {
tracing::warn!("Invalid GOOSE_INPUT_LIMIT value: {}", e);
None
}
};
input_limit.or(model_config.context_limit)
}
fn resolve_ollama_stream_usage() -> bool {
let config = crate::config::Config::global();
match config.get_param::<bool>("OLLAMA_STREAM_USAGE") {
Ok(val) => val,
// Key not set: default to true. Ollama supports stream_options since
// mid-2025 and most installs benefit from token usage tracking.
Err(crate::config::ConfigError::NotFound(_)) => true,
// Invalid value (e.g. "0", "yes", typo): warn and disable stream_options
// so users who intended to opt out aren't silently left hanging.
Err(e) => {
tracing::warn!(
"Invalid OLLAMA_STREAM_USAGE value ({}); disabling stream_options. \
Use true or false.",
e
);
false
}
}
}
fn apply_ollama_options(payload: &mut Value, model_config: &ModelConfig) {
if let Some(obj) = payload.as_object_mut() {
// Gate stream_options behind OLLAMA_STREAM_USAGE (default: true).
// Older Ollama builds that don't support stream_options may stall before
// emitting any SSE data, blocking until the client timeout (600s).
// with_line_timeout() only protects after the first line arrives, so
// users on older builds should set OLLAMA_STREAM_USAGE=false.
if !resolve_ollama_stream_usage() {
obj.remove("stream_options");
}
// Convert max_completion_tokens / max_tokens to Ollama's options.num_predict.
// Reasoning models emit max_completion_tokens; non-reasoning models emit max_tokens.
let max_tokens = obj
.remove("max_completion_tokens")
.or_else(|| obj.remove("max_tokens"));
if let Some(max_tokens) = max_tokens {
let options = obj.entry("options").or_insert_with(|| json!({}));
if let Some(options_obj) = options.as_object_mut() {
options_obj.entry("num_predict").or_insert(max_tokens);
}
}
// Apply num_ctx from context limit settings.
if let Some(limit) = resolve_ollama_num_ctx(model_config) {
let options = obj.entry("options").or_insert_with(|| json!({}));
if let Some(options_obj) = options.as_object_mut() {
options_obj.insert("num_ctx".to_string(), json!(limit));
}
}
}
}
pub(crate) fn ollama_host_configured(config: &crate::config::Config) -> bool {
config.get_param::<String>("OLLAMA_HOST").is_ok()
}
impl OllamaProvider {
pub async fn from_env(
tls_config: Option<crate::providers::api_client::TlsConfig>,
) -> Result<Self> {
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_and_tls(
base_url.to_string(),
AuthMethod::NoAuth,
timeout,
tls_config,
)?
.with_request_builder(crate::session_context::session_id_request_builder());
Ok(Self {
api_client,
supports_streaming: true,
name: OLLAMA_PROVIDER_NAME.to_string(),
skip_canonical_filtering: false,
})
}
pub fn from_custom_config(
config: DeclarativeProviderConfig,
tls_config: Option<crate::providers::api_client::TlsConfig>,
) -> Result<Self> {
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_and_tls(
base_url.to_string(),
AuthMethod::NoAuth,
timeout,
tls_config,
)?
.with_request_builder(crate::session_context::session_id_request_builder());
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)?;
}
let supports_streaming = config.supports_streaming.unwrap_or(true);
if !supports_streaming {
return Err(anyhow::anyhow!(
"Ollama provider does not support non-streaming mode. All Ollama models support streaming. \
Please remove 'supports_streaming: false' from your provider configuration."
));
}
Ok(Self {
api_client,
supports_streaming,
name: config.name.clone(),
skip_canonical_filtering: config.skip_canonical_filtering,
})
}
}
impl goose_providers::base::ProviderDescriptor for OllamaProvider {
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), true),
ConfigKey::new(
"OLLAMA_TIMEOUT",
false,
false,
Some(&(OLLAMA_TIMEOUT.to_string())),
false,
),
],
)
}
}
impl ProviderDef for OllamaProvider {
type Provider = Self;
fn from_env(
_extensions: Vec<crate::config::ExtensionConfig>,
tls_config: Option<crate::providers::api_client::TlsConfig>,
) -> BoxFuture<'static, Result<Self::Provider>> {
Box::pin(Self::from_env(tls_config))
}
}
#[async_trait]
impl Provider for OllamaProvider {
fn get_name(&self) -> &str {
&self.name
}
fn skip_canonical_filtering(&self) -> bool {
self.skip_canonical_filtering
}
fn retry_config(&self) -> RetryConfig {
RetryConfig::new(
OLLAMA_MAX_RETRIES,
OLLAMA_INITIAL_RETRY_INTERVAL_MS,
OLLAMA_BACKOFF_MULTIPLIER,
OLLAMA_MAX_RETRY_INTERVAL_MS,
)
.transient_only()
}
async fn stream(
&self,
model_config: &ModelConfig,
system: &str,
messages: &[Message],
tools: &[Tool],
) -> Result<MessageStream, ProviderError> {
let mut payload = create_request(
model_config,
system,
messages,
tools,
&ImageFormat::OpenAi,
true,
)?;
apply_ollama_options(&mut payload, model_config);
let mut log = start_log(model_config, &payload)?;
let response = self
.with_retry(|| async {
let resp = self
.api_client
.response_post("v1/chat/completions", &payload)
.await?;
handle_status(resp).await
})
.await
.inspect_err(|e| {
let _ = log.error(e);
})?;
stream_ollama(response, log)
}
async fn fetch_supported_models(&self) -> Result<Vec<String>, ProviderError> {
let response = self
.api_client
.request("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::<Value>().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<String> = models
.iter()
.filter_map(|model| model.get("name").and_then(|n| n.as_str()).map(String::from))
.collect();
model_names.sort();
Ok(model_names)
}
}
/// Default per-chunk timeout for Ollama streaming responses (seconds).
/// Configurable via OLLAMA_STREAM_TIMEOUT, GOOSE_STREAM_TIMEOUT, or falls back
/// to OLLAMA_TIMEOUT. Set high to accommodate slower models (CPU inference,
/// large parameter counts, complex reasoning).
const OLLAMA_DEFAULT_CHUNK_TIMEOUT_SECS: u64 = 120;
/// Resolve the per-chunk stream timeout from config.
/// Priority: OLLAMA_STREAM_TIMEOUT > GOOSE_STREAM_TIMEOUT > OLLAMA_TIMEOUT > default (120s).
/// Zero values are treated as invalid and skipped, since a zero timeout would
/// cause every chunk after the first to be treated as a stall.
fn resolve_ollama_chunk_timeout() -> u64 {
let config = crate::config::Config::global();
if let Ok(val) = config.get_param::<u64>("OLLAMA_STREAM_TIMEOUT") {
if val > 0 {
return val;
}
}
if let Ok(val) = config.get_param::<u64>("GOOSE_STREAM_TIMEOUT") {
if val > 0 {
return val;
}
}
match config.get_param::<u64>("OLLAMA_TIMEOUT") {
Ok(val) if val > 0 => val,
_ => OLLAMA_DEFAULT_CHUNK_TIMEOUT_SECS,
}
}
/// Wraps a line stream with a per-item timeout at the raw SSE level.
/// This detects dead connections without false-positive stalls during long
/// tool-call generations where response_to_streaming_message_ollama buffers.
fn with_line_timeout(
stream: impl futures::Stream<Item = anyhow::Result<String>> + Unpin + Send + 'static,
timeout_secs: u64,
) -> std::pin::Pin<Box<dyn futures::Stream<Item = anyhow::Result<String>> + Send>> {
let timeout = Duration::from_secs(timeout_secs);
Box::pin(try_stream! {
let mut stream = stream;
// Allow time-to-first-token to be governed by the request timeout.
// Only enforce per-chunk timeout after first SSE line arrives.
match stream.next().await {
Some(first_item) => yield first_item?,
None => return,
}
loop {
match tokio::time::timeout(timeout, stream.next()).await {
Ok(Some(item)) => yield item?,
Ok(None) => break,
Err(_) => {
Err::<(), anyhow::Error>(anyhow::anyhow!(
"Ollama stream stalled: no data received for {}s. \
This may indicate the model is overwhelmed by the request payload. \
Try a smaller model, reduce the number of tools, or increase the \
timeout via OLLAMA_STREAM_TIMEOUT, GOOSE_STREAM_TIMEOUT, or \
OLLAMA_TIMEOUT in your config.",
timeout_secs
))?;
}
}
}
})
}
/// Ollama-specific streaming handler with XML tool call fallback.
/// Uses the Ollama format module which buffers text when XML tool calls are detected,
/// preventing duplicate content from being emitted to the UI.
/// Timeout is applied at the raw SSE line level via with_line_timeout so that
/// buffering inside response_to_streaming_message_ollama does not cause false stalls.
fn stream_ollama(
response: Response,
mut log: Option<Box<dyn RequestLogHandle>>,
) -> Result<MessageStream, ProviderError> {
let stream = response.bytes_stream().map_err(std::io::Error::other);
Ok(Box::pin(try_stream! {
let stream_reader = StreamReader::new(stream);
let framed = FramedRead::new(stream_reader, LinesCodec::new())
.map_err(Error::from);
let chunk_timeout = resolve_ollama_chunk_timeout();
let timed_lines = with_line_timeout(framed, chunk_timeout);
let message_stream = response_to_streaming_message_ollama(timed_lines);
pin!(message_stream);
while let Some(message) = message_stream.next().await {
let (message, usage) = message.map_err(ProviderError::from_stream_error)?;
log.write(&message, usage.as_ref().map(|f| f.usage).as_ref())?;
yield (message, usage);
}
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ollama_host_default_does_not_mark_inventory_configured() {
let _guard = env_lock::lock_env([("OLLAMA_HOST", None::<&str>)]);
let config_file = tempfile::NamedTempFile::new().unwrap();
let secrets_file = tempfile::NamedTempFile::new().unwrap();
let config = crate::config::Config::new_with_config_paths(
vec![config_file.path().to_path_buf()],
secrets_file.path(),
)
.unwrap();
assert!(!ollama_host_configured(&config));
}
#[test]
fn test_ollama_host_env_marks_inventory_configured() {
let _guard = env_lock::lock_env([("OLLAMA_HOST", Some("http://127.0.0.1:11435"))]);
let config_file = tempfile::NamedTempFile::new().unwrap();
let secrets_file = tempfile::NamedTempFile::new().unwrap();
let config = crate::config::Config::new_with_config_paths(
vec![config_file.path().to_path_buf()],
secrets_file.path(),
)
.unwrap();
assert!(ollama_host_configured(&config));
}
#[test]
fn test_ollama_host_config_marks_inventory_configured() {
let _guard = env_lock::lock_env([("OLLAMA_HOST", None::<&str>)]);
let config_file = tempfile::NamedTempFile::new().unwrap();
let secrets_file = tempfile::NamedTempFile::new().unwrap();
let config = crate::config::Config::new_with_config_paths(
vec![config_file.path().to_path_buf()],
secrets_file.path(),
)
.unwrap();
config
.set_param("OLLAMA_HOST", "http://127.0.0.1:11435")
.unwrap();
assert!(ollama_host_configured(&config));
}
#[test]
fn test_apply_ollama_options_uses_input_limit() {
let _guard = env_lock::lock_env([("GOOSE_INPUT_LIMIT", Some("8192"))]);
let model_config = ModelConfig::new("qwen3").with_context_limit(Some(16_000));
let mut payload = json!({});
apply_ollama_options(&mut payload, &model_config);
assert_eq!(payload["options"]["num_ctx"], 8192);
}
#[test]
fn test_apply_ollama_options_falls_back_to_context_limit() {
let _guard = env_lock::lock_env([("GOOSE_INPUT_LIMIT", None::<&str>)]);
let model_config = ModelConfig::new("qwen3").with_context_limit(Some(12_000));
let mut payload = json!({});
apply_ollama_options(&mut payload, &model_config);
assert_eq!(payload["options"]["num_ctx"], 12_000);
}
#[test]
fn test_apply_ollama_options_skips_when_no_limit() {
let _guard = env_lock::lock_env([("GOOSE_INPUT_LIMIT", None::<&str>)]);
let mut model_config = ModelConfig::new("qwen3");
model_config.context_limit = None;
let mut payload = json!({});
apply_ollama_options(&mut payload, &model_config);
assert!(payload.get("options").is_none());
}
#[test]
fn test_raw_create_request_contains_unsupported_ollama_fields() {
use crate::providers::formats::ollama::create_request;
let model_config = ModelConfig::new("llama3.1").with_max_tokens(Some(4096));
let messages = vec![crate::conversation::message::Message::user().with_text("hi")];
let payload = create_request(
&model_config,
"You are a helpful assistant.",
&messages,
&[],
&ImageFormat::OpenAi,
true,
)
.unwrap();
assert!(
payload.get("stream_options").is_some(),
"create_request should produce stream_options for usage tracking"
);
assert!(
payload.get("max_tokens").is_some(),
"create_request should produce max_tokens (unsupported by Ollama)"
);
}
#[test]
fn test_apply_ollama_options_preserves_stream_options_by_default() {
use crate::providers::formats::ollama::create_request;
let _guard = env_lock::lock_env([
("GOOSE_INPUT_LIMIT", None::<&str>),
("OLLAMA_STREAM_USAGE", None::<&str>),
]);
let model_config = ModelConfig::new("llama3.1").with_max_tokens(Some(4096));
let messages = vec![crate::conversation::message::Message::user().with_text("hi")];
let mut payload = create_request(
&model_config,
"You are a helpful assistant.",
&messages,
&[],
&ImageFormat::OpenAi,
true,
)
.unwrap();
apply_ollama_options(&mut payload, &model_config);
assert!(
payload.get("stream_options").is_some(),
"stream_options should be preserved by default for usage tracking"
);
assert!(
payload.get("max_tokens").is_none(),
"max_tokens should be removed for Ollama"
);
assert!(
payload.get("max_completion_tokens").is_none(),
"max_completion_tokens should be removed for Ollama"
);
assert_eq!(
payload["options"]["num_predict"], 4096,
"max_tokens should be moved to options.num_predict"
);
assert_eq!(payload["stream"], true, "stream field should be preserved");
}
#[test]
fn test_apply_ollama_options_strips_stream_options_when_disabled() {
use crate::providers::formats::ollama::create_request;
let _guard = env_lock::lock_env([
("GOOSE_INPUT_LIMIT", None::<&str>),
("OLLAMA_STREAM_USAGE", Some("false")),
]);
let model_config = ModelConfig::new("llama3.1").with_max_tokens(Some(4096));
let messages = vec![crate::conversation::message::Message::user().with_text("hi")];
let mut payload = create_request(
&model_config,
"You are a helpful assistant.",
&messages,
&[],
&ImageFormat::OpenAi,
true,
)
.unwrap();
apply_ollama_options(&mut payload, &model_config);
assert!(
payload.get("stream_options").is_none(),
"stream_options should be removed when OLLAMA_STREAM_USAGE=false"
);
}
#[test]
fn test_resolve_ollama_chunk_timeout_defaults_to_ollama_timeout() {
let _guard = env_lock::lock_env([
("OLLAMA_STREAM_TIMEOUT", None::<&str>),
("GOOSE_STREAM_TIMEOUT", None::<&str>),
("OLLAMA_TIMEOUT", Some("300")),
]);
assert_eq!(resolve_ollama_chunk_timeout(), 300);
}
#[test]
fn test_resolve_ollama_chunk_timeout_prefers_stream_override() {
let _guard = env_lock::lock_env([
("OLLAMA_STREAM_TIMEOUT", Some("60")),
("GOOSE_STREAM_TIMEOUT", Some("90")),
("OLLAMA_TIMEOUT", Some("300")),
]);
assert_eq!(resolve_ollama_chunk_timeout(), 60);
}
#[test]
fn test_resolve_ollama_chunk_timeout_uses_goose_stream_fallback() {
let _guard = env_lock::lock_env([
("OLLAMA_STREAM_TIMEOUT", None::<&str>),
("GOOSE_STREAM_TIMEOUT", Some("90")),
("OLLAMA_TIMEOUT", Some("300")),
]);
assert_eq!(resolve_ollama_chunk_timeout(), 90);
}
#[test]
fn test_resolve_ollama_chunk_timeout_uses_default_when_unset() {
let _guard = env_lock::lock_env([
("OLLAMA_STREAM_TIMEOUT", None::<&str>),
("GOOSE_STREAM_TIMEOUT", None::<&str>),
("OLLAMA_TIMEOUT", None::<&str>),
]);
assert_eq!(
resolve_ollama_chunk_timeout(),
OLLAMA_DEFAULT_CHUNK_TIMEOUT_SECS
);
}
#[test]
fn test_resolve_ollama_chunk_timeout_skips_zero_values() {
let _guard = env_lock::lock_env([
("OLLAMA_STREAM_TIMEOUT", Some("0")),
("GOOSE_STREAM_TIMEOUT", Some("0")),
("OLLAMA_TIMEOUT", Some("300")),
]);
assert_eq!(resolve_ollama_chunk_timeout(), 300);
}
#[test]
fn test_resolve_ollama_chunk_timeout_skips_all_zero_to_default() {
let _guard = env_lock::lock_env([
("OLLAMA_STREAM_TIMEOUT", Some("0")),
("GOOSE_STREAM_TIMEOUT", Some("0")),
("OLLAMA_TIMEOUT", Some("0")),
]);
assert_eq!(
resolve_ollama_chunk_timeout(),
OLLAMA_DEFAULT_CHUNK_TIMEOUT_SECS
);
}
#[test]
fn test_ollama_retry_config_is_transient_only() {
let config = RetryConfig::new(
OLLAMA_MAX_RETRIES,
OLLAMA_INITIAL_RETRY_INTERVAL_MS,
OLLAMA_BACKOFF_MULTIPLIER,
OLLAMA_MAX_RETRY_INTERVAL_MS,
)
.transient_only();
assert!(config.transient_only);
use super::super::retry::should_retry;
use goose_providers::errors::ProviderError;
assert!(!should_retry(
&ProviderError::RequestFailed("Resource not found (404)".into()),
&config
));
assert!(!should_retry(
&ProviderError::RequestFailed("Bad request (400)".into()),
&config
));
assert!(should_retry(
&ProviderError::ServerError("500 model loading".into()),
&config
));
assert!(should_retry(
&ProviderError::NetworkError("connection refused".into()),
&config
));
}
}

View file

@ -0,0 +1,276 @@
use std::time::Duration;
use anyhow::Result;
use futures::future::BoxFuture;
use url::Url;
use crate::{
config::declarative_providers::DeclarativeProviderConfig, providers::base::ProviderDef,
};
use goose_providers::{
api_client::{ApiClient, AuthMethod},
base::ProviderDescriptor,
ollama::{
OllamaOptions, OllamaProvider, OLLAMA_DEFAULT_CHUNK_TIMEOUT_SECS, OLLAMA_DEFAULT_PORT,
OLLAMA_HOST, OLLAMA_PROVIDER_NAME, OLLAMA_TIMEOUT,
},
};
pub struct OllamaProviderDef;
impl ProviderDescriptor for OllamaProviderDef {
fn metadata() -> goose_providers::base::ProviderMetadata {
OllamaProvider::metadata()
}
}
impl ProviderDef for OllamaProviderDef {
type Provider = OllamaProvider;
fn from_env(
_extensions: Vec<crate::config::ExtensionConfig>,
tls_config: Option<crate::providers::api_client::TlsConfig>,
) -> BoxFuture<'static, Result<Self::Provider>> {
Box::pin(from_env(tls_config))
}
}
pub async fn from_env(
tls_config: Option<crate::providers::api_client::TlsConfig>,
) -> Result<OllamaProvider> {
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_and_tls(
base_url.to_string(),
AuthMethod::NoAuth,
timeout,
tls_config,
)?
.with_request_builder(crate::session_context::session_id_request_builder());
Ok(OllamaProvider::new(
api_client,
OLLAMA_PROVIDER_NAME.to_string(),
false,
options_from_config(),
))
}
pub fn from_custom_config(
config: DeclarativeProviderConfig,
tls_config: Option<crate::providers::api_client::TlsConfig>,
) -> Result<OllamaProvider> {
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_and_tls(
base_url.to_string(),
AuthMethod::NoAuth,
timeout,
tls_config,
)?
.with_request_builder(crate::session_context::session_id_request_builder());
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)?;
}
let supports_streaming = config.supports_streaming.unwrap_or(true);
if !supports_streaming {
return Err(anyhow::anyhow!(
"Ollama provider does not support non-streaming mode. All Ollama models support streaming. \
Please remove 'supports_streaming: false' from your provider configuration."
));
}
Ok(OllamaProvider::new(
api_client,
config.name.clone(),
config.skip_canonical_filtering,
options_from_config(),
))
}
pub fn options_from_config() -> OllamaOptions {
let config = crate::config::Config::global();
let input_limit = match config.get_param::<usize>("GOOSE_INPUT_LIMIT") {
Ok(limit) if limit > 0 => Some(limit),
Ok(_) => None,
Err(crate::config::ConfigError::NotFound(_)) => None,
Err(e) => {
tracing::warn!("Invalid GOOSE_INPUT_LIMIT value: {}", e);
None
}
};
let stream_usage = match config.get_param::<bool>("OLLAMA_STREAM_USAGE") {
Ok(val) => val,
// Key not set: default to true. Ollama supports stream_options since
// mid-2025 and most installs benefit from token usage tracking.
Err(crate::config::ConfigError::NotFound(_)) => true,
// Invalid value (e.g. "0", "yes", typo): warn and disable stream_options
// so users who intended to opt out aren't silently left hanging.
Err(e) => {
tracing::warn!(
"Invalid OLLAMA_STREAM_USAGE value ({}); disabling stream_options. \
Use true or false.",
e
);
false
}
};
OllamaOptions {
input_limit,
stream_usage,
chunk_timeout_secs: resolve_ollama_chunk_timeout(config),
}
}
/// Resolve the per-chunk stream timeout from config.
/// Priority: OLLAMA_STREAM_TIMEOUT > GOOSE_STREAM_TIMEOUT > OLLAMA_TIMEOUT > default (120s).
/// Zero values are treated as invalid and skipped, since a zero timeout would
/// cause every chunk after the first to be treated as a stall.
fn resolve_ollama_chunk_timeout(config: &crate::config::Config) -> u64 {
if let Ok(val) = config.get_param::<u64>("OLLAMA_STREAM_TIMEOUT") {
if val > 0 {
return val;
}
}
if let Ok(val) = config.get_param::<u64>("GOOSE_STREAM_TIMEOUT") {
if val > 0 {
return val;
}
}
match config.get_param::<u64>("OLLAMA_TIMEOUT") {
Ok(val) if val > 0 => val,
_ => OLLAMA_DEFAULT_CHUNK_TIMEOUT_SECS,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_ollama_chunk_timeout_defaults_to_ollama_timeout() {
let _guard = env_lock::lock_env([
("OLLAMA_STREAM_TIMEOUT", None::<&str>),
("GOOSE_STREAM_TIMEOUT", None::<&str>),
("OLLAMA_TIMEOUT", Some("300")),
]);
let config = crate::config::Config::global();
assert_eq!(resolve_ollama_chunk_timeout(config), 300);
}
#[test]
fn test_resolve_ollama_chunk_timeout_prefers_stream_override() {
let _guard = env_lock::lock_env([
("OLLAMA_STREAM_TIMEOUT", Some("60")),
("GOOSE_STREAM_TIMEOUT", Some("90")),
("OLLAMA_TIMEOUT", Some("300")),
]);
let config = crate::config::Config::global();
assert_eq!(resolve_ollama_chunk_timeout(config), 60);
}
#[test]
fn test_resolve_ollama_chunk_timeout_uses_goose_stream_fallback() {
let _guard = env_lock::lock_env([
("OLLAMA_STREAM_TIMEOUT", None::<&str>),
("GOOSE_STREAM_TIMEOUT", Some("90")),
("OLLAMA_TIMEOUT", Some("300")),
]);
let config = crate::config::Config::global();
assert_eq!(resolve_ollama_chunk_timeout(config), 90);
}
#[test]
fn test_resolve_ollama_chunk_timeout_uses_default_when_unset() {
let _guard = env_lock::lock_env([
("OLLAMA_STREAM_TIMEOUT", None::<&str>),
("GOOSE_STREAM_TIMEOUT", None::<&str>),
("OLLAMA_TIMEOUT", None::<&str>),
]);
let config = crate::config::Config::global();
assert_eq!(
resolve_ollama_chunk_timeout(config),
OLLAMA_DEFAULT_CHUNK_TIMEOUT_SECS
);
}
#[test]
fn test_resolve_ollama_chunk_timeout_skips_zero_values() {
let _guard = env_lock::lock_env([
("OLLAMA_STREAM_TIMEOUT", Some("0")),
("GOOSE_STREAM_TIMEOUT", Some("0")),
("OLLAMA_TIMEOUT", Some("300")),
]);
let config = crate::config::Config::global();
assert_eq!(resolve_ollama_chunk_timeout(config), 300);
}
#[test]
fn test_resolve_ollama_chunk_timeout_skips_all_zero_to_default() {
let _guard = env_lock::lock_env([
("OLLAMA_STREAM_TIMEOUT", Some("0")),
("GOOSE_STREAM_TIMEOUT", Some("0")),
("OLLAMA_TIMEOUT", Some("0")),
]);
let config = crate::config::Config::global();
assert_eq!(
resolve_ollama_chunk_timeout(config),
OLLAMA_DEFAULT_CHUNK_TIMEOUT_SECS
);
}
}