mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-09-02 01:29:23 +00:00
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:
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user