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("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
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue