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

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

View File

@@ -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

View File

@@ -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

View File

@@ -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"]`}

View File

@@ -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"`

View File

@@ -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 不支持参考视频或音频")

View File

@@ -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",

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 {

View File

@@ -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

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
}

View File

@@ -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

View File

@@ -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) {

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

View File

@@ -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 {

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,

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{},
}}},
}
}

View File

@@ -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
}

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"

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"},

View File

@@ -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 关联 |
## 插件管理
| 表 | 用途 | 关键边界 |

View File

@@ -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 写入提示词;创作页附件对用户仍显示同类编号,但内部使用稳定附件身份,避免删除中间项后引用错位。以下场景仍需要在浏览器中由用户确认:

View File

@@ -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 };
}

View File

@@ -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,

View File

@@ -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) => (
<div className="flex min-w-0 items-center gap-2.5">
<span className="grid size-8 shrink-0 place-items-center rounded-md border border-border/70 bg-muted/35">
<ModelIcon model={item.modelKey} />
<ModelIcon model={item.modelKey} icon={item.icon} />
</span>
<div className="min-w-0">
<div className="truncate font-medium">{item.displayName || item.modelKey}</div>
@@ -469,7 +474,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
</Form.Item>
<section className="admin-form-section admin-model-editor-section">
<SectionHeading title="模型身份" description="区分产品侧展示标识与上游实际调用 ID。" />
<div className="admin-model-editor-section-content admin-model-identity-grid">
<div className="admin-model-editor-section-content admin-model-identity-grid admin-model-identity-grid-with-icon">
<Form.Item name="modelKey" label="产品模型标识" rules={[{ required: true, message: "请输入产品模型标识" }]}>
<Input
prefix={
@@ -486,6 +491,9 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
<Form.Item name="displayName" label="后台显示名称">
<Input placeholder="不填则使用模型标识" />
</Form.Item>
<Form.Item name="icon" label="模型 Logo">
<ModelIconPicker />
</Form.Item>
</div>
</section>

View File

@@ -134,6 +134,7 @@ export type PublicChannelModel = {
id: string;
modelKey: string;
displayName: string;
icon: string;
capability: string;
protocol?: string;
capabilityConfig?: Record<string, any>;

View File

@@ -41,5 +41,5 @@ export type ApiEnvelope<T> = 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 };

View File

@@ -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<VideoGenerationTask> {
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<ApiEnvelope<AgnesTaskResponse>>(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<VideoGenerationTaskState> {
try {
const raw = await deps.transport.get<ApiEnvelope<AgnesTaskResponse>>(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<string, unknown> = {
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<string, unknown> = {
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 视频生成失败");
}

View File

@@ -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<VideoGenerationResult> {
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);
}

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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<AiConfig> = {}) {
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<typeof resolveModelRequestConfig>, 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<string, string> = {};
axios.post = (async (_url: string, body: unknown, options?: { headers?: Record<string, string> }) => {
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<string, unknown>).not.toHaveProperty("input_reference");
expect(requestBody as Record<string, unknown>).not.toHaveProperty("resolution_name");
expect(requestBody as Record<string, unknown>).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"],
});
});
});