feat: add test connection for custom LLM providers

- Add POST /api/llm/test_connection endpoint that validates
  connectivity and auth for OpenAI-compatible, Anthropic, and
  Ollama adapters (10s timeout, per-adapter request logic)
- Add "Test" button next to Save/Cancel in the add-provider form;
  result shown inline with green/red styling
- Hide delete button for the active provider instead of showing
  an error toast
- Sort the active provider to the top of the provider list
- Clear selected_model when switching providers to avoid
  model-not-supported errors on the new provider
- Add i18n keys for test/testing states (en + zh-CN)
This commit is contained in:
italic-jinxin
2026-03-18 19:45:07 +08:00
parent 3800a09613
commit 2339616637
7 changed files with 687 additions and 49 deletions
+263
View File
@@ -301,6 +301,9 @@ pub async fn start_server(
"/api/settings/{key}",
axum::routing::delete(settings_delete_handler),
)
// LLM utilities
.route("/api/llm/test_connection", post(llm_test_connection_handler))
.route("/api/llm/list_models", post(llm_list_models_handler))
// Gateway control plane
.route("/api/gateway/status", get(gateway_status_handler))
// OpenAI-compatible API
@@ -2589,6 +2592,266 @@ async fn settings_delete_handler(
Ok(StatusCode::NO_CONTENT)
}
#[derive(serde::Deserialize)]
struct TestConnectionRequest {
adapter: String,
base_url: String,
#[serde(default)]
api_key: Option<String>,
#[serde(default)]
model: Option<String>,
}
#[derive(serde::Serialize)]
struct TestConnectionResponse {
ok: bool,
message: String,
}
async fn llm_test_connection_handler(
Json(body): Json<TestConnectionRequest>,
) -> Json<TestConnectionResponse> {
Json(test_provider_connection(body).await)
}
async fn test_provider_connection(req: TestConnectionRequest) -> TestConnectionResponse {
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
{
Ok(c) => c,
Err(e) => {
return TestConnectionResponse {
ok: false,
message: format!("Failed to build HTTP client: {e}"),
}
}
};
let base = req.base_url.trim_end_matches('/');
match req.adapter.as_str() {
"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" => {
let url = format!("{base}/messages");
let model = req.model.as_deref().unwrap_or("claude-3-haiku-20240307");
let payload = serde_json::json!({
"model": model,
"max_tokens": 1,
"messages": [{"role": "user", "content": "hi"}]
});
let mut builder = client
.post(&url)
.header("anthropic-version", "2023-06-01")
.json(&payload);
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)
}
_ => {
// OpenAI-compatible
let url = format!("{base}/chat/completions");
let model = req.model.as_deref().unwrap_or("gpt-4o-mini");
let payload = serde_json::json!({
"model": model,
"messages": [{"role": "user", "content": "hi"}],
"max_tokens": 1
});
let mut builder = client.post(&url).json(&payload);
if let Some(key) = req.api_key.as_deref().filter(|k| !k.is_empty()) {
builder = builder.header("Authorization", format!("Bearer {key}"));
}
interpret_chat_response(builder.send().await)
}
}
}
fn interpret_chat_response(
result: Result<reqwest::Response, reqwest::Error>,
) -> TestConnectionResponse {
match result {
Ok(r) => {
let status = r.status();
if status.is_success() {
TestConnectionResponse { ok: true, message: format!("Connected ({})", status) }
} else if status == reqwest::StatusCode::UNAUTHORIZED
|| status == reqwest::StatusCode::FORBIDDEN
{
TestConnectionResponse {
ok: false,
message: format!("Authentication failed ({})", status),
}
} else if status.is_client_error() {
// 400/422 = server reachable, likely wrong model name — still a success for connectivity
TestConnectionResponse {
ok: true,
message: format!("Server reachable ({})", status),
}
} else {
TestConnectionResponse {
ok: false,
message: format!("Server error ({})", status),
}
}
}
Err(e) => TestConnectionResponse { ok: false, message: format!("Connection failed: {e}") },
}
}
#[derive(serde::Deserialize)]
struct ListModelsRequest {
adapter: String,
base_url: String,
#[serde(default)]
api_key: Option<String>,
}
#[derive(serde::Serialize)]
struct ListModelsResponse {
ok: bool,
models: Vec<String>,
message: String,
}
async fn llm_list_models_handler(
Json(body): Json<ListModelsRequest>,
) -> Json<ListModelsResponse> {
Json(fetch_provider_models(body).await)
}
async fn fetch_provider_models(req: ListModelsRequest) -> ListModelsResponse {
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
{
Ok(c) => c,
Err(e) => {
return ListModelsResponse {
ok: false,
models: vec![],
message: format!("Failed to build HTTP client: {e}"),
}
}
};
let base = req.base_url.trim_end_matches('/');
let auth = req.api_key.as_deref().filter(|k| !k.is_empty());
match req.adapter.as_str() {
"ollama" => {
let url = format!("{base}/api/tags");
match client.get(&url).send().await {
Ok(r) if r.status().is_success() => {
let body: serde_json::Value = r.json().await.unwrap_or_default();
let models: Vec<String> = body["models"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|m| m["name"].as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
if models.is_empty() {
ListModelsResponse {
ok: false,
models: vec![],
message: "No models found".to_string(),
}
} else {
ListModelsResponse {
ok: true,
message: format!("{} model(s) found", models.len()),
models,
}
}
}
Ok(r) => ListModelsResponse {
ok: false,
models: vec![],
message: format!("Server returned {}", r.status()),
},
Err(e) => ListModelsResponse {
ok: false,
models: vec![],
message: format!("Connection failed: {e}"),
},
}
}
_ => {
// OpenAI-compatible and Anthropic both support GET /models
let url = format!("{base}/models");
let mut builder = client.get(&url);
if let Some(key) = auth {
builder = builder.header("Authorization", format!("Bearer {key}"));
}
// Anthropic also needs the version header and uses x-api-key
if req.adapter == "anthropic" {
if let Some(key) = auth {
builder = client
.get(&url)
.header("x-api-key", key)
.header("anthropic-version", "2023-06-01");
}
}
match builder.send().await {
Ok(r) if r.status().is_success() => {
let body: serde_json::Value = r.json().await.unwrap_or_default();
// OpenAI: {"data": [{"id": "..."}]}
// Anthropic: {"data": [{"id": "..."}]}
let models: Vec<String> = body["data"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|m| m["id"].as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
if models.is_empty() {
ListModelsResponse {
ok: false,
models: vec![],
message: "No models found in response".to_string(),
}
} else {
ListModelsResponse {
ok: true,
message: format!("{} model(s) found", models.len()),
models,
}
}
}
Ok(r) => ListModelsResponse {
ok: false,
models: vec![],
message: format!("Server returned {} — list models not supported", r.status()),
},
Err(e) => ListModelsResponse {
ok: false,
models: vec![],
message: format!("Connection failed: {e}"),
},
}
}
}
}
async fn settings_export_handler(
State(state): State<Arc<GatewayState>>,
) -> Result<Json<SettingsExportResponse>, StatusCode> {