fix(security): 模型目录 - 收敛插件出站边界并保留补充能力元数据

This commit is contained in:
ddCat committed 2026-08-17 17:20:00 +08:00
1 parent 115eb76bad
commit 0002cdd2cb
6 files changed
+193 -558

No files matched your search

@@ -104,12 +104,6 @@ type ChannelModelCatalogOption struct {
}
func (s *Service) FetchChannelModelCatalog(ctx context.Context, actor *model.User, input ChannelModelsRequest) ([]ChannelModelCatalogItem, error) {
// 功能开关:如果启用插件机制,使用新实现
if s.isPluginEnabled() {
return s.FetchChannelModelCatalogWithPlugin(ctx, actor, input)
}
// 否则使用原有实现(向后兼容)
if actor == nil || strings.TrimSpace(actor.ID) == "" {
return nil, Unauthorized("请先登录")
}
@@ -206,6 +200,9 @@ func (s *Service) FetchChannelModelCatalog(ctx context.Context, actor *model.Use
sort.Slice(catalog, func(left int, right int) bool {
return catalog[left].ID < catalog[right].ID
})
if s.isPluginEnabled() {
catalog = extendChannelModelCatalog(baseURL, apiFormat, headers, catalog)
}
return catalog, nil
}
@@ -1,138 +1,117 @@
package service
import (
"context"
"fmt"
"net/url"
"os"
"sort"
"strings"
"sync"
"infinite-canvas/backend/internal/model"
"infinite-canvas/backend/internal/provider"
"infinite-canvas/backend/internal/provider/bailian"
"infinite-canvas/backend/internal/provider/openai"
)
var (
pluginsInitialized bool
pluginsInitMutex sync.Mutex
)
var pluginsInitOnce sync.Once
// initPlugins 初始化并注册所有插件(只执行一次)
func initPlugins() {
pluginsInitMutex.Lock()
defer pluginsInitMutex.Unlock()
if pluginsInitialized {
return
}
// 注册 OpenAI 标准插件(默认)
provider.Register(openai.New())
// 注册阿里云百炼插件
provider.Register(bailian.New())
// 未来可以添加更多插件:
// provider.Register(gemini.New())
// provider.Register(anthropic.New())
pluginsInitialized = true
}
// FetchChannelModelCatalogWithPlugin 使用插件机制拉取模型(新实现)
func (s *Service) FetchChannelModelCatalogWithPlugin(ctx context.Context, actor *model.User, input ChannelModelsRequest) ([]ChannelModelCatalogItem, error) {
// 确保插件已初始化
initPlugins()
// 验证基本参数
if actor == nil || strings.TrimSpace(actor.ID) == "" {
return nil, Unauthorized("请先登录")
}
baseURL := strings.TrimRight(strings.TrimSpace(input.BaseURL), "/")
apiKey := strings.TrimSpace(input.APIKey)
if baseURL == "" {
return nil, BadAuthRequest("请填写 Base URL")
}
if apiKey == "" {
return nil, BadAuthRequest("请填写 API Key")
}
// 1. 匹配插件
headers := s.headersToMap(input.Headers)
discovery := provider.MatchProvider(baseURL, headers)
if discovery == nil {
return nil, fmt.Errorf("未找到匹配的服务商插件,请检查 Base URL")
}
// 2. 构建配置
config := provider.DiscoveryConfig{
BaseURL: baseURL,
APIKey: apiKey,
APIFormat: strings.ToLower(strings.TrimSpace(input.APIFormat)),
Headers: headers,
AllowLocalChannel: input.AllowLocalChannel,
Region: s.extractRegion(baseURL),
}
if config.APIFormat == "" {
config.APIFormat = "openai"
}
// 3. 调用插件发现模型
models, err := discovery.DiscoverModels(ctx, config)
if err != nil {
return nil, fmt.Errorf("插件发现模型失败: %w", err)
}
// 4. 转换为内部格式
catalog := make([]ChannelModelCatalogItem, 0, len(models))
for _, m := range models {
catalog = append(catalog, ChannelModelCatalogItem{
ID: m.ID,
SupportedEndpointTypes: m.SupportedEndpointTypes,
})
}
// 5. 排序
sort.Slice(catalog, func(i, j int) bool {
return catalog[i].ID < catalog[j].ID
func initModelCatalogPlugins() {
pluginsInitOnce.Do(func() {
provider.Register(bailian.New())
})
return catalog, nil
}
// headersToMap 将 OutboundHeader 数组转换为 map
func (s *Service) headersToMap(headers []OutboundHeader) map[string]string {
result := make(map[string]string, len(headers))
for _, h := range headers {
if h.Name != "" && h.Value != "" {
result[h.Name] = h.Value
// extendChannelModelCatalog 在已通过统一安全出站链路获取的标准目录上补充厂商条目。
// 插件不持有密钥也不自行发 HTTP,避免绕过 SSRF、请求头和响应大小边界。
func extendChannelModelCatalog(baseURL string, apiFormat string, headers []OutboundHeader, catalog []ChannelModelCatalogItem) []ChannelModelCatalogItem {
initModelCatalogPlugins()
discovery := provider.MatchProvider(baseURL, outboundHeadersMap(headers))
if discovery == nil {
return catalog
}
extra := discovery.AdditionalModels(provider.DiscoveryConfig{
BaseURL: baseURL,
APIFormat: apiFormat,
Headers: outboundHeadersMap(headers),
Region: modelCatalogRegion(baseURL),
})
if len(extra) == 0 {
return catalog
}
indexByID := make(map[string]int, len(catalog)+len(extra))
for index := range catalog {
indexByID[catalog[index].ID] = index
}
for _, model := range extra {
item := providerCatalogItem(model)
if item.ID == "" {
continue
}
if index, exists := indexByID[item.ID]; exists {
catalog[index] = enrichCatalogItem(catalog[index], item)
continue
}
indexByID[item.ID] = len(catalog)
catalog = append(catalog, item)
}
sort.Slice(catalog, func(left int, right int) bool {
return catalog[left].ID < catalog[right].ID
})
return catalog
}
func providerCatalogItem(item provider.Model) ChannelModelCatalogItem {
modelType := ""
for _, capability := range item.Capability {
if normalized := normalizeCatalogModelType(capability); normalized != "" {
modelType = normalized
break
}
}
return ChannelModelCatalogItem{
ID: strings.TrimPrefix(strings.TrimSpace(item.ID), "models/"),
DisplayName: strings.TrimSpace(item.DisplayName),
ModelType: modelType,
SupportedEndpointTypes: normalizeCatalogEndpointTypes(item.SupportedEndpointTypes),
}
}
func enrichCatalogItem(current ChannelModelCatalogItem, fallback ChannelModelCatalogItem) ChannelModelCatalogItem {
if current.DisplayName == "" {
current.DisplayName = fallback.DisplayName
}
if current.ModelType == "" {
current.ModelType = fallback.ModelType
}
if len(current.SupportedEndpointTypes) == 0 {
current.SupportedEndpointTypes = fallback.SupportedEndpointTypes
}
return current
}
func outboundHeadersMap(headers []OutboundHeader) map[string]string {
result := make(map[string]string, len(headers))
for _, header := range headers {
result[header.Name] = header.Value
}
return result
}
// extractRegion 从 baseURL 提取地域信息
func (s *Service) extractRegion(baseURL string) string {
// 美国地域
if strings.Contains(baseURL, "dashscope-us") {
func modelCatalogRegion(baseURL string) string {
parsed, err := url.Parse(strings.TrimSpace(baseURL))
if err != nil {
return ""
}
host := strings.ToLower(parsed.Hostname())
if strings.Contains(host, "dashscope-us") {
return "us-east-1"
}
// 亚太地域(新加坡)
if strings.Contains(baseURL, "ap-southeast-1") {
if strings.Contains(host, "ap-southeast-1") {
return "ap-southeast-1"
}
// 默认:中国北京
return "cn-beijing"
}
// isPluginEnabled 检查插件机制是否启用
func (s *Service) isPluginEnabled() bool {
// 通过环境变量控制
value := os.Getenv("ENABLE_PROVIDER_PLUGINS")
return strings.ToLower(value) == "true" || value == "1"
value := strings.ToLower(strings.TrimSpace(os.Getenv("ENABLE_PROVIDER_PLUGINS")))
return value == "true" || value == "1"
}