diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 876547f37..6194a55ec 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -834,6 +834,9 @@ func proxySystemRequestPath(c *gin.Context, svc *service.Service, user *model.Us return } modelName := proxyRequestModelForPath(path, c.GetHeader("Content-Type"), body) + if modelName == "" && c.Request.Method == http.MethodGet && path == "/agnesapi" { + modelName = strings.TrimSpace(c.Query("model_name")) + } protocol := model.ChannelInterfaceType("") capability := "text" var channelModel *model.ChannelModel diff --git a/backend/internal/handler/security.go b/backend/internal/handler/security.go index d5d20c68d..84f68859e 100644 --- a/backend/internal/handler/security.go +++ b/backend/internal/handler/security.go @@ -65,6 +65,12 @@ func authorizeCustomRelay(method string, target *url.URL, apiFormat string, cont return errors.New("自定义渠道调用格式无效") } if method == http.MethodGet { + if apiFormat == "openai" && requestPath == "/agnesapi" { + if len(query) == 2 && len(query["video_id"]) == 1 && len(query["model_name"]) == 1 && strings.TrimSpace(query.Get("video_id")) != "" && strings.TrimSpace(query.Get("model_name")) != "" { + return nil + } + return errors.New("Agnes 视频查询必须提供 video_id 和 model_name") + } if customNovitaTaskResultPath.MatchString(requestPath) { if len(query) == 1 && len(query["task_id"]) == 1 && strings.TrimSpace(query.Get("task_id")) != "" { return nil @@ -168,6 +174,23 @@ func authorizeSystemProxy(channel *model.ModelChannel, protocol model.ChannelInt if method == http.MethodGet && requestPath == "/models" { return nil } + if protocol == model.ChannelInterfaceAgnesVideo { + if method == http.MethodGet && requestPath == "/agnesapi" { + return nil + } + if method != http.MethodPost || requestPath != "/videos" { + return errors.New("系统渠道不允许访问该上游接口") + } + mediaType, _, err := mime.ParseMediaType(contentType) + if err != nil || mediaType != "application/json" { + return errors.New("Agnes 视频生成请求必须使用 application/json") + } + modelName := proxyRequestModel(contentType, body) + if modelName == "" || !channelAllowsModel(channel, modelName) { + return errors.New("当前系统渠道未授权该模型") + } + return nil + } if protocol == model.ChannelInterfaceMiniMaxVideo { if method == http.MethodGet && systemMiniMaxTaskPath.MatchString(requestPath) { return nil diff --git a/backend/internal/handler/security_test.go b/backend/internal/handler/security_test.go index 80145402d..82506ca26 100644 --- a/backend/internal/handler/security_test.go +++ b/backend/internal/handler/security_test.go @@ -67,6 +67,28 @@ func TestAuthorizeCustomRelayAllowsModelsAndAgentEndpoints(t *testing.T) { } } +func TestAuthorizeCustomRelayAllowsOnlyOfficialAgnesPollQuery(t *testing.T) { + target, err := url.Parse("https://apihub.agnes-ai.com/agnesapi?video_id=video-1&model_name=agnes-video-2.5") + if err != nil { + t.Fatal(err) + } + if err := authorizeCustomRelay(http.MethodGet, target, "openai", ""); err != nil { + t.Fatalf("authorizeCustomRelay() Agnes poll error = %v", err) + } + for _, raw := range []string{ + "https://apihub.agnes-ai.com/agnesapi?video_id=video-1", + "https://apihub.agnes-ai.com/agnesapi?video_id=video-1&model_name=agnes-video-2.5&extra=1", + } { + invalid, parseErr := url.Parse(raw) + if parseErr != nil { + t.Fatal(parseErr) + } + if err := authorizeCustomRelay(http.MethodGet, invalid, "openai", ""); err == nil { + t.Fatalf("authorizeCustomRelay(%q) should fail", raw) + } + } +} + func TestAuthorizeCustomRelayRejectsArbitraryRequestsAndCredentialQueries(t *testing.T) { tests := []struct { method string @@ -148,6 +170,23 @@ func TestAuthorizeSystemProxyMiniMaxVideoCreateAndPoll(t *testing.T) { } } +func TestAuthorizeSystemProxyAgnesVideoCreateAndPoll(t *testing.T) { + channel := &model.ModelChannel{APIFormat: "openai", ModelsJSON: `["agnes-video-2.5"]`} + createBody := []byte(`{"model":"agnes-video-2.5","prompt":"test","mode":"text","seconds":"5","size":"720P"}`) + if err := authorizeSystemProxy(channel, model.ChannelInterfaceAgnesVideo, http.MethodPost, "/videos", "application/json", createBody); err != nil { + t.Fatalf("Agnes create authorization error = %v", err) + } + if err := authorizeSystemProxy(channel, model.ChannelInterfaceAgnesVideo, http.MethodGet, "/agnesapi", "", nil); err != nil { + t.Fatalf("Agnes poll authorization error = %v", err) + } + if err := authorizeSystemProxy(channel, model.ChannelInterfaceAgnesVideo, http.MethodPost, "/videos", "multipart/form-data; boundary=test", createBody); err == nil { + t.Fatal("expected Agnes multipart create to be rejected") + } + if err := authorizeSystemProxy(channel, model.ChannelInterfaceAgnesVideo, http.MethodGet, "/videos/task-1", "", nil); err == nil { + t.Fatal("expected unsupported Agnes poll path to be rejected") + } +} + func TestAuthorizeSystemProxyVolcengineArkImageOnlyAllowsGenerations(t *testing.T) { body := []byte(`{"model":"doubao-seedream-test"}`) channel := &model.ModelChannel{APIFormat: "openai", ModelsJSON: `["doubao-seedream-test"]`} diff --git a/backend/internal/model/models_channel.go b/backend/internal/model/models_channel.go index 3d3cf83a7..61894299b 100644 --- a/backend/internal/model/models_channel.go +++ b/backend/internal/model/models_channel.go @@ -32,6 +32,7 @@ type ChannelModel struct { ModelKey string `json:"modelKey" gorm:"size:120;uniqueIndex:idx_channel_model_key_active,priority:2,where:deleted_at IS NULL"` ProviderModelKey string `json:"providerModelKey" gorm:"size:120"` DisplayName string `json:"displayName" gorm:"size:160"` + Icon string `json:"icon" gorm:"size:80"` Capability string `json:"capability" gorm:"size:32;index"` Protocol ChannelInterfaceType `json:"protocol" gorm:"size:32;index"` BillingMode string `json:"billingMode" gorm:"size:32"` diff --git a/backend/internal/protocol/builtin.go b/backend/internal/protocol/builtin.go index 74d138f15..f590a4602 100644 --- a/backend/internal/protocol/builtin.go +++ b/backend/internal/protocol/builtin.go @@ -488,6 +488,7 @@ func asyncAudioAdapter() Adapter { func agnesAdapter() Adapter { info := metadata("agnes-video", "Agnes 视频", "Agnes AI", CapabilityVideo, "POST /v1/videos", "GET /agnesapi?video_id={id}&model_name={model}", "application/json") + info.Version = "1.2.0" info.Description = "Agnes AI 官方异步视频协议,支持 Agnes Video V2.0、2.5 与 2.5 Flash。" info.RequiresPublicMediaURLs = true info.Parameters = []Parameter{ @@ -497,8 +498,8 @@ func agnesAdapter() Adapter { {Name: "videos", Type: "media[]", Mapping: "videos", Description: "仅 Agnes Video 2.5 reference 模式支持,Flash 与 V2.0 不支持。"}, {Name: "audios", Type: "media[]", Mapping: "audios", Description: "Agnes Video 2.5 系列 reference 模式支持。"}, {Name: "duration", Type: "integer", Mapping: "seconds/num_frames", Description: "2.5 系列映射为字符串 seconds;V2.0 按帧率换算 num_frames。"}, - {Name: "aspectRatio", Type: "string", Mapping: "aspect_ratio", Description: "2.5 系列画幅比例。"}, - {Name: "resolution", Type: "string", Mapping: "size", Description: "2.5 支持 720P、960P、2K;Flash 固定 720P。"}, + {Name: "aspectRatio", Type: "string", Values: []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16"}, Mapping: "aspect_ratio", Description: "2.5 系列画幅比例;历史像素尺寸会在插件边界转换为对应比例。"}, + {Name: "resolution", Type: "string", Values: []string{"720P", "960P", "2K"}, Mapping: "size", Description: "通用层分辨率档位,映射为 Agnes 的 size;Flash 固定 720P。"}, } return builtinAdapter{ info: info, @@ -542,13 +543,22 @@ func buildAgnesCreate(r GenerationRequest) (RequestSpec, error) { func buildAgnesV25Body(r GenerationRequest) (map[string]any, error) { mode := firstString(r.Extra, "mode") if mode == "" { - switch { - case len(r.Videos) > 0 || len(r.Audios) > 0: + switch strings.TrimSpace(r.Operation) { + case "reference_to_video", "audio_to_video": mode = "reference" - case len(r.Images) > 0: + case "image_to_video": mode = "keyframe" default: - mode = "text" + switch { + case len(r.Videos) > 0 || len(r.Audios) > 0: + mode = "reference" + case len(r.Images) > 2: + mode = "reference" + case len(r.Images) > 0: + mode = "keyframe" + default: + mode = "text" + } } } if mode != "text" && mode != "keyframe" && mode != "reference" { @@ -558,9 +568,13 @@ func buildAgnesV25Body(r GenerationRequest) (map[string]any, error) { if seconds < 4 || seconds > 12 { return nil, fmt.Errorf("Agnes 2.5 seconds 必须在 4 到 12 秒之间") } - size := strings.ToUpper(defaultValue(r.Resolution, firstString(r.Extra, "size"))) - if size == "" { - size = "720P" + size, err := normalizeAgnesV25Resolution(defaultValue(r.Resolution, firstString(r.Extra, "size"))) + if err != nil { + return nil, err + } + aspectRatio, err := normalizeAgnesV25AspectRatio(defaultValue(r.AspectRatio, firstString(r.Extra, "aspect_ratio"))) + if err != nil { + return nil, err } if r.Model == "agnes-video-2.5-flash" { if size != "720P" { @@ -581,7 +595,7 @@ func buildAgnesV25Body(r GenerationRequest) (map[string]any, error) { "mode": mode, "seconds": strconv.Itoa(seconds), "size": size, - "aspect_ratio": defaultValue(r.AspectRatio, firstString(r.Extra, "aspect_ratio")), + "aspect_ratio": aspectRatio, "n": 1, } if seed, ok := r.Extra["seed"]; ok { @@ -621,6 +635,48 @@ func buildAgnesV25Body(r GenerationRequest) (map[string]any, error) { return compactMap(body), nil } +func normalizeAgnesV25Resolution(value string) (string, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "", "auto", "720", "720p": + return "720P", nil + case "960", "960p": + return "960P", nil + case "2k", "1440", "1440p": + return "2K", nil + default: + return "", fmt.Errorf("Agnes Video 2.5 分辨率必须是 720P、960P 或 2K") + } +} + +func normalizeAgnesV25AspectRatio(value string) (string, error) { + normalized := strings.ToLower(strings.TrimSpace(strings.ReplaceAll(value, "×", "x"))) + if normalized == "" || normalized == "auto" { + return "16:9", nil + } + ratios := []string{"21:9", "16:9", "4:3", "1:1", "3:4", "9:16"} + for _, ratio := range ratios { + if normalized == ratio { + return ratio, nil + } + } + dimensions := strings.Split(normalized, "x") + if len(dimensions) == 2 { + width, widthErr := strconv.ParseInt(dimensions[0], 10, 64) + height, heightErr := strconv.ParseInt(dimensions[1], 10, 64) + if widthErr == nil && heightErr == nil && width > 0 && height > 0 && width <= 1_000_000 && height <= 1_000_000 { + for _, ratio := range ratios { + parts := strings.Split(ratio, ":") + ratioWidth, _ := strconv.ParseInt(parts[0], 10, 64) + ratioHeight, _ := strconv.ParseInt(parts[1], 10, 64) + if width*ratioHeight == height*ratioWidth { + return ratio, nil + } + } + } + } + return "", fmt.Errorf("Agnes Video 2.5 画幅必须是 21:9、16:9、4:3、1:1、3:4 或 9:16") +} + func buildAgnesV20Body(r GenerationRequest) (map[string]any, error) { if len(r.Videos)+len(r.Audios) > 0 { return nil, fmt.Errorf("Agnes Video V2.0 不支持参考视频或音频") diff --git a/backend/internal/protocol/builtin_test.go b/backend/internal/protocol/builtin_test.go index 2ed50d1d9..abd3b6a4d 100644 --- a/backend/internal/protocol/builtin_test.go +++ b/backend/internal/protocol/builtin_test.go @@ -248,6 +248,23 @@ func TestAgnesMapsOfficialCreateAndPollContracts(t *testing.T) { } } +func TestAgnesVideo25SeparatesHistoricalPixelSizeFromResolutionTier(t *testing.T) { + adapter, _ := Builtins().Get("agnes-video") + create, err := adapter.BuildCreate(context.Background(), RequestContext{Request: GenerationRequest{ + Model: "agnes-video-2.5", Prompt: "test", Duration: 5, Resolution: "720", AspectRatio: "1280x720", + }}) + if err != nil { + t.Fatalf("BuildCreate() error = %v", err) + } + body := create.Body.(map[string]any) + if body["size"] != "720P" || body["aspect_ratio"] != "16:9" { + t.Fatalf("Agnes normalized body = %#v", body) + } + if body["size"] == "1280x720" { + t.Fatalf("Agnes size must be a resolution tier: %#v", body) + } +} + func TestAgnesFlashRejectsUnsupportedOfficialInputs(t *testing.T) { adapter, _ := Builtins().Get("agnes-video") _, err := adapter.BuildCreate(context.Background(), RequestContext{Request: GenerationRequest{Model: "agnes-video-2.5-flash", Prompt: "test", Duration: 5, Resolution: "2K"}}) @@ -260,6 +277,21 @@ func TestAgnesFlashRejectsUnsupportedOfficialInputs(t *testing.T) { } } +func TestAgnesVideo25UsesRequestedReferenceOperationForSingleImage(t *testing.T) { + adapter, _ := Builtins().Get("agnes-video") + spec, err := adapter.BuildCreate(context.Background(), RequestContext{Request: GenerationRequest{ + Model: "agnes-video-2.5", Prompt: "use it as a style reference", Duration: 5, Resolution: "720P", AspectRatio: "16:9", + Operation: "reference_to_video", Images: []MediaReference{{URL: "https://cdn.example/reference.png"}}, + }}) + if err != nil { + t.Fatalf("BuildCreate() error = %v", err) + } + body := spec.Body.(map[string]any) + if body["mode"] != "reference" || body["images"] == nil || body["first_frame"] != nil { + t.Fatalf("Agnes reference body = %#v", body) + } +} + func TestDeclarativeManifestMapsFieldsAndResponses(t *testing.T) { manifest := []byte(`{ "apiVersion":"yingce.plugin/v1", diff --git a/backend/internal/service/admin.go b/backend/internal/service/admin.go index 06770554e..34d921be7 100644 --- a/backend/internal/service/admin.go +++ b/backend/internal/service/admin.go @@ -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 { diff --git a/backend/internal/service/admin_channel_test.go b/backend/internal/service/admin_channel_test.go index ac26f277d..7d411cfff 100644 --- a/backend/internal/service/admin_channel_test.go +++ b/backend/internal/service/admin_channel_test.go @@ -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 diff --git a/backend/internal/service/channel_models.go b/backend/internal/service/channel_models.go index 3f4b7016a..983059be8 100644 --- a/backend/internal/service/channel_models.go +++ b/backend/internal/service/channel_models.go @@ -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 } diff --git a/backend/internal/service/channel_models_test.go b/backend/internal/service/channel_models_test.go index 729c46fd7..48e7c9512 100644 --- a/backend/internal/service/channel_models_test.go +++ b/backend/internal/service/channel_models_test.go @@ -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 diff --git a/backend/internal/service/logical_models.go b/backend/internal/service/logical_models.go index e3bdc8f32..1f745effd 100644 --- a/backend/internal/service/logical_models.go +++ b/backend/internal/service/logical_models.go @@ -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) { diff --git a/backend/internal/service/model_capability.go b/backend/internal/service/model_capability.go index 15c41096b..909afab4a 100644 --- a/backend/internal/service/model_capability.go +++ b/backend/internal/service/model_capability.go @@ -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 diff --git a/backend/internal/service/model_capability_test.go b/backend/internal/service/model_capability_test.go index 2ce2991c1..40573db30 100644 --- a/backend/internal/service/model_capability_test.go +++ b/backend/internal/service/model_capability_test.go @@ -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 { diff --git a/backend/internal/service/model_catalog.go b/backend/internal/service/model_catalog.go index d8e0d5cc3..ec915f6df 100644 --- a/backend/internal/service/model_catalog.go +++ b/backend/internal/service/model_catalog.go @@ -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, diff --git a/backend/internal/service/protocol_plugins.go b/backend/internal/service/protocol_plugins.go index b8ee60c30..ec016eaf5 100644 --- a/backend/internal/service/protocol_plugins.go +++ b/backend/internal/service/protocol_plugins.go @@ -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{}, }}}, } } diff --git a/backend/internal/service/protocol_registry.go b/backend/internal/service/protocol_registry.go index cf0014ffe..75f21a36a 100644 --- a/backend/internal/service/protocol_registry.go +++ b/backend/internal/service/protocol_registry.go @@ -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 } diff --git a/backend/internal/service/provider.go b/backend/internal/service/provider.go index 2d09c355f..cfdfc51b7 100644 --- a/backend/internal/service/provider.go +++ b/backend/internal/service/provider.go @@ -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" diff --git a/backend/internal/service/provider_test.go b/backend/internal/service/provider_test.go index d93976234..cd4062f80 100644 --- a/backend/internal/service/provider_test.go +++ b/backend/internal/service/provider_test.go @@ -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"}, diff --git a/docs/content/docs/backend/backend-database.mdx b/docs/content/docs/backend/backend-database.mdx index 1fde37b02..73179a423 100644 --- a/docs/content/docs/backend/backend-database.mdx +++ b/docs/content/docs/backend/backend-database.mdx @@ -7,6 +7,15 @@ description: 影策后端核心持久化表与状态边界 后端通过 GORM 管理 SQLite 与 PostgreSQL 表结构。服务启动时由 `backend/internal/database/schema.go` 执行迁移;业务写路径必须通过 service 和 repository 的状态约束,不能直接依赖前端状态。 +## 模型渠道与目录 + +| 表 | 用途 | 关键边界 | +| --- | --- | --- | +| `model_channels` | 保存系统渠道与用户渠道的连接配置 | 系统渠道密钥不进入公开目录;普通用户只能读取脱敏后的可用模型集合 | +| `channel_models` | 保存系统渠道中的产品模型标识、上游模型 ID、显示名称、模型 Logo、能力、协议和版本状态 | `icon` 只用于模型选择器展示;能力、协议、启用状态和价格仍由 service 强校验 | +| `channel_model_price_tiers` | 保存系统模型按规格匹配的上游 SKU 与价格 | 价格档与渠道模型同事务保存;未启用或未完成定价的规格不进入普通用户目录 | +| `logical_models` | 保存前台模型的公开名称、Logo、能力类型和归档状态 | 前台模型与系统渠道模型分别维护展示信息;供应线路通过 revision 和 route 关联 | + ## 插件管理 | 表 | 用途 | 关键边界 | diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index a1037ded9..ca3b86c3c 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,21 @@ description: 影策已实现功能的手工验证清单与未确认边界 # 待测试 +## Agnes Video 2.5 JSON 协议 + +- [x] 前端专项测试确认 Agnes 创建请求使用 `application/json`,不再发送 `input_reference[]`、`resolution_name`、`preset` 或像素型 `size`。 +- [x] 后端专项测试确认内置 `host:agnes-video` 插件直接进入 Agnes JSON 协议执行器,不再回退到 OpenAI 风格 multipart;旧渠道能力读取时会收紧为官方 4–12 秒和 720P/960P/2K 档位。 +- [x] Agnes 插件升级到 `1.2.0`:公共层像素画幅(如 `1280x720`)只转换为 `aspect_ratio: "16:9"`,公共层分辨率(如 `720`/`720p`)只转换为 `size: "720P"`,不再把两类字段混用。 +- [x] 后端专项测试确认自定义渠道和系统渠道均允许官方 `/agnesapi?video_id=...&model_name=...` 轮询,并将该路径转发到 Agnes 主机根路径。 +- [ ] 使用真实 Agnes API Key 分别验证文生视频、单张首帧、三张图片 reference、视频/音频 reference;确认 `metadata.url` 可播放并且失败信息可见。 +- [ ] 在系统渠道中验证 4 秒与 12 秒边界、720P/960P/2K、横竖画幅,以及 2.5 Flash 的 720P/最多 5 图/不支持视频限制。 + +## 系统渠道模型 Logo + +- [x] 后端保存系统渠道模型 Logo,并通过脱敏模型目录下发;前端类型检查与生产构建通过。 +- [ ] 在 `/admin/channels` 的模型管理中新增和编辑模型,确认 Logo 可选择、清除并在列表中立即更新。 +- [ ] 关闭“前台模型”功能后刷新创作端,确认模型选择器显示系统渠道模型配置的 Logo,浅色和深色主题下均清晰可见。 + ## 提示词位置型资源引用与缩略图 提示词中的画布连接使用 `@图片N`、`@视频N`、`@音频N`、`@文本N` 等位置型槽位,不再把节点 ID 写入提示词;创作页附件对用户仍显示同类编号,但内部使用稳定附件身份,避免删除中间项后引用错位。以下场景仍需要在浏览器中由用户确认: diff --git a/web/src/lib/model-capabilities.ts b/web/src/lib/model-capabilities.ts index 81c7897fa..b783690fe 100644 --- a/web/src/lib/model-capabilities.ts +++ b/web/src/lib/model-capabilities.ts @@ -320,6 +320,18 @@ export function defaultModelCapabilityConfig(protocol?: ModelProtocol, model = " video.watermark = { supported: true, default: false }; video.operations.push("reference_to_video"); } + if (protocol === "agnes-video" && ["agnes-video-2.5", "agnes-video-2.5-flash"].includes(model.trim().toLowerCase())) { + const flash = model.trim().toLowerCase() === "agnes-video-2.5-flash"; + video.references.maxImages = flash ? 5 : 9; + video.references.maxVideos = flash ? 0 : 3; + video.references.maxAudios = 3; + video.duration = { selection: "range", min: 4, max: 12, step: 1, default: 5 }; + video.ratios = ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"]; + video.defaultRatio = "16:9"; + video.resolutions = flash ? ["720P"] : ["720P", "960P", "2K"]; + video.defaultResolution = "720P"; + video.operations.push("reference_to_video", "audio_to_video"); + } return { version: 1, text, image: defaultImageCapabilityConfig(protocol, model), video }; } diff --git a/web/src/lib/user-session.ts b/web/src/lib/user-session.ts index 900d0570a..28fba178f 100644 --- a/web/src/lib/user-session.ts +++ b/web/src/lib/user-session.ts @@ -171,7 +171,7 @@ function systemChannelModelChannels(channels: PublicChannelCatalog[]): ModelChan model: model.modelKey, displayName: model.displayName, description: "", - icon: "", + icon: model.icon || "", capability: model.capability as ModelCapability, protocol: model.protocol as any, pricePolicy: "channel" as const, diff --git a/web/src/pages/admin/components/channel-model-manager.tsx b/web/src/pages/admin/components/channel-model-manager.tsx index 965440246..08a2adc0b 100644 --- a/web/src/pages/admin/components/channel-model-manager.tsx +++ b/web/src/pages/admin/components/channel-model-manager.tsx @@ -4,6 +4,7 @@ import type { ColumnsType } from "antd/es/table"; import { FlaskConical, Plus, RefreshCw, Search, Trash2 } from "lucide-react"; import { PaginationBar } from "@/components/layout/workspace-page"; +import { ModelIconPicker } from "@/components/model-logo"; import { ModelIcon } from "@/components/model-picker"; import { ModelCapabilityEditor } from "@/components/model-capability-editor"; import { CapabilityCardPicker, ProtocolCardPicker, type ModelCapabilityChoice } from "@/components/model-protocol-picker"; @@ -21,6 +22,7 @@ type FormValues = { modelKey: string; providerModelKey?: string; displayName?: string; + icon?: string; capability: EditableCapability; protocol?: ModelProtocol; priceTiers: PriceTierFormValues[]; @@ -114,6 +116,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: modelKey: "", providerModelKey: "", displayName: "", + icon: "", capability: "text", protocol: availableProtocols.find((item) => item.capability === "text")?.value, priceTiers: [defaultPriceTier()], @@ -129,6 +132,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: modelKey: item.modelKey, providerModelKey: item.providerModelKey || item.modelKey, displayName: item.displayName, + icon: item.icon, capability: item.capability || undefined, protocol: item.protocol, priceTiers: item.priceTiers?.length ? item.priceTiers.map(priceTierToForm) : [legacyPriceTierToForm(item)], @@ -152,6 +156,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: modelKey: values.modelKey.trim(), providerModelKey: upstreamModel, displayName: values.displayName?.trim() || values.modelKey.trim(), + icon: values.icon?.trim() || "", capability: values.capability, protocol: values.protocol, priceTiers: values.priceTiers.map((tier) => ({ @@ -247,7 +252,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel: render: (_, item) => (
- +
{item.displayName || item.modelKey}
@@ -469,7 +474,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
-
+
+ + +
diff --git a/web/src/services/api/logical-models.ts b/web/src/services/api/logical-models.ts index 535177be5..477449bc4 100644 --- a/web/src/services/api/logical-models.ts +++ b/web/src/services/api/logical-models.ts @@ -134,6 +134,7 @@ export type PublicChannelModel = { id: string; modelKey: string; displayName: string; + icon: string; capability: string; protocol?: string; capabilityConfig?: Record; diff --git a/web/src/services/api/video-contracts.ts b/web/src/services/api/video-contracts.ts index 528a6ad0c..85f9a6850 100644 --- a/web/src/services/api/video-contracts.ts +++ b/web/src/services/api/video-contracts.ts @@ -41,5 +41,5 @@ export type ApiEnvelope = T | { code?: number; data?: T | null; msg?: string export type RequestOptions = { signal?: AbortSignal }; export type VideoGenerationResult = { blob?: Blob; url?: string; mimeType?: string }; -export type VideoGenerationTask = { id: string; provider: "openai" | "seedance" | "video-generations" | "gemini-veo" | "novita" | "minimax"; model: string }; +export type VideoGenerationTask = { id: string; provider: "openai" | "agnes" | "seedance" | "video-generations" | "gemini-veo" | "novita" | "minimax"; model: string }; export type VideoGenerationTaskState = { status: "pending" } | { status: "completed"; result: VideoGenerationResult } | { status: "failed"; error: string }; diff --git a/web/src/services/api/video-provider-agnes.ts b/web/src/services/api/video-provider-agnes.ts new file mode 100644 index 000000000..3d903ac8f --- /dev/null +++ b/web/src/services/api/video-provider-agnes.ts @@ -0,0 +1,179 @@ +import { getResourceOSSUrl } from "@/services/api/resources"; +import { buildApiUrl, isSystemProxyBaseUrl, modelOptionName } from "@/stores/use-config-store"; +import type { ReferenceImage } from "@/types/image"; +import type { ReferenceAudio, ReferenceVideo } from "@/types/media"; + +import type { ApiEnvelope, RequestOptions, ResolvedAiConfig, VideoGenerationTask, VideoGenerationTaskState } from "./video-contracts"; +import type { VideoProviderDeps } from "./video-provider-deps"; + +type AgnesTaskResponse = { + id?: string; + task_id?: string; + video_id?: string; + status?: string; + metadata?: { url?: string } | null; + error?: { message?: string; detail?: string; code?: string } | null; + message?: string; + detail?: string; +}; + +const AGNES_V25_MODELS = new Set(["agnes-video-2.5", "agnes-video-2.5-flash"]); +const AGNES_ASPECT_RATIOS = ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"] as const; + +export function isAgnesConfig(config: ResolvedAiConfig) { + return config.interfaceType === "agnes-video"; +} + +export async function createAgnesVideoTask( + deps: VideoProviderDeps, + config: ResolvedAiConfig, + model: string, + prompt: string, + references: ReferenceImage[], + videoReferences: ReferenceVideo[], + audioReferences: ReferenceAudio[], + options?: RequestOptions, +): Promise { + const modelName = modelOptionName(model).trim(); + const normalizedPrompt = prompt.trim(); + if (!normalizedPrompt) throw new Error("请输入 Agnes 视频提示词"); + + const [imageUrls, videoUrls, audioUrls] = await Promise.all([ + Promise.all(references.map((item) => resolveAgnesMediaUrl(item.url || item.dataUrl, item.storageKey))), + Promise.all(videoReferences.map((item) => resolveAgnesMediaUrl(item.url, item.storageKey))), + Promise.all(audioReferences.map((item) => resolveAgnesMediaUrl(item.url, item.storageKey))), + ]); + const payload = modelName === "agnes-video-v2.0" + ? buildAgnesV20Payload(config, modelName, normalizedPrompt, imageUrls, videoUrls, audioUrls) + : buildAgnesV25Payload(config, modelName, normalizedPrompt, imageUrls, videoUrls, audioUrls); + + try { + const raw = await deps.transport.post>(deps.transport.apiUrl("/videos"), payload, options); + const created = deps.response.unwrapEnvelope(raw, "Agnes 接口没有返回视频任务"); + const id = String(created.video_id || created.task_id || created.id || "").trim(); + if (!id) throw new Error("Agnes 创建响应没有 video_id"); + return { id, provider: "agnes", model }; + } catch (error) { + throw new Error(deps.response.readAxiosError(error, "Agnes 视频任务创建失败")); + } +} + +export async function pollAgnesVideoTask(deps: VideoProviderDeps, config: ResolvedAiConfig, task: VideoGenerationTask, options?: RequestOptions): Promise { + try { + const raw = await deps.transport.get>(agnesPollUrl(config, task.id, modelOptionName(task.model)), options); + const state = deps.response.unwrapEnvelope(raw, "Agnes 接口没有返回任务状态"); + const status = String(state.status || "").trim().toLowerCase(); + if (status === "completed" || status === "succeeded" || status === "success" || status === "done") { + const url = String(state.metadata?.url || "").trim(); + if (!url) return { status: "failed", error: "Agnes 任务已完成但没有返回 metadata.url" }; + return { status: "completed", result: await deps.response.videoResultFromUrl(url, options) }; + } + if (status === "failed" || status === "cancelled" || status === "canceled") return { status: "failed", error: agnesFailureMessage(state) }; + return { status: "pending" }; + } catch (error) { + throw new Error(deps.response.readAxiosError(error, "Agnes 视频任务查询失败")); + } +} + +export function agnesPollUrl(config: ResolvedAiConfig, videoId: string, modelName: string) { + const query = new URLSearchParams({ video_id: videoId, model_name: modelName }).toString(); + const path = `/agnesapi?${query}`; + if (isSystemProxyBaseUrl(config.baseUrl)) return buildApiUrl(config.baseUrl, path); + let base: URL; + try { + base = new URL(config.baseUrl); + } catch { + throw new Error("Agnes Base URL 无效,请填写完整的 HTTP(S) 地址"); + } + base.pathname = "/agnesapi"; + base.search = query; + base.hash = ""; + return base.toString(); +} + +function buildAgnesV25Payload(config: ResolvedAiConfig, model: string, prompt: string, images: string[], videos: string[], audios: string[]) { + if (!AGNES_V25_MODELS.has(model)) throw new Error(`Agnes 视频协议不支持模型 ${model}`); + const seconds = agnesSeconds(config.videoSeconds); + const size = agnesResolution(config.vquality); + if (model === "agnes-video-2.5-flash") { + if (size !== "720P") throw new Error("Agnes Video 2.5 Flash 仅支持 720P"); + if (images.length > 5) throw new Error("Agnes Video 2.5 Flash 最多支持 5 张参考图"); + if (videos.length) throw new Error("Agnes Video 2.5 Flash 不支持参考视频"); + } + const mode = videos.length || audios.length || images.length > 2 ? "reference" : images.length ? "keyframe" : "text"; + const payload: Record = { + model, + prompt, + mode, + seconds, + size, + aspect_ratio: agnesAspectRatio(config.size), + n: 1, + }; + if (mode === "keyframe") { + payload.first_frame = images[0]; + if (images[1]) payload.last_frame = images[1]; + } else if (mode === "reference") { + if (images.length) payload.images = images; + if (audios.length) payload.audios = audios; + if (videos.length) payload.videos = videos.map((url) => ({ url })); + } + return payload; +} + +function buildAgnesV20Payload(config: ResolvedAiConfig, model: string, prompt: string, images: string[], videos: string[], audios: string[]) { + if (videos.length || audios.length) throw new Error("Agnes Video V2.0 不支持参考视频或音频"); + const seconds = Math.max(1, Math.floor(Number(config.videoSeconds) || 5)); + const frameRate = 24; + const targetFrames = seconds * frameRate; + const payload: Record = { + model, + prompt, + frame_rate: frameRate, + num_frames: Math.min(441, Math.floor((targetFrames - 1 + 4) / 8) * 8 + 1), + }; + if (images.length === 1) payload.image = images[0]; + if (images.length > 1) payload.extra_body = { image: images, mode: "keyframes" }; + return payload; +} + +function agnesSeconds(value: string) { + const seconds = Math.floor(Number(value) || 5); + if (seconds < 4 || seconds > 12) throw new Error("Agnes Video 2.5 时长必须在 4–12 秒之间"); + return String(seconds); +} + +function agnesResolution(value: string) { + const normalized = String(value || "720").trim().toLowerCase(); + if (normalized === "auto" || normalized === "720" || normalized === "720p") return "720P"; + if (normalized === "960" || normalized === "960p") return "960P"; + if (normalized === "2k" || normalized === "1440" || normalized === "1440p") return "2K"; + throw new Error("Agnes Video 2.5 分辨率必须是 720P、960P 或 2K"); +} + +function agnesAspectRatio(value: string) { + const normalized = String(value || "16:9").trim().toLowerCase().replace("×", "x"); + if (normalized === "auto" || !normalized) return "16:9"; + const direct = AGNES_ASPECT_RATIOS.find((ratio) => ratio === normalized); + if (direct) return direct; + const dimensions = normalized.match(/^(\d+)x(\d+)$/); + if (dimensions) { + const aspect = Number(dimensions[1]) / Number(dimensions[2]); + const matched = AGNES_ASPECT_RATIOS.find((ratio) => { + const [width, height] = ratio.split(":").map(Number); + return Math.abs(width / height - aspect) < 0.01; + }); + if (matched) return matched; + } + throw new Error("Agnes Video 2.5 画幅必须是 21:9、16:9、4:3、1:1、3:4 或 9:16"); +} + +async function resolveAgnesMediaUrl(value: string | undefined, storageKey?: string) { + if (storageKey?.startsWith("resource:")) return getResourceOSSUrl(storageKey); + if (/^https?:\/\//i.test(value || "")) return String(value); + throw new Error("Agnes 参考素材需要公网 URL;请先把素材保存到对象存储"); +} + +function agnesFailureMessage(state: AgnesTaskResponse) { + return String(state.error?.message || state.error?.detail || state.error?.code || state.message || state.detail || "Agnes 视频生成失败"); +} diff --git a/web/src/services/api/video.ts b/web/src/services/api/video.ts index 4ac57e88d..1c28c1720 100644 --- a/web/src/services/api/video.ts +++ b/web/src/services/api/video.ts @@ -8,6 +8,7 @@ import { assertVideoCapability, assertVideoConfig } from "./video-validation"; import type { RequestOptions, VideoGenerationResult, VideoGenerationTask, VideoGenerationTaskState } from "./video-contracts"; import { videoResponseTools } from "./video-response"; import type { VideoProviderDeps } from "./video-provider-deps"; +import { createAgnesVideoTask, isAgnesConfig, pollAgnesVideoTask } from "./video-provider-agnes"; import { createGeminiVeoTask, pollGeminiVeoTask } from "./video-provider-gemini"; import { createMiniMaxVideoTask, pollMiniMaxVideoTask } from "./video-provider-minimax"; import { createVideoGenerationsTask, pollVideoGenerationsTask } from "./video-provider-newapi"; @@ -20,7 +21,7 @@ export type { VideoGenerationResult, VideoGenerationTask, VideoGenerationTaskSta export async function requestVideoGeneration(config: AiConfig, prompt: string, references: ReferenceImage[] = [], videoReferences: ReferenceVideo[] = [], audioReferences: ReferenceAudio[] = [], options?: RequestOptions): Promise { const task = await createVideoGenerationTask(config, prompt, references, videoReferences, audioReferences, options); - const delayMs = task.provider === "openai" ? 2500 : 5000; + const delayMs = task.provider === "agnes" ? 1500 : task.provider === "openai" ? 2500 : 5000; for (let attempt = 0; attempt < 120; attempt += 1) { if (options?.signal?.aborted) throw new DOMException("Aborted", "AbortError"); const state = await pollVideoGenerationTask(config, task, options); @@ -42,6 +43,7 @@ export async function createVideoGenerationTask(config: AiConfig, prompt: string if (requestConfig.interfaceType === "gemini-veo") return createGeminiVeoTask(deps, requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options); if (requestConfig.interfaceType === "novita-video") return createNovitaVideoTask(deps, requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options); if (requestConfig.interfaceType === "minimax-video") return createMiniMaxVideoTask(deps, requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options); + if (isAgnesConfig(requestConfig)) return createAgnesVideoTask(deps, requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options); if (isSeedanceConfig(requestConfig)) return createSeedanceTask(deps, requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options); if (videoReferences.length || audioReferences.length) throw new Error("当前视频接口不支持参考视频或参考音频,请切换到 Seedance 2.0 / 火山 Agent Plan 模型,或移除参考素材"); return createOpenAIVideoTask(deps, requestConfig, selectedModel, prompt, references, options); @@ -55,6 +57,7 @@ export async function pollVideoGenerationTask(config: AiConfig, task: VideoGener if (task.provider === "gemini-veo") return pollGeminiVeoTask(deps, requestConfig, task, options); if (task.provider === "novita") return pollNovitaVideoTask(deps, requestConfig, task, options); if (task.provider === "minimax") return pollMiniMaxVideoTask(deps, requestConfig, task, options); + if (task.provider === "agnes") return pollAgnesVideoTask(deps, requestConfig, task, options); if (task.provider === "seedance") return pollSeedanceTask(deps, requestConfig, task, options); return pollOpenAIVideoTask(deps, task, options); } diff --git a/web/src/services/api/wallet.ts b/web/src/services/api/wallet.ts index 1973bdeef..4b4038f6d 100644 --- a/web/src/services/api/wallet.ts +++ b/web/src/services/api/wallet.ts @@ -52,6 +52,7 @@ export type ChannelModel = { modelKey: string; providerModelKey: string; displayName: string; + icon: string; capability: "text" | "image" | "video" | "audio" | ""; protocol?: import("@/lib/model-protocols").ModelProtocol; billingMode: "fixed_request" | "per_second" | "token"; @@ -94,6 +95,7 @@ export type ChannelModelMutation = { modelKey: string; providerModelKey?: string; displayName?: string; + icon?: string; capability: ChannelModel["capability"]; protocol?: ChannelModel["protocol"]; enabled?: boolean; diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 9eb8c6998..17065980c 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -11527,6 +11527,10 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background: gap: 16px; } +.admin-model-identity-grid-with-icon { + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + .admin-model-editor-drawer .admin-capability-editor { border-radius: 0; background: transparent; @@ -12013,7 +12017,8 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background: .admin-model-editor-section, .admin-capability-protocol-grid, .admin-price-tier-card-body, - .admin-model-identity-grid { + .admin-model-identity-grid, + .admin-model-identity-grid-with-icon { grid-template-columns: minmax(0, 1fr); } diff --git a/web/test/agnes-video.test.ts b/web/test/agnes-video.test.ts new file mode 100644 index 000000000..7135d4669 --- /dev/null +++ b/web/test/agnes-video.test.ts @@ -0,0 +1,163 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import axios from "axios"; + +import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities"; +import { createVideoGenerationTask } from "../src/services/api/video"; +import { agnesPollUrl, createAgnesVideoTask, pollAgnesVideoTask } from "../src/services/api/video-provider-agnes"; +import type { VideoProviderDeps } from "../src/services/api/video-provider-deps"; +import { videoResponseTools } from "../src/services/api/video-response"; +import { buildApiUrl, createModelChannel, defaultConfig, normalizeConfigSnapshot, resolveModelRequestConfig, type AiConfig } from "../src/stores/use-config-store"; + +const originalAxiosPost = axios.post; + +afterEach(() => { + axios.post = originalAxiosPost; +}); + +function configForAgnes(model = "agnes-video-2.5", input: Partial = {}) { + const capabilityConfig = defaultModelCapabilityConfig("agnes-video", model); + const channel = createModelChannel({ + id: "agnes", + name: "Agnes", + baseUrl: "https://apihub.agnes-ai.com/v1", + apiKey: "synthetic-test-key", + apiFormat: "openai", + interfaceType: "agnes-video", + models: [model], + modelCosts: [{ model, capability: "video", protocol: "agnes-video", billingMode: "per_second", unitPriceMicrocredits: 1, capabilityConfig }], + }); + return normalizeConfigSnapshot({ + config: { + ...defaultConfig, + ...input, + channels: [channel], + model: `agnes::${model}`, + videoModel: `agnes::${model}`, + videoSeconds: input.videoSeconds || "5", + size: input.size || "16:9", + vquality: input.vquality || "720", + }, + }).config; +} + +function providerDeps(config: ReturnType, input?: { created?: unknown; polled?: unknown }) { + const calls: Array<{ method: string; url: string; body?: unknown }> = []; + const deps = { + transport: { + apiUrl: (path: string) => buildApiUrl(config.baseUrl, path), + post: async (url: string, body: unknown) => { + calls.push({ method: "POST", url, body }); + return input?.created || { video_id: "video-1", status: "queued" }; + }, + postForm: async () => { + throw new Error("Agnes must not use multipart"); + }, + get: async (url: string) => { + calls.push({ method: "GET", url }); + return input?.polled || { video_id: "video-1", status: "in_progress" }; + }, + }, + response: { + ...videoResponseTools, + videoResultFromUrl: async (url: string) => ({ url, mimeType: "video/mp4" }), + }, + } as unknown as VideoProviderDeps; + return { calls, deps }; +} + +describe("Agnes Video 2.5 request contract", () => { + test("video.ts dispatches Agnes through JSON instead of OpenAI multipart", async () => { + let requestBody: unknown; + let requestHeaders: Record = {}; + axios.post = (async (_url: string, body: unknown, options?: { headers?: Record }) => { + requestBody = body; + requestHeaders = options?.headers || {}; + return { data: { id: "task-1", video_id: "video-1", status: "queued" } }; + }) as typeof axios.post; + + const task = await createVideoGenerationTask( + configForAgnes("agnes-video-2.5", { size: "1280x720" }), + "自我介绍图片1", + [{ id: "image-1", name: "reference.png", type: "image/png", dataUrl: "", url: "https://cdn.example.com/reference.png" }], + ); + + expect(task).toEqual({ id: "video-1", provider: "agnes", model: "agnes::agnes-video-2.5" }); + expect(requestBody).not.toBeInstanceOf(FormData); + expect(requestHeaders["content-type"]).toBe("application/json"); + expect(requestBody).toEqual({ + model: "agnes-video-2.5", + prompt: "自我介绍图片1", + mode: "keyframe", + seconds: "5", + size: "720P", + aspect_ratio: "16:9", + n: 1, + first_frame: "https://cdn.example.com/reference.png", + }); + expect(requestBody as Record).not.toHaveProperty("input_reference"); + expect(requestBody as Record).not.toHaveProperty("resolution_name"); + expect(requestBody as Record).not.toHaveProperty("preset"); + }); + + test("maps multimodal references to official reference fields", async () => { + const config = resolveModelRequestConfig(configForAgnes(), "agnes::agnes-video-2.5"); + const { calls, deps } = providerDeps(config); + await createAgnesVideoTask( + deps, + config, + "agnes::agnes-video-2.5", + "参考素材生成", + [ + { id: "image-1", name: "1.png", type: "image/png", dataUrl: "", url: "https://cdn.example.com/1.png" }, + { id: "image-2", name: "2.png", type: "image/png", dataUrl: "", url: "https://cdn.example.com/2.png" }, + { id: "image-3", name: "3.png", type: "image/png", dataUrl: "", url: "https://cdn.example.com/3.png" }, + ], + [{ id: "video-1", name: "video.mp4", type: "video/mp4", url: "https://cdn.example.com/video.mp4" }], + [{ id: "audio-1", name: "audio.mp3", type: "audio/mpeg", url: "https://cdn.example.com/audio.mp3" }], + ); + + expect(calls[0]?.body).toMatchObject({ + mode: "reference", + images: ["https://cdn.example.com/1.png", "https://cdn.example.com/2.png", "https://cdn.example.com/3.png"], + audios: ["https://cdn.example.com/audio.mp3"], + videos: [{ url: "https://cdn.example.com/video.mp4" }], + }); + }); + + test("polls the Agnes host root and reads metadata.url", async () => { + const config = resolveModelRequestConfig(configForAgnes(), "agnes::agnes-video-2.5"); + const { calls, deps } = providerDeps(config, { polled: { video_id: "video-1", status: "completed", metadata: { url: "https://cdn.example.com/result.mp4" } } }); + const state = await pollAgnesVideoTask(deps, config, { id: "video-1", provider: "agnes", model: "agnes::agnes-video-2.5" }); + + expect(calls[0]?.url).toBe("https://apihub.agnes-ai.com/agnesapi?video_id=video-1&model_name=agnes-video-2.5"); + expect(state).toEqual({ status: "completed", result: { url: "https://cdn.example.com/result.mp4", mimeType: "video/mp4" } }); + }); + + test("keeps system-channel polls inside the proxy while preserving the root path", () => { + const config = { ...resolveModelRequestConfig(configForAgnes(), "agnes::agnes-video-2.5"), baseUrl: "/api/system-agnes" }; + expect(agnesPollUrl(config, "video/1", "agnes-video-2.5")).toBe("/api/system-agnes/agnesapi?video_id=video%2F1&model_name=agnes-video-2.5"); + }); + + test("rejects unsupported duration, Flash inputs and local-only media", async () => { + const longConfig = resolveModelRequestConfig(configForAgnes("agnes-video-2.5", { videoSeconds: "15" }), "agnes::agnes-video-2.5"); + await expect(createAgnesVideoTask(providerDeps(longConfig).deps, longConfig, "agnes::agnes-video-2.5", "test", [], [], [])).rejects.toThrow("4–12"); + + const flashConfig = resolveModelRequestConfig(configForAgnes("agnes-video-2.5-flash", { vquality: "2K" }), "agnes::agnes-video-2.5-flash"); + await expect(createAgnesVideoTask(providerDeps(flashConfig).deps, flashConfig, "agnes::agnes-video-2.5-flash", "test", [], [], [])).rejects.toThrow("仅支持 720P"); + + const config = resolveModelRequestConfig(configForAgnes(), "agnes::agnes-video-2.5"); + await expect(createAgnesVideoTask(providerDeps(config).deps, config, "agnes::agnes-video-2.5", "test", [{ id: "image-1", name: "local.png", type: "image/png", dataUrl: "data:image/png;base64,AAAA" }], [], [])).rejects.toThrow("需要公网 URL"); + }); + + test("publishes Agnes-specific capability limits", () => { + expect(defaultModelCapabilityConfig("agnes-video", "agnes-video-2.5").video).toMatchObject({ + duration: { selection: "range", min: 4, max: 12, default: 5 }, + resolutions: ["720P", "960P", "2K"], + defaultResolution: "720P", + }); + expect(defaultModelCapabilityConfig("agnes-video", "agnes-video-2.5-flash").video).toMatchObject({ + references: { maxImages: 5, maxVideos: 0 }, + resolutions: ["720P"], + }); + }); +});