@@ -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}
5657fn 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+
7185fn 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