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

671 lines
22 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.
// 模型能力规格(CapabilitySpec)的解码、规范化与匹配。
//
// 一次生成请求先被归一成 ModelRequestIntent(模式、尺寸、时长、参考图数量等),
// 再逐个路由比对能力规格:输入约束(参考图/视频/音频数量)与选项约束(尺寸、比例、质量)
// 都满足才算可用。这里是纯函数,不读数据库。
package app
import (
"encoding/json"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
"yingce/backend/internal/model"
"yingce/backend/internal/protocol"
)
type CapabilityImageSizePreset = ImageSizePreset
type CapabilityImageSize struct {
Parameter string `json:"parameter,omitempty"`
AllowCustom bool `json:"allowCustom,omitempty"`
Presets []CapabilityImageSizePreset `json:"presets,omitempty"`
}
type CapabilitySpec struct {
ImageParameters []protocol.ImageParameter `json:"imageParameters"`
Version int `json:"version"`
Capability string `json:"capability"`
Operations []string `json:"operations,omitempty"`
Inputs map[string]InputConstraint `json:"inputs,omitempty"`
Options map[string]OptionConstraint `json:"options,omitempty"`
ImageSize *CapabilityImageSize `json:"imageSize,omitempty"`
}
type InputConstraint struct {
Min int `json:"min"`
Max int `json:"max"`
}
type OptionConstraint struct {
Values []any `json:"values,omitempty"`
Min *float64 `json:"min,omitempty"`
Max *float64 `json:"max,omitempty"`
Step *float64 `json:"step,omitempty"`
}
type ModelRequestIntent struct {
Capability string `json:"capability"`
Operation string `json:"operation,omitempty"`
Inputs map[string]int `json:"inputs,omitempty"`
Options map[string]any `json:"options,omitempty"`
}
// ModelRequestIntentFromTaskInput 从统一任务输入推导路由意图;它只统计实际输入和显式参数,不假设任何固定图片数或视频时长。
func ModelRequestIntentFromTaskInput(input map[string]any, taskType string, operation string) ModelRequestIntent {
capability := normalizeCapability(fmt.Sprint(input["mode"]))
if capability == "" {
capability = capabilityFromTaskType(taskType)
}
intent := ModelRequestIntent{Capability: capability, Operation: strings.TrimSpace(operation), Inputs: map[string]int{}, Options: map[string]any{}}
for inputType, key := range map[string]string{"image": "referenceImages", "video": "referenceVideos", "audio": "referenceAudios"} {
if values, ok := input[key].([]any); ok {
intent.Inputs[inputType] = len(values)
}
}
if mask, exists := input["mask"]; exists && mask != nil {
intent.Inputs["mask"] = 1
}
explicitOptions := false
if options, ok := input["capabilityOptions"].(map[string]any); ok {
explicitOptions = true
for key, value := range options {
name := canonicalModelOptionName(capability, key)
// auto/any 表示调用方不指定质量或图片分辨率;不能把它当作模型必须声明的
// 枚举值,否则未列出 auto 的模型会被错误判定为参数不支持。
if name == "quality" || name == "imageResolution" {
if normalized, ok := value.(string); ok && (strings.EqualFold(strings.TrimSpace(normalized), "auto") || strings.EqualFold(strings.TrimSpace(normalized), "any")) {
continue
}
}
if capability == "image" {
intent.Options[name] = value
} else {
intent.Options[name] = normalizeModelRequestOption(name, value)
}
}
}
if config, ok := input["config"].(map[string]any); ok && !explicitOptions {
if capability == "image" {
if parameters, ok := config["imageParameters"].(map[string]any); ok {
for name, value := range parameters {
intent.Options[name] = value
}
return intent
}
}
for key, value := range config {
switch key {
case "channelId", "apiFormat", "interfaceType", "baseUrl", "apiKey", "secretKey", "headers", "model", "capabilityConfig":
continue
default:
canonical := canonicalCapabilityOptionName(key)
if isCapabilityOptionFor(capability, canonical) && value != nil && strings.TrimSpace(fmt.Sprint(value)) != "" {
intent.Options[canonical] = normalizeModelRequestOption(canonical, value)
}
}
}
}
return intent
}
func normalizeModelRequestOption(name string, value any) any {
canonicalName := canonicalCapabilityOptionName(name)
if canonicalName == "quality" || canonicalName == "size" || canonicalName == "imageResolution" {
if text, ok := value.(string); ok {
return strings.ToLower(strings.TrimSpace(text))
}
return value
}
if canonicalName != "vquality" {
return value
}
resolution, ok := value.(string)
if !ok {
return value
}
switch strings.ToLower(strings.TrimSpace(resolution)) {
case "low", "480", "480p":
return "480p"
case "720", "720p":
return "720p"
case "1080", "1080p":
return "1080p"
case "2k", "1440", "1440p":
return "1440p"
case "4k", "2160", "2160p":
return "2160p"
default:
// Providers expose additional resolutions such as 768P and 960P.
// Canonicalize numeric P values generically so billing selectors match
// regardless of whether the client sends "960" or "960P".
pixels := strings.TrimSuffix(strings.ToLower(strings.TrimSpace(resolution)), "p")
if numeric, err := strconv.Atoi(pixels); err == nil && numeric > 0 {
return strconv.Itoa(numeric) + "p"
}
return value
}
}
type CapabilityIssue struct {
Kind string `json:"kind"`
Name string `json:"name"`
Reason string `json:"reason"`
}
type CapabilityMatch struct {
Matched bool `json:"matched"`
Reasons []string `json:"reasons,omitempty"`
Issues []CapabilityIssue `json:"issues,omitempty"`
}
type cachedLogicalModel struct {
Model model.LogicalModel
Revision model.LogicalModelRevision
ProductSpec CapabilitySpec
Defaults map[string]any
Routes []cachedLogicalRoute
}
type cachedLogicalRoute struct {
Route model.LogicalModelRoute
CapabilitySpec CapabilitySpec
ChannelModel model.ChannelModel
}
type routeCatalogSnapshot struct {
LoadedAt time.Time
CatalogVersion int64
Models map[string]cachedLogicalModel
Ordered []string
}
type RoutedModel struct {
LogicalModel model.LogicalModel
Revision model.LogicalModelRevision
Route model.LogicalModelRoute
ChannelModel model.ChannelModel
CapabilitySpec CapabilitySpec
PriceTier *model.ChannelModelPriceTier
Defaults map[string]any
// Intent 是已归一的公开生成意图(比例口径),与线路执行编码分离。
Intent ModelRequestIntent
}
func DecodeCapabilitySpec(raw string) (CapabilitySpec, error) {
var spec CapabilitySpec
if err := json.Unmarshal([]byte(raw), &spec); err != nil {
return spec, BadAuthRequest("能力配置不是有效 JSON")
}
normalized, err := NormalizeCapabilitySpec(spec)
if err != nil {
return spec, err
}
return normalized, nil
}
func ValidateCapabilitySpec(spec CapabilitySpec) error {
_, err := NormalizeCapabilitySpec(spec)
return err
}
func NormalizeCapabilitySpec(spec CapabilitySpec) (CapabilitySpec, error) {
spec.Capability = normalizeCapability(spec.Capability)
if spec.Version != 1 {
return spec, BadAuthRequest("能力配置 version 必须为 1")
}
if spec.Capability == "" {
return spec, BadAuthRequest("能力配置必须声明 capability")
}
operations := make([]string, 0, len(spec.Operations))
seenOperations := make(map[string]bool, len(spec.Operations))
for _, operation := range spec.Operations {
normalized := normalizeCapabilityValue(operation)
if normalized != "" && !seenOperations[normalized] {
seenOperations[normalized] = true
operations = append(operations, normalized)
}
}
spec.Operations = operations
normalizedInputs := make(map[string]InputConstraint, len(spec.Inputs))
for rawName, constraint := range spec.Inputs {
name := normalizeCapabilityValue(rawName)
if name == "" || constraint.Min < 0 || constraint.Max < constraint.Min {
return spec, BadAuthRequest("输入能力范围无效")
}
if _, exists := normalizedInputs[name]; exists {
return spec, BadAuthRequest("输入能力存在重复名称:" + name)
}
normalizedInputs[name] = constraint
}
spec.Inputs = normalizedInputs
normalizedOptions := make(map[string]OptionConstraint, len(spec.Options))
for rawName, constraint := range spec.Options {
name := canonicalModelOptionName(spec.Capability, rawName)
if strings.TrimSpace(name) == "" {
return spec, BadAuthRequest("能力参数名称不能为空")
}
if _, exists := normalizedOptions[name]; exists {
return spec, BadAuthRequest("能力参数存在重复别名:" + name)
}
hasValues := len(constraint.Values) > 0
hasRange := constraint.Min != nil || constraint.Max != nil || constraint.Step != nil
if !hasValues && !hasRange {
return spec, BadAuthRequest("能力参数必须声明 values 或数值范围")
}
if hasValues && hasRange {
return spec, BadAuthRequest("能力参数不能同时声明 values 和数值范围")
}
if hasRange && (constraint.Min == nil || constraint.Max == nil) {
return spec, BadAuthRequest("数值范围必须同时声明 min 和 max")
}
if constraint.Min != nil && constraint.Max != nil && *constraint.Max < *constraint.Min {
return spec, BadAuthRequest("能力参数数值范围无效")
}
if constraint.Step != nil && *constraint.Step <= 0 {
return spec, BadAuthRequest("能力参数 step 必须大于 0")
}
normalizedOptions[name] = constraint
}
spec.Options = normalizedOptions
if spec.Capability == "image" && spec.ImageParameters != nil {
if err := protocol.ValidateImageParameters(spec.ImageParameters); err != nil {
return spec, BadAuthRequest(err.Error())
}
}
return spec, nil
}
func decodeLogicalDefaults(raw string, spec CapabilitySpec) (map[string]any, error) {
defaults := map[string]any{}
if err := json.Unmarshal([]byte(raw), &defaults); err != nil {
return nil, err
}
if spec.Capability == "image" && spec.ImageParameters != nil {
current := imageParameterDefaults(spec.ImageParameters)
for _, p := range spec.ImageParameters {
if value, ok := defaults[p.Name]; ok && p.IsEnabled() && p.Accepts(imageParameterValue(p, value)) {
current[p.Name] = imageParameterValue(p, value)
}
}
defaults = current
}
return normalizeLogicalDefaults(spec, defaults)
}
func MatchCapability(spec CapabilitySpec, intent ModelRequestIntent) CapabilityMatch {
reasons := make([]string, 0)
issues := make([]CapabilityIssue, 0)
addIssue := func(kind, name, reason string) {
reasons = append(reasons, reason)
issues = append(issues, CapabilityIssue{Kind: kind, Name: name, Reason: reason})
}
if spec.Capability == "image" && spec.ImageParameters != nil {
values := make(map[string]any, len(intent.Options))
for name, value := range intent.Options {
values[name] = value
}
// Size may already be encoded as a pixel preset by an older client.
parameters := append([]protocol.ImageParameter(nil), spec.ImageParameters...)
for index := range parameters {
parameters[index].Required = false
}
if value, ok := values["size"]; ok && capabilityImageSizePresetMatches(spec, value, values) {
delete(values, "size")
parameters = make([]protocol.ImageParameter, 0, len(spec.ImageParameters))
for _, p := range spec.ImageParameters {
if p.Name != "size" {
p.Required = false
parameters = append(parameters, p)
}
}
}
if _, err := resolveImageParameterValues(parameters, values); err != nil {
var modelErr *ModelError
if errors.As(err, &modelErr) && modelErr != nil {
field, _ := modelErr.Details["field"].(string)
message, _ := modelErr.Details["message"].(string)
if field != "" {
if message == "" {
message = modelErr.Error()
}
addIssue("image_parameter", field, message)
} else {
addIssue("image_parameter", "imageParameters", err.Error())
}
} else {
addIssue("image_parameter", "imageParameters", err.Error())
}
}
}
if normalizeCapability(intent.Capability) != normalizeCapability(spec.Capability) {
addIssue("capability", "capability", "能力类型不匹配")
}
if operation := normalizeCapabilityValue(intent.Operation); operation != "" && len(spec.Operations) > 0 && !containsNormalized(spec.Operations, operation) {
addIssue("operation", "operation", "不支持操作 "+intent.Operation)
}
for inputType, count := range intent.Inputs {
if count < 0 {
addIssue("input", inputType, "输入数量不能小于 0")
continue
}
constraint, declared := spec.Inputs[inputType]
if !declared {
if count > 0 {
addIssue("input", inputType, "不支持 "+capabilityInputLabel(inputType)+"输入")
}
continue
}
if count < constraint.Min || count > constraint.Max {
addIssue("input", inputType, fmt.Sprintf("%s数量需在 %d-%d 之间", capabilityInputLabel(inputType), constraint.Min, constraint.Max))
}
}
for inputType, constraint := range spec.Inputs {
if intent.Inputs[inputType] < constraint.Min {
addIssue("input", inputType, fmt.Sprintf("至少需要 %d 个%s", constraint.Min, capabilityInputLabel(inputType)))
}
}
for name, value := range intent.Options {
canonical := canonicalModelOptionName(spec.Capability, name)
if inactiveCapabilityOptionValue(canonical, value) {
continue
}
constraint, declared := spec.Options[canonical]
if !declared {
addIssue("option", canonical, "不支持参数 "+capabilityOptionLabel(name))
continue
}
if !matchOptionConstraint(name, constraint, value) {
addIssue("option", canonical, "参数 "+capabilityOptionLabel(name)+"超出支持范围")
}
}
return CapabilityMatch{Matched: len(reasons) == 0, Reasons: reasons, Issues: issues}
}
// Image size intent and provider encoding share explicit preset mappings.
// 公开意图按比例口径表达,线路执行编码可能是像素。两者只依据当前规格的显式 presets
// 互为别名,不做宽高数值猜测。
// resolveModelRequestIntent 产出匹配、计价与线路选择共用的唯一公开生成意图。
func resolveModelRequestIntent(spec CapabilitySpec, intent ModelRequestIntent, defaults map[string]any) ModelRequestIntent {
intent.Options = mergeIntentDefaults(intent.Options, defaults)
return normalizeImageSizeIntent(spec, intent)
}
func normalizeImageSizeIntent(spec CapabilitySpec, intent ModelRequestIntent) ModelRequestIntent {
if normalizeCapability(intent.Capability) != "image" || spec.ImageSize == nil || intent.Options == nil {
return intent
}
value, ok := intent.Options["size"].(string)
if !ok || strings.TrimSpace(value) == "" {
return intent
}
tier := stringValue(intent.Options["imageResolution"])
if _, hasIndependentResolution := spec.Options["imageResolution"]; hasIndependentResolution {
if strings.EqualFold(tier, "auto") || strings.EqualFold(tier, "any") {
tier = ""
}
if tier == "" {
return intent
}
} else if spec.ImageParameters == nil {
tier = stringValue(intent.Options["quality"])
}
preset, ok := declaredImageSizePreset(spec.ImageSize.Presets, value, tier, "")
if !ok {
return intent
}
options := make(map[string]any, len(intent.Options))
for key, option := range intent.Options {
options[key] = option
}
if spec.ImageSize.Parameter == "size" {
options["size"] = strings.TrimSpace(preset.Size)
} else {
options["size"] = normalizeDeclaredImageRatio(preset.Ratio)
}
intent.Options = options
return intent
}
// capabilityImageSizePresetMatches lets routing compare the public image-size
// intent with either route encoding. Only an explicit tier + preset mapping is
// an alias; numeric similarity is never inferred.
func capabilityImageSizePresetMatches(spec CapabilitySpec, value any, options map[string]any) bool {
size := spec.ImageSize
if size == nil || len(size.Presets) == 0 {
return false
}
text, ok := value.(string)
if !ok {
return false
}
var tier any
if spec.ImageParameters == nil {
tier = options["quality"]
}
if _, independent := spec.Options["imageResolution"]; independent {
resolution := stringValue(options["imageResolution"])
if resolution == "" || strings.EqualFold(resolution, "auto") || strings.EqualFold(resolution, "any") {
return false
}
tier = resolution
}
_, matched := declaredImageSizePreset(size.Presets, text, stringValue(tier), "")
return matched
}
// inactiveCapabilityOptionValue 判断取值是否表示“未请求该参数”。
// 空值和图片质量/分辨率的 auto/any 不构成请求;其他非空字符串、true、非零数字仍视为真实要求。
func inactiveCapabilityOptionValue(name string, value any) bool {
switch typed := value.(type) {
case nil:
return true
case bool:
return !typed
case string:
normalized := strings.TrimSpace(typed)
if normalized == "" {
return true
}
return (name == "quality" || name == "imageResolution") && (strings.EqualFold(normalized, "auto") || strings.EqualFold(normalized, "any"))
case float64:
return typed == 0
case float32:
return typed == 0
case int:
return typed == 0
case int64:
return typed == 0
}
return false
}
func capabilityInputLabel(name string) string {
switch normalizeCapabilityValue(name) {
case "image":
return "参考图片"
case "video":
return "参考视频"
case "audio":
return "参考音频"
case "mask":
return "蒙版"
default:
return name
}
}
func capabilityOptionLabel(name string) string {
switch canonicalCapabilityOptionName(name) {
case "size":
return "画面尺寸"
case "quality":
return "生成质量"
case "imageResolution":
return "图片分辨率"
case "transparentBackground":
return "透明背景"
case "count":
return "输出数量"
case "videoSeconds":
return "视频时长"
case "vquality":
return "输出分辨率"
case "videoGenerateAudio":
return "同步音频"
case "videoWatermark":
return "水印设置"
case "audioVoice":
return "音色"
case "audioFormat":
return "音频格式"
case "audioSpeed":
return "语速"
case "audioInstructions":
return "朗读指令"
default:
return name
}
}
func matchOptionConstraint(name string, constraint OptionConstraint, value any) bool {
if len(constraint.Values) > 0 {
for _, candidate := range constraint.Values {
if normalizedScalar(candidate) == "*" {
return true
}
if capabilityOptionValuesEqual(name, candidate, value) {
return true
}
}
return false
}
number, ok := numericScalar(value)
if !ok {
return false
}
if constraint.Min != nil && number < *constraint.Min {
return false
}
if constraint.Max != nil && number > *constraint.Max {
return false
}
if constraint.Step != nil && constraint.Min != nil {
steps := (number - *constraint.Min) / *constraint.Step
return math.Abs(steps-math.Round(steps)) < 1e-9
}
return true
}
func capabilityOptionValuesEqual(name string, candidate any, value any) bool {
left := normalizedScalar(candidate)
right := normalizedScalar(value)
if canonicalCapabilityOptionName(name) == "vquality" {
// Compare using the same aliases as request intents and price tiers.
left = strings.TrimSuffix(normalizedScalar(normalizeModelRequestOption(name, left)), "p")
right = strings.TrimSuffix(normalizedScalar(normalizeModelRequestOption(name, right)), "p")
}
return left == right
}
func normalizedScalar(value any) string {
switch typed := value.(type) {
case string:
return normalizeCapabilityValue(typed)
case bool:
return strconv.FormatBool(typed)
case float64:
return strconv.FormatFloat(typed, 'f', -1, 64)
case float32:
return strconv.FormatFloat(float64(typed), 'f', -1, 64)
case int:
return strconv.Itoa(typed)
case int64:
return strconv.FormatInt(typed, 10)
default:
encoded, _ := json.Marshal(value)
return string(encoded)
}
}
func numericScalar(value any) (float64, bool) {
switch typed := value.(type) {
case float64:
return typed, true
case float32:
return float64(typed), true
case int:
return float64(typed), true
case int64:
return float64(typed), true
case json.Number:
parsed, err := typed.Float64()
return parsed, err == nil
case string:
parsed, err := strconv.ParseFloat(strings.TrimSpace(typed), 64)
return parsed, err == nil
default:
return 0, false
}
}
func containsNormalized(values []string, expected string) bool {
for _, value := range values {
if normalizeCapabilityValue(value) == expected {
return true
}
}
return false
}
func normalizeCapabilityValue(value string) string {
return strings.ToLower(strings.TrimSpace(value))
}
func canonicalCapabilityOptionName(value string) string {
name := strings.TrimSpace(value)
switch name {
case "duration":
return "videoSeconds"
case "aspectRatio":
return "size"
case "resolution":
return "vquality"
default:
return name
}
}
func canonicalModelOptionName(capability, value string) string {
if capability == "image" {
return strings.TrimSpace(value)
}
return canonicalCapabilityOptionName(value)
}
func isCapabilityOptionFor(capability string, name string) bool {
switch normalizeCapability(capability) {
case "image":
return name == "size" || name == "quality" || name == "imageResolution" || name == "transparentBackground" || name == "count"
case "video":
return name == "size" || name == "videoSeconds" || name == "vquality" || name == "videoGenerateAudio" || name == "videoWatermark"
case "audio":
return name == "audioVoice" || name == "audioFormat" || name == "audioSpeed" || name == "audioInstructions"
case "text":
// systemPrompt 是请求内容,不是供应线路能力维度,不能参与路由匹配。
return false
default:
return false
}
}
func isProviderCapabilityOption(name string) bool {
return isCapabilityOptionFor("image", name) || isCapabilityOptionFor("video", name) || isCapabilityOptionFor("audio", name) || isCapabilityOptionFor("text", name)
}