Skip to content

Commit a1928e8

Browse files
fix: honor Ollama timeout during streaming
Fixes #8437 Ollama requests used OLLAMA_TIMEOUT for the initial HTTP request but still used a separate hardcoded 30s stall timeout while streaming. Default the stream timeout to the configured request timeout, while still allowing GOOSE_STREAM_TIMEOUT to override it explicitly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Vincenzo Palazzo <vincenzopalazzodev@gmail.com>
1 parent 1fe379a commit a1928e8

File tree

1 file changed

+59
-22
lines changed

1 file changed

+59
-22
lines changed

crates/goose/src/providers/ollama.rs

Lines changed: 59 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ pub struct OllamaProvider {
5252
supports_streaming: bool,
5353
name: String,
5454
skip_canonical_filtering: bool,
55+
stream_timeout_secs: u64,
5556
}
5657
fn resolve_ollama_num_ctx(model_config: &ModelConfig) -> Option<usize> {
5758
let config = crate::config::Config::global();
@@ -68,6 +69,19 @@ fn resolve_ollama_num_ctx(model_config: &ModelConfig) -> Option<usize> {
6869
input_limit.or(model_config.context_limit)
6970
}
7071

72+
fn resolve_ollama_stream_timeout_secs(request_timeout_secs: u64) -> u64 {
73+
let config = crate::config::Config::global();
74+
match config.get_param::<u64>("GOOSE_STREAM_TIMEOUT") {
75+
Ok(0) => request_timeout_secs,
76+
Ok(timeout_secs) => timeout_secs,
77+
Err(crate::config::ConfigError::NotFound(_)) => request_timeout_secs,
78+
Err(e) => {
79+
tracing::warn!("Invalid GOOSE_STREAM_TIMEOUT value: {}", e);
80+
request_timeout_secs
81+
}
82+
}
83+
}
84+
7185
fn apply_ollama_options(payload: &mut Value, model_config: &ModelConfig) {
7286
if let Some(obj) = payload.as_object_mut() {
7387
// Ollama does not support stream_options; remove it to prevent hangs.
@@ -102,8 +116,9 @@ impl OllamaProvider {
102116
.get_param("OLLAMA_HOST")
103117
.unwrap_or_else(|_| OLLAMA_HOST.to_string());
104118

105-
let timeout: Duration =
106-
Duration::from_secs(config.get_param("OLLAMA_TIMEOUT").unwrap_or(OLLAMA_TIMEOUT));
119+
let request_timeout_secs = config.get_param("OLLAMA_TIMEOUT").unwrap_or(OLLAMA_TIMEOUT);
120+
let timeout: Duration = Duration::from_secs(request_timeout_secs);
121+
let stream_timeout_secs = resolve_ollama_stream_timeout_secs(request_timeout_secs);
107122

108123
let base = if host.starts_with("http://") || host.starts_with("https://") {
109124
host.clone()
@@ -133,14 +148,17 @@ impl OllamaProvider {
133148
supports_streaming: true,
134149
name: OLLAMA_PROVIDER_NAME.to_string(),
135150
skip_canonical_filtering: false,
151+
stream_timeout_secs,
136152
})
137153
}
138154

139155
pub fn from_custom_config(
140156
model: ModelConfig,
141157
config: DeclarativeProviderConfig,
142158
) -> Result<Self> {
143-
let timeout = Duration::from_secs(config.timeout_seconds.unwrap_or(OLLAMA_TIMEOUT));
159+
let request_timeout_secs = config.timeout_seconds.unwrap_or(OLLAMA_TIMEOUT);
160+
let timeout = Duration::from_secs(request_timeout_secs);
161+
let stream_timeout_secs = resolve_ollama_stream_timeout_secs(request_timeout_secs);
144162

145163
let base =
146164
if config.base_url.starts_with("http://") || config.base_url.starts_with("https://") {
@@ -196,6 +214,7 @@ impl OllamaProvider {
196214
supports_streaming,
197215
name: config.name.clone(),
198216
skip_canonical_filtering: config.skip_canonical_filtering,
217+
stream_timeout_secs,
199218
})
200219
}
201220
}
@@ -287,7 +306,7 @@ impl Provider for OllamaProvider {
287306
.inspect_err(|e| {
288307
let _ = log.error(e);
289308
})?;
290-
stream_ollama(response, log)
309+
stream_ollama(response, log, self.stream_timeout_secs)
291310
}
292311

293312
async fn fetch_supported_models(&self) -> Result<Vec<String>, ProviderError> {
@@ -327,10 +346,6 @@ impl Provider for OllamaProvider {
327346
}
328347
}
329348

330-
/// Per-chunk timeout for Ollama streaming responses.
331-
/// If no new raw SSE data arrives within this duration, the connection is considered dead.
332-
const OLLAMA_CHUNK_TIMEOUT_SECS: u64 = 30;
333-
334349
/// Wraps a line stream with a per-item timeout at the raw SSE level.
335350
/// This detects dead connections without false-positive stalls during long
336351
/// tool-call generations where response_to_streaming_message_ollama buffers.
@@ -370,15 +385,19 @@ fn with_line_timeout(
370385
/// preventing duplicate content from being emitted to the UI.
371386
/// Timeout is applied at the raw SSE line level via with_line_timeout so that
372387
/// buffering inside response_to_streaming_message_ollama does not cause false stalls.
373-
fn stream_ollama(response: Response, mut log: RequestLog) -> Result<MessageStream, ProviderError> {
388+
fn stream_ollama(
389+
response: Response,
390+
mut log: RequestLog,
391+
stream_timeout_secs: u64,
392+
) -> Result<MessageStream, ProviderError> {
374393
let stream = response.bytes_stream().map_err(std::io::Error::other);
375394

376395
Ok(Box::pin(try_stream! {
377396
let stream_reader = StreamReader::new(stream);
378397
let framed = FramedRead::new(stream_reader, LinesCodec::new())
379398
.map_err(Error::from);
380399

381-
let timed_lines = with_line_timeout(framed, OLLAMA_CHUNK_TIMEOUT_SECS);
400+
let timed_lines = with_line_timeout(framed, stream_timeout_secs);
382401
let message_stream = response_to_streaming_message_ollama(timed_lines);
383402
pin!(message_stream);
384403

@@ -522,20 +541,20 @@ mod tests {
522541
)
523542
.unwrap();
524543

525-
let mut msg_stream = stream_ollama(response, log).unwrap();
544+
let stream_timeout_secs = 30;
545+
let mut msg_stream = stream_ollama(response, log, stream_timeout_secs).unwrap();
526546

527-
let result =
528-
tokio::time::timeout(Duration::from_secs(OLLAMA_CHUNK_TIMEOUT_SECS + 5), async {
529-
let mut last_err = None;
530-
while let Some(item) = msg_stream.next().await {
531-
if let Err(e) = item {
532-
last_err = Some(e);
533-
break;
534-
}
547+
let result = tokio::time::timeout(Duration::from_secs(stream_timeout_secs + 5), async {
548+
let mut last_err = None;
549+
while let Some(item) = msg_stream.next().await {
550+
if let Err(e) = item {
551+
last_err = Some(e);
552+
break;
535553
}
536-
last_err
537-
})
538-
.await;
554+
}
555+
last_err
556+
})
557+
.await;
539558

540559
match result {
541560
Ok(Some(err)) => {
@@ -553,6 +572,24 @@ mod tests {
553572
drop(tx);
554573
}
555574

575+
#[test]
576+
fn test_resolve_ollama_stream_timeout_uses_request_timeout_by_default() {
577+
let _guard = env_lock::lock_env([("GOOSE_STREAM_TIMEOUT", None::<&str>)]);
578+
assert_eq!(resolve_ollama_stream_timeout_secs(1200), 1200);
579+
}
580+
581+
#[test]
582+
fn test_resolve_ollama_stream_timeout_uses_override_when_present() {
583+
let _guard = env_lock::lock_env([("GOOSE_STREAM_TIMEOUT", Some("45"))]);
584+
assert_eq!(resolve_ollama_stream_timeout_secs(1200), 45);
585+
}
586+
587+
#[test]
588+
fn test_resolve_ollama_stream_timeout_falls_back_for_zero() {
589+
let _guard = env_lock::lock_env([("GOOSE_STREAM_TIMEOUT", Some("0"))]);
590+
assert_eq!(resolve_ollama_stream_timeout_secs(1200), 1200);
591+
}
592+
556593
#[test]
557594
fn test_ollama_retry_config_is_transient_only() {
558595
let config = RetryConfig::new(

0 commit comments

Comments
 (0)