From 77c6f31983b72ebadcb5fe5d80c8c4199ab3e781 Mon Sep 17 00:00:00 2001 From: SiYue-ZO <2835601846@qq.com> Date: Wed, 6 May 2026 18:13:19 +0800 Subject: [PATCH] feat(web,api): test connection with real connectivity verification and unsaved form values Add POST /api/models/test-inline endpoint that performs actual network probes (GET /models) instead of just checking config. Frontend Test Connection now uses current form values (not saved state) and is available in both Add and Edit model flows. --- web/backend/api/models.go | 93 +++ web/frontend/src/api/models.ts | 27 +- .../src/components/models/add-model-sheet.tsx | 684 ++++++++++-------- .../components/models/edit-model-sheet.tsx | 48 +- .../components/models/test-model-dialog.tsx | 77 +- 5 files changed, 574 insertions(+), 355 deletions(-) diff --git a/web/backend/api/models.go b/web/backend/api/models.go index 59f95a8e5..c3640b030 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -25,6 +25,7 @@ func (h *Handler) registerModelRoutes(mux *http.ServeMux) { mux.HandleFunc("PUT /api/models/{index}", h.handleUpdateModel) mux.HandleFunc("DELETE /api/models/{index}", h.handleDeleteModel) mux.HandleFunc("POST /api/models/{index}/test", h.handleTestModel) + mux.HandleFunc("POST /api/models/test-inline", h.handleTestInlineModel) 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) @@ -665,6 +666,98 @@ func (h *Handler) handleTestModel(w http.ResponseWriter, r *http.Request) { 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"` + } + 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) + } + + // 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 + } +} + // handleFetchModels fetches available models from an upstream provider. // // POST /api/models/fetch diff --git a/web/frontend/src/api/models.ts b/web/frontend/src/api/models.ts index c9f70db00..1ed9c29e0 100644 --- a/web/frontend/src/api/models.ts +++ b/web/frontend/src/api/models.ts @@ -127,6 +127,24 @@ export async function testModel(index: number): Promise { }) } +export interface TestModelInlineRequest { + provider: string + model: string + api_base?: string + api_key?: string + auth_method?: string +} + +export async function testModelInline( + params: TestModelInlineRequest, +): Promise { + return request("/api/models/test-inline", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(params), + }) +} + export interface UpstreamModel { id: string owned_by?: string @@ -180,9 +198,12 @@ export async function getCatalogs(): Promise { } export async function deleteCatalog(id: string): Promise { - await request>(`/api/models/catalog/${encodeURIComponent(id)}`, { - method: "DELETE", - }) + await request>( + `/api/models/catalog/${encodeURIComponent(id)}`, + { + method: "DELETE", + }, + ) } export type { ModelsListResponse, ModelActionResponse } diff --git a/web/frontend/src/components/models/add-model-sheet.tsx b/web/frontend/src/components/models/add-model-sheet.tsx index 807fe32cd..e20443601 100644 --- a/web/frontend/src/components/models/add-model-sheet.tsx +++ b/web/frontend/src/components/models/add-model-sheet.tsx @@ -1,4 +1,8 @@ -import { IconDownload, IconLoader2 } from "@tabler/icons-react" +import { + IconDownload, + IconLoader2, + IconPlugConnected, +} from "@tabler/icons-react" import { useCallback, useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" @@ -27,13 +31,11 @@ import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" import { FetchModelsDialog } from "./fetch-models-dialog" -import { - type FieldValidation, - validateModelField, -} from "./model-validation" +import { type FieldValidation, validateModelField } from "./model-validation" import { ProviderCombobox } from "./provider-combobox" import { getProviderKey } from "./provider-label" import { PROVIDER_MAP } from "./provider-registry" +import { TestModelDialog } from "./test-model-dialog" interface AddForm { modelName: string @@ -86,7 +88,8 @@ function getNextApiBaseForProviderChange( const currentDefaultApiBase = normalizeApiBase( PROVIDER_MAP.get(currentProvider)?.defaultApiBase ?? "", ) - const nextDefaultApiBase = PROVIDER_MAP.get(nextProvider)?.defaultApiBase ?? "" + const nextDefaultApiBase = + PROVIDER_MAP.get(nextProvider)?.defaultApiBase ?? "" if (!normalizedCurrentApiBase) { return nextDefaultApiBase @@ -124,8 +127,10 @@ export function AddModelSheet({ Partial> >({}) const [serverError, setServerError] = useState("") - const [modelValidation, setModelValidation] = useState(null) + const [modelValidation, setModelValidation] = + useState(null) const [fetchOpen, setFetchOpen] = useState(false) + const [testOpen, setTestOpen] = useState(false) const [fetchedModels, setFetchedModels] = useState([]) const [catalogModels, setCatalogModels] = useState([]) const debounceRef = useRef>(undefined) @@ -170,7 +175,9 @@ export function AddModelSheet({ setCatalogModels(unique) }) .catch(() => {}) - return () => { cancelled = true } + return () => { + cancelled = true + } }, [form.provider, form.apiBase]) const validate = (): boolean => { @@ -183,7 +190,10 @@ export function AddModelSheet({ } if (!form.model.trim()) errors.model = t("models.add.errorRequired") if (modelValidation?.level === "error") { - errors.model = t(modelValidation.messageKey, modelValidation.messageParams) + errors.model = t( + modelValidation.messageKey, + modelValidation.messageParams, + ) } setFieldErrors(errors) return Object.keys(errors).length === 0 @@ -275,7 +285,9 @@ export function AddModelSheet({ extraBody = JSON.parse(form.extraBody.trim()) } } catch { - setServerError(t("models.field.extraBody") + ": " + t("models.field.invalidJson")) + setServerError( + t("models.field.extraBody") + ": " + t("models.field.invalidJson"), + ) return } try { @@ -283,7 +295,9 @@ export function AddModelSheet({ customHeaders = JSON.parse(form.customHeaders.trim()) } } catch { - setServerError(t("models.field.customHeaders") + ": " + t("models.field.invalidJson")) + setServerError( + t("models.field.customHeaders") + ": " + t("models.field.invalidJson"), + ) return } @@ -334,341 +348,377 @@ export function AddModelSheet({ return ( <> - !v && onClose()}> - - - {t("models.add.title")} - - {t("models.add.description")} - - + !v && onClose()}> + + + + {t("models.add.title")} + + + {t("models.add.description")} + + -
-
- - - {fieldErrors.modelName && ( -

- {fieldErrors.modelName} -

- )} -
+
+
+ + + {fieldErrors.modelName && ( +

+ {fieldErrors.modelName} +

+ )} +
- - - + + + - - - {modelValidation && modelValidation.messageKey && ( -
- {t(modelValidation.messageKey, modelValidation.messageParams)} - {modelValidation.fix && ( - + + + {modelValidation && modelValidation.messageKey && ( +
+ + {t( + modelValidation.messageKey, + modelValidation.messageParams, + )} + + {modelValidation.fix && ( + + )} +
+ )} + {fieldErrors.model && !modelValidation && ( +

+ {fieldErrors.model} +

+ )} + {commonModels.length > 0 && ( +
+ {commonModels.map((m) => ( + handleCommonModel(m)} + > + {m} + + ))} +
+ )} + {catalogModels.length > 0 && ( +
+ {catalogModels.map((m) => ( + handleCommonModel(m)} + > + {m} + + ))} +
+ )} + {fetchedModels.length > 0 && ( +
+ {fetchedModels.map((m) => ( + handleCommonModel(m)} + > + {m} + + ))} +
+ )} +
+ + {!form.provider && ( + + {t("models.field.selectProviderFirst")} + )}
- )} - {fieldErrors.model && !modelValidation && ( -

{fieldErrors.model}

- )} - {commonModels.length > 0 && ( -
- {commonModels.map((m) => ( - handleCommonModel(m)} - > - {m} - - ))} -
- )} - {catalogModels.length > 0 && ( -
- {catalogModels.map((m) => ( - handleCommonModel(m)} - > - {m} - - ))} -
- )} - {fetchedModels.length > 0 && ( -
- {fetchedModels.map((m) => ( - handleCommonModel(m)} - > - {m} - - ))} -
- )} +
+ + + setForm((f) => ({ ...f, apiKey: v }))} + placeholder={apiKeyPlaceholder} + /> + + + + + +
- {!form.provider && ( - - {t("models.field.selectProviderFirst")} - - )}
- - - setForm((f) => ({ ...f, apiKey: v }))} - placeholder={apiKeyPlaceholder} + - - - - + + + + - + + + - - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + + + - - - + +