From 604187e3125703fdcc8ebbd7e1b62acb74fc30a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=82=86=E6=9C=88?= <2835601846@qq.com> Date: Mon, 18 May 2026 09:47:44 +0800 Subject: [PATCH] feat(web,api): test connection with real connectivity verification (#2833) * feat(web,api): add fetch models and saved catalog support Split from PR #2752 (part 2 of 3). Backend: - /api/models/catalog endpoint for browsing remote model catalogs - /api/models/fetch endpoint for fetching available models from providers - Credential reuse with provider/API base matching for security - Default API base resolution for providers without explicit base Frontend: - FetchModelsDialog for importing models from remote providers - CatalogDialog for browsing and importing from model catalogs - Static import for FetchModelsDialog (replaces dynamic import from PR1) - Dynamic import retained for TestModelDialog (PR3 territory) * feat(web,api): add test connection with real connectivity verification Split from PR #2752 (part 3 of 3). Backend: - /api/models/{index}/test endpoint for testing saved model configs - /api/models/test-inline endpoint for testing unsaved form values - Real network probe (GET /models) for connectivity verification - Credential reuse with provider/API base matching for security - Default API base resolution for providers without explicit base Frontend: - TestModelDialog for testing model connectivity - Inline test support for add/edit model sheets - Static import for TestModelDialog (replaces dynamic import from PR1) --- web/backend/api/models.go | 194 +++++++++++++++++ .../src/components/models/add-model-sheet.tsx | 38 ++-- .../components/models/edit-model-sheet.tsx | 40 ++-- .../components/models/test-model-dialog.tsx | 197 +++++++++++++++++- 4 files changed, 417 insertions(+), 52 deletions(-) diff --git a/web/backend/api/models.go b/web/backend/api/models.go index f1761cb70..33124f46f 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strconv" "strings" "sync" @@ -39,6 +40,8 @@ func (h *Handler) registerModelRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /api/models/fetch", h.handleFetchModels) mux.HandleFunc("GET /api/models/catalog", h.handleListCatalogs) mux.HandleFunc("DELETE /api/models/catalog/{id}", h.handleDeleteCatalog) + mux.HandleFunc("POST /api/models/{index}/test", h.handleTestModel) + mux.HandleFunc("POST /api/models/test-inline", h.handleTestInlineModel) } // modelResponse is the JSON structure returned for each model in the list. @@ -827,3 +830,194 @@ func fetchOllamaModels(ctx context.Context, fetchURL string) ([]upstreamModel, e } return models, nil } + +// normalizeAPIBaseForCompare normalizes an API base URL for equality comparison +// by trimming trailing slashes and lowering the scheme/host. +func normalizeAPIBaseForCompare(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + raw = strings.TrimRight(raw, "/") + u, err := url.Parse(raw) + if err != nil { + return strings.ToLower(raw) + } + if u.Host == "" { + u, err = url.Parse("//" + raw) + if err != nil { + return strings.ToLower(raw) + } + } + return strings.ToLower(u.Scheme) + "://" + strings.ToLower(u.Host) + strings.TrimRight(u.Path, "/") +} + +// handleTestModel tests connectivity to a model endpoint. +// +// POST /api/models/{index}/test +func (h *Handler) handleTestModel(w http.ResponseWriter, r *http.Request) { + idx, err := strconv.Atoi(r.PathValue("index")) + if err != nil { + http.Error(w, "Invalid index", http.StatusBadRequest) + return + } + + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + if idx < 0 || idx >= len(cfg.ModelList) { + http.Error(w, fmt.Sprintf("Index %d out of range (0-%d)", idx, len(cfg.ModelList)-1), http.StatusNotFound) + return + } + + m := cfg.ModelList[idx] + start := time.Now() + summary := modelConfigurationStatus(m) + latency := time.Since(start).Milliseconds() + + result := map[string]any{ + "success": summary.Available, + "latency_ms": latency, + "status": summary.Status, + } + + if !summary.Available { + if summary.Status == modelStatusUnconfigured { + result["error"] = "API key not configured" + } else { + result["error"] = "Endpoint unreachable" + } + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) +} + +// handleTestInlineModel tests connectivity using inline (unsaved) parameters. +// Unlike handleTestModel which only checks saved config, this endpoint performs +// a real network probe (e.g. GET /models) to verify the endpoint is reachable. +// +// POST /api/models/test-inline +func (h *Handler) handleTestInlineModel(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + http.Error(w, "Failed to read request body", http.StatusBadRequest) + return + } + + var req struct { + Provider string `json:"provider"` + Model string `json:"model"` + APIBase string `json:"api_base"` + APIKey string `json:"api_key"` + AuthMethod string `json:"auth_method"` + ModelIndex *int `json:"model_index"` + } + if err := json.Unmarshal(body, &req); err != nil { + http.Error(w, "Invalid JSON", http.StatusBadRequest) + return + } + + m := &config.ModelConfig{ + Provider: strings.TrimSpace(req.Provider), + Model: strings.TrimSpace(req.Model), + APIBase: strings.TrimSpace(req.APIBase), + AuthMethod: strings.TrimSpace(req.AuthMethod), + } + if req.APIKey != "" { + m.SetAPIKey(req.APIKey) + } + + // When api_key is empty and model_index is provided, fall back to stored credentials. + // This lets the edit form test unsaved field changes while using the saved key. + // Only reuse the stored key when the provider and effective API base match + // the saved model, to prevent attaching a credential to a different endpoint. + if req.APIKey == "" && req.ModelIndex != nil { + cfg, err := config.LoadConfig(h.configPath) + if err == nil && *req.ModelIndex >= 0 && *req.ModelIndex < len(cfg.ModelList) { + stored := cfg.ModelList[*req.ModelIndex] + storedProvider, _ := providers.ExtractProtocol(stored) + reqProvider := providers.NormalizeProvider(m.Provider) + providerMatch := reqProvider == "" || reqProvider == providers.NormalizeProvider(storedProvider) + + effectiveReqBase := strings.TrimSpace(m.APIBase) + if effectiveReqBase == "" { + effectiveReqBase = providers.DefaultAPIBaseForProtocol(reqProvider) + } + effectiveStoredBase := strings.TrimSpace(stored.APIBase) + if effectiveStoredBase == "" { + effectiveStoredBase = providers.DefaultAPIBaseForProtocol(storedProvider) + } + baseMatch := normalizeAPIBaseForCompare(effectiveReqBase) == normalizeAPIBaseForCompare(effectiveStoredBase) + + if providerMatch && baseMatch { + if stored.APIKey() != "" { + m.SetAPIKey(stored.APIKey()) + } + if m.APIBase == "" && stored.APIBase != "" { + m.APIBase = stored.APIBase + } + } + } + } + + // Check if configuration exists + if !hasModelConfiguration(m) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "success": false, + "latency_ms": 0, + "status": modelStatusUnconfigured, + "error": "API key not configured", + }) + return + } + + // Perform a real network probe + start := time.Now() + available := probeModelConnectivity(m) + latency := time.Since(start).Milliseconds() + + result := map[string]any{ + "success": available, + "latency_ms": latency, + } + if available { + result["status"] = modelStatusAvailable + } else { + result["status"] = modelStatusUnreachable + result["error"] = "Endpoint unreachable" + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(result) +} + +// probeModelConnectivity performs a real network probe to verify model endpoint reachability. +func probeModelConnectivity(m *config.ModelConfig) bool { + apiBase := modelProbeAPIBase(m) + protocol, modelID := splitModel(m) + + switch protocol { + case "ollama": + return probeOllamaModel(apiBase, modelID) + case "vllm", "lmstudio": + return probeOpenAICompatibleModel(apiBase, modelID, m.APIKey()) + case "github-copilot", "copilot": + return probeTCPService(apiBase) + case "claude-cli", "claudecli": + return probeCommandAvailable("claude") + case "codex-cli", "codexcli": + return probeCommandAvailable("codex") + default: + // For remote providers (OpenAI, Anthropic, Gemini, DeepSeek, etc.), + // make a real GET /models request to verify connectivity and credentials. + if apiBase != "" { + return probeOpenAICompatibleModel(apiBase, modelID, m.APIKey()) + } + return false + } +} diff --git a/web/frontend/src/components/models/add-model-sheet.tsx b/web/frontend/src/components/models/add-model-sheet.tsx index e24c0d81e..3d601963e 100644 --- a/web/frontend/src/components/models/add-model-sheet.tsx +++ b/web/frontend/src/components/models/add-model-sheet.tsx @@ -40,6 +40,7 @@ import { type FieldValidation, validateModelField } from "./model-validation" import { ProviderCombobox } from "./provider-combobox" import { getProviderKey } from "./provider-label" import { FETCHABLE_PROVIDER_KEYS, PROVIDER_MAP } from "./provider-registry" +import { TestModelDialog } from "./test-model-dialog" interface AddForm { modelName: string @@ -142,15 +143,6 @@ export function AddModelSheet({ const debounceRef = useRef>(undefined) const scrollContainerRef = useRef(null) - // Dynamic import for TestModelDialog (added in PR3) - const [TestModelDialogComp, setTestModelDialogComp] = useState void; - inlineParams: { provider: string; model: string; apiBase: string; apiKey: string; authMethod: string }; - }> | null>(null) - useEffect(() => { - import("./test-model-dialog").then((m) => setTestModelDialogComp(() => m.TestModelDialog)).catch(() => {}) - }, []) - const apiKeyPlaceholder = maskedSecretPlaceholder( form.apiKey, t("models.field.apiKeyPlaceholder"), @@ -556,7 +548,7 @@ export function AddModelSheet({ variant="outline" size="sm" onClick={() => setTestOpen(true)} - disabled={!form.provider || !form.model || !TestModelDialogComp} + disabled={!form.provider || !form.model} > {t("models.test.testConnection")} @@ -744,20 +736,18 @@ export function AddModelSheet({ apiBase={form.apiBase} /> - {TestModelDialogComp && ( - setTestOpen(false)} - inlineParams={{ - provider: form.provider, - model: form.model, - apiBase: form.apiBase, - apiKey: form.apiKey, - authMethod: form.authMethod, - }} - /> - )} + setTestOpen(false)} + inlineParams={{ + provider: form.provider, + model: form.model, + apiBase: form.apiBase, + apiKey: form.apiKey, + authMethod: form.authMethod, + }} + /> ) diff --git a/web/frontend/src/components/models/edit-model-sheet.tsx b/web/frontend/src/components/models/edit-model-sheet.tsx index f8a645316..2de2c367e 100644 --- a/web/frontend/src/components/models/edit-model-sheet.tsx +++ b/web/frontend/src/components/models/edit-model-sheet.tsx @@ -41,6 +41,7 @@ import { type FieldValidation, validateModelField } from "./model-validation" import { ProviderCombobox } from "./provider-combobox" import { getProviderKey } from "./provider-label" import { FETCHABLE_PROVIDER_KEYS, PROVIDER_API_BASES, PROVIDER_MAP } from "./provider-registry" +import { TestModelDialog } from "./test-model-dialog" interface EditForm { provider: string @@ -159,15 +160,6 @@ export function EditModelSheet({ const debounceRef = useRef>(undefined) const scrollContainerRef = useRef(null) - // Dynamic import for TestModelDialog (added in PR3) - const [TestModelDialogComp, setTestModelDialogComp] = useState void; - inlineParams: { provider: string; model: string; apiBase: string; apiKey: string; authMethod: string; modelIndex?: number }; - }> | null>(null) - useEffect(() => { - import("./test-model-dialog").then((m) => setTestModelDialogComp(() => m.TestModelDialog)).catch(() => {}) - }, []) - const initialForm = model ? buildInitialEditForm(model) : null const isDirty = model != null && @@ -513,7 +505,7 @@ export function EditModelSheet({ variant="outline" size="sm" onClick={() => setTestOpen(true)} - disabled={!model || !TestModelDialogComp} + disabled={!model} > {t("models.test.testConnection")} @@ -693,21 +685,19 @@ export function EditModelSheet({ - {TestModelDialogComp && ( - setTestOpen(false)} - inlineParams={{ - provider: form.provider, - model: form.modelId, - apiBase: form.apiBase, - apiKey: form.apiKey, - authMethod: form.authMethod, - modelIndex: model?.index, - }} - /> - )} + setTestOpen(false)} + inlineParams={{ + provider: form.provider, + model: form.modelId, + apiBase: form.apiBase, + apiKey: form.apiKey, + authMethod: form.authMethod, + modelIndex: model?.index, + }} + /> void + inlineParams?: TestInlineParams +} + +interface TestResult { + success: boolean + latency_ms: number + status: string + error?: string +} + +export function TestModelDialog({ + model, + open, + onClose, + inlineParams, +}: TestModelDialogProps) { + const { t } = useTranslation() + const [testing, setTesting] = useState(false) + const [result, setResult] = useState(null) + + const handleTest = async () => { + setTesting(true) + setResult(null) + try { + let res: TestResult + if (inlineParams) { + const req: TestModelInlineRequest = { + provider: inlineParams.provider, + model: inlineParams.model, + api_base: inlineParams.apiBase || undefined, + api_key: inlineParams.apiKey || undefined, + auth_method: inlineParams.authMethod || undefined, + model_index: inlineParams.modelIndex, + } + res = await testModelInline(req) + } else if (model) { + res = await testModel(model.index) + } else { + return + } + setResult(res) + } catch (e) { + setResult({ + success: false, + latency_ms: 0, + status: "error", + error: e instanceof Error ? e.message : t("models.test.testFailed"), + }) + } finally { + setTesting(false) + } + } + + const handleClose = () => { + setResult(null) + onClose() + } + + // Display info: prefer inline params, fall back to saved model + const displayModelName = inlineParams?.model || model?.model_name || "" + const displayModel = inlineParams?.model || model?.model || "" + const displayApiBase = inlineParams?.apiBase || model?.api_base || "" + const canTest = !!(inlineParams || model) + + return ( + !v && handleClose()}> + + + + + {t("models.test.title")} + + {t("models.test.description")} + + + {canTest && ( +
+
+
+ + {t("models.test.modelLabel")}{" "} + + {displayModelName} +
+
+ + {t("models.test.identifierLabel")}{" "} + + {displayModel} +
+ {displayApiBase && ( +
+ + {t("models.test.endpointLabel")}{" "} + + {displayApiBase} +
+ )} +
+ + {!result && !testing && ( + + )} + + {testing && ( +
+ + {t("models.test.testing")} +
+ )} + + {result && ( +
+ {result.success ? ( +
+
+ {t("models.test.success")} +
+
+ {t("models.test.responseTime", { ms: result.latency_ms })} +
+
+ ) : ( +
+
+ + {t("models.test.failed")} +
+
+ {result.error || + t("models.test.status", { status: result.status })} +
+
+ )} +
+ )} +
+ )} + + + + {result && ( + + )} + +
+
+ ) }