// 模型能力规格(CapabilitySpec)的解码、规范化与匹配。 // // 一次生成请求先被归一成 ModelRequestIntent(模式、尺寸、时长、参考图数量等), // 再逐个路由比对能力规格:输入约束(参考图/视频/音频数量)与选项约束(尺寸、比例、质量) // 都满足才算可用。这里是纯函数,不读数据库。 package app import ( "encoding/json" "fmt" "math" "strconv" "strings" "time" "infinite-canvas/backend/internal/model" ) type CapabilityImageSizePreset struct { Size string `json:"size"` Tier string `json:"tier"` Ratio string `json:"ratio"` Width int `json:"width"` Height int `json:"height"` } type CapabilityImageSize struct { Parameter string `json:"parameter,omitempty"` AllowCustom bool `json:"allowCustom,omitempty"` Presets []CapabilityImageSizePreset `json:"presets,omitempty"` } type CapabilitySpec struct { 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 := canonicalCapabilityOptionName(key) // auto/any 表示调用方不指定质量;不能把它当作模型必须声明的 // 枚举值,否则未列出 auto 的模型会被错误判定为参数不支持。 if name == "quality" { if normalized, ok := value.(string); ok && (strings.EqualFold(strings.TrimSpace(normalized), "auto") || strings.EqualFold(strings.TrimSpace(normalized), "any")) { continue } } intent.Options[name] = normalizeModelRequestOption(name, value) } } if config, ok := input["config"].(map[string]any); ok && !explicitOptions { 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" { 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 CapabilityMatch struct { Matched bool `json:"matched"` Reasons []string `json:"reasons,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 PriceTier *model.ChannelModelPriceTier Defaults map[string]any } 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 := canonicalCapabilityOptionName(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 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 } return normalizeLogicalDefaults(spec, defaults) } func MatchCapability(spec CapabilitySpec, intent ModelRequestIntent) CapabilityMatch { reasons := make([]string, 0) if normalizeCapability(intent.Capability) != normalizeCapability(spec.Capability) { reasons = append(reasons, "能力类型不匹配") } if operation := normalizeCapabilityValue(intent.Operation); operation != "" && len(spec.Operations) > 0 && !containsNormalized(spec.Operations, operation) { reasons = append(reasons, "不支持操作 "+intent.Operation) } for inputType, count := range intent.Inputs { if count < 0 { reasons = append(reasons, "输入数量不能小于 0") continue } constraint, declared := spec.Inputs[inputType] if !declared { if count > 0 { reasons = append(reasons, "不支持 "+capabilityInputLabel(inputType)+"输入") } continue } if count < constraint.Min || count > constraint.Max { reasons = append(reasons, fmt.Sprintf("%s数量需在 %d-%d 之间", capabilityInputLabel(inputType), constraint.Min, constraint.Max)) } } for inputType, constraint := range spec.Inputs { if intent.Inputs[inputType] < constraint.Min { reasons = append(reasons, fmt.Sprintf("至少需要 %d 个%s", constraint.Min, capabilityInputLabel(inputType))) } } for name, value := range intent.Options { constraint, declared := spec.Options[canonicalCapabilityOptionName(name)] if !declared { reasons = append(reasons, "不支持参数 "+capabilityOptionLabel(name)) continue } if !matchOptionConstraint(name, constraint, value) { reasons = append(reasons, "参数 "+capabilityOptionLabel(name)+"超出支持范围") } } return CapabilityMatch{Matched: len(reasons) == 0, Reasons: reasons} } 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 "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 isCapabilityOptionFor(capability string, name string) bool { switch normalizeCapability(capability) { case "image": return name == "size" || name == "quality" || 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) }