diff --git a/src/apps/cli/tests/support/mod.rs b/src/apps/cli/tests/support/mod.rs index fe0538a856..761180adac 100644 --- a/src/apps/cli/tests/support/mod.rs +++ b/src/apps/cli/tests/support/mod.rs @@ -196,7 +196,8 @@ impl CliTestEnvironment { }, "max_rounds": 1, "stream_idle_timeout_secs": 10, - "stream_ttft_timeout_secs": 10 + "stream_ttft_timeout_secs": 10, + "stream_connect_timeout_secs": 10 } }); std::fs::write( diff --git a/src/apps/desktop/src/api/config_api.rs b/src/apps/desktop/src/api/config_api.rs index c2f71aabf8..308758e4dd 100644 --- a/src/apps/desktop/src/api/config_api.rs +++ b/src/apps/desktop/src/api/config_api.rs @@ -198,6 +198,7 @@ pub async fn set_config( || request.path.starts_with("ai.agent_model_defaults") || request.path.starts_with("ai.stream_idle_timeout_secs") || request.path.starts_with("ai.stream_ttft_timeout_secs") + || request.path.starts_with("ai.stream_connect_timeout_secs") || request.path.starts_with("ai.proxy") { state.ai_client_factory.invalidate_cache(); diff --git a/src/crates/adapters/ai-adapters/src/client.rs b/src/crates/adapters/ai-adapters/src/client.rs index fff05f5637..5467e895a9 100644 --- a/src/crates/adapters/ai-adapters/src/client.rs +++ b/src/crates/adapters/ai-adapters/src/client.rs @@ -52,6 +52,9 @@ pub struct StreamOptions { /// reasoning, or tool-call data) after a request starts. `None` means wait /// indefinitely. pub ttft_timeout: Option, + /// TCP connect timeout in seconds while opening a streaming request. + /// `None` means wait indefinitely. + pub connect_timeout: Option, } #[derive(Debug, Clone)] @@ -67,7 +70,6 @@ impl AIClient { pub(crate) const TEST_IMAGE_EXPECTED_CODE: &'static str = "BYGR"; pub(crate) const TEST_IMAGE_PNG_BASE64: &'static str = "iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAIAAADTED8xAAACBklEQVR42u3ZsREAIAwDMYf9dw4txwJupI7Wua+YZEPBfO91h4ZjAgQAAgABgABAACAAEAAIAAQAAgABgABAACAAEAAIAAQAAgABgABAACAAEAAIAAQAAgABgABAACAAEAAIAAQAAgABgABAACAAEAAIAAQAAgABgABAACAAEAAIAAQAAgABIAAQAAgABAACAAGAAEAAIAAQAAgABAACAAGAAEAAIAAQAAgABAACAAGAAEAAIAAQAAgABAACAAGAAEAAIAAQAAgABAACAAGAAEAAIAAQAAgABAACAAGAAEAAIAAQAAgABIAAQAAgABAACAAEAAIAAYAAQAAgABAACAAEAAIAAYAAQAAgABAAAAAAAEDRZI3QGf7jDvEPAAIAAYAAQAAgABAACAAEAAIAAYAAQAAgABAACAAEAAIAAYAAQAAgABAACAABgABAACAAEAAIAAQAAgABgABAACAAEAAIAAQAAgABgABAACAAEAAIAAQAAgABgABAACAAEAAIAAQAAgABgABAACAAEAAIAAQAAgABgABAACAAEAAIAAQAAgABgABAAAjABAgABAACAAGAAEAAIAAQAAgABAACAAGAAEAAIAAQAAgABAACAAGAAEAAIAAQAAgABAACAAGAAEAAIAAQAAgABAACAAGAAEAAIAAQAAgABAACAAGAAEAAIAAQALwuLkoG8OSfau4AAAAASUVORK5CYII="; - pub(crate) const STREAM_CONNECT_TIMEOUT_SECS: u64 = 10; pub(crate) const HTTP_POOL_IDLE_TIMEOUT_SECS: u64 = 30; pub(crate) const HTTP_TCP_KEEPALIVE_SECS: u64 = 60; @@ -87,7 +89,11 @@ impl AIClient { proxy_config: Option, stream_options: StreamOptions, ) -> Self { - let client = http::create_http_client(proxy_config, config.skip_ssl_verify); + let client = http::create_http_client( + proxy_config, + config.skip_ssl_verify, + stream_options.connect_timeout, + ); Self { client, config, diff --git a/src/crates/adapters/ai-adapters/src/client/http.rs b/src/crates/adapters/ai-adapters/src/client/http.rs index 3477831804..bf6c1d147a 100644 --- a/src/crates/adapters/ai-adapters/src/client/http.rs +++ b/src/crates/adapters/ai-adapters/src/client/http.rs @@ -7,12 +7,10 @@ use reqwest::{Client, Proxy}; pub(crate) fn create_http_client( proxy_config: Option, skip_ssl_verify: bool, + connect_timeout: Option, ) -> Client { let mut builder = Client::builder() .tls_backend_rustls() - .connect_timeout(std::time::Duration::from_secs( - AIClient::STREAM_CONNECT_TIMEOUT_SECS, - )) .user_agent("BitFun/1.0") .pool_idle_timeout(std::time::Duration::from_secs( AIClient::HTTP_POOL_IDLE_TIMEOUT_SECS, @@ -23,6 +21,10 @@ pub(crate) fn create_http_client( ))) .danger_accept_invalid_certs(skip_ssl_verify); + // Default to 10s connect timeout if not specified (mirror stream_ttft behavior) + let timeout = connect_timeout.unwrap_or(std::time::Duration::from_secs(10)); + builder = builder.connect_timeout(timeout); + if skip_ssl_verify { warn!( "SSL certificate verification disabled - security risk, use only in test environments" diff --git a/src/crates/assembly/core/src/infrastructure/ai/mod.rs b/src/crates/assembly/core/src/infrastructure/ai/mod.rs index 799d947abc..971ee96f43 100644 --- a/src/crates/assembly/core/src/infrastructure/ai/mod.rs +++ b/src/crates/assembly/core/src/infrastructure/ai/mod.rs @@ -28,10 +28,13 @@ pub fn build_stream_options_for_model( _model_config: Option<&AIModelConfig>, ) -> StreamOptions { let idle_timeout = config.stream_idle_timeout_secs.map(Duration::from_secs); + let ttft_timeout = config.stream_ttft_timeout_secs.map(Duration::from_secs); + let connect_timeout = config.stream_connect_timeout_secs.map(Duration::from_secs); StreamOptions { idle_timeout, - ttft_timeout: config.stream_ttft_timeout_secs.map(Duration::from_secs), + ttft_timeout, + connect_timeout, } } @@ -52,6 +55,7 @@ mod tests { assert_eq!(options.ttft_timeout, Some(Duration::from_secs(600))); assert_eq!(options.idle_timeout, Some(Duration::from_secs(600))); + assert_eq!(options.connect_timeout, Some(Duration::from_secs(10))); } #[test] @@ -59,6 +63,7 @@ mod tests { let config = AIConfig { stream_idle_timeout_secs: None, stream_ttft_timeout_secs: None, + stream_connect_timeout_secs: None, ..Default::default() }; @@ -66,5 +71,6 @@ mod tests { assert_eq!(options.ttft_timeout, None); assert_eq!(options.idle_timeout, None); + assert_eq!(options.connect_timeout, None); } } diff --git a/src/crates/assembly/core/src/service/config/providers.rs b/src/crates/assembly/core/src/service/config/providers.rs index 41ac6144f2..d5a997fa94 100644 --- a/src/crates/assembly/core/src/service/config/providers.rs +++ b/src/crates/assembly/core/src/service/config/providers.rs @@ -80,6 +80,12 @@ fn ai_validation_error_location(message: &str) -> (String, String) { "AI_STREAM_TTFT_TIMEOUT_INVALID".to_string(), ); } + if message.contains("stream_connect_timeout_secs") { + return ( + "ai.stream_connect_timeout_secs".to_string(), + "AI_STREAM_CONNECT_TIMEOUT_INVALID".to_string(), + ); + } if message.contains("session-title task model") { return ( "ai.task_models.session_title".to_string(), @@ -154,6 +160,14 @@ impl ConfigProvider for AIConfigProvider { } } + if let Some(stream_connect_timeout_secs) = ai_config.stream_connect_timeout_secs { + if stream_connect_timeout_secs == 0 { + return Err(BitFunError::validation( + "AI stream_connect_timeout_secs must be greater than 0".to_string(), + )); + } + } + for (index, model) in ai_config.models.iter().enumerate() { if !model.enabled { continue; diff --git a/src/crates/assembly/core/src/service/config/types.rs b/src/crates/assembly/core/src/service/config/types.rs index acd8301223..5a9fe2db96 100644 --- a/src/crates/assembly/core/src/service/config/types.rs +++ b/src/crates/assembly/core/src/service/config/types.rs @@ -906,6 +906,11 @@ pub struct AIConfig { #[serde(default = "default_stream_ttft_timeout")] pub stream_ttft_timeout_secs: Option, + /// TCP connect timeout in seconds while opening a streaming request; + /// `None` means wait indefinitely. + #[serde(default = "default_stream_connect_timeout")] + pub stream_connect_timeout_secs: Option, + /// Tool execution timeout in seconds; `None` means wait indefinitely. #[serde(default = "default_tool_execution_timeout")] pub tool_execution_timeout_secs: Option, @@ -1147,6 +1152,10 @@ fn default_stream_ttft_timeout() -> Option { Some(600) } +fn default_stream_connect_timeout() -> Option { + Some(10) +} + /// Default is no timeout (wait forever). fn default_tool_execution_timeout() -> Option { None @@ -1961,6 +1970,7 @@ impl Default for AIConfig { proxy: ProxyConfig::default(), stream_idle_timeout_secs: default_stream_idle_timeout(), stream_ttft_timeout_secs: default_stream_ttft_timeout(), + stream_connect_timeout_secs: default_stream_connect_timeout(), tool_execution_timeout_secs: default_tool_execution_timeout(), enable_deferred_tool_loading: default_enable_deferred_tool_loading(), allow_tool_json_repair: true, @@ -2787,6 +2797,7 @@ mod tests { assert_eq!(config.stream_idle_timeout_secs, Some(600)); assert_eq!(config.stream_ttft_timeout_secs, Some(600)); + assert_eq!(config.stream_connect_timeout_secs, Some(10)); assert!(config.enable_deferred_tool_loading); assert!(config.allow_tool_json_repair); assert_eq!(config.subagent_max_concurrency, 5); @@ -2962,6 +2973,7 @@ mod tests { assert_eq!(config.stream_idle_timeout_secs, Some(600)); assert_eq!(config.stream_ttft_timeout_secs, Some(600)); + assert_eq!(config.stream_connect_timeout_secs, Some(10)); assert!(config.allow_tool_json_repair); assert_eq!(config.subagent_max_concurrency, 5); assert_eq!( diff --git a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx index 0175b40c9d..99a3253d5d 100644 --- a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx @@ -408,6 +408,7 @@ const AIModelConfig: React.FC = () => { }); const [streamIdleTimeoutInput, setStreamIdleTimeoutInput] = useState(''); const [streamTtftTimeoutInput, setStreamTtftTimeoutInput] = useState(''); + const [streamConnectTimeoutInput, setStreamConnectTimeoutInput] = useState(''); const [isStreamTimeoutSaving, setIsStreamTimeoutSaving] = useState(false); const [allowNormalToolJsonRepair, setAllowNormalToolJsonRepair] = useState(true); const [isToolJsonRepairSaving, setIsToolJsonRepairSaving] = useState(false); @@ -480,9 +481,14 @@ const AIModelConfig: React.FC = () => { () => parseOptionalPositiveIntegerInput(streamTtftTimeoutInput), [streamTtftTimeoutInput] ); + const parsedStreamConnectTimeout = useMemo( + () => parseOptionalPositiveIntegerInput(streamConnectTimeoutInput), + [streamConnectTimeoutInput] + ); const isStreamIdleTimeoutInvalid = parsedStreamIdleTimeout === undefined; const isStreamTtftTimeoutInvalid = parsedStreamTtftTimeout === undefined; - const isStreamTimeoutInvalid = isStreamIdleTimeoutInvalid || isStreamTtftTimeoutInvalid; + const isStreamConnectTimeoutInvalid = parsedStreamConnectTimeout === undefined; + const isStreamTimeoutInvalid = isStreamIdleTimeoutInvalid || isStreamTtftTimeoutInvalid || isStreamConnectTimeoutInvalid; const getCustomRequestBodyTrimHint = useCallback((provider?: string): string => { switch (provider) { @@ -547,11 +553,12 @@ const AIModelConfig: React.FC = () => { const loadConfig = useCallback(async () => { try { - const [models, proxy, streamIdleTimeoutSecs, streamTtftTimeoutSecs, allowJsonRepair] = await Promise.all([ + const [models, proxy, streamIdleTimeoutSecs, streamTtftTimeoutSecs, streamConnectTimeoutSecs, allowJsonRepair] = await Promise.all([ configManager.getConfig('ai.models'), configManager.getConfig('ai.proxy'), configManager.getConfig('ai.stream_idle_timeout_secs'), configManager.getConfig('ai.stream_ttft_timeout_secs'), + configManager.getConfig('ai.stream_connect_timeout_secs'), configManager.getConfig('ai.allow_tool_json_repair'), ]); setAiModels(models); @@ -566,6 +573,9 @@ const AIModelConfig: React.FC = () => { setStreamTtftTimeoutInput( streamTtftTimeoutSecs != null ? String(streamTtftTimeoutSecs) : '' ); + setStreamConnectTimeoutInput( + streamConnectTimeoutSecs != null ? String(streamConnectTimeoutSecs) : '' + ); setAllowNormalToolJsonRepair(allowJsonRepair !== false); } catch (error) { log.error('Failed to load AI config', error); @@ -3084,6 +3094,22 @@ const AIModelConfig: React.FC = () => { ); + const streamConnectTimeoutLabel = ( + + {t('streamConnectTimeout.label')} + + + + + + + ); + const streamIdleTimeoutLabel = ( {t('streamIdleTimeout.label')} @@ -3524,6 +3550,17 @@ const AIModelConfig: React.FC = () => { inputSize="small" /> + + setStreamConnectTimeoutInput(e.target.value)} + placeholder={t('streamConnectTimeout.placeholder')} + inputSize="small" + /> +