fix(models): 模型规格价格 - 修复主线兼容与参考图 SKU 匹配
This commit is contained in:
1 parent
c5e2312968
commit
f4b62b8e11
9 files changed
+112
-46
No files matched your search
@@ -187,8 +187,10 @@ func (r *Repository) attachChannelModelPriceTiers(items []*model.ChannelModel) e
|
||||
ids = append(ids, item.ID)
|
||||
}
|
||||
var tiers []model.ChannelModelPriceTier
|
||||
if err := r.db.Where("channel_model_id IN ?", ids).Order("selector_key asc, created_at asc").Find(&tiers).Error; err != nil {
|
||||
return err
|
||||
if r.db.Migrator().HasTable(&model.ChannelModelPriceTier{}) {
|
||||
if err := r.db.Where("channel_model_id IN ?", ids).Order("selector_key asc, created_at asc").Find(&tiers).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for index := range tiers {
|
||||
tiers[index].Selector = model.DecodeSKUSelector(tiers[index].SelectorJSON)
|
||||
@@ -199,6 +201,16 @@ func (r *Repository) attachChannelModelPriceTiers(items []*model.ChannelModel) e
|
||||
}
|
||||
for _, item := range items {
|
||||
item.PriceTiers = tiersByModelID[item.ID]
|
||||
// 兼容尚未执行价格档回填的旧数据库;正式迁移会将同一数据持久化为默认档。
|
||||
if len(item.PriceTiers) == 0 && item.PriceConfigured {
|
||||
item.PriceTiers = []model.ChannelModelPriceTier{{
|
||||
ChannelModelID: item.ID, SelectorKey: "{}", SelectorJSON: "{}", Resolution: "*",
|
||||
ProviderModelKey: item.ProviderModelKey, BillingMode: item.BillingMode,
|
||||
UnitPriceMicrocredits: item.UnitPriceMicrocredits, InputTokenPriceMicrocredits: item.InputTokenPriceMicrocredits,
|
||||
OutputTokenPriceMicrocredits: item.OutputTokenPriceMicrocredits, CachedTokenPriceMicrocredits: item.CachedTokenPriceMicrocredits,
|
||||
PriceConfigured: item.PriceConfigured, Enabled: item.Enabled, PriceVersion: item.PriceVersion,
|
||||
}}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -837,7 +837,7 @@ func publicChannel(channel model.ModelChannel, admin bool, channelModels []model
|
||||
if item.Enabled && item.PriceConfigured {
|
||||
capabilityConfig, decodeErr := DecodeModelCapabilityConfig(item.CapabilityConfigJSON)
|
||||
if decodeErr == nil && capabilityConfig != nil {
|
||||
if normalized, normalizeErr := NormalizeModelCapabilityConfig(item.Capability, string(item.Protocol), firstNonEmpty(item.ProviderModelKey, item.ModelKey), channel.APIFormat, capabilityConfig); normalizeErr == nil {
|
||||
if normalized, normalizeErr := NormalizeModelCapabilityConfig(item.Capability, string(item.Protocol), capabilityConfig); normalizeErr == nil {
|
||||
capabilityConfig = normalized
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ type ChannelModelRequest struct {
|
||||
// ChannelModelPriceTierRequest 是系统渠道内某个规格的上游 SKU 与结算价格。
|
||||
// Resolution="*"、VideoSeconds=0 分别表示任意分辨率和任意时长。
|
||||
type ChannelModelPriceTierRequest struct {
|
||||
// Selector 是 SKU 的规范匹配条件。支持 operation、quality、size、vquality、videoSeconds;
|
||||
// Selector 是 SKU 的规范匹配条件。支持 operation、quality、size、vquality、videoSeconds、imageCount;
|
||||
// operation 可区分文生/图生/视频生,避免同一分辨率下错误复用价格。
|
||||
Selector map[string]string `json:"selector"`
|
||||
Resolution string `json:"resolution"`
|
||||
@@ -84,7 +84,7 @@ func (s *Service) AdminChannelModels(actor *model.User, channelID string) ([]mod
|
||||
if err := s.RequireAdmin(actor); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.repo.AdminSystemChannel(channelID); err != nil {
|
||||
if _, err := s.adminSystemChannel(channelID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := s.ensureChannelModels(channelID, true)
|
||||
@@ -99,7 +99,7 @@ func (s *Service) AdminChannelModels(actor *model.User, channelID string) ([]mod
|
||||
if decodeErr != nil || config == nil {
|
||||
continue
|
||||
}
|
||||
normalized, normalizeErr := NormalizeModelCapabilityConfig(items[index].Capability, string(items[index].Protocol), firstNonEmpty(items[index].ProviderModelKey, items[index].ModelKey), channel.APIFormat, config)
|
||||
normalized, normalizeErr := NormalizeModelCapabilityConfig(items[index].Capability, string(items[index].Protocol), config)
|
||||
if normalizeErr != nil || normalized == nil {
|
||||
continue
|
||||
}
|
||||
@@ -203,7 +203,7 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id
|
||||
return nil, BadAuthRequest("该渠道已存在模型 " + modelKey + ",请直接编辑已有模型")
|
||||
}
|
||||
if capability == "text" || capability == "image" || capability == "video" {
|
||||
if _, err := NormalizeModelCapabilityConfig(capability, string(protocol), providerModelKey, channel.APIFormat, req.CapabilityConfig); err != nil {
|
||||
if _, err := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -233,7 +233,7 @@ func (s *Service) SaveAdminChannelModel(actor *model.User, channelID string, id
|
||||
item.Protocol = protocol
|
||||
s.applyChannelModelPriceTierSummary(item, tiers)
|
||||
if capability == "text" || capability == "image" || capability == "video" {
|
||||
capabilityConfig, normalizeErr := NormalizeModelCapabilityConfig(capability, string(protocol), providerModelKey, channel.APIFormat, req.CapabilityConfig)
|
||||
capabilityConfig, normalizeErr := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig)
|
||||
if normalizeErr != nil {
|
||||
return nil, normalizeErr
|
||||
}
|
||||
@@ -403,6 +403,15 @@ func normalizeChannelModelTierSelector(capability string, input ChannelModelPric
|
||||
continue
|
||||
}
|
||||
value = strconv.Itoa(seconds)
|
||||
case "imageCount":
|
||||
count, err := strconv.Atoi(value)
|
||||
if err != nil || count < 0 {
|
||||
return nil, "", 0, BadAuthRequest("参考图片数量必须是非负整数")
|
||||
}
|
||||
if count == 0 {
|
||||
continue
|
||||
}
|
||||
value = strconv.Itoa(count)
|
||||
default:
|
||||
return nil, "", 0, BadAuthRequest("价格档不支持规格字段:" + key)
|
||||
}
|
||||
@@ -433,6 +442,9 @@ func normalizeChannelModelTierSelector(capability string, input ChannelModelPric
|
||||
if _, exists := selector["videoSeconds"]; exists && capability != "video" {
|
||||
return nil, "", 0, BadAuthRequest("只有视频模型可以按时长配置价格档")
|
||||
}
|
||||
if _, exists := selector["imageCount"]; exists && capability != "video" {
|
||||
return nil, "", 0, BadAuthRequest("只有视频模型可以按参考图片数量配置价格档")
|
||||
}
|
||||
resolution := "*"
|
||||
if value := selector["vquality"]; value != "" {
|
||||
resolution = value
|
||||
@@ -522,7 +534,7 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User,
|
||||
return nil, err
|
||||
}
|
||||
if capability == "text" || capability == "image" || capability == "video" {
|
||||
if _, err := NormalizeModelCapabilityConfig(capability, string(protocol), providerModelKey, channel.APIFormat, req.CapabilityConfig); err != nil {
|
||||
if _, err := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -552,7 +564,7 @@ func (s *Service) TestAdminChannelModel(ctx context.Context, actor *model.User,
|
||||
imageSize, imageQuality := "", ""
|
||||
var imageProfile *ImageCapabilityConfig
|
||||
if capability == "image" {
|
||||
profile, normalizeErr := NormalizeModelCapabilityConfig(capability, string(protocol), providerModelKey, channel.APIFormat, req.CapabilityConfig)
|
||||
profile, normalizeErr := NormalizeModelCapabilityConfig(capability, string(protocol), req.CapabilityConfig)
|
||||
if normalizeErr != nil {
|
||||
return nil, normalizeErr
|
||||
}
|
||||
|
||||
@@ -579,6 +579,14 @@ func (s *Service) ValidateTaskCapability(input map[string]any) error {
|
||||
return validateVideoTask(profile.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 {
|
||||
|
||||
@@ -684,6 +684,9 @@ func skuSelectorForIntent(intent ModelRequestIntent) map[string]string {
|
||||
}
|
||||
switch normalizeCapability(intent.Capability) {
|
||||
case "video":
|
||||
if count := intent.Inputs["image"]; count > 0 {
|
||||
selector["imageCount"] = strconv.Itoa(count)
|
||||
}
|
||||
if value := normalizeChannelModelTierResolution(fmt.Sprint(intent.Options["vquality"])); value != "*" {
|
||||
selector["vquality"] = value
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package service
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestModelRequestIntentNormalizesVideoResolution(t *testing.T) {
|
||||
input := map[string]any{
|
||||
@@ -12,3 +16,18 @@ func TestModelRequestIntentNormalizesVideoResolution(t *testing.T) {
|
||||
t.Fatalf("vquality = %#v, want 480p", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSKUSelectorIncludesVideoReferenceImageCount(t *testing.T) {
|
||||
selector := skuSelectorForIntent(ModelRequestIntent{Capability: "video", Inputs: map[string]int{"image": 5}, Options: map[string]any{"vquality": "720p"}})
|
||||
if selector["imageCount"] != "5" || selector["vquality"] != "720p" {
|
||||
t.Fatalf("selector = %#v", selector)
|
||||
}
|
||||
modelWithTiers := model.ChannelModel{PriceTiers: []model.ChannelModelPriceTier{
|
||||
{SelectorJSON: `{"vquality":"720p","imageCount":"5"}`, Enabled: true, PriceConfigured: true},
|
||||
{SelectorJSON: `{"vquality":"720p","imageCount":"9"}`, Enabled: true, PriceConfigured: true},
|
||||
}}
|
||||
matched := channelModelPriceTierForIntent(modelWithTiers, ModelRequestIntent{Capability: "video", Inputs: map[string]int{"image": 5}, Options: map[string]any{"vquality": "720p"}})
|
||||
if matched == nil || matched.SelectorJSON != `{"vquality":"720p","imageCount":"5"}` {
|
||||
t.Fatalf("matched tier = %#v", matched)
|
||||
}
|
||||
}
|
||||
@@ -825,14 +825,19 @@ func (s *Service) resolveProviderConfig(config providerConfig) (providerConfig,
|
||||
modelKey = requestedModel
|
||||
}
|
||||
if modelKey == "" {
|
||||
models := channelModelNames(*channel)
|
||||
if len(models) == 0 {
|
||||
return providerConfig{}, errors.New("系统渠道未配置可用模型")
|
||||
channelModels, listErr := s.repo.ChannelModels(channel.ID, false)
|
||||
if listErr != nil {
|
||||
return providerConfig{}, listErr
|
||||
}
|
||||
if len(channelModels) > 0 {
|
||||
modelKey = channelModels[0].ModelKey
|
||||
} else {
|
||||
models := channelModelNames(*channel)
|
||||
if len(models) == 0 {
|
||||
return providerConfig{}, errors.New("系统渠道未配置可用模型")
|
||||
}
|
||||
modelKey = models[0]
|
||||
}
|
||||
modelKey = models[0]
|
||||
}
|
||||
if !stringInSlice(modelKey, channelModelNames(*channel)) {
|
||||
return providerConfig{}, errors.New("当前系统渠道未授权该模型")
|
||||
}
|
||||
if _, err := s.validateChannelOutboundURL(channel.BaseURL, channel.AllowLocalChannel, false); err != nil {
|
||||
return providerConfig{}, err
|
||||
|
||||
Reference in new issue
Block a user