Files
ttstd-infinite-canvas/backend/internal/app/model_capability_validate.go

1023 lines
42 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"
"yingce/backend/internal/model"
)
func validateTextCapabilityConfig(value *TextCapabilityConfig) error {
if value.ContextWindowTokens < 4096 || value.ContextWindowTokens > 10000000 {
return BadAuthRequest("文本模型上下文窗口必须在 4096-10000000 Token 之间")
}
if value.MaxOutputTokens < 256 || value.MaxOutputTokens > 1000000 || value.MaxOutputTokens >= value.ContextWindowTokens {
return BadAuthRequest("文本模型最大输出 Token 必须小于上下文窗口且在 256-1000000 之间")
}
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 value.Parameters == nil && 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 value.Parameters == nil && preset.Tier != "1k" && preset.Tier != "1.5k" && preset.Tier != "2k" && preset.Tier != "4k" {
return BadAuthRequest("图片分辨率档位仅支持 1K、1.5K、2K、4K")
}
if value.Parameters != nil && value.Resolution.Supported && !containsCapabilityString(value.Resolution.Values, preset.Tier) {
return BadAuthRequest("像素映射使用了未启用的分辨率")
}
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 || value.Parameters == nil && max(w, h) > min(w, h)*3 {
return BadAuthRequest("图片预设比例无效")
}
if preset.Width <= 0 || preset.Height <= 0 || max(preset.Width, preset.Height) > 100000 || preset.Size != fmt.Sprintf("%dx%d", preset.Width, preset.Height) {
return BadAuthRequest("图片预设像素尺寸无效")
}
if value.Parameters == nil && (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) {
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 value.Parameters == nil && (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 value.Resolution.Supported {
if value.Parameters == nil && (len(value.Resolution.Values) == 0 || strings.TrimSpace(value.Resolution.Default) == "" || !containsCapabilityString(value.Resolution.Values, value.Resolution.Default)) {
return BadAuthRequest("请配置图片分辨率支持值和默认值")
}
} else {
value.Resolution.Values = []string{}
value.Resolution.Default = "auto"
}
defaultTier := value.Quality.Default
if value.Resolution.Supported {
defaultTier = value.Resolution.Default
}
if err := validateImagePresetSelection(value, defaultTier, 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
}
normalized, err := NormalizeModelCapabilityConfigForModel("image", taskInput.Config.InterfaceType, taskInput.Config.Model, &ModelCapabilityConfig{Version: 1, Image: profile}, s.protocolRegistry())
if err != nil {
return err
}
profile = normalized.Image
if profile.Parameters == nil {
fillMissingImageSizePresets(profile, taskInput.Config.InterfaceType)
}
if presetErr := imageSizePresetReadyError(profile, taskInput.Config.InterfaceType); presetErr != nil {
return presetErr
}
return validateImageTaskWithResolvedSize(input, &taskInput, profile)
}
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("当前视频模型能力参数无效")
}
applyAutoDLVideoScreenSpec(&taskInput, normalized.Video)
if config, ok := input["config"].(map[string]any); ok {
config["size"], config["vquality"] = taskInput.Config.Size, taskInput.Config.VQuality
}
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" {
profile, err = normalizedChannelModelCapability(item, s.protocolRegistry())
if err != nil {
return BadAuthRequest("当前图片模型能力参数无效")
}
imageProfile := DefaultImageCapabilityConfig(string(item.Protocol), firstNonEmpty(item.ProviderModelKey, item.ModelKey))
if profile != nil && profile.Image != nil {
imageProfile = profile.Image
}
imageProfile = applyModelSpecificImageCapability(imageProfile, string(item.Protocol), firstNonEmpty(item.ProviderModelKey, item.ModelKey), taskInput.Config.APIFormat)
// 旧线路没有换算表时按声明的档位补齐;补不齐就失败关闭,不能静默按 1K 出图。
if imageProfile.Parameters == nil {
fillMissingImageSizePresets(imageProfile, string(item.Protocol))
}
if presetErr := imageSizePresetReadyError(imageProfile, string(item.Protocol)); presetErr != nil {
return presetErr
}
return validateImageTaskWithResolvedSize(input, &taskInput, imageProfile)
}
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("当前视频模型能力参数无效")
}
applyAutoDLVideoScreenSpec(&taskInput, normalized.Video)
applyFixedVideoResolution(&taskInput, normalized.Video)
if config, ok := input["config"].(map[string]any); ok {
config["size"] = taskInput.Config.Size
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 newModelCapabilityArgumentError("referenceNodeIds", "item_count", BadAuthRequest("参考素材数量超过当前模型限制"))
}
if model.IsVolcengineArkVideoProtocol(model.ChannelInterfaceType(input.Config.InterfaceType)) && len(input.ReferenceAudios) > 0 && len(input.ReferenceImages) == 0 && len(input.ReferenceVideos) == 0 {
return newModelCapabilityArgumentError("referenceNodeIds", "invalid_combination", BadAuthRequest("火山方舟全模态参考不支持纯音频或文本+音频,请同时添加参考图片或参考视频"))
}
if len(input.ReferenceImages) < profile.References.MinImages {
return newModelCapabilityArgumentError("referenceNodeIds", "item_count", BadAuthRequest(fmt.Sprintf("当前视频模型至少需要 %d 张参考图", profile.References.MinImages)))
}
for _, media := range input.ReferenceImages {
if profile.References.MaxImageBytes > 0 && media.Bytes > profile.References.MaxImageBytes {
return newModelCapabilityArgumentError("referenceNodeIds", "unsupported_value", BadAuthRequest("参考图片文件超过当前模型大小限制"))
}
}
for _, media := range input.ReferenceVideos {
if profile.References.MaxVideoBytes > 0 && media.Bytes > profile.References.MaxVideoBytes {
return newModelCapabilityArgumentError("referenceNodeIds", "unsupported_value", BadAuthRequest("参考视频文件超过当前模型大小限制"))
}
if profile.References.MaxVideoDuration > 0 && media.DurationMs > int64(profile.References.MaxVideoDuration)*1000 {
return newModelCapabilityArgumentError("referenceNodeIds", "unsupported_value", BadAuthRequest("参考视频时长超过当前模型限制"))
}
}
for _, media := range input.ReferenceAudios {
if profile.References.MaxAudioBytes > 0 && media.Bytes > profile.References.MaxAudioBytes {
return newModelCapabilityArgumentError("referenceNodeIds", "unsupported_value", BadAuthRequest("参考音频文件超过当前模型大小限制"))
}
if profile.References.MaxAudioDuration > 0 && media.DurationMs > int64(profile.References.MaxAudioDuration)*1000 {
return newModelCapabilityArgumentError("referenceNodeIds", "unsupported_value", BadAuthRequest("参考音频时长超过当前模型限制"))
}
}
seconds, err := strconv.Atoi(strings.TrimSpace(input.Config.VideoSeconds))
if err != nil || !videoDurationAllowed(profile.Duration, seconds) {
return newModelCapabilityArgumentError("durationSeconds", "unsupported_value", BadAuthRequest("视频时长不在当前模型支持范围内"))
}
if input.Config.Size != "" && !videoRatioAllowed(profile.Ratios, input.Config.Size) {
return newModelCapabilityArgumentError("size", "unsupported_value", BadAuthRequest("画面比例不在当前模型支持范围内"))
}
if len(profile.Resolutions) > 0 && !isAutomaticVideoResolution(input.Config.VQuality) && videoResolutionNameRequest(profile, input.Config.VQuality) == "" {
return newModelCapabilityArgumentError("quality", "unsupported_value", 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 newModelCapabilityArgumentError("logicalModelId", "unsupported_value", BadAuthRequest("当前视频模型不支持该生成模式"))
}
return nil
}
func validateImageTask(profile *ImageCapabilityConfig, input canvasGenerationInput) error {
if profile == nil {
return nil
}
if profile.Parameters != nil {
if _, err := resolveImageParameterValues(profile.Parameters, input.Config.ImageParameters); err != nil {
return err
}
}
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 newModelCapabilityArgumentError("referenceNodeIds", "item_count", BadAuthRequest(fmt.Sprintf("当前图片模型最多支持 %d 张参考图", profile.References.MaxImages)))
}
for _, media := range input.ReferenceImages {
if profile.References.MaxImageBytes > 0 && media.Bytes > profile.References.MaxImageBytes {
return newModelCapabilityArgumentError("referenceNodeIds", "unsupported_value", BadAuthRequest("参考图片文件超过当前模型大小限制"))
}
}
if input.Mask != nil && !profile.References.MaskSupported {
return newModelCapabilityArgumentError("logicalModelId", "unsupported_value", BadAuthRequest("当前图片模型不支持蒙版编辑"))
}
quality := strings.TrimSpace(input.Config.Quality)
resolution := strings.TrimSpace(input.Config.ImageResolution)
if profile.Resolution.Supported && resolution != "" && !strings.EqualFold(resolution, "auto") && !strings.EqualFold(resolution, "any") && !containsCapabilityString(profile.Resolution.Values, resolution) {
return newModelCapabilityArgumentError("imageResolution", "unsupported_value", BadAuthRequest("图片分辨率不在当前模型支持范围内"))
}
selectionTier := quality
if profile.Resolution.Supported {
selectionTier = resolution
}
// 比例型线路会在任务输入边界把画布比例换算成 preset 像素值(OpenAI Images 只收
// WIDTHxHEIGHT)。换算结果必须继续通过同一套尺寸与分辨率校验。
resolvedRatio := imageSizeRatioForDeclaredPreset(profile, input.Config.Size, qualityForImageValidation(selectionTier, profile.Resolution.Default))
if profile.Size.Parameter != "none" && !profile.Size.AllowCustom && strings.TrimSpace(input.Config.Size) != "" &&
!containsCapabilityString(profile.Size.Values, input.Config.Size) && resolvedRatio == "" {
return newModelCapabilityArgumentError("size", "unsupported_value", BadAuthRequest("图片尺寸不在当前模型支持范围内"))
}
if profile.Parameters == nil && 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 newModelCapabilityArgumentError("size", "unsupported_value", err)
}
}
if profile.Quality.Supported && quality != "" && !strings.EqualFold(quality, "auto") && !strings.EqualFold(quality, "any") && !containsCapabilityString(profile.Quality.Values, quality) {
return newModelCapabilityArgumentError("quality", "unsupported_value", BadAuthRequest("图片质量不在当前模型支持范围内"))
}
if err := validateImagePresetSelection(profile, qualityForImageValidation(selectionTier, firstNonEmpty(profile.Resolution.Default, profile.Quality.Default)), firstNonEmpty(resolvedRatio, 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 newModelCapabilityArgumentError("count", "unsupported_value", BadAuthRequest(fmt.Sprintf("当前图片模型单次最多生成 %d 张", profile.MaxOutputs)))
}
return nil
}
func validateImageTaskWithResolvedSize(input map[string]any, taskInput *canvasGenerationInput, profile *ImageCapabilityConfig) error {
if profile.Parameters != nil {
if err := applyImageParameterValues(&taskInput.Config, profile); err != nil {
return err
}
if config, ok := input["config"].(map[string]any); ok {
config["imageParameters"] = taskInput.Config.ImageParameters
config["size"], config["quality"], config["imageResolution"] = taskInput.Config.Size, taskInput.Config.Quality, taskInput.Config.ImageResolution
config["count"], config["transparentBackground"] = taskInput.Config.Count, taskInput.Config.TransparentBackground
}
return validateImageTask(profile, *taskInput)
}
if imageSizeResolutionAfterValidation(profile, taskInput.Config.InterfaceType) {
if err := validateImageTask(profile, *taskInput); err != nil {
return err
}
applyImageSizeResolution(input, taskInput, profile)
return nil
}
applyImageSizeResolution(input, taskInput, profile)
return validateImageTask(profile, *taskInput)
}
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 := imagePresetSelectionTier(profile, quality)
if tier == "" {
return nil
}
for _, preset := range profile.Size.Presets {
if preset.Tier == tier && preset.Ratio == ratio {
return nil
}
}
return newModelCapabilityArgumentError("size", "unsupported_value", BadAuthRequest("当前分辨率不支持所选图片宽高比"))
}
func imageSizeValueForDeclaredPreset(profile *ImageCapabilityConfig, size string, quality string) string {
if profile == nil {
return ""
}
preset, ok := declaredImageSizePreset(profile.Size.Presets, size, quality, imagePresetDefaultTier(profile))
if !ok {
return ""
}
if profile.Size.Parameter == "size" {
return strings.TrimSpace(preset.Size)
}
return normalizeDeclaredImageRatio(preset.Ratio)
}
// imageSizeRatioForDeclaredPreset 把已声明的 preset 像素值还原成它对应的比例。比例型线路
// 会把画布比例换算成 preset 像素值(OpenAI Images 只收 WIDTHxHEIGHT),换算结果必须继续
// 通过同一套尺寸与分辨率校验。只认当前档位的 preset,避免用低档价格取到高档像素。
func imageSizeRatioForDeclaredPreset(profile *ImageCapabilityConfig, size string, quality string) string {
if profile == nil {
return ""
}
value := strings.TrimSpace(size)
if value == "" || !strings.ContainsAny(value, "xX") {
return ""
}
if preset, ok := declaredImageSizePreset(profile.Size.Presets, value, imagePresetSelectionTier(profile, quality), imagePresetDefaultTier(profile)); ok {
return normalizeDeclaredImageRatio(preset.Ratio)
}
return ""
}
func declaredImageSizePreset(presets []ImageSizePreset, value string, quality string, fallbackQuality string) (ImageSizePreset, bool) {
value = strings.TrimSpace(value)
if value == "" || strings.EqualFold(value, "auto") {
return ImageSizePreset{}, false
}
tier := strings.TrimSpace(quality)
if tier == "" {
tier = strings.TrimSpace(fallbackQuality)
}
ratio := ""
if strings.Contains(value, ":") {
ratio = normalizeDeclaredImageRatio(value)
}
for _, preset := range presets {
if tier != "" && !strings.EqualFold(strings.TrimSpace(preset.Tier), tier) {
continue
}
if (ratio != "" && normalizeDeclaredImageRatio(preset.Ratio) == ratio) || strings.EqualFold(strings.TrimSpace(preset.Size), value) {
return preset, true
}
}
return ImageSizePreset{}, false
}
func normalizeDeclaredImageRatio(value string) string {
ratio := normalizeImageAspectRatio(value)
left, right, found := strings.Cut(ratio, ":")
if !found {
return ratio
}
width, widthErr := strconv.Atoi(left)
height, heightErr := strconv.Atoi(right)
if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 {
return ratio
}
divisor := imageDimensionGCD(width, height)
return strconv.Itoa(width/divisor) + ":" + strconv.Itoa(height/divisor)
}
func imageResolutionTier(quality string) string {
switch strings.ToLower(strings.TrimSpace(quality)) {
case "1k", "low":
return "1k"
case "1.5k":
return "1.5k"
case "2k", "medium":
return "2k"
case "4k", "high":
return "4k"
default:
return ""
}
}
func declaredImageResolutionTier(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1k", "1.5k", "2k", "4k":
return strings.ToLower(strings.TrimSpace(value))
default:
return ""
}
}
// Legacy aliases are meaningful only when the model explicitly declares resolution tiers.
// Ordinary image quality never selects a pixel preset.
func imagePresetSelectionTier(profile *ImageCapabilityConfig, value string) string {
if profile.Parameters != nil {
if profile.Resolution.Supported && containsCapabilityString(profile.Resolution.Values, value) {
return value
}
if !profile.Resolution.Supported {
if tier, ok := singleImageSizeTier(profile); ok {
return tier
}
}
return ""
}
if tier := declaredImageResolutionTier(value); tier != "" {
return tier
}
if profile.Resolution.Supported || (profile.Quality.Supported && profile.ResolutionUsesQuality) {
return imageResolutionTier(value)
}
for _, quality := range profile.Quality.Values {
if declaredImageResolutionTier(quality) != "" {
return imageResolutionTier(value)
}
}
return ""
}
func imagePresetDefaultTier(profile *ImageCapabilityConfig) string {
if profile.Resolution.Supported {
return imagePresetSelectionTier(profile, profile.Resolution.Default)
}
return imagePresetSelectionTier(profile, profile.Quality.Default)
}
// resolveImageTaskSize 把画布按比例保存的 size(如 16:9)换算成图片模型
// 声明的 preset 像素尺寸。声明式 OpenAI Images 协议把统一请求的 aspectRatio
// 原样映射到上游 size 字段,只认 WIDTHxHEIGHT 的上游会拒绝比例字符串,所以
// 换算必须在任务输入边界完成。未命中 preset 的组合不做猜测,交给能力校验给
// 出明确错误。
func resolveImageTaskSize(profile *ImageCapabilityConfig, interfaceType string, size string, quality string) string {
if profile == nil {
return size
}
value := strings.TrimSpace(size)
if value == "" || strings.EqualFold(value, "auto") {
return size
}
preset, ok := declaredImageSizePreset(activeImageSizePresets(profile), value, imagePresetSelectionTier(profile, quality), imagePresetDefaultTier(profile))
if !ok {
return size
}
if imageSizePixelOutbound(profile, interfaceType) {
return preset.Size
}
if profile.Size.Parameter == "aspect_ratio" {
return normalizeDeclaredImageRatio(preset.Ratio)
}
return size
}
// imageSizePixelOutbound 表示该线路出网需要像素尺寸,决定“比例 → 像素”换算是否发生。
// parameter=size 由字段本身要求像素;aspect_ratio 只在声明式 OpenAI Images 上被翻成像素。
func imageSizePixelOutbound(profile *ImageCapabilityConfig, protocol string) bool {
if profile == nil {
return false
}
if profile.Parameters != nil {
for _, p := range profile.Parameters {
if p.Name == "size" && p.IsEnabled() {
return p.Encoding == "pixels"
}
}
return false
}
if profile.Size.Parameter == "size" {
return true
}
return profile.Size.Parameter == "aspect_ratio" && strings.TrimSpace(protocol) == string(model.ChannelInterfaceOpenAIImage)
}
// imageSizeClientSubmitsRatio 表示画布会按比例提交尺寸。size 字段里出现比例值属于历史
// 遗留形态:字段名说“发送像素”,值却是比例,同样需要换算表。
func imageSizeClientSubmitsRatio(profile *ImageCapabilityConfig) bool {
if profile.Size.Parameter == "aspect_ratio" || profile.Size.AllowCustom {
return true
}
if strings.Contains(profile.Size.Default, ":") {
return true
}
for _, value := range profile.Size.Values {
if strings.Contains(value, ":") {
return true
}
}
return false
}
// imageSizePresetMissingExact 是规则本体:出网需要像素、客户端按比例提交、没有换算表、
// 且确实有档位要被编码进尺寸。与 imageSizeResolutionEnabled 共用 imageSizePixelOutbound,
// 保证“需要换算”只有一个判断来源。size + 像素值 + 无预设表 的线路不需要换算,不能误伤。
func imageSizePresetMissingExact(profile *ImageCapabilityConfig, protocol string) bool {
return imageSizePixelOutbound(profile, protocol) && len(profile.Size.Presets) == 0 && imageSizeClientSubmitsRatio(profile)
}
// imageSizePresetMissing 在规则本体上再要求“有可解析的档位”:没有档位声明的线路(如 ark、
// 即梦图片)档位不参与请求,用不着换算表,不能因为缺表就判为配置不完整。
func imageSizePresetMissing(profile *ImageCapabilityConfig, protocol string) bool {
if !imageSizePresetMissingExact(profile, protocol) {
return false
}
if profile.Parameters != nil {
return true
}
return len(imageSizeDeclaredTiers(profile)) > 0
}
// imageSizeDeclaredTiers 取出该线路声明里可解析的分辨率档位。只有"档位要被编码进尺寸"的
// 线路才需要换算表:没有质量档位声明(例如 ark / 即梦图片)时,档位不参与请求,用不着表。
func imageSizeDeclaredTiers(profile *ImageCapabilityConfig) []string {
tiers := make([]string, 0, 3)
seen := make(map[string]bool, 3)
add := func(value string) {
tier := imageResolutionTier(value)
if tier == "" || seen[tier] {
return
}
seen[tier] = true
tiers = append(tiers, tier)
}
if profile.Quality.Supported {
for _, value := range profile.Quality.Values {
add(value)
}
}
add(profile.Quality.Default)
return tiers
}
// imageSizeDeclaredRatios 取出该线路声明里按比例形式的支持值与默认值,归一化后去重。
func imageSizeDeclaredRatios(profile *ImageCapabilityConfig) []string {
ratios := make([]string, 0, len(profile.Size.Values))
seen := make(map[string]bool, len(profile.Size.Values)+1)
add := func(value string) {
if !strings.Contains(value, ":") {
return
}
ratio := normalizeImageAspectRatio(value)
if ratio == "" || seen[ratio] {
return
}
seen[ratio] = true
ratios = append(ratios, ratio)
}
add(profile.Size.Default)
for _, value := range profile.Size.Values {
add(value)
}
return ratios
}
// openAIImageDefaultPixelSize 从默认换算表取「比例 + 档位」对应的像素尺寸。
func openAIImageDefaultPixelSize(ratio string, tier string) (ImageSizePreset, bool) {
index := -1
switch tier {
case "1k":
index = 0
case "2k":
index = 1
case "4k":
index = 2
}
if index < 0 {
return ImageSizePreset{}, false
}
for _, entry := range openAIImageDefaultSizeTable {
if entry.ratio != ratio {
continue
}
size := entry.tierSizes[index]
widthText, heightText, found := strings.Cut(size, "x")
width, widthErr := strconv.Atoi(widthText)
height, heightErr := strconv.Atoi(heightText)
if !found || widthErr != nil || heightErr != nil {
return ImageSizePreset{}, false
}
return ImageSizePreset{Tier: tier, Ratio: ratio, Size: size, Width: width, Height: height}, true
}
return ImageSizePreset{}, false
}
// fillMissingImageSizePresets 为「出网需要像素、按比例提交、却没有换算表」的旧线路补齐
// 换算表:按线路自己声明的档位与比例,从 openAIImageDefaultSizeTable 取值。这与
// legacyImageSizeValues 修复旧通配符尺寸是同一套做法 —— 旧数据在读取边界补成当前形态,
// 而不是要求人工逐条重配。补不齐(声明了表里没有的比例)时保持原样,交给调用方拒绝。
// 返回是否发生了补齐。
func fillMissingImageSizePresets(profile *ImageCapabilityConfig, protocol string) bool {
if !imageSizePresetMissingExact(profile, protocol) {
return false
}
tiers := imageSizeDeclaredTiers(profile)
ratios := imageSizeDeclaredRatios(profile)
if len(tiers) == 0 || len(ratios) == 0 {
return false
}
presets := make([]ImageSizePreset, 0, len(tiers)*len(ratios))
for _, tier := range tiers {
for _, ratio := range ratios {
if preset, ok := openAIImageDefaultPixelSize(ratio, tier); ok {
presets = append(presets, preset)
}
}
}
if len(presets) == 0 {
return false
}
profile.Size.Presets = presets
if profile.Size.Parameter == "size" {
// parameter=size 的线路要求预设像素值同时出现在支持值里(validateImageCapabilityConfig),
// 与前端 imageSizeConfigWithPresets 的做法一致。
for _, preset := range presets {
if !containsCapabilityString(profile.Size.Values, preset.Size) {
profile.Size.Values = append(profile.Size.Values, preset.Size)
}
}
}
return true
}
// imageSizePresetReadyError 是运行时的失败关闭点:需要把档位换算成像素、又确实没有可用的
// 换算表(含补不齐的情况)时,宁可报错,也不要静默按 1K 出图。
func imageSizePresetReadyError(profile *ImageCapabilityConfig, protocol string) error {
if !imageSizePresetMissing(profile, protocol) {
return nil
}
return BadAuthRequest("当前线路缺少像素尺寸换算表,无法把所选比例与档位换算成上游要求的像素尺寸;请在管理端为该线路的每个档位配置像素预设")
}
// validateImageSizePresetRequirement 是写路径(保存渠道模型)使用的完整性校验:
// 出网需要像素、客户端又按比例提交时必须有换算表。读路径不做这个拦截。
func validateImageSizePresetRequirement(capability string, protocol string, profile *ModelCapabilityConfig) error {
if normalizeCapability(capability) != "image" || profile == nil {
return nil
}
if image := profile.Image; image != nil && image.Parameters != nil && imageSizePixelOutbound(image, protocol) {
if !image.Resolution.Supported && len(image.Size.Presets) > 0 {
if _, ok := singleImageSizeTier(image); !ok || len(activeImageSizePresets(image)) == 0 {
return BadAuthRequest("关闭分辨率前请设置默认尺寸档位")
}
}
hasRatios := false
for _, ratio := range image.Size.Values {
if !strings.Contains(ratio, ":") {
continue
}
hasRatios = true
if _, ok := declaredImageSizePreset(image.Size.Presets, ratio, "", ""); !ok {
return BadAuthRequest(fmt.Sprintf("请配置比例 %s 对应的像素尺寸", ratio))
}
}
// Each tier owns its available ratios; the cartesian product is not required.
if hasRatios && image.Resolution.Supported {
for _, tier := range image.Resolution.Values {
found := false
for _, preset := range image.Size.Presets {
if preset.Tier == tier {
found = true
break
}
}
if !found {
return BadAuthRequest(fmt.Sprintf("请为分辨率 %s 至少配置一个比例", tier))
}
}
}
}
if !imageSizePresetMissing(profile.Image, protocol) {
return nil
}
return BadAuthRequest("该线路按像素尺寸出网但未配置像素预设:请为每个分辨率档位填写对应的像素尺寸,否则实际出图尺寸会与所选档位不一致")
}
func imageSizeResolutionEnabled(profile *ImageCapabilityConfig, interfaceType string) bool {
return imageSizePixelOutbound(profile, interfaceType) && len(profile.Size.Presets) > 0
}
func imageSizeResolutionAfterValidation(profile *ImageCapabilityConfig, interfaceType string) bool {
return profile != nil && profile.Size.Parameter != "size" && imageSizeResolutionEnabled(profile, interfaceType)
}
// applyImageSizeResolution 把换算结果写回任务输入,与视频的 applyVideoResolution
// 写回 config.vquality 同一机制,保证持久化的任务输入已是可执行值。
func applyImageSizeResolution(input map[string]any, taskInput *canvasGenerationInput, profile *ImageCapabilityConfig) {
tier := taskInput.Config.Quality
if profile.Resolution.Supported {
tier = taskInput.Config.ImageResolution
}
resolved := resolveImageTaskSize(profile, taskInput.Config.InterfaceType, taskInput.Config.Size, tier)
if resolved == taskInput.Config.Size {
return
}
taskInput.Config.Size = resolved
if config, ok := input["config"].(map[string]any); ok {
config["size"] = resolved
}
}
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
}