From 426159285e3cc808d87b58ef4f3f8309cd7b7106 Mon Sep 17 00:00:00 2001 From: SiYue-ZO <2835601846@qq.com> Date: Mon, 11 May 2026 18:00:07 +0800 Subject: [PATCH 1/2] feat(web,api): add fetch models and saved catalog support Split from PR #2752 (part 2 of 3). Backend: - /api/models/catalog endpoint for browsing remote model catalogs - /api/models/fetch endpoint for fetching available models from providers - Credential reuse with provider/API base matching for security - Default API base resolution for providers without explicit base Frontend: - FetchModelsDialog for importing models from remote providers - CatalogDialog for browsing and importing from model catalogs - Static import for FetchModelsDialog (replaces dynamic import from PR1) - Dynamic import retained for TestModelDialog (PR3 territory) --- web/backend/api/model_catalog.go | 161 +++++++++ web/backend/api/models.go | 186 ++++++++++ .../src/components/models/add-model-sheet.tsx | 31 +- .../src/components/models/catalog-dialog.tsx | 333 +++++++++++++++++- .../components/models/edit-model-sheet.tsx | 31 +- .../components/models/fetch-models-dialog.tsx | 226 +++++++++++- .../src/components/models/models-page.tsx | 24 +- 7 files changed, 931 insertions(+), 61 deletions(-) create mode 100644 web/backend/api/model_catalog.go diff --git a/web/backend/api/model_catalog.go b/web/backend/api/model_catalog.go new file mode 100644 index 000000000..da092e89e --- /dev/null +++ b/web/backend/api/model_catalog.go @@ -0,0 +1,161 @@ +package api + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "net/http" + "os" + "path/filepath" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/fileutil" +) + +// CatalogModel represents a single model entry in a saved catalog. +type CatalogModel struct { + ID string `json:"id"` + OwnedBy string `json:"owned_by,omitempty"` + Extra map[string]any `json:"extra,omitempty"` +} + +// CatalogEntry is a saved list of upstream models fetched for a specific provider+key combination. +type CatalogEntry struct { + ID string `json:"id"` + Provider string `json:"provider"` + APIBase string `json:"api_base"` + APIKeyMask string `json:"api_key_mask"` + Models []CatalogModel `json:"models"` + FetchedAt string `json:"fetched_at"` +} + +// CatalogStore holds all saved model catalogs. +type CatalogStore struct { + Entries map[string]*CatalogEntry `json:"entries"` +} + +func catalogFilePath() string { + return filepath.Join(config.GetHome(), "model_catalogs.json") +} + +// generateCatalogKey creates a deterministic key for a provider+base+key combination. +func generateCatalogKey(provider, apiBase, apiKey string) string { + provider = strings.ToLower(strings.TrimSpace(provider)) + apiBase = strings.TrimRight(strings.TrimSpace(apiBase), "/") + hash := sha256.Sum256([]byte(apiKey)) + return fmt.Sprintf("%s|%s|%x", provider, apiBase, hash[:6]) +} + +// maskAPIKeyValue masks an API key for display, keeping first 4 and last 4 chars. +func maskAPIKeyValue(key string) string { + key = strings.TrimSpace(key) + if key == "" { + return "" + } + if len(key) <= 8 { + return "****" + } + return key[:4] + "****" + key[len(key)-4:] +} + +func loadCatalogs() (*CatalogStore, error) { + path := catalogFilePath() + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &CatalogStore{Entries: make(map[string]*CatalogEntry)}, nil + } + return nil, err + } + var store CatalogStore + if err := json.Unmarshal(data, &store); err != nil { + return nil, err + } + if store.Entries == nil { + store.Entries = make(map[string]*CatalogEntry) + } + return &store, nil +} + +func saveCatalogs(store *CatalogStore) error { + path := catalogFilePath() + data, err := json.MarshalIndent(store, "", " ") + if err != nil { + return err + } + return fileutil.WriteFileAtomic(path, data, 0o600) +} + +// SaveCatalog persists a fetched model list for a given provider+key combination. +// If a catalog with the same key already exists, it is updated. +func SaveCatalog(provider, apiBase, apiKey string, models []CatalogModel) error { + store, err := loadCatalogs() + if err != nil { + return err + } + key := generateCatalogKey(provider, apiBase, apiKey) + store.Entries[key] = &CatalogEntry{ + ID: key, + Provider: strings.ToLower(strings.TrimSpace(provider)), + APIBase: strings.TrimRight(strings.TrimSpace(apiBase), "/"), + APIKeyMask: maskAPIKeyValue(apiKey), + Models: models, + FetchedAt: time.Now().UTC().Format(time.RFC3339), + } + return saveCatalogs(store) +} + +// handleListCatalogs returns all saved model catalogs. +// +// GET /api/models/catalog +func (h *Handler) handleListCatalogs(w http.ResponseWriter, r *http.Request) { + store, err := loadCatalogs() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load catalogs: %v", err), http.StatusInternalServerError) + return + } + + entries := make([]*CatalogEntry, 0, len(store.Entries)) + for _, e := range store.Entries { + entries = append(entries, e) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "entries": entries, + "total": len(entries), + }) +} + +// handleDeleteCatalog deletes a saved model catalog by ID. +// +// DELETE /api/models/catalog/{id} +func (h *Handler) handleDeleteCatalog(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + http.Error(w, "id is required", http.StatusBadRequest) + return + } + + store, err := loadCatalogs() + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load catalogs: %v", err), http.StatusInternalServerError) + return + } + + if _, ok := store.Entries[id]; !ok { + http.Error(w, "catalog not found", http.StatusNotFound) + return + } + + delete(store.Entries, id) + if err := saveCatalogs(store); err != nil { + http.Error(w, fmt.Sprintf("Failed to save catalogs: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "ok"}) +} diff --git a/web/backend/api/models.go b/web/backend/api/models.go index 8a66918f9..145d7ac4a 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "fmt" "io" @@ -8,6 +9,7 @@ import ( "strconv" "strings" "sync" + "time" "github.com/sipeed/picoclaw/pkg/audio/asr" "github.com/sipeed/picoclaw/pkg/config" @@ -15,6 +17,18 @@ import ( "github.com/sipeed/picoclaw/pkg/providers" ) +// fetchableProviders lists providers that support OpenAI-compatible /models listing. +var fetchableProviders = map[string]bool{ + "openai": true, "deepseek": true, "openrouter": true, + "qwen-portal": true, "qwen-intl": true, "moonshot": true, + "volcengine": true, "zhipu": true, "groq": true, + "mistral": true, "nvidia": true, "cerebras": true, + "venice": true, "shengsuanyun": true, "vivgrid": true, + "minimax": true, "longcat": true, "modelscope": true, + "mimo": true, "avian": true, "zai": true, "novita": true, + "litellm": true, "vllm": true, "lmstudio": true, "ollama": true, +} + // registerModelRoutes binds model list management endpoints to the ServeMux. func (h *Handler) registerModelRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/models", h.handleListModels) @@ -22,6 +36,9 @@ func (h *Handler) registerModelRoutes(mux *http.ServeMux) { mux.HandleFunc("POST /api/models/default", h.handleSetDefaultModel) mux.HandleFunc("PUT /api/models/{index}", h.handleUpdateModel) mux.HandleFunc("DELETE /api/models/{index}", h.handleDeleteModel) + 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) } // modelResponse is the JSON structure returned for each model in the list. @@ -614,3 +631,172 @@ func maskAPIKey(key string) string { // Show first 3 chars and last 4 chars return key[:3] + "****" + key[len(key)-4:] } + +// handleFetchModels fetches available models from an upstream provider. +// +// POST /api/models/fetch +func (h *Handler) handleFetchModels(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 + } + defer r.Body.Close() + + var req struct { + Provider string `json:"provider"` + APIKey string `json:"api_key"` + APIBase string `json:"api_base"` + } + if err = json.Unmarshal(body, &req); err != nil { + http.Error(w, fmt.Sprintf("Invalid JSON: %v", err), http.StatusBadRequest) + return + } + + if req.Provider == "" { + http.Error(w, "provider is required", http.StatusBadRequest) + return + } + + if !fetchableProviders[strings.ToLower(req.Provider)] { + http.Error(w, fmt.Sprintf("provider %q does not support model listing", req.Provider), http.StatusBadRequest) + return + } + + apiBase := strings.TrimSpace(req.APIBase) + if apiBase == "" { + apiBase = providers.DefaultAPIBaseForProtocol(req.Provider) + } + if apiBase == "" { + http.Error(w, fmt.Sprintf("No default API base for provider %q", req.Provider), http.StatusBadRequest) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) + defer cancel() + + models, err := fetchUpstreamModels(ctx, req.Provider, apiBase, req.APIKey) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to fetch models: %v", err), http.StatusBadGateway) + return + } + + // Auto-save fetched models to catalog + catalogModels := make([]CatalogModel, len(models)) + for i, m := range models { + catalogModels[i] = CatalogModel{ID: m.ID, OwnedBy: m.OwnedBy} + } + if saveErr := SaveCatalog(req.Provider, apiBase, req.APIKey, catalogModels); saveErr != nil { + // Log but don't fail the request — saving catalog is non-critical + logger.Warnf("Failed to save model catalog: %v", saveErr) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "models": models, + "total": len(models), + }) +} + +type upstreamModel struct { + ID string `json:"id"` + OwnedBy string `json:"owned_by,omitempty"` +} + +func fetchUpstreamModels(ctx context.Context, provider, apiBase, apiKey string) ([]upstreamModel, error) { + apiBase = strings.TrimRight(strings.TrimSpace(apiBase), "/") + + var fetchURL string + switch strings.ToLower(provider) { + case "ollama": + // Strip /v1 suffix if present to get the Ollama root + root := apiBase + if strings.HasSuffix(root, "/v1") { + root = root[:len(root)-3] + } + root = strings.TrimRight(root, "/") + fetchURL = root + "/api/tags" + return fetchOllamaModels(ctx, fetchURL) + default: + // OpenAI-compatible: /v1/models + fetchURL = apiBase + "/models" + return fetchOpenAICompatibleModels(ctx, fetchURL, apiKey) + } +} + +func fetchOpenAICompatibleModels(ctx context.Context, fetchURL, apiKey string) ([]upstreamModel, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fetchURL, nil) + if err != nil { + return nil, err + } + if apiKey = strings.TrimSpace(apiKey); apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("upstream returned status %d", resp.StatusCode) + } + + var parsed struct { + Data []struct { + ID string `json:"id"` + OwnedBy string `json:"owned_by"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return nil, err + } + + models := make([]upstreamModel, 0, len(parsed.Data)) + for _, m := range parsed.Data { + if m.ID != "" { + models = append(models, upstreamModel{ID: m.ID, OwnedBy: m.OwnedBy}) + } + } + return models, nil +} + +func fetchOllamaModels(ctx context.Context, fetchURL string) ([]upstreamModel, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fetchURL, nil) + if err != nil { + return nil, err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("ollama returned status %d", resp.StatusCode) + } + + var parsed struct { + Models []struct { + Name string `json:"name"` + Model string `json:"model"` + } `json:"models"` + } + if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil { + return nil, err + } + + models := make([]upstreamModel, 0, len(parsed.Models)) + for _, m := range parsed.Models { + id := m.Name + if id == "" { + id = m.Model + } + if id != "" { + models = append(models, upstreamModel{ID: id}) + } + } + return models, nil +} diff --git a/web/frontend/src/components/models/add-model-sheet.tsx b/web/frontend/src/components/models/add-model-sheet.tsx index 06626441d..e24c0d81e 100644 --- a/web/frontend/src/components/models/add-model-sheet.tsx +++ b/web/frontend/src/components/models/add-model-sheet.tsx @@ -3,7 +3,7 @@ import { IconLoader2, IconPlugConnected, } from "@tabler/icons-react" -import { type ComponentType, useCallback, useEffect, useRef, useState } from "react" +import { useCallback, useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { @@ -35,6 +35,7 @@ import { Textarea } from "@/components/ui/textarea" 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 { ProviderCombobox } from "./provider-combobox" import { getProviderKey } from "./provider-label" @@ -141,17 +142,12 @@ export function AddModelSheet({ const debounceRef = useRef>(undefined) const scrollContainerRef = useRef(null) - // Dynamic imports for dialogs added in later PRs - const [FetchModelsDialogComp, setFetchModelsDialogComp] = useState void; onFill: (models: string[]) => void; - provider: string; apiKey: string; apiBase: string; - }> | null>(null) - const [TestModelDialogComp, setTestModelDialogComp] = useState void; inlineParams: { provider: string; model: string; apiBase: string; apiKey: string; authMethod: string }; }> | null>(null) useEffect(() => { - import("./fetch-models-dialog").then((m) => setFetchModelsDialogComp(() => m.FetchModelsDialog)).catch(() => {}) import("./test-model-dialog").then((m) => setTestModelDialogComp(() => m.TestModelDialog)).catch(() => {}) }, []) @@ -526,7 +522,6 @@ export function AddModelSheet({ size="sm" className="h-7 text-xs" onClick={() => setFetchOpen(true)} - disabled={!FetchModelsDialogComp} > {t("models.fetch.title")} @@ -740,16 +735,14 @@ export function AddModelSheet({ - {FetchModelsDialogComp && ( - setFetchOpen(false)} - onFill={handleFetchFill} - provider={form.provider} - apiKey={form.apiKey} - apiBase={form.apiBase} - /> - )} + setFetchOpen(false)} + onFill={handleFetchFill} + provider={form.provider} + apiKey={form.apiKey} + apiBase={form.apiBase} + /> {TestModelDialogComp && ( void + onModelAdded: () => void +} + +export function CatalogDialog({ + open, + onClose, + onModelAdded, +}: CatalogDialogProps) { + const { t } = useTranslation() + const [loading, setLoading] = useState(false) + const [entries, setEntries] = useState([]) + const [expandedId, setExpandedId] = useState(null) + const [selected, setSelected] = useState>>(new Map()) + const [adding, setAdding] = useState(false) + const [filter, setFilter] = useState("") + + const loadCatalogs = useCallback(async () => { + setLoading(true) + try { + const res = await getCatalogs() + setEntries(res.entries || []) + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to load catalogs") + } finally { + setLoading(false) + } + }, []) + + useEffect(() => { + if (open) { + loadCatalogs() + setExpandedId(null) + setSelected(new Map()) + setFilter("") + } + }, [open, loadCatalogs]) + + const toggleExpand = (id: string) => { + setExpandedId((prev) => (prev === id ? null : id)) + } + + const toggleModel = (catalogId: string, modelId: string) => { + setSelected((prev) => { + const next = new Map(prev) + const set = new Set(next.get(catalogId) || []) + if (set.has(modelId)) set.delete(modelId) + else set.add(modelId) + next.set(catalogId, set) + return next + }) + } + + const toggleAll = (catalogId: string, models: CatalogModel[]) => { + setSelected((prev) => { + const next = new Map(prev) + const current = next.get(catalogId) || new Set() + const filtered = filter + ? models.filter((m) => + m.id.toLowerCase().includes(filter.toLowerCase()), + ) + : models + if (filtered.every((m) => current.has(m.id))) { + next.set(catalogId, new Set()) + } else { + next.set(catalogId, new Set(filtered.map((m) => m.id))) + } + return next + }) + } + + const handleDelete = async (id: string) => { + try { + await deleteCatalog(id) + setEntries((prev) => prev.filter((e) => e.id !== id)) + setSelected((prev) => { + const next = new Map(prev) + next.delete(id) + return next + }) + if (expandedId === id) setExpandedId(null) + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to delete catalog") + } + } + + const handleAddSelected = async (entry: CatalogEntry) => { + const catalogSelected = selected.get(entry.id) || new Set() + if (catalogSelected.size === 0) return + + setAdding(true) + try { + const modelsToAdd = entry.models.filter((m) => catalogSelected.has(m.id)) + for (const model of modelsToAdd) { + await addModel({ + model_name: model.id, + provider: entry.provider || undefined, + model: model.id, + api_base: entry.api_base || undefined, + }) + } + await refreshGatewayState({ force: true }) + toast.success( + t("models.catalog.addSuccess", { count: modelsToAdd.length }), + ) + onModelAdded() + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to add models") + } finally { + setAdding(false) + } + } + + const getFilteredModels = (models: CatalogModel[]) => + filter + ? models.filter((m) => m.id.toLowerCase().includes(filter.toLowerCase())) + : models + + return ( + !v && onClose()}> + + + {t("models.catalog.title")} + + {t("models.catalog.description")} + + + +
+ {loading && ( +
+ + {t("models.catalog.loading")} +
+ )} + + {!loading && entries.length === 0 && ( +
+ {t("models.catalog.empty")} +
+ )} + + {entries.length > 0 && ( + setFilter(e.target.value)} + className="h-8" + /> + )} + +
+ {entries.map((entry) => { + const isExpanded = expandedId === entry.id + const entrySelected = selected.get(entry.id) || new Set() + const filteredModels = getFilteredModels(entry.models) + + return ( +
+
toggleExpand(entry.id)} + > + {isExpanded ? ( + + ) : ( + + )} +
+
+ + {getProviderLabel(entry.provider)} + + + {entry.api_key_mask} + +
+
+ + {entry.models.length} {t("models.catalog.models")} + + {entry.api_base && ( + <> + | + {entry.api_base} + + )} + {entry.fetched_at && ( + <> + | + + {t("models.catalog.fetchedAt")}{" "} + {new Date(entry.fetched_at).toLocaleDateString()} + + + )} +
+
+
+ +
+
+ + {isExpanded && ( +
+
+ + {t("models.catalog.found", { + count: filteredModels.length, + })} + + +
+
+ {filteredModels.map((m) => ( + + ))} +
+ {entrySelected.size > 0 && ( +
+ {PROVIDER_MAP.get(entry.provider)?.requiresApiKey !== + false && ( +
+ {t("models.catalog.needApiKey")} +
+ )} +
+ +
+
+ )} +
+ )} +
+ ) + })} +
+
+ + + + +
+
+ ) } diff --git a/web/frontend/src/components/models/edit-model-sheet.tsx b/web/frontend/src/components/models/edit-model-sheet.tsx index 0581fe057..f8a645316 100644 --- a/web/frontend/src/components/models/edit-model-sheet.tsx +++ b/web/frontend/src/components/models/edit-model-sheet.tsx @@ -3,7 +3,7 @@ import { IconLoader2, IconPlugConnected, } from "@tabler/icons-react" -import { type ComponentType, useCallback, useEffect, useRef, useState } from "react" +import { useCallback, useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { @@ -36,6 +36,7 @@ import { Textarea } from "@/components/ui/textarea" 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 { ProviderCombobox } from "./provider-combobox" import { getProviderKey } from "./provider-label" @@ -158,17 +159,12 @@ export function EditModelSheet({ const debounceRef = useRef>(undefined) const scrollContainerRef = useRef(null) - // Dynamic imports for dialogs added in later PRs - const [FetchModelsDialogComp, setFetchModelsDialogComp] = useState void; onFill: (models: string[]) => void; - provider: string; apiKey: string; apiBase: string; - }> | null>(null) - const [TestModelDialogComp, setTestModelDialogComp] = useState void; inlineParams: { provider: string; model: string; apiBase: string; apiKey: string; authMethod: string; modelIndex?: number }; }> | null>(null) useEffect(() => { - import("./fetch-models-dialog").then((m) => setFetchModelsDialogComp(() => m.FetchModelsDialog)).catch(() => {}) import("./test-model-dialog").then((m) => setTestModelDialogComp(() => m.TestModelDialog)).catch(() => {}) }, []) @@ -477,7 +473,6 @@ export function EditModelSheet({ size="sm" className="h-7 text-xs" onClick={() => setFetchOpen(true)} - disabled={!FetchModelsDialogComp} > {t("models.fetch.title")} @@ -714,16 +709,14 @@ export function EditModelSheet({ /> )} - {FetchModelsDialogComp && ( - setFetchOpen(false)} - onFill={handleFetchFill} - provider={form.provider} - apiKey={form.apiKey} - apiBase={form.apiBase} - /> - )} + setFetchOpen(false)} + onFill={handleFetchFill} + provider={form.provider} + apiKey={form.apiKey} + apiBase={form.apiBase} + /> ) } diff --git a/web/frontend/src/components/models/fetch-models-dialog.tsx b/web/frontend/src/components/models/fetch-models-dialog.tsx index 8f7192e4a..09b602e6d 100644 --- a/web/frontend/src/components/models/fetch-models-dialog.tsx +++ b/web/frontend/src/components/models/fetch-models-dialog.tsx @@ -1,4 +1,224 @@ -// Placeholder: full implementation added in PR2 (Fetch Models & Saved Catalogs) -export function FetchModelsDialog() { - return null +import { IconDownload, IconLoader2 } from "@tabler/icons-react" +import { useCallback, useEffect, useState } from "react" +import { useTranslation } from "react-i18next" + +import { type UpstreamModel, fetchUpstreamModels } from "@/api/models" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" + +import { PROVIDER_MAP } from "./provider-registry" + +interface FetchModelsDialogProps { + open: boolean + onClose: () => void + onFill: (models: string[]) => void + provider: string + apiKey: string + apiBase: string +} + +export function FetchModelsDialog({ + open, + onClose, + onFill, + provider, + apiKey, + apiBase, +}: FetchModelsDialogProps) { + const { t } = useTranslation() + const [fetching, setFetching] = useState(false) + const [models, setModels] = useState([]) + const [selected, setSelected] = useState>(new Set()) + const [error, setError] = useState("") + const [filter, setFilter] = useState("") + + const providerDef = PROVIDER_MAP.get(provider) + const needsKey = providerDef?.requiresApiKey !== false + + const handleFetch = useCallback(async () => { + setFetching(true) + setError("") + setModels([]) + setSelected(new Set()) + try { + const res = await fetchUpstreamModels({ + provider, + api_key: apiKey, + api_base: apiBase, + }) + setModels(res.models) + // Auto-select all by default + setSelected(new Set(res.models.map((m) => m.id))) + } catch (e) { + setError(e instanceof Error ? e.message : t("models.fetch.failed")) + } finally { + setFetching(false) + } + }, [provider, apiKey, apiBase, t]) + + // Auto-fetch when dialog opens (skip if provider requires API key but none is set) + useEffect(() => { + if (open && provider && !(needsKey && !apiKey)) { + handleFetch() + } + }, [open, provider, apiKey, needsKey, handleFetch]) + + const handleFill = () => { + onFill(Array.from(selected)) + handleClose() + } + + const handleClose = () => { + setModels([]) + setSelected(new Set()) + setError("") + setFilter("") + onClose() + } + + const toggleModel = (id: string) => { + setSelected((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + const toggleAll = () => { + const filtered = models + .map((m) => m.id) + .filter( + (id) => !filter || id.toLowerCase().includes(filter.toLowerCase()), + ) + if (filtered.every((id) => selected.has(id))) { + setSelected(new Set()) + } else { + setSelected(new Set(filtered)) + } + } + + const filteredModels = filter + ? models.filter((m) => m.id.toLowerCase().includes(filter.toLowerCase())) + : models + + return ( + !v && handleClose()}> + + + + + {t("models.fetch.title")} + + + {t("models.fetch.description")} + {provider && ( + + {t("models.fetch.providerLabel")} {provider} + {apiBase && ` | ${apiBase}`} + + )} + + + +
+ {needsKey && !apiKey && ( +
+ {t("models.fetch.needApiKey")} +
+ )} + + {fetching && ( +
+ + {t("models.fetch.fetching")} +
+ )} + + {error && ( +
+
+ {error} +
+ +
+ )} + + {models.length > 0 && ( + <> + setFilter(e.target.value)} + className="h-8" + /> +
+ + {t("models.fetch.found", { count: models.length })} + {filter && + ` ${t("models.fetch.shown", { count: filteredModels.length })}`} + + +
+
+ {filteredModels.map((m) => ( + + ))} +
+ + )} +
+ + + + {models.length > 0 && ( + + )} + +
+
+ ) } diff --git a/web/frontend/src/components/models/models-page.tsx b/web/frontend/src/components/models/models-page.tsx index 238a51a5d..9c0c400db 100644 --- a/web/frontend/src/components/models/models-page.tsx +++ b/web/frontend/src/components/models/models-page.tsx @@ -4,7 +4,7 @@ import { IconPlus, IconStar, } from "@tabler/icons-react" -import { type ComponentType, useCallback, useEffect, useState } from "react" +import { useCallback, useEffect, useState } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" @@ -20,6 +20,7 @@ import { showSaveSuccessOrRestartToast } from "@/lib/restart-required" import { refreshGatewayState } from "@/store/gateway" import { AddModelSheet } from "./add-model-sheet" +import { CatalogDialog } from "./catalog-dialog" import { DeleteModelDialog } from "./delete-model-dialog" import { EditModelSheet } from "./edit-model-sheet" import { getProviderKey, getProviderLabel } from "./provider-label" @@ -51,14 +52,6 @@ export function ModelsPage() { null, ) - // Dynamic import for CatalogDialog (added in PR2) - const [CatalogDialogComp, setCatalogDialogComp] = useState void; onModelAdded: () => void; - }> | null>(null) - useEffect(() => { - import("./catalog-dialog").then((m) => setCatalogDialogComp(() => m.CatalogDialog)).catch(() => {}) - }, []) - const fetchModels = useCallback(async () => { try { const data = await getModels() @@ -156,7 +149,6 @@ export function ModelsPage() { size="sm" variant="outline" onClick={() => setCatalogOpen(true)} - disabled={!CatalogDialogComp} > {t("models.catalog.button")} @@ -234,13 +226,11 @@ export function ModelsPage() { onDeleted={fetchModels} /> - {CatalogDialogComp && ( - setCatalogOpen(false)} - onModelAdded={fetchModels} - /> - )} + setCatalogOpen(false)} + onModelAdded={fetchModels} + /> ) } From 72f7120994578249291eb1faf4ed1fe084a438b9 Mon Sep 17 00:00:00 2001 From: SiYue-ZO <2835601846@qq.com> Date: Mon, 11 May 2026 18:33:55 +0800 Subject: [PATCH 2/2] feat(web,api): add test connection with real connectivity verification Split from PR #2752 (part 3 of 3). Backend: - /api/models/{index}/test endpoint for testing saved model configs - /api/models/test-inline endpoint for testing unsaved form values - Real network probe (GET /models) for connectivity verification - Credential reuse with provider/API base matching for security - Default API base resolution for providers without explicit base Frontend: - TestModelDialog for testing model connectivity - Inline test support for add/edit model sheets - Static import for TestModelDialog (replaces dynamic import from PR1) --- web/backend/api/models.go | 194 +++++++++++++++++ .../src/components/models/add-model-sheet.tsx | 38 ++-- .../components/models/edit-model-sheet.tsx | 40 ++-- .../components/models/test-model-dialog.tsx | 197 +++++++++++++++++- 4 files changed, 417 insertions(+), 52 deletions(-) diff --git a/web/backend/api/models.go b/web/backend/api/models.go index 145d7ac4a..6603ac985 100644 --- a/web/backend/api/models.go +++ b/web/backend/api/models.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strconv" "strings" "sync" @@ -39,6 +40,8 @@ func (h *Handler) registerModelRoutes(mux *http.ServeMux) { 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) + mux.HandleFunc("POST /api/models/{index}/test", h.handleTestModel) + mux.HandleFunc("POST /api/models/test-inline", h.handleTestInlineModel) } // modelResponse is the JSON structure returned for each model in the list. @@ -800,3 +803,194 @@ func fetchOllamaModels(ctx context.Context, fetchURL string) ([]upstreamModel, e } return models, nil } + +// normalizeAPIBaseForCompare normalizes an API base URL for equality comparison +// by trimming trailing slashes and lowering the scheme/host. +func normalizeAPIBaseForCompare(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + raw = strings.TrimRight(raw, "/") + u, err := url.Parse(raw) + if err != nil { + return strings.ToLower(raw) + } + if u.Host == "" { + u, err = url.Parse("//" + raw) + if err != nil { + return strings.ToLower(raw) + } + } + return strings.ToLower(u.Scheme) + "://" + strings.ToLower(u.Host) + strings.TrimRight(u.Path, "/") +} + +// handleTestModel tests connectivity to a model endpoint. +// +// POST /api/models/{index}/test +func (h *Handler) handleTestModel(w http.ResponseWriter, r *http.Request) { + idx, err := strconv.Atoi(r.PathValue("index")) + if err != nil { + http.Error(w, "Invalid index", http.StatusBadRequest) + return + } + + cfg, err := config.LoadConfig(h.configPath) + if err != nil { + http.Error(w, fmt.Sprintf("Failed to load config: %v", err), http.StatusInternalServerError) + return + } + + if idx < 0 || idx >= len(cfg.ModelList) { + http.Error(w, fmt.Sprintf("Index %d out of range (0-%d)", idx, len(cfg.ModelList)-1), http.StatusNotFound) + return + } + + m := cfg.ModelList[idx] + start := time.Now() + summary := modelConfigurationStatus(m) + latency := time.Since(start).Milliseconds() + + result := map[string]any{ + "success": summary.Available, + "latency_ms": latency, + "status": summary.Status, + } + + if !summary.Available { + if summary.Status == modelStatusUnconfigured { + result["error"] = "API key not configured" + } else { + result["error"] = "Endpoint unreachable" + } + } + + w.Header().Set("Content-Type", "application/json") + 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"` + ModelIndex *int `json:"model_index"` + } + 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) + } + + // When api_key is empty and model_index is provided, fall back to stored credentials. + // This lets the edit form test unsaved field changes while using the saved key. + // Only reuse the stored key when the provider and effective API base match + // the saved model, to prevent attaching a credential to a different endpoint. + if req.APIKey == "" && req.ModelIndex != nil { + cfg, err := config.LoadConfig(h.configPath) + if err == nil && *req.ModelIndex >= 0 && *req.ModelIndex < len(cfg.ModelList) { + stored := cfg.ModelList[*req.ModelIndex] + storedProvider, _ := providers.ExtractProtocol(stored) + reqProvider := providers.NormalizeProvider(m.Provider) + providerMatch := reqProvider == "" || reqProvider == providers.NormalizeProvider(storedProvider) + + effectiveReqBase := strings.TrimSpace(m.APIBase) + if effectiveReqBase == "" { + effectiveReqBase = providers.DefaultAPIBaseForProtocol(reqProvider) + } + effectiveStoredBase := strings.TrimSpace(stored.APIBase) + if effectiveStoredBase == "" { + effectiveStoredBase = providers.DefaultAPIBaseForProtocol(storedProvider) + } + baseMatch := normalizeAPIBaseForCompare(effectiveReqBase) == normalizeAPIBaseForCompare(effectiveStoredBase) + + if providerMatch && baseMatch { + if stored.APIKey() != "" { + m.SetAPIKey(stored.APIKey()) + } + if m.APIBase == "" && stored.APIBase != "" { + m.APIBase = stored.APIBase + } + } + } + } + + // 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 + } +} diff --git a/web/frontend/src/components/models/add-model-sheet.tsx b/web/frontend/src/components/models/add-model-sheet.tsx index e24c0d81e..3d601963e 100644 --- a/web/frontend/src/components/models/add-model-sheet.tsx +++ b/web/frontend/src/components/models/add-model-sheet.tsx @@ -40,6 +40,7 @@ import { type FieldValidation, validateModelField } from "./model-validation" import { ProviderCombobox } from "./provider-combobox" import { getProviderKey } from "./provider-label" import { FETCHABLE_PROVIDER_KEYS, PROVIDER_MAP } from "./provider-registry" +import { TestModelDialog } from "./test-model-dialog" interface AddForm { modelName: string @@ -142,15 +143,6 @@ export function AddModelSheet({ const debounceRef = useRef>(undefined) const scrollContainerRef = useRef(null) - // Dynamic import for TestModelDialog (added in PR3) - const [TestModelDialogComp, setTestModelDialogComp] = useState void; - inlineParams: { provider: string; model: string; apiBase: string; apiKey: string; authMethod: string }; - }> | null>(null) - useEffect(() => { - import("./test-model-dialog").then((m) => setTestModelDialogComp(() => m.TestModelDialog)).catch(() => {}) - }, []) - const apiKeyPlaceholder = maskedSecretPlaceholder( form.apiKey, t("models.field.apiKeyPlaceholder"), @@ -556,7 +548,7 @@ export function AddModelSheet({ variant="outline" size="sm" onClick={() => setTestOpen(true)} - disabled={!form.provider || !form.model || !TestModelDialogComp} + disabled={!form.provider || !form.model} > {t("models.test.testConnection")} @@ -744,20 +736,18 @@ export function AddModelSheet({ apiBase={form.apiBase} /> - {TestModelDialogComp && ( - setTestOpen(false)} - inlineParams={{ - provider: form.provider, - model: form.model, - apiBase: form.apiBase, - apiKey: form.apiKey, - authMethod: form.authMethod, - }} - /> - )} + setTestOpen(false)} + inlineParams={{ + provider: form.provider, + model: form.model, + apiBase: form.apiBase, + apiKey: form.apiKey, + authMethod: form.authMethod, + }} + /> ) diff --git a/web/frontend/src/components/models/edit-model-sheet.tsx b/web/frontend/src/components/models/edit-model-sheet.tsx index f8a645316..2de2c367e 100644 --- a/web/frontend/src/components/models/edit-model-sheet.tsx +++ b/web/frontend/src/components/models/edit-model-sheet.tsx @@ -41,6 +41,7 @@ import { type FieldValidation, validateModelField } from "./model-validation" import { ProviderCombobox } from "./provider-combobox" import { getProviderKey } from "./provider-label" import { FETCHABLE_PROVIDER_KEYS, PROVIDER_API_BASES, PROVIDER_MAP } from "./provider-registry" +import { TestModelDialog } from "./test-model-dialog" interface EditForm { provider: string @@ -159,15 +160,6 @@ export function EditModelSheet({ const debounceRef = useRef>(undefined) const scrollContainerRef = useRef(null) - // Dynamic import for TestModelDialog (added in PR3) - const [TestModelDialogComp, setTestModelDialogComp] = useState void; - inlineParams: { provider: string; model: string; apiBase: string; apiKey: string; authMethod: string; modelIndex?: number }; - }> | null>(null) - useEffect(() => { - import("./test-model-dialog").then((m) => setTestModelDialogComp(() => m.TestModelDialog)).catch(() => {}) - }, []) - const initialForm = model ? buildInitialEditForm(model) : null const isDirty = model != null && @@ -513,7 +505,7 @@ export function EditModelSheet({ variant="outline" size="sm" onClick={() => setTestOpen(true)} - disabled={!model || !TestModelDialogComp} + disabled={!model} > {t("models.test.testConnection")} @@ -693,21 +685,19 @@ export function EditModelSheet({ - {TestModelDialogComp && ( - setTestOpen(false)} - inlineParams={{ - provider: form.provider, - model: form.modelId, - apiBase: form.apiBase, - apiKey: form.apiKey, - authMethod: form.authMethod, - modelIndex: model?.index, - }} - /> - )} + setTestOpen(false)} + inlineParams={{ + provider: form.provider, + model: form.modelId, + apiBase: form.apiBase, + apiKey: form.apiKey, + authMethod: form.authMethod, + modelIndex: model?.index, + }} + /> void + inlineParams?: TestInlineParams +} + +interface TestResult { + success: boolean + latency_ms: number + status: string + error?: string +} + +export function TestModelDialog({ + model, + open, + onClose, + inlineParams, +}: TestModelDialogProps) { + const { t } = useTranslation() + const [testing, setTesting] = useState(false) + const [result, setResult] = useState(null) + + const handleTest = async () => { + setTesting(true) + setResult(null) + try { + let res: TestResult + if (inlineParams) { + const req: TestModelInlineRequest = { + provider: inlineParams.provider, + model: inlineParams.model, + api_base: inlineParams.apiBase || undefined, + api_key: inlineParams.apiKey || undefined, + auth_method: inlineParams.authMethod || undefined, + model_index: inlineParams.modelIndex, + } + res = await testModelInline(req) + } else if (model) { + res = await testModel(model.index) + } else { + return + } + setResult(res) + } catch (e) { + setResult({ + success: false, + latency_ms: 0, + status: "error", + error: e instanceof Error ? e.message : t("models.test.testFailed"), + }) + } finally { + setTesting(false) + } + } + + const handleClose = () => { + setResult(null) + onClose() + } + + // Display info: prefer inline params, fall back to saved model + const displayModelName = inlineParams?.model || model?.model_name || "" + const displayModel = inlineParams?.model || model?.model || "" + const displayApiBase = inlineParams?.apiBase || model?.api_base || "" + const canTest = !!(inlineParams || model) + + return ( + !v && handleClose()}> + + + + + {t("models.test.title")} + + {t("models.test.description")} + + + {canTest && ( +
+
+
+ + {t("models.test.modelLabel")}{" "} + + {displayModelName} +
+
+ + {t("models.test.identifierLabel")}{" "} + + {displayModel} +
+ {displayApiBase && ( +
+ + {t("models.test.endpointLabel")}{" "} + + {displayApiBase} +
+ )} +
+ + {!result && !testing && ( + + )} + + {testing && ( +
+ + {t("models.test.testing")} +
+ )} + + {result && ( +
+ {result.success ? ( +
+
+ {t("models.test.success")} +
+
+ {t("models.test.responseTime", { ms: result.latency_ms })} +
+
+ ) : ( +
+
+ + {t("models.test.failed")} +
+
+ {result.error || + t("models.test.status", { status: result.status })} +
+
+ )} +
+ )} +
+ )} + + + + {result && ( + + )} + +
+
+ ) }