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)。