feat(*): 模型渠道 - 支持系统模型图标并升级 Agnes 视频协议

This commit is contained in:
ddCat committed 2026-08-27 23:31:38 +08:00
1 parent 7cb6aed27d
commit b3e977a985
30 files changed
+861 -44

No files matched your search

+3 -2
View File
@@ -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
+7 -5
View File
@@ -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) {
+64 -7
View File
@@ -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 {
+3 -1
View File
@@ -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,
+2 -1
View File
@@ -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
}
+45 -9
View File
@@ -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"
+84
View File
@@ -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"},