feat: 更新主流大模型供应商目录
- 按云服务、Coding Plan、API 网关和本地模型分组 - 新增兼容模型发现与供应商协议校验
This commit is contained in:
1 parent
01e4435f29
commit
d8b124fc7c
15 files changed
+798
-197
No files matched your search
@@ -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 {
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,10 @@ 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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,18 +65,19 @@ 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
|
||||
}
|
||||
}
|
||||
@@ -85,6 +90,7 @@ type openaiRequest struct {
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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<LLMConfigData>) =>
|
||||
http.post<LLMTestResult>('/aiops/llm-config/test', data ?? {}),
|
||||
// 从 OpenAI/Anthropic 兼容端点读取当前密钥可用的模型。 // Logan.Li
|
||||
listModels: (data: Partial<LLMConfigData>) =>
|
||||
http.post<LLMModelListResult>('/aiops/llm-config/models', data),
|
||||
// AI 对话(messages 含历史 + 当前用户消息,session_id 区分多轮对话)
|
||||
chat: (messages: { role: 'user' | 'assistant'; content: string }[], sessionId?: string) =>
|
||||
http.post<ChatResult>('/aiops/chat', { messages, session_id: sessionId || '' }),
|
||||
|
||||
@@ -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<string[]>([]);
|
||||
const [testResult, setTestResult] = useState<LLMTestResult | null>(null);
|
||||
const [modelMode, setModelMode] = useState<'select' | 'input'>('select');
|
||||
const [verifiedSnapshot, setVerifiedSnapshot] = useState<string | null>(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 (
|
||||
<PageShell
|
||||
@@ -294,7 +201,7 @@ export default function LLMConfigPage() {
|
||||
<Card className="dg-card">
|
||||
<CardHeader><CardTitle className="text-[var(--dg-text-primary)] text-base">{t('aiops.providerAndCredential')}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('aiops.providerLabel')}</Label>
|
||||
<Select value={cfg.provider} onValueChange={onProvider}>
|
||||
@@ -309,33 +216,44 @@ export default function LLMConfigPage() {
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t('aiops.cloudService')}</SelectLabel>
|
||||
{CLOUD_GENERAL_PROVIDERS.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
|
||||
<SelectItem key={p.id} value={p.id}>{t(p.nameKey)}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t('aiops.codingPlan')}</SelectLabel>
|
||||
{CLOUD_CODING_PROVIDERS.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
|
||||
<SelectItem key={p.id} value={p.id}>{t(p.nameKey)}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t('aiops.customAccess')}</SelectLabel>
|
||||
{CUSTOM_PROVIDERS.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
|
||||
<SelectLabel>{t('aiops.apiGateway')}</SelectLabel>
|
||||
{GATEWAY_PROVIDERS.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>{t(p.nameKey)}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t('aiops.localSelfHosted')}</SelectLabel>
|
||||
{LOCAL_PROVIDERS.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
|
||||
<SelectItem key={p.id} value={p.id}>{t(p.nameKey)}</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex min-h-5 items-center justify-between gap-2">
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('aiops.modelLabel')}</Label>
|
||||
{manualModel || modelMode === 'input' ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={discoverModels}
|
||||
disabled={loadingModels}
|
||||
className="inline-flex items-center gap-1 text-xs dg-text-blue hover:underline disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${loadingModels ? 'animate-spin' : ''}`} />
|
||||
{t('aiops.loadModels')}
|
||||
</button>
|
||||
</div>
|
||||
{showModelInput ? (
|
||||
<Input value={cfg.model} onChange={(e) => set('model', e.target.value)}
|
||||
placeholder={isCustom ? t('aiops.modelPlaceholder') : t('aiops.manualInputModel')}
|
||||
className="dg-input" />
|
||||
@@ -365,7 +283,7 @@ export default function LLMConfigPage() {
|
||||
<ShoppingBag className="w-4 h-4 shrink-0 mt-0.5 text-[var(--dg-orange)]" />
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
<div className="text-sm text-[var(--dg-text-secondary)]">
|
||||
{prov.planHint || t('aiops.providerNeedsKey', { name: prov.name })}
|
||||
{prov.planHintKey ? t(prov.planHintKey) : t('aiops.providerNeedsKey', { name: t(prov.nameKey) })}
|
||||
</div>
|
||||
<a
|
||||
href={prov.planUrl}
|
||||
@@ -373,17 +291,17 @@ export default function LLMConfigPage() {
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs dg-text-blue hover:underline"
|
||||
>
|
||||
{prov.planLabel || t('aiops.goEnable')}
|
||||
{prov.planLabelKey ? t(prov.planLabelKey) : t('aiops.goEnable')}
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCustom && prov?.planHint && (
|
||||
{isCustom && prov?.planHintKey && (
|
||||
<div className="flex items-start gap-3 rounded-lg bg-[var(--dg-bg-tertiary)] px-3 py-2.5">
|
||||
<KeyRound className="w-4 h-4 shrink-0 mt-0.5 text-[var(--dg-blue)]" />
|
||||
<div className="text-sm text-[var(--dg-text-secondary)]">{prov.planHint}</div>
|
||||
<div className="text-sm text-[var(--dg-text-secondary)]">{t(prov.planHintKey)}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -402,8 +320,8 @@ export default function LLMConfigPage() {
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('aiops.endpointLabel')}</Label>
|
||||
<Input value={cfg.endpoint} onChange={(e) => set('endpoint', e.target.value)}
|
||||
placeholder={endpointPlaceholder(cfg.provider)} className="dg-input" />
|
||||
{prov?.planHint && !prov.planUrl && (
|
||||
<p className="text-xs dg-text-muted">{prov.planHint}</p>
|
||||
{prov?.planHintKey && !prov.planUrl && !isCustom && (
|
||||
<p className="text-xs dg-text-muted">{t(prov.planHintKey)}</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "推荐",
|
||||
|
||||
Reference in new issue
Block a user