Files
NianAIGC/backend/internal/billing/defaults.go
T
2026-09-23 15:46:21 +08:00

251 lines
15 KiB
Go
Raw 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 or administrator prices 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()},
evolink25PriceRule("gpt-image-2.5-flare", "https://evolink.ai/gpt-image-2-5"),
evolink25PriceRule("gpt-image-2.5-sunburst", "https://evolink.ai/zh/gpt-image-2-5-sunburst"),
{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},
}},
}
}
func evolink25PriceRule(model, sourceURL string) PriceRule {
dimensions := evolinkDimensions()
dimensions[0] = ParameterDimension{
Key: "quality", Label: "生成质量", BaselineValue: "medium", DefaultValue: "medium",
Tiers: []ParameterTier{
{Value: "low", Label: "快速", StandardFactor: 4.0 / 9, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true},
{Value: "medium", Label: "标准", StandardFactor: 1, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true},
{Value: "high", Label: "精细", StandardFactor: 35.0 / 9, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true},
},
}
// This integration exposes only 1K. Do not inherit Image 2's 2K/4K estimates.
dimensions[1].Tiers = dimensions[1].Tiers[:1]
dimensions[2].Tiers = append(dimensions[2].Tiers, ParameterTier{Value: "other", Match: map[string]any{}, StandardFactor: 1, MarkupMultiplier: DefaultBillingMarkupMultiplier, Enabled: true})
return PriceRule{
ID: "base-evolink-" + model, Provider: "evolink", Capability: "image.generate", ReqKey: model,
Unit: UnitImage, StandardUnitPriceFen: 9, Dimensions: dimensions,
Note: "1K 基础输出估算:low/medium/high 成本为 ¥0.04/¥0.09/¥0.35;输入费用未单独加收,非上游实际 token 账单。",
Source: map[string]any{
"url": sourceURL, "observedAt": "2026-09-23", "currency": "USD", "unitPrice": 0.012, "fxRate": 7.2,
"basis": "1024×1024、无参考图输出费用估算:low 约 $0.0053,medium 约 $0.012,high 约 $0.0474;人民币标准成本向上取整到分。参考图和提示词会影响上游实扣。",
},
}
}
// 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
}