feat: 更新主流大模型供应商目录
- 按云服务、Coding Plan、API 网关和本地模型分组 - 新增兼容模型发现与供应商协议校验
This commit is contained in:
1 parent
01e4435f29
commit
d8b124fc7c
15 files changed
+838
-237
No files matched your search
+60
-17
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+17
-17
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user