225 lines
14 KiB
Go
225 lines
14 KiB
Go
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.30,2K ¥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: "1–4", Match: map[string]any{"min": 1, "max": 4}, StandardFactor: 1, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true},
|
||
{Value: "5–8", Match: map[string]any{"min": 5, "max": 8}, StandardFactor: 1, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true},
|
||
{Value: "9–16", 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
|
||
}
|