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
@@ -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>
)
}