fix(models): 模型目录 - 识别公共视频能力并按声明发送分辨率
This commit is contained in:
@@ -29,9 +29,28 @@ type channelModelsPayload struct {
|
||||
}
|
||||
|
||||
type channelModelItem struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SupportedEndpointTypes []string `json:"supported_endpoint_types"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DisplayName string `json:"display_name"`
|
||||
ModelType string `json:"model_type"`
|
||||
SupportedEndpointTypes []string `json:"supported_endpoint_types"`
|
||||
DefaultParameters channelModelCatalogParameters `json:"default_parameters"`
|
||||
Options channelModelCatalogOptions `json:"options"`
|
||||
SupportsImages *bool `json:"supports_images"`
|
||||
MinImages *int `json:"min_images"`
|
||||
MaxImages *int `json:"max_images"`
|
||||
}
|
||||
|
||||
type channelModelCatalogParameters struct {
|
||||
AspectRatio string `json:"aspect_ratio"`
|
||||
DurationSeconds string `json:"duration_seconds"`
|
||||
Resolution string `json:"resolution"`
|
||||
}
|
||||
|
||||
type channelModelCatalogOptions struct {
|
||||
AspectRatio []ChannelModelCatalogOption `json:"aspect_ratio"`
|
||||
DurationSeconds []ChannelModelCatalogOption `json:"duration_seconds"`
|
||||
Resolution []ChannelModelCatalogOption `json:"resolution"`
|
||||
}
|
||||
|
||||
func (s *Service) FetchChannelModels(ctx context.Context, actor *model.User, input ChannelModelsRequest) ([]string, error) {
|
||||
@@ -54,10 +73,34 @@ func (s *Service) FetchChannelModels(ctx context.Context, actor *model.User, inp
|
||||
}
|
||||
|
||||
// ChannelModelCatalogItem 是前端自定义渠道拉取模型目录后的最小合同;
|
||||
// supportedEndpointTypes 由上游 /models 返回,前端据此推导能力与协议,不依赖模型名猜测。
|
||||
// 协议、能力和可选参数均来自上游公开元数据,不展开供应商内部兼容模型。
|
||||
type ChannelModelCatalogItem struct {
|
||||
ID string `json:"id"`
|
||||
SupportedEndpointTypes []string `json:"supportedEndpointTypes,omitempty"`
|
||||
ID string `json:"id"`
|
||||
DisplayName string `json:"displayName,omitempty"`
|
||||
ModelType string `json:"modelType,omitempty"`
|
||||
SupportedEndpointTypes []string `json:"supportedEndpointTypes,omitempty"`
|
||||
DefaultParameters ChannelModelCatalogDefaultParameters `json:"defaultParameters,omitempty"`
|
||||
Options ChannelModelCatalogOptions `json:"options,omitempty"`
|
||||
SupportsImages *bool `json:"supportsImages,omitempty"`
|
||||
MinImages *int `json:"minImages,omitempty"`
|
||||
MaxImages *int `json:"maxImages,omitempty"`
|
||||
}
|
||||
|
||||
type ChannelModelCatalogDefaultParameters struct {
|
||||
AspectRatio string `json:"aspectRatio,omitempty"`
|
||||
DurationSeconds string `json:"durationSeconds,omitempty"`
|
||||
Resolution string `json:"resolution,omitempty"`
|
||||
}
|
||||
|
||||
type ChannelModelCatalogOptions struct {
|
||||
AspectRatio []ChannelModelCatalogOption `json:"aspectRatio,omitempty"`
|
||||
DurationSeconds []ChannelModelCatalogOption `json:"durationSeconds,omitempty"`
|
||||
Resolution []ChannelModelCatalogOption `json:"resolution,omitempty"`
|
||||
}
|
||||
|
||||
type ChannelModelCatalogOption struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label,omitempty"`
|
||||
}
|
||||
|
||||
func (s *Service) FetchChannelModelCatalog(ctx context.Context, actor *model.User, input ChannelModelsRequest) ([]ChannelModelCatalogItem, error) {
|
||||
@@ -134,7 +177,25 @@ func (s *Service) FetchChannelModelCatalog(ctx context.Context, actor *model.Use
|
||||
continue
|
||||
}
|
||||
seen[name] = true
|
||||
catalog = append(catalog, ChannelModelCatalogItem{ID: name, SupportedEndpointTypes: normalizeCatalogEndpointTypes(item.SupportedEndpointTypes)})
|
||||
catalog = append(catalog, ChannelModelCatalogItem{
|
||||
ID: name,
|
||||
DisplayName: strings.TrimSpace(item.DisplayName),
|
||||
ModelType: normalizeCatalogModelType(item.ModelType),
|
||||
SupportedEndpointTypes: normalizeCatalogEndpointTypes(item.SupportedEndpointTypes),
|
||||
DefaultParameters: ChannelModelCatalogDefaultParameters{
|
||||
AspectRatio: strings.TrimSpace(item.DefaultParameters.AspectRatio),
|
||||
DurationSeconds: strings.TrimSpace(item.DefaultParameters.DurationSeconds),
|
||||
Resolution: strings.TrimSpace(item.DefaultParameters.Resolution),
|
||||
},
|
||||
Options: ChannelModelCatalogOptions{
|
||||
AspectRatio: normalizeCatalogOptions(item.Options.AspectRatio),
|
||||
DurationSeconds: normalizeCatalogOptions(item.Options.DurationSeconds),
|
||||
Resolution: normalizeCatalogOptions(item.Options.Resolution),
|
||||
},
|
||||
SupportsImages: item.SupportsImages,
|
||||
MinImages: item.MinImages,
|
||||
MaxImages: item.MaxImages,
|
||||
})
|
||||
}
|
||||
sort.Slice(catalog, func(left int, right int) bool {
|
||||
return catalog[left].ID < catalog[right].ID
|
||||
@@ -142,6 +203,30 @@ func (s *Service) FetchChannelModelCatalog(ctx context.Context, actor *model.Use
|
||||
return catalog, nil
|
||||
}
|
||||
|
||||
func normalizeCatalogModelType(value string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(value))
|
||||
switch normalized {
|
||||
case "text", "image", "video", "audio":
|
||||
return normalized
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeCatalogOptions(options []ChannelModelCatalogOption) []ChannelModelCatalogOption {
|
||||
seen := make(map[string]bool, len(options))
|
||||
normalized := make([]ChannelModelCatalogOption, 0, len(options))
|
||||
for _, option := range options {
|
||||
value := strings.TrimSpace(option.Value)
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
normalized = append(normalized, ChannelModelCatalogOption{Value: value, Label: strings.TrimSpace(option.Label)})
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func normalizeCatalogEndpointTypes(values []string) []string {
|
||||
seen := make(map[string]bool, len(values))
|
||||
normalized := make([]string, 0, len(values))
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -101,6 +102,66 @@ func TestCustomModelCatalogConsumesAllowLocalChannelAndServerCapability(t *testi
|
||||
}
|
||||
}
|
||||
|
||||
func TestCustomModelCatalogPreservesPublicCapabilityMetadataWithoutCompatibilityIDs(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"data":[
|
||||
{"id":"image-fast","display_name":"Image Fast","model_type":"image"},
|
||||
{"id":"image-quality","display_name":"Image Quality","model_type":"image"},
|
||||
{"id":"omni","capability_id":"omni-flash","display_name":"Omni Flash","model_type":"video","supports_images":false,"min_images":0,"max_images":0,"default_parameters":{"aspect_ratio":"16:9","duration_seconds":"10"},"options":{"aspect_ratio":[{"value":"16:9"},{"value":"9:16"}],"duration_seconds":[{"value":"8"},{"value":"10"}]},"compatibility_map":[{"parameters":{"aspect_ratio":"9:16","duration_seconds":"10"},"model_id":"omni_portrait_10s"}]},
|
||||
{"id":"veo-lite","display_name":"Veo Lite","model_type":"video"},
|
||||
{"id":"veo-fast","display_name":"Veo Fast","model_type":"video"},
|
||||
{"id":"veo-quality","display_name":"Veo Quality","model_type":"video"}
|
||||
]}`))
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
svc := &Service{runtimeCapabilities: RuntimeCapabilities{desktopLocalChannels: true}}
|
||||
catalog, err := svc.FetchChannelModelCatalog(context.Background(), &model.User{ID: "user-1"}, ChannelModelsRequest{
|
||||
BaseURL: upstream.URL, APIKey: "test-key", APIFormat: "openai", AllowLocalChannel: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchChannelModelCatalog() error = %v", err)
|
||||
}
|
||||
if len(catalog) != 6 {
|
||||
t.Fatalf("catalog length = %d; want 6", len(catalog))
|
||||
}
|
||||
var omni *ChannelModelCatalogItem
|
||||
for index := range catalog {
|
||||
if catalog[index].ID == "omni" {
|
||||
omni = &catalog[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if omni == nil {
|
||||
t.Fatal("catalog is missing public omni capability")
|
||||
}
|
||||
if omni.DisplayName != "Omni Flash" || omni.ModelType != "video" {
|
||||
t.Fatalf("omni metadata = %#v", omni)
|
||||
}
|
||||
if omni.DefaultParameters.AspectRatio != "16:9" || omni.DefaultParameters.DurationSeconds != "10" {
|
||||
t.Fatalf("omni defaults = %#v", omni.DefaultParameters)
|
||||
}
|
||||
if got := optionValues(omni.Options.DurationSeconds); len(got) != 2 || got[0] != "8" || got[1] != "10" {
|
||||
t.Fatalf("omni duration options = %#v", got)
|
||||
}
|
||||
encoded, err := json.Marshal(catalog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(encoded), "omni_portrait_10s") || strings.Contains(string(encoded), "compatibility") {
|
||||
t.Fatalf("ordinary catalog exposed compatibility IDs: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func optionValues(options []ChannelModelCatalogOption) []string {
|
||||
values := make([]string, 0, len(options))
|
||||
for _, option := range options {
|
||||
values = append(values, option.Value)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func TestSystemWorkerRestartReevaluatesStoredLocalFlagAgainstServerCapability(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
|
||||
@@ -405,7 +405,7 @@ func validateVideoTask(profile *VideoCapabilityConfig, input canvasGenerationInp
|
||||
if input.Config.Size != "" && !videoRatioAllowed(profile.Ratios, input.Config.Size) {
|
||||
return BadAuthRequest("画面比例不在当前模型支持范围内")
|
||||
}
|
||||
if input.Config.VQuality != "" && !containsCapabilityString(profile.Resolutions, normalizeResolution(input.Config.VQuality)) {
|
||||
if len(profile.Resolutions) > 0 && !isAutomaticVideoResolution(input.Config.VQuality) && videoResolutionNameRequest(profile, input.Config.VQuality) == "" {
|
||||
return BadAuthRequest("输出分辨率不在当前模型支持范围内")
|
||||
}
|
||||
operation := metadataString(input.Metadata, "videoEditOperation")
|
||||
|
||||
@@ -97,3 +97,22 @@ func TestNormalizeResolutionSupportsCommonAliases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateVideoTaskIgnoresGlobalResolutionWhenCatalogDeclaresNone(t *testing.T) {
|
||||
profile := DefaultModelCapabilityConfigForModel("newapi", "omni").Video
|
||||
profile.Duration = VideoDurationConfig{Selection: "enum", Values: []int{8, 10}, Default: 10}
|
||||
profile.Ratios = []string{"16:9", "9:16"}
|
||||
profile.DefaultRatio = "16:9"
|
||||
profile.Resolutions = nil
|
||||
profile.DefaultResolution = ""
|
||||
profile.References.MaxImages = 0
|
||||
profile.Operations = []string{"text_to_video"}
|
||||
profile.DefaultOperation = "text_to_video"
|
||||
|
||||
err := validateVideoTask(profile, canvasGenerationInput{
|
||||
Config: providerConfig{Model: "omni", VideoSeconds: "10", Size: "16:9", VQuality: "720"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("validateVideoTask() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1420,7 +1420,9 @@ func runVideoTask(ctx context.Context, input canvasGenerationInput) (map[string]
|
||||
if size := normalizeVideoSize(input.Config.Size); size != "" {
|
||||
writeField(writer, "size", size)
|
||||
}
|
||||
writeField(writer, "resolution_name", normalizeVideoResolution(input.Config.VQuality))
|
||||
if resolution := videoResolutionNameRequest(input.VideoCapability, input.Config.VQuality); resolution != "" {
|
||||
writeField(writer, "resolution_name", resolution)
|
||||
}
|
||||
writeField(writer, "preset", "normal")
|
||||
if shouldSendNewAPIVideoImages(input) {
|
||||
for _, image := range input.ReferenceImages {
|
||||
|
||||
@@ -640,6 +640,64 @@ func TestRunVideoTaskUsesNewAPIForAnyVideoModel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunVideoTaskSendsOnlyDeclaredResolutionName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
resolutions []string
|
||||
quality string
|
||||
withoutProfile bool
|
||||
want string
|
||||
}{
|
||||
{name: "catalog omits resolution capability", quality: "720"},
|
||||
{name: "auto never invents 720p", resolutions: []string{"720p", "1080p"}, quality: "auto"},
|
||||
{name: "legacy auto never invents 720p", quality: "auto", withoutProfile: true},
|
||||
{name: "missing profile explicit 720 never invents resolution", quality: "720", withoutProfile: true},
|
||||
{name: "declared HD resolution", resolutions: []string{"1080p"}, quality: "1080", want: "1080p"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
var got string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method + " " + r.URL.Path {
|
||||
case "POST /v1/videos":
|
||||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||||
t.Fatalf("ParseMultipartForm() error = %v", err)
|
||||
}
|
||||
got = r.FormValue("resolution_name")
|
||||
_, _ = w.Write([]byte(`{"id":"video-resolution","status":"queued"}`))
|
||||
case "GET /v1/videos/video-resolution":
|
||||
_, _ = w.Write([]byte(`{"id":"video-resolution","status":"completed"}`))
|
||||
case "GET /v1/videos/video-resolution/content":
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
_, _ = w.Write([]byte("video"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
profile := DefaultModelCapabilityConfigForModel("newapi", "public-video").Video
|
||||
profile.Resolutions = test.resolutions
|
||||
profile.DefaultResolution = ""
|
||||
if test.withoutProfile {
|
||||
profile = nil
|
||||
}
|
||||
_, err := runVideoTask(context.Background(), canvasGenerationInput{
|
||||
Prompt: "synthetic prompt",
|
||||
Config: providerConfig{BaseURL: server.URL + "/v1", APIKey: "test-key", Model: "public-video", VideoSeconds: "8", Size: "16:9", VQuality: test.quality},
|
||||
VideoCapability: profile,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runVideoTask() error = %v", err)
|
||||
}
|
||||
if got != test.want {
|
||||
t.Fatalf("resolution_name = %q; want %q", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunVideoTaskUsesNestedURLBeforeResultURL(t *testing.T) {
|
||||
t.Setenv("CANVAS_ALLOW_PRIVATE_UPSTREAMS", "true")
|
||||
paths := make([]string, 0, 3)
|
||||
|
||||
@@ -169,6 +169,43 @@ func normalizeVideoResolution(value string) string {
|
||||
return value + "p"
|
||||
}
|
||||
|
||||
func videoResolutionNameRequest(profile *VideoCapabilityConfig, value string) string {
|
||||
requested := strings.ToLower(strings.TrimSpace(value))
|
||||
if requested == "" || requested == "auto" || requested == "default" || requested == "medium" || requested == "high" {
|
||||
return ""
|
||||
}
|
||||
if profile == nil {
|
||||
return ""
|
||||
}
|
||||
if len(profile.Resolutions) == 0 {
|
||||
return ""
|
||||
}
|
||||
candidates := []string{requested, strings.ToLower(normalizeVideoResolution(requested))}
|
||||
if requested == "4k" {
|
||||
candidates = append(candidates, "2160p")
|
||||
}
|
||||
if requested == "2160" || requested == "2160p" {
|
||||
candidates = append(candidates, "4k")
|
||||
}
|
||||
for _, supported := range profile.Resolutions {
|
||||
for _, candidate := range candidates {
|
||||
if strings.EqualFold(strings.TrimSpace(supported), candidate) {
|
||||
return strings.TrimSpace(supported)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func isAutomaticVideoResolution(value string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "auto", "default", "medium", "high":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeXAIVideoDuration(value string) int {
|
||||
duration, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err != nil || duration <= 0 {
|
||||
|
||||
Reference in New Issue
Block a user