From cdfcf7174c2334e72f7ead722a84bf530c77c026 Mon Sep 17 00:00:00 2001 From: italic-jinxin <106428113+italic-jinxin@users.noreply.github.com> Date: Thu, 26 Mar 2026 09:38:27 +0800 Subject: [PATCH] fix: test_connection sends actual chat completion --- src/channels/web/server.rs | 104 ++++++++++++++------------------- src/channels/web/static/app.js | 20 +++++-- 2 files changed, 59 insertions(+), 65 deletions(-) diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index f9b39429..109c019c 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -2619,6 +2619,8 @@ async fn routines_runs_handler( struct TestConnectionRequest { adapter: String, base_url: String, + /// Model to use for the test chat completion request. + model: String, #[serde(default)] api_key: Option, /// Provider identifier used to look up the vaulted API key when `api_key` @@ -2628,11 +2630,6 @@ struct TestConnectionRequest { /// `"builtin"` or `"custom"` — determines the secret name prefix. #[serde(default)] provider_type: Option, - /// Accepted for backward compatibility with frontends that still send it, - /// but no longer used since test_connection switched to `GET /models`. - #[serde(default)] - #[allow(dead_code)] - model: Option, } #[derive(serde::Serialize)] @@ -2705,8 +2702,15 @@ async fn test_provider_connection(req: TestConnectionRequest) -> TestConnectionR }; } + if req.model.trim().is_empty() { + return TestConnectionResponse { + ok: false, + message: "Model is required for connection test".to_string(), + }; + } + let client = match reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(30)) .build() { Ok(c) => c, @@ -2721,73 +2725,51 @@ async fn test_provider_connection(req: TestConnectionRequest) -> TestConnectionR let base = req.base_url.trim_end_matches('/'); match req.adapter.as_str() { - "nearai" if is_nearai_private_endpoint(base) => { - // NEAR AI private endpoints use /health for connectivity checks. - let url = format!("{base}/health"); - let mut builder = client.get(&url); - if let Some(key) = req.api_key.as_deref().filter(|k| !k.is_empty()) { - builder = builder.header("Authorization", format!("Bearer {key}")); - } - match builder.send().await { - Ok(r) if r.status().is_success() => TestConnectionResponse { - ok: true, - message: format!("Connected ({})", r.status()), - }, - Ok(r) - if r.status() == reqwest::StatusCode::UNAUTHORIZED - || r.status() == reqwest::StatusCode::FORBIDDEN => - { - TestConnectionResponse { - ok: false, - message: format!("Authentication failed ({})", r.status()), - } - } - Ok(r) => TestConnectionResponse { - ok: false, - message: format!("Server returned {}", r.status()), - }, - Err(e) => TestConnectionResponse { - ok: false, - message: format!("Connection failed: {e}"), - }, - } - } - "ollama" => { - let url = format!("{base}/api/tags"); - match client.get(&url).send().await { - Ok(r) if r.status().is_success() => TestConnectionResponse { - ok: true, - message: format!("Connected ({})", r.status()), - }, - Ok(r) => TestConnectionResponse { - ok: false, - message: format!("Server returned {}", r.status()), - }, - Err(e) => TestConnectionResponse { - ok: false, - message: format!("Connection failed: {e}"), - }, - } - } "anthropic" => { - // Use GET /v1/models to verify connectivity + auth without consuming tokens. let anthropic_base = if base.ends_with("/v1") || base.contains("/v1/") { base.to_string() } else { format!("{base}/v1") }; - let url = format!("{anthropic_base}/models"); - let mut builder = client.get(&url).header("anthropic-version", "2023-06-01"); + let url = format!("{anthropic_base}/messages"); + let body = serde_json::json!({ + "model": req.model, + "max_tokens": 16, + "messages": [{"role": "user", "content": "hi"}] + }); + let mut builder = client + .post(&url) + .header("anthropic-version", "2023-06-01") + .json(&body); if let Some(key) = req.api_key.as_deref().filter(|k| !k.is_empty()) { builder = builder.header("x-api-key", key); } interpret_chat_response(builder.send().await) } + "ollama" => { + let url = format!("{base}/api/chat"); + let body = serde_json::json!({ + "model": req.model, + "messages": [{"role": "user", "content": "hi"}], + "stream": false + }); + let builder = client.post(&url).json(&body); + interpret_chat_response(builder.send().await) + } _ => { - // OpenAI-compatible: use GET /models to verify connectivity + auth - // without consuming tokens (no chat/completions call). - let url = format!("{base}/models"); - let mut builder = client.get(&url); + // OpenAI-compatible (including nearai): POST /v1/chat/completions + // If base already ends with /v1, append directly; otherwise insert /v1. + let chat_url = if base.ends_with("/v1") { + format!("{base}/chat/completions") + } else { + format!("{base}/v1/chat/completions") + }; + let body = serde_json::json!({ + "model": req.model, + "max_tokens": 16, + "messages": [{"role": "user", "content": "hi"}] + }); + let mut builder = client.post(&chat_url).json(&body); if let Some(key) = req.api_key.as_deref().filter(|k| !k.is_empty()) { builder = builder.header("Authorization", format!("Bearer {key}")); } diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index 43013010..1991cb51 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -6486,12 +6486,21 @@ document.getElementById('test-provider-btn').addEventListener('click', () => { // Resolve provider_id so the backend can look up vaulted API keys. const providerId = _configuringBuiltinId || document.getElementById('provider-id').value.trim(); + if (!model) { + result.textContent = I18n.t('config.modelRequired') || 'Model is required for connection test'; + result.className = 'test-connection-result test-fail'; + result.style.display = ''; + btn.disabled = false; + btn.textContent = I18n.t('config.testConnection'); + return; + } + apiFetch('/api/llm/test_connection', { method: 'POST', body: { adapter, base_url: baseUrl, api_key: apiKey || undefined, - model: model || undefined, + model, provider_id: providerId || undefined, provider_type: _configuringBuiltinId ? 'builtin' : 'custom', }, @@ -6547,7 +6556,9 @@ document.getElementById('save-provider-btn').addEventListener('click', () => { renderProviders(); resetProviderForm(); scrollToProviders(); - document.getElementById('config-restart-notice').style.display = 'flex'; + if (isActive) { + document.getElementById('config-restart-notice').style.display = 'flex'; + } showToast(I18n.t('config.providerConfigured', { name: id })); }) .catch((e) => { @@ -6597,7 +6608,9 @@ document.getElementById('save-provider-btn').addEventListener('click', () => { renderProviders(); resetProviderForm(); scrollToProviders(); - document.getElementById('config-restart-notice').style.display = 'flex'; + if (isActive) { + document.getElementById('config-restart-notice').style.display = 'flex'; + } showToast(I18n.t('config.providerUpdated', { name })); }).catch((e) => { _customProviders[idx] = original; @@ -6623,7 +6636,6 @@ document.getElementById('save-provider-btn').addEventListener('click', () => { renderProviders(); resetProviderForm(); scrollToProviders(); - document.getElementById('config-restart-notice').style.display = 'flex'; showToast(I18n.t('config.providerAdded', { name })); }).catch((e) => { _customProviders.pop();