From 95060ea0aa873e17d223e4e7b6d7c3d724ff914c Mon Sep 17 00:00:00 2001 From: fengsilin Date: Wed, 17 Jun 2026 17:27:09 +0800 Subject: [PATCH 01/35] feat(task-pricing): add pricing config and decision types --- types/model_pricing.go | 133 +++++++++++++++++++++++++++++++++++++++++ types/price_data.go | 39 ++++++++++-- 2 files changed, 167 insertions(+), 5 deletions(-) create mode 100644 types/model_pricing.go diff --git a/types/model_pricing.go b/types/model_pricing.go new file mode 100644 index 0000000..3d3a301 --- /dev/null +++ b/types/model_pricing.go @@ -0,0 +1,133 @@ +package types + +import ( + "fmt" + + "github.com/QuantumNous/new-api/common" +) + +const ( + PricingSchemaVersion = 1 + + PricingScopeModel = "model" + + BillingUnitPerCall = "per_call" + BillingUnitPer1MTokens = "per_1m_tokens" + + PreconsumeStrategyExact = "exact" + PreconsumeStrategyMinimum = "minimum" + + PricingFallbackReject = "reject" + PricingFallbackMax = "max" + PricingFallbackDefault = "default" + + PricingRowSourceManual = "manual" + PricingRowSourceGenerated = "generated" + + BillingModeMatrix = "matrix" +) + +type PricingConfig struct { + SchemaVersion int `json:"schema_version"` + Scope string `json:"scope"` + BillingUnit string `json:"billing_unit,omitempty"` + PreconsumeStrategy string `json:"preconsume_strategy,omitempty"` + Dimensions []PricingDimension `json:"dimensions"` + Table []PricingRow `json:"table"` + Fallback PricingFallback `json:"fallback"` + GeneratorSnapshot map[string]any `json:"generator_snapshot,omitempty"` +} + +type PricingDimension struct { + Key string `json:"key"` + Source string `json:"source"` + Type string `json:"type"` + Optional bool `json:"optional,omitempty"` + Default any `json:"default"` +} + +type PricingRow map[string]any + +type PricingFallback struct { + Strategy string `json:"strategy"` + DefaultPrice float64 `json:"default_price,omitempty"` +} + +type OriginPricingSnapshot struct { + BillingMode string + BillingUnit string + PriceUSD float64 + TokenUnitPriceUSD float64 + Snapshot map[string]any + OtherRatios map[string]float64 + PerCallBilling bool +} + +func ClonePricingConfig(src *PricingConfig) *PricingConfig { + if src == nil { + return nil + } + return &PricingConfig{ + SchemaVersion: src.SchemaVersion, + Scope: src.Scope, + BillingUnit: src.BillingUnit, + PreconsumeStrategy: src.PreconsumeStrategy, + Dimensions: append([]PricingDimension(nil), src.Dimensions...), + Table: ClonePricingRows(src.Table), + Fallback: src.Fallback, + GeneratorSnapshot: CloneMapAny(src.GeneratorSnapshot), + } +} + +func ClonePricingRows(src []PricingRow) []PricingRow { + if src == nil { + return nil + } + dst := make([]PricingRow, len(src)) + for i := range src { + dst[i] = PricingRow(CloneMapAny(map[string]any(src[i]))) + } + return dst +} + +func CloneMapAny(src map[string]any) map[string]any { + if src == nil { + return nil + } + bytes, err := common.Marshal(src) + if err != nil { + dst := make(map[string]any, len(src)) + for k, v := range src { + dst[k] = v + } + return dst + } + var dst map[string]any + if err := common.Unmarshal(bytes, &dst); err != nil { + panic(fmt.Sprintf("clone map failed after successful marshal: %v", err)) + } + return dst +} + +func CloneRatios(src map[string]float64) map[string]float64 { + if src == nil { + return nil + } + dst := make(map[string]float64, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + +func NormalizePricingConfigDefaults(cfg *PricingConfig) { + if cfg == nil { + return + } + if cfg.BillingUnit == "" { + cfg.BillingUnit = BillingUnitPerCall + } + if cfg.PreconsumeStrategy == "" { + cfg.PreconsumeStrategy = PreconsumeStrategyExact + } +} diff --git a/types/price_data.go b/types/price_data.go index 6076f3b..c554282 100644 --- a/types/price_data.go +++ b/types/price_data.go @@ -2,7 +2,8 @@ package types import "fmt" -// ClaudeCacheCreation1hMultiplier 1小时缓存写入价格相对于5分钟的比例 +// ClaudeCacheCreation1hMultiplier is the 1-hour cache write price multiplier +// relative to the 5-minute cache write price. // https://docs.claude.com/en/docs/build-with-claude/prompt-caching#1-hour-cache-duration const ClaudeCacheCreation1hMultiplier = 6 / 3.75 @@ -12,6 +13,33 @@ type GroupRatioInfo struct { HasSpecialRatio bool } +type PricingMode string + +const ( + PricingModeMatched PricingMode = "matched" + PricingModeFallbackMax PricingMode = "fallback_max" + PricingModeFallbackDefault PricingMode = "fallback_default" + PricingModeLegacy PricingMode = "legacy" + PricingModeRemix PricingMode = "remix" +) + +type PricingDecision struct { + Mode PricingMode + BillingMode string + BillingUnit string + PriceUSD float64 + TokenUnitPriceUSD float64 + Quota int + GroupRatioInfo GroupRatioInfo + Snapshot map[string]any + FreeModel bool + LegacyModelPrice float64 + OtherRatios map[string]float64 + UsePrice bool + PerCallBilling bool + Finalized bool +} + type PriceData struct { FreeModel bool ModelPrice float64 @@ -26,13 +54,14 @@ type PriceData struct { AudioCompletionRatio float64 OtherRatios map[string]float64 UsePrice bool - Quota int // 按次计费的最终额度(MJ / Task) - QuotaToPreConsume int // 按量计费的预消耗额度 + Quota int + QuotaToPreConsume int GroupRatioInfo GroupRatioInfo + PricingDecision *PricingDecision } -// ApplyChannelPricingRatios 将渠道定价的扩展比率应用到 PriceData(非零值覆盖) -// cacheRatio, cacheCreationRatio, imageRatio, audioRatio, audioCompletionRatio +// ApplyChannelPricingRatios applies channel-level pricing ratios when the +// channel value is non-zero. func (p *PriceData) ApplyChannelPricingRatios(cacheRatio, cacheCreationRatio, imageRatio, audioRatio, audioCompletionRatio float64) { if cacheRatio != 0 { p.CacheRatio = cacheRatio From b4301a53ae255f4bb03b291b489e6fb1f1ed6942 Mon Sep 17 00:00:00 2001 From: fengsilin Date: Wed, 17 Jun 2026 17:27:18 +0800 Subject: [PATCH 02/35] feat(task-pricing): validate and cache model pricing rules --- model/option.go | 3 + setting/ratio_setting/model_pricing.go | 377 ++++++++++++++++++++ setting/ratio_setting/model_pricing_test.go | 161 +++++++++ 3 files changed, 541 insertions(+) create mode 100644 setting/ratio_setting/model_pricing.go create mode 100644 setting/ratio_setting/model_pricing_test.go diff --git a/model/option.go b/model/option.go index b2e583c..056bfac 100644 --- a/model/option.go +++ b/model/option.go @@ -133,6 +133,7 @@ func InitOptionMap() { common.OptionMap["ModelRequestRateLimitGroup"] = setting.ModelRequestRateLimitGroup2JSONString() common.OptionMap["ModelRatio"] = ratio_setting.ModelRatio2JSONString() common.OptionMap["ModelPrice"] = ratio_setting.ModelPrice2JSONString() + common.OptionMap["ModelPricingRules"] = ratio_setting.ModelPricingRules2JSONString() common.OptionMap["CacheRatio"] = ratio_setting.CacheRatio2JSONString() common.OptionMap["CreateCacheRatio"] = ratio_setting.CreateCacheRatio2JSONString() common.OptionMap["GroupRatio"] = ratio_setting.GroupRatio2JSONString() @@ -511,6 +512,8 @@ func updateOptionMap(key string, value string) (err error) { err = ratio_setting.UpdateCompletionRatioByJSONString(value) case "ModelPrice": err = ratio_setting.UpdateModelPriceByJSONString(value) + case "ModelPricingRules": + err = ratio_setting.UpdateModelPricingRulesByJSONString(value) case "CacheRatio": err = ratio_setting.UpdateCacheRatioByJSONString(value) case "CreateCacheRatio": diff --git a/setting/ratio_setting/model_pricing.go b/setting/ratio_setting/model_pricing.go new file mode 100644 index 0000000..64a23d3 --- /dev/null +++ b/setting/ratio_setting/model_pricing.go @@ -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) +} diff --git a/setting/ratio_setting/model_pricing_test.go b/setting/ratio_setting/model_pricing_test.go new file mode 100644 index 0000000..e243e25 --- /dev/null +++ b/setting/ratio_setting/model_pricing_test.go @@ -0,0 +1,161 @@ +package ratio_setting + +import ( + "testing" + + "github.com/QuantumNous/new-api/types" + "github.com/stretchr/testify/require" +) + +func validPricingConfig() *types.PricingConfig { + return &types.PricingConfig{ + SchemaVersion: types.PricingSchemaVersion, + Scope: types.PricingScopeModel, + Dimensions: []types.PricingDimension{ + {Key: "resolution", Source: "request.resolution", Type: "string", Default: nil}, + {Key: "duration", Source: "request.duration", Type: "number", Default: nil}, + }, + Table: []types.PricingRow{ + {"resolution": "720P", "duration": float64(5), "price": float64(0.10), "source": types.PricingRowSourceManual}, + {"resolution": "720P", "duration": float64(10), "price": float64(0.20), "source": types.PricingRowSourceGenerated}, + }, + Fallback: types.PricingFallback{Strategy: types.PricingFallbackReject}, + } +} + +func TestValidatePricingConfigValidDefaults(t *testing.T) { + cfg := validPricingConfig() + require.NoError(t, ValidatePricingConfig("hailuo-test", cfg)) + require.Equal(t, types.BillingUnitPerCall, cfg.BillingUnit) + require.Equal(t, types.PreconsumeStrategyExact, cfg.PreconsumeStrategy) +} + +func TestValidatePricingConfigSchemaRules(t *testing.T) { + tests := []struct { + name string + mutate func(*types.PricingConfig) + wantErr string + }{ + {"schema_version", func(c *types.PricingConfig) { c.SchemaVersion = 2 }, "schema_version"}, + {"scope", func(c *types.PricingConfig) { c.Scope = "channel" }, "scope"}, + {"dimensions", func(c *types.PricingConfig) { c.Dimensions = nil }, "dimensions"}, + {"table", func(c *types.PricingConfig) { c.Table = nil }, "table"}, + {"fallback", func(c *types.PricingConfig) { c.Fallback.Strategy = "nearest" }, "fallback.strategy"}, + {"default_price", func(c *types.PricingConfig) { + c.Fallback = types.PricingFallback{Strategy: types.PricingFallbackDefault, DefaultPrice: -1} + }, "default_price"}, + {"per_call_strategy", func(c *types.PricingConfig) { + c.BillingUnit = types.BillingUnitPerCall + c.PreconsumeStrategy = types.PreconsumeStrategyMinimum + }, "per_call"}, + {"usage_strategy", func(c *types.PricingConfig) { + c.BillingUnit = types.BillingUnitPer1MTokens + c.PreconsumeStrategy = types.PreconsumeStrategyExact + }, "per_1m_tokens"}, + {"usage_allowlist", func(c *types.PricingConfig) { + c.BillingUnit = types.BillingUnitPer1MTokens + c.PreconsumeStrategy = types.PreconsumeStrategyMinimum + }, "does not support"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := validPricingConfig() + tt.mutate(cfg) + err := ValidatePricingConfig("hailuo-test", cfg) + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +func TestValidatePricingConfigUsageAllowlist(t *testing.T) { + cfg := validPricingConfig() + cfg.BillingUnit = types.BillingUnitPer1MTokens + cfg.PreconsumeStrategy = types.PreconsumeStrategyMinimum + require.NoError(t, ValidatePricingConfig("seedance-2", cfg)) +} + +func TestValidatePricingConfigDimensionRules(t *testing.T) { + tests := []struct { + name string + dim types.PricingDimension + wantErr string + }{ + {"bad_key", types.PricingDimension{Key: "Bad", Source: "request.x", Type: "string", Default: nil}, "invalid"}, + {"reserved", types.PricingDimension{Key: "price", Source: "request.x", Type: "string", Default: nil}, "reserved"}, + {"bad_type", types.PricingDimension{Key: "quality", Source: "request.x", Type: "object", Default: nil}, "type"}, + {"bad_source", types.PricingDimension{Key: "quality", Source: "response.usage.total_tokens", Type: "number", Default: nil}, "source"}, + {"optional_default_missing", types.PricingDimension{Key: "quality", Source: "request.quality", Type: "string", Optional: true, Default: nil}, "default required"}, + {"optional_default_type", types.PricingDimension{Key: "quality", Source: "request.quality", Type: "number", Optional: true, Default: "fast"}, "default type"}, + {"required_default", types.PricingDimension{Key: "quality", Source: "request.quality", Type: "string", Default: "fast"}, "default must be null"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := validPricingConfig() + cfg.Dimensions = []types.PricingDimension{tt.dim} + cfg.Table = []types.PricingRow{{tt.dim.Key: "x", "price": float64(1), "source": types.PricingRowSourceManual}} + if tt.dim.Type == "number" { + cfg.Table[0][tt.dim.Key] = float64(1) + } + err := ValidatePricingConfig("hailuo-test", cfg) + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +func TestValidatePricingConfigRowRules(t *testing.T) { + tests := []struct { + name string + mutate func(*types.PricingConfig) + wantErr string + }{ + {"missing_dim", func(c *types.PricingConfig) { delete(c.Table[0], "duration") }, "missing dimension"}, + {"extra_field", func(c *types.PricingConfig) { c.Table[0]["price_formula"] = "x" }, "unexpected field"}, + {"bad_dim_type", func(c *types.PricingConfig) { c.Table[0]["duration"] = "five" }, "value type"}, + {"bad_price", func(c *types.PricingConfig) { c.Table[0]["price"] = -1.0 }, "price"}, + {"bad_source", func(c *types.PricingConfig) { c.Table[0]["source"] = "api" }, "source"}, + {"duplicate", func(c *types.PricingConfig) { c.Table = append(c.Table, types.PricingRow(types.CloneMapAny(c.Table[0]))) }, "duplicate"}, + {"ambiguous", func(c *types.PricingConfig) { + c.Dimensions = []types.PricingDimension{ + {Key: "res", Source: "request.res", Type: "string", Default: nil}, + {Key: "dur", Source: "request.dur", Type: "number", Default: nil}, + } + c.Table = []types.PricingRow{ + {"res": "*", "dur": float64(5), "price": float64(0.10), "source": types.PricingRowSourceManual}, + {"res": "720P", "dur": "*", "price": float64(0.12), "source": types.PricingRowSourceManual}, + } + }, "ambiguous"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := validPricingConfig() + tt.mutate(cfg) + err := ValidatePricingConfig("hailuo-test", cfg) + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +func TestModelPricingRulesRoundTripAndClone(t *testing.T) { + require.NoError(t, UpdateModelPricingRulesByJSONString(`{}`)) + cfg := validPricingConfig() + require.NoError(t, SetPricingConfig("hailuo-test", cfg)) + + cfg.Table[0]["price"] = float64(999) + got := GetPricingConfig("hailuo-test") + require.NotNil(t, got) + require.Equal(t, float64(0.10), got.Table[0]["price"]) + + jsonStr := ModelPricingRules2JSONString() + require.Contains(t, jsonStr, "hailuo-test") + require.NoError(t, UpdateModelPricingRulesByJSONString(jsonStr)) + require.NotNil(t, GetPricingConfig("hailuo-test")) + + require.NoError(t, DeletePricingConfig("hailuo-test")) + require.Nil(t, GetPricingConfig("hailuo-test")) +} From ae1e68792a367e2d7e2235080269e507b4d687cb Mon Sep 17 00:00:00 2001 From: fengsilin Date: Wed, 17 Jun 2026 17:35:04 +0800 Subject: [PATCH 03/35] feat(task-pricing): resolve pricing dimensions from task requests --- relay/helper/derived_funcs.go | 126 ++++++++++++ relay/helper/derived_funcs_test.go | 41 ++++ relay/helper/dimension_resolver.go | 251 ++++++++++++++++++++++++ relay/helper/dimension_resolver_test.go | 127 ++++++++++++ 4 files changed, 545 insertions(+) create mode 100644 relay/helper/derived_funcs.go create mode 100644 relay/helper/derived_funcs_test.go create mode 100644 relay/helper/dimension_resolver.go create mode 100644 relay/helper/dimension_resolver_test.go diff --git a/relay/helper/derived_funcs.go b/relay/helper/derived_funcs.go new file mode 100644 index 0000000..7cc1c8b --- /dev/null +++ b/relay/helper/derived_funcs.go @@ -0,0 +1,126 @@ +package helper + +import ( + "fmt" + "math" + "strconv" + "strings" +) + +func init() { + registerDerivedPricingFunc("megapixels", derivedMegapixels) + registerDerivedPricingFunc("aspect_ratio", derivedAspectRatio) + registerDerivedPricingFunc("input_mode", derivedInputMode) +} + +func derivedMegapixels(req, resp map[string]any) (any, error) { + resolution, ok, _ := getNestedValue(req, "resolution") + if !ok || !hasNonEmptyValue(resolution) { + resolution, ok, _ = getNestedValue(req, "options.size") + } + if !ok || !hasNonEmptyValue(resolution) { + return nil, nil + } + width, height, err := parseResolution(fmt.Sprintf("%v", resolution)) + if err != nil { + return nil, err + } + return float64(width*height) / 1_000_000, nil +} + +func derivedAspectRatio(req, resp map[string]any) (any, error) { + resolution, ok, _ := getNestedValue(req, "resolution") + if !ok || !hasNonEmptyValue(resolution) { + resolution, ok, _ = getNestedValue(req, "options.size") + } + if !ok || !hasNonEmptyValue(resolution) { + return nil, nil + } + width, height, err := parseResolution(fmt.Sprintf("%v", resolution)) + if err != nil { + return nil, err + } + if height == 0 { + return nil, fmt.Errorf("resolution height must be greater than zero") + } + gcd := intGCD(width, height) + return fmt.Sprintf("%d:%d", width/gcd, height/gcd), nil +} + +func derivedInputMode(req, resp map[string]any) (any, error) { + if hasAnyNonEmpty(req, "video", "video_url", "reference_video", "first_frame_video") { + return "v2v", nil + } + if hasAnyNonEmpty(req, "image", "image_url", "image[]", "images", "first_frame_image", "last_frame_image") { + return "i2v", nil + } + return "t2v", nil +} + +func hasNonEmptyValue(v any) bool { + switch value := v.(type) { + case nil: + return false + case string: + return strings.TrimSpace(value) != "" + case []any: + return len(value) > 0 + case []string: + return len(value) > 0 + case map[string]any: + return len(value) > 0 + default: + return true + } +} + +func hasAnyNonEmpty(req map[string]any, keys ...string) bool { + for _, key := range keys { + value, ok, _ := getNestedValue(req, key) + if ok && hasNonEmptyValue(value) { + return true + } + } + return false +} + +func parseResolution(raw string) (int, int, error) { + normalized := strings.ToLower(strings.TrimSpace(raw)) + if strings.HasSuffix(normalized, "p") { + height, err := strconv.Atoi(strings.TrimSuffix(normalized, "p")) + if err != nil || height <= 0 { + return 0, 0, fmt.Errorf("invalid resolution %q", raw) + } + width := int(math.Round(float64(height) * 16 / 9)) + return width, height, nil + } + + parts := strings.FieldsFunc(normalized, func(r rune) bool { + return r == 'x' || r == '*' || r == '×' + }) + if len(parts) != 2 { + return 0, 0, fmt.Errorf("invalid resolution %q", raw) + } + width, err := strconv.Atoi(strings.TrimSpace(parts[0])) + if err != nil || width <= 0 { + return 0, 0, fmt.Errorf("invalid resolution width %q", raw) + } + height, err := strconv.Atoi(strings.TrimSpace(parts[1])) + if err != nil || height <= 0 { + return 0, 0, fmt.Errorf("invalid resolution height %q", raw) + } + return width, height, nil +} + +func intGCD(a, b int) int { + for b != 0 { + a, b = b, a%b + } + if a < 0 { + return -a + } + if a == 0 { + return 1 + } + return a +} diff --git a/relay/helper/derived_funcs_test.go b/relay/helper/derived_funcs_test.go new file mode 100644 index 0000000..fad16de --- /dev/null +++ b/relay/helper/derived_funcs_test.go @@ -0,0 +1,41 @@ +package helper + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDerivedInputMode(t *testing.T) { + mode, err := derivedInputMode(map[string]any{}, nil) + require.NoError(t, err) + require.Equal(t, "t2v", mode) + + mode, err = derivedInputMode(map[string]any{"image": "frame.png"}, nil) + require.NoError(t, err) + require.Equal(t, "i2v", mode) + + mode, err = derivedInputMode(map[string]any{"image": "frame.png", "video": "clip.mp4"}, nil) + require.NoError(t, err) + require.Equal(t, "v2v", mode) +} + +func TestDerivedResolutionHelpers(t *testing.T) { + mp, err := derivedMegapixels(map[string]any{"resolution": "1280x720"}, nil) + require.NoError(t, err) + require.InDelta(t, 0.9216, mp, 0.0001) + + ratio, err := derivedAspectRatio(map[string]any{"options": map[string]any{"size": "1280x720"}}, nil) + require.NoError(t, err) + require.Equal(t, "16:9", ratio) +} + +func TestHasNonEmptyValue(t *testing.T) { + require.False(t, hasNonEmptyValue("")) + require.False(t, hasNonEmptyValue(" ")) + require.False(t, hasNonEmptyValue(nil)) + require.False(t, hasNonEmptyValue([]any{})) + require.True(t, hasNonEmptyValue([]any{"x"})) + require.True(t, hasNonEmptyValue("x")) + require.True(t, hasNonEmptyValue(0)) +} diff --git a/relay/helper/dimension_resolver.go b/relay/helper/dimension_resolver.go new file mode 100644 index 0000000..fb523bd --- /dev/null +++ b/relay/helper/dimension_resolver.go @@ -0,0 +1,251 @@ +package helper + +import ( + "fmt" + "io" + "mime/multipart" + "strconv" + "strings" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" +) + +type derivedPricingFunc func(req, resp map[string]any) (any, error) + +var derivedPricingFuncs = map[string]derivedPricingFunc{} + +type ResolvedDimensionValues struct { + Values map[string]any + Missing map[string]bool + RawInput map[string]any +} + +type DimensionResolver struct { + c *gin.Context + request map[string]any + response map[string]any +} + +func NewDimensionResolver(c *gin.Context) (*DimensionResolver, error) { + contentType := c.Request.Header.Get("Content-Type") + req := map[string]any{} + + switch { + case strings.HasPrefix(contentType, "application/json"): + storage, err := common.GetBodyStorage(c) + if err != nil { + return nil, err + } + body, err := storage.Bytes() + if err != nil { + return nil, err + } + if len(strings.TrimSpace(string(body))) > 0 { + if err := common.Unmarshal(body, &req); err != nil { + return nil, err + } + } + if err := resetRequestBody(c, storage); err != nil { + return nil, err + } + case strings.Contains(contentType, gin.MIMEMultipartPOSTForm): + form, err := common.ParseMultipartFormReusable(c) + if err != nil { + return nil, err + } + req = requestMapFromMultipart(form) + default: + return nil, fmt.Errorf("unsupported content-type for pricing dimensions: %s", contentType) + } + + return &DimensionResolver{ + c: c, + request: req, + response: map[string]any{}, + }, nil +} + +func (r *DimensionResolver) ResolveDimensions(dims []types.PricingDimension) (*ResolvedDimensionValues, error) { + resolved := &ResolvedDimensionValues{ + Values: make(map[string]any, len(dims)), + Missing: make(map[string]bool), + RawInput: types.CloneMapAny(r.request), + } + + for _, dim := range dims { + value, exists, err := r.resolveSource(dim.Source) + if err != nil { + return nil, err + } + if !exists || !hasNonEmptyValue(value) { + if dim.Optional { + resolved.Values[dim.Key] = dim.Default + continue + } + resolved.Values[dim.Key] = nil + resolved.Missing[dim.Key] = true + continue + } + converted, err := convertPricingDimensionValue(value, dim.Type) + if err != nil { + return nil, fmt.Errorf("dimension %q: %w", dim.Key, err) + } + resolved.Values[dim.Key] = converted + } + + return resolved, nil +} + +func IsAllowedPricingSource(source string) bool { + if strings.HasPrefix(source, "response.") { + return false + } + if strings.HasPrefix(source, "request.") { + return len(strings.TrimPrefix(source, "request.")) > 0 + } + if _, ok := derivedPricingFuncs[strings.TrimPrefix(source, "derived.")]; ok && strings.HasPrefix(source, "derived.") { + return true + } + return false +} + +func registerDerivedPricingFunc(name string, fn derivedPricingFunc) { + derivedPricingFuncs[name] = fn +} + +func (r *DimensionResolver) resolveSource(source string) (any, bool, error) { + if strings.HasPrefix(source, "response.") { + return nil, false, fmt.Errorf("pricing source %q is unsupported in schema v1", source) + } + if strings.HasPrefix(source, "request.") { + return getNestedValue(r.request, strings.TrimPrefix(source, "request.")) + } + if strings.HasPrefix(source, "derived.") { + name := strings.TrimPrefix(source, "derived.") + fn, ok := derivedPricingFuncs[name] + if !ok { + return nil, false, fmt.Errorf("derived pricing source %q is not registered", source) + } + value, err := fn(r.request, r.response) + if err != nil { + return nil, false, err + } + return value, hasNonEmptyValue(value), nil + } + return nil, false, fmt.Errorf("pricing source %q is not allowed", source) +} + +func getNestedValue(src map[string]any, path string) (any, bool, error) { + if path == "" { + return nil, false, nil + } + var cur any = src + for _, part := range strings.Split(path, ".") { + m, ok := cur.(map[string]any) + if !ok { + return nil, false, nil + } + cur, ok = m[part] + if !ok { + return nil, false, nil + } + } + return cur, true, nil +} + +func convertPricingDimensionValue(value any, typ string) (any, error) { + switch typ { + case "string": + if s, ok := value.(string); ok { + return s, nil + } + return fmt.Sprintf("%v", value), nil + case "number": + switch v := value.(type) { + case float64: + return v, nil + case float32: + return float64(v), nil + case int: + return float64(v), nil + case int64: + return float64(v), nil + case int32: + return float64(v), nil + case string: + f, err := strconv.ParseFloat(strings.TrimSpace(v), 64) + if err != nil { + return nil, fmt.Errorf("value %q is not a number", v) + } + return f, nil + default: + return nil, fmt.Errorf("value is not a number") + } + case "boolean": + switch v := value.(type) { + case bool: + return v, nil + case string: + b, err := strconv.ParseBool(strings.TrimSpace(v)) + if err != nil { + return nil, fmt.Errorf("value %q is not a boolean", v) + } + return b, nil + default: + return nil, fmt.Errorf("value is not a boolean") + } + default: + return nil, fmt.Errorf("unknown dimension type %q", typ) + } +} + +func requestMapFromMultipart(form *multipart.Form) map[string]any { + req := map[string]any{} + for key, vals := range form.Value { + if len(vals) == 1 { + assignNestedValue(req, key, vals[0]) + continue + } + copied := append([]string(nil), vals...) + assignNestedValue(req, key, copied) + } + for key, files := range form.File { + if len(files) == 1 { + assignNestedValue(req, key, files[0].Filename) + continue + } + names := make([]string, 0, len(files)) + for _, file := range files { + names = append(names, file.Filename) + } + assignNestedValue(req, key, names) + } + return req +} + +func assignNestedValue(dst map[string]any, key string, value any) { + parts := strings.Split(key, ".") + cur := dst + for i, part := range parts { + if i == len(parts)-1 { + cur[part] = value + return + } + next, ok := cur[part].(map[string]any) + if !ok { + next = map[string]any{} + cur[part] = next + } + cur = next + } +} + +func resetRequestBody(c *gin.Context, storage common.BodyStorage) error { + if _, err := storage.Seek(0, io.SeekStart); err != nil { + return err + } + c.Request.Body = io.NopCloser(storage) + return nil +} diff --git a/relay/helper/dimension_resolver_test.go b/relay/helper/dimension_resolver_test.go new file mode 100644 index 0000000..05cab01 --- /dev/null +++ b/relay/helper/dimension_resolver_test.go @@ -0,0 +1,127 @@ +package helper + +import ( + "bytes" + "mime/multipart" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/require" +) + +func TestDimensionResolver_JSONBody(t *testing.T) { + c := newPricingJSONContext(`{"resolution":"720P","duration":"10","options":{"size":"1280x720"},"image_url":"https://example.test/a.png"}`) + + resolver, err := NewDimensionResolver(c) + require.NoError(t, err) + + values, err := resolver.ResolveDimensions([]types.PricingDimension{ + {Key: "resolution", Source: "request.resolution", Type: "string"}, + {Key: "size", Source: "request.options.size", Type: "string"}, + {Key: "duration", Source: "request.duration", Type: "number"}, + {Key: "mode", Source: "derived.input_mode", Type: "string"}, + }) + require.NoError(t, err) + + require.Equal(t, "720P", values.Values["resolution"]) + require.Equal(t, "1280x720", values.Values["size"]) + require.Equal(t, 10.0, values.Values["duration"]) + require.Equal(t, "i2v", values.Values["mode"]) + require.Empty(t, values.Missing) +} + +func TestDimensionResolver_DerivedInputModePriority(t *testing.T) { + c := newPricingJSONContext(`{"image_url":"https://example.test/a.png","video_url":"https://example.test/a.mp4"}`) + resolver, err := NewDimensionResolver(c) + require.NoError(t, err) + + values, err := resolver.ResolveDimensions([]types.PricingDimension{ + {Key: "mode", Source: "derived.input_mode", Type: "string"}, + }) + require.NoError(t, err) + require.Equal(t, "v2v", values.Values["mode"]) +} + +func TestDimensionResolver_MissingRequiredValue(t *testing.T) { + c := newPricingJSONContext(`{"resolution":"720P"}`) + resolver, err := NewDimensionResolver(c) + require.NoError(t, err) + + values, err := resolver.ResolveDimensions([]types.PricingDimension{ + {Key: "duration", Source: "request.duration", Type: "number"}, + }) + require.NoError(t, err) + require.True(t, values.Missing["duration"]) + require.Nil(t, values.Values["duration"]) +} + +func TestDimensionResolver_OptionalDefault(t *testing.T) { + c := newPricingJSONContext(`{"resolution":"720P"}`) + resolver, err := NewDimensionResolver(c) + require.NoError(t, err) + + values, err := resolver.ResolveDimensions([]types.PricingDimension{ + {Key: "duration", Source: "request.duration", Type: "number", Optional: true, Default: 5.0}, + }) + require.NoError(t, err) + require.Equal(t, 5.0, values.Values["duration"]) + require.Empty(t, values.Missing) +} + +func TestDimensionResolver_MultipartFileInputMode(t *testing.T) { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + require.NoError(t, writer.WriteField("resolution", "720P")) + part, err := writer.CreateFormFile("image", "frame.png") + require.NoError(t, err) + _, err = part.Write([]byte("fake")) + require.NoError(t, err) + require.NoError(t, writer.Close()) + + c := newPricingContext(body.String(), writer.FormDataContentType()) + resolver, err := NewDimensionResolver(c) + require.NoError(t, err) + + values, err := resolver.ResolveDimensions([]types.PricingDimension{ + {Key: "resolution", Source: "request.resolution", Type: "string"}, + {Key: "mode", Source: "derived.input_mode", Type: "string"}, + }) + require.NoError(t, err) + require.Equal(t, "720P", values.Values["resolution"]) + require.Equal(t, "i2v", values.Values["mode"]) +} + +func TestDimensionResolver_UnsupportedContentType(t *testing.T) { + c := newPricingContext("resolution=720P", "application/x-www-form-urlencoded") + _, err := NewDimensionResolver(c) + require.ErrorContains(t, err, "unsupported content-type") +} + +func TestDimensionResolver_ResponseSourceUnsupported(t *testing.T) { + c := newPricingJSONContext(`{"resolution":"720P"}`) + resolver, err := NewDimensionResolver(c) + require.NoError(t, err) + + _, err = resolver.ResolveDimensions([]types.PricingDimension{ + {Key: "tokens", Source: "response.usage.total_tokens", Type: "number"}, + }) + require.ErrorContains(t, err, "unsupported in schema v1") +} + +func newPricingJSONContext(body string) *gin.Context { + return newPricingContext(body, "application/json") +} + +func newPricingContext(body string, contentType string) *gin.Context { + gin.SetMode(gin.TestMode) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + req, _ := http.NewRequest(http.MethodPost, "/v1/video", strings.NewReader(body)) + req.Header.Set("Content-Type", contentType) + c.Request = req + return c +} From 8f2b0143663d8472c27857e662a07b1a62a27de0 Mon Sep 17 00:00:00 2001 From: fengsilin Date: Wed, 17 Jun 2026 17:42:59 +0800 Subject: [PATCH 04/35] feat(task-pricing): add matrix lookup decisions --- relay/common/relay_info.go | 6 + relay/helper/price.go | 76 +++++++- relay/helper/price_test.go | 60 +++++- relay/helper/pricing_lookup.go | 287 ++++++++++++++++++++++++++++ relay/helper/pricing_lookup_test.go | 119 ++++++++++++ relay/mjproxy_handler.go | 16 +- relay/relay_task.go | 6 +- 7 files changed, 560 insertions(+), 10 deletions(-) create mode 100644 relay/helper/pricing_lookup.go create mode 100644 relay/helper/pricing_lookup_test.go diff --git a/relay/common/relay_info.go b/relay/common/relay_info.go index 3877ad4..8833d3a 100644 --- a/relay/common/relay_info.go +++ b/relay/common/relay_info.go @@ -155,6 +155,12 @@ type RelayInfo struct { PriceData types.PriceData + PricingConfigSnapshot *types.PricingConfig + PricingConfigSnapshotLoaded bool + PricingDecisionFrozen *types.PricingDecision + OriginPricing *types.OriginPricingSnapshot + RequireMatrixUsageBilling bool + Request dto.Request // RequestConversionChain records request format conversions in order, e.g. diff --git a/relay/helper/price.go b/relay/helper/price.go index 2b98fa5..a9e78fa 100644 --- a/relay/helper/price.go +++ b/relay/helper/price.go @@ -2,8 +2,10 @@ package helper import ( "fmt" + "net/http" "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" "github.com/QuantumNous/new-api/logger" relaycommon "github.com/QuantumNous/new-api/relay/common" "github.com/QuantumNous/new-api/setting/operation_setting" @@ -124,9 +126,57 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens } // ModelPriceHelperPerCall 按次计费的 PriceHelper (MJ、Task) -func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) types.PriceData { +func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) (types.PriceData, *dto.TaskError) { groupRatioInfo := HandleGroupRatio(c, info) + if info.PricingDecisionFrozen != nil { + priceData := priceDataFromDecision(info.PricingDecisionFrozen) + info.PriceData = priceData + return priceData, nil + } + + if info.OriginPricing != nil { + var ( + decision *types.PricingDecision + taskErr *dto.TaskError + ) + if info.OriginPricing.BillingMode == types.BillingModeMatrix { + decision, taskErr = buildRemixMatrixDecision(info.OriginPricing, groupRatioInfo) + } else { + decision, taskErr = buildRemixLegacyDecision(info.OriginPricing, groupRatioInfo) + } + if taskErr != nil { + return types.PriceData{}, taskErr + } + info.PricingDecisionFrozen = decision + priceData := priceDataFromDecision(decision) + info.PriceData = priceData + return priceData, nil + } + + if info.PricingConfigSnapshotLoaded && info.PricingConfigSnapshot != nil { + resolver, err := NewDimensionResolver(c) + if err != nil { + return types.PriceData{}, pricingDimensionTaskError(err) + } + resolved, err := resolver.ResolveDimensions(info.PricingConfigSnapshot.Dimensions) + if err != nil { + return types.PriceData{}, pricingDimensionTaskError(err) + } + result, taskErr := lookupPricingConfig(info.PricingConfigSnapshot, resolved) + if taskErr != nil { + return types.PriceData{}, taskErr + } + decision := buildMatchedDecision(info.PricingConfigSnapshot, result, groupRatioInfo) + if result.Mode != types.PricingModeMatched { + decision = buildFallbackDecision(info.PricingConfigSnapshot, result, groupRatioInfo) + } + info.PricingDecisionFrozen = decision + priceData := priceDataFromDecision(decision) + info.PriceData = priceData + return priceData, nil + } + modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true) if !success { defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[info.OriginModelName] @@ -147,12 +197,26 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) types. } priceData := types.PriceData{ - FreeModel: freeModel, - ModelPrice: modelPrice, - Quota: quota, - GroupRatioInfo: groupRatioInfo, + FreeModel: freeModel, + ModelPrice: modelPrice, + Quota: quota, + GroupRatioInfo: groupRatioInfo, + } + decision := buildLegacyDecision(modelPrice, groupRatioInfo, freeModel, quota) + priceData.PricingDecision = decision + info.PricingDecisionFrozen = decision + info.PriceData = priceData + return priceData, nil +} + +func pricingDimensionTaskError(err error) *dto.TaskError { + return &dto.TaskError{ + Code: "pricing_dimension_resolve_failed", + Message: err.Error(), + StatusCode: http.StatusBadRequest, + LocalError: true, + Error: err, } - return priceData } func ContainPriceOrRatio(modelName string) bool { diff --git a/relay/helper/price_test.go b/relay/helper/price_test.go index 42a25e8..cf21dbf 100644 --- a/relay/helper/price_test.go +++ b/relay/helper/price_test.go @@ -116,7 +116,6 @@ func TestGlobalRatiosFilledWhenUseRatioMode(t *testing.T) { assert.Equal(t, 2.0, priceData.ImageRatio) } - func TestHandleGroupRatio_DefaultGroup(t *testing.T) { setupUsePriceSwitchTest(t) c := buildTestContext(t) @@ -305,3 +304,62 @@ func TestModelPriceHelper_FreeGroup(t *testing.T) { // cleanup require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"default":1,"vip":1,"svip":1}`)) } + +func TestModelPriceHelperPerCall_MatrixMatchFreezesDecision(t *testing.T) { + setupUsePriceSwitchTest(t) + c := newPricingJSONContext(`{"resolution":"720P","duration":10}`) + info := &relaycommon.RelayInfo{ + OriginModelName: "hailuo-video", + UsingGroup: "default", + UserGroup: "default", + PricingConfigSnapshotLoaded: true, + PricingConfigSnapshot: testPricingLookupConfig(types.PricingFallbackReject), + } + + priceData, taskErr := ModelPriceHelperPerCall(c, info) + require.Nil(t, taskErr) + require.Equal(t, int(0.08*common.QuotaPerUnit), priceData.Quota) + require.NotNil(t, priceData.PricingDecision) + require.Same(t, info.PricingDecisionFrozen, priceData.PricingDecision) + require.Equal(t, types.BillingModeMatrix, priceData.PricingDecision.BillingMode) + + info.PricingConfigSnapshot.Table[0]["price"] = 1.5 + next, taskErr := ModelPriceHelperPerCall(c, info) + require.Nil(t, taskErr) + require.Equal(t, priceData.Quota, next.Quota) +} + +func TestModelPriceHelperPerCall_MatrixReject(t *testing.T) { + setupUsePriceSwitchTest(t) + c := newPricingJSONContext(`{"resolution":"1080P","duration":15}`) + info := &relaycommon.RelayInfo{ + OriginModelName: "hailuo-video", + UsingGroup: "default", + UserGroup: "default", + PricingConfigSnapshotLoaded: true, + PricingConfigSnapshot: testPricingLookupConfig(types.PricingFallbackReject), + } + + priceData, taskErr := ModelPriceHelperPerCall(c, info) + require.NotNil(t, taskErr) + require.Equal(t, "pricing_no_match", taskErr.Code) + require.Nil(t, info.PricingDecisionFrozen) + require.Zero(t, priceData.Quota) +} + +func TestModelPriceHelperPerCall_LegacyDecision(t *testing.T) { + setupUsePriceSwitchTest(t) + c := buildTestContext(t) + info := &relaycommon.RelayInfo{ + OriginModelName: testModelUsePriceSwitch, + UsingGroup: "default", + UserGroup: "default", + } + + priceData, taskErr := ModelPriceHelperPerCall(c, info) + require.Nil(t, taskErr) + require.Equal(t, int(0.5*common.QuotaPerUnit), priceData.Quota) + require.NotNil(t, priceData.PricingDecision) + require.Equal(t, types.PricingModeLegacy, priceData.PricingDecision.Mode) + require.Same(t, info.PricingDecisionFrozen, priceData.PricingDecision) +} diff --git a/relay/helper/pricing_lookup.go b/relay/helper/pricing_lookup.go new file mode 100644 index 0000000..2169405 --- /dev/null +++ b/relay/helper/pricing_lookup.go @@ -0,0 +1,287 @@ +package helper + +import ( + "fmt" + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/dto" + "github.com/QuantumNous/new-api/types" +) + +type pricingLookupResult struct { + Mode types.PricingMode + PriceUSD float64 + Snapshot map[string]any +} + +func lookupPricingConfig(cfg *types.PricingConfig, resolved *ResolvedDimensionValues) (*pricingLookupResult, *dto.TaskError) { + if cfg == nil { + return nil, localPricingTaskError("pricing config is required", "pricing_config_required") + } + + var best types.PricingRow + bestWildcardCount := len(cfg.Dimensions) + 1 + bestCount := 0 + + for _, row := range cfg.Table { + wildcards, ok := rowMatchesDimensions(cfg.Dimensions, row, resolved.Values) + if !ok { + continue + } + if wildcards < bestWildcardCount { + best = row + bestWildcardCount = wildcards + bestCount = 1 + continue + } + if wildcards == bestWildcardCount { + bestCount++ + } + } + + if best != nil && bestCount == 1 { + price, _ := pricingNumberValue(best["price"]) + return &pricingLookupResult{ + Mode: types.PricingModeMatched, + PriceUSD: price, + Snapshot: buildPricingSnapshot(cfg, resolved, price, types.PricingModeMatched, ""), + }, nil + } + + if bestCount > 1 { + return fallbackPricingConfig(cfg, resolved) + } + return fallbackPricingConfig(cfg, resolved) +} + +func buildMatchedDecision(cfg *types.PricingConfig, result *pricingLookupResult, group types.GroupRatioInfo) *types.PricingDecision { + return buildMatrixDecision(cfg, result, group) +} + +func buildFallbackDecision(cfg *types.PricingConfig, result *pricingLookupResult, group types.GroupRatioInfo) *types.PricingDecision { + return buildMatrixDecision(cfg, result, group) +} + +func buildLegacyDecision(modelPrice float64, group types.GroupRatioInfo, free bool, quota int) *types.PricingDecision { + return &types.PricingDecision{ + Mode: types.PricingModeLegacy, + PriceUSD: modelPrice, + Quota: quota, + GroupRatioInfo: group, + FreeModel: free, + LegacyModelPrice: modelPrice, + UsePrice: true, + PerCallBilling: true, + Finalized: true, + } +} + +func buildRemixMatrixDecision(origin *types.OriginPricingSnapshot, group types.GroupRatioInfo) (*types.PricingDecision, *dto.TaskError) { + if origin == nil { + return nil, localPricingTaskError("origin pricing snapshot is required", "invalid_pricing_snapshot") + } + billingUnit := origin.BillingUnit + if billingUnit == "" { + billingUnit = types.BillingUnitPerCall + } + result := &pricingLookupResult{ + Mode: types.PricingModeRemix, + PriceUSD: origin.PriceUSD, + Snapshot: types.CloneMapAny(origin.Snapshot), + } + cfg := &types.PricingConfig{BillingUnit: billingUnit} + decision := buildMatrixDecision(cfg, result, group) + decision.Mode = types.PricingModeRemix + decision.TokenUnitPriceUSD = origin.TokenUnitPriceUSD + decision.OtherRatios = types.CloneRatios(origin.OtherRatios) + decision.PerCallBilling = origin.PerCallBilling + if billingUnit == types.BillingUnitPer1MTokens { + decision.PriceUSD = 0 + decision.Quota = minimumUsagePreconsumeQuota(origin.TokenUnitPriceUSD, group.GroupRatio) + } + return decision, nil +} + +func buildRemixLegacyDecision(origin *types.OriginPricingSnapshot, group types.GroupRatioInfo) (*types.PricingDecision, *dto.TaskError) { + if origin == nil { + return nil, localPricingTaskError("origin legacy pricing snapshot is required", "invalid_legacy_pricing_snapshot") + } + quota := int(origin.PriceUSD * common.QuotaPerUnit * group.GroupRatio) + return &types.PricingDecision{ + Mode: types.PricingModeRemix, + PriceUSD: origin.PriceUSD, + Quota: quota, + GroupRatioInfo: group, + LegacyModelPrice: origin.PriceUSD, + OtherRatios: types.CloneRatios(origin.OtherRatios), + UsePrice: true, + PerCallBilling: origin.PerCallBilling, + Finalized: true, + }, nil +} + +func buildMatrixDecision(cfg *types.PricingConfig, result *pricingLookupResult, group types.GroupRatioInfo) *types.PricingDecision { + billingUnit := cfg.BillingUnit + if billingUnit == "" { + billingUnit = types.BillingUnitPerCall + } + + decision := &types.PricingDecision{ + Mode: result.Mode, + BillingMode: types.BillingModeMatrix, + BillingUnit: billingUnit, + GroupRatioInfo: group, + Snapshot: types.CloneMapAny(result.Snapshot), + UsePrice: true, + Finalized: true, + } + + if billingUnit == types.BillingUnitPer1MTokens { + decision.TokenUnitPriceUSD = result.PriceUSD + decision.Quota = minimumUsagePreconsumeQuota(result.PriceUSD, group.GroupRatio) + decision.PerCallBilling = false + return decision + } + + decision.PriceUSD = result.PriceUSD + decision.Quota = int(result.PriceUSD * common.QuotaPerUnit * group.GroupRatio) + decision.PerCallBilling = true + if group.GroupRatio == 0 || result.PriceUSD == 0 { + decision.FreeModel = true + decision.Quota = 0 + } + return decision +} + +func fallbackPricingConfig(cfg *types.PricingConfig, resolved *ResolvedDimensionValues) (*pricingLookupResult, *dto.TaskError) { + switch cfg.Fallback.Strategy { + case types.PricingFallbackReject: + return nil, localPricingTaskError("pricing dimensions did not match any row", "pricing_no_match") + case types.PricingFallbackMax: + price := maxPricingTablePrice(cfg.Table) + return &pricingLookupResult{ + Mode: types.PricingModeFallbackMax, + PriceUSD: price, + Snapshot: buildPricingSnapshot(cfg, resolved, price, types.PricingModeFallbackMax, cfg.Fallback.Strategy), + }, nil + case types.PricingFallbackDefault: + price := cfg.Fallback.DefaultPrice + return &pricingLookupResult{ + Mode: types.PricingModeFallbackDefault, + PriceUSD: price, + Snapshot: buildPricingSnapshot(cfg, resolved, price, types.PricingModeFallbackDefault, cfg.Fallback.Strategy), + }, nil + default: + return nil, localPricingTaskError(fmt.Sprintf("unsupported fallback strategy %q", cfg.Fallback.Strategy), "invalid_pricing_config") + } +} + +func buildPricingSnapshot(cfg *types.PricingConfig, resolved *ResolvedDimensionValues, price float64, mode types.PricingMode, fallbackStrategy string) map[string]any { + snapshot := make(map[string]any, len(cfg.Dimensions)+4) + for _, dim := range cfg.Dimensions { + snapshot[dim.Key] = resolved.Values[dim.Key] + } + snapshot["price"] = price + snapshot["billing_unit"] = cfg.BillingUnit + snapshot["pricing_mode"] = string(mode) + if fallbackStrategy != "" { + snapshot["fallback_strategy"] = fallbackStrategy + } + return snapshot +} + +func rowMatchesDimensions(dimensions []types.PricingDimension, row types.PricingRow, values map[string]any) (int, bool) { + wildcards := 0 + for _, dim := range dimensions { + rv := row[dim.Key] + if s, ok := rv.(string); ok && s == "*" { + wildcards++ + continue + } + if !samePricingLiteral(rv, values[dim.Key]) { + return 0, false + } + } + return wildcards, true +} + +func maxPricingTablePrice(rows []types.PricingRow) float64 { + var max float64 + for i, row := range rows { + price, ok := pricingNumberValue(row["price"]) + if !ok { + continue + } + if i == 0 || price > max { + max = price + } + } + return max +} + +func minimumUsagePreconsumeQuota(tokenUnitPriceUSD float64, groupRatio float64) int { + if tokenUnitPriceUSD == 0 || groupRatio == 0 { + return 0 + } + if common.PreConsumedQuota <= 0 { + return 0 + } + quota := int(float64(common.PreConsumedQuota) * tokenUnitPriceUSD / 1_000_000 * common.QuotaPerUnit * groupRatio) + if quota <= 0 { + return 1 + } + return quota +} + +func priceDataFromDecision(decision *types.PricingDecision) types.PriceData { + if decision == nil { + return types.PriceData{} + } + return types.PriceData{ + FreeModel: decision.FreeModel, + ModelPrice: decision.PriceUSD, + Quota: decision.Quota, + GroupRatioInfo: decision.GroupRatioInfo, + OtherRatios: types.CloneRatios(decision.OtherRatios), + UsePrice: decision.UsePrice, + PricingDecision: decision, + } +} + +func pricingNumberValue(v any) (float64, bool) { + switch n := v.(type) { + case float64: + return n, true + case float32: + return float64(n), true + case int: + return float64(n), true + case int64: + return float64(n), true + case int32: + return float64(n), true + default: + return 0, false + } +} + +func samePricingLiteral(a, b any) bool { + af, aok := pricingNumberValue(a) + bf, bok := pricingNumberValue(b) + if aok && bok { + return af == bf + } + return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b) +} + +func localPricingTaskError(message string, code string) *dto.TaskError { + err := fmt.Errorf("%s", message) + return &dto.TaskError{ + Code: code, + Message: message, + StatusCode: http.StatusBadRequest, + LocalError: true, + Error: err, + } +} diff --git a/relay/helper/pricing_lookup_test.go b/relay/helper/pricing_lookup_test.go new file mode 100644 index 0000000..8db194c --- /dev/null +++ b/relay/helper/pricing_lookup_test.go @@ -0,0 +1,119 @@ +package helper + +import ( + "testing" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/types" + "github.com/stretchr/testify/require" +) + +func TestPricingLookup_MatchSpecificity(t *testing.T) { + cfg := testPricingLookupConfig(types.PricingFallbackReject) + resolved := &ResolvedDimensionValues{Values: map[string]any{"resolution": "720P", "duration": 10.0}} + + result, taskErr := lookupPricingConfig(cfg, resolved) + require.Nil(t, taskErr) + require.Equal(t, types.PricingModeMatched, result.Mode) + require.Equal(t, 0.08, result.PriceUSD) + require.Equal(t, "720P", result.Snapshot["resolution"]) + require.Equal(t, 10.0, result.Snapshot["duration"]) +} + +func TestPricingLookup_WildcardFallbackMatch(t *testing.T) { + cfg := testPricingLookupConfig(types.PricingFallbackReject) + resolved := &ResolvedDimensionValues{Values: map[string]any{"resolution": "480P", "duration": 10.0}} + + result, taskErr := lookupPricingConfig(cfg, resolved) + require.Nil(t, taskErr) + require.Equal(t, 0.05, result.PriceUSD) +} + +func TestPricingLookup_RejectFallback(t *testing.T) { + cfg := testPricingLookupConfig(types.PricingFallbackReject) + resolved := &ResolvedDimensionValues{Values: map[string]any{"resolution": "1080P", "duration": 15.0}} + + result, taskErr := lookupPricingConfig(cfg, resolved) + require.Nil(t, result) + require.NotNil(t, taskErr) + require.Equal(t, "pricing_no_match", taskErr.Code) +} + +func TestPricingLookup_MaxFallback(t *testing.T) { + cfg := testPricingLookupConfig(types.PricingFallbackMax) + resolved := &ResolvedDimensionValues{Values: map[string]any{"resolution": "1080P", "duration": 15.0}} + + result, taskErr := lookupPricingConfig(cfg, resolved) + require.Nil(t, taskErr) + require.Equal(t, types.PricingModeFallbackMax, result.Mode) + require.Equal(t, 0.1, result.PriceUSD) + require.Equal(t, types.PricingFallbackMax, result.Snapshot["fallback_strategy"]) +} + +func TestPricingLookup_DefaultFallback(t *testing.T) { + cfg := testPricingLookupConfig(types.PricingFallbackDefault) + cfg.Fallback.DefaultPrice = 0.2 + resolved := &ResolvedDimensionValues{Values: map[string]any{"resolution": "1080P", "duration": 15.0}} + + result, taskErr := lookupPricingConfig(cfg, resolved) + require.Nil(t, taskErr) + require.Equal(t, types.PricingModeFallbackDefault, result.Mode) + require.Equal(t, 0.2, result.PriceUSD) +} + +func TestPricingLookup_MissingDimensionSnapshotMarshalsNull(t *testing.T) { + cfg := testPricingLookupConfig(types.PricingFallbackDefault) + cfg.Fallback.DefaultPrice = 0.2 + resolved := &ResolvedDimensionValues{ + Values: map[string]any{"resolution": "1080P", "duration": nil}, + Missing: map[string]bool{"duration": true}, + } + + result, taskErr := lookupPricingConfig(cfg, resolved) + require.Nil(t, taskErr) + body, err := common.Marshal(result.Snapshot) + require.NoError(t, err) + require.Contains(t, string(body), `"duration":null`) +} + +func TestPricingLookup_DecisionQuota(t *testing.T) { + cfg := testPricingLookupConfig(types.PricingFallbackReject) + result := &pricingLookupResult{Mode: types.PricingModeMatched, PriceUSD: 0.1, Snapshot: map[string]any{"price": 0.1}} + + decision := buildMatchedDecision(cfg, result, types.GroupRatioInfo{GroupRatio: 0.5}) + require.Equal(t, types.BillingModeMatrix, decision.BillingMode) + require.Equal(t, types.BillingUnitPerCall, decision.BillingUnit) + require.Equal(t, int(0.1*common.QuotaPerUnit*0.5), decision.Quota) + require.True(t, decision.PerCallBilling) +} + +func TestPricingLookup_UsageDecisionMinimumPreconsume(t *testing.T) { + cfg := testPricingLookupConfig(types.PricingFallbackReject) + cfg.BillingUnit = types.BillingUnitPer1MTokens + cfg.PreconsumeStrategy = types.PreconsumeStrategyMinimum + result := &pricingLookupResult{Mode: types.PricingModeMatched, PriceUSD: 2.0, Snapshot: map[string]any{"price": 2.0}} + + decision := buildMatchedDecision(cfg, result, types.GroupRatioInfo{GroupRatio: 1}) + require.Equal(t, 2.0, decision.TokenUnitPriceUSD) + require.Equal(t, minimumUsagePreconsumeQuota(2.0, 1), decision.Quota) + require.False(t, decision.PerCallBilling) +} + +func testPricingLookupConfig(fallback string) *types.PricingConfig { + return &types.PricingConfig{ + SchemaVersion: types.PricingSchemaVersion, + Scope: types.PricingScopeModel, + BillingUnit: types.BillingUnitPerCall, + PreconsumeStrategy: types.PreconsumeStrategyExact, + Dimensions: []types.PricingDimension{ + {Key: "resolution", Source: "request.resolution", Type: "string"}, + {Key: "duration", Source: "request.duration", Type: "number"}, + }, + Table: []types.PricingRow{ + {"resolution": "720P", "duration": 10.0, "price": 0.08, "source": types.PricingRowSourceManual}, + {"resolution": "720P", "duration": "*", "price": 0.1, "source": types.PricingRowSourceManual}, + {"resolution": "*", "duration": 10.0, "price": 0.05, "source": types.PricingRowSourceManual}, + }, + Fallback: types.PricingFallback{Strategy: fallback}, + } +} diff --git a/relay/mjproxy_handler.go b/relay/mjproxy_handler.go index 63bc907..ab36646 100644 --- a/relay/mjproxy_handler.go +++ b/relay/mjproxy_handler.go @@ -193,7 +193,13 @@ func RelaySwapFace(c *gin.Context, info *relaycommon.RelayInfo) *dto.MidjourneyR } modelName := service.CovertMjpActionToModelName(constant.MjActionSwapFace) - priceData := helper.ModelPriceHelperPerCall(c, info) + priceData, taskErr := helper.ModelPriceHelperPerCall(c, info) + if taskErr != nil { + return &dto.MidjourneyResponse{ + Code: 4, + Description: taskErr.Message, + } + } userQuota, err := model.GetUserQuota(info.UserId, false) if err != nil { @@ -494,7 +500,13 @@ func RelayMidjourneySubmit(c *gin.Context, relayInfo *relaycommon.RelayInfo) *dt modelName := service.CovertMjpActionToModelName(midjRequest.Action) - priceData := helper.ModelPriceHelperPerCall(c, relayInfo) + priceData, taskErr := helper.ModelPriceHelperPerCall(c, relayInfo) + if taskErr != nil { + return &dto.MidjourneyResponse{ + Code: 4, + Description: taskErr.Message, + } + } userQuota, err := model.GetUserQuota(relayInfo.UserId, false) if err != nil { diff --git a/relay/relay_task.go b/relay/relay_task.go index c740fac..4040ec7 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -176,7 +176,11 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe // 4. 价格计算:基础模型价格 info.OriginModelName = modelName - info.PriceData = helper.ModelPriceHelperPerCall(c, info) + priceData, pricingErr := helper.ModelPriceHelperPerCall(c, info) + if pricingErr != nil { + return nil, pricingErr + } + info.PriceData = priceData // 5. 计费估算:让适配器根据用户请求提供 OtherRatios(时长、分辨率等) // 必须在 ModelPriceHelperPerCall 之后调用(它会重建 PriceData)。 From db8eacd558dafae98afcab7059d312a4adfe59f7 Mon Sep 17 00:00:00 2001 From: fengsilin Date: Wed, 17 Jun 2026 18:25:11 +0800 Subject: [PATCH 05/35] feat(task-pricing): persist matrix billing snapshots --- controller/relay.go | 112 +++++++++++++++++++++++++++++++++++----- model/task.go | 16 +++--- relay/relay_task.go | 65 +++++++++++------------ service/task_billing.go | 42 +++++++++++++++ service/task_polling.go | 19 +++++++ types/model_pricing.go | 61 ++++++++++++++++++++++ 6 files changed, 263 insertions(+), 52 deletions(-) diff --git a/controller/relay.go b/controller/relay.go index 4ca1497..9c2d1c4 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -24,6 +24,7 @@ import ( "github.com/QuantumNous/new-api/service" "github.com/QuantumNous/new-api/setting" "github.com/QuantumNous/new-api/setting/operation_setting" + "github.com/QuantumNous/new-api/setting/ratio_setting" "github.com/QuantumNous/new-api/types" "github.com/bytedance/gopkg/util/gopool" @@ -204,7 +205,6 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { break } - addUsedChannel(c, channel.Id) bodyStorage, bodyErr := common.GetBodyStorage(c) if bodyErr != nil { @@ -488,6 +488,95 @@ func RelayTaskFetch(c *gin.Context) { } } +func preloadTaskPricingConfig(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskError { + modelName := strings.TrimSpace(info.OriginModelName) + action := strings.TrimSpace(info.Action) + contentType := c.Request.Header.Get("Content-Type") + + storage, err := common.GetBodyStorage(c) + if err != nil { + status := http.StatusBadRequest + if common.IsRequestBodyTooLargeError(err) || errors.Is(err, common.ErrRequestBodyTooLarge) { + status = http.StatusRequestEntityTooLarge + } + return service.TaskErrorWrapperLocal(err, "read_request_body_failed", status) + } + defer func() { + _, _ = storage.Seek(0, io.SeekStart) + c.Request.Body = io.NopCloser(storage) + }() + + switch { + case strings.HasPrefix(contentType, "application/json"): + body, err := storage.Bytes() + if err != nil { + return service.TaskErrorWrapperLocal(err, "read_request_body_failed", http.StatusBadRequest) + } + if strings.TrimSpace(string(body)) != "" { + var payload map[string]any + if err := common.Unmarshal(body, &payload); err != nil { + return service.TaskErrorWrapperLocal(err, "invalid_json", http.StatusBadRequest) + } + if raw, ok := payload["model"].(string); ok && strings.TrimSpace(raw) != "" { + modelName = strings.TrimSpace(raw) + } + if raw, ok := payload["action"].(string); ok && strings.TrimSpace(raw) != "" { + action = strings.TrimSpace(raw) + } + } + case strings.Contains(contentType, gin.MIMEMultipartPOSTForm): + form, err := common.ParseMultipartFormReusable(c) + if err != nil { + return service.TaskErrorWrapperLocal(err, "invalid_multipart_form", http.StatusBadRequest) + } + if vals := form.Value["model"]; len(vals) > 0 && strings.TrimSpace(vals[0]) != "" { + modelName = strings.TrimSpace(vals[0]) + } + if vals := form.Value["action"]; len(vals) > 0 && strings.TrimSpace(vals[0]) != "" { + action = strings.TrimSpace(vals[0]) + } + } + + if modelName == "" && action != "" { + platform := constant.TaskPlatform(c.GetString("platform")) + modelName = service.CoverTaskActionToModelName(platform, action) + } + if modelName != "" { + info.OriginModelName = modelName + } + if action != "" { + info.Action = action + } + + info.PricingConfigSnapshotLoaded = true + info.PricingConfigSnapshot = ratio_setting.GetPricingConfig(info.OriginModelName) + info.RequireMatrixUsageBilling = info.PricingConfigSnapshot != nil && + info.PricingConfigSnapshot.BillingUnit == types.BillingUnitPer1MTokens + return nil +} + +func buildTaskBillingContext(info *relaycommon.RelayInfo) *model.TaskBillingContext { + bc := &model.TaskBillingContext{ + ModelPrice: info.PriceData.ModelPrice, + GroupRatio: info.PriceData.GroupRatioInfo.GroupRatio, + ModelRatio: info.PriceData.ModelRatio, + OtherRatios: info.PriceData.OtherRatios, + OriginModelName: info.OriginModelName, + PerCallBilling: common.StringsContains(constant.TaskPricePatches, info.OriginModelName), + } + if decision := info.PricingDecisionFrozen; decision != nil && decision.BillingMode == types.BillingModeMatrix { + bc.BillingMode = decision.BillingMode + bc.PricingSnapshot = types.CloneMapAny(decision.Snapshot) + bc.BillingUnit = decision.BillingUnit + bc.TokenUnitPriceUSD = decision.TokenUnitPriceUSD + bc.PerCallBilling = decision.PerCallBilling + bc.ModelPrice = decision.PriceUSD + bc.GroupRatio = decision.GroupRatioInfo.GroupRatio + bc.OtherRatios = types.CloneRatios(decision.OtherRatios) + } + return bc +} + func RelayTask(c *gin.Context) { relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatTask, nil, nil) if err != nil { @@ -503,6 +592,10 @@ func RelayTask(c *gin.Context) { respondTaskError(c, taskErr) return } + if taskErr := preloadTaskPricingConfig(c, relayInfo); taskErr != nil { + respondTaskError(c, taskErr) + return + } var result *relay.TaskSubmitResult var taskErr *dto.TaskError @@ -524,11 +617,9 @@ func RelayTask(c *gin.Context) { if lockedCh, ok := relayInfo.LockedChannel.(*model.Channel); ok && lockedCh != nil { channel = lockedCh - if retryParam.GetRetry() > 0 { - if setupErr := middleware.SetupContextForSelectedChannel(c, channel, relayInfo.OriginModelName); setupErr != nil { - taskErr = service.TaskErrorWrapperLocal(setupErr.Err, "setup_locked_channel_failed", http.StatusInternalServerError) - break - } + if setupErr := middleware.SetupContextForSelectedChannel(c, channel, relayInfo.OriginModelName); setupErr != nil { + taskErr = service.TaskErrorWrapperLocal(setupErr.Err, "setup_locked_channel_failed", http.StatusInternalServerError) + break } } else { var channelErr *types.NewAPIError @@ -587,14 +678,7 @@ func RelayTask(c *gin.Context) { task.PrivateData.BillingSource = relayInfo.BillingSource task.PrivateData.SubscriptionId = relayInfo.SubscriptionId task.PrivateData.TokenId = relayInfo.TokenId - task.PrivateData.BillingContext = &model.TaskBillingContext{ - ModelPrice: relayInfo.PriceData.ModelPrice, - GroupRatio: relayInfo.PriceData.GroupRatioInfo.GroupRatio, - ModelRatio: relayInfo.PriceData.ModelRatio, - OtherRatios: relayInfo.PriceData.OtherRatios, - OriginModelName: relayInfo.OriginModelName, - PerCallBilling: common.StringsContains(constant.TaskPricePatches, relayInfo.OriginModelName), - } + task.PrivateData.BillingContext = buildTaskBillingContext(relayInfo) task.Quota = result.Quota task.Data = result.TaskData task.Action = relayInfo.Action diff --git a/model/task.go b/model/task.go index 9844450..12072be 100644 --- a/model/task.go +++ b/model/task.go @@ -109,12 +109,16 @@ type TaskPrivateData struct { // TaskBillingContext 记录任务提交时的计费参数,以便轮询阶段可以重新计算额度。 type TaskBillingContext struct { - ModelPrice float64 `json:"model_price,omitempty"` // 模型单价 - GroupRatio float64 `json:"group_ratio,omitempty"` // 分组倍率 - ModelRatio float64 `json:"model_ratio,omitempty"` // 模型倍率 - OtherRatios map[string]float64 `json:"other_ratios,omitempty"` // 附加倍率(时长、分辨率等) - OriginModelName string `json:"origin_model_name,omitempty"` // 模型名称,必须为OriginModelName - PerCallBilling bool `json:"per_call_billing,omitempty"` // 按次计费:跳过轮询阶段的差额结算 + ModelPrice float64 `json:"model_price,omitempty"` // 模型单价 + GroupRatio float64 `json:"group_ratio,omitempty"` // 分组倍率 + ModelRatio float64 `json:"model_ratio,omitempty"` // 模型倍率 + OtherRatios map[string]float64 `json:"other_ratios,omitempty"` // 附加倍率(时长、分辨率等) + OriginModelName string `json:"origin_model_name,omitempty"` // 模型名称,必须为OriginModelName + PerCallBilling bool `json:"per_call_billing,omitempty"` // 按次计费:跳过轮询阶段的差额结算 + BillingMode string `json:"billing_mode,omitempty"` + PricingSnapshot map[string]any `json:"pricing_snapshot,omitempty"` + BillingUnit string `json:"billing_unit,omitempty"` + TokenUnitPriceUSD float64 `json:"token_unit_price_usd,omitempty"` } // GetUpstreamTaskID 获取上游真实 task ID(用于与 provider 通信) diff --git a/relay/relay_task.go b/relay/relay_task.go index 4040ec7..47ed96a 100644 --- a/relay/relay_task.go +++ b/relay/relay_task.go @@ -19,6 +19,7 @@ import ( relayconstant "github.com/QuantumNous/new-api/relay/constant" "github.com/QuantumNous/new-api/relay/helper" "github.com/QuantumNous/new-api/service" + "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" ) @@ -87,28 +88,19 @@ func ResolveOriginTask(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskErr } info.LockedChannel = ch - if originTask.ChannelId != info.ChannelId { - key, _, newAPIError := ch.GetNextEnabledKey() - if newAPIError != nil { - return service.TaskErrorWrapper(newAPIError, "channel_no_available_key", newAPIError.StatusCode) - } - common.SetContextKey(c, constant.ContextKeyChannelKey, key) - common.SetContextKey(c, constant.ContextKeyChannelType, ch.Type) - common.SetContextKey(c, constant.ContextKeyChannelBaseUrl, ch.GetBaseURL()) - common.SetContextKey(c, constant.ContextKeyChannelId, originTask.ChannelId) - - info.ChannelBaseUrl = ch.GetBaseURL() - info.ChannelId = originTask.ChannelId - info.ChannelType = ch.Type - info.ApiKey = key - } - // 提取 remix 参数(时长、分辨率 → OtherRatios) if info.Action == constant.TaskActionRemix { if originTask.PrivateData.BillingContext != nil { // 新的 remix 逻辑:直接从原始任务的 BillingContext 中提取 OtherRatios(如果存在) - for s, f := range originTask.PrivateData.BillingContext.OtherRatios { - info.PriceData.AddOtherRatio(s, f) + bc := originTask.PrivateData.BillingContext + info.OriginPricing = &types.OriginPricingSnapshot{ + BillingMode: bc.BillingMode, + BillingUnit: bc.BillingUnit, + PriceUSD: bc.ModelPrice, + TokenUnitPriceUSD: bc.TokenUnitPriceUSD, + Snapshot: types.CloneMapAny(bc.PricingSnapshot), + OtherRatios: types.CloneRatios(bc.OtherRatios), + PerCallBilling: bc.PerCallBilling, } } else { // 旧的 remix 逻辑:直接从 task data 解析 seconds 和 size(如果存在) @@ -120,13 +112,16 @@ func ResolveOriginTask(c *gin.Context, info *relaycommon.RelayInfo) *dto.TaskErr seconds = 4 } sizeStr, _ := taskData["size"].(string) - if info.PriceData.OtherRatios == nil { - info.PriceData.OtherRatios = map[string]float64{} + otherRatios := map[string]float64{ + "seconds": float64(seconds), + "size": 1, } - info.PriceData.OtherRatios["seconds"] = float64(seconds) - info.PriceData.OtherRatios["size"] = 1 if sizeStr == "1792x1024" || sizeStr == "1024x1792" { - info.PriceData.OtherRatios["size"] = 1.666667 + otherRatios["size"] = 1.666667 + } + info.OriginPricing = &types.OriginPricingSnapshot{ + OtherRatios: otherRatios, + PerCallBilling: false, } } } @@ -181,18 +176,22 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe return nil, pricingErr } info.PriceData = priceData + matrixOrOriginPricing := info.OriginPricing != nil || + (info.PricingDecisionFrozen != nil && info.PricingDecisionFrozen.BillingMode == types.BillingModeMatrix) // 5. 计费估算:让适配器根据用户请求提供 OtherRatios(时长、分辨率等) // 必须在 ModelPriceHelperPerCall 之后调用(它会重建 PriceData)。 // ResolveOriginTask 可能已在 remix 路径中预设了 OtherRatios,此处合并。 - if estimatedRatios := adaptor.EstimateBilling(c, info); len(estimatedRatios) > 0 { - for k, v := range estimatedRatios { - info.PriceData.AddOtherRatio(k, v) + if !matrixOrOriginPricing { + if estimatedRatios := adaptor.EstimateBilling(c, info); len(estimatedRatios) > 0 { + for k, v := range estimatedRatios { + info.PriceData.AddOtherRatio(k, v) + } } } // 6. 将 OtherRatios 应用到基础额度 - if !common.StringsContains(constant.TaskPricePatches, modelName) { + if !matrixOrOriginPricing && !common.StringsContains(constant.TaskPricePatches, modelName) { for _, ra := range info.PriceData.OtherRatios { if ra != 1.0 { info.PriceData.Quota = int(float64(info.PriceData.Quota) * ra) @@ -240,11 +239,13 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe // 11. 提交后计费调整:让适配器根据上游实际返回调整 OtherRatios finalQuota := info.PriceData.Quota - if adjustedRatios := adaptor.AdjustBillingOnSubmit(info, taskData); len(adjustedRatios) > 0 { - // 基于调整后的 ratios 重新计算 quota - finalQuota = recalcQuotaFromRatios(info, adjustedRatios) - info.PriceData.OtherRatios = adjustedRatios - info.PriceData.Quota = finalQuota + if !matrixOrOriginPricing { + if adjustedRatios := adaptor.AdjustBillingOnSubmit(info, taskData); len(adjustedRatios) > 0 { + // 基于调整后的 ratios 重新计算 quota + finalQuota = recalcQuotaFromRatios(info, adjustedRatios) + info.PriceData.OtherRatios = adjustedRatios + info.PriceData.Quota = finalQuota + } } return &TaskSubmitResult{ diff --git a/service/task_billing.go b/service/task_billing.go index 0da4cf4..87eef0b 100644 --- a/service/task_billing.go +++ b/service/task_billing.go @@ -118,6 +118,12 @@ func taskBillingOther(task *model.Task) map[string]interface{} { if bc := task.PrivateData.BillingContext; bc != nil { other["model_price"] = bc.ModelPrice other["group_ratio"] = bc.GroupRatio + if bc.BillingMode != "" { + other["billing_mode"] = bc.BillingMode + other["billing_unit"] = bc.BillingUnit + other["token_unit_price_usd"] = bc.TokenUnitPriceUSD + other["pricing_snapshot"] = bc.PricingSnapshot + } if len(bc.OtherRatios) > 0 { for k, v := range bc.OtherRatios { other[k] = v @@ -241,6 +247,42 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int // RecalculateTaskQuotaByTokens 根据实际 token 消耗重新计费(异步差额结算)。 // 当任务成功且返回了 totalTokens 时,根据模型倍率和分组倍率重新计算实际扣费额度, // 与预扣费的差额进行补扣或退还。支持钱包和订阅计费来源。 + +func RecalculateTaskQuotaWithOther(ctx context.Context, task *model.Task, actualQuota int, reason string, extra map[string]any) { + if len(extra) == 0 { + RecalculateTaskQuota(ctx, task, actualQuota, reason) + return + } + if task.PrivateData.BillingContext == nil { + task.PrivateData.BillingContext = &model.TaskBillingContext{} + } + if task.PrivateData.BillingContext.PricingSnapshot == nil { + task.PrivateData.BillingContext.PricingSnapshot = map[string]any{} + } + for k, v := range extra { + task.PrivateData.BillingContext.PricingSnapshot[k] = v + } + RecalculateTaskQuota(ctx, task, actualQuota, reason) +} + +func RecalculateMatrixUsageTaskQuota(ctx context.Context, task *model.Task, totalTokens int) { + if totalTokens <= 0 { + return + } + bc := task.PrivateData.BillingContext + if bc == nil { + return + } + actualQuota := int(float64(totalTokens) * bc.TokenUnitPriceUSD / 1_000_000 * common.QuotaPerUnit * bc.GroupRatio) + if actualQuota <= 0 && totalTokens > 0 && bc.TokenUnitPriceUSD > 0 && bc.GroupRatio > 0 { + actualQuota = 1 + } + reason := fmt.Sprintf("matrix usage billing: tokens=%d, token_unit_price_usd=%.6f, group_ratio=%.4f", totalTokens, bc.TokenUnitPriceUSD, bc.GroupRatio) + RecalculateTaskQuotaWithOther(ctx, task, actualQuota, reason, map[string]any{ + "total_tokens": totalTokens, + }) +} + func RecalculateTaskQuotaByTokens(ctx context.Context, task *model.Task, totalTokens int) { if totalTokens <= 0 { return diff --git a/service/task_polling.go b/service/task_polling.go index 3c5cab5..3186905 100644 --- a/service/task_polling.go +++ b/service/task_polling.go @@ -17,6 +17,7 @@ import ( "github.com/QuantumNous/new-api/model" "github.com/QuantumNous/new-api/relay/channel/task/taskcommon" relaycommon "github.com/QuantumNous/new-api/relay/common" + "github.com/QuantumNous/new-api/types" "github.com/samber/lo" ) @@ -520,6 +521,24 @@ func truncateBase64(s string) string { // 2. taskResult.TotalTokens > 0 → 按 token 重算 // 3. 都不满足 → 保持预扣额度不变 func settleTaskBillingOnComplete(ctx context.Context, adaptor TaskPollingAdaptor, task *model.Task, taskResult *relaycommon.TaskInfo) { + if bc := task.PrivateData.BillingContext; bc != nil && bc.BillingMode == types.BillingModeMatrix { + _, billingUnit, _, err := types.ResolveMatrixPricingSnapshot(bc.BillingUnit, bc.PricingSnapshot) + if err != nil { + logger.LogError(ctx, fmt.Sprintf("task %s matrix pricing snapshot invalid: %s", task.TaskID, err.Error())) + return + } + if billingUnit == types.BillingUnitPerCall { + logger.LogInfo(ctx, fmt.Sprintf("task %s matrix per_call billing, skip settlement", task.TaskID)) + return + } + if billingUnit == types.BillingUnitPer1MTokens { + RecalculateMatrixUsageTaskQuota(ctx, task, taskResult.TotalTokens) + return + } + logger.LogError(ctx, fmt.Sprintf("task %s unknown matrix billing_unit: %s", task.TaskID, billingUnit)) + return + } + // 0. 按次计费的任务不做差额结算 if bc := task.PrivateData.BillingContext; bc != nil && bc.PerCallBilling { logger.LogInfo(ctx, fmt.Sprintf("任务 %s 按次计费,跳过差额结算", task.TaskID)) diff --git a/types/model_pricing.go b/types/model_pricing.go index 3d3a301..19cd893 100644 --- a/types/model_pricing.go +++ b/types/model_pricing.go @@ -1,6 +1,7 @@ package types import ( + "errors" "fmt" "github.com/QuantumNous/new-api/common" @@ -131,3 +132,63 @@ func NormalizePricingConfigDefaults(cfg *PricingConfig) { cfg.PreconsumeStrategy = PreconsumeStrategyExact } } + +func ResolveMatrixPricingSnapshot(billingUnit string, snapshot map[string]any) (price float64, resolvedBillingUnit string, pricingMode string, err error) { + if snapshot == nil { + return 0, "", "", errors.New("pricing snapshot is required") + } + rawPrice, ok := snapshot["price"] + if !ok { + return 0, "", "", errors.New("pricing snapshot missing price") + } + price, ok = snapshotNumber(rawPrice) + if !ok { + return 0, "", "", errors.New("pricing snapshot price must be numeric") + } + rawMode, ok := snapshot["pricing_mode"].(string) + if !ok || rawMode == "" { + return 0, "", "", errors.New("pricing snapshot pricing_mode must be string") + } + switch PricingMode(rawMode) { + case PricingModeMatched, PricingModeFallbackMax, PricingModeFallbackDefault: + pricingMode = rawMode + default: + return 0, "", "", fmt.Errorf("unsupported pricing_mode %q", rawMode) + } + + snapshotUnit, _ := snapshot["billing_unit"].(string) + if snapshotUnit != "" && snapshotUnit != BillingUnitPerCall && snapshotUnit != BillingUnitPer1MTokens { + return 0, "", "", fmt.Errorf("unsupported snapshot billing_unit %q", snapshotUnit) + } + if billingUnit != "" && billingUnit != BillingUnitPerCall && billingUnit != BillingUnitPer1MTokens { + return 0, "", "", fmt.Errorf("unsupported billing_unit %q", billingUnit) + } + switch { + case billingUnit != "" && snapshotUnit != "" && billingUnit != snapshotUnit: + return 0, "", "", fmt.Errorf("billing_unit %q does not match snapshot billing_unit %q", billingUnit, snapshotUnit) + case billingUnit != "": + resolvedBillingUnit = billingUnit + case snapshotUnit != "": + resolvedBillingUnit = snapshotUnit + default: + resolvedBillingUnit = BillingUnitPerCall + } + return price, resolvedBillingUnit, pricingMode, nil +} + +func snapshotNumber(v any) (float64, bool) { + switch n := v.(type) { + case float64: + return n, true + case float32: + return float64(n), true + case int: + return float64(n), true + case int64: + return float64(n), true + case int32: + return float64(n), true + default: + return 0, false + } +} From 3a52f3160881c1758649e0b5a3934ff2e210409f Mon Sep 17 00:00:00 2001 From: fengsilin Date: Wed, 17 Jun 2026 18:33:27 +0800 Subject: [PATCH 06/35] feat(task-pricing): gate channels for matrix usage billing --- controller/relay.go | 12 ++-- relay/helper/matrix_usage_capability.go | 60 ++++++++++++++++++++ relay/helper/matrix_usage_capability_test.go | 27 +++++++++ service/channel_select.go | 29 ++++++++-- 4 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 relay/helper/matrix_usage_capability.go create mode 100644 relay/helper/matrix_usage_capability_test.go diff --git a/controller/relay.go b/controller/relay.go index 9c2d1c4..a9a6179 100644 --- a/controller/relay.go +++ b/controller/relay.go @@ -191,10 +191,11 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) { }() retryParam := &service.RetryParam{ - Ctx: c, - TokenGroup: relayInfo.TokenGroup, - ModelName: relayInfo.OriginModelName, - Retry: common.GetPointer(0), + Ctx: c, + TokenGroup: relayInfo.TokenGroup, + ModelName: relayInfo.OriginModelName, + Retry: common.GetPointer(0), + RequireMatrixUsageBilling: relayInfo.RequireMatrixUsageBilling, } for ; retryParam.GetRetry() <= common.RetryTimes; retryParam.IncreaseRetry() { @@ -314,6 +315,9 @@ func getChannel(c *gin.Context, info *relaycommon.RelayInfo, retryParam *service info.PriceData.GroupRatioInfo = helper.HandleGroupRatio(c, info) if err != nil { + if apiErr, ok := err.(*types.NewAPIError); ok { + return nil, apiErr + } return nil, types.NewError(fmt.Errorf("获取分组 %s 下模型 %s 的可用渠道失败(retry): %s", selectGroup, info.OriginModelName, err.Error()), types.ErrorCodeGetChannelFailed, types.ErrOptionWithSkipRetry()) } if channel == nil { diff --git a/relay/helper/matrix_usage_capability.go b/relay/helper/matrix_usage_capability.go new file mode 100644 index 0000000..cc0a56e --- /dev/null +++ b/relay/helper/matrix_usage_capability.go @@ -0,0 +1,60 @@ +package helper + +import ( + "errors" + + commonpkg "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/constant" +) + +type MatrixUsageCapability struct { + BillingModelName string + UpstreamModelName string + ChannelType int +} + +var matrixUsageCapabilities = []MatrixUsageCapability{ + {BillingModelName: "seedance-2", UpstreamModelName: "seedance-2", ChannelType: constant.ChannelTypeDoubaoVideo}, + {BillingModelName: "doubao-video", UpstreamModelName: "doubao-video", ChannelType: constant.ChannelTypeDoubaoVideo}, + {BillingModelName: "doubao-video-seedance", UpstreamModelName: "doubao-video-seedance", ChannelType: constant.ChannelTypeDoubaoVideo}, +} + +func SupportsMatrixUsageBilling(modelName string, channelType int, mappedModel string) bool { + if mappedModel == "" { + mappedModel = modelName + } + for _, capability := range matrixUsageCapabilities { + if capability.BillingModelName == modelName && + capability.UpstreamModelName == mappedModel && + capability.ChannelType == channelType { + return true + } + } + return false +} + +func ResolvePricingModelMapping(modelName string, modelMapping string) (string, error) { + if modelMapping == "" || modelMapping == "{}" { + return modelName, nil + } + modelMap := map[string]string{} + if err := commonpkg.Unmarshal([]byte(modelMapping), &modelMap); err != nil { + return "", err + } + current := modelName + visited := map[string]bool{current: true} + for { + next := modelMap[current] + if next == "" { + return current, nil + } + if visited[next] { + if next == current { + return current, nil + } + return "", errors.New("model_mapping_contains_cycle") + } + visited[next] = true + current = next + } +} diff --git a/relay/helper/matrix_usage_capability_test.go b/relay/helper/matrix_usage_capability_test.go new file mode 100644 index 0000000..9524f2a --- /dev/null +++ b/relay/helper/matrix_usage_capability_test.go @@ -0,0 +1,27 @@ +package helper + +import ( + "testing" + + "github.com/QuantumNous/new-api/constant" + "github.com/stretchr/testify/require" +) + +func TestSupportsMatrixUsageBilling(t *testing.T) { + require.True(t, SupportsMatrixUsageBilling("seedance-2", constant.ChannelTypeDoubaoVideo, "seedance-2")) + require.False(t, SupportsMatrixUsageBilling("seedance-2", constant.ChannelTypeOpenAI, "seedance-2")) + require.False(t, SupportsMatrixUsageBilling("unknown", constant.ChannelTypeDoubaoVideo, "unknown")) +} + +func TestResolvePricingModelMapping(t *testing.T) { + mapped, err := ResolvePricingModelMapping("seedance-2", `{"seedance-2":"doubao-video-seedance"}`) + require.NoError(t, err) + require.Equal(t, "doubao-video-seedance", mapped) + + mapped, err = ResolvePricingModelMapping("seedance-2", `{"seedance-2":"a","a":"b"}`) + require.NoError(t, err) + require.Equal(t, "b", mapped) + + _, err = ResolvePricingModelMapping("seedance-2", `{"seedance-2":"a","a":"seedance-2"}`) + require.ErrorContains(t, err, "model_mapping_contains_cycle") +} diff --git a/service/channel_select.go b/service/channel_select.go index a3710ef..8075ef3 100644 --- a/service/channel_select.go +++ b/service/channel_select.go @@ -2,21 +2,26 @@ package service import ( "errors" + "fmt" + "net/http" "github.com/QuantumNous/new-api/common" "github.com/QuantumNous/new-api/constant" "github.com/QuantumNous/new-api/logger" "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/relay/helper" "github.com/QuantumNous/new-api/setting" + "github.com/QuantumNous/new-api/types" "github.com/gin-gonic/gin" ) type RetryParam struct { - Ctx *gin.Context - TokenGroup string - ModelName string - Retry *int - resetNextTry bool + Ctx *gin.Context + TokenGroup string + ModelName string + Retry *int + RequireMatrixUsageBilling bool + resetNextTry bool } func (p *RetryParam) GetRetry() int { @@ -158,5 +163,19 @@ func CacheGetRandomSatisfiedChannel(param *RetryParam) (*model.Channel, string, return nil, param.TokenGroup, err } } + if channel != nil && param.RequireMatrixUsageBilling { + mappedModel, mapErr := helper.ResolvePricingModelMapping(param.ModelName, channel.GetModelMapping()) + if mapErr != nil { + return nil, selectGroup, mapErr + } + if !helper.SupportsMatrixUsageBilling(param.ModelName, channel.Type, mappedModel) { + return nil, selectGroup, types.NewErrorWithStatusCode( + fmt.Errorf("matrix_usage_billing_channel_unavailable"), + types.ErrorCode("matrix_usage_billing_channel_unavailable"), + http.StatusBadRequest, + types.ErrOptionWithSkipRetry(), + ) + } + } return channel, selectGroup, nil } From 285323d765b285c4c4fc05531e5295c45d456dd1 Mon Sep 17 00:00:00 2001 From: fengsilin Date: Wed, 17 Jun 2026 18:37:01 +0800 Subject: [PATCH 07/35] feat(task-pricing): expose model pricing rules api --- controller/model_pricing_controller.go | 56 ++++++++++++++++++++++++++ router/api-router.go | 4 ++ 2 files changed, 60 insertions(+) create mode 100644 controller/model_pricing_controller.go diff --git a/controller/model_pricing_controller.go b/controller/model_pricing_controller.go new file mode 100644 index 0000000..27fe2f6 --- /dev/null +++ b/controller/model_pricing_controller.go @@ -0,0 +1,56 @@ +package controller + +import ( + "net/http" + + "github.com/QuantumNous/new-api/common" + "github.com/QuantumNous/new-api/model" + "github.com/QuantumNous/new-api/setting/ratio_setting" + "github.com/QuantumNous/new-api/types" + "github.com/gin-gonic/gin" +) + +func GetModelPricingRules(c *gin.Context) { + common.ApiSuccess(c, ratio_setting.GetPricingConfigCopy()) +} + +func GetModelPricingRule(c *gin.Context) { + modelName := c.Param("model") + cfg := ratio_setting.GetPricingConfig(modelName) + if cfg == nil { + c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "model pricing rule not found"}) + return + } + common.ApiSuccess(c, cfg) +} + +func UpdateModelPricingRule(c *gin.Context) { + modelName := c.Param("model") + var cfg types.PricingConfig + if err := common.DecodeJson(c.Request.Body, &cfg); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()}) + return + } + if err := ratio_setting.SetPricingConfig(modelName, &cfg); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()}) + return + } + if err := model.UpdateOption(ratio_setting.ModelPricingRulesOptionKey, ratio_setting.ModelPricingRules2JSONString()); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()}) + return + } + common.ApiSuccess(c, ratio_setting.GetPricingConfig(modelName)) +} + +func DeleteModelPricingRule(c *gin.Context) { + modelName := c.Param("model") + if err := ratio_setting.DeletePricingConfig(modelName); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"success": false, "message": err.Error()}) + return + } + if err := model.UpdateOption(ratio_setting.ModelPricingRulesOptionKey, ratio_setting.ModelPricingRules2JSONString()); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"success": false, "message": err.Error()}) + return + } + common.ApiSuccess(c, nil) +} diff --git a/router/api-router.go b/router/api-router.go index be15355..7edc158 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -185,6 +185,10 @@ func SetApiRouter(router *gin.Engine) { { optionRoute.GET("/", controller.GetOptions) optionRoute.PUT("/", controller.UpdateOption) + optionRoute.GET("/model_pricing", controller.GetModelPricingRules) + optionRoute.GET("/model_pricing/:model", controller.GetModelPricingRule) + optionRoute.PUT("/model_pricing/:model", controller.UpdateModelPricingRule) + optionRoute.DELETE("/model_pricing/:model", controller.DeleteModelPricingRule) optionRoute.GET("/channel_affinity_cache", controller.GetChannelAffinityCacheStats) optionRoute.DELETE("/channel_affinity_cache", controller.ClearChannelAffinityCache) optionRoute.POST("/rest_model_ratio", controller.ResetModelRatio) From f3778e393131994a76a0c16b0862727a205abcd2 Mon Sep 17 00:00:00 2001 From: fengsilin Date: Wed, 17 Jun 2026 18:39:32 +0800 Subject: [PATCH 08/35] test(task-pricing): cover doubao usage parsing --- relay/channel/task/doubao/adaptor_test.go | 50 +++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 relay/channel/task/doubao/adaptor_test.go diff --git a/relay/channel/task/doubao/adaptor_test.go b/relay/channel/task/doubao/adaptor_test.go new file mode 100644 index 0000000..396f4d3 --- /dev/null +++ b/relay/channel/task/doubao/adaptor_test.go @@ -0,0 +1,50 @@ +package doubao + +import ( + "testing" + + "github.com/QuantumNous/new-api/model" + "github.com/stretchr/testify/require" +) + +func TestParseTaskResult_SuccessWithUsage(t *testing.T) { + adaptor := &TaskAdaptor{} + taskInfo, err := adaptor.ParseTaskResult([]byte(`{ + "status":"succeeded", + "content":{"video_url":"https://example.test/video.mp4"}, + "usage":{"completion_tokens":12,"total_tokens":3456} + }`)) + + require.NoError(t, err) + require.Equal(t, string(model.TaskStatusSuccess), taskInfo.Status) + require.Equal(t, "https://example.test/video.mp4", taskInfo.Url) + require.Equal(t, 12, taskInfo.CompletionTokens) + require.Equal(t, 3456, taskInfo.TotalTokens) +} + +func TestParseTaskResult_SuccessWithoutUsage(t *testing.T) { + adaptor := &TaskAdaptor{} + taskInfo, err := adaptor.ParseTaskResult([]byte(`{ + "status":"succeeded", + "content":{"video_url":"https://example.test/video.mp4"} + }`)) + + require.NoError(t, err) + require.Equal(t, string(model.TaskStatusSuccess), taskInfo.Status) + require.Zero(t, taskInfo.TotalTokens) +} + +func TestParseTaskResult_NonSuccessStatusesDoNotRequireUsage(t *testing.T) { + adaptor := &TaskAdaptor{} + cases := map[string]model.TaskStatus{ + "queued": model.TaskStatusQueued, + "processing": model.TaskStatusInProgress, + "failed": model.TaskStatusFailure, + } + for status, want := range cases { + taskInfo, err := adaptor.ParseTaskResult([]byte(`{"status":"` + status + `"}`)) + require.NoError(t, err) + require.Equal(t, string(want), taskInfo.Status) + require.Zero(t, taskInfo.TotalTokens) + } +} From aff3284d026cc965bf325e76f4a36561d2f48656 Mon Sep 17 00:00:00 2001 From: fengsilin Date: Wed, 17 Jun 2026 19:08:41 +0800 Subject: [PATCH 09/35] feat(ui): add model pricing config utilities --- .../Operation/ModelPricing/pricingConfig.js | 274 ++++++++++++++++++ .../ModelPricing/pricingConfig.test.js | 82 ++++++ 2 files changed, 356 insertions(+) create mode 100644 web/src/pages/Setting/Operation/ModelPricing/pricingConfig.js create mode 100644 web/src/pages/Setting/Operation/ModelPricing/pricingConfig.test.js diff --git a/web/src/pages/Setting/Operation/ModelPricing/pricingConfig.js b/web/src/pages/Setting/Operation/ModelPricing/pricingConfig.js new file mode 100644 index 0000000..c4965da --- /dev/null +++ b/web/src/pages/Setting/Operation/ModelPricing/pricingConfig.js @@ -0,0 +1,274 @@ +export const BILLING_UNITS = { + perCall: 'per_call', + per1MTokens: 'per_1m_tokens', +}; + +export const PRECONSUME_STRATEGIES = { + exact: 'exact', + minimum: 'minimum', +}; + +export const FALLBACKS = { + reject: 'reject', + max: 'max', + default: 'default', +}; + +export const ROW_SOURCES = { + manual: 'manual', + generated: 'generated', +}; + +const RESERVED_KEYS = new Set([ + 'price', + 'source', + '__fallback__', + 'price_formula', + 'pricing_mode', + 'fallback_strategy', + 'schema_version', + 'scope', + 'billing_unit', + 'preconsume_strategy', +]); + +export const createDefaultConfig = () => ({ + schema_version: 1, + scope: 'model', + billing_unit: BILLING_UNITS.perCall, + preconsume_strategy: PRECONSUME_STRATEGIES.exact, + dimensions: [ + { + key: 'resolution', + source: 'request.resolution', + type: 'string', + default: null, + }, + ], + table: [ + { + resolution: '*', + price: 0.1, + source: ROW_SOURCES.manual, + }, + ], + fallback: { + strategy: FALLBACKS.reject, + }, +}); + +export const validatePricingConfig = (config) => { + const errors = []; + if (!config || typeof config !== 'object') { + return { ok: false, errors: ['config must be an object'] }; + } + + if (config.schema_version !== 1) errors.push('schema_version must be 1'); + if (config.scope !== 'model') errors.push('scope must be model'); + + const billingUnit = config.billing_unit || BILLING_UNITS.perCall; + const preconsume = config.preconsume_strategy || PRECONSUME_STRATEGIES.exact; + if (!Object.values(BILLING_UNITS).includes(billingUnit)) errors.push('billing_unit is invalid'); + if (!Object.values(PRECONSUME_STRATEGIES).includes(preconsume)) errors.push('preconsume_strategy is invalid'); + if (billingUnit === BILLING_UNITS.perCall && preconsume !== PRECONSUME_STRATEGIES.exact) { + errors.push('per_call requires exact preconsume_strategy'); + } + if (billingUnit === BILLING_UNITS.per1MTokens && preconsume !== PRECONSUME_STRATEGIES.minimum) { + errors.push('per_1m_tokens requires minimum preconsume_strategy'); + } + + if (!Array.isArray(config.dimensions) || config.dimensions.length === 0) errors.push('dimensions must not be empty'); + if (!Array.isArray(config.table) || config.table.length === 0) errors.push('table must not be empty'); + + const dimensionKeys = new Set(); + for (const dim of config.dimensions || []) { + if (!/^[a-z][a-z0-9_]{0,31}$/.test(dim.key || '')) errors.push(`dimension key "${dim.key || ''}" is invalid`); + if (RESERVED_KEYS.has(dim.key)) errors.push(`dimension key "${dim.key}" is reserved`); + if (dimensionKeys.has(dim.key)) errors.push(`dimension key "${dim.key}" is duplicated`); + dimensionKeys.add(dim.key); + if (!['string', 'number', 'boolean'].includes(dim.type)) errors.push(`dimension "${dim.key}" type is invalid`); + if (dim.optional && (dim.default === null || dim.default === undefined)) { + errors.push(`optional dimension "${dim.key}" requires a default value`); + } + if (!dim.optional && dim.default !== null && dim.default !== undefined) { + errors.push(`required dimension "${dim.key}" must use null default`); + } + if (dim.optional && dim.default !== null && dim.default !== undefined && !valueMatchesType(dim.default, dim.type)) { + errors.push(`dimension "${dim.key}" default does not match type`); + } + } + + const rowSignatures = new Set(); + const rowPatterns = []; + for (const [index, row] of (config.table || []).entries()) { + for (const key of Object.keys(row || {})) { + if (!dimensionKeys.has(key) && key !== 'price' && key !== 'source') errors.push(`row ${index + 1} contains extra field "${key}"`); + } + for (const dim of config.dimensions || []) { + if (!(dim.key in row)) { + errors.push(`row ${index + 1} missing "${dim.key}"`); + continue; + } + if (row[dim.key] !== '*' && !valueMatchesType(row[dim.key], dim.type)) { + errors.push(`row ${index + 1} value for "${dim.key}" does not match type`); + } + } + const price = Number(row.price); + if (Number.isNaN(price) || price < 0) errors.push(`row ${index + 1} price must be >= 0`); + if (!Object.values(ROW_SOURCES).includes(row.source)) errors.push(`row ${index + 1} source must be manual or generated`); + + const signature = (config.dimensions || []).map((dim) => String(row[dim.key])).join('\u0001'); + if (rowSignatures.has(signature)) errors.push(`row ${index + 1} duplicates another row`); + rowSignatures.add(signature); + rowPatterns.push({ + index, + values: (config.dimensions || []).map((dim) => row[dim.key]), + wildcards: (config.dimensions || []).filter((dim) => row[dim.key] === '*').length, + }); + } + + for (let i = 0; i < rowPatterns.length; i += 1) { + for (let j = i + 1; j < rowPatterns.length; j += 1) { + if (rowPatterns[i].wildcards !== rowPatterns[j].wildcards) continue; + if (patternsOverlap(rowPatterns[i].values, rowPatterns[j].values)) { + errors.push(`rows ${rowPatterns[i].index + 1} and ${rowPatterns[j].index + 1} are ambiguous`); + } + } + } + + const strategy = config.fallback?.strategy; + if (!Object.values(FALLBACKS).includes(strategy)) errors.push('fallback.strategy is invalid'); + if (strategy === FALLBACKS.default && (config.fallback.default_price === undefined || Number(config.fallback.default_price) < 0)) { + errors.push('fallback.default_price must be >= 0'); + } + + return { ok: errors.length === 0, errors }; +}; + +export const generateRowsFromRatios = ({ dimensions, basePrice, ratios }) => { + const dims = dimensions || []; + if (dims.length === 0) return []; + + const build = (index, prefix, price) => { + if (index >= dims.length) return [{ ...prefix, price: Number(Number(price).toFixed(6)), source: ROW_SOURCES.generated }]; + const dim = dims[index]; + const dimRatios = ratios?.[dim.key] || {}; + return Object.entries(dimRatios).flatMap(([value, ratio]) => + build(index + 1, { ...prefix, [dim.key]: parseDimensionValue(value, dim.type) }, price * Number(ratio || 1)), + ); + }; + + return build(0, {}, Number(basePrice || 0)); +}; + +export const mergeGeneratedRows = ({ existingRows, generatedRows, strategy }) => { + if (strategy === 'replace_all') return [...(generatedRows || [])]; + const manualRows = (existingRows || []).filter((row) => row.source === ROW_SOURCES.manual); + return [...manualRows, ...(generatedRows || [])]; +}; + +export const importPricingCsv = (text, dimensions) => { + const lines = String(text || '').trim().split(/\r?\n/).filter(Boolean); + if (lines.length === 0) return { rows: [], errors: [] }; + + const headers = parseCsvLine(lines[0]); + const errors = []; + const rows = lines.slice(1).map((line, index) => { + const cells = parseCsvLine(line); + const row = {}; + headers.forEach((header, i) => { + row[header] = cells[i] ?? ''; + }); + for (const dim of dimensions || []) row[dim.key] = row[dim.key] === '*' ? '*' : parseDimensionValue(row[dim.key], dim.type); + row.price = Number(row.price); + row.source = row.source || ROW_SOURCES.manual; + if (Number.isNaN(row.price)) errors.push(`line ${index + 2} has invalid price`); + return row; + }); + + return { rows, errors }; +}; + +export const exportPricingCsv = (rows, dimensions) => { + const headers = [...(dimensions || []).map((dim) => dim.key), 'price', 'source']; + const body = (rows || []).map((row) => headers.map((header) => escapeCsvValue(row[header] ?? '')).join(',')); + return [headers.join(','), ...body].join('\n'); +}; + +export const previewLookup = (config, requestValues) => { + const validation = validatePricingConfig(config); + if (!validation.ok) return { mode: 'invalid', error: validation.errors.join('\n') }; + + const matches = []; + for (const row of config.table || []) { + let wildcards = 0; + let matched = true; + for (const dim of config.dimensions || []) { + const expected = row[dim.key]; + if (expected === '*') { + wildcards += 1; + continue; + } + if (String(expected) !== String(requestValues?.[dim.key])) { + matched = false; + break; + } + } + if (matched) matches.push({ row, wildcards }); + } + + matches.sort((a, b) => a.wildcards - b.wildcards); + if (matches.length > 1 && matches[0].wildcards === matches[1].wildcards) return { mode: 'reject', error: 'ambiguous matching rows' }; + if (matches.length > 0) { + const best = matches[0]; + return { + mode: 'matched', + row: best.row, + price: Number(best.row.price), + snapshot: buildSnapshot(config, requestValues, Number(best.row.price), 'matched'), + }; + } + + if (config.fallback.strategy === FALLBACKS.reject) return { mode: 'reject', error: 'no pricing row matched' }; + if (config.fallback.strategy === FALLBACKS.max) { + const price = Math.max(...config.table.map((row) => Number(row.price || 0))); + return { mode: 'fallback_max', price, snapshot: buildSnapshot(config, requestValues, price, 'fallback_max', FALLBACKS.max) }; + } + + const price = Number(config.fallback.default_price || 0); + return { mode: 'fallback_default', price, snapshot: buildSnapshot(config, requestValues, price, 'fallback_default', FALLBACKS.default) }; +}; + +export const parseDimensionValue = (value, type) => { + if (value === '*') return value; + if (type === 'number') return Number(value); + if (type === 'boolean') return value === true || value === 'true'; + return value; +}; + +const valueMatchesType = (value, type) => { + if (type === 'number') return typeof value === 'number' && !Number.isNaN(value); + if (type === 'boolean') return typeof value === 'boolean'; + return typeof value === 'string'; +}; + +const patternsOverlap = (left, right) => + left.every((leftValue, index) => leftValue === '*' || right[index] === '*' || String(leftValue) === String(right[index])); + +const parseCsvLine = (line) => line.split(',').map((item) => item.trim()); + +const escapeCsvValue = (value) => { + const text = String(value); + if (!/[",\n]/.test(text)) return text; + return `"${text.replaceAll('"', '""')}"`; +}; + +const buildSnapshot = (config, values, price, mode, fallbackStrategy) => { + const snapshot = {}; + for (const dim of config.dimensions || []) snapshot[dim.key] = values?.[dim.key] ?? null; + snapshot.price = price; + snapshot.billing_unit = config.billing_unit || BILLING_UNITS.perCall; + snapshot.pricing_mode = mode; + if (fallbackStrategy) snapshot.fallback_strategy = fallbackStrategy; + return snapshot; +}; diff --git a/web/src/pages/Setting/Operation/ModelPricing/pricingConfig.test.js b/web/src/pages/Setting/Operation/ModelPricing/pricingConfig.test.js new file mode 100644 index 0000000..031e039 --- /dev/null +++ b/web/src/pages/Setting/Operation/ModelPricing/pricingConfig.test.js @@ -0,0 +1,82 @@ +import { describe, expect, test } from 'bun:test'; +import { + createDefaultConfig, + exportPricingCsv, + generateRowsFromRatios, + importPricingCsv, + mergeGeneratedRows, + previewLookup, + validatePricingConfig, +} from './pricingConfig'; + +describe('pricingConfig utilities', () => { + test('create and validate default config', () => { + const config = createDefaultConfig('hailuo-video'); + expect(config.scope).toBe('model'); + expect(validatePricingConfig(config).ok).toBe(true); + }); + + test('validate dimensions and rows', () => { + const config = createDefaultConfig(); + config.dimensions[0].key = 'price'; + expect(validatePricingConfig(config).ok).toBe(false); + + config.dimensions[0].key = 'resolution'; + config.table = [{ resolution: 720, price: -1, source: 'manual' }]; + const result = validatePricingConfig(config); + expect(result.ok).toBe(false); + expect(result.errors.length).toBeGreaterThan(0); + }); + + test('generate rows and preserve manual rows', () => { + const dimensions = [ + { key: 'resolution', type: 'string' }, + { key: 'duration', type: 'number' }, + ]; + const generatedRows = generateRowsFromRatios({ + dimensions, + basePrice: 0.1, + ratios: { + resolution: { '720P': 1, '1080P': 2 }, + duration: { 5: 1, 10: 1.5 }, + }, + }); + expect(generatedRows).toHaveLength(4); + + const merged = mergeGeneratedRows({ + existingRows: [{ resolution: '480P', duration: 5, price: 0.05, source: 'manual' }], + generatedRows, + strategy: 'preserve_manual', + }); + expect(merged).toHaveLength(5); + expect(merged[0].source).toBe('manual'); + }); + + test('csv import and export', () => { + const dimensions = [{ key: 'resolution', type: 'string' }]; + const parsed = importPricingCsv('resolution,price,source\n720P,0.1,manual', dimensions); + expect(parsed.errors).toHaveLength(0); + expect(parsed.rows[0].price).toBe(0.1); + expect(exportPricingCsv(parsed.rows, dimensions)).toContain('720P,0.1,manual'); + }); + + test('preview exact row, wildcard row, and fallbacks', () => { + const config = createDefaultConfig(); + config.table = [ + { resolution: '720P', price: 0.1, source: 'manual' }, + { resolution: '*', price: 0.2, source: 'manual' }, + ]; + expect(previewLookup(config, { resolution: '720P' }).price).toBe(0.1); + expect(previewLookup(config, { resolution: '1080P' }).price).toBe(0.2); + + config.table = [{ resolution: '720P', price: 0.1, source: 'manual' }]; + config.fallback = { strategy: 'reject' }; + expect(previewLookup(config, { resolution: '1080P' }).mode).toBe('reject'); + + config.fallback = { strategy: 'max' }; + expect(previewLookup(config, { resolution: '1080P' }).price).toBe(0.1); + + config.fallback = { strategy: 'default', default_price: 0.3 }; + expect(previewLookup(config, { resolution: '1080P' }).price).toBe(0.3); + }); +}); From b36edf7f430a139502c2846b21559fc7704a574c Mon Sep 17 00:00:00 2001 From: fengsilin Date: Wed, 17 Jun 2026 19:08:54 +0800 Subject: [PATCH 10/35] feat(ui): add model multidimensional pricing editor --- .../components/settings/OperationSetting.jsx | 2 + web/src/i18n/locales/en.json | 18 +- web/src/i18n/locales/zh-CN.json | 17 +- .../ModelPricing/DimensionEditor.jsx | 91 +++++++++ .../Setting/Operation/ModelPricing/Editor.jsx | 174 ++++++++++++++++++ .../Operation/ModelPricing/GeneratorModal.jsx | 61 ++++++ .../Operation/ModelPricing/PreviewPanel.jsx | 36 ++++ .../Operation/ModelPricing/PricingGrid.jsx | 66 +++++++ .../Setting/Operation/ModelPricing/index.jsx | 138 ++++++++++++++ 9 files changed, 599 insertions(+), 4 deletions(-) create mode 100644 web/src/pages/Setting/Operation/ModelPricing/DimensionEditor.jsx create mode 100644 web/src/pages/Setting/Operation/ModelPricing/Editor.jsx create mode 100644 web/src/pages/Setting/Operation/ModelPricing/GeneratorModal.jsx create mode 100644 web/src/pages/Setting/Operation/ModelPricing/PreviewPanel.jsx create mode 100644 web/src/pages/Setting/Operation/ModelPricing/PricingGrid.jsx create mode 100644 web/src/pages/Setting/Operation/ModelPricing/index.jsx diff --git a/web/src/components/settings/OperationSetting.jsx b/web/src/components/settings/OperationSetting.jsx index ef0e427..a836c55 100644 --- a/web/src/components/settings/OperationSetting.jsx +++ b/web/src/components/settings/OperationSetting.jsx @@ -30,6 +30,7 @@ import SettingsCreditLimit from '../../pages/Setting/Operation/SettingsCreditLim import SettingsCheckin from '../../pages/Setting/Operation/SettingsCheckin'; import SettingsRegionSync from '../../pages/Setting/Operation/SettingsRegionSync'; import SettingsPlayground from '../../pages/Setting/Operation/SettingsPlayground'; +import ModelPricing from '../../pages/Setting/Operation/ModelPricing'; import { API, showError, toBoolean } from '../../helpers'; const OperationSetting = () => { @@ -189,6 +190,7 @@ const OperationSetting = () => { + ); diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index 28a8fe5..9cbda58 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -3112,14 +3112,26 @@ ",点击更新": ", click Update", ",音频 {{symbol}}{{price}} / 1M tokens": ", Audio {{symbol}}{{price}} / 1M tokens", "???????????????": "Advanced ratios (leave empty to use global defaults)", - "??????": "Audio output ratio", + "??????": "Apply generated rows", "??????1.0?": "Global default (1.0)", "?????????????0.5 = ????? token ???????????????????? 1.0": "Multiplier applied to input pricing when cache is hit. 0.5 means cached tokens are billed at half the input price. Leave empty to use the global default 1.0.", "???????5 ???": "Cache creation ratio (5 minutes)", "??????1.25?": "Global default (1.25)", "????? token ???? ? ????????? 1.25?5 ?? TTL???? 1 ????? 1.6x ????????????": "Tokens written into cache are billed as this ratio ? input price. Default is 1.25 for a 5-minute TTL; cache creation over 1 hour is billed automatically at 1.6x. Leave empty to use the global default.", - "????": "Image ratio", - "?????": "Global default" + "????": "Final price", + "?????": "Preserve manual rows", + "??????????": "Failed to save model multidimensional pricing", + "?????????": "Model multidimensional pricing saved", + "???": "Default value", + "Legacy ??": "Legacy price", + "??????? Legacy ???{{price}}": "Deleting this rule falls back to Legacy price: {{price}}", + "????????": "Add model multidimensional pricing", + "??? tokens": "Per 1M tokens", + "??": "Refresh", + "?? JSON": "Apply JSON", + "??? {{count}} ?????{{sample}}": "Will generate {{count}} rows. Sample: {{sample}}", + "JSON ????": "JSON format error", + "Key": "Key" }, "邮箱后缀": "Email Suffix", "邮箱后缀必须以 @ 开头": "Email suffix must start with @", diff --git a/web/src/i18n/locales/zh-CN.json b/web/src/i18n/locales/zh-CN.json index 5938369..058d04b 100644 --- a/web/src/i18n/locales/zh-CN.json +++ b/web/src/i18n/locales/zh-CN.json @@ -3087,7 +3087,22 @@ ",当前无生效订阅,将自动使用钱包": ",当前无生效订阅,将自动使用钱包", ",时间:": ",时间:", ",点击更新": ",点击更新", - ",音频 {{symbol}}{{price}} / 1M tokens": ",音频 {{symbol}}{{price}} / 1M tokens" + ",音频 {{symbol}}{{price}} / 1M tokens": ",音频 {{symbol}}{{price}} / 1M tokens", + "??????????": "??????????", + "?????????": "?????????", + "??????": "??????", + "???": "???", + "Legacy ??": "Legacy ??", + "????": "????", + "??????? Legacy ???{{price}}": "??????? Legacy ???{{price}}", + "????????": "????????", + "??? tokens": "??? tokens", + "??": "??", + "?????": "?????", + "?? JSON": "?? JSON", + "??? {{count}} ?????{{sample}}": "??? {{count}} ?????{{sample}}", + "JSON ????": "JSON ????", + "Key": "Key" }, "邮箱后缀": "邮箱后缀", "邮箱后缀必须以 @ 开头": "邮箱后缀必须以 @ 开头", diff --git a/web/src/pages/Setting/Operation/ModelPricing/DimensionEditor.jsx b/web/src/pages/Setting/Operation/ModelPricing/DimensionEditor.jsx new file mode 100644 index 0000000..9e863a5 --- /dev/null +++ b/web/src/pages/Setting/Operation/ModelPricing/DimensionEditor.jsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { Button, Checkbox, Input, Select, Space, Table } from '@douyinfe/semi-ui'; +import { Plus, Trash2 } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { parseDimensionValue } from './pricingConfig'; + +const SOURCE_OPTIONS = ['request.resolution', 'request.duration', 'request.options.size', 'derived.input_mode', 'derived.megapixels', 'derived.aspect_ratio']; + +export default function DimensionEditor({ dimensions, onChange }) { + const { t } = useTranslation(); + + const update = (index, patch) => { + const next = [...dimensions]; + next[index] = { ...next[index], ...patch }; + onChange(next); + }; + + const remove = (index) => onChange(dimensions.filter((_, itemIndex) => itemIndex !== index)); + const add = () => onChange([...dimensions, { key: 'duration', source: 'request.duration', type: 'number', default: null }]); + + const columns = [ + { title: t('Key'), dataIndex: 'key', render: (value, _, index) => update(index, { key })} /> }, + { + title: t('来源'), + dataIndex: 'source', + render: (value, _, index) => ( + + ), + }, + { + title: t('类型'), + dataIndex: 'type', + width: 130, + render: (value, _, index) => ( + + ), + }, + { + title: t('可选'), + dataIndex: 'optional', + width: 80, + render: (value, row, index) => ( + update(index, { optional: event.target.checked, default: event.target.checked ? defaultForType(row.type) : null })} + /> + ), + }, + { + title: t('默认值'), + dataIndex: 'default', + render: (value, row, index) => ( + update(index, { default: text === '' ? null : parseDimensionValue(text, row.type) })} + /> + ), + }, + { + title: t('操作'), + width: 80, + render: (_, __, index) =>