|
|
|
@@ -0,0 +1,377 @@ |
|
|
|
package ratio_setting |
|
|
|
|
|
|
|
import ( |
|
|
|
"fmt" |
|
|
|
"math" |
|
|
|
"regexp" |
|
|
|
"sort" |
|
|
|
"strconv" |
|
|
|
"strings" |
|
|
|
|
|
|
|
"github.com/QuantumNous/new-api/common" |
|
|
|
"github.com/QuantumNous/new-api/types" |
|
|
|
) |
|
|
|
|
|
|
|
const ModelPricingRulesOptionKey = "ModelPricingRules" |
|
|
|
|
|
|
|
var ( |
|
|
|
pricingConfigMap = types.NewRWMap[string, *types.PricingConfig]() |
|
|
|
dimensionKeyRE = regexp.MustCompile(`^[a-z][a-z0-9_]{0,31}$`) |
|
|
|
requestSourceRE = regexp.MustCompile(`^request\.[a-zA-Z_][a-zA-Z0-9_.]*$`) |
|
|
|
derivedSourceRE = regexp.MustCompile(`^derived\.(megapixels|aspect_ratio|input_mode)$`) |
|
|
|
reservedDimKeys = map[string]bool{ |
|
|
|
"price": true, |
|
|
|
"source": true, |
|
|
|
"__fallback__": true, |
|
|
|
"price_formula": true, |
|
|
|
"pricing_mode": true, |
|
|
|
"fallback_strategy": true, |
|
|
|
"schema_version": true, |
|
|
|
"scope": true, |
|
|
|
"billing_unit": true, |
|
|
|
"preconsume_strategy": true, |
|
|
|
} |
|
|
|
) |
|
|
|
|
|
|
|
func GetPricingConfig(model string) *types.PricingConfig { |
|
|
|
cfg, ok := pricingConfigMap.Get(model) |
|
|
|
if !ok { |
|
|
|
return nil |
|
|
|
} |
|
|
|
return types.ClonePricingConfig(cfg) |
|
|
|
} |
|
|
|
|
|
|
|
func GetPricingConfigCopy() map[string]*types.PricingConfig { |
|
|
|
src := pricingConfigMap.ReadAll() |
|
|
|
dst := make(map[string]*types.PricingConfig, len(src)) |
|
|
|
for k, v := range src { |
|
|
|
dst[k] = types.ClonePricingConfig(v) |
|
|
|
} |
|
|
|
return dst |
|
|
|
} |
|
|
|
|
|
|
|
func SetPricingConfig(model string, cfg *types.PricingConfig) error { |
|
|
|
if strings.TrimSpace(model) == "" { |
|
|
|
return fmt.Errorf("model name is required") |
|
|
|
} |
|
|
|
clone := types.ClonePricingConfig(cfg) |
|
|
|
if err := ValidatePricingConfig(model, clone); err != nil { |
|
|
|
return err |
|
|
|
} |
|
|
|
pricingConfigMap.Set(model, clone) |
|
|
|
InvalidateExposedDataCache() |
|
|
|
return nil |
|
|
|
} |
|
|
|
|
|
|
|
func DeletePricingConfig(model string) error { |
|
|
|
all := pricingConfigMap.ReadAll() |
|
|
|
delete(all, model) |
|
|
|
pricingConfigMap.Clear() |
|
|
|
pricingConfigMap.AddAll(all) |
|
|
|
InvalidateExposedDataCache() |
|
|
|
return nil |
|
|
|
} |
|
|
|
|
|
|
|
func ModelPricingRules2JSONString() string { |
|
|
|
bytes, err := common.Marshal(GetPricingConfigCopy()) |
|
|
|
if err != nil { |
|
|
|
return "{}" |
|
|
|
} |
|
|
|
return string(bytes) |
|
|
|
} |
|
|
|
|
|
|
|
func UpdateModelPricingRulesByJSONString(jsonStr string) error { |
|
|
|
if strings.TrimSpace(jsonStr) == "" { |
|
|
|
jsonStr = "{}" |
|
|
|
} |
|
|
|
raw := map[string]*types.PricingConfig{} |
|
|
|
if err := common.UnmarshalJsonStr(jsonStr, &raw); err != nil { |
|
|
|
return err |
|
|
|
} |
|
|
|
next := make(map[string]*types.PricingConfig, len(raw)) |
|
|
|
for model, cfg := range raw { |
|
|
|
clone := types.ClonePricingConfig(cfg) |
|
|
|
if err := ValidatePricingConfig(model, clone); err != nil { |
|
|
|
return fmt.Errorf("model %s: %w", model, err) |
|
|
|
} |
|
|
|
next[model] = clone |
|
|
|
} |
|
|
|
pricingConfigMap.Clear() |
|
|
|
pricingConfigMap.AddAll(next) |
|
|
|
InvalidateExposedDataCache() |
|
|
|
return nil |
|
|
|
} |
|
|
|
|
|
|
|
func ValidatePricingConfig(model string, cfg *types.PricingConfig) error { |
|
|
|
if cfg == nil { |
|
|
|
return fmt.Errorf("pricing config is required") |
|
|
|
} |
|
|
|
if cfg.SchemaVersion != types.PricingSchemaVersion { |
|
|
|
return fmt.Errorf("schema_version must be %d", types.PricingSchemaVersion) |
|
|
|
} |
|
|
|
if cfg.Scope != types.PricingScopeModel { |
|
|
|
return fmt.Errorf("scope must be %q", types.PricingScopeModel) |
|
|
|
} |
|
|
|
types.NormalizePricingConfigDefaults(cfg) |
|
|
|
if !isAllowedBillingUnit(cfg.BillingUnit) { |
|
|
|
return fmt.Errorf("billing_unit must be per_call or per_1m_tokens") |
|
|
|
} |
|
|
|
if !isAllowedPreconsumeStrategy(cfg.PreconsumeStrategy) { |
|
|
|
return fmt.Errorf("preconsume_strategy must be exact or minimum") |
|
|
|
} |
|
|
|
if cfg.BillingUnit == types.BillingUnitPerCall && cfg.PreconsumeStrategy != types.PreconsumeStrategyExact { |
|
|
|
return fmt.Errorf("billing_unit per_call requires preconsume_strategy exact") |
|
|
|
} |
|
|
|
if cfg.BillingUnit == types.BillingUnitPer1MTokens { |
|
|
|
if cfg.PreconsumeStrategy != types.PreconsumeStrategyMinimum { |
|
|
|
return fmt.Errorf("billing_unit per_1m_tokens requires preconsume_strategy minimum") |
|
|
|
} |
|
|
|
if !SupportsAnyMatrixUsageBillingModel(model) { |
|
|
|
return fmt.Errorf("model %s does not support matrix usage billing", model) |
|
|
|
} |
|
|
|
} |
|
|
|
if len(cfg.Dimensions) == 0 { |
|
|
|
return fmt.Errorf("dimensions must not be empty") |
|
|
|
} |
|
|
|
if len(cfg.Table) == 0 { |
|
|
|
return fmt.Errorf("table must not be empty") |
|
|
|
} |
|
|
|
if err := validateFallback(cfg.Fallback); err != nil { |
|
|
|
return err |
|
|
|
} |
|
|
|
if err := validateDimensions(cfg.Dimensions); err != nil { |
|
|
|
return err |
|
|
|
} |
|
|
|
if err := validateRows(cfg.Dimensions, cfg.Table); err != nil { |
|
|
|
return err |
|
|
|
} |
|
|
|
return nil |
|
|
|
} |
|
|
|
|
|
|
|
func SupportsAnyMatrixUsageBillingModel(modelName string) bool { |
|
|
|
switch modelName { |
|
|
|
case "seedance-2", "doubao-video", "doubao-video-seedance": |
|
|
|
return true |
|
|
|
default: |
|
|
|
return false |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
func isAllowedBillingUnit(unit string) bool { |
|
|
|
return unit == types.BillingUnitPerCall || unit == types.BillingUnitPer1MTokens |
|
|
|
} |
|
|
|
|
|
|
|
func isAllowedPreconsumeStrategy(strategy string) bool { |
|
|
|
return strategy == types.PreconsumeStrategyExact || strategy == types.PreconsumeStrategyMinimum |
|
|
|
} |
|
|
|
|
|
|
|
func validateFallback(fallback types.PricingFallback) error { |
|
|
|
switch fallback.Strategy { |
|
|
|
case types.PricingFallbackReject, types.PricingFallbackMax: |
|
|
|
return nil |
|
|
|
case types.PricingFallbackDefault: |
|
|
|
if fallback.DefaultPrice < 0 { |
|
|
|
return fmt.Errorf("fallback.default_price must be >= 0") |
|
|
|
} |
|
|
|
return nil |
|
|
|
default: |
|
|
|
return fmt.Errorf("fallback.strategy must be reject, max, or default") |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
func validateDimensions(dimensions []types.PricingDimension) error { |
|
|
|
seen := map[string]bool{} |
|
|
|
for _, dim := range dimensions { |
|
|
|
if !dimensionKeyRE.MatchString(dim.Key) { |
|
|
|
return fmt.Errorf("dimension key %q is invalid", dim.Key) |
|
|
|
} |
|
|
|
if reservedDimKeys[dim.Key] { |
|
|
|
return fmt.Errorf("dimension key %q is reserved", dim.Key) |
|
|
|
} |
|
|
|
if seen[dim.Key] { |
|
|
|
return fmt.Errorf("dimension key %q is duplicated", dim.Key) |
|
|
|
} |
|
|
|
seen[dim.Key] = true |
|
|
|
if dim.Type != "string" && dim.Type != "number" && dim.Type != "boolean" { |
|
|
|
return fmt.Errorf("dimension %q type must be string, number, or boolean", dim.Key) |
|
|
|
} |
|
|
|
if !requestSourceRE.MatchString(dim.Source) && !derivedSourceRE.MatchString(dim.Source) { |
|
|
|
return fmt.Errorf("dimension %q source is not allowed", dim.Key) |
|
|
|
} |
|
|
|
if dim.Optional { |
|
|
|
if dim.Default == nil { |
|
|
|
return fmt.Errorf("dimension %q default required when optional=true", dim.Key) |
|
|
|
} |
|
|
|
if !valueMatchesType(dim.Default, dim.Type) { |
|
|
|
return fmt.Errorf("dimension %q default type must match %s", dim.Key, dim.Type) |
|
|
|
} |
|
|
|
} else if dim.Default != nil { |
|
|
|
return fmt.Errorf("dimension %q default must be null when optional=false", dim.Key) |
|
|
|
} |
|
|
|
} |
|
|
|
return nil |
|
|
|
} |
|
|
|
|
|
|
|
func validateRows(dimensions []types.PricingDimension, rows []types.PricingRow) error { |
|
|
|
for i, row := range rows { |
|
|
|
if err := validateRowFields(dimensions, row); err != nil { |
|
|
|
return fmt.Errorf("row %d: %w", i, err) |
|
|
|
} |
|
|
|
} |
|
|
|
if err := validateUniqueRows(dimensions, rows); err != nil { |
|
|
|
return err |
|
|
|
} |
|
|
|
if err := validateAmbiguousRows(dimensions, rows); err != nil { |
|
|
|
return err |
|
|
|
} |
|
|
|
return nil |
|
|
|
} |
|
|
|
|
|
|
|
func validateRowFields(dimensions []types.PricingDimension, row types.PricingRow) error { |
|
|
|
allowed := map[string]bool{"price": true, "source": true} |
|
|
|
for _, dim := range dimensions { |
|
|
|
allowed[dim.Key] = true |
|
|
|
} |
|
|
|
for key := range row { |
|
|
|
if !allowed[key] { |
|
|
|
return fmt.Errorf("unexpected field %q", key) |
|
|
|
} |
|
|
|
} |
|
|
|
for _, dim := range dimensions { |
|
|
|
v, ok := row[dim.Key] |
|
|
|
if !ok { |
|
|
|
return fmt.Errorf("missing dimension %q", dim.Key) |
|
|
|
} |
|
|
|
if s, ok := v.(string); ok && s == "*" { |
|
|
|
continue |
|
|
|
} |
|
|
|
if !valueMatchesType(v, dim.Type) { |
|
|
|
return fmt.Errorf("dimension %q value type must match %s or wildcard", dim.Key, dim.Type) |
|
|
|
} |
|
|
|
} |
|
|
|
price, ok := numberValue(row["price"]) |
|
|
|
if !ok || price < 0 { |
|
|
|
return fmt.Errorf("price must be >= 0") |
|
|
|
} |
|
|
|
source, ok := row["source"].(string) |
|
|
|
if !ok || (source != types.PricingRowSourceManual && source != types.PricingRowSourceGenerated) { |
|
|
|
return fmt.Errorf("source must be manual or generated") |
|
|
|
} |
|
|
|
return nil |
|
|
|
} |
|
|
|
|
|
|
|
func validateUniqueRows(dimensions []types.PricingDimension, rows []types.PricingRow) error { |
|
|
|
seen := map[string]bool{} |
|
|
|
for _, row := range rows { |
|
|
|
key := rowSignature(dimensions, row) |
|
|
|
if seen[key] { |
|
|
|
return fmt.Errorf("duplicate pricing row %s", key) |
|
|
|
} |
|
|
|
seen[key] = true |
|
|
|
} |
|
|
|
return nil |
|
|
|
} |
|
|
|
|
|
|
|
func validateAmbiguousRows(dimensions []types.PricingDimension, rows []types.PricingRow) error { |
|
|
|
for i := 0; i < len(rows); i++ { |
|
|
|
for j := i + 1; j < len(rows); j++ { |
|
|
|
if wildcardCount(dimensions, rows[i]) != wildcardCount(dimensions, rows[j]) { |
|
|
|
continue |
|
|
|
} |
|
|
|
if rowsIntersect(dimensions, rows[i], rows[j]) { |
|
|
|
return fmt.Errorf("ambiguous rows can both match with same wildcard count") |
|
|
|
} |
|
|
|
} |
|
|
|
} |
|
|
|
return nil |
|
|
|
} |
|
|
|
|
|
|
|
func rowSignature(dimensions []types.PricingDimension, row types.PricingRow) string { |
|
|
|
parts := make([]string, 0, len(dimensions)) |
|
|
|
for _, dim := range dimensions { |
|
|
|
parts = append(parts, dim.Key+"="+literalValue(row[dim.Key])) |
|
|
|
} |
|
|
|
sort.Strings(parts) |
|
|
|
return strings.Join(parts, "|") |
|
|
|
} |
|
|
|
|
|
|
|
func wildcardCount(dimensions []types.PricingDimension, row types.PricingRow) int { |
|
|
|
count := 0 |
|
|
|
for _, dim := range dimensions { |
|
|
|
if s, ok := row[dim.Key].(string); ok && s == "*" { |
|
|
|
count++ |
|
|
|
} |
|
|
|
} |
|
|
|
return count |
|
|
|
} |
|
|
|
|
|
|
|
func rowsIntersect(dimensions []types.PricingDimension, a, b types.PricingRow) bool { |
|
|
|
for _, dim := range dimensions { |
|
|
|
av := a[dim.Key] |
|
|
|
bv := b[dim.Key] |
|
|
|
as, awild := av.(string) |
|
|
|
bs, bwild := bv.(string) |
|
|
|
if awild && as == "*" || bwild && bs == "*" { |
|
|
|
continue |
|
|
|
} |
|
|
|
if !sameLiteralValue(av, bv) { |
|
|
|
return false |
|
|
|
} |
|
|
|
} |
|
|
|
return true |
|
|
|
} |
|
|
|
|
|
|
|
func valueMatchesType(v any, typ string) bool { |
|
|
|
switch typ { |
|
|
|
case "string": |
|
|
|
_, ok := v.(string) |
|
|
|
return ok |
|
|
|
case "number": |
|
|
|
_, ok := numberValue(v) |
|
|
|
return ok |
|
|
|
case "boolean": |
|
|
|
_, ok := v.(bool) |
|
|
|
return ok |
|
|
|
default: |
|
|
|
return false |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
func numberValue(v any) (float64, bool) { |
|
|
|
switch n := v.(type) { |
|
|
|
case float64: |
|
|
|
return n, !math.IsNaN(n) && !math.IsInf(n, 0) |
|
|
|
case float32: |
|
|
|
f := float64(n) |
|
|
|
return f, !math.IsNaN(f) && !math.IsInf(f, 0) |
|
|
|
case int: |
|
|
|
return float64(n), true |
|
|
|
case int64: |
|
|
|
return float64(n), true |
|
|
|
case int32: |
|
|
|
return float64(n), true |
|
|
|
case jsonNumber: |
|
|
|
f, err := strconv.ParseFloat(string(n), 64) |
|
|
|
return f, err == nil && !math.IsNaN(f) && !math.IsInf(f, 0) |
|
|
|
default: |
|
|
|
return 0, false |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
type jsonNumber string |
|
|
|
|
|
|
|
func literalValue(v any) string { |
|
|
|
if f, ok := numberValue(v); ok { |
|
|
|
return strconv.FormatFloat(f, 'f', -1, 64) |
|
|
|
} |
|
|
|
return fmt.Sprintf("%v", v) |
|
|
|
} |
|
|
|
|
|
|
|
func sameLiteralValue(a, b any) bool { |
|
|
|
af, aok := numberValue(a) |
|
|
|
bf, bok := numberValue(b) |
|
|
|
if aok && bok { |
|
|
|
return af == bf |
|
|
|
} |
|
|
|
return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b) |
|
|
|
} |