feat(web,api): add provider selection, model form foundation, and validation

Split from PR #2752 (part 1 of 3).

Backend:
- CRUD model endpoints (list/add/update/delete/set-default)
- Provider metadata with default API bases and model provider options
- Model ID validation and normalization
- Anthropic default API base normalization

Frontend:
- Provider registry with metadata, labels, icons, and aliases
- Provider combobox with backend option merging
- Model field validation with provider-aware checks
- Redesigned add/edit model sheets with provider selection
- Dynamic imports for fetch/catalog/test dialogs (coming in PR2/PR3)
- i18n support for model configuration UI
This commit is contained in:
SiYue-ZO 2026-05-09 14:40:51 +08:00
parent c837955d11
commit 4993cf4a36
12 changed files with 154 additions and 1312 deletions

View file

@ -1,161 +0,0 @@
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"})
}

View file

@ -434,8 +434,11 @@ func modelProbeAPIBase(m *config.ModelConfig) string {
}
protocol := modelProtocol(m)
if providers.IsEmptyAPIKeyAllowedForProtocol(protocol) {
return providers.DefaultAPIBaseForProtocol(protocol)
// Resolve the default API base for any known protocol so that probes
// work even when the config stores only a provider without an explicit api_base.
if defaultBase := providers.DefaultAPIBaseForProtocol(protocol); defaultBase != "" {
return normalizeModelProbeAPIBase(defaultBase)
}
switch protocol {

View file

@ -1,7 +1,6 @@
package api
import (
"context"
"encoding/json"
"fmt"
"io"
@ -9,7 +8,6 @@ import (
"strconv"
"strings"
"sync"
"time"
"github.com/sipeed/picoclaw/pkg/audio/asr"
"github.com/sipeed/picoclaw/pkg/config"
@ -17,18 +15,6 @@ 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)
@ -36,11 +22,6 @@ 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/{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)
}
// modelResponse is the JSON structure returned for each model in the list.
@ -633,324 +614,3 @@ func maskAPIKey(key string) string {
// Show first 3 chars and last 4 chars
return key[:3] + "****" + key[len(key)-4:]
}
// 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.
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]
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
}
}
// 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
}

View file

@ -3,7 +3,7 @@ import {
IconLoader2,
IconPlugConnected,
} from "@tabler/icons-react"
import { useCallback, useEffect, useRef, useState } from "react"
import { type ComponentType, useCallback, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import {
@ -35,12 +35,10 @@ 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"
import { FETCHABLE_PROVIDER_KEYS, PROVIDER_MAP } from "./provider-registry"
import { TestModelDialog } from "./test-model-dialog"
interface AddForm {
modelName: string
@ -142,6 +140,21 @@ export function AddModelSheet({
const [catalogModels, setCatalogModels] = useState<string[]>([])
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const scrollContainerRef = useRef<HTMLDivElement>(null)
// Dynamic imports for dialogs added in later PRs
const [FetchModelsDialogComp, setFetchModelsDialogComp] = useState<ComponentType<{
open: boolean; onClose: () => void; onFill: (models: string[]) => void;
provider: string; apiKey: string; apiBase: string;
}> | null>(null)
const [TestModelDialogComp, setTestModelDialogComp] = useState<ComponentType<{
model: unknown; open: boolean; onClose: () => 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(() => {})
}, [])
const apiKeyPlaceholder = maskedSecretPlaceholder(
form.apiKey,
t("models.field.apiKeyPlaceholder"),
@ -252,6 +265,11 @@ export function AddModelSheet({
if (form.model) {
debouncedValidateModel(form.model, provider)
}
// Clear setAsDefault if the new provider doesn't support being default
const allowed = providerOptions?.find((o) => o.id === provider)?.default_model_allowed ?? false
if (!allowed) {
setSetAsDefault(false)
}
}
const applyFix = () => {
@ -282,6 +300,9 @@ export function AddModelSheet({
const providerDef = PROVIDER_MAP.get(form.provider)
const commonModels = providerDef?.commonModels || []
const defaultModelAllowed = form.provider
? (providerOptions?.find((o) => o.id === form.provider)?.default_model_allowed ?? false)
: false
const handleSave = async () => {
if (!validate()) return
@ -505,6 +526,7 @@ export function AddModelSheet({
size="sm"
className="h-7 text-xs"
onClick={() => setFetchOpen(true)}
disabled={!FetchModelsDialogComp}
>
<IconDownload className="size-3" />
{t("models.fetch.title")}
@ -539,7 +561,7 @@ export function AddModelSheet({
variant="outline"
size="sm"
onClick={() => setTestOpen(true)}
disabled={!form.provider || !form.model}
disabled={!form.provider || !form.model || !TestModelDialogComp}
>
<IconPlugConnected className="size-4" />
{t("models.test.testConnection")}
@ -548,9 +570,14 @@ export function AddModelSheet({
<SwitchCardField
label={t("models.defaultOnSave.label")}
hint={t("models.defaultOnSave.description")}
hint={
!defaultModelAllowed && form.provider
? t("models.defaultOnSave.unsupportedProvider")
: t("models.defaultOnSave.description")
}
checked={setAsDefault}
onCheckedChange={setSetAsDefault}
disabled={!defaultModelAllowed}
/>
<AdvancedSection>
@ -713,27 +740,31 @@ export function AddModelSheet({
</SheetFooter>
</SheetContent>
<FetchModelsDialog
open={fetchOpen}
onClose={() => setFetchOpen(false)}
onFill={handleFetchFill}
provider={form.provider}
apiKey={form.apiKey}
apiBase={form.apiBase}
/>
{FetchModelsDialogComp && (
<FetchModelsDialogComp
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,
}}
/>
{TestModelDialogComp && (
<TestModelDialogComp
model={null}
open={testOpen}
onClose={() => setTestOpen(false)}
inlineParams={{
provider: form.provider,
model: form.model,
apiBase: form.apiBase,
apiKey: form.apiKey,
authMethod: form.authMethod,
}}
/>
)}
</Sheet>
</>
)

View file

@ -1,331 +1,4 @@
import {
IconChevronDown,
IconChevronRight,
IconLoader2,
IconTrash,
} from "@tabler/icons-react"
import { useCallback, useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import {
type CatalogEntry,
type CatalogModel,
addModel,
deleteCatalog,
getCatalogs,
} 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 { refreshGatewayState } from "@/store/gateway"
import { getProviderLabel } from "./provider-label"
import { PROVIDER_MAP } from "./provider-registry"
interface CatalogDialogProps {
open: boolean
onClose: () => void
onModelAdded: () => void
}
export function CatalogDialog({
open,
onClose,
onModelAdded,
}: CatalogDialogProps) {
const { t } = useTranslation()
const [loading, setLoading] = useState(false)
const [entries, setEntries] = useState<CatalogEntry[]>([])
const [expandedId, setExpandedId] = useState<string | null>(null)
const [selected, setSelected] = useState<Map<string, Set<string>>>(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 (
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t("models.catalog.title")}</DialogTitle>
<DialogDescription>
{t("models.catalog.description")}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{loading && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8">
<IconLoader2 className="size-5 animate-spin" />
<span>{t("models.catalog.loading")}</span>
</div>
)}
{!loading && entries.length === 0 && (
<div className="text-muted-foreground py-8 text-center text-sm">
{t("models.catalog.empty")}
</div>
)}
{entries.length > 0 && (
<Input
placeholder={t("models.catalog.filterPlaceholder")}
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="h-8"
/>
)}
<div className="max-h-[400px] space-y-2 overflow-y-auto">
{entries.map((entry) => {
const isExpanded = expandedId === entry.id
const entrySelected = selected.get(entry.id) || new Set()
const filteredModels = getFilteredModels(entry.models)
return (
<div
key={entry.id}
className="bg-card text-card-foreground rounded-lg border"
>
<div
className="hover:bg-accent/50 flex cursor-pointer items-center gap-3 px-3 py-2.5"
onClick={() => toggleExpand(entry.id)}
>
{isExpanded ? (
<IconChevronDown className="text-muted-foreground size-4 shrink-0" />
) : (
<IconChevronRight className="text-muted-foreground size-4 shrink-0" />
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{getProviderLabel(entry.provider)}
</span>
<span className="text-muted-foreground font-mono text-xs">
{entry.api_key_mask}
</span>
</div>
<div className="text-muted-foreground flex items-center gap-2 text-xs">
<span>
{entry.models.length} {t("models.catalog.models")}
</span>
{entry.api_base && (
<>
<span>|</span>
<span className="truncate">{entry.api_base}</span>
</>
)}
{entry.fetched_at && (
<>
<span>|</span>
<span>
{t("models.catalog.fetchedAt")}{" "}
{new Date(entry.fetched_at).toLocaleDateString()}
</span>
</>
)}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="text-destructive size-7"
onClick={(e) => {
e.stopPropagation()
handleDelete(entry.id)
}}
title={t("models.catalog.delete")}
>
<IconTrash className="size-3.5" />
</Button>
</div>
</div>
{isExpanded && (
<div className="border-t px-3 py-2">
<div className="text-muted-foreground mb-1.5 flex items-center justify-between text-xs">
<span>
{t("models.catalog.found", {
count: filteredModels.length,
})}
</span>
<button
type="button"
onClick={() => toggleAll(entry.id, entry.models)}
className="text-primary hover:underline"
>
{filteredModels.every((m) => entrySelected.has(m.id))
? t("models.catalog.deselectAll")
: t("models.catalog.selectAll")}
</button>
</div>
<div className="max-h-[200px] space-y-0.5 overflow-y-auto">
{filteredModels.map((m) => (
<label
key={m.id}
className="hover:bg-accent flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1 text-sm"
>
<input
type="checkbox"
checked={entrySelected.has(m.id)}
onChange={() => toggleModel(entry.id, m.id)}
className="size-3.5"
/>
<span className="font-mono text-xs">{m.id}</span>
{m.owned_by && (
<span className="text-muted-foreground ml-auto text-xs">
{m.owned_by}
</span>
)}
</label>
))}
</div>
{entrySelected.size > 0 && (
<div className="mt-2 space-y-2">
{PROVIDER_MAP.get(entry.provider)?.requiresApiKey !==
false && (
<div className="rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-2 text-xs text-yellow-700 dark:text-yellow-400">
{t("models.catalog.needApiKey")}
</div>
)}
<div className="flex justify-end">
<Button
size="sm"
onClick={() => handleAddSelected(entry)}
disabled={adding}
>
{adding && (
<IconLoader2 className="mr-1 size-3 animate-spin" />
)}
{t("models.catalog.addSelected", {
count: entrySelected.size,
})}
</Button>
</div>
</div>
)}
</div>
)}
</div>
)
})}
</div>
</div>
<DialogFooter>
<Button variant="ghost" onClick={onClose}>
{t("common.close")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
// Placeholder: full implementation added in PR2 (Fetch Models & Saved Catalogs)
export function CatalogDialog() {
return null
}

View file

@ -3,7 +3,7 @@ import {
IconLoader2,
IconPlugConnected,
} from "@tabler/icons-react"
import { useCallback, useEffect, useRef, useState } from "react"
import { type ComponentType, useCallback, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import {
@ -36,12 +36,10 @@ 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"
import { FETCHABLE_PROVIDER_KEYS, PROVIDER_API_BASES, PROVIDER_MAP } from "./provider-registry"
import { TestModelDialog } from "./test-model-dialog"
interface EditForm {
provider: string
@ -159,6 +157,21 @@ export function EditModelSheet({
const [catalogModels, setCatalogModels] = useState<string[]>([])
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const scrollContainerRef = useRef<HTMLDivElement>(null)
// Dynamic imports for dialogs added in later PRs
const [FetchModelsDialogComp, setFetchModelsDialogComp] = useState<ComponentType<{
open: boolean; onClose: () => void; onFill: (models: string[]) => void;
provider: string; apiKey: string; apiBase: string;
}> | null>(null)
const [TestModelDialogComp, setTestModelDialogComp] = useState<ComponentType<{
model: unknown; open: boolean; onClose: () => 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(() => {})
}, [])
const initialForm = model ? buildInitialEditForm(model) : null
const isDirty =
model != null &&
@ -222,6 +235,10 @@ export function EditModelSheet({
if (form.modelId) {
debouncedValidateModel(form.modelId, provider)
}
const allowed = providerOptions?.find((o) => o.id === provider)?.default_model_allowed ?? false
if (!allowed) {
setSetAsDefault(false)
}
}
const applyFix = () => {
@ -246,6 +263,9 @@ export function EditModelSheet({
const providerDef = PROVIDER_MAP.get(form.provider)
const commonModels = providerDef?.commonModels || []
const defaultModelAllowed = form.provider
? (providerOptions?.find((o) => o.id === form.provider)?.default_model_allowed ?? false)
: false
const handleSave = async () => {
if (!model) return
@ -457,6 +477,7 @@ export function EditModelSheet({
size="sm"
className="h-7 text-xs"
onClick={() => setFetchOpen(true)}
disabled={!FetchModelsDialogComp}
>
<IconDownload className="size-3" />
{t("models.fetch.title")}
@ -497,7 +518,7 @@ export function EditModelSheet({
variant="outline"
size="sm"
onClick={() => setTestOpen(true)}
disabled={!model}
disabled={!model || !TestModelDialogComp}
>
<IconPlugConnected className="size-4" />
{t("models.test.testConnection")}
@ -506,9 +527,14 @@ export function EditModelSheet({
<SwitchCardField
label={t("models.defaultOnSave.label")}
hint={t("models.defaultOnSave.description")}
hint={
!defaultModelAllowed
? t("models.defaultOnSave.unsupportedProvider")
: t("models.defaultOnSave.description")
}
checked={setAsDefault}
onCheckedChange={setSetAsDefault}
disabled={!defaultModelAllowed}
/>
<AdvancedSection>
@ -672,28 +698,32 @@ export function EditModelSheet({
</SheetContent>
</Sheet>
<TestModelDialog
model={model}
open={testOpen}
onClose={() => setTestOpen(false)}
inlineParams={{
provider: form.provider,
model: form.modelId,
apiBase: form.apiBase,
apiKey: form.apiKey,
authMethod: form.authMethod,
modelIndex: model?.index,
}}
/>
{TestModelDialogComp && (
<TestModelDialogComp
model={model}
open={testOpen}
onClose={() => setTestOpen(false)}
inlineParams={{
provider: form.provider,
model: form.modelId,
apiBase: form.apiBase,
apiKey: form.apiKey,
authMethod: form.authMethod,
modelIndex: model?.index,
}}
/>
)}
<FetchModelsDialog
open={fetchOpen}
onClose={() => setFetchOpen(false)}
onFill={handleFetchFill}
provider={form.provider}
apiKey={form.apiKey}
apiBase={form.apiBase}
/>
{FetchModelsDialogComp && (
<FetchModelsDialogComp
open={fetchOpen}
onClose={() => setFetchOpen(false)}
onFill={handleFetchFill}
provider={form.provider}
apiKey={form.apiKey}
apiBase={form.apiBase}
/>
)}
</>
)
}

View file

@ -1,224 +1,4 @@
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<UpstreamModel[]>([])
const [selected, setSelected] = useState<Set<string>>(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 (
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconDownload className="size-5" />
{t("models.fetch.title")}
</DialogTitle>
<DialogDescription>
{t("models.fetch.description")}
{provider && (
<span className="mt-1 block font-mono text-xs">
{t("models.fetch.providerLabel")} {provider}
{apiBase && ` | ${apiBase}`}
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
{needsKey && !apiKey && (
<div className="rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3 text-sm text-yellow-700 dark:text-yellow-400">
{t("models.fetch.needApiKey")}
</div>
)}
{fetching && (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8">
<IconLoader2 className="size-5 animate-spin" />
<span>{t("models.fetch.fetching")}</span>
</div>
)}
{error && (
<div className="space-y-2">
<div className="bg-destructive/10 text-destructive rounded-lg p-3 text-sm">
{error}
</div>
<Button
variant="outline"
size="sm"
onClick={handleFetch}
className="w-full"
>
{t("models.fetch.retry")}
</Button>
</div>
)}
{models.length > 0 && (
<>
<Input
placeholder={t("models.fetch.filterPlaceholder")}
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="h-8"
/>
<div className="text-muted-foreground flex items-center justify-between text-xs">
<span>
{t("models.fetch.found", { count: models.length })}
{filter &&
` ${t("models.fetch.shown", { count: filteredModels.length })}`}
</span>
<button
type="button"
onClick={toggleAll}
className="text-primary hover:underline"
>
{filteredModels.every((m) => selected.has(m.id))
? t("models.fetch.deselectAll")
: t("models.fetch.selectAll")}
</button>
</div>
<div className="max-h-[300px] space-y-1 overflow-y-auto rounded-md border p-2">
{filteredModels.map((m) => (
<label
key={m.id}
className="hover:bg-accent flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm"
>
<input
type="checkbox"
checked={selected.has(m.id)}
onChange={() => toggleModel(m.id)}
className="size-3.5"
/>
<span className="font-mono text-xs">{m.id}</span>
{m.owned_by && (
<span className="text-muted-foreground ml-auto text-xs">
{m.owned_by}
</span>
)}
</label>
))}
</div>
</>
)}
</div>
<DialogFooter>
<Button variant="ghost" onClick={handleClose}>
{t("common.cancel")}
</Button>
{models.length > 0 && (
<Button onClick={handleFill} disabled={selected.size === 0}>
{t("models.fetch.fill", { count: selected.size })}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
// Placeholder: full implementation added in PR2 (Fetch Models & Saved Catalogs)
export function FetchModelsDialog() {
return null
}

View file

@ -4,7 +4,7 @@ import {
IconPlus,
IconStar,
} from "@tabler/icons-react"
import { useCallback, useEffect, useState } from "react"
import { type ComponentType, useCallback, useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
@ -20,7 +20,6 @@ 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"
@ -52,6 +51,14 @@ export function ModelsPage() {
null,
)
// Dynamic import for CatalogDialog (added in PR2)
const [CatalogDialogComp, setCatalogDialogComp] = useState<ComponentType<{
open: boolean; onClose: () => void; onModelAdded: () => void;
}> | null>(null)
useEffect(() => {
import("./catalog-dialog").then((m) => setCatalogDialogComp(() => m.CatalogDialog)).catch(() => {})
}, [])
const fetchModels = useCallback(async () => {
try {
const data = await getModels()
@ -149,6 +156,7 @@ export function ModelsPage() {
size="sm"
variant="outline"
onClick={() => setCatalogOpen(true)}
disabled={!CatalogDialogComp}
>
<IconDatabase className="size-4" />
{t("models.catalog.button")}
@ -226,11 +234,13 @@ export function ModelsPage() {
onDeleted={fetchModels}
/>
<CatalogDialog
open={catalogOpen}
onClose={() => setCatalogOpen(false)}
onModelAdded={fetchModels}
/>
{CatalogDialogComp && (
<CatalogDialogComp
open={catalogOpen}
onClose={() => setCatalogOpen(false)}
onModelAdded={fetchModels}
/>
)}
</div>
)
}

View file

@ -1,5 +1,5 @@
import { IconCheck, IconChevronDown } from "@tabler/icons-react"
import { useState } from "react"
import { useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { Button } from "@/components/ui/button"
@ -50,6 +50,11 @@ export function ProviderCombobox({
const [open, setOpen] = useState(false)
const [customMode, setCustomMode] = useState(false)
const [customValue, setCustomValue] = useState("")
const [containerEl, setContainerEl] = useState<HTMLElement | null>(null)
useEffect(() => {
setContainerEl(containerRef?.current ?? null)
}, [containerRef])
const allProviders: MergedProvider[] = backendOptions
? mergeWithBackendOptions(backendOptions)
@ -122,7 +127,7 @@ export function ProviderCombobox({
<IconChevronDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" container={containerRef?.current}>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" container={containerEl}>
{customMode ? (
<div className="flex flex-col gap-2 p-2">
<Input

View file

@ -1,195 +1,4 @@
import { IconLoader2, IconPlugConnected, IconX } from "@tabler/icons-react"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import {
type ModelInfo,
type TestModelInlineRequest,
testModel,
testModelInline,
} from "@/api/models"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
export interface TestInlineParams {
provider: string
model: string
apiBase: string
apiKey: string
authMethod: string
modelIndex?: number
}
interface TestModelDialogProps {
model: ModelInfo | null
open: boolean
onClose: () => 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<TestResult | null>(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 (
<Dialog open={open} onOpenChange={(v) => !v && handleClose()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<IconPlugConnected className="size-5" />
{t("models.test.title")}
</DialogTitle>
<DialogDescription>{t("models.test.description")}</DialogDescription>
</DialogHeader>
{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">{displayModelName}</span>
</div>
<div>
<span className="text-muted-foreground">
{t("models.test.identifierLabel")}{" "}
</span>
<span className="font-mono">{displayModel}</span>
</div>
{displayApiBase && (
<div>
<span className="text-muted-foreground">
{t("models.test.endpointLabel")}{" "}
</span>
<span className="font-mono text-xs">{displayApiBase}</span>
</div>
)}
</div>
{!result && !testing && (
<Button onClick={handleTest} className="w-full">
<IconPlugConnected className="size-4" />
{t("models.test.testConnection")}
</Button>
)}
{testing && (
<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>
)}
{result && (
<div
className={`rounded-lg p-4 text-sm ${
result.success
? "bg-green-500/10 text-green-700 dark:text-green-400"
: "bg-destructive/10 text-destructive"
}`}
>
{result.success ? (
<div className="space-y-1">
<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="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 })}
</div>
</div>
)}
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="ghost" onClick={handleClose}>
{t("common.cancel")}
</Button>
{result && (
<Button variant="outline" onClick={handleTest}>
{t("models.test.testAgain")}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
)
// Placeholder: full implementation added in PR3 (Test Connection)
export function TestModelDialog() {
return null
}

View file

@ -246,7 +246,8 @@
},
"defaultOnSave": {
"label": "Default Model",
"description": "Automatically set this model as default after saving."
"description": "Automatically set this model as default after saving.",
"unsupportedProvider": "This provider can be saved in the model list but cannot be used as the default chat model."
},
"add": {
"button": "Add Model",

View file

@ -244,7 +244,8 @@
},
"defaultOnSave": {
"label": "Modelo Padrão",
"description": "Definir automaticamente este modelo como padrão após salvar."
"description": "Definir automaticamente este modelo como padrão após salvar.",
"unsupportedProvider": "Este provedor pode ser salvo na lista de modelos, mas não pode ser usado como modelo de chat padrão."
},
"add": {
"button": "Adicionar Modelo",