diff --git a/backend/internal/provider/bailian/discovery.go b/backend/internal/provider/bailian/discovery.go index bc8a46b48..b7704a9ee 100644 --- a/backend/internal/provider/bailian/discovery.go +++ b/backend/internal/provider/bailian/discovery.go @@ -1,11 +1,7 @@ package bailian import ( - "context" - "encoding/json" - "fmt" - "net/http" - "sort" + "net/url" "strings" "infinite-canvas/backend/internal/provider" @@ -26,109 +22,18 @@ func (d *Discovery) GetProviderID() string { // Match 判断是否匹配阿里云百炼 func (d *Discovery) Match(baseURL string, headers map[string]string) bool { - // 匹配阿里云百炼域名 - return strings.Contains(baseURL, "dashscope") || - strings.Contains(baseURL, "maas.aliyuncs.com") + parsed, err := url.Parse(strings.TrimSpace(baseURL)) + if err != nil { + return false + } + host := strings.ToLower(parsed.Hostname()) + dashscopeHost := host == "dashscope.aliyuncs.com" || strings.HasSuffix(host, ".dashscope.aliyuncs.com") || (strings.HasPrefix(host, "dashscope-") && strings.HasSuffix(host, ".aliyuncs.com")) + return dashscopeHost || host == "maas.aliyuncs.com" || strings.HasSuffix(host, ".maas.aliyuncs.com") } -// DiscoverModels 发现模型列表 -func (d *Discovery) DiscoverModels(ctx context.Context, config provider.DiscoveryConfig) ([]provider.Model, error) { - // 1. 调用标准 /v1/models 获取文本模型 - textModels, err := d.fetchStandardModels(ctx, config) - if err != nil { - return nil, err - } - - // 2. 添加百炼特有的多模态模型 - extendedModels := d.getExtendedModels(config.Region) - - // 3. 合并并去重 - allModels := append(textModels, extendedModels...) - return d.deduplicate(allModels), nil -} - -// fetchStandardModels 调用标准 /v1/models 接口(自己实现,不依赖 openai 包) -func (d *Discovery) fetchStandardModels(ctx context.Context, config provider.DiscoveryConfig) ([]provider.Model, error) { - // 构建 URL - target := config.BaseURL - if !strings.HasSuffix(target, "/models") { - if strings.HasSuffix(target, "/") { - target += "models" - } else { - target += "/models" - } - } - - // 创建请求 - req, err := http.NewRequestWithContext(ctx, "GET", target, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - // 设置认证头 - req.Header.Set("Authorization", "Bearer "+config.APIKey) - for k, v := range config.Headers { - req.Header.Set(k, v) - } - - // 执行请求 - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to fetch models: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - // 解析响应 - var payload struct { - Data []struct { - ID string `json:"id"` - Name string `json:"name"` - SupportedEndpointTypes []string `json:"supported_generation_methods"` - } `json:"data"` - } - - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) - } - - // 转换为统一格式 - models := make([]provider.Model, 0, len(payload.Data)) - seen := make(map[string]bool) - - for _, item := range payload.Data { - name := strings.TrimSpace(item.ID) - if name == "" { - name = strings.TrimSpace(item.Name) - } - name = strings.TrimPrefix(name, "models/") - - if name == "" || seen[name] { - continue - } - seen[name] = true - - models = append(models, provider.Model{ - ID: name, - DisplayName: name, - Provider: "bailian", - Capability: []string{"text"}, - SupportedEndpointTypes: item.SupportedEndpointTypes, - Metadata: map[string]any{ - "source": "standard", - }, - }) - } - - sort.Slice(models, func(i, j int) bool { - return models[i].ID < models[j].ID - }) - - return models, nil +// AdditionalModels 只返回百炼标准 /models 未暴露的补充目录。 +func (d *Discovery) AdditionalModels(_ provider.DiscoveryConfig) []provider.Model { + return d.deduplicate(d.getExtendedModels()) } // GetMetadata 返回插件元数据 @@ -143,7 +48,7 @@ func (d *Discovery) GetMetadata() provider.ProviderMetadata { } // getExtendedModels 获取百炼特有的扩展模型列表 -func (d *Discovery) getExtendedModels(region string) []provider.Model { +func (d *Discovery) getExtendedModels() []provider.Model { models := []provider.Model{ // === 视频生成模型 === { @@ -154,8 +59,8 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model { SupportedEndpointTypes: []string{"video"}, APIPath: "/api/v1/services/aigc/video-generation/video-synthesis", Metadata: map[string]any{ - "source": "extended", - "model_type": "video_generation", + "source": "extended", + "model_type": "video_generation", "generation_mode": "text_to_video", }, }, @@ -167,8 +72,8 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model { SupportedEndpointTypes: []string{"video"}, APIPath: "/api/v1/services/aigc/video-generation/video-synthesis", Metadata: map[string]any{ - "source": "extended", - "model_type": "video_generation", + "source": "extended", + "model_type": "video_generation", "generation_mode": "image_to_video", }, }, @@ -180,8 +85,8 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model { SupportedEndpointTypes: []string{"video"}, APIPath: "/api/v1/services/aigc/video-generation/video-synthesis", Metadata: map[string]any{ - "source": "extended", - "model_type": "video_generation", + "source": "extended", + "model_type": "video_generation", "generation_mode": "reference_to_video", }, }, @@ -193,12 +98,12 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model { SupportedEndpointTypes: []string{"video"}, APIPath: "/api/v1/services/aigc/video-generation/video-synthesis", Metadata: map[string]any{ - "source": "extended", - "model_type": "video_generation", + "source": "extended", + "model_type": "video_generation", "generation_mode": "video_edit", }, }, - + // Wan 系列视频生成模型 { ID: "wan2.7-t2v-2026-06-12", @@ -208,8 +113,8 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model { SupportedEndpointTypes: []string{"video"}, APIPath: "/api/v1/services/aigc/video-generation/video-synthesis", Metadata: map[string]any{ - "source": "extended", - "model_type": "video_generation", + "source": "extended", + "model_type": "video_generation", "generation_mode": "text_to_video", }, }, @@ -221,8 +126,8 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model { SupportedEndpointTypes: []string{"video"}, APIPath: "/api/v1/services/aigc/video-generation/video-synthesis", Metadata: map[string]any{ - "source": "extended", - "model_type": "video_generation", + "source": "extended", + "model_type": "video_generation", "generation_mode": "image_to_video", }, }, @@ -234,12 +139,12 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model { SupportedEndpointTypes: []string{"video"}, APIPath: "/api/v1/services/aigc/video-generation/video-synthesis", Metadata: map[string]any{ - "source": "extended", - "model_type": "video_generation", + "source": "extended", + "model_type": "video_generation", "generation_mode": "reference_to_video", }, }, - + // Kling(可灵)系列视频生成模型 { ID: "kling/kling-v3-omni-video-generation", @@ -249,10 +154,10 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model { SupportedEndpointTypes: []string{"video"}, APIPath: "/api/v1/services/aigc/video-generation/video-synthesis", Metadata: map[string]any{ - "source": "extended", - "model_type": "video_generation", - "resolution": "up to 4K", - "duration": "3-15s", + "source": "extended", + "model_type": "video_generation", + "resolution": "up to 4K", + "duration": "3-15s", "generation_modes": []string{"text_to_video", "image_to_video", "reference_to_video", "video_edit"}, }, }, @@ -264,14 +169,14 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model { SupportedEndpointTypes: []string{"video"}, APIPath: "/api/v1/services/aigc/video-generation/video-synthesis", Metadata: map[string]any{ - "source": "extended", - "model_type": "video_generation", - "resolution": "720P-4K", - "duration": "3-15s", + "source": "extended", + "model_type": "video_generation", + "resolution": "720P-4K", + "duration": "3-15s", "generation_modes": []string{"text_to_video", "image_to_video"}, }, }, - + // PixVerse(爱诗)系列 - 文生视频 { ID: "pixverse/pixverse-c1-t2v", @@ -281,11 +186,11 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model { SupportedEndpointTypes: []string{"video"}, APIPath: "/api/v1/services/aigc/video-generation/video-synthesis", Metadata: map[string]any{ - "source": "extended", - "model_type": "video_generation", + "source": "extended", + "model_type": "video_generation", "generation_mode": "text_to_video", - "recommended": "action scenes, effects", - "duration": "1-15s", + "recommended": "action scenes, effects", + "duration": "1-15s", }, }, { @@ -296,11 +201,11 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model { SupportedEndpointTypes: []string{"video"}, APIPath: "/api/v1/services/aigc/video-generation/video-synthesis", Metadata: map[string]any{ - "source": "extended", - "model_type": "video_generation", + "source": "extended", + "model_type": "video_generation", "generation_mode": "text_to_video", - "recommended": "general purpose", - "duration": "1-15s", + "recommended": "general purpose", + "duration": "1-15s", }, }, { @@ -311,13 +216,13 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model { SupportedEndpointTypes: []string{"video"}, APIPath: "/api/v1/services/aigc/video-generation/video-synthesis", Metadata: map[string]any{ - "source": "extended", - "model_type": "video_generation", + "source": "extended", + "model_type": "video_generation", "generation_mode": "text_to_video", - "deprecated": "recommend upgrade to v6", + "deprecated": "recommend upgrade to v6", }, }, - + // PixVerse(爱诗)系列 - 图生视频 { ID: "pixverse/pixverse-c1-it2v", @@ -327,10 +232,10 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model { SupportedEndpointTypes: []string{"video"}, APIPath: "/api/v1/services/aigc/video-generation/video-synthesis", Metadata: map[string]any{ - "source": "extended", - "model_type": "video_generation", + "source": "extended", + "model_type": "video_generation", "generation_mode": "image_to_video", - "duration": "1-15s", + "duration": "1-15s", }, }, { @@ -341,10 +246,10 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model { SupportedEndpointTypes: []string{"video"}, APIPath: "/api/v1/services/aigc/video-generation/video-synthesis", Metadata: map[string]any{ - "source": "extended", - "model_type": "video_generation", + "source": "extended", + "model_type": "video_generation", "generation_mode": "image_to_video", - "duration": "1-15s", + "duration": "1-15s", }, }, { @@ -355,12 +260,12 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model { SupportedEndpointTypes: []string{"video"}, APIPath: "/api/v1/services/aigc/video-generation/video-synthesis", Metadata: map[string]any{ - "source": "extended", - "model_type": "video_generation", + "source": "extended", + "model_type": "video_generation", "generation_mode": "image_to_video", }, }, - + // Vidu 系列视频生成模型 { ID: "vidu/viduq3-pro_text2video", @@ -370,11 +275,11 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model { SupportedEndpointTypes: []string{"video"}, APIPath: "/api/v1/services/aigc/video-generation/video-synthesis", Metadata: map[string]any{ - "source": "extended", - "model_type": "video_generation", + "source": "extended", + "model_type": "video_generation", "generation_mode": "text_to_video", - "resolution": "540P-1080P", - "duration": "1-16s", + "resolution": "540P-1080P", + "duration": "1-16s", }, }, { @@ -385,11 +290,11 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model { SupportedEndpointTypes: []string{"video"}, APIPath: "/api/v1/services/aigc/video-generation/video-synthesis", Metadata: map[string]any{ - "source": "extended", - "model_type": "video_generation", + "source": "extended", + "model_type": "video_generation", "generation_mode": "text_to_video", - "resolution": "540P-1080P", - "duration": "1-16s", + "resolution": "540P-1080P", + "duration": "1-16s", }, }, { @@ -400,27 +305,11 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model { SupportedEndpointTypes: []string{"video"}, APIPath: "/api/v1/services/aigc/video-generation/video-synthesis", Metadata: map[string]any{ - "source": "extended", - "model_type": "video_generation", + "source": "extended", + "model_type": "video_generation", "generation_mode": "text_to_video", - "resolution": "540P-1080P", - "duration": "1-10s", - }, - }, - - // === 3D 生成模型 === - { - ID: "Tripo/Tripo-H3.1", - DisplayName: "Tripo H3.1 3D生成", - Provider: "bailian", - Capability: []string{"image"}, // 前端无 3d 分类,暂归 image - SupportedEndpointTypes: []string{"image"}, - APIPath: "/api/v1/services/aigc/3d-generation", - RequiresPlan: true, - Metadata: map[string]any{ - "source": "extended", - "model_type": "3d_generation", - "note": "frontend lacks 3d category, classified as image", + "resolution": "540P-1080P", + "duration": "1-10s", }, }, @@ -451,21 +340,6 @@ func (d *Discovery) getExtendedModels(region string) []provider.Model { }, } - // 根据地域过滤(如果指定了地域) - if region != "" { - models = d.filterByRegion(models, region) - } - - return models -} - -// filterByRegion 根据地域过滤模型 -func (d *Discovery) filterByRegion(models []provider.Model, region string) []provider.Model { - // 美国地域可能不支持某些国内模型 - if region == "us-east-1" { - // 过滤逻辑:保留所有模型(示例) - // 实际可以根据需要过滤 - } return models } diff --git a/backend/internal/provider/interface.go b/backend/internal/provider/interface.go index 0e50b69f8..22ec5f9cf 100644 --- a/backend/internal/provider/interface.go +++ b/backend/internal/provider/interface.go @@ -1,11 +1,8 @@ package provider -import ( - "context" -) - -// ModelDiscovery 模型发现插件接口 -type ModelDiscovery interface { +// ModelCatalogPlugin 只补充标准 /models 没有暴露的厂商目录项。 +// 标准目录请求、鉴权和出站安全边界仍由 service 统一负责。 +type ModelCatalogPlugin interface { // GetProviderID 返回服务商唯一标识 GetProviderID() string @@ -13,8 +10,8 @@ type ModelDiscovery interface { // 基于 baseURL、headers 等信息判断 Match(baseURL string, headers map[string]string) bool - // DiscoverModels 发现模型列表 - DiscoverModels(ctx context.Context, config DiscoveryConfig) ([]Model, error) + // AdditionalModels 返回标准目录之外的补充模型。 + AdditionalModels(config DiscoveryConfig) []Model // GetMetadata 返回插件元数据 GetMetadata() ProviderMetadata @@ -22,13 +19,10 @@ type ModelDiscovery interface { // DiscoveryConfig 模型发现配置 type DiscoveryConfig struct { - BaseURL string // API 基础 URL - APIKey string // API 密钥 - SecretKey string // 可选的密钥对 - APIFormat string // API 格式:openai, gemini, anthropic - Headers map[string]string // 自定义请求头 - AllowLocalChannel bool // 是否允许本地渠道 - Region string // 地域(从 baseURL 解析或指定) + BaseURL string + APIFormat string + Headers map[string]string + Region string } // Model 统一的模型定义 diff --git a/backend/internal/provider/openai/discovery.go b/backend/internal/provider/openai/discovery.go deleted file mode 100644 index 82e34b35a..000000000 --- a/backend/internal/provider/openai/discovery.go +++ /dev/null @@ -1,205 +0,0 @@ -package openai - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "sort" - "strings" - - "infinite-canvas/backend/internal/provider" -) - -// Discovery OpenAI 标准插件 -type Discovery struct{} - -// New 创建 OpenAI 插件实例 -func New() *Discovery { - return &Discovery{} -} - -// GetProviderID 返回服务商标识 -func (d *Discovery) GetProviderID() string { - return "openai" -} - -// Match 判断是否匹配 OpenAI 兼容端点 -func (d *Discovery) Match(baseURL string, headers map[string]string) bool { - // 排除已知的其他服务商 - if strings.Contains(baseURL, "dashscope") || - strings.Contains(baseURL, "maas.aliyuncs.com") { - return false // 百炼有自己的插件 - } - - // 匹配标准的 OpenAI 兼容端点 - return strings.Contains(baseURL, "/v1") || - strings.Contains(baseURL, "openai.com") || - strings.Contains(baseURL, "compatible-mode") -} - -// DiscoverModels 发现模型列表 -func (d *Discovery) DiscoverModels(ctx context.Context, config provider.DiscoveryConfig) ([]provider.Model, error) { - // 构建 /v1/models 请求 - target := config.BaseURL - if !strings.HasSuffix(target, "/models") { - if strings.HasSuffix(target, "/") { - target += "models" - } else { - target += "/models" - } - } - - req, err := http.NewRequestWithContext(ctx, "GET", target, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - // 设置认证头 - if config.APIFormat == "gemini" { - req.Header.Set("x-goog-api-key", config.APIKey) - } else { - req.Header.Set("Authorization", "Bearer "+config.APIKey) - } - - // 添加自定义请求头 - for k, v := range config.Headers { - req.Header.Set(k, v) - } - - // 执行请求 - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("failed to fetch models: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) - } - - // 解析响应 - var payload struct { - Data []modelItem `json:"data"` - Models []modelItem `json:"models"` // Gemini 使用这个字段 - } - - if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) - } - - // 提取模型列表 - items := payload.Data - if config.APIFormat == "gemini" && len(payload.Models) > 0 { - items = payload.Models - } - - // 转换为统一格式 - models := make([]provider.Model, 0, len(items)) - seen := make(map[string]bool) - - for _, item := range items { - // 提取模型 ID - name := strings.TrimSpace(item.ID) - if name == "" { - name = strings.TrimSpace(item.Name) - } - name = strings.TrimPrefix(name, "models/") // 移除 Gemini 的前缀 - - if name == "" || seen[name] { - continue - } - seen[name] = true - - models = append(models, provider.Model{ - ID: name, - DisplayName: d.getDisplayName(item), - Provider: "openai", - Capability: d.inferCapability(name), - SupportedEndpointTypes: d.normalizeSupportedTypes(item.SupportedEndpointTypes), - Metadata: map[string]any{ - "source": "standard", - }, - }) - } - - // 排序 - sort.Slice(models, func(i, j int) bool { - return models[i].ID < models[j].ID - }) - - return models, nil -} - -// GetMetadata 返回插件元数据 -func (d *Discovery) GetMetadata() provider.ProviderMetadata { - return provider.ProviderMetadata{ - Name: "openai", - Version: "1.0.0", - Description: "Standard OpenAI-compatible model discovery", - Author: "infinite-canvas", - } -} - -// modelItem 内部使用的模型数据结构 -type modelItem struct { - ID string `json:"id"` - Name string `json:"name"` - SupportedEndpointTypes []string `json:"supported_generation_methods"` -} - -// getDisplayName 获取显示名称 -func (d *Discovery) getDisplayName(item modelItem) string { - if item.Name != "" { - return item.Name - } - return item.ID -} - -// inferCapability 根据模型 ID 推断能力 -func (d *Discovery) inferCapability(modelID string) []string { - lower := strings.ToLower(modelID) - - // 图像模型 - if strings.Contains(lower, "dall-e") || - strings.Contains(lower, "image") { - return []string{"image"} - } - - // 音频模型 - if strings.Contains(lower, "whisper") || - strings.Contains(lower, "tts") || - strings.Contains(lower, "audio") { - return []string{"audio"} - } - - // 嵌入模型 - if strings.Contains(lower, "embedding") { - return []string{"embedding"} - } - - // 默认为文本模型 - return []string{"text"} -} - -// normalizeSupportedTypes 标准化支持的端点类型 -func (d *Discovery) normalizeSupportedTypes(types []string) []string { - if len(types) == 0 { - return nil - } - - seen := make(map[string]bool) - result := make([]string, 0, len(types)) - - for _, t := range types { - normalized := strings.TrimSpace(t) - if normalized == "" || seen[normalized] { - continue - } - seen[normalized] = true - result = append(result, normalized) - } - - return result -} diff --git a/backend/internal/provider/registry.go b/backend/internal/provider/registry.go index 72bad5b47..e12d9af50 100644 --- a/backend/internal/provider/registry.go +++ b/backend/internal/provider/registry.go @@ -7,54 +7,50 @@ import ( // Registry 插件注册中心 type Registry struct { mu sync.RWMutex - providers map[string]ModelDiscovery + providers []ModelCatalogPlugin } -var globalRegistry = &Registry{ - providers: make(map[string]ModelDiscovery), -} +var globalRegistry = &Registry{} // Register 注册插件 -func Register(discovery ModelDiscovery) { +func Register(discovery ModelCatalogPlugin) { globalRegistry.mu.Lock() defer globalRegistry.mu.Unlock() id := discovery.GetProviderID() - globalRegistry.providers[id] = discovery + for index, registered := range globalRegistry.providers { + if registered.GetProviderID() == id { + globalRegistry.providers[index] = discovery + return + } + } + globalRegistry.providers = append(globalRegistry.providers, discovery) } // GetProvider 根据 ID 获取插件 -func GetProvider(id string) (ModelDiscovery, bool) { +func GetProvider(id string) (ModelCatalogPlugin, bool) { globalRegistry.mu.RLock() defer globalRegistry.mu.RUnlock() - provider, ok := globalRegistry.providers[id] - return provider, ok + for _, registered := range globalRegistry.providers { + if registered.GetProviderID() == id { + return registered, true + } + } + return nil, false } // MatchProvider 根据配置匹配插件 -func MatchProvider(baseURL string, headers map[string]string) ModelDiscovery { +func MatchProvider(baseURL string, headers map[string]string) ModelCatalogPlugin { globalRegistry.mu.RLock() defer globalRegistry.mu.RUnlock() - // 按优先级匹配 - // 1. 先匹配特定服务商(如百炼) - for _, provider := range globalRegistry.providers { - if provider.GetProviderID() == "openai" { - continue // OpenAI 插件最后匹配 - } - if provider.Match(baseURL, headers) { - return provider + // 按注册顺序匹配,避免 map 遍历导致多个厂商规则命中时结果不稳定。 + for _, registered := range globalRegistry.providers { + if registered.Match(baseURL, headers) { + return registered } } - - // 2. 最后使用 OpenAI 默认实现 - if provider, ok := globalRegistry.providers["openai"]; ok { - if provider.Match(baseURL, headers) { - return provider - } - } - return nil } @@ -64,8 +60,8 @@ func ListProviders() []ProviderMetadata { defer globalRegistry.mu.RUnlock() result := make([]ProviderMetadata, 0, len(globalRegistry.providers)) - for _, provider := range globalRegistry.providers { - result = append(result, provider.GetMetadata()) + for _, registered := range globalRegistry.providers { + result = append(result, registered.GetMetadata()) } return result } diff --git a/backend/internal/service/channel_model_catalog.go b/backend/internal/service/channel_model_catalog.go index 681493b7e..0a030bc2e 100644 --- a/backend/internal/service/channel_model_catalog.go +++ b/backend/internal/service/channel_model_catalog.go @@ -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 } diff --git a/backend/internal/service/channel_model_catalog_plugin.go b/backend/internal/service/channel_model_catalog_plugin.go index 620fced76..e687919fa 100644 --- a/backend/internal/service/channel_model_catalog_plugin.go +++ b/backend/internal/service/channel_model_catalog_plugin.go @@ -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" }