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)
This commit is contained in:
肆月
2026-05-18 09:47:44 +08:00
committed by GitHub
parent 0df050ff2e
commit 604187e312
4 changed files with 417 additions and 52 deletions
+194
View File
@@ -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
}
}
@@ -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<ReturnType<typeof setTimeout>>(undefined)
const scrollContainerRef = useRef<HTMLDivElement>(null)
// Dynamic import for TestModelDialog (added in PR3)
const [TestModelDialogComp, setTestModelDialogComp] = useState<React.ComponentType<{
model: unknown; open: boolean; onClose: () => 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}
>
<IconPlugConnected className="size-4" />
{t("models.test.testConnection")}
@@ -744,20 +736,18 @@ export function AddModelSheet({
apiBase={form.apiBase}
/>
{TestModelDialogComp && (
<TestModelDialogComp
model={null}
open={testOpen}
onClose={() => setTestOpen(false)}
inlineParams={{
provider: form.provider,
model: form.model,
apiBase: form.apiBase,
apiKey: form.apiKey,
authMethod: form.authMethod,
}}
/>
)}
<TestModelDialog
model={null}
open={testOpen}
onClose={() => setTestOpen(false)}
inlineParams={{
provider: form.provider,
model: form.model,
apiBase: form.apiBase,
apiKey: form.apiKey,
authMethod: form.authMethod,
}}
/>
</Sheet>
</>
)
@@ -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<ReturnType<typeof setTimeout>>(undefined)
const scrollContainerRef = useRef<HTMLDivElement>(null)
// Dynamic import for TestModelDialog (added in PR3)
const [TestModelDialogComp, setTestModelDialogComp] = useState<React.ComponentType<{
model: unknown; open: boolean; onClose: () => 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}
>
<IconPlugConnected className="size-4" />
{t("models.test.testConnection")}
@@ -693,21 +685,19 @@ export function EditModelSheet({
</SheetContent>
</Sheet>
{TestModelDialogComp && (
<TestModelDialogComp
model={model}
open={testOpen}
onClose={() => setTestOpen(false)}
inlineParams={{
provider: form.provider,
model: form.modelId,
apiBase: form.apiBase,
apiKey: form.apiKey,
authMethod: form.authMethod,
modelIndex: model?.index,
}}
/>
)}
<TestModelDialog
model={model}
open={testOpen}
onClose={() => setTestOpen(false)}
inlineParams={{
provider: form.provider,
model: form.modelId,
apiBase: form.apiBase,
apiKey: form.apiKey,
authMethod: form.authMethod,
modelIndex: model?.index,
}}
/>
<FetchModelsDialog
open={fetchOpen}
@@ -1,4 +1,195 @@
// Placeholder: full implementation added in PR3 (Test Connection)
export function TestModelDialog() {
return null
import { IconLoader2, IconPlugConnected, IconX } from "@tabler/icons-react"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import {
type ModelInfo,
type TestModelInlineRequest,
testModel,
testModelInline,
} from "@/api/models"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
export interface TestInlineParams {
provider: string
model: string
apiBase: string
apiKey: string
authMethod: string
modelIndex?: number
}
interface TestModelDialogProps {
model: ModelInfo | null
open: boolean
onClose: () => 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<TestResult | null>(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 (
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconPlugConnected className="size-5" />
{t("models.test.title")}
</DialogTitle>
<DialogDescription>{t("models.test.description")}</DialogDescription>
</DialogHeader>
{canTest && (
<div className="space-y-3">
<div className="bg-muted/50 rounded-lg p-3 text-sm">
<div>
<span className="text-muted-foreground">
{t("models.test.modelLabel")}{" "}
</span>
<span className="font-mono">{displayModelName}</span>
</div>
<div>
<span className="text-muted-foreground">
{t("models.test.identifierLabel")}{" "}
</span>
<span className="font-mono">{displayModel}</span>
</div>
{displayApiBase && (
<div>
<span className="text-muted-foreground">
{t("models.test.endpointLabel")}{" "}
</span>
<span className="font-mono text-xs">{displayApiBase}</span>
</div>
)}
</div>
{!result && !testing && (
<Button onClick={handleTest} className="w-full">
<IconPlugConnected className="size-4" />
{t("models.test.testConnection")}
</Button>
)}
{testing && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-6">
<IconLoader2 className="size-5 animate-spin" />
<span>{t("models.test.testing")}</span>
</div>
)}
{result && (
<div
className={`rounded-lg p-4 text-sm ${
result.success
? "bg-green-500/10 text-green-700 dark:text-green-400"
: "bg-destructive/10 text-destructive"
}`}
>
{result.success ? (
<div className="space-y-1">
<div className="font-medium">
{t("models.test.success")}
</div>
<div className="text-xs opacity-80">
{t("models.test.responseTime", { ms: result.latency_ms })}
</div>
</div>
) : (
<div className="space-y-1">
<div className="flex items-center gap-1 font-medium">
<IconX className="size-4" />
{t("models.test.failed")}
</div>
<div className="text-xs opacity-80">
{result.error ||
t("models.test.status", { status: result.status })}
</div>
</div>
)}
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="ghost" onClick={handleClose}>
{t("common.cancel")}
</Button>
{result && (
<Button variant="outline" onClick={handleTest}>
{t("models.test.testAgain")}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
}