Files
ttstd-infinite-canvas/backend/internal/app/model_capability.go
2026-09-14 01:11:56 +08:00

1057 lines
44 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"unicode/utf8"
"infinite-canvas/backend/internal/model"
)
// ModelCapabilityConfig 是模型能力声明,不包含供应商字段名;协议适配器负责把统一参数映射到上游请求。
type ModelCapabilityConfig struct {
Version int `json:"version"`
Text *TextCapabilityConfig `json:"text,omitempty"`
Image *ImageCapabilityConfig `json:"image,omitempty"`
Video *VideoCapabilityConfig `json:"video,omitempty"`
}
type TextCapabilityConfig struct {
// Streaming controls whether this model accepts upstream SSE text responses.
// A nil value is treated as true for backwards compatibility with older configs.
Streaming *bool `json:"streaming,omitempty"`
References TextReferenceConfig `json:"references"`
}
type TextReferenceConfig struct {
PromptMaxChars int `json:"promptMaxChars"`
MaxImages int `json:"maxImages"`
MaxImageBytes int64 `json:"maxImageBytes"`
MaxVideos int `json:"maxVideos"`
MaxVideoBytes int64 `json:"maxVideoBytes"`
}
type ImageCapabilityConfig struct {
References ImageReferenceConfig `json:"references"`
Size ImageSizeConfig `json:"size"`
Quality ImageQualityConfig `json:"quality"`
TransparentBackground VideoBooleanConfig `json:"transparentBackground"`
ResponseFormat ParameterSupport `json:"responseFormat"`
OutputFormat ParameterSupport `json:"outputFormat"`
MaxOutputs int `json:"maxOutputs"`
}
type ImageReferenceConfig struct {
PromptMaxChars int `json:"promptMaxChars"`
MaxImages int `json:"maxImages"`
MaxImageBytes int64 `json:"maxImageBytes"`
MaskSupported bool `json:"maskSupported"`
}
type ImageSizeConfig struct {
Parameter string `json:"parameter"`
Values []string `json:"values"`
Default string `json:"default"`
AllowCustom bool `json:"allowCustom"`
Presets []ImageSizePreset `json:"presets,omitempty"`
}
type ImageSizePreset struct {
Tier string `json:"tier"`
Ratio string `json:"ratio"`
Size string `json:"size"`
Width int `json:"width"`
Height int `json:"height"`
}
type ImageQualityConfig struct {
Supported bool `json:"supported"`
Values []string `json:"values"`
Default string `json:"default"`
}
type ParameterSupport struct {
Supported bool `json:"supported"`
}
type VideoCapabilityConfig struct {
References VideoReferenceConfig `json:"references"`
Duration VideoDurationConfig `json:"duration"`
DurationSupported *bool `json:"durationSupported,omitempty"`
Ratios []string `json:"ratios"`
DefaultRatio string `json:"defaultRatio"`
Resolutions []string `json:"resolutions"`
DefaultResolution string `json:"defaultResolution"`
GenerateAudio VideoBooleanConfig `json:"generateAudio"`
Watermark VideoBooleanConfig `json:"watermark"`
Operations []string `json:"operations"`
DefaultOperation string `json:"defaultOperation"`
}
type VideoReferenceConfig struct {
PromptMaxChars int `json:"promptMaxChars"`
MinImages int `json:"minImages"`
MaxImages int `json:"maxImages"`
MaxImageBytes int64 `json:"maxImageBytes"`
MaxVideos int `json:"maxVideos"`
MaxVideoBytes int64 `json:"maxVideoBytes"`
MaxVideoDuration int `json:"maxVideoDurationSeconds"`
MaxAudios int `json:"maxAudios"`
MaxAudioBytes int64 `json:"maxAudioBytes"`
MaxAudioDuration int `json:"maxAudioDurationSeconds"`
}
type VideoDurationConfig struct {
Selection string `json:"selection"`
Min int `json:"min,omitempty"`
Max int `json:"max,omitempty"`
Step int `json:"step,omitempty"`
Values []int `json:"values,omitempty"`
Default int `json:"default"`
}
type VideoBooleanConfig struct {
Supported bool `json:"supported"`
Default bool `json:"default"`
}
func DefaultModelCapabilityConfig(protocol string) *ModelCapabilityConfig {
return DefaultModelCapabilityConfigForModel(protocol, "")
}
func videoDurationSupported(value *VideoCapabilityConfig) bool {
return value == nil || value.DurationSupported == nil || *value.DurationSupported
}
func DefaultImageCapabilityConfig(protocol string, modelName string) *ImageCapabilityConfig {
image := &ImageCapabilityConfig{
References: ImageReferenceConfig{PromptMaxChars: 32000, MaxImages: 16, MaxImageBytes: 30 * 1024 * 1024, MaskSupported: true},
Size: ImageSizeConfig{Parameter: "size", Values: defaultImageSizeValues(), Default: "1:1", AllowCustom: true},
Quality: ImageQualityConfig{Supported: true, Values: []string{"auto", "low", "medium", "high"}, Default: "auto"},
TransparentBackground: VideoBooleanConfig{Supported: true, Default: false},
ResponseFormat: ParameterSupport{Supported: true},
OutputFormat: ParameterSupport{Supported: true},
MaxOutputs: 15,
}
switch model.ChannelInterfaceType(protocol) {
case model.ChannelInterfaceGrokImage:
image.References.MaxImages = 1
image.References.MaskSupported = false
// grok2api / xAI Imagine:size→aspect_ratio,quality→resolution(1k/2k)。
image.Size = ImageSizeConfig{Parameter: "aspect_ratio", Values: []string{"1:1", "3:4", "4:3", "9:16", "16:9", "2:3", "3:2"}, Default: "1:1", AllowCustom: false}
image.Quality = ImageQualityConfig{Supported: true, Values: []string{"1k", "2k"}, Default: "2k"}
image.TransparentBackground = VideoBooleanConfig{Supported: false, Default: false}
image.ResponseFormat = ParameterSupport{Supported: true}
image.OutputFormat = ParameterSupport{Supported: false}
image.MaxOutputs = 1
case model.ChannelInterfaceVolcengineArkImage:
image.References.MaskSupported = false
image.Quality.Supported = false
image.TransparentBackground.Supported = false
image.ResponseFormat.Supported = false
image.OutputFormat.Supported = false
case model.ChannelInterfaceVolcengineJiMengImage:
image.References.MaxImages = 14
image.References.MaskSupported = false
image.Quality.Supported = false
image.TransparentBackground.Supported = false
image.ResponseFormat.Supported = false
image.OutputFormat.Supported = false
case model.ChannelInterfaceGeminiImage:
image.References.MaskSupported = false
// Gemini Images uses imageConfig.aspectRatio, not the OpenAI-style pixel size field.
image.Size = ImageSizeConfig{Parameter: "aspect_ratio", Values: []string{"auto", "1:1", "2:3", "3:2", "3:4", "4:3", "9:16", "16:9", "21:9"}, Default: "1:1", AllowCustom: false}
image.TransparentBackground.Supported = false
image.ResponseFormat.Supported = false
image.OutputFormat.Supported = false
image.MaxOutputs = 4
}
if model.ChannelInterfaceType(protocol) != model.ChannelInterfaceGrokImage && strings.HasPrefix(strings.ToLower(strings.TrimSpace(modelName)), "grok-imagine-image") {
image.References.MaxImages = 0
image.References.MaskSupported = false
image.Size = ImageSizeConfig{Parameter: "aspect_ratio", Values: []string{"1:1", "3:4", "4:3", "9:16", "16:9", "2:3", "3:2"}, Default: "1:1", AllowCustom: false}
image.Quality = ImageQualityConfig{Supported: true, Values: []string{"1k", "2k"}, Default: "2k"}
image.TransparentBackground = VideoBooleanConfig{Supported: false, Default: false}
image.ResponseFormat = ParameterSupport{Supported: true}
image.OutputFormat = ParameterSupport{Supported: false}
image.MaxOutputs = 1
}
return image
}
func defaultImageSizeValues() []string {
return []string{
"auto", "1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "21:9", "9:16",
"1024x1024", "1360x1024", "1024x1360", "1536x1024", "1024x1536", "1024x1280", "1280x1024", "2048x878", "1824x1024", "1024x1824",
"2048x2048", "2304x1728", "1728x2304", "2496x1664", "1664x2496", "1792x2240", "2240x1792", "3136x1344", "2752x1536", "1536x2752",
"2880x2880", "3264x2448", "2448x3264", "3504x2336", "2336x3504", "2560x3200", "3200x2560", "3808x1632", "3840x2160", "2160x3840",
}
}
// legacyImageSizeValues 用于修复旧数据中仅保存了 "*" 的图片尺寸能力。
// 这组值是前后台共同展示的基础预设,不能让历史通配符配置继续污染用户生成参数。
func legacyImageSizeValues() []string {
return []string{
"1:1", "3:2", "2:3", "4:3", "3:4", "16:9", "21:9", "9:16",
"1024x1024", "1536x1024", "1024x1536",
}
}
func DefaultModelCapabilityConfigForModel(protocol string, modelName string) *ModelCapabilityConfig {
// 文本模型是否支持视觉输入不能从协议或模型名可靠推断,默认关闭,由管理员按真实上游能力开启。
streaming := true
text := &TextCapabilityConfig{Streaming: &streaming, References: TextReferenceConfig{PromptMaxChars: 32000}}
video := &VideoCapabilityConfig{
References: VideoReferenceConfig{PromptMaxChars: 1000, MinImages: 0, MaxImages: 9, MaxImageBytes: 30 * 1024 * 1024, MaxVideos: 0, MaxVideoBytes: 0, MaxVideoDuration: 0, MaxAudios: 0, MaxAudioBytes: 0, MaxAudioDuration: 0},
Duration: VideoDurationConfig{Selection: "range", Min: 1, Max: 15, Step: 1, Default: 6},
Ratios: []string{"16:9", "9:16", "1:1", "4:3", "3:4", "21:9"},
DefaultRatio: "16:9",
Resolutions: []string{"480p", "720p", "1080p", "1440p", "2160p"},
DefaultResolution: "720p",
GenerateAudio: VideoBooleanConfig{Supported: false, Default: false},
Watermark: VideoBooleanConfig{Supported: false, Default: false},
Operations: []string{"text_to_video", "image_to_video"},
DefaultOperation: "text_to_video",
}
switch model.ChannelInterfaceType(protocol) {
case model.ChannelInterfaceVolcengineJiMengVideo:
video.Duration = VideoDurationConfig{Selection: "enum", Values: []int{5, 10}, Default: 5}
video.Resolutions = []string{"720p"}
case model.ChannelInterfaceGeminiVeo:
video.Duration = VideoDurationConfig{Selection: "enum", Values: []int{4, 6, 8}, Default: 6}
video.Resolutions = []string{"720p", "1080p"}
case model.ChannelInterfaceVolcengineArkVideo:
video.Operations = append(video.Operations, "reference_to_video", "audio_to_video")
video.References.MaxVideos, video.References.MaxAudios = 3, 3
video.References.MaxVideoBytes, video.References.MaxAudioBytes = 200*1024*1024, 15*1024*1024
video.References.MaxVideoDuration, video.References.MaxAudioDuration = 15, 15
video.GenerateAudio = VideoBooleanConfig{Supported: true, Default: true}
video.Watermark = VideoBooleanConfig{Supported: true, Default: false}
video.Resolutions = []string{"480p", "720p", "1080p"}
case model.ChannelInterfaceNewAPIChannel1, model.ChannelInterfaceNewAPIChannel2:
video.References.MaxVideos, video.References.MaxAudios = 3, 3
video.References.MaxVideoBytes, video.References.MaxAudioBytes = 200*1024*1024, 15*1024*1024
video.References.MaxVideoDuration, video.References.MaxAudioDuration = 15, 15
video.GenerateAudio = VideoBooleanConfig{Supported: true, Default: true}
if model.ChannelInterfaceType(protocol) == model.ChannelInterfaceNewAPIChannel1 {
video.Resolutions = []string{"480p", "720p", "1080p"}
}
case model.ChannelInterfaceNewAPIVideo, model.ChannelInterfaceXAIVideo:
video.GenerateAudio = VideoBooleanConfig{Supported: false, Default: false}
case model.ChannelInterfaceNovitaVideo:
video.References.MaxImages, video.References.MaxImageBytes = 1, 10*1024*1024
video.Duration = VideoDurationConfig{Selection: "enum", Values: []int{5, 10}, Default: 5}
video.Ratios = []string{"16:9", "9:16", "1:1"}
video.Resolutions = []string{"1080p"}
video.DefaultResolution = "1080p"
case model.ChannelInterfaceMiniMaxVideo:
video.Operations = append(video.Operations, "reference_to_video")
video.References.MaxImages = 9
video.References.MaxImageBytes = 30 * 1024 * 1024
video.References.MaxVideos = 3
video.References.MaxVideoBytes = 50 * 1024 * 1024
video.References.MaxVideoDuration = 15
video.References.MaxAudios = 3
video.References.MaxAudioBytes = 15 * 1024 * 1024
video.References.MaxAudioDuration = 15
video.Duration = VideoDurationConfig{Selection: "enum", Values: []int{4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15}, Default: 5}
video.Ratios = []string{"adaptive", "21:9", "16:9", "4:3", "1:1", "3:4", "9:16"}
video.DefaultRatio = "16:9"
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}
}
func DecodeModelCapabilityConfig(raw string) (*ModelCapabilityConfig, error) {
if strings.TrimSpace(raw) == "" {
return nil, nil
}
var value ModelCapabilityConfig
if err := json.Unmarshal([]byte(raw), &value); err != nil {
return nil, err
}
return &value, nil
}
// normalizedChannelModelCapability 从持久化记录恢复渠道模型的权威能力合同。
// 目录读取可以选择隔离损坏记录;任务创建等写路径必须把错误向上返回并失败关闭。
func normalizedChannelModelCapability(channelModel *model.ChannelModel) (*ModelCapabilityConfig, error) {
if channelModel == nil {
return nil, errors.New("渠道模型为空")
}
capability := normalizeCapability(channelModel.Capability)
if capability == "audio" {
return nil, nil
}
if capability != "text" && capability != "image" && capability != "video" {
return nil, fmt.Errorf("不支持的渠道模型能力:%s", channelModel.Capability)
}
config, err := DecodeModelCapabilityConfig(channelModel.CapabilityConfigJSON)
if err != nil {
return nil, fmt.Errorf("解析渠道模型能力配置失败:%w", err)
}
normalized, err := NormalizeModelCapabilityConfigForModel(capability, string(channelModel.Protocol), firstNonEmpty(channelModel.ProviderModelKey, channelModel.ModelKey), config)
if err != nil {
return nil, err
}
return normalized, nil
}
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
}
if capability == "text" {
if input == nil || input.Text == nil {
return nil, BadAuthRequest("请配置文本模型能力参数")
}
text := *input.Text
if text.Streaming == nil {
streaming := true
text.Streaming = &streaming
}
value := &ModelCapabilityConfig{Version: 1, Text: &text}
if err := validateTextCapabilityConfig(value.Text); err != nil {
return nil, err
}
return value, nil
}
if capability == "image" {
if input == nil || input.Image == nil {
return nil, BadAuthRequest("请配置图片模型能力参数")
}
value := &ModelCapabilityConfig{Version: 1, Image: input.Image}
if err := validateImageCapabilityConfig(value.Image); err != nil {
return nil, err
}
return value, nil
}
if input == nil || input.Video == nil {
return nil, BadAuthRequest("请配置视频模型能力参数")
}
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) {
spec := CapabilitySpec{Version: 1, Capability: capability, Inputs: map[string]InputConstraint{}, Options: map[string]OptionConstraint{}}
// 音频模型当前没有可编辑的渠道能力 JSON,使用空能力规格表示“无额外路由约束”。
if capability == "audio" {
return spec, nil
}
if config == nil {
switch capability {
case "text":
return spec, BadAuthRequest("渠道文本模型尚未配置能力参数")
case "image":
return spec, BadAuthRequest("渠道图片模型尚未配置能力参数")
case "video":
return spec, BadAuthRequest("渠道视频模型尚未配置能力参数")
default:
return spec, BadAuthRequest("渠道模型尚未配置能力参数")
}
}
switch capability {
case "text":
if config.Text == nil {
return spec, BadAuthRequest("渠道文本模型尚未配置能力参数")
}
addInputConstraint(spec.Inputs, "image", 0, config.Text.References.MaxImages)
addInputConstraint(spec.Inputs, "video", 0, config.Text.References.MaxVideos)
case "image":
if config.Image == nil {
return spec, BadAuthRequest("渠道图片模型尚未配置能力参数")
}
image := config.Image
addInputConstraint(spec.Inputs, "image", 0, image.References.MaxImages)
if image.References.MaskSupported {
addInputConstraint(spec.Inputs, "mask", 0, 1)
}
if image.Size.Parameter != "none" {
spec.Options["size"] = imageSizeOptionConstraint(image.Size)
spec.ImageSize = capabilityImageSizeFromConfig(image.Size)
}
if image.Quality.Supported {
spec.Options["quality"] = anyValues(image.Quality.Values)
}
if image.TransparentBackground.Supported {
spec.Options["transparentBackground"] = boolValues(true)
} else {
spec.Options["transparentBackground"] = boolValues(false)
}
spec.Options["count"] = numericRange(1, float64(image.MaxOutputs), 1)
case "video":
if config.Video == nil {
return spec, BadAuthRequest("渠道视频模型尚未配置能力参数")
}
video := config.Video
spec.Operations = append([]string(nil), video.Operations...)
addInputConstraint(spec.Inputs, "image", video.References.MinImages, video.References.MaxImages)
addInputConstraint(spec.Inputs, "video", 0, video.References.MaxVideos)
addInputConstraint(spec.Inputs, "audio", 0, video.References.MaxAudios)
if video.Duration.Selection == "enum" {
values := make([]any, 0, len(video.Duration.Values))
for _, value := range video.Duration.Values {
values = append(values, value)
}
spec.Options["videoSeconds"] = OptionConstraint{Values: values}
} else {
spec.Options["videoSeconds"] = numericRange(float64(video.Duration.Min), float64(video.Duration.Max), float64(video.Duration.Step))
}
spec.Options["size"] = anyValues(video.Ratios)
if len(video.Resolutions) > 0 {
spec.Options["vquality"] = anyValues(video.Resolutions)
}
if video.GenerateAudio.Supported {
spec.Options["videoGenerateAudio"] = boolValues(true)
} else {
spec.Options["videoGenerateAudio"] = boolValues(false)
}
if video.Watermark.Supported {
spec.Options["videoWatermark"] = boolValues(true)
} else {
spec.Options["videoWatermark"] = boolValues(false)
}
default:
return spec, BadAuthRequest("未知模型能力类型")
}
return spec, nil
}
// imageSizeOptionConstraint 保留可见的标准尺寸/比例,同时用 * 表示允许自定义。
// * 不能替代标准值,否则管理端只能看到一个没有业务含义的通配符。
func imageSizeOptionConstraint(size ImageSizeConfig) OptionConstraint {
values := make([]string, 0, len(size.Values)+1)
seen := make(map[string]struct{}, len(size.Values)+1)
for _, value := range size.Values {
value = strings.TrimSpace(value)
if value == "" || value == "*" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
values = append(values, value)
}
if size.AllowCustom {
if len(values) == 0 {
for _, value := range legacyImageSizeValues() {
seen[value] = struct{}{}
values = append(values, value)
}
}
values = append(values, "*")
}
return anyValues(values)
}
func capabilityImageSizeFromConfig(size ImageSizeConfig) *CapabilityImageSize {
if size.Parameter == "" || size.Parameter == "none" {
return nil
}
result := &CapabilityImageSize{Parameter: size.Parameter, AllowCustom: size.AllowCustom}
for _, preset := range size.Presets {
tier := strings.ToLower(strings.TrimSpace(preset.Tier))
ratio := strings.TrimSpace(preset.Ratio)
value := strings.TrimSpace(preset.Size)
if tier == "" || ratio == "" || value == "" {
continue
}
result.Presets = append(result.Presets, CapabilityImageSizePreset{
Size: value, Tier: tier, Ratio: ratio, Width: preset.Width, Height: preset.Height,
})
}
return result
}
func addInputConstraint(inputs map[string]InputConstraint, name string, min int, max int) {
if min <= 0 && max <= 0 {
return
}
inputs[name] = InputConstraint{Min: min, Max: max}
}
func validateTextCapabilityConfig(value *TextCapabilityConfig) error {
if value.References.PromptMaxChars < 1 || value.References.PromptMaxChars > 1000000 {
return BadAuthRequest("提示词最大字符数必须在 1-1000000 之间")
}
for name, number := range map[string]int{"最大图片引用数": value.References.MaxImages, "最大视频引用数": value.References.MaxVideos} {
if number < 0 || number > 100 {
return BadAuthRequest(name + "必须在 0-100 之间")
}
}
if value.References.MaxImageBytes < 0 || value.References.MaxVideoBytes < 0 {
return BadAuthRequest("引用素材大小限制不能小于 0")
}
return nil
}
func validateImageCapabilityConfig(value *ImageCapabilityConfig) error {
if value.References.PromptMaxChars < 1 || value.References.PromptMaxChars > 1000000 {
return BadAuthRequest("提示词最大字符数必须在 1-1000000 之间")
}
if value.References.MaxImages < 0 || value.References.MaxImages > 100 || value.References.MaxImageBytes < 0 {
return BadAuthRequest("图片引用限制无效")
}
if value.MaxOutputs < 1 || value.MaxOutputs > 100 {
return BadAuthRequest("单次图片数量必须在 1-100 之间")
}
switch value.Size.Parameter {
case "none":
value.Size.Values = []string{}
value.Size.Presets = nil
value.Size.Default = "auto"
value.Size.AllowCustom = false
case "size", "aspect_ratio":
if strings.TrimSpace(value.Size.Default) == "" {
return BadAuthRequest("请配置默认图片尺寸或比例")
}
if !value.Size.AllowCustom && !containsCapabilityString(value.Size.Values, value.Size.Default) {
return BadAuthRequest("默认图片尺寸必须属于支持值")
}
default:
return BadAuthRequest("尺寸参数仅支持不发送、size 或 aspect_ratio")
}
seenPresets := make(map[string]bool)
for _, preset := range value.Size.Presets {
if preset.Tier != "1k" && preset.Tier != "2k" && preset.Tier != "4k" {
return BadAuthRequest("图片分辨率档位仅支持 1K、2K、4K")
}
parts := strings.Split(preset.Ratio, ":")
if len(parts) != 2 {
return BadAuthRequest("图片预设比例格式无效")
}
w, ew := strconv.Atoi(parts[0])
h, eh := strconv.Atoi(parts[1])
if ew != nil || eh != nil || w <= 0 || h <= 0 || w > 100000 || h > 100000 || max(w, h) > min(w, h)*3 {
return BadAuthRequest("图片预设比例无效")
}
if preset.Width <= 0 || preset.Height <= 0 || max(preset.Width, preset.Height) > 3840 || max(preset.Width, preset.Height) > min(preset.Width, preset.Height)*3 || preset.Width*preset.Height < 655360 || preset.Width*preset.Height > 8294400 || preset.Size != fmt.Sprintf("%dx%d", preset.Width, preset.Height) {
return BadAuthRequest("图片预设像素尺寸无效")
}
// 容许像素取整误差,但不能将横屏尺寸标记成竖屏或其他比例。
difference := preset.Width*h - preset.Height*w
if difference < 0 {
difference = -difference
}
if difference*1000 > preset.Height*w*25 {
return BadAuthRequest("图片预设像素尺寸与宽高比不一致")
}
a, b := w, h
for b != 0 {
a, b = b, a%b
}
key := fmt.Sprintf("%s:%d:%d", preset.Tier, w/a, h/a)
if seenPresets[key] {
return BadAuthRequest("图片尺寸预设重复")
}
seenPresets[key] = true
requestValue := preset.Size
if value.Size.Parameter == "aspect_ratio" {
requestValue = preset.Ratio
}
if !containsCapabilityString(value.Size.Values, requestValue) {
return BadAuthRequest("图片预设必须包含在尺寸支持值中")
}
}
if value.Quality.Supported {
if len(value.Quality.Values) == 0 || strings.TrimSpace(value.Quality.Default) == "" || !containsCapabilityString(value.Quality.Values, value.Quality.Default) {
return BadAuthRequest("请配置图片质量支持值和默认值")
}
} else {
value.Quality.Values = []string{}
value.Quality.Default = "auto"
}
if err := validateImagePresetSelection(value, value.Quality.Default, value.Size.Default); err != nil {
return BadAuthRequest("默认图片分辨率与宽高比不在已配置的组合中")
}
if !value.TransparentBackground.Supported {
value.TransparentBackground.Default = false
}
return nil
}
func validateVideoCapabilityConfig(value *VideoCapabilityConfig) error {
if value.References.PromptMaxChars < 1 || value.References.PromptMaxChars > 1000000 {
return BadAuthRequest("提示词最大字符数必须在 1-1000000 之间")
}
for name, number := range map[string]int{"最少图片引用数": value.References.MinImages, "最大图片引用数": value.References.MaxImages, "最大视频引用数": value.References.MaxVideos, "最大音频引用数": value.References.MaxAudios} {
if number < 0 || number > 100 {
return BadAuthRequest(name + "必须在 0-100 之间")
}
}
if value.References.MinImages > value.References.MaxImages {
return BadAuthRequest("最少图片引用数不能超过最大图片引用数")
}
if value.References.MaxImageBytes < 0 || value.References.MaxVideoBytes < 0 || value.References.MaxAudioBytes < 0 || value.References.MaxVideoDuration < 0 || value.References.MaxAudioDuration < 0 {
return BadAuthRequest("引用素材限制不能小于 0")
}
if err := validateVideoDuration(value.Duration); err != nil {
return err
}
if len(value.Ratios) == 0 {
if strings.TrimSpace(value.DefaultRatio) != "" {
return BadAuthRequest("未配置画面比例时不能设置默认比例")
}
} else if strings.TrimSpace(value.DefaultRatio) == "" || !containsCapabilityString(value.Ratios, value.DefaultRatio) {
return BadAuthRequest("默认画面比例必须属于支持值")
}
if len(value.Resolutions) == 0 {
if strings.TrimSpace(value.DefaultResolution) != "" {
return BadAuthRequest("未配置输出分辨率时不能设置默认分辨率")
}
} else if strings.TrimSpace(value.DefaultResolution) == "" || !containsCapabilityString(value.Resolutions, value.DefaultResolution) {
return BadAuthRequest("默认输出分辨率必须属于支持值")
}
if len(value.Operations) == 0 || strings.TrimSpace(value.DefaultOperation) == "" || !containsCapabilityString(value.Operations, value.DefaultOperation) {
return BadAuthRequest("请至少配置一个生成模式,并选择默认模式")
}
return nil
}
func validateVideoDuration(value VideoDurationConfig) error {
switch value.Selection {
case "range":
if value.Min < 1 || value.Max < value.Min || value.Max > 3600 || value.Step < 1 || value.Default < value.Min || value.Default > value.Max || (value.Default-value.Min)%value.Step != 0 {
return BadAuthRequest("视频时长范围或默认值无效")
}
case "enum":
if len(value.Values) == 0 || len(value.Values) > 100 {
return BadAuthRequest("视频固定时长至少需要一个选项")
}
values := append([]int(nil), value.Values...)
sort.Ints(values)
for index, item := range values {
if item < 1 || item > 3600 || (index > 0 && values[index-1] == item) {
return BadAuthRequest("视频固定时长选项无效或重复")
}
}
if !containsInt(values, value.Default) {
return BadAuthRequest("视频默认时长必须属于固定时长选项")
}
default:
return BadAuthRequest("视频时长选择方式仅支持范围或固定值")
}
return nil
}
func (s *Service) ValidateTaskCapability(input map[string]any) error {
encoded, err := json.Marshal(input)
if err != nil {
return BadAuthRequest("任务输入格式无效")
}
var taskInput canvasGenerationInput
if err := json.Unmarshal(encoded, &taskInput); err != nil || (taskInput.Mode != "image" && taskInput.Mode != "video" && taskInput.Mode != "audio") {
return nil
}
if isWorkflowProviderInterface(taskInput.Config.InterfaceType) {
if err := validateWorkflowProviderPromptLength(taskInput); err != nil {
return err
}
return validateWorkflowProviderConfig(taskInput.Mode, taskInput.Config)
}
// 普通音频模型沿用主线的能力校验路径;当前专用能力表只覆盖图片和视频。
if taskInput.Mode == "audio" {
return nil
}
channelID := strings.TrimSpace(taskInput.Config.ChannelID)
if channelID == "" {
channelID = systemChannelIDFromBaseURL(taskInput.Config.BaseURL)
}
if channelID == "" {
if taskInput.Mode == "image" {
profile := DefaultImageCapabilityConfig(taskInput.Config.InterfaceType, taskInput.Config.Model)
if taskInput.Config.CapabilityConfig != nil && taskInput.Config.CapabilityConfig.Image != nil {
profile = taskInput.Config.CapabilityConfig.Image
}
return validateImageTask(profile, taskInput)
}
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)
}
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 {
return BadAuthRequest("当前系统渠道模型未配置或已停用")
}
profile, err := DecodeModelCapabilityConfig(item.CapabilityConfigJSON)
if taskInput.Mode == "image" {
if err != nil {
return BadAuthRequest("当前图片模型能力参数无效")
}
imageProfile := DefaultImageCapabilityConfig(string(item.Protocol), firstNonEmpty(item.ProviderModelKey, item.ModelKey))
if profile != nil && profile.Image != nil {
imageProfile = profile.Image
}
return validateImageTask(applyModelSpecificImageCapability(imageProfile, string(item.Protocol), firstNonEmpty(item.ProviderModelKey, item.ModelKey), taskInput.Config.APIFormat), taskInput)
}
if err != nil || profile == nil || profile.Video == nil {
return BadAuthRequest("当前视频模型尚未配置能力参数")
}
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(normalized.Video, taskInput)
}
// applyModelSpecificImageCapability is retained as a narrow normalization hook
// for provider-specific image validation. The stored capability profile is
// already normalized when the channel model is saved, so no second override is
// needed here.
func applyModelSpecificImageCapability(profile *ImageCapabilityConfig, _ string, _ string, _ string) *ImageCapabilityConfig {
return profile
}
// applyFixedVideoResolution 让单档位 SKU 的预扣、恢复和上游请求保持同一分辨率。
func applyFixedVideoResolution(input *canvasGenerationInput, profile *VideoCapabilityConfig) {
if input == nil || profile == nil || len(profile.Resolutions) != 1 {
return
}
if resolution := videoResolutionNameRequest(profile, profile.Resolutions[0]); resolution != "" {
input.Config.VQuality = resolution
}
}
func validateVideoTask(profile *VideoCapabilityConfig, input canvasGenerationInput) error {
if profile == nil {
return BadAuthRequest("当前视频模型能力参数无效")
}
if err := validateModelPromptLength("视频", input.Prompt, profile.References.PromptMaxChars); err != nil {
return err
}
if len(input.ReferenceImages) > profile.References.MaxImages || len(input.ReferenceVideos) > profile.References.MaxVideos || len(input.ReferenceAudios) > profile.References.MaxAudios {
return BadAuthRequest("参考素材数量超过当前模型限制")
}
if input.Config.InterfaceType == string(model.ChannelInterfaceVolcengineArkVideo) && len(input.ReferenceAudios) > 0 && len(input.ReferenceImages) == 0 && len(input.ReferenceVideos) == 0 {
return BadAuthRequest("火山方舟全模态参考不支持纯音频或文本+音频,请同时添加参考图片或参考视频")
}
if len(input.ReferenceImages) < profile.References.MinImages {
return BadAuthRequest(fmt.Sprintf("当前视频模型至少需要 %d 张参考图", profile.References.MinImages))
}
for _, media := range input.ReferenceImages {
if profile.References.MaxImageBytes > 0 && media.Bytes > profile.References.MaxImageBytes {
return BadAuthRequest("参考图片文件超过当前模型大小限制")
}
}
for _, media := range input.ReferenceVideos {
if profile.References.MaxVideoBytes > 0 && media.Bytes > profile.References.MaxVideoBytes {
return BadAuthRequest("参考视频文件超过当前模型大小限制")
}
if profile.References.MaxVideoDuration > 0 && media.DurationMs > int64(profile.References.MaxVideoDuration)*1000 {
return BadAuthRequest("参考视频时长超过当前模型限制")
}
}
for _, media := range input.ReferenceAudios {
if profile.References.MaxAudioBytes > 0 && media.Bytes > profile.References.MaxAudioBytes {
return BadAuthRequest("参考音频文件超过当前模型大小限制")
}
if profile.References.MaxAudioDuration > 0 && media.DurationMs > int64(profile.References.MaxAudioDuration)*1000 {
return BadAuthRequest("参考音频时长超过当前模型限制")
}
}
seconds, err := strconv.Atoi(strings.TrimSpace(input.Config.VideoSeconds))
if err != nil || !videoDurationAllowed(profile.Duration, seconds) {
return BadAuthRequest("视频时长不在当前模型支持范围内")
}
if input.Config.Size != "" && !videoRatioAllowed(profile.Ratios, input.Config.Size) {
return BadAuthRequest("画面比例不在当前模型支持范围内")
}
if len(profile.Resolutions) > 0 && !isAutomaticVideoResolution(input.Config.VQuality) && videoResolutionNameRequest(profile, input.Config.VQuality) == "" {
return BadAuthRequest("输出分辨率不在当前模型支持范围内")
}
operation := metadataString(input.Metadata, "videoEditOperation")
if operation == "" {
if len(input.ReferenceImages) > 0 {
operation = "image_to_video"
} else {
operation = profile.DefaultOperation
}
}
if !containsCapabilityString(profile.Operations, operation) {
return BadAuthRequest("当前视频模型不支持该生成模式")
}
return nil
}
func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInput) error {
if profile == nil {
return nil
}
modelName := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(input.Config.Model)), "models/")
if input.Config.InterfaceType == string(model.ChannelInterfaceGrokImage) && modelName == "grok-imagine-image-quality" {
const maxPromptBytes = 8000
promptBytes := len(withSystemPrompt(input.Config, input.Prompt))
if promptBytes > maxPromptBytes {
return BadAuthRequest(fmt.Sprintf("Grok 图片完整提示词为 %d UTF-8 字节,超过上游 %d 字节限制。系统不会自动删改;请精简当前输入、连线文本、角色卡、画风或模板内容后重试", promptBytes, maxPromptBytes))
}
}
if len(input.ReferenceImages) > profile.References.MaxImages {
return BadAuthRequest(fmt.Sprintf("当前图片模型最多支持 %d 张参考图", profile.References.MaxImages))
}
for _, media := range input.ReferenceImages {
if profile.References.MaxImageBytes > 0 && media.Bytes > profile.References.MaxImageBytes {
return BadAuthRequest("参考图片文件超过当前模型大小限制")
}
}
if input.Mask != nil && !profile.References.MaskSupported {
return BadAuthRequest("当前图片模型不支持蒙版编辑")
}
if profile.Size.Parameter != "none" && !profile.Size.AllowCustom && strings.TrimSpace(input.Config.Size) != "" && !containsCapabilityString(profile.Size.Values, input.Config.Size) {
return BadAuthRequest("图片尺寸不在当前模型支持范围内")
}
if profile.Size.Parameter == "size" && profile.Size.AllowCustom && strings.HasPrefix(modelName, "gpt-image-2") && !containsCapabilityString(profile.Size.Values, input.Config.Size) {
if err := validateGPTImage2CustomSize(input.Config.Size); err != nil {
return BadAuthRequest(err.Error())
}
}
quality := strings.TrimSpace(input.Config.Quality)
if profile.Quality.Supported && quality != "" && !strings.EqualFold(quality, "auto") && !strings.EqualFold(quality, "any") && !containsCapabilityString(profile.Quality.Values, quality) {
return BadAuthRequest("图片质量不在当前模型支持范围内")
}
if err := validateImagePresetSelection(profile, qualityForImageValidation(quality, profile.Quality.Default), firstNonEmpty(input.Config.Size, profile.Size.Default)); err != nil {
return err
}
count, err := strconv.Atoi(strings.TrimSpace(input.Config.Count))
if err == nil && count > profile.MaxOutputs {
return BadAuthRequest(fmt.Sprintf("当前图片模型单次最多生成 %d 张", profile.MaxOutputs))
}
return nil
}
func qualityForImageValidation(quality string, fallback string) string {
if strings.EqualFold(quality, "auto") || strings.EqualFold(quality, "any") {
return fallback
}
return firstNonEmpty(quality, fallback)
}
func validateImagePresetSelection(profile *ImageCapabilityConfig, quality, ratio string) error {
if profile.Size.Parameter != "aspect_ratio" || profile.Size.AllowCustom || len(profile.Size.Presets) == 0 || ratio == "auto" {
return nil
}
tier := imageResolutionTier(quality)
if tier == "" {
return nil
}
for _, preset := range profile.Size.Presets {
if preset.Tier == tier && preset.Ratio == ratio {
return nil
}
}
return BadAuthRequest("当前分辨率不支持所选图片宽高比")
}
func imageResolutionTier(quality string) string {
switch strings.ToLower(strings.TrimSpace(quality)) {
case "1k", "low":
return "1k"
case "2k", "medium":
return "2k"
case "4k", "high":
return "4k"
default:
return ""
}
}
func validateWorkflowProviderPromptLength(input canvasGenerationInput) error {
profile := input.Config.CapabilityConfig
if input.Mode != "video" || profile == nil || profile.Video == nil {
return nil
}
return validateModelPromptLength("视频", input.Prompt, profile.Video.References.PromptMaxChars)
}
func validateModelPromptLength(label string, prompt string, maxChars int) error {
if maxChars <= 0 {
return nil
}
actualChars := utf8.RuneCountInString(prompt)
if actualChars <= maxChars {
return nil
}
return BadAuthRequest(fmt.Sprintf("当前%s模型提示词最多 %d 个字符,完整提示词为 %d 个字符。系统不会自动截断,请精简当前输入、连线内容或技能上下文后重试", label, maxChars, actualChars))
}
func validateGPTImage2CustomSize(value string) error {
value = strings.ToLower(strings.TrimSpace(strings.ReplaceAll(value, "×", "x")))
if value == "" || value == "auto" {
return nil
}
parts := strings.Split(value, "x")
if len(parts) != 2 {
return errors.New("自定义图片尺寸请使用宽x高,例如 3840x1920")
}
width, widthErr := strconv.Atoi(parts[0])
height, heightErr := strconv.Atoi(parts[1])
if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 {
return errors.New("图片尺寸必须是正整数")
}
if width%16 != 0 || height%16 != 0 {
return errors.New("图片尺寸宽高必须是 16 的倍数")
}
if max(width, height) > 3840 {
return errors.New("图片尺寸最长边不能超过 3840px")
}
if max(width, height) > min(width, height)*3 {
return errors.New("图片宽高比不能超过 3:1")
}
pixels := int64(width) * int64(height)
if pixels < 655360 || pixels > 8294400 {
return errors.New("图片总像素需在 655360 到 8294400 之间")
}
return nil
}
func videoDurationAllowed(value VideoDurationConfig, seconds int) bool {
if value.Selection == "enum" {
return containsInt(value.Values, seconds)
}
return seconds >= value.Min && seconds <= value.Max && value.Step > 0 && (seconds-value.Min)%value.Step == 0
}
func videoRatioAllowed(options []string, value string) bool {
value = strings.TrimSpace(strings.ToLower(strings.ReplaceAll(value, "×", "x")))
if containsCapabilityString(options, value) {
return true
}
parts := strings.Split(value, "x")
if len(parts) != 2 {
return false
}
width, widthErr := strconv.ParseFloat(parts[0], 64)
height, heightErr := strconv.ParseFloat(parts[1], 64)
if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 {
return false
}
actual := width / height
for _, option := range options {
candidate := ratioValue(option)
if candidate > 0 && absFloat(candidate-actual)/candidate < 0.01 {
return true
}
}
return false
}
func ratioValue(value string) float64 {
parts := strings.Split(strings.TrimSpace(value), ":")
if len(parts) != 2 {
return 0
}
width, widthErr := strconv.ParseFloat(parts[0], 64)
height, heightErr := strconv.ParseFloat(parts[1], 64)
if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 {
return 0
}
return width / height
}
func normalizeResolution(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
value = strings.TrimSuffix(value, "p")
if value == "2k" {
return "1440p"
}
if value == "4k" {
return "2160p"
}
return value + "p"
}
func containsCapabilityString(values []string, target string) bool {
for _, value := range values {
if strings.EqualFold(strings.TrimSpace(value), strings.TrimSpace(target)) {
return true
}
}
return false
}
func containsInt(values []int, target int) bool {
for _, value := range values {
if value == target {
return true
}
}
return false
}
func absFloat(value float64) float64 {
if value < 0 {
return -value
}
return value
}