feat(*): 模型渠道 - 支持系统模型图标并升级 Agnes 视频协议
This commit is contained in:
1 parent
7cb6aed27d
commit
b3e977a985
30 files changed
+861
-44
No files matched your search
@@ -119,6 +119,7 @@ type PublicModelChannel struct {
|
||||
type PublicChannelModelPrice struct {
|
||||
Model string `json:"model"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Icon string `json:"icon"`
|
||||
Capability string `json:"capability"`
|
||||
Protocol model.ChannelInterfaceType `json:"protocol"`
|
||||
BillingMode string `json:"billingMode"`
|
||||
@@ -828,11 +829,11 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model
|
||||
if item.Enabled && item.PriceConfigured {
|
||||
capabilityConfig, decodeErr := DecodeModelCapabilityConfig(item.CapabilityConfigJSON)
|
||||
if decodeErr == nil && capabilityConfig != nil {
|
||||
if normalized, normalizeErr := NormalizeModelCapabilityConfig(item.Capability, string(item.Protocol), capabilityConfig); normalizeErr == nil {
|
||||
if normalized, normalizeErr := NormalizeModelCapabilityConfigForModel(item.Capability, string(item.Protocol), firstNonEmpty(item.ProviderModelKey, item.ModelKey), capabilityConfig); normalizeErr == nil {
|
||||
capabilityConfig = normalized
|
||||
}
|
||||
}
|
||||
modelCosts = append(modelCosts, PublicChannelModelPrice{Model: item.ModelKey, DisplayName: item.DisplayName, Capability: item.Capability, Protocol: item.Protocol, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputTokenPriceMicrocredits: item.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: item.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: item.CachedTokenPriceMicrocredits, CapabilityConfig: capabilityConfig})
|
||||
modelCosts = append(modelCosts, PublicChannelModelPrice{Model: item.ModelKey, DisplayName: item.DisplayName, Icon: item.Icon, Capability: item.Capability, Protocol: item.Protocol, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputTokenPriceMicrocredits: item.InputTokenPriceMicrocredits, OutputTokenPriceMicrocredits: item.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: item.CachedTokenPriceMicrocredits, CapabilityConfig: capabilityConfig})
|
||||
}
|
||||
}
|
||||
if len(models) == 0 {
|
||||
|
||||
@@ -244,7 +244,7 @@ func newChannelModelTestService(t *testing.T) (*Service, *gorm.DB) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.ModelChannel{}, &model.ChannelModel{}, &model.IDSequence{}); err != nil {
|
||||
if err := db.AutoMigrate(&model.ModelChannel{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}, &model.IDSequence{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Service{repo: repository.New(db)}, db
|
||||
|
||||
@@ -21,6 +21,7 @@ type ChannelModelRequest struct {
|
||||
ModelKey string `json:"modelKey"`
|
||||
ProviderModelKey string `json:"providerModelKey"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Icon string `json:"icon"`
|
||||
Capability string `json:"capability"`
|
||||
Protocol string `json:"protocol"`
|
||||
BillingMode string `json:"billingMode"`
|
||||
@@ -100,7 +101,7 @@ func (s *Service) AdminChannelModels(actor *model.User, channelID string) ([]mod
|
||||
if decodeErr != nil || config == nil {
|
||||
continue
|
||||
}
|
||||
normalized, normalizeErr := NormalizeModelCapabilityConfig(items[index].Capability, string(items[index].Protocol), config)
|
||||
normalized, normalizeErr := NormalizeModelCapabilityConfigForModel(items[index].Capability, string(items[index].Protocol), firstNonEmpty(items[index].ProviderModelKey, items[index].ModelKey), config)
|
||||
if normalizeErr != nil || normalized == nil {
|
||||
continue
|
||||
}
|
||||
@@ -204,7 +205,7 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id
|
||||
return nil, BadAuthRequest("该渠道已存在模型 " + modelKey + ",请直接编辑已有模型")
|
||||
}
|
||||
if capability == "text" || capability == "image" || capability == "video" {
|
||||
if _, err := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig); err != nil {
|
||||
if _, err := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -230,11 +231,12 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id
|
||||
if item.DisplayName == "" {
|
||||
item.DisplayName = modelKey
|
||||
}
|
||||
item.Icon = strings.TrimSpace(req.Icon)
|
||||
item.Capability = capability
|
||||
item.Protocol = protocol
|
||||
s.applyChannelModelPriceTierSummary(item, tiers)
|
||||
if capability == "text" || capability == "image" || capability == "video" {
|
||||
capabilityConfig, normalizeErr := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig)
|
||||
capabilityConfig, normalizeErr := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig)
|
||||
if normalizeErr != nil {
|
||||
return nil, normalizeErr
|
||||
}
|
||||
@@ -529,7 +531,7 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User,
|
||||
return nil, err
|
||||
}
|
||||
if capability == "text" || capability == "image" || capability == "video" {
|
||||
if _, err := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig); err != nil {
|
||||
if _, err := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -559,7 +561,7 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User,
|
||||
imageSize, imageQuality := "", ""
|
||||
var imageProfile *ImageCapabilityConfig
|
||||
if capability == "image" {
|
||||
profile, normalizeErr := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig)
|
||||
profile, normalizeErr := NormalizeModelCapabilityConfigForModel(capability, string(protocol), providerModelKey, req.CapabilityConfig)
|
||||
if normalizeErr != nil {
|
||||
return nil, normalizeErr
|
||||
}
|
||||
|
||||
@@ -52,6 +52,41 @@ func TestNormalizeChannelModelContractRequiresJiMengSecret(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveAdminChannelModelPersistsAndPublishesIcon(t *testing.T) {
|
||||
svc, db := newChannelModelTestService(t)
|
||||
admin := &model.User{ID: "admin", Role: model.UserRoleAdmin}
|
||||
channel := model.ModelChannel{ID: "channel-1", UserID: admin.ID, Scope: model.ChannelScopeSystem, Enabled: true, Name: "Test", BaseURL: "https://example.com/v1", APIKey: "key", APIFormat: "openai", ModelsJSON: `[]`}
|
||||
if err := db.Create(&channel).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
enabled := true
|
||||
saved, err := svc.SaveAdminChannelModel(admin, channel.ID, "", ChannelModelRequest{
|
||||
ModelKey: "gpt-test", DisplayName: "GPT Test", Icon: "OpenAI", Capability: "text", Protocol: string(model.ChannelInterfaceChatCompletion),
|
||||
CapabilityConfig: DefaultModelCapabilityConfigForModel(string(model.ChannelInterfaceChatCompletion), "gpt-test"),
|
||||
PriceTiers: []ChannelModelPriceTierRequest{{BillingMode: "fixed_request", PriceConfigured: true, Enabled: &enabled}}, Enabled: &enabled,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.Icon != "OpenAI" {
|
||||
t.Fatalf("saved icon = %q, want OpenAI", saved.Icon)
|
||||
}
|
||||
var stored model.ChannelModel
|
||||
if err := db.First(&stored, "id = ?", saved.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stored.Icon != "OpenAI" {
|
||||
t.Fatalf("stored icon = %q, want OpenAI", stored.Icon)
|
||||
}
|
||||
if public := svc.sanitizeChannelModel(saved); public.Icon != "OpenAI" {
|
||||
t.Fatalf("public icon = %q, want OpenAI", public.Icon)
|
||||
}
|
||||
legacyPublic := publicChannel(channel, false, []model.ChannelModel{*saved})
|
||||
if len(legacyPublic.ModelCosts) != 1 || legacyPublic.ModelCosts[0].Icon != "OpenAI" {
|
||||
t.Fatalf("legacy public model costs = %#v", legacyPublic.ModelCosts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageTestDefaultsUseModelCapability(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -761,6 +761,12 @@ func channelModelCapabilitySpec(channelModel model.ChannelModel) (CapabilitySpec
|
||||
if err != nil {
|
||||
return CapabilitySpec{}, BadAuthRequest("渠道模型能力配置无效,请先修复渠道模型")
|
||||
}
|
||||
if config != nil {
|
||||
config, err = NormalizeModelCapabilityConfigForModel(normalizeCapability(channelModel.Capability), string(channelModel.Protocol), firstNonEmpty(channelModel.ProviderModelKey, channelModel.ModelKey), config)
|
||||
if err != nil {
|
||||
return CapabilitySpec{}, err
|
||||
}
|
||||
}
|
||||
spec, err := CapabilitySpecFromModelCapabilityConfig(config, normalizeCapability(channelModel.Capability))
|
||||
if err != nil {
|
||||
return CapabilitySpec{}, err
|
||||
@@ -821,6 +827,12 @@ func channelModelDefaultOptions(channelModel model.ChannelModel, spec Capability
|
||||
if err != nil {
|
||||
return nil, BadAuthRequest("渠道模型能力配置无效,请先修复渠道模型")
|
||||
}
|
||||
if config != nil {
|
||||
config, err = NormalizeModelCapabilityConfigForModel(normalizeCapability(channelModel.Capability), string(channelModel.Protocol), firstNonEmpty(channelModel.ProviderModelKey, channelModel.ModelKey), config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
defaults := make(map[string]any)
|
||||
if config != nil {
|
||||
switch normalizeCapability(channelModel.Capability) {
|
||||
|
||||
@@ -245,6 +245,8 @@ func DefaultModelCapabilityConfigForModel(protocol string, modelName string) *Mo
|
||||
video.Resolutions = []string{"768P", "2K"}
|
||||
video.DefaultResolution = "768P"
|
||||
video.Watermark = VideoBooleanConfig{Supported: true, Default: false}
|
||||
case model.ChannelInterfaceAgnesVideo:
|
||||
video = applyModelSpecificVideoCapability(video, protocol, modelName)
|
||||
}
|
||||
return &ModelCapabilityConfig{Version: 1, Text: text, Image: DefaultImageCapabilityConfig(protocol, modelName), Video: video}
|
||||
}
|
||||
@@ -260,7 +262,11 @@ func DecodeModelCapabilityConfig(raw string) (*ModelCapabilityConfig, error) {
|
||||
return &value, nil
|
||||
}
|
||||
|
||||
func NormalizeModelCapabilityConfig(capability string, _ string, input *ModelCapabilityConfig) (*ModelCapabilityConfig, error) {
|
||||
func NormalizeModelCapabilityConfig(capability string, protocol string, input *ModelCapabilityConfig) (*ModelCapabilityConfig, error) {
|
||||
return NormalizeModelCapabilityConfigForModel(capability, protocol, "", input)
|
||||
}
|
||||
|
||||
func NormalizeModelCapabilityConfigForModel(capability string, protocol string, modelName string, input *ModelCapabilityConfig) (*ModelCapabilityConfig, error) {
|
||||
if capability != "text" && capability != "image" && capability != "video" {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -287,13 +293,52 @@ func NormalizeModelCapabilityConfig(capability string, _ string, input *ModelCap
|
||||
if input == nil || input.Video == nil {
|
||||
return nil, BadAuthRequest("请配置视频模型能力参数")
|
||||
}
|
||||
value := &ModelCapabilityConfig{Version: 1, Video: input.Video}
|
||||
value := &ModelCapabilityConfig{Version: 1, Video: applyModelSpecificVideoCapability(input.Video, protocol, modelName)}
|
||||
if err := validateVideoCapabilityConfig(value.Video); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func applyModelSpecificVideoCapability(profile *VideoCapabilityConfig, protocol string, modelName string) *VideoCapabilityConfig {
|
||||
if profile == nil || model.ChannelInterfaceType(strings.TrimSpace(protocol)) != model.ChannelInterfaceAgnesVideo {
|
||||
return profile
|
||||
}
|
||||
normalizedModel := strings.ToLower(strings.TrimSpace(modelName))
|
||||
if normalizedModel != "agnes-video-2.5" && normalizedModel != "agnes-video-2.5-flash" {
|
||||
return profile
|
||||
}
|
||||
value := *profile
|
||||
value.References = profile.References
|
||||
flash := normalizedModel == "agnes-video-2.5-flash"
|
||||
value.References.MaxImages = 9
|
||||
value.References.MaxVideos = 3
|
||||
value.References.MaxAudios = 3
|
||||
value.References.MaxVideoBytes = 200 * 1024 * 1024
|
||||
value.References.MaxVideoDuration = 15
|
||||
value.References.MaxAudioBytes = 15 * 1024 * 1024
|
||||
value.References.MaxAudioDuration = 15
|
||||
if flash {
|
||||
value.References.MaxImages = 5
|
||||
value.References.MaxVideos = 0
|
||||
value.References.MaxVideoBytes = 0
|
||||
value.References.MaxVideoDuration = 0
|
||||
}
|
||||
value.Duration = VideoDurationConfig{Selection: "range", Min: 4, Max: 12, Step: 1, Default: 5}
|
||||
value.Ratios = []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16"}
|
||||
value.DefaultRatio = "16:9"
|
||||
value.Resolutions = []string{"720P", "960P", "2K"}
|
||||
if flash {
|
||||
value.Resolutions = []string{"720P"}
|
||||
}
|
||||
value.DefaultResolution = "720P"
|
||||
value.GenerateAudio = VideoBooleanConfig{Supported: false, Default: false}
|
||||
value.Watermark = VideoBooleanConfig{Supported: false, Default: false}
|
||||
value.Operations = []string{"text_to_video", "image_to_video", "reference_to_video", "audio_to_video"}
|
||||
value.DefaultOperation = "text_to_video"
|
||||
return &value
|
||||
}
|
||||
|
||||
// CapabilitySpecFromModelCapabilityConfig 将渠道模型的真实供应能力投影为路由能力规格。
|
||||
// 渠道模型能力参数是唯一事实来源,前台模型供应线路直接引用该规格。
|
||||
func CapabilitySpecFromModelCapabilityConfig(config *ModelCapabilityConfig, capability string) (CapabilitySpec, error) {
|
||||
@@ -557,10 +602,18 @@ func (s *Service) ValidateTaskCapability(input map[string]any) error {
|
||||
}
|
||||
return validateImageTask(profile, taskInput)
|
||||
}
|
||||
if taskInput.Config.CapabilityConfig == nil || taskInput.Config.CapabilityConfig.Video == nil {
|
||||
return nil
|
||||
profile := taskInput.Config.CapabilityConfig
|
||||
if profile == nil || profile.Video == nil {
|
||||
if taskInput.Config.InterfaceType != string(model.ChannelInterfaceAgnesVideo) {
|
||||
return nil
|
||||
}
|
||||
profile = DefaultModelCapabilityConfigForModel(taskInput.Config.InterfaceType, taskInput.Config.Model)
|
||||
}
|
||||
return validateVideoTask(taskInput.Config.CapabilityConfig.Video, taskInput)
|
||||
normalized, normalizeErr := NormalizeModelCapabilityConfigForModel("video", taskInput.Config.InterfaceType, taskInput.Config.Model, profile)
|
||||
if normalizeErr != nil || normalized == nil || normalized.Video == nil {
|
||||
return BadAuthRequest("当前视频模型能力参数无效")
|
||||
}
|
||||
return validateVideoTask(normalized.Video, taskInput)
|
||||
}
|
||||
item, err := s.repo.ChannelModelByKey(channelID, providerChannelModelKey(taskInput.Config))
|
||||
if err != nil {
|
||||
@@ -580,11 +633,15 @@ func (s *Service) ValidateTaskCapability(input map[string]any) error {
|
||||
if err != nil || profile == nil || profile.Video == nil {
|
||||
return BadAuthRequest("当前视频模型尚未配置能力参数")
|
||||
}
|
||||
applyFixedVideoResolution(&taskInput, profile.Video)
|
||||
normalized, normalizeErr := NormalizeModelCapabilityConfigForModel("video", string(item.Protocol), firstNonEmpty(item.ProviderModelKey, item.ModelKey), profile)
|
||||
if normalizeErr != nil || normalized == nil || normalized.Video == nil {
|
||||
return BadAuthRequest("当前视频模型能力参数无效")
|
||||
}
|
||||
applyFixedVideoResolution(&taskInput, normalized.Video)
|
||||
if config, ok := input["config"].(map[string]any); ok {
|
||||
config["vquality"] = taskInput.Config.VQuality
|
||||
}
|
||||
return validateVideoTask(profile.Video, taskInput)
|
||||
return validateVideoTask(normalized.Video, taskInput)
|
||||
}
|
||||
|
||||
// applyModelSpecificImageCapability is retained as a narrow normalization hook
|
||||
|
||||
@@ -93,6 +93,38 @@ func TestDefaultMiniMaxVideoCapabilitySupportsReferenceGeneration(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgnesVideo25CapabilityUsesOfficialLimits(t *testing.T) {
|
||||
standard := DefaultModelCapabilityConfigForModel("agnes-video", "agnes-video-2.5").Video
|
||||
if standard.Duration.Min != 4 || standard.Duration.Max != 12 || standard.Duration.Default != 5 {
|
||||
t.Fatalf("Agnes 2.5 duration = %#v", standard.Duration)
|
||||
}
|
||||
if fmt.Sprint(standard.Resolutions) != fmt.Sprint([]string{"720P", "960P", "2K"}) || standard.References.MaxVideos != 3 {
|
||||
t.Fatalf("Agnes 2.5 capability = %#v", standard)
|
||||
}
|
||||
flash := DefaultModelCapabilityConfigForModel("agnes-video", "agnes-video-2.5-flash").Video
|
||||
if fmt.Sprint(flash.Resolutions) != fmt.Sprint([]string{"720P"}) || flash.References.MaxImages != 5 || flash.References.MaxVideos != 0 {
|
||||
t.Fatalf("Agnes 2.5 Flash capability = %#v", flash)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAgnesVideo25CapabilityRepairsLegacyStoredLimits(t *testing.T) {
|
||||
legacy := DefaultModelCapabilityConfigForModel("newapi", "legacy-video")
|
||||
normalized, err := NormalizeModelCapabilityConfigForModel("video", "agnes-video", "agnes-video-2.5", legacy)
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeModelCapabilityConfigForModel() error = %v", err)
|
||||
}
|
||||
if normalized.Video.Duration.Min != 4 || normalized.Video.Duration.Max != 12 || normalized.Video.Duration.Default != 5 {
|
||||
t.Fatalf("normalized duration = %#v", normalized.Video.Duration)
|
||||
}
|
||||
if fmt.Sprint(normalized.Video.Resolutions) != fmt.Sprint([]string{"720P", "960P", "2K"}) {
|
||||
t.Fatalf("normalized resolutions = %v", normalized.Video.Resolutions)
|
||||
}
|
||||
input := canvasGenerationInput{Config: providerConfig{InterfaceType: "agnes-video", Model: "agnes-video-2.5", VideoSeconds: "15", Size: "16:9", VQuality: "720P"}}
|
||||
if err := validateVideoTask(normalized.Video, input); err == nil {
|
||||
t.Fatalf("validateVideoTask(15 seconds) error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultVolcengineArkVideoCapabilitySupportsFullModalReference(t *testing.T) {
|
||||
profile := DefaultModelCapabilityConfigForModel("volcengine-ark-video", "doubao-seedance-2-0-260128")
|
||||
if profile == nil || profile.Video == nil {
|
||||
|
||||
@@ -35,6 +35,7 @@ type PublicChannelModel struct {
|
||||
ID string `json:"id"`
|
||||
ModelKey string `json:"modelKey"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Icon string `json:"icon"`
|
||||
Capability string `json:"capability"`
|
||||
Protocol model.ChannelInterfaceType `json:"protocol"`
|
||||
CapabilityConfig map[string]any `json:"capabilityConfig,omitempty"`
|
||||
@@ -168,7 +169,7 @@ func (s *Service) sanitizeChannelModel(cm *model.ChannelModel) PublicChannelMode
|
||||
if cm.CapabilityConfigJSON != "" {
|
||||
config, _ := DecodeModelCapabilityConfig(cm.CapabilityConfigJSON)
|
||||
if config != nil {
|
||||
normalized, _ := NormalizeModelCapabilityConfig(cm.Capability, string(cm.Protocol), config)
|
||||
normalized, _ := NormalizeModelCapabilityConfigForModel(cm.Capability, string(cm.Protocol), firstNonEmpty(cm.ProviderModelKey, cm.ModelKey), config)
|
||||
if normalized != nil {
|
||||
// 转换为 map[string]any
|
||||
capabilityConfig = modelCapabilityConfigToMap(normalized)
|
||||
@@ -180,6 +181,7 @@ func (s *Service) sanitizeChannelModel(cm *model.ChannelModel) PublicChannelMode
|
||||
ID: cm.ID,
|
||||
ModelKey: cm.ModelKey,
|
||||
DisplayName: cm.DisplayName,
|
||||
Icon: cm.Icon,
|
||||
Capability: cm.Capability,
|
||||
Protocol: cm.Protocol,
|
||||
CapabilityConfig: capabilityConfig,
|
||||
|
||||
@@ -150,7 +150,8 @@ func (c *pluginRuntime) bootstrapBundledPlugins() error {
|
||||
Permissions: []string{"generation.run"},
|
||||
Contributes: protocol.ManifestContributions{Providers: []protocol.ManifestProvider{{
|
||||
ID: metadata.ID, Label: metadata.Name, Capabilities: metadata.Categories, Scopes: metadata.Scopes,
|
||||
Parameters: metadata.Parameters, Create: protocol.ManifestOperation{Method: "POST", Path: "/__host__/" + metadata.ID}, Response: protocol.ManifestResponse{},
|
||||
Parameters: metadata.Parameters, RequiresPublicMediaURLs: metadata.RequiresPublicMediaURLs,
|
||||
Create: protocol.ManifestOperation{Method: "POST", Path: "/__host__/" + metadata.ID}, Response: protocol.ManifestResponse{},
|
||||
}}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,12 +18,16 @@ func withProtocolRegistry(ctx context.Context, registry *protocol.Registry) cont
|
||||
return context.WithValue(ctx, protocolRegistryContextKey{}, registry)
|
||||
}
|
||||
|
||||
func declarativeProtocolAdapterForContext(ctx context.Context, id string) (protocol.Adapter, bool) {
|
||||
func protocolAdapterForContext(ctx context.Context, id string) (protocol.Adapter, bool) {
|
||||
registry, _ := ctx.Value(protocolRegistryContextKey{}).(*protocol.Registry)
|
||||
if registry == nil {
|
||||
registry = protocol.Builtins()
|
||||
}
|
||||
adapter, ok := registry.Resolve(strings.TrimSpace(id))
|
||||
return registry.Resolve(strings.TrimSpace(id))
|
||||
}
|
||||
|
||||
func declarativeProtocolAdapterForContext(ctx context.Context, id string) (protocol.Adapter, bool) {
|
||||
adapter, ok := protocolAdapterForContext(ctx, id)
|
||||
if !ok || adapter.Metadata().Execution != "declarative" {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
@@ -378,7 +378,7 @@ func (s *Service) processCanvasGenerationTask(ctx context.Context, userID string
|
||||
}
|
||||
if resumedProviderRequestID(ctx) == "" {
|
||||
requirePublicURL := input.Config.InterfaceType == "newapi-channel-1" || input.Config.InterfaceType == "newapi-channel-2" || input.Config.InterfaceType == string(model.ChannelInterfaceVolcengineArkVideo) || input.Config.InterfaceType == string(model.ChannelInterfaceMiniMaxVideo)
|
||||
if adapter, ok := declarativeProtocolAdapterForContext(ctx, input.Config.InterfaceType); ok {
|
||||
if adapter, ok := protocolAdapterForContext(ctx, input.Config.InterfaceType); ok {
|
||||
requirePublicURL = requirePublicURL || adapter.Metadata().RequiresPublicMediaURLs
|
||||
}
|
||||
if err := s.hydrateGenerationMedia(userID, &input, requirePublicURL); err != nil {
|
||||
@@ -1199,11 +1199,21 @@ func styleAssetSupportsModel(baseModels []string, generationModel string) bool {
|
||||
func (s *Service) validateResolvedVideoCapability(input *canvasGenerationInput) error {
|
||||
channelID := strings.TrimSpace(input.Config.ChannelID)
|
||||
if channelID == "" {
|
||||
if input.Config.CapabilityConfig == nil || input.Config.CapabilityConfig.Video == nil {
|
||||
return nil
|
||||
profile := input.Config.CapabilityConfig
|
||||
if profile == nil || profile.Video == nil {
|
||||
if input.Config.InterfaceType != string(model.ChannelInterfaceAgnesVideo) {
|
||||
return nil
|
||||
}
|
||||
profile = DefaultModelCapabilityConfigForModel(input.Config.InterfaceType, input.Config.Model)
|
||||
}
|
||||
input.VideoCapability = input.Config.CapabilityConfig.Video
|
||||
return validateVideoTask(input.VideoCapability, *input)
|
||||
normalized, err := NormalizeModelCapabilityConfigForModel("video", input.Config.InterfaceType, input.Config.Model, profile)
|
||||
if err != nil || normalized == nil || normalized.Video == nil {
|
||||
return errors.New("当前视频模型能力参数无效")
|
||||
}
|
||||
input.Config.CapabilityConfig = normalized
|
||||
input.VideoCapability = normalized.Video
|
||||
applyFixedVideoResolution(input, normalized.Video)
|
||||
return validateVideoTask(normalized.Video, *input)
|
||||
}
|
||||
item, err := s.repo.ChannelModelByKey(channelID, providerChannelModelKey(input.Config))
|
||||
if err != nil {
|
||||
@@ -1213,9 +1223,13 @@ func (s *Service) validateResolvedVideoCapability(input *canvasGenerationInput)
|
||||
if err != nil || profile == nil || profile.Video == nil {
|
||||
return errors.New("当前视频模型尚未配置能力参数")
|
||||
}
|
||||
input.VideoCapability = profile.Video
|
||||
applyFixedVideoResolution(input, profile.Video)
|
||||
return validateVideoTask(profile.Video, *input)
|
||||
normalized, err := NormalizeModelCapabilityConfigForModel("video", string(item.Protocol), firstNonEmpty(item.ProviderModelKey, item.ModelKey), profile)
|
||||
if err != nil || normalized == nil || normalized.Video == nil {
|
||||
return errors.New("当前视频模型能力参数无效")
|
||||
}
|
||||
input.VideoCapability = normalized.Video
|
||||
applyFixedVideoResolution(input, normalized.Video)
|
||||
return validateVideoTask(normalized.Video, *input)
|
||||
}
|
||||
|
||||
func (s *Service) validateResolvedImageCapability(input *canvasGenerationInput) error {
|
||||
@@ -2262,6 +2276,10 @@ func runDeclarativeProtocolTask(ctx context.Context, input canvasGenerationInput
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("接口类型 %s 未安装声明式适配器", input.Config.InterfaceType)
|
||||
}
|
||||
return runProtocolAdapterTask(ctx, input, adapter)
|
||||
}
|
||||
|
||||
func runProtocolAdapterTask(ctx context.Context, input canvasGenerationInput, adapter protocol.Adapter) (map[string]interface{}, error) {
|
||||
request := protocolRequestFromInput(input)
|
||||
taskID := resumedProviderRequestID(ctx)
|
||||
var created protocol.CreateResult
|
||||
@@ -2331,7 +2349,7 @@ func protocolRequestFromInput(input canvasGenerationInput) protocol.GenerationRe
|
||||
Quality: input.Config.Quality,
|
||||
GenerateAudio: parseBool(input.Config.VideoGenerateAudio, false),
|
||||
Watermark: parseBool(input.Config.VideoWatermark, false),
|
||||
Operation: metadataString(input.Metadata, "videoOperation"),
|
||||
Operation: firstNonEmpty(metadataString(input.Metadata, "videoEditOperation"), metadataString(input.Metadata, "videoOperation")),
|
||||
Extra: map[string]any{
|
||||
"videoSeconds": input.Config.VideoSeconds,
|
||||
"audioVoice": input.Config.AudioVoice,
|
||||
@@ -2694,6 +2712,13 @@ func runVideoTask(ctx context.Context, input canvasGenerationInput) (map[string]
|
||||
if _, ok := declarativeProtocolAdapterForContext(ctx, input.Config.InterfaceType); ok {
|
||||
return runDeclarativeProtocolTask(ctx, input)
|
||||
}
|
||||
if input.Config.InterfaceType == string(model.ChannelInterfaceAgnesVideo) {
|
||||
adapter, ok := protocolAdapterForContext(ctx, input.Config.InterfaceType)
|
||||
if !ok {
|
||||
return nil, errors.New("Agnes 视频插件未安装")
|
||||
}
|
||||
return runProtocolAdapterTask(ctx, input, adapter)
|
||||
}
|
||||
if input.Config.InterfaceType == string(model.ChannelInterfaceMiniMaxVideo) {
|
||||
return runMiniMaxVideoTask(ctx, input)
|
||||
}
|
||||
@@ -4608,6 +4633,17 @@ func ChannelAPIURL(baseURL string, path string) string {
|
||||
// boundary. In particular, Gemini uses v1beta while OpenAI-compatible APIs
|
||||
// conventionally use v1. An explicit version in either input wins.
|
||||
func ChannelAPIURLForProtocol(baseURL string, path string, interfaceType model.ChannelInterfaceType) string {
|
||||
if interfaceType == model.ChannelInterfaceAgnesVideo && strings.HasPrefix(strings.TrimSpace(path), "/agnesapi") {
|
||||
base, err := url.Parse(strings.TrimSpace(baseURL))
|
||||
requestPath, pathErr := url.Parse(strings.TrimSpace(path))
|
||||
if err == nil && pathErr == nil && base.Scheme != "" && base.Host != "" && strings.HasPrefix(requestPath.Path, "/") {
|
||||
base.Path = requestPath.Path
|
||||
base.RawPath = requestPath.RawPath
|
||||
base.RawQuery = requestPath.RawQuery
|
||||
base.Fragment = ""
|
||||
return base.String()
|
||||
}
|
||||
}
|
||||
defaultPrefix := "/v1"
|
||||
if interfaceType == model.ChannelInterfaceGeminiVeo || interfaceType == model.ChannelInterfaceGeminiImage {
|
||||
defaultPrefix = "/v1beta"
|
||||
|
||||
@@ -77,6 +77,13 @@ func TestChannelAPIURLForProtocolUsesGeminiDefault(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestChannelAPIURLForProtocolUsesAgnesOriginPollPath(t *testing.T) {
|
||||
got := ChannelAPIURLForProtocol("https://apihub.agnes-ai.com/v1", "/agnesapi?video_id=video-1&model_name=agnes-video-2.5", model.ChannelInterfaceAgnesVideo)
|
||||
if got != "https://apihub.agnes-ai.com/agnesapi?video_id=video-1&model_name=agnes-video-2.5" {
|
||||
t.Fatalf("Agnes poll URL = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtocolRequestURLCanResolveSameOriginRootPath(t *testing.T) {
|
||||
got, err := protocolRequestURL("https://apihub.agnes-ai.com/v1", protocol.RequestSpec{Path: "/agnesapi?video_id=video-1&model_name=agnes-video-2.5", OriginPath: true})
|
||||
if err != nil {
|
||||
@@ -87,6 +94,83 @@ func TestProtocolRequestURLCanResolveSameOriginRootPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunVideoTaskUsesHostBackedAgnesJSONProtocol(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
center, err := newPluginRuntime(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("newPluginRuntime() error = %v", err)
|
||||
}
|
||||
adapter, ok := center.registrySnapshot().Resolve("agnes-video")
|
||||
if !ok {
|
||||
t.Fatal("host-backed Agnes adapter is missing")
|
||||
}
|
||||
if metadata := adapter.Metadata(); metadata.Version != "1.2.0" || metadata.Execution != "host:agnes-video" || !metadata.RequiresPublicMediaURLs {
|
||||
t.Fatalf("Agnes runtime metadata = %#v", metadata)
|
||||
}
|
||||
|
||||
paths := make([]string, 0, 3)
|
||||
var server *httptest.Server
|
||||
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
paths = append(paths, r.Method+" "+r.URL.RequestURI())
|
||||
switch r.Method + " " + r.URL.Path {
|
||||
case "POST /v1/videos":
|
||||
if contentType := r.Header.Get("Content-Type"); !strings.HasPrefix(contentType, "application/json") {
|
||||
t.Errorf("Content-Type = %q, want application/json", contentType)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
t.Errorf("decode create body: %v", err)
|
||||
}
|
||||
want := map[string]any{
|
||||
"model": "agnes-video-2.5", "prompt": "make it move", "mode": "keyframe",
|
||||
"seconds": "5", "size": "720P", "aspect_ratio": "16:9", "n": float64(1),
|
||||
"first_frame": server.URL + "/reference.png",
|
||||
}
|
||||
if !reflect.DeepEqual(body, want) {
|
||||
t.Errorf("create body = %#v, want %#v", body, want)
|
||||
}
|
||||
for _, legacy := range []string{"input_reference", "input_reference[]", "preset", "resolution_name"} {
|
||||
if _, exists := body[legacy]; exists {
|
||||
t.Errorf("create body contains legacy field %q: %#v", legacy, body)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"video_id":"video-1","status":"queued"}`))
|
||||
case "GET /agnesapi":
|
||||
if r.URL.Query().Get("video_id") != "video-1" || r.URL.Query().Get("model_name") != "agnes-video-2.5" {
|
||||
t.Errorf("poll query = %q", r.URL.RawQuery)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"video_id":"video-1","status":"completed","metadata":{"url":"` + server.URL + `/video.mp4"}}`))
|
||||
case "GET /video.mp4":
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
_, _ = w.Write([]byte("video"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := withProtocolRegistry(context.Background(), center.registrySnapshot())
|
||||
result, err := runVideoTask(ctx, canvasGenerationInput{
|
||||
Mode: "video",
|
||||
Prompt: "make it move",
|
||||
Config: providerConfig{BaseURL: server.URL + "/v1", APIKey: "test-key", InterfaceType: "agnes-video", Model: "agnes-video-2.5", VideoSeconds: "5", Size: "16:9", VQuality: "720P"},
|
||||
ReferenceImages: []providerMedia{{URL: server.URL + "/reference.png"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runVideoTask() error = %v", err)
|
||||
}
|
||||
video, ok := result["video"].(map[string]interface{})
|
||||
if !ok || video["dataUrl"] != "data:video/mp4;base64,dmlkZW8=" {
|
||||
t.Fatalf("video = %#v", result["video"])
|
||||
}
|
||||
wantPaths := "POST /v1/videos,GET /agnesapi?video_id=video-1&model_name=agnes-video-2.5,GET /video.mp4"
|
||||
if got := strings.Join(paths, ","); got != wantPaths {
|
||||
t.Fatalf("paths = %q, want %q", got, wantPaths)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemChannelIDFromBaseURLSupportsShortAndLegacyProxyPaths(t *testing.T) {
|
||||
for _, test := range []struct{ base, want string }{
|
||||
{base: "/api/channel-1", want: "channel-1"},
|
||||
|
||||
Reference in new issue
Block a user