| @@ -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. | |||
| @@ -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 { | |||
| @@ -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) | |||
| } | |||
| @@ -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) | |||
| 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 { | |||
| @@ -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)。 | |||