fix(pricing): 图片规格积分 - 按分辨率匹配画布价格
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
"infinite-canvas/backend/internal/repository"
|
||||
@@ -10,6 +13,72 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestImageSpecificationQuoteAgreesWithTaskBilling(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.SystemSetting{}, &model.ChannelModel{}, &model.ChannelModelPriceTier{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channelModel := model.ChannelModel{
|
||||
ID: "image-channel-model", ChannelID: "image-channel", ModelKey: "image-model", Capability: "image",
|
||||
Protocol: model.ChannelInterfaceOpenAIImage, Enabled: true, PriceConfigured: true,
|
||||
}
|
||||
for operationIndex, operation := range []string{"text_to_image", "image_to_image"} {
|
||||
for index, quality := range []string{"1k", "2k", "4k"} {
|
||||
selector := fmt.Sprintf(`{"operation":%q,"quality":%q}`, operation, quality)
|
||||
channelModel.PriceTiers = append(channelModel.PriceTiers, model.ChannelModelPriceTier{
|
||||
ID: operation + "-" + quality, ChannelModelID: channelModel.ID, SelectorKey: selector, SelectorJSON: selector,
|
||||
Resolution: "*", BillingMode: "fixed_request", UnitPriceMicrocredits: int64(index+1+operationIndex*3) * 1_000_000,
|
||||
Enabled: true, PriceConfigured: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
if err := db.Create(&channelModel).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&channelModel.PriceTiers).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spec := CapabilitySpec{Version: 1, Capability: "image", Inputs: map[string]InputConstraint{"image": {Max: 9}}, Options: map[string]OptionConstraint{"quality": {Values: []any{"1k", "2k", "4k"}}}}
|
||||
cached := cachedLogicalModel{
|
||||
Model: model.LogicalModel{ID: "logical-image", PricePolicy: "channel", Capability: "image"},
|
||||
ProductSpec: spec, Defaults: map[string]any{"quality": "2k"},
|
||||
Routes: []cachedLogicalRoute{{Route: model.LogicalModelRoute{ID: "image-route", Enabled: true, Weight: 1}, CapabilitySpec: spec, ChannelModel: channelModel}},
|
||||
}
|
||||
svc := &Service{repo: repository.New(db), routeCatalogTTL: time.Hour, routeCatalog: &routeCatalogSnapshot{LoadedAt: time.Now(), Models: map[string]cachedLogicalModel{"logical-image": cached}}}
|
||||
for _, tier := range channelModel.PriceTiers {
|
||||
selector := model.DecodeSKUSelector(tier.SelectorJSON)
|
||||
imageCount := 0
|
||||
if selector["operation"] == "image_to_image" {
|
||||
imageCount = 1
|
||||
}
|
||||
input := map[string]any{"mode": "image", "referenceImages": make([]any, imageCount), "config": map[string]any{
|
||||
"channelId": channelModel.ChannelID, "model": channelModel.ModelKey, "quality": strings.ToUpper(selector["quality"]),
|
||||
}}
|
||||
intent := ModelRequestIntentFromTaskInput(input, "canvas_image", "image")
|
||||
quote, err := svc.QuoteLogicalModel("logical-image", intent)
|
||||
if err != nil {
|
||||
t.Fatalf("quote %s: %v", tier.ID, err)
|
||||
}
|
||||
order, err := svc.taskBillingOrder("user", &model.Task{ID: "task", Type: "canvas_image", Operation: "image"}, input)
|
||||
if err != nil {
|
||||
t.Fatalf("billing %s: %v", tier.ID, err)
|
||||
}
|
||||
if quote.AmountMicrocredits != tier.UnitPriceMicrocredits || order.AmountMicrocredits != quote.AmountMicrocredits || order.PriceTierID != tier.ID {
|
||||
t.Fatalf("tier %s: quote=%#v order=%#v", tier.ID, quote, order)
|
||||
}
|
||||
}
|
||||
quote, err := svc.QuoteLogicalModel("logical-image", ModelRequestIntent{Capability: "image"})
|
||||
if err != nil || quote.AmountMicrocredits != 2_000_000 {
|
||||
t.Fatalf("default resolution quote=%#v error=%v", quote, err)
|
||||
}
|
||||
if _, err := svc.QuoteLogicalModel("logical-image", ModelRequestIntent{Capability: "image", Options: map[string]any{"quality": "8k"}}); err == nil {
|
||||
t.Fatal("unconfigured resolution must not be quoted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskBillingOrderMatchesSystemImagePriceTierFromRequestedSpec(t *testing.T) {
|
||||
db, err := gorm.Open(sqlite.Open("file:"+newID()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
|
||||
@@ -85,7 +85,14 @@ func ModelRequestIntentFromTaskInput(input map[string]any, taskType string, oper
|
||||
}
|
||||
|
||||
func normalizeModelRequestOption(name string, value any) any {
|
||||
if canonicalCapabilityOptionName(name) != "vquality" {
|
||||
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)
|
||||
@@ -702,7 +709,18 @@ func skuSelectorForIntent(intent ModelRequestIntent) map[string]string {
|
||||
selector["videoSeconds"] = strconv.Itoa(seconds)
|
||||
}
|
||||
case "image":
|
||||
if intent.Inputs["image"] > 0 {
|
||||
selector["operation"] = "image_to_image"
|
||||
} else {
|
||||
selector["operation"] = "text_to_image"
|
||||
}
|
||||
if quality := normalizeImagePriceQuality(fmt.Sprint(intent.Options["quality"]), fmt.Sprint(intent.Options["size"])); quality != "" {
|
||||
selector["quality"] = quality
|
||||
}
|
||||
for _, key := range []string{"quality", "size"} {
|
||||
if key == "quality" && selector["quality"] != "" {
|
||||
continue
|
||||
}
|
||||
if value := strings.ToLower(strings.TrimSpace(fmt.Sprint(intent.Options[key]))); value != "" && value != "auto" && value != "any" {
|
||||
selector[key] = value
|
||||
}
|
||||
@@ -711,6 +729,33 @@ func skuSelectorForIntent(intent ModelRequestIntent) map[string]string {
|
||||
return selector
|
||||
}
|
||||
|
||||
func normalizeImagePriceQuality(rawQuality string, rawSize string) string {
|
||||
quality := strings.ToLower(strings.TrimSpace(rawQuality))
|
||||
if quality != "" && quality != "auto" && quality != "any" {
|
||||
return quality
|
||||
}
|
||||
parts := strings.Split(strings.ToLower(strings.TrimSpace(rawSize)), "x")
|
||||
if len(parts) != 2 {
|
||||
return ""
|
||||
}
|
||||
width, widthErr := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64)
|
||||
height, heightErr := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
|
||||
if widthErr != nil || heightErr != nil || width <= 0 || height <= 0 || width > (1<<32)/height {
|
||||
return ""
|
||||
}
|
||||
pixels := width * height
|
||||
switch {
|
||||
case pixels <= 2_000_000:
|
||||
return "1k"
|
||||
case pixels <= 4_300_000:
|
||||
return "2k"
|
||||
case pixels <= 8_294_400:
|
||||
return "4k"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func skuSelectorForTier(tier model.ChannelModelPriceTier) map[string]string {
|
||||
selector := model.DecodeSKUSelector(tier.SelectorJSON)
|
||||
if len(selector) == 0 {
|
||||
|
||||
@@ -1,11 +1,43 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"infinite-canvas/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestImagePriceTiersMatchResolutionAndActualReferences(t *testing.T) {
|
||||
channelModel := model.ChannelModel{}
|
||||
for _, operation := range []string{"text_to_image", "image_to_image"} {
|
||||
for _, quality := range []string{"1k", "2k", "4k"} {
|
||||
channelModel.PriceTiers = append(channelModel.PriceTiers, model.ChannelModelPriceTier{
|
||||
ID: operation + "-" + quality, SelectorJSON: fmt.Sprintf(`{"operation":%q,"quality":%q}`, operation, quality), Enabled: true, PriceConfigured: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
for _, operation := range []string{"", "image", "text_to_image", "image_to_image"} {
|
||||
for _, quality := range []string{"1K", "2K", "4K"} {
|
||||
for _, imageCount := range []int{0, 1, 3} {
|
||||
intent := ModelRequestIntentFromTaskInput(map[string]any{
|
||||
"mode": "image", "referenceImages": make([]any, imageCount), "config": map[string]any{"quality": quality},
|
||||
}, "canvas_image", operation)
|
||||
wantOperation := "text_to_image"
|
||||
if imageCount > 0 {
|
||||
wantOperation = "image_to_image"
|
||||
}
|
||||
tier := channelModelPriceTierForIntent(channelModel, intent)
|
||||
if tier == nil || tier.ID != wantOperation+"-"+skuSelectorForIntent(intent)["quality"] {
|
||||
t.Fatalf("operation=%q quality=%q imageCount=%d: tier=%#v", operation, quality, imageCount, tier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if tier := channelModelPriceTierForIntent(channelModel, ModelRequestIntent{Capability: "image", Options: map[string]any{"quality": "8k"}}); tier != nil {
|
||||
t.Fatalf("unconfigured resolution matched tier: %#v", tier)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelRequestIntentNormalizesVideoResolution(t *testing.T) {
|
||||
input := map[string]any{
|
||||
"mode": "video",
|
||||
@@ -17,6 +49,36 @@ func TestModelRequestIntentNormalizesVideoResolution(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelRequestIntentNormalizesImageSpecificationValues(t *testing.T) {
|
||||
input := map[string]any{
|
||||
"mode": "image",
|
||||
"config": map[string]any{"quality": "1K", "size": "3:2"},
|
||||
}
|
||||
intent := ModelRequestIntentFromTaskInput(input, "canvas_image", "image")
|
||||
if got := intent.Options["quality"]; got != "1k" {
|
||||
t.Fatalf("quality = %#v, want 1k", got)
|
||||
}
|
||||
if got := intent.Options["size"]; got != "3:2" {
|
||||
t.Fatalf("size = %#v, want 3:2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSKUSelectorInfersImageResolutionFromSizeWhenQualityIsAutomatic(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
size string
|
||||
want string
|
||||
}{
|
||||
{size: "1024x1024", want: "1k"},
|
||||
{size: "2048x2048", want: "2k"},
|
||||
{size: "2880x2880", want: "4k"},
|
||||
} {
|
||||
selector := skuSelectorForIntent(ModelRequestIntent{Capability: "image", Options: map[string]any{"quality": "auto", "size": test.size}})
|
||||
if selector["quality"] != test.want {
|
||||
t.Fatalf("size %s quality = %q, want %q", test.size, selector["quality"], test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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" {
|
||||
|
||||
Reference in New Issue
Block a user