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.
This commit is contained in:
SiYue-ZO 2026-05-06 18:13:19 +08:00
parent 9cb14eef52
commit 77c6f31983
5 changed files with 574 additions and 355 deletions

View file

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

View file

@ -127,6 +127,24 @@ export async function testModel(index: number): Promise<TestModelResponse> {
})
}
export interface TestModelInlineRequest {
provider: string
model: string
api_base?: string
api_key?: string
auth_method?: string
}
export async function testModelInline(
params: TestModelInlineRequest,
): Promise<TestModelResponse> {
return request<TestModelResponse>("/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<CatalogListResponse> {
}
export async function deleteCatalog(id: string): Promise<void> {
await request<Record<string, never>>(`/api/models/catalog/${encodeURIComponent(id)}`, {
method: "DELETE",
})
await request<Record<string, never>>(
`/api/models/catalog/${encodeURIComponent(id)}`,
{
method: "DELETE",
},
)
}
export type { ModelsListResponse, ModelActionResponse }

View file

@ -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<Record<keyof AddForm, string>>
>({})
const [serverError, setServerError] = useState("")
const [modelValidation, setModelValidation] = useState<FieldValidation | null>(null)
const [modelValidation, setModelValidation] =
useState<FieldValidation | null>(null)
const [fetchOpen, setFetchOpen] = useState(false)
const [testOpen, setTestOpen] = useState(false)
const [fetchedModels, setFetchedModels] = useState<string[]>([])
const [catalogModels, setCatalogModels] = useState<string[]>([])
const debounceRef = useRef<ReturnType<typeof setTimeout>>(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 (
<>
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
<SheetContent
side="right"
className="flex flex-col gap-0 p-0 data-[side=right]:!w-full data-[side=right]:sm:!w-[560px] data-[side=right]:sm:!max-w-[560px]"
>
<SheetHeader className="border-b-muted border-b px-6 py-5">
<SheetTitle className="text-base">{t("models.add.title")}</SheetTitle>
<SheetDescription className="text-xs">
{t("models.add.description")}
</SheetDescription>
</SheetHeader>
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
<SheetContent
side="right"
className="flex flex-col gap-0 p-0 data-[side=right]:!w-full data-[side=right]:sm:!w-[560px] data-[side=right]:sm:!max-w-[560px]"
>
<SheetHeader className="border-b-muted border-b px-6 py-5">
<SheetTitle className="text-base">
{t("models.add.title")}
</SheetTitle>
<SheetDescription className="text-xs">
{t("models.add.description")}
</SheetDescription>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="space-y-5 px-6 py-5">
<Field
label={t("models.add.modelName")}
hint={t("models.add.modelNameHint")}
>
<Input
value={form.modelName}
onChange={setField("modelName")}
placeholder={t("models.add.modelNamePlaceholder")}
aria-invalid={!!fieldErrors.modelName}
/>
{fieldErrors.modelName && (
<p className="text-destructive text-xs">
{fieldErrors.modelName}
</p>
)}
</Field>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="space-y-5 px-6 py-5">
<Field
label={t("models.add.modelName")}
hint={t("models.add.modelNameHint")}
>
<Input
value={form.modelName}
onChange={setField("modelName")}
placeholder={t("models.add.modelNamePlaceholder")}
aria-invalid={!!fieldErrors.modelName}
/>
{fieldErrors.modelName && (
<p className="text-destructive text-xs">
{fieldErrors.modelName}
</p>
)}
</Field>
<Field
label={t("models.field.provider")}
hint={t("models.field.providerHint")}
>
<ProviderCombobox
value={form.provider}
onChange={handleProviderChange}
placeholder={t("models.field.providerPlaceholder")}
/>
</Field>
<Field
label={t("models.field.provider")}
hint={t("models.field.providerHint")}
>
<ProviderCombobox
value={form.provider}
onChange={handleProviderChange}
placeholder={t("models.field.providerPlaceholder")}
/>
</Field>
<Field
label={t("models.add.modelId")}
hint={t("models.add.modelIdHint")}
>
<Input
value={form.model}
onChange={handleModelChange}
placeholder={
providerDef
? `${commonModels[0] || "model-name"}`
: t("models.add.modelIdPlaceholder")
}
className="font-mono text-sm"
aria-invalid={
!!fieldErrors.model || modelValidation?.level === "error"
}
/>
{modelValidation && modelValidation.messageKey && (
<div
className={`flex items-center gap-2 text-xs ${
modelValidation.level === "error"
? "text-destructive"
: modelValidation.level === "warning"
? "text-yellow-600 dark:text-yellow-500"
: "text-green-600 dark:text-green-500"
}`}
>
<span>{t(modelValidation.messageKey, modelValidation.messageParams)}</span>
{modelValidation.fix && (
<button
type="button"
onClick={applyFix}
className="text-primary underline hover:no-underline"
>
{t("common.fix")}
</button>
<Field
label={t("models.add.modelId")}
hint={t("models.add.modelIdHint")}
>
<Input
value={form.model}
onChange={handleModelChange}
placeholder={
providerDef
? `${commonModels[0] || "model-name"}`
: t("models.add.modelIdPlaceholder")
}
className="font-mono text-sm"
aria-invalid={
!!fieldErrors.model || modelValidation?.level === "error"
}
/>
{modelValidation && modelValidation.messageKey && (
<div
className={`flex items-center gap-2 text-xs ${
modelValidation.level === "error"
? "text-destructive"
: modelValidation.level === "warning"
? "text-yellow-600 dark:text-yellow-500"
: "text-green-600 dark:text-green-500"
}`}
>
<span>
{t(
modelValidation.messageKey,
modelValidation.messageParams,
)}
</span>
{modelValidation.fix && (
<button
type="button"
onClick={applyFix}
className="text-primary underline hover:no-underline"
>
{t("common.fix")}
</button>
)}
</div>
)}
{fieldErrors.model && !modelValidation && (
<p className="text-destructive text-xs">
{fieldErrors.model}
</p>
)}
{commonModels.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{commonModels.map((m) => (
<Badge
key={m}
variant="secondary"
className="hover:bg-secondary/80 cursor-pointer font-mono text-xs"
onClick={() => handleCommonModel(m)}
>
{m}
</Badge>
))}
</div>
)}
{catalogModels.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{catalogModels.map((m) => (
<Badge
key={m}
variant={form.model === m ? "default" : "outline"}
className="cursor-pointer font-mono text-xs"
onClick={() => handleCommonModel(m)}
>
{m}
</Badge>
))}
</div>
)}
{fetchedModels.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{fetchedModels.map((m) => (
<Badge
key={m}
variant={form.model === m ? "default" : "outline"}
className="cursor-pointer font-mono text-xs"
onClick={() => handleCommonModel(m)}
>
{m}
</Badge>
))}
</div>
)}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setFetchOpen(true)}
disabled={!form.provider}
>
<IconDownload className="size-3" />
{t("models.fetch.title")}
</Button>
{!form.provider && (
<span className="text-muted-foreground text-xs">
{t("models.field.selectProviderFirst")}
</span>
)}
</div>
)}
{fieldErrors.model && !modelValidation && (
<p className="text-destructive text-xs">{fieldErrors.model}</p>
)}
{commonModels.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{commonModels.map((m) => (
<Badge
key={m}
variant="secondary"
className="cursor-pointer font-mono text-xs hover:bg-secondary/80"
onClick={() => handleCommonModel(m)}
>
{m}
</Badge>
))}
</div>
)}
{catalogModels.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{catalogModels.map((m) => (
<Badge
key={m}
variant={form.model === m ? "default" : "outline"}
className="cursor-pointer font-mono text-xs"
onClick={() => handleCommonModel(m)}
>
{m}
</Badge>
))}
</div>
)}
{fetchedModels.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{fetchedModels.map((m) => (
<Badge
key={m}
variant={form.model === m ? "default" : "outline"}
className="cursor-pointer font-mono text-xs"
onClick={() => handleCommonModel(m)}
>
{m}
</Badge>
))}
</div>
)}
</Field>
<Field label={t("models.field.apiKey")}>
<KeyInput
value={form.apiKey}
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
placeholder={apiKeyPlaceholder}
/>
</Field>
<Field label={t("models.field.apiBase")}>
<Input
value={form.apiBase}
onChange={setField("apiBase")}
placeholder="https://api.example.com/v1"
/>
</Field>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setFetchOpen(true)}
disabled={!form.provider}
onClick={() => setTestOpen(true)}
disabled={!form.provider || !form.model}
>
<IconDownload className="size-3" />
{t("models.fetch.title")}
<IconPlugConnected className="size-4" />
{t("models.test.testConnection")}
</Button>
{!form.provider && (
<span className="text-muted-foreground text-xs">
{t("models.field.selectProviderFirst")}
</span>
)}
</div>
</Field>
<Field label={t("models.field.apiKey")}>
<KeyInput
value={form.apiKey}
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
placeholder={apiKeyPlaceholder}
<SwitchCardField
label={t("models.defaultOnSave.label")}
hint={t("models.defaultOnSave.description")}
checked={setAsDefault}
onCheckedChange={setSetAsDefault}
/>
</Field>
<Field label={t("models.field.apiBase")}>
<Input
value={form.apiBase}
onChange={setField("apiBase")}
placeholder="https://api.example.com/v1"
/>
</Field>
<AdvancedSection>
<Field
label={t("models.field.proxy")}
hint={t("models.field.proxyHint")}
>
<Input
value={form.proxy}
onChange={setField("proxy")}
placeholder="http://127.0.0.1:7890"
/>
</Field>
<SwitchCardField
label={t("models.defaultOnSave.label")}
hint={t("models.defaultOnSave.description")}
checked={setAsDefault}
onCheckedChange={setSetAsDefault}
/>
<Field
label={t("models.field.authMethod")}
hint={t("models.field.authMethodHint")}
>
<Input
value={form.authMethod}
onChange={setField("authMethod")}
placeholder="oauth"
/>
</Field>
<AdvancedSection>
<Field
label={t("models.field.proxy")}
hint={t("models.field.proxyHint")}
>
<Input
value={form.proxy}
onChange={setField("proxy")}
placeholder="http://127.0.0.1:7890"
/>
</Field>
<Field
label={t("models.field.connectMode")}
hint={t("models.field.connectModeHint")}
>
<Input
value={form.connectMode}
onChange={setField("connectMode")}
placeholder="stdio"
/>
</Field>
<Field
label={t("models.field.authMethod")}
hint={t("models.field.authMethodHint")}
>
<Input
value={form.authMethod}
onChange={setField("authMethod")}
placeholder="oauth"
/>
</Field>
<Field
label={t("models.field.workspace")}
hint={t("models.field.workspaceHint")}
>
<Input
value={form.workspace}
onChange={setField("workspace")}
placeholder="/path/to/workspace"
/>
</Field>
<Field
label={t("models.field.connectMode")}
hint={t("models.field.connectModeHint")}
>
<Input
value={form.connectMode}
onChange={setField("connectMode")}
placeholder="stdio"
/>
</Field>
<Field
label={t("models.field.requestTimeout")}
hint={t("models.field.requestTimeoutHint")}
>
<Input
value={form.requestTimeout}
onChange={setField("requestTimeout")}
placeholder="60"
type="number"
min={0}
/>
</Field>
<Field
label={t("models.field.workspace")}
hint={t("models.field.workspaceHint")}
>
<Input
value={form.workspace}
onChange={setField("workspace")}
placeholder="/path/to/workspace"
/>
</Field>
<Field
label={t("models.field.rpm")}
hint={t("models.field.rpmHint")}
>
<Input
value={form.rpm}
onChange={setField("rpm")}
placeholder="60"
type="number"
min={0}
/>
</Field>
<Field
label={t("models.field.requestTimeout")}
hint={t("models.field.requestTimeoutHint")}
>
<Input
value={form.requestTimeout}
onChange={setField("requestTimeout")}
placeholder="60"
type="number"
min={0}
/>
</Field>
<Field
label={t("models.field.thinkingLevel")}
hint={t("models.field.thinkingLevelHint")}
>
<Input
value={form.thinkingLevel}
onChange={setField("thinkingLevel")}
placeholder="off"
/>
</Field>
<Field
label={t("models.field.rpm")}
hint={t("models.field.rpmHint")}
>
<Input
value={form.rpm}
onChange={setField("rpm")}
placeholder="60"
type="number"
min={0}
/>
</Field>
<Field
label={t("models.field.maxTokensField")}
hint={t("models.field.maxTokensFieldHint")}
>
<Input
value={form.maxTokensField}
onChange={setField("maxTokensField")}
placeholder="max_completion_tokens"
/>
</Field>
<Field
label={t("models.field.thinkingLevel")}
hint={t("models.field.thinkingLevelHint")}
>
<Input
value={form.thinkingLevel}
onChange={setField("thinkingLevel")}
placeholder="off"
/>
</Field>
<Field
label={t("models.field.toolSchemaTransform")}
hint={t("models.field.toolSchemaTransformHint")}
>
<Input
value={form.toolSchemaTransform}
onChange={setField("toolSchemaTransform")}
placeholder="google"
/>
</Field>
<Field
label={t("models.field.maxTokensField")}
hint={t("models.field.maxTokensFieldHint")}
>
<Input
value={form.maxTokensField}
onChange={setField("maxTokensField")}
placeholder="max_completion_tokens"
/>
</Field>
<Field
label={t("models.field.extraBody")}
hint={t("models.field.extraBodyHint")}
>
<Textarea
value={form.extraBody}
onChange={setField("extraBody")}
placeholder='{"key": "value"}'
rows={3}
/>
</Field>
<Field
label={t("models.field.toolSchemaTransform")}
hint={t("models.field.toolSchemaTransformHint")}
>
<Input
value={form.toolSchemaTransform}
onChange={setField("toolSchemaTransform")}
placeholder="google"
/>
</Field>
<Field
label={t("models.field.customHeaders")}
hint={t("models.field.customHeadersHint")}
>
<Textarea
value={form.customHeaders}
onChange={setField("customHeaders")}
placeholder='{"X-Source": "coding-plan"}'
rows={3}
/>
</Field>
</AdvancedSection>
<Field
label={t("models.field.extraBody")}
hint={t("models.field.extraBodyHint")}
>
<Textarea
value={form.extraBody}
onChange={setField("extraBody")}
placeholder='{"key": "value"}'
rows={3}
/>
</Field>
<Field
label={t("models.field.customHeaders")}
hint={t("models.field.customHeadersHint")}
>
<Textarea
value={form.customHeaders}
onChange={setField("customHeaders")}
placeholder='{"X-Source": "coding-plan"}'
rows={3}
/>
</Field>
</AdvancedSection>
{serverError && (
<p className="text-destructive bg-destructive/10 rounded-md px-3 py-2 text-sm">
{serverError}
</p>
)}
{serverError && (
<p className="text-destructive bg-destructive/10 rounded-md px-3 py-2 text-sm">
{serverError}
</p>
)}
</div>
</div>
</div>
<SheetFooter className="border-t-muted border-t px-6 py-4">
{isDirty && (
<ConfigChangeNotice
kind="save"
title={t("common.saveChangesTitle")}
description={t("models.unsavedPrompt")}
/>
)}
<Button variant="ghost" onClick={onClose} disabled={saving}>
{t("common.cancel")}
</Button>
<Button
onClick={handleSave}
disabled={!isDirty || saving || modelValidation?.level === "error"}
>
{saving && <IconLoader2 className="size-4 animate-spin" />}
{t("models.add.confirm")}
</Button>
</SheetFooter>
</SheetContent>
<SheetFooter className="border-t-muted border-t px-6 py-4">
{isDirty && (
<ConfigChangeNotice
kind="save"
title={t("common.saveChangesTitle")}
description={t("models.unsavedPrompt")}
/>
)}
<Button variant="ghost" onClick={onClose} disabled={saving}>
{t("common.cancel")}
</Button>
<Button
onClick={handleSave}
disabled={
!isDirty || saving || modelValidation?.level === "error"
}
>
{saving && <IconLoader2 className="size-4 animate-spin" />}
{t("models.add.confirm")}
</Button>
</SheetFooter>
</SheetContent>
<FetchModelsDialog
open={fetchOpen}
onClose={() => setFetchOpen(false)}
onFill={handleFetchFill}
provider={form.provider}
apiKey={form.apiKey}
apiBase={form.apiBase}
/>
</Sheet>
<FetchModelsDialog
open={fetchOpen}
onClose={() => setFetchOpen(false)}
onFill={handleFetchFill}
provider={form.provider}
apiKey={form.apiKey}
apiBase={form.apiBase}
/>
<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>
</>
)
}

View file

@ -6,7 +6,12 @@ import {
import { useCallback, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { type ModelInfo, getCatalogs, setDefaultModel, updateModel } from "@/api/models"
import {
type ModelInfo,
getCatalogs,
setDefaultModel,
updateModel,
} from "@/api/models"
import { ConfigChangeNotice } from "@/components/config-change-notice"
import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
import {
@ -31,14 +36,10 @@ 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_API_BASES, PROVIDER_MAP } from "./provider-registry"
import { TestModelDialog } from "./test-model-dialog"
interface EditForm {
@ -108,9 +109,7 @@ function buildInitialEditForm(model: ModelInfo): EditForm {
workspace: model.workspace ?? "",
rpm: model.rpm ? String(model.rpm) : "",
maxTokensField: model.max_tokens_field ?? "",
requestTimeout: model.request_timeout
? String(model.request_timeout)
: "",
requestTimeout: model.request_timeout ? String(model.request_timeout) : "",
thinkingLevel: model.thinking_level ?? "",
toolSchemaTransform: model.tool_schema_transform ?? "", // <-- AGGIUNGI QUESTA RIGA
extraBody: model.extra_body
@ -149,7 +148,8 @@ export function EditModelSheet({
const [saving, setSaving] = useState(false)
const [setAsDefault, setSetAsDefault] = useState(false)
const [error, setError] = useState("")
const [modelValidation, setModelValidation] = useState<FieldValidation | null>(null)
const [modelValidation, setModelValidation] =
useState<FieldValidation | null>(null)
const [testOpen, setTestOpen] = useState(false)
const [fetchOpen, setFetchOpen] = useState(false)
const [fetchedModels, setFetchedModels] = useState<string[]>([])
@ -258,7 +258,9 @@ export function EditModelSheet({
extraBody = JSON.parse(form.extraBody.trim())
}
} catch {
setError(t("models.field.extraBody") + ": " + t("models.field.invalidJson"))
setError(
t("models.field.extraBody") + ": " + t("models.field.invalidJson"),
)
return
}
try {
@ -266,7 +268,9 @@ export function EditModelSheet({
customHeaders = JSON.parse(form.customHeaders.trim())
}
} catch {
setError(t("models.field.customHeaders") + ": " + t("models.field.invalidJson"))
setError(
t("models.field.customHeaders") + ": " + t("models.field.invalidJson"),
)
return
}
@ -365,9 +369,7 @@ export function EditModelSheet({
: t("models.add.modelIdPlaceholder")
}
className="font-mono text-sm"
aria-invalid={
!!error || modelValidation?.level === "error"
}
aria-invalid={!!error || modelValidation?.level === "error"}
/>
{modelValidation && modelValidation.messageKey && (
<div
@ -379,7 +381,12 @@ export function EditModelSheet({
: "text-green-600 dark:text-green-500"
}`}
>
<span>{t(modelValidation.messageKey, modelValidation.messageParams)}</span>
<span>
{t(
modelValidation.messageKey,
modelValidation.messageParams,
)}
</span>
{modelValidation.fix && (
<button
type="button"
@ -397,7 +404,7 @@ export function EditModelSheet({
<Badge
key={m}
variant="secondary"
className="cursor-pointer font-mono text-xs hover:bg-secondary/80"
className="hover:bg-secondary/80 cursor-pointer font-mono text-xs"
onClick={() => handleCommonModel(m)}
>
{m}
@ -658,6 +665,13 @@ export function EditModelSheet({
model={model}
open={testOpen}
onClose={() => setTestOpen(false)}
inlineParams={{
provider: form.provider,
model: form.modelId,
apiBase: form.apiBase,
apiKey: form.apiKey,
authMethod: form.authMethod,
}}
/>
<FetchModelsDialog

View file

@ -2,7 +2,12 @@ import { IconLoader2, IconPlugConnected, IconX } from "@tabler/icons-react"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { type ModelInfo, testModel } from "@/api/models"
import {
type ModelInfo,
type TestModelInlineRequest,
testModel,
testModelInline,
} from "@/api/models"
import { Button } from "@/components/ui/button"
import {
Dialog,
@ -13,10 +18,19 @@ import {
DialogTitle,
} from "@/components/ui/dialog"
export interface TestInlineParams {
provider: string
model: string
apiBase: string
apiKey: string
authMethod: string
}
interface TestModelDialogProps {
model: ModelInfo | null
open: boolean
onClose: () => void
inlineParams?: TestInlineParams
}
interface TestResult {
@ -30,17 +44,31 @@ 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 () => {
if (!model) return
setTesting(true)
setResult(null)
try {
const res = await testModel(model.index)
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,
}
res = await testModelInline(req)
} else if (model) {
res = await testModel(model.index)
} else {
return
}
setResult(res)
} catch (e) {
setResult({
@ -59,6 +87,12 @@ export function TestModelDialog({
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">
@ -67,26 +101,30 @@ export function TestModelDialog({
<IconPlugConnected className="size-5" />
{t("models.test.title")}
</DialogTitle>
<DialogDescription>
{t("models.test.description")}
</DialogDescription>
<DialogDescription>{t("models.test.description")}</DialogDescription>
</DialogHeader>
{model && (
{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">{model.model_name}</span>
<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">{model.model}</span>
<span className="text-muted-foreground">
{t("models.test.identifierLabel")}{" "}
</span>
<span className="font-mono">{displayModel}</span>
</div>
{model.api_base && (
{displayApiBase && (
<div>
<span className="text-muted-foreground">{t("models.test.endpointLabel")} </span>
<span className="font-mono text-xs">{model.api_base}</span>
<span className="text-muted-foreground">
{t("models.test.endpointLabel")}{" "}
</span>
<span className="font-mono text-xs">{displayApiBase}</span>
</div>
)}
</div>
@ -99,7 +137,7 @@ export function TestModelDialog({
)}
{testing && (
<div className="flex items-center justify-center gap-2 py-6 text-muted-foreground">
<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>
@ -115,19 +153,22 @@ export function TestModelDialog({
>
{result.success ? (
<div className="space-y-1">
<div className="font-medium">{t("models.test.success")}</div>
<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="font-medium flex items-center gap-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 })}
{result.error ||
t("models.test.status", { status: result.status })}
</div>
</div>
)}