From d8b124fc7c1fbfa5e9f854e85f55cf54925a324c Mon Sep 17 00:00:00 2001 From: "Logan.Li" Date: Thu, 30 Jul 2026 21:34:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9B=B4=E6=96=B0=E4=B8=BB=E6=B5=81?= =?UTF-8?q?=E5=A4=A7=E6=A8=A1=E5=9E=8B=E4=BE=9B=E5=BA=94=E5=95=86=E7=9B=AE?= =?UTF-8?q?=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 按云服务、Coding Plan、API 网关和本地模型分组 - 新增兼容模型发现与供应商协议校验 --- backend/controller/llm.go | 77 ++++-- backend/controller/llm_chat.go | 10 +- backend/controller/llm_models.go | 181 +++++++++++++ backend/controller/llm_models_test.go | 96 +++++++ backend/controller/llm_provider_test.go | 70 +++++ backend/model/llm.go | 34 +-- backend/pkg/agentcli/llm/anthropic.go | 12 +- backend/pkg/agentcli/llm/openai.go | 46 ++-- .../pkg/agentcli/llm/provider_compat_test.go | 39 +++ backend/router/routes.go | 1 + frontend/src/api/aiops/assistant.ts | 8 + frontend/src/components/aiops/LLMConfig.tsx | 254 ++++++------------ frontend/src/components/aiops/llmProviders.ts | 193 +++++++++++++ frontend/src/locales/en-US.json | 27 +- frontend/src/locales/zh-CN.json | 27 +- 15 files changed, 838 insertions(+), 237 deletions(-) create mode 100644 backend/controller/llm_models.go create mode 100644 backend/controller/llm_models_test.go create mode 100644 backend/controller/llm_provider_test.go create mode 100644 backend/pkg/agentcli/llm/provider_compat_test.go create mode 100644 frontend/src/components/aiops/llmProviders.ts diff --git a/backend/controller/llm.go b/backend/controller/llm.go index b524ff2..ca927bd 100644 --- a/backend/controller/llm.go +++ b/backend/controller/llm.go @@ -34,10 +34,20 @@ func maskAPIKey(key string) string { return strings.Repeat("*", len(key)-4) + key[len(key)-4:] } +// canReuseMaskedAPIKey 仅允许原提供商与原 Endpoint 继续使用数据库中的密钥。 // Logan.Li +func canReuseMaskedAPIKey(provider, endpoint string, saved model.LLMConfig) bool { + if saved.ID == 0 || saved.Provider != provider { + return false + } + requestedEndpoint := resolveEndpoint(provider, strings.TrimSpace(endpoint)) + savedEndpoint := resolveEndpoint(saved.Provider, strings.TrimSpace(saved.Endpoint)) + return requestedEndpoint != "" && requestedEndpoint == savedEndpoint +} + // getActiveConfig 读取启用的配置(脱敏后) // Logan.Li func (h *LLMHandler) getActiveConfig(maskKey bool) (model.LLMConfig, error) { var cfg model.LLMConfig - if err := h.db.First(&cfg).Error; err != nil { + if err := h.db.Take(&cfg).Error; err != nil { return cfg, err } if maskKey { @@ -60,17 +70,17 @@ func (h *LLMHandler) GetLLMConfig(c *gin.Context) { return } c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{ - "id": cfg.ID, - "provider": cfg.Provider, - "model": cfg.Model, - "api_key": cfg.APIKey, - "endpoint": cfg.Endpoint, - "temperature": cfg.Temperature, - "max_tokens": cfg.MaxTokens, - "system_prompt": cfg.SystemPrompt, - "enabled": cfg.Enabled, + "id": cfg.ID, + "provider": cfg.Provider, + "model": cfg.Model, + "api_key": cfg.APIKey, + "endpoint": cfg.Endpoint, + "temperature": cfg.Temperature, + "max_tokens": cfg.MaxTokens, + "system_prompt": cfg.SystemPrompt, + "enabled": cfg.Enabled, "reasoning_enabled": cfg.ReasoningEnabled, - "configured": true, + "configured": true, }}) } @@ -83,9 +93,13 @@ func (h *LLMHandler) SaveLLMConfig(c *gin.Context) { return } var cfg model.LLMConfig - h.db.First(&cfg) - // 若前端回传脱敏值(含 ****),保留原 Key - if strings.Contains(req.APIKey, "****") && cfg.ID != 0 { + h.db.Take(&cfg) + // 脱敏密钥只能在提供商和 Endpoint 均未改变时复用,避免凭证被转发到其他地址。 // Logan.Li + if strings.Contains(req.APIKey, "****") { + if !canReuseMaskedAPIKey(req.Provider, req.Endpoint, cfg) { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "提供商或 Endpoint 已变化,请重新填写 API Key"}) + return + } req.APIKey = cfg.APIKey } cfg.Provider = req.Provider @@ -137,12 +151,18 @@ func resolveEndpoint(provider, endpoint string) string { return "https://open.bigmodel.cn/api/paas/v4" case "qwen": return "https://dashscope.aliyuncs.com/compatible-mode/v1" + case "hunyuan": // 腾讯混元 OpenAI 兼容端点 // Logan.Li + return "https://tokenhub.tencentmaas.com/v1" + case "volcengine": // 火山引擎方舟 OpenAI 兼容端点 // Logan.Li + return "https://ark.cn-beijing.volces.com/api/v3" + case "baidu": // 百度千帆 v2 OpenAI 兼容端点 // Logan.Li + return "https://qianfan.baidubce.com/v2" case "moonshot": return "https://api.moonshot.cn/v1" case "kimi": // Kimi 编码套餐 OpenAI 兼容端点(注意:该端点有客户端白名单,通用调用可能被拒) // Logan.Li return "https://api.kimi.com/coding/v1" case "mimo": // 小米 MiMo OpenAI 兼容端点 // Logan.Li - return "https://api.mimo.mi.com/v1" + return "https://api.xiaomimimo.com/v1" case "anthropic": // Anthropic 官方 Messages 协议(anthropic.go 客户端拼 /v1/messages) // Logan.Li return "https://api.anthropic.com" case "glm-coding": // 智谱 GLM 编码套餐 Anthropic 协议端点(模型 GLM-5.2) // Logan.Li @@ -153,20 +173,43 @@ func resolveEndpoint(provider, endpoint string) string { return "https://api.minimaxi.com/anthropic" case "openai": // OpenAI 官方端点 // Logan.Li return "https://api.openai.com/v1" + case "google": // Gemini OpenAI 兼容端点 // Logan.Li + return "https://generativelanguage.googleapis.com/v1beta/openai" + case "xai": // xAI OpenAI 兼容端点 // Logan.Li + return "https://api.x.ai/v1" + case "mistral": // Mistral OpenAI 兼容端点 // Logan.Li + return "https://api.mistral.ai/v1" + case "groq": // Groq OpenAI 兼容端点 // Logan.Li + return "https://api.groq.com/openai/v1" + case "openrouter": // OpenRouter OpenAI 兼容端点 // Logan.Li + return "https://openrouter.ai/api/v1" + case "together": // Together AI OpenAI 兼容端点 // Logan.Li + return "https://api.together.ai/v1" + case "cerebras": // Cerebras OpenAI 兼容端点 // Logan.Li + return "https://api.cerebras.ai/v1" case "siliconflow": // 硅基流动 OpenAI 兼容端点 // Logan.Li return "https://api.siliconflow.cn/v1" case "ollama": return "http://localhost:11434/v1" case "vllm": // vLLM OpenAI 兼容端点 // Logan.Li return "http://localhost:8000/v1" - default: // openai / custom + case "lmstudio": // LM Studio OpenAI 兼容端点 // Logan.Li + return "http://localhost:1234/v1" + case "new-api", "custom": // 网关地址必须由用户明确填写 // Logan.Li + return "" + default: // 未知的历史供应商保持 OpenAI 默认兼容 // Logan.Li return "https://api.openai.com/v1" } } // isLocalLLMProvider 本地/自建模型,API Key 可选 // Logan.Li func isLocalLLMProvider(provider string) bool { - return provider == "ollama" || provider == "vllm" || provider == "custom" + return provider == "ollama" || provider == "vllm" || provider == "lmstudio" || provider == "custom" +} + +// requiresExplicitEndpoint 判断是否必须手动填写网关地址。 // Logan.Li +func requiresExplicitEndpoint(provider string) bool { + return provider == "new-api" || provider == "custom" } // resolveProtocol 根据提供商推断接入协议(决定走 OpenAI 还是 Anthropic 客户端) // Logan.Li diff --git a/backend/controller/llm_chat.go b/backend/controller/llm_chat.go index 98612b1..cf05bdb 100644 --- a/backend/controller/llm_chat.go +++ b/backend/controller/llm_chat.go @@ -195,10 +195,11 @@ func (h *LLMHandler) llmConfigFromTestRequest(req model.SaveLLMConfigRequest) (m } if strings.Contains(req.APIKey, "****") { var saved model.LLMConfig - if err := h.db.First(&saved).Error; err != nil { + if err := h.db.Take(&saved).Error; err != nil || !canReuseMaskedAPIKey(req.Provider, req.Endpoint, saved) { if !isLocalLLMProvider(req.Provider) { - return cfg, "请填写 API Key" + return cfg, "提供商或 Endpoint 已变化,请重新填写 API Key" } + cfg.APIKey = "" } else { cfg.APIKey = saved.APIKey } @@ -209,6 +210,9 @@ func (h *LLMHandler) llmConfigFromTestRequest(req model.SaveLLMConfigRequest) (m if cfg.Model == "" { return cfg, "请填写模型名" } + if requiresExplicitEndpoint(cfg.Provider) && strings.TrimSpace(cfg.Endpoint) == "" { + return cfg, "请填写 Endpoint" + } if cfg.APIKey == "" && !isLocalLLMProvider(cfg.Provider) { return cfg, "请填写 API Key" } @@ -226,7 +230,7 @@ func (h *LLMHandler) TestLLMConnection(c *gin.Context) { if req.Provider != "" { cfg, errMsg = h.llmConfigFromTestRequest(req) } else { - if err := h.db.First(&cfg).Error; err != nil { + if err := h.db.Take(&cfg).Error; err != nil { c.JSON(http.StatusOK, model.Response{Code: 400, Message: "请先填写配置并测试连接"}) return } diff --git a/backend/controller/llm_models.go b/backend/controller/llm_models.go new file mode 100644 index 0000000..387b3d8 --- /dev/null +++ b/backend/controller/llm_models.go @@ -0,0 +1,181 @@ +package controller + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "ezops/model" + + "github.com/gin-gonic/gin" +) + +const maxLLMModelListBytes = 2 << 20 + +type llmModelListItem struct { + ID string `json:"id"` + Name string `json:"name"` + Model string `json:"model"` +} + +// resolveModelListCredential 解析草稿配置,并在前端回传脱敏值时复用已保存密钥。 // Logan.Li +func (h *LLMHandler) resolveModelListCredential(req model.SaveLLMConfigRequest) (string, string, string) { + provider := strings.TrimSpace(req.Provider) + if provider == "" { + return "", "", "请选择提供商" + } + endpoint := resolveEndpoint(provider, strings.TrimSpace(req.Endpoint)) + if endpoint == "" { + return "", "", "请填写 Endpoint" + } + apiKey := strings.TrimSpace(req.APIKey) + if strings.Contains(apiKey, "****") { + var saved model.LLMConfig + if err := h.db.Take(&saved).Error; err != nil || !canReuseMaskedAPIKey(provider, req.Endpoint, saved) { + return "", "", "提供商或 Endpoint 已变化,请重新填写 API Key" + } + apiKey = saved.APIKey + } + if apiKey == "" && !isLocalLLMProvider(provider) { + return "", "", "请填写 API Key" + } + return endpoint, apiKey, "" +} + +// buildLLMModelListURL 将基础地址转换为兼容协议的模型列表地址。 // Logan.Li +func buildLLMModelListURL(provider, endpoint string) (string, error) { + base := strings.TrimRight(strings.TrimSpace(endpoint), "/") + if resolveProtocol(provider) == "anthropic" && !strings.HasSuffix(base, "/v1") { + base += "/v1" + } + parsed, err := url.Parse(base + "/models") + if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return "", fmt.Errorf("Endpoint 必须是有效的 HTTP 或 HTTPS 地址") + } + return parsed.String(), nil +} + +// decodeLLMModelItems 兼容 OpenAI/New API 的 data 数组及部分本地服务的 models 数组。 // Logan.Li +func decodeLLMModelItems(raw json.RawMessage) []string { + var items []llmModelListItem + if err := json.Unmarshal(raw, &items); err == nil { + models := make([]string, 0, len(items)) + for _, item := range items { + name := strings.TrimSpace(item.ID) + if name == "" { + name = strings.TrimSpace(item.Name) + } + if name == "" { + name = strings.TrimSpace(item.Model) + } + if name != "" { + models = append(models, name) + } + } + return models + } + var names []string + _ = json.Unmarshal(raw, &names) + return names +} + +// parseLLMModelList 提取、去重并排序上游返回的模型 ID。 // Logan.Li +func parseLLMModelList(raw []byte) ([]string, error) { + var payload map[string]json.RawMessage + if err := json.Unmarshal(raw, &payload); err != nil { + return nil, fmt.Errorf("解析模型列表失败: %w", err) + } + models := decodeLLMModelItems(payload["data"]) + models = append(models, decodeLLMModelItems(payload["models"])...) + unique := make(map[string]struct{}, len(models)) + result := make([]string, 0, len(models)) + for _, item := range models { + name := strings.TrimSpace(item) + if name == "" { + continue + } + if _, exists := unique[name]; exists { + continue + } + unique[name] = struct{}{} + result = append(result, name) + } + sort.Strings(result) + return result, nil +} + +// requestLLMModelList 请求上游模型目录,禁止跨域重定向以免凭证被转发。 // Logan.Li +func requestLLMModelList(ctx context.Context, provider, endpoint, apiKey string) ([]string, error) { + requestURL, err := buildLLMModelListURL(provider, endpoint) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) + if err != nil { + return nil, fmt.Errorf("构造模型列表请求失败: %w", err) + } + req.Header.Set("Accept", "application/json") + if apiKey != "" { + req.Header.Set("Authorization", "Bearer "+apiKey) + } + if resolveProtocol(provider) == "anthropic" { + req.Header.Set("x-api-key", apiKey) + req.Header.Set("anthropic-version", "2023-06-01") + } + client := &http.Client{ + Timeout: 20 * time.Second, + CheckRedirect: func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("读取模型列表失败: %w", err) + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxLLMModelListBytes+1)) + if err != nil { + return nil, fmt.Errorf("读取模型列表响应失败: %w", err) + } + if len(raw) > maxLLMModelListBytes { + return nil, fmt.Errorf("模型列表响应超过 2 MiB 限制") + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + message := []rune(strings.TrimSpace(string(raw))) + if len(message) > 1000 { + message = message[:1000] + } + return nil, fmt.Errorf("模型服务返回 HTTP %d: %s", resp.StatusCode, string(message)) + } + return parseLLMModelList(raw) +} + +// ListLLMModels 从当前草稿 Endpoint 读取可用模型,便于 New API/One API 和本地服务配置。 // Logan.Li +// POST /api/v1/aiops/llm-config/models // Logan.Li +func (h *LLMHandler) ListLLMModels(c *gin.Context) { + var req model.SaveLLMConfigRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, model.Response{Code: 400, Message: "请求参数错误: " + err.Error()}) + return + } + endpoint, apiKey, errMsg := h.resolveModelListCredential(req) + if errMsg != "" { + c.JSON(http.StatusOK, model.Response{Code: 400, Message: errMsg}) + return + } + models, err := requestLLMModelList(c.Request.Context(), req.Provider, endpoint, apiKey) + if err != nil { + c.JSON(http.StatusOK, model.Response{Code: 502, Message: err.Error()}) + return + } + c.JSON(http.StatusOK, model.Response{Code: 200, Data: gin.H{ + "models": models, + "total": len(models), + }}) +} diff --git a/backend/controller/llm_models_test.go b/backend/controller/llm_models_test.go new file mode 100644 index 0000000..ec67eaa --- /dev/null +++ b/backend/controller/llm_models_test.go @@ -0,0 +1,96 @@ +package controller + +import ( + "context" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "ezops/model" + + "github.com/glebarez/sqlite" + "gorm.io/gorm" +) + +func TestParseLLMModelList(t *testing.T) { + raw := []byte(`{"data":[{"id":"model-b"},{"id":"model-a"},{"id":"model-b"}],"models":[{"name":"model-c"}]}`) + got, err := parseLLMModelList(raw) + if err != nil { + t.Fatalf("parseLLMModelList() error = %v", err) + } + want := []string{"model-a", "model-b", "model-c"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("parseLLMModelList() = %#v, want %#v", got, want) + } +} + +func TestRequestLLMModelListForOpenAICompatibleGateway(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" { + t.Errorf("request path = %q, want /v1/models", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer test-key" { + t.Errorf("Authorization = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"gpt-test"}]}`)) + })) + defer server.Close() + + models, err := requestLLMModelList(context.Background(), "new-api", server.URL+"/v1", "test-key") + if err != nil { + t.Fatalf("requestLLMModelList() error = %v", err) + } + if !reflect.DeepEqual(models, []string{"gpt-test"}) { + t.Fatalf("requestLLMModelList() = %#v", models) + } +} + +func TestRequestLLMModelListForAnthropic(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/models" { + t.Errorf("request path = %q, want /v1/models", r.URL.Path) + } + if got := r.Header.Get("x-api-key"); got != "anthropic-key" { + t.Errorf("x-api-key = %q", got) + } + if got := r.Header.Get("anthropic-version"); got != "2023-06-01" { + t.Errorf("anthropic-version = %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":[{"id":"claude-test"}]}`)) + })) + defer server.Close() + + models, err := requestLLMModelList(context.Background(), "anthropic", server.URL, "anthropic-key") + if err != nil { + t.Fatalf("requestLLMModelList() error = %v", err) + } + if !reflect.DeepEqual(models, []string{"claude-test"}) { + t.Fatalf("requestLLMModelList() = %#v", models) + } +} + +func TestResolveModelListCredentialDoesNotReuseKeyAcrossProviders(t *testing.T) { + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open database: %v", err) + } + if err := db.AutoMigrate(&model.LLMConfig{}); err != nil { + t.Fatalf("migrate database: %v", err) + } + if err := db.Create(&model.LLMConfig{Provider: "deepseek", APIKey: "deepseek-secret"}).Error; err != nil { + t.Fatalf("create config: %v", err) + } + + handler := NewLLMHandler(db) + _, _, errMsg := handler.resolveModelListCredential(model.SaveLLMConfigRequest{ + Provider: "new-api", + Endpoint: "https://new-api.example.com/v1", + APIKey: "****cret", + }) + if errMsg != "提供商或 Endpoint 已变化,请重新填写 API Key" { + t.Fatalf("provider mismatch error = %q", errMsg) + } +} diff --git a/backend/controller/llm_provider_test.go b/backend/controller/llm_provider_test.go new file mode 100644 index 0000000..931fa21 --- /dev/null +++ b/backend/controller/llm_provider_test.go @@ -0,0 +1,70 @@ +package controller + +import ( + "testing" + + "ezops/model" +) + +func TestResolveEndpoint(t *testing.T) { + tests := []struct { + provider string + endpoint string + want string + }{ + {provider: "google", want: "https://generativelanguage.googleapis.com/v1beta/openai"}, + {provider: "openrouter", want: "https://openrouter.ai/api/v1"}, + {provider: "hunyuan", want: "https://tokenhub.tencentmaas.com/v1"}, + {provider: "mimo", want: "https://api.xiaomimimo.com/v1"}, + {provider: "together", want: "https://api.together.ai/v1"}, + {provider: "lmstudio", want: "http://localhost:1234/v1"}, + {provider: "new-api", want: ""}, + {provider: "new-api", endpoint: "https://llm.example.com/v1/", want: "https://llm.example.com/v1"}, + } + + for _, test := range tests { + t.Run(test.provider+test.endpoint, func(t *testing.T) { + if got := resolveEndpoint(test.provider, test.endpoint); got != test.want { + t.Fatalf("resolveEndpoint() = %q, want %q", got, test.want) + } + }) + } +} + +func TestResolveProtocol(t *testing.T) { + for _, provider := range []string{"anthropic", "glm-coding", "minimax-coding"} { + if got := resolveProtocol(provider); got != "anthropic" { + t.Fatalf("resolveProtocol(%q) = %q, want anthropic", provider, got) + } + } + for _, provider := range []string{"openai", "google", "openrouter", "new-api"} { + if got := resolveProtocol(provider); got != "openai" { + t.Fatalf("resolveProtocol(%q) = %q, want openai", provider, got) + } + } +} + +func TestProviderRequirements(t *testing.T) { + if !isLocalLLMProvider("lmstudio") { + t.Fatal("LM Studio should allow an empty API key") + } + if isLocalLLMProvider("new-api") { + t.Fatal("New API should require an API key") + } + if !requiresExplicitEndpoint("new-api") || !requiresExplicitEndpoint("custom") { + t.Fatal("API gateway providers should require an explicit endpoint") + } +} + +func TestCanReuseMaskedAPIKey(t *testing.T) { + saved := model.LLMConfig{ID: 1, Provider: "openai", Endpoint: "https://api.openai.com/v1"} + if !canReuseMaskedAPIKey("openai", "https://api.openai.com/v1/", saved) { + t.Fatal("same provider and endpoint should reuse the saved key") + } + if canReuseMaskedAPIKey("openai", "https://gateway.example.com/v1", saved) { + t.Fatal("changed endpoint must not reuse the saved key") + } + if canReuseMaskedAPIKey("new-api", saved.Endpoint, saved) { + t.Fatal("changed provider must not reuse the saved key") + } +} diff --git a/backend/model/llm.go b/backend/model/llm.go index 7efd4ea..db5e867 100644 --- a/backend/model/llm.go +++ b/backend/model/llm.go @@ -3,18 +3,18 @@ package model import "time" // LLMConfig 大语言模型配置(单行配置,类似 LDAPConfig) // Logan.Li -// 支持 OpenAI 兼容接口(OpenAI / DeepSeek / 智谱GLM / 通义千问 / Moonshot / Kimi / 小米MiMo / Anthropic(需代理) / Ollama / 自定义) +// 支持 Anthropic Messages 协议与主流 OpenAI 兼容云服务、API 网关及本地模型。 // Logan.Li type LLMConfig struct { - ID uint `gorm:"primarykey" json:"id"` - Provider string `gorm:"size:50;not null;default:openai" json:"provider"` // deepseek/glm/glm-coding/kimi/moonshot/minimax/minimax-coding/mimo/siliconflow/openai/anthropic/ollama/vllm/custom - Model string `gorm:"size:100;not null;default:gpt-4o-mini" json:"model"` // 模型名,如 gpt-4o-mini / deepseek-v4-flash - APIKey string `gorm:"size:500" json:"api_key"` // API Key(GET 时会被脱敏) - Endpoint string `gorm:"size:500" json:"endpoint"` // 基础地址,如 https://api.openai.com/v1 - Temperature float64 `gorm:"default:0.7" json:"temperature"` // 采样温度 0-2 - MaxTokens int `gorm:"default:2000" json:"max_tokens"` // 最大输出 token - SystemPrompt string `gorm:"type:text" json:"system_prompt"` // 系统提示词(AIOps 运维助手角色) - Enabled bool `gorm:"default:false" json:"enabled"` // 是否启用 - ReasoningEnabled bool `gorm:"default:false" json:"reasoning_enabled"` // 是否启用推理/Plan 模式(OpenAI o-series / DeepSeek R1 / Claude thinking 等) // Logan.Li + ID uint `gorm:"primarykey" json:"id"` + Provider string `gorm:"size:50;not null;default:openai" json:"provider"` // 供应商标识,包含云服务、网关与本地模型 // Logan.Li + Model string `gorm:"size:100;not null;default:gpt-4o-mini" json:"model"` // 模型名,如 gpt-4o-mini / deepseek-v4-flash + APIKey string `gorm:"size:500" json:"api_key"` // API Key(GET 时会被脱敏) + Endpoint string `gorm:"size:500" json:"endpoint"` // 基础地址,如 https://api.openai.com/v1 + Temperature float64 `gorm:"default:0.7" json:"temperature"` // 采样温度 0-2 + MaxTokens int `gorm:"default:2000" json:"max_tokens"` // 最大输出 token + SystemPrompt string `gorm:"type:text" json:"system_prompt"` // 系统提示词(AIOps 运维助手角色) + Enabled bool `gorm:"default:false" json:"enabled"` // 是否启用 + ReasoningEnabled bool `gorm:"default:false" json:"reasoning_enabled"` // 是否启用推理/Plan 模式(OpenAI o-series / DeepSeek R1 / Claude thinking 等) // Logan.Li CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } @@ -26,7 +26,7 @@ func (LLMConfig) TableName() string { return "ai_llm_configs" } type SaveLLMConfigRequest struct { Provider string `json:"provider"` Model string `json:"model"` - APIKey string `json:"api_key"` // 若为脱敏值(含 ****)则后端保留原值 + APIKey string `json:"api_key"` // 若为脱敏值(含 ****)则后端保留原值 Endpoint string `json:"endpoint"` Temperature float64 `json:"temperature"` MaxTokens int `json:"max_tokens"` @@ -37,7 +37,7 @@ type SaveLLMConfigRequest struct { // ChatMessage 对话消息 // Logan.Li type ChatMessage struct { - Role string `json:"role"` // system / user / assistant + Role string `json:"role"` // system / user / assistant Content string `json:"content"` } @@ -58,11 +58,11 @@ type ChatResponse struct { // 仅持久化成功完成的对话(用户消息 + 助手回复成对写入),错误气泡为瞬时 UI 不入库 type ChatHistory struct { ID uint `gorm:"primarykey" json:"id"` - UserID uint `gorm:"index;not null" json:"user_id"` // 所属用户(JWT 中间件注入) + UserID uint `gorm:"index;not null" json:"user_id"` // 所属用户(JWT 中间件注入) SessionID string `gorm:"size:64;index;not null;default:''" json:"session_id"` // 对话会话 ID(前端 UUID) - Role string `gorm:"size:16;not null" json:"role"` // user / assistant - Content string `gorm:"type:text;not null" json:"content"` // 消息内容 - Model string `gorm:"size:100" json:"model"` // 生成回复所用模型(仅 assistant 行有意义) + Role string `gorm:"size:16;not null" json:"role"` // user / assistant + Content string `gorm:"type:text;not null" json:"content"` // 消息内容 + Model string `gorm:"size:100" json:"model"` // 生成回复所用模型(仅 assistant 行有意义) CreatedAt time.Time `json:"created_at"` } diff --git a/backend/pkg/agentcli/llm/anthropic.go b/backend/pkg/agentcli/llm/anthropic.go index 3bf2e91..72c9bde 100644 --- a/backend/pkg/agentcli/llm/anthropic.go +++ b/backend/pkg/agentcli/llm/anthropic.go @@ -1,7 +1,7 @@ // anthropic.go - Anthropic Messages 协议 LLM 客户端实现 // Logan.Li // 支持官方 Claude(api.anthropic.com)及智谱 GLM 编码套餐(open.bigmodel.cn/api/anthropic)等 // 所有 Anthropic /v1/messages 兼容端点。与 openai.go 实现同一 Client 接口,由 client.go 按 Protocol 路由。 -// 鉴权统一用 Authorization: Bearer(智谱编码套餐实测通过;官方 Claude 较新版本亦支持)+ anthropic-version。 +// 同时发送 x-api-key 与 Bearer,兼容 Anthropic 官方和国内编码套餐网关。 // Logan.Li package llm import ( @@ -167,7 +167,10 @@ func buildTools(tools []Tool) []anthropicTool { // isAnthropicThinkingModel 判断是否为支持 thinking 的 Claude 模型 // Logan.Li func isAnthropicThinkingModel(model string) bool { m := strings.ToLower(model) - return strings.Contains(m, "claude-3-7") || strings.Contains(m, "claude-3.7") + return strings.Contains(m, "claude-3-7") || strings.Contains(m, "claude-3.7") || + strings.Contains(m, "claude-sonnet-4") || strings.Contains(m, "claude-opus-4") || + strings.Contains(m, "claude-sonnet-5") || strings.Contains(m, "claude-opus-5") || + strings.Contains(m, "claude-fable-5") } // buildRequest 构造 Anthropic 请求体(Complete 与 Stream 共用) // Logan.Li @@ -209,6 +212,7 @@ func (c *AnthropicClient) newHTTPRequest(ctx context.Context, body []byte) (*htt } httpReq.Header.Set("Content-Type", "application/json") httpReq.Header.Set("Authorization", "Bearer "+c.cfg.APIKey) + httpReq.Header.Set("x-api-key", c.cfg.APIKey) httpReq.Header.Set("anthropic-version", "2023-06-01") return httpReq, nil } @@ -356,8 +360,8 @@ func (c *AnthropicClient) Stream(ctx context.Context, req *CompletionRequest, on } } return &CompletionResponse{ - Model: c.cfg.Model, + Model: c.cfg.Model, Message: Message{Role: "assistant", Content: contentBuilder.String()}, - Usage: usage, + Usage: usage, }, nil } diff --git a/backend/pkg/agentcli/llm/openai.go b/backend/pkg/agentcli/llm/openai.go index 1757a22..3f79042 100644 --- a/backend/pkg/agentcli/llm/openai.go +++ b/backend/pkg/agentcli/llm/openai.go @@ -49,10 +49,14 @@ func NewOpenAIClient(cfg Config) Client { // Name 返回客户端名称 // Logan.Li func (c *OpenAIClient) Name() string { return "openai-compatible(" + c.cfg.Model + ")" } -// isOpenAIReasoningModel 判断是否为需要 reasoning_effort 的 OpenAI o-series 模型 // Logan.Li +// isOpenAIReasoningModel 判断是否为使用新推理参数的 OpenAI 模型。 // Logan.Li func isOpenAIReasoningModel(model string) bool { m := strings.ToLower(model) - return strings.Contains(m, "o1") || strings.Contains(m, "o3") + if index := strings.LastIndex(m, "/"); index >= 0 { + m = m[index+1:] + } + return strings.HasPrefix(m, "o1") || strings.HasPrefix(m, "o3") || + strings.HasPrefix(m, "o4") || strings.HasPrefix(m, "gpt-5") } // isGenericReasoningModel 判断是否为通用推理开关模型(DeepSeek R1 等) // Logan.Li @@ -61,33 +65,35 @@ func isGenericReasoningModel(model string) bool { return strings.Contains(m, "reasoning") || strings.Contains(m, "r1") || strings.Contains(m, "deepseek-reasoner") } -// applyReasoningParams 根据模型类型应用 reasoning 参数 // Logan.Li +// applyReasoningParams 根据模型类型应用 token 与 reasoning 参数。 // Logan.Li func applyReasoningParams(payload *openaiRequest, enabled bool) { - if !enabled { - return - } if isOpenAIReasoningModel(payload.Model) { - // OpenAI o-series 使用 reasoning_effort,temperature 必须固定为 1(不传) // Logan.Li - payload.ReasoningEffort = "medium" + // 新版 OpenAI 推理模型使用 max_completion_tokens 且不接收 temperature。 // Logan.Li + payload.MaxCompletionTokens = payload.MaxTokens + payload.MaxTokens = 0 payload.Temperature = 0 + if enabled { + payload.ReasoningEffort = "medium" + } return } - if isGenericReasoningModel(payload.Model) { + if enabled && isGenericReasoningModel(payload.Model) { payload.Reasoning = true } } // openaiRequest OpenAI chat/completions 请求体 // Logan.Li type openaiRequest struct { - Model string `json:"model"` - Messages []Message `json:"messages"` - Tools []Tool `json:"tools,omitempty"` - ToolChoice string `json:"tool_choice,omitempty"` // "auto" 让模型自主决定 - Temperature float64 `json:"temperature,omitempty"` - MaxTokens int `json:"max_tokens,omitempty"` - Stream bool `json:"stream,omitempty"` // 流式补全(Stream 方法置 true) - ReasoningEffort string `json:"reasoning_effort,omitempty"` // OpenAI o-series 推理强度 // Logan.Li - Reasoning bool `json:"reasoning,omitempty"` // DeepSeek 等部分平台推理开关 // Logan.Li + Model string `json:"model"` + Messages []Message `json:"messages"` + Tools []Tool `json:"tools,omitempty"` + ToolChoice string `json:"tool_choice,omitempty"` // "auto" 让模型自主决定 + Temperature float64 `json:"temperature,omitempty"` + MaxTokens int `json:"max_tokens,omitempty"` + MaxCompletionTokens int `json:"max_completion_tokens,omitempty"` // 新版 OpenAI 推理模型的输出上限 // Logan.Li + Stream bool `json:"stream,omitempty"` // 流式补全(Stream 方法置 true) + ReasoningEffort string `json:"reasoning_effort,omitempty"` // OpenAI o-series 推理强度 // Logan.Li + Reasoning bool `json:"reasoning,omitempty"` // DeepSeek 等部分平台推理开关 // Logan.Li } // openaiResponse OpenAI chat/completions 响应体 // Logan.Li @@ -245,9 +251,9 @@ func (c *OpenAIClient) Stream(ctx context.Context, req *CompletionRequest, onDel } } return &CompletionResponse{ - Model: c.cfg.Model, + Model: c.cfg.Model, Message: Message{Role: "assistant", Content: contentBuilder.String()}, - Usage: usage, + Usage: usage, }, nil } diff --git a/backend/pkg/agentcli/llm/provider_compat_test.go b/backend/pkg/agentcli/llm/provider_compat_test.go new file mode 100644 index 0000000..f69dc57 --- /dev/null +++ b/backend/pkg/agentcli/llm/provider_compat_test.go @@ -0,0 +1,39 @@ +package llm + +import "testing" + +func TestApplyReasoningParamsForModernOpenAIModels(t *testing.T) { + payload := openaiRequest{ + Model: "openai/gpt-5.4", + Temperature: 0.7, + MaxTokens: 8192, + } + applyReasoningParams(&payload, true) + + if payload.Temperature != 0 || payload.MaxTokens != 0 { + t.Fatalf("unsupported parameters were not cleared: %+v", payload) + } + if payload.MaxCompletionTokens != 8192 || payload.ReasoningEffort != "medium" { + t.Fatalf("modern OpenAI parameters were not applied: %+v", payload) + } +} + +func TestApplyReasoningParamsWithoutReasoningEffort(t *testing.T) { + payload := openaiRequest{Model: "gpt-5.4-mini", Temperature: 0.7, MaxTokens: 4096} + applyReasoningParams(&payload, false) + + if payload.MaxCompletionTokens != 4096 || payload.ReasoningEffort != "" { + t.Fatalf("model compatibility parameters are incorrect: %+v", payload) + } +} + +func TestAnthropicThinkingModels(t *testing.T) { + for _, model := range []string{"claude-3-7-sonnet", "claude-sonnet-4-6", "claude-opus-4-8", "claude-sonnet-5", "claude-fable-5"} { + if !isAnthropicThinkingModel(model) { + t.Fatalf("%q should support thinking parameters", model) + } + } + if isAnthropicThinkingModel("claude-haiku-4-5") { + t.Fatal("Claude Haiku should not receive thinking parameters") + } +} diff --git a/backend/router/routes.go b/backend/router/routes.go index 52c9fdc..4f3d7d3 100644 --- a/backend/router/routes.go +++ b/backend/router/routes.go @@ -942,6 +942,7 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config, c aiopsGroup.GET("/llm-config", llmHandler.GetLLMConfig) aiopsGroup.PUT("/llm-config", llmHandler.SaveLLMConfig) aiopsGroup.POST("/llm-config/test", llmHandler.TestLLMConnection) + aiopsGroup.POST("/llm-config/models", llmHandler.ListLLMModels) // 读取 OpenAI/New API 兼容模型列表 // Logan.Li aiopsGroup.POST("/chat", llmHandler.Chat) aiopsGroup.POST("/chat/stream", llmHandler.ChatStream) aiopsGroup.GET("/chat-history", llmHandler.GetChatHistory) diff --git a/frontend/src/api/aiops/assistant.ts b/frontend/src/api/aiops/assistant.ts index b54f242..f1fc877 100644 --- a/frontend/src/api/aiops/assistant.ts +++ b/frontend/src/api/aiops/assistant.ts @@ -33,6 +33,11 @@ export interface LLMTestResult { reasoning_enabled: boolean; } +export interface LLMModelListResult { + models: string[]; + total: number; +} + export interface ChatHistoryItem { id: number; user_id: number; @@ -192,6 +197,9 @@ export const aiApi = { // 测试 LLM 连接(可传草稿配置,保存前即可验证) // Logan.Li testConnection: (data?: Partial) => http.post('/aiops/llm-config/test', data ?? {}), + // 从 OpenAI/Anthropic 兼容端点读取当前密钥可用的模型。 // Logan.Li + listModels: (data: Partial) => + http.post('/aiops/llm-config/models', data), // AI 对话(messages 含历史 + 当前用户消息,session_id 区分多轮对话) chat: (messages: { role: 'user' | 'assistant'; content: string }[], sessionId?: string) => http.post('/aiops/chat', { messages, session_id: sessionId || '' }), diff --git a/frontend/src/components/aiops/LLMConfig.tsx b/frontend/src/components/aiops/LLMConfig.tsx index de8452f..62335ee 100644 --- a/frontend/src/components/aiops/LLMConfig.tsx +++ b/frontend/src/components/aiops/LLMConfig.tsx @@ -12,153 +12,25 @@ import { import { toast } from 'sonner'; import { Loader2, Save, Wifi, KeyRound, Brain, Sparkles, MessageSquare, AlertCircle, - ExternalLink, CheckCircle2, ShoppingBag, + ExternalLink, CheckCircle2, ShoppingBag, RefreshCw, } from 'lucide-react'; import { aiApi, type LLMConfigData, type LLMTestResult } from '@/api'; import { PageShell } from '@/components/layout/PageShell'; import { useTranslation } from 'react-i18next'; -import i18n from '@/i18n'; - -// 单个提供商配置:默认 endpoint、候选模型、套餐/充值引导链接 // Logan.Li -interface ProviderMeta { - id: string; - name: string; - endpoint: string; - models: string[]; - apiKeyOptional?: boolean; // 本地/自建模型 API Key 可留空 // Logan.Li - planUrl?: string; - planLabel?: string; - planHint?: string; -} +import { + CLOUD_CODING_PROVIDERS, + CLOUD_GENERAL_PROVIDERS, + endpointPlaceholder, + findProvider, + GATEWAY_PROVIDERS, + LOCAL_PROVIDERS, + needsManualModel, +} from './llmProviders'; // 连接凭证快照(仅含影响连通的字段,切换后需重新验证) // Logan.Li const connectionSnapshot = (c: LLMConfigData) => `${c.provider}|${c.model}|${c.endpoint}|${c.api_key}`; -// 云服务(按量 / 通用 API) // Logan.Li -const CLOUD_GENERAL_PROVIDERS: ProviderMeta[] = [ - { - id: 'deepseek', name: 'DeepSeek', endpoint: 'https://api.deepseek.com/v1', - models: ['deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek-reasoner', 'deepseek-chat'], - planUrl: 'https://platform.deepseek.com', - planLabel: i18n.t('aiops.getApiKey'), - planHint: i18n.t('aiops.deepseekHint'), - }, - { - id: 'glm', name: i18n.t('aiops.zhipuGeneral'), endpoint: 'https://open.bigmodel.cn/api/paas/v4', - models: ['glm-4.5', 'glm-4.6', 'glm-4-flash', 'glm-4-air', 'glm-4-plus'], - planUrl: 'https://open.bigmodel.cn/', - planLabel: i18n.t('aiops.zhipuOpenPlatform'), - planHint: i18n.t('aiops.zhipuGetKey'), - }, - { - id: 'moonshot', name: 'Kimi (Moonshot)', endpoint: 'https://api.moonshot.cn/v1', - models: ['kimi-latest', 'moonshot-v1-8k', 'moonshot-v1-32k', 'moonshot-v1-128k'], - planUrl: 'https://www.kimi.com/membership/pricing', - planLabel: i18n.t('aiops.buyKimiPlan'), - planHint: i18n.t('aiops.kimiGeneralHint'), - }, - { - id: 'minimax', name: i18n.t('aiops.minimaxPayg'), endpoint: 'https://api.minimaxi.com/v1', - models: ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed'], - planUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key', - planLabel: i18n.t('aiops.getPaygApiKey'), - planHint: i18n.t('aiops.byTokenBilling'), - }, - { - id: 'mimo', name: i18n.t('aiops.mimoXiaomi'), endpoint: 'https://api.mimo.mi.com/v1', - models: ['mimo-v2', 'mimo-v2-flash'], - planUrl: 'https://mimo.mi.com/', - planLabel: i18n.t('aiops.mimoPlatform'), - }, - { - id: 'siliconflow', name: i18n.t('aiops.providerSiliconflow'), endpoint: 'https://api.siliconflow.cn/v1', - models: ['deepseek-ai/DeepSeek-V3', 'Qwen/Qwen2.5-72B-Instruct', 'Pro/deepseek-ai/DeepSeek-R1'], - planUrl: 'https://cloud.siliconflow.cn/', - planLabel: i18n.t('aiops.siliconflowConsole'), - planHint: i18n.t('aiops.siliconflowHint'), - }, - { - id: 'openai', name: 'OpenAI', endpoint: 'https://api.openai.com/v1', - models: ['gpt-4o', 'gpt-4o-mini', 'gpt-4.1', 'gpt-4.1-mini', 'o3-mini', 'o4-mini', 'o1-mini'], - planUrl: 'https://platform.openai.com/api-keys', - planLabel: i18n.t('system.createApiKey'), - planHint: i18n.t('aiops.openaiHint'), - }, - { - id: 'anthropic', name: 'Anthropic Claude', endpoint: 'https://api.anthropic.com', - models: ['claude-sonnet-4-6', 'claude-opus-4-8', 'claude-haiku-4-5'], - planUrl: 'https://console.anthropic.com/settings/billing', - planLabel: i18n.t('aiops.anthropicBill'), - planHint: i18n.t('aiops.customProxyHint'), - }, -]; - -// 编码套餐 Coding Plan(固定月费,适合 Agent CLI 高频调用) // Logan.Li -const CLOUD_CODING_PROVIDERS: ProviderMeta[] = [ - { - id: 'glm-coding', name: i18n.t('aiops.zhipuCoding'), endpoint: 'https://open.bigmodel.cn/api/anthropic', - models: ['GLM-5.2', 'GLM-5.1'], - planUrl: 'https://bigmodel.cn/glm-coding', - planLabel: i18n.t('aiops.buyGlmCoding'), - planHint: i18n.t('aiops.zhipuCodingHint'), - }, - { - id: 'kimi', name: i18n.t('aiops.kimiCoding'), endpoint: 'https://api.kimi.com/coding/v1', - models: ['kimi-for-coding', 'kimi-coding-latest'], - planUrl: 'https://www.kimi.com/membership/pricing', - planLabel: i18n.t('aiops.buyKimiPlan'), - planHint: i18n.t('aiops.kimiCodingEndpoint'), - }, - { - id: 'minimax-coding', name: i18n.t('aiops.minimaxCoding'), endpoint: 'https://api.minimaxi.com/anthropic', - models: ['MiniMax-M3', 'MiniMax-M3[1m]', 'MiniMax-M2.7'], - planUrl: 'https://platform.minimaxi.com/user-center/payment/token-plan', - planLabel: i18n.t('aiops.buyTokenPack'), - planHint: i18n.t('aiops.anthropicSubHint'), - }, -]; - -const CLOUD_PROVIDERS: ProviderMeta[] = [...CLOUD_GENERAL_PROVIDERS, ...CLOUD_CODING_PROVIDERS]; - -// 自定义接入:手动填请求地址 + API Key + 模型名(OpenAI 兼容) // Logan.Li -const CUSTOM_PROVIDERS: ProviderMeta[] = [ - { - id: 'custom', name: i18n.t('aiops.customProvider'), endpoint: '', models: [], - apiKeyOptional: true, - planHint: i18n.t('aiops.customProviderDesc'), - }, -]; - -// 本地/自建模型(API Key 可选,置于列表底部) // Logan.Li -const LOCAL_PROVIDERS: ProviderMeta[] = [ - { - id: 'ollama', name: i18n.t('aiops.ollamaLocal'), endpoint: 'http://localhost:11434/v1', - models: ['qwen2.5:7b', 'llama3.2', 'deepseek-r1:7b'], - apiKeyOptional: true, - }, - { - id: 'vllm', name: i18n.t('aiops.vllmSelfHosted'), endpoint: 'http://localhost:8000/v1', - models: [], - apiKeyOptional: true, - }, -]; - -const PROVIDERS: ProviderMeta[] = [...CLOUD_PROVIDERS, ...CUSTOM_PROVIDERS, ...LOCAL_PROVIDERS]; - -// 模型名需手动输入的提供商(空 models 或需填接入点 ID) // Logan.Li -const needsManualModel = (provider: string, prov?: ProviderMeta) => - provider === 'custom' || provider === 'vllm' || !prov?.models.length; - -const endpointPlaceholder = (provider: string) => { - switch (provider) { - case 'custom': return 'https://openrouter.ai/api/v1'; - case 'ollama': return 'http://localhost:11434/v1'; - case 'vllm': return 'http://localhost:8000/v1'; - default: return 'https://api.xxx.com/v1'; - } -}; - const defaultCfg: LLMConfigData = { provider: 'deepseek', model: 'deepseek-v4-pro', api_key: '', endpoint: 'https://api.deepseek.com/v1', temperature: 0.7, max_tokens: 8192, @@ -171,20 +43,36 @@ export default function LLMConfigPage() { const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); + const [loadingModels, setLoadingModels] = useState(false); + const [discoveredModels, setDiscoveredModels] = useState([]); const [testResult, setTestResult] = useState(null); const [modelMode, setModelMode] = useState<'select' | 'input'>('select'); const [verifiedSnapshot, setVerifiedSnapshot] = useState(null); + const prov = findProvider(cfg.provider); const currentSnapshot = useMemo(() => connectionSnapshot(cfg), [cfg]); const isVerified = verifiedSnapshot === currentSnapshot; + const models = useMemo(() => Array.from(new Set([ + ...(prov?.models ?? []), + ...discoveredModels, + ...(cfg.model?.trim() ? [cfg.model.trim()] : []), + ])), [cfg.model, discoveredModels, prov]); + const manualModel = needsManualModel(cfg.provider, prov); + const isCustom = cfg.provider === 'custom'; + const apiKeyOptional = prov?.apiKeyOptional ?? false; + const endpointRequired = !prov?.endpoint; + const canTest = Boolean(cfg.model?.trim()) + && (endpointRequired ? Boolean(cfg.endpoint?.trim()) : true) + && (apiKeyOptional || Boolean(cfg.api_key?.trim())); const load = useCallback(async () => { setLoading(true); try { const saved = await aiApi.getConfig(); - const merged = { ...defaultCfg, ...saved }; + const merged = saved.configured ? { ...defaultCfg, ...saved } : defaultCfg; setCfg(merged); - const prov = PROVIDERS.find((x) => x.id === merged.provider); + setDiscoveredModels([]); + const prov = findProvider(merged.provider); const inList = prov ? prov.models.includes(merged.model) : false; setModelMode(needsManualModel(merged.provider, prov) || !inList ? 'input' : 'select'); if (saved.configured) { @@ -244,14 +132,41 @@ export default function LLMConfigPage() { finally { setTesting(false); } }; + const discoverModels = async () => { + if (!cfg.endpoint?.trim()) { + toast.error(t('system.fillEndpointFirst')); + return; + } + if (!apiKeyOptional && !cfg.api_key?.trim()) { + toast.error(t('system.fillApiKeyFirst')); + return; + } + setLoadingModels(true); + try { + const result = await aiApi.listModels(cfg); + if (!result.models.length) { + toast.error(t('aiops.noModelsDiscovered')); + return; + } + setDiscoveredModels(result.models); + setModelMode('select'); + if (!cfg.model?.trim()) { + set('model', result.models[0]); + } + toast.success(t('aiops.modelsDiscovered', { count: result.total })); + } + catch { toast.error(t('aiops.modelListFailed')); } + finally { setLoadingModels(false); } + }; + const onProvider = (p: string) => { - const next = PROVIDERS.find((x) => x.id === p); + const next = findProvider(p); if (!next) return; const model = next.models[0] || ''; setModelMode(needsManualModel(p, next) ? 'input' : 'select'); + setDiscoveredModels([]); invalidateVerify(); - const endpoint = p === 'custom' ? '' : (next.endpoint || cfg.endpoint); - setCfg({ ...cfg, provider: p, endpoint, model }); + setCfg({ ...cfg, provider: p, endpoint: next.endpoint, model, api_key: '' }); }; const onModel = (m: string) => { @@ -270,16 +185,8 @@ export default function LLMConfigPage() { setCfg({ ...cfg, [k]: v }); }; - const prov = PROVIDERS.find((x) => x.id === cfg.provider); const unknownProvider = Boolean(cfg.provider && !prov); - const models = prov?.models ?? []; - const manualModel = needsManualModel(cfg.provider, prov); - const isCustom = cfg.provider === 'custom'; - const apiKeyOptional = prov?.apiKeyOptional ?? false; - const endpointRequired = apiKeyOptional || cfg.provider === 'custom'; - const canTest = Boolean(cfg.model?.trim()) - && (endpointRequired ? Boolean(cfg.endpoint?.trim()) : true) - && (apiKeyOptional || Boolean(cfg.api_key?.trim())); + const showModelInput = modelMode === 'input' || (manualModel && discoveredModels.length === 0); return ( {t('aiops.providerAndCredential')} -
+
- - {manualModel || modelMode === 'input' ? ( +
+ + +
+ {showModelInput ? ( set('model', e.target.value)} placeholder={isCustom ? t('aiops.modelPlaceholder') : t('aiops.manualInputModel')} className="dg-input" /> @@ -365,7 +283,7 @@ export default function LLMConfigPage() {
- {prov.planHint || t('aiops.providerNeedsKey', { name: prov.name })} + {prov.planHintKey ? t(prov.planHintKey) : t('aiops.providerNeedsKey', { name: t(prov.nameKey) })}
- {prov.planLabel || t('aiops.goEnable')} + {prov.planLabelKey ? t(prov.planLabelKey) : t('aiops.goEnable')}
)} - {isCustom && prov?.planHint && ( + {isCustom && prov?.planHintKey && (
-
{prov.planHint}
+
{t(prov.planHintKey)}
)} @@ -402,8 +320,8 @@ export default function LLMConfigPage() { set('endpoint', e.target.value)} placeholder={endpointPlaceholder(cfg.provider)} className="dg-input" /> - {prov?.planHint && !prov.planUrl && ( -

{prov.planHint}

+ {prov?.planHintKey && !prov.planUrl && !isCustom && ( +

{t(prov.planHintKey)}

)}
@@ -486,4 +404,4 @@ export default function LLMConfigPage() {
); -} \ No newline at end of file +} diff --git a/frontend/src/components/aiops/llmProviders.ts b/frontend/src/components/aiops/llmProviders.ts new file mode 100644 index 0000000..0c195d5 --- /dev/null +++ b/frontend/src/components/aiops/llmProviders.ts @@ -0,0 +1,193 @@ +// LLM 供应商目录参考 models.dev,兼容通道参考 New API;仅收录当前客户端可调用的 Chat/Messages 服务。 // Logan.Li +export interface ProviderMeta { + id: string; + nameKey: string; + endpoint: string; + models: string[]; + apiKeyOptional?: boolean; + planUrl?: string; + planLabelKey?: string; + planHintKey?: string; +} + +// 主流公有云模型服务,均使用 OpenAI 兼容协议,Anthropic 除外。 // Logan.Li +export const CLOUD_GENERAL_PROVIDERS: ProviderMeta[] = [ + { + id: 'deepseek', nameKey: 'aiops.providerDeepseek', endpoint: 'https://api.deepseek.com/v1', + models: ['deepseek-v4-pro', 'deepseek-v4-flash', 'deepseek-reasoner', 'deepseek-chat'], + planUrl: 'https://platform.deepseek.com', planLabelKey: 'aiops.getApiKey', + planHintKey: 'aiops.deepseekHint', + }, + { + id: 'qwen', nameKey: 'aiops.providerQwen', endpoint: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + models: ['qwen3.7-plus', 'qwen3.7-max', 'qwen3.6-plus', 'qwen3-coder-flash', 'qwq-plus'], + planUrl: 'https://bailian.console.aliyun.com/', planLabelKey: 'aiops.getApiKey', + }, + { + id: 'glm', nameKey: 'aiops.providerZhipu', endpoint: 'https://open.bigmodel.cn/api/paas/v4', + models: ['glm-5.2', 'glm-5.1', 'glm-5'], + planUrl: 'https://open.bigmodel.cn/', planLabelKey: 'aiops.zhipuOpenPlatform', + planHintKey: 'aiops.zhipuGetKey', + }, + { + id: 'moonshot', nameKey: 'aiops.providerMoonshot', endpoint: 'https://api.moonshot.cn/v1', + models: ['kimi-k3', 'kimi-k2.7-code', 'kimi-k2.6', 'kimi-k2.5', 'kimi-k2-thinking-turbo'], + planUrl: 'https://platform.moonshot.cn/console/api-keys', planLabelKey: 'aiops.getApiKey', + planHintKey: 'aiops.kimiGeneralHint', + }, + { + id: 'minimax', nameKey: 'aiops.providerMinimax', endpoint: 'https://api.minimaxi.com/v1', + models: ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5'], + planUrl: 'https://platform.minimaxi.com/user-center/basic-information/interface-key', + planLabelKey: 'aiops.getPaygApiKey', planHintKey: 'aiops.byTokenBilling', + }, + { + id: 'mimo', nameKey: 'aiops.mimoXiaomi', endpoint: 'https://api.xiaomimimo.com/v1', + models: ['mimo-v2.5-pro', 'mimo-v2.5', 'mimo-v2-pro', 'mimo-v2-flash'], + planUrl: 'https://platform.xiaomimimo.com/', planLabelKey: 'aiops.mimoPlatform', + }, + { + id: 'hunyuan', nameKey: 'aiops.providerTencentTokenHub', endpoint: 'https://tokenhub.tencentmaas.com/v1', + models: ['hy3', 'hy3-preview'], + planUrl: 'https://console.cloud.tencent.com/lkeap', planLabelKey: 'aiops.getApiKey', + }, + { + id: 'volcengine', nameKey: 'aiops.providerVolcengine', endpoint: 'https://ark.cn-beijing.volces.com/api/v3', + models: [], planUrl: 'https://console.volcengine.com/ark', planLabelKey: 'aiops.goEnable', + planHintKey: 'aiops.endpointModelHint', + }, + { + id: 'baidu', nameKey: 'aiops.providerBaidu', endpoint: 'https://qianfan.baidubce.com/v2', + models: [], planUrl: 'https://console.bce.baidu.com/qianfan/ais/console/applicationConsole/application', + planLabelKey: 'aiops.getApiKey', planHintKey: 'aiops.manualModelHint', + }, + { + id: 'siliconflow', nameKey: 'aiops.providerSiliconflow', endpoint: 'https://api.siliconflow.cn/v1', + models: ['deepseek-ai/DeepSeek-V4-Pro', 'deepseek-ai/DeepSeek-V4-Flash', 'Pro/deepseek-ai/DeepSeek-R1', 'Qwen/Qwen3.5-397B-A17B'], + planUrl: 'https://cloud.siliconflow.cn/', planLabelKey: 'aiops.siliconflowConsole', + planHintKey: 'aiops.siliconflowHint', + }, + { + id: 'openai', nameKey: 'aiops.providerOpenai', endpoint: 'https://api.openai.com/v1', + models: ['gpt-5.5', 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.3-chat-latest', 'gpt-4.1', 'o4-mini'], + planUrl: 'https://platform.openai.com/api-keys', planLabelKey: 'system.createApiKey', + planHintKey: 'aiops.openaiHint', + }, + { + id: 'anthropic', nameKey: 'aiops.providerAnthropic', endpoint: 'https://api.anthropic.com', + models: ['claude-sonnet-5', 'claude-fable-5', 'claude-opus-4-8', 'claude-sonnet-4-6', 'claude-haiku-4-5'], + planUrl: 'https://console.anthropic.com/settings/keys', planLabelKey: 'aiops.getApiKey', + }, + { + id: 'google', nameKey: 'aiops.providerGemini', endpoint: 'https://generativelanguage.googleapis.com/v1beta/openai', + models: ['gemini-3.5-flash', 'gemini-3.1-pro-preview', 'gemini-3-flash-preview', 'gemini-2.5-pro', 'gemini-2.5-flash'], + planUrl: 'https://aistudio.google.com/app/apikey', planLabelKey: 'aiops.getApiKey', + }, + { + id: 'xai', nameKey: 'aiops.providerXai', endpoint: 'https://api.x.ai/v1', + models: ['grok-4.5', 'grok-4.3', 'grok-4.20-0309-reasoning', 'grok-4.20-0309-non-reasoning'], + planUrl: 'https://console.x.ai/', planLabelKey: 'aiops.getApiKey', + }, + { + id: 'mistral', nameKey: 'aiops.providerMistral', endpoint: 'https://api.mistral.ai/v1', + models: ['mistral-large-latest', 'mistral-medium-latest', 'mistral-small-latest', 'codestral-latest'], + planUrl: 'https://console.mistral.ai/api-keys', planLabelKey: 'aiops.getApiKey', + }, + { + id: 'groq', nameKey: 'aiops.providerGroq', endpoint: 'https://api.groq.com/openai/v1', + models: ['openai/gpt-oss-120b', 'llama-3.3-70b-versatile', 'qwen/qwen3-32b'], + planUrl: 'https://console.groq.com/keys', planLabelKey: 'aiops.getApiKey', + }, + { + id: 'openrouter', nameKey: 'aiops.providerOpenrouter', endpoint: 'https://openrouter.ai/api/v1', + models: ['openai/gpt-5.4', 'anthropic/claude-sonnet-4.6', 'google/gemini-3.1-pro-preview', 'deepseek/deepseek-v4-pro'], + planUrl: 'https://openrouter.ai/settings/keys', planLabelKey: 'aiops.getApiKey', + }, + { + id: 'together', nameKey: 'aiops.providerTogether', endpoint: 'https://api.together.ai/v1', + models: ['deepseek-ai/DeepSeek-V4-Pro', 'moonshotai/Kimi-K3', 'Qwen/Qwen3.7-Max', 'openai/gpt-oss-120b'], + planUrl: 'https://api.together.ai/settings/api-keys', planLabelKey: 'aiops.getApiKey', + }, + { + id: 'cerebras', nameKey: 'aiops.providerCerebras', endpoint: 'https://api.cerebras.ai/v1', + models: ['gpt-oss-120b', 'zai-glm-4.7', 'gemma-4-31b'], + planUrl: 'https://cloud.cerebras.ai/platform/', planLabelKey: 'aiops.getApiKey', + }, + { + id: 'perplexity', nameKey: 'aiops.providerPerplexity', endpoint: 'https://api.perplexity.ai', + models: ['sonar-pro', 'sonar-reasoning-pro', 'sonar'], + planUrl: 'https://www.perplexity.ai/account/api/keys', planLabelKey: 'aiops.getApiKey', + }, +]; + +// 编码套餐使用独立密钥或专用端点,不与通用 API 配置混用。 // Logan.Li +export const CLOUD_CODING_PROVIDERS: ProviderMeta[] = [ + { + id: 'glm-coding', nameKey: 'aiops.zhipuCoding', endpoint: 'https://open.bigmodel.cn/api/anthropic', + models: ['GLM-5.2', 'GLM-5.1'], planUrl: 'https://bigmodel.cn/glm-coding', + planLabelKey: 'aiops.buyGlmCoding', planHintKey: 'aiops.zhipuCodingHint', + }, + { + id: 'kimi', nameKey: 'aiops.kimiCoding', endpoint: 'https://api.kimi.com/coding/v1', + models: ['kimi-for-coding', 'kimi-coding-latest'], planUrl: 'https://www.kimi.com/membership/pricing', + planLabelKey: 'aiops.buyKimiPlan', planHintKey: 'aiops.kimiCodingEndpoint', + }, + { + id: 'minimax-coding', nameKey: 'aiops.minimaxCoding', endpoint: 'https://api.minimaxi.com/anthropic', + models: ['MiniMax-M3', 'MiniMax-M3[1m]', 'MiniMax-M2.7'], + planUrl: 'https://platform.minimaxi.com/user-center/payment/token-plan', + planLabelKey: 'aiops.buyTokenPack', planHintKey: 'aiops.anthropicSubHint', + }, +]; + +// New API/One API 是需要密钥的 OpenAI 兼容网关,自定义接入则允许无鉴权。 // Logan.Li +export const GATEWAY_PROVIDERS: ProviderMeta[] = [ + { + id: 'new-api', nameKey: 'aiops.providerNewApi', endpoint: '', models: [], + planHintKey: 'aiops.newApiHint', + }, + { + id: 'custom', nameKey: 'aiops.customProvider', endpoint: '', models: [], + apiKeyOptional: true, planHintKey: 'aiops.customProviderDesc', + }, +]; + +// 本地与自建服务默认不要求 API Key。 // Logan.Li +export const LOCAL_PROVIDERS: ProviderMeta[] = [ + { + id: 'ollama', nameKey: 'aiops.ollamaLocal', endpoint: 'http://localhost:11434/v1', + models: ['qwen3:8b', 'llama3.3', 'deepseek-r1:8b'], apiKeyOptional: true, + }, + { + id: 'vllm', nameKey: 'aiops.vllmSelfHosted', endpoint: 'http://localhost:8000/v1', + models: [], apiKeyOptional: true, + }, + { + id: 'lmstudio', nameKey: 'aiops.providerLmStudio', endpoint: 'http://localhost:1234/v1', + models: [], apiKeyOptional: true, + }, +]; + +export const PROVIDERS: ProviderMeta[] = [ + ...CLOUD_GENERAL_PROVIDERS, + ...CLOUD_CODING_PROVIDERS, + ...GATEWAY_PROVIDERS, + ...LOCAL_PROVIDERS, +]; + +export const findProvider = (provider: string) => PROVIDERS.find((item) => item.id === provider); + +export const needsManualModel = (provider: string, meta?: ProviderMeta) => + provider === 'custom' || provider === 'new-api' || provider === 'vllm' + || provider === 'lmstudio' || !meta?.models.length; + +export const endpointPlaceholder = (provider: string) => { + switch (provider) { + case 'new-api': return 'https://new-api.example.com/v1'; + case 'custom': return 'https://gateway.example.com/v1'; + case 'ollama': return 'http://localhost:11434/v1'; + case 'vllm': return 'http://localhost:8000/v1'; + case 'lmstudio': return 'http://localhost:1234/v1'; + default: return 'https://api.example.com/v1'; + } +}; diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index fd32787..4da9073 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -8380,12 +8380,14 @@ "eventAborted": "Task aborted", "eventFailed": "Task execution failed" }, - "cloudService": "Cloud Services", + "agentCliSharedConfig": "This connection is shared by AI Assistant and Agent CLI", + "apiGateway": "API Gateways", + "cloudService": "Official Cloud Services", "codingPlan": "Coding Plan", "collapseHistory": "Collapse history", "confidenceLabel": "Confidence", "confidencePercent": "Confidence {{n}}%", - "configHelpText": "Cloud Services: Select a provider → Activate and enter API Key → Test Connection → Save.\n Coding Plans: GLM / Kimi / MiniMax fixed monthly plans, ideal for frequent Agent CLI usage (note: keys are not shared with pay-as-you-go versions).\n Custom LLM: Enter endpoint + API Key + model name, compatible with OpenRouter and other OpenAI-format services.\n Local/Self-hosted (Ollama, vLLM): Enter endpoint and model name; API Key can be left blank.\n Testing sends \"What model are you? Please answer briefly.\" to confirm the target model.", + "configHelpText": "Official cloud service: Select a provider and enter its API Key; you can load the models available to that key.\n API gateway: For New API / One API, enter the site's /v1 URL, token, and model name.\n Local/self-hosted (Ollama, vLLM, LM Studio): Enter the Endpoint and model name; API Key is optional.\n Test the connection before saving; the test sends one short message to confirm the actual model.", "configLlmFirst": "Please configure an LLM first...", "confirmDeleteSession": "Delete \"{{title}}\"? This action cannot be undone.", "confirmExecute": "Confirm Execute", @@ -8406,7 +8408,7 @@ "currentValue": "Current", "customAccess": "Custom Access", "customProvider": "Custom LLM", - "customProviderDesc": "Fill in an OpenAI-compatible endpoint, API Key (blank if no auth) and model name; works with OpenRouter, intranet proxies and other unlisted services", + "customProviderDesc": "Enter an OpenAI-compatible endpoint, API Key (blank if no auth), and model name; suitable for internal proxies and other unlisted services", "customProxyHint": "Direct access from China may be restricted; use your own network or a compatible proxy via \"Custom LLM\"", "dailyOpsReport": "Generate ops report", "daysToThreshold": "reaches threshold in {{days}} days", @@ -8420,6 +8422,7 @@ "enableLlm": "Enable LLM Service", "enableReasoning": "Enable reasoning mode", "endpointLabel": "Endpoint", + "endpointModelHint": "Enter the endpoint ID created in the provider console", "errorOccurred": "An error occurred", "examplePrefix": "Ex: ", "executeAutoFix": "Execute Auto-Fix", @@ -8479,12 +8482,14 @@ "llmTopP": "Top P", "llmVisionModel": "Vision Model", "loadAlertsDot": "Loading alert data...", + "loadModels": "Load Models", "loadAnomalyDot": "Loading anomaly forecast...", "loadingCapacityData": "Loading capacity data...", "loadingMetricData": "Loading metric data...", "localNoKeyHint": "Local or intranet self-hosted services usually need no key; fill it in only if the gateway requires auth.", "localSelfHosted": "Local / Self-hosted", "manualInputModel": "Enter model name manually", + "manualModelHint": "Enter the model ID shown in the provider console", "mayRecur": "{{name}} may recur", "mayTrigger": "{{name}} may trigger", "memoryKeyword": "Memory", @@ -8495,11 +8500,14 @@ "minimaxPayg": "MiniMax (pay-as-you-go)", "modelColon": "Model: ", "modelLabel": "Model", + "modelListFailed": "Could not load models; check the Endpoint, API Key, and upstream service", "modelPlaceholder": "e.g. deepseek/deepseek-chat or gpt-4o", "modelUnknown": "Unknown", + "modelsDiscovered": "Loaded {{count}} models", "networkKeyword": "Network", "newConversation": "New Chat", "newConversationStarted": "New conversation started", + "newApiHint": "Enter the OpenAI-compatible URL of your New API or One API instance (usually ending in /v1), its token, and the model name", "newSessionBtn": "New", "newTask": "New Task", "next12Hours": "Next 12 hours", @@ -8515,6 +8523,7 @@ "noHostsHint": "No hosts yet; add servers in \"Asset Management\" first", "noMetricData": "No metric data", "noModelContent": "(The model returned no content)", + "noModelsDiscovered": "The upstream returned no models; enter the model name manually", "noneFallback": "(none)", "notConnectedHint": "Connection not ready; check the LLM config or refresh the page", "notConnectedRetry": "Connection not ready; please retry later", @@ -8620,23 +8629,33 @@ "providerAndCredential": "Model & Credential", "providerAnthropic": "Anthropic", "providerAzure": "Azure OpenAI", + "providerBaidu": "Baidu Qianfan", "providerBaichuan": "Baichuan", + "providerCerebras": "Cerebras", "providerCohere": "Cohere", "providerColon": "Provider: ", "providerCustom": "Custom", "providerDeepseek": "DeepSeek", "providerGemini": "Google Gemini", + "providerGroq": "Groq", "providerLabel": "Provider", + "providerLmStudio": "LM Studio", "providerMinimax": "MiniMax", "providerMistral": "Mistral", "providerMoonshot": "Moonshot", + "providerNewApi": "New API / One API", "providerNeedsKey": "To use {{name}}, you need to activate the service and obtain an API Key first", "providerOllama": "Ollama", "providerOpenai": "OpenAI", "providerOpenrouter": "OpenRouter", + "providerPerplexity": "Perplexity", "providerQwen": "Qwen", "providerSiliconflow": "SiliconFlow", "providerSpark": "iFlytek Spark", + "providerTencentTokenHub": "Tencent Cloud TokenHub", + "providerTogether": "Together AI", + "providerVolcengine": "Volcengine Ark", + "providerXai": "xAI Grok", "providerYi": "01.AI", "providerZhipu": "Zhipu GLM", "quickActions": "Quick Actions", @@ -8646,7 +8665,7 @@ "reasoningHigh": "High", "reasoningLow": "Low", "reasoningMedium": "Medium", - "reasoningModelHint": "Suitable for reasoning models like OpenAI o-series / DeepSeek R1 / Claude 3.7 Sonnet. May cause errors with standard models.", + "reasoningModelHint": "For reasoning models such as OpenAI GPT-5 / o-series, DeepSeek R1, and Claude Sonnet / Opus 4.", "reasoningOn": "Reasoning mode is on", "reasoningPlanMode": "Reasoning / Plan Mode", "recommended": "Recommended", diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index 3e3a3cb..6833db5 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -8380,12 +8380,14 @@ "eventAborted": "任务已取消", "eventFailed": "任务执行失败" }, - "cloudService": "云服务", + "agentCliSharedConfig": "智能助手与 Agent CLI 共用此连接配置", + "apiGateway": "API 网关", + "cloudService": "官方云服务", "codingPlan": "编码套餐 Coding Plan", "collapseHistory": "收起历史任务", "confidenceLabel": "置信度", "confidencePercent": "置信度 {{n}}%", - "configHelpText": "云服务:选择提供商 → 开通并填写 API Key → 测试连接 → 保存。\n 编码套餐:GLM / Kimi / MiniMax 固定月费套餐,适合 Agent CLI 高频调用(注意 Key 与按量版不互通)。\n 大模型自定义:填请求地址 + API Key + 模型名,兼容 OpenRouter 等 OpenAI 格式服务。\n 本地/自建(Ollama、vLLM):填写 Endpoint 与模型名即可,API Key 可留空。\n 测试时会发送「你是什么模型?请简短回答。」以确认接入目标模型。", + "configHelpText": "官方云服务:选择提供商并填写 API Key,可读取当前密钥可用的模型。\n API 网关:New API / One API 需填写站点的 /v1 地址、令牌和模型名。\n 本地/自建(Ollama、vLLM、LM Studio):填写 Endpoint 与模型名即可,API Key 可留空。\n 保存前必须先测试连接;测试会发送一条简短消息确认实际接入模型。", "configLlmFirst": "请先配置大模型...", "confirmDeleteSession": "确定删除「{{title}}」?此操作不可恢复。", "confirmExecute": "确认执行", @@ -8406,7 +8408,7 @@ "currentValue": "当前值", "customAccess": "自定义接入", "customProvider": "大模型自定义", - "customProviderDesc": "填写 OpenAI 兼容的请求地址、API Key(无鉴权可留空)和模型名;适用于 OpenRouter、内网代理、其他未列出的服务", + "customProviderDesc": "填写 OpenAI 兼容的请求地址、API Key(无鉴权可留空)和模型名;适用于内网代理或其他未列出的服务", "customProxyHint": "国内直连可能受限,需自备网络或使用「大模型自定义」接入兼容代理", "dailyOpsReport": "生成运维报告", "daysToThreshold": "{{days}} 天后触及阈值", @@ -8420,6 +8422,7 @@ "enableLlm": "启用 LLM 服务", "enableReasoning": "启用推理模式", "endpointLabel": "接口地址(Endpoint)", + "endpointModelHint": "请填写在提供商控制台创建的接入点 ID", "errorOccurred": "发生错误", "examplePrefix": "例:", "executeAutoFix": "执行自动修复", @@ -8479,12 +8482,14 @@ "llmTopP": "Top P", "llmVisionModel": "视觉模型", "loadAlertsDot": "加载告警数据...", + "loadModels": "获取模型", "loadAnomalyDot": "加载异常预测...", "loadingCapacityData": "加载容量数据...", "loadingMetricData": "加载指标数据...", "localNoKeyHint": "本地或内网自建通常无需密钥;若网关启用了鉴权再填写。", "localSelfHosted": "本地 / 自建", "manualInputModel": "手动输入模型名", + "manualModelHint": "请按提供商控制台显示的模型 ID 手动填写", "mayRecur": "{{name}}可能再次发生", "mayTrigger": "{{name}}可能触发", "memoryKeyword": "内存", @@ -8495,11 +8500,14 @@ "minimaxPayg": "MiniMax (按量)", "modelColon": "模型:", "modelLabel": "模型", + "modelListFailed": "读取模型列表失败,请检查 Endpoint、API Key 和上游服务", "modelPlaceholder": "如 deepseek/deepseek-chat 或 gpt-4o", "modelUnknown": "未知", + "modelsDiscovered": "已加载 {{count}} 个模型", "networkKeyword": "网络", "newConversation": "新对话", "newConversationStarted": "已开始新对话", + "newApiHint": "填写 New API 或 One API 站点的 OpenAI 兼容地址(通常以 /v1 结尾)、令牌和模型名", "newSessionBtn": "新建", "newTask": "新建任务", "next12Hours": "未来12小时", @@ -8515,6 +8523,7 @@ "noHostsHint": "暂无主机,请先前往「资产管理」添加服务器", "noMetricData": "暂无指标数据", "noModelContent": "(模型未返回内容)", + "noModelsDiscovered": "上游没有返回可用模型,请手动填写模型名", "noneFallback": "(无)", "notConnectedHint": "连接未就绪,请检查大模型配置或刷新页面重试", "notConnectedRetry": "连接未就绪,请稍后重试", @@ -8620,23 +8629,33 @@ "providerAndCredential": "模型与凭证", "providerAnthropic": "Anthropic", "providerAzure": "Azure OpenAI", + "providerBaidu": "百度千帆", "providerBaichuan": "百川", + "providerCerebras": "Cerebras", "providerCohere": "Cohere", "providerColon": "提供商:", "providerCustom": "自定义", "providerDeepseek": "DeepSeek", "providerGemini": "Google Gemini", + "providerGroq": "Groq", "providerLabel": "提供商", + "providerLmStudio": "LM Studio", "providerMinimax": "MiniMax", "providerMistral": "Mistral", "providerMoonshot": "月之暗面", + "providerNewApi": "New API / One API", "providerNeedsKey": "使用 {{name}} 需先开通服务并获取 API Key", "providerOllama": "Ollama", "providerOpenai": "OpenAI", "providerOpenrouter": "OpenRouter", + "providerPerplexity": "Perplexity", "providerQwen": "通义千问", "providerSiliconflow": "硅基流动", "providerSpark": "讯飞星火", + "providerTencentTokenHub": "腾讯云 TokenHub", + "providerTogether": "Together AI", + "providerVolcengine": "火山引擎方舟", + "providerXai": "xAI Grok", "providerYi": "零一万物", "providerZhipu": "智谱 GLM", "quickActions": "快速操作", @@ -8646,7 +8665,7 @@ "reasoningHigh": "高", "reasoningLow": "低", "reasoningMedium": "中", - "reasoningModelHint": "适用于 OpenAI o-series / DeepSeek R1 / Claude 3.7 Sonnet 等推理模型;普通模型开启后可能报错。", + "reasoningModelHint": "适用于 OpenAI GPT-5 / o-series、DeepSeek R1、Claude Sonnet / Opus 4 等推理模型。", "reasoningOn": "推理模式已开启", "reasoningPlanMode": "推理 / Plan 模式", "recommended": "推荐",