fix(billing): 封住发布前计费竞态
ci / verify (pull_request) Failing after 8s
ci / verify (pull_request) Failing after 8s
阻止上游提交状态不明的任务被租约接管后重复执行,并为人工复核保留可操作的结算记录。 将生产提交绑定到当前估价签名,统一复用预处理快照,并补强规则形状、定点溢出与历史规则兼容校验。 已通过 PostgreSQL 16 集成测试、Go 全量测试与静态检查、前端测试与构建、OpenAPI、依赖审计、镜像、迁移、流水线和 SemVer 门禁。
This commit is contained in:
@@ -20,9 +20,11 @@ import (
|
||||
const (
|
||||
pricingVersionV2 = "effective-pricing-v2"
|
||||
fixedScale = int64(1_000_000_000)
|
||||
maxFixedAmount = fixedAmount(math.MaxInt64)
|
||||
)
|
||||
|
||||
var ErrPricingUnavailable = errors.New("pricing unavailable")
|
||||
var errFixedAmountOverflow = errors.New("fixed amount overflow")
|
||||
|
||||
type PricingUnavailableError struct {
|
||||
Reason string
|
||||
@@ -129,11 +131,11 @@ func fixedAmountFromAny(value any) (fixedAmount, error) {
|
||||
case json.Number:
|
||||
return parseFixedAmount(typed.String())
|
||||
case int:
|
||||
return fixedAmount(int64(typed) * fixedScale), nil
|
||||
return parseFixedAmount(strconv.FormatInt(int64(typed), 10))
|
||||
case int64:
|
||||
return fixedAmount(typed * fixedScale), nil
|
||||
return parseFixedAmount(strconv.FormatInt(typed, 10))
|
||||
case int32:
|
||||
return fixedAmount(int64(typed) * fixedScale), nil
|
||||
return parseFixedAmount(strconv.FormatInt(int64(typed), 10))
|
||||
case float64:
|
||||
if math.IsNaN(typed) || math.IsInf(typed, 0) {
|
||||
return 0, fmt.Errorf("non-finite decimal")
|
||||
@@ -159,26 +161,37 @@ func (a fixedAmount) String() string {
|
||||
func (a fixedAmount) Float64() float64 { return float64(a) / float64(fixedScale) }
|
||||
func (a fixedAmount) IsZero() bool { return a == 0 }
|
||||
|
||||
func (a fixedAmount) Add(other fixedAmount) fixedAmount { return a + other }
|
||||
|
||||
func (a fixedAmount) MulInt(multiplier int) fixedAmount {
|
||||
return fixedAmount(int64(a) * int64(multiplier))
|
||||
func addFixedAmounts(left fixedAmount, right fixedAmount) (fixedAmount, error) {
|
||||
value := new(big.Int).Add(big.NewInt(int64(left)), big.NewInt(int64(right)))
|
||||
return fixedAmountFromBigInt(value)
|
||||
}
|
||||
|
||||
func (a fixedAmount) Mul(other fixedAmount) fixedAmount {
|
||||
product := new(big.Int).Mul(big.NewInt(int64(a)), big.NewInt(int64(other)))
|
||||
return fixedAmount(roundBigIntRatio(product, big.NewInt(fixedScale)))
|
||||
func multiplyFixedAmountByInt(amount fixedAmount, multiplier int) (fixedAmount, error) {
|
||||
value := new(big.Int).Mul(big.NewInt(int64(amount)), big.NewInt(int64(multiplier)))
|
||||
return fixedAmountFromBigInt(value)
|
||||
}
|
||||
|
||||
func (a fixedAmount) MulRatio(numerator int, denominator int) fixedAmount {
|
||||
func multiplyFixedAmounts(left fixedAmount, right fixedAmount) (fixedAmount, error) {
|
||||
product := new(big.Int).Mul(big.NewInt(int64(left)), big.NewInt(int64(right)))
|
||||
return fixedAmountFromBigInt(roundBigIntRatio(product, big.NewInt(fixedScale)))
|
||||
}
|
||||
|
||||
func multiplyFixedAmountRatio(amount fixedAmount, numerator int, denominator int) (fixedAmount, error) {
|
||||
if denominator == 0 {
|
||||
return 0
|
||||
return 0, fmt.Errorf("division by zero")
|
||||
}
|
||||
product := new(big.Int).Mul(big.NewInt(int64(a)), big.NewInt(int64(numerator)))
|
||||
return fixedAmount(roundBigIntRatio(product, big.NewInt(int64(denominator))))
|
||||
product := new(big.Int).Mul(big.NewInt(int64(amount)), big.NewInt(int64(numerator)))
|
||||
return fixedAmountFromBigInt(roundBigIntRatio(product, big.NewInt(int64(denominator))))
|
||||
}
|
||||
|
||||
func roundBigIntRatio(numerator *big.Int, denominator *big.Int) int64 {
|
||||
func fixedAmountFromBigInt(value *big.Int) (fixedAmount, error) {
|
||||
if value == nil || !value.IsInt64() {
|
||||
return 0, errFixedAmountOverflow
|
||||
}
|
||||
return fixedAmount(value.Int64()), nil
|
||||
}
|
||||
|
||||
func roundBigIntRatio(numerator *big.Int, denominator *big.Int) *big.Int {
|
||||
negative := numerator.Sign() < 0
|
||||
absolute := new(big.Int).Abs(new(big.Int).Set(numerator))
|
||||
quotient, remainder := new(big.Int), new(big.Int)
|
||||
@@ -189,7 +202,7 @@ func roundBigIntRatio(numerator *big.Int, denominator *big.Int) int64 {
|
||||
if negative {
|
||||
quotient.Neg(quotient)
|
||||
}
|
||||
return quotient.Int64()
|
||||
return quotient
|
||||
}
|
||||
|
||||
type resolvedPricing struct {
|
||||
@@ -248,6 +261,105 @@ func (pricing resolvedPricing) price(resource string, keys ...string) (fixedAmou
|
||||
return 0, false, nil
|
||||
}
|
||||
|
||||
func (pricing resolvedPricing) weight(resource string, key string, name string) (fixedAmount, error) {
|
||||
if strings.TrimSpace(name) == "" {
|
||||
return fixedAmount(fixedScale), nil
|
||||
}
|
||||
keys := weightKeyAliases(key)
|
||||
names := weightValueAliases(key, name)
|
||||
value, found := pricingWeightValue(pricing.Config, resource, keys, names)
|
||||
if !found {
|
||||
return fixedAmount(fixedScale), nil
|
||||
}
|
||||
weight, err := fixedAmountFromAny(value)
|
||||
if err != nil || weight <= 0 {
|
||||
reason := fmt.Sprintf("invalid %s weight for %s", key, name)
|
||||
if err != nil {
|
||||
reason += ": " + err.Error()
|
||||
}
|
||||
return 0, &PricingUnavailableError{Reason: reason, ResourceType: resource, RuleSetID: pricing.RuleSetID}
|
||||
}
|
||||
return weight, nil
|
||||
}
|
||||
|
||||
func (pricing resolvedPricing) calculate(resource string, base fixedAmount, integerFactors []int, fixedFactors ...fixedAmount) (fixedAmount, error) {
|
||||
amount := base
|
||||
var err error
|
||||
for _, factor := range integerFactors {
|
||||
amount, err = multiplyFixedAmountByInt(amount, factor)
|
||||
if err != nil {
|
||||
return 0, pricing.calculationError(resource, err)
|
||||
}
|
||||
}
|
||||
for _, factor := range fixedFactors {
|
||||
amount, err = multiplyFixedAmounts(amount, factor)
|
||||
if err != nil {
|
||||
return 0, pricing.calculationError(resource, err)
|
||||
}
|
||||
}
|
||||
return amount, nil
|
||||
}
|
||||
|
||||
func (pricing resolvedPricing) calculateRatio(resource string, base fixedAmount, numerator int, denominator int, fixedFactors ...fixedAmount) (fixedAmount, error) {
|
||||
amount, err := multiplyFixedAmountRatio(base, numerator, denominator)
|
||||
if err != nil {
|
||||
return 0, pricing.calculationError(resource, err)
|
||||
}
|
||||
return pricing.calculate(resource, amount, nil, fixedFactors...)
|
||||
}
|
||||
|
||||
func (pricing resolvedPricing) add(resource string, left fixedAmount, right fixedAmount) (fixedAmount, error) {
|
||||
amount, err := addFixedAmounts(left, right)
|
||||
if err != nil {
|
||||
return 0, pricing.calculationError(resource, err)
|
||||
}
|
||||
return amount, nil
|
||||
}
|
||||
|
||||
func (pricing resolvedPricing) calculationError(resource string, err error) error {
|
||||
return &PricingUnavailableError{Reason: "pricing calculation failed: " + err.Error(), ResourceType: resource, RuleSetID: pricing.RuleSetID}
|
||||
}
|
||||
|
||||
func pricingWeightValue(config map[string]any, resource string, keys []string, names []string) (any, bool) {
|
||||
if value, ok := pricingWeightValueFromConfig(config, keys, names); ok {
|
||||
return value, true
|
||||
}
|
||||
resourceConfig, _ := config[resource].(map[string]any)
|
||||
if len(resourceConfig) == 0 && resource == "image_edit" {
|
||||
resourceConfig, _ = config["image"].(map[string]any)
|
||||
}
|
||||
return pricingWeightValueFromConfig(resourceConfig, keys, names)
|
||||
}
|
||||
|
||||
func pricingWeightValueFromConfig(config map[string]any, keys []string, names []string) (any, bool) {
|
||||
if len(config) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
for _, key := range keys {
|
||||
weights, _ := config[key].(map[string]any)
|
||||
for _, name := range names {
|
||||
if value, ok := weights[name]; ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
}
|
||||
dynamic, _ := config["dynamicWeight"].(map[string]any)
|
||||
for _, name := range names {
|
||||
if value, ok := dynamic[name]; ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
for _, key := range keys {
|
||||
weights, _ := dynamic[key].(map[string]any)
|
||||
for _, name := range names {
|
||||
if value, ok := weights[name]; ok {
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
type candidateEstimate struct {
|
||||
Items []any
|
||||
Amount fixedAmount
|
||||
@@ -474,8 +586,14 @@ func (s *Service) billingsWithResolvedPricingV2(
|
||||
items := make([]any, 0, 3)
|
||||
total := fixedAmount(0)
|
||||
if uncachedInputTokens > 0 || cachedInputTokens == 0 {
|
||||
amount := inputPrice.MulRatio(uncachedInputTokens, 1000).Mul(discount)
|
||||
total = total.Add(amount)
|
||||
amount, calculationErr := pricing.calculateRatio("text_input", inputPrice, uncachedInputTokens, 1000, discount)
|
||||
if calculationErr != nil {
|
||||
return nil, 0, resolvedPricing{}, calculationErr
|
||||
}
|
||||
total, calculationErr = pricing.add("text", total, amount)
|
||||
if calculationErr != nil {
|
||||
return nil, 0, resolvedPricing{}, calculationErr
|
||||
}
|
||||
items = append(items, buildLine("text_input", "1k_tokens", uncachedInputTokens, amount, map[string]any{
|
||||
"inputTokens": inputTokens, "uncachedInputTokens": uncachedInputTokens,
|
||||
"cachedInputTokens": cachedInputTokens, "pricePer1k": inputPrice.Float64(),
|
||||
@@ -486,8 +604,14 @@ func (s *Service) billingsWithResolvedPricingV2(
|
||||
if priceErr != nil {
|
||||
return nil, 0, resolvedPricing{}, priceErr
|
||||
}
|
||||
amount := cachedPrice.MulRatio(cachedInputTokens, 1000).Mul(discount)
|
||||
total = total.Add(amount)
|
||||
amount, calculationErr := pricing.calculateRatio("text_cached_input", cachedPrice, cachedInputTokens, 1000, discount)
|
||||
if calculationErr != nil {
|
||||
return nil, 0, resolvedPricing{}, calculationErr
|
||||
}
|
||||
total, calculationErr = pricing.add("text", total, amount)
|
||||
if calculationErr != nil {
|
||||
return nil, 0, resolvedPricing{}, calculationErr
|
||||
}
|
||||
items = append(items, buildLine("text_cached_input", "1k_tokens", cachedInputTokens, amount, map[string]any{
|
||||
"inputTokens": inputTokens, "uncachedInputTokens": uncachedInputTokens,
|
||||
"cachedInputTokens": cachedInputTokens, "pricePer1k": cachedPrice.Float64(),
|
||||
@@ -498,8 +622,14 @@ func (s *Service) billingsWithResolvedPricingV2(
|
||||
if priceErr != nil {
|
||||
return nil, 0, resolvedPricing{}, priceErr
|
||||
}
|
||||
amount := outputPrice.MulRatio(outputTokens, 1000).Mul(discount)
|
||||
total = total.Add(amount)
|
||||
amount, calculationErr := pricing.calculateRatio("text_output", outputPrice, outputTokens, 1000, discount)
|
||||
if calculationErr != nil {
|
||||
return nil, 0, resolvedPricing{}, calculationErr
|
||||
}
|
||||
total, calculationErr = pricing.add("text", total, amount)
|
||||
if calculationErr != nil {
|
||||
return nil, 0, resolvedPricing{}, calculationErr
|
||||
}
|
||||
items = append(items, buildLine("text_output", "1k_tokens", outputTokens, amount, map[string]any{"pricePer1k": outputPrice.Float64()}))
|
||||
}
|
||||
return items, total, pricing, nil
|
||||
@@ -524,12 +654,26 @@ func (s *Service) billingsWithResolvedPricingV2(
|
||||
if priceErr != nil {
|
||||
return nil, 0, resolvedPricing{}, priceErr
|
||||
}
|
||||
amount := price.MulInt(count).MulInt(durationUnits).
|
||||
Mul(fixedWeight(resourceWeight(pricing.Config, resource, "resolutionWeights", firstNonEmptyString(stringFromMap(body, "resolution"), stringFromMap(body, "size"))))).
|
||||
Mul(fixedWeight(resourceWeight(pricing.Config, resource, "audioWeights", boolWeightKey(audioEnabled)))).
|
||||
Mul(fixedWeight(resourceWeight(pricing.Config, resource, "referenceVideoWeights", boolWeightKey(requestHasReferenceVideo(body))))).
|
||||
Mul(fixedWeight(resourceWeight(pricing.Config, resource, "voiceSpecifiedWeights", boolWeightKey(requestHasVoiceID(body, audioEnabled))))).
|
||||
Mul(discount)
|
||||
resolutionWeight, weightErr := pricing.weight(resource, "resolutionWeights", firstNonEmptyString(stringFromMap(body, "resolution"), stringFromMap(body, "size")))
|
||||
if weightErr != nil {
|
||||
return nil, 0, resolvedPricing{}, weightErr
|
||||
}
|
||||
audioWeight, weightErr := pricing.weight(resource, "audioWeights", boolWeightKey(audioEnabled))
|
||||
if weightErr != nil {
|
||||
return nil, 0, resolvedPricing{}, weightErr
|
||||
}
|
||||
referenceVideoWeight, weightErr := pricing.weight(resource, "referenceVideoWeights", boolWeightKey(requestHasReferenceVideo(body)))
|
||||
if weightErr != nil {
|
||||
return nil, 0, resolvedPricing{}, weightErr
|
||||
}
|
||||
voiceWeight, weightErr := pricing.weight(resource, "voiceSpecifiedWeights", boolWeightKey(requestHasVoiceID(body, audioEnabled)))
|
||||
if weightErr != nil {
|
||||
return nil, 0, resolvedPricing{}, weightErr
|
||||
}
|
||||
amount, calculationErr := pricing.calculate(resource, price, []int{count, durationUnits}, resolutionWeight, audioWeight, referenceVideoWeight, voiceWeight, discount)
|
||||
if calculationErr != nil {
|
||||
return nil, 0, resolvedPricing{}, calculationErr
|
||||
}
|
||||
item := buildLine(resource, unit, count*durationUnits, amount, map[string]any{
|
||||
"count": count, "audio": audioEnabled, "audioSource": audioSource,
|
||||
"durationSeconds": duration, "durationSource": durationSource,
|
||||
@@ -555,14 +699,32 @@ func (s *Service) billingsWithResolvedPricingV2(
|
||||
if err != nil {
|
||||
return nil, 0, resolvedPricing{}, err
|
||||
}
|
||||
amount := price.MulInt(count)
|
||||
amount, calculationErr := pricing.calculate(resource, price, []int{count})
|
||||
if calculationErr != nil {
|
||||
return nil, 0, resolvedPricing{}, calculationErr
|
||||
}
|
||||
if resource == "image" || resource == "image_edit" {
|
||||
amount = amount.
|
||||
Mul(fixedWeight(resourceWeight(pricing.Config, resource, "qualityWeights", stringFromMap(body, "quality")))).
|
||||
Mul(fixedWeight(resourceWeight(pricing.Config, resource, "sizeWeights", stringFromMap(body, "size")))).
|
||||
Mul(fixedWeight(resourceWeight(pricing.Config, resource, "resolutionWeights", firstNonEmptyString(stringFromMap(body, "resolution"), stringFromMap(body, "size")))))
|
||||
qualityWeight, weightErr := pricing.weight(resource, "qualityWeights", stringFromMap(body, "quality"))
|
||||
if weightErr != nil {
|
||||
return nil, 0, resolvedPricing{}, weightErr
|
||||
}
|
||||
sizeWeight, weightErr := pricing.weight(resource, "sizeWeights", stringFromMap(body, "size"))
|
||||
if weightErr != nil {
|
||||
return nil, 0, resolvedPricing{}, weightErr
|
||||
}
|
||||
resolutionWeight, weightErr := pricing.weight(resource, "resolutionWeights", firstNonEmptyString(stringFromMap(body, "resolution"), stringFromMap(body, "size")))
|
||||
if weightErr != nil {
|
||||
return nil, 0, resolvedPricing{}, weightErr
|
||||
}
|
||||
amount, calculationErr = pricing.calculate(resource, amount, nil, qualityWeight, sizeWeight, resolutionWeight)
|
||||
if calculationErr != nil {
|
||||
return nil, 0, resolvedPricing{}, calculationErr
|
||||
}
|
||||
}
|
||||
amount, calculationErr = pricing.calculate(resource, amount, nil, discount)
|
||||
if calculationErr != nil {
|
||||
return nil, 0, resolvedPricing{}, calculationErr
|
||||
}
|
||||
amount = amount.Mul(discount)
|
||||
return []any{buildLine(resource, unit, count, amount, nil)}, amount, pricing, nil
|
||||
}
|
||||
|
||||
@@ -580,14 +742,6 @@ func (pricing resolvedPricing) requiredTextPrice(resource string, keys ...string
|
||||
return 0, &PricingUnavailableError{Reason: "missing, invalid, or not explicitly free", ResourceType: resource, RuleSetID: pricing.RuleSetID}
|
||||
}
|
||||
|
||||
func fixedWeight(value float64) fixedAmount {
|
||||
weight, err := fixedAmountFromAny(value)
|
||||
if err != nil || weight <= 0 {
|
||||
return fixedAmount(fixedScale)
|
||||
}
|
||||
return weight
|
||||
}
|
||||
|
||||
func maximumCandidateEstimate(estimates []candidateEstimate) (candidateEstimate, error) {
|
||||
if len(estimates) == 0 {
|
||||
return candidateEstimate{}, &PricingUnavailableError{Reason: "no candidate has effective pricing"}
|
||||
|
||||
Reference in New Issue
Block a user