mirror of
https://github.com/block/goose.git
synced 2026-07-09 16:09:22 +00:00
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
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:
parent
808b8ca9ad
commit
b806204887
11 changed files with 752 additions and 746 deletions
|
|
@ -1,3 +1,4 @@
|
|||
pub mod anthropic;
|
||||
pub mod ollama;
|
||||
pub mod openai;
|
||||
pub mod openai_responses;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
453
crates/goose-providers/src/ollama.rs
Normal file
453
crates/goose-providers/src/ollama.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
276
crates/goose/src/providers/ollama_def.rs
Normal file
276
crates/goose/src/providers/ollama_def.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue