fix(platform): 模型能力 - 修复前台默认参数与后台空状态

This commit is contained in:
ddCat
2026-08-19 09:08:34 +08:00
parent d2a089f1ec
commit 6a698129bd
14 changed files with 518 additions and 120 deletions

View File

@@ -165,6 +165,11 @@ func (s *Service) PublicLogicalModels(intent *ModelRequestIntent) ([]PublicLogic
func publicLogicalModel(cached cachedLogicalModel, available bool) PublicLogicalModel {
item := cached.Model
routeSpecs := make([]CapabilitySpec, 0, len(cached.Routes))
for _, route := range cached.Routes {
routeSpecs = append(routeSpecs, route.VariantSpec)
}
productSpec := capabilitySpecWithRoutePresets(cached.ProductSpec, routeSpecs)
variants := make([]CapabilitySpec, 0, len(cached.Routes))
seen := make(map[string]bool, len(cached.Routes))
for _, route := range cached.Routes {
@@ -177,7 +182,37 @@ func publicLogicalModel(cached cachedLogicalModel, available bool) PublicLogical
variants = append(variants, route.VariantSpec)
}
}
return PublicLogicalModel{ID: item.ID, Code: item.Code, Name: item.Name, Icon: item.Icon, Description: item.Description, Capability: item.Capability, SortOrder: item.SortOrder, PricePolicy: item.PricePolicy, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputPriceMicrocredits: item.InputPriceMicrocredits, OutputPriceMicrocredits: item.OutputPriceMicrocredits, CachedPriceMicrocredits: item.CachedPriceMicrocredits, CapabilitySpec: cached.ProductSpec, CapabilityProfiles: variants, DefaultOptions: cached.Defaults, Available: available}
return PublicLogicalModel{ID: item.ID, Code: item.Code, Name: item.Name, Icon: item.Icon, Description: item.Description, Capability: item.Capability, SortOrder: item.SortOrder, PricePolicy: item.PricePolicy, BillingMode: item.BillingMode, UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputPriceMicrocredits: item.InputPriceMicrocredits, OutputPriceMicrocredits: item.OutputPriceMicrocredits, CachedPriceMicrocredits: item.CachedPriceMicrocredits, CapabilitySpec: productSpec, CapabilityProfiles: variants, DefaultOptions: cached.Defaults, Available: available}
}
// capabilitySpecWithRoutePresets repairs old front-model snapshots that stored
// only `*` for a custom size. The wildcard remains for matching custom values,
// while route presets are restored for admin and creator-side selectors.
func capabilitySpecWithRoutePresets(spec CapabilitySpec, routes []CapabilitySpec) CapabilitySpec {
result := spec
result.Options = make(map[string]OptionConstraint, len(spec.Options))
for name, constraint := range spec.Options {
if !isWildcardOptionConstraint(constraint) {
result.Options[name] = constraint
continue
}
values := append([]any(nil), constraint.Values...)
seen := make(map[string]bool, len(values))
for _, value := range values {
seen[normalizedScalar(value)] = true
}
for _, route := range routes {
for _, value := range route.Options[name].Values {
key := normalizedScalar(value)
if key != "" && !seen[key] {
seen[key] = true
values = append(values, value)
}
}
}
result.Options[name] = OptionConstraint{Values: values}
}
return result
}
// capabilityFingerprint 用规范化后的结构去重能力画像;不能直接依赖原始 JSON,
@@ -250,10 +285,6 @@ func (s *Service) buildAdminLogicalModel(item model.LogicalModel, graph *reposit
if err != nil {
return nil, err
}
defaults, err := decodeLogicalDefaults(graph.Revision.DefaultOptionsJSON, productSpec)
if err != nil {
return nil, err
}
variantByID := make(map[string]model.PhysicalCapabilityVariant, len(graph.Variants))
for _, variant := range graph.Variants {
variantByID[variant.ID] = variant
@@ -262,7 +293,7 @@ func (s *Service) buildAdminLogicalModel(item model.LogicalModel, graph *reposit
for _, channelModel := range graph.ChannelModels {
channelModelByID[channelModel.ID] = channelModel
}
admin := AdminLogicalModel{PublicLogicalModel: publicLogicalModel(cachedLogicalModel{Model: item, ProductSpec: productSpec, Defaults: defaults}, false), Enabled: item.Enabled, ActiveRevisionID: graph.Revision.ID, RevisionVersion: graph.Revision.Version, Routes: []AdminLogicalRoute{}}
admin := AdminLogicalModel{PublicLogicalModel: publicLogicalModel(cachedLogicalModel{Model: item, ProductSpec: productSpec, Defaults: map[string]any{}}, false), Enabled: item.Enabled, ActiveRevisionID: graph.Revision.ID, RevisionVersion: graph.Revision.Version, Routes: []AdminLogicalRoute{}}
for _, route := range graph.Routes {
variant, variantOK := variantByID[route.PhysicalVariantID]
if !variantOK {
@@ -281,6 +312,20 @@ func (s *Service) buildAdminLogicalModel(item model.LogicalModel, graph *reposit
available := structurallyAvailable && (item.PricePolicy != "channel" || channelModel.PriceConfigured)
admin.Routes = append(admin.Routes, AdminLogicalRoute{ID: route.ID, PhysicalVariantID: variant.ID, PhysicalVariantName: variant.Name, ChannelModelID: channelModel.ID, ChannelID: channelModel.ChannelID, PhysicalModelKey: channelModel.ModelKey, PhysicalModelName: channelModel.DisplayName, Enabled: route.Enabled, Priority: route.Priority, Weight: route.Weight, Available: available, structurallyAvailable: structurallyAvailable, CapabilitySpec: variantSpec})
}
routeSpecs := make([]CapabilitySpec, 0, len(admin.Routes))
for _, route := range admin.Routes {
routeSpecs = append(routeSpecs, route.CapabilitySpec)
}
productSpec = capabilitySpecWithRoutePresets(productSpec, routeSpecs)
defaults, err := decodeLogicalDefaults(graph.Revision.DefaultOptionsJSON, productSpec)
if err != nil {
return nil, err
}
admin.PublicLogicalModel = publicLogicalModel(cachedLogicalModel{Model: item, ProductSpec: productSpec, Defaults: defaults}, false)
// publicLogicalModel above has no route list; admin routes are already attached
// and the enriched product spec is the source used by the editor.
admin.CapabilitySpec = productSpec
admin.DefaultOptions = defaults
structuralRouteSpecs := structuralAdminRouteSpecs(admin.Routes)
settlementRouteSpecs := settlementReadyAdminRouteSpecs(admin.Routes)
admin.ConfigurationError = logicalModelConfigurationError(productSpec, structuralRouteSpecs)
@@ -532,9 +577,18 @@ func normalizeLogicalDefaults(spec CapabilitySpec, defaults map[string]any) (map
return nil, BadAuthRequest("默认参数存在重复别名:" + name)
}
constraint, ok := spec.Options[name]
if !ok || !matchOptionConstraint(constraint, value) {
if !ok || !matchOptionConstraint(name, constraint, value) {
return nil, BadAuthRequest("默认参数 " + name + " 不在前台模型能力范围内")
}
// `*` 只表示允许任意自定义值,不能作为创作端默认参数发送。
if normalizedScalar(value) == "*" {
for _, candidate := range constraint.Values {
if normalizedScalar(candidate) != "*" {
value = candidate
break
}
}
}
result[name] = value
}
return result, nil
@@ -751,14 +805,11 @@ func validateVariantWithinChannelModel(channelModel model.ChannelModel, spec Cap
}
}
physical := map[string]OptionConstraint{
"size": anyValues(image.Size.Values),
"size": imageSizeOptionConstraint(image.Size),
"quality": anyValues(image.Quality.Values),
"transparentBackground": boolValues(image.TransparentBackground.Supported),
"count": numericRange(1, float64(image.MaxOutputs), 1),
}
if image.Size.AllowCustom {
physical["size"] = OptionConstraint{Values: []any{"*"}}
}
return validateKnownVariantOptions(spec.Options, physical)
case "video":
if profile == nil || profile.Video == nil {
@@ -884,13 +935,13 @@ func optionConstraintCovered(candidate OptionConstraint, name string, routeSpecs
return false
}
for _, routeConstraint := range routeConstraints {
if len(routeConstraint.Values) == 1 && normalizedScalar(routeConstraint.Values[0]) == "*" {
if isWildcardOptionConstraint(routeConstraint) {
return true
}
}
if len(candidate.Values) > 0 {
for _, value := range candidate.Values {
if !optionValueSupported(value, routeConstraints) {
if !optionValueSupported(name, value, routeConstraints) {
return false
}
}
@@ -900,7 +951,7 @@ func optionConstraintCovered(candidate OptionConstraint, name string, routeSpecs
return false
}
if math.Abs(*candidate.Max-*candidate.Min) < 1e-9 {
return optionValueSupported(*candidate.Min, routeConstraints)
return optionValueSupported(name, *candidate.Min, routeConstraints)
}
if candidate.Step == nil {
return continuousOptionRangeCovered(*candidate.Min, *candidate.Max, routeConstraints)
@@ -910,7 +961,7 @@ func optionConstraintCovered(candidate OptionConstraint, name string, routeSpecs
if count <= 10000 {
for index := 0; index < count; index++ {
value := *candidate.Min + float64(index)*step
if !optionValueSupported(value, routeConstraints) {
if !optionValueSupported(name, value, routeConstraints) {
return false
}
}
@@ -933,12 +984,21 @@ func optionConstraintCovered(candidate OptionConstraint, name string, routeSpecs
return false
}
func optionValueSupported(value any, constraints []OptionConstraint) bool {
func optionValueSupported(name string, value any, constraints []OptionConstraint) bool {
for _, constraint := range constraints {
if len(constraint.Values) == 1 && normalizedScalar(constraint.Values[0]) == "*" {
if isWildcardOptionConstraint(constraint) {
return true
}
if matchOptionConstraint(constraint, value) {
if matchOptionConstraint(name, constraint, value) {
return true
}
}
return false
}
func isWildcardOptionConstraint(constraint OptionConstraint) bool {
for _, value := range constraint.Values {
if normalizedScalar(value) == "*" {
return true
}
}

View File

@@ -40,6 +40,31 @@ func TestMatchCapabilityAcceptsWildcardOptionValues(t *testing.T) {
}
}
func TestMatchCapabilityAcceptsWildcardAlongsidePresetOptionValues(t *testing.T) {
intent := ModelRequestIntent{Capability: "image", Options: map[string]any{"size": "2:1"}}
spec := CapabilitySpec{
Version: 1,
Capability: "image",
Options: map[string]OptionConstraint{"size": {Values: []any{"1:1", "9:16", "*"}}},
}
match := MatchCapability(spec, intent)
if !match.Matched {
t.Fatalf("wildcard alongside presets did not match custom value: %#v", match)
}
}
func TestMatchCapabilityTreatsVideoResolutionSuffixAsEquivalent(t *testing.T) {
spec := CapabilitySpec{
Version: 1,
Capability: "video",
Options: map[string]OptionConstraint{"vquality": {Values: []any{"720p"}}},
}
intent := ModelRequestIntent{Capability: "video", Options: map[string]any{"vquality": "720"}}
if match := MatchCapability(spec, intent); !match.Matched {
t.Fatalf("720 and 720p should match the same video resolution: %#v", match)
}
}
func TestValidateProductSpecWithinRoutesRejectsUnsupportedCapabilityValue(t *testing.T) {
routes := []CapabilitySpec{
{

View File

@@ -174,6 +174,15 @@ func defaultImageSizeValues() []string {
}
}
// 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 {
// 文本模型是否支持视觉输入不能从协议或模型名可靠推断,默认关闭,由管理员按真实上游能力开启。
text := &TextCapabilityConfig{References: TextReferenceConfig{PromptMaxChars: 32000}}
@@ -297,11 +306,7 @@ func CapabilitySpecFromModelCapabilityConfig(config *ModelCapabilityConfig, capa
addInputConstraint(spec.Inputs, "mask", 0, 1)
}
if image.Size.Parameter != "none" {
if image.Size.AllowCustom {
spec.Options["size"] = OptionConstraint{Values: []any{"*"}}
} else {
spec.Options["size"] = anyValues(image.Size.Values)
}
spec.Options["size"] = imageSizeOptionConstraint(image.Size)
}
if image.Quality.Supported {
spec.Options["quality"] = anyValues(image.Quality.Values)
@@ -350,6 +355,34 @@ func CapabilitySpecFromModelCapabilityConfig(config *ModelCapabilityConfig, capa
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 addInputConstraint(inputs map[string]InputConstraint, name string, min int, max int) {
if min <= 0 && max <= 0 {
return

View File

@@ -83,6 +83,36 @@ func TestDefaultVideoCapabilityUsesProtocolSpecificResolutionTiers(t *testing.T)
}
}
func TestCapabilitySpecFromModelCapabilityConfigRestoresLegacyWildcardImageSizes(t *testing.T) {
config := &ModelCapabilityConfig{
Version: 1,
Image: &ImageCapabilityConfig{
Size: ImageSizeConfig{Parameter: "size", Values: []string{"*"}, AllowCustom: true},
},
}
spec, err := CapabilitySpecFromModelCapabilityConfig(config, "image")
if err != nil {
t.Fatalf("CapabilitySpecFromModelCapabilityConfig() error = %v", err)
}
constraint, ok := spec.Options["size"]
if !ok {
t.Fatal("size constraint is missing")
}
values := make(map[string]int)
for _, value := range constraint.Values {
values[fmt.Sprint(value)]++
}
for _, value := range legacyImageSizeValues() {
if values[value] != 1 {
t.Fatalf("size constraint missing %q: %v", value, constraint.Values)
}
}
if values["*"] != 1 {
t.Fatalf("size constraint wildcard count = %d, values = %v", values["*"], constraint.Values)
}
}
func TestNormalizeResolutionSupportsCommonAliases(t *testing.T) {
tests := map[string]string{
"1440": "1440p",
@@ -145,8 +175,8 @@ func TestCapabilitySpecFromModelCapabilityConfigProjectsImageSizeOnce(t *testing
if err != nil {
t.Fatalf("CapabilitySpecFromModelCapabilityConfig() error = %v", err)
}
if got := spec.Options["size"].Values; len(got) != 1 || got[0] != "*" {
t.Fatalf("size projection = %#v, want wildcard", got)
if got := spec.Options["size"].Values; len(got) != 3 || got[0] != "1:1" || got[1] != "16:9" || got[2] != "*" {
t.Fatalf("size projection = %#v, want configured values plus wildcard", got)
}
if got := spec.Inputs["image"].Max; got != 3 {
t.Fatalf("image input max = %d, want 3", got)
@@ -169,8 +199,8 @@ func TestCapabilitySpecFromModelCapabilityConfigProjectsCustomImageSizeAsWildcar
if err != nil {
t.Fatalf("CapabilitySpecFromModelCapabilityConfig() error = %v", err)
}
if got := spec.Options["size"].Values; len(got) != 1 || got[0] != "*" {
t.Fatalf("custom size projection = %#v, want wildcard", got)
if got := spec.Options["size"].Values; len(got) != 2 || got[0] != "1:1" || got[1] != "*" {
t.Fatalf("custom size projection = %#v, want configured value plus wildcard", got)
}
}

View File

@@ -241,7 +241,7 @@ func MatchCapability(spec CapabilitySpec, intent ModelRequestIntent) CapabilityM
reasons = append(reasons, "不支持参数 "+capabilityOptionLabel(name))
continue
}
if !matchOptionConstraint(constraint, value) {
if !matchOptionConstraint(name, constraint, value) {
reasons = append(reasons, "参数 "+capabilityOptionLabel(name)+"超出支持范围")
}
}
@@ -294,13 +294,13 @@ func capabilityOptionLabel(name string) string {
}
}
func matchOptionConstraint(constraint OptionConstraint, value any) bool {
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 normalizedScalar(candidate) == normalizedScalar(value) {
if capabilityOptionValuesEqual(name, candidate, value) {
return true
}
}
@@ -323,6 +323,16 @@ func matchOptionConstraint(constraint OptionConstraint, value any) bool {
return true
}
func capabilityOptionValuesEqual(name string, candidate any, value any) bool {
left := normalizedScalar(candidate)
right := normalizedScalar(value)
if canonicalCapabilityOptionName(name) == "vquality" {
left = strings.TrimSuffix(left, "p")
right = strings.TrimSuffix(right, "p")
}
return left == right
}
func normalizedScalar(value any) string {
switch typed := value.(type) {
case string:
@@ -519,11 +529,6 @@ func (s *Service) loadRouteCatalog() (*routeCatalogSnapshot, error) {
log.Printf("logical model omitted from route catalog id=%s: invalid product capability: %v", item.ID, decodeErr)
continue
}
defaults, defaultsErr := decodeLogicalDefaults(graph.Revision.DefaultOptionsJSON, productSpec)
if defaultsErr != nil {
log.Printf("logical model omitted from route catalog id=%s: invalid defaults: %v", item.ID, defaultsErr)
continue
}
variantByID := make(map[string]model.PhysicalCapabilityVariant, len(graph.Variants))
for _, variant := range graph.Variants {
variantByID[variant.ID] = variant
@@ -532,7 +537,7 @@ func (s *Service) loadRouteCatalog() (*routeCatalogSnapshot, error) {
for _, channelModel := range graph.ChannelModels {
channelModelByID[channelModel.ID] = channelModel
}
cached := cachedLogicalModel{Model: item, Revision: *graph.Revision, ProductSpec: productSpec, Defaults: defaults}
cached := cachedLogicalModel{Model: item, Revision: *graph.Revision, ProductSpec: productSpec, Defaults: map[string]any{}}
for _, route := range graph.Routes {
variant, ok := variantByID[route.PhysicalVariantID]
if !ok || !variant.Enabled {
@@ -552,6 +557,18 @@ func (s *Service) loadRouteCatalog() (*routeCatalogSnapshot, error) {
}
cached.Routes = append(cached.Routes, cachedLogicalRoute{Route: route, Variant: variant, VariantSpec: variantSpec, ChannelModel: channelModel})
}
routeSpecs := make([]CapabilitySpec, 0, len(cached.Routes))
for _, route := range cached.Routes {
routeSpecs = append(routeSpecs, route.VariantSpec)
}
productSpec = capabilitySpecWithRoutePresets(productSpec, routeSpecs)
defaults, defaultsErr := decodeLogicalDefaults(graph.Revision.DefaultOptionsJSON, productSpec)
if defaultsErr != nil {
log.Printf("logical model omitted from route catalog id=%s: invalid defaults: %v", item.ID, defaultsErr)
continue
}
cached.ProductSpec = productSpec
cached.Defaults = defaults
snapshot.Models[item.ID] = cached
snapshot.Ordered = append(snapshot.Ordered, item.ID)
}