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:
parent
9cb14eef52
commit
77c6f31983
5 changed files with 574 additions and 355 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue