| @@ -155,6 +155,12 @@ type RelayInfo struct { | |||||
| PriceData types.PriceData | PriceData types.PriceData | ||||
| PricingConfigSnapshot *types.PricingConfig | |||||
| PricingConfigSnapshotLoaded bool | |||||
| PricingDecisionFrozen *types.PricingDecision | |||||
| OriginPricing *types.OriginPricingSnapshot | |||||
| RequireMatrixUsageBilling bool | |||||
| Request dto.Request | Request dto.Request | ||||
| // RequestConversionChain records request format conversions in order, e.g. | // RequestConversionChain records request format conversions in order, e.g. | ||||
| @@ -2,8 +2,10 @@ package helper | |||||
| import ( | import ( | ||||
| "fmt" | "fmt" | ||||
| "net/http" | |||||
| "github.com/QuantumNous/new-api/common" | "github.com/QuantumNous/new-api/common" | ||||
| "github.com/QuantumNous/new-api/dto" | |||||
| "github.com/QuantumNous/new-api/logger" | "github.com/QuantumNous/new-api/logger" | ||||
| relaycommon "github.com/QuantumNous/new-api/relay/common" | relaycommon "github.com/QuantumNous/new-api/relay/common" | ||||
| "github.com/QuantumNous/new-api/setting/operation_setting" | "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) | // 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) | 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) | modelPrice, success := ratio_setting.GetModelPrice(info.OriginModelName, true) | ||||
| if !success { | if !success { | ||||
| defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[info.OriginModelName] | defaultPrice, ok := ratio_setting.GetDefaultModelPriceMap()[info.OriginModelName] | ||||
| @@ -147,12 +197,26 @@ func ModelPriceHelperPerCall(c *gin.Context, info *relaycommon.RelayInfo) types. | |||||
| } | } | ||||
| priceData := types.PriceData{ | 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 { | func ContainPriceOrRatio(modelName string) bool { | ||||
| @@ -116,7 +116,6 @@ func TestGlobalRatiosFilledWhenUseRatioMode(t *testing.T) { | |||||
| assert.Equal(t, 2.0, priceData.ImageRatio) | assert.Equal(t, 2.0, priceData.ImageRatio) | ||||
| } | } | ||||
| func TestHandleGroupRatio_DefaultGroup(t *testing.T) { | func TestHandleGroupRatio_DefaultGroup(t *testing.T) { | ||||
| setupUsePriceSwitchTest(t) | setupUsePriceSwitchTest(t) | ||||
| c := buildTestContext(t) | c := buildTestContext(t) | ||||
| @@ -305,3 +304,62 @@ func TestModelPriceHelper_FreeGroup(t *testing.T) { | |||||
| // cleanup | // cleanup | ||||
| require.NoError(t, ratio_setting.UpdateGroupRatioByJSONString(`{"default":1,"vip":1,"svip":1}`)) | 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) | |||||
| } | |||||
| @@ -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, | |||||
| } | |||||
| } | |||||
| @@ -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}, | |||||
| } | |||||
| } | |||||
| @@ -193,7 +193,13 @@ func RelaySwapFace(c *gin.Context, info *relaycommon.RelayInfo) *dto.MidjourneyR | |||||
| } | } | ||||
| modelName := service.CovertMjpActionToModelName(constant.MjActionSwapFace) | 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) | userQuota, err := model.GetUserQuota(info.UserId, false) | ||||
| if err != nil { | if err != nil { | ||||
| @@ -494,7 +500,13 @@ func RelayMidjourneySubmit(c *gin.Context, relayInfo *relaycommon.RelayInfo) *dt | |||||
| modelName := service.CovertMjpActionToModelName(midjRequest.Action) | 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) | userQuota, err := model.GetUserQuota(relayInfo.UserId, false) | ||||
| if err != nil { | if err != nil { | ||||
| @@ -176,7 +176,11 @@ func RelayTaskSubmit(c *gin.Context, info *relaycommon.RelayInfo) (*TaskSubmitRe | |||||
| // 4. 价格计算:基础模型价格 | // 4. 价格计算:基础模型价格 | ||||
| info.OriginModelName = modelName | 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(时长、分辨率等) | // 5. 计费估算:让适配器根据用户请求提供 OtherRatios(时长、分辨率等) | ||||
| // 必须在 ModelPriceHelperPerCall 之后调用(它会重建 PriceData)。 | // 必须在 ModelPriceHelperPerCall 之后调用(它会重建 PriceData)。 | ||||