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