Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/apps/cli/tests/support/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions src/apps/desktop/src/api/config_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
10 changes: 8 additions & 2 deletions src/crates/adapters/ai-adapters/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ pub struct StreamOptions {
/// reasoning, or tool-call data) after a request starts. `None` means wait
/// indefinitely.
pub ttft_timeout: Option<Duration>,
/// TCP connect timeout in seconds while opening a streaming request.
/// `None` means wait indefinitely.
pub connect_timeout: Option<Duration>,
}

#[derive(Debug, Clone)]
Expand All @@ -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;

Expand All @@ -87,7 +89,11 @@ impl AIClient {
proxy_config: Option<ProxyConfig>,
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,
Expand Down
8 changes: 5 additions & 3 deletions src/crates/adapters/ai-adapters/src/client/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,10 @@ use reqwest::{Client, Proxy};
pub(crate) fn create_http_client(
proxy_config: Option<ProxyConfig>,
skip_ssl_verify: bool,
connect_timeout: Option<std::time::Duration>,
) -> 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,
Expand All @@ -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"
Expand Down
8 changes: 7 additions & 1 deletion src/crates/assembly/core/src/infrastructure/ai/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}

Expand All @@ -52,19 +55,22 @@ 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]
fn explicit_none_stream_timeouts_mean_wait_indefinitely() {
let config = AIConfig {
stream_idle_timeout_secs: None,
stream_ttft_timeout_secs: None,
stream_connect_timeout_secs: None,
..Default::default()
};

let options = build_stream_options_for_model(&config, None);

assert_eq!(options.ttft_timeout, None);
assert_eq!(options.idle_timeout, None);
assert_eq!(options.connect_timeout, None);
}
}
14 changes: 14 additions & 0 deletions src/crates/assembly/core/src/service/config/providers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions src/crates/assembly/core/src/service/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -906,6 +906,11 @@ pub struct AIConfig {
#[serde(default = "default_stream_ttft_timeout")]
pub stream_ttft_timeout_secs: Option<u64>,

/// 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<u64>,

/// Tool execution timeout in seconds; `None` means wait indefinitely.
#[serde(default = "default_tool_execution_timeout")]
pub tool_execution_timeout_secs: Option<u64>,
Expand Down Expand Up @@ -1147,6 +1152,10 @@ fn default_stream_ttft_timeout() -> Option<u64> {
Some(600)
}

fn default_stream_connect_timeout() -> Option<u64> {
Some(10)
}

/// Default is no timeout (wait forever).
fn default_tool_execution_timeout() -> Option<u64> {
None
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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!(
Expand Down
41 changes: 39 additions & 2 deletions src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<AIModelConfigType[]>('ai.models'),
configManager.getConfig<ProxyConfig>('ai.proxy'),
configManager.getConfig<number | null>('ai.stream_idle_timeout_secs'),
configManager.getConfig<number | null>('ai.stream_ttft_timeout_secs'),
configManager.getConfig<number | null>('ai.stream_connect_timeout_secs'),
configManager.getConfig<boolean>('ai.allow_tool_json_repair'),
]);
setAiModels(models);
Expand All @@ -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);
Expand Down Expand Up @@ -3084,6 +3094,22 @@ const AIModelConfig: React.FC = () => {
</span>
);

const streamConnectTimeoutLabel = (
<span className="bitfun-ai-model-config__inline-header-main">
<span>{t('streamConnectTimeout.label')}</span>
<Tooltip content={t('streamConnectTimeout.hint')} placement="top">
<span
className="bitfun-ai-model-config__inline-header-info"
role="button"
tabIndex={0}
aria-label={t('streamConnectTimeout.hint')}
>
<Info size={14} />
</span>
</Tooltip>
</span>
);

const streamIdleTimeoutLabel = (
<span className="bitfun-ai-model-config__inline-header-main">
<span>{t('streamIdleTimeout.label')}</span>
Expand Down Expand Up @@ -3524,6 +3550,17 @@ const AIModelConfig: React.FC = () => {
inputSize="small"
/>
</ConfigPageRow>
<ConfigPageRow
label={streamConnectTimeoutLabel}
align="center"
>
<Input
value={streamConnectTimeoutInput}
onChange={(e) => setStreamConnectTimeoutInput(e.target.value)}
placeholder={t('streamConnectTimeout.placeholder')}
inputSize="small"
/>
</ConfigPageRow>
<ConfigPageRow
label={streamIdleTimeoutLabel}
align="center"
Expand Down
1 change: 1 addition & 0 deletions src/web-ui/src/infrastructure/config/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,7 @@ export interface AIConfig {
conversation_history_limit: number;
stream_idle_timeout_secs?: number | null;
stream_ttft_timeout_secs?: number | null;
stream_connect_timeout_secs?: number | null;
tool_execution_timeout_secs?: number | null;
allow_tool_json_repair?: boolean;
subagent_batch_execution_policy?: 'safe_only' | 'force_parallel' | 'serial';
Expand Down
9 changes: 9 additions & 0 deletions src/web-ui/src/locales/en-US/settings/ai-model.json
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,15 @@
"save": "Save First Token Timeout",
"saveSuccess": "First token timeout saved"
},
"streamConnectTimeout": {
"title": "Connect Timeout",
"label": "TCP Connect Timeout (seconds)",
"hint": "Maximum wait time when establishing a TCP connection. Leave empty to wait indefinitely.",
"placeholder": "Leave empty for no timeout",
"invalid": "Enter a positive integer in seconds, or leave this field empty.",
"save": "Save Connect Timeout",
"saveSuccess": "Connect timeout saved"
},
"toolArgumentJsonRepair": {
"title": "Tool Argument JSON Repair",
"description": "Applies to the next model round.",
Expand Down
9 changes: 9 additions & 0 deletions src/web-ui/src/locales/zh-CN/settings/ai-model.json
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,15 @@
"save": "保存首 Token 超时",
"saveSuccess": "首 Token 超时已保存"
},
"streamConnectTimeout": {
"title": "连接超时",
"label": "TCP 连接超时(秒)",
"hint": "建立 TCP 连接时的最大等待时间。留空表示无限等待。",
"placeholder": "留空表示不设超时",
"invalid": "请输入正整数秒数,或留空。",
"save": "保存连接超时",
"saveSuccess": "连接超时已保存"
},
"toolArgumentJsonRepair": {
"title": "工具参数 JSON 修复",
"description": "修改将在下一轮模型调用生效。",
Expand Down
9 changes: 9 additions & 0 deletions src/web-ui/src/locales/zh-TW/settings/ai-model.json
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,15 @@
"save": "儲存首 Token 超時",
"saveSuccess": "首 Token 超時已儲存"
},
"streamConnectTimeout": {
"title": "連接超時",
"label": "TCP 連接超時(秒)",
"hint": "建立 TCP 連線時的最大等待時間。留空表示無限等待。",
"placeholder": "留空表示不設超時",
"invalid": "請輸入正整數秒數,或留空。",
"save": "儲存連接超時",
"saveSuccess": "連接超時已儲存"
},
"toolArgumentJsonRepair": {
"title": "工具參數 JSON 修復",
"description": "修改將在下一輪模型呼叫生效。",
Expand Down