Files
NianAIGC/backend/internal/billing/defaults.go
2026-09-11 15:33:18 +08:00

225 lines
14 KiB
Go
Raw Permalink 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.

package billing
import (
"context"
"fmt"
"math"
"strings"
)
const (
DefaultBillingMarkupMultiplier = 1.2
BillingCatalogObservedAt = "2026-08-21"
)
// PriceRuleSeeder is an optional store capability. Existing Store
// implementations remain source-compatible and seed-capable stores can make
// the official base catalog durable before quotes are read.
type PriceRuleSeeder interface {
SeedBillingPriceRules(context.Context, []PriceRule) error
}
func DefaultBillingPriceRules() []PriceRule {
rules := []PriceRule{
{ID: "base-volcengine-jimeng-seedream46", Provider: "volcengine-visual", Capability: "image.generate", ReqKey: "jimeng_seedream46_cvtob", Unit: UnitImage, StandardUnitPriceFen: 20, Note: "即梦4.6公开资源包基准。", Source: catalogSource("https://www.volcengine.com/activity/jimeng")},
{ID: "base-evolink-gpt-image-2", Provider: "evolink", Capability: "image.generate", ReqKey: "gpt-image-2", Unit: UnitImage, StandardUnitPriceFen: 34, Note: "medium / 1K / 1:1 / 无参考图基准。", Source: catalogSource("https://evolink.ai/zh/gpt-image-2"), Dimensions: evolinkDimensions()},
{ID: "base-bailian-wan27-image-pro", Provider: "bailian", Capability: "image.generate", ReqKey: "wan2.7-image-pro", Unit: UnitImage, StandardUnitPriceFen: 50, Source: catalogSource("https://help.aliyun.com/zh/model-studio/wan2-7-image-pro")},
{ID: "base-seedream-5-0-pro", Provider: "seedream", Capability: "image.generate", ReqKey: Seedream50ProModel, Unit: UnitImage, StandardUnitPriceFen: 30, Note: "基础生图1K/1.5K ¥0.302K ¥0.60;首张输入图免费,额外输入图 ¥0.02/张。", Source: catalogSource("https://docs.volcengine.com/docs/82379/1544106?lang=zh"), Dimensions: seedreamDimensions()},
{ID: "base-seedream-5-0-pro-layers", Provider: "seedream", Capability: "image.generate", ReqKey: Seedream50ProModel, Unit: UnitImage, StandardUnitPriceFen: 15, Conditions: Conditions{"layerDecomposition": true}, Priority: 10, Note: "图层拆分≤261万像素 ¥0.15/张,>261万像素 ¥0.30/张最多预留底图加16个图层完成后按每张实际像素结算。", Source: catalogSource("https://docs.volcengine.com/docs/82379/1544106?lang=zh"), Dimensions: seedreamDimensions()},
{ID: "base-bailian-wan27-i2v-720p", Provider: "bailian", Capability: "video.generate", ReqKey: "wan2.7-i2v-2026-04-25", VariantKey: "resolution=720p", Unit: UnitVideoSecond, StandardUnitPriceFen: 60, Source: catalogSource("https://help.aliyun.com/zh/model-studio/wan2-7-i2v")},
{ID: "base-bailian-wan27-i2v-1080p", Provider: "bailian", Capability: "video.generate", ReqKey: "wan2.7-i2v-2026-04-25", VariantKey: "resolution=1080p", Unit: UnitVideoSecond, StandardUnitPriceFen: 100, Source: catalogSource("https://help.aliyun.com/zh/model-studio/wan2-7-i2v")},
{ID: "base-seedance-2-0-480p", Provider: "seedance", Capability: "video.generate", ReqKey: "doubao-seedance-2-0-260128", VariantKey: "resolution=480p", Unit: UnitVideoSecond, StandardUnitPriceFen: 46, Source: catalogSource("https://www.volcengine.com/docs/82379/1544106?lang=zh")},
{ID: "base-seedance-2-0-720p", Provider: "seedance", Capability: "video.generate", ReqKey: "doubao-seedance-2-0-260128", VariantKey: "resolution=720p", Unit: UnitVideoSecond, StandardUnitPriceFen: 99, Source: catalogSource("https://www.volcengine.com/docs/82379/1544106?lang=zh")},
{ID: "base-seedance-2-0-1080p", Provider: "seedance", Capability: "video.generate", ReqKey: "doubao-seedance-2-0-260128", VariantKey: "resolution=1080p", Unit: UnitVideoSecond, StandardUnitPriceFen: 248, Source: catalogSource("https://www.volcengine.com/docs/82379/1544106?lang=zh")},
{ID: "base-seedance-2-0-4k", Provider: "seedance", Capability: "video.generate", ReqKey: "doubao-seedance-2-0-260128", VariantKey: "resolution=4k", Unit: UnitVideoSecond, StandardUnitPriceFen: 505, Source: catalogSource("https://www.volcengine.com/docs/82379/1544106?lang=zh")},
{ID: "base-seedance-2-5-480p", Provider: "seedance", Capability: "video.generate", ReqKey: "doubao-seedance-2-5-260628", VariantKey: "resolution=480p", Unit: UnitVideoSecond, StandardUnitPriceFen: 67, Source: catalogSource("https://www.volcengine.com/docs/82379/1544106?lang=zh#sd25_price")},
{ID: "base-seedance-2-5-720p", Provider: "seedance", Capability: "video.generate", ReqKey: "doubao-seedance-2-5-260628", VariantKey: "resolution=720p", Unit: UnitVideoSecond, StandardUnitPriceFen: 151, Source: catalogSource("https://www.volcengine.com/docs/82379/1544106?lang=zh#sd25_price")},
{ID: "base-seedance-2-5-1080p", Provider: "seedance", Capability: "video.generate", ReqKey: "doubao-seedance-2-5-260628", VariantKey: "resolution=1080p", Unit: UnitVideoSecond, StandardUnitPriceFen: 374, Source: catalogSource("https://www.volcengine.com/docs/82379/1544106?lang=zh#sd25_price")},
{ID: "base-minimax-h3-768p", Provider: "minimax", Capability: "video.generate", ReqKey: MinimaxH3Model, VariantKey: "resolution=768p", Unit: UnitVideoSecond, StandardUnitPriceFen: 50, Note: "MiniMax H3 768P 输出 ¥0.50/秒;首五张输入图免费。", Source: minimaxCatalogSource()},
{ID: "base-minimax-h3-2k", Provider: "minimax", Capability: "video.generate", ReqKey: MinimaxH3Model, VariantKey: "resolution=2k", Unit: UnitVideoSecond, StandardUnitPriceFen: 80, Note: "MiniMax H3 2K 输出 ¥0.80/秒;首五张输入图免费。", Source: minimaxCatalogSource()},
}
for i := range rules {
rules[i].Enabled = true
rules[i].MarkupMultiplier = DefaultBillingMarkupMultiplier
}
return rules
}
func minimaxCatalogSource() map[string]any {
return map[string]any{"url": "https://platform.minimaxi.com/docs/guides/pricing-paygo#视频", "observedAt": "2026-09-04"}
}
func seedreamDimensions() []ParameterDimension {
return []ParameterDimension{{
Key: "size", Label: "分辨率", BaselineValue: "1.5K", DefaultValue: "1.5K",
Tiers: []ParameterTier{
{Value: "auto", Label: "自动(图层拆分)", StandardFactor: 2, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true},
{Value: "1K", Label: "1K", StandardFactor: 1, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true},
{Value: "1.5K", Label: "1.5K", StandardFactor: 1, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true},
{Value: "2K", Label: "2K", StandardFactor: 2, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true},
},
}}
}
func catalogSource(url string) map[string]any {
return map[string]any{"url": url, "observedAt": BillingCatalogObservedAt}
}
func evolinkDimensions() []ParameterDimension {
tiers := func(values ...any) []ParameterTier {
out := make([]ParameterTier, 0, len(values))
for index := 0; index < len(values); index += 2 {
out = append(out, ParameterTier{Value: values[index], StandardFactor: values[index+1].(float64), MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true})
}
return out
}
return []ParameterDimension{
{Key: "quality", BaselineValue: "medium", DefaultValue: "medium", Tiers: tiers("low", .11, "medium", 1.0, "high", 4.0)},
{Key: "resolution", BaselineValue: "1K", DefaultValue: "1K", Tiers: tiers("1K", 1.0, "2K", 4.0, "4K", 8.0)},
{Key: "aspectRatio", BaselineValue: "1:1", DefaultValue: "1:1", Tiers: tiers("1:1", 1.0, "4:3", 1.0, "3:4", 1.0, "16:9", 1.0, "9:16", 1.0, "70:99", 1.0, "99:70", 1.0)},
{Key: "referenceImageCount", BaselineValue: 0, DefaultValue: 0, Tiers: []ParameterTier{
{Value: 0, StandardFactor: 1, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true},
{Value: "14", Match: map[string]any{"min": 1, "max": 4}, StandardFactor: 1, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true},
{Value: "58", Match: map[string]any{"min": 5, "max": 8}, StandardFactor: 1, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true},
{Value: "916", Match: map[string]any{"min": 9, "max": 16}, StandardFactor: 1, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true},
}},
}
}
// NormalizeBillingParameters derives quote inputs from the persisted generation
// payload. Explicit parameters are retained only as a fallback for values the
// payload does not carry.
func NormalizeBillingParameters(payload map[string]any, fallback Parameters) Parameters {
out := Parameters{}
for key, value := range fallback {
out[key] = value
}
settings := record(payload["settings"])
providerPayload := record(payload["providerPayload"])
providerParameters := record(providerPayload["parameters"])
input := record(payload["input"])
inputSettings := record(input["settings"])
setText(out, "model", first(providerPayload["model"], input["model"], settings["model"]))
setText(out, "resolution", first(settings["resolution"], providerParameters["resolution"], providerPayload["resolution"], inputSettings["resolution"], input["resolution"]))
setText(out, "quality", first(input["quality"], settings["quality"], providerPayload["quality"], providerParameters["quality"]))
setNumber(out, "duration", first(settings["duration"], providerParameters["duration"], providerPayload["duration"], inputSettings["duration"], input["duration"]), false)
setNumber(out, "imageCount", first(settings["imageCount"], providerPayload["n"], providerParameters["n"], input["n"], input["imageCount"]), true)
setNumber(out, "scale", first(input["scale"], settings["scale"]), false)
setText(out, "outputFormat", first(settings["outputFormat"], providerPayload["output_format"], inputSettings["outputFormat"], input["outputFormat"]))
setText(out, "optimizeMode", first(settings["optimizeMode"], record(providerPayload["optimize_prompt_options"])["mode"], inputSettings["optimizeMode"], input["optimizeMode"]))
setText(out, "creationMode", first(settings["creationMode"], inputSettings["creationMode"], input["creationMode"]))
if value, ok := boolean(first(settings["layerDecomposition"], providerPayload["layer_decomposition"], inputSettings["layerDecomposition"], input["layerDecomposition"])); ok {
out["layerDecomposition"] = value
}
if strings.EqualFold(fmt.Sprint(out["creationMode"]), "layers") {
out["layerDecomposition"] = true
}
if value, _ := out["layerDecomposition"].(bool); value {
// Seedream can return one base image plus at most sixteen layers. Reserve
// the documented maximum, then settle each returned image by pixel tier.
out["imageCount"] = float64(17)
}
width, widthOK := finiteNumber(first(providerPayload["width"], input["width"], settings["width"], payload["width"], out["width"]))
height, heightOK := finiteNumber(first(providerPayload["height"], input["height"], settings["height"], payload["height"], out["height"]))
if size := normalizedText(first(settings["size"], providerPayload["size"], providerParameters["size"], inputSettings["size"], input["size"])); size != "" {
out["size"] = strings.ReplaceAll(size, "×", "*")
} else if widthOK && heightOK {
out["size"] = fmt.Sprintf("%g*%g", width, height)
}
if ratio := normalizedText(first(settings["ratio"], settings["aspectRatio"], providerPayload["ratio"], providerParameters["ratio"], inputSettings["ratio"], input["ratio"], input["aspectRatio"])); ratio != "" {
out["aspectRatio"] = ratio
} else if widthOK && heightOK && height > 0 {
out["aspectRatio"] = aspectRatio(width, height)
}
if count := referenceCount(payload, input, providerPayload); count > 0 {
out["referenceImageCount"] = float64(count)
}
if value, ok := boolean(first(settings["generate_audio"], settings["generateAudio"], providerParameters["generate_audio"], providerParameters["generateAudio"], input["generate_audio"], input["generateAudio"])); ok {
out["generateAudio"] = value
}
return out
}
func record(value any) map[string]any { result, _ := value.(map[string]any); return result }
func first(values ...any) any {
for _, value := range values {
if value != nil {
return value
}
}
return nil
}
func normalizedText(value any) string {
if value == nil {
return ""
}
return strings.ToLower(strings.TrimSpace(fmt.Sprint(value)))
}
func setText(out Parameters, key string, value any) {
if value != nil {
if text := normalizedText(value); text != "" {
out[key] = text
}
}
}
func setNumber(out Parameters, key string, value any, ceil bool) {
if number, ok := finiteNumber(value); ok && (!ceil || number > 0) {
if ceil {
number = math.Ceil(number)
}
out[key] = number
}
}
func finiteNumber(value any) (float64, bool) { return number(value) }
func boolean(value any) (bool, bool) {
switch v := value.(type) {
case bool:
return v, true
case string:
if strings.EqualFold(strings.TrimSpace(v), "true") {
return true, true
}
if strings.EqualFold(strings.TrimSpace(v), "false") {
return false, true
}
}
return false, false
}
func aspectRatio(width, height float64) string {
ratio := width / height
for _, item := range []struct {
value float64
label string
}{{1, "1:1"}, {4.0 / 3, "4:3"}, {16.0 / 9, "16:9"}, {3.0 / 4, "3:4"}, {9.0 / 16, "9:16"}, {21.0 / 9, "21:9"}, {70.0 / 99, "70:99"}, {99.0 / 70, "99:70"}} {
if math.Abs(ratio-item.value) < .02 {
return item.label
}
}
return fmt.Sprintf("%g:%g", width, height)
}
func referenceCount(payload, input, providerPayload map[string]any) int {
for _, value := range []any{payload["imageUrls"], payload["inputUrls"], input["imageUrls"], providerPayload["image_urls"], providerPayload["imageUrls"]} {
switch items := value.(type) {
case []any:
if len(items) > 0 {
return len(items)
}
case []string:
if len(items) > 0 {
return len(items)
}
}
}
count := 0
for _, material := range billingMaterials(payload) {
materialType := strings.ToLower(strings.TrimSpace(fmt.Sprint(material["type"])))
if materialType == "" || materialType == "image" {
count++
}
}
return count
}