fix(models): 模型目录 - 识别公共视频能力并按声明发送分辨率

This commit is contained in:
Codex
2026-08-16 22:27:29 +08:00
parent 6cdece9e04
commit f8cbcd1b6f
19 changed files with 792 additions and 51 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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