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("PUT /api/models/{index}", h.handleUpdateModel)
mux.HandleFunc("DELETE /api/models/{index}", h.handleDeleteModel) mux.HandleFunc("DELETE /api/models/{index}", h.handleDeleteModel)
mux.HandleFunc("POST /api/models/{index}/test", h.handleTestModel) 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("POST /api/models/fetch", h.handleFetchModels)
mux.HandleFunc("GET /api/models/catalog", h.handleListCatalogs) mux.HandleFunc("GET /api/models/catalog", h.handleListCatalogs)
mux.HandleFunc("DELETE /api/models/catalog/{id}", h.handleDeleteCatalog) 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) 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. // handleFetchModels fetches available models from an upstream provider.
// //
// POST /api/models/fetch // 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 { export interface UpstreamModel {
id: string id: string
owned_by?: string owned_by?: string
@ -180,9 +198,12 @@ export async function getCatalogs(): Promise<CatalogListResponse> {
} }
export async function deleteCatalog(id: string): Promise<void> { export async function deleteCatalog(id: string): Promise<void> {
await request<Record<string, never>>(`/api/models/catalog/${encodeURIComponent(id)}`, { await request<Record<string, never>>(
method: "DELETE", `/api/models/catalog/${encodeURIComponent(id)}`,
}) {
method: "DELETE",
},
)
} }
export type { ModelsListResponse, ModelActionResponse } 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 { useCallback, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
@ -27,13 +31,11 @@ import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
import { refreshGatewayState } from "@/store/gateway" import { refreshGatewayState } from "@/store/gateway"
import { FetchModelsDialog } from "./fetch-models-dialog" import { FetchModelsDialog } from "./fetch-models-dialog"
import { import { type FieldValidation, validateModelField } from "./model-validation"
type FieldValidation,
validateModelField,
} from "./model-validation"
import { ProviderCombobox } from "./provider-combobox" import { ProviderCombobox } from "./provider-combobox"
import { getProviderKey } from "./provider-label" import { getProviderKey } from "./provider-label"
import { PROVIDER_MAP } from "./provider-registry" import { PROVIDER_MAP } from "./provider-registry"
import { TestModelDialog } from "./test-model-dialog"
interface AddForm { interface AddForm {
modelName: string modelName: string
@ -86,7 +88,8 @@ function getNextApiBaseForProviderChange(
const currentDefaultApiBase = normalizeApiBase( const currentDefaultApiBase = normalizeApiBase(
PROVIDER_MAP.get(currentProvider)?.defaultApiBase ?? "", PROVIDER_MAP.get(currentProvider)?.defaultApiBase ?? "",
) )
const nextDefaultApiBase = PROVIDER_MAP.get(nextProvider)?.defaultApiBase ?? "" const nextDefaultApiBase =
PROVIDER_MAP.get(nextProvider)?.defaultApiBase ?? ""
if (!normalizedCurrentApiBase) { if (!normalizedCurrentApiBase) {
return nextDefaultApiBase return nextDefaultApiBase
@ -124,8 +127,10 @@ export function AddModelSheet({
Partial<Record<keyof AddForm, string>> Partial<Record<keyof AddForm, string>>
>({}) >({})
const [serverError, setServerError] = useState("") const [serverError, setServerError] = useState("")
const [modelValidation, setModelValidation] = useState<FieldValidation | null>(null) const [modelValidation, setModelValidation] =
useState<FieldValidation | null>(null)
const [fetchOpen, setFetchOpen] = useState(false) const [fetchOpen, setFetchOpen] = useState(false)
const [testOpen, setTestOpen] = useState(false)
const [fetchedModels, setFetchedModels] = useState<string[]>([]) const [fetchedModels, setFetchedModels] = useState<string[]>([])
const [catalogModels, setCatalogModels] = useState<string[]>([]) const [catalogModels, setCatalogModels] = useState<string[]>([])
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined) const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
@ -170,7 +175,9 @@ export function AddModelSheet({
setCatalogModels(unique) setCatalogModels(unique)
}) })
.catch(() => {}) .catch(() => {})
return () => { cancelled = true } return () => {
cancelled = true
}
}, [form.provider, form.apiBase]) }, [form.provider, form.apiBase])
const validate = (): boolean => { const validate = (): boolean => {
@ -183,7 +190,10 @@ export function AddModelSheet({
} }
if (!form.model.trim()) errors.model = t("models.add.errorRequired") if (!form.model.trim()) errors.model = t("models.add.errorRequired")
if (modelValidation?.level === "error") { if (modelValidation?.level === "error") {
errors.model = t(modelValidation.messageKey, modelValidation.messageParams) errors.model = t(
modelValidation.messageKey,
modelValidation.messageParams,
)
} }
setFieldErrors(errors) setFieldErrors(errors)
return Object.keys(errors).length === 0 return Object.keys(errors).length === 0
@ -275,7 +285,9 @@ export function AddModelSheet({
extraBody = JSON.parse(form.extraBody.trim()) extraBody = JSON.parse(form.extraBody.trim())
} }
} catch { } catch {
setServerError(t("models.field.extraBody") + ": " + t("models.field.invalidJson")) setServerError(
t("models.field.extraBody") + ": " + t("models.field.invalidJson"),
)
return return
} }
try { try {
@ -283,7 +295,9 @@ export function AddModelSheet({
customHeaders = JSON.parse(form.customHeaders.trim()) customHeaders = JSON.parse(form.customHeaders.trim())
} }
} catch { } catch {
setServerError(t("models.field.customHeaders") + ": " + t("models.field.invalidJson")) setServerError(
t("models.field.customHeaders") + ": " + t("models.field.invalidJson"),
)
return return
} }
@ -334,341 +348,377 @@ export function AddModelSheet({
return ( return (
<> <>
<Sheet open={open} onOpenChange={(v) => !v && onClose()}> <Sheet open={open} onOpenChange={(v) => !v && onClose()}>
<SheetContent <SheetContent
side="right" 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]" 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"> <SheetHeader className="border-b-muted border-b px-6 py-5">
<SheetTitle className="text-base">{t("models.add.title")}</SheetTitle> <SheetTitle className="text-base">
<SheetDescription className="text-xs"> {t("models.add.title")}
{t("models.add.description")} </SheetTitle>
</SheetDescription> <SheetDescription className="text-xs">
</SheetHeader> {t("models.add.description")}
</SheetDescription>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto"> <div className="min-h-0 flex-1 overflow-y-auto">
<div className="space-y-5 px-6 py-5"> <div className="space-y-5 px-6 py-5">
<Field <Field
label={t("models.add.modelName")} label={t("models.add.modelName")}
hint={t("models.add.modelNameHint")} hint={t("models.add.modelNameHint")}
> >
<Input <Input
value={form.modelName} value={form.modelName}
onChange={setField("modelName")} onChange={setField("modelName")}
placeholder={t("models.add.modelNamePlaceholder")} placeholder={t("models.add.modelNamePlaceholder")}
aria-invalid={!!fieldErrors.modelName} aria-invalid={!!fieldErrors.modelName}
/> />
{fieldErrors.modelName && ( {fieldErrors.modelName && (
<p className="text-destructive text-xs"> <p className="text-destructive text-xs">
{fieldErrors.modelName} {fieldErrors.modelName}
</p> </p>
)} )}
</Field> </Field>
<Field <Field
label={t("models.field.provider")} label={t("models.field.provider")}
hint={t("models.field.providerHint")} hint={t("models.field.providerHint")}
> >
<ProviderCombobox <ProviderCombobox
value={form.provider} value={form.provider}
onChange={handleProviderChange} onChange={handleProviderChange}
placeholder={t("models.field.providerPlaceholder")} placeholder={t("models.field.providerPlaceholder")}
/> />
</Field> </Field>
<Field <Field
label={t("models.add.modelId")} label={t("models.add.modelId")}
hint={t("models.add.modelIdHint")} hint={t("models.add.modelIdHint")}
> >
<Input <Input
value={form.model} value={form.model}
onChange={handleModelChange} onChange={handleModelChange}
placeholder={ placeholder={
providerDef providerDef
? `${commonModels[0] || "model-name"}` ? `${commonModels[0] || "model-name"}`
: t("models.add.modelIdPlaceholder") : t("models.add.modelIdPlaceholder")
} }
className="font-mono text-sm" className="font-mono text-sm"
aria-invalid={ aria-invalid={
!!fieldErrors.model || modelValidation?.level === "error" !!fieldErrors.model || modelValidation?.level === "error"
} }
/> />
{modelValidation && modelValidation.messageKey && ( {modelValidation && modelValidation.messageKey && (
<div <div
className={`flex items-center gap-2 text-xs ${ className={`flex items-center gap-2 text-xs ${
modelValidation.level === "error" modelValidation.level === "error"
? "text-destructive" ? "text-destructive"
: modelValidation.level === "warning" : modelValidation.level === "warning"
? "text-yellow-600 dark:text-yellow-500" ? "text-yellow-600 dark:text-yellow-500"
: "text-green-600 dark:text-green-500" : "text-green-600 dark:text-green-500"
}`} }`}
> >
<span>{t(modelValidation.messageKey, modelValidation.messageParams)}</span> <span>
{modelValidation.fix && ( {t(
<button modelValidation.messageKey,
type="button" modelValidation.messageParams,
onClick={applyFix} )}
className="text-primary underline hover:no-underline" </span>
> {modelValidation.fix && (
{t("common.fix")} <button
</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> </div>
)} </Field>
{fieldErrors.model && !modelValidation && (
<p className="text-destructive text-xs">{fieldErrors.model}</p> <Field label={t("models.field.apiKey")}>
)} <KeyInput
{commonModels.length > 0 && ( value={form.apiKey}
<div className="flex flex-wrap gap-1.5"> onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
{commonModels.map((m) => ( placeholder={apiKeyPlaceholder}
<Badge />
key={m} </Field>
variant="secondary"
className="cursor-pointer font-mono text-xs hover:bg-secondary/80" <Field label={t("models.field.apiBase")}>
onClick={() => handleCommonModel(m)} <Input
> value={form.apiBase}
{m} onChange={setField("apiBase")}
</Badge> placeholder="https://api.example.com/v1"
))} />
</div> </Field>
)}
{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"> <div className="flex items-center gap-2">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-7 text-xs" onClick={() => setTestOpen(true)}
onClick={() => setFetchOpen(true)} disabled={!form.provider || !form.model}
disabled={!form.provider}
> >
<IconDownload className="size-3" /> <IconPlugConnected className="size-4" />
{t("models.fetch.title")} {t("models.test.testConnection")}
</Button> </Button>
{!form.provider && (
<span className="text-muted-foreground text-xs">
{t("models.field.selectProviderFirst")}
</span>
)}
</div> </div>
</Field>
<Field label={t("models.field.apiKey")}> <SwitchCardField
<KeyInput label={t("models.defaultOnSave.label")}
value={form.apiKey} hint={t("models.defaultOnSave.description")}
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))} checked={setAsDefault}
placeholder={apiKeyPlaceholder} onCheckedChange={setSetAsDefault}
/> />
</Field>
<Field label={t("models.field.apiBase")}> <AdvancedSection>
<Input <Field
value={form.apiBase} label={t("models.field.proxy")}
onChange={setField("apiBase")} hint={t("models.field.proxyHint")}
placeholder="https://api.example.com/v1" >
/> <Input
</Field> value={form.proxy}
onChange={setField("proxy")}
placeholder="http://127.0.0.1:7890"
/>
</Field>
<SwitchCardField <Field
label={t("models.defaultOnSave.label")} label={t("models.field.authMethod")}
hint={t("models.defaultOnSave.description")} hint={t("models.field.authMethodHint")}
checked={setAsDefault} >
onCheckedChange={setSetAsDefault} <Input
/> value={form.authMethod}
onChange={setField("authMethod")}
placeholder="oauth"
/>
</Field>
<AdvancedSection> <Field
<Field label={t("models.field.connectMode")}
label={t("models.field.proxy")} hint={t("models.field.connectModeHint")}
hint={t("models.field.proxyHint")} >
> <Input
<Input value={form.connectMode}
value={form.proxy} onChange={setField("connectMode")}
onChange={setField("proxy")} placeholder="stdio"
placeholder="http://127.0.0.1:7890" />
/> </Field>
</Field>
<Field <Field
label={t("models.field.authMethod")} label={t("models.field.workspace")}
hint={t("models.field.authMethodHint")} hint={t("models.field.workspaceHint")}
> >
<Input <Input
value={form.authMethod} value={form.workspace}
onChange={setField("authMethod")} onChange={setField("workspace")}
placeholder="oauth" placeholder="/path/to/workspace"
/> />
</Field> </Field>
<Field <Field
label={t("models.field.connectMode")} label={t("models.field.requestTimeout")}
hint={t("models.field.connectModeHint")} hint={t("models.field.requestTimeoutHint")}
> >
<Input <Input
value={form.connectMode} value={form.requestTimeout}
onChange={setField("connectMode")} onChange={setField("requestTimeout")}
placeholder="stdio" placeholder="60"
/> type="number"
</Field> min={0}
/>
</Field>
<Field <Field
label={t("models.field.workspace")} label={t("models.field.rpm")}
hint={t("models.field.workspaceHint")} hint={t("models.field.rpmHint")}
> >
<Input <Input
value={form.workspace} value={form.rpm}
onChange={setField("workspace")} onChange={setField("rpm")}
placeholder="/path/to/workspace" placeholder="60"
/> type="number"
</Field> min={0}
/>
</Field>
<Field <Field
label={t("models.field.requestTimeout")} label={t("models.field.thinkingLevel")}
hint={t("models.field.requestTimeoutHint")} hint={t("models.field.thinkingLevelHint")}
> >
<Input <Input
value={form.requestTimeout} value={form.thinkingLevel}
onChange={setField("requestTimeout")} onChange={setField("thinkingLevel")}
placeholder="60" placeholder="off"
type="number" />
min={0} </Field>
/>
</Field>
<Field <Field
label={t("models.field.rpm")} label={t("models.field.maxTokensField")}
hint={t("models.field.rpmHint")} hint={t("models.field.maxTokensFieldHint")}
> >
<Input <Input
value={form.rpm} value={form.maxTokensField}
onChange={setField("rpm")} onChange={setField("maxTokensField")}
placeholder="60" placeholder="max_completion_tokens"
type="number" />
min={0} </Field>
/>
</Field>
<Field <Field
label={t("models.field.thinkingLevel")} label={t("models.field.toolSchemaTransform")}
hint={t("models.field.thinkingLevelHint")} hint={t("models.field.toolSchemaTransformHint")}
> >
<Input <Input
value={form.thinkingLevel} value={form.toolSchemaTransform}
onChange={setField("thinkingLevel")} onChange={setField("toolSchemaTransform")}
placeholder="off" placeholder="google"
/> />
</Field> </Field>
<Field <Field
label={t("models.field.maxTokensField")} label={t("models.field.extraBody")}
hint={t("models.field.maxTokensFieldHint")} hint={t("models.field.extraBodyHint")}
> >
<Input <Textarea
value={form.maxTokensField} value={form.extraBody}
onChange={setField("maxTokensField")} onChange={setField("extraBody")}
placeholder="max_completion_tokens" placeholder='{"key": "value"}'
/> rows={3}
</Field> />
</Field>
<Field <Field
label={t("models.field.toolSchemaTransform")} label={t("models.field.customHeaders")}
hint={t("models.field.toolSchemaTransformHint")} hint={t("models.field.customHeadersHint")}
> >
<Input <Textarea
value={form.toolSchemaTransform} value={form.customHeaders}
onChange={setField("toolSchemaTransform")} onChange={setField("customHeaders")}
placeholder="google" placeholder='{"X-Source": "coding-plan"}'
/> rows={3}
</Field> />
</Field>
</AdvancedSection>
<Field {serverError && (
label={t("models.field.extraBody")} <p className="text-destructive bg-destructive/10 rounded-md px-3 py-2 text-sm">
hint={t("models.field.extraBodyHint")} {serverError}
> </p>
<Textarea )}
value={form.extraBody} </div>
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>
)}
</div> </div>
</div>
<SheetFooter className="border-t-muted border-t px-6 py-4"> <SheetFooter className="border-t-muted border-t px-6 py-4">
{isDirty && ( {isDirty && (
<ConfigChangeNotice <ConfigChangeNotice
kind="save" kind="save"
title={t("common.saveChangesTitle")} title={t("common.saveChangesTitle")}
description={t("models.unsavedPrompt")} description={t("models.unsavedPrompt")}
/> />
)} )}
<Button variant="ghost" onClick={onClose} disabled={saving}> <Button variant="ghost" onClick={onClose} disabled={saving}>
{t("common.cancel")} {t("common.cancel")}
</Button> </Button>
<Button <Button
onClick={handleSave} onClick={handleSave}
disabled={!isDirty || saving || modelValidation?.level === "error"} disabled={
> !isDirty || saving || modelValidation?.level === "error"
{saving && <IconLoader2 className="size-4 animate-spin" />} }
{t("models.add.confirm")} >
</Button> {saving && <IconLoader2 className="size-4 animate-spin" />}
</SheetFooter> {t("models.add.confirm")}
</SheetContent> </Button>
</SheetFooter>
</SheetContent>
<FetchModelsDialog <FetchModelsDialog
open={fetchOpen} open={fetchOpen}
onClose={() => setFetchOpen(false)} onClose={() => setFetchOpen(false)}
onFill={handleFetchFill} onFill={handleFetchFill}
provider={form.provider} provider={form.provider}
apiKey={form.apiKey} apiKey={form.apiKey}
apiBase={form.apiBase} apiBase={form.apiBase}
/> />
</Sheet>
<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 { useCallback, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next" 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 { ConfigChangeNotice } from "@/components/config-change-notice"
import { maskedSecretPlaceholder } from "@/components/secret-placeholder" import { maskedSecretPlaceholder } from "@/components/secret-placeholder"
import { import {
@ -31,14 +36,10 @@ import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
import { refreshGatewayState } from "@/store/gateway" import { refreshGatewayState } from "@/store/gateway"
import { FetchModelsDialog } from "./fetch-models-dialog" import { FetchModelsDialog } from "./fetch-models-dialog"
import { import { type FieldValidation, validateModelField } from "./model-validation"
type FieldValidation,
validateModelField,
} from "./model-validation"
import { ProviderCombobox } from "./provider-combobox" import { ProviderCombobox } from "./provider-combobox"
import { getProviderKey } from "./provider-label" import { getProviderKey } from "./provider-label"
import { PROVIDER_API_BASES, PROVIDER_MAP } from "./provider-registry" import { PROVIDER_API_BASES, PROVIDER_MAP } from "./provider-registry"
import { TestModelDialog } from "./test-model-dialog" import { TestModelDialog } from "./test-model-dialog"
interface EditForm { interface EditForm {
@ -108,9 +109,7 @@ function buildInitialEditForm(model: ModelInfo): EditForm {
workspace: model.workspace ?? "", workspace: model.workspace ?? "",
rpm: model.rpm ? String(model.rpm) : "", rpm: model.rpm ? String(model.rpm) : "",
maxTokensField: model.max_tokens_field ?? "", maxTokensField: model.max_tokens_field ?? "",
requestTimeout: model.request_timeout requestTimeout: model.request_timeout ? String(model.request_timeout) : "",
? String(model.request_timeout)
: "",
thinkingLevel: model.thinking_level ?? "", thinkingLevel: model.thinking_level ?? "",
toolSchemaTransform: model.tool_schema_transform ?? "", // <-- AGGIUNGI QUESTA RIGA toolSchemaTransform: model.tool_schema_transform ?? "", // <-- AGGIUNGI QUESTA RIGA
extraBody: model.extra_body extraBody: model.extra_body
@ -149,7 +148,8 @@ export function EditModelSheet({
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [setAsDefault, setSetAsDefault] = useState(false) const [setAsDefault, setSetAsDefault] = useState(false)
const [error, setError] = useState("") const [error, setError] = useState("")
const [modelValidation, setModelValidation] = useState<FieldValidation | null>(null) const [modelValidation, setModelValidation] =
useState<FieldValidation | null>(null)
const [testOpen, setTestOpen] = useState(false) const [testOpen, setTestOpen] = useState(false)
const [fetchOpen, setFetchOpen] = useState(false) const [fetchOpen, setFetchOpen] = useState(false)
const [fetchedModels, setFetchedModels] = useState<string[]>([]) const [fetchedModels, setFetchedModels] = useState<string[]>([])
@ -258,7 +258,9 @@ export function EditModelSheet({
extraBody = JSON.parse(form.extraBody.trim()) extraBody = JSON.parse(form.extraBody.trim())
} }
} catch { } catch {
setError(t("models.field.extraBody") + ": " + t("models.field.invalidJson")) setError(
t("models.field.extraBody") + ": " + t("models.field.invalidJson"),
)
return return
} }
try { try {
@ -266,7 +268,9 @@ export function EditModelSheet({
customHeaders = JSON.parse(form.customHeaders.trim()) customHeaders = JSON.parse(form.customHeaders.trim())
} }
} catch { } catch {
setError(t("models.field.customHeaders") + ": " + t("models.field.invalidJson")) setError(
t("models.field.customHeaders") + ": " + t("models.field.invalidJson"),
)
return return
} }
@ -365,9 +369,7 @@ export function EditModelSheet({
: t("models.add.modelIdPlaceholder") : t("models.add.modelIdPlaceholder")
} }
className="font-mono text-sm" className="font-mono text-sm"
aria-invalid={ aria-invalid={!!error || modelValidation?.level === "error"}
!!error || modelValidation?.level === "error"
}
/> />
{modelValidation && modelValidation.messageKey && ( {modelValidation && modelValidation.messageKey && (
<div <div
@ -379,7 +381,12 @@ export function EditModelSheet({
: "text-green-600 dark:text-green-500" : "text-green-600 dark:text-green-500"
}`} }`}
> >
<span>{t(modelValidation.messageKey, modelValidation.messageParams)}</span> <span>
{t(
modelValidation.messageKey,
modelValidation.messageParams,
)}
</span>
{modelValidation.fix && ( {modelValidation.fix && (
<button <button
type="button" type="button"
@ -397,7 +404,7 @@ export function EditModelSheet({
<Badge <Badge
key={m} key={m}
variant="secondary" 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)} onClick={() => handleCommonModel(m)}
> >
{m} {m}
@ -658,6 +665,13 @@ export function EditModelSheet({
model={model} model={model}
open={testOpen} open={testOpen}
onClose={() => setTestOpen(false)} onClose={() => setTestOpen(false)}
inlineParams={{
provider: form.provider,
model: form.modelId,
apiBase: form.apiBase,
apiKey: form.apiKey,
authMethod: form.authMethod,
}}
/> />
<FetchModelsDialog <FetchModelsDialog

View file

@ -2,7 +2,12 @@ import { IconLoader2, IconPlugConnected, IconX } from "@tabler/icons-react"
import { useState } from "react" import { useState } from "react"
import { useTranslation } from "react-i18next" 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 { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
@ -13,10 +18,19 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
export interface TestInlineParams {
provider: string
model: string
apiBase: string
apiKey: string
authMethod: string
}
interface TestModelDialogProps { interface TestModelDialogProps {
model: ModelInfo | null model: ModelInfo | null
open: boolean open: boolean
onClose: () => void onClose: () => void
inlineParams?: TestInlineParams
} }
interface TestResult { interface TestResult {
@ -30,17 +44,31 @@ export function TestModelDialog({
model, model,
open, open,
onClose, onClose,
inlineParams,
}: TestModelDialogProps) { }: TestModelDialogProps) {
const { t } = useTranslation() const { t } = useTranslation()
const [testing, setTesting] = useState(false) const [testing, setTesting] = useState(false)
const [result, setResult] = useState<TestResult | null>(null) const [result, setResult] = useState<TestResult | null>(null)
const handleTest = async () => { const handleTest = async () => {
if (!model) return
setTesting(true) setTesting(true)
setResult(null) setResult(null)
try { 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) setResult(res)
} catch (e) { } catch (e) {
setResult({ setResult({
@ -59,6 +87,12 @@ export function TestModelDialog({
onClose() 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 ( return (
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}> <Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">
@ -67,26 +101,30 @@ export function TestModelDialog({
<IconPlugConnected className="size-5" /> <IconPlugConnected className="size-5" />
{t("models.test.title")} {t("models.test.title")}
</DialogTitle> </DialogTitle>
<DialogDescription> <DialogDescription>{t("models.test.description")}</DialogDescription>
{t("models.test.description")}
</DialogDescription>
</DialogHeader> </DialogHeader>
{model && ( {canTest && (
<div className="space-y-3"> <div className="space-y-3">
<div className="bg-muted/50 rounded-lg p-3 text-sm"> <div className="bg-muted/50 rounded-lg p-3 text-sm">
<div> <div>
<span className="text-muted-foreground">{t("models.test.modelLabel")} </span> <span className="text-muted-foreground">
<span className="font-mono">{model.model_name}</span> {t("models.test.modelLabel")}{" "}
</span>
<span className="font-mono">{displayModelName}</span>
</div> </div>
<div> <div>
<span className="text-muted-foreground">{t("models.test.identifierLabel")} </span> <span className="text-muted-foreground">
<span className="font-mono">{model.model}</span> {t("models.test.identifierLabel")}{" "}
</span>
<span className="font-mono">{displayModel}</span>
</div> </div>
{model.api_base && ( {displayApiBase && (
<div> <div>
<span className="text-muted-foreground">{t("models.test.endpointLabel")} </span> <span className="text-muted-foreground">
<span className="font-mono text-xs">{model.api_base}</span> {t("models.test.endpointLabel")}{" "}
</span>
<span className="font-mono text-xs">{displayApiBase}</span>
</div> </div>
)} )}
</div> </div>
@ -99,7 +137,7 @@ export function TestModelDialog({
)} )}
{testing && ( {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" /> <IconLoader2 className="size-5 animate-spin" />
<span>{t("models.test.testing")}</span> <span>{t("models.test.testing")}</span>
</div> </div>
@ -115,19 +153,22 @@ export function TestModelDialog({
> >
{result.success ? ( {result.success ? (
<div className="space-y-1"> <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"> <div className="text-xs opacity-80">
{t("models.test.responseTime", { ms: result.latency_ms })} {t("models.test.responseTime", { ms: result.latency_ms })}
</div> </div>
</div> </div>
) : ( ) : (
<div className="space-y-1"> <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" /> <IconX className="size-4" />
{t("models.test.failed")} {t("models.test.failed")}
</div> </div>
<div className="text-xs opacity-80"> <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>
</div> </div>
)} )}