fix(security): 模型目录 - 收敛插件出站边界并保留补充能力元数据
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 统一的模型定义
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user