From ab44458d23ba4b338bbbeffddf616b8f05b43c5c Mon Sep 17 00:00:00 2001 From: wsp1911 Date: Wed, 1 Jul 2026 10:51:27 +0800 Subject: [PATCH] fix: refine AI model connection testing Use a dedicated 5-attempt retry budget for AI connection tests while leaving normal streaming requests at the existing 10-attempt budget. Expand transport error details so TLS/certificate, proxy, and network failures can surface localized troubleshooting guidance instead of only showing a generic request send failure. Validate model API URLs before saving so they must start with http:// or https://. Also narrow automatic post-save connection tests to new or request-affecting model changes, and only retest an entire provider group when provider-level connection settings changed. --- src/crates/adapters/ai-adapters/src/client.rs | 82 ++++++++++++- .../ai-adapters/src/client/healthcheck.rs | 76 +++++++++++- .../adapters/ai-adapters/src/client/sse.rs | 13 +- src/crates/contracts/core-types/src/ai.rs | 3 + .../config/components/AIModelConfig.tsx | 113 +++++++++++++++--- .../src/locales/en-US/settings/ai-model.json | 6 +- .../src/locales/zh-CN/settings/ai-model.json | 6 +- .../src/locales/zh-TW/settings/ai-model.json | 6 +- .../shared/utils/aiConnectionTestMessages.ts | 8 +- 9 files changed, 282 insertions(+), 31 deletions(-) diff --git a/src/crates/adapters/ai-adapters/src/client.rs b/src/crates/adapters/ai-adapters/src/client.rs index a072ce8669..9e68e7d9f4 100644 --- a/src/crates/adapters/ai-adapters/src/client.rs +++ b/src/crates/adapters/ai-adapters/src/client.rs @@ -26,6 +26,7 @@ use std::time::Duration; use tokio::sync::mpsc; const SEND_MESSAGE_STREAM_ATTEMPTS: usize = 10; +const TEST_CONNECTION_STREAM_ATTEMPTS: usize = 5; const SEND_MESSAGE_RETRY_BASE_DELAY_MS: u64 = 500; /// Streamed response result with the parsed stream and optional raw SSE receiver. @@ -208,12 +209,31 @@ impl AIClient { extra_body: Option, trace: Option, ) -> Result { - for attempt in 0..SEND_MESSAGE_STREAM_ATTEMPTS { + self.send_message_with_extra_body_trace_and_max_attempts( + messages, + tools, + extra_body, + trace, + SEND_MESSAGE_STREAM_ATTEMPTS, + ) + .await + } + + async fn send_message_with_extra_body_trace_and_max_attempts( + &self, + messages: Vec, + tools: Option>, + extra_body: Option, + trace: Option, + max_attempts: usize, + ) -> Result { + for attempt in 0..max_attempts { let stream_response = self - .send_message_stream_with_extra_body( + .send_message_stream_with_extra_body_and_max_attempts( messages.clone(), tools.clone(), extra_body.clone(), + max_attempts, trace.clone(), ) .await?; @@ -226,7 +246,7 @@ impl AIClient { return Ok(response); } Err(error) - if attempt < SEND_MESSAGE_STREAM_ATTEMPTS - 1 + if attempt < max_attempts - 1 && is_transient_stream_error(&error.to_string()) => { fail_aggregated_trace( @@ -239,7 +259,7 @@ impl AIClient { warn!( "Retrying aggregated AI stream after transient error: attempt={}/{}, delay_ms={}, error={}", attempt + 1, - SEND_MESSAGE_STREAM_ATTEMPTS, + max_attempts, delay_ms, error ); @@ -260,12 +280,62 @@ impl AIClient { unreachable!("send_message retry loop always returns") } + async fn send_message_stream_with_extra_body_and_max_attempts( + &self, + messages: Vec, + tools: Option>, + extra_body: Option, + max_tries: usize, + trace: Option, + ) -> Result { + match ApiFormat::parse(&self.config.format)? { + ApiFormat::OpenAIChat => { + openai::chat::send_stream(self, messages, tools, extra_body, max_tries, trace).await + } + ApiFormat::OpenAIResponses => { + openai::responses::send_stream(self, messages, tools, extra_body, max_tries, trace) + .await + } + ApiFormat::Anthropic => { + anthropic::request::send_stream(self, messages, tools, extra_body, max_tries, trace) + .await + } + ApiFormat::Gemini => { + gemini::request::send_stream(self, messages, tools, extra_body, max_tries, trace) + .await + } + ApiFormat::GeminiCodeAssist => { + gemini::code_assist::send_stream( + self, messages, tools, extra_body, max_tries, trace, + ) + .await + } + } + } + pub async fn test_connection(&self) -> Result { - healthcheck::test_connection(self).await + healthcheck::test_connection(self, TEST_CONNECTION_STREAM_ATTEMPTS).await } pub async fn test_image_input_connection(&self) -> Result { - healthcheck::test_image_input_connection(self).await + healthcheck::test_image_input_connection(self, TEST_CONNECTION_STREAM_ATTEMPTS).await + } + + pub(crate) async fn send_test_message( + &self, + messages: Vec, + tools: Option>, + max_attempts: usize, + ) -> Result { + let custom_body = self.config.custom_request_body.clone(); + self.send_message_with_extra_body_trace_and_max_attempts( + messages, + tools, + custom_body, + None, + max_attempts, + ) + .await } pub async fn list_models(&self) -> Result> { diff --git a/src/crates/adapters/ai-adapters/src/client/healthcheck.rs b/src/crates/adapters/ai-adapters/src/client/healthcheck.rs index 4a25ebb945..f298baeeec 100644 --- a/src/crates/adapters/ai-adapters/src/client/healthcheck.rs +++ b/src/crates/adapters/ai-adapters/src/client/healthcheck.rs @@ -58,7 +58,62 @@ pub(crate) fn image_test_response_matches_expected(response: &str) -> bool { color_letter_stream.contains(AIClient::TEST_IMAGE_EXPECTED_CODE) } -pub(crate) async fn test_connection(client: &AIClient) -> Result { +fn connection_error_message_code(error_msg: &str) -> Option { + let msg = error_msg.to_ascii_lowercase(); + + let tls_keywords = [ + "certificate", + "cert", + "tls", + "ssl", + "rustls", + "native-tls", + "handshake", + "unknownissuer", + "unknown issuer", + "invalid peer certificate", + "peer certificate", + "certificate verify", + "certificate verification", + "invalid certificate", + "self signed", + "self-signed", + "webpki", + ]; + if tls_keywords.iter().any(|keyword| msg.contains(keyword)) { + return Some(ConnectionTestMessageCode::TlsOrCertificateIssue); + } + + let proxy_keywords = ["proxy", "tunnel", "connect tunnel", "http connect"]; + if proxy_keywords.iter().any(|keyword| msg.contains(keyword)) { + return Some(ConnectionTestMessageCode::ProxyIssue); + } + + let network_keywords = [ + "connection failed", + "error sending request", + "dns", + "network", + "connection refused", + "connection reset", + "connection closed", + "timed out", + "timeout", + "econnreset", + "econnrefused", + "etimedout", + ]; + if network_keywords.iter().any(|keyword| msg.contains(keyword)) { + return Some(ConnectionTestMessageCode::NetworkIssue); + } + + None +} + +pub(crate) async fn test_connection( + client: &AIClient, + max_attempts: usize, +) -> Result { let start_time = std::time::Instant::now(); let test_messages = vec![Message::user( @@ -77,7 +132,10 @@ pub(crate) async fn test_connection(client: &AIClient) -> Result { let response_time_ms = elapsed_ms_u64(start_time); if response.tool_calls.is_some() { @@ -106,14 +164,17 @@ pub(crate) async fn test_connection(client: &AIClient) -> Result Result { +pub(crate) async fn test_image_input_connection( + client: &AIClient, + max_attempts: usize, +) -> Result { let start_time = std::time::Instant::now(); let provider = client.config.format.to_ascii_lowercase(); let prompt = "Inspect the attached image and reply with exactly one 4-letter code for quadrant colors in TL,TR,BL,BR order using letters R,G,B,Y (R=red, G=green, B=blue, Y=yellow)."; @@ -160,7 +221,10 @@ pub(crate) async fn test_image_input_connection(client: &AIClient) -> Result { if image_test_response_matches_expected(&response.text) { Ok(ConnectionTestResult { @@ -193,7 +257,7 @@ pub(crate) async fn test_image_input_connection(client: &AIClient) -> Result String { - format!("{} connection failed: {}", label, error) + let mut message = format!("{} connection failed: {}", label, error); + let mut source = error.source(); + let mut index = 1; + + while let Some(cause) = source { + message.push_str(&format!("; cause {}: {}", index, cause)); + source = cause.source(); + index += 1; + } + + message } fn is_retryable_http_status(status: StatusCode) -> bool { diff --git a/src/crates/contracts/core-types/src/ai.rs b/src/crates/contracts/core-types/src/ai.rs index 322f486e10..fed192263a 100644 --- a/src/crates/contracts/core-types/src/ai.rs +++ b/src/crates/contracts/core-types/src/ai.rs @@ -186,6 +186,9 @@ impl Message { pub enum ConnectionTestMessageCode { ToolCallsNotDetected, ImageInputCheckFailed, + TlsOrCertificateIssue, + ProxyIssue, + NetworkIssue, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx index c6cb0712b2..f93692c95e 100644 --- a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx @@ -277,6 +277,90 @@ function previewRequestUrl(baseUrl: string, provider: string): string { return resolveRequestUrl(baseUrl, provider); } +function hasHttpUrlScheme(value: string): boolean { + return /^https?:\/\//i.test(value.trim()); +} + +function stableJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(stableJson).join(',')}]`; + } + if (value && typeof value === 'object') { + return `{${Object.entries(value as Record) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableJson(entryValue)}`) + .join(',')}}`; + } + return JSON.stringify(value); +} + +function normalizeComparableString(value: string | undefined): string { + return (value || '').trim(); +} + +function providerConnectionChanged( + previous: AIModelConfigType | undefined, + next: AIModelConfigType +): boolean { + if (!previous) return true; + + return ( + normalizeComparableString(previous.provider) !== normalizeComparableString(next.provider) || + normalizeComparableString(previous.base_url) !== normalizeComparableString(next.base_url) || + normalizeComparableString(previous.api_key) !== normalizeComparableString(next.api_key) || + stableJson(previous.auth || { type: 'api_key' }) !== stableJson(next.auth || { type: 'api_key' }) || + stableJson(previous.custom_headers || {}) !== stableJson(next.custom_headers || {}) || + normalizeComparableString(previous.custom_headers_mode) !== normalizeComparableString(next.custom_headers_mode) || + normalizeComparableString(previous.custom_request_body) !== normalizeComparableString(next.custom_request_body) || + normalizeComparableString(previous.custom_request_body_mode) !== normalizeComparableString(next.custom_request_body_mode) || + (previous.skip_ssl_verify ?? false) !== (next.skip_ssl_verify ?? false) + ); +} + +function modelRequestBehaviorChanged( + previous: AIModelConfigType | undefined, + next: AIModelConfigType +): boolean { + if (!previous) return true; + + return ( + normalizeComparableString(previous.model_name) !== normalizeComparableString(next.model_name) || + normalizeComparableString(previous.request_url) !== normalizeComparableString(next.request_url) || + previous.context_window !== next.context_window || + previous.max_tokens !== next.max_tokens || + previous.category !== next.category || + stableJson(previous.capabilities || []) !== stableJson(next.capabilities || []) || + normalizeComparableString(previous.reasoning_mode) !== normalizeComparableString(next.reasoning_mode) || + normalizeComparableString(previous.reasoning_effort) !== normalizeComparableString(next.reasoning_effort) || + previous.thinking_budget_tokens !== next.thinking_budget_tokens || + (previous.inline_think_in_text ?? true) !== (next.inline_think_in_text ?? true) + ); +} + +function configsNeedingAutoTest( + previousModels: AIModelConfigType[], + nextConfigs: AIModelConfigType[], + isProviderGroupEdit: boolean +): AIModelConfigType[] { + const previousById = new Map(previousModels.map(model => [model.id, model])); + const providerConnectionWasChanged = isProviderGroupEdit && nextConfigs.some(config => + providerConnectionChanged(previousById.get(config.id), config) + ); + + if (providerConnectionWasChanged) { + return nextConfigs; + } + + return nextConfigs.filter(config => { + const previous = previousById.get(config.id); + return ( + !previous || + providerConnectionChanged(previous, config) || + modelRequestBehaviorChanged(previous, config) + ); + }); +} + const AIModelConfig: React.FC = () => { const { t } = useTranslation('settings/ai-model'); const { t: tDefault } = useTranslation('settings/default-model'); @@ -1010,11 +1094,15 @@ const AIModelConfig: React.FC = () => { try { const providerName = editingConfig.name.trim(); - const baseUrl = editingConfig.base_url; + const baseUrl = editingConfig.base_url.trim(); if (!providerName || !baseUrl) { notification.warning(t('messages.fillRequired')); return; } + if (!hasHttpUrlScheme(baseUrl)) { + notification.warning(t('messages.invalidBaseUrlScheme')); + return; + } const draftsToSave = dedupeSelectedModelDraftsByModelName(selectedModelDrafts); const existingProviderInstanceId = getProviderInstanceId(editingConfig); const isProviderGroupEdit = !editingConfig.id && editingProviderModelIds.size > 0; @@ -1067,6 +1155,11 @@ const AIModelConfig: React.FC = () => { auth: editingConfig.auth || { type: 'api_key' }, }; }); + const configsToAutoTest = configsNeedingAutoTest( + aiModels, + configsToSave, + isProviderGroupEdit + ); let updatedModels: AIModelConfigType[]; if (editingConfig.id) { @@ -1106,17 +1199,6 @@ const AIModelConfig: React.FC = () => { } - const createdConfigIds = configsToSave.map(config => config.id).filter((id): id is string => !!id); - if (createdConfigIds.length === 0) { - - setIsEditing(false); - setEditingConfig(null); - setCreationMode(null); - setSelectedProviderId(null); - setEditingProviderModelIds(new Set()); - return; - } - setIsEditing(false); setEditingConfig(null); setCreationMode(null); @@ -1124,11 +1206,14 @@ const AIModelConfig: React.FC = () => { setEditingProviderModelIds(new Set()); - setExpandedIds(prev => new Set([...prev, ...createdConfigIds])); + const autoTestConfigIds = configsToAutoTest.map(config => config.id).filter((id): id is string => !!id); + if (autoTestConfigIds.length > 0) { + setExpandedIds(prev => new Set([...prev, ...autoTestConfigIds])); + } - configsToSave.forEach(config => { + configsToAutoTest.forEach(config => { const configId = config.id; if (!configId) return; diff --git a/src/web-ui/src/locales/en-US/settings/ai-model.json b/src/web-ui/src/locales/en-US/settings/ai-model.json index 4fcc0d3d27..efe84ba007 100644 --- a/src/web-ui/src/locales/en-US/settings/ai-model.json +++ b/src/web-ui/src/locales/en-US/settings/ai-model.json @@ -271,6 +271,7 @@ "fillRequired": "Please fill in required fields", "fillModelName": "Please fill in model name", "duplicateModelNameUnderProvider": "This provider already has a model with this name. Use a different name or edit the existing entry.", + "invalidBaseUrlScheme": "API URL must start with http:// or https://", "saveFailed": "Save failed", "deleteFailed": "Failed to delete configuration", "loadFailed": "Failed to load AI configuration", @@ -279,7 +280,10 @@ "errorDetails": "Error details", "connectionTestMessages": { "toolCallsNotDetected": "This test did not detect any tool calls, so tool calling could not be verified this time.", - "imageInputCheckFailed": "The connection succeeded, but the image input check failed." + "imageInputCheckFailed": "The connection succeeded, but the image input check failed.", + "tlsOrCertificateIssue": "The connection failure may be related to SSL/TLS certificate verification. Check corporate proxies, self-signed certificates, system root certificates, or network interception. If you trust this network environment, you can enable \"Skip SSL Certificate Verification\" in this model's Advanced Settings, but it reduces HTTPS security.", + "proxyIssue": "The connection failure may be related to proxy or tunnel configuration. Check global proxy settings, proxy authentication, HTTPS CONNECT support, and whether the current network can reach this model API endpoint.", + "networkIssue": "The request failed while being sent, before any HTTP response was received. Check network connectivity, DNS, proxy, firewall, and certificate settings. In corporate proxy or self-signed certificate environments, you may also need to review \"Skip SSL Certificate Verification\" in Advanced Settings." }, "autoSetPrimary": "Automatically set as primary model", "defaultDescription": "{{name}} model configuration", diff --git a/src/web-ui/src/locales/zh-CN/settings/ai-model.json b/src/web-ui/src/locales/zh-CN/settings/ai-model.json index 3759b445e3..da84c42568 100644 --- a/src/web-ui/src/locales/zh-CN/settings/ai-model.json +++ b/src/web-ui/src/locales/zh-CN/settings/ai-model.json @@ -271,6 +271,7 @@ "fillRequired": "请填写必要字段", "fillModelName": "请填写模型名称", "duplicateModelNameUnderProvider": "该服务商下已有同名模型,请改用其他名称或编辑已有条目", + "invalidBaseUrlScheme": "API地址必须以 http:// 或 https:// 开头", "saveFailed": "保存失败", "deleteFailed": "删除配置失败", "loadFailed": "加载AI配置失败", @@ -279,7 +280,10 @@ "errorDetails": "详细错误", "connectionTestMessages": { "toolCallsNotDetected": "本次测试未检测到工具调用,因此暂时无法验证工具调用能力。", - "imageInputCheckFailed": "连接已成功,但图片输入测试未通过。" + "imageInputCheckFailed": "连接已成功,但图片输入测试未通过。", + "tlsOrCertificateIssue": "连接失败可能与 SSL/TLS 证书校验有关。请检查公司代理、自签证书、系统根证书或网络拦截设置;如果确认网络环境可信,可在该模型的高级设置中开启“跳过SSL证书验证”,但这会降低 HTTPS 安全性。", + "proxyIssue": "连接失败可能与代理或隧道配置有关。请检查全局代理设置、代理认证、代理是否支持 HTTPS CONNECT,以及当前网络是否允许访问该模型 API 地址。", + "networkIssue": "连接失败发生在发送请求阶段,尚未收到服务端 HTTP 响应。请检查网络连通性、DNS、代理、防火墙和证书环境;如果处在公司代理或自签证书环境,也可能需要检查高级设置中的“跳过SSL证书验证”。" }, "autoSetPrimary": "已自动设为主力模型", "defaultDescription": "{{name}} 模型配置", diff --git a/src/web-ui/src/locales/zh-TW/settings/ai-model.json b/src/web-ui/src/locales/zh-TW/settings/ai-model.json index 8ac650fd07..3a00b03256 100644 --- a/src/web-ui/src/locales/zh-TW/settings/ai-model.json +++ b/src/web-ui/src/locales/zh-TW/settings/ai-model.json @@ -271,6 +271,7 @@ "fillRequired": "請填寫必要字段", "fillModelName": "請填寫模型名稱", "duplicateModelNameUnderProvider": "該服務商下已有同名模型,請改用其他名稱或編輯已有條目", + "invalidBaseUrlScheme": "API地址必須以 http:// 或 https:// 開頭", "saveFailed": "儲存失敗", "deleteFailed": "刪除設定失敗", "loadFailed": "載入AI設定失敗", @@ -279,7 +280,10 @@ "errorDetails": "詳細錯誤", "connectionTestMessages": { "toolCallsNotDetected": "本次測試未檢測到工具調用,因此暫時無法驗證工具調用能力。", - "imageInputCheckFailed": "連接已成功,但圖片輸入測試未通過。" + "imageInputCheckFailed": "連接已成功,但圖片輸入測試未通過。", + "tlsOrCertificateIssue": "連接失敗可能與 SSL/TLS 證書校驗有關。請檢查公司代理、自簽證書、系統根證書或網路攔截設定;如果確認網路環境可信,可在該模型的高級設置中開啟「跳過SSL證書驗證」,但這會降低 HTTPS 安全性。", + "proxyIssue": "連接失敗可能與代理或隧道設定有關。請檢查全局代理設定、代理認證、代理是否支援 HTTPS CONNECT,以及目前網路是否允許訪問該模型 API 地址。", + "networkIssue": "連接失敗發生在發送請求階段,尚未收到服務端 HTTP 回應。請檢查網路連通性、DNS、代理、防火牆和證書環境;如果處在公司代理或自簽證書環境,也可能需要檢查高級設置中的「跳過SSL證書驗證」。" }, "autoSetPrimary": "已自動設為主力模型", "defaultDescription": "{{name}} 模型設定", diff --git a/src/web-ui/src/shared/utils/aiConnectionTestMessages.ts b/src/web-ui/src/shared/utils/aiConnectionTestMessages.ts index 5bc243dc30..ea1fc652b9 100644 --- a/src/web-ui/src/shared/utils/aiConnectionTestMessages.ts +++ b/src/web-ui/src/shared/utils/aiConnectionTestMessages.ts @@ -2,11 +2,17 @@ type TranslateFn = (key: string) => string; export type ConnectionTestMessageCode = | 'tool_calls_not_detected' - | 'image_input_check_failed'; + | 'image_input_check_failed' + | 'tls_or_certificate_issue' + | 'proxy_issue' + | 'network_issue'; const MESSAGE_KEY_BY_CODE: Record = { tool_calls_not_detected: 'messages.connectionTestMessages.toolCallsNotDetected', image_input_check_failed: 'messages.connectionTestMessages.imageInputCheckFailed', + tls_or_certificate_issue: 'messages.connectionTestMessages.tlsOrCertificateIssue', + proxy_issue: 'messages.connectionTestMessages.proxyIssue', + network_issue: 'messages.connectionTestMessages.networkIssue', }; export function translateConnectionTestMessage(