From 9c44bb956718196aebed386de4d8c275ba77ff13 Mon Sep 17 00:00:00 2001 From: user Date: Sun, 30 Aug 2026 21:11:53 +0800 Subject: [PATCH 1/4] feat(ai): make stream connect timeout configurable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The streaming HTTP client previously used a hardcoded TCP connect timeout (STREAM_CONNECT_TIMEOUT_SECS = 10). This rigid configuration lacks flexibility for diverse network environments and does not follow the project's existing timeout configuration pattern (e.g., stream_idle_timeout_secs, stream_ttft_timeout_secs), which all use Option with serde defaults. This change introduces stream_connect_timeout_secs: Option to AIConfig with default value Some(10) (preserving original behavior). The propagation path is: AIConfig.stream_connect_timeout_secs → StreamOptions.connect_timeout → http::create_http_client() → reqwest::ClientBuilder::connect_timeout(if-let). Cleanup: Removed dead code STREAM_CONNECT_TIMEOUT_SECS constant. Frontend integration: Added config component (AIModelConfig.tsx) with load/save/render cycle, plus TypeScript type definition (index.ts). i18n coverage: English (en-US), Simplified Chinese (zh-CN), Traditional Chinese (zh-TW) labels/hints/placeholders. Test: Light test (compile pass / type-check / i18n:audit 0 warning) AI: Semantic quality optimization rewrite (mirroring ttft chain pattern, aligned with upstream Duration style) --- src/apps/cli/tests/support/mod.rs | 3 +- src/apps/desktop/src/api/config_api.rs | 1 + src/crates/adapters/ai-adapters/src/client.rs | 10 ++++- .../adapters/ai-adapters/src/client/http.rs | 8 ++-- .../core/src/infrastructure/ai/mod.rs | 7 +++- .../core/src/service/config/providers.rs | 14 +++++++ .../assembly/core/src/service/config/types.rs | 12 ++++++ .../config/components/AIModelConfig.tsx | 41 ++++++++++++++++++- .../src/infrastructure/config/types/index.ts | 1 + .../src/locales/en-US/settings/ai-model.json | 9 ++++ .../src/locales/zh-CN/settings/ai-model.json | 9 ++++ .../src/locales/zh-TW/settings/ai-model.json | 9 ++++ 12 files changed, 115 insertions(+), 9 deletions(-) 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..852710a3a8 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); + if let Some(timeout) = connect_timeout { + 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..cb92a59070 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] @@ -66,5 +70,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" + /> + Date: Mon, 31 Aug 2026 01:03:45 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(ai):=20restore=20default=20connect=20ti?= =?UTF-8?q?meout=20behavior=20for=20G2=20=E8=87=B4=E5=9B=A0=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: G2 introduced conditional connect_timeout setting (if let Some(timeout) = connect_timeout) which caused http client to have NO connect timeout when stream_options.connect_timeout=None. The test lightweight_client_negotiates_with_the_production_server uses StreamOptions::default() where connect_timeout=None, leading to indefinite connection wait and session/reloadContext method not being advertised in capabilities negotiation. Fix: Restore unconditional 10s default timeout using unwrap_or(10), mirroring pre-G2 behavior and following ttft chain pattern. --- src/crates/adapters/ai-adapters/src/client/http.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/crates/adapters/ai-adapters/src/client/http.rs b/src/crates/adapters/ai-adapters/src/client/http.rs index 852710a3a8..bf6c1d147a 100644 --- a/src/crates/adapters/ai-adapters/src/client/http.rs +++ b/src/crates/adapters/ai-adapters/src/client/http.rs @@ -21,9 +21,9 @@ pub(crate) fn create_http_client( ))) .danger_accept_invalid_certs(skip_ssl_verify); - if let Some(timeout) = connect_timeout { - builder = builder.connect_timeout(timeout); - } + // 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!( From ca77749746d0fcba3ed9adf7abb0e6e935c8565a Mon Sep 17 00:00:00 2001 From: user Date: Mon, 31 Aug 2026 06:34:34 +0800 Subject: [PATCH 3/4] fix(ai): set stream_connect_timeout_secs None in explicit-none test The test explicit_none_stream_timeouts_mean_wait_indefinitely constructs an AIConfig with idle/ttft None and ..Default::default(), but stream_connect_timeout defaults to Some(10) via the G2 field. Set connect None explicitly so the 'wait indefinitely' assertion holds. --- src/crates/adapters/ai-adapters/src/client/http.rs | 6 +++--- src/crates/assembly/core/src/infrastructure/ai/mod.rs | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/crates/adapters/ai-adapters/src/client/http.rs b/src/crates/adapters/ai-adapters/src/client/http.rs index bf6c1d147a..852710a3a8 100644 --- a/src/crates/adapters/ai-adapters/src/client/http.rs +++ b/src/crates/adapters/ai-adapters/src/client/http.rs @@ -21,9 +21,9 @@ 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 let Some(timeout) = connect_timeout { + builder = builder.connect_timeout(timeout); + } if skip_ssl_verify { warn!( diff --git a/src/crates/assembly/core/src/infrastructure/ai/mod.rs b/src/crates/assembly/core/src/infrastructure/ai/mod.rs index cb92a59070..971ee96f43 100644 --- a/src/crates/assembly/core/src/infrastructure/ai/mod.rs +++ b/src/crates/assembly/core/src/infrastructure/ai/mod.rs @@ -63,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() }; From 39917f1ade333896657070bbe7d93e72450d4d2a Mon Sep 17 00:00:00 2001 From: user Date: Mon, 31 Aug 2026 06:44:49 +0800 Subject: [PATCH 4/4] fix(ai): restore default 10s connect timeout in http client Revert the if-let change from ca777497 back to connect_timeout .unwrap_or(Duration::from_secs(10)). The lightweight client stream needs the http layer connect_timeout to default to 10s so it does not hang; keeping the mod.rs explicit-none test fix untouched. --- src/crates/adapters/ai-adapters/src/client/http.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/crates/adapters/ai-adapters/src/client/http.rs b/src/crates/adapters/ai-adapters/src/client/http.rs index 852710a3a8..bf6c1d147a 100644 --- a/src/crates/adapters/ai-adapters/src/client/http.rs +++ b/src/crates/adapters/ai-adapters/src/client/http.rs @@ -21,9 +21,9 @@ pub(crate) fn create_http_client( ))) .danger_accept_invalid_certs(skip_ssl_verify); - if let Some(timeout) = connect_timeout { - builder = builder.connect_timeout(timeout); - } + // 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!(