This commit is contained in:
肆月 2026-05-11 11:09:33 +08:00 committed by GitHub
commit caa4d910b4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 3677 additions and 912 deletions

View file

@ -614,6 +614,21 @@ func (c *ModelConfig) Validate() error {
if _, err := providercommon.NormalizeToolSchemaTransform(c.ToolSchemaTransform); err != nil {
return err
}
// Reject whitespace in model identifier
if strings.ContainsAny(c.Model, " \t\n\r") {
return fmt.Errorf("model identifier contains whitespace")
}
// Reject leading slash
if strings.HasPrefix(c.Model, "/") {
return fmt.Errorf("model identifier must not start with /")
}
// Reject consecutive slashes
if strings.Contains(c.Model, "//") {
return fmt.Errorf("model identifier must not contain //")
}
return nil
}

View file

@ -15,6 +15,7 @@ import (
anthropicmessages "github.com/sipeed/picoclaw/pkg/providers/anthropic_messages"
"github.com/sipeed/picoclaw/pkg/providers/azure"
"github.com/sipeed/picoclaw/pkg/providers/bedrock"
"github.com/sipeed/picoclaw/pkg/providers/common"
)
type protocolMeta struct {
@ -60,6 +61,8 @@ var protocolMetaByName = map[string]protocolMeta{
"longcat": {defaultAPIBase: "https://api.longcat.chat/openai"},
"modelscope": {defaultAPIBase: "https://api-inference.modelscope.cn/v1"},
"mimo": {defaultAPIBase: "https://api.xiaomimimo.com/v1"},
"anthropic": {defaultAPIBase: "https://api.anthropic.com/v1"},
"anthropic-messages": {defaultAPIBase: "https://api.anthropic.com/v1"},
}
// createClaudeAuthProvider creates a Claude provider using OAuth credentials from auth store.
@ -318,10 +321,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err
return finalizeProviderFromConfig(provider, modelID, cfg)
}
// Use API key with HTTP API
apiBase := cfg.APIBase
if apiBase == "" {
apiBase = "https://api.anthropic.com/v1"
}
apiBase := common.NormalizeBaseURL(cfg.APIBase, "https://api.anthropic.com/v1", true)
if cfg.APIKey() == "" {
return nil, "", fmt.Errorf("api_key is required for anthropic protocol (model: %s)", cfg.Model)
}

View file

@ -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"})
}

View file

@ -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,11 @@ 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.
@ -614,3 +633,324 @@ 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

@ -18,6 +18,7 @@
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@radix-ui/react-popover": "^1.1.15",
"@tabler/icons-react": "^3.43.0",
"@tailwindcss/vite": "^4.2.4",
"@tanstack/react-query": "^5.99.0",
@ -25,6 +26,7 @@
"@tanstack/react-router-devtools": "^1.166.13",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"dayjs": "^1.11.20",
"highlight.js": "^11.11.1",
"i18next": "^26.0.10",

View file

@ -11,6 +11,9 @@ importers:
'@fontsource-variable/inter':
specifier: ^5.2.8
version: 5.2.8
'@radix-ui/react-popover':
specifier: ^1.1.15
version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@tabler/icons-react':
specifier: ^3.43.0
version: 3.43.0(react@19.2.5)
@ -32,6 +35,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
dayjs:
specifier: ^1.11.20
version: 1.11.20
@ -2038,6 +2044,12 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'}
cmdk@1.1.1:
resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
peerDependencies:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
code-block-writer@13.0.3:
resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==}
@ -5976,6 +5988,18 @@ snapshots:
clsx@2.1.1: {}
cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.5)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react: 19.2.5
react-dom: 19.2.5(react@19.2.5)
transitivePeerDependencies:
- '@types/react'
- '@types/react-dom'
code-block-writer@13.0.3: {}
color-convert@2.0.1:

View file

@ -23,6 +23,7 @@ export interface ModelInfo {
extra_body?: Record<string, unknown>
custom_headers?: Record<string, string>
// Meta
enabled: boolean
available: boolean
status: "available" | "unconfigured" | "unreachable"
is_default: boolean
@ -58,7 +59,13 @@ const BASE_URL = ""
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const res = await launcherFetch(`${BASE_URL}${path}`, options)
if (!res.ok) {
throw new Error(`API error: ${res.status} ${res.statusText}`)
let detail = ""
try {
detail = await res.text()
} catch {
// ignore
}
throw new Error(detail || `API error: ${res.status} ${res.statusText}`)
}
return res.json() as Promise<T>
}
@ -107,4 +114,97 @@ export async function setDefaultModel(
return response
}
export interface TestModelResponse {
success: boolean
latency_ms: number
status: string
error?: string
}
export async function testModel(index: number): Promise<TestModelResponse> {
return request<TestModelResponse>(`/api/models/${index}/test`, {
method: "POST",
})
}
export interface TestModelInlineRequest {
provider: string
model: string
api_base?: string
api_key?: string
auth_method?: string
model_index?: number
}
export async function testModelInline(
params: TestModelInlineRequest,
): Promise<TestModelResponse> {
return request<TestModelResponse>("/api/models/test-inline", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
})
}
export interface UpstreamModel {
id: string
owned_by?: string
}
export interface FetchModelsRequest {
provider: string
api_key?: string
api_base?: string
}
export interface FetchModelsResponse {
models: UpstreamModel[]
total: number
}
export async function fetchUpstreamModels(
req: FetchModelsRequest,
): Promise<FetchModelsResponse> {
return request<FetchModelsResponse>("/api/models/fetch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
})
}
// --- Model Catalog API ---
export interface CatalogModel {
id: string
owned_by?: string
extra?: Record<string, unknown>
}
export interface CatalogEntry {
id: string
provider: string
api_base: string
api_key_mask: string
models: CatalogModel[]
fetched_at: string
}
interface CatalogListResponse {
entries: CatalogEntry[]
total: number
}
export async function getCatalogs(): Promise<CatalogListResponse> {
return request<CatalogListResponse>("/api/models/catalog")
}
export async function deleteCatalog(id: string): Promise<void> {
await request<Record<string, never>>(
`/api/models/catalog/${encodeURIComponent(id)}`,
{
method: "DELETE",
},
)
}
export type { ModelsListResponse, ModelActionResponse }

View file

@ -66,10 +66,7 @@ export function WebSearchGeneralSettings({
</SettingRow>
<SettingRow
label={t(
"pages.agent.tools.web_search.proxy",
"Proxy Configuration",
)}
label={t("pages.agent.tools.web_search.proxy", "Proxy Configuration")}
description={t(
"pages.agent.tools.web_search.proxy_description",
"Optional global HTTP/S proxy for underlying web requests.",

View file

@ -84,10 +84,7 @@ function ProviderCard({
const { t } = useTranslation()
const apiKeyPlaceholder = maskedSecretPlaceholder(
settings.api_key_set ? `${providerId}-configured` : "",
t(
"pages.agent.tools.web_search.api_key_placeholder",
"Enter API key...",
),
t("pages.agent.tools.web_search.api_key_placeholder", "Enter API key..."),
)
const updateSettings = (
@ -167,7 +164,10 @@ function ProviderCard({
>
<div className="ml-8 flex max-w-xl flex-col gap-5">
<ProviderField
label={t("pages.agent.tools.web_search.max_results", "Max Results")}
label={t(
"pages.agent.tools.web_search.max_results",
"Max Results",
)}
>
<Input
type="number"

View file

@ -128,7 +128,10 @@ export function ChatComposer({
<div className="flex items-center gap-1.5">
{contextUsage && (
<ContextUsageRing usage={contextUsage} onDetailClick={onContextDetail} />
<ContextUsageRing
usage={contextUsage}
onDetailClick={onContextDetail}
/>
)}
{canInput ? (
<Tooltip delayDuration={700}>

View file

@ -127,7 +127,7 @@ export function ContextUsageRing({
: "pointer-events-none scale-95 opacity-0"
}`}
>
<div className="bg-popover absolute -bottom-1.5 right-3 h-3 w-3 rotate-45 border-r border-b" />
<div className="bg-popover absolute right-3 -bottom-1.5 h-3 w-3 rotate-45 border-r border-b" />
<div className="flex items-center justify-between">
<span className="text-muted-foreground text-xs">

View file

@ -1,10 +1,15 @@
import { IconLoader2 } from "@tabler/icons-react"
import { useEffect, useMemo, useState } from "react"
import {
IconDownload,
IconLoader2,
IconPlugConnected,
} from "@tabler/icons-react"
import { useCallback, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import {
type ModelProviderOption,
addModel,
getCatalogs,
setDefaultModel,
} from "@/api/models"
import { ConfigChangeNotice } from "@/components/config-change-notice"
@ -15,15 +20,9 @@ import {
KeyInput,
SwitchCardField,
} from "@/components/shared-form"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Sheet,
SheetContent,
@ -36,14 +35,12 @@ import { Textarea } from "@/components/ui/textarea"
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
import { refreshGatewayState } from "@/store/gateway"
import {
findProviderOption,
getProviderDefaultAPIBase,
getProviderDefaultAuthMethod,
getProviderLabel,
getSortedProviderOptions,
isProviderAuthMethodLocked,
} from "./provider-label"
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
@ -66,7 +63,7 @@ interface AddForm {
const EMPTY_ADD_FORM: AddForm = {
modelName: "",
provider: "openai",
provider: "",
model: "",
apiBase: "",
apiKey: "",
@ -83,12 +80,43 @@ const EMPTY_ADD_FORM: AddForm = {
customHeaders: "",
}
function normalizeApiBase(value: string): string {
return value.trim().replace(/\/+$/, "")
}
function getNextApiBaseForProviderChange(
currentApiBase: string,
currentProvider: string,
nextProvider: string,
): string {
const normalizedCurrentApiBase = normalizeApiBase(currentApiBase)
const currentDefaultApiBase = normalizeApiBase(
PROVIDER_MAP.get(currentProvider)?.defaultApiBase ?? "",
)
const nextDefaultApiBase =
PROVIDER_MAP.get(nextProvider)?.defaultApiBase ?? ""
if (!normalizedCurrentApiBase) {
return nextDefaultApiBase
}
if (
normalizedCurrentApiBase &&
currentDefaultApiBase &&
normalizedCurrentApiBase === currentDefaultApiBase
) {
return nextDefaultApiBase
}
return currentApiBase
}
interface AddModelSheetProps {
open: boolean
onClose: () => void
onSaved: () => void
existingModelNames: string[]
providerOptions: ModelProviderOption[]
providerOptions?: ModelProviderOption[]
}
export function AddModelSheet({
@ -106,41 +134,18 @@ export function AddModelSheet({
Partial<Record<keyof AddForm, string>>
>({})
const [serverError, setServerError] = useState("")
const [modelValidation, setModelValidation] =
useState<FieldValidation | null>(null)
const [fetchOpen, setFetchOpen] = useState(false)
const [testOpen, setTestOpen] = useState(false)
const [fetchedModels, setFetchedModels] = useState<string[]>([])
const [catalogModels, setCatalogModels] = useState<string[]>([])
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const apiKeyPlaceholder = maskedSecretPlaceholder(
form.apiKey,
t("models.field.apiKeyPlaceholder"),
)
const sortedProviderOptions = useMemo(
() => getSortedProviderOptions(providerOptions),
[providerOptions],
)
const creatableProviderOptions = useMemo(
() => sortedProviderOptions.filter((option) => option.create_allowed),
[sortedProviderOptions],
)
const selectedProviderOption = findProviderOption(
form.provider,
providerOptions,
)
const authMethodLocked = isProviderAuthMethodLocked(
form.provider,
providerOptions,
)
const defaultAuthMethod = getProviderDefaultAuthMethod(
form.provider,
providerOptions,
)
const effectiveAuthMethod = (
authMethodLocked ? defaultAuthMethod : form.authMethod
)
.trim()
.toLowerCase()
const isOAuth = effectiveAuthMethod === "oauth"
const defaultModelAllowed =
selectedProviderOption?.default_model_allowed !== false
const apiBasePlaceholder =
getProviderDefaultAPIBase(form.provider, providerOptions) ||
"https://api.example.com/v1"
const isDirty =
JSON.stringify(form) !== JSON.stringify(EMPTY_ADD_FORM) || setAsDefault
@ -150,9 +155,39 @@ export function AddModelSheet({
setSetAsDefault(false)
setFieldErrors({})
setServerError("")
setModelValidation(null)
setFetchedModels([])
setCatalogModels([])
}
}, [open])
// Load catalog models when provider or apiBase changes
useEffect(() => {
const providerKey = getProviderKey(form.provider || undefined)
const apiBase = form.apiBase.trim().replace(/\/+$/, "")
if (!form.provider.trim()) {
setCatalogModels([])
return
}
let cancelled = false
getCatalogs()
.then((res) => {
if (cancelled) return
const matched = (res.entries || []).filter((e) => {
const ep = getProviderKey(e.provider || undefined)
const eb = (e.api_base ?? "").trim().replace(/\/+$/, "")
return ep === providerKey && eb === apiBase
})
const ids = matched.flatMap((e) => e.models.map((m) => m.id))
const unique = [...new Set(ids)]
setCatalogModels(unique)
})
.catch(() => {})
return () => {
cancelled = true
}
}, [form.provider, form.apiBase])
const validate = (): boolean => {
const errors: Partial<Record<keyof AddForm, string>> = {}
const modelName = form.modelName.trim()
@ -161,10 +196,13 @@ export function AddModelSheet({
} else if (existingModelNames.some((name) => name.trim() === modelName)) {
errors.modelName = t("models.add.errorDuplicateModelName")
}
if (!selectedProviderOption) {
errors.provider = t("models.field.providerInvalid")
}
if (!form.model.trim()) errors.model = t("models.add.errorRequired")
if (modelValidation?.level === "error") {
errors.model = t(
modelValidation.messageKey,
modelValidation.messageParams,
)
}
setFieldErrors(errors)
return Object.keys(errors).length === 0
}
@ -178,47 +216,117 @@ export function AddModelSheet({
}
}
const setProvider = (value: string) => {
setForm((f) => {
const previousOption = findProviderOption(f.provider, providerOptions)
const nextOption = findProviderOption(value, providerOptions)
let authMethod = f.authMethod
if (nextOption?.auth_method_locked) {
authMethod = nextOption.default_auth_method ?? ""
} else if (
previousOption?.auth_method_locked &&
f.authMethod === (previousOption.default_auth_method ?? "")
) {
authMethod = ""
}
return { ...f, provider: value, authMethod }
})
const nextOption = findProviderOption(value, providerOptions)
if (nextOption?.default_model_allowed === false) {
setSetAsDefault(false)
const debouncedValidateModel = useCallback(
(value: string, provider: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
const result = validateModelField(value, provider || undefined)
setModelValidation(result)
}, 300)
},
[],
)
const handleModelChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setForm((f) => ({ ...f, model: value }))
if (fieldErrors.model) {
setFieldErrors((prev) => ({ ...prev, model: undefined }))
}
if (fieldErrors.provider) {
setFieldErrors((prev) => ({ ...prev, provider: undefined }))
debouncedValidateModel(value, form.provider)
}
const handleProviderChange = (provider: string) => {
setForm((f) => {
return {
...f,
provider,
apiBase: getNextApiBaseForProviderChange(
f.apiBase,
f.provider,
provider,
),
}
})
// Re-validate model with new provider context
if (form.model) {
debouncedValidateModel(form.model, provider)
}
}
const applyFix = () => {
if (modelValidation?.fix) {
setForm((f) => ({ ...f, model: modelValidation.fix! }))
setModelValidation(null)
}
}
const handleCommonModel = (modelId: string) => {
setForm((f) => ({ ...f, model: modelId }))
setModelValidation(null)
if (fieldErrors.model) {
setFieldErrors((prev) => ({ ...prev, model: undefined }))
}
}
const handleFetchFill = (models: string[]) => {
setFetchedModels(models)
if (models.length >= 1) {
setForm((f) => ({ ...f, model: models[0] }))
setModelValidation(null)
if (fieldErrors.model) {
setFieldErrors((prev) => ({ ...prev, model: undefined }))
}
}
}
const providerDef = PROVIDER_MAP.get(form.provider)
const commonModels = providerDef?.commonModels || []
const handleSave = async () => {
if (!validate()) return
let extraBody: Record<string, unknown> | undefined
let customHeaders: Record<string, string> | undefined
try {
if (form.extraBody.trim()) {
extraBody = JSON.parse(form.extraBody.trim())
} else {
extraBody = {}
}
} catch {
setServerError(
t("models.field.extraBody") + ": " + t("models.field.invalidJson"),
)
return
}
try {
if (form.customHeaders.trim()) {
customHeaders = JSON.parse(form.customHeaders.trim())
} else {
customHeaders = {}
}
} catch {
setServerError(
t("models.field.customHeaders") + ": " + t("models.field.invalidJson"),
)
return
}
setSaving(true)
setServerError("")
try {
const modelName = form.modelName.trim()
const provider = form.provider.trim()
const modelId = form.model.trim()
await addModel({
model_name: modelName,
provider: form.provider.trim(),
provider: provider || undefined,
model: modelId,
api_base: form.apiBase.trim() || undefined,
api_key: form.apiKey.trim() || undefined,
proxy: form.proxy.trim() || undefined,
auth_method: authMethodLocked
? defaultAuthMethod || undefined
: form.authMethod.trim() || undefined,
auth_method: form.authMethod.trim() || undefined,
connect_mode: form.connectMode.trim() || undefined,
workspace: form.workspace.trim() || undefined,
rpm: form.rpm ? Number(form.rpm) : undefined,
@ -228,12 +336,8 @@ export function AddModelSheet({
: undefined,
thinking_level: form.thinkingLevel.trim() || undefined,
tool_schema_transform: form.toolSchemaTransform.trim() || undefined,
extra_body: form.extraBody.trim()
? JSON.parse(form.extraBody.trim())
: undefined,
custom_headers: form.customHeaders.trim()
? JSON.parse(form.customHeaders.trim())
: undefined,
extra_body: extraBody,
custom_headers: customHeaders,
})
if (setAsDefault) {
await setDefaultModel(modelName)
@ -255,82 +359,165 @@ export function AddModelSheet({
}
return (
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
<SheetContent
side="right"
className="flex flex-col gap-0 p-0 data-[side=right]:!w-full data-[side=right]:sm:!w-[560px] data-[side=right]:sm:!max-w-[560px]"
>
<SheetHeader className="border-b-muted border-b px-6 py-5">
<SheetTitle className="text-base">{t("models.add.title")}</SheetTitle>
<SheetDescription className="text-xs">
{t("models.add.description")}
</SheetDescription>
</SheetHeader>
<>
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
<SheetContent
side="right"
className="flex flex-col gap-0 p-0 data-[side=right]:!w-full data-[side=right]:sm:!w-[560px] data-[side=right]:sm:!max-w-[560px]"
>
<SheetHeader className="border-b-muted border-b px-6 py-5">
<SheetTitle className="text-base">
{t("models.add.title")}
</SheetTitle>
<SheetDescription className="text-xs">
{t("models.add.description")}
</SheetDescription>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="space-y-5 px-6 py-5">
<Field
label={t("models.add.modelName")}
hint={t("models.add.modelNameHint")}
>
<Input
value={form.modelName}
onChange={setField("modelName")}
placeholder={t("models.add.modelNamePlaceholder")}
aria-invalid={!!fieldErrors.modelName}
/>
{fieldErrors.modelName && (
<p className="text-destructive text-xs">
{fieldErrors.modelName}
</p>
)}
</Field>
<Field
label={t("models.field.provider")}
hint={t("models.field.providerHint")}
error={fieldErrors.provider}
required
>
<Select
value={selectedProviderOption?.id}
onValueChange={setProvider}
<div className="min-h-0 flex-1 overflow-y-auto" ref={scrollContainerRef}>
<div className="space-y-5 px-6 py-5">
<Field
label={t("models.add.modelName")}
hint={t("models.add.modelNameHint")}
>
<SelectTrigger
className="w-full"
aria-invalid={!!fieldErrors.provider}
>
<SelectValue
placeholder={t("models.field.providerPlaceholder")}
/>
</SelectTrigger>
<SelectContent>
{creatableProviderOptions.map((option) => (
<SelectItem key={option.id} value={option.id}>
{getProviderLabel(option.id)}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<Input
value={form.modelName}
onChange={setField("modelName")}
placeholder={t("models.add.modelNamePlaceholder")}
aria-invalid={!!fieldErrors.modelName}
/>
{fieldErrors.modelName && (
<p className="text-destructive text-xs">
{fieldErrors.modelName}
</p>
)}
</Field>
<Field
label={t("models.add.modelId")}
hint={t("models.add.modelIdHint")}
>
<Input
value={form.model}
onChange={setField("model")}
placeholder={t("models.add.modelIdPlaceholder")}
className="font-mono text-sm"
aria-invalid={!!fieldErrors.model}
/>
{fieldErrors.model && (
<p className="text-destructive text-xs">{fieldErrors.model}</p>
)}
</Field>
<Field
label={t("models.field.provider")}
hint={t("models.field.providerHint")}
>
<ProviderCombobox
value={form.provider}
onChange={handleProviderChange}
placeholder={t("models.field.providerPlaceholder")}
backendOptions={providerOptions}
filterCreateAllowed
containerRef={scrollContainerRef}
/>
</Field>
<Field
label={t("models.add.modelId")}
hint={t("models.add.modelIdHint")}
>
<Input
value={form.model}
onChange={handleModelChange}
placeholder={
providerDef
? `${commonModels[0] || "model-name"}`
: t("models.add.modelIdPlaceholder")
}
className="font-mono text-sm"
aria-invalid={
!!fieldErrors.model || modelValidation?.level === "error"
}
/>
{modelValidation && modelValidation.messageKey && (
<div
className={`flex items-center gap-2 text-xs ${
modelValidation.level === "error"
? "text-destructive"
: modelValidation.level === "warning"
? "text-yellow-600 dark:text-yellow-500"
: "text-green-600 dark:text-green-500"
}`}
>
<span>
{t(
modelValidation.messageKey,
modelValidation.messageParams,
)}
</span>
{modelValidation.fix && (
<button
type="button"
onClick={applyFix}
className="text-primary underline hover:no-underline"
>
{t("common.fix")}
</button>
)}
</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">
{form.provider && FETCHABLE_PROVIDER_KEYS.has(form.provider) && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setFetchOpen(true)}
>
<IconDownload className="size-3" />
{t("models.fetch.title")}
</Button>
)}
{!form.provider && (
<span className="text-muted-foreground text-xs">
{t("models.field.selectProviderFirst")}
</span>
)}
</div>
</Field>
{!isOAuth && (
<Field label={t("models.field.apiKey")}>
<KeyInput
value={form.apiKey}
@ -338,191 +525,216 @@ export function AddModelSheet({
placeholder={apiKeyPlaceholder}
/>
</Field>
)}
<Field
label={t("models.field.apiBase")}
hint={isOAuth ? t("models.edit.oauthNote") : undefined}
>
<Input
value={form.apiBase}
onChange={setField("apiBase")}
placeholder={apiBasePlaceholder}
disabled={isOAuth}
<Field label={t("models.field.apiBase")}>
<Input
value={form.apiBase}
onChange={setField("apiBase")}
placeholder="https://api.example.com/v1"
/>
</Field>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setTestOpen(true)}
disabled={!form.provider || !form.model}
>
<IconPlugConnected className="size-4" />
{t("models.test.testConnection")}
</Button>
</div>
<SwitchCardField
label={t("models.defaultOnSave.label")}
hint={t("models.defaultOnSave.description")}
checked={setAsDefault}
onCheckedChange={setSetAsDefault}
/>
</Field>
<SwitchCardField
label={t("models.defaultOnSave.label")}
hint={
defaultModelAllowed
? t("models.defaultOnSave.description")
: t("models.defaultOnSave.unsupportedProvider")
}
checked={setAsDefault}
onCheckedChange={setSetAsDefault}
disabled={!defaultModelAllowed}
/>
<AdvancedSection>
<Field
label={t("models.field.proxy")}
hint={t("models.field.proxyHint")}
>
<Input
value={form.proxy}
onChange={setField("proxy")}
placeholder="http://127.0.0.1:7890"
/>
</Field>
<AdvancedSection>
<Field
label={t("models.field.proxy")}
hint={t("models.field.proxyHint")}
>
<Input
value={form.proxy}
onChange={setField("proxy")}
placeholder="http://127.0.0.1:7890"
/>
</Field>
<Field
label={t("models.field.authMethod")}
hint={t("models.field.authMethodHint")}
>
<Input
value={form.authMethod}
onChange={setField("authMethod")}
placeholder="oauth"
/>
</Field>
<Field
label={t("models.field.authMethod")}
hint={
authMethodLocked
? t("models.field.authMethodManagedHint")
: t("models.field.authMethodHint")
}
>
<Input
value={authMethodLocked ? defaultAuthMethod : form.authMethod}
onChange={setField("authMethod")}
placeholder="oauth"
disabled={authMethodLocked}
/>
</Field>
<Field
label={t("models.field.connectMode")}
hint={t("models.field.connectModeHint")}
>
<Input
value={form.connectMode}
onChange={setField("connectMode")}
placeholder="stdio"
/>
</Field>
<Field
label={t("models.field.connectMode")}
hint={t("models.field.connectModeHint")}
>
<Input
value={form.connectMode}
onChange={setField("connectMode")}
placeholder="stdio"
/>
</Field>
<Field
label={t("models.field.workspace")}
hint={t("models.field.workspaceHint")}
>
<Input
value={form.workspace}
onChange={setField("workspace")}
placeholder="/path/to/workspace"
/>
</Field>
<Field
label={t("models.field.workspace")}
hint={t("models.field.workspaceHint")}
>
<Input
value={form.workspace}
onChange={setField("workspace")}
placeholder="/path/to/workspace"
/>
</Field>
<Field
label={t("models.field.requestTimeout")}
hint={t("models.field.requestTimeoutHint")}
>
<Input
value={form.requestTimeout}
onChange={setField("requestTimeout")}
placeholder="60"
type="number"
min={0}
/>
</Field>
<Field
label={t("models.field.requestTimeout")}
hint={t("models.field.requestTimeoutHint")}
>
<Input
value={form.requestTimeout}
onChange={setField("requestTimeout")}
placeholder="60"
type="number"
min={0}
/>
</Field>
<Field
label={t("models.field.rpm")}
hint={t("models.field.rpmHint")}
>
<Input
value={form.rpm}
onChange={setField("rpm")}
placeholder="60"
type="number"
min={0}
/>
</Field>
<Field
label={t("models.field.rpm")}
hint={t("models.field.rpmHint")}
>
<Input
value={form.rpm}
onChange={setField("rpm")}
placeholder="60"
type="number"
min={0}
/>
</Field>
<Field
label={t("models.field.thinkingLevel")}
hint={t("models.field.thinkingLevelHint")}
>
<Input
value={form.thinkingLevel}
onChange={setField("thinkingLevel")}
placeholder="off"
/>
</Field>
<Field
label={t("models.field.thinkingLevel")}
hint={t("models.field.thinkingLevelHint")}
>
<Input
value={form.thinkingLevel}
onChange={setField("thinkingLevel")}
placeholder="off"
/>
</Field>
<Field
label={t("models.field.maxTokensField")}
hint={t("models.field.maxTokensFieldHint")}
>
<Input
value={form.maxTokensField}
onChange={setField("maxTokensField")}
placeholder="max_completion_tokens"
/>
</Field>
<Field
label={t("models.field.maxTokensField")}
hint={t("models.field.maxTokensFieldHint")}
>
<Input
value={form.maxTokensField}
onChange={setField("maxTokensField")}
placeholder="max_completion_tokens"
/>
</Field>
<Field
label={t("models.field.toolSchemaTransform")}
hint={t("models.field.toolSchemaTransformHint")}
>
<Input
value={form.toolSchemaTransform}
onChange={setField("toolSchemaTransform")}
placeholder="google"
/>
</Field>
<Field
label={t("models.field.toolSchemaTransform")}
hint={t("models.field.toolSchemaTransformHint")}
>
<Input
value={form.toolSchemaTransform}
onChange={setField("toolSchemaTransform")}
placeholder="google"
/>
</Field>
<Field
label={t("models.field.extraBody")}
hint={t("models.field.extraBodyHint")}
>
<Textarea
value={form.extraBody}
onChange={setField("extraBody")}
placeholder='{"key": "value"}'
rows={3}
/>
</Field>
<Field
label={t("models.field.extraBody")}
hint={t("models.field.extraBodyHint")}
>
<Textarea
value={form.extraBody}
onChange={setField("extraBody")}
placeholder='{"key": "value"}'
rows={3}
/>
</Field>
<Field
label={t("models.field.customHeaders")}
hint={t("models.field.customHeadersHint")}
>
<Textarea
value={form.customHeaders}
onChange={setField("customHeaders")}
placeholder='{"X-Source": "coding-plan"}'
rows={3}
/>
</Field>
</AdvancedSection>
<Field
label={t("models.field.customHeaders")}
hint={t("models.field.customHeadersHint")}
>
<Textarea
value={form.customHeaders}
onChange={setField("customHeaders")}
placeholder='{"X-Source": "coding-plan"}'
rows={3}
/>
</Field>
</AdvancedSection>
{serverError && (
<p className="text-destructive bg-destructive/10 rounded-md px-3 py-2 text-sm">
{serverError}
</p>
)}
{serverError && (
<p className="text-destructive bg-destructive/10 rounded-md px-3 py-2 text-sm">
{serverError}
</p>
)}
</div>
</div>
</div>
<SheetFooter className="border-t-muted border-t px-6 py-4">
{isDirty && (
<ConfigChangeNotice
kind="save"
title={t("common.saveChangesTitle")}
description={t("models.unsavedPrompt")}
/>
)}
<Button variant="ghost" onClick={onClose} disabled={saving}>
{t("common.cancel")}
</Button>
<Button onClick={handleSave} disabled={!isDirty || saving}>
{saving && <IconLoader2 className="size-4 animate-spin" />}
{t("models.add.confirm")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<SheetFooter className="border-t-muted border-t px-6 py-4">
{isDirty && (
<ConfigChangeNotice
kind="save"
title={t("common.saveChangesTitle")}
description={t("models.unsavedPrompt")}
/>
)}
<Button variant="ghost" onClick={onClose} disabled={saving}>
{t("common.cancel")}
</Button>
<Button
onClick={handleSave}
disabled={
!isDirty || saving || modelValidation?.level === "error"
}
>
{saving && <IconLoader2 className="size-4 animate-spin" />}
{t("models.add.confirm")}
</Button>
</SheetFooter>
</SheetContent>
<FetchModelsDialog
open={fetchOpen}
onClose={() => setFetchOpen(false)}
onFill={handleFetchFill}
provider={form.provider}
apiKey={form.apiKey}
apiBase={form.apiBase}
/>
<TestModelDialog
model={null}
open={testOpen}
onClose={() => setTestOpen(false)}
inlineParams={{
provider: form.provider,
model: form.model,
apiBase: form.apiBase,
apiKey: form.apiKey,
authMethod: form.authMethod,
}}
/>
</Sheet>
</>
)
}

View file

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

View file

@ -1,10 +1,15 @@
import { IconLoader2 } from "@tabler/icons-react"
import { useEffect, useMemo, useState } from "react"
import {
IconDownload,
IconLoader2,
IconPlugConnected,
} from "@tabler/icons-react"
import { useCallback, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import {
type ModelInfo,
type ModelProviderOption,
getCatalogs,
setDefaultModel,
updateModel,
} from "@/api/models"
@ -16,15 +21,9 @@ import {
KeyInput,
SwitchCardField,
} from "@/components/shared-form"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
Sheet,
SheetContent,
@ -37,14 +36,12 @@ import { Textarea } from "@/components/ui/textarea"
import { showSaveSuccessOrRestartToast } from "@/lib/restart-required"
import { refreshGatewayState } from "@/store/gateway"
import {
findProviderOption,
getProviderDefaultAPIBase,
getProviderDefaultAuthMethod,
getProviderLabel,
getSortedProviderOptions,
isProviderAuthMethodLocked,
} from "./provider-label"
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
@ -66,10 +63,40 @@ interface EditForm {
interface EditModelSheetProps {
model: ModelInfo | null
providerOptions: ModelProviderOption[]
open: boolean
onClose: () => void
onSaved: () => void
providerOptions?: ModelProviderOption[]
}
function normalizeApiBase(value: string): string {
return value.trim().replace(/\/+$/, "")
}
function getNextApiBaseForProviderChange(
currentApiBase: string,
currentProvider: string,
nextProvider: string,
): string {
const normalizedCurrentApiBase = normalizeApiBase(currentApiBase)
const currentDefaultApiBase = normalizeApiBase(
PROVIDER_API_BASES[currentProvider] || "",
)
const nextDefaultApiBase = PROVIDER_API_BASES[nextProvider] || ""
if (!normalizedCurrentApiBase) {
return nextDefaultApiBase
}
if (
normalizedCurrentApiBase &&
currentDefaultApiBase &&
normalizedCurrentApiBase === currentDefaultApiBase
) {
return nextDefaultApiBase
}
return currentApiBase
}
function buildInitialEditForm(model: ModelInfo): EditForm {
@ -98,10 +125,10 @@ function buildInitialEditForm(model: ModelInfo): EditForm {
export function EditModelSheet({
model,
providerOptions,
open,
onClose,
onSaved,
providerOptions,
}: EditModelSheetProps) {
const { t } = useTranslation()
const [form, setForm] = useState<EditForm>({
@ -124,43 +151,15 @@ export function EditModelSheet({
const [saving, setSaving] = useState(false)
const [setAsDefault, setSetAsDefault] = useState(false)
const [error, setError] = useState("")
const [modelValidation, setModelValidation] =
useState<FieldValidation | null>(null)
const [testOpen, setTestOpen] = useState(false)
const [fetchOpen, setFetchOpen] = useState(false)
const [fetchedModels, setFetchedModels] = useState<string[]>([])
const [catalogModels, setCatalogModels] = useState<string[]>([])
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const initialForm = model ? buildInitialEditForm(model) : null
const sortedProviderOptions = useMemo(
() => getSortedProviderOptions(providerOptions),
[providerOptions],
)
const currentProviderID = model
? (findProviderOption(model.provider, providerOptions)?.id ??
model.provider?.trim().toLowerCase() ??
"")
: ""
const selectedProviderOption = findProviderOption(
form.provider,
providerOptions,
)
const authMethodLocked = isProviderAuthMethodLocked(
form.provider,
providerOptions,
)
const defaultAuthMethod = getProviderDefaultAuthMethod(
form.provider,
providerOptions,
)
const effectiveAuthMethod = (
authMethodLocked ? defaultAuthMethod : form.authMethod
)
.trim()
.toLowerCase()
const providerError = selectedProviderOption
? ""
: t("models.field.providerInvalid")
const defaultModelAllowed =
selectedProviderOption?.default_model_allowed !== false
const willClearDefaultOnSave =
model?.is_default === true && defaultModelAllowed === false
const apiBasePlaceholder =
getProviderDefaultAPIBase(form.provider, providerOptions) ||
"https://api.example.com/v1"
const isDirty =
model != null &&
(JSON.stringify(form) !== JSON.stringify(initialForm) ||
@ -168,73 +167,134 @@ export function EditModelSheet({
useEffect(() => {
if (model) {
const initialForm = buildInitialEditForm(model)
const option = findProviderOption(initialForm.provider, providerOptions)
if (option?.auth_method_locked && !initialForm.authMethod) {
initialForm.authMethod = option.default_auth_method ?? ""
}
setForm(initialForm)
setSetAsDefault(model.is_default && model.default_model_allowed !== false)
setForm(buildInitialEditForm(model))
setSetAsDefault(model.is_default)
setError("")
setModelValidation(null)
setFetchedModels([])
setCatalogModels([])
// Load matching catalog models
const providerKey = getProviderKey(model.provider || undefined)
const apiBase = (model.api_base ?? "").trim().replace(/\/+$/, "")
getCatalogs()
.then((res) => {
const matched = (res.entries || []).filter((e) => {
const ep = getProviderKey(e.provider || undefined)
const eb = (e.api_base ?? "").trim().replace(/\/+$/, "")
return ep === providerKey && eb === apiBase
})
const ids = matched.flatMap((e) => e.models.map((m) => m.id))
const unique = [...new Set(ids)]
if (unique.length > 0) setCatalogModels(unique)
})
.catch(() => {})
}
}, [model, providerOptions])
}, [model])
const setField =
(key: keyof EditForm) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (error) {
setError("")
}
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
setForm((f) => ({ ...f, [key]: e.target.value }))
}
const setProvider = (value: string) => {
if (error) {
setError("")
}
setForm((f) => {
const previousOption = findProviderOption(f.provider, providerOptions)
const nextOption = findProviderOption(value, providerOptions)
let authMethod = f.authMethod
if (nextOption?.auth_method_locked) {
authMethod = nextOption.default_auth_method ?? ""
} else if (
previousOption?.auth_method_locked &&
f.authMethod === (previousOption.default_auth_method ?? "")
) {
authMethod = ""
}
return { ...f, provider: value, authMethod }
})
const nextOption = findProviderOption(value, providerOptions)
if (nextOption?.default_model_allowed === false) {
setSetAsDefault(false)
const debouncedValidateModel = useCallback(
(value: string, provider: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
const result = validateModelField(value, provider || undefined)
setModelValidation(result)
}, 300)
},
[],
)
const handleModelChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setForm((f) => ({ ...f, modelId: value }))
debouncedValidateModel(value, form.provider)
}
const handleProviderChange = (provider: string) => {
setForm((f) => ({
...f,
provider,
apiBase: getNextApiBaseForProviderChange(f.apiBase, f.provider, provider),
}))
if (form.modelId) {
debouncedValidateModel(form.modelId, provider)
}
}
const applyFix = () => {
if (modelValidation?.fix) {
setForm((f) => ({ ...f, modelId: modelValidation.fix! }))
setModelValidation(null)
}
}
const handleCommonModel = (modelId: string) => {
setForm((f) => ({ ...f, modelId }))
setModelValidation(null)
}
const handleFetchFill = (models: string[]) => {
setFetchedModels(models)
if (models.length >= 1) {
setForm((f) => ({ ...f, modelId: models[0] }))
setModelValidation(null)
}
}
const providerDef = PROVIDER_MAP.get(form.provider)
const commonModels = providerDef?.commonModels || []
const handleSave = async () => {
if (!model) return
if (!selectedProviderOption) {
setError(providerError)
return
}
if (!form.modelId.trim()) {
setError(t("models.add.errorRequired"))
return
}
if (modelValidation?.level === "error") return
let extraBody: Record<string, unknown> | undefined
let customHeaders: Record<string, string> | undefined
try {
if (form.extraBody.trim()) {
extraBody = JSON.parse(form.extraBody.trim())
} else {
extraBody = {}
}
} catch {
setError(
t("models.field.extraBody") + ": " + t("models.field.invalidJson"),
)
return
}
try {
if (form.customHeaders.trim()) {
customHeaders = JSON.parse(form.customHeaders.trim())
} else {
customHeaders = {}
}
} catch {
setError(
t("models.field.customHeaders") + ": " + t("models.field.invalidJson"),
)
return
}
setSaving(true)
setError("")
try {
const modelId = form.modelId.trim()
const provider = form.provider.trim()
await updateModel(model.index, {
model_name: model.model_name,
provider: form.provider.trim(),
model: form.modelId.trim(),
provider: provider,
model: modelId,
api_base: form.apiBase || undefined,
api_key: form.apiKey || undefined,
proxy: form.proxy || undefined,
auth_method: authMethodLocked
? defaultAuthMethod || undefined
: form.authMethod || undefined,
auth_method: form.authMethod || undefined,
connect_mode: form.connectMode || undefined,
workspace: form.workspace || undefined,
rpm: form.rpm ? Number(form.rpm) : undefined,
@ -244,12 +304,8 @@ export function EditModelSheet({
: undefined,
thinking_level: form.thinkingLevel || undefined,
tool_schema_transform: form.toolSchemaTransform.trim() || undefined,
extra_body: form.extraBody.trim()
? JSON.parse(form.extraBody.trim())
: {},
custom_headers: form.customHeaders.trim()
? JSON.parse(form.customHeaders.trim())
: {},
extra_body: extraBody,
custom_headers: customHeaders,
})
if (setAsDefault && !model.is_default) {
await setDefaultModel(model.model_name)
@ -270,7 +326,7 @@ export function EditModelSheet({
}
}
const isOAuth = effectiveAuthMethod === "oauth"
const isOAuth = model?.auth_method === "oauth"
const hasSavedAPIKey = Boolean(model?.api_key)
const apiKeyPlaceholder = hasSavedAPIKey
? maskedSecretPlaceholder(
@ -280,267 +336,364 @@ export function EditModelSheet({
: t("models.field.apiKeyPlaceholder")
return (
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
<SheetContent
side="right"
className="flex flex-col gap-0 p-0 data-[side=right]:!w-full data-[side=right]:sm:!w-[560px] data-[side=right]:sm:!max-w-[560px]"
>
<SheetHeader className="border-b-muted border-b px-6 py-5">
<SheetTitle className="text-base">
{t("models.edit.title", { name: model?.model_name })}
</SheetTitle>
<SheetDescription className="font-mono text-xs">
{model?.model}
</SheetDescription>
</SheetHeader>
<>
<Sheet open={open} onOpenChange={(v) => !v && onClose()}>
<SheetContent
side="right"
className="flex flex-col gap-0 p-0 data-[side=right]:!w-full data-[side=right]:sm:!w-[560px] data-[side=right]:sm:!max-w-[560px]"
>
<SheetHeader className="border-b-muted border-b px-6 py-5">
<SheetTitle className="text-base">
{t("models.edit.title", { name: model?.model_name })}
</SheetTitle>
<SheetDescription className="font-mono text-xs">
{model?.model}
</SheetDescription>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="space-y-5 px-6 py-5">
<Field
label={t("models.field.provider")}
hint={t("models.field.providerHint")}
error={providerError}
required
>
<Select
value={selectedProviderOption?.id}
onValueChange={setProvider}
<div className="min-h-0 flex-1 overflow-y-auto" ref={scrollContainerRef}>
<div className="space-y-5 px-6 py-5">
<Field
label={t("models.field.provider")}
hint={t("models.field.providerHint")}
>
<SelectTrigger
className="w-full"
aria-invalid={!!providerError}
>
<SelectValue
placeholder={t("models.field.providerPlaceholder")}
/>
</SelectTrigger>
<SelectContent>
{sortedProviderOptions.map((option) => (
<SelectItem
key={option.id}
value={option.id}
disabled={
!option.create_allowed &&
option.id !== currentProviderID
}
<ProviderCombobox
value={form.provider}
onChange={handleProviderChange}
placeholder={t("models.field.providerPlaceholder")}
backendOptions={providerOptions}
containerRef={scrollContainerRef}
/>
</Field>
<Field
label={t("models.add.modelId")}
hint={t("models.add.modelIdHint")}
>
<Input
value={form.modelId}
onChange={handleModelChange}
placeholder={
providerDef
? `${commonModels[0] || "model-name"}`
: t("models.add.modelIdPlaceholder")
}
className="font-mono text-sm"
aria-invalid={!!error || modelValidation?.level === "error"}
/>
{modelValidation && modelValidation.messageKey && (
<div
className={`flex items-center gap-2 text-xs ${
modelValidation.level === "error"
? "text-destructive"
: modelValidation.level === "warning"
? "text-yellow-600 dark:text-yellow-500"
: "text-green-600 dark:text-green-500"
}`}
>
<span>
{t(
modelValidation.messageKey,
modelValidation.messageParams,
)}
</span>
{modelValidation.fix && (
<button
type="button"
onClick={applyFix}
className="text-primary underline hover:no-underline"
>
{t("common.fix")}
</button>
)}
</div>
)}
{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.modelId === 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.modelId === m ? "default" : "outline"}
className="cursor-pointer font-mono text-xs"
onClick={() => handleCommonModel(m)}
>
{m}
</Badge>
))}
</div>
)}
<div className="flex items-center gap-2">
{form.provider && FETCHABLE_PROVIDER_KEYS.has(form.provider) && (
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setFetchOpen(true)}
>
{getProviderLabel(option.id)}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
<IconDownload className="size-3" />
{t("models.fetch.title")}
</Button>
)}
</div>
</Field>
<Field
label={t("models.add.modelId")}
hint={t("models.add.modelIdHint")}
>
<Input
value={form.modelId}
onChange={setField("modelId")}
placeholder={t("models.add.modelIdPlaceholder")}
className="font-mono text-sm"
{!isOAuth && (
<Field
label={t("models.field.apiKey")}
hint={
hasSavedAPIKey ? t("models.edit.apiKeyHint") : undefined
}
>
<KeyInput
value={form.apiKey}
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
placeholder={apiKeyPlaceholder}
/>
</Field>
)}
<Field
label={t("models.field.apiBase")}
hint={isOAuth ? t("models.edit.oauthNote") : undefined}
>
<Input
value={form.apiBase}
onChange={setField("apiBase")}
placeholder="https://api.example.com/v1"
disabled={isOAuth}
/>
</Field>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setTestOpen(true)}
disabled={!model}
>
<IconPlugConnected className="size-4" />
{t("models.test.testConnection")}
</Button>
</div>
<SwitchCardField
label={t("models.defaultOnSave.label")}
hint={t("models.defaultOnSave.description")}
checked={setAsDefault}
onCheckedChange={setSetAsDefault}
/>
</Field>
{!isOAuth && (
<Field
label={t("models.field.apiKey")}
hint={hasSavedAPIKey ? t("models.edit.apiKeyHint") : undefined}
>
<KeyInput
value={form.apiKey}
onChange={(v) => setForm((f) => ({ ...f, apiKey: v }))}
placeholder={apiKeyPlaceholder}
/>
</Field>
)}
<AdvancedSection>
<Field
label={t("models.field.proxy")}
hint={t("models.field.proxyHint")}
>
<Input
value={form.proxy}
onChange={setField("proxy")}
placeholder="http://127.0.0.1:7890"
/>
</Field>
<Field
label={t("models.field.apiBase")}
hint={isOAuth ? t("models.edit.oauthNote") : undefined}
>
<Input
value={form.apiBase}
onChange={setField("apiBase")}
placeholder={apiBasePlaceholder}
disabled={isOAuth}
/>
</Field>
<Field
label={t("models.field.authMethod")}
hint={t("models.field.authMethodHint")}
>
<Input
value={form.authMethod}
onChange={setField("authMethod")}
placeholder="oauth"
/>
</Field>
<SwitchCardField
label={t("models.defaultOnSave.label")}
hint={
willClearDefaultOnSave
? t("models.defaultOnSave.clearOnSave")
: defaultModelAllowed
? t("models.defaultOnSave.description")
: t("models.defaultOnSave.unsupportedProvider")
}
checked={setAsDefault}
onCheckedChange={setSetAsDefault}
disabled={!defaultModelAllowed}
/>
<Field
label={t("models.field.connectMode")}
hint={t("models.field.connectModeHint")}
>
<Input
value={form.connectMode}
onChange={setField("connectMode")}
placeholder="stdio"
/>
</Field>
<AdvancedSection>
<Field
label={t("models.field.proxy")}
hint={t("models.field.proxyHint")}
>
<Input
value={form.proxy}
onChange={setField("proxy")}
placeholder="http://127.0.0.1:7890"
/>
</Field>
<Field
label={t("models.field.workspace")}
hint={t("models.field.workspaceHint")}
>
<Input
value={form.workspace}
onChange={setField("workspace")}
placeholder="/path/to/workspace"
/>
</Field>
<Field
label={t("models.field.authMethod")}
hint={
authMethodLocked
? t("models.field.authMethodManagedHint")
: t("models.field.authMethodHint")
}
>
<Input
value={authMethodLocked ? defaultAuthMethod : form.authMethod}
onChange={setField("authMethod")}
placeholder="oauth"
disabled={authMethodLocked}
/>
</Field>
<Field
label={t("models.field.requestTimeout")}
hint={t("models.field.requestTimeoutHint")}
>
<Input
value={form.requestTimeout}
onChange={setField("requestTimeout")}
placeholder="60"
type="number"
min={0}
/>
</Field>
<Field
label={t("models.field.connectMode")}
hint={t("models.field.connectModeHint")}
>
<Input
value={form.connectMode}
onChange={setField("connectMode")}
placeholder="stdio"
/>
</Field>
<Field
label={t("models.field.rpm")}
hint={t("models.field.rpmHint")}
>
<Input
value={form.rpm}
onChange={setField("rpm")}
placeholder="60"
type="number"
min={0}
/>
</Field>
<Field
label={t("models.field.workspace")}
hint={t("models.field.workspaceHint")}
>
<Input
value={form.workspace}
onChange={setField("workspace")}
placeholder="/path/to/workspace"
/>
</Field>
<Field
label={t("models.field.thinkingLevel")}
hint={t("models.field.thinkingLevelHint")}
>
<Input
value={form.thinkingLevel}
onChange={setField("thinkingLevel")}
placeholder="off"
/>
</Field>
<Field
label={t("models.field.requestTimeout")}
hint={t("models.field.requestTimeoutHint")}
>
<Input
value={form.requestTimeout}
onChange={setField("requestTimeout")}
placeholder="60"
type="number"
min={0}
/>
</Field>
<Field
label={t("models.field.maxTokensField")}
hint={t("models.field.maxTokensFieldHint")}
>
<Input
value={form.maxTokensField}
onChange={setField("maxTokensField")}
placeholder="max_completion_tokens"
/>
</Field>
<Field
label={t("models.field.rpm")}
hint={t("models.field.rpmHint")}
>
<Input
value={form.rpm}
onChange={setField("rpm")}
placeholder="60"
type="number"
min={0}
/>
</Field>
<Field
label={t("models.field.extraBody")}
hint={t("models.field.extraBodyHint")}
>
<Textarea
value={form.extraBody}
onChange={setField("extraBody")}
placeholder='{"key": "value"}'
rows={3}
/>
</Field>
<Field
label={t("models.field.thinkingLevel")}
hint={t("models.field.thinkingLevelHint")}
>
<Input
value={form.thinkingLevel}
onChange={setField("thinkingLevel")}
placeholder="off"
/>
</Field>
<Field
label={t("models.field.customHeaders")}
hint={t("models.field.customHeadersHint")}
>
<Textarea
value={form.customHeaders}
onChange={setField("customHeaders")}
placeholder='{"X-Source": "coding-plan"}'
rows={3}
/>
</Field>
<Field
label={t("models.field.maxTokensField")}
hint={t("models.field.maxTokensFieldHint")}
>
<Input
value={form.maxTokensField}
onChange={setField("maxTokensField")}
placeholder="max_completion_tokens"
/>
</Field>
<Field
label={t("models.field.toolSchemaTransform")}
hint={t("models.field.toolSchemaTransformHint")}
>
<Input
value={form.toolSchemaTransform}
onChange={setField("toolSchemaTransform")}
placeholder="google"
/>
</Field>
</AdvancedSection>
<Field
label={t("models.field.toolSchemaTransform")}
hint={t("models.field.toolSchemaTransformHint")}
>
<Input
value={form.toolSchemaTransform}
onChange={setField("toolSchemaTransform")}
placeholder="google"
/>
</Field>
<Field
label={t("models.field.extraBody")}
hint={t("models.field.extraBodyHint")}
>
<Textarea
value={form.extraBody}
onChange={setField("extraBody")}
placeholder='{"key": "value"}'
rows={3}
/>
</Field>
<Field
label={t("models.field.customHeaders")}
hint={t("models.field.customHeadersHint")}
>
<Textarea
value={form.customHeaders}
onChange={setField("customHeaders")}
placeholder='{"X-Source": "coding-plan"}'
rows={3}
/>
</Field>
</AdvancedSection>
{error && (
<p className="text-destructive bg-destructive/10 rounded-md px-3 py-2 text-sm">
{error}
</p>
)}
{error && (
<p className="text-destructive bg-destructive/10 rounded-md px-3 py-2 text-sm">
{error}
</p>
)}
</div>
</div>
</div>
<SheetFooter className="border-t-muted border-t px-6 py-4">
{isDirty && (
<ConfigChangeNotice
kind="save"
title={t("common.saveChangesTitle")}
description={t("models.unsavedPrompt")}
/>
)}
<Button variant="ghost" onClick={onClose} disabled={saving}>
{t("common.cancel")}
</Button>
<Button onClick={handleSave} disabled={!isDirty || saving}>
{saving && <IconLoader2 className="size-4 animate-spin" />}
{t("common.save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
<SheetFooter className="border-t-muted border-t px-6 py-4">
{isDirty && (
<ConfigChangeNotice
kind="save"
title={t("common.saveChangesTitle")}
description={t("models.unsavedPrompt")}
/>
)}
<Button variant="ghost" onClick={onClose} disabled={saving}>
{t("common.cancel")}
</Button>
<Button
onClick={handleSave}
disabled={
!isDirty || saving || modelValidation?.level === "error"
}
>
{saving && <IconLoader2 className="size-4 animate-spin" />}
{t("common.save")}
</Button>
</SheetFooter>
</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,
}}
/>
<FetchModelsDialog
open={fetchOpen}
onClose={() => setFetchOpen(false)}
onFill={handleFetchFill}
provider={form.provider}
apiKey={form.apiKey}
apiBase={form.apiBase}
/>
</>
)
}

View file

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

View file

@ -0,0 +1,114 @@
/**
* Real-time model field validation utilities.
* All checks are pure frontend, no network required.
*
* Messages use i18n keys with interpolation params callers must
* translate them via t(key, params).
*/
import {
KNOWN_PROVIDER_KEYS,
PROVIDER_ALIASES,
findClosestProvider,
} from "./provider-registry"
export type ValidationLevel = "error" | "warning" | "success"
export interface FieldValidation {
level: ValidationLevel
messageKey: string
messageParams?: Record<string, string>
fix?: string
}
/**
* Validate a model identifier string with optional provider context.
* Returns validation result with optional one-click fix suggestion.
*/
export function validateModelField(
input: string,
selectedProvider?: string,
): FieldValidation {
const trimmed = input.trim()
if (!trimmed) return { level: "success", messageKey: "" }
// Hard errors
if (/\s/.test(trimmed)) {
return {
level: "error",
messageKey: "models.validation.whitespace",
fix: trimmed.replace(/\s+/g, "/"),
}
}
if (trimmed.startsWith("/")) {
return {
level: "error",
messageKey: "models.validation.leadingSlash",
fix: trimmed.replace(/^\/+/, ""),
}
}
if (trimmed.includes("//")) {
return {
level: "error",
messageKey: "models.validation.consecutiveSlash",
fix: trimmed.replace(/\/+/g, "/"),
}
}
const slashIdx = trimmed.indexOf("/")
if (slashIdx === -1) {
// No provider prefix — when a provider is already selected,
// the model ID is provider-local and needs no prefix.
if (selectedProvider) {
return {
level: "success",
messageKey: "models.validation.parsed",
messageParams: { provider: selectedProvider, model: trimmed },
}
}
return {
level: "warning",
messageKey: "models.validation.defaultToOpenAI",
fix: `openai/${trimmed}`,
}
}
const provider = trimmed.slice(0, slashIdx)
const model = trimmed.slice(slashIdx + 1)
if (!model) {
return { level: "error", messageKey: "models.validation.emptyModel" }
}
if (!KNOWN_PROVIDER_KEYS.has(provider)) {
// Check aliases
const alias = PROVIDER_ALIASES[provider]
if (alias) {
return {
level: "warning",
messageKey: "models.validation.shouldUse",
messageParams: { provider, alias },
fix: `${alias}/${model}`,
}
}
// Typo check
const closest = findClosestProvider(provider)
if (closest) {
return {
level: "warning",
messageKey: "models.validation.didYouMean",
messageParams: { closest },
fix: `${closest}/${model}`,
}
}
return {
level: "warning",
messageKey: "models.validation.unknownProvider",
messageParams: { provider },
}
}
return {
level: "success",
messageKey: "models.validation.parsed",
messageParams: { provider, model },
}
}

View file

@ -1,4 +1,9 @@
import { IconLoader2, IconPlus, IconStar } from "@tabler/icons-react"
import {
IconDatabase,
IconLoader2,
IconPlus,
IconStar,
} from "@tabler/icons-react"
import { useCallback, useEffect, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
@ -15,13 +20,11 @@ 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 {
PROVIDER_PRIORITY,
getProviderKey,
getProviderLabel,
} from "./provider-label"
import { getProviderKey, getProviderLabel } from "./provider-label"
import { PROVIDER_PRIORITY } from "./provider-registry"
import { ProviderSection } from "./provider-section"
interface ProviderGroup {
@ -35,19 +38,19 @@ interface ProviderGroup {
export function ModelsPage() {
const { t } = useTranslation()
const [models, setModels] = useState<ModelInfo[]>([])
const [providerOptions, setProviderOptions] = useState<ModelProviderOption[]>(
[],
)
const [providerOptions, setProviderOptions] = useState<
ModelProviderOption[]
>([])
const [loading, setLoading] = useState(true)
const [fetchError, setFetchError] = useState("")
const [editingModel, setEditingModel] = useState<ModelInfo | null>(null)
const [deletingModel, setDeletingModel] = useState<ModelInfo | null>(null)
const [addOpen, setAddOpen] = useState(false)
const [catalogOpen, setCatalogOpen] = useState(false)
const [settingDefaultIndex, setSettingDefaultIndex] = useState<number | null>(
null,
)
const addDisabled = loading || providerOptions.length === 0
const fetchModels = useCallback(async () => {
try {
@ -60,7 +63,7 @@ export function ModelsPage() {
return a.model_name.localeCompare(b.model_name)
})
setModels(sorted)
setProviderOptions(data.provider_options ?? [])
setProviderOptions(data.provider_options || [])
setFetchError("")
} catch (e) {
setFetchError(e instanceof Error ? e.message : t("models.loadError"))
@ -145,9 +148,12 @@ export function ModelsPage() {
<Button
size="sm"
variant="outline"
disabled={addDisabled}
onClick={() => setAddOpen(true)}
onClick={() => setCatalogOpen(true)}
>
<IconDatabase className="size-4" />
{t("models.catalog.button")}
</Button>
<Button size="sm" variant="outline" onClick={() => setAddOpen(true)}>
<IconPlus className="size-4" />
{t("models.add.button")}
</Button>
@ -200,18 +206,18 @@ export function ModelsPage() {
<EditModelSheet
model={editingModel}
providerOptions={providerOptions}
open={editingModel !== null}
onClose={() => setEditingModel(null)}
onSaved={fetchModels}
providerOptions={providerOptions}
/>
<AddModelSheet
open={addOpen}
providerOptions={providerOptions}
onClose={() => setAddOpen(false)}
onSaved={fetchModels}
existingModelNames={models.map((model) => model.model_name)}
providerOptions={providerOptions}
/>
<DeleteModelDialog
@ -219,6 +225,12 @@ export function ModelsPage() {
onClose={() => setDeletingModel(null)}
onDeleted={fetchModels}
/>
<CatalogDialog
open={catalogOpen}
onClose={() => setCatalogOpen(false)}
onModelAdded={fetchModels}
/>
</div>
)
}

View file

@ -0,0 +1,220 @@
import { IconCheck, IconChevronDown } from "@tabler/icons-react"
import { useState } from "react"
import { useTranslation } from "react-i18next"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import { Input } from "@/components/ui/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
import { cn } from "@/lib/utils"
import { ProviderIcon } from "./provider-icon"
import {
type MergedProvider,
PROVIDERS,
mergeWithBackendOptions,
} from "./provider-registry"
import type { ModelProviderOption } from "@/api/models"
interface ProviderComboboxProps {
value: string
onChange: (value: string) => void
placeholder?: string
backendOptions?: ModelProviderOption[]
/** When true, only show providers with create_allowed from the backend. */
filterCreateAllowed?: boolean
/** Container element for the popover portal. Use to avoid scroll conflicts inside dialogs/sheets. */
containerRef?: React.RefObject<HTMLElement | null>
}
export function ProviderCombobox({
value,
onChange,
placeholder,
backendOptions,
filterCreateAllowed,
containerRef,
}: ProviderComboboxProps) {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [customMode, setCustomMode] = useState(false)
const [customValue, setCustomValue] = useState("")
const allProviders: MergedProvider[] = backendOptions
? mergeWithBackendOptions(backendOptions)
: [...PROVIDERS]
.sort((a, b) => b.priority - a.priority)
.map((p) => ({
...p,
createAllowed: true,
defaultModelAllowed: false,
}))
const visible = filterCreateAllowed
? allProviders.filter((p) => p.createAllowed)
: allProviders
const allKeys = new Set(allProviders.map((p) => p.key))
const selected = allProviders.find((p) => p.key === value)
const isCustom = value && !allKeys.has(value)
const handleSelect = (currentValue: string) => {
if (currentValue === "__custom__") {
setCustomMode(true)
setCustomValue(isCustom ? value : "")
return
}
onChange(currentValue === value ? "" : currentValue)
setCustomMode(false)
setOpen(false)
}
const handleCustomConfirm = () => {
const trimmed = customValue.trim()
if (trimmed) {
onChange(trimmed)
}
setCustomMode(false)
setOpen(false)
}
return (
<Popover
open={open}
onOpenChange={(v) => {
setOpen(v)
if (!v) setCustomMode(false)
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between font-normal"
>
{selected ? (
<span className="flex items-center gap-2">
<ProviderIcon
providerKey={selected.key}
providerLabel={selected.label}
/>
{selected.labelZh || selected.label}
</span>
) : isCustom ? (
<span className="flex items-center gap-2 font-mono text-sm">
{value}
</span>
) : (
<span className="text-muted-foreground">
{placeholder || t("models.combobox.selectProvider")}
</span>
)}
<IconChevronDown className="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[--radix-popover-trigger-width] p-0" container={containerRef?.current}>
{customMode ? (
<div className="flex flex-col gap-2 p-2">
<Input
value={customValue}
onChange={(e) => setCustomValue(e.target.value)}
placeholder={t("models.combobox.customPlaceholder")}
className="h-8 font-mono text-sm"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") handleCustomConfirm()
if (e.key === "Escape") {
setCustomMode(false)
setOpen(false)
}
}}
/>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 flex-1 text-xs"
onClick={() => {
setCustomMode(false)
setOpen(false)
}}
>
{t("common.cancel")}
</Button>
<Button
size="sm"
className="h-7 flex-1 text-xs"
onClick={handleCustomConfirm}
disabled={!customValue.trim()}
>
{t("common.confirm")}
</Button>
</div>
</div>
) : (
<Command>
<CommandInput placeholder={t("models.combobox.searchProvider")} />
<CommandList>
<CommandEmpty>{t("models.combobox.noProvider")}</CommandEmpty>
<CommandGroup>
{visible.map((provider) => (
<CommandItem
key={provider.key}
value={provider.key}
keywords={[
provider.label,
provider.labelZh || "",
...(provider.aliases || []),
]}
onSelect={handleSelect}
>
<span className="flex items-center gap-2">
<ProviderIcon
providerKey={provider.key}
providerLabel={provider.label}
/>
<span>{provider.labelZh || provider.label}</span>
{provider.isLocal && (
<span className="text-muted-foreground text-xs">
{t("models.combobox.local")}
</span>
)}
</span>
<IconCheck
className={cn(
"ml-auto size-4",
value === provider.key ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
))}
<CommandItem
value="__custom__"
keywords={["custom", "自定义"]}
onSelect={handleSelect}
>
<span className="text-muted-foreground italic">
{t("models.combobox.custom")}
</span>
{isCustom && (
<IconCheck className="ml-auto size-4 opacity-100" />
)}
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
)}
</PopoverContent>
</Popover>
)
}

View file

@ -1,57 +1,6 @@
import { useMemo, useState } from "react"
const PROVIDER_ICON_SLUGS: Record<string, string> = {
openai: "openai",
elevenlabs: "elevenlabs",
anthropic: "anthropic",
azure: "microsoftazure",
gemini: "googlegemini",
deepseek: "deepseek",
"qwen-portal": "alibabacloud",
"qwen-intl": "alibabacloud",
groq: "groq",
openrouter: "openrouter",
nvidia: "nvidia",
cerebras: "cerebras",
volcengine: "bytedance",
"github-copilot": "githubcopilot",
ollama: "ollama",
mistral: "mistralai",
zhipu: "zhipu",
}
const PROVIDER_DOMAINS: Record<string, string> = {
openai: "openai.com",
elevenlabs: "elevenlabs.io",
anthropic: "anthropic.com",
azure: "azure.com",
gemini: "gemini.google.com",
deepseek: "deepseek.com",
"qwen-portal": "qwenlm.ai",
"qwen-intl": "alibabacloud.com",
moonshot: "moonshot.ai",
groq: "groq.com",
openrouter: "openrouter.ai",
nvidia: "nvidia.com",
cerebras: "cerebras.ai",
volcengine: "volcengine.com",
shengsuanyun: "shengsuanyun.com",
antigravity: "antigravity.google",
"github-copilot": "github.com",
ollama: "ollama.com",
lmstudio: "lmstudio.ai",
mistral: "mistral.ai",
avian: "avian.io",
vllm: "vllm.ai",
zhipu: "zhipuai.cn",
zai: "z.ai",
mimo: "xiaomi.com",
venice: "venice.ai",
vivgrid: "vivgrid.com",
minimax: "minimaxi.com",
longcat: "longcat.chat",
modelscope: "modelscope.cn",
}
import { PROVIDER_DOMAINS, PROVIDER_ICON_SLUGS } from "./provider-registry"
interface ProviderIconProps {
providerKey: string
@ -82,7 +31,7 @@ export function ProviderIcon({
if (!iconUrl || loadFailed) {
return (
<span className="inline-flex size-4 shrink-0 items-center justify-center rounded-sm border border-black/10 bg-white text-[9px] font-semibold text-black/70 dark:border-white/20 dark:text-black/70">
<span className="inline-flex size-4 shrink-0 items-center justify-center rounded-sm border border-black/10 bg-white text-[9px] font-semibold text-black/70 dark:border-white/20 dark:text-white/70">
{initial}
</span>
)

View file

@ -1,98 +1,4 @@
import type { ModelProviderOption } from "@/api/models"
const PROVIDER_LABELS: Record<string, string> = {
openai: "OpenAI",
bedrock: "AWS Bedrock",
elevenlabs: "ElevenLabs ASR",
anthropic: "Anthropic",
"anthropic-messages": "Anthropic Messages",
azure: "Azure OpenAI",
gemini: "Google Gemini",
deepseek: "DeepSeek",
"coding-plan": "Alibaba Coding Plan",
"coding-plan-anthropic": "Alibaba Coding Plan (Anthropic)",
"qwen-portal": "Qwen (阿里云)",
"qwen-intl": "Qwen International",
"qwen-us": "Qwen US",
moonshot: "Moonshot (月之暗面)",
groq: "Groq",
openrouter: "OpenRouter",
nvidia: "NVIDIA",
cerebras: "Cerebras",
volcengine: "Volcengine (火山引擎)",
shengsuanyun: "ShengsuanYun (神算云)",
antigravity: "Google Code Assist",
"github-copilot": "GitHub Copilot",
"claude-cli": "Claude CLI (local)",
"codex-cli": "Codex CLI (local)",
ollama: "Ollama (local)",
lmstudio: "LM Studio (local)",
litellm: "LiteLLM",
mistral: "Mistral AI",
avian: "Avian",
vllm: "VLLM (local)",
zhipu: "Zhipu AI (智谱)",
zai: "Z.ai",
mimo: "Xiaomi MiMo",
venice: "Venice AI",
vivgrid: "Vivgrid",
minimax: "MiniMax",
longcat: "LongCat",
modelscope: "ModelScope (魔搭社区)",
novita: "Novita AI",
}
const PROVIDER_ALIASES: Record<string, string> = {
qwen: "qwen-portal",
"qwen-international": "qwen-intl",
"dashscope-intl": "qwen-intl",
"z.ai": "zai",
"z-ai": "zai",
google: "gemini",
"google-antigravity": "antigravity",
}
export const PROVIDER_PRIORITY: Record<string, number> = {
volcengine: 0,
openai: 1,
gemini: 2,
anthropic: 3,
bedrock: 4,
elevenlabs: 5,
"anthropic-messages": 6,
zhipu: 7,
deepseek: 8,
openrouter: 9,
"qwen-portal": 10,
"qwen-intl": 11,
"qwen-us": 12,
moonshot: 13,
groq: 14,
"coding-plan": 15,
"coding-plan-anthropic": 16,
"github-copilot": 17,
antigravity: 18,
nvidia: 19,
cerebras: 20,
shengsuanyun: 21,
venice: 22,
vivgrid: 23,
minimax: 24,
longcat: 25,
modelscope: 26,
mistral: 27,
avian: 28,
novita: 29,
azure: 30,
litellm: 31,
ollama: 32,
vllm: 33,
lmstudio: 34,
"claude-cli": 35,
"codex-cli": 36,
zai: 37,
mimo: 38,
}
import { PROVIDER_ALIASES, PROVIDER_LABELS } from "./provider-registry"
export function getProviderKey(provider?: string): string {
const normalized = provider?.trim().toLowerCase()
@ -105,44 +11,4 @@ export function getProviderLabel(provider?: string): string {
return PROVIDER_LABELS[prefix] ?? prefix
}
export function findProviderOption(
provider: string | undefined,
options: ModelProviderOption[],
): ModelProviderOption | undefined {
const providerKey = getProviderKey(provider)
return options.find((option) => option.id === providerKey)
}
export function getProviderDefaultAPIBase(
provider: string | undefined,
options: ModelProviderOption[],
): string {
return findProviderOption(provider, options)?.default_api_base ?? ""
}
export function getSortedProviderOptions(
options: ModelProviderOption[],
): ModelProviderOption[] {
return [...options].sort((a, b) => {
const aPriority = PROVIDER_PRIORITY[a.id] ?? Number.MAX_SAFE_INTEGER
const bPriority = PROVIDER_PRIORITY[b.id] ?? Number.MAX_SAFE_INTEGER
if (aPriority !== bPriority) {
return aPriority - bPriority
}
return getProviderLabel(a.id).localeCompare(getProviderLabel(b.id))
})
}
export function getProviderDefaultAuthMethod(
provider: string | undefined,
options: ModelProviderOption[],
): string {
return findProviderOption(provider, options)?.default_auth_method ?? ""
}
export function isProviderAuthMethodLocked(
provider: string | undefined,
options: ModelProviderOption[],
): boolean {
return findProviderOption(provider, options)?.auth_method_locked === true
}
export { PROVIDER_LABELS, PROVIDER_ALIASES }

View file

@ -0,0 +1,520 @@
/**
* Unified provider registry single source of truth for all provider metadata.
* All consumer files (provider-label, provider-icon, models-page, add/edit sheets)
* should derive their data from this registry.
*/
import type { ModelProviderOption } from "@/api/models"
export interface ProviderDefinition {
key: string
label: string
labelZh?: string
iconSlug?: string
domain?: string
defaultApiBase?: string
requiresApiKey: boolean
isLocal: boolean
priority: number
commonModels?: string[]
aliases?: string[]
/** Whether this provider supports the OpenAI-compatible /models listing endpoint. */
supportsFetch?: boolean
}
export const PROVIDERS: ProviderDefinition[] = [
{
key: "openai",
label: "OpenAI",
iconSlug: "openai",
domain: "openai.com",
defaultApiBase: "https://api.openai.com/v1",
requiresApiKey: true,
isLocal: false,
priority: 100,
commonModels: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "o1", "o3-mini"],
aliases: ["gpt"],
supportsFetch: true,
},
{
key: "anthropic",
label: "Anthropic",
iconSlug: "anthropic",
domain: "anthropic.com",
defaultApiBase: "https://api.anthropic.com/v1",
requiresApiKey: true,
isLocal: false,
priority: 95,
commonModels: [
"claude-sonnet-4-20250514",
"claude-haiku-4-20250414",
"claude-3-5-sonnet-20241022",
],
aliases: ["claude"],
},
{
key: "gemini",
label: "Google Gemini",
iconSlug: "googlegemini",
domain: "gemini.google.com",
defaultApiBase: "https://generativelanguage.googleapis.com/v1beta",
requiresApiKey: true,
isLocal: false,
priority: 90,
commonModels: ["gemini-2.0-flash", "gemini-2.5-pro", "gemini-1.5-flash"],
aliases: ["google"],
},
{
key: "deepseek",
label: "DeepSeek",
iconSlug: "deepseek",
domain: "deepseek.com",
defaultApiBase: "https://api.deepseek.com/v1",
requiresApiKey: true,
isLocal: false,
priority: 85,
commonModels: ["deepseek-chat", "deepseek-reasoner"],
supportsFetch: true,
},
{
key: "openrouter",
label: "OpenRouter",
iconSlug: "openrouter",
domain: "openrouter.ai",
defaultApiBase: "https://openrouter.ai/api/v1",
requiresApiKey: true,
isLocal: false,
priority: 80,
commonModels: [
"openai/gpt-4o",
"anthropic/claude-sonnet-4",
"google/gemini-2.0-flash",
],
supportsFetch: true,
},
{
key: "qwen-portal",
label: "Qwen",
labelZh: "Qwen (阿里云)",
iconSlug: "alibabacloud",
domain: "qwenlm.ai",
defaultApiBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
requiresApiKey: true,
isLocal: false,
priority: 75,
commonModels: ["qwen-max", "qwen-plus", "qwen-turbo"],
aliases: ["qwen"],
supportsFetch: true,
},
{
key: "qwen-intl",
label: "Qwen International",
iconSlug: "alibabacloud",
domain: "alibabacloud.com",
defaultApiBase: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
requiresApiKey: true,
isLocal: false,
priority: 74,
commonModels: ["qwen-max", "qwen-plus", "qwen-turbo"],
aliases: ["qwen-international", "dashscope-intl"],
supportsFetch: true,
},
{
key: "moonshot",
label: "Moonshot",
labelZh: "Moonshot (月之暗面)",
domain: "moonshot.ai",
defaultApiBase: "https://api.moonshot.cn/v1",
requiresApiKey: true,
isLocal: false,
priority: 70,
commonModels: ["moonshot-v1-8k", "moonshot-v1-32k", "moonshot-v1-128k"],
supportsFetch: true,
},
{
key: "volcengine",
label: "Volcengine",
labelZh: "Volcengine (火山引擎)",
iconSlug: "bytedance",
domain: "volcengine.com",
defaultApiBase: "https://ark.cn-beijing.volces.com/api/v3",
requiresApiKey: true,
isLocal: false,
priority: 69,
commonModels: ["doubao-1.5-pro", "doubao-1.5-lite"],
supportsFetch: true,
},
{
key: "zhipu",
label: "Zhipu AI",
labelZh: "Zhipu AI (智谱)",
iconSlug: "zhipu",
domain: "zhipuai.cn",
defaultApiBase: "https://open.bigmodel.cn/api/paas/v4",
requiresApiKey: true,
isLocal: false,
priority: 68,
commonModels: ["glm-4-plus", "glm-4-flash"],
supportsFetch: true,
},
{
key: "groq",
label: "Groq",
iconSlug: "groq",
domain: "groq.com",
defaultApiBase: "https://api.groq.com/openai/v1",
requiresApiKey: true,
isLocal: false,
priority: 65,
commonModels: ["llama-3.3-70b-versatile", "mixtral-8x7b-32768"],
supportsFetch: true,
},
{
key: "mistral",
label: "Mistral AI",
iconSlug: "mistralai",
domain: "mistral.ai",
defaultApiBase: "https://api.mistral.ai/v1",
requiresApiKey: true,
isLocal: false,
priority: 64,
commonModels: ["mistral-large-latest", "mistral-small-latest"],
supportsFetch: true,
},
{
key: "nvidia",
label: "NVIDIA",
iconSlug: "nvidia",
domain: "nvidia.com",
defaultApiBase: "https://integrate.api.nvidia.com/v1",
requiresApiKey: true,
isLocal: false,
priority: 63,
commonModels: ["meta/llama-3.1-405b-instruct"],
supportsFetch: true,
},
{
key: "cerebras",
label: "Cerebras",
iconSlug: "cerebras",
domain: "cerebras.ai",
defaultApiBase: "https://api.cerebras.ai/v1",
requiresApiKey: true,
isLocal: false,
priority: 62,
commonModels: ["llama3.1-8b", "llama3.1-70b"],
supportsFetch: true,
},
{
key: "azure",
label: "Azure OpenAI",
iconSlug: "microsoftazure",
domain: "azure.com",
requiresApiKey: true,
isLocal: false,
priority: 61,
commonModels: ["gpt-4o", "gpt-4o-mini"],
},
{
key: "github-copilot",
label: "GitHub Copilot",
iconSlug: "githubcopilot",
domain: "github.com",
requiresApiKey: false,
isLocal: true,
priority: 55,
},
{
key: "antigravity",
label: "Google Code Assist",
domain: "antigravity.google",
requiresApiKey: false,
isLocal: false,
priority: 54,
},
{
key: "ollama",
label: "Ollama",
labelZh: "Ollama (本地)",
iconSlug: "ollama",
domain: "ollama.com",
defaultApiBase: "http://localhost:11434/v1",
requiresApiKey: false,
isLocal: true,
priority: 50,
commonModels: ["llama3", "mistral", "codellama", "qwen2.5"],
supportsFetch: true,
},
{
key: "vllm",
label: "VLLM",
labelZh: "VLLM (本地)",
domain: "vllm.ai",
defaultApiBase: "http://localhost:8000/v1",
requiresApiKey: false,
isLocal: true,
priority: 49,
supportsFetch: true,
},
{
key: "lmstudio",
label: "LM Studio",
labelZh: "LM Studio (本地)",
domain: "lmstudio.ai",
defaultApiBase: "http://localhost:1234/v1",
requiresApiKey: false,
isLocal: true,
priority: 48,
supportsFetch: true,
},
{
key: "venice",
label: "Venice AI",
iconSlug: "venice",
domain: "venice.ai",
defaultApiBase: "https://api.venice.ai/api/v1",
requiresApiKey: true,
isLocal: false,
priority: 45,
supportsFetch: true,
},
{
key: "shengsuanyun",
label: "ShengsuanYun",
labelZh: "ShengsuanYun (神算云)",
domain: "shengsuanyun.com",
defaultApiBase: "https://router.shengsuanyun.com/api/v1",
requiresApiKey: true,
isLocal: false,
priority: 44,
supportsFetch: true,
},
{
key: "vivgrid",
label: "Vivgrid",
domain: "vivgrid.com",
defaultApiBase: "https://api.vivgrid.com/v1",
requiresApiKey: true,
isLocal: false,
priority: 43,
supportsFetch: true,
},
{
key: "minimax",
label: "MiniMax",
domain: "minimaxi.com",
defaultApiBase: "https://api.minimaxi.com/v1",
requiresApiKey: true,
isLocal: false,
priority: 42,
supportsFetch: true,
},
{
key: "longcat",
label: "LongCat",
domain: "longcat.chat",
defaultApiBase: "https://api.longcat.chat/openai",
requiresApiKey: true,
isLocal: false,
priority: 41,
supportsFetch: true,
},
{
key: "modelscope",
label: "ModelScope",
labelZh: "ModelScope (魔搭社区)",
domain: "modelscope.cn",
defaultApiBase: "https://api-inference.modelscope.cn/v1",
requiresApiKey: true,
isLocal: false,
priority: 40,
supportsFetch: true,
},
{
key: "mimo",
label: "Xiaomi MiMo",
iconSlug: "xiaomi",
domain: "xiaomi.com",
defaultApiBase: "https://api.xiaomimimo.com/v1",
requiresApiKey: true,
isLocal: false,
priority: 39,
supportsFetch: true,
},
{
key: "avian",
label: "Avian",
domain: "avian.io",
defaultApiBase: "https://api.avian.io/v1",
requiresApiKey: true,
isLocal: false,
priority: 38,
supportsFetch: true,
},
{
key: "zai",
label: "Z.ai",
domain: "z.ai",
defaultApiBase: "https://api.z.ai/api/coding/paas/v4",
requiresApiKey: true,
isLocal: false,
priority: 37,
aliases: ["z.ai", "z-ai"],
supportsFetch: true,
},
{
key: "novita",
label: "Novita AI",
domain: "novita.ai",
defaultApiBase: "https://api.novita.ai/openai",
requiresApiKey: true,
isLocal: false,
priority: 36,
supportsFetch: true,
},
{
key: "litellm",
label: "LiteLLM",
domain: "litellm.ai",
defaultApiBase: "http://localhost:4000/v1",
requiresApiKey: true,
isLocal: false,
priority: 35,
supportsFetch: true,
},
]
// ── Derived data for consumers ───────────────────────────────────────────────
export const PROVIDER_MAP = new Map(PROVIDERS.map((p) => [p.key, p]))
export const PROVIDER_LABELS: Record<string, string> = Object.fromEntries(
PROVIDERS.map((p) => [p.key, p.labelZh || p.label]),
)
export const PROVIDER_ALIASES: Record<string, string> = Object.fromEntries(
PROVIDERS.flatMap((p) => (p.aliases || []).map((a) => [a, p.key])),
)
export const KNOWN_PROVIDER_KEYS = new Set(PROVIDERS.map((p) => p.key))
export const FETCHABLE_PROVIDER_KEYS = new Set(
PROVIDERS.filter((p) => p.supportsFetch).map((p) => p.key),
)
export const PROVIDER_ICON_SLUGS: Record<string, string> = Object.fromEntries(
PROVIDERS.filter((p) => p.iconSlug).map((p) => [p.key, p.iconSlug!]),
)
export const PROVIDER_DOMAINS: Record<string, string> = Object.fromEntries(
PROVIDERS.filter((p) => p.domain).map((p) => [p.key, p.domain!]),
)
export const PROVIDER_PRIORITY: Record<string, number> = Object.fromEntries(
PROVIDERS.map((p) => [p.key, p.priority]),
)
export const PROVIDER_API_BASES: Record<string, string> = Object.fromEntries(
PROVIDERS.filter((p) => p.defaultApiBase).map((p) => [
p.key,
p.defaultApiBase!,
]),
)
/**
* Find the closest known provider key by edit distance.
* Returns the key if distance <= 2, otherwise undefined.
*/
export function findClosestProvider(input: string): string | undefined {
const lower = input.toLowerCase()
let best: string | undefined
let bestDist = 3 // only accept distance <= 2
for (const key of KNOWN_PROVIDER_KEYS) {
const dist = editDistance(lower, key)
if (dist < bestDist) {
bestDist = dist
best = key
}
}
// Also check aliases
for (const alias of Object.keys(PROVIDER_ALIASES)) {
const dist = editDistance(lower, alias)
if (dist < bestDist) {
bestDist = dist
best = PROVIDER_ALIASES[alias]
}
}
return best
}
function editDistance(a: string, b: string): number {
const m = a.length
const n = b.length
const dp: number[][] = Array.from({ length: m + 1 }, () =>
new Array(n + 1).fill(0),
)
for (let i = 0; i <= m; i++) dp[i][0] = i
for (let j = 0; j <= n; j++) dp[0][j] = j
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] =
a[i - 1] === b[j - 1]
? dp[i - 1][j - 1]
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1])
}
}
return dp[m][n]
}
// ── Backend options merge ────────────────────────────────────────────────────
export interface MergedProvider extends ProviderDefinition {
createAllowed: boolean
defaultModelAllowed: boolean
defaultAuthMethod?: string
authMethodLocked?: boolean
}
/**
* Merge the frontend PROVIDERS registry with backend provider_options.
* Frontend provides presentation data (labels, icons, priority, etc.).
* Backend provides authoritative availability and policy fields.
*/
export function mergeWithBackendOptions(
backendOptions: ModelProviderOption[],
): MergedProvider[] {
const backendMap = new Map(backendOptions.map((o) => [o.id, o]))
const merged: MergedProvider[] = []
// Start with frontend providers, enriched with backend policy
for (const p of PROVIDERS) {
const backend = backendMap.get(p.key)
merged.push({
...p,
createAllowed: backend?.create_allowed ?? false,
defaultModelAllowed: backend?.default_model_allowed ?? false,
defaultAuthMethod: backend?.default_auth_method,
authMethodLocked: backend?.auth_method_locked,
})
if (backend) backendMap.delete(p.key)
}
// Add providers only known to the backend
for (const [key, backend] of backendMap) {
merged.push({
key,
label: key,
requiresApiKey: !backend.empty_api_key_allowed,
isLocal: backend.empty_api_key_allowed,
priority: 0,
createAllowed: backend.create_allowed,
defaultModelAllowed: backend.default_model_allowed,
defaultAuthMethod: backend.default_auth_method,
authMethodLocked: backend.auth_method_locked,
defaultApiBase: backend.default_api_base || undefined,
})
}
return merged.sort((a, b) => b.priority - a.priority)
}

View file

@ -57,7 +57,7 @@ export function ProviderSection({
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{models.map((model) => (
<ModelCard
key={model.index}
key={model.model_name}
model={model}
onEdit={onEdit}
onSetDefault={onSetDefault}

View file

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

View file

@ -93,7 +93,12 @@ interface KeyInputProps {
className?: string
}
export function KeyInput({ value, onChange, placeholder, className }: KeyInputProps) {
export function KeyInput({
value,
onChange,
placeholder,
className,
}: KeyInputProps) {
const [show, setShow] = useState(false)
return (

View file

@ -0,0 +1,149 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"
const Command = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className,
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({
children,
...props
}: React.ComponentProps<typeof Dialog>) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn(
"max-h-[300px] overflow-y-auto overflow-x-hidden",
className,
)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className,
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ComponentRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className,
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className,
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View file

@ -0,0 +1,31 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ComponentRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content> & {
container?: HTMLElement | null
}
>(({ className, align = "center", sideOffset = 4, container, ...props }, ref) => (
<PopoverPrimitive.Portal container={container}>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View file

@ -1,7 +1,6 @@
import { useEffect } from "react"
import githubDarkCss from "highlight.js/styles/github-dark.css?inline"
import githubLightCss from "highlight.js/styles/github.css?inline"
import { useEffect } from "react"
const THEME_STYLE_ID = "hljs-theme-style"
const THEME_STYLE_OWNER_ATTR = "data-picoclaw-highlight-theme"
@ -17,8 +16,9 @@ function getOrCreateThemeStyleElement(): HTMLStyleElement {
return managedStyleElement
}
const existingStyleElement =
document.querySelector<HTMLStyleElement>(ID_THEME_STYLE_SELECTOR)
const existingStyleElement = document.querySelector<HTMLStyleElement>(
ID_THEME_STYLE_SELECTOR,
)
if (existingStyleElement) {
existingStyleElement.setAttribute(
THEME_STYLE_OWNER_ATTR,

View file

@ -129,10 +129,12 @@
},
"common": {
"cancel": "Cancel",
"close": "Close",
"save": "Save",
"saving": "Saving...",
"reset": "Reset",
"confirm": "Confirm",
"fix": "Fix",
"saveChangesTitle": "You have unsaved configuration changes",
"restartRequiredTitle": "Gateway restart required",
"restartRequiredDesc": "The latest {{name}} configuration has been saved. Restart the gateway for it to take effect."
@ -236,8 +238,7 @@
"setting": "Setting as default...",
"unavailable": "Cannot set unavailable model as default",
"isDefault": "Already the default model",
"isVirtual": "Cannot set virtual model as default",
"unsupportedProvider": "This provider is ASR-only and cannot be the default chat model"
"isVirtual": "Cannot set virtual model as default"
},
"deleteDisabled": {
"isDefault": "Cannot delete the default model"
@ -245,9 +246,7 @@
},
"defaultOnSave": {
"label": "Default Model",
"description": "Automatically set this model as default after saving.",
"unsupportedProvider": "This provider can be saved in model_list, but it cannot be used as the default chat model.",
"clearOnSave": "Saving this ASR-only model will clear the current default chat model selection."
"description": "Automatically set this model as default after saving."
},
"add": {
"button": "Add Model",
@ -258,7 +257,7 @@
"modelNameHint": "A short name used to identify this model in conversations.",
"modelId": "Model Identifier",
"modelIdPlaceholder": "e.g. gpt-4o or openai/gpt-4o",
"modelIdHint": "This field is sent as the canonical model ID for the selected Provider. If the model ID itself contains slashes, such as openai/gpt-5.4, it is preserved as-is instead of being split again.",
"modelIdHint": "If Provider is not specified, values such as openai/gpt-4o are interpreted using the provider/model format. If Provider is specified, this field is treated as the canonical model ID and is not parsed for a provider prefix.",
"errorRequired": "This field is required.",
"errorDuplicateModelName": "Model alias already exists. Please use a different name.",
"saveError": "Failed to add model",
@ -275,9 +274,9 @@
},
"field": {
"provider": "Provider",
"providerPlaceholder": "Select a provider",
"providerHint": "Choose a Provider from the backend catalog. The Model Identifier field is interpreted as that Provider's canonical model ID.",
"providerInvalid": "The current Provider is invalid. Select a supported Provider.",
"providerPlaceholder": "e.g. openai",
"providerHint": "Optional. If specified, this value is used as the effective provider, and Model Identifier is interpreted as the canonical model ID.",
"selectProviderFirst": "Select a provider first",
"apiBase": "API Base URL",
"apiKey": "API Key",
"apiKeyPlaceholder": "Enter your API key",
@ -286,7 +285,6 @@
"proxyHint": "Optional. e.g. http://127.0.0.1:7890",
"authMethod": "Auth Method",
"authMethodHint": "Authentication method: oauth, token. Leave blank for API key auth.",
"authMethodManagedHint": "This Provider manages its authentication mode automatically.",
"connectMode": "Connect Mode",
"connectModeHint": "Connection mode for CLI-based providers: stdio or grpc.",
"workspace": "Workspace Path",
@ -304,7 +302,8 @@
"extraBody": "Extra Body",
"extraBodyHint": "Additional JSON fields to inject into the request body, e.g. {\"reasoning_split\": true}.",
"customHeaders": "Custom Headers",
"customHeadersHint": "Additional HTTP headers to inject into every request, e.g. {\"X-Source\": \"coding-plan\"}."
"customHeadersHint": "Additional HTTP headers to inject into every request, e.g. {\"X-Source\": \"coding-plan\"}.",
"invalidJson": "Invalid JSON format"
},
"edit": {
"title": "Configure {{name}}",
@ -312,6 +311,77 @@
"oauthNote": "This provider uses OAuth — no API key required.",
"saveError": "Failed to save",
"saveSuccess": "Model configuration saved."
},
"fetch": {
"title": "Fetch Available Models",
"description": "Fetch model list from the upstream provider.",
"providerLabel": "Provider:",
"needApiKey": "Please enter an API Key first to fetch models.",
"fetching": "Fetching models...",
"retry": "Retry",
"filterPlaceholder": "Filter models...",
"found": "Found {{count}} model",
"found_plural": "Found {{count}} models",
"shown": "({{count}} shown)",
"selectAll": "Select All",
"deselectAll": "Deselect All",
"fill": "Fill {{count}} Selected Model",
"fill_plural": "Fill {{count}} Selected Models",
"failed": "Failed to fetch models"
},
"catalog": {
"button": "Saved Catalogs",
"title": "Saved Model Catalogs",
"description": "Previously fetched model lists, stored per API key. Select models to add to your configuration.",
"loading": "Loading catalogs...",
"empty": "No saved catalogs yet. Fetch models from a provider to save a catalog.",
"filterPlaceholder": "Filter models...",
"models": "models",
"fetchedAt": "Fetched",
"delete": "Delete catalog",
"refresh": "Refresh from upstream",
"found": "Found {{count}} model",
"found_plural": "Found {{count}} models",
"selectAll": "Select All",
"deselectAll": "Deselect All",
"addSelected": "Add {{count}} Selected",
"addSuccess": "Added {{count}} model(s) to configuration.",
"needApiKey": "These models require an API key. You'll need to configure credentials after import."
},
"test": {
"title": "Test Model Connectivity",
"description": "Verify that the model endpoint is reachable and configured correctly.",
"modelLabel": "Model:",
"identifierLabel": "Identifier:",
"endpointLabel": "Endpoint:",
"testConnection": "Test Connection",
"testing": "Testing connection...",
"success": "Connection successful",
"responseTime": "Response time: {{ms}}ms",
"failed": "Connection failed",
"status": "Status: {{status}}",
"testFailed": "Test failed",
"testAgain": "Test Again"
},
"validation": {
"whitespace": "Model identifier cannot contain whitespace",
"leadingSlash": "Should not start with /",
"consecutiveSlash": "Should not contain consecutive /",
"useProvider": "Will use \"{{provider}}\" as provider",
"defaultToOpenAI": "No provider specified, defaults to OpenAI",
"emptyModel": "Model name cannot be empty",
"shouldUse": "\"{{provider}}\" should use \"{{alias}}\"",
"didYouMean": "Did you mean \"{{closest}}\"?",
"unknownProvider": "Unknown provider \"{{provider}}\"",
"parsed": "provider={{provider}}, model={{model}}"
},
"combobox": {
"selectProvider": "Select provider...",
"searchProvider": "Search provider...",
"noProvider": "No provider found.",
"local": "local",
"custom": "Custom provider...",
"customPlaceholder": "Enter provider name..."
}
},
"channels": {

View file

@ -129,13 +129,15 @@
},
"common": {
"cancel": "取消",
"close": "关闭",
"save": "保存",
"saving": "保存中...",
"reset": "重置",
"confirm": "确认",
"saveChangesTitle": "有未保存的配置更改",
"restartRequiredTitle": "需要重启服务",
"restartRequiredDesc": "{{name}} 的最新配置已保存。重启服务后才能正式生效。"
"restartRequiredDesc": "{{name}} 的最新配置已保存。重启服务后才能正式生效。",
"fix": "修复"
},
"labels": {
"loading": "加载中..."
@ -304,7 +306,9 @@
"extraBody": "Extra Body",
"extraBodyHint": "要注入到请求体中的额外 JSON 字段,例如 {\"reasoning_split\": true}。",
"customHeaders": "Custom Headers",
"customHeadersHint": "要注入到每个请求中的额外 HTTP Headers例如 {\"X-Source\": \"coding-plan\"}。"
"customHeadersHint": "要注入到每个请求中的额外 HTTP Headers例如 {\"X-Source\": \"coding-plan\"}。",
"selectProviderFirst": "请先选择服务商",
"invalidJson": "JSON 格式不正确"
},
"edit": {
"title": "配置 {{name}}",
@ -312,6 +316,74 @@
"oauthNote": "该服务商使用 OAuth 认证,无需 API Key。",
"saveError": "保存失败",
"saveSuccess": "模型配置已保存。"
},
"fetch": {
"title": "获取可用模型",
"description": "从上游服务商获取模型列表。",
"providerLabel": "服务商:",
"needApiKey": "请先输入 API Key 再获取模型。",
"fetching": "正在获取模型...",
"retry": "重试",
"filterPlaceholder": "筛选模型...",
"found": "已找到 {{count}} 个模型",
"shown": "(显示 {{count}} 个)",
"selectAll": "全选",
"deselectAll": "取消全选",
"fill": "填充 {{count}} 个选中的模型",
"failed": "获取模型失败"
},
"catalog": {
"button": "已保存目录",
"title": "已保存的模型目录",
"description": "之前获取的模型列表,按 API Key 分别存储。选择模型以添加到配置中。",
"loading": "正在加载目录...",
"empty": "暂无已保存的模型目录。从服务商获取模型后将自动保存。",
"filterPlaceholder": "筛选模型...",
"models": "个模型",
"fetchedAt": "获取于",
"delete": "删除目录",
"refresh": "从上游刷新",
"found": "共 {{count}} 个模型",
"selectAll": "全选",
"deselectAll": "取消全选",
"addSelected": "添加 {{count}} 个选中模型",
"addSuccess": "已添加 {{count}} 个模型到配置中。",
"needApiKey": "这些模型需要 API Key。导入后需要配置凭证才能使用。"
},
"test": {
"title": "测试模型连通性",
"description": "验证模型端点是否可达且配置正确。",
"modelLabel": "模型:",
"identifierLabel": "标识符:",
"endpointLabel": "端点:",
"testConnection": "测试连接",
"testing": "正在测试连接...",
"success": "连接成功",
"responseTime": "响应时间:{{ms}}ms",
"failed": "连接失败",
"status": "状态:{{status}}",
"testFailed": "测试失败",
"testAgain": "重新测试"
},
"validation": {
"whitespace": "模型标识符不能包含空格",
"leadingSlash": "不应以 / 开头",
"consecutiveSlash": "不应包含连续的 /",
"useProvider": "将使用 \"{{provider}}\" 作为服务商",
"defaultToOpenAI": "未指定服务商,默认使用 OpenAI",
"emptyModel": "模型名称不能为空",
"shouldUse": "\"{{provider}}\" 应使用 \"{{alias}}\"",
"didYouMean": "您是否想输入 \"{{closest}}\"",
"unknownProvider": "未知服务商 \"{{provider}}\"",
"parsed": "服务商={{provider}},模型={{model}}"
},
"combobox": {
"selectProvider": "选择服务商...",
"searchProvider": "搜索服务商...",
"noProvider": "未找到服务商。",
"local": "本地",
"custom": "自定义服务商...",
"customPlaceholder": "输入服务商名称..."
}
},
"channels": {